Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions Sources/tart/Commands/Prune.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,13 @@ struct Prune: AsyncParsableCommand {
}

static func pruneOlderThan(prunableStorages: [PrunableStorage], olderThanDate: Date) throws {
let prunables: [Prunable] = try prunableStorages.flatMap { try $0.prunables() }

try prunables.filter { try $0.accessDate() <= olderThanDate }.forEach { try $0.delete() }
while let prunable = try prunableStorages
.flatMap({ try $0.prunables() })
.first(where: { try $0.accessDate() <= olderThanDate }) {
// Deletion may remove derived prunables (for example shared content),
// so never continue from a stale snapshot.
try prunable.delete()
}
}

static func pruneSpaceBudget(prunableStorages: [PrunableStorage], spaceBudgetBytes: UInt64) throws {
Expand Down
34 changes: 31 additions & 3 deletions Sources/tart/VMStorageOCI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,34 @@ class VMStorageOCI: PrunableStorage {
}
}

/// Remove tag links that point at a cached image which has just been deleted.
/// Keep this targeted so deleting one prune candidate cannot collect other
/// unreferenced cached images that a caller may still need to consider.
fileprivate func removeTagSymlinks(pointingTo targetURL: URL) throws {
guard let enumerator = FileManager.default.enumerator(
at: baseURL,
includingPropertiesForKeys: [.isSymbolicLinkKey]
) else {
return
}

let standardizedTargetURL = targetURL.standardizedFileURL
for case let foundURL as URL in enumerator {
guard try foundURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink == true else {
continue
}

let destination = try FileManager.default.destinationOfSymbolicLink(atPath: foundURL.path)
let destinationURL = URL(
fileURLWithPath: destination,
relativeTo: foundURL.deletingLastPathComponent()
).standardizedFileURL
if destinationURL == standardizedTargetURL {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compare symlink targets without directory URL hints

When pruning a digest record that has a tag, vmDir.delete() removes the target directory before this comparison runs. Constructing destinationURL after that removal produces a file URL without a directory marker, while targetURL came from the directory enumerator and retains its trailing-slash directory hint; Foundation treats those URLs as unequal, so the branch is skipped and the broken tag symlink remains. The new tests do not catch this because fileExists(atPath:) follows a dangling symlink and returns false even when the link itself still exists; compare normalized path strings or otherwise inspect the symlink itself.

Useful? React with 👍 / 👎.

try FileManager.default.removeItem(at: foundURL)
}
}
}

