Skip to main content

forensic_vfs/
uri.rs

1//! The lossless canonical URI form of a [`crate::PathSpec`] and its parser.
2//!
3//! `PathSpec` to `String` to `PathSpec` is byte-for-byte lossless (every reserved
4//! byte, including `/` and `%`, is percent-encoded), so a spec pasted from a
5//! report re-opens exactly. Round-trip is a test-enforced invariant.
6//!
7//! Grammar: `fvfs:` then layers joined by `|`. Each layer is `tag:body`; every
8//! value byte outside the unreserved set `[A-Za-z0-9._-]` is `%HH`, so the
9//! structural delimiters `| : , /` only ever appear as structure.
10
11use core::fmt;
12use std::fmt::Write as _;
13use std::path::{Path, PathBuf};
14
15use crate::encryption::EncryptionScheme;
16use crate::error::{SmallHex, VfsError, VfsResult};
17use crate::fs::{FileId, FsKind, StreamId};
18use crate::pathspec::{Guid, Layer, NodeAddr, PathSpec, SnapshotRef};
19use crate::registry::ContainerFormat;
20use crate::volume::VolumeScheme;
21
22const SCHEME: &str = "fvfs:";
23const LAYER_SEP: char = '|';
24
25fn err(detail: &str, ctx: &str) -> VfsError {
26    VfsError::Decode {
27        layer: "pathspec-uri",
28        offset: 0,
29        detail: detail.to_string(),
30        bytes: SmallHex::new(ctx.as_bytes()),
31    }
32}
33
34// --- percent-encoding: unreserved = [A-Za-z0-9._-]; everything else is %HH ---
35
36fn pct_encode(bytes: &[u8]) -> String {
37    let mut s = String::with_capacity(bytes.len());
38    for &b in bytes {
39        if b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-') {
40            s.push(b as char);
41        } else {
42            let _ = write!(s, "%{b:02X}");
43        }
44    }
45    s
46}
47
48fn pct_decode(s: &str) -> VfsResult<Vec<u8>> {
49    let b = s.as_bytes();
50    let mut out = Vec::with_capacity(b.len());
51    let mut i = 0;
52    while let Some(&c) = b.get(i) {
53        if c == b'%' {
54            let hex = b
55                .get(i + 1..i + 3)
56                .ok_or_else(|| err("truncated percent escape", s))?;
57            let hex = core::str::from_utf8(hex).map_err(|_| err("non-ascii percent escape", s))?;
58            let v = u8::from_str_radix(hex, 16).map_err(|_| err("bad percent hex", s))?;
59            out.push(v);
60            i += 3;
61        } else {
62            out.push(c);
63            i += 1;
64        }
65    }
66    Ok(out)
67}
68
69#[cfg(unix)]
70fn path_to_bytes(p: &Path) -> Vec<u8> {
71    use std::os::unix::ffi::OsStrExt;
72    p.as_os_str().as_bytes().to_vec()
73}
74#[cfg(not(unix))]
75fn path_to_bytes(p: &Path) -> Vec<u8> {
76    p.to_string_lossy().into_owned().into_bytes()
77}
78#[cfg(unix)]
79fn bytes_to_path(b: &[u8]) -> PathBuf {
80    use std::os::unix::ffi::OsStrExt;
81    PathBuf::from(std::ffi::OsStr::from_bytes(b))
82}
83#[cfg(not(unix))]
84fn bytes_to_path(b: &[u8]) -> PathBuf {
85    PathBuf::from(String::from_utf8_lossy(b).into_owned())
86}
87
88// --- token vocab (round-trips 1:1; #[non_exhaustive] handled by _ => error) ---
89
90fn container_token(f: ContainerFormat) -> &'static str {
91    match f {
92        ContainerFormat::Ewf => "ewf",
93        ContainerFormat::Vmdk => "vmdk",
94        ContainerFormat::Vhdx => "vhdx",
95        ContainerFormat::Vhd => "vhd",
96        ContainerFormat::Qcow2 => "qcow2",
97        ContainerFormat::Dmg => "dmg",
98        ContainerFormat::Aff4 => "aff4",
99        ContainerFormat::Ad1 => "ad1",
100        ContainerFormat::Dar => "dar",
101        ContainerFormat::Raw => "raw",
102        ContainerFormat::Auto => "auto",
103    }
104}
105fn parse_container(t: &str, ctx: &str) -> VfsResult<ContainerFormat> {
106    Ok(match t {
107        "ewf" => ContainerFormat::Ewf,
108        "vmdk" => ContainerFormat::Vmdk,
109        "vhdx" => ContainerFormat::Vhdx,
110        "vhd" => ContainerFormat::Vhd,
111        "qcow2" => ContainerFormat::Qcow2,
112        "dmg" => ContainerFormat::Dmg,
113        "aff4" => ContainerFormat::Aff4,
114        "ad1" => ContainerFormat::Ad1,
115        "dar" => ContainerFormat::Dar,
116        "raw" => ContainerFormat::Raw,
117        "auto" => ContainerFormat::Auto,
118        _ => return Err(err("unknown container format", ctx)),
119    })
120}
121fn volume_token(s: VolumeScheme) -> &'static str {
122    match s {
123        VolumeScheme::Mbr => "mbr",
124        VolumeScheme::Gpt => "gpt",
125        VolumeScheme::Apm => "apm",
126        VolumeScheme::Vss => "vss",
127        VolumeScheme::ApfsContainer => "apfscontainer",
128        VolumeScheme::Lvm => "lvm",
129    }
130}
131fn parse_volume_scheme(t: &str, ctx: &str) -> VfsResult<VolumeScheme> {
132    Ok(match t {
133        "mbr" => VolumeScheme::Mbr,
134        "gpt" => VolumeScheme::Gpt,
135        "apm" => VolumeScheme::Apm,
136        "vss" => VolumeScheme::Vss,
137        "apfscontainer" => VolumeScheme::ApfsContainer,
138        "lvm" => VolumeScheme::Lvm,
139        _ => return Err(err("unknown volume scheme", ctx)),
140    })
141}
142fn encryption_token(s: EncryptionScheme) -> &'static str {
143    match s {
144        EncryptionScheme::Bitlocker => "bitlocker",
145        EncryptionScheme::Luks1 => "luks1",
146        EncryptionScheme::Luks2 => "luks2",
147        EncryptionScheme::FileVault => "filevault",
148        EncryptionScheme::ApfsEncrypted => "apfsencrypted",
149        EncryptionScheme::VeraCrypt => "veracrypt",
150    }
151}
152fn parse_encryption(t: &str, ctx: &str) -> VfsResult<EncryptionScheme> {
153    Ok(match t {
154        "bitlocker" => EncryptionScheme::Bitlocker,
155        "luks1" => EncryptionScheme::Luks1,
156        "luks2" => EncryptionScheme::Luks2,
157        "filevault" => EncryptionScheme::FileVault,
158        "apfsencrypted" => EncryptionScheme::ApfsEncrypted,
159        "veracrypt" => EncryptionScheme::VeraCrypt,
160        _ => return Err(err("unknown encryption scheme", ctx)),
161    })
162}
163fn parse_fs_kind(t: &str, ctx: &str) -> VfsResult<FsKind> {
164    FsKind::known()
165        .iter()
166        .copied()
167        .find(|k| k.as_str() == t)
168        .ok_or_else(|| err("unknown filesystem kind", ctx))
169}
170
171fn u64_field(t: &str, ctx: &str) -> VfsResult<u64> {
172    t.parse::<u64>().map_err(|_| err("expected integer", ctx))
173}
174fn u32_field(t: &str, ctx: &str) -> VfsResult<u32> {
175    t.parse::<u32>().map_err(|_| err("expected u32", ctx))
176}
177fn usize_field(t: &str, ctx: &str) -> VfsResult<usize> {
178    t.parse::<usize>().map_err(|_| err("expected index", ctx))
179}
180
181fn guid_hex(g: Guid) -> String {
182    let mut s = String::with_capacity(32);
183    for b in g.0 {
184        let _ = write!(s, "{b:02x}");
185    }
186    s
187}
188fn parse_guid(t: &str, ctx: &str) -> VfsResult<Guid> {
189    if t.len() != 32 {
190        return Err(err("guid must be 32 hex chars", ctx));
191    }
192    let mut out = [0u8; 16];
193    for (i, slot) in out.iter_mut().enumerate() {
194        let pair = t
195            .get(i * 2..i * 2 + 2)
196            .ok_or_else(|| err("bad guid", ctx))?;
197        *slot = u8::from_str_radix(pair, 16).map_err(|_| err("bad guid hex", ctx))?;
198    }
199    Ok(Guid(out))
200}
201
202// FileId: '.'-separated token; no '/' or ',' so it nests inside a NodeAddr.
203fn file_id_token(id: FileId) -> String {
204    match id {
205        FileId::NtfsRef { entry, seq } => format!("ntfsref.{entry}.{seq}"),
206        FileId::ExtInode { ino, gen } => format!("extinode.{ino}.{gen}"),
207        FileId::ApfsOid { oid, xid } => format!("apfsoid.{oid}.{xid}"),
208        FileId::FatDirEntry { cluster, index } => format!("fatdirentry.{cluster}.{index}"),
209        FileId::IsoExtent { block } => format!("isoextent.{block}"),
210        FileId::Opaque(n) => format!("opaque.{n}"),
211    }
212}
213fn parse_file_id(t: &str, ctx: &str) -> VfsResult<FileId> {
214    let mut it = t.split('.');
215    let head = it.next().ok_or_else(|| err("empty file id", ctx))?;
216    let mut num = |name: &str| -> VfsResult<u64> {
217        it.next()
218            .ok_or_else(|| err(name, ctx))
219            .and_then(|s| u64_field(s, ctx))
220    };
221    Ok(match head {
222        "ntfsref" => FileId::NtfsRef {
223            entry: num("entry")?,
224            seq: num("seq")? as u16,
225        },
226        "extinode" => FileId::ExtInode {
227            ino: num("ino")?,
228            gen: num("gen")? as u32,
229        },
230        "apfsoid" => FileId::ApfsOid {
231            oid: num("oid")?,
232            xid: num("xid")?,
233        },
234        "fatdirentry" => FileId::FatDirEntry {
235            cluster: num("cluster")? as u32,
236            index: num("index")? as u16,
237        },
238        "isoextent" => FileId::IsoExtent {
239            block: num("block")? as u32,
240        },
241        "opaque" => FileId::Opaque(num("n")?),
242        _ => return Err(err("unknown file id kind", ctx)),
243    })
244}
245
246fn stream_token(s: StreamId) -> String {
247    match s {
248        StreamId::Default => "default".to_string(),
249        StreamId::Named(n) => format!("named.{n}"),
250        StreamId::ResourceFork => "resourcefork".to_string(),
251        StreamId::Xattr(n) => format!("xattr.{n}"),
252        StreamId::Slack => "slack".to_string(),
253    }
254}
255fn parse_stream(t: &str, ctx: &str) -> VfsResult<StreamId> {
256    let mut it = t.split('.');
257    let head = it.next().unwrap_or("");
258    Ok(match head {
259        "default" => StreamId::Default,
260        "resourcefork" => StreamId::ResourceFork,
261        "slack" => StreamId::Slack,
262        "named" => StreamId::Named(u32_field(it.next().unwrap_or(""), ctx)? as u16),
263        "xattr" => StreamId::Xattr(u32_field(it.next().unwrap_or(""), ctx)? as u16),
264        _ => return Err(err("unknown stream id", ctx)),
265    })
266}
267
268fn node_addr_encode(a: &NodeAddr) -> String {
269    let comps = |cs: &[Vec<u8>]| -> String {
270        let mut s = String::new();
271        for c in cs {
272            s.push('/');
273            s.push_str(&pct_encode(c));
274        }
275        s
276    };
277    match a {
278        NodeAddr::Path(cs) => format!("p{}", comps(cs)),
279        NodeAddr::File(id) => format!("f/{}", file_id_token(*id)),
280        NodeAddr::Both { path, id } => format!("b/{}{}", file_id_token(*id), comps(path)),
281    }
282}
283fn parse_node_addr(t: &str, ctx: &str) -> VfsResult<NodeAddr> {
284    let decode_comps = |rest: &str| -> VfsResult<Vec<Vec<u8>>> {
285        if rest.is_empty() {
286            return Ok(Vec::new());
287        }
288        // `rest` begins with '/'.
289        let body = rest.strip_prefix('/').unwrap_or(rest);
290        body.split('/').map(pct_decode).collect()
291    };
292    let (tag, rest) = t.split_at(t.chars().next().map_or(0, char::len_utf8));
293    match tag {
294        "p" => Ok(NodeAddr::Path(decode_comps(rest)?)),
295        "f" => {
296            let id = rest.strip_prefix('/').unwrap_or(rest);
297            Ok(NodeAddr::File(parse_file_id(id, ctx)?))
298        }
299        "b" => {
300            let rest = rest.strip_prefix('/').unwrap_or(rest);
301            let mut parts = rest.splitn(2, '/');
302            let id = parse_file_id(parts.next().unwrap_or(""), ctx)?;
303            let path = match parts.next() {
304                Some(p) => p
305                    .split('/')
306                    .map(pct_decode)
307                    .collect::<VfsResult<Vec<_>>>()?,
308                None => Vec::new(),
309            };
310            Ok(NodeAddr::Both { path, id })
311        }
312        _ => Err(err("unknown node address", ctx)),
313    }
314}
315
316fn layer_encode(l: &Layer) -> String {
317    match l {
318        Layer::Os { path } => format!("os:{}", pct_encode(&path_to_bytes(path))),
319        Layer::Range { start, len } => format!("range:{start},{len}"),
320        Layer::Container { format } => format!("container:{}", container_token(*format)),
321        Layer::Volume {
322            scheme,
323            index,
324            guid,
325        } => {
326            let mut s = format!("volume:{},{index}", volume_token(*scheme));
327            if let Some(g) = guid {
328                let _ = write!(s, ",{}", guid_hex(*g));
329            }
330            s
331        }
332        Layer::Encryption { scheme } => format!("encryption:{}", encryption_token(*scheme)),
333        Layer::Snapshot { store } => match store {
334            SnapshotRef::VssStore(i) => format!("snapshot:vss,{i}"),
335            SnapshotRef::ApfsXid(x) => format!("snapshot:apfs,{x}"),
336        },
337        Layer::Fs { kind, at } => format!("fs:{},{}", kind.as_str(), node_addr_encode(at)),
338        Layer::Stream { id } => format!("stream:{}", stream_token(*id)),
339        // `archive:` (empty body) = a 1→1 stream peel; `archive:<i>` = member `i`.
340        Layer::Archive { member } => match member {
341            None => "archive:".to_string(),
342            Some(i) => format!("archive:{i}"),
343        },
344    }
345}
346
347fn layer_parse(s: &str) -> VfsResult<Layer> {
348    let (tag, body) = s
349        .split_once(':')
350        .ok_or_else(|| err("layer missing tag", s))?;
351    match tag {
352        "os" => Ok(Layer::Os {
353            path: bytes_to_path(&pct_decode(body)?),
354        }),
355        "range" => {
356            let (a, b) = body
357                .split_once(',')
358                .ok_or_else(|| err("range needs start,len", s))?;
359            Ok(Layer::Range {
360                start: u64_field(a, s)?,
361                len: u64_field(b, s)?,
362            })
363        }
364        "container" => Ok(Layer::Container {
365            format: parse_container(body, s)?,
366        }),
367        "volume" => {
368            let mut it = body.split(',');
369            let scheme = parse_volume_scheme(it.next().unwrap_or(""), s)?;
370            let index = usize_field(it.next().ok_or_else(|| err("volume needs index", s))?, s)?;
371            let guid = match it.next() {
372                Some(g) => Some(parse_guid(g, s)?),
373                None => None,
374            };
375            Ok(Layer::Volume {
376                scheme,
377                index,
378                guid,
379            })
380        }
381        "encryption" => Ok(Layer::Encryption {
382            scheme: parse_encryption(body, s)?,
383        }),
384        "snapshot" => {
385            let (kind, num) = body
386                .split_once(',')
387                .ok_or_else(|| err("snapshot needs kind,id", s))?;
388            let store = match kind {
389                "vss" => SnapshotRef::VssStore(usize_field(num, s)?),
390                "apfs" => SnapshotRef::ApfsXid(u64_field(num, s)?),
391                _ => return Err(err("unknown snapshot kind", s)),
392            };
393            Ok(Layer::Snapshot { store })
394        }
395        "fs" => {
396            let (kind, at) = body
397                .split_once(',')
398                .ok_or_else(|| err("fs needs kind,addr", s))?;
399            Ok(Layer::Fs {
400                kind: parse_fs_kind(kind, s)?,
401                at: parse_node_addr(at, s)?,
402            })
403        }
404        "stream" => Ok(Layer::Stream {
405            id: parse_stream(body, s)?,
406        }),
407        "archive" => Ok(Layer::Archive {
408            member: if body.is_empty() {
409                None
410            } else {
411                Some(usize_field(body, s)?)
412            },
413        }),
414        _ => Err(err("unknown layer tag", s)),
415    }
416}
417
418impl PathSpec {
419    /// The lossless canonical URI form — round-trips byte-for-byte through
420    /// [`PathSpec::from_uri`].
421    #[must_use]
422    pub fn to_uri(&self) -> String {
423        let mut s = String::from(SCHEME);
424        let layers = self.layers();
425        for (i, l) in layers.iter().enumerate() {
426            if i > 0 {
427                s.push(LAYER_SEP);
428            }
429            s.push_str(&layer_encode(l));
430        }
431        s
432    }
433
434    /// Parse a canonical URI produced by [`PathSpec::to_uri`]. Loud on any
435    /// malformed input (carries the offending string).
436    pub fn from_uri(s: &str) -> VfsResult<PathSpec> {
437        let rest = s
438            .strip_prefix(SCHEME)
439            .ok_or_else(|| err("missing fvfs: scheme", s))?;
440        let mut layers = rest.split(LAYER_SEP);
441        let first = layers.next().ok_or_else(|| err("empty spec", s))?;
442        let mut spec = PathSpec::root(layer_parse(first)?);
443        for l in layers {
444            spec = spec.push(layer_parse(l)?);
445        }
446        Ok(spec)
447    }
448}
449
450/// Lossy, human-readable form — readable, explicitly non-parseable (use
451/// [`PathSpec::to_uri`] for a form that round-trips).
452impl fmt::Display for PathSpec {
453    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
454        let comps = |cs: &[Vec<u8>]| {
455            cs.iter()
456                .map(|c| String::from_utf8_lossy(c).into_owned())
457                .collect::<Vec<_>>()
458                .join("/")
459        };
460        let mut first = true;
461        for l in self.layers() {
462            if !first {
463                write!(f, " | ")?;
464            }
465            first = false;
466            match l {
467                Layer::Os { path } => write!(f, "os:{}", path.display())?,
468                Layer::Range { start, len } => write!(f, "range[{start}+{len}]")?,
469                Layer::Container { format } => write!(f, "{}", container_token(*format))?,
470                Layer::Volume { scheme, index, .. } => {
471                    write!(f, "{}#{index}", volume_token(*scheme))?;
472                }
473                Layer::Encryption { scheme } => write!(f, "{}", encryption_token(*scheme))?,
474                Layer::Snapshot { store } => match store {
475                    SnapshotRef::VssStore(i) => write!(f, "vss#{i}")?,
476                    SnapshotRef::ApfsXid(x) => write!(f, "apfs@{x}")?,
477                },
478                Layer::Fs { kind, at } => match at {
479                    NodeAddr::Path(cs) | NodeAddr::Both { path: cs, .. } => {
480                        write!(f, "{}:/{}", kind.as_str(), comps(cs))?;
481                    }
482                    NodeAddr::File(id) => write!(f, "{}#{}", kind.as_str(), file_id_token(*id))?,
483                },
484                Layer::Stream { id } => write!(f, ":{}", stream_token(*id))?,
485                Layer::Archive { member } => match member {
486                    None => write!(f, "archive")?,
487                    Some(i) => write!(f, "archive#{i}")?,
488                },
489            }
490        }
491        Ok(())
492    }
493}
494
495#[cfg(feature = "serde")]
496impl serde::Serialize for PathSpec {
497    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
498        s.serialize_str(&self.to_uri())
499    }
500}
501
502#[cfg(feature = "serde")]
503impl<'de> serde::Deserialize<'de> for PathSpec {
504    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
505        let uri = String::deserialize(d)?;
506        PathSpec::from_uri(&uri).map_err(serde::de::Error::custom)
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use crate::encryption::EncryptionScheme;
513    use crate::fs::{FileId, FsKind, StreamId};
514    use crate::pathspec::{Guid, Layer, NodeAddr, PathSpec, SnapshotRef};
515    use crate::registry::ContainerFormat;
516    use crate::volume::VolumeScheme;
517
518    fn roundtrip(spec: &PathSpec) {
519        let uri = spec.to_uri();
520        let back = PathSpec::from_uri(&uri).expect("parse own output");
521        assert_eq!(&back, spec, "round-trip changed the spec; uri={uri}");
522    }
523
524    #[test]
525    fn rich_chain_round_trips() {
526        let spec = PathSpec::os("/evidence/DC01.E01")
527            .push(Layer::Container {
528                format: ContainerFormat::Ewf,
529            })
530            .push(Layer::Volume {
531                scheme: VolumeScheme::Gpt,
532                index: 1,
533                guid: Some(Guid([
534                    0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76,
535                    0x54, 0x32, 0x10,
536                ])),
537            })
538            .push(Layer::Fs {
539                kind: FsKind::NTFS,
540                at: NodeAddr::Path(vec![
541                    b"Windows".to_vec(),
542                    b"System32".to_vec(),
543                    b"config".to_vec(),
544                    b"SYSTEM".to_vec(),
545                ]),
546            })
547            .push(Layer::Stream {
548                id: StreamId::Named(3),
549            });
550        roundtrip(&spec);
551    }
552
553    #[test]
554    fn hostile_path_bytes_round_trip_losslessly() {
555        // A component with the layer delimiter, the escape char, a slash, a
556        // space, and a non-UTF-8 byte — all must survive.
557        let nasty = b"a|b%c/d e\xff\x00z".to_vec();
558        let spec = PathSpec::os("/img.raw").push(Layer::Fs {
559            kind: FsKind::EXT,
560            at: NodeAddr::Path(vec![nasty.clone(), b"plain".to_vec()]),
561        });
562        let uri = spec.to_uri();
563        // No raw delimiter leaked from the value into the structure.
564        assert!(!uri.contains("a|b"), "delimiter leaked: {uri}");
565        roundtrip(&spec);
566    }
567
568    #[test]
569    fn every_layer_kind_round_trips() {
570        roundtrip(&PathSpec::os("/x").push(Layer::Range {
571            start: 512,
572            len: 1_048_576,
573        }));
574        roundtrip(&PathSpec::os("/x").push(Layer::Encryption {
575            scheme: EncryptionScheme::Bitlocker,
576        }));
577        roundtrip(&PathSpec::os("/x").push(Layer::Snapshot {
578            store: SnapshotRef::VssStore(4),
579        }));
580        roundtrip(&PathSpec::os("/x").push(Layer::Snapshot {
581            store: SnapshotRef::ApfsXid(9_000_000),
582        }));
583        // Every FileId variant.
584        for id in [
585            FileId::NtfsRef { entry: 5, seq: 2 },
586            FileId::ExtInode { ino: 12, gen: 7 },
587            FileId::ApfsOid { oid: 99, xid: 3 },
588            FileId::FatDirEntry {
589                cluster: 8,
590                index: 1,
591            },
592            FileId::IsoExtent { block: 40 },
593            FileId::Opaque(123),
594        ] {
595            roundtrip(&PathSpec::os("/x").push(Layer::Fs {
596                kind: FsKind::APFS,
597                at: NodeAddr::File(id),
598            }));
599        }
600        // Both (id + observed path).
601        roundtrip(&PathSpec::os("/x").push(Layer::Fs {
602            kind: FsKind::NTFS,
603            at: NodeAddr::Both {
604                path: vec![b"Users".to_vec(), b"beth".to_vec()],
605                id: FileId::NtfsRef { entry: 91, seq: 1 },
606            },
607        }));
608        // Stream variants.
609        for s in [
610            StreamId::Default,
611            StreamId::Named(7),
612            StreamId::ResourceFork,
613            StreamId::Xattr(2),
614            StreamId::Slack,
615        ] {
616            roundtrip(&PathSpec::os("/x").push(Layer::Stream { id: s }));
617        }
618    }
619
620    #[test]
621    fn archive_layer_round_trips() {
622        // `None` = a 1→1 stream peel (bare gz/bz2); `Some(i)` = the i-th member of
623        // a multi-member archive. Both must survive the canonical URI byte-for-byte.
624        roundtrip(&PathSpec::os("/case.tgz").push(Layer::Archive { member: None }));
625        roundtrip(&PathSpec::os("/case.zip").push(Layer::Archive { member: Some(3) }));
626        roundtrip(&PathSpec::os("/case.zip").push(Layer::Archive { member: Some(0) }));
627    }
628
629    #[test]
630    fn from_uri_rejects_malformed_archive_member() {
631        // A non-numeric archive member index is a hard reject, never a panic.
632        assert!(PathSpec::from_uri("fvfs:archive:notanumber").is_err());
633    }
634
635    #[test]
636    fn every_encryption_scheme_token_round_trips() {
637        // Each FDE scheme's `encryption:<scheme>` token must survive to_uri ->
638        // from_uri byte-for-byte, mirroring the container/volume token tests.
639        for scheme in [
640            EncryptionScheme::Bitlocker,
641            EncryptionScheme::Luks1,
642            EncryptionScheme::Luks2,
643            EncryptionScheme::FileVault,
644            EncryptionScheme::ApfsEncrypted,
645            EncryptionScheme::VeraCrypt,
646        ] {
647            roundtrip(&PathSpec::os("/x").push(Layer::Encryption { scheme }));
648        }
649    }
650
651    #[test]
652    fn from_uri_rejects_unknown_encryption_scheme() {
653        // An encryption token outside the known set is a hard reject (loud), never
654        // a fallback or a panic.
655        assert!(PathSpec::from_uri("fvfs:encryption:rot13").is_err());
656    }
657
658    #[test]
659    fn empty_path_round_trips() {
660        roundtrip(&PathSpec::os("/x").push(Layer::Fs {
661            kind: FsKind::ISO9660,
662            at: NodeAddr::Path(vec![]),
663        }));
664    }
665
666    #[test]
667    fn every_fs_kind_token_round_trips() {
668        // Every registered kind of the string-backed newtype must survive
669        // to_uri -> from_uri (the `as_str` / `parse_fs_kind` pair). Iterating
670        // `known()` means a newly-registered kind is covered automatically.
671        for &kind in FsKind::known() {
672            roundtrip(&PathSpec::os("/x").push(Layer::Fs {
673                kind,
674                at: NodeAddr::Path(vec![b"a".to_vec()]),
675            }));
676        }
677    }
678
679    #[test]
680    fn from_uri_rejects_unknown_fs_kind() {
681        // A token outside `known()` must error, not collapse to a fallback: the
682        // newtype has no `Other`, so an unrecognized fs token is a hard reject.
683        assert!(PathSpec::from_uri("fvfs:fs:zzz,path").is_err());
684    }
685
686    #[test]
687    fn from_uri_rejects_garbage() {
688        assert!(PathSpec::from_uri("not-a-spec").is_err());
689        assert!(PathSpec::from_uri("fvfs:").is_err());
690        assert!(PathSpec::from_uri("fvfs:bogustag:x").is_err());
691        assert!(PathSpec::from_uri("fvfs:range:notanumber").is_err());
692    }
693
694    #[cfg(feature = "serde")]
695    #[test]
696    fn serde_round_trips_as_the_canonical_uri() {
697        let spec = PathSpec::os("/evidence/DC01.E01")
698            .push(Layer::Container {
699                format: ContainerFormat::Ewf,
700            })
701            .push(Layer::Fs {
702                kind: FsKind::NTFS,
703                at: NodeAddr::Path(vec![b"Windows".to_vec(), b"a/b".to_vec()]),
704            });
705        let json = serde_json::to_string(&spec).expect("serialize");
706        // Serialized form IS the canonical URI, as a JSON string.
707        assert_eq!(json, format!("\"{}\"", spec.to_uri()));
708        let back: PathSpec = serde_json::from_str(&json).expect("deserialize");
709        assert_eq!(back, spec);
710        // A malformed URI string is a deserialization error, not a panic.
711        assert!(serde_json::from_str::<PathSpec>("\"not-a-spec\"").is_err());
712    }
713
714    #[test]
715    fn human_display_is_readable_and_not_the_uri() {
716        let spec = PathSpec::os("/evidence/DC01.E01")
717            .push(Layer::Container {
718                format: ContainerFormat::Ewf,
719            })
720            .push(Layer::Fs {
721                kind: FsKind::NTFS,
722                at: NodeAddr::Path(vec![b"Windows".to_vec()]),
723            });
724        let human = format!("{spec}");
725        assert!(human.contains("DC01.E01"), "human form: {human}");
726        assert!(human.contains("Windows"), "human form: {human}");
727        assert_ne!(human, spec.to_uri());
728    }
729}