diff --git a/Sources/ContainerizationEXT4/EXT4+Formatter.swift b/Sources/ContainerizationEXT4/EXT4+Formatter.swift index 6fa4a99a..6812f083 100644 --- a/Sources/ContainerizationEXT4/EXT4+Formatter.swift +++ b/Sources/ContainerizationEXT4/EXT4+Formatter.swift @@ -235,13 +235,13 @@ extension EXT4 { // the file we are deleting is a hardlink, decrement the link count let linkedInodePtr = self.inodes[Int(hardlink - 1)] var linkedInode = linkedInodePtr.pointee - if linkedInode.linksCount > 2 { + if linkedInode.linksCount > 1 { linkedInode.linksCount -= 1 linkedInodePtr.initialize(to: linkedInode) } } - guard inodeNumber > FirstInode else { + guard inodeNumber >= FirstInode else { // Free the inodes and the blocks related to the inode only if its valid return } diff --git a/Tests/ContainerizationEXT4Tests/TestEXT4Format+Create.swift b/Tests/ContainerizationEXT4Tests/TestEXT4Format+Create.swift index 1a435d0e..ef81b634 100644 --- a/Tests/ContainerizationEXT4Tests/TestEXT4Format+Create.swift +++ b/Tests/ContainerizationEXT4Tests/TestEXT4Format+Create.swift @@ -78,33 +78,3 @@ 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) - } -} diff --git a/Tests/ContainerizationEXT4Tests/TestEXT4Format+Link.swift b/Tests/ContainerizationEXT4Tests/TestEXT4Format+Link.swift new file mode 100644 index 00000000..ab570520 --- /dev/null +++ b/Tests/ContainerizationEXT4Tests/TestEXT4Format+Link.swift @@ -0,0 +1,60 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import SystemPackage +import Testing + +@testable import ContainerizationEXT4 + +struct Ext4FormatLinkTests { + @Test func hardlinkLinksCount() throws { + func makeFile(unlink: Bool) throws -> FilePath { + let path = FilePath( + FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: false)) + let fmt = try EXT4.Formatter(path, minDiskSize: 32.kib()) + try fmt.create(path: "/original", mode: EXT4.Inode.Mode(.S_IFREG, 0o755), buf: nil) + try fmt.link(link: "/hardlink", target: "/original") + if unlink { + try fmt.unlink(path: "/hardlink") + } + try fmt.close() + return path + } + + let afterLink = try makeFile(unlink: false) + #expect(try EXT4.EXT4Reader(blockDevice: afterLink).stat("/original").inode.linksCount == 2) + + let afterUnlink = try makeFile(unlink: true) + #expect(try EXT4.EXT4Reader(blockDevice: afterUnlink).stat("/original").inode.linksCount == 1) + } + + @Test func unlinkFirstInodeFreesInode() throws { + let emptyPath = FilePath(FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: false)) + defer { try? FileManager.default.removeItem(at: emptyPath.url) } + try EXT4.Formatter(emptyPath, minDiskSize: 32.kib()).close() + + let path = FilePath(FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: false)) + defer { try? FileManager.default.removeItem(at: path.url) } + let fmt = try EXT4.Formatter(path, minDiskSize: 32.kib()) + try fmt.create(path: FilePath("/file"), mode: EXT4.Inode.Mode(.S_IFREG, 0o755), buf: nil) + try fmt.unlink(path: FilePath("/file")) + try fmt.close() + + #expect(try EXT4.EXT4Reader(blockDevice: path).superBlock.freeInodesCount == EXT4.EXT4Reader(blockDevice: emptyPath).superBlock.freeInodesCount) + } +} diff --git a/Tests/ContainerizationEXT4Tests/TestEXT4Format.swift b/Tests/ContainerizationEXT4Tests/TestEXT4Format.swift index 4c0f2952..fd5e24fb 100644 --- a/Tests/ContainerizationEXT4Tests/TestEXT4Format.swift +++ b/Tests/ContainerizationEXT4Tests/TestEXT4Format.swift @@ -219,3 +219,33 @@ struct Ext4FormatTests: ~Copyable { #expect(regFile.sizeLow == 4) } } + +@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) + } +}