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 = 2;
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct Artifact {
50 pub path: String,
52 pub sha256: Digest,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct ReleaseManifest {
60 pub release_kit_version: String,
62 pub payload_schema: u32,
64 pub payload_sha256: Digest,
67 pub artifacts: Vec<Artifact>,
69}
70
71impl ReleaseManifest {
72 #[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 #[must_use]
85 pub fn artifact(&self, path: &str) -> Option<&Artifact> {
86 self.artifacts.iter().find(|artifact| artifact.path == path)
87 }
88
89 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 #[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 pub fn check_schema(&self) -> Result<(), RkError> {
123 check_schema(self.payload_schema, &self.release_kit_version)
124 }
125}
126
127pub 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#[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
165pub trait ReleaseSource {
167 fn manifest(&self) -> Result<ReleaseManifest, RkError>;
174
175 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
194pub 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
212pub(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}