From aae355b932a29bc7c38c78b7eedc601e3fc4d89a Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:53:23 -0500 Subject: [PATCH 1/6] fix(gl,ipfs): sign gl ipfs list so it works under the #134 pins auth gate /api/v1/ipfs/pins now 401s anonymous callers (#134). Load the caller's identity (--dir, default keystore) and use NodeClient::get_signed; when no identity exists, propagate load_keypair_from_dir's existing 'gl identity new' error instead of a raw 401. Tests: signed-headers present, empty, and no-identity-issues-no-request (mockito). --- crates/gl/src/ipfs_cmd.rs | 108 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 104 insertions(+), 4 deletions(-) diff --git a/crates/gl/src/ipfs_cmd.rs b/crates/gl/src/ipfs_cmd.rs index b1b12f0..b01cb25 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,15 +38,19 @@ 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); +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: Value = client - .get("/api/v1/ipfs/pins") + .get_signed("/api/v1/ipfs/pins") .await? .json() .await @@ -108,3 +117,94 @@ 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; + } +} From 9d03290cdcb82824d34977e7adb626fb38d8fb18 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:58:48 -0500 Subject: [PATCH 2/6] fix(gl,node): sign the gl node status pins panel, degrade gracefully The pins panel signs its /api/v1/ipfs/pins read (#134 gates it behind auth) via an injectable fetch_pins helper with four states: pins, empty, unavailable (signed read rejected/errored), and needs-identity (no keypair, no request issued). cmd_status loads the identity gracefully so a missing keystore never aborts status; peers/repos/p2p/events panels stay anonymous. Tests drive fetch_pins directly against a mock node. --- crates/gl/src/node.rs | 186 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 177 insertions(+), 9 deletions(-) diff --git a/crates/gl/src/node.rs b/crates/gl/src/node.rs index 9b3a6e1..bf5f6e4 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)] @@ -177,6 +179,52 @@ async fn try_get_json(client: &NodeClient, path: &str) -> Option { resp.json::().await.ok() } +/// 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 raw JSON body). + Pins(Value), + /// 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, +} + +/// Fetch the pins panel state. With a keypair, signs the `/api/v1/ipfs/pins` +/// read and maps the outcome; without one, returns `NeedsIdentity` and issues +/// no request. Injectable (node URL + optional keypair) so tests drive it with +/// a mock server and never touch the default keystore. +async fn fetch_pins(node: &str, keypair: Option) -> PinsPanel { + let Some(kp) = keypair else { + return PinsPanel::NeedsIdentity; + }; + 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(body) + } +} + async fn cmd_status(node: String) -> Result<()> { let client = NodeClient::new(&node, None); @@ -194,13 +242,18 @@ 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); + // load the identity gracefully so a missing keystore never aborts status. + let keypair = load_keypair_from_dir(None).ok(); + // ── 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, keypair), ); // ── Render dashboard ────────────────────────────────────────────────── @@ -307,13 +360,22 @@ 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"); + match pins_panel { + PinsPanel::Pins(ref pins) => { + 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}"); + } + PinsPanel::Empty => { + println!(" IPFS not configured"); + } + PinsPanel::Unavailable => { + println!(" IPFS pins: unavailable"); + } + PinsPanel::NeedsIdentity => { + println!(" IPFS pins: sign in to view (run `gl identity new`)"); + } } println!(); @@ -409,3 +471,109 @@ 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(), Some(kp)).await; + match panel { + PinsPanel::Pins(pins) => { + assert_eq!(pins["count"].as_u64(), Some(1)); + assert_eq!(pins["pins"].as_array().map(|a| a.len()), Some(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(), Some(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(), Some(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(), None).await; + assert!( + matches!(panel, PinsPanel::NeedsIdentity), + "expected NeedsIdentity, got {panel:?}" + ); + + m.assert_async().await; + } +} From 411d1ca02d20dfa07dd1c8943db66f1f39865343 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:07:42 -0500 Subject: [PATCH 3/6] =?UTF-8?q?fix(gl):=20address=20review=20=E2=80=94=20s?= =?UTF-8?q?urface=20pins=20HTTP=20errors,=20correct=20empty-panel=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gl ipfs list: check the /ipfs/pins response status before parsing, so a rejected signed read (e.g. 401) surfaces as an error instead of silently printing 'No IPFS pins recorded'. Matches the sibling gl ipfs get. - gl node status: PinsPanel::Empty now renders 'Pinned CIDs: 0' (a reachable node with zero pins), reserving 'unavailable' for the failure state; the prior label reused 'IPFS not configured' and misreported the zero-pins case. - Carry the resolved count in PinsPanel::Pins, removing the duplicated count-fallback closure between fetch_pins and the render arm. --- crates/gl/src/ipfs_cmd.rs | 37 ++++++++++++++++++++++++++++++++++--- crates/gl/src/node.rs | 18 ++++++------------ 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/crates/gl/src/ipfs_cmd.rs b/crates/gl/src/ipfs_cmd.rs index b01cb25..fea6844 100644 --- a/crates/gl/src/ipfs_cmd.rs +++ b/crates/gl/src/ipfs_cmd.rs @@ -49,9 +49,13 @@ async fn cmd_list(node: String, dir: Option) -> Result<()> { // 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: Value = client - .get_signed("/api/v1/ipfs/pins") - .await? + 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")?; @@ -207,4 +211,31 @@ mod tests { 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 bf5f6e4..b9ad972 100644 --- a/crates/gl/src/node.rs +++ b/crates/gl/src/node.rs @@ -186,8 +186,8 @@ async fn try_get_json(client: &NodeClient, path: &str) -> Option { /// sign in. A pins failure never aborts the dashboard. #[derive(Debug)] enum PinsPanel { - /// Signed read succeeded and returned pins (carries the raw JSON body). - Pins(Value), + /// 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. @@ -221,7 +221,7 @@ async fn fetch_pins(node: &str, keypair: Option) -> PinsPanel { if count == 0 { PinsPanel::Empty } else { - PinsPanel::Pins(body) + PinsPanel::Pins(count) } } @@ -361,14 +361,11 @@ async fn cmd_status(node: String) -> Result<()> { // Pins println!("Pins"); match pins_panel { - PinsPanel::Pins(ref pins) => { - let count = pins["count"] - .as_u64() - .unwrap_or_else(|| pins["pins"].as_array().map(|a| a.len() as u64).unwrap_or(0)); + PinsPanel::Pins(count) => { println!(" Pinned CIDs: {count}"); } PinsPanel::Empty => { - println!(" IPFS not configured"); + println!(" Pinned CIDs: 0"); } PinsPanel::Unavailable => { println!(" IPFS pins: unavailable"); @@ -499,10 +496,7 @@ mod tests { let panel = fetch_pins(&server.url(), Some(kp)).await; match panel { - PinsPanel::Pins(pins) => { - assert_eq!(pins["count"].as_u64(), Some(1)); - assert_eq!(pins["pins"].as_array().map(|a| a.len()), Some(1)); - } + PinsPanel::Pins(count) => assert_eq!(count, 1), other => panic!("expected Pins, got {other:?}"), } From 819b435550425cb88c88c47ffd8e5de2aa3734fb Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:13:12 -0500 Subject: [PATCH 4/6] test(gl,node): cover fetch_pins error branches and pins-panel rendering Extract the pins-panel render into a testable pins_status_line helper and assert all four states' output (closes the untested cmd_status render path, including the empty->'Pinned CIDs: 0' label). Add fetch_pins tests for the two error branches that were code-traced but unexecuted: a malformed 200 body and a transport error both degrade to Unavailable without panicking. --- crates/gl/src/node.rs | 83 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 69 insertions(+), 14 deletions(-) diff --git a/crates/gl/src/node.rs b/crates/gl/src/node.rs index b9ad972..6227a9f 100644 --- a/crates/gl/src/node.rs +++ b/crates/gl/src/node.rs @@ -225,6 +225,18 @@ async fn fetch_pins(node: &str, keypair: Option) -> PinsPanel { } } +/// 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() + } + } +} + async fn cmd_status(node: String) -> Result<()> { let client = NodeClient::new(&node, None); @@ -360,20 +372,7 @@ async fn cmd_status(node: String) -> Result<()> { // Pins println!("Pins"); - match pins_panel { - PinsPanel::Pins(count) => { - println!(" Pinned CIDs: {count}"); - } - PinsPanel::Empty => { - println!(" Pinned CIDs: 0"); - } - PinsPanel::Unavailable => { - println!(" IPFS pins: unavailable"); - } - PinsPanel::NeedsIdentity => { - println!(" IPFS pins: sign in to view (run `gl identity new`)"); - } - } + println!("{}", pins_status_line(&pins_panel)); println!(); Ok(()) @@ -570,4 +569,60 @@ mod tests { 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(), Some(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}"), Some(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`)" + ); + } } From 93ab3da1dff715af98c1357d559f80054d085ebd Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:17:54 -0500 Subject: [PATCH 5/6] style(gl): rustfmt the ipfs list pins-response parse --- crates/gl/src/ipfs_cmd.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/gl/src/ipfs_cmd.rs b/crates/gl/src/ipfs_cmd.rs index fea6844..d4cb64f 100644 --- a/crates/gl/src/ipfs_cmd.rs +++ b/crates/gl/src/ipfs_cmd.rs @@ -55,10 +55,7 @@ async fn cmd_list(node: String, dir: Option) -> Result<()> { 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 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); From cf588d0ddac68e48bfc759d6e73dfca89dcc51e0 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 2 Jul 2026 19:55:39 -0500 Subject: [PATCH 6/6] fix(gl,node): honor --dir identity selector in `gl node status` (#146) `gl node status` always loaded the default keystore via `load_keypair_from_dir(None)`, so a user who created or selected an identity with `--dir` saw the status pins panel render "sign in to view" even though `gl ipfs list --dir` authenticated fine. Add a `--dir` option to `NodeCmd::Status` and thread it into the signed pins fetch, so both CLI pins callers select the same identity directory. An explicit `--dir` that can't be loaded (missing, corrupt, or unreadable) no longer masquerades as "no identity, run `gl identity new`": the pins identity is resolved up front into Keyed / Anonymous / DirUnusable, and the unusable explicit-dir case renders a distinct "no usable identity in " panel line that names the path, while a missing default keystore (no `--dir`) still degrades quietly. A pins failure never aborts the dashboard. Tests cover both flag branches (Some/None) and the resolver both ways: a good `--dir` loads Keyed, an unusable one reports DirUnusable/IdentityError without issuing a request. The new symbols were absent before, so they compile-RED first. --- crates/gl/src/node.rs | 167 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 149 insertions(+), 18 deletions(-) diff --git a/crates/gl/src/node.rs b/crates/gl/src/node.rs index 6227a9f..a48eb3d 100644 --- a/crates/gl/src/node.rs +++ b/crates/gl/src/node.rs @@ -22,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 { @@ -123,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 { @@ -194,15 +197,48 @@ enum PinsPanel { 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; without one, returns `NeedsIdentity` and issues -/// no request. Injectable (node URL + optional keypair) so tests drive it with -/// a mock server and never touch the default keystore. -async fn fetch_pins(node: &str, keypair: Option) -> PinsPanel { - let Some(kp) = keypair else { - return PinsPanel::NeedsIdentity; +/// 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 { @@ -234,10 +270,13 @@ fn pins_status_line(panel: &PinsPanel) -> 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) -> Result<()> { +async fn cmd_status(node: String, dir: Option) -> Result<()> { let client = NodeClient::new(&node, None); // ── Fetch node info (required — bail if unreachable) ────────────────── @@ -254,9 +293,12 @@ 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); - // load the identity gracefully so a missing keystore never aborts status. - let keypair = load_keypair_from_dir(None).ok(); + // 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 ───────────────────────────── // Peers/repos/p2p/events stay anonymous; only pins is signed. @@ -265,7 +307,7 @@ async fn cmd_status(node: String) -> Result<()> { 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"), - fetch_pins(&node, keypair), + fetch_pins(&node, pins_auth), ); // ── Render dashboard ────────────────────────────────────────────────── @@ -493,7 +535,7 @@ mod tests { .create_async() .await; - let panel = fetch_pins(&server.url(), Some(kp)).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:?}"), @@ -516,7 +558,7 @@ mod tests { .create_async() .await; - let panel = fetch_pins(&server.url(), Some(kp)).await; + let panel = fetch_pins(&server.url(), PinsAuth::Keyed(kp)).await; assert!( matches!(panel, PinsPanel::Empty), "expected Empty, got {panel:?}" @@ -541,7 +583,7 @@ mod tests { .create_async() .await; - let panel = fetch_pins(&server.url(), Some(kp)).await; + let panel = fetch_pins(&server.url(), PinsAuth::Keyed(kp)).await; assert!( matches!(panel, PinsPanel::Unavailable), "expected Unavailable, got {panel:?}" @@ -561,7 +603,7 @@ mod tests { .create_async() .await; - let panel = fetch_pins(&server.url(), None).await; + let panel = fetch_pins(&server.url(), PinsAuth::Anonymous).await; assert!( matches!(panel, PinsPanel::NeedsIdentity), "expected NeedsIdentity, got {panel:?}" @@ -586,7 +628,7 @@ mod tests { .create_async() .await; - let panel = fetch_pins(&server.url(), Some(kp)).await; + let panel = fetch_pins(&server.url(), PinsAuth::Keyed(kp)).await; assert!( matches!(panel, PinsPanel::Unavailable), "malformed body -> Unavailable, got {panel:?}" @@ -605,7 +647,7 @@ mod tests { }; let kp = Keypair::generate(); - let panel = fetch_pins(&format!("http://127.0.0.1:{port}"), Some(kp)).await; + 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:?}" @@ -624,5 +666,94 @@ mod tests { 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); } }