Skip to content

Commit 250546f

Browse files
authored
Fix the hardlink count decrement threshold and unlink cleanup (#610)
- Fixes a hardlink count decrement threshold - Fixes unlinking not freeing the first inode Resolves the failing added tests: ``` ✘ Test hardlinkLinksCount() recorded an issue at TestEXT4Format+Link.swift:43:9: Expectation failed: try EXT4.EXT4Reader(blockDevice: afterUnlink).stat("/original").inode.linksCount == 1 ✘ Test hardlinkLinksCount() failed after 0.016 seconds with 1 issue. ``` ``` ✘ Test unlinkFirstInodeFreesInode() recorded an issue at TestEXT4Format+Link.swift:58:9: Expectation failed: try EXT4.EXT4Reader(blockDevice: path).superBlock.freeInodesCount == EXT4.EXT4Reader(blockDevice: emptyPath).superBlock.freeInodesCount ✘ Test unlinkFirstInodeFreesInode() failed after 0.014 seconds with 1 issue. ```
1 parent 2c4012e commit 250546f

4 files changed

Lines changed: 92 additions & 32 deletions

File tree

Sources/ContainerizationEXT4/EXT4+Formatter.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,13 +233,13 @@ extension EXT4 {
233233
// the file we are deleting is a hardlink, decrement the link count
234234
let linkedInodePtr = self.inodes[Int(hardlink - 1)]
235235
var linkedInode = linkedInodePtr.pointee
236-
if linkedInode.linksCount > 2 {
236+
if linkedInode.linksCount > 1 {
237237
linkedInode.linksCount -= 1
238238
linkedInodePtr.initialize(to: linkedInode)
239239
}
240240
}
241241

242-
guard inodeNumber > FirstInode else {
242+
guard inodeNumber >= FirstInode else {
243243
// Free the inodes and the blocks related to the inode only if its valid
244244
return
245245
}

Tests/ContainerizationEXT4Tests/TestEXT4Format+Create.swift

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -78,33 +78,3 @@ struct Ext4FormatCreateTests {
7878
} // should create /parent automatically
7979
}
8080
}
81-
82-
@Suite(.serialized)
83-
struct NegativeTimestampRoundtripTests {
84-
private let fsPath = FilePath(
85-
FileManager.default.temporaryDirectory
86-
.appendingPathComponent("ext4-pre1970-roundtrip.img", isDirectory: false))
87-
private let apollo11MoonLanding: Date = {
88-
let f = ISO8601DateFormatter()
89-
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
90-
return f.date(from: "1969-07-20T20:17:39.9Z")!
91-
}()
92-
93-
@Test func encodeNegativeTimestamp() throws {
94-
let formatter = try EXT4.Formatter(fsPath, minDiskSize: 32.kib())
95-
defer { try? formatter.close() }
96-
let ts = FileTimestamps(access: apollo11MoonLanding, modification: apollo11MoonLanding, creation: apollo11MoonLanding)
97-
try formatter.create(path: FilePath("/file"), mode: EXT4.Inode.Mode(.S_IFREG, 0o755), ts: ts, buf: nil)
98-
}
99-
100-
@Test func decodeNegativeTimestamp() throws {
101-
let reader = try EXT4.EXT4Reader(blockDevice: fsPath)
102-
let (_, inode) = try reader.stat(FilePath("/file"))
103-
let mtime = Date(fsTimestamp: UInt64(inode.mtime) | (UInt64(inode.mtimeExtra) << 32))
104-
let atime = Date(fsTimestamp: UInt64(inode.atime) | (UInt64(inode.atimeExtra) << 32))
105-
let crtime = Date(fsTimestamp: UInt64(inode.crtime) | (UInt64(inode.crtimeExtra) << 32))
106-
#expect(mtime == apollo11MoonLanding)
107-
#expect(atime == apollo11MoonLanding)
108-
#expect(crtime == apollo11MoonLanding)
109-
}
110-
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the Containerization project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import Foundation
18+
import SystemPackage
19+
import Testing
20+
21+
@testable import ContainerizationEXT4
22+
23+
struct Ext4FormatLinkTests {
24+
@Test func hardlinkLinksCount() throws {
25+
func makeFile(unlink: Bool) throws -> FilePath {
26+
let path = FilePath(
27+
FileManager.default.temporaryDirectory
28+
.appendingPathComponent(UUID().uuidString, isDirectory: false))
29+
let fmt = try EXT4.Formatter(path, minDiskSize: 32.kib())
30+
try fmt.create(path: "/original", mode: EXT4.Inode.Mode(.S_IFREG, 0o755), buf: nil)
31+
try fmt.link(link: "/hardlink", target: "/original")
32+
if unlink {
33+
try fmt.unlink(path: "/hardlink")
34+
}
35+
try fmt.close()
36+
return path
37+
}
38+
39+
let afterLink = try makeFile(unlink: false)
40+
#expect(try EXT4.EXT4Reader(blockDevice: afterLink).stat("/original").inode.linksCount == 2)
41+
42+
let afterUnlink = try makeFile(unlink: true)
43+
#expect(try EXT4.EXT4Reader(blockDevice: afterUnlink).stat("/original").inode.linksCount == 1)
44+
}
45+
46+
@Test func unlinkFirstInodeFreesInode() throws {
47+
let emptyPath = FilePath(FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: false))
48+
defer { try? FileManager.default.removeItem(at: emptyPath.url) }
49+
try EXT4.Formatter(emptyPath, minDiskSize: 32.kib()).close()
50+
51+
let path = FilePath(FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: false))
52+
defer { try? FileManager.default.removeItem(at: path.url) }
53+
let fmt = try EXT4.Formatter(path, minDiskSize: 32.kib())
54+
try fmt.create(path: FilePath("/file"), mode: EXT4.Inode.Mode(.S_IFREG, 0o755), buf: nil)
55+
try fmt.unlink(path: FilePath("/file"))
56+
try fmt.close()
57+
58+
#expect(try EXT4.EXT4Reader(blockDevice: path).superBlock.freeInodesCount == EXT4.EXT4Reader(blockDevice: emptyPath).superBlock.freeInodesCount)
59+
}
60+
}

