1use std::collections::BTreeMap;
23use std::path::{Path, PathBuf};
24
25use serde::Deserialize;
26use sha2::{Digest, Sha256};
27
28use crate::layer::LayerId;
29use crate::manifest::ManifestEntry;
30
31pub const PAYLOAD_DIR: &str = "payloads";
33
34#[derive(Debug, Clone, Copy)]
37pub struct Payload<'a> {
38 pub name: &'a str,
39 pub version: Option<&'a str>,
42 pub dispatchable: bool,
44 pub bytes: &'a [u8],
45}
46
47impl<'a> Payload<'a> {
48 pub fn tool(name: &'a str, bytes: &'a [u8]) -> Self {
50 Payload {
51 name,
52 version: None,
53 dispatchable: true,
54 bytes,
55 }
56 }
57}
58
59pub fn entry_is_dispatchable(entry: &ManifestEntry) -> bool {
67 matches!(entry.kind(), Ok(kind) if kind.is_dispatchable())
68}
69
70pub fn entry_version(entry: &ManifestEntry) -> Option<&str> {
72 entry
73 .annotations
74 .get("eu.pulseengine.tool.version")
75 .map(String::as_str)
76}
77
78fn safe_component(what: &'static str, value: &str) -> Result<(), StoreError> {
83 let bad = |why: &str| {
84 Err(StoreError::UnsafeComponent {
85 what,
86 value: value.to_string(),
87 why: why.to_string(),
88 })
89 };
90 if value.is_empty() {
91 return bad("empty");
92 }
93 if value == "." || value == ".." {
94 return bad("a relative path element");
95 }
96 if let Some(c) = value
97 .chars()
98 .find(|c| matches!(c, '/' | '\\' | '\0') || c.is_control())
99 {
100 return bad(&format!("contains {c:?}"));
101 }
102 Ok(())
103}
104
105pub fn payload_rel_path(
115 dispatchable: bool,
116 name: &str,
117 version: Option<&str>,
118) -> Result<PathBuf, StoreError> {
119 safe_component("payload name", name)?;
120 match (dispatchable, version) {
121 (false, Some(version)) => {
122 safe_component("payload version", version)?;
123 Ok(PathBuf::from(PAYLOAD_DIR).join(name).join(version))
124 }
125 _ => Ok(PathBuf::from("bin").join(name)),
126 }
127}
128
129#[derive(Debug, Clone, Deserialize)]
133struct ManifestEnvelope {
134 #[serde(default)]
135 annotations: BTreeMap<String, String>,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct InstalledLayer {
141 pub digest: String,
143 pub layer: LayerId,
145 pub channel: String,
147 pub root: PathBuf,
149}
150
151#[derive(Debug, Clone)]
154pub struct Store {
155 root: PathBuf,
156}
157
158#[derive(Debug, thiserror::Error)]
161pub enum StoreError {
162 #[error("io error at {path}")]
163 Io {
164 path: String,
165 #[source]
166 source: std::io::Error,
167 },
168 #[error("{path}: layer.json is not a valid layer manifest: {reason}")]
169 BadManifest { path: String, reason: String },
170 #[error("{what} {value:?} is not a usable path component ({why}) — refusing to lay it down")]
171 UnsafeComponent {
172 what: &'static str,
173 value: String,
174 why: String,
175 },
176 #[error(
177 "two payloads of this layer both claim {path} ('{first}' and '{second}') — refusing to \
178 lay one down over the other. A layer may hold several versions of one name, but each \
179 must be a distinct payload; two entries with one identity would mean the wrong bytes \
180 land under the right name."
181 )]
182 Collision {
183 path: String,
184 first: String,
185 second: String,
186 },
187}
188
189impl Store {
190 pub fn at(root: impl Into<PathBuf>) -> Self {
191 Store { root: root.into() }
192 }
193
194 pub fn root(&self) -> &Path {
195 &self.root
196 }
197
198 pub fn lay_down(
204 &self,
205 manifest_bytes: &[u8],
206 tools: &[(&str, &[u8])],
207 ) -> Result<String, StoreError> {
208 let payloads: Vec<Payload<'_>> = tools
209 .iter()
210 .map(|(name, bytes)| Payload::tool(name, bytes))
211 .collect();
212 self.lay_down_payloads(manifest_bytes, &payloads)
213 }
214
215 pub fn lay_down_payloads(
226 &self,
227 manifest_bytes: &[u8],
228 payloads: &[Payload<'_>],
229 ) -> Result<String, StoreError> {
230 let digest = manifest_digest(manifest_bytes);
231 let entry = self.core_dir().join(digest.replace(':', "-"));
232 let io = |path: &Path, source: std::io::Error| StoreError::Io {
233 path: path.display().to_string(),
234 source,
235 };
236
237 let mut placed: BTreeMap<PathBuf, String> = BTreeMap::new();
240 let mut plan: Vec<(PathBuf, &Payload<'_>)> = Vec::new();
241 for payload in payloads {
242 let rel = payload_rel_path(payload.dispatchable, payload.name, payload.version)?;
243 let who = match payload.version {
244 Some(v) => format!("{}@{v}", payload.name),
245 None => payload.name.to_string(),
246 };
247 if let Some(first) = placed.get(&rel) {
248 return Err(StoreError::Collision {
249 path: rel.display().to_string(),
250 first: first.clone(),
251 second: who,
252 });
253 }
254 placed.insert(rel.clone(), who);
255 plan.push((rel, payload));
256 }
257
258 let bin = entry.join("bin");
259 std::fs::create_dir_all(&bin).map_err(|e| io(&bin, e))?;
260 let manifest_path = entry.join("layer.json");
261 std::fs::write(&manifest_path, manifest_bytes).map_err(|e| io(&manifest_path, e))?;
262 for (rel, payload) in plan {
263 let path = entry.join(rel);
264 if let Some(parent) = path.parent() {
265 std::fs::create_dir_all(parent).map_err(|e| io(parent, e))?;
266 }
267 std::fs::write(&path, payload.bytes).map_err(|e| io(&path, e))?;
268 #[cfg(unix)]
269 {
270 use std::os::unix::fs::PermissionsExt;
271 let mode = if payload.dispatchable { 0o755 } else { 0o644 };
274 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode))
275 .map_err(|e| io(&path, e))?;
276 }
277 }
278 Ok(digest)
279 }
280
281 pub fn list(&self) -> Result<Vec<InstalledLayer>, StoreError> {
283 let core = self.core_dir();
284 if !core.exists() {
285 return Ok(Vec::new());
286 }
287 let mut names: Vec<String> = std::fs::read_dir(&core)
288 .map_err(|e| StoreError::Io {
289 path: core.display().to_string(),
290 source: e,
291 })?
292 .filter_map(|e| e.ok())
293 .filter(|e| e.path().is_dir())
294 .map(|e| e.file_name().to_string_lossy().into_owned())
295 .filter(|n| n.starts_with("sha256-"))
296 .collect();
297 names.sort();
298 names
299 .into_iter()
300 .map(|name| self.read_entry(&name.replacen('-', ":", 1)))
301 .collect()
302 }
303
304 pub fn varve_root(&self) -> std::path::PathBuf {
308 let root = self.root();
309 if root
310 .parent()
311 .and_then(|p| p.file_name())
312 .is_some_and(|n| n == "realms")
313 {
314 root.parent()
315 .and_then(|p| p.parent())
316 .map(|p| p.to_path_buf())
317 .unwrap_or_else(|| root.to_path_buf())
318 } else {
319 root.to_path_buf()
320 }
321 }
322
323 pub fn partitions(&self) -> Vec<(Option<String>, Store)> {
334 let root = self.varve_root();
335 let mut out: Vec<(Option<String>, Store)> = vec![(None, Store::at(&root))];
336 if let Ok(rd) = std::fs::read_dir(root.join("realms")) {
337 let mut parts: Vec<std::path::PathBuf> =
338 rd.filter_map(|e| e.ok()).map(|e| e.path()).collect();
339 parts.sort();
340 for p in parts {
341 let fp = p.file_name().map(|n| n.to_string_lossy().into_owned());
342 out.push((fp, Store::at(p)));
343 }
344 }
345 out
346 }
347
348 pub fn find_anywhere(
358 &self,
359 digest: &str,
360 ) -> Result<Option<(Store, InstalledLayer)>, StoreError> {
361 if let Some(entry) = self.get(digest)? {
362 return Ok(Some((self.clone(), entry)));
363 }
364 let root = self.varve_root();
365 let mut candidates = vec![Store::at(&root)];
367 if let Ok(rd) = std::fs::read_dir(root.join("realms")) {
368 let mut parts: Vec<std::path::PathBuf> =
369 rd.filter_map(|e| e.ok()).map(|e| e.path()).collect();
370 parts.sort();
371 candidates.extend(parts.into_iter().map(Store::at));
372 }
373 for candidate in candidates {
374 if candidate.root() == self.root() {
375 continue;
376 }
377 if let Some(entry) = candidate.get(digest)? {
378 return Ok(Some((candidate, entry)));
379 }
380 }
381 Ok(None)
382 }
383
384 pub fn get(&self, digest: &str) -> Result<Option<InstalledLayer>, StoreError> {
385 let entry = self.core_dir().join(digest.replace(':', "-"));
386 if !entry.join("layer.json").is_file() {
387 return Ok(None);
388 }
389 self.read_entry(digest).map(Some)
390 }
391
392 pub fn tool_path(&self, layer: &InstalledLayer, tool: &str) -> Option<PathBuf> {
396 let path = layer.root.join("bin").join(tool);
397 path.is_file().then_some(path)
398 }
399
400 pub fn entry_path(&self, layer: &InstalledLayer, entry: &ManifestEntry) -> Option<PathBuf> {
411 let name = entry.annotations.get("eu.pulseengine.tool")?;
412 let dispatchable = entry_is_dispatchable(entry);
413 let rel = payload_rel_path(dispatchable, name, entry_version(entry)).ok()?;
414 let path = layer.root.join(rel);
415 if path.is_file() {
416 return Some(path);
417 }
418 (!dispatchable)
419 .then(|| layer.root.join("bin").join(name))
420 .filter(|legacy| legacy.is_file())
421 }
422
423 fn core_dir(&self) -> PathBuf {
424 self.root.join("core")
425 }
426
427 fn read_entry(&self, digest: &str) -> Result<InstalledLayer, StoreError> {
428 let root = self.core_dir().join(digest.replace(':', "-"));
429 let manifest_path = root.join("layer.json");
430 let bad = |reason: String| StoreError::BadManifest {
431 path: manifest_path.display().to_string(),
432 reason,
433 };
434 let bytes = std::fs::read(&manifest_path).map_err(|source| StoreError::Io {
435 path: manifest_path.display().to_string(),
436 source,
437 })?;
438 let envelope: ManifestEnvelope =
439 serde_json::from_slice(&bytes).map_err(|e| bad(e.to_string()))?;
440 let layer_str = envelope
441 .annotations
442 .get("eu.pulseengine.varve.layer")
443 .ok_or_else(|| bad("missing eu.pulseengine.varve.layer annotation".into()))?;
444 let layer: LayerId = layer_str
445 .parse()
446 .map_err(|e: crate::layer::LayerIdError| bad(e.to_string()))?;
447 let channel = envelope
448 .annotations
449 .get("eu.pulseengine.varve.channel")
450 .cloned()
451 .unwrap_or_default();
452 Ok(InstalledLayer {
453 digest: digest.to_string(),
454 layer,
455 channel,
456 root,
457 })
458 }
459}
460
461impl Store {
463 pub fn manifest_tool_names(&self, layer: &InstalledLayer) -> Result<Vec<String>, StoreError> {
469 let payload = std::fs::read(layer.root.join("layer.json")).map_err(|e| StoreError::Io {
470 path: layer.root.join("layer.json").display().to_string(),
471 source: e,
472 })?;
473 let json: serde_json::Value = match serde_json::from_slice(&payload) {
474 Ok(j) => j,
475 Err(_) => return Ok(Vec::new()),
476 };
477 Ok(json["manifests"]
478 .as_array()
479 .map(|es| {
480 es.iter()
481 .filter_map(|e| e["annotations"]["eu.pulseengine.tool"].as_str())
482 .map(str::to_string)
483 .collect()
484 })
485 .unwrap_or_default())
486 }
487}
488
489pub fn manifest_digest(bytes: &[u8]) -> String {
490 format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
491}
492
493#[cfg(test)]
494pub(crate) mod fixtures {
495 pub fn manifest(layer: &str, channel: &str) -> Vec<u8> {
497 format!(
498 r#"{{
499 "schemaVersion": 2,
500 "mediaType": "application/vnd.oci.image.index.v1+json",
501 "artifactType": "application/vnd.pulseengine.varve.layer.v1+json",
502 "annotations": {{
503 "eu.pulseengine.varve.layer": "{layer}",
504 "eu.pulseengine.varve.channel": "{channel}"
505 }},
506 "manifests": []
507}}"#
508 )
509 .into_bytes()
510 }
511
512 pub fn manifest_without_channel(layer: &str) -> Vec<u8> {
516 format!(
517 r#"{{
518 "schemaVersion": 2,
519 "mediaType": "application/vnd.oci.image.index.v1+json",
520 "artifactType": "application/vnd.pulseengine.varve.layer.v1+json",
521 "annotations": {{
522 "eu.pulseengine.varve.layer": "{layer}"
523 }},
524 "manifests": []
525}}"#
526 )
527 .into_bytes()
528 }
529}
530
531#[cfg(test)]
532mod partition_tests {
533 use super::*;
534
535 #[test]
537 fn a_layer_is_found_in_any_partition_under_the_same_root() {
538 let tmp = tempfile::tempdir().unwrap();
544 let root = tmp.path();
545 let mine = Store::at(root.join("realms").join("aaaa"));
546 let theirs = Store::at(root.join("realms").join("bbbb"));
547 let digest = theirs
548 .lay_down(
549 &fixtures::manifest("2026.08.0", "qualified"),
550 &[("btool", b"b")],
551 )
552 .unwrap();
553
554 assert!(mine.get(&digest).unwrap().is_none());
556 let (owner, entry) = mine.find_anywhere(&digest).unwrap().expect("found");
558 assert_eq!(entry.digest, digest);
559 assert_eq!(owner.root(), theirs.root(), "found in the owning partition");
560 assert!(owner.tool_path(&entry, "btool").is_some());
562 }
563
564 #[test]
566 fn the_varve_root_is_recovered_from_a_realm_partition() {
567 let tmp = tempfile::tempdir().unwrap();
568 let root = tmp.path();
569 assert_eq!(
570 Store::at(root.join("realms").join("ffff")).varve_root(),
571 root.to_path_buf()
572 );
573 assert_eq!(Store::at(root).varve_root(), root.to_path_buf());
575 }
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581
582 fn store() -> (tempfile::TempDir, Store) {
583 let tmp = tempfile::tempdir().unwrap();
584 let store = Store::at(tmp.path().join("varve-root"));
585 (tmp, store)
586 }
587
588 #[test]
590 fn two_layers_coexist_and_are_independently_addressable() {
591 let (_tmp, store) = store();
592 let july = fixtures::manifest("2026.07.0", "qualified");
593 let august = fixtures::manifest("2026.08.0", "qualified");
594 let d_july = store.lay_down(&july, &[("synth", b"july-synth")]).unwrap();
595 let d_august = store
596 .lay_down(&august, &[("synth", b"august-synth")])
597 .unwrap();
598 assert_ne!(d_july, d_august);
599
600 let listed = store.list().unwrap();
601 assert_eq!(listed.len(), 2);
602
603 let july_entry = store.get(&d_july).unwrap().unwrap();
604 let august_entry = store.get(&d_august).unwrap().unwrap();
605 assert_eq!(july_entry.layer.to_string(), "2026.07.0");
606 assert_eq!(august_entry.layer.to_string(), "2026.08.0");
607
608 let july_synth = store.tool_path(&july_entry, "synth").unwrap();
611 let august_synth = store.tool_path(&august_entry, "synth").unwrap();
612 assert_eq!(std::fs::read(july_synth).unwrap(), b"july-synth");
613 assert_eq!(std::fs::read(august_synth).unwrap(), b"august-synth");
614 }
615
616 #[test]
618 fn store_key_is_the_manifest_digest() {
619 let (_tmp, store) = store();
620 let bytes = fixtures::manifest("2026.07.0", "qualified");
621 let digest = store.lay_down(&bytes, &[]).unwrap();
622 assert_eq!(digest, manifest_digest(&bytes));
623 let entry = store.get(&digest).unwrap().unwrap();
624 assert!(
625 entry
626 .root
627 .ends_with(format!("core/{}", digest.replace(':', "-"))),
628 "entry rooted at digest-keyed dir, got {}",
629 entry.root.display()
630 );
631 assert_eq!(std::fs::read(entry.root.join("layer.json")).unwrap(), bytes);
633 }
634
635 #[test]
637 fn missing_layer_is_none_not_an_invention() {
638 let (_tmp, store) = store();
639 assert_eq!(
640 store
641 .get("sha256:0000000000000000000000000000000000000000000000000000000000000000")
642 .unwrap(),
643 None
644 );
645 assert_eq!(store.list().unwrap(), vec![]);
646 }
647
648 fn entry(name: &str, version: Option<&str>, kind: Option<&str>) -> ManifestEntry {
650 let mut annotations = BTreeMap::new();
651 annotations.insert("eu.pulseengine.tool".to_string(), name.to_string());
652 if let Some(v) = version {
653 annotations.insert("eu.pulseengine.tool.version".to_string(), v.to_string());
654 }
655 if let Some(k) = kind {
656 annotations.insert(crate::kind::ANN_KIND.to_string(), k.to_string());
657 }
658 ManifestEntry {
659 digest: manifest_digest(name.as_bytes()),
660 annotations,
661 }
662 }
663
664 fn held<'a>(name: &'a str, version: &'a str, bytes: &'a [u8]) -> Payload<'a> {
665 Payload {
666 name,
667 version: Some(version),
668 dispatchable: false,
669 bytes,
670 }
671 }
672
673 #[test]
675 fn two_versions_of_one_name_are_two_files_neither_overwriting_the_other() {
676 let (_tmp, store) = store();
682 let manifest = fixtures::manifest("2026.08.0", "qualified");
683 let digest = store
684 .lay_down_payloads(
685 &manifest,
686 &[
687 held("serde", "1.0.200", b"serde-200-bytes"),
688 held("serde", "1.0.210", b"serde-210-bytes"),
689 ],
690 )
691 .unwrap();
692 let layer = store.get(&digest).unwrap().unwrap();
693
694 let two_hundred = layer.root.join("payloads/serde/1.0.200");
696 let two_ten = layer.root.join("payloads/serde/1.0.210");
697 assert_eq!(std::fs::read(&two_hundred).unwrap(), b"serde-200-bytes");
698 assert_eq!(std::fs::read(&two_ten).unwrap(), b"serde-210-bytes");
699 assert!(!layer.root.join("bin/serde").exists());
701
702 assert_eq!(
706 store.entry_path(&layer, &entry("serde", Some("1.0.200"), Some("crate"))),
707 Some(two_hundred)
708 );
709 assert_eq!(
710 store.entry_path(&layer, &entry("serde", Some("1.0.210"), Some("crate"))),
711 Some(two_ten)
712 );
713 }
714
715 #[test]
717 fn two_payloads_claiming_one_path_are_refused_before_anything_is_written() {
718 let (_tmp, store) = store();
724 let manifest = fixtures::manifest("2026.08.0", "qualified");
725 let err = store
726 .lay_down_payloads(
727 &manifest,
728 &[
729 Payload::tool("synth", b"first-bytes"),
730 Payload::tool("synth", b"second-bytes"),
731 ],
732 )
733 .unwrap_err();
734 assert!(
735 matches!(&err, StoreError::Collision { path, .. } if path.contains("synth")),
736 "got: {err}"
737 );
738 assert!(store.list().unwrap().is_empty(), "nothing may be laid down");
741
742 let err = store
744 .lay_down_payloads(
745 &manifest,
746 &[
747 Payload {
748 name: "wit-pkg",
749 version: None,
750 dispatchable: false,
751 bytes: b"a",
752 },
753 Payload {
754 name: "wit-pkg",
755 version: None,
756 dispatchable: false,
757 bytes: b"b",
758 },
759 ],
760 )
761 .unwrap_err();
762 assert!(matches!(err, StoreError::Collision { .. }), "got: {err}");
763 }
764
765 #[test]
767 fn a_name_or_version_that_escapes_the_layer_is_refused() {
768 let (_tmp, store) = store();
772 let manifest = fixtures::manifest("2026.08.0", "qualified");
773 for (name, version) in [
774 ("../../escape", Some("1.0.0")),
775 ("serde", Some("../../escape")),
776 ("a/b", Some("1.0.0")),
777 ("..", Some("1.0.0")),
778 ("", Some("1.0.0")),
779 ("serde", Some("")),
780 ] {
781 let err = store
782 .lay_down_payloads(&manifest, &[held(name, version.unwrap(), b"x")])
783 .unwrap_err();
784 assert!(
785 matches!(err, StoreError::UnsafeComponent { .. }),
786 "{name:?}@{version:?} must be refused, got: {err}"
787 );
788 }
789 assert!(store.list().unwrap().is_empty());
790 assert!(!store.root().join("escape").exists());
792 }
793
794 #[test]
796 fn a_layer_installed_before_this_change_still_resolves_its_crate() {
797 let (_tmp, store) = store();
803 let manifest = fixtures::manifest("2026.08.0", "qualified");
804 let digest = store
805 .lay_down(&manifest, &[("legacy-crate", b"old-layout-bytes")])
806 .unwrap();
807 let layer = store.get(&digest).unwrap().unwrap();
808 let path = store
809 .entry_path(&layer, &entry("legacy-crate", Some("0.1.0"), Some("crate")))
810 .expect("a pre-REQ-STORE-002 layer must keep resolving");
811 assert_eq!(std::fs::read(path).unwrap(), b"old-layout-bytes");
812 }
813
814 #[test]
816 fn a_tool_keeps_bin_and_a_held_payload_never_borrows_it() {
817 let (_tmp, store) = store();
822 let manifest = fixtures::manifest("2026.08.0", "qualified");
823 let digest = store
824 .lay_down_payloads(
825 &manifest,
826 &[
827 Payload::tool("synth", b"synth-binary"),
828 held("serde", "1.0.200", b"serde-crate"),
829 ],
830 )
831 .unwrap();
832 let layer = store.get(&digest).unwrap().unwrap();
833 assert_eq!(
834 std::fs::read(store.tool_path(&layer, "synth").unwrap()).unwrap(),
835 b"synth-binary"
836 );
837 assert_eq!(store.tool_path(&layer, "serde"), None, "not dispatchable");
838 #[cfg(unix)]
839 {
840 use std::os::unix::fs::PermissionsExt;
841 let mode =
842 |p: std::path::PathBuf| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
843 assert_eq!(mode(layer.root.join("bin/synth")), 0o755);
844 assert_eq!(
845 mode(layer.root.join("payloads/serde/1.0.200")),
846 0o644,
847 "a .crate tarball is data, not something to execute"
848 );
849 }
850 }
851
852 #[test]
854 fn an_unrecognised_kind_is_held_by_version_never_dispatched_by_name() {
855 let e = entry("future", Some("2.0.0"), Some("quantum-blob"));
861 assert!(!entry_is_dispatchable(&e));
862 assert_eq!(
863 payload_rel_path(false, "future", Some("2.0.0")).unwrap(),
864 PathBuf::from("payloads/future/2.0.0")
865 );
866 assert!(entry_is_dispatchable(&entry("synth", Some("0.45.0"), None)));
869 assert_eq!(
870 payload_rel_path(true, "synth", Some("0.45.0")).unwrap(),
871 PathBuf::from("bin/synth")
872 );
873 }
874
875 #[test]
877 fn a_tree_shaped_payload_is_held_by_name_and_version_like_any_other() {
878 let (_tmp, store) = store();
893 let manifest = fixtures::manifest("2026.08.0", "qualified");
894 let digest = store
895 .lay_down_payloads(
896 &manifest,
897 &[
898 held("poky-cortexa53", "4.0.15", b"sdk-tarball-4.0.15"),
899 held("poky-cortexa53", "5.0.2", b"sdk-tarball-5.0.2"),
900 held("zephyr-hal", "3.6.0", b"zephyr-module-tarball"),
901 ],
902 )
903 .unwrap();
904 let layer = store.get(&digest).unwrap().unwrap();
905
906 for (version, want) in [
909 ("4.0.15", &b"sdk-tarball-4.0.15"[..]),
910 ("5.0.2", &b"sdk-tarball-5.0.2"[..]),
911 ] {
912 let path = store
913 .entry_path(&layer, &entry("poky-cortexa53", Some(version), Some("sdk")))
914 .expect("an sdk resolves from its manifest entry like any held payload");
915 assert_eq!(
916 path,
917 layer
918 .root
919 .join(format!("payloads/poky-cortexa53/{version}"))
920 );
921 assert_eq!(std::fs::read(&path).unwrap(), want);
922 }
923 assert!(!crate::kind::PayloadKind::Sdk.is_dispatchable());
925 assert_eq!(store.tool_path(&layer, "poky-cortexa53"), None);
926 assert!(!layer.root.join("bin/poky-cortexa53").exists());
927 assert!(
931 store
932 .entry_path(
933 &layer,
934 &entry("zephyr-hal", Some("3.6.0"), Some("zephyr-module"))
935 )
936 .is_some()
937 );
938 }
939
940 #[test]
942 fn missing_tool_in_an_installed_layer_is_none() {
943 let (_tmp, store) = store();
944 let bytes = fixtures::manifest("2026.07.0", "qualified");
945 let digest = store.lay_down(&bytes, &[("rivet", b"r")]).unwrap();
946 let entry = store.get(&digest).unwrap().unwrap();
947 assert!(store.tool_path(&entry, "rivet").is_some());
948 assert_eq!(store.tool_path(&entry, "synth"), None);
949 }
950}