Skip to main content

pkgsrc/
plist.rs

1/*
2 * Copyright (c) 2026 Jonathan Perkin <jonathan@perkin.org.uk>
3 *
4 * Permission to use, copy, modify, and distribute this software for any
5 * purpose with or without fee is hereby granted, provided that the above
6 * copyright notice and this permission notice appear in all copies.
7 *
8 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
9 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
10 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
11 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
12 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
13 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
14 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
15 */
16
17/*!
18 * Packing list parsing.
19 *
20 * Packing lists, commonly referred to as plists and named `PLIST` in pkgsrc
21 * package directories, contain a list of files installed by a package.  They
22 * also support a limited number of commands that configure additional package
23 * metadata, as well as setting file permissions and performing install and
24 * deinstall commands for extracted files.
25 *
26 * A [`PlistEntry`] is an enum representing a single line in a plist, and a
27 * [`Plist`] is a collection of [`PlistEntry`] making up a complete plist.
28 * Once a [`Plist`] has been parsed, various functions allow examination of
29 * the parsed data.
30 *
31 * As plists can contain data that is not UTF-8 clean (for example ISO-8859
32 * filenames), the primary interfaces for parsing input are byte oriented.
33 *
34 * Two parser styles are available:
35 *
36 * * [`Plist::from_bytes`] parses the entire byte slice eagerly into an
37 *   owning [`Plist`], a `Vec<PlistEntry<'static>>` with named query methods.
38 *   Use this when you want to interrogate the plist multiple times.
39 *
40 * * [`parse`] returns a lazy iterator of [`PlistEntry<'_>`] borrowing
41 *   directly from the source bytes.  Use this when you only need to walk
42 *   the plist once: it avoids per-entry allocation.
43 *
44 * ## Examples
45 *
46 * Initialize a basic PLIST.  Blank lines are ignored, and only used here for
47 * clarity.
48 *
49 * ```
50 * use pkgsrc::plist::{Plist, Result};
51 * use indoc::indoc;
52 *
53 * fn main() -> Result<()> {
54 *     let input = indoc! {"
55 *         @comment $NetBSD$
56 *
57 *         @name pkgtest-1.0
58 *         @pkgdep dep-pkg1-[0-9]*
59 *         @pkgdep dep-pkg2>=2.0
60 *         @blddep dep-pkg1-1.0nb2
61 *         @blddep dep-pkg2-2.0nb4
62 *         @pkgcfl cfl-pkg1<2.0
63 *
64 *         @display MESSAGE
65 *
66 *         @cwd /opt/pkg
67 *
68 *         @comment bin/foo installed with specific permissions, preserved
69 *         @comment on uninstall (obsolete feature?), and commands are executed
70 *         @comment after it is installed and deleted.
71 *
72 *         @option preserve
73 *         @mode 0644
74 *         @owner root
75 *         @group wheel
76 *         bin/foo
77 *         @exec echo \"I just installed F=%F D=%D B=%B f=%f\"
78 *         @unexec echo \"I just deleted F=%F D=%D B=%B f=%f\"
79 *
80 *         @comment bin/bar just installed with default permissions
81 *
82 *         @mode
83 *         @owner
84 *         @group
85 *         bin/bar
86 *
87 *         @pkgdir /opt/pkg/share/junk
88 *         @dirrm /opt/pkg/share/obsolete-option
89 *
90 *         @ignore
91 *         +BUILD_INFO
92 *     "};
93 *
94 *      let pkglist = Plist::from_bytes(input.as_bytes())?;
95 *
96 *      assert_eq!(pkglist.pkgname(), Some("pkgtest-1.0"));
97 *      assert_eq!(pkglist.depends().count(), 2);
98 *      assert_eq!(pkglist.build_depends().count(), 2);
99 *      assert_eq!(pkglist.conflicts().count(), 1);
100 *      assert_eq!(pkglist.pkgdirs().count(), 1);
101 *      assert_eq!(pkglist.pkgrmdirs().count(), 1);
102 *
103 *      Ok(())
104 * }
105 * ```
106 *
107 * [`Plist`] implements [`IntoIterator`], allowing direct iteration over entries:
108 *
109 * ```
110 * use pkgsrc::plist::{Plist, PlistEntry, Result};
111 *
112 * fn main() -> Result<()> {
113 *     let plist = Plist::from_bytes(b"@name pkg-1.0\nbin/foo\nbin/bar")?;
114 *
115 *     for entry in &plist {
116 *         if let PlistEntry::File(path) = entry {
117 *             println!("File: {}", path.display());
118 *         }
119 *     }
120 *
121 *     Ok(())
122 * }
123 * ```
124 */
125use std::borrow::Cow;
126use std::ffi::{OsStr, OsString};
127use std::os::unix::ffi::OsStrExt;
128use std::path::{Path, PathBuf};
129use std::str::Utf8Error;
130use thiserror::Error;
131
132#[cfg(test)]
133use indoc::indoc;
134
135/**
136 * A type alias for the result from the creation of either a [`PlistEntry`] or
137 * a [`Plist`], with [`PlistError`] returned in [`Err`] variants.
138 */
139pub type Result<T> = std::result::Result<T, PlistError>;
140
141/**
142 * Error type containing possible parse failures.
143 */
144#[derive(Debug, Error)]
145pub enum PlistError {
146    /**
147     * An unsupported `@command` string, or an unsupported argument to a command
148     * that requires specific values (for example `@option preserve`).
149     */
150    #[error("unsupported plist command: {cmd}", cmd = .0.to_string_lossy())]
151    UnsupportedCommand(OsString),
152    /**
153     * Incorrect number of arguments, or incorrect argument passed to a command
154     * that requires a specific format.
155     */
156    #[error("incorrect command arguments: {args}", args = .0.to_string_lossy())]
157    IncorrectArguments(OsString),
158    /**
159     * Wrapped [`Utf8Error`] when failing to parse valid UTF-8.
160     */
161    #[error("invalid UTF-8 sequence: {0}")]
162    Utf8(#[from] Utf8Error),
163}
164
165/**
166 * A single plist entry.
167 *
168 * Entries can be constructed either by using [`PlistEntry::from_bytes`] to
169 * parse an array of bytes from a plist, or by constructing one of the
170 * variants manually.
171 *
172 * ## Examples
173 *
174 * ```
175 * use pkgsrc::plist::{PlistEntry, Result};
176 * use std::borrow::Cow;
177 * use std::ffi::OsStr;
178 *
179 * fn main() -> Result<()> {
180 *     let p1 = PlistEntry::from_bytes(b"@comment hi")?;
181 *     let p2 = PlistEntry::Comment(Some(Cow::Borrowed(OsStr::new("hi"))));
182 *     assert_eq!(p1, p2);
183 *     Ok(())
184 * }
185 * ```
186 */
187#[derive(Clone, Debug, Eq, Hash, PartialEq)]
188#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
189pub enum PlistEntry<'a> {
190    /**
191     * Filename to extract relative to the current working directory.
192     */
193    File(Cow<'a, Path>),
194    /**
195     * Set the internal directory pointer.  All subsequent filenames will be
196     * assumed relative to this directory.
197     */
198    Cwd(Cow<'a, Path>),
199    /**
200     * Execute command as part of the unpacking process.
201     */
202    Exec(Cow<'a, OsStr>),
203    /**
204     * Execute command as part of the deinstallation process.
205     */
206    UnExec(Cow<'a, OsStr>),
207    /**
208     * Set default permission for all subsequently extracted files.
209     */
210    Mode(Option<Cow<'a, str>>),
211    /**
212     * Set internal package options.  Named PkgOpt to avoid conflict with
213     * Rust "Option".
214     */
215    PkgOpt(PlistOption),
216    /**
217     * Set default ownership for all subsequently extracted files to specified
218     * user.
219     */
220    Owner(Option<Cow<'a, str>>),
221    /**
222     * Set default group ownership for all subsequently extracted files to
223     * specified group.
224     */
225    Group(Option<Cow<'a, str>>),
226    /**
227     * Embed a comment in the packing list.  While specified as mandatory in
228     * the manual page, in practise it is not (e.g. `print-PLIST`).
229     */
230    Comment(Option<Cow<'a, OsStr>>),
231    /**
232     * Used internally to tell extraction to ignore the next file.
233     */
234    Ignore,
235    /**
236     * Set the name of the package.
237     */
238    Name(Cow<'a, str>),
239    /**
240     * Declare directory name as managed.
241     */
242    PkgDir(Cow<'a, Path>),
243    /**
244     * If directory name exists, it will be deleted at deinstall time.
245     */
246    DirRm(Cow<'a, Path>),
247    /**
248     * Declare name as the file to be displayed at install time.
249     */
250    Display(Cow<'a, Path>),
251    /**
252     * Declare a dependency on the pkgname package.
253     */
254    PkgDep(Cow<'a, str>),
255    /**
256     * Declare that this package was built with the exact version of pkgname.
257     */
258    BldDep(Cow<'a, str>),
259    /**
260     * Declare a conflict with the pkgcflname package.
261     */
262    PkgCfl(Cow<'a, str>),
263    /**
264     * MD5 checksum of the preceding file entry.
265     * Parsed from `@comment MD5:<32-char-hex>`.
266     */
267    FileChecksum(Cow<'a, str>),
268    /**
269     * Symlink target for the preceding file entry.
270     * Parsed from `@comment Symlink:<target>`.
271     */
272    SymlinkTarget(Cow<'a, Path>),
273}
274
275/**
276 * List of valid arguments for the `@option` command.  Currently the only
277 * supported argument is `preserve`.
278 */
279#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
280#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
281pub enum PlistOption {
282    /**
283     * Indicates that any existing files should be moved out of the way before
284     * the package contents are install (and subsequently restored when the
285     * contents are uninstalled).
286     */
287    Preserve,
288}
289
290impl<'a> PlistEntry<'a> {
291    /**
292     * Construct a new [`PlistEntry`] from a stream of bytes representing a
293     * line from a package list.  Validates UTF-8 for variants that are
294     * semantically `String`.
295     */
296    pub fn from_bytes(bytes: &'a [u8]) -> Result<Self> {
297        parse_line(bytes)
298    }
299
300    /**
301     * Convert into a `'static` (fully owned) entry by cloning any borrowed
302     * payloads.
303     */
304    #[must_use]
305    pub fn into_owned(self) -> PlistEntry<'static> {
306        use PlistEntry as P;
307        match self {
308            P::File(p) => P::File(own(p)),
309            P::Cwd(p) => P::Cwd(own(p)),
310            P::Exec(o) => P::Exec(own(o)),
311            P::UnExec(o) => P::UnExec(own(o)),
312            P::Mode(s) => P::Mode(own_opt(s)),
313            P::PkgOpt(o) => P::PkgOpt(o),
314            P::Owner(s) => P::Owner(own_opt(s)),
315            P::Group(s) => P::Group(own_opt(s)),
316            P::Comment(o) => P::Comment(own_opt(o)),
317            P::Ignore => P::Ignore,
318            P::Name(s) => P::Name(own(s)),
319            P::PkgDir(p) => P::PkgDir(own(p)),
320            P::DirRm(p) => P::DirRm(own(p)),
321            P::Display(p) => P::Display(own(p)),
322            P::PkgDep(s) => P::PkgDep(own(s)),
323            P::BldDep(s) => P::BldDep(own(s)),
324            P::PkgCfl(s) => P::PkgCfl(own(s)),
325            P::FileChecksum(s) => P::FileChecksum(own(s)),
326            P::SymlinkTarget(p) => P::SymlinkTarget(own(p)),
327        }
328    }
329}
330
331/**
332 * A lazy iterator over a plist's entries, borrowing from the source bytes.
333 *
334 * Returned by [`parse`].  Yields `Result<PlistEntry<'_>>`: each item is a
335 * parsed entry or a [`PlistError`] for that line.  Blank and
336 * whitespace-only lines are skipped.  UTF-8 validation is performed
337 * inline for variants whose payloads are typed as [`Cow<str>`] (see
338 * [`PlistEntry`]); a bad-UTF-8 payload on those variants yields a
339 * [`PlistError::Utf8`].
340 */
341#[derive(Clone, Debug)]
342pub struct Parser<'a> {
343    rest: &'a [u8],
344}
345
346impl<'a> Iterator for Parser<'a> {
347    type Item = Result<PlistEntry<'a>>;
348
349    fn next(&mut self) -> Option<Self::Item> {
350        loop {
351            let line = next_line(&mut self.rest)?;
352            if line.iter().all(u8::is_ascii_whitespace) {
353                continue;
354            }
355            return Some(parse_line(line));
356        }
357    }
358}
359
360impl std::iter::FusedIterator for Parser<'_> {}
361
362/**
363 * Lazily parse `bytes` into a stream of [`PlistEntry`] values.
364 *
365 * Payloads borrow directly from `bytes` and the call itself does no work.
366 * Intended for one-pass walks over large plists where allocating owned
367 * payloads per line is wasteful.
368 *
369 * UTF-8 is validated inline for variants whose payloads are typed as
370 * [`Cow<str>`].  The cost is byte-level scanning over those payloads
371 * only (typically a small fraction of plist content) and produces an
372 * `Err` for malformed input rather than silently lossy data.
373 */
374#[must_use]
375pub fn parse(bytes: &[u8]) -> Parser<'_> {
376    Parser { rest: bytes }
377}
378
379fn next_line<'a>(rest: &mut &'a [u8]) -> Option<&'a [u8]> {
380    if rest.is_empty() {
381        return None;
382    }
383    match rest.iter().position(|&b| b == b'\n') {
384        Some(i) => {
385            let line = &rest[..i];
386            *rest = &rest[i + 1..];
387            Some(line)
388        }
389        None => {
390            let line = *rest;
391            *rest = &[];
392            Some(line)
393        }
394    }
395}
396
397fn parse_line(line: &[u8]) -> Result<PlistEntry<'_>> {
398    let line = line.trim_ascii_end();
399    let (cmd, args) = split_cmd_args(line);
400
401    if !cmd.starts_with(b"@") {
402        return Ok(PlistEntry::File(borrow_path(line)));
403    }
404
405    match cmd {
406        /*
407         * @src and @cd are effectively aliases for @cwd.
408         */
409        b"@cwd" | b"@src" | b"@cd" => {
410            required_path(args, line, PlistEntry::Cwd)
411        }
412        b"@exec" => required_osstr(args, line, PlistEntry::Exec),
413        b"@unexec" => required_osstr(args, line, PlistEntry::UnExec),
414
415        /*
416         * File ownership and permissions are allowed to be unset,
417         * indicating that they return to their respective defaults.
418         */
419        b"@mode" => Ok(PlistEntry::Mode(optional_str(args)?)),
420        b"@owner" => Ok(PlistEntry::Owner(optional_str(args)?)),
421        b"@group" => Ok(PlistEntry::Group(optional_str(args)?)),
422
423        /*
424         * Currently "preserve" is the only valid option.
425         */
426        b"@option" => match args {
427            Some(b"preserve") => Ok(PlistEntry::PkgOpt(PlistOption::Preserve)),
428            Some(_) => Err(PlistError::UnsupportedCommand(os(line))),
429            None => Err(PlistError::IncorrectArguments(os(line))),
430        },
431
432        /*
433         * Whilst the manual page specifies that @comment takes an
434         * argument, it's too pedantic to insist that it must, so we
435         * handle it as an optional argument.  Comments often carry
436         * non-UTF-8 filenames so the payload is typed as OsStr.
437         *
438         * Special cases:
439         * - "@comment MD5:<hash>"      -> FileChecksum (32-char hex MD5)
440         * - "@comment Symlink:<target>" -> SymlinkTarget
441         */
442        b"@comment" => parse_comment(args),
443
444        /*
445         * For now be strict that @ignore must not take arguments.
446         */
447        b"@ignore" => match args {
448            None => Ok(PlistEntry::Ignore),
449            Some(_) => Err(PlistError::IncorrectArguments(os(line))),
450        },
451
452        b"@name" => required_str(args, line, PlistEntry::Name),
453        b"@pkgdep" => required_str(args, line, PlistEntry::PkgDep),
454        b"@blddep" => required_str(args, line, PlistEntry::BldDep),
455        b"@pkgcfl" => required_str(args, line, PlistEntry::PkgCfl),
456
457        b"@pkgdir" => required_path(args, line, PlistEntry::PkgDir),
458        b"@dirrm" => required_path(args, line, PlistEntry::DirRm),
459        b"@display" => required_path(args, line, PlistEntry::Display),
460
461        _ => Err(PlistError::UnsupportedCommand(os(cmd))),
462    }
463}
464
465fn split_cmd_args(line: &[u8]) -> (&[u8], Option<&[u8]>) {
466    match line.iter().position(|&b| b == b' ') {
467        None => (line, None),
468        Some(i) => {
469            let args = line[i + 1..].trim_ascii_start();
470            (&line[..i], (!args.is_empty()).then_some(args))
471        }
472    }
473}
474
475fn required_path<'a>(
476    args: Option<&'a [u8]>,
477    line: &[u8],
478    ctor: fn(Cow<'a, Path>) -> PlistEntry<'a>,
479) -> Result<PlistEntry<'a>> {
480    match args {
481        Some(a) => Ok(ctor(borrow_path(a))),
482        None => Err(PlistError::IncorrectArguments(os(line))),
483    }
484}
485
486fn required_osstr<'a>(
487    args: Option<&'a [u8]>,
488    line: &[u8],
489    ctor: fn(Cow<'a, OsStr>) -> PlistEntry<'a>,
490) -> Result<PlistEntry<'a>> {
491    match args {
492        Some(a) => Ok(ctor(borrow_osstr(a))),
493        None => Err(PlistError::IncorrectArguments(os(line))),
494    }
495}
496
497fn required_str<'a>(
498    args: Option<&'a [u8]>,
499    line: &[u8],
500    ctor: fn(Cow<'a, str>) -> PlistEntry<'a>,
501) -> Result<PlistEntry<'a>> {
502    match args {
503        Some(a) => Ok(ctor(Cow::Borrowed(std::str::from_utf8(a)?))),
504        None => Err(PlistError::IncorrectArguments(os(line))),
505    }
506}
507
508fn optional_str(args: Option<&[u8]>) -> Result<Option<Cow<'_, str>>> {
509    Ok(args
510        .map(|a| std::str::from_utf8(a).map(Cow::Borrowed))
511        .transpose()?)
512}
513
514fn parse_comment(args: Option<&[u8]>) -> Result<PlistEntry<'_>> {
515    let Some(a) = args else {
516        return Ok(PlistEntry::Comment(None));
517    };
518    if let Some(rest) = a.strip_prefix(b"MD5:")
519        && rest.len() == 32
520        && rest.iter().all(u8::is_ascii_hexdigit)
521    {
522        return Ok(PlistEntry::FileChecksum(Cow::Borrowed(
523            std::str::from_utf8(rest)?,
524        )));
525    }
526    if let Some(rest) = a.strip_prefix(b"Symlink:") {
527        return Ok(PlistEntry::SymlinkTarget(borrow_path(rest)));
528    }
529    Ok(PlistEntry::Comment(Some(borrow_osstr(a))))
530}
531
532#[inline]
533fn borrow_osstr(bytes: &[u8]) -> Cow<'_, OsStr> {
534    Cow::Borrowed(OsStr::from_bytes(bytes))
535}
536
537#[inline]
538fn borrow_path(bytes: &[u8]) -> Cow<'_, Path> {
539    Cow::Borrowed(Path::new(OsStr::from_bytes(bytes)))
540}
541
542#[inline]
543fn os(bytes: &[u8]) -> OsString {
544    OsStr::from_bytes(bytes).to_os_string()
545}
546
547#[inline]
548fn own<T: ToOwned + ?Sized + 'static>(c: Cow<'_, T>) -> Cow<'static, T> {
549    Cow::Owned(c.into_owned())
550}
551
552#[inline]
553fn own_opt<T: ToOwned + ?Sized + 'static>(
554    c: Option<Cow<'_, T>>,
555) -> Option<Cow<'static, T>> {
556    c.map(own)
557}
558
559/**
560 * Information about a file in the packing list, including optional metadata.
561 *
562 * This struct combines a file path with its associated metadata from the
563 * packing list, including:
564 * - MD5 checksum (from `@comment MD5:...`)
565 * - Symlink target (from `@comment Symlink:...`)
566 * - File mode, owner, and group (from `@mode`, `@owner`, `@group`)
567 */
568#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
569#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
570pub struct FileInfo {
571    /// The file path relative to the current working directory.
572    pub path: PathBuf,
573    /// MD5 checksum as 32-character hex string, if present.
574    pub checksum: Option<String>,
575    /// Symlink target path, if this entry represents a symlink.
576    pub symlink_target: Option<PathBuf>,
577    /// File mode (e.g., "0644", "755"), if set.
578    pub mode: Option<String>,
579    /// File owner username, if set.
580    pub owner: Option<String>,
581    /// File group name, if set.
582    pub group: Option<String>,
583}
584
585/**
586 * A complete list of [`PlistEntry`] entries.
587 *
588 * Entries are parsed eagerly using [`Plist::from_bytes`].  For one-pass
589 * streaming parsing without per-entry allocation use [`parse`], which
590 * yields [`PlistEntry<'_>`] borrowed from the source.
591 *
592 * See the top of the module for a full example.
593 *
594 * ## Examples
595 *
596 * ```
597 * use pkgsrc::plist::{Plist, Result};
598 *
599 * fn main() -> Result<()> {
600 *     let plist = Plist::from_bytes(b"@name pkg-1.0")?;
601 *     assert_eq!(plist.pkgname(), Some("pkg-1.0"));
602 *     Ok(())
603 * }
604 * ```
605 */
606#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
607#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
608pub struct Plist {
609    entries: Vec<PlistEntry<'static>>,
610}
611
612impl Plist {
613    /**
614     * Return an empty new [`Plist`].
615     */
616    #[must_use]
617    pub fn new() -> Self {
618        Self::default()
619    }
620
621    /**
622     * Construct a new [`Plist`] from a stream of bytes representing lines
623     * from a package list.
624     *
625     * Parsing is strict: the first malformed line (an unsupported command,
626     * a bad argument, or invalid UTF-8 in a text field) aborts with that
627     * [`PlistError`].  Callers that prefer to skip past bad lines and
628     * continue (as `pkg_install` does) should drive [`parse`] directly and
629     * filter the per-line `Result`s.
630     */
631    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
632        let mut entries = Vec::new();
633        for r in parse(bytes) {
634            entries.push(r?.into_owned());
635        }
636        Ok(Self { entries })
637    }
638
639    /**
640     * Return the package name as specified with `@name`.  If multiple entries
641     * are found only the first is returned.  This is wrapped in [`Option`] as
642     * while indicated as mandatory in the manual page it is often left out,
643     * deferring to deriving the package name from the file name instead.
644     */
645    #[must_use]
646    pub fn pkgname(&self) -> Option<&str> {
647        self.entries.iter().find_map(|e| match e {
648            PlistEntry::Name(s) => Some(s.as_ref()),
649            _ => None,
650        })
651    }
652
653    /**
654     * Return the optional package display file (i.e. `MESSAGE`) as specified
655     * with `@display`.  If multiple entries are found only the first is
656     * returned.
657     */
658    #[must_use]
659    pub fn display(&self) -> Option<&Path> {
660        self.entries.iter().find_map(|e| match e {
661            PlistEntry::Display(p) => Some(p.as_ref()),
662            _ => None,
663        })
664    }
665
666    /**
667     * Return an iterator over `@pkgdep` entries as string slices.
668     */
669    pub fn depends(&self) -> impl Iterator<Item = &str> + '_ {
670        self.entries.iter().filter_map(|e| match e {
671            PlistEntry::PkgDep(s) => Some(s.as_ref()),
672            _ => None,
673        })
674    }
675
676    /**
677     * Return an iterator over `@blddep` entries as string slices.
678     */
679    pub fn build_depends(&self) -> impl Iterator<Item = &str> + '_ {
680        self.entries.iter().filter_map(|e| match e {
681            PlistEntry::BldDep(s) => Some(s.as_ref()),
682            _ => None,
683        })
684    }
685
686    /**
687     * Return an iterator over `@pkgcfl` entries as string slices.
688     */
689    pub fn conflicts(&self) -> impl Iterator<Item = &str> + '_ {
690        self.entries.iter().filter_map(|e| match e {
691            PlistEntry::PkgCfl(s) => Some(s.as_ref()),
692            _ => None,
693        })
694    }
695
696    /**
697     * Return an iterator over `@pkgdir` entries as path slices.
698     */
699    pub fn pkgdirs(&self) -> impl Iterator<Item = &Path> + '_ {
700        self.entries.iter().filter_map(|e| match e {
701            PlistEntry::PkgDir(p) => Some(p.as_ref()),
702            _ => None,
703        })
704    }
705
706    /**
707     * Return an iterator over `@dirrm` entries as path slices.
708     */
709    pub fn pkgrmdirs(&self) -> impl Iterator<Item = &Path> + '_ {
710        self.entries.iter().filter_map(|e| match e {
711            PlistEntry::DirRm(p) => Some(p.as_ref()),
712            _ => None,
713        })
714    }
715
716    /**
717     * Return an iterator over file entries as path slices.  Any files
718     * that come after an `@ignore` command are not listed.
719     */
720    pub fn files(&self) -> impl Iterator<Item = &Path> + '_ {
721        let mut ignore = false;
722        self.entries.iter().filter_map(move |entry| match entry {
723            PlistEntry::Ignore => {
724                ignore = true;
725                None
726            }
727            PlistEntry::File(file) => {
728                if std::mem::take(&mut ignore) {
729                    None
730                } else {
731                    Some(file.as_ref())
732                }
733            }
734            _ => None,
735        })
736    }
737
738    /**
739     * Return an iterator over file entries joined with their preceding
740     * `@cwd` prefix as owned [`PathBuf`] values.  Any files that come
741     * after an `@ignore` command are not listed.
742     */
743    pub fn files_prefixed(&self) -> impl Iterator<Item = PathBuf> + '_ {
744        let mut ignore = false;
745        let mut prefix: Option<&Path> = None;
746        self.entries.iter().filter_map(move |entry| match entry {
747            PlistEntry::Cwd(dir) => {
748                prefix = Some(dir.as_ref());
749                None
750            }
751            PlistEntry::Ignore => {
752                ignore = true;
753                None
754            }
755            PlistEntry::File(file) => {
756                if std::mem::take(&mut ignore) {
757                    return None;
758                }
759                let file: &Path = file.as_ref();
760                Some(match prefix {
761                    Some(pfx) => pfx.join(file),
762                    None => file.to_path_buf(),
763                })
764            }
765            _ => None,
766        })
767    }
768
769    /**
770     * Return an iterator over file entries with their associated metadata.
771     *
772     * For each non-`@ignore`d file directive, the iterator yields a
773     * [`FileInfo`] carrying:
774     * - the file path,
775     * - the most recent `@mode` / `@owner` / `@group` settings,
776     * - an optional MD5 checksum (from a following `@comment MD5:...`),
777     * - an optional symlink target (from a following `@comment Symlink:...`).
778     *
779     * Call `.collect()` if a `Vec<FileInfo>` is needed.
780     */
781    pub fn files_with_info(&self) -> impl Iterator<Item = FileInfo> + '_ {
782        FilesWithInfo::new(&self.entries)
783    }
784
785    /**
786     * Return an iterator over the [`PlistEntry`] entries used during an
787     * install procedure.  It is up to the caller to keep track of file
788     * metadata.
789     */
790    pub fn install_cmds(
791        &self,
792    ) -> impl Iterator<Item = &PlistEntry<'static>> + '_ {
793        let mut ignore = false;
794        self.entries.iter().filter(move |entry| match entry {
795            /*
796             * Ignore the next file, usually (always?) a +METADATA file.
797             */
798            PlistEntry::Ignore => {
799                ignore = true;
800                false
801            }
802            PlistEntry::File(_) => !std::mem::take(&mut ignore),
803            PlistEntry::Cwd(_)
804            | PlistEntry::Exec(_)
805            | PlistEntry::Mode(_)
806            | PlistEntry::Owner(_)
807            | PlistEntry::Group(_)
808            | PlistEntry::PkgDir(_) => true,
809            _ => false,
810        })
811    }
812
813    /**
814     * Return an iterator over the [`PlistEntry`] entries used during an
815     * uninstall procedure.  It is up to the caller to keep track of file
816     * metadata.
817     */
818    pub fn uninstall_cmds(
819        &self,
820    ) -> impl Iterator<Item = &PlistEntry<'static>> + '_ {
821        let mut ignore = false;
822        self.entries.iter().filter(move |entry| match entry {
823            /*
824             * Ignore the next file, usually (always?) a +METADATA file.
825             */
826            PlistEntry::Ignore => {
827                ignore = true;
828                false
829            }
830            PlistEntry::File(_) => !std::mem::take(&mut ignore),
831            PlistEntry::Cwd(_)
832            | PlistEntry::UnExec(_)
833            | PlistEntry::Mode(_)
834            | PlistEntry::Owner(_)
835            | PlistEntry::Group(_)
836            | PlistEntry::PkgDir(_)
837            | PlistEntry::DirRm(_) => true,
838            _ => false,
839        })
840    }
841
842    /**
843     * Return bool indicating whether `@option preserve` has been set or not.
844     */
845    #[must_use]
846    pub fn is_preserve(&self) -> bool {
847        self.entries
848            .iter()
849            .any(|e| matches!(e, PlistEntry::PkgOpt(PlistOption::Preserve)))
850    }
851}
852
853struct FilesWithInfo<'a> {
854    entries: &'a [PlistEntry<'static>],
855    i: usize,
856    ignore: bool,
857    mode: Option<String>,
858    owner: Option<String>,
859    group: Option<String>,
860}
861
862impl<'a> FilesWithInfo<'a> {
863    fn new(entries: &'a [PlistEntry<'static>]) -> Self {
864        Self {
865            entries,
866            i: 0,
867            ignore: false,
868            mode: None,
869            owner: None,
870            group: None,
871        }
872    }
873}
874
875impl Iterator for FilesWithInfo<'_> {
876    type Item = FileInfo;
877
878    fn next(&mut self) -> Option<FileInfo> {
879        while self.i < self.entries.len() {
880            match &self.entries[self.i] {
881                PlistEntry::Mode(m) => {
882                    self.mode = m.as_deref().map(str::to_owned);
883                }
884                PlistEntry::Owner(o) => {
885                    self.owner = o.as_deref().map(str::to_owned);
886                }
887                PlistEntry::Group(g) => {
888                    self.group = g.as_deref().map(str::to_owned);
889                }
890                PlistEntry::Ignore => self.ignore = true,
891                PlistEntry::File(path) => {
892                    self.i += 1;
893                    if std::mem::take(&mut self.ignore) {
894                        continue;
895                    }
896                    let mut info = FileInfo {
897                        path: path.as_ref().to_path_buf(),
898                        checksum: None,
899                        symlink_target: None,
900                        mode: self.mode.clone(),
901                        owner: self.owner.clone(),
902                        group: self.group.clone(),
903                    };
904                    while self.i < self.entries.len() {
905                        match &self.entries[self.i] {
906                            PlistEntry::FileChecksum(hash) => {
907                                info.checksum = Some(hash.as_ref().to_owned());
908                                self.i += 1;
909                            }
910                            PlistEntry::SymlinkTarget(target) => {
911                                info.symlink_target =
912                                    Some(target.as_ref().to_path_buf());
913                                self.i += 1;
914                            }
915                            _ => break,
916                        }
917                    }
918                    return Some(info);
919                }
920                _ => {}
921            }
922            self.i += 1;
923        }
924        None
925    }
926}
927
928impl IntoIterator for Plist {
929    type Item = PlistEntry<'static>;
930    type IntoIter = std::vec::IntoIter<PlistEntry<'static>>;
931
932    fn into_iter(self) -> Self::IntoIter {
933        self.entries.into_iter()
934    }
935}
936
937impl<'a> IntoIterator for &'a Plist {
938    type Item = &'a PlistEntry<'static>;
939    type IntoIter = std::slice::Iter<'a, PlistEntry<'static>>;
940
941    fn into_iter(self) -> Self::IntoIter {
942        self.entries.iter()
943    }
944}
945
946#[cfg(test)]
947mod tests {
948    use super::*;
949
950    /*
951     * Set up some macros to simplify tests.
952     */
953    macro_rules! plist {
954        ($s:expr) => {
955            Plist::from_bytes(String::from($s).as_bytes())
956        };
957    }
958    macro_rules! plist_entry {
959        ($s:expr) => {
960            PlistEntry::from_bytes(String::from($s).as_bytes())
961                .map(PlistEntry::into_owned)
962        };
963    }
964    macro_rules! plist_match_ok {
965        ($s:expr, $p:path) => {
966            let plist = plist_entry!($s)?;
967            assert_eq!(plist, $p);
968        };
969    }
970    macro_rules! plist_match_ok_arg {
971        ($s:expr, $p:path) => {
972            match plist_entry!($s) {
973                Ok(e) => match e {
974                    $p(_) => {}
975                    _ => panic!("should be a valid {} entry", stringify!($p)),
976                },
977                Err(_) => panic!("should be a valid {} entry", stringify!($p)),
978            }
979        };
980    }
981    macro_rules! plist_match_error {
982        ($s:expr, $p:path) => {
983            match plist!($s) {
984                Ok(_) => panic!("should return {} error", stringify!($p)),
985                Err(e) => match e {
986                    $p(_) => {}
987                    _ => panic!("should return {} error", stringify!($p)),
988                },
989            }
990        };
991    }
992
993    /*
994     * Plist commands that only accept strict UTF-8 input.
995     */
996    macro_rules! valid_utf8 {
997        ($s:expr, $p:path) => {
998            /*
999             * A UTF-8 sparkle heart as used in the Rust documentation, and a
1000             * Norwegian "ΓΈ" in non-UTF-8 compatible ISO-8859 format.
1001             */
1002            let heart = vec![240, 159, 146, 150];
1003            let oe = vec![0xf8];
1004
1005            /*
1006             * Supported UTF-8 string.
1007             */
1008            let mut t = String::from($s).into_bytes();
1009            t.extend_from_slice(&heart);
1010            assert_eq!(PlistEntry::from_bytes(&t)?, $p(Cow::Borrowed("πŸ’–")));
1011
1012            /*
1013             * Unsupported ISO-8859 byte sequence.
1014             */
1015            let mut t = String::from($s).into_bytes();
1016            t.extend_from_slice(&oe);
1017            match PlistEntry::from_bytes(&t) {
1018                Ok(p) => panic!(
1019                    "should be an invalid {} entry, not {:?}",
1020                    stringify!($p),
1021                    p
1022                ),
1023                Err(e) => match e {
1024                    PlistError::Utf8(_) => {}
1025                    _ => panic!(
1026                        "should be an invalid {} entry: {}",
1027                        stringify!($p),
1028                        e
1029                    ),
1030                },
1031            }
1032        };
1033    }
1034
1035    /*
1036     * Plist commands that only accept optional strict UTF-8 input.
1037     */
1038    macro_rules! valid_utf8_opt {
1039        ($s:expr, $p:path) => {
1040            /*
1041             * A UTF-8 sparkle heart as used in the Rust documentation, and a
1042             * Norwegian "ΓΈ" in non-UTF-8 compatible ISO-8859 format.
1043             */
1044            let heart = vec![240, 159, 146, 150];
1045            let oe = vec![0xf8];
1046
1047            /*
1048             * Supported UTF-8 string.
1049             */
1050            let mut t = String::from($s).into_bytes();
1051            t.extend_from_slice(&heart);
1052            assert_eq!(
1053                PlistEntry::from_bytes(&t)?,
1054                $p(Some(Cow::Borrowed("πŸ’–")))
1055            );
1056
1057            /*
1058             * Unsupported ISO-8859 byte sequence.
1059             */
1060            let mut t = String::from($s).into_bytes();
1061            t.extend_from_slice(&oe);
1062            match PlistEntry::from_bytes(&t) {
1063                Ok(p) => panic!(
1064                    "should be an invalid {} entry, not {:?}",
1065                    stringify!($p),
1066                    p
1067                ),
1068                Err(e) => match e {
1069                    PlistError::Utf8(_) => {}
1070                    _ => panic!(
1071                        "should be an invalid {} entry: {}",
1072                        stringify!($p),
1073                        e
1074                    ),
1075                },
1076            }
1077        };
1078    }
1079
1080    /*
1081     * Plist commands that accept ISO-8859 input as a Path payload.
1082     */
1083    macro_rules! valid_path {
1084        ($s:expr, $p:path) => {
1085            /*
1086             * A UTF-8 sparkle heart as used in the Rust documentation, and a
1087             * Norwegian "ΓΈ" in non-UTF-8 compatible ISO-8859 format.
1088             */
1089            let heart = vec![240, 159, 146, 150];
1090            let oe = vec![0xf8];
1091
1092            let mut t = String::from($s).into_bytes();
1093            t.extend_from_slice(&heart);
1094            assert_eq!(
1095                PlistEntry::from_bytes(&t)?,
1096                $p(Cow::Borrowed(Path::new("πŸ’–")))
1097            );
1098            let mut t = String::from($s).into_bytes();
1099            t.extend_from_slice(&oe);
1100            match PlistEntry::from_bytes(&t) {
1101                Ok(e) => match e {
1102                    $p(_) => {}
1103                    _ => panic!("should be a valid {} entry", stringify!($p)),
1104                },
1105                Err(_) => panic!("should be a valid {} entry", stringify!($p)),
1106            }
1107        };
1108    }
1109
1110    /*
1111     * Plist commands that accept ISO-8859 input as an OsStr payload.
1112     */
1113    macro_rules! valid_osstr {
1114        ($s:expr, $p:path) => {
1115            /*
1116             * A UTF-8 sparkle heart as used in the Rust documentation, and a
1117             * Norwegian "ΓΈ" in non-UTF-8 compatible ISO-8859 format.
1118             */
1119            let heart = vec![240, 159, 146, 150];
1120            let oe = vec![0xf8];
1121
1122            let mut t = String::from($s).into_bytes();
1123            t.extend_from_slice(&heart);
1124            assert_eq!(
1125                PlistEntry::from_bytes(&t)?,
1126                $p(Cow::Borrowed(OsStr::new("πŸ’–")))
1127            );
1128            let mut t = String::from($s).into_bytes();
1129            t.extend_from_slice(&oe);
1130            match PlistEntry::from_bytes(&t) {
1131                Ok(e) => match e {
1132                    $p(_) => {}
1133                    _ => panic!("should be a valid {} entry", stringify!($p)),
1134                },
1135                Err(_) => panic!("should be a valid {} entry", stringify!($p)),
1136            }
1137        };
1138    }
1139
1140    /*
1141     * Plist commands that accept optional ISO-8859 input as an OsStr.
1142     */
1143    macro_rules! valid_osstr_opt {
1144        ($s:expr, $p:path) => {
1145            /*
1146             * A UTF-8 sparkle heart as used in the Rust documentation, and a
1147             * Norwegian "ΓΈ" in non-UTF-8 compatible ISO-8859 format.
1148             */
1149            let heart = vec![240, 159, 146, 150];
1150            let oe = vec![0xf8];
1151
1152            let mut t = String::from($s).into_bytes();
1153            t.extend_from_slice(&heart);
1154            assert_eq!(
1155                PlistEntry::from_bytes(&t)?,
1156                $p(Some(Cow::Borrowed(OsStr::new("πŸ’–"))))
1157            );
1158            let mut t = String::from($s).into_bytes();
1159            t.extend_from_slice(&oe);
1160            match PlistEntry::from_bytes(&t) {
1161                Ok(e) => match e {
1162                    $p(_) => {}
1163                    _ => panic!("should be a valid {} entry", stringify!($p)),
1164                },
1165                Err(_) => panic!("should be a valid {} entry", stringify!($p)),
1166            }
1167        };
1168    }
1169
1170    /*
1171     * Test an example full plist for functionality.  Correctness tests are
1172     * elsewhere.
1173     */
1174    #[test]
1175    fn test_full_plist() -> Result<()> {
1176        let input = indoc! {"
1177            @comment $NetBSD$
1178            @name pkgtest-1.0
1179            @pkgdep dep-pkg1-[0-9]*
1180            @pkgdep dep-pkg2>=2.0
1181            @blddep dep-pkg1-1.0nb2
1182            @blddep dep-pkg2-2.1
1183            @pkgcfl cfl-pkg1-[0-9]*
1184            @pkgcfl cfl-pkg2>=2.0
1185            @display MESSAGE
1186            @option preserve
1187            @cwd /
1188            @src /
1189            @cd /
1190            @mode 0644
1191            @owner root
1192            @group wheel
1193            bin/foo
1194            @exec touch F=%F D=%D B=%B f=%f
1195            @unexec rm F=%F D=%D B=%B f=%f
1196            @mode
1197            @owner
1198            @group
1199            bin/bar
1200            @pkgdir /var/db/pkgsrc-rs
1201            @dirrm /var/db/pkgsrc-rs-legacy
1202            @ignore
1203            +BUILD_INFO
1204        "};
1205        let plist = Plist::from_bytes(input.as_bytes())?;
1206        assert_eq!(plist.depends().count(), 2);
1207        assert_eq!(plist.build_depends().count(), 2);
1208        assert_eq!(plist.conflicts().count(), 2);
1209        Ok(())
1210    }
1211
1212    /*
1213     * Check parsing for lines and whitespace is as expected.  Notes:
1214     *
1215     *  - Trailing whitespace is always removed.
1216     *  - Leading whitespace of the command is never removed.
1217     *  - Leading whitespace of optional arguments is removed.
1218     *  - Entries containing only whitespace are skipped.
1219     *
1220     */
1221    #[test]
1222    fn test_line_input() -> Result<()> {
1223        /*
1224         * Stripping all trailing whitespace for commands that support
1225         * optional arguments when none is specified should return None.
1226         */
1227        assert_eq!(plist_entry!("@comment  \n")?, PlistEntry::Comment(None));
1228        assert_eq!(plist_entry!("@mode  ")?, PlistEntry::Mode(None));
1229        assert_eq!(plist_entry!("@owner \t ")?, PlistEntry::Owner(None));
1230        assert_eq!(plist_entry!("@group \t\n ")?, PlistEntry::Group(None));
1231
1232        /*
1233         * Strip leading whitespace from a valid argument.
1234         */
1235        let p1 = plist_entry!("@comment  hi")?;
1236        let p2 = PlistEntry::Comment(Some(Cow::Borrowed(OsStr::new("hi"))));
1237        assert_eq!(p1, p2);
1238
1239        /*
1240         * Any leading whitespace means the line is treated as a filename.
1241         */
1242        let p1 = plist_entry!(" @comment ")?;
1243        let p2 = PlistEntry::File(Cow::Borrowed(Path::new(" @comment")));
1244        assert_eq!(p1, p2);
1245
1246        Ok(())
1247    }
1248
1249    /*
1250     * Plist commands that only support strict UTF-8 input.
1251     */
1252    #[test]
1253    fn test_utf8() -> Result<()> {
1254        valid_utf8_opt!("@mode ", PlistEntry::Mode);
1255        valid_utf8_opt!("@owner ", PlistEntry::Owner);
1256        valid_utf8_opt!("@group ", PlistEntry::Group);
1257
1258        valid_utf8!("@name ", PlistEntry::Name);
1259        valid_utf8!("@pkgdep ", PlistEntry::PkgDep);
1260        valid_utf8!("@blddep ", PlistEntry::BldDep);
1261        valid_utf8!("@pkgcfl ", PlistEntry::PkgCfl);
1262
1263        Ok(())
1264    }
1265
1266    /*
1267     * Plist commands that must support ISO-8859 characters that are invalid
1268     * UTF-8 sequences.  This is mostly to support filenames and DESCR files
1269     * that still use (mostly European) ISO-8859 characters.
1270     */
1271    #[test]
1272    fn test_8859() -> Result<()> {
1273        valid_path!("", PlistEntry::File);
1274        valid_path!("@cwd ", PlistEntry::Cwd);
1275        valid_osstr!("@exec ", PlistEntry::Exec);
1276        valid_osstr!("@unexec ", PlistEntry::UnExec);
1277        valid_path!("@pkgdir ", PlistEntry::PkgDir);
1278        valid_path!("@dirrm ", PlistEntry::DirRm);
1279        valid_path!("@display ", PlistEntry::Display);
1280        valid_osstr_opt!("@comment ", PlistEntry::Comment);
1281
1282        Ok(())
1283    }
1284
1285    /*
1286     * Check for valid argument processing.
1287     */
1288    #[test]
1289    fn test_args() -> Result<()> {
1290        /*
1291         * Commands that must not contain arguments.
1292         */
1293        plist_match_ok!("@ignore", PlistEntry::Ignore);
1294        plist_match_error!("@ignore hi", PlistError::IncorrectArguments);
1295
1296        /*
1297         * Commands that must contain an argument.
1298         */
1299        plist_match_ok_arg!("@cwd /cwd", PlistEntry::Cwd);
1300        plist_match_ok_arg!("@src /cwd", PlistEntry::Cwd);
1301        plist_match_ok_arg!("@cd /cwd", PlistEntry::Cwd);
1302        plist_match_ok_arg!("@exec echo hi", PlistEntry::Exec);
1303        plist_match_ok_arg!("@unexec echo lo", PlistEntry::UnExec);
1304        plist_match_ok_arg!("@name pkgname", PlistEntry::Name);
1305        plist_match_ok_arg!("@pkgdir /dirname", PlistEntry::PkgDir);
1306        plist_match_ok_arg!("@dirrm /dirname", PlistEntry::DirRm);
1307        plist_match_ok_arg!("@display MESSAGE", PlistEntry::Display);
1308        plist_match_ok_arg!("@pkgdep pkgname", PlistEntry::PkgDep);
1309        plist_match_ok_arg!("@blddep pkgname", PlistEntry::BldDep);
1310        plist_match_ok_arg!("@pkgcfl pkgname", PlistEntry::PkgCfl);
1311        plist_match_error!("@cwd", PlistError::IncorrectArguments);
1312        plist_match_error!("@src", PlistError::IncorrectArguments);
1313        plist_match_error!("@cd", PlistError::IncorrectArguments);
1314        plist_match_error!("@exec", PlistError::IncorrectArguments);
1315        plist_match_error!("@unexec", PlistError::IncorrectArguments);
1316        plist_match_error!("@name", PlistError::IncorrectArguments);
1317        plist_match_error!("@pkgdir", PlistError::IncorrectArguments);
1318        plist_match_error!("@dirrm", PlistError::IncorrectArguments);
1319        plist_match_error!("@display", PlistError::IncorrectArguments);
1320        plist_match_error!("@pkgdep", PlistError::IncorrectArguments);
1321        plist_match_error!("@blddep", PlistError::IncorrectArguments);
1322        plist_match_error!("@pkgcfl", PlistError::IncorrectArguments);
1323
1324        /*
1325         * Commands where arguments are optional.
1326         */
1327        plist_match_ok_arg!("@comment", PlistEntry::Comment);
1328        plist_match_ok_arg!("@comment hi there", PlistEntry::Comment);
1329        plist_match_ok_arg!("@mode", PlistEntry::Mode);
1330        plist_match_ok_arg!("@mode 0644", PlistEntry::Mode);
1331        plist_match_ok_arg!("@owner", PlistEntry::Owner);
1332        plist_match_ok_arg!("@owner root", PlistEntry::Owner);
1333        plist_match_ok_arg!("@group", PlistEntry::Group);
1334        plist_match_ok_arg!("@group wheel", PlistEntry::Group);
1335
1336        /*
1337         * Commands that require specific arguments.
1338         */
1339        plist_match_ok_arg!("@option preserve", PlistEntry::PkgOpt);
1340        plist_match_error!("@option", PlistError::IncorrectArguments);
1341        plist_match_error!("@option invalid", PlistError::UnsupportedCommand);
1342
1343        Ok(())
1344    }
1345
1346    /*
1347     * Test functions that return vectors.
1348     */
1349    #[test]
1350    fn test_vecs() -> Result<()> {
1351        let plist = plist!("@pkgdir one\n@pkgdir two\n@pkgdir three")?;
1352        assert_eq!(
1353            plist.pkgdirs().collect::<Vec<_>>(),
1354            ["one", "two", "three"].map(Path::new)
1355        );
1356
1357        let plist = plist!("@dirrm one\n@dirrm two\n@dirrm three")?;
1358        assert_eq!(
1359            plist.pkgrmdirs().collect::<Vec<_>>(),
1360            ["one", "two", "three"].map(Path::new)
1361        );
1362
1363        let plist = plist!("@pkgdep one\n@pkgdep two\n@pkgdep three")?;
1364        assert_eq!(
1365            plist.depends().collect::<Vec<_>>(),
1366            ["one", "two", "three"]
1367        );
1368
1369        let plist = plist!("@blddep one\n@blddep two\n@blddep three")?;
1370        assert_eq!(
1371            plist.build_depends().collect::<Vec<_>>(),
1372            ["one", "two", "three"]
1373        );
1374
1375        let plist = plist!("@pkgcfl one\n@pkgcfl two\n@pkgcfl three")?;
1376        assert_eq!(
1377            plist.conflicts().collect::<Vec<_>>(),
1378            ["one", "two", "three"]
1379        );
1380
1381        Ok(())
1382    }
1383
1384    /*
1385     * Test functions that return file matches.
1386     */
1387    #[test]
1388    fn test_files() -> Result<()> {
1389        let input = indoc! {"
1390            @cwd /opt/pkg
1391            bin/good
1392            @cwd /
1393            bin/evil
1394            @ignore
1395            @cwd /tmp
1396            +IGNORE_ME
1397            @cwd /opt/pkg
1398            bin/ok
1399        "};
1400        let plist = Plist::from_bytes(input.as_bytes())?;
1401        let files: Vec<&Path> = plist.files().collect();
1402        assert_eq!(files, ["bin/good", "bin/evil", "bin/ok"].map(Path::new));
1403        let prefixed: Vec<PathBuf> = plist.files_prefixed().collect();
1404        assert_eq!(
1405            prefixed,
1406            ["/opt/pkg/bin/good", "/bin/evil", "/opt/pkg/bin/ok"]
1407                .map(PathBuf::from)
1408        );
1409
1410        let plist = Plist::from_bytes(b"bin/relative\n")?;
1411        let files: Vec<&Path> = plist.files().collect();
1412        assert_eq!(files, [Path::new("bin/relative")]);
1413        let prefixed: Vec<PathBuf> = plist.files_prefixed().collect();
1414        assert_eq!(prefixed, [PathBuf::from("bin/relative")]);
1415        Ok(())
1416    }
1417
1418    /*
1419     * Test functions that return only the first match.
1420     */
1421    #[test]
1422    fn test_first_match() -> Result<()> {
1423        let plist = plist!("@comment not a pkgname")?;
1424        assert_eq!(plist.pkgname(), None);
1425
1426        let plist = plist!("@name one\n@name two\n@name three")?;
1427        assert_eq!(plist.pkgname(), Some("one"));
1428
1429        let plist = plist!("@comment not a display")?;
1430        assert_eq!(plist.display(), None);
1431
1432        let plist = plist!("@display one\n@display two\n@display three")?;
1433        assert_eq!(plist.display(), Some(Path::new("one")));
1434
1435        Ok(())
1436    }
1437
1438    /*
1439     * Test that is_preserve() functions correctly.
1440     */
1441    #[test]
1442    fn test_preserve() -> Result<()> {
1443        assert!(!plist!("@comment not set")?.is_preserve());
1444        assert!(plist!("@option preserve")?.is_preserve());
1445
1446        Ok(())
1447    }
1448
1449    /*
1450     * Test MD5 checksum parsing from @comment MD5:...
1451     */
1452    #[test]
1453    fn test_file_checksum() -> Result<()> {
1454        // Valid MD5 checksum (32 hex chars)
1455        let entry =
1456            plist_entry!("@comment MD5:d41d8cd98f00b204e9800998ecf8427e")?;
1457        assert_eq!(
1458            entry,
1459            PlistEntry::FileChecksum(Cow::Borrowed(
1460                "d41d8cd98f00b204e9800998ecf8427e"
1461            ))
1462        );
1463
1464        // Invalid MD5 (too short) - treated as regular comment
1465        let entry = plist_entry!("@comment MD5:abc123")?;
1466        assert!(matches!(entry, PlistEntry::Comment(_)));
1467
1468        // Invalid MD5 (non-hex chars) - treated as regular comment
1469        let entry =
1470            plist_entry!("@comment MD5:d41d8cd98f00b204e9800998ecf8427g")?;
1471        assert!(matches!(entry, PlistEntry::Comment(_)));
1472
1473        // Regular comment should still work
1474        let entry = plist_entry!("@comment This is a comment")?;
1475        assert!(matches!(entry, PlistEntry::Comment(_)));
1476
1477        Ok(())
1478    }
1479
1480    /*
1481     * Test symlink target parsing from @comment Symlink:...
1482     */
1483    #[test]
1484    fn test_symlink_target() -> Result<()> {
1485        let entry = plist_entry!("@comment Symlink:/usr/bin/target")?;
1486        assert_eq!(
1487            entry,
1488            PlistEntry::SymlinkTarget(Cow::Borrowed(Path::new(
1489                "/usr/bin/target"
1490            )))
1491        );
1492
1493        // Empty symlink target
1494        let entry = plist_entry!("@comment Symlink:")?;
1495        assert_eq!(
1496            entry,
1497            PlistEntry::SymlinkTarget(Cow::Borrowed(Path::new("")))
1498        );
1499
1500        Ok(())
1501    }
1502
1503    /*
1504     * Test files_with_info() returns files with associated metadata.
1505     */
1506    #[test]
1507    fn test_files_with_info() -> Result<()> {
1508        let input = indoc! {"
1509            @mode 0755
1510            @owner root
1511            @group wheel
1512            bin/myapp
1513            @comment MD5:d41d8cd98f00b204e9800998ecf8427e
1514            @mode 0644
1515            etc/myapp.conf
1516            @comment MD5:098f6bcd4621d373cade4e832627b4f6
1517            lib/libfoo.so
1518            @comment Symlink:libfoo.so.1
1519            @ignore
1520            +BUILD_INFO
1521        "};
1522
1523        let plist = Plist::from_bytes(input.as_bytes())?;
1524        let files: Vec<FileInfo> = plist.files_with_info().collect();
1525
1526        assert_eq!(files.len(), 3);
1527
1528        // First file: bin/myapp with mode 0755, owner root, group wheel
1529        assert_eq!(files[0].path, PathBuf::from("bin/myapp"));
1530        assert_eq!(
1531            files[0].checksum,
1532            Some("d41d8cd98f00b204e9800998ecf8427e".to_string())
1533        );
1534        assert_eq!(files[0].symlink_target, None);
1535        assert_eq!(files[0].mode, Some("0755".to_string()));
1536        assert_eq!(files[0].owner, Some("root".to_string()));
1537        assert_eq!(files[0].group, Some("wheel".to_string()));
1538
1539        // Second file: etc/myapp.conf with mode 0644
1540        assert_eq!(files[1].path, PathBuf::from("etc/myapp.conf"));
1541        assert_eq!(
1542            files[1].checksum,
1543            Some("098f6bcd4621d373cade4e832627b4f6".to_string())
1544        );
1545        assert_eq!(files[1].mode, Some("0644".to_string()));
1546
1547        // Third file: lib/libfoo.so is a symlink
1548        assert_eq!(files[2].path, PathBuf::from("lib/libfoo.so"));
1549        assert_eq!(files[2].checksum, None);
1550        assert_eq!(files[2].symlink_target, Some(PathBuf::from("libfoo.so.1")));
1551
1552        Ok(())
1553    }
1554
1555    #[test]
1556    fn test_into_iterator() -> Result<()> {
1557        let plist =
1558            plist!("@name pkg-1.0\nbin/foo\n@pkgdep dep-[0-9]*\nbin/bar")?;
1559
1560        let entries: Vec<_> = plist.into_iter().collect();
1561        assert_eq!(entries.len(), 4);
1562        assert!(matches!(entries[0], PlistEntry::Name(_)));
1563        assert!(matches!(entries[1], PlistEntry::File(_)));
1564        assert!(matches!(entries[2], PlistEntry::PkgDep(_)));
1565        assert!(matches!(entries[3], PlistEntry::File(_)));
1566
1567        Ok(())
1568    }
1569
1570    #[test]
1571    fn test_iter_by_ref() -> Result<()> {
1572        let plist = plist!("@name pkg-1.0\nbin/foo\nbin/bar")?;
1573
1574        let file_count = (&plist)
1575            .into_iter()
1576            .filter(|e| matches!(e, PlistEntry::File(_)))
1577            .count();
1578        assert_eq!(file_count, 2);
1579
1580        // plist is still usable after iteration by reference
1581        assert_eq!(plist.pkgname(), Some("pkg-1.0"));
1582
1583        Ok(())
1584    }
1585
1586    /*
1587     * Lazy parser tests: parse() yields borrowed entries, validating
1588     * UTF-8 inline for variants whose payloads are typed as Cow<str>.
1589     */
1590    #[test]
1591    fn test_parse_iter() -> Result<()> {
1592        let input = b"@name pkg-1.0\nbin/foo\n@pkgdir /var/db/x\n";
1593        let entries: Vec<_> = parse(input).collect::<Result<Vec<_>>>()?;
1594        assert_eq!(entries.len(), 3);
1595        assert_eq!(entries[0], PlistEntry::Name(Cow::Borrowed("pkg-1.0")));
1596        assert_eq!(
1597            entries[1],
1598            PlistEntry::File(Cow::Borrowed(Path::new("bin/foo")))
1599        );
1600        assert_eq!(
1601            entries[2],
1602            PlistEntry::PkgDir(Cow::Borrowed(Path::new("/var/db/x")))
1603        );
1604        Ok(())
1605    }
1606
1607    #[test]
1608    fn test_parse_iter_no_trailing_newline() -> Result<()> {
1609        let input = b"@name pkg-1.0\nbin/foo";
1610        let entries: Vec<_> = parse(input).collect::<Result<Vec<_>>>()?;
1611        assert_eq!(entries.len(), 2);
1612        Ok(())
1613    }
1614
1615    #[test]
1616    fn test_parse_iter_skips_blanks() -> Result<()> {
1617        let input = b"@name pkg-1.0\n\n   \nbin/foo\n";
1618        let entries: Vec<_> = parse(input).collect::<Result<Vec<_>>>()?;
1619        assert_eq!(entries.len(), 2);
1620        Ok(())
1621    }
1622
1623    #[test]
1624    fn test_parse_comment_special_forms() -> Result<()> {
1625        let entries: Vec<_> = parse(
1626            b"@comment MD5:d41d8cd98f00b204e9800998ecf8427e\n\
1627              @comment Symlink:/usr/bin/target\n\
1628              @comment plain comment\n",
1629        )
1630        .collect::<Result<Vec<_>>>()?;
1631        assert_eq!(
1632            entries[0],
1633            PlistEntry::FileChecksum(Cow::Borrowed(
1634                "d41d8cd98f00b204e9800998ecf8427e"
1635            ))
1636        );
1637        assert_eq!(
1638            entries[1],
1639            PlistEntry::SymlinkTarget(Cow::Borrowed(Path::new(
1640                "/usr/bin/target"
1641            )))
1642        );
1643        assert!(matches!(entries[2], PlistEntry::Comment(Some(_))));
1644        Ok(())
1645    }
1646
1647    /*
1648     * parse() validates UTF-8 inline for the Cow<str>-typed variants;
1649     * malformed input yields PlistError::Utf8 immediately rather than
1650     * propagating bad bytes downstream.
1651     */
1652    #[test]
1653    fn test_parse_validates_utf8() -> Result<()> {
1654        let input = b"@name \xff-bad\nbin/foo\n";
1655        match parse(input).next() {
1656            Some(Err(PlistError::Utf8(_))) => Ok(()),
1657            other => panic!("expected Utf8 error from parse(), got {other:?}"),
1658        }
1659    }
1660
1661    /*
1662     * into_owned() turns a borrowed entry into a 'static one, suitable
1663     * for storing past the lifetime of the source bytes.
1664     */
1665    #[test]
1666    fn test_into_owned() -> Result<()> {
1667        let owned: PlistEntry<'static> = {
1668            let bytes: Vec<u8> = b"@name pkg-1.0".to_vec();
1669            PlistEntry::from_bytes(&bytes)?.into_owned()
1670        };
1671        assert_eq!(owned, PlistEntry::Name(Cow::Owned("pkg-1.0".to_owned())));
1672        Ok(())
1673    }
1674}