libsignify_rs/
file.rs

1//
2// signify-rs: cryptographically sign and verify files
3// lib/src/file.rs: File utility functions
4//
5// Copyright (c) 2025, 2026 Ali Polatel <alip@chesswob.org>
6// Based in part upon OpenBSD's signify which is:
7//   Copyright (c) 2013 Ted Unangst <tedu@openbsd.org>
8//   Copyright (c) 2016 Marc Espie <espie@openbsd.org>
9//   Copyright (c) 2019 Adrian Perez de Castro <aperez@igalia.com>
10//   Copyright (c) 2019 Scott Bennett and other contributors
11//   SPDX-License-Identifier: ISC
12//
13// SPDX-License-Identifier: ISC
14
15use crate::crypto::{CHECKSUM_LEN, KDFALG, KEYNUMLEN, PKALG, SALT_LEN};
16use crate::error::{Error, Result};
17use base64ct::{Base64, Encoding as _};
18use core::str;
19use memchr::memchr;
20use static_assertions::assert_eq_size;
21use std::fs::File;
22use std::io::stdin;
23use std::io::stdout;
24use std::io::{Read, Write};
25use std::path::Component;
26use std::path::Path;
27use zeroize::Zeroize;
28use zeroize::Zeroizing;
29
30/// Untrusted comment header.
31pub const COMMENTHDR: &str = "untrusted comment: ";
32/// Max comment length.
33pub const MAX_COMMENT_LEN: usize = 1024;
34
35/// Encrypted secret key structure.
36///
37/// Stores the encrypted secret key along with KDF parameters and checksums.
38#[repr(C)]
39#[derive(Debug)]
40pub struct EncKey {
41    /// Public key algorithm (must be "Ed").
42    pub pkalg: [u8; 2],
43    /// KDF algorithm (must be "BK").
44    pub kdfalg: [u8; 2],
45    /// Number of KDF rounds.
46    pub kdfrounds: u32,
47    /// Salt for KDF.
48    pub salt: [u8; SALT_LEN],
49    /// Checksum of the decrypted key.
50    pub checksum: [u8; CHECKSUM_LEN],
51    /// Key ID (`KeyNum`).
52    pub keynum: [u8; KEYNUMLEN],
53    /// Encrypted secret key data.
54    pub seckey: Zeroizing<[u8; 64]>,
55}
56
57impl Zeroize for EncKey {
58    fn zeroize(&mut self) {
59        self.pkalg.zeroize();
60        self.kdfalg.zeroize();
61        self.kdfrounds.zeroize();
62        self.salt.zeroize();
63        self.checksum.zeroize();
64        self.keynum.zeroize();
65        self.seckey.zeroize();
66    }
67}
68
69impl Drop for EncKey {
70    fn drop(&mut self) {
71        self.zeroize();
72    }
73}
74
75/// Public key structure.
76#[repr(C)]
77#[derive(Debug, Clone, Copy)]
78pub struct PubKey {
79    /// Public key algorithm (must be "Ed").
80    pub pkalg: [u8; 2],
81    /// Key ID (`KeyNum`).
82    pub keynum: [u8; KEYNUMLEN],
83    /// Public key data.
84    pub pubkey: [u8; 32],
85}
86
87/// Signature structure.
88#[derive(Debug, Clone, Copy)]
89pub struct Sig {
90    /// Public key algorithm (must be "Ed").
91    pub pkalg: [u8; 2],
92    /// Key ID (`KeyNum`).
93    pub keynum: [u8; KEYNUMLEN],
94    /// Signature data.
95    pub sig: [u8; 64],
96}
97
98impl EncKey {
99    /// Parse `EncKey` from raw bytes.
100    ///
101    /// # Errors
102    ///
103    /// Returns `Error::InvalidKeyLength` if byte length is incorrect.
104    /// Returns `Error::UnsupportedPkAlgo` if algorithm is not Ed.
105    /// Returns `Error::UnsupportedKdfAlgo` if algorithm is not BK.
106    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
107        let pkalg = bytes
108            .get(0..2)
109            .ok_or(Error::InvalidKeyLength)?
110            .try_into()
111            .map_err(|_e| Error::InvalidKeyLength)?;
112        let kdfalg = bytes
113            .get(2..4)
114            .ok_or(Error::InvalidKeyLength)?
115            .try_into()
116            .map_err(|_e| Error::InvalidKeyLength)?;
117        let kdfrounds_bytes = bytes.get(4..8).ok_or(Error::InvalidKeyLength)?;
118        let kdfrounds = u32::from_be_bytes(
119            kdfrounds_bytes
120                .try_into()
121                .map_err(|_e| Error::InvalidKeyLength)?,
122        );
123        let salt = bytes
124            .get(8..24)
125            .ok_or(Error::InvalidKeyLength)?
126            .try_into()
127            .map_err(|_e| Error::InvalidKeyLength)?;
128        let checksum = bytes
129            .get(24..32)
130            .ok_or(Error::InvalidKeyLength)?
131            .try_into()
132            .map_err(|_e| Error::InvalidKeyLength)?;
133        let keynum = bytes
134            .get(32..40)
135            .ok_or(Error::InvalidKeyLength)?
136            .try_into()
137            .map_err(|_e| Error::InvalidKeyLength)?;
138        let seckey = Zeroizing::new(
139            bytes
140                .get(40..104)
141                .ok_or(Error::InvalidKeyLength)?
142                .try_into()
143                .map_err(|_e| Error::InvalidKeyLength)?,
144        );
145
146        // Strict length check
147        if bytes.len() != 104 {
148            return Err(Error::InvalidKeyLength);
149        }
150
151        if pkalg != PKALG {
152            return Err(Error::UnsupportedPkAlgo);
153        }
154        if kdfalg != KDFALG {
155            return Err(Error::UnsupportedKdfAlgo);
156        }
157
158        Ok(Self {
159            pkalg,
160            kdfalg,
161            kdfrounds,
162            salt,
163            checksum,
164            keynum,
165            seckey,
166        })
167    }
168
169    /// Serialize `EncKey` to raw bytes.
170    #[must_use]
171    pub fn to_bytes(&self) -> Zeroizing<Vec<u8>> {
172        let mut out = Zeroizing::new(Vec::with_capacity(104));
173        out.extend_from_slice(&self.pkalg);
174        out.extend_from_slice(&self.kdfalg);
175        out.extend_from_slice(&self.kdfrounds.to_be_bytes());
176        out.extend_from_slice(&self.salt);
177        out.extend_from_slice(&self.checksum);
178        out.extend_from_slice(&self.keynum);
179        out.extend_from_slice(self.seckey.as_ref());
180        out
181    }
182}
183
184impl PubKey {
185    /// Parse `PubKey` from bytes.
186    ///
187    /// # Errors
188    ///
189    /// Returns `Error::InvalidKeyLength` if byte length is incorrect.
190    /// Returns `Error::UnsupportedPkAlgo` if algorithm is not Ed.
191    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
192        let pkalg = bytes
193            .get(0..2)
194            .ok_or(Error::InvalidKeyLength)?
195            .try_into()
196            .map_err(|_e| Error::InvalidKeyLength)?;
197        let keynum = bytes
198            .get(2..10)
199            .ok_or(Error::InvalidKeyLength)?
200            .try_into()
201            .map_err(|_e| Error::InvalidKeyLength)?;
202        let pubkey = bytes
203            .get(10..42)
204            .ok_or(Error::InvalidKeyLength)?
205            .try_into()
206            .map_err(|_e| Error::InvalidKeyLength)?;
207
208        if bytes.len() != 42 {
209            return Err(Error::InvalidKeyLength);
210        }
211
212        if pkalg != PKALG {
213            return Err(Error::UnsupportedPkAlgo);
214        }
215
216        Ok(Self {
217            pkalg,
218            keynum,
219            pubkey,
220        })
221    }
222
223    /// Serialize `PubKey` to bytes.
224    #[must_use]
225    pub fn to_bytes(&self) -> Vec<u8> {
226        let mut out = Vec::with_capacity(42);
227        out.extend_from_slice(&self.pkalg);
228        out.extend_from_slice(&self.keynum);
229        out.extend_from_slice(&self.pubkey);
230        out
231    }
232}
233
234impl Sig {
235    /// Parse `Sig` from bytes.
236    ///
237    /// # Errors
238    ///
239    /// Returns `Error::InvalidKeyLength` if byte length is incorrect.
240    /// Returns `Error::UnsupportedPkAlgo` if algorithm is not Ed.
241    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
242        let pkalg = bytes
243            .get(0..2)
244            .ok_or(Error::InvalidKeyLength)?
245            .try_into()
246            .map_err(|_e| Error::InvalidKeyLength)?;
247        let keynum = bytes
248            .get(2..10)
249            .ok_or(Error::InvalidKeyLength)?
250            .try_into()
251            .map_err(|_e| Error::InvalidKeyLength)?;
252        let sig = bytes
253            .get(10..74)
254            .ok_or(Error::InvalidKeyLength)?
255            .try_into()
256            .map_err(|_e| Error::InvalidKeyLength)?;
257
258        if bytes.len() != 74 {
259            return Err(Error::InvalidKeyLength);
260        }
261
262        if pkalg != PKALG {
263            return Err(Error::UnsupportedPkAlgo);
264        }
265
266        Ok(Self { pkalg, keynum, sig })
267    }
268
269    /// Serialize `Sig` to bytes.
270    #[must_use]
271    pub fn to_bytes(&self) -> Vec<u8> {
272        let mut out = Vec::with_capacity(74);
273        out.extend_from_slice(&self.pkalg);
274        out.extend_from_slice(&self.keynum);
275        out.extend_from_slice(&self.sig);
276        out
277    }
278}
279
280/// Parse a signify file from a stream.
281///
282/// Reads from `reader`, parses the OpenBSD-compatible format
283/// (comment line + base64 line), and decodes the object using `parse_fn`.
284///
285/// # Errors
286///
287/// Returns `Error::Io` if file cannot be read.
288/// Returns `Error::InvalidCommentHeader` if header is missing or malformed.
289/// Returns `Error::InvalidSignatureUtf8` if base64 is invalid utf8.
290/// Returns `Error::Base64Decode` if decoding fails.
291/// Returns various errors from `parse_fn`.
292pub fn parse_stream<F, R, T>(mut reader: R, parse_fn: F) -> Result<(T, Vec<u8>)>
293where
294    R: Read,
295    F: Fn(&[u8]) -> Result<T>,
296{
297    // Bounded read for header (comment + base64).
298    // Keys are small (104 bytes encoded ~ 140 bytes + comment).
299    // Signatures are small (74 bytes encoded ~ 100 bytes + comment).
300    // 4KB is generous.
301    const HEADER_LIMIT: usize = 4096;
302    let mut header_buf = vec![0_u8; HEADER_LIMIT];
303
304    // Read up to HEADER_LIMIT.
305    let mut total_read = 0;
306    while total_read < HEADER_LIMIT {
307        let n = reader
308            .read(&mut header_buf[total_read..])
309            .map_err(Error::Io)?;
310        if n == 0 {
311            break;
312        }
313        total_read = total_read.checked_add(n).ok_or(Error::Overflow)?;
314    }
315    header_buf.truncate(total_read);
316
317    // Find first newline.
318    let n1 = memchr(b'\n', &header_buf).ok_or(Error::InvalidCommentHeader)?;
319    let header_bytes = &header_buf[..n1];
320
321    // Strict verify prefix.
322    let prefix = COMMENTHDR.as_bytes();
323    if !header_bytes.starts_with(prefix) {
324        return Err(Error::InvalidCommentHeader);
325    }
326    let comment = header_bytes[prefix.len()..].to_vec();
327
328    let n2_start = n1.checked_add(1).ok_or(Error::Overflow)?;
329    let n2 = memchr(b'\n', &header_buf[n2_start..])
330        .unwrap_or_else(|| header_buf.len().saturating_sub(n2_start));
331
332    let b64_start = n2_start;
333    let b64_end = b64_start.checked_add(n2).ok_or(Error::Overflow)?;
334
335    if b64_end > header_buf.len() {
336        return Err(Error::InvalidCommentHeader);
337    }
338
339    let b64_bytes = &header_buf[b64_start..b64_end];
340
341    // Base64 decode.
342    let b64_str = str::from_utf8(b64_bytes).map_err(|_e| Error::InvalidSignatureUtf8)?;
343    let decoded = Base64::decode_vec(b64_str.trim()).map_err(Error::Base64Decode)?;
344
345    let obj = parse_fn(&decoded)?;
346    Ok((obj, comment))
347}
348
349/// Parse a signify file.
350///
351/// Reads file at `path` (or stdin if "-"), parses the OpenBSD-compatible format
352/// (comment line + base64 line), and decodes the object using `parse_fn`.
353///
354/// # Errors
355///
356/// Returns `Error::Io` if file cannot be read.
357/// Returns `Error::InvalidCommentHeader` if header is missing or malformed.
358/// Returns `Error::InvalidSignatureUtf8` if base64 is invalid utf8.
359/// Returns `Error::Base64Decode` if decoding fails.
360/// Returns various errors from `parse_fn`.
361pub fn parse<T, F>(path: &Path, parse_fn: F) -> Result<(T, Vec<u8>)>
362where
363    F: Fn(&[u8]) -> Result<T>,
364{
365    let reader: Box<dyn Read> = if path.to_str() == Some("-") {
366        Box::new(stdin())
367    } else {
368        Box::new(open(path, false)?)
369    };
370
371    parse_stream(reader, parse_fn)
372}
373
374/// Write a signify file to a stream.
375///
376/// Writes `comment` and base64-encoded `data` to `writer`.
377///
378/// # Errors
379///
380/// Returns `Error::Io` on write failure.
381pub fn write_stream(mut writer: impl Write, comment: &[u8], data: &[u8]) -> Result<()> {
382    let encoded = Base64::encode_string(data);
383
384    let mut content = Vec::new();
385    content.extend_from_slice(COMMENTHDR.as_bytes());
386    content.extend_from_slice(comment);
387    content.push(b'\n');
388    content.extend_from_slice(encoded.as_bytes());
389    content.push(b'\n');
390
391    writer.write_all(&content).map_err(Error::Io)?;
392    Ok(())
393}
394
395/// Write a signify file.
396///
397/// Writes `comment` and base64-encoded `data` to `path` (or stdout if "-").
398///
399/// # Errors
400///
401/// Returns `Error::Io` on file creation or write failure.
402pub fn write(path: &Path, comment: &[u8], data: &[u8]) -> Result<()> {
403    let writer: Box<dyn Write> = if path.to_str() == Some("-") {
404        Box::new(stdout())
405    } else {
406        Box::new(open(path, true)?)
407    };
408
409    write_stream(writer, comment, data)
410}
411
412/// Open a file for read or write.
413///
414/// If `write` the file is created new with mode 600.
415pub fn open(path: &Path, write: bool) -> Result<File> {
416    if path.components().any(|p| p == Component::ParentDir) {
417        return Err(Error::InvalidPath);
418    }
419    #[cfg(unix)]
420    {
421        safe_name(path)?;
422    }
423    #[cfg(target_os = "linux")]
424    {
425        safe_open(path, write)
426    }
427    #[cfg(not(target_os = "linux"))]
428    {
429        use std::fs::OpenOptions;
430
431        let mut opts = OpenOptions::new();
432        if write {
433            opts.write(true).create_new(true);
434            #[cfg(unix)]
435            {
436                use std::os::unix::fs::OpenOptionsExt;
437                opts.mode(0o600);
438            }
439        } else {
440            opts.read(true);
441            #[cfg(unix)]
442            {
443                use nix::fcntl::OFlag;
444                use std::os::unix::fs::OpenOptionsExt;
445                opts.custom_flags(OFlag::O_NOFOLLOW.bits());
446            }
447        }
448
449        opts.open(path).map_err(Error::Io)
450    }
451}
452
453#[cfg(target_os = "linux")]
454fn safe_open(path: &Path, write: bool) -> Result<File> {
455    use nix::fcntl::{openat2, OFlag, OpenHow, ResolveFlag, AT_FDCWD};
456    use nix::sys::stat::Mode;
457    use std::os::fd::AsRawFd;
458
459    let mut how = OpenHow::new()
460        .resolve(ResolveFlag::RESOLVE_NO_SYMLINKS | ResolveFlag::RESOLVE_NO_MAGICLINKS);
461
462    if write {
463        // Exclusively create new file for write.
464        how = how.flags(OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_EXCL);
465        how = how.mode(Mode::from_bits_truncate(0o600));
466        return Ok(openat2(AT_FDCWD, path, how).map(File::from)?);
467    }
468
469    // Check for regular file.
470    how = how.flags(OFlag::O_PATH | OFlag::O_NOFOLLOW);
471    let file = openat2(AT_FDCWD, path, how).map(File::from)?;
472    if !file.metadata()?.is_file() {
473        return Err(Error::InvalidPath);
474    }
475
476    // Reopen using proc indirection.
477    how = how.flags(OFlag::O_RDONLY).resolve(ResolveFlag::empty());
478    let path = format!("/proc/thread-self/fd/{}", file.as_raw_fd());
479    let file = openat2(AT_FDCWD, path.as_str(), how).map(File::from)?;
480
481    Ok(file)
482}
483
484// Validates a filename based on David A. Wheeler's Safename Linux
485// Security Module (LSM) rules.
486#[cfg(unix)]
487fn safe_name(path: &Path) -> Result<()> {
488    use nix::errno::Errno;
489    use std::os::unix::ffi::OsStrExt;
490
491    let name = match path.file_name() {
492        Some(name) => name.as_bytes(),
493        None => return Err(Errno::EILSEQ.into()),
494    };
495
496    // Check the first byte.
497    if !name
498        .get(0)
499        .map(|&b| is_permitted_initial(b))
500        .unwrap_or(false)
501    {
502        return Err(Errno::EILSEQ.into());
503    }
504
505    // Check the last byte.
506    let last = name.len().saturating_sub(1);
507    if !name
508        .get(last)
509        .map(|&b| is_permitted_final(b))
510        .unwrap_or(false)
511    {
512        return Err(Errno::EILSEQ.into());
513    }
514
515    // Check the middle bytes (if any).
516    if last > 1 && !name[1..last].iter().all(|&b| is_permitted_middle(b)) {
517        return Err(Errno::EILSEQ.into());
518    }
519
520    // Check if name is valid UTF-8.
521    let name = std::str::from_utf8(name).or(Err(Errno::EILSEQ))?;
522
523    // Check if first and last character is not whitespace.
524    // This includes UTF-8 whitespace.
525    if name
526        .chars()
527        .nth(0)
528        .map(|c| c.is_whitespace())
529        .unwrap_or(false)
530    {
531        return Err(Errno::EILSEQ.into());
532    }
533    if name
534        .chars()
535        .last()
536        .map(|c| c.is_whitespace())
537        .unwrap_or(false)
538    {
539        return Err(Errno::EILSEQ.into());
540    }
541
542    Ok(())
543}
544
545#[cfg(unix)]
546fn is_permitted_initial(b: u8) -> bool {
547    is_permitted_byte(b) && !matches!(b, b'-' | b' ' | b'~')
548}
549
550#[cfg(unix)]
551fn is_permitted_middle(b: u8) -> bool {
552    is_permitted_byte(b)
553}
554
555#[cfg(unix)]
556fn is_permitted_final(b: u8) -> bool {
557    is_permitted_byte(b) && b != b' '
558}
559
560#[cfg(unix)]
561fn is_permitted_byte(b: u8) -> bool {
562    match b {
563        b'*' | b'?' | b'[' | b']' | b'"' | b'<' | b'>' | b'|' | b'(' | b')' | b'&' | b'\''
564        | b'!' | b'\\' | b':' | b'{' | b'}' | b';' | b'$' | b'`' => false,
565        0x20..=0x7E => true,
566        0x80..=0xFE => true,
567        _ => false,
568    }
569}
570
571assert_eq_size!(EncKey, [u8; 104]);
572assert_eq_size!(PubKey, [u8; 42]);
573assert_eq_size!(Sig, [u8; 74]);
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    #[cfg(unix)]
579    use std::ffi::OsStr;
580    use std::fs::OpenOptions;
581    #[cfg(unix)]
582    use std::os::unix::ffi::OsStrExt;
583
584    #[test]
585    fn test_enckey_serialization() -> crate::error::Result<()> {
586        let enc = EncKey {
587            pkalg: PKALG,
588            kdfalg: KDFALG,
589            kdfrounds: 42,
590            salt: [1u8; SALT_LEN],
591            checksum: [2u8; CHECKSUM_LEN],
592            keynum: [3u8; KEYNUMLEN],
593            seckey: Zeroizing::new([4u8; 64]),
594        };
595        let bytes = enc.to_bytes();
596        let enc2 = EncKey::from_bytes(&bytes)?;
597        assert_eq!(enc.kdfrounds, enc2.kdfrounds);
598        assert_eq!(enc.salt, enc2.salt);
599
600        // Test invalid lengths.
601        assert!(matches!(
602            EncKey::from_bytes(&bytes[..103]),
603            Err(Error::InvalidKeyLength)
604        ));
605
606        let mut long = bytes.clone();
607        long.push(0);
608        assert!(matches!(
609            EncKey::from_bytes(&long),
610            Err(Error::InvalidKeyLength)
611        ));
612
613        // Test bad PKALG.
614        let mut bad_alg = bytes.clone();
615        bad_alg[0] = b'X';
616        assert!(matches!(
617            EncKey::from_bytes(&bad_alg),
618            Err(Error::UnsupportedPkAlgo)
619        ));
620
621        // Test bad KDFALG.
622        let mut bad_kdf = bytes.clone();
623        bad_kdf[2] = b'X';
624        assert!(matches!(
625            EncKey::from_bytes(&bad_kdf),
626            Err(Error::UnsupportedKdfAlgo)
627        ));
628
629        Ok(())
630    }
631
632    #[test]
633    fn test_pubkey_serialization() -> crate::error::Result<()> {
634        let pubk = PubKey {
635            pkalg: PKALG,
636            keynum: [1u8; KEYNUMLEN],
637            pubkey: [2u8; 32],
638        };
639        let bytes = pubk.to_bytes();
640        let pubk2 = PubKey::from_bytes(&bytes)?;
641        assert_eq!(pubk.keynum, pubk2.keynum);
642
643        // Test invalid lengths.
644        assert!(matches!(
645            PubKey::from_bytes(&bytes[..41]),
646            Err(Error::InvalidKeyLength)
647        ));
648        let mut long = bytes.clone();
649        long.push(0);
650        assert!(matches!(
651            PubKey::from_bytes(&long),
652            Err(Error::InvalidKeyLength)
653        ));
654
655        // Test bad PKALG.
656        let mut bad_alg = bytes.clone();
657        bad_alg[0] = b'X';
658        assert!(matches!(
659            PubKey::from_bytes(&bad_alg),
660            Err(Error::UnsupportedPkAlgo)
661        ));
662
663        Ok(())
664    }
665
666    #[test]
667    fn test_sig_serialization() -> crate::error::Result<()> {
668        let sig = Sig {
669            pkalg: PKALG,
670            keynum: [1u8; KEYNUMLEN],
671            sig: [0u8; 64],
672        };
673        let bytes = sig.to_bytes();
674        let sig2 = Sig::from_bytes(&bytes)?;
675        assert_eq!(sig.keynum, sig2.keynum);
676
677        // Test invalid lengths.
678        assert!(matches!(
679            Sig::from_bytes(&bytes[..73]),
680            Err(Error::InvalidKeyLength)
681        ));
682
683        let mut long = bytes.clone();
684        long.push(0);
685        assert!(matches!(
686            Sig::from_bytes(&long),
687            Err(Error::InvalidKeyLength)
688        ));
689
690        // Test bad PKALG.
691        let mut bad_alg = bytes.clone();
692        bad_alg[0] = b'X';
693        assert!(matches!(
694            Sig::from_bytes(&bad_alg),
695            Err(Error::UnsupportedPkAlgo)
696        ));
697
698        Ok(())
699    }
700
701    #[test]
702    #[cfg_attr(any(target_arch = "wasm32", target_arch = "wasm64"), ignore)]
703    fn test_file_io() -> std::result::Result<(), Box<dyn std::error::Error>> {
704        let dir = tempfile::tempdir()?;
705        let path = dir.path().join("secret.key");
706        let data = b"secret data";
707
708        write(&path, b"mycomment", data)?;
709
710        let (read_data, comment) = parse::<Vec<u8>, _>(&path, |b| Ok(b.to_vec()))?;
711        assert_eq!(read_data, data);
712        assert_eq!(comment, b"mycomment");
713
714        // Test missing file.
715        let missing = dir.path().join("missing");
716        let result = parse::<Vec<u8>, _>(&missing, |_| Ok(vec![]));
717        #[cfg(not(target_os = "linux"))]
718        assert!(matches!(result, Err(Error::Io(_))));
719        #[cfg(target_os = "linux")]
720        assert!(matches!(result, Err(Error::Nix(_))));
721
722        // Test invalid prefix.
723        let bad_prefix = dir.path().join("bad_prefix");
724        let mut f = OpenOptions::new()
725            .write(true)
726            .create_new(true)
727            .open(&bad_prefix)?;
728        f.write_all(b"invalid header\n")?;
729        assert!(matches!(
730            parse::<Vec<u8>, _>(&bad_prefix, |_| Ok(vec![])),
731            Err(Error::InvalidCommentHeader)
732        ));
733
734        // Test missing newline.
735        let no_newline = dir.path().join("no_newline");
736        let mut f = OpenOptions::new()
737            .write(true)
738            .create_new(true)
739            .open(&no_newline)?;
740        f.write_all(b"untrusted comment: foo")?;
741        assert!(matches!(
742            parse::<Vec<u8>, _>(&no_newline, |_| Ok(vec![])),
743            Err(Error::InvalidCommentHeader)
744        ));
745
746        // Test invalid base64 (not utf8).
747        // Create valid structure first.
748        // Overwrite base64 part with garbage.
749        let bad_utf8 = dir.path().join("bad_utf8");
750        write(&bad_utf8, b"comment", b"")?;
751        let mut f = OpenOptions::new().write(true).open(&bad_utf8)?;
752        f.write_all(b"untrusted comment: comment\n\xFF\xFF\n")?;
753        assert!(matches!(
754            parse::<Vec<u8>, _>(&bad_utf8, |_| Ok(vec![])),
755            Err(Error::InvalidSignatureUtf8)
756        ));
757
758        Ok(())
759    }
760
761    #[test]
762    #[cfg(unix)]
763    fn test_open_symlink_fail() -> std::result::Result<(), Box<dyn std::error::Error>> {
764        use std::os::unix::fs::symlink;
765        let dir = tempfile::tempdir()?;
766        let target = dir.path().join("target");
767        let link = dir.path().join("link");
768
769        std::fs::write(&target, b"target")?;
770        symlink(&target, &link)?;
771
772        // Opening a symlink should fail due to O_NOFOLLOW / RESOLVE_NO_SYMLINKS.
773        assert!(open(&link, false).is_err());
774
775        Ok(())
776    }
777
778    #[test]
779    #[cfg(unix)]
780    fn test_open_write_mode() -> std::result::Result<(), Box<dyn std::error::Error>> {
781        use std::os::unix::fs::PermissionsExt;
782
783        let dir = tempfile::tempdir()?;
784        let path = dir.path().join("secret.key");
785
786        let _f = open(&path, true)?;
787
788        let metadata = std::fs::metadata(&path)?;
789        let mode = metadata.permissions().mode();
790
791        // Mode should be 0600.
792        assert_eq!(mode & 0o777, 0o600);
793
794        Ok(())
795    }
796
797    #[test]
798    fn test_open_parent_dir_fail() {
799        let path = Path::new("foo/../bar");
800        assert!(matches!(open(path, false), Err(Error::InvalidPath)));
801        assert!(matches!(open(path, true), Err(Error::InvalidPath)));
802    }
803
804    #[test]
805    #[cfg(target_os = "linux")]
806    fn test_safe_open_not_file_fail() -> std::result::Result<(), Box<dyn std::error::Error>> {
807        let dir = tempfile::tempdir()?;
808        let path = dir.path();
809
810        // Opening a directory should fail.
811        assert!(matches!(open(path, false), Err(Error::InvalidPath)));
812
813        Ok(())
814    }
815
816    #[test]
817    #[cfg(target_os = "linux")]
818    fn test_open_magiclink_fail() {
819        let path = Path::new("/proc/self/root");
820        assert!(matches!(
821            open(path, false),
822            Err(Error::Nix(nix::errno::Errno::ELOOP))
823        ));
824    }
825
826    #[test]
827    #[cfg(target_os = "linux")]
828    fn test_open_char_device_fail() {
829        let path = Path::new("/dev/null");
830        assert!(matches!(open(path, false), Err(Error::InvalidPath)));
831    }
832
833    #[test]
834    #[cfg(target_os = "linux")]
835    fn test_open_fifo_fail() -> std::result::Result<(), Box<dyn std::error::Error>> {
836        use nix::sys::stat::Mode;
837        use nix::unistd::mkfifo;
838
839        let dir = tempfile::tempdir()?;
840        let path = dir.path().join("test.fifo");
841
842        mkfifo(&path, Mode::S_IRUSR | Mode::S_IWUSR)?;
843
844        // FIFO is not a file.
845        assert!(matches!(open(&path, false), Err(Error::InvalidPath)));
846
847        Ok(())
848    }
849
850    #[test]
851    #[cfg(target_os = "linux")]
852    fn test_open_socket_fail() -> std::result::Result<(), Box<dyn std::error::Error>> {
853        use std::os::unix::net::UnixListener;
854
855        let dir = tempfile::tempdir()?;
856        let path = dir.path().join("test.sock");
857
858        let _listener = UnixListener::bind(&path)?;
859
860        // Socket is not a file.
861        assert!(matches!(open(&path, false), Err(Error::InvalidPath)));
862
863        Ok(())
864    }
865
866    #[test]
867    #[cfg(unix)]
868    fn test_check_name_valid() {
869        let valid_filenames = [
870            "valid_filename.txt",
871            "hello_world",
872            "File123",
873            "Makefile",
874            "こんにちは", // Japanese characters
875            "文件",       // Chinese characters
876            "emoji😀",    // Starts with permitted character
877            "valid~name", // '~' allowed in middle
878            "name~",      // '~' allowed at end
879            "a",
880            "normal",
881            "test-file",
882            "test_file",
883            "file name",
884            "file☃name",    // Snowman character
885            "name\u{0080}", // Contains 0x80 (allowed)
886            "name\u{00FE}", // Contains 0xFE (allowed)
887            "😀name",       // Multi-byte character at start
888            "name😀",       // Multi-byte character at end
889            "😀",           // Single multi-byte character
890            "name😀name",   // Multi-byte character in middle
891            "na~me",        // '~' allowed in middle
892            "name-",        // Hyphen at end (allowed)
893            "name_",        // Underscore at end (allowed)
894            "name.",        // Period at end (allowed)
895            "a\u{0020}b",   // SPACE in the middle (allowed)
896            "a\u{00A0}b",   // NO-BREAK SPACE in the middle (allowed)
897            "a\u{1680}b",   // OGHAM SPACE MARK in the middle (allowed)
898            "a\u{2007}b",   // FIGURE SPACE in the middle (allowed)
899            "a\u{202F}b",   // NARROW NO-BREAK SPACE in the middle (allowed)
900            "a\u{3000}b",   // IDEOGRAPHIC SPACE in the middle (allowed)
901        ];
902
903        for (idx, name) in valid_filenames.iter().enumerate() {
904            assert!(
905                safe_name(Path::new(name)).is_ok(),
906                "Filename {idx} '{name}' should be valid"
907            );
908        }
909    }
910
911    #[test]
912    #[cfg(unix)]
913    fn test_check_name_invalid() {
914        let invalid_filenames: &[&[u8]] = &[
915            b"",                             // Empty filename
916            b"-",                            // Starts with '-'
917            b"*",                            // Starts with '*'
918            b"?",                            // Starts with '?'
919            b"!",                            // Starts with '!'
920            b"$",                            // Starts with '$'
921            b"`",                            // Starts with '`'
922            b" -",                           // Starts with space
923            b"~home",                        // Starts with '~'
924            b"*home",                        // Starts with '*'
925            b"?home",                        // Starts with '?'
926            b"!home",                        // Starts with '!'
927            b"$home",                        // Starts with '$'
928            b"`home",                        // Starts with '`'
929            b"file ",                        // Ends with space
930            b"file*",                        // Ends with '*'
931            b"file?",                        // Ends with '?'
932            b"file!",                        // Ends with '!'
933            b"file$",                        // Ends with '$'
934            b"file`",                        // Ends with '`'
935            b"bad*name",                     // Contains '*'
936            b"bad?name",                     // Contains '?'
937            b"bad!name",                     // Contains '!'
938            b"bad$name",                     // Contains '$'
939            b"bad`name",                     // Contains '`'
940            b"bad\nname",                    // Contains newline
941            b"\0",                           // Null byte
942            b"bad\0name",                    // Contains null byte
943            b"bad\x7Fname",                  // Contains delete character
944            b"bad\xFFname",                  // Contains 0xFF
945            b"\x1Fcontrol",                  // Starts with control character
946            b"name\x1F",                     // Ends with control character
947            b"name\x7F",                     // Ends with delete character
948            b"name\xFF",                     // Ends with 0xFF
949            b"name ",                        // Ends with space
950            b"-name",                        // Starts with '-'
951            b" name",                        // Starts with space
952            b"~name",                        // Starts with '~'
953            b"*name",                        // Starts with '*'
954            b"?name",                        // Starts with '?'
955            b"!name",                        // Starts with '!'
956            b"$name",                        // Starts with '$'
957            b"`name",                        // Starts with '`'
958            b"name\x19",                     // Contains control character
959            b"name\n",                       // Ends with newline
960            b"\nname",                       // Starts with newline
961            b"na\nme",                       // Contains newline
962            b"name\t",                       // Contains tab
963            b"name\r",                       // Contains carriage return
964            b"name\x1B",                     // Contains escape character
965            b"name\x00",                     // Contains null byte
966            b"name\x7F",                     // Contains delete character
967            b"name\xFF",                     // Contains 0xFF (disallowed)
968            b"\xFF",                         // Single byte 0xFF
969            b"name\x80\xFF",                 // Contains valid and invalid extended ASCII
970            b"name\xC0\xAF",                 // Invalid UTF-8 sequence
971            b"\xF0\x28\x8C\xBC",             // Invalid UTF-8 sequence
972            b"\xF0\x90\x28\xBC",             // Invalid UTF-8 sequence
973            b"\xF0\x28\x8C\x28",             // Invalid UTF-8 sequence
974            b"name\xFFname",                 // Contains 0xFF
975            b"name\xC3\x28",                 // Invalid UTF-8 sequence
976            b"name\xA0\xA1",                 // Invalid UTF-8 sequence
977            b"\xE2\x28\xA1",                 // Invalid UTF-8 sequence
978            b"\xE2\x82\x28",                 // Invalid UTF-8 sequence
979            b"\xF0\x28\x8C\xBC",             // Invalid UTF-8 sequence
980            b"\xF0\x90\x28\xBC",             // Invalid UTF-8 sequence
981            b"\xF0\x28\x8C\x28",             // Invalid UTF-8 sequence
982            b"\xC2\xA0",                     // Non-breaking space
983            b"\x20file",                     // leading SPACE U+0020
984            b"file\x20",                     // trailing SPACE U+0020
985            b"\xC2\xA0file",                 // leading NO-BREAK SPACE U+00A0
986            b"file\xE3\x80\x80",             // trailing IDEOGRAPHIC SPACE U+3000
987            b"\xE2\x80\xAFfile",             // leading NARROW NO-BREAK SPACE U+202F
988            b"\xE2\x81\x9Ffile\xE2\x81\x9F", // both sides MEDIUM MATHEMATICAL SPACE U+205F
989        ];
990
991        for (idx, name) in invalid_filenames.iter().enumerate() {
992            assert!(
993                safe_name(Path::new(OsStr::from_bytes(name))).is_err(),
994                "Filename {idx} '{name:?}' should not be valid"
995            );
996        }
997    }
998
999    #[test]
1000    #[cfg(unix)]
1001    fn test_check_name_control_characters() {
1002        for b in 0x00..=0x1F {
1003            if let Some(c) = char::from_u32(b as u32) {
1004                let name = format!("name{c}char");
1005                assert!(
1006                    safe_name(Path::new(&name)).is_err(),
1007                    "Filename with control character '\\x{b:02X}' should be invalid",
1008                );
1009            }
1010        }
1011    }
1012
1013    #[test]
1014    #[cfg(unix)]
1015    fn test_check_name_extended_ascii_characters() {
1016        for b in 0x80..=0xFE {
1017            if b == 0xFF {
1018                continue; // 0xFF is disallowed.
1019            }
1020            let mut bytes = b"name".to_vec();
1021            bytes.push(b);
1022            bytes.extend_from_slice(b"char");
1023            let name = OsStr::from_bytes(&bytes);
1024            let name = Path::new(name);
1025            let result = safe_name(name);
1026            if std::str::from_utf8(&bytes).is_ok() {
1027                assert!(result.is_ok(), "Filename with byte 0x{b:X} should be valid",);
1028            } else {
1029                assert!(
1030                    result.is_err(),
1031                    "Filename with invalid UTF-8 byte 0x{b:X} should be invalid",
1032                );
1033            }
1034        }
1035    }
1036
1037    #[test]
1038    #[cfg(unix)]
1039    fn test_check_name_edge_cases() {
1040        // Filenames with length 1
1041        let valid_single_chars = [
1042            "a", "b", "Z", "9", "_", "😀", // Valid multi-byte character
1043        ];
1044
1045        for (idx, name) in valid_single_chars.iter().enumerate() {
1046            assert!(
1047                safe_name(Path::new(name)).is_ok(),
1048                "Single-character filename {idx} '{name}' should be valid",
1049            );
1050        }
1051
1052        let invalid_single_chars: &[&[u8]] = &[
1053            b".",            // '.'
1054            b"-",            // '-'
1055            b" ",            // Space character
1056            b"~",            // Tilde character
1057            b"*",            // Starts with '*'
1058            b"?",            // Starts with '?'
1059            b"\n",           // Newline character
1060            b"\r",           // Newline character
1061            b"\x7F",         // Delete character
1062            b"\x1F",         // Control character
1063            b"\xFF",         // 0xFF disallowed
1064            b"\0",           // Null byte
1065            b"\xC2\xA0",     // Non-breaking space
1066            b"\x20",         // SPACE U+0020
1067            b"\xC2\xA0",     // NO-BREAK SPACE U+00A0
1068            b"\xE1\x9A\x80", // OGHAM SPACE MARK U+1680
1069            b"\xE2\x80\x87", // FIGURE SPACE U+2007
1070            b"\xE2\x80\xAF", // NARROW NO-BREAK SPACE U+202F
1071            b"\xE3\x80\x80", // IDEOGRAPHIC SPACE U+3000
1072        ];
1073
1074        for (idx, name) in invalid_single_chars.iter().enumerate() {
1075            assert!(
1076                safe_name(Path::new(OsStr::from_bytes(name))).is_err(),
1077                "Single-character filename {idx} '{name:?}' should be invalid",
1078            );
1079        }
1080    }
1081}