composefs/
dumpfile_parse.rs

1//! # Parsing and generating composefs dump file entry
2//!
3//! The composefs project defines a "dump file" which is a textual
4//! serializion of the metadata file.  This module supports parsing
5//! and generating dump file entries.
6use std::borrow::Cow;
7use std::ffi::OsStr;
8use std::ffi::OsString;
9use std::fmt::Display;
10use std::fmt::Write as WriteFmt;
11use std::fs::File;
12use std::io::BufRead;
13use std::io::Write;
14use std::os::unix::ffi::{OsStrExt, OsStringExt};
15use std::path::{Path, PathBuf};
16use std::process::Command;
17use std::str::FromStr;
18
19use anyhow::Context;
20use anyhow::{anyhow, Result};
21use rustix::fs::FileType;
22
23/// https://github.com/torvalds/linux/blob/47ac09b91befbb6a235ab620c32af719f8208399/include/uapi/linux/limits.h#L13
24const PATH_MAX: u32 = 4096;
25/// Maximum size accepted for inline content.
26const MAX_INLINE_CONTENT: u16 = 5000;
27/// https://github.com/torvalds/linux/blob/47ac09b91befbb6a235ab620c32af719f8208399/include/uapi/linux/limits.h#L15
28/// This isn't exposed in libc/rustix, and in any case we should be conservative...if this ever
29/// gets bumped it'd be a hazard.
30const XATTR_NAME_MAX: usize = 255;
31// See above
32const XATTR_LIST_MAX: usize = u16::MAX as usize;
33// See above
34const XATTR_SIZE_MAX: usize = u16::MAX as usize;
35
36#[derive(Debug, PartialEq, Eq)]
37/// An extended attribute entry
38pub struct Xattr<'k> {
39    /// key
40    pub key: Cow<'k, OsStr>,
41    /// value
42    pub value: Cow<'k, [u8]>,
43}
44/// A full set of extended attributes
45pub type Xattrs<'k> = Vec<Xattr<'k>>;
46
47/// Modification time
48#[derive(Debug, PartialEq, Eq)]
49pub struct Mtime {
50    /// Seconds
51    pub sec: u64,
52    /// Nanoseconds
53    pub nsec: u64,
54}
55
56/// A composefs dumpfile entry
57#[derive(Debug, PartialEq, Eq)]
58pub struct Entry<'p> {
59    /// The filename
60    pub path: Cow<'p, Path>,
61    /// uid
62    pub uid: u32,
63    /// gid
64    pub gid: u32,
65    /// mode (includes file type)
66    pub mode: u32,
67    /// Modification time
68    pub mtime: Mtime,
69    /// The specific file/directory data
70    pub item: Item<'p>,
71    /// Extended attributes
72    pub xattrs: Xattrs<'p>,
73}
74
75#[derive(Debug, PartialEq, Eq)]
76/// A serializable composefs entry.
77///
78/// The `Display` implementation for this type is defined to serialize
79/// into a format consumable by `mkcomposefs --from-file`.
80pub enum Item<'p> {
81    /// A regular, inlined file
82    RegularInline {
83        /// Number of links
84        nlink: u32,
85        /// Inline content
86        content: Cow<'p, [u8]>,
87    },
88    /// A regular external file
89    Regular {
90        /// Size of the file
91        size: u64,
92        /// Number of links
93        nlink: u32,
94        /// The backing store path
95        path: Cow<'p, Path>,
96        /// The fsverity digest
97        fsverity_digest: Option<String>,
98    },
99    /// A character or block device node
100    Device {
101        /// Number of links
102        nlink: u32,
103        /// The device number
104        rdev: u64,
105    },
106    /// A symbolic link
107    Symlink {
108        /// Number of links
109        nlink: u32,
110        /// Symlink target
111        target: Cow<'p, Path>,
112    },
113    /// A hardlink entry
114    Hardlink {
115        /// The hardlink target
116        target: Cow<'p, Path>,
117    },
118    /// FIFO
119    Fifo {
120        /// Number of links
121        nlink: u32,
122    },
123    /// A directory
124    Directory {
125        /// Size of a directory is not necessarily meaningful
126        size: u64,
127        /// Number of links
128        nlink: u32,
129    },
130}
131
132/// Unescape a byte array according to the composefs dump file escaping format,
133/// limiting the maximum possible size.
134fn unescape_limited(s: &str, max: usize) -> Result<Cow<'_, [u8]>> {
135    // If there are no escapes, just return the input unchanged. However,
136    // it must also be ASCII to maintain a 1-1 correspondence between byte
137    // and character.
138    if !s.contains('\\') && s.is_ascii() {
139        let len = s.len();
140        if len > max {
141            anyhow::bail!("Input {len} exceeded maximum length {max}");
142        }
143        return Ok(Cow::Borrowed(s.as_bytes()));
144    }
145    let mut it = s.chars();
146    let mut r = Vec::new();
147    while let Some(c) = it.next() {
148        if r.len() == max {
149            anyhow::bail!("Input exceeded maximum length {max}");
150        }
151        if c != '\\' {
152            write!(r, "{c}").unwrap();
153            continue;
154        }
155        let c = it.next().ok_or_else(|| anyhow!("Unterminated escape"))?;
156        let c = match c {
157            '\\' => b'\\',
158            'n' => b'\n',
159            'r' => b'\r',
160            't' => b'\t',
161            'x' => {
162                let mut s = String::new();
163                s.push(
164                    it.next()
165                        .ok_or_else(|| anyhow!("Unterminated hex escape"))?,
166                );
167                s.push(
168                    it.next()
169                        .ok_or_else(|| anyhow!("Unterminated hex escape"))?,
170                );
171
172                u8::from_str_radix(&s, 16).with_context(|| anyhow!("Invalid hex escape {s}"))?
173            }
174            o => anyhow::bail!("Invalid escape {o}"),
175        };
176        r.push(c);
177    }
178    Ok(r.into())
179}
180
181/// Unescape a byte array according to the composefs dump file escaping format.
182fn unescape(s: &str) -> Result<Cow<'_, [u8]>> {
183    unescape_limited(s, usize::MAX)
184}
185
186/// Unescape a string into a Rust `OsStr` which is really just an alias for a byte array,
187/// but we also impose a constraint that it can not have an embedded NUL byte.
188fn unescape_to_osstr(s: &str) -> Result<Cow<'_, OsStr>> {
189    let v = unescape(s)?;
190    if v.contains(&0u8) {
191        anyhow::bail!("Invalid embedded NUL");
192    }
193    let r = match v {
194        Cow::Borrowed(v) => Cow::Borrowed(OsStr::from_bytes(v)),
195        Cow::Owned(v) => Cow::Owned(OsString::from_vec(v)),
196    };
197    Ok(r)
198}
199
200/// Unescape a string into a Rust `Path`, which is like a byte array but
201/// with a few constraints:
202/// - Cannot contain an embedded NUL
203/// - Cannot be empty, or longer than PATH_MAX
204fn unescape_to_path(s: &str) -> Result<Cow<'_, Path>> {
205    let v = unescape_to_osstr(s).and_then(|v| {
206        if v.is_empty() {
207            anyhow::bail!("Invalid empty path");
208        }
209        let l = v.len();
210        if l > PATH_MAX as usize {
211            anyhow::bail!("Path is too long: {l} bytes");
212        }
213        Ok(v)
214    })?;
215    let r = match v {
216        Cow::Borrowed(v) => Cow::Borrowed(Path::new(v)),
217        Cow::Owned(v) => Cow::Owned(PathBuf::from(v)),
218    };
219    Ok(r)
220}
221
222/// Like [`unescape_to_path`], but also ensures the path is in "canonical"
223/// form; this has the same semantics as Rust https://doc.rust-lang.org/std/path/struct.Path.html#method.components
224/// which in particular removes `.` and extra `//`.
225///
226/// We also deny uplinks `..` and empty paths.
227fn unescape_to_path_canonical(s: &str) -> Result<Cow<'_, Path>> {
228    let p = unescape_to_path(s)?;
229    let mut components = p.components();
230    let mut r = std::path::PathBuf::new();
231    let Some(first) = components.next() else {
232        anyhow::bail!("Invalid empty path");
233    };
234    if first != std::path::Component::RootDir {
235        anyhow::bail!("Invalid non-absolute path");
236    }
237    r.push(first);
238    for component in components {
239        match component {
240            // Prefix is a windows thing; I don't think RootDir or CurDir are reachable
241            // after the first component has been RootDir.
242            std::path::Component::Prefix(_)
243            | std::path::Component::RootDir
244            | std::path::Component::CurDir => {
245                anyhow::bail!("Internal error in unescape_to_path_canonical");
246            }
247            std::path::Component::ParentDir => {
248                anyhow::bail!("Invalid \"..\" in path");
249            }
250            std::path::Component::Normal(_) => {
251                r.push(component);
252            }
253        }
254    }
255    // If the input was already in normal form,
256    // then we can just return the original version, which
257    // may itself be a Cow::Borrowed, and hence we free our malloc buffer.
258    if r.as_os_str().as_bytes() == p.as_os_str().as_bytes() {
259        Ok(p)
260    } else {
261        // Otherwise return our copy.
262        Ok(r.into())
263    }
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267enum EscapeMode {
268    Standard,
269    XattrKey,
270}
271
272/// Escape a byte array according to the composefs dump file text format.
273fn escape<W: std::fmt::Write>(out: &mut W, s: &[u8], mode: EscapeMode) -> std::fmt::Result {
274    // Empty content must be represented by `-`
275    if s.is_empty() {
276        return out.write_char('-');
277    }
278    // But a single `-` must be "quoted".
279    if s == b"-" {
280        return out.write_str(r"\x2d");
281    }
282    for c in s.iter().copied() {
283        // Escape `=` as hex in xattr keys.
284        let is_special = c == b'\\' || (matches!((mode, c), (EscapeMode::XattrKey, b'=')));
285        let is_printable = c.is_ascii_alphanumeric() || c.is_ascii_punctuation();
286        if is_printable && !is_special {
287            out.write_char(c as char)?;
288        } else {
289            match c {
290                b'\\' => out.write_str(r"\\")?,
291                b'\n' => out.write_str(r"\n")?,
292                b'\t' => out.write_str(r"\t")?,
293                b'\r' => out.write_str(r"\r")?,
294                o => write!(out, "\\x{o:02x}")?,
295            }
296        }
297    }
298    std::fmt::Result::Ok(())
299}
300
301/// If the provided string is empty, map it to `-`.
302fn optional_str(s: &str) -> Option<&str> {
303    match s {
304        "-" => None,
305        o => Some(o),
306    }
307}
308
309impl FromStr for Mtime {
310    type Err = anyhow::Error;
311
312    fn from_str(s: &str) -> Result<Self> {
313        let (sec, nsec) = s
314            .split_once('.')
315            .ok_or_else(|| anyhow!("Missing . in mtime"))?;
316        Ok(Self {
317            sec: u64::from_str(sec)?,
318            nsec: u64::from_str(nsec)?,
319        })
320    }
321}
322
323impl<'k> Xattr<'k> {
324    fn parse(s: &'k str) -> Result<Self> {
325        let (key, value) = s
326            .split_once('=')
327            .ok_or_else(|| anyhow!("Missing = in xattrs"))?;
328        let key = unescape_to_osstr(key)?;
329        let keylen = key.as_bytes().len();
330        if keylen > XATTR_NAME_MAX {
331            anyhow::bail!("xattr name too long; max={XATTR_NAME_MAX} found={keylen}");
332        }
333        let value = unescape(value)?;
334        let valuelen = value.len();
335        if valuelen > XATTR_SIZE_MAX {
336            anyhow::bail!("xattr value too long; max={XATTR_SIZE_MAX} found={keylen}");
337        }
338        Ok(Self { key, value })
339    }
340}
341
342impl<'p> Entry<'p> {
343    fn check_nonregfile(content: Option<&str>, fsverity_digest: Option<&str>) -> Result<()> {
344        if content.is_some() {
345            anyhow::bail!("entry cannot have content");
346        }
347        if fsverity_digest.is_some() {
348            anyhow::bail!("entry cannot have fsverity digest");
349        }
350        Ok(())
351    }
352
353    fn check_rdev(rdev: u64) -> Result<()> {
354        if rdev != 0 {
355            anyhow::bail!("entry cannot have device (rdev) {rdev}");
356        }
357        Ok(())
358    }
359
360    /// Parse an entry from a composefs dump file line.
361    pub fn parse(s: &'p str) -> Result<Entry<'p>> {
362        let mut components = s.split(' ');
363        let mut next = |name: &str| components.next().ok_or_else(|| anyhow!("Missing {name}"));
364        let path = unescape_to_path_canonical(next("path")?)?;
365        let size = u64::from_str(next("size")?)?;
366        let modeval = next("mode")?;
367        let (is_hardlink, mode) = if let Some((_, rest)) = modeval.split_once('@') {
368            (true, u32::from_str_radix(rest, 8)?)
369        } else {
370            (false, u32::from_str_radix(modeval, 8)?)
371        };
372        let nlink = u32::from_str(next("nlink")?)?;
373        let uid = u32::from_str(next("uid")?)?;
374        let gid = u32::from_str(next("gid")?)?;
375        let rdev = u64::from_str(next("rdev")?)?;
376        let mtime = Mtime::from_str(next("mtime")?)?;
377        let payload = optional_str(next("payload")?);
378        let content = optional_str(next("content")?);
379        let fsverity_digest = optional_str(next("digest")?);
380        let xattrs = components
381            .try_fold((Vec::new(), 0usize), |(mut acc, total_namelen), line| {
382                let xattr = Xattr::parse(line)?;
383                // Limit the total length of keys.
384                let total_namelen = total_namelen.saturating_add(xattr.key.len());
385                if total_namelen > XATTR_LIST_MAX {
386                    anyhow::bail!("Too many xattrs");
387                }
388                acc.push(xattr);
389                Ok((acc, total_namelen))
390            })?
391            .0;
392
393        let ty = FileType::from_raw_mode(mode);
394        let item = if is_hardlink {
395            if ty == FileType::Directory {
396                anyhow::bail!("Invalid hardlinked directory");
397            }
398            let target =
399                unescape_to_path_canonical(payload.ok_or_else(|| anyhow!("Missing payload"))?)?;
400            // TODO: the dumpfile format suggests to retain all the metadata on hardlink lines
401            Item::Hardlink { target }
402        } else {
403            match ty {
404                FileType::RegularFile => {
405                    Self::check_rdev(rdev)?;
406                    if let Some(path) = payload.as_ref() {
407                        let path = unescape_to_path(path)?;
408                        Item::Regular {
409                            size,
410                            nlink,
411                            path,
412                            fsverity_digest: fsverity_digest.map(ToOwned::to_owned),
413                        }
414                    } else {
415                        // A dumpfile entry with no backing path or payload is treated as an empty file
416                        let content = content.unwrap_or_default();
417                        let content = unescape_limited(content, MAX_INLINE_CONTENT.into())?;
418                        if fsverity_digest.is_some() {
419                            anyhow::bail!("Inline file cannot have fsverity digest");
420                        }
421                        Item::RegularInline { nlink, content }
422                    }
423                }
424                FileType::Symlink => {
425                    Self::check_nonregfile(content, fsverity_digest)?;
426                    Self::check_rdev(rdev)?;
427
428                    // Note that the target of *symlinks* is not required to be in canonical form,
429                    // as we don't actually traverse those links on our own, and we need to support
430                    // symlinks that e.g. contain `//` or other things.
431                    let target =
432                        unescape_to_path(payload.ok_or_else(|| anyhow!("Missing payload"))?)?;
433                    let targetlen = target.as_os_str().as_bytes().len();
434                    if targetlen > PATH_MAX as usize {
435                        anyhow::bail!("Target length too large {targetlen}");
436                    }
437                    Item::Symlink { nlink, target }
438                }
439                FileType::Fifo => {
440                    Self::check_nonregfile(content, fsverity_digest)?;
441                    Self::check_rdev(rdev)?;
442
443                    Item::Fifo { nlink }
444                }
445                FileType::CharacterDevice | FileType::BlockDevice => {
446                    Self::check_nonregfile(content, fsverity_digest)?;
447                    Item::Device { nlink, rdev }
448                }
449                FileType::Directory => {
450                    Self::check_nonregfile(content, fsverity_digest)?;
451                    Self::check_rdev(rdev)?;
452
453                    Item::Directory { size, nlink }
454                }
455                FileType::Socket => {
456                    anyhow::bail!("sockets are not supported");
457                }
458                FileType::Unknown => {
459                    anyhow::bail!("Unhandled file type from raw mode: {mode}")
460                }
461            }
462        };
463        Ok(Entry {
464            path,
465            uid,
466            gid,
467            mode,
468            mtime,
469            item,
470            xattrs,
471        })
472    }
473
474    /// Remove internal entries
475    /// FIXME: This is arguably a composefs-info dump bug?
476    pub fn filter_special(mut self) -> Self {
477        self.xattrs.retain(|v| {
478            !matches!(
479                (v.key.as_bytes(), &*v.value),
480                (b"trusted.overlay.opaque" | b"user.overlay.opaque", b"x")
481            )
482        });
483        self
484    }
485}
486
487impl Item<'_> {
488    pub(crate) fn size(&self) -> u64 {
489        match self {
490            Item::Regular { size, .. } | Item::Directory { size, .. } => *size,
491            Item::RegularInline { content, .. } => content.len() as u64,
492            _ => 0,
493        }
494    }
495
496    pub(crate) fn nlink(&self) -> u32 {
497        match self {
498            Item::RegularInline { nlink, .. } => *nlink,
499            Item::Regular { nlink, .. } => *nlink,
500            Item::Device { nlink, .. } => *nlink,
501            Item::Symlink { nlink, .. } => *nlink,
502            Item::Directory { nlink, .. } => *nlink,
503            Item::Fifo { nlink, .. } => *nlink,
504            _ => 0,
505        }
506    }
507
508    pub(crate) fn rdev(&self) -> u64 {
509        match self {
510            Item::Device { rdev, .. } => *rdev,
511            _ => 0,
512        }
513    }
514
515    pub(crate) fn payload(&self) -> Option<&Path> {
516        match self {
517            Item::Regular { path, .. } => Some(path),
518            Item::Symlink { target, .. } => Some(target),
519            Item::Hardlink { target } => Some(target),
520            _ => None,
521        }
522    }
523}
524
525impl Display for Mtime {
526    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
527        write!(f, "{}.{}", self.sec, self.nsec)
528    }
529}
530
531impl Display for Entry<'_> {
532    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
533        escape(f, self.path.as_os_str().as_bytes(), EscapeMode::Standard)?;
534        write!(
535            f,
536            " {} {:o} {} {} {} {} {} ",
537            self.item.size(),
538            self.mode,
539            self.item.nlink(),
540            self.uid,
541            self.gid,
542            self.item.rdev(),
543            self.mtime,
544        )?;
545        // Payload is written for non-inline files, hardlinks and symlinks
546        if let Some(payload) = self.item.payload() {
547            escape(f, payload.as_os_str().as_bytes(), EscapeMode::Standard)?;
548            f.write_char(' ')?;
549        } else {
550            write!(f, "- ")?;
551        }
552        match &self.item {
553            Item::RegularInline { content, .. } => {
554                escape(f, content, EscapeMode::Standard)?;
555                write!(f, " -")?;
556            }
557            Item::Regular {
558                fsverity_digest, ..
559            } => {
560                let fsverity_digest = fsverity_digest.as_deref().unwrap_or("-");
561                write!(f, "- {fsverity_digest}")?;
562            }
563            _ => {
564                write!(f, "- -")?;
565            }
566        }
567        for xattr in self.xattrs.iter() {
568            f.write_char(' ')?;
569            escape(f, xattr.key.as_bytes(), EscapeMode::XattrKey)?;
570            f.write_char('=')?;
571            escape(f, &xattr.value, EscapeMode::Standard)?;
572        }
573        std::fmt::Result::Ok(())
574    }
575}
576
577/// Configuration for parsing a dumpfile
578#[derive(Debug, Default)]
579pub struct DumpConfig<'a> {
580    /// Only dump these toplevel filenames
581    pub filters: Option<&'a [&'a str]>,
582}
583
584/// Parse the provided composefs into dumpfile entries.
585pub fn dump<F>(input: File, config: DumpConfig, mut handler: F) -> Result<()>
586where
587    F: FnMut(Entry<'_>) -> Result<()> + Send,
588{
589    let mut proc = Command::new("composefs-info");
590    proc.arg("dump");
591    if let Some(filter) = config.filters {
592        proc.args(filter.iter().flat_map(|f| ["--filter", f]));
593    }
594    proc.args(["/dev/stdin"])
595        .stdin(std::process::Stdio::from(input))
596        .stderr(std::process::Stdio::piped())
597        .stdout(std::process::Stdio::piped());
598    let mut proc = proc.spawn().context("Spawning composefs-info")?;
599
600    // SAFETY: we set up these streams
601    let child_stdout = proc.stdout.take().unwrap();
602    let child_stderr = proc.stderr.take().unwrap();
603
604    std::thread::scope(|s| {
605        let stderr_copier = s.spawn(move || {
606            let mut child_stderr = std::io::BufReader::new(child_stderr);
607            let mut buf = Vec::new();
608            std::io::copy(&mut child_stderr, &mut buf)?;
609            anyhow::Ok(buf)
610        });
611
612        let child_stdout = std::io::BufReader::new(child_stdout);
613        for line in child_stdout.lines() {
614            let line = line.context("Reading dump stdout")?;
615            let entry = Entry::parse(&line)?.filter_special();
616            handler(entry)?;
617        }
618
619        let r = proc.wait()?;
620        let stderr = stderr_copier.join().unwrap()?;
621        if !r.success() {
622            let stderr = String::from_utf8_lossy(&stderr);
623            let stderr = stderr.trim();
624            anyhow::bail!("composefs-info dump failed: {r}: {stderr}")
625        }
626
627        Ok(())
628    })
629}
630
631#[cfg(test)]
632mod tests {
633    use std::{
634        fs::File,
635        io::{BufWriter, Seek},
636        process::Stdio,
637    };
638
639    use super::*;
640
641    const SPECIAL_DUMP: &str = include_str!("tests/assets/special.dump");
642    const SPECIALS: &[&str] = &["foo=bar=baz", r"\x01\x02", "-"];
643    const UNQUOTED: &[&str] = &["foo!bar", "hello-world", "--"];
644
645    fn mkcomposefs(dumpfile: &str, out: &mut File) -> Result<()> {
646        let mut tf = tempfile::tempfile().map(BufWriter::new)?;
647        tf.write_all(dumpfile.as_bytes())?;
648        let mut tf = tf.into_inner()?;
649        tf.seek(std::io::SeekFrom::Start(0))?;
650        let mut mkcomposefs = Command::new("mkcomposefs")
651            .args(["--from-file", "-", "-"])
652            .stdin(Stdio::from(tf))
653            .stdout(Stdio::from(out.try_clone()?))
654            .stderr(Stdio::inherit())
655            .spawn()?;
656
657        let st = mkcomposefs.wait()?;
658        if !st.success() {
659            anyhow::bail!("mkcomposefs failed: {st}");
660        };
661
662        Ok(())
663    }
664
665    #[test]
666    fn test_escape_specials() {
667        let cases = [("", "-"), ("-", r"\x2d")];
668        for (source, expected) in cases {
669            let mut buf = String::new();
670            escape(&mut buf, source.as_bytes(), EscapeMode::Standard).unwrap();
671            assert_eq!(&buf, expected);
672        }
673    }
674
675    #[test]
676    fn test_escape_roundtrip() {
677        let cases = SPECIALS.iter().chain(UNQUOTED);
678        for case in cases {
679            let mut buf = String::new();
680            escape(&mut buf, case.as_bytes(), EscapeMode::Standard).unwrap();
681            let case2 = unescape(&buf).unwrap();
682            assert_eq!(case, &String::from_utf8(case2.into()).unwrap());
683        }
684    }
685
686    #[test]
687    fn test_escape_unquoted() {
688        let cases = UNQUOTED;
689        for case in cases {
690            let mut buf = String::new();
691            escape(&mut buf, case.as_bytes(), EscapeMode::Standard).unwrap();
692            assert_eq!(case, &buf);
693        }
694    }
695
696    #[test]
697    fn test_escape_quoted() {
698        // We don't escape `=` in standard mode
699        {
700            let mut buf = String::new();
701            escape(&mut buf, b"=", EscapeMode::Standard).unwrap();
702            assert_eq!(buf, "=");
703        }
704        // Verify other special cases
705        let cases = &[("=", r"\x3d"), ("-", r"\x2d")];
706        for (src, expected) in cases {
707            let mut buf = String::new();
708            escape(&mut buf, src.as_bytes(), EscapeMode::XattrKey).unwrap();
709            assert_eq!(expected, &buf);
710        }
711    }
712
713    #[test]
714    fn test_unescape() {
715        assert_eq!(unescape("").unwrap().len(), 0);
716        assert_eq!(unescape_limited("", 0).unwrap().len(), 0);
717        assert!(unescape_limited("foobar", 3).is_err());
718        // This is borrowed input
719        assert!(matches!(
720            unescape_limited("foobar", 6).unwrap(),
721            Cow::Borrowed(_)
722        ));
723        // But non-ASCII is currently owned out of conservatism
724        assert!(matches!(unescape_limited("→", 6).unwrap(), Cow::Owned(_)));
725        assert!(unescape_limited("foo→bar", 3).is_err());
726    }
727
728    #[test]
729    fn test_unescape_path() {
730        // Empty
731        assert!(unescape_to_path("").is_err());
732        // Embedded NUL
733        assert!(unescape_to_path("\0").is_err());
734        assert!(unescape_to_path("foo\0bar").is_err());
735        assert!(unescape_to_path("\0foobar").is_err());
736        assert!(unescape_to_path("foobar\0").is_err());
737        assert!(unescape_to_path("foo\\x00bar").is_err());
738        let mut p = "a".repeat(PATH_MAX.try_into().unwrap());
739        assert!(unescape_to_path(&p).is_ok());
740        p.push('a');
741        assert!(unescape_to_path(&p).is_err());
742    }
743
744    #[test]
745    fn test_unescape_path_canonical() {
746        // Invalid cases
747        assert!(unescape_to_path_canonical("").is_err());
748        assert!(unescape_to_path_canonical("foo").is_err());
749        assert!(unescape_to_path_canonical("../blah").is_err());
750        assert!(unescape_to_path_canonical("/foo/..").is_err());
751        assert!(unescape_to_path_canonical("/foo/../blah").is_err());
752        // Verify that we return borrowed input where possible
753        assert!(matches!(
754            unescape_to_path_canonical("/foo").unwrap(),
755            Cow::Borrowed(v) if v.to_str() == Some("/foo")
756        ));
757        // But an escaped version must be owned
758        assert!(matches!(
759            unescape_to_path_canonical(r#"/\x66oo"#).unwrap(),
760            Cow::Owned(v) if v.to_str() == Some("/foo")
761        ));
762        // Test successful normalization
763        assert_eq!(
764            unescape_to_path_canonical("///foo/bar//baz")
765                .unwrap()
766                .to_str()
767                .unwrap(),
768            "/foo/bar/baz"
769        );
770        assert_eq!(
771            unescape_to_path_canonical("/.").unwrap().to_str().unwrap(),
772            "/"
773        );
774    }
775
776    #[test]
777    fn test_xattr() {
778        let v = Xattr::parse("foo=bar").unwrap();
779        similar_asserts::assert_eq!(v.key.as_bytes(), b"foo");
780        similar_asserts::assert_eq!(&*v.value, b"bar");
781        // Invalid embedded NUL in keys
782        assert!(Xattr::parse("foo\0bar=baz").is_err());
783        assert!(Xattr::parse("foo\x00bar=baz").is_err());
784        // But embedded NUL in values is OK
785        let v = Xattr::parse("security.selinux=bar\x00").unwrap();
786        similar_asserts::assert_eq!(v.key.as_bytes(), b"security.selinux");
787        similar_asserts::assert_eq!(&*v.value, b"bar\0");
788    }
789
790    #[test]
791    fn long_xattrs() {
792        let mut s = String::from("/file 0 100755 1 0 0 0 0.0 00/26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae - -");
793        Entry::parse(&s).unwrap();
794        let xattrs_to_fill = XATTR_LIST_MAX / XATTR_NAME_MAX;
795        let xattr_name_remainder = XATTR_LIST_MAX % XATTR_NAME_MAX;
796        assert_eq!(xattr_name_remainder, 0);
797        let uniqueidlen = 8u8;
798        let xattr_prefix_len = XATTR_NAME_MAX.checked_sub(uniqueidlen.into()).unwrap();
799        let push_long_xattr = |s: &mut String, n| {
800            s.push(' ');
801            for _ in 0..xattr_prefix_len {
802                s.push('a');
803            }
804            write!(s, "{n:08x}=x").unwrap();
805        };
806        for i in 0..xattrs_to_fill {
807            push_long_xattr(&mut s, i);
808        }
809        Entry::parse(&s).unwrap();
810        push_long_xattr(&mut s, xattrs_to_fill);
811        assert!(Entry::parse(&s).is_err());
812    }
813
814    #[test]
815    fn test_parse() {
816        const CONTENT: &str = include_str!("tests/assets/special.dump");
817        for line in CONTENT.lines() {
818            // Test a full round trip by parsing, serialize, parsing again
819            let e = Entry::parse(line).unwrap();
820            let serialized = e.to_string();
821            if line != serialized {
822                dbg!(&line, &e, &serialized);
823            }
824            similar_asserts::assert_eq!(line, serialized);
825            let e2 = Entry::parse(&serialized).unwrap();
826            similar_asserts::assert_eq!(e, e2);
827        }
828    }
829
830    fn parse_all(name: &str, s: &str) -> Result<()> {
831        for line in s.lines() {
832            if line.is_empty() {
833                continue;
834            }
835            let _: Entry =
836                Entry::parse(line).with_context(|| format!("Test case={name:?} line={line:?}"))?;
837        }
838        Ok(())
839    }
840
841    #[test]
842    fn test_should_fail() {
843        const CASES: &[(&str, &str)] = &[
844            (
845                "content in fifo",
846                "/ 4096 40755 2 0 0 0 0.0 - - -\n/fifo 0 10777 1 0 0 0 0.0 - foobar -",
847            ),
848            ("root with rdev", "/ 4096 40755 2 0 0 42 0.0 - - -"),
849            ("root with fsverity", "/ 4096 40755 2 0 0 0 0.0 - - 35d02f81325122d77ec1d11baba655bc9bf8a891ab26119a41c50fa03ddfb408"),
850        ];
851        for (name, case) in CASES.iter().copied() {
852            assert!(
853                parse_all(name, case).is_err(),
854                "Expected case {name} to fail"
855            );
856        }
857    }
858
859    #[test_with::executable(mkcomposefs)]
860    #[test]
861    fn test_load_cfs() -> Result<()> {
862        let mut tmpf = tempfile::tempfile()?;
863        mkcomposefs(SPECIAL_DUMP, &mut tmpf).unwrap();
864        let mut entries = String::new();
865        tmpf.seek(std::io::SeekFrom::Start(0))?;
866        dump(tmpf, DumpConfig::default(), |e| {
867            writeln!(entries, "{e}")?;
868            Ok(())
869        })
870        .unwrap();
871        similar_asserts::assert_eq!(SPECIAL_DUMP, &entries);
872        Ok(())
873    }
874
875    #[test_with::executable(mkcomposefs)]
876    #[test]
877    fn test_load_cfs_filtered() -> Result<()> {
878        const FILTERED: &str =
879            "/ 4096 40555 2 0 0 0 1633950376.0 - - - trusted.foo1=bar-1 user.foo2=bar-2\n\
880/blockdev 0 60777 1 0 0 107690 1633950376.0 - - - trusted.bar=bar-2\n\
881/inline 15 100777 1 0 0 0 1633950376.0 - FOOBAR\\nINAFILE\\n - user.foo=bar-2\n";
882        let mut tmpf = tempfile::tempfile()?;
883        mkcomposefs(SPECIAL_DUMP, &mut tmpf).unwrap();
884        let mut entries = String::new();
885        tmpf.seek(std::io::SeekFrom::Start(0))?;
886        let filter = DumpConfig {
887            filters: Some(&["blockdev", "inline"]),
888        };
889        dump(tmpf, filter, |e| {
890            writeln!(entries, "{e}")?;
891            Ok(())
892        })
893        .unwrap();
894        assert_eq!(FILTERED, &entries);
895        Ok(())
896    }
897}