Skip to content

Commit 96fcfdb

Browse files
authored
fix(node): preserve promisor mirror mode on unknown withheld-paths lookup (#48) (#69)
Peer sync decided mirror mode from the origin's withheld-paths answer via classify_mirror, whose _ => Plain arm collapsed both Some(empty) (genuinely public) and None (lookup 404'd / 5xx / network / parse error) into Plain. For an existing promisor mirror that meant fetch_repo(.., Plain) unset remote.origin.promisor + partialclonefilter and ran git fetch --refetch, so a transient withheld-paths outage downgraded a still-withheld mirror to a full clone and broke syncs until the endpoint recovered. Replace classify_mirror with resolve_mirror_mode(withheld, exists, promisor): only Some(empty) downgrades an existing mirror; None preserves the on-disk promisor mode. A genuine public transition always returns Some(empty), never None, so preserving on None cannot mask one; it only suppresses the transient-error false positive. Add a three-valued PromisorProbe read from git config --get exit codes so a transient probe failure (Unknown) is not mistaken for a definitive NotPromisor and cannot itself trigger the downgrade. One warn marks the preserve path, derived from the resolved mode so it cannot drift. Tests cover every resolve_mirror_mode arm (incl. the regression and the indeterminate-probe case) and the probe's Promisor / NotPromisor / Unknown outcomes against real git repos.
1 parent c9f43b0 commit 96fcfdb

1 file changed

Lines changed: 179 additions & 20 deletions

File tree

‎crates/gitlawb-node/src/sync.rs‎

Lines changed: 179 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -32,19 +32,50 @@ enum MirrorMode {
3232
Promisor,
3333
}
3434

