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}