ferrocrypt 0.3.0

Recipient-oriented file and directory encryption: passphrase (Argon2id) and X25519 public-key recipients, XChaCha20-Poly1305 STREAM payloads, HKDF-SHA3-256 / HMAC-SHA3-256 key derivation and authentication.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
//! General path helpers — special-file-safe input opening, bounded and
//! staged key-file reads, occupied-path rejection, output base-name
//! derivation, parent-directory resolution, and user-path I/O error
//! mapping.
//!
//! Centralises small filesystem-level helpers that do not belong to a single
//! crypto or container module.

use std::ffi::OsStr;
use std::fs::File;
use std::io;
use std::io::Read;
use std::path::Path;

use crate::CryptoError;
use crate::error::{sanitize_for_display, sanitize_path_for_display, sanitize_prefix_for_display};

/// Suffix appended to atomic-write working names so plaintext (or any
/// not-yet-finalised output) is never visible under the final name.
/// Used by `container::write_encrypted_file` for the streaming
/// `.fcr` tempfile and by `archive::decode::unarchive` for the per-root
/// rename-into-place pattern. `pub` (in a private module) so
/// `fuzz_exports` can re-export it for the full-pipeline fuzz oracle.
///
/// It is also the suffix of the extraction working name
/// `{root}.incomplete` that `FORMAT.md` §9.11 step 10 fixes, so its text
/// must not change. The frozen §9.6 component limit reserves exactly its
/// length, and `archive::path` fails the build when that length changes;
/// a change of the text alone is not detected.
pub const INCOMPLETE_SUFFIX: &str = ".incomplete";

/// Conflict-message label for the encrypted-file or extracted output
/// artefact ("Output already exists: …"). The occupancy pre-check
/// ([`reject_occupied`]) and the no-clobber commit
/// (`fs::atomic::finalize_file`) for the same operation must pass the
/// same label so their two messages read identically; a shared
/// constant makes that structural rather than a per-site literal.
pub(crate) const OUTPUT_LABEL: &str = "Output";

/// Conflict-message label for a generated key file ("Key file already
/// exists: …"). Paired between the keygen pre-check and its no-clobber
/// commit the same way as [`OUTPUT_LABEL`].
pub(crate) const KEY_FILE_LABEL: &str = "Key file";

/// Builds the "Input name is not valid UTF-8: `<path>`" rejection.
/// FCA paths are UTF-8 (`FORMAT.md` §9.6), so a non-UTF-8 input name
/// can never encrypt. Shared by the default-output-name path
/// ([`encryption_base_name`]) and the archive writer's root-name check
/// so both surfaces of the same rejection read identically.
pub(crate) fn input_name_not_utf8_error(path: &Path) -> CryptoError {
    CryptoError::InvalidInput(format!(
        "Input name is not valid UTF-8: {}",
        sanitize_path_for_display(path)
    ))
}

/// Builds the "Unsupported file type: `<path>`" rejection for an input
/// that is neither a regular file nor a directory (a FIFO, socket, or
/// device node). Shared by [`open_input_file`] and the archive
/// writer's input-root checks.
pub(crate) fn unsupported_file_type_error(path: &Path) -> CryptoError {
    CryptoError::InvalidInput(format!(
        "Unsupported file type: {}",
        sanitize_path_for_display(path)
    ))
}

/// Opens `path` read-only for header probing, decryption, or key-file
/// reading, refusing FIFOs, sockets, and device nodes.
///
/// On Unix the open itself uses `O_NONBLOCK`, so an attacker-placed
/// FIFO cannot block the process inside `open(2)` (without that flag,
/// opening a FIFO read-only blocks until a writer appears). The file
/// type is then checked on the open handle, leaving no window between
/// check and use; `O_NONBLOCK` has no effect on regular-file reads.
/// Directories pass through deliberately — each caller keeps its
/// established directory handling (the probe short-circuits them,
/// `Decryptor::open` refuses them as directories, and decrypt surfaces the
/// platform's directory-read error).
///
/// A missing path maps to the typed [`CryptoError::InputPath`] via
/// [`map_user_path_io_error`], so every caller reports a missing input
/// with the same typed error. Other open failures surface as
/// [`CryptoError::Io`].
pub(crate) fn open_input_file(path: &Path) -> Result<File, CryptoError> {
    let mut options = File::options();
    options.read(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        options.custom_flags(libc::O_NONBLOCK);
    }
    let file = options.open(path).map_err(map_user_path_io_error)?;
    let file_type = file.metadata().map_err(CryptoError::Io)?.file_type();
    if !file_type.is_file() && !file_type.is_dir() {
        return Err(unsupported_file_type_error(path));
    }
    Ok(file)
}

