@@ -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
366457extension 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
396495extension EXT4 . EXT4Reader {
0 commit comments