Skip to main content

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        &current_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;