Files
Allofit/tests/IndexStateTests.swift
Bitsy 876122ff98 Audit fixes: security hardening, index correctness, performance, UX
Security
- Elevated copies: root only reads the original; the copy is written by
  the user (sudo -u tee), so root never chowns / chmods a user-controlled
  path. Staging folder forced to 0700.
- Service logs moved from fixed /tmp names to /Library/Logs/Allofit
  (root-owned) and ~/Library/Logs/Allofit; Diagnostics reveals instead of
  opening the log.
- Index files are owner-only (0600; the root daemon's belongs to the
  installing user), set on the temp file before an atomic rename.
- Root install passes the plist inline (base64, plutil -lint) instead of a
  user-writable temp file; the binary comes from Bundle.main.
- Cache loader caps and checks the declared payload size; each save uses
  its own temp file.
- Release action pinned to a commit; non-system LC_RPATHs stripped.

Correctness
- Move to Trash removes the files from the index (the watcher ignores the
  app's own operations) and registers Undo (Put Back); trashed folders are
  matched with the original URLs; failures are shown.
- The saved event id stays below pending subtree walks (GUI and service).
- Roots / exclusions changes restart the watcher from the snapshot's id.
- Case-only renames no longer leave a ghost entry.
- Service mode is saved only after a successful install; a saved but
  missing service falls back to the in-process indexer.

Performance
- New folders are merged without the O(n) removal pass.
- Size / date sorts use a compact key array (539 -> 47 ms for 630k).
- Selection, preview and actions use the selected records directly.
- Service saves at most every 15 s; reader reloads pause while hidden and
  are deferred instead of dropped; window close saves only when dirty.

Usability
- Results appear during the first index; empty-list explanations.
- Down arrow moves to the results, Up on the first row back; history on
  Up / Option-Up / Option-Down.
- Search syntax popover and Help menu; shortcuts shown in the context menu;
  confirmations for Clear Cache and Uninstall; privacy usage strings;
  Group Containers excluded by default; wording, VoiceOver labels, plural.
2026-10-02 14:44:49 +02:00

248 lines
9.8 KiB
Swift

import Foundation
import Testing
@testable import Allofit
// Tests for the index bookkeeping rules (IndexState), stable ids, the
// top-N result selection and the cache file round trip.
@Suite("IndexState")
struct IndexStateTests {
// shorthand for a file record at an absolute path
static func file(_ inPath: String, size inSize: Int64 = 0) -> FileRecord {
let vUrl = URL(fileURLWithPath: inPath)
return FileRecord(
name: vUrl.lastPathComponent,
parentPath: vUrl.deletingLastPathComponent().path,
size: inSize,
dateCreated: Date(timeIntervalSince1970: 1),
dateModified: Date(timeIntervalSince1970: 1),
isDirectory: false
)
}
// shorthand for a directory record at an absolute path
static func dir(_ inPath: String) -> FileRecord {
let vUrl = URL(fileURLWithPath: inPath)
return FileRecord(
name: vUrl.lastPathComponent,
parentPath: vUrl.deletingLastPathComponent().path,
size: 0,
dateCreated: Date(timeIntervalSince1970: 1),
dateModified: Date(timeIntervalSince1970: 1),
isDirectory: true
)
}
// sorted full paths of the state, for readable comparisons
static func paths(_ inState: IndexState) -> [String] {
return inState.records.map { $0.fullPath }.sorted()
}
// a small tree rooted at /r
static func sampleState() -> IndexState {
return IndexState(inRecords: [
dir("/r"),
dir("/r/a"),
file("/r/a/1.txt"),
dir("/r/a/b"),
file("/r/a/b/2.txt"),
file("/r/c.txt")
])
}
@Test func idIsStableAndMatchesPathHash() {
let vRecord = Self.file("/Users/me/x.txt")
#expect(vRecord.id == FileRecord.pathHash("/Users/me/x.txt"))
#expect(FileRecord.pathHash(inParent: "/", inName: "etc") == FileRecord.pathHash("/etc"))
}
@Test func duplicatesAreDropped() {
let vState = IndexState(inRecords: [Self.file("/r/x"), Self.file("/r/x")])
#expect(vState.count == 1)
}
@Test func removingADirectoryRemovesItsSubtree() {
var vState = Self.sampleState()
let vRemoved = vState.removeSubtrees(inPaths: ["/r/a"])
#expect(vRemoved == 4)
#expect(Self.paths(vState) == ["/r", "/r/c.txt"])
// lookup stays consistent after swap-removal
for (vPos, vRecord) in vState.records.enumerated() {
#expect(vState.positions[vRecord.id] == vPos)
}
}
@Test func siblingWithSharedPrefixIsKept() {
var vState = IndexState(inRecords: [Self.dir("/r"), Self.dir("/r/a"), Self.file("/r/a/x"), Self.file("/r/ab")])
vState.removeSubtrees(inPaths: ["/r/a"])
#expect(Self.paths(vState) == ["/r", "/r/ab"])
}
@Test func applyRequiresAnIndexedParentAndRescansNewFolders() {
var vState = Self.sampleState()
var vChanges = ResolvedChanges()
vChanges.upserts = [
Self.file("/r/a/new.txt"), // parent indexed: accepted
Self.file("/r/hidden/x.txt"), // parent not indexed: dropped
Self.dir("/r/moved"), // new folder: accepted + rescanned
Self.file("/r/moved/inside.txt") // its parent arrives in the same batch
]
let vResult = vState.apply(inChanges: vChanges, inRoots: ["/r"])
#expect(vResult.changed)
#expect(vResult.newFolders == ["/r/moved"])
#expect(vResult.rescans.isEmpty)
#expect(vState.contains(inPath: "/r/a/new.txt"))
#expect(!vState.contains(inPath: "/r/hidden/x.txt"))
#expect(vState.contains(inPath: "/r/moved/inside.txt"))
}
@Test func renameRemovesOldSubtree() {
var vState = Self.sampleState()
var vChanges = ResolvedChanges()
vChanges.removals = ["/r/a"]
vChanges.upserts = [Self.dir("/r/renamed")]
let vResult = vState.apply(inChanges: vChanges, inRoots: ["/r"])
#expect(!vState.contains(inPath: "/r/a/b/2.txt"))
#expect(vResult.newFolders == ["/r/renamed"])
}
@Test func newFolderInsideKernelRescanIsWalkedOnce() {
var vState = Self.sampleState()
var vChanges = ResolvedChanges()
vChanges.upserts = [Self.dir("/r/a/fresh")]
vChanges.rescans = ["/r/a"]
let vResult = vState.apply(inChanges: vChanges, inRoots: ["/r"])
#expect(vResult.rescans == ["/r/a"])
#expect(vResult.newFolders.isEmpty)
// merging a new folder's walk only upserts (no removal pass)
vState.mergeNewFolders(inRecords: [Self.dir("/r/a/fresh"), Self.file("/r/a/fresh/x.txt")])
#expect(vState.contains(inPath: "/r/a/fresh/x.txt"))
#expect(vState.contains(inPath: "/r/a/b/2.txt"))
}
@Test func fullSortMatchesComparatorForNumericKeys() {
let vRecords = (0..<3000).map { Self.file("/r/f\($0)", size: Int64(($0 * 7919) % 97)) }
let vPositions = (0..<Int32(vRecords.count)).map { $0 }
for vSort in [FileSortDescriptor.sizeAscending, .sizeDescending] {
let vFull = ResultSorter.topPositions(inRecords: vRecords, inPositions: vPositions, inLimit: vRecords.count, inDescriptor: vSort)
let vHeap = ResultSorter.topPositions(inRecords: vRecords, inPositions: vPositions, inLimit: 100, inDescriptor: vSort)
#expect(Array(vFull.prefix(100)) == vHeap, "sort \(vSort.rawValue)")
}
}
@Test func caseOnlyRenameDropsTheOldSpelling() throws {
let vDir = FileManager.default.temporaryDirectory.appendingPathComponent("allofit-case-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: vDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: vDir) }
let vOld = vDir.appendingPathComponent("Report.txt")
let vNew = vDir.appendingPathComponent("report.txt")
try Data("x".utf8).write(to: vOld)
try FileManager.default.moveItem(at: vOld, to: vNew)
// the old spelling still opens on a case-insensitive volume, but must
// not be re-added as a separate entry
#expect(FileIndexer.makeRecord(inURL: vOld, inSkipHidden: true) == nil)
#expect(FileIndexer.makeRecord(inURL: vNew, inSkipHidden: true)?.name == "report.txt")
}
@Test func unchangedUpsertIsNotAChange() {
var vState = Self.sampleState()
var vChanges = ResolvedChanges()
vChanges.upserts = [Self.file("/r/c.txt")]
#expect(!vState.apply(inChanges: vChanges, inRoots: ["/r"]).changed)
}
@Test func pruneDropsOrphansExclusionsAndOldRoots() {
var vState = Self.sampleState()
// orphan: parent /r/hidden is not indexed
vState.upsert(Self.file("/r/hidden/x.txt"))
// leftover of a root that is no longer configured
vState.upsert(Self.dir("/old"))
vState.upsert(Self.file("/old/y.txt"))
let vRemoved = vState.prune(inRoots: ["/r"], inExclusions: ExclusionMatcher(inExclusions: ["/r/a/b"]))
#expect(vRemoved == 5)
#expect(Self.paths(vState) == ["/r", "/r/a", "/r/a/1.txt", "/r/c.txt"])
}
@Test func pruneDropsHiddenEntries() {
var vState = Self.sampleState()
vState.upsert(Self.dir("/r/Library"))
vState.upsert(Self.file("/r/Library/deep.txt"))
vState.upsert(Self.dir("/r/a/.git"))
vState.upsert(Self.file("/r/a/.git/HEAD"))
let vRemoved = vState.prune(
inRoots: ["/r"],
inExclusions: ExclusionMatcher(inExclusions: []),
inIsHidden: { $0 == "/r/Library" }
)
#expect(vRemoved == 4)
#expect(Self.paths(vState) == Self.paths(Self.sampleState()))
}
@Test func composedAndDecomposedNamesShareAnId() {
let vComposed = Self.file("/r/Envoy\u{00E9}s.msf")
let vDecomposed = Self.file("/r/Envoye\u{0301}s.msf")
#expect(vComposed.id == vDecomposed.id)
#expect(IndexState(inRecords: [vComposed, vDecomposed]).count == 1)
#expect(FileRecord.pathHash("/r/Envoye\u{0301}s.msf") == vComposed.id)
}
@Test func replaceSubtreesSwapsContent() {
var vState = Self.sampleState()
vState.replaceSubtrees(inRoots: ["/r/a"], inRecords: [Self.dir("/r/a"), Self.file("/r/a/fresh.txt")])
#expect(Self.paths(vState) == ["/r", "/r/a", "/r/a/fresh.txt", "/r/c.txt"])
}
@Test func rootsAreCanonicalized() {
// /tmp is a symlink: FSEvents and the walker report /private/tmp
#expect(VolumeManager.canonicalPath(inPath: "/tmp") == "/private/tmp")
#expect(VolumeManager.canonicalPath(inPath: "/Users/") == "/Users")
#expect(VolumeManager.canonicalPath(inPath: "/no/such/root/") == "/no/such/root")
}
@Test func recordStoreChunksAndCopiesOnWrite() {
var vStore = RecordStore()
let vTotal = RecordStore.kChunkSize * 2 + 10
for vI in 0..<vTotal { vStore.append(Self.file("/r/f\(vI)")) }
#expect(vStore.count == vTotal)
#expect(vStore[RecordStore.kChunkSize].name == "f\(RecordStore.kChunkSize)")
// a snapshot keeps its values when the original changes
let vSnapshot = vStore
vStore[5] = Self.file("/r/changed")
#expect(vSnapshot[5].name == "f5")
#expect(vStore[5].name == "changed")
for _ in 0..<11 { vStore.removeLast() }
#expect(vStore.count == RecordStore.kChunkSize * 2 - 1)
#expect(vStore.last?.name == "f\(RecordStore.kChunkSize * 2 - 2)")
// result lists read through the snapshot
let vList = ResultList(inStore: vSnapshot, inPositions: [3, 1])
#expect(vList.map(\.name) == ["f3", "f1"])
#expect(vList.removing(inIds: [vSnapshot[3].id]).map(\.name) == ["f1"])
}
@Test func minimalRootsDropsNestedPaths() {
let vRoots = SubtreeMatcher.minimalRoots(inPaths: ["/a/b", "/a", "/a b/c", "/a", "/c"])
#expect(vRoots.sorted() == ["/a", "/a b/c", "/c"])
}
@Test func topSelectionMatchesFullSort() {
let vRecords = (0..<5000).map { Self.file("/r/f\($0 % 977)-\($0).txt", size: Int64(($0 * 7919) % 1013)) }
let vPositions = (0..<Int32(vRecords.count)).map { $0 }
for vSort in FileSortDescriptor.allCases {
let vTop = ResultSorter.top(inRecords: vRecords, inPositions: vPositions, inLimit: 100, inDescriptor: vSort)
let vFull = ResultSorter.top(inRecords: vRecords, inPositions: vPositions, inLimit: vRecords.count, inDescriptor: vSort)
#expect(vTop.map(\.id) == Array(vFull.prefix(100)).map(\.id), "sort \(vSort.rawValue)")
}
}
@Test func cacheRoundTrip() throws {
let vUrl = FileManager.default.temporaryDirectory
.appendingPathComponent("allofit-test-\(UUID().uuidString).bin")
defer { try? FileManager.default.removeItem(at: vUrl) }
let vRecords = Array(Self.sampleState().records)
IndexStore.save(inRecords: vRecords, inLastEventId: 42, to: vUrl)
let vLoaded = try #require(IndexStore.load(from: vUrl))
#expect(vLoaded.lastEventId == 42)
#expect(vLoaded.records == vRecords)
}
}