Skip to content

Commit d992a19

Browse files
authored
fix(EXT4): create missing parent dirs for hardlinks during unpack (#777)
When unpacking an OCI/tar layer, create() already creates missing parent directories recursively, so regular files and symlinks with absent parent entries unpack correctly. link() did not, so a hardlink whose parent directory had no explicit archive entry failed with "<path> not found" (e.g. images produced by Bazel rules_img). Mirror create()'s implicit parent creation in link() so such layers unpack, matching Docker/containerd. Adds a direct link() unit test and an end-to-end unpack regression test covering a hardlink, regular file, and symlink with no explicit parent. Fixes apple/container#1797
1 parent a132341 commit d992a19

3 files changed

Lines changed: 120 additions & 0 deletions

File tree

Sources/ContainerizationEXT4/EXT4+Formatter.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,8 @@ extension EXT4 {
166166
if self.tree.lookup(path: link) != nil {
167167
try self.unlink(path: link)
168168
}
169+
// create all predecessors recursively
170+
try self.create(path: parentPath, mode: Inode.Mode(.S_IFDIR, 0o755), recursion: true)
169171
guard let parentTreeNodePtr = self.tree.lookup(path: parentPath) else {
170172
throw Error.notFound(parentPath)
171173
}

Tests/ContainerizationEXT4Tests/TestEXT4Format+Link.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,25 @@ struct Ext4FormatLinkTests {
4343
#expect(try EXT4.EXT4Reader(blockDevice: afterUnlink).stat("/original").inode.linksCount == 1)
4444
}
4545

46+
@Test func hardlinkCreatesMissingParents() throws {
47+
let path = FilePath(
48+
FileManager.default.temporaryDirectory
49+
.appendingPathComponent(UUID().uuidString, isDirectory: false))
50+
defer { try? FileManager.default.removeItem(at: path.url) }
51+
let fmt = try EXT4.Formatter(path, minDiskSize: 32.kib())
52+
try fmt.create(path: "/original", mode: EXT4.Inode.Mode(.S_IFREG, 0o755), buf: nil)
53+
// Parent dirs /a and /a/b do not exist yet; link must create them implicitly.
54+
try fmt.link(link: "/a/b/hardlink", target: "/original")
55+
try fmt.close()
56+
57+
let reader = try EXT4.EXT4Reader(blockDevice: path)
58+
#expect(try reader.stat("/a").inode.mode.isDir())
59+
#expect(try reader.stat("/a/b").inode.mode.isDir())
60+
let target = try reader.stat("/original")
61+
#expect(try reader.stat("/a/b/hardlink").inodeNumber == target.inodeNumber)
62+
#expect(target.inode.linksCount == 2)
63+
}
64+
4665
@Test func unlinkFirstInodeFreesInode() throws {
4766
let emptyPath = FilePath(FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: false))
4867
defer { try? FileManager.default.removeItem(at: emptyPath.url) }

Tests/ContainerizationEXT4Tests/TestFormatterUnpack.swift

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,97 @@ struct UnpackProgressTest {
361361
let childNames = Set(children.map { $0.0 })
362362
#expect(childNames.contains("test"), "Directory 'test' should exist in unpacked filesystem")
363363
}
364+
365+
@Test func unpackCreatesImplicitParentsForHardlink() async throws {
366+
// A hardlink whose parent dir has no explicit archive entry must unpack: the
367+
// missing parents are created implicitly instead of failing with "... not found".
368+
// This is the exact repro shape (e.g. Bazel rules_img runfiles trees).
369+
let tempDir = FileManager.default.uniqueTemporaryDirectory()
370+
let archivePath = tempDir.appendingPathComponent("hardlink.tar.gz", isDirectory: false)
371+
let fsPath = FilePath(tempDir.appendingPathComponent("hardlink.ext4.img", isDirectory: false))
372+
defer { try? FileManager.default.removeItem(at: tempDir) }
373+
374+
let archiver = try ArchiveWriter(
375+
configuration: ArchiveWriterConfiguration(format: .paxRestricted, filter: .gzip))
376+
try archiver.open(file: archivePath)
377+
// The hardlink target. /bin itself has no explicit dir entry either.
378+
let payload = Data("hello".utf8)
379+
try archiver.writeEntry(
380+
entry: WriteEntry.file(path: "/bin/app", permissions: 0o755, size: Int64(payload.count)),
381+
data: payload)
382+
// The parent dir /bin/app.runfiles/_main/app_ has no explicit archive entry.
383+
try archiver.writeEntry(
384+
entry: WriteEntry.hardlink(path: "/bin/app.runfiles/_main/app_/app", target: "/bin/app"),
385+
data: nil)
386+
try archiver.finishEncoding()
387+
388+
let formatter = try EXT4.Formatter(fsPath)
389+
try await formatter.unpack(source: archivePath) // must not throw notFound
390+
try formatter.close()
391+
392+
let reader = try EXT4.EXT4Reader(blockDevice: fsPath)
393+
// Implicitly-created parent directories exist and are directories.
394+
#expect(try reader.stat("/bin/app.runfiles").inode.mode.isDir())
395+
#expect(try reader.stat("/bin/app.runfiles/_main").inode.mode.isDir())
396+
#expect(try reader.stat("/bin/app.runfiles/_main/app_").inode.mode.isDir())
397+
// Hardlink resolves to the target inode and bumps the link count to 2.
398+
let target = try reader.stat("/bin/app")
399+
let hardlink = try reader.stat("/bin/app.runfiles/_main/app_/app")
400+
#expect(hardlink.inodeNumber == target.inodeNumber)
401+
#expect(target.inode.linksCount == 2)
402+
}
403+
404+
@Test func unpackCreatesImplicitParentsForSymlink() async throws {
405+
// A symlink whose parent dir has no explicit archive entry must unpack.
406+
let tempDir = FileManager.default.uniqueTemporaryDirectory()
407+
let archivePath = tempDir.appendingPathComponent("symlink.tar.gz", isDirectory: false)
408+
let fsPath = FilePath(tempDir.appendingPathComponent("symlink.ext4.img", isDirectory: false))
409+
defer { try? FileManager.default.removeItem(at: tempDir) }
410+
411+
let archiver = try ArchiveWriter(
412+
configuration: ArchiveWriterConfiguration(format: .paxRestricted, filter: .gzip))
413+
try archiver.open(file: archivePath)
414+
// The parent dir /etc/links has no explicit archive entry.
415+
try archiver.writeEntry(
416+
entry: WriteEntry.link(path: "/etc/links/cur", permissions: 0o777, target: "/bin/app"),
417+
data: nil)
418+
try archiver.finishEncoding()
419+
420+
let formatter = try EXT4.Formatter(fsPath)
421+
try await formatter.unpack(source: archivePath) // must not throw notFound
422+
try formatter.close()
423+
424+
let reader = try EXT4.EXT4Reader(blockDevice: fsPath)
425+
#expect(try reader.stat("/etc/links").inode.mode.isDir())
426+
#expect(try reader.stat("/etc/links/cur", followSymlinks: false).inode.mode.isLink())
427+
}
428+
429+
@Test func unpackCreatesImplicitParentsForRegularFile() async throws {
430+
// A regular file whose parent dir has no explicit archive entry must unpack.
431+
let tempDir = FileManager.default.uniqueTemporaryDirectory()
432+
let archivePath = tempDir.appendingPathComponent("file.tar.gz", isDirectory: false)
433+
let fsPath = FilePath(tempDir.appendingPathComponent("file.ext4.img", isDirectory: false))
434+
defer { try? FileManager.default.removeItem(at: tempDir) }
435+
436+
let archiver = try ArchiveWriter(
437+
configuration: ArchiveWriterConfiguration(format: .paxRestricted, filter: .gzip))
438+
try archiver.open(file: archivePath)
439+
// The parent dir /var/lib/data has no explicit archive entry.
440+
let payload = Data("world".utf8)
441+
try archiver.writeEntry(
442+
entry: WriteEntry.file(path: "/var/lib/data/file.txt", permissions: 0o644, size: Int64(payload.count)),
443+
data: payload)
444+
try archiver.finishEncoding()
445+
446+
let formatter = try EXT4.Formatter(fsPath)
447+
try await formatter.unpack(source: archivePath) // must not throw notFound
448+
try formatter.close()
449+
450+
let reader = try EXT4.EXT4Reader(blockDevice: fsPath)
451+
#expect(try reader.stat("/var/lib/data").inode.mode.isDir())
452+
#expect(try reader.stat("/var/lib/data/file.txt").inode.mode.isReg())
453+
#expect(try reader.readFile(at: "/var/lib/data/file.txt") == payload)
454+
}
364455
}
365456

366457
extension ContainerizationArchive.WriteEntry {
@@ -391,6 +482,14 @@ extension ContainerizationArchive.WriteEntry {
391482
entry.symlinkTarget = target
392483
return entry
393484
}
485+
486+
static func hardlink(path: String, target: String) -> WriteEntry {
487+
let entry = WriteEntry()
488+
entry.path = path
489+
entry.fileType = .regular
490+
entry.hardlink = target
491+
return entry
492+
}
394493
}
395494

396495
extension EXT4.EXT4Reader {

0 commit comments

Comments
 (0)