From ec6d000046b5f3b14fe8360a413d781f8ffbda10 Mon Sep 17 00:00:00 2001 From: euxaristia Date: Sat, 22 Aug 2026 20:38:28 -0400 Subject: [PATCH 1/6] fix(api,sync): Delimit positional arguments in git clone mirror invocations Pass "--" before positional path/URL arguments in Command invocations for "git clone --mirror" across repos.rs, sync.rs, and gl mirror.rs. Fixes #374 --- crates/gitlawb-node/src/api/repos.rs | 1 + crates/gitlawb-node/src/sync.rs | 1 + crates/gl/src/mirror.rs | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index 4e327c42..35c9cacf 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -3071,6 +3071,7 @@ pub async fn fork_repo( .args([ "clone", "--mirror", + "--", source_path.to_str().unwrap_or(""), disk_path.to_str().unwrap_or(""), ]) diff --git a/crates/gitlawb-node/src/sync.rs b/crates/gitlawb-node/src/sync.rs index 0ed4a9f9..be5014d5 100644 --- a/crates/gitlawb-node/src/sync.rs +++ b/crates/gitlawb-node/src/sync.rs @@ -701,6 +701,7 @@ async fn clone_repo(remote_url: &str, local_path: &Path, mode: MirrorMode) -> an if mode == MirrorMode::Promisor { args.push("--filter=blob:limit=10g"); } + args.push("--"); args.push(remote_url); args.push(local_str); diff --git a/crates/gl/src/mirror.rs b/crates/gl/src/mirror.rs index 400d3d45..6e275746 100644 --- a/crates/gl/src/mirror.rs +++ b/crates/gl/src/mirror.rs @@ -81,7 +81,7 @@ pub async fn run(args: MirrorArgs) -> Result<()> { println!("Cloning source (this may take a while for large repos)..."); let clone_status = Command::new("git") - .args(["clone", "--mirror", &source, mirror_path.to_str().unwrap()]) + .args(["clone", "--mirror", "--", &source, mirror_path.to_str().unwrap()]) .status() .context("failed to run git clone — is git installed?")?; From 912ddc0013428b18f5307667d8b1deb8ec5c5c28 Mon Sep 17 00:00:00 2001 From: euxaristia Date: Sat, 22 Aug 2026 21:59:46 -0400 Subject: [PATCH 2/6] style(gl): Format args array in mirror.rs Fix cargo fmt line-length break for git clone --mirror arguments. Refs #376 --- crates/gl/src/mirror.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/gl/src/mirror.rs b/crates/gl/src/mirror.rs index 6e275746..27cbe84c 100644 --- a/crates/gl/src/mirror.rs +++ b/crates/gl/src/mirror.rs @@ -81,7 +81,13 @@ pub async fn run(args: MirrorArgs) -> Result<()> { println!("Cloning source (this may take a while for large repos)..."); let clone_status = Command::new("git") - .args(["clone", "--mirror", "--", &source, mirror_path.to_str().unwrap()]) + .args([ + "clone", + "--mirror", + "--", + &source, + mirror_path.to_str().unwrap(), + ]) .status() .context("failed to run git clone — is git installed?")?; From 6d3b969c12f7ac764e90d1e233547bbde00da1dd Mon Sep 17 00:00:00 2001 From: euxaristia Date: Mon, 24 Aug 2026 10:42:34 -0400 Subject: [PATCH 3/6] fix(node): Pin the git clone delimiter against remote-argument injection The delimiter change had no test, so nothing stopped it being reverted as cosmetic. What it guards is real: git parses a remote beginning with a dash as an option, and --upload-pack= is one git hands to a shell. No path reaches that today. Peer URLs are gated by is_public_http_url, so the origin clone_repo composes can only begin with http:// or https://; fork paths are joins under repos_dir; and clap rejects a leading-dash positional in gl mirror. The delimiter is what removes git's dependence on those three unrelated gates staying correct. Assert git reports the whole argument as a repository it cannot find, which is only true once the delimiter forces it to be a path. Undelimited, git consumes it as an option and fails against the destination instead, never naming the injected string, so the assertion is red before the fix and green after. Refs #374 --- crates/gitlawb-node/src/sync.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/gitlawb-node/src/sync.rs b/crates/gitlawb-node/src/sync.rs index be5014d5..cb7df7c0 100644 --- a/crates/gitlawb-node/src/sync.rs +++ b/crates/gitlawb-node/src/sync.rs @@ -1007,6 +1007,39 @@ mod tests { assert_eq!(probe, PromisorProbe::NotPromisor); } + /// #374: `clone_repo` interpolates the peer's origin URL into a `git + /// clone --mirror` argument list. Without a `--` delimiter git parses a + /// remote beginning with `-` as an option instead of a repository, and + /// `--upload-pack=` is an option git hands to a shell — arbitrary + /// execution on the node driven by a value this process did not author. + /// + /// The delimiter is what forces the same string to be a repository, and + /// git says so in its own words: it reports the whole argument as a + /// repository it cannot find. Undelimited, git consumes it as an option + /// and fails against the *destination* instead ("Could not read from + /// remote repository"), never naming the injected string — which is why + /// this assertion is RED before the fix and GREEN after. + /// + /// The payload is deliberately free of `:` and spaces: a colon would make + /// git read the argument as an ssh-style `host:path` URL and report a + /// hostname rather than a repository, which is a different code path. + #[tokio::test] + async fn clone_never_parses_a_remote_as_a_git_option() { + let td = TempDir::new().unwrap(); + let dest = td.path().join("mirror.git"); + let injected = "--upload-pack=false"; + + let err = clone_repo(injected, &dest, MirrorMode::Plain) + .await + .expect_err("a remote that is not a repository must fail the clone") + .to_string(); + + assert!( + err.contains(injected), + "git must report the argument as a repository, not consume it as an option: {err}" + ); + } + #[tokio::test] async fn probe_reports_unknown_on_git_error() { // A path git cannot resolve as a repo at all (exit 128) is an indeterminate From 49155cd5432c12a071bc86b5cc4c234bec3ac9de Mon Sep 17 00:00:00 2001 From: euxaristia Date: Tue, 25 Aug 2026 01:28:08 -0400 Subject: [PATCH 4/6] fix(sync,gl): Delimit positional arguments in fetch_repo and gl clone Carry the positional argument delimiter into fetch_repo when setting the remote origin URL and into setup_partial_clone across all clone branches. Correct the error string quoted in the clone regression test docstring. Refs #374 --- crates/gitlawb-node/src/sync.rs | 15 ++++++++------- crates/gl/src/clone.rs | 34 +++++++++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/crates/gitlawb-node/src/sync.rs b/crates/gitlawb-node/src/sync.rs index cb7df7c0..c58f4525 100644 --- a/crates/gitlawb-node/src/sync.rs +++ b/crates/gitlawb-node/src/sync.rs @@ -730,7 +730,10 @@ async fn clone_repo(remote_url: &str, local_path: &Path, mode: MirrorMode) -> an async fn fetch_repo(local_path: &Path, remote_url: &str, mode: MirrorMode) -> anyhow::Result<()> { let local_str = local_path.to_str().unwrap_or("."); - git_run(&["-C", local_str, "remote", "set-url", "origin", remote_url]).await?; + git_run(&[ + "-C", local_str, "remote", "set-url", "origin", "--", remote_url, + ]) + .await?; match mode { MirrorMode::Promisor => { @@ -1009,16 +1012,14 @@ mod tests { /// #374: `clone_repo` interpolates the peer's origin URL into a `git /// clone --mirror` argument list. Without a `--` delimiter git parses a - /// remote beginning with `-` as an option instead of a repository, and - /// `--upload-pack=` is an option git hands to a shell — arbitrary - /// execution on the node driven by a value this process did not author. + /// remote beginning with `-` as an option instead of a repository. /// /// The delimiter is what forces the same string to be a repository, and /// git says so in its own words: it reports the whole argument as a /// repository it cannot find. Undelimited, git consumes it as an option - /// and fails against the *destination* instead ("Could not read from - /// remote repository"), never naming the injected string — which is why - /// this assertion is RED before the fix and GREEN after. + /// and fails against the *destination* instead (`fatal: repository '' does not exist`), + /// never naming the injected string — which is why this assertion is RED + /// before the fix and GREEN after. /// /// The payload is deliberately free of `:` and spaces: a colon would make /// git read the argument as an ssh-style `host:path` URL and report a diff --git a/crates/gl/src/clone.rs b/crates/gl/src/clone.rs index 926a2d57..3299e511 100644 --- a/crates/gl/src/clone.rs +++ b/crates/gl/src/clone.rs @@ -159,8 +159,8 @@ pub fn setup_partial_clone( if withheld_globs.is_empty() { match branch { - Some(b) => git_global(&["clone", "-q", "--branch", b, remote_url, dest_str])?, - None => git_global(&["clone", "-q", remote_url, dest_str])?, + Some(b) => git_global(&["clone", "-q", "--branch", b, "--", remote_url, dest_str])?, + None => git_global(&["clone", "-q", "--", remote_url, dest_str])?, } return Ok(()); } @@ -170,6 +170,7 @@ pub fn setup_partial_clone( "-q", "--filter=blob:none", "--no-checkout", + "--", remote_url, dest_str, ])?; @@ -1086,6 +1087,35 @@ mod tests { ); } + #[test] + fn setup_partial_clone_plain_without_withheld_paths() { + let (td, url) = bare_remote(&[("file.txt", b"hello\n")]); + let dest = td.path().join("dest"); + setup_partial_clone(&dest, &url, &[], &[], None).unwrap(); + assert!(dest.join("file.txt").exists()); + } + + #[test] + fn setup_partial_clone_with_branch_without_withheld_paths() { + let (td, url) = bare_remote(&[("file.txt", b"hello\n")]); + let branch_out = Command::new("git") + .args([ + "-C", + td.path().join("origin").to_str().unwrap(), + "branch", + "--show-current", + ]) + .output() + .unwrap(); + let branch = String::from_utf8(branch_out.stdout) + .unwrap() + .trim() + .to_string(); + let dest = td.path().join("dest"); + setup_partial_clone(&dest, &url, &[], &[], Some(&branch)).unwrap(); + assert!(dest.join("file.txt").exists()); + } + #[test] fn sparse_patterns_subtree_and_exact() { assert_eq!(sparse_patterns("/secret/**"), vec!["/secret/".to_string()]); From 7ab2b7e0081946e9403f2b83bcd2eb5d93e63bd9 Mon Sep 17 00:00:00 2001 From: euxaristia Date: Wed, 26 Aug 2026 23:23:12 -0400 Subject: [PATCH 5/6] test(gl,node): Pin the argv delimiter on every git sink this PR changed The delimiter contract is duplicated across five direct `git` argv constructions, but only `sync::clone_repo` had a test that went red when `--` was deleted. Removing or misplacing the delimiter in any other sink left the suite green, so the hardening rested on review rather than on the tests. Each changed sink now has a subprocess-facing regression driven through real git: the three `setup_partial_clone` shapes (plain, `--branch`, and the sparse/promisor arm), `fetch_repo`'s `remote set-url`, `gl mirror`, and the fork clone. Two of these could not carry the obvious assertion: - `git_global` and `git_run` both format failures as `git {args:?} failed: {stderr}`, so the injected value is already in the message via the debug-printed argv. Asserting that the error merely contains it passes with `--` deleted. The clone tests assert on git's own single-quoted `repository ''` instead, which only appears when git read the value as a repository. - `remote set-url` does not fail on a delimited option-shaped value, it stores it. The `fetch_repo` test asserts the stored URL rather than an error: undelimited, git exits 129 with `unknown option` and leaves the previous URL in place. `gl mirror` and `fork_repo` needed their argv extracted to be testable. `mirror::run` loads a keypair and contacts a node before reaching the clone, and bails with a message that interpolates the source whether or not the delimiter is present. The extraction keeps the production `.status()` call, so a large mirror clone still streams git's progress to the terminal instead of being captured. Every one of the five was confirmed red with its delimiter removed and green with it restored. Refs #374 --- crates/gitlawb-node/src/api/repos.rs | 41 +++++++++++++++++--- crates/gitlawb-node/src/sync.rs | 45 ++++++++++++++++++++++ crates/gl/src/clone.rs | 50 ++++++++++++++++++++++++ crates/gl/src/mirror.rs | 57 ++++++++++++++++++++++++---- 4 files changed, 180 insertions(+), 13 deletions(-) diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index 35c9cacf..5d103e4c 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -3000,6 +3000,17 @@ pub struct ForkRepoRequest { } /// POST /api/v1/repos/:owner/:repo/fork +/// Argv for the fork's mirror clone, with `--` before the first positional (#374). +/// +/// Both positionals here are server-derived paths rather than caller-supplied +/// URLs, so this is defence in depth rather than a reachable injection. It is +/// still the same argv contract as the other clone sites, and it is extracted for +/// the same reason: so the delimiter's placement is pinned by a test instead of +/// resting on review. +fn fork_clone_args<'a>(source: &'a str, dest: &'a str) -> [&'a str; 5] { + ["clone", "--mirror", "--", source, dest] +} + pub async fn fork_repo( State(state): State, Extension(auth): Extension, @@ -3068,13 +3079,10 @@ pub async fn fork_repo( // Clone the source repo as a mirror let output = std::process::Command::new("git") - .args([ - "clone", - "--mirror", - "--", + .args(fork_clone_args( source_path.to_str().unwrap_or(""), disk_path.to_str().unwrap_or(""), - ]) + )) .output() .map_err(|e| AppError::Git(format!("git clone --mirror failed: {e}")))?; @@ -3336,6 +3344,29 @@ fn dedupe_canonical_repos(rows: Vec<(RepoRecord, i64)>) -> Vec<(RepoRecord, i64) #[cfg(test)] mod tests { use super::*; + + /// #374: the fork path builds its own `git clone --mirror` argv. Driven + /// through real git so a missing or misplaced `--` fails, matching the + /// coverage on the `gl clone`, `gl mirror`, and sync sinks. + #[test] + fn fork_clone_never_parses_the_source_as_a_git_option() { + let td = tempfile::TempDir::new().unwrap(); + let dest = td.path().join("fork.git"); + let injected = "--upload-pack=false"; + + let out = std::process::Command::new("git") + .args(fork_clone_args(injected, dest.to_str().unwrap())) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + + assert!(!out.status.success(), "an option-shaped source must fail"); + assert!( + stderr.contains(&format!("repository '{injected}'")), + "git must name the source as the repository, not consume it as an option: {stderr}" + ); + } + use crate::auth::caller_authorized_to_push; use crate::error::AppError; use gitlawb_core::identity::Keypair; diff --git a/crates/gitlawb-node/src/sync.rs b/crates/gitlawb-node/src/sync.rs index c58f4525..bc7e943b 100644 --- a/crates/gitlawb-node/src/sync.rs +++ b/crates/gitlawb-node/src/sync.rs @@ -1041,6 +1041,51 @@ mod tests { ); } + /// #374: `fetch_repo` interpolates the peer's origin URL into + /// `git remote set-url`, a second argv construction with the same contract as + /// the clone. It needs its own proof: the clone test cannot reach this path. + /// + /// The assertion is on stored state rather than on the error string, and that + /// is deliberate. `git_run` formats its failure as `git {args:?} failed: + /// {stderr}`, so the injected value is in the message via the debug-printed + /// argv whether or not the delimiter is present — asserting on containment + /// would pass with `--` deleted. Delimited, `set-url` succeeds and stores the + /// value verbatim; undelimited, git exits 129 with `unknown option` and leaves + /// the previous URL in place, so this goes red without the fix. + /// + /// The fetch that follows the rewrite fails, because the stored URL is not a + /// repository. That is expected and ignored: the property under test is what + /// `set-url` wrote, not whether the subsequent fetch could succeed. + #[tokio::test] + async fn fetch_never_parses_a_remote_as_a_git_option() { + let td = TempDir::new().unwrap(); + let local = td.path().join("mirror.git"); + let local_str = local.to_str().unwrap(); + let placeholder = "http://example.invalid/x.git"; + + assert!(Command::new("git") + .args(["init", "-q", "--bare", local_str]) + .status() + .unwrap() + .success()); + assert!(Command::new("git") + .args(["-C", local_str, "remote", "add", "origin", placeholder]) + .status() + .unwrap() + .success()); + + let injected = "--upload-pack=false"; + let _ = fetch_repo(&local, injected, MirrorMode::Plain).await; + + assert_eq!( + git_config_get(local_str, "remote.origin.url") + .await + .as_deref(), + Some(injected), + "set-url must store an option-shaped remote verbatim, not reject it as an option" + ); + } + #[tokio::test] async fn probe_reports_unknown_on_git_error() { // A path git cannot resolve as a repo at all (exit 128) is an indeterminate diff --git a/crates/gl/src/clone.rs b/crates/gl/src/clone.rs index 3299e511..8e31e8e2 100644 --- a/crates/gl/src/clone.rs +++ b/crates/gl/src/clone.rs @@ -1116,6 +1116,56 @@ mod tests { assert!(dest.join("file.txt").exists()); } + /// #374: every `setup_partial_clone` shape interpolates `remote_url` into a + /// `git clone` argv. The `--` delimiter is what forces an option-shaped + /// remote to be read as a repository rather than parsed as an option. + /// + /// The assertion is on git's own words — `repository ''`, single-quoted + /// — and not merely on the value appearing somewhere in the error. `git_global` + /// formats its failure as `git {args:?} failed: {stderr}`, so the injected + /// string is already present in the debug-printed argv whether or not the + /// delimiter is there; asserting on containment alone would pass with `--` + /// deleted. Undelimited, git consumes the value as an option, leaving the + /// destination as the only positional, and names *that* instead + /// (`repository '' does not exist`) — so this goes red without the fix. + /// + /// The payload carries no `:` or space: a colon would make git read the + /// argument as an ssh-style `host:path` URL and report a hostname, which is a + /// different parse. + fn assert_option_shaped_remote_is_a_repository(withheld: &[String], branch: Option<&str>) { + let td = TempDir::new().unwrap(); + let dest = td.path().join("dest"); + let injected = "--upload-pack=false"; + + let err = setup_partial_clone(&dest, injected, withheld, &[], branch) + .expect_err("an option-shaped remote is not a repository and must fail") + .to_string(); + + assert!( + err.contains(&format!("repository '{injected}'")), + "git must name the argument as the repository, not consume it as an option: {err}" + ); + } + + #[test] + fn plain_clone_never_parses_a_remote_as_a_git_option() { + assert_option_shaped_remote_is_a_repository(&[], None); + } + + #[test] + fn branch_clone_never_parses_a_remote_as_a_git_option() { + // Pins that `--branch ` stays ahead of the delimiter: if `--` were moved + // before `--branch`, git would read the branch name as a positional instead. + assert_option_shaped_remote_is_a_repository(&[], Some("main")); + } + + #[test] + fn sparse_clone_never_parses_a_remote_as_a_git_option() { + // The withheld-globs arm is a separate argv construction + // (`--filter=blob:none --no-checkout`) and needs its own proof. + assert_option_shaped_remote_is_a_repository(&["/secret/**".to_string()], None); + } + #[test] fn sparse_patterns_subtree_and_exact() { assert_eq!(sparse_patterns("/secret/**"), vec!["/secret/".to_string()]); diff --git a/crates/gl/src/mirror.rs b/crates/gl/src/mirror.rs index 27cbe84c..0dce9834 100644 --- a/crates/gl/src/mirror.rs +++ b/crates/gl/src/mirror.rs @@ -37,6 +37,17 @@ pub struct MirrorArgs { pub dir: Option, } +/// Argv for the mirror clone, with `--` immediately before the first positional +/// so an option-shaped `source` cannot be parsed as a git option (#374). +/// +/// This is a separate function purely so the delimiter's placement can be driven +/// through real git by a test. The production call keeps `.status()` rather than +/// `.output()`: a mirror clone of a large repo streams git's progress to the +/// user's terminal, and capturing it to assert on stderr here would swallow that. +fn mirror_clone_args<'a>(source: &'a str, dest: &'a str) -> [&'a str; 5] { + ["clone", "--mirror", "--", source, dest] +} + pub async fn run(args: MirrorArgs) -> Result<()> { let source = args.source.trim_end_matches('/').to_string(); @@ -81,13 +92,7 @@ pub async fn run(args: MirrorArgs) -> Result<()> { println!("Cloning source (this may take a while for large repos)..."); let clone_status = Command::new("git") - .args([ - "clone", - "--mirror", - "--", - &source, - mirror_path.to_str().unwrap(), - ]) + .args(mirror_clone_args(&source, mirror_path.to_str().unwrap())) .status() .context("failed to run git clone — is git installed?")?; @@ -176,6 +181,41 @@ pub fn extract_repo_name(url: &str) -> Option { mod tests { use super::*; + /// #374: `gl mirror` interpolates the user-supplied source into a + /// `git clone --mirror` argv. `--` must sit immediately before the first + /// positional so an option-shaped source is read as a repository. + /// + /// This drives the production argv through real git rather than asserting on + /// the array's contents, so it fails on a delimiter that is missing *or* + /// misplaced. It cannot go through `run()`: that loads an identity keypair and + /// contacts a node before reaching the clone, and its bail message interpolates + /// `{source}` unconditionally, so an assertion there would pass with `--` gone. + /// + /// Asserting on git's single-quoted `repository ''` is what makes this + /// red without the delimiter — undelimited, git consumes the value as an option + /// and names the destination as the repository instead. + #[test] + fn mirror_clone_never_parses_the_source_as_a_git_option() { + let td = tempfile::TempDir::new().unwrap(); + let dest = td.path().join("mirror.git"); + let injected = "--upload-pack=false"; + + let out = Command::new("git") + .args(mirror_clone_args(injected, dest.to_str().unwrap())) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + + assert!( + !out.status.success(), + "an option-shaped source is not a repository and must fail the clone" + ); + assert!( + stderr.contains(&format!("repository '{injected}'")), + "git must name the source as the repository, not consume it as an option: {stderr}" + ); + } + #[test] fn test_extract_github_url() { assert_eq!( @@ -246,7 +286,8 @@ mod tests { .create_async() .await; - // We can't easily test the git subprocess calls, but we can test the + // The git subprocess argv is pinned separately by + // `mirror_clone_never_parses_the_source_as_a_git_option`; this covers the // API error path by calling the create step directly via NodeClient. let kp2 = gitlawb_core::identity::Keypair::generate(); let client = NodeClient::new(server.url(), Some(kp2)); From 23e7a07c18274f6f634a04907aad8a2c209f0e4f Mon Sep 17 00:00:00 2001 From: euxaristia Date: Wed, 26 Aug 2026 23:23:18 -0400 Subject: [PATCH 6/6] fix(gl): Route the last three clone warnings through emit_warning `emit_warning` sanitizes a line before writing it, which strips ANSI escape and bidi characters from untrusted text. Three warning sites in the blob-recovery paths still used a bare `eprintln!` while interpolating `oid`, which comes from node JSON or an Arweave gateway manifest, so those escapes reached the user's terminal raw. Refs #374 --- crates/gl/src/clone.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/gl/src/clone.rs b/crates/gl/src/clone.rs index 8e31e8e2..44cc3cdf 100644 --- a/crates/gl/src/clone.rs +++ b/crates/gl/src/clone.rs @@ -385,7 +385,7 @@ async fn recover_encrypted_blobs( let plaintext = match open_blob(&envelope, keypair) { Ok(p) => p, Err(e) => { - eprintln!("warning: could not decrypt {oid}: {e}"); + emit_warning(&format!("warning: could not decrypt {oid}: {e}")); continue; } }; @@ -403,7 +403,9 @@ async fn recover_encrypted_blobs( recovered.push(p.clone()); } } else { - eprintln!("warning: recovered blob {oid} hashed to {written}; discarding"); + emit_warning(&format!( + "warning: recovered blob {oid} hashed to {written}; discarding" + )); } } Ok(recovered) @@ -801,7 +803,9 @@ async fn recover_from_arweave( recovered.push(p.clone()); } } else { - eprintln!("warning: recovered blob {oid} hashed to {written}; discarding"); + emit_warning(&format!( + "warning: recovered blob {oid} hashed to {written}; discarding" + )); } } Ok(recovered)