Skip to content

Commit 96483e4

Browse files
committed
Fix ArchiveReader to support readonly directory
Defer attribute setting of directories so that we can unarchive child entries first, then apply attributes which might be read-only.
1 parent f2c4240 commit 96483e4

2 files changed

Lines changed: 48 additions & 5 deletions

File tree

Sources/ContainerizationArchive/ArchiveReader.swift

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@ extension ArchiveReader {
284284
// Iterate and extract archive entries, collecting rejected paths.
285285
var foundEntry = false
286286
var rejectedPaths = [String]()
287+
var deferredDirAttrs: [(path: FilePath, entry: WriteEntry)] = []
287288
for (entry, dataReader) in self.makeStreamingIterator() {
288289
guard let memberPath = (entry.path.map { FilePath($0) }) else {
289290
continue
@@ -298,6 +299,10 @@ extension ArchiveReader {
298299
rootFileDescriptor: rootFileDescriptor
299300
)
300301

302+
if extracted, entry.fileType == .directory {
303+
deferredDirAttrs.append((memberPath, entry))
304+
}
305+
301306
if !extracted {
302307
rejectedPaths.append(memberPath.string)
303308
}
@@ -306,6 +311,15 @@ extension ArchiveReader {
306311
throw ArchiveError.failedToExtractArchive("no entries found in archive")
307312
}
308313

314+
// Apply directory permissions after all children are extracted, deepest first,
315+
// so a read-only parent doesn't block applying permissions to its children.
316+
for deferred in deferredDirAttrs.sorted(by: { $0.path.string.count > $1.path.string.count }) {
317+
let fd = openat(rootFileDescriptor.rawValue, deferred.path.string, O_RDONLY | O_DIRECTORY | O_NOFOLLOW)
318+
guard fd >= 0 else { continue }
319+
setFileAttributes(fd: fd, entry: deferred.entry)
320+
close(fd)
321+
}
322+
309323
return rejectedPaths
310324
}
311325

@@ -362,9 +376,7 @@ extension ArchiveReader {
362376
setFileAttributes(fd: fileFd, entry: entry)
363377
}
364378
case .directory:
365-
try rootFileDescriptor.mkdirSecure(memberPath, makeIntermediates: true) { fd in
366-
setFileAttributes(fd: fd.rawValue, entry: entry)
367-
}
379+
try rootFileDescriptor.mkdirSecure(memberPath, makeIntermediates: true) { _ in }
368380
case .symbolicLink:
369381
guard let targetPath = (entry.symlinkTarget.map { FilePath($0) }) else {
370382
return false

Tests/ContainerizationArchiveTests/ArchiveTests.swift

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,37 @@ struct ArchiveTests {
242242
#expect(try String(contentsOf: extractDir.appendingPathComponent("subdir/file2.txt"), encoding: .utf8) == "world")
243243
}
244244

245+
@Test func archiveDirectoryPreservesReadonlySubdirectory() throws {
246+
let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveDirReadonlySubdir")!
247+
defer { try? FileManager.default.removeItem(at: testDir) }
248+
249+
let sourceDir = testDir.appendingPathComponent("source")
250+
let readonlyDir = sourceDir.appendingPathComponent("readonly")
251+
try FileManager.default.createDirectory(at: readonlyDir, withIntermediateDirectories: true)
252+
try "content".write(to: readonlyDir.appendingPathComponent("file.txt"), atomically: true, encoding: .utf8)
253+
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: readonlyDir.path)
254+
defer {
255+
try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: readonlyDir.path)
256+
}
257+
258+
let archiveURL = testDir.appendingPathComponent("test.tar.gz")
259+
let writer = try ArchiveWriter(format: .pax, filter: .gzip, file: archiveURL)
260+
try writer.archiveDirectory(sourceDir)
261+
try writer.finishEncoding()
262+
263+
let extractDir = testDir.appendingPathComponent("extract")
264+
let reader = try ArchiveReader(file: archiveURL)
265+
let rejected = try reader.extractContents(to: extractDir)
266+
267+
#expect(rejected.isEmpty)
268+
#expect(
269+
try String(contentsOf: extractDir.appendingPathComponent("readonly/file.txt"), encoding: .utf8)
270+
== "content")
271+
let attrs = try FileManager.default.attributesOfItem(atPath: extractDir.appendingPathComponent("readonly").path)
272+
let perms = (attrs[.posixPermissions] as? NSNumber)?.uint16Value ?? 0
273+
#expect((perms & 0o777) == 0o555, "Read-only directory permissions should be preserved")
274+
}
275+
245276
@Test func archiveDirectoryEmpty() throws {
246277
let testDir = createTemporaryDirectory(baseName: "ArchiveTests.archiveDirEmpty")!
247278
defer { try? FileManager.default.removeItem(at: testDir) }
@@ -722,7 +753,7 @@ struct ArchiveTests {
722753
let readonlyDir = sourceDir.appendingPathComponent("readonly")
723754
try FileManager.default.createDirectory(at: readonlyDir, withIntermediateDirectories: true)
724755
try "content".write(to: readonlyDir.appendingPathComponent("file.txt"), atomically: true, encoding: .utf8)
725-
try FileManager.default.setAttributes([.posixPermissions: 0o777], ofItemAtPath: readonlyDir.path)
756+
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: readonlyDir.path)
726757
defer {
727758
try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: readonlyDir.path)
728759
}
@@ -742,7 +773,7 @@ struct ArchiveTests {
742773
== "content")
743774
let attrs = try FileManager.default.attributesOfItem(atPath: extractDir.appendingPathComponent("readonly").path)
744775
let perms = (attrs[.posixPermissions] as? NSNumber)?.uint16Value ?? 0
745-
#expect((perms & 0o777) == 0o777, "Read-only directory permissions should be preserved")
776+
#expect((perms & 0o777) == 0o555, "Read-only directory permissions should be preserved")
746777
}
747778

748779
@Test func archiveURLsSymlinks() throws {

0 commit comments

Comments
 (0)