Files
Allofit/sources/core/ResultSorter.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

131 lines
5.1 KiB
Swift

import Foundation
// ResultSorter orders the matching positions of a search. The full order of
// a large result takes a while (~0.2 s for 600k entries), so the first rows
// can be computed on their own: a bounded max-heap of the best N candidates
// is O(m log N), about one comparison per match once the heap is warm, which
// lets the top of the list appear before the complete sort finishes.
enum ResultSorter {
// returns the first inLimit records of inPositions, ordered by inDescriptor
static func top<C: RandomAccessCollection>(inRecords: C,
inPositions: [Int32],
inLimit: Int,
inDescriptor: FileSortDescriptor) -> [FileRecord]
where C.Index == Int, C.Element == FileRecord {
return topPositions(inRecords: inRecords, inPositions: inPositions, inLimit: inLimit, inDescriptor: inDescriptor)
.map { inRecords[Int($0)] }
}
// same as top, returning positions into inRecords instead of copies
static func topPositions<C: RandomAccessCollection>(inRecords: C,
inPositions: [Int32],
inLimit: Int,
inDescriptor: FileSortDescriptor) -> [Int32]
where C.Index == Int, C.Element == FileRecord {
let vLess = comparator(for: inDescriptor)
// strict weak order on positions, ties broken by id for stability
let vBefore: (Int32, Int32) -> Bool = { vA, vB in
let vRa = inRecords[Int(vA)]
let vRb = inRecords[Int(vB)]
if vLess(vRa, vRb) { return true }
if vLess(vRb, vRa) { return false }
return vRa.id < vRb.id
}
if inPositions.count <= inLimit {
switch inDescriptor {
case .nameAscending, .nameDescending, .pathAscending, .pathDescending:
// string keys: comparing in place is as fast or faster
// (moving tuples of strings during the sort costs more)
return inPositions.sorted(by: vBefore)
default:
// numeric keys: ~10x faster sorted as a compact key array
return sortedByKey(inRecords: inRecords, inPositions: inPositions, inDescriptor: inDescriptor)
}
}
// max-heap (worst candidate on top) of the best inLimit seen so far
var vHeap = Array(inPositions.prefix(inLimit))
// restores the heap property downward from inStart
func siftDown(_ inStart: Int) {
var vI = inStart
while true {
let vLeft = 2 * vI + 1
if vLeft >= vHeap.count { return }
var vWorst = vLeft
let vRight = vLeft + 1
if vRight < vHeap.count && vBefore(vHeap[vLeft], vHeap[vRight]) { vWorst = vRight }
if !vBefore(vHeap[vI], vHeap[vWorst]) { return }
vHeap.swapAt(vI, vWorst)
vI = vWorst
}
}
for vI in stride(from: vHeap.count / 2 - 1, through: 0, by: -1) {
siftDown(vI)
}
for vPos in inPositions[inLimit...] where vBefore(vPos, vHeap[0]) {
vHeap[0] = vPos
siftDown(0)
}
return vHeap.sorted(by: vBefore)
}
// complete sort by size or date. Each record's key is read once into a
// compact array, which is then sorted: comparing through the records
// copies two whole FileRecords (with their strings' retain / release)
// per comparison - 539 ms vs 47 ms for 630k entries by date. Ties are
// broken by id, matching the top-N path.
private static func sortedByKey<C: RandomAccessCollection>(inRecords: C,
inPositions: [Int32],
inDescriptor: FileSortDescriptor) -> [Int32]
where C.Index == Int, C.Element == FileRecord {
let vDescending = inDescriptor == .sizeDescending
|| inDescriptor == .createdDescending
|| inDescriptor == .modifiedDescending
var vKeys = inPositions.map { vPos -> (Double, UInt64, Int32) in
let vRecord = inRecords[Int(vPos)]
let vKey: Double
switch inDescriptor {
case .sizeAscending, .sizeDescending: vKey = Double(vRecord.size)
case .createdAscending, .createdDescending: vKey = vRecord.dateCreated.timeIntervalSinceReferenceDate
default: vKey = vRecord.dateModified.timeIntervalSinceReferenceDate
}
return (vKey, vRecord.id, vPos)
}
vKeys.sort { vA, vB in
if vA.0 != vB.0 { return vDescending ? vA.0 > vB.0 : vA.0 < vB.0 }
return vA.1 < vB.1
}
return vKeys.map(\.2)
}
// primary ordering for each sort mode. Names compare on the folded form
// (plain code-point order: fast, case-insensitive), path sorts by folder
// then name.
private static func comparator(for inDescriptor: FileSortDescriptor) -> (FileRecord, FileRecord) -> Bool {
switch inDescriptor {
case .nameAscending:
return { $0.nameLower < $1.nameLower }
case .nameDescending:
return { $0.nameLower > $1.nameLower }
case .sizeAscending:
return { $0.size < $1.size }
case .sizeDescending:
return { $0.size > $1.size }
case .createdAscending:
return { $0.dateCreated < $1.dateCreated }
case .createdDescending:
return { $0.dateCreated > $1.dateCreated }
case .modifiedAscending:
return { $0.dateModified < $1.dateModified }
case .modifiedDescending:
return { $0.dateModified > $1.dateModified }
case .pathAscending:
return { $0.parentPath != $1.parentPath ? $0.parentPath < $1.parentPath : $0.nameLower < $1.nameLower }
case .pathDescending:
return { $0.parentPath != $1.parentPath ? $0.parentPath > $1.parentPath : $0.nameLower > $1.nameLower }
}
}
}