diff --git a/Cargo.lock b/Cargo.lock index b7050bc6c..cbf075258 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2630,7 +2630,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" dependencies = [ "data-encoding", - "syn 2.0.117", + "syn 1.0.109", ] [[package]] @@ -3002,7 +3002,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3542,9 +3542,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" dependencies = [ "atomic-waker", "bytes", @@ -3863,7 +3863,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -5431,7 +5431,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -5468,7 +5468,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", "windows-sys 0.52.0", ] @@ -5900,7 +5900,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6794,7 +6794,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/crates/gl/src/whoami.rs b/crates/gl/src/whoami.rs index 66c1438c5..6807aa167 100644 --- a/crates/gl/src/whoami.rs +++ b/crates/gl/src/whoami.rs @@ -1,11 +1,11 @@ //! `gl whoami` — print current identity and optional node registration info. -use anyhow::Result; +use anyhow::{bail, Result}; use clap::Args; use serde_json::{json, Value}; use std::path::PathBuf; -use crate::http::NodeClient; +use crate::http::{read_body_capped, sanitize_node_msg, NodeClient}; use crate::identity::load_keypair_from_dir; #[derive(Args)] @@ -22,6 +22,10 @@ pub struct WhoamiArgs { } pub async fn run(args: WhoamiArgs) -> Result<()> { + run_to_writer(args, &mut std::io::stdout()).await +} + +pub(crate) async fn run_to_writer(args: WhoamiArgs, w: &mut impl std::io::Write) -> Result<()> { let keypair = load_keypair_from_dir(args.dir.as_deref())?; let did = keypair.did().to_string(); let short = did.split(':').next_back().unwrap_or(&did).to_string(); @@ -41,7 +45,7 @@ pub async fn run(args: WhoamiArgs) -> Result<()> { if let Some(caps) = info["capabilities"].as_array() { capabilities = caps .iter() - .filter_map(|c| c.as_str().map(String::from)) + .filter_map(|c| c.as_str().map(sanitize_node_msg)) .collect(); } // Try to get repo count @@ -51,11 +55,35 @@ pub async fn run(args: WhoamiArgs) -> Result<()> { } } } - Ok(_) => { + Ok(resp) if resp.status().as_u16() == 404 => { registered = Some(false); } - Err(_) => { - registered = Some(false); + Ok(resp) => { + let status = resp.status(); + let raw = read_body_capped(resp, 8 * 1024).await; + let msg = serde_json::from_str::(&raw) + .ok() + .and_then(|v| { + let non_empty = |m: Option<&Value>| { + m.and_then(|m| m.as_str()) + .map(String::from) + .filter(|s| !s.is_empty()) + }; + non_empty(v.get("message")).or_else(|| non_empty(v.get("error"))) + }) + .unwrap_or(raw); + bail!( + "agent lookup failed ({status}): {}", + sanitize_node_msg(&msg) + ); + } + Err(e) => { + let detail: String = e + .chain() + .map(|e| sanitize_node_msg(&e.to_string())) + .collect::>() + .join(": "); + bail!("agent lookup failed: {detail}"); } } } @@ -77,21 +105,21 @@ pub async fn run(args: WhoamiArgs) -> Result<()> { if let Some(rc) = repo_count { out["repos"] = json!(rc); } - println!("{}", serde_json::to_string_pretty(&out)?); + writeln!(w, "{}", serde_json::to_string_pretty(&out)?)?; } else { - println!("DID: {did}"); - println!("Short: {short}"); + writeln!(w, "DID: {did}")?; + writeln!(w, "Short: {short}")?; if let Some(reg) = registered { - println!("Registered: {}", if reg { "yes" } else { "no" }); + writeln!(w, "Registered: {}", if reg { "yes" } else { "no" })?; } if let Some(ts) = trust_score { - println!("Trust: {ts:.2}"); + writeln!(w, "Trust: {ts:.2}")?; } if !capabilities.is_empty() { - println!("Caps: {}", capabilities.join(", ")); + writeln!(w, "Caps: {}", capabilities.join(", "))?; } if let Some(rc) = repo_count { - println!("Repos: {rc}"); + writeln!(w, "Repos: {rc}")?; } } Ok(()) @@ -192,7 +220,236 @@ mod tests { node: Some(server.url()), json: false, }; - run(args).await.unwrap(); + let mut out = Vec::new(); + run_to_writer(args, &mut out).await.unwrap(); + let out = String::from_utf8(out).unwrap(); + assert!(out.contains("Registered: no"), "unexpected output: {out}"); + + let args = WhoamiArgs { + dir: Some(dir.path().to_path_buf()), + node: Some(server.url()), + json: true, + }; + let mut out = Vec::new(); + run_to_writer(args, &mut out).await.unwrap(); + let out = String::from_utf8(out).unwrap(); + assert!( + out.contains("\"registered\": false"), + "unexpected output: {out}" + ); + } + + #[tokio::test] + async fn test_whoami_with_node_forbidden() { + let dir = TempDir::new().unwrap(); + let kp = gitlawb_core::identity::Keypair::generate(); + let pem = kp.to_pem().unwrap(); + std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap(); + let did = kp.did().to_string(); + + let mut server = mockito::Server::new_async().await; + let _agent = server + .mock("GET", format!("/api/v1/agents/{did}").as_str()) + .with_status(403) + .with_header("content-type", "application/json") + .with_body(r#"{"message":"forbidden"}"#) + .create_async() + .await; + + let args = WhoamiArgs { + dir: Some(dir.path().to_path_buf()), + node: Some(server.url()), + json: false, + }; + let err = run(args).await.unwrap_err(); + let msg = format!("{err:?}"); + assert!(msg.contains("403"), "expected 403 error, got: {msg}"); + assert!( + msg.contains("forbidden"), + "expected 'forbidden' in error, got: {msg}" + ); + } + + #[tokio::test] + async fn test_whoami_with_node_server_error() { + let dir = TempDir::new().unwrap(); + let kp = gitlawb_core::identity::Keypair::generate(); + let pem = kp.to_pem().unwrap(); + std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap(); + let did = kp.did().to_string(); + + let mut server = mockito::Server::new_async().await; + let _agent = server + .mock("GET", format!("/api/v1/agents/{did}").as_str()) + .with_status(500) + .with_header("content-type", "application/json") + .with_body(r#"{"message":"internal error"}"#) + .create_async() + .await; + + let args = WhoamiArgs { + dir: Some(dir.path().to_path_buf()), + node: Some(server.url()), + json: false, + }; + let err = run(args).await.unwrap_err(); + let msg = format!("{err:?}"); + assert!(msg.contains("500"), "expected 500 error, got: {msg}"); + assert!( + msg.contains("internal error"), + "expected 'internal error' in error, got: {msg}" + ); + } + + #[tokio::test] + async fn test_whoami_server_error_unusable_message_uses_error() { + let dir = TempDir::new().unwrap(); + let kp = gitlawb_core::identity::Keypair::generate(); + let pem = kp.to_pem().unwrap(); + std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap(); + let did = kp.did().to_string(); + + let mut server = mockito::Server::new_async().await; + let _agent = server + .mock("GET", format!("/api/v1/agents/{did}").as_str()) + .with_status(500) + .with_header("content-type", "application/json") + .with_body(r#"{"message":"","error":"boom"}"#) + .create_async() + .await; + + let args = WhoamiArgs { + dir: Some(dir.path().to_path_buf()), + node: Some(server.url()), + json: false, + }; + let err = run(args).await.unwrap_err(); + let msg = format!("{err:?}"); + assert!( + msg.contains("boom"), + "expected 'boom' from error field, got: {msg}" + ); + } + + #[tokio::test] + async fn test_whoami_with_node_transport_error() { + let dir = TempDir::new().unwrap(); + let kp = gitlawb_core::identity::Keypair::generate(); + let pem = kp.to_pem().unwrap(); + std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap(); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let node = format!("http://{}", listener.local_addr().unwrap()); + drop(listener); + + let args = WhoamiArgs { + dir: Some(dir.path().to_path_buf()), + node: Some(node), + json: false, + }; + let err = run(args).await.unwrap_err(); + let msg = format!("{err:?}"); + assert!( + msg.contains("agent lookup failed"), + "expected transport error, got: {msg}" + ); + } + + #[tokio::test] + async fn test_whoami_transport_sanitizes_control_chars_in_node_url() { + let dir = TempDir::new().unwrap(); + let kp = gitlawb_core::identity::Keypair::generate(); + let pem = kp.to_pem().unwrap(); + std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap(); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + + let node = format!("http://127.0.0.1:{port}/\u{1b}]0;PWNED\u{7} \u{202e}gnitirw"); + let args = WhoamiArgs { + dir: Some(dir.path().to_path_buf()), + node: Some(node), + json: false, + }; + let err = run(args).await.unwrap_err(); + let msg = format!("{err:?}"); + assert!( + msg.contains("agent lookup failed"), + "expected transport error, got: {msg}" + ); + assert!(!msg.contains('\u{1b}'), "ESC leaked into output: {msg:?}"); + assert!(!msg.contains('\u{7}'), "BEL leaked into output: {msg:?}"); + assert!(!msg.contains('\u{202e}'), "RLO leaked into output: {msg:?}"); + } + + #[tokio::test] + async fn test_whoami_server_error_body_display_bounded() { + let dir = TempDir::new().unwrap(); + let kp = gitlawb_core::identity::Keypair::generate(); + let pem = kp.to_pem().unwrap(); + std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap(); + let did = kp.did().to_string(); + + let mut server = mockito::Server::new_async().await; + let _agent = server + .mock("GET", format!("/api/v1/agents/{did}").as_str()) + .with_status(502) + .with_header("content-type", "application/json") + .with_body("x".repeat(100_000)) + .create_async() + .await; + + let args = WhoamiArgs { + dir: Some(dir.path().to_path_buf()), + node: Some(server.url()), + json: false, + }; + let err = run(args).await.unwrap_err(); + let msg = format!("{err:?}"); + assert!( + msg.contains("502"), + "expected 502 error with bounded body, got: {msg}" + ); + let display = format!("{err}"); + assert!( + display.len() < 1000, + "error message too long ({} bytes) — body was not capped", + display.len() + ); + } + + #[tokio::test] + async fn test_whoami_server_error_sanitizes_controls() { + let dir = TempDir::new().unwrap(); + let kp = gitlawb_core::identity::Keypair::generate(); + let pem = kp.to_pem().unwrap(); + std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap(); + let did = kp.did().to_string(); + + let mut server = mockito::Server::new_async().await; + let _agent = server + .mock("GET", format!("/api/v1/agents/{did}").as_str()) + .with_status(500) + .with_header("content-type", "application/json") + .with_body("{\"message\":\"\\u001b[31mowned\\u0007\\u202eevil\"}") + .create_async() + .await; + + let args = WhoamiArgs { + dir: Some(dir.path().to_path_buf()), + node: Some(server.url()), + json: false, + }; + let err = run(args).await.unwrap_err(); + let msg = format!("{err:?}"); + assert!( + msg.contains("owned"), + "expected sanitized error body, got: {msg}" + ); + assert!(!msg.contains('\u{1b}'), "ESC control char leaked: {msg}"); + assert!(!msg.contains('\u{07}'), "BEL control char leaked: {msg}"); + assert!(!msg.contains('\u{202e}'), "RTL override leaked: {msg}"); } #[tokio::test] @@ -230,4 +487,67 @@ mod tests { }; run(args).await.unwrap(); } + + #[tokio::test] + async fn test_whoami_sanitizes_hostile_capabilities() { + let dir = TempDir::new().unwrap(); + let kp = gitlawb_core::identity::Keypair::generate(); + let pem = kp.to_pem().unwrap(); + std::fs::write(dir.path().join("identity.pem"), pem.as_bytes()).unwrap(); + let did = kp.did().to_string(); + let short = did.split(':').next_back().unwrap().to_string(); + + let mut server = mockito::Server::new_async().await; + let _agent = server + .mock("GET", format!("/api/v1/agents/{did}").as_str()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + "{\"trust_score\":0.5,\"capabilities\":[\"\\u001b]0;PWNED\\u0007repo:write\",\"\\u202egnitirw-tfel\"]}", + ) + .create_async() + .await; + let _repos = server + .mock( + "GET", + mockito::Matcher::Regex(format!(r"^/api/v1/repos\?owner={short}")), + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body("[]") + .create_async() + .await; + + // Human mode: no control bytes or bidi overrides reach the terminal + let mut buf = Vec::new(); + let args = WhoamiArgs { + dir: Some(dir.path().to_path_buf()), + node: Some(server.url()), + json: false, + }; + run_to_writer(args, &mut buf).await.unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert!(!out.contains('\u{1b}'), "ESC leaked in human mode: {out:?}"); + assert!(!out.contains('\u{07}'), "BEL leaked in human mode: {out:?}"); + assert!( + !out.contains('\u{202e}'), + "RLO leaked in human mode: {out:?}" + ); + assert!(out.contains("repo:write"), "benign text missing: {out:?}"); + assert!(out.contains("tfel"), "reversed text missing: {out:?}"); + + // JSON mode: serde escapes C0 but passes bidi — ensure no U+202E + let mut buf = Vec::new(); + let args = WhoamiArgs { + dir: Some(dir.path().to_path_buf()), + node: Some(server.url()), + json: true, + }; + run_to_writer(args, &mut buf).await.unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert!( + !out.contains('\u{202e}'), + "RLO leaked in JSON mode: {out:?}" + ); + } }