Skip to main content

docker_registry/
render.rs

1//! Render a docker image.
2
3// Docker image format is specified at
4// https://github.com/moby/moby/blob/v17.05.0-ce/image/spec/v1.md
5
6use std::{fs, io, path};
7
8use libflate::gzip;
9
10/// Zstd magic number: 0x28B52FFD
11const 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
25/// Unpack an ordered list of layers to a target directory.
26///
27/// Layers must be provided as gzip-compressed tar archives, with lower layers
28/// coming first. Target directory must be an existing absolute path.
29pub 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
35/// Unpack an ordered list of layers to a target directory, filtering
36/// file entries by path.
37///
38/// Layers must be provided as gzip-compressed tar archives, with lower layers
39/// coming first. Target directory must be an existing absolute path.
40pub 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    // Pre-pass: handle opaque whiteouts BEFORE unpacking so that
75    // new files from the current layer survive the directory clearing.
76    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    // Unpack layers
101    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    // Clean whiteouts
108    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          // Already handled in pre-pass; just remove the marker.
118          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          // Remove real file behind whiteout
125          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          // Remove whiteout place-holder
130          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
139// Whiteout files in archive may not exist on filesystem if they were
140// filtered out via filter_unpack.  If not found, that's ok and the
141// error is non-fatal.  Otherwise still return error for other
142// failures.
143fn 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  /// Helper: create a gzip-compressed tar archive containing a single file.
168  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  /// Helper: create a layer with a whiteout marker file.
190  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    // Should succeed with no files created
241  }
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  /// Helper: create a zstd-compressed tar archive containing a single file.
276  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    // Layer 1: create a directory with files
348    let mut tar_buf = Vec::new();
349    {
350      let mut builder = tar::Builder::new(&mut tar_buf);
351
352      // Create dir
353      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      // Create file inside dir
362      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    // Layer 2: opaque whiteout marker + new file in same dir
381    let mut tar_buf2 = Vec::new();
382    {
383      let mut builder = tar::Builder::new(&mut tar_buf2);
384
385      // Opaque whiteout marker
386      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      // New file in the same directory
394      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    // Old file should be gone (opaque whiteout clears directory)
415    assert!(
416      !dir.path().join("mydir/old_file.txt").exists(),
417      "opaque whiteout should have removed old_file.txt"
418    );
419    // New file from layer 2 should exist
420    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    // Directory itself should still exist
426    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])); // gzip
433    assert!(!is_zstd(&[0x00, 0x01, 0x02])); // too short
434    assert!(!is_zstd(&[]));
435  }
436}