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