use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::{
errors::{FileError, HeaderError},
file::{ContentKind, FileMetadata},
memory::{SecureBytes, SecureString},
};
const FLAG_MTIME: u8 = 0b0000_0001;
const FLAG_MODE: u8 = 0b0000_0010;
const FLAG_ARCHIVE: u8 = 0b0000_0100;
const MAX_ENVELOPE_LEN: usize = u16::MAX as usize - 16;
pub fn serialize(metadata: &FileMetadata) -> Result<SecureBytes, HeaderError> {
let filename = metadata.filename().as_str().as_bytes();
if u16::try_from(filename.len()).is_err() {
return Err(HeaderError::MetadataTooLong);
}
let mtime = metadata.mtime().map(systemtime_to_parts);
let mut flags = 0u8;
if mtime.is_some() {
flags |= FLAG_MTIME;
}
if metadata.mode().is_some() {
flags |= FLAG_MODE;
}
if metadata.kind() == ContentKind::Archive {
flags |= FLAG_ARCHIVE;
}
let mut envelope = Vec::with_capacity(1 + 2 + filename.len() + 12 + 4);
envelope.push(flags);
envelope.extend_from_slice(&(filename.len() as u16).to_le_bytes());
envelope.extend_from_slice(filename);
if let Some((secs, nanos)) = mtime {
envelope.extend_from_slice(&secs.to_le_bytes());
envelope.extend_from_slice(&nanos.to_le_bytes());
}
if let Some(mode) = metadata.mode() {
envelope.extend_from_slice(&mode.to_le_bytes());
}
if envelope.len() > MAX_ENVELOPE_LEN {
return Err(HeaderError::MetadataTooLong);
}
Ok(SecureBytes::new(envelope))
}
pub fn parse(envelope: &[u8]) -> Result<FileMetadata, FileError> {
let err = || FileError::InvalidMetadata;
let (&flags, rest) = envelope.split_first().ok_or_else(err)?;
if flags & !(FLAG_MTIME | FLAG_MODE | FLAG_ARCHIVE) != 0 {
return Err(err());
}
let (len_bytes, rest) = rest.split_at_checked(2).ok_or_else(err)?;
let filename_len = u16::from_le_bytes(len_bytes.try_into().map_err(|_| err())?) as usize;
let (filename_bytes, rest) = rest.split_at_checked(filename_len).ok_or_else(err)?;
let filename = std::str::from_utf8(filename_bytes).map_err(|_| err())?;
let (mtime, rest) = if flags & FLAG_MTIME != 0 {
let (secs_bytes, rest) = rest.split_at_checked(8).ok_or_else(err)?;
let (nanos_bytes, rest) = rest.split_at_checked(4).ok_or_else(err)?;
let secs = i64::from_le_bytes(secs_bytes.try_into().map_err(|_| err())?);
let nanos = u32::from_le_bytes(nanos_bytes.try_into().map_err(|_| err())?);
if nanos >= 1_000_000_000 {
return Err(err());
}
(
Some(parts_to_systemtime(secs, nanos).ok_or_else(err)?),
rest,
)
} else {
(None, rest)
};
let (mode, rest) = if flags & FLAG_MODE != 0 {
let (mode_bytes, rest) = rest.split_at_checked(4).ok_or_else(err)?;
let mode = u32::from_le_bytes(mode_bytes.try_into().map_err(|_| err())?);
(Some(mode), rest)
} else {
(None, rest)
};
if !rest.is_empty() {
return Err(err());
}
let metadata = FileMetadata::new(SecureString::new(filename.to_string()), mtime, mode);
Ok(if flags & FLAG_ARCHIVE != 0 {
metadata.into_archive()
} else {
metadata
})
}
fn systemtime_to_parts(t: SystemTime) -> (i64, u32) {
let (secs, nanos): (i128, u32) = match t.duration_since(UNIX_EPOCH) {
Ok(d) => (d.as_secs().into(), d.subsec_nanos()),
Err(e) => {
let d = e.duration();
let (secs, nanos) = (i128::from(d.as_secs()), d.subsec_nanos());
if nanos == 0 {
(-secs, 0)
} else {
(-(secs + 1), 1_000_000_000 - nanos)
}
}
};
(secs.clamp(i64::MIN.into(), i64::MAX.into()) as i64, nanos)
}
fn parts_to_systemtime(secs: i64, nanos: u32) -> Option<SystemTime> {
if secs >= 0 {
UNIX_EPOCH.checked_add(Duration::new(secs as u64, nanos))
} else if nanos == 0 {
UNIX_EPOCH.checked_sub(Duration::from_secs(secs.unsigned_abs()))
} else {
UNIX_EPOCH.checked_sub(Duration::new(
(secs + 1).unsigned_abs(),
1_000_000_000 - nanos,
))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn meta(filename: &str, mtime: Option<SystemTime>, mode: Option<u32>) -> FileMetadata {
FileMetadata::new(SecureString::new(filename.to_string()), mtime, mode)
}
fn round_trip(metadata: &FileMetadata) -> FileMetadata {
parse(serialize(metadata).unwrap().as_slice()).unwrap()
}
#[test]
fn round_trip_all_fields() {
let mtime = UNIX_EPOCH + Duration::new(1_700_000_000, 123_456_789);
let parsed = round_trip(&meta("café.txt", Some(mtime), Some(0o644)));
assert_eq!(parsed.filename().as_str(), "café.txt");
assert_eq!(parsed.mtime(), Some(mtime));
assert_eq!(parsed.mode(), Some(0o644));
}
#[test]
fn round_trip_filename_only() {
let parsed = round_trip(&meta("a.txt", None, None));
assert_eq!(parsed.filename().as_str(), "a.txt");
assert_eq!(parsed.mtime(), None);
assert_eq!(parsed.mode(), None);
assert_eq!(parsed.kind(), crate::file::ContentKind::File);
}
#[test]
fn round_trip_archive_kind() {
let archive_meta = meta("photos", None, Some(0o755)).into_archive();
let parsed = round_trip(&archive_meta);
assert_eq!(parsed.filename().as_str(), "photos");
assert_eq!(parsed.kind(), crate::file::ContentKind::Archive);
}
#[test]
fn round_trip_extreme_mtimes() {
for secs in [i64::MIN, i64::MIN + 1, i64::MAX] {
let mut envelope = vec![1u8]; envelope.extend_from_slice(&1u16.to_le_bytes());
envelope.push(b'f');
envelope.extend_from_slice(&secs.to_le_bytes());
envelope.extend_from_slice(&0u32.to_le_bytes());
if let Ok(parsed) = parse(&envelope) {
let reparsed = parse(serialize(&parsed).unwrap().as_slice()).unwrap();
assert_eq!(reparsed.mtime(), parsed.mtime(), "secs {secs}");
}
}
}
#[test]
fn round_trip_pre_epoch_mtime() {
let mtime = UNIX_EPOCH - Duration::new(100, 250_000_000);
let parsed = round_trip(&meta("old.txt", Some(mtime), None));
assert_eq!(parsed.mtime(), Some(mtime));
}
#[test]
fn unknown_flags_rejected() {
let mut envelope = serialize(&meta("a", None, None))
.unwrap()
.as_slice()
.to_vec();
envelope[0] |= 0b1000_0000;
assert!(parse(&envelope).is_err());
}
#[test]
fn trailing_bytes_rejected() {
let mut envelope = serialize(&meta("a", None, None))
.unwrap()
.as_slice()
.to_vec();
envelope.push(0);
assert!(parse(&envelope).is_err());
}
#[test]
fn truncated_envelope_rejected() {
let envelope = serialize(&meta("abcdef", None, Some(0o600)))
.unwrap()
.as_slice()
.to_vec();
for len in 0..envelope.len() {
assert!(parse(&envelope[..len]).is_err(), "accepted prefix {len}");
}
}
#[test]
fn invalid_utf8_filename_rejected() {
let envelope = [0u8, 2, 0, 0xff, 0xfe];
assert!(parse(&envelope).is_err());
}
#[test]
fn oversized_filename_rejected() {
let long = "x".repeat(u16::MAX as usize + 1);
assert!(matches!(
serialize(&meta(&long, None, None)),
Err(HeaderError::MetadataTooLong)
));
}
}