git_remote_object_store/protocol/push.rs
1//! `push` handler with per-ref locking via conditional writes.
2//!
3//! Sequential-batch semantics: every `push <refspec>` line in a batch
4//! is processed in order under its own per-ref lock, and one outcome
5//! line is emitted per push (`ok <ref>\n` or `error <ref> "msg"\n`).
6//! `gix::Repository` is `!Sync` so the handler holds the repo handle
7//! on a single task — pushes never run in parallel within one client.
8//!
9//! Stdout discipline: this module returns [`PushOutcome`] values and
10//! never writes to the protocol stream itself. The REPL renders each
11//! outcome and the trailing blank-line terminator (see
12//! `.claude/rules/protocol-stdout.md`).
13
14use std::collections::HashSet;
15use std::env;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use std::sync::atomic::{AtomicU64, Ordering};
19
20use bytes::Bytes;
21use time::{Duration, OffsetDateTime};
22use tokio::sync::watch;
23use tokio::task::JoinHandle;
24use tracing::{debug, info, warn};
25
26use crate::git::{self, GitError, RefName, RefNameError, Sha, ShaError, is_valid_ref_name};
27use crate::keys;
28use crate::object_store::{ObjectMeta, ObjectStore, ObjectStoreError, ProgressSink, PutOpts};
29use crate::packchain::gc::{tombstoned_bundle_keys, write_baseline_tombstone_best_effort};
30use crate::packchain::schema::Sha40;
31use crate::url::{BackendKind, StorageEngine};
32
33/// Default per-ref lock TTL (seconds) when [`ENV_LOCK_TTL_SECONDS`] is
34/// unset or unparseable. Single source of truth for the lock TTL —
35/// `manage::doctor`'s stale-lock predicate, the CLI's
36/// `--lock-ttl-seconds` default (resolved through [`lock_ttl_from_env`]
37/// when the flag is unset), and integration-test wire-format pinning
38/// all consume this constant (the management surface re-exports it
39/// from [`crate::manage::DEFAULT_LOCK_TTL_SECONDS`]) so the views of
40/// "stale" cannot drift silently.
41pub const DEFAULT_LOCK_TTL_SECONDS: u64 = 60;
42
43/// Push configuration that is constant across an entire batch.
44///
45/// Bundles the `zip`, `engine`, and `ttl` parameters so that [`push_one`]
46/// and [`perform_push_under_lock`] stay within the argument-count budget.
47struct PushConfig {
48 /// Whether to upload `repo.zip` alongside each bundle.
49 zip: bool,
50 /// Storage engine to lock into the `FORMAT` key on the first push.
51 engine: StorageEngine,
52 /// Per-ref lock TTL.
53 ttl: Duration,
54 /// Backend kind of the destination. Used by the zip-artifact path to
55 /// decide whether to attach the `codepipeline-artifact-revision-summary`
56 /// user-metadata header — that header is meaningful only on S3
57 /// (AWS `CodePipeline` consumes it) and the hyphenated key is invalid on
58 /// Azure, where it would otherwise cause the entire zip upload to fail
59 /// silently under the issue #127 best-effort swallow contract.
60 /// See issue #161.
61 kind: BackendKind,
62}
63
64/// Environment override for the lock TTL, in seconds.
65pub(crate) const ENV_LOCK_TTL_SECONDS: &str = "GIT_REMOTE_OBJECT_STORE_LOCK_TTL_SECONDS";
66
67/// Stable substring embedded in the rejection message returned when the
68/// remote ref is not an ancestor of the pushed local ref. Treated as a
69/// user-facing contract: shellspec suites assert on this token to
70/// distinguish the ancestor-mismatch failure mode from unrelated push
71/// failures (network, permission, malformed URL, ...). Reword the
72/// surrounding sentence freely; do not change this token without
73/// updating every call site, including the spec files under
74/// `spec/integration/*/force_push_spec.sh` and
75/// `spec/live/*/force_push_spec.sh`.
76pub(crate) const NOT_ANCESTOR_TOKEN: &str = "not ancestor";
77
78/// Build the canonical wire-format rejection message returned when a
79/// push is refused because the remote ref is not an ancestor of
80/// `local_spec`. Centralising the template here keeps the bundle and
81/// packchain engines in lockstep — they previously inlined byte-identical
82/// `format!` calls, and silent drift between them would have produced
83/// engine-dependent wire output the spec suites could not match against
84/// [`NOT_ANCESTOR_TOKEN`].
85pub(crate) fn not_ancestor_wire_message(local_spec: &str) -> String {
86 format!(r#""remote ref is {NOT_ANCESTOR_TOKEN} of {local_spec}."?"#)
87}
88
89/// Canonical wire-format message returned when a delete is refused
90/// because a `PROTECTED#` marker is present under the ref. Shared by
91/// the bundle engine ([`delete_remote_ref_under_lock`]) and the
92/// packchain engine ([`crate::packchain`]'s `delete_remote_ref_packchain`)
93/// so both surface identical bytes to git/clients. A single source of
94/// truth here avoids the duplicate-literal drift that a `const _` byte
95/// equality guard previously had to defend against.
96pub(crate) const DELETE_PROTECTION_MESSAGE: &str = r#""ref is protected. Run git-remote-object-store unprotect <url> <branch> to remove protection before deleting."?"#;
97
98/// Errors surfaced by the push path. These abort the helper — per-ref
99/// failures (multi-bundle, ancestor mismatch, lock contention, ...) are
100/// returned as [`PushOutcome::Error`] without aborting the batch.
101#[derive(Debug, thiserror::Error)]
102pub enum PushError {
103 /// `push <refspec>` line could not be parsed.
104 #[error("invalid push command {line:?}: expected `[+]<src>:<dst>`")]
105 Parse {
106 /// The offending line payload (after the `push ` prefix).
107 line: String,
108 },
109
110 /// Local rev-spec failed permissive ref-name validation.
111 #[error("invalid local ref-spec: {0:?}")]
112 InvalidLocalSpec(String),
113
114 /// Remote ref name is malformed.
115 #[error("invalid remote ref: {0}")]
116 RemoteRef(#[from] RefNameError),
117
118 /// SHA hex extracted from a stored bundle key was malformed.
119 #[error("invalid SHA in bundle key: {0}")]
120 Sha(#[from] ShaError),
121
122 /// Object-store transport / auth failure.
123 #[error("object-store error during push: {0}")]
124 Store(#[from] ObjectStoreError),
125
126 /// Local git operation failed (rev-parse, bundle, archive).
127 #[error("git error during push: {0}")]
128 Git(#[from] GitError),
129
130 /// Local I/O failure (tempdir, file read).
131 #[error("local I/O error during push: {0}")]
132 Io(#[from] std::io::Error),
133
134 /// Packchain-engine-specific failure surfaced by
135 /// [`crate::packchain::push::push_batch`]. Wrapped here so the
136 /// per-ref `error <ref>` arm in [`push_batch`] can render a
137 /// uniform wire line regardless of which engine produced the
138 /// error.
139 #[error("packchain engine error during push: {0}")]
140 Packchain(#[from] crate::packchain::PackchainError),
141}
142
143/// Result of a single push within a batch. Rendered to stdout by the REPL
144/// as either `ok <ref>\n` or `error <ref> <msg>\n`.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum PushOutcome {
147 /// Push succeeded. `remote_ref` echoes back to git so it can mark
148 /// the local ref as updated.
149 Ok {
150 /// The remote ref that was pushed (unparsed wire form).
151 remote_ref: String,
152 },
153 /// Push was rejected. `message` is the free-form reason rendered
154 /// after the ref name on the wire.
155 Error {
156 /// The remote ref the rejection applies to.
157 remote_ref: String,
158 /// Human-readable rejection reason.
159 message: String,
160 },
161}
162
163impl PushOutcome {
164 /// Format `self` as the single line emitted on stdout (terminator
165 /// included).
166 #[must_use]
167 pub(crate) fn to_protocol_line(&self) -> String {
168 match self {
169 Self::Ok { remote_ref } => format!("ok {remote_ref}\n"),
170 Self::Error {
171 remote_ref,
172 message,
173 } => format!("error {remote_ref} {message}\n"),
174 }
175 }
176}
177
178/// Parsed `push` command line.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub(crate) struct PushSpec {
181 /// `+` was present — the user requested a force push.
182 pub(crate) force: bool,
183 /// User-supplied local rev-spec. Empty means "delete the remote ref".
184 pub(crate) local_spec: String,
185 /// Strict, fully-qualified remote ref.
186 pub(crate) remote_ref: RefName,
187}
188
189/// Parse the payload of a `push <refspec>` line (the bytes after the
190/// `push ` prefix have already been stripped by the REPL).
191pub(crate) fn parse_push_args(args: &str) -> Result<PushSpec, PushError> {
192 let parse_err = || PushError::Parse {
193 line: args.to_owned(),
194 };
195 if args.is_empty() || args.contains(' ') {
196 return Err(parse_err());
197 }
198 let (local, remote) = args.split_once(':').ok_or_else(parse_err)?;
199 if remote.is_empty() {
200 return Err(parse_err());
201 }
202 let (force, local) = match local.strip_prefix('+') {
203 Some(rest) => (true, rest),
204 None => (false, local),
205 };
206 if !local.is_empty() && !is_valid_ref_name(local) {
207 return Err(PushError::InvalidLocalSpec(local.to_owned()));
208 }
209 let remote_ref = RefName::new(remote)?;
210 Ok(PushSpec {
211 force,
212 local_spec: local.to_owned(),
213 remote_ref,
214 })
215}
216
217/// Build the `<prefix>/<ref>/` listing prefix used by lock and bundle
218/// listings. Empty / absent prefix collapses to a bare `<ref>/`.
219///
220/// Thin typed wrapper over [`keys::ref_listing_prefix`] that takes the
221/// validated `RefName` newtype so helper-protocol call sites can't pass
222/// an unvalidated string. The shared helper is the single allocation
223/// point — `manage::doctor` and `manage::branch` reach it directly with
224/// already-validated `&str` ref paths.
225pub(crate) fn ref_listing_prefix(prefix: Option<&str>, remote_ref: &RefName) -> String {
226 keys::ref_listing_prefix(prefix, remote_ref.as_str())
227}
228
229/// Build the lock key: `<prefix>/<ref>/LOCK#.lock`.
230pub(crate) fn lock_key(prefix: Option<&str>, remote_ref: &RefName) -> String {
231 format!("{}LOCK#.lock", ref_listing_prefix(prefix, remote_ref))
232}
233
234/// Build the zip-archive key: `<prefix>/<ref>/repo.zip`.
235fn archive_key(prefix: Option<&str>, remote_ref: &RefName) -> String {
236 format!("{}repo.zip", ref_listing_prefix(prefix, remote_ref))
237}
238
239/// Build the HEAD key: `<prefix>/HEAD` (no slash when prefix is absent).
240pub(crate) fn head_key(prefix: Option<&str>) -> String {
241 keys::join(prefix, "HEAD")
242}
243
244/// Bundle-candidate filter: positive predicate — the final path segment
245/// must be `<sha>.bundle` (40 lower-hex chars + `.bundle`). All other
246/// per-ref siblings (`repo.zip`, `LOCK#.lock`, `PROTECTED#`, any
247/// hypothetical future artefact) are rejected by construction.
248///
249/// Earlier revisions filtered by full-key substring (`.zip`, `/LOCKS/`,
250/// `.lock`), which false-positively dropped real bundle keys whose ref
251/// name happened to contain those substrings — e.g.
252/// `refs/heads/v1.zip-rc1/<sha>.bundle` or
253/// `refs/heads/LOCKS-feature/x/<sha>.bundle`. A positive check on the
254/// final segment cannot misfire on ref-name content.
255fn is_bundle_candidate(key: &str) -> bool {
256 parse_remote_sha_from_key(key).is_some()
257}
258
259/// Returns every bundle object currently stored under `remote_ref`,
260/// filtered by [`is_bundle_candidate`] and excluding bundles named by
261/// any `<prefix>/gc/baseline-tomb-*.json` (issue #157). The store's
262/// listing prefix is `<prefix>/<ref>/` so sibling-ref keys don't leak
263/// in.
264///
265/// Tombstoned bundles are filtered here because the bundle engine
266/// uses the bundle-key listing as its source of truth for "ref →
267/// current SHA". A force-push that deferred the prior bundle leaves
268/// two `<sha>.bundle` keys under the ref; without the tombstone
269/// filter the next push's under-lock multi-bundle guard would refuse
270/// it. The tombstoned bundle stays readable at its original key for
271/// in-flight fetchers — only the listing path hides it.
272///
273/// Issue #165: `cached_hidden` lets the caller supply a tombstone set
274/// computed earlier in the same push (e.g. by [`prepare_push`]) and
275/// reused under the per-ref lock by [`perform_push_under_lock`]. The
276/// cached set is sound because all tombstone writers for a given ref
277/// (push's `defer_prior_bundle_via_tombstone`, compact's
278/// `tombstone_prior_baseline_bundle`, delete-branch's orphan path)
279/// run under the same per-ref lock — so no new tombstone for this
280/// ref can land between the pre-lock and under-lock calls within one
281/// `push_one` invocation. `None` keeps the original "fetch on demand"
282/// shape used by call sites that don't have a cache to share.
283async fn bundles_for_ref(
284 store: &dyn ObjectStore,
285 prefix: Option<&str>,
286 remote_ref: &RefName,
287 cached_hidden: Option<&HashSet<String>>,
288) -> Result<Vec<ObjectMeta>, ObjectStoreError> {
289 let listing = ref_listing_prefix(prefix, remote_ref);
290 let metas = store.list(&listing).await?;
291 let bundles: Vec<ObjectMeta> = metas
292 .into_iter()
293 .filter(|m| is_bundle_candidate(&m.key))
294 .collect();
295 // Short-circuit the tombstone lookup when there is nothing that
296 // could be filtered. The common case (no force-push in the last
297 // grace window) pays no extra listing cost.
298 if bundles.is_empty() {
299 return Ok(bundles);
300 }
301 // Reuse the caller-supplied set when present; otherwise fetch
302 // on demand. The owned binding outlives the borrow so a single
303 // `&HashSet` covers both paths without an `Option`/`expect` dance.
304 let fetched;
305 let hidden: &HashSet<String> = if let Some(h) = cached_hidden {
306 h
307 } else {
308 fetched = tombstoned_bundle_keys(store, prefix).await?;
309 &fetched
310 };
311 Ok(bundles
312 .into_iter()
313 .filter(|m| !hidden.contains(&m.key))
314 .collect())
315}
316
317/// Returns `true` iff a `<prefix>/<ref>/PROTECTED#` marker exists.
318///
319/// Uses an exact-key `head` rather than a prefix `list`. `ObjectStore::list`
320/// is a byte-prefix match, so a `list("…/PROTECTED#")` would also return any
321/// future `PROTECTED#`-prefixed sibling key (e.g. `PROTECTED#audit`). The
322/// equality check here matches the semantics of the canonical
323/// `is_protected_marker_segment` snapshot-side helper and avoids
324/// spuriously blocking pushes or deletes on unprotected refs.
325pub(crate) async fn is_protected(
326 store: &dyn ObjectStore,
327 prefix: Option<&str>,
328 remote_ref: &RefName,
329) -> Result<bool, ObjectStoreError> {
330 let key = protected_marker_key(prefix, remote_ref);
331 match store.head(&key).await {
332 Ok(_) => Ok(true),
333 Err(ObjectStoreError::NotFound(_)) => Ok(false),
334 Err(e) => Err(e),
335 }
336}
337
338/// Build the `<prefix>/<ref>/PROTECTED#` key. Pulled out so the
339/// pre-delete protection probe ([`is_protected`]) and the post-sweep
340/// integrity verification ([`verify_no_orphan_protected_after_delete`])
341/// share one source of truth for the marker key shape.
342pub(crate) fn protected_marker_key(prefix: Option<&str>, remote_ref: &RefName) -> String {
343 format!(
344 "{}{}",
345 ref_listing_prefix(prefix, remote_ref),
346 keys::PROTECTED_MARKER_SEGMENT,
347 )
348}
349
350/// Issue #151 defence-in-depth: after a delete-path sweep completes,
351/// confirm no `PROTECTED#` marker exists for `remote_ref`. The primary
352/// defence is the per-ref lock (#158, #159): every delete path acquires
353/// `<prefix>/<ref>/LOCK#.lock` before its under-lock listing and the
354/// sweep, and both `protect` and `unprotect` acquire the same key —
355/// so a marker landing between the under-lock listing and the sweep is
356/// mechanically impossible.
357///
358/// This helper is the belt to the lock's suspenders: if the marker is
359/// observed here, the contract was violated (a lock bypass, bucket-level
360/// inconsistency, or a misbehaving sibling tool), and we surface it as
361/// a structured `tracing::error!` so operators can investigate. The
362/// delete is NOT rolled back — the operator-visible "ref is gone"
363/// outcome stands; the orphan marker, if real, will block any future
364/// recreation of the same ref until `unprotect` removes it.
365///
366/// Uses one `head` (not a `list`) — cheap, exact-key, identical shape
367/// to [`is_protected`]. The caller passes the same `prefix` / `remote_ref`
368/// it used to compute the listing prefix so the marker key matches the
369/// sweep's namespace byte-for-byte.
370pub(crate) async fn verify_no_orphan_protected_after_delete(
371 store: &dyn ObjectStore,
372 prefix: Option<&str>,
373 remote_ref: &RefName,
374) {
375 let key = protected_marker_key(prefix, remote_ref);
376 match store.head(&key).await {
377 Ok(_) => {
378 tracing::error!(
379 key = %key,
380 remote_ref = %remote_ref.as_str(),
381 "delete path observed a PROTECTED# marker after sweep; the per-ref lock contract (#158, #159) should make this impossible — investigate for lock bypass or bucket-level inconsistency",
382 );
383 }
384 Err(ObjectStoreError::NotFound(_)) => {}
385 Err(e) => {
386 // The probe itself failed; this is not the integrity
387 // violation we are guarding against. Log at `debug!` so
388 // operators have the trail if a future incident needs it,
389 // but do not promote a transient `head` failure to an
390 // error — the delete already succeeded under the lock.
391 tracing::debug!(
392 key = %key,
393 error = %e,
394 "post-sweep PROTECTED# probe failed; cannot verify orphan-marker invariant for this delete",
395 );
396 }
397 }
398}
399
400/// Extract the SHA from a `<…>/<sha>.bundle` key. Returns `None` if the
401/// trailing segment does not match `[0-9a-f]{40}\.bundle`.
402///
403/// Charset/length validation goes through [`keys::is_valid_bundle_stem`]
404/// so the doctor's malformed-key detector and this parser agree on
405/// what counts as a well-formed stem. The defensive parse-error arm in
406/// [`prepare_push`] still calls this — although push's pre-lock listing
407/// filters malformed stems via [`is_bundle_candidate`] before reaching
408/// the arm, the guard remains in place so a future caller that bypasses
409/// the filter cannot silently treat a malformed key as a bundle.
410fn parse_remote_sha_from_key(key: &str) -> Option<Sha> {
411 let stem = parse_remote_sha_stem_from_key(key)?;
412 Sha::from_hex(stem).ok()
413}
414
415/// Extract the validated 40-lowercase-hex stem from a `<…>/<sha>.bundle`
416/// key without the `&str → Sha → String` round-trip
417/// [`parse_remote_sha_from_key`] incurs. Callers that need the schema
418/// [`Sha40`] (e.g. the tombstone writers) save one hex-encode + one
419/// re-validation by going through the borrowed stem directly.
420fn parse_remote_sha_stem_from_key(key: &str) -> Option<&str> {
421 let last = key.rsplit('/').next()?;
422 let stem = last.strip_suffix(".bundle")?;
423 if !keys::is_valid_bundle_stem(stem) {
424 return None;
425 }
426 Some(stem)
427}
428
429/// Read the lock TTL from `GIT_REMOTE_OBJECT_STORE_LOCK_TTL_SECONDS`,
430/// falling back to [`DEFAULT_LOCK_TTL_SECONDS`] if the env var is unset,
431/// unparseable, or zero. A zero TTL would make `acquire_lock` treat
432/// every held lock as instantly stale and defeat per-ref locking, so
433/// we mirror [`crate::packchain::gc::grace_hours_from_env`] and clamp
434/// it to the default.
435pub(crate) fn lock_ttl_from_env() -> Duration {
436 // Test-only: the `cfg`-gated block compiles out of release
437 // binaries (the `test-util` feature is dev-only and `cfg(test)`
438 // is set only when the crate itself is under test), so production
439 // callers pay nothing. In test builds, the read lock serialises
440 // against env-mutating tests that hold an `EnvGuard` for this
441 // key; `env_var_read_lock` returns `None` when the current thread
442 // itself holds the writer, so the env-mutating test does not
443 // deadlock on its own write lock.
444 #[cfg(any(test, feature = "test-util"))]
445 let _read = crate::test_util::env_var_read_lock(ENV_LOCK_TTL_SECONDS);
446 let secs = env::var(ENV_LOCK_TTL_SECONDS)
447 .ok()
448 .and_then(|s| s.parse::<u64>().ok())
449 .filter(|s| *s > 0)
450 .unwrap_or(DEFAULT_LOCK_TTL_SECONDS);
451 saturating_duration_seconds(secs)
452}
453
454/// Convert a `u64` seconds count to [`Duration`], saturating at
455/// [`i64::MAX`] (~292-billion-year sentinel TTL ceiling). Centralises
456/// the `i64::try_from(secs).unwrap_or(i64::MAX)` idiom that previously
457/// lived inline in both [`lock_ttl_from_env`] and
458/// [`crate::manage::compact::Compact::run_into`] (#221).
459#[must_use]
460pub(crate) fn saturating_duration_seconds(secs: u64) -> Duration {
461 Duration::seconds(i64::try_from(secs).unwrap_or(i64::MAX))
462}
463
464/// Same as [`lock_ttl_from_env`] but returns the value as `u64` seconds
465/// for callers that want the raw count rather than a `Duration`. The
466/// `expect` surfaces a future regression that loosens `lock_ttl_from_env`'s
467/// non-negative invariant instead of silently masking it.
468pub(crate) fn lock_ttl_from_env_seconds() -> u64 {
469 u64::try_from(lock_ttl_from_env().whole_seconds())
470 .expect("lock_ttl_from_env returns a non-negative seconds count")
471}
472
473/// Resolve a caller-supplied `Option<u64>` lock TTL to a concrete
474/// seconds value, applying the same zero-clamp as [`lock_ttl_from_env`].
475///
476/// Without this helper, `Compact::run_into` would accept a raw
477/// `Option<u64>` and feed `Some(0)` straight into the engine,
478/// bypassing the #112 clamp that protects against `acquire_lock`
479/// treating every held lock as instantly stale. A zero TTL defeats
480/// per-ref locking and corrupts concurrent pushes (issue #208).
481/// `Doctor::resolved_lock_ttl_seconds` deliberately does NOT route
482/// through this helper — doctor only compares lock ages and never
483/// acquires a lock, so an operator-explicit `--lock-ttl-seconds 0`
484/// is honoured as a "treat every lock as stale" request.
485///
486/// Resolution rules:
487///
488/// * `None` — defer to [`lock_ttl_from_env_seconds`] (env override or default).
489/// * `Some(0)` — defer to [`lock_ttl_from_env_seconds`] as well, so
490/// that an accidental zero from a CLI flag or default-constructed
491/// opts still picks up the operator's env override. This matches
492/// the shape of the existing env-side clamp.
493/// * `Some(n)` for `n > 0` — return `n` unchanged.
494///
495/// No upper bound is enforced: the existing env path accepts any
496/// `u64` and downstream `time::Duration::seconds` saturates at
497/// `i64::MAX` (~292 billion years), which is fine for a TTL ceiling.
498pub(crate) fn resolve_lock_ttl_seconds(opt: Option<u64>) -> u64 {
499 opt.filter(|&n| n > 0)
500 .unwrap_or_else(lock_ttl_from_env_seconds)
501}
502
503/// Handle to an acquired per-ref lock with a live heartbeat task.
504///
505/// Returned by [`acquire_lock`]. The guard owns a background tokio task
506/// that periodically re-PUTs the lock key (overwrite, not conditional)
507/// to refresh `last_modified` and keep stale-lock recovery from
508/// stealing a still-live lock from under a long-running critical
509/// section (issue #118).
510///
511/// ## Release
512///
513/// Call [`release_lock`] (or [`Self::release`]) to relinquish: this
514/// signals cooperative shutdown to the heartbeat, awaits its exit so
515/// any in-flight heartbeat PUT completes (or errors) on the server
516/// BEFORE the DELETE is issued, then deletes the lock key. Without
517/// the await-then-delete ordering, an in-flight heartbeat PUT could
518/// settle on the server after the DELETE and resurrect the lock key
519/// as an orphan (issue #150).
520///
521/// ## Drop
522///
523/// If a guard is dropped without an explicit release (panic, early
524/// return without the post-result match, etc.) the heartbeat is told
525/// to stop via the same shutdown signal and the join handle is
526/// aborted (we cannot `.await` in `Drop`). The lock key remains on
527/// the bucket and will be picked up by the next acquire attempt's
528/// stale-recovery path after `ttl` elapses (bounded by
529/// `ttl + heartbeat_interval` since the last heartbeat). Callers
530/// should still prefer explicit release so the lock is freed
531/// immediately.
532#[must_use = "lock guards must be released; dropping leaks the lock until TTL"]
533pub(crate) struct LockGuard {
534 /// Bucket key of the lock. `pub(crate)` so call sites can format
535 /// it into error messages (the old API surfaced `&str`).
536 lock_key: String,
537 /// Store handle, kept around so [`Self::release`] can delete the
538 /// key without callers re-passing it. Also given to the heartbeat
539 /// task via clone.
540 store: Arc<dyn ObjectStore>,
541 /// Cooperative shutdown signal. The heartbeat task watches this
542 /// receiver via [`tokio::select!`]: a `send(true)` wakes the loop
543 /// at any await point inside the `select!` (between ticks) AND is
544 /// checked synchronously before issuing each `put_bytes`, so no
545 /// PUT can be dispatched after [`Self::release`] has begun.
546 shutdown: watch::Sender<bool>,
547 /// Heartbeat task handle. `Some` while the guard owns a live
548 /// heartbeat; taken by release (awaited) or drop (aborted as a
549 /// fallback since `Drop` cannot await).
550 heartbeat: Option<JoinHandle<()>>,
551}
552
553/// Upper bound on how long [`LockGuard::release`] waits for the
554/// heartbeat task to exit cooperatively before falling back to abort.
555///
556/// The heartbeat task wakes from its `select!` immediately on
557/// shutdown and only issues a PUT if it had already passed the
558/// shutdown re-check, so the worst-case wait is one in-flight
559/// `put_bytes` RTT. Five seconds is well above any healthy RTT but
560/// well below any reasonable user-visible push timeout — if the
561/// network is wedged hard enough to outlast it, the fallback abort
562/// keeps `release` from hanging and the orphan key just regresses to
563/// the pre-fix behaviour (stale-lock recovery after TTL).
564const HEARTBEAT_JOIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
565
566impl LockGuard {
567 /// Release the lock: cooperatively stop the heartbeat (awaiting
568 /// the task's exit so any in-flight PUT settles first), then
569 /// delete the lock key. `NotFound` is mapped to `Ok(())` (the
570 /// heartbeat may have raced a stale-recovery delete, or an
571 /// operator may have cleared the lock manually); every other
572 /// delete failure is propagated.
573 pub(crate) async fn release(mut self) -> Result<(), ObjectStoreError> {
574 // ORDER IS LOAD-BEARING: stop the heartbeat AND wait for its
575 // join handle BEFORE deleting the lock key. The previous
576 // implementation called `handle.abort()` and then DELETE
577 // synchronously, but `abort()` only cancels the future at its
578 // next await point — a `put_bytes` already in flight on the
579 // server completes regardless of cancellation and can settle
580 // AFTER our DELETE, resurrecting the lock key as an orphan
581 // (issue #150). Cooperative shutdown via `watch` + join-await
582 // makes the worst case "one in-flight PUT completes before
583 // DELETE" instead of "one in-flight PUT completes after
584 // DELETE".
585 self.stop_heartbeat().await;
586 delete_idempotent(self.store.as_ref(), &self.lock_key).await
587 }
588
589 /// Signal cooperative shutdown to the heartbeat task and await
590 /// its exit (bounded by [`HEARTBEAT_JOIN_TIMEOUT`]). On timeout
591 /// the handle is `abort()`-ed, restoring the pre-#150 behaviour
592 /// for the pathological case of a `put_bytes` hung past the
593 /// timeout.
594 async fn stop_heartbeat(&mut self) {
595 // `send` only fails when there are no receivers — i.e. the
596 // heartbeat task has already exited. Either way, there's no
597 // PUT we can still prevent, so we ignore the result.
598 let _ = self.shutdown.send(true);
599 let Some(mut handle) = self.heartbeat.take() else {
600 return;
601 };
602 // Borrow the handle into `timeout` so we can still call
603 // `abort()` on it after the wait fails.
604 if tokio::time::timeout(HEARTBEAT_JOIN_TIMEOUT, &mut handle)
605 .await
606 .is_err()
607 {
608 warn!(
609 key = %self.lock_key,
610 timeout_secs = HEARTBEAT_JOIN_TIMEOUT.as_secs(),
611 "lock heartbeat did not exit within join timeout; \
612 falling back to abort (in-flight PUT may still race \
613 the upcoming DELETE)",
614 );
615 handle.abort();
616 }
617 }
618}
619
620impl Drop for LockGuard {
621 fn drop(&mut self) {
622 // `Drop` cannot `.await`, so we cannot mirror the release
623 // path's "wait for in-flight PUT to settle" guarantee here.
624 // The best we can do is (a) signal shutdown so the heartbeat
625 // task exits at its next await point, and (b) abort the
626 // JoinHandle so a future PUT issued while we were dropping
627 // doesn't keep racing forever. The lock key may briefly
628 // outlive the holder; the stale-lock recovery path
629 // (`acquire_lock`) reclaims it after TTL.
630 let _ = self.shutdown.send(true);
631 if let Some(handle) = self.heartbeat.take() {
632 handle.abort();
633 }
634 }
635}
636
637/// Heartbeat interval: `ttl/3`, floored at one second so a pathological
638/// sub-second TTL doesn't busy-loop. We use `ttl/3` rather than `ttl/2`
639/// so a single missed heartbeat (transient network blip with
640/// `MissedTickBehavior::Delay`) still leaves margin before `age > ttl`:
641/// two consecutive misses are needed to push the lock past the
642/// staleness threshold (#118 follow-up).
643pub(crate) fn heartbeat_interval(ttl: Duration) -> std::time::Duration {
644 let secs = ttl.whole_seconds().max(3) / 3;
645 // `try_from` rather than `as`: we just clamped to ≥ 1, but
646 // `clippy::cast_sign_loss` insists on the explicit fallible cast.
647 let secs_u64 =
648 u64::try_from(secs).expect("ttl.whole_seconds().max(3) / 3 is always >= 1 (non-negative)");
649 std::time::Duration::from_secs(secs_u64)
650}
651
652/// Try to acquire the per-ref lock. Returns `Ok(Some(guard))` when the
653/// lock was taken (heartbeat is already running) and `Ok(None)` on
654/// contention (caller should surface a "lock held" error). On a stale
655/// lock (older than `ttl`, with no heartbeat from a live holder), the
656/// lock is deleted and the conditional `put_if_absent` is retried
657/// once.
658///
659/// The race window between `head` and the retry `put_if_absent` is
660/// inherent to non-conditional deletes — another client could acquire
661/// the lock between our delete and retry. We accept that race; the
662/// retry `put_if_absent` will return `Ok(false)` and the user will
663/// retry.
664///
665/// Heartbeat semantics (issue #118): a live holder refreshes the lock
666/// every `ttl/3` so the staleness check correctly excludes locks held
667/// by an in-flight critical section. A long-running [`compact`] no
668/// longer races a concurrent writer that wakes up after the original
669/// TTL elapses.
670///
671/// [`compact`]: crate::packchain::compact::compact
672pub(crate) async fn acquire_lock(
673 store: Arc<dyn ObjectStore>,
674 lock_key: &str,
675 ttl: Duration,
676 now: OffsetDateTime,
677) -> Result<Option<LockGuard>, ObjectStoreError> {
678 if store.put_if_absent(lock_key, Bytes::new()).await? {
679 return Ok(Some(spawn_lock_guard(store, lock_key.to_owned(), ttl)));
680 }
681 let meta = match store.head(lock_key).await {
682 Ok(m) => m,
683 // Lock vanished between put_if_absent and head — another client
684 // released it. Treat as contention; user retries.
685 Err(ObjectStoreError::NotFound(_)) => return Ok(None),
686 Err(e) => return Err(e),
687 };
688 let age = now - meta.last_modified;
689 if age <= ttl {
690 return Ok(None);
691 }
692 debug!(key = %lock_key, age_secs = age.whole_seconds(), "deleting stale lock");
693 delete_idempotent(store.as_ref(), lock_key).await?;
694 if store.put_if_absent(lock_key, Bytes::new()).await? {
695 Ok(Some(spawn_lock_guard(store, lock_key.to_owned(), ttl)))
696 } else {
697 Ok(None)
698 }
699}
700
701/// Build a [`LockGuard`] and spawn its heartbeat task. The heartbeat
702/// re-PUTs the lock key every `heartbeat_interval(ttl)` (overwrite, not
703/// conditional) so the stale-lock recovery branch in [`acquire_lock`]
704/// correctly excludes still-held locks. Heartbeat failures are logged
705/// at `warn` and retried on the next tick — a transient blip should
706/// not surface as a critical-section-aborting error.
707///
708/// The task watches a [`watch::Receiver<bool>`]: `release` flips it to
709/// `true` and the loop exits at the next `select!` await point.
710/// Critically, the loop also re-checks the flag synchronously after
711/// waking from the tick and BEFORE issuing `put_bytes`, so once
712/// `release` has signalled shutdown no further PUT can be dispatched
713/// — closing the issue #150 race where an `abort()`-cancelled task
714/// had an in-flight `put_bytes` that settled on the server after
715/// `release`'s DELETE.
716fn spawn_lock_guard(store: Arc<dyn ObjectStore>, lock_key: String, ttl: Duration) -> LockGuard {
717 let interval = heartbeat_interval(ttl);
718 let task_store = Arc::clone(&store);
719 let task_key = lock_key.clone();
720 let (shutdown_tx, mut shutdown_rx) = watch::channel(false);
721 let handle = tokio::spawn(async move {
722 let mut tick = tokio::time::interval(interval);
723 // Skip the immediate first tick — the acquire just wrote the
724 // key, so a refresh in the same millisecond would be wasted
725 // bandwidth.
726 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
727 tick.tick().await; // immediate
728 loop {
729 tokio::select! {
730 // `biased` so shutdown is checked first on every
731 // wake: a tick and a shutdown that fire on the same
732 // poll must resolve to shutdown, otherwise the tick
733 // arm could win and issue one final PUT after release
734 // has begun.
735 biased;
736 _ = shutdown_rx.changed() => break,
737 _ = tick.tick() => {}
738 }
739 // Re-check the shutdown flag synchronously between the
740 // tick and the `put_bytes` await. Without this, a
741 // `release` that signals shutdown AFTER `tick.tick()`
742 // resolved but BEFORE we issued `put_bytes` would still
743 // let the PUT through and race the DELETE — exactly the
744 // issue #150 failure mode the `select!` alone does not
745 // close.
746 if *shutdown_rx.borrow() {
747 break;
748 }
749 match task_store
750 .put_bytes(&task_key, Bytes::new(), PutOpts::default())
751 .await
752 {
753 Ok(()) => debug!(key = %task_key, "lock heartbeat refreshed"),
754 Err(e) => warn!(
755 key = %task_key,
756 error = %e,
757 "lock heartbeat refresh failed; will retry",
758 ),
759 }
760 }
761 });
762 LockGuard {
763 lock_key,
764 store,
765 shutdown: shutdown_tx,
766 heartbeat: Some(handle),
767 }
768}
769
770/// Release a previously acquired per-ref lock. `NotFound` is mapped to
771/// `Ok(())` (another client or the TTL may have already cleaned it up);
772/// every other delete failure is propagated so the caller can surface it.
773///
774/// Heartbeat semantics: the guard's heartbeat task is aborted before
775/// the delete so a heartbeat refresh in flight cannot re-create the
776/// key after we've removed it.
777pub(crate) async fn release_lock(guard: LockGuard) -> Result<(), ObjectStoreError> {
778 guard.release().await
779}
780
781/// Idempotent delete: treats `NotFound` as success (another client may
782/// have raced ahead) but propagates every other error.
783pub(crate) async fn delete_idempotent(
784 store: &dyn ObjectStore,
785 key: &str,
786) -> Result<(), ObjectStoreError> {
787 match store.delete(key).await {
788 Ok(()) | Err(ObjectStoreError::NotFound(_)) => Ok(()),
789 Err(e) => Err(e),
790 }
791}
792
793/// Best-effort delete of the prior bundle once the new bundle is durable.
794///
795/// Issue #121: `perform_push_under_lock` writes the new bundle first
796/// (the durable commit) and then deletes the previous bundle. A
797/// non-`NotFound` error on the delete must NOT fail the push — the new
798/// bundle is already on the bucket, so reporting failure to the user
799/// is a lie about the remote state. The two-bundle state that results
800/// from the orphan trips the under-lock "multiple bundles" guard on
801/// the next push, which directs the operator to `doctor`; the warn
802/// log gives them the orphan key directly.
803///
804/// Mirrors `force_push_baseline_cleanup` in `packchain::push`
805/// (issue #113).
806///
807/// Issue #157: this is now the fallback path used only when the
808/// preferred deferred-via-tombstone path
809/// ([`defer_prior_bundle_via_tombstone`]) cannot be taken — namely a
810/// prior key whose stem does not parse as a [`Sha40`]. The on-bucket
811/// layout filter ([`is_bundle_candidate`]) already rejects malformed
812/// stems before they reach the push path, so this branch is reachable
813/// only via a future schema loosening or manual bucket tampering.
814async fn delete_prior_bundle_best_effort(
815 store: &dyn ObjectStore,
816 remote_ref: &RefName,
817 prior_key: &str,
818) {
819 if let Err(e) = delete_idempotent(store, prior_key).await {
820 warn!(
821 ref_path = %remote_ref.as_str(),
822 key = %prior_key,
823 error = %e,
824 "prior-bundle cleanup failed (new bundle already committed); \
825 orphan key left for manual cleanup",
826 );
827 }
828}
829
830/// Defer the prior-bundle delete to `gc sweep` by writing a baseline
831/// tombstone naming the old SHA.
832///
833/// Issue #157: synchronously deleting the prior bundle from the push
834/// path created a race against in-flight fetches. A client that ran
835/// `list` and saw `refs/heads/main -> old_sha` then issued
836/// `fetch old_sha refs/heads/main` against the EXACT
837/// `<prefix>/<ref>/<old_sha>.bundle` path — [`fetch_one`] does not
838/// re-list or consult HEAD. If a concurrent force-push synchronously
839/// deleted that key between the `list` advertisement and the fetcher's
840/// GET, fetch failed with `NotFound`.
841///
842/// The fix mirrors the packchain force-push tombstone path (issue
843/// #134, `force_push_baseline_cleanup` in `packchain::push`): write a
844/// `<prefix>/gc/baseline-tomb-*.json` record naming the prior SHA, and
845/// let `gc sweep` reclaim the bundle after the configured grace
846/// window. Until grace expires the prior bundle remains readable, so
847/// any fetcher that advertised it from a stale list can still
848/// complete.
849///
850/// The tombstone format and key namespace are shared with the
851/// packchain engine because `gc sweep` is engine-agnostic at the
852/// reclamation layer: [`sweep_one_baseline_tombstone`] derives the
853/// bundle key from `(ref_name, sha)` and runs the same live-state
854/// recheck regardless of which engine wrote the tombstone.
855///
856/// **Fallback semantics on tombstone write failure**: `gc sweep` only
857/// reclaims keys it has a tombstone for, so a tombstone PUT failure
858/// would otherwise orphan the prior bundle indefinitely. To preserve
859/// the issue #121 "two-bundle state surfaces via the doctor message"
860/// recovery path, this helper falls back to the synchronous
861/// best-effort delete from [`delete_prior_bundle_best_effort`] on
862/// either:
863///
864/// - a prior key whose stem does not parse as a [`Sha40`] (no way to
865/// name it in the tombstone body), or
866/// - a [`write_baseline_tombstone_best_effort`] PUT failure (the
867/// helper already logged a warn naming the orphan).
868async fn defer_prior_bundle_via_tombstone(
869 store: &dyn ObjectStore,
870 prefix: Option<&str>,
871 remote_ref: &RefName,
872 prior_key: &str,
873 local_sha: Sha,
874) {
875 let Some(prior_stem) = parse_remote_sha_stem_from_key(prior_key) else {
876 warn!(
877 ref_path = %remote_ref.as_str(),
878 key = %prior_key,
879 "prior bundle key does not parse as a valid SHA; falling back to synchronous delete",
880 );
881 delete_prior_bundle_best_effort(store, remote_ref, prior_key).await;
882 return;
883 };
884 // `parse_remote_sha_stem_from_key` already validated the stem
885 // against `is_valid_bundle_stem` (40 lowercase hex), so
886 // `Sha40::try_new(prior_stem)` is infallible in practice; surface
887 // a failure as the synchronous-delete fallback rather than
888 // panicking — a future tightening of `Sha40` validation must not
889 // crash the push. `local_sha` still routes through `to_string`
890 // because `Sha` (gix `ObjectId`) only exposes a binary form.
891 let (Ok(prior_sha40), Ok(local_sha40)) = (
892 Sha40::try_new(prior_stem),
893 Sha40::try_new(local_sha.to_string()),
894 ) else {
895 warn!(
896 ref_path = %remote_ref.as_str(),
897 key = %prior_key,
898 "prior or local sha failed Sha40 validation; falling back to synchronous delete",
899 );
900 delete_prior_bundle_best_effort(store, remote_ref, prior_key).await;
901 return;
902 };
903 let wrote = write_baseline_tombstone_best_effort(
904 store,
905 prefix,
906 remote_ref,
907 &prior_sha40,
908 &local_sha40,
909 "bundle-engine-force-push",
910 )
911 .await;
912 if wrote {
913 debug!(
914 ref_path = %remote_ref.as_str(),
915 key = %prior_key,
916 "prior bundle deferred to gc sweep via baseline tombstone",
917 );
918 } else {
919 // `write_baseline_tombstone_best_effort` already logged a warn
920 // naming the orphan key. Fall back to the synchronous delete
921 // so the next push's multi-bundle guard does not flag this
922 // case to the operator unnecessarily.
923 delete_prior_bundle_best_effort(store, remote_ref, prior_key).await;
924 }
925}
926
927/// Best-effort upload of the optional zip artifact once the new bundle
928/// is durable.
929///
930/// Issue #127: `perform_push_under_lock` writes the new bundle, `HEAD`,
931/// and `FORMAT` (the git-protocol contract for a successful push) and
932/// then uploads an optional `repo.zip` CodePipeline-side convenience
933/// artifact. A non-`NotFound` error on the zip upload must NOT fail
934/// the push — the bundle is already durable, so reporting failure is
935/// a lie about the remote state. The next push re-puts the zip at
936/// the same key (idempotent), so an operator who notices the warn log
937/// or wants the artifact re-uploaded simply re-pushes.
938///
939/// Mirrors [`delete_prior_bundle_best_effort`] (issue #121) and
940/// `force_push_baseline_cleanup` in `packchain::push` (issue #113):
941/// log at warn with `ref_path`, `key`, and `error`, and return
942/// without propagating.
943async fn upload_zip_artifact_best_effort(
944 store: &dyn ObjectStore,
945 remote_ref: &RefName,
946 zip_dest: &str,
947 archive_path: &Path,
948 opts: PutOpts,
949) {
950 if let Err(e) = store.put_path(zip_dest, archive_path, opts).await {
951 warn!(
952 ref_path = %remote_ref.as_str(),
953 key = %zip_dest,
954 error = %e,
955 "zip artifact upload failed (bundle already committed); \
956 retry the push to re-upload the zip at the same key",
957 );
958 }
959}
960
961/// Drive a batch of `push` commands sequentially.
962///
963/// Each command is parsed, executed under its own per-ref lock, and
964/// produces one [`PushOutcome`]. Catastrophic errors (transport,
965/// malformed protocol input) abort the batch and bubble out as
966/// [`PushError`]; per-ref failures are encoded as
967/// [`PushOutcome::Error`] and the batch continues.
968pub(crate) async fn push_batch(
969 ctx: &super::BatchCtx,
970 kind: BackendKind,
971 zip: bool,
972 engine: StorageEngine,
973 cmds: Vec<String>,
974) -> Result<Vec<PushOutcome>, PushError> {
975 if cmds.is_empty() {
976 return Ok(Vec::new());
977 }
978 debug!(count = cmds.len(), "processing push batch");
979
980 let config = PushConfig {
981 zip,
982 engine,
983 ttl: lock_ttl_from_env(),
984 kind,
985 };
986 let mut outcomes = Vec::with_capacity(cmds.len());
987
988 for cmd in cmds {
989 // `parse_push_args` failures are catastrophic: a malformed `push`
990 // line means we cannot trust subsequent commands. Abort the batch.
991 let spec = parse_push_args(&cmd)?;
992 // Capture the ref name before `push_one` consumes the spec so we
993 // can still render an `error <ref> ...` line if the call fails.
994 let remote_ref_str = spec.remote_ref.as_str().to_owned();
995 let outcome = match push_one(
996 Arc::clone(&ctx.store),
997 ctx.prefix.as_deref(),
998 ctx.repo_dir.as_path(),
999 &config,
1000 OffsetDateTime::now_utc(),
1001 spec,
1002 )
1003 .await
1004 {
1005 Ok(o) => o,
1006 // Per-push operational failures (transport, local git, local I/O,
1007 // malformed remote bundle SHA) become `error <ref>` lines so the
1008 // batch can continue. Without this, a single 5xx blip in the
1009 // middle of a multi-ref push would silently drop the outcome
1010 // lines for already-completed pushes and leave git's local
1011 // ref-tracking inconsistent with the remote.
1012 Err(e)
1013 if matches!(
1014 e,
1015 PushError::Store(_) | PushError::Git(_) | PushError::Io(_) | PushError::Sha(_)
1016 ) =>
1017 {
1018 let chain = full_error_chain(&e);
1019 warn!(ref = %remote_ref_str, error = %chain, "push ref failed");
1020 PushOutcome::Error {
1021 remote_ref: remote_ref_str,
1022 message: format!(r#""{chain}"?"#),
1023 }
1024 }
1025 Err(e) => return Err(e),
1026 };
1027 outcomes.push(outcome);
1028 }
1029 Ok(outcomes)
1030}
1031
1032/// Render a [`PushError`] as a colon-separated chain so the
1033/// `error <ref>` wire line and the stderr log carry every level of
1034/// causal context.
1035///
1036/// `PushError`'s `thiserror` formats inline the immediate source via
1037/// `{0}` / `{source}`, and some of those sources (notably
1038/// [`ObjectStoreError::Network`], whose own Display embeds its boxed
1039/// inner error) themselves inline a further level — so a naive
1040/// chain-walk would produce `"object-store error during push: network
1041/// error: dns failure: dns failure"`. [`super::append_source_chain`]
1042/// dedups any level whose text is already at the tail of `msg`, so
1043/// the rendered chain is uniform across `Store`, `Git`, `Io`, and `Sha`
1044/// variants without per-variant special-casing.
1045fn full_error_chain(err: &PushError) -> String {
1046 let mut msg = err.to_string();
1047 super::append_source_chain(&mut msg, err);
1048 msg
1049}
1050
1051/// Recoverable per-push errors discovered while talking to the local
1052/// repo. Mapped by the caller into [`PushOutcome::Error`] strings.
1053enum GitProbeError {
1054 /// `local_spec` did not resolve in the local repo.
1055 LocalRefNotFound,
1056 /// Pre-existing remote bundle is not an ancestor of `local_sha`.
1057 NotAncestor,
1058}
1059
1060/// Local git work that must run synchronously because `gix::Repository`
1061/// is `!Sync` and cannot cross `.await` points without making the
1062/// surrounding future `!Send`.
1063struct LocalGit {
1064 /// Resolved commit OID for the user's `local_spec`.
1065 local_sha: Sha,
1066 /// Working directory passed to `bundle::create`.
1067 cwd: PathBuf,
1068 /// On the zip path: archive on disk + metadata for the upload.
1069 /// `None` on the regular push path. The `TempDir` keeps the file
1070 /// alive until the async caller reads its bytes.
1071 zip_artifacts: Option<ZipArtifacts>,
1072 /// Whether the pre-lock-listed remote bundle's SHA was an ancestor
1073 /// of `local_sha`. `true` when there was no pre-existing bundle.
1074 /// Stashed so [`perform_push_under_lock`] can decide, under the
1075 /// per-ref lock, whether a force-push against a now-`PROTECTED#`
1076 /// ref is a safe fast-forward (issue #129).
1077 pre_existing_was_ancestor: bool,
1078}
1079
1080struct ZipArtifacts {
1081 archive_path: PathBuf,
1082 short_sha: String,
1083 commit_msg: String,
1084 /// Owned tempdir that backs `archive_path`; dropped after upload.
1085 _tempdir: tempfile::TempDir,
1086}
1087
1088/// All state computed before the per-ref lock is acquired.
1089///
1090/// Passed to [`perform_push_under_lock`] so the argument count stays within
1091/// the clippy budget and the two phases of [`push_one`] have a clean
1092/// boundary: pre-lock work (protect check, bundle listing, local git,
1093/// bundle creation) vs. under-lock work (re-list, upload, HEAD/FORMAT
1094/// init, old-bundle deletion).
1095struct PushReadyState {
1096 remote_ref: RefName,
1097 local_sha: Sha,
1098 /// Key of the pre-existing bundle (if any). Checked inside the lock
1099 /// to detect concurrent pushes (stale-remote guard).
1100 pre_existing: Option<String>,
1101 bundle_path: PathBuf,
1102 zip_artifacts: Option<ZipArtifacts>,
1103 engine: StorageEngine,
1104 /// User's `--force` intent. Carried into the lock window so the
1105 /// `PROTECTED#` check can run *under* the lock and close the TOCTOU
1106 /// window between the pre-lock check and the lock acquisition
1107 /// (issue #129).
1108 force: bool,
1109 /// Whether the pre-lock-listed bundle's SHA was an ancestor of
1110 /// `local_sha` (always `true` when there was no pre-existing
1111 /// bundle). Used together with `force` and the under-lock
1112 /// protection re-check to decide whether to reject a force-push
1113 /// that became protected mid-flight.
1114 pre_existing_was_ancestor: bool,
1115 /// Original local refspec, kept so the under-lock `NotAncestor` error
1116 /// message renders the same text the pre-lock arm would have used.
1117 local_spec: String,
1118 /// Issue #165: tombstone set captured during [`prepare_push`] so the
1119 /// under-lock [`bundles_for_ref`] call inside
1120 /// [`perform_push_under_lock`] does not re-list `<prefix>/gc/` and
1121 /// re-fetch every baseline tombstone. Sound because all tombstone
1122 /// writers for this ref serialize through the same per-ref lock —
1123 /// no new tombstone for this ref can land in the pre-lock /
1124 /// under-lock window.
1125 hidden_bundles: HashSet<String>,
1126 /// Keeps the temp directory (and `bundle_path`) alive until the bundle
1127 /// is uploaded inside `perform_push_under_lock`.
1128 _temp_dir: tempfile::TempDir,
1129}
1130
1131/// Outcome of [`prepare_push`]: one of three paths into [`push_one`]'s
1132/// lock window.
1133///
1134/// - [`PrepareOutcome::Ready`] — pre-lock work completed; caller must
1135/// acquire the per-ref lock and call [`perform_push_under_lock`].
1136/// - [`PrepareOutcome::Delete`] — delete refspec (`:<ref>`); caller must
1137/// acquire the per-ref lock and call [`delete_remote_ref_under_lock`].
1138/// Issue #133: the listing and sweep MUST run under the lock so a
1139/// concurrent push that lands a new bundle cannot turn the delete
1140/// into a silent false success.
1141/// - [`PrepareOutcome::Done`] — outcome already decided pre-lock
1142/// (multi-bundle corruption, ancestry failure, …); caller returns it
1143/// directly without taking the lock.
1144enum PrepareOutcome {
1145 // `PushReadyState` is the largest variant (paths, temp dir guard,
1146 // hidden-bundle hash set added in #165); boxing keeps the enum's
1147 // discriminant compact regardless of variant.
1148 Ready(Box<PushReadyState>),
1149 Delete { remote_ref: RefName },
1150 Done(PushOutcome),
1151}
1152
1153/// Open the repo, resolve `local_sha`, compute ancestry of the
1154/// pre-existing remote bundle, and (for the zip variant) build the
1155/// archive synchronously. The `Repository` handle is dropped before
1156/// this returns so the caller's `Future` can stay `Send`. Archive
1157/// bytes are NOT read here — the async caller does that with
1158/// `tokio::fs::read` to avoid blocking the runtime on file I/O.
1159///
1160/// Ancestry is always computed and returned via
1161/// [`LocalGit::pre_existing_was_ancestor`]; this function only emits
1162/// [`GitProbeError::NotAncestor`] for the non-force case. Force pushes
1163/// defer the ancestry-vs-protection decision to
1164/// [`perform_push_under_lock`] so a concurrent `protect` cannot race
1165/// the pre-lock check (issue #129).
1166fn local_git_work(
1167 repo_dir: &Path,
1168 local_spec: &str,
1169 pre_existing_sha: Option<Sha>,
1170 force_push: bool,
1171 zip: bool,
1172) -> Result<Result<LocalGit, GitProbeError>, GitError> {
1173 let repo = gix::open(repo_dir)?;
1174 let cwd = repo.workdir().unwrap_or_else(|| repo.git_dir()).to_owned();
1175
1176 let Ok(local_sha) = git::branch::resolve(&repo, local_spec) else {
1177 return Ok(Err(GitProbeError::LocalRefNotFound));
1178 };
1179
1180 let pre_existing_was_ancestor = match pre_existing_sha {
1181 None => true,
1182 Some(remote_sha) => git::is_ancestor(&repo, remote_sha, local_sha)?,
1183 };
1184 if !force_push && !pre_existing_was_ancestor {
1185 return Ok(Err(GitProbeError::NotAncestor));
1186 }
1187
1188 let zip_artifacts = if zip {
1189 let tempdir = tempfile::Builder::new()
1190 .prefix("git_remote_object_store_archive_")
1191 .tempdir()?;
1192 let archive_path = git::archive(&repo, tempdir.path(), local_spec)?;
1193 let commit_msg = git::last_commit_message(&repo).unwrap_or_default();
1194 let sha_hex = local_sha.to_string();
1195 let short_sha = sha_hex[..8].to_owned();
1196 Some(ZipArtifacts {
1197 archive_path,
1198 short_sha,
1199 commit_msg,
1200 _tempdir: tempdir,
1201 })
1202 } else {
1203 None
1204 };
1205
1206 drop(repo);
1207 Ok(Ok(LocalGit {
1208 local_sha,
1209 cwd,
1210 zip_artifacts,
1211 pre_existing_was_ancestor,
1212 }))
1213}
1214
1215/// Perform all pre-lock work for a push: resolve the local ref, check
1216/// ancestry, list the remote bundles, and create the local bundle file.
1217/// Returns [`PrepareOutcome::Done`] for cases that are already resolved
1218/// (delete-refspec, protection check, multiple-bundle error, ancestry
1219/// failure) or [`PrepareOutcome::Ready`] when the caller should proceed
1220/// to acquire the per-ref lock and call [`perform_push_under_lock`].
1221async fn prepare_push(
1222 store: &dyn ObjectStore,
1223 prefix: Option<&str>,
1224 repo_dir: &Path,
1225 config: &PushConfig,
1226 spec: PushSpec,
1227) -> Result<PrepareOutcome, PushError> {
1228 let PushSpec {
1229 force,
1230 local_spec,
1231 remote_ref,
1232 } = spec;
1233 let remote_ref_str = remote_ref.as_str().to_owned();
1234
1235 if local_spec.is_empty() {
1236 // Issue #133: do NOT list / sweep here. The bundle engine's
1237 // delete must run INSIDE the per-ref lock so a concurrent push
1238 // landing a new bundle between our listing and our deletion
1239 // cannot produce a silent false success. Defer to
1240 // [`delete_remote_ref_under_lock`] in [`push_one`].
1241 return Ok(PrepareOutcome::Delete { remote_ref });
1242 }
1243
1244 // Issue #129: do NOT call `is_protected` here. A pre-lock check
1245 // races against a concurrent `protect`: the check could pass and a
1246 // `PROTECTED#` marker could land before we acquire the per-ref
1247 // lock, letting a force-push overwrite a now-protected ref. The
1248 // check is performed *under* the lock in
1249 // [`perform_push_under_lock`] instead. Pre-lock we just respect the
1250 // user's `--force` intent; the `local_git_work` probe still returns
1251 // ancestry info via [`LocalGit::pre_existing_was_ancestor`] so the
1252 // under-lock arm can render the same NotAncestor message a
1253 // protection-demoted non-force push would have produced.
1254 let force_push = force;
1255 debug!(local = %local_spec, remote = %remote_ref, force_push, "push");
1256
1257 // Issue #165: compute the tombstone set once here and reuse it under
1258 // the per-ref lock in [`perform_push_under_lock`]. The lock ensures
1259 // no concurrent writer can publish a tombstone for this ref between
1260 // the two `bundles_for_ref` calls. The cached set is unused on the
1261 // multi-bundle / delete / parse-error early-return paths — a wasted
1262 // listing — but each path already terminates the push, so the
1263 // ~one-call overhead is bounded.
1264 let hidden_bundles = tombstoned_bundle_keys(store, prefix).await?;
1265 let pre_bundles = bundles_for_ref(store, prefix, &remote_ref, Some(&hidden_bundles)).await?;
1266 if pre_bundles.len() > 1 {
1267 return Ok(PrepareOutcome::Done(PushOutcome::Error {
1268 remote_ref: remote_ref_str,
1269 message:
1270 r#""multiple bundles exist on server. Run git-remote-object-store doctor to fix."?"#
1271 .to_owned(),
1272 }));
1273 }
1274 let pre_existing = pre_bundles.into_iter().next().map(|m| m.key);
1275
1276 let pre_existing_sha = match pre_existing.as_deref() {
1277 None => None,
1278 Some(key) => {
1279 let Some(s) = parse_remote_sha_from_key(key) else {
1280 return Ok(PrepareOutcome::Done(PushOutcome::Error {
1281 remote_ref: remote_ref_str,
1282 message: format!(
1283 r#""unable to parse remote bundle key {key:?}; run git-remote-object-store doctor to fix."?"#,
1284 ),
1285 }));
1286 };
1287 Some(s)
1288 }
1289 };
1290
1291 // Sync gix work (rev-parse / ancestor / archive) runs in a
1292 // dedicated scope so the !Sync `Repository` is dropped before any
1293 // .await — keeps `push_batch`'s future `Send`.
1294 let probe = local_git_work(
1295 repo_dir,
1296 &local_spec,
1297 pre_existing_sha,
1298 force_push,
1299 config.zip,
1300 )?;
1301 let local = match probe {
1302 Ok(local) => local,
1303 Err(GitProbeError::LocalRefNotFound) => {
1304 return Ok(PrepareOutcome::Done(PushOutcome::Error {
1305 remote_ref: remote_ref_str,
1306 message: format!(r#""{local_spec} not found"?"#),
1307 }));
1308 }
1309 Err(GitProbeError::NotAncestor) => {
1310 return Ok(PrepareOutcome::Done(PushOutcome::Error {
1311 remote_ref: remote_ref_str,
1312 message: not_ancestor_wire_message(&local_spec),
1313 }));
1314 }
1315 };
1316
1317 let temp_dir = tempfile::Builder::new()
1318 .prefix("git_remote_object_store_push_")
1319 .tempdir()?;
1320 let bundle_path =
1321 git::bundle_at(&local.cwd, temp_dir.path(), local.local_sha, &local_spec).await?;
1322
1323 Ok(PrepareOutcome::Ready(Box::new(PushReadyState {
1324 remote_ref,
1325 local_sha: local.local_sha,
1326 pre_existing,
1327 bundle_path,
1328 zip_artifacts: local.zip_artifacts,
1329 engine: config.engine,
1330 force,
1331 pre_existing_was_ancestor: local.pre_existing_was_ancestor,
1332 local_spec,
1333 hidden_bundles,
1334 _temp_dir: temp_dir,
1335 })))
1336}
1337
1338/// Execute one push or delete: prepare, lock, do work, release.
1339///
1340/// Both the upload path ([`perform_push_under_lock`]) and the delete
1341/// path ([`delete_remote_ref_under_lock`]) run inside the SAME per-ref
1342/// lock window with identical acquire/release accounting:
1343///
1344/// - `acquire_lock` returning `None` → emit the standard
1345/// "failed to acquire ref lock" wire error without doing work.
1346/// - `release_lock` failing AFTER successful work → downgrade the
1347/// outcome to an `Error` so the operator is alerted to a potentially
1348/// dangling lock.
1349/// - A genuine work error takes priority over a release failure — the
1350/// release error is logged but never masks the original failure.
1351///
1352/// Issue #133: putting the delete path under this same window closes
1353/// the race where a concurrent push could land a new bundle between a
1354/// pre-lock listing and a pre-lock sweep, producing a silent false
1355/// success.
1356async fn push_one(
1357 store: Arc<dyn ObjectStore>,
1358 prefix: Option<&str>,
1359 repo_dir: &Path,
1360 config: &PushConfig,
1361 now: OffsetDateTime,
1362 spec: PushSpec,
1363) -> Result<PushOutcome, PushError> {
1364 let (remote_ref_str, work): (String, UnderLockWork) =
1365 match prepare_push(store.as_ref(), prefix, repo_dir, config, spec).await? {
1366 PrepareOutcome::Done(o) => return Ok(o),
1367 PrepareOutcome::Ready(state) => (
1368 state.remote_ref.as_str().to_owned(),
1369 // `state` is already `Box<PushReadyState>` (`PrepareOutcome::Ready`
1370 // is boxed to keep the enum's largest variant compact); reuse the
1371 // existing allocation instead of re-boxing.
1372 UnderLockWork::Push(state),
1373 ),
1374 PrepareOutcome::Delete { remote_ref } => (
1375 remote_ref.as_str().to_owned(),
1376 UnderLockWork::Delete { remote_ref },
1377 ),
1378 };
1379
1380 let lock = match &work {
1381 UnderLockWork::Push(state) => lock_key(prefix, &state.remote_ref),
1382 UnderLockWork::Delete { remote_ref, .. } => lock_key(prefix, remote_ref),
1383 };
1384
1385 let Some(guard) = acquire_lock(Arc::clone(&store), &lock, config.ttl, now).await? else {
1386 return Ok(PushOutcome::Error {
1387 remote_ref: remote_ref_str,
1388 message: format!(
1389 // `push_one` covers BOTH the Push and Delete arms under the
1390 // same per-ref lock, so the contention message names both
1391 // — mirroring the packchain delete path's wording. A
1392 // delete-arm caller previously saw a misleading
1393 // "may be pushing" hint here.
1394 r#""failed to acquire ref lock at {lock}. Another client may be pushing or deleting. If this persists beyond {}s, run git-remote-object-store doctor to inspect and optionally clear stale locks."?"#,
1395 config.ttl.whole_seconds(),
1396 ),
1397 });
1398 };
1399
1400 let result = match work {
1401 UnderLockWork::Push(state) => {
1402 perform_push_under_lock(store.as_ref(), prefix, config.kind, *state).await
1403 }
1404 UnderLockWork::Delete { remote_ref } => {
1405 delete_remote_ref_under_lock(store.as_ref(), prefix, &remote_ref, &lock).await
1406 }
1407 };
1408 let release_result = release_lock(guard).await;
1409
1410 match (&result, release_result) {
1411 (Ok(PushOutcome::Ok { .. }), Err(e)) => {
1412 warn!(key = %lock, error = %e, "failed to release lock");
1413 Ok(PushOutcome::Error {
1414 remote_ref: remote_ref_str,
1415 message: format!(
1416 r#""failed to release lock. You may need to manually remove the lock {lock} from the server or use git-remote-object-store doctor to fix."?"#,
1417 ),
1418 })
1419 }
1420 (_, Err(e)) => {
1421 warn!(key = %lock, error = %e, "lock release failed (work already errored)");
1422 result
1423 }
1424 _ => result,
1425 }
1426}
1427
1428/// The work to perform inside the per-ref lock acquired by [`push_one`].
1429///
1430/// `PushReadyState` is significantly larger than the `Delete` variant
1431/// (paths, the temp dir guard, the captured refspec); boxing it keeps
1432/// the [`UnderLockWork`] discriminant compact regardless of variant.
1433enum UnderLockWork {
1434 Push(Box<PushReadyState>),
1435 Delete { remote_ref: RefName },
1436}
1437
1438/// Re-list under the lock, upload the bundle, init HEAD, write the `FORMAT`
1439/// key, defer the previous bundle's delete via a baseline tombstone (issue
1440/// #157), optionally upload `repo.zip`. Split out so the lock release in
1441/// the caller is unconditional.
1442async fn perform_push_under_lock(
1443 store: &dyn ObjectStore,
1444 prefix: Option<&str>,
1445 kind: BackendKind,
1446 state: PushReadyState,
1447) -> Result<PushOutcome, PushError> {
1448 let PushReadyState {
1449 remote_ref,
1450 local_sha,
1451 pre_existing,
1452 bundle_path,
1453 zip_artifacts,
1454 engine,
1455 force,
1456 pre_existing_was_ancestor,
1457 local_spec,
1458 hidden_bundles,
1459 _temp_dir,
1460 } = state;
1461 let remote_ref_str = remote_ref.as_str().to_owned();
1462
1463 // Issue #129: under-lock force-push protection check. A pre-lock
1464 // check would race against a concurrent `protect`; running here —
1465 // after `acquire_lock`, before any writes — closes that TOCTOU
1466 // window. The historical "protected ref + force" semantic is
1467 // "demote to non-force": if the local SHA would have been a
1468 // fast-forward against the pre-lock-listed remote (already pinned
1469 // by the stale-remote guard below), let the push through; if not,
1470 // emit the same NotAncestor wire error a pre-lock non-force probe
1471 // would have produced. The `is_protected` helper uses `head`, not
1472 // `list`, so this adds one cheap key probe and does not duplicate
1473 // any existing under-lock listing.
1474 if force && !pre_existing_was_ancestor && is_protected(store, prefix, &remote_ref).await? {
1475 return Ok(PushOutcome::Error {
1476 remote_ref: remote_ref_str,
1477 message: not_ancestor_wire_message(&local_spec),
1478 });
1479 }
1480
1481 // Issue #165: reuse the tombstone set captured in `prepare_push`.
1482 // The per-ref lock prevents any new tombstone for this ref from
1483 // landing between then and now.
1484 let current = bundles_for_ref(store, prefix, &remote_ref, Some(&hidden_bundles)).await?;
1485 if current.len() > 1 {
1486 return Ok(PushOutcome::Error {
1487 remote_ref: remote_ref_str,
1488 message: r#""multiple bundles exist for the same ref on server. Run git-remote-object-store doctor to fix."?"#.to_owned(),
1489 });
1490 }
1491 let current_key = current.into_iter().next().map(|m| m.key);
1492 // Compare the pre-lock snapshot to the under-lock reality. All five
1493 // (pre / under) cases must agree:
1494 // None / None — happy path, no bundle existed before or now.
1495 // Some(K) / Some(K) — happy path, same bundle (e.g. force-push that
1496 // re-uploads against the same SHA).
1497 // Some(A) / Some(B) — concurrent push replaced our snapshot's
1498 // bundle. Reject: we'd silently overwrite it.
1499 // None / Some — concurrent push created a bundle after our
1500 // pre-lock list. Reject.
1501 // Some / None — concurrent delete (`git push :ref`) removed
1502 // our snapshot's bundle. Reject.
1503 // Without this guard a concurrent writer between the pre-lock list and
1504 // the lock acquisition would be silently overwritten.
1505 if pre_existing.as_deref() != current_key.as_deref() {
1506 return Ok(PushOutcome::Error {
1507 remote_ref: remote_ref_str,
1508 message: r#""stale remote. Please fetch and retry."?"#.to_owned(),
1509 });
1510 }
1511
1512 let bundle_dest = keys::bundle_key(prefix, &remote_ref, local_sha);
1513 // Stat once for "X / total" formatting in progress logs. The
1514 // multipart upload re-stats `bundle_path` to size the part plan;
1515 // a brief race here would surface as the log showing a stale
1516 // total — never as wrong bytes — so we tolerate it. On stat
1517 // failure (vanishingly rare on a tempdir we just wrote) fall
1518 // back to "unknown" rather than aborting the push.
1519 let bundle_total = tokio::fs::metadata(&bundle_path)
1520 .await
1521 .map(|m| m.len())
1522 .ok();
1523 let bundle_opts = PutOpts {
1524 progress: Some(bundle_progress_sink(&bundle_dest, bundle_total)),
1525 ..PutOpts::default()
1526 };
1527 store
1528 .put_path(&bundle_dest, &bundle_path, bundle_opts)
1529 .await?;
1530
1531 // HEAD bootstrap: write only if absent. Single round-trip via
1532 // put_if_absent — we don't care about the boolean (existing HEAD is
1533 // intentionally preserved).
1534 let head = head_key(prefix);
1535 store
1536 .put_if_absent(
1537 &head,
1538 Bytes::copy_from_slice(remote_ref.as_str().as_bytes()),
1539 )
1540 .await?;
1541
1542 // Lock in the storage engine on the first push. `put_if_absent` makes
1543 // concurrent first-push races safe — both write the same `bundle` value,
1544 // so the one that loses is a no-op. The boolean result is intentionally
1545 // ignored: an existing FORMAT key was already validated at connect time by
1546 // `backend::build`.
1547 let format_key = keys::join(prefix, "FORMAT");
1548 store
1549 .put_if_absent(&format_key, Bytes::from_static(engine.as_str().as_bytes()))
1550 .await?;
1551
1552 if let Some(prev) = current_key
1553 && prev != bundle_dest
1554 {
1555 // Issue #157: defer the prior-bundle delete to `gc sweep` via a
1556 // baseline tombstone instead of deleting it synchronously. A
1557 // concurrent fetcher that already advertised the prior SHA via
1558 // `list` would otherwise race the GET against this DELETE; the
1559 // tombstone gives `fetch_one` the grace window it needs.
1560 defer_prior_bundle_via_tombstone(store, prefix, &remote_ref, &prev, local_sha).await;
1561 }
1562
1563 if let Some(artifacts) = zip_artifacts {
1564 let zip_dest = archive_key(prefix, &remote_ref);
1565 let zip_total = tokio::fs::metadata(&artifacts.archive_path)
1566 .await
1567 .map(|m| m.len())
1568 .ok();
1569 // Issue #161: the `codepipeline-artifact-revision-summary` user
1570 // metadata is only meaningful on S3 (AWS CodePipeline consumes it
1571 // as `x-amz-meta-codepipeline-artifact-revision-summary`). Azure
1572 // metadata names must be valid C# identifiers (no hyphens), so
1573 // attaching the header on the Azure path causes the entire blob
1574 // upload to fail with `InvalidMetadata` — and the issue #127
1575 // best-effort swallow then hides the failure, leaving every
1576 // `?zip=1` push silently missing `repo.zip`. Omit the header
1577 // outside S3 so the zip artifact lands successfully.
1578 let user_metadata = match kind {
1579 BackendKind::S3 => vec![(
1580 "codepipeline-artifact-revision-summary".to_owned(),
1581 sanitize_metadata_value(&artifacts.commit_msg),
1582 )],
1583 BackendKind::Azure => Vec::new(),
1584 };
1585 let opts = PutOpts {
1586 content_disposition: Some(format!(
1587 "attachment; filename=repo-{}.zip",
1588 artifacts.short_sha
1589 )),
1590 user_metadata,
1591 progress: Some(bundle_progress_sink(&zip_dest, zip_total)),
1592 };
1593 upload_zip_artifact_best_effort(
1594 store,
1595 &remote_ref,
1596 &zip_dest,
1597 &artifacts.archive_path,
1598 opts,
1599 )
1600 .await;
1601 }
1602
1603 Ok(PushOutcome::Ok {
1604 remote_ref: remote_ref_str,
1605 })
1606}
1607
1608/// Build a [`ProgressSink`] that emits one structured `tracing::info!`
1609/// line per chunk transferred during a bundle / zip-archive upload.
1610///
1611/// Issue #55. Git's helper protocol has no upload-progress channel
1612/// (the helper-protocol stdout is reserved for protocol traffic per
1613/// `.claude/rules/protocol-stdout.md`), so the bundle path's only way
1614/// to inform the user during a multi-GiB upload is `tracing::info!`,
1615/// which routes to stderr via the tracing-subscriber initialised in
1616/// `main()`. The LFS path keeps its own sink wiring (one
1617/// progress-event JSON line per chunk on stdout, governed by the LFS
1618/// custom-transfer protocol).
1619///
1620/// `total` is the bundle size at the moment we stat'd it. A short
1621/// race against a writer that re-stats during multipart planning
1622/// would only mis-format the log line — it cannot drive wrong-byte
1623/// behaviour. `None` renders "unknown" so the call site can
1624/// gracefully degrade when stat'ing the source fails.
1625///
1626/// The granularity of events is whatever the backend's `put_path`
1627/// provides: one event per completed multipart part / staged block
1628/// for bodies above [`crate::object_store::multipart::MULTIPART_PUT_THRESHOLD`],
1629/// one event total for bodies below it. With the default 16 MiB
1630/// part size and 8-way concurrency, a 1 GiB bundle emits ~64 lines
1631/// — ample motion to spot a stall, far short of log spam.
1632pub(crate) fn bundle_progress_sink(key: &str, total: Option<u64>) -> ProgressSink {
1633 // The closure needs an owned `String` because `ProgressSink` is
1634 // `'static`; cloning here keeps the call sites' borrow intact so
1635 // they can pass `&bundle_dest` to `put_path` without juggling
1636 // ownership.
1637 let key = key.to_owned();
1638 let bytes_so_far = Arc::new(AtomicU64::new(0));
1639 ProgressSink::new(move |bytes_amount| {
1640 // `Ordering::Relaxed` is enough: we're not synchronising with
1641 // any other state, just maintaining a monotonic counter for
1642 // log lines. The worst a re-ordered store could do is print
1643 // an out-of-order count, which `tracing` already disclaims.
1644 let so_far = bytes_so_far
1645 .fetch_add(bytes_amount, Ordering::Relaxed)
1646 .saturating_add(bytes_amount);
1647 // Render `total` as a Display value so the field is omitted
1648 // when stat'ing the source failed at the call site. Tracing's
1649 // `Option<u64>` rendering would print "None" — uglier than a
1650 // bare absence of the field.
1651 if let Some(t) = total {
1652 info!(
1653 key = %key,
1654 bytes_so_far = so_far,
1655 total = t,
1656 bytes_chunk = bytes_amount,
1657 "uploading"
1658 );
1659 } else {
1660 info!(
1661 key = %key,
1662 bytes_so_far = so_far,
1663 bytes_chunk = bytes_amount,
1664 "uploading"
1665 );
1666 }
1667 })
1668}
1669
1670/// Replace ASCII control characters in `s` with spaces so the result
1671/// is safe to use as an HTTP header value.
1672///
1673/// `commit_msg` flows from `git::last_commit_message` (whose summary
1674/// is "everything before the first blank line" per gix) into the
1675/// `codepipeline-artifact-revision-summary` user-metadata header on
1676/// the zip-archive upload. A maliciously-crafted commit could embed
1677/// `\r\n` in the summary, which would be a CRLF injection on
1678/// transport — splitting one logical header into two and letting an
1679/// attacker forge arbitrary user-metadata headers on the uploaded
1680/// archive. Both backends' SDKs reject CRLF at the transport layer
1681/// today, but that defense is version-dependent and the resulting
1682/// error is a cryptic "invalid header" 400; sanitising here surfaces
1683/// a clean, predictable value at the call site instead.
1684fn sanitize_metadata_value(s: &str) -> String {
1685 s.chars()
1686 .map(|c| if c.is_control() { ' ' } else { c })
1687 .collect()
1688}
1689
1690/// Handle a delete refspec (`:<remote_ref>`) UNDER the per-ref lock
1691/// acquired by [`push_one`]: list `<prefix>/<ref>/`, classify the
1692/// remaining entries by shape, sweep every artifact that belongs to the
1693/// ref, and emit `ok` or the appropriate error.
1694///
1695/// Issue #133: this must run inside the lock window. A pre-lock
1696/// listing-then-sweep races a concurrent push that lands a new bundle
1697/// between the listing and the deletion, producing a silent false
1698/// success — the delete reports `ok` to git while the ref survives
1699/// with a different bundle on the server.
1700///
1701/// The lock key (`<prefix>/<ref>/LOCK#.lock`) is filtered from the
1702/// listing — `release_lock` removes it last, after this function
1703/// returns. The sweep must not touch it (deleting our own lock
1704/// mid-critical-section would let concurrent clients acquire it).
1705///
1706/// Issue #128: the `PROTECTED#` marker check is the FIRST guard, run
1707/// against the fresh under-lock listing BEFORE any sweep dispatch, so a
1708/// listing that pairs a marker with a bundle (and/or `repo.zip`) cannot
1709/// sweep the marker and report `ok`.
1710///
1711/// Issue #242: deletability is decided by the entry SHAPE, never by the
1712/// connection-time `zip` flag. The `repo.zip` upload is best-effort and
1713/// its failure is swallowed (see [`upload_zip_artifact_best_effort`]),
1714/// so a `?zip=1` ref can legitimately have only its `<sha>.bundle` with
1715/// no sibling archive — and a non-zip URL can be asked to delete a ref
1716/// that still carries a leftover `repo.zip` from an earlier zip-mode
1717/// push. Asserting `entries.len() == (1 or 2 by zip flag)` mis-routed
1718/// both cases to the corruption branch. Instead we count the
1719/// `<sha>.bundle` objects and treat every non-bundle sibling (the
1720/// `repo.zip` archive and any future per-ref artifact) as a deletable
1721/// companion of the ref's single bundle.
1722///
1723/// Four behaviours fall out:
1724///
1725/// 1. **Protected ref** — the listing (lock filtered out) includes the
1726/// [`keys::PROTECTED_MARKER_SEGMENT`] marker. Emit a
1727/// protection-specific refusal naming the `unprotect` workflow.
1728/// 2. **Exactly one bundle** — with or without a `repo.zip` sibling,
1729/// sweep every present artifact (bundle + siblings) and report `ok`.
1730/// 3. **No bundle present** — emit the `"not found"?` wire error. This
1731/// covers a ref whose only on-server state was a stale `LOCK#.lock`
1732/// (recovered and now held by us, so filtered out) and a ref that
1733/// carries only an orphaned `repo.zip` with no bundle.
1734/// 4. **Two or more distinct `<sha>.bundle` keys** — genuine
1735/// multi-bundle corruption. Fall through to the doctor message.
1736async fn delete_remote_ref_under_lock(
1737 store: &dyn ObjectStore,
1738 prefix: Option<&str>,
1739 remote_ref: &RefName,
1740 lock_key: &str,
1741) -> Result<PushOutcome, PushError> {
1742 let listing = ref_listing_prefix(prefix, remote_ref);
1743 let all_entries = store.list(&listing).await?;
1744 let remote_ref_str = remote_ref.as_str().to_owned();
1745 // Issue #128: the canonical protection guard, run FIRST against the
1746 // fresh under-lock listing. `entries_have_protected_marker` matches
1747 // only the literal `PROTECTED#` last segment — never the
1748 // `LOCK#.lock` lock key — so scanning the unfiltered `all_entries`
1749 // here is safe.
1750 if keys::entries_have_protected_marker(&all_entries) {
1751 return Ok(PushOutcome::Error {
1752 remote_ref: remote_ref_str,
1753 message: DELETE_PROTECTION_MESSAGE.to_owned(),
1754 });
1755 }
1756 // Issue #133: filter out the lock key we hold so the sweep cannot
1757 // delete it. Everything that survives this filter is a deletable
1758 // artifact of the ref (bundle objects plus any `repo.zip` / future
1759 // sibling) — the `PROTECTED#` marker already returned above.
1760 let deletable: Vec<&ObjectMeta> = all_entries.iter().filter(|e| e.key != lock_key).collect();
1761 // Issue #242: route on the number of distinct `<sha>.bundle` keys,
1762 // not the connection-time `zip` flag. A ref is a clean single-bundle
1763 // ref when exactly one bundle is present, regardless of whether a
1764 // `repo.zip` sibling rode along.
1765 let bundle_count = deletable
1766 .iter()
1767 .filter(|e| is_bundle_candidate(&e.key))
1768 .count();
1769 match bundle_count {
1770 0 => Ok(PushOutcome::Error {
1771 remote_ref: remote_ref_str,
1772 message: r#""not found"?"#.to_owned(),
1773 }),
1774 1 => {
1775 for entry in &deletable {
1776 delete_idempotent(store, &entry.key).await?;
1777 }
1778 // Issue #151 defence-in-depth: confirm no `PROTECTED#` marker
1779 // sneaked in. The lock window is still open (the caller
1780 // releases it after we return), so a `protect`/`unprotect`
1781 // racing this delete would be blocked on the lock per #159.
1782 // Finding a marker here would indicate a contract violation;
1783 // the helper logs at `error!` and the delete still reports
1784 // `ok` — see the helper doc for the rationale.
1785 verify_no_orphan_protected_after_delete(store, prefix, remote_ref).await;
1786 Ok(PushOutcome::Ok {
1787 remote_ref: remote_ref_str,
1788 })
1789 }
1790 _ => Ok(PushOutcome::Error {
1791 remote_ref: remote_ref_str,
1792 message:
1793 r#""multiple bundles exist on server. Run git-remote-object-store doctor to fix."?"#
1794 .to_owned(),
1795 }),
1796 }
1797}
1798
1799#[cfg(test)]
1800mod tests {
1801 use super::*;
1802 use crate::object_store::mock::MockStore;
1803 use crate::packchain::gc::baseline_tombstone_listing_prefix;
1804
1805 const SHA: &str = "0123456789abcdef0123456789abcdef01234567";
1806 /// A second 40-char hex SHA distinct from `SHA`. Used by tests that
1807 /// need to seed pre-existing state under a SHA different from
1808 /// `local_sha` so a regression cannot pass through coincidental key
1809 /// alignment between `pre_existing` and `bundle_dest`.
1810 const OTHER_SHA: &str = "ffffffffffffffffffffffffffffffffffffffff";
1811
1812 /// Compile-time guard: replaces the runtime `assert_ne!(other_sha, SHA, …)`
1813 /// that the constant lift removed. A typo making the two consts equal
1814 /// fails the build rather than silently letting a stale-remote test
1815 /// pass for the wrong reason.
1816 const _: () = {
1817 let a = SHA.as_bytes();
1818 let b = OTHER_SHA.as_bytes();
1819 let mut i = 0;
1820 let mut differs = a.len() != b.len();
1821 while i < a.len() && i < b.len() {
1822 if a[i] != b[i] {
1823 differs = true;
1824 }
1825 i += 1;
1826 }
1827 assert!(differs, "OTHER_SHA must differ from SHA");
1828 };
1829
1830 fn rn(s: &str) -> RefName {
1831 RefName::new(s).expect("RefName")
1832 }
1833
1834 // --- parse_push_args ----------------------------------------------
1835
1836 #[test]
1837 fn parse_push_args_accepts_canonical_form() {
1838 let spec = parse_push_args("refs/heads/main:refs/heads/main").expect("parse");
1839 assert!(!spec.force);
1840 assert_eq!(spec.local_spec, "refs/heads/main");
1841 assert_eq!(spec.remote_ref.as_str(), "refs/heads/main");
1842 }
1843
1844 #[test]
1845 fn parse_push_args_accepts_force_flag() {
1846 let spec = parse_push_args("+refs/heads/main:refs/heads/main").expect("parse");
1847 assert!(spec.force);
1848 assert_eq!(spec.local_spec, "refs/heads/main");
1849 }
1850
1851 #[test]
1852 fn parse_push_args_accepts_delete_form() {
1853 let spec = parse_push_args(":refs/heads/main").expect("parse");
1854 assert!(!spec.force);
1855 assert!(spec.local_spec.is_empty());
1856 assert_eq!(spec.remote_ref.as_str(), "refs/heads/main");
1857 }
1858
1859 #[test]
1860 fn parse_push_args_accepts_force_delete_form() {
1861 let spec = parse_push_args("+:refs/heads/main").expect("parse");
1862 assert!(spec.force);
1863 assert!(spec.local_spec.is_empty());
1864 assert_eq!(spec.remote_ref.as_str(), "refs/heads/main");
1865 }
1866
1867 #[test]
1868 fn parse_push_args_accepts_short_local() {
1869 let spec = parse_push_args("HEAD:refs/heads/main").expect("parse");
1870 assert_eq!(spec.local_spec, "HEAD");
1871 }
1872
1873 #[test]
1874 fn parse_push_args_rejects_missing_colon() {
1875 assert!(matches!(
1876 parse_push_args("refs/heads/main"),
1877 Err(PushError::Parse { .. })
1878 ));
1879 }
1880
1881 #[test]
1882 fn parse_push_args_rejects_empty_remote() {
1883 assert!(matches!(
1884 parse_push_args("refs/heads/main:"),
1885 Err(PushError::Parse { .. })
1886 ));
1887 }
1888
1889 #[test]
1890 fn parse_push_args_rejects_invalid_remote_ref() {
1891 assert!(matches!(
1892 parse_push_args("refs/heads/main:refs/heads/.bad"),
1893 Err(PushError::RemoteRef(_))
1894 ));
1895 }
1896
1897 #[test]
1898 fn parse_push_args_rejects_invalid_local_spec() {
1899 assert!(matches!(
1900 parse_push_args("refs/heads/.bad:refs/heads/main"),
1901 Err(PushError::InvalidLocalSpec(_))
1902 ));
1903 }
1904
1905 #[test]
1906 fn parse_push_args_rejects_embedded_whitespace() {
1907 assert!(matches!(
1908 parse_push_args("refs/heads/main:refs/heads/main extra"),
1909 Err(PushError::Parse { .. })
1910 ));
1911 }
1912
1913 #[test]
1914 fn parse_push_args_rejects_empty_input() {
1915 assert!(matches!(parse_push_args(""), Err(PushError::Parse { .. })));
1916 }
1917
1918 // --- key formatting -----------------------------------------------
1919
1920 #[test]
1921 fn key_formatters_with_prefix() {
1922 let r = rn("refs/heads/main");
1923 let sha = Sha::from_hex(SHA).unwrap();
1924 assert_eq!(
1925 keys::bundle_key(Some("repo"), &r, sha),
1926 format!("repo/refs/heads/main/{SHA}.bundle"),
1927 );
1928 assert_eq!(
1929 lock_key(Some("repo"), &r),
1930 "repo/refs/heads/main/LOCK#.lock"
1931 );
1932 assert_eq!(
1933 archive_key(Some("repo"), &r),
1934 "repo/refs/heads/main/repo.zip"
1935 );
1936 assert_eq!(head_key(Some("repo")), "repo/HEAD");
1937 }
1938
1939 #[test]
1940 fn key_formatters_with_no_prefix() {
1941 let r = rn("refs/heads/main");
1942 let sha = Sha::from_hex(SHA).unwrap();
1943 assert_eq!(
1944 keys::bundle_key(None, &r, sha),
1945 format!("refs/heads/main/{SHA}.bundle"),
1946 );
1947 assert_eq!(lock_key(None, &r), "refs/heads/main/LOCK#.lock");
1948 assert_eq!(archive_key(None, &r), "refs/heads/main/repo.zip");
1949 assert_eq!(head_key(None), "HEAD");
1950 // Empty-string prefix is treated identically to None.
1951 assert_eq!(head_key(Some("")), "HEAD");
1952 assert_eq!(lock_key(Some(""), &r), "refs/heads/main/LOCK#.lock");
1953 }
1954
1955 // --- bundle filter ------------------------------------------------
1956
1957 #[test]
1958 fn is_bundle_candidate_keeps_real_bundles() {
1959 assert!(is_bundle_candidate(&format!(
1960 "repo/refs/heads/main/{SHA}.bundle"
1961 )));
1962 assert!(is_bundle_candidate(&format!(
1963 "refs/heads/main/{SHA}.bundle"
1964 )));
1965 }
1966
1967 #[test]
1968 fn is_bundle_candidate_rejects_protected_zip_lock() {
1969 assert!(!is_bundle_candidate("repo/refs/heads/main/PROTECTED#"));
1970 assert!(!is_bundle_candidate("repo/refs/heads/main/repo.zip"));
1971 assert!(!is_bundle_candidate("repo/refs/heads/main/LOCK#.lock"));
1972 assert!(!is_bundle_candidate("repo/refs/heads/main/file.lock"));
1973 assert!(!is_bundle_candidate("repo/refs/heads/main/LOCKS/x"));
1974 }
1975
1976 /// Regression: refs whose names embed `.zip` as a substring must
1977 /// not be filtered out. Previously the predicate rejected any key
1978 /// containing `.zip` anywhere in the byte sequence.
1979 #[test]
1980 fn is_bundle_candidate_keeps_refs_containing_zip_substring() {
1981 assert!(is_bundle_candidate(&format!(
1982 "repo/refs/heads/v1.zip-rc1/{SHA}.bundle"
1983 )));
1984 assert!(is_bundle_candidate(&format!(
1985 "refs/heads/myrelease.zip-v1/{SHA}.bundle"
1986 )));
1987 }
1988
1989 /// Regression: refs whose names embed `LOCKS` as a substring must
1990 /// not be filtered out. Previously the predicate rejected any key
1991 /// containing `/LOCKS/` anywhere in the byte sequence.
1992 #[test]
1993 fn is_bundle_candidate_keeps_refs_containing_locks_substring() {
1994 assert!(is_bundle_candidate(&format!(
1995 "repo/refs/heads/LOCKS-feature/x/{SHA}.bundle"
1996 )));
1997 assert!(is_bundle_candidate(&format!(
1998 "refs/heads/LOCKS/sub/{SHA}.bundle"
1999 )));
2000 }
2001
2002 /// Regression: a ref-name segment ending in `.lock` is permitted by
2003 /// `gix_validate`; bundle keys under such refs must still match.
2004 /// The unwanted `.lock` sibling is `<ref>/LOCK#.lock`, where the
2005 /// final segment is `LOCK#.lock`, not `<sha>.bundle`.
2006 #[test]
2007 fn is_bundle_candidate_keeps_refs_containing_lock_substring() {
2008 assert!(is_bundle_candidate(&format!(
2009 "refs/heads/feature.lock-rc/{SHA}.bundle"
2010 )));
2011 }
2012
2013 // --- parse_remote_sha_from_key ------------------------------------
2014
2015 #[test]
2016 fn parse_remote_sha_from_key_extracts_lower_hex_40() {
2017 let sha = parse_remote_sha_from_key(&format!("repo/refs/heads/main/{SHA}.bundle"))
2018 .expect("parse");
2019 assert_eq!(sha.to_string(), SHA);
2020 }
2021
2022 #[test]
2023 fn parse_remote_sha_from_key_rejects_uppercase() {
2024 let upper = SHA.to_uppercase();
2025 assert!(parse_remote_sha_from_key(&format!("refs/heads/main/{upper}.bundle")).is_none());
2026 }
2027
2028 #[test]
2029 fn parse_remote_sha_from_key_rejects_wrong_length() {
2030 let short = &SHA[..39];
2031 assert!(parse_remote_sha_from_key(&format!("refs/heads/main/{short}.bundle")).is_none());
2032 }
2033
2034 #[test]
2035 fn parse_remote_sha_from_key_rejects_missing_extension() {
2036 assert!(parse_remote_sha_from_key(&format!("refs/heads/main/{SHA}")).is_none());
2037 }
2038
2039 // --- bundles_for_ref / is_protected ------------------------------
2040
2041 #[tokio::test]
2042 async fn bundles_for_ref_filters_protected_zip_lock() {
2043 let store = MockStore::new();
2044 let r = rn("refs/heads/main");
2045 store.insert(
2046 format!("repo/refs/heads/main/{SHA}.bundle"),
2047 Bytes::from_static(b"b"),
2048 );
2049 store.insert("repo/refs/heads/main/PROTECTED#", Bytes::from_static(b""));
2050 store.insert("repo/refs/heads/main/repo.zip", Bytes::from_static(b""));
2051 store.insert("repo/refs/heads/main/LOCK#.lock", Bytes::from_static(b""));
2052 let bundles = bundles_for_ref(&store, Some("repo"), &r, None)
2053 .await
2054 .unwrap();
2055 assert_eq!(bundles.len(), 1);
2056 assert!(bundles[0].key.ends_with(".bundle"));
2057 }
2058
2059 /// Regression for #109: a ref whose name contains `.zip` must
2060 /// not have its bundle silently filtered out.
2061 #[tokio::test]
2062 async fn bundles_for_ref_keeps_bundle_when_ref_name_contains_zip() {
2063 let store = MockStore::new();
2064 let r = rn("refs/heads/v1.zip-rc1");
2065 let bundle_key = format!("repo/refs/heads/v1.zip-rc1/{SHA}.bundle");
2066 store.insert(bundle_key.clone(), Bytes::from_static(b"b"));
2067 let bundles = bundles_for_ref(&store, Some("repo"), &r, None)
2068 .await
2069 .unwrap();
2070 assert_eq!(bundles.len(), 1);
2071 assert_eq!(bundles[0].key, bundle_key);
2072 }
2073
2074 /// Regression for #109: a ref whose name contains `LOCKS` must
2075 /// not have its bundle silently filtered out.
2076 #[tokio::test]
2077 async fn bundles_for_ref_keeps_bundle_when_ref_name_contains_locks() {
2078 let store = MockStore::new();
2079 let r = rn("refs/heads/LOCKS-feature/x");
2080 let bundle_key = format!("repo/refs/heads/LOCKS-feature/x/{SHA}.bundle");
2081 store.insert(bundle_key.clone(), Bytes::from_static(b"b"));
2082 let bundles = bundles_for_ref(&store, Some("repo"), &r, None)
2083 .await
2084 .unwrap();
2085 assert_eq!(bundles.len(), 1);
2086 assert_eq!(bundles[0].key, bundle_key);
2087 }
2088
2089 /// Issue #165: a caller-supplied `cached_hidden` set must satisfy
2090 /// the tombstone lookup — `bundles_for_ref` must NOT re-list
2091 /// `<prefix>/gc/` or fetch any tombstone body. Counts every `list`
2092 /// + `get_bytes` call so a regression that drops the cache and
2093 /// re-walks the tombstone set fails this test.
2094 #[tokio::test]
2095 async fn bundles_for_ref_skips_tombstone_lookup_when_cache_provided() {
2096 use std::sync::atomic::{AtomicUsize, Ordering};
2097
2098 struct CountingStore {
2099 inner: MockStore,
2100 gc_lists: AtomicUsize,
2101 tombstone_gets: AtomicUsize,
2102 }
2103
2104 // `put_path` is intentionally omitted from the forward list to
2105 // preserve the original behavior (trait default forwarding via
2106 // `Self::put_bytes`); this decorator never has `put_path` called
2107 // on it in practice.
2108 crate::delegate_to_inner_impl! {
2109 impl ObjectStore for CountingStore {
2110 forward: get_to_file, get_bytes_range,
2111 put_bytes, put_if_absent,
2112 head, copy, delete;
2113
2114 async fn list(&self, prefix: &str) -> Result<Vec<ObjectMeta>, ObjectStoreError> {
2115 if prefix == "repo/gc/" {
2116 self.gc_lists.fetch_add(1, Ordering::SeqCst);
2117 }
2118 self.inner.list(prefix).await
2119 }
2120
2121 async fn get_bytes(&self, key: &str) -> Result<Bytes, ObjectStoreError> {
2122 if key.starts_with(&baseline_tombstone_listing_prefix(Some("repo"))) {
2123 self.tombstone_gets.fetch_add(1, Ordering::SeqCst);
2124 }
2125 self.inner.get_bytes(key).await
2126 }
2127 }
2128 }
2129
2130 let inner = MockStore::new();
2131 let r = rn("refs/heads/main");
2132 // Seed: a bundle for the ref plus a baseline tombstone naming a
2133 // *different* SHA. The tombstone exists so a non-cached call
2134 // would have to fetch & parse it to compute the hide set.
2135 inner.insert(
2136 format!("repo/refs/heads/main/{SHA}.bundle"),
2137 Bytes::from_static(b"b"),
2138 );
2139 // Plausible tombstone body — `tombstoned_bundle_keys` parses it
2140 // as `BaselineTombstone` JSON. Body shape mirrors the one
2141 // written by `write_baseline_tombstone_unconditional`; `v`
2142 // must be `TOMBSTONE_SCHEMA_VERSION` or the parser rejects it
2143 // and the tombstone is silently skipped (defeating the test).
2144 let tomb_body = format!(
2145 r#"{{"v":1,"ref_name":"refs/heads/main","sha":"{OTHER_SHA}","marked_at":"2024-01-01T00:00:00Z"}}"#
2146 );
2147 inner.insert(
2148 format!(
2149 "{}test.json",
2150 baseline_tombstone_listing_prefix(Some("repo"))
2151 ),
2152 Bytes::from(tomb_body),
2153 );
2154
2155 let store = CountingStore {
2156 inner,
2157 gc_lists: AtomicUsize::new(0),
2158 tombstone_gets: AtomicUsize::new(0),
2159 };
2160
2161 // Baseline: a `None` cache pays for one `gc/` list + one
2162 // tombstone GET. Asserting these counts pins the un-optimised
2163 // shape so the contrast with the cached path is meaningful.
2164 let bundles_uncached = bundles_for_ref(&store, Some("repo"), &r, None)
2165 .await
2166 .unwrap();
2167 assert_eq!(bundles_uncached.len(), 1);
2168 assert_eq!(store.gc_lists.load(Ordering::SeqCst), 1);
2169 assert_eq!(store.tombstone_gets.load(Ordering::SeqCst), 1);
2170
2171 // Cached path: hand the same hide set we already paid for.
2172 // `bundles_for_ref` must consult it and skip the `gc/` walk
2173 // entirely. A regression that drops the cache and re-lists
2174 // would bump these counters past 1.
2175 let hidden: HashSet<String> = [format!("repo/refs/heads/main/{OTHER_SHA}.bundle")]
2176 .into_iter()
2177 .collect();
2178 let bundles_cached = bundles_for_ref(&store, Some("repo"), &r, Some(&hidden))
2179 .await
2180 .unwrap();
2181 assert_eq!(bundles_cached.len(), 1);
2182 assert_eq!(
2183 store.gc_lists.load(Ordering::SeqCst),
2184 1,
2185 "cached call must not re-list gc/",
2186 );
2187 assert_eq!(
2188 store.tombstone_gets.load(Ordering::SeqCst),
2189 1,
2190 "cached call must not refetch any tombstone body",
2191 );
2192 }
2193
2194 #[tokio::test]
2195 async fn is_protected_detects_marker() {
2196 let store = MockStore::new();
2197 let r = rn("refs/heads/main");
2198 assert!(!is_protected(&store, Some("repo"), &r).await.unwrap());
2199 store.insert("repo/refs/heads/main/PROTECTED#", Bytes::from_static(b""));
2200 assert!(is_protected(&store, Some("repo"), &r).await.unwrap());
2201 }
2202
2203 /// Regression for #119: only the exact `PROTECTED#` key counts as a
2204 /// protection marker. A sibling key that merely starts with
2205 /// `PROTECTED#` (e.g. `PROTECTED#audit`) must not flip the result.
2206 #[tokio::test]
2207 async fn is_protected_ignores_protected_prefixed_sibling() {
2208 let store = MockStore::new();
2209 let r = rn("refs/heads/main");
2210 store.insert(
2211 "repo/refs/heads/main/PROTECTED#audit",
2212 Bytes::from_static(b""),
2213 );
2214 assert!(!is_protected(&store, Some("repo"), &r).await.unwrap());
2215 }
2216
2217 /// Regression for #119: `is_protected` must use `head`, not `list`.
2218 /// Arm `AccessDeniedOnAnyList`; if the implementation calls `list`
2219 /// at all, the fault fires and the call returns an error. We assert
2220 /// success and that the fault is still pending (i.e. unfired).
2221 #[tokio::test]
2222 async fn is_protected_uses_head_not_list() {
2223 use crate::object_store::mock::Fault;
2224 let store = MockStore::new();
2225 let r = rn("refs/heads/main");
2226 store.arm(Fault::AccessDeniedOnAnyList);
2227 let got = is_protected(&store, Some("repo"), &r).await.unwrap();
2228 assert!(!got);
2229 assert_eq!(store.pending_faults(), 1, "is_protected must not call list");
2230 // And the positive case too — still no list.
2231 store.insert("repo/refs/heads/main/PROTECTED#", Bytes::from_static(b""));
2232 let got = is_protected(&store, Some("repo"), &r).await.unwrap();
2233 assert!(got);
2234 assert_eq!(store.pending_faults(), 1, "is_protected must not call list");
2235 }
2236
2237 // --- acquire_lock / release_lock ----------------------------------
2238 //
2239 // Issue #118: `acquire_lock` returns a `LockGuard` instead of a
2240 // plain bool because the lock now carries a background heartbeat
2241 // task. Tests construct an `Arc<dyn ObjectStore>` so the heartbeat
2242 // task can clone the store; `MockStore`'s internal state is
2243 // already `Arc<Mutex<...>>`-shared, so the `Arc` clone is shape
2244 // bookkeeping, not extra state.
2245
2246 #[tokio::test]
2247 async fn acquire_lock_succeeds_when_absent() {
2248 let store = Arc::new(MockStore::new());
2249 let now = OffsetDateTime::now_utc();
2250 let guard = acquire_lock(
2251 Arc::clone(&store) as Arc<dyn ObjectStore>,
2252 "k",
2253 Duration::seconds(60),
2254 now,
2255 )
2256 .await
2257 .unwrap();
2258 assert!(guard.is_some(), "expected a fresh guard");
2259 assert!(store.contains("k"));
2260 // Drop the guard so the heartbeat task exits before the
2261 // runtime tears down (also covered explicitly by
2262 // `lock_guard_drop_aborts_heartbeat`).
2263 drop(guard);
2264 }
2265
2266 #[tokio::test]
2267 async fn acquire_lock_returns_none_when_recently_held() {
2268 let store = Arc::new(MockStore::new());
2269 let now = OffsetDateTime::now_utc();
2270 store.insert_with("k", Bytes::new(), now, PutOpts::default());
2271 let guard = acquire_lock(
2272 Arc::clone(&store) as Arc<dyn ObjectStore>,
2273 "k",
2274 Duration::seconds(60),
2275 now,
2276 )
2277 .await
2278 .unwrap();
2279 assert!(guard.is_none(), "expected contention");
2280 }
2281
2282 #[tokio::test]
2283 async fn acquire_lock_recovers_stale_lock() {
2284 let store = Arc::new(MockStore::new());
2285 let now = OffsetDateTime::now_utc();
2286 let stale = now - Duration::seconds(120);
2287 store.insert_with("k", Bytes::new(), stale, PutOpts::default());
2288 let guard = acquire_lock(
2289 Arc::clone(&store) as Arc<dyn ObjectStore>,
2290 "k",
2291 Duration::seconds(60),
2292 now,
2293 )
2294 .await
2295 .unwrap();
2296 assert!(guard.is_some(), "stale lock must be recoverable");
2297 // Lock still exists (we re-created it with put_if_absent).
2298 assert!(store.contains("k"));
2299 drop(guard);
2300 }
2301
2302 #[tokio::test]
2303 async fn acquire_lock_treats_disappeared_lock_as_contention() {
2304 // First put_if_absent says "exists", but head returns NotFound
2305 // (race: another client released between the calls). We must
2306 // surface contention, not error.
2307 use crate::object_store::mock::Fault;
2308 let store = MockStore::new();
2309 store.insert("k", Bytes::new());
2310 store.arm(Fault::NotFoundOnHead { key: "k".into() });
2311 let arc = Arc::new(store);
2312 let now = OffsetDateTime::now_utc();
2313 let guard = acquire_lock(
2314 Arc::clone(&arc) as Arc<dyn ObjectStore>,
2315 "k",
2316 Duration::seconds(60),
2317 now,
2318 )
2319 .await
2320 .unwrap();
2321 assert!(guard.is_none(), "expected contention on disappeared lock");
2322 // Confirm head() was actually called — a regression that skipped
2323 // the staleness branch and returned None directly would also
2324 // satisfy the assertion above. The fault firing proves head ran.
2325 assert_eq!(arc.pending_faults(), 0);
2326 }
2327
2328 #[tokio::test]
2329 async fn release_lock_deletes_existing_key() {
2330 let store = Arc::new(MockStore::new());
2331 let now = OffsetDateTime::now_utc();
2332 let guard = acquire_lock(
2333 Arc::clone(&store) as Arc<dyn ObjectStore>,
2334 "k",
2335 Duration::seconds(60),
2336 now,
2337 )
2338 .await
2339 .unwrap()
2340 .expect("acquire_lock must succeed on an empty store");
2341 release_lock(guard).await.unwrap();
2342 assert!(!store.contains("k"));
2343 }
2344
2345 #[tokio::test]
2346 async fn release_lock_swallows_not_found_when_lock_already_gone() {
2347 // Acquire a lock, then delete the key out-of-band before
2348 // release_lock runs. The release must map NotFound → Ok(()).
2349 let store = Arc::new(MockStore::new());
2350 let now = OffsetDateTime::now_utc();
2351 let guard = acquire_lock(
2352 Arc::clone(&store) as Arc<dyn ObjectStore>,
2353 "k",
2354 Duration::seconds(60),
2355 now,
2356 )
2357 .await
2358 .unwrap()
2359 .expect("acquire_lock must succeed");
2360 // Cancel the heartbeat first so it cannot race the manual
2361 // delete and re-create the key.
2362 guard.heartbeat.as_ref().unwrap().abort();
2363 // Give the abort a chance to take effect, then remove the key.
2364 tokio::task::yield_now().await;
2365 let _ = store.delete("k").await;
2366 release_lock(guard).await.unwrap();
2367 }
2368
2369 #[tokio::test]
2370 async fn release_lock_propagates_non_not_found_errors() {
2371 use crate::object_store::mock::Fault;
2372 let store = Arc::new(MockStore::new());
2373 let now = OffsetDateTime::now_utc();
2374 let guard = acquire_lock(
2375 Arc::clone(&store) as Arc<dyn ObjectStore>,
2376 "k",
2377 Duration::seconds(60),
2378 now,
2379 )
2380 .await
2381 .unwrap()
2382 .expect("acquire_lock must succeed");
2383 store.arm(Fault::NetworkOnDelete { key: "k".into() });
2384 let err = release_lock(guard).await.unwrap_err();
2385 assert!(
2386 matches!(err, ObjectStoreError::Network(_)),
2387 "expected Network error, got {err:?}",
2388 );
2389 // The fault fired exactly once.
2390 assert_eq!(store.pending_faults(), 0);
2391 // Key remains because the delete was faulted, not executed.
2392 assert!(store.contains("k"));
2393 }
2394
2395 /// Issue #118: a long-running critical section must not lose its
2396 /// lock. The heartbeat refreshes `last_modified` faster than the
2397 /// TTL expires, so a concurrent acquire after the original TTL
2398 /// elapses still sees a live lock and returns `None` (contention).
2399 #[tokio::test(start_paused = true)]
2400 async fn heartbeat_keeps_lock_alive_past_ttl() {
2401 let store = Arc::new(MockStore::new());
2402 let now = OffsetDateTime::now_utc();
2403 // TTL is 4 s → heartbeat fires every 2 s (see
2404 // `heartbeat_interval`). Run the test for ~10 s of virtual
2405 // time so a regression that disabled the heartbeat would have
2406 // multiple TTLs to expire under.
2407 let ttl = Duration::seconds(4);
2408 let guard = acquire_lock(Arc::clone(&store) as Arc<dyn ObjectStore>, "k", ttl, now)
2409 .await
2410 .unwrap()
2411 .expect("acquire must succeed");
2412
2413 // Advance the clock past several TTLs, letting the heartbeat
2414 // task fire each time.
2415 for _ in 0..5 {
2416 tokio::time::advance(std::time::Duration::from_secs(3)).await;
2417 // Yield so the spawned heartbeat task can take its turn
2418 // on the runtime and PUT the lock key.
2419 tokio::task::yield_now().await;
2420 }
2421
2422 // A concurrent acquire would see `last_modified` recent
2423 // (heartbeat just refreshed it), so it should report
2424 // contention — not steal the lock as stale. We use a `now`
2425 // far in the future (matches the wall-clock view a second
2426 // process would have) but rely on the heartbeat having
2427 // overwritten `last_modified` to the runtime "now".
2428 let future = OffsetDateTime::now_utc();
2429 let other = acquire_lock(Arc::clone(&store) as Arc<dyn ObjectStore>, "k", ttl, future)
2430 .await
2431 .unwrap();
2432 assert!(
2433 other.is_none(),
2434 "live lock must not be stealable while the holder's heartbeat runs",
2435 );
2436
2437 release_lock(guard).await.unwrap();
2438 assert!(!store.contains("k"));
2439 }
2440
2441 /// Releasing the guard must stop the heartbeat so no further PUTs
2442 /// hit the lock key after release. We assert by deleting the key
2443 /// post-release and confirming it stays gone.
2444 #[tokio::test(start_paused = true)]
2445 async fn release_lock_stops_heartbeat() {
2446 let store = Arc::new(MockStore::new());
2447 let now = OffsetDateTime::now_utc();
2448 let ttl = Duration::seconds(4);
2449 let guard = acquire_lock(Arc::clone(&store) as Arc<dyn ObjectStore>, "k", ttl, now)
2450 .await
2451 .unwrap()
2452 .expect("acquire must succeed");
2453 release_lock(guard).await.unwrap();
2454 assert!(!store.contains("k"));
2455
2456 // Advance well past multiple heartbeat intervals. A
2457 // regression that forgot to abort the task would re-create
2458 // the key via put_bytes.
2459 for _ in 0..5 {
2460 tokio::time::advance(std::time::Duration::from_secs(3)).await;
2461 tokio::task::yield_now().await;
2462 }
2463 assert!(
2464 !store.contains("k"),
2465 "heartbeat must not re-create the key after release",
2466 );
2467 }
2468
2469 /// Dropping the guard without calling `release_lock` aborts the
2470 /// heartbeat (so the lock becomes stealable after TTL) and leaves
2471 /// the lock key in place (a future caller's stale-recovery path
2472 /// reclaims it).
2473 #[tokio::test(start_paused = true)]
2474 async fn lock_guard_drop_aborts_heartbeat() {
2475 let store = Arc::new(MockStore::new());
2476 let now = OffsetDateTime::now_utc();
2477 let ttl = Duration::seconds(4);
2478 let guard = acquire_lock(Arc::clone(&store) as Arc<dyn ObjectStore>, "k", ttl, now)
2479 .await
2480 .unwrap()
2481 .expect("acquire must succeed");
2482 drop(guard);
2483
2484 // Capture the lock's last_modified right after drop —
2485 // heartbeats after this point would advance it. `head` is the
2486 // trait-level path to last_modified and avoids a test-only
2487 // accessor on MockStore.
2488 let after_drop = store.head("k").await.expect("lock present").last_modified;
2489
2490 // Advance time past multiple heartbeat intervals.
2491 for _ in 0..5 {
2492 tokio::time::advance(std::time::Duration::from_secs(3)).await;
2493 tokio::task::yield_now().await;
2494 }
2495
2496 let after_advance = store
2497 .head("k")
2498 .await
2499 .expect("lock still present")
2500 .last_modified;
2501 assert_eq!(
2502 after_drop, after_advance,
2503 "heartbeat must not refresh last_modified after drop",
2504 );
2505
2506 // And the lock is now stealable via the stale path: an acquire
2507 // with a `now` past TTL deletes the orphaned lock and reclaims.
2508 let future = now + Duration::seconds(120);
2509 let recovered = acquire_lock(Arc::clone(&store) as Arc<dyn ObjectStore>, "k", ttl, future)
2510 .await
2511 .unwrap();
2512 assert!(recovered.is_some(), "orphaned lock must be reclaimable");
2513 drop(recovered);
2514 }
2515
2516 /// Issue #150 regression: a heartbeat `put_bytes` already in
2517 /// flight when `release` is called must complete BEFORE the
2518 /// release issues its DELETE. The pre-fix code did
2519 /// `handle.abort(); delete`, which only cancelled the future at
2520 /// its next await point — an in-flight network PUT continued on
2521 /// the server and could settle AFTER the DELETE, resurrecting the
2522 /// lock key as an orphan.
2523 ///
2524 /// The test wraps the mock store in a barrier-gated `put_bytes`
2525 /// (the first PUT records its start, then waits on a notify) so
2526 /// the heartbeat tick lands in a state where the PUT has been
2527 /// issued but not yet completed when `release` runs. The
2528 /// post-condition is the sequence of recorded operations: the
2529 /// in-flight PUT's completion must precede the DELETE's start.
2530 /// Expected sequence is derived from the invariant in the
2531 /// `LockGuard::release` doc comment (the spec), not from the
2532 /// code's current output (lesson #5).
2533 // The test body inlines a small `ObjectStore` decorator
2534 // (`GatedPutStore`) so the trait wiring inflates the line count
2535 // past clippy's default budget. Splitting the decorator into a
2536 // sibling helper would obscure the test's single behaviour
2537 // contract, so we accept the lint locally.
2538 #[allow(clippy::too_many_lines)]
2539 #[tokio::test(start_paused = true)]
2540 async fn release_awaits_in_flight_heartbeat_put_before_delete() {
2541 use std::sync::Mutex;
2542 use tokio::sync::Notify;
2543
2544 /// Op-log entry recording the relative order of `put_bytes`
2545 /// and `delete` events. The release contract requires
2546 /// `PutEnd` to precede `DeleteStart`.
2547 #[derive(Debug, PartialEq, Eq, Clone, Copy)]
2548 enum Op {
2549 PutStart,
2550 PutEnd,
2551 DeleteStart,
2552 DeleteEnd,
2553 }
2554
2555 /// Decorator that gates the FIRST `put_bytes` on a notify
2556 /// barrier. Subsequent `put_bytes` calls pass through (they
2557 /// should not happen — once we hold the gate, release should
2558 /// stop the heartbeat before another tick fires).
2559 struct GatedPutStore {
2560 inner: Arc<MockStore>,
2561 put_gate: Arc<Notify>,
2562 log: Arc<Mutex<Vec<Op>>>,
2563 gated_key: String,
2564 gate_consumed: std::sync::atomic::AtomicBool,
2565 }
2566
2567 #[async_trait::async_trait]
2568 impl ObjectStore for GatedPutStore {
2569 async fn list(&self, prefix: &str) -> Result<Vec<ObjectMeta>, ObjectStoreError> {
2570 self.inner.list(prefix).await
2571 }
2572 async fn get_to_file(
2573 &self,
2574 key: &str,
2575 dest: &std::path::Path,
2576 opts: crate::object_store::GetOpts,
2577 ) -> Result<(), ObjectStoreError> {
2578 self.inner.get_to_file(key, dest, opts).await
2579 }
2580 async fn get_bytes(&self, key: &str) -> Result<Bytes, ObjectStoreError> {
2581 self.inner.get_bytes(key).await
2582 }
2583 async fn get_bytes_range(
2584 &self,
2585 key: &str,
2586 range: std::ops::Range<u64>,
2587 ) -> Result<Bytes, ObjectStoreError> {
2588 self.inner.get_bytes_range(key, range).await
2589 }
2590 async fn put_bytes(
2591 &self,
2592 key: &str,
2593 body: Bytes,
2594 opts: PutOpts,
2595 ) -> Result<(), ObjectStoreError> {
2596 let is_gated = key == self.gated_key
2597 && !self
2598 .gate_consumed
2599 .swap(true, std::sync::atomic::Ordering::SeqCst);
2600 if is_gated {
2601 self.log.lock().unwrap().push(Op::PutStart);
2602 self.put_gate.notified().await;
2603 }
2604 let result = self.inner.put_bytes(key, body, opts).await;
2605 if is_gated {
2606 self.log.lock().unwrap().push(Op::PutEnd);
2607 }
2608 result
2609 }
2610 async fn put_path(
2611 &self,
2612 key: &str,
2613 src: &std::path::Path,
2614 opts: PutOpts,
2615 ) -> Result<(), ObjectStoreError> {
2616 self.inner.put_path(key, src, opts).await
2617 }
2618 async fn put_if_absent(
2619 &self,
2620 key: &str,
2621 body: Bytes,
2622 ) -> Result<bool, ObjectStoreError> {
2623 self.inner.put_if_absent(key, body).await
2624 }
2625 async fn head(&self, key: &str) -> Result<ObjectMeta, ObjectStoreError> {
2626 self.inner.head(key).await
2627 }
2628 async fn copy(&self, src: &str, dst: &str) -> Result<(), ObjectStoreError> {
2629 self.inner.copy(src, dst).await
2630 }
2631 async fn delete(&self, key: &str) -> Result<(), ObjectStoreError> {
2632 self.log.lock().unwrap().push(Op::DeleteStart);
2633 let result = self.inner.delete(key).await;
2634 self.log.lock().unwrap().push(Op::DeleteEnd);
2635 result
2636 }
2637 }
2638
2639 let inner = Arc::new(MockStore::new());
2640 let put_gate = Arc::new(Notify::new());
2641 let log = Arc::new(Mutex::new(Vec::<Op>::new()));
2642 let store = Arc::new(GatedPutStore {
2643 inner: Arc::clone(&inner),
2644 put_gate: Arc::clone(&put_gate),
2645 log: Arc::clone(&log),
2646 gated_key: "k".to_owned(),
2647 gate_consumed: std::sync::atomic::AtomicBool::new(false),
2648 });
2649
2650 let now = OffsetDateTime::now_utc();
2651 let ttl = Duration::seconds(4);
2652 let guard = acquire_lock(Arc::clone(&store) as Arc<dyn ObjectStore>, "k", ttl, now)
2653 .await
2654 .unwrap()
2655 .expect("acquire must succeed");
2656
2657 // Yield so the heartbeat task is polled and consumes its
2658 // immediate (zero-duration) first `tick.tick()`. Without this
2659 // yield, the next `advance` would fire the immediate tick
2660 // first and then the periodic tick, but the task still
2661 // wouldn't be polled until the test yields again — we want
2662 // to be inside the loop body before advancing time.
2663 for _ in 0..4 {
2664 tokio::task::yield_now().await;
2665 }
2666
2667 // Drive one heartbeat tick. The heartbeat task issues
2668 // `put_bytes`, which the gate intercepts and parks before the
2669 // inner write completes. The interval is `ttl/3 = 1s` so any
2670 // advance >= 1s is sufficient.
2671 tokio::time::advance(std::time::Duration::from_secs(2)).await;
2672 // Yield repeatedly so the spawned heartbeat task is polled
2673 // and reaches the gate.
2674 for _ in 0..16 {
2675 tokio::task::yield_now().await;
2676 if !log.lock().unwrap().is_empty() {
2677 break;
2678 }
2679 }
2680 assert_eq!(
2681 log.lock().unwrap().as_slice(),
2682 &[Op::PutStart],
2683 "heartbeat PUT must be in flight before release fires",
2684 );
2685
2686 // Begin release on a separate task so we can observe the
2687 // ordering: release blocks on `stop_heartbeat`'s join-await,
2688 // which can only complete after the gated PUT finishes.
2689 let release_store = Arc::clone(&store);
2690 let release_handle = tokio::spawn(async move {
2691 let _ = release_store; // borrow check: keep store alive
2692 release_lock(guard).await
2693 });
2694
2695 // Let the release task get as far as it can — up to the
2696 // join-await on the heartbeat task.
2697 for _ in 0..8 {
2698 tokio::task::yield_now().await;
2699 }
2700 // No DELETE may have fired yet: the heartbeat task is still
2701 // inside the gated `put_bytes`.
2702 assert_eq!(
2703 log.lock().unwrap().as_slice(),
2704 &[Op::PutStart],
2705 "release must NOT issue DELETE while heartbeat PUT is in flight",
2706 );
2707
2708 // Open the gate: the in-flight PUT completes, the heartbeat
2709 // task exits the loop on its next shutdown re-check, and
2710 // release proceeds to DELETE.
2711 put_gate.notify_one();
2712 let result = release_handle.await.expect("release task panicked");
2713 result.expect("release_lock");
2714
2715 let final_log = log.lock().unwrap().clone();
2716 assert_eq!(
2717 final_log,
2718 vec![Op::PutStart, Op::PutEnd, Op::DeleteStart, Op::DeleteEnd],
2719 "operation order must be: heartbeat PUT completes, THEN release DELETE",
2720 );
2721 assert!(
2722 !inner.contains("k"),
2723 "lock key must be deleted after release"
2724 );
2725 }
2726
2727 // --- delete_remote_ref_under_lock ---------------------------------
2728
2729 #[tokio::test]
2730 async fn delete_remote_ref_removes_single_bundle() {
2731 let store = MockStore::new();
2732 let r = rn("refs/heads/main");
2733 store.insert(
2734 format!("repo/refs/heads/main/{SHA}.bundle"),
2735 Bytes::from_static(b"b"),
2736 );
2737 let outcome = delete_remote_ref_under_lock(
2738 &store,
2739 Some("repo"),
2740 &r,
2741 "repo/refs/heads/main/LOCK#.lock",
2742 )
2743 .await
2744 .unwrap();
2745 assert_eq!(
2746 outcome,
2747 PushOutcome::Ok {
2748 remote_ref: "refs/heads/main".into()
2749 }
2750 );
2751 // Prefix-empty oracle (lesson 15): no key survives under the ref
2752 // prefix after a successful delete. Stronger than `!contains(bundle)`
2753 // because a regression that wrote a tombstone or other residue
2754 // (e.g. a re-introduction of c5468b4's bundle-engine tombstone)
2755 // would also trip it.
2756 let remaining: Vec<_> = store
2757 .keys()
2758 .into_iter()
2759 .filter(|k| k.starts_with("repo/refs/heads/main/"))
2760 .collect();
2761 assert!(
2762 remaining.is_empty(),
2763 "ref prefix must be empty after delete: {remaining:?}",
2764 );
2765 // Bundle-engine delete must not write a tombstone — tombstoning
2766 // is a packchain-only deferral (#143, #203).
2767 let gc_keys: Vec<_> = store
2768 .keys()
2769 .into_iter()
2770 .filter(|k| k.starts_with("repo/gc/"))
2771 .collect();
2772 assert!(
2773 gc_keys.is_empty(),
2774 "bundle-engine delete must not write a tombstone: {gc_keys:?}",
2775 );
2776 }
2777
2778 #[tokio::test]
2779 async fn delete_remote_ref_returns_not_found_when_empty() {
2780 let store = MockStore::new();
2781 let r = rn("refs/heads/main");
2782 let outcome = delete_remote_ref_under_lock(
2783 &store,
2784 Some("repo"),
2785 &r,
2786 "repo/refs/heads/main/LOCK#.lock",
2787 )
2788 .await
2789 .unwrap();
2790 match outcome {
2791 PushOutcome::Error { message, .. } => {
2792 assert_eq!(message, r#""not found"?"#);
2793 }
2794 PushOutcome::Ok { .. } => panic!("expected Error outcome"),
2795 }
2796 }
2797
2798 #[tokio::test]
2799 async fn delete_remote_ref_rejects_protected_marker() {
2800 // PROTECTED# is unfiltered for the delete-path count, but we
2801 // detect the marker before the generic multi-bundle error and
2802 // emit a protection-specific refusal that names `unprotect`.
2803 let store = MockStore::new();
2804 let r = rn("refs/heads/main");
2805 let bundle = format!("repo/refs/heads/main/{SHA}.bundle");
2806 let protected = "repo/refs/heads/main/PROTECTED#";
2807 store.insert(&bundle, Bytes::from_static(b"b"));
2808 store.insert(protected, Bytes::from_static(b""));
2809 let outcome = delete_remote_ref_under_lock(
2810 &store,
2811 Some("repo"),
2812 &r,
2813 "repo/refs/heads/main/LOCK#.lock",
2814 )
2815 .await
2816 .unwrap();
2817 match outcome {
2818 PushOutcome::Error { message, .. } => {
2819 assert_eq!(
2820 message,
2821 r#""ref is protected. Run git-remote-object-store unprotect <url> <branch> to remove protection before deleting."?"#,
2822 );
2823 }
2824 PushOutcome::Ok { .. } => panic!("expected Error outcome"),
2825 }
2826 // Both keys must remain — a regression that deleted on the way
2827 // to the error branch would still satisfy the message check.
2828 assert!(store.contains(&bundle));
2829 assert!(store.contains(protected));
2830 }
2831
2832 #[tokio::test]
2833 async fn delete_remote_ref_reports_corruption_without_protected_marker() {
2834 // Two bundles, no PROTECTED# marker → genuine corruption case
2835 // still falls through to the doctor message.
2836 let store = MockStore::new();
2837 let r = rn("refs/heads/main");
2838 let bundle_a = format!("repo/refs/heads/main/{SHA}.bundle");
2839 let bundle_b = format!("repo/refs/heads/main/{OTHER_SHA}.bundle");
2840 store.insert(&bundle_a, Bytes::from_static(b"a"));
2841 store.insert(&bundle_b, Bytes::from_static(b"b"));
2842 let outcome = delete_remote_ref_under_lock(
2843 &store,
2844 Some("repo"),
2845 &r,
2846 "repo/refs/heads/main/LOCK#.lock",
2847 )
2848 .await
2849 .unwrap();
2850 match outcome {
2851 PushOutcome::Error { message, .. } => {
2852 assert_eq!(
2853 message,
2854 r#""multiple bundles exist on server. Run git-remote-object-store doctor to fix."?"#,
2855 );
2856 }
2857 PushOutcome::Ok { .. } => panic!("expected Error outcome"),
2858 }
2859 assert!(store.contains(&bundle_a));
2860 assert!(store.contains(&bundle_b));
2861 }
2862
2863 /// Issue #128 / #242: the canonical PROTECTED# guard must reject
2864 /// before any sweep, even when a bundle rides alongside the marker.
2865 /// The guard runs first against the unfiltered under-lock listing, so
2866 /// a `[bundle, PROTECTED#]` listing surfaces the protection refusal
2867 /// rather than sweeping the marker — independent of the connection's
2868 /// `zip` flag.
2869 #[tokio::test]
2870 async fn delete_remote_ref_rejects_protected_marker_alongside_bundle() {
2871 let store = MockStore::new();
2872 let r = rn("refs/heads/main");
2873 let bundle = format!("repo/refs/heads/main/{SHA}.bundle");
2874 let protected = "repo/refs/heads/main/PROTECTED#";
2875 store.insert(&bundle, Bytes::from_static(b"b"));
2876 store.insert(protected, Bytes::from_static(b""));
2877 let outcome = delete_remote_ref_under_lock(
2878 &store,
2879 Some("repo"),
2880 &r,
2881 "repo/refs/heads/main/LOCK#.lock",
2882 )
2883 .await
2884 .unwrap();
2885 match outcome {
2886 PushOutcome::Error { message, .. } => {
2887 assert_eq!(
2888 message,
2889 r#""ref is protected. Run git-remote-object-store unprotect <url> <branch> to remove protection before deleting."?"#,
2890 );
2891 }
2892 PushOutcome::Ok { .. } => panic!("expected protection-refusal Error"),
2893 }
2894 assert!(store.contains(&bundle), "bundle must survive");
2895 assert!(store.contains(protected), "marker must survive");
2896 }
2897
2898 /// Issue #128: the protection guard must also fire when the lone
2899 /// remaining key under the ref prefix is the PROTECTED# marker
2900 /// itself. Pre-#128, `entries == [PROTECTED#]` matched `expected = 1`
2901 /// in non-zip mode and the marker was swept — the next push to the
2902 /// ref would then succeed against an unprotected branch even though
2903 /// the operator never ran `unprotect`. Pin the guard: the marker
2904 /// survives and the wire error is the protection-specific message.
2905 #[tokio::test]
2906 async fn delete_remote_ref_rejects_protected_marker_when_only_marker_present() {
2907 let store = MockStore::new();
2908 let r = rn("refs/heads/main");
2909 let protected = "repo/refs/heads/main/PROTECTED#";
2910 store.insert(protected, Bytes::from_static(b""));
2911 let outcome = delete_remote_ref_under_lock(
2912 &store,
2913 Some("repo"),
2914 &r,
2915 "repo/refs/heads/main/LOCK#.lock",
2916 )
2917 .await
2918 .unwrap();
2919 match outcome {
2920 PushOutcome::Error { message, .. } => {
2921 assert_eq!(
2922 message,
2923 r#""ref is protected. Run git-remote-object-store unprotect <url> <branch> to remove protection before deleting."?"#,
2924 );
2925 }
2926 PushOutcome::Ok { .. } => panic!("expected protection-refusal Error"),
2927 }
2928 assert!(store.contains(protected), "marker must survive");
2929 }
2930
2931 /// Issue #128: simulate the TOCTOU sequence the bug describes.
2932 /// Client A starts a delete; between `acquire_lock` and the
2933 /// under-lock listing, a marker appears at PROTECTED#. After #159
2934 /// `protect` itself acquires the same lock and cannot race here,
2935 /// but the under-lock listing remains the canonical guard against
2936 /// any other source of a same-key marker (a lock-bypass bug, a
2937 /// non-cooperating client). The fresh under-lock listing reflects
2938 /// the marker, so the canonical guard rejects the delete. Both the
2939 /// bundle and the marker survive.
2940 #[tokio::test]
2941 async fn delete_remote_ref_rejects_protect_landed_between_acquire_and_list() {
2942 let store = MockStore::new();
2943 let r = rn("refs/heads/main");
2944 let bundle = format!("repo/refs/heads/main/{SHA}.bundle");
2945 let lock_key = "repo/refs/heads/main/LOCK#.lock";
2946 let protected = "repo/refs/heads/main/PROTECTED#";
2947 // Client A acquired the lock; bundle was already present.
2948 store.insert(&bundle, Bytes::from_static(b"b"));
2949 store.insert(lock_key, Bytes::from_static(b"held-lock-payload"));
2950 // A PROTECTED# marker appears AFTER our lock-acquire but BEFORE
2951 // our under-lock listing — exactly the window the pre-#128
2952 // ordering left open. Post-#159 `protect` cannot reach this
2953 // window itself, but the under-lock listing must still catch
2954 // markers from any other source (lock-bypass bug, non-cooperating
2955 // client).
2956 store.insert(protected, Bytes::from_static(b""));
2957
2958 let outcome = delete_remote_ref_under_lock(&store, Some("repo"), &r, lock_key)
2959 .await
2960 .unwrap();
2961
2962 match outcome {
2963 PushOutcome::Error { message, .. } => {
2964 assert_eq!(
2965 message,
2966 r#""ref is protected. Run git-remote-object-store unprotect <url> <branch> to remove protection before deleting."?"#,
2967 );
2968 }
2969 PushOutcome::Ok { .. } => panic!("expected protection-refusal Error"),
2970 }
2971 assert!(store.contains(&bundle), "bundle must survive");
2972 assert!(store.contains(protected), "marker must survive");
2973 assert!(store.contains(lock_key), "held lock must survive");
2974 }
2975
2976 /// Issue #242: a single bundle with a `repo.zip` sibling sweeps both
2977 /// and reports `ok`. Deletability is decided by entry shape, so this
2978 /// holds regardless of the connection's `zip` flag.
2979 #[tokio::test]
2980 async fn delete_remote_ref_sweeps_bundle_and_zip_sibling() {
2981 let store = MockStore::new();
2982 let r = rn("refs/heads/main");
2983 let bundle = format!("repo/refs/heads/main/{SHA}.bundle");
2984 let zip = "repo/refs/heads/main/repo.zip";
2985 store.insert(&bundle, Bytes::from_static(b"b"));
2986 store.insert(zip, Bytes::from_static(b""));
2987 let outcome = delete_remote_ref_under_lock(
2988 &store,
2989 Some("repo"),
2990 &r,
2991 "repo/refs/heads/main/LOCK#.lock",
2992 )
2993 .await
2994 .unwrap();
2995 assert_eq!(
2996 outcome,
2997 PushOutcome::Ok {
2998 remote_ref: "refs/heads/main".into()
2999 }
3000 );
3001 assert!(!store.contains(&bundle));
3002 assert!(!store.contains(zip));
3003 }
3004
3005 /// Issue #242 regression: a ref whose only on-server state is a
3006 /// single `<sha>.bundle` (no `repo.zip` sibling) must delete cleanly
3007 /// even though the push connection was opened with `?zip=1`. The
3008 /// `repo.zip` upload is best-effort, so a zip-mode ref can legitimately
3009 /// carry only its bundle. Pre-#242 the count-vs-`zip`-flag assertion
3010 /// (`entries.len() == 2`) mis-routed this `len == 1` listing to the
3011 /// "multiple bundles exist" corruption branch. The fix routes on the
3012 /// bundle count, so the lone bundle is swept and `ok` is reported.
3013 #[tokio::test]
3014 async fn delete_remote_ref_zip_mode_with_only_bundle_sweeps_and_oks() {
3015 let store = MockStore::new();
3016 let r = rn("refs/heads/main");
3017 let bundle = format!("repo/refs/heads/main/{SHA}.bundle");
3018 store.insert(&bundle, Bytes::from_static(b"b"));
3019 // The connection's `zip` flag is no longer an input to the delete
3020 // path — this listing is exactly what a `?zip=1` ref looks like
3021 // after a swallowed `repo.zip` upload failure.
3022 let outcome = delete_remote_ref_under_lock(
3023 &store,
3024 Some("repo"),
3025 &r,
3026 "repo/refs/heads/main/LOCK#.lock",
3027 )
3028 .await
3029 .unwrap();
3030 assert_eq!(
3031 outcome,
3032 PushOutcome::Ok {
3033 remote_ref: "refs/heads/main".into()
3034 }
3035 );
3036 assert!(!store.contains(&bundle), "bundle must be swept");
3037 }
3038
3039 /// Issue #242: a ref carrying only an orphaned `repo.zip` and no
3040 /// `<sha>.bundle` has zero bundles, so the delete reports `not found`
3041 /// rather than `ok`. The orphan archive is left untouched — sweeping a
3042 /// sibling with no owning bundle would be deleting state the delete
3043 /// path cannot attribute to this ref's lifecycle.
3044 #[tokio::test]
3045 async fn delete_remote_ref_with_only_orphan_zip_reports_not_found() {
3046 let store = MockStore::new();
3047 let r = rn("refs/heads/main");
3048 let zip = "repo/refs/heads/main/repo.zip";
3049 store.insert(zip, Bytes::from_static(b""));
3050 let outcome = delete_remote_ref_under_lock(
3051 &store,
3052 Some("repo"),
3053 &r,
3054 "repo/refs/heads/main/LOCK#.lock",
3055 )
3056 .await
3057 .unwrap();
3058 match outcome {
3059 PushOutcome::Error { message, .. } => assert_eq!(message, r#""not found"?"#),
3060 PushOutcome::Ok { .. } => panic!("expected not-found Error outcome"),
3061 }
3062 assert!(
3063 store.contains(zip),
3064 "orphan zip must survive a no-bundle delete",
3065 );
3066 }
3067
3068 /// Issue #242: two distinct `<sha>.bundle` keys still trip the
3069 /// multi-bundle corruption guard even when a `repo.zip` sibling is
3070 /// also present — the sibling does not mask the corruption, and
3071 /// neither bundle is swept. (That the sibling is not itself counted
3072 /// as a bundle is pinned at the 0-/1-bundle boundary by
3073 /// `delete_remote_ref_with_only_orphan_zip_reports_not_found`, where
3074 /// miscounting would flip the outcome; at this 2-bundle boundary it
3075 /// could not.)
3076 #[tokio::test]
3077 async fn delete_remote_ref_two_bundles_with_zip_sibling_reports_corruption() {
3078 let store = MockStore::new();
3079 let r = rn("refs/heads/main");
3080 let bundle_a = format!("repo/refs/heads/main/{SHA}.bundle");
3081 let bundle_b = format!("repo/refs/heads/main/{OTHER_SHA}.bundle");
3082 let zip = "repo/refs/heads/main/repo.zip";
3083 store.insert(&bundle_a, Bytes::from_static(b"a"));
3084 store.insert(&bundle_b, Bytes::from_static(b"b"));
3085 store.insert(zip, Bytes::from_static(b""));
3086 let outcome = delete_remote_ref_under_lock(
3087 &store,
3088 Some("repo"),
3089 &r,
3090 "repo/refs/heads/main/LOCK#.lock",
3091 )
3092 .await
3093 .unwrap();
3094 match outcome {
3095 PushOutcome::Error { message, .. } => assert_eq!(
3096 message,
3097 r#""multiple bundles exist on server. Run git-remote-object-store doctor to fix."?"#,
3098 ),
3099 PushOutcome::Ok { .. } => panic!("expected corruption Error outcome"),
3100 }
3101 assert!(store.contains(&bundle_a), "bundle a must survive");
3102 assert!(store.contains(&bundle_b), "bundle b must survive");
3103 assert!(store.contains(zip), "zip sibling must survive");
3104 }
3105
3106 // --- PushOutcome rendering ----------------------------------------
3107
3108 #[test]
3109 fn push_outcome_renders_ok_line() {
3110 let line = PushOutcome::Ok {
3111 remote_ref: "refs/heads/main".into(),
3112 }
3113 .to_protocol_line();
3114 assert_eq!(line, "ok refs/heads/main\n");
3115 }
3116
3117 #[test]
3118 fn push_outcome_renders_error_line() {
3119 let line = PushOutcome::Error {
3120 remote_ref: "refs/heads/main".into(),
3121 message: r#""bad"?"#.into(),
3122 }
3123 .to_protocol_line();
3124 assert_eq!(line, "error refs/heads/main \"bad\"?\n");
3125 }
3126
3127 /// Both duplicate-bundle paths (pre-lock at ~line 482 and under-lock
3128 /// at ~line 600) must produce wire output ending in `"?\n`. The `?`
3129 /// suffix is the project-wide Rust convention for `error <ref> "..."`
3130 /// messages — git treats `"..."?` as recoverable and `"..."` as
3131 /// fatal. Both branches normalize to the recoverable form.
3132 #[test]
3133 fn duplicate_bundle_errors_use_consistent_wire_format() {
3134 let pre_lock_line = PushOutcome::Error {
3135 remote_ref: "refs/heads/main".into(),
3136 message:
3137 r#""multiple bundles exist on server. Run git-remote-object-store doctor to fix."?"#
3138 .to_owned(),
3139 }
3140 .to_protocol_line();
3141 let under_lock_line = PushOutcome::Error {
3142 remote_ref: "refs/heads/main".into(),
3143 message: r#""multiple bundles exist for the same ref on server. Run git-remote-object-store doctor to fix."?"#.to_owned(),
3144 }
3145 .to_protocol_line();
3146
3147 assert_eq!(
3148 pre_lock_line,
3149 "error refs/heads/main \"multiple bundles exist on server. \
3150 Run git-remote-object-store doctor to fix.\"?\n",
3151 );
3152 assert_eq!(
3153 under_lock_line,
3154 "error refs/heads/main \"multiple bundles exist for the same ref on server. \
3155 Run git-remote-object-store doctor to fix.\"?\n",
3156 );
3157 assert!(pre_lock_line.ends_with("\"?\n"));
3158 assert!(under_lock_line.ends_with("\"?\n"));
3159 }
3160
3161 // --- lock_ttl_from_env --------------------------------------------
3162
3163 #[test]
3164 fn lock_ttl_env_override_falls_back_for_unset_invalid_or_zero() {
3165 // Group all env-var cases in one test fn so they share a single
3166 // `EnvGuard` and its per-key lock — the var is process-global,
3167 // and the guard serialises against `manage::doctor`'s
3168 // env-touching test that pokes the same key. Drop restores the
3169 // prior value on every exit path, including assertion panics.
3170 let env = crate::test_util::EnvGuard::take(ENV_LOCK_TTL_SECONDS);
3171 let default_ttl = Duration::seconds(i64::try_from(DEFAULT_LOCK_TTL_SECONDS).unwrap());
3172 // Unset returns default.
3173 env.clear();
3174 assert_eq!(lock_ttl_from_env(), default_ttl);
3175 // `None` and `Some(0)` (issue #208) defer to env-or-default.
3176 assert_eq!(
3177 resolve_lock_ttl_seconds(None),
3178 DEFAULT_LOCK_TTL_SECONDS,
3179 "None must defer to env-or-default",
3180 );
3181 assert_eq!(
3182 resolve_lock_ttl_seconds(Some(0)),
3183 DEFAULT_LOCK_TTL_SECONDS,
3184 "Some(0) must not defeat per-ref locking (issue #208)",
3185 );
3186 // Non-numeric falls back.
3187 env.set_to("not-a-number");
3188 assert_eq!(lock_ttl_from_env(), default_ttl);
3189 // Zero falls back (would defeat per-ref locking).
3190 env.set_to("0");
3191 assert_eq!(lock_ttl_from_env(), default_ttl);
3192 // Positive integer wins.
3193 env.set_to("120");
3194 assert_eq!(lock_ttl_from_env(), Duration::seconds(120));
3195 // With env set, `None` and `Some(0)` honour the env override —
3196 // an operator's env var must still take effect when a CLI
3197 // consumer accidentally passes the wrong default.
3198 assert_eq!(
3199 resolve_lock_ttl_seconds(None),
3200 120,
3201 "None must honour env override",
3202 );
3203 assert_eq!(
3204 resolve_lock_ttl_seconds(Some(0)),
3205 120,
3206 "Some(0) must honour env override",
3207 );
3208 }
3209
3210 // --- saturating_duration_seconds (issue #221) ---------------------
3211
3212 #[test]
3213 fn saturating_duration_seconds_caps_at_i64_max() {
3214 // `u64::MAX` exceeds `i64::MAX` — the helper must saturate at
3215 // `i64::MAX` rather than panic on the `try_from`. This is the
3216 // ~292-billion-year sentinel ceiling shared by all TTL paths.
3217 assert_eq!(
3218 saturating_duration_seconds(u64::MAX),
3219 Duration::seconds(i64::MAX),
3220 );
3221 }
3222
3223 #[test]
3224 fn saturating_duration_seconds_passes_normal_value() {
3225 assert_eq!(saturating_duration_seconds(60), Duration::seconds(60));
3226 }
3227
3228 // --- resolve_lock_ttl_seconds (issue #208) ------------------------
3229 //
3230 // `Compact::run_into` used to accept `Some(0)` and feed it straight
3231 // into the engine, bypassing the `lock_ttl_from_env` zero-clamp
3232 // from #112 and defeating per-ref locking. The shared resolver
3233 // collapses both `None` and `Some(0)` onto the env-or-default path
3234 // so the lock-acquiring call site cannot re-introduce the footgun.
3235 // `Doctor::resolved_lock_ttl_seconds` deliberately does NOT route
3236 // through this resolver — doctor only compares lock ages and never
3237 // acquires a lock, so an operator-explicit `Some(0)` is a valid
3238 // "treat every lock as stale" request and is honoured.
3239
3240 #[test]
3241 fn resolve_lock_ttl_some_positive_returns_unchanged() {
3242 // Positive values bypass the env entirely and so are safe to
3243 // assert in parallel with the env-touching test below. Cover
3244 // the smallest valid value, a normal value, and the u64 ceiling
3245 // — the ceiling documents the deliberate decision to not impose
3246 // an upper bound: downstream `time::Duration::seconds` saturates
3247 // safely at `i64::MAX`.
3248 assert_eq!(resolve_lock_ttl_seconds(Some(1)), 1);
3249 assert_eq!(resolve_lock_ttl_seconds(Some(120)), 120);
3250 assert_eq!(resolve_lock_ttl_seconds(Some(u64::MAX)), u64::MAX);
3251 }
3252
3253 // --- FORMAT key write via perform_push_under_lock --------------------
3254
3255 /// Helper: run `perform_push_under_lock` against a temporary bundle file
3256 /// so we can assert on the resulting store state.
3257 async fn push_under_lock_with_bundle(
3258 store: &MockStore,
3259 prefix: Option<&str>,
3260 engine: StorageEngine,
3261 ) -> PushOutcome {
3262 let r = rn("refs/heads/main");
3263 let temp_dir = tempfile::Builder::new()
3264 .prefix("test_push_")
3265 .tempdir()
3266 .unwrap();
3267 let bundle_path = temp_dir.path().join("bundle");
3268 std::fs::write(&bundle_path, b"fake bundle").unwrap();
3269
3270 let state = PushReadyState {
3271 remote_ref: r,
3272 local_sha: Sha::from_hex(SHA).unwrap(),
3273 pre_existing: None,
3274 bundle_path,
3275 zip_artifacts: None,
3276 engine,
3277 force: false,
3278 pre_existing_was_ancestor: true,
3279 local_spec: "refs/heads/main".to_owned(),
3280 hidden_bundles: HashSet::new(),
3281 _temp_dir: temp_dir,
3282 };
3283
3284 perform_push_under_lock(store, prefix, BackendKind::S3, state)
3285 .await
3286 .unwrap()
3287 }
3288
3289 #[tokio::test]
3290 async fn perform_push_under_lock_writes_format_key_on_first_push() {
3291 let store = MockStore::new();
3292 let outcome =
3293 push_under_lock_with_bundle(&store, Some("repo"), StorageEngine::Bundle).await;
3294 assert!(
3295 matches!(outcome, PushOutcome::Ok { .. }),
3296 "expected Ok outcome"
3297 );
3298 assert!(
3299 store.contains("repo/FORMAT"),
3300 "FORMAT key must be written on the first push",
3301 );
3302 let content = store.get_bytes("repo/FORMAT").await.unwrap();
3303 assert_eq!(content.as_ref(), b"bundle");
3304 }
3305
3306 #[tokio::test]
3307 async fn perform_push_under_lock_writes_format_key_without_prefix() {
3308 let store = MockStore::new();
3309 let outcome = push_under_lock_with_bundle(&store, None, StorageEngine::Bundle).await;
3310 assert!(
3311 matches!(outcome, PushOutcome::Ok { .. }),
3312 "expected Ok outcome"
3313 );
3314 assert!(
3315 store.contains("FORMAT"),
3316 "FORMAT key must be written at root when no prefix",
3317 );
3318 let content = store.get_bytes("FORMAT").await.unwrap();
3319 assert_eq!(content.as_ref(), b"bundle");
3320 }
3321
3322 #[tokio::test]
3323 async fn perform_push_under_lock_format_key_is_idempotent() {
3324 // If FORMAT already exists (second push), put_if_absent is a no-op.
3325 // Pre-insert with a trailing newline so the original bytes differ from
3326 // what the push would write — a plain `put` would overwrite to
3327 // b"bundle", while put_if_absent must preserve b"bundle\n".
3328 let store = MockStore::new();
3329 store.insert("repo/FORMAT", Bytes::from_static(b"bundle\n"));
3330 let outcome =
3331 push_under_lock_with_bundle(&store, Some("repo"), StorageEngine::Bundle).await;
3332 assert!(
3333 matches!(outcome, PushOutcome::Ok { .. }),
3334 "expected Ok outcome"
3335 );
3336 // Original content preserved — put_if_absent did not overwrite.
3337 let content = store.get_bytes("repo/FORMAT").await.unwrap();
3338 assert_eq!(content.as_ref(), b"bundle\n");
3339 }
3340
3341 // --- stale-remote guard -------------------------------------------
3342
3343 /// Build a `PushReadyState` with a specific `pre_existing` key and a
3344 /// matching `bundle_path` on disk. The store is left in whatever state
3345 /// the caller configured (e.g. with a pre-seeded bundle key); the
3346 /// caller already knows which prefix it seeded under, so this helper
3347 /// does not need to take it.
3348 fn push_state_with_pre_existing(pre_existing: Option<String>) -> PushReadyState {
3349 let r = rn("refs/heads/main");
3350 let temp_dir = tempfile::Builder::new()
3351 .prefix("test_push_")
3352 .tempdir()
3353 .unwrap();
3354 let bundle_path = temp_dir.path().join("bundle");
3355 std::fs::write(&bundle_path, b"fake bundle").unwrap();
3356 PushReadyState {
3357 remote_ref: r,
3358 local_sha: Sha::from_hex(SHA).unwrap(),
3359 pre_existing,
3360 bundle_path,
3361 zip_artifacts: None,
3362 engine: StorageEngine::Bundle,
3363 force: false,
3364 pre_existing_was_ancestor: true,
3365 local_spec: "refs/heads/main".to_owned(),
3366 hidden_bundles: HashSet::new(),
3367 _temp_dir: temp_dir,
3368 }
3369 }
3370
3371 /// `pre_existing=None`, `current_key=Some(...)`: a concurrent push
3372 /// created a bundle after our pre-lock list but before we acquired
3373 /// the lock.
3374 ///
3375 /// The seeded `existing_key` deliberately uses a SHA distinct from
3376 /// the local push's `local_sha` (= `SHA`). If they matched, a
3377 /// regression that compared `current_key` against `bundle_dest`
3378 /// (the key derived from `local_sha`) instead of against
3379 /// `pre_existing` could pass for the wrong reason — the keys
3380 /// happen to align. With distinct SHAs the only way the test
3381 /// passes is the correct comparison: `pre_existing(None) !=
3382 /// current_key(Some)`.
3383 #[tokio::test]
3384 async fn perform_push_under_lock_rejects_none_to_some_stale_remote() {
3385 let store = MockStore::new();
3386 let existing_key = format!("repo/refs/heads/main/{OTHER_SHA}.bundle");
3387 store.insert(&existing_key, Bytes::from_static(b"old bundle"));
3388 let state = push_state_with_pre_existing(None);
3389 let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
3390 .await
3391 .unwrap();
3392 assert!(
3393 matches!(
3394 &outcome,
3395 PushOutcome::Error { message, .. }
3396 if message == r#""stale remote. Please fetch and retry."?"#
3397 ),
3398 "expected stale-remote error, got {outcome:?}",
3399 );
3400 }
3401
3402 /// `pre_existing=Some(key)`, `current_key=None`: the bundle was
3403 /// deleted between our pre-lock list and the lock acquisition (e.g.
3404 /// a concurrent `git push :<ref>` delete).
3405 #[tokio::test]
3406 async fn perform_push_under_lock_rejects_some_to_none_stale_remote() {
3407 let store = MockStore::new();
3408 let old_key = format!("repo/refs/heads/main/{SHA}.bundle");
3409 let state = push_state_with_pre_existing(Some(old_key.clone()));
3410 // Store is empty — the previously-seen bundle is gone.
3411 let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
3412 .await
3413 .unwrap();
3414 assert!(
3415 matches!(
3416 &outcome,
3417 PushOutcome::Error { message, .. }
3418 if message == r#""stale remote. Please fetch and retry."?"#
3419 ),
3420 "expected stale-remote error, got {outcome:?}",
3421 );
3422 }
3423
3424 /// `pre_existing=Some(key_a)`, `current_key=Some(key_b)` where
3425 /// `key_a != key_b`: a concurrent push replaced our bundle.
3426 #[tokio::test]
3427 async fn perform_push_under_lock_rejects_replaced_bundle_stale_remote() {
3428 let store = MockStore::new();
3429 let old_sha = SHA;
3430 let new_sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
3431 let old_key = format!("repo/refs/heads/main/{old_sha}.bundle");
3432 let new_key = format!("repo/refs/heads/main/{new_sha}.bundle");
3433 // Under-lock the store shows the *new* key.
3434 store.insert(&new_key, Bytes::from_static(b"new bundle"));
3435 let state = push_state_with_pre_existing(Some(old_key));
3436 let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
3437 .await
3438 .unwrap();
3439 assert!(
3440 matches!(
3441 &outcome,
3442 PushOutcome::Error { message, .. }
3443 if message == r#""stale remote. Please fetch and retry."?"#
3444 ),
3445 "expected stale-remote error, got {outcome:?}",
3446 );
3447 }
3448
3449 /// Under-lock re-listing sees two bundles for the same ref (two clients
3450 /// raced and both uploaded before either acquired the lock). Must return
3451 /// the under-lock duplicate-bundle error before reaching the stale-remote
3452 /// guard or the upload.
3453 #[tokio::test]
3454 async fn perform_push_under_lock_rejects_two_bundles_seen_under_lock() {
3455 let store = MockStore::new();
3456 let sha_a = "1111111111111111111111111111111111111111";
3457 let sha_b = "2222222222222222222222222222222222222222";
3458 store.insert(
3459 format!("repo/refs/heads/main/{sha_a}.bundle"),
3460 Bytes::from_static(b"bundle_a"),
3461 );
3462 store.insert(
3463 format!("repo/refs/heads/main/{sha_b}.bundle"),
3464 Bytes::from_static(b"bundle_b"),
3465 );
3466 // Pre-lock snapshot saw zero bundles; under-lock sees two.
3467 let state = push_state_with_pre_existing(None);
3468 let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
3469 .await
3470 .unwrap();
3471 assert!(
3472 matches!(
3473 &outcome,
3474 PushOutcome::Error { message, .. }
3475 if message == r#""multiple bundles exist for the same ref on server. Run git-remote-object-store doctor to fix."?"#
3476 ),
3477 "expected under-lock multi-bundle error, got {outcome:?}",
3478 );
3479 // Neither bundle was overwritten or deleted.
3480 assert!(store.contains(&format!("repo/refs/heads/main/{sha_a}.bundle")));
3481 assert!(store.contains(&format!("repo/refs/heads/main/{sha_b}.bundle")));
3482 }
3483
3484 /// Stale-remote happy path: `pre_existing == current_key`. The four
3485 /// drift scenarios above all assert that mismatch produces an error.
3486 /// This case asserts that the *match* path goes through to a normal
3487 /// `Ok(remote_ref)` push outcome — without it, a regression that
3488 /// flipped the comparison to always-mismatch would pass every drift
3489 /// test and silently break every real push.
3490 ///
3491 /// `pre_existing` is seeded under SHA distinct from `local_sha`
3492 /// (mirroring the `rejects_none_to_some` hardening): so a buggy
3493 /// regression that compared `current_key` against `bundle_dest`
3494 /// (the key derived from `local_sha`) instead of against
3495 /// `pre_existing` cannot pass for the wrong reason — the keys
3496 /// are deliberately different. The push must:
3497 /// 1. produce `Ok(remote_ref)`
3498 /// 2. upload the new bundle at `bundle_dest` (SHA = `local_sha`)
3499 /// 3. write a baseline tombstone naming `OTHER_SHA` (issue #157
3500 /// defers the prior-bundle delete to `gc sweep`) and leave
3501 /// the prior bundle in place until grace expires
3502 /// 4. write `HEAD` and `FORMAT`
3503 #[tokio::test]
3504 async fn perform_push_under_lock_passes_through_when_pre_existing_matches_current() {
3505 let store = MockStore::new();
3506 let pre_key = format!("repo/refs/heads/main/{OTHER_SHA}.bundle");
3507 // Seed under `OTHER_SHA` so under-lock list returns this key.
3508 // The local push's `local_sha` is `SHA`, so `bundle_dest` is a
3509 // DIFFERENT key. The stale-remote check passes (pre_existing ==
3510 // current_key, both = pre_key); the function then uploads the
3511 // new bundle at `bundle_dest` and writes a baseline tombstone
3512 // naming the old bundle's SHA.
3513 store.insert(&pre_key, Bytes::from_static(b"old bundle"));
3514 let state = push_state_with_pre_existing(Some(pre_key.clone()));
3515 let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
3516 .await
3517 .unwrap();
3518 assert!(
3519 matches!(&outcome, PushOutcome::Ok { remote_ref } if remote_ref == "refs/heads/main"),
3520 "expected Ok(refs/heads/main), got {outcome:?}",
3521 );
3522 // The new bundle must land at the bundle_dest derived from
3523 // local_sha, not at pre_key.
3524 let bundle_dest = format!("repo/refs/heads/main/{SHA}.bundle");
3525 let new_bytes = store
3526 .get_bytes(&bundle_dest)
3527 .await
3528 .expect("new bundle must be uploaded at bundle_dest");
3529 assert_eq!(
3530 new_bytes.as_ref(),
3531 b"fake bundle",
3532 "new bundle must contain the local payload",
3533 );
3534 // Issue #157: the old bundle must remain readable — fetchers
3535 // that advertised it via an earlier `list` need the grace
3536 // window. `gc sweep` will reclaim it later.
3537 assert!(
3538 store.contains(&pre_key),
3539 "old bundle at pre_key must survive the push (deferred via tombstone)",
3540 );
3541 // A single baseline tombstone naming the prior SHA must exist.
3542 let tomb_listing = baseline_tombstone_listing_prefix(Some("repo"));
3543 let metas = store.list("repo/gc/").await.unwrap();
3544 let tombstones: Vec<_> = metas
3545 .iter()
3546 .filter(|m| m.key.starts_with(&tomb_listing))
3547 .collect();
3548 assert_eq!(
3549 tombstones.len(),
3550 1,
3551 "exactly one baseline tombstone must be written; got keys: {:?}",
3552 tombstones.iter().map(|m| &m.key).collect::<Vec<_>>(),
3553 );
3554 let body = store.get_bytes(&tombstones[0].key).await.unwrap();
3555 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
3556 assert_eq!(
3557 parsed["ref_name"].as_str(),
3558 Some("refs/heads/main"),
3559 "tombstone must name the pushed ref",
3560 );
3561 assert_eq!(
3562 parsed["sha"].as_str(),
3563 Some(OTHER_SHA),
3564 "tombstone must name the prior SHA so `gc sweep` reclaims OTHER_SHA.bundle",
3565 );
3566 assert!(
3567 store.contains("repo/FORMAT"),
3568 "FORMAT key must be written by the push",
3569 );
3570 assert!(
3571 store.contains("repo/HEAD"),
3572 "HEAD key must be written by the push",
3573 );
3574 }
3575
3576 /// Issue #121 + #157 regression: the prior-bundle cleanup must
3577 /// never fail the push. The new bundle is already durable;
3578 /// reporting failure misrepresents the remote state. Match the
3579 /// `compact` / `force_push_baseline_cleanup` best-effort contract:
3580 /// log at warn and report success.
3581 ///
3582 /// Under issue #157 the preferred cleanup path is a baseline
3583 /// tombstone (deferred reclamation). When the tombstone PUT
3584 /// itself fails, the fallback synchronous delete runs — so this
3585 /// test arms a fault on BOTH the tombstone PUT and the prior
3586 /// bundle's delete to exercise the worst-case orphan path. The
3587 /// two-bundle state remains on the bucket so the next push's
3588 /// under-lock multi-bundle guard surfaces it to the operator.
3589 #[tokio::test]
3590 async fn perform_push_under_lock_succeeds_when_prior_cleanup_fails() {
3591 use crate::object_store::mock::Fault;
3592 let store = MockStore::new();
3593 let pre_key = format!("repo/refs/heads/main/{OTHER_SHA}.bundle");
3594 store.insert(&pre_key, Bytes::from_static(b"old bundle"));
3595 // Fail the tombstone PUT (any key under the baseline-tomb
3596 // namespace) AND the fallback synchronous delete of the
3597 // prior bundle. The push must still report Ok.
3598 store.arm(Fault::NetworkOnPutBytesPrefix {
3599 prefix: baseline_tombstone_listing_prefix(Some("repo")),
3600 });
3601 store.arm(Fault::NetworkOnDelete {
3602 key: pre_key.clone(),
3603 });
3604
3605 let state = push_state_with_pre_existing(Some(pre_key.clone()));
3606 let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
3607 .await
3608 .expect("push must succeed even when prior-bundle cleanup fails");
3609 assert!(
3610 matches!(&outcome, PushOutcome::Ok { remote_ref } if remote_ref == "refs/heads/main"),
3611 "expected Ok(refs/heads/main), got {outcome:?}",
3612 );
3613
3614 let bundle_dest = format!("repo/refs/heads/main/{SHA}.bundle");
3615 assert!(
3616 store.contains(&bundle_dest),
3617 "new bundle must be uploaded at bundle_dest",
3618 );
3619 // Both faults fired: tombstone PUT first, fallback delete
3620 // second. None should remain pending.
3621 assert_eq!(store.pending_faults(), 0);
3622 // Orphan remains on the bucket — operator sees the warn log and
3623 // the next push's multi-bundle guard will direct them to doctor.
3624 assert!(
3625 store.contains(&pre_key),
3626 "cleanup faults must leave the prior bundle in place",
3627 );
3628 // No tombstone was written (the PUT failed); the operator path
3629 // is the multi-bundle guard, not deferred reclamation.
3630 let metas = store.list("repo/gc/").await.unwrap();
3631 let tomb_listing = baseline_tombstone_listing_prefix(Some("repo"));
3632 assert!(
3633 !metas.iter().any(|m| m.key.starts_with(&tomb_listing)),
3634 "no baseline tombstone must remain after a failed PUT",
3635 );
3636 }
3637
3638 /// Issue #157 fallback path: when the baseline tombstone PUT fails
3639 /// but the synchronous delete succeeds, the prior bundle is
3640 /// reclaimed immediately. This is the recovery shape that
3641 /// preserves the issue #121 "two-bundle state surfaces via doctor"
3642 /// invariant — without the fallback delete, a tombstone PUT
3643 /// failure would orphan the prior bundle indefinitely (gc sweep
3644 /// has no tombstone to act on).
3645 #[tokio::test]
3646 async fn perform_push_under_lock_falls_back_to_sync_delete_on_tombstone_put_failure() {
3647 use crate::object_store::mock::Fault;
3648 let store = MockStore::new();
3649 let pre_key = format!("repo/refs/heads/main/{OTHER_SHA}.bundle");
3650 store.insert(&pre_key, Bytes::from_static(b"old bundle"));
3651 // Fail only the tombstone PUT — the prior-bundle delete is
3652 // left armable-free so the fallback path completes.
3653 store.arm(Fault::NetworkOnPutBytesPrefix {
3654 prefix: baseline_tombstone_listing_prefix(Some("repo")),
3655 });
3656
3657 let state = push_state_with_pre_existing(Some(pre_key.clone()));
3658 let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
3659 .await
3660 .expect("push must succeed when tombstone PUT fails but fallback delete succeeds");
3661 assert!(
3662 matches!(&outcome, PushOutcome::Ok { remote_ref } if remote_ref == "refs/heads/main"),
3663 "expected Ok(refs/heads/main), got {outcome:?}",
3664 );
3665
3666 // The fault on the tombstone PUT fired; no tombstone remains.
3667 assert_eq!(store.pending_faults(), 0);
3668 let metas = store.list("repo/gc/").await.unwrap();
3669 let tomb_listing = baseline_tombstone_listing_prefix(Some("repo"));
3670 assert!(
3671 !metas.iter().any(|m| m.key.starts_with(&tomb_listing)),
3672 "tombstone PUT failed, so no baseline-tomb key may exist",
3673 );
3674 // The fallback synchronous delete succeeded — prior bundle gone.
3675 assert!(
3676 !store.contains(&pre_key),
3677 "fallback synchronous delete must reclaim the prior bundle",
3678 );
3679 // The new bundle is durable.
3680 let bundle_dest = format!("repo/refs/heads/main/{SHA}.bundle");
3681 assert!(
3682 store.contains(&bundle_dest),
3683 "new bundle must be uploaded at bundle_dest",
3684 );
3685 }
3686
3687 /// Issue #127 regression: a non-`NotFound` error on the optional
3688 /// zip artifact upload after the new bundle has been put must NOT
3689 /// fail the push. The bundle, `HEAD`, and `FORMAT` are already
3690 /// durable; reporting failure misrepresents the remote state. The
3691 /// zip is a CodePipeline-side convenience surface — bundle
3692 /// availability is what determines whether `git clone`/`fetch`
3693 /// work. Match the `delete_prior_bundle_best_effort` contract: log
3694 /// at warn and report success.
3695 #[tokio::test]
3696 async fn perform_push_under_lock_succeeds_when_zip_upload_fails() {
3697 use crate::object_store::mock::Fault;
3698 let store = MockStore::new();
3699
3700 // Build a PushReadyState with zip_artifacts present so the
3701 // zip-upload block runs. The archive file is written to disk
3702 // so a future refactor that re-orders the fault check vs. the
3703 // file read still has a real file to act on.
3704 let r = rn("refs/heads/main");
3705 let temp_dir = tempfile::Builder::new()
3706 .prefix("test_push_")
3707 .tempdir()
3708 .unwrap();
3709 let bundle_path = temp_dir.path().join("bundle");
3710 std::fs::write(&bundle_path, b"fake bundle").unwrap();
3711
3712 let archive_tempdir = tempfile::Builder::new()
3713 .prefix("test_zip_")
3714 .tempdir()
3715 .unwrap();
3716 let archive_path = archive_tempdir.path().join("repo.zip");
3717 std::fs::write(&archive_path, b"fake zip body").unwrap();
3718
3719 let state = PushReadyState {
3720 remote_ref: r,
3721 local_sha: Sha::from_hex(SHA).unwrap(),
3722 pre_existing: None,
3723 bundle_path,
3724 zip_artifacts: Some(ZipArtifacts {
3725 archive_path,
3726 short_sha: "deadbeef".to_owned(),
3727 commit_msg: "test commit".to_owned(),
3728 _tempdir: archive_tempdir,
3729 }),
3730 engine: StorageEngine::Bundle,
3731 force: false,
3732 pre_existing_was_ancestor: true,
3733 local_spec: "refs/heads/main".to_owned(),
3734 hidden_bundles: HashSet::new(),
3735 _temp_dir: temp_dir,
3736 };
3737
3738 let zip_dest = "repo/refs/heads/main/repo.zip".to_owned();
3739 store.arm(Fault::NetworkOnPutPath {
3740 key: zip_dest.clone(),
3741 });
3742
3743 let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
3744 .await
3745 .expect("push must succeed even when zip upload fails");
3746 assert!(
3747 matches!(&outcome, PushOutcome::Ok { remote_ref } if remote_ref == "refs/heads/main"),
3748 "expected Ok(refs/heads/main), got {outcome:?}",
3749 );
3750
3751 // The bundle reached the bucket — that is the git-protocol
3752 // contract for a successful push.
3753 let bundle_dest = format!("repo/refs/heads/main/{SHA}.bundle");
3754 assert!(
3755 store.contains(&bundle_dest),
3756 "new bundle must be uploaded at bundle_dest",
3757 );
3758 // The zip fault fired exactly once — proves put_path was
3759 // attempted and failed.
3760 assert_eq!(store.pending_faults(), 0);
3761 // The zip key is absent — proves the failure was not silently
3762 // swallowed by a retry that masked the regression.
3763 assert!(
3764 !store.contains(&zip_dest),
3765 "zip key must be absent when the upload fault fires",
3766 );
3767 }
3768
3769 /// Issue #161: on S3 the zip-artifact upload must carry the
3770 /// `codepipeline-artifact-revision-summary` user-metadata header so
3771 /// AWS `CodePipeline` can consume the commit summary. On Azure the
3772 /// same hyphenated key is rejected by the service (metadata names
3773 /// must be valid C# identifiers), and the issue #127 swallow path
3774 /// then hides the upload failure — silently dropping every zip
3775 /// artifact. The fix only emits the metadata on S3; this pair of
3776 /// tests pins both halves of the contract against `MockStore`,
3777 /// which records `user_metadata` verbatim and lets us inspect it
3778 /// without standing up a live backend.
3779 #[tokio::test]
3780 async fn perform_push_under_lock_emits_codepipeline_metadata_on_s3() {
3781 let (store, zip_dest) = run_zip_push(BackendKind::S3).await;
3782 let meta = store.metadata(&zip_dest).expect("zip stored");
3783 let summary = meta
3784 .user_metadata
3785 .iter()
3786 .find(|(k, _)| k == "codepipeline-artifact-revision-summary")
3787 .expect("S3 push must attach the CodePipeline revision-summary metadata");
3788 assert_eq!(summary.1, "test commit");
3789 }
3790
3791 #[tokio::test]
3792 async fn perform_push_under_lock_omits_codepipeline_metadata_on_azure() {
3793 let (store, zip_dest) = run_zip_push(BackendKind::Azure).await;
3794 let meta = store.metadata(&zip_dest).expect("zip stored");
3795 assert!(
3796 meta.user_metadata.is_empty(),
3797 "Azure push must not attach hyphenated CodePipeline metadata; \
3798 got {entries:?}",
3799 entries = meta.user_metadata,
3800 );
3801 }
3802
3803 /// Drive a single `?zip=1` push through `perform_push_under_lock` for
3804 /// the given backend kind, returning the store and the zip key so
3805 /// each test can assert on `user_metadata` independently. Centralised
3806 /// here so an accidental drift between the S3 and Azure variants
3807 /// (different `commit_msg`, different prefix, different ref) cannot
3808 /// hide a regression in the metadata wiring.
3809 async fn run_zip_push(kind: BackendKind) -> (MockStore, String) {
3810 let store = MockStore::new();
3811 let r = rn("refs/heads/main");
3812 let temp_dir = tempfile::Builder::new()
3813 .prefix("test_push_")
3814 .tempdir()
3815 .unwrap();
3816 let bundle_path = temp_dir.path().join("bundle");
3817 std::fs::write(&bundle_path, b"fake bundle").unwrap();
3818
3819 let archive_tempdir = tempfile::Builder::new()
3820 .prefix("test_zip_")
3821 .tempdir()
3822 .unwrap();
3823 let archive_path = archive_tempdir.path().join("repo.zip");
3824 std::fs::write(&archive_path, b"fake zip body").unwrap();
3825
3826 let state = PushReadyState {
3827 remote_ref: r,
3828 local_sha: Sha::from_hex(SHA).unwrap(),
3829 pre_existing: None,
3830 bundle_path,
3831 zip_artifacts: Some(ZipArtifacts {
3832 archive_path,
3833 short_sha: "deadbeef".to_owned(),
3834 commit_msg: "test commit".to_owned(),
3835 _tempdir: archive_tempdir,
3836 }),
3837 engine: StorageEngine::Bundle,
3838 force: false,
3839 pre_existing_was_ancestor: true,
3840 local_spec: "refs/heads/main".to_owned(),
3841 hidden_bundles: HashSet::new(),
3842 _temp_dir: temp_dir,
3843 };
3844
3845 let outcome = perform_push_under_lock(&store, Some("repo"), kind, state)
3846 .await
3847 .expect("push must succeed");
3848 assert!(matches!(outcome, PushOutcome::Ok { .. }));
3849 let zip_dest = "repo/refs/heads/main/repo.zip".to_owned();
3850 assert!(
3851 store.contains(&zip_dest),
3852 "zip artifact must land on bucket"
3853 );
3854 (store, zip_dest)
3855 }
3856
3857 // --- delete_remote_ref_under_lock ---------------------------------
3858
3859 /// Issue #133: under the lock, a ref whose only remaining object is
3860 /// the lock key itself reports `"not found"?` rather than the
3861 /// pre-#133 quirk of treating the lock as a bundle. In production
3862 /// the lock here is the one [`acquire_lock`] holds across the call;
3863 /// `release_lock` deletes it after this function returns, so the
3864 /// caller never sees a dangling lock either way.
3865 ///
3866 /// This pins the new contract: the filter on the lock key must
3867 /// short-circuit to `"not found"?` when the lock is the only
3868 /// listed entry, NOT delete it as a bundle.
3869 #[tokio::test]
3870 async fn delete_remote_ref_under_lock_reports_not_found_when_only_lock_present() {
3871 let store = MockStore::new();
3872 let lock_key = "repo/refs/heads/main/LOCK#.lock";
3873 // Simulate the lock we hold across the call (the production
3874 // caller has already acquired it via `acquire_lock`).
3875 store.insert(lock_key, Bytes::from_static(b"held-lock-payload"));
3876 let r = rn("refs/heads/main");
3877
3878 let outcome = delete_remote_ref_under_lock(&store, Some("repo"), &r, lock_key)
3879 .await
3880 .unwrap();
3881
3882 match outcome {
3883 PushOutcome::Error { message, .. } => {
3884 assert_eq!(message, r#""not found"?"#);
3885 }
3886 PushOutcome::Ok { .. } => panic!("expected Error, got Ok"),
3887 }
3888 // The lock key is NOT swept by `delete_remote_ref_under_lock` —
3889 // `release_lock` is responsible for removing it. Pin that here
3890 // so a regression that swept the held lock would fail.
3891 assert!(
3892 store.contains(lock_key),
3893 "delete_remote_ref_under_lock must NOT delete the held lock key",
3894 );
3895 }
3896
3897 /// Issue #133: when a concurrent push lands a NEW bundle between
3898 /// the lock-acquire and our listing inside the lock, the post-lock
3899 /// listing reflects the new bundle. The delete proceeds normally
3900 /// against that bundle (and the lock is filtered out). This pins
3901 /// the close of the race window the issue describes: the listing
3902 /// is now under the lock, so the deletion target is whatever the
3903 /// concurrent writer left behind, not a stale pre-lock snapshot.
3904 #[tokio::test]
3905 async fn delete_remote_ref_under_lock_sweeps_concurrently_landed_bundle() {
3906 let store = MockStore::new();
3907 let r = rn("refs/heads/main");
3908 let lock_key = "repo/refs/heads/main/LOCK#.lock";
3909 // Concurrent push landed this bundle; we hold the lock now.
3910 let bundle = format!("repo/refs/heads/main/{OTHER_SHA}.bundle");
3911 store.insert(&bundle, Bytes::from_static(b"new"));
3912 store.insert(lock_key, Bytes::from_static(b"held-lock-payload"));
3913
3914 let outcome = delete_remote_ref_under_lock(&store, Some("repo"), &r, lock_key)
3915 .await
3916 .unwrap();
3917
3918 assert_eq!(
3919 outcome,
3920 PushOutcome::Ok {
3921 remote_ref: "refs/heads/main".into()
3922 }
3923 );
3924 assert!(
3925 !store.contains(&bundle),
3926 "concurrently-landed bundle must be swept by the under-lock listing",
3927 );
3928 assert!(
3929 store.contains(lock_key),
3930 "held lock must survive the sweep (release_lock removes it)",
3931 );
3932 }
3933
3934 /// Stale lock is deleted but another client re-acquires it before our
3935 /// retry `put_if_absent`. Must return `Ok(None)` — the caller maps
3936 /// this to a "lock held" user error, not a hard failure.
3937 #[tokio::test]
3938 async fn acquire_lock_stale_retry_loses_second_race() {
3939 use crate::object_store::mock::Fault;
3940 let store = MockStore::new();
3941 let now = OffsetDateTime::now_utc();
3942 let stale = now - Duration::seconds(120);
3943 store.insert_with("k", Bytes::new(), stale, PutOpts::default());
3944 // Another client wins the race between our delete and retry.
3945 store.arm(Fault::ContendedPutIfAbsent { key: "k".into() });
3946 let arc = Arc::new(store);
3947 let guard = acquire_lock(
3948 Arc::clone(&arc) as Arc<dyn ObjectStore>,
3949 "k",
3950 Duration::seconds(60),
3951 now,
3952 )
3953 .await
3954 .unwrap();
3955 assert!(guard.is_none(), "expected contention on the retry race");
3956 // Fault fired — confirms the retry put_if_absent was called.
3957 assert_eq!(arc.pending_faults(), 0);
3958 // The stale lock was removed; no key remains.
3959 assert!(!arc.contains("k"));
3960 }
3961
3962 // --- full_error_chain dedup --------------------------------------
3963
3964 /// Tripwire for the dedup-by-suffix fix: a naive chain-walk on
3965 /// `PushError::Store(ObjectStoreError::Network(_))` produces a
3966 /// duplicated tail because both `PushError::Store` and
3967 /// `ObjectStoreError::Network` inline their immediate source via
3968 /// `{0}` in the `Display` derive. The shared
3969 /// `super::append_source_chain` helper skips levels whose text is
3970 /// already at the tail of the message. A regression that
3971 /// re-introduced the always-append walk would render
3972 /// `"…network error: dns failure: dns failure"` (or even longer
3973 /// for deeper chains), failing this byte-exact assertion.
3974 #[test]
3975 fn full_error_chain_deduplicates_inlined_source_text() {
3976 let inner: crate::object_store::BoxError = Box::new(std::io::Error::other("dns failure"));
3977 let err = PushError::Store(ObjectStoreError::Network(inner));
3978 let rendered = full_error_chain(&err);
3979 assert_eq!(
3980 rendered, "object-store error during push: network error: dns failure",
3981 "PushError::Store(Network(_)) must not duplicate the inner source",
3982 );
3983 }
3984
3985 // --- bundle progress sink wiring (issue #55) -----------------------
3986
3987 /// Decorator around `MockStore` that records, for every `put_path`
3988 /// call, whether `opts.progress` was `Some`. Used to pin the
3989 /// "bundle uploads attach a progress sink" contract from issue
3990 /// #55: a regression that drops the sink from `perform_push_under_lock`
3991 /// would silently regress the only thing `git push` users have to
3992 /// watch a multi-GiB transfer.
3993 ///
3994 /// The decorator forwards every other method to the inner
3995 /// `MockStore` unchanged. Wrapping for the assertion-of-interest
3996 /// alone keeps the test tightly scoped — there is no fault
3997 /// injection, no chunking knob, just a Vec of "did `put_path` get
3998 /// a sink?" booleans keyed by the destination key.
3999 #[derive(Default)]
4000 struct RecordingPutPathStore {
4001 inner: MockStore,
4002 put_path_progress_seen: std::sync::Mutex<Vec<(String, bool)>>,
4003 }
4004
4005 impl RecordingPutPathStore {
4006 fn observed(&self) -> Vec<(String, bool)> {
4007 self.put_path_progress_seen
4008 .lock()
4009 .expect("observation lock")
4010 .clone()
4011 }
4012 }
4013
4014 #[async_trait::async_trait]
4015 impl ObjectStore for RecordingPutPathStore {
4016 async fn list(&self, prefix: &str) -> Result<Vec<ObjectMeta>, ObjectStoreError> {
4017 self.inner.list(prefix).await
4018 }
4019 async fn get_to_file(
4020 &self,
4021 key: &str,
4022 dest: &Path,
4023 opts: crate::object_store::GetOpts,
4024 ) -> Result<(), ObjectStoreError> {
4025 self.inner.get_to_file(key, dest, opts).await
4026 }
4027 async fn get_bytes(&self, key: &str) -> Result<Bytes, ObjectStoreError> {
4028 self.inner.get_bytes(key).await
4029 }
4030 async fn get_bytes_range(
4031 &self,
4032 key: &str,
4033 range: std::ops::Range<u64>,
4034 ) -> Result<Bytes, ObjectStoreError> {
4035 self.inner.get_bytes_range(key, range).await
4036 }
4037 async fn put_bytes(
4038 &self,
4039 key: &str,
4040 body: Bytes,
4041 opts: PutOpts,
4042 ) -> Result<(), ObjectStoreError> {
4043 self.inner.put_bytes(key, body, opts).await
4044 }
4045 async fn put_path(
4046 &self,
4047 key: &str,
4048 src: &Path,
4049 opts: PutOpts,
4050 ) -> Result<(), ObjectStoreError> {
4051 self.put_path_progress_seen
4052 .lock()
4053 .expect("observation lock")
4054 .push((key.to_owned(), opts.progress.is_some()));
4055 self.inner.put_path(key, src, opts).await
4056 }
4057 async fn put_if_absent(&self, key: &str, body: Bytes) -> Result<bool, ObjectStoreError> {
4058 self.inner.put_if_absent(key, body).await
4059 }
4060 async fn head(&self, key: &str) -> Result<ObjectMeta, ObjectStoreError> {
4061 self.inner.head(key).await
4062 }
4063 async fn copy(&self, src: &str, dst: &str) -> Result<(), ObjectStoreError> {
4064 self.inner.copy(src, dst).await
4065 }
4066 async fn delete(&self, key: &str) -> Result<(), ObjectStoreError> {
4067 self.inner.delete(key).await
4068 }
4069 }
4070
4071 /// `perform_push_under_lock` must attach a `ProgressSink` to the
4072 /// bundle `put_path` so `git push` users see motion during a slow
4073 /// upload (issue #55). Before the fix, this call passed
4074 /// `PutOpts::default()` and the user saw nothing for hours on a
4075 /// 20 GiB push.
4076 #[tokio::test]
4077 async fn perform_push_under_lock_attaches_progress_sink_to_bundle_put_path() {
4078 let store = RecordingPutPathStore::default();
4079 let r = rn("refs/heads/main");
4080 let temp_dir = tempfile::Builder::new()
4081 .prefix("test_push_progress_")
4082 .tempdir()
4083 .unwrap();
4084 let bundle_path = temp_dir.path().join("bundle");
4085 std::fs::write(&bundle_path, b"fake bundle").unwrap();
4086 let state = PushReadyState {
4087 remote_ref: r,
4088 local_sha: Sha::from_hex(SHA).unwrap(),
4089 pre_existing: None,
4090 bundle_path,
4091 zip_artifacts: None,
4092 engine: StorageEngine::Bundle,
4093 force: false,
4094 pre_existing_was_ancestor: true,
4095 local_spec: "refs/heads/main".to_owned(),
4096 hidden_bundles: HashSet::new(),
4097 _temp_dir: temp_dir,
4098 };
4099 let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
4100 .await
4101 .unwrap();
4102 assert!(
4103 matches!(outcome, PushOutcome::Ok { .. }),
4104 "expected Ok outcome",
4105 );
4106 let observed = store.observed();
4107 // Exactly one `put_path` call (no zip artifacts in this fixture).
4108 // Pinning ≥1 rather than ==1 guards against future test
4109 // refactors that legitimately add another upload — the contract
4110 // we care about is that *every* bundle-class upload carries a
4111 // sink.
4112 assert!(
4113 !observed.is_empty(),
4114 "perform_push_under_lock must call put_path for the bundle",
4115 );
4116 for (key, has_sink) in &observed {
4117 assert!(
4118 has_sink,
4119 "put_path for `{key}` must carry a ProgressSink (issue #55)",
4120 );
4121 }
4122 }
4123
4124 /// `bundle_progress_sink` accepts an arbitrary stream of
4125 /// `report(amount)` calls without panicking and tolerates `None`
4126 /// for `total` (the stat-failed fallback). Pins the sink's
4127 /// public-shape contract: a regression that switched the
4128 /// `Option<u64>` to a non-optional `u64` would force every caller
4129 /// to handle stat errors, breaking the "graceful degradation"
4130 /// note in the helper's doc comment.
4131 #[test]
4132 fn bundle_progress_sink_accepts_reports_without_panicking() {
4133 let with_total = bundle_progress_sink("repo/bundle.bundle", Some(1_024));
4134 with_total.report(256);
4135 with_total.report(256);
4136 with_total.report(512);
4137
4138 let without_total = bundle_progress_sink("repo/bundle.bundle", None);
4139 without_total.report(1);
4140 without_total.report(u64::MAX); // saturates rather than wraps
4141 }
4142
4143 /// Pin the not-ancestor wire token at the Rust level. The shellspec
4144 /// suites (`spec/integration/{s3,az}/force_push_spec.sh`,
4145 /// `spec/live/s3/force_push_spec.sh`) assert on the literal
4146 /// substring `"not ancestor"` — but they only run via `make
4147 /// shellspec-*` targets, not the default `cargo test --workspace`
4148 /// gate. This test catches a Rust dev who renames the constant
4149 /// without updating the spec files.
4150 #[test]
4151 fn not_ancestor_token_value_is_stable() {
4152 assert_eq!(
4153 NOT_ANCESTOR_TOKEN, "not ancestor",
4154 "spec/{{integration,live}}/*/force_push_spec.sh asserts on this exact substring",
4155 );
4156 let formatted = format!(r#""remote ref is {NOT_ANCESTOR_TOKEN} of refs/heads/main."?"#);
4157 assert!(
4158 formatted.contains(NOT_ANCESTOR_TOKEN),
4159 "the not-ancestor PushOutcome::Error message must embed the token literally; got {formatted:?}",
4160 );
4161 }
4162
4163 /// CRLF in a commit-message summary would split the
4164 /// `x-amz-meta-codepipeline-artifact-revision-summary` (or
4165 /// `x-ms-meta-…`) header on the wire, letting a forged commit
4166 /// inject arbitrary user metadata onto the uploaded zip
4167 /// archive. `sanitize_metadata_value` must collapse every ASCII
4168 /// control byte (CR, LF, NUL, HTAB, …) to a space so the
4169 /// resulting header value is single-line and free of injection
4170 /// payloads.
4171 #[test]
4172 fn sanitize_metadata_value_strips_control_chars() {
4173 assert_eq!(
4174 sanitize_metadata_value("hello\r\nX-Injected: yes"),
4175 "hello X-Injected: yes",
4176 );
4177 assert_eq!(sanitize_metadata_value("nul\0byte"), "nul byte");
4178 assert_eq!(sanitize_metadata_value("plain text"), "plain text");
4179 assert_eq!(
4180 sanitize_metadata_value("café — short summary"),
4181 "café — short summary",
4182 "non-ASCII printable characters must pass through unchanged",
4183 );
4184 assert_eq!(sanitize_metadata_value(""), "");
4185 }
4186
4187 // --- Issue #129: force-push protection check runs under the lock ---
4188
4189 /// Regression for issue #129. If a concurrent `protect` lands a
4190 /// `PROTECTED#` marker between the pre-lock work in `prepare_push`
4191 /// and the lock acquisition in `push_one`, the under-lock arm of
4192 /// `perform_push_under_lock` must observe it and reject a non-FF
4193 /// force-push with the same `NotAncestor` wire token the pre-lock
4194 /// non-force probe would have produced.
4195 ///
4196 /// `pre_existing_was_ancestor = false` represents "this force-push
4197 /// is NOT a fast-forward" — exactly the case the historical
4198 /// protection-demotion logic rejected. The `PROTECTED#` key is
4199 /// seeded after the would-be pre-lock check (we simulate that by
4200 /// constructing the state with `force=true` and seeding the marker
4201 /// alongside the matching pre-existing bundle).
4202 #[tokio::test]
4203 async fn perform_push_under_lock_rejects_force_when_protected_under_lock_and_not_ff() {
4204 let store = MockStore::new();
4205 let pre_key = format!("repo/refs/heads/main/{OTHER_SHA}.bundle");
4206 store.insert(&pre_key, Bytes::from_static(b"old bundle"));
4207 // Concurrent `protect` lands the marker before we get the lock.
4208 store.insert("repo/refs/heads/main/PROTECTED#", Bytes::from_static(b""));
4209 let mut state = push_state_with_pre_existing(Some(pre_key.clone()));
4210 state.force = true;
4211 state.pre_existing_was_ancestor = false;
4212 let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
4213 .await
4214 .unwrap();
4215 assert!(
4216 matches!(
4217 &outcome,
4218 PushOutcome::Error { message, .. }
4219 if message == r#""remote ref is not ancestor of refs/heads/main."?"#
4220 ),
4221 "expected under-lock NotAncestor refusal, got {outcome:?}",
4222 );
4223 // The protection marker must survive — the engine never touches
4224 // protect/unprotect state. A regression that swept it on the
4225 // refusal path would silently downgrade a server's protection.
4226 assert!(store.contains("repo/refs/heads/main/PROTECTED#"));
4227 // The pre-existing bundle must survive intact: a refusal path
4228 // that erroneously progressed past the protection check could
4229 // overwrite or delete it.
4230 let local_sha = SHA;
4231 assert!(store.contains(&pre_key));
4232 assert!(
4233 !store.contains(&format!("repo/refs/heads/main/{local_sha}.bundle")),
4234 "refused push must not upload the new bundle",
4235 );
4236 }
4237
4238 /// Companion to the rejection case: when the user's local tip IS a
4239 /// fast-forward of the pre-existing remote bundle (`pre_existing_was_ancestor
4240 /// = true`), the historical "protected ref + force" semantic is to
4241 /// proceed — protection only blocks non-fast-forward force-pushes.
4242 /// This test pins that branch: a `PROTECTED#` marker under the
4243 /// lock plus a FF push must still succeed.
4244 #[tokio::test]
4245 async fn perform_push_under_lock_allows_force_when_protected_under_lock_but_ff() {
4246 let store = MockStore::new();
4247 let pre_key = format!("repo/refs/heads/main/{OTHER_SHA}.bundle");
4248 store.insert(&pre_key, Bytes::from_static(b"old bundle"));
4249 store.insert("repo/refs/heads/main/PROTECTED#", Bytes::from_static(b""));
4250 let mut state = push_state_with_pre_existing(Some(pre_key.clone()));
4251 state.force = true;
4252 state.pre_existing_was_ancestor = true; // FF case.
4253 let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
4254 .await
4255 .unwrap();
4256 assert!(
4257 matches!(&outcome, PushOutcome::Ok { remote_ref } if remote_ref == "refs/heads/main"),
4258 "FF push must pass even when protected, got {outcome:?}",
4259 );
4260 // Protection marker still in place after the push.
4261 assert!(store.contains("repo/refs/heads/main/PROTECTED#"));
4262 }
4263
4264 /// Companion to the rejection case: a legitimate force-push (force,
4265 /// non-FF, no `PROTECTED#` marker) must proceed. This pins the
4266 /// polarity of the AND-clause guarding the protection rejection — a
4267 /// regression that dropped the `is_protected` check from the
4268 /// condition would refuse every non-FF force-push, not just those
4269 /// against protected refs.
4270 #[tokio::test]
4271 async fn perform_push_under_lock_allows_force_when_not_ancestor_and_not_protected() {
4272 let store = MockStore::new();
4273 let pre_key = format!("repo/refs/heads/main/{OTHER_SHA}.bundle");
4274 store.insert(&pre_key, Bytes::from_static(b"old bundle"));
4275 // No PROTECTED# marker.
4276 let mut state = push_state_with_pre_existing(Some(pre_key.clone()));
4277 state.force = true;
4278 state.pre_existing_was_ancestor = false; // non-FF force-push.
4279 let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
4280 .await
4281 .unwrap();
4282 assert!(
4283 matches!(&outcome, PushOutcome::Ok { remote_ref } if remote_ref == "refs/heads/main"),
4284 "legitimate non-FF force-push must proceed, got {outcome:?}",
4285 );
4286 // A passing push uploads the new bundle keyed by `local_sha`.
4287 let local_sha = SHA;
4288 assert!(
4289 store.contains(&format!("repo/refs/heads/main/{local_sha}.bundle")),
4290 "new bundle must be uploaded on a successful force-push",
4291 );
4292 // Defensive: the NotAncestor wire token must NOT appear anywhere
4293 // in the outcome — that would mean the guard mis-fired.
4294 if let PushOutcome::Error { message, .. } = &outcome {
4295 assert!(
4296 !message.contains("not ancestor"),
4297 "force-push without protection must not emit NotAncestor: {message}",
4298 );
4299 }
4300 }
4301
4302 /// A non-force push (`force=false`) must never consult `is_protected`
4303 /// under the lock: non-FF non-force pushes were already rejected
4304 /// pre-lock by the ancestry probe, and FF non-force pushes are
4305 /// unaffected by protection. The under-lock check is gated on
4306 /// `force` precisely so this round-trip costs zero extra HEAD calls.
4307 ///
4308 /// Test-design note: `pre_existing_was_ancestor` is forced to `false`
4309 /// here so that ONLY the `force` clause of the under-lock guard
4310 /// (`force && !pre_existing_was_ancestor && is_protected(...)`)
4311 /// keeps the protection check off. A regression that drops the
4312 /// `force &&` clause would flip the guard to true and fail this
4313 /// test; if `pre_existing_was_ancestor` were left as `true`, the
4314 /// `!pre_existing_was_ancestor` clause would short-circuit and
4315 /// hide that regression. The `pre_existing` bundle key is omitted
4316 /// to keep the FF-vs-non-FF semantics consistent with "no prior
4317 /// remote SHA, therefore not an ancestor".
4318 #[tokio::test]
4319 async fn perform_push_under_lock_skips_protection_check_for_non_force() {
4320 let store = MockStore::new();
4321 // Marker present, but a non-force push must not even probe for it.
4322 store.insert("repo/refs/heads/main/PROTECTED#", Bytes::from_static(b""));
4323 let mut state = push_state_with_pre_existing(None);
4324 state.force = false;
4325 state.pre_existing_was_ancestor = false;
4326 let outcome = perform_push_under_lock(&store, Some("repo"), BackendKind::S3, state)
4327 .await
4328 .unwrap();
4329 assert!(
4330 matches!(&outcome, PushOutcome::Ok { remote_ref } if remote_ref == "refs/heads/main"),
4331 "non-force push must pass regardless of protection: {outcome:?}",
4332 );
4333 }
4334
4335 // -----------------------------------------------------------------
4336 // Issue #151 — bundle-engine delete must not miss a `PROTECTED#`
4337 // marker written after the under-lock listing. The primary defence
4338 // is the per-ref lock (#159 made `protect`/`unprotect` acquire the
4339 // same key the delete holds). These tests pin the post-sweep
4340 // defensive verification (`verify_no_orphan_protected_after_delete`)
4341 // behaves correctly on the happy path and is silent there.
4342 // -----------------------------------------------------------------
4343
4344 #[tokio::test]
4345 async fn issue_151_clean_delete_passes_post_sweep_verification() {
4346 // Happy path with the lock contract honoured: bundle present,
4347 // no marker, lock held. The sweep deletes the bundle and the
4348 // post-sweep `head(PROTECTED#)` returns NotFound — silently —
4349 // and the delete reports `ok`. A regression that promoted the
4350 // post-sweep probe into a hard error would surface here.
4351 let store = MockStore::new();
4352 let r = rn("refs/heads/main");
4353 let bundle = format!("repo/refs/heads/main/{SHA}.bundle");
4354 let lock_key = "repo/refs/heads/main/LOCK#.lock";
4355 store.insert(&bundle, Bytes::from_static(b"b"));
4356 store.insert(lock_key, Bytes::from_static(b"held-lock-payload"));
4357
4358 let outcome = delete_remote_ref_under_lock(&store, Some("repo"), &r, lock_key)
4359 .await
4360 .unwrap();
4361 assert_eq!(
4362 outcome,
4363 PushOutcome::Ok {
4364 remote_ref: "refs/heads/main".into()
4365 },
4366 "clean delete must report ok after the post-sweep probe",
4367 );
4368 assert!(!store.contains(&bundle), "bundle must be swept");
4369 assert!(
4370 store.contains(lock_key),
4371 "lock survives the sweep (release removes it)",
4372 );
4373 }
4374
4375 /// Helper unit test for [`verify_no_orphan_protected_after_delete`]:
4376 /// the helper itself must not error or panic when the marker is
4377 /// absent, and the call must be cheap (a single `head`). Pinned
4378 /// here so a future refactor of the helper cannot regress its
4379 /// contract — the delete paths rely on this being a no-op on
4380 /// the happy path.
4381 #[tokio::test]
4382 async fn verify_no_orphan_protected_after_delete_is_noop_when_marker_absent() {
4383 use crate::object_store::mock::Fault;
4384 let store = MockStore::new();
4385 let r = rn("refs/heads/main");
4386 // Arm a one-shot transient HEAD fault on the marker key. The
4387 // helper MUST issue a `head()` on that key; the fault is the
4388 // witness. A regression that turned the helper into a literal
4389 // no-op would leave the fault unconsumed and fail the
4390 // pending-faults assertion below. Without this witness, the
4391 // "marker absent → marker absent" assertion is vacuous.
4392 store.arm(Fault::NetworkOnHead {
4393 key: "repo/refs/heads/main/PROTECTED#".to_owned(),
4394 });
4395 verify_no_orphan_protected_after_delete(&store, Some("repo"), &r).await;
4396 assert_eq!(
4397 store.pending_faults(),
4398 0,
4399 "helper must call head() on the marker key — fault unconsumed",
4400 );
4401 // The transient HEAD error goes through the `debug!` branch and
4402 // the helper returns silently; the bucket stays unchanged.
4403 assert!(
4404 !store.contains("repo/refs/heads/main/PROTECTED#"),
4405 "helper must not touch the bucket",
4406 );
4407 }
4408
4409 /// When the helper observes a marker — the lock-contract-violation
4410 /// branch — it logs at `error!` and returns silently. The bucket
4411 /// state must be unchanged (no rollback). This is the
4412 /// belt-and-suspenders branch the issue's race scenario would
4413 /// reach if a future regression bypassed the lock.
4414 #[tokio::test]
4415 async fn verify_no_orphan_protected_after_delete_does_not_mutate_when_marker_present() {
4416 let store = MockStore::new();
4417 let r = rn("refs/heads/main");
4418 let marker = "repo/refs/heads/main/PROTECTED#";
4419 store.insert(marker, Bytes::new());
4420 verify_no_orphan_protected_after_delete(&store, Some("repo"), &r).await;
4421 // The helper logs but does NOT delete the marker: rollback is
4422 // not the helper's job (the delete is already complete; the
4423 // operator-visible "ref is gone" outcome stands; the orphan
4424 // marker is surveillance telemetry).
4425 assert!(
4426 store.contains(marker),
4427 "helper must not delete the orphan marker — surveillance only",
4428 );
4429 }
4430}