/// Reads `path` into memory, refusing files whose byte length exceeds
/// `cap`. The read stops at `cap + 1` bytes, so a caller pointed at a
/// multi-gigabyte file refuses it without reading it all into memory. The
/// `over_cap_error` closure supplies the typed rejection so the caller can
/// route the failure to its own diagnostic class (`MalformedPublicKey` for
/// a `public.key`). The file is opened through [`open_input_file`], so
/// FIFOs, sockets, and device nodes are refused without blocking.
pub(crate) fn read_file_capped(
    path: &Path,
    cap: usize,
    over_cap_error: impl FnOnce() -> CryptoError,
) -> Result<Vec<u8>, CryptoError> {
    let mut file = open_input_file(path)?;
    let mut buf = Vec::with_capacity(initial_reserve(cap.saturating_add(1)));
    let read = file
        .by_ref()
        .take((cap as u64).saturating_add(1))
        .read_to_end(&mut buf)
        .map_err(CryptoError::Io)?;
    if read > cap {
        return Err(over_cap_error());
    }
    Ok(buf)
}

/// Reads `path` in two stages so a file is never pulled into memory at
/// its format's structural maximum when its own header declares less.
///
/// Reads `head_len` bytes, passes them to `remaining_len`, then reads
/// one byte beyond whatever that returns — the extra byte lets a caller
/// checking an exact total still see a file with trailing bytes as too
/// long. A file shorter than `head_len` is returned as read, for the
/// caller to reject.
///
/// A remainder above `cap - head_len` is clamped to it, so the read
/// stays within `cap + 1` bytes whatever `remaining_len` returns, provided
/// the caller supplies `head_len <= cap`.
pub(crate) fn read_file_staged(
    path: &Path,
    head_len: usize,
    cap: usize,
    remaining_len: impl FnOnce(&[u8]) -> usize,
) -> Result<Vec<u8>, CryptoError> {
    let mut file = open_input_file(path)?;
    let mut buf = Vec::with_capacity(initial_reserve(head_len));
    file.by_ref()
        .take(head_len as u64)
        .read_to_end(&mut buf)
        .map_err(CryptoError::Io)?;
    if buf.len() < head_len {
        return Ok(buf);
    }

    // `remaining_len` derives its answer from attacker-controlled header
    // bytes, so the cap bounds the read here rather than at each call site.
    let remaining = remaining_len(&buf).min(cap.saturating_sub(head_len));
    let rest = (remaining as u64).saturating_add(1);
    buf.reserve(initial_reserve(remaining));
    file.by_ref()
        .take(rest)
        .read_to_end(&mut buf)
        .map_err(CryptoError::Io)?;
    Ok(buf)
}

/// Upper bound on the buffer a bounded read reserves up front. A
/// declared length is attacker-controlled, so the reserve grows on
/// demand rather than trusting it in one allocation.
const INITIAL_READ_RESERVE_BYTES: usize = 64 * 1024;

fn initial_reserve(wanted: usize) -> usize {
    wanted.min(INITIAL_READ_RESERVE_BYTES)
}

pub(crate) fn file_stem(filename: &Path) -> Result<&OsStr, CryptoError> {
    filename
        .file_stem()
        .ok_or_else(|| CryptoError::InvalidInput("Cannot get file stem".to_string()))
}

