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}