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