/// Returns the final component of a user-supplied input path.
///
/// `.` and `..` point at a directory without naming it, so they have no
/// final component of their own, and neither does a path ending in one of
/// them such as `photos/..`. A path with no final component is resolved
/// against the current directory first, and the resolved path supplies the
/// name. Every other path keeps the name as typed, symlinks included.
///
/// A path that still has no final component once resolved is the
/// filesystem root, which has no name to encrypt under, and rejects with
/// [`CryptoError::InvalidInput`]. A failed resolution goes through
/// [`map_user_path_io_error`], so a missing input reports the same typed
/// error as every other input read.
///
/// FCA paths are UTF-8 (`FORMAT.md` §9.6), so the name is returned as a
/// `String` and a name that is not UTF-8 rejects here. Both rejections
/// name the path the leaf came from, which for a resolved path is the
/// resolved directory rather than what the caller typed.
///
/// Shared by [`encryption_base_name`], which derives the default output
/// file name, and the archive writer, which derives the archive root name,
/// so both name an input the same way.
pub(crate) fn input_leaf_name(path: &Path) -> Result<String, CryptoError> {
    if let Some(name) = path.file_name() {
        return os_str_to_utf8_name(name, path);
    }
    let resolved = std::fs::canonicalize(path).map_err(map_user_path_io_error)?;
    let Some(name) = resolved.file_name() else {
        return Err(CryptoError::InvalidInput(format!(
            "Cannot determine the input name: {}",
            sanitize_path_for_display(&resolved)
        )));
    };
    os_str_to_utf8_name(name, &resolved)
}

/// Converts one path component to `String`, reporting `source` as the
/// offending path when the bytes are not UTF-8.
fn os_str_to_utf8_name(name: &OsStr, source: &Path) -> Result<String, CryptoError> {
    name.to_str()
        .map(str::to_owned)
        .ok_or_else(|| input_name_not_utf8_error(source))
}

/// Returns the base name for building the default encrypted output filename.
/// For regular files, returns the file stem (without extension).
/// For directories, returns the full directory name (preserving dots like `photos.v1`).
/// A directory given as `.` or `..` takes its name from [`input_leaf_name`].
///
/// Returns [`CryptoError::InvalidInput`] when the name is not valid
/// UTF-8: FCA paths are UTF-8 (`FORMAT.md` §9.6), so such an input can
/// never encrypt anyway, and a lossy replacement could silently
/// collapse two distinct on-disk names into the same output name.
///
/// Uses `symlink_metadata` (lstat) rather than `Path::is_dir` so a
/// symlink that races into place between the earlier
/// `validate_encrypt_input` symlink check and this lookup cannot be
/// followed to a directory and silently change the chosen output
/// name. The later `open_no_follow` would still abort the archive step,
/// but defending here keeps the directory-vs-file classification honest.
/// `NotFound` falls through to the file branch (race against deletion);
/// other I/O errors propagate so `PermissionDenied` is not misreported as
/// "not a directory" and downgraded into a confusing later failure.
pub(crate) fn encryption_base_name(path: impl AsRef<Path>) -> Result<String, CryptoError> {
    let path = path.as_ref();
    let is_real_dir = match std::fs::symlink_metadata(path) {
        Ok(m) => m.file_type().is_dir(),
        Err(e) if e.kind() == io::ErrorKind::NotFound => false,
        Err(e) => return Err(CryptoError::Io(e)),
    };
    if is_real_dir {
        input_leaf_name(path)
    } else {
        os_str_to_utf8_name(file_stem(path)?, path)
    }
}

/// Returns `true` if anything occupies `path`, including a dangling
/// symlink. Uses `symlink_metadata` (lstat) so a symlink whose target
/// is missing is reported as occupied — `Path::exists()` would follow
/// the link and return `false`, letting an output preflight pass even
/// though the final no-clobber rename would later refuse to overwrite
/// the dangling link. Used by the encrypt / keygen output-precheck
/// sites so a stale symlink rejects in milliseconds instead of after
/// Argon2id / file-key wrapping.
pub(crate) fn path_occupied(path: &Path) -> Result<bool, CryptoError> {
    match std::fs::symlink_metadata(path) {
        Ok(_) => Ok(true),
        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
        Err(e) => Err(CryptoError::Io(e)),
    }
}

/// Builds the "`<label>` already exists: `<path>`" rejection. Shared by
/// [`reject_occupied`] (pre-write occupancy check),
/// `fs::atomic::finalize_file` (no-clobber rename refusal), and the
/// archive extractor's occupied-output and occupied-staging rejections,
/// so every surface of the same conflict renders identically.
///
/// The parent directory is the caller's trust boundary: kept readable
/// and never truncated so the operator can always locate the conflict,
/// but control and bidirectional-formatting characters in it are still
/// escaped so a hostile intermediate component cannot smuggle a terminal
/// escape sequence into the message. The final component can be
/// attacker-influenced (an input-derived file stem, an archive-chosen
/// root name), so it is routed through the stricter, length-bounded
/// sanitizer.
pub(crate) fn already_exists_error(label: &str, path: &Path) -> CryptoError {
    CryptoError::InvalidInput(format!(
        "{label} already exists: {}",
        sanitize_path_keeping_parent(path)
    ))
}

