1use std::{fs, io, path};
7
8use libflate::gzip;
9
10const ZSTD_MAGIC: [u8; 4] = [0x28, 0xB5, 0x2F, 0xFD];
12
13fn is_zstd(data: &[u8]) -> bool {
14 data.len() >= 4 && data[..4] == ZSTD_MAGIC
15}
16
17#[derive(Debug, thiserror::Error)]
18pub enum RenderError {
19 #[error("wrong target path {}: must be absolute path to existing directory", _0.display())]
20 WrongTargetPath(path::PathBuf),
21 #[error("io error")]
22 Io(#[from] std::io::Error),
23}
24
25pub fn unpack(layers: &[Vec<u8>], target_dir: &path::Path) -> Result<(), RenderError> {
30 _unpack(layers, target_dir, |mut archive, target_dir| {
31 Ok(archive.unpack(target_dir)?)
32 })
33}
34
35pub fn filter_unpack<P>(layers: &[Vec<u8>], target_dir: &path::Path, predicate: P) -> Result<(), RenderError>
41where
42 P: Fn(&path::Path) -> bool,
43{
44 _unpack(layers, target_dir, |mut archive, target_dir| {
45 for entry in archive.entries()? {
46 let mut entry = entry?;
47 let path = entry.path()?;
48
49 if predicate(&path) {
50 entry.unpack_in(target_dir)?;
51 }
52 }
53
54 Ok(())
55 })
56}
57
58fn decompress(data: &[u8]) -> Result<Box<dyn io::Read + '_>, RenderError> {
59 if is_zstd(data) {
60 Ok(Box::new(zstd::Decoder::new(data)?))
61 } else {
62 Ok(Box::new(gzip::Decoder::new(data)?))
63 }
64}
65
66fn _unpack<U>(layers: &[Vec<u8>], target_dir: &path::Path, unpacker: U) -> Result<(), RenderError>
67where
68 U: Fn(tar::Archive<Box<dyn io::Read + '_>>, &path::Path) -> Result<(), RenderError>,
69{
70 if !target_dir.is_absolute() || !target_dir.exists() || !target_dir.is_dir() {
71 return Err(RenderError::WrongTargetPath(target_dir.to_path_buf()));
72 }
73 for l in layers {
74 let reader = decompress(l.as_slice())?;
77 let mut archive = tar::Archive::new(reader);
78 for entry in archive.entries()? {
79 let file = entry?;
80 let path = file.path()?;
81 let parent = path.parent().unwrap_or_else(|| path::Path::new("/"));
82 if let Some(fname) = path.file_name() {
83 if fname.to_string_lossy() == ".wh..wh..opq" {
84 let rel_parent = path::PathBuf::from("./".to_string() + &parent.to_string_lossy());
85 let abs_parent = target_dir.join(&rel_parent);
86 if abs_parent.is_dir() {
87 for dir_entry in fs::read_dir(&abs_parent)? {
88 let dir_entry = dir_entry?;
89 if dir_entry.path().is_dir() {
90 fs::remove_dir_all(dir_entry.path())?;
91 } else {
92 fs::remove_file(dir_entry.path())?;
93 }
94 }
95 }
96 }
97 }
98 }
99
100 let reader = decompress(l.as_slice())?;
102 let mut archive = tar::Archive::new(reader);
103 archive.set_preserve_permissions(true);
104 archive.set_unpack_xattrs(true);
105 unpacker(archive, target_dir)?;
106
107 let reader = decompress(l.as_slice())?;
109 let mut archive = tar::Archive::new(reader);
110 for entry in archive.entries()? {
111 let file = entry?;
112 let path = file.path()?;
113 let parent = path.parent().unwrap_or_else(|| path::Path::new("/"));
114 if let Some(fname) = path.file_name() {
115 let wh_name = fname.to_string_lossy();
116 if wh_name == ".wh..wh..opq" {
117 let rel_parent = path::PathBuf::from("./".to_string() + &parent.to_string_lossy());
119 let abs_wh_path = target_dir.join(&rel_parent).join(fname);
120 remove_whiteout(abs_wh_path)?;
121 } else if wh_name.starts_with(".wh.") {
122 let rel_parent = path::PathBuf::from("./".to_string() + &parent.to_string_lossy());
123
124 let real_name = wh_name.trim_start_matches(".wh.");
126 let abs_real_path = target_dir.join(&rel_parent).join(real_name);
127 remove_whiteout(abs_real_path)?;
128
129 let abs_wh_path = target_dir.join(&rel_parent).join(fname);
131 remove_whiteout(abs_wh_path)?;
132 };
133 }
134 }
135 }
136 Ok(())
137}
138
139fn remove_whiteout(path: path::PathBuf) -> io::Result<()> {
144 if path.is_dir() {
145 let res = fs::remove_dir_all(&path);
146 match res {
147 Ok(_) => Ok(()),
148 Err(ref e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
149 Err(e) => Err(e),
150 }
151 } else {
152 let res = fs::remove_file(&path);
153 match res {
154 Ok(_) => Ok(()),
155 Err(ref e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
156 Err(e) => Err(e),
157 }
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use std::{io::Write, path::Path};
164
165 use super::*;
166
167 fn make_layer(file_name: &str, content: &[u8]) -> Vec<u8> {
169 let mut tar_buf = Vec::new();
170 {
171 let mut builder = tar::Builder::new(&mut tar_buf);
172 let mut header = tar::Header::new_gnu();
173 header.set_path(file_name).unwrap();
174 header.set_size(content.len() as u64);
175 header.set_mode(0o644);
176 header.set_cksum();
177 builder.append(&header, content).unwrap();
178 builder.finish().unwrap();
179 }
180 let mut gz_buf = Vec::new();
181 {
182 let mut encoder = gzip::Encoder::new(&mut gz_buf).unwrap();
183 encoder.write_all(&tar_buf).unwrap();
184 encoder.finish().into_result().unwrap();
185 }
186 gz_buf
187 }
188
189 fn make_whiteout_layer(whiteout_path: &str) -> Vec<u8> {
191 make_layer(whiteout_path, b"")
192 }
193
194 #[test]
195 fn test_unpack_single_layer() {
196 let dir = tempfile::tempdir().unwrap();
197 let layer = make_layer("hello.txt", b"hello world");
198 unpack(&[layer], dir.path()).unwrap();
199 let content = fs::read_to_string(dir.path().join("hello.txt")).unwrap();
200 assert_eq!(content, "hello world");
201 }
202
203 #[test]
204 fn test_unpack_multiple_layers() {
205 let dir = tempfile::tempdir().unwrap();
206 let layer1 = make_layer("file1.txt", b"content1");
207 let layer2 = make_layer("file2.txt", b"content2");
208 unpack(&[layer1, layer2], dir.path()).unwrap();
209 assert_eq!(fs::read_to_string(dir.path().join("file1.txt")).unwrap(), "content1");
210 assert_eq!(fs::read_to_string(dir.path().join("file2.txt")).unwrap(), "content2");
211 }
212
213 #[test]
214 fn test_unpack_layer_overwrites_previous() {
215 let dir = tempfile::tempdir().unwrap();
216 let layer1 = make_layer("file.txt", b"old");
217 let layer2 = make_layer("file.txt", b"new");
218 unpack(&[layer1, layer2], dir.path()).unwrap();
219 assert_eq!(fs::read_to_string(dir.path().join("file.txt")).unwrap(), "new");
220 }
221
222 #[test]
223 fn test_unpack_relative_path_rejected() {
224 let layer = make_layer("hello.txt", b"hello");
225 let result = unpack(&[layer], Path::new("relative/path"));
226 assert!(result.is_err());
227 }
228
229 #[test]
230 fn test_unpack_nonexistent_path_rejected() {
231 let layer = make_layer("hello.txt", b"hello");
232 let result = unpack(&[layer], Path::new("/nonexistent/path/that/does/not/exist"));
233 assert!(result.is_err());
234 }
235
236 #[test]
237 fn test_unpack_empty_layers() {
238 let dir = tempfile::tempdir().unwrap();
239 unpack(&[], dir.path()).unwrap();
240 }
242
243 #[test]
244 fn test_filter_unpack_includes_matching() {
245 let dir = tempfile::tempdir().unwrap();
246 let layer = make_layer("include-me.txt", b"included");
247 filter_unpack(&[layer], dir.path(), |p| p.to_string_lossy().contains("include")).unwrap();
248 assert!(dir.path().join("include-me.txt").exists());
249 }
250
251 #[test]
252 fn test_filter_unpack_excludes_non_matching() {
253 let dir = tempfile::tempdir().unwrap();
254 let layer = make_layer("exclude-me.txt", b"excluded");
255 filter_unpack(&[layer], dir.path(), |p| p.to_string_lossy().contains("include")).unwrap();
256 assert!(!dir.path().join("exclude-me.txt").exists());
257 }
258
259 #[test]
260 fn test_whiteout_removes_file() {
261 let dir = tempfile::tempdir().unwrap();
262 let layer1 = make_layer("myfile.txt", b"content");
263 let layer2 = make_whiteout_layer(".wh.myfile.txt");
264 unpack(&[layer1, layer2], dir.path()).unwrap();
265 assert!(!dir.path().join("myfile.txt").exists());
266 }
267
268 #[test]
269 fn test_unpack_invalid_gzip() {
270 let dir = tempfile::tempdir().unwrap();
271 let result = unpack(&[b"not gzip data".to_vec()], dir.path());
272 assert!(result.is_err());
273 }
274
275 fn make_zstd_layer(file_name: &str, content: &[u8]) -> Vec<u8> {
277 let mut tar_buf = Vec::new();
278 {
279 let mut builder = tar::Builder::new(&mut tar_buf);
280 let mut header = tar::Header::new_gnu();
281 header.set_path(file_name).unwrap();
282 header.set_size(content.len() as u64);
283 header.set_mode(0o644);
284 header.set_cksum();
285 builder.append(&header, content).unwrap();
286 builder.finish().unwrap();
287 }
288 zstd::encode_all(tar_buf.as_slice(), 3).unwrap()
289 }
290
291 #[test]
292 fn test_unpack_zstd_single_layer() {
293 let dir = tempfile::tempdir().unwrap();
294 let layer = make_zstd_layer("hello.txt", b"hello zstd");
295 unpack(&[layer], dir.path()).unwrap();
296 let content = fs::read_to_string(dir.path().join("hello.txt")).unwrap();
297 assert_eq!(content, "hello zstd");
298 }
299
300 #[test]
301 fn test_unpack_zstd_multiple_layers() {
302 let dir = tempfile::tempdir().unwrap();
303 let layer1 = make_zstd_layer("file1.txt", b"content1");
304 let layer2 = make_zstd_layer("file2.txt", b"content2");
305 unpack(&[layer1, layer2], dir.path()).unwrap();
306 assert_eq!(fs::read_to_string(dir.path().join("file1.txt")).unwrap(), "content1");
307 assert_eq!(fs::read_to_string(dir.path().join("file2.txt")).unwrap(), "content2");
308 }
309
310 #[test]
311 fn test_unpack_mixed_gzip_and_zstd() {
312 let dir = tempfile::tempdir().unwrap();
313 let gz_layer = make_layer("from_gzip.txt", b"gzip content");
314 let zstd_layer = make_zstd_layer("from_zstd.txt", b"zstd content");
315 unpack(&[gz_layer, zstd_layer], dir.path()).unwrap();
316 assert_eq!(
317 fs::read_to_string(dir.path().join("from_gzip.txt")).unwrap(),
318 "gzip content"
319 );
320 assert_eq!(
321 fs::read_to_string(dir.path().join("from_zstd.txt")).unwrap(),
322 "zstd content"
323 );
324 }
325
326 #[test]
327 fn test_filter_unpack_zstd() {
328 let dir = tempfile::tempdir().unwrap();
329 let layer = make_zstd_layer("include-me.txt", b"included");
330 filter_unpack(&[layer], dir.path(), |p| p.to_string_lossy().contains("include")).unwrap();
331 assert!(dir.path().join("include-me.txt").exists());
332 }
333
334 #[test]
335 fn test_whiteout_removes_file_zstd() {
336 let dir = tempfile::tempdir().unwrap();
337 let layer1 = make_zstd_layer("myfile.txt", b"content");
338 let layer2 = make_zstd_layer(".wh.myfile.txt", b"");
339 unpack(&[layer1, layer2], dir.path()).unwrap();
340 assert!(!dir.path().join("myfile.txt").exists());
341 }
342
343 #[test]
344 fn test_opaque_whiteout_clears_directory() {
345 let dir = tempfile::tempdir().unwrap();
346
347 let mut tar_buf = Vec::new();
349 {
350 let mut builder = tar::Builder::new(&mut tar_buf);
351
352 let mut header = tar::Header::new_gnu();
354 header.set_path("mydir/").unwrap();
355 header.set_size(0);
356 header.set_mode(0o755);
357 header.set_entry_type(tar::EntryType::Directory);
358 header.set_cksum();
359 builder.append(&header, &[] as &[u8]).unwrap();
360
361 let content = b"old content";
363 let mut header = tar::Header::new_gnu();
364 header.set_path("mydir/old_file.txt").unwrap();
365 header.set_size(content.len() as u64);
366 header.set_mode(0o644);
367 header.set_cksum();
368 builder.append(&header, content.as_slice()).unwrap();
369
370 builder.finish().unwrap();
371 }
372 let mut gz_buf = Vec::new();
373 {
374 let mut encoder = gzip::Encoder::new(&mut gz_buf).unwrap();
375 io::Write::write_all(&mut encoder, &tar_buf).unwrap();
376 encoder.finish().into_result().unwrap();
377 }
378 let layer1 = gz_buf;
379
380 let mut tar_buf2 = Vec::new();
382 {
383 let mut builder = tar::Builder::new(&mut tar_buf2);
384
385 let mut header = tar::Header::new_gnu();
387 header.set_path("mydir/.wh..wh..opq").unwrap();
388 header.set_size(0);
389 header.set_mode(0o644);
390 header.set_cksum();
391 builder.append(&header, &[] as &[u8]).unwrap();
392
393 let content = b"new content";
395 let mut header = tar::Header::new_gnu();
396 header.set_path("mydir/new_file.txt").unwrap();
397 header.set_size(content.len() as u64);
398 header.set_mode(0o644);
399 header.set_cksum();
400 builder.append(&header, content.as_slice()).unwrap();
401
402 builder.finish().unwrap();
403 }
404 let mut gz_buf2 = Vec::new();
405 {
406 let mut encoder = gzip::Encoder::new(&mut gz_buf2).unwrap();
407 io::Write::write_all(&mut encoder, &tar_buf2).unwrap();
408 encoder.finish().into_result().unwrap();
409 }
410 let layer2 = gz_buf2;
411
412 unpack(&[layer1, layer2], dir.path()).unwrap();
413
414 assert!(
416 !dir.path().join("mydir/old_file.txt").exists(),
417 "opaque whiteout should have removed old_file.txt"
418 );
419 assert!(dir.path().join("mydir/new_file.txt").exists());
421 assert_eq!(
422 fs::read_to_string(dir.path().join("mydir/new_file.txt")).unwrap(),
423 "new content"
424 );
425 assert!(dir.path().join("mydir").is_dir());
427 }
428
429 #[test]
430 fn test_is_zstd_detection() {
431 assert!(is_zstd(&[0x28, 0xB5, 0x2F, 0xFD, 0x00]));
432 assert!(!is_zstd(&[0x1F, 0x8B, 0x08, 0x00])); assert!(!is_zstd(&[0x00, 0x01, 0x02])); assert!(!is_zstd(&[]));
435 }
436}