func list() throws -> [(String, VMDirectory, Bool)] {
var result: [(String, VMDirectory, Bool)] = Array()

Expand Down Expand Up @@ -830,9 +858,9 @@ private struct CachedImagePrunable: Prunable {

func delete() throws {
try vmDir.delete()
// Deleting a record can make attributed content unreferenced. Run GC now
// so one prune invocation reclaims those bytes.
try VMStorageOCI().gcContent()
let storage = try VMStorageOCI()
try storage.removeTagSymlinks(pointingTo: vmDir.url)
try storage.gcContent()
}

func accessDate() throws -> Date {
Expand Down
80 changes: 78 additions & 2 deletions Tests/TartTests/VMStorageOCITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -348,8 +348,14 @@ final class VMStorageOCITests: XCTestCase {
func testTagReplacementWaitsForPruneLock() throws {
try withTemporaryTartHome {
let storage = try VMStorageOCI()
let firstManifest = try stackedManifest(baseContentDigest: "sha256:first")
let secondManifest = try stackedManifest(baseContentDigest: "sha256:second")
let firstManifest = try stackedManifest(
baseContentDigest: "sha256:" + String(repeating: "1", count: 64),
overlayContentDigest: "sha256:" + String(repeating: "2", count: 64)
)
let secondManifest = try stackedManifest(
baseContentDigest: "sha256:" + String(repeating: "3", count: 64),
overlayContentDigest: "sha256:" + String(repeating: "4", count: 64)
)
let firstName = try digestName(for: firstManifest)
let secondName = try digestName(for: secondManifest)
_ = try createRecord(for: firstManifest, in: storage)
Expand Down Expand Up @@ -730,6 +736,76 @@ final class VMStorageOCITests: XCTestCase {
}
}

func testPruningCachedImageRemovesOnlyItsTagSymlink() throws {
try withTemporaryTartHome {
let storage = try VMStorageOCI()
// Content digests are validated when manifests are scanned for pruning.
// Use syntactically valid digests here; the test only exercises record
// and tag-symlink cleanup, not content installation.
let deletedManifest = try stackedManifest(
baseContentDigest: "sha256:" + String(repeating: "a", count: 64),
overlayContentDigest: "sha256:" + String(repeating: "e", count: 64)
)
let retainedManifest = try stackedManifest(
baseContentDigest: "sha256:" + String(repeating: "b", count: 64),
overlayContentDigest: "sha256:" + String(repeating: "f", count: 64)
)
let deletedName = try digestName(for: deletedManifest)
let deletedRecord = try createRecord(for: deletedManifest, in: storage)
let tagName = RemoteName(host: "example.com", namespace: "org/image", reference: Reference(tag: "deleted"))
let tagURL = storage.baseURL.appendingRemoteName(tagName)
try storage.link(from: tagName, to: deletedName)
let retainedRecord = try createRecord(for: retainedManifest, in: storage)
let retainedTagName = RemoteName(host: "example.com", namespace: "org/image", reference: Reference(tag: "retained"))
let retainedTagURL = storage.baseURL.appendingRemoteName(retainedTagName)
try storage.link(from: retainedTagName, to: try digestName(for: retainedManifest))
try deletedRecord.url.updateAccessDate(Date(timeIntervalSince1970: 1))

try Prune.pruneOlderThan(
prunableStorages: [storage],
olderThanDate: Date(timeIntervalSince1970: 2)
)

XCTAssertFalse(FileManager.default.fileExists(atPath: deletedRecord.url.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: tagURL.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: retainedRecord.url.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: retainedTagURL.path))
}
}

func testSpaceBudgetPruningCachedImageRemovesOnlyItsTagSymlink() throws {
try withTemporaryTartHome {
let storage = try VMStorageOCI()
let deletedManifest = try stackedManifest(
baseContentDigest: "sha256:" + String(repeating: "c", count: 64),
overlayContentDigest: "sha256:" + String(repeating: "0", count: 64)
)
let retainedManifest = try stackedManifest(
baseContentDigest: "sha256:" + String(repeating: "d", count: 64),
overlayContentDigest: "sha256:" + String(repeating: "1", count: 64)
)
let deletedName = try digestName(for: deletedManifest)
let deletedRecord = try createRecord(for: deletedManifest, in: storage)
let tagName = RemoteName(host: "example.com", namespace: "org/image", reference: Reference(tag: "deleted"))
let tagURL = storage.baseURL.appendingRemoteName(tagName)
try storage.link(from: tagName, to: deletedName)
let retainedRecord = try createRecord(for: retainedManifest, in: storage)
let retainedTagName = RemoteName(host: "example.com", namespace: "org/image", reference: Reference(tag: "retained"))
let retainedTagURL = storage.baseURL.appendingRemoteName(retainedTagName)
try storage.link(from: retainedTagName, to: try digestName(for: retainedManifest))
try deletedRecord.url.updateAccessDate(Date(timeIntervalSince1970: 1))
try retainedRecord.url.updateAccessDate(Date(timeIntervalSince1970: 2))
let retainedSize = UInt64(try retainedRecord.allocatedSizeBytes())

try Prune.pruneSpaceBudget(prunableStorages: [storage], spaceBudgetBytes: retainedSize)

XCTAssertFalse(FileManager.default.fileExists(atPath: deletedRecord.url.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: tagURL.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: retainedRecord.url.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: retainedTagURL.path))
}
}

#if canImport(DiskImageKit)
@available(macOS 27.0, *)
func testPopulateStackedPushedImageCachesImmutableTopOverlay() throws {
Expand Down