greentic_bundle/bundle_fs/
backhand_writer.rs1use 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 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}