diff --git a/crates/gl/src/ipfs_cmd.rs b/crates/gl/src/ipfs_cmd.rs index b1b12f0..d4cb64f 100644 --- a/crates/gl/src/ipfs_cmd.rs +++ b/crates/gl/src/ipfs_cmd.rs @@ -3,6 +3,8 @@ //! Communicates with the gitlawb node to list pinned CIDs and retrieve git //! objects by their content-addressed CID. +use std::path::PathBuf; + use anyhow::{Context, Result}; use clap::{Args, Subcommand}; use serde_json::Value; @@ -21,6 +23,9 @@ pub enum IpfsCmd { List { #[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")] node: String, + /// Identity directory (default: ~/.gitlawb) + #[arg(long)] + dir: Option, }, /// Retrieve and display a git object from the node by its CIDv1 Get { @@ -33,19 +38,24 @@ pub enum IpfsCmd { pub async fn run(args: IpfsArgs) -> Result<()> { match args.cmd { - IpfsCmd::List { node } => cmd_list(node).await, + IpfsCmd::List { node, dir } => cmd_list(node, dir).await, IpfsCmd::Get { cid, node } => cmd_get(cid, node).await, } } -async fn cmd_list(node: String) -> Result<()> { - let client = NodeClient::new(&node, None); - let resp: Value = client - .get("/api/v1/ipfs/pins") - .await? - .json() - .await - .context("failed to parse pins response")?; +async fn cmd_list(node: String, dir: Option) -> Result<()> { + // #134 gates /api/v1/ipfs/pins behind auth: sign the request with the + // caller's identity. On no identity, propagate load_keypair_from_dir's + // error (it already names `gl identity new`) rather than a bare 401. + let keypair = crate::identity::load_keypair_from_dir(dir.as_deref())?; + let client = NodeClient::new(&node, Some(keypair)); + let resp = client.get_signed("/api/v1/ipfs/pins").await?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("node returned {status} for pins listing: {body}"); + } + let resp: Value = resp.json().await.context("failed to parse pins response")?; let pins = resp["pins"].as_array().cloned().unwrap_or_default(); let count = resp["count"].as_u64().unwrap_or(pins.len() as u64); @@ -108,3 +118,121 @@ async fn cmd_get(cid: String, node: String) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Seed a keypair into a temp dir the way `load_keypair_from_dir` expects, + /// then return the dir handle (keeps it alive for the test's duration). + fn seed_keystore() -> tempfile::TempDir { + let dir = tempfile::TempDir::new().unwrap(); + let kp = gitlawb_core::identity::Keypair::generate(); + std::fs::write( + dir.path().join("identity.pem"), + kp.to_pem().unwrap().as_bytes(), + ) + .unwrap(); + dir + } + + #[tokio::test] + async fn test_cmd_list_signs_request_and_renders_pins() { + let mut server = mockito::Server::new_async().await; + let keystore = seed_keystore(); + + // Happy path: signed GET to /api/v1/ipfs/pins carrying the RFC 9421 + // signature headers, node returns a populated pins body. + let m = server + .mock("GET", "/api/v1/ipfs/pins") + .match_header("signature", mockito::Matcher::Any) + .match_header("signature-input", mockito::Matcher::Any) + .match_header("content-digest", mockito::Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"pins":[{"cid":"bafyone","sha256_hex":"abc123","pinned_at":"2026-07-02T12:00:00.123456Z"}],"count":1}"#, + ) + .create_async() + .await; + + cmd_list(server.url(), Some(keystore.path().to_path_buf())) + .await + .unwrap(); + + m.assert_async().await; + } + + #[tokio::test] + async fn test_cmd_list_empty_pins() { + let mut server = mockito::Server::new_async().await; + let keystore = seed_keystore(); + + let m = server + .mock("GET", "/api/v1/ipfs/pins") + .match_header("signature", mockito::Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"pins":[],"count":0}"#) + .create_async() + .await; + + cmd_list(server.url(), Some(keystore.path().to_path_buf())) + .await + .unwrap(); + + m.assert_async().await; + } + + #[tokio::test] + async fn test_cmd_list_no_identity_errors_without_request() { + let mut server = mockito::Server::new_async().await; + // Empty keystore dir: no identity.pem present. + let empty = tempfile::TempDir::new().unwrap(); + + // The endpoint must never be hit when there is no identity. + let m = server + .mock("GET", "/api/v1/ipfs/pins") + .expect(0) + .create_async() + .await; + + let err = cmd_list(server.url(), Some(empty.path().to_path_buf())) + .await + .expect_err("no identity should be an error"); + assert!( + err.to_string().contains("gl identity new") + || err.to_string().contains("no identity found"), + "error should name `gl identity new`, got: {err}" + ); + + m.assert_async().await; + } + + #[tokio::test] + async fn test_cmd_list_non_success_status_is_error_not_empty() { + let mut server = mockito::Server::new_async().await; + let keystore = seed_keystore(); + + // A signed request the node rejects (401) must surface as an error, + // not be silently parsed into an empty pin list. + let m = server + .mock("GET", "/api/v1/ipfs/pins") + .match_header("signature", mockito::Matcher::Any) + .with_status(401) + .with_header("content-type", "application/json") + .with_body(r#"{"error":"unauthorized"}"#) + .create_async() + .await; + + let err = cmd_list(server.url(), Some(keystore.path().to_path_buf())) + .await + .expect_err("non-2xx status should be an error"); + assert!( + err.to_string().contains("401"), + "error should mention the status, got: {err}" + ); + + m.assert_async().await; + } +} diff --git a/crates/gl/src/node.rs b/crates/gl/src/node.rs index 9b3a6e1..a48eb3d 100644 --- a/crates/gl/src/node.rs +++ b/crates/gl/src/node.rs @@ -2,10 +2,12 @@ use anyhow::Result; use clap::{Args, Subcommand}; +use gitlawb_core::identity::Keypair; use serde_json::Value; use std::path::PathBuf; use crate::http::NodeClient; +use crate::identity::load_keypair_from_dir; use crate::node_stake; #[derive(Args)] @@ -20,6 +22,9 @@ pub enum NodeCmd { Status { #[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")] node: String, + /// Identity directory (default: ~/.gitlawb) + #[arg(long)] + dir: Option, }, /// Check trust score for a DID Trust { @@ -121,7 +126,7 @@ pub enum NodeCmd { pub async fn run(args: NodeArgs) -> Result<()> { match args.cmd { - NodeCmd::Status { node } => cmd_status(node).await, + NodeCmd::Status { node, dir } => cmd_status(node, dir).await, NodeCmd::Trust { did, node } => cmd_trust(did, node).await, NodeCmd::Resolve { did, node } => cmd_resolve(did, node).await, NodeCmd::Register { @@ -177,7 +182,101 @@ async fn try_get_json(client: &NodeClient, path: &str) -> Option { resp.json::().await.ok() } -async fn cmd_status(node: String) -> Result<()> { +/// Outcome of fetching the IPFS pins panel for `gl node status`. +/// +/// #134 gates `/api/v1/ipfs/pins` behind auth, so this panel signs its request +/// when an identity is available and otherwise reports that the caller must +/// sign in. A pins failure never aborts the dashboard. +#[derive(Debug)] +enum PinsPanel { + /// Signed read succeeded and returned pins (carries the resolved count). + Pins(u64), + /// Signed read succeeded but the node has no pins recorded. + Empty, + /// Signed read was rejected (401/other) or errored. + Unavailable, + /// No identity available; no request was issued. + NeedsIdentity, + /// An explicit `--dir` was given but held no usable identity; carries the dir + /// so the panel can name it instead of the misleading "sign in" prompt. + IdentityError(PathBuf), +} + +/// Identity resolved for the signed pins read, decided before the request so the +/// dashboard can distinguish "no identity at all" from "an explicit `--dir` that +/// could not be loaded". +enum PinsAuth { + /// A usable keypair; the read will be signed. + Keyed(Keypair), + /// No identity requested and none in the default keystore. + Anonymous, + /// An explicit `--dir` was given but held no usable identity (carries the dir). + DirUnusable(PathBuf), +} + +/// Resolve the pins-panel identity from an optional `--dir`. Mirrors `gl ipfs +/// list`'s identity selection: an explicit `--dir` that fails to load is reported +/// as `DirUnusable` (so the panel names the bad path) rather than collapsing to +/// the misleading "sign in to view"; a missing default keystore (no `--dir`) +/// degrades quietly to `Anonymous`. +fn resolve_pins_auth(dir: Option<&std::path::Path>) -> PinsAuth { + match load_keypair_from_dir(dir) { + Ok(kp) => PinsAuth::Keyed(kp), + Err(_) => match dir { + Some(d) => PinsAuth::DirUnusable(d.to_path_buf()), + None => PinsAuth::Anonymous, + }, + } +} + +/// Fetch the pins panel state. With a keypair, signs the `/api/v1/ipfs/pins` +/// read and maps the outcome; with no identity it returns `NeedsIdentity`, and +/// with an unusable explicit `--dir` it returns `IdentityError` — both without +/// issuing a request. Injectable (node URL + resolved auth) so tests drive it +/// with a mock server and never touch the default keystore. +async fn fetch_pins(node: &str, auth: PinsAuth) -> PinsPanel { + let kp = match auth { + PinsAuth::Keyed(kp) => kp, + PinsAuth::Anonymous => return PinsPanel::NeedsIdentity, + PinsAuth::DirUnusable(dir) => return PinsPanel::IdentityError(dir), + }; + let client = NodeClient::new(node, Some(kp)); + let resp = match client.get_signed("/api/v1/ipfs/pins").await { + Ok(r) => r, + Err(_) => return PinsPanel::Unavailable, + }; + if !resp.status().is_success() { + return PinsPanel::Unavailable; + } + let Ok(body) = resp.json::().await else { + return PinsPanel::Unavailable; + }; + let count = body["count"] + .as_u64() + .unwrap_or_else(|| body["pins"].as_array().map(|a| a.len() as u64).unwrap_or(0)); + if count == 0 { + PinsPanel::Empty + } else { + PinsPanel::Pins(count) + } +} + +/// Render the one-line pins-panel status for the `gl node status` dashboard. +fn pins_status_line(panel: &PinsPanel) -> String { + match panel { + PinsPanel::Pins(count) => format!(" Pinned CIDs: {count}"), + PinsPanel::Empty => " Pinned CIDs: 0".to_string(), + PinsPanel::Unavailable => " IPFS pins: unavailable".to_string(), + PinsPanel::NeedsIdentity => { + " IPFS pins: sign in to view (run `gl identity new`)".to_string() + } + PinsPanel::IdentityError(dir) => { + format!(" IPFS pins: no usable identity in {}", dir.display()) + } + } +} + +async fn cmd_status(node: String, dir: Option) -> Result<()> { let client = NodeClient::new(&node, None); // ── Fetch node info (required — bail if unreachable) ────────────────── @@ -194,13 +293,21 @@ async fn cmd_status(node: String) -> Result<()> { let version = info["version"].as_str().unwrap_or("unknown"); let network = info["network"].as_str().unwrap_or("unknown"); + // The pins panel signs its read (#134 gates /api/v1/ipfs/pins behind auth). + // `--dir` selects the same identity directory as `gl ipfs list` (#146); an + // explicit --dir that can't be loaded is surfaced in the panel rather than + // masquerading as "no identity", while a missing default keystore degrades + // quietly. A pins failure never aborts the dashboard. + let pins_auth = resolve_pins_auth(dir.as_deref()); + // ── Fetch remaining endpoints in parallel ───────────────────────────── - let (peers_val, repos_val, p2p_val, events_val, pins_val) = tokio::join!( + // Peers/repos/p2p/events stay anonymous; only pins is signed. + let (peers_val, repos_val, p2p_val, events_val, pins_panel) = tokio::join!( try_get_json(&client, "/api/v1/peers"), try_get_json(&client, "/api/v1/repos"), try_get_json(&client, "/api/v1/p2p/info"), try_get_json(&client, "/api/v1/events/ref-updates?limit=5"), - try_get_json(&client, "/api/v1/ipfs/pins"), + fetch_pins(&node, pins_auth), ); // ── Render dashboard ────────────────────────────────────────────────── @@ -307,14 +414,7 @@ async fn cmd_status(node: String) -> Result<()> { // Pins println!("Pins"); - if let Some(ref pins) = pins_val { - let count = pins["count"] - .as_u64() - .unwrap_or_else(|| pins["pins"].as_array().map(|a| a.len() as u64).unwrap_or(0)); - println!(" Pinned CIDs: {count}"); - } else { - println!(" IPFS not configured"); - } + println!("{}", pins_status_line(&pins_panel)); println!(); Ok(()) @@ -409,3 +509,251 @@ async fn cmd_resolve(did: String, node: String) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use gitlawb_core::identity::Keypair; + + #[tokio::test] + async fn test_fetch_pins_keyed_happy_signs_and_returns_pins() { + let mut server = mockito::Server::new_async().await; + let kp = Keypair::generate(); + + // A keyed fetch must sign the request (RFC 9421 headers) and, on a + // populated 200 body, land in the Pins state carrying the pins. + let m = server + .mock("GET", "/api/v1/ipfs/pins") + .match_header("signature", mockito::Matcher::Any) + .match_header("signature-input", mockito::Matcher::Any) + .match_header("content-digest", mockito::Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"pins":[{"cid":"bafyone","sha256_hex":"abc123","pinned_at":"2026-07-02T12:00:00Z"}],"count":1}"#, + ) + .create_async() + .await; + + let panel = fetch_pins(&server.url(), PinsAuth::Keyed(kp)).await; + match panel { + PinsPanel::Pins(count) => assert_eq!(count, 1), + other => panic!("expected Pins, got {other:?}"), + } + + m.assert_async().await; + } + + #[tokio::test] + async fn test_fetch_pins_keyed_empty_returns_empty() { + let mut server = mockito::Server::new_async().await; + let kp = Keypair::generate(); + + let m = server + .mock("GET", "/api/v1/ipfs/pins") + .match_header("signature", mockito::Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"pins":[],"count":0}"#) + .create_async() + .await; + + let panel = fetch_pins(&server.url(), PinsAuth::Keyed(kp)).await; + assert!( + matches!(panel, PinsPanel::Empty), + "expected Empty, got {panel:?}" + ); + + m.assert_async().await; + } + + #[tokio::test] + async fn test_fetch_pins_keyed_rejected_returns_unavailable() { + let mut server = mockito::Server::new_async().await; + let kp = Keypair::generate(); + + // Node rejects the signed read (401): the panel must degrade to + // Unavailable without panicking, so cmd_status still completes. + let m = server + .mock("GET", "/api/v1/ipfs/pins") + .match_header("signature", mockito::Matcher::Any) + .with_status(401) + .with_header("content-type", "application/json") + .with_body(r#"{"error":"unauthorized"}"#) + .create_async() + .await; + + let panel = fetch_pins(&server.url(), PinsAuth::Keyed(kp)).await; + assert!( + matches!(panel, PinsPanel::Unavailable), + "expected Unavailable, got {panel:?}" + ); + + m.assert_async().await; + } + + #[tokio::test] + async fn test_fetch_pins_unkeyed_needs_identity_without_request() { + let mut server = mockito::Server::new_async().await; + + // With no keypair the endpoint must never be hit. + let m = server + .mock("GET", "/api/v1/ipfs/pins") + .expect(0) + .create_async() + .await; + + let panel = fetch_pins(&server.url(), PinsAuth::Anonymous).await; + assert!( + matches!(panel, PinsPanel::NeedsIdentity), + "expected NeedsIdentity, got {panel:?}" + ); + + m.assert_async().await; + } + + #[tokio::test] + async fn test_fetch_pins_malformed_body_returns_unavailable() { + let mut server = mockito::Server::new_async().await; + let kp = Keypair::generate(); + + // 2xx but the body is not valid JSON: must degrade to Unavailable, + // never panic. + let m = server + .mock("GET", "/api/v1/ipfs/pins") + .match_header("signature", mockito::Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body("not json{{{") + .create_async() + .await; + + let panel = fetch_pins(&server.url(), PinsAuth::Keyed(kp)).await; + assert!( + matches!(panel, PinsPanel::Unavailable), + "malformed body -> Unavailable, got {panel:?}" + ); + + m.assert_async().await; + } + + #[tokio::test] + async fn test_fetch_pins_transport_error_returns_unavailable() { + // Bind then drop to obtain a definitely-closed port -> connection + // refused -> get_signed Err -> Unavailable (no panic). + let port = { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + l.local_addr().unwrap().port() + }; + let kp = Keypair::generate(); + + let panel = fetch_pins(&format!("http://127.0.0.1:{port}"), PinsAuth::Keyed(kp)).await; + assert!( + matches!(panel, PinsPanel::Unavailable), + "transport error -> Unavailable, got {panel:?}" + ); + } + + #[test] + fn test_pins_status_line_renders_each_state() { + assert_eq!(pins_status_line(&PinsPanel::Pins(3)), " Pinned CIDs: 3"); + assert_eq!(pins_status_line(&PinsPanel::Empty), " Pinned CIDs: 0"); + assert_eq!( + pins_status_line(&PinsPanel::Unavailable), + " IPFS pins: unavailable" + ); + assert_eq!( + pins_status_line(&PinsPanel::NeedsIdentity), + " IPFS pins: sign in to view (run `gl identity new`)" + ); + assert_eq!( + pins_status_line(&PinsPanel::IdentityError(PathBuf::from("/tmp/id"))), + " IPFS pins: no usable identity in /tmp/id" + ); + } + + // Adversarial follow-up to jatmn #146 P2: an explicit `--dir` that holds no + // usable identity must resolve to DirUnusable (which names the path), NOT the + // Anonymous "sign in to view / run `gl identity new`" case — otherwise a user + // who did supply an identity dir is misdirected to create one they may have. + #[test] + fn resolve_pins_auth_reports_explicit_bad_dir_as_unusable() { + let bad = PathBuf::from("/nonexistent/gl-id-xyz"); + match resolve_pins_auth(Some(&bad)) { + PinsAuth::DirUnusable(d) => assert_eq!(d, bad), + _ => panic!("explicit unusable --dir must be DirUnusable, not Anonymous"), + } + } + + // The success wiring for #146: an explicit --dir with a valid identity must + // load that key (dir -> load_keypair_from_dir -> Keyed), so the pins read is + // signed with the selected identity rather than the default keystore. + #[test] + fn resolve_pins_auth_loads_keyed_identity_from_explicit_dir() { + let dir = tempfile::TempDir::new().unwrap(); + let kp = Keypair::generate(); + std::fs::write( + dir.path().join("identity.pem"), + kp.to_pem().unwrap().as_bytes(), + ) + .unwrap(); + assert!( + matches!(resolve_pins_auth(Some(dir.path())), PinsAuth::Keyed(_)), + "an explicit --dir with a valid identity must resolve to Keyed" + ); + } + + #[tokio::test] + async fn test_fetch_pins_dir_unusable_reports_identity_error_without_request() { + let mut server = mockito::Server::new_async().await; + // An unusable explicit --dir must render the identity error and never hit + // the endpoint (no identity to sign with). + let m = server + .mock("GET", "/api/v1/ipfs/pins") + .expect(0) + .create_async() + .await; + + let bad = PathBuf::from("/nonexistent/gl-id-xyz"); + let panel = fetch_pins(&server.url(), PinsAuth::DirUnusable(bad.clone())).await; + match panel { + PinsPanel::IdentityError(d) => assert_eq!(d, bad), + other => panic!("expected IdentityError, got {other:?}"), + } + + m.assert_async().await; + } + + // jatmn #146 P2: `gl node status` must accept the same `--dir` identity + // selector as `gl ipfs list`, so a user who selected an identity via --dir can + // authenticate the status pins panel instead of seeing "sign in to view". + #[test] + fn status_accepts_dir_identity_selector() { + use clap::Parser; + #[derive(Parser)] + struct TestCli { + #[command(flatten)] + args: NodeArgs, + } + let cli = TestCli::try_parse_from([ + "gl", + "status", + "--node", + "http://example", + "--dir", + "/tmp/id", + ]) + .expect("`node status` must accept --dir"); + let NodeCmd::Status { dir, .. } = cli.args.cmd else { + panic!("expected the Status subcommand"); + }; + assert_eq!(dir, Some(PathBuf::from("/tmp/id"))); + + // Absent --dir must stay None so the default keystore path is unchanged. + let cli = TestCli::try_parse_from(["gl", "status"]).expect("status parses without --dir"); + let NodeCmd::Status { dir, .. } = cli.args.cmd else { + panic!("expected the Status subcommand"); + }; + assert_eq!(dir, None); + } +}