From 6e32963617b3ed8f4b63432dbcf834f94807342b Mon Sep 17 00:00:00 2001 From: Steve Date: Tue, 28 Jul 2026 11:19:59 +0100 Subject: [PATCH 1/2] fix(archive): preserve special permission bits Archive extraction masked modes to ordinary rwx bits, stripping sticky, set-user-ID, and set-group-ID permissions. Preserve the full POSIX permission mask and add a sticky-directory regression. --- .../ArchiveReader.swift | 2 +- .../ArchiveReaderTests.swift | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/Sources/ContainerizationArchive/ArchiveReader.swift b/Sources/ContainerizationArchive/ArchiveReader.swift index 3c291d1a5..9e254e6fb 100644 --- a/Sources/ContainerizationArchive/ArchiveReader.swift +++ b/Sources/ContainerizationArchive/ArchiveReader.swift @@ -398,7 +398,7 @@ extension ArchiveReader { } private func setFileAttributes(fd: Int32, entry: WriteEntry) { - fchmod(fd, entry.permissions & 0o777) + fchmod(fd, entry.permissions & 0o7777) if let owner = entry.owner, let group = entry.group { fchown(fd, owner, group) } diff --git a/Tests/ContainerizationArchiveTests/ArchiveReaderTests.swift b/Tests/ContainerizationArchiveTests/ArchiveReaderTests.swift index c7c0e563a..e2158d804 100644 --- a/Tests/ContainerizationArchiveTests/ArchiveReaderTests.swift +++ b/Tests/ContainerizationArchiveTests/ArchiveReaderTests.swift @@ -438,6 +438,36 @@ struct ArchiveReaderTests { #expect((perms & permMask) == 0o755, "Permissions should be preserved") } + @Test func preserveSpecialPermissionBits() throws { + let testDirectory = createTemporaryDirectory(baseName: "ArchiveReaderTests")! + let archiveURL = testDirectory.appendingPathComponent("special-permissions.tar") + let archiver = try ArchiveWriter(format: .paxRestricted, filter: .none, file: archiveURL) + + let writeEntry = WriteEntry() + writeEntry.path = "sticky" + writeEntry.fileType = .directory + writeEntry.permissions = 0o1777 + writeEntry.size = 0 + try archiver.writeEntry(entry: writeEntry, data: nil) + try archiver.finishEncoding() + + defer { try? FileManager.default.removeItem(at: testDirectory) } + + let extractDir = try createExtractionDirectory(name: "special-permissions") + defer { try? FileManager.default.removeItem(at: extractDir.deletingLastPathComponent()) } + + let reader = try ArchiveReader(format: .paxRestricted, filter: .none, file: archiveURL) + let rejectedPaths = try reader.extractContents(to: extractDir) + + #expect(rejectedPaths.isEmpty) + + let directoryPath = extractDir.appendingPathComponent("sticky").path + let attrs = try FileManager.default.attributesOfItem(atPath: directoryPath) + let perms = (attrs[.posixPermissions] as? NSNumber)?.uint16Value ?? 0 + let permMask: UInt16 = 0o7777 + #expect((perms & permMask) == 0o1777, "Special permission bits should be preserved") + } + // MARK: - Duplicate Entry Tests @Test func duplicateRegularFiles() throws { From 726e1ffdceada5cc62d32c8fc939aef30220e6ff Mon Sep 17 00:00:00 2001 From: Steve Date: Tue, 28 Jul 2026 11:27:16 +0100 Subject: [PATCH 2/2] fix(archive): restore permissions after ownership Darwin clears set-user-ID and set-group-ID bits when fchown changes ownership. Apply ownership first, then restore the archived mode, and extend the archive regression to cover set-ID permissions. --- .../ContainerizationArchive/ArchiveReader.swift | 2 +- .../ArchiveReaderTests.swift | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Sources/ContainerizationArchive/ArchiveReader.swift b/Sources/ContainerizationArchive/ArchiveReader.swift index 9e254e6fb..c130200fb 100644 --- a/Sources/ContainerizationArchive/ArchiveReader.swift +++ b/Sources/ContainerizationArchive/ArchiveReader.swift @@ -398,10 +398,10 @@ extension ArchiveReader { } private func setFileAttributes(fd: Int32, entry: WriteEntry) { - fchmod(fd, entry.permissions & 0o7777) if let owner = entry.owner, let group = entry.group { fchown(fd, owner, group) } + fchmod(fd, entry.permissions & 0o7777) } private static func copyDataReaderToFd(dataReader: ArchiveEntryReader, fileFd: Int32, memberPath: FilePath) throws { diff --git a/Tests/ContainerizationArchiveTests/ArchiveReaderTests.swift b/Tests/ContainerizationArchiveTests/ArchiveReaderTests.swift index e2158d804..e9da47cb2 100644 --- a/Tests/ContainerizationArchiveTests/ArchiveReaderTests.swift +++ b/Tests/ContainerizationArchiveTests/ArchiveReaderTests.swift @@ -449,6 +449,17 @@ struct ArchiveReaderTests { writeEntry.permissions = 0o1777 writeEntry.size = 0 try archiver.writeEntry(entry: writeEntry, data: nil) + + let setIDEntry = WriteEntry() + setIDEntry.path = "set-id" + setIDEntry.fileType = .regular + setIDEntry.permissions = 0o6755 + setIDEntry.owner = getuid() + setIDEntry.group = getgid() + let setIDData = Data("set-id".utf8) + setIDEntry.size = numericCast(setIDData.count) + try archiver.writeEntry(entry: setIDEntry, data: setIDData) + try archiver.finishEncoding() defer { try? FileManager.default.removeItem(at: testDirectory) } @@ -466,6 +477,11 @@ struct ArchiveReaderTests { let perms = (attrs[.posixPermissions] as? NSNumber)?.uint16Value ?? 0 let permMask: UInt16 = 0o7777 #expect((perms & permMask) == 0o1777, "Special permission bits should be preserved") + + let setIDPath = extractDir.appendingPathComponent("set-id").path + let setIDAttrs = try FileManager.default.attributesOfItem(atPath: setIDPath) + let setIDPerms = (setIDAttrs[.posixPermissions] as? NSNumber)?.uint16Value ?? 0 + #expect((setIDPerms & permMask) == 0o6755, "Set-ID permission bits should be preserved after ownership") } // MARK: - Duplicate Entry Tests