release_kit/release/
mod.rs1pub 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
31pub const PAYLOAD_SCHEMA: u32 = 1;
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct Artifact {
41 pub path: String,
43 pub sha256: Digest,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct ReleaseManifest {
51 pub release_kit_version: String,
53 pub payload_schema: u32,
55 pub payload_sha256: Digest,
58 pub artifacts: Vec<Artifact>,
60}
61
62impl ReleaseManifest {
63 #[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 #[must_use]
76 pub fn artifact(&self, path: &str) -> Option<&Artifact> {
77 self.artifacts.iter().find(|artifact| artifact.path == path)
78 }
79
80 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 #[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 pub fn check_schema(&self) -> Result<(), RkError> {
114 check_schema(self.payload_schema, &self.release_kit_version)
115 }
116}
117
118pub 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#[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
156pub trait ReleaseSource {
158 fn manifest(&self) -> Result<ReleaseManifest, RkError>;
165
166 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
185pub 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
203pub(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}