Skip to main content

shadow_crypt_core/v3/
metadata.rs

1//! Plaintext layout of the encrypted metadata envelope.
2//!
3//! The envelope is serialized, then AEAD-encrypted under the metadata domain
4//! and stored in the header. Layout (little endian):
5//!
6//! | field        | size     | present               |
7//! |--------------|----------|-----------------------|
8//! | flags        | 1 byte   | always                |
9//! | filename_len | 2 bytes  | always                |
10//! | filename     | variable | always (UTF-8)        |
11//! | mtime_secs   | 8 bytes  | flags bit 0 (signed)  |
12//! | mtime_nanos  | 4 bytes  | flags bit 0           |
13//! | mode         | 4 bytes  | flags bit 1           |
14//!
15//! Flags bit 2 marks the content as a [`crate::archive`] stream (a
16//! directory tree) instead of a single file's bytes; `filename` is then the
17//! directory name.
18//!
19//! Trailing unknown bytes are rejected: this layout is fixed for the v3
20//! format, and any extension is a new format version.
21
22use std::time::{Duration, SystemTime, UNIX_EPOCH};
23
24use crate::{
25    errors::{FileError, HeaderError},
26    file::{ContentKind, FileMetadata},
27    memory::{SecureBytes, SecureString},
28};
29
30const FLAG_MTIME: u8 = 0b0000_0001;
31const FLAG_MODE: u8 = 0b0000_0010;
32const FLAG_ARCHIVE: u8 = 0b0000_0100;
33
34/// Largest serialized envelope that still fits the header's u16 ciphertext
35/// length field once the 16-byte AEAD tag is added.
36const MAX_ENVELOPE_LEN: usize = u16::MAX as usize - 16;
37
38/// Serializes a metadata envelope. Fails with
39/// [`HeaderError::MetadataTooLong`] when the filename pushes the envelope
40/// past what the header can hold.
41pub fn serialize(metadata: &FileMetadata) -> Result<SecureBytes, HeaderError> {
42    let filename = metadata.filename().as_str().as_bytes();
43    if u16::try_from(filename.len()).is_err() {
44        return Err(HeaderError::MetadataTooLong);
45    }
46
47    let mtime = metadata.mtime().map(systemtime_to_parts);
48
49    let mut flags = 0u8;
50    if mtime.is_some() {
51        flags |= FLAG_MTIME;
52    }
53    if metadata.mode().is_some() {
54        flags |= FLAG_MODE;
55    }
56    if metadata.kind() == ContentKind::Archive {
57        flags |= FLAG_ARCHIVE;
58    }
59
60    let mut envelope = Vec::with_capacity(1 + 2 + filename.len() + 12 + 4);
61    envelope.push(flags);
62    envelope.extend_from_slice(&(filename.len() as u16).to_le_bytes());
63    envelope.extend_from_slice(filename);
64    if let Some((secs, nanos)) = mtime {
65        envelope.extend_from_slice(&secs.to_le_bytes());
66        envelope.extend_from_slice(&nanos.to_le_bytes());
67    }
68    if let Some(mode) = metadata.mode() {
69        envelope.extend_from_slice(&mode.to_le_bytes());
70    }
71
72    if envelope.len() > MAX_ENVELOPE_LEN {
73        return Err(HeaderError::MetadataTooLong);
74    }
75    Ok(SecureBytes::new(envelope))
76}
77
78/// Parses a decrypted metadata envelope. Any structural mismatch (bad
79/// lengths, unknown flags, trailing bytes, invalid UTF-8 filename) is
80/// [`FileError::InvalidMetadata`].
81pub fn parse(envelope: &[u8]) -> Result<FileMetadata, FileError> {
82    let err = || FileError::InvalidMetadata;
83
84    let (&flags, rest) = envelope.split_first().ok_or_else(err)?;
85    if flags & !(FLAG_MTIME | FLAG_MODE | FLAG_ARCHIVE) != 0 {
86        return Err(err());
87    }
88
89    let (len_bytes, rest) = rest.split_at_checked(2).ok_or_else(err)?;
90    let filename_len = u16::from_le_bytes(len_bytes.try_into().map_err(|_| err())?) as usize;
91    let (filename_bytes, rest) = rest.split_at_checked(filename_len).ok_or_else(err)?;
92    let filename = std::str::from_utf8(filename_bytes).map_err(|_| err())?;
93
94    let (mtime, rest) = if flags & FLAG_MTIME != 0 {
95        let (secs_bytes, rest) = rest.split_at_checked(8).ok_or_else(err)?;
96        let (nanos_bytes, rest) = rest.split_at_checked(4).ok_or_else(err)?;
97        let secs = i64::from_le_bytes(secs_bytes.try_into().map_err(|_| err())?);
98        let nanos = u32::from_le_bytes(nanos_bytes.try_into().map_err(|_| err())?);
99        if nanos >= 1_000_000_000 {
100            return Err(err());
101        }
102        (
103            Some(parts_to_systemtime(secs, nanos).ok_or_else(err)?),
104            rest,
105        )
106    } else {
107        (None, rest)
108    };
109
110    let (mode, rest) = if flags & FLAG_MODE != 0 {
111        let (mode_bytes, rest) = rest.split_at_checked(4).ok_or_else(err)?;
112        let mode = u32::from_le_bytes(mode_bytes.try_into().map_err(|_| err())?);
113        (Some(mode), rest)
114    } else {
115        (None, rest)
116    };
117
118    if !rest.is_empty() {
119        return Err(err());
120    }
121
122    let metadata = FileMetadata::new(SecureString::new(filename.to_string()), mtime, mode);
123    Ok(if flags & FLAG_ARCHIVE != 0 {
124        metadata.into_archive()
125    } else {
126        metadata
127    })
128}
129
130/// Splits a `SystemTime` into (seconds, nanoseconds) relative to the Unix
131/// epoch, with pre-epoch times as negative seconds and nanos in `[0, 1e9)`.
132/// Total for any `SystemTime`: the seconds saturate at the i64 range
133/// (hundreds of billions of years out), so extreme timestamps can never
134/// overflow — found by fuzzing with `mtime_secs = i64::MIN`.
135fn systemtime_to_parts(t: SystemTime) -> (i64, u32) {
136    let (secs, nanos): (i128, u32) = match t.duration_since(UNIX_EPOCH) {
137        Ok(d) => (d.as_secs().into(), d.subsec_nanos()),
138        Err(e) => {
139            let d = e.duration();
140            let (secs, nanos) = (i128::from(d.as_secs()), d.subsec_nanos());
141            if nanos == 0 {
142                (-secs, 0)
143            } else {
144                (-(secs + 1), 1_000_000_000 - nanos)
145            }
146        }
147    };
148    (secs.clamp(i64::MIN.into(), i64::MAX.into()) as i64, nanos)
149}
150
151fn parts_to_systemtime(secs: i64, nanos: u32) -> Option<SystemTime> {
152    if secs >= 0 {
153        UNIX_EPOCH.checked_add(Duration::new(secs as u64, nanos))
154    } else if nanos == 0 {
155        UNIX_EPOCH.checked_sub(Duration::from_secs(secs.unsigned_abs()))
156    } else {
157        UNIX_EPOCH.checked_sub(Duration::new(
158            (secs + 1).unsigned_abs(),
159            1_000_000_000 - nanos,
160        ))
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    fn meta(filename: &str, mtime: Option<SystemTime>, mode: Option<u32>) -> FileMetadata {
169        FileMetadata::new(SecureString::new(filename.to_string()), mtime, mode)
170    }
171
172    fn round_trip(metadata: &FileMetadata) -> FileMetadata {
173        parse(serialize(metadata).unwrap().as_slice()).unwrap()
174    }
175
176    #[test]
177    fn round_trip_all_fields() {
178        let mtime = UNIX_EPOCH + Duration::new(1_700_000_000, 123_456_789);
179        let parsed = round_trip(&meta("café.txt", Some(mtime), Some(0o644)));
180
181        assert_eq!(parsed.filename().as_str(), "café.txt");
182        assert_eq!(parsed.mtime(), Some(mtime));
183        assert_eq!(parsed.mode(), Some(0o644));
184    }
185
186    #[test]
187    fn round_trip_filename_only() {
188        let parsed = round_trip(&meta("a.txt", None, None));
189        assert_eq!(parsed.filename().as_str(), "a.txt");
190        assert_eq!(parsed.mtime(), None);
191        assert_eq!(parsed.mode(), None);
192        assert_eq!(parsed.kind(), crate::file::ContentKind::File);
193    }
194
195    #[test]
196    fn round_trip_archive_kind() {
197        let archive_meta = meta("photos", None, Some(0o755)).into_archive();
198        let parsed = round_trip(&archive_meta);
199        assert_eq!(parsed.filename().as_str(), "photos");
200        assert_eq!(parsed.kind(), crate::file::ContentKind::Archive);
201    }
202
203    /// Regression (found by fuzzing): mtime_secs = i64::MIN parses into a
204    /// valid SystemTime whose re-serialization must not overflow.
205    #[test]
206    fn round_trip_extreme_mtimes() {
207        for secs in [i64::MIN, i64::MIN + 1, i64::MAX] {
208            let mut envelope = vec![1u8]; // flags: mtime only
209            envelope.extend_from_slice(&1u16.to_le_bytes());
210            envelope.push(b'f');
211            envelope.extend_from_slice(&secs.to_le_bytes());
212            envelope.extend_from_slice(&0u32.to_le_bytes());
213
214            if let Ok(parsed) = parse(&envelope) {
215                let reparsed = parse(serialize(&parsed).unwrap().as_slice()).unwrap();
216                assert_eq!(reparsed.mtime(), parsed.mtime(), "secs {secs}");
217            }
218        }
219    }
220
221    #[test]
222    fn round_trip_pre_epoch_mtime() {
223        let mtime = UNIX_EPOCH - Duration::new(100, 250_000_000);
224        let parsed = round_trip(&meta("old.txt", Some(mtime), None));
225        assert_eq!(parsed.mtime(), Some(mtime));
226    }
227
228    #[test]
229    fn unknown_flags_rejected() {
230        let mut envelope = serialize(&meta("a", None, None))
231            .unwrap()
232            .as_slice()
233            .to_vec();
234        envelope[0] |= 0b1000_0000;
235        assert!(parse(&envelope).is_err());
236    }
237
238    #[test]
239    fn trailing_bytes_rejected() {
240        let mut envelope = serialize(&meta("a", None, None))
241            .unwrap()
242            .as_slice()
243            .to_vec();
244        envelope.push(0);
245        assert!(parse(&envelope).is_err());
246    }
247
248    #[test]
249    fn truncated_envelope_rejected() {
250        let envelope = serialize(&meta("abcdef", None, Some(0o600)))
251            .unwrap()
252            .as_slice()
253            .to_vec();
254        for len in 0..envelope.len() {
255            assert!(parse(&envelope[..len]).is_err(), "accepted prefix {len}");
256        }
257    }
258
259    #[test]
260    fn invalid_utf8_filename_rejected() {
261        // flags=0, filename_len=2, invalid UTF-8 bytes
262        let envelope = [0u8, 2, 0, 0xff, 0xfe];
263        assert!(parse(&envelope).is_err());
264    }
265
266    #[test]
267    fn oversized_filename_rejected() {
268        let long = "x".repeat(u16::MAX as usize + 1);
269        assert!(matches!(
270            serialize(&meta(&long, None, None)),
271            Err(HeaderError::MetadataTooLong)
272        ));
273    }
274}