Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions crates/composefs-boot/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,15 @@ const REQUIRED_TOPLEVEL_TO_EMPTY_DIRS: &[&str] = &["boot", "sysroot"];

/// Empty the required top-level directories and set their mtime to match /usr.
fn empty_toplevel_dirs<ObjectID: FsVerityHashValue>(fs: &mut FileSystem<ObjectID>) -> Result<()> {
let usr_mtime = fs.root.get_directory(OsStr::new("usr"))?.stat.st_mtim_sec;
let usr_mtime = {
let stat = &fs.root.get_directory(OsStr::new("usr"))?.stat;
(stat.st_mtim_sec, stat.st_mtim_nsec)
};

for d in REQUIRED_TOPLEVEL_TO_EMPTY_DIRS {
let d = fs.root.get_directory_mut(d.as_ref())?;
d.stat.st_mtim_sec = usr_mtime;
d.stat.st_mtim_sec = usr_mtime.0;
d.stat.st_mtim_nsec = usr_mtime.1;
d.clear();
}

Expand Down
2 changes: 2 additions & 0 deletions crates/composefs-boot/src/selabel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,7 @@ mod tests {
st_uid: 0,
st_gid: 0,
st_mtim_sec: 0,
st_mtim_nsec: 0,
xattrs: Default::default(),
};

Expand All @@ -522,6 +523,7 @@ mod tests {
st_uid: 0,
st_gid: 0,
st_mtim_sec: 0,
st_mtim_nsec: 0,
xattrs: Default::default(),
},
LeafContent::Regular(RegularFile::Inline(data.to_vec().into_boxed_slice())),
Expand Down
3 changes: 2 additions & 1 deletion crates/composefs-fuse/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@ impl<'a, ObjectID: FsVerityHashValue> InodeRef<'a, ObjectID> {

fn fileattr(&self, ino: Ino, nlink_map: &[u32]) -> FileAttr {
let stat = self.stat();
let mtime = SystemTime::UNIX_EPOCH + Duration::from_secs(stat.st_mtim_sec as u64);
let mtime =
SystemTime::UNIX_EPOCH + Duration::new(stat.st_mtim_sec as u64, stat.st_mtim_nsec);

FileAttr {
ino,
Expand Down
2 changes: 2 additions & 0 deletions crates/composefs-oci/src/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ mod test {
st_uid: 0,
st_gid: 0,
st_mtim_sec: 0,
st_mtim_nsec: 0,
xattrs: BTreeMap::new(),
},
item: TarItem::Leaf(LeafContent::Regular(RegularFile::Inline([].into()))),
Expand All @@ -185,6 +186,7 @@ mod test {
st_uid: 0,
st_gid: 0,
st_mtim_sec: 0,
st_mtim_nsec: 0,
xattrs: BTreeMap::new(),
},
item: TarItem::Directory,
Expand Down
115 changes: 114 additions & 1 deletion crates/composefs-oci/src/tar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use anyhow::{Context, Result, bail, ensure};
use bytes::{Bytes, BytesMut};
use rustix::fs::makedev;
use tar_core::{
EntryType, HEADER_SIZE,
EntryType, HEADER_SIZE, PaxExtensions,
parse::{ParseEvent, Parser},
};
use tokio::{
Expand All @@ -42,6 +42,40 @@ use composefs::{

use crate::ImportStats;

/// Extract sub-second nanoseconds from PAX extension mtime.
///
/// PAX mtime values have the form `"<sec>.<frac>"` where `<frac>` is a
/// decimal fraction of a second with up to 9 significant digits.
/// `tar-core` keeps only the integer part in `ParsedEntry::mtime`; we read
/// the fractional part from the raw PAX bytes ourselves.
///
/// Returns 0 if there is no PAX mtime, the value has no fractional part,
/// or the value cannot be parsed.
fn pax_mtime_nsec(pax: &[u8]) -> u32 {
for ext in PaxExtensions::new(pax).flatten() {
if ext.key_bytes() == b"mtime" {
let Ok(value) = ext.value() else { return 0 };
// Split on '.': "1234567890.123456789" → frac = "123456789"
let Some(frac) = value.split_once('.').map(|(_, f)| f) else {
return 0;
};
// Truncate or pad to exactly 9 digits (nanosecond precision)
let frac = if frac.len() >= 9 {
&frac[..9]
} else {
// fewer than 9 digits: treat as leading digits, e.g. "5" → 500_000_000
let padding_digits = 9u32.saturating_sub(frac.len() as u32);
return frac
.parse::<u32>()
.ok()
.map_or(0, |v| v * 10u32.pow(padding_digits));
};
return frac.parse::<u32>().unwrap_or(0);
}
}
0
}

/// Receive data from channel, write to tmpfile, compute verity, and store object.
///
/// This runs in a blocking task to avoid blocking the async runtime.
Expand Down Expand Up @@ -436,6 +470,7 @@ pub fn get_entry<ObjectID: FsVerityHashValue>(
st_gid: entry.gid as u32,
st_mode: entry.mode,
st_mtim_sec: entry.mtime as i64,
st_mtim_nsec: entry.pax.map_or(0, pax_mtime_nsec),
xattrs,
},
item,
Expand Down Expand Up @@ -522,6 +557,84 @@ mod tests {
Ok(entries)
}

#[test]
fn test_pax_mtime_nsec_parsing() {
// Standard 9-digit fractional part
// "30 mtime=1234567890.123456789\n": "mtime=1234567890.123456789\n" = 27 bytes, "30 " = 3 → total 30
let pax = b"30 mtime=1234567890.123456789\n";
assert_eq!(pax_mtime_nsec(pax), 123_456_789, "9-digit fraction");

// Fewer than 9 digits: "5" → 500_000_000 ns
// "mtime=1234567890.5\n" = 19 bytes, "22 " = 3 → total 22
let pax = b"22 mtime=1234567890.5\n";
assert_eq!(pax_mtime_nsec(pax), 500_000_000, "1-digit fraction");

// Exactly 9 digits (no truncation needed)
// "mtime=1234567890.000000001\n" = 27 bytes, "30 " = 3 → total 30
let pax = b"30 mtime=1234567890.000000001\n";
assert_eq!(pax_mtime_nsec(pax), 1, "trailing single non-zero digit");

// More than 9 digits (truncate to 9)
// "mtime=1234567890.1234567899\n" = 28 bytes, "31 " = 3 → total 31
let pax = b"31 mtime=1234567890.1234567899\n";
assert_eq!(
pax_mtime_nsec(pax),
123_456_789,
"10-digit fraction truncated"
);

// No fractional part
// "mtime=1234567890\n" = 17 bytes, "20 " = 3 → total 20
let pax = b"20 mtime=1234567890\n";
assert_eq!(pax_mtime_nsec(pax), 0, "no fractional part");

// No mtime key
// "path=foo.txt\n" = 13 bytes, "16 " = 3 → total 16
let pax = b"16 path=foo.txt\n";
assert_eq!(pax_mtime_nsec(pax), 0, "no mtime key");

// Empty PAX data
assert_eq!(pax_mtime_nsec(b""), 0, "empty pax");
}

#[tokio::test]
async fn test_pax_mtime_nsec_on_entry() {
let content = b"test content";
let mut tar_data = Vec::new();
{
let mut builder = Builder::new(&mut tar_data);

let mut pax = tar_core::builder::PaxBuilder::new();
pax.add("mtime", "1234567890.123456789");
let pax_data = pax.finish();

let mut pax_header = tar::Header::new_ustar();
pax_header.set_entry_type(tar::EntryType::XHeader);
pax_header.set_mode(0o644);
pax_header.set_size(pax_data.len() as u64);
builder
.append_data(&mut pax_header, "PaxHeader/file.txt", &pax_data[..])
.unwrap();

let mut header = tar::Header::new_ustar();
header.set_mode(0o644);
header.set_uid(1000);
header.set_gid(1000);
header.set_mtime(1234567890);
header.set_size(content.len() as u64);
header.set_entry_type(tar::EntryType::Regular);
builder
.append_data(&mut header, "file.txt", &content[..])
.unwrap();
builder.finish().unwrap();
}

let entries = read_all_via_splitstream(tar_data).await.unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].stat.st_mtim_sec, 1_234_567_890);
assert_eq!(entries[0].stat.st_mtim_nsec, 123_456_789);
}

#[test]
fn test_make_absolute_path() {
let cases: &[(&[u8], &str)] = &[
Expand Down
1 change: 1 addition & 0 deletions crates/composefs/fuzz/generate_corpus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ fn stat(mode: u32, uid: u32, gid: u32, mtime: i64) -> Stat {
st_uid: uid,
st_gid: gid,
st_mtim_sec: mtime,
st_mtim_nsec: 0,
xattrs: BTreeMap::new(),
}
}
Expand Down
7 changes: 6 additions & 1 deletion crates/composefs/src/dumpfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,12 @@ fn write_entry(
let uid = stat.st_uid;
let gid = stat.st_gid;
let mtim_sec = stat.st_mtim_sec;
let mtim_nsec = stat.st_mtim_nsec;

write_escaped(writer, path.as_os_str().as_bytes())?;
write!(
writer,
" {size} {mode:o} {nlink} {uid} {gid} {rdev} {mtim_sec}.0 "
" {size} {mode:o} {nlink} {uid} {gid} {rdev} {mtim_sec}.{mtim_nsec} "
)?;
write_escaped(writer, payload.as_ref().as_bytes())?;
write!(writer, " ")?;
Expand Down Expand Up @@ -540,6 +541,7 @@ fn entry_to_stat(entry: &Entry<'_>) -> Stat {
st_uid: entry.uid,
st_gid: entry.gid,
st_mtim_sec: entry.mtime.sec as i64,
st_mtim_nsec: entry.mtime.nsec as u32,
xattrs,
}
}
Expand Down Expand Up @@ -724,6 +726,7 @@ mod tests {
st_uid: 0,
st_gid: 0,
st_mtim_sec: 0,
st_mtim_nsec: 0,
xattrs: BTreeMap::new(),
});
let leaf_id = fs.push_leaf(
Expand All @@ -732,6 +735,7 @@ mod tests {
st_uid: 0,
st_gid: 0,
st_mtim_sec: 0,
st_mtim_nsec: 0,
xattrs,
},
LeafContent::Regular(RegularFile::Inline(b"test".to_vec().into())),
Expand All @@ -757,6 +761,7 @@ mod tests {
st_uid: 0,
st_gid: 0,
st_mtim_sec: 0,
st_mtim_nsec: 0,
xattrs: BTreeMap::new(),
};

Expand Down
7 changes: 6 additions & 1 deletion crates/composefs/src/dumpfile_parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,9 +317,14 @@ impl FromStr for Mtime {
let (sec, nsec) = s
.split_once('.')
.ok_or_else(|| anyhow!("Missing . in mtime"))?;
let nsec = u32::from_str(nsec)?;
anyhow::ensure!(
nsec < 1_000_000_000,
"Invalid mtime nanoseconds: {nsec} (must be < 1_000_000_000)"
);
Ok(Self {
sec: u64::from_str(sec)?,
nsec: u64::from_str(nsec)?,
nsec: u64::from(nsec),
})
}
}
Expand Down
5 changes: 4 additions & 1 deletion crates/composefs/src/erofs/reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1078,7 +1078,7 @@ fn construct_xattr_name(xattr: &XAttr) -> Result<Vec<u8>, ErofsReaderError> {
/// - Strips `trusted.overlay.metacopy` and `trusted.overlay.redirect`
/// - Unescapes `trusted.overlay.overlay.X` back to `trusted.overlay.X`
fn stat_from_inode_for_tree(img: &Image, inode: &InodeType) -> anyhow::Result<tree::Stat> {
let (st_mode, st_uid, st_gid, st_mtim_sec) = match inode {
let (st_mode, st_uid, st_gid, st_mtim_sec, st_mtim_nsec) = match inode {
InodeType::Compact(inode) => (
inode.header.mode.0.get() as u32 & 0o7777,
inode.header.uid.get() as u32,
Expand All @@ -1087,12 +1087,14 @@ fn stat_from_inode_for_tree(img: &Image, inode: &InodeType) -> anyhow::Result<tr
// but for round-trip purposes, 0 matches what was written for
// compact headers (the writer always uses ExtendedInodeHeader)
0i64,
img.sb.build_time_nsec.get(),
),
InodeType::Extended(inode) => (
inode.header.mode.0.get() as u32 & 0o7777,
inode.header.uid.get(),
inode.header.gid.get(),
inode.header.mtime.get() as i64,
inode.header.mtime_nsec.get(),
),
};

Expand Down Expand Up @@ -1120,6 +1122,7 @@ fn stat_from_inode_for_tree(img: &Image, inode: &InodeType) -> anyhow::Result<tr
st_uid,
st_gid,
st_mtim_sec,
st_mtim_nsec,
xattrs,
})
}
Expand Down
1 change: 1 addition & 0 deletions crates/composefs/src/erofs/writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@ impl<ObjectID: FsVerityHashValue> Inode<'_, ObjectID> {
uid: self.stat.st_uid.into(),
gid: self.stat.st_gid.into(),
mtime: (self.stat.st_mtim_sec as u64).into(),
mtime_nsec: self.stat.st_mtim_nsec.into(),
nlink: (nlink as u32).into(),
..Default::default()
});
Expand Down
2 changes: 2 additions & 0 deletions crates/composefs/src/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ fn stat_fd(fd: &OwnedFd, ifmt: FileType) -> Result<(rustix::fs::Stat, generic_tr
st_uid: buf.st_uid,
st_gid: buf.st_gid,
st_mtim_sec: buf.st_mtime as i64,
st_mtim_nsec: buf.st_mtime_nsec as u32,
xattrs: read_xattrs(fd)?,
},
))
Expand Down Expand Up @@ -689,6 +690,7 @@ mod tests {
st_uid: 0,
st_gid: 0,
st_mtim_sec: Default::default(),
st_mtim_nsec: 0,
xattrs: Default::default(),
};
set_file_contents(&td, OsStr::new("testfile"), &st, b"new contents").unwrap();
Expand Down
Loading