1pub mod crates_io;
17pub mod embedded;
18pub mod fixture;
19pub mod legacy;
20
21use std::collections::BTreeMap;
22
23use serde::Serialize;
24
25use crate::domain::ownership::Sha256;
26use crate::domain::projection::{DECLARATION_PATH, Declaration};
27use crate::domain::version::CanonVersion;
28use crate::error::AppError;
29
30pub use semver::Version;
33
34pub const MANIFEST_SCHEMA: &str = "sdd.payload/1";
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
44#[serde(rename_all = "kebab-case")]
45pub enum Provenance {
46 Native,
48 LegacyAdapted,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
55#[serde(rename_all = "kebab-case")]
56pub enum Role {
57 Payload,
59 Metadata,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65pub struct Artifact {
66 pub path: String,
68 pub role: Role,
70 pub bytes: u64,
72 pub sha256: Sha256,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
78pub struct ReleaseManifest {
79 pub schema: &'static str,
81 pub version: Version,
83 pub payload_schema: u32,
85 pub provenance: Provenance,
87 pub descriptor_sha256: Option<Sha256>,
89 pub payload_sha256: Sha256,
91 pub artifacts: Vec<Artifact>,
93}
94
95impl ReleaseManifest {
96 #[must_use]
98 pub fn digest_of(&self, path: &str) -> Option<&Sha256> {
99 self.artifacts
100 .iter()
101 .find(|artifact| artifact.path == path)
102 .map(|artifact| &artifact.sha256)
103 }
104
105 #[must_use]
111 pub fn payload_digest(artifacts: &[Artifact]) -> Sha256 {
112 let mut joined = String::new();
113 for artifact in artifacts {
114 joined.push_str(&artifact.path);
115 joined.push('\0');
116 joined.push_str(artifact.sha256.as_str());
117 joined.push('\n');
118 }
119 Sha256::of(joined.as_bytes())
120 }
121}
122
123pub trait ReleaseBundle: Send + Sync {
125 fn manifest(&self) -> Result<ReleaseManifest, AppError>;
131
132 fn blob(&self, digest: &Sha256) -> Result<Vec<u8>, AppError>;
138
139 fn artifact(&self, path: &str) -> Result<Vec<u8>, AppError> {
145 let manifest = self.manifest()?;
146 let digest = manifest.digest_of(path).ok_or_else(|| {
147 AppError::Refused(format!("release {} carries no {path}", manifest.version))
148 })?;
149 self.blob(digest)
150 }
151
152 fn declaration(&self) -> Result<Declaration, AppError> {
159 let bytes = self.artifact(DECLARATION_PATH)?;
160 Declaration::parse(&bytes).map_err(|source| AppError::Refused(source.to_string()))
161 }
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum Selector {
167 Embedded,
169 Latest,
171 Exact(Version),
173}
174
175impl std::fmt::Display for Selector {
176 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177 match self {
178 Self::Embedded => f.write_str("embedded"),
179 Self::Latest => f.write_str("latest"),
180 Self::Exact(version) => write!(f, "{version}"),
181 }
182 }
183}
184
185pub struct ResolvedRelease {
187 pub selector: Selector,
189 pub version: Version,
191 pub registry_checksum: Option<Sha256>,
193 pub payload_sha256: Sha256,
195 pub yanked: bool,
197 pub bundle: Box<dyn ReleaseBundle>,
199}
200
201impl std::fmt::Debug for ResolvedRelease {
202 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203 f.debug_struct("ResolvedRelease")
204 .field("selector", &self.selector)
205 .field("version", &self.version)
206 .field("registry_checksum", &self.registry_checksum)
207 .field("payload_sha256", &self.payload_sha256)
208 .field("yanked", &self.yanked)
209 .finish_non_exhaustive()
210 }
211}
212
213impl ResolvedRelease {
214 pub fn canon_version(&self) -> Result<CanonVersion, AppError> {
222 self.version.to_string().parse().map_err(|_| {
223 AppError::Refused(format!(
224 "{} is not a released triple, so no instance record can name it",
225 self.version
226 ))
227 })
228 }
229}
230
231pub trait ReleaseResolver {
233 fn resolve(&self, selector: &Selector) -> Result<ResolvedRelease, AppError>;
240}
241
242#[must_use]
247pub fn manifest_from(
248 version: Version,
249 payload_schema: u32,
250 provenance: Provenance,
251 descriptor_sha256: Option<Sha256>,
252 files: &BTreeMap<String, Vec<u8>>,
253 metadata: &BTreeMap<String, Vec<u8>>,
254) -> ReleaseManifest {
255 let mut artifacts: Vec<Artifact> = files
256 .iter()
257 .map(|(path, bytes)| artifact_of(path, bytes, Role::Payload))
258 .chain(
259 metadata
260 .iter()
261 .map(|(path, bytes)| artifact_of(path, bytes, Role::Metadata)),
262 )
263 .collect();
264 artifacts.sort_by(|left, right| left.path.cmp(&right.path));
265 ReleaseManifest {
266 schema: MANIFEST_SCHEMA,
267 version,
268 payload_schema,
269 provenance,
270 descriptor_sha256,
271 payload_sha256: ReleaseManifest::payload_digest(&artifacts),
272 artifacts,
273 }
274}
275
276fn artifact_of(path: &str, bytes: &[u8], role: Role) -> Artifact {
277 Artifact {
278 path: path.to_string(),
279 role,
280 bytes: bytes.len() as u64,
281 sha256: Sha256::of(bytes),
282 }
283}
284
285pub fn blob_from(
291 files: &BTreeMap<String, Vec<u8>>,
292 metadata: &BTreeMap<String, Vec<u8>>,
293 digest: &Sha256,
294) -> Result<Vec<u8>, AppError> {
295 files
296 .values()
297 .chain(metadata.values())
298 .find(|bytes| &Sha256::of(bytes) == digest)
299 .cloned()
300 .ok_or_else(|| AppError::Refused(format!("the bundle carries no blob {digest}")))
301}
302
303#[cfg(test)]
304mod tests {
305 #![allow(
306 clippy::unwrap_used,
307 reason = "a test panics as its failure signal, not as control flow"
308 )]
309
310 use super::*;
311
312 fn current() -> Version {
313 CanonVersion::current().to_string().parse().unwrap()
314 }
315
316 fn files() -> BTreeMap<String, Vec<u8>> {
317 BTreeMap::from([
318 ("b.md".to_string(), b"two".to_vec()),
319 ("a.md".to_string(), b"one".to_vec()),
320 ])
321 }
322
323 #[test]
324 fn a_manifest_lists_every_artifact_in_path_order() {
325 let manifest = manifest_from(
326 current(),
327 1,
328 Provenance::Native,
329 None,
330 &files(),
331 &BTreeMap::new(),
332 );
333 let paths: Vec<&str> = manifest
334 .artifacts
335 .iter()
336 .map(|artifact| artifact.path.as_str())
337 .collect();
338 assert_eq!(paths, ["a.md", "b.md"]);
339 assert_eq!(manifest.schema, MANIFEST_SCHEMA);
340 assert_eq!(manifest.digest_of("a.md"), Some(&Sha256::of(b"one")));
341 assert_eq!(manifest.digest_of("absent.md"), None);
342 }
343
344 #[test]
345 fn the_payload_digest_reads_the_content_and_not_the_order_it_was_given() {
346 let one = manifest_from(
347 current(),
348 1,
349 Provenance::Native,
350 None,
351 &files(),
352 &BTreeMap::new(),
353 );
354 let mut reversed = BTreeMap::new();
355 reversed.insert("a.md".to_string(), b"one".to_vec());
356 reversed.insert("b.md".to_string(), b"two".to_vec());
357 let two = manifest_from(
358 current(),
359 1,
360 Provenance::Native,
361 None,
362 &reversed,
363 &BTreeMap::new(),
364 );
365 assert_eq!(one.payload_sha256, two.payload_sha256);
366 }
367
368 #[test]
369 fn metadata_joins_the_manifest_as_a_second_role() {
370 let metadata = BTreeMap::from([("m.toml".to_string(), b"x".to_vec())]);
371 let manifest = manifest_from(
372 current(),
373 1,
374 Provenance::LegacyAdapted,
375 Some(Sha256::of(b"descriptor")),
376 &files(),
377 &metadata,
378 );
379 let roles: Vec<Role> = manifest
380 .artifacts
381 .iter()
382 .map(|artifact| artifact.role)
383 .collect();
384 assert_eq!(roles, [Role::Payload, Role::Payload, Role::Metadata]);
385 assert_eq!(manifest.provenance, Provenance::LegacyAdapted);
386 }
387
388 #[test]
389 fn a_blob_is_served_by_digest_and_an_unknown_digest_refuses() {
390 let held = files();
391 assert_eq!(
392 blob_from(&held, &BTreeMap::new(), &Sha256::of(b"one")).unwrap(),
393 b"one"
394 );
395 assert!(blob_from(&held, &BTreeMap::new(), &Sha256::of(b"nope")).is_err());
396 }
397}