mirror of
https://github.com/Twigpine/node.git
synced 2026-11-09 05:20:05 -10:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c69640818a | ||
|
|
31e78f60ed | ||
|
|
73a75f623d | ||
|
|
634d39ed3a | ||
|
|
8adcbc39ac | ||
|
|
fba90038d5 | ||
|
|
ed9d6f8eee | ||
|
|
bfc44f926d | ||
|
|
65f5989a1d | ||
|
|
4725146db2 | ||
|
|
21ba974c54 | ||
|
|
d48ae1ea43 | ||
|
|
221160ae4c |
No files matched your search
+4
-19
@@ -24,26 +24,11 @@ DATABASE_URL=postgresql://gitlawb:changeme@localhost:5432/gitlawb
|
||||
# ── Database pool & startup resilience ────────────────────────────────────
|
||||
# Maximum connections in the PostgreSQL pool. A cap, not a floor —
|
||||
# connections open lazily. Size against the DB server's max_connections,
|
||||
# remembering admin tooling opens its own pool.
|
||||
# remembering admin tooling opens its own pool. Each concurrent write pins one
|
||||
# connection for its whole duration (the connection-affine advisory lock), so the
|
||||
# node REJECTS at boot any value below GITLAWB_MAX_CONCURRENT_GIT_PUSHES + 8
|
||||
# headroom — keep this comfortably above that (default 48 for pushes 32).
|
||||
GITLAWB_DB_MAX_CONNECTIONS=48
|
||||
# Maximum connections in the DEDICATED advisory-lock pool, separate from the
|
||||
# pool above. Every in-flight repo write pins one connection here for its whole
|
||||
# duration, so this is a hard ceiling on simultaneous writes node-wide: size it
|
||||
# to expected peak concurrent writers, not small. Keeping it separate is what
|
||||
# stops a push burst from starving ordinary request handlers. Budget
|
||||
# (GITLAWB_DB_MAX_CONNECTIONS + this) per node against the server's
|
||||
# max_connections, times node count, plus admin tooling.
|
||||
GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS=40
|
||||
# Upper bound, in seconds, on any object-storage transfer that runs while a
|
||||
# per-repo write lock is HELD (the archive download inside acquire_write and the
|
||||
# upload inside release). These were free before the lock's connection was
|
||||
# pinned to the guard; now an unbounded stall holds a lock-pool slot, and enough
|
||||
# stalls deny every write on the node. The bound applies PER SPAN and there are
|
||||
# two (the acquire-side refresh, which covers the existence check and download
|
||||
# together, and the release-side upload), so worst-case slot occupancy is about
|
||||
# twice this value plus the git work between them. Read it together with the pool
|
||||
# size above and with GITLAWB_GIT_SERVICE_TIMEOUT_SECS.
|
||||
GITLAWB_LOCK_HELD_TRANSFER_TIMEOUT_SECS=300
|
||||
# Seconds a request waits for a pool connection before failing with 503.
|
||||
GITLAWB_DB_ACQUIRE_TIMEOUT_SECS=5
|
||||
# Upper bound on each startup connect+migrate attempt, in seconds. Keep it
|
||||
|
||||
@@ -3758,13 +3758,9 @@ mod tests {
|
||||
// deterministically.
|
||||
let endpoint = crate::test_support::silent_http_endpoint().await;
|
||||
let tigris =
|
||||
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint);
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(
|
||||
repos_dir,
|
||||
Some(tigris),
|
||||
pool,
|
||||
std::time::Duration::from_secs(300),
|
||||
);
|
||||
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint)
|
||||
.await;
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(repos_dir, Some(tigris), pool);
|
||||
state.push_limiter_trust = crate::rate_limit::TrustedProxy::None;
|
||||
let mut cfg = (*state.config).clone();
|
||||
cfg.git_acquire_timeout_secs = 1;
|
||||
@@ -3835,13 +3831,9 @@ mod tests {
|
||||
// (endpoint-pinned test client, no AWS_* env reads).
|
||||
let endpoint = crate::test_support::silent_http_endpoint().await;
|
||||
let tigris =
|
||||
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint);
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(
|
||||
repos_dir,
|
||||
Some(tigris),
|
||||
pool,
|
||||
std::time::Duration::from_secs(300),
|
||||
);
|
||||
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint)
|
||||
.await;
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(repos_dir, Some(tigris), pool);
|
||||
let mut cfg = (*state.config).clone();
|
||||
cfg.git_acquire_timeout_secs = 1;
|
||||
state.config = Arc::new(cfg);
|
||||
@@ -7964,18 +7956,9 @@ mod tests {
|
||||
// (endpoint-pinned test client, no AWS_* env reads).
|
||||
let endpoint = crate::test_support::silent_http_endpoint().await;
|
||||
let tigris =
|
||||
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint);
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(
|
||||
repos_dir,
|
||||
Some(tigris),
|
||||
pool,
|
||||
std::time::Duration::from_secs(300),
|
||||
);
|
||||
state
|
||||
.db
|
||||
.upsert_mirror_repo("z6f3budget", "ghost", "/unused-ghost", None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint)
|
||||
.await;
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(repos_dir, Some(tigris), pool);
|
||||
let mut cfg = (*state.config).clone();
|
||||
cfg.ipfs_request_budget_secs = 1;
|
||||
cfg.git_acquire_timeout_secs = 2;
|
||||
|
||||
@@ -61,30 +61,20 @@ pub async fn create_issue(
|
||||
let json_str = serde_json::to_string(&issue)
|
||||
.map_err(|e| AppError::BadRequest(format!("serialization error: {e}")))?;
|
||||
|
||||
// Shed 503 + Retry-After on an exhausted write-lock POOL instead of a generic
|
||||
// git 500 (#173 F1). This path holds no admission permit, so it reaches the pool
|
||||
// unthrottled; reuse the push handler's mapping so the two cannot drift.
|
||||
let guard = state
|
||||
.repo_store
|
||||
.acquire_write(&record.owner_did, &record.name)
|
||||
.await
|
||||
.map_err(|e| crate::api::repos::acquire_write_app_error(&e, &repo))?;
|
||||
let disk_path = guard.path().to_path_buf();
|
||||
|
||||
let create_result = git_issues::create_issue(guard.path(), &issue_id, &json_str);
|
||||
let create_result = git_issues::create_issue(&disk_path, &issue_id, &json_str);
|
||||
|
||||
let git_timeout = std::time::Duration::from_secs(state.config.git_service_timeout_secs);
|
||||
let deadline = std::time::Instant::now() + git_timeout;
|
||||
let git_bin = state.git_bin.clone();
|
||||
let issue_id_for_comp = issue_id.clone();
|
||||
|
||||
let release_result = if create_result.is_ok() {
|
||||
guard
|
||||
.release_compensating(true, move |path| {
|
||||
git_issues::delete_issue_ref(&git_bin, path, &issue_id_for_comp, deadline)
|
||||
})
|
||||
.await
|
||||
.into_result()
|
||||
} else {
|
||||
guard.release(false).await.into_result()
|
||||
};
|
||||
release_result?;
|
||||
// Always release the advisory lock — even on error; upload to Tigris only on success.
|
||||
guard.release(create_result.is_ok()).await;
|
||||
|
||||
create_result.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
|
||||
@@ -235,8 +225,6 @@ pub async fn close_issue(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, repo, issue_id)): Path<(String, String, String)>,
|
||||
headers: axum::http::HeaderMap,
|
||||
crate::rate_limit::PeerAddr(peer): crate::rate_limit::PeerAddr,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
@@ -244,161 +232,8 @@ pub async fn close_issue(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
|
||||
// READ-GATE before any snapshot work or rate charging. The author fallback below
|
||||
// needs the issue blob, which needs the repo tree, so authorship cannot be established
|
||||
// without a download; but a caller who cannot even READ the repo must be
|
||||
// stopped here, cheaply, before any Tigris transfer or extraction happens.
|
||||
// Without this, any signed non-owner could issue parallel close requests for
|
||||
// arbitrary issue ids and drive unbounded downloads and blocking extraction
|
||||
// (a disposable-identity DoS), because the route has no other pre-authorization.
|
||||
//
|
||||
// Rate limiting runs AFTER this gate so a 429 cannot distinguish a hidden repo
|
||||
// from a missing one (INV-12 read-denial status contract).
|
||||
//
|
||||
// mirror-rows-handled: a repo row synced from a peer is stored public and
|
||||
// carries none of the owner's visibility rules, so for such a row this check
|
||||
// can only return allow. That is deliberate rather than overlooked, and it is
|
||||
// the same verdict every other read gate in this API reaches for one. Refusing
|
||||
// instead would deny the repo's real owner and the issue's real author on any
|
||||
// node whose only copy of the repo is a synced one, which is an ordinary state
|
||||
// here, and it would not buy the protection it appears to, because a synced
|
||||
// row's recorded owner comes from the peer that sent it. The expensive work
|
||||
// this check guards is bounded ahead of it by the per-IP limiter and the read
|
||||
// pool slot taken below, and the authoritative owner-or-author decision still
|
||||
// runs below and again under the write lock.
|
||||
{
|
||||
// mirror-rows-handled: synced mirror rows carry no owner rules; read gate only.
|
||||
let rules = state.db.list_visibility_rules(&record.id).await?;
|
||||
let caller = auth.0.as_str();
|
||||
if crate::visibility::visibility_check(
|
||||
&rules,
|
||||
record.is_public,
|
||||
&record.owner_did,
|
||||
Some(caller),
|
||||
"/",
|
||||
) == crate::visibility::Decision::Deny
|
||||
{
|
||||
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
|
||||
}
|
||||
}
|
||||
|
||||
// Per-IP flood brake, layered on the same shared limiter and trusted-proxy
|
||||
// policy as the push advertisement. The pre-lock snapshot downloads the
|
||||
// whole archive and runs a blocking extraction, so an unlimited route would
|
||||
// let disposable identities drive unbounded transfer/CPU/disk with parallel
|
||||
// close requests for arbitrary issue ids. Applied after the read gate so a
|
||||
// denied reader still sees 404, not 429.
|
||||
if let Some(key) = crate::rate_limit::client_key(&headers, peer, state.push_limiter_trust) {
|
||||
if !state.close_issue_rate_limiter.check(&key).await {
|
||||
tracing::warn!(repo = %repo, key = %key, "close_issue rate limited");
|
||||
return Err(AppError::TooManyRequests(
|
||||
"rate limit exceeded — try again later".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// AUTHORIZE BEFORE ACQUIRING. The per-repo advisory lock genuinely excludes
|
||||
// now, so taking it first would hand any caller with read access a way to hold
|
||||
// that lock on demand and be refused afterwards, while a legitimate writer
|
||||
// burned its retry budget against it. On a public repo that is every
|
||||
// permissionless identity. The lock must not be reachable by a caller who is
|
||||
// about to be refused the write.
|
||||
let is_owner = crate::api::require_repo_owner(&record, &auth.0).is_ok();
|
||||
if !is_owner {
|
||||
// Cap concurrent snapshot work on the shared read pool. The hourly rate
|
||||
// bucket above is not a concurrent-work brake; without this, parallel close
|
||||
// attempts from read-capable callers could each drive a full archive
|
||||
// download and blocking extraction before the author denial below.
|
||||
let _read_permit = state
|
||||
.git_read_semaphore
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| {
|
||||
tracing::warn!(
|
||||
repo = %repo,
|
||||
"close_issue snapshot refused — git read pool at capacity"
|
||||
);
|
||||
AppError::Overloaded("git service at capacity, retry shortly".into())
|
||||
})?;
|
||||
|
||||
// Not the owner, so the author fallback decides it, and the author lives in
|
||||
// the issue's git-JSON blob rather than a DB column.
|
||||
//
|
||||
// Read it WITHOUT the write lock, from a NON-MUTATING SNAPSHOT. The
|
||||
// justification is NOT that authorship is immutable — it is not:
|
||||
// `refs/gitlawb/**` is pushable, so a forged author blob can be pushed
|
||||
// (tracked separately; it is what makes this fallback only as trustworthy
|
||||
// as push authorization). The justification is that this read is only a
|
||||
// PRE-CHECK, deciding whether to take the lock at all. It is NOT the
|
||||
// authorization decision: `acquire_write` re-downloads the archive after
|
||||
// locking, so the tree that gets mutated is routinely not this one, and the
|
||||
// authoritative owner-or-author check runs again under the guard below.
|
||||
// Refusing here early just keeps a caller who is already visibly
|
||||
// unauthorized from reaching the lock.
|
||||
//
|
||||
// `read_snapshot`, not `acquire_fresh`: acquire's fast path returns as soon
|
||||
// as the directory exists and never contacts object storage, so on a node
|
||||
// with a stale copy the author's own issue would be invisible and the
|
||||
// cannot-establish-authorship arm below would 403 a legitimate author.
|
||||
// read_snapshot refreshes the same way, but unpacks into a throwaway temp
|
||||
// dir instead of publishing into the live repo path — an unlocked
|
||||
// pre-check must not delete or swap the directory under a concurrent
|
||||
// guarded write on the same path.
|
||||
// Pre-lock authorization only: bound with the read acquire budget, not the
|
||||
// under-lock transfer timeout a guarded write may hold for minutes.
|
||||
let snapshot_bound_secs = state.config.git_acquire_timeout_secs;
|
||||
let snapshot = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(snapshot_bound_secs),
|
||||
state
|
||||
.repo_store
|
||||
.read_snapshot(&record.owner_did, &record.name),
|
||||
)
|
||||
.await
|
||||
.map_err(|_elapsed| {
|
||||
tracing::warn!(
|
||||
repo = %repo,
|
||||
bound_secs = snapshot_bound_secs,
|
||||
"close_issue snapshot exceeded the read acquire bound — shedding as a retryable refusal"
|
||||
);
|
||||
AppError::RepoUnavailable
|
||||
})??;
|
||||
let snapshot_path = snapshot.path().to_path_buf();
|
||||
|
||||
let author_did: Option<String> = match git_issues::get_issue(&snapshot_path, &issue_id) {
|
||||
Ok(Some(raw)) => serde_json::from_str::<IssueRecord>(&raw)
|
||||
.ok()
|
||||
.and_then(|i| i.author),
|
||||
// Cannot establish authorship, so fail closed. Deliberately 403 rather
|
||||
// than 404 for a non-owner: a caller who is not authorized to write
|
||||
// should not learn from this route whether the issue exists. Both arms
|
||||
// below return None; they are split only so a read failure is visible
|
||||
// to operators, since a genuinely absent issue and an unreadable one
|
||||
// are the same answer to the client but not the same event.
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
repo = %repo,
|
||||
issue = %issue_id,
|
||||
err = %e,
|
||||
"get_issue failed during close_issue authorship pre-check"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
let is_author = author_did
|
||||
.as_deref()
|
||||
.is_some_and(|a| crate::api::did_matches(&auth.0, a));
|
||||
if !is_author {
|
||||
return Err(AppError::Forbidden(
|
||||
"only the repo owner or the issue author can close this issue".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Authorized. Only now is the lock taken.
|
||||
// Propagate rather than stringify: AppError's From<anyhow::Error> downcasts to
|
||||
// sqlx::Error so a pool timeout or a database outage surfaces as a retryable
|
||||
// 503. Calling .to_string() first destroys that and reports both as a 500.
|
||||
// Same capacity shed as create_issue above (#173 F1): an exhausted write-lock
|
||||
// pool is a 503 + Retry-After, not a 500 git error.
|
||||
let guard = state
|
||||
.repo_store
|
||||
.acquire_write(&record.owner_did, &record.name)
|
||||
@@ -406,56 +241,37 @@ pub async fn close_issue(
|
||||
.map_err(|e| crate::api::repos::acquire_write_app_error(&e, &repo))?;
|
||||
let disk_path = guard.path().to_path_buf();
|
||||
|
||||
// Re-read under the guard and RE-AUTHORIZE against what we read, rather than
|
||||
// only confirming the issue still exists. The pre-lock read decided whether to
|
||||
// take the lock; it cannot be the authorization decision, because acquire_write
|
||||
// re-downloads the archive after locking, so this is frequently a different tree
|
||||
// than the one the author was read from. Checking existence alone would leave the
|
||||
// whole decision resting on the earlier read of a tree we are no longer looking
|
||||
// at. The blob is already in hand here, so this costs a deserialize.
|
||||
match git_issues::get_issue(&disk_path, &issue_id) {
|
||||
Ok(Some(raw)) => {
|
||||
let author_now: Option<String> = serde_json::from_str::<IssueRecord>(&raw)
|
||||
.ok()
|
||||
.and_then(|i| i.author);
|
||||
let is_author_now = author_now
|
||||
.as_deref()
|
||||
.is_some_and(|a| crate::api::did_matches(&auth.0, a));
|
||||
if !is_owner && !is_author_now {
|
||||
// Consumed, NOT propagated, and that is deliberate at all three
|
||||
// `release(false)` sites below. These release without
|
||||
// publishing, so there is nothing for the store to refuse, and
|
||||
// mapping the outcome here would let a 503 shadow the
|
||||
// authorization answer this route exists to give.
|
||||
let _ = guard.release(false).await;
|
||||
return Err(AppError::Forbidden(
|
||||
"only the repo owner or the issue author can close this issue".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
// Owner OR issue author may close. The author lives in the issue's git-JSON
|
||||
// blob (not a DB column); a None author (legacy issues) falls back to
|
||||
// owner-only. Read it under the write guard, before mutating.
|
||||
let author_did: Option<String> = match git_issues::get_issue(&disk_path, &issue_id) {
|
||||
Ok(Some(raw)) => serde_json::from_str::<IssueRecord>(&raw)
|
||||
.ok()
|
||||
.and_then(|i| i.author),
|
||||
Ok(None) => {
|
||||
let _ = guard.release(false).await;
|
||||
// The owner keeps the informative 404; a non-owner must not learn from
|
||||
// this route whether the issue exists, matching the pre-check above.
|
||||
return Err(if is_owner {
|
||||
AppError::NotFound(format!("issue {issue_id} not found"))
|
||||
} else {
|
||||
AppError::Forbidden(
|
||||
"only the repo owner or the issue author can close this issue".into(),
|
||||
)
|
||||
});
|
||||
guard.release(false).await;
|
||||
return Err(AppError::NotFound(format!("issue {issue_id} not found")));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = guard.release(false).await;
|
||||
guard.release(false).await;
|
||||
return Err(AppError::Git(e.to_string()));
|
||||
}
|
||||
};
|
||||
let is_owner = crate::api::require_repo_owner(&record, &auth.0).is_ok();
|
||||
let is_author = author_did
|
||||
.as_deref()
|
||||
.is_some_and(|a| crate::api::did_matches(&auth.0, a));
|
||||
if !is_owner && !is_author {
|
||||
guard.release(false).await;
|
||||
return Err(AppError::Forbidden(
|
||||
"only the repo owner or the issue author can close this issue".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let close_result = git_issues::close_issue(&disk_path, &issue_id);
|
||||
|
||||
// Always release the advisory lock — even on error; upload to Tigris only on success.
|
||||
// Same short-circuit as create_issue, and before the 200 body below.
|
||||
guard.release(close_result.is_ok()).await.into_result()?;
|
||||
guard.release(close_result.is_ok()).await;
|
||||
|
||||
let updated = close_result
|
||||
.map_err(|e| AppError::Git(e.to_string()))?
|
||||
@@ -469,344 +285,12 @@ pub async fn close_issue(
|
||||
Ok(Json(issue))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::PgPool;
|
||||
|
||||
/// U7: once the advisory lock actually excludes, taking it BEFORE authorizing
|
||||
/// turns close_issue into a wedge primitive. Any caller with repo read access
|
||||
/// (on a public repo, any permissionless identity) could take the per-repo
|
||||
/// write lock on demand and be refused the write afterwards, while the owner's
|
||||
/// push burned its retry budget against a lock held by someone with no write
|
||||
/// authorization.
|
||||
///
|
||||
/// The observable: hold the lock from an independent session, then call the
|
||||
/// handler as a stranger. If it authorizes first it refuses immediately; if it
|
||||
/// acquires first it sits in the 60-attempt retry loop and the deadline fires.
|
||||
#[sqlx::test]
|
||||
async fn stranger_is_refused_without_waiting_on_the_write_lock(pool: PgPool) {
|
||||
use sqlx::Connection;
|
||||
let opts = (*pool.connect_options()).clone();
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
|
||||
let owner = "did:key:z6MkU7Owner";
|
||||
state
|
||||
.db
|
||||
.upsert_mirror_repo("z6MkU7Owner", "u7repo", "/tmp/u7repo", None, true)
|
||||
.await
|
||||
.expect("seed repo");
|
||||
let record = state
|
||||
.db
|
||||
.get_repo("z6MkU7Owner", "u7repo")
|
||||
.await
|
||||
.expect("get_repo")
|
||||
.expect("repo exists");
|
||||
|
||||
// An independent session holds the repo's write lock for the whole call.
|
||||
let key = crate::git::repo_store::advisory_lock_key_for_test(
|
||||
&record.owner_did.replace([':', '/'], "_"),
|
||||
&record.name,
|
||||
);
|
||||
let mut holder = sqlx::PgConnection::connect_with(&opts).await.unwrap();
|
||||
let held: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock($1)")
|
||||
.bind(key)
|
||||
.fetch_one(&mut holder)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
held.0,
|
||||
"the test must hold the lock for this to mean anything"
|
||||
);
|
||||
let _ = owner;
|
||||
|
||||
let stranger = crate::auth::AuthenticatedDid("did:key:z6MkU7Stranger".to_string());
|
||||
let outcome = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(3),
|
||||
close_issue(
|
||||
axum::extract::State(state.clone()),
|
||||
axum::Extension(stranger),
|
||||
axum::extract::Path((
|
||||
"z6MkU7Owner".to_string(),
|
||||
"u7repo".to_string(),
|
||||
"1".to_string(),
|
||||
)),
|
||||
axum::http::HeaderMap::new(),
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.64:5000".parse().unwrap())),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
let refused = outcome.expect(
|
||||
"a caller with no write authorization must be refused WITHOUT waiting on the \
|
||||
write lock; hitting this deadline means the handler tried to acquire first, \
|
||||
which is the wedge primitive",
|
||||
);
|
||||
assert!(
|
||||
matches!(refused, Err(AppError::Forbidden(_))),
|
||||
"expected 403 Forbidden for a stranger, got {:?}",
|
||||
refused.err().map(|e| format!("{e:?}"))
|
||||
);
|
||||
}
|
||||
|
||||
/// The read-gate's blind spot, pinned rather than left to be rediscovered.
|
||||
///
|
||||
/// A repo row synced from a peer is stored public and carries none of the
|
||||
/// owner's visibility rules, so the gate's own inputs can only produce allow
|
||||
/// for it. The gate above therefore does not carry this class of row, and the
|
||||
/// test that does cover it (`non_reader_is_refused_before_the_snapshot`) seeds
|
||||
/// a locally created repo, which cannot observe this: a passing test there is
|
||||
/// not coverage here.
|
||||
///
|
||||
/// Two things are asserted, and the second is why the first is acceptable.
|
||||
/// The gate's verdict for such a row is allow for an arbitrary caller, and the
|
||||
/// handler still refuses that caller afterwards, because the decision that
|
||||
/// matters is the owner-or-author check rather than this one. If a later change
|
||||
/// makes the gate the load-bearing decision for this route, the first assertion
|
||||
/// breaks and this comment is where to start.
|
||||
#[sqlx::test]
|
||||
async fn a_synced_row_is_not_gated_by_its_own_visibility(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
|
||||
// Only a synced row exists for this repo, with no locally created twin.
|
||||
state
|
||||
.db
|
||||
.upsert_mirror_repo("z6MkSyncOwner", "syncrepo", "/tmp/syncrepo", None, false)
|
||||
.await
|
||||
.expect("seed synced repo"); // false = not quarantined, the ordinary case
|
||||
let record = state
|
||||
.db
|
||||
.get_repo("z6MkSyncOwner", "syncrepo")
|
||||
.await
|
||||
.expect("get_repo")
|
||||
.expect("repo exists")
|
||||
.clone();
|
||||
assert!(
|
||||
record.id.contains('/'),
|
||||
"this test is only meaningful against a synced row; got id {}",
|
||||
record.id
|
||||
);
|
||||
|
||||
// The gate's two inputs, and what they force.
|
||||
let rules = state
|
||||
.db
|
||||
.list_visibility_rules(&record.id)
|
||||
.await
|
||||
.expect("list rules");
|
||||
assert!(rules.is_empty(), "a synced row carries no rules of its own");
|
||||
assert!(record.is_public, "a synced row is stored public");
|
||||
assert_eq!(
|
||||
crate::visibility::visibility_check(
|
||||
&rules,
|
||||
record.is_public,
|
||||
&record.owner_did,
|
||||
Some("did:key:z6MkSyncStranger"),
|
||||
"/",
|
||||
),
|
||||
crate::visibility::Decision::Allow,
|
||||
"the gate can only allow for a synced row, which is the property the \
|
||||
handler's mirror-rows-handled note records",
|
||||
);
|
||||
|
||||
// So the refusal has to come from the decision that is actually load-bearing.
|
||||
let stranger = crate::auth::AuthenticatedDid("did:key:z6MkSyncStranger".to_string());
|
||||
let outcome = close_issue(
|
||||
axum::extract::State(state.clone()),
|
||||
axum::Extension(stranger),
|
||||
axum::extract::Path((
|
||||
"z6MkSyncOwner".to_string(),
|
||||
"syncrepo".to_string(),
|
||||
"1".to_string(),
|
||||
)),
|
||||
axum::http::HeaderMap::new(),
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.99:5000".parse().unwrap())),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
outcome.is_err(),
|
||||
"a stranger must still be refused on a synced row, gate or no gate",
|
||||
);
|
||||
let body = format!("{:?}", outcome.err().unwrap());
|
||||
assert!(
|
||||
!body.contains("syncrepo/") && !body.to_lowercase().contains("issue body"),
|
||||
"the refusal must not leak repo contents: {body}",
|
||||
);
|
||||
}
|
||||
|
||||
/// The read-gate added for the pre-lock snapshot: a caller who cannot READ
|
||||
/// the repo (private repo, no rule granting them access) must be refused with
|
||||
/// a not-found BEFORE any snapshot download or extraction happens. The
|
||||
/// observable is the refusal itself; the cheaper part (no Tigris work) is
|
||||
/// structural (the gate precedes the snapshot call in the handler).
|
||||
#[sqlx::test]
|
||||
async fn non_reader_is_refused_before_the_snapshot(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
let now = chrono::Utc::now();
|
||||
state
|
||||
.db
|
||||
.create_repo(&crate::db::RepoRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: "priv-close".to_string(),
|
||||
owner_did: "z6MkT3Owner".to_string(),
|
||||
description: None,
|
||||
is_public: false,
|
||||
default_branch: "main".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
disk_path: "/tmp/priv-close".to_string(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
})
|
||||
.await
|
||||
.expect("seed private repo");
|
||||
|
||||
let stranger = crate::auth::AuthenticatedDid("did:key:z6MkT3Stranger".to_string());
|
||||
let res = close_issue(
|
||||
axum::extract::State(state.clone()),
|
||||
axum::Extension(stranger),
|
||||
axum::extract::Path((
|
||||
"z6MkT3Owner".to_string(),
|
||||
"priv-close".to_string(),
|
||||
"1".to_string(),
|
||||
)),
|
||||
axum::http::HeaderMap::new(),
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.67:5000".parse().unwrap())),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(res, Err(AppError::RepoNotFound(_))),
|
||||
"a non-reader must be refused as not-found, got {:?}",
|
||||
res.err().map(|e| format!("{e:?}"))
|
||||
);
|
||||
}
|
||||
|
||||
async fn seed_repo_with_issue(
|
||||
state: &crate::state::AppState,
|
||||
owner_slug: &str,
|
||||
owner_did: &str,
|
||||
repo: &str,
|
||||
issue_id: &str,
|
||||
author_did: &str,
|
||||
) -> std::path::PathBuf {
|
||||
state
|
||||
.db
|
||||
.upsert_mirror_repo(owner_slug, repo, "/unused", None, true)
|
||||
.await
|
||||
.expect("seed repo row");
|
||||
// Seed at the path the HANDLER will resolve. upsert_mirror_repo stores the
|
||||
// bare slug in owner_did, and close_issue resolves from record.owner_did, so
|
||||
// seeding from the full did:key would create the repo in a different
|
||||
// directory and the handler would find nothing.
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(owner_slug, repo)
|
||||
.await
|
||||
.expect("get_repo")
|
||||
.expect("seeded repo exists");
|
||||
let _ = owner_did;
|
||||
let path = state
|
||||
.repo_store
|
||||
.acquire(&record.owner_did, &record.name)
|
||||
.await
|
||||
.expect("resolve disk path");
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
crate::git::store::init_bare(&path).expect("init bare repo");
|
||||
// Must deserialize as a real IssueRecord: `created_at` and `status` are
|
||||
// required, and a parse failure would silently drop the author (the
|
||||
// `.ok()` on from_str), which reads as a 403 rather than as a broken fixture.
|
||||
let json = serde_json::to_string(&IssueRecord {
|
||||
id: issue_id.to_string(),
|
||||
title: "seeded".to_string(),
|
||||
body: Some(String::new()),
|
||||
author: Some(author_did.to_string()),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
status: "open".to_string(),
|
||||
signed_payload: None,
|
||||
})
|
||||
.expect("serialize seeded issue");
|
||||
crate::git::issues::create_issue(&path, issue_id, &json).expect("seed issue blob");
|
||||
path
|
||||
}
|
||||
|
||||
/// INV-21(c) positive twin 1: the OWNER can still close. The reorder moved the
|
||||
/// owner check above the lock, so this is the arm most likely to have broken,
|
||||
/// and the deny test alone could not see it.
|
||||
///
|
||||
/// The issue is seeded with a THIRD party as its author, deliberately. Seeding
|
||||
/// the owner as their own author made this test unable to fail: with the owner
|
||||
/// check disabled, the author fallback granted the close anyway and the test
|
||||
/// stayed green. Only the owner arm can grant here now.
|
||||
#[sqlx::test]
|
||||
async fn owner_can_still_close_after_the_reorder(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
let owner_did = "did:key:z6MkT1Owner";
|
||||
seed_repo_with_issue(
|
||||
&state,
|
||||
"z6MkT1Owner",
|
||||
owner_did,
|
||||
"t1repo",
|
||||
"1",
|
||||
"did:key:z6MkT1Stranger",
|
||||
)
|
||||
.await;
|
||||
|
||||
let res = close_issue(
|
||||
axum::extract::State(state.clone()),
|
||||
axum::Extension(crate::auth::AuthenticatedDid(owner_did.to_string())),
|
||||
axum::extract::Path((
|
||||
"z6MkT1Owner".to_string(),
|
||||
"t1repo".to_string(),
|
||||
"1".to_string(),
|
||||
)),
|
||||
axum::http::HeaderMap::new(),
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.65:5000".parse().unwrap())),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
res.is_ok(),
|
||||
"the owner must still be able to close: {:?}",
|
||||
res.err().map(|e| format!("{e:?}"))
|
||||
);
|
||||
}
|
||||
|
||||
/// INV-21(c) positive twin 2: the non-owner AUTHOR can still close, through both
|
||||
/// the pre-lock check and the re-assertion under the guard.
|
||||
///
|
||||
/// It does NOT cover the acquire-vs-acquire_fresh distinction, despite that being
|
||||
/// the reason the call changed. `RepoStore::for_testing` hardcodes `tigris: None`,
|
||||
/// which makes `acquire` and `acquire_fresh` identical in every test here, so
|
||||
/// reverting that line leaves this green. Separating them needs an object-storage
|
||||
/// seam, which is out of scope for this change and tracked separately. Claiming
|
||||
/// the coverage here would be worse than admitting the gap.
|
||||
#[sqlx::test]
|
||||
async fn issue_author_who_is_not_the_owner_can_still_close(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
let owner_did = "did:key:z6MkT2Owner";
|
||||
let author_did = "did:key:z6MkT2Author";
|
||||
seed_repo_with_issue(&state, "z6MkT2Owner", owner_did, "t2repo", "1", author_did).await;
|
||||
|
||||
let res = close_issue(
|
||||
axum::extract::State(state.clone()),
|
||||
axum::Extension(crate::auth::AuthenticatedDid(author_did.to_string())),
|
||||
axum::extract::Path((
|
||||
"z6MkT2Owner".to_string(),
|
||||
"t2repo".to_string(),
|
||||
"1".to_string(),
|
||||
)),
|
||||
axum::http::HeaderMap::new(),
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.66:5000".parse().unwrap())),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
res.is_ok(),
|
||||
"the issue author, who is NOT the repo owner, must still be able to close: {:?}",
|
||||
res.err().map(|e| format!("{e:?}"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// #173 F1 follow-up: issue write paths reach `acquire_write` with no admission
|
||||
/// permit, so an exhausted write-lock pool must shed 503 + Retry-After.
|
||||
/// #173 F1 follow-up: the two issue write paths reach `acquire_write` holding NO
|
||||
/// admission permit (unlike the push handler, which is capped by the git-push
|
||||
/// semaphore), so they are the callers most likely to meet an exhausted write-lock
|
||||
/// POOL under load. An exhausted pool is a capacity signal, so both must shed
|
||||
/// 503 + Retry-After (`AppError::Overloaded`) the way the push handler does, not
|
||||
/// report the generic 500 git error that says nothing about retrying.
|
||||
#[cfg(test)]
|
||||
mod lock_pool_shed_tests {
|
||||
use super::*;
|
||||
@@ -830,17 +314,22 @@ mod lock_pool_shed_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// State whose repo store draws write locks from a ONE-connection pool with a
|
||||
/// short checkout timeout, so a single held guard exhausts it promptly rather
|
||||
/// than at the pool default.
|
||||
async fn one_connection_lock_pool_state(pool: &PgPool) -> AppState {
|
||||
let mut state = crate::test_support::test_state(pool.clone()).await;
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(
|
||||
std::path::PathBuf::from("/tmp/gitlawb-issues-lockpool"),
|
||||
None,
|
||||
crate::git::repo_store::build_lock_pool(pool, 1, std::time::Duration::from_secs(1)),
|
||||
std::time::Duration::from_secs(300),
|
||||
);
|
||||
state
|
||||
}
|
||||
|
||||
/// The shed must be a real 503 carrying Retry-After, not just an internal enum
|
||||
/// variant: assert on the rendered response so a remapping of `Overloaded` is
|
||||
/// caught here too.
|
||||
fn assert_sheds_503_with_retry_after(err: AppError, what: &str) {
|
||||
let resp = err.into_response();
|
||||
assert_eq!(
|
||||
@@ -857,6 +346,10 @@ mod lock_pool_shed_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// RED-before/GREEN-after for `create_issue`. Both directions: the shed while the
|
||||
/// only lock-pool connection is held by a guard on a DIFFERENT repo (so this is
|
||||
/// pool capacity, not advisory-lock contention on this repo), and the must-not
|
||||
/// case once that connection is back.
|
||||
#[sqlx::test]
|
||||
async fn create_issue_lock_pool_exhaustion_sheds_503_not_500(pool: PgPool) {
|
||||
let owner = "did:key:zISSUECREATELOCKPOOLAAAAAAAAAAAAAAAAAAAA";
|
||||
@@ -887,7 +380,9 @@ mod lock_pool_shed_tests {
|
||||
let err = shed.expect_err("an exhausted lock pool must fail the call");
|
||||
assert_sheds_503_with_retry_after(err, "create_issue");
|
||||
|
||||
let _ = held.release(false).await;
|
||||
// MUST-NOT: with the pool free again the call is not shed as capacity (it
|
||||
// fails later on the nonexistent on-disk repo, which is a git 500).
|
||||
held.release(false).await;
|
||||
let admitted = create_issue(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
@@ -906,38 +401,7 @@ mod lock_pool_shed_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn close_issue_read_pool_exhaustion_sheds_before_snapshot(pool: PgPool) {
|
||||
use std::sync::Arc;
|
||||
let owner = "did:key:zISSUECLOSEREADPOOLBBBBBBBBBBBBBBBBBBBBB";
|
||||
let mut state = crate::test_support::test_state(pool.clone()).await;
|
||||
state.git_read_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
|
||||
state
|
||||
.db
|
||||
.create_repo(&seed_repo(owner, "read-cap"))
|
||||
.await
|
||||
.expect("seed repo");
|
||||
|
||||
let shed = close_issue(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(
|
||||
"did:key:zISSUECLOSEREADSTRANGER".to_string(),
|
||||
)),
|
||||
Path((
|
||||
owner.to_string(),
|
||||
"read-cap".to_string(),
|
||||
"deadbeef".to_string(),
|
||||
)),
|
||||
axum::http::HeaderMap::new(),
|
||||
crate::rate_limit::PeerAddr(None),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(shed, Err(AppError::Overloaded(_))),
|
||||
"an exhausted read pool must shed before snapshot work; got {shed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// RED-before/GREEN-after for `close_issue`, same two directions.
|
||||
#[sqlx::test]
|
||||
async fn close_issue_lock_pool_exhaustion_sheds_503_not_500(pool: PgPool) {
|
||||
let owner = "did:key:zISSUECLOSELOCKPOOLBBBBBBBBBBBBBBBBBBBBB";
|
||||
@@ -962,14 +426,12 @@ mod lock_pool_shed_tests {
|
||||
"lp-close".to_string(),
|
||||
"deadbeef".to_string(),
|
||||
)),
|
||||
axum::http::HeaderMap::new(),
|
||||
crate::rate_limit::PeerAddr(None),
|
||||
)
|
||||
.await;
|
||||
let err = shed.expect_err("an exhausted lock pool must fail the call");
|
||||
assert_sheds_503_with_retry_after(err, "close_issue");
|
||||
|
||||
let _ = held.release(false).await;
|
||||
held.release(false).await;
|
||||
let admitted = close_issue(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
@@ -978,8 +440,6 @@ mod lock_pool_shed_tests {
|
||||
"lp-close".to_string(),
|
||||
"deadbeef".to_string(),
|
||||
)),
|
||||
axum::http::HeaderMap::new(),
|
||||
crate::rate_limit::PeerAddr(None),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
@@ -988,244 +448,4 @@ mod lock_pool_shed_tests {
|
||||
admitted.err()
|
||||
);
|
||||
}
|
||||
|
||||
async fn assert_retryable_repo_acquire(err: AppError, what: &str, code: &str) {
|
||||
let resp = err.into_response();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"{what}: a transient acquire refusal must shed 503, not a 500 git error"
|
||||
);
|
||||
let body = axum::body::to_bytes(resp.into_body(), 64 * 1024)
|
||||
.await
|
||||
.expect("body");
|
||||
let body = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
body.contains(code),
|
||||
"{what}: the 503 must carry the {code} code, got {body}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Advisory-lock contention on a non-push mutation must map through the shared
|
||||
/// `acquire_write_app_error` classifier to `repo_busy`, not a 500 git_error.
|
||||
#[sqlx::test]
|
||||
async fn create_issue_contention_sheds_repo_busy_not_500(pool: PgPool) {
|
||||
let owner = "did:key:zISSUECREATEBUSYAAAAAAAAAAAAAAAAAAAAA";
|
||||
let repo_name = "busy-create";
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
state
|
||||
.db
|
||||
.create_repo(&seed_repo(owner, repo_name))
|
||||
.await
|
||||
.expect("seed repo");
|
||||
|
||||
let held = state
|
||||
.repo_store
|
||||
.acquire_write(owner, repo_name)
|
||||
.await
|
||||
.expect("the first writer takes the advisory lock");
|
||||
|
||||
let shed = create_issue(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
Path((owner.to_string(), repo_name.to_string())),
|
||||
Json(CreateIssueRequest {
|
||||
title: "t".to_string(),
|
||||
body: None,
|
||||
signed_payload: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let err = shed.expect_err("contention must refuse the second writer");
|
||||
assert_retryable_repo_acquire(err, "create_issue", "repo_busy").await;
|
||||
|
||||
let _ = held.release(false).await;
|
||||
}
|
||||
|
||||
/// A refused under-lock refresh on a non-push mutation must map to
|
||||
/// `repo_unavailable`, not expose the storage error as a 500 git_error.
|
||||
#[sqlx::test]
|
||||
async fn create_issue_unavailable_sheds_repo_unavailable_not_500(pool: PgPool) {
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
let app = axum::Router::new().route(
|
||||
"/{*key}",
|
||||
axum::routing::any(|method: axum::http::Method| async move {
|
||||
if method == axum::http::Method::HEAD {
|
||||
let mut resp = axum::http::StatusCode::OK.into_response();
|
||||
resp.headers_mut()
|
||||
.insert("etag", axum::http::HeaderValue::from_static("\"gen-1\""));
|
||||
resp
|
||||
} else {
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let endpoint = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port());
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let owner = "did:key:zISSUEUNAVAILAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let repo_name = "unavail-create";
|
||||
let mut state = crate::test_support::test_state(pool.clone()).await;
|
||||
state.repo_store = crate::git::repo_store::RepoStore::for_testing_with_tigris(
|
||||
std::path::PathBuf::from("/tmp/gitlawb-issue-unavail"),
|
||||
crate::git::repo_store::build_lock_pool(&pool, 2, std::time::Duration::from_secs(1)),
|
||||
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint),
|
||||
);
|
||||
state
|
||||
.db
|
||||
.create_repo(&seed_repo(owner, repo_name))
|
||||
.await
|
||||
.expect("seed repo");
|
||||
|
||||
let shed = create_issue(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
Path((owner.to_string(), repo_name.to_string())),
|
||||
Json(CreateIssueRequest {
|
||||
title: "t".to_string(),
|
||||
body: None,
|
||||
signed_payload: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let err = shed.expect_err("a failed archive download must refuse the write");
|
||||
assert_retryable_repo_acquire(err, "create_issue", "repo_unavailable").await;
|
||||
|
||||
server.abort();
|
||||
}
|
||||
|
||||
/// A denied reader on a private repo must see 404 even when the caller's rate
|
||||
/// bucket is exhausted. Rate limiting runs after the read gate.
|
||||
#[sqlx::test]
|
||||
async fn close_issue_rate_limit_runs_after_the_read_gate(pool: PgPool) {
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
let owner = "did:key:zCLOSERATEOWNERAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let stranger = "did:key:zCLOSERATESTRANGERBBBBBBBBBBBBBBBBBB";
|
||||
let mut state = crate::test_support::test_state(pool).await;
|
||||
state.close_issue_rate_limiter =
|
||||
crate::rate_limit::RateLimiter::new(1, Duration::from_secs(60));
|
||||
state.push_limiter_trust = crate::rate_limit::TrustedProxy::None;
|
||||
|
||||
let mut repo = seed_repo(owner, "priv-close");
|
||||
repo.is_public = false;
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
|
||||
let peer: SocketAddr = "203.0.113.88:7000".parse().unwrap();
|
||||
assert!(
|
||||
state
|
||||
.close_issue_rate_limiter
|
||||
.check(&peer.ip().to_string())
|
||||
.await,
|
||||
"exhaust the close_issue bucket before close_issue"
|
||||
);
|
||||
|
||||
let res = close_issue(
|
||||
State(state),
|
||||
Extension(AuthenticatedDid(stranger.to_string())),
|
||||
Path((owner.to_string(), "priv-close".to_string(), "1".to_string())),
|
||||
axum::http::HeaderMap::new(),
|
||||
crate::rate_limit::PeerAddr(Some(peer)),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
matches!(res, Err(AppError::RepoNotFound(_))),
|
||||
"a non-reader must see 404, not 429, even when rate limited: {:?}",
|
||||
res
|
||||
);
|
||||
}
|
||||
|
||||
/// close_issue and receive-pack must not share one per-IP bucket.
|
||||
#[sqlx::test]
|
||||
async fn close_issue_rate_limit_does_not_drain_push_bucket(pool: PgPool) {
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
let mut state = crate::test_support::test_state(pool).await;
|
||||
state.close_issue_rate_limiter =
|
||||
crate::rate_limit::RateLimiter::new(1, Duration::from_secs(60));
|
||||
state.push_rate_limiter = crate::rate_limit::RateLimiter::new(1, Duration::from_secs(60));
|
||||
state.push_limiter_trust = crate::rate_limit::TrustedProxy::None;
|
||||
|
||||
let peer: SocketAddr = "203.0.113.99:7000".parse().unwrap();
|
||||
let key = peer.ip().to_string();
|
||||
assert!(
|
||||
state.close_issue_rate_limiter.check(&key).await,
|
||||
"exhaust only the close_issue bucket"
|
||||
);
|
||||
assert!(
|
||||
state.push_rate_limiter.check(&key).await,
|
||||
"push traffic must keep its own bucket after close_issue is exhausted"
|
||||
);
|
||||
}
|
||||
|
||||
/// A refused publish must roll back the local issue ref so a retry does not
|
||||
/// mint a second id for the same logical filing attempt.
|
||||
#[sqlx::test]
|
||||
async fn create_issue_rolls_back_local_ref_when_publish_refuses(pool: PgPool) {
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
let app = axum::Router::new().route(
|
||||
"/{*key}",
|
||||
axum::routing::any(|method: axum::http::Method| async move {
|
||||
if method == axum::http::Method::HEAD {
|
||||
let mut resp = axum::http::StatusCode::OK.into_response();
|
||||
resp.headers_mut()
|
||||
.insert("etag", axum::http::HeaderValue::from_static("\"gen-1\""));
|
||||
resp
|
||||
} else {
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let endpoint = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port());
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let owner = "did:key:zISSUEROLLBACKAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let repo_name = "rollback-create";
|
||||
let mut state = crate::test_support::test_state(pool.clone()).await;
|
||||
let disk = tempfile::TempDir::new().unwrap();
|
||||
crate::git::store::init_bare(&disk.path().join("repo.git")).expect("bare repo");
|
||||
let mut seeded = seed_repo(owner, repo_name);
|
||||
seeded.disk_path = disk.path().join("repo.git").to_string_lossy().to_string();
|
||||
state.db.create_repo(&seeded).await.expect("seed repo");
|
||||
state.repo_store = crate::git::repo_store::RepoStore::for_testing_with_tigris(
|
||||
disk.path().to_path_buf(),
|
||||
crate::git::repo_store::build_lock_pool(&pool, 2, std::time::Duration::from_secs(1)),
|
||||
crate::git::tigris::TigrisClient::for_testing_with_endpoint("test-bucket", &endpoint),
|
||||
);
|
||||
|
||||
let shed = create_issue(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
Path((owner.to_string(), repo_name.to_string())),
|
||||
Json(CreateIssueRequest {
|
||||
title: "t".to_string(),
|
||||
body: None,
|
||||
signed_payload: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
shed.is_err(),
|
||||
"a failed archive upload must refuse the write"
|
||||
);
|
||||
|
||||
let issues = git_issues::list_issues(&disk.path().join("repo.git")).expect("list issues");
|
||||
assert!(
|
||||
issues.is_empty(),
|
||||
"the local issue ref must be rolled back after a refused publish, got {issues:?}"
|
||||
);
|
||||
|
||||
server.abort();
|
||||
}
|
||||
}
|
||||
@@ -209,6 +209,9 @@ pub async fn merge_pr(
|
||||
return Err(AppError::BadRequest(format!("PR is already {}", pr.status)));
|
||||
}
|
||||
|
||||
// Shed 503 + Retry-After on an exhausted write-lock POOL instead of a generic
|
||||
// git 500 (#173 F1). Merging holds no admission permit, so it reaches the pool
|
||||
// unthrottled; reuse the push handler's mapping so the two cannot drift.
|
||||
let guard = state
|
||||
.repo_store
|
||||
.acquire_write(&record.owner_did, &record.name)
|
||||
@@ -225,10 +228,7 @@ pub async fn merge_pr(
|
||||
);
|
||||
|
||||
// Always release the advisory lock — even on error; upload to Tigris only on success.
|
||||
// Short-circuit on a refused publish before the PR is marked merged and
|
||||
// before the webhook fires. Both are irreversible announcements of a merge
|
||||
// commit that only exists on this node's disk.
|
||||
guard.release(merge_result.is_ok()).await.into_result()?;
|
||||
guard.release(merge_result.is_ok()).await;
|
||||
|
||||
let merge_sha = merge_result.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
|
||||
@@ -428,8 +428,12 @@ pub async fn list_comments(
|
||||
Ok(Json(serde_json::json!({ "comments": comments })))
|
||||
}
|
||||
|
||||
/// #173 F1 follow-up: merge holds no admission permit, so an exhausted write-lock
|
||||
/// pool must shed 503 + Retry-After.
|
||||
/// #173 F1 follow-up: `merge_pr` reaches `acquire_write` holding NO admission permit
|
||||
/// (unlike the push handler, which is capped by the git-push semaphore), so it is one
|
||||
/// of the callers most likely to meet an exhausted write-lock POOL under load. An
|
||||
/// exhausted pool is a capacity signal, so the merge must shed 503 + Retry-After
|
||||
/// (`AppError::Overloaded`) the way the push handler does, not report the generic
|
||||
/// 500 git error that says nothing about retrying.
|
||||
#[cfg(test)]
|
||||
mod lock_pool_shed_tests {
|
||||
use super::*;
|
||||
@@ -453,15 +457,20 @@ mod lock_pool_shed_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// RED-before/GREEN-after for `merge_pr`. Both directions: the shed while the only
|
||||
/// lock-pool connection is held by a guard on a DIFFERENT repo (so this is pool
|
||||
/// capacity, not advisory-lock contention on this repo), and the must-not case
|
||||
/// once that connection is back.
|
||||
#[sqlx::test]
|
||||
async fn merge_pr_lock_pool_exhaustion_sheds_503_not_500(pool: PgPool) {
|
||||
let owner = "did:key:zMERGELOCKPOOLOWNERAAAAAAAAAAAAAAAAAAAAA";
|
||||
let mut state = crate::test_support::test_state(pool.clone()).await;
|
||||
// One lock-pool connection with a short checkout timeout, so a single held
|
||||
// guard exhausts it promptly rather than at the pool default.
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(
|
||||
std::path::PathBuf::from("/tmp/gitlawb-pulls-lockpool"),
|
||||
None,
|
||||
crate::git::repo_store::build_lock_pool(&pool, 1, std::time::Duration::from_secs(1)),
|
||||
std::time::Duration::from_secs(300),
|
||||
);
|
||||
|
||||
let repo = seed_repo(owner, "lp-merge");
|
||||
@@ -515,7 +524,9 @@ mod lock_pool_shed_tests {
|
||||
"merge_pr: a capacity shed must tell the client when to retry"
|
||||
);
|
||||
|
||||
let _ = held.release(false).await;
|
||||
// MUST-NOT: with the pool free again the merge is not shed as capacity (it
|
||||
// fails later on the nonexistent on-disk repo, which is a git 500).
|
||||
held.release(false).await;
|
||||
let admitted = merge_pr(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.to_string())),
|
||||
|
||||
@@ -3,7 +3,6 @@ use axum::http::StatusCode;
|
||||
use axum::response::Response;
|
||||
use axum::Json;
|
||||
use bytes::Bytes;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::auth::{caller_authorized_to_push, AuthenticatedDid};
|
||||
@@ -693,17 +692,8 @@ pub async fn git_info_refs(
|
||||
git_permit(&state.git_read_semaphore)?
|
||||
};
|
||||
|
||||
// For receive-pack (push), read the latest from Tigris so the client sees
|
||||
// the same refs that acquire_write() will operate on. A NON-MUTATING
|
||||
// snapshot, not `acquire_fresh`: the advertisement runs WITHOUT the advisory
|
||||
// lock, and acquire_fresh downloads and publishes into the live repo path
|
||||
// (removing the existing directory and renaming the extract into place), so
|
||||
// an unlocked advertisement could delete or swap the directory under a
|
||||
// concurrent guarded write. In the worst ordering the guarded write has
|
||||
// finished but `release` has not compressed the tree, and the guarded
|
||||
// release uploads the replaced old tree with its still-valid ETag and
|
||||
// reports success, losing the accepted write. The snapshot unpacks into a
|
||||
// throwaway temp dir that is served from and then removed.
|
||||
// For receive-pack (push), download the latest from Tigris so the client
|
||||
// sees the same refs that acquire_write() will operate on.
|
||||
//
|
||||
// Bound the acquire under `git_acquire_timeout_secs`: the concurrency permit is
|
||||
// already held above, and `git_service_timeout_secs` only starts once git spawns,
|
||||
@@ -713,46 +703,28 @@ pub async fn git_info_refs(
|
||||
// so the shed frees the slot; return a bounded 503.
|
||||
let acquire_deadline = std::time::Duration::from_secs(state.config.git_acquire_timeout_secs);
|
||||
let acquire_fut = async {
|
||||
let res = if service == "git-receive-pack" {
|
||||
if service == "git-receive-pack" {
|
||||
state
|
||||
.repo_store
|
||||
.read_snapshot(&record.owner_did, &record.name)
|
||||
.acquire_fresh(&record.owner_did, &record.name)
|
||||
.await
|
||||
.map(|s| (s.path().to_path_buf(), Some(s)))
|
||||
} else {
|
||||
state
|
||||
.repo_store
|
||||
.acquire(&record.owner_did, &record.name)
|
||||
.await
|
||||
.map(|p| (p, None))
|
||||
};
|
||||
res.map_err(|e| {
|
||||
if is_expected_transient_acquire_failure(&e) {
|
||||
tracing::warn!(repo = %name, service = %service, err = %e, "repo acquire failed");
|
||||
} else {
|
||||
tracing::error!(repo = %name, service = %service, err = %e, "repo acquire failed");
|
||||
}
|
||||
// This closure bypasses the `From<anyhow::Error>` chain, so a typed
|
||||
// refusal would otherwise be stringified into a 500 `git_error`. Route
|
||||
// just that one case through `From` and leave every other failure on
|
||||
// exactly today's behavior: this call site also serves the read path via
|
||||
// `acquire`, whose error vocabulary is out of scope here.
|
||||
if e.is::<crate::git::repo_store::RepoUnavailable>() {
|
||||
AppError::from(e)
|
||||
} else {
|
||||
AppError::Git(e.to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
// The snapshot (if any) is kept alive for the whole handler scope below: its
|
||||
// Drop removes the temp dir it was unpacked into, so dropping it here would
|
||||
// delete the directory `info_refs` is about to serve from.
|
||||
let (disk_path, _snapshot_keepalive) = tokio::time::timeout(acquire_deadline, acquire_fut)
|
||||
let disk_path = tokio::time::timeout(acquire_deadline, acquire_fut)
|
||||
.await
|
||||
.map_err(|_elapsed| {
|
||||
tracing::warn!(repo = %name, service = %service, "repo acquire timed out; shedding with 503");
|
||||
AppError::Overloaded("git service acquisition timed out, retry shortly".into())
|
||||
})??;
|
||||
})?
|
||||
.map_err(|e| {
|
||||
tracing::error!(repo = %name, service = %service, err = %e, "repo acquire failed");
|
||||
AppError::Git(e.to_string())
|
||||
})?;
|
||||
|
||||
// Move the admission permits into the guard so they release only after the spawned
|
||||
// git process group is confirmed reaped, on complete/timeout/disconnect — not the
|
||||
@@ -1006,7 +978,6 @@ pub(crate) mod drain_faults {
|
||||
pub(crate) rules_read_failures_left: usize,
|
||||
pub(crate) repo_read_attempts: usize,
|
||||
pub(crate) rules_read_attempts: usize,
|
||||
pub(crate) reread_exhausted: bool,
|
||||
}
|
||||
|
||||
fn table() -> &'static Mutex<HashMap<String, Counters>> {
|
||||
@@ -1037,25 +1008,6 @@ pub(crate) mod drain_faults {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether the drain re-read loop exhausted its retry budget for `repo_id`.
|
||||
pub(crate) fn reread_exhausted(repo_id: &str) -> bool {
|
||||
table()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(repo_id)
|
||||
.map(|c| c.reread_exhausted)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn mark_reread_exhausted(repo_id: &str) {
|
||||
table()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(repo_id.to_string())
|
||||
.or_default()
|
||||
.reread_exhausted = true;
|
||||
}
|
||||
|
||||
/// Production-path hook: count one repo re-read attempt, return whether it must fail.
|
||||
pub(crate) fn take_repo_read(repo_id: &str) -> bool {
|
||||
let mut map = table().lock().unwrap();
|
||||
@@ -1177,8 +1129,6 @@ async fn drain_refresh_state(ctx: &EncryptTaskCtx) -> DrainRefresh {
|
||||
"coalesced drain: re-read failed on every attempt; the coalesced push's \
|
||||
pin/encrypt pass is dropped (no reconciliation sweep re-derives it)"
|
||||
);
|
||||
#[cfg(test)]
|
||||
drain_faults::mark_reread_exhausted(&ctx.repo_id);
|
||||
DrainRefresh::Failed
|
||||
}
|
||||
|
||||
@@ -1566,12 +1516,11 @@ async fn pin_and_encrypt_objects(
|
||||
/// Map an `acquire_write` failure to the right `AppError`. An exhausted repo write-lock
|
||||
/// POOL is a capacity signal, not a broken repo, so it sheds 503 + Retry-After the same
|
||||
/// way the admission caps around it do; it used to fall into the generic git 500, which
|
||||
/// tells the client nothing about retrying (#173 F1). Lock contention and a refused
|
||||
/// under-lock refresh are transient and map to fixed-body 503s via [`RepoBusy`] and
|
||||
/// [`RepoUnavailable`]; only genuine untyped git failures stay on the 500 path.
|
||||
/// tells the client nothing about retrying (#173 F1). Anything else stays a git error.
|
||||
///
|
||||
/// Shared with every `acquire_write` caller (`receive-pack`, `api/issues.rs`,
|
||||
/// `api/pulls.rs`) so the write-acquisition contract cannot drift between routes.
|
||||
/// Shared with the non-push `acquire_write` callers (`api/issues.rs`, `api/pulls.rs`)
|
||||
/// rather than copied: those hold no admission permit, so they meet an exhausted pool
|
||||
/// first, and a second copy of this mapping would be free to drift from the push path.
|
||||
pub(crate) fn acquire_write_app_error(err: &anyhow::Error, repo: &str) -> AppError {
|
||||
if err
|
||||
.downcast_ref::<crate::git::repo_store::LockPoolBusy>()
|
||||
@@ -1579,16 +1528,6 @@ pub(crate) fn acquire_write_app_error(err: &anyhow::Error, repo: &str) -> AppErr
|
||||
{
|
||||
tracing::warn!(repo = %repo, err = %err, "write-lock pool exhausted; shedding with 503");
|
||||
AppError::Overloaded("git write locks at capacity, retry shortly".into())
|
||||
} else if is_expected_transient_acquire_failure(err) {
|
||||
tracing::warn!(repo = %repo, err = %err, "acquire_write failed");
|
||||
if err
|
||||
.downcast_ref::<crate::git::repo_store::RepoBusy>()
|
||||
.is_some()
|
||||
{
|
||||
AppError::RepoBusy
|
||||
} else {
|
||||
AppError::RepoUnavailable
|
||||
}
|
||||
} else {
|
||||
tracing::error!(repo = %repo, err = %err, "acquire_write failed");
|
||||
AppError::Git(err.to_string())
|
||||
@@ -1599,23 +1538,6 @@ pub(crate) fn acquire_write_app_error(err: &anyhow::Error, repo: &str) -> AppErr
|
||||
/// [`smart_http::GitServiceTimeout`] to 504, a malformed client request to 400,
|
||||
/// anything else to a 500 git error. Pure (no logging) so it is unit-testable;
|
||||
/// callers add their own tracing.
|
||||
/// Acquire failures that are ordinary and transient: lock contention
|
||||
/// ([`RepoBusy`]) and an under-lock refresh that could not reach object storage
|
||||
/// ([`RepoUnavailable`]). Both already log at their raise site and both map to a
|
||||
/// retryable 503, so the handler layer logs them at warn rather than paging.
|
||||
/// Best-effort, like the database startup classifier: anything this cannot
|
||||
/// recognize counts as NOT transient and keeps its error-level log.
|
||||
///
|
||||
/// [`RepoBusy`]: crate::git::repo_store::RepoBusy
|
||||
/// [`RepoUnavailable`]: crate::git::repo_store::RepoUnavailable
|
||||
fn is_expected_transient_acquire_failure(err: &anyhow::Error) -> bool {
|
||||
err.downcast_ref::<crate::git::repo_store::RepoBusy>()
|
||||
.is_some()
|
||||
|| err
|
||||
.downcast_ref::<crate::git::repo_store::RepoUnavailable>()
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn git_service_app_error(err: &anyhow::Error) -> AppError {
|
||||
if err
|
||||
.downcast_ref::<smart_http::GitServiceTimeout>()
|
||||
@@ -2375,7 +2297,19 @@ pub async fn git_receive_pack(
|
||||
// would return 200 to the pusher before the durable copy lands, which is a larger
|
||||
// change to the client contract than the window it closes.
|
||||
let push_succeeded = receive_result.is_ok();
|
||||
let publish_durability = PublishDurabilitySlot::new();
|
||||
if push_succeeded {
|
||||
tokio::spawn(post_receive_replication_tail(
|
||||
state.clone(),
|
||||
record.clone(),
|
||||
ref_updates.clone(),
|
||||
disk_path.clone(),
|
||||
auth.0.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Always release the advisory lock — even on error — to prevent stale locks
|
||||
// from blocking subsequent pushes. Only upload to Tigris when the push
|
||||
// succeeded; uploading a half-applied repo would propagate corruption.
|
||||
// Reclaim the write lock from the shared cell (#173 F2). This is only reachable
|
||||
// once `receive_pack` has returned, so the admission guard's copy can only ever
|
||||
// DELAY release, never perform it early; on the disconnect path this line is not
|
||||
@@ -2385,27 +2319,7 @@ pub async fn git_receive_pack(
|
||||
.expect("repo write-lock mutex poisoned")
|
||||
.take()
|
||||
.expect("the write lock is only taken here, and only once");
|
||||
if push_succeeded {
|
||||
publish_durability.mark_release_started();
|
||||
tokio::spawn(post_receive_replication_tail(
|
||||
state.clone(),
|
||||
record.clone(),
|
||||
ref_updates.clone(),
|
||||
disk_path.clone(),
|
||||
auth.0.to_string(),
|
||||
Some(publish_durability.arc()),
|
||||
));
|
||||
}
|
||||
// Short-circuit on a refused publish BEFORE anything downstream observes
|
||||
// the push. The pack is on local disk but not in object storage, so
|
||||
// touching the repo, recording the push, bumping trust, issuing ref
|
||||
// certificates or answering 200 would all be reporting a write no other
|
||||
// node can read.
|
||||
let outcome = reclaimed.release(push_succeeded).await;
|
||||
if push_succeeded {
|
||||
publish_durability.record(outcome).await;
|
||||
}
|
||||
outcome.into_result()?;
|
||||
reclaimed.release(push_succeeded).await;
|
||||
// Clean path: clone (a) already dropped inside run_git_service when the receive-pack
|
||||
// group was reaped; clone (b) held here spanned the success-only Tigris upload that
|
||||
// ran inside release() above. Drop it now so a second same-repo push proceeds the
|
||||
@@ -2522,122 +2436,13 @@ pub async fn git_receive_pack(
|
||||
/// the per-repo-coalesced pin/encrypt task, and this push's own Pinata + announce
|
||||
/// task. Split out of `git_receive_pack` so the ordering the coalescing gate depends
|
||||
/// on is directly testable; the handler spawns it and returns.
|
||||
/// Records the release-side publish outcome for the detached post-receive tail.
|
||||
/// If the handler future is dropped after [`Self::mark_release_started`] but
|
||||
/// before [`Self::record`], the drop sets [`ReleaseOutcome::UploadUnknowable`]
|
||||
/// so the tail can proceed on local disk without waiting out the full transfer
|
||||
/// bound. A drop before release starts leaves the slot empty so the tail fails
|
||||
/// closed rather than treating an unattempted publish as unknowably durable.
|
||||
struct PublishDurabilitySlot {
|
||||
inner: Arc<std::sync::Mutex<Option<crate::git::repo_store::ReleaseOutcome>>>,
|
||||
recorded: std::sync::atomic::AtomicBool,
|
||||
release_started: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl PublishDurabilitySlot {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(std::sync::Mutex::new(None)),
|
||||
recorded: std::sync::atomic::AtomicBool::new(false),
|
||||
release_started: std::sync::atomic::AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_release_started(&self) {
|
||||
self.release_started
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn arc(&self) -> Arc<std::sync::Mutex<Option<crate::git::repo_store::ReleaseOutcome>>> {
|
||||
Arc::clone(&self.inner)
|
||||
}
|
||||
|
||||
async fn record(&self, outcome: crate::git::repo_store::ReleaseOutcome) {
|
||||
*self
|
||||
.inner
|
||||
.lock()
|
||||
.expect("publish durability mutex poisoned") = Some(outcome);
|
||||
self.recorded
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PublishDurabilitySlot {
|
||||
fn drop(&mut self) {
|
||||
if self.recorded.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
if !self
|
||||
.release_started
|
||||
.load(std::sync::atomic::Ordering::SeqCst)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut slot = self
|
||||
.inner
|
||||
.lock()
|
||||
.expect("publish durability mutex poisoned");
|
||||
if slot.is_none() {
|
||||
*slot = Some(crate::git::repo_store::ReleaseOutcome::UploadUnknowable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn publish_durability_confirmed(
|
||||
slot: &Option<Arc<std::sync::Mutex<Option<crate::git::repo_store::ReleaseOutcome>>>>,
|
||||
wait: std::time::Duration,
|
||||
) -> bool {
|
||||
let Some(slot) = slot else {
|
||||
return true;
|
||||
};
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
if let Some(outcome) = *slot.lock().expect("publish durability mutex poisoned") {
|
||||
return matches!(
|
||||
outcome,
|
||||
crate::git::repo_store::ReleaseOutcome::Released
|
||||
| crate::git::repo_store::ReleaseOutcome::UploadUnknowable
|
||||
);
|
||||
}
|
||||
if start.elapsed() >= wait {
|
||||
// No outcome within the release-side transfer bound plus slack. Fail
|
||||
// closed when the slot is still empty; an abandoned handler installs
|
||||
// UploadUnknowable via PublishDurabilitySlot::drop so the tail can
|
||||
// proceed on local disk without waiting out the full bound.
|
||||
return matches!(
|
||||
*slot.lock().expect("publish durability mutex poisoned"),
|
||||
Some(crate::git::repo_store::ReleaseOutcome::Released)
|
||||
| Some(crate::git::repo_store::ReleaseOutcome::UploadUnknowable)
|
||||
);
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn post_receive_replication_tail(
|
||||
state: AppState,
|
||||
record: RepoRecord,
|
||||
ref_updates: Vec<RefUpdate>,
|
||||
disk_path: std::path::PathBuf,
|
||||
did: String,
|
||||
publish_durability: Option<
|
||||
Arc<std::sync::Mutex<Option<crate::git::repo_store::ReleaseOutcome>>>,
|
||||
>,
|
||||
) {
|
||||
let durability_wait = std::time::Duration::from_secs(
|
||||
state
|
||||
.config
|
||||
.lock_held_transfer_timeout_secs
|
||||
.saturating_add(5),
|
||||
);
|
||||
if !publish_durability_confirmed(&publish_durability, durability_wait).await {
|
||||
tracing::warn!(
|
||||
repo = %record.id,
|
||||
"skipping post-receive tail: publish durability was not confirmed"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Replication enforcement (Phase 2): decide once per push whether the public
|
||||
// may read this repo at all and, if so, which blob OIDs must not leave the
|
||||
// node. `withheld == None` means this push pins nothing (private / mode A /
|
||||
@@ -3189,129 +2994,6 @@ pub async fn list_federated_repos(
|
||||
|
||||
// ── Fork ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Removes a fork's local mirror clone on every exit until disarmed after the
|
||||
/// database row commits.
|
||||
struct ForkCloneGuard(Option<PathBuf>);
|
||||
|
||||
const FORK_CREATE_CONFIRM_ATTEMPTS: u32 = 5;
|
||||
|
||||
/// After `create_repo` fails, re-read the row with bounded retries so a
|
||||
/// transport-level error does not skip archive compensation when the insert never
|
||||
/// landed, and so a successful insert is not mistaken for a failure.
|
||||
async fn confirm_fork_repo_row(
|
||||
db: &crate::db::Db,
|
||||
owner_short: &str,
|
||||
fork_name: &str,
|
||||
) -> anyhow::Result<Option<crate::db::RepoRecord>> {
|
||||
let mut delay = std::time::Duration::from_millis(25);
|
||||
for attempt in 0..FORK_CREATE_CONFIRM_ATTEMPTS {
|
||||
match db.get_repo(owner_short, fork_name).await {
|
||||
Ok(row) => return Ok(row),
|
||||
Err(e) if attempt + 1 < FORK_CREATE_CONFIRM_ATTEMPTS => {
|
||||
tracing::warn!(
|
||||
fork = %fork_name,
|
||||
attempt,
|
||||
err = %e,
|
||||
"fork create_repo confirmation lookup failed — retrying"
|
||||
);
|
||||
tokio::time::sleep(delay).await;
|
||||
delay = delay
|
||||
.saturating_mul(2)
|
||||
.min(std::time::Duration::from_secs(1));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
unreachable!("loop returns on every attempt")
|
||||
}
|
||||
|
||||
/// When the confirmation lookup stays unavailable after bounded retries, keep
|
||||
/// retrying in the background and compensate only after establishing that no row
|
||||
/// exists for this fork name.
|
||||
async fn schedule_fork_create_recovery(
|
||||
db: std::sync::Arc<crate::db::Db>,
|
||||
repo_store: crate::git::repo_store::RepoStore,
|
||||
owner_short: String,
|
||||
fork_name: String,
|
||||
owner_did: String,
|
||||
disk_path: std::path::PathBuf,
|
||||
) {
|
||||
let mut delay = std::time::Duration::from_millis(250);
|
||||
for attempt in 0..12 {
|
||||
match db.get_repo(&owner_short, &fork_name).await {
|
||||
Ok(Some(_)) => {
|
||||
tracing::info!(
|
||||
fork = %fork_name,
|
||||
attempt,
|
||||
"fork create_repo recovery found a committed row — no archive compensation"
|
||||
);
|
||||
return;
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::warn!(
|
||||
fork = %fork_name,
|
||||
attempt,
|
||||
"fork create_repo recovery confirmed no row — compensating orphan archive"
|
||||
);
|
||||
repo_store
|
||||
.compensate_fork_archive(&owner_did, &fork_name, disk_path.as_path())
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
Err(e) if attempt + 1 < 12 => {
|
||||
tracing::warn!(
|
||||
fork = %fork_name,
|
||||
attempt,
|
||||
err = %e,
|
||||
"fork create_repo recovery lookup failed — retrying"
|
||||
);
|
||||
tokio::time::sleep(delay).await;
|
||||
delay = delay
|
||||
.saturating_mul(2)
|
||||
.min(std::time::Duration::from_secs(30));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
fork = %fork_name,
|
||||
err = %e,
|
||||
"fork create_repo recovery gave up — orphan archive may remain until operator cleanup"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ForkCloneGuard {
|
||||
fn new(path: crate::git::repo_store::ValidatedRepoDiskPath) -> Self {
|
||||
Self(Some(path.into_path_buf()))
|
||||
}
|
||||
|
||||
fn path(&self) -> &std::path::Path {
|
||||
self.0
|
||||
.as_deref()
|
||||
.expect("fork clone guard disarmed while still in use")
|
||||
}
|
||||
|
||||
fn disarm(&mut self) {
|
||||
self.0 = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ForkCloneGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(path) = self.0.take() {
|
||||
if let Err(e) = std::fs::remove_dir_all(&path) {
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
err = %e,
|
||||
"failed to remove fork clone during attempt cleanup"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ForkRepoRequest {
|
||||
pub name: Option<String>, // defaults to source repo name
|
||||
@@ -3382,12 +3064,7 @@ pub async fn fork_repo(
|
||||
.await
|
||||
.map_err(|e| AppError::Git(e.to_string()))?;
|
||||
|
||||
let disk_path = crate::git::repo_store::validated_repo_disk_path(
|
||||
&state.config.repos_dir,
|
||||
&forker_did,
|
||||
&fork_name,
|
||||
)
|
||||
.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let disk_path = store::repo_disk_path(&state.config.repos_dir, &forker_did, &fork_name);
|
||||
|
||||
// Clone the source repo as a mirror
|
||||
let output = std::process::Command::new("git")
|
||||
@@ -3407,30 +3084,11 @@ pub async fn fork_repo(
|
||||
)));
|
||||
}
|
||||
|
||||
let mut clone_guard = ForkCloneGuard::new(disk_path.clone());
|
||||
|
||||
// Upload fork to Tigris. Create-only: a refused precondition means an orphan
|
||||
// archive already sits under this key (a failed create_repo or another
|
||||
// writer), and proceeding would create a DB record whose archive is shadowed
|
||||
// by bytes that are not this fork. Refuse rather than accept a fork other
|
||||
// nodes would fetch as unrelated content. The clone guard removes the local
|
||||
// mirror on every upload failure path.
|
||||
// Upload fork to Tigris
|
||||
state
|
||||
.repo_store
|
||||
.release_after_write(&forker_did, &fork_name)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
crate::git::tigris::UploadError::PreconditionLost { status } => {
|
||||
tracing::warn!(
|
||||
forker = %forker_did,
|
||||
fork = %fork_name,
|
||||
status,
|
||||
"fork refused: an archive already exists under the fork's key"
|
||||
);
|
||||
AppError::RepoExists(fork_name.clone())
|
||||
}
|
||||
other => AppError::Git(format!("fork upload failed: {other}")),
|
||||
})?;
|
||||
.await;
|
||||
|
||||
let now = Utc::now();
|
||||
let record = crate::db::RepoRecord {
|
||||
@@ -3442,64 +3100,12 @@ pub async fn fork_repo(
|
||||
default_branch: source.default_branch.clone(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
disk_path: clone_guard.path().to_string_lossy().to_string(),
|
||||
disk_path: disk_path.to_string_lossy().to_string(),
|
||||
forked_from: Some(source.id.clone()),
|
||||
machine_id: state.machine_id.clone(),
|
||||
};
|
||||
|
||||
if let Err(e) = state.db.create_repo(&record).await {
|
||||
match confirm_fork_repo_row(&state.db, forker_short, &fork_name).await {
|
||||
Ok(Some(committed)) => {
|
||||
clone_guard.disarm();
|
||||
tracing::warn!(
|
||||
fork = %fork_name,
|
||||
forker = %forker_did,
|
||||
"fork create_repo returned an error but the row is present — treating as success"
|
||||
);
|
||||
return Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(to_response(&committed, &state, 0)),
|
||||
));
|
||||
}
|
||||
Ok(None) => {
|
||||
clone_guard.disarm();
|
||||
state
|
||||
.repo_store
|
||||
.compensate_fork_archive(&forker_did, &fork_name, disk_path.as_path())
|
||||
.await;
|
||||
return Err(e.into());
|
||||
}
|
||||
Err(lookup_err) => {
|
||||
clone_guard.disarm();
|
||||
let db = std::sync::Arc::clone(&state.db);
|
||||
let repo_store = state.repo_store.clone();
|
||||
let owner_short = forker_short.to_string();
|
||||
let fork_name_cl = fork_name.clone();
|
||||
let owner_did = forker_did.clone();
|
||||
let disk_path_cl = disk_path.as_path().to_path_buf();
|
||||
tokio::spawn(async move {
|
||||
schedule_fork_create_recovery(
|
||||
db,
|
||||
repo_store,
|
||||
owner_short,
|
||||
fork_name_cl,
|
||||
owner_did,
|
||||
disk_path_cl,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
tracing::warn!(
|
||||
fork = %fork_name,
|
||||
create_err = %e,
|
||||
lookup_err = %lookup_err,
|
||||
"fork create_repo failed and confirmation lookup stayed unavailable — scheduled recovery"
|
||||
);
|
||||
return Err(AppError::RepoUnavailable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clone_guard.disarm();
|
||||
state.db.create_repo(&record).await?;
|
||||
|
||||
// Persist the proof so the fork carries it when it propagates to peers.
|
||||
if let Some(p) = verified_proof {
|
||||
@@ -3737,129 +3343,6 @@ mod tests {
|
||||
const OWNER_SHORT: &str = "z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH";
|
||||
const STRANGER_DID: &str = "did:key:z6Mkffonly5tranger0000000000000000000000000000000";
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_durability_slot_drop_installs_unknowable_when_release_started_but_unrecorded()
|
||||
{
|
||||
let slot = PublishDurabilitySlot::new();
|
||||
slot.mark_release_started();
|
||||
let arc = slot.arc();
|
||||
drop(slot);
|
||||
let outcome = *arc.lock().expect("publish durability mutex poisoned");
|
||||
assert_eq!(
|
||||
outcome,
|
||||
Some(crate::git::repo_store::ReleaseOutcome::UploadUnknowable),
|
||||
"dropping an unrecorded slot after release starts must publish UploadUnknowable for the tail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_durability_slot_drop_leaves_empty_before_release_starts() {
|
||||
let slot = PublishDurabilitySlot::new();
|
||||
let arc = slot.arc();
|
||||
drop(slot);
|
||||
assert_eq!(
|
||||
*arc.lock().expect("publish durability mutex poisoned"),
|
||||
None,
|
||||
"dropping before release starts must not synthesize durability for the tail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_durability_slot_drop_waits_for_contended_mutex() {
|
||||
let slot = PublishDurabilitySlot::new();
|
||||
slot.mark_release_started();
|
||||
let arc = slot.arc();
|
||||
let holder = {
|
||||
let arc = Arc::clone(&arc);
|
||||
std::thread::spawn(move || {
|
||||
let _guard = arc.lock().expect("publish durability mutex poisoned");
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
})
|
||||
};
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
drop(slot);
|
||||
holder.join().expect("mutex holder thread");
|
||||
let outcome = *arc.lock().expect("publish durability mutex poisoned");
|
||||
assert_eq!(
|
||||
outcome,
|
||||
Some(crate::git::repo_store::ReleaseOutcome::UploadUnknowable),
|
||||
"drop must block until it can install UploadUnknowable, not give up on try_lock"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_durability_confirmed_proceeds_quickly_after_unrecorded_slot_drop() {
|
||||
let start = std::time::Instant::now();
|
||||
let slot = PublishDurabilitySlot::new();
|
||||
slot.mark_release_started();
|
||||
let arc = slot.arc();
|
||||
drop(slot);
|
||||
let confirmed =
|
||||
publish_durability_confirmed(&Some(arc), std::time::Duration::from_millis(50)).await;
|
||||
assert!(confirmed);
|
||||
assert!(
|
||||
start.elapsed() < std::time::Duration::from_millis(200),
|
||||
"the tail must not wait out the full transfer bound when the slot already carries UploadUnknowable"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_durability_confirmed_fails_closed_when_release_never_records() {
|
||||
let slot = Arc::new(std::sync::Mutex::new(None));
|
||||
let confirmed =
|
||||
publish_durability_confirmed(&Some(slot), std::time::Duration::from_millis(30)).await;
|
||||
assert!(
|
||||
!confirmed,
|
||||
"an empty slot after the bounded wait must not admit the tail; only an installed \
|
||||
Released or UploadUnknowable outcome may"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_durability_confirmed_accepts_only_released() {
|
||||
let slot = Arc::new(std::sync::Mutex::new(Some(
|
||||
crate::git::repo_store::ReleaseOutcome::Released,
|
||||
)));
|
||||
assert!(
|
||||
publish_durability_confirmed(&Some(slot), std::time::Duration::from_millis(5)).await
|
||||
);
|
||||
|
||||
let slot = Arc::new(std::sync::Mutex::new(Some(
|
||||
crate::git::repo_store::ReleaseOutcome::UploadUnknowable,
|
||||
)));
|
||||
assert!(
|
||||
publish_durability_confirmed(&Some(slot), std::time::Duration::from_millis(5)).await,
|
||||
"an unknowable upload still landed on local disk, so the tail may proceed"
|
||||
);
|
||||
|
||||
let slot = Arc::new(std::sync::Mutex::new(Some(
|
||||
crate::git::repo_store::ReleaseOutcome::UploadFailed,
|
||||
)));
|
||||
assert!(
|
||||
!publish_durability_confirmed(&Some(slot), std::time::Duration::from_millis(5)).await
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_clone_guard_removes_mirror_on_drop() {
|
||||
let root = tempfile::TempDir::new().unwrap();
|
||||
let validated = crate::git::repo_store::validated_repo_disk_path(
|
||||
root.path(),
|
||||
"did:key:testfork",
|
||||
"fork",
|
||||
)
|
||||
.expect("test fork path must validate");
|
||||
std::fs::create_dir_all(validated.as_path()).unwrap();
|
||||
{
|
||||
let _guard = ForkCloneGuard::new(validated.clone());
|
||||
assert!(validated.exists());
|
||||
}
|
||||
assert!(
|
||||
!validated.exists(),
|
||||
"dropping the fork clone guard must remove the mirror directory"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_pack_request_finalizes_only_with_done_pktline() {
|
||||
let want = "0032want 1111111111111111111111111111111111111111\n";
|
||||
@@ -4042,55 +3525,6 @@ mod tests {
|
||||
assert!(git_permit(&sem).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_expected_transient_matches_both_typed_refusals() {
|
||||
// The real raise shape wraps the marker in a `.context()` layer naming the
|
||||
// owner slug and repo, so the downcast has to survive that wrapping.
|
||||
let busy = anyhow::Error::new(crate::git::repo_store::RepoBusy)
|
||||
.context("another write is in progress for alice/demo");
|
||||
assert!(is_expected_transient_acquire_failure(&busy));
|
||||
|
||||
let unavailable = anyhow::Error::new(crate::git::repo_store::RepoUnavailable)
|
||||
.context("could not read the archive HEAD for alice/demo");
|
||||
assert!(is_expected_transient_acquire_failure(&unavailable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_expected_transient_rejects_unrelated_failures() {
|
||||
// Anything the classifier cannot recognize keeps paging at error level.
|
||||
let other = anyhow::anyhow!("disk on fire");
|
||||
assert!(!is_expected_transient_acquire_failure(&other));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acquire_write_app_error_maps_transient_markers_to_retryable_503() {
|
||||
let busy = anyhow::Error::new(crate::git::repo_store::RepoBusy)
|
||||
.context("another write is in progress for alice/demo");
|
||||
assert!(matches!(
|
||||
acquire_write_app_error(&busy, "demo"),
|
||||
AppError::RepoBusy
|
||||
));
|
||||
|
||||
let unavailable = anyhow::Error::new(crate::git::repo_store::RepoUnavailable)
|
||||
.context("could not read the archive HEAD for alice/demo");
|
||||
assert!(matches!(
|
||||
acquire_write_app_error(&unavailable, "demo"),
|
||||
AppError::RepoUnavailable
|
||||
));
|
||||
|
||||
let pool = anyhow::Error::new(crate::git::repo_store::LockPoolBusy);
|
||||
assert!(matches!(
|
||||
acquire_write_app_error(&pool, "demo"),
|
||||
AppError::Overloaded(_)
|
||||
));
|
||||
|
||||
let other = anyhow::anyhow!("disk on fire");
|
||||
assert!(matches!(
|
||||
acquire_write_app_error(&other, "demo"),
|
||||
AppError::Git(_)
|
||||
));
|
||||
}
|
||||
|
||||
fn repo_owned_by(owner_did: &str) -> crate::db::RepoRecord {
|
||||
let now = chrono::Utc::now();
|
||||
crate::db::RepoRecord {
|
||||
@@ -6821,12 +6255,14 @@ mod tests {
|
||||
repos_dir.path().to_path_buf(),
|
||||
None,
|
||||
crate::git::repo_store::build_lock_pool(&pool, 4, std::time::Duration::from_secs(5)),
|
||||
std::time::Duration::from_secs(300),
|
||||
);
|
||||
let mut cfg = (*state.config).clone();
|
||||
// Long enough that the git-service timeout is never what ends this push; the
|
||||
// disconnect is.
|
||||
cfg.git_service_timeout_secs = 600;
|
||||
// Lock-path test, not owner-push authorization: pin off so the default-on gate
|
||||
// does not reject the proof DID before receive-pack runs.
|
||||
cfg.enforce_owner_push = false;
|
||||
state.config = std::sync::Arc::new(cfg);
|
||||
state
|
||||
.db
|
||||
@@ -6869,7 +6305,9 @@ mod tests {
|
||||
let mut fut = Box::pin(git_receive_pack(
|
||||
State(state.clone()),
|
||||
Path((owner.to_string(), name.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid("did:key:z6disc".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(
|
||||
"did:key:z6MkDisconnectWriteLockProofDidAAAAAAAA".to_string(),
|
||||
)),
|
||||
crate::rate_limit::PeerAddr(Some(
|
||||
"203.0.113.81:5000".parse::<SocketAddr>().unwrap(),
|
||||
)),
|
||||
@@ -6994,8 +6432,10 @@ mod tests {
|
||||
repos_dir.path().to_path_buf(),
|
||||
None,
|
||||
crate::git::repo_store::build_lock_pool(&pool, 4, std::time::Duration::from_secs(5)),
|
||||
std::time::Duration::from_secs(300),
|
||||
);
|
||||
let mut cfg = (*state.config).clone();
|
||||
cfg.enforce_owner_push = false;
|
||||
state.config = std::sync::Arc::new(cfg);
|
||||
state
|
||||
.db
|
||||
.upsert_mirror_repo(owner, name, "/tmp/z6succ-sc1", None, false)
|
||||
@@ -7019,7 +6459,9 @@ mod tests {
|
||||
git_receive_pack(
|
||||
State(state.clone()),
|
||||
Path((owner.to_string(), name.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid("did:key:z6succ".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(
|
||||
"did:key:z6MkPushSuccessReleaseProofDidAAAAAAAA".to_string(),
|
||||
)),
|
||||
crate::rate_limit::PeerAddr(Some(
|
||||
"203.0.113.83:5000".parse::<SocketAddr>().unwrap(),
|
||||
)),
|
||||
@@ -7080,13 +6522,15 @@ mod tests {
|
||||
let owner = "z6lockpool";
|
||||
let name = "lp1";
|
||||
let mut state = crate::test_support::test_state(pool.clone()).await;
|
||||
let mut cfg = (*state.config).clone();
|
||||
cfg.enforce_owner_push = false;
|
||||
state.config = std::sync::Arc::new(cfg);
|
||||
// One lock-pool connection, short checkout timeout so the exhaustion surfaces
|
||||
// promptly rather than at the handler's own acquire deadline.
|
||||
state.repo_store = crate::git::repo_store::RepoStore::new(
|
||||
std::path::PathBuf::from("/tmp/gitlawb-lockpool-shed"),
|
||||
None,
|
||||
crate::git::repo_store::build_lock_pool(&pool, 1, std::time::Duration::from_secs(1)),
|
||||
std::time::Duration::from_secs(300),
|
||||
);
|
||||
state
|
||||
.db
|
||||
@@ -7094,7 +6538,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let did = "did:key:z6lockpool";
|
||||
let did = "did:key:z6MkLockPoolShedProofDidAAAAAAAAAAAAAAAAAA";
|
||||
let peer: SocketAddr = "203.0.113.71:5000".parse().unwrap();
|
||||
|
||||
// Occupy the only lock-pool connection with a write on an UNRELATED repo.
|
||||
@@ -7121,7 +6565,7 @@ mod tests {
|
||||
|
||||
// MUST-NOT: with the pool free again, the push is not shed as capacity (it fails
|
||||
// later on the nonexistent on-disk repo, which is a git error, not Overloaded).
|
||||
let _ = held.release(false).await;
|
||||
held.release(false).await;
|
||||
let admitted = git_receive_pack(
|
||||
State(state.clone()),
|
||||
Path((owner.to_string(), name.to_string())),
|
||||
@@ -10196,43 +9640,6 @@ mod tests {
|
||||
|
||||
const F2A_PUSHER: &str = "did:key:z6MkF2aPusherAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
/// When publish durability is fenced, the tail must not run walks or take
|
||||
/// coalescing keys before returning.
|
||||
#[cfg(unix)]
|
||||
#[sqlx::test]
|
||||
async fn post_receive_tail_skips_all_work_when_publish_fenced(pool: sqlx::PgPool) {
|
||||
let repo = tempfile::TempDir::new().unwrap();
|
||||
let bin = tempfile::TempDir::new().unwrap();
|
||||
let log = bin.path().join("git.log");
|
||||
let git_bin = f2a_logging_git(bin.path(), &log);
|
||||
u5_init_repo(repo.path());
|
||||
let c1 = u5_commit_file(repo.path(), "a.txt", "one\n");
|
||||
let (state, rec) = f2a_state(pool, &git_bin, "z6f2afence", "fence-repo", false).await;
|
||||
let slot = Arc::new(std::sync::Mutex::new(Some(
|
||||
crate::git::repo_store::ReleaseOutcome::Fenced,
|
||||
)));
|
||||
post_receive_replication_tail(
|
||||
state.clone(),
|
||||
rec,
|
||||
f2a_update("refs/heads/main", &c1),
|
||||
repo.path().to_path_buf(),
|
||||
F2A_PUSHER.to_string(),
|
||||
Some(slot),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
f2a_walks(&log),
|
||||
0,
|
||||
"a fenced publish must not run the replication walk; log:\n{}",
|
||||
f2a_log(&log)
|
||||
);
|
||||
assert_eq!(
|
||||
state.encrypt_inflight.len(),
|
||||
0,
|
||||
"a fenced publish must not take the per-repo coalescing key"
|
||||
);
|
||||
}
|
||||
|
||||
/// Scenario 1 (the finding). A second rapid push to the same repo coalesces
|
||||
/// WITHOUT running the withheld walk. Asserted on the walk's git children, not
|
||||
/// on the `Coalesced` outcome: with `try_begin` below the walk (the pre-fix
|
||||
@@ -10262,7 +9669,6 @@ mod tests {
|
||||
f2a_update("refs/heads/main", &c2),
|
||||
repo.path().to_path_buf(),
|
||||
F2A_PUSHER.to_string(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let after_first = f2a_walks(&log);
|
||||
@@ -10283,7 +9689,6 @@ mod tests {
|
||||
f2a_update("refs/heads/second", &c1),
|
||||
repo.path().to_path_buf(),
|
||||
F2A_PUSHER.to_string(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -10357,7 +9762,6 @@ mod tests {
|
||||
f2a_update("refs/heads/main", &c2),
|
||||
repo.path().to_path_buf(),
|
||||
F2A_PUSHER.to_string(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -10439,7 +9843,6 @@ mod tests {
|
||||
f2a_update("refs/heads/main", &c2),
|
||||
repo.path().to_path_buf(),
|
||||
F2A_PUSHER.to_string(),
|
||||
None,
|
||||
));
|
||||
f2a_wait_for(|| started.exists(), "the admitted push's walk to start").await;
|
||||
|
||||
@@ -10553,7 +9956,6 @@ mod tests {
|
||||
}],
|
||||
repo.path().to_path_buf(),
|
||||
F2A_PUSHER.to_string(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -10832,7 +10234,6 @@ mod tests {
|
||||
}],
|
||||
repo.path().to_path_buf(),
|
||||
F2A_PUSHER.to_string(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -10888,7 +10289,6 @@ mod tests {
|
||||
f2a_update("refs/heads/main", &c1),
|
||||
repo.path().to_path_buf(),
|
||||
F2A_PUSHER.to_string(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
@@ -10930,7 +10330,6 @@ mod tests {
|
||||
}],
|
||||
repo.path().to_path_buf(),
|
||||
F2A_PUSHER.to_string(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -11028,7 +10427,6 @@ mod tests {
|
||||
}],
|
||||
repo.path().to_path_buf(),
|
||||
F2A_PUSHER.to_string(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -517,7 +517,6 @@ mod tests {
|
||||
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
|
||||
create_ip_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)),
|
||||
push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
close_issue_rate_limiter: RateLimiter::new(120, Duration::from_secs(3600)),
|
||||
ipfs_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
ipfs_work_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
ipfs_max_history_walks: crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST,
|
||||
|
||||
@@ -252,9 +252,11 @@ pub struct Config {
|
||||
|
||||
/// Maximum connections in the PostgreSQL pool. This is a cap, not a floor
|
||||
/// (connections open lazily). Size against the database server's
|
||||
/// max_connections, remembering admin tooling opens its own pool. Writes pin
|
||||
/// the dedicated lock pool, not this one; this pool still needs enough headroom
|
||||
/// for ordinary request handlers and metadata paths (`DB_POOL_APP_HEADROOM`).
|
||||
/// max_connections, remembering admin tooling opens its own pool. Each
|
||||
/// concurrent write pins one pooled connection for its whole duration (the
|
||||
/// advisory lock in `repo_store::acquire_write` is connection-affine), so this
|
||||
/// must exceed `max_concurrent_git_pushes` by `DB_POOL_APP_HEADROOM` or slow
|
||||
/// pushes starve every other DB path — enforced by `Config::validate`.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_DB_MAX_CONNECTIONS",
|
||||
@@ -263,46 +265,6 @@ pub struct Config {
|
||||
)]
|
||||
pub db_max_connections: u32,
|
||||
|
||||
/// Maximum connections in the dedicated advisory-lock pool, which is separate
|
||||
/// from the main pool above.
|
||||
///
|
||||
/// Size this against the expected peak number of concurrent distinct-repo
|
||||
/// writers, NOT small. Every in-flight repo write pins one connection here for
|
||||
/// its whole duration (the write, its metadata tail, and the bounded archive
|
||||
/// upload), so this value is a hard ceiling on simultaneous writes node-wide.
|
||||
/// Keeping it separate from GITLAWB_DB_MAX_CONNECTIONS is what stops a push
|
||||
/// burst from starving ordinary request handlers; the cost is that
|
||||
/// (main pool + lock pool) must fit inside the database server's
|
||||
/// max_connections, times the number of nodes, plus admin tooling.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS",
|
||||
default_value_t = 40,
|
||||
value_parser = clap::value_parser!(u32).range(1..)
|
||||
)]
|
||||
pub db_lock_pool_max_connections: u32,
|
||||
|
||||
/// Upper bound, in seconds, on any single object-storage transfer that runs
|
||||
/// while the per-repo advisory lock is HELD.
|
||||
///
|
||||
/// Two bounded spans exist, and the bound applies per span. The acquire-side
|
||||
/// refresh in `acquire_write` covers the existence HEAD and the download
|
||||
/// together under ONE budget (it runs after the lock is taken and before the
|
||||
/// guard is constructed), and the archive upload in `release` gets its own.
|
||||
/// Worst-case slot occupancy is therefore about twice this value plus the git
|
||||
/// work between them, not one times this value. Both used to be free, because the lock's
|
||||
/// connection was returned to the pool immediately; now that a write guard
|
||||
/// pins a lock-pool connection for its whole lifetime, an unbounded transfer
|
||||
/// holds that slot, and enough stalled transfers deny every write on the node.
|
||||
/// This is the bound that keeps a stall from becoming an outage.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_LOCK_HELD_TRANSFER_TIMEOUT_SECS",
|
||||
default_value_t = 300,
|
||||
value_parser = clap::value_parser!(u64).range(1..)
|
||||
)]
|
||||
pub lock_held_transfer_timeout_secs: u64,
|
||||
|
||||
/// Maximum time a request waits for a pool connection before failing with
|
||||
/// 503, in seconds. Bounds queueing when the database is slow or down.
|
||||
#[arg(
|
||||
@@ -397,14 +359,15 @@ pub struct Config {
|
||||
/// error rather than a boot-time panic).
|
||||
///
|
||||
/// CONNECTION BUDGET. A push holds a Postgres connection from the node's separate
|
||||
/// advisory-lock pool (`GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS`, default 40) for
|
||||
/// the whole receive-pack. That pool must be at least this value plus
|
||||
/// `DB_LOCK_POOL_NON_PUSH_HEADROOM` so admitted pushes and non-push mutations
|
||||
/// can each pin a lock-pool connection for their whole duration. Size BOTH
|
||||
/// pools against the database server's `max_connections`: the main pool
|
||||
/// (`GITLAWB_DB_MAX_CONNECTIONS`, default 48) serves ordinary handlers, and the
|
||||
/// lock pool serves writes. Raising this knob does not raise the lock pool;
|
||||
/// set `GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS` explicitly.
|
||||
/// advisory-lock pool for the whole receive-pack, and that pool is sized from this
|
||||
/// knob (this value + 8, clamped to 64 in `main.rs`). The node's total ceiling is
|
||||
/// therefore `db_max_connections` (default 48) + the lock pool (default 40), i.e.
|
||||
/// 88 by default, and at most `db_max_connections` + 64. Size BOTH against the
|
||||
/// database server's `max_connections`: `db_max_connections`' own doc predates the
|
||||
/// lock pool and no longer covers most of the node's connections. The +8 headroom
|
||||
/// is shared with the three non-push `acquire_write` callers (`api/issues.rs` x2,
|
||||
/// `api/pulls.rs`). Raising this knob past the clamp does NOT buy more lock-pool
|
||||
/// connections; pushes beyond it wait briefly and then shed a 503 + Retry-After.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_MAX_CONCURRENT_GIT_PUSHES",
|
||||
@@ -762,37 +725,25 @@ impl Config {
|
||||
/// the pool must clear the concurrent-write cap by at least this margin.
|
||||
pub const DB_POOL_APP_HEADROOM: u32 = 8;
|
||||
|
||||
/// Lock-pool slots reserved for non-push `acquire_write` callers (issue create,
|
||||
/// close, merge) so a saturated push budget cannot turn ordinary mutations into
|
||||
/// lock-pool exhaustion on unrelated repositories.
|
||||
pub const DB_LOCK_POOL_NON_PUSH_HEADROOM: u32 = 8;
|
||||
|
||||
/// 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`.
|
||||
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.
|
||||
let lock_floor = self
|
||||
.max_concurrent_git_pushes
|
||||
.saturating_add(Self::DB_LOCK_POOL_NON_PUSH_HEADROOM as usize);
|
||||
if (self.db_lock_pool_max_connections as usize) < lock_floor {
|
||||
// A write pins one pooled connection for its whole duration (the
|
||||
// connection-affine advisory lock in repo_store::acquire_write), and
|
||||
// concurrent writes are capped at max_concurrent_git_pushes. If the pool
|
||||
// does not exceed that cap by DB_POOL_APP_HEADROOM, a burst of slow pushes
|
||||
// drains every connection and every other DB path 503s. (#174 F1)
|
||||
let floor = (self.max_concurrent_git_pushes as u64) + (Self::DB_POOL_APP_HEADROOM as u64);
|
||||
if (self.db_max_connections as u64) < floor {
|
||||
return Err(format!(
|
||||
"GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS ({}) must be at least \
|
||||
max_concurrent_git_pushes ({}) plus {} for non-push mutations \
|
||||
that share the lock pool",
|
||||
self.db_lock_pool_max_connections,
|
||||
"GITLAWB_DB_MAX_CONNECTIONS ({}) must be at least max_concurrent_git_pushes ({}) \
|
||||
+ {} headroom = {}: each concurrent write pins one pooled connection for its whole \
|
||||
duration, so a smaller pool lets a burst of slow pushes starve every other DB path.",
|
||||
self.db_max_connections,
|
||||
self.max_concurrent_git_pushes,
|
||||
Self::DB_LOCK_POOL_NON_PUSH_HEADROOM
|
||||
));
|
||||
}
|
||||
let main_floor = Self::DB_POOL_APP_HEADROOM as u64;
|
||||
if (self.db_max_connections as u64) < main_floor {
|
||||
return Err(format!(
|
||||
"GITLAWB_DB_MAX_CONNECTIONS ({}) must be at least {} for non-git \
|
||||
database paths",
|
||||
self.db_max_connections, main_floor
|
||||
Self::DB_POOL_APP_HEADROOM,
|
||||
floor
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
@@ -803,25 +754,6 @@ impl Config {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lock_pool_size_defaults_to_40_and_rejects_zero() {
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node"]).db_lock_pool_max_connections,
|
||||
40
|
||||
);
|
||||
assert_eq!(
|
||||
Config::parse_from(["gitlawb-node", "--db-lock-pool-max-connections", "8"])
|
||||
.db_lock_pool_max_connections,
|
||||
8
|
||||
);
|
||||
// A zero-sized lock pool would deny every write, so clap must reject it
|
||||
// rather than let a node boot into a state where no repo can be written.
|
||||
assert!(
|
||||
Config::try_parse_from(["gitlawb-node", "--db-lock-pool-max-connections", "0"])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_service_timeout_defaults_to_600_and_rejects_zero() {
|
||||
assert_eq!(
|
||||
@@ -1431,61 +1363,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #174 F1: concurrent git writes pin the dedicated lock pool, not the main
|
||||
/// pool. `validate()` must reject an under-sized lock pool at boot.
|
||||
/// #174 F1: a connection-affine write lock pins a pooled connection per
|
||||
/// concurrent write, so the pool must clear `max_concurrent_git_pushes` by
|
||||
/// `DB_POOL_APP_HEADROOM` or a push burst starves every other DB path.
|
||||
/// `validate()` must reject an under-sized pool at boot.
|
||||
#[test]
|
||||
fn db_pool_must_clear_the_git_push_cap() {
|
||||
// Shipped defaults validate (lock pool 40 >= pushes 32 + non-push headroom 8).
|
||||
// Shipped defaults validate (48 >= 32 + 8).
|
||||
Config::parse_from(["gitlawb-node"])
|
||||
.validate()
|
||||
.expect("default config must validate");
|
||||
|
||||
// An under-sized lock pool relative to the push cap is rejected.
|
||||
let under_lock = Config::parse_from([
|
||||
// An under-sized pool relative to the push cap is rejected (20 < 32 + 8).
|
||||
let under = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--db-lock-pool-max-connections",
|
||||
"16",
|
||||
"--db-max-connections",
|
||||
"20",
|
||||
"--max-concurrent-git-pushes",
|
||||
"32",
|
||||
]);
|
||||
assert!(
|
||||
under_lock.validate().is_err(),
|
||||
"db_lock_pool_max_connections below max_concurrent_git_pushes + headroom must be rejected"
|
||||
under.validate().is_err(),
|
||||
"db_max_connections 20 below max_concurrent_git_pushes 32 + headroom must be rejected"
|
||||
);
|
||||
|
||||
// Exactly at the push cap without non-push headroom is rejected.
|
||||
let push_only = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--db-lock-pool-max-connections",
|
||||
"32",
|
||||
"--max-concurrent-git-pushes",
|
||||
"32",
|
||||
]);
|
||||
assert!(
|
||||
push_only.validate().is_err(),
|
||||
"db_lock_pool_max_connections equal to max_concurrent_git_pushes must be rejected"
|
||||
);
|
||||
|
||||
// Main pool can be smaller than pushes + headroom when the lock pool carries writes.
|
||||
let split = Config::parse_from([
|
||||
// Exactly at the floor validates (40 == 32 + 8).
|
||||
let at_floor = Config::parse_from([
|
||||
"gitlawb-node",
|
||||
"--db-max-connections",
|
||||
"16",
|
||||
"--db-lock-pool-max-connections",
|
||||
"40",
|
||||
"--max-concurrent-git-pushes",
|
||||
"32",
|
||||
]);
|
||||
assert!(
|
||||
split.validate().is_ok(),
|
||||
"a small main pool with a large lock pool must validate"
|
||||
);
|
||||
|
||||
// Main pool still needs the app headroom floor.
|
||||
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"
|
||||
at_floor.validate().is_ok(),
|
||||
"db_max_connections at the floor (pushes + headroom) must validate"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -265,6 +265,11 @@ pub struct Db {
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Access the underlying Postgres connection pool.
|
||||
pub fn pool(&self) -> &PgPool {
|
||||
&self.pool
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn for_testing(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
@@ -305,46 +310,6 @@ impl Db {
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// Build the dedicated pool that advisory-lock connections come from.
|
||||
///
|
||||
/// Deliberately **lazy**: connections open on first use rather than at boot.
|
||||
/// The main pool has to connect eagerly because it runs migrations, which is
|
||||
/// why it needs `connect_db_with_retry`'s backoff and degraded-server
|
||||
/// handoff. This pool has no startup work at all, so an eager connect would
|
||||
/// only add a new way for the process to fail to boot, and would need a
|
||||
/// second copy of that retry machinery to be safe. Being lazy removes the
|
||||
/// failure mode instead of handling it: if Postgres is unreachable when the
|
||||
/// first write arrives, that write fails on the pool's own acquire timeout,
|
||||
/// the same way any other database-backed request already does.
|
||||
///
|
||||
/// Kept separate from the main pool so a burst of lock-holding connections
|
||||
/// cannot starve ordinary request handlers. See
|
||||
/// `GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS` for the sizing tradeoff.
|
||||
pub fn lock_pool(
|
||||
database_url: &str,
|
||||
max_connections: u32,
|
||||
acquire_timeout: Duration,
|
||||
) -> Result<PgPool> {
|
||||
info!(
|
||||
max_connections,
|
||||
acquire_timeout_secs = acquire_timeout.as_secs(),
|
||||
"creating dedicated advisory-lock pool (lazy)"
|
||||
);
|
||||
PgPoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
// Explicit, and load-bearing rather than cosmetic: `RepoWriteGuard::Drop`
|
||||
// has a no-runtime branch that calls `PoolConnection::leak()` and relies
|
||||
// on the husk's own drop doing nothing. With `min_connections > 0` that
|
||||
// drop still spawns a pool-replenish task, and spawning without a runtime
|
||||
// panics inside Drop, which aborts the process during unwind. It is 0 by
|
||||
// default, so this line exists to keep a future tuning change from
|
||||
// silently re-arming that panic.
|
||||
.min_connections(0)
|
||||
.acquire_timeout(acquire_timeout)
|
||||
.connect_lazy(database_url)
|
||||
.context("creating advisory-lock pool")
|
||||
}
|
||||
|
||||
/// Cheap liveness probe against the pool, for readiness checks: one
|
||||
/// `SELECT 1` that fails fast when the database is unreachable.
|
||||
pub async fn ping(&self) -> Result<()> {
|
||||
|
||||
@@ -69,15 +69,6 @@ pub enum AppError {
|
||||
#[error("server overloaded: {0}")]
|
||||
Overloaded(String),
|
||||
|
||||
#[error("repository is busy")]
|
||||
RepoBusy,
|
||||
|
||||
#[error("repository is temporarily unavailable")]
|
||||
RepoUnavailable,
|
||||
|
||||
#[error("repository write was fenced by a concurrent publish")]
|
||||
RepoWriteFenced,
|
||||
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
|
||||
@@ -119,31 +110,7 @@ impl From<anyhow::Error> for AppError {
|
||||
fn from(err: anyhow::Error) -> Self {
|
||||
match err.downcast::<sqlx::Error>() {
|
||||
Ok(sql) => AppError::Db(sql),
|
||||
// Lock contention is transient and ordinary, so it must not land as a
|
||||
// 500. The internal message names the owner slug and repo, so the
|
||||
// variant carries nothing: the detail stays in the log at the raise
|
||||
// site and the client gets a fixed retryable body.
|
||||
Err(err) => match err.downcast::<crate::git::repo_store::LockPoolBusy>() {
|
||||
Ok(_) => AppError::Overloaded("git write locks at capacity, retry shortly".into()),
|
||||
Err(err) => match err.downcast::<crate::git::repo_store::RepoBusy>() {
|
||||
Ok(_) => AppError::RepoBusy,
|
||||
// Same reasoning one rung down: a refused under-lock refresh is a
|
||||
// transient storage condition, and its internal message names the
|
||||
// owner slug and repo, so the variant carries nothing.
|
||||
Err(err) => match err.downcast::<crate::git::repo_store::RepoUnavailable>() {
|
||||
Ok(_) => AppError::RepoUnavailable,
|
||||
// And one more rung: a publish the store refused twice is
|
||||
// transient in the same way, and the retry is the client's
|
||||
// to make. The variant carries nothing for the same reason
|
||||
// as the two above.
|
||||
Err(err) => match err.downcast::<crate::git::repo_store::RepoWriteFenced>()
|
||||
{
|
||||
Ok(_) => AppError::RepoWriteFenced,
|
||||
Err(err) => AppError::Internal(err),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Err(err) => AppError::Internal(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,29 +184,6 @@ impl IntoResponse for AppError {
|
||||
// 504, distinct from the 500 git_error and from the read-gate's 404 /
|
||||
// the auth 401, so the client can tell a deadline from a failure.
|
||||
AppError::Timeout(msg) => (StatusCode::GATEWAY_TIMEOUT, "git_timeout", msg.clone()),
|
||||
// 503 with a FIXED body: the caller should retry, and must not be told
|
||||
// which repo is contended or for how long.
|
||||
AppError::RepoBusy => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"repo_busy",
|
||||
"repository is busy — retry".into(),
|
||||
),
|
||||
// 503 with a FIXED body for the same reason: the caller should retry, and
|
||||
// must not be told which repo could not be refreshed or why.
|
||||
AppError::RepoUnavailable => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"repo_unavailable",
|
||||
"repository is temporarily unavailable, retry".into(),
|
||||
),
|
||||
// 503 with a FIXED body again, and its own code: the caller should
|
||||
// retry, but the condition is not contention, so a client that
|
||||
// distinguishes them should be able to. The body must not say which
|
||||
// repo lost its publish or to whom.
|
||||
AppError::RepoWriteFenced => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"repo_write_fenced",
|
||||
"repository changed underneath this write, retry".into(),
|
||||
),
|
||||
AppError::Db(e) if db_unavailable(e) => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
DB_UNAVAILABLE_CODE,
|
||||
@@ -300,11 +244,7 @@ impl IntoResponse for AppError {
|
||||
// here rather than in bespoke early returns, keeping each variant handled once.
|
||||
if matches!(
|
||||
self,
|
||||
AppError::Overloaded(_)
|
||||
| AppError::SearchIncomplete { .. }
|
||||
| AppError::RepoBusy
|
||||
| AppError::RepoUnavailable
|
||||
| AppError::RepoWriteFenced
|
||||
AppError::Overloaded(_) | AppError::SearchIncomplete { .. }
|
||||
) {
|
||||
resp.headers_mut().insert(
|
||||
axum::http::header::RETRY_AFTER,
|
||||
@@ -422,31 +362,4 @@ mod tests {
|
||||
let resp = err.into_response();
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_pool_busy_via_anyhow_from_is_503_overloaded() {
|
||||
let err: AppError = anyhow::Error::new(crate::git::repo_store::LockPoolBusy).into();
|
||||
let resp = err.into_response();
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(
|
||||
resp.headers().get("retry-after").unwrap().to_str().unwrap(),
|
||||
"1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_busy_unavailable_and_fenced_advertise_retry_after() {
|
||||
for err in [
|
||||
AppError::RepoBusy,
|
||||
AppError::RepoUnavailable,
|
||||
AppError::RepoWriteFenced,
|
||||
] {
|
||||
let resp = err.into_response();
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(
|
||||
resp.headers().get("retry-after").unwrap().to_str().unwrap(),
|
||||
"1"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,27 +61,6 @@ pub fn create_issue(repo_path: &Path, issue_id: &str, json: &str) -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a single issue ref while the write guard still holds the advisory lock,
|
||||
/// after a definite publish refusal. Best-effort: a failed delete leaves a
|
||||
/// local-only orphan, which is still better than telling the client to retry into a
|
||||
/// duplicate id. Not used on `UploadUnknowable`, where the PUT may still land.
|
||||
pub fn delete_issue_ref(
|
||||
git_bin: &str,
|
||||
repo_path: &Path,
|
||||
issue_id: &str,
|
||||
deadline: std::time::Instant,
|
||||
) -> Result<()> {
|
||||
let ref_name = format!("refs/gitlawb/issues/{issue_id}");
|
||||
crate::git::visibility_pack::run_bounded_git(
|
||||
git_bin,
|
||||
&["update-ref", "-d", &ref_name],
|
||||
repo_path,
|
||||
b"",
|
||||
deadline,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all issue refs and return their JSON content.
|
||||
pub fn list_issues(repo_path: &Path) -> Result<Vec<String>> {
|
||||
// List all refs under refs/gitlawb/issues/
|
||||
@@ -299,27 +278,6 @@ mod tests {
|
||||
assert!(result.unwrap_err().to_string().contains("ambiguous"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_issue_ref_removes_a_created_ref() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
init_repo(&dir);
|
||||
let full_id = "eee88888-0000-0000-0000-000000000000";
|
||||
create_issue(
|
||||
dir.path(),
|
||||
full_id,
|
||||
r#"{"id":"eee88888-0000-0000-0000-000000000000","status":"open"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
delete_issue_ref(
|
||||
"git",
|
||||
dir.path(),
|
||||
full_id,
|
||||
std::time::Instant::now() + std::time::Duration::from_secs(30),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(resolve_issue_id(dir.path(), full_id).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_close_issue_via_prefix() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
+1005
-4298
File diff suppressed because it is too large.
Load diff
@@ -870,7 +870,6 @@ pub fn merge_branch(
|
||||
}
|
||||
|
||||
/// Resolve a repo disk path: {repos_dir}/{owner_slug}/{repo_name}.git
|
||||
#[allow(dead_code)] // exercised from test_support and state tests; production uses validated_repo_disk_path
|
||||
pub fn repo_disk_path(repos_dir: &Path, owner_did: &str, repo_name: &str) -> PathBuf {
|
||||
// Sanitize the DID for use as a directory name
|
||||
let owner_slug = owner_did.replace([':', '/'], "_");
|
||||
|
||||
@@ -5,41 +5,12 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use aws_sdk_s3::Client as S3Client;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// The precondition an upload is fenced on.
|
||||
///
|
||||
/// Object storage is the only place a fence can hold. Dropping the future of an
|
||||
/// in-flight PUT does not cancel the request the server is already processing,
|
||||
/// so no amount of local locking stops an abandoned writer's bytes from landing
|
||||
/// after a successor has published. A conditional PUT the store itself refuses
|
||||
/// is what actually stops it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum UploadPrecondition {
|
||||
/// Publish only if the stored object is still the generation we observed.
|
||||
IfMatch(String),
|
||||
/// Publish only if nothing is stored under the key yet.
|
||||
IfAbsent,
|
||||
/// No fence. Last writer wins.
|
||||
Unconditional,
|
||||
}
|
||||
|
||||
/// Why an upload failed, split so a caller can tell "someone else already
|
||||
/// published this key" (expected, and dropping our bytes is the correct
|
||||
/// outcome) from a real storage failure.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum UploadError {
|
||||
#[error("upload precondition lost (HTTP {status})")]
|
||||
PreconditionLost { status: u16 },
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
/// Wrapper around the S3 client with the configured bucket.
|
||||
#[derive(Clone)]
|
||||
pub struct TigrisClient {
|
||||
@@ -60,29 +31,21 @@ impl TigrisClient {
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a client pointed at an arbitrary endpoint, for tests.
|
||||
///
|
||||
/// The production constructor reads the endpoint and credentials from the
|
||||
/// environment, which a test cannot steer without mutating process-global
|
||||
/// state. This takes both explicitly so a test can aim the client at a
|
||||
/// closed port and get a prompt transport error out of `exists()`.
|
||||
///
|
||||
/// `RetryConfig::disabled()` is load-bearing, not tidiness: the SDK's default
|
||||
/// policy retries a connection refusal with backoff, which turns each failing
|
||||
/// call into seconds of waiting.
|
||||
/// Test-only constructor with an explicit S3 endpoint, region, and static
|
||||
/// credentials — no env-var reads, so parallel tests cannot race each other's
|
||||
/// `AWS_*` environment the way the env-based `new` would. Lets a test point
|
||||
/// the client at a non-routable endpoint to exercise acquire-stall paths.
|
||||
#[cfg(test)]
|
||||
pub fn for_testing_with_endpoint(bucket: &str, endpoint: &str) -> Self {
|
||||
use aws_sdk_s3::config::{retry::RetryConfig, Credentials, Region};
|
||||
|
||||
let config = aws_sdk_s3::config::Config::builder()
|
||||
.endpoint_url(endpoint)
|
||||
.credentials_provider(Credentials::new("test", "test", None, None, "test"))
|
||||
.region(Region::new("auto"))
|
||||
.retry_config(RetryConfig::disabled())
|
||||
.behavior_version_latest()
|
||||
.build();
|
||||
pub(crate) async fn for_testing_with_endpoint(bucket: &str, endpoint_url: &str) -> Self {
|
||||
let creds = aws_sdk_s3::config::Credentials::new("test", "test", None, None, "test");
|
||||
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
|
||||
.endpoint_url(endpoint_url)
|
||||
.region(aws_config::Region::new("auto"))
|
||||
.credentials_provider(creds)
|
||||
.load()
|
||||
.await;
|
||||
Self {
|
||||
s3: S3Client::from_conf(config),
|
||||
s3: S3Client::new(&config),
|
||||
bucket: bucket.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -114,47 +77,8 @@ impl TigrisClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the ETag of a repo archive, or `None` when nothing is stored under
|
||||
/// the key. The ETag identifies the generation a later conditional upload
|
||||
/// can fence itself on.
|
||||
///
|
||||
/// Separate from `exists` rather than folded into it: `exists` has callers
|
||||
/// that only want the boolean, and widening its return type would churn
|
||||
/// every one of them for no benefit.
|
||||
pub async fn head_etag(&self, owner_slug: &str, repo_name: &str) -> Result<Option<String>> {
|
||||
let key = Self::repo_key(owner_slug, repo_name);
|
||||
match self
|
||||
.s3
|
||||
.head_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(&key)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(out) => Ok(Some(
|
||||
out.e_tag()
|
||||
.context(format!("tigris HEAD {key}: hit carried no ETag"))?
|
||||
.to_string(),
|
||||
)),
|
||||
Err(e) => {
|
||||
if e.as_service_error().is_some_and(|e| e.is_not_found()) {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(anyhow::anyhow!("tigris HEAD {key}: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload a local bare repo directory to Tigris as a tar.zst archive,
|
||||
/// fenced by `precondition`.
|
||||
pub async fn upload(
|
||||
&self,
|
||||
owner_slug: &str,
|
||||
repo_name: &str,
|
||||
local_path: &Path,
|
||||
precondition: UploadPrecondition,
|
||||
) -> std::result::Result<(), UploadError> {
|
||||
/// Upload a local bare repo directory to Tigris as a tar.zst archive.
|
||||
pub async fn upload(&self, owner_slug: &str, repo_name: &str, local_path: &Path) -> Result<()> {
|
||||
let key = Self::repo_key(owner_slug, repo_name);
|
||||
debug!(key = %key, path = %local_path.display(), "uploading repo to tigris");
|
||||
|
||||
@@ -169,57 +93,15 @@ impl TigrisClient {
|
||||
|
||||
let body = aws_sdk_s3::primitives::ByteStream::from(archive_bytes);
|
||||
|
||||
let mut req = self
|
||||
.s3
|
||||
self.s3
|
||||
.put_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(&key)
|
||||
.body(body)
|
||||
.content_type("application/zstd");
|
||||
match &precondition {
|
||||
UploadPrecondition::IfMatch(etag) => req = req.if_match(etag),
|
||||
UploadPrecondition::IfAbsent => req = req.if_none_match("*"),
|
||||
UploadPrecondition::Unconditional => {}
|
||||
}
|
||||
|
||||
if let Err(e) = req.send().await {
|
||||
// `PutObjectError` models no PreconditionFailed variant (its arms are
|
||||
// EncryptionTypeMismatch, InvalidRequest, InvalidWriteOffset,
|
||||
// TooManyParts, Unhandled), so a refused precondition arrives as
|
||||
// `Unhandled` and matching the enum would classify it as a generic
|
||||
// failure. The raw HTTP status off the response is the only place the
|
||||
// answer actually lives.
|
||||
//
|
||||
// Read it via `raw_response()`, not a `ServiceError`-only match: the
|
||||
// SDK exposes the raw response for BOTH `ServiceError` and
|
||||
// `ResponseError`, and a refused conditional PUT whose error body the
|
||||
// SDK cannot parse (malformed XML, premature close) surfaces as
|
||||
// `ResponseError`. Matching only `ServiceError` would classify that
|
||||
// unparsable 409/412 as a generic failure, and `RepoWriteGuard::release`
|
||||
// would log-and-succeed instead of taking the supersede retry,
|
||||
// acknowledging a write that was definitively not published.
|
||||
let status = e.raw_response().map(|raw| raw.status().as_u16());
|
||||
// 412 is always a lost precondition. 409 is one only when we asked
|
||||
// for create-only, which is how S3-compatible stores report "the key
|
||||
// already exists". Everything else, 404 included, is a real failure:
|
||||
// archive keys are never deleted (`delete` has no callers), so a 404
|
||||
// here means something permanent like a missing bucket or a
|
||||
// misrouted endpoint, and reporting that as a lost precondition
|
||||
// would tell a caller to expect a successor that does not exist.
|
||||
let lost = match status {
|
||||
Some(412) => true,
|
||||
Some(409) => matches!(precondition, UploadPrecondition::IfAbsent),
|
||||
_ => false,
|
||||
};
|
||||
if lost {
|
||||
return Err(UploadError::PreconditionLost {
|
||||
status: status.expect("a lost precondition came from a status"),
|
||||
});
|
||||
}
|
||||
return Err(UploadError::Other(
|
||||
anyhow::Error::new(e).context(format!("tigris PUT {key}")),
|
||||
));
|
||||
}
|
||||
.content_type("application/zstd")
|
||||
.send()
|
||||
.await
|
||||
.context(format!("tigris PUT {key}"))?;
|
||||
|
||||
info!(key = %key, "uploaded repo to tigris");
|
||||
Ok(())
|
||||
@@ -230,32 +112,10 @@ impl TigrisClient {
|
||||
&self,
|
||||
owner_slug: &str,
|
||||
repo_name: &str,
|
||||
local_path: &super::repo_store::ValidatedRepoDiskPath,
|
||||
swap_authority: Option<Arc<AtomicBool>>,
|
||||
local_path: &Path,
|
||||
) -> Result<()> {
|
||||
self.download_to(owner_slug, repo_name, local_path, true, swap_authority)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Download a repo archive from Tigris and extract it, returning the
|
||||
/// directory that was populated.
|
||||
///
|
||||
/// `publish` controls whether the extract is swapped into `target` in place
|
||||
/// (the live-path mutation used by writes; returns `target`) or unpacked
|
||||
/// into a fresh temp directory under `target`'s parent (a non-mutating
|
||||
/// snapshot read; returns the temp dir, which the caller owns and cleans
|
||||
/// up). The snapshot form never touches the live repo path.
|
||||
pub async fn download_to(
|
||||
&self,
|
||||
owner_slug: &str,
|
||||
repo_name: &str,
|
||||
target: &super::repo_store::ValidatedRepoDiskPath,
|
||||
publish: bool,
|
||||
swap_authority: Option<Arc<AtomicBool>>,
|
||||
) -> Result<DownloadExtract> {
|
||||
let key = Self::repo_key(owner_slug, repo_name);
|
||||
debug!(key = %key, path = %target.as_path().display(), "downloading repo from tigris");
|
||||
debug!(key = %key, path = %local_path.display(), "downloading repo from tigris");
|
||||
|
||||
let resp = self
|
||||
.s3
|
||||
@@ -273,62 +133,21 @@ impl TigrisClient {
|
||||
.context("reading tigris response body")?
|
||||
.into_bytes();
|
||||
|
||||
// The snapshot temp dir is decided HERE, in the async layer, before the
|
||||
// extraction runs. Cleanup ownership moves into the blocking task so a
|
||||
// cancelled async future cannot drop it while extraction is still running.
|
||||
let snapshot_tmp = if publish {
|
||||
None
|
||||
} else {
|
||||
let parent = target.parent().context("snapshot path has no parent")?;
|
||||
std::fs::create_dir_all(parent).context("creating parent dir")?;
|
||||
let file_name = target
|
||||
.file_name()
|
||||
.context("snapshot path has no file name")?
|
||||
.to_string_lossy();
|
||||
Some(parent.join(format!(
|
||||
".{file_name}.tmp-snapshot.{}",
|
||||
uuid::Uuid::new_v4()
|
||||
)))
|
||||
};
|
||||
|
||||
// Extract tar.zst to a directory.
|
||||
let extracted = tokio::task::spawn_blocking({
|
||||
let target = target.clone();
|
||||
let snapshot_tmp = snapshot_tmp.clone();
|
||||
move || -> Result<DownloadExtract> {
|
||||
let result = (|| -> Result<DownloadExtract> {
|
||||
if publish {
|
||||
decompress_repo(&data, &target, swap_authority.as_ref())?;
|
||||
return Ok(DownloadExtract::Published(()));
|
||||
}
|
||||
// Non-mutating snapshot: unpack into the temp dir decided above.
|
||||
// The live repo path is never touched.
|
||||
let tmp_dir = snapshot_tmp.expect("snapshot path was decided above");
|
||||
std::fs::create_dir_all(&tmp_dir).context("creating temp extract dir")?;
|
||||
let unpack = (|| -> Result<()> {
|
||||
let decoder = zstd::stream::Decoder::new(&data[..])?;
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive.unpack(&tmp_dir).context("unpacking tar.zst")?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = unpack {
|
||||
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(DownloadExtract::Snapshot(TempSnapshotDir { path: tmp_dir }))
|
||||
})();
|
||||
result
|
||||
}
|
||||
// Extract tar.zst to local path
|
||||
tokio::task::spawn_blocking({
|
||||
let local_path = local_path.to_path_buf();
|
||||
move || decompress_repo(&data, &local_path)
|
||||
})
|
||||
.await
|
||||
.context("extract task panicked")?
|
||||
.context("extracting repo")?;
|
||||
|
||||
info!(key = %key, path = %target.as_path().display(), "downloaded repo from tigris");
|
||||
Ok(extracted)
|
||||
info!(key = %key, path = %local_path.display(), "downloaded repo from tigris");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a repo archive from Tigris.
|
||||
#[allow(dead_code)]
|
||||
pub async fn delete(&self, owner_slug: &str, repo_name: &str) -> Result<()> {
|
||||
let key = Self::repo_key(owner_slug, repo_name);
|
||||
self.s3
|
||||
@@ -361,7 +180,7 @@ fn compress_repo(repo_path: &Path) -> Result<Vec<u8>> {
|
||||
/// `decompress_repo`. Concurrent extractions unpack into isolated temp dirs in
|
||||
/// parallel, but the final `remove_dir_all` + `rename` must not interleave for
|
||||
/// the same `local_path`, or they race to a nondeterministic overwrite/failure.
|
||||
pub(crate) fn publish_lock(local_path: &Path) -> Arc<Mutex<()>> {
|
||||
fn publish_lock(local_path: &Path) -> Arc<Mutex<()>> {
|
||||
// KNOWN LIMITATION: this map is never evicted — one (PathBuf, Arc<Mutex>)
|
||||
// entry accrues per distinct repo path for the process lifetime. Bounded by
|
||||
// the number of repos a node hosts, so it's negligible for normal use, but
|
||||
@@ -374,35 +193,6 @@ pub(crate) fn publish_lock(local_path: &Path) -> Arc<Mutex<()>> {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Owns a snapshot temp dir from extraction through handoff to [`RepoSnapshot`].
|
||||
/// Dropped when the `spawn_blocking` join result is abandoned, so cancellation
|
||||
/// before the outer future resumes still removes the directory.
|
||||
pub(crate) struct TempSnapshotDir {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TempSnapshotDir {
|
||||
pub(crate) fn into_repo_snapshot(self) -> super::repo_store::RepoSnapshot {
|
||||
let path = self.path.clone();
|
||||
std::mem::forget(self);
|
||||
super::repo_store::RepoSnapshot::from_owned_path(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempSnapshotDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`TigrisClient::download_to`] produced.
|
||||
pub enum DownloadExtract {
|
||||
/// Published into the validated live repo path.
|
||||
Published(()),
|
||||
/// Unpacked into a throwaway temp dir that cleans up on drop until adopted.
|
||||
Snapshot(TempSnapshotDir),
|
||||
}
|
||||
|
||||
/// Decompress a tar.zst byte vector into a local directory.
|
||||
///
|
||||
/// Extraction is atomic with respect to `local_path`: the archive is unpacked
|
||||
@@ -410,16 +200,11 @@ pub enum DownloadExtract {
|
||||
/// fully succeeds. A corrupt or truncated archive therefore can never clobber a
|
||||
/// good existing copy at `local_path` — on failure we discard the temp dir and
|
||||
/// leave `local_path` exactly as it was.
|
||||
fn decompress_repo(
|
||||
data: &[u8],
|
||||
local_path: &super::repo_store::ValidatedRepoDiskPath,
|
||||
swap_authority: Option<&Arc<AtomicBool>>,
|
||||
) -> Result<()> {
|
||||
let live = local_path.as_path();
|
||||
let parent = live.parent().context("repo path has no parent")?;
|
||||
fn decompress_repo(data: &[u8], local_path: &Path) -> Result<()> {
|
||||
let parent = local_path.parent().context("repo path has no parent")?;
|
||||
std::fs::create_dir_all(parent).context("creating parent dir")?;
|
||||
|
||||
let file_name = live
|
||||
let file_name = local_path
|
||||
.file_name()
|
||||
.context("repo path has no file name")?
|
||||
.to_string_lossy();
|
||||
@@ -444,218 +229,17 @@ fn decompress_repo(
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Swap through the validated-path helper so CodeQL sees the barrier before the
|
||||
// remove/rename sink (`rust/path-injection`).
|
||||
super::repo_store::swap_extracted_into_validated_repo(local_path, &tmp_dir, swap_authority)?;
|
||||
// Swap the freshly-extracted repo into place. rename within the same parent
|
||||
// is effectively atomic, but most platforms refuse to rename onto a
|
||||
// non-empty dir, so remove the old copy first. Serialize this per repo path:
|
||||
// concurrent extractions unpack into isolated temp dirs, but their swaps
|
||||
// must not interleave or they race to a nondeterministic overwrite/failure.
|
||||
let lock = publish_lock(local_path);
|
||||
let _publish = lock.lock().expect("publish lock poisoned");
|
||||
if local_path.exists() {
|
||||
std::fs::remove_dir_all(local_path).context("removing stale repo dir")?;
|
||||
}
|
||||
std::fs::rename(&tmp_dir, local_path).context("swapping extracted repo into place")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use futures::FutureExt;
|
||||
|
||||
/// The envs the probe needs, all of them, or it does not run.
|
||||
///
|
||||
/// `AWS_ENDPOINT_URL_S3` is included on purpose: without it the SDK resolves
|
||||
/// to real AWS S3, and a probe that passed there would say nothing about
|
||||
/// Tigris.
|
||||
fn probe_env() -> Option<String> {
|
||||
if std::env::var("GITLAWB_TIGRIS_PROBE").ok().as_deref() != Some("1") {
|
||||
return None;
|
||||
}
|
||||
for name in [
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"AWS_ENDPOINT_URL_S3",
|
||||
] {
|
||||
if std::env::var(name).is_err() {
|
||||
eprintln!("tigris conditional-write probe: {name} is unset, skipping");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
match std::env::var("GITLAWB_TIGRIS_BUCKET") {
|
||||
Ok(b) if !b.is_empty() => Some(b),
|
||||
_ => {
|
||||
eprintln!(
|
||||
"tigris conditional-write probe: GITLAWB_TIGRIS_BUCKET is unset, skipping"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One conditional PUT, reported as the status that REFUSED it, or `None`
|
||||
/// when the store accepted the write.
|
||||
///
|
||||
/// Accepted is the interesting answer here, not an error: it means the
|
||||
/// endpoint ignored the header we fenced on.
|
||||
async fn conditional_put(
|
||||
s3: &S3Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
body: &'static [u8],
|
||||
if_match: Option<&str>,
|
||||
if_none_match: Option<&str>,
|
||||
) -> Result<Option<u16>, String> {
|
||||
let mut req = s3
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(body));
|
||||
if let Some(v) = if_match {
|
||||
req = req.if_match(v);
|
||||
}
|
||||
if let Some(v) = if_none_match {
|
||||
req = req.if_none_match(v);
|
||||
}
|
||||
match req.send().await {
|
||||
Ok(_) => Ok(None),
|
||||
Err(e) => match e.raw_response() {
|
||||
// Same raw-response rule as `upload`: a refused conditional PUT
|
||||
// whose body the SDK cannot parse surfaces as `ResponseError`, and
|
||||
// the status has to come off the raw response for both variants.
|
||||
// Without it, an unparsable 409/412 would report "no HTTP
|
||||
// response" here and skip the supersede retry.
|
||||
Some(raw) => Ok(Some(raw.status().as_u16())),
|
||||
None => Err(format!("conditional PUT {key}: no HTTP response: {e}")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The probe body, written to RETURN its failures rather than panic on
|
||||
/// them, so the caller's cleanup is reached on every arm.
|
||||
async fn conditional_write_probe(s3: &S3Client, bucket: &str, key: &str) -> Result<(), String> {
|
||||
// 1. A plain PUT under a throwaway key.
|
||||
let seeded = s3
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"probe-one"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("seeding PUT {key}: {e}"))?;
|
||||
|
||||
// 2. Its ETag, which is the generation the next arm fences against.
|
||||
let etag = seeded
|
||||
.e_tag()
|
||||
.ok_or_else(|| format!("seeding PUT {key} returned no ETag"))?
|
||||
.to_string();
|
||||
|
||||
// 3. A deliberately wrong If-Match. A store honoring it answers 412.
|
||||
let wrong = format!("\"{}\"", "0".repeat(32));
|
||||
if etag.trim_matches('"') == wrong.trim_matches('"') {
|
||||
return Err(format!(
|
||||
"the seeded ETag {etag} collides with the deliberately wrong one, \
|
||||
so this arm would prove nothing"
|
||||
));
|
||||
}
|
||||
match conditional_put(s3, bucket, key, b"probe-two", Some(&wrong), None).await? {
|
||||
Some(412) => {}
|
||||
Some(status) => {
|
||||
return Err(format!(
|
||||
"a stale If-Match must be refused with 412, the endpoint answered {status}"
|
||||
))
|
||||
}
|
||||
None => {
|
||||
return Err(
|
||||
"a stale If-Match was ACCEPTED: this endpoint does not honor If-Match, so \
|
||||
the release fence cannot hold here"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. If-None-Match `*` over the object that now exists. This arm matters
|
||||
// MORE than the one above. An ignored If-Match eventually surfaces as
|
||||
// odd behavior, because a stale writer overwrites and someone notices
|
||||
// the lost tree. An ignored If-None-Match just returns 200, so a publish
|
||||
// that should have been fenced lands with no error anywhere: the silent
|
||||
// no-op the bucket-type caveat on this test describes.
|
||||
match conditional_put(s3, bucket, key, b"probe-three", None, Some("*")).await? {
|
||||
// Either status is a pass, and the asymmetry with the If-Match arm
|
||||
// above mirrors `upload`'s classifier exactly: 412 is always a lost
|
||||
// precondition, and 409 is one too when we asked for create-only.
|
||||
// AWS documents 409 for a create-only conflict racing a delete, so a
|
||||
// store answering it is enforcing the precondition and we already
|
||||
// handle it. Pinning 412 alone here would fail the probe against a
|
||||
// backend that is behaving correctly, which sends whoever runs it
|
||||
// chasing a fault that is not there.
|
||||
Some(412) | Some(409) => {}
|
||||
Some(status) => {
|
||||
return Err(format!(
|
||||
"create-only over an existing object must be refused with 412 or 409, \
|
||||
the endpoint answered {status}"
|
||||
))
|
||||
}
|
||||
None => {
|
||||
return Err(
|
||||
"If-None-Match * was ACCEPTED over an existing object: this endpoint does \
|
||||
not honor create-only, so a fenced publish lands silently"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Probe the REAL Tigris endpoint for the conditional-write semantics the
|
||||
/// release fence depends on.
|
||||
///
|
||||
/// UNTIL THIS IS RUN AGAINST REAL CREDENTIALS, the fence is verified against
|
||||
/// vendor documentation and an in-process mock, not against the backend it
|
||||
/// runs on. The mock implements the semantics we believe Tigris has; it
|
||||
/// cannot tell us whether Tigris actually has them.
|
||||
///
|
||||
/// The bucket matters, not just the endpoint. Tigris documents conditional
|
||||
/// operations as supported on Single-region and Multi-region buckets only.
|
||||
/// Global and Dual-region buckets are eventually consistent, and a
|
||||
/// conditional PUT evaluated against a stale replica would make the fence a
|
||||
/// silent no-op rather than an error. So point `GITLAWB_TIGRIS_BUCKET` at a
|
||||
/// throwaway bucket of the SAME type production uses.
|
||||
///
|
||||
/// Ignored by default and additionally gated on `GITLAWB_TIGRIS_PROBE=1`,
|
||||
/// because it writes to a real bucket and costs real requests. Run with:
|
||||
/// `GITLAWB_TIGRIS_PROBE=1 cargo test -p gitlawb-node --bin gitlawb-node
|
||||
/// tigris_honors_conditional_writes -- --ignored --nocapture`
|
||||
#[tokio::test]
|
||||
#[ignore = "writes to a real Tigris bucket; needs GITLAWB_TIGRIS_PROBE=1 plus credentials"]
|
||||
async fn tigris_honors_conditional_writes() {
|
||||
let Some(bucket) = probe_env() else {
|
||||
eprintln!(
|
||||
"tigris conditional-write probe: skipped. Set GITLAWB_TIGRIS_PROBE=1, \
|
||||
GITLAWB_TIGRIS_BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and \
|
||||
AWS_ENDPOINT_URL_S3 to run it."
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
|
||||
let s3 = S3Client::new(&config);
|
||||
// A fresh key per run, so a probe that somehow orphaned an object on an
|
||||
// earlier run cannot change what this one observes.
|
||||
let key = format!("probe/conditional-write-{}.bin", uuid::Uuid::new_v4());
|
||||
|
||||
// CLEANUP MUST RUN ON EVERY ARM, and a failing assertion is precisely
|
||||
// the case this probe exists to catch, so the delete cannot sit after
|
||||
// the checks. The body returns its failures rather than panicking, and
|
||||
// `catch_unwind` covers the panic an SDK call could still raise; either
|
||||
// way the delete below is reached before the verdict is re-raised.
|
||||
let outcome = std::panic::AssertUnwindSafe(conditional_write_probe(&s3, &bucket, &key))
|
||||
.catch_unwind()
|
||||
.await;
|
||||
|
||||
if let Err(e) = s3.delete_object().bucket(&bucket).key(&key).send().await {
|
||||
eprintln!("tigris conditional-write probe: cleanup of {key} failed: {e}");
|
||||
}
|
||||
|
||||
match outcome {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(msg)) => panic!("tigris conditional-write probe: {msg}"),
|
||||
Err(payload) => std::panic::resume_unwind(payload),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -676,7 +676,7 @@ async fn warm_candidates(
|
||||
&repo.owner_did,
|
||||
&repo.name,
|
||||
) {
|
||||
Ok(p) if p.is_dir() => out.push((repo, created_at_key, p.into_path_buf())),
|
||||
Ok(p) if p.is_dir() => out.push((repo, created_at_key, p)),
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(repo_id = %repo.id, err = %e, "sweep discovery: rejected unsafe repo path");
|
||||
|
||||
+231
-54
@@ -59,6 +59,32 @@ struct DbStartupStatus {
|
||||
next_retry_secs: AtomicU64,
|
||||
}
|
||||
|
||||
/// Hard ceiling on the advisory-lock pool's `max_connections`.
|
||||
///
|
||||
/// `max_concurrent_git_pushes` is validated all the way up to 1_048_576, and the lock
|
||||
/// pool used to derive its size straight from that knob, so raising the push cap
|
||||
/// silently raised the node's Postgres connection ceiling with no CLI error and no
|
||||
/// relation to the server's own `max_connections` (#173 F4). The node's total budget is
|
||||
/// now bounded: `db_max_connections` (default 48) + at most this.
|
||||
const LOCK_POOL_MAX_CONNECTIONS: u32 = 64;
|
||||
|
||||
/// Connections the lock pool keeps above the push cap. Covers the three non-push
|
||||
/// `acquire_write` callers (`api/issues.rs` x2, `api/pulls.rs`), which hold no
|
||||
/// concurrency permit, so a push never queues here for a connection where it did not
|
||||
/// before.
|
||||
const LOCK_POOL_PUSH_HEADROOM: u8 = 8;
|
||||
|
||||
/// Size the advisory-lock pool for a given push cap: the cap plus
|
||||
/// [`LOCK_POOL_PUSH_HEADROOM`], clamped to [`LOCK_POOL_MAX_CONNECTIONS`]. Past the
|
||||
/// clamp a push may wait for a lock-pool connection, which is a bounded wait that sheds
|
||||
/// a clean 503 (see `LockPoolBusy`), not an unbounded hang.
|
||||
fn lock_pool_size(max_concurrent_git_pushes: usize) -> u32 {
|
||||
u32::try_from(max_concurrent_git_pushes)
|
||||
.unwrap_or(u32::MAX)
|
||||
.saturating_add(u32::from(LOCK_POOL_PUSH_HEADROOM))
|
||||
.min(LOCK_POOL_MAX_CONNECTIONS)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
@@ -90,6 +116,8 @@ async fn main() -> Result<()> {
|
||||
|
||||
// Load or generate the node's identity keypair
|
||||
let keypair = load_or_create_keypair(&config)?;
|
||||
// Sealing key for the legacy-scan continuation tokens, DERIVED from the identity
|
||||
// just loaded so it is the same key after a restart (see `derive_scan_token_key`).
|
||||
let scan_token_key = AppState::derive_scan_token_key(&keypair);
|
||||
let node_did = keypair.did();
|
||||
|
||||
@@ -288,20 +316,20 @@ async fn main() -> Result<()> {
|
||||
None
|
||||
};
|
||||
|
||||
// Advisory-lock connections come from their own pool: a write guard pins one
|
||||
// for its whole lifetime, and sharing the application pool would let a push
|
||||
// burst starve ordinary request handlers.
|
||||
let lock_pool = db::Db::lock_pool(
|
||||
&config.database_url,
|
||||
config.db_lock_pool_max_connections,
|
||||
// Repo write locks run on their own pool, never the main query pool: each push
|
||||
// holds its connection for the whole receive-pack, so a burst of concurrent
|
||||
// pushes drawing from the main pool would park that many connections for the
|
||||
// duration of their receive-packs and starve every other query. That holds
|
||||
// whatever the two pools are sized at, which is why the separation is
|
||||
// structural rather than a consequence of the defaults; config validate()
|
||||
// separately requires db_max_connections >= max_concurrent_git_pushes + 8. See
|
||||
// build_lock_pool for the cancellation semantics (#173).
|
||||
let lock_pool = git::repo_store::build_lock_pool(
|
||||
db.pool(),
|
||||
lock_pool_size(config.max_concurrent_git_pushes),
|
||||
std::time::Duration::from_secs(config.db_acquire_timeout_secs),
|
||||
)?;
|
||||
let repo_store = git::repo_store::RepoStore::new(
|
||||
config.repos_dir.clone(),
|
||||
tigris,
|
||||
lock_pool,
|
||||
std::time::Duration::from_secs(config.lock_held_transfer_timeout_secs),
|
||||
);
|
||||
let repo_store = git::repo_store::RepoStore::new(config.repos_dir.clone(), tigris, lock_pool);
|
||||
|
||||
// Per-DID limiter for the creation endpoints. Keyed on the authenticated
|
||||
// DID (attacker-varied), so bound its key set to cap memory.
|
||||
@@ -346,23 +374,6 @@ async fn main() -> Result<()> {
|
||||
tracing::warn!("GITLAWB_PUSH_RATE_LIMIT=0 — per-IP push rate limiting disabled");
|
||||
}
|
||||
|
||||
// close_issue drives a full archive download for non-owner author pre-checks.
|
||||
// Keep it on its own bucket so it cannot drain the receive-pack limit.
|
||||
let close_issue_limit = std::env::var("GITLAWB_CLOSE_ISSUE_RATE_LIMIT")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
.unwrap_or(120);
|
||||
let close_issue_rate_limiter = rate_limit::RateLimiter::new_bounded(
|
||||
close_issue_limit,
|
||||
std::time::Duration::from_secs(3600),
|
||||
200_000,
|
||||
);
|
||||
if close_issue_limit == 0 {
|
||||
tracing::warn!(
|
||||
"GITLAWB_CLOSE_ISSUE_RATE_LIMIT=0 — per-IP close_issue rate limiting disabled"
|
||||
);
|
||||
}
|
||||
|
||||
// Which forwarded header the edge is trusted to set. Default None (trust
|
||||
// nothing, key on the socket peer). Fly nodes set GITLAWB_TRUSTED_PROXY=fly;
|
||||
// a node behind Caddy/NGINX sets it to x-forwarded-for.
|
||||
@@ -412,10 +423,18 @@ async fn main() -> Result<()> {
|
||||
rate_limiter,
|
||||
create_ip_rate_limiter,
|
||||
push_rate_limiter,
|
||||
close_issue_rate_limiter,
|
||||
ipfs_max_history_walks: crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST,
|
||||
// The legacy-probe budget is operator-tunable via GITLAWB_IPFS_MAX_LEGACY_PROBES
|
||||
// (R5), which is what `ipfs_legacy_probe_budget` reads. NOT
|
||||
// GITLAWB_IPFS_MAX_REPOS_WALKED, which this comment used to name: that is the
|
||||
// separate cap on expensive visibility walks, so an operator following the old
|
||||
// text tuned the walk cap and left this fan-out unchanged. The history-walk
|
||||
// ceiling above stays constant (a smaller value false-503s a provenanced
|
||||
// request). Default 256 preserves the shipped behaviour.
|
||||
ipfs_max_legacy_probes: AppState::ipfs_legacy_probe_budget(&config),
|
||||
ipfs_legacy_scan_page_rows: crate::api::ipfs::LEGACY_SCAN_PAGE_ROWS,
|
||||
// Operator-tunable via GITLAWB_IPFS_MAX_LEGACY_SCAN_ROWS, read through the same
|
||||
// helper shape as the probe budget so the knob cannot be a silent no-op.
|
||||
ipfs_max_legacy_scan_rows: AppState::ipfs_legacy_scan_row_budget(&config),
|
||||
ipfs_max_legacy_scan_rule_bytes: crate::api::ipfs::MAX_LEGACY_SCAN_RULE_BYTES_PER_REQUEST,
|
||||
ipfs_scan_token_key: Arc::new(scan_token_key),
|
||||
@@ -494,6 +513,10 @@ async fn main() -> Result<()> {
|
||||
std::time::Duration::from_secs(3600),
|
||||
200_000,
|
||||
),
|
||||
// Separate WORK-budget bucket for the resolver's per-probe/per-walk charges (R6).
|
||||
// Its capacity is DERIVED from the route limit (no new knob) and floored at the
|
||||
// legacy-probe budget, so one full default-config legacy scan never self-throttles
|
||||
// mid-request while the route brake above stays the pure once-per-request cap.
|
||||
ipfs_work_rate_limiter: rate_limit::RateLimiter::new_bounded(
|
||||
AppState::ipfs_work_budget(&config),
|
||||
std::time::Duration::from_secs(3600),
|
||||
@@ -533,14 +556,16 @@ async fn main() -> Result<()> {
|
||||
|
||||
// Periodic cleanup of expired rate limit entries + consumed-proof ledger
|
||||
{
|
||||
let sweep_state = state.clone();
|
||||
let cleanup_state = state.clone();
|
||||
let db = state.db.clone();
|
||||
let mut shutdown_rx = state.subscribe_shutdown();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(300)) => {
|
||||
sweep_state.sweep_rate_limiters().await;
|
||||
// Sweep every per-IP/DID limiter (incl. the ipfs walk brake)
|
||||
// so bounded maps shed stale keys instead of sitting at cap.
|
||||
cleanup_state.sweep_rate_limiters().await;
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
@@ -683,6 +708,44 @@ async fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// U4 (#173): spawn the periodic legacy provider-CID repair sweep. Releases before this
|
||||
/// version stored the PROVIDER CID (Kubo dag-pb / Pinata CIDv0) in `pinned_cids.cid`,
|
||||
/// and this version's `/ipfs/{cid}` resolver withholds any row whose stored key is not
|
||||
/// the raw-content CID. The opportunistic repair on the pin path only fires when a push
|
||||
/// re-carries the object, which normal git negotiation makes it not do, so those rows
|
||||
/// need a walk. DETACHED, never on the boot path: the caller keeps serving while this
|
||||
/// runs, and the sweep's own batch bound plus inter-batch delay keep it off the DB's
|
||||
/// critical path. Its cursor is durable, so a restart mid-walk resumes instead of
|
||||
/// rewinding. It re-arms after every run, including a failed one, so it never returns
|
||||
/// and there is no awaited value to log here; the shutdown watcher below is what ends
|
||||
/// it.
|
||||
///
|
||||
/// A named function rather than an inline block in `main` so the WIRING has a seam a
|
||||
/// test can call: that the task is spawned at all, that it reads its batch and delay
|
||||
/// from the config knobs rather than some other field, that the caller is not blocked
|
||||
/// on it, and that the shutdown watcher actually ends it mid-walk. The sweep's own
|
||||
/// behavior is covered elsewhere; this is the boot-path half.
|
||||
fn spawn_legacy_cid_sweep(state: &AppState, config: &Config) -> tokio::task::JoinHandle<()> {
|
||||
let db = state.db.clone();
|
||||
let repos_dir = config.repos_dir.clone();
|
||||
let git_bin = state.git_bin.clone();
|
||||
let git_timeout = std::time::Duration::from_secs(config.git_service_timeout_secs);
|
||||
let batch = config.pin_repair_sweep_batch;
|
||||
let delay = std::time::Duration::from_secs(config.pin_repair_sweep_delay_secs);
|
||||
let mut shutdown_rx = state.subscribe_shutdown();
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = ipfs_pin::run_sweep_rearmed(
|
||||
&repos_dir, &git_bin, git_timeout, batch, delay,
|
||||
ipfs_pin::SWEEP_REARM_DELAY, &db,
|
||||
) => {}
|
||||
// Shutdown mid-walk simply drops the run; the persisted cursor means the
|
||||
// next boot picks up where this one stopped.
|
||||
_ = shutdown_rx.changed() => {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_shutdown_signal(tx: watch::Sender<bool>) {
|
||||
tokio::spawn(async move {
|
||||
#[cfg(unix)]
|
||||
@@ -1124,20 +1187,20 @@ mod rate_limiter_sweep_tests {
|
||||
state.rate_limiter = RateLimiter::new(10, window);
|
||||
state.create_ip_rate_limiter = RateLimiter::new(10, window);
|
||||
state.push_rate_limiter = RateLimiter::new(10, window);
|
||||
state.close_issue_rate_limiter = RateLimiter::new(10, window);
|
||||
state.sync_trigger_rate_limiter = RateLimiter::new(10, window);
|
||||
state.peer_write_rate_limiter = RateLimiter::new(10, window);
|
||||
state.ipfs_rate_limiter = RateLimiter::new(10, window);
|
||||
state.ipfs_work_rate_limiter = RateLimiter::new(10, window);
|
||||
|
||||
let limiters = |s: &crate::state::AppState| {
|
||||
[
|
||||
s.rate_limiter.clone(),
|
||||
s.create_ip_rate_limiter.clone(),
|
||||
s.push_rate_limiter.clone(),
|
||||
s.close_issue_rate_limiter.clone(),
|
||||
s.sync_trigger_rate_limiter.clone(),
|
||||
s.peer_write_rate_limiter.clone(),
|
||||
s.ipfs_rate_limiter.clone(),
|
||||
s.ipfs_work_rate_limiter.clone(),
|
||||
]
|
||||
};
|
||||
for l in limiters(&state) {
|
||||
@@ -1154,26 +1217,6 @@ mod rate_limiter_sweep_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// it.
|
||||
fn spawn_legacy_cid_sweep(state: &AppState, config: &Config) -> tokio::task::JoinHandle<()> {
|
||||
let db = state.db.clone();
|
||||
let repos_dir = config.repos_dir.clone();
|
||||
let git_bin = state.git_bin.clone();
|
||||
let git_timeout = std::time::Duration::from_secs(config.git_service_timeout_secs);
|
||||
let batch = config.pin_repair_sweep_batch;
|
||||
let delay = std::time::Duration::from_secs(config.pin_repair_sweep_delay_secs);
|
||||
let mut shutdown_rx = state.subscribe_shutdown();
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = ipfs_pin::run_sweep_rearmed(
|
||||
&repos_dir, &git_bin, git_timeout, batch, delay,
|
||||
ipfs_pin::SWEEP_REARM_DELAY, &db,
|
||||
) => {}
|
||||
_ = shutdown_rx.changed() => {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn gossip_ping_round(
|
||||
db: &Db,
|
||||
client: &reqwest::Client,
|
||||
@@ -1350,6 +1393,140 @@ fn load_or_create_keypair(config: &Config) -> Result<Keypair> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod legacy_cid_sweep_wiring_tests {
|
||||
use super::spawn_legacy_cid_sweep;
|
||||
use sqlx::PgPool;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Seed `count` `pinned_cids` rows whose keys are already canonical raw CIDv1, in a
|
||||
/// known `sha256_hex` order. The sweep's own cost gate skips a raw-CIDv1 row without
|
||||
/// reading bytes or resolving a repo, so each row is SCANNED (it advances the cursor)
|
||||
/// and nothing else. That is what makes the cursor a clean readout of how far the
|
||||
/// walk got, with no dependency on repos on disk.
|
||||
async fn seed_scannable_rows(pool: &PgPool, count: usize) -> Vec<String> {
|
||||
let mut shas = Vec::new();
|
||||
for i in 1..=count {
|
||||
let sha = format!("wire{i:02}");
|
||||
let cid = gitlawb_core::cid::Cid::from_git_object_bytes(sha.as_bytes()).to_string();
|
||||
assert!(
|
||||
gitlawb_core::cid::is_raw_cidv1(&cid),
|
||||
"the seeded key must hit the sweep's raw-CIDv1 skip, not a repair attempt"
|
||||
);
|
||||
sqlx::query("INSERT INTO pinned_cids (sha256_hex, cid, pinned_at) VALUES ($1, $2, $3)")
|
||||
.bind(&sha)
|
||||
.bind(&cid)
|
||||
.bind("2020-01-01T00:00:00Z")
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
shas.push(sha);
|
||||
}
|
||||
shas
|
||||
}
|
||||
|
||||
/// Poll the persisted sweep cursor until it reaches `want`, or give up.
|
||||
async fn cursor_reaches(db: &crate::db::Db, want: &str, within: Duration) -> String {
|
||||
let deadline = std::time::Instant::now() + within;
|
||||
loop {
|
||||
let c = db.pin_repair_cursor().await.unwrap();
|
||||
if c == want || std::time::Instant::now() >= deadline {
|
||||
return c;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// #173 U4, the BOOT-PATH half. The sweep's own logic (batching, cursor resumption,
|
||||
/// terminal vs retryable skips) is covered in `test_support`; what this covers is the
|
||||
/// wiring `main` performs, which nothing else executes: the task is spawned at all,
|
||||
/// it takes its batch and delay from the two `pin_repair_sweep_*` knobs rather than
|
||||
/// some other config field, the caller is not blocked on the walk, and the shutdown
|
||||
/// watcher ends the run mid-walk.
|
||||
///
|
||||
/// Six scannable rows, batch 2, delay 30s. One pass must land the cursor on exactly
|
||||
/// the second row and the task must then still be alive in its inter-batch sleep,
|
||||
/// which pins both knobs at once: a different batch stops at a different row, and a
|
||||
/// delay that did not come from the knob either finishes the table or leaves the task
|
||||
/// gone. Shutdown must then end it while four rows are still unwalked.
|
||||
#[sqlx::test]
|
||||
async fn the_boot_path_spawns_the_sweep_detached_with_its_configured_knobs(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
let shas = seed_scannable_rows(&pool, 6).await;
|
||||
let repos_dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let mut config = (*state.config).clone();
|
||||
config.repos_dir = repos_dir.path().to_path_buf();
|
||||
config.pin_repair_sweep_batch = 2;
|
||||
// Far longer than this test runs, so a task still alive after the first pass can
|
||||
// only be one that is honoring the configured inter-batch delay.
|
||||
config.pin_repair_sweep_delay_secs = 30;
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let handle = spawn_legacy_cid_sweep(&state, &config);
|
||||
let spawn_cost = started.elapsed();
|
||||
|
||||
let cursor = cursor_reaches(&state.db, &shas[1], Duration::from_secs(10)).await;
|
||||
assert_eq!(
|
||||
cursor, shas[1],
|
||||
"the spawned sweep must run and stop its first pass at the CONFIGURED batch \
|
||||
bound (2), leaving the cursor on the second row"
|
||||
);
|
||||
assert!(
|
||||
spawn_cost < Duration::from_secs(1),
|
||||
"the sweep must be detached, not awaited on the boot path; the spawn took \
|
||||
{spawn_cost:?}"
|
||||
);
|
||||
assert!(
|
||||
!handle.is_finished(),
|
||||
"with a 30s inter-batch delay the task must still be sleeping between passes, \
|
||||
not finished: a finished task means the delay was not the configured one"
|
||||
);
|
||||
|
||||
state.shutdown();
|
||||
tokio::time::timeout(Duration::from_secs(10), handle)
|
||||
.await
|
||||
.expect("the shutdown watcher must end the sweep, and not after its 30s delay")
|
||||
.expect("the sweep task must not panic");
|
||||
|
||||
assert_eq!(
|
||||
state.db.pin_repair_cursor().await.unwrap(),
|
||||
shas[1],
|
||||
"shutdown must have ended the run MID-walk, with the remaining rows unwalked"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod lock_pool_sizing_tests {
|
||||
use super::{lock_pool_size, LOCK_POOL_MAX_CONNECTIONS, LOCK_POOL_PUSH_HEADROOM};
|
||||
|
||||
/// The default push cap gets its cap plus headroom, so no push ever queues for a
|
||||
/// lock-pool connection where it did not before.
|
||||
#[test]
|
||||
fn default_push_cap_gets_headroom_over_the_cap() {
|
||||
assert_eq!(lock_pool_size(32), 32 + u32::from(LOCK_POOL_PUSH_HEADROOM));
|
||||
assert_eq!(lock_pool_size(1), 1 + u32::from(LOCK_POOL_PUSH_HEADROOM));
|
||||
}
|
||||
|
||||
/// #173 F4: `max_concurrent_git_pushes` is validated all the way to 1_048_576, so an
|
||||
/// operator raising it used to raise the node's Postgres connection ceiling with it,
|
||||
/// silently and without bound. The lock pool is CLAMPED instead.
|
||||
#[test]
|
||||
fn an_oversized_push_cap_is_clamped_not_propagated() {
|
||||
assert_eq!(lock_pool_size(1_048_576), LOCK_POOL_MAX_CONNECTIONS);
|
||||
assert_eq!(lock_pool_size(usize::MAX), LOCK_POOL_MAX_CONNECTIONS);
|
||||
// The largest cap that still fits under the clamp keeps its full headroom.
|
||||
let widest = (LOCK_POOL_MAX_CONNECTIONS - u32::from(LOCK_POOL_PUSH_HEADROOM)) as usize;
|
||||
assert_eq!(lock_pool_size(widest), LOCK_POOL_MAX_CONNECTIONS);
|
||||
assert_eq!(
|
||||
lock_pool_size(widest - 1),
|
||||
LOCK_POOL_MAX_CONNECTIONS - 1,
|
||||
"values below the clamp must not be rounded up to it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gossip_ssrf_tests {
|
||||
use super::{
|
||||
|
||||
@@ -79,10 +79,6 @@ pub struct AppState {
|
||||
/// brake a push flood from a DID farm (one throwaway DID per repo), so the
|
||||
/// push path throttles on the resolved client IP instead.
|
||||
pub push_rate_limiter: RateLimiter,
|
||||
/// Per-client-IP rate limiter for `close_issue`'s pre-lock snapshot path.
|
||||
/// Distinct from `push_rate_limiter` so a flood of close attempts cannot
|
||||
/// drain the receive-pack budget for the same source IP.
|
||||
pub close_issue_rate_limiter: RateLimiter,
|
||||
/// Per-client-IP ROUTE brake for `GET /ipfs/{cid}`: charged ONCE per request by the
|
||||
/// `rate_limit_by_ip` middleware (server.rs), never inside the handler. It bounds
|
||||
/// request RATE (the "requests per hour" contract of `GITLAWB_IPFS_RATE_LIMIT`) on
|
||||
@@ -346,7 +342,6 @@ impl AppState {
|
||||
self.rate_limiter.cleanup().await;
|
||||
self.create_ip_rate_limiter.cleanup().await;
|
||||
self.push_rate_limiter.cleanup().await;
|
||||
self.close_issue_rate_limiter.cleanup().await;
|
||||
self.ipfs_rate_limiter.cleanup().await;
|
||||
self.ipfs_work_rate_limiter.cleanup().await;
|
||||
self.sync_trigger_rate_limiter.cleanup().await;
|
||||
|
||||
@@ -103,7 +103,6 @@ fn build_state(db: Arc<crate::db::Db>, pool: PgPool) -> AppState {
|
||||
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
|
||||
create_ip_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)),
|
||||
push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
close_issue_rate_limiter: RateLimiter::new(120, Duration::from_secs(3600)),
|
||||
ipfs_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
ipfs_work_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
|
||||
ipfs_max_history_walks: crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST,
|
||||
@@ -558,35 +557,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Fork disk paths must go through `validated_repo_disk_path` so a user-supplied
|
||||
/// name cannot reach `remove_dir_all` on an escaped path (CodeQL path-injection).
|
||||
#[sqlx::test]
|
||||
async fn fork_rejects_name_that_fails_validated_disk_path(pool: PgPool) {
|
||||
let owner = "did:key:zFORKOWNERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let state = test_state(pool).await;
|
||||
let repo = seed_repo(owner, "fork-src");
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
|
||||
let router = Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/fork",
|
||||
axum::routing::post(crate::api::repos::fork_repo),
|
||||
)
|
||||
.with_state(state.clone());
|
||||
let too_long = "a".repeat(101);
|
||||
let uri = format!("/api/v1/repos/{owner}/fork-src/fork");
|
||||
let body = Body::from(format!(r#"{{"name":"{too_long}"}}"#));
|
||||
let resp = router
|
||||
.oneshot(signed_request_as(owner, Method::POST, &uri, body))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"fork names that fail validated_repo_disk_path must be rejected before clone"
|
||||
);
|
||||
}
|
||||
|
||||
/// N13: the task handlers bind the acting DID to the signer. A caller signed
|
||||
/// as B claiming delegator_did A is rejected before any DB write (DB-free).
|
||||
#[sqlx::test]
|
||||
@@ -15966,6 +15936,65 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::api::repos::drain_faults;
|
||||
|
||||
/// Process-wide tracing capture so a test can assert the give-up is logged at
|
||||
/// ERROR. A global default subscriber can only be installed once per process,
|
||||
/// so it is shared by every test here and assertions filter on the repo id,
|
||||
/// which is a fresh uuid per test.
|
||||
mod logcap {
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use tracing::{Event, Level, Subscriber};
|
||||
use tracing_subscriber::layer::{Context, Layer};
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
type Lines = Arc<Mutex<Vec<(Level, String)>>>;
|
||||
|
||||
fn lines() -> &'static Lines {
|
||||
static LINES: OnceLock<Lines> = OnceLock::new();
|
||||
LINES.get_or_init(|| Arc::new(Mutex::new(Vec::new())))
|
||||
}
|
||||
|
||||
struct Capture;
|
||||
impl<S: Subscriber> Layer<S> for Capture {
|
||||
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
||||
struct V(String);
|
||||
impl tracing::field::Visit for V {
|
||||
fn record_debug(
|
||||
&mut self,
|
||||
field: &tracing::field::Field,
|
||||
value: &dyn std::fmt::Debug,
|
||||
) {
|
||||
self.0.push_str(&format!(" {}={:?}", field.name(), value));
|
||||
}
|
||||
}
|
||||
let mut v = V(String::new());
|
||||
event.record(&mut v);
|
||||
lines()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((*event.metadata().level(), v.0));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn install() {
|
||||
static ONCE: OnceLock<()> = OnceLock::new();
|
||||
ONCE.get_or_init(|| {
|
||||
let _ = tracing::subscriber::set_global_default(
|
||||
tracing_subscriber::registry().with(Capture),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn errors_containing(needle: &str) -> Vec<String> {
|
||||
lines()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(lvl, msg)| *lvl == Level::ERROR && msg.contains(needle))
|
||||
.map(|(_, msg)| msg.clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// SCENARIO 1. The repo re-read fails once, then succeeds: the drain lap
|
||||
/// must still RUN, under the refreshed state, and pin the coalesced push's
|
||||
/// object. RED before the fix (the single `Err` returned `None`, the lap
|
||||
@@ -16031,11 +16060,11 @@ mod tests {
|
||||
}
|
||||
|
||||
/// SCENARIO 2. Every re-read attempt fails: the loop must give up on a BOUND
|
||||
/// (asserted as a literal, so raising or removing the bound goes RED) and hit
|
||||
/// the give-up path that logs at ERROR in production (asserted here via the
|
||||
/// drain_faults seam, which fires on the same branch as that log).
|
||||
/// (asserted as a literal, so raising or removing the bound goes RED) and log
|
||||
/// the give-up at ERROR so the residual loss is observable rather than silent.
|
||||
#[sqlx::test]
|
||||
async fn u2_sustained_repo_reread_failure_is_bounded_and_logged(pool: PgPool) {
|
||||
logcap::install();
|
||||
let state = test_state(pool).await;
|
||||
let owner = new_did();
|
||||
let repo = seed_repo(&owner, "u2-bounded");
|
||||
@@ -16086,10 +16115,11 @@ mod tests {
|
||||
!state.db.is_pinned(&obj2).await.unwrap(),
|
||||
"with the read never succeeding there is nothing fresh to act on"
|
||||
);
|
||||
let errs = logcap::errors_containing(&repo.id);
|
||||
assert!(
|
||||
drain_faults::reread_exhausted(&repo.id),
|
||||
"the exhausted drain re-read must hit the give-up path that logs at \
|
||||
ERROR in production"
|
||||
!errs.is_empty(),
|
||||
"the exhausted drain re-read is logged at ERROR with the repo id, so \
|
||||
the residual work loss is observable; captured: {errs:?}"
|
||||
);
|
||||
assert!(
|
||||
state.encrypt_inflight.is_empty(),
|
||||
|
||||
@@ -225,8 +225,7 @@ fn f4_release_keeps_conn_owned_until_unlock_resolves() {
|
||||
let repo_store = src("git/repo_store.rs");
|
||||
|
||||
let rel_start = repo_store
|
||||
.find("pub async fn release(self")
|
||||
.or_else(|| repo_store.find("pub async fn release(mut self"))
|
||||
.find("pub async fn release(mut self")
|
||||
.expect("F4 gate: repo_store.rs no longer defines RepoWriteGuard::release");
|
||||
let rel_end = repo_store[rel_start..]
|
||||
.find("impl Drop for RepoWriteGuard")
|
||||
|
||||
+16
-25
@@ -2,7 +2,6 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Args, Subcommand};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
@@ -57,24 +56,16 @@ async fn cmd_list(node: String, capability: Option<String>) -> Result<()> {
|
||||
.get(&path)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let status = resp.status();
|
||||
|
||||
if status == reqwest::StatusCode::NOT_FOUND {
|
||||
// Keep the bespoke 404 hint (older node without the agents API) ahead of
|
||||
// read_json; every other status routes through it (capped + sanitized error).
|
||||
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
anyhow::bail!(
|
||||
"this node does not yet support the agents API (v0.3+)\n\
|
||||
upgrade the node or check GITLAWB_NODE is pointing to the right server"
|
||||
);
|
||||
}
|
||||
|
||||
let body: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("invalid JSON from node — is GITLAWB_NODE correct?")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("list agents failed ({status}): {msg}");
|
||||
}
|
||||
let body = crate::http::read_json(resp, "list agents").await?;
|
||||
|
||||
let agents = body["agents"].as_array().cloned().unwrap_or_default();
|
||||
|
||||
@@ -117,24 +108,15 @@ async fn cmd_show(did: String, node: String) -> Result<()> {
|
||||
.get(&format!("/api/v1/agents/{did}"))
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let status = resp.status();
|
||||
|
||||
if status == reqwest::StatusCode::NOT_FOUND {
|
||||
// Keep the bespoke 404 hint ahead of read_json; other statuses route through it.
|
||||
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
anyhow::bail!(
|
||||
"agent not found, or this node does not yet support the agents API (v0.3+)\n\
|
||||
upgrade the node or check GITLAWB_NODE is pointing to the right server"
|
||||
);
|
||||
}
|
||||
|
||||
let agent: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("invalid JSON from node — is GITLAWB_NODE correct?")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = agent["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("agent not found ({status}): {msg}");
|
||||
}
|
||||
let agent = crate::http::read_json(resp, "agent").await?;
|
||||
|
||||
let full_did = agent["did"].as_str().unwrap_or("?");
|
||||
let trust = agent["trust_score"].as_f64().unwrap_or(0.0);
|
||||
@@ -215,11 +197,14 @@ mod tests {
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_list(server.url(), None).await.unwrap_err();
|
||||
assert!(err.to_string().contains("agents API"));
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -230,11 +215,14 @@ mod tests {
|
||||
.with_status(500)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"internal error"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_list(server.url(), None).await.unwrap_err();
|
||||
assert!(err.to_string().contains("list agents failed"));
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -261,6 +249,7 @@ mod tests {
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"agent not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -268,6 +257,8 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("agents API") || err.to_string().contains("not found"));
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+104
-50
@@ -2,7 +2,6 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Args, Subcommand};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
@@ -189,13 +188,7 @@ async fn cmd_create(
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("create bounty failed ({status}): {msg}");
|
||||
}
|
||||
let body = crate::http::read_json(resp, "create bounty").await?;
|
||||
|
||||
let id = body["id"].as_str().unwrap_or("?");
|
||||
println!("Bounty created: {id}");
|
||||
@@ -231,11 +224,14 @@ async fn cmd_list(
|
||||
u
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.get_authed(&url)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
let body = crate::http::read_json(
|
||||
client
|
||||
.get_authed(&url)
|
||||
.await
|
||||
.context("failed to connect to node")?,
|
||||
"bounties",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let bounties = body["bounties"].as_array();
|
||||
if let Some(arr) = bounties {
|
||||
@@ -259,13 +255,7 @@ async fn cmd_show(id: String, node: String, dir: Option<PathBuf>) -> Result<()>
|
||||
let client = signed_client(&node, dir.as_deref());
|
||||
let resp = client.get_authed(&format!("/api/v1/bounties/{id}")).await?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("not found");
|
||||
anyhow::bail!("bounty not found: {msg}");
|
||||
}
|
||||
let body = crate::http::read_json(resp, "show bounty").await?;
|
||||
|
||||
println!("{}", serde_json::to_string_pretty(&body)?);
|
||||
Ok(())
|
||||
@@ -289,13 +279,7 @@ async fn cmd_claim(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("claim failed ({status}): {msg}");
|
||||
}
|
||||
let _ = crate::http::read_json(resp, "claim").await?;
|
||||
|
||||
println!("Bounty {id} claimed. Deadline starts now.");
|
||||
Ok(())
|
||||
@@ -314,13 +298,7 @@ async fn cmd_submit(id: String, pr: String, node: String, dir: Option<PathBuf>)
|
||||
)
|
||||
.await?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("submit failed ({status}): {msg}");
|
||||
}
|
||||
let _ = crate::http::read_json(resp, "submit").await?;
|
||||
|
||||
println!("Bounty {id} submitted with PR {pr}. Awaiting creator approval.");
|
||||
Ok(())
|
||||
@@ -344,13 +322,7 @@ async fn cmd_approve(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("approve failed ({status}): {msg}");
|
||||
}
|
||||
let _ = crate::http::read_json(resp, "approve").await?;
|
||||
|
||||
println!("Bounty {id} approved! Payout released to agent.");
|
||||
Ok(())
|
||||
@@ -369,13 +341,7 @@ async fn cmd_cancel(id: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
)
|
||||
.await?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("cancel failed ({status}): {msg}");
|
||||
}
|
||||
let _ = crate::http::read_json(resp, "cancel").await?;
|
||||
|
||||
println!("Bounty {id} cancelled. Tokens refunded.");
|
||||
Ok(())
|
||||
@@ -383,8 +349,11 @@ async fn cmd_cancel(id: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
|
||||
async fn cmd_stats(node: String) -> Result<()> {
|
||||
let client = NodeClient::new(&node, None);
|
||||
let resp = client.get("/api/v1/bounties/stats").await?;
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
// Route through read_json so a node denial (403/404/5xx, incl. a non-JSON
|
||||
// body) surfaces as an error instead of a fake `Bounty Stats` with every
|
||||
// counter zeroed (#186, the denial-as-success class #123 fixes elsewhere).
|
||||
let body =
|
||||
crate::http::read_json(client.get("/api/v1/bounties/stats").await?, "bounty stats").await?;
|
||||
|
||||
let open = body["open"].as_i64().unwrap_or(0);
|
||||
let claimed = body["claimed"].as_i64().unwrap_or(0);
|
||||
@@ -521,6 +490,7 @@ mod tests {
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -528,6 +498,8 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("not found"));
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -544,4 +516,86 @@ mod tests {
|
||||
|
||||
cmd_stats(server.url()).await.unwrap();
|
||||
}
|
||||
|
||||
/// #186 (F1): a node denial on the stats endpoint must surface as an error,
|
||||
/// not print a fake `Bounty Stats` with every counter zeroed. RED before the
|
||||
/// fix (unwrap_or_default + no status check -> Ok with zeros), GREEN after
|
||||
/// (read_json bails on the non-2xx).
|
||||
#[tokio::test]
|
||||
async fn cmd_stats_surfaces_denial_not_fake_result() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let m = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/stats$".to_string()))
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"forbidden"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_stats(server.url())
|
||||
.await
|
||||
.expect_err("a 403 must be an error, not a zeroed fake result");
|
||||
assert!(err.to_string().contains("403"), "err={err}");
|
||||
// Pin the mock hit explicitly. The status assertion above already fails
|
||||
// when the route does not match (mockito answers an unmatched route with
|
||||
// 501, so "403" is absent), but that proof is incidental to mockito's
|
||||
// fallback status; assert the hit directly so it survives a reworded
|
||||
// assertion.
|
||||
m.assert_async().await;
|
||||
}
|
||||
|
||||
/// #186 (F1): the same denial-as-error behavior across the non-2xx response
|
||||
/// classes, not just 403 — a 404 and a 500 (incl. a non-JSON body) must each
|
||||
/// surface as an error with the status, never a zeroed fake result.
|
||||
#[tokio::test]
|
||||
async fn cmd_stats_surfaces_denial_across_status_classes() {
|
||||
for (status, body, json) in [
|
||||
(404, r#"{"message":"not found"}"#, true),
|
||||
(500, "internal boom", false),
|
||||
] {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mut m = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/stats$".to_string()))
|
||||
.with_status(status)
|
||||
.with_body(body);
|
||||
if json {
|
||||
m = m.with_header("content-type", "application/json");
|
||||
}
|
||||
let m = m.expect(1).create_async().await;
|
||||
|
||||
let err = cmd_stats(server.url()).await.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains(&status.to_string()),
|
||||
"status {status} must surface as an error, got: {err}"
|
||||
);
|
||||
m.assert_async().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_list_repo_scoped_surfaces_denial_not_empty() {
|
||||
// `gl bounty list --repo owner/name` hits the gated /repos/{o}/{n}/bounties;
|
||||
// a 404 must Err, not silently print nothing.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/repos/alice/secret/bounties".to_string()),
|
||||
)
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repository 'alice/secret' not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let result = cmd_list(Some("alice/secret".to_string()), None, server.url(), None).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"bounty list --repo must Err on a gated 404"
|
||||
);
|
||||
// Prove the gated repo-scoped path was actually requested: without this,
|
||||
// an unmatched route (mockito's 501, also non-2xx) would satisfy is_err().
|
||||
_m.assert_async().await;
|
||||
}
|
||||
}
|
||||
+613
-48
@@ -4,7 +4,6 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Args, Subcommand};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
@@ -82,12 +81,7 @@ async fn resolve_repo(
|
||||
did.split(':').next_back().unwrap_or(&did).to_string()
|
||||
} else {
|
||||
let client = signed_client(node, dir);
|
||||
let info: Value = client
|
||||
.get_authed("/")
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to fetch node info")?;
|
||||
let info = crate::http::read_json(client.get_authed("/").await?, "node info").await?;
|
||||
let did = info["did"].as_str().context("node info missing 'did'")?;
|
||||
did.split(':').next_back().unwrap_or(did).to_string()
|
||||
};
|
||||
@@ -100,12 +94,7 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
|
||||
let client = signed_client(&node, dir.as_deref());
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/certs");
|
||||
let resp: Value = client
|
||||
.get_authed(&path)
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to list certificates")?;
|
||||
let resp = crate::http::read_json(client.get_authed(&path).await?, "certificates").await?;
|
||||
|
||||
let certs = resp["certificates"].as_array().cloned().unwrap_or_default();
|
||||
|
||||
@@ -139,14 +128,10 @@ async fn cmd_show(
|
||||
let client = signed_client(&node, dir.as_deref());
|
||||
let id = resolve_cert_id(&client, &owner, &name, &id).await?;
|
||||
|
||||
// Fetch the certificate
|
||||
// Fetch the certificate. read_json checks status first and surfaces the node's
|
||||
// capped+sanitized message on a non-2xx (a bounded error read, not the whole body).
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/certs/{id}");
|
||||
let resp = client
|
||||
.get_authed(&path)
|
||||
.await?
|
||||
.error_for_status()
|
||||
.context("certificate not found")?;
|
||||
let cert: Value = resp.json().await.context("certificate not found")?;
|
||||
let cert = crate::http::read_json(client.get_authed(&path).await?, "certificate").await?;
|
||||
|
||||
let cert_id = cert["id"].as_str().unwrap_or("?");
|
||||
let ref_name = cert["ref_name"].as_str().unwrap_or("?");
|
||||
@@ -191,27 +176,20 @@ async fn cmd_show(
|
||||
|
||||
// Contextual only — the verdict above stands on its own, so a node-info
|
||||
// hiccup here must not turn a successfully displayed certificate into an
|
||||
// error exit.
|
||||
let current_node_did = match client.get("/").await {
|
||||
Ok(resp) => resp
|
||||
.json::<Value>()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|info| info["did"].as_str().map(str::to_string)),
|
||||
Err(_) => None,
|
||||
// error exit. Route the lookup through read_json so a denial or a capped
|
||||
// error body yields a reportable reason instead of an opaque None, and keep
|
||||
// "carried no DID" distinct from "the lookup failed": an empty DID must not
|
||||
// reach the comparison and fabricate a mismatch warning.
|
||||
let current: std::result::Result<String, String> = match client.get("/").await {
|
||||
Ok(resp) => match crate::http::read_json(resp, "node info").await {
|
||||
Ok(info) => did_from_node_info(&info),
|
||||
Err(e) => Err(e.to_string()),
|
||||
},
|
||||
Err(e) => Err(e.to_string()),
|
||||
};
|
||||
match current_node_did.as_deref() {
|
||||
Some(current) if current == node_did => {
|
||||
println!(" Issuing node DID matches the node being queried.");
|
||||
}
|
||||
Some(current) => {
|
||||
println!(" WARNING: Certificate node DID ({node_did}) does not match");
|
||||
println!(" current node DID ({current}).");
|
||||
println!(" This certificate was issued by a different node.");
|
||||
}
|
||||
None => {
|
||||
println!(" NOTE: could not fetch current node info — skipping node-DID comparison.");
|
||||
}
|
||||
let current_node_did = current.as_ref().ok().cloned();
|
||||
for line in did_check_report(¤t, node_did) {
|
||||
println!("{line}");
|
||||
}
|
||||
|
||||
if require_valid {
|
||||
@@ -239,6 +217,46 @@ async fn cmd_show(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pull the node's DID out of a `GET /` body for the comparison in `cmd_show`.
|
||||
///
|
||||
/// A missing OR empty `did` is a failure, not a value: letting `""` through
|
||||
/// would reach the comparison and print a mismatch WARNING against a DID the
|
||||
/// node never claimed, which reads as "issued by a different node" when the
|
||||
/// truth is that the lookup told us nothing.
|
||||
fn did_from_node_info(info: &serde_json::Value) -> std::result::Result<String, String> {
|
||||
match info["did"].as_str() {
|
||||
Some(did) if !did.is_empty() => Ok(did.to_string()),
|
||||
_ => Err("node info response carried no DID".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Select the report lines for the node-DID comparison in `cmd_show`.
|
||||
///
|
||||
/// `current` is the current node's DID, or the reason it could not be
|
||||
/// determined. A comparison verdict (match or WARNING) is only produced when a
|
||||
/// real DID was obtained; otherwise the report degrades to a could-not-compare
|
||||
/// hint naming the reason, plus the offline-verification guidance. The signature
|
||||
/// verdict printed above stands on its own either way — this block only answers
|
||||
/// *which* node issued the certificate.
|
||||
fn did_check_report(current: &std::result::Result<String, String>, node_did: &str) -> Vec<String> {
|
||||
match current {
|
||||
Ok(current) if current == node_did => {
|
||||
vec![" Issuing node DID matches the node being queried.".to_string()]
|
||||
}
|
||||
Ok(current) => vec![
|
||||
format!(" WARNING: Certificate node DID ({node_did}) does not match"),
|
||||
format!(" current node DID ({current})."),
|
||||
" This certificate was issued by a different node.".to_string(),
|
||||
],
|
||||
Err(reason) => vec![
|
||||
format!(" Could not fetch the current node's DID ({reason}), so the comparison"),
|
||||
" with the certificate's node DID is unavailable.".to_string(),
|
||||
" To verify offline, use the node's Ed25519 public key derived from:".to_string(),
|
||||
format!(" did:key → {node_did}"),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
@@ -291,14 +309,7 @@ async fn resolve_cert_id(client: &NodeClient, owner: &str, name: &str, id: &str)
|
||||
}
|
||||
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/certs?prefix={id}");
|
||||
let resp: Value = client
|
||||
.get_authed(&path)
|
||||
.await?
|
||||
.error_for_status()
|
||||
.context("failed to list certificates")?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to list certificates")?;
|
||||
let resp = crate::http::read_json(client.get_authed(&path).await?, "certificates").await?;
|
||||
|
||||
let certs = resp["certificates"].as_array().cloned().unwrap_or_default();
|
||||
let matches: Vec<String> = certs
|
||||
@@ -397,4 +408,558 @@ mod tests {
|
||||
);
|
||||
assert!(garbage.is_err(), "malformed signature must not verify");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_list_surfaces_denial_not_empty() {
|
||||
// A gated 404 on the repo-scoped certs read must Err, not print "No certificates".
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"^/api/v1/repos/alice/secret/certs$".to_string()),
|
||||
)
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repository 'alice/secret' not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let result = cmd_list("alice/secret".to_string(), server.url(), None).await;
|
||||
assert!(result.is_err(), "cert list must Err on a gated 404");
|
||||
// Prove the gated certs path was actually requested: without this, an
|
||||
// unmatched route (mockito's 501, also non-2xx) would satisfy is_err().
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_repo_surfaces_denial() {
|
||||
// A slash-free repo with an empty identity dir forces the GET / node-info
|
||||
// fetch. A gated 404 there must Err (surfacing the status), proving the
|
||||
// read_json conversion is load-bearing rather than silently ignored.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let dir = tempfile::TempDir::new().unwrap(); // empty, no identity.pem, forces the GET / branch
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"denied"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let err = resolve_repo("noslash", &server.url(), Some(dir.path()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("404"), "got: {err}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
// The `GET /` node-info lookup after the cert loads is a fail-soft diagnostic:
|
||||
// a response-level failure degrades to a could-not-compare hint and the command
|
||||
// completes Ok, never a fabricated mismatch warning and never a fatal Err. The
|
||||
// cert fetch itself stays fail-closed. A >=36-char id skips resolve_cert_id so
|
||||
// only two mocks are needed.
|
||||
#[tokio::test]
|
||||
async fn cmd_show_completes_with_degraded_hint_when_node_info_denied() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let long_id = "a".repeat(36);
|
||||
let _cert = server
|
||||
.mock(
|
||||
"GET",
|
||||
format!("/api/v1/repos/alice/secret/certs/{long_id}").as_str(),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"id":"c1","ref_name":"refs/heads/main","old_sha":"0","new_sha":"1","pusher_did":"p","node_did":"n","signature":"s","issued_at":"2026-01-01T00:00:00Z"}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
let _root = server
|
||||
.mock("GET", "/")
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"denied"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = cmd_show(
|
||||
"alice/secret".to_string(),
|
||||
long_id,
|
||||
server.url(),
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"cert show must complete despite a denied node-info lookup"
|
||||
);
|
||||
_cert.assert_async().await;
|
||||
_root.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_show_degrades_on_malformed_node_info() {
|
||||
// A 2xx node-info body that fails to parse degrades the same way a denial
|
||||
// does: hint printed, command completes Ok.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let long_id = "a".repeat(36);
|
||||
let _cert = server
|
||||
.mock(
|
||||
"GET",
|
||||
format!("/api/v1/repos/alice/secret/certs/{long_id}").as_str(),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"id":"c1","ref_name":"refs/heads/main","old_sha":"0","new_sha":"1","pusher_did":"p","node_did":"n","signature":"s","issued_at":"2026-01-01T00:00:00Z"}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
let _root = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_body("not json")
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = cmd_show(
|
||||
"alice/secret".to_string(),
|
||||
long_id,
|
||||
server.url(),
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"cert show must complete despite malformed node info"
|
||||
);
|
||||
_cert.assert_async().await;
|
||||
_root.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_show_degrades_when_node_info_lacks_did() {
|
||||
// A 2xx node-info body with no `did` routes to the degraded hint, not a
|
||||
// fabricated empty-DID mismatch warning; the command completes Ok.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let long_id = "a".repeat(36);
|
||||
let _cert = server
|
||||
.mock(
|
||||
"GET",
|
||||
format!("/api/v1/repos/alice/secret/certs/{long_id}").as_str(),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"id":"c1","ref_name":"refs/heads/main","old_sha":"0","new_sha":"1","pusher_did":"p","node_did":"n","signature":"s","issued_at":"2026-01-01T00:00:00Z"}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
let _root = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body("{}")
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = cmd_show(
|
||||
"alice/secret".to_string(),
|
||||
long_id,
|
||||
server.url(),
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"cert show must complete when node info lacks a DID"
|
||||
);
|
||||
_cert.assert_async().await;
|
||||
_root.assert_async().await;
|
||||
}
|
||||
|
||||
// Must-not case: the certificate fetch itself stays fail-closed. A gated 404
|
||||
// aborts the command with the status surfaced, and the node-info lookup is
|
||||
// never reached (the expect(0) assert proves it never ran).
|
||||
#[tokio::test]
|
||||
async fn cmd_show_surfaces_denied_certificate() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let long_id = "a".repeat(36);
|
||||
let _cert = server
|
||||
.mock(
|
||||
"GET",
|
||||
format!("/api/v1/repos/alice/secret/certs/{long_id}").as_str(),
|
||||
)
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repository not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let _root = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"did":"n"}"#)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_show(
|
||||
"alice/secret".to_string(),
|
||||
long_id,
|
||||
server.url(),
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("404"), "got: {err}");
|
||||
_cert.assert_async().await;
|
||||
_root.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_show_reports_matching_node_did() {
|
||||
// Pins the unchanged success path: node info fetched, DIDs compared, Ok.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let long_id = "a".repeat(36);
|
||||
let _cert = server
|
||||
.mock(
|
||||
"GET",
|
||||
format!("/api/v1/repos/alice/secret/certs/{long_id}").as_str(),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"id":"c1","ref_name":"refs/heads/main","old_sha":"0","new_sha":"1","pusher_did":"p","node_did":"n","signature":"s","issued_at":"2026-01-01T00:00:00Z"}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
let _root = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"did":"n"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = cmd_show(
|
||||
"alice/secret".to_string(),
|
||||
long_id,
|
||||
server.url(),
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok(), "cert show must report a matching node DID");
|
||||
_cert.assert_async().await;
|
||||
_root.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_show_warns_on_mismatching_node_did() {
|
||||
// A real, differing node DID drives the WARNING branch end to end; the
|
||||
// command still completes Ok.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let long_id = "a".repeat(36);
|
||||
let _cert = server
|
||||
.mock(
|
||||
"GET",
|
||||
format!("/api/v1/repos/alice/secret/certs/{long_id}").as_str(),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"id":"c1","ref_name":"refs/heads/main","old_sha":"0","new_sha":"1","pusher_did":"p","node_did":"n","signature":"s","issued_at":"2026-01-01T00:00:00Z"}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
let _root = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"did":"did:key:other"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = cmd_show(
|
||||
"alice/secret".to_string(),
|
||||
long_id,
|
||||
server.url(),
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"cert show must warn, not fail, on a mismatching node DID"
|
||||
);
|
||||
_cert.assert_async().await;
|
||||
_root.assert_async().await;
|
||||
}
|
||||
|
||||
// ── --verify issuer anchoring ────────────────────────────────────────────
|
||||
//
|
||||
// Every other cmd_show test passes require_valid=false, so the whole
|
||||
// `if require_valid` block went unexecuted. It is the security-bearing half of
|
||||
// the command: a valid signature only proves the certificate is internally
|
||||
// consistent (a hostile node can mint a keypair, name it in node_did, and
|
||||
// self-sign), so --verify must additionally anchor the issuer to a DID the
|
||||
// caller trusts. These drive all four outcomes plus the must-not case.
|
||||
//
|
||||
// The certificate must carry a REAL signature over the canonical payload:
|
||||
// with a bogus one, --verify bails on the signature and never reaches the
|
||||
// anchoring, which would make every assertion below vacuous.
|
||||
fn signed_cert(node_kp: &gitlawb_core::identity::Keypair) -> (String, String) {
|
||||
let node_did = node_kp.did().as_str().to_string();
|
||||
let id = "a".repeat(36); // >= 36 chars skips resolve_cert_id's prefix lookup
|
||||
let payload = serde_json::json!({
|
||||
"repo_id": "repo-1",
|
||||
"ref": "refs/heads/main",
|
||||
"old": "0".repeat(40),
|
||||
"new": "b".repeat(40),
|
||||
"pusher": "did:key:z6MkPusher",
|
||||
"node": node_did,
|
||||
"ts": "2026-07-22T00:00:00+00:00",
|
||||
});
|
||||
let sig = node_kp.sign_b64(&serde_json::to_vec(&payload).unwrap());
|
||||
let body = serde_json::json!({
|
||||
"id": id,
|
||||
"repo_id": "repo-1",
|
||||
"ref_name": "refs/heads/main",
|
||||
"old_sha": "0".repeat(40),
|
||||
"new_sha": "b".repeat(40),
|
||||
"pusher_did": "did:key:z6MkPusher",
|
||||
"node_did": node_did,
|
||||
"signature": sig,
|
||||
"issued_at": "2026-07-22T00:00:00+00:00",
|
||||
})
|
||||
.to_string();
|
||||
(id, body)
|
||||
}
|
||||
|
||||
/// Mount the cert fetch, plus a `GET /` answering with `node_info` (a JSON
|
||||
/// body) or a denial when `None`.
|
||||
async fn cert_server(
|
||||
server: &mut mockito::Server,
|
||||
id: &str,
|
||||
cert_body: &str,
|
||||
node_info: Option<&str>,
|
||||
) -> (mockito::Mock, mockito::Mock) {
|
||||
let cert = server
|
||||
.mock("GET", format!("/api/v1/repos/alice/r/certs/{id}").as_str())
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(cert_body)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let root = match node_info {
|
||||
Some(b) => {
|
||||
server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(b)
|
||||
.create_async()
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
server
|
||||
.mock("GET", "/")
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"denied"}"#)
|
||||
.create_async()
|
||||
.await
|
||||
}
|
||||
};
|
||||
(cert, root)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verify_ok_when_issuing_node_is_the_queried_node() {
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let (id, body) = signed_cert(&kp);
|
||||
let info = format!(r#"{{"did":"{}"}}"#, kp.did().as_str());
|
||||
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, None).await;
|
||||
assert!(got.is_ok(), "valid sig + matching issuer must pass");
|
||||
cert.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verify_bails_when_issuer_is_a_different_node() {
|
||||
// The self-signing-hostile-node case: the signature verifies against the
|
||||
// key the cert names, but that key is not the node we queried.
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let (id, body) = signed_cert(&kp);
|
||||
let other = gitlawb_core::identity::Keypair::generate();
|
||||
let info = format!(r#"{{"did":"{}"}}"#, other.did().as_str());
|
||||
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, None)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("expected issuer"), "got: {err}");
|
||||
cert.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verify_bails_when_node_info_denied_and_no_expect_node() {
|
||||
// Fail closed: with no trusted DID to anchor against, --verify must NOT
|
||||
// fall through to a pass just because the signature checked out.
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let (id, body) = signed_cert(&kp);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let (cert, _root) = cert_server(&mut server, &id, &body, None).await;
|
||||
|
||||
let err = cmd_show("alice/r".to_string(), id, server.url(), None, true, None)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("cannot anchor the issuer"), "got: {err}");
|
||||
cert.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verify_ok_when_expect_node_anchors_a_denied_lookup() {
|
||||
// --expect-node supplies the trust anchor the denied lookup could not, so
|
||||
// the same denial that fails the case above now passes.
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let (id, body) = signed_cert(&kp);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let (cert, _root) = cert_server(&mut server, &id, &body, None).await;
|
||||
|
||||
let got = cmd_show(
|
||||
"alice/r".to_string(),
|
||||
id,
|
||||
server.url(),
|
||||
None,
|
||||
true,
|
||||
Some(kp.did().as_str().to_string()),
|
||||
)
|
||||
.await;
|
||||
assert!(got.is_ok(), "explicit anchor must pass");
|
||||
cert.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verify_bails_on_a_bad_signature_before_anchoring() {
|
||||
// The must-not: a forged certificate naming the queried node must fail on
|
||||
// the signature, even though its issuer would otherwise anchor cleanly.
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let (id, body) = signed_cert(&kp);
|
||||
let forged = body.replace(&"b".repeat(40), &"c".repeat(40));
|
||||
let info = format!(r#"{{"did":"{}"}}"#, kp.did().as_str());
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let (cert, _root) = cert_server(&mut server, &id, &forged, Some(&info)).await;
|
||||
|
||||
let err = cmd_show("alice/r".to_string(), id, server.url(), None, true, None)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("signature did not verify"), "got: {err}");
|
||||
cert.assert_async().await;
|
||||
}
|
||||
|
||||
// The must-not case for the DID extraction: an empty or missing `did` has to
|
||||
// become a could-not-compare reason. If it leaks through as a value, the
|
||||
// comparison below fabricates a mismatch WARNING against a DID the node
|
||||
// never claimed.
|
||||
#[test]
|
||||
fn did_from_node_info_rejects_empty_and_missing() {
|
||||
assert!(did_from_node_info(&serde_json::json!({"did": "n"})).is_ok());
|
||||
for body in [
|
||||
serde_json::json!({"did": ""}),
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({"did": 7}),
|
||||
] {
|
||||
let got = did_from_node_info(&body);
|
||||
assert!(
|
||||
got.is_err(),
|
||||
"must not yield a comparable DID: {body} -> {got:?}"
|
||||
);
|
||||
}
|
||||
// And the reason it produces must route to the degraded hint, never a verdict.
|
||||
let report =
|
||||
did_check_report(&did_from_node_info(&serde_json::json!({"did": ""})), "n").join("\n");
|
||||
assert!(
|
||||
!report.contains("WARNING"),
|
||||
"fabricated a mismatch: {report}"
|
||||
);
|
||||
assert!(report.contains("Could not fetch"), "got: {report}");
|
||||
}
|
||||
|
||||
// did_check_report is the three-way selector between the match text, the
|
||||
// mismatch WARNING, and the degraded could-not-compare hint. Substring
|
||||
// asserts (not full-line equality) so cosmetic wording edits don't break them.
|
||||
|
||||
#[test]
|
||||
fn did_check_report_match() {
|
||||
let report = did_check_report(&Ok("n".to_string()), "n").join("\n");
|
||||
assert!(
|
||||
report.contains("matches the node being queried"),
|
||||
"got: {report}"
|
||||
);
|
||||
assert!(!report.contains("WARNING"), "got: {report}");
|
||||
assert!(!report.contains("Could not fetch"), "got: {report}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn did_check_report_mismatch() {
|
||||
let report = did_check_report(&Ok("did:key:other".to_string()), "n").join("\n");
|
||||
assert!(report.contains("WARNING"), "got: {report}");
|
||||
assert!(report.contains("does not match"), "got: {report}");
|
||||
assert!(report.contains("did:key:other"), "got: {report}");
|
||||
assert!(!report.contains("Could not fetch"), "got: {report}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn did_check_report_missing_did_reason() {
|
||||
let report =
|
||||
did_check_report(&Err("node info response carried no DID".to_string()), "n").join("\n");
|
||||
assert!(report.contains("Could not fetch"), "got: {report}");
|
||||
assert!(
|
||||
report.contains("node info response carried no DID"),
|
||||
"got: {report}"
|
||||
);
|
||||
assert!(report.contains("verify offline"), "got: {report}");
|
||||
// The must-not case: no real DID was obtained, so no comparison verdict
|
||||
// may be claimed in either direction.
|
||||
assert!(!report.contains("WARNING"), "got: {report}");
|
||||
assert!(
|
||||
!report.contains("matches the node being queried"),
|
||||
"got: {report}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn did_check_report_lookup_error_reason() {
|
||||
let report =
|
||||
did_check_report(&Err("node info failed (403): denied".to_string()), "n").join("\n");
|
||||
assert!(report.contains("Could not fetch"), "got: {report}");
|
||||
assert!(report.contains("403"), "got: {report}");
|
||||
assert!(report.contains("verify offline"), "got: {report}");
|
||||
assert!(!report.contains("WARNING"), "got: {report}");
|
||||
}
|
||||
}
|
||||
+32
-14
@@ -2,7 +2,6 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Args;
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
@@ -42,12 +41,7 @@ pub async fn run(args: ChangelogArgs) -> Result<()> {
|
||||
did.split(':').next_back().unwrap_or(&did).to_string()
|
||||
} else {
|
||||
let client = NodeClient::new(&args.node, None);
|
||||
let info: Value = client
|
||||
.get("/")
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to fetch node info")?;
|
||||
let info = crate::http::read_json(client.get("/").await?, "node info").await?;
|
||||
let did = info["did"].as_str().context("node missing DID")?;
|
||||
did.split(':').next_back().unwrap_or(did).to_string()
|
||||
};
|
||||
@@ -64,13 +58,7 @@ pub async fn run(args: ChangelogArgs) -> Result<()> {
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("changelog failed ({status}): {msg}");
|
||||
}
|
||||
let body = crate::http::read_json(resp, "changelog").await?;
|
||||
|
||||
let events = body["events"].as_array().cloned().unwrap_or_default();
|
||||
|
||||
@@ -212,6 +200,7 @@ mod tests {
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repo not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -223,6 +212,8 @@ mod tests {
|
||||
};
|
||||
let err = run(args).await.unwrap_err();
|
||||
assert!(err.to_string().contains("changelog failed"));
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -270,4 +261,31 @@ mod tests {
|
||||
let err = rt.block_on(run(args)).unwrap_err();
|
||||
assert!(err.to_string().contains("no repo specified"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_via_run_surfaces_denial() {
|
||||
// A slash-free repo with an empty identity dir forces the inline GET /
|
||||
// node-info fetch during resolution. A gated 404 there must Err (surfacing
|
||||
// the status), proving the read_json conversion is load-bearing.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let dir = tempfile::TempDir::new().unwrap(); // empty, no identity.pem, forces the GET / branch
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"denied"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let err = run(ChangelogArgs {
|
||||
repo: Some("noslash".to_string()),
|
||||
limit: 20,
|
||||
node: server.url(),
|
||||
dir: Some(dir.path().to_path_buf()),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("404"), "got: {err}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
}
|
||||
+269
-1
@@ -10,6 +10,7 @@ use anyhow::{Context, Result};
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use icaptcha_client::IcaptchaCfg;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Max times we'll fetch a fresh proof and retry a 403-iCaptcha response
|
||||
/// (absorbs proof expiry / first-seen replay).
|
||||
@@ -319,7 +320,7 @@ pub(crate) async fn read_body_capped(mut resp: reqwest::Response, cap: usize) ->
|
||||
/// Strip terminal-dangerous characters from (and cap the length of) a
|
||||
/// node-supplied error string before surfacing it. The node a caller talks to
|
||||
/// could be hostile and embed escape sequences in its error body; those must not
|
||||
/// reach the terminal verbatim (INV-6). We drop the C0/C1 control bytes (which
|
||||
/// reach the terminal verbatim. We drop the C0/C1 control bytes (which
|
||||
/// defangs ANSI/OSC escapes) AND the Unicode bidi/format controls (which
|
||||
/// `char::is_control` does not cover — they can reorder the displayed line).
|
||||
pub(crate) fn sanitize_node_msg(s: &str) -> String {
|
||||
@@ -329,6 +330,60 @@ pub(crate) fn sanitize_node_msg(s: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read a JSON response, surfacing a node denial/error instead of parsing it as
|
||||
/// the requested resource. On a non-2xx status it returns an `Err` carrying the
|
||||
/// node's sanitized `message` plus the status; on success it parses the
|
||||
/// body and propagates a parse error, so a truncated/garbage 2xx body is an error
|
||||
/// rather than a silently-empty success (the denial-as-success bug #123 fixes).
|
||||
/// `what` names the resource for the error text (e.g. "repo", "commits").
|
||||
///
|
||||
/// Callers must route gated reads through this rather than `resp.json().await?`:
|
||||
/// the bare parse renders a gated 404/5xx body back as the resource.
|
||||
/// Cap on the error body `read_json` reads before parsing a node's `message`.
|
||||
/// Matches the capped-read bound used on the sync error path; large enough for any
|
||||
/// legitimate error, small enough that a hostile node cannot exhaust memory.
|
||||
const ERROR_BODY_CAP: usize = 8 * 1024;
|
||||
|
||||
pub(crate) async fn read_json(resp: reqwest::Response, what: &str) -> Result<Value> {
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
// Bound the error read: `resp.json()` would buffer and parse the WHOLE
|
||||
// body, so a hostile node could stream an arbitrarily large valid JSON
|
||||
// error and exhaust this process's memory (#186). Read a small capped body
|
||||
// first, then best-effort extract `message`. The error body may also be
|
||||
// non-JSON (503 degraded, 413 body-limit from middleware), so a parse miss
|
||||
// falls back to the capped raw text; an empty body to the status alone.
|
||||
let raw = read_body_capped(resp, ERROR_BODY_CAP).await.text;
|
||||
// Preserve the prior contract: surface the JSON `message` when present in
|
||||
// the capped body, else the status alone ("request failed") — a non-JSON
|
||||
// or message-less body (503 degraded, 413 body-limit) never surfaces raw
|
||||
// node bytes. A body whose `message` sits past the cap parses as truncated
|
||||
// garbage here and correctly falls back rather than being buffered whole.
|
||||
//
|
||||
// Fall back to `error` before giving up: the node's shared AppError
|
||||
// envelope carries both keys, but the task API answers with `error` alone
|
||||
// (`{"error":"task not found"}` in gitlawb-node/src/api/tasks.rs), so
|
||||
// reading only `message` blanked every `gl task` denial to the generic
|
||||
// "request failed". `sync.rs`'s hand-rolled reader already reads both; this
|
||||
// matches it.
|
||||
let msg = serde_json::from_str::<Value>(&raw)
|
||||
.ok()
|
||||
.and_then(|b| {
|
||||
b.get("message")
|
||||
.or_else(|| b.get("error"))
|
||||
.and_then(|m| m.as_str())
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.unwrap_or_else(|| "request failed".to_owned());
|
||||
anyhow::bail!("{what} failed ({status}): {}", sanitize_node_msg(&msg));
|
||||
}
|
||||
// Success: a truncated/garbage 2xx body must be an `Err`, not `Ok(Null)` that
|
||||
// a caller then renders as an empty success.
|
||||
resp.json()
|
||||
.await
|
||||
.with_context(|| format!("invalid JSON in {what} response"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1268,4 +1323,217 @@ mod tests {
|
||||
let out = sanitize_node_msg("ok \u{0627}\u{200D}b");
|
||||
assert_eq!(out, "ok \u{0627}\u{200D}b");
|
||||
}
|
||||
|
||||
// ── read_json (status-checked, bounded, sanitized read; #123) ────────
|
||||
|
||||
/// Drive a real `reqwest::Response` off a mockito mock so `read_json` sees an
|
||||
/// actual HTTP status + body, the same shape the gated read arms produce.
|
||||
async fn response_for(
|
||||
server: &mut Server,
|
||||
status: usize,
|
||||
body: &str,
|
||||
json: bool,
|
||||
) -> reqwest::Response {
|
||||
let mut m = server.mock("GET", "/x").with_status(status).with_body(body);
|
||||
if json {
|
||||
m = m.with_header("content-type", "application/json");
|
||||
}
|
||||
let _m = m.create_async().await;
|
||||
NodeClient::new(server.url(), None).get("/x").await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_json_returns_body_on_2xx() {
|
||||
let mut server = Server::new_async().await;
|
||||
let resp = response_for(
|
||||
&mut server,
|
||||
200,
|
||||
r#"{"name":"r","owner_did":"did:gitlawb:z"}"#,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let v = read_json(resp, "repo").await.unwrap();
|
||||
assert_eq!(v["name"], "r");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_json_errs_on_404_surfacing_message_and_status() {
|
||||
let mut server = Server::new_async().await;
|
||||
let resp = response_for(
|
||||
&mut server,
|
||||
404,
|
||||
r#"{"message":"repository 'o/r' not found"}"#,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let err = read_json(resp, "repo").await.unwrap_err().to_string();
|
||||
assert!(err.contains("404"), "err={err}");
|
||||
assert!(err.contains("not found"), "err={err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_json_errs_on_500_surfacing_message() {
|
||||
let mut server = Server::new_async().await;
|
||||
let resp = response_for(&mut server, 500, r#"{"message":"internal boom"}"#, true).await;
|
||||
let err = read_json(resp, "commits").await.unwrap_err().to_string();
|
||||
assert!(err.contains("500"), "err={err}");
|
||||
assert!(err.contains("internal boom"), "err={err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_json_errs_on_non_json_error_body_with_fallback() {
|
||||
// 503 with a plain-text (middleware) body: no `message` field to surface.
|
||||
let mut server = Server::new_async().await;
|
||||
let resp = response_for(&mut server, 503, "service unavailable", false).await;
|
||||
let err = read_json(resp, "repo").await.unwrap_err().to_string();
|
||||
assert!(err.contains("503"), "err={err}");
|
||||
assert!(err.contains("request failed"), "err={err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_json_sanitizes_control_and_bidi_in_message() {
|
||||
// A hostile node embeds ESC, BEL, and a right-to-left override in
|
||||
// the error message; none may reach the terminal verbatim.
|
||||
let mut server = Server::new_async().await;
|
||||
let resp = response_for(
|
||||
&mut server,
|
||||
404,
|
||||
r#"{"message":"a\u001b[31mb\u0007c\u202ed"}"#,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let err = read_json(resp, "repo").await.unwrap_err().to_string();
|
||||
assert!(!err.contains('\u{1b}'), "ESC leaked: {err:?}");
|
||||
assert!(!err.contains('\u{7}'), "BEL leaked: {err:?}");
|
||||
assert!(!err.contains('\u{202e}'), "bidi override leaked: {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_json_errs_on_garbage_2xx_body() {
|
||||
// The #123 correctness point: a 200 with a non-JSON/truncated body must be
|
||||
// an `Err`, NOT `Ok(Null)` that a caller renders as an empty success.
|
||||
let mut server = Server::new_async().await;
|
||||
let resp = response_for(&mut server, 200, "this is not json", false).await;
|
||||
assert!(read_json(resp, "repo").await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_json_errs_on_empty_2xx_body() {
|
||||
// A zero-byte 200 body must be an `Err`, not a silent `Ok(Null)`.
|
||||
let mut server = Server::new_async().await;
|
||||
let resp = response_for(&mut server, 200, "", false).await;
|
||||
assert!(read_json(resp, "repo").await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_json_errs_on_non_2xx_json_without_message_or_error() {
|
||||
// A non-2xx JSON body carrying NEITHER key is the real fallback case.
|
||||
let mut server = Server::new_async().await;
|
||||
let resp = response_for(&mut server, 403, r#"{"detail":"forbidden"}"#, true).await;
|
||||
let err = read_json(resp, "repo").await.unwrap_err().to_string();
|
||||
assert!(err.contains("403"), "err={err}");
|
||||
assert!(err.contains("request failed"), "err={err}");
|
||||
// The body's own text must not leak when no recognised key carries it.
|
||||
assert!(!err.contains("forbidden"), "raw body leaked: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_json_surfaces_error_key_when_message_absent() {
|
||||
// The task API answers with `error` alone — see
|
||||
// gitlawb-node/src/api/tasks.rs `{"error":"task not found"}`. Reading only
|
||||
// `message` blanked those denials to the generic "request failed", so the
|
||||
// user was told nothing about why the call failed.
|
||||
let mut server = Server::new_async().await;
|
||||
let resp = response_for(&mut server, 404, r#"{"error":"task not found"}"#, true).await;
|
||||
let err = read_json(resp, "task show").await.unwrap_err().to_string();
|
||||
assert!(err.contains("404"), "err={err}");
|
||||
assert!(err.contains("task not found"), "reason not surfaced: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_json_prefers_message_over_error_key() {
|
||||
// The shared AppError envelope carries both; `message` is the human text
|
||||
// and must win over the machine code in `error`.
|
||||
let mut server = Server::new_async().await;
|
||||
let resp = response_for(
|
||||
&mut server,
|
||||
500,
|
||||
r#"{"error":"db_error","message":"database unavailable"}"#,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let err = read_json(resp, "repo").await.unwrap_err().to_string();
|
||||
assert!(err.contains("database unavailable"), "err={err}");
|
||||
assert!(
|
||||
!err.contains("db_error"),
|
||||
"machine code won over message: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_json_sanitizes_error_key_text() {
|
||||
// The `error` fallback goes through the same sanitizer as `message`; a
|
||||
// hostile node must not reach the terminal through the new path. The
|
||||
// controls are JSON \u escapes so the body stays VALID JSON — embedding
|
||||
// them raw would fail the parse and pass this test vacuously.
|
||||
let mut server = Server::new_async().await;
|
||||
let resp = response_for(
|
||||
&mut server,
|
||||
404,
|
||||
r#"{"error":"a\u001b[31mb\u0007c\u202ed"}"#,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let err = read_json(resp, "task show").await.unwrap_err().to_string();
|
||||
// Proves the parse succeeded and the `error` key was read, not the
|
||||
// "request failed" fallback (which would make the asserts below vacuous).
|
||||
assert!(err.contains("task show failed (404"), "err={err}");
|
||||
assert!(
|
||||
!err.contains("request failed"),
|
||||
"fell back, sanitizer untested: {err}"
|
||||
);
|
||||
assert!(
|
||||
err.contains('a') && err.contains('d'),
|
||||
"text dropped: {err}"
|
||||
);
|
||||
assert!(!err.contains('\u{1b}'), "ESC leaked: {err:?}");
|
||||
assert!(!err.contains('\u{7}'), "BEL leaked: {err:?}");
|
||||
assert!(!err.contains('\u{202e}'), "bidi override leaked: {err:?}");
|
||||
}
|
||||
|
||||
/// #186 (F2): the non-2xx error path must read a CAPPED body, not buffer and
|
||||
/// parse the whole thing — a hostile node can stream an arbitrarily large valid
|
||||
/// JSON error. Place the `message` field AFTER more than the cap of padding: the
|
||||
/// bounded read stops before it, so it must be ABSENT from the surfaced error.
|
||||
/// RED before the fix (`resp.json()` parses the full body → the far message
|
||||
/// appears), GREEN after (capped read truncates → fallback → far message gone).
|
||||
/// The status is still surfaced, so a legitimate small error is unaffected.
|
||||
#[tokio::test]
|
||||
async fn read_json_bounds_the_error_body_read() {
|
||||
let mut server = Server::new_async().await;
|
||||
// >8 KiB of padding, then the marker message at the very end of the body.
|
||||
let big = format!(
|
||||
r#"{{"padding":"{}","message":"FARMARKER_BEYOND_CAP"}}"#,
|
||||
"A".repeat(16 * 1024)
|
||||
);
|
||||
let resp = response_for(&mut server, 500, &big, true).await;
|
||||
let err = read_json(resp, "repo").await.unwrap_err().to_string();
|
||||
assert!(err.contains("500"), "bounded read still errors: {err}");
|
||||
assert!(
|
||||
!err.contains("FARMARKER_BEYOND_CAP"),
|
||||
"the error body read must be capped: a message beyond the cap must not be surfaced, err={err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #186 (F2): an EMPTY non-2xx body is its own input class — the capped read
|
||||
/// yields "", the JSON parse fails, and it falls back to the status alone
|
||||
/// ("request failed"), never a panic or a spurious success.
|
||||
#[tokio::test]
|
||||
async fn read_json_errs_on_empty_non_2xx_body() {
|
||||
let mut server = Server::new_async().await;
|
||||
let resp = response_for(&mut server, 502, "", false).await;
|
||||
let err = read_json(resp, "repo").await.unwrap_err().to_string();
|
||||
assert!(err.contains("502"), "err={err}");
|
||||
assert!(err.contains("request failed"), "err={err}");
|
||||
}
|
||||
}
|
||||
+72
-49
@@ -3,7 +3,7 @@
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Utc;
|
||||
use clap::{Args, Subcommand};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -142,12 +142,7 @@ async fn resolve_repo(
|
||||
did.split(':').next_back().unwrap_or(&did).to_string()
|
||||
} else {
|
||||
let client = NodeClient::new(node, None);
|
||||
let info: Value = client
|
||||
.get("/")
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to fetch node info")?;
|
||||
let info = crate::http::read_json(client.get("/").await?, "node info").await?;
|
||||
let did = info["did"].as_str().context("node info missing 'did'")?;
|
||||
did.split(':').next_back().unwrap_or(did).to_string()
|
||||
};
|
||||
@@ -198,13 +193,7 @@ async fn cmd_create(
|
||||
.post(&path, &request_body)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let status = resp.status();
|
||||
let result: Value = resp.json().await.context("invalid JSON response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = result["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("create issue failed ({status}): {msg}");
|
||||
}
|
||||
let result = crate::http::read_json(resp, "create issue").await?;
|
||||
|
||||
let id = result["id"].as_str().unwrap_or("?");
|
||||
println!("✓ Created issue #{id}");
|
||||
@@ -221,12 +210,7 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
|
||||
let client = signed_client(&node, dir.as_deref());
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/issues");
|
||||
let resp: Value = client
|
||||
.get_authed(&path)
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to list issues")?;
|
||||
let resp = crate::http::read_json(client.get_authed(&path).await?, "issues").await?;
|
||||
|
||||
let issues = resp["issues"].as_array().cloned().unwrap_or_default();
|
||||
|
||||
@@ -264,13 +248,7 @@ async fn cmd_show(repo: String, id: String, node: String, dir: Option<PathBuf>)
|
||||
.get_authed(&path)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let status = resp.status();
|
||||
let issue: Value = resp.json().await.context("invalid JSON response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = issue["message"].as_str().unwrap_or("issue not found");
|
||||
anyhow::bail!("show failed ({status}): {msg}");
|
||||
}
|
||||
let issue = crate::http::read_json(resp, "show").await?;
|
||||
|
||||
let title = issue["title"].as_str().unwrap_or("(no title)");
|
||||
let status = issue["status"].as_str().unwrap_or("?");
|
||||
@@ -301,13 +279,7 @@ async fn cmd_close(repo: String, id: String, node: String, dir: Option<PathBuf>)
|
||||
.post(&format!("{path}/close"), &body)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let status = resp.status();
|
||||
let result: Value = resp.json().await.context("invalid JSON response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = result["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("close issue failed ({status}): {msg}");
|
||||
}
|
||||
let _ = crate::http::read_json(resp, "close issue").await?;
|
||||
|
||||
println!("✗ Closed issue {id}");
|
||||
Ok(())
|
||||
@@ -332,13 +304,7 @@ async fn cmd_issue_comment(
|
||||
)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let code = resp.status();
|
||||
let result: Value = resp.json().await.context("invalid JSON")?;
|
||||
|
||||
if !code.is_success() {
|
||||
let msg = result["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("comment failed ({code}): {msg}");
|
||||
}
|
||||
let _ = crate::http::read_json(resp, "comment").await?;
|
||||
|
||||
println!("· Comment posted on issue {id}");
|
||||
Ok(())
|
||||
@@ -353,14 +319,15 @@ async fn cmd_issue_comments(
|
||||
let (owner, name) = resolve_repo(&repo, &node, dir.as_deref()).await?;
|
||||
let client = signed_client(&node, dir.as_deref());
|
||||
|
||||
let resp: Value = client
|
||||
.get_authed(&format!(
|
||||
"/api/v1/repos/{owner}/{name}/issues/{id}/comments"
|
||||
))
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("invalid JSON")?;
|
||||
let resp = crate::http::read_json(
|
||||
client
|
||||
.get_authed(&format!(
|
||||
"/api/v1/repos/{owner}/{name}/issues/{id}/comments"
|
||||
))
|
||||
.await?,
|
||||
"issue comments",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let comments = resp["comments"].as_array().cloned().unwrap_or_default();
|
||||
if comments.is_empty() {
|
||||
@@ -490,6 +457,7 @@ mod tests {
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repo not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -504,6 +472,8 @@ mod tests {
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("repo not found"));
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -551,6 +521,7 @@ mod tests {
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"issue not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -564,6 +535,8 @@ mod tests {
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("show failed"), "got: {err}");
|
||||
assert!(err.to_string().contains("issue not found"), "got: {err}");
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -612,6 +585,7 @@ mod tests {
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"issue not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -624,6 +598,8 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("close issue failed"), "got: {err}");
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -670,6 +646,7 @@ mod tests {
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"issue not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -683,6 +660,8 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("issue not found"), "got: {err}");
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -740,4 +719,48 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_list_surfaces_denial_not_empty() {
|
||||
// A gated 404 on the issue-list read must Err, not print "No issues".
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"^/api/v1/repos/alice/secret/issues$".to_string()),
|
||||
)
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repository 'alice/secret' not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let result = cmd_list("alice/secret".to_string(), server.url(), None).await;
|
||||
assert!(result.is_err(), "issue list must Err on a gated 404");
|
||||
// Prove the gated issues path was actually requested: without this, an
|
||||
// unmatched route (mockito's 501, also non-2xx) would satisfy is_err().
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_repo_surfaces_denial() {
|
||||
// A slash-free repo with an empty identity dir forces the GET / node-info
|
||||
// fetch. A gated 404 there must Err (surfacing the status), proving the
|
||||
// read_json conversion is load-bearing rather than silently ignored.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let dir = tempfile::TempDir::new().unwrap(); // empty, no identity.pem, forces the GET / branch
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"denied"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let err = resolve_repo("noslash", &server.url(), Some(dir.path()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("404"), "got: {err}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
}
|
||||
+836
-154
File diff suppressed because it is too large.
Load diff
+296
-31
@@ -63,12 +63,7 @@ pub async fn run(args: PeerArgs) -> Result<()> {
|
||||
|
||||
async fn cmd_list(node: String) -> Result<()> {
|
||||
let client = NodeClient::new(&node, None);
|
||||
let resp: Value = client
|
||||
.get("/api/v1/peers")
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to list peers")?;
|
||||
let resp = crate::http::read_json(client.get("/api/v1/peers").await?, "peers").await?;
|
||||
|
||||
let peers = resp["peers"].as_array().cloned().unwrap_or_default();
|
||||
let count = resp["count"].as_u64().unwrap_or(peers.len() as u64);
|
||||
@@ -169,18 +164,20 @@ async fn cmd_add(peer_url: String, node: String, dir: Option<PathBuf>) -> Result
|
||||
let keypair = load_keypair_from_dir(dir.as_deref())?;
|
||||
let my_did = keypair.did().to_string();
|
||||
|
||||
// Fetch our node's public URL so we can announce it to the peer
|
||||
// Fetch our node's public URL so we can announce it to the peer. This lookup
|
||||
// is a deliberate fail-soft diagnostic: it only improves the URL we advertise.
|
||||
// A response-level failure (denial, error, garbage body) falls back to the
|
||||
// --node value with a visible note rather than aborting; only a transport
|
||||
// failure on the GET itself is fatal. The announce below keeps the
|
||||
// fail-closed read_json check.
|
||||
let local_client = NodeClient::new(&node, None);
|
||||
let node_info: Value = local_client
|
||||
.get("/")
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to fetch local node info")?;
|
||||
let my_url = node_info["public_url"]
|
||||
.as_str()
|
||||
.unwrap_or(&node)
|
||||
.to_string();
|
||||
let my_url = match crate::http::read_json(local_client.get("/").await?, "node info").await {
|
||||
Ok(info) => info["public_url"].as_str().unwrap_or(&node).to_string(),
|
||||
Err(e) => {
|
||||
eprintln!("note: local node info unavailable ({e}); announcing {node}");
|
||||
node.clone()
|
||||
}
|
||||
};
|
||||
|
||||
// Announce our local node to the remote peer
|
||||
let body = serde_json::to_vec(&serde_json::json!({
|
||||
@@ -256,12 +253,7 @@ async fn cmd_add(peer_url: String, node: String, dir: Option<PathBuf>) -> Result
|
||||
async fn cmd_ping(did: String, node: String) -> Result<()> {
|
||||
let client = NodeClient::new(&node, None);
|
||||
let path = format!("/api/v1/peers/{did}/ping");
|
||||
let resp: Value = client
|
||||
.get(&path)
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to ping peer")?;
|
||||
let resp = crate::http::read_json(client.get(&path).await?, "ping peer").await?;
|
||||
|
||||
let url = resp["http_url"].as_str().unwrap_or("?");
|
||||
let reachable = resp["reachable"].as_bool().unwrap_or(false);
|
||||
@@ -281,12 +273,7 @@ async fn cmd_resolve(did: String, node: String) -> Result<()> {
|
||||
let client = NodeClient::new(&node, None);
|
||||
let encoded = urlencoding::encode(&did);
|
||||
let path = format!("/api/v1/resolve/{encoded}");
|
||||
let resp: Value = client
|
||||
.get(&path)
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to resolve DID")?;
|
||||
let resp = crate::http::read_json(client.get(&path).await?, "resolve DID").await?;
|
||||
|
||||
let source = resp["source"].as_str().unwrap_or("not found");
|
||||
let http_url = resp["http_url"].as_str().unwrap_or("(none)");
|
||||
@@ -309,8 +296,8 @@ async fn cmd_resolve(did: String, node: String) -> Result<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
announced_peer_summary, cmd_add, local_add_refusal, local_add_report,
|
||||
remote_announce_failure,
|
||||
announced_peer_summary, cmd_add, cmd_list, cmd_ping, cmd_resolve, local_add_refusal,
|
||||
local_add_report, remote_announce_failure,
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::json;
|
||||
@@ -603,4 +590,282 @@ mod tests {
|
||||
"legitimate text mangled: {warning:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_list_surfaces_denial_not_empty() {
|
||||
// A node error must Err, not print "No known peers" as if the list were empty.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/api/v1/peers")
|
||||
.with_status(500)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"boom"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let err = cmd_list(server.url()).await.unwrap_err();
|
||||
assert!(err.to_string().contains("500"), "got: {err}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_ping_surfaces_denial_not_unreachable() {
|
||||
// A node error must Err, not print a fabricated "unreachable" peer.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/api/v1/peers/peer1/ping")
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let err = cmd_ping("peer1".to_string(), server.url())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("404"), "got: {err}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_resolve_surfaces_denial_not_notfound() {
|
||||
// A node error must Err, not print "Source: not found" as if resolved-absent.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/api/v1/resolve/".to_string()),
|
||||
)
|
||||
.with_status(500)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"boom"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let err = cmd_resolve("peer1".to_string(), server.url())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("500"), "got: {err}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
// cmd_add needs a local identity to build my_did before the GET /.
|
||||
fn identity_dir() -> tempfile::TempDir {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(
|
||||
dir.path().join("identity.pem"),
|
||||
kp.to_pem().unwrap().as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// Mock a successful announce that requires `http_url` in the POSTed body.
|
||||
/// The `{}` response body matters: it leaves `node_url` absent, so cmd_add
|
||||
/// skips the follow-up local peer-list POST.
|
||||
async fn announce_ok_mock(server: &mut mockito::ServerGuard, http_url: &str) -> mockito::Mock {
|
||||
announce_mock_with_body(server, http_url, "{}").await
|
||||
}
|
||||
|
||||
/// Like [`announce_ok_mock`] but with a custom response body, for tests
|
||||
/// that need `node_url` populated so the follow-up local add runs.
|
||||
async fn announce_mock_with_body(
|
||||
server: &mut mockito::ServerGuard,
|
||||
http_url: &str,
|
||||
body: &str,
|
||||
) -> mockito::Mock {
|
||||
server
|
||||
.mock("POST", "/api/v1/peers/announce")
|
||||
.match_body(mockito::Matcher::PartialJson(serde_json::json!({
|
||||
"http_url": http_url,
|
||||
})))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(body)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await
|
||||
}
|
||||
|
||||
// The local `GET /` lookup in cmd_add is a fail-soft diagnostic: a denied
|
||||
// or error node-info response must fall back to announcing the --node URL
|
||||
// (with a stderr note), not abort the command before the announce POST.
|
||||
#[tokio::test]
|
||||
async fn cmd_add_announces_fallback_url_when_node_info_denied() {
|
||||
let mut local = mockito::Server::new_async().await;
|
||||
let _info = local
|
||||
.mock("GET", "/")
|
||||
.with_status(500)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"internal"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let mut remote = mockito::Server::new_async().await;
|
||||
let _announce = announce_ok_mock(&mut remote, &local.url()).await;
|
||||
|
||||
let dir = identity_dir();
|
||||
cmd_add(remote.url(), local.url(), Some(dir.path().to_path_buf()))
|
||||
.await
|
||||
.unwrap();
|
||||
_info.assert_async().await;
|
||||
_announce.assert_async().await;
|
||||
}
|
||||
|
||||
// Same fallback for a 200 node-info response whose body is not JSON.
|
||||
#[tokio::test]
|
||||
async fn cmd_add_falls_back_on_malformed_node_info() {
|
||||
let mut local = mockito::Server::new_async().await;
|
||||
let _info = local
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_body("not json")
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let mut remote = mockito::Server::new_async().await;
|
||||
let _announce = announce_ok_mock(&mut remote, &local.url()).await;
|
||||
|
||||
let dir = identity_dir();
|
||||
cmd_add(remote.url(), local.url(), Some(dir.path().to_path_buf()))
|
||||
.await
|
||||
.unwrap();
|
||||
_info.assert_async().await;
|
||||
_announce.assert_async().await;
|
||||
}
|
||||
|
||||
// Must-not case: the announce POST itself stays fail-closed. A denied
|
||||
// announce surfaces as an Err naming the status, never a printed success.
|
||||
#[tokio::test]
|
||||
async fn cmd_add_surfaces_denied_announce() {
|
||||
let mut local = mockito::Server::new_async().await;
|
||||
let _info = local
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"public_url":"https://pub.example"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let mut remote = mockito::Server::new_async().await;
|
||||
let _announce = remote
|
||||
.mock("POST", "/api/v1/peers/announce")
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"denied"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let dir = identity_dir();
|
||||
let err = cmd_add(remote.url(), local.url(), Some(dir.path().to_path_buf()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("403"), "got: {err}");
|
||||
_info.assert_async().await;
|
||||
_announce.assert_async().await;
|
||||
}
|
||||
|
||||
// Success path unchanged: a usable node-info response announces its
|
||||
// public_url; the --node fallback must not leak in.
|
||||
#[tokio::test]
|
||||
async fn cmd_add_uses_public_url_when_lookup_succeeds() {
|
||||
let mut local = mockito::Server::new_async().await;
|
||||
let _info = local
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"public_url":"https://pub.example"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let mut remote = mockito::Server::new_async().await;
|
||||
let _announce = announce_ok_mock(&mut remote, "https://pub.example").await;
|
||||
|
||||
let dir = identity_dir();
|
||||
cmd_add(remote.url(), local.url(), Some(dir.path().to_path_buf()))
|
||||
.await
|
||||
.unwrap();
|
||||
_info.assert_async().await;
|
||||
_announce.assert_async().await;
|
||||
}
|
||||
|
||||
// When the peer's announce response carries a node_url, cmd_add posts the
|
||||
// peer back to the local node's peer list; a successful local POST is the
|
||||
// one case that prints the added line.
|
||||
#[tokio::test]
|
||||
async fn cmd_add_adds_peer_to_local_list_on_success() {
|
||||
let mut local = mockito::Server::new_async().await;
|
||||
let _info = local
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"public_url":"https://pub.example"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let _local_add = local
|
||||
.mock("POST", "/api/v1/peers/announce")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"status":"added"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let mut remote = mockito::Server::new_async().await;
|
||||
let _announce = announce_mock_with_body(
|
||||
&mut remote,
|
||||
"https://pub.example",
|
||||
r#"{"node_did":"their-did","node_url":"http://their.example"}"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
let dir = identity_dir();
|
||||
cmd_add(remote.url(), local.url(), Some(dir.path().to_path_buf()))
|
||||
.await
|
||||
.unwrap();
|
||||
_info.assert_async().await;
|
||||
_announce.assert_async().await;
|
||||
_local_add.assert_async().await;
|
||||
}
|
||||
|
||||
// The local add stays best-effort: a failing local POST must not fail the
|
||||
// command (the announce to the remote peer already succeeded), it only
|
||||
// changes the printed outcome.
|
||||
#[tokio::test]
|
||||
async fn cmd_add_stays_ok_when_local_add_fails() {
|
||||
let mut local = mockito::Server::new_async().await;
|
||||
let _info = local
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"public_url":"https://pub.example"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let _local_add = local
|
||||
.mock("POST", "/api/v1/peers/announce")
|
||||
.with_status(500)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"boom"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let mut remote = mockito::Server::new_async().await;
|
||||
let _announce = announce_mock_with_body(
|
||||
&mut remote,
|
||||
"https://pub.example",
|
||||
r#"{"node_did":"their-did","node_url":"http://their.example"}"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
let dir = identity_dir();
|
||||
cmd_add(remote.url(), local.url(), Some(dir.path().to_path_buf()))
|
||||
.await
|
||||
.unwrap();
|
||||
_info.assert_async().await;
|
||||
_announce.assert_async().await;
|
||||
_local_add.assert_async().await;
|
||||
}
|
||||
}
|
||||
+321
-73
@@ -2,7 +2,6 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Args, Subcommand};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
@@ -233,13 +232,7 @@ async fn cmd_create(
|
||||
.post(&format!("/api/v1/repos/{owner}/{repo}/pulls"), &payload)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let status = resp.status();
|
||||
let pr: Value = resp.json().await.context("invalid JSON")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = pr["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("create PR failed ({status}): {msg}");
|
||||
}
|
||||
let pr = crate::http::read_json(resp, "create PR").await?;
|
||||
|
||||
let number = pr["number"].as_i64().unwrap_or(0);
|
||||
println!("✓ Opened PR #{number}: {title}");
|
||||
@@ -254,12 +247,13 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
let owner = resolve_owner(&keypair);
|
||||
let client = NodeClient::new(&node, None);
|
||||
|
||||
let resp: Value = client
|
||||
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls"))
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("invalid JSON")?;
|
||||
let resp = crate::http::read_json(
|
||||
client
|
||||
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls"))
|
||||
.await?,
|
||||
"pull requests",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let prs = resp["pulls"].as_array().cloned().unwrap_or_default();
|
||||
if prs.is_empty() {
|
||||
@@ -298,12 +292,13 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
|
||||
let owner = resolve_owner(&keypair);
|
||||
let client = NodeClient::new(&node, None);
|
||||
|
||||
let pr: Value = client
|
||||
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}"))
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("invalid JSON")?;
|
||||
let pr = crate::http::read_json(
|
||||
client
|
||||
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}"))
|
||||
.await?,
|
||||
"pull request",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let title = pr["title"].as_str().unwrap_or("?");
|
||||
let status = pr["status"].as_str().unwrap_or("?");
|
||||
@@ -321,15 +316,25 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
|
||||
}
|
||||
|
||||
// Show reviews
|
||||
let reviews: Value = client
|
||||
.get(&format!(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews"
|
||||
))
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("invalid JSON")?;
|
||||
let reviews = reviews["reviews"].as_array().cloned().unwrap_or_default();
|
||||
// A denied sub-fetch degrades this section rather than aborting: the header
|
||||
// above has already printed, so a bare `?` here leaves a half-rendered PR and
|
||||
// skips the comments below it. Mirrors `gl status`'s unavailable-section line.
|
||||
let reviews = match crate::http::read_json(
|
||||
client
|
||||
.get(&format!(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews"
|
||||
))
|
||||
.await?,
|
||||
"reviews",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(v) => v["reviews"].as_array().cloned().unwrap_or_default(),
|
||||
Err(e) => {
|
||||
println!("\nReviews unavailable ({e})");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
if !reviews.is_empty() {
|
||||
println!("\nReviews ({}):", reviews.len());
|
||||
for r in &reviews {
|
||||
@@ -354,15 +359,22 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
|
||||
}
|
||||
|
||||
// Show comments
|
||||
let comments: Value = client
|
||||
.get(&format!(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/comments"
|
||||
))
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("invalid JSON")?;
|
||||
let comments = comments["comments"].as_array().cloned().unwrap_or_default();
|
||||
let comments = match crate::http::read_json(
|
||||
client
|
||||
.get(&format!(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/comments"
|
||||
))
|
||||
.await?,
|
||||
"comments",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(v) => v["comments"].as_array().cloned().unwrap_or_default(),
|
||||
Err(e) => {
|
||||
println!("\nComments unavailable ({e})");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
if !comments.is_empty() {
|
||||
println!("\nComments ({}):", comments.len());
|
||||
for c in &comments {
|
||||
@@ -386,12 +398,13 @@ async fn cmd_diff(repo: String, number: u64, node: String, dir: Option<PathBuf>)
|
||||
let owner = resolve_owner(&keypair);
|
||||
let client = NodeClient::new(&node, None);
|
||||
|
||||
let resp: Value = client
|
||||
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/diff"))
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("invalid JSON")?;
|
||||
let resp = crate::http::read_json(
|
||||
client
|
||||
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/diff"))
|
||||
.await?,
|
||||
"diff",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let diff = resp["diff"].as_str().unwrap_or("");
|
||||
if diff.is_empty() {
|
||||
@@ -415,13 +428,7 @@ async fn cmd_merge(repo: String, number: u64, node: String, dir: Option<PathBuf>
|
||||
)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let status = resp.status();
|
||||
let result: Value = resp.json().await.context("invalid JSON")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = result["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("merge failed ({status}): {msg}");
|
||||
}
|
||||
let result = crate::http::read_json(resp, "merge").await?;
|
||||
|
||||
let sha = result["merge_sha"].as_str().unwrap_or("?");
|
||||
println!("✓ Merged PR #{number}");
|
||||
@@ -453,13 +460,7 @@ async fn cmd_review(
|
||||
)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let code = resp.status();
|
||||
let result: Value = resp.json().await.context("invalid JSON")?;
|
||||
|
||||
if !code.is_success() {
|
||||
let msg = result["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("review failed ({code}): {msg}");
|
||||
}
|
||||
let _ = crate::http::read_json(resp, "review").await?;
|
||||
|
||||
let icon = match status.as_str() {
|
||||
"approved" => "✓",
|
||||
@@ -489,13 +490,7 @@ async fn cmd_comment(
|
||||
)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let code = resp.status();
|
||||
let result: Value = resp.json().await.context("invalid JSON")?;
|
||||
|
||||
if !code.is_success() {
|
||||
let msg = result["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("comment failed ({code}): {msg}");
|
||||
}
|
||||
let _ = crate::http::read_json(resp, "comment").await?;
|
||||
|
||||
println!("· Comment posted on PR #{number}");
|
||||
Ok(())
|
||||
@@ -506,14 +501,15 @@ async fn cmd_comments(repo: String, number: u64, node: String, dir: Option<PathB
|
||||
let owner = resolve_owner(&keypair);
|
||||
let client = NodeClient::new(&node, None);
|
||||
|
||||
let resp: Value = client
|
||||
.get(&format!(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/comments"
|
||||
))
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("invalid JSON")?;
|
||||
let resp = crate::http::read_json(
|
||||
client
|
||||
.get(&format!(
|
||||
"/api/v1/repos/{owner}/{repo}/pulls/{number}/comments"
|
||||
))
|
||||
.await?,
|
||||
"comments",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let comments = resp["comments"].as_array().cloned().unwrap_or_default();
|
||||
if comments.is_empty() {
|
||||
@@ -654,6 +650,7 @@ mod tests {
|
||||
.with_status(422)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"branch not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -671,6 +668,8 @@ mod tests {
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("branch not found"));
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -768,6 +767,7 @@ mod tests {
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"PR not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -781,6 +781,8 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("PR not found"), "got: {err}");
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -865,4 +867,250 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// ── Gated PR reads surface node denials, not empty/stub renders (#123) ──
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_list_surfaces_denial_not_empty() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/pulls$".to_string()))
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repository not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let result = cmd_list(
|
||||
"myrepo".to_string(),
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"cmd_list must Err on 404, not print 'No pull requests'"
|
||||
);
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_view_surfaces_denial_not_stub() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// The PR fetch is first; a 404 there errors before the reviews/comments reads.
|
||||
let _m = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/pulls/1$".to_string()))
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repository not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let result = cmd_view(
|
||||
"myrepo".to_string(),
|
||||
1,
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"cmd_view must Err on 404, not print a stub PR"
|
||||
);
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_diff_surfaces_denial_not_empty() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/pulls/1/diff$".to_string()),
|
||||
)
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repository not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let result = cmd_diff(
|
||||
"myrepo".to_string(),
|
||||
1,
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"cmd_diff must Err on 404, not print 'No diff'"
|
||||
);
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_comments_surfaces_denial_not_empty() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/pulls/1/comments$".to_string()),
|
||||
)
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repository not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let result = cmd_comments(
|
||||
"myrepo".to_string(),
|
||||
1,
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err(), "cmd_comments must Err on a gated 404");
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
/// A denied sub-fetch must not abort `gl pr view` after the header has
|
||||
/// already printed. The header is fatal; reviews and comments degrade.
|
||||
#[tokio::test]
|
||||
async fn cmd_view_continues_when_reviews_are_denied() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _pr = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/pulls/1$".to_string()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"number":1,"title":"T","status":"open","source_branch":"a","target_branch":"main","author_did":"did:key:z6MkTest"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let _reviews = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/pulls/1/reviews$".to_string()),
|
||||
)
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"forbidden"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
// The load-bearing assertion: this mock is only hit if rendering
|
||||
// continued past the denied reviews fetch.
|
||||
let _comments = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/pulls/1/comments$".to_string()),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"comments":[]}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let r = cmd_view(
|
||||
"myrepo".to_string(),
|
||||
1,
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
r.is_ok(),
|
||||
"cmd_view must finish rendering when reviews are denied"
|
||||
);
|
||||
_comments.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_view_continues_when_comments_are_denied() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _pr = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/pulls/1$".to_string()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"number":1,"title":"T","status":"open","source_branch":"a","target_branch":"main","author_did":"did:key:z6MkTest"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let _reviews = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/pulls/1/reviews$".to_string()),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"reviews":[]}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let _comments = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/pulls/1/comments$".to_string()),
|
||||
)
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"forbidden"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let r = cmd_view(
|
||||
"myrepo".to_string(),
|
||||
1,
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
r.is_ok(),
|
||||
"cmd_view must finish rendering when comments are denied"
|
||||
);
|
||||
_comments.assert_async().await;
|
||||
}
|
||||
|
||||
/// The must-not case: with no header there is nothing to render partially,
|
||||
/// so a denied header stays fatal. Softening it would resurrect
|
||||
/// denial-as-success for the whole command.
|
||||
#[tokio::test]
|
||||
async fn cmd_view_header_denial_stays_fatal() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _pr = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"/pulls/1$".to_string()))
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let r = cmd_view(
|
||||
"myrepo".to_string(),
|
||||
1,
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await;
|
||||
assert!(r.is_err(), "a denied PR header must stay fatal");
|
||||
_pr.assert_async().await;
|
||||
}
|
||||
}
|
||||
+29
-28
@@ -2,7 +2,6 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Args, Subcommand};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
@@ -82,12 +81,7 @@ async fn resolve_owner_repo(
|
||||
did.split(':').next_back().unwrap_or(&did).to_string()
|
||||
} else {
|
||||
let client = NodeClient::new(node, None);
|
||||
let info: Value = client
|
||||
.get("/")
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to fetch node info")?;
|
||||
let info = crate::http::read_json(client.get("/").await?, "node info").await?;
|
||||
let did = info["did"].as_str().context("node missing DID")?;
|
||||
did.split(':').next_back().unwrap_or(did).to_string()
|
||||
};
|
||||
@@ -108,13 +102,7 @@ async fn cmd_set(branch: String, repo: String, node: String, dir: Option<PathBuf
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("protect failed ({status}): {msg}");
|
||||
}
|
||||
let _ = crate::http::read_json(resp, "protect").await?;
|
||||
|
||||
println!("✓ Branch '{branch}' is now protected in {owner}/{name}");
|
||||
println!(" Only the repo owner can push to this branch.");
|
||||
@@ -140,13 +128,7 @@ async fn cmd_remove(
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("unprotect failed ({status}): {msg}");
|
||||
}
|
||||
let _ = crate::http::read_json(resp, "unprotect").await?;
|
||||
|
||||
println!("✓ Branch '{branch}' is no longer protected in {owner}/{name}");
|
||||
Ok(())
|
||||
@@ -163,13 +145,7 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("list protected branches failed ({status}): {msg}");
|
||||
}
|
||||
let body = crate::http::read_json(resp, "list protected branches").await?;
|
||||
|
||||
let branches = body["protected_branches"]
|
||||
.as_array()
|
||||
@@ -247,6 +223,7 @@ mod tests {
|
||||
.with_status(400)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"only the repo owner can protect branches"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -259,6 +236,8 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("protect failed"));
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -370,4 +349,26 @@ mod tests {
|
||||
assert_eq!(owner, "alice");
|
||||
assert_eq!(name, "myrepo");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_owner_repo_surfaces_denial() {
|
||||
// A slash-free repo with an empty identity dir forces the GET / node-info
|
||||
// fetch. A gated 404 there must Err (surfacing the status), proving the
|
||||
// read_json conversion is load-bearing rather than silently ignored.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let dir = tempfile::TempDir::new().unwrap(); // empty, no identity.pem, forces the GET / branch
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"denied"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let err = resolve_owner_repo("noslash", &server.url(), Some(dir.path()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("404"), "got: {err}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Args;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
@@ -55,16 +55,7 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
|
||||
let status = resp.status();
|
||||
let payload: Value = resp.json().await.context("invalid JSON response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = payload
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown error");
|
||||
anyhow::bail!("registration failed ({status}): {msg}");
|
||||
}
|
||||
let payload = crate::http::read_json(resp, "registration").await?;
|
||||
|
||||
// Save bootstrap UCAN
|
||||
let ucan = payload.get("ucan").and_then(|v| v.as_str()).unwrap_or("");
|
||||
@@ -171,6 +162,7 @@ mod tests {
|
||||
.with_status(401)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"invalid signature"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -187,6 +179,8 @@ mod tests {
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("invalid signature"));
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+164
-83
@@ -251,13 +251,7 @@ async fn cmd_create(
|
||||
.post("/api/v1/repos", &body)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let status = resp.status();
|
||||
let payload: Value = resp.json().await.context("invalid JSON response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = payload["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("create failed ({status}): {msg}");
|
||||
}
|
||||
let payload = crate::http::read_json(resp, "create").await?;
|
||||
|
||||
let clone_url = payload["clone_url"].as_str().unwrap_or("");
|
||||
let gitlawb_url = format!("gitlawb://{owner_did}/{name}");
|
||||
@@ -277,12 +271,7 @@ async fn cmd_list(node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
let client = NodeClient::new(&node, None);
|
||||
|
||||
let url = format!("/api/v1/repos?owner={owner}");
|
||||
let repos: Value = client
|
||||
.get(&url)
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to list repos")?;
|
||||
let repos = crate::http::read_json(client.get(&url).await?, "list repos").await?;
|
||||
let repos = repos.as_array().context("expected array")?;
|
||||
|
||||
if repos.is_empty() {
|
||||
@@ -351,20 +340,13 @@ async fn cmd_info(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
// A non-existent (or unreadable/quarantined) repo is a real 404 from the
|
||||
// node — surface it plainly instead of printing a stub card with `?` fields
|
||||
// and a placeholder owner DID.
|
||||
if !resp.status().is_success() {
|
||||
if resp.status().as_u16() == 404 {
|
||||
anyhow::bail!("repository '{owner}/{name}' not found");
|
||||
}
|
||||
let status = resp.status();
|
||||
let msg = resp
|
||||
.json::<Value>()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|v| v["message"].as_str().map(String::from))
|
||||
.unwrap_or_else(|| "request failed".to_string());
|
||||
anyhow::bail!("repo info failed ({status}): {msg}");
|
||||
if resp.status().as_u16() == 404 {
|
||||
anyhow::bail!("repository '{owner}/{name}' not found");
|
||||
}
|
||||
let r: Value = resp.json().await.context("parse repo info")?;
|
||||
// Every other status routes through read_json: a 2xx yields the parsed body,
|
||||
// any other non-2xx yields the node's capped + sanitized message, and
|
||||
// the error read is bounded rather than buffering the whole hostile body.
|
||||
let r = crate::http::read_json(resp, "repo info").await?;
|
||||
|
||||
let owner_did = r["owner_did"].as_str().unwrap_or(&owner);
|
||||
let gitlawb_url = format!("gitlawb://{owner_did}/{name}");
|
||||
@@ -423,12 +405,7 @@ async fn cmd_replica_register(
|
||||
.await
|
||||
.context("failed to connect to origin node")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("replica register failed ({status}): {msg}");
|
||||
}
|
||||
let body = crate::http::read_json(resp, "replica register").await?;
|
||||
|
||||
let count = body["replica_count"].as_i64().unwrap_or(0);
|
||||
println!("Registered as replica of {owner}/{name}");
|
||||
@@ -455,12 +432,7 @@ async fn cmd_replica_unregister(repo: String, node: String, dir: Option<PathBuf>
|
||||
.await
|
||||
.context("failed to connect to origin node")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("replica unregister failed ({status}): {msg}");
|
||||
}
|
||||
let body = crate::http::read_json(resp, "replica unregister").await?;
|
||||
|
||||
let count = body["replica_count"].as_i64().unwrap_or(0);
|
||||
println!("Unregistered as replica of {owner}/{name} ({count} replicas remaining)");
|
||||
@@ -480,12 +452,7 @@ async fn cmd_replicas(repo: String, node: String, dir: Option<PathBuf>) -> Resul
|
||||
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}/replicas"))
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("replicas list failed ({status}): {msg}");
|
||||
}
|
||||
let body = crate::http::read_json(resp, "replicas list").await?;
|
||||
|
||||
let count = body["replica_count"].as_i64().unwrap_or(0);
|
||||
println!("{owner}/{name}: {count} replicas");
|
||||
@@ -521,12 +488,7 @@ pub(crate) async fn cmd_commits(
|
||||
};
|
||||
|
||||
let url = format!("/api/v1/repos/{owner}/{name}/commits?branch={branch}&limit={limit}");
|
||||
let resp: Value = client
|
||||
.get(&url)
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to fetch commits")?;
|
||||
let resp = crate::http::read_json(client.get(&url).await?, "commits").await?;
|
||||
|
||||
let commits = resp["commits"].as_array().cloned().unwrap_or_default();
|
||||
if commits.is_empty() {
|
||||
@@ -579,13 +541,7 @@ async fn cmd_fork(
|
||||
.post(&format!("/api/v1/repos/{owner}/{repo_name}/fork"), &body)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let status = resp.status();
|
||||
let result: Value = resp.json().await.context("invalid JSON response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = result["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("fork failed ({status}): {msg}");
|
||||
}
|
||||
let result = crate::http::read_json(resp, "fork").await?;
|
||||
|
||||
let fork_name = result["name"].as_str().unwrap_or(&repo_name);
|
||||
let owner_did = result["owner_did"].as_str().unwrap_or("?");
|
||||
@@ -623,13 +579,7 @@ async fn cmd_label_add(
|
||||
.post(&format!("/api/v1/repos/{owner}/{name}/labels"), &body)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let status = resp.status();
|
||||
let result: Value = resp.json().await.context("invalid JSON")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = result["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("add label failed ({status}): {msg}");
|
||||
}
|
||||
let result = crate::http::read_json(resp, "add label").await?;
|
||||
|
||||
let added = result["added"].as_bool().unwrap_or(true);
|
||||
if added {
|
||||
@@ -654,13 +604,7 @@ async fn cmd_label_remove(
|
||||
.delete(&format!("/api/v1/repos/{owner}/{name}/labels/{label}"), &[])
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let status = resp.status();
|
||||
let result: Value = resp.json().await.context("invalid JSON")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = result["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("remove label failed ({status}): {msg}");
|
||||
}
|
||||
let _ = crate::http::read_json(resp, "remove label").await?;
|
||||
|
||||
println!("- Label removed: {label} from {owner}/{name}");
|
||||
Ok(())
|
||||
@@ -673,18 +617,21 @@ async fn cmd_label_list(repo: String, node: String, dir: Option<PathBuf>) -> Res
|
||||
// read their own labels, while public repos stay anonymously listable.
|
||||
let client = NodeClient::new(&node, load_keypair_from_dir(dir.as_deref()).ok());
|
||||
|
||||
let resp = client
|
||||
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}/labels"))
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("list labels failed ({status}): {msg}");
|
||||
}
|
||||
// Marry both refactors: main's get_maybe_signed (read-visibility gating —
|
||||
// public repos stay anon-listable, private-repo owner signs) carrying the
|
||||
// PR's read_json status-check-before-parse (#186: bounded error read, no
|
||||
// parse-before-status). get_authed from the PR is dropped in favor of
|
||||
// get_maybe_signed so the visibility gate is preserved.
|
||||
let resp = crate::http::read_json(
|
||||
client
|
||||
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}/labels"))
|
||||
.await
|
||||
.context("failed to connect to node")?,
|
||||
"list labels",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let labels = body["labels"].as_array().cloned().unwrap_or_default();
|
||||
let labels = resp["labels"].as_array().cloned().unwrap_or_default();
|
||||
if labels.is_empty() {
|
||||
println!("No labels on {owner}/{name}");
|
||||
} else {
|
||||
@@ -857,6 +804,7 @@ mod tests {
|
||||
.with_status(409)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repository already exists"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -872,6 +820,8 @@ mod tests {
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("already exists"));
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -940,6 +890,33 @@ mod tests {
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_list_repos_surfaces_denial() {
|
||||
// A node error must Err with the status, not trip the vague "expected
|
||||
// array" fallback that hides the node's actual response.
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"^/api/v1/repos\?owner=".to_string()),
|
||||
)
|
||||
.with_status(500)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"boom"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_list(server.url(), Some(dir.path().to_path_buf()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("500"), "got: {err}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_commits_empty() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -1039,6 +1016,7 @@ mod tests {
|
||||
.with_status(400)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"you already have a repo named myrepo"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -1054,6 +1032,8 @@ mod tests {
|
||||
err.to_string().contains("already have a repo"),
|
||||
"got: {err}"
|
||||
);
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1426,6 +1406,7 @@ mod tests {
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -1438,6 +1419,106 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("not found"), "got: {err}");
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
|
||||
_repo.assert_async().await;
|
||||
}
|
||||
|
||||
// ── Gated CLI reads surface node denials, not empty renders (#123) ──
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_commits_surfaces_denial_not_empty() {
|
||||
// A gated 404 must Err, not print "No commits" as if the repo were empty.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"^/api/v1/repos/alice/secret/commits".to_string()),
|
||||
)
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repository 'alice/secret' not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let result = cmd_commits(
|
||||
"alice/secret".to_string(),
|
||||
"main".to_string(),
|
||||
20,
|
||||
server.url(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err(), "cmd_commits must Err on 404");
|
||||
assert!(result.unwrap_err().to_string().contains("not found"));
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_label_list_surfaces_denial_not_empty() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"^/api/v1/repos/alice/secret/labels".to_string()),
|
||||
)
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repository 'alice/secret' not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let result = cmd_label_list("alice/secret".to_string(), server.url(), None).await;
|
||||
assert!(result.is_err(), "cmd_label_list must Err on 404");
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy is_err() vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
// cmd_info keeps its bespoke 404 message ahead of read_json...
|
||||
#[tokio::test]
|
||||
async fn cmd_info_404_keeps_bespoke_message() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/api/v1/repos/alice/secret")
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"gone"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let err = cmd_info("alice/secret".to_string(), server.url(), None)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
err.contains("repository 'alice/secret' not found"),
|
||||
"bespoke 404 text lost: {err}"
|
||||
);
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
// ...and every other non-2xx routes through read_json (capped + sanitized).
|
||||
#[tokio::test]
|
||||
async fn cmd_info_500_routes_through_read_json() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/api/v1/repos/alice/secret")
|
||||
.with_status(500)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"boom"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let err = cmd_info("alice/secret".to_string(), server.url(), None)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
err.contains("repo info failed"),
|
||||
"read_json label lost: {err}"
|
||||
);
|
||||
assert!(err.contains("500"), "status not surfaced: {err}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1636,7 +1717,7 @@ mod vet_merge_deny_arms {
|
||||
}
|
||||
|
||||
// An authenticated caller DENIED with a 403 must surface an error, never
|
||||
// render a repo card (deny-as-success is the INV-8 client-contract break).
|
||||
// render a repo card (deny-as-success is the client-contract break).
|
||||
#[tokio::test]
|
||||
async fn cmd_info_403_denied_surfaces_error_not_card() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
+56
-22
@@ -2,7 +2,6 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Args, Subcommand};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
@@ -75,13 +74,7 @@ async fn cmd_add(repo: String, node: String, dir: Option<PathBuf>) -> Result<()>
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("star failed ({status}): {msg}");
|
||||
}
|
||||
let body = crate::http::read_json(resp, "star").await?;
|
||||
|
||||
let count = body["star_count"].as_i64().unwrap_or(0);
|
||||
println!("Starred {owner}/{name} ({count} stars total)");
|
||||
@@ -99,13 +92,7 @@ async fn cmd_remove(repo: String, node: String, dir: Option<PathBuf>) -> Result<
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("unstar failed ({status}): {msg}");
|
||||
}
|
||||
let body = crate::http::read_json(resp, "unstar").await?;
|
||||
|
||||
let count = body["star_count"].as_i64().unwrap_or(0);
|
||||
println!("Unstarred {owner}/{name} ({count} stars remaining)");
|
||||
@@ -124,13 +111,7 @@ async fn cmd_count(repo: String, node: String, dir: Option<PathBuf>) -> Result<(
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("star count failed ({status}): {msg}");
|
||||
}
|
||||
let body = crate::http::read_json(resp, "star count").await?;
|
||||
|
||||
let count = body["star_count"].as_i64().unwrap_or(0);
|
||||
println!("{owner}/{name}: {count} stars");
|
||||
@@ -230,6 +211,7 @@ mod tests {
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repo not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -241,6 +223,8 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("star failed"));
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -287,6 +271,7 @@ mod tests {
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"repo not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -298,6 +283,8 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("unstar failed"));
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -335,4 +322,51 @@ mod tests {
|
||||
assert_eq!(owner, "alice");
|
||||
assert_eq!(name, "myrepo");
|
||||
}
|
||||
|
||||
// U15 (#186): a converted handler must inherit read_json's capped + sanitized
|
||||
// error path end-to-end. A hostile 500 whose `message` carries terminal-control
|
||||
// + bidi bytes and is long must reach the terminal neither verbatim nor unbounded.
|
||||
#[tokio::test]
|
||||
async fn cmd_add_hostile_error_is_sanitized_and_bounded() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(
|
||||
dir.path().join("identity.pem"),
|
||||
kp.to_pem().unwrap().as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// ESC + a long run + a right-to-left override, all inside the node message
|
||||
// (JSON \u escapes so the source has no raw control bytes).
|
||||
let hostile = format!(r#"{{"message":"a\u001b[31m{}b\u202ec"}}"#, "Z".repeat(500));
|
||||
let _m = server
|
||||
.mock("PUT", mockito::Matcher::Regex(r"/star$".to_string()))
|
||||
.with_status(500)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(hostile)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_add(
|
||||
"myrepo".to_string(),
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(
|
||||
!err.contains('\u{1b}'),
|
||||
"ESC leaked to the terminal: {err:?}"
|
||||
);
|
||||
assert!(!err.contains('\u{202e}'), "bidi override leaked: {err:?}");
|
||||
// Bounded: sanitize_node_msg caps the surfaced message, so the 500-char run
|
||||
// cannot flood the error string.
|
||||
assert!(err.len() < 300, "error not bounded ({} bytes)", err.len());
|
||||
assert!(err.contains("star failed"), "handler label lost: {err}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
}
|
||||
+166
-15
@@ -37,17 +37,9 @@ pub async fn run(args: StatusArgs) -> Result<()> {
|
||||
let client = NodeClient::new(&args.node, None);
|
||||
if let Some(ref did) = maybe_did {
|
||||
let short_key = did.split(':').next_back().unwrap_or(did);
|
||||
match client.get(&format!("/api/v1/agents/{short_key}")).await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
if let Ok(body) = resp.json::<Value>().await {
|
||||
let score = body["trust_score"].as_f64().unwrap_or(0.0);
|
||||
let bar = trust_bar(score);
|
||||
println!(" trust {score:.2} {bar}");
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
println!(" trust — not registered (run `gl register`)");
|
||||
}
|
||||
let resp = client.get(&format!("/api/v1/agents/{short_key}")).await;
|
||||
if let Some(line) = trust_line(resp).await {
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,8 +73,10 @@ pub async fn run(args: StatusArgs) -> Result<()> {
|
||||
let pr_resp = client
|
||||
.get(&format!("/api/v1/repos/{short_owner}/{repo_name}/pulls"))
|
||||
.await;
|
||||
if let Ok(r) = pr_resp {
|
||||
if let Ok(body) = r.json::<Value>().await {
|
||||
if let Some(line) = section_unavailable_line("PRs", &pr_resp) {
|
||||
println!("{line}");
|
||||
} else if let Ok(r) = pr_resp {
|
||||
if let Ok(body) = crate::http::read_json(r, "pull requests").await {
|
||||
let prs = body["pulls"].as_array().cloned().unwrap_or_default();
|
||||
let open: Vec<_> = prs
|
||||
.iter()
|
||||
@@ -108,8 +102,10 @@ pub async fn run(args: StatusArgs) -> Result<()> {
|
||||
let issue_resp = client
|
||||
.get(&format!("/api/v1/repos/{short_owner}/{repo_name}/issues"))
|
||||
.await;
|
||||
if let Ok(r) = issue_resp {
|
||||
if let Ok(body) = r.json::<Value>().await {
|
||||
if let Some(line) = section_unavailable_line("issues", &issue_resp) {
|
||||
println!("{line}");
|
||||
} else if let Ok(r) = issue_resp {
|
||||
if let Ok(body) = crate::http::read_json(r, "issues").await {
|
||||
let issues = body["issues"].as_array().cloned().unwrap_or_default();
|
||||
let open: Vec<_> = issues
|
||||
.iter()
|
||||
@@ -136,6 +132,40 @@ pub async fn run(args: StatusArgs) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The status line for a repo section (PRs / issues) whose gated read returned a
|
||||
/// non-2xx: the denial must surface, never render as "no open ...".
|
||||
/// Returns `None` for a success (the caller renders the body) or a transport error
|
||||
/// (the section degrades silently — R5 — so the multi-section `gl status` never
|
||||
/// hard-fails on one denied read).
|
||||
fn section_unavailable_line(label: &str, resp: &Result<reqwest::Response>) -> Option<String> {
|
||||
match resp {
|
||||
Ok(r) if !r.status().is_success() => {
|
||||
Some(format!(" {label:<10}unavailable ({})", r.status()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `trust` status line for the caller's own identity. A genuine 404 means the
|
||||
/// identity is not registered; any OTHER non-2xx (403/429/5xx) is a failed lookup
|
||||
/// and must surface as unavailable rather than fabricating an unregistered state
|
||||
/// A transport error degrades silently — the node-unreachable line below
|
||||
/// already surfaces it — matching `section_unavailable_line`.
|
||||
async fn trust_line(resp: Result<reqwest::Response>) -> Option<String> {
|
||||
match resp {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
let body = r.json::<Value>().await.ok()?;
|
||||
let score = body["trust_score"].as_f64().unwrap_or(0.0);
|
||||
Some(format!(" trust {score:.2} {}", trust_bar(score)))
|
||||
}
|
||||
Ok(r) if r.status() == reqwest::StatusCode::NOT_FOUND => {
|
||||
Some(" trust — not registered (run `gl register`)".to_string())
|
||||
}
|
||||
Ok(r) => Some(format!(" trust unavailable ({})", r.status())),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a simple ASCII trust bar: 0.75 → "███░"
|
||||
fn trust_bar(score: f64) -> String {
|
||||
let filled = (score * 4.0).round() as usize;
|
||||
@@ -376,4 +406,125 @@ mod tests {
|
||||
fn trust_bar_quarter() {
|
||||
assert_eq!(trust_bar(0.25), "█░░░");
|
||||
}
|
||||
|
||||
// ── gl status section denial surfacing (#123, R5) ────────────────────
|
||||
|
||||
async fn get_response(
|
||||
server: &mut mockito::Server,
|
||||
status: usize,
|
||||
) -> Result<reqwest::Response> {
|
||||
let _m = server
|
||||
.mock("GET", "/x")
|
||||
.with_status(status)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"pulls":[]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
NodeClient::new(server.url(), None).get("/x").await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gated_section_surfaces_unavailable_not_empty() {
|
||||
// A gated 404 on a status section must surface "unavailable", never be
|
||||
// treated as "no open PRs".
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let resp = get_response(&mut server, 404).await;
|
||||
assert_eq!(
|
||||
section_unavailable_line("PRs", &resp),
|
||||
Some(format!(" {:<10}unavailable (404 Not Found)", "PRs"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn success_section_returns_none_for_body_render() {
|
||||
// A 2xx returns None so the caller renders the body as before.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let resp = get_response(&mut server, 200).await;
|
||||
assert!(section_unavailable_line("issues", &resp).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_error_degrades_silently() {
|
||||
// A transport error (not a status) must NOT surface — the section
|
||||
// degrades silently so gl status never hard-fails on one bad read (R5).
|
||||
let err: Result<reqwest::Response> = Err(anyhow::anyhow!("connection refused"));
|
||||
assert!(section_unavailable_line("PRs", &err).is_none());
|
||||
}
|
||||
|
||||
// ── trust_line: a 404 means unregistered; any OTHER failure must surface as
|
||||
// unavailable, never fabricate an unregistered verdict. ───────────────
|
||||
async fn agents_resp(
|
||||
server: &mut mockito::Server,
|
||||
status: usize,
|
||||
body: &str,
|
||||
) -> Result<reqwest::Response> {
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"^/api/v1/agents/".to_string()),
|
||||
)
|
||||
.with_status(status)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(body)
|
||||
.create_async()
|
||||
.await;
|
||||
NodeClient::new(server.url(), None)
|
||||
.get("/api/v1/agents/zTest")
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trust_line_registered_shows_score_only() {
|
||||
let mut s = mockito::Server::new_async().await;
|
||||
let line = trust_line(agents_resp(&mut s, 200, r#"{"trust_score":0.5}"#).await)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(line.contains("0.50"), "line={line}");
|
||||
assert!(!line.contains("not registered"), "line={line}");
|
||||
assert!(!line.contains("unavailable"), "line={line}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trust_line_404_is_not_registered() {
|
||||
let mut s = mockito::Server::new_async().await;
|
||||
let line = trust_line(agents_resp(&mut s, 404, r#"{"message":"agent not found"}"#).await)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(line.contains("not registered"), "line={line}");
|
||||
assert!(!line.contains("unavailable"), "line={line}");
|
||||
}
|
||||
|
||||
// Load-bearing: a 403 for an existing identity is a denied lookup, not proof it is unregistered.
|
||||
#[tokio::test]
|
||||
async fn trust_line_403_is_unavailable_not_registered() {
|
||||
let mut s = mockito::Server::new_async().await;
|
||||
let line = trust_line(agents_resp(&mut s, 403, r#"{"message":"forbidden"}"#).await)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
line.contains("unavailable") && line.contains("403"),
|
||||
"line={line}"
|
||||
);
|
||||
assert!(!line.contains("not registered"), "line={line}");
|
||||
}
|
||||
|
||||
// Load-bearing: a 5xx is a failed lookup, not an unregistered verdict.
|
||||
#[tokio::test]
|
||||
async fn trust_line_500_is_unavailable_not_registered() {
|
||||
let mut s = mockito::Server::new_async().await;
|
||||
let line = trust_line(agents_resp(&mut s, 500, r#"{"message":"boom"}"#).await)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(line.contains("unavailable"), "line={line}");
|
||||
assert!(!line.contains("not registered"), "line={line}");
|
||||
}
|
||||
|
||||
// Load-bearing: a transport error degrades silently, never fabricates unregistered.
|
||||
#[tokio::test]
|
||||
async fn trust_line_transport_error_is_silent() {
|
||||
let resp = NodeClient::new("http://127.0.0.1:1", None)
|
||||
.get("/api/v1/agents/zTest")
|
||||
.await;
|
||||
assert!(trust_line(resp).await.is_none());
|
||||
}
|
||||
}
|
||||
+82
-2
@@ -71,8 +71,9 @@ pub async fn run(args: SyncArgs) -> Result<()> {
|
||||
SyncCmd::Status => {
|
||||
let client = NodeClient::new(&args.node, None);
|
||||
// Just show peer list and node stats for now
|
||||
let stats: serde_json::Value = client.get("/api/v1/stats").await?.json().await?;
|
||||
let peers: serde_json::Value = client.get("/api/v1/peers").await?.json().await?;
|
||||
let stats =
|
||||
crate::http::read_json(client.get("/api/v1/stats").await?, "node stats").await?;
|
||||
let peers = crate::http::read_json(client.get("/api/v1/peers").await?, "peers").await?;
|
||||
println!("Node stats:");
|
||||
println!(" repos: {}", stats["repos"].as_i64().unwrap_or(0));
|
||||
println!(" agents: {}", stats["agents"].as_i64().unwrap_or(0));
|
||||
@@ -158,6 +159,7 @@ mod tests {
|
||||
// Valid JSON: the parse-without-status-check bug deserializes this
|
||||
// into a zero-count success struct and prints "✓ sync triggered".
|
||||
.with_body(r#"{"message":"unauthorized"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let (args, _dir) = trigger_args(server.url());
|
||||
@@ -166,6 +168,8 @@ mod tests {
|
||||
err.to_string().contains("401"),
|
||||
"expected 401 surfaced, got: {err}"
|
||||
);
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -176,6 +180,7 @@ mod tests {
|
||||
.with_status(429)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"slow down"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let (args, _dir) = trigger_args(server.url());
|
||||
@@ -184,6 +189,8 @@ mod tests {
|
||||
err.to_string().contains("429"),
|
||||
"expected 429 surfaced, got: {err}"
|
||||
);
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -200,6 +207,7 @@ mod tests {
|
||||
// BEL (\u0007); serde decodes them to real control bytes a naive
|
||||
// client would print. (The status-check bug fake-successes here.)
|
||||
.with_body("{\"message\":\"pwned\\u001b[31m\\u0007bad\"}")
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let (args, _dir) = trigger_args(server.url());
|
||||
@@ -211,6 +219,8 @@ mod tests {
|
||||
s.contains("pwned") && s.contains("bad"),
|
||||
"message text dropped: {s:?}"
|
||||
);
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -236,6 +246,7 @@ mod tests {
|
||||
.mock("POST", "/api/v1/sync/trigger")
|
||||
.with_status(401)
|
||||
.with_body("A".repeat(2_000_000))
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let (args, _dir) = trigger_args(server.url());
|
||||
@@ -247,6 +258,8 @@ mod tests {
|
||||
"error message not bounded: {} chars",
|
||||
s.len()
|
||||
);
|
||||
// Prove the mocked route was actually requested; a non-matching request (mockito's 501, also non-2xx) would otherwise satisfy the error assertion vacuously.
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -277,4 +290,71 @@ mod tests {
|
||||
(0, 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_surfaces_stats_denial_not_fake_zeros() {
|
||||
// A node error on /stats must Err, not print "0 repos / 0 agents / 0 pushes".
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let stats = server
|
||||
.mock("GET", "/api/v1/stats")
|
||||
.with_status(500)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"boom"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
// A peers mock so the pre-fix path reaches a clean success rather than a
|
||||
// 501 on an unmocked route; after the fix the /stats denial bails first.
|
||||
let _peers = server
|
||||
.mock("GET", "/api/v1/peers")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"count":0,"peers":[]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let args = SyncArgs {
|
||||
cmd: SyncCmd::Status,
|
||||
node: server.url(),
|
||||
dir: None,
|
||||
};
|
||||
let err = run(args).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("500"),
|
||||
"expected 500 surfaced, got: {err}"
|
||||
);
|
||||
stats.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_surfaces_peers_denial() {
|
||||
// /stats succeeds but /peers denies — the peers read must Err too, not
|
||||
// print "Known peers: 0".
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _stats = server
|
||||
.mock("GET", "/api/v1/stats")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"repos":1,"agents":2,"pushes":3}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let peers = server
|
||||
.mock("GET", "/api/v1/peers")
|
||||
.with_status(500)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"boom"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let args = SyncArgs {
|
||||
cmd: SyncCmd::Status,
|
||||
node: server.url(),
|
||||
dir: None,
|
||||
};
|
||||
let err = run(args).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("500"),
|
||||
"expected 500 surfaced, got: {err}"
|
||||
);
|
||||
peers.assert_async().await;
|
||||
}
|
||||
}
|
||||
+176
-47
@@ -166,13 +166,14 @@ async fn cmd_create(
|
||||
"delegator_did": delegator_did,
|
||||
}))?;
|
||||
|
||||
let resp: Value = client
|
||||
.post("/api/v1/tasks", &body)
|
||||
.await
|
||||
.context("failed to create task")?
|
||||
.json()
|
||||
.await
|
||||
.context("invalid JSON response")?;
|
||||
let resp = crate::http::read_json(
|
||||
client
|
||||
.post("/api/v1/tasks", &body)
|
||||
.await
|
||||
.context("failed to create task")?,
|
||||
"create task",
|
||||
)
|
||||
.await?;
|
||||
print_json(&resp);
|
||||
Ok(())
|
||||
}
|
||||
@@ -191,26 +192,25 @@ async fn cmd_list(
|
||||
if let Some(a) = &assignee_did {
|
||||
path.push_str(&format!("&assignee_did={}", urlencoding::encode(a)));
|
||||
}
|
||||
let resp: Value = client
|
||||
.get(&path)
|
||||
.await
|
||||
.context("failed to list tasks")?
|
||||
.json()
|
||||
.await
|
||||
.context("invalid JSON response")?;
|
||||
let resp = crate::http::read_json(
|
||||
client.get(&path).await.context("failed to list tasks")?,
|
||||
"list tasks",
|
||||
)
|
||||
.await?;
|
||||
print_json(&resp);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_view(id: String, node: String) -> Result<()> {
|
||||
let client = NodeClient::new(&node, None);
|
||||
let resp: Value = client
|
||||
.get(&format!("/api/v1/tasks/{}", id))
|
||||
.await
|
||||
.context("failed to get task")?
|
||||
.json()
|
||||
.await
|
||||
.context("invalid JSON response")?;
|
||||
let resp = crate::http::read_json(
|
||||
client
|
||||
.get(&format!("/api/v1/tasks/{}", id))
|
||||
.await
|
||||
.context("failed to get task")?,
|
||||
"view task",
|
||||
)
|
||||
.await?;
|
||||
print_json(&resp);
|
||||
Ok(())
|
||||
}
|
||||
@@ -221,13 +221,14 @@ async fn cmd_claim(id: String, node: String, dir: Option<PathBuf>) -> Result<()>
|
||||
let client = NodeClient::new(&node, Some(keypair));
|
||||
|
||||
let body = serde_json::to_vec(&json!({ "assignee_did": assignee_did }))?;
|
||||
let resp: Value = client
|
||||
.post(&format!("/api/v1/tasks/{}/claim", id), &body)
|
||||
.await
|
||||
.context("failed to claim task")?
|
||||
.json()
|
||||
.await
|
||||
.context("invalid JSON response")?;
|
||||
let resp = crate::http::read_json(
|
||||
client
|
||||
.post(&format!("/api/v1/tasks/{}/claim", id), &body)
|
||||
.await
|
||||
.context("failed to claim task")?,
|
||||
"claim task",
|
||||
)
|
||||
.await?;
|
||||
print_json(&resp);
|
||||
Ok(())
|
||||
}
|
||||
@@ -243,13 +244,14 @@ async fn cmd_complete(
|
||||
let client = NodeClient::new(&node, Some(keypair));
|
||||
|
||||
let body = serde_json::to_vec(&json!({ "result": result, "by_did": by_did }))?;
|
||||
let resp: Value = client
|
||||
.post(&format!("/api/v1/tasks/{}/complete", id), &body)
|
||||
.await
|
||||
.context("failed to complete task")?
|
||||
.json()
|
||||
.await
|
||||
.context("invalid JSON response")?;
|
||||
let resp = crate::http::read_json(
|
||||
client
|
||||
.post(&format!("/api/v1/tasks/{}/complete", id), &body)
|
||||
.await
|
||||
.context("failed to complete task")?,
|
||||
"complete task",
|
||||
)
|
||||
.await?;
|
||||
print_json(&resp);
|
||||
Ok(())
|
||||
}
|
||||
@@ -265,13 +267,14 @@ async fn cmd_fail(
|
||||
let client = NodeClient::new(&node, Some(keypair));
|
||||
|
||||
let body = serde_json::to_vec(&json!({ "reason": reason, "by_did": by_did }))?;
|
||||
let resp: Value = client
|
||||
.post(&format!("/api/v1/tasks/{}/fail", id), &body)
|
||||
.await
|
||||
.context("failed to fail task")?
|
||||
.json()
|
||||
.await
|
||||
.context("invalid JSON response")?;
|
||||
let resp = crate::http::read_json(
|
||||
client
|
||||
.post(&format!("/api/v1/tasks/{}/fail", id), &body)
|
||||
.await
|
||||
.context("failed to fail task")?,
|
||||
"fail task",
|
||||
)
|
||||
.await?;
|
||||
print_json(&resp);
|
||||
Ok(())
|
||||
}
|
||||
@@ -355,11 +358,12 @@ mod tests {
|
||||
.with_status(500)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"internal error"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
// Should still succeed (prints JSON, doesn't check status code)
|
||||
cmd_create(
|
||||
// A node 5xx must surface as an Err, not be printed as if it were a task.
|
||||
let err = cmd_create(
|
||||
"deploy".to_string(),
|
||||
"agent:task".to_string(),
|
||||
None,
|
||||
@@ -371,7 +375,9 @@ mod tests {
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("500"), "got: {err}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
// ── list ─────────────────────────────────────────────────────────
|
||||
@@ -445,11 +451,16 @@ mod tests {
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
// cmd_view doesn't check status — it prints the JSON
|
||||
cmd_view("nope".to_string(), server.url()).await.unwrap();
|
||||
// A 404 must surface as an Err, not be printed as if it were a task.
|
||||
let err = cmd_view("nope".to_string(), server.url())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("404"), "got: {err}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
// ── claim ────────────────────────────────────────────────────────
|
||||
@@ -601,4 +612,122 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// ── denial surfaces: a node 4xx/5xx must Err, not render as success ──
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_tasks_surfaces_denial() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"/api/v1/tasks\?".to_string()),
|
||||
)
|
||||
.with_status(500)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"boom"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_list(None, None, 50, server.url()).await.unwrap_err();
|
||||
assert!(err.to_string().contains("500"), "got: {err}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_claim_task_surfaces_denial() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(
|
||||
dir.path().join("identity.pem"),
|
||||
kp.to_pem().unwrap().as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let _m = server
|
||||
.mock("POST", "/api/v1/tasks/task-x/claim")
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"forbidden"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_claim(
|
||||
"task-x".to_string(),
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("403"), "got: {err}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_complete_task_surfaces_denial() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(
|
||||
dir.path().join("identity.pem"),
|
||||
kp.to_pem().unwrap().as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let _m = server
|
||||
.mock("POST", "/api/v1/tasks/task-x/complete")
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"not found"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_complete(
|
||||
"task-x".to_string(),
|
||||
None,
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("404"), "got: {err}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fail_task_surfaces_denial() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(
|
||||
dir.path().join("identity.pem"),
|
||||
kp.to_pem().unwrap().as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let _m = server
|
||||
.mock("POST", "/api/v1/tasks/task-x/fail")
|
||||
.with_status(409)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"already terminal"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = cmd_fail(
|
||||
"task-x".to_string(),
|
||||
None,
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("409"), "got: {err}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
}
|
||||
+26
-25
@@ -2,7 +2,6 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Args, Subcommand};
|
||||
use serde_json::Value;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::http::NodeClient;
|
||||
@@ -87,12 +86,7 @@ async fn resolve_owner_repo(
|
||||
did.split(':').next_back().unwrap_or(&did).to_string()
|
||||
} else {
|
||||
let client = NodeClient::new(node, None);
|
||||
let info: Value = client
|
||||
.get("/")
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to fetch node info")?;
|
||||
let info = crate::http::read_json(client.get("/").await?, "node info").await?;
|
||||
let did = info["did"].as_str().context("node missing DID")?;
|
||||
did.split(':').next_back().unwrap_or(did).to_string()
|
||||
};
|
||||
@@ -123,12 +117,7 @@ async fn cmd_set(
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("visibility set failed ({status}): {msg}");
|
||||
}
|
||||
let _ = crate::http::read_json(resp, "visibility set").await?;
|
||||
|
||||
println!("✓ Visibility rule set on {owner}/{name}: {path_glob} (mode {mode})");
|
||||
if path_glob != "/" {
|
||||
@@ -156,12 +145,7 @@ async fn cmd_remove(
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("visibility remove failed ({status}): {msg}");
|
||||
}
|
||||
let _ = crate::http::read_json(resp, "visibility remove").await?;
|
||||
|
||||
println!("✓ Visibility rule removed from {owner}/{name}: {path_glob}");
|
||||
Ok(())
|
||||
@@ -179,12 +163,7 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("visibility list failed ({status}): {msg}");
|
||||
}
|
||||
let body = crate::http::read_json(resp, "visibility list").await?;
|
||||
|
||||
let rules = body["rules"].as_array().cloned().unwrap_or_default();
|
||||
if rules.is_empty() {
|
||||
@@ -307,4 +286,26 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_owner_repo_surfaces_denial() {
|
||||
// A slash-free repo with an empty identity dir forces the GET / node-info
|
||||
// fetch. A gated 404 there must Err (surfacing the status), proving the
|
||||
// read_json conversion is load-bearing rather than silently ignored.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let dir = tempfile::TempDir::new().unwrap(); // empty, no identity.pem, forces the GET / branch
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"denied"}"#)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let err = resolve_owner_repo("noslash", &server.url(), Some(dir.path()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("404"), "got: {err}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
}
|
||||
+34
-35
@@ -98,17 +98,17 @@ async fn cmd_create(
|
||||
"events": event_list,
|
||||
}))?;
|
||||
|
||||
let resp = client
|
||||
.post(&format!("/api/v1/repos/{owner}/{name}/hooks"), &payload)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let status = resp.status();
|
||||
let hook: Value = resp.json().await.context("invalid JSON")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = hook["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("create webhook failed ({status}): {msg}");
|
||||
}
|
||||
// #186: read_json status-checks before parsing and bounds the error body, so
|
||||
// a denial (or a hostile oversized error) fails here instead of being parsed
|
||||
// whole. `what` preserves main's "create webhook failed (...)" message prefix.
|
||||
let hook = crate::http::read_json(
|
||||
client
|
||||
.post(&format!("/api/v1/repos/{owner}/{name}/hooks"), &payload)
|
||||
.await
|
||||
.context("failed to connect to node")?,
|
||||
"create webhook",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let id = hook["id"].as_str().unwrap_or("?");
|
||||
let hook_events = hook["events"]
|
||||
@@ -145,16 +145,15 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
|
||||
// get_signed (not get) attaches the RFC 9421 signature — plain get() never
|
||||
// signs, and the node owner-gates this route, so an unsigned GET 401s.
|
||||
let resp = client
|
||||
.get_signed(&format!("/api/v1/repos/{owner}/{name}/hooks"))
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.context("invalid JSON")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("list webhooks failed ({status}): {msg}");
|
||||
}
|
||||
// #186: read_json status-checks + bounds the error body; `what` preserves
|
||||
// main's "list webhooks failed (...)" message prefix.
|
||||
let body = crate::http::read_json(
|
||||
client
|
||||
.get_signed(&format!("/api/v1/repos/{owner}/{name}/hooks"))
|
||||
.await?,
|
||||
"list webhooks",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let hooks = body
|
||||
.get("webhooks")
|
||||
@@ -195,20 +194,20 @@ async fn cmd_delete(repo: String, id: String, node: String, dir: Option<PathBuf>
|
||||
let client = NodeClient::new(&node, Some(keypair));
|
||||
|
||||
let payload = serde_json::to_vec(&serde_json::json!({}))?;
|
||||
let resp = client
|
||||
.delete(
|
||||
&format!("/api/v1/repos/{owner}/{name}/hooks/{id}"),
|
||||
&payload,
|
||||
)
|
||||
.await
|
||||
.context("failed to connect to node")?;
|
||||
let status = resp.status();
|
||||
let result: Value = resp.json().await.context("invalid JSON")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = result["message"].as_str().unwrap_or("unknown error");
|
||||
anyhow::bail!("delete webhook failed ({status}): {msg}");
|
||||
}
|
||||
// #186: read_json status-checks + bounds the error body; `what` preserves
|
||||
// main's "delete webhook failed (...)" message prefix. The success body is
|
||||
// unused, so discard it.
|
||||
let _ = crate::http::read_json(
|
||||
client
|
||||
.delete(
|
||||
&format!("/api/v1/repos/{owner}/{name}/hooks/{id}"),
|
||||
&payload,
|
||||
)
|
||||
.await
|
||||
.context("failed to connect to node")?,
|
||||
"delete webhook",
|
||||
)
|
||||
.await?;
|
||||
|
||||
println!("✓ Webhook {id} deleted");
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
//! Completeness gate for #186, itself proven load-bearing by reverting a
|
||||
//! converted handler and watching this go red.
|
||||
//!
|
||||
//! The client handlers converted in #186 must read node responses through
|
||||
//! `crate::http::read_json` — status-first, capped error read, sanitized message.
|
||||
//! The bypass this fix removed is *parse-before-status*: a `resp.json().await`
|
||||
//! whose result is only checked against the status AFTER parsing, which lets a
|
||||
//! hostile node stream an unbounded JSON error and print its `message` unsanitized.
|
||||
//!
|
||||
//! This test scans the converted source files and fails if that idiom returns —
|
||||
//! a `.json().await` call (not `read_json`) with an `.is_success()` check within a
|
||||
//! few lines AFTER it. A status check BEFORE the parse (the `KEEP` probes in
|
||||
//! repo.rs) reads as status-first and is not flagged. If a converted site is
|
||||
//! reverted to the bypass, this goes RED.
|
||||
//!
|
||||
//! The gate does not hand-list the files to scan; it DERIVES them: every
|
||||
//! `src/*.rs` that references `read_json` (except `http.rs`, where it is defined)
|
||||
//! is a converted handler and is scanned for the bypass idiom.
|
||||
//!
|
||||
//! Derivation alone has a blind spot: it keys on the very `read_json` marker a
|
||||
//! full revert deletes. A converted handler with a single node call (register.rs)
|
||||
//! reverted to `resp.json().await` loses `read_json` entirely, drops out of the
|
||||
//! derived set, and its bypass goes unscanned. So `CONVERTED_IN_186` is the
|
||||
//! authoritative required set and the gate asserts the derived surface EQUALS it,
|
||||
//! failing closed in both directions:
|
||||
//!
|
||||
//! - a pinned file missing from the derived set was reverted off `read_json`
|
||||
//! (the blind-spot escape), so the gate goes RED;
|
||||
//! - a file that uses `read_json` but is not pinned is a conversion that was
|
||||
//! never enrolled, so its own later revert would escape unseen; RED until it
|
||||
//! is added to the pin.
|
||||
//!
|
||||
//! Equality is what extends the protection to handlers converted after #186, not
|
||||
//! just the original sixteen: a conversion cannot ship unpinned, and a pinned
|
||||
//! surface cannot be deconverted silently.
|
||||
//!
|
||||
//! Pre-existing bypasses in files this PR did not convert (e.g. init.rs,
|
||||
//! mirror.rs, profile.rs) are known debt, tracked separately and out of scope.
|
||||
//! They do not use `read_json`, so the derivation excludes them and they are not
|
||||
//! pinned.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Files that define/host `read_json` rather than consume it as a converted
|
||||
/// handler. Excluded from the scanned set even though they reference the symbol.
|
||||
const NOT_A_HANDLER: &[&str] = &["http.rs"];
|
||||
|
||||
/// The authoritative converted-handler surface: every non-`http.rs` file whose
|
||||
/// node-response reads route through `read_json`. The gate asserts the derived
|
||||
/// `read_json` set EQUALS this exactly, so a new conversion must be added here
|
||||
/// (the gate is RED until it is) and a pinned surface cannot be reverted off
|
||||
/// `read_json` without tripping the gate. Both directions fail closed, which is
|
||||
/// what protects post-#186 conversions, not only the original sixteen.
|
||||
const CONVERTED_IN_186: &[&str] = &[
|
||||
"agent.rs",
|
||||
"bounty.rs",
|
||||
"cert.rs",
|
||||
"changelog.rs",
|
||||
"issue.rs",
|
||||
"mcp.rs",
|
||||
"peer.rs",
|
||||
"pr.rs",
|
||||
"protect.rs",
|
||||
"register.rs",
|
||||
"repo.rs",
|
||||
"star.rs",
|
||||
"status.rs",
|
||||
"sync.rs",
|
||||
"task.rs",
|
||||
"visibility.rs",
|
||||
"webhook.rs",
|
||||
];
|
||||
|
||||
/// Derive the converted-handler surface: every `src/*.rs` that references
|
||||
/// `read_json`, minus the definition site(s). This is the set the equality check
|
||||
/// compares against `CONVERTED_IN_186`; a newly-converted handler surfaces here
|
||||
/// and must then be pinned (the gate is RED until it is).
|
||||
fn scanned_handlers(src: &Path) -> Vec<(String, String)> {
|
||||
let mut handlers = Vec::new();
|
||||
for entry in std::fs::read_dir(src).expect("read src dir") {
|
||||
let path = entry.expect("dir entry").path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
|
||||
continue;
|
||||
}
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.expect("file name")
|
||||
.to_string();
|
||||
if NOT_A_HANDLER.contains(&file_name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
|
||||
if has_read_json_call_site(&text) {
|
||||
handlers.push((file_name, text));
|
||||
}
|
||||
}
|
||||
handlers.sort();
|
||||
handlers
|
||||
}
|
||||
|
||||
/// Does this file actually CALL `read_json`, as opposed to merely mentioning it?
|
||||
///
|
||||
/// Membership in the derived set must key on a call site, never on the text of
|
||||
/// the file. A textual `contains("read_json")` matched prose too, so a handler
|
||||
/// fully reverted off `read_json` kept its membership on the strength of a
|
||||
/// leftover comment ("previously routed through read_json"). Still-derived means
|
||||
/// the pinned-vs-derived equality below never fired, and the reverted file went
|
||||
/// on being scanned as though it were converted, so the deconversion — which
|
||||
/// drops the cap and the sanitizer — shipped green. Requiring the trailing `(`
|
||||
/// on a non-comment line also drops test names and doc references.
|
||||
fn has_read_json_call_site(text: &str) -> bool {
|
||||
text.lines().any(|l| {
|
||||
let t = l.trim_start();
|
||||
!t.starts_with("//") && t.contains("read_json(")
|
||||
})
|
||||
}
|
||||
|
||||
/// How far above a parse site to look for its status check.
|
||||
///
|
||||
/// The widest legitimate gap in the tree is `sync.rs`'s hand-rolled status-first
|
||||
/// `Trigger` arm, whose `.status()` sits 19 lines above its parse; 24 leaves a
|
||||
/// little headroom. Wider is weaker, so this should shrink if that arm is ever
|
||||
/// routed through `read_json` like its siblings.
|
||||
const STATUS_LOOKBACK: usize = 24;
|
||||
|
||||
/// Does `line` open a JSON parse — `.json().await`, or a turbofish
|
||||
/// `.json::<T>().await`, or the head of a split-line chain (`.json(` /
|
||||
/// `.json::<T>(` whose `()` / `.await` continue on following lines)?
|
||||
///
|
||||
/// Lines routed through `read_json` are never parse sites (that IS the fix).
|
||||
fn opens_json_parse(line: &str) -> bool {
|
||||
if line.contains("read_json") {
|
||||
return false;
|
||||
}
|
||||
// A `.json(` token starts every reqwest JSON parse, bare or turbofished,
|
||||
// single- or split-line. We anchor on it and let the window below confirm
|
||||
// the `.await` / status check; anchoring on `.json(` alone is what catches
|
||||
// `resp.json::<Value>().await` and `resp\n .json()\n .await` chains
|
||||
// that the old bare-`.json().await` substring missed.
|
||||
line.contains(".json()") || line.contains(".json::<")
|
||||
}
|
||||
|
||||
/// Within `window` (the parse-site line joined with the few lines after it), is
|
||||
/// the parse actually completed with `.await` and then checked against the
|
||||
/// status? A completed parse whose `.is_success()` lands AFTER it is the
|
||||
/// parse-before-status bypass. A status check BEFORE the parse (KEEP probes)
|
||||
/// never appears in this after-the-parse window, so it is not flagged.
|
||||
fn window_is_bypass(window: &str) -> bool {
|
||||
// The parse must actually resolve — guards against matching a `.json`-shaped
|
||||
// token that never awaits. Split-line chains put `.await` a line or two down,
|
||||
// which the joined window still contains.
|
||||
let completes = window.contains(".await");
|
||||
completes && window.contains(".is_success()")
|
||||
}
|
||||
|
||||
/// Is there a FULL non-success guard ABOVE this parse site — one that exits for
|
||||
/// every non-2xx status, not merely a bespoke branch for one code?
|
||||
///
|
||||
/// `window_is_bypass` only recognises a status check that lands AFTER the parse,
|
||||
/// so it describes one specific bypass and says nothing about a parse with no
|
||||
/// status check anywhere. That shape is the more dangerous revert (it renders a
|
||||
/// denial body as a successful result with no check at all). Requiring positive
|
||||
/// evidence of a guard first turns the rule from "detect one bad ordering" into
|
||||
/// "require the good ordering".
|
||||
///
|
||||
/// The evidence must cover the whole non-success range. "Any `.status()`
|
||||
/// nearby" is not enough: agent.rs legitimately keeps a 404-only hint
|
||||
/// (`resp.status() == StatusCode::NOT_FOUND`) ahead of its `read_json` call,
|
||||
/// and a revert that swaps that call for `resp.json().await?` would still see
|
||||
/// the 404 check in the lookback — while a 500's JSON denial body renders as an
|
||||
/// empty agent list again. A single-status equality (or a `matches!(…, 404 |
|
||||
/// 501)` enumeration) guards its own arm and nothing else, so it does not
|
||||
/// count. What counts:
|
||||
///
|
||||
/// - `.is_success()` — the canonical spelling; sync.rs binds the status to a
|
||||
/// variable first, so this token is matched on its own rather than as a
|
||||
/// `.status()` suffix;
|
||||
/// - a RANGE comparison over `as_u16()` (`>= 400` and friends), which reads the
|
||||
/// whole class rather than enumerating codes.
|
||||
///
|
||||
/// The `special_404_hint_is_not_a_guard` mutation test below keeps this
|
||||
/// distinction honest against the real agent.rs source.
|
||||
fn has_full_status_guard_above(lookback: &str) -> bool {
|
||||
if lookback.contains(".is_success()") {
|
||||
return true;
|
||||
}
|
||||
lookback.contains("as_u16() >=")
|
||||
|| lookback.contains("as_u16() >")
|
||||
|| lookback.contains("as_u16() <=")
|
||||
|| lookback.contains("as_u16() <")
|
||||
}
|
||||
|
||||
/// The scan shared by the gate and the mutation regression: every raw parse
|
||||
/// site in `text` is classified as an after-the-parse bypass (`offenders`) or a
|
||||
/// parse with no full status guard above it (`unguarded`). Factored out so the
|
||||
/// mutation test exercises EXACTLY the logic the gate runs, not a paraphrase of
|
||||
/// it.
|
||||
fn scan_handler(name: &str, text: &str) -> (Vec<String>, Vec<String>) {
|
||||
let mut offenders = Vec::new();
|
||||
let mut unguarded = Vec::new();
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if !opens_json_parse(line) {
|
||||
continue;
|
||||
}
|
||||
// Join the parse-site line with the following lines so a split-line
|
||||
// chain's `.await` and an after-the-parse `.is_success()` are both in
|
||||
// view. Status-first probes put `.is_success()` on an EARLIER line, so
|
||||
// it is outside this window and stays green.
|
||||
let window = lines[i..(i + 6).min(lines.len())].join("\n");
|
||||
if window_is_bypass(&window) {
|
||||
offenders.push(format!("{name}:{}", i + 1));
|
||||
continue;
|
||||
}
|
||||
// A parse that never resolves is not a read; only a completed parse
|
||||
// can render a denial body as a result.
|
||||
if !window.contains(".await") {
|
||||
continue;
|
||||
}
|
||||
let lookback = lines[i.saturating_sub(STATUS_LOOKBACK)..i].join("\n");
|
||||
if !has_full_status_guard_above(&lookback) {
|
||||
unguarded.push(format!("{name}:{}", i + 1));
|
||||
}
|
||||
}
|
||||
(offenders, unguarded)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converted_handlers_never_parse_before_status() {
|
||||
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let handlers = scanned_handlers(&src);
|
||||
|
||||
// Deriving the set is the whole point of the fix; if the tree ever stops
|
||||
// using `read_json` the gate would silently pass on an empty set.
|
||||
assert!(
|
||||
!handlers.is_empty(),
|
||||
"derived no converted handlers from src/*.rs read_json usage — the gate \
|
||||
would vacuously pass; check the derivation"
|
||||
);
|
||||
|
||||
// The pin is the authoritative required set: the derived `read_json` surface
|
||||
// must EQUAL it, failing closed in both directions. A pinned file that drops
|
||||
// out was reverted off `read_json` (register.rs, its single call, is the
|
||||
// motivating case) and its bypass would escape the derived scan. A derived
|
||||
// file that is not pinned is a conversion nobody enrolled, so ITS later revert
|
||||
// would escape the same way; force it into the pin now. Equality is what
|
||||
// extends the protection to handlers converted after #186.
|
||||
let derived: Vec<&str> = handlers.iter().map(|(n, _)| n.as_str()).collect();
|
||||
let deconverted: Vec<&str> = CONVERTED_IN_186
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|f| !derived.contains(f))
|
||||
.collect();
|
||||
assert!(
|
||||
deconverted.is_empty(),
|
||||
"pinned handler(s) no longer route node responses through read_json: a \
|
||||
parse-before-status revert would otherwise escape the derived scan. \
|
||||
Re-route through crate::http::read_json (or drop from CONVERTED_IN_186 if \
|
||||
the surface was intentionally deconverted): {deconverted:?}"
|
||||
);
|
||||
let unpinned: Vec<&str> = derived
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|f| !CONVERTED_IN_186.contains(f))
|
||||
.collect();
|
||||
assert!(
|
||||
unpinned.is_empty(),
|
||||
"handler(s) use read_json but are absent from CONVERTED_IN_186: add them so \
|
||||
the gate protects them against a later parse-before-status revert (an \
|
||||
unpinned conversion drops out of both the scan and this check when \
|
||||
reverted): {unpinned:?}"
|
||||
);
|
||||
|
||||
let mut offenders = Vec::new();
|
||||
let mut unguarded = Vec::new();
|
||||
for (name, text) in &handlers {
|
||||
let (o, u) = scan_handler(name, text);
|
||||
offenders.extend(o);
|
||||
unguarded.extend(u);
|
||||
}
|
||||
|
||||
assert!(
|
||||
offenders.is_empty(),
|
||||
"parse-before-status bypass present in converted handler(s) — route the read \
|
||||
through crate::http::read_json (status-first, capped, sanitized): {offenders:?}"
|
||||
);
|
||||
assert!(
|
||||
unguarded.is_empty(),
|
||||
"node response parsed with NO full status guard above it in a converted \
|
||||
handler — the denial body is being rendered as a result. Route the read \
|
||||
through crate::http::read_json (status-first, capped, sanitized): {unguarded:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Mutation regression for the special-404 escape: agent.rs (and the agent-show
|
||||
/// / repo-info paths shaped like it) keeps a legitimate 404-only hint ahead of
|
||||
/// its `read_json` call. Revert that call to a raw `resp.json().await?` and the
|
||||
/// 404 equality is the only status evidence in the lookback — under the old
|
||||
/// "any `.status()` nearby" rule the fence stayed green while a 500's denial
|
||||
/// body rendered as an empty agent list. This test performs that exact revert
|
||||
/// on the REAL agent.rs source and requires the scan to flag the mutant, so the
|
||||
/// full-guard distinction in `has_full_status_guard_above` cannot regress to a
|
||||
/// mere proximity check without going red here.
|
||||
#[test]
|
||||
fn special_404_hint_is_not_a_guard() {
|
||||
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let text = std::fs::read_to_string(src.join("agent.rs")).expect("read agent.rs");
|
||||
|
||||
// The mutation must stay real: it requires the idiom it reverts. If
|
||||
// agent.rs drops the 404 hint or the read_json call, this needs re-basing
|
||||
// on whichever handler carries the special-404 idiom then.
|
||||
assert!(
|
||||
text.contains("StatusCode::NOT_FOUND"),
|
||||
"agent.rs no longer carries the special-404 hint this mutation reverts; \
|
||||
re-anchor the mutation on the handler that does"
|
||||
);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let not_found_line = lines
|
||||
.iter()
|
||||
.position(|l| l.contains("StatusCode::NOT_FOUND"))
|
||||
.unwrap();
|
||||
let read_json_line = lines[not_found_line..]
|
||||
.iter()
|
||||
.position(|l| l.contains("read_json"))
|
||||
.map(|off| not_found_line + off)
|
||||
.expect(
|
||||
"agent.rs has no read_json call after its 404 hint; re-anchor the \
|
||||
mutation on the handler that carries the idiom",
|
||||
);
|
||||
|
||||
// The revert under test: the guarded helper call becomes a raw parse.
|
||||
let mut mutant: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
|
||||
mutant[read_json_line] = " let body: serde_json::Value = resp.json().await?;".to_string();
|
||||
let mutant = mutant.join("\n");
|
||||
|
||||
let (_, unguarded) = scan_handler("agent.rs[mutant]", &mutant);
|
||||
assert!(
|
||||
unguarded
|
||||
.iter()
|
||||
.any(|hit| hit.starts_with("agent.rs[mutant]:")),
|
||||
"the special-404 revert was not flagged: a 404-only equality upstream is \
|
||||
being accepted as a full status guard, which reopens the escape this \
|
||||
mutation pins (expected an unguarded hit, got {unguarded:?})"
|
||||
);
|
||||
|
||||
// Positive control: the unmutated file must stay green, or the fence is
|
||||
// flagging the legitimate hint rather than the revert.
|
||||
let (offenders, unguarded) = scan_handler("agent.rs", &text);
|
||||
assert!(
|
||||
offenders.is_empty() && unguarded.is_empty(),
|
||||
"unmutated agent.rs trips the fence; the guard classifier is wrong, not \
|
||||
the handler: offenders={offenders:?} unguarded={unguarded:?}"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user