fix(node): hold fork to the same repo-name rules as every other creation route

fork_repo built its clone destination with store::repo_disk_path, a raw
join with no validation, while create_repo and the sync mirror path both
go through validated_repo_disk_path. Its own name check is
`.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')`, which is
Unicode-aware and has no length or leading-character rule, so it admits
four shapes validate_repo_name refuses: the empty string (all() is
vacuously true on it), a non-ASCII alphanumeric such as "cafe" with an
acute e, a leading '-', and a name over the 100-char bound.

The empty name was the reachable defect. Driven through the production
router with a real Ed25519 signature, a `{"name":""}` fork returned 201
Created with a repo row whose name is "" and a clone_url ending in
`/.git`, materialized at `<repos_dir>/<owner_slug>/.git`. It now returns
400 and creates neither the row nor the directory.

This is a deliberate narrowing, not only an empty-name fix: the other
three shapes above are now 400 on fork where they previously reached the
raw join. None of them could ever have been created through create_repo,
which fails at repo_store.init on the same validator, and no existing row
can carry such a name (create_repo validates before its DB insert, and
mirror rows go through validate_repo_slug), so no repo becomes
unforkable. A fork that defaults to source.name is unaffected.

The traversal shape of the same join is NOT reachable and is not what
this changes: forker_did comes from AuthenticatedDid, which auth/mod.rs
inserts only after Did::to_verifying_key resolves a multibase-decodable
did:key, so it can carry neither ".." nor "/".

repo_disk_path is now cfg(test). It has no production caller left, and
the attribute turns the next one into a compile error rather than a
silent bypass. Tests keep it because a fixture that must escape repos_dir
cannot be built with the validated form.

Two tests. A unit test on the mounted handler, and an end-to-end test
through server::build_router with real signatures that covers all four
refused shapes plus a positive control asserting an ordinary name still
forks to disk and to a row. Both are mutation-verified in both
directions: reverting to the raw join reddens the refusals, and forcing
fork to always refuse reddens the control.

Closed #272 fixed this class on the sync route and pointed at
repo_disk_path as the sanitizing convention; it is in fact the
unvalidated join, and the fork route was never scoped.
This commit is contained in:
beardthelion committed 2026-09-07 20:45:37 -05:00
1 parent bfc44f926d
commit a7257dce20
3 files changed
+251 -3

No files matched your search

