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!(
332                "xattr name too long; max={} found={}",
333                XATTR_NAME_MAX,
334                keylen
335            );
336        }
337        let value = unescape(value)?;
338        let valuelen = value.len();
339        if valuelen > XATTR_SIZE_MAX {
340            anyhow::bail!(
341                "xattr value too long; max={} found={}",
342                XATTR_SIZE_MAX,
343                keylen
344            );
345        }
346        Ok(Self { key, value })
347    }
348}
349
350impl<'p> Entry<'p> {
351    fn check_nonregfile(content: Option<&str>, fsverity_digest: Option<&str>) -> Result<()> {
352        if content.is_some() {
353            anyhow::bail!("entry cannot have content");
354        }
355        if fsverity_digest.is_some() {
356            anyhow::bail!("entry cannot have fsverity digest");
357        }
358        Ok(())
359    }
360
361    fn check_rdev(rdev: u64) -> Result<()> {
362        if rdev != 0 {
363            anyhow::bail!("entry cannot have device (rdev) {rdev}");
364        }
365        Ok(())
366    }
367
368    /// Parse an entry from a composefs dump file line.
369    pub fn parse(s: &'p str) -> Result<Entry<'p>> {
370        let mut components = s.split(' ');
371        let mut next = |name: &str| components.next().ok_or_else(|| anyhow!("Missing {name}"));
372        let path = unescape_to_path_canonical(next("path")?)?;
373        let size = u64::from_str(next("size")?)?;
374        let modeval = next("mode")?;
375        let (is_hardlink, mode) = if let Some((_, rest)) = modeval.split_once('@') {
376            (true, u32::from_str_radix(rest, 8)?)
377        } else {
378            (false, u32::from_str_radix(modeval, 8)?)
379        };
380        let nlink = u32::from_str(next("nlink")?)?;
381        let uid = u32::from_str(next("uid")?)?;
382        let gid = u32::from_str(next("gid")?)?;
383        let rdev = u64::from_str(next("rdev")?)?;
384        let mtime = Mtime::from_str(next("mtime")?)?;
385        let payload = optional_str(next("payload")?);
386        let content = optional_str(next("content")?);
387        let fsverity_digest = optional_str(next("digest")?);
388        let xattrs = components
389            .try_fold((Vec::new(), 0usize), |(mut acc, total_namelen), line| {
390                let xattr = Xattr::parse(line)?;
391                // Limit the total length of keys.
392                let total_namelen = total_namelen.saturating_add(xattr.key.len());
393                if total_namelen > XATTR_LIST_MAX {
394                    anyhow::bail!("Too many xattrs");
395                }
396                acc.push(xattr);
397                Ok((acc, total_namelen))
398            })?
399            .0;
400
401        let ty = FileType::from_raw_mode(mode);
402        let item = if is_hardlink {
403            if ty == FileType::Directory {
404                anyhow::bail!("Invalid hardlinked directory");
405            }
406            let target =
407                unescape_to_path_canonical(payload.ok_or_else(|| anyhow!("Missing payload"))?)?;
408            // TODO: the dumpfile format suggests to retain all the metadata on hardlink lines
409            Item::Hardlink { target }
410        } else {
411            match ty {
412                FileType::RegularFile => {
413                    Self::check_rdev(rdev)?;
414                    if let Some(path) = payload.as_ref() {
415                        let path = unescape_to_path(path)?;
416                        Item::Regular {
417                            size,
418                            nlink,
419                            path,
420                            fsverity_digest: fsverity_digest.map(ToOwned::to_owned),
421                        }
422                    } else {
423                        // A dumpfile entry with no backing path or payload is treated as an empty file
424                        let content = content.unwrap_or_default();
425                        let content = unescape_limited(content, MAX_INLINE_CONTENT.into())?;
426                        if fsverity_digest.is_some() {
427                            anyhow::bail!("Inline file cannot have fsverity digest");
428                        }
429                        Item::RegularInline { nlink, content }
430                    }
431                }
432                FileType::Symlink => {
433                    Self::check_nonregfile(content, fsverity_digest)?;
434                    Self::check_rdev(rdev)?;
435
436                    // Note that the target of *symlinks* is not required to be in canonical form,
437                    // as we don't actually traverse those links on our own, and we need to support
438                    // symlinks that e.g. contain `//` or other things.
439                    let target =
440                        unescape_to_path(payload.ok_or_else(|| anyhow!("Missing payload"))?)?;
441                    let targetlen = target.as_os_str().as_bytes().len();
442                    if targetlen > PATH_MAX as usize {
443                        anyhow::bail!("Target length too large {}", targetlen);
444                    }
445                    Item::Symlink { nlink, target }
446                }
447                FileType::Fifo => {
448                    Self::check_nonregfile(content, fsverity_digest)?;
449                    Self::check_rdev(rdev)?;
450
451                    Item::Fifo { nlink }
452                }
453                FileType::CharacterDevice | FileType::BlockDevice => {
454                    Self::check_nonregfile(content, fsverity_digest)?;
455                    Item::Device { nlink, rdev }
456                }
457                FileType::Directory => {
458                    Self::check_nonregfile(content, fsverity_digest)?;
459                    Self::check_rdev(rdev)?;
460
461                    Item::Directory { size, nlink }
462                }
463                FileType::Socket => {
464                    anyhow::bail!("sockets are not supported");
465                }
466                FileType::Unknown => {
467                    anyhow::bail!("Unhandled file type from raw mode: {mode}")
468                }
469            }
470        };
471        Ok(Entry {
472            path,
473            uid,
474            gid,
475            mode,
476            mtime,
477            item,
478            xattrs,
479        })
480    }
481
482    /// Remove internal entries
483    /// FIXME: This is arguably a composefs-info dump bug?
484    pub fn filter_special(mut self) -> Self {
485        self.xattrs.retain(|v| {
486            !matches!(
487                (v.key.as_bytes(), &*v.value),
488                (b"trusted.overlay.opaque" | b"user.overlay.opaque", b"x")
489            )
490        });
491        self
492    }
493}
494
495impl Item<'_> {
496    pub(crate) fn size(&self) -> u64 {
497        match self {
498            Item::Regular { size, .. } | Item::Directory { size, .. } => *size,
499            Item::RegularInline { content, .. } => content.len() as u64,
500            _ => 0,
501        }
502    }
503
504    pub(crate) fn nlink(&self) -> u32 {
505        match self {
506            Item::RegularInline { nlink, .. } => *nlink,
507            Item::Regular { nlink, .. } => *nlink,
508            Item::Device { nlink, .. } => *nlink,
509            Item::Symlink { nlink, .. } => *nlink,
510            Item::Directory { nlink, .. } => *nlink,
511            Item::Fifo { nlink, .. } => *nlink,
512            _ => 0,
513        }
514    }
515
516    pub(crate) fn rdev(&self) -> u64 {
517        match self {
518            Item::Device { rdev, .. } => *rdev,
519            _ => 0,
520        }
521    }
522
523    pub(crate) fn payload(&self) -> Option<&Path> {
524        match self {
525            Item::Regular { path, .. } => Some(path),
526            Item::Symlink { target, .. } => Some(target),
527            Item::Hardlink { target } => Some(target),
528            _ => None,
529        }
530    }
531}
532
533impl Display for Mtime {
534    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
535        write!(f, "{}.{}", self.sec, self.nsec)
536    }
537}
538
539impl Display for Entry<'_> {
540    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
541        escape(f, self.path.as_os_str().as_bytes(), EscapeMode::Standard)?;
542        write!(
543            f,
544            " {} {:o} {} {} {} {} {} ",
545            self.item.size(),
546            self.mode,
547            self.item.nlink(),
548            self.uid,
549            self.gid,
550            self.item.rdev(),
551            self.mtime,
552        )?;
553        // Payload is written for non-inline files, hardlinks and symlinks
554        if let Some(payload) = self.item.payload() {
555            escape(f, payload.as_os_str().as_bytes(), EscapeMode::Standard)?;
556            f.write_char(' ')?;
557        } else {
558            write!(f, "- ")?;
559        }
560        match &self.item {
561            Item::RegularInline { content, .. } => {
562                escape(f, content, EscapeMode::Standard)?;
563                write!(f, " -")?;
564            }
565            Item::Regular {
566                fsverity_digest, ..
567            } => {
568                let fsverity_digest = fsverity_digest.as_deref().unwrap_or("-");
569                write!(f, "- {fsverity_digest}")?;
570            }
571            _ => {
572                write!(f, "- -")?;
573            }
574        }
575        for xattr in self.xattrs.iter() {
576            f.write_char(' ')?;
577            escape(f, xattr.key.as_bytes(), EscapeMode::XattrKey)?;
578            f.write_char('=')?;
579            escape(f, &xattr.value, EscapeMode::Standard)?;
580        }
581        std::fmt::Result::Ok(())
582    }
583}
584
585/// Configuration for parsing a dumpfile
586#[derive(Debug, Default)]
587pub struct DumpConfig<'a> {
588    /// Only dump these toplevel filenames
589    pub filters: Option<&'a [&'a str]>,
590}
591
592/// Parse the provided composefs into dumpfile entries.
593pub fn dump<F>(input: File, config: DumpConfig, mut handler: F) -> Result<()>
594where
595    F: FnMut(Entry<'_>) -> Result<()> + Send,
596{
597    let mut proc = Command::new("composefs-info");
598    proc.arg("dump");
599    if let Some(filter) = config.filters {
600        proc.args(filter.iter().flat_map(|f| ["--filter", f]));
601    }
602    proc.args(["/dev/stdin"])
603        .stdin(std::process::Stdio::from(input))
604        .stderr(std::process::Stdio::piped())
605        .stdout(std::process::Stdio::piped());
606    let mut proc = proc.spawn().context("Spawning composefs-info")?;
607
608    // SAFETY: we set up these streams
609    let child_stdout = proc.stdout.take().unwrap();
610    let child_stderr = proc.stderr.take().unwrap();
611
612    std::thread::scope(|s| {
613        let stderr_copier = s.spawn(move || {
614            let mut child_stderr = std::io::BufReader::new(child_stderr);
615            let mut buf = Vec::new();
616            std::io::copy(&mut child_stderr, &mut buf)?;
617            anyhow::Ok(buf)
618        });
619
620        let child_stdout = std::io::BufReader::new(child_stdout);
621        for line in child_stdout.lines() {
622            let line = line.context("Reading dump stdout")?;
623            let entry = Entry::parse(&line)?.filter_special();
624            handler(entry)?;
625        }
626
627        let r = proc.wait()?;
628        let stderr = stderr_copier.join().unwrap()?;
629        if !r.success() {
630            let stderr = String::from_utf8_lossy(&stderr);
631            let stderr = stderr.trim();
632            anyhow::bail!("composefs-info dump failed: {r}: {stderr}")
633        }
634
635        Ok(())
636    })
637}
638
639#[cfg(test)]
640mod tests {
641    use std::{
642        fs::File,
643        io::{BufWriter, Seek},
644        process::Stdio,
645    };
646
647    use super::*;
648
649    const SPECIAL_DUMP: &str = include_str!("tests/assets/special.dump");
650    const SPECIALS: &[&str] = &["foo=bar=baz", r"\x01\x02", "-"];
651    const UNQUOTED: &[&str] = &["foo!bar", "hello-world", "--"];
652
653    fn mkcomposefs(dumpfile: &str, out: &mut File) -> Result<()> {
654        let mut tf = tempfile::tempfile().map(BufWriter::new)?;
655        tf.write_all(dumpfile.as_bytes())?;
656        let mut tf = tf.into_inner()?;
657        tf.seek(std::io::SeekFrom::Start(0))?;
658        let mut mkcomposefs = Command::new("mkcomposefs")
659            .args(["--from-file", "-", "-"])
660            .stdin(Stdio::from(tf))
661            .stdout(Stdio::from(out.try_clone()?))
662            .stderr(Stdio::inherit())
663            .spawn()?;
664
665        let st = mkcomposefs.wait()?;
666        if !st.success() {
667            anyhow::bail!("mkcomposefs failed: {st}");
668        };
669
670        Ok(())
671    }
672
673    #[test]
674    fn test_escape_specials() {
675        let cases = [("", "-"), ("-", r"\x2d")];
676        for (source, expected) in cases {
677            let mut buf = String::new();
678            escape(&mut buf, source.as_bytes(), EscapeMode::Standard).unwrap();
679            assert_eq!(&buf, expected);
680        }
681    }
682
683    #[test]
684    fn test_escape_roundtrip() {
685        let cases = SPECIALS.iter().chain(UNQUOTED);
686        for case in cases {
687            let mut buf = String::new();
688            escape(&mut buf, case.as_bytes(), EscapeMode::Standard).unwrap();
689            let case2 = unescape(&buf).unwrap();
690            assert_eq!(case, &String::from_utf8(case2.into()).unwrap());
691        }
692    }
693
694    #[test]
695    fn test_escape_unquoted() {
696        let cases = UNQUOTED;
697        for case in cases {
698            let mut buf = String::new();
699            escape(&mut buf, case.as_bytes(), EscapeMode::Standard).unwrap();
700            assert_eq!(case, &buf);
701        }
702    }
703
704    #[test]
705    fn test_escape_quoted() {
706        // We don't escape `=` in standard mode
707        {
708            let mut buf = String::new();
709            escape(&mut buf, b"=", EscapeMode::Standard).unwrap();
710            assert_eq!(buf, "=");
711        }
712        // Verify other special cases
713        let cases = &[("=", r"\x3d"), ("-", r"\x2d")];
714        for (src, expected) in cases {
715            let mut buf = String::new();
716            escape(&mut buf, src.as_bytes(), EscapeMode::XattrKey).unwrap();
717            assert_eq!(expected, &buf);
718        }
719    }
720
721    #[test]
722    fn test_unescape() {
723        assert_eq!(unescape("").unwrap().len(), 0);
724        assert_eq!(unescape_limited("", 0).unwrap().len(), 0);
725        assert!(unescape_limited("foobar", 3).is_err());
726        // This is borrowed input
727        assert!(matches!(
728            unescape_limited("foobar", 6).unwrap(),
729            Cow::Borrowed(_)
730        ));
731        // But non-ASCII is currently owned out of conservatism
732        assert!(matches!(unescape_limited("→", 6).unwrap(), Cow::Owned(_)));
733        assert!(unescape_limited("foo→bar", 3).is_err());
734    }
735
736    #[test]
737    fn test_unescape_path() {
738        // Empty
739        assert!(unescape_to_path("").is_err());
740        // Embedded NUL
741        assert!(unescape_to_path("\0").is_err());
742        assert!(unescape_to_path("foo\0bar").is_err());
743        assert!(unescape_to_path("\0foobar").is_err());
744        assert!(unescape_to_path("foobar\0").is_err());
745        assert!(unescape_to_path("foo\\x00bar").is_err());
746        let mut p = "a".repeat(PATH_MAX.try_into().unwrap());
747        assert!(unescape_to_path(&p).is_ok());
748        p.push('a');
749        assert!(unescape_to_path(&p).is_err());
750    }
751
752    #[test]
753    fn test_unescape_path_canonical() {
754        // Invalid cases
755        assert!(unescape_to_path_canonical("").is_err());
756        assert!(unescape_to_path_canonical("foo").is_err());
757        assert!(unescape_to_path_canonical("../blah").is_err());
758        assert!(unescape_to_path_canonical("/foo/..").is_err());
759        assert!(unescape_to_path_canonical("/foo/../blah").is_err());
760        // Verify that we return borrowed input where possible
761        assert!(matches!(
762            unescape_to_path_canonical("/foo").unwrap(),
763            Cow::Borrowed(v) if v.to_str() == Some("/foo")
764        ));
765        // But an escaped version must be owned
766        assert!(matches!(
767            unescape_to_path_canonical(r#"/\x66oo"#).unwrap(),
768            Cow::Owned(v) if v.to_str() == Some("/foo")
769        ));
770        // Test successful normalization
771        assert_eq!(
772            unescape_to_path_canonical("///foo/bar//baz")
773                .unwrap()
774                .to_str()
775                .unwrap(),
776            "/foo/bar/baz"
777        );
778        assert_eq!(
779            unescape_to_path_canonical("/.").unwrap().to_str().unwrap(),
780            "/"
781        );
782    }
783
784    #[test]
785    fn test_xattr() {
786        let v = Xattr::parse("foo=bar").unwrap();
787        similar_asserts::assert_eq!(v.key.as_bytes(), b"foo");
788        similar_asserts::assert_eq!(&*v.value, b"bar");
789        // Invalid embedded NUL in keys
790        assert!(Xattr::parse("foo\0bar=baz").is_err());
791        assert!(Xattr::parse("foo\x00bar=baz").is_err());
792        // But embedded NUL in values is OK
793        let v = Xattr::parse("security.selinux=bar\x00").unwrap();
794        similar_asserts::assert_eq!(v.key.as_bytes(), b"security.selinux");
795        similar_asserts::assert_eq!(&*v.value, b"bar\0");
796    }
797
798    #[test]
799    fn long_xattrs() {
800        let mut s = String::from("/file 0 100755 1 0 0 0 0.0 00/26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae - -");
801        Entry::parse(&s).unwrap();
802        let xattrs_to_fill = XATTR_LIST_MAX / XATTR_NAME_MAX;
803        let xattr_name_remainder = XATTR_LIST_MAX % XATTR_NAME_MAX;
804        assert_eq!(xattr_name_remainder, 0);
805        let uniqueidlen = 8u8;
806        let xattr_prefix_len = XATTR_NAME_MAX.checked_sub(uniqueidlen.into()).unwrap();
807        let push_long_xattr = |s: &mut String, n| {
808            s.push(' ');
809            for _ in 0..xattr_prefix_len {
810                s.push('a');
811            }
812            write!(s, "{n:08x}=x").unwrap();
813        };
814        for i in 0..xattrs_to_fill {
815            push_long_xattr(&mut s, i);
816        }
817        Entry::parse(&s).unwrap();
818        push_long_xattr(&mut s, xattrs_to_fill);
819        assert!(Entry::parse(&s).is_err());
820    }
821
822    #[test]
823    fn test_parse() {
824        const CONTENT: &str = include_str!("tests/assets/special.dump");
825        for line in CONTENT.lines() {
826            // Test a full round trip by parsing, serialize, parsing again
827            let e = Entry::parse(line).unwrap();
828            let serialized = e.to_string();
829            if line != serialized {
830                dbg!(&line, &e, &serialized);
831            }
832            similar_asserts::assert_eq!(line, serialized);
833            let e2 = Entry::parse(&serialized).unwrap();
834            similar_asserts::assert_eq!(e, e2);
835        }
836    }
837
838    fn parse_all(name: &str, s: &str) -> Result<()> {
839        for line in s.lines() {
840            if line.is_empty() {
841                continue;
842            }
843            let _: Entry =
844                Entry::parse(line).with_context(|| format!("Test case={name:?} line={line:?}"))?;
845        }
846        Ok(())
847    }
848
849    #[test]
850    fn test_should_fail() {
851        const CASES: &[(&str, &str)] = &[
852            (
853                "content in fifo",
854                "/ 4096 40755 2 0 0 0 0.0 - - -\n/fifo 0 10777 1 0 0 0 0.0 - foobar -",
855            ),
856            ("root with rdev", "/ 4096 40755 2 0 0 42 0.0 - - -"),
857            ("root with fsverity", "/ 4096 40755 2 0 0 0 0.0 - - 35d02f81325122d77ec1d11baba655bc9bf8a891ab26119a41c50fa03ddfb408"),
858        ];
859        for (name, case) in CASES.iter().copied() {
860            assert!(
861                parse_all(name, case).is_err(),
862                "Expected case {name} to fail"
863            );
864        }
865    }
866
867    #[test_with::executable(mkcomposefs)]
868    #[test]
869    fn test_load_cfs() -> Result<()> {
870        let mut tmpf = tempfile::tempfile()?;
871        mkcomposefs(SPECIAL_DUMP, &mut tmpf).unwrap();
872        let mut entries = String::new();
873        tmpf.seek(std::io::SeekFrom::Start(0))?;
874        dump(tmpf, DumpConfig::default(), |e| {
875            writeln!(entries, "{e}")?;
876            Ok(())
877        })
878        .unwrap();
879        similar_asserts::assert_eq!(SPECIAL_DUMP, &entries);
880        Ok(())
881    }
882
883    #[test_with::executable(mkcomposefs)]
884    #[test]
885    fn test_load_cfs_filtered() -> Result<()> {
886        const FILTERED: &str =
887            "/ 4096 40555 2 0 0 0 1633950376.0 - - - trusted.foo1=bar-1 user.foo2=bar-2\n\
888/blockdev 0 60777 1 0 0 107690 1633950376.0 - - - trusted.bar=bar-2\n\
889/inline 15 100777 1 0 0 0 1633950376.0 - FOOBAR\\nINAFILE\\n - user.foo=bar-2\n";
890        let mut tmpf = tempfile::tempfile()?;
891        mkcomposefs(SPECIAL_DUMP, &mut tmpf).unwrap();
892        let mut entries = String::new();
893        tmpf.seek(std::io::SeekFrom::Start(0))?;
894        let filter = DumpConfig {
895            filters: Some(&["blockdev", "inline"]),
896        };
897        dump(tmpf, filter, |e| {
898            writeln!(entries, "{e}")?;
899            Ok(())
900        })
901        .unwrap();
902        assert_eq!(FILTERED, &entries);
903        Ok(())
904    }
905}