mirror of
https://github.com/bitsycore/Allofit.git
synced 2026-10-05 12:27:26 +00:00
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.
248 lines
9.8 KiB
Swift
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)
|
|
}
|
|
}
|