+11 -1
View File
@@ -3064,7 +3064,17 @@ pub async fn fork_repo(
.await
.map_err(|e| AppError::Git(e.to_string()))?;
let disk_path = store::repo_disk_path(&state.config.repos_dir, &forker_did, &fork_name);
// Same barrier every other repo-creation route goes through. The character
// allowlist above is vacuously true on an empty name, and `repo_disk_path`
// is a raw join, so without this a `{"name":""}` fork lands a row with an
// empty name at `<repos_dir>/<owner_slug>/.git`. Closed #272 fixed this
// class on the sync route and never scoped fork.
let disk_path = crate::git::repo_store::validated_repo_disk_path(
&state.config.repos_dir,
&forker_did,
&fork_name,
)
.map_err(|e| AppError::BadRequest(e.to_string()))?;
// Clone the source repo as a mirror
let output = std::process::Command::new("git")
+14 -2
View File
@@ -1,5 +1,5 @@
use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
use std::path::Path;
use std::process::Command;
/// Initialize a new bare git repository with SHA-1 object format (default).
@@ -870,7 +870,19 @@ pub fn merge_branch(
}
/// Resolve a repo disk path: {repos_dir}/{owner_slug}/{repo_name}.git
pub fn repo_disk_path(repos_dir: &Path, owner_did: &str, repo_name: &str) -> PathBuf {
///
/// UNVALIDATED, and test-only for that reason. It performs the bare join with no
/// allowlist, no containment check and no component walk, so it accepts an empty
/// or traversal-bearing name. Production code must use
/// `git::repo_store::validated_repo_disk_path`; the last production caller was
/// `fork_repo`, which used this and turned a `{"name":""}` request into a repo
/// row at `<repos_dir>/<owner_slug>/.git`. The `cfg(test)` is the guard: a new
/// production caller fails to compile rather than silently skipping the barrier.
///
/// Tests keep it because a fixture that must escape `repos_dir` cannot be built
/// with the validated form.
#[cfg(test)]
pub fn repo_disk_path(repos_dir: &Path, owner_did: &str, repo_name: &str) -> std::path::PathBuf {
// Sanitize the DID for use as a directory name
let owner_slug = owner_did.replace([':', '/'], "_");
repos_dir.join(owner_slug).join(format!("{repo_name}.git"))
+226
View File
@@ -557,6 +557,232 @@ mod tests {
);
}
/// A fork name that is empty passes `fork_repo`'s character allowlist
/// (`.chars().all(..)` is vacuously true on ""), and the handler then builds
/// its clone destination with the RAW `store::repo_disk_path`. Every other
/// repo-creation route goes through `validated_repo_disk_path`, whose
/// `validate_repo_name` rejects an empty name, so fork is the one entrypoint
/// that skips that barrier and lands a repo at `<repos_dir>/<owner>/.git`
/// instead of `<repos_dir>/<owner>/<name>.git`.
///
/// Closed #272 fixed this class on the sync route and named
/// `repo_disk_path` as the sanitizing convention; it is in fact the
/// unvalidated join, and the fork route was never scoped.
#[sqlx::test]
async fn fork_rejects_an_empty_name(pool: PgPool) {
// `repo_store::for_testing` pins its on-disk root to /tmp, so the config
// the handler reads at the raw join must name the same root or the two
// halves of this test look at different directories.
let owner = "did:key:zFORKEMPTYNAMEAAAAAAAAAAAAAAAAAAAAAAAAAA";
let owner_slug = owner.replace([':', '/'], "_");
let repos_dir = std::path::PathBuf::from("/tmp");
let owner_dir = repos_dir.join(&owner_slug);
let _cleanup = OwnerDirGuard(owner_dir.clone());
let _ = std::fs::remove_dir_all(&owner_dir);
let state = test_state_with(pool, |cfg| cfg.repos_dir = repos_dir.clone()).await;
let repo = seed_repo(owner, "source-repo");
state.db.create_repo(&repo).await.expect("seed repo");
// Put the source where `repo_store.acquire` reads, so the handler gets
// past acquire and the empty name is what this test measures.
let source_path = crate::git::store::repo_disk_path(&repos_dir, owner, "source-repo");
std::fs::create_dir_all(&owner_dir).expect("owner dir");
crate::git::store::init_bare(&source_path).expect("init source repo");
let router = Router::new()
.route(
"/api/v1/repos/{owner}/{repo}/fork",
axum::routing::post(crate::api::repos::fork_repo),
)
.with_state(state.clone());
let uri = format!("/api/v1/repos/{owner}/source-repo/fork");
let resp = router
.oneshot(signed_request_as(
owner,
Method::POST,
&uri,
Body::from(r#"{"name":""}"#),
))
.await
.unwrap();
let status = resp.status();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("read fork response body");
let body = String::from_utf8_lossy(&body);
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"an empty fork name must be refused, not turned into a '.git' path; body={body}"
);
// The barrier's real job: nothing may be created at <owner>/.git.
assert!(
!owner_dir.join(".git").exists(),
"an empty fork name must not materialize <repos_dir>/<owner>/.git"
);
let owner_short = owner.split(':').next_back().unwrap();
assert!(
state
.db
.get_repo(owner_short, "")
.await
.expect("get_repo")
.is_none(),
"no repo row may be created for a refused fork"
);
}
/// End-to-end companion to `fork_rejects_an_empty_name`, through the
/// PRODUCTION router with REAL Ed25519 signatures rather than an injected
/// `AuthenticatedDid`. `signed_request_as` only sets the extension, so the
/// unit test above proves the handler but says nothing about the request
/// actually clearing `require_signature` / `require_ucan_chain` and the
/// creation-route rate limiter that `build_router` puts in front of fork.
///
/// It also pins the FULL set the barrier refuses, not just the empty name.
/// The handler's own allowlist is `char::is_alphanumeric`, which is
/// Unicode-aware and has no length or leading-character rule, so it admits
/// "", "cafe\u{301}"-style names, a leading '-', and a 101-char name.
/// `validate_repo_name` is ASCII-only and bounded, so routing fork through
/// the shared barrier narrows all five at once. The positive control is what
/// keeps that from being a blanket refusal: an ordinary name must still fork.
#[sqlx::test]
async fn fork_name_rules_match_the_shared_barrier_e2e(pool: PgPool) {
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
let kp = Keypair::generate();
let owner_did = kp.did().to_string();
let short = owner_did.split(':').next_back().unwrap().to_string();
// repo_store::for_testing pins its root to /tmp; the handler reads
// config.repos_dir at the join, so both must name the same root.
let repos_dir = std::path::PathBuf::from("/tmp");
let owner_slug = owner_did.replace([':', '/'], "_");
let owner_dir = repos_dir.join(&owner_slug);
let _cleanup = OwnerDirGuard(owner_dir.clone());
let state = test_state_with(pool, |cfg| cfg.repos_dir = repos_dir.clone()).await;
let source = seed_repo(&owner_did, "source-repo");
state
.db
.create_repo(&source)
.await
.expect("seed source repo");
let source_path = crate::git::store::repo_disk_path(&repos_dir, &owner_did, "source-repo");
std::fs::create_dir_all(&owner_dir).expect("owner dir");
crate::git::store::init_bare(&source_path).expect("init source repo");
let path = format!("/api/v1/repos/{short}/source-repo/fork");
let long_name = "x".repeat(101);
// Every name the handler's own allowlist admits but the shared barrier
// refuses. Each must now be a 400 through the real stack.
let refused: [(&str, &str); 4] = [
("", "empty"),
("caf\u{e9}", "non-ascii alphanumeric"),
("-lead", "leading hyphen"),
(long_name.as_str(), "over the 100-char bound"),
];
for (name, why) in refused {
let body = serde_json::to_vec(&serde_json::json!({ "name": name })).unwrap();
let signed = sign_request(&kp, "POST", &path, &body);
let req = Request::builder()
.method(Method::POST)
.uri(&path)
.header("content-type", "application/json")
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature)
.body(Body::from(body))
.unwrap();
let resp = crate::server::build_router(state.clone())
.oneshot(req)
.await
.unwrap();
let status = resp.status();
let rb = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let rb = String::from_utf8_lossy(&rb);
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"a fork name that is {why} must be refused through the real router; body={rb}"
);
}
// The specific artifact the empty name used to leave behind.
assert!(
!owner_dir.join(".git").exists(),
"no fork may materialize <repos_dir>/<owner_slug>/.git"
);
assert!(
state
.db
.get_repo(&short, "")
.await
.expect("get_repo")
.is_none(),
"no repo row may be created for a refused fork"
);
// Positive control: an ordinary name still forks, all the way to disk
// and a row. Without this the four assertions above would also pass if
// the barrier rejected everything.
let body = serde_json::to_vec(&serde_json::json!({ "name": "forked-ok" })).unwrap();
let signed = sign_request(&kp, "POST", &path, &body);
let req = Request::builder()
.method(Method::POST)
.uri(&path)
.header("content-type", "application/json")
.header("content-digest", signed.content_digest)
.header("signature-input", signed.signature_input)
.header("signature", signed.signature)
.body(Body::from(body))
.unwrap();
let resp = crate::server::build_router(state.clone())
.oneshot(req)
.await
.unwrap();
let status = resp.status();
let rb = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let rb = String::from_utf8_lossy(&rb);
assert_eq!(
status,
StatusCode::CREATED,
"an ordinary fork name must still succeed through the real router; body={rb}"
);
assert!(
owner_dir.join("forked-ok.git").is_dir(),
"the accepted fork must exist on disk at <repos_dir>/<owner_slug>/forked-ok.git"
);
assert!(
state
.db
.get_repo(&short, "forked-ok")
.await
.expect("get_repo")
.is_some(),
"the accepted fork must have a repo row"
);
}
/// Fixed-path temp cleanup that survives a panic (the suite's convention;
/// `TempDir` cannot own a path this test must compute up front).
struct OwnerDirGuard(std::path::PathBuf);
impl Drop for OwnerDirGuard {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// N13: the task handlers bind the acting DID to the signer. A caller signed
/// as B claiming delegator_did A is rejected before any DB write (DB-free).
#[sqlx::test]