From 4ad83ceb186b5c911fb4238646fbfcdec48d0ab5 Mon Sep 17 00:00:00 2001 From: Dmitry Kovba Date: Fri, 20 Mar 2026 22:52:48 -0700 Subject: [PATCH 1/5] Add a roundtrip test for pre-1970 dates --- .../TestEXT4Format+Create.swift | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Tests/ContainerizationEXT4Tests/TestEXT4Format+Create.swift b/Tests/ContainerizationEXT4Tests/TestEXT4Format+Create.swift index ef81b634..1a435d0e 100644 --- a/Tests/ContainerizationEXT4Tests/TestEXT4Format+Create.swift +++ b/Tests/ContainerizationEXT4Tests/TestEXT4Format+Create.swift @@ -78,3 +78,33 @@ struct Ext4FormatCreateTests { } // should create /parent automatically } } + +@Suite(.serialized) +struct NegativeTimestampRoundtripTests { + private let fsPath = FilePath( + FileManager.default.temporaryDirectory + .appendingPathComponent("ext4-pre1970-roundtrip.img", isDirectory: false)) + private let apollo11MoonLanding: Date = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f.date(from: "1969-07-20T20:17:39.9Z")! + }() + + @Test func encodeNegativeTimestamp() throws { + let formatter = try EXT4.Formatter(fsPath, minDiskSize: 32.kib()) + defer { try? formatter.close() } + let ts = FileTimestamps(access: apollo11MoonLanding, modification: apollo11MoonLanding, creation: apollo11MoonLanding) + try formatter.create(path: FilePath("/file"), mode: EXT4.Inode.Mode(.S_IFREG, 0o755), ts: ts, buf: nil) + } + + @Test func decodeNegativeTimestamp() throws { + let reader = try EXT4.EXT4Reader(blockDevice: fsPath) + let (_, inode) = try reader.stat(FilePath("/file")) + let mtime = Date(fsTimestamp: UInt64(inode.mtime) | (UInt64(inode.mtimeExtra) << 32)) + let atime = Date(fsTimestamp: UInt64(inode.atime) | (UInt64(inode.atimeExtra) << 32)) + let crtime = Date(fsTimestamp: UInt64(inode.crtime) | (UInt64(inode.crtimeExtra) << 32)) + #expect(mtime == apollo11MoonLanding) + #expect(atime == apollo11MoonLanding) + #expect(crtime == apollo11MoonLanding) + } +} From 8441622855dc91f30a43c5dba85adaade043bd0f Mon Sep 17 00:00:00 2001 From: Dmitry Kovba Date: Sat, 21 Mar 2026 01:40:21 -0700 Subject: [PATCH 2/5] Fix EXT4 timestamp encoding for pre-1970 dates --- Sources/ContainerizationEXT4/EXT4+Formatter.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Sources/ContainerizationEXT4/EXT4+Formatter.swift b/Sources/ContainerizationEXT4/EXT4+Formatter.swift index 6eb22df0..95190bfd 100644 --- a/Sources/ContainerizationEXT4/EXT4+Formatter.swift +++ b/Sources/ContainerizationEXT4/EXT4+Formatter.swift @@ -1326,9 +1326,13 @@ extension Date { return 0x3_7fff_ffff } - let seconds = UInt64(s) - let nanoseconds = UInt64(self.timeIntervalSince1970.truncatingRemainder(dividingBy: 1) * 1_000_000_000) - - return seconds | (nanoseconds << 34) + // 32 bits - base: seconds since January 1, 1970, signed (negative for pre-1970 dates) + // 2 bits - epoch: overflow counter (0-3), how many times the 32-bit seconds field has wrapped + // 30 bits - nanoseconds (0-999,999,999) + let sInt64 = Int64(floor(s)) + let base = Int32(truncatingIfNeeded: sInt64) + let epoch = UInt64(sInt64 - Int64(base)) + let nanoseconds = min(UInt32((s - floor(s)) * 1_000_000_000), 999_999_999) + return UInt64(UInt32(bitPattern: base)) | epoch | (UInt64(nanoseconds) << 34) } } From 6c6177f2a3934f09c98037d886284399d0e19b09 Mon Sep 17 00:00:00 2001 From: Dmitry Kovba Date: Sat, 21 Mar 2026 02:02:33 -0700 Subject: [PATCH 3/5] Fix EXT4 timestamp decoding for pre-1970 dates --- Sources/ContainerizationEXT4/EXT4Reader+Export.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/ContainerizationEXT4/EXT4Reader+Export.swift b/Sources/ContainerizationEXT4/EXT4Reader+Export.swift index 9b32b71e..0ebf0256 100644 --- a/Sources/ContainerizationEXT4/EXT4Reader+Export.swift +++ b/Sources/ContainerizationEXT4/EXT4Reader+Export.swift @@ -203,7 +203,12 @@ extension Date { return } - let seconds = Int64(fsTimestamp & 0x3_ffff_ffff) + // 32 bits - base: seconds since January 1, 1970, signed (negative for pre-1970 dates) + // 2 bits - epoch: overflow counter (0-3), how many times the 32-bit seconds field has wrapped + // 30 bits - nanoseconds (0-999,999,999) + let base = Int32(truncatingIfNeeded: fsTimestamp) + let epoch = Int64(fsTimestamp & 0x3_0000_0000) + let seconds = Int64(base) + epoch let nanoseconds = Double(fsTimestamp >> 34) / 1_000_000_000 self = Date(timeIntervalSince1970: Double(seconds) + nanoseconds) From e303ec391932ee09ac19bf0b4ace6defbd882e6f Mon Sep 17 00:00:00 2001 From: Dmitry Kovba Date: Sat, 21 Mar 2026 14:34:28 -0700 Subject: [PATCH 4/5] Use `crtime` for the creation date --- Sources/ContainerizationEXT4/EXT4Reader+Export.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/ContainerizationEXT4/EXT4Reader+Export.swift b/Sources/ContainerizationEXT4/EXT4Reader+Export.swift index 0ebf0256..7d0a1359 100644 --- a/Sources/ContainerizationEXT4/EXT4Reader+Export.swift +++ b/Sources/ContainerizationEXT4/EXT4Reader+Export.swift @@ -73,7 +73,7 @@ extension EXT4.EXT4Reader { entry.size = Int64(size) entry.group = gid_t(inode.gid) entry.owner = uid_t(inode.uid) - entry.creationDate = Date(fsTimestamp: UInt64((inode.ctimeExtra << 32) | inode.ctime)) + entry.creationDate = Date(fsTimestamp: UInt64((inode.crtimeExtra << 32) | inode.crtime)) entry.modificationDate = Date(fsTimestamp: UInt64((inode.mtimeExtra << 32) | inode.mtime)) entry.contentAccessDate = Date(fsTimestamp: UInt64((inode.atimeExtra << 32) | inode.atime)) entry.xattrs = xattrs @@ -156,7 +156,7 @@ extension EXT4.EXT4Reader { entry.permissions = inode.mode entry.group = gid_t(inode.gid) entry.owner = uid_t(inode.uid) - entry.creationDate = Date(fsTimestamp: UInt64((inode.ctimeExtra << 32) | inode.ctime)) + entry.creationDate = Date(fsTimestamp: UInt64((inode.crtimeExtra << 32) | inode.crtime)) entry.modificationDate = Date(fsTimestamp: UInt64((inode.mtimeExtra << 32) | inode.mtime)) entry.contentAccessDate = Date(fsTimestamp: UInt64((inode.atimeExtra << 32) | inode.atime)) try writer.writeEntry(entry: entry, data: nil) From 0e406164ea35307d18d347075638902d5e4c6266 Mon Sep 17 00:00:00 2001 From: Dmitry Kovba Date: Sat, 21 Mar 2026 14:49:17 -0700 Subject: [PATCH 5/5] Fix a `UInt32` overflow --- Sources/ContainerizationEXT4/EXT4Reader+Export.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/ContainerizationEXT4/EXT4Reader+Export.swift b/Sources/ContainerizationEXT4/EXT4Reader+Export.swift index 7d0a1359..49f4978c 100644 --- a/Sources/ContainerizationEXT4/EXT4Reader+Export.swift +++ b/Sources/ContainerizationEXT4/EXT4Reader+Export.swift @@ -73,9 +73,9 @@ extension EXT4.EXT4Reader { entry.size = Int64(size) entry.group = gid_t(inode.gid) entry.owner = uid_t(inode.uid) - entry.creationDate = Date(fsTimestamp: UInt64((inode.crtimeExtra << 32) | inode.crtime)) - entry.modificationDate = Date(fsTimestamp: UInt64((inode.mtimeExtra << 32) | inode.mtime)) - entry.contentAccessDate = Date(fsTimestamp: UInt64((inode.atimeExtra << 32) | inode.atime)) + entry.creationDate = Date(fsTimestamp: UInt64(inode.crtimeExtra) << 32 | UInt64(inode.crtime)) + entry.modificationDate = Date(fsTimestamp: UInt64(inode.mtimeExtra) << 32 | UInt64(inode.mtime)) + entry.contentAccessDate = Date(fsTimestamp: UInt64(inode.atimeExtra) << 32 | UInt64(inode.atime)) entry.xattrs = xattrs if mode.isDir() { @@ -156,9 +156,9 @@ extension EXT4.EXT4Reader { entry.permissions = inode.mode entry.group = gid_t(inode.gid) entry.owner = uid_t(inode.uid) - entry.creationDate = Date(fsTimestamp: UInt64((inode.crtimeExtra << 32) | inode.crtime)) - entry.modificationDate = Date(fsTimestamp: UInt64((inode.mtimeExtra << 32) | inode.mtime)) - entry.contentAccessDate = Date(fsTimestamp: UInt64((inode.atimeExtra << 32) | inode.atime)) + entry.creationDate = Date(fsTimestamp: UInt64(inode.crtimeExtra) << 32 | UInt64(inode.crtime)) + entry.modificationDate = Date(fsTimestamp: UInt64(inode.mtimeExtra) << 32 | UInt64(inode.mtime)) + entry.contentAccessDate = Date(fsTimestamp: UInt64(inode.atimeExtra) << 32 | UInt64(inode.atime)) try writer.writeEntry(entry: entry, data: nil) } try writer.finishEncoding()