mirror of
https://github.com/Twigpine/node.git
synced 2026-11-10 13:11:37 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d15f194f7f | ||
|
|
f67688eae2 | ||
|
|
af6a759330 | ||
|
|
66fe646320 |
No files matched your search
@@ -6,29 +6,24 @@ use axum::Json;
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
use crate::visibility::{visibility_check, Decision};
|
||||
|
||||
/// GET /api/v1/repos/{owner}/{repo}/encrypted-blobs
|
||||
/// Returns [{oid, cid}] for every encrypted blob in the repo, to any caller who
|
||||
/// can read the repo. Not recipient-scoped: recipient identities are not stored,
|
||||
/// so access control here is repo readability and decryption is gated by the
|
||||
/// envelope crypto (only a real recipient can open an envelope).
|
||||
///
|
||||
/// Quarantined repos are opaque 404 via [`crate::api::authorize_repo_read`] —
|
||||
/// same as issues/changelogs — so a public-but-quarantined mirror cannot leak
|
||||
/// its encrypted blob index.
|
||||
pub async fn list_encrypted_blobs(
|
||||
State(state): State<AppState>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let rules = state.db.list_visibility_rules(&record.id).await?;
|
||||
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny
|
||||
{
|
||||
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
|
||||
}
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
|
||||
let rows = state.db.list_all_encrypted_blobs(&record.id).await?;
|
||||
let blobs: Vec<_> = rows
|
||||
.into_iter()
|
||||
@@ -45,17 +40,9 @@ pub async fn get_encrypted_blob(
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
Path((owner, repo, oid)): Path<(String, String, String)>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let rules = state.db.list_visibility_rules(&record.id).await?;
|
||||
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny
|
||||
{
|
||||
return Err(AppError::RepoNotFound(format!("{owner}/{repo}/{oid}")));
|
||||
}
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
|
||||
let cid = state
|
||||
.db
|
||||
.encrypted_blob_cid(&record.id, &oid)
|
||||
@@ -81,17 +68,9 @@ pub async fn replicate_encrypted_blobs(
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let rules = state.db.list_visibility_rules(&record.id).await?;
|
||||
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny
|
||||
{
|
||||
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
|
||||
}
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
|
||||
let rows = state.db.list_all_encrypted_blobs(&record.id).await?;
|
||||
let blobs: Vec<_> = rows
|
||||
.into_iter()
|
||||
|
||||
@@ -238,49 +238,32 @@ pub async fn close_issue(
|
||||
headers: axum::http::HeaderMap,
|
||||
crate::rate_limit::PeerAddr(peer): crate::rate_limit::PeerAddr,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.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.
|
||||
// READ-GATE before any snapshot work or rate charging, through the shared
|
||||
// authorize_repo_read gate so quarantined repos are opaque here too. 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).
|
||||
// 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}")));
|
||||
}
|
||||
}
|
||||
// carries none of the owner's visibility rules, so for such a row this gate
|
||||
// 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.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
|
||||
|
||||
// Per-IP flood brake, layered on the same shared limiter and trusted-proxy
|
||||
// policy as the push advertisement. The pre-lock snapshot downloads the
|
||||
@@ -502,6 +485,15 @@ mod tests {
|
||||
.await
|
||||
.expect("get_repo")
|
||||
.expect("repo exists");
|
||||
// upsert_mirror_repo seeds the row quarantined, and the read gate now
|
||||
// hides quarantined rows as opaque 404 before the owner-or-author check
|
||||
// this test names. Release it so the stranger is refused by the 403, not
|
||||
// by the quarantine short-circuit.
|
||||
state
|
||||
.db
|
||||
.set_repo_quarantine(&record.id, false)
|
||||
.await
|
||||
.expect("release seed quarantine");
|
||||
|
||||
// An independent session holds the repo's write lock for the whole call.
|
||||
let key = crate::git::repo_store::advisory_lock_key_for_test(
|
||||
@@ -703,6 +695,15 @@ mod tests {
|
||||
.await
|
||||
.expect("get_repo")
|
||||
.expect("seeded repo exists");
|
||||
// upsert_mirror_repo seeds the admission flag as quarantined, but the
|
||||
// gate now hides quarantined rows from every caller class. Release it
|
||||
// so these tests exercise what they name: the owner-or-author check
|
||||
// after the read gate, not the quarantine short-circuit.
|
||||
state
|
||||
.db
|
||||
.set_repo_quarantine(&record.id, false)
|
||||
.await
|
||||
.expect("release seed quarantine");
|
||||
let _ = owner_did;
|
||||
let path = state
|
||||
.repo_store
|
||||
@@ -728,6 +729,40 @@ mod tests {
|
||||
path
|
||||
}
|
||||
|
||||
/// The read gate blocks the owner (and any caller) before the
|
||||
/// owner-or-author checks: even the owner gets the opaque 404 on a
|
||||
/// quarantined repo, and the seeded row's quarantined flag is left set
|
||||
/// deliberately. The released-mirror variants above exercise the
|
||||
/// owner-or-author path.
|
||||
#[sqlx::test]
|
||||
async fn quarantined_repo_close_is_opaque_404(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
let owner_did = "did:key:z6MkT1Owner";
|
||||
state
|
||||
.db
|
||||
.upsert_mirror_repo("z6MkT1Owner", "tquar", "/unused", None, true)
|
||||
.await
|
||||
.expect("seed repo row");
|
||||
|
||||
let res = close_issue(
|
||||
axum::extract::State(state.clone()),
|
||||
axum::Extension(crate::auth::AuthenticatedDid(owner_did.to_string())),
|
||||
axum::extract::Path((
|
||||
"z6MkT1Owner".to_string(),
|
||||
"tquar".to_string(),
|
||||
"1".to_string(),
|
||||
)),
|
||||
axum::http::HeaderMap::new(),
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.70:5000".parse().unwrap())),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(res, Err(crate::error::AppError::RepoNotFound(_))),
|
||||
"a quarantined repo must 404 close_issue for every caller: {:?}",
|
||||
res.ok().map(|_| "unexpected success")
|
||||
);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -44,11 +44,8 @@ pub async fn add_label(
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>)> {
|
||||
let label = canonicalize_label(&req.label)?;
|
||||
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
|
||||
crate::api::require_repo_owner(&record, &auth.0)?;
|
||||
|
||||
let added = state.db.add_label(&record.id, &label).await?;
|
||||
@@ -71,11 +68,8 @@ pub async fn remove_label(
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let label = canonicalize_label(&label)?;
|
||||
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
|
||||
crate::api::require_repo_owner(&record, &auth.0)?;
|
||||
|
||||
let removed = state.db.remove_label(&record.id, &label).await?;
|
||||
|
||||
@@ -177,13 +177,22 @@ mod authz_guard {
|
||||
"\nasync fn ",
|
||||
"\npub fn ",
|
||||
"\nfn ",
|
||||
"#[cfg(test)]",
|
||||
]
|
||||
.iter()
|
||||
.filter_map(|p| rest[1..].find(p).map(|i| i + 1))
|
||||
.min()
|
||||
.unwrap_or(rest.len());
|
||||
rest[..end]
|
||||
.lines()
|
||||
// Strip block comments too: a marker surviving only inside /* */ would
|
||||
// otherwise satisfy a row with the real gate deleted.
|
||||
let mut body = rest[..end].to_string();
|
||||
while let Some(open) = body.find("/*") {
|
||||
match body[open..].find("*/").map(|i| open + i + 2) {
|
||||
Some(close) => body.replace_range(open..close, ""),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
body.lines()
|
||||
.filter(|l| !l.trim_start().starts_with("//"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
@@ -209,9 +218,14 @@ mod authz_guard {
|
||||
|
||||
// (source, handler, expected gate marker)
|
||||
let rows: &[(&str, &str, &str)] = &[
|
||||
// Bucket A — owner-gate (require_repo_owner -> 403)
|
||||
// Bucket A — owner-gate (require_repo_owner -> 403), now behind
|
||||
// the shared read gate first so a hidden repo 404s for every
|
||||
// caller class before the owner check can answer 403.
|
||||
(pulls, "merge_pr", "authorize_repo_read("),
|
||||
(pulls, "merge_pr", "require_repo_owner("),
|
||||
(webhooks, "create_webhook", "authorize_repo_read("),
|
||||
(webhooks, "create_webhook", "require_repo_owner("),
|
||||
(webhooks, "delete_webhook", "authorize_repo_read("),
|
||||
(webhooks, "delete_webhook", "require_repo_owner("),
|
||||
// Bucket A/B hybrid — list_webhooks is read-visibility THEN owner:
|
||||
// authorize_repo_read 404s a non-reader of a private repo, then
|
||||
@@ -220,10 +234,15 @@ mod authz_guard {
|
||||
// (existence hiding) and require_repo_owner guards the owner half.
|
||||
(webhooks, "list_webhooks", "authorize_repo_read("),
|
||||
(webhooks, "list_webhooks", "require_repo_owner("),
|
||||
(labels, "add_label", "authorize_repo_read("),
|
||||
(labels, "add_label", "require_repo_owner("),
|
||||
(labels, "remove_label", "authorize_repo_read("),
|
||||
(labels, "remove_label", "require_repo_owner("),
|
||||
// Bucket A' — owner OR author (did_matches against the author)
|
||||
// Bucket A' — owner OR author (did_matches against the author),
|
||||
// also behind the shared read gate first.
|
||||
(pulls, "close_pr", "authorize_repo_read("),
|
||||
(pulls, "close_pr", "did_matches("),
|
||||
(issues, "close_issue", "authorize_repo_read("),
|
||||
(issues, "close_issue", "did_matches("),
|
||||
// Bucket B — read-gate (authorize_repo_read)
|
||||
(pulls, "create_review", "authorize_repo_read("),
|
||||
@@ -269,21 +288,66 @@ mod authz_guard {
|
||||
(bounties, "dispute_bounty", "did_matches("),
|
||||
(replicas, "register_replica", "did_matches("),
|
||||
(replicas, "unregister_replica", "replica_did = &auth.0"),
|
||||
// PRE-GATED — already owner-gated, in-scope group; guard the gate itself
|
||||
// PRE-GATED — owner-gated behind the shared read gate; guard both
|
||||
(protect, "protect_branch", "authorize_repo_read("),
|
||||
(protect, "protect_branch", "did_matches("),
|
||||
(protect, "unprotect_branch", "authorize_repo_read("),
|
||||
(protect, "unprotect_branch", "did_matches("),
|
||||
(visibility, "set_visibility", "authorize_repo_read("),
|
||||
(visibility, "set_visibility", "require_owner("),
|
||||
(visibility, "remove_visibility", "authorize_repo_read("),
|
||||
(visibility, "remove_visibility", "require_owner("),
|
||||
(visibility, "list_visibility", "authorize_repo_read("),
|
||||
(visibility, "list_visibility", "require_owner("),
|
||||
];
|
||||
|
||||
// The visibility rows prove require_owner is CALLED; this proves the helper
|
||||
// itself does DID-safe matching, not a raw/trailing-segment compare.
|
||||
// Rows above prove each visibility handler calls authorize_repo_read and
|
||||
// require_owner; this proves the helper itself does DID-safe matching.
|
||||
assert!(
|
||||
fn_body(visibility, "require_owner").contains("did_matches("),
|
||||
"visibility::require_owner must use did_matches for DID-safe owner matching"
|
||||
);
|
||||
|
||||
// Order matters as much as presence: hoisting the owner/author check
|
||||
// above the read gate would 403 a non-owner on a hidden repo, an
|
||||
// existence oracle. Pin read-gate-then-owner on every handler that
|
||||
// takes both; the owner marker differs per bucket (require_repo_owner
|
||||
// for owner-only, require_owner for visibility, did_matches for the
|
||||
// owner-or-author and protect paths).
|
||||
for (src, func, owner_marker) in [
|
||||
(visibility, "set_visibility", "require_owner("),
|
||||
(visibility, "remove_visibility", "require_owner("),
|
||||
(visibility, "list_visibility", "require_owner("),
|
||||
(pulls, "merge_pr", "require_repo_owner("),
|
||||
(webhooks, "list_webhooks", "require_repo_owner("),
|
||||
(webhooks, "create_webhook", "require_repo_owner("),
|
||||
(webhooks, "delete_webhook", "require_repo_owner("),
|
||||
(labels, "add_label", "require_repo_owner("),
|
||||
(labels, "remove_label", "require_repo_owner("),
|
||||
(pulls, "close_pr", "did_matches("),
|
||||
(issues, "close_issue", "did_matches("),
|
||||
(protect, "protect_branch", "did_matches("),
|
||||
(protect, "unprotect_branch", "did_matches("),
|
||||
] {
|
||||
let body = fn_body(src, func);
|
||||
let gate = body.find("authorize_repo_read(").expect("gate marker");
|
||||
let owner = body.find(owner_marker).expect("owner marker");
|
||||
assert!(
|
||||
gate < owner,
|
||||
"{func} must run authorize_repo_read before {owner_marker}"
|
||||
);
|
||||
// The owner-or-author and protect handlers also carry
|
||||
// require_repo_owner: a hoist of that call above the read gate is
|
||||
// the same 403-vs-404 oracle, so pin it too where it exists.
|
||||
if func == "close_pr" || func == "close_issue" {
|
||||
let owner_gate = body.find("require_repo_owner(").expect("owner marker");
|
||||
assert!(
|
||||
gate < owner_gate,
|
||||
"{func} must run authorize_repo_read before require_repo_owner("
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The CID read surface (#173) enforces its gate inside the shared
|
||||
// `gate_and_serve`, which BOTH the provenance path and the legacy scan call, so
|
||||
// the markers must live there (the get_by_cid row above only proves delegation).
|
||||
|
||||
@@ -17,11 +17,8 @@ pub async fn protect_branch(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, repo, branch)): Path<(String, String, String)>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>)> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
|
||||
|
||||
// Only the repo owner can protect branches (DID-safe match, shared idiom).
|
||||
let caller = &auth.0;
|
||||
@@ -51,11 +48,8 @@ pub async fn unprotect_branch(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, repo, branch)): Path<(String, String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
|
||||
|
||||
let caller = &auth.0;
|
||||
if !crate::api::did_matches(caller, &record.owner_did) {
|
||||
|
||||
@@ -199,11 +199,8 @@ pub async fn merge_pr(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, name, number)): Path<(String, String, i64)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
|
||||
|
||||
// Owner-only merge (N7). Merging writes the served tree, so this is the same
|
||||
// trust boundary as owner-only push; it subsumes branch protection (a
|
||||
@@ -274,11 +271,8 @@ pub async fn close_pr(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, name, number)): Path<(String, String, i64)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
|
||||
|
||||
let pr = state
|
||||
.db
|
||||
|
||||
@@ -1296,6 +1296,60 @@ mod visible_tasks_tests {
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
/// An over-ceiling limit caps at MAX_VISIBLE_TASKS rather than scanning or
|
||||
/// serializing the whole table (#405, adapted to the cursor handler).
|
||||
#[sqlx::test]
|
||||
async fn limit_ceiling_caps_at_200(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
for i in 0..201 {
|
||||
state
|
||||
.db
|
||||
.create_task(&task(&format!("ceil-{i}"), None, DELEGATOR))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let resp = list_router(state)
|
||||
.oneshot(signed_request_as(
|
||||
DELEGATOR,
|
||||
Method::GET,
|
||||
"/api/v1/tasks?limit=9223372036854775807",
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(
|
||||
body["count"], 200,
|
||||
"i64::MAX must clamp to the 200 ceiling, not scan the table"
|
||||
);
|
||||
}
|
||||
|
||||
/// An omitted limit falls back to the 50-row default (#405).
|
||||
#[sqlx::test]
|
||||
async fn omitted_limit_uses_default_50(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
for i in 0..60 {
|
||||
state
|
||||
.db
|
||||
.create_task(&task(&format!("def-{i}"), None, DELEGATOR))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let resp = list_router(state)
|
||||
.oneshot(signed_request_as(
|
||||
DELEGATOR,
|
||||
Method::GET,
|
||||
"/api/v1/tasks",
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(body["count"], 50, "omitted limit must default to 50");
|
||||
}
|
||||
|
||||
/// A negative limit must clamp to zero through `collect_visible_tasks`,
|
||||
/// not fall through to the visible set.
|
||||
#[sqlx::test]
|
||||
|
||||
@@ -85,11 +85,10 @@ pub async fn set_visibility(
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
Json(req): Json<SetVisibilityRequest>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>)> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
// Quarantine first (via authorize_repo_read), then owner — same posture as
|
||||
// list_visibility so a quarantined repo cannot be mutated while reads 404.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, Some(&auth.0), "/").await?;
|
||||
require_owner(&record, &auth.0)?;
|
||||
validate_path_glob(&req.path_glob)?;
|
||||
|
||||
@@ -141,11 +140,8 @@ pub async fn remove_visibility(
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
Json(req): Json<RemoveVisibilityRequest>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, Some(&auth.0), "/").await?;
|
||||
require_owner(&record, &auth.0)?;
|
||||
|
||||
state
|
||||
@@ -171,14 +167,12 @@ pub async fn list_visibility(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
// Quarantine first (via authorize_repo_read), then owner — a quarantined
|
||||
// mirror must be opaque even to a caller matching owner_did.
|
||||
let (record, rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, Some(&auth.0), "/").await?;
|
||||
require_owner(&record, &auth.0)?;
|
||||
|
||||
let rules = state.db.list_visibility_rules(&record.id).await?;
|
||||
let rules_json: Vec<_> = rules
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
@@ -205,28 +199,18 @@ pub async fn list_visibility(
|
||||
/// denied one (`reinclude`), so a clean-clone client can sparse-exclude the
|
||||
/// denied subtrees while re-including the allowed nested paths. Unlike
|
||||
/// `list_visibility` this is not owner-gated and never exposes reader_dids.
|
||||
///
|
||||
/// Quarantined repos are opaque 404 via [`crate::api::authorize_repo_read`] —
|
||||
/// same posture as encrypted-blob discovery — so admission and private-subtree
|
||||
/// layout are not disclosed to anon, owner, or peers.
|
||||
pub async fn withheld_paths(
|
||||
State(state): State<AppState>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
|
||||
let rules = state.db.list_visibility_rules(&record.id).await?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
|
||||
// Whole-repo read gate: a caller who cannot read "/" gets repo-not-found,
|
||||
// matching the git read endpoints, so this never discloses a private repo's
|
||||
// existence or its path layout to an unauthorized caller.
|
||||
if crate::visibility::visibility_check(&rules, record.is_public, &record.owner_did, caller, "/")
|
||||
== crate::visibility::Decision::Deny
|
||||
{
|
||||
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
|
||||
}
|
||||
let (record, rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
|
||||
|
||||
let withheld =
|
||||
crate::visibility::withheld_globs(&rules, record.is_public, &record.owner_did, caller);
|
||||
|
||||
@@ -33,11 +33,8 @@ pub async fn create_webhook(
|
||||
Path((owner, name)): Path<(String, String)>,
|
||||
Json(req): Json<CreateWebhookRequest>,
|
||||
) -> Result<(StatusCode, Json<Webhook>)> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
|
||||
crate::api::require_repo_owner(&record, &auth.0)?;
|
||||
|
||||
// Gate the target through the same hardened public-host validator the peer
|
||||
@@ -118,11 +115,8 @@ pub async fn delete_webhook(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, name, id)): Path<(String, String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
|
||||
crate::api::require_repo_owner(&record, &auth.0)?;
|
||||
|
||||
// Verify the webhook belongs to this repo
|
||||
|
||||
@@ -126,15 +126,55 @@ fn decode_key(b64url: &str) -> Option<VerifyingKey> {
|
||||
VerifyingKey::from_bytes(&arr).ok()
|
||||
}
|
||||
|
||||
/// Upper bound on the iCaptcha `/v1/pubkey` response body. The document is a
|
||||
/// handful of keys at most; anything larger is refused rather than buffered
|
||||
/// into memory (the unbounded `.json()` read this replaces, #507).
|
||||
const KEY_MAX_BODY_BYTES: usize = 8 * 1024;
|
||||
|
||||
/// Read at most [`KEY_MAX_BODY_BYTES`] bytes of a key response. Returns `None`
|
||||
/// when the body exceeds the cap, so an oversized document is treated as absent
|
||||
/// exactly like a network error or a timeout (fail safe, never a partial key).
|
||||
async fn read_key_body(mut resp: reqwest::Response) -> Option<Vec<u8>> {
|
||||
let mut buf = Vec::with_capacity(1024);
|
||||
loop {
|
||||
match resp.chunk().await {
|
||||
Ok(Some(chunk)) => {
|
||||
let take = (KEY_MAX_BODY_BYTES - buf.len()).min(chunk.len());
|
||||
buf.extend_from_slice(&chunk[..take]);
|
||||
if take < chunk.len() {
|
||||
return None; // exceeded the cap mid-chunk
|
||||
}
|
||||
}
|
||||
Ok(None) => return Some(buf),
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_key(url: &str) -> Option<VerifyingKey> {
|
||||
let endpoint = format!("{}/v1/pubkey", url.trim_end_matches('/'));
|
||||
// Bounded request: a hung /v1/pubkey must never block node startup. On
|
||||
// timeout/error we return None and the gate stays inert (fail safe).
|
||||
//
|
||||
// No redirects and a capped body read: the key this fetch produces is the
|
||||
// TRUST ANCHOR for the whole proof gate, so a 3xx must not be allowed to move
|
||||
// where it comes from (anchor substitution / SSRF, matching the node's other
|
||||
// outbound clients — `build_http_client` in main.rs, the sync worker), and
|
||||
// the body is bounded rather than handed to `.json()`'s unbounded buffering.
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.ok()?;
|
||||
let jwks: Jwks = client.get(&endpoint).send().await.ok()?.json().await.ok()?;
|
||||
let resp = client.get(&endpoint).send().await.ok()?;
|
||||
// A non-2xx response body is not a key: with Policy::none() a 3xx is
|
||||
// surfaced rather than followed, but its own body must not be parsed
|
||||
// either, or the redirect would move the anchor after all.
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let body = read_key_body(resp).await?;
|
||||
let jwks: Jwks = serde_json::from_slice(&body).ok()?;
|
||||
decode_key(&jwks.keys.first()?.x)
|
||||
}
|
||||
|
||||
@@ -647,4 +687,129 @@ mod tests {
|
||||
let tampered = format!("{}.{}", payload, chars.into_iter().collect::<String>());
|
||||
assert!(verify_token(&v, &tampered, SUB, IAT, false).is_err());
|
||||
}
|
||||
|
||||
/// `{"keys":[{"x":"<base64url ed25519 point>"}]}` for the live `PUBKEY_X`.
|
||||
fn jwks_body() -> Vec<u8> {
|
||||
format!(r#"{{"keys":[{{"x":"{PUBKEY_X}"}}]}}"#).into_bytes()
|
||||
}
|
||||
|
||||
/// The no-redirect / bounded-body client still parses a valid JWKS, so the
|
||||
/// hardening did not break the happy path the gate depends on at startup.
|
||||
#[tokio::test]
|
||||
async fn fetch_key_parses_a_valid_jwks() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/v1/pubkey")
|
||||
.with_status(200)
|
||||
.with_header("Content-Type", "application/json")
|
||||
.with_body(jwks_body())
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let key = fetch_key(&server.url()).await.expect("valid JWKS parses");
|
||||
assert_eq!(key, decode_key(PUBKEY_X).unwrap());
|
||||
}
|
||||
|
||||
/// A 302 must not move the trust anchor. The redirect target serves a REAL
|
||||
/// key, so following the redirect would make `fetch_key` adopt it; instead
|
||||
/// `Policy::none()` leaves the 302's (non-JWKS) body as the answer → None.
|
||||
#[tokio::test]
|
||||
async fn fetch_key_does_not_follow_a_redirect() {
|
||||
let mut target = mockito::Server::new_async().await;
|
||||
let _t = target
|
||||
.mock("GET", "/v1/pubkey")
|
||||
.expect(0)
|
||||
.with_status(200)
|
||||
.with_header("Content-Type", "application/json")
|
||||
.with_body(jwks_body())
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let mut redirector = mockito::Server::new_async().await;
|
||||
// Location points at the target's /v1/pubkey, so a client that DID
|
||||
// follow the redirect would fetch the real key and return Some: the
|
||||
// assertion below only holds while Policy::none() is in place.
|
||||
let _r = redirector
|
||||
.mock("GET", "/v1/pubkey")
|
||||
.with_status(302)
|
||||
.with_header("Location", &format!("{}/v1/pubkey", target.url()))
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
fetch_key(&redirector.url()).await.is_none(),
|
||||
"a redirect must not be followed to a host serving a valid key"
|
||||
);
|
||||
}
|
||||
|
||||
/// A body past the cap is refused rather than buffered, so a broken
|
||||
/// endpoint cannot make the node materialize an arbitrarily large document.
|
||||
/// The body is a VALID JWKS (`jwks_body()`) padded with JSON whitespace past
|
||||
/// the cap, so an unbounded read would still parse it; this test only passes
|
||||
/// because the byte cap refuses it.
|
||||
#[tokio::test]
|
||||
async fn fetch_key_rejects_an_oversized_body() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mut oversized = jwks_body();
|
||||
oversized.resize(KEY_MAX_BODY_BYTES + 1, b' ');
|
||||
let _m = server
|
||||
.mock("GET", "/v1/pubkey")
|
||||
.with_status(200)
|
||||
.with_header("Content-Type", "application/json")
|
||||
.with_body(oversized)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
fetch_key(&server.url()).await.is_none(),
|
||||
"an oversized body must be refused even when it is a valid JWKS"
|
||||
);
|
||||
}
|
||||
|
||||
/// A body of exactly the cap still parses: the contract refuses bodies that
|
||||
/// EXCEED it, not bodies that reach it (the padded JWKS is exactly
|
||||
/// KEY_MAX_BODY_BYTES bytes of otherwise-valid JSON).
|
||||
#[tokio::test]
|
||||
async fn fetch_key_accepts_a_body_at_the_cap() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mut at_cap = jwks_body();
|
||||
at_cap.resize(KEY_MAX_BODY_BYTES, b' ');
|
||||
let _m = server
|
||||
.mock("GET", "/v1/pubkey")
|
||||
.with_status(200)
|
||||
.with_header("Content-Type", "application/json")
|
||||
.with_body(at_cap)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let key = fetch_key(&server.url()).await.expect("at-cap JWKS parses");
|
||||
assert_eq!(key, decode_key(PUBKEY_X).unwrap());
|
||||
}
|
||||
|
||||
/// A non-2xx response never supplies the anchor, even when its body is a
|
||||
/// perfectly valid JWKS: the redirect pin only stops the hop, and the
|
||||
/// status gate is what stops the 3xx's own body from being adopted.
|
||||
#[tokio::test]
|
||||
async fn fetch_key_rejects_a_non_success_status() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/v1/pubkey")
|
||||
.with_status(500)
|
||||
.with_header("Content-Type", "application/json")
|
||||
.with_body(jwks_body())
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
fetch_key(&server.url()).await.is_none(),
|
||||
"a non-2xx body must not be adopted as the anchor even when it parses"
|
||||
);
|
||||
}
|
||||
|
||||
/// An unreachable endpoint is the same fail-safe None as every other fetch
|
||||
/// error: the gate stays inert rather than the node failing to start.
|
||||
#[tokio::test]
|
||||
async fn fetch_key_returns_none_when_the_endpoint_is_unreachable() {
|
||||
assert!(fetch_key("http://127.0.0.1:1").await.is_none());
|
||||
}
|
||||
}
|
||||
@@ -578,7 +578,18 @@ async fn replicate_encrypted_blobs(
|
||||
let url = format!("{origin_url}/api/v1/repos/{owner}/{repo}/encrypted-blobs/replicate");
|
||||
let resp = match client.get(&url).send().await {
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
_ => return,
|
||||
// A peer that predates the replicate route answers 404: expected and
|
||||
// quiet. Anything else is the route existing and refusing or failing,
|
||||
// which should be observable rather than read as route-absent.
|
||||
Ok(r) if r.status().as_u16() == 404 => return,
|
||||
Ok(r) => {
|
||||
tracing::warn!(repo = %repo, status = %r.status(), "encrypted-blobs replicate refused; skipping");
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(repo = %repo, error = %e, "encrypted-blobs replicate request failed; skipping");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let parsed: ReplicateResponse = match resp.json().await {
|
||||
Ok(p) => p,
|
||||
|
||||
@@ -18723,6 +18723,366 @@ mod tests {
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
/// Public-but-quarantined repos must not expose encrypted blob indexes
|
||||
/// (discovery or replicate). Previously these handlers used
|
||||
/// `visibility_check` alone and skipped the quarantine short-circuit in
|
||||
/// `authorize_repo_read`.
|
||||
#[sqlx::test]
|
||||
async fn encrypted_blobs_quarantined_repo_opaque_404(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let owner = "did:key:zENCQUAROWNERAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let short = owner.split(':').next_back().unwrap();
|
||||
let mut repo = seed_private_repo(owner, "enc-quar");
|
||||
repo.is_public = true;
|
||||
let repo_id = repo.id.clone();
|
||||
state.db.create_repo(&repo).await.unwrap();
|
||||
state
|
||||
.db
|
||||
.record_encrypted_blob(&repo_id, "deadbeef", "bafybeiquarantinedcid", "")
|
||||
.await
|
||||
.unwrap();
|
||||
state.db.set_repo_quarantine(&repo_id, true).await.unwrap();
|
||||
|
||||
let router = crate::server::build_router(state.clone());
|
||||
for suffix in [
|
||||
"encrypted-blobs",
|
||||
"encrypted-blobs/replicate",
|
||||
"encrypted-blob/deadbeef",
|
||||
] {
|
||||
let path = format!("/api/v1/repos/{short}/enc-quar/{suffix}");
|
||||
let resp = router.clone().oneshot(anon_get(&path)).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"quarantined public repo must 404 on {path}"
|
||||
);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
!text.contains("bafybeiquarantinedcid") && !text.contains("deadbeef"),
|
||||
"blob index must not leak on quarantine 404 for {path}: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
// Owner (full did:key and bare key) must also 404 — quarantine is not a
|
||||
// visibility deny that the owner short-circuit can bypass.
|
||||
for caller in [owner, short] {
|
||||
for suffix in [
|
||||
"encrypted-blobs",
|
||||
"encrypted-blobs/replicate",
|
||||
"encrypted-blob/deadbeef",
|
||||
] {
|
||||
let path = format!("/api/v1/repos/{short}/enc-quar/{suffix}");
|
||||
let resp = router
|
||||
.clone()
|
||||
.oneshot(signed_request_as(caller, Method::GET, &path, Body::empty()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"owner form {caller} must not read quarantined {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Control: clear quarantine → all three discovery surfaces admit again.
|
||||
// `encrypted-blob/{oid}` may 500 without a live IPFS node after the gate
|
||||
// opens; assert it is not the quarantine 404.
|
||||
state.db.set_repo_quarantine(&repo_id, false).await.unwrap();
|
||||
for suffix in ["encrypted-blobs", "encrypted-blobs/replicate"] {
|
||||
let path = format!("/api/v1/repos/{short}/enc-quar/{suffix}");
|
||||
let resp = router.clone().oneshot(anon_get(&path)).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK, "released must admit {path}");
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
text.contains("bafybeiquarantinedcid"),
|
||||
"released repo must expose blob index on {path}: {text}"
|
||||
);
|
||||
}
|
||||
let get_path = format!("/api/v1/repos/{short}/enc-quar/encrypted-blob/deadbeef");
|
||||
let resp = router.oneshot(anon_get(&get_path)).await.unwrap();
|
||||
assert!(
|
||||
resp.status() == StatusCode::OK || resp.status().is_server_error(),
|
||||
"released get must admit (OK) or reach the backend (5xx); a 4xx means the denial path still fires: {}",
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirror-admission path (`upsert_mirror_repo` + slash-form id) must also
|
||||
/// opaque-404 encrypted discovery while quarantined.
|
||||
#[sqlx::test]
|
||||
async fn encrypted_blobs_quarantined_mirror_admission_opaque_404(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let short = "z6MkEncMirrorAdmitAAAAAAAAAAAAAAAAAAAA";
|
||||
state
|
||||
.db
|
||||
.upsert_mirror_repo(short, "enc-mirror", "/tmp/enc-mirror", None, true)
|
||||
.await
|
||||
.unwrap();
|
||||
let repo_id = format!("{short}/enc-mirror");
|
||||
state
|
||||
.db
|
||||
.record_encrypted_blob(&repo_id, "aabbccdd", "bafybeimirrorcid", "")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let router = crate::server::build_router(state.clone());
|
||||
for suffix in [
|
||||
"encrypted-blobs",
|
||||
"encrypted-blobs/replicate",
|
||||
"encrypted-blob/aabbccdd",
|
||||
] {
|
||||
let path = format!("/api/v1/repos/{short}/enc-mirror/{suffix}");
|
||||
let resp = router.clone().oneshot(anon_get(&path)).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"anon must 404 quarantined mirror {path}"
|
||||
);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
!text.contains("bafybeimirrorcid") && !text.contains("aabbccdd"),
|
||||
"blob index must not leak on quarantine 404 for {path}: {text}"
|
||||
);
|
||||
|
||||
let resp = router
|
||||
.clone()
|
||||
.oneshot(signed_request_as(short, Method::GET, &path, Body::empty()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"bare-key mirror owner must not read quarantined {path}"
|
||||
);
|
||||
}
|
||||
|
||||
state.db.set_repo_quarantine(&repo_id, false).await.unwrap();
|
||||
for suffix in ["encrypted-blobs", "encrypted-blobs/replicate"] {
|
||||
let path = format!("/api/v1/repos/{short}/enc-mirror/{suffix}");
|
||||
let resp = router.clone().oneshot(anon_get(&path)).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK, "released must admit {path}");
|
||||
}
|
||||
let get_path = format!("/api/v1/repos/{short}/enc-mirror/encrypted-blob/aabbccdd");
|
||||
let resp = router.oneshot(anon_get(&get_path)).await.unwrap();
|
||||
assert!(
|
||||
resp.status() == StatusCode::OK || resp.status().is_server_error(),
|
||||
"released get must admit (OK) or reach the backend (5xx); a 4xx means the denial path still fires: {}",
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
|
||||
/// `withheld-paths` and `list_visibility` must share the quarantine gate.
|
||||
#[sqlx::test]
|
||||
async fn withheld_paths_and_list_visibility_quarantine_opaque(pool: PgPool) {
|
||||
use crate::db::VisibilityMode;
|
||||
|
||||
let state = test_state(pool).await;
|
||||
let owner = "did:key:zVISQUAROWNERAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let short = owner.split(':').next_back().unwrap();
|
||||
let mut repo = seed_private_repo(owner, "vis-quar");
|
||||
repo.is_public = true;
|
||||
let repo_id = repo.id.clone();
|
||||
state.db.create_repo(&repo).await.unwrap();
|
||||
state
|
||||
.db
|
||||
.set_visibility_rule(&repo_id, "/secret/**", VisibilityMode::B, &[], owner)
|
||||
.await
|
||||
.unwrap();
|
||||
state.db.set_repo_quarantine(&repo_id, true).await.unwrap();
|
||||
|
||||
let router = crate::server::build_router(state.clone());
|
||||
let withheld = format!("/api/v1/repos/{short}/vis-quar/withheld-paths");
|
||||
let resp = router.clone().oneshot(anon_get(&withheld)).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"anon withheld-paths must 404 while quarantined"
|
||||
);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
!text.contains("/secret"),
|
||||
"withheld layout must not leak: {text}"
|
||||
);
|
||||
|
||||
for caller in [owner, short] {
|
||||
let resp = Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/withheld-paths",
|
||||
axum::routing::get(crate::api::visibility::withheld_paths),
|
||||
)
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
|
||||
crate::auth::optional_signature,
|
||||
))
|
||||
.with_state(state.clone())
|
||||
.oneshot(signed_request_as(
|
||||
caller,
|
||||
Method::GET,
|
||||
&withheld,
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"owner form {caller} withheld-paths must 404 while quarantined"
|
||||
);
|
||||
|
||||
let vis = format!("/api/v1/repos/{short}/vis-quar/visibility");
|
||||
let resp = Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/visibility",
|
||||
axum::routing::get(crate::api::visibility::list_visibility),
|
||||
)
|
||||
.with_state(state.clone())
|
||||
.oneshot(signed_request_as(caller, Method::GET, &vis, Body::empty()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"owner form {caller} list_visibility must 404 while quarantined"
|
||||
);
|
||||
|
||||
// PUT/DELETE must also opaque-404 while quarantined (no rule mutation).
|
||||
let put_body = Body::from(r#"{"path_glob":"/extra/**","reader_dids":[]}"#);
|
||||
let resp = Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/visibility",
|
||||
axum::routing::put(crate::api::visibility::set_visibility),
|
||||
)
|
||||
.with_state(state.clone())
|
||||
.oneshot(signed_request_as(caller, Method::PUT, &vis, put_body))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"owner form {caller} set_visibility must 404 while quarantined"
|
||||
);
|
||||
|
||||
let del_body = Body::from(r#"{"path_glob":"/secret/**"}"#);
|
||||
let resp = Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/visibility",
|
||||
axum::routing::delete(crate::api::visibility::remove_visibility),
|
||||
)
|
||||
.with_state(state.clone())
|
||||
.oneshot(signed_request_as(caller, Method::DELETE, &vis, del_body))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"owner form {caller} remove_visibility must 404 while quarantined"
|
||||
);
|
||||
}
|
||||
|
||||
// A signed non-owner must get 404 too, never 403: this leg is what pins
|
||||
// the quarantine-before-owner ordering. If require_owner were hoisted
|
||||
// above authorize_repo_read the same caller would read Forbidden, an
|
||||
// existence oracle on a hidden repo.
|
||||
let stranger = "did:key:zSTRANGERQUARAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let vis = format!("/api/v1/repos/{short}/vis-quar/visibility");
|
||||
for (method, path, body) in [
|
||||
(Method::GET, withheld.as_str(), Body::empty()),
|
||||
(Method::GET, vis.as_str(), Body::empty()),
|
||||
(
|
||||
Method::PUT,
|
||||
vis.as_str(),
|
||||
Body::from(r#"{"path_glob":"/extra/**","reader_dids":[]}"#),
|
||||
),
|
||||
(
|
||||
Method::DELETE,
|
||||
vis.as_str(),
|
||||
Body::from(r#"{"path_glob":"/secret/**"}"#),
|
||||
),
|
||||
] {
|
||||
let handler: axum::routing::MethodRouter<crate::state::AppState> = match method.clone()
|
||||
{
|
||||
Method::GET => axum::routing::get(crate::api::visibility::list_visibility),
|
||||
Method::PUT => axum::routing::put(crate::api::visibility::set_visibility),
|
||||
_ => axum::routing::delete(crate::api::visibility::remove_visibility),
|
||||
};
|
||||
let router = if path == withheld.as_str() {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/withheld-paths",
|
||||
axum::routing::get(crate::api::visibility::withheld_paths),
|
||||
)
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
|
||||
crate::auth::optional_signature,
|
||||
))
|
||||
.with_state(state.clone())
|
||||
} else {
|
||||
Router::new()
|
||||
.route("/api/v1/repos/{owner}/{repo}/visibility", handler)
|
||||
.with_state(state.clone())
|
||||
};
|
||||
let resp = router
|
||||
.oneshot(signed_request_as(stranger, method.clone(), path, body))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"non-owner {method} {path} must 404 (not 403) while quarantined"
|
||||
);
|
||||
}
|
||||
|
||||
state.db.set_repo_quarantine(&repo_id, false).await.unwrap();
|
||||
let resp = router.clone().oneshot(anon_get(&withheld)).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
text.contains("/secret"),
|
||||
"released withheld-paths must return globs: {text}"
|
||||
);
|
||||
|
||||
// Same release control for list_visibility (owner-gated after gate opens).
|
||||
let vis = format!("/api/v1/repos/{short}/vis-quar/visibility");
|
||||
let resp = Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/visibility",
|
||||
axum::routing::get(crate::api::visibility::list_visibility),
|
||||
)
|
||||
.with_state(state.clone())
|
||||
.oneshot(signed_request_as(owner, Method::GET, &vis, Body::empty()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"released list_visibility must admit owner"
|
||||
);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
text.contains("/secret"),
|
||||
"released list_visibility must return rules: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn repo_gate_public_repo_anon_read_admitted(pool: PgPool) {
|
||||
struct DirGuard(std::path::PathBuf);
|
||||
|
||||
+31
-2
@@ -267,7 +267,31 @@ async fn fetch_withheld(node: &str, owner: &str, name: &str) -> Result<(Vec<Stri
|
||||
};
|
||||
let resp = match resp {
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
Ok(r) if matches!(r.status().as_u16(), 404 | 501) => return Ok((Vec::new(), Vec::new())),
|
||||
Ok(r) if matches!(r.status().as_u16(), 404 | 501) => {
|
||||
// Two 404s share this status and must be told apart before the
|
||||
// legacy empty-glob fallback is taken: a route miss on a node that
|
||||
// predates withheld-paths has an empty body, while a current node
|
||||
// returns the JSON AppError body for absent/denied/quarantined
|
||||
// repos. The JSON body proves the route exists, so the repo is
|
||||
// unavailable to this caller and cloning cannot proceed.
|
||||
if r.status().as_u16() == 404 {
|
||||
let status = r.status();
|
||||
let body = crate::http::read_body_capped(r, 4096).await;
|
||||
if body.read_failed {
|
||||
// The read died part-way: we cannot classify the denial,
|
||||
// and a failed read must not collapse into the legacy
|
||||
// route-miss arm and start a clone.
|
||||
bail!("withheld-paths 404 body from this node could not be read");
|
||||
}
|
||||
if !body.text.trim().is_empty() {
|
||||
bail!(
|
||||
"repository {owner}/{name} is not available on this node ({status}): {}",
|
||||
sanitize_node_msg(&body.text)
|
||||
);
|
||||
}
|
||||
}
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
Ok(r) => bail!("withheld-paths lookup failed: {}", r.status()),
|
||||
Err(err) => return Err(err).context("fetching withheld paths"),
|
||||
};
|
||||
@@ -327,7 +351,12 @@ async fn recover_encrypted_blobs(
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
_ => return Ok(vec![]),
|
||||
// Discovery failure is not an empty inventory: a denied, quarantined,
|
||||
// erroring, or unreachable node must surface so the caller can warn
|
||||
// and still run the Arweave fallback rather than report a clean clone
|
||||
// with authorized blobs silently missing.
|
||||
Ok(r) => bail!("encrypted-blobs inventory unavailable: {}", r.status()),
|
||||
Err(err) => return Err(err).context("fetching encrypted-blobs inventory"),
|
||||
};
|
||||
let body: EncryptedBlobsResponse = resp.json().await.context("parsing encrypted-blobs")?;
|
||||
if body.blobs.is_empty() {
|
||||
|
||||
+131
-24
@@ -963,7 +963,7 @@ async fn call_tool(
|
||||
.as_array()
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
|
||||
.unwrap_or_else(|| vec!["*"]);
|
||||
let owner = resolve_owner(&args, &client).await?;
|
||||
let (owner, name) = resolve_owner_repo(&args, repo, &keypair, &client).await?;
|
||||
let body = serde_json::to_vec(&json!({
|
||||
"url": url,
|
||||
"secret": args["secret"],
|
||||
@@ -971,7 +971,7 @@ async fn call_tool(
|
||||
}))?;
|
||||
let resp = crate::http::read_json(
|
||||
client
|
||||
.post(&format!("/api/v1/repos/{owner}/{repo}/hooks"), &body)
|
||||
.post(&format!("/api/v1/repos/{owner}/{name}/hooks"), &body)
|
||||
.await?,
|
||||
"create webhook",
|
||||
)
|
||||
@@ -981,19 +981,12 @@ async fn call_tool(
|
||||
|
||||
"webhook_list" => {
|
||||
let repo = args["repo"].as_str().context("missing 'repo'")?;
|
||||
// Owner-gated route: an explicit `owner` arg wins, otherwise default to
|
||||
// the signing keypair's short DID (NOT the node DID that resolve_owner
|
||||
// returns). The request is signed by this keypair, so the path owner
|
||||
// must match it or the node returns 403/404.
|
||||
let owner = if let Some(o) = args.get("owner").and_then(|v| v.as_str()) {
|
||||
o.to_string()
|
||||
} else {
|
||||
let kp = keypair
|
||||
.as_ref()
|
||||
.context("no identity found — run `gl identity new` first")?;
|
||||
let did = kp.did().to_string();
|
||||
did.split(':').next_back().unwrap_or(&did).to_string()
|
||||
};
|
||||
// Owner-gated route: `owner/repo` splits first, an explicit `owner`
|
||||
// arg wins next, otherwise the signing keypair's short DID (NOT the
|
||||
// node DID that resolve_owner returns). The request is signed by
|
||||
// this keypair, so the path owner must match it or the node
|
||||
// returns 403/404.
|
||||
let (owner, name) = resolve_owner_repo(&args, repo, &keypair, &client).await?;
|
||||
// Owner-gated AND auth-required route (list_webhooks 401s a headerless
|
||||
// caller, then 403s a non-owner). get_maybe_signed signs when an
|
||||
// identity is present (so the owner is authorized) and otherwise sends
|
||||
@@ -1004,7 +997,7 @@ async fn call_tool(
|
||||
// failing client-side before the node is contacted.
|
||||
let resp = crate::http::read_json(
|
||||
client
|
||||
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{repo}/hooks"))
|
||||
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}/hooks"))
|
||||
.await?,
|
||||
"webhook_list",
|
||||
)
|
||||
@@ -1015,11 +1008,11 @@ async fn call_tool(
|
||||
"webhook_delete" => {
|
||||
let repo = args["repo"].as_str().context("missing 'repo'")?;
|
||||
let id = args["id"].as_str().context("missing 'id'")?;
|
||||
let owner = resolve_owner(&args, &client).await?;
|
||||
let (owner, name) = resolve_owner_repo(&args, repo, &keypair, &client).await?;
|
||||
let body = serde_json::to_vec(&json!({}))?;
|
||||
let resp = crate::http::read_json(
|
||||
client
|
||||
.delete(&format!("/api/v1/repos/{owner}/{repo}/hooks/{id}"), &body)
|
||||
.delete(&format!("/api/v1/repos/{owner}/{name}/hooks/{id}"), &body)
|
||||
.await?,
|
||||
"delete webhook",
|
||||
)
|
||||
@@ -1345,6 +1338,38 @@ async fn resolve_owner(args: &Value, client: &NodeClient) -> Result<String> {
|
||||
Ok(did.split(':').next_back().unwrap_or(did).to_string())
|
||||
}
|
||||
|
||||
/// Resolve (owner, repo) for the webhook tools, matching the CLI's
|
||||
/// `resolve_owner_repo_pair` contract: an `owner/repo` string splits first, an
|
||||
/// explicit `owner` arg wins next, then the signing keypair's short DID. The
|
||||
/// last resort is the node root DID via `resolve_owner`, reachable only by
|
||||
/// callers that have no keypair — the unsigned `webhook_list` arm, and an
|
||||
/// unsigned `webhook_delete` the same way `resolve_owner` always served it —
|
||||
/// for a bare
|
||||
/// repo name under a signed caller the path owner must be the caller's DID or
|
||||
/// the node 403/404s the request under the wrong namespace.
|
||||
async fn resolve_owner_repo(
|
||||
args: &Value,
|
||||
repo: &str,
|
||||
keypair: &Option<gitlawb_core::identity::Keypair>,
|
||||
client: &NodeClient,
|
||||
) -> Result<(String, String)> {
|
||||
if let Some((o, r)) = repo.split_once('/') {
|
||||
return Ok((o.to_string(), r.to_string()));
|
||||
}
|
||||
if let Some(o) = args.get("owner").and_then(|v| v.as_str()) {
|
||||
return Ok((o.to_string(), repo.to_string()));
|
||||
}
|
||||
if let Some(kp) = keypair {
|
||||
let did = kp.did().to_string();
|
||||
return Ok((
|
||||
did.split(':').next_back().unwrap_or(&did).to_string(),
|
||||
repo.to_string(),
|
||||
));
|
||||
}
|
||||
let owner = resolve_owner(args, client).await?;
|
||||
Ok((owner, repo.to_string()))
|
||||
}
|
||||
|
||||
/// Parse git pkt-line info/refs response into a list of {ref, sha} objects.
|
||||
fn parse_info_refs(bytes: &[u8]) -> Value {
|
||||
let mut refs = Vec::new();
|
||||
@@ -2482,11 +2507,27 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_webhook_list_without_keypair_errors() {
|
||||
// No identity loaded and no explicit owner → the tool must error rather
|
||||
// than fall back to the node DID and issue an unsigned, mis-scoped GET.
|
||||
let server = mockito::Server::new_async().await;
|
||||
async fn test_webhook_list_without_keypair_surfaces_node_denial() {
|
||||
// No identity loaded and no explicit owner → the tool falls back to the
|
||||
// node DID for the path and still issues the GET unsigned, so the node
|
||||
// itself answers the denial (list_webhooks 401s a headerless caller)
|
||||
// rather than the client inventing a failure before contact.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let dir = tempfile::TempDir::new().unwrap(); // empty: no identity.pem
|
||||
let _root = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"did":"did:key:z6MkNodeOperator"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let _hooks = server
|
||||
.mock("GET", "/api/v1/repos/z6MkNodeOperator/myrepo/hooks")
|
||||
.with_status(401)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"signature required"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = call_tool(
|
||||
"webhook_list",
|
||||
@@ -2495,8 +2536,13 @@ mod tests {
|
||||
Some(dir.path()),
|
||||
)
|
||||
.await
|
||||
.expect_err("must error without an identity");
|
||||
assert!(err.to_string().contains("no identity found"), "got: {err}");
|
||||
.expect_err("unsigned list must surface the node denial");
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("401") || msg.contains("signature required"),
|
||||
"got: {msg}"
|
||||
);
|
||||
_hooks.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3161,4 +3207,65 @@ mod tests {
|
||||
assert!(err.to_string().contains("404"), "got: {err}");
|
||||
_m.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_owner_repo_splits_slash_form() {
|
||||
let server = mockito::Server::new_async().await;
|
||||
let (owner, name) = resolve_owner_repo(
|
||||
&json!({}),
|
||||
"alice/proj",
|
||||
&None,
|
||||
&NodeClient::new(server.url(), None),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((owner.as_str(), name.as_str()), ("alice", "proj"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_owner_repo_owner_arg_beats_keypair() {
|
||||
let server = mockito::Server::new_async().await;
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let (owner, name) = resolve_owner_repo(
|
||||
&json!({"owner": "bob"}),
|
||||
"proj",
|
||||
&Some(kp.clone()),
|
||||
&NodeClient::new(server.url(), None),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((owner.as_str(), name.as_str()), ("bob", "proj"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_owner_repo_bare_repo_uses_keypair_did() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// A root mock answering with a different DID must never be consulted:
|
||||
// under a signed caller the path owner is the keypair's short DID.
|
||||
let _m = server
|
||||
.mock("GET", "/")
|
||||
.with_status(200)
|
||||
.with_body(r#"{"did":"did:key:z6MkNodeOperator"}"#)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
let short = kp
|
||||
.did()
|
||||
.to_string()
|
||||
.split(':')
|
||||
.next_back()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let (owner, name) = resolve_owner_repo(
|
||||
&json!({}),
|
||||
"proj",
|
||||
&Some(kp),
|
||||
&NodeClient::new(server.url(), None),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((owner.as_str(), name.as_str()), (short.as_str(), "proj"));
|
||||
_m.assert_async().await;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user