Skip to main content

greentic_setup/
gtbundle.rs

1//! .gtbundle archive format support.
2//!
3//! A `.gtbundle` file is an archive containing a complete Greentic bundle.
4//! Supports both SquashFS (default) and ZIP formats.
5//!
6//! ## Format
7//!
8//! ```text
9//! my-bundle.gtbundle (SquashFS or ZIP archive)
10//! ├── greentic.demo.yaml or bundle.yaml
11//! ├── packs/
12//! ├── providers/
13//! ├── resolved/
14//! ├── state/
15//! └── tenants/
16//! ```
17
18use std::fs::{self, File};
19use std::io::{BufReader, Read, Write};
20use std::path::{Path, PathBuf};
21
22use anyhow::{Context, Result, bail};
23use zip::write::SimpleFileOptions;
24use zip::{ZipArchive, ZipWriter};
25
26/// Archive format for gtbundle files.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum BundleFormat {
29    /// SquashFS format (read-only compressed filesystem)
30    #[cfg(feature = "squashfs")]
31    SquashFs,
32    /// ZIP format (portable compressed archive)
33    Zip,
34}
35
36// Feature-conditional default: SquashFs when `squashfs` feature enabled, otherwise Zip.
37// Cannot use `#[derive(Default)]` with conditional `#[default]` attributes.
38#[allow(clippy::derivable_impls)]
39impl Default for BundleFormat {
40    fn default() -> Self {
41        #[cfg(feature = "squashfs")]
42        {
43            Self::SquashFs
44        }
45        #[cfg(not(feature = "squashfs"))]
46        {
47            Self::Zip
48        }
49    }
50}
51
52/// Detect the format of a gtbundle file by reading its magic bytes.
53pub fn detect_bundle_format(path: &Path) -> Result<BundleFormat> {
54    let mut file = File::open(path).context("failed to open bundle file")?;
55    let mut magic = [0u8; 4];
56    file.read_exact(&mut magic)
57        .context("failed to read magic bytes")?;
58
59    // SquashFS magic: "hsqs" (little-endian) or "sqsh" (big-endian)
60    if &magic == b"hsqs" || &magic == b"sqsh" {
61        #[cfg(feature = "squashfs")]
62        return Ok(BundleFormat::SquashFs);
63        #[cfg(not(feature = "squashfs"))]
64        bail!("squashfs format detected but squashfs feature is not enabled");
65    }
66
67    // ZIP magic: PK\x03\x04
68    if &magic == b"PK\x03\x04" {
69        return Ok(BundleFormat::Zip);
70    }
71
72    bail!("unknown archive format (magic: {:?})", magic);
73}
74
75/// Create a .gtbundle archive from a bundle directory using the default format.
76///
77/// # Arguments
78/// * `bundle_dir` - Source bundle directory
79/// * `output_path` - Destination .gtbundle file path
80///
81/// # Example
82/// ```ignore
83/// create_gtbundle(Path::new("./my-bundle"), Path::new("./dist/my-bundle.gtbundle"))?;
84/// ```
85pub fn create_gtbundle(bundle_dir: &Path, output_path: &Path) -> Result<()> {
86    create_gtbundle_with_format(bundle_dir, output_path, BundleFormat::default())
87}
88
89/// Create a .gtbundle archive with a specific format.
90pub fn create_gtbundle_with_format(
91    bundle_dir: &Path,
92    output_path: &Path,
93    format: BundleFormat,
94) -> Result<()> {
95    match format {
96        #[cfg(feature = "squashfs")]
97        BundleFormat::SquashFs => create_gtbundle_squashfs(bundle_dir, output_path),
98        BundleFormat::Zip => create_gtbundle_zip(bundle_dir, output_path),
99    }
100}
101
102/// Create a .gtbundle archive using SquashFS format.
103#[cfg(feature = "squashfs")]
104fn create_gtbundle_squashfs(bundle_dir: &Path, output_path: &Path) -> Result<()> {
105    use backhand::FilesystemWriter;
106
107    if !bundle_dir.is_dir() {
108        bail!("bundle directory not found: {}", bundle_dir.display());
109    }
110
111    // Ensure parent directory exists
112    if let Some(parent) = output_path.parent() {
113        fs::create_dir_all(parent).context("failed to create output directory")?;
114    }
115
116    let mut writer = FilesystemWriter::default();
117    // The root inode header inherits `NodeHeader::default()` (mode 0o000)
118    // unless we override it — same trap as the per-entry headers below.
119    writer.set_root_mode(0o755);
120
121    // Walk the bundle directory and add all files
122    add_directory_to_squashfs(&mut writer, bundle_dir, bundle_dir)?;
123
124    // Write the filesystem
125    let mut output = File::create(output_path)
126        .with_context(|| format!("failed to create archive: {}", output_path.display()))?;
127    writer
128        .write(&mut output)
129        .context("failed to write squashfs archive")?;
130
131    Ok(())
132}
133
134/// Add a directory and its contents to a SquashFS filesystem.
135#[cfg(feature = "squashfs")]
136fn add_directory_to_squashfs(
137    writer: &mut backhand::FilesystemWriter,
138    base_dir: &Path,
139    current_dir: &Path,
140) -> Result<()> {
141    use std::io::Cursor;
142
143    let entries = fs::read_dir(current_dir)
144        .with_context(|| format!("failed to read directory: {}", current_dir.display()))?;
145
146    for entry in entries {
147        let entry = entry?;
148        let path = entry.path();
149        let relative_path = path
150            .strip_prefix(base_dir)
151            .context("failed to compute relative path")?;
152        let name = relative_path.to_string_lossy().to_string();
153
154        if path.is_dir() {
155            writer
156                .push_dir(&name, dir_node_header())
157                .with_context(|| format!("failed to add directory: {}", name))?;
158            add_directory_to_squashfs(writer, base_dir, &path)?;
159        } else {
160            let content = fs::read(&path)
161                .with_context(|| format!("failed to read file: {}", path.display()))?;
162            let cursor = Cursor::new(content);
163            writer
164                .push_file(cursor, &name, file_node_header())
165                .with_context(|| format!("failed to add file: {}", name))?;
166        }
167    }
168
169    Ok(())
170}
171
172// `NodeHeader::default()` zero-fills permissions, which yields squashfs
173// archives whose extracted directories have mode `0o000` and cannot be
174// `read_dir()`'d by `gtc start`. Stamp world-readable defaults so any
175// consumer can extract and start the bundle without a manual chmod.
176#[cfg(feature = "squashfs")]
177fn dir_node_header() -> backhand::NodeHeader {
178    backhand::NodeHeader::new(0o755, 0, 0, 0)
179}
180
181#[cfg(feature = "squashfs")]
182fn file_node_header() -> backhand::NodeHeader {
183    backhand::NodeHeader::new(0o644, 0, 0, 0)
184}
185
186/// Create a .gtbundle archive using ZIP format.
187fn create_gtbundle_zip(bundle_dir: &Path, output_path: &Path) -> Result<()> {
188    if !bundle_dir.is_dir() {
189        bail!("bundle directory not found: {}", bundle_dir.display());
190    }
191
192    // Ensure parent directory exists
193    if let Some(parent) = output_path.parent() {
194        fs::create_dir_all(parent).context("failed to create output directory")?;
195    }
196
197    let file = File::create(output_path)
198        .with_context(|| format!("failed to create archive: {}", output_path.display()))?;
199    let mut zip = ZipWriter::new(file);
200
201    let options = SimpleFileOptions::default()
202        .compression_method(zip::CompressionMethod::Deflated)
203        .unix_permissions(0o644);
204
205    // Walk the bundle directory and add all files
206    add_directory_to_zip(&mut zip, bundle_dir, bundle_dir, options)?;
207
208    zip.finish().context("failed to finalize archive")?;
209
210    Ok(())
211}
212
213/// Extract a .gtbundle archive to a directory.
214///
215/// Auto-detects the archive format (SquashFS or ZIP) and extracts accordingly.
216///
217/// # Arguments
218/// * `gtbundle_path` - Source .gtbundle file
219/// * `output_dir` - Destination directory (will be created if needed)
220///
221/// # Example
222/// ```ignore
223/// extract_gtbundle(Path::new("./my-bundle.gtbundle"), Path::new("/tmp/my-bundle"))?;
224/// ```
225pub fn extract_gtbundle(gtbundle_path: &Path, output_dir: &Path) -> Result<()> {
226    if !gtbundle_path.is_file() {
227        bail!("gtbundle file not found: {}", gtbundle_path.display());
228    }
229
230    let format = detect_bundle_format(gtbundle_path)?;
231    match format {
232        #[cfg(feature = "squashfs")]
233        BundleFormat::SquashFs => extract_gtbundle_squashfs(gtbundle_path, output_dir),
234        BundleFormat::Zip => extract_gtbundle_zip(gtbundle_path, output_dir),
235    }
236}
237
238/// Extract a .gtbundle archive using SquashFS format.
239#[cfg(feature = "squashfs")]
240fn extract_gtbundle_squashfs(gtbundle_path: &Path, output_dir: &Path) -> Result<()> {
241    use backhand::FilesystemReader;
242
243    let file = BufReader::new(
244        File::open(gtbundle_path)
245            .with_context(|| format!("failed to open archive: {}", gtbundle_path.display()))?,
246    );
247    let reader = FilesystemReader::from_reader(file).context("failed to read squashfs archive")?;
248
249    fs::create_dir_all(output_dir).context("failed to create output directory")?;
250
251    // Extract all entries
252    for node in reader.files() {
253        let path_str = node.fullpath.to_string_lossy();
254
255        // Security: prevent path traversal
256        if path_str.contains("..") {
257            bail!("invalid path in archive: {}", path_str);
258        }
259
260        // Skip root directory
261        if path_str == "/" || path_str.is_empty() {
262            continue;
263        }
264
265        // Remove leading slash for joining
266        let relative_path = path_str.trim_start_matches('/');
267        let out_path = output_dir.join(relative_path);
268
269        match &node.inner {
270            backhand::InnerNode::Dir(_) => {
271                fs::create_dir_all(&out_path)?;
272            }
273            backhand::InnerNode::File(file_reader) => {
274                if let Some(parent) = out_path.parent() {
275                    fs::create_dir_all(parent)?;
276                }
277                let mut out_file = File::create(&out_path)
278                    .with_context(|| format!("failed to create: {}", out_path.display()))?;
279                let content = reader.file(file_reader);
280                let mut decompressed = Vec::new();
281                content
282                    .reader()
283                    .read_to_end(&mut decompressed)
284                    .context("failed to decompress file")?;
285                out_file
286                    .write_all(&decompressed)
287                    .context("failed to write file")?;
288            }
289            backhand::InnerNode::Symlink(link) => {
290                #[cfg(unix)]
291                {
292                    if let Some(parent) = out_path.parent() {
293                        fs::create_dir_all(parent)?;
294                    }
295                    let target = link.link.to_string_lossy();
296                    std::os::unix::fs::symlink(&*target, &out_path).with_context(|| {
297                        format!("failed to create symlink: {}", out_path.display())
298                    })?;
299                }
300                #[cfg(not(unix))]
301                {
302                    // Skip symlinks on non-Unix platforms
303                    let _ = link;
304                }
305            }
306            _ => {
307                // Skip other node types (devices, etc.)
308            }
309        }
310    }
311
312    Ok(())
313}
314
315/// Extract a .gtbundle archive using ZIP format.
316fn extract_gtbundle_zip(gtbundle_path: &Path, output_dir: &Path) -> Result<()> {
317    let file = File::open(gtbundle_path)
318        .with_context(|| format!("failed to open archive: {}", gtbundle_path.display()))?;
319    let mut archive = ZipArchive::new(file).context("failed to read archive")?;
320
321    fs::create_dir_all(output_dir).context("failed to create output directory")?;
322
323    for i in 0..archive.len() {
324        let mut file = archive
325            .by_index(i)
326            .context("failed to read archive entry")?;
327        let name = file.name().to_string();
328
329        // Security: prevent path traversal
330        if name.contains("..") {
331            bail!("invalid path in archive: {}", name);
332        }
333
334        let out_path = output_dir.join(&name);
335
336        if file.is_dir() {
337            fs::create_dir_all(&out_path)?;
338        } else {
339            if let Some(parent) = out_path.parent() {
340                fs::create_dir_all(parent)?;
341            }
342            let mut out_file = File::create(&out_path)
343                .with_context(|| format!("failed to create: {}", out_path.display()))?;
344            std::io::copy(&mut file, &mut out_file)?;
345
346            // Restore permissions on Unix
347            #[cfg(unix)]
348            {
349                use std::os::unix::fs::PermissionsExt;
350                if let Some(mode) = file.unix_mode() {
351                    fs::set_permissions(&out_path, fs::Permissions::from_mode(mode))?;
352                }
353            }
354        }
355    }
356
357    Ok(())
358}
359
360/// Extract a .gtbundle to a temporary directory and return the path.
361///
362/// The caller is responsible for cleaning up the temporary directory.
363pub fn extract_gtbundle_to_temp(gtbundle_path: &Path) -> Result<PathBuf> {
364    let temp_dir = std::env::temp_dir().join(format!(
365        "gtbundle-{}",
366        gtbundle_path
367            .file_stem()
368            .and_then(|s| s.to_str())
369            .unwrap_or("bundle")
370    ));
371
372    // Clean up existing temp directory
373    if temp_dir.exists() {
374        fs::remove_dir_all(&temp_dir).ok();
375    }
376
377    extract_gtbundle(gtbundle_path, &temp_dir)?;
378
379    Ok(temp_dir)
380}
381
382/// Check if a path is a .gtbundle archive file.
383pub fn is_gtbundle_file(path: &Path) -> bool {
384    path.is_file() && path.extension().is_some_and(|ext| ext == "gtbundle")
385}
386
387/// Check if a path is a .gtbundle directory (named *.gtbundle but is a dir).
388pub fn is_gtbundle_dir(path: &Path) -> bool {
389    path.is_dir() && path.extension().is_some_and(|ext| ext == "gtbundle")
390}
391
392// ── Internal helpers ─────────────────────────────────────────────────────────
393
394fn add_directory_to_zip<W: Write + std::io::Seek>(
395    zip: &mut ZipWriter<W>,
396    base_dir: &Path,
397    current_dir: &Path,
398    options: SimpleFileOptions,
399) -> Result<()> {
400    let entries = fs::read_dir(current_dir)
401        .with_context(|| format!("failed to read directory: {}", current_dir.display()))?;
402
403    for entry in entries {
404        let entry = entry?;
405        let path = entry.path();
406        let relative_path = path
407            .strip_prefix(base_dir)
408            .context("failed to compute relative path")?;
409        let name = relative_path.to_string_lossy();
410
411        if path.is_dir() {
412            // Add directory entry
413            zip.add_directory(format!("{}/", name), options)?;
414            // Recurse
415            add_directory_to_zip(zip, base_dir, &path, options)?;
416        } else {
417            // Add file
418            zip.start_file(name.to_string(), options)?;
419            let mut file = File::open(&path)?;
420            let mut buffer = Vec::new();
421            file.read_to_end(&mut buffer)?;
422            zip.write_all(&buffer)?;
423        }
424    }
425
426    Ok(())
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use crate::bundle::{BUNDLE_WORKSPACE_MARKER, LEGACY_BUNDLE_MARKER};
433    use std::fs;
434    use tempfile::tempdir;
435
436    fn create_test_bundle(bundle_dir: &Path) {
437        fs::create_dir_all(bundle_dir).unwrap();
438        fs::write(bundle_dir.join(LEGACY_BUNDLE_MARKER), "name: test").unwrap();
439        fs::create_dir_all(bundle_dir.join("packs")).unwrap();
440        fs::write(bundle_dir.join("packs/test.txt"), "hello").unwrap();
441    }
442
443    fn verify_extracted_bundle(extract_dir: &Path) {
444        assert!(extract_dir.join(LEGACY_BUNDLE_MARKER).exists());
445        assert!(extract_dir.join("packs/test.txt").exists());
446
447        let content = fs::read_to_string(extract_dir.join("packs/test.txt")).unwrap();
448        assert_eq!(content, "hello");
449    }
450
451    fn create_test_bundle_workspace(bundle_dir: &Path) {
452        fs::create_dir_all(bundle_dir).unwrap();
453        fs::write(
454            bundle_dir.join(BUNDLE_WORKSPACE_MARKER),
455            "schema_version: 1\n",
456        )
457        .unwrap();
458        fs::create_dir_all(bundle_dir.join("packs")).unwrap();
459        fs::write(bundle_dir.join("packs/test.txt"), "hello").unwrap();
460    }
461
462    #[test]
463    fn test_create_and_extract_gtbundle_zip() {
464        let temp = tempdir().unwrap();
465        let bundle_dir = temp.path().join("test-bundle");
466        let gtbundle_path = temp.path().join("test.gtbundle");
467        let extract_dir = temp.path().join("extracted");
468
469        create_test_bundle(&bundle_dir);
470
471        // Create ZIP archive
472        create_gtbundle_with_format(&bundle_dir, &gtbundle_path, BundleFormat::Zip).unwrap();
473        assert!(gtbundle_path.exists());
474
475        // Verify format detection
476        let format = detect_bundle_format(&gtbundle_path).unwrap();
477        assert_eq!(format, BundleFormat::Zip);
478
479        // Extract archive
480        extract_gtbundle(&gtbundle_path, &extract_dir).unwrap();
481        verify_extracted_bundle(&extract_dir);
482    }
483
484    #[cfg(feature = "squashfs")]
485    #[test]
486    fn test_create_and_extract_gtbundle_squashfs() {
487        let temp = tempdir().unwrap();
488        let bundle_dir = temp.path().join("test-bundle");
489        let gtbundle_path = temp.path().join("test.gtbundle");
490        let extract_dir = temp.path().join("extracted");
491
492        create_test_bundle(&bundle_dir);
493
494        // Create SquashFS archive
495        create_gtbundle_with_format(&bundle_dir, &gtbundle_path, BundleFormat::SquashFs).unwrap();
496        assert!(gtbundle_path.exists());
497
498        // Verify format detection
499        let format = detect_bundle_format(&gtbundle_path).unwrap();
500        assert_eq!(format, BundleFormat::SquashFs);
501
502        // Extract archive
503        extract_gtbundle(&gtbundle_path, &extract_dir).unwrap();
504        verify_extracted_bundle(&extract_dir);
505    }
506
507    #[test]
508    fn test_create_and_extract_gtbundle_default() {
509        let temp = tempdir().unwrap();
510        let bundle_dir = temp.path().join("test-bundle");
511        let gtbundle_path = temp.path().join("test.gtbundle");
512        let extract_dir = temp.path().join("extracted");
513
514        create_test_bundle(&bundle_dir);
515
516        // Create archive with default format
517        create_gtbundle(&bundle_dir, &gtbundle_path).unwrap();
518        assert!(gtbundle_path.exists());
519
520        // Extract archive
521        extract_gtbundle(&gtbundle_path, &extract_dir).unwrap();
522        verify_extracted_bundle(&extract_dir);
523    }
524
525    #[test]
526    fn test_create_and_extract_gtbundle_with_bundle_yaml_root() {
527        let temp = tempdir().unwrap();
528        let bundle_dir = temp.path().join("test-bundle");
529        let gtbundle_path = temp.path().join("test.gtbundle");
530        let extract_dir = temp.path().join("extracted");
531
532        create_test_bundle_workspace(&bundle_dir);
533
534        create_gtbundle(&bundle_dir, &gtbundle_path).unwrap();
535        extract_gtbundle(&gtbundle_path, &extract_dir).unwrap();
536
537        assert!(extract_dir.join(BUNDLE_WORKSPACE_MARKER).exists());
538        assert!(extract_dir.join("packs/test.txt").exists());
539    }
540
541    #[test]
542    fn test_is_gtbundle() {
543        let temp = tempdir().unwrap();
544
545        // Create a file
546        let file_path = temp.path().join("test.gtbundle");
547        fs::write(&file_path, "test").unwrap();
548        assert!(is_gtbundle_file(&file_path));
549        assert!(!is_gtbundle_dir(&file_path));
550
551        // Create a directory
552        let dir_path = temp.path().join("test2.gtbundle");
553        fs::create_dir(&dir_path).unwrap();
554        assert!(!is_gtbundle_file(&dir_path));
555        assert!(is_gtbundle_dir(&dir_path));
556    }
557
558    #[test]
559    fn test_detect_unknown_format() {
560        let temp = tempdir().unwrap();
561        let file_path = temp.path().join("unknown.gtbundle");
562        fs::write(&file_path, "UNKN").unwrap();
563
564        let result = detect_bundle_format(&file_path);
565        assert!(result.is_err());
566    }
567}