/// Renders a path whose parent is the caller's and whose final component
/// may be attacker-influenced, on the terms [`already_exists_error`]
/// describes: the parent readable and untruncated, the final component
/// through the strict, length-bounded sanitizer. Shared with the
/// decrypt cleanup report, which names the staged working path the same
/// way.
pub(crate) fn sanitize_path_keeping_parent(path: &Path) -> String {
    let full = path.display().to_string();
    match path.file_name().map(|n| n.to_string_lossy()) {
        // The match guard confirmed `full` ends with the file name, so
        // the bytes before it are the parent prefix: render that
        // readable and the final component strictly.
        Some(name) if name.len() < full.len() && full.ends_with(name.as_ref()) => {
            format!(
                "{}{}",
                sanitize_prefix_for_display(&full[..full.len() - name.len()]),
                sanitize_for_display(&name)
            )
        }
        // No directory prefix to keep (bare file name, filesystem
        // root, or a `..` tail): sanitize the whole path.
        _ => sanitize_for_display(&full),
    }
}

/// Returns `Err(InvalidInput)` if `path` is occupied (real file, real
/// directory, or dangling symlink). `label` is the artefact name in the
/// message prefix; callers pass [`OUTPUT_LABEL`] or [`KEY_FILE_LABEL`]
/// and must reuse the same value at the paired
/// `fs::atomic::finalize_file` commit so both messages match.
pub(crate) fn reject_occupied(path: &Path, label: &str) -> Result<(), CryptoError> {
    if path_occupied(path)? {
        return Err(already_exists_error(label, path));
    }
    Ok(())
}

/// Returns the parent directory of `path`, or `Path::new(".")` when the
/// parent is empty or absent. Centralises the "directory in which to
/// create the staging tempfile / open a dirfd for `sync_all`" lookup
/// shared by `fs::atomic` and `container`.
pub(crate) fn parent_or_cwd(path: &Path) -> &Path {
    path.parent()
        .filter(|p| !p.as_os_str().is_empty())
        .unwrap_or_else(|| Path::new("."))
}

/// Converts an [`io::Error`] from a user-supplied path read into a
/// typed [`CryptoError`]. `NotFound` maps to [`CryptoError::InputPath`]
/// so a missing input reports the same typed error from every caller.
/// Everything else falls through to [`CryptoError::Io`].
pub(crate) fn map_user_path_io_error(e: io::Error) -> CryptoError {
    if e.kind() == io::ErrorKind::NotFound {
        CryptoError::InputPath
    } else {
        CryptoError::Io(e)
    }
}

/// Whether a failure to reach an entry by path says that the path no longer
/// leads there: an entry on it is missing, is not a directory where the path
/// needs one, or is a symlink cycle. Each is a change someone made to the
/// path, never an environment fault. The cycle is matched by raw OS error
/// code because `std` has no stable `io::ErrorKind` for it yet.
pub(crate) fn path_no_longer_resolves(error: &io::Error) -> bool {
    matches!(
        error.kind(),
        io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
    ) || error.raw_os_error() == Some(SYMLINK_LOOP_CODE)
}

/// Raw OS error code for a symlink cycle.
#[cfg(unix)]
const SYMLINK_LOOP_CODE: i32 = libc::ELOOP;

/// Raw OS error code for a symlink cycle: what Windows returns for a
/// reparse-point cycle.
#[cfg(windows)]
const SYMLINK_LOOP_CODE: i32 = ERROR_CANT_RESOLVE_FILENAME;

/// `ERROR_CANT_RESOLVE_FILENAME` from `WinError.h`.
#[cfg(windows)]
const ERROR_CANT_RESOLVE_FILENAME: i32 = 1921;

