ossctl_core/release/plan.rs
1//! The sealed, content-addressed release plan — the read-only pre-image the
2//! human approves (ADR-0002 §3).
3//!
4//! `release plan` computes and seals a `plan_id`; `release cut --plan <plan_id>`
5//! executes it and refuses on repo drift. The binary never prompts: it plans
6//! and exits at the approval boundary.
7//!
8//! ## What `plan_id` hashes (the content address)
9//!
10//! [`build`] derives a [`ReleasePlan`] from the already-normalized contract and
11//! detected repo facts, then content-addresses it. The `plan_id` is the
12//! lowercase SHA-256 hex digest of a canonical JSON pre-image (`serde_json`,
13//! whose struct-field and `BTreeMap` ordering is deterministic) covering
14//! **exactly**, in this fixed order:
15//!
16//! 1. a domain separator + `SEAL_VERSION` — so a `plan_id` can never collide
17//! with any other ossctl digest and the canonicalization format can be
18//! evolved by a deliberate `SEAL_VERSION` bump instead of silently;
19//! 2. the contract-document `schema_version` (ADR-0002 lists it explicitly);
20//! 3. the **full normalized contract JSON** (`contract show`'s canonical output
21//! — every defaulted field, so any config change is drift; hashing the whole
22//! contract is deliberately *fail-closed*: a cosmetic change re-requires
23//! approval rather than risk missing a substantive one);
24//! 4. the git `HEAD` sha the plan was sealed against;
25//! 5. the chosen release version (the human's bump — design §3.4);
26//! 6. the **resolved concrete target set** — each target's ecosystem, resolved
27//! package name, registry, and adapter *identity*. Resolution overlays
28//! facts-derived package names onto the contract's (which may be `null`), so
29//! a manifest rename is detectable drift even though the contract text is
30//! unchanged;
31//! 7. the phase sequence (constant per ADR-0002 §2 for a `--bump`-less plan, so it
32//! never *causes* drift within a binary, but binding it authenticates the
33//! execution shape the approver saw and makes a future phase-model change a
34//! `SEAL_VERSION` event). A `--bump` plan prepends a `bump` phase, which this
35//! field binds;
36//! 8. the engine-owned **bump plan** (`release-rust-workspace-multicrate` facet 2/3),
37//! or absent. `--bump <level>` computes a new version from the current manifest
38//! version + the level and seals the deterministic edit set (computed version,
39//! intra-workspace pin rewrites, CHANGELOG-finalize intent, any declared
40//! `bump_hook`). Omitted from the pre-image when absent (`skip_serializing_if`),
41//! so a `--bump`-less plan hashes byte-for-byte as it did before this field
42//! existed — the additive superset that made a `SEAL_VERSION` bump unnecessary.
43//!
44//! ## Coordinator seam (what the sibling consumes)
45//!
46//! The coordinator refuses a `release cut --plan <id>` on drift by re-deriving
47//! current state and calling [`verify`]. It needs to persist only two plain
48//! fields from an approved plan — `plan_id` and `version` — into its journal;
49//! the approved [`ReleasePlan`] is otherwise reconstructed via [`build`] from
50//! the journalled sealed inputs. The plan DTOs are therefore `Serialize`-only,
51//! matching the repo-wide convention that the wire enums (`Ecosystem`/`Registry`
52//! /`Adapter`) do not derive `Deserialize` (they collect-all-errors on parse).
53//! The trust boundary is the *local journal*: an approved plan is one ossctl
54//! itself wrote, not untrusted caller input.
55//!
56//! ## Out of this worker's scope (handed to the coordinator)
57//!
58//! - **Working-tree cleanliness.** The seal binds `HEAD`, not uncommitted
59//! changes. Enforcing a clean tree / executing from a clean checkout of the
60//! sealed commit is an *execution* guard the coordinator owns (it needs a new
61//! read-only `GitRepo` status port). Until then a dirty tree can publish code
62//! that differs from the sealed commit — an accepted, documented gap.
63//!
64//! **Adapter tool *versions* (accepted gap).** ADR-0002 §3 names "resolved
65//! adapter identities+versions". The adapter registry (a sibling unit) is not
66//! landed, so no adapter *tool version* (e.g. a pinned `cargo-dist` release) is
67//! resolvable yet; today the address binds adapter **identity** (the enum). When
68//! the registry lands, fold the resolved versions into the pre-image — a
69//! deliberate `schema_version`-bumping change to what the address covers, never
70//! a silent one.
71//!
72//! Determinism: no wall-clock, no id-gen, no ordering-unstable map enters the
73//! pre-image — identical `(contract, facts, head, version)` always yield the
74//! same `plan_id` (proven in tests).
75
76use std::collections::BTreeSet;
77
78use serde::Serialize;
79
80use crate::contract::schema::{ChangelogMode, Contract, Ecosystem, Registry};
81use crate::protocol::facts::Facts;
82use crate::protocol::plan::{BumpLevel, BumpPlan, PinRewrite, PlanPhase, PlanTarget, ReleasePlan};
83
84/// Build and seal a [`ReleasePlan`] from an already-normalized `contract` and
85/// detected `facts`, at git `head_sha`, for the chosen `version`.
86///
87/// The caller (the `ossctl-cli` handler behind `release plan`, or the release
88/// coordinator re-deriving current state) is responsible for having normalized
89/// the contract and gathered the facts through the same code paths behind
90/// `contract show` / `facts` — this function never re-parses `OSS-RELEASE.md`
91/// nor re-derives facts. `version` is treated as an opaque, already-validated
92/// identifier (scheme-specific validation — semver vs a calver pattern — is the
93/// contract's/skill's job, not the plan's).
94#[must_use]
95pub fn build(contract: &Contract, facts: &Facts, head_sha: &str, version: &str) -> ReleasePlan {
96 build_inner(contract, facts, head_sha, version, None)
97}
98
99/// Build and seal a `--bump` [`ReleasePlan`]: an engine-owned version-bump plan
100/// that computes a new version from the current manifest version + a semantic
101/// `level` and owns the deterministic edit set (`release-rust-workspace-multicrate`
102/// facet 2).
103///
104/// `from_version` is the current `[workspace.package] version` (the tree's single
105/// source of truth); the engine **computes** the new version by applying `level` to
106/// it ([`crate::release::bump::bump_version`]) — the caller supplies only the level,
107/// never a literal target, so the plan can never seal a `to_version` that contradicts
108/// its declared `level` (the invariant lives in the core constructor, not the CLI).
109/// The returned plan carries a [`PlanPhase::Bump`] at the front of its phase sequence
110/// and a [`BumpPlan`] describing the edits (pin rewrites, CHANGELOG finalize, any
111/// declared `bump_hook`), all folded into the content address. Its
112/// [`ReleasePlan::version`] is the computed new version — every publish/tag threads it.
113///
114/// A `--bump`-less plan is [`build`]; the two share every non-bump derivation, so
115/// the bump path is a strict additive superset.
116///
117/// # Errors
118/// [`BumpError`](crate::release::bump::BumpError) when `from_version` is not a strict
119/// `MAJOR.MINOR.PATCH` release version — the engine will not seal a plan whose computed
120/// version it cannot derive (fail closed).
121pub fn build_with_bump(
122 contract: &Contract,
123 facts: &Facts,
124 head_sha: &str,
125 from_version: &str,
126 level: BumpLevel,
127) -> Result<ReleasePlan, crate::release::bump::BumpError> {
128 let to_version = crate::release::bump::bump_version(level, from_version)?;
129 let bump = derive_bump_plan(contract, facts, level, from_version, &to_version);
130 Ok(build_inner(
131 contract,
132 facts,
133 head_sha,
134 &to_version,
135 Some(bump),
136 ))
137}
138
139/// The shared core of [`build`] / [`build_with_bump`]: resolve targets, assemble the
140/// (bump-aware) phase sequence, seal, and construct the [`ReleasePlan`]. `bump` is
141/// `None` for the default path (identical output and `plan_id` to before this field
142/// existed) and `Some` for a `--bump` plan.
143#[must_use]
144fn build_inner(
145 contract: &Contract,
146 facts: &Facts,
147 head_sha: &str,
148 version: &str,
149 bump: Option<BumpPlan>,
150) -> ReleasePlan {
151 let targets = resolve_targets(contract, facts);
152 let phases = bump_aware_phases(bump.is_some());
153 let plan_id = seal(
154 contract,
155 &targets,
156 head_sha,
157 version,
158 &phases,
159 bump.as_ref(),
160 );
161 ReleasePlan {
162 plan_id,
163 contract_schema_version: contract.schema_version,
164 head_sha: head_sha.to_string(),
165 version: version.to_string(),
166 targets,
167 phases,
168 bump,
169 // Carried from the (already-hashed) contract so the coordinator can hand
170 // the Homebrew adapter its tap + license without re-reading the contract.
171 // The first distribution that declares a tap — identical to the old
172 // single-`Distribution` behavior. The release-engine CLI path
173 // (`ensure_single_distribution`) rejects a multi-distribution monorepo
174 // BEFORE reaching here, so `distributions.len() <= 1` and this `find_map`
175 // never silently drops a second distribution's tap; carrying a per-package
176 // tap for a true multi-tap monorepo is a deliberate follow-up.
177 homebrew_tap: contract
178 .distributions
179 .iter()
180 .find_map(|d| d.homebrew_tap.clone()),
181 license: Some(contract.license.clone()),
182 }
183}
184
185/// Compute the content-addressed `plan_id` of a **`--bump`-less** plan for
186/// `(contract, facts, head_sha, version)` **without** allocating a full
187/// [`ReleasePlan`].
188///
189/// The drift-check seam for the coordinator: given the plan a human approved, it
190/// re-derives the *current* repo's contract + facts + `HEAD`, calls this with
191/// the approved plan's sealed `version`, and compares. Prefer [`verify`], which
192/// wraps this and reports *which* inputs drifted; this raw form is exposed for
193/// callers that only need the digest.
194///
195/// **No-bump only.** This seals the invariant phase sequence with **no** bump plan,
196/// so it computes the id of the *no-bump* plan for these inputs — it is **not** the id
197/// of a `--bump` plan (that comes from [`build_with_bump`]). The bump-aware drift check
198/// lives in the CLI (`cut` re-derives via [`build_with_bump`] and compares `plan_id`
199/// directly); this helper is unchanged by the bump feature and stays no-bump.
200#[must_use]
201pub fn compute_plan_id(
202 contract: &Contract,
203 facts: &Facts,
204 head_sha: &str,
205 version: &str,
206) -> String {
207 let targets = resolve_targets(contract, facts);
208 seal(
209 contract,
210 &targets,
211 head_sha,
212 version,
213 &PlanPhase::SEQUENCE,
214 None,
215 )
216}
217
218/// Check whether an `approved` plan still matches the **current** repo state.
219///
220/// The coordinator calls this before crossing into any irreversible phase of
221/// `release cut --plan <plan_id>`. It re-derives the current `plan_id` from the
222/// current `contract`, `facts`, and `head_sha`, holding the *chosen version*
223/// fixed to the approved plan's (a cut may not change the sealed version — that
224/// would require a new plan). `Ok(())` means the approval is still valid; a
225/// [`PlanDrift`] carries the mismatched id pair and human-readable reasons for
226/// the `plan_stale` error envelope. The `plan_id` mismatch is authoritative;
227/// the reasons are **best-effort and may be non-exhaustive** — the approved
228/// plan intentionally does not retain the old normalized contract (trust the
229/// journal, not a re-supplied contract), so an exact field-level contract diff
230/// is not possible here. When more than one input drifts, the reasons name
231/// every one they can pinpoint (`HEAD`, schema version, target set) and fall
232/// back to a generic contract-changed note only when none of those explain it.
233///
234/// # Errors
235/// Returns [`PlanDrift`] when the recomputed `plan_id` differs from
236/// `approved.plan_id` — i.e. the repo moved (a commit, a manifest rename, a
237/// schema bump, a target-set change, or any normalized-contract change) since
238/// approval.
239pub fn verify(
240 approved: &ReleasePlan,
241 contract: &Contract,
242 facts: &Facts,
243 head_sha: &str,
244) -> Result<(), PlanDrift> {
245 let current_targets = resolve_targets(contract, facts);
246 // Hold the sealed *shape* — the approved plan's phase sequence and bump plan —
247 // fixed while re-deriving targets from the current contract/facts: verify checks
248 // for contract/head/target drift, not a re-computation of the bump itself (a cut
249 // recomputes the bump from `--bump` + the current manifest via `build_with_bump`
250 // and compares `plan_id` directly; verify is the read-only reconcile seam).
251 let current_id = seal(
252 contract,
253 ¤t_targets,
254 head_sha,
255 &approved.version,
256 &approved.phases,
257 approved.bump.as_ref(),
258 );
259 if current_id == approved.plan_id {
260 return Ok(());
261 }
262
263 // The ids differ; pinpoint *why* so the coordinator can surface an
264 // actionable `plan_stale` message rather than a bare hash mismatch.
265 let mut reasons = Vec::new();
266 if approved.head_sha != head_sha {
267 reasons.push(format!(
268 "HEAD moved from {} to {}",
269 short_sha(&approved.head_sha),
270 short_sha(head_sha)
271 ));
272 }
273 if approved.contract_schema_version != contract.schema_version {
274 reasons.push(format!(
275 "contract schema_version changed from {} to {}",
276 approved.contract_schema_version, contract.schema_version
277 ));
278 }
279 if approved.targets != current_targets {
280 reasons.push(
281 "the resolved target set changed (a target, package, registry, or adapter differs)"
282 .to_string(),
283 );
284 }
285 // A change the specific probes above did not catch (any other normalized
286 // contract field: version scheme, changelog, license, health badges, …).
287 if reasons.is_empty() {
288 reasons.push("the normalized contract changed".to_string());
289 }
290
291 Err(PlanDrift {
292 approved_plan_id: approved.plan_id.clone(),
293 current_plan_id: current_id,
294 reasons,
295 })
296}
297
298/// Why a `release cut --plan <plan_id>` was refused: the current repo no longer
299/// hashes to the approved plan (ADR-0002 §3, `plan_stale`).
300#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
301pub struct PlanDrift {
302 /// The `plan_id` the human approved.
303 pub approved_plan_id: String,
304 /// The `plan_id` the current repo state produces.
305 pub current_plan_id: String,
306 /// Human-readable specifics of what drifted (`HEAD` moved, the target set
307 /// changed, …) — at least one entry.
308 pub reasons: Vec<String>,
309}
310
311/// Whether a publish target derives its release version from a package manifest
312/// the version guard can read, or has no manifest version by design — the capability
313/// the fail-closed guard keys on (`version-source-fail-closed-nonrust`).
314///
315/// The distinction is a function of the target's **[`Ecosystem`]**, not its publish
316/// registry. A Rust/Node/Python package carries its version in a manifest
317/// (`Cargo.toml`/`package.json`/`pyproject.toml`) regardless of *where* it is
318/// published — a Rust crate repackaged for a Homebrew tap still reads its version
319/// from `Cargo.toml`, so it is [`Manifest`](VersionSource::Manifest). Keying on the
320/// registry instead would wrongly treat that crate (and a binary-distribution-only
321/// Rust repo) as versionless and refuse to derive a version that is plainly in the
322/// tree.
323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324pub enum VersionSource {
325 /// The ecosystem carries the package version in a manifest
326 /// (`rust`←`Cargo.toml`, `node`←`package.json`, `python`←`pyproject.toml`/`setup.py`).
327 /// A resolved target of this class **must** expose a detected manifest version in
328 /// `facts`; a resolved package with none is a *detector failure* that fails the
329 /// guard **closed** ([`VersionResolveError::MissingManifestVersion`]) rather than
330 /// silently skipping the version check (the fail-OPEN gap for manifest-versioned
331 /// non-Rust ecosystems this model closes).
332 Manifest,
333 /// No manifest version **by design**: the ecosystem's version does not live in a
334 /// tree manifest — a raw `binary` distribution (its version binds to the artifact
335 /// it ships), or a VCS-tag-versioned `go` module (`go.mod` declares no version).
336 /// Legitimately **skipped** by the version guard: there is no manifest to read a
337 /// version from and none is expected.
338 Distribution,
339}
340
341impl VersionSource {
342 /// Classify a target by its [`Ecosystem`] (the ecosystem is the authority on
343 /// whether a package's version lives in a tree manifest).
344 ///
345 /// Exhaustive over [`Ecosystem`] on purpose — a new ecosystem must make a
346 /// deliberate manifest-vs-distribution choice here rather than default to a silent
347 /// skip (which would re-open the fail-OPEN gap).
348 #[must_use]
349 pub fn of(ecosystem: Ecosystem) -> Self {
350 match ecosystem {
351 // Ecosystems whose package version lives in a version-carrying manifest.
352 Ecosystem::Rust | Ecosystem::Node | Ecosystem::Python => Self::Manifest,
353 // No tree-manifest version: a raw binary (versioned by the built artifact),
354 // or a Go module (versioned by its VCS tag).
355 Ecosystem::Go | Ecosystem::Binary => Self::Distribution,
356 }
357 }
358}
359
360/// One publishable target's resolved package paired with the version its **tree
361/// manifest** declares — the version the ecosystem's publish command (`cargo
362/// publish` reading `Cargo.toml`, …) would **actually** upload.
363///
364/// The workspace manifest is the single source of truth for the release version
365/// ([`resolve_release_version`]); this is one row of that truth. A tree whose
366/// manifests disagree among themselves carries a set of these
367/// ([`VersionResolveError::InconsistentTree`]).
368#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
369pub struct VersionMismatch {
370 /// The resolved package this row describes.
371 pub package: String,
372 /// The package's ecosystem.
373 pub ecosystem: Ecosystem,
374 /// The version declared in the tree manifest — what the ecosystem's publish
375 /// command (`cargo publish` reading `Cargo.toml`, …) would **actually**
376 /// upload for this package.
377 pub manifest_version: String,
378}
379
380/// A manifest-versioned target ([`VersionSource::Manifest`]) whose resolved package
381/// has **no** detected manifest version in `facts` — the fail-closed row for
382/// `version-source-fail-closed-nonrust`.
383///
384/// Unlike a [`VersionSource::Distribution`] target (skipped by design), a manifest
385/// target with no readable version means the detector failed on an ecosystem that
386/// *is* manifest-versioned. The guard refuses rather than publish an unchecked
387/// version.
388#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
389pub struct UnversionedTarget {
390 /// The resolved package whose manifest version could not be read.
391 pub package: String,
392 /// The package's ecosystem.
393 pub ecosystem: Ecosystem,
394 /// The publish destination — the registry whose manifest a version was expected
395 /// from (`npm`←`package.json`, `PyPI`←`pyproject.toml`, …).
396 pub registry: Registry,
397}
398
399/// Why a single release version could not be resolved from the workspace manifest —
400/// the **single source of truth** for the release version. `ossctl release cut`
401/// publishes the version already in the tree; there is no `--version` input to
402/// override it (`release-drop-version-flag`).
403#[derive(Debug, Clone, PartialEq, Eq)]
404pub enum VersionResolveError {
405 /// One or more **manifest-versioned** targets ([`VersionSource::Manifest`]) have a
406 /// resolved package but **no** detected manifest version — the detector returned
407 /// nothing for an ecosystem that *is* manifest-versioned (npm/PyPI/…). Failing
408 /// **closed** here (rather than silently skipping the target) is the fix for
409 /// `version-source-fail-closed-nonrust`: a distribution target is skipped by
410 /// design, but a manifest target with no readable version is a bug that must not
411 /// publish an unchecked version. Carries each such target (sorted, one per
412 /// package).
413 MissingManifestVersion {
414 /// Every manifest-versioned target whose version could not be read.
415 targets: Vec<UnversionedTarget>,
416 },
417 /// The tree's publishable manifests declare **more than one distinct version**,
418 /// so there is no single source of truth to derive the release version from —
419 /// bring the workspace into lockstep first. Carries each checkable target's
420 /// package + version (sorted, one per package).
421 InconsistentTree {
422 /// Every checkable target and the version its manifest declares.
423 versions: Vec<VersionMismatch>,
424 },
425 /// No manifest version could be detected — every target is a distribution target
426 /// with no manifest version by design (or has no resolved package) — so there is
427 /// no manifest to derive the release version from. With the `--version` input
428 /// removed, the release version can **only** come from a manifest; a repo with no
429 /// version-carrying manifest cannot be cut until one declares a version.
430 Undeterminable,
431}
432
433/// Resolve the release version from the workspace manifest — the **single source of
434/// truth**.
435///
436/// `ossctl release cut` does **not** bump the manifest: each ecosystem's publish
437/// command uploads the version already in the tree (`cargo publish` reads
438/// `Cargo.toml`), and the engine threads that version into every registry probe,
439/// index-wait, and receipt. So the version a cut publishes is a **projection of the
440/// tree**, not an independent input — there is no `--version` flag to override it
441/// (`release-drop-version-flag`), which removes the two-masters footgun at the root
442/// (a flag and the manifest could silently drift, the engine publishing the manifest
443/// version while waiting for/recording the flag's, which never lands —
444/// `release-cut-publish-noop`).
445///
446/// The manifest version is the distinct version shared by every **checkable** target
447/// (a [`VersionSource::Manifest`] target with a detected manifest version in
448/// `facts`). A [`VersionSource::Distribution`] target (a homebrew/binary/cargo-dist
449/// target) has no manifest version by design — its release version is bound to the
450/// crate it repackages — so it is skipped. A manifest-versioned target whose version
451/// the detector could not read is **not** skipped: it fails the guard closed
452/// (`version-source-fail-closed-nonrust`).
453///
454/// # Errors
455/// - [`VersionResolveError::MissingManifestVersion`] — a manifest-versioned target
456/// has a resolved package but no readable manifest version (fail closed).
457/// - [`VersionResolveError::InconsistentTree`] — the checkable targets declare more
458/// than one distinct version, so no single source of truth exists.
459/// - [`VersionResolveError::Undeterminable`] — no manifest version anywhere to derive
460/// from.
461pub fn resolve_release_version(
462 contract: &Contract,
463 facts: &Facts,
464) -> Result<String, VersionResolveError> {
465 let classified = classify_target_versions(contract, facts);
466
467 // Fail CLOSED first: a manifest-versioned target whose version the detector could
468 // not read is NOT silently skipped (that would fail OPEN — publishing a version no
469 // guard confirmed). This is the `version-source-fail-closed-nonrust` fix.
470 if !classified.missing.is_empty() {
471 return Err(VersionResolveError::MissingManifestVersion {
472 targets: classified.missing,
473 });
474 }
475
476 let distinct: BTreeSet<&str> = classified
477 .checkable
478 .iter()
479 .map(|m| m.manifest_version.as_str())
480 .collect();
481
482 match distinct.len() {
483 // No manifest version anywhere to derive from (every target is a distribution
484 // target, or has no resolved package). With `--version` removed there is no
485 // fallback — a repo without a version-carrying manifest cannot be cut.
486 0 => Err(VersionResolveError::Undeterminable),
487 // One source of truth: every checkable row shares it, so any row's version is
488 // THE manifest version.
489 1 => Ok(classified.checkable[0].manifest_version.clone()),
490 // The tree disagrees with itself — no single source of truth to project.
491 _ => Err(VersionResolveError::InconsistentTree {
492 versions: classified.checkable,
493 }),
494 }
495}
496
497/// The version-source classification of a repo's resolved targets: the checkable
498/// rows the release version is projected from, and the manifest-versioned targets
499/// whose version could not be read (the fail-closed set).
500struct ClassifiedVersions {
501 /// [`VersionSource::Manifest`] targets **with** a detected manifest version — the
502 /// checkable set the single release version is derived from.
503 checkable: Vec<VersionMismatch>,
504 /// [`VersionSource::Manifest`] targets with a resolved package but **no** detected
505 /// manifest version — the fail-closed set (`version-source-fail-closed-nonrust`).
506 missing: Vec<UnversionedTarget>,
507}
508
509/// Classify every resolved target by its [`VersionSource`], separating the checkable
510/// manifest versions from the manifest-versioned targets whose version could not be
511/// read.
512///
513/// - A [`VersionSource::Distribution`] target (a `binary`/`go` ecosystem) is skipped
514/// regardless of version: it has no tree-manifest version by design.
515/// - A [`VersionSource::Manifest`] target with a detected version becomes a `checkable`
516/// row; one with a resolved package but **no** detected version becomes a `missing`
517/// row (fail closed).
518/// - A manifest target with **no resolved package** cannot be looked up here at all.
519/// Package resolution is a separate concern guarded elsewhere — `release plan` warns
520/// and `release cut` refuses via `coordinator::validate_plan` — so it is not
521/// double-reported here as a version failure. (Deeper: hardening the resolver itself
522/// to fail closed on an unresolved manifest target is tracked as a follow-up.)
523fn classify_target_versions(contract: &Contract, facts: &Facts) -> ClassifiedVersions {
524 let mut checkable: Vec<VersionMismatch> = Vec::new();
525 let mut missing: Vec<UnversionedTarget> = Vec::new();
526 for t in resolve_targets(contract, facts) {
527 // Distribution ecosystems have no tree-manifest version by design — skip them
528 // whether or not `facts` happens to carry a version for their package.
529 if VersionSource::of(t.ecosystem) == VersionSource::Distribution {
530 continue;
531 }
532 // A manifest target with no resolved package cannot be version-checked here
533 // (see the null-package guards named above).
534 let Some(package) = t.package else { continue };
535 match facts
536 .packages
537 .iter()
538 .find(|p| p.ecosystem == t.ecosystem && p.package.as_deref() == Some(package.as_str()))
539 .and_then(|p| p.version.clone())
540 {
541 Some(manifest_version) => checkable.push(VersionMismatch {
542 package,
543 ecosystem: t.ecosystem,
544 manifest_version,
545 }),
546 // Manifest-versioned, resolved package, but the detector read no version:
547 // fail closed rather than skip (the non-Rust fail-OPEN gap).
548 None => missing.push(UnversionedTarget {
549 package,
550 ecosystem: t.ecosystem,
551 registry: t.registry,
552 }),
553 }
554 }
555 // Deterministic order, and one row per package even if a package backs several
556 // targets (a crate published to crates.io AND repackaged for homebrew). Sort and
557 // dedup on the SAME (ecosystem, package) key so equal keys are guaranteed adjacent
558 // before the consecutive-only `dedup_by` runs.
559 checkable.sort_by(|a, b| {
560 (a.ecosystem.as_str(), &a.package).cmp(&(b.ecosystem.as_str(), &b.package))
561 });
562 checkable.dedup_by(|a, b| a.package == b.package && a.ecosystem == b.ecosystem);
563 missing.sort_by(|a, b| {
564 (a.ecosystem.as_str(), &a.package).cmp(&(b.ecosystem.as_str(), &b.package))
565 });
566 missing.dedup_by(|a, b| a.package == b.package && a.ecosystem == b.ecosystem);
567 ClassifiedVersions { checkable, missing }
568}
569
570/// Overlay facts-derived package names onto the contract's target set, yielding
571/// the concrete targets a cut would execute, then **expand a multi-crate Rust
572/// workspace** into its full dependency-ordered publish set.
573///
574/// Base resolution is 1:1 with the contract's (normalizer-canonical) `targets`,
575/// resolving a `null` package from facts. Then [`expand_rust_workspace_members`]
576/// derives the complete crates.io publish set for a Cargo workspace from
577/// [`Facts::rust_workspace`]: a downstream repo that declares only its bin crate
578/// still gets its lib crate planned, lib-before-bin, so a cut never `cargo publish`es
579/// a crate whose `=`-pinned workspace sibling is not yet on the index
580/// (`release-rust-workspace-multicrate`). A repo that already declares every member
581/// (ossctl itself) is unchanged: the derived set equals what it declared.
582/// The coordinator phase sequence for a plan, prepending [`PlanPhase::Bump`] when
583/// the plan owns a version bump. A `--bump`-less plan yields exactly
584/// [`PlanPhase::SEQUENCE`], so its sealed `phases` (and `plan_id`) are unchanged.
585fn bump_aware_phases(has_bump: bool) -> Vec<PlanPhase> {
586 if has_bump {
587 let mut phases = Vec::with_capacity(PlanPhase::SEQUENCE.len() + 1);
588 phases.push(PlanPhase::Bump);
589 phases.extend_from_slice(&PlanPhase::SEQUENCE);
590 phases
591 } else {
592 PlanPhase::SEQUENCE.to_vec()
593 }
594}
595
596/// Assemble the [`BumpPlan`] — the deterministic edit set the bump phase applies —
597/// from the contract + workspace facts and the caller-computed `from`/`to` versions.
598fn derive_bump_plan(
599 contract: &Contract,
600 facts: &Facts,
601 level: BumpLevel,
602 from_version: &str,
603 to_version: &str,
604) -> BumpPlan {
605 BumpPlan {
606 level,
607 from_version: from_version.to_string(),
608 to_version: to_version.to_string(),
609 pin_rewrites: derive_pin_rewrites(facts, from_version, to_version),
610 changelog_finalize: changelog_is_finalizable(contract),
611 // Copied from the (already-hashed) contract so the executor need not re-read it;
612 // being a copy of a hashed value it adds no new content to the address beyond
613 // its presence on the bump plan.
614 bump_hook: contract.release.bump_hook.clone(),
615 }
616}
617
618/// Whether the bump phase finalizes the CHANGELOG (`[Unreleased]` → a dated
619/// `[to_version]` section).
620///
621/// True for the human/fragment-authored modes (`curated`, `fragment`) whose
622/// `[Unreleased]` section the engine promotes on release. False for `automated`,
623/// where a release bot (release-please/changesets) owns the CHANGELOG and the engine
624/// must not also rewrite it (a double-writer would clash). The concrete date is a
625/// cut-time value and is deliberately not part of the plan (see [`BumpPlan::changelog_finalize`]).
626///
627/// An **exhaustive** match (not `!= Automated`) so a future `ChangelogMode` variant —
628/// e.g. a "none"/"off" that means *no* changelog to finalize — must make a deliberate
629/// choice here rather than silently defaulting to engine-finalized (which would seal a
630/// bump plan that promotes a changelog that does not exist).
631fn changelog_is_finalizable(contract: &Contract) -> bool {
632 match contract.changelog.mode {
633 ChangelogMode::Curated | ChangelogMode::Fragment => true,
634 ChangelogMode::Automated => false,
635 }
636}
637
638/// Derive the intra-workspace `=`-version pin rewrites the bump applies in lockstep
639/// with the workspace version.
640///
641/// For each publishable workspace member and each of its intra-workspace dependency
642/// edges (`M` depends on `D`, both members), the workspace's `=`-pinning convention
643/// (the bin's `lib = "=<workspace version>"`, `release-rust-workspace-multicrate`)
644/// means `M`'s manifest carries a `D = "=<from_version>"` pin that must become
645/// `D = "=<to_version>"`. Emitted deterministically (sorted by dependent then
646/// dependency), one per edge; empty for a single-crate workspace or a repo with no
647/// detected workspace graph.
648///
649/// **Precise, not over-broad** (`release-rust-workspace-multicrate` facet 3, llm-review):
650/// a rewrite is emitted **only** when the member's manifest declares that edge's
651/// requirement literally as `=<from_version>` — the exact lockstep pin — read from
652/// [`WorkspaceMember::dep_reqs`](crate::protocol::facts::WorkspaceMember). A
653/// caret/range/`workspace = true`/independently-versioned edge (whose recorded
654/// requirement is absent or is not `=<from_version>`) is **skipped**, so the bump never
655/// clobbers a `^1.2` or a `workspace = true` sibling that does not track the workspace
656/// version in lockstep. Skipping a genuinely-lockstepped edge whose requirement the
657/// parser could not read (a dotted-key blind spot) fails the cut *closed* — the stale
658/// `=<from>` pin the publish rejects — never a mis-rewrite. The executor re-verifies the
659/// exact old value in the manifest before replacing (fail closed on zero/multiple).
660fn derive_pin_rewrites(facts: &Facts, from_version: &str, to_version: &str) -> Vec<PinRewrite> {
661 let Some(workspace) = facts.rust_workspace.as_ref() else {
662 return Vec::new();
663 };
664 let is_member: BTreeSet<&str> = workspace
665 .members
666 .iter()
667 .map(|m| m.package.as_str())
668 .collect();
669 let from_pin = format!("={from_version}");
670 let mut rewrites: Vec<PinRewrite> = Vec::new();
671 for member in &workspace.members {
672 for dep in &member.workspace_deps {
673 // Only edges to another publishable member carry an intra-workspace pin.
674 if !is_member.contains(dep.as_str()) {
675 continue;
676 }
677 // Only a literal `=<from_version>` lockstep pin is rewritten — a caret/range
678 // or an inherited (`workspace = true`) requirement is left untouched.
679 if member.dep_reqs.get(dep).map(String::as_str) != Some(from_pin.as_str()) {
680 continue;
681 }
682 rewrites.push(PinRewrite {
683 in_package: member.package.clone(),
684 dependency: dep.clone(),
685 from: from_pin.clone(),
686 to: format!("={to_version}"),
687 });
688 }
689 }
690 rewrites.sort_unstable_by(|a, b| {
691 (&a.in_package, &a.dependency).cmp(&(&b.in_package, &b.dependency))
692 });
693 // Defensive dedup: a well-formed facts graph lists each (member, dep) edge once, so
694 // this is a no-op in practice; it guards against a facts parser that emitted a
695 // duplicate edge producing a duplicated rewrite.
696 rewrites.dedup_by(|a, b| a.in_package == b.in_package && a.dependency == b.dependency);
697 rewrites
698}
699
700fn resolve_targets(contract: &Contract, facts: &Facts) -> Vec<PlanTarget> {
701 let base: Vec<PlanTarget> = contract
702 .targets
703 .iter()
704 .map(|t| {
705 let package = t
706 .package
707 .clone()
708 .or_else(|| resolve_package(facts, t.ecosystem));
709 PlanTarget {
710 ecosystem: t.ecosystem,
711 package,
712 registry: t.registry,
713 adapter: t.adapter,
714 }
715 })
716 .collect();
717 expand_rust_workspace_members(base, facts)
718}
719
720/// Whether a resolved target is a Rust crate published to crates.io via
721/// `cargo-publish` — the target class the workspace-member derivation expands (a
722/// `cargo-dist` binary distribution or a non-crates.io registry is left untouched).
723fn is_rust_crates_io_publish(t: &PlanTarget) -> bool {
724 t.ecosystem == Ecosystem::Rust
725 && t.registry == Registry::CratesIo
726 && t.adapter == crate::contract::schema::Adapter::CargoPublish
727}
728
729/// Expand the crates.io `cargo-publish` Rust targets of `base` into the
730/// **dependency-ordered closure** of the declared crates (lib before bin), leaving
731/// every other target in place.
732///
733/// The gap this closes (`release-rust-workspace-multicrate`): a two-crate workspace
734/// (a lib + a bin pinning `lib = "=X"`) whose contract declares **only** the bin as a
735/// target would plan a single `cargo publish <bin>` — which fails, because `lib@X` is
736/// not yet on crates.io. From [`Facts::rust_workspace`] this derives the bin's
737/// intra-workspace dependency closure and adds each dep as its own ordered target so
738/// the coordinator publishes lib → bin (ADR-0004, one target = one publish unit; the
739/// coordinator walks plan order and the adapter index-waits on each crate's own deps).
740///
741/// **Closure, not "every member".** The publish set is the declared Rust crates.io
742/// targets plus their transitive intra-workspace dependencies — **never** an unrelated
743/// publishable member the contract deliberately omitted (a not-yet-release-ready
744/// crate). Publishing is irreversible, so "all publishable members" would be the wrong,
745/// dangerous safety property. It is still a **strict superset of what the contract
746/// declared**: every declared Rust crates.io package is a closure root (a package not
747/// present as a workspace member is planned as-is, never dropped). For a repo that
748/// already declares every member (ossctl itself) the closure equals the declared set,
749/// so its plan is unchanged.
750///
751/// **Ambiguity is preserved, never expanded.** If any Rust crates.io target is
752/// unresolved (`package: None` — a monorepo the facts could not disambiguate), `base`
753/// is returned untouched so the downstream null-package guard/warning fires; an
754/// unnamed target must never be silently turned into a workspace-wide publish.
755///
756/// The derived targets are spliced in at the position of the **first** Rust crates.io
757/// target; the contract's other targets (cargo-dist, homebrew, a non-crates.io
758/// registry) keep their relative order. Cross-ecosystem/registry order is immaterial
759/// to correctness (publishes are independent per registry and the single tag is taken
760/// after *all* publishes), so hoisting the crates.io block changes no behavior. When
761/// there is no Rust crates.io target, or the repo is not a multi-crate workspace
762/// ([`Facts::rust_workspace`] is `None`), `base` is returned unchanged — so a
763/// single-crate repo and every non-Rust plan are untouched.
764fn expand_rust_workspace_members(base: Vec<PlanTarget>, facts: &Facts) -> Vec<PlanTarget> {
765 let Some(workspace) = facts.rust_workspace.as_ref() else {
766 return base;
767 };
768 let first_rust = base.iter().position(is_rust_crates_io_publish);
769 let Some(first_rust_idx) = first_rust else {
770 return base;
771 };
772 // Never expand an ambiguous (unresolved-package) Rust crates.io target into a
773 // workspace-wide publish: leave the plan untouched so the downstream null-package
774 // guard refuses it. (`is_rust_crates_io_publish` targets only.)
775 if base
776 .iter()
777 .filter(|t| is_rust_crates_io_publish(t))
778 .any(|t| t.package.is_none())
779 {
780 return base;
781 }
782 // The declared crates.io Rust packages — the closure roots (all resolved by the
783 // guard above).
784 let roots: Vec<String> = base
785 .iter()
786 .filter(|t| is_rust_crates_io_publish(t))
787 .filter_map(|t| t.package.clone())
788 .collect();
789 // The (uniform) registry+adapter every derived member target carries — taken from
790 // the representative target so the derived crates match how the contract publishes
791 // Rust (crates.io / cargo-publish, by construction of `is_rust_crates_io_publish`).
792 let representative = base[first_rust_idx].clone();
793
794 let ordered_packages = dependency_closure_order(&roots, &workspace.members);
795 let derived: Vec<PlanTarget> = ordered_packages
796 .into_iter()
797 .map(|package| PlanTarget {
798 ecosystem: Ecosystem::Rust,
799 package: Some(package),
800 registry: representative.registry,
801 adapter: representative.adapter,
802 })
803 .collect();
804
805 // Splice: derived member set at the first Rust crates.io position; all other
806 // (non-Rust-crates.io) targets keep their relative order around it.
807 let mut out: Vec<PlanTarget> = Vec::with_capacity(base.len() + derived.len());
808 let mut spliced = false;
809 for t in base {
810 if is_rust_crates_io_publish(&t) {
811 if !spliced {
812 out.extend(derived.iter().cloned());
813 spliced = true;
814 }
815 // Drop the original Rust crates.io target — it is represented in `derived`.
816 continue;
817 }
818 out.push(t);
819 }
820 out
821}
822
823/// The dependency-ordered publish set for `roots`: the transitive intra-workspace
824/// dependency closure of the declared crates, topologically ordered (a dependency
825/// before its dependents).
826///
827/// The closure follows [`WorkspaceMember::workspace_deps`](crate::protocol::facts::WorkspaceMember)
828/// edges from each root. A root that is **not** a workspace member (an explicitly
829/// declared package the graph did not capture) contributes no edges but is still
830/// included — the superset guarantee. Only members in the closure are ordered; an
831/// unrelated publishable member the contract omitted never enters the set.
832fn dependency_closure_order(
833 roots: &[String],
834 members: &[crate::protocol::facts::WorkspaceMember],
835) -> Vec<String> {
836 use std::collections::BTreeMap;
837 let by_name: BTreeMap<&str, &crate::protocol::facts::WorkspaceMember> =
838 members.iter().map(|m| (m.package.as_str(), m)).collect();
839
840 // Transitive closure of `roots` over workspace_deps edges.
841 let mut required: BTreeSet<String> = BTreeSet::new();
842 let mut stack: Vec<String> = roots.to_vec();
843 while let Some(pkg) = stack.pop() {
844 if !required.insert(pkg.clone()) {
845 continue;
846 }
847 if let Some(member) = by_name.get(pkg.as_str()) {
848 for dep in &member.workspace_deps {
849 if !required.contains(dep) {
850 stack.push(dep.clone());
851 }
852 }
853 }
854 }
855
856 // Topologically order only the members inside the closure (declaration order
857 // preserved as the deterministic tie-break); append any root that is not a graph
858 // member (no edges to order, superset guarantee) in declared order.
859 let subgraph: Vec<crate::protocol::facts::WorkspaceMember> = members
860 .iter()
861 .filter(|m| required.contains(&m.package))
862 .cloned()
863 .collect();
864 let mut ordered = topo_order_members(&subgraph);
865 for root in roots {
866 if !ordered.iter().any(|p| p == root) {
867 ordered.push(root.clone());
868 }
869 }
870 ordered
871}
872
873/// Topologically order a workspace's publishable members so a dependency precedes
874/// its dependents (lib before bin) — the publish order the coordinator walks.
875///
876/// Kahn's algorithm with a **deterministic** tie-break: among members whose
877/// intra-workspace dependencies are all already emitted, the one earliest in
878/// declaration order is chosen next, so the output is stable and reproducible (a
879/// requirement of the content-addressed plan). Only edges to *other listed members*
880/// gate order (an edge to a filtered-out member cannot, and does not, block).
881///
882/// Emission is tracked **by index**, not by package name, so two members that happen
883/// to share a name (Cargo forbids this, but the graph is parsed from raw manifests)
884/// are both emitted rather than one masking the other. A dependency **cycle** (which
885/// Cargo itself rejects among normal/build deps, so unreachable for a valid
886/// workspace) cannot be ordered; the remaining members are appended in declaration
887/// order rather than dropped or looped on — the plan stays a faithful superset and the
888/// cut fails later with a concrete registry error, never a planner-omitted crate.
889fn topo_order_members(members: &[crate::protocol::facts::WorkspaceMember]) -> Vec<String> {
890 let names: BTreeSet<&str> = members.iter().map(|m| m.package.as_str()).collect();
891 // Remaining dependency count per member, counting only edges to other members.
892 let mut pending: Vec<usize> = members
893 .iter()
894 .map(|m| {
895 m.workspace_deps
896 .iter()
897 .filter(|d| names.contains(d.as_str()) && d.as_str() != m.package)
898 .count()
899 })
900 .collect();
901 // Emitted state per member INDEX (never by name — see the doc comment).
902 let mut emitted: Vec<bool> = vec![false; members.len()];
903 let mut order: Vec<String> = Vec::with_capacity(members.len());
904 // Each round emits the earliest-declared member whose deps are all emitted.
905 while order.len() < members.len() {
906 let next = (0..members.len()).find(|&i| !emitted[i] && pending[i] == 0);
907 let Some(idx) = next else {
908 // A cycle blocks every remaining member: append them in declaration order
909 // (deterministic) rather than loop forever or drop them.
910 for i in 0..members.len() {
911 if !emitted[i] {
912 emitted[i] = true;
913 order.push(members[i].package.clone());
914 }
915 }
916 break;
917 };
918 emitted[idx] = true;
919 order.push(members[idx].package.clone());
920 // Decrement dependents that depended on the just-emitted member.
921 for i in 0..members.len() {
922 if !emitted[i]
923 && pending[i] > 0
924 && members[i]
925 .workspace_deps
926 .iter()
927 .any(|d| *d == members[idx].package)
928 {
929 pending[i] -= 1;
930 }
931 }
932 }
933 order
934}
935
936/// The detected package name for `ecosystem`, resolved **only when
937/// unambiguous** — exactly one named manifest for that ecosystem.
938///
939/// `None` when no manifest named one (a virtual workspace, a binary-only repo)
940/// **or** when several do (a monorepo with multiple crates of one ecosystem):
941/// with no per-target manifest key in the contract, picking the first would
942/// silently mis-assign the same package to every `null` target, so we leave it
943/// `null` for cut-time inference instead. A monorepo should declare explicit
944/// per-target `package`s in the contract; the CLI warns when this fires.
945fn resolve_package(facts: &Facts, ecosystem: crate::contract::schema::Ecosystem) -> Option<String> {
946 let mut named = facts
947 .packages
948 .iter()
949 .filter(|p| p.ecosystem == ecosystem && p.package.is_some());
950 let first = named.next()?;
951 // More than one named candidate ⇒ ambiguous ⇒ do not guess.
952 if named.next().is_some() {
953 return None;
954 }
955 first.package.clone()
956}
957
958/// Domain separator baked into every pre-image so a `plan_id` can never be
959/// confused with any other SHA-256 an ossctl subsystem might compute over
960/// similar bytes. Ends in the seal-format version for readability; the numeric
961/// [`SEAL_VERSION`] is also hashed as its own field.
962const SEAL_DOMAIN: &str = "ossctl.release-plan";
963
964/// Version of the *hashing pre-image format* — the field set, their order, and
965/// the canonicalization. Independent of the contract-document or wire-envelope
966/// versions. Bump this (never silently) whenever the pre-image shape changes
967/// (e.g. once resolved adapter versions are folded in), so old and new plan ids
968/// are intentionally disjoint rather than accidentally colliding.
969const SEAL_VERSION: u32 = 5;
970
971/// The canonical hashed pre-image (see the module docs for the exact contents).
972/// A dedicated struct rather than an ad-hoc byte concatenation so the field set
973/// is explicit and serde's deterministic struct-field ordering fixes the byte
974/// layout.
975///
976/// **DO NOT REORDER these fields** — field order is part of the content address,
977/// so a reorder silently changes every `plan_id`. Evolve the format via
978/// [`SEAL_VERSION`] instead.
979#[derive(Serialize)]
980struct SealInput<'a> {
981 domain: &'static str,
982 seal_version: u32,
983 contract_schema_version: u32,
984 contract: &'a Contract,
985 head_sha: &'a str,
986 version: &'a str,
987 targets: &'a [PlanTarget],
988 phases: &'a [PlanPhase],
989 /// The engine-owned bump plan, or absent. Omitted from the pre-image when `None`
990 /// (`skip_serializing_if`), so a `--bump`-less plan hashes byte-for-byte as it did
991 /// before this field existed — the additive superset guarantee, and why the field
992 /// did not require a [`SEAL_VERSION`] bump (an absent field changes no existing
993 /// pre-image). A `--bump` plan's `phases` also differ (a leading `bump`), which the
994 /// already-hashed `phases` field independently binds.
995 #[serde(skip_serializing_if = "Option::is_none")]
996 bump: Option<&'a BumpPlan>,
997}
998
999/// Serialize the pre-image to canonical JSON and return its SHA-256 hex digest.
1000fn seal(
1001 contract: &Contract,
1002 targets: &[PlanTarget],
1003 head_sha: &str,
1004 version: &str,
1005 phases: &[PlanPhase],
1006 bump: Option<&BumpPlan>,
1007) -> String {
1008 let input = SealInput {
1009 domain: SEAL_DOMAIN,
1010 seal_version: SEAL_VERSION,
1011 contract_schema_version: contract.schema_version,
1012 contract,
1013 head_sha,
1014 version,
1015 targets,
1016 phases,
1017 bump,
1018 };
1019 // `to_vec` on a struct of only structs/Vecs/BTreeMaps (contract's
1020 // `extra_fields` is a `serde_json::Map` = `BTreeMap` without the
1021 // `preserve_order` feature) is deterministic — no wall-clock, no HashMap,
1022 // no float. It is also infallible for these concrete types; `expect` (never
1023 // `unwrap_or_default`, which would fail *open* by hashing an empty pre-image
1024 // and collide every failing plan on the empty-string digest).
1025 let bytes =
1026 serde_json::to_vec(&input).expect("release-plan pre-image is infallible to serialize");
1027 sha256::hex(&bytes)
1028}
1029
1030/// Short (first 12 hex chars) `HEAD` sha for drift messages; whole string if
1031/// shorter.
1032fn short_sha(sha: &str) -> &str {
1033 sha.get(..12).unwrap_or(sha)
1034}
1035
1036/// A self-contained SHA-256 (FIPS 180-4) so `plan_id` needs no third-party hash
1037/// dependency and no edit to the workspace `Cargo.toml` (a hot file). Content
1038/// addressing is an integrity check over local, non-adversarial inputs, so a
1039/// vendored reference implementation is appropriate; correctness is pinned by
1040/// the RFC known-answer vectors in the module tests.
1041mod sha256 {
1042 // The canonical reference form is dense in bit-twiddling and single-letter
1043 // working variables; the lints below fight that idiom for no clarity gain.
1044 #![allow(
1045 clippy::unreadable_literal,
1046 clippy::many_single_char_names,
1047 clippy::needless_range_loop
1048 )]
1049
1050 use std::fmt::Write as _;
1051
1052 /// SHA-256 round constants (first 32 bits of the fractional parts of the
1053 /// cube roots of the first 64 primes).
1054 const K: [u32; 64] = [
1055 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
1056 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
1057 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
1058 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
1059 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
1060 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
1061 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
1062 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
1063 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
1064 0xc67178f2,
1065 ];
1066
1067 /// Initial hash values (first 32 bits of the fractional parts of the square
1068 /// roots of the first 8 primes).
1069 const H0: [u32; 8] = [
1070 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
1071 0x5be0cd19,
1072 ];
1073
1074 /// The lowercase 64-character SHA-256 hex digest of `data`.
1075 pub fn hex(data: &[u8]) -> String {
1076 let mut h = H0;
1077
1078 // Pad: 0x80, then zeros to a 56-mod-64 boundary, then the 64-bit
1079 // big-endian bit length.
1080 let mut msg = data.to_vec();
1081 // FIPS 180-4 caps the message at 2^64 - 1 bits; a checked multiply turns
1082 // the (practically unreachable) overflow into a loud panic rather than a
1083 // silently wrong digest.
1084 let bit_len = (data.len() as u64)
1085 .checked_mul(8)
1086 .expect("SHA-256 input exceeds 2^64 bits");
1087 msg.push(0x80);
1088 while msg.len() % 64 != 56 {
1089 msg.push(0);
1090 }
1091 msg.extend_from_slice(&bit_len.to_be_bytes());
1092
1093 for chunk in msg.chunks_exact(64) {
1094 let mut w = [0u32; 64];
1095 for i in 0..16 {
1096 w[i] = u32::from_be_bytes([
1097 chunk[4 * i],
1098 chunk[4 * i + 1],
1099 chunk[4 * i + 2],
1100 chunk[4 * i + 3],
1101 ]);
1102 }
1103 for i in 16..64 {
1104 let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
1105 let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
1106 w[i] = w[i - 16]
1107 .wrapping_add(s0)
1108 .wrapping_add(w[i - 7])
1109 .wrapping_add(s1);
1110 }
1111
1112 let mut a = h[0];
1113 let mut b = h[1];
1114 let mut c = h[2];
1115 let mut d = h[3];
1116 let mut e = h[4];
1117 let mut f = h[5];
1118 let mut g = h[6];
1119 let mut hh = h[7];
1120
1121 for i in 0..64 {
1122 let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
1123 let ch = (e & f) ^ ((!e) & g);
1124 let t1 = hh
1125 .wrapping_add(s1)
1126 .wrapping_add(ch)
1127 .wrapping_add(K[i])
1128 .wrapping_add(w[i]);
1129 let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
1130 let maj = (a & b) ^ (a & c) ^ (b & c);
1131 let t2 = s0.wrapping_add(maj);
1132 hh = g;
1133 g = f;
1134 f = e;
1135 e = d.wrapping_add(t1);
1136 d = c;
1137 c = b;
1138 b = a;
1139 a = t1.wrapping_add(t2);
1140 }
1141
1142 h[0] = h[0].wrapping_add(a);
1143 h[1] = h[1].wrapping_add(b);
1144 h[2] = h[2].wrapping_add(c);
1145 h[3] = h[3].wrapping_add(d);
1146 h[4] = h[4].wrapping_add(e);
1147 h[5] = h[5].wrapping_add(f);
1148 h[6] = h[6].wrapping_add(g);
1149 h[7] = h[7].wrapping_add(hh);
1150 }
1151
1152 let mut out = String::with_capacity(64);
1153 for v in h {
1154 let _ = write!(out, "{v:08x}");
1155 }
1156 out
1157 }
1158}
1159
1160#[cfg(test)]
1161mod tests;