Skip to main content

greentic_bundle/bundle_fs/
backhand_writer.rs

1use std::collections::HashSet;
2use std::fs::File;
3use std::io::{BufReader, Read};
4use std::path::{Component, Path, PathBuf};
5
6use anyhow::{Context, Result, anyhow, bail};
7use backhand::{FilesystemReader, FilesystemWriter, InnerNode, NodeHeader};
8use walkdir::WalkDir;
9
10use super::{BundleEntry, BundleEntryKind, BundleFsReader, BundleFsWriter};
11
12const ROOT_PERMISSIONS: u16 = 0o755;
13const DIR_PERMISSIONS: u16 = 0o755;
14const FILE_PERMISSIONS: u16 = 0o644;
15const SYMLINK_PERMISSIONS: u16 = 0o777;
16const NORMALIZED_TIME: u32 = 0;
17
18pub struct BackhandBundleFsWriter;
19
20pub struct BackhandBundleFsReader;
21
22impl BundleFsWriter for BackhandBundleFsWriter {
23    fn write_bundle(&self, input_dir: &Path, output_file: &Path) -> Result<()> {
24        write_bundle_with_backhand(input_dir, output_file).with_context(|| {
25            format!(
26                "Failed to create .gtbundle using Rust-native SquashFS writer from {} to {}",
27                input_dir.display(),
28                output_file.display()
29            )
30        })
31    }
32}
33
34impl BundleFsReader for BackhandBundleFsReader {
35    fn list_bundle(&self, bundle_file: &Path) -> Result<Vec<BundleEntry>> {
36        let filesystem = open_backhand_filesystem(bundle_file)?;
37        let mut entries = Vec::new();
38        for node in filesystem.files() {
39            let Some(path) = normalized_node_path(&node.fullpath)? else {
40                continue;
41            };
42            let kind = match &node.inner {
43                InnerNode::File(_) => BundleEntryKind::File,
44                InnerNode::Dir(_) => BundleEntryKind::Directory,
45                InnerNode::Symlink(_) => BundleEntryKind::Symlink,
46                InnerNode::CharacterDevice(_)
47                | InnerNode::BlockDevice(_)
48                | InnerNode::NamedPipe
49                | InnerNode::Socket => BundleEntryKind::Other,
50            };
51            entries.push(BundleEntry { path, kind });
52        }
53        entries.sort_by(|left, right| left.path.cmp(&right.path));
54        Ok(entries)
55    }
56
57    fn extract_bundle(&self, bundle_file: &Path, output_dir: &Path) -> Result<()> {
58        let filesystem = open_backhand_filesystem(bundle_file)?;
59        std::fs::create_dir_all(output_dir)
60            .with_context(|| format!("create extraction directory {}", output_dir.display()))?;
61        // Phase 0 P0.4: prevent duplicate normalized inner paths from silently
62        // overwriting each other. An attacker could otherwise stage a benign
63        // entry first to pass any scanner and then overwrite it with a
64        // malicious payload before the consumer ever reads the file.
65        let mut seen_paths: HashSet<String> = HashSet::new();
66        for node in filesystem.files() {
67            let Some(path) = normalized_node_path(&node.fullpath)? else {
68                continue;
69            };
70            if !seen_paths.insert(path.clone()) {
71                bail!("duplicate bundle entry rejected: {path}");
72            }
73            let destination = safe_output_path(output_dir, &path)?;
74            match &node.inner {
75                InnerNode::Dir(_) => {
76                    safe_create_dir_all(output_dir, &destination)
77                        .with_context(|| format!("create directory {}", destination.display()))?;
78                }
79                InnerNode::File(file) => {
80                    if let Some(parent) = destination.parent() {
81                        safe_create_dir_all(output_dir, parent).with_context(|| {
82                            format!("create parent directory {}", parent.display())
83                        })?;
84                    }
85                    assert_no_existing_symlink(&destination)
86                        .with_context(|| format!("validate file destination {path}"))?;
87                    let mut source = filesystem.file(file).reader();
88                    let mut target = File::create(&destination)
89                        .with_context(|| format!("create file {}", destination.display()))?;
90                    std::io::copy(&mut source, &mut target)
91                        .with_context(|| format!("extract file {path}"))?;
92                }
93                InnerNode::Symlink(symlink) => {
94                    if let Some(parent) = destination.parent() {
95                        safe_create_dir_all(output_dir, parent).with_context(|| {
96                            format!("create parent directory {}", parent.display())
97                        })?;
98                    }
99                    assert_no_existing_symlink(&destination)
100                        .with_context(|| format!("validate symlink destination {path}"))?;
101                    // Phase 0 P0.4: validate the symlink's target string before
102                    // we materialize it. The writer accepts symlinks for
103                    // legitimate bundle authoring, so we cannot refuse them
104                    // outright on the reader — but we must refuse targets that
105                    // resolve outside the extract root. `link.link` is the raw
106                    // bytes the archive stored; we treat it as a Path and
107                    // reject absolute paths, drive-letter prefixes, and
108                    // relative paths whose `..` count outruns the depth of
109                    // the symlink's own parent.
110                    assert_symlink_target_within_root(&path, &symlink.link)
111                        .with_context(|| format!("validate symlink target for {path}"))?;
112                    create_symlink(&symlink.link, &destination)
113                        .with_context(|| format!("extract symlink {path}"))?;
114                }
115                InnerNode::CharacterDevice(_)
116                | InnerNode::BlockDevice(_)
117                | InnerNode::NamedPipe
118                | InnerNode::Socket => {
119                    bail!("unsupported SquashFS entry type while extracting {path}");
120                }
121            }
122        }
123        Ok(())
124    }
125}
126
127pub fn read_bundle_file_with_backhand(bundle_file: &Path, inner_path: &str) -> Result<Vec<u8>> {
128    let filesystem = open_backhand_filesystem(bundle_file)?;
129    let normalized_inner = normalize_inner_path(inner_path)?;
130    for node in filesystem.files() {
131        let Some(path) = normalized_node_path(&node.fullpath)? else {
132            continue;
133        };
134        if path != normalized_inner {
135            continue;
136        }
137        let InnerNode::File(file) = &node.inner else {
138            bail!("{inner_path} is not a file in {}", bundle_file.display());
139        };
140        let mut reader = filesystem.file(file).reader();
141        let mut bytes = Vec::new();
142        reader
143            .read_to_end(&mut bytes)
144            .with_context(|| format!("read {inner_path} from {}", bundle_file.display()))?;
145        return Ok(bytes);
146    }
147    bail!(
148        "bundle entry {inner_path} not found in {}",
149        bundle_file.display()
150    )
151}
152
153fn write_bundle_with_backhand(input_dir: &Path, output_file: &Path) -> Result<()> {
154    if !input_dir.is_dir() {
155        bail!(
156            "bundle input directory does not exist: {}",
157            input_dir.display()
158        );
159    }
160    super::assert_no_dev_secret_paths(input_dir)?;
161    if let Some(parent) = output_file.parent() {
162        std::fs::create_dir_all(parent)
163            .with_context(|| format!("create artifact parent {}", parent.display()))?;
164    }
165    if output_file.exists() {
166        std::fs::remove_file(output_file)
167            .with_context(|| format!("remove existing artifact {}", output_file.display()))?;
168    }
169
170    let mut writer = FilesystemWriter::default();
171    writer.set_time(NORMALIZED_TIME);
172    writer.set_root_uid(0);
173    writer.set_root_gid(0);
174    writer.set_root_mode(ROOT_PERMISSIONS);
175    writer.set_only_root_id();
176    writer.set_no_padding();
177    // mksquashfs wrote compressor options by default. We leave backhand's default
178    // XZ compressor/options intact and normalize everything else we control.
179    writer.set_emit_compression_options(true);
180
181    for entry in sorted_entries(input_dir)? {
182        let relative_path = normalized_relative_path(input_dir, &entry)?;
183        let metadata = std::fs::symlink_metadata(&entry)
184            .with_context(|| format!("read metadata for {}", entry.display()))?;
185        let file_type = metadata.file_type();
186
187        if file_type.is_dir() {
188            writer
189                .push_dir(&relative_path, header(DIR_PERMISSIONS))
190                .with_context(|| format!("add directory {relative_path} to SquashFS"))?;
191        } else if file_type.is_file() {
192            let file = File::open(&entry)
193                .with_context(|| format!("open staged file {}", entry.display()))?;
194            writer
195                .push_file(file, &relative_path, header(FILE_PERMISSIONS))
196                .with_context(|| format!("add file {relative_path} to SquashFS"))?;
197        } else if file_type.is_symlink() {
198            let target = std::fs::read_link(&entry)
199                .with_context(|| format!("read symlink target {}", entry.display()))?;
200            let target = normalized_link_target(&target)?;
201            writer
202                .push_symlink(target, &relative_path, header(SYMLINK_PERMISSIONS))
203                .with_context(|| format!("add symlink {relative_path} to SquashFS"))?;
204        } else {
205            bail!(
206                "unsupported staged bundle entry type at {}; only files, directories, and symlinks can be bundled",
207                entry.display()
208            );
209        }
210    }
211
212    let mut output = File::create(output_file)
213        .with_context(|| format!("create artifact {}", output_file.display()))?;
214    writer
215        .write(&mut output)
216        .with_context(|| format!("write SquashFS artifact {}", output_file.display()))?;
217    Ok(())
218}
219
220fn open_backhand_filesystem(bundle_file: &Path) -> Result<FilesystemReader<'static>> {
221    let file = File::open(bundle_file)
222        .with_context(|| format!("open bundle {}", bundle_file.display()))?;
223    FilesystemReader::from_reader(BufReader::new(file))
224        .with_context(|| format!("read SquashFS bundle {}", bundle_file.display()))
225}
226
227fn header(permissions: u16) -> NodeHeader {
228    NodeHeader {
229        permissions,
230        uid: 0,
231        gid: 0,
232        mtime: NORMALIZED_TIME,
233    }
234}
235
236fn sorted_entries(input_dir: &Path) -> Result<Vec<PathBuf>> {
237    let mut entries = Vec::new();
238    for entry in WalkDir::new(input_dir)
239        .min_depth(1)
240        .follow_links(false)
241        .sort_by_file_name()
242    {
243        let entry = entry.with_context(|| format!("walk staged bundle {}", input_dir.display()))?;
244        entries.push(entry.into_path());
245    }
246    entries.sort_by_key(|path| normalized_relative_path(input_dir, path).unwrap_or_default());
247    Ok(entries)
248}
249
250fn normalized_relative_path(input_dir: &Path, path: &Path) -> Result<String> {
251    let relative = path.strip_prefix(input_dir).with_context(|| {
252        format!(
253            "make {} relative to {}",
254            path.display(),
255            input_dir.display()
256        )
257    })?;
258    normalized_path(relative)
259}
260
261fn normalized_link_target(path: &Path) -> Result<String> {
262    normalized_path(path)
263}
264
265fn normalized_node_path(path: &Path) -> Result<Option<String>> {
266    if path == Path::new("/") {
267        return Ok(None);
268    }
269    let stripped = path.strip_prefix("/").unwrap_or(path);
270    Ok(Some(normalized_path(stripped)?))
271}
272
273fn normalize_inner_path(path: &str) -> Result<String> {
274    normalized_path(Path::new(path.trim_matches('/')))
275}
276
277fn normalized_path(path: &Path) -> Result<String> {
278    let mut parts = Vec::new();
279    for component in path.components() {
280        match component {
281            Component::Normal(part) => {
282                let part = part.to_str().ok_or_else(|| {
283                    anyhow!("bundle paths must be valid UTF-8: {}", path.display())
284                })?;
285                if part.is_empty() {
286                    bail!(
287                        "bundle path contains an empty component: {}",
288                        path.display()
289                    );
290                }
291                parts.push(part.to_string());
292            }
293            Component::CurDir => {}
294            Component::ParentDir => parts.push("..".to_string()),
295            Component::RootDir | Component::Prefix(_) => {
296                bail!("bundle paths must be relative: {}", path.display());
297            }
298        }
299    }
300    if parts.is_empty() {
301        bail!("bundle path cannot be empty");
302    }
303    Ok(parts.join("/"))
304}
305
306fn safe_output_path(output_dir: &Path, inner_path: &str) -> Result<PathBuf> {
307    let mut out = output_dir.to_path_buf();
308    for component in Path::new(inner_path).components() {
309        match component {
310            Component::Normal(part) => out.push(part),
311            Component::CurDir => {}
312            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
313                bail!("refusing to extract unsafe bundle path: {inner_path}");
314            }
315        }
316    }
317    Ok(out)
318}
319
320// Phase 0 P0.4: walk each ancestor of `target` from the extract root down,
321// verifying with no-follow `symlink_metadata` that no existing component is a
322// symlink before descending. This is the categorization fix called out in
323// the plan: the previous `std::fs::create_dir_all` follows symlinks, so an
324// archive could plant `etc-link -> /etc` and then write through it on the
325// next iteration. Creates only missing directories under `target`; preserves
326// any pre-existing real directory.
327fn safe_create_dir_all(extract_root: &Path, target: &Path) -> Result<()> {
328    if !target.starts_with(extract_root) {
329        bail!(
330            "refusing to descend outside extract root: {} not under {}",
331            target.display(),
332            extract_root.display()
333        );
334    }
335    let relative = target.strip_prefix(extract_root).map_err(|err| {
336        anyhow!(
337            "make {} relative to extract root {}: {err}",
338            target.display(),
339            extract_root.display()
340        )
341    })?;
342    let mut current = extract_root.to_path_buf();
343    for component in relative.components() {
344        let part = match component {
345            Component::Normal(part) => part,
346            Component::CurDir => continue,
347            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
348                bail!(
349                    "refusing to traverse unsafe component during mkdir: {}",
350                    target.display()
351                );
352            }
353        };
354        current.push(part);
355        match std::fs::symlink_metadata(&current) {
356            Ok(meta) => {
357                if meta.file_type().is_symlink() {
358                    bail!(
359                        "refusing to descend through symlink at {}",
360                        current.display()
361                    );
362                }
363                if !meta.file_type().is_dir() {
364                    bail!(
365                        "refusing to descend through non-directory at {}",
366                        current.display()
367                    );
368                }
369            }
370            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
371                std::fs::create_dir(&current)
372                    .with_context(|| format!("create directory {}", current.display()))?;
373            }
374            Err(err) => {
375                return Err(err)
376                    .with_context(|| format!("stat {} during safe mkdir", current.display()));
377            }
378        }
379    }
380    Ok(())
381}
382
383// Phase 0 P0.4: the final write into `destination` would also follow a
384// symlink at `destination` itself (not just its ancestors), so refuse if the
385// destination already exists as a symlink. Missing path is fine.
386fn assert_no_existing_symlink(destination: &Path) -> Result<()> {
387    match std::fs::symlink_metadata(destination) {
388        Ok(meta) if meta.file_type().is_symlink() => {
389            bail!(
390                "refusing to write through existing symlink at {}",
391                destination.display()
392            );
393        }
394        Ok(_) | Err(_) => Ok(()),
395    }
396}
397
398// Phase 0 P0.4: refuse symlink targets that escape the extract root. The
399// link target is interpreted relative to the symlink's *parent directory*
400// inside the archive — that's how the kernel will resolve it once extracted.
401// Reject absolute paths and Windows-style prefixes outright. For relative
402// targets, fold `..` against the symlink's depth: if the accumulated depth
403// ever goes negative, the symlink can escape.
404//
405// Example: a symlink at `packs/inner/link` with target `../../../etc/passwd`
406// starts at depth 2 (`packs`, `inner`), consumes 3 `..` -> depth -1 -> bail.
407fn assert_symlink_target_within_root(symlink_inner_path: &str, target: &Path) -> Result<()> {
408    let parent_depth = Path::new(symlink_inner_path)
409        .parent()
410        .map(|parent| {
411            parent
412                .components()
413                .filter(|component| matches!(component, Component::Normal(_)))
414                .count()
415        })
416        .unwrap_or(0);
417    let mut depth: i64 = parent_depth as i64;
418    for component in target.components() {
419        match component {
420            Component::Normal(_) => depth += 1,
421            Component::CurDir => {}
422            Component::ParentDir => {
423                depth -= 1;
424                if depth < 0 {
425                    bail!(
426                        "refusing symlink target {} from {}: escapes extract root",
427                        target.display(),
428                        symlink_inner_path
429                    );
430                }
431            }
432            Component::RootDir | Component::Prefix(_) => {
433                bail!(
434                    "refusing absolute symlink target {} from {}",
435                    target.display(),
436                    symlink_inner_path
437                );
438            }
439        }
440    }
441    Ok(())
442}
443
444#[cfg(unix)]
445fn create_symlink(target: &Path, destination: &Path) -> std::io::Result<()> {
446    std::os::unix::fs::symlink(target, destination)
447}
448
449#[cfg(windows)]
450fn create_symlink(target: &Path, destination: &Path) -> std::io::Result<()> {
451    std::os::windows::fs::symlink_file(target, destination)
452}
453
454#[cfg(test)]
455mod tests {
456    use super::{
457        assert_no_existing_symlink, assert_symlink_target_within_root, normalized_path,
458        safe_create_dir_all,
459    };
460    use std::path::Path;
461    use tempfile::TempDir;
462
463    #[test]
464    fn normalizes_paths_with_forward_slashes() {
465        assert_eq!(
466            normalized_path(Path::new("assets/example.txt")).unwrap(),
467            "assets/example.txt"
468        );
469    }
470
471    // Phase 0 P0.4 — safe_create_dir_all.
472
473    #[test]
474    fn safe_create_dir_all_creates_missing_dirs() {
475        let temp = TempDir::new().expect("tempdir");
476        let root = temp.path();
477        let target = root.join("a/b/c");
478        safe_create_dir_all(root, &target).expect("mkdir");
479        assert!(target.is_dir());
480    }
481
482    #[test]
483    fn safe_create_dir_all_accepts_existing_real_dirs() {
484        let temp = TempDir::new().expect("tempdir");
485        let root = temp.path();
486        std::fs::create_dir_all(root.join("a/b")).expect("seed");
487        safe_create_dir_all(root, &root.join("a/b/c")).expect("mkdir");
488        assert!(root.join("a/b/c").is_dir());
489    }
490
491    #[cfg(unix)]
492    #[test]
493    fn safe_create_dir_all_rejects_traversal_through_symlink() {
494        let temp = TempDir::new().expect("tempdir");
495        let root = temp.path();
496        let outside = temp.path().join("outside");
497        std::fs::create_dir(&outside).expect("outside");
498        // Plant a symlink at <root>/escape -> <outside>. A naive
499        // create_dir_all(<root>/escape/inner) would resolve through the link.
500        std::os::unix::fs::symlink(&outside, root.join("escape")).expect("symlink");
501        let err = safe_create_dir_all(root, &root.join("escape/inner"))
502            .expect_err("must reject symlink ancestor");
503        assert!(
504            format!("{err:#}").contains("descend through symlink"),
505            "unexpected error: {err:#}"
506        );
507        // The outside dir must remain empty.
508        assert!(!outside.join("inner").exists());
509    }
510
511    #[test]
512    fn safe_create_dir_all_rejects_target_outside_root() {
513        let temp = TempDir::new().expect("tempdir");
514        let root = temp.path().join("root");
515        std::fs::create_dir(&root).expect("root");
516        let outside = temp.path().join("outside");
517        let err =
518            safe_create_dir_all(&root, &outside).expect_err("must reject target outside root");
519        assert!(format!("{err:#}").contains("outside extract root"));
520    }
521
522    // Phase 0 P0.4 — assert_no_existing_symlink.
523
524    #[cfg(unix)]
525    #[test]
526    fn assert_no_existing_symlink_rejects_symlink_at_destination() {
527        let temp = TempDir::new().expect("tempdir");
528        let root = temp.path();
529        std::os::unix::fs::symlink("/tmp/nope", root.join("link")).expect("symlink");
530        let err = assert_no_existing_symlink(&root.join("link"))
531            .expect_err("must reject existing symlink");
532        assert!(format!("{err:#}").contains("write through existing symlink"));
533    }
534
535    #[test]
536    fn assert_no_existing_symlink_accepts_missing_destination() {
537        let temp = TempDir::new().expect("tempdir");
538        assert_no_existing_symlink(&temp.path().join("missing")).expect("missing is fine");
539    }
540
541    #[test]
542    fn assert_no_existing_symlink_accepts_existing_file() {
543        let temp = TempDir::new().expect("tempdir");
544        let path = temp.path().join("real.txt");
545        std::fs::write(&path, "x").expect("write");
546        assert_no_existing_symlink(&path).expect("real file is fine");
547    }
548
549    // Phase 0 P0.4 — assert_symlink_target_within_root.
550
551    #[test]
552    fn symlink_target_within_root_accepts_sibling() {
553        assert_symlink_target_within_root("packs/a/link", Path::new("../b/file"))
554            .expect("sibling resolves under root");
555    }
556
557    #[test]
558    fn symlink_target_within_root_rejects_absolute_target() {
559        let err = assert_symlink_target_within_root("packs/link", Path::new("/etc/passwd"))
560            .expect_err("must reject absolute");
561        assert!(format!("{err:#}").contains("absolute symlink target"));
562    }
563
564    #[test]
565    fn symlink_target_within_root_rejects_escaping_target() {
566        // Symlink at depth 1 (parent = "packs"). `../../etc` consumes 2 `..`
567        // → depth -1 → escape.
568        let err = assert_symlink_target_within_root("packs/link", Path::new("../../etc"))
569            .expect_err("must reject escape");
570        assert!(format!("{err:#}").contains("escapes extract root"));
571    }
572
573    #[test]
574    fn symlink_target_within_root_accepts_walk_back_to_root() {
575        // Symlink at depth 2; walking back to depth 0 stays at root and is
576        // therefore fine — anything inside the root after `../..` is allowed.
577        assert_symlink_target_within_root("packs/inner/link", Path::new("../../allowed/file"))
578            .expect("walk back to root is within bounds");
579    }
580}