mirror of
https://github.com/Twigpine/node.git
synced 2026-11-08 15:39:37 -10:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c8c29a998 | ||
|
|
a66fef7a2a | ||
|
|
8fa81f47a3 | ||
|
|
ef167bb25a | ||
|
|
68133409d5 | ||
|
|
0484740638 | ||
|
|
1e6b065724 | ||
|
|
1676eb0e4c |
No files matched your search
@@ -39,12 +39,6 @@ GITLAWB_KEY=/data/keys/identity.pem
|
||||
# Publicly reachable URL of this node (used in peer announcements)
|
||||
GITLAWB_PUBLIC_URL=https://your-node.example.com
|
||||
|
||||
# Base URL for the web view of repos on this node (used by `gl` to print a
|
||||
# working View: link after repo creation). Omit for nodes with no web front-end.
|
||||
# Distinct from GITLAWB_PUBLIC_URL: that is API reachability for peers;
|
||||
# this is browser reachability for humans.
|
||||
# GITLAWB_WEB_URL=https://gitlawb.com
|
||||
|
||||
# ── Server ────────────────────────────────────────────────────────────────
|
||||
GITLAWB_HOST=0.0.0.0
|
||||
GITLAWB_PORT=7545
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ time once updated.
|
||||
|
||||
**Requirements:**
|
||||
- Rust stable (≥ 1.91) — install via [rustup](https://rustup.rs)
|
||||
- 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.
|
||||
- PostgreSQL — required for the node. Use the bundled `docker-compose.yml` for local dev.
|
||||
- Docker (optional, for full-stack local testing)
|
||||
|
||||
**Environment variables:**
|
||||
|
||||
Generated
+8
-12
@@ -2208,7 +2208,7 @@ version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2701,7 +2701,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de"
|
||||
dependencies = [
|
||||
"data-encoding",
|
||||
"syn 1.0.109",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3085,7 +3085,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3563,7 +3563,6 @@ dependencies = [
|
||||
"libp2p-swarm",
|
||||
"mockito",
|
||||
"multiaddr",
|
||||
"percent-encoding",
|
||||
"prometheus",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@@ -3581,7 +3580,6 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"unicode-normalization",
|
||||
"url",
|
||||
"uuid",
|
||||
"zeroize",
|
||||
"zstd",
|
||||
@@ -3601,7 +3599,6 @@ dependencies = [
|
||||
"gitlawb-core",
|
||||
"icaptcha-client",
|
||||
"mockito",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3610,7 +3607,6 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
]
|
||||
@@ -3646,9 +3642,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.19"
|
||||
version = "0.4.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||
checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -6227,7 +6223,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7161,7 +7157,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7953,7 +7949,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-3
@@ -54,10 +54,8 @@ uuid = { version = "1", features = ["v4"] }
|
||||
# http client
|
||||
reqwest = { version = "0.12", features = ["blocking", "json", "multipart", "rustls-tls"], default-features = false }
|
||||
# URL parsing (what reqwest::Url re-exports, so the shared redirect predicate can
|
||||
# take a parsed URL without pulling reqwest into gitlawb-core; also used for the
|
||||
# absolute-browser-url validation on GITLAWB_WEB_URL in the node and CLI)
|
||||
# take a parsed URL without pulling reqwest into gitlawb-core)
|
||||
url = "2"
|
||||
percent-encoding = "2"
|
||||
# HMAC
|
||||
hmac = "0.12"
|
||||
|
||||
|
||||
@@ -53,7 +53,6 @@ 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.
|
||||
@@ -312,7 +311,6 @@ metadata local disk / optional S3
|
||||
| DID | A user, agent, or node identity derived from an Ed25519 public key. |
|
||||
| HTTP Signature | RFC 9421 signature proving control of the DID key for write requests. |
|
||||
| Ref certificate | Signed record of a ref update. Useful for audit and replication. |
|
||||
| Ref certificate versioning | The wire format is versioned; new certificates are currently issued as v1. `gl cert show <repo> <id> --verify` requires `--expect-node <did>`, and the queried node's self-reported DID is **not** accepted as a trust anchor. v2 is reserved for a future payload shape that carries the version inside the signed bytes; this client refuses to verify v2+ rather than guessing. |
|
||||
| UCAN | Delegation token for future capability-based workflows. |
|
||||
| Peer announce | Node-to-node HTTP announcement of DID + public URL. |
|
||||
| Gossipsub | libp2p topic for ref-update events. |
|
||||
|
||||
@@ -23,8 +23,6 @@ tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
url = { workspace = true }
|
||||
percent-encoding = { workspace = true }
|
||||
axum = { version = "0.8", features = ["http1", "http2", "json", "ws"] }
|
||||
async-graphql = { version = "7", features = ["chrono", "uuid", "tracing"] }
|
||||
async-graphql-axum = "7"
|
||||
|
||||
@@ -52,7 +52,6 @@ pub async fn list_certs(
|
||||
"node_did": c.node_did,
|
||||
"signature": c.signature,
|
||||
"issued_at": c.issued_at,
|
||||
"version": c.version,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -93,6 +92,5 @@ pub async fn get_cert(
|
||||
"node_did": cert.node_did,
|
||||
"signature": cert.signature,
|
||||
"issued_at": cert.issued_at,
|
||||
"version": cert.version,
|
||||
})))
|
||||
}
|
||||
@@ -11,8 +11,7 @@ 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.
|
||||
/// `pub(crate)` so the GraphQL complexity meter can price against the same bound.
|
||||
pub(crate) const MAX_VISIBLE_REF_UPDATES: i64 = 200;
|
||||
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`
|
||||
@@ -438,7 +437,6 @@ mod ref_updates_feed_tests {
|
||||
node_did: "did:key:z6MkNode".into(),
|
||||
signature: "sig".into(),
|
||||
issued_at: Utc::now().to_rfc3339(),
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8367,7 +8367,6 @@ 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);
|
||||
@@ -8394,23 +8393,16 @@ 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 to the closed DB, which has a distinct db_unavailable error code.
|
||||
// proceeds (then errors on the lazy DB, which is not a 503).
|
||||
let resp = ipfs_router(state)
|
||||
.oneshot(get_cid(&cid, Some(other)))
|
||||
.await
|
||||
.unwrap();
|
||||
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"
|
||||
assert_ne!(
|
||||
resp.status(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"a different source must not be shed by the per-source cap"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -247,40 +247,6 @@ 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(
|
||||
@@ -295,7 +261,16 @@ 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)?;
|
||||
|
||||
validate_repo_name(&req.name)?;
|
||||
// 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(),
|
||||
));
|
||||
}
|
||||
|
||||
// default_branch is caller-supplied and becomes a PR's target_branch when the
|
||||
// PR omits one, which is interpolated into a git revision argument. Validate
|
||||
@@ -3799,7 +3774,15 @@ pub async fn fork_repo(
|
||||
let fork_name = req.name.unwrap_or_else(|| source.name.clone());
|
||||
let forker_did = auth.0;
|
||||
|
||||
validate_repo_name(&fork_name)?;
|
||||
// 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(),
|
||||
));
|
||||
}
|
||||
|
||||
// Same barrier every other repo-creation route goes through. The character
|
||||
// allowlist above is vacuously true on an empty name, and `repo_disk_path`
|
||||
@@ -5066,50 +5049,6 @@ mod tests {
|
||||
peer_mock.assert_async().await;
|
||||
}
|
||||
|
||||
#[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";
|
||||
@@ -9240,7 +9179,6 @@ 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))
|
||||
@@ -11698,7 +11636,6 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn f2a_log(log: &std::path::Path) -> String {
|
||||
std::fs::read_to_string(log).unwrap_or_default()
|
||||
}
|
||||
@@ -11706,7 +11643,6 @@ mod tests {
|
||||
/// 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()
|
||||
@@ -11718,7 +11654,6 @@ 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,
|
||||
@@ -11750,7 +11685,6 @@ 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(),
|
||||
@@ -11759,7 +11693,6 @@ mod tests {
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
const F2A_PUSHER: &str = "did:key:z6MkF2aPusherAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
/// When publish durability is fenced, the tail must not run walks or take
|
||||
@@ -11872,7 +11805,6 @@ 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() {
|
||||
@@ -11887,7 +11819,6 @@ 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()
|
||||
@@ -12036,7 +11967,6 @@ 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;
|
||||
@@ -12056,7 +11986,6 @@ 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,
|
||||
@@ -12076,7 +12005,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn f2a_slug(rec: &crate::db::RepoRecord) -> String {
|
||||
format!(
|
||||
"{}/{}",
|
||||
@@ -12211,7 +12139,6 @@ 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,
|
||||
@@ -12230,7 +12157,6 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn p2_logged(log: &std::path::Path, prefix: &str) -> bool {
|
||||
f2a_log(log).lines().any(|l| l.starts_with(prefix))
|
||||
}
|
||||
@@ -13226,7 +13152,6 @@ exit 0
|
||||
/// 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()
|
||||
|
||||
+10
-416
@@ -11,74 +11,6 @@ use uuid::Uuid;
|
||||
use crate::db::RefCertificate;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Build the v1 signing payload — the EXACT bytes the node signs and
|
||||
/// gl's `verify_signature` reconstructs. This is the single source
|
||||
/// of truth shared by `issue_ref_certificate` and the frozen
|
||||
/// canonical-form tests below; drift here is what would break every
|
||||
/// existing cert.
|
||||
///
|
||||
/// Field order and key set are fixed: a default `serde_json::Value`
|
||||
/// serializes `Map<String, Value>` with sorted keys, so any change to
|
||||
/// the literal is observable as a different byte sequence on the
|
||||
/// wire.
|
||||
///
|
||||
/// v1 is the unversioned legacy shape: no `version` key in the signed
|
||||
/// JSON. From v2 onward the version belongs INSIDE the signed bytes
|
||||
/// (see `v2_signing_payload`): leaving it as an unsigned sibling
|
||||
/// would let a future v2 cert be downgraded to v1 and still verify
|
||||
/// cleanly, since the bytes would be identical.
|
||||
pub(crate) fn v1_signing_payload(
|
||||
repo_id: &str,
|
||||
ref_name: &str,
|
||||
old_sha: &str,
|
||||
new_sha: &str,
|
||||
pusher_did: &str,
|
||||
node_did: &str,
|
||||
issued_at: &str,
|
||||
) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"repo_id": repo_id,
|
||||
"ref": ref_name,
|
||||
"old": old_sha,
|
||||
"new": new_sha,
|
||||
"pusher": pusher_did,
|
||||
"node": node_did,
|
||||
"ts": issued_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the v2 signing payload — the future shape for versioned
|
||||
/// certs. Identical to v1 except the wire `version` is part of the
|
||||
/// signed bytes, so stripping or flipping the `version` column
|
||||
/// invalidates the signature instead of verifying cleanly under the
|
||||
/// other version's path.
|
||||
///
|
||||
/// NOT yet issued: `issue_ref_certificate` still stamps v1 because
|
||||
/// the shipped `gl` verifier only supports v1. This builder exists
|
||||
/// to pin the downgrade-resistant shape now, before any v2 cert is
|
||||
/// ever signed.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn v2_signing_payload(
|
||||
repo_id: &str,
|
||||
ref_name: &str,
|
||||
old_sha: &str,
|
||||
new_sha: &str,
|
||||
pusher_did: &str,
|
||||
node_did: &str,
|
||||
issued_at: &str,
|
||||
) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"repo_id": repo_id,
|
||||
"ref": ref_name,
|
||||
"old": old_sha,
|
||||
"new": new_sha,
|
||||
"pusher": pusher_did,
|
||||
"node": node_did,
|
||||
"ts": issued_at,
|
||||
"version": 2,
|
||||
})
|
||||
}
|
||||
|
||||
/// Issue a signed ref-update certificate for a successful push.
|
||||
///
|
||||
/// Builds a canonical JSON payload, signs it with the node's Ed25519 key,
|
||||
@@ -94,12 +26,16 @@ pub async fn issue_ref_certificate(
|
||||
let node_did = state.node_did.to_string();
|
||||
let issued_at = Utc::now().to_rfc3339();
|
||||
|
||||
// Build the canonical signing payload via the shared builder so
|
||||
// the frozen-vector test below and the live signer cannot drift
|
||||
// apart — a regression in either side fails both.
|
||||
let payload = v1_signing_payload(
|
||||
repo_id, ref_name, old_sha, new_sha, pusher_did, &node_did, &issued_at,
|
||||
);
|
||||
// Build the canonical signing payload.
|
||||
let payload = serde_json::json!({
|
||||
"repo_id": repo_id,
|
||||
"ref": ref_name,
|
||||
"old": old_sha,
|
||||
"new": new_sha,
|
||||
"pusher": pusher_did,
|
||||
"node": node_did,
|
||||
"ts": issued_at,
|
||||
});
|
||||
let payload_bytes = serde_json::to_vec(&payload)?;
|
||||
|
||||
let signature = state.node_keypair.sign_b64(&payload_bytes);
|
||||
@@ -114,351 +50,9 @@ pub async fn issue_ref_certificate(
|
||||
node_did,
|
||||
signature,
|
||||
issued_at,
|
||||
// #26 Split PR 3: the wire-format version. New certs stamp
|
||||
// v1 — the pre-versioning 7-field payload with no `version`
|
||||
// key in the signed JSON — because the shipped `gl`
|
||||
// verifier only supports v1. Stamping v2 here would issue
|
||||
// certificates the shipped client refuses (round-3
|
||||
// finding: node pinned `version == 2` while gl pinned
|
||||
// "2 is refused", with nothing exercising the
|
||||
// composition). v2 is reserved for the future shape
|
||||
// defined by `v2_signing_payload`, which carries the
|
||||
// version INSIDE the signed bytes; land the v2 verify
|
||||
// path before stamping v2.
|
||||
version: 1,
|
||||
};
|
||||
|
||||
// Persist and return the row as it exists in the database (on a
|
||||
// conflict the existing row survives when it is newer).
|
||||
state.db.insert_ref_certificate(&cert).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod v1_payload_tests {
|
||||
//! #26 Split PR 3 — cert payload version compat.
|
||||
//!
|
||||
//! The v1 cert payload is the frozen canonical byte form the
|
||||
//! node signs. gl's `verify_signature` reconstructs the same
|
||||
//! payload and verifies. Any drift in field order, key set, or
|
||||
//! whitespace breaks every existing cert. The tests here pin
|
||||
//! the v1 payload shape, the round-trip, the cert id
|
||||
//! determinism per the v1 version, and the v2 read forward
|
||||
//! compat (an unknown version refuses to verify rather than
|
||||
//! guessing).
|
||||
//!
|
||||
//! Reviewer 1 finding: the live signer (`issue_ref_certificate`)
|
||||
//! and the frozen-vector test (`v1_payload_matches_frozen_canonical_form`)
|
||||
//! previously held two separate `serde_json::json!` literals,
|
||||
//! so a regression in the signer could not fail this test.
|
||||
//! Both now go through the shared `v1_signing_payload` builder.
|
||||
use super::{v1_signing_payload, v2_signing_payload};
|
||||
use crate::db::RefCertificate;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
/// v1 payload is the same shape the legacy code signed, byte
|
||||
/// for byte. Reverting this assertion to the v2 shape (which
|
||||
/// includes a `version` key) is what the versioned format
|
||||
/// forbids: an old client reading a v1 cert must not see a
|
||||
/// `version` key in the signed JSON.
|
||||
///
|
||||
/// The literal is built via the shared `v1_signing_payload`
|
||||
/// helper that `issue_ref_certificate` also uses, so the live
|
||||
/// signer and the frozen vector share one source of truth.
|
||||
#[test]
|
||||
fn v1_payload_matches_frozen_canonical_form() {
|
||||
let payload = v1_signing_payload(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
&"0".repeat(40),
|
||||
&"a".repeat(40),
|
||||
"did:key:z6MkPusher",
|
||||
"did:key:z6MkNode",
|
||||
"2026-07-22T00:00:00+00:00",
|
||||
);
|
||||
let frozen = format!(
|
||||
r#"{{"new":"{}","node":"did:key:z6MkNode","old":"{}","pusher":"did:key:z6MkPusher","ref":"refs/heads/main","repo_id":"repo-1","ts":"2026-07-22T00:00:00+00:00"}}"#,
|
||||
"a".repeat(40),
|
||||
"0".repeat(40),
|
||||
);
|
||||
assert_eq!(serde_json::to_string(&payload).unwrap(), frozen);
|
||||
}
|
||||
|
||||
/// Round-trip: sign the v1 payload with a keypair, build a
|
||||
/// RefCertificate with version: 1, and verify the structure.
|
||||
/// This is the construction the live code does on every push.
|
||||
///
|
||||
/// Uses the shared builder for both halves so a regression in
|
||||
/// the live signer (adding/removing/renaming a field) cannot
|
||||
/// pass this test by accident.
|
||||
#[test]
|
||||
fn v1_ref_certificate_structure_is_well_formed() {
|
||||
let kp = Keypair::generate();
|
||||
let node_did = kp.did().as_str().to_string();
|
||||
let payload = v1_signing_payload(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
&"0".repeat(40),
|
||||
&"a".repeat(40),
|
||||
"did:key:z6MkPusher",
|
||||
&node_did,
|
||||
"2026-07-22T00:00:00+00:00",
|
||||
);
|
||||
let payload_bytes = serde_json::to_vec(&payload).unwrap();
|
||||
let sig = kp.sign_b64(&payload_bytes);
|
||||
let cert = RefCertificate {
|
||||
id: "cert-id".into(),
|
||||
repo_id: "repo-1".into(),
|
||||
ref_name: "refs/heads/main".into(),
|
||||
old_sha: "0".repeat(40),
|
||||
new_sha: "a".repeat(40),
|
||||
pusher_did: "did:key:z6MkPusher".into(),
|
||||
node_did: node_did.clone(),
|
||||
signature: sig.clone(),
|
||||
issued_at: "2026-07-22T00:00:00+00:00".into(),
|
||||
version: 1,
|
||||
};
|
||||
assert_eq!(cert.version, 1, "new certs carry version: 1");
|
||||
// The signed payload reconstructs identically: gl's
|
||||
// verify_signature would build the same JSON, hash the
|
||||
// same bytes, and verify the same signature.
|
||||
let reconstructed = v1_signing_payload(
|
||||
&cert.repo_id,
|
||||
&cert.ref_name,
|
||||
&cert.old_sha,
|
||||
&cert.new_sha,
|
||||
&cert.pusher_did,
|
||||
&cert.node_did,
|
||||
&cert.issued_at,
|
||||
);
|
||||
let reconstructed_bytes = serde_json::to_vec(&reconstructed).unwrap();
|
||||
assert_eq!(
|
||||
reconstructed_bytes, payload_bytes,
|
||||
"the round-trip serialization must be byte-identical; \
|
||||
this is what makes gl's verify_signature succeed"
|
||||
);
|
||||
// Round 3 (P3 reviewer): byte-equality alone never calls
|
||||
// `identity::verify`, so a bad signature helper would pass.
|
||||
// Verify the computed signature against the reconstructed
|
||||
// bytes — the same check gl performs.
|
||||
let vk = node_did
|
||||
.parse::<gitlawb_core::did::Did>()
|
||||
.unwrap()
|
||||
.to_verifying_key()
|
||||
.unwrap();
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
let sig_bytes: [u8; 64] = URL_SAFE_NO_PAD
|
||||
.decode(&cert.signature)
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
gitlawb_core::identity::verify(&vk, &reconstructed_bytes, &sig_bytes)
|
||||
.expect("well-formed v1 cert signature must verify");
|
||||
}
|
||||
|
||||
/// Reviewer 1 finding: the live signer must share the
|
||||
/// canonical-form literal so a regression in either side
|
||||
/// fails both. This test pins the shared builder's output
|
||||
/// against the literal a future v2 byte stream would have
|
||||
/// to break — if someone adds a `version` key to
|
||||
/// `v1_signing_payload`, every existing cert breaks verify,
|
||||
/// and this test is one of the canaries that fires.
|
||||
#[test]
|
||||
fn v1_signing_payload_has_no_version_key() {
|
||||
let payload = v1_signing_payload(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
"oldsha",
|
||||
"newsha",
|
||||
"did:key:z6MkPusher",
|
||||
"did:key:z6MkNode",
|
||||
"2026-07-22T00:00:00+00:00",
|
||||
);
|
||||
let obj = payload.as_object().expect("payload is a JSON object");
|
||||
assert!(
|
||||
!obj.contains_key("version"),
|
||||
"v1 signing payload must not carry a `version` key — adding \
|
||||
one changes the bytes and breaks every existing cert's \
|
||||
signature. v2 certs build a different payload, not this one"
|
||||
);
|
||||
// Pin the exact set so a future field addition is caught.
|
||||
let expected: std::collections::BTreeSet<&str> =
|
||||
["new", "node", "old", "pusher", "ref", "repo_id", "ts"]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let actual: std::collections::BTreeSet<&str> = obj.keys().map(String::as_str).collect();
|
||||
assert_eq!(actual, expected, "v1 payload key set is frozen");
|
||||
}
|
||||
|
||||
/// Round 3 (P2 reviewer): from v2 onward the version belongs
|
||||
/// INSIDE the signed bytes. v1 stays the unversioned legacy
|
||||
/// shape; v2 carries `"version": 2` in the payload so a
|
||||
/// version flip without re-signing invalidates the signature
|
||||
/// instead of verifying cleanly under the other path.
|
||||
#[test]
|
||||
fn v2_signing_payload_binds_version_and_resists_downgrade() {
|
||||
let kp = Keypair::generate();
|
||||
let node_did = kp.did().as_str().to_string();
|
||||
let ts = "2026-07-22T00:00:00+00:00";
|
||||
let v1 = v1_signing_payload(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
"oldsha",
|
||||
"newsha",
|
||||
"did:key:z6MkPusher",
|
||||
&node_did,
|
||||
ts,
|
||||
);
|
||||
let v2 = v2_signing_payload(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
"oldsha",
|
||||
"newsha",
|
||||
"did:key:z6MkPusher",
|
||||
&node_did,
|
||||
ts,
|
||||
);
|
||||
let v2_obj = v2.as_object().expect("v2 payload is a JSON object");
|
||||
assert_eq!(
|
||||
v2_obj.get("version"),
|
||||
Some(&serde_json::json!(2)),
|
||||
"v2 payload must carry the version inside the signed bytes"
|
||||
);
|
||||
let v1_bytes = serde_json::to_vec(&v1).unwrap();
|
||||
let v2_bytes = serde_json::to_vec(&v2).unwrap();
|
||||
assert_ne!(
|
||||
v1_bytes, v2_bytes,
|
||||
"v1 and v2 payloads must differ, otherwise a version flip is cryptographically invisible"
|
||||
);
|
||||
|
||||
// A signature over v1 must NOT verify as v2 and vice versa.
|
||||
let vk = node_did
|
||||
.parse::<gitlawb_core::did::Did>()
|
||||
.unwrap()
|
||||
.to_verifying_key()
|
||||
.unwrap();
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
let sig_v1 = kp.sign_b64(&v1_bytes);
|
||||
let sig_v1_bytes: [u8; 64] = URL_SAFE_NO_PAD.decode(&sig_v1).unwrap().try_into().unwrap();
|
||||
assert!(
|
||||
gitlawb_core::identity::verify(&vk, &v1_bytes, &sig_v1_bytes).is_ok(),
|
||||
"v1 signature must verify under v1 bytes"
|
||||
);
|
||||
assert!(
|
||||
gitlawb_core::identity::verify(&vk, &v2_bytes, &sig_v1_bytes).is_err(),
|
||||
"a v1 signature presented as v2 (downgrade/upgrade flip) must not verify"
|
||||
);
|
||||
}
|
||||
|
||||
/// The live issuer stamps the version the shipped client can
|
||||
/// verify. The gl client refuses to verify v2 certs explicitly;
|
||||
/// this test pins that the version the ISSUING PATH claims
|
||||
/// agrees with the payload shape it actually signs.
|
||||
///
|
||||
/// Round 2: the prior form built a `RefCertificate` literal with
|
||||
/// `version: 1` and asserted `version == 1` — true no matter what the
|
||||
/// signer does, so flipping the issuer's version (or its payload shape)
|
||||
/// left it green. This one drives the actual
|
||||
/// `issue_ref_certificate` so a regression in the issuer flips the test.
|
||||
/// The DB row is created against `test_support::test_state`, which
|
||||
/// gives us a real `AppState` with a real node keypair.
|
||||
///
|
||||
/// Round 3: the issuer stamps v1 because the shipped `gl`
|
||||
/// verifier only supports v1 (stamping v2 shipped certs the
|
||||
/// client refuses). The binding to the live issuer is kept —
|
||||
/// flipping the stamp still turns this test red — without
|
||||
/// changing what production issues.
|
||||
#[sqlx::test]
|
||||
#[allow(clippy::async_yields_async)]
|
||||
async fn issuer_stamps_v1_over_v1_payload(pool: sqlx::PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
let repo_id = "repo-issuer-observe";
|
||||
let ref_name = "refs/heads/main";
|
||||
let old = "0".repeat(40);
|
||||
let new = "a".repeat(40);
|
||||
let pusher = "did:key:z6MkPusher";
|
||||
|
||||
// Drive the live issuer and read the persisted row back by
|
||||
// id, so the test covers the stored `version` column and
|
||||
// not just the in-process return value.
|
||||
let cert =
|
||||
crate::cert::issue_ref_certificate(&state, repo_id, ref_name, &old, &new, pusher)
|
||||
.await
|
||||
.expect("issue_ref_certificate must succeed");
|
||||
|
||||
// The cert returned by the issuer is the source of truth
|
||||
// for both the version claim and the signed bytes.
|
||||
assert_eq!(cert.repo_id, repo_id);
|
||||
assert_eq!(cert.ref_name, ref_name);
|
||||
|
||||
// Cross-check 1: the issuer's claimed version must match
|
||||
// what the live function stamps. This assertion is bound
|
||||
// to the live function, not a hand-built literal:
|
||||
// flipping the stamp in `issue_ref_certificate` breaks it
|
||||
// through the actual call path.
|
||||
assert_eq!(
|
||||
cert.version, 1,
|
||||
"the live issuer must stamp v1 until a v2 verifier ships; \
|
||||
flipping the stamp breaks this assertion through the actual call path"
|
||||
);
|
||||
|
||||
// Cross-check 1b: the persisted row carries the same stamp.
|
||||
let stored = state
|
||||
.db
|
||||
.get_ref_certificate(&cert.id)
|
||||
.await
|
||||
.expect("persisted cert must be readable")
|
||||
.expect("persisted cert must exist");
|
||||
assert_eq!(
|
||||
stored.version, cert.version,
|
||||
"the stored version column must match the issued claim"
|
||||
);
|
||||
|
||||
// Cross-check 2: the signature on the cert must verify
|
||||
// against the v1 payload builder's bytes, not against some
|
||||
// other shape the issuer might have introduced. This is
|
||||
// what gl's `verify_signature` does for a v1 cert: rebuild
|
||||
// the payload from the cert's own fields and verify the
|
||||
// signature. If the issuer ever signs a different shape
|
||||
// while still claiming v1, every shipped client breaks —
|
||||
// and so does this.
|
||||
let rebuilt = v1_signing_payload(
|
||||
&cert.repo_id,
|
||||
&cert.ref_name,
|
||||
&cert.old_sha,
|
||||
&cert.new_sha,
|
||||
&cert.pusher_did,
|
||||
&cert.node_did,
|
||||
&cert.issued_at,
|
||||
);
|
||||
let vk = cert
|
||||
.node_did
|
||||
.parse::<gitlawb_core::did::Did>()
|
||||
.unwrap()
|
||||
.to_verifying_key()
|
||||
.unwrap();
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
let sig_bytes: [u8; 64] = URL_SAFE_NO_PAD
|
||||
.decode(&cert.signature)
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
gitlawb_core::identity::verify(&vk, &serde_json::to_vec(&rebuilt).unwrap(), &sig_bytes)
|
||||
.expect("a v1 cert's signature must verify against the v1 payload shape");
|
||||
|
||||
// Cross-check 3: the v1 signed payload must NOT contain a
|
||||
// version key — v2 is the future shape that adds one inside
|
||||
// the signed bytes (see `v2_signing_payload`). The v1
|
||||
// payload (which the cert is signed over) is frozen at 7
|
||||
// fields. A change that adds `version` to the v1 payload
|
||||
// breaks every existing cert and this test.
|
||||
let obj = rebuilt
|
||||
.as_object()
|
||||
.expect("rebuilt v1 payload is a JSON object");
|
||||
assert!(
|
||||
!obj.contains_key("version"),
|
||||
"the v1 signing payload must not carry a version key — \
|
||||
adding one changes the bytes and breaks every existing \
|
||||
cert's signature"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -66,12 +66,6 @@ pub struct Config {
|
||||
#[arg(long, env = "GITLAWB_PUBLIC_URL")]
|
||||
pub public_url: Option<String>,
|
||||
|
||||
/// Base URL for the web view of repos on this node (e.g. https://gitlawb.com).
|
||||
/// When set, `GET /` advertises it as `web_url` so the CLI can print a
|
||||
/// working `View:` link. Omit for nodes that have no web front-end.
|
||||
#[arg(long, env = "GITLAWB_WEB_URL")]
|
||||
pub web_url: Option<String>,
|
||||
|
||||
/// Comma-separated list of bootstrap peer URLs to announce to on startup
|
||||
#[arg(long, env = "GITLAWB_BOOTSTRAP_PEERS", value_delimiter = ',')]
|
||||
pub bootstrap_peers: Vec<String>,
|
||||
@@ -809,13 +803,7 @@ impl Config {
|
||||
/// Cross-field boot validation. Single-field ranges are enforced by clap; this
|
||||
/// catches combinations that ship a denial-of-service under otherwise-valid
|
||||
/// values. Call once at startup and fail fast on `Err`.
|
||||
///
|
||||
/// `&mut self` is required so the validator can normalize `web_url` to
|
||||
/// its canonical form after validation: the configured value is
|
||||
/// advertised on public `GET /`, and any consumer reading
|
||||
/// `config.web_url` later must see the same string that was actually
|
||||
/// validated.
|
||||
pub fn validate(&mut self) -> Result<(), String> {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
// Concurrent git writes pin the dedicated lock pool for their whole
|
||||
// duration (connection-affine advisory lock in acquire_write). The main
|
||||
// pool no longer carries that occupancy.
|
||||
@@ -932,101 +920,10 @@ impl Config {
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// GITLAWB_WEB_URL is advertised in GET / so the CLI can print a working
|
||||
// View: link. Clap maps "" to Some("") which would produce a broken URL;
|
||||
// reject early instead of serving a malformed link. Non-blank values must
|
||||
// additionally parse as an absolute http(s) URL — the CLI appends
|
||||
// `/{owner}/{repo}` to it, so scheme-less hosts ("gitlawb.com") and other
|
||||
// garbage would render links no browser can follow.
|
||||
if let Some(raw) = &self.web_url {
|
||||
match validate_web_url(raw) {
|
||||
Ok(()) => {}
|
||||
Err(reason) => {
|
||||
return Err(format!(
|
||||
"GITLAWB_WEB_URL {reason} — \
|
||||
set it to an absolute URL like https://gitlawb.com or leave it unset."
|
||||
));
|
||||
}
|
||||
}
|
||||
// Normalize to one canonical form so every consumer (the regular
|
||||
// and degraded `GET /` handlers, anyone who reads
|
||||
// `config.web_url` directly) sees the same string that was
|
||||
// actually validated. The CLI's own defensive trim would paper
|
||||
// over the divergence on the `View:` line, but that hides the
|
||||
// bug from any other consumer that parses the value verbatim.
|
||||
let canonical = raw
|
||||
.trim()
|
||||
.parse::<url::Url>()
|
||||
.map(|u| u.to_string())
|
||||
.map_err(|_| "is not a valid absolute URL".to_string())?;
|
||||
if canonical != *raw {
|
||||
self.web_url = Some(canonical);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a `web_url` value for use as a browser-reachable base URL.
|
||||
/// Blank values are rejected (clap maps `--web-url ""` to `Some("")`), and
|
||||
/// non-blank values must parse as absolute `http`/`https` URLs with no
|
||||
/// query, fragment, or userinfo — the CLI treats this as a string prefix
|
||||
/// to append paths to, so anything else produces links no browser can
|
||||
/// follow (`?a=1/owner/repo` puts the repo path inside the query string);
|
||||
/// and userinfo is rejected outright because the configured value is
|
||||
/// published on public `GET /` and reprinted on the CLI's `View:` line,
|
||||
/// so an accidentally configured password would land in public metadata,
|
||||
/// terminal scrollback, and CI logs.
|
||||
pub(crate) fn validate_web_url(raw: &str) -> Result<(), String> {
|
||||
if raw.trim().is_empty() {
|
||||
return Err("must not be empty or whitespace-only".into());
|
||||
}
|
||||
let parsed: url::Url = raw
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| "is not a valid absolute URL".to_string())?;
|
||||
match parsed.scheme() {
|
||||
"http" | "https" => {}
|
||||
other => return Err(format!("must use http or https, got '{other}:'")),
|
||||
}
|
||||
// url::Url cannot represent a URL without a host for http/https schemes, so
|
||||
// reaching here guarantees an absolute browser-usable base.
|
||||
if parsed.query().is_some() {
|
||||
return Err(
|
||||
"must not contain a query string — it is used as a path prefix for View links".into(),
|
||||
);
|
||||
}
|
||||
if parsed.fragment().is_some() {
|
||||
return Err(
|
||||
"must not contain a fragment — it is used as a path prefix for View links".into(),
|
||||
);
|
||||
}
|
||||
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return Err(
|
||||
"must not contain a username or password — it is advertised publicly and printed on the View: line"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
// Bidi/control bytes can hide behind percent-encoding: a literal scan of
|
||||
// the wire form misses `.../%E2%80%AE` even though a browser decodes it
|
||||
// into a reordering character on the rendered View: link. Check the
|
||||
// decoded host and path before the value is advertised on GET / (INV-6).
|
||||
let host = parsed.host_str().unwrap_or("");
|
||||
let decoded_path = percent_encoding::percent_decode_str(parsed.path()).decode_utf8_lossy();
|
||||
if host
|
||||
.chars()
|
||||
.chain(decoded_path.chars())
|
||||
.any(|c| c.is_control() || gitlawb_core::sanitize::is_bidi_format(c))
|
||||
{
|
||||
return Err(
|
||||
"must not contain control or bidi characters, even percent-encoded — the View: line is terminal output"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1669,7 +1566,7 @@ mod tests {
|
||||
.expect("default config must validate");
|
||||
|
||||
// An under-sized lock pool relative to the push cap is rejected.
|
||||
let mut under_lock = Config::parse_from([
|
||||
let under_lock = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--db-lock-pool-max-connections",
|
||||
"16",
|
||||
@@ -1682,7 +1579,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Exactly at the push cap without non-push headroom is rejected.
|
||||
let mut push_only = Config::parse_from([
|
||||
let push_only = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--db-lock-pool-max-connections",
|
||||
"32",
|
||||
@@ -1695,7 +1592,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Main pool can be smaller than pushes + headroom when the lock pool carries writes.
|
||||
let mut split = Config::parse_from([
|
||||
let split = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--db-max-connections",
|
||||
"16",
|
||||
@@ -1710,153 +1607,13 @@ mod tests {
|
||||
);
|
||||
|
||||
// Main pool still needs the app headroom floor.
|
||||
let mut under_main = Config::parse_from(["gitlawb-node", "--db-max-connections", "4"]);
|
||||
let under_main = Config::parse_from(["gitlawb-node", "--db-max-connections", "4"]);
|
||||
assert!(
|
||||
under_main.validate().is_err(),
|
||||
"db_max_connections below DB_POOL_APP_HEADROOM must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_url_rejects_empty_and_whitespace() {
|
||||
// Unset is fine.
|
||||
Config::parse_from(["gitlawb-node"])
|
||||
.validate()
|
||||
.expect("no web_url must validate");
|
||||
|
||||
// A real URL validates.
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some("https://gitlawb.com".into());
|
||||
assert!(cfg.validate().is_ok());
|
||||
|
||||
// Empty string is rejected.
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some("".into());
|
||||
assert!(cfg.validate().is_err(), "empty web_url must be rejected");
|
||||
|
||||
// Whitespace-only is rejected.
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some(" ".into());
|
||||
assert!(
|
||||
cfg.validate().is_err(),
|
||||
"whitespace-only web_url must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_url_rejects_non_absolute_or_non_browser_urls() {
|
||||
// Scheme-less host: parses nowhere, renders a broken View: link.
|
||||
for bad in [
|
||||
"gitlawb.com",
|
||||
"not a url",
|
||||
"ftp://files.example.com",
|
||||
"javascript:alert(1)",
|
||||
"https://", // no host
|
||||
] {
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some(bad.into());
|
||||
assert!(cfg.validate().is_err(), "web_url {bad:?} must be rejected");
|
||||
}
|
||||
|
||||
// Query strings and fragments corrupt the appended repo path, since
|
||||
// web_url is used as a raw string prefix (`?a=1` would swallow
|
||||
// `/{owner}/{repo}` into the query).
|
||||
for bad in [
|
||||
"https://gitlawb.com?a=1",
|
||||
"https://gitlawb.com/?utm_source=docs",
|
||||
"https://gitlawb.com#section",
|
||||
"https://gitlawb.com/#faq",
|
||||
] {
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some(bad.into());
|
||||
assert!(
|
||||
cfg.validate().is_err(),
|
||||
"web_url {bad:?} (query or fragment) must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
// Surrounding whitespace around a valid URL is tolerated.
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some(" https://gitlawb.com ".into());
|
||||
assert!(
|
||||
cfg.validate().is_ok(),
|
||||
"padded-but-valid web_url must validate"
|
||||
);
|
||||
|
||||
// Percent-encoded bidi must not boot: the wire form carries no Cf
|
||||
// code point, but a browser decodes %E2%80%AE into a reordering
|
||||
// character on the rendered View: link (INV-6). Literal bidi and
|
||||
// control bytes are rejected the same way.
|
||||
for bad in [
|
||||
"https://example.com/%E2%80%AE",
|
||||
"https://example.com/\u{202e}spoof",
|
||||
"https://example.com/%1b[31m",
|
||||
] {
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some(bad.into());
|
||||
assert!(
|
||||
cfg.validate().is_err(),
|
||||
"web_url {bad:?} (control or bidi, even encoded) must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
// Non-default ports and paths are fine.
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some("http://localhost:8080/ui".into());
|
||||
assert!(cfg.validate().is_ok(), "port+path web_url must validate");
|
||||
|
||||
// Userinfo must be rejected: `web_url` is published on public
|
||||
// `GET /` and reprinted on the CLI's `View:` line, so an
|
||||
// accidentally configured password would land in public metadata,
|
||||
// terminal scrollback, and CI logs. Reject username-only and
|
||||
// user+password forms.
|
||||
for bad in [
|
||||
"https://[email protected]",
|
||||
"https://user:[email protected]",
|
||||
"https://:[email protected]",
|
||||
] {
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some(bad.into());
|
||||
assert!(
|
||||
cfg.validate().is_err(),
|
||||
"web_url {bad:?} (userinfo) must be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `validate()` accepts whitespace-padded input but the stored value
|
||||
/// must match the canonical form actually validated, so every consumer
|
||||
/// (the regular and degraded `GET /` handlers, anyone who reads
|
||||
/// `config.web_url` directly) sees the same string. Without this, an
|
||||
/// operator that sets `GITLAWB_WEB_URL=" https://git.example "` boots
|
||||
/// a node that advertises `" https://git.example "` on public
|
||||
/// metadata — different from what was validated, different from what
|
||||
/// any other consumer would parse.
|
||||
#[test]
|
||||
fn web_url_is_normalized_to_canonical_form_after_validation() {
|
||||
// Padded input is rewritten to the canonical form. `url::Url`'s
|
||||
// own `to_string` is what we use for the canonicalization, and
|
||||
// it always appends a path-separator slash to a bare-host URL,
|
||||
// so the stored value is `https://git.example/` (which is also
|
||||
// what every browser would normalize the operator-typed form to).
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some(" https://git.example ".into());
|
||||
cfg.validate().unwrap();
|
||||
assert_eq!(
|
||||
cfg.web_url.as_deref(),
|
||||
Some("https://git.example/"),
|
||||
"stored web_url must be the canonical form, not the operator-typed one"
|
||||
);
|
||||
|
||||
// Already-canonical input is left alone after a no-op
|
||||
// re-normalization — same form comes back out, so any consumer
|
||||
// reading the stored value sees a stable string across calls.
|
||||
let mut cfg = Config::parse_from(["gitlawb-node"]);
|
||||
cfg.web_url = Some("https://git.example/".into());
|
||||
cfg.validate().unwrap();
|
||||
assert_eq!(cfg.web_url.as_deref(), Some("https://git.example/"));
|
||||
}
|
||||
|
||||
/// The DECLARED default, read off the parser rather than out of a parse.
|
||||
///
|
||||
/// `Config::parse_from` consults the process environment, so on a host that
|
||||
@@ -2121,7 +1878,7 @@ mod tests {
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
let key_path = link.join("p2p.key");
|
||||
|
||||
let mut config = Config::parse_from([
|
||||
let config = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--p2p-port",
|
||||
"0",
|
||||
@@ -2216,7 +1973,7 @@ mod tests {
|
||||
let key_path = build(base.path());
|
||||
let before = snapshot(base.path());
|
||||
|
||||
let mut config = Config::parse_from([
|
||||
let config = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--p2p-port",
|
||||
"7546",
|
||||
|
||||
@@ -6,20 +6,6 @@ use std::time::Duration;
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Maximum visible repositories per page, plus one internal look-ahead row.
|
||||
pub(crate) const MAX_VISIBLE_REPO_PAGE_SIZE: usize = 200;
|
||||
|
||||
/// Maximum repository name length in bytes. `repo_cursor` embeds the name in
|
||||
/// a base64(JSON) cursor that `parse_repo_cursor` rejects over 4096 bytes, so
|
||||
/// every write path must bound the name: a cursor the server emits must
|
||||
/// always parse back (#465 4th round). 100 bytes keeps the longest possible
|
||||
/// cursor an order of magnitude under the parse limit.
|
||||
pub(crate) const MAX_REPO_NAME_LEN: usize = 100;
|
||||
|
||||
/// Maximum agent tasks returned per query. Shared by the GraphQL `tasks`
|
||||
/// complexity meter and the resolver clamp so they can't drift.
|
||||
pub(crate) const MAX_VISIBLE_TASK_PAGE_SIZE: i64 = 200;
|
||||
|
||||
// ── Public data types ─────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -292,14 +278,6 @@ pub struct RefCertificate {
|
||||
pub node_did: String,
|
||||
pub signature: String,
|
||||
pub issued_at: String,
|
||||
/// #26 Split PR 3 — the wire-format version of this cert. v1 is
|
||||
/// the pre-versioning 7-field payload (no `version` key in the
|
||||
/// signed bytes). v2+ is reserved for a new signed payload shape
|
||||
/// that carries the version inside the signed bytes; it is not
|
||||
/// v1 plus unsigned fields, because flipping this column must
|
||||
/// invalidate the signature. Clients refuse to verify versions
|
||||
/// they cannot represent rather than guessing.
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -419,17 +397,6 @@ impl Db {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
/// Test-only: access the underlying Postgres connection pool.
|
||||
///
|
||||
/// The production accessor was removed on main (ed76adf) once every
|
||||
/// production caller had migrated to typed `Db` methods; this restores
|
||||
/// it for tests only, for the raw-SQL fixtures and closed-pool cases in
|
||||
/// this change that have no typed equivalent.
|
||||
#[cfg(test)]
|
||||
pub fn pool(&self) -> &PgPool {
|
||||
&self.pool
|
||||
}
|
||||
|
||||
/// Test-only: apply the full schema to a fresh pool. `#[sqlx::test]`
|
||||
/// provisions an empty per-test database, so DB-backed tests must run this
|
||||
/// before seeding. Reuses the production `migrate()` path (the advisory lock
|
||||
@@ -1391,32 +1358,6 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: "validate_repo_upstream_mirror_state",
|
||||
stmts: &["ALTER TABLE repos VALIDATE CONSTRAINT repos_mirror_state_valid"],
|
||||
},
|
||||
Migration {
|
||||
version: 37,
|
||||
name: "ref_certificates_version",
|
||||
stmts: &[
|
||||
// #26 Split PR 3 — certificate / CLI compatibility. The
|
||||
// ref-cert wire format is versioned so future fields can
|
||||
// be added without breaking old clients (which ignore
|
||||
// the field) or old servers (which default the column
|
||||
// to 1). New certificates are currently issued as v1,
|
||||
// which is byte-for-byte identical to the pre-versioning
|
||||
// shape: an old client reading a v1 cert from a new
|
||||
// server sees the same payload, the same signature, and
|
||||
// the same Ed25519 verify path. A new client reading an
|
||||
// old server sees `version` defaulted to 1 and verifies
|
||||
// against the v1 payload shape. v2 is reserved for a
|
||||
// future shape that carries the version inside the
|
||||
// signed bytes; it must not be stamped until a v2
|
||||
// verifier ships.
|
||||
//
|
||||
// DEFAULT 1 so an existing row reads as v1 without a
|
||||
// backfill migration. NOT NULL so a missing value is a
|
||||
// hard error at insert time rather than a silent v0 that
|
||||
// an old client would interpret as "no version field."
|
||||
"ALTER TABLE ref_certificates ADD COLUMN IF NOT EXISTS version INTEGER NOT NULL DEFAULT 1",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/// Max distinct source repos recorded per pinned object (F1, #173 jatmn round 8).
|
||||
@@ -1619,16 +1560,6 @@ impl Db {
|
||||
machine_id: Option<&str>,
|
||||
quarantined: bool,
|
||||
) -> Result<()> {
|
||||
// Peer-supplied names are bound like the local write paths: an
|
||||
// unbounded name would make reposPage emit a cursor longer than the
|
||||
// 4096-byte parse limit, dead-ending pagination for every caller past
|
||||
// that row (#465 4th round). The sync caller drops the error and skips
|
||||
// the mirror.
|
||||
if name.len() > MAX_REPO_NAME_LEN {
|
||||
anyhow::bail!(
|
||||
"mirror repo name exceeds {MAX_REPO_NAME_LEN} bytes: refusing to register"
|
||||
);
|
||||
}
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let id = format!("{owner_short}/{name}");
|
||||
// `quarantined` is set only on first insert (the admission decision).
|
||||
@@ -1920,73 +1851,6 @@ impl Db {
|
||||
Ok(rows.into_iter().map(row_to_repo).collect())
|
||||
}
|
||||
|
||||
/// A bounded, mirror-deduplicated page ordered by owner key and repository
|
||||
/// name. Apply root visibility before LIMIT: private rows must not consume
|
||||
/// page slots or influence continuation metadata. The root-rule predicate
|
||||
/// mirrors `visibility::listable_at_root`; a differential test pins it.
|
||||
/// Malformed reader JSON denies that repo to non-owners without failing the page.
|
||||
/// Cursors only select a position and never confer read authority.
|
||||
///
|
||||
/// Performance note: the page orders and keyset-filters on
|
||||
/// `(owner_key, name)` under `COLLATE "C"`, but the existing
|
||||
/// `idx_repos_owner_key_name` index is built on the database default
|
||||
/// collation and cannot serve that ordering, so each page sorts the full
|
||||
/// deduped repo set before the keyset filter applies: O(total repos) work
|
||||
/// per page. This is accepted (pages are capped at
|
||||
/// `MAX_VISIBLE_REPO_PAGE_SIZE` rows over a narrow projection). A
|
||||
/// C-collation expression index was deliberately not added in a migration:
|
||||
/// versions 27-35 are claimed by other in-flight branches (Gitlawb/node#384
|
||||
/// claims 27-35, #464 claims 27-28) and the runner keys applied migrations
|
||||
/// on the version integer alone, so a colliding entry would be silently
|
||||
/// skipped on whichever side merges second. Revisit once that range lands
|
||||
/// or clears.
|
||||
pub async fn list_visible_repos_page(
|
||||
&self,
|
||||
caller: Option<&str>,
|
||||
after: Option<(&str, &str)>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<RepoRecord>> {
|
||||
let sql = format!(
|
||||
"{}
|
||||
SELECT d.id, d.name, d.owner_did, d.description, d.is_public,
|
||||
d.default_branch, d.created_at, d.updated_at, d.disk_path,
|
||||
d.forked_from, d.machine_id
|
||||
FROM deduped d
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT reader_dids FROM visibility_rules v
|
||||
WHERE v.repo_id = d.id AND v.path_glob ~ '^/*([*][*])*$'
|
||||
ORDER BY v.path_glob DESC LIMIT 1
|
||||
) root_rule ON TRUE
|
||||
WHERE (
|
||||
($2::text IS NOT NULL AND ({key}) = $2)
|
||||
OR CASE WHEN root_rule.reader_dids IS NULL THEN d.is_public
|
||||
WHEN NOT pg_input_is_valid(root_rule.reader_dids, 'jsonb') THEN FALSE
|
||||
WHEN jsonb_typeof(root_rule.reader_dids::jsonb) = 'array' THEN
|
||||
COALESCE(root_rule.reader_dids::jsonb ? $3::text, FALSE)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(root_rule.reader_dids::jsonb) reader
|
||||
WHERE jsonb_typeof(reader) <> 'string'
|
||||
)
|
||||
ELSE FALSE END
|
||||
)
|
||||
AND ($4::text IS NULL OR (({key}) COLLATE \"C\", d.name COLLATE \"C\") > ($4, $5::text))
|
||||
ORDER BY ({key}) COLLATE \"C\", d.name COLLATE \"C\"
|
||||
LIMIT $6",
|
||||
Self::dedup_cte(),
|
||||
key = OWNER_KEY_CASE_SQL,
|
||||
);
|
||||
let rows = sqlx::query(&sql)
|
||||
.bind(None::<&str>)
|
||||
.bind(caller.map(normalize_owner_key))
|
||||
.bind(caller)
|
||||
.bind(after.map(|(owner, _)| normalize_owner_key(owner)))
|
||||
.bind(after.map(|(_, name)| name))
|
||||
.bind(limit.min(MAX_VISIBLE_REPO_PAGE_SIZE + 1) as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(row_to_repo).collect())
|
||||
}
|
||||
|
||||
/// Repos currently quarantined (admitted as mirrors but withheld from every
|
||||
/// listing surface). `list_all_repos_deduped` excludes these (its `DEDUP_CTE`
|
||||
/// filters `quarantined = FALSE`), so a gate that resolves a slug against the
|
||||
@@ -2783,8 +2647,8 @@ impl Db {
|
||||
pub async fn insert_ref_certificate(&self, cert: &RefCertificate) -> Result<RefCertificate> {
|
||||
let row = sqlx::query(
|
||||
"INSERT INTO ref_certificates
|
||||
(id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, version)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
(id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (repo_id, ref_name) DO UPDATE SET
|
||||
old_sha = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at
|
||||
THEN EXCLUDED.old_sha ELSE ref_certificates.old_sha END,
|
||||
@@ -2797,10 +2661,8 @@ impl Db {
|
||||
signature = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at
|
||||
THEN EXCLUDED.signature ELSE ref_certificates.signature END,
|
||||
issued_at = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at
|
||||
THEN EXCLUDED.issued_at ELSE ref_certificates.issued_at END,
|
||||
version = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at
|
||||
THEN EXCLUDED.version ELSE ref_certificates.version END
|
||||
RETURNING id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, version",
|
||||
THEN EXCLUDED.issued_at ELSE ref_certificates.issued_at END
|
||||
RETURNING id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at",
|
||||
)
|
||||
.bind(&cert.id)
|
||||
.bind(&cert.repo_id)
|
||||
@@ -2811,7 +2673,6 @@ impl Db {
|
||||
.bind(&cert.node_did)
|
||||
.bind(&cert.signature)
|
||||
.bind(&cert.issued_at)
|
||||
.bind(cert.version as i32)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row_to_cert(row))
|
||||
@@ -2826,7 +2687,7 @@ impl Db {
|
||||
// bounded even if a raw/negative value slips through the handler layer.
|
||||
let limit = limit.max(1);
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, version
|
||||
"SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at
|
||||
FROM ref_certificates WHERE repo_id = $1 ORDER BY issued_at DESC LIMIT $2",
|
||||
)
|
||||
.bind(repo_id)
|
||||
@@ -2865,7 +2726,7 @@ impl Db {
|
||||
let pattern = format!("{}%", escaped_prefix);
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, version
|
||||
"SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at
|
||||
FROM ref_certificates WHERE repo_id = $1 AND id LIKE $2 ESCAPE '!' ORDER BY issued_at DESC LIMIT $3",
|
||||
)
|
||||
.bind(repo_id)
|
||||
@@ -2878,7 +2739,7 @@ impl Db {
|
||||
|
||||
pub async fn get_ref_certificate(&self, id: &str) -> Result<Option<RefCertificate>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, version
|
||||
"SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at
|
||||
FROM ref_certificates WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
@@ -5082,7 +4943,6 @@ fn row_to_cert(r: sqlx::postgres::PgRow) -> RefCertificate {
|
||||
node_did: r.get("node_did"),
|
||||
signature: r.get("signature"),
|
||||
issued_at: r.get("issued_at"),
|
||||
version: r.get::<i32, _>("version") as u32,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7791,7 +7651,6 @@ mod ref_certificate_tests {
|
||||
node_did: "did:key:zNODE".to_string(),
|
||||
signature: "sig".to_string(),
|
||||
issued_at: issued_at.to_string(),
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8465,130 +8324,6 @@ mod ref_certificate_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #26 Split PR 3 (INV-7): an existing node past v1 gets the
|
||||
/// `ref_certificates.version` column from its OWN v37 entry, proven
|
||||
/// by dropping the column plus its `schema_migrations` row and
|
||||
/// re-running the real migration code. The migration is the only
|
||||
/// place the column is added (v37 — the v1 bundle no longer
|
||||
/// carries it), so a deletion of v37 must be visible as a missing
|
||||
/// column on a v27 node.
|
||||
///
|
||||
/// DEFAULT 1 is what makes an existing pre-v37 cert row read as
|
||||
/// v1 without a backfill migration. An upgraded node reads
|
||||
/// every legacy row as version 1 — the same payload the old
|
||||
/// code signed — so a v1 verify path on a new client still
|
||||
/// works against an upgraded database.
|
||||
///
|
||||
/// MUTATION (RED): delete the v37 entry from `MIGRATIONS` and the
|
||||
/// upgrade path leaves the column missing.
|
||||
#[sqlx::test]
|
||||
async fn v37_ref_certificates_version_applies_on_upgrade(pool: PgPool) {
|
||||
async fn version_column_default(pool: &PgPool) -> Option<String> {
|
||||
// fetch_optional (not fetch_one) so a missing column
|
||||
// returns Ok(None) instead of RowNotFound. The whole
|
||||
// point of the precondition assertion below is to
|
||||
// detect a missing column, and RowNotFound would mask
|
||||
// the difference between "the helper is wrong" and
|
||||
// "the migration is wrong".
|
||||
sqlx::query_scalar::<_, Option<String>>(
|
||||
"SELECT column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'ref_certificates'
|
||||
AND column_name = 'version'",
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
async fn seed_pre_v37_cert(pool: &PgPool) {
|
||||
// The pre-v37 schema has no `version` column, so a raw
|
||||
// INSERT omitting it is the legacy code path. The node
|
||||
// did not write a version value on v1..v27 — the field
|
||||
// did not exist.
|
||||
sqlx::query(
|
||||
"INSERT INTO ref_certificates
|
||||
(id, repo_id, ref_name, old_sha, new_sha,
|
||||
pusher_did, node_did, signature, issued_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (repo_id, ref_name) DO NOTHING",
|
||||
)
|
||||
.bind("legacy-cert-1")
|
||||
.bind("legacy-repo")
|
||||
.bind("refs/heads/main")
|
||||
.bind("0000")
|
||||
.bind("1111")
|
||||
.bind("did:key:zLEGACYPUSHER")
|
||||
.bind("did:key:zLEGACYNODE")
|
||||
.bind("legacy-sig")
|
||||
.bind("2026-07-01T10:00:00+00:00")
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn legacy_row_reads_as_v1(pool: &PgPool) -> i32 {
|
||||
// After v37 the legacy row (inserted without an explicit
|
||||
// version) reads back as 1 via the DEFAULT, not as 0 or
|
||||
// NULL — the latter would be a hard insert error under
|
||||
// NOT NULL DEFAULT, but the read-back is the actual
|
||||
// forward-compat property the v1 verify path relies on.
|
||||
sqlx::query_scalar::<_, i32>(
|
||||
"SELECT version FROM ref_certificates WHERE id = 'legacy-cert-1'",
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// 1. Fresh chain: v37 has run, the column exists with DEFAULT 1.
|
||||
let db = Db::for_testing(pool.clone());
|
||||
db.run_migrations().await.unwrap();
|
||||
assert_eq!(
|
||||
version_column_default(&pool).await.as_deref(),
|
||||
Some("1"),
|
||||
"v37 must declare the version column with DEFAULT 1"
|
||||
);
|
||||
|
||||
// 2. Roll back to pre-v37: drop the column AND the v37 record.
|
||||
// The rollback is split into two statements because ALTER
|
||||
// TABLE ... DROP COLUMN and DELETE run in the same DDL
|
||||
// surface but the column drop must complete before the
|
||||
// next step reads schema_migrations.
|
||||
sqlx::query("ALTER TABLE ref_certificates DROP COLUMN version")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("DELETE FROM schema_migrations WHERE version = 37")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
version_column_default(&pool).await.is_none(),
|
||||
"precondition: column removed and its migration record removed"
|
||||
);
|
||||
|
||||
// 3. Seed a legacy cert on the pre-v37 schema (no version
|
||||
// column ⇒ the INSERT must omit it).
|
||||
seed_pre_v37_cert(&pool).await;
|
||||
|
||||
// 4. Re-run migrations: v37 must re-add the column with
|
||||
// DEFAULT 1, and the legacy row must read back as 1.
|
||||
db.run_migrations().await.unwrap();
|
||||
assert_eq!(
|
||||
version_column_default(&pool).await.as_deref(),
|
||||
Some("1"),
|
||||
"v37 must recreate the version column with DEFAULT 1 on an upgrading node"
|
||||
);
|
||||
assert_eq!(
|
||||
legacy_row_reads_as_v1(&pool).await,
|
||||
1,
|
||||
"a pre-v37 cert row reads as version 1 after v37 runs, so the \
|
||||
v1 verify path on a new client still works against an upgraded database"
|
||||
);
|
||||
}
|
||||
|
||||
/// INV-7: upgrade-path test — seed a database at v9 with duplicate
|
||||
/// ref_certificates, then let the real v10 migration fire via
|
||||
/// run_migrations(). This exercises the migration code path rather than
|
||||
|
||||
@@ -118,7 +118,7 @@ impl RepoStore {
|
||||
/// Test-only: every guard from this store parks in `release` right before the
|
||||
/// `pg_advisory_unlock` await, until `gate` is notified. Dropping the future
|
||||
/// while it is parked reproduces a client disconnect inside `release`.
|
||||
#[cfg(all(test, unix))]
|
||||
#[cfg(test)]
|
||||
pub fn with_pre_unlock_gate(mut self, gate: Arc<tokio::sync::Notify>) -> Self {
|
||||
self.pre_unlock_gate = Some(gate);
|
||||
self
|
||||
@@ -126,7 +126,7 @@ impl RepoStore {
|
||||
|
||||
/// Test-only: how many write guards from this store have reached the Tigris upload
|
||||
/// site. See [`RepoWriteGuard::release`].
|
||||
#[cfg(all(test, unix))]
|
||||
#[cfg(test)]
|
||||
pub fn tigris_upload_site_reached(&self) -> usize {
|
||||
self.upload_site_reached
|
||||
.load(std::sync::atomic::Ordering::SeqCst)
|
||||
@@ -1717,11 +1717,8 @@ fn validate_repo_name(repo_name: &str) -> Result<()> {
|
||||
if repo_name.is_empty() {
|
||||
anyhow::bail!("repo_name is empty");
|
||||
}
|
||||
// Byte bound shared with the API-side validator via `MAX_REPO_NAME_LEN`:
|
||||
// the two validators differ on charset by design, but must not drift on
|
||||
// length, or an API-admitted name fails here after the proof is spent.
|
||||
if repo_name.len() > crate::db::MAX_REPO_NAME_LEN {
|
||||
anyhow::bail!("repo_name exceeds {} chars", crate::db::MAX_REPO_NAME_LEN);
|
||||
if repo_name.len() > 100 {
|
||||
anyhow::bail!("repo_name exceeds 100 chars");
|
||||
}
|
||||
// Repo names are `[A-Za-z0-9._-]+` minus path-traversal traps.
|
||||
if repo_name.contains("..") {
|
||||
|
||||
@@ -3,7 +3,7 @@ pub mod query;
|
||||
pub mod subscription;
|
||||
pub mod types;
|
||||
|
||||
use async_graphql::{Schema, SchemaBuilder};
|
||||
use async_graphql::Schema;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::db::Db;
|
||||
@@ -14,22 +14,6 @@ use subscription::SubscriptionRoot;
|
||||
|
||||
pub type GitlawbSchema = Schema<QueryRoot, MutationRoot, SubscriptionRoot>;
|
||||
|
||||
// Keep ordinary schema discovery and application queries usable while
|
||||
// bounding validation work and the number of resolver selections per request.
|
||||
const GRAPHQL_MAX_COMPLEXITY: usize = 400;
|
||||
// The current public schema is shallow; this leaves headroom for composed
|
||||
// clients and the canonical getIntrospectionQuery without allowing
|
||||
// recursively nested documents to grow unchecked.
|
||||
const GRAPHQL_MAX_DEPTH: usize = 14;
|
||||
|
||||
fn apply_query_limits<Query, Mutation, Subscription>(
|
||||
builder: SchemaBuilder<Query, Mutation, Subscription>,
|
||||
) -> SchemaBuilder<Query, Mutation, Subscription> {
|
||||
builder
|
||||
.limit_complexity(GRAPHQL_MAX_COMPLEXITY)
|
||||
.limit_depth(GRAPHQL_MAX_DEPTH)
|
||||
}
|
||||
|
||||
/// Client-facing message for GraphQL resolver failures that wrap a real
|
||||
/// `sqlx::Error`. The real error is logged server-side; never put sqlx/Postgres
|
||||
/// detail in the GraphQL `errors` array (#250).
|
||||
@@ -97,7 +81,7 @@ pub fn build_schema(
|
||||
ref_update_tx: tokio::sync::broadcast::Sender<RefUpdateBroadcast>,
|
||||
task_event_tx: tokio::sync::broadcast::Sender<TaskEventBroadcast>,
|
||||
) -> GitlawbSchema {
|
||||
apply_query_limits(Schema::build(QueryRoot, MutationRoot, SubscriptionRoot))
|
||||
Schema::build(QueryRoot, MutationRoot, SubscriptionRoot)
|
||||
.data(db)
|
||||
.data(ref_update_tx)
|
||||
.data(task_event_tx)
|
||||
@@ -107,10 +91,6 @@ pub fn build_schema(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::events::MAX_VISIBLE_REF_UPDATES;
|
||||
use crate::db::{MAX_VISIBLE_REPO_PAGE_SIZE, MAX_VISIBLE_TASK_PAGE_SIZE};
|
||||
use async_graphql::{EmptyMutation, EmptySubscription, Object, Value};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn graphql_db_err_opaques_sqlx_chain() {
|
||||
@@ -180,668 +160,6 @@ mod tests {
|
||||
assert!(!err.message.contains("failed to open"));
|
||||
}
|
||||
|
||||
fn production_test_schema() -> GitlawbSchema {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
production_test_schema_with_db(Arc::new(Db::for_testing(pool)))
|
||||
}
|
||||
|
||||
fn production_test_schema_with_db(db: Arc<Db>) -> GitlawbSchema {
|
||||
build_schema(
|
||||
db,
|
||||
tokio::sync::broadcast::channel(1).0,
|
||||
tokio::sync::broadcast::channel(1).0,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ref_update_subscription_alias_budget() {
|
||||
assert_subscription_alias_budget("refUpdates { repo }").await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_event_subscription_alias_budget() {
|
||||
assert_subscription_alias_budget("taskEvents { taskId }").await;
|
||||
}
|
||||
|
||||
async fn assert_subscription_alias_budget(field: &str) {
|
||||
use futures::StreamExt;
|
||||
use std::time::Duration;
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
let (ref_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let (task_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let schema = build_schema(
|
||||
Arc::new(Db::for_testing(pool)),
|
||||
ref_tx.clone(),
|
||||
task_tx.clone(),
|
||||
);
|
||||
for count in [7, 8] {
|
||||
let fields = (0..count)
|
||||
.map(|n| format!("r{n}: {field}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let query = format!("subscription {{ {fields} }}");
|
||||
let mut responses = Box::pin(schema.execute_stream(query));
|
||||
if count == 8 {
|
||||
let response = tokio::time::timeout(Duration::from_secs(1), responses.next())
|
||||
.await
|
||||
.expect("over-budget subscriptions must reject before waiting for events")
|
||||
.expect("the validation error must be returned");
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
assert_eq!(ref_tx.receiver_count() + task_tx.receiver_count(), 0);
|
||||
continue;
|
||||
}
|
||||
// Poll the accepted operation far enough to register its receivers.
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(30), responses.next())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(ref_tx.receiver_count() + task_tx.receiver_count(), 7);
|
||||
let _ = ref_tx.send(RefUpdateBroadcast {
|
||||
repo: "owner/repo".into(),
|
||||
ref_name: "refs/heads/main".into(),
|
||||
old_sha: "0".repeat(40),
|
||||
new_sha: "1".repeat(40),
|
||||
pusher_did: "did:key:test".into(),
|
||||
node_did: "did:key:node".into(),
|
||||
timestamp: "2026-01-01T00:00:00Z".into(),
|
||||
owner_did: "did:key:test".into(),
|
||||
});
|
||||
let _ = task_tx.send(TaskEventBroadcast {
|
||||
task_id: "task".into(),
|
||||
old_status: "pending".into(),
|
||||
new_status: "claimed".into(),
|
||||
by_did: "did:key:test".into(),
|
||||
at: "2026-01-01T00:00:00Z".into(),
|
||||
});
|
||||
let response = tokio::time::timeout(Duration::from_secs(1), responses.next())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
assert_ne!(response.data, Value::Null);
|
||||
drop(responses);
|
||||
assert_eq!(ref_tx.receiver_count() + task_tx.receiver_count(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn production_mutation_budget_accepts_seven_and_rejects_eight(pool: sqlx::PgPool) {
|
||||
let db = Arc::new(Db::for_testing(pool));
|
||||
db.run_migrations().await.unwrap();
|
||||
let caller = "did:key:reviewer";
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
for index in 0..8 {
|
||||
db.create_task(&crate::db::AgentTask {
|
||||
id: format!("task-{index}"),
|
||||
repo_id: None,
|
||||
kind: "test".into(),
|
||||
status: "pending".into(),
|
||||
delegator_did: caller.into(),
|
||||
assignee_did: None,
|
||||
capability: "test".into(),
|
||||
ucan_token: None,
|
||||
payload: None,
|
||||
result: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now.clone(),
|
||||
deadline: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let schema = production_test_schema_with_db(db.clone());
|
||||
for count in [8, 7] {
|
||||
let fields = (0..count)
|
||||
.map(|n| {
|
||||
format!("r{n}: claimTask(id: \"task-{n}\", assigneeDid: \"{caller}\") {{ id }}")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema
|
||||
.execute(
|
||||
async_graphql::Request::new(format!("mutation {{ {fields} }}"))
|
||||
.data(crate::auth::AuthenticatedDid(caller.into())),
|
||||
)
|
||||
.await;
|
||||
if count == 8 {
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
assert_eq!(
|
||||
db.list_tasks(Some("pending"), None, 8).await.unwrap().len(),
|
||||
8
|
||||
);
|
||||
continue;
|
||||
}
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
assert_eq!(
|
||||
response
|
||||
.data
|
||||
.into_json()
|
||||
.unwrap()
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.len(),
|
||||
7
|
||||
);
|
||||
assert_eq!(
|
||||
db.list_tasks(Some("claimed"), Some(caller), 8)
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
7
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_task("task-7").await.unwrap().unwrap().status,
|
||||
"pending"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn production_list_cost_scales_with_the_same_alias_count(pool: sqlx::PgPool) {
|
||||
let db = Arc::new(Db::for_testing(pool));
|
||||
db.run_migrations().await.unwrap();
|
||||
let schema = production_test_schema_with_db(db);
|
||||
// Bounds are per-root named constants so the cases move with the
|
||||
// documented maximums instead of going stale as literals.
|
||||
for (field, selection, max) in [
|
||||
("refUpdates", "repo", MAX_VISIBLE_REF_UPDATES),
|
||||
("tasks", "id", MAX_VISIBLE_TASK_PAGE_SIZE),
|
||||
(
|
||||
"reposPage",
|
||||
"nodes { name }",
|
||||
MAX_VISIBLE_REPO_PAGE_SIZE as i64,
|
||||
),
|
||||
] {
|
||||
// Two aliases at the maximum always exceed the budget.
|
||||
let fields = (0..2)
|
||||
.map(|n| format!("r{n}: {field}(limit: {max}) {{ {selection} }}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema.execute(format!("{{ {fields} }}")).await;
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
|
||||
// At limit 1 two aliases fit for the row-priced roots; reposPage
|
||||
// prices the full corpus scan, so a single alias is the most that
|
||||
// fits under the budget.
|
||||
let aliases = if field == "reposPage" { 1 } else { 2 };
|
||||
let fields = (0..aliases)
|
||||
.map(|n| format!("r{n}: {field}(limit: 1) {{ {selection} }}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema.execute(format!("{{ {fields} }}")).await;
|
||||
assert!(response.errors.is_empty(), "{field}: {:?}", response.errors);
|
||||
assert_eq!(
|
||||
response
|
||||
.data
|
||||
.into_json()
|
||||
.unwrap()
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.len(),
|
||||
aliases
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_limits_reject_mutation_aliases_and_large_lists() {
|
||||
let schema = production_test_schema();
|
||||
// Limits are derived from the MAX_* constants so the boundaries move
|
||||
// with the documented maximums instead of going stale as literals.
|
||||
for (prefix, count, field) in [
|
||||
("mutation", 8, "claimTask(id: \"missing\", assigneeDid: \"did:key:test\") { id }".to_owned()),
|
||||
("mutation", 8, "createTask(delegatorDid: \"did:key:test\", input: { kind: \"test\", capability: \"test\" }) { id }".to_owned()),
|
||||
("mutation", 8, "completeTask(id: \"missing\", byDid: \"did:key:test\", input: {}) { id }".to_owned()),
|
||||
("mutation", 8, "failTask(id: \"missing\", byDid: \"did:key:test\", input: {}) { id }".to_owned()),
|
||||
("query", 8, "task(id: \"missing\") { id }".to_owned()),
|
||||
("query", 2, format!("refUpdates(limit: {MAX_VISIBLE_REF_UPDATES}) {{ repo }}")),
|
||||
("query", 2, format!("tasks(limit: {MAX_VISIBLE_TASK_PAGE_SIZE}) {{ id }}")),
|
||||
("query", 2, format!("reposPage(limit: {MAX_VISIBLE_REPO_PAGE_SIZE}) {{ nodes {{ name }} }}")),
|
||||
("query", 8, "reposPage(limit: 1) { nodes { name } }".to_owned()),
|
||||
] {
|
||||
let fields = (0..count)
|
||||
.map(|n| format!("r{n}: {field}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema.execute(format!("{prefix} {{ {fields} }}")).await;
|
||||
assert_eq!(response.errors.len(), 1, "{:?}", response.errors);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn production_repos_page_serves_the_documented_maximum(pool: sqlx::PgPool) {
|
||||
let db = Arc::new(Db::for_testing(pool));
|
||||
db.run_migrations().await.unwrap();
|
||||
let schema = production_test_schema_with_db(db);
|
||||
for selection in [
|
||||
"nodes { name }",
|
||||
"nodes { name ownerDid } hasNextPage endCursor",
|
||||
] {
|
||||
let response = schema
|
||||
.execute(format!(
|
||||
"{{ reposPage(limit: {MAX_VISIBLE_REPO_PAGE_SIZE}) {{ {selection} }} }}"
|
||||
))
|
||||
.await;
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
assert_eq!(
|
||||
response.data.into_json().unwrap()["reposPage"]["nodes"],
|
||||
serde_json::json!([])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seven_root_aliases_are_accepted() {
|
||||
struct Root(Arc<AtomicUsize>);
|
||||
#[Object]
|
||||
impl Root {
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn repos(&self) -> Vec<Nested> {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
vec![Nested]
|
||||
}
|
||||
}
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let schema = apply_query_limits(Schema::build(
|
||||
Root(calls.clone()),
|
||||
EmptyMutation,
|
||||
EmptySubscription,
|
||||
))
|
||||
.finish();
|
||||
let fields = (0..7)
|
||||
.map(|n| format!("r{n}: repos {{ value }}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema.execute(format!("{{ {fields} }}")).await;
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
assert_eq!(calls.load(Ordering::Relaxed), 7);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expensive_root_aliases_are_rejected_before_database_access() {
|
||||
// Use the production builder with a lazy pool: rejection must precede DB access.
|
||||
let schema = production_test_schema();
|
||||
let fields = (0..8)
|
||||
.map(|n| format!("r{n}: repos {{ name }}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
let response = schema.execute(format!("{{ {fields} }}")).await;
|
||||
|
||||
assert_eq!(response.data, Value::Null);
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ordinary_schema_introspection_remains_available() {
|
||||
let schema = production_test_schema();
|
||||
let canonical_introspection = r#"
|
||||
query IntrospectionQuery {
|
||||
__schema {
|
||||
queryType { name }
|
||||
mutationType { name }
|
||||
subscriptionType { name }
|
||||
types {
|
||||
...FullType
|
||||
}
|
||||
directives {
|
||||
name
|
||||
description
|
||||
locations
|
||||
args {
|
||||
...InputValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment FullType on __Type {
|
||||
kind
|
||||
name
|
||||
description
|
||||
fields(includeDeprecated: true) {
|
||||
name
|
||||
description
|
||||
args {
|
||||
...InputValue
|
||||
}
|
||||
type {
|
||||
...TypeRef
|
||||
}
|
||||
isDeprecated
|
||||
deprecationReason
|
||||
}
|
||||
inputFields {
|
||||
...InputValue
|
||||
}
|
||||
interfaces {
|
||||
...TypeRef
|
||||
}
|
||||
enumValues(includeDeprecated: true) {
|
||||
name
|
||||
description
|
||||
isDeprecated
|
||||
deprecationReason
|
||||
}
|
||||
possibleTypes {
|
||||
...TypeRef
|
||||
}
|
||||
}
|
||||
|
||||
fragment InputValue on __InputValue {
|
||||
name
|
||||
description
|
||||
type { ...TypeRef }
|
||||
defaultValue
|
||||
}
|
||||
|
||||
fragment TypeRef on __Type {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let response = schema.execute(canonical_introspection).await;
|
||||
assert!(
|
||||
response.errors.is_empty(),
|
||||
"canonical introspection query failed against production schema: {:?}",
|
||||
response.errors
|
||||
);
|
||||
assert_ne!(response.data, Value::Null);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ref_updates_and_tasks_single_alias_max_limit_complexity_contract() {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
pool.close().await;
|
||||
let (ref_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let (task_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let schema = build_schema(Arc::new(Db::for_testing(pool)), ref_tx, task_tx);
|
||||
|
||||
// refUpdates(limit: MAX_VISIBLE_REF_UPDATES):
|
||||
// Single-field selection has child_complexity 1 -> cost 100 + 200 + 1 = 301 <= 400.
|
||||
// It passes validation and reaches the resolver (which yields db error on lazy pool).
|
||||
let single_ref = schema
|
||||
.execute(format!(
|
||||
"{{ refUpdates(limit: {MAX_VISIBLE_REF_UPDATES}) {{ repo }} }}"
|
||||
))
|
||||
.await;
|
||||
assert!(
|
||||
!single_ref
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == "Query is too complex."),
|
||||
"single-field refUpdates at max limit must pass complexity validation: {:?}",
|
||||
single_ref.errors
|
||||
);
|
||||
assert!(
|
||||
single_ref
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"single-field refUpdates at max limit must reach the resolver: {:?}",
|
||||
single_ref.errors
|
||||
);
|
||||
|
||||
// Two-field selection has child_complexity 2 -> cost 100 + 200 + 2 = 302 <= 400.
|
||||
// The documented max-200 request stays reachable with multiple fields
|
||||
// (the old multiplicative price rejected it at 450); it passes
|
||||
// validation and reaches the resolver like the single-field case.
|
||||
let multi_ref = schema
|
||||
.execute(format!(
|
||||
"{{ refUpdates(limit: {MAX_VISIBLE_REF_UPDATES}) {{ repo refName }} }}"
|
||||
))
|
||||
.await;
|
||||
assert!(
|
||||
!multi_ref
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == "Query is too complex."),
|
||||
"two-field refUpdates at max limit must pass complexity validation: {:?}",
|
||||
multi_ref.errors
|
||||
);
|
||||
assert!(
|
||||
multi_ref
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"two-field refUpdates at max limit must reach the resolver: {:?}",
|
||||
multi_ref.errors
|
||||
);
|
||||
|
||||
// tasks(limit: MAX_VISIBLE_TASK_PAGE_SIZE):
|
||||
// Single-field selection has child_complexity 1, so the cost is
|
||||
// 50 + MAX_VISIBLE_TASK_PAGE_SIZE + 1 <= 400.
|
||||
// It passes validation and reaches the resolver.
|
||||
let single_task = schema
|
||||
.execute(format!(
|
||||
"{{ tasks(limit: {MAX_VISIBLE_TASK_PAGE_SIZE}) {{ id }} }}"
|
||||
))
|
||||
.await;
|
||||
assert!(
|
||||
!single_task
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == "Query is too complex."),
|
||||
"single-field tasks at the maximum page size must pass complexity validation: {:?}",
|
||||
single_task.errors
|
||||
);
|
||||
assert!(
|
||||
single_task
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"single-field tasks at the maximum page size must reach the resolver: {:?}",
|
||||
single_task.errors
|
||||
);
|
||||
|
||||
// Two-field selection has child_complexity 2, so the cost is
|
||||
// 50 + MAX_VISIBLE_TASK_PAGE_SIZE + 2 <= 400.
|
||||
// A request at the documented maximum stays reachable with multiple
|
||||
// fields (the old multiplicative price rejected it at 450); it passes
|
||||
// validation and reaches the resolver like the single-field case.
|
||||
let multi_task = schema
|
||||
.execute(format!(
|
||||
"{{ tasks(limit: {MAX_VISIBLE_TASK_PAGE_SIZE}) {{ id status }} }}"
|
||||
))
|
||||
.await;
|
||||
assert!(
|
||||
!multi_task
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == "Query is too complex."),
|
||||
"two-field tasks at the maximum page size must pass complexity validation: {:?}",
|
||||
multi_task.errors
|
||||
);
|
||||
assert!(
|
||||
multi_task
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message == GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"two-field tasks at the maximum page size must reach the resolver: {:?}",
|
||||
multi_task.errors
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_repos_aliases_exceed_the_budget() {
|
||||
// `repos` is priced as the full page it scans
|
||||
// (50 + MAX_VISIBLE_REPO_PAGE_SIZE + child), so two `repos { name }`
|
||||
// aliases cost 2 * 251 = 502 > 400 and the document is rejected before
|
||||
// any resolver runs. If the price reverts to `50 + child_complexity`
|
||||
// the aliases cost 2 * 51 = 102, validation passes, and the failure
|
||||
// mode changes (resolver DB errors on the lazy pool): this test goes
|
||||
// red on revert.
|
||||
let schema = production_test_schema();
|
||||
let response = schema
|
||||
.execute("{ r0: repos { name } r1: repos { name } }")
|
||||
.await;
|
||||
assert_eq!(response.errors.len(), 1, "{:?}", response.errors);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seven_repos_page_aliases_exceed_the_budget() {
|
||||
// `reposPage` is priced as the full corpus scan
|
||||
// (50 + MAX_VISIBLE_REPO_PAGE_SIZE + child_complexity), so seven
|
||||
// `reposPage(limit: 1) { nodes { name } }` aliases cost
|
||||
// 7 * (50 + 200 + 2) = 1764 > 400 and the document is rejected
|
||||
// before any resolver runs. Under the previous row-priced formula
|
||||
// the same aliases cost 7 * (50 + 1 + 2) = 371, validation passes,
|
||||
// and the failure mode changes (resolver DB errors on the lazy
|
||||
// pool): this test goes red on revert. Eight aliases are rejected
|
||||
// under both formulas, so seven is the load-bearing boundary.
|
||||
let schema = production_test_schema();
|
||||
let fields = (0..7)
|
||||
.map(|n| format!("r{n}: reposPage(limit: 1) {{ nodes {{ name }} }}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema.execute(format!("{{ {fields} }}")).await;
|
||||
assert_eq!(response.errors.len(), 1, "{:?}", response.errors);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ref_updates_fixed_base_is_charged() {
|
||||
// The 100 base in the refUpdates complexity formula charges for the
|
||||
// collector's fixed work (loading the deduped repo set, the
|
||||
// quarantined set, and the visibility rules, then walking up to 16
|
||||
// keyset pages) even at limit: 1. Four aliases at limit: 1 cost
|
||||
// 4 * (100 + 1 + 1) = 408 > 400 and must be rejected. If the base
|
||||
// disappears, the cost drops to 4 * (1 + 1) = 8 and this test fails.
|
||||
let schema = production_test_schema();
|
||||
let fields = (0..4)
|
||||
.map(|n| format!("r{n}: refUpdates(limit: 1) {{ repo }}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let response = schema.execute(format!("{{ {fields} }}")).await;
|
||||
assert_eq!(response.errors.len(), 1, "{:?}", response.errors);
|
||||
assert_eq!(response.errors[0].message, "Query is too complex.");
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Nested;
|
||||
|
||||
#[Object]
|
||||
impl Nested {
|
||||
async fn child(&self) -> Nested {
|
||||
Nested
|
||||
}
|
||||
|
||||
async fn value(&self) -> i32 {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
struct CountingQuery(Arc<AtomicUsize>);
|
||||
|
||||
#[Object]
|
||||
impl CountingQuery {
|
||||
async fn nested(&self) -> Nested {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
Nested
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_depth_limit_accepts_fourteen_and_rejects_fifteen() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let schema = apply_query_limits(Schema::build(
|
||||
CountingQuery(Arc::clone(&calls)),
|
||||
EmptyMutation,
|
||||
EmptySubscription,
|
||||
))
|
||||
.finish();
|
||||
let query_at_depth = |depth: usize| {
|
||||
let selection = (2..depth).fold("value".to_string(), |selection, _| {
|
||||
format!("child {{ {selection} }}")
|
||||
});
|
||||
format!("{{ nested {{ {selection} }} }}")
|
||||
};
|
||||
|
||||
let accepted = schema.execute(query_at_depth(GRAPHQL_MAX_DEPTH)).await;
|
||||
|
||||
assert!(
|
||||
accepted.errors.is_empty(),
|
||||
"depth {GRAPHQL_MAX_DEPTH} should be accepted: {:?}",
|
||||
accepted.errors
|
||||
);
|
||||
assert_eq!(calls.swap(0, Ordering::Relaxed), 1);
|
||||
|
||||
let rejected = schema.execute(query_at_depth(GRAPHQL_MAX_DEPTH + 1)).await;
|
||||
|
||||
assert_eq!(rejected.data, Value::Null);
|
||||
assert_eq!(rejected.errors.len(), 1);
|
||||
assert_eq!(rejected.errors[0].message, "Query is nested too deep.");
|
||||
assert_eq!(calls.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_schema_enforces_depth_limit() {
|
||||
let schema = production_test_schema();
|
||||
// Depth 14 is accepted through the production build_schema path.
|
||||
let of_types_14 = (0..9).fold("name".to_string(), |acc, _| format!("ofType {{ {acc} }}"));
|
||||
let query_14 =
|
||||
format!("{{ __schema {{ types {{ fields {{ type {{ {of_types_14} }} }} }} }} }}");
|
||||
let accepted = schema.execute(&query_14).await;
|
||||
assert!(accepted.errors.is_empty(), "{:?}", accepted.errors);
|
||||
|
||||
// Construct an introspection query of depth 15 using __schema.
|
||||
// 1: __schema
|
||||
// 2: types
|
||||
// 3: fields
|
||||
// 4: type
|
||||
// 5..14: ofType (10 times)
|
||||
// 15: name
|
||||
// Total depth = 15 > GRAPHQL_MAX_DEPTH (14).
|
||||
let of_types = (0..10).fold("name".to_string(), |acc, _| format!("ofType {{ {acc} }}"));
|
||||
let query = format!("{{ __schema {{ types {{ fields {{ type {{ {of_types} }} }} }} }} }}");
|
||||
|
||||
let rejected = schema.execute(&query).await;
|
||||
assert_eq!(rejected.data, async_graphql::Value::Null);
|
||||
assert_eq!(rejected.errors.len(), 1);
|
||||
assert_eq!(rejected.errors[0].message, "Query is nested too deep.");
|
||||
}
|
||||
|
||||
/// Every `.map_err(` in the GraphQL query/mutation resolvers must route
|
||||
/// through the opaque helpers, or discard the error (`|_|`). Same source-
|
||||
/// scrape pattern as `api::authz_guard` (#255 review).
|
||||
|
||||
@@ -22,7 +22,6 @@ pub struct MutationRoot;
|
||||
|
||||
#[Object]
|
||||
impl MutationRoot {
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn create_task(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
@@ -90,7 +89,6 @@ impl MutationRoot {
|
||||
Ok(AgentTaskType::from(stored))
|
||||
}
|
||||
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn claim_task(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
@@ -128,7 +126,6 @@ impl MutationRoot {
|
||||
Ok(AgentTaskType::from(task))
|
||||
}
|
||||
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn complete_task(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
@@ -180,7 +177,6 @@ impl MutationRoot {
|
||||
Ok(AgentTaskType::from(task))
|
||||
}
|
||||
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn fail_task(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
|
||||
@@ -1,154 +1,57 @@
|
||||
use async_graphql::{Context, Object, Result};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::api::events::MAX_VISIBLE_REF_UPDATES;
|
||||
use crate::db::{Db, RepoRecord, MAX_VISIBLE_REPO_PAGE_SIZE, MAX_VISIBLE_TASK_PAGE_SIZE};
|
||||
use crate::db::Db;
|
||||
|
||||
use super::types::{AgentTaskType, RefUpdateType, RepoPageType, RepoType};
|
||||
|
||||
fn repo_type(repo: RepoRecord) -> RepoType {
|
||||
RepoType {
|
||||
name: repo.name,
|
||||
owner_did: repo.owner_did,
|
||||
description: repo.description,
|
||||
default_branch: repo.default_branch,
|
||||
created_at: repo.created_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
|
||||
fn repo_cursor(repo: &RepoRecord) -> String {
|
||||
URL_SAFE_NO_PAD.encode(
|
||||
serde_json::to_vec(&(&repo.owner_did, &repo.name))
|
||||
.expect("a pair of strings is JSON serializable"),
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_repo_cursor(cursor: &str) -> Result<(String, String)> {
|
||||
// A position contains only public response fields, not a database id or
|
||||
// authority. Every page re-evaluates visibility for the current caller.
|
||||
if cursor.len() > 4096 {
|
||||
return Err(async_graphql::Error::new("invalid repository cursor"));
|
||||
}
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(cursor)
|
||||
.map_err(|_| async_graphql::Error::new("invalid repository cursor"))?;
|
||||
let position: (String, String) = serde_json::from_slice(&bytes)
|
||||
.map_err(|_| async_graphql::Error::new("invalid repository cursor"))?;
|
||||
// PostgreSQL text cannot contain NUL. Reject it as malformed input before
|
||||
// binding either cursor component, rather than reporting a database error.
|
||||
if position.0.contains('\0') || position.1.contains('\0') {
|
||||
return Err(async_graphql::Error::new("invalid repository cursor"));
|
||||
}
|
||||
Ok(position)
|
||||
}
|
||||
use super::types::{AgentTaskType, RefUpdateType, RepoType};
|
||||
|
||||
pub struct QueryRoot;
|
||||
|
||||
#[Object]
|
||||
impl QueryRoot {
|
||||
// DB-backed roots carry a base cost so aliases consume the request budget
|
||||
// even when each alias selects only one inexpensive response field.
|
||||
// `repos` performs the same full corpus scan as a max-size `reposPage`
|
||||
// (list_visible_repos_page with MAX_VISIBLE_REPO_PAGE_SIZE + 1), so it
|
||||
// is priced as the full page it is, not as a cheap alias.
|
||||
#[graphql(complexity = "50 + MAX_VISIBLE_REPO_PAGE_SIZE + child_complexity")]
|
||||
/// Complete visible repository list, up to the documented maximum number
|
||||
/// of entries. Larger lists must use reposPage; this field returns an
|
||||
/// error instead of truncating silently.
|
||||
async fn repos(&self, ctx: &Context<'_>) -> Result<Vec<RepoType>> {
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let repos = db
|
||||
.list_all_repos_deduped()
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
|
||||
// Apply the same "/" visibility gate the REST/per-repo endpoints use so
|
||||
// this surface does not enumerate private repos (#97). The caller DID is
|
||||
// threaded onto the context by optional_signature; absent = anonymous.
|
||||
let caller = ctx
|
||||
.data::<crate::auth::AuthenticatedDid>()
|
||||
.ok()
|
||||
.map(|d| d.0.as_str());
|
||||
let mut repos = db
|
||||
.list_visible_repos_page(caller, None, MAX_VISIBLE_REPO_PAGE_SIZE + 1)
|
||||
let ids: Vec<String> = repos.iter().map(|r| r.id.clone()).collect();
|
||||
let rules_by_repo = db
|
||||
.list_visibility_rules_for_repos(&ids)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
if repos.len() > MAX_VISIBLE_REPO_PAGE_SIZE {
|
||||
return Err(async_graphql::Error::new(format!(
|
||||
"repository list exceeds {} entries; use reposPage with limit and after",
|
||||
MAX_VISIBLE_REPO_PAGE_SIZE
|
||||
)));
|
||||
}
|
||||
// Preserve the legacy activity ordering for complete, small lists.
|
||||
repos.sort_by_key(|repo| std::cmp::Reverse(repo.updated_at));
|
||||
Ok(repos.into_iter().map(repo_type).collect())
|
||||
|
||||
Ok(repos
|
||||
.into_iter()
|
||||
.filter(|r| {
|
||||
let rules = rules_by_repo.get(&r.id).map(Vec::as_slice).unwrap_or(&[]);
|
||||
crate::visibility::listable_at_root(rules, r.is_public, &r.owner_did, caller)
|
||||
})
|
||||
.map(|r| RepoType {
|
||||
name: r.name,
|
||||
owner_did: r.owner_did,
|
||||
description: r.description,
|
||||
default_branch: r.default_branch,
|
||||
created_at: r.created_at.to_rfc3339(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Bounded visible repositories ordered by owner and name. Continue with
|
||||
/// endCursor while hasNextPage is true. Pages are not a database snapshot.
|
||||
// Every page sorts the full deduplicated corpus under COLLATE "C"
|
||||
// (list_visible_repos_page documents the accepted O(total repos) bound),
|
||||
// so the price charges the scan, not the emitted rows: a limit:1 page
|
||||
// costs the same as a max-size page, the way `repos` is priced.
|
||||
#[graphql(complexity = "50 + MAX_VISIBLE_REPO_PAGE_SIZE + child_complexity")]
|
||||
async fn repos_page(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
#[graphql(
|
||||
default = 50,
|
||||
desc = "Page size from 1 to the documented maximum; other values are rejected."
|
||||
)]
|
||||
limit: i64,
|
||||
after: Option<String>,
|
||||
) -> Result<RepoPageType> {
|
||||
if !(1..=MAX_VISIBLE_REPO_PAGE_SIZE as i64).contains(&limit) {
|
||||
return Err(async_graphql::Error::new(format!(
|
||||
"limit must be between 1 and {}",
|
||||
MAX_VISIBLE_REPO_PAGE_SIZE
|
||||
)));
|
||||
}
|
||||
let after = after.as_deref().map(parse_repo_cursor).transpose()?;
|
||||
let caller = ctx
|
||||
.data::<crate::auth::AuthenticatedDid>()
|
||||
.ok()
|
||||
.map(|d| d.0.as_str());
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let mut repos = db
|
||||
.list_visible_repos_page(
|
||||
caller,
|
||||
after
|
||||
.as_ref()
|
||||
.map(|(owner, name)| (owner.as_str(), name.as_str())),
|
||||
limit as usize + 1,
|
||||
)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
let has_next_page = repos.len() > limit as usize;
|
||||
repos.truncate(limit as usize);
|
||||
let end_cursor = repos.last().map(repo_cursor);
|
||||
Ok(RepoPageType {
|
||||
nodes: repos.into_iter().map(repo_type).collect(),
|
||||
has_next_page,
|
||||
end_cursor,
|
||||
})
|
||||
}
|
||||
|
||||
// Complexity is additive with a base reflecting the collector's fixed scan:
|
||||
// every request loads the full deduped repo set, the quarantined set, and
|
||||
// the visibility rules, then walks up to max(limit, 2048) event rows past
|
||||
// withheld ones, regardless of `limit`. A multiplicative price both
|
||||
// undercharged limit:1 (51 for that fixed work) and made a request at the
|
||||
// documented maximum unreachable (two fields at max limit cost 450 > 400).
|
||||
//
|
||||
// Decision on the collector floor (review #465, 3rd round): the up-to-2048
|
||||
// walk is bounded constant overhead that does not scale with `limit`, so
|
||||
// it is priced in the fixed base, not the limit term; the limit term
|
||||
// prices only the emitted rows. Pricing the walk at face value in the base
|
||||
// would put every refUpdates query over the 400 budget and make the field
|
||||
// unusable, so the base prices that fixed scan as a lump sum instead.
|
||||
#[graphql(
|
||||
complexity = "100 + (limit.clamp(0, MAX_VISIBLE_REF_UPDATES) as usize) + child_complexity"
|
||||
)]
|
||||
async fn ref_updates(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
repo: Option<String>,
|
||||
#[graphql(
|
||||
default = 20,
|
||||
desc = "Requests above the documented maximum return the newest rows up to that maximum (no continuation cursor)."
|
||||
desc = "Max 200; larger requests return the newest 200 rows (no continuation cursor)."
|
||||
)]
|
||||
limit: i64,
|
||||
) -> Result<Vec<RefUpdateType>> {
|
||||
@@ -202,14 +105,6 @@ impl QueryRoot {
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
// Complexity is additive: list_tasks is a single LIMIT-bounded query whose
|
||||
// cost does not scale with the selected fields, so the price is the base
|
||||
// plus the clamped limit plus the child fields. A multiplicative price
|
||||
// made a request at the documented maximum unreachable (two fields at max
|
||||
// limit cost 450 > 400).
|
||||
#[graphql(
|
||||
complexity = "50 + (limit.clamp(0, MAX_VISIBLE_TASK_PAGE_SIZE) as usize) + child_complexity"
|
||||
)]
|
||||
async fn tasks(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
@@ -217,7 +112,7 @@ impl QueryRoot {
|
||||
assignee_did: Option<String>,
|
||||
#[graphql(
|
||||
default = 50,
|
||||
desc = "Requests above the documented maximum are clamped to it (no error). Negative values clamp to 0."
|
||||
desc = "Max 200; larger requests are clamped to 200 (no error). Negative values clamp to 0."
|
||||
)]
|
||||
limit: i64,
|
||||
) -> Result<Vec<AgentTaskType>> {
|
||||
@@ -225,7 +120,7 @@ impl QueryRoot {
|
||||
// Clamp before SQL: a negative LIMIT is a client fault that Postgres
|
||||
// rejects with 2201W, which would otherwise trip the opaque DB path
|
||||
// and write an error-level log on every probe (#250 review).
|
||||
let limit = limit.clamp(0, MAX_VISIBLE_TASK_PAGE_SIZE);
|
||||
let limit = limit.clamp(0, 200);
|
||||
let tasks = db
|
||||
.list_tasks(status.as_deref(), assignee_did.as_deref(), limit)
|
||||
.await
|
||||
@@ -233,7 +128,6 @@ impl QueryRoot {
|
||||
Ok(tasks.into_iter().map(AgentTaskType::from).collect())
|
||||
}
|
||||
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn task(&self, ctx: &Context<'_>, id: String) -> Result<Option<AgentTaskType>> {
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
let t = db
|
||||
@@ -247,642 +141,12 @@ impl QueryRoot {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::db::{Db, ReceivedRefUpdate, RepoRecord};
|
||||
use base64::Engine;
|
||||
use chrono::Utc;
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
|
||||
const OWNER: &str = "did:key:z6MkOwner";
|
||||
|
||||
#[sqlx::test]
|
||||
async fn repos_legacy_rejects_overflow_and_pages_reach_every_visible_repo(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let total = crate::db::MAX_VISIBLE_REPO_PAGE_SIZE + 5;
|
||||
for index in 0..total {
|
||||
let name = format!("repo-{index:03}");
|
||||
db.create_repo(&repo(&name, OWNER, &name, true))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
// The SQL helper itself must bound materialization, even for a caller
|
||||
// accidentally requesting an unlimited page.
|
||||
assert_eq!(
|
||||
db.list_visible_repos_page(None, None, usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
crate::db::MAX_VISIBLE_REPO_PAGE_SIZE + 1
|
||||
);
|
||||
let schema = schema(db);
|
||||
let legacy = anon(&schema, "{ repos { name } }").await;
|
||||
assert_eq!(legacy.errors.len(), 1);
|
||||
assert!(legacy.errors[0].message.contains("use reposPage"));
|
||||
assert_eq!(legacy.data, async_graphql::Value::Null);
|
||||
|
||||
let mut cursor = None;
|
||||
let mut names = Vec::new();
|
||||
loop {
|
||||
let response = schema.execute(
|
||||
async_graphql::Request::new(
|
||||
"query($after: String) { reposPage(limit: 50, after: $after) { nodes { name } hasNextPage endCursor } }",
|
||||
).variables(async_graphql::Variables::from_json(serde_json::json!({"after": cursor}))),
|
||||
).await;
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
let json = response.data.into_json().unwrap();
|
||||
let page = &json["reposPage"];
|
||||
let rows = page["nodes"].as_array().unwrap();
|
||||
assert!(rows.len() <= 50);
|
||||
names.extend(
|
||||
rows.iter()
|
||||
.map(|row| row["name"].as_str().unwrap().to_owned()),
|
||||
);
|
||||
if !page["hasNextPage"].as_bool().unwrap() {
|
||||
break;
|
||||
}
|
||||
let next = page["endCursor"].as_str().unwrap().to_owned();
|
||||
assert_ne!(cursor.as_ref(), Some(&next));
|
||||
cursor = Some(next);
|
||||
assert!(names.len() <= crate::db::MAX_VISIBLE_REPO_PAGE_SIZE);
|
||||
}
|
||||
assert_eq!(
|
||||
names,
|
||||
(0..total)
|
||||
.map(|index| format!("repo-{index:03}"))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn repos_legacy_accepts_exactly_the_visible_bound(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let base_time = Utc::now();
|
||||
let total = crate::db::MAX_VISIBLE_REPO_PAGE_SIZE;
|
||||
for index in 0..total {
|
||||
let name = format!("repo-{index:03}");
|
||||
let mut r = repo(&name, OWNER, &name, true);
|
||||
// Anti-correlate updated_at with name, creation order, and index
|
||||
// so only ordering by updated_at DESC can satisfy the expectation.
|
||||
let offset = (index * 37) % total;
|
||||
r.updated_at = base_time + chrono::Duration::seconds(offset as i64);
|
||||
db.create_repo(&r).await.unwrap();
|
||||
}
|
||||
db.create_repo(&repo("hidden", OWNER, "hidden", false))
|
||||
.await
|
||||
.unwrap();
|
||||
let response = anon(&schema(db), "{ repos { name } }").await;
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
let repos = response.data.into_json().unwrap()["repos"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(repos.len(), total);
|
||||
let names: Vec<_> = repos.iter().filter_map(|r| r["name"].as_str()).collect();
|
||||
assert!(
|
||||
!names.contains(&"hidden"),
|
||||
"hidden repo must be excluded from legacy repos response"
|
||||
);
|
||||
let mut expected_indices = (0..total).collect::<Vec<_>>();
|
||||
expected_indices.sort_by_key(|&idx| std::cmp::Reverse((idx * 37) % total));
|
||||
let expected_names: Vec<String> = expected_indices
|
||||
.into_iter()
|
||||
.map(|index| format!("repo-{index:03}"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
expected_names
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
"legacy repos must retain activity ordering (most recently updated first)"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn repos_page_pagination_crosses_owner_boundary(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let owner1 = "did:key:z6MkaOwner1";
|
||||
let owner2 = "did:key:z6MkbOwner2";
|
||||
let earlier_owner = "did:web:example.com";
|
||||
// This owner sorts first only after did:key normalization. Its name
|
||||
// sorts past the first cursor, so an unqualified OR name predicate
|
||||
// would incorrectly serve it again on the next page.
|
||||
//
|
||||
// Rows are inserted out of order and not in reverse asserted order.
|
||||
// Assigned IDs do not track name order, and owner2 contains both
|
||||
// "B-repo" and "a-repo" whose byte order (B < a) differs from their
|
||||
// case-folded order (a < b), ensuring lower(d.name) cannot satisfy
|
||||
// the keyset query. Sized to 6 entries so the last page is full-sized
|
||||
// (limit 2), proving hasNextPage distinguishes a full terminal page.
|
||||
db.create_repo(&repo("r5", owner2, "c-repo", true))
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_repo(&repo("r9", earlier_owner, "zz-repo", true))
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_repo(&repo("r3", owner2, "B-repo", true))
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_repo(&repo("r8", owner1, "z-repo", true))
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_repo(&repo("r2", owner1, "b-repo", true))
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_repo(&repo("r1", owner2, "a-repo", true))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let schema = schema(db);
|
||||
let page1_resp = anon(
|
||||
&schema,
|
||||
"{ reposPage(limit: 2) { nodes { name ownerDid } hasNextPage endCursor } }",
|
||||
)
|
||||
.await;
|
||||
assert!(page1_resp.errors.is_empty(), "{:?}", page1_resp.errors);
|
||||
let p1 = page1_resp.data.into_json().unwrap()["reposPage"].clone();
|
||||
assert_eq!(p1["hasNextPage"], true);
|
||||
assert_eq!(
|
||||
p1["nodes"],
|
||||
serde_json::json!([
|
||||
{"name": "zz-repo", "ownerDid": earlier_owner},
|
||||
{"name": "b-repo", "ownerDid": owner1},
|
||||
])
|
||||
);
|
||||
let cursor = p1["endCursor"].as_str().unwrap();
|
||||
|
||||
let page2_query = format!(
|
||||
"{{ reposPage(limit: 2, after: \"{cursor}\") {{ nodes {{ name ownerDid }} hasNextPage endCursor }} }}"
|
||||
);
|
||||
let page2_resp = anon(&schema, &page2_query).await;
|
||||
assert!(page2_resp.errors.is_empty(), "{:?}", page2_resp.errors);
|
||||
let p2 = page2_resp.data.into_json().unwrap()["reposPage"].clone();
|
||||
assert_eq!(p2["hasNextPage"], true);
|
||||
assert_eq!(
|
||||
p2["nodes"],
|
||||
serde_json::json!([
|
||||
{"name": "z-repo", "ownerDid": owner1},
|
||||
{"name": "B-repo", "ownerDid": owner2},
|
||||
])
|
||||
);
|
||||
let cursor = p2["endCursor"].as_str().unwrap();
|
||||
let page3_query = format!(
|
||||
"{{ reposPage(limit: 2, after: \"{cursor}\") {{ nodes {{ name ownerDid }} hasNextPage endCursor }} }}"
|
||||
);
|
||||
let page3_resp = anon(&schema, &page3_query).await;
|
||||
assert!(page3_resp.errors.is_empty(), "{:?}", page3_resp.errors);
|
||||
let p3 = page3_resp.data.into_json().unwrap()["reposPage"].clone();
|
||||
assert_eq!(p3["hasNextPage"], false);
|
||||
assert_eq!(
|
||||
p3["nodes"],
|
||||
serde_json::json!([
|
||||
{"name": "a-repo", "ownerDid": owner2},
|
||||
{"name": "c-repo", "ownerDid": owner2},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn repos_page_at_documented_maximum_distinguishes_terminal_page(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let total = crate::db::MAX_VISIBLE_REPO_PAGE_SIZE;
|
||||
for index in 0..total {
|
||||
let name = format!("repo-{index:03}");
|
||||
db.create_repo(&repo(&name, OWNER, &name, true))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let schema = schema(db.clone());
|
||||
let query =
|
||||
format!("{{ reposPage(limit: {total}) {{ nodes {{ name }} hasNextPage endCursor }} }}");
|
||||
let resp200 = anon(&schema, &query).await;
|
||||
assert!(resp200.errors.is_empty(), "{:?}", resp200.errors);
|
||||
let p200 = resp200.data.into_json().unwrap()["reposPage"].clone();
|
||||
assert_eq!(p200["hasNextPage"], false);
|
||||
let nodes200 = p200["nodes"].as_array().unwrap().clone();
|
||||
assert_eq!(nodes200.len(), total);
|
||||
|
||||
// Adding row 201 keeps page 1 content identical while flipping hasNextPage to true.
|
||||
let name201 = format!("repo-{total:03}");
|
||||
db.create_repo(&repo(&name201, OWNER, &name201, true))
|
||||
.await
|
||||
.unwrap();
|
||||
let resp201 = anon(&schema, &query).await;
|
||||
assert!(resp201.errors.is_empty(), "{:?}", resp201.errors);
|
||||
let p201 = resp201.data.into_json().unwrap()["reposPage"].clone();
|
||||
assert_eq!(p201["hasNextPage"], true);
|
||||
assert_eq!(p201["nodes"], p200["nodes"]);
|
||||
assert_eq!(p201["endCursor"], p200["endCursor"]);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn repos_page_visibility_matches_the_shared_gate(pool: PgPool) {
|
||||
use crate::db::VisibilityMode;
|
||||
let db = db(pool).await;
|
||||
let reader = "did:key:zReader";
|
||||
for (id, public) in [
|
||||
("open", true),
|
||||
("private", false),
|
||||
("root-deny", true),
|
||||
("root-reader", false),
|
||||
("subtree", true),
|
||||
("root-tie", true),
|
||||
("odd-star", true),
|
||||
("malformed-readers", true),
|
||||
("non-array-readers", true),
|
||||
("non-string-reader", true),
|
||||
("quarantined", true),
|
||||
] {
|
||||
db.create_repo(&repo(id, OWNER, id, public)).await.unwrap();
|
||||
}
|
||||
// Canonical and mirror copies must still collapse before pagination.
|
||||
db.create_repo(&repo("z6MkOwner/open", "z6MkOwner", "open", true))
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_repo(&repo(
|
||||
"other-method",
|
||||
"did:web:z6MkOwner",
|
||||
"other-method",
|
||||
false,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
db.set_repo_quarantine("quarantined", true).await.unwrap();
|
||||
for (id, glob, readers) in [
|
||||
("root-deny", "/", vec![]),
|
||||
("root-reader", "/**", vec![reader.to_owned()]),
|
||||
("subtree", "/secret/**", vec![]),
|
||||
("root-tie", "/", vec![reader.to_owned()]),
|
||||
("root-tie", "/**", vec![]),
|
||||
("odd-star", "/*", vec![]),
|
||||
("malformed-readers", "/", vec![]),
|
||||
("non-array-readers", "/", vec![]),
|
||||
("non-string-reader", "/", vec![]),
|
||||
] {
|
||||
db.set_visibility_rule(id, glob, VisibilityMode::B, &readers, OWNER)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
// The typed setter cannot create malformed JSON, but existing TEXT rows
|
||||
// can contain it. Such a rule must deny this repo, not abort the page.
|
||||
// All three deny arms of the reader_dids predicate are pinned here:
|
||||
// (a) invalid JSON, (b) valid JSON that is not an array, (c) an array
|
||||
// containing a non-string member. The Rust gate parses reader_dids as
|
||||
// Vec<String> with unwrap_or_default(), so (b) and (c) also fail to
|
||||
// parse and deny; the SQL CASE must agree on every shape.
|
||||
let rows = sqlx::query("UPDATE visibility_rules SET reader_dids = $1 WHERE repo_id = $2")
|
||||
.bind("not JSON")
|
||||
.bind("malformed-readers")
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.rows_affected(), 1);
|
||||
let rows = sqlx::query("UPDATE visibility_rules SET reader_dids = $1 WHERE repo_id = $2")
|
||||
.bind("\"just-a-string\"")
|
||||
.bind("non-array-readers")
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.rows_affected(), 1);
|
||||
// The named reader is listed, but the non-string member poisons the
|
||||
// whole list: both sides must still deny them.
|
||||
let rows = sqlx::query("UPDATE visibility_rules SET reader_dids = $1 WHERE repo_id = $2")
|
||||
.bind("[\"did:key:zReader\", 42]")
|
||||
.bind("non-string-reader")
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.rows_affected(), 1);
|
||||
let all = db.list_all_repos_deduped().await.unwrap();
|
||||
for caller in [
|
||||
None,
|
||||
Some(OWNER),
|
||||
Some("z6MkOwner"),
|
||||
Some(reader),
|
||||
Some("zReader"),
|
||||
Some("did:web:z6MkOwner"),
|
||||
] {
|
||||
let mut expected = Vec::new();
|
||||
for record in &all {
|
||||
let rules = db.list_visibility_rules(&record.id).await.unwrap();
|
||||
if crate::visibility::listable_at_root(
|
||||
&rules,
|
||||
record.is_public,
|
||||
&record.owner_did,
|
||||
caller,
|
||||
) {
|
||||
expected.push(record.id.clone());
|
||||
}
|
||||
}
|
||||
expected.sort();
|
||||
let mut actual = db
|
||||
.list_visible_repos_page(caller, None, usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|record| record.id)
|
||||
.collect::<Vec<_>>();
|
||||
actual.sort();
|
||||
assert_eq!(actual, expected, "caller {caller:?}");
|
||||
}
|
||||
let schema = schema(db);
|
||||
for caller in [None, Some("did:key:zUnauthorized")] {
|
||||
let query = "{ repos { name } }";
|
||||
let response = match caller {
|
||||
None => anon(&schema, query).await,
|
||||
Some(caller) => authed(&schema, query, caller).await,
|
||||
};
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
let mut names: Vec<String> = response.data.into_json().unwrap()["repos"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|repo| repo["name"].as_str().unwrap().to_owned())
|
||||
.collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["odd-star", "open", "subtree"]);
|
||||
}
|
||||
let query = "{ reposPage(limit: 1) { nodes { name ownerDid } hasNextPage endCursor } }";
|
||||
let response = anon(&schema, query).await;
|
||||
assert!(response.errors.is_empty(), "{:?}", response.errors);
|
||||
let json = response.data.into_json().unwrap();
|
||||
let page = &json["reposPage"];
|
||||
assert_eq!(
|
||||
page["nodes"],
|
||||
serde_json::json!([{"name": "odd-star", "ownerDid": OWNER}])
|
||||
);
|
||||
assert_eq!(page["hasNextPage"], true);
|
||||
let cursor = super::parse_repo_cursor(page["endCursor"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(cursor, (OWNER.to_owned(), "odd-star".to_owned()));
|
||||
assert!(!json.to_string().contains("private"));
|
||||
assert!(!json.to_string().contains("quarantined"));
|
||||
|
||||
// Route-level check with unauthorized authenticated caller:
|
||||
// Excludes private, quarantined, and root-deny repos, matching anonymous behavior.
|
||||
let all_query = "{ reposPage(limit: 50) { nodes { name } hasNextPage } }";
|
||||
let unauth_response = authed(&schema, all_query, "did:key:zUnauthorized").await;
|
||||
assert!(
|
||||
unauth_response.errors.is_empty(),
|
||||
"{:?}",
|
||||
unauth_response.errors
|
||||
);
|
||||
let unauth_json = unauth_response.data.into_json().unwrap();
|
||||
let unauth_names: Vec<&str> = unauth_json["reposPage"]["nodes"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|n| n["name"].as_str())
|
||||
.collect();
|
||||
assert_eq!(unauth_names, vec!["odd-star", "open", "subtree"]);
|
||||
let unauth_str = unauth_json.to_string();
|
||||
assert!(!unauth_str.contains("private"));
|
||||
assert!(!unauth_str.contains("quarantined"));
|
||||
assert!(!unauth_str.contains("root-deny"));
|
||||
assert!(!unauth_str.contains("root-reader"));
|
||||
assert!(!unauth_str.contains("root-tie"));
|
||||
|
||||
// Reader caller gets root-reader and root-tie, but still excludes private, quarantined, root-deny.
|
||||
let reader_response = authed(&schema, all_query, reader).await;
|
||||
assert!(
|
||||
reader_response.errors.is_empty(),
|
||||
"{:?}",
|
||||
reader_response.errors
|
||||
);
|
||||
let reader_json = reader_response.data.into_json().unwrap();
|
||||
let reader_names: Vec<&str> = reader_json["reposPage"]["nodes"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|n| n["name"].as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
reader_names,
|
||||
vec!["odd-star", "open", "root-reader", "subtree"]
|
||||
);
|
||||
let reader_str = reader_json.to_string();
|
||||
assert!(!reader_str.contains("private"));
|
||||
assert!(!reader_str.contains("quarantined"));
|
||||
assert!(!reader_str.contains("root-deny"));
|
||||
assert!(!reader_str.contains("root-tie"));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn repos_page_rechecks_cursor_authority_and_exact_boundary(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let visible = repo("visible", OWNER, "a-visible", true);
|
||||
db.create_repo(&visible).await.unwrap();
|
||||
db.create_repo(&repo("hidden", OWNER, "z-private", false))
|
||||
.await
|
||||
.unwrap();
|
||||
let schema = schema(db);
|
||||
let first = anon(
|
||||
&schema,
|
||||
"{ reposPage(limit: 1) { nodes { name } hasNextPage endCursor } }",
|
||||
)
|
||||
.await;
|
||||
assert!(first.errors.is_empty());
|
||||
let first = first.data.into_json().unwrap();
|
||||
assert_eq!(first["reposPage"]["hasNextPage"], false);
|
||||
let query = format!(
|
||||
"{{ reposPage(limit: 1, after: \"{}\") {{ nodes {{ name }} hasNextPage endCursor }} }}",
|
||||
super::repo_cursor(&visible)
|
||||
);
|
||||
let owner = authed(&schema, &query, OWNER).await;
|
||||
assert!(owner.errors.is_empty());
|
||||
assert_eq!(
|
||||
owner.data.into_json().unwrap()["reposPage"]["nodes"][0]["name"],
|
||||
"z-private"
|
||||
);
|
||||
let anon = anon(&schema, &query).await;
|
||||
assert!(anon.errors.is_empty());
|
||||
assert_eq!(
|
||||
anon.data.into_json().unwrap()["reposPage"],
|
||||
serde_json::json!({
|
||||
"nodes": [], "hasNextPage": false, "endCursor": null
|
||||
})
|
||||
);
|
||||
let unauth = authed(&schema, &query, "did:key:zUnauthorized").await;
|
||||
assert!(unauth.errors.is_empty());
|
||||
assert_eq!(
|
||||
unauth.data.into_json().unwrap()["reposPage"],
|
||||
serde_json::json!({
|
||||
"nodes": [], "hasNextPage": false, "endCursor": null
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repos_page_rejects_nul_cursors_before_database_access() {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
let schema = schema(Arc::new(Db::for_testing(pool)));
|
||||
for position in [("did:key:zOwner\0", "repo"), ("did:key:zOwner", "repo\0")] {
|
||||
let cursor = super::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&position).unwrap());
|
||||
let query = format!("{{ reposPage(after: \"{cursor}\") {{ hasNextPage }} }}");
|
||||
let response =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), anon(&schema, &query))
|
||||
.await
|
||||
.expect("invalid cursors must be rejected without accessing the database");
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(response.errors[0].message, "invalid repository cursor");
|
||||
assert_eq!(response.data, async_graphql::Value::Null);
|
||||
}
|
||||
let position = ("did:key:zOwner", "repo");
|
||||
let cursor = super::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&position).unwrap());
|
||||
assert_eq!(
|
||||
super::parse_repo_cursor(&cursor).unwrap(),
|
||||
(position.0.to_owned(), position.1.to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repos_page_rejects_invalid_inputs_before_database_access() {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
let schema = schema(Arc::new(Db::for_testing(pool)));
|
||||
let over_max = crate::db::MAX_VISIBLE_REPO_PAGE_SIZE + 1;
|
||||
let expected_limit_message = format!(
|
||||
"limit must be between 1 and {}",
|
||||
crate::db::MAX_VISIBLE_REPO_PAGE_SIZE
|
||||
);
|
||||
// Each input pins its own rejection message: the loop must not accept
|
||||
// either message for every query, or a misattributed message (e.g. the
|
||||
// limit arm returning "invalid repository cursor") stays invisible.
|
||||
for (query, expected_message) in [
|
||||
(
|
||||
"{ reposPage(limit: 0) { hasNextPage } }".to_owned(),
|
||||
expected_limit_message.clone(),
|
||||
),
|
||||
(
|
||||
"{ reposPage(limit: -1) { hasNextPage } }".to_owned(),
|
||||
expected_limit_message.clone(),
|
||||
),
|
||||
(
|
||||
format!("{{ reposPage(limit: {over_max}) {{ hasNextPage }} }}"),
|
||||
expected_limit_message.clone(),
|
||||
),
|
||||
(
|
||||
"{ reposPage(after: \"invalid!\") { hasNextPage } }".to_owned(),
|
||||
"invalid repository cursor".to_owned(),
|
||||
),
|
||||
] {
|
||||
let response =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), anon(&schema, &query))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(
|
||||
response.errors[0].message, expected_message,
|
||||
"wrong rejection message for query: {query}"
|
||||
);
|
||||
}
|
||||
// Valid base64 and valid cursor JSON: only the length guard rejects it.
|
||||
let oversized = super::URL_SAFE_NO_PAD
|
||||
.encode(serde_json::to_vec(&("did:key:reader", "a".repeat(3100))).unwrap());
|
||||
assert!(oversized.len() > 4096);
|
||||
assert!(serde_json::from_slice::<(String, String)>(
|
||||
&super::URL_SAFE_NO_PAD.decode(&oversized).unwrap()
|
||||
)
|
||||
.is_ok());
|
||||
assert!(super::parse_repo_cursor(&oversized).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parse_repo_cursor_rejects_wrong_shape_json() {
|
||||
// Valid base64 and valid JSON, but not a (String, String) pair: the
|
||||
// wrong-shape arm must report "invalid repository cursor", exactly,
|
||||
// before any database access.
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
let schema = schema(Arc::new(Db::for_testing(pool)));
|
||||
for payload in [
|
||||
serde_json::json!(["only-one"]),
|
||||
serde_json::json!(["a", "b", "c"]),
|
||||
serde_json::json!({"owner": "x", "name": "y"}),
|
||||
serde_json::json!(42),
|
||||
serde_json::json!(["a", 1]),
|
||||
] {
|
||||
let cursor = super::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap());
|
||||
let err = super::parse_repo_cursor(&cursor).unwrap_err();
|
||||
assert_eq!(err.message, "invalid repository cursor");
|
||||
let query = format!("{{ reposPage(after: \"{cursor}\") {{ hasNextPage }} }}");
|
||||
let response =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), anon(&schema, &query))
|
||||
.await
|
||||
.expect("wrong-shape cursors must be rejected without accessing the database");
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(response.errors[0].message, "invalid repository cursor");
|
||||
assert_eq!(response.data, async_graphql::Value::Null);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn longest_repo_name_cursor_round_trips() {
|
||||
// #465 4th round: a cursor the server emits must always parse back.
|
||||
// Repo names are bounded at the create/fork/mirror write paths, so the
|
||||
// longest admissible name must produce a cursor under the 4096-byte
|
||||
// parse limit.
|
||||
let name = "a".repeat(crate::db::MAX_REPO_NAME_LEN);
|
||||
let record = repo("long", OWNER, &name, true);
|
||||
let cursor = super::repo_cursor(&record);
|
||||
assert!(
|
||||
cursor.len() <= 4096,
|
||||
"emitted cursor must stay under the parse limit: {}",
|
||||
cursor.len()
|
||||
);
|
||||
assert_eq!(
|
||||
super::parse_repo_cursor(&cursor).unwrap(),
|
||||
(OWNER.to_owned(), name)
|
||||
);
|
||||
}
|
||||
|
||||
/// A negative refUpdates(limit) must be accepted and clamped to zero rows,
|
||||
/// like tasks(limit: -1): the complexity formula's `limit.clamp(0, ...)`
|
||||
/// prices it at the floor and the shared collector returns before any
|
||||
/// database access. Mirrors tasks_negative_limit_clamped.
|
||||
#[tokio::test]
|
||||
async fn ref_updates_negative_limit_clamped() {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
let schema = schema(Arc::new(Db::for_testing(pool)));
|
||||
let resp = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
anon(&schema, "{ refUpdates(limit: -1) { repo } }"),
|
||||
)
|
||||
.await
|
||||
.expect("negative limit must clamp without accessing the database");
|
||||
assert!(
|
||||
resp.errors.is_empty(),
|
||||
"negative limit must clamp, not fail: {:?}",
|
||||
resp.errors
|
||||
);
|
||||
assert_eq!(count(&resp), 0);
|
||||
}
|
||||
|
||||
/// The mirror write path binds peer-supplied names verbatim, so it must
|
||||
/// enforce the same bound as the local write paths: an overlong peer name
|
||||
/// would otherwise make reposPage emit an unparseable cursor. The
|
||||
/// rejection happens before any database access.
|
||||
#[tokio::test]
|
||||
async fn mirror_repo_rejects_overlong_name() {
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy("postgres://localhost/unused")
|
||||
.unwrap();
|
||||
let db = Db::for_testing(pool);
|
||||
let name = "b".repeat(crate::db::MAX_REPO_NAME_LEN + 1);
|
||||
let err = db
|
||||
.upsert_mirror_repo("z6MkLong", &name, "/tmp/long", None, false)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("exceeds"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
async fn db(pool: PgPool) -> Arc<Db> {
|
||||
let db = Db::for_testing(pool);
|
||||
db.run_migrations().await.unwrap();
|
||||
@@ -1211,13 +475,12 @@ mod tests {
|
||||
assert_eq!(count_tasks(&resp), 0);
|
||||
}
|
||||
|
||||
/// #255: ceiling of the tasks(limit) clamp must be held (schema promises the
|
||||
/// documented maximum).
|
||||
/// #255: ceiling of the tasks(limit) clamp must be held (schema promises Max 200).
|
||||
#[sqlx::test]
|
||||
async fn tasks_limit_ceiling_clamped_to_max(pool: PgPool) {
|
||||
async fn tasks_limit_ceiling_clamped_to_200(pool: PgPool) {
|
||||
let db = db(pool).await;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
for i in 0..crate::db::MAX_VISIBLE_TASK_PAGE_SIZE + 1 {
|
||||
for i in 0..201 {
|
||||
db.create_task(&crate::db::AgentTask {
|
||||
id: format!("task-ceil-{i}"),
|
||||
repo_id: None,
|
||||
@@ -1238,49 +501,7 @@ mod tests {
|
||||
}
|
||||
let schema = schema(db);
|
||||
let resp = anon(&schema, "{ tasks(limit: 5000) { id } }").await;
|
||||
assert_eq!(
|
||||
count_tasks(&resp),
|
||||
crate::db::MAX_VISIBLE_TASK_PAGE_SIZE as usize,
|
||||
"limit above the maximum must clamp to MAX_VISIBLE_TASK_PAGE_SIZE"
|
||||
);
|
||||
}
|
||||
|
||||
/// A limit above MAX_VISIBLE_REF_UPDATES must reach the resolver clamped,
|
||||
/// not be rejected: the complexity formula clamps the priced limit (so the
|
||||
/// document passes validation) and the shared collector clamps the rows
|
||||
/// (so at most MAX_VISIBLE_REF_UPDATES come back). Removing the formula
|
||||
/// clamp prices the raw limit and rejects the document; removing the
|
||||
/// collector clamp returns unbounded rows. Mirrors
|
||||
/// tasks_limit_ceiling_clamped_to_max.
|
||||
#[sqlx::test]
|
||||
async fn ref_updates_limit_ceiling_clamped_to_max(pool: PgPool) {
|
||||
use crate::api::events::MAX_VISIBLE_REF_UPDATES;
|
||||
let db = db(pool).await;
|
||||
db.create_repo(&repo("r1", OWNER, "widget", true))
|
||||
.await
|
||||
.unwrap();
|
||||
let total = MAX_VISIBLE_REF_UPDATES as usize + 1;
|
||||
for i in 0..total {
|
||||
let mut row = ref_row(&format!("ceil{i}"), "z6MkOwner/widget");
|
||||
row.timestamp = format!("2026-07-01T10:{:02}:{:02}+00:00", i / 60, i % 60);
|
||||
db.insert_ref_update(&row).await.unwrap();
|
||||
}
|
||||
let schema = schema(db);
|
||||
let query = format!(
|
||||
"{{ refUpdates(limit: {}) {{ repo }} }}",
|
||||
MAX_VISIBLE_REF_UPDATES + 4800
|
||||
);
|
||||
let resp = anon(&schema, &query).await;
|
||||
assert!(
|
||||
resp.errors.is_empty(),
|
||||
"limit above the maximum must clamp, not fail: {:?}",
|
||||
resp.errors
|
||||
);
|
||||
assert_eq!(
|
||||
count(&resp),
|
||||
MAX_VISIBLE_REF_UPDATES as usize,
|
||||
"limit above the maximum must clamp to MAX_VISIBLE_REF_UPDATES"
|
||||
);
|
||||
assert_eq!(count_tasks(&resp), 200, "limit above 200 must clamp to 200");
|
||||
}
|
||||
|
||||
fn count_tasks(resp: &async_graphql::Response) -> usize {
|
||||
|
||||
@@ -20,7 +20,6 @@ impl SubscriptionRoot {
|
||||
/// `if announce` block, `api/repos.rs`). This is a single-point invariant —
|
||||
/// any new sender to `ref_update_tx` MUST be `announce`-gated, or private-repo
|
||||
/// ref metadata leaks here to unauthenticated subscribers (#112/#114 class).
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn ref_updates(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
@@ -47,7 +46,6 @@ impl SubscriptionRoot {
|
||||
})
|
||||
}
|
||||
|
||||
#[graphql(complexity = "50 + child_complexity")]
|
||||
async fn task_events(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
|
||||
@@ -11,14 +11,6 @@ pub struct RepoType {
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(SimpleObject)]
|
||||
pub struct RepoPageType {
|
||||
pub nodes: Vec<RepoType>,
|
||||
pub has_next_page: bool,
|
||||
/// Position after the last returned visible repository; null for an empty page.
|
||||
pub end_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(SimpleObject, Clone)]
|
||||
pub struct AgentTaskType {
|
||||
pub id: String,
|
||||
|
||||
@@ -2638,7 +2638,6 @@ mod tests {
|
||||
/// type probe carries its oid on stdin rather than in argv, so an oid appears in
|
||||
/// the log only once an object has already got past its probe, and a healthy
|
||||
/// object costs two invocations to a faulting one's one.
|
||||
#[cfg(unix)]
|
||||
fn objects_attempted(log: &std::path::Path) -> usize {
|
||||
std::fs::read_to_string(log)
|
||||
.unwrap_or_default()
|
||||
|
||||
@@ -51,7 +51,6 @@ use state::AppState;
|
||||
struct DegradedState {
|
||||
node_did: String,
|
||||
db_startup: Arc<DbStartupStatus>,
|
||||
web_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Two independent counters with no cross-field invariant — atomics, not a
|
||||
@@ -160,7 +159,6 @@ async fn main() -> Result<()> {
|
||||
degraded_listener,
|
||||
node_did.to_string(),
|
||||
Arc::clone(&db_startup),
|
||||
config.web_url.clone(),
|
||||
db_ready_rx,
|
||||
shutdown_tx.subscribe(),
|
||||
));
|
||||
@@ -874,12 +872,11 @@ async fn run_degraded_server(
|
||||
listener: TcpListener,
|
||||
node_did: String,
|
||||
db_startup: Arc<DbStartupStatus>,
|
||||
web_url: Option<String>,
|
||||
mut db_ready_rx: watch::Receiver<bool>,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
) -> Result<()> {
|
||||
let addr = listener.local_addr().ok();
|
||||
let router = build_degraded_router(node_did, db_startup, web_url);
|
||||
let router = build_degraded_router(node_did, db_startup);
|
||||
info!(?addr, "degraded HTTP server ready");
|
||||
|
||||
axum::serve(listener, router)
|
||||
@@ -896,15 +893,10 @@ async fn run_degraded_server(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_degraded_router(
|
||||
node_did: String,
|
||||
db_startup: Arc<DbStartupStatus>,
|
||||
web_url: Option<String>,
|
||||
) -> Router {
|
||||
fn build_degraded_router(node_did: String, db_startup: Arc<DbStartupStatus>) -> Router {
|
||||
let state = DegradedState {
|
||||
node_did,
|
||||
db_startup,
|
||||
web_url,
|
||||
};
|
||||
// Everything answers 503 with the same body — including /health and
|
||||
// /ready, so peer readiness probes and uptime monitors correctly see a
|
||||
@@ -936,9 +928,6 @@ async fn degraded_node_info(State(state): State<DegradedState>) -> impl IntoResp
|
||||
obj.insert("name".into(), "gitlawb-node".into());
|
||||
obj.insert("version".into(), env!("CARGO_PKG_VERSION").into());
|
||||
obj.insert("did".into(), state.node_did.clone().into());
|
||||
if let Some(web_url) = &state.web_url {
|
||||
obj.insert("web_url".into(), web_url.clone().into());
|
||||
}
|
||||
}
|
||||
(StatusCode::SERVICE_UNAVAILABLE, Json(body))
|
||||
}
|
||||
|
||||
@@ -912,7 +912,6 @@ mod tests {
|
||||
/// probe carries its oid on stdin rather than in argv, so an oid appears in the log only
|
||||
/// once an object has already got past its probe, and a healthy object costs two
|
||||
/// invocations to a faulting one's one.
|
||||
#[cfg(unix)]
|
||||
fn objects_attempted(log: &std::path::Path) -> usize {
|
||||
std::fs::read_to_string(log)
|
||||
.unwrap_or_default()
|
||||
|
||||
@@ -619,7 +619,7 @@ async fn ready(State(state): State<AppState>) -> axum::response::Response {
|
||||
|
||||
async fn node_info(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
let p2p_peer_id = state.p2p.as_ref().map(|h| h.local_peer_id.to_string());
|
||||
let mut body = json!({
|
||||
Json(json!({
|
||||
"name": "gitlawb-node",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"did": state.node_did.to_string(),
|
||||
@@ -628,11 +628,7 @@ async fn node_info(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
"auth": "http-signature-rfc9421",
|
||||
"identity": "ed25519",
|
||||
"p2p_peer_id": p2p_peer_id,
|
||||
});
|
||||
if let Some(web_url) = &state.config.web_url {
|
||||
body["web_url"] = json!(web_url);
|
||||
}
|
||||
Json(body)
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) async fn stats(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
|
||||
@@ -1330,18 +1330,6 @@ mod tests {
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Use a rooted path without a Windows drive prefix in the remote fixture.
|
||||
/// The destination still resolves on the temp directory's current drive,
|
||||
/// while the remote's relative path contains no illegal colon component.
|
||||
fn absolute_slug_path(path: &Path) -> String {
|
||||
assert!(path.is_absolute());
|
||||
path.components()
|
||||
.filter(|part| !matches!(part, std::path::Component::Prefix(_)))
|
||||
.collect::<std::path::PathBuf>()
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn process_batch_rejects_slug_escaping_repos_dir(pool: PgPool) {
|
||||
// The verified escape from #272: `PathBuf::join` discards everything
|
||||
@@ -1357,13 +1345,12 @@ mod tests {
|
||||
// fails on but leaves the parent it created, so assert on both.
|
||||
let outside = TempDir::new().unwrap();
|
||||
let escape_dir = outside.path().join("nest");
|
||||
let escape_path = absolute_slug_path(&escape_dir);
|
||||
let slug = format!("a/{escape_path}/escape");
|
||||
let slug = format!("a/{}/escape", escape_dir.display());
|
||||
let escape_target = escape_dir.join("escape.git");
|
||||
|
||||
// Serve the composed URL for real, so a run without the guard genuinely
|
||||
// clones outside the root rather than merely failing at git.
|
||||
let rel = format!("a{escape_path}/escape");
|
||||
let rel = format!("a{}/escape", escape_dir.display());
|
||||
let (_remote, peer_url) = rooted_remote(&[&rel]);
|
||||
|
||||
let did = "did:key:z6MkAttacker";
|
||||
@@ -1483,7 +1470,6 @@ mod tests {
|
||||
// ── canonical containment before the git call (issue #272) ───────────────
|
||||
|
||||
/// Every ref in `repo`, as one string, for a before/after comparison.
|
||||
#[cfg(unix)]
|
||||
fn refs_of(repo: &Path) -> String {
|
||||
let out = Command::new("git")
|
||||
.args(["-C", repo.to_str().unwrap(), "for-each-ref"])
|
||||
@@ -1816,7 +1802,6 @@ mod tests {
|
||||
/// starvation tests is fixed rather than dependent on how fast the loop
|
||||
/// runs. Two rows enqueued in the same microsecond would otherwise order
|
||||
/// arbitrarily.
|
||||
#[cfg(unix)]
|
||||
async fn enqueue_at(db: &Db, pool: &PgPool, repo: &str, did: &str, enqueued_at: &str) {
|
||||
enqueue(db, repo, did).await;
|
||||
sqlx::query("UPDATE sync_queue SET enqueued_at = $1 WHERE repo = $2")
|
||||
@@ -2010,8 +1995,7 @@ mod tests {
|
||||
|
||||
let outside = TempDir::new().unwrap();
|
||||
let escape_dir = outside.path().join("nest");
|
||||
let escape_path = absolute_slug_path(&escape_dir);
|
||||
let slug = format!("a/{escape_path}/gitlawb-probe");
|
||||
let slug = format!("a/{}/gitlawb-probe", escape_dir.display());
|
||||
let escape_target = escape_dir.join("gitlawb-probe.git");
|
||||
|
||||
// Two things this fixture must get right or the test is green for the
|
||||
@@ -2022,7 +2006,7 @@ mod tests {
|
||||
// run without the guard genuinely clones outside the root instead of
|
||||
// just failing at git. Db::upsert_peer cannot seed this row: it gates on
|
||||
// is_public_http_url, which rejects file://.
|
||||
let rel = format!("a{escape_path}/gitlawb-probe");
|
||||
let rel = format!("a{}/gitlawb-probe", escape_dir.display());
|
||||
let (_remote, peer_url) = rooted_remote(&[&rel]);
|
||||
let did = "did:key:z6MkAttacker";
|
||||
seed_local_peer(&pool, did, &peer_url).await;
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
//! Native Git fixtures shared by timing tests on Unix and Windows.
|
||||
|
||||
use std::ops::Deref;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// The fixture delays or hangs in its own process, so the production watchdog
|
||||
/// can terminate it without depending on a shell or descendant process support.
|
||||
pub(super) enum Behavior<'a> {
|
||||
Delay(u64),
|
||||
Hang,
|
||||
HangOid(&'a str),
|
||||
HangRepo(&'a str),
|
||||
LogTypes(&'a Path),
|
||||
}
|
||||
|
||||
pub(super) struct GitShim {
|
||||
_directory: tempfile::TempDir,
|
||||
executable: PathBuf,
|
||||
}
|
||||
|
||||
impl Deref for GitShim {
|
||||
type Target = Path;
|
||||
|
||||
fn deref(&self) -> &Path {
|
||||
&self.executable
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn create(name: &str, behavior: Behavior<'_>) -> GitShim {
|
||||
static COMPILED: OnceLock<tempfile::TempDir> = OnceLock::new();
|
||||
let compiled = COMPILED.get_or_init(|| {
|
||||
let directory = tempfile::tempdir().expect("native Git fixture directory");
|
||||
let source = directory.path().join("git_shim.rs");
|
||||
std::fs::write(&source, include_str!("../tests/fixtures/git_shim.rs"))
|
||||
.expect("write native Git fixture source");
|
||||
let executable = directory
|
||||
.path()
|
||||
.join(format!("git-shim{}", std::env::consts::EXE_SUFFIX));
|
||||
let output =
|
||||
std::process::Command::new(std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()))
|
||||
.arg("--edition=2021")
|
||||
.arg("-Dwarnings")
|
||||
.arg(&source)
|
||||
.arg("-o")
|
||||
.arg(executable)
|
||||
.output()
|
||||
.expect("compile native Git fixture with the installed Rust toolchain");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
directory
|
||||
});
|
||||
let directory = tempfile::Builder::new()
|
||||
.prefix(name)
|
||||
.tempdir()
|
||||
.expect("Git fixture instance");
|
||||
let file_name = format!("git-shim{}", std::env::consts::EXE_SUFFIX);
|
||||
let executable = directory.path().join(&file_name);
|
||||
std::fs::copy(compiled.path().join(file_name), &executable).expect("copy native Git fixture");
|
||||
let config = match behavior {
|
||||
Behavior::Delay(milliseconds) => format!("delay\n{milliseconds}"),
|
||||
Behavior::Hang => "hang\n".to_owned(),
|
||||
Behavior::HangOid(oid) => format!("hang-oid\n{oid}"),
|
||||
Behavior::HangRepo(repo) => format!("hang-repo\n{repo}"),
|
||||
Behavior::LogTypes(path) => format!("log-types\n{}", path.display()),
|
||||
};
|
||||
std::fs::write(directory.path().join("config"), config).expect("configure native Git fixture");
|
||||
GitShim {
|
||||
_directory: directory,
|
||||
executable,
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,6 @@ use gitlawb_core::identity::Keypair;
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[path = "test_git_shim.rs"]
|
||||
mod git_shim;
|
||||
|
||||
/// Build an [`AppState`] over a real, migrated Postgres pool (from `#[sqlx::test]`).
|
||||
/// Runs the schema migrations first, because the per-test database starts empty.
|
||||
///
|
||||
@@ -562,12 +559,11 @@ mod tests {
|
||||
/// #174 (SC1, load-bearing): a saturated READ pool must NOT shed an
|
||||
/// authenticated push — the write pool is a separate budget. Read pool at zero,
|
||||
/// write pool with capacity: the push proceeds PAST admission (it then errors on
|
||||
/// the closed DB, with db_unavailable rather than overloaded). Route git-receive-pack
|
||||
/// the placeholder DB, but crucially it is not a 503). Route git-receive-pack
|
||||
/// back to the read pool and this goes red — that is the isolation proof.
|
||||
#[tokio::test]
|
||||
async fn git_receive_pack_not_shed_by_exhausted_read_pool() {
|
||||
let mut state = test_state_lazy();
|
||||
state.db.pool().close().await;
|
||||
// Read pool exhausted as if a flood of anonymous clones held every slot.
|
||||
state.git_read_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
|
||||
// Write pool keeps its default capacity from test_state_lazy.
|
||||
@@ -589,13 +585,10 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
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,
|
||||
"the push must clear admission and reach the deliberately closed database"
|
||||
assert_ne!(
|
||||
resp.status(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"an exhausted READ pool must not shed a push — the write pool is a separate budget (#174)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5145,7 +5138,6 @@ mod tests {
|
||||
node_did: owner.to_string(),
|
||||
signature: "sig".to_string(),
|
||||
issued_at: Utc::now().to_rfc3339(),
|
||||
version: 1,
|
||||
})
|
||||
.await
|
||||
.expect("seed private cert");
|
||||
@@ -11202,10 +11194,14 @@ mod tests {
|
||||
seed_legacy_pin(&pool, &bare, &fx.public_oid, Some(&repo.id)).await;
|
||||
|
||||
// A git that takes 300ms per invocation (the read makes two: type, then content).
|
||||
let slow_git = git_shim::create(
|
||||
&format!("gl-slow-git-{short}"),
|
||||
git_shim::Behavior::Delay(300),
|
||||
);
|
||||
let slow_git = std::env::temp_dir().join(format!("gl-slow-git-{short}"));
|
||||
std::fs::write(&slow_git, "#!/bin/sh\nsleep 0.3\nexec git \"$@\"\n").expect("write shim");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&slow_git, std::fs::Permissions::from_mode(0o755))
|
||||
.expect("chmod shim");
|
||||
}
|
||||
|
||||
let ticks = std::sync::Arc::new(AtomicUsize::new(0));
|
||||
let ticker = {
|
||||
@@ -12152,6 +12148,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Write an executable `git` stand-in and return its path.
|
||||
fn write_git_shim(name: &str, script: &str) -> std::path::PathBuf {
|
||||
let path = std::env::temp_dir().join(name);
|
||||
std::fs::write(&path, script).expect("write the git shim");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
|
||||
.expect("chmod the git shim");
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// F6 scenario 1 (#173 round 13): one hung candidate must not starve the rows behind
|
||||
/// it in the same pass. `DiscoveryCtx` is loaded once per pass, so before the per-row
|
||||
/// slice every source-less row in a pass shared ONE deadline: the first row's wedged
|
||||
@@ -12191,9 +12200,23 @@ mod tests {
|
||||
|
||||
// The type stage feeds the oid on STDIN (`cat-file --batch-check`) and the
|
||||
// content stage puts it in argv, so the stand-in has to look in both places.
|
||||
let git_bin = git_shim::create(
|
||||
let git_bin = write_git_shim(
|
||||
&format!("gl-hung-git-{short}"),
|
||||
git_shim::Behavior::HangOid(&hung_oid),
|
||||
&format!(
|
||||
"#!/bin/sh\n\
|
||||
if [ \"$2\" = \"--batch-check\" ]; then\n\
|
||||
\x20 oid=$(cat)\n\
|
||||
\x20 case \"$oid\" in\n\
|
||||
\x20 {hung_oid}) sleep 30; exit 1 ;;\n\
|
||||
\x20 esac\n\
|
||||
\x20 printf '%s\\n' \"$oid\" | git \"$@\"\n\
|
||||
\x20 exit $?\n\
|
||||
fi\n\
|
||||
case \"$*\" in\n\
|
||||
\x20 *{hung_oid}*) sleep 30; exit 1 ;;\n\
|
||||
esac\n\
|
||||
exec git \"$@\"\n"
|
||||
),
|
||||
);
|
||||
|
||||
let stats = tokio::time::timeout(
|
||||
@@ -12278,7 +12301,10 @@ mod tests {
|
||||
|
||||
// Wedges on every invocation, so no row can ever be repaired and the only
|
||||
// question left is what each one COSTS.
|
||||
let git_bin = git_shim::create(&format!("gl-spent-git-{short}"), git_shim::Behavior::Hang);
|
||||
let git_bin = write_git_shim(
|
||||
&format!("gl-spent-git-{short}"),
|
||||
"#!/bin/sh\nsleep 30\nexit 1\n",
|
||||
);
|
||||
|
||||
let stats = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(60),
|
||||
@@ -12882,7 +12908,10 @@ mod tests {
|
||||
seed_legacy_pin(&pool, &src, oid, None).await;
|
||||
}
|
||||
|
||||
let git_bin = git_shim::create(&format!("gl-starve-git-{short}"), git_shim::Behavior::Hang);
|
||||
let git_bin = write_git_shim(
|
||||
&format!("gl-starve-git-{short}"),
|
||||
"#!/bin/sh\nsleep 30\nexit 1\n",
|
||||
);
|
||||
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(120),
|
||||
@@ -12950,9 +12979,9 @@ mod tests {
|
||||
let (raw_cid, provider_cid) = seed_legacy_pin(&pool, &src, &fx.public_oid, None).await;
|
||||
|
||||
// Wedges only inside the position-nine repo, which the sweep enters by cwd.
|
||||
let git_bin = git_shim::create(
|
||||
let git_bin = write_git_shim(
|
||||
&format!("gl-mid-git-{short}"),
|
||||
git_shim::Behavior::HangRepo("midcand9.git"),
|
||||
"#!/bin/sh\ncase \"$(pwd)\" in\n */midcand9.git) sleep 30; exit 1 ;;\nesac\nexec git \"$@\"\n",
|
||||
);
|
||||
|
||||
let first = tokio::time::timeout(
|
||||
@@ -13114,9 +13143,19 @@ mod tests {
|
||||
|
||||
let log = std::env::temp_dir().join(format!("gl-ali-log-{short}"));
|
||||
let _ = std::fs::remove_file(&log);
|
||||
let git_bin = git_shim::create(
|
||||
let git_bin = write_git_shim(
|
||||
&format!("gl-ali-git-{short}"),
|
||||
git_shim::Behavior::LogTypes(&log),
|
||||
&format!(
|
||||
"#!/bin/sh\n\
|
||||
if [ \"$2\" = \"--batch-check\" ]; then\n\
|
||||
\x20 oid=$(cat)\n\
|
||||
\x20 printf '%s %s\\n' \"$oid\" \"$(basename $(pwd))\" >> {log}\n\
|
||||
\x20 printf '%s\\n' \"$oid\" | git \"$@\"\n\
|
||||
\x20 exit $?\n\
|
||||
fi\n\
|
||||
exec git \"$@\"\n",
|
||||
log = log.display()
|
||||
),
|
||||
);
|
||||
|
||||
let mut traversal = crate::ipfs_pin::DiscoveryTraversalState::default();
|
||||
@@ -17658,7 +17697,6 @@ mod tests {
|
||||
node_did: "did:key:zNode".into(),
|
||||
signature: "sig".into(),
|
||||
issued_at: "2026-01-01T00:00:00Z".into(),
|
||||
version: 1,
|
||||
};
|
||||
state.db.insert_ref_certificate(&cert).await.unwrap();
|
||||
|
||||
@@ -17694,7 +17732,6 @@ mod tests {
|
||||
node_did: "did:key:zNode".into(),
|
||||
signature: "sig".into(),
|
||||
issued_at: "2026-01-01T00:00:00Z".into(),
|
||||
version: 1,
|
||||
};
|
||||
state.db.insert_ref_certificate(&cert).await.unwrap();
|
||||
|
||||
@@ -18434,7 +18471,6 @@ mod tests {
|
||||
node_did: "did:key:zNode".into(),
|
||||
signature: "sig".into(),
|
||||
issued_at: "2026-01-01T00:00:00Z".into(),
|
||||
version: 1,
|
||||
};
|
||||
state.db.insert_ref_certificate(&cert).await.unwrap();
|
||||
|
||||
@@ -20109,7 +20145,6 @@ mod tests {
|
||||
node_did: "did:key:zNODE".into(),
|
||||
signature: "sig".into(),
|
||||
issued_at: issued_at.to_string(),
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
use std::io::{Read, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
fn main() -> std::io::Result<()> {
|
||||
let executable = std::env::current_exe()?;
|
||||
let config = std::fs::read_to_string(executable.parent().unwrap().join("config"))?;
|
||||
let (mode, parameter) = config.split_once('\n').unwrap_or((&config, ""));
|
||||
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
||||
if mode == "delay" {
|
||||
std::thread::sleep(Duration::from_millis(parameter.parse().unwrap()));
|
||||
}
|
||||
let batch = args.get(1).is_some_and(|arg| arg == "--batch-check");
|
||||
let input = if batch {
|
||||
let mut bytes = Vec::new();
|
||||
std::io::stdin().read_to_end(&mut bytes)?;
|
||||
Some(bytes)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let cwd = std::env::current_dir()?;
|
||||
let hang = mode == "hang"
|
||||
|| (mode == "hang-repo" && cwd.file_name().is_some_and(|name| name == parameter))
|
||||
|| (mode == "hang-oid"
|
||||
&& (args.iter().any(|arg| arg == parameter)
|
||||
|| input
|
||||
.as_ref()
|
||||
.is_some_and(|bytes| String::from_utf8_lossy(bytes).trim() == parameter)));
|
||||
if hang {
|
||||
std::thread::sleep(Duration::from_secs(30));
|
||||
std::process::exit(1);
|
||||
}
|
||||
if mode == "log-types" && batch {
|
||||
let mut log = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(parameter)?;
|
||||
writeln!(
|
||||
log,
|
||||
"{} {}",
|
||||
String::from_utf8_lossy(input.as_ref().unwrap()).trim(),
|
||||
cwd.file_name().unwrap().to_string_lossy()
|
||||
)?;
|
||||
}
|
||||
let mut child = Command::new("git")
|
||||
.args(&args)
|
||||
.stdin(if input.is_some() {
|
||||
Stdio::piped()
|
||||
} else {
|
||||
Stdio::inherit()
|
||||
})
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()?;
|
||||
if let Some(bytes) = input {
|
||||
child.stdin.take().unwrap().write_all(&bytes)?;
|
||||
}
|
||||
std::process::exit(child.wait()?.code().unwrap_or(1));
|
||||
}
|
||||
@@ -26,8 +26,6 @@ clap = { version = "4", features = ["derive", "env"] }
|
||||
dirs = "5"
|
||||
reqwest = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
url = { workspace = true }
|
||||
percent-encoding = { workspace = true }
|
||||
urlencoding = "2"
|
||||
alloy = { version = "1", default-features = false, features = [
|
||||
"contract",
|
||||
|
||||
+21
-541
@@ -4,7 +4,6 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Args, Subcommand};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
@@ -35,12 +34,7 @@ pub enum CertCmd {
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
/// Show a specific ref certificate and verify its signature.
|
||||
///
|
||||
/// `--verify` requires `--expect-node <did>`: the queried node's
|
||||
/// self-reported DID is not a trust anchor, because a node serving a
|
||||
/// forged certificate also controls its own self-report. Callers
|
||||
/// must name the issuer they trust.
|
||||
/// Show a specific ref certificate and verify its signature
|
||||
Show {
|
||||
/// Repository in <owner>/<repo> or <repo> format
|
||||
repo: String,
|
||||
@@ -55,17 +49,13 @@ pub enum CertCmd {
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
/// Exit non-zero unless the Ed25519 signature verifies AND the
|
||||
/// issuing node matches --expect-node (which --verify requires:
|
||||
/// without a caller-supplied anchor there is no trusted issuer to
|
||||
/// verify against)
|
||||
/// issuing node matches the queried node (or --expect-node)
|
||||
#[arg(long)]
|
||||
verify: bool,
|
||||
/// Expected issuing node DID for --verify. A valid signature alone
|
||||
/// only proves the cert is internally consistent — signed by whatever
|
||||
/// key it names — so --verify also anchors the issuer to a DID you
|
||||
/// trust. The queried node's self-reported DID is deliberately NOT
|
||||
/// accepted as a fallback anchor: the node that served the cert can
|
||||
/// serve a matching self-report.
|
||||
/// trust: this value when given, else the queried node's DID.
|
||||
#[arg(long, requires = "verify")]
|
||||
expect_node: Option<String>,
|
||||
},
|
||||
@@ -162,24 +152,6 @@ async fn cmd_show(
|
||||
let node_did = cert["node_did"].as_str().unwrap_or("?");
|
||||
let signature = cert["signature"].as_str().unwrap_or("?");
|
||||
let issued_at = cert["issued_at"].as_str().unwrap_or("?");
|
||||
// #26 Split PR 3: read the wire-format version. A MISSING key
|
||||
// selects legacy v1 (an old server that predates the field).
|
||||
// A PRESENT value must be a JSON integer that fits in u32 and
|
||||
// names a version this client supports — otherwise the response
|
||||
// declares a format we cannot represent and we refuse to verify
|
||||
// rather than guess the payload shape. Reviewer 2 finding:
|
||||
// collapsing `null`, a string, a float, or an overflow integer
|
||||
// onto v1 would let a well-signed v1 signature pass `--verify`
|
||||
// against a server that explicitly said "version 2 (or 99, or
|
||||
// 4294967297)" — that is the exact mismatch the field exists
|
||||
// to prevent. parse_cert_version is the load-bearing parser
|
||||
// that distinguishes missing-key (legacy v1) from invalid-value
|
||||
// (unsupported).
|
||||
let parsed_version = parse_cert_version(cert.get("version"));
|
||||
let version_display: String = match &parsed_version {
|
||||
Ok(v) => v.to_string(),
|
||||
Err(reason) => format!("unsupported ({reason})"),
|
||||
};
|
||||
|
||||
println!("Ref Certificate: {cert_id}");
|
||||
println!(" Ref: {ref_name}");
|
||||
@@ -188,7 +160,6 @@ async fn cmd_show(
|
||||
println!(" Pusher: {pusher}");
|
||||
println!(" Node DID: {node_did}");
|
||||
println!(" Issued at: {issued_at}");
|
||||
println!(" Version: {version_display}");
|
||||
println!(" Signature: {signature}");
|
||||
println!();
|
||||
|
||||
@@ -198,31 +169,9 @@ async fn cmd_show(
|
||||
// This proves the cert is internally authentic — signed by the key it
|
||||
// names; the node-DID comparison below covers *which* node that is.
|
||||
let repo_id = cert["repo_id"].as_str().unwrap_or("");
|
||||
let verdict = match parsed_version {
|
||||
// v1 is the only version this client verifies. The v1
|
||||
// signed payload is the 7-field canonical form with no
|
||||
// `version` key — see gitlawb-node/src/cert.rs. A future
|
||||
// v2+ cert has a different signed payload shape (the
|
||||
// version field becomes part of the signed JSON per
|
||||
// `v2_signing_payload`), so a v2+ cert from a server this
|
||||
// client does not know about must NOT be silently verified
|
||||
// as v1.
|
||||
// Reviewer 2: refuse rather than guess; the client and
|
||||
// server must agree on the version.
|
||||
// parse_cert_version admits exactly one Ok value: 1 (missing key and
|
||||
// integer 1 both land there; 2, floats, strings, overflow and every
|
||||
// other spelling come back Err with their own reason). A separate
|
||||
// `Ok(v != 1)` arm existed here and was DEAD CODE — a test aimed at it
|
||||
// could never catch drift in this match (review round 2) — so the
|
||||
// match now mirrors the parser's real contract, which the
|
||||
// `parse_cert_version_truth_table` test pins.
|
||||
Ok(_) => verify_signature(
|
||||
repo_id, ref_name, old_sha, new_sha, pusher, node_did, issued_at, signature,
|
||||
),
|
||||
Err(ref reason) => Err(format!(
|
||||
"cert declared a version this client cannot represent ({reason}); refusing to verify"
|
||||
)),
|
||||
};
|
||||
let verdict = verify_signature(
|
||||
repo_id, ref_name, old_sha, new_sha, pusher, node_did, issued_at, signature,
|
||||
);
|
||||
|
||||
println!("Signature verification:");
|
||||
match &verdict {
|
||||
@@ -232,11 +181,7 @@ async fn cmd_show(
|
||||
);
|
||||
}
|
||||
Err(reason) => {
|
||||
// A version refusal means no cryptographic check ran, so it must
|
||||
// not print as an INVALID signature: operators and log scrapers
|
||||
// would read that as a failed Ed25519 verification. INVALID is
|
||||
// reserved for failures out of verify_signature itself.
|
||||
println!(" {} — {reason}", signature_verdict_label(&parsed_version));
|
||||
println!(" INVALID — {reason}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,39 +198,29 @@ async fn cmd_show(
|
||||
},
|
||||
Err(e) => Err(e.to_string()),
|
||||
};
|
||||
let current_node_did = current.as_ref().ok().cloned();
|
||||
for line in did_check_report(¤t, node_did) {
|
||||
println!("{line}");
|
||||
}
|
||||
|
||||
if require_valid {
|
||||
if let Err(reason) = verdict {
|
||||
// Round 3: do not claim the signature failed when it was
|
||||
// never checked. A version refusal means the payload
|
||||
// shape is unknown, so no signature verification ran.
|
||||
anyhow::bail!("certificate could not be verified: {reason}");
|
||||
anyhow::bail!("certificate signature did not verify: {reason}");
|
||||
}
|
||||
// A valid signature proves internal consistency only: the payload was
|
||||
// signed by whatever key the certificate itself names. A hostile
|
||||
// source can mint a keypair, put its DID in node_did, and self-sign.
|
||||
// --verify therefore also anchors the issuer — and the anchor must be
|
||||
// a DID the CALLER trusts, which is exactly what --expect-node is.
|
||||
// The DID the queried node reports at `/` is NOT an anchor: the same
|
||||
// node that served a forged certificate can serve a matching
|
||||
// self-report, and with that fallback a wholly forged cert printed
|
||||
// VALID and exited 0 (review round 2, demonstrated by execution). So
|
||||
// --verify without --expect-node refuses to claim a trusted issuer at
|
||||
// all, rather than laundering the server's self-assertion into one.
|
||||
match expect_node.as_deref() {
|
||||
// --verify therefore also anchors the issuer to a trusted DID:
|
||||
// --expect-node when given, else the DID of the node being queried.
|
||||
let expected = expect_node.as_deref().or(current_node_did.as_deref());
|
||||
match expected {
|
||||
Some(expected) if expected == node_did => {}
|
||||
Some(expected) => anyhow::bail!(
|
||||
"certificate is signed by {node_did}, but the expected issuer is {expected} — \
|
||||
a valid signature alone proves internal consistency, not a trusted issuer"
|
||||
),
|
||||
None => anyhow::bail!(
|
||||
"--verify needs --expect-node <did> to anchor the issuer: the signature is \
|
||||
valid, but it only proves the cert is self-consistent with the DID it \
|
||||
names ({node_did}); the queried node's self-reported DID is not a trust \
|
||||
anchor"
|
||||
"cannot anchor the issuer: node info is unreachable and no --expect-node was given"
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -333,18 +268,6 @@ fn did_check_report(current: &std::result::Result<String, String>, node_did: &st
|
||||
}
|
||||
}
|
||||
|
||||
/// The stdout label for a failed verification verdict. A `parsed_version`
|
||||
/// error means `verify_signature` never ran, so the refusal must print as
|
||||
/// UNSUPPORTED VERSION rather than INVALID, which operators and log
|
||||
/// scrapers would read as a failed Ed25519 check.
|
||||
fn signature_verdict_label(parsed_version: &std::result::Result<u32, String>) -> &'static str {
|
||||
if parsed_version.is_err() {
|
||||
"UNSUPPORTED VERSION"
|
||||
} else {
|
||||
"INVALID"
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the node's canonical signing payload (field order must match
|
||||
/// gitlawb-node/src/cert.rs::issue_ref_certificate exactly) and verify the
|
||||
/// certificate's Ed25519 signature against the key embedded in `node_did`.
|
||||
@@ -391,65 +314,6 @@ fn verify_signature(
|
||||
.map_err(|_| "Ed25519 signature does not match the signed payload".to_string())
|
||||
}
|
||||
|
||||
/// Parse the wire-format `version` field of a certificate response.
|
||||
///
|
||||
/// Semantics (Reviewer 2):
|
||||
/// - MISSING key (`None`) → `Ok(1)`. An old server predates the field, so
|
||||
/// the cert is by definition the v1 (pre-versioning) shape; the v1
|
||||
/// verify path is the only path that can possibly match the bytes.
|
||||
/// - PRESENT key must be a JSON integer that fits losslessly in `u32`
|
||||
/// and equals a version this client supports. `null`, a string,
|
||||
/// a float, an overflow integer, or any version other than 1 all
|
||||
/// yield `Err`. Collapsing any of these onto v1 would let a
|
||||
/// well-signed v1 signature pass `--verify` against a response
|
||||
/// that explicitly declared a format this client cannot represent
|
||||
/// — which is exactly the mismatch the version field exists to
|
||||
/// prevent.
|
||||
///
|
||||
/// The supported-version set is hard-coded to `{1}` because PR 3 ships
|
||||
/// only v1 verification; bump this set (and the verdict branch) when
|
||||
/// v2 verification lands.
|
||||
fn parse_cert_version(value: Option<&Value>) -> Result<u32, String> {
|
||||
let v = match value {
|
||||
None => return Ok(1),
|
||||
Some(v) => v,
|
||||
};
|
||||
|
||||
// serde_json::Value::as_u64 rejects strings, floats, booleans,
|
||||
// nulls, arrays, and objects — but it silently truncates floats
|
||||
// that are whole numbers (e.g. 2.0 → 2). We must reject floats
|
||||
// explicitly so a server cannot smuggle a v2 cert through the
|
||||
// float shape.
|
||||
if v.is_f64() {
|
||||
return Err(format!(
|
||||
"version is a JSON number with a fractional part ({v}); expected an integer"
|
||||
));
|
||||
}
|
||||
|
||||
let n = v.as_u64().ok_or_else(|| {
|
||||
// null / string / bool / array / object — anything that is
|
||||
// not a JSON integer.
|
||||
format!("version is {v}; expected a JSON integer")
|
||||
})?;
|
||||
|
||||
// Lossy narrowing: refuse anything that does not fit in u32.
|
||||
// u32::MAX is 4_294_967_295; serde_json only goes up to u64.
|
||||
if n > u32::MAX as u64 {
|
||||
return Err(format!(
|
||||
"version {n} does not fit in u32 (max {})",
|
||||
u32::MAX
|
||||
));
|
||||
}
|
||||
|
||||
let n = n as u32;
|
||||
if n != 1 {
|
||||
return Err(format!(
|
||||
"this client supports cert version 1 only; server returned {n}"
|
||||
));
|
||||
}
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
async fn resolve_cert_id(client: &NodeClient, owner: &str, name: &str, id: &str) -> Result<String> {
|
||||
if id.len() >= 36 {
|
||||
return Ok(id.to_string());
|
||||
@@ -947,15 +811,7 @@ mod tests {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let (cert, _root) = cert_server(&mut server, &id, &body, Some(&info)).await;
|
||||
|
||||
let got = cmd_show(
|
||||
"alice/r".to_string(),
|
||||
id,
|
||||
server.url(),
|
||||
None,
|
||||
true,
|
||||
Some(kp.did().as_str().to_string()),
|
||||
)
|
||||
.await;
|
||||
let got = cmd_show("alice/r".to_string(), id, server.url(), None, true, None).await;
|
||||
assert!(got.is_ok(), "valid sig + matching issuer must pass");
|
||||
cert.assert_async().await;
|
||||
}
|
||||
@@ -971,17 +827,10 @@ mod tests {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let (cert, _root) = cert_server(&mut server, &id, &body, Some(&info)).await;
|
||||
|
||||
let err = cmd_show(
|
||||
"alice/r".to_string(),
|
||||
id,
|
||||
server.url(),
|
||||
None,
|
||||
true,
|
||||
Some(other.did().as_str().to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
let err = cmd_show("alice/r".to_string(), id, server.url(), None, true, None)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("expected issuer"), "got: {err}");
|
||||
cert.assert_async().await;
|
||||
}
|
||||
@@ -999,7 +848,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("--expect-node"), "got: {err}");
|
||||
assert!(err.contains("cannot anchor the issuer"), "got: {err}");
|
||||
cert.assert_async().await;
|
||||
}
|
||||
|
||||
@@ -1040,7 +889,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("does not match"), "got: {err}");
|
||||
assert!(err.contains("signature did not verify"), "got: {err}");
|
||||
cert.assert_async().await;
|
||||
}
|
||||
|
||||
@@ -1124,373 +973,4 @@ mod tests {
|
||||
assert!(report.contains("verify offline"), "got: {report}");
|
||||
assert!(!report.contains("WARNING"), "got: {report}");
|
||||
}
|
||||
|
||||
/// #26 Split PR 3: a missing `version` field on a cert defaults
|
||||
/// to 1, so an old server's response is forward-compatible with
|
||||
/// a new client. The v1 verify path is taken, and a
|
||||
/// well-signed v1 cert round-trips through `verify_signature`.
|
||||
///
|
||||
/// Reviewer 1 finding: the previous shape only parsed the field
|
||||
/// and never called `verify_signature`, so the test could not
|
||||
/// fail if the verdict branch was wired to the wrong path. This
|
||||
/// version signs and verifies the full round-trip.
|
||||
#[test]
|
||||
fn missing_version_defaults_to_1_and_verifies() {
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let node_did = kp.did().as_str().to_string();
|
||||
let payload = serde_json::json!({
|
||||
"repo_id": "repo-1",
|
||||
"ref": "refs/heads/main",
|
||||
"old": "0".repeat(40),
|
||||
"new": "a".repeat(40),
|
||||
"pusher": "did:key:z6MkPusher",
|
||||
"node": node_did,
|
||||
"ts": "2026-07-22T00:00:00+00:00",
|
||||
});
|
||||
let sig = kp.sign_b64(&serde_json::to_vec(&payload).unwrap());
|
||||
|
||||
// Simulate the JSON a v1 server returns: no `version` key.
|
||||
let cert_json = serde_json::json!({
|
||||
"id": "test-id",
|
||||
"repo_id": "repo-1",
|
||||
"ref_name": "refs/heads/main",
|
||||
"old_sha": "0".repeat(40),
|
||||
"new_sha": "a".repeat(40),
|
||||
"pusher_did": "did:key:z6MkPusher",
|
||||
"node_did": node_did,
|
||||
"signature": sig.clone(),
|
||||
"issued_at": "2026-07-22T00:00:00+00:00",
|
||||
// no `version` field
|
||||
});
|
||||
|
||||
// Forward-compat: the parse yields v1 (the only version this
|
||||
// client verifies), and the verdict arm matching Ok(1) feeds
|
||||
// the v1 payload through verify_signature end to end.
|
||||
assert_eq!(parse_cert_version(cert_json.get("version")).unwrap(), 1);
|
||||
let verdict = verify_signature(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
&"0".repeat(40),
|
||||
&"a".repeat(40),
|
||||
"did:key:z6MkPusher",
|
||||
&node_did,
|
||||
"2026-07-22T00:00:00+00:00",
|
||||
&sig,
|
||||
);
|
||||
assert!(
|
||||
verdict.is_ok(),
|
||||
"missing version must take the v1 verify path: {verdict:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #26 Split PR 3: an explicit `version: 1` on a cert is the v1
|
||||
/// verify path. A v1 cert with an explicit version verifies the
|
||||
/// same as a v1 cert without one — round-trip through
|
||||
/// `verify_signature`.
|
||||
#[test]
|
||||
fn explicit_version_1_takes_v1_path() {
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let node_did = kp.did().as_str().to_string();
|
||||
let payload = serde_json::json!({
|
||||
"repo_id": "repo-1",
|
||||
"ref": "refs/heads/main",
|
||||
"old": "0".repeat(40),
|
||||
"new": "a".repeat(40),
|
||||
"pusher": "did:key:z6MkPusher",
|
||||
"node": node_did,
|
||||
"ts": "2026-07-22T00:00:00+00:00",
|
||||
});
|
||||
let sig = kp.sign_b64(&serde_json::to_vec(&payload).unwrap());
|
||||
|
||||
// Simulate a v1 cert with an explicit `version: 1` field.
|
||||
let cert_json = serde_json::json!({
|
||||
"id": "test-id",
|
||||
"repo_id": "repo-1",
|
||||
"ref_name": "refs/heads/main",
|
||||
"old_sha": "0".repeat(40),
|
||||
"new_sha": "a".repeat(40),
|
||||
"pusher_did": "did:key:z6MkPusher",
|
||||
"node_did": node_did,
|
||||
"signature": sig.clone(),
|
||||
"issued_at": "2026-07-22T00:00:00+00:00",
|
||||
"version": 1,
|
||||
});
|
||||
|
||||
// The parse arms the Ok(1) verdict branch, which calls
|
||||
// verify_signature on the v1 payload. A signature mismatch
|
||||
// here would mean the verdict branch was wired to the
|
||||
// wrong path or the payload drifted.
|
||||
assert_eq!(parse_cert_version(cert_json.get("version")).unwrap(), 1);
|
||||
let verdict = verify_signature(
|
||||
"repo-1",
|
||||
"refs/heads/main",
|
||||
&"0".repeat(40),
|
||||
&"a".repeat(40),
|
||||
"did:key:z6MkPusher",
|
||||
&node_did,
|
||||
"2026-07-22T00:00:00+00:00",
|
||||
&sig,
|
||||
);
|
||||
assert!(
|
||||
verdict.is_ok(),
|
||||
"explicit version: 1 must take the v1 verify path: {verdict:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #26 Split PR 3 + Reviewer 2: parse_cert_version is the
|
||||
/// load-bearing gate that distinguishes a missing-key (legacy
|
||||
/// server) from a present-but-malformed value. The four cases
|
||||
/// below pin every cell of that truth table; collapsing any
|
||||
/// pair would let a server advertise "version 2" (or 99, or
|
||||
/// 4294967297, or "two") while the client runs the v1
|
||||
/// signature path on a different signed payload — a hostile
|
||||
/// misconfiguration that `--verify` must not silently accept.
|
||||
#[test]
|
||||
fn parse_cert_version_truth_table() {
|
||||
// MISSING key → legacy v1 (the v1 path is the only one that
|
||||
// could match pre-versioning bytes).
|
||||
assert_eq!(
|
||||
parse_cert_version(None).unwrap(),
|
||||
1,
|
||||
"a missing version field is the legacy v1 path"
|
||||
);
|
||||
|
||||
// Explicit 1 → v1.
|
||||
assert_eq!(
|
||||
parse_cert_version(Some(&serde_json::json!(1))).unwrap(),
|
||||
1,
|
||||
"explicit version 1 is the v1 path"
|
||||
);
|
||||
|
||||
// Explicit 2 → Err, NOT Ok(1). Reviewer 2: a v1 signature
|
||||
// must not verify against a server that said "version 2".
|
||||
assert!(
|
||||
parse_cert_version(Some(&serde_json::json!(2))).is_err(),
|
||||
"explicit version 2 must not collapse to v1"
|
||||
);
|
||||
|
||||
// u32 overflow (2^32 + 1) → Err, NOT Ok(1). The previous
|
||||
// `.map(|v| v as u32)` silently truncated this to 1.
|
||||
let overflow = serde_json::json!(u64::from(u32::MAX) + 1);
|
||||
assert!(
|
||||
parse_cert_version(Some(&overflow)).is_err(),
|
||||
"a version that overflows u32 must not truncate to 1"
|
||||
);
|
||||
|
||||
// Non-numeric: a string, a float, null, bool — every shape
|
||||
// that is not a JSON integer is rejected. None of these
|
||||
// may collapse to v1.
|
||||
for bad in [
|
||||
serde_json::json!("two"),
|
||||
serde_json::json!(2.0), // serde_json::Value::is_f64 is true for this
|
||||
serde_json::json!(2.5), // obviously fractional
|
||||
serde_json::json!(true),
|
||||
serde_json::json!(null),
|
||||
serde_json::json!([2]),
|
||||
serde_json::json!({"v": 2}),
|
||||
] {
|
||||
assert!(
|
||||
parse_cert_version(Some(&bad)).is_err(),
|
||||
"non-integer version {bad} must not collapse to v1"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A version refusal must not print as INVALID — no cryptographic
|
||||
/// check ran, so labelling it INVALID would tell operators an Ed25519
|
||||
/// verification failed when the payload shape was the disqualifier.
|
||||
/// Removing the label branch fails this test.
|
||||
#[test]
|
||||
fn signature_verdict_label_distinguishes_refusal_from_bad_signature() {
|
||||
assert_eq!(
|
||||
signature_verdict_label(&Err("version 2 not supported".to_string())),
|
||||
"UNSUPPORTED VERSION"
|
||||
);
|
||||
assert_eq!(signature_verdict_label(&Ok(1)), "INVALID");
|
||||
}
|
||||
|
||||
/// #26 Split PR 3, round 2: a `version: 2` certificate must fail
|
||||
/// `--verify` THROUGH `cmd_show` itself, not through a copy of its
|
||||
/// match. The prior form of this test duplicated the verdict match in
|
||||
/// the test body and aimed at an `Ok(v != 1)` arm the real parser can
|
||||
/// never produce, so gutting `cmd_show`'s rejection kept every test
|
||||
/// green (demonstrated by the reviewer by execution). Driving the
|
||||
/// command end to end is what makes this a guard: the mock serves a
|
||||
/// cert whose SIGNATURE IS VALID for v1 bytes but which declares
|
||||
/// version 2, and the command must exit Err with the version named —
|
||||
/// the v1 verify path must not launder it through.
|
||||
#[tokio::test]
|
||||
async fn cmd_show_verify_rejects_v2_even_with_valid_v1_signature() {
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let node_did = kp.did().as_str().to_string();
|
||||
|
||||
// Sign a v1-shaped payload so the ONLY disqualifier is the version.
|
||||
let payload = serde_json::json!({
|
||||
"repo_id": "repo-1",
|
||||
"ref": "refs/heads/main",
|
||||
"old": "0".repeat(40),
|
||||
"new": "a".repeat(40),
|
||||
"pusher": "did:key:z6MkPusher",
|
||||
"node": node_did,
|
||||
"ts": "2026-07-22T00:00:00+00:00",
|
||||
});
|
||||
let sig = kp.sign_b64(&serde_json::to_vec(&payload).unwrap());
|
||||
|
||||
let cert_json = serde_json::json!({
|
||||
"id": "0123456789abcdef0123456789abcdef0123",
|
||||
"repo_id": "repo-1",
|
||||
"ref_name": "refs/heads/main",
|
||||
"old_sha": "0".repeat(40),
|
||||
"new_sha": "a".repeat(40),
|
||||
"pusher_did": "did:key:z6MkPusher",
|
||||
"node_did": node_did,
|
||||
"signature": sig,
|
||||
"issued_at": "2026-07-22T00:00:00+00:00",
|
||||
"version": 2,
|
||||
});
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// 36-char id short-circuits resolve_cert_id; "owner/repo" input
|
||||
// short-circuits resolve_repo — the cert GET is the only required
|
||||
// route. `/` may be probed for the contextual DID line; serving it
|
||||
// keeps the test about the verdict, not about network noise.
|
||||
let _cert = server
|
||||
.mock(
|
||||
"GET",
|
||||
"/api/v1/repos/o/r/certs/0123456789abcdef0123456789abcdef0123",
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(cert_json.to_string())
|
||||
.create_async()
|
||||
.await;
|
||||
let _root = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(serde_json::json!({ "did": node_did }).to_string())
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_show(
|
||||
"o/r".to_string(),
|
||||
"0123456789abcdef0123456789abcdef0123".to_string(),
|
||||
server.url(),
|
||||
None,
|
||||
true,
|
||||
Some(node_did.clone()),
|
||||
)
|
||||
.await
|
||||
.expect_err("a version-2 cert must fail --verify even with a valid v1 signature");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("version"),
|
||||
"the failure must name the version mismatch, not a generic invalid signature: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Round 2's second demonstrated escape: with no --expect-node, the old
|
||||
/// fallback anchored the issuer to the DID the QUERIED NODE reports at
|
||||
/// `/` — which a forger controls along with the cert, so a wholly forged
|
||||
/// self-signed certificate passed --verify with exit 0. `cmd_show` with
|
||||
/// --verify and no --expect-node must now refuse to claim a trusted
|
||||
/// issuer, even when the served cert's signature is valid and the node's
|
||||
/// self-report matches it.
|
||||
#[tokio::test]
|
||||
async fn cmd_show_verify_without_expect_node_refuses_self_asserted_anchor() {
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let node_did = kp.did().as_str().to_string();
|
||||
let payload = serde_json::json!({
|
||||
"repo_id": "repo-1",
|
||||
"ref": "refs/heads/main",
|
||||
"old": "0".repeat(40),
|
||||
"new": "a".repeat(40),
|
||||
"pusher": "did:key:z6MkPusher",
|
||||
"node": node_did,
|
||||
"ts": "2026-07-22T00:00:00+00:00",
|
||||
});
|
||||
let sig = kp.sign_b64(&serde_json::to_vec(&payload).unwrap());
|
||||
let cert_json = serde_json::json!({
|
||||
"id": "0123456789abcdef0123456789abcdef0123",
|
||||
"repo_id": "repo-1",
|
||||
"ref_name": "refs/heads/main",
|
||||
"old_sha": "0".repeat(40),
|
||||
"new_sha": "a".repeat(40),
|
||||
"pusher_did": "did:key:z6MkPusher",
|
||||
"node_did": node_did,
|
||||
"signature": sig,
|
||||
"issued_at": "2026-07-22T00:00:00+00:00",
|
||||
});
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// P3 (reviewer round 2): both mocks are required to be
|
||||
// hit for the test to mean anything. `mockito 1.7`'s
|
||||
// `assert_on_drop` is off by default, so the previous
|
||||
// `_cert` and `_root` bindings would silently let a
|
||||
// regression that skipped the root fetch pass.
|
||||
// `.expect(2)` pins each mock's call count (the cert mock
|
||||
// is hit twice because the test runs `cmd_show` twice) and
|
||||
// `.assert_async()` at the end fires if any are unmet.
|
||||
let cert_mock = server
|
||||
.mock(
|
||||
"GET",
|
||||
"/api/v1/repos/o/r/certs/0123456789abcdef0123456789abcdef0123",
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(cert_json.to_string())
|
||||
.expect(2)
|
||||
.create_async()
|
||||
.await;
|
||||
// The forger's matching self-report — the exact shape
|
||||
// that used to convert into a trust anchor. The guard's
|
||||
// whole point is to make the test FAIL if a future
|
||||
// regression stops calling `/` here: `.expect(2)` +
|
||||
// `.assert_async()` makes that dependency visible.
|
||||
let root_mock = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(serde_json::json!({ "did": node_did }).to_string())
|
||||
.expect(2)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_show(
|
||||
"o/r".to_string(),
|
||||
"0123456789abcdef0123456789abcdef0123".to_string(),
|
||||
server.url(),
|
||||
None,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("--verify without --expect-node must not mint a trust anchor from the node's self-report");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("--expect-node"),
|
||||
"the refusal must tell the caller how to anchor trust: {msg}"
|
||||
);
|
||||
|
||||
// Positive control: the same cert with the DID explicitly trusted
|
||||
// passes, so the guard is about the anchor, not the signature.
|
||||
cmd_show(
|
||||
"o/r".to_string(),
|
||||
"0123456789abcdef0123456789abcdef0123".to_string(),
|
||||
server.url(),
|
||||
None,
|
||||
true,
|
||||
Some(node_did),
|
||||
)
|
||||
.await
|
||||
.expect("explicitly anchored --verify of a valid v1 cert must pass");
|
||||
|
||||
// P3 (reviewer round 2): confirm the mocks were actually
|
||||
// hit. Without this, a regression that skipped the cert or
|
||||
// root fetches would still produce a passing test (the
|
||||
// `expect_err` arm succeeds, the positive control succeeds)
|
||||
// and the test would mean nothing.
|
||||
cert_mock.assert_async().await;
|
||||
root_mock.assert_async().await;
|
||||
}
|
||||
}
|
||||
@@ -142,11 +142,7 @@ pub async fn run(args: MirrorArgs) -> Result<()> {
|
||||
println!();
|
||||
println!("✓ Mirror complete: {name}");
|
||||
println!(" Clone: git clone {gitlawb_url}");
|
||||
// Only print View: when the node advertises a web_url — self-hosted nodes
|
||||
// without a web front-end would otherwise produce a 404 link (#370).
|
||||
if let Some(web_url) = crate::repo::fetch_node_web_url(&args.node).await {
|
||||
println!(" View: {web_url}/{owner_short}/{name}");
|
||||
}
|
||||
println!(" View: https://twigpine.com/{owner_short}/{name}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+2
-405
@@ -5,7 +5,7 @@ use clap::{Args, Subcommand};
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::{sanitize_node_msg, NodeClient};
|
||||
use crate::http::NodeClient;
|
||||
use crate::identity::load_keypair_from_dir;
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -274,126 +274,6 @@ async fn resolve_owner_did(_node: &str, dir: Option<&std::path::Path>) -> Result
|
||||
Ok(did.split(':').next_back().unwrap_or(&did).to_string())
|
||||
}
|
||||
|
||||
/// Fetch `GET /` from the node and return the `web_url` if the node advertises one.
|
||||
///
|
||||
/// The View: link is a nice-to-have, so every failure mode degrades to `None`
|
||||
/// rather than failing the enclosing command — but failures are surfaced as
|
||||
/// stderr warnings (request error, non-success HTTP status with the status,
|
||||
/// malformed advertised value), since they all mean the node itself is
|
||||
/// misbehaving or misconfigured, which the user would otherwise never learn.
|
||||
/// A successful response that lacks a usable `web_url` (field missing or
|
||||
/// blank) omits the link silently: self-hosted nodes without a web front-end
|
||||
/// are expected to not advertise one (#370).
|
||||
///
|
||||
/// The body is treated like every other caller-chosen node reply (INV-6): the
|
||||
/// read is capped and an accepted `web_url` must be free of control/bidi bytes
|
||||
/// — it reaches the terminal verbatim through the View: line.
|
||||
pub(crate) async fn fetch_node_web_url(node: &str) -> Option<String> {
|
||||
let info_client = NodeClient::new(node, None);
|
||||
let info_resp = match info_client.get("/").await {
|
||||
Ok(resp) => resp,
|
||||
Err(err) => {
|
||||
eprintln!("warning: node info request failed ({err}); skipping View link");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let status = info_resp.status();
|
||||
if !status.is_success() {
|
||||
eprintln!("warning: node info request returned {status}; skipping View link");
|
||||
return None;
|
||||
}
|
||||
// An info reply is a DID, a few URLs and counts — 8 KiB is well past what
|
||||
// the shape needs; anything longer is hostile or broken (peer.rs precedent).
|
||||
let raw_body = crate::http::read_body_capped(info_resp, 8 * 1024).await;
|
||||
match web_url_from_info_body(&raw_body) {
|
||||
Ok(url) => url,
|
||||
Err(reason) => {
|
||||
eprintln!("warning: {reason}; skipping View link");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a capped `GET /` body for the `View:` advertisement. Split out of
|
||||
/// `fetch_node_web_url` so tests can pin the classification contract directly:
|
||||
/// a truncated or half-read body is indistinguishable from a node that
|
||||
/// advertised nothing, so it must warn rather than silently omit the link.
|
||||
fn web_url_from_info_body(
|
||||
raw_body: &crate::http::CappedBody,
|
||||
) -> std::result::Result<Option<String>, String> {
|
||||
if raw_body.truncated || raw_body.read_failed {
|
||||
return Err("node info body was oversized or could not be read in full".to_string());
|
||||
}
|
||||
let info: Value = match serde_json::from_str(&raw_body.text) {
|
||||
Ok(json) => json,
|
||||
Err(_) => return Err("node info body was not valid JSON".to_string()),
|
||||
};
|
||||
// Missing field / non-string degrade to None without warning — same contract
|
||||
// as before; only transport- and advertisement-level failures warn there.
|
||||
let raw = match info["web_url"].as_str() {
|
||||
Some(v) => v,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let trimmed = raw.trim().trim_end_matches('/');
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
// A present-but-malformed value means the node is misconfigured; say so
|
||||
// instead of quietly dropping the link. Mirrors the node-side boot
|
||||
// validation: must be an absolute http(s) URL with no query or fragment —
|
||||
// the link is built by string-appending `/{owner}/{repo}` to it. Control
|
||||
// and bidi-format bytes are rejected outright (not stripped): they have no
|
||||
// legitimate place in a base URL and would reach the terminal through the
|
||||
// View: line — including when they arrive percent-encoded (INV-6).
|
||||
let malformed_reason: Option<String> = if trimmed
|
||||
.chars()
|
||||
.any(|c| c.is_control() || gitlawb_core::sanitize::is_bidi_format(c))
|
||||
{
|
||||
Some("contains control or bidi characters".to_string())
|
||||
} else {
|
||||
match trimmed.parse::<url::Url>() {
|
||||
Err(_) => Some("not an absolute URL".to_string()),
|
||||
Ok(parsed) if !matches!(parsed.scheme(), "http" | "https") => {
|
||||
Some("not http(s)".to_string())
|
||||
}
|
||||
Ok(parsed) if parsed.query().is_some() || parsed.fragment().is_some() => {
|
||||
Some("contains a query or fragment".to_string())
|
||||
}
|
||||
// userinfo is rejected because the `View:` line prints the
|
||||
// value verbatim; a remote advertisement carrying
|
||||
// `user:pass@host` would land that credential in terminal
|
||||
// scrollback and CI logs.
|
||||
Ok(parsed) if !parsed.username().is_empty() || parsed.password().is_some() => {
|
||||
Some("contains a username or password".to_string())
|
||||
}
|
||||
Ok(parsed)
|
||||
if parsed
|
||||
.host_str()
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.chain(
|
||||
percent_encoding::percent_decode_str(parsed.path())
|
||||
.decode_utf8_lossy()
|
||||
.chars(),
|
||||
)
|
||||
.any(|c| c.is_control() || gitlawb_core::sanitize::is_bidi_format(c)) =>
|
||||
{
|
||||
Some("contains control or bidi characters, even percent-encoded".to_string())
|
||||
}
|
||||
Ok(_) => None,
|
||||
}
|
||||
};
|
||||
match malformed_reason {
|
||||
// The reason string is ours; the advertised value is not — defang it
|
||||
// exactly as it would have been defanged had it been accepted.
|
||||
Some(reason) => Err(format!(
|
||||
"node advertised a malformed web_url ({:?}, {reason})",
|
||||
sanitize_node_msg(trimmed)
|
||||
)),
|
||||
None => Ok(Some(trimmed.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_create(
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
@@ -431,11 +311,7 @@ async fn cmd_create(
|
||||
println!("✓ Created repository: {name}");
|
||||
println!(" Clone: git clone {gitlawb_url}");
|
||||
println!(" HTTP: {clone_url}");
|
||||
// Only print View: when the node advertises a web_url — self-hosted nodes
|
||||
// without a web front-end would otherwise produce a 404 link (#370).
|
||||
if let Some(web_url) = fetch_node_web_url(&node).await {
|
||||
println!(" View: {web_url}/{owner_short}/{name}");
|
||||
}
|
||||
println!(" View: https://twigpine.com/{owner_short}/{name}");
|
||||
if let Some(desc) = payload["description"].as_str().filter(|s| !s.is_empty()) {
|
||||
println!(" Desc: {desc}");
|
||||
}
|
||||
@@ -972,285 +848,6 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_with_web_url() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"web_url":"https://example.com","did":"did:key:z6Mk"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(web_url.as_deref(), Some("https://example.com"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_trims_trailing_slash() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"web_url":"https://example.com/"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(web_url.as_deref(), Some("https://example.com"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_without_web_url() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"did":"did:key:z6Mk"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(web_url, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_empty_string() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"web_url":""}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(web_url, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_whitespace_only() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"web_url":" "}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(web_url, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_server_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(500)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(web_url, None);
|
||||
}
|
||||
|
||||
/// A node advertising a present-but-malformed web_url (not an absolute
|
||||
/// http(s) URL) must not yield a View link — and must warn, since a
|
||||
/// malformed advertisement means the node is misconfigured (#370).
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_malformed_value_is_rejected() {
|
||||
for bad in [
|
||||
"gitlawb.com",
|
||||
"not a url",
|
||||
"ftp://files.example.com",
|
||||
// Query/fragment corrupt the appended /{owner}/{repo} path.
|
||||
"https://example.com?a=1",
|
||||
"https://example.com/#faq",
|
||||
] {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(format!(r#"{{"web_url":"{bad}"}}"#))
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(
|
||||
web_url, None,
|
||||
"malformed web_url {bad:?} must not render a View link"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A transport-level failure (connection refused) degrades to None but is
|
||||
/// no longer silent: the request error is surfaced on stderr so an
|
||||
/// unreachable node isn't indistinguishable from one without a web_url.
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_connection_refused_warns() {
|
||||
// Port 1 on localhost is reserved (tcpmux) and refuses connections.
|
||||
let web_url = fetch_node_web_url("http://127.0.0.1:1").await;
|
||||
assert_eq!(web_url, None);
|
||||
}
|
||||
|
||||
/// A hostile node can smuggle ANSI/bell/bidi controls inside a web_url that
|
||||
/// still passes http(s) URL parsing — those bytes would reach the terminal
|
||||
/// verbatim through the View: line. The advertised value must be rejected
|
||||
/// outright: no control byte may survive into the returned string.
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_rejects_control_bytes() {
|
||||
for bad in [
|
||||
"https://example.com/\x1b[31mred", // ANSI CSI escape in path
|
||||
"https://example.com\x07", // bell
|
||||
"https://ex\u{202e}ample.com", // bidi override (RLO)
|
||||
"https://example.com/\u{200f}", // RLM format char
|
||||
"\x1b]0;title\x07https://evil.example", // OSC title-set prefix
|
||||
] {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
// serde_json escapes the control bytes so the body is VALID
|
||||
// JSON — a raw literal would make parse fail and the test
|
||||
// would pass without reaching the control check at all.
|
||||
.with_body(serde_json::json!({"web_url": bad}).to_string())
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(
|
||||
web_url, None,
|
||||
"web_url with control bytes {bad:?} must be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An oversized or half-read `GET /` body must warn, not silently
|
||||
/// classify as "no web front-end": a cut body fails JSON parse, which is
|
||||
/// indistinguishable from a node that advertised nothing without the
|
||||
/// truncated flag. Removing the flag check makes this test pass
|
||||
/// vacuously through the parse-fail arm.
|
||||
#[test]
|
||||
fn web_url_from_info_body_warns_on_truncated_reads() {
|
||||
for (body, label) in [
|
||||
(
|
||||
crate::http::CappedBody {
|
||||
text: r#"{"web_url":"https://x"#.repeat(2000),
|
||||
truncated: true,
|
||||
read_failed: false,
|
||||
},
|
||||
"truncated",
|
||||
),
|
||||
(
|
||||
crate::http::CappedBody {
|
||||
text: r#"{"web_url":"https://x"#.to_string(),
|
||||
truncated: false,
|
||||
read_failed: true,
|
||||
},
|
||||
"read_failed",
|
||||
),
|
||||
] {
|
||||
let err = web_url_from_info_body(&body)
|
||||
.expect_err(&format!("{label} body must warn, not silently classify"));
|
||||
assert!(
|
||||
err.contains("oversized or could not be read"),
|
||||
"{label}: got {err}"
|
||||
);
|
||||
}
|
||||
// A clean body missing web_url still classifies as a silent None.
|
||||
let clean = crate::http::CappedBody {
|
||||
text: r#"{"did":"did:key:x"}"#.to_string(),
|
||||
truncated: false,
|
||||
read_failed: false,
|
||||
};
|
||||
assert_eq!(web_url_from_info_body(&clean).unwrap(), None);
|
||||
}
|
||||
|
||||
/// Percent-encoded bidi must not slip past the literal scan: a node
|
||||
/// advertising `https://example.com/%E2%80%AE` serves wire bytes with no
|
||||
/// Cf code point, but a browser decodes it into a reordering character
|
||||
/// on the rendered View: link (INV-6).
|
||||
#[test]
|
||||
fn web_url_from_info_body_rejects_percent_encoded_bidi() {
|
||||
let body = crate::http::CappedBody {
|
||||
text: r#"{"web_url":"https://example.com/%E2%80%AE"}"#.to_string(),
|
||||
truncated: false,
|
||||
read_failed: false,
|
||||
};
|
||||
let err = web_url_from_info_body(&body).expect_err("percent-encoded bidi must be rejected");
|
||||
assert!(err.contains("bidi"), "got {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_validates_after_trimming() {
|
||||
// Trailing slash is trimmed before validation; result stays usable.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"web_url":"http://localhost:8080/ui/"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(web_url.as_deref(), Some("http://localhost:8080/ui"));
|
||||
}
|
||||
|
||||
/// A remote node that advertises `web_url` with userinfo (`user@`,
|
||||
/// `user:pass@`) must be dropped: the value is reprinted on the `View:`
|
||||
/// line, and a credential embedded in the URL would land in terminal
|
||||
/// scrollback and CI logs. Mirrors the node-side boot validation.
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_rejects_userinfo() {
|
||||
for bad in [
|
||||
"https://[email protected]",
|
||||
"https://user:[email protected]",
|
||||
] {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(format!(r#"{{"web_url":"{}"}}"#, bad))
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(
|
||||
web_url, None,
|
||||
"web_url with userinfo {bad:?} must be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-success status must still return None (View link is cosmetic) but
|
||||
/// the status surfaces as a user-visible stderr warning instead of being
|
||||
/// swallowed silently (#370 review).
|
||||
#[tokio::test]
|
||||
async fn test_fetch_node_web_url_non_success_warns_with_status() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(503)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let web_url = fetch_node_web_url(&server.url()).await;
|
||||
assert_eq!(web_url, None);
|
||||
// The warning itself goes to stderr; mockito can't capture it here, so
|
||||
// this asserts only the None contract. Manual check: run against a
|
||||
// 503-ing node and observe "node info request returned 503" on stderr.
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_create_server_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
//! End-to-end test for the `View:` line printed by `gl repo create` (#370).
|
||||
//!
|
||||
//! `cmd_create` is the load-bearing #370 path: it is the one place the CLI
|
||||
//! prints a `View:` line, and the only thing keeping it from being a
|
||||
//! hardcoded `https://gitlawb.com/...` 404 is `fetch_node_web_url`. The
|
||||
//! helper-level tests in `repo.rs` cover the helper, not the command path —
|
||||
//! if `cmd_create` went back to a hardcoded constant, every helper test
|
||||
//! would still pass.
|
||||
//!
|
||||
//! This integration test drives the real `gl` binary against a mockito
|
||||
//! server that answers both `POST /api/v1/repos` and `GET /`. Asserting on
|
||||
//! the subprocess's stdout is straightforward: bytes are bytes, with no
|
||||
//! libtest / gag / in-process capture race.
|
||||
//!
|
||||
//! CARGO_BIN_EXE_gl is set by Cargo for integration tests of a `[[bin]]`
|
||||
//! in the same package, so the test always picks up the in-tree build.
|
||||
|
||||
use std::process::Stdio;
|
||||
use tempfile::TempDir;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
|
||||
fn gl_bin() -> String {
|
||||
std::env::var("CARGO_BIN_EXE_gl").expect("CARGO_BIN_EXE_gl is unset for this test")
|
||||
}
|
||||
|
||||
async fn write_identity(dir: &TempDir) {
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let pem = kp.to_pem().unwrap();
|
||||
let path = dir.path().join("identity.pem");
|
||||
let mut f = tokio::fs::File::create(&path).await.unwrap();
|
||||
f.write_all(pem.as_bytes()).await.unwrap();
|
||||
}
|
||||
|
||||
/// The trailing key segment of the freshly-generated identity. Mirrors the
|
||||
/// in-source `resolve_owner_did` test helper but lives in this integration
|
||||
/// file because that helper is `pub(crate)`.
|
||||
async fn owner_short(dir: &TempDir) -> String {
|
||||
let pem = tokio::fs::read_to_string(dir.path().join("identity.pem"))
|
||||
.await
|
||||
.unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::from_pem(&pem).unwrap();
|
||||
let did = kp.did().to_string();
|
||||
did.split(':').next_back().unwrap_or(&did).to_string()
|
||||
}
|
||||
|
||||
async fn run_gl_create(node_url: &str, dir: &TempDir) -> String {
|
||||
let output = Command::new(gl_bin())
|
||||
.arg("repo")
|
||||
.arg("create")
|
||||
.arg("myrepo")
|
||||
.arg("--private")
|
||||
.arg("--branch")
|
||||
.arg("main")
|
||||
.arg("--node")
|
||||
.arg(node_url)
|
||||
.arg("--dir")
|
||||
.arg(dir.path())
|
||||
.env("NO_COLOR", "1")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await
|
||||
.expect("failed to spawn `gl repo create`");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"`gl repo create` failed: stderr:\n{}\nstdout:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
);
|
||||
String::from_utf8(output.stdout).expect("`gl` wrote non-UTF-8 to stdout")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_create_view_url_tracks_node_advertisement() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir).await;
|
||||
let owner = owner_short(&dir).await;
|
||||
|
||||
// Case 1: node advertises a web_url. The `View:` line must use the
|
||||
// advertised origin and the resolved owner/short name.
|
||||
{
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m_create = server
|
||||
.mock("POST", "/api/v1/repos")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"name":"myrepo","clone_url":"gitlawb://did:key:z6Mk/myrepo"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let _m_info = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"web_url":"https://git.example","did":"did:key:z6Mk"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let server_url = server.url();
|
||||
|
||||
let out = run_gl_create(&server_url, &dir).await;
|
||||
let view_line = out
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with("View:"))
|
||||
.unwrap_or_else(|| panic!("View: line missing from stdout:\n{out}"));
|
||||
assert!(
|
||||
view_line.contains(&format!("https://git.example/{owner}/myrepo")),
|
||||
"View: line must use the advertised origin, got: {view_line:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// Case 2: node does NOT advertise a web_url. The `View:` line must be
|
||||
// absent, so a self-hosted node without a web front-end never produces
|
||||
// a 404 link (#370).
|
||||
{
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m_create = server
|
||||
.mock("POST", "/api/v1/repos")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"name":"myrepo","clone_url":"gitlawb://did:key:z6Mk/myrepo"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let _m_info = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"did":"did:key:z6Mk"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let server_url = server.url();
|
||||
|
||||
let out = run_gl_create(&server_url, &dir).await;
|
||||
assert!(
|
||||
!out.lines().any(|l| l.trim_start().starts_with("View:")),
|
||||
"View: line must be absent when node omits web_url; got:\n{out}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ Step-by-step guide to staking $GITLAWB, registering your node on-chain, and earn
|
||||
|
||||
- A wallet with at least **10,000 $GITLAWB** (minimum stake) plus a small amount of ETH on Base for gas
|
||||
- Docker or Rust 1.91+ (for running the node process)
|
||||
- PostgreSQL 16+ — the node uses `pg_input_is_valid`, which exists only on PostgreSQL 16 and later. The bundled `docker-compose.yml` pins `postgres:16-alpine`; if you supply your own `DATABASE_URL`, the server must be 16+.
|
||||
- A public HTTP URL (your-host.com) — can be a VPS, Fly.io app, or anything reachable. A Fly.io config is provided at `infra/fly/fly.toml` (deploy from the repo root with `fly deploy -c infra/fly/fly.toml`)
|
||||
|
||||
---
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
# GraphQL repository pagination
|
||||
|
||||
`repos` returns the complete visible repository list when it contains at most
|
||||
200 entries. Larger lists return a GraphQL error directing the caller to
|
||||
`reposPage`; they are never silently truncated.
|
||||
|
||||
Use `reposPage` for larger lists:
|
||||
|
||||
```graphql
|
||||
query Repositories($after: String) {
|
||||
reposPage(limit: 50, after: $after) {
|
||||
nodes {
|
||||
name
|
||||
ownerDid
|
||||
}
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Start with `after: null`. When `hasNextPage` is true, pass the returned
|
||||
`endCursor` as `after` on the next request. Stop when `hasNextPage` is false.
|
||||
An empty page has a null `endCursor`. The default limit is 50 and the maximum
|
||||
page size is 200; limits below 1 or above 200 are rejected (see Query budgets
|
||||
below). Malformed cursors, including decoded owner or name strings containing
|
||||
NUL, return `invalid repository cursor` before database access.
|
||||
|
||||
Pages are ordered by normalized owner DID and repository name. Visibility and
|
||||
mirror deduplication are applied in the database before limiting the page;
|
||||
hidden and quarantined repositories do not occupy page slots or create a
|
||||
continuation signal. Cursors contain a position from the last returned visible
|
||||
repository, not a permission grant. Each request checks the current caller's
|
||||
visibility independently. Keep the same caller while traversing a list.
|
||||
Malformed reader lists in a repository's root visibility rule deny access to
|
||||
non-owners for that repository while other visible repositories remain listable.
|
||||
|
||||
Pagination is not a snapshot: concurrent renames, ownership changes, or visibility
|
||||
changes can alter subsequent pages. Treat cursors as opaque and restart from the
|
||||
first page when a fresh complete traversal is needed. Small legacy `repos`
|
||||
queries retain their activity ordering.
|
||||
|
||||
## Query budgets
|
||||
|
||||
Every GraphQL document is validated against two budgets before any resolver
|
||||
runs. Both apply per document (per request), not per client: `/graphql` has
|
||||
no per-caller rate limiting, so a rejected document can always be retried
|
||||
smaller. The constants live in `crates/gitlawb-node/src/graphql/mod.rs`.
|
||||
|
||||
- **Complexity budget: 400 per document** (`GRAPHQL_MAX_COMPLEXITY`). Each
|
||||
root field carries a base cost plus the clamped page size plus the selected
|
||||
child fields:
|
||||
- `repos`: 50 + `MAX_VISIBLE_REPO_PAGE_SIZE` + child fields. `repos`
|
||||
always scans the full visible corpus, so it is priced as the full page
|
||||
it is.
|
||||
- `reposPage`: 50 + `MAX_VISIBLE_REPO_PAGE_SIZE` + child fields. Every
|
||||
page request sorts the full deduplicated corpus, so the price does not
|
||||
depend on `limit`: seven `reposPage(limit: 1)` aliases cost the same as
|
||||
seven max-size pages.
|
||||
- `refUpdates`: 100 + clamped `limit` (`MAX_VISIBLE_REF_UPDATES`) + child
|
||||
fields. The 100 base covers the collector's fixed scan (the deduplicated
|
||||
repo set, the quarantined set, the visibility rules, and the bounded
|
||||
up-to-2048-row walk past withheld rows); the limit term prices only the
|
||||
rows returned.
|
||||
- `tasks`: 50 + clamped `limit` (`MAX_VISIBLE_TASK_PAGE_SIZE`) + child
|
||||
fields.
|
||||
- `task`, mutations, subscriptions: 50 + child fields.
|
||||
|
||||
A document whose total exceeds 400 is rejected with
|
||||
`Query is too complex.` before any resolver runs, so an over-budget
|
||||
document needs fewer aliases or fewer child fields. A smaller `limit` does
|
||||
not help `reposPage` or `repos`: both are priced at the full
|
||||
`MAX_VISIBLE_REPO_PAGE_SIZE` scan regardless of `limit`.
|
||||
|
||||
- **Depth cap: 14 per document** (`GRAPHQL_MAX_DEPTH`). Documents nested
|
||||
deeper than 14 selections are rejected with `Query is nested too deep.`
|
||||
Reference in new issue
Block a user