release_kit/release/
mod.rs1pub 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
32pub const PAYLOAD_SCHEMA: u32 = 1;
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct Artifact {
42 pub path: String,
44 pub sha256: Digest,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct ReleaseManifest {
52 pub release_kit_version: String,
54 pub payload_schema: u32,
56 pub payload_sha256: Digest,
59 pub artifacts: Vec<Artifact>,
61}
62
63impl ReleaseManifest {
64 #[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 #[must_use]
77 pub fn artifact(&self, path: &str) -> Option<&Artifact> {
78 self.artifacts.iter().find(|artifact| artifact.path == path)
79 }
80
81 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 #[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 if !out.iter().any(|known| known == dir) {
101 out.push(dir.to_owned());
102 }
103 }
104 }
105 out
106 }
107
108 pub fn check_schema(&self) -> Result<(), RkError> {
115 check_schema(self.payload_schema, &self.release_kit_version)
116 }
117}
118
119pub 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#[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
157pub trait ReleaseSource {
159 fn manifest(&self) -> Result<ReleaseManifest, RkError>;
166
167 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
186pub 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
204pub(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}