Skip to main content

greentic_bundle_reader/
lib.rs

1use std::fmt;
2use std::fs;
3use std::io::ErrorKind;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
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 output = Command::new("unsquashfs")
441        .args(["-cat", path.to_str().unwrap_or_default(), inner_path])
442        .output()
443        .map_err(|error| {
444            BundleReadError::tool(
445                BundleSourceKind::Artifact,
446                path,
447                match error.kind() {
448                    ErrorKind::NotFound => "required tool `unsquashfs` was not found on PATH; install SquashFS tools to read `.gtbundle` artifacts".to_string(),
449                    _ => format!("spawn unsquashfs: {error}"),
450                },
451            )
452        })?;
453    if !output.status.success() {
454        return Err(BundleReadError::tool(
455            BundleSourceKind::Artifact,
456            path,
457            format!(
458                "unsquashfs failed for {}: {}",
459                inner_path,
460                String::from_utf8_lossy(&output.stderr).trim()
461            ),
462        ));
463    }
464    String::from_utf8(output.stdout).map_err(|error| {
465        BundleReadError::invalid(
466            BundleSourceKind::Artifact,
467            &path.display().to_string(),
468            format!("artifact entry {inner_path} is not valid utf-8: {error}"),
469        )
470    })
471}
472
473fn parse_manifest(
474    source_kind: BundleSourceKind,
475    source_path: &Path,
476    raw: &str,
477) -> Result<BundleManifest, BundleReadError> {
478    serde_json::from_str(raw).map_err(|error| {
479        BundleReadError::invalid(
480            source_kind,
481            &source_path.display().to_string(),
482            format!("parse bundle-manifest.json: {error}"),
483        )
484    })
485}
486
487fn parse_lock(
488    source_kind: BundleSourceKind,
489    source_path: &Path,
490    raw: &str,
491) -> Result<BundleLock, BundleReadError> {
492    serde_json::from_str(raw).map_err(|error| {
493        BundleReadError::invalid(
494            source_kind,
495            &source_path.display().to_string(),
496            format!("parse bundle-lock.json: {error}"),
497        )
498    })
499}
500
501fn validate_build_dir_contents(path: &Path, opened: &OpenedBundle) -> Result<(), BundleReadError> {
502    ensure_path_exists(
503        BundleSourceKind::BuildDir,
504        path,
505        &path.join("bundle.yaml"),
506        "bundle.yaml",
507    )?;
508    for rel_path in &opened.manifest.generated_resolved_files {
509        ensure_path_exists(
510            BundleSourceKind::BuildDir,
511            path,
512            &path.join(rel_path),
513            rel_path,
514        )?;
515    }
516    for rel_path in &opened.manifest.generated_setup_files {
517        ensure_path_exists(
518            BundleSourceKind::BuildDir,
519            path,
520            &path.join(rel_path),
521            rel_path,
522        )?;
523    }
524    Ok(())
525}
526
527fn validate_artifact_contents(path: &Path, opened: &OpenedBundle) -> Result<(), BundleReadError> {
528    read_artifact_file(path, "bundle.yaml")?;
529    for rel_path in &opened.manifest.generated_resolved_files {
530        read_artifact_file(path, rel_path)?;
531    }
532    for rel_path in &opened.manifest.generated_setup_files {
533        read_artifact_file(path, rel_path)?;
534    }
535    Ok(())
536}
537
538fn ensure_path_exists(
539    source_kind: BundleSourceKind,
540    source_path: &Path,
541    full_path: &Path,
542    display_path: &str,
543) -> Result<(), BundleReadError> {
544    if full_path.exists() {
545        return Ok(());
546    }
547    Err(BundleReadError::invalid(
548        source_kind,
549        &source_path.display().to_string(),
550        format!("missing required bundle file: {display_path}"),
551    ))
552}
553
554pub fn build_dir_from_artifact_source(root: &Path, bundle_id: &str) -> PathBuf {
555    root.join("state")
556        .join("build")
557        .join(bundle_id)
558        .join("normalized")
559}