/// Test helper that creates a FIFO through the POSIX `mkfifo` utility.
/// It is shared by special-file tests in this module and in the archive
/// layer. Using a subprocess avoids an unsafe `libc::mkfifo` call;
/// `rustix` does not expose FIFO creation on Apple targets.
#[cfg(all(test, unix))]
pub(crate) fn make_fifo(path: &Path) {
    let status = std::process::Command::new("mkfifo")
        .arg(path)
        .status()
        .expect("spawn mkfifo");
    assert!(status.success(), "mkfifo failed for {}", path.display());
}

#[cfg(test)]
mod tests {
    use super::*;

    fn write_temp(bytes: &[u8]) -> (tempfile::TempDir, std::path::PathBuf) {
        let dir = tempfile::TempDir::new().unwrap();
        let path = dir.path().join("input");
        std::fs::write(&path, bytes).unwrap();
        (dir, path)
    }

    fn over_cap() -> CryptoError {
        CryptoError::InvalidInput("over cap".to_string())
    }

    /// A file exactly at the cap is accepted and one byte over is
    /// rejected through the caller's closure, so the boundary is
    /// inclusive and the caller keeps its own diagnostic class.
    #[test]
    fn read_file_capped_boundary_is_inclusive() {
        const CAP: usize = 64;

        let (_dir, at_cap) = write_temp(&[0x41; CAP]);
        let read = read_file_capped(&at_cap, CAP, over_cap).unwrap();
        assert_eq!(read.len(), CAP);

        let (_dir, over) = write_temp(&[0x41; CAP + 1]);
        match read_file_capped(&over, CAP, over_cap) {
            Err(CryptoError::InvalidInput(msg)) => assert_eq!(msg, "over cap"),
            other => panic!("expected the supplied over-cap error, got {other:?}"),
        }
    }

    /// A head shorter than `head_len` is handed back as read, for the
    /// caller to classify, and `remaining_len` never sees it: a caller
    /// may index its whole head.
    #[test]
    fn read_file_staged_returns_a_short_head_as_read() {
        let (_dir, path) = write_temp(b"ab");
        let read = read_file_staged(&path, 8, 64, |_| {
            unreachable!("a short head is returned before remaining_len runs")
        })
        .unwrap();
        assert_eq!(read, b"ab");
    }

    /// With a declared remainder the read stops one byte past it, which
    /// is what lets a caller checking an exact total notice a file that
    /// carries trailing bytes.
    #[test]
    fn read_file_staged_reads_one_byte_past_the_declared_length() {
        let (_dir, path) = write_temp(&[0x41; 100]);
        let read = read_file_staged(&path, 4, 1024, |_| 10).unwrap();
        assert_eq!(read.len(), 4 + 10 + 1);
    }

    /// A declared remainder larger than the cap allows cannot widen the
    /// read: the helper clamps it, so the buffer stops at `cap + 1`
    /// bytes.
    #[test]
    fn read_file_staged_clamps_a_declared_length_above_the_cap() {
        const HEAD: usize = 4;
        const CAP: usize = 64;

        let (_dir, path) = write_temp(&[0x41; CAP * 4]);
        let read = read_file_staged(&path, HEAD, CAP, |_| usize::MAX).unwrap();
        assert_eq!(read.len(), CAP + 1);
    }

    /// An ordinary path keeps the name the caller typed. Nothing is
    /// resolved, so a path that does not exist still yields a name and a
    /// symlink keeps its own name rather than its target's.
    #[test]
    fn input_leaf_name_keeps_a_typed_name() {
        assert_eq!(input_leaf_name(Path::new("dir/photos")).unwrap(), "photos");
        assert_eq!(input_leaf_name(Path::new("photos")).unwrap(), "photos");
    }

    /// `.` and `..` are natural things to type and carry no name of their
    /// own, so both take the name of the directory they resolve to.
    #[test]
    fn input_leaf_name_resolves_dot_and_dot_dot() {
        let cwd = std::fs::canonicalize(".").unwrap();
        assert_eq!(
            input_leaf_name(Path::new(".")).unwrap(),
            cwd.file_name().unwrap().to_str().unwrap()
        );

        let parent = cwd.parent().unwrap();
        assert_eq!(
            input_leaf_name(Path::new("..")).unwrap(),
            parent.file_name().unwrap().to_str().unwrap()
        );
    }

