mirror of
https://github.com/Twigpine/node.git
synced 2026-11-08 17:55:53 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94b870e368 | ||
|
|
52b834365e | ||
|
|
35a4443a38 | ||
|
|
96260a0eb7 | ||
|
|
3131e220b6 |
No files matched your search
@@ -1,13 +1,12 @@
|
||||
//! GET /api/v1/arweave/anchors — list Arweave ref-update anchors.
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Query, State},
|
||||
extract::{Query, State},
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::db::normalize_owner_key;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::error::Result;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -22,62 +21,17 @@ fn default_limit() -> i64 {
|
||||
}
|
||||
|
||||
/// GET /api/v1/arweave/anchors
|
||||
///
|
||||
/// `?repo=<owner>/<name>` returns anchors for one repository and binds the
|
||||
/// request to the canonical read-authorization path used by every other
|
||||
/// repo-scoped read: the same `authorize_repo_read` helper, with the caller
|
||||
/// identity attached. Missing repositories, quarantined mirrors, and signed
|
||||
/// non-readers all collapse to the standard `404` (no existence oracle). The
|
||||
/// unscoped listing is auth-only — the #121 contract — and does not admit
|
||||
/// visibility filtering, which is the #136 stale-index class and explicitly
|
||||
/// out of scope for this auth slice.
|
||||
pub async fn list_anchors(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ListAnchorsQuery>,
|
||||
auth: Option<Extension<crate::auth::AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
// The route's `optional_signature` layer is permissive (it admits unsigned
|
||||
// legacy callers) so this in-handler check is the actual gate. A signed
|
||||
// non-reader still passes this check and proceeds to the authz step below.
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
if caller.is_none() {
|
||||
return Err(AppError::Unauthorized(
|
||||
"authentication required for anchor listing".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Clamp before the SQL query. A negative or non-numeric limit must not
|
||||
// reach Postgres as `LIMIT -1` (which it rejects as a db error / 500) — the
|
||||
// contract is the same default ceiling as the listing page itself.
|
||||
let limit = q.limit.clamp(0, 200);
|
||||
|
||||
let anchors = if let Some(repo_slug) = q.repo.as_deref() {
|
||||
// Parse the user-supplied "owner/name" through the same slug validator
|
||||
// the sync path uses, so a malformed query is a 400, not a 500.
|
||||
let (owner, name) = match crate::git::repo_store::validate_repo_slug(repo_slug) {
|
||||
Ok(parts) => parts,
|
||||
Err(e) => return Err(AppError::BadRequest(format!("invalid ?repo: {e}"))),
|
||||
};
|
||||
// Read gate. Returns `RepoNotFound` (→ 404) indistinguishably for
|
||||
// missing repos, quarantined mirrors, and signed non-readers.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, owner, name, caller, "/").await?;
|
||||
// Build the SQL filter from the canonical stored slug, NOT from the
|
||||
// user-supplied `?repo=` string. Anchor rows are written as
|
||||
// `{normalize_owner_key(owner_did)}/{name}` (the short form), so a
|
||||
// request that passes authz with the full `did:key:…/name` form would
|
||||
// match zero rows and return a false empty page. The gate already
|
||||
// verified the repo is readable; `record.owner_did` is the canonical
|
||||
// identity and `record.name` is the stored name.
|
||||
let stored_slug = format!("{}/{}", normalize_owner_key(&record.owner_did), record.name);
|
||||
state
|
||||
.db
|
||||
.list_arweave_anchors(Some(&stored_slug), limit)
|
||||
.await?
|
||||
} else {
|
||||
// No scope → no repo-read decision. Auth-only.
|
||||
state.db.list_arweave_anchors(None, limit).await?
|
||||
};
|
||||
let limit = q.limit.min(200);
|
||||
// Bare `?` so connection-class sqlx failures downcast to `AppError::Db` and
|
||||
// map to 503 `db_unavailable` (not 500 via `.map_err(AppError::Internal)`) (#251).
|
||||
let anchors = state
|
||||
.db
|
||||
.list_arweave_anchors(q.repo.as_deref(), limit)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"anchors": anchors,
|
||||
@@ -106,7 +60,6 @@ mod closed_pool_tests {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/arweave/anchors")
|
||||
.extension(crate::auth::AuthenticatedDid("did:key:test".into()))
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
|
||||
@@ -2133,15 +2133,7 @@ async fn gate_and_serve(
|
||||
/// Returns all CIDs that have been pinned to the local IPFS node from git
|
||||
/// objects received via push. Each entry includes the git SHA-256 hex, the
|
||||
/// CIDv1 string, and the timestamp when it was pinned.
|
||||
pub async fn list_pins(
|
||||
State(state): State<AppState>,
|
||||
auth: Option<Extension<crate::auth::AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
if auth.is_none() {
|
||||
return Err(crate::error::AppError::Unauthorized(
|
||||
"authentication required for pin listing".into(),
|
||||
));
|
||||
}
|
||||
pub async fn list_pins(State(state): State<AppState>) -> Result<Json<serde_json::Value>> {
|
||||
// Bare `?` so connection-class sqlx failures downcast to `AppError::Db` and
|
||||
// map to 503 `db_unavailable` (not 500 via `.map_err(AppError::Internal)`) (#251).
|
||||
let pins = state.db.list_pinned_cids().await?;
|
||||
@@ -2434,7 +2426,6 @@ mod closed_pool_tests {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/ipfs/pins")
|
||||
.extension(crate::auth::AuthenticatedDid("did:key:test".into()))
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
|
||||
@@ -70,8 +70,6 @@ pub struct PeerResponse {
|
||||
pub reachable: bool,
|
||||
}
|
||||
|
||||
pub(crate) const PUBLIC_HTTP_URL_REQUIREMENT: &str = "must be a public http(s) URL (no loopback, private, localhost, .localhost, .internal, or .local hosts)";
|
||||
|
||||
/// Extract an IPv4 address embedded in an IPv6 literal across the transition
|
||||
/// formats that carry one: IPv4-mapped (`::ffff:a.b.c.d`), IPv4-compatible
|
||||
/// (`::a.b.c.d`), 6to4 (`2002:WWXX:YYZZ::/16`), and the NAT64 well-known prefix
|
||||
@@ -106,8 +104,8 @@ fn embedded_ipv4(v6: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
|
||||
|
||||
/// Whether a peer `http_url` is a public http(s) endpoint safe to register.
|
||||
/// Rejects non-http(s) schemes, loopback/unspecified/private/link-local IPs,
|
||||
/// and `localhost` / `.localhost` / `.local` / `.internal` hostnames. Used at
|
||||
/// announce time and by the boot-time prune of already-poisoned rows.
|
||||
/// and `localhost` / `.local` / `.internal` hostnames. Used at announce time
|
||||
/// and by the boot-time prune of already-poisoned rows.
|
||||
pub fn is_public_http_url(raw: &str) -> bool {
|
||||
let url = match reqwest::Url::parse(raw) {
|
||||
Ok(u) => u,
|
||||
@@ -127,7 +125,6 @@ pub fn is_public_http_url(raw: &str) -> bool {
|
||||
host.truncate(host.trim_end_matches('.').len());
|
||||
if host.is_empty()
|
||||
|| host == "localhost"
|
||||
|| host.ends_with(".localhost")
|
||||
|| host.ends_with(".local")
|
||||
|| host.ends_with(".internal")
|
||||
{
|
||||
@@ -263,9 +260,9 @@ pub async fn announce(
|
||||
// and turn our outbound sync-notify fan-out into an SSRF probe — and bury
|
||||
// the real peers under junk so node-origin repos stop replicating.
|
||||
if !is_public_http_url(&req.http_url) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"http_url {PUBLIC_HTTP_URL_REQUIREMENT}"
|
||||
)));
|
||||
return Err(AppError::BadRequest(
|
||||
"http_url must be a public http(s) URL (no loopback, private, or .internal/.local hosts)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Reject self-announcements: a peer row whose http_url is our own public
|
||||
@@ -544,7 +541,6 @@ mod tests {
|
||||
fn rejects_loopback_private_and_internal() {
|
||||
for bad in [
|
||||
"http://localhost:7545",
|
||||
"http://node.localhost:7545",
|
||||
"http://127.0.0.1:5432/",
|
||||
"http://localhost:22/",
|
||||
"http://0.0.0.0:7545",
|
||||
@@ -624,14 +620,11 @@ mod tests {
|
||||
#[test]
|
||||
fn rejects_repeated_trailing_root_dots() {
|
||||
// Stripping a single root dot leaves `localhost.`, which matches
|
||||
// neither the equality nor the suffix check. The subdomain form now
|
||||
// exercises the `.localhost` suffix rejection together with the extra
|
||||
// trailing dot.
|
||||
for bad in [
|
||||
"http://localhost../",
|
||||
"http://localhost...:7545",
|
||||
"http://node.localhost../",
|
||||
] {
|
||||
// neither the equality nor the suffix check. The subdomain form
|
||||
// (`node.localhost..`) is not asserted here: `.localhost` is not yet a
|
||||
// rejected suffix on this base, so that case belongs to whichever
|
||||
// change adds it rather than to this one.
|
||||
for bad in ["http://localhost../", "http://localhost...:7545"] {
|
||||
assert!(!is_public_http_url(bad), "{bad:?} must be rejected");
|
||||
}
|
||||
}
|
||||
@@ -1589,31 +1582,6 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn announce_rejects_localhost_subdomains_with_an_accurate_error(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let did = Keypair::generate().did().to_string();
|
||||
let resp = announce_only(state.clone())
|
||||
.oneshot(announce_as(
|
||||
&did,
|
||||
&announce_body(&did, "https://node.localhost:7545"),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (status, error, message) = status_and_error(resp).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(error, "bad_request");
|
||||
assert_eq!(
|
||||
message,
|
||||
"http_url must be a public http(s) URL (no loopback, private, localhost, .localhost, .internal, or .local hosts)"
|
||||
);
|
||||
assert!(
|
||||
snapshot(&state.db, &did).await.is_none(),
|
||||
"a rejected .localhost announce must leave no row behind"
|
||||
);
|
||||
}
|
||||
|
||||
/// U4 scenario 1, and the first test the keyid branch has ever had: a caller
|
||||
/// who proved control of one DID must not announce another's. Kills
|
||||
/// neutralizing the handler's keyid comparison, and kills demoting this
|
||||
|
||||
@@ -3508,17 +3508,6 @@ pub struct ForkRepoRequest {
|
||||
}
|
||||
|
||||
/// POST /api/v1/repos/:owner/:repo/fork
|
||||
/// Argv for the fork's mirror clone, with `--` before the first positional (#374).
|
||||
///
|
||||
/// Both positionals here are server-derived paths rather than caller-supplied
|
||||
/// URLs, so this is defence in depth rather than a reachable injection. It is
|
||||
/// still the same argv contract as the other clone sites, and it is extracted for
|
||||
/// the same reason: so the delimiter's placement is pinned by a test instead of
|
||||
/// resting on review.
|
||||
fn fork_clone_args<'a>(source: &'a str, dest: &'a str) -> [&'a str; 5] {
|
||||
["clone", "--mirror", "--", source, dest]
|
||||
}
|
||||
|
||||
pub async fn fork_repo(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
@@ -3604,10 +3593,12 @@ pub async fn fork_repo(
|
||||
|
||||
// Clone the source repo as a mirror
|
||||
let output = std::process::Command::new("git")
|
||||
.args(fork_clone_args(
|
||||
.args([
|
||||
"clone",
|
||||
"--mirror",
|
||||
source_path.to_str().unwrap_or(""),
|
||||
disk_path.to_str().unwrap_or(""),
|
||||
))
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| AppError::Git(format!("git clone --mirror failed: {e}")))?;
|
||||
|
||||
@@ -4074,30 +4065,6 @@ fn dedupe_canonical_repos(rows: Vec<(RepoRecord, i64)>) -> Vec<(RepoRecord, i64)
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #374: the fork path builds its own `git clone --mirror` argv. Driven
|
||||
/// through real git so a missing or misplaced `--` fails, matching the
|
||||
/// coverage on the `gl clone`, `gl mirror`, and sync sinks.
|
||||
#[test]
|
||||
fn fork_clone_never_parses_the_source_as_a_git_option() {
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
let dest = td.path().join("fork.git");
|
||||
let injected = "--upload-pack=false";
|
||||
|
||||
let out = std::process::Command::new("git")
|
||||
.env("LC_ALL", "C")
|
||||
.args(fork_clone_args(injected, dest.to_str().unwrap()))
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
|
||||
assert!(!out.status.success(), "an option-shaped source must fail");
|
||||
assert!(
|
||||
stderr.contains(&format!("repository '{injected}'")),
|
||||
"git must name the source as the repository, not consume it as an option: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
use crate::auth::caller_authorized_to_push;
|
||||
use crate::error::AppError;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
@@ -46,10 +46,9 @@ pub async fn create_webhook(
|
||||
// endpoints (SSRF). Delivery runs on the shared no-redirect client
|
||||
// (main.rs), which closes the 3xx-to-internal bounce.
|
||||
if !crate::api::peers::is_public_http_url(&req.url) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"webhook URL {}",
|
||||
crate::api::peers::PUBLIC_HTTP_URL_REQUIREMENT
|
||||
)));
|
||||
return Err(AppError::BadRequest(
|
||||
"webhook URL must be a public http(s) URL (no loopback, private, or .internal/.local hosts)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let events = req.events.unwrap_or_else(|| vec!["*".into()]);
|
||||
@@ -142,19 +141,7 @@ pub async fn delete_webhook(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::extract::{Extension, Path, State};
|
||||
use axum::Json;
|
||||
use chrono::Utc;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{create_webhook, CreateWebhookRequest};
|
||||
use crate::api::peers::{is_public_http_url, PUBLIC_HTTP_URL_REQUIREMENT};
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::db::RepoRecord;
|
||||
use crate::error::AppError;
|
||||
use crate::test_support::test_state;
|
||||
use crate::api::peers::is_public_http_url;
|
||||
|
||||
// create_webhook gates req.url through is_public_http_url. Pin the exact
|
||||
// SSRF targets from issue #81 so the webhook path can never regress to the
|
||||
@@ -165,7 +152,6 @@ mod tests {
|
||||
"http://127.0.0.1:5432/",
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://localhost/",
|
||||
"http://node.localhost/",
|
||||
"http://10.0.0.5/",
|
||||
"http://[::1]/",
|
||||
// IPv6 transition encodings smuggling loopback v4 (6to4 / NAT64).
|
||||
@@ -183,53 +169,4 @@ mod tests {
|
||||
assert!(is_public_http_url("https://hooks.example.com/gitlawb"));
|
||||
assert!(is_public_http_url("http://203.0.113.10:7545/"));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn webhook_localhost_rejection_uses_shared_public_url_contract(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let owner = Keypair::generate().did().to_string();
|
||||
let repo_id = Uuid::new_v4().to_string();
|
||||
state
|
||||
.db
|
||||
.create_repo(&RepoRecord {
|
||||
id: repo_id.clone(),
|
||||
name: "webhook-contract".to_string(),
|
||||
owner_did: owner.clone(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".to_string(),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
disk_path: "/tmp/webhook-contract.git".to_string(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = create_webhook(
|
||||
State(state.clone()),
|
||||
Extension(AuthenticatedDid(owner.clone())),
|
||||
Path((owner, "webhook-contract".to_string())),
|
||||
Json(CreateWebhookRequest {
|
||||
url: "https://node.localhost/hook".to_string(),
|
||||
secret: None,
|
||||
events: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Err(AppError::BadRequest(message)) => assert_eq!(
|
||||
message,
|
||||
format!("webhook URL {PUBLIC_HTTP_URL_REQUIREMENT}")
|
||||
),
|
||||
Err(error) => panic!("unexpected webhook rejection: {error}"),
|
||||
Ok(_) => panic!("a .localhost webhook must be rejected"),
|
||||
}
|
||||
assert!(
|
||||
state.db.list_webhooks(&repo_id).await.unwrap().is_empty(),
|
||||
"a rejected .localhost webhook must leave no row behind"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,133 +23,6 @@ pub struct RepoRecord {
|
||||
pub machine_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Which side is authoritative for a continuously mirrored repository.
|
||||
///
|
||||
/// A repository without an `upstream_url` has no mirror status. Transitioning
|
||||
/// states always carry a durable job id and phase so a node restart can resume
|
||||
/// or roll back instead of guessing which side owns writes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub enum MirrorStatus {
|
||||
Inbound,
|
||||
TransitioningInboundToOutbound,
|
||||
Outbound,
|
||||
TransitioningOutboundToInbound,
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
impl MirrorStatus {
|
||||
fn from_db(value: &str) -> Result<Self> {
|
||||
match value {
|
||||
"inbound" => Ok(Self::Inbound),
|
||||
"transitioning_inbound_to_outbound" => Ok(Self::TransitioningInboundToOutbound),
|
||||
"outbound" => Ok(Self::Outbound),
|
||||
"transitioning_outbound_to_inbound" => Ok(Self::TransitioningOutboundToInbound),
|
||||
_ => anyhow::bail!("invalid mirror status stored in database"),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_transitioning(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::TransitioningInboundToOutbound | Self::TransitioningOutboundToInbound
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Durable progress marker for a mirror-authority transition job.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub enum MirrorTransitionPhase {
|
||||
Queued,
|
||||
Starting,
|
||||
InitializingMirrorFetch,
|
||||
DrainingWrites,
|
||||
FinalizingMirrorFetch,
|
||||
FinalizingMirrorPush,
|
||||
CommittingTargetStatus,
|
||||
Completed,
|
||||
RollingBack,
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
impl MirrorTransitionPhase {
|
||||
fn from_db(value: &str) -> Result<Self> {
|
||||
match value {
|
||||
"queued" => Ok(Self::Queued),
|
||||
"starting" => Ok(Self::Starting),
|
||||
"initializing_mirror_fetch" => Ok(Self::InitializingMirrorFetch),
|
||||
"draining_writes" => Ok(Self::DrainingWrites),
|
||||
"finalizing_mirror_fetch" => Ok(Self::FinalizingMirrorFetch),
|
||||
"finalizing_mirror_push" => Ok(Self::FinalizingMirrorPush),
|
||||
"committing_target_status" => Ok(Self::CommittingTargetStatus),
|
||||
"completed" => Ok(Self::Completed),
|
||||
"rolling_back" => Ok(Self::RollingBack),
|
||||
_ => anyhow::bail!("invalid mirror transition phase stored in database"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub struct RepoMirrorState {
|
||||
pub repo_id: String,
|
||||
pub upstream_url: String,
|
||||
pub status: MirrorStatus,
|
||||
pub transition_id: Option<Uuid>,
|
||||
pub transition_phase: Option<MirrorTransitionPhase>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
impl RepoMirrorState {
|
||||
fn validate(&self) -> Result<()> {
|
||||
validate_mirror_upstream_url(&self.upstream_url)?;
|
||||
|
||||
match (
|
||||
self.status.is_transitioning(),
|
||||
self.transition_id,
|
||||
self.transition_phase,
|
||||
) {
|
||||
(true, Some(_), Some(phase)) if phase != MirrorTransitionPhase::Completed => Ok(()),
|
||||
(false, None, None) => Ok(()),
|
||||
(false, Some(_), Some(MirrorTransitionPhase::Completed)) => Ok(()),
|
||||
_ => anyhow::bail!("mirror status and transition metadata are inconsistent"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
fn validate_mirror_upstream_url(raw: &str) -> Result<reqwest::Url> {
|
||||
if raw.is_empty() || raw.len() > 2048 {
|
||||
anyhow::bail!("mirror upstream URL must contain 1 to 2048 bytes");
|
||||
}
|
||||
if raw.chars().any(|c| c.is_whitespace() || c.is_control()) {
|
||||
anyhow::bail!("mirror upstream URL contains whitespace or control characters");
|
||||
}
|
||||
|
||||
let url = reqwest::Url::parse(raw).context("parsing mirror upstream URL")?;
|
||||
if url.scheme() != "https" {
|
||||
anyhow::bail!("mirror upstream URL must use HTTPS");
|
||||
}
|
||||
if !crate::api::peers::is_public_http_url(raw) {
|
||||
anyhow::bail!("mirror upstream URL must use a public host");
|
||||
}
|
||||
if url.host_str().is_none() || url.path().trim_matches('/').is_empty() {
|
||||
anyhow::bail!("mirror upstream URL must include a host and repository path");
|
||||
}
|
||||
if !url.username().is_empty() || url.password().is_some() {
|
||||
anyhow::bail!("mirror upstream URL must not embed credentials");
|
||||
}
|
||||
if url.query().is_some() || url.fragment().is_some() {
|
||||
anyhow::bail!("mirror upstream URL must not contain a query or fragment");
|
||||
}
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
/// One row of a keyset page from [`Db::list_repos_page_for_scan`].
|
||||
///
|
||||
/// Carries the row's quarantine flag inline (so the IPFS scan needs no separate
|
||||
@@ -1285,79 +1158,6 @@ const MIGRATIONS: &[Migration] = &[
|
||||
"ALTER TABLE pin_repair_sweep ADD COLUMN IF NOT EXISTS discovery_cursor_id TEXT NOT NULL DEFAULT ''",
|
||||
],
|
||||
},
|
||||
// Reservation: open branches currently claim migration versions through
|
||||
// v29. Gaps are valid, while reusing a version can silently skip one
|
||||
// branch's schema when both eventually land. v30 and v31 are intentionally
|
||||
// separate so validation does not inherit v30's stronger table lock.
|
||||
Migration {
|
||||
version: 30,
|
||||
name: "repo_upstream_mirror_state",
|
||||
stmts: &[
|
||||
// Nullable, default-free columns keep this migration metadata-only
|
||||
// for every existing non-mirror repository.
|
||||
"ALTER TABLE repos ADD COLUMN IF NOT EXISTS upstream_url TEXT",
|
||||
"ALTER TABLE repos ADD COLUMN IF NOT EXISTS mirror_status TEXT",
|
||||
"ALTER TABLE repos ADD COLUMN IF NOT EXISTS mirror_transition_id UUID",
|
||||
"ALTER TABLE repos ADD COLUMN IF NOT EXISTS mirror_transition_phase TEXT",
|
||||
"ALTER TABLE repos ADD COLUMN IF NOT EXISTS mirror_updated_at TEXT",
|
||||
// A constraint name does not prove its definition. Replace any
|
||||
// same-named manual or partial-recovery artifact before installing
|
||||
// the exact invariant this migration promises.
|
||||
"ALTER TABLE repos DROP CONSTRAINT IF EXISTS repos_mirror_state_valid",
|
||||
// PostgreSQL accepts a CHECK whose result is NULL. Wrap the complete
|
||||
// invariant in IS TRUE so a missing status or phase cannot slip
|
||||
// through three-valued logic.
|
||||
r#"ALTER TABLE repos ADD CONSTRAINT repos_mirror_state_valid CHECK ((
|
||||
(
|
||||
upstream_url IS NULL
|
||||
AND mirror_status IS NULL
|
||||
AND mirror_transition_id IS NULL
|
||||
AND mirror_transition_phase IS NULL
|
||||
AND mirror_updated_at IS NULL
|
||||
)
|
||||
OR
|
||||
(
|
||||
upstream_url IS NOT NULL
|
||||
AND upstream_url LIKE 'https://%'
|
||||
AND upstream_url <> 'https://'
|
||||
AND mirror_updated_at IS NOT NULL
|
||||
AND (
|
||||
(
|
||||
mirror_status IN ('inbound', 'outbound')
|
||||
AND (
|
||||
(mirror_transition_id IS NULL AND mirror_transition_phase IS NULL)
|
||||
OR
|
||||
(mirror_transition_id IS NOT NULL AND mirror_transition_phase = 'completed')
|
||||
)
|
||||
)
|
||||
OR
|
||||
(
|
||||
mirror_status IN (
|
||||
'transitioning_inbound_to_outbound',
|
||||
'transitioning_outbound_to_inbound'
|
||||
)
|
||||
AND mirror_transition_id IS NOT NULL
|
||||
AND mirror_transition_phase IN (
|
||||
'queued',
|
||||
'starting',
|
||||
'initializing_mirror_fetch',
|
||||
'draining_writes',
|
||||
'finalizing_mirror_fetch',
|
||||
'finalizing_mirror_push',
|
||||
'committing_target_status',
|
||||
'rolling_back'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
) IS TRUE) NOT VALID"#,
|
||||
],
|
||||
},
|
||||
Migration {
|
||||
version: 31,
|
||||
name: "validate_repo_upstream_mirror_state",
|
||||
stmts: &["ALTER TABLE repos VALIDATE CONSTRAINT repos_mirror_state_valid"],
|
||||
},
|
||||
];
|
||||
|
||||
/// Max distinct source repos recorded per pinned object (F1, #173 jatmn round 8).
|
||||
@@ -1484,72 +1284,6 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attach an external HTTPS upstream to a canonical repository in inbound
|
||||
/// mode. This is deliberately idempotent only for the same untouched
|
||||
/// inbound configuration; it never overwrites an existing upstream or
|
||||
/// resets a transition that may be in progress.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub async fn configure_inbound_mirror(
|
||||
&self,
|
||||
repo_id: &str,
|
||||
upstream_url: &str,
|
||||
) -> Result<RepoMirrorState> {
|
||||
Uuid::parse_str(repo_id)
|
||||
.context("continuous mirrors require a canonical UUID repository id")?;
|
||||
let upstream_url = validate_mirror_upstream_url(upstream_url)?.to_string();
|
||||
let updated_at = Utc::now().to_rfc3339();
|
||||
|
||||
let row = sqlx::query(
|
||||
"UPDATE repos
|
||||
SET upstream_url = $2,
|
||||
mirror_status = 'inbound',
|
||||
mirror_transition_id = NULL,
|
||||
mirror_transition_phase = NULL,
|
||||
mirror_updated_at = COALESCE(mirror_updated_at, $3)
|
||||
WHERE id = $1
|
||||
AND (
|
||||
upstream_url IS NULL
|
||||
OR (
|
||||
upstream_url = $2
|
||||
AND mirror_status = 'inbound'
|
||||
AND mirror_transition_id IS NULL
|
||||
AND mirror_transition_phase IS NULL
|
||||
)
|
||||
)
|
||||
RETURNING id, upstream_url, mirror_status, mirror_transition_id,
|
||||
mirror_transition_phase, mirror_updated_at",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(&upstream_url)
|
||||
.bind(&updated_at)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
let row = row.context(
|
||||
"repository does not exist or already has a different/transitioning mirror configuration",
|
||||
)?;
|
||||
row_to_repo_mirror_state(row)
|
||||
}
|
||||
|
||||
/// Return the durable mirror state, or `None` for a normal non-mirrored
|
||||
/// repository (and for an unknown id). Callers that need to distinguish
|
||||
/// those cases must resolve the repository first through the auth-gated
|
||||
/// repository path.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub async fn get_repo_mirror_state(&self, repo_id: &str) -> Result<Option<RepoMirrorState>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, upstream_url, mirror_status, mirror_transition_id,
|
||||
mirror_transition_phase, mirror_updated_at
|
||||
FROM repos
|
||||
WHERE id = $1 AND upstream_url IS NOT NULL",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
row.map(row_to_repo_mirror_state).transpose()
|
||||
}
|
||||
|
||||
/// Register a mirrored repo from a peer in the local DB so git smart HTTP can serve it.
|
||||
/// Uses INSERT OR IGNORE (SQLite) / ON CONFLICT DO NOTHING (Postgres) so it's idempotent.
|
||||
pub async fn upsert_mirror_repo(
|
||||
@@ -4196,576 +3930,6 @@ impl Db {
|
||||
|
||||
// ── Row helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
fn row_to_repo_mirror_state(r: sqlx::postgres::PgRow) -> Result<RepoMirrorState> {
|
||||
let repo_id = r
|
||||
.try_get("id")
|
||||
.context("reading mirror repository id from database")?;
|
||||
let upstream_url = r
|
||||
.try_get("upstream_url")
|
||||
.context("reading mirror upstream URL from database")?;
|
||||
let status = r
|
||||
.try_get::<String, _>("mirror_status")
|
||||
.context("reading mirror status from database")?;
|
||||
let status = MirrorStatus::from_db(&status)?;
|
||||
let transition_id = r
|
||||
.try_get("mirror_transition_id")
|
||||
.context("reading mirror transition id from database")?;
|
||||
let transition_phase = r
|
||||
.try_get::<Option<String>, _>("mirror_transition_phase")
|
||||
.context("reading mirror transition phase from database")?
|
||||
.as_deref()
|
||||
.map(MirrorTransitionPhase::from_db)
|
||||
.transpose()?;
|
||||
let updated_at = r
|
||||
.try_get::<String, _>("mirror_updated_at")
|
||||
.context("reading mirror update timestamp from database")?
|
||||
.parse::<DateTime<Utc>>()
|
||||
.context("invalid mirror_updated_at stored in database")?;
|
||||
|
||||
let state = RepoMirrorState {
|
||||
repo_id,
|
||||
upstream_url,
|
||||
status,
|
||||
transition_id,
|
||||
transition_phase,
|
||||
updated_at,
|
||||
};
|
||||
state.validate()?;
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod mirror_state_tests {
|
||||
use super::{
|
||||
row_to_repo_mirror_state, validate_mirror_upstream_url, Db, MirrorStatus,
|
||||
MirrorTransitionPhase, RepoMirrorState, RepoRecord,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn ts(value: &str) -> DateTime<Utc> {
|
||||
value.parse().unwrap()
|
||||
}
|
||||
|
||||
fn repo(id: Uuid) -> RepoRecord {
|
||||
RepoRecord {
|
||||
id: id.to_string(),
|
||||
name: "mirror-fixture".to_string(),
|
||||
owner_did: "did:key:z6MkMirrorOwner".to_string(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".to_string(),
|
||||
created_at: ts("2026-08-14T00:00:00Z"),
|
||||
updated_at: ts("2026-08-14T00:00:00Z"),
|
||||
disk_path: format!("/srv/repos/{id}.git"),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn migrated_db(pool: PgPool) -> Db {
|
||||
let db = Db::for_testing(pool);
|
||||
db.run_migrations().await.unwrap();
|
||||
db
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_url_accepts_https_forges_without_credentials() {
|
||||
for valid in [
|
||||
"https://github.com/Gitlawb/node.git",
|
||||
"https://gitlab.example.com/platform/subgroup/node",
|
||||
"https://ghe.internal.example/org/repo.git",
|
||||
] {
|
||||
validate_mirror_upstream_url(valid).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_url_rejects_unsafe_or_ambiguous_forms() {
|
||||
for invalid in [
|
||||
"",
|
||||
"http://github.com/Gitlawb/node.git",
|
||||
"ssh://[email protected]/Gitlawb/node.git",
|
||||
"file:///etc/passwd",
|
||||
"https://user:[email protected]/Gitlawb/node.git",
|
||||
"https://github.com/Gitlawb/node.git?token=secret",
|
||||
"https://github.com/Gitlawb/node.git#main",
|
||||
"https://github.com/",
|
||||
"https://github.com/Gitlawb/node.git\n",
|
||||
"https://localhost/Gitlawb/node.git",
|
||||
"https://forge.localhost/Gitlawb/node.git",
|
||||
"https://127.0.0.1/Gitlawb/node.git",
|
||||
"https://[::1]/Gitlawb/node.git",
|
||||
"https://169.254.169.254/Gitlawb/node.git",
|
||||
"https://10.0.0.5/Gitlawb/node.git",
|
||||
"https://172.16.0.5/Gitlawb/node.git",
|
||||
"https://192.168.1.5/Gitlawb/node.git",
|
||||
"https://forge.internal/Gitlawb/node.git",
|
||||
"https://[::ffff:127.0.0.1]/Gitlawb/node.git",
|
||||
"https://[64:ff9b::7f00:1]/Gitlawb/node.git",
|
||||
] {
|
||||
assert!(
|
||||
validate_mirror_upstream_url(invalid).is_err(),
|
||||
"unsafe mirror URL was accepted: {invalid:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_metadata_must_match_the_status() {
|
||||
let base = RepoMirrorState {
|
||||
repo_id: Uuid::new_v4().to_string(),
|
||||
upstream_url: "https://github.com/Gitlawb/node.git".to_string(),
|
||||
status: MirrorStatus::Inbound,
|
||||
transition_id: None,
|
||||
transition_phase: None,
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
base.validate().unwrap();
|
||||
|
||||
let mut invalid = base.clone();
|
||||
invalid.status = MirrorStatus::TransitioningInboundToOutbound;
|
||||
assert!(invalid.validate().is_err());
|
||||
|
||||
invalid.transition_id = Some(Uuid::new_v4());
|
||||
invalid.transition_phase = Some(MirrorTransitionPhase::DrainingWrites);
|
||||
invalid.validate().unwrap();
|
||||
|
||||
invalid.transition_phase = Some(MirrorTransitionPhase::Completed);
|
||||
assert!(invalid.validate().is_err());
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn migrations_v30_v31_upgrade_an_existing_repo_without_rewriting_it(pool: PgPool) {
|
||||
let db = migrated_db(pool).await;
|
||||
|
||||
// Recreate the pre-v30 shape, then add data written by the old node.
|
||||
sqlx::query("ALTER TABLE repos DROP COLUMN upstream_url CASCADE")
|
||||
.execute(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
for column in [
|
||||
"mirror_status",
|
||||
"mirror_transition_id",
|
||||
"mirror_transition_phase",
|
||||
"mirror_updated_at",
|
||||
] {
|
||||
sqlx::query(&format!("ALTER TABLE repos DROP COLUMN {column}"))
|
||||
.execute(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
sqlx::query("DELETE FROM schema_migrations WHERE version IN (30, 31)")
|
||||
.execute(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let existing = repo(Uuid::new_v4());
|
||||
db.create_repo(&existing).await.unwrap();
|
||||
|
||||
db.run_migrations().await.unwrap();
|
||||
|
||||
let still_there: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM repos WHERE id = $1")
|
||||
.bind(&existing.id)
|
||||
.fetch_one(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
still_there, 1,
|
||||
"the existing repository must survive v30/v31"
|
||||
);
|
||||
|
||||
let mirror_values: (Option<String>, Option<String>, Option<Uuid>, Option<String>) =
|
||||
sqlx::query_as(
|
||||
"SELECT upstream_url, mirror_status, mirror_transition_id,
|
||||
mirror_transition_phase
|
||||
FROM repos WHERE id = $1",
|
||||
)
|
||||
.bind(&existing.id)
|
||||
.fetch_one(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(mirror_values, (None, None, None, None));
|
||||
|
||||
let recorded: Vec<(i64, String)> = sqlx::query_as(
|
||||
"SELECT version, name FROM schema_migrations
|
||||
WHERE version IN (30, 31) ORDER BY version",
|
||||
)
|
||||
.fetch_all(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
recorded,
|
||||
vec![
|
||||
(30, "repo_upstream_mirror_state".to_string()),
|
||||
(31, "validate_repo_upstream_mirror_state".to_string()),
|
||||
]
|
||||
);
|
||||
let constraint_validated: bool = sqlx::query_scalar(
|
||||
"SELECT convalidated FROM pg_constraint
|
||||
WHERE conrelid = 'repos'::regclass
|
||||
AND conname = 'repos_mirror_state_valid'",
|
||||
)
|
||||
.fetch_one(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(constraint_validated, "v31 must validate the v30 constraint");
|
||||
|
||||
// Production entry point remains idempotent after the upgrade.
|
||||
db.run_migrations().await.unwrap();
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn migration_replaces_a_same_named_weaker_constraint(pool: PgPool) {
|
||||
let db = migrated_db(pool).await;
|
||||
|
||||
sqlx::query("ALTER TABLE repos DROP CONSTRAINT repos_mirror_state_valid")
|
||||
.execute(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"ALTER TABLE repos ADD CONSTRAINT repos_mirror_state_valid CHECK (TRUE) NOT VALID",
|
||||
)
|
||||
.execute(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("DELETE FROM schema_migrations WHERE version IN (30, 31)")
|
||||
.execute(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
db.run_migrations().await.unwrap();
|
||||
|
||||
let definition: String = sqlx::query_scalar(
|
||||
"SELECT pg_get_constraintdef(oid)
|
||||
FROM pg_constraint
|
||||
WHERE conrelid = 'repos'::regclass
|
||||
AND conname = 'repos_mirror_state_valid'",
|
||||
)
|
||||
.fetch_one(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
definition.contains("transitioning_inbound_to_outbound")
|
||||
&& !definition.eq_ignore_ascii_case("CHECK (true)"),
|
||||
"migration preserved an impostor constraint: {definition}"
|
||||
);
|
||||
|
||||
let existing = repo(Uuid::new_v4());
|
||||
db.create_repo(&existing).await.unwrap();
|
||||
let malformed = sqlx::query(
|
||||
"UPDATE repos
|
||||
SET upstream_url = 'https://github.com/Gitlawb/node.git',
|
||||
mirror_status = 'transitioning_inbound_to_outbound',
|
||||
mirror_updated_at = '2026-08-14T00:00:00Z'
|
||||
WHERE id = $1",
|
||||
)
|
||||
.bind(&existing.id)
|
||||
.execute(&db.pool)
|
||||
.await;
|
||||
assert!(
|
||||
malformed.is_err(),
|
||||
"replacement constraint accepted missing transition metadata"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn inbound_configuration_round_trips_without_overwriting_state(pool: PgPool) {
|
||||
let db = migrated_db(pool).await;
|
||||
let existing = repo(Uuid::new_v4());
|
||||
db.create_repo(&existing).await.unwrap();
|
||||
|
||||
assert!(db
|
||||
.get_repo_mirror_state(&existing.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
|
||||
let first = db
|
||||
.configure_inbound_mirror(&existing.id, "https://github.com/Gitlawb/node.git")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first.status, MirrorStatus::Inbound);
|
||||
assert_eq!(first.transition_id, None);
|
||||
assert_eq!(first.transition_phase, None);
|
||||
|
||||
// An exact retry is safe, but a different source cannot replace it.
|
||||
let original_updated_at = DateTime::parse_from_rfc3339("2026-01-02T03:04:05Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc);
|
||||
sqlx::query("UPDATE repos SET mirror_updated_at = $2 WHERE id = $1")
|
||||
.bind(&existing.id)
|
||||
.bind(original_updated_at.to_rfc3339())
|
||||
.execute(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let retry = db
|
||||
.configure_inbound_mirror(&existing.id, "https://github.com/Gitlawb/node.git")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(retry.updated_at, original_updated_at);
|
||||
assert!(db
|
||||
.configure_inbound_mirror(&existing.id, "https://github.com/Gitlawb/other.git",)
|
||||
.await
|
||||
.is_err());
|
||||
|
||||
let stored = db
|
||||
.get_repo_mirror_state(&existing.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(stored.upstream_url, first.upstream_url);
|
||||
assert_eq!(stored.status, MirrorStatus::Inbound);
|
||||
assert_eq!(stored.updated_at, original_updated_at);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn malformed_mirror_row_returns_an_error_instead_of_panicking(pool: PgPool) {
|
||||
let row = sqlx::query(
|
||||
"SELECT 'repo-id'::text AS id,
|
||||
'https://github.com/Gitlawb/node.git'::text AS upstream_url,
|
||||
'inbound'::text AS mirror_status,
|
||||
NULL::uuid AS mirror_transition_id,
|
||||
NULL::text AS mirror_transition_phase,
|
||||
NULL::text AS mirror_updated_at",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let error = row_to_repo_mirror_state(row).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("reading mirror update timestamp from database"),
|
||||
"unexpected error: {error:#}"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn inbound_configuration_never_resets_an_active_transition(pool: PgPool) {
|
||||
let db = migrated_db(pool).await;
|
||||
let existing = repo(Uuid::new_v4());
|
||||
db.create_repo(&existing).await.unwrap();
|
||||
db.configure_inbound_mirror(&existing.id, "https://github.com/Gitlawb/node.git")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let transition_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"UPDATE repos
|
||||
SET mirror_status = 'transitioning_inbound_to_outbound',
|
||||
mirror_transition_id = $2,
|
||||
mirror_transition_phase = 'draining_writes'
|
||||
WHERE id = $1",
|
||||
)
|
||||
.bind(&existing.id)
|
||||
.bind(transition_id)
|
||||
.execute(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let retry = db
|
||||
.configure_inbound_mirror(&existing.id, "https://github.com/Gitlawb/node.git")
|
||||
.await;
|
||||
assert!(retry.is_err(), "configuration must not reset a transition");
|
||||
|
||||
let stored = db
|
||||
.get_repo_mirror_state(&existing.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(stored.status, MirrorStatus::TransitioningInboundToOutbound);
|
||||
assert_eq!(stored.transition_id, Some(transition_id));
|
||||
assert_eq!(
|
||||
stored.transition_phase,
|
||||
Some(MirrorTransitionPhase::DrainingWrites)
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn database_constraint_rejects_partial_or_insecure_mirror_state(pool: PgPool) {
|
||||
let db = migrated_db(pool).await;
|
||||
let existing = repo(Uuid::new_v4());
|
||||
db.create_repo(&existing).await.unwrap();
|
||||
|
||||
let partial = sqlx::query(
|
||||
"UPDATE repos
|
||||
SET upstream_url = 'https://github.com/Gitlawb/node.git',
|
||||
mirror_status = 'transitioning_inbound_to_outbound',
|
||||
mirror_updated_at = '2026-08-14T00:00:00Z'
|
||||
WHERE id = $1",
|
||||
)
|
||||
.bind(&existing.id)
|
||||
.execute(&db.pool)
|
||||
.await;
|
||||
assert!(partial.is_err(), "transition metadata must be durable");
|
||||
|
||||
// PostgreSQL CHECK constraints accept NULL results. These cases pin
|
||||
// the outer `IS TRUE` guard so SQL three-valued logic cannot admit a
|
||||
// row that the Rust reader rejects or panics while decoding.
|
||||
struct InvalidNullState<'a> {
|
||||
case: &'a str,
|
||||
status: Option<&'a str>,
|
||||
transition_id: Option<Uuid>,
|
||||
transition_phase: Option<&'a str>,
|
||||
}
|
||||
let invalid_null_states = [
|
||||
InvalidNullState {
|
||||
case: "missing mirror status",
|
||||
status: None,
|
||||
transition_id: None,
|
||||
transition_phase: None,
|
||||
},
|
||||
InvalidNullState {
|
||||
case: "stable status with job but no phase",
|
||||
status: Some("inbound"),
|
||||
transition_id: Some(Uuid::new_v4()),
|
||||
transition_phase: None,
|
||||
},
|
||||
InvalidNullState {
|
||||
case: "transition with job but no phase",
|
||||
status: Some("transitioning_inbound_to_outbound"),
|
||||
transition_id: Some(Uuid::new_v4()),
|
||||
transition_phase: None,
|
||||
},
|
||||
];
|
||||
for invalid in invalid_null_states {
|
||||
let result = sqlx::query(
|
||||
"UPDATE repos
|
||||
SET upstream_url = 'https://github.com/Gitlawb/node.git',
|
||||
mirror_status = $2,
|
||||
mirror_transition_id = $3,
|
||||
mirror_transition_phase = $4,
|
||||
mirror_updated_at = '2026-08-14T00:00:00Z'
|
||||
WHERE id = $1",
|
||||
)
|
||||
.bind(&existing.id)
|
||||
.bind(invalid.status)
|
||||
.bind(invalid.transition_id)
|
||||
.bind(invalid.transition_phase)
|
||||
.execute(&db.pool)
|
||||
.await;
|
||||
assert!(result.is_err(), "constraint accepted {}", invalid.case);
|
||||
}
|
||||
|
||||
let insecure = sqlx::query(
|
||||
"UPDATE repos
|
||||
SET upstream_url = 'http://github.com/Gitlawb/node.git',
|
||||
mirror_status = 'inbound',
|
||||
mirror_updated_at = '2026-08-14T00:00:00Z'
|
||||
WHERE id = $1",
|
||||
)
|
||||
.bind(&existing.id)
|
||||
.execute(&db.pool)
|
||||
.await;
|
||||
assert!(
|
||||
insecure.is_err(),
|
||||
"the database must reject non-HTTPS upstreams"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn database_constraint_accepts_the_complete_transition_vocabulary(pool: PgPool) {
|
||||
let db = migrated_db(pool).await;
|
||||
let existing = repo(Uuid::new_v4());
|
||||
db.create_repo(&existing).await.unwrap();
|
||||
|
||||
let phases = [
|
||||
"queued",
|
||||
"starting",
|
||||
"initializing_mirror_fetch",
|
||||
"draining_writes",
|
||||
"finalizing_mirror_fetch",
|
||||
"finalizing_mirror_push",
|
||||
"committing_target_status",
|
||||
"rolling_back",
|
||||
];
|
||||
for status in [
|
||||
"transitioning_inbound_to_outbound",
|
||||
"transitioning_outbound_to_inbound",
|
||||
] {
|
||||
for phase in phases {
|
||||
sqlx::query(
|
||||
"UPDATE repos
|
||||
SET upstream_url = 'https://github.com/Gitlawb/node.git',
|
||||
mirror_status = $2,
|
||||
mirror_transition_id = $3,
|
||||
mirror_transition_phase = $4,
|
||||
mirror_updated_at = '2026-08-14T00:00:00Z'
|
||||
WHERE id = $1",
|
||||
)
|
||||
.bind(&existing.id)
|
||||
.bind(status)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(phase)
|
||||
.execute(&db.pool)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{status}/{phase} was rejected: {error}"));
|
||||
}
|
||||
}
|
||||
|
||||
// A completed job remains auditable after authority reaches its stable
|
||||
// target status; the initial never-transitioned state uses two NULLs.
|
||||
sqlx::query(
|
||||
"UPDATE repos
|
||||
SET mirror_status = 'outbound',
|
||||
mirror_transition_id = $2,
|
||||
mirror_transition_phase = 'completed'
|
||||
WHERE id = $1",
|
||||
)
|
||||
.bind(&existing.id)
|
||||
.bind(Uuid::new_v4())
|
||||
.execute(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stored = db
|
||||
.get_repo_mirror_state(&existing.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(stored.status, MirrorStatus::Outbound);
|
||||
assert_eq!(
|
||||
stored.transition_phase,
|
||||
Some(MirrorTransitionPhase::Completed)
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn continuous_mirror_uuid_gate_leaves_peer_rows_untouched(pool: PgPool) {
|
||||
let db = migrated_db(pool).await;
|
||||
db.upsert_mirror_repo("z6MkPeer", "repo", "/srv/peer/repo.git", None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let error = db
|
||||
.configure_inbound_mirror("z6MkPeer/repo", "https://github.com/Gitlawb/node.git")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
format!("{error:#}")
|
||||
.contains("continuous mirrors require a canonical UUID repository id"),
|
||||
"unexpected error: {error:#}"
|
||||
);
|
||||
|
||||
let unchanged: (Option<String>, Option<String>) =
|
||||
sqlx::query_as("SELECT upstream_url, mirror_status FROM repos WHERE id = $1")
|
||||
.bind("z6MkPeer/repo")
|
||||
.fetch_one(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(unchanged, (None, None));
|
||||
assert!(db
|
||||
.get_repo_mirror_state("z6MkPeer/repo")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_repo(r: sqlx::postgres::PgRow) -> RepoRecord {
|
||||
let created_str: String = r.get("created_at");
|
||||
let updated_str: String = r.get("updated_at");
|
||||
|
||||
@@ -314,9 +314,7 @@ pub fn record_sync_processed(status: &str) {
|
||||
}
|
||||
|
||||
/// Record one webhook delivery attempt outcome. `result` ∈
|
||||
/// {ok, http_error, network_error, skipped, invalid_url}.
|
||||
/// `invalid_url` is a policy skip: the stored hook URL failed the public-URL
|
||||
/// check at delivery time and the POST was never sent.
|
||||
/// {ok, http_error, network_error, skipped}.
|
||||
pub fn record_webhook_delivery(result: &str) {
|
||||
if let Some(c) = WEBHOOK_DELIVERIES.get() {
|
||||
c.with_label_values(&[result]).inc();
|
||||
|
||||
@@ -237,62 +237,35 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.layer(axum::Extension(push_limiter));
|
||||
|
||||
// ── IPFS content-addressed retrieval and pin listing ──────────────────
|
||||
// Two independent sub-routers, then merged. They share a URL prefix family
|
||||
// but have separate rate-limit policies and must not share a bucket.
|
||||
//
|
||||
// `/ipfs/{cid}` (CID resolver): carries `optional_signature` so `get_by_cid`
|
||||
// sees the caller identity and can apply per-repo visibility (#110); anon
|
||||
// callers stay anonymous and still read genuinely public content. The
|
||||
// per-IP flood brake is layered on because the resolver is anon-reachable
|
||||
// and each request can drive a full-history git walk — the brake is the
|
||||
// outermost layer (rejects a flood before the walk-admission work), mirroring
|
||||
// the push/create routers. The `IpRateLimiter` extension MUST be attached
|
||||
// or `rate_limit_by_ip` is a silent no-op.
|
||||
//
|
||||
// `/api/v1/ipfs/pins` (pin listing): now carries `optional_signature` only
|
||||
// (#121). The handler rejects requests without a verified `AuthenticatedDid`
|
||||
// with 401. It does NOT carry the CID flood brake — `list_pins` is a single
|
||||
// `list_pinned_cids()` call, no walk, and routing pins through the resolver's
|
||||
// bucket would let `/ipfs/{cid}` traffic exhaust the bucket and 429 the
|
||||
// pins endpoint, or let signed pin polling exhaust the bucket for legitimate
|
||||
// CID reads. The two surfaces have separate availability contracts.
|
||||
//
|
||||
// Both sub-routers are built first with their own layer sets, then merged.
|
||||
// This is the structure the prior routing guidance called for and the
|
||||
// earlier 429 test for `/ipfs/{cid}` (test_support.rs) assumes.
|
||||
// `/ipfs/{cid}` carries `optional_signature` so `get_by_cid` sees the caller
|
||||
// identity and can apply per-repo visibility (#110); anonymous callers stay
|
||||
// anonymous and still read genuinely public content. `/api/v1/ipfs/pins`
|
||||
// stays unsigned — gating the pin index is tracked separately (#121).
|
||||
// `/ipfs/{cid}` also carries a per-IP flood brake: it is anon-reachable and each
|
||||
// request can drive a full-history git walk, so the per-IP rate limiter is the
|
||||
// outermost layer (rejects a flood before the walk-admission work), mirroring the
|
||||
// push/create routers. The extension MUST be attached or rate_limit_by_ip is a
|
||||
// silent no-op. `/api/v1/ipfs/pins` (no walk) is merged in unbraked, as before.
|
||||
let ipfs_limiter = rate_limit::IpRateLimiter {
|
||||
limiter: state.ipfs_rate_limiter.clone(),
|
||||
trust: state.push_limiter_trust,
|
||||
};
|
||||
let ipfs_cid_routes = Router::new()
|
||||
let ipfs_routes = Router::new()
|
||||
.route("/ipfs/{cid}", get(ipfs::get_by_cid))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
|
||||
auth::optional_signature,
|
||||
))
|
||||
.layer(middleware::from_fn(rate_limit::rate_limit_by_ip))
|
||||
.layer(axum::Extension(ipfs_limiter));
|
||||
let ipfs_pins_routes = Router::new()
|
||||
.route("/api/v1/ipfs/pins", get(ipfs::list_pins))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
|
||||
auth::optional_signature,
|
||||
));
|
||||
let ipfs_routes = ipfs_cid_routes.merge(ipfs_pins_routes);
|
||||
.layer(axum::Extension(ipfs_limiter))
|
||||
.merge(Router::new().route("/api/v1/ipfs/pins", get(ipfs::list_pins)));
|
||||
|
||||
// ── Arweave permanent anchors ──────────────────────────────────────────
|
||||
// `list_anchors` rejects callers without a verified `AuthenticatedDid`, so
|
||||
// unsigned enumeration is denied. The same `optional_signature` layer used
|
||||
// on the other read surfaces is applied here, closing the anonymous
|
||||
// anchor-listing gap alongside `/api/v1/ipfs/pins`. The verify endpoint
|
||||
// and its probe are deferred to the vertical slice with the uploader and
|
||||
// a real provider contract.
|
||||
let arweave_routes = Router::new()
|
||||
.route("/api/v1/arweave/anchors", get(arweave::list_anchors))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
auth::SignatureBodyLimit(SIGNED_BODY_LIMIT),
|
||||
auth::optional_signature,
|
||||
));
|
||||
// List endpoint only (public; issue #134 tracks surfacing
|
||||
// visibility rules on list). The verify endpoint and its probe
|
||||
// are deferred to the vertical slice with the uploader and a
|
||||
// real provider contract (reviewer 2, #26 split 2/4 round 5).
|
||||
let arweave_routes = Router::new().route("/api/v1/arweave/anchors", get(arweave::list_anchors));
|
||||
|
||||
// ── Bounty routes (write — require HTTP Signature) ─────────────────
|
||||
let bounty_write_routes = add_auth_layers(
|
||||
@@ -699,667 +672,3 @@ async fn p2p_info(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
None => Json(json!({ "enabled": false })),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::db::RepoRecord;
|
||||
use crate::test_support::test_state;
|
||||
|
||||
/// Regression: anonymous callers must not see the pin/anchor index (#121, #134).
|
||||
#[sqlx::test]
|
||||
async fn unsigned_get_pins_and_anchors_is_401_through_build_router(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
|
||||
// Seed identifiable rows so the denial oracle proves the 401 body omits
|
||||
// them: a status-only check would stay green if a later change served
|
||||
// the listing before the auth layer rejected.
|
||||
state
|
||||
.db
|
||||
.record_pinned_cid("seeded-pin-oid", "bafyPIN-SECRET-DO-NOT-LEAK", None)
|
||||
.await
|
||||
.expect("seed pin");
|
||||
let owner_did = "did:key:zSEEDOWNERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let repo = seed_repo_inline(owner_did, "seeded-repo");
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
state
|
||||
.db
|
||||
.record_arweave_anchor(&crate::db::RecordAnchorInput {
|
||||
repo: "zSEEDOWNERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/seeded-repo",
|
||||
owner_did,
|
||||
ref_name: "refs/heads/secret",
|
||||
old_sha: "0".repeat(64).as_str(),
|
||||
new_sha: "1".repeat(64).as_str(),
|
||||
cid: Some("bafytest"),
|
||||
irys_tx_id: "irys-secret-tx-DO-NOT-LEAK",
|
||||
arweave_url: "https://arweave.net/secret-tx",
|
||||
node_did: "did:key:zNODE",
|
||||
})
|
||||
.await
|
||||
.expect("seed anchor");
|
||||
|
||||
let router = build_router(state);
|
||||
|
||||
let pins = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/v1/ipfs/pins?limit=50")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let pins_resp = router.clone().oneshot(pins).await.unwrap();
|
||||
assert_eq!(
|
||||
pins_resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"anonymous pin listing must be rejected"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(pins_resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = String::from_utf8_lossy(&bytes);
|
||||
assert!(
|
||||
!body.contains("bafyPIN-SECRET-DO-NOT-LEAK"),
|
||||
"the 401 body must not leak the seeded pin cid; body was: {body}"
|
||||
);
|
||||
|
||||
let anchors = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/v1/arweave/anchors?limit=50")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let anchors_resp = router.oneshot(anchors).await.unwrap();
|
||||
assert_eq!(
|
||||
anchors_resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"anonymous anchors listing must be rejected"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(anchors_resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = String::from_utf8_lossy(&bytes);
|
||||
assert!(
|
||||
!body.contains("irys-secret-tx-DO-NOT-LEAK"),
|
||||
"the 401 body must not leak the anchor's irys tx id; body was: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: a real RFC-9421 signature produced exactly as `gl` does — built
|
||||
/// with `gitlawb_core::http_sig::sign_request` over a GET, headers attached,
|
||||
/// and sent through the actual `build_router` — is verified by the
|
||||
/// `optional_signature` layer that wraps the pin/anchor routes, and the
|
||||
/// handler returns 200. Pairs with the anonymous-denial test above; one
|
||||
/// proves headers are required, the other proves a valid header is honored.
|
||||
/// Without this test the unsigned-denial test would stay green even if the
|
||||
/// `optional_signature` layer were never wired onto these routes, because
|
||||
/// signed and unsigned requests would fail identically (#134 review).
|
||||
#[sqlx::test]
|
||||
async fn signed_get_pins_and_anchors_succeeds_through_build_router(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let state = test_state(pool).await;
|
||||
let router = build_router(state);
|
||||
|
||||
let kp = Keypair::generate();
|
||||
|
||||
let pins_path = "/api/v1/ipfs/pins";
|
||||
let signed = sign_request(&kp, "GET", pins_path, b"");
|
||||
let pins = Request::builder()
|
||||
.method("GET")
|
||||
.uri(pins_path)
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let pins_resp = router.clone().oneshot(pins).await.unwrap();
|
||||
assert_eq!(
|
||||
pins_resp.status(),
|
||||
StatusCode::OK,
|
||||
"a valid signature on /api/v1/ipfs/pins must be honored through build_router"
|
||||
);
|
||||
|
||||
let anchors_path = "/api/v1/arweave/anchors";
|
||||
let signed = sign_request(&kp, "GET", anchors_path, b"");
|
||||
let anchors = Request::builder()
|
||||
.method("GET")
|
||||
.uri(anchors_path)
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let anchors_resp = router.oneshot(anchors).await.unwrap();
|
||||
assert_eq!(
|
||||
anchors_resp.status(),
|
||||
StatusCode::OK,
|
||||
"a valid signature on /api/v1/arweave/anchors must be honored through build_router"
|
||||
);
|
||||
}
|
||||
|
||||
/// Companion to the two regressions above: a request that *carries* signature
|
||||
/// headers but whose signature does not verify (here, garbled) must be denied
|
||||
/// with 401, not silently treated as anonymous and re-checked by the handler.
|
||||
/// This pins the failure mode of the `optional_signature` layer: when the
|
||||
/// caller claims to be signed, the layer must commit to verifying — there is
|
||||
/// no fall-through path that lets a bad signature bypass auth.
|
||||
#[sqlx::test]
|
||||
async fn malformed_signature_on_pins_is_401_through_build_router(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let state = test_state(pool).await;
|
||||
let router = build_router(state);
|
||||
|
||||
let kp = Keypair::generate();
|
||||
let path = "/api/v1/ipfs/pins";
|
||||
let mut signed = sign_request(&kp, "GET", path, b"");
|
||||
// Flip a character well inside the signature value (base64) so the header
|
||||
// still parses but does not verify against the key.
|
||||
let mut tampered = signed.signature.clone();
|
||||
let mid = tampered.len() / 2;
|
||||
let flipped = if tampered.as_bytes()[mid] == b'A' {
|
||||
'B'
|
||||
} else {
|
||||
'A'
|
||||
};
|
||||
tampered.replace_range(mid..mid + 1, &flipped.to_string());
|
||||
signed.signature = tampered;
|
||||
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(path)
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"a malformed signature must be rejected by the auth layer, not silently accepted"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same malformed-signature rejection must hold on the anchors route,
|
||||
/// which carries its own `optional_signature` layer beside pins: a later
|
||||
/// edit that dropped the layer here (but not on pins) would otherwise stay
|
||||
/// green.
|
||||
#[sqlx::test]
|
||||
async fn malformed_signature_on_anchors_is_401_through_build_router(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let state = test_state(pool).await;
|
||||
let router = build_router(state);
|
||||
|
||||
let kp = Keypair::generate();
|
||||
let path = "/api/v1/arweave/anchors";
|
||||
let mut signed = sign_request(&kp, "GET", path, b"");
|
||||
let mut tampered = signed.signature.clone();
|
||||
let mid = tampered.len() / 2;
|
||||
let flipped = if tampered.as_bytes()[mid] == b'A' {
|
||||
'B'
|
||||
} else {
|
||||
'A'
|
||||
};
|
||||
tampered.replace_range(mid..mid + 1, &flipped.to_string());
|
||||
signed.signature = tampered;
|
||||
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(path)
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"a malformed signature must be rejected by the auth layer, not silently accepted"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scoped anchor contract (P1 follow-up: ?repo= must authorize_repo_read) ──
|
||||
//
|
||||
// The follow-up review (after the auth-layer wiring landed) found that a
|
||||
// signed but unauthorized caller could still obtain scoped anchor metadata
|
||||
// because the `?repo=` branch handed the user-supplied string straight to
|
||||
// the SQL filter. These tests exercise the full production contract end to
|
||||
// end: real RFC-9421 signature → `optional_signature` layer →
|
||||
// `authorize_repo_read` → SQL. They fail closed if the authz call is moved
|
||||
// after the query or removed entirely.
|
||||
|
||||
/// Inline repo seed for the scoped-anchor tests. Mirrors the shape in
|
||||
/// `test_support::tests::seed_repo` without taking a cross-module private
|
||||
/// helper as a dependency.
|
||||
fn seed_repo_inline(owner_did: &str, name: &str) -> RepoRecord {
|
||||
let now = chrono::Utc::now();
|
||||
RepoRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: name.to_string(),
|
||||
owner_did: owner_did.to_string(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
disk_path: format!("/tmp/{name}"),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Unsigned scoped anchor request → 401, identical to the global anchor
|
||||
/// 401 test. Proves the auth layer fires before any scope decision (no
|
||||
/// existence oracle via the `?repo=` path either).
|
||||
#[sqlx::test]
|
||||
async fn unsigned_scoped_anchors_is_401_through_build_router(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
|
||||
// Seed an anchor under a repo the request claims to scope to, so the
|
||||
// denial oracle proves the 401 body omits it rather than merely
|
||||
// returning the right status over an empty table.
|
||||
let owner_did = "did:key:zSCOPEDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let repo = seed_repo_inline(owner_did, "priv");
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
state
|
||||
.db
|
||||
.record_arweave_anchor(&crate::db::RecordAnchorInput {
|
||||
repo: "zSCOPEDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/priv",
|
||||
owner_did,
|
||||
ref_name: "refs/heads/secret",
|
||||
old_sha: "0".repeat(64).as_str(),
|
||||
new_sha: "1".repeat(64).as_str(),
|
||||
cid: Some("bafytest"),
|
||||
irys_tx_id: "irys-scoped-secret-DO-NOT-LEAK",
|
||||
arweave_url: "https://arweave.net/scoped-secret",
|
||||
node_did: "did:key:zNODE",
|
||||
})
|
||||
.await
|
||||
.expect("seed anchor");
|
||||
|
||||
let router = build_router(state);
|
||||
|
||||
let resp = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/v1/arweave/anchors?repo=did:key:zSCOPED%2Fpriv")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"unsigned scoped anchor request must be 401, before any scope lookup"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = String::from_utf8_lossy(&bytes);
|
||||
assert!(
|
||||
!body.contains("irys-scoped-secret-DO-NOT-LEAK"),
|
||||
"the 401 body must not leak the anchor's irys tx id; body was: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Signed owner on a private repo → 200, anchor metadata returned. Mirrors
|
||||
/// the existing `list_webhooks_accepts_a_real_gl_signature_e2e` shape: real
|
||||
/// signature, real middleware, real `authorize_repo_read`.
|
||||
#[sqlx::test]
|
||||
async fn signed_scoped_anchors_owner_succeeds_through_build_router(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let kp = Keypair::generate();
|
||||
let owner_did = kp.did().to_string();
|
||||
let state = test_state(pool).await;
|
||||
|
||||
let mut repo = seed_repo_inline(&owner_did, "scoped-priv");
|
||||
repo.is_public = false;
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
let short = owner_did.split(':').next_back().unwrap().to_string();
|
||||
state
|
||||
.db
|
||||
.record_arweave_anchor(&crate::db::RecordAnchorInput {
|
||||
repo: &format!("{short}/scoped-priv"),
|
||||
owner_did: &owner_did,
|
||||
ref_name: "refs/heads/main",
|
||||
old_sha: "0".repeat(64).as_str(),
|
||||
new_sha: "1".repeat(64).as_str(),
|
||||
cid: Some("bafytest"),
|
||||
irys_tx_id: "irys-owner-tx",
|
||||
arweave_url: "https://arweave.net/owner-tx",
|
||||
node_did: "did:key:zNODE",
|
||||
})
|
||||
.await
|
||||
.expect("seed anchor");
|
||||
|
||||
let path = format!("/api/v1/arweave/anchors?repo={short}/scoped-priv");
|
||||
let signed = sign_request(&kp, "GET", &path, b"");
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(&path)
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let router = build_router(state);
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"the owner of a private repo must see their scoped anchors"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = String::from_utf8_lossy(&bytes);
|
||||
assert!(
|
||||
body.contains("irys-owner-tx"),
|
||||
"owner must see the anchor's irys tx id; body was: {body}"
|
||||
);
|
||||
// Owner must see the ref name + new SHA so this is not just a count oracle.
|
||||
assert!(
|
||||
body.contains("refs/heads/main"),
|
||||
"owner must see the ref name; body was: {body}"
|
||||
);
|
||||
|
||||
// Sanity: a second test would need a different repo name to avoid
|
||||
// colliding on `did:key:zSCOPED/scoped-priv` in the anchors table.
|
||||
// Distinct repo names per test keep the rows queryable in isolation.
|
||||
let _ = repo;
|
||||
}
|
||||
|
||||
/// #121 R3: the SQL filter must use the canonical stored slug, not the
|
||||
/// user-supplied `?repo=` string. Anchor rows are stored as
|
||||
/// `{normalize_owner_key(owner_did)}/{name}` (the short form). Authz
|
||||
/// passes for the full-DID form, but a literal `WHERE repo=$1` against
|
||||
/// the full DID would match zero rows and return a false empty page,
|
||||
/// breaking callers that legitimately use the full `did:key:…/name`
|
||||
/// form in `?repo=`. RED before the fix: handler built the filter from
|
||||
/// the raw `?repo=` string, so this query returned `anchors: []`.
|
||||
#[sqlx::test]
|
||||
async fn signed_scoped_anchors_full_did_form_matches_stored_slug_through_build_router(
|
||||
pool: PgPool,
|
||||
) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let kp = Keypair::generate();
|
||||
let owner_did = kp.did().to_string();
|
||||
let state = test_state(pool).await;
|
||||
|
||||
let mut repo = seed_repo_inline(&owner_did, "scoped-did-form");
|
||||
repo.is_public = false;
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
// Use the same slug-construction the writer uses
|
||||
// (`db::normalize_owner_key`) so the test reader and the production
|
||||
// writer can't drift if a future change moves the slug off the
|
||||
// short-form. P3 (reviewer-3): the previous `split(':').next_back()`
|
||||
// hand-rolled a third convention that agreed with the writer for
|
||||
// `did:key` owners and diverged for every other method.
|
||||
let short = crate::db::normalize_owner_key(&owner_did).to_string();
|
||||
state
|
||||
.db
|
||||
.record_arweave_anchor(&crate::db::RecordAnchorInput {
|
||||
repo: &format!("{short}/scoped-did-form"),
|
||||
owner_did: &owner_did,
|
||||
ref_name: "refs/heads/main",
|
||||
old_sha: "0".repeat(64).as_str(),
|
||||
new_sha: "1".repeat(64).as_str(),
|
||||
cid: Some("bafytest"),
|
||||
irys_tx_id: "irys-did-form-tx",
|
||||
arweave_url: "https://arweave.net/did-form-tx",
|
||||
node_did: "did:key:zNODE",
|
||||
})
|
||||
.await
|
||||
.expect("seed anchor");
|
||||
|
||||
// P2 (reviewer-3): the previous suite only proved the authorized
|
||||
// row is present. A correct filter must ALSO prove that anchors
|
||||
// for OTHER repos do not leak into the response — the failure
|
||||
// mode of the original bug was a cross-repo leak, not a
|
||||
// fail-safe empty page. Seed a second repo (also under the
|
||||
// same owner, so the authz test stays scoped) with its own
|
||||
// irys_tx_id and assert it is absent from the body.
|
||||
let other_owner = {
|
||||
let kp2 = Keypair::generate();
|
||||
kp2.did().to_string()
|
||||
};
|
||||
let mut other_repo = seed_repo_inline(&other_owner, "other-repo");
|
||||
other_repo.is_public = true;
|
||||
state
|
||||
.db
|
||||
.create_repo(&other_repo)
|
||||
.await
|
||||
.expect("seed other repo");
|
||||
state
|
||||
.db
|
||||
.record_arweave_anchor(&crate::db::RecordAnchorInput {
|
||||
repo: &format!(
|
||||
"{}/other-repo",
|
||||
crate::db::normalize_owner_key(&other_owner)
|
||||
),
|
||||
owner_did: &other_owner,
|
||||
ref_name: "refs/heads/main",
|
||||
old_sha: "0".repeat(64).as_str(),
|
||||
new_sha: "2".repeat(64).as_str(),
|
||||
cid: Some("bafyother"),
|
||||
irys_tx_id: "irys-other-repo-tx",
|
||||
arweave_url: "https://arweave.net/other-tx",
|
||||
node_did: "did:key:zNODE",
|
||||
})
|
||||
.await
|
||||
.expect("seed other anchor");
|
||||
|
||||
// The caller passes the FULL DID form in ?repo=. validate_repo_slug
|
||||
// accepts it, authorize_repo_read resolves it, and the handler MUST
|
||||
// then translate to the canonical stored slug (short form) before
|
||||
// running the SQL — otherwise this returns 200 with anchors: [].
|
||||
let path = format!("/api/v1/arweave/anchors?repo={owner_did}/scoped-did-form");
|
||||
let signed = sign_request(&kp, "GET", &path, b"");
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(&path)
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let router = build_router(state);
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"authz passed via the full DID form, so the response must be 200"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = String::from_utf8_lossy(&bytes);
|
||||
assert!(
|
||||
body.contains("irys-did-form-tx"),
|
||||
"the SQL filter must use the stored short slug, not the full DID — \
|
||||
a false-empty page would break callers that pass ?repo=did:key:…/name. \
|
||||
body was: {body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("irys-other-repo-tx"),
|
||||
"the scoped anchor filter must exclude other repos' anchors — \
|
||||
a cross-repo leak would expose metadata of repos the caller \
|
||||
did not authorize. body was: {body}"
|
||||
);
|
||||
|
||||
let _ = repo;
|
||||
}
|
||||
|
||||
/// Signed non-reader on a private repo → 404, anchor metadata MUST NOT
|
||||
/// leak. This is the test that catches the "auth-but-no-authz" bug if
|
||||
/// the gate is moved after the SQL query or removed entirely. The 404
|
||||
/// is the standard `repo_not_found` shape — indistinguishable from the
|
||||
/// missing-repo case below.
|
||||
#[sqlx::test]
|
||||
async fn signed_scoped_anchors_non_reader_is_404_no_leak_through_build_router(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let owner_kp = Keypair::generate();
|
||||
let stranger_kp = Keypair::generate();
|
||||
let owner_did = owner_kp.did().to_string();
|
||||
let stranger_did = stranger_kp.did().to_string();
|
||||
let state = test_state(pool).await;
|
||||
|
||||
let mut repo = seed_repo_inline(&owner_did, "scoped-priv-nr");
|
||||
repo.is_public = false;
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
let short_owner = owner_did.split(':').next_back().unwrap().to_string();
|
||||
state
|
||||
.db
|
||||
.record_arweave_anchor(&crate::db::RecordAnchorInput {
|
||||
repo: &format!("{short_owner}/scoped-priv-nr"),
|
||||
owner_did: &owner_did,
|
||||
ref_name: "refs/heads/secret",
|
||||
old_sha: "2".repeat(64).as_str(),
|
||||
new_sha: "3".repeat(64).as_str(),
|
||||
cid: Some("bafytest"),
|
||||
irys_tx_id: "irys-secret-tx-DO-NOT-LEAK",
|
||||
arweave_url: "https://arweave.net/secret-tx",
|
||||
node_did: "did:key:zNODE",
|
||||
})
|
||||
.await
|
||||
.expect("seed anchor");
|
||||
|
||||
let _ = stranger_did; // signature carries the DID; this is just documentation
|
||||
let path = format!("/api/v1/arweave/anchors?repo={short_owner}/scoped-priv-nr");
|
||||
let signed = sign_request(&stranger_kp, "GET", &path, b"");
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(&path)
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let router = build_router(state);
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"a signed non-reader must be 404 on a private repo's anchors"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = String::from_utf8_lossy(&bytes);
|
||||
assert!(
|
||||
!body.contains("irys-secret-tx-DO-NOT-LEAK"),
|
||||
"the 404 body must not leak the anchor's irys tx id; body was: {body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("refs/heads/secret"),
|
||||
"the 404 body must not leak the ref name; body was: {body}"
|
||||
);
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).expect("json body");
|
||||
assert_eq!(
|
||||
v["error"], "repo_not_found",
|
||||
"non-reader denial must carry the same error code as a missing repo"
|
||||
);
|
||||
}
|
||||
|
||||
/// Signed caller on a missing repo → 404, same shape as the non-reader
|
||||
/// case. This is the indistinguishability half: a probe cannot tell
|
||||
/// "private" from "absent" from the response.
|
||||
#[sqlx::test]
|
||||
async fn signed_scoped_anchors_missing_repo_is_404_through_build_router(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let kp = Keypair::generate();
|
||||
let state = test_state(pool).await;
|
||||
|
||||
let short = kp
|
||||
.did()
|
||||
.to_string()
|
||||
.split(':')
|
||||
.next_back()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let path = format!("/api/v1/arweave/anchors?repo={short}/does-not-exist");
|
||||
let signed = sign_request(&kp, "GET", &path, b"");
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(&path)
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let router = build_router(state);
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"a missing repo must 404 indistinguishably from a non-readable private repo"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).expect("json body");
|
||||
assert_eq!(
|
||||
v["error"], "repo_not_found",
|
||||
"the 404 body must carry the standard repo_not_found error code"
|
||||
);
|
||||
}
|
||||
|
||||
/// `?limit=-1` must not crash as `LIMIT -1` (Postgres 500). It clamps to
|
||||
/// zero and returns 200 with an empty list — the same shape as a valid
|
||||
/// listing that happens to have no rows in the configured range.
|
||||
#[sqlx::test]
|
||||
async fn signed_anchors_negative_limit_clamps_to_zero_through_build_router(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let kp = Keypair::generate();
|
||||
let state = test_state(pool).await;
|
||||
|
||||
let path = "/api/v1/arweave/anchors?limit=-1";
|
||||
let signed = sign_request(&kp, "GET", path, b"");
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(path)
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let router = build_router(state);
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"?limit=-1 must clamp, not 500"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).expect("json body");
|
||||
assert_eq!(v["count"], 0, "clamped limit yields an empty list");
|
||||
assert_eq!(v["anchors"].as_array().map(|a| a.len()), Some(0));
|
||||
}
|
||||
}
|
||||
@@ -701,7 +701,6 @@ async fn clone_repo(remote_url: &str, local_path: &Path, mode: MirrorMode) -> an
|
||||
if mode == MirrorMode::Promisor {
|
||||
args.push("--filter=blob:limit=10g");
|
||||
}
|
||||
args.push("--");
|
||||
args.push(remote_url);
|
||||
args.push(local_str);
|
||||
|
||||
@@ -730,10 +729,7 @@ async fn clone_repo(remote_url: &str, local_path: &Path, mode: MirrorMode) -> an
|
||||
async fn fetch_repo(local_path: &Path, remote_url: &str, mode: MirrorMode) -> anyhow::Result<()> {
|
||||
let local_str = local_path.to_str().unwrap_or(".");
|
||||
|
||||
git_run(&[
|
||||
"-C", local_str, "remote", "set-url", "origin", "--", remote_url,
|
||||
])
|
||||
.await?;
|
||||
git_run(&["-C", local_str, "remote", "set-url", "origin", remote_url]).await?;
|
||||
|
||||
match mode {
|
||||
MirrorMode::Promisor => {
|
||||
@@ -1010,82 +1006,6 @@ mod tests {
|
||||
assert_eq!(probe, PromisorProbe::NotPromisor);
|
||||
}
|
||||
|
||||
/// #374: `clone_repo` interpolates the peer's origin URL into a `git
|
||||
/// clone --mirror` argument list. Without a `--` delimiter git parses a
|
||||
/// remote beginning with `-` as an option instead of a repository.
|
||||
///
|
||||
/// The delimiter is what forces the same string to be a repository, and
|
||||
/// git says so in its own words: it reports the whole argument as a
|
||||
/// repository it cannot find. Undelimited, git consumes it as an option
|
||||
/// and fails against the *destination* instead (`fatal: repository '<dest>' does not exist`),
|
||||
/// never naming the injected string — which is why this assertion is RED
|
||||
/// before the fix and GREEN after.
|
||||
///
|
||||
/// The payload is deliberately free of `:` and spaces: a colon would make
|
||||
/// git read the argument as an ssh-style `host:path` URL and report a
|
||||
/// hostname rather than a repository, which is a different code path.
|
||||
#[tokio::test]
|
||||
async fn clone_never_parses_a_remote_as_a_git_option() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let dest = td.path().join("mirror.git");
|
||||
let injected = "--upload-pack=false";
|
||||
|
||||
let err = clone_repo(injected, &dest, MirrorMode::Plain)
|
||||
.await
|
||||
.expect_err("a remote that is not a repository must fail the clone")
|
||||
.to_string();
|
||||
|
||||
assert!(
|
||||
err.contains(injected),
|
||||
"git must report the argument as a repository, not consume it as an option: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #374: `fetch_repo` interpolates the peer's origin URL into
|
||||
/// `git remote set-url`, a second argv construction with the same contract as
|
||||
/// the clone. It needs its own proof: the clone test cannot reach this path.
|
||||
///
|
||||
/// The assertion is on stored state rather than on the error string, and that
|
||||
/// is deliberate. `git_run` formats its failure as `git {args:?} failed:
|
||||
/// {stderr}`, so the injected value is in the message via the debug-printed
|
||||
/// argv whether or not the delimiter is present — asserting on containment
|
||||
/// would pass with `--` deleted. Delimited, `set-url` succeeds and stores the
|
||||
/// value verbatim; undelimited, git exits 129 with `unknown option` and leaves
|
||||
/// the previous URL in place, so this goes red without the fix.
|
||||
///
|
||||
/// The fetch that follows the rewrite fails, because the stored URL is not a
|
||||
/// repository. That is expected and ignored: the property under test is what
|
||||
/// `set-url` wrote, not whether the subsequent fetch could succeed.
|
||||
#[tokio::test]
|
||||
async fn fetch_never_parses_a_remote_as_a_git_option() {
|
||||
let td = TempDir::new().unwrap();
|
||||
let local = td.path().join("mirror.git");
|
||||
let local_str = local.to_str().unwrap();
|
||||
let placeholder = "http://example.invalid/x.git";
|
||||
|
||||
assert!(Command::new("git")
|
||||
.args(["init", "-q", "--bare", local_str])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
assert!(Command::new("git")
|
||||
.args(["-C", local_str, "remote", "add", "origin", placeholder])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
|
||||
let injected = "--upload-pack=false";
|
||||
let _ = fetch_repo(&local, injected, MirrorMode::Plain).await;
|
||||
|
||||
assert_eq!(
|
||||
git_config_get(local_str, "remote.origin.url")
|
||||
.await
|
||||
.as_deref(),
|
||||
Some(injected),
|
||||
"set-url must store an option-shaped remote verbatim, not reject it as an option"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_reports_unknown_on_git_error() {
|
||||
// A path git cannot resolve as a repo at all (exit 128) is an indeterminate
|
||||
|
||||
@@ -16560,181 +16560,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #134 R2 (split buckets): signed `/api/v1/ipfs/pins` polling must NOT debit
|
||||
/// the CID resolver's per-IP flood brake. The pre-fix composition merged
|
||||
/// pins under the same router as `/ipfs/{cid}`, so every signed pin
|
||||
/// request consumed a token from `ipfs_rate_limiter` and a flood of pinned
|
||||
/// traffic could 429 legitimate CID reads. RED before the split (pins
|
||||
/// debited the bucket): a flood of pins followed by a CID request → 429
|
||||
/// on the CID. GREEN after the split: the CID bucket is untouched.
|
||||
#[sqlx::test]
|
||||
async fn ipfs_pins_do_not_debit_the_cid_bucket(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
let kp = Keypair::generate();
|
||||
let owner_did = kp.did().to_string();
|
||||
let slug = owner_did.replace([':', '/'], "_");
|
||||
let short = owner_did.split(':').next_back().unwrap().to_string();
|
||||
let mut state = test_state(pool).await;
|
||||
// Bucket size 2: any third /ipfs/{cid} request from the same IP would
|
||||
// 429 if the bucket were touched by the preceding pin requests.
|
||||
state.ipfs_rate_limiter = crate::rate_limit::RateLimiter::new(2, Duration::from_secs(3600));
|
||||
state.ipfs_work_rate_limiter =
|
||||
crate::rate_limit::RateLimiter::new(600, Duration::from_secs(3600));
|
||||
state.push_limiter_trust = crate::rate_limit::TrustedProxy::XForwardedFor;
|
||||
|
||||
// Seed a public, walk-free CID so /ipfs/{cid} serves cheaply. This
|
||||
// matters because the proof half of the test fires a real CID request
|
||||
// and needs the route bucket to be the only thing that can 429 it.
|
||||
let fx = seed_cid_repos(&slug, &short, &["pinsdebit"]);
|
||||
let bare = std::path::PathBuf::from("/tmp")
|
||||
.join(&slug)
|
||||
.join("pinsdebit.git");
|
||||
let repo = seed_repo(&owner_did, "pinsdebit");
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
let cid = pin_cid_for(&bare, &fx.public_oid, &state.db).await;
|
||||
|
||||
let router = crate::server::build_router(state);
|
||||
let peer_ip = "203.0.113.50";
|
||||
|
||||
// Five signed pin requests — more than the bucket — all must 200 (the
|
||||
// pins route has no rate limit; the bucket only charges /ipfs/{cid}).
|
||||
let pins_path = "/api/v1/ipfs/pins";
|
||||
let pins_signed = sign_request(&kp, "GET", pins_path, b"");
|
||||
for i in 0..5 {
|
||||
let req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(pins_path)
|
||||
.header("content-digest", &pins_signed.content_digest)
|
||||
.header("signature-input", &pins_signed.signature_input)
|
||||
.header("signature", &pins_signed.signature)
|
||||
.header("x-forwarded-for", peer_ip)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = router.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"pin request {i} must not be rate-limited even though the CID bucket is size 2"
|
||||
);
|
||||
}
|
||||
|
||||
// A subsequent /ipfs/{cid} request from the same IP must still serve
|
||||
// 200 — the bucket was untouched by the pin traffic. (If pins had
|
||||
// debited it, the bucket would be empty and the CID request would 429.)
|
||||
let cid_path = format!("/ipfs/{cid}");
|
||||
let cid_signed = sign_request(&kp, "GET", &cid_path, b"");
|
||||
let req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(&cid_path)
|
||||
.header("content-digest", cid_signed.content_digest)
|
||||
.header("signature-input", cid_signed.signature_input)
|
||||
.header("signature", cid_signed.signature)
|
||||
.header("x-forwarded-for", peer_ip)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"the CID bucket must still have tokens — pin traffic did not debit it"
|
||||
);
|
||||
}
|
||||
|
||||
/// #134 R2 (split buckets, second half): an EXHAUSTED CID bucket must
|
||||
/// NOT block a valid signed `/api/v1/ipfs/pins` listing. The pre-fix
|
||||
/// composition routed pins through the same middleware stack, so a CID
|
||||
/// flood that exhausted the bucket would 429 the pins endpoint too. RED
|
||||
/// before the split: a CID request that 429s the bucket makes the next
|
||||
/// pin request 429 too. GREEN after the split: pins never see this
|
||||
/// bucket and remain servable.
|
||||
#[sqlx::test]
|
||||
async fn ipfs_pins_survive_an_exhausted_cid_bucket(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
let kp = Keypair::generate();
|
||||
let owner_did = kp.did().to_string();
|
||||
let slug = owner_did.replace([':', '/'], "_");
|
||||
let short = owner_did.split(':').next_back().unwrap().to_string();
|
||||
let mut state = test_state(pool).await;
|
||||
// Bucket size 1: the first /ipfs/{cid} request from the IP spends the
|
||||
// only token; the second is 429 (proves exhaustion).
|
||||
state.ipfs_rate_limiter = crate::rate_limit::RateLimiter::new(1, Duration::from_secs(3600));
|
||||
state.ipfs_work_rate_limiter =
|
||||
crate::rate_limit::RateLimiter::new(600, Duration::from_secs(3600));
|
||||
state.push_limiter_trust = crate::rate_limit::TrustedProxy::XForwardedFor;
|
||||
|
||||
let fx = seed_cid_repos(&slug, &short, &["pinsexh"]);
|
||||
let bare = std::path::PathBuf::from("/tmp")
|
||||
.join(&slug)
|
||||
.join("pinsexh.git");
|
||||
let repo = seed_repo(&owner_did, "pinsexh");
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
let cid = pin_cid_for(&bare, &fx.public_oid, &state.db).await;
|
||||
|
||||
let router = crate::server::build_router(state);
|
||||
let peer_ip = "203.0.113.60";
|
||||
|
||||
// First /ipfs/{cid} request — debits the single CID token, serves 200.
|
||||
let cid_path = format!("/ipfs/{cid}");
|
||||
let cid_signed = sign_request(&kp, "GET", &cid_path, b"");
|
||||
let req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(&cid_path)
|
||||
.header("content-digest", cid_signed.content_digest)
|
||||
.header("signature-input", cid_signed.signature_input)
|
||||
.header("signature", cid_signed.signature)
|
||||
.header("x-forwarded-for", peer_ip)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = router.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"the first CID request spends the token and serves"
|
||||
);
|
||||
|
||||
// Second /ipfs/{cid} request — bucket empty → 429 (proves the
|
||||
// exhaustion is real and observable on this router). Re-sign because
|
||||
// `SignedHeaders` holds owned `String`s.
|
||||
let cid_signed_2 = sign_request(&kp, "GET", &cid_path, b"");
|
||||
let req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(&cid_path)
|
||||
.header("content-digest", cid_signed_2.content_digest)
|
||||
.header("signature-input", cid_signed_2.signature_input)
|
||||
.header("signature", cid_signed_2.signature)
|
||||
.header("x-forwarded-for", peer_ip)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = router.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"the second CID request from the same IP is 429 — bucket exhausted"
|
||||
);
|
||||
|
||||
// A signed /api/v1/ipfs/pins request from the SAME IP must still
|
||||
// serve 200 — pins are auth-only and never see this bucket.
|
||||
let pins_path = "/api/v1/ipfs/pins";
|
||||
let pins_signed = sign_request(&kp, "GET", pins_path, b"");
|
||||
let req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(pins_path)
|
||||
.header("content-digest", pins_signed.content_digest)
|
||||
.header("signature-input", pins_signed.signature_input)
|
||||
.header("signature", pins_signed.signature)
|
||||
.header("x-forwarded-for", peer_ip)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"an exhausted CID bucket must not block a valid signed pin listing"
|
||||
);
|
||||
}
|
||||
|
||||
/// U5 (R6): the two buckets are independent — the WORK budget can be exhausted
|
||||
/// (429) WITHOUT draining the ROUTE bucket. Through the production router, route
|
||||
/// generous (5) but work tight (1): one request drives two legacy probes, so the
|
||||
|
||||
@@ -79,19 +79,6 @@ async fn fire_event_async(
|
||||
let delivery_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Policy skip, not a delivery failure: the stored URL predates the
|
||||
// create-time gate. The "invalid_url" label is documented on
|
||||
// record_webhook_delivery in metrics.rs.
|
||||
if !crate::api::peers::is_public_http_url(&hook.url) {
|
||||
crate::metrics::record_webhook_delivery("invalid_url");
|
||||
tracing::warn!(
|
||||
url = %hook.url,
|
||||
event = %event_name,
|
||||
"webhook delivery skipped: stored URL is not a public http(s) target"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let signature = hook.secret.as_deref().map(|s| sign_payload(s, &bytes));
|
||||
|
||||
let mut req = client
|
||||
|
||||
+5
-89
@@ -163,8 +163,8 @@ pub fn setup_partial_clone(
|
||||
|
||||
if withheld_globs.is_empty() {
|
||||
match branch {
|
||||
Some(b) => git_global(&["clone", "-q", "--branch", b, "--", remote_url, dest_str])?,
|
||||
None => git_global(&["clone", "-q", "--", remote_url, dest_str])?,
|
||||
Some(b) => git_global(&["clone", "-q", "--branch", b, remote_url, dest_str])?,
|
||||
None => git_global(&["clone", "-q", remote_url, dest_str])?,
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -174,7 +174,6 @@ pub fn setup_partial_clone(
|
||||
"-q",
|
||||
"--filter=blob:none",
|
||||
"--no-checkout",
|
||||
"--",
|
||||
remote_url,
|
||||
dest_str,
|
||||
])?;
|
||||
@@ -389,7 +388,7 @@ async fn recover_encrypted_blobs(
|
||||
let plaintext = match open_blob(&envelope, keypair) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
emit_warning(&format!("warning: could not decrypt {oid}: {e}"));
|
||||
eprintln!("warning: could not decrypt {oid}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -407,9 +406,7 @@ async fn recover_encrypted_blobs(
|
||||
recovered.push(p.clone());
|
||||
}
|
||||
} else {
|
||||
emit_warning(&format!(
|
||||
"warning: recovered blob {oid} hashed to {written}; discarding"
|
||||
));
|
||||
eprintln!("warning: recovered blob {oid} hashed to {written}; discarding");
|
||||
}
|
||||
}
|
||||
Ok(recovered)
|
||||
@@ -807,9 +804,7 @@ async fn recover_from_arweave(
|
||||
recovered.push(p.clone());
|
||||
}
|
||||
} else {
|
||||
emit_warning(&format!(
|
||||
"warning: recovered blob {oid} hashed to {written}; discarding"
|
||||
));
|
||||
eprintln!("warning: recovered blob {oid} hashed to {written}; discarding");
|
||||
}
|
||||
}
|
||||
Ok(recovered)
|
||||
@@ -1095,85 +1090,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_partial_clone_plain_without_withheld_paths() {
|
||||
let (td, url) = bare_remote(&[("file.txt", b"hello\n")]);
|
||||
let dest = td.path().join("dest");
|
||||
setup_partial_clone(&dest, &url, &[], &[], None).unwrap();
|
||||
assert!(dest.join("file.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_partial_clone_with_branch_without_withheld_paths() {
|
||||
let (td, url) = bare_remote(&[("file.txt", b"hello\n")]);
|
||||
let branch_out = Command::new("git")
|
||||
.args([
|
||||
"-C",
|
||||
td.path().join("origin").to_str().unwrap(),
|
||||
"branch",
|
||||
"--show-current",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
let branch = String::from_utf8(branch_out.stdout)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
let dest = td.path().join("dest");
|
||||
setup_partial_clone(&dest, &url, &[], &[], Some(&branch)).unwrap();
|
||||
assert!(dest.join("file.txt").exists());
|
||||
}
|
||||
|
||||
/// #374: every `setup_partial_clone` shape interpolates `remote_url` into a
|
||||
/// `git clone` argv. The `--` delimiter is what forces an option-shaped
|
||||
/// remote to be read as a repository rather than parsed as an option.
|
||||
///
|
||||
/// The assertion is on git's own words — `repository '<value>'`, single-quoted
|
||||
/// — and not merely on the value appearing somewhere in the error. `git_global`
|
||||
/// formats its failure as `git {args:?} failed: {stderr}`, so the injected
|
||||
/// string is already present in the debug-printed argv whether or not the
|
||||
/// delimiter is there; asserting on containment alone would pass with `--`
|
||||
/// deleted. Undelimited, git consumes the value as an option, leaving the
|
||||
/// destination as the only positional, and names *that* instead
|
||||
/// (`repository '<dest>' does not exist`) — so this goes red without the fix.
|
||||
///
|
||||
/// The payload carries no `:` or space: a colon would make git read the
|
||||
/// argument as an ssh-style `host:path` URL and report a hostname, which is a
|
||||
/// different parse.
|
||||
fn assert_option_shaped_remote_is_a_repository(withheld: &[String], branch: Option<&str>) {
|
||||
let td = TempDir::new().unwrap();
|
||||
let dest = td.path().join("dest");
|
||||
let injected = "--upload-pack=false";
|
||||
|
||||
let err = setup_partial_clone(&dest, injected, withheld, &[], branch)
|
||||
.expect_err("an option-shaped remote is not a repository and must fail")
|
||||
.to_string();
|
||||
|
||||
assert!(
|
||||
err.contains(&format!("repository '{injected}'")),
|
||||
"git must name the argument as the repository, not consume it as an option: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_clone_never_parses_a_remote_as_a_git_option() {
|
||||
assert_option_shaped_remote_is_a_repository(&[], None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_clone_never_parses_a_remote_as_a_git_option() {
|
||||
// Pins that `--branch <b>` stays ahead of the delimiter: if `--` were moved
|
||||
// before `--branch`, git would read the branch name as a positional instead.
|
||||
assert_option_shaped_remote_is_a_repository(&[], Some("main"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_clone_never_parses_a_remote_as_a_git_option() {
|
||||
// The withheld-globs arm is a separate argv construction
|
||||
// (`--filter=blob:none --no-checkout`) and needs its own proof.
|
||||
assert_option_shaped_remote_is_a_repository(&["/secret/**".to_string()], None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_patterns_subtree_and_exact() {
|
||||
assert_eq!(sparse_patterns("/secret/**"), vec!["/secret/".to_string()]);
|
||||
|
||||
+2
-51
@@ -41,17 +41,6 @@ pub struct MirrorArgs {
|
||||
pub dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Argv for the mirror clone, with `--` immediately before the first positional
|
||||
/// so an option-shaped `source` cannot be parsed as a git option (#374).
|
||||
///
|
||||
/// This is a separate function purely so the delimiter's placement can be driven
|
||||
/// through real git by a test. The production call keeps `.status()` rather than
|
||||
/// `.output()`: a mirror clone of a large repo streams git's progress to the
|
||||
/// user's terminal, and capturing it to assert on stderr here would swallow that.
|
||||
fn mirror_clone_args(source: &str) -> [&str; 4] {
|
||||
["clone", "--mirror", "--", source]
|
||||
}
|
||||
|
||||
pub async fn run(args: MirrorArgs) -> Result<()> {
|
||||
let source = args.source.trim_end_matches('/').to_string();
|
||||
|
||||
@@ -156,7 +145,7 @@ fn clone_mirror(source: &str, dest: &std::path::Path) -> Result<()> {
|
||||
// bounded-runner rule governs node request handlers. Same spawn as before,
|
||||
// only relocated into this helper.
|
||||
let status = Command::new("git")
|
||||
.args(mirror_clone_args(source))
|
||||
.args(["clone", "--mirror", source])
|
||||
.arg(dest)
|
||||
.status()
|
||||
.context("failed to run git clone — is git installed?")?;
|
||||
@@ -197,43 +186,6 @@ pub fn extract_repo_name(url: &str) -> Option<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #374: `gl mirror` interpolates the user-supplied source into a
|
||||
/// `git clone --mirror` argv. `--` must sit immediately before the first
|
||||
/// positional so an option-shaped source is read as a repository.
|
||||
///
|
||||
/// This drives the production argv through real git rather than asserting on
|
||||
/// the array's contents, so it fails on a delimiter that is missing *or*
|
||||
/// misplaced. It cannot go through `run()`: that loads an identity keypair and
|
||||
/// contacts a node before reaching the clone, and its bail message interpolates
|
||||
/// `{source}` unconditionally, so an assertion there would pass with `--` gone.
|
||||
///
|
||||
/// Asserting on git's single-quoted `repository '<value>'` is what makes this
|
||||
/// red without the delimiter — undelimited, git consumes the value as an option
|
||||
/// and names the destination as the repository instead.
|
||||
#[test]
|
||||
fn mirror_clone_never_parses_the_source_as_a_git_option() {
|
||||
let td = tempfile::TempDir::new().unwrap();
|
||||
let dest = td.path().join("mirror.git");
|
||||
let injected = "--upload-pack=false";
|
||||
|
||||
let out = Command::new("git")
|
||||
.env("LC_ALL", "C")
|
||||
.args(mirror_clone_args(injected))
|
||||
.arg(&dest)
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"an option-shaped source is not a repository and must fail the clone"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains(&format!("repository '{injected}'")),
|
||||
"git must name the source as the repository, not consume it as an option: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_github_url() {
|
||||
assert_eq!(
|
||||
@@ -378,8 +330,7 @@ mod tests {
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
// The git subprocess argv is pinned separately by
|
||||
// `mirror_clone_never_parses_the_source_as_a_git_option`; this covers the
|
||||
// We can't easily test the git subprocess calls, but we can test the
|
||||
// API error path by calling the create step directly via NodeClient.
|
||||
let kp2 = gitlawb_core::identity::Keypair::generate();
|
||||
let client = NodeClient::new(server.url(), Some(kp2));
|
||||
|
||||
Reference in new issue
Block a user