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}