    /// FCA paths are UTF-8, so a leaf that is not rejects here rather than
    /// being lossily replaced. The rejection names the path the leaf came
    /// from — the typed path in this branch. Unix-only because building a
    /// non-UTF-8 name from raw bytes needs the Unix `OsStrExt`.
    #[cfg(unix)]
    #[test]
    fn input_leaf_name_rejects_a_non_utf8_leaf() {
        use std::os::unix::ffi::OsStrExt;

        let path = Path::new(OsStr::from_bytes(b"dir/bad\xFFname"));
        match input_leaf_name(path) {
            Err(CryptoError::InvalidInput(msg)) => {
                assert!(
                    msg.starts_with("Input name is not valid UTF-8: dir/"),
                    "got: {msg}"
                );
            }
            other => panic!("expected InvalidInput for a non-UTF-8 leaf, got {other:?}"),
        }
    }

    /// The filesystem root has no name to encrypt under, and resolution
    /// cannot invent one, so it rejects instead of falling back to a
    /// surprising name. Unix-only because the Windows root resolves
    /// against the current drive, so the rendered path is not fixed.
    #[cfg(unix)]
    #[test]
    fn input_leaf_name_rejects_the_filesystem_root() {
        match input_leaf_name(Path::new("/")) {
            Err(CryptoError::InvalidInput(msg)) => {
                assert_eq!(msg, "Cannot determine the input name: /");
            }
            other => panic!("expected InvalidInput for the filesystem root, got {other:?}"),
        }
    }

    #[test]
    fn test_encryption_base_name_file() {
        let stem = encryption_base_name("path/to/file.txt").unwrap();
        assert_eq!(stem, "file");
    }

    #[test]
    fn test_encryption_base_name_no_extension() {
        let stem = encryption_base_name("path/to/file").unwrap();
        assert_eq!(stem, "file");
    }

    #[test]
    fn test_encryption_base_name_dotted_directory() {
        let tmp = tempfile::TempDir::new().unwrap();
        let dotted_dir = tmp.path().join("photos.v1");
        std::fs::create_dir(&dotted_dir).unwrap();
        let name = encryption_base_name(&dotted_dir).unwrap();
        assert_eq!(name, "photos.v1");
    }

    /// A non-UTF-8 input name rejects with a typed error instead of
    /// lossy-replacing the invalid bytes: two distinct on-disk names
    /// could otherwise collapse to the same computed output name, and
    /// the FCA layer would refuse the name later anyway. Unix-only —
    /// Windows file names are always valid Unicode at this API level.
    #[cfg(unix)]
    #[test]
    fn encryption_base_name_rejects_non_utf8_name() {
        use std::os::unix::ffi::OsStrExt;
        let name = OsStr::from_bytes(b"bad\xFFname.txt");
        match encryption_base_name(Path::new(name)) {
            Err(CryptoError::InvalidInput(msg)) => {
                assert!(
                    msg.starts_with("Input name is not valid UTF-8: "),
                    "got: {msg}"
                );
            }
            other => panic!("expected InvalidInput for non-UTF-8 name, got {other:?}"),
        }
    }

    /// Fallback arm of the conflict-message renderer: a path with no
    /// directory prefix (a bare file name, as `save_as` can supply)
    /// sanitizes the whole path, so malicious bytes in a bare name are
    /// escaped the same way as in a name under a directory.
    #[test]
    fn already_exists_error_escapes_bare_hostile_name() {
        match already_exists_error("Output", Path::new("evil\u{1b}.fcr")) {
            CryptoError::InvalidInput(msg) => {
                assert_eq!(msg, "Output already exists: evil\\u{1b}.fcr");
            }
            other => panic!("expected InvalidInput, got {other:?}"),
        }
    }

    /// Fast arm with a multibyte directory prefix: the split between the
    /// readable prefix and the escaped leaf lands on a UTF-8 boundary
    /// (the byte slice must not panic), the non-ASCII directory name
    /// stays readable so the operator can read it, and the malicious
    /// leaf is escaped.
    #[test]
    fn already_exists_error_keeps_multibyte_dir_readable_escapes_name() {
        match already_exists_error("Key file", Path::new("Données/evil\u{202e}.key")) {
            CryptoError::InvalidInput(msg) => {
                assert_eq!(msg, "Key file already exists: Données/evil\\u{202e}.key");
            }
            other => panic!("expected InvalidInput, got {other:?}"),
        }
    }

