ossctl_core/release/adapters/cargo.rs
1//! Rust ecosystem adapter: `cargo-publish` (crates.io) and `cargo-dist`.
2//!
3//! `cargo-publish` publishes a crate to crates.io via `cargo publish`.
4//! `cargo-dist` plans and builds distributable binaries locally (`dist`), but
5//! its *upload* is the CI release workflow — so its publish body is
6//! [`AdapterError::Unsupported`] from this host rather than a fabricated receipt
7//! for a build-only command. `verify` (for `cargo-publish`) reconciles against
8//! crates.io through [`RegistryQuery`](crate::ports::RegistryQuery) via the
9//! adapter's default path.
10//!
11//! ## One plan target = one publish unit (ADR-0004)
12//!
13//! Each plan target publishes **exactly its own package** — one target ⇒ one
14//! `cargo publish -p <package>`. The [coordinator](super::super::coordinator) owns
15//! all cross-target ordering: it cuts same-ecosystem targets in dependency order
16//! (a dependency's target before its dependents'), so the adapter never re-orders
17//! or re-publishes another target's crate. This removes the earlier
18//! closure-per-target model, where two authorities (coordinator + adapter) each
19//! computed overlapping publish orderings and the crates.io publish→index lag
20//! between them could trigger a *duplicate* `cargo publish` of a shared dependency
21//! → a partial-publish trap.
22//!
23//! A workspace whose crates depend on one another still cannot publish in one
24//! shot: crates.io rejects a crate whose sibling dependency is not yet indexed
25//! (`no matching package named … found`). So before publishing its own package,
26//! the adapter discovers that package's publishable intra-workspace dependencies
27//! (read-only `cargo metadata`) and **waits for each to be crates.io-index-visible**
28//! (polling the injected [`RegistryQuery`](crate::ports::RegistryQuery), bounded by
29//! a timeout) — the dependency's own target, cut earlier, already published it, so
30//! this only closes the index-lag window. A crate with no publishable workspace
31//! dependencies publishes immediately with no wait.
32//!
33//! ## Deferred packaging for a `=`-pinned dependent (cargo-interleave, ADR-0002)
34//!
35//! A dependent crate that pins its workspace dependency by exact version
36//! (`dep = "=X.Y.Z"`, the shape `/oss-init` emits) **cannot be `cargo package`d
37//! before that dependency is published** — not even with `--no-verify`. `cargo
38//! package` resolves the `=`-pinned dependency against the crates.io *index* while
39//! preparing the upload (a published `.crate` cannot reference a `path` dep), and
40//! that version only lands later, in publish-all. `--no-verify` skips the isolated
41//! verify *compile*, but not this index resolution. So a strict `build-all` that
42//! packaged every crate up front could never package such a dependent
43//! (`release-cut-build-phase-dep-ordering`).
44//!
45//! The fix scopes the ADR-0002 phase barrier narrowly for cargo. `dry_run` /
46//! `build` read the workspace graph and probe the registry (see
47//! `unpublished_workspace_deps`), then branch on whether the target depends on a
48//! workspace crate **not yet on the crates.io index**:
49//!
50//! - **No unpublished workspace dep** (a leaf, or a dependent whose workspace deps
51//! are already published — a re-cut): fully packaged pre-publish — `cargo check`
52//! (compile safety net) then `cargo package --no-verify` (produces the `.crate`,
53//! validating the manifest). It CAN be packaged: `cargo package` resolves the dep
54//! against the index it is already on.
55//! - **Depends on a not-yet-published workspace crate**: its **packaging is deferred**
56//! to `cargo publish` in publish-all, which packages and publishes as one unit
57//! *after* the dependency is published and index-visible (`build` runs only the
58//! index-independent `cargo check`, which resolves the sibling via its `path`).
59//! This is the "build interleaves with publish" exception the coordinator relies
60//! on: the dependent's package step is intrinsic to its dep-ordered publish, not a
61//! premature global-build step. The pre-publish compile safety net (`cargo check`
62//! over every target) still runs as a global build-all barrier, so a compile error
63//! in the default host build fails before **any** irreversible publish.
64//!
65//! The registry probe is **fail-closed**: a dep the registry cannot confirm as
66//! published defers, so a registry outage never risks a build-all `cargo package`
67//! that resolves a `=`-pinned dep against an index it cannot reach.
68//!
69//! The consequence is a target model where **each publishable crate is its own
70//! declared target** (which is what `/oss-init` emits). A multi-crate workspace
71//! that wants every crate on crates.io declares every crate as a target; a target
72//! whose package depends on a workspace crate that is *not* itself a declared
73//! target — and whose required version is not already on the index — times out
74//! waiting for that crate, the signal that it must be declared. (If that
75//! dependency's version happens to already be published, the wait clears and the
76//! publish proceeds; the coverage check that would catch an under-declared plan up
77//! front is tracked separately, not owned by the adapter.)
78
79use std::collections::BTreeMap;
80use std::collections::HashSet;
81use std::time::Duration;
82
83use serde::Deserialize;
84
85use crate::contract::schema::{Adapter, Ecosystem, Registry};
86use crate::protocol::release::{BuildArtifacts, DryRunReport, PlannedCommand, PublishReceipt};
87
88use super::{
89 hash_file, make_receipt, run_all, AdapterError, AdapterTarget, EffectCtx, ReleaseAdapter,
90};
91
92/// Wall-clock ceiling for a single crate's crates.io index-wait, in seconds.
93///
94/// crates.io's sparse index is usually visible within seconds of a publish, but
95/// the publish→index pipeline can lag under load; a generous per-crate ceiling
96/// avoids a spurious failure while still bounding a hung wait so it can never
97/// wedge a run.
98const INDEX_WAIT_TIMEOUT_SECS: u64 = 300;
99
100/// Interval between crates.io index polls while waiting for a just-published
101/// version to appear.
102const INDEX_POLL_INTERVAL: Duration = Duration::from_secs(3);
103
104/// Cargo's registry alias for crates.io. Used two ways: as the value of every
105/// `cargo publish/package --registry <alias>` this adapter emits (so the publish
106/// destination is pinned and never resolved from ambient registry config —
107/// `registry.default`, `.cargo/config.toml`, `CARGO_REGISTRY_DEFAULT`), and as the
108/// token a manifest's `publish` allow-list must contain to be crates.io-publishable
109/// (a member restricted to a *different* registry is excluded).
110const CRATES_IO_ALIAS: &str = "crates-io";
111
112/// The rust release adapter, operating as either `cargo-publish` or `cargo-dist`.
113pub struct CargoAdapter {
114 adapter: Adapter,
115}
116
117impl CargoAdapter {
118 /// Construct for a resolved rust adapter identity (`cargo-publish` /
119 /// `cargo-dist`).
120 #[must_use]
121 pub fn new(adapter: Adapter) -> Self {
122 debug_assert!(matches!(
123 adapter,
124 Adapter::CargoPublish | Adapter::CargoDist
125 ));
126 Self { adapter }
127 }
128
129 /// The cargo `--registry` alias to pin this target's `cargo publish`/`package`
130 /// invocations to, derived from the target's declared registry.
131 ///
132 /// crates.io is the only rust registry ossctl supports today, so any other
133 /// declared registry is a misconfiguration that must fail **fast, before any
134 /// external action** — never a silent publish to an unexpected destination.
135 /// Returns [`CRATES_IO_ALIAS`] for [`Registry::CratesIo`], else
136 /// [`AdapterError::UnsupportedRegistry`] tagged with **this** adapter's identity
137 /// (`self.adapter`, not a hard-coded value, so a `cargo-dist` caller could never
138 /// misreport itself). Threading the flag value through here (rather than
139 /// hard-coding it at each call site) keeps the destination tied to the contract's
140 /// `registry` field and the rejection in one place.
141 fn crates_io_registry(&self, t: &AdapterTarget) -> Result<&'static str, AdapterError> {
142 match t.target.registry {
143 Registry::CratesIo => Ok(CRATES_IO_ALIAS),
144 registry => Err(AdapterError::UnsupportedRegistry {
145 adapter: self.adapter,
146 registry,
147 }),
148 }
149 }
150}
151
152impl ReleaseAdapter for CargoAdapter {
153 fn adapter(&self) -> Adapter {
154 self.adapter
155 }
156
157 fn is_ci_delegated(&self) -> bool {
158 // `cargo-dist` builds distributables locally but its *upload* is the
159 // tag-triggered `release.yml` — the engine cannot (and must not) publish it
160 // from this host. `cargo-publish` is a real host publish and is not
161 // delegated. Consistent with `publish` returning `Unsupported` for
162 // `cargo-dist` only.
163 matches!(self.adapter, Adapter::CargoDist)
164 }
165
166 fn ci_owns_github_release(&self) -> bool {
167 // `cargo-dist`'s generated `release.yml` runs `gh release create <tag> …
168 // artifacts/*` — it creates AND finalizes the shared GitHub Release and
169 // uploads the cross-platform binaries. So the coordinator must not create
170 // the Release itself (a pre-existing Release makes `gh release create`
171 // error). `cargo-publish` owns no GitHub Release. See
172 // `coordinator-release-vs-cargo-dist-ownership`.
173 matches!(self.adapter, Adapter::CargoDist)
174 }
175
176 fn dry_run(
177 &self,
178 ctx: &EffectCtx<'_>,
179 t: &AdapterTarget,
180 ) -> Result<DryRunReport, AdapterError> {
181 if matches!(self.adapter, Adapter::CargoDist) {
182 return Ok(DryRunReport {
183 adapter: self.adapter,
184 planned_commands: vec![PlannedCommand::new(
185 "dist",
186 &["plan", "--output-format=json"],
187 )],
188 notes: vec![],
189 });
190 }
191 // Reject a non-crates.io target before doing anything — the dry-run
192 // preflight must exercise the exact registry-pinned build a real cut runs, and
193 // a misconfigured registry is a fail-fast error, not a plannable command.
194 let registry = self.crates_io_registry(t)?;
195 // FAITHFUL PREFLIGHT: actually run the SAME index-independent build gate the
196 // build phase runs, so a plan that cannot compile (or, for a leaf, cannot
197 // package) fails HERE at dry-run-all, before any external effect, rather than
198 // passing dry-run and failing mid-cut in build-all. (The old dry-run only
199 // *described* a `cargo publish --dry-run` without running it, so a build-phase
200 // failure slipped past the preflight.)
201 //
202 // `target_workspace_deps` runs read-only `cargo metadata` first, validating
203 // the target is a publishable member and listing its publishable workspace
204 // dependencies. `unpublished_workspace_deps` then probes the registry for each
205 // (fail-closed) to decide the gate ([`cargo_build_gate`]): a target with **no
206 // workspace dep still absent from the index** is packaged now (`cargo package
207 // --no-verify`, validating the manifest); a dependent on a **not-yet-published**
208 // workspace crate DEFERS packaging to its own `cargo publish` — it cannot be
209 // `cargo package`d until that dep is on the index — so the preflight is the
210 // index-independent `cargo check` compile gate alone (the check resolves the
211 // sibling via its on-disk `path`, never the index), and never false-fails on
212 // the unpublished dep. The real end-to-end verify happens in publish-all's
213 // `cargo publish`, after the dep is index-visible.
214 //
215 // The gate COMMANDS are local + self-overwriting (a package writes
216 // `target/package/`, the same artifact build-all produces; `check` only warms
217 // `target/`), so dry-run stays re-runnable and free of any *external* side
218 // effect (ADR-0002). One plan target = one publish unit: exactly this target's
219 // own package.
220 let deps = target_workspace_deps(ctx, t)?;
221 let deferred = unpublished_workspace_deps(ctx, t.ecosystem(), &deps);
222 let defer_packaging = !deferred.is_empty();
223 let (planned_commands, _artifacts) =
224 cargo_build_gate(registry, &t.package, &t.version, defer_packaging);
225 run_all(ctx, &planned_commands)?;
226 let mut notes = vec![format!(
227 "publishes with `cargo publish --registry {registry} -p {}` in publish-all",
228 t.package
229 )];
230 if defer_packaging {
231 let chain = deferred
232 .iter()
233 .map(|m| format!("{}@{}", m.name, m.version))
234 .collect::<Vec<_>>()
235 .join(", ");
236 notes.push(format!(
237 "packaging of `{}` is deferred to that publish: it depends on workspace \
238 crate(s) not yet on the crates.io index, and a dependent cannot be \
239 `cargo package`d until they are published",
240 t.package
241 ));
242 notes.push(format!(
243 "waits for these workspace dependencies to be crates.io-index-visible \
244 before publishing `{}`: {chain}",
245 t.package
246 ));
247 }
248 Ok(DryRunReport {
249 adapter: self.adapter,
250 planned_commands,
251 notes,
252 })
253 }
254
255 fn build(
256 &self,
257 ctx: &EffectCtx<'_>,
258 t: &AdapterTarget,
259 ) -> Result<BuildArtifacts, AdapterError> {
260 // `dist build` emits per-platform tarballs/installers, not a `.crate`;
261 // name the artifact set to match what each identity actually produces.
262 let (cmds, artifacts, notes) = if matches!(self.adapter, Adapter::CargoDist) {
263 (
264 vec![PlannedCommand::new("dist", &["build"])],
265 vec!["dist/".to_string()],
266 vec![],
267 )
268 } else {
269 // Pin `cargo package` to crates.io too (rejecting a non-crates.io target
270 // up front) so the build phase can never verify-package against a
271 // different registry than the publish phase will target. Reject BEFORE the
272 // read-only `cargo metadata` probe so a misconfigured registry runs no
273 // command at all.
274 let registry = self.crates_io_registry(t)?;
275 // Read the workspace graph, then probe the registry to decide the gate: a
276 // target that depends on a workspace crate NOT YET on the index cannot be
277 // packaged (packaging resolves the `=`-pinned dep against the index), so its
278 // packaging is DEFERRED to `cargo publish` and build runs only the
279 // index-independent `cargo check`; a target whose workspace deps are already
280 // published (or has none) is packaged now. Fail-closed: an unreachable
281 // registry defers (see [`unpublished_workspace_deps`]). See
282 // [`cargo_build_gate`] and `release-cut-build-phase-dep-ordering`.
283 let deps = target_workspace_deps(ctx, t)?;
284 let deferred = unpublished_workspace_deps(ctx, t.ecosystem(), &deps);
285 let defer_packaging = !deferred.is_empty();
286 let (cmds, artifacts) =
287 cargo_build_gate(registry, &t.package, &t.version, defer_packaging);
288 let notes = if defer_packaging {
289 let chain = deferred
290 .iter()
291 .map(|m| format!("{}@{}", m.name, m.version))
292 .collect::<Vec<_>>()
293 .join(", ");
294 vec![format!(
295 "packaging of `{}` deferred to `cargo publish` in publish-all (it \
296 depends on workspace crate(s) not yet on the crates.io index: {chain})",
297 t.package
298 )]
299 } else {
300 vec![]
301 };
302 (cmds, artifacts, notes)
303 };
304 run_all(ctx, &cmds)?;
305 // SKELETON: a production build parses the exact packaged `.crate` /
306 // `dist-manifest.json` paths out of the command output; here we name the
307 // expected artifact set deterministically.
308 Ok(BuildArtifacts {
309 adapter: self.adapter,
310 artifacts,
311 notes,
312 })
313 }
314
315 fn publish(
316 &self,
317 ctx: &EffectCtx<'_>,
318 t: &AdapterTarget,
319 ) -> Result<PublishReceipt, AdapterError> {
320 // cargo-dist uploads via the CI release workflow, not from this host —
321 // `dist build` only builds. Report that honestly rather than returning a
322 // receipt for a publish that did not happen.
323 if matches!(self.adapter, Adapter::CargoDist) {
324 return Err(AdapterError::Unsupported {
325 adapter: self.adapter,
326 operation: "publish",
327 });
328 }
329 // Reject a non-crates.io target BEFORE the idempotency probe or any publish —
330 // the whole publish path (probe, index-wait, receipt URL) assumes crates.io,
331 // so a mismatched registry must fail closed here, never reach `cargo publish`.
332 let registry = self.crates_io_registry(t)?;
333 // PER-TARGET IRREVERSIBLE — drives the real `cargo publish` through the
334 // injected runner (the port is the safety seam under test). ADR-0004: one
335 // plan target = one publish unit, so this publishes ONLY `t.package`; the
336 // coordinator cut every dependency's target before this one. No
337 // `--no-verify`: a resume that enters publish without re-running build must
338 // still let cargo verify the package before it lands.
339 //
340 // IDEMPOTENT re-entry with TRI-STATE probing. On resume the coordinator
341 // re-enters this method from the top, so probe the registry first and skip
342 // an already-landed publish (a second `cargo publish` of an uploaded version
343 // hard-fails and would wedge every resume). Crucially, a probe that cannot
344 // reach the registry is NOT read as "not published" — that would permit a
345 // duplicate upload of a crate that in fact landed. It fails the publish
346 // closed ([`AdapterError::RegistryUnavailable`]), mirroring the reconcile
347 // layer's outage ⇒ `Unknown` ⇒ never-`Missing` discipline.
348 //
349 // DIGEST-AUTHENTICATE THE SKIP (`is-published-digest-authenticate`). "Already
350 // published" by name + version is NOT proof the crate on the registry is the
351 // one this cut intended — a *different* artifact could occupy the version. So
352 // before trusting the skip, [`authenticate_skip`] proves the registry's crate
353 // is byte-identical to what this cut would upload (its `.crate` sha256 vs the
354 // sparse-index `cksum`); only a match skips, a mismatch fails CLOSED, and an
355 // outage keeps the same never-guess discipline. This closes the last
356 // "receipt without a fresh upload" path (the self-visibility no-op's mirror).
357 let ecosystem = t.ecosystem();
358 if is_published(ctx, ecosystem, &t.package, &t.version)? {
359 return authenticate_skip(ctx, registry, ecosystem, t);
360 }
361 // crates.io rejects a crate whose sibling dependency is not yet indexed, so
362 // wait for this package's own publishable workspace dependencies to be
363 // index-visible before publishing it. Each dependency's target was cut
364 // earlier by the coordinator; this only closes the publish→index lag window.
365 for dep in &target_workspace_deps(ctx, t)? {
366 wait_for_index(ctx, ecosystem, &dep.name, &dep.version)?;
367 }
368 run_all(
369 ctx,
370 &[PlannedCommand::new(
371 "cargo",
372 &["publish", "--registry", registry, "-p", &t.package],
373 )],
374 )?;
375 // SELF-VISIBILITY CONFIRM (`cut-noop-self-visibility-check`). `cargo publish`
376 // exiting 0 is NOT proof the crate landed: a registry-alias/credential/env
377 // difference (or an under-declared target) can make it a silent no-op that
378 // ships nothing. Before journaling a receipt, probe the index for this
379 // target's OWN `{package, version}` — reusing the bounded index-wait so
380 // normal propagation lag is tolerated (only a genuine never-appears no-op
381 // fails), and failing closed on a registry outage rather than fabricating a
382 // receipt for a publish that may not have happened.
383 confirm_self_published(ctx, ecosystem, &t.package, &t.version)?;
384 // SKELETON: a production publish parses the crates.io checksum from the
385 // `cargo publish` output for `digest`; the canonical URL is well-known. One
386 // target publishes exactly one crate, so the journal records exactly one
387 // receipt for this crate — `resume`/`verify` track it precisely.
388 Ok(make_receipt(ctx, t, None, Some(remote_url(t))))
389 }
390
391 fn timeout(&self) -> Duration {
392 Duration::from_secs(600)
393 }
394}
395
396/// The build/preflight gate for a `cargo-publish` target — the commands `dry_run`
397/// and `build` both run — plus the build artifacts it produces.
398///
399/// A pure function of the caller's `defer_packaging` decision (computed identically
400/// by `dry_run` and `build` from [`unpublished_workspace_deps`], so the two stay in
401/// lockstep — a faithful preflight):
402///
403/// - **`defer_packaging == false`** (a leaf, or a dependent whose workspace deps are
404/// already on the index — so it CAN be packaged): `cargo check -p <pkg>` (compile
405/// safety net) then `cargo package --registry <r> -p <pkg> --no-verify`. The
406/// package validates the manifest and produces the `.crate`, the single build
407/// artifact. `--no-verify` skips only the isolated verify *compile* (redundant with
408/// the `cargo check` above and re-run for real by `cargo publish` in publish-all).
409/// - **`defer_packaging == true`** (a dependent on a workspace crate NOT yet on the
410/// index): `cargo check -p <pkg>` **alone** — an index-independent compile (the
411/// sibling resolves via its on-disk `path`, never the index). It is the pre-publish
412/// safety net that fails a genuine compile error (type/trait/API mismatch, missing
413/// item) before any irreversible publish — the partial-publish trap ADR-0004 exists
414/// to prevent. Packaging is **deferred** to `cargo publish` in publish-all, which
415/// packages+publishes as one unit *after* the dependency is published and
416/// index-visible: `cargo package` (even `--no-verify`) resolves the `=X.Y.Z` dep
417/// against the crates.io *index* when preparing the upload, so it cannot run until
418/// that dependency is published (`release-cut-build-phase-dep-ordering`). No
419/// `.crate` is produced here, so the artifact set is empty.
420///
421/// The gate commands are local + per-target, so no build-time cross-target ordering
422/// leaks into the adapter (ADR-0002/0004 preserved); the coordinator alone orders
423/// the publishes.
424fn cargo_build_gate(
425 registry: &str,
426 package: &str,
427 version: &str,
428 defer_packaging: bool,
429) -> (Vec<PlannedCommand>, Vec<String>) {
430 let mut cmds = vec![PlannedCommand::new("cargo", &["check", "-p", package])];
431 if defer_packaging {
432 // Deferred packaging: only the index-independent compile gate runs now.
433 return (cmds, Vec::new());
434 }
435 cmds.push(PlannedCommand::new(
436 "cargo",
437 &[
438 "package",
439 "--registry",
440 registry,
441 "-p",
442 package,
443 "--no-verify",
444 ],
445 ));
446 (cmds, vec![format!("{package}-{version}.crate")])
447}
448
449/// The canonical crates.io URL for a target's own package at its version — the
450/// receipt's `remote_url`. Correct because the publish paths call this only after
451/// [`crates_io_registry`] has confirmed the target's registry is crates.io.
452fn remote_url(t: &AdapterTarget) -> String {
453 format!("https://crates.io/crates/{}/{}", t.package, t.version)
454}
455
456/// The publishable intra-workspace dependencies of the target's own package — the
457/// crates that must be crates.io-index-visible before `t.package` can publish
458/// (ADR-0004). Each has its own plan target, cut earlier by the coordinator.
459///
460/// Runs read-only `cargo metadata`, keeps only members publishable to crates.io
461/// (dropping `publish = false` and members restricted to another registry), and
462/// returns the direct workspace dependencies of `t.package` among them. Errors if
463/// `t.package` is not itself a publishable member (the plan approved a package this
464/// workspace cannot publish to crates.io). A crate with no publishable workspace
465/// dependencies resolves to an empty list — it publishes with no wait.
466fn target_workspace_deps(
467 ctx: &EffectCtx<'_>,
468 t: &AdapterTarget,
469) -> Result<Vec<Member>, AdapterError> {
470 let meta = load_metadata(ctx)?;
471 let members = publishable_members(&meta);
472 let by_name: BTreeMap<&str, &Member> = members.iter().map(|m| (m.name.as_str(), m)).collect();
473 let Some(target) = by_name.get(t.package.as_str()) else {
474 let available: Vec<&str> = members.iter().map(|m| m.name.as_str()).collect();
475 return Err(AdapterError::Command {
476 command: "cargo metadata".to_string(),
477 code: None,
478 stderr: format!(
479 "target package `{}` is not a crates.io-publishable member of this workspace \
480 (publishable members: {available:?}); declare each publishable crate as its own \
481 target and check the contract `package` and each crate's `publish` setting",
482 t.package
483 ),
484 });
485 };
486 // `publishable_members` already restricted each member's `deps` to *other kept
487 // members*, so every dep name here resolves to a publishable member.
488 Ok(target
489 .deps
490 .iter()
491 .filter_map(|d| by_name.get(d.as_str()).map(|m| (*m).clone()))
492 .collect())
493}
494
495/// Run `cargo metadata` and parse the workspace graph. Errors on a command
496/// failure, on empty output (a real `cargo metadata` never succeeds with empty
497/// stdout — empty means a broken host/runner, which must not silently degrade the
498/// publish set), or on unparseable output.
499fn load_metadata(ctx: &EffectCtx<'_>) -> Result<CargoMetadata, AdapterError> {
500 let cmd = PlannedCommand::new("cargo", &["metadata", "--no-deps", "--format-version", "1"]);
501 let outputs = run_all(ctx, std::slice::from_ref(&cmd))?;
502 let stdout = outputs[0].stdout.trim();
503 if stdout.is_empty() {
504 return Err(AdapterError::Command {
505 command: cmd.rendered(),
506 code: None,
507 stderr: "`cargo metadata` succeeded but emitted no output — cannot resolve the \
508 workspace publish set"
509 .to_string(),
510 });
511 }
512 serde_json::from_str(stdout).map_err(|e| AdapterError::Command {
513 command: cmd.rendered(),
514 code: None,
515 stderr: format!("could not parse `cargo metadata` output: {e}"),
516 })
517}
518
519/// Project the metadata onto the crates.io-publishable members and their
520/// intra-workspace (non-dev) dependency edges.
521///
522/// A member is kept unless its manifest sets `publish = false` (which
523/// `cargo metadata` reports as an empty `publish` array) or restricts publishing
524/// to a registry set that does not include crates.io. Only edges to *other kept
525/// members* gate order; dev-dependencies are excluded (they never gate publish
526/// order and can form legitimate cycles, e.g. a lib crate that dev-depends on the
527/// CLI crate for integration tests).
528fn publishable_members(meta: &CargoMetadata) -> Vec<Member> {
529 let member_ids: HashSet<&str> = meta.workspace_members.iter().map(String::as_str).collect();
530 let pkgs: Vec<&MetaPackage> = meta
531 .packages
532 .iter()
533 .filter(|p| member_ids.contains(p.id.as_str()))
534 .filter(|p| publishable_to_crates_io(p.publish.as_deref()))
535 .collect();
536 let names: HashSet<&str> = pkgs.iter().map(|p| p.name.as_str()).collect();
537 pkgs.iter()
538 .map(|p| {
539 let mut deps: Vec<String> = p
540 .dependencies
541 .iter()
542 // Allow-list the ordering-relevant kinds (normal + build); a future
543 // dep kind is excluded rather than accidentally treated as ordering.
544 .filter(|d| matches!(d.kind.as_deref(), None | Some("build")))
545 .filter(|d| d.name != p.name && names.contains(d.name.as_str()))
546 .map(|d| d.name.clone())
547 .collect();
548 deps.sort();
549 deps.dedup();
550 Member {
551 name: p.name.clone(),
552 version: p.version.clone(),
553 deps,
554 }
555 })
556 .collect()
557}
558
559/// Whether a member's `publish` field permits crates.io. `None`/absent ⇒ any
560/// registry (yes); `Some([])` ⇒ `publish = false` (no); `Some([regs…])` ⇒ only if
561/// the list names crates.io.
562fn publishable_to_crates_io(publish: Option<&[String]>) -> bool {
563 match publish {
564 None => true,
565 Some(regs) => regs.iter().any(|r| r == CRATES_IO_ALIAS),
566 }
567}
568
569/// The subset of the target's publishable workspace dependencies whose **exact
570/// release version is not yet visible on the crates.io index** — the dependencies
571/// that make the target unpackageable *now*, so its packaging must defer to
572/// `cargo publish` (which packages after those deps publish + index).
573///
574/// **Fail-closed:** a dependency the registry cannot confirm as published — absent
575/// (`Ok(false)`) **or** a registry error (`Err`) — counts as not-yet-published, so
576/// packaging defers rather than risk a build-all `cargo package` that resolves a
577/// `=`-pinned dep against an index that is missing it or cannot be reached. A
578/// dependency **already on the index** (`Ok(true)`) is dropped, so a re-cut whose
579/// dependency was published by an earlier release still packages — and manifest-
580/// validates — the dependent in build-all (it can: `cargo package` resolves that
581/// dep against the index it is already on). This is the precise predicate: "defer
582/// iff a workspace dep is not yet on the index", not the coarser "has any workspace
583/// dep".
584fn unpublished_workspace_deps(
585 ctx: &EffectCtx<'_>,
586 ecosystem: Ecosystem,
587 deps: &[Member],
588) -> Vec<Member> {
589 deps.iter()
590 .filter(|d| !matches!(is_published(ctx, ecosystem, &d.name, &d.version), Ok(true)))
591 .cloned()
592 .collect()
593}
594
595/// Whether `package@version` is already visible on crates.io — the idempotency
596/// probe run before `cargo publish` so a resumed cut skips a crate that already
597/// landed instead of hard-failing on a duplicate upload.
598///
599/// **Tri-state, fail-closed.** `Ok(true)` ⇒ already published (skip); `Ok(false)`
600/// ⇒ the registry answered and the version is definitively absent (safe to
601/// publish); `Err(RegistryUnavailable)` ⇒ the registry could not be reached, so
602/// the probe cannot prove the crate has *not* landed. A registry error is **never**
603/// read as "not published" (which would permit a duplicate, irreversible upload);
604/// the caller fails closed, mirroring the reconcile layer's outage ⇒ `Unknown`
605/// discipline.
606fn is_published(
607 ctx: &EffectCtx<'_>,
608 ecosystem: Ecosystem,
609 package: &str,
610 version: &str,
611) -> Result<bool, AdapterError> {
612 match ctx.registry.published_versions(ecosystem.as_str(), package) {
613 Ok(versions) => Ok(versions.iter().any(|v| v == version)),
614 Err(e) => Err(AdapterError::RegistryUnavailable {
615 package: package.to_string(),
616 version: version.to_string(),
617 source: e.to_string(),
618 }),
619 }
620}
621
622/// Authenticate an idempotency skip: `t.package@t.version` is already on the
623/// registry, so prove the crate published there is **byte-identical** to what this
624/// cut would upload before trusting the skip (`is-published-digest-authenticate`).
625///
626/// Name + version existence alone is not enough — a *different* artifact could
627/// occupy the version (a re-used version from another source, or a supply-chain
628/// substitution). So this compares two digests:
629///
630/// - the **intended** digest: the sha256 of the `.crate` this cut would upload,
631/// (re)produced deterministically by [`intended_crate_digest`] (the target's
632/// published dependencies are on the index, so it packages cleanly);
633/// - the **published** digest: the registry-recorded checksum (crates.io
634/// sparse-index `cksum`) from [`RegistryQuery::published_checksum`].
635///
636/// Only a match trusts the skip and journals a receipt carrying the verified
637/// digest (no `cargo publish` runs — the crate is already there). A **mismatch**
638/// fails **closed** with [`AdapterError::DigestMismatch`] (the registry holds a
639/// different artifact than intended). An **outage** — the checksum cannot be read
640/// — fails closed with [`AdapterError::RegistryUnavailable`], never trusting a skip
641/// it cannot authenticate (the same never-guess discipline as the idempotency
642/// probe and self-visibility confirm).
643fn authenticate_skip(
644 ctx: &EffectCtx<'_>,
645 registry: &str,
646 ecosystem: Ecosystem,
647 t: &AdapterTarget,
648) -> Result<PublishReceipt, AdapterError> {
649 let local = intended_crate_digest(ctx, registry, &t.package, &t.version)?;
650 let remote = ctx
651 .registry
652 .published_checksum(ecosystem.as_str(), &t.package, &t.version)
653 .map_err(|e| AdapterError::RegistryUnavailable {
654 package: t.package.clone(),
655 version: t.version.clone(),
656 source: e.to_string(),
657 })?;
658 // Re-validate the registry digest at this domain boundary rather than trusting
659 // the [`RegistryQuery`] contract blindly: a faulty/future backend returning a
660 // non-hex `Ok(..)` must fail CLOSED as unavailable, never be reported as a
661 // `DigestMismatch` (which would misattribute a backend bug to a conflicting
662 // artifact). The real crates.io impl already validates; this guards the port.
663 if !is_sha256_hex(&remote) {
664 return Err(AdapterError::RegistryUnavailable {
665 package: t.package.clone(),
666 version: t.version.clone(),
667 source: format!("registry returned a malformed checksum: {remote:?}"),
668 });
669 }
670 // Both digests are lowercase hex; compare case-insensitively for robustness.
671 if !local.eq_ignore_ascii_case(&remote) {
672 return Err(AdapterError::DigestMismatch {
673 package: t.package.clone(),
674 version: t.version.clone(),
675 local,
676 remote,
677 });
678 }
679 // Authenticated: the registry's crate matches what this cut intended, so the
680 // publish is safely skipped and the receipt records the verified digest.
681 Ok(make_receipt(ctx, t, Some(local), Some(remote_url(t))))
682}
683
684/// The sha256 (lowercase hex) of the `.crate` this cut would upload for
685/// `package@version` — the *intended* digest an idempotency skip is authenticated
686/// against.
687///
688/// (Re)produces the `.crate` with `cargo package --no-verify` (local, idempotent,
689/// self-overwriting — the same artifact build-all produces) so the digest is of the
690/// exact bytes `cargo publish` would upload, independent of whether an earlier build
691/// phase left the file on disk. The target is already published, so its `=`-pinned
692/// workspace dependencies are on the crates.io index and packaging resolves them.
693///
694/// The packaged `.crate` lands at `<target_directory>/package/<pkg>-<version>.crate`,
695/// where `target_directory` is read from `cargo metadata` (honoring
696/// `CARGO_TARGET_DIR` / `[build] target-dir` / workspace config — never a hard-coded
697/// `<repo>/target`); [`hash_file`] then hashes it cross-platform.
698///
699/// **Caveat (tracked for the receipt-provenance cluster).** `cargo package` is
700/// deterministic only under a fixed toolchain + source tree + index state; a resume
701/// under a *different* cargo version can produce different `.crate` bytes for the same
702/// sealed commit, so the digest is a faithful "what this toolchain would upload now",
703/// not a durable record of the original upload. The regression-free source of the
704/// intended digest is a value journaled at the original publish — see
705/// `cargo-publish-receipt-provenance-resume-safety`.
706fn intended_crate_digest(
707 ctx: &EffectCtx<'_>,
708 registry: &str,
709 package: &str,
710 version: &str,
711) -> Result<String, AdapterError> {
712 // Resolve the real target directory BEFORE packaging so the hash reads the file
713 // cargo actually writes, not a hard-coded path a custom target-dir would miss.
714 let target_dir = load_metadata(ctx)?.target_directory;
715 run_all(
716 ctx,
717 &[PlannedCommand::new(
718 "cargo",
719 &[
720 "package",
721 "--registry",
722 registry,
723 "-p",
724 package,
725 "--no-verify",
726 ],
727 )],
728 )?;
729 let crate_path = format!("{target_dir}/package/{package}-{version}.crate");
730 hash_file(ctx, &crate_path).map_err(|source| AdapterError::Command {
731 command: format!("sha256 of {crate_path}"),
732 code: None,
733 stderr: source,
734 })
735}
736
737/// Whether `s` is a well-formed lowercase-or-mixed-case 64-char hex SHA-256 — the
738/// shape both a crates.io `cksum` and a local `.crate` hash must have. Used to
739/// validate a registry-supplied digest at the [`authenticate_skip`] boundary.
740fn is_sha256_hex(s: &str) -> bool {
741 s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
742}
743
744/// Why a bounded index-wait gave up without ever observing `package@version` — the
745/// honest distinction the caller maps onto its own [`AdapterError`] variant.
746enum WaitFailure {
747 /// The registry *answered at least once* over the window and the version was
748 /// definitively absent every time it did — a genuine "did not appear" (the
749 /// dependency never indexed, or the crate's own publish shipped nothing).
750 Absent {
751 /// How long the wait actually lasted before giving up, in seconds.
752 waited_secs: u64,
753 },
754 /// The registry was **never** reached with a definitive answer over the whole
755 /// window — every poll errored — so absence could not be established: an outage,
756 /// not a proven absence. Carries the last underlying error so it is surfaced,
757 /// never masked.
758 Unreachable {
759 /// The underlying registry lookup error, rendered as text.
760 source: String,
761 },
762}
763
764/// Poll the crates.io index (through the injected [`RegistryQuery`]) until
765/// `package@version` is visible, or the per-crate timeout elapses.
766///
767/// Between polls it waits [`INDEX_POLL_INTERVAL`] through the injected
768/// [`Clock::sleep`](crate::ports::Clock::sleep) — real time in production, a
769/// virtual advance under test — so the loop is bounded, never busy, and
770/// deterministic in tests. A transient lookup error is retried (waiting is
771/// reversible), and the outcome on timeout is classified over the **whole window,
772/// not just the final poll**: if *any* poll reached the registry and observed the
773/// version absent, that is [`WaitFailure::Absent`]; if **no** poll ever got a
774/// definitive answer (every one errored), that is [`WaitFailure::Unreachable`]
775/// carrying the last error — a sustained outage is never masked as "did not index"
776/// just because the last poll happened (or failed) to answer. This fails closed:
777/// an all-outage window is `Unreachable`, never a false absence. The two callers
778/// ([`wait_for_index`] for a dependency, [`confirm_self_published`] for the crate
779/// just published) map these to their own [`AdapterError`] variants, so the same
780/// bounded, propagation-lag-tolerant wait backs both.
781fn poll_for_index(
782 ctx: &EffectCtx<'_>,
783 ecosystem: Ecosystem,
784 package: &str,
785 version: &str,
786) -> Result<(), WaitFailure> {
787 let start = ctx.clock.now_unix();
788 // Whether ANY poll reached the registry and got a definitive answer (a version
789 // list, in which the version was absent). Drives the fail-closed classification:
790 // a window that never once saw the registry answer is an outage
791 // (`Unreachable`), never a proven absence — even if the final poll erred or
792 // answered. Only a window that DID observe a clean absence classifies as
793 // `Absent`.
794 let mut observed_absent = false;
795 // The most recent registry error, surfaced when the window was a pure outage.
796 let mut last_err: Option<String> = None;
797 loop {
798 match ctx.registry.published_versions(ecosystem.as_str(), package) {
799 Ok(versions) => {
800 if versions.iter().any(|v| v == version) {
801 return Ok(());
802 }
803 // A definitive answer: the registry was reached and the version is
804 // absent. `last_err` is intentionally NOT cleared — it is only read on
805 // the `Unreachable` path, which is taken solely when `observed_absent`
806 // is false (no clean answer ever occurred), so a stale error string
807 // can never leak into an `Absent` classification.
808 observed_absent = true;
809 }
810 Err(e) => last_err = Some(e.to_string()),
811 }
812 let waited = ctx.clock.now_unix().saturating_sub(start);
813 if waited >= INDEX_WAIT_TIMEOUT_SECS {
814 return Err(if observed_absent {
815 WaitFailure::Absent {
816 waited_secs: waited,
817 }
818 } else {
819 WaitFailure::Unreachable {
820 source: last_err.unwrap_or_else(|| {
821 "the registry never returned a definitive answer".to_string()
822 }),
823 }
824 });
825 }
826 ctx.clock.sleep(INDEX_POLL_INTERVAL);
827 }
828}
829
830/// Wait for a **workspace dependency** to be crates.io-index-visible before the
831/// dependent's `cargo publish` (crates.io rejects a crate whose sibling dependency
832/// is not yet indexed). An absence-after-wait is [`AdapterError::IndexTimeout`] (the
833/// dependency never indexed — likely under-declared); an outage is
834/// [`AdapterError::RegistryUnavailable`] (fail-closed, never masked as "did not
835/// index").
836fn wait_for_index(
837 ctx: &EffectCtx<'_>,
838 ecosystem: Ecosystem,
839 package: &str,
840 version: &str,
841) -> Result<(), AdapterError> {
842 poll_for_index(ctx, ecosystem, package, version).map_err(|f| match f {
843 WaitFailure::Absent { waited_secs } => AdapterError::IndexTimeout {
844 package: package.to_string(),
845 version: version.to_string(),
846 waited_secs,
847 },
848 WaitFailure::Unreachable { source } => AdapterError::RegistryUnavailable {
849 package: package.to_string(),
850 version: version.to_string(),
851 source,
852 },
853 })
854}
855
856/// Confirm the crate the adapter **just published** is visible on the index before a
857/// receipt is journaled — the self-visibility check that turns an unconfirmed upload
858/// into a fail-closed refusal rather than a fabricated success
859/// (`cut-noop-self-visibility-check`).
860///
861/// A `cargo publish` that exits 0 but shipped nothing (a registry-alias/credential/
862/// env difference, an under-declared target) would otherwise fabricate a
863/// [`PublishReceipt`](crate::protocol::release::PublishReceipt) and report the cut a
864/// success while nothing reached crates.io. So after the irreversible upload the
865/// publish path probes the registry for the target's *own* `{package, version}`,
866/// reusing the same bounded [`poll_for_index`] wait as the dependency index-wait so
867/// normal sparse-index propagation lag is tolerated — only a version that never
868/// appears within the window fails. That failure is
869/// [`AdapterError::PublishNotVisible`] (naming the crate + version): the cut fails
870/// **closed** rather than record a receipt it cannot substantiate — the upload may
871/// have landed on a slow index (resume/verify) or shipped nothing (a genuine no-op).
872/// A registry outage (never reachable across the window) is
873/// [`AdapterError::RegistryUnavailable`] instead (fail-closed too, mirroring the
874/// reconcile layer's outage discipline).
875fn confirm_self_published(
876 ctx: &EffectCtx<'_>,
877 ecosystem: Ecosystem,
878 package: &str,
879 version: &str,
880) -> Result<(), AdapterError> {
881 poll_for_index(ctx, ecosystem, package, version).map_err(|f| match f {
882 WaitFailure::Absent { waited_secs } => AdapterError::PublishNotVisible {
883 package: package.to_string(),
884 version: version.to_string(),
885 waited_secs,
886 },
887 WaitFailure::Unreachable { source } => AdapterError::RegistryUnavailable {
888 package: package.to_string(),
889 version: version.to_string(),
890 source,
891 },
892 })
893}
894
895/// A publishable workspace member with its version and its intra-workspace
896/// (non-dev) dependency names.
897#[derive(Clone)]
898struct Member {
899 name: String,
900 version: String,
901 deps: Vec<String>,
902}
903
904/// The subset of `cargo metadata --format-version 1 --no-deps` output the
905/// publish-order discovery reads.
906#[derive(Deserialize)]
907struct CargoMetadata {
908 /// Every package in the metadata; with `--no-deps` these are the workspace
909 /// members only.
910 packages: Vec<MetaPackage>,
911 /// The package ids that are workspace members (matched against
912 /// [`MetaPackage::id`] to be exact regardless of the id string format).
913 workspace_members: Vec<String>,
914 /// The absolute build target directory cargo resolved for this invocation —
915 /// honoring `CARGO_TARGET_DIR`, `[build] target-dir`, and workspace config, so a
916 /// consumer never hard-codes `<repo>/target`. `cargo metadata` always emits it;
917 /// defaulted only so the many callers that read only [`Self::packages`] /
918 /// [`Self::workspace_members`] can deserialize fixtures without it. The
919 /// packaged `.crate` lands under `<target_directory>/package/`.
920 #[serde(default)]
921 target_directory: String,
922}
923
924/// One package entry from `cargo metadata`.
925#[derive(Deserialize)]
926struct MetaPackage {
927 name: String,
928 version: String,
929 id: String,
930 #[serde(default)]
931 dependencies: Vec<MetaDep>,
932 /// `null`/absent ⇒ publishable to any registry; `[]` ⇒ `publish = false`;
933 /// `["<registry>",…]` ⇒ publishable to a restricted set (still publishable).
934 #[serde(default)]
935 publish: Option<Vec<String>>,
936}
937
938/// One dependency entry from `cargo metadata`.
939#[derive(Deserialize)]
940struct MetaDep {
941 name: String,
942 /// `null` (normal), `"dev"`, or `"build"`. Only normal/build deps gate
943 /// publish order; dev-deps never do.
944 #[serde(default)]
945 kind: Option<String>,
946}