Skip to main content

release_kit/release/
mod.rs

1//! The seam between the engine and any release bundle.
2//!
3//! A release bundle is the payload of one release-kit release: every root
4//! `src/payload_roots.rs` declares, at that release's bytes. The engine
5//! reads a bundle through [`ReleaseSource`] and through nothing else, so
6//! the same planner describes the release compiled into this binary, a
7//! release fetched from the crates venue, and a directory a test wrote.
8//! The trait is two methods on purpose: the manifest, and a blob by
9//! digest. Every method on the seam is a promise every source must keep.
10//!
11//! `payload_schema` is the protocol version between an engine and a
12//! bundle. An engine reads any bundle whose schema is at or below its own,
13//! and refuses a newer one by naming the engine version to install: that
14//! is the whole compatibility rule, and the only case where a newer
15//! binary must be obtained.
16
17pub mod crate_source;
18pub mod dir;
19pub mod embedded;
20
21use serde::{Deserialize, Serialize};
22
23use crate::diagnostic::{Diagnostic, Reason};
24use crate::digest::Digest;
25use crate::error::RkError;
26
27pub use crate_source::CrateReleaseSource;
28pub use dir::DirReleaseSource;
29pub use embedded::EmbeddedReleaseSource;
30
31/// The version of the manifest's shape and of the bundle protocol.
32///
33/// The one number an engine compares before it reads a bundle. The
34/// constant is declared with this exact spelling because a bundle's own
35/// copy is read back out of its sources by [`dir::declared_schema`].
36pub const PAYLOAD_SCHEMA: u32 = 1;
37
38/// One artifact of a bundle and the digest of its bytes.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct Artifact {
41    /// The artifact's path, carrying its payload root as the first segment.
42    pub path: String,
43    /// SHA-256 of the bytes.
44    pub sha256: Digest,
45}
46
47/// What identifies one release bundle: the `rk.payload/1` document, as
48/// `rk payload --json` has always emitted it, promoted to a type.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct ReleaseManifest {
51    /// The release's version.
52    pub release_kit_version: String,
53    /// The bundle protocol version.
54    pub payload_schema: u32,
55    /// One digest over the ordered artifact list, identifying the payload
56    /// as a whole.
57    pub payload_sha256: Digest,
58    /// Every artifact, in root order and sorted within each root.
59    pub artifacts: Vec<Artifact>,
60}
61
62impl ReleaseManifest {
63    /// Build the manifest over an artifact list already in root order.
64    #[must_use]
65    pub fn new(release_kit_version: String, payload_schema: u32, artifacts: Vec<Artifact>) -> Self {
66        Self {
67            release_kit_version,
68            payload_schema,
69            payload_sha256: aggregate(&artifacts),
70            artifacts,
71        }
72    }
73
74    /// The artifact at one path.
75    #[must_use]
76    pub fn artifact(&self, path: &str) -> Option<&Artifact> {
77        self.artifacts.iter().find(|artifact| artifact.path == path)
78    }
79
80    /// The artifacts under one directory prefix, as `(path below the
81    /// prefix, artifact)`, in manifest order.
82    pub fn under<'a>(&'a self, prefix: &'a str) -> impl Iterator<Item = (&'a str, &'a Artifact)> {
83        self.artifacts.iter().filter_map(move |artifact| {
84            artifact
85                .path
86                .strip_prefix(prefix)
87                .and_then(|rest| rest.strip_prefix('/'))
88                .map(|rest| (rest, artifact))
89        })
90    }
91
92    /// The immediate child directories of one prefix, deduplicated, in
93    /// manifest order.
94    #[must_use]
95    pub fn dirs_under(&self, prefix: &str) -> Vec<String> {
96        let mut out: Vec<String> = Vec::new();
97        for (rest, _) in self.under(prefix) {
98            if let Some((dir, _)) = rest.split_once('/') {
99                if !out.iter().any(|known| known == dir) {
100                    out.push(dir.to_owned());
101                }
102            }
103        }
104        out
105    }
106
107    /// Refuse a bundle whose protocol is newer than this engine's.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`RkError::Refusal`] naming the bundle's version as the
112    /// engine to install when its schema exceeds [`PAYLOAD_SCHEMA`].
113    pub fn check_schema(&self) -> Result<(), RkError> {
114        check_schema(self.payload_schema, &self.release_kit_version)
115    }
116}
117
118/// The protocol rule, as one function both sides of the boundary test.
119///
120/// # Errors
121///
122/// Returns [`RkError::Refusal`] when `schema` exceeds [`PAYLOAD_SCHEMA`].
123pub fn check_schema(schema: u32, version: &str) -> Result<(), RkError> {
124    if schema <= PAYLOAD_SCHEMA {
125        return Ok(());
126    }
127    Err(RkError::refusal(
128        Diagnostic::new(
129            Reason::UnsupportedSchema,
130            format!(
131                "the bundle for release-kit {version} declares payload schema {schema}, and this engine reads schema {PAYLOAD_SCHEMA} at most"
132            ),
133        )
134        .expected("a bundle whose payload schema is at or below the engine's")
135        .action(format!(
136            "install release-kit {version} or newer; an engine reads any bundle at or below its own schema, and no older engine can read this one"
137        )),
138    ))
139}
140
141/// The aggregate digest: SHA-256 over one `<path>\n<sha256>\n` record per
142/// artifact, in list order. Any change to any artifact, any rename, and
143/// any reordering of the roots changes it.
144#[must_use]
145pub fn aggregate(artifacts: &[Artifact]) -> Digest {
146    let mut lines = String::new();
147    for artifact in artifacts {
148        lines.push_str(&artifact.path);
149        lines.push('\n');
150        lines.push_str(&artifact.sha256.to_string());
151        lines.push('\n');
152    }
153    Digest::of(lines.as_bytes())
154}
155
156/// One release bundle, read by the engine.
157pub trait ReleaseSource {
158    /// The bundle's manifest.
159    ///
160    /// # Errors
161    ///
162    /// A source that cannot describe itself — an unreadable directory, an
163    /// unverifiable fetch — fails here, before any blob is asked for.
164    fn manifest(&self) -> Result<ReleaseManifest, RkError>;
165
166    /// The bytes behind one digest the manifest names.
167    ///
168    /// # Errors
169    ///
170    /// Returns [`RkError::NotFound`] for a digest the bundle does not
171    /// carry, and the source's own failure for bytes it cannot read.
172    fn blob(&self, digest: &Digest) -> Result<Vec<u8>, RkError>;
173}
174
175impl ReleaseSource for &dyn ReleaseSource {
176    fn manifest(&self) -> Result<ReleaseManifest, RkError> {
177        (**self).manifest()
178    }
179
180    fn blob(&self, digest: &Digest) -> Result<Vec<u8>, RkError> {
181        (**self).blob(digest)
182    }
183}
184
185/// The bytes of one artifact by path, through the manifest.
186///
187/// # Errors
188///
189/// Returns [`RkError::NotFound`] for a path the manifest does not name,
190/// and the source's failures otherwise.
191pub fn read(
192    source: &dyn ReleaseSource,
193    manifest: &ReleaseManifest,
194    path: &str,
195) -> Result<Vec<u8>, RkError> {
196    let artifact = manifest.artifact(path).ok_or_else(|| RkError::NotFound {
197        kind: "artifact",
198        name: path.to_owned(),
199    })?;
200    source.blob(&artifact.sha256)
201}
202
203/// The blob refusal every source shares for a digest it does not carry.
204pub(crate) fn unknown_digest(digest: &Digest) -> RkError {
205    RkError::NotFound {
206        kind: "blob",
207        name: digest.to_string(),
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::{Artifact, PAYLOAD_SCHEMA, ReleaseManifest, check_schema};
214    use crate::digest::Digest;
215
216    fn manifest() -> ReleaseManifest {
217        ReleaseManifest::new(
218            "0.0.0".into(),
219            PAYLOAD_SCHEMA,
220            vec![
221                Artifact {
222                    path: "snippets/_shared/github/SECURITY.md".into(),
223                    sha256: Digest::of(b"a"),
224                },
225                Artifact {
226                    path: "snippets/rust/github/release-plz.toml".into(),
227                    sha256: Digest::of(b"b"),
228                },
229                Artifact {
230                    path: "versions.toml".into(),
231                    sha256: Digest::of(b"c"),
232                },
233            ],
234        )
235    }
236
237    #[test]
238    fn an_engine_reads_a_bundle_at_or_below_its_schema() {
239        assert!(check_schema(PAYLOAD_SCHEMA, "9.9.9").is_ok());
240        assert!(check_schema(0, "0.0.1").is_ok());
241        assert!(manifest().check_schema().is_ok());
242    }
243
244    #[test]
245    fn an_engine_refuses_a_newer_schema_naming_the_engine_to_install() {
246        let err = check_schema(PAYLOAD_SCHEMA + 1, "9.9.9").expect_err("a newer schema refuses");
247        assert_eq!(err.exit_code(), 73);
248        assert_eq!(err.reason(), crate::diagnostic::Reason::UnsupportedSchema);
249        let text = err.to_string();
250        assert!(text.contains("release-kit 9.9.9"), "{text}");
251        assert!(
252            text.contains(&format!("schema {}", PAYLOAD_SCHEMA + 1)),
253            "{text}"
254        );
255        let action = err
256            .diagnostic()
257            .action
258            .expect("the refusal names the engine to install");
259        assert!(action.contains("install release-kit 9.9.9"), "{action}");
260    }
261
262    #[test]
263    fn the_manifest_lists_under_a_prefix() {
264        let manifest = manifest();
265        assert_eq!(manifest.dirs_under("snippets"), ["_shared", "rust"]);
266        assert_eq!(manifest.dirs_under("snippets/rust"), ["github"]);
267        let under: Vec<&str> = manifest
268            .under("snippets/rust/github")
269            .map(|(p, _)| p)
270            .collect();
271        assert_eq!(under, ["release-plz.toml"]);
272        assert!(manifest.artifact("versions.toml").is_some());
273        assert!(manifest.artifact("versions").is_none());
274    }
275}