    /// Control and bidi characters in an *intermediate* directory
    /// component are escaped too, not only in the final component. The
    /// parent stays whole and readable, but it must never carry a raw
    /// terminal escape, newline, or direction-override into the message.
    /// The direction-override assertion is separate from the control-byte
    /// one because a bidi character is not an ASCII control byte.
    #[test]
    fn already_exists_error_escapes_hostile_parent_component() {
        match already_exists_error("Output", Path::new("sa\u{202e}fe/ev\u{1b}il\ndir/file.fcr")) {
            CryptoError::InvalidInput(msg) => {
                assert_eq!(
                    msg,
                    "Output already exists: sa\\u{202e}fe/ev\\u{1b}il\\ndir/file.fcr"
                );
                assert!(
                    !msg.chars().any(char::is_control),
                    "raw control character leaked from parent: {msg:?}"
                );
                assert!(
                    !msg.contains('\u{202e}'),
                    "raw direction-override leaked from parent: {msg:?}"
                );
            }
            other => panic!("expected InvalidInput, got {other:?}"),
        }
    }

    /// A malicious file name at an occupied output path renders escaped:
    /// the rejection message must never carry raw control bytes to a
    /// terminal. The parent directory stays readable so the operator can
    /// locate the conflict; the file name has its own display budget,
    /// so the escaped form appears in full regardless of how long the
    /// directory prefix is. Unix-only — Windows refuses control bytes
    /// in file names at creation time.
    #[cfg(unix)]
    #[test]
    fn reject_occupied_escapes_hostile_file_name() {
        let tmp = tempfile::TempDir::new().unwrap();
        let hostile = tmp.path().join("evil\u{1b}]0;pwned\u{7}.txt");
        std::fs::write(&hostile, b"x").unwrap();
        match reject_occupied(&hostile, "Output") {
            Err(CryptoError::InvalidInput(msg)) => {
                assert!(msg.starts_with("Output already exists: "), "got: {msg}");
                assert!(
                    msg.contains("evil\\u{1b}]0;pwned\\u{7}.txt"),
                    "escaped file name must appear in full: {msg}"
                );
                assert!(
                    !msg.chars().any(char::is_control),
                    "raw control character leaked: {msg:?}"
                );
            }
            other => panic!("expected InvalidInput, got {other:?}"),
        }
    }

    #[test]
    fn parent_or_cwd_returns_parent_when_present() {
        assert_eq!(parent_or_cwd(Path::new("dir/file.txt")), Path::new("dir"));
        assert_eq!(parent_or_cwd(Path::new("/abs/file.txt")), Path::new("/abs"));
    }

    #[test]
    fn parent_or_cwd_falls_back_to_cwd() {
        // `Path::parent` returns `Some("")` for a bare filename and `None` for
        // the empty path; both must collapse to ".".
        assert_eq!(parent_or_cwd(Path::new("file.txt")), Path::new("."));
        assert_eq!(parent_or_cwd(Path::new("")), Path::new("."));
    }

    /// Filesystem-root inputs are unreachable in production (no caller
    /// stages a tempfile at `/`), but the fallback semantic ("parent of
    /// `/` is the current working directory") would silently mis-route
    /// such a stage. Pinning the existing behaviour with a test so a
    /// future caller that tries this hits a clear contract rather than
    /// a surprise.
    #[cfg(unix)]
    #[test]
    fn parent_or_cwd_root_path_falls_back_to_cwd() {
        assert_eq!(parent_or_cwd(Path::new("/")), Path::new("."));
    }

    /// FIFO input tests. Windows has no FIFO file type in the filesystem
    /// namespace. Without the `open_input_file` check, opening a FIFO for
    /// reading can wait indefinitely for a writer.
    #[cfg(unix)]
    mod special_file_inputs {
        use super::*;

        #[test]
        fn open_input_file_rejects_fifo_without_blocking() {
            let tmp = tempfile::TempDir::new().unwrap();
            let fifo = tmp.path().join("pipe.fcr");
            make_fifo(&fifo);
            match open_input_file(&fifo) {
                Err(CryptoError::InvalidInput(msg)) => {
                    assert!(msg.contains("Unsupported file type"), "got: {msg}");
                }
                other => panic!("expected InvalidInput, got {other:?}"),
            }
        }

