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}