#!/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 --user --pass `, fetches the URL with HTTP Digest auth, parses the XML response as a list of `` elements (each with ``, ``, ``), 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) 1 Camera-Front 10.0.0.50 2 Camera-Back 10.0.0.51 ### 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), 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 }` 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` 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, } #[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 { from_str(xml) } #[cfg(test)] mod tests { use super::*; #[test] fn parse_two_devices() { let xml = r#" 1Camera-Front10.0.0.50 2Camera-Back10.0.0.51 "#; 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> { 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 --user --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 = ( '\n' '\n' ' 1Camera-Front10.0.0.50\n' ' 2Camera-Back10.0.0.51\n' '' ) 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)