Skip to content

Commit a52b384

Browse files
authored
fix: make descriptor path recovery sanitizer-safe (#1451)
1 parent 41af4e7 commit a52b384

2 files changed

Lines changed: 159 additions & 36 deletions

File tree

Pine/TerminationSaveCoordinator.swift

Lines changed: 74 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1113,43 +1113,75 @@ nonisolated enum TerminationSaveCoordinator {
11131113
leaf: String,
11141114
fallback: URL
11151115
) -> URL {
1116-
currentArtifactURL(
1116+
resolvedArtifactURL(
11171117
parentDescriptor: parentDescriptor,
1118-
leaf: leaf
1118+
leaf: leaf,
1119+
fallback: fallback
11191120
) ?? fallback
11201121
}
11211122

1123+
private static func resolvedArtifactURL(
1124+
parentDescriptor: Int32,
1125+
leaf: String,
1126+
fallback: URL?
1127+
) -> URL? {
1128+
if let fallback,
1129+
fallback.lastPathComponent == leaf,
1130+
directoryPathMatches(
1131+
parentDescriptor: parentDescriptor,
1132+
url: fallback.deletingLastPathComponent()
1133+
) {
1134+
return fallback
1135+
}
1136+
return currentArtifactURL(
1137+
parentDescriptor: parentDescriptor,
1138+
leaf: leaf
1139+
)
1140+
}
1141+
11221142
private static func currentArtifactURL(
11231143
parentDescriptor: Int32,
11241144
leaf: String
11251145
) -> URL? {
1126-
var path = [CChar](
1127-
repeating: 0,
1128-
count: 4 * Int(MAXPATHLEN)
1129-
)
1130-
guard let handle = Darwin.dlopen(nil, RTLD_LAZY | RTLD_LOCAL) else {
1131-
return nil
1146+
var descriptorInfo = vnode_fdinfowithpath()
1147+
let expectedSize = MemoryLayout<vnode_fdinfowithpath>.size
1148+
guard expectedSize <= Int(Int32.max) else { return nil }
1149+
let result = withUnsafeMutablePointer(to: &descriptorInfo) { pointer in
1150+
Darwin.proc_pidfdinfo(
1151+
Darwin.getpid(),
1152+
parentDescriptor,
1153+
PROC_PIDFDVNODEPATHINFO,
1154+
pointer,
1155+
Int32(expectedSize)
1156+
)
11321157
}
1133-
defer { Darwin.dlclose(handle) }
1134-
guard let symbol = Darwin.dlsym(handle, "fcntl") else {
1158+
guard result == Int32(expectedSize) else { return nil }
1159+
let currentPath = withUnsafeBytes(
1160+
of: &descriptorInfo.pvip.vip_path
1161+
) { buffer -> String? in
1162+
let path = buffer.bindMemory(to: CChar.self)
1163+
let terminator = path.firstIndex(of: 0) ?? path.endIndex
1164+
guard terminator != path.startIndex else { return nil }
1165+
let pathBytes = path[..<terminator].map(UInt8.init(bitPattern:))
1166+
return String(bytes: pathBytes, encoding: .utf8)
1167+
}
1168+
guard let currentPath else {
11351169
return nil
11361170
}
1137-
typealias FcntlPath = @convention(c) (
1138-
Int32,
1139-
Int32,
1140-
UnsafeMutableRawPointer?
1141-
) -> Int32
1142-
let fcntlPath = unsafeBitCast(symbol, to: FcntlPath.self)
1143-
let result = path.withUnsafeMutableBytes { buffer in
1144-
fcntlPath(parentDescriptor, F_GETPATH, buffer.baseAddress)
1145-
}
1146-
guard result == 0 else { return nil }
1147-
let terminator = path.firstIndex(of: 0) ?? path.endIndex
1148-
let pathBytes = path[..<terminator].map(UInt8.init(bitPattern:))
1149-
guard let currentPath = String(bytes: pathBytes, encoding: .utf8) else {
1171+
let currentParentURL = URL(
1172+
fileURLWithPath: currentPath,
1173+
isDirectory: true
1174+
)
1175+
var descriptorStatus = stat()
1176+
var pathStatus = stat()
1177+
guard Darwin.fstat(parentDescriptor, &descriptorStatus) == 0,
1178+
(descriptorStatus.st_mode & S_IFMT) == S_IFDIR,
1179+
Darwin.lstat(currentParentURL.path, &pathStatus) == 0,
1180+
(pathStatus.st_mode & S_IFMT) == S_IFDIR,
1181+
sameObject(descriptorStatus, pathStatus) else {
11501182
return nil
11511183
}
1152-
return URL(fileURLWithPath: currentPath)
1184+
return currentParentURL
11531185
.appendingPathComponent(leaf)
11541186
}
11551187

@@ -2034,21 +2066,23 @@ nonisolated enum TerminationSaveCoordinator {
20342066
if errno == ENOENT { return .removed }
20352067
return .failed(
20362068
message: "Could not quarantine an artifact before cleanup",
2037-
retainedURL: currentArtifactURL(
2069+
retainedURL: resolvedArtifactURL(
20382070
parentDescriptor: parentDescriptor,
2039-
leaf: leaf
2040-
) ?? fallbackOriginalURL
2071+
leaf: leaf,
2072+
fallback: fallbackOriginalURL
2073+
)
20412074
)
20422075
}
20432076
do {
20442077
try synchronizeDirectory(parentDescriptor)
20452078
} catch {
20462079
return .failed(
20472080
message: "Could not make artifact quarantine durable",
2048-
retainedURL: currentArtifactURL(
2081+
retainedURL: resolvedArtifactURL(
20492082
parentDescriptor: parentDescriptor,
2050-
leaf: cleanupLeaf
2051-
) ?? fallbackCleanupURL
2083+
leaf: cleanupLeaf,
2084+
fallback: fallbackCleanupURL
2085+
)
20522086
)
20532087
}
20542088
guard regularFileMatches(
@@ -2067,19 +2101,23 @@ nonisolated enum TerminationSaveCoordinator {
20672101
}
20682102
return .failed(
20692103
message: "An artifact changed identity during cleanup",
2070-
retainedURL: currentArtifactURL(
2104+
retainedURL: resolvedArtifactURL(
20712105
parentDescriptor: parentDescriptor,
2072-
leaf: restored ? leaf : cleanupLeaf
2073-
) ?? (restored ? fallbackOriginalURL : fallbackCleanupURL)
2106+
leaf: restored ? leaf : cleanupLeaf,
2107+
fallback: restored
2108+
? fallbackOriginalURL
2109+
: fallbackCleanupURL
2110+
)
20742111
)
20752112
}
20762113
guard Darwin.unlinkat(parentDescriptor, cleanupLeaf, 0) == 0 else {
20772114
return .failed(
20782115
message: "Could not unlink a quarantined artifact",
2079-
retainedURL: currentArtifactURL(
2116+
retainedURL: resolvedArtifactURL(
20802117
parentDescriptor: parentDescriptor,
2081-
leaf: cleanupLeaf
2082-
) ?? fallbackCleanupURL
2118+
leaf: cleanupLeaf,
2119+
fallback: fallbackCleanupURL
2120+
)
20832121
)
20842122
}
20852123
do {

PineTests/TerminationSaveCoordinatorSecurityTests.swift

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,43 @@ struct TerminationSaveCoordinatorSecurityTests {
268268
)
269269
}
270270

271+
@Test func failedCleanupReportsArtifactInRenamedDirectory() async throws {
272+
let container = try makeDirectory()
273+
defer { remove(container) }
274+
let directory = container.appendingPathComponent("project")
275+
let movedDirectory = container.appendingPathComponent("project-moved")
276+
try FileManager.default.createDirectory(
277+
at: directory,
278+
withIntermediateDirectories: false
279+
)
280+
let staged = try await stage(
281+
content: "private bytes retained after rename",
282+
destination: directory.appendingPathComponent("cleanup-moved.txt")
283+
)
284+
try FileManager.default.moveItem(at: directory, to: movedDirectory)
285+
try FileManager.default.createDirectory(
286+
at: directory,
287+
withIntermediateDirectories: false
288+
)
289+
#expect(Darwin.chmod(movedDirectory.path, mode_t(0o500)) == 0)
290+
let result = TerminationSaveCoordinator.cleanup([staged])
291+
#expect(Darwin.chmod(movedDirectory.path, mode_t(0o700)) == 0)
292+
293+
guard case .failed(_, let retainedArtifacts) = result else {
294+
Issue.record("Expected cleanup failure in the renamed directory")
295+
return
296+
}
297+
let expectedArtifact = movedDirectory.appendingPathComponent(
298+
staged.stagingURL.lastPathComponent
299+
)
300+
#expect(
301+
retainedArtifacts.map { $0.resolvingSymlinksInPath() }
302+
== [expectedArtifact.resolvingSymlinksInPath()]
303+
)
304+
#expect(FileManager.default.fileExists(atPath: expectedArtifact.path))
305+
#expect(TerminationSaveCoordinator.cleanup([staged]) == .cleaned)
306+
}
307+
271308
@Test func installedDestinationReportsRetainedRecoveryFailure() async throws {
272309
let directory = try makeDirectory()
273310
defer { remove(directory) }
@@ -658,6 +695,54 @@ struct TerminationSaveCoordinatorSecurityTests {
658695
)
659696
}
660697

698+
@Test func finalVerificationReportsDestinationAfterParentRename() async throws {
699+
let container = try makeDirectory()
700+
defer { remove(container) }
701+
let directory = container.appendingPathComponent("project")
702+
let movedDirectory = container.appendingPathComponent("project-moved")
703+
try FileManager.default.createDirectory(
704+
at: directory,
705+
withIntermediateDirectories: false
706+
)
707+
let destination = directory.appendingPathComponent("new-moved.txt")
708+
let staged = try await stage(
709+
content: "installed before parent rename",
710+
destination: destination
711+
)
712+
713+
let result = await TerminationSaveCoordinator.install(
714+
staged,
715+
until: .now() + 5,
716+
beforeFinalInstalledFence: {
717+
try? FileManager.default.moveItem(
718+
at: directory,
719+
to: movedDirectory
720+
)
721+
try? FileManager.default.createDirectory(
722+
at: directory,
723+
withIntermediateDirectories: false
724+
)
725+
}
726+
)
727+
728+
guard case .failed(_, let retainedArtifacts) = result else {
729+
Issue.record("A renamed parent must fail final verification")
730+
return
731+
}
732+
let movedDestination = movedDirectory.appendingPathComponent(
733+
destination.lastPathComponent
734+
)
735+
#expect(
736+
retainedArtifacts.map { $0.resolvingSymlinksInPath() }
737+
== [movedDestination.resolvingSymlinksInPath()]
738+
)
739+
#expect(
740+
try String(contentsOf: movedDestination, encoding: .utf8)
741+
== "installed before parent rename"
742+
)
743+
#expect(!FileManager.default.fileExists(atPath: destination.path))
744+
}
745+
661746
private func stage(
662747
content: String,
663748
destination: URL

0 commit comments

Comments
 (0)