Skip to content

Commit 02fba70

Browse files
committed
fix(node): validate the fork name before spending the proof
The barrier landed after `proof.consume` and after `repo_store.acquire`, so a name the character allowlist admits but `validate_repo_name` refuses burned a valid iCaptcha proof and paid a source-repo download before its 400. The handler's own header comment promises the opposite: a fork rejected for a bad name never burns a proof. Reported by CodeRabbit on the review of a7257dc. It now runs with the other admissibility checks, above both. The regression test pins the ORDER without iCaptcha plumbing. Seed a repo row whose bytes are not on disk: late validation lets the acquire fail first and the caller sees a 500 from git, so only early validation can produce the 400 the test asserts. Verified load-bearing by degrading the early call to `unwrap_or_else`, which reddens it. Both name rules are kept. CodeRabbit also suggested deleting the character allowlist as a strict subset of `validate_repo_name`, and that is not the relation between them: the allowlist rejects a dot, so `v1.2.3` and `my.repo` are refused today while `validate_repo_name` accepts both, and the allowlist accepts a non-ASCII alphanumeric that `validate_repo_name` rejects. Removing it would newly admit dotted fork names, which is a behaviour change and not a cleanup. A comment now records why the two coexist. The empty-name fixture also generates its owner DID per run. It writes to a fixed `/tmp/<owner_slug>`, so a shared constant let two concurrent `cargo test` processes delete each other's repo mid-test (reported by Greptile).
1 parent a7257dc commit 02fba70

2 files changed

Lines changed: 80 additions & 13 deletions

File tree

‎crates/gitlawb-node/src/api/repos.rs‎

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3046,6 +3046,25 @@ pub async fn fork_repo(
30463046
));
30473047
}
30483048

3049+
// Same barrier every other repo-creation route goes through. The character
3050+
// allowlist above is vacuously true on an empty name, and `repo_disk_path`
3051+
// is a raw join, so without this a `{"name":""}` fork lands a row with an
3052+
// empty name at `<repos_dir>/<owner_slug>/.git`. Closed #272 fixed this
3053+
// class on the sync route and never scoped fork.
3054+
//
3055+
// It runs with the other admissibility checks, above the proof spend and the
3056+
// source acquire, because the header comment promises that a fork rejected
3057+
// for a bad name never burns a valid proof. The two name rules are kept
3058+
// separate on purpose: neither is a subset of the other, since the allowlist
3059+
// above rejects a dot (`v1.2.3`) that `validate_repo_name` accepts, and
3060+
// accepts a non-ASCII alphanumeric that it rejects.
3061+
let disk_path = crate::git::repo_store::validated_repo_disk_path(
3062+
&state.config.repos_dir,
3063+
&forker_did,
3064+
&fork_name,
3065+
)
3066+
.map_err(|e| AppError::BadRequest(e.to_string()))?;
3067+
30493068
// Check no name conflict under the forker's ownership
30503069
let forker_short = crate::db::normalize_owner_key(&forker_did);
30513070
if state.db.get_repo(forker_short, &fork_name).await?.is_some() {
@@ -3064,18 +3083,6 @@ pub async fn fork_repo(
30643083
.await
30653084
.map_err(|e| AppError::Git(e.to_string()))?;
30663085

3067-
// Same barrier every other repo-creation route goes through. The character
3068-
// allowlist above is vacuously true on an empty name, and `repo_disk_path`
3069-
// is a raw join, so without this a `{"name":""}` fork lands a row with an
3070-
// empty name at `<repos_dir>/<owner_slug>/.git`. Closed #272 fixed this
3071-
// class on the sync route and never scoped fork.
3072-
let disk_path = crate::git::repo_store::validated_repo_disk_path(
3073-
&state.config.repos_dir,
3074-
&forker_did,
3075-
&fork_name,
3076-
)
3077-
.map_err(|e| AppError::BadRequest(e.to_string()))?;
3078-
30793086
// Clone the source repo as a mirror
30803087
let output = std::process::Command::new("git")
30813088
.args([

‎crates/gitlawb-node/src/test_support.rs‎

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,17 @@ mod tests {
573573
// `repo_store::for_testing` pins its on-disk root to /tmp, so the config
574574
// the handler reads at the raw join must name the same root or the two
575575
// halves of this test look at different directories.
576-
let owner = "did:key:zFORKEMPTYNAMEAAAAAAAAAAAAAAAAAAAAAAAAAA";
576+
// A per-run owner: the fixture path is a fixed /tmp/<slug>, so a shared
577+
// constant lets two concurrent `cargo test` PROCESSES delete each
578+
// other's repo mid-test.
579+
let owner = format!(
580+
"did:key:zFORKEMPTY{}",
581+
gitlawb_core::identity::Keypair::generate()
582+
.did()
583+
.to_string()
584+
.replace("did:key:", "")
585+
);
586+
let owner = owner.as_str();
577587
let owner_slug = owner.replace([':', '/'], "_");
578588
let repos_dir = std::path::PathBuf::from("/tmp");
579589
let owner_dir = repos_dir.join(&owner_slug);
@@ -774,6 +784,56 @@ mod tests {
774784
);
775785
}
776786

787+
/// The name barrier must run BEFORE the proof spend and the source acquire.
788+
/// `fork_repo`'s header comment promises that a fork rejected for a bad name
789+
/// never burns a valid proof, and the first version of this fix validated
790+
/// after both, so an empty name paid a Tigris download and spent the proof
791+
/// before its 400.
792+
///
793+
/// The seam that pins the order without iCaptcha plumbing: seed a repo ROW
794+
/// whose bytes are not on disk. If validation still ran after the acquire,
795+
/// the acquire fails first and the caller sees a 500 from git. A 400 proves
796+
/// the refusal came first.
797+
#[sqlx::test]
798+
async fn fork_refuses_a_bad_name_before_acquiring_the_source(pool: PgPool) {
799+
let owner = "did:key:zFORKORDERAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
800+
let repos_dir = std::path::PathBuf::from("/tmp");
801+
let state = test_state_with(pool, |cfg| cfg.repos_dir = repos_dir.clone()).await;
802+
803+
// Row only. Nothing is written to disk, so `repo_store.acquire` cannot
804+
// succeed and any 500 here means validation ran too late.
805+
let repo = seed_repo(owner, "absent-source");
806+
state.db.create_repo(&repo).await.expect("seed source row");
807+
808+
let router = Router::new()
809+
.route(
810+
"/api/v1/repos/{owner}/{repo}/fork",
811+
axum::routing::post(crate::api::repos::fork_repo),
812+
)
813+
.with_state(state.clone());
814+
let uri = format!("/api/v1/repos/{owner}/absent-source/fork");
815+
let resp = router
816+
.oneshot(signed_request_as(
817+
owner,
818+
Method::POST,
819+
&uri,
820+
Body::from(r#"{"name":""}"#),
821+
))
822+
.await
823+
.unwrap();
824+
825+
let status = resp.status();
826+
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
827+
.await
828+
.expect("read body");
829+
let body = String::from_utf8_lossy(&body);
830+
assert_eq!(
831+
status,
832+
StatusCode::BAD_REQUEST,
833+
"a bad fork name must be refused before the source acquire, not after; body={body}"
834+
);
835+
}
836+
777837
/// Fixed-path temp cleanup that survives a panic (the suite's convention;
778838
/// `TempDir` cannot own a path this test must compute up front).
779839
struct OwnerDirGuard(std::path::PathBuf);

0 commit comments

Comments
 (0)