35-
/// Decide the mirror mode from the origin's `withheld-paths` response.
35+
/// The on-disk promisor state of an existing mirror, read from
36+
/// `remote.origin.promisor`. Three-valued so a git error is not mistaken for a
37+
/// definitive "not a promisor": `git config --get` collapses "key absent" and
38+
/// "git failed" into the same non-zero exit otherwise, and treating an errored
39+
/// probe as `NotPromisor` would let a transient failure downgrade a still-withheld
40+
/// mirror (issue #48).
41+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42+
enum PromisorProbe {
43+
/// `remote.origin.promisor` is `"true"`.
44+
Promisor,
45+
/// The key is absent (git exit 1) or set to a non-`true` value.
46+
NotPromisor,
47+
/// The probe itself failed (git spawn error or other non-zero exit).
48+
Unknown,
49+
}
50+
51+
/// Decide the mirror mode from the origin's `withheld-paths` response plus, when
52+
/// the response is unknown, the existing mirror's on-disk promisor state.
3653
///
3754
/// `Some(non-empty)` → the repo has a private subtree → `Promisor`.
3855
/// `Some(empty)` → fully public → `Plain`.
39-
/// `None` → the lookup 404'd or failed. Attempt a `Plain` mirror; a
40-
/// mode-A repo also 404s the git read endpoint, so the clone
41-
/// fails and nothing is mirrored (fail-closed at the git
42-
/// layer), while a public repo on a peer that predates the
43-
/// `withheld-paths` route still gets mirrored.
44-
fn classify_mirror(withheld: Option<Vec<String>>) -> MirrorMode {
56+
/// `None` → the lookup 404'd or failed; the answer is *unknown*, which
57+
/// is not the same as "public". For a fresh clone this stays
58+
/// `Plain` (a mode-A repo also 404s the git read endpoint, so
59+
/// the clone fails and nothing is mirrored — fail-closed at the
60+
/// git layer — while a public repo on a peer that predates the
61+
/// `withheld-paths` route still gets mirrored). For an existing
62+
/// mirror it *biases toward preserving* the promisor state: a
63+
/// genuine public transition returns `Some(empty)`, so on
64+
/// `None` we cannot distinguish "still withheld, unreachable"
65+
/// from "newly public, unreachable" and prefer the recoverable
66+
/// choice over destroying the partial-clone config. An
67+
/// indeterminate probe (`Unknown`) preserves for the same
68+
/// reason (defense-in-depth, #48).
69+
fn resolve_mirror_mode(
70+
withheld: Option<Vec<String>>,
71+
exists: bool,
72+
promisor: PromisorProbe,
73+
) -> MirrorMode {
4574
match withheld {
4675
Some(globs) if !globs.is_empty() => MirrorMode::Promisor,
47-
_ => MirrorMode::Plain,
76+
Some(_) => MirrorMode::Plain,
77+
None if exists && promisor != PromisorProbe::NotPromisor => MirrorMode::Promisor,
78+
None => MirrorMode::Plain,
4879
}
4980
}
5081

@@ -212,9 +243,30 @@ async fn process_batch(
212243
let remote_url = format!("{}/{}", origin_url, item.repo);
213244

214245
let withheld = fetch_withheld(client, &origin_url, owner_short, repo_name).await;
215-
let mode = classify_mirror(withheld);
246+
let exists = local_path.exists();
247+
let lookup_unknown = withheld.is_none();
248+
// Only probe the on-disk promisor state when the lookup is unknown and the
249+
// repo already exists — the sole case where it changes the resolved mode.
250+
let promisor = if lookup_unknown && exists {
251+
let local_str = local_path.to_str().unwrap_or(".");
252+
existing_promisor_state(local_str).await
253+
} else {
254+
PromisorProbe::NotPromisor
255+
};
256+
let mode = resolve_mirror_mode(withheld, exists, promisor);
257+
// Surface the case where an unknown withheld-paths lookup kept (or, on an
258+
// indeterminate probe, defensively applied) promisor mode instead of
259+
// downgrading to a full clone. Derived from the resolved mode so it cannot
260+
// drift from resolve_mirror_mode's preserve branch.
261+
if lookup_unknown && mode == MirrorMode::Promisor {
262+
warn!(
263+
repo = %item.repo,
264+
origin = %origin_url,
265+
"withheld-paths lookup unavailable; using promisor mirror mode to avoid an unsafe full-clone downgrade"
266+
);
267+
}
216268

217-
let result = if local_path.exists() {
269+
let result = if exists {
218270
fetch_repo(&local_path, &remote_url, mode).await
219271
} else {
220272
clone_repo(&remote_url, &local_path, mode).await
@@ -273,7 +325,7 @@ async fn process_batch(
273325

274326
/// Query the origin's anonymous `withheld-paths` endpoint. Returns the withheld
275327
/// glob list on a 2xx, or `None` on any non-success / network / parse error
276-
/// (treated as "unknown" by `classify_mirror`).
328+
/// (treated as "unknown" by `resolve_mirror_mode`).
277329
async fn fetch_withheld(
278330
client: &reqwest::Client,
279331
origin_url: &str,
@@ -468,6 +520,38 @@ async fn git_config_get(repo: &str, key: &str) -> Option<String> {
468520
(!value.is_empty()).then_some(value)
469521
}
470522

523+
/// Probe an existing mirror's `remote.origin.promisor` config as a three-valued
524+
/// state. Unlike [`git_config_get`], this distinguishes a definitively-absent key
525+
/// from a probe failure so a transient git error cannot be read as "not a
526+
/// promisor" and trigger a downgrade (issue #48).
527+
///
528+
/// `git config --get` exits 0 when the key is set, 1 when it is absent (or the
529+
/// directory is not a git repo) — both definitive `NotPromisor` — and other
530+
/// non-zero codes (e.g. 128 for a bad path or unreadable config) on error. A spawn
531+
/// failure or any non-{0,1} exit is `Unknown`.
532+
async fn existing_promisor_state(repo: &str) -> PromisorProbe {
533+
let out = match tokio::process::Command::new("git")
534+
.args(["-C", repo, "config", "--get", "remote.origin.promisor"])
535+
.output()
536+
.await
537+
{
538+
Ok(out) => out,
539+
Err(_) => return PromisorProbe::Unknown,
540+
};
541+
match out.status.code() {
542+
Some(0) => {
543+
let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
544+
if value == "true" {
545+
PromisorProbe::Promisor
546+
} else {
547+
PromisorProbe::NotPromisor
548+
}
549+
}
550+
Some(1) => PromisorProbe::NotPromisor,
551+
_ => PromisorProbe::Unknown,
552+
}
553+
}
554+
471555
/// Mirror-clone a repo from a remote URL into a local bare repo.
472556
/// `Promisor` mode adds `--filter=blob:limit=10g`, which marks the repo a git
473557
/// promisor (so a pack with origin-omitted withheld blobs is accepted) while
@@ -558,25 +642,68 @@ mod tests {
558642
use tempfile::TempDir;
559643

560644
#[test]
561-
fn classify_promisor_when_withheld_nonempty() {
562-
let mode = classify_mirror(Some(vec!["/secret/**".to_string()]));
645+
fn resolve_promisor_when_withheld_nonempty() {
646+
let mode = resolve_mirror_mode(
647+
Some(vec!["/secret/**".to_string()]),
648+
true,
649+
PromisorProbe::NotPromisor,
650+
);
563651
assert!(matches!(mode, MirrorMode::Promisor));
564652
}
565653

566654
#[test]
567-
fn classify_plain_when_withheld_empty() {
568-
let mode = classify_mirror(Some(vec![]));
569-
assert!(matches!(mode, MirrorMode::Plain));
655+
fn resolve_plain_when_withheld_empty() {
656+
// A genuine public transition returns Some(empty) and still downgrades,
657+
// regardless of whether the mirror exists or was a promisor.
658+
for exists in [true, false] {
659+
for probe in [
660+
PromisorProbe::Promisor,
661+
PromisorProbe::NotPromisor,
662+
PromisorProbe::Unknown,
663+
] {
664+
let mode = resolve_mirror_mode(Some(vec![]), exists, probe);
665+
assert!(matches!(mode, MirrorMode::Plain));
666+
}
667+
}
668+
}
669+
670+
#[test]
671+
fn resolve_preserves_promisor_on_unknown_lookup_for_existing_mirror() {
672+
// Regression for #48: a transient withheld-paths outage (None) must NOT
673+
// downgrade a still-withheld promisor mirror to a full clone.
674+
let mode = resolve_mirror_mode(None, true, PromisorProbe::Promisor);
675+
assert!(matches!(mode, MirrorMode::Promisor));
570676
}
571677

572678
#[test]
573-
fn classify_plain_when_lookup_failed() {
574-
// None == 404 / network error / parse failure: attempt a plain mirror
575-
// and let the git read endpoint fail-close a mode-A repo.
576-
let mode = classify_mirror(None);
679+
fn resolve_preserves_promisor_on_indeterminate_probe() {
680+
// Defense-in-depth (#48): if the config probe itself fails (Unknown) in the
681+
// same cycle as a withheld-paths outage, bias toward preserving rather than
682+
// firing the destructive downgrade.
683+
let mode = resolve_mirror_mode(None, true, PromisorProbe::Unknown);
684+
assert!(matches!(mode, MirrorMode::Promisor));
685+
}
686+
687+
#[test]
688+
fn resolve_plain_when_unknown_lookup_for_non_promisor_mirror() {
689+
// An existing non-promisor mirror is unaffected by the preserve branch.
690+
let mode = resolve_mirror_mode(None, true, PromisorProbe::NotPromisor);
577691
assert!(matches!(mode, MirrorMode::Plain));
578692
}
579693

694+
#[test]
695+
fn resolve_plain_when_unknown_lookup_for_fresh_clone() {
696+
// No local mirror yet: None stays Plain (fail-closed at the git layer).
697+
for probe in [
698+
PromisorProbe::Promisor,
699+
PromisorProbe::NotPromisor,
700+
PromisorProbe::Unknown,
701+
] {
702+
let mode = resolve_mirror_mode(None, false, probe);
703+
assert!(matches!(mode, MirrorMode::Plain));
704+
}
705+
}
706+
580707
fn rb(oid: &str, cid: &str) -> ReplicaBlob {
581708
ReplicaBlob {
582709
oid: oid.to_string(),
@@ -716,6 +843,38 @@ mod tests {
716843
assert_eq!(git_config(&dest, "remote.origin.mirror"), "true");
717844
}
718845

846+
#[tokio::test]
847+
async fn probe_reports_promisor_for_promisor_mirror() {
848+
let (td, url) = bare_remote(&[("public/a.txt", b"pub\n")]);
849+
let dest = td.path().join("mirror.git");
850+
clone_repo(&url, &dest, MirrorMode::Promisor).await.unwrap();
851+
852+
let probe = existing_promisor_state(dest.to_str().unwrap()).await;
853+
assert_eq!(probe, PromisorProbe::Promisor);
854+
}
855+
856+
#[tokio::test]
857+
async fn probe_reports_not_promisor_when_key_absent() {
858+
// A plain mirror never sets remote.origin.promisor, so `git config --get`
859+
// exits 1 (key absent) — the probe must read that as NotPromisor, never
860+
// Unknown (which would wrongly preserve and upgrade a plain mirror).
861+
let (td, url) = bare_remote(&[("public/a.txt", b"pub\n")]);
862+
let dest = td.path().join("mirror.git");
863+
clone_repo(&url, &dest, MirrorMode::Plain).await.unwrap();
864+
865+
let probe = existing_promisor_state(dest.to_str().unwrap()).await;
866+
assert_eq!(probe, PromisorProbe::NotPromisor);
867+
}
868+
869+
#[tokio::test]
870+
async fn probe_reports_unknown_on_git_error() {
871+
// A path git cannot resolve as a repo at all (exit 128) is an indeterminate
872+
// probe, not a definitive "not a promisor" — it must map to Unknown so the
873+
// caller preserves rather than downgrades (#48 defense-in-depth).
874+
let probe = existing_promisor_state("/nonexistent/gitlawb-probe-xyz").await;
875+
assert_eq!(probe, PromisorProbe::Unknown);
876+
}
877+
719878
#[tokio::test]
720879
async fn promisor_fetch_updates_existing_mirror() {
721880
let (td, url) = bare_remote(&[("public/a.txt", b"pub\n")]);

0 commit comments

Comments
 (0)