Skip to main content

greentic_bundle/bundle_fs/
mod.rs

1mod backhand_writer;
2mod native_mksquashfs_writer;
3mod native_unsquashfs_reader;
4
5use std::path::{Component, Path};
6
7use anyhow::{Context, Result, bail};
8use walkdir::WalkDir;
9
10pub use backhand_writer::{BackhandBundleFsReader, BackhandBundleFsWriter};
11pub use native_mksquashfs_writer::MksquashfsBundleFsWriter;
12pub use native_unsquashfs_reader::UnsquashfsBundleFsReader;
13
14pub const WRITER_ENV: &str = "GREENTIC_BUNDLE_SQUASHFS_WRITER";
15pub const READER_ENV: &str = "GREENTIC_BUNDLE_SQUASHFS_READER";
16
17pub trait BundleFsWriter {
18    fn write_bundle(&self, input_dir: &Path, output_file: &Path) -> Result<()>;
19}
20
21pub trait BundleFsReader {
22    fn list_bundle(&self, bundle_file: &Path) -> Result<Vec<BundleEntry>>;
23    fn extract_bundle(&self, bundle_file: &Path, output_dir: &Path) -> Result<()>;
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct BundleEntry {
28    pub path: String,
29    pub kind: BundleEntryKind,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum BundleEntryKind {
34    File,
35    Directory,
36    Symlink,
37    Other,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum BundleFsWriterKind {
42    Backhand,
43    Mksquashfs,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum BundleFsReaderKind {
48    Backhand,
49    Unsquashfs,
50}
51
52impl BundleFsWriterKind {
53    pub fn from_env_value(value: Option<&str>) -> Result<Self> {
54        match value.map(str::trim).filter(|value| !value.is_empty()) {
55            None => Ok(Self::Backhand),
56            Some("backhand") => Ok(Self::Backhand),
57            Some("mksquashfs") => Ok(Self::Mksquashfs),
58            Some(value) => bail!(
59                "{WRITER_ENV}={value} is not supported. Accepted values: backhand, mksquashfs"
60            ),
61        }
62    }
63}
64
65impl BundleFsReaderKind {
66    pub fn from_env_value(value: Option<&str>) -> Result<Self> {
67        match value.map(str::trim).filter(|value| !value.is_empty()) {
68            None => Ok(Self::Backhand),
69            Some("backhand") => Ok(Self::Backhand),
70            Some("unsquashfs") => Ok(Self::Unsquashfs),
71            Some(value) => bail!(
72                "{READER_ENV}={value} is not supported. Accepted values: backhand, unsquashfs"
73            ),
74        }
75    }
76}
77
78pub fn selected_writer_kind() -> Result<BundleFsWriterKind> {
79    BundleFsWriterKind::from_env_value(std::env::var(WRITER_ENV).ok().as_deref())
80}
81
82pub fn selected_reader_kind() -> Result<BundleFsReaderKind> {
83    BundleFsReaderKind::from_env_value(std::env::var(READER_ENV).ok().as_deref())
84}
85
86pub fn write_bundle(input_dir: &Path, output_file: &Path) -> Result<()> {
87    match selected_writer_kind()? {
88        BundleFsWriterKind::Backhand => BackhandBundleFsWriter.write_bundle(input_dir, output_file),
89        BundleFsWriterKind::Mksquashfs => {
90            MksquashfsBundleFsWriter.write_bundle(input_dir, output_file)
91        }
92    }
93}
94
95// Phase 0 secret-leak hotfix: defense in depth at the archive boundary.
96// build_dir is supposed to be controlled by `write_normalized_build_dir`, but
97// any drift that lands a dev-store file or its directory tree inside build_dir
98// must not silently ship inside the .gtbundle. We bail loud so the upstream
99// pipeline gets fixed instead of leaking. See plans/next-gen-deployment.md P0.1.
100pub(crate) fn assert_no_dev_secret_paths(input_dir: &Path) -> Result<()> {
101    for entry in WalkDir::new(input_dir).min_depth(1).follow_links(false) {
102        let entry = entry.with_context(|| {
103            format!(
104                "walk staged bundle for dev-secret denylist: {}",
105                input_dir.display()
106            )
107        })?;
108        let relative = entry.path().strip_prefix(input_dir).unwrap_or(entry.path());
109        if let Some(reason) = dev_secret_match(relative) {
110            bail!(
111                "refusing to archive dev-secret path {} ({reason}); fix the bundle pipeline rather than shipping it",
112                relative.display()
113            );
114        }
115        // Phase 0 P0.1 symlink-bypass guard. WalkDir with follow_links(false)
116        // visits symlinks as single entries without recursing into them, so
117        // a symlink whose own relative path is benign (e.g. `packs/seed.env`)
118        // but whose target points into a dev-store path would otherwise pass
119        // through. The backhand writer preserves symlinks as symlinks
120        // (push_symlink ships the target STRING into the archive), so even
121        // without dereferencing, an attacker can plant a known-bad target
122        // path inside the .gtbundle. Bail if the target itself matches the
123        // dev-secret pattern.
124        if entry.file_type().is_symlink() {
125            let target = std::fs::read_link(entry.path()).with_context(|| {
126                format!(
127                    "read symlink target for dev-secret denylist: {}",
128                    relative.display()
129                )
130            })?;
131            if let Some(reason) = dev_secret_match(&target) {
132                bail!(
133                    "refusing to archive symlink {} whose target {} matches dev-secret pattern ({reason})",
134                    relative.display(),
135                    target.display()
136                );
137            }
138        }
139    }
140    Ok(())
141}
142
143pub(crate) fn dev_secret_match(relative: &Path) -> Option<&'static str> {
144    let parts: Vec<&str> = relative
145        .components()
146        .filter_map(|component| match component {
147            Component::Normal(part) => part.to_str(),
148            _ => None,
149        })
150        .collect();
151    for window in parts.windows(2) {
152        if window[0] == ".greentic" && window[1] == "dev" {
153            return Some(".greentic/dev/ tree");
154        }
155    }
156    for window in parts.windows(3) {
157        if window[0] == ".greentic" && window[1] == "state" && window[2] == "dev" {
158            return Some(".greentic/state/dev/ tree");
159        }
160    }
161    if parts.last().copied() == Some(".dev.secrets.env") {
162        return Some(".dev.secrets.env file");
163    }
164    None
165}
166
167pub fn list_bundle(bundle_file: &Path) -> Result<Vec<BundleEntry>> {
168    match selected_reader_kind()? {
169        BundleFsReaderKind::Backhand => BackhandBundleFsReader.list_bundle(bundle_file),
170        BundleFsReaderKind::Unsquashfs => UnsquashfsBundleFsReader.list_bundle(bundle_file),
171    }
172}
173
174pub fn extract_bundle(bundle_file: &Path, output_dir: &Path) -> Result<()> {
175    match selected_reader_kind()? {
176        BundleFsReaderKind::Backhand => {
177            BackhandBundleFsReader.extract_bundle(bundle_file, output_dir)
178        }
179        BundleFsReaderKind::Unsquashfs => {
180            UnsquashfsBundleFsReader.extract_bundle(bundle_file, output_dir)
181        }
182    }
183}
184
185pub fn read_bundle_file(bundle_file: &Path, inner_path: &str) -> Result<Vec<u8>> {
186    match selected_reader_kind()? {
187        BundleFsReaderKind::Backhand => {
188            backhand_writer::read_bundle_file_with_backhand(bundle_file, inner_path)
189        }
190        BundleFsReaderKind::Unsquashfs => {
191            native_unsquashfs_reader::read_bundle_file_with_unsquashfs(bundle_file, inner_path)
192        }
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use std::path::Path;
199
200    use tempfile::TempDir;
201
202    use super::{
203        BundleFsReaderKind, BundleFsWriterKind, READER_ENV, WRITER_ENV, assert_no_dev_secret_paths,
204        dev_secret_match,
205    };
206
207    #[test]
208    fn dev_secret_match_detects_dev_directory() {
209        assert_eq!(
210            dev_secret_match(Path::new(".greentic/dev/whatever.bin")),
211            Some(".greentic/dev/ tree")
212        );
213    }
214
215    #[test]
216    fn dev_secret_match_detects_state_dev_directory() {
217        assert_eq!(
218            dev_secret_match(Path::new(".greentic/state/dev/anything")),
219            Some(".greentic/state/dev/ tree")
220        );
221    }
222
223    #[test]
224    fn dev_secret_match_detects_dev_secrets_env_file() {
225        assert_eq!(
226            dev_secret_match(Path::new("nested/path/.dev.secrets.env")),
227            Some(".dev.secrets.env file")
228        );
229    }
230
231    #[test]
232    fn dev_secret_match_passes_through_safe_paths() {
233        assert_eq!(dev_secret_match(Path::new("packs/pack-a.gtpack")), None);
234        assert_eq!(
235            dev_secret_match(Path::new("state/setup/provider-a.json")),
236            None
237        );
238    }
239
240    #[test]
241    fn assert_denylist_bails_on_dev_store_file() {
242        let temp = TempDir::new().expect("tempdir");
243        let root = temp.path();
244        let dev_dir = root.join(".greentic/dev");
245        std::fs::create_dir_all(&dev_dir).expect("dev dir");
246        std::fs::write(dev_dir.join(".dev.secrets.env"), "GTC_SECRET=leaked").expect("seed");
247        let err = assert_no_dev_secret_paths(root).expect_err("must bail");
248        let msg = format!("{err:#}");
249        assert!(msg.contains("refusing to archive"));
250        assert!(msg.contains(".greentic/dev"));
251    }
252
253    #[test]
254    fn assert_denylist_bails_on_stray_dev_secrets_env() {
255        let temp = TempDir::new().expect("tempdir");
256        let root = temp.path();
257        std::fs::create_dir_all(root.join("packs")).expect("dir");
258        std::fs::write(root.join("packs/.dev.secrets.env"), "TOKEN=leaked").expect("seed");
259        let err = assert_no_dev_secret_paths(root).expect_err("must bail");
260        let msg = format!("{err:#}");
261        assert!(msg.contains(".dev.secrets.env"));
262    }
263
264    #[test]
265    fn assert_denylist_passes_on_clean_tree() {
266        let temp = TempDir::new().expect("tempdir");
267        let root = temp.path();
268        std::fs::create_dir_all(root.join("state/setup")).expect("dir");
269        std::fs::write(root.join("state/setup/provider-a.json"), "{}").expect("seed");
270        assert_no_dev_secret_paths(root).expect("clean tree passes");
271    }
272
273    #[test]
274    fn writer_selection_defaults_to_backhand() {
275        assert_eq!(
276            BundleFsWriterKind::from_env_value(None).expect("writer kind"),
277            BundleFsWriterKind::Backhand
278        );
279    }
280
281    #[test]
282    fn writer_selection_accepts_backhand() {
283        assert_eq!(
284            BundleFsWriterKind::from_env_value(Some("backhand")).expect("writer kind"),
285            BundleFsWriterKind::Backhand
286        );
287    }
288
289    #[test]
290    fn writer_selection_accepts_mksquashfs() {
291        assert_eq!(
292            BundleFsWriterKind::from_env_value(Some("mksquashfs")).expect("writer kind"),
293            BundleFsWriterKind::Mksquashfs
294        );
295    }
296
297    #[test]
298    fn writer_selection_rejects_unknown_values() {
299        let error = BundleFsWriterKind::from_env_value(Some("external")).expect_err("error");
300        let message = error.to_string();
301        assert!(message.contains(WRITER_ENV));
302        assert!(message.contains("backhand, mksquashfs"));
303    }
304
305    #[test]
306    fn reader_selection_defaults_to_backhand() {
307        assert_eq!(
308            BundleFsReaderKind::from_env_value(None).expect("reader kind"),
309            BundleFsReaderKind::Backhand
310        );
311    }
312
313    #[test]
314    fn reader_selection_accepts_backhand() {
315        assert_eq!(
316            BundleFsReaderKind::from_env_value(Some("backhand")).expect("reader kind"),
317            BundleFsReaderKind::Backhand
318        );
319    }
320
321    #[test]
322    fn reader_selection_accepts_unsquashfs() {
323        assert_eq!(
324            BundleFsReaderKind::from_env_value(Some("unsquashfs")).expect("reader kind"),
325            BundleFsReaderKind::Unsquashfs
326        );
327    }
328
329    #[test]
330    fn reader_selection_rejects_unknown_values() {
331        let error = BundleFsReaderKind::from_env_value(Some("external")).expect_err("error");
332        let message = error.to_string();
333        assert!(message.contains(READER_ENV));
334        assert!(message.contains("backhand, unsquashfs"));
335    }
336
337    // Phase 0 P0.1 symlink-bypass regression tests. The backhand writer
338    // preserves symlinks intentionally, so we cannot reject them wholesale —
339    // instead we inspect the symlink TARGET against the dev-secret pattern.
340    //
341    // dev_secret_match works on Path::components() with non-Normal components
342    // filtered out, so it correctly handles `../`, absolute, and relative
343    // targets uniformly.
344
345    #[cfg(unix)]
346    #[test]
347    fn assert_denylist_bails_on_file_symlink_targeting_dev_store() {
348        let temp = TempDir::new().expect("tempdir");
349        let root = temp.path();
350        std::fs::create_dir_all(root.join("packs")).expect("packs dir");
351        let benign_name = root.join("packs/seed.env");
352        // The target itself does not exist; that's fine — read_link only
353        // returns the link string, not target metadata.
354        std::os::unix::fs::symlink("../.greentic/dev/.dev.secrets.env", &benign_name)
355            .expect("create symlink");
356        let err = assert_no_dev_secret_paths(root).expect_err("must bail on dev-targeted symlink");
357        let msg = format!("{err:#}");
358        assert!(
359            msg.contains("refusing to archive symlink"),
360            "expected symlink refusal; got: {msg}"
361        );
362        assert!(msg.contains(".greentic/dev"));
363    }
364
365    #[cfg(unix)]
366    #[test]
367    fn assert_denylist_bails_on_directory_symlink_targeting_dev_tree() {
368        let temp = TempDir::new().expect("tempdir");
369        let root = temp.path();
370        std::fs::create_dir_all(root.join("packs")).expect("dir");
371        std::os::unix::fs::symlink("/tmp/host/.greentic/state/dev", root.join("packs/seed-dir"))
372            .expect("create dir symlink");
373        let err = assert_no_dev_secret_paths(root).expect_err("must bail");
374        assert!(format!("{err:#}").contains(".greentic/state/dev"));
375    }
376
377    #[cfg(unix)]
378    #[test]
379    fn assert_denylist_bails_on_symlink_targeting_stray_dev_secrets_env() {
380        let temp = TempDir::new().expect("tempdir");
381        let root = temp.path();
382        std::fs::create_dir_all(root.join("packs")).expect("dir");
383        std::os::unix::fs::symlink(
384            "/elsewhere/.dev.secrets.env",
385            root.join("packs/innocent.txt"),
386        )
387        .expect("create symlink");
388        let err = assert_no_dev_secret_paths(root).expect_err("must bail");
389        assert!(format!("{err:#}").contains(".dev.secrets.env"));
390    }
391
392    #[cfg(unix)]
393    #[test]
394    fn assert_denylist_allows_benign_symlink_target() {
395        // A symlink whose target is unrelated to dev-store paths is allowed,
396        // because the backhand writer's push_symlink legitimately supports
397        // symlinks. The string-leak class is bounded to dev-store target
398        // patterns; everything else is the bundle author's choice.
399        let temp = TempDir::new().expect("tempdir");
400        let root = temp.path();
401        std::fs::create_dir_all(root.join("packs")).expect("dir");
402        std::os::unix::fs::symlink("../resolved/default.yaml", root.join("packs/link"))
403            .expect("create benign symlink");
404        assert_no_dev_secret_paths(root).expect("benign symlink must pass");
405    }
406}