Skip to main content

greentic_bundle_reader/
lib.rs

1use std::fmt;
2use std::fs;
3use std::io::{BufReader, Read};
4use std::path::{Path, PathBuf};
5
6use backhand::{FilesystemReader, InnerNode};
7use serde::{Deserialize, Serialize};
8
9pub const BUNDLE_FORMAT_VERSION: &str = "gtbundle-v1";
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct BundleManifest {
13    pub format_version: String,
14    pub bundle_id: String,
15    pub bundle_name: String,
16    pub requested_mode: String,
17    pub locale: String,
18    pub artifact_extension: String,
19    #[serde(default)]
20    pub generated_resolved_files: Vec<String>,
21    #[serde(default)]
22    pub generated_setup_files: Vec<String>,
23    #[serde(default)]
24    pub app_packs: Vec<String>,
25    #[serde(default)]
26    pub extension_providers: Vec<String>,
27    #[serde(default)]
28    pub catalogs: Vec<String>,
29    #[serde(default)]
30    pub hooks: Vec<String>,
31    #[serde(default)]
32    pub subscriptions: Vec<String>,
33    #[serde(default)]
34    pub capabilities: Vec<String>,
35    #[serde(default)]
36    pub resolved_targets: Vec<BundleResolvedTargetView>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct BundleLock {
41    pub schema_version: u32,
42    pub bundle_id: String,
43    pub requested_mode: String,
44    pub execution: String,
45    pub cache_policy: String,
46    pub tool_version: String,
47    pub build_format_version: String,
48    pub workspace_root: String,
49    pub lock_file: String,
50    pub catalogs: Vec<CatalogLockEntry>,
51    pub app_packs: Vec<DependencyLock>,
52    pub extension_providers: Vec<DependencyLock>,
53    pub setup_state_files: Vec<String>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct CatalogLockEntry {
58    pub requested_ref: String,
59    pub resolved_ref: String,
60    pub digest: String,
61    pub source: String,
62    pub item_count: usize,
63    pub item_ids: Vec<String>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub cache_path: Option<String>,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct DependencyLock {
70    pub reference: String,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub digest: Option<String>,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum BundleSourceKind {
78    Artifact,
79    BuildDir,
80}
81
82impl BundleSourceKind {
83    pub fn as_str(self) -> &'static str {
84        match self {
85            Self::Artifact => "artifact",
86            Self::BuildDir => "build_dir",
87        }
88    }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct BundleRuntimeSurface {
93    pub format_version: String,
94    pub bundle_id: String,
95    pub bundle_name: String,
96    pub requested_mode: String,
97    pub locale: String,
98    pub execution: String,
99    pub cache_policy: String,
100    pub workspace_root: String,
101    pub lock_file: String,
102    pub app_packs: Vec<BundleDependencyView>,
103    pub extension_providers: Vec<BundleDependencyView>,
104    pub catalogs: Vec<BundleCatalogView>,
105    pub hooks: Vec<String>,
106    pub subscriptions: Vec<String>,
107    pub capabilities: Vec<String>,
108    pub resolved_targets: Vec<BundleResolvedTargetView>,
109    pub generated_resolved_files: Vec<BundleFileView>,
110    pub generated_setup_files: Vec<BundleFileView>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct BundleDependencyView {
115    pub reference: String,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub digest: Option<String>,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct BundleCatalogView {
122    pub requested_ref: String,
123    pub resolved_ref: String,
124    pub digest: String,
125    pub source: String,
126    pub item_count: usize,
127    pub item_ids: Vec<String>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub cache_path: Option<String>,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct BundleFileView {
134    pub path: String,
135    pub kind: BundleFileKind,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139pub struct BundleResolvedTargetView {
140    pub path: String,
141    pub tenant: String,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub team: Option<String>,
144    pub default_policy: String,
145    pub tenant_gmap: String,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub team_gmap: Option<String>,
148    #[serde(default)]
149    pub app_pack_policies: Vec<BundleResolvedReferencePolicyView>,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct BundleResolvedReferencePolicyView {
154    pub reference: String,
155    pub policy: String,
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "snake_case")]
160pub enum BundleFileKind {
161    Resolved,
162    SetupState,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166pub struct OpenedBundle {
167    pub source_kind: BundleSourceKind,
168    pub source_path: String,
169    pub format_version: String,
170    pub manifest: BundleManifest,
171    pub lock: BundleLock,
172}
173
174impl OpenedBundle {
175    pub fn from_parts(
176        source_kind: BundleSourceKind,
177        source_path: impl Into<String>,
178        manifest: BundleManifest,
179        lock: BundleLock,
180    ) -> Result<Self, BundleReadError> {
181        let opened = Self {
182            source_kind,
183            source_path: source_path.into(),
184            format_version: manifest.format_version.clone(),
185            manifest,
186            lock,
187        };
188        opened.validate_basic_structure()?;
189        Ok(opened)
190    }
191
192    pub fn runtime_surface(&self) -> BundleRuntimeSurface {
193        BundleRuntimeSurface {
194            format_version: self.manifest.format_version.clone(),
195            bundle_id: self.manifest.bundle_id.clone(),
196            bundle_name: self.manifest.bundle_name.clone(),
197            requested_mode: self.manifest.requested_mode.clone(),
198            locale: self.manifest.locale.clone(),
199            execution: self.lock.execution.clone(),
200            cache_policy: self.lock.cache_policy.clone(),
201            workspace_root: self.lock.workspace_root.clone(),
202            lock_file: self.lock.lock_file.clone(),
203            app_packs: self
204                .lock
205                .app_packs
206                .iter()
207                .map(|entry| BundleDependencyView {
208                    reference: entry.reference.clone(),
209                    digest: entry.digest.clone(),
210                })
211                .collect(),
212            extension_providers: self
213                .lock
214                .extension_providers
215                .iter()
216                .map(|entry| BundleDependencyView {
217                    reference: entry.reference.clone(),
218                    digest: entry.digest.clone(),
219                })
220                .collect(),
221            catalogs: self
222                .lock
223                .catalogs
224                .iter()
225                .map(|entry| BundleCatalogView {
226                    requested_ref: entry.requested_ref.clone(),
227                    resolved_ref: entry.resolved_ref.clone(),
228                    digest: entry.digest.clone(),
229                    source: entry.source.clone(),
230                    item_count: entry.item_count,
231                    item_ids: entry.item_ids.clone(),
232                    cache_path: entry.cache_path.clone(),
233                })
234                .collect(),
235            hooks: self.manifest.hooks.clone(),
236            subscriptions: self.manifest.subscriptions.clone(),
237            capabilities: self.manifest.capabilities.clone(),
238            resolved_targets: self.manifest.resolved_targets.clone(),
239            generated_resolved_files: self
240                .manifest
241                .generated_resolved_files
242                .iter()
243                .map(|path| BundleFileView {
244                    path: path.clone(),
245                    kind: BundleFileKind::Resolved,
246                })
247                .collect(),
248            generated_setup_files: self
249                .manifest
250                .generated_setup_files
251                .iter()
252                .map(|path| BundleFileView {
253                    path: path.clone(),
254                    kind: BundleFileKind::SetupState,
255                })
256                .collect(),
257        }
258    }
259
260    pub fn validate_basic_structure(&self) -> Result<(), BundleReadError> {
261        if self.manifest.format_version != BUNDLE_FORMAT_VERSION {
262            return Err(BundleReadError::invalid(
263                self.source_kind,
264                &self.source_path,
265                format!(
266                    "unsupported bundle format version: {}",
267                    self.manifest.format_version
268                ),
269            ));
270        }
271        if self.manifest.bundle_id.trim().is_empty() {
272            return Err(BundleReadError::invalid(
273                self.source_kind,
274                &self.source_path,
275                "bundle manifest is missing bundle_id".to_string(),
276            ));
277        }
278        if self.lock.bundle_id.trim().is_empty() {
279            return Err(BundleReadError::invalid(
280                self.source_kind,
281                &self.source_path,
282                "bundle lock is missing bundle_id".to_string(),
283            ));
284        }
285        if self.manifest.bundle_id != self.lock.bundle_id {
286            return Err(BundleReadError::invalid(
287                self.source_kind,
288                &self.source_path,
289                "bundle manifest and lock bundle_id do not match".to_string(),
290            ));
291        }
292        if self.manifest.requested_mode != self.lock.requested_mode {
293            return Err(BundleReadError::invalid(
294                self.source_kind,
295                &self.source_path,
296                "bundle manifest and lock requested_mode do not match".to_string(),
297            ));
298        }
299        if self.manifest.artifact_extension != ".gtbundle" {
300            return Err(BundleReadError::invalid(
301                self.source_kind,
302                &self.source_path,
303                format!(
304                    "unsupported artifact extension: {}",
305                    self.manifest.artifact_extension
306                ),
307            ));
308        }
309        if self.lock.workspace_root != "bundle.yaml" {
310            return Err(BundleReadError::invalid(
311                self.source_kind,
312                &self.source_path,
313                format!("unexpected workspace_root: {}", self.lock.workspace_root),
314            ));
315        }
316        if self.lock.lock_file != "bundle.lock.json" {
317            return Err(BundleReadError::invalid(
318                self.source_kind,
319                &self.source_path,
320                format!("unexpected lock_file: {}", self.lock.lock_file),
321            ));
322        }
323        if self.lock.setup_state_files != self.manifest.generated_setup_files {
324            return Err(BundleReadError::invalid(
325                self.source_kind,
326                &self.source_path,
327                "bundle manifest and lock setup state files do not match".to_string(),
328            ));
329        }
330        Ok(())
331    }
332}
333
334#[derive(Debug, Clone, PartialEq, Eq)]
335pub struct BundleReadError {
336    pub kind: BundleReadErrorKind,
337    pub source_kind: BundleSourceKind,
338    pub source_path: String,
339    pub details: String,
340}
341
342#[derive(Debug, Clone, Copy, PartialEq, Eq)]
343pub enum BundleReadErrorKind {
344    Io,
345    Invalid,
346    Tool,
347}
348
349impl BundleReadError {
350    fn io(source_kind: BundleSourceKind, source_path: &Path, details: String) -> Self {
351        Self {
352            kind: BundleReadErrorKind::Io,
353            source_kind,
354            source_path: source_path.display().to_string(),
355            details,
356        }
357    }
358
359    fn invalid(source_kind: BundleSourceKind, source_path: &str, details: String) -> Self {
360        Self {
361            kind: BundleReadErrorKind::Invalid,
362            source_kind,
363            source_path: source_path.to_string(),
364            details,
365        }
366    }
367
368    fn tool(source_kind: BundleSourceKind, source_path: &Path, details: String) -> Self {
369        Self {
370            kind: BundleReadErrorKind::Tool,
371            source_kind,
372            source_path: source_path.display().to_string(),
373            details,
374        }
375    }
376}
377
378impl fmt::Display for BundleReadError {
379    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
380        write!(
381            f,
382            "{} read failed for {} ({}): {}",
383            self.source_kind.as_str(),
384            self.source_path,
385            match self.kind {
386                BundleReadErrorKind::Io => "io",
387                BundleReadErrorKind::Invalid => "invalid",
388                BundleReadErrorKind::Tool => "tool",
389            },
390            self.details
391        )
392    }
393}
394
395impl std::error::Error for BundleReadError {}
396
397pub fn open_artifact(path: &Path) -> Result<OpenedBundle, BundleReadError> {
398    let manifest_raw = read_artifact_file(path, "bundle-manifest.json")?;
399    let lock_raw = read_artifact_file(path, "bundle-lock.json")?;
400    let manifest = parse_manifest(BundleSourceKind::Artifact, path, &manifest_raw)?;
401    let lock = parse_lock(BundleSourceKind::Artifact, path, &lock_raw)?;
402    let opened = OpenedBundle::from_parts(
403        BundleSourceKind::Artifact,
404        path.display().to_string(),
405        manifest,
406        lock,
407    )?;
408    validate_artifact_contents(path, &opened)?;
409    Ok(opened)
410}
411
412pub fn open_build_dir(path: &Path) -> Result<OpenedBundle, BundleReadError> {
413    open_build_dir_with_source(path, path.display().to_string())
414}
415
416pub fn open_build_dir_with_source(
417    path: &Path,
418    source_path: impl Into<String>,
419) -> Result<OpenedBundle, BundleReadError> {
420    let manifest_raw = read_build_file(path, "bundle-manifest.json")?;
421    let lock_raw = read_build_file(path, "bundle-lock.json")?;
422    let manifest = parse_manifest(BundleSourceKind::BuildDir, path, &manifest_raw)?;
423    let lock = parse_lock(BundleSourceKind::BuildDir, path, &lock_raw)?;
424    let opened = OpenedBundle::from_parts(BundleSourceKind::BuildDir, source_path, manifest, lock)?;
425    validate_build_dir_contents(path, &opened)?;
426    Ok(opened)
427}
428
429fn read_build_file(root: &Path, name: &str) -> Result<String, BundleReadError> {
430    fs::read_to_string(root.join(name)).map_err(|error| {
431        BundleReadError::io(
432            BundleSourceKind::BuildDir,
433            root,
434            format!("read {}: {error}", root.join(name).display()),
435        )
436    })
437}
438
439fn read_artifact_file(path: &Path, inner_path: &str) -> Result<String, BundleReadError> {
440    let bytes = read_artifact_bytes(path, inner_path)?;
441    String::from_utf8(bytes).map_err(|error| {
442        BundleReadError::invalid(
443            BundleSourceKind::Artifact,
444            &path.display().to_string(),
445            format!("artifact entry {inner_path} is not valid utf-8: {error}"),
446        )
447    })
448}
449
450fn read_artifact_bytes(path: &Path, inner_path: &str) -> Result<Vec<u8>, BundleReadError> {
451    let filesystem = open_artifact_filesystem(path)?;
452    let normalized_inner = normalize_artifact_path(inner_path).map_err(|error| {
453        BundleReadError::invalid(
454            BundleSourceKind::Artifact,
455            &path.display().to_string(),
456            format!("invalid artifact path {inner_path}: {error}"),
457        )
458    })?;
459    for node in filesystem.files() {
460        let Some(node_path) = normalize_node_path(&node.fullpath).map_err(|error| {
461            BundleReadError::tool(
462                BundleSourceKind::Artifact,
463                path,
464                format!("read SquashFS path {}: {error}", node.fullpath.display()),
465            )
466        })?
467        else {
468            continue;
469        };
470        if node_path != normalized_inner {
471            continue;
472        }
473        let InnerNode::File(file) = &node.inner else {
474            return Err(BundleReadError::invalid(
475                BundleSourceKind::Artifact,
476                &path.display().to_string(),
477                format!("artifact entry {inner_path} is not a file"),
478            ));
479        };
480        let mut reader = filesystem.file(file).reader();
481        let mut bytes = Vec::new();
482        reader.read_to_end(&mut bytes).map_err(|error| {
483            BundleReadError::tool(
484                BundleSourceKind::Artifact,
485                path,
486                format!("read artifact entry {inner_path}: {error}"),
487            )
488        })?;
489        return Ok(bytes);
490    }
491    Err(BundleReadError::tool(
492        BundleSourceKind::Artifact,
493        path,
494        format!("artifact entry {inner_path} not found"),
495    ))
496}
497
498fn open_artifact_filesystem(path: &Path) -> Result<FilesystemReader<'static>, BundleReadError> {
499    let file = fs::File::open(path).map_err(|error| {
500        BundleReadError::io(
501            BundleSourceKind::Artifact,
502            path,
503            format!("open artifact {}: {error}", path.display()),
504        )
505    })?;
506    FilesystemReader::from_reader(BufReader::new(file)).map_err(|error| {
507        BundleReadError::tool(
508            BundleSourceKind::Artifact,
509            path,
510            format!("read SquashFS artifact with Rust-native reader: {error}"),
511        )
512    })
513}
514
515fn normalize_node_path(path: &Path) -> Result<Option<String>, String> {
516    if path == Path::new("/") {
517        return Ok(None);
518    }
519    let stripped = path.strip_prefix("/").unwrap_or(path);
520    normalize_path(stripped).map(Some)
521}
522
523fn normalize_artifact_path(path: &str) -> Result<String, String> {
524    normalize_path(Path::new(path.trim_matches('/')))
525}
526
527fn normalize_path(path: &Path) -> Result<String, String> {
528    let mut parts = Vec::new();
529    for component in path.components() {
530        match component {
531            std::path::Component::Normal(part) => {
532                let part = part
533                    .to_str()
534                    .ok_or_else(|| format!("path must be valid UTF-8: {}", path.display()))?;
535                parts.push(part.to_string());
536            }
537            std::path::Component::CurDir => {}
538            std::path::Component::ParentDir
539            | std::path::Component::RootDir
540            | std::path::Component::Prefix(_) => {
541                return Err(format!("path must be relative: {}", path.display()));
542            }
543        }
544    }
545    if parts.is_empty() {
546        return Err("path cannot be empty".to_string());
547    }
548    Ok(parts.join("/"))
549}
550
551fn parse_manifest(
552    source_kind: BundleSourceKind,
553    source_path: &Path,
554    raw: &str,
555) -> Result<BundleManifest, BundleReadError> {
556    serde_json::from_str(raw).map_err(|error| {
557        BundleReadError::invalid(
558            source_kind,
559            &source_path.display().to_string(),
560            format!("parse bundle-manifest.json: {error}"),
561        )
562    })
563}
564
565fn parse_lock(
566    source_kind: BundleSourceKind,
567    source_path: &Path,
568    raw: &str,
569) -> Result<BundleLock, BundleReadError> {
570    serde_json::from_str(raw).map_err(|error| {
571        BundleReadError::invalid(
572            source_kind,
573            &source_path.display().to_string(),
574            format!("parse bundle-lock.json: {error}"),
575        )
576    })
577}
578
579fn validate_build_dir_contents(path: &Path, opened: &OpenedBundle) -> Result<(), BundleReadError> {
580    ensure_path_exists(
581        BundleSourceKind::BuildDir,
582        path,
583        &path.join("bundle.yaml"),
584        "bundle.yaml",
585    )?;
586    for rel_path in &opened.manifest.generated_resolved_files {
587        ensure_path_exists(
588            BundleSourceKind::BuildDir,
589            path,
590            &path.join(rel_path),
591            rel_path,
592        )?;
593    }
594    for rel_path in &opened.manifest.generated_setup_files {
595        ensure_path_exists(
596            BundleSourceKind::BuildDir,
597            path,
598            &path.join(rel_path),
599            rel_path,
600        )?;
601    }
602    Ok(())
603}
604
605fn validate_artifact_contents(path: &Path, opened: &OpenedBundle) -> Result<(), BundleReadError> {
606    read_artifact_file(path, "bundle.yaml")?;
607    for rel_path in &opened.manifest.generated_resolved_files {
608        read_artifact_file(path, rel_path)?;
609    }
610    for rel_path in &opened.manifest.generated_setup_files {
611        read_artifact_file(path, rel_path)?;
612    }
613    Ok(())
614}
615
616fn ensure_path_exists(
617    source_kind: BundleSourceKind,
618    source_path: &Path,
619    full_path: &Path,
620    display_path: &str,
621) -> Result<(), BundleReadError> {
622    if full_path.exists() {
623        return Ok(());
624    }
625    Err(BundleReadError::invalid(
626        source_kind,
627        &source_path.display().to_string(),
628        format!("missing required bundle file: {display_path}"),
629    ))
630}
631
632pub fn build_dir_from_artifact_source(root: &Path, bundle_id: &str) -> PathBuf {
633    root.join("state")
634        .join("build")
635        .join(bundle_id)
636        .join("normalized")
637}
638
639#[cfg(test)]
640mod tests {
641    use super::*;
642
643    fn valid_manifest() -> BundleManifest {
644        BundleManifest {
645            format_version: BUNDLE_FORMAT_VERSION.to_string(),
646            bundle_id: "demo".to_string(),
647            bundle_name: "Demo".to_string(),
648            requested_mode: "auto".to_string(),
649            locale: "en".to_string(),
650            artifact_extension: ".gtbundle".to_string(),
651            generated_resolved_files: Vec::new(),
652            generated_setup_files: Vec::new(),
653            app_packs: Vec::new(),
654            extension_providers: Vec::new(),
655            catalogs: Vec::new(),
656            hooks: Vec::new(),
657            subscriptions: Vec::new(),
658            capabilities: Vec::new(),
659            resolved_targets: Vec::new(),
660        }
661    }
662
663    fn valid_lock() -> BundleLock {
664        BundleLock {
665            schema_version: 1,
666            bundle_id: "demo".to_string(),
667            requested_mode: "auto".to_string(),
668            execution: "exec".to_string(),
669            cache_policy: "policy".to_string(),
670            tool_version: "0.0.0".to_string(),
671            build_format_version: BUNDLE_FORMAT_VERSION.to_string(),
672            workspace_root: "bundle.yaml".to_string(),
673            lock_file: "bundle.lock.json".to_string(),
674            catalogs: Vec::new(),
675            app_packs: Vec::new(),
676            extension_providers: Vec::new(),
677            setup_state_files: Vec::new(),
678        }
679    }
680
681    fn opened_with(
682        manifest: BundleManifest,
683        lock: BundleLock,
684    ) -> Result<OpenedBundle, BundleReadError> {
685        OpenedBundle::from_parts(BundleSourceKind::Artifact, "demo.gtbundle", manifest, lock)
686    }
687
688    #[test]
689    fn source_kind_as_str_returns_canonical_labels() {
690        assert_eq!(BundleSourceKind::Artifact.as_str(), "artifact");
691        assert_eq!(BundleSourceKind::BuildDir.as_str(), "build_dir");
692    }
693
694    #[test]
695    fn display_renders_kind_and_details() {
696        let io = BundleReadError {
697            kind: BundleReadErrorKind::Io,
698            source_kind: BundleSourceKind::Artifact,
699            source_path: "demo.gtbundle".to_string(),
700            details: "boom".to_string(),
701        };
702        assert_eq!(
703            io.to_string(),
704            "artifact read failed for demo.gtbundle (io): boom"
705        );
706
707        let invalid = BundleReadError {
708            kind: BundleReadErrorKind::Invalid,
709            source_kind: BundleSourceKind::BuildDir,
710            source_path: "build/".to_string(),
711            details: "bad".to_string(),
712        };
713        assert_eq!(
714            invalid.to_string(),
715            "build_dir read failed for build/ (invalid): bad"
716        );
717
718        let tool = BundleReadError {
719            kind: BundleReadErrorKind::Tool,
720            source_kind: BundleSourceKind::Artifact,
721            source_path: "x".to_string(),
722            details: "y".to_string(),
723        };
724        assert_eq!(tool.to_string(), "artifact read failed for x (tool): y");
725    }
726
727    #[test]
728    fn from_parts_succeeds_for_consistent_manifest_and_lock() {
729        let opened = opened_with(valid_manifest(), valid_lock()).expect("valid bundle");
730        assert_eq!(opened.source_kind, BundleSourceKind::Artifact);
731        assert_eq!(opened.format_version, BUNDLE_FORMAT_VERSION);
732        let surface = opened.runtime_surface();
733        assert_eq!(surface.bundle_id, "demo");
734        assert_eq!(surface.workspace_root, "bundle.yaml");
735        assert!(surface.generated_resolved_files.is_empty());
736        assert!(surface.generated_setup_files.is_empty());
737    }
738
739    #[test]
740    fn validate_rejects_unsupported_format_version() {
741        let mut manifest = valid_manifest();
742        manifest.format_version = "gtbundle-v999".to_string();
743        let err = opened_with(manifest, valid_lock()).expect_err("bad version");
744        assert_eq!(err.kind, BundleReadErrorKind::Invalid);
745        assert!(err.details.contains("unsupported bundle format version"));
746    }
747
748    #[test]
749    fn validate_rejects_empty_manifest_bundle_id() {
750        let mut manifest = valid_manifest();
751        manifest.bundle_id = "   ".to_string();
752        let err = opened_with(manifest, valid_lock()).expect_err("empty manifest id");
753        assert!(err.details.contains("manifest is missing bundle_id"));
754    }
755
756    #[test]
757    fn validate_rejects_empty_lock_bundle_id() {
758        let mut lock = valid_lock();
759        lock.bundle_id = " ".to_string();
760        let err = opened_with(valid_manifest(), lock).expect_err("empty lock id");
761        assert!(err.details.contains("lock is missing bundle_id"));
762    }
763
764    #[test]
765    fn validate_rejects_mismatched_bundle_id() {
766        let mut lock = valid_lock();
767        lock.bundle_id = "other".to_string();
768        let err = opened_with(valid_manifest(), lock).expect_err("mismatched id");
769        assert!(err.details.contains("bundle_id do not match"));
770    }
771
772    #[test]
773    fn validate_rejects_mismatched_requested_mode() {
774        let mut lock = valid_lock();
775        lock.requested_mode = "different".to_string();
776        let err = opened_with(valid_manifest(), lock).expect_err("mismatched mode");
777        assert!(err.details.contains("requested_mode do not match"));
778    }
779
780    #[test]
781    fn validate_rejects_unexpected_artifact_extension() {
782        let mut manifest = valid_manifest();
783        manifest.artifact_extension = ".zip".to_string();
784        let err = opened_with(manifest, valid_lock()).expect_err("bad ext");
785        assert!(err.details.contains("unsupported artifact extension"));
786    }
787
788    #[test]
789    fn validate_rejects_unexpected_workspace_root() {
790        let mut lock = valid_lock();
791        lock.workspace_root = "other.yaml".to_string();
792        let err = opened_with(valid_manifest(), lock).expect_err("bad workspace");
793        assert!(err.details.contains("unexpected workspace_root"));
794    }
795
796    #[test]
797    fn validate_rejects_unexpected_lock_file_name() {
798        let mut lock = valid_lock();
799        lock.lock_file = "other.json".to_string();
800        let err = opened_with(valid_manifest(), lock).expect_err("bad lock file");
801        assert!(err.details.contains("unexpected lock_file"));
802    }
803
804    #[test]
805    fn validate_rejects_mismatched_setup_state_files() {
806        let mut manifest = valid_manifest();
807        manifest.generated_setup_files = vec!["state/a.json".to_string()];
808        let err = opened_with(manifest, valid_lock()).expect_err("setup mismatch");
809        assert!(err.details.contains("setup state files do not match"));
810    }
811
812    #[test]
813    fn normalize_artifact_path_strips_leading_and_trailing_slashes() {
814        assert_eq!(
815            normalize_artifact_path("/bundle-manifest.json").expect("normalize"),
816            "bundle-manifest.json"
817        );
818        assert_eq!(
819            normalize_artifact_path("setup/state.json/").expect("normalize"),
820            "setup/state.json"
821        );
822    }
823
824    #[test]
825    fn normalize_artifact_path_rejects_empty_input() {
826        let err = normalize_artifact_path("").expect_err("empty path");
827        assert!(err.contains("path cannot be empty"));
828    }
829
830    #[test]
831    fn normalize_artifact_path_rejects_parent_segments() {
832        let err = normalize_artifact_path("a/../b").expect_err("parent dir");
833        assert!(err.contains("path must be relative"));
834    }
835
836    #[test]
837    fn normalize_node_path_treats_root_as_none() {
838        assert_eq!(normalize_node_path(Path::new("/")).expect("root"), None);
839        assert_eq!(
840            normalize_node_path(Path::new("/bundle-manifest.json")).expect("file"),
841            Some("bundle-manifest.json".to_string())
842        );
843        assert_eq!(
844            normalize_node_path(Path::new("/nested/dir/file.bin")).expect("nested"),
845            Some("nested/dir/file.bin".to_string())
846        );
847    }
848
849    #[test]
850    fn build_dir_from_artifact_source_uses_state_build_normalized_layout() {
851        let root = Path::new("/tmp/root");
852        let path = build_dir_from_artifact_source(root, "demo");
853        assert_eq!(path, Path::new("/tmp/root/state/build/demo/normalized"));
854    }
855}