Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ time once updated.

**Requirements:**
- Rust stable (≥ 1.91) — install via [rustup](https://rustup.rs)
- PostgreSQL — required for the node. Use the bundled `docker-compose.yml` for local dev.
- PostgreSQL 16+ — required for the node (it uses `pg_input_is_valid`, which exists only on PostgreSQL 16 and later). Use the bundled `docker-compose.yml` for local dev.
- Docker (optional, for full-stack local testing)

**Environment variables:**
Expand Down
23 changes: 12 additions & 11 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ Good today:

- Local or Docker node startup.
- Postgres-backed repo metadata.
- Bounded GraphQL queries with [repository pagination](docs/graphql-pagination.md).
- Bare git repository storage.
- Git smart-HTTP clone/fetch/push.
- RFC 9421-signed writes.
Expand Down
3 changes: 2 additions & 1 deletion crates/gitlawb-node/src/api/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ use crate::state::AppState;

/// Hard ceiling on rows any ref-update feed returns in one request. Shared by the
/// shared collector's clamp and the per-handler request caps so they can't drift.
const MAX_VISIBLE_REF_UPDATES: i64 = 200;
/// `pub(crate)` so the GraphQL complexity meter can price against the same bound.
pub(crate) const MAX_VISIBLE_REF_UPDATES: i64 = 200;

/// Collect up to `limit` ref-update rows visible to `caller`, newest first,
/// paging past rows the feed gate drops. Filtering after a plain SQL `LIMIT`
Expand Down
18 changes: 13 additions & 5 deletions crates/gitlawb-node/src/api/ipfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8338,6 +8338,7 @@ mod tests {
#[tokio::test]
async fn get_by_cid_per_source_cap_sheds_same_source_admits_other() {
let mut state = crate::test_support::test_state_lazy();
state.db.pool().close().await;
// Global pool has room; the per-source cap is 1.
state.git_ipfs_walk_semaphore = Arc::new(Semaphore::new(8));
state.git_ipfs_walk_per_caller = crate::rate_limit::PerCallerConcurrency::new(1, 100);
Expand All @@ -8364,16 +8365,23 @@ mod tests {
"a source at its per-source /ipfs walk cap must shed 503 with global capacity free"
);

let bytes = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(body["error"], "overloaded");

// A DIFFERENT source is NOT shed by the per-source cap: it clears admission and
// proceeds (then errors on the lazy DB, which is not a 503).
// proceeds to the closed DB, which has a distinct db_unavailable error code.
let resp = ipfs_router(state)
.oneshot(get_cid(&cid, Some(other)))
.await
.unwrap();
assert_ne!(
resp.status(),
StatusCode::SERVICE_UNAVAILABLE,
"a different source must not be shed by the per-source cap"
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let bytes = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
body["error"],
crate::error::DB_UNAVAILABLE_CODE,
"a different source must clear admission and reach the closed database"
);
}

Expand Down
113 changes: 94 additions & 19 deletions crates/gitlawb-node/src/api/repos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,40 @@ pub struct InfoRefsQuery {

// ── Handlers ──────────────────────────────────────────────────────────────

/// Validate a repository name at the create/fork write paths. The charset is
/// restricted, and the length is bounded so every cursor the server emits
/// parses back: `repo_cursor` embeds the name in a base64(JSON) cursor that
/// `parse_repo_cursor` rejects over 4096 bytes (#465 4th round).
///
/// This is the API-side validator, and it deliberately differs from the
/// stricter disk-side `validate_repo_name` in `git/repo_store.rs`: this one
/// admits names the store rejects (empty, leading `-`, non-ASCII
/// alphanumeric) and rejects `.`, which the store allows. The divergence
/// predates this bound (#412 owns the fork half), so a store-rejected name
/// still fails later at `repo_store.init` after the proof is spent. Do not
/// "fix" the divergence here; the length bound below is the one that keeps
/// emitted cursors parseable, and it is shared via `MAX_REPO_NAME_LEN` so the
/// two validators cannot drift apart on length.
fn validate_repo_name(name: &str) -> Result<()> {
// Sanitize name: alphanumeric, hyphens, underscores only
if !name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(AppError::BadRequest(
"repo name must contain only alphanumeric characters, hyphens, and underscores".into(),
));
}
// Byte length, not char count: the cursor budget is measured in bytes.
if name.len() > crate::db::MAX_REPO_NAME_LEN {
return Err(AppError::BadRequest(format!(
"repo name must be at most {} bytes",
crate::db::MAX_REPO_NAME_LEN
)));
}
Ok(())
}

/// POST /api/v1/repos
/// Create a new repository. Requires HTTP Signature auth.
pub async fn create_repo(
Expand All @@ -231,16 +265,7 @@ pub async fn create_repo(
// rejected request (bad name, already exists) never burns a valid proof.
let proof = crate::icaptcha::verify_request(&headers, &auth.0)?;

// Sanitize name: alphanumeric, hyphens, underscores only
if !req
.name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(AppError::BadRequest(
"repo name must contain only alphanumeric characters, hyphens, and underscores".into(),
));
}
validate_repo_name(&req.name)?;

// Owner is the authenticated agent's DID
let owner_did = auth.0;
Expand Down Expand Up @@ -3036,15 +3061,7 @@ pub async fn fork_repo(
let fork_name = req.name.unwrap_or_else(|| source.name.clone());
let forker_did = auth.0;

// Validate fork name
if !fork_name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(AppError::BadRequest(
"repo name must contain only alphanumeric characters, hyphens, and underscores".into(),
));
}
validate_repo_name(&fork_name)?;

// Check no name conflict under the forker's ownership
let forker_short = crate::db::normalize_owner_key(&forker_did);
Expand Down Expand Up @@ -3343,6 +3360,50 @@ mod tests {
const OWNER_SHORT: &str = "z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH";
const STRANGER_DID: &str = "did:key:z6Mkffonly5tranger0000000000000000000000000000000";

#[test]
fn repo_name_validation_rejects_bad_charset_and_overlong_names() {
assert!(validate_repo_name("ok-name_123").is_ok());
assert!(validate_repo_name(&"a".repeat(crate::db::MAX_REPO_NAME_LEN)).is_ok());
// The bound is in bytes: the cursor budget is measured in bytes.
let overlong = "a".repeat(crate::db::MAX_REPO_NAME_LEN + 1);
let err = validate_repo_name(&overlong).unwrap_err();
assert!(
matches!(err, AppError::BadRequest(_)),
"overlong name must be a 400, got: {err:?}"
);
assert!(validate_repo_name("bad name!").is_err());
// Empty names were already admissible before the length bound; the
// bound does not change that.
assert!(validate_repo_name("").is_ok());
}

#[tokio::test]
async fn create_repo_rejects_overlong_name_through_handler() {
// Pins the wiring: `create_repo` must call `validate_repo_name`
// before any database access. The lazy state never connects, so
// deleting the call site would let the handler proceed to `get_repo`
// and fail with a connection error instead of BadRequest.
let state = crate::test_support::test_state_lazy();
let overlong = "a".repeat(crate::db::MAX_REPO_NAME_LEN + 1);
let err = create_repo(
State(state),
Extension(AuthenticatedDid(OWNER_DID.to_owned())),
axum::http::HeaderMap::new(),
Json(CreateRepoRequest {
name: overlong,
description: None,
is_public: true,
default_branch: "main".into(),
}),
)
.await
.unwrap_err();
assert!(
matches!(err, AppError::BadRequest(ref msg) if msg.contains("at most")),
"overlong name through create_repo must be a 400, got: {err:?}"
);
}

#[test]
fn upload_pack_request_finalizes_only_with_done_pktline() {
let want = "0032want 1111111111111111111111111111111111111111\n";
Expand Down Expand Up @@ -7124,6 +7185,7 @@ mod tests {
/// A pkt-line receive-pack body carrying one branch-create ref update, so the
/// handler's post-receive tail resolves a non-empty new-tip set (the delta
/// scan's git stages run).
#[cfg(unix)]
fn ref_update_body(new_sha: &str) -> axum::body::Bytes {
let line = format!("{ZERO_SHA} {new_sha} refs/heads/main");
axum::body::Bytes::from(format!("{:04x}{}0000", line.len() + 4, line))
Expand Down Expand Up @@ -9581,13 +9643,15 @@ mod tests {
)
}

#[cfg(unix)]
fn f2a_log(log: &std::path::Path) -> String {
std::fs::read_to_string(log).unwrap_or_default()
}

/// Withheld-walk children run so far. `ls-tree` is the walk's signature child
/// (`blob_paths` lists every reachable commit's tree); the delta scan and the
/// full-scan fallback use `rev-list` / `cat-file` instead.
#[cfg(unix)]
fn f2a_walks(log: &std::path::Path) -> usize {
f2a_log(log)
.lines()
Expand All @@ -9599,6 +9663,7 @@ mod tests {
/// the withheld walk actually runs rather than taking the no-rule shortcut).
/// The repo's on-disk path is passed to the tail directly, so no repo_store or
/// receive-pack plumbing is involved.
#[cfg(unix)]
async fn f2a_state(
pool: sqlx::PgPool,
git_bin: &str,
Expand Down Expand Up @@ -9630,6 +9695,7 @@ mod tests {
(state, rec)
}

#[cfg(unix)]
fn f2a_update(ref_name: &str, new_sha: &str) -> Vec<RefUpdate> {
vec![RefUpdate {
old_sha: ZERO_SHA.to_string(),
Expand All @@ -9638,6 +9704,7 @@ mod tests {
}]
}

#[cfg(unix)]
const F2A_PUSHER: &str = "did:key:z6MkF2aPusherAAAAAAAAAAAAAAAAAAAAAAAAAA";

/// Scenario 1 (the finding). A second rapid push to the same repo coalesces
Expand Down Expand Up @@ -9711,6 +9778,7 @@ mod tests {
}
/// Poll `cond` until it holds, with a bound so a regression fails the test
/// rather than hanging the suite.
#[cfg(unix)]
async fn f2a_wait_for(mut cond: impl FnMut() -> bool, what: &str) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
while !cond() {
Expand All @@ -9725,6 +9793,7 @@ mod tests {
/// A `rev-list --objects` line names the tips a DELTA scan was asked to resolve,
/// so it attributes that scan to one push's tips. The withheld walk's own
/// `rev-list --all` / `ls-tree` lines never carry a tip as an argument this way.
#[cfg(unix)]
fn f2a_delta_scanned(log: &std::path::Path, tip: &str) -> bool {
f2a_log(log)
.lines()
Expand Down Expand Up @@ -9871,6 +9940,7 @@ mod tests {

/// Mount a Pinata upload endpoint that assigns every object the same CID, and
/// point the state at it. Returns the server (kept alive by the caller) and CID.
#[cfg(unix)]
async fn f2a_pinata(state: &mut AppState) -> (mockito::ServerGuard, String) {
let cid = "bafyf2acoalescedmapping".to_string();
let mut server = mockito::Server::new_async().await;
Expand All @@ -9890,6 +9960,7 @@ mod tests {

/// Poll the branch to CID table until the push's mapping lands (the Pinata
/// worker is detached), bounded so a regression fails rather than hangs.
#[cfg(unix)]
async fn f2a_wait_for_branch_cid(
db: &crate::db::Db,
slug: &str,
Expand All @@ -9909,6 +9980,7 @@ mod tests {
}
}

#[cfg(unix)]
fn f2a_slug(rec: &crate::db::RepoRecord) -> String {
format!(
"{}/{}",
Expand Down Expand Up @@ -10042,6 +10114,7 @@ mod tests {
// and `z6p2fail`), and owner-only push is on by default, so the identity has to
// follow the repo each push targets rather than being fixed for both.

#[cfg(unix)]
fn p2_push(
state: &AppState,
owner: &str,
Expand All @@ -10060,6 +10133,7 @@ mod tests {
)
}

#[cfg(unix)]
fn p2_logged(log: &std::path::Path, prefix: &str) -> bool {
f2a_log(log).lines().any(|l| l.starts_with(prefix))
}
Expand Down Expand Up @@ -10359,6 +10433,7 @@ mod tests {
/// runs exactly one `rev-list --all`, so this counts the walks that were attempted
/// (the `ls-tree` counter above cannot: a walk whose enumeration fails never gets
/// to `ls-tree`).
#[cfg(unix)]
fn f2b_walk_attempts(log: &std::path::Path) -> usize {
f2a_log(log)
.lines()
Expand Down
Loading
Loading