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
118    // Walk the bundle directory and add all files
119    add_directory_to_squashfs(&mut writer, bundle_dir, bundle_dir)?;
120
121    // Write the filesystem
122    let mut output = File::create(output_path)
123        .with_context(|| format!("failed to create archive: {}", output_path.display()))?;
124    writer
125        .write(&mut output)
126        .context("failed to write squashfs archive")?;
127
128    Ok(())
129}
130
131/// Add a directory and its contents to a SquashFS filesystem.
132#[cfg(feature = "squashfs")]
133fn add_directory_to_squashfs(
134    writer: &mut backhand::FilesystemWriter,
135    base_dir: &Path,
136    current_dir: &Path,
137) -> Result<()> {
138    use backhand::NodeHeader;
139    use std::io::Cursor;
140
141    let entries = fs::read_dir(current_dir)
142        .with_context(|| format!("failed to read directory: {}", current_dir.display()))?;
143
144    for entry in entries {
145        let entry = entry?;
146        let path = entry.path();
147        let relative_path = path
148            .strip_prefix(base_dir)
149            .context("failed to compute relative path")?;
150        let name = relative_path.to_string_lossy().to_string();
151
152        if path.is_dir() {
153            // Add directory
154            writer
155                .push_dir(&name, NodeHeader::default())
156                .with_context(|| format!("failed to add directory: {}", name))?;
157            // Recurse
158            add_directory_to_squashfs(writer, base_dir, &path)?;
159        } else {
160            // Add file
161            let content = fs::read(&path)
162                .with_context(|| format!("failed to read file: {}", path.display()))?;
163            let cursor = Cursor::new(content);
164            writer
165                .push_file(cursor, &name, NodeHeader::default())
166                .with_context(|| format!("failed to add file: {}", name))?;
167        }
168    }
169
170    Ok(())
171}
172
173/// Create a .gtbundle archive using ZIP format.
174fn create_gtbundle_zip(bundle_dir: &Path, output_path: &Path) -> Result<()> {
175    if !bundle_dir.is_dir() {
176        bail!("bundle directory not found: {}", bundle_dir.display());
177    }
178
179    // Ensure parent directory exists
180    if let Some(parent) = output_path.parent() {
181        fs::create_dir_all(parent).context("failed to create output directory")?;
182    }
183
184    let file = File::create(output_path)
185        .with_context(|| format!("failed to create archive: {}", output_path.display()))?;
186    let mut zip = ZipWriter::new(file);
187
188    let options = SimpleFileOptions::default()
189        .compression_method(zip::CompressionMethod::Deflated)
190        .unix_permissions(0o644);
191
192    // Walk the bundle directory and add all files
193    add_directory_to_zip(&mut zip, bundle_dir, bundle_dir, options)?;
194
195    zip.finish().context("failed to finalize archive")?;
196
197    Ok(())
198}
199
200/// Extract a .gtbundle archive to a directory.
201///
202/// Auto-detects the archive format (SquashFS or ZIP) and extracts accordingly.
203///
204/// # Arguments
205/// * `gtbundle_path` - Source .gtbundle file
206/// * `output_dir` - Destination directory (will be created if needed)
207///
208/// # Example
209/// ```ignore
210/// extract_gtbundle(Path::new("./my-bundle.gtbundle"), Path::new("/tmp/my-bundle"))?;
211/// ```
212pub fn extract_gtbundle(gtbundle_path: &Path, output_dir: &Path) -> Result<()> {
213    if !gtbundle_path.is_file() {
214        bail!("gtbundle file not found: {}", gtbundle_path.display());
215    }
216
217    let format = detect_bundle_format(gtbundle_path)?;
218    match format {
219        #[cfg(feature = "squashfs")]
220        BundleFormat::SquashFs => extract_gtbundle_squashfs(gtbundle_path, output_dir),
221        BundleFormat::Zip => extract_gtbundle_zip(gtbundle_path, output_dir),
222    }
223}
224
225/// Extract a .gtbundle archive using SquashFS format.
226#[cfg(feature = "squashfs")]
227fn extract_gtbundle_squashfs(gtbundle_path: &Path, output_dir: &Path) -> Result<()> {
228    use backhand::FilesystemReader;
229
230    let file = BufReader::new(
231        File::open(gtbundle_path)
232            .with_context(|| format!("failed to open archive: {}", gtbundle_path.display()))?,
233    );
234    let reader = FilesystemReader::from_reader(file).context("failed to read squashfs archive")?;
235
236    fs::create_dir_all(output_dir).context("failed to create output directory")?;
237
238    // Extract all entries
239    for node in reader.files() {
240        let path_str = node.fullpath.to_string_lossy();
241
242        // Security: prevent path traversal
243        if path_str.contains("..") {
244            bail!("invalid path in archive: {}", path_str);
245        }
246
247        // Skip root directory
248        if path_str == "/" || path_str.is_empty() {
249            continue;
250        }
251
252        // Remove leading slash for joining
253        let relative_path = path_str.trim_start_matches('/');
254        let out_path = output_dir.join(relative_path);
255
256        match &node.inner {
257            backhand::InnerNode::Dir(_) => {
258                fs::create_dir_all(&out_path)?;
259            }
260            backhand::InnerNode::File(file_reader) => {
261                if let Some(parent) = out_path.parent() {
262                    fs::create_dir_all(parent)?;
263                }
264                let mut out_file = File::create(&out_path)
265                    .with_context(|| format!("failed to create: {}", out_path.display()))?;
266                let content = reader.file(file_reader);
267                let mut decompressed = Vec::new();
268                content
269                    .reader()
270                    .read_to_end(&mut decompressed)
271                    .context("failed to decompress file")?;
272                out_file
273                    .write_all(&decompressed)
274                    .context("failed to write file")?;
275            }
276            backhand::InnerNode::Symlink(link) => {
277                #[cfg(unix)]
278                {
279                    if let Some(parent) = out_path.parent() {
280                        fs::create_dir_all(parent)?;
281                    }
282                    let target = link.link.to_string_lossy();
283                    std::os::unix::fs::symlink(&*target, &out_path).with_context(|| {
284                        format!("failed to create symlink: {}", out_path.display())
285                    })?;
286                }
287                #[cfg(not(unix))]
288                {
289                    // Skip symlinks on non-Unix platforms
290                    let _ = link;
291                }
292            }
293            _ => {
294                // Skip other node types (devices, etc.)
295            }
296        }
297    }
298
299    Ok(())
300}
301
302/// Extract a .gtbundle archive using ZIP format.
303fn extract_gtbundle_zip(gtbundle_path: &Path, output_dir: &Path) -> Result<()> {
304    let file = File::open(gtbundle_path)
305        .with_context(|| format!("failed to open archive: {}", gtbundle_path.display()))?;
306    let mut archive = ZipArchive::new(file).context("failed to read archive")?;
307
308    fs::create_dir_all(output_dir).context("failed to create output directory")?;
309
310    for i in 0..archive.len() {
311        let mut file = archive
312            .by_index(i)
313            .context("failed to read archive entry")?;
314        let name = file.name().to_string();
315
316        // Security: prevent path traversal
317        if name.contains("..") {
318            bail!("invalid path in archive: {}", name);
319        }
320
321        let out_path = output_dir.join(&name);
322
323        if file.is_dir() {
324            fs::create_dir_all(&out_path)?;
325        } else {
326            if let Some(parent) = out_path.parent() {
327                fs::create_dir_all(parent)?;
328            }
329            let mut out_file = File::create(&out_path)
330                .with_context(|| format!("failed to create: {}", out_path.display()))?;
331            std::io::copy(&mut file, &mut out_file)?;
332
333            // Restore permissions on Unix
334            #[cfg(unix)]
335            {
336                use std::os::unix::fs::PermissionsExt;
337                if let Some(mode) = file.unix_mode() {
338                    fs::set_permissions(&out_path, fs::Permissions::from_mode(mode))?;
339                }
340            }
341        }
342    }
343
344    Ok(())
345}
346
347/// Extract a .gtbundle to a temporary directory and return the path.
348///
349/// The caller is responsible for cleaning up the temporary directory.
350pub fn extract_gtbundle_to_temp(gtbundle_path: &Path) -> Result<PathBuf> {
351    let temp_dir = std::env::temp_dir().join(format!(
352        "gtbundle-{}",
353        gtbundle_path
354            .file_stem()
355            .and_then(|s| s.to_str())
356            .unwrap_or("bundle")
357    ));
358
359    // Clean up existing temp directory
360    if temp_dir.exists() {
361        fs::remove_dir_all(&temp_dir).ok();
362    }
363
364    extract_gtbundle(gtbundle_path, &temp_dir)?;
365
366    Ok(temp_dir)
367}
368
369/// Check if a path is a .gtbundle archive file.
370pub fn is_gtbundle_file(path: &Path) -> bool {
371    path.is_file() && path.extension().is_some_and(|ext| ext == "gtbundle")
372}
373
374/// Check if a path is a .gtbundle directory (named *.gtbundle but is a dir).
375pub fn is_gtbundle_dir(path: &Path) -> bool {
376    path.is_dir() && path.extension().is_some_and(|ext| ext == "gtbundle")
377}
378
379// ── Internal helpers ─────────────────────────────────────────────────────────
380
381fn add_directory_to_zip<W: Write + std::io::Seek>(
382    zip: &mut ZipWriter<W>,
383    base_dir: &Path,
384    current_dir: &Path,
385    options: SimpleFileOptions,
386) -> Result<()> {
387    let entries = fs::read_dir(current_dir)
388        .with_context(|| format!("failed to read directory: {}", current_dir.display()))?;
389
390    for entry in entries {
391        let entry = entry?;
392        let path = entry.path();
393        let relative_path = path
394            .strip_prefix(base_dir)
395            .context("failed to compute relative path")?;
396        let name = relative_path.to_string_lossy();
397
398        if path.is_dir() {
399            // Add directory entry
400            zip.add_directory(format!("{}/", name), options)?;
401            // Recurse
402            add_directory_to_zip(zip, base_dir, &path, options)?;
403        } else {
404            // Add file
405            zip.start_file(name.to_string(), options)?;
406            let mut file = File::open(&path)?;
407            let mut buffer = Vec::new();
408            file.read_to_end(&mut buffer)?;
409            zip.write_all(&buffer)?;
410        }
411    }
412
413    Ok(())
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use crate::bundle::{BUNDLE_WORKSPACE_MARKER, LEGACY_BUNDLE_MARKER};
420    use std::fs;
421    use tempfile::tempdir;
422
423    fn create_test_bundle(bundle_dir: &Path) {
424        fs::create_dir_all(bundle_dir).unwrap();
425        fs::write(bundle_dir.join(LEGACY_BUNDLE_MARKER), "name: test").unwrap();
426        fs::create_dir_all(bundle_dir.join("packs")).unwrap();
427        fs::write(bundle_dir.join("packs/test.txt"), "hello").unwrap();
428    }
429
430    fn verify_extracted_bundle(extract_dir: &Path) {
431        assert!(extract_dir.join(LEGACY_BUNDLE_MARKER).exists());
432        assert!(extract_dir.join("packs/test.txt").exists());
433
434        let content = fs::read_to_string(extract_dir.join("packs/test.txt")).unwrap();
435        assert_eq!(content, "hello");
436    }
437
438    fn create_test_bundle_workspace(bundle_dir: &Path) {
439        fs::create_dir_all(bundle_dir).unwrap();
440        fs::write(
441            bundle_dir.join(BUNDLE_WORKSPACE_MARKER),
442            "schema_version: 1\n",
443        )
444        .unwrap();
445        fs::create_dir_all(bundle_dir.join("packs")).unwrap();
446        fs::write(bundle_dir.join("packs/test.txt"), "hello").unwrap();
447    }
448
449    #[test]
450    fn test_create_and_extract_gtbundle_zip() {
451        let temp = tempdir().unwrap();
452        let bundle_dir = temp.path().join("test-bundle");
453        let gtbundle_path = temp.path().join("test.gtbundle");
454        let extract_dir = temp.path().join("extracted");
455
456        create_test_bundle(&bundle_dir);
457
458        // Create ZIP archive
459        create_gtbundle_with_format(&bundle_dir, &gtbundle_path, BundleFormat::Zip).unwrap();
460        assert!(gtbundle_path.exists());
461
462        // Verify format detection
463        let format = detect_bundle_format(&gtbundle_path).unwrap();
464        assert_eq!(format, BundleFormat::Zip);
465
466        // Extract archive
467        extract_gtbundle(&gtbundle_path, &extract_dir).unwrap();
468        verify_extracted_bundle(&extract_dir);
469    }
470
471    #[cfg(feature = "squashfs")]
472    #[test]
473    fn test_create_and_extract_gtbundle_squashfs() {
474        let temp = tempdir().unwrap();
475        let bundle_dir = temp.path().join("test-bundle");
476        let gtbundle_path = temp.path().join("test.gtbundle");
477        let extract_dir = temp.path().join("extracted");
478
479        create_test_bundle(&bundle_dir);
480
481        // Create SquashFS archive
482        create_gtbundle_with_format(&bundle_dir, &gtbundle_path, BundleFormat::SquashFs).unwrap();
483        assert!(gtbundle_path.exists());
484
485        // Verify format detection
486        let format = detect_bundle_format(&gtbundle_path).unwrap();
487        assert_eq!(format, BundleFormat::SquashFs);
488
489        // Extract archive
490        extract_gtbundle(&gtbundle_path, &extract_dir).unwrap();
491        verify_extracted_bundle(&extract_dir);
492    }
493
494    #[test]
495    fn test_create_and_extract_gtbundle_default() {
496        let temp = tempdir().unwrap();
497        let bundle_dir = temp.path().join("test-bundle");
498        let gtbundle_path = temp.path().join("test.gtbundle");
499        let extract_dir = temp.path().join("extracted");
500
501        create_test_bundle(&bundle_dir);
502
503        // Create archive with default format
504        create_gtbundle(&bundle_dir, &gtbundle_path).unwrap();
505        assert!(gtbundle_path.exists());
506
507        // Extract archive
508        extract_gtbundle(&gtbundle_path, &extract_dir).unwrap();
509        verify_extracted_bundle(&extract_dir);
510    }
511
512    #[test]
513    fn test_create_and_extract_gtbundle_with_bundle_yaml_root() {
514        let temp = tempdir().unwrap();
515        let bundle_dir = temp.path().join("test-bundle");
516        let gtbundle_path = temp.path().join("test.gtbundle");
517        let extract_dir = temp.path().join("extracted");
518
519        create_test_bundle_workspace(&bundle_dir);
520
521        create_gtbundle(&bundle_dir, &gtbundle_path).unwrap();
522        extract_gtbundle(&gtbundle_path, &extract_dir).unwrap();
523
524        assert!(extract_dir.join(BUNDLE_WORKSPACE_MARKER).exists());
525        assert!(extract_dir.join("packs/test.txt").exists());
526    }
527
528    #[test]
529    fn test_is_gtbundle() {
530        let temp = tempdir().unwrap();
531
532        // Create a file
533        let file_path = temp.path().join("test.gtbundle");
534        fs::write(&file_path, "test").unwrap();
535        assert!(is_gtbundle_file(&file_path));
536        assert!(!is_gtbundle_dir(&file_path));
537
538        // Create a directory
539        let dir_path = temp.path().join("test2.gtbundle");
540        fs::create_dir(&dir_path).unwrap();
541        assert!(!is_gtbundle_file(&dir_path));
542        assert!(is_gtbundle_dir(&dir_path));
543    }
544
545    #[test]
546    fn test_detect_unknown_format() {
547        let temp = tempdir().unwrap();
548        let file_path = temp.path().join("unknown.gtbundle");
549        fs::write(&file_path, "UNKN").unwrap();
550
551        let result = detect_bundle_format(&file_path);
552        assert!(result.is_err());
553    }
554}