6ad897477d988ba75c2bf78f4acc0949132901e0 / bench/tasks/multitask/rust_cli.py · 14551 bytes · raw
#!/usr/bin/env python3
"""
Task: rust_cli — a Rust CLI with reqwest + quick-xml + async.
Models a Rust CLI with reqwest + quick-xml + async in miniature: a Rust CLI
that fetches XML from an HTTP endpoint with Digest auth, parses it with
serde/quick-xml, and prints a structured result. Async (tokio), reqwest
(rustls), quick-xml for parsing. The "real product" = a binary that hits a
mock HTTP server the harness spins up (serves a fixed XML doc behind Digest
auth), fetches it, parses the device list, and prints one device per line.
"""
import os, sys, subprocess, time, shutil, signal, socket, urllib.request, urllib.parse, json, http.server, threading, base64
from bench.core import tools
HERE = os.path.dirname(os.path.abspath(__file__))
TASK_NAME = "rust_cli"
PLAN = """\
# Task — Build a Rust CLI that fetches + parses XML over HTTP with Digest auth
## Goal
`cargo build` and `cargo test` run green, and the resulting binary fetches an
XML device list from a URL (with HTTP Digest auth), parses it with serde +
quick-xml, and prints one device per line as `id|name|ip`. A Rust CLI with
reqwest + quick-xml + async: Rust + reqwest (rustls) + quick-XML + async
(tokio) + a native binary that talks to an HTTP endpoint.
## Prerequisites
Starter scaffold in the work dir:
- `Cargo.toml` (package `device-list`, edition 2021, no deps)
- `src/main.rs` (empty stub: `fn main() {}`)
## Spec — the app to build
A Rust CLI that takes `--url <URL> --user <user> --pass <pass>`, fetches the
URL with HTTP Digest auth, parses the XML response as a list of `<Device>`
elements (each with `<ID>`, `<Name>`, `<IP>`), and prints each as
`id|name|ip` on its own line. Async via tokio; HTTP via reqwest (rustls, no
default features); XML via quick-xml + serde.
### XML format the server returns (Content-Type: application/xml)
<?xml version="1.0" encoding="UTF-8"?>
<DeviceList>
<Device>
<ID>1</ID>
<Name>Camera-Front</Name>
<IP>10.0.0.50</IP>
</Device>
<Device>
<ID>2</ID>
<Name>Camera-Back</Name>
<IP>10.0.0.51</IP>
</Device>
</DeviceList>
### Files to produce
Cargo.toml [package] name="device-list", edition="2021"; [dependencies] tokio (rt-multi-thread + macros), reqwest (rustls, no default features, charset), quick-xml (serde), serde (derive), serde_json (optional, for debug). Binary at src/main.rs.
src/main.rs arg parsing (manual or std::env::args -- no clap needed), the async fetch (reqwest with Digest auth), XML parse (quick-xml de::from_str into a Vec<Device>), print `id|name|ip` per line. Use `reqwest::Client` with `.digest()` (the `digest_auth` or reqwest's digest support — use the `digest-auth` feature or handle the 401→retry manually). The simplest path: reqwest doesn't have built-in digest, so use the `digest_auth` crate OR handle the two-step (GET → 401 with WWW-Authenticate → compute response → GET with Authorization) manually. For this bench, either is fine — the mock server's Digest challenge is standard RFC 7616.
src/parser.rs the XML types: `#[derive(Debug, Deserialize)] struct DeviceList { #[serde(rename = "Device")] devices: Vec<Device> }` and `#[derive(Debug, Deserialize)] struct Device { #[serde(rename = "ID")] id: String, #[serde(rename = "Name")] name: String, #[serde(rename = "IP")] ip: String }`, plus a `parse_devices(xml: &str) -> Result<DeviceList, quick_xml::DeError>` function.
src/parser.rs (same file) a #[test] that parses a sample XML string and asserts 2 devices with the right fields.
src/main.rs (same file, in a #[cfg(test)] mod) an integration-ish test that spins up an in-process mock HTTP server (use `std::net::TcpListener` + a tiny thread that writes a raw HTTP response — no extra dep needed), fetches from it, and asserts the parsed output. Keep it simple: the mock can return 200 + the XML directly (skip Digest in the test if it complicates things — the bench's scoring gate handles the Digest path against the harness's mock).
### Constraints
- Use `reqwest` with `default-features = false, features = ["rustls", "charset"]`. No OpenSSL.
- Use `quick-xml` with `serde` for parsing (the `serde` feature on quick-xml).
- Use `tokio` with `features = ["rt-multi-thread", "macros"]` for the async runtime.
- For Digest auth: the simplest approach is to use the `digest_auth` crate (add it as a dep) to compute the response from the 401's WWW-Authenticate header, then retry with the Authorization header. OR if that's too much, just do Basic auth for the fetch and note Digest as a TODO — the scoring harness's mock accepts Basic too for this bench. **BUT** the plan's intent is Digest; try it.
- Arg parsing: manual (std::env::args) is fine — no need for clap.
- The binary must exit 0 on success, non-zero on error.
## Steps
1. Write Cargo.toml with the deps.
2. Write src/parser.rs (the XML types + parse function + a unit test).
3. Write src/main.rs (arg parsing, async fetch, parse, print).
4. `cargo build` — fix compile errors (expect some Rust borrow-checker friction; that's normal).
5. `cargo test` — the parser unit test + the integration test must pass.
6. Optionally test the binary against a real URL (the harness will test it against a mock).
## Acceptance (harness-verified)
1. `cargo build` exits 0.
2. `cargo test` exits 0.
3. The binary, pointed at the harness's mock HTTP server (serves the XML above behind Digest auth at a URL the harness provides), fetches it and prints `1|Camera-Front|10.0.0.50` and `2|Camera-Back|10.0.0.51` on separate lines.
## Out of scope
Tauri (no GUI), keyring (no OS keychain), ONVIF, ISAPI-specific endpoints, batch operations, config files. This is the smallest real Rust CLI with reqwest + quick-xml + async — a working fetch+parse CLI.
## Commit
You do not commit; the harness owns the work dir.
"""
STARTER = {
"Cargo.toml": '''[package]
name = "device-list"
version = "0.1.0"
edition = "2021"
[dependencies]
''',
"src/main.rs": "fn main() {}\n",
}
REFERENCE = {
"Cargo.toml": '''[package]
name = "device-list"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
reqwest = { version = "0.13", default-features = false, features = ["rustls", "charset"] }
quick-xml = { version = "0.36", features = ["serialize"] }
serde = { version = "1", features = ["derive"] }
digest_auth = "0.3"
''',
"src/parser.rs": '''use serde::Deserialize;
use quick_xml::de::from_str;
#[derive(Debug, Deserialize)]
pub struct DeviceList {
#[serde(rename = "Device")]
pub devices: Vec<Device>,
}
#[derive(Debug, Deserialize)]
pub struct Device {
#[serde(rename = "ID")]
pub id: String,
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "IP")]
pub ip: String,
}
pub fn parse_devices(xml: &str) -> Result<DeviceList, quick_xml::de::DeError> {
from_str(xml)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_two_devices() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<DeviceList>
<Device><ID>1</ID><Name>Camera-Front</Name><IP>10.0.0.50</IP></Device>
<Device><ID>2</ID><Name>Camera-Back</Name><IP>10.0.0.51</IP></Device>
</DeviceList>"#;
let list = parse_devices(xml).unwrap();
assert_eq!(list.devices.len(), 2);
assert_eq!(list.devices[0].id, "1");
assert_eq!(list.devices[0].name, "Camera-Front");
assert_eq!(list.devices[0].ip, "10.0.0.50");
assert_eq!(list.devices[1].name, "Camera-Back");
}
}
''',
"src/main.rs": '''mod parser;
use std::env;
use std::process;
fn parse_args() -> (String, String, String) {
let mut url = String::new();
let mut user = String::new();
let mut pass = String::new();
let mut args = env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--url" => { url = args.next().unwrap_or_default(); }
"--user" => { user = args.next().unwrap_or_default(); }
"--pass" => { pass = args.next().unwrap_or_default(); }
_ => {}
}
}
(url, user, pass)
}
async fn fetch_with_digest(url: &str, user: &str, pass: &str) -> Result<String, Box<dyn std::error::Error>> {
let client = reqwest::Client::builder()
.build()?;
// First request — get the 401 + WWW-Authenticate.
let resp = client.get(url).send().await?;
if resp.status() == 401 {
let www_auth = resp.headers().get("www-authenticate")
.and_then(|v| v.to_str().ok())
.ok_or("no WWW-Authenticate header")?;
// Parse the challenge + compute the digest response.
let mut prompt = digest_auth::parse(www_auth)?;
let ctx = digest_auth::AuthContext::new(user, pass, url);
let auth_header = prompt.respond(&ctx)?;
let resp2 = client.get(url)
.header("Authorization", auth_header.to_header_string())
.send().await?;
let body = resp2.text().await?;
Ok(body)
} else {
let body = resp.text().await?;
Ok(body)
}
}
#[tokio::main]
async fn main() {
let (url, user, pass) = parse_args();
if url.is_empty() {
eprintln!("usage: device-list --url <URL> --user <user> --pass <pass>");
process::exit(1);
}
let xml = match fetch_with_digest(&url, &user, &pass).await {
Ok(x) => x,
Err(e) => { eprintln!("fetch error: {e}"); process::exit(1); }
};
let list = match parser::parse_devices(&xml) {
Ok(l) => l,
Err(e) => { eprintln!("parse error: {e}"); process::exit(1); }
};
for d in list.devices {
println!("{}|{}|{}", d.id, d.name, d.ip);
}
}
''',
}
def _materialize(files, dest):
shutil.rmtree(dest, ignore_errors=True)
os.makedirs(dest, exist_ok=True)
for path, content in files.items():
fp = os.path.join(dest, path)
os.makedirs(os.path.dirname(fp), exist_ok=True)
with open(fp, "w") as f: f.write(content)
return dest
def _run(cmd, cwd, timeout=300):
try:
p = subprocess.run(cmd, cwd=cwd, shell=True, capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
return "TIMEOUT"
return f"exit={p.returncode}\n{(p.stdout + p.stderr).strip()}"
def _has_real_app(work_dir):
main_rs = os.path.join(work_dir, "src", "main.rs")
if not os.path.isfile(main_rs): return False
with open(main_rs) as f:
src = f.read()
return "tokio" in src and "reqwest" in src and "parse_devices" in src
class _DigestMockServer:
"""A tiny HTTP server that serves the XML behind Digest auth."""
XML_BODY = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<DeviceList>\n'
' <Device><ID>1</ID><Name>Camera-Front</Name><IP>10.0.0.50</IP></Device>\n'
' <Device><ID>2</ID><Name>Camera-Back</Name><IP>10.0.0.51</IP></Device>\n'
'</DeviceList>'
)
def __init__(self):
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.bind(("127.0.0.1", 0))
self.port = self.sock.getsockname()[1]
self.sock.listen(4)
self.thread = threading.Thread(target=self._serve, daemon=True)
self.running = True
def start(self): self.thread.start()
def stop(self): self.running = False; self.sock.close()
def _serve(self):
while self.running:
try:
conn, _ = self.sock.accept()
except OSError:
break
try:
data = conn.recv(4096).decode(errors="replace")
# If no Authorization header → 401 with Digest challenge.
if "Authorization:" not in data and "authorization:" not in data:
resp = (
"HTTP/1.1 401 Unauthorized\r\n"
'WWW-Authenticate: Digest realm="test", qop="auth", nonce="abc123", opaque=""\r\n'
"Content-Length: 0\r\n"
"\r\n"
)
conn.sendall(resp.encode())
else:
body = self.XML_BODY.encode()
resp = (
"HTTP/1.1 200 OK\r\n"
"Content-Type: application/xml\r\n"
f"Content-Length: {len(body)}\r\n"
"\r\n"
)
conn.sendall(resp.encode() + body)
except Exception:
pass
finally:
conn.close()
def gates(work_dir):
g = {}
if not _has_real_app(work_dir):
return {"build": False, "test": False, "fetches": False}
b = _run("cargo build", work_dir, timeout=600)
g["build"] = b.startswith("exit=0")
t = _run("cargo test", work_dir, timeout=600)
g["test"] = t.startswith("exit=0")
# fetches gate: run the binary against the mock server
binpath = os.path.join(work_dir, "target", "debug", "device-list")
if not os.path.isfile(binpath):
g["fetches"] = False; return g
srv = _DigestMockServer(); srv.start()
try:
url = f"http://127.0.0.1:{srv.port}/"
try:
p = subprocess.run([binpath, "--url", url, "--user", "admin", "--pass", "secret"],
capture_output=True, text=True, timeout=30)
out = p.stdout.strip()
g["fetches"] = ("1|Camera-Front|10.0.0.50" in out and
"2|Camera-Back|10.0.0.51" in out)
if not g["fetches"]:
g["_fetch_detail"] = f"exit={p.returncode} stdout={out[:200]} stderr={p.stderr[:200]}"
except Exception as e:
g["fetches"] = False
g["_fetch_detail"] = repr(e)
finally:
srv.stop()
return g
def validate(work_dir):
_materialize(REFERENCE, dest=work_dir)
print("cargo build...")
b = _run("cargo build", work_dir, timeout=600)
print(f"build: {'OK' if b.startswith('exit=0') else 'FAIL'}\n{b[-400:]}")
print("cargo test...")
t = _run("cargo test", work_dir, timeout=600)
print(f"test: {'OK' if t.startswith('exit=0') else 'FAIL'}\n{t[-400:]}")
g = gates(work_dir)
print(f"gates: {g}")
ok = all(v for k, v in g.items() if not k.startswith("_"))
print(f"-> {'OK' if ok else 'BAD'}")
return ok
if __name__ == "__main__":
wd = os.path.join(HERE, "..", "..", "work", "v2hard_validate_rust_cli")
validate(wd)