Skip to main content

release_kit/commands/
payload.rs

1//! `rk payload`: what this binary carries, provably.
2//!
3//! The version alone does not identify a payload — two locally built
4//! binaries can share a Cargo version while embedding different bytes —
5//! so the report carries a digest per artifact and one aggregate over the
6//! ordered list, computed at runtime over the embedded bytes. A landing
7//! record, a bug report, or a comparison between two installs can then
8//! name the payload it actually saw.
9
10use serde::Serialize;
11
12use crate::cli::payload::PayloadArgs;
13use crate::digest::Digest;
14use crate::embedded;
15use crate::error::RkError;
16use crate::output::Output;
17
18/// The version of this report's shape, not of the payload it describes; a
19/// consumer is told when the shape changes without being told when the
20/// content does.
21const PAYLOAD_SCHEMA: u32 = 1;
22
23/// One embedded file and the digest of its bytes.
24#[derive(Debug, Serialize)]
25pub struct Artifact {
26    /// The artifact's path, carrying its payload root as the first segment.
27    pub path: String,
28    /// SHA-256 of the embedded bytes.
29    pub sha256: Digest,
30}
31
32/// The machine form of the payload report.
33#[derive(Debug, Serialize)]
34pub struct Report {
35    /// The one version, from `CARGO_PKG_VERSION` and nowhere else.
36    pub release_kit_version: &'static str,
37    /// The version of this document's shape.
38    pub payload_schema: u32,
39    /// One digest over the ordered artifact list, identifying the payload
40    /// as a whole.
41    pub payload_sha256: Digest,
42    /// Every embedded artifact, in root order and sorted within each root.
43    pub artifacts: Vec<Artifact>,
44}
45
46/// Build the report over the embedded payload.
47#[must_use]
48pub fn report() -> Report {
49    let artifacts: Vec<Artifact> = embedded::artifacts()
50        .into_iter()
51        .map(|(path, bytes)| Artifact {
52            path,
53            sha256: Digest::of(bytes),
54        })
55        .collect();
56    Report {
57        release_kit_version: env!("CARGO_PKG_VERSION"),
58        payload_schema: PAYLOAD_SCHEMA,
59        payload_sha256: aggregate(&artifacts),
60        artifacts,
61    }
62}
63
64/// The aggregate digest: SHA-256 over one `<path>\n<sha256>\n` record per
65/// artifact, in list order. Any change to any artifact, any rename, and
66/// any reordering of the roots changes it.
67fn aggregate(artifacts: &[Artifact]) -> Digest {
68    let mut lines = String::new();
69    for artifact in artifacts {
70        lines.push_str(&artifact.path);
71        lines.push('\n');
72        lines.push_str(&artifact.sha256.to_string());
73        lines.push('\n');
74    }
75    Digest::of(lines.as_bytes())
76}
77
78/// Print the payload report.
79///
80/// # Errors
81///
82/// Returns [`RkError::Other`] when the report cannot serialize, which is a
83/// defect in this binary rather than anything a caller can correct.
84pub fn run(args: &PayloadArgs) -> Result<(), RkError> {
85    let out = Output::new(args.json);
86    let report = report();
87    out.result_line(format!("release-kit {}", report.release_kit_version));
88    out.result_line(format!("payload sha256 {}", report.payload_sha256));
89    for root in embedded::PAYLOAD_ROOTS {
90        let count = report
91            .artifacts
92            .iter()
93            .filter(|a| a.path == root || a.path.starts_with(&format!("{root}/")))
94            .count();
95        let noun = if count == 1 { "file" } else { "files" };
96        out.result_line(format!("{root}: {count} {noun}"));
97    }
98    out.emit(&report)
99}
100
101#[cfg(test)]
102mod tests {
103    #![allow(clippy::expect_used)]
104
105    use super::{Artifact, Report, aggregate, report};
106    use crate::digest::Digest;
107
108    /// The complete `rk.payload/1` shape, held by snapshot against fixture
109    /// values, beside the live test that checks the real digests.
110    #[test]
111    fn the_payload_report_schema_snapshot_holds() {
112        let fixture = Report {
113            release_kit_version: "0.0.0",
114            payload_schema: 1,
115            payload_sha256: Digest::of(b""),
116            artifacts: vec![Artifact {
117                path: "versions.toml".into(),
118                sha256: Digest::of(b""),
119            }],
120        };
121        assert_eq!(
122            serde_json::to_string(&fixture).expect("a report serializes"),
123            r#"{"release_kit_version":"0.0.0","payload_schema":1,"payload_sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","artifacts":[{"path":"versions.toml","sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}]}"#
124        );
125    }
126
127    #[test]
128    fn the_report_names_the_cargo_version_and_every_artifact() {
129        let report = report();
130        assert_eq!(report.release_kit_version, env!("CARGO_PKG_VERSION"));
131        assert!(!report.artifacts.is_empty());
132        assert_eq!(report.payload_sha256, aggregate(&report.artifacts));
133    }
134
135    /// The aggregate must see renames and reorders, not only content.
136    #[test]
137    fn the_aggregate_covers_paths_and_order() {
138        let mut artifacts = report().artifacts;
139        let original = aggregate(&artifacts);
140        artifacts.reverse();
141        assert_ne!(original, aggregate(&artifacts));
142    }
143}