Skip to content

Commit bf91410

Browse files
author
t
committed
fix(gl): sign gl ipfs get when an identity is available (#173, F5)
The /ipfs/{cid} resolver now serves path-scoped objects to authorized readers, but `gl ipfs get` built NodeClient with None and always sent an unsigned request, so an owner or listed reader received the opaque anonymous 404 for content they can read. Add a --dir identity arg (like `list`), load the keypair best-effort, and use get_authed (signs iff a keypair is present, unsigned fallback keeps public reads working). RED->GREEN: test_cmd_get_signs_when_identity_present drives a signature-matching mock — 501 (unmatched) while unsigned, 200 once signed. test_cmd_get_anonymous_ denial_is_error guards the must-not: a 404 surfaces as an error, not masked success.
1 parent c396857 commit bf91410

1 file changed

Lines changed: 72 additions & 4 deletions

File tree

‎crates/gl/src/ipfs_cmd.rs‎

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,16 @@ pub enum IpfsCmd {
3333
cid: String,
3434
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
3535
node: String,
36+
/// Identity directory (default: ~/.gitlawb)
37+
#[arg(long)]
38+
dir: Option<PathBuf>,
3639
},
3740
}
3841

3942
pub async fn run(args: IpfsArgs) -> Result<()> {
4043
match args.cmd {
4144
IpfsCmd::List { node, dir } => cmd_list(node, dir).await,
42-
IpfsCmd::Get { cid, node } => cmd_get(cid, node).await,
45+
IpfsCmd::Get { cid, node, dir } => cmd_get(cid, node, dir).await,
4346
}
4447
}
4548

@@ -86,11 +89,17 @@ async fn cmd_list(node: String, dir: Option<PathBuf>) -> Result<()> {
8689
Ok(())
8790
}
8891

89-
async fn cmd_get(cid: String, node: String) -> Result<()> {
90-
let client = NodeClient::new(&node, None);
92+
async fn cmd_get(cid: String, node: String, dir: Option<PathBuf>) -> Result<()> {
93+
// #173 (F5): the resolver now serves path-scoped objects to authorized readers,
94+
// so sign with an available identity like `gl ipfs list` — otherwise an owner or
95+
// listed reader gets the opaque anonymous 404 for content they can read. Load
96+
// best-effort (unlike `list`, `get` stays usable for genuinely public content):
97+
// `get_authed` signs when a keypair is present and falls back to unsigned.
98+
let keypair = crate::identity::load_keypair_from_dir(dir.as_deref()).ok();
99+
let client = NodeClient::new(&node, keypair);
91100
let path = format!("/ipfs/{cid}");
92101
let resp = client
93-
.get(&path)
102+
.get_authed(&path)
94103
.await
95104
.with_context(|| format!("failed to fetch CID {cid} from {node}"))?;
96105

@@ -235,4 +244,63 @@ mod tests {
235244

236245
m.assert_async().await;
237246
}
247+
248+
/// #173 (F5): `gl ipfs get` must SIGN with an available identity, like
249+
/// `gl ipfs list`, so an owner/reader can retrieve a path-scoped object the node
250+
/// now resolves by CID. RED before the fix: cmd_get ignores the identity dir and
251+
/// sends an unsigned request, so the signature-matching mock is never hit
252+
/// (cmd_get errors on the unmatched 501, and m.assert fails). GREEN after: the
253+
/// signed request carries the RFC 9421 headers and is served 200.
254+
#[tokio::test]
255+
async fn test_cmd_get_signs_when_identity_present() {
256+
let mut server = mockito::Server::new_async().await;
257+
let keystore = seed_keystore();
258+
259+
let m = server
260+
.mock("GET", "/ipfs/bafkreitestcid")
261+
.match_header("signature", mockito::Matcher::Any)
262+
.match_header("signature-input", mockito::Matcher::Any)
263+
.with_status(200)
264+
.with_header("content-type", "application/octet-stream")
265+
.with_header("x-git-hash", "abc123")
266+
.with_body("object bytes")
267+
.create_async()
268+
.await;
269+
270+
cmd_get(
271+
"bafkreitestcid".to_string(),
272+
server.url(),
273+
Some(keystore.path().to_path_buf()),
274+
)
275+
.await
276+
.expect("signed get of a resolvable object should succeed");
277+
278+
m.assert_async().await;
279+
}
280+
281+
/// #173 (F5) must-not: a genuine anonymous denial must surface as an error, not
282+
/// be masked as success. With no identity dir the request is unsigned; a 404
283+
/// from the node must produce an Err mentioning the status.
284+
#[tokio::test]
285+
async fn test_cmd_get_anonymous_denial_is_error() {
286+
let mut server = mockito::Server::new_async().await;
287+
288+
let m = server
289+
.mock("GET", "/ipfs/bafkreidenied")
290+
.with_status(404)
291+
.with_header("content-type", "text/plain")
292+
.with_body("no git object found")
293+
.create_async()
294+
.await;
295+
296+
let err = cmd_get("bafkreidenied".to_string(), server.url(), None)
297+
.await
298+
.expect_err("a 404 denial must be an error, not masked success");
299+
assert!(
300+
err.to_string().contains("404"),
301+
"error should mention the status, got: {err}"
302+
);
303+
304+
m.assert_async().await;
305+
}
238306
}

0 commit comments

Comments
 (0)