Skip to main content

greentic_bundle/bundle_fs/
backhand_writer.rs

1use std::fs::File;
2use std::io::{BufReader, Read};
3use std::path::{Component, Path, PathBuf};
4
5use anyhow::{Context, Result, anyhow, bail};
6use backhand::{FilesystemReader, FilesystemWriter, InnerNode, NodeHeader};
7use walkdir::WalkDir;
8
9use super::{BundleEntry, BundleEntryKind, BundleFsReader, BundleFsWriter};
10
11const ROOT_PERMISSIONS: u16 = 0o755;
12const DIR_PERMISSIONS: u16 = 0o755;
13const FILE_PERMISSIONS: u16 = 0o644;
14const SYMLINK_PERMISSIONS: u16 = 0o777;
15const NORMALIZED_TIME: u32 = 0;
16
17pub struct BackhandBundleFsWriter;
18
19pub struct BackhandBundleFsReader;
20
21impl BundleFsWriter for BackhandBundleFsWriter {
22    fn write_bundle(&self, input_dir: &Path, output_file: &Path) -> Result<()> {
23        write_bundle_with_backhand(input_dir, output_file).with_context(|| {
24            format!(
25                "Failed to create .gtbundle using Rust-native SquashFS writer from {} to {}",
26                input_dir.display(),
27                output_file.display()
28            )
29        })
30    }
31}
32
33impl BundleFsReader for BackhandBundleFsReader {
34    fn list_bundle(&self, bundle_file: &Path) -> Result<Vec<BundleEntry>> {
35        let filesystem = open_backhand_filesystem(bundle_file)?;
36        let mut entries = Vec::new();
37        for node in filesystem.files() {
38            let Some(path) = normalized_node_path(&node.fullpath)? else {
39                continue;
40            };
41            let kind = match &node.inner {
42                InnerNode::File(_) => BundleEntryKind::File,
43                InnerNode::Dir(_) => BundleEntryKind::Directory,
44                InnerNode::Symlink(_) => BundleEntryKind::Symlink,
45                InnerNode::CharacterDevice(_)
46                | InnerNode::BlockDevice(_)
47                | InnerNode::NamedPipe
48                | InnerNode::Socket => BundleEntryKind::Other,
49            };
50            entries.push(BundleEntry { path, kind });
51        }
52        entries.sort_by(|left, right| left.path.cmp(&right.path));
53        Ok(entries)
54    }
55
56    fn extract_bundle(&self, bundle_file: &Path, output_dir: &Path) -> Result<()> {
57        let filesystem = open_backhand_filesystem(bundle_file)?;
58        std::fs::create_dir_all(output_dir)
59            .with_context(|| format!("create extraction directory {}", output_dir.display()))?;
60        for node in filesystem.files() {
61            let Some(path) = normalized_node_path(&node.fullpath)? else {
62                continue;
63            };
64            let destination = safe_output_path(output_dir, &path)?;
65            match &node.inner {
66                InnerNode::Dir(_) => {
67                    std::fs::create_dir_all(&destination)
68                        .with_context(|| format!("create directory {}", destination.display()))?;
69                }
70                InnerNode::File(file) => {
71                    if let Some(parent) = destination.parent() {
72                        std::fs::create_dir_all(parent).with_context(|| {
73                            format!("create parent directory {}", parent.display())
74                        })?;
75                    }
76                    let mut source = filesystem.file(file).reader();
77                    let mut target = File::create(&destination)
78                        .with_context(|| format!("create file {}", destination.display()))?;
79                    std::io::copy(&mut source, &mut target)
80                        .with_context(|| format!("extract file {path}"))?;
81                }
82                InnerNode::Symlink(symlink) => {
83                    if let Some(parent) = destination.parent() {
84                        std::fs::create_dir_all(parent).with_context(|| {
85                            format!("create parent directory {}", parent.display())
86                        })?;
87                    }
88                    create_symlink(&symlink.link, &destination)
89                        .with_context(|| format!("extract symlink {path}"))?;
90                }
91                InnerNode::CharacterDevice(_)
92                | InnerNode::BlockDevice(_)
93                | InnerNode::NamedPipe
94                | InnerNode::Socket => {
95                    bail!("unsupported SquashFS entry type while extracting {path}");
96                }
97            }
98        }
99        Ok(())
100    }
101}
102
103pub fn read_bundle_file_with_backhand(bundle_file: &Path, inner_path: &str) -> Result<Vec<u8>> {
104    let filesystem = open_backhand_filesystem(bundle_file)?;
105    let normalized_inner = normalize_inner_path(inner_path)?;
106    for node in filesystem.files() {
107        let Some(path) = normalized_node_path(&node.fullpath)? else {
108            continue;
109        };
110        if path != normalized_inner {
111            continue;
112        }
113        let InnerNode::File(file) = &node.inner else {
114            bail!("{inner_path} is not a file in {}", bundle_file.display());
115        };
116        let mut reader = filesystem.file(file).reader();
117        let mut bytes = Vec::new();
118        reader
119            .read_to_end(&mut bytes)
120            .with_context(|| format!("read {inner_path} from {}", bundle_file.display()))?;
121        return Ok(bytes);
122    }
123    bail!(
124        "bundle entry {inner_path} not found in {}",
125        bundle_file.display()
126    )
127}
128
129fn write_bundle_with_backhand(input_dir: &Path, output_file: &Path) -> Result<()> {
130    if !input_dir.is_dir() {
131        bail!(
132            "bundle input directory does not exist: {}",
133            input_dir.display()
134        );
135    }
136    if let Some(parent) = output_file.parent() {
137        std::fs::create_dir_all(parent)
138            .with_context(|| format!("create artifact parent {}", parent.display()))?;
139    }
140    if output_file.exists() {
141        std::fs::remove_file(output_file)
142            .with_context(|| format!("remove existing artifact {}", output_file.display()))?;
143    }
144
145    let mut writer = FilesystemWriter::default();
146    writer.set_time(NORMALIZED_TIME);
147    writer.set_root_uid(0);
148    writer.set_root_gid(0);
149    writer.set_root_mode(ROOT_PERMISSIONS);
150    writer.set_only_root_id();
151    writer.set_no_padding();
152    // mksquashfs wrote compressor options by default. We leave backhand's default
153    // XZ compressor/options intact and normalize everything else we control.
154    writer.set_emit_compression_options(true);
155
156    for entry in sorted_entries(input_dir)? {
157        let relative_path = normalized_relative_path(input_dir, &entry)?;
158        let metadata = std::fs::symlink_metadata(&entry)
159            .with_context(|| format!("read metadata for {}", entry.display()))?;
160        let file_type = metadata.file_type();
161
162        if file_type.is_dir() {
163            writer
164                .push_dir(&relative_path, header(DIR_PERMISSIONS))
165                .with_context(|| format!("add directory {relative_path} to SquashFS"))?;
166        } else if file_type.is_file() {
167            let file = File::open(&entry)
168                .with_context(|| format!("open staged file {}", entry.display()))?;
169            writer
170                .push_file(file, &relative_path, header(FILE_PERMISSIONS))
171                .with_context(|| format!("add file {relative_path} to SquashFS"))?;
172        } else if file_type.is_symlink() {
173            let target = std::fs::read_link(&entry)
174                .with_context(|| format!("read symlink target {}", entry.display()))?;
175            let target = normalized_link_target(&target)?;
176            writer
177                .push_symlink(target, &relative_path, header(SYMLINK_PERMISSIONS))
178                .with_context(|| format!("add symlink {relative_path} to SquashFS"))?;
179        } else {
180            bail!(
181                "unsupported staged bundle entry type at {}; only files, directories, and symlinks can be bundled",
182                entry.display()
183            );
184        }
185    }
186
187    let mut output = File::create(output_file)
188        .with_context(|| format!("create artifact {}", output_file.display()))?;
189    writer
190        .write(&mut output)
191        .with_context(|| format!("write SquashFS artifact {}", output_file.display()))?;
192    Ok(())
193}
194
195fn open_backhand_filesystem(bundle_file: &Path) -> Result<FilesystemReader<'static>> {
196    let file = File::open(bundle_file)
197        .with_context(|| format!("open bundle {}", bundle_file.display()))?;
198    FilesystemReader::from_reader(BufReader::new(file))
199        .with_context(|| format!("read SquashFS bundle {}", bundle_file.display()))
200}
201
202fn header(permissions: u16) -> NodeHeader {
203    NodeHeader {
204        permissions,
205        uid: 0,
206        gid: 0,
207        mtime: NORMALIZED_TIME,
208    }
209}
210
211fn sorted_entries(input_dir: &Path) -> Result<Vec<PathBuf>> {
212    let mut entries = Vec::new();
213    for entry in WalkDir::new(input_dir)
214        .min_depth(1)
215        .follow_links(false)
216        .sort_by_file_name()
217    {
218        let entry = entry.with_context(|| format!("walk staged bundle {}", input_dir.display()))?;
219        entries.push(entry.into_path());
220    }
221    entries.sort_by_key(|path| normalized_relative_path(input_dir, path).unwrap_or_default());
222    Ok(entries)
223}
224
225fn normalized_relative_path(input_dir: &Path, path: &Path) -> Result<String> {
226    let relative = path.strip_prefix(input_dir).with_context(|| {
227        format!(
228            "make {} relative to {}",
229            path.display(),
230            input_dir.display()
231        )
232    })?;
233    normalized_path(relative)
234}
235
236fn normalized_link_target(path: &Path) -> Result<String> {
237    normalized_path(path)
238}
239
240fn normalized_node_path(path: &Path) -> Result<Option<String>> {
241    if path == Path::new("/") {
242        return Ok(None);
243    }
244    let stripped = path.strip_prefix("/").unwrap_or(path);
245    Ok(Some(normalized_path(stripped)?))
246}
247
248fn normalize_inner_path(path: &str) -> Result<String> {
249    normalized_path(Path::new(path.trim_matches('/')))
250}
251
252fn normalized_path(path: &Path) -> Result<String> {
253    let mut parts = Vec::new();
254    for component in path.components() {
255        match component {
256            Component::Normal(part) => {
257                let part = part.to_str().ok_or_else(|| {
258                    anyhow!("bundle paths must be valid UTF-8: {}", path.display())
259                })?;
260                if part.is_empty() {
261                    bail!(
262                        "bundle path contains an empty component: {}",
263                        path.display()
264                    );
265                }
266                parts.push(part.to_string());
267            }
268            Component::CurDir => {}
269            Component::ParentDir => parts.push("..".to_string()),
270            Component::RootDir | Component::Prefix(_) => {
271                bail!("bundle paths must be relative: {}", path.display());
272            }
273        }
274    }
275    if parts.is_empty() {
276        bail!("bundle path cannot be empty");
277    }
278    Ok(parts.join("/"))
279}
280
281fn safe_output_path(output_dir: &Path, inner_path: &str) -> Result<PathBuf> {
282    let mut out = output_dir.to_path_buf();
283    for component in Path::new(inner_path).components() {
284        match component {
285            Component::Normal(part) => out.push(part),
286            Component::CurDir => {}
287            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
288                bail!("refusing to extract unsafe bundle path: {inner_path}");
289            }
290        }
291    }
292    Ok(out)
293}
294
295#[cfg(unix)]
296fn create_symlink(target: &Path, destination: &Path) -> std::io::Result<()> {
297    std::os::unix::fs::symlink(target, destination)
298}
299
300#[cfg(windows)]
301fn create_symlink(target: &Path, destination: &Path) -> std::io::Result<()> {
302    std::os::windows::fs::symlink_file(target, destination)
303}
304
305#[cfg(test)]
306mod tests {
307    use super::normalized_path;
308    use std::path::Path;
309
310    #[test]
311    fn normalizes_paths_with_forward_slashes() {
312        assert_eq!(
313            normalized_path(Path::new("assets/example.txt")).unwrap(),
314            "assets/example.txt"
315        );
316    }
317}