Skip to main content

greentic_bundle_reader/
lib.rs

1use std::fmt;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use serde::{Deserialize, Serialize};
7
8pub const BUNDLE_FORMAT_VERSION: &str = "gtbundle-v1";
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct BundleManifest {
12    pub format_version: String,
13    pub bundle_id: String,
14    pub bundle_name: String,
15    pub requested_mode: String,
16    pub locale: String,
17    pub artifact_extension: String,
18    #[serde(default)]
19    pub generated_resolved_files: Vec<String>,
20    #[serde(default)]
21    pub generated_setup_files: Vec<String>,
22    #[serde(default)]
23    pub app_packs: Vec<String>,
24    #[serde(default)]
25    pub extension_providers: Vec<String>,
26    #[serde(default)]
27    pub catalogs: Vec<String>,
28    #[serde(default)]
29    pub hooks: Vec<String>,
30    #[serde(default)]
31    pub subscriptions: Vec<String>,
32    #[serde(default)]
33    pub capabilities: Vec<String>,
34    #[serde(default)]
35    pub resolved_targets: Vec<BundleResolvedTargetView>,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct BundleLock {
40    pub schema_version: u32,
41    pub bundle_id: String,
42    pub requested_mode: String,
43    pub execution: String,
44    pub cache_policy: String,
45    pub tool_version: String,
46    pub build_format_version: String,
47    pub workspace_root: String,
48    pub lock_file: String,
49    pub catalogs: Vec<CatalogLockEntry>,
50    pub app_packs: Vec<DependencyLock>,
51    pub extension_providers: Vec<DependencyLock>,
52    pub setup_state_files: Vec<String>,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct CatalogLockEntry {
57    pub requested_ref: String,
58    pub resolved_ref: String,
59    pub digest: String,
60    pub source: String,
61    pub item_count: usize,
62    pub item_ids: Vec<String>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub cache_path: Option<String>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68pub struct DependencyLock {
69    pub reference: String,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub digest: Option<String>,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum BundleSourceKind {
77    Artifact,
78    BuildDir,
79}
80
81impl BundleSourceKind {
82    pub fn as_str(self) -> &'static str {
83        match self {
84            Self::Artifact => "artifact",
85            Self::BuildDir => "build_dir",
86        }
87    }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct BundleRuntimeSurface {
92    pub format_version: String,
93    pub bundle_id: String,
94    pub bundle_name: String,
95    pub requested_mode: String,
96    pub locale: String,
97    pub execution: String,
98    pub cache_policy: String,
99    pub workspace_root: String,
100    pub lock_file: String,
101    pub app_packs: Vec<BundleDependencyView>,
102    pub extension_providers: Vec<BundleDependencyView>,
103    pub catalogs: Vec<BundleCatalogView>,
104    pub hooks: Vec<String>,
105    pub subscriptions: Vec<String>,
106    pub capabilities: Vec<String>,
107    pub resolved_targets: Vec<BundleResolvedTargetView>,
108    pub generated_resolved_files: Vec<BundleFileView>,
109    pub generated_setup_files: Vec<BundleFileView>,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct BundleDependencyView {
114    pub reference: String,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub digest: Option<String>,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct BundleCatalogView {
121    pub requested_ref: String,
122    pub resolved_ref: String,
123    pub digest: String,
124    pub source: String,
125    pub item_count: usize,
126    pub item_ids: Vec<String>,
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub cache_path: Option<String>,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct BundleFileView {
133    pub path: String,
134    pub kind: BundleFileKind,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct BundleResolvedTargetView {
139    pub path: String,
140    pub tenant: String,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub team: Option<String>,
143    pub default_policy: String,
144    pub tenant_gmap: String,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub team_gmap: Option<String>,
147    #[serde(default)]
148    pub app_pack_policies: Vec<BundleResolvedReferencePolicyView>,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152pub struct BundleResolvedReferencePolicyView {
153    pub reference: String,
154    pub policy: String,
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "snake_case")]
159pub enum BundleFileKind {
160    Resolved,
161    SetupState,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct OpenedBundle {
166    pub source_kind: BundleSourceKind,
167    pub source_path: String,
168    pub format_version: String,
169    pub manifest: BundleManifest,
170    pub lock: BundleLock,
171}
172
173impl OpenedBundle {
174    pub fn from_parts(
175        source_kind: BundleSourceKind,
176        source_path: impl Into<String>,
177        manifest: BundleManifest,
178        lock: BundleLock,
179    ) -> Result<Self, BundleReadError> {
180        let opened = Self {
181            source_kind,
182            source_path: source_path.into(),
183            format_version: manifest.format_version.clone(),
184            manifest,
185            lock,
186        };
187        opened.validate_basic_structure()?;
188        Ok(opened)
189    }
190
191    pub fn runtime_surface(&self) -> BundleRuntimeSurface {
192        BundleRuntimeSurface {
193            format_version: self.manifest.format_version.clone(),
194            bundle_id: self.manifest.bundle_id.clone(),
195            bundle_name: self.manifest.bundle_name.clone(),
196            requested_mode: self.manifest.requested_mode.clone(),
197            locale: self.manifest.locale.clone(),
198            execution: self.lock.execution.clone(),
199            cache_policy: self.lock.cache_policy.clone(),
200            workspace_root: self.lock.workspace_root.clone(),
201            lock_file: self.lock.lock_file.clone(),
202            app_packs: self
203                .lock
204                .app_packs
205                .iter()
206                .map(|entry| BundleDependencyView {
207                    reference: entry.reference.clone(),
208                    digest: entry.digest.clone(),
209                })
210                .collect(),
211            extension_providers: self
212                .lock
213                .extension_providers
214                .iter()
215                .map(|entry| BundleDependencyView {
216                    reference: entry.reference.clone(),
217                    digest: entry.digest.clone(),
218                })
219                .collect(),
220            catalogs: self
221                .lock
222                .catalogs
223                .iter()
224                .map(|entry| BundleCatalogView {
225                    requested_ref: entry.requested_ref.clone(),
226                    resolved_ref: entry.resolved_ref.clone(),
227                    digest: entry.digest.clone(),
228                    source: entry.source.clone(),
229                    item_count: entry.item_count,
230                    item_ids: entry.item_ids.clone(),
231                    cache_path: entry.cache_path.clone(),
232                })
233                .collect(),
234            hooks: self.manifest.hooks.clone(),
235            subscriptions: self.manifest.subscriptions.clone(),
236            capabilities: self.manifest.capabilities.clone(),
237            resolved_targets: self.manifest.resolved_targets.clone(),
238            generated_resolved_files: self
239                .manifest
240                .generated_resolved_files
241                .iter()
242                .map(|path| BundleFileView {
243                    path: path.clone(),
244                    kind: BundleFileKind::Resolved,
245                })
246                .collect(),
247            generated_setup_files: self
248                .manifest
249                .generated_setup_files
250                .iter()
251                .map(|path| BundleFileView {
252                    path: path.clone(),
253                    kind: BundleFileKind::SetupState,
254                })
255                .collect(),
256        }
257    }
258
259    pub fn validate_basic_structure(&self) -> Result<(), BundleReadError> {
260        if self.manifest.format_version != BUNDLE_FORMAT_VERSION {
261            return Err(BundleReadError::invalid(
262                self.source_kind,
263                &self.source_path,
264                format!(
265                    "unsupported bundle format version: {}",
266                    self.manifest.format_version
267                ),
268            ));
269        }
270        if self.manifest.bundle_id.trim().is_empty() {
271            return Err(BundleReadError::invalid(
272                self.source_kind,
273                &self.source_path,
274                "bundle manifest is missing bundle_id".to_string(),
275            ));
276        }
277        if self.lock.bundle_id.trim().is_empty() {
278            return Err(BundleReadError::invalid(
279                self.source_kind,
280                &self.source_path,
281                "bundle lock is missing bundle_id".to_string(),
282            ));
283        }
284        if self.manifest.bundle_id != self.lock.bundle_id {
285            return Err(BundleReadError::invalid(
286                self.source_kind,
287                &self.source_path,
288                "bundle manifest and lock bundle_id do not match".to_string(),
289            ));
290        }
291        if self.manifest.requested_mode != self.lock.requested_mode {
292            return Err(BundleReadError::invalid(
293                self.source_kind,
294                &self.source_path,
295                "bundle manifest and lock requested_mode do not match".to_string(),
296            ));
297        }
298        if self.manifest.artifact_extension != ".gtbundle" {
299            return Err(BundleReadError::invalid(
300                self.source_kind,
301                &self.source_path,
302                format!(
303                    "unsupported artifact extension: {}",
304                    self.manifest.artifact_extension
305                ),
306            ));
307        }
308        if self.lock.workspace_root != "bundle.yaml" {
309            return Err(BundleReadError::invalid(
310                self.source_kind,
311                &self.source_path,
312                format!("unexpected workspace_root: {}", self.lock.workspace_root),
313            ));
314        }
315        if self.lock.lock_file != "bundle.lock.json" {
316            return Err(BundleReadError::invalid(
317                self.source_kind,
318                &self.source_path,
319                format!("unexpected lock_file: {}", self.lock.lock_file),
320            ));
321        }
322        if self.lock.setup_state_files != self.manifest.generated_setup_files {
323            return Err(BundleReadError::invalid(
324                self.source_kind,
325                &self.source_path,
326                "bundle manifest and lock setup state files do not match".to_string(),
327            ));
328        }
329        Ok(())
330    }
331}
332
333#[derive(Debug, Clone, PartialEq, Eq)]
334pub struct BundleReadError {
335    pub kind: BundleReadErrorKind,
336    pub source_kind: BundleSourceKind,
337    pub source_path: String,
338    pub details: String,
339}
340
341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342pub enum BundleReadErrorKind {
343    Io,
344    Invalid,
345    Tool,
346}
347
348impl BundleReadError {
349    fn io(source_kind: BundleSourceKind, source_path: &Path, details: String) -> Self {
350        Self {
351            kind: BundleReadErrorKind::Io,
352            source_kind,
353            source_path: source_path.display().to_string(),
354            details,
355        }
356    }
357
358    fn invalid(source_kind: BundleSourceKind, source_path: &str, details: String) -> Self {
359        Self {
360            kind: BundleReadErrorKind::Invalid,
361            source_kind,
362            source_path: source_path.to_string(),
363            details,
364        }
365    }
366
367    fn tool(source_kind: BundleSourceKind, source_path: &Path, details: String) -> Self {
368        Self {
369            kind: BundleReadErrorKind::Tool,
370            source_kind,
371            source_path: source_path.display().to_string(),
372            details,
373        }
374    }
375}
376
377impl fmt::Display for BundleReadError {
378    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
379        write!(
380            f,
381            "{} read failed for {} ({}): {}",
382            self.source_kind.as_str(),
383            self.source_path,
384            match self.kind {
385                BundleReadErrorKind::Io => "io",
386                BundleReadErrorKind::Invalid => "invalid",
387                BundleReadErrorKind::Tool => "tool",
388            },
389            self.details
390        )
391    }
392}
393
394impl std::error::Error for BundleReadError {}
395
396pub fn open_artifact(path: &Path) -> Result<OpenedBundle, BundleReadError> {
397    let manifest_raw = read_artifact_file(path, "bundle-manifest.json")?;
398    let lock_raw = read_artifact_file(path, "bundle-lock.json")?;
399    let manifest = parse_manifest(BundleSourceKind::Artifact, path, &manifest_raw)?;
400    let lock = parse_lock(BundleSourceKind::Artifact, path, &lock_raw)?;
401    let opened = OpenedBundle::from_parts(
402        BundleSourceKind::Artifact,
403        path.display().to_string(),
404        manifest,
405        lock,
406    )?;
407    validate_artifact_contents(path, &opened)?;
408    Ok(opened)
409}
410
411pub fn open_build_dir(path: &Path) -> Result<OpenedBundle, BundleReadError> {
412    open_build_dir_with_source(path, path.display().to_string())
413}
414
415pub fn open_build_dir_with_source(
416    path: &Path,
417    source_path: impl Into<String>,
418) -> Result<OpenedBundle, BundleReadError> {
419    let manifest_raw = read_build_file(path, "bundle-manifest.json")?;
420    let lock_raw = read_build_file(path, "bundle-lock.json")?;
421    let manifest = parse_manifest(BundleSourceKind::BuildDir, path, &manifest_raw)?;
422    let lock = parse_lock(BundleSourceKind::BuildDir, path, &lock_raw)?;
423    let opened = OpenedBundle::from_parts(BundleSourceKind::BuildDir, source_path, manifest, lock)?;
424    validate_build_dir_contents(path, &opened)?;
425    Ok(opened)
426}
427
428fn read_build_file(root: &Path, name: &str) -> Result<String, BundleReadError> {
429    fs::read_to_string(root.join(name)).map_err(|error| {
430        BundleReadError::io(
431            BundleSourceKind::BuildDir,
432            root,
433            format!("read {}: {error}", root.join(name).display()),
434        )
435    })
436}
437
438fn read_artifact_file(path: &Path, inner_path: &str) -> Result<String, BundleReadError> {
439    let output = Command::new("unsquashfs")
440        .args(["-cat", path.to_str().unwrap_or_default(), inner_path])
441        .output()
442        .map_err(|error| {
443            BundleReadError::tool(
444                BundleSourceKind::Artifact,
445                path,
446                format!("spawn unsquashfs: {error}"),
447            )
448        })?;
449    if !output.status.success() {
450        return Err(BundleReadError::tool(
451            BundleSourceKind::Artifact,
452            path,
453            format!(
454                "unsquashfs failed for {}: {}",
455                inner_path,
456                String::from_utf8_lossy(&output.stderr).trim()
457            ),
458        ));
459    }
460    String::from_utf8(output.stdout).map_err(|error| {
461        BundleReadError::invalid(
462            BundleSourceKind::Artifact,
463            &path.display().to_string(),
464            format!("artifact entry {inner_path} is not valid utf-8: {error}"),
465        )
466    })
467}
468
469fn parse_manifest(
470    source_kind: BundleSourceKind,
471    source_path: &Path,
472    raw: &str,
473) -> Result<BundleManifest, BundleReadError> {
474    serde_json::from_str(raw).map_err(|error| {
475        BundleReadError::invalid(
476            source_kind,
477            &source_path.display().to_string(),
478            format!("parse bundle-manifest.json: {error}"),
479        )
480    })
481}
482
483fn parse_lock(
484    source_kind: BundleSourceKind,
485    source_path: &Path,
486    raw: &str,
487) -> Result<BundleLock, BundleReadError> {
488    serde_json::from_str(raw).map_err(|error| {
489        BundleReadError::invalid(
490            source_kind,
491            &source_path.display().to_string(),
492            format!("parse bundle-lock.json: {error}"),
493        )
494    })
495}
496
497fn validate_build_dir_contents(path: &Path, opened: &OpenedBundle) -> Result<(), BundleReadError> {
498    ensure_path_exists(
499        BundleSourceKind::BuildDir,
500        path,
501        &path.join("bundle.yaml"),
502        "bundle.yaml",
503    )?;
504    for rel_path in &opened.manifest.generated_resolved_files {
505        ensure_path_exists(
506            BundleSourceKind::BuildDir,
507            path,
508            &path.join(rel_path),
509            rel_path,
510        )?;
511    }
512    for rel_path in &opened.manifest.generated_setup_files {
513        ensure_path_exists(
514            BundleSourceKind::BuildDir,
515            path,
516            &path.join(rel_path),
517            rel_path,
518        )?;
519    }
520    Ok(())
521}
522
523fn validate_artifact_contents(path: &Path, opened: &OpenedBundle) -> Result<(), BundleReadError> {
524    read_artifact_file(path, "bundle.yaml")?;
525    for rel_path in &opened.manifest.generated_resolved_files {
526        read_artifact_file(path, rel_path)?;
527    }
528    for rel_path in &opened.manifest.generated_setup_files {
529        read_artifact_file(path, rel_path)?;
530    }
531    Ok(())
532}
533
534fn ensure_path_exists(
535    source_kind: BundleSourceKind,
536    source_path: &Path,
537    full_path: &Path,
538    display_path: &str,
539) -> Result<(), BundleReadError> {
540    if full_path.exists() {
541        return Ok(());
542    }
543    Err(BundleReadError::invalid(
544        source_kind,
545        &source_path.display().to_string(),
546        format!("missing required bundle file: {display_path}"),
547    ))
548}
549
550pub fn build_dir_from_artifact_source(root: &Path, bundle_id: &str) -> PathBuf {
551    root.join("state")
552        .join("build")
553        .join(bundle_id)
554        .join("normalized")
555}