Tests/ContainerizationEXT4Tests/TestEXT4Format.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,36 @@ struct Ext4FormatTests: ~Copyable {
220220
}
221221
}
222222

223+
@Suite(.serialized)
224+
struct NegativeTimestampRoundtripTests {
225+
private let fsPath = FilePath(
226+
FileManager.default.temporaryDirectory
227+
.appendingPathComponent("ext4-pre1970-roundtrip.img", isDirectory: false))
228+
private let apollo11MoonLanding: Date = {
229+
let f = ISO8601DateFormatter()
230+
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
231+
return f.date(from: "1969-07-20T20:17:39.9Z")!
232+
}()
233+
234+
@Test func encodeNegativeTimestamp() throws {
235+
let formatter = try EXT4.Formatter(fsPath, minDiskSize: 32.kib())
236+
defer { try? formatter.close() }
237+
let ts = FileTimestamps(access: apollo11MoonLanding, modification: apollo11MoonLanding, creation: apollo11MoonLanding)
238+
try formatter.create(path: FilePath("/file"), mode: EXT4.Inode.Mode(.S_IFREG, 0o755), ts: ts, buf: nil)
239+
}
240+
241+
@Test func decodeNegativeTimestamp() throws {
242+
let reader = try EXT4.EXT4Reader(blockDevice: fsPath)
243+
let (_, inode) = try reader.stat(FilePath("/file"))
244+
let mtime = Date(fsTimestamp: UInt64(inode.mtime) | (UInt64(inode.mtimeExtra) << 32))
245+
let atime = Date(fsTimestamp: UInt64(inode.atime) | (UInt64(inode.atimeExtra) << 32))
246+
let crtime = Date(fsTimestamp: UInt64(inode.crtime) | (UInt64(inode.crtimeExtra) << 32))
247+
#expect(mtime == apollo11MoonLanding)
248+
#expect(atime == apollo11MoonLanding)
249+
#expect(crtime == apollo11MoonLanding)
250+
}
251+
}
252+
223253
struct Ext4FormatEmptyRangeTests {
224254
@Test func closeEmptyFilesystem() throws {
225255
let fsPath = FilePath(

0 commit comments

Comments
 (0)