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, so it never *causes* drift
32//!    within a binary, but binding it authenticates the execution shape the
33//!    approver saw and makes a future phase-model change a `SEAL_VERSION` event).
34//!
35//! ## Coordinator seam (what the sibling consumes)
36//!
37//! The coordinator refuses a `release cut --plan <id>` on drift by re-deriving
38//! current state and calling [`verify`]. It needs to persist only two plain
39//! fields from an approved plan — `plan_id` and `version` — into its journal;
40//! the approved [`ReleasePlan`] is otherwise reconstructed via [`build`] from
41//! the journalled sealed inputs. The plan DTOs are therefore `Serialize`-only,
42//! matching the repo-wide convention that the wire enums (`Ecosystem`/`Registry`
43//! /`Adapter`) do not derive `Deserialize` (they collect-all-errors on parse).
44//! The trust boundary is the *local journal*: an approved plan is one ossctl
45//! itself wrote, not untrusted caller input.
46//!
47//! ## Out of this worker's scope (handed to the coordinator)
48//!
49//! - **Working-tree cleanliness.** The seal binds `HEAD`, not uncommitted
50//!   changes. Enforcing a clean tree / executing from a clean checkout of the
51//!   sealed commit is an *execution* guard the coordinator owns (it needs a new
52//!   read-only `GitRepo` status port). Until then a dirty tree can publish code
53//!   that differs from the sealed commit — an accepted, documented gap.
54//!
55//! **Adapter tool *versions* (accepted gap).** ADR-0002 §3 names "resolved
56//! adapter identities+versions". The adapter registry (a sibling unit) is not
57//! landed, so no adapter *tool version* (e.g. a pinned `cargo-dist` release) is
58//! resolvable yet; today the address binds adapter **identity** (the enum). When
59//! the registry lands, fold the resolved versions into the pre-image — a
60//! deliberate `schema_version`-bumping change to what the address covers, never
61//! a silent one.
62//!
63//! Determinism: no wall-clock, no id-gen, no ordering-unstable map enters the
64//! pre-image — identical `(contract, facts, head, version)` always yield the
65//! same `plan_id` (proven in tests).
66
67use std::collections::BTreeSet;
68
69use serde::Serialize;
70
71use crate::contract::schema::{Contract, Ecosystem};
72use crate::protocol::facts::Facts;
73use crate::protocol::plan::{PlanPhase, PlanTarget, ReleasePlan};
74
75/// Build and seal a [`ReleasePlan`] from an already-normalized `contract` and
76/// detected `facts`, at git `head_sha`, for the chosen `version`.
77///
78/// The caller (the `ossctl-cli` handler behind `release plan`, or the release
79/// coordinator re-deriving current state) is responsible for having normalized
80/// the contract and gathered the facts through the same code paths behind
81/// `contract show` / `facts` — this function never re-parses `OSS-RELEASE.md`
82/// nor re-derives facts. `version` is treated as an opaque, already-validated
83/// identifier (scheme-specific validation — semver vs a calver pattern — is the
84/// contract's/skill's job, not the plan's).
85#[must_use]
86pub fn build(contract: &Contract, facts: &Facts, head_sha: &str, version: &str) -> ReleasePlan {
87    let targets = resolve_targets(contract, facts);
88    let plan_id = seal(contract, &targets, head_sha, version);
89    ReleasePlan {
90        plan_id,
91        contract_schema_version: contract.schema_version,
92        head_sha: head_sha.to_string(),
93        version: version.to_string(),
94        targets,
95        phases: PlanPhase::SEQUENCE.to_vec(),
96        // Carried from the (already-hashed) contract so the coordinator can hand
97        // the Homebrew adapter its tap + license without re-reading the contract.
98        // The first distribution that declares a tap — identical to the old
99        // single-`Distribution` behavior. The release-engine CLI path
100        // (`ensure_single_distribution`) rejects a multi-distribution monorepo
101        // BEFORE reaching here, so `distributions.len() <= 1` and this `find_map`
102        // never silently drops a second distribution's tap; carrying a per-package
103        // tap for a true multi-tap monorepo is a deliberate follow-up.
104        homebrew_tap: contract
105            .distributions
106            .iter()
107            .find_map(|d| d.homebrew_tap.clone()),
108        license: Some(contract.license.clone()),
109    }
110}
111
112/// Compute the content-addressed `plan_id` for `(contract, facts, head_sha,
113/// version)` **without** allocating a full [`ReleasePlan`].
114///
115/// The drift-check seam for the coordinator: given the plan a human approved, it
116/// re-derives the *current* repo's contract + facts + `HEAD`, calls this with
117/// the approved plan's sealed `version`, and compares. Prefer [`verify`], which
118/// wraps this and reports *which* inputs drifted; this raw form is exposed for
119/// callers that only need the digest.
120#[must_use]
121pub fn compute_plan_id(
122    contract: &Contract,
123    facts: &Facts,
124    head_sha: &str,
125    version: &str,
126) -> String {
127    let targets = resolve_targets(contract, facts);
128    seal(contract, &targets, head_sha, version)
129}
130
131/// Check whether an `approved` plan still matches the **current** repo state.
132///
133/// The coordinator calls this before crossing into any irreversible phase of
134/// `release cut --plan <plan_id>`. It re-derives the current `plan_id` from the
135/// current `contract`, `facts`, and `head_sha`, holding the *chosen version*
136/// fixed to the approved plan's (a cut may not change the sealed version — that
137/// would require a new plan). `Ok(())` means the approval is still valid; a
138/// [`PlanDrift`] carries the mismatched id pair and human-readable reasons for
139/// the `plan_stale` error envelope. The `plan_id` mismatch is authoritative;
140/// the reasons are **best-effort and may be non-exhaustive** — the approved
141/// plan intentionally does not retain the old normalized contract (trust the
142/// journal, not a re-supplied contract), so an exact field-level contract diff
143/// is not possible here. When more than one input drifts, the reasons name
144/// every one they can pinpoint (`HEAD`, schema version, target set) and fall
145/// back to a generic contract-changed note only when none of those explain it.
146///
147/// # Errors
148/// Returns [`PlanDrift`] when the recomputed `plan_id` differs from
149/// `approved.plan_id` — i.e. the repo moved (a commit, a manifest rename, a
150/// schema bump, a target-set change, or any normalized-contract change) since
151/// approval.
152pub fn verify(
153    approved: &ReleasePlan,
154    contract: &Contract,
155    facts: &Facts,
156    head_sha: &str,
157) -> Result<(), PlanDrift> {
158    let current_targets = resolve_targets(contract, facts);
159    let current_id = seal(contract, &current_targets, head_sha, &approved.version);
160    if current_id == approved.plan_id {
161        return Ok(());
162    }
163
164    // The ids differ; pinpoint *why* so the coordinator can surface an
165    // actionable `plan_stale` message rather than a bare hash mismatch.
166    let mut reasons = Vec::new();
167    if approved.head_sha != head_sha {
168        reasons.push(format!(
169            "HEAD moved from {} to {}",
170            short_sha(&approved.head_sha),
171            short_sha(head_sha)
172        ));
173    }
174    if approved.contract_schema_version != contract.schema_version {
175        reasons.push(format!(
176            "contract schema_version changed from {} to {}",
177            approved.contract_schema_version, contract.schema_version
178        ));
179    }
180    if approved.targets != current_targets {
181        reasons.push(
182            "the resolved target set changed (a target, package, registry, or adapter differs)"
183                .to_string(),
184        );
185    }
186    // A change the specific probes above did not catch (any other normalized
187    // contract field: version scheme, changelog, license, health badges, …).
188    if reasons.is_empty() {
189        reasons.push("the normalized contract changed".to_string());
190    }
191
192    Err(PlanDrift {
193        approved_plan_id: approved.plan_id.clone(),
194        current_plan_id: current_id,
195        reasons,
196    })
197}
198
199/// Why a `release cut --plan <plan_id>` was refused: the current repo no longer
200/// hashes to the approved plan (ADR-0002 §3, `plan_stale`).
201#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
202pub struct PlanDrift {
203    /// The `plan_id` the human approved.
204    pub approved_plan_id: String,
205    /// The `plan_id` the current repo state produces.
206    pub current_plan_id: String,
207    /// Human-readable specifics of what drifted (`HEAD` moved, the target set
208    /// changed, …) — at least one entry.
209    pub reasons: Vec<String>,
210}
211
212/// One publishable target's resolved package paired with the version its **tree
213/// manifest** declares — the version the ecosystem's publish command (`cargo
214/// publish` reading `Cargo.toml`, …) would **actually** upload.
215///
216/// The workspace manifest is the single source of truth for the release version
217/// ([`resolve_release_version`]); this is one row of that truth. Two failure modes
218/// carry a set of these: a caller `--version` that disagrees with the manifest
219/// ([`VersionResolveError::Mismatch`]), and a tree whose manifests disagree among
220/// themselves ([`VersionResolveError::InconsistentTree`]).
221#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
222pub struct VersionMismatch {
223    /// The resolved package this row describes.
224    pub package: String,
225    /// The package's ecosystem.
226    pub ecosystem: Ecosystem,
227    /// The version declared in the tree manifest — what the ecosystem's publish
228    /// command (`cargo publish` reading `Cargo.toml`, …) would **actually**
229    /// upload for this package.
230    pub manifest_version: String,
231}
232
233/// Why a single release version could not be resolved from the workspace manifest
234/// (the single source of truth) — the reconciled superset of the old `--version`
235/// drift guard.
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub enum VersionResolveError {
238    /// A caller-supplied `--version` disagreed with the version the manifest — and
239    /// therefore the cut — would actually publish (`release-cut-publish-noop`). The
240    /// old drift guard, folded in: `--version` is an optional *confirmation*, and a
241    /// wrong one is refused before any publish. Carries the requested version and the
242    /// checkable targets it disagrees with (sorted, one per package).
243    Mismatch {
244        /// The rejected `--version` the caller supplied.
245        requested: String,
246        /// Every checkable target whose manifest version differs from `requested`.
247        mismatches: Vec<VersionMismatch>,
248    },
249    /// The tree's publishable manifests declare **more than one distinct version**,
250    /// so there is no single source of truth to derive the release version from —
251    /// bring the workspace into lockstep first. Carries each checkable target's
252    /// package + version (sorted, one per package).
253    InconsistentTree {
254        /// Every checkable target and the version its manifest declares.
255        versions: Vec<VersionMismatch>,
256    },
257    /// No manifest version could be detected — every target is a distribution/binary
258    /// target or an undetected package — **and** no `--version` was supplied, so the
259    /// version can neither be derived nor confirmed. Supplying `--version` resolves
260    /// it (there is nothing to confirm against).
261    Undeterminable,
262}
263
264/// Resolve the release version from the workspace manifest — the **single source of
265/// truth** — optionally confirming it against a caller-supplied `--version`.
266///
267/// `ossctl release cut` does **not** bump the manifest: each ecosystem's publish
268/// command uploads the version already in the tree (`cargo publish` reads
269/// `Cargo.toml`), and the engine threads that version into every registry probe,
270/// index-wait, and receipt. So the version a cut publishes is a **projection of the
271/// tree**, not an independent input — deriving it here removes the two-masters
272/// footgun where a `--version` flag and the manifest could silently drift (the
273/// engine would publish the manifest version while waiting for/recording the flag's,
274/// which never lands — `release-cut-publish-noop`).
275///
276/// The manifest version is the distinct version shared by every **checkable** target
277/// (a resolved package with a detected manifest version in `facts`). `requested` —
278/// the `--version` flag — is an **optional confirmation**, kept so the documented
279/// cut recipe (`release plan --version X.Y.Z`) and downstream skill wiring keep
280/// working: when present it must equal the derived version. Targets with no
281/// detectable manifest version (a homebrew/binary distribution target, an undetected
282/// package) are not checkable — their release version is bound to the crate they
283/// repackage, not a manifest of their own.
284///
285/// # Errors
286/// - [`VersionResolveError::Mismatch`] — `requested` disagrees with the single
287///   manifest version (the subsumed drift guard).
288/// - [`VersionResolveError::InconsistentTree`] — the checkable targets declare more
289///   than one distinct version, so no single source of truth exists.
290/// - [`VersionResolveError::Undeterminable`] — nothing to derive from and no
291///   `requested` to fall back to.
292pub fn resolve_release_version(
293    contract: &Contract,
294    facts: &Facts,
295    requested: Option<&str>,
296) -> Result<String, VersionResolveError> {
297    let checkable = tree_manifest_versions(contract, facts);
298    let distinct: BTreeSet<&str> = checkable
299        .iter()
300        .map(|m| m.manifest_version.as_str())
301        .collect();
302
303    match distinct.len() {
304        // Nothing to derive from: fall back to the caller's --version, else fail.
305        0 => match requested {
306            Some(v) => Ok(v.to_string()),
307            None => Err(VersionResolveError::Undeterminable),
308        },
309        // One source of truth. Confirm --version against it when supplied.
310        1 => {
311            // `distinct` is non-empty with exactly one element, and every row shares
312            // it, so any row's version is THE manifest version.
313            let manifest_version = checkable[0].manifest_version.clone();
314            match requested {
315                Some(v) if v != manifest_version => Err(VersionResolveError::Mismatch {
316                    requested: v.to_string(),
317                    // A single-version tree ⇒ every checkable target disagrees with a
318                    // differing `--version`; report them all.
319                    mismatches: checkable,
320                }),
321                _ => Ok(manifest_version),
322            }
323        }
324        // The tree disagrees with itself — no single source of truth to project.
325        _ => Err(VersionResolveError::InconsistentTree {
326            versions: checkable,
327        }),
328    }
329}
330
331/// Every **checkable** target — a resolved package with a detected manifest version
332/// in `facts` — paired with that version, sorted and deduplicated one row per
333/// `(ecosystem, package)`. The raw material [`resolve_release_version`] projects the
334/// single release version from.
335fn tree_manifest_versions(contract: &Contract, facts: &Facts) -> Vec<VersionMismatch> {
336    let mut rows: Vec<VersionMismatch> = Vec::new();
337    for t in resolve_targets(contract, facts) {
338        let Some(package) = t.package else { continue };
339        // The tree manifest version for this resolved (ecosystem, package). Absent
340        // ⇒ not checkable (a distribution target, or an undetected manifest).
341        let Some(manifest_version) = facts
342            .packages
343            .iter()
344            .find(|p| p.ecosystem == t.ecosystem && p.package.as_deref() == Some(package.as_str()))
345            .and_then(|p| p.version.clone())
346        else {
347            continue;
348        };
349        rows.push(VersionMismatch {
350            package,
351            ecosystem: t.ecosystem,
352            manifest_version,
353        });
354    }
355    // Deterministic order, and one row per package even if a package backs several
356    // targets (a crate published to crates.io AND repackaged for homebrew). Sort and
357    // dedup on the SAME (ecosystem, package) key so equal keys are guaranteed adjacent
358    // before the consecutive-only `dedup_by` runs.
359    rows.sort_by(|a, b| {
360        (a.ecosystem.as_str(), &a.package).cmp(&(b.ecosystem.as_str(), &b.package))
361    });
362    rows.dedup_by(|a, b| a.package == b.package && a.ecosystem == b.ecosystem);
363    rows
364}
365
366/// Overlay facts-derived package names onto the contract's target set, yielding
367/// the concrete targets a cut would execute. Order follows the contract's
368/// `targets` (already canonicalized by the normalizer).
369fn resolve_targets(contract: &Contract, facts: &Facts) -> Vec<PlanTarget> {
370    contract
371        .targets
372        .iter()
373        .map(|t| {
374            let package = t
375                .package
376                .clone()
377                .or_else(|| resolve_package(facts, t.ecosystem));
378            PlanTarget {
379                ecosystem: t.ecosystem,
380                package,
381                registry: t.registry,
382                adapter: t.adapter,
383            }
384        })
385        .collect()
386}
387
388/// The detected package name for `ecosystem`, resolved **only when
389/// unambiguous** — exactly one named manifest for that ecosystem.
390///
391/// `None` when no manifest named one (a virtual workspace, a binary-only repo)
392/// **or** when several do (a monorepo with multiple crates of one ecosystem):
393/// with no per-target manifest key in the contract, picking the first would
394/// silently mis-assign the same package to every `null` target, so we leave it
395/// `null` for cut-time inference instead. A monorepo should declare explicit
396/// per-target `package`s in the contract; the CLI warns when this fires.
397fn resolve_package(facts: &Facts, ecosystem: crate::contract::schema::Ecosystem) -> Option<String> {
398    let mut named = facts
399        .packages
400        .iter()
401        .filter(|p| p.ecosystem == ecosystem && p.package.is_some());
402    let first = named.next()?;
403    // More than one named candidate ⇒ ambiguous ⇒ do not guess.
404    if named.next().is_some() {
405        return None;
406    }
407    first.package.clone()
408}
409
410/// Domain separator baked into every pre-image so a `plan_id` can never be
411/// confused with any other SHA-256 an ossctl subsystem might compute over
412/// similar bytes. Ends in the seal-format version for readability; the numeric
413/// [`SEAL_VERSION`] is also hashed as its own field.
414const SEAL_DOMAIN: &str = "ossctl.release-plan";
415
416/// Version of the *hashing pre-image format* — the field set, their order, and
417/// the canonicalization. Independent of the contract-document or wire-envelope
418/// versions. Bump this (never silently) whenever the pre-image shape changes
419/// (e.g. once resolved adapter versions are folded in), so old and new plan ids
420/// are intentionally disjoint rather than accidentally colliding.
421const SEAL_VERSION: u32 = 5;
422
423/// The canonical hashed pre-image (see the module docs for the exact contents).
424/// A dedicated struct rather than an ad-hoc byte concatenation so the field set
425/// is explicit and serde's deterministic struct-field ordering fixes the byte
426/// layout.
427///
428/// **DO NOT REORDER these fields** — field order is part of the content address,
429/// so a reorder silently changes every `plan_id`. Evolve the format via
430/// [`SEAL_VERSION`] instead.
431#[derive(Serialize)]
432struct SealInput<'a> {
433    domain: &'static str,
434    seal_version: u32,
435    contract_schema_version: u32,
436    contract: &'a Contract,
437    head_sha: &'a str,
438    version: &'a str,
439    targets: &'a [PlanTarget],
440    phases: &'a [PlanPhase],
441}
442
443/// Serialize the pre-image to canonical JSON and return its SHA-256 hex digest.
444fn seal(contract: &Contract, targets: &[PlanTarget], head_sha: &str, version: &str) -> String {
445    let input = SealInput {
446        domain: SEAL_DOMAIN,
447        seal_version: SEAL_VERSION,
448        contract_schema_version: contract.schema_version,
449        contract,
450        head_sha,
451        version,
452        targets,
453        phases: &PlanPhase::SEQUENCE,
454    };
455    // `to_vec` on a struct of only structs/Vecs/BTreeMaps (contract's
456    // `extra_fields` is a `serde_json::Map` = `BTreeMap` without the
457    // `preserve_order` feature) is deterministic — no wall-clock, no HashMap,
458    // no float. It is also infallible for these concrete types; `expect` (never
459    // `unwrap_or_default`, which would fail *open* by hashing an empty pre-image
460    // and collide every failing plan on the empty-string digest).
461    let bytes =
462        serde_json::to_vec(&input).expect("release-plan pre-image is infallible to serialize");
463    sha256::hex(&bytes)
464}
465
466/// Short (first 12 hex chars) `HEAD` sha for drift messages; whole string if
467/// shorter.
468fn short_sha(sha: &str) -> &str {
469    sha.get(..12).unwrap_or(sha)
470}
471
472/// A self-contained SHA-256 (FIPS 180-4) so `plan_id` needs no third-party hash
473/// dependency and no edit to the workspace `Cargo.toml` (a hot file). Content
474/// addressing is an integrity check over local, non-adversarial inputs, so a
475/// vendored reference implementation is appropriate; correctness is pinned by
476/// the RFC known-answer vectors in the module tests.
477mod sha256 {
478    // The canonical reference form is dense in bit-twiddling and single-letter
479    // working variables; the lints below fight that idiom for no clarity gain.
480    #![allow(
481        clippy::unreadable_literal,
482        clippy::many_single_char_names,
483        clippy::needless_range_loop
484    )]
485
486    use std::fmt::Write as _;
487
488    /// SHA-256 round constants (first 32 bits of the fractional parts of the
489    /// cube roots of the first 64 primes).
490    const K: [u32; 64] = [
491        0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
492        0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
493        0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
494        0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
495        0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
496        0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
497        0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
498        0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
499        0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
500        0xc67178f2,
501    ];
502
503    /// Initial hash values (first 32 bits of the fractional parts of the square
504    /// roots of the first 8 primes).
505    const H0: [u32; 8] = [
506        0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
507        0x5be0cd19,
508    ];
509
510    /// The lowercase 64-character SHA-256 hex digest of `data`.
511    pub fn hex(data: &[u8]) -> String {
512        let mut h = H0;
513
514        // Pad: 0x80, then zeros to a 56-mod-64 boundary, then the 64-bit
515        // big-endian bit length.
516        let mut msg = data.to_vec();
517        // FIPS 180-4 caps the message at 2^64 - 1 bits; a checked multiply turns
518        // the (practically unreachable) overflow into a loud panic rather than a
519        // silently wrong digest.
520        let bit_len = (data.len() as u64)
521            .checked_mul(8)
522            .expect("SHA-256 input exceeds 2^64 bits");
523        msg.push(0x80);
524        while msg.len() % 64 != 56 {
525            msg.push(0);
526        }
527        msg.extend_from_slice(&bit_len.to_be_bytes());
528
529        for chunk in msg.chunks_exact(64) {
530            let mut w = [0u32; 64];
531            for i in 0..16 {
532                w[i] = u32::from_be_bytes([
533                    chunk[4 * i],
534                    chunk[4 * i + 1],
535                    chunk[4 * i + 2],
536                    chunk[4 * i + 3],
537                ]);
538            }
539            for i in 16..64 {
540                let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
541                let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
542                w[i] = w[i - 16]
543                    .wrapping_add(s0)
544                    .wrapping_add(w[i - 7])
545                    .wrapping_add(s1);
546            }
547
548            let mut a = h[0];
549            let mut b = h[1];
550            let mut c = h[2];
551            let mut d = h[3];
552            let mut e = h[4];
553            let mut f = h[5];
554            let mut g = h[6];
555            let mut hh = h[7];
556
557            for i in 0..64 {
558                let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
559                let ch = (e & f) ^ ((!e) & g);
560                let t1 = hh
561                    .wrapping_add(s1)
562                    .wrapping_add(ch)
563                    .wrapping_add(K[i])
564                    .wrapping_add(w[i]);
565                let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
566                let maj = (a & b) ^ (a & c) ^ (b & c);
567                let t2 = s0.wrapping_add(maj);
568                hh = g;
569                g = f;
570                f = e;
571                e = d.wrapping_add(t1);
572                d = c;
573                c = b;
574                b = a;
575                a = t1.wrapping_add(t2);
576            }
577
578            h[0] = h[0].wrapping_add(a);
579            h[1] = h[1].wrapping_add(b);
580            h[2] = h[2].wrapping_add(c);
581            h[3] = h[3].wrapping_add(d);
582            h[4] = h[4].wrapping_add(e);
583            h[5] = h[5].wrapping_add(f);
584            h[6] = h[6].wrapping_add(g);
585            h[7] = h[7].wrapping_add(hh);
586        }
587
588        let mut out = String::with_capacity(64);
589        for v in h {
590            let _ = write!(out, "{v:08x}");
591        }
592        out
593    }
594}
595
596#[cfg(test)]
597mod tests;