        /// The unsupported-file-type rejection escapes a malicious input
        /// name; the message must never carry raw control bytes.
        #[test]
        fn open_input_file_escapes_hostile_fifo_name() {
            let tmp = tempfile::TempDir::new().unwrap();
            let fifo = tmp.path().join("pipe\u{1b}[2K.fcr");
            make_fifo(&fifo);
            match open_input_file(&fifo) {
                Err(CryptoError::InvalidInput(msg)) => {
                    assert!(msg.starts_with("Unsupported file type: "), "got: {msg}");
                    assert!(
                        !msg.chars().any(char::is_control),
                        "raw control character leaked: {msg:?}"
                    );
                }
                other => panic!("expected InvalidInput, got {other:?}"),
            }
        }

        #[test]
        fn read_file_capped_rejects_fifo() {
            let tmp = tempfile::TempDir::new().unwrap();
            let fifo = tmp.path().join("private.key");
            make_fifo(&fifo);
            match read_file_capped(&fifo, 1024, || {
                CryptoError::InvalidInput("over cap".to_string())
            }) {
                Err(CryptoError::InvalidInput(msg)) => {
                    assert!(msg.contains("Unsupported file type"), "got: {msg}");
                }
                other => panic!("expected InvalidInput, got {other:?}"),
            }
        }

        #[test]
        fn open_input_file_accepts_regular_file() {
            let tmp = tempfile::TempDir::new().unwrap();
            let file = tmp.path().join("regular");
            std::fs::write(&file, b"bytes").unwrap();
            open_input_file(&file).expect("regular file must open");
        }
    }

    /// A missing path maps to the typed `InputPath`, not a raw
    /// `Io(NotFound)`.
    #[test]
    fn open_input_file_maps_missing_path_to_input_path() {
        let tmp = tempfile::TempDir::new().unwrap();
        let missing = tmp.path().join("absent.fcr");
        match open_input_file(&missing) {
            Err(CryptoError::InputPath) => {}
            other => panic!("expected InputPath, got {other:?}"),
        }
    }

    #[test]
    fn path_occupied_returns_false_for_missing_path() {
        let tmp = tempfile::TempDir::new().unwrap();
        let absent = tmp.path().join("does-not-exist");
        assert!(!path_occupied(&absent).unwrap());
    }

    #[test]
    fn path_occupied_returns_true_for_real_file() {
        let tmp = tempfile::TempDir::new().unwrap();
        let file = tmp.path().join("real");
        std::fs::write(&file, b"x").unwrap();
        assert!(path_occupied(&file).unwrap());
    }

    /// A dangling symlink (target missing) must be reported as occupied
    /// so encrypt / keygen output prechecks reject it before any
    /// expensive KDF / wrapping work runs. `Path::exists()` follows the
    /// link and would return `false`, masking the conflict until the
    /// atomic no-clobber rename. Unix-only because Windows symlink
    /// creation requires elevated privileges and the platform-level
    /// hardening tests already cover that surface.
    #[cfg(unix)]
    #[test]
    fn path_occupied_returns_true_for_dangling_symlink() {
        use std::os::unix::fs::symlink;

        let tmp = tempfile::TempDir::new().unwrap();
        let link = tmp.path().join("dangling");
        symlink(tmp.path().join("absent-target"), &link).unwrap();
        assert!(!link.exists(), "sanity: target really is missing");
        assert!(path_occupied(&link).unwrap());
    }

    /// A missing entry, a non-directory where the path needs a directory,
    /// and a symlink cycle each say the path no longer leads to its entry;
    /// a failure that says nothing about the path, such as a denied read,
    /// does not.
    #[test]
    fn path_no_longer_resolves_only_for_a_changed_path() {
        for error in [
            io::Error::from(io::ErrorKind::NotFound),
            io::Error::from(io::ErrorKind::NotADirectory),
            io::Error::from_raw_os_error(SYMLINK_LOOP_CODE),
        ] {
            assert!(path_no_longer_resolves(&error), "{error}");
        }
        for error in [
            io::Error::from(io::ErrorKind::PermissionDenied),
            io::Error::other("storage failure"),
        ] {
            assert!(!path_no_longer_resolves(&error), "{error}");
        }
    }
}