mirror of
https://github.com/Twigpine/node.git
synced 2026-11-09 05:20:05 -10:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5338a51e12 | ||
|
|
fe155e1ea8 | ||
|
|
f2d172432e | ||
|
|
a7f32fbf01 | ||
|
|
5ed3381a6b | ||
|
|
9b915b09a7 | ||
|
|
77ac1037cb | ||
|
|
db98b3957a | ||
|
|
0bae56f5ca | ||
|
|
a6d8459002 | ||
|
|
1b7639f0d7 | ||
|
|
5b9e06d501 | ||
|
|
92997b3695 | ||
|
|
ea34d84c3a | ||
|
|
5cc7e7a564 |
No files matched your search
@@ -504,6 +504,23 @@ mod tests {
|
||||
assert!(matches!(err, Error::Did(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_oversized_method_id_before_decoding() {
|
||||
let sk = fresh();
|
||||
let mut att = dummy_attestation(&sk, sample_cert_hash());
|
||||
// A well-formed prefix on an absurdly long id must fail the length
|
||||
// bound, not the decoder: base58 decode cost is quadratic in input
|
||||
// length, so reaching it at all is the defect.
|
||||
att.signer = format!("did:key:z{}", "1".repeat(256));
|
||||
let err = att.verify_signature(sample_cert_hash()).unwrap_err();
|
||||
// Match the bound's own message: any other Did error (a decode or
|
||||
// length mismatch downstream) means the oversized input was decoded.
|
||||
match err {
|
||||
Error::Did(m) => assert!(m.contains("too long"), "unexpected: {m}"),
|
||||
e => panic!("expected Error::Did, got {e:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_bad_signature_base64() {
|
||||
let sk = fresh();
|
||||
|
||||
@@ -150,23 +150,48 @@ impl Registry {
|
||||
}
|
||||
|
||||
/// Verify a batch, then enforce `RequireAll`.
|
||||
///
|
||||
/// Under [`Policy::RequireAll`] this never short-circuits: an attestation
|
||||
/// that fails to verify — bad signature, cert-hash mismatch, or malformed
|
||||
/// payload, the actual `Err` paths of [`Registry::verify`] — is dropped
|
||||
/// from the result rather than aborting the batch. A valid attestation of
|
||||
/// a type with NO registered verifier is not one of those: under
|
||||
/// `RequireAll` it verifies to `Ok` with `fully_verified: false` and stays
|
||||
/// in the result, preserving the distinction between *untrusted* and
|
||||
/// *invalid* (see `require_all_is_lenient_on_unknown_types_in_the_batch`).
|
||||
/// Any third party can attach an attestation, so a hard error here would
|
||||
/// let an unrelated `attacker/spam/v1` deny-of-service the whole cert (the
|
||||
/// DoS vector the module docstring warns about). The `required_types`
|
||||
/// check below is the real gate, and it only counts `fully_verified`
|
||||
/// entries — so a required type represented solely by a malformed
|
||||
/// attestation still fails with [`Error::RequiredMissing`].
|
||||
///
|
||||
/// Under `AcceptKnown` and `RejectUnknown` the batch stays strict: the
|
||||
/// first verification error is surfaced to the caller.
|
||||
pub fn verify_all(
|
||||
&self,
|
||||
attestations: &[Attestation],
|
||||
expected_cert_hash: [u8; 32],
|
||||
) -> Result<Vec<VerifiedAttestation>> {
|
||||
let mut verified = Vec::with_capacity(attestations.len());
|
||||
for a in attestations {
|
||||
verified.push(self.verify(a, expected_cert_hash)?);
|
||||
if self.policy != Policy::RequireAll {
|
||||
let mut verified = Vec::with_capacity(attestations.len());
|
||||
for a in attestations {
|
||||
verified.push(self.verify(a, expected_cert_hash)?);
|
||||
}
|
||||
return Ok(verified);
|
||||
}
|
||||
if self.policy == Policy::RequireAll {
|
||||
for required in &self.required_types {
|
||||
let present = verified
|
||||
.iter()
|
||||
.any(|v| &v.type_ == required && v.fully_verified);
|
||||
if !present {
|
||||
return Err(Error::RequiredMissing(required.clone()));
|
||||
}
|
||||
|
||||
// RequireAll: drop entries that fail to verify instead of aborting.
|
||||
let verified: Vec<VerifiedAttestation> = attestations
|
||||
.iter()
|
||||
.filter_map(|a| self.verify(a, expected_cert_hash).ok())
|
||||
.collect();
|
||||
for required in &self.required_types {
|
||||
let present = verified
|
||||
.iter()
|
||||
.any(|v| &v.type_ == required && v.fully_verified);
|
||||
if !present {
|
||||
return Err(Error::RequiredMissing(required.clone()));
|
||||
}
|
||||
}
|
||||
Ok(verified)
|
||||
@@ -324,6 +349,38 @@ mod tests {
|
||||
.any(|v| v.type_ == "other/v1" && !v.fully_verified));
|
||||
}
|
||||
|
||||
/// The strict policies must surface a batch member's verification error
|
||||
/// through `verify_all`, not launder it into a partial result. This binds
|
||||
/// the POLICY BOUNDARY itself: only the `RequireAll` arm may drop failing
|
||||
/// entries, and an apparently harmless cleanup that applies its
|
||||
/// `filter_map(Result::ok)` shape to the strict arms would silently turn
|
||||
/// the fail-closed contracts into fail-open — with every per-attestation
|
||||
/// `verify` test still green, because none of them go through the batch
|
||||
/// path. The failing entry is a wrong-cert-hash attestation (signed over a
|
||||
/// different hash than the batch expects), a genuine `Err` under every
|
||||
/// policy.
|
||||
#[test]
|
||||
fn strict_policies_propagate_a_batch_members_error() {
|
||||
let sk = fresh();
|
||||
let cert_hash = sample_hash();
|
||||
let mut wrong_hash = sample_hash();
|
||||
wrong_hash[0] ^= 0xff;
|
||||
|
||||
for policy in [Policy::AcceptKnown, Policy::RejectUnknown] {
|
||||
let mut reg = Registry::new().with_policy(policy);
|
||||
reg.register(DemoVerifier);
|
||||
let good = signed_demo(&sk, cert_hash, "ok");
|
||||
let bad = signed_demo(&sk, wrong_hash, "ok");
|
||||
let err = reg
|
||||
.verify_all(&[good, bad], cert_hash)
|
||||
.expect_err("a strict policy must fail the whole batch");
|
||||
assert!(
|
||||
matches!(err, Error::Signature(_) | Error::CertHashMismatch),
|
||||
"expected the underlying verification error, got {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A required type that is present but unverified (no verifier registered
|
||||
/// even though the type appears in `required_types`) must fail
|
||||
/// `verify_all` with `RequiredMissing`. Catches a misconfiguration where
|
||||
@@ -341,6 +398,65 @@ mod tests {
|
||||
assert!(matches!(err, Error::RequiredMissing(t) if t == "demo/v1"));
|
||||
}
|
||||
|
||||
/// A cert-hash that does not match `sample_hash()`, so an attestation
|
||||
/// signed against it fails `verify_signature` when the batch is checked
|
||||
/// against `sample_hash()`.
|
||||
fn other_hash() -> [u8; 32] {
|
||||
let mut h = sample_hash();
|
||||
h[0] ^= 0xff;
|
||||
h
|
||||
}
|
||||
|
||||
/// Under `RequireAll`, a malformed attestation (here: signed against the
|
||||
/// wrong cert hash, so it fails signature verification) attached alongside a
|
||||
/// valid required one must not abort the batch. The junk is dropped and the
|
||||
/// cert still verifies. Before the fix, `verify_all` propagated the error
|
||||
/// via `?` and the whole batch failed — the DoS vector the docstring warns
|
||||
/// about.
|
||||
#[test]
|
||||
fn require_all_drops_a_malformed_extra_and_still_accepts() {
|
||||
let sk = fresh();
|
||||
let cert_hash = sample_hash();
|
||||
let mut reg = Registry::new()
|
||||
.with_policy(Policy::RequireAll)
|
||||
.require_types(["demo/v1"]);
|
||||
reg.register(DemoVerifier);
|
||||
|
||||
// Signed against `other_hash()`, so it will not verify under `cert_hash`.
|
||||
let malformed = signed_demo(&sk, other_hash(), "boom");
|
||||
let real = signed_demo(&sk, cert_hash, "ok");
|
||||
|
||||
let verified = reg
|
||||
.verify_all(&[malformed, real], cert_hash)
|
||||
.expect("a malformed extra must not abort the batch under RequireAll");
|
||||
assert_eq!(verified.len(), 1, "the malformed entry should be dropped");
|
||||
assert!(verified
|
||||
.iter()
|
||||
.any(|v| v.type_ == "demo/v1" && v.fully_verified));
|
||||
}
|
||||
|
||||
/// Leniency must not open a hole: if the *only* attestation of a required
|
||||
/// type is malformed, dropping it leaves the required type absent and
|
||||
/// `verify_all` must still fail with `RequiredMissing`. Guards against a fix
|
||||
/// that skips errors so eagerly it lets an unverified required type pass.
|
||||
#[test]
|
||||
fn require_all_rejects_when_only_a_malformed_required_type_is_present() {
|
||||
let sk = fresh();
|
||||
let cert_hash = sample_hash();
|
||||
let mut reg = Registry::new()
|
||||
.with_policy(Policy::RequireAll)
|
||||
.require_types(["demo/v1"]);
|
||||
reg.register(DemoVerifier);
|
||||
|
||||
// The sole demo/v1 is bound to the wrong cert hash → fails verification.
|
||||
let malformed_required = signed_demo(&sk, other_hash(), "ok");
|
||||
|
||||
let err = reg
|
||||
.verify_all(&[malformed_required], cert_hash)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, Error::RequiredMissing(t) if t == "demo/v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_check_failure_rejects() {
|
||||
let sk = fresh();
|
||||
|
||||
@@ -70,6 +70,8 @@ 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
|
||||
@@ -104,8 +106,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` / `.local` / `.internal` hostnames. Used at announce time
|
||||
/// and by the boot-time prune of already-poisoned rows.
|
||||
/// and `localhost` / `.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,
|
||||
@@ -125,6 +127,7 @@ 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")
|
||||
{
|
||||
@@ -260,9 +263,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(
|
||||
"http_url must be a public http(s) URL (no loopback, private, or .internal/.local hosts)".into(),
|
||||
));
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"http_url {PUBLIC_HTTP_URL_REQUIREMENT}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Reject self-announcements: a peer row whose http_url is our own public
|
||||
@@ -541,6 +544,7 @@ 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",
|
||||
@@ -620,11 +624,14 @@ 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
|
||||
// (`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"] {
|
||||
// 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../",
|
||||
] {
|
||||
assert!(!is_public_http_url(bad), "{bad:?} must be rejected");
|
||||
}
|
||||
}
|
||||
@@ -1582,6 +1589,31 @@ 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
|
||||
|
||||
@@ -46,9 +46,10 @@ 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(
|
||||
"webhook URL must be a public http(s) URL (no loopback, private, or .internal/.local hosts)".into(),
|
||||
));
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"webhook URL {}",
|
||||
crate::api::peers::PUBLIC_HTTP_URL_REQUIREMENT
|
||||
)));
|
||||
}
|
||||
|
||||
let events = req.events.unwrap_or_else(|| vec!["*".into()]);
|
||||
@@ -141,7 +142,19 @@ pub async fn delete_webhook(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::peers::is_public_http_url;
|
||||
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;
|
||||
|
||||
// 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
|
||||
@@ -152,6 +165,7 @@ 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).
|
||||
@@ -169,4 +183,53 @@ 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,6 +23,133 @@ 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
|
||||
@@ -1158,6 +1285,79 @@ 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).
|
||||
@@ -1284,6 +1484,72 @@ 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(
|
||||
@@ -3930,6 +4196,576 @@ 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,7 +314,9 @@ pub fn record_sync_processed(status: &str) {
|
||||
}
|
||||
|
||||
/// Record one webhook delivery attempt outcome. `result` ∈
|
||||
/// {ok, http_error, network_error, skipped}.
|
||||
/// {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.
|
||||
pub fn record_webhook_delivery(result: &str) {
|
||||
if let Some(c) = WEBHOOK_DELIVERIES.get() {
|
||||
c.with_label_values(&[result]).inc();
|
||||
|
||||
@@ -79,6 +79,19 @@ 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
|
||||
|
||||
@@ -88,7 +88,7 @@ async fn cmd_list(node: String, capability: Option<String>) -> Result<()> {
|
||||
let short = did
|
||||
.split(':')
|
||||
.next_back()
|
||||
.map(|s| &s[..s.len().min(16)])
|
||||
.map(|s| crate::text::truncate(s, 16))
|
||||
.unwrap_or("?");
|
||||
let trust = agent["trust_score"].as_f64().unwrap_or(0.0);
|
||||
let caps = agent["capabilities"]
|
||||
|
||||
@@ -275,7 +275,7 @@ async fn cmd_list(
|
||||
let title = b["title"].as_str().unwrap_or("?");
|
||||
let amount = b["amount"].as_i64().unwrap_or(0);
|
||||
let st = b["status"].as_str().unwrap_or("?");
|
||||
let short_id = &id[..8.min(id.len())];
|
||||
let short_id = crate::text::truncate(id, 8);
|
||||
println!("{short_id} {st:<10} {amount:>12} $GITLAWB {title}");
|
||||
}
|
||||
}
|
||||
@@ -403,7 +403,7 @@ async fn cmd_stats(node: String) -> Result<()> {
|
||||
let did = entry["did"].as_str().unwrap_or("?");
|
||||
let count = entry["completed"].as_i64().unwrap_or(0);
|
||||
let earned = entry["total_earned"].as_i64().unwrap_or(0);
|
||||
let short = &did[did.len().saturating_sub(8)..];
|
||||
let short = crate::text::truncate_suffix(did, 8);
|
||||
println!(
|
||||
" {}. ...{short} {count} bounties {earned} $GITLAWB",
|
||||
i + 1
|
||||
|
||||
@@ -117,7 +117,10 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
let id = cert["id"].as_str().unwrap_or("?");
|
||||
let ref_name = cert["ref_name"].as_str().unwrap_or("?");
|
||||
let new_sha = cert["new_sha"].as_str().unwrap_or("?");
|
||||
let issued_at = cert["issued_at"].as_str().map(|s| &s[..19]).unwrap_or("?");
|
||||
let issued_at = cert["issued_at"]
|
||||
.as_str()
|
||||
.map(|s| crate::text::truncate(s, 19))
|
||||
.unwrap_or("?");
|
||||
println!(" {id:.8} {issued_at} {ref_name} {new_sha:.12}");
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -75,14 +75,14 @@ pub async fn run(args: ChangelogArgs) -> Result<()> {
|
||||
|
||||
for event in &events {
|
||||
let ts = event["timestamp"].as_str().unwrap_or("?");
|
||||
let date = &ts[..ts.len().min(10)];
|
||||
let date = crate::text::truncate(ts, 10);
|
||||
|
||||
match event["type"].as_str().unwrap_or("") {
|
||||
"commit" => {
|
||||
let sha = event["sha"].as_str().unwrap_or("?");
|
||||
let msg = event["message"].as_str().unwrap_or("?");
|
||||
let first_line = msg.lines().next().unwrap_or(msg);
|
||||
let short_sha = &sha[..sha.len().min(8)];
|
||||
let short_sha = crate::text::truncate(sha, 8);
|
||||
println!(" {date} commit {short_sha} {first_line}");
|
||||
}
|
||||
"pr_merged" => {
|
||||
|
||||
@@ -131,11 +131,7 @@ async fn cmd_list(node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
let sha = pin["sha256_hex"].as_str().unwrap_or("?");
|
||||
let pinned_at = pin["pinned_at"].as_str().unwrap_or("?");
|
||||
// Trim pinned_at to date+time without subseconds
|
||||
let ts = if pinned_at.len() >= 19 {
|
||||
&pinned_at[..19]
|
||||
} else {
|
||||
pinned_at
|
||||
};
|
||||
let ts = crate::text::truncate(pinned_at, 19);
|
||||
println!(" {cid}");
|
||||
println!(" sha256: {sha}");
|
||||
println!(" pinned: {ts}");
|
||||
|
||||
+34
-3
@@ -251,7 +251,7 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
let status = issue["status"].as_str().unwrap_or("?");
|
||||
let created = issue["created_at"]
|
||||
.as_str()
|
||||
.map(|s| &s[..10])
|
||||
.map(|s| crate::text::truncate(s, 10))
|
||||
.unwrap_or("?");
|
||||
let icon = match status {
|
||||
"open" => "○",
|
||||
@@ -365,10 +365,13 @@ async fn cmd_issue_comments(
|
||||
let author_short = author
|
||||
.split(':')
|
||||
.next_back()
|
||||
.map(|s| &s[..s.len().min(8)])
|
||||
.map(|s| crate::text::truncate(s, 8))
|
||||
.unwrap_or("?");
|
||||
let cbody = c["body"].as_str().unwrap_or("");
|
||||
let created = c["created_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
|
||||
let created = c["created_at"]
|
||||
.as_str()
|
||||
.map(|s| crate::text::truncate(s, 10))
|
||||
.unwrap_or("?");
|
||||
println!(" · {author_short} ({created})");
|
||||
println!(" {cbody}");
|
||||
println!();
|
||||
@@ -439,6 +442,34 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_list_survives_malformed_timestamps() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
// A short timestamp used to panic on `&s[..10]`; a timestamp whose
|
||||
// byte 10 is mid-char panics even with a len() guard.
|
||||
let _m = server
|
||||
.mock(
|
||||
"GET",
|
||||
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/issues$".to_string()),
|
||||
)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"issues":[{"id":"a","title":"short ts","status":"open","created_at":"2026"},{"id":"b","title":"mb ts","status":"open","created_at":"2026-08-1é5T00:00:00Z"},{"id":"c","title":"empty ts","status":"open","created_at":""}]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
cmd_list(
|
||||
"myrepo".to_string(),
|
||||
server.url(),
|
||||
Some(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_create_success() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -30,6 +30,7 @@ mod star;
|
||||
mod status;
|
||||
mod sync;
|
||||
mod task;
|
||||
mod text;
|
||||
mod ucan_cmd;
|
||||
mod visibility;
|
||||
mod webhook;
|
||||
|
||||
@@ -413,7 +413,7 @@ async fn cmd_status(node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
let ref_name = ev["ref"].as_str().unwrap_or("?");
|
||||
let ts = ev["timestamp"]
|
||||
.as_str()
|
||||
.map(|s| &s[..10.min(s.len())])
|
||||
.map(|s| crate::text::truncate(s, 10))
|
||||
.unwrap_or("?");
|
||||
println!(" {ts} {repo} {ref_name}");
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ async fn cmd_list(node: String) -> Result<()> {
|
||||
let reachable = peer["reachable"].as_bool().unwrap_or(false);
|
||||
let last_seen = peer["last_seen"]
|
||||
.as_str()
|
||||
.map(|s| &s[..10])
|
||||
.map(|s| crate::text::truncate(s, 10))
|
||||
.unwrap_or("never");
|
||||
let status = if reachable { "✓" } else { "✗" };
|
||||
println!(" {status} {url}");
|
||||
|
||||
+13
-7
@@ -304,7 +304,7 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
|
||||
let author_short = author
|
||||
.split(':')
|
||||
.next_back()
|
||||
.map(|s| &s[..s.len().min(8)])
|
||||
.map(|s| crate::text::truncate(s, 8))
|
||||
.unwrap_or("?");
|
||||
let status_icon = match status {
|
||||
"open" => "○",
|
||||
@@ -374,7 +374,7 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
|
||||
let reviewer_short = reviewer
|
||||
.split(':')
|
||||
.next_back()
|
||||
.map(|s| &s[..s.len().min(8)])
|
||||
.map(|s| crate::text::truncate(s, 8))
|
||||
.unwrap_or("?");
|
||||
let rstatus = r["status"].as_str().unwrap_or("?");
|
||||
let rbody = r["body"].as_str().unwrap_or("");
|
||||
@@ -414,10 +414,13 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
|
||||
let author_short = author
|
||||
.split(':')
|
||||
.next_back()
|
||||
.map(|s| &s[..s.len().min(8)])
|
||||
.map(|s| crate::text::truncate(s, 8))
|
||||
.unwrap_or("?");
|
||||
let cbody = c["body"].as_str().unwrap_or("");
|
||||
let created = c["created_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
|
||||
let created = c["created_at"]
|
||||
.as_str()
|
||||
.map(|s| crate::text::truncate(s, 10))
|
||||
.unwrap_or("?");
|
||||
println!(" · {author_short} ({created})");
|
||||
println!(" {cbody}");
|
||||
}
|
||||
@@ -464,7 +467,7 @@ async fn cmd_merge(repo: String, number: u64, node: String, dir: Option<PathBuf>
|
||||
|
||||
let sha = result["merge_sha"].as_str().unwrap_or("?");
|
||||
println!("✓ Merged PR #{number}");
|
||||
println!(" Merge commit: {}", &sha[..sha.len().min(12)]);
|
||||
println!(" Merge commit: {}", crate::text::truncate(sha, 12));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -555,10 +558,13 @@ async fn cmd_comments(repo: String, number: u64, node: String, dir: Option<PathB
|
||||
let author_short = author
|
||||
.split(':')
|
||||
.next_back()
|
||||
.map(|s| &s[..s.len().min(8)])
|
||||
.map(|s| crate::text::truncate(s, 8))
|
||||
.unwrap_or("?");
|
||||
let cbody = c["body"].as_str().unwrap_or("");
|
||||
let created = c["created_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
|
||||
let created = c["created_at"]
|
||||
.as_str()
|
||||
.map(|s| crate::text::truncate(s, 10))
|
||||
.unwrap_or("?");
|
||||
println!(" · {author_short} ({created})");
|
||||
println!(" {cbody}");
|
||||
println!();
|
||||
|
||||
@@ -337,7 +337,10 @@ async fn cmd_list(node: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
let name = r["name"].as_str().unwrap_or("?");
|
||||
let desc = r["description"].as_str().unwrap_or("");
|
||||
let public = r["is_public"].as_bool().unwrap_or(true);
|
||||
let updated = r["updated_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
|
||||
let updated = r["updated_at"]
|
||||
.as_str()
|
||||
.map(|s| crate::text::truncate(s, 10))
|
||||
.unwrap_or("?");
|
||||
let vis = if public { "public" } else { "private" };
|
||||
println!(" {name:<24} {vis:<8} {updated} {desc}");
|
||||
}
|
||||
@@ -514,7 +517,7 @@ async fn cmd_replicas(repo: String, node: String, dir: Option<PathBuf>) -> Resul
|
||||
let url = r["replica_url"].as_str().unwrap_or("?");
|
||||
let registered = r["registered_at"]
|
||||
.as_str()
|
||||
.map(|s| &s[..10.min(s.len())])
|
||||
.map(|s| crate::text::truncate(s, 10))
|
||||
.unwrap_or("?");
|
||||
println!(" {registered} {did} → {url}");
|
||||
}
|
||||
@@ -556,7 +559,7 @@ pub(crate) async fn cmd_commits(
|
||||
.or_else(|| c["sha"].as_str())
|
||||
.or_else(|| c["oid"].as_str())
|
||||
.unwrap_or("?");
|
||||
let short_sha = &sha[..sha.len().min(10)];
|
||||
let short_sha = crate::text::truncate(sha, 10);
|
||||
let msg = c["message"].as_str().unwrap_or("(no message)");
|
||||
let first_line = msg.lines().next().unwrap_or(msg);
|
||||
let author = c["author_name"]
|
||||
@@ -566,7 +569,7 @@ pub(crate) async fn cmd_commits(
|
||||
let date = c["date"]
|
||||
.as_str()
|
||||
.or_else(|| c["committer_date"].as_str())
|
||||
.map(|s| &s[..10.min(s.len())])
|
||||
.map(|s| crate::text::truncate(s, 10))
|
||||
.unwrap_or("?");
|
||||
println!(" {short_sha} {date} {first_line} ({author})");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Display helpers for strings that come off the wire unchecked.
|
||||
|
||||
/// Truncate `s` to at most `max` bytes, cutting at a char boundary.
|
||||
///
|
||||
/// `&s[..max]` panics when `s` is shorter than `max` bytes and when `max`
|
||||
/// lands inside a multi-byte char; `s.len().min(max)` guards only the first
|
||||
/// case. Node-supplied timestamps and ids hit both.
|
||||
pub(crate) fn truncate(s: &str, max: usize) -> &str {
|
||||
let mut end = max.min(s.len());
|
||||
while !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
&s[..end]
|
||||
}
|
||||
|
||||
/// Keep at most the last `max` bytes of `s`, cutting at a char boundary.
|
||||
///
|
||||
/// `&s[s.len().saturating_sub(max)..]` panics when the start index lands
|
||||
/// inside a multi-byte char; node-supplied ids hit that case.
|
||||
pub(crate) fn truncate_suffix(s: &str, max: usize) -> &str {
|
||||
let mut start = s.len().saturating_sub(max);
|
||||
while !s.is_char_boundary(start) {
|
||||
start += 1;
|
||||
}
|
||||
&s[start..]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{truncate, truncate_suffix};
|
||||
|
||||
#[test]
|
||||
fn cuts_ascii_at_max() {
|
||||
assert_eq!(truncate("2026-08-15T12:34:56Z", 10), "2026-08-15");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_and_empty_inputs_return_whole() {
|
||||
assert_eq!(truncate("2026", 10), "2026");
|
||||
assert_eq!(truncate("", 10), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_cut_does_not_panic() {
|
||||
// Byte index 10 lands inside 'é'; the cut must land on the boundary
|
||||
// before it (9 bytes) rather than panic or exceed the byte limit.
|
||||
assert_eq!(truncate("2026-08-1\u{e9}5T12:34:56Z", 10), "2026-08-1");
|
||||
// A string that is entirely multi-byte and shorter than max.
|
||||
assert_eq!(truncate("\u{e9}\u{e9}", 10), "\u{e9}\u{e9}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suffix_cut_does_not_panic() {
|
||||
// 'é' is two bytes; a start index of len-8 lands inside it.
|
||||
assert_eq!(truncate_suffix("did:key:\u{e9}1234567", 8), "1234567");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suffix_short_input_returns_whole() {
|
||||
assert_eq!(truncate_suffix("short", 8), "short");
|
||||
assert_eq!(truncate_suffix("", 8), "");
|
||||
}
|
||||
}
|
||||
@@ -88,20 +88,81 @@ fn sanitize_excerpt(s: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `u` parses as a trusted URL.
|
||||
/// Fixed warning when `GITLAWB_ICAPTCHA_INSECURE` is present but not an
|
||||
/// explicit truthy value. Does not include the raw env value (arbitrary
|
||||
/// deployment input — secrets / terminal controls).
|
||||
fn warn_icaptcha_insecure_non_truthy() {
|
||||
// One literal line on purpose: a continuation escape here keeps emitting
|
||||
// correct prose (Rust strips the next line's leading whitespace), but the
|
||||
// single-line form makes that property visible without knowing the escape
|
||||
// semantics, which is what the review asked for.
|
||||
tracing::warn!(
|
||||
"GITLAWB_ICAPTCHA_INSECURE is set but not truthy (expected 1 or true); loopback HTTP trust relaxation remains disabled"
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse a possibly-present `GITLAWB_ICAPTCHA_INSECURE` string.
|
||||
///
|
||||
/// Production trusts only `https`. When `GITLAWB_ICAPTCHA_INSECURE` is set (a
|
||||
/// runtime escape hatch for integration tests) the function also trusts
|
||||
/// `http://127.0.0.1` and `http://localhost` so the full iCaptcha retry path
|
||||
/// can be exercised against a local mockito server.
|
||||
fn is_https(u: &str) -> bool {
|
||||
/// Only an explicit truthy value (`1` / `true`, case-insensitive, after trim)
|
||||
/// enables the loopback-HTTP trust relaxation. Presence alone is not enough:
|
||||
/// `=0`, `=false`, or an empty value leave it off. A non-empty non-truthy
|
||||
/// value emits [`warn_icaptcha_insecure_non_truthy`].
|
||||
fn icaptcha_insecure_from_str(raw: Option<&str>) -> bool {
|
||||
let Some(v) = raw else {
|
||||
return false;
|
||||
};
|
||||
let t = v.trim();
|
||||
if t.eq_ignore_ascii_case("1") || t.eq_ignore_ascii_case("true") {
|
||||
true
|
||||
} else {
|
||||
if !t.is_empty() {
|
||||
warn_icaptcha_insecure_non_truthy();
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Interpret `std::env::var("GITLAWB_ICAPTCHA_INSECURE")` without reading the
|
||||
/// environment again — used by production and by unit tests.
|
||||
///
|
||||
/// `NotPresent` → disabled. `NotUnicode` → disabled + the same fixed warning
|
||||
/// as a present non-truthy value (fail closed; do not treat as unset).
|
||||
fn icaptcha_insecure_from_var(result: Result<String, std::env::VarError>) -> bool {
|
||||
match result {
|
||||
Ok(v) => icaptcha_insecure_from_str(Some(&v)),
|
||||
Err(std::env::VarError::NotUnicode(_)) => {
|
||||
warn_icaptcha_insecure_non_truthy();
|
||||
false
|
||||
}
|
||||
Err(std::env::VarError::NotPresent) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `GITLAWB_ICAPTCHA_INSECURE` explicitly enables the loopback-HTTP
|
||||
/// trust relaxation used by integration tests.
|
||||
///
|
||||
/// Only an explicit truthy value (`1` / `true`, case-insensitive, after trim)
|
||||
/// enables it. Presence alone is not enough: `=0`, `=false`, or an empty
|
||||
/// value leave the relaxation off. A non-empty non-truthy value (including a
|
||||
/// present non-Unicode value) emits a `tracing::warn!` (visible when the
|
||||
/// `icaptcha_client` target is at WARN+, e.g. `RUST_LOG=icaptcha_client=warn`).
|
||||
fn icaptcha_insecure_enabled() -> bool {
|
||||
icaptcha_insecure_from_var(std::env::var("GITLAWB_ICAPTCHA_INSECURE"))
|
||||
}
|
||||
|
||||
/// Whether `u` parses as a trusted URL, given a pre-parsed insecure flag.
|
||||
///
|
||||
/// Production trusts only `https`. When `insecure` is true the function also
|
||||
/// trusts `http://127.0.0.1` and `http://localhost` so the full iCaptcha retry
|
||||
/// path can be exercised against a local mockito server.
|
||||
fn is_https_with(u: &str, insecure: bool) -> bool {
|
||||
let Ok(parsed) = reqwest::Url::parse(u) else {
|
||||
return false;
|
||||
};
|
||||
if parsed.scheme() == "https" {
|
||||
return true;
|
||||
}
|
||||
if std::env::var_os("GITLAWB_ICAPTCHA_INSECURE").is_some()
|
||||
if insecure
|
||||
&& parsed.scheme() == "http"
|
||||
&& matches!(parsed.host_str(), Some("127.0.0.1" | "localhost"))
|
||||
{
|
||||
@@ -139,7 +200,15 @@ fn origin_key(u: &str) -> Option<String> {
|
||||
/// explicitly-configured host (never a node-discovered one), so a key is never
|
||||
/// exfiltrated to an origin the operator did not choose.
|
||||
fn resolve_solver_url(advertised: Option<&str>, operator: Option<&str>) -> (String, bool) {
|
||||
let operator = operator.filter(|u| is_https(u));
|
||||
resolve_solver_url_with(advertised, operator, icaptcha_insecure_enabled())
|
||||
}
|
||||
|
||||
fn resolve_solver_url_with(
|
||||
advertised: Option<&str>,
|
||||
operator: Option<&str>,
|
||||
insecure: bool,
|
||||
) -> (String, bool) {
|
||||
let operator = operator.filter(|u| is_https_with(u, insecure));
|
||||
let operator_origin = operator.as_ref().and_then(|u| origin_key(u));
|
||||
let default_origin = origin_key(DEFAULT_URL);
|
||||
|
||||
@@ -157,7 +226,7 @@ fn resolve_solver_url(advertised: Option<&str>, operator: Option<&str>) -> (Stri
|
||||
};
|
||||
|
||||
let chosen = match advertised {
|
||||
Some(a) if is_https(a) && allowed(&origin_key(a)) => a.to_string(),
|
||||
Some(a) if is_https_with(a, insecure) && allowed(&origin_key(a)) => a.to_string(),
|
||||
Some(a) => {
|
||||
tracing::warn!(
|
||||
advertised = %a,
|
||||
@@ -389,27 +458,36 @@ mod tests {
|
||||
use std::ffi::OsString;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
/// Serializes tests that touch the process-global
|
||||
/// `GITLAWB_ICAPTCHA_INSECURE` env var so they never race.
|
||||
/// Serializes the real-env reader test so it never races with other
|
||||
/// process-global env mutations (and recovers from a poisoned lock).
|
||||
static ICAPTCHA_ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Set `GITLAWB_ICAPTCHA_INSECURE` for the test lifetime, restoring any
|
||||
/// prior value on drop.
|
||||
struct InsecureEnv {
|
||||
struct EnvGuard {
|
||||
_lock: MutexGuard<'static, ()>,
|
||||
prev: Option<OsString>,
|
||||
}
|
||||
|
||||
impl InsecureEnv {
|
||||
fn new() -> Self {
|
||||
let lock = ICAPTCHA_ENV_LOCK.lock().unwrap();
|
||||
impl EnvGuard {
|
||||
fn lock() -> MutexGuard<'static, ()> {
|
||||
ICAPTCHA_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn with_value(val: &str) -> Self {
|
||||
let lock = Self::lock();
|
||||
let prev = std::env::var_os("GITLAWB_ICAPTCHA_INSECURE");
|
||||
std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", "1");
|
||||
InsecureEnv { _lock: lock, prev }
|
||||
std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", val);
|
||||
EnvGuard { _lock: lock, prev }
|
||||
}
|
||||
|
||||
fn unset() -> Self {
|
||||
let lock = Self::lock();
|
||||
let prev = std::env::var_os("GITLAWB_ICAPTCHA_INSECURE");
|
||||
std::env::remove_var("GITLAWB_ICAPTCHA_INSECURE");
|
||||
EnvGuard { _lock: lock, prev }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InsecureEnv {
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.prev.take() {
|
||||
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", v),
|
||||
@@ -418,6 +496,118 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #227 / #246: cover the production env read in `icaptcha_insecure_enabled`.
|
||||
/// Pure-helper tests alone leave mutations of this function green.
|
||||
#[test]
|
||||
fn icaptcha_insecure_enabled_reads_real_env() {
|
||||
{
|
||||
let _g = EnvGuard::unset();
|
||||
assert!(!icaptcha_insecure_enabled(), "unset must disable");
|
||||
}
|
||||
{
|
||||
let _g = EnvGuard::with_value("0");
|
||||
assert!(!icaptcha_insecure_enabled(), "=0 must disable");
|
||||
}
|
||||
{
|
||||
let _g = EnvGuard::with_value("false");
|
||||
assert!(!icaptcha_insecure_enabled(), "=false must disable");
|
||||
}
|
||||
{
|
||||
let _g = EnvGuard::with_value("");
|
||||
assert!(!icaptcha_insecure_enabled(), "empty must disable");
|
||||
}
|
||||
{
|
||||
let _g = EnvGuard::with_value("1");
|
||||
assert!(icaptcha_insecure_enabled(), "=1 must enable");
|
||||
}
|
||||
{
|
||||
let _g = EnvGuard::with_value("true");
|
||||
assert!(icaptcha_insecure_enabled(), "=true must enable");
|
||||
}
|
||||
{
|
||||
let _g = EnvGuard::with_value("TRUE");
|
||||
assert!(icaptcha_insecure_enabled(), "=TRUE must enable");
|
||||
}
|
||||
{
|
||||
let _g = EnvGuard::with_value(" 1 ");
|
||||
assert!(icaptcha_insecure_enabled(), "\" 1 \" (trimmed) must enable");
|
||||
}
|
||||
}
|
||||
|
||||
/// #227: `=0` / `=false` / empty / unset must NOT enable loopback HTTP;
|
||||
/// only explicit truthy `1` / `true` may. Uses pure helpers so tests do
|
||||
/// not mutate process-global environment (avoids races with parallel
|
||||
/// resolver tests that also call `resolve_solver_url`).
|
||||
#[test]
|
||||
fn insecure_env_only_truthy_enables_loopback_http() {
|
||||
assert!(
|
||||
!is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some("0"))),
|
||||
"=0 must leave loopback HTTP disabled"
|
||||
);
|
||||
assert!(!icaptcha_insecure_from_str(Some("0")));
|
||||
|
||||
assert!(
|
||||
!is_https_with(
|
||||
"http://127.0.0.1",
|
||||
icaptcha_insecure_from_str(Some("false"))
|
||||
),
|
||||
"=false must leave loopback HTTP disabled"
|
||||
);
|
||||
assert!(!icaptcha_insecure_from_str(Some("false")));
|
||||
|
||||
assert!(
|
||||
!is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some(""))),
|
||||
"empty value must leave loopback HTTP disabled"
|
||||
);
|
||||
assert!(!icaptcha_insecure_from_str(Some("")));
|
||||
|
||||
assert!(
|
||||
!is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(None)),
|
||||
"unset must leave loopback HTTP disabled"
|
||||
);
|
||||
assert!(!icaptcha_insecure_from_str(None));
|
||||
assert!(!icaptcha_insecure_from_var(Err(
|
||||
std::env::VarError::NotPresent
|
||||
)));
|
||||
|
||||
assert!(
|
||||
is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some("1"))),
|
||||
"=1 must enable loopback HTTP"
|
||||
);
|
||||
assert!(is_https_with(
|
||||
"http://localhost",
|
||||
icaptcha_insecure_from_str(Some("1"))
|
||||
));
|
||||
assert!(icaptcha_insecure_from_str(Some("1")));
|
||||
|
||||
assert!(
|
||||
is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some("true"))),
|
||||
"=true must enable loopback HTTP"
|
||||
);
|
||||
assert!(icaptcha_insecure_from_str(Some("true")));
|
||||
|
||||
assert!(
|
||||
is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some("TRUE"))),
|
||||
"=TRUE (case-insensitive) must enable loopback HTTP"
|
||||
);
|
||||
|
||||
// Trim is load-bearing: whitespace around a truthy token must still enable.
|
||||
assert!(
|
||||
is_https_with("http://127.0.0.1", icaptcha_insecure_from_str(Some(" 1 "))),
|
||||
"\" 1 \" (trimmed) must enable loopback HTTP"
|
||||
);
|
||||
assert!(icaptcha_insecure_from_str(Some(" 1 ")));
|
||||
|
||||
// Present non-Unicode: fail closed + same fixed warning path as non-truthy.
|
||||
assert!(!icaptcha_insecure_from_var(Err(
|
||||
std::env::VarError::NotUnicode(OsString::from("x"))
|
||||
)));
|
||||
|
||||
// https remains trusted regardless of the insecure flag.
|
||||
assert!(is_https_with("https://icaptcha.gitlawb.com", false));
|
||||
assert!(is_https_with("https://icaptcha.gitlawb.com", true));
|
||||
}
|
||||
|
||||
// ── P1: hostile error bodies must not reach the terminal raw ──────────
|
||||
|
||||
#[test]
|
||||
@@ -543,13 +733,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rejects_insecure_advertised_url_on_different_port() {
|
||||
// With GITLAWB_ICAPTCHA_INSECURE=1, two loopback URLs on different
|
||||
// With insecure mode enabled, two loopback URLs on different
|
||||
// ports must be treated as distinct origins so a hostile node cannot
|
||||
// redirect the bearer key to a different listener on localhost.
|
||||
let _env = InsecureEnv::new();
|
||||
|
||||
let op = "http://localhost:3000";
|
||||
let (url, key_trusted) = resolve_solver_url(Some("http://localhost:9000"), Some(op));
|
||||
let (url, key_trusted) =
|
||||
resolve_solver_url_with(Some("http://localhost:9000"), Some(op), true);
|
||||
assert_eq!(
|
||||
url, op,
|
||||
"must fall back to operator origin, not use different port"
|
||||
|
||||
Reference in new issue
Block a user