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.
347 lines
13 KiB
Swift
347 lines
13 KiB
Swift
import Foundation
|
|
import CoreServices
|
|
|
|
// IndexStore persists the file index to disk using a compact LZ4-compressed
|
|
// binary format. It also stores the FSEvents event id at save time so the
|
|
// watcher can replay events that happened while the app was closed - the
|
|
// fast-resume mechanism analogous to how Everything reads the USN journal
|
|
// on launch.
|
|
//
|
|
// On-disk layout:
|
|
// [magic "AZLF" : u32][version : u32][uncompressedSize : u64]
|
|
// [LZ4-compressed payload]
|
|
// The compressed payload decompresses to:
|
|
// [lastEventId : u64][recordCount : u64]
|
|
// { for each record: id u64, size i64, created f64, modified f64,
|
|
// flags u8, name(len u32, utf8 bytes),
|
|
// parentPath(len u32, utf8 bytes) }
|
|
enum IndexStore {
|
|
|
|
// outer envelope magic: ASCII "AZLF" (compressed) - bumped from v1 "ALOF"
|
|
private static let kMagic: UInt32 = 0x415A_4C46
|
|
// format version - bump if the inner payload layout changes
|
|
private static let kVersion: UInt32 = 2
|
|
|
|
// the value loaded back from the cache
|
|
struct LoadResult {
|
|
// records previously persisted
|
|
let records: [FileRecord]
|
|
// last FSEvents event id known when the cache was written
|
|
let lastEventId: UInt64
|
|
}
|
|
|
|
// per-process cache location: the system-wide path when running as root
|
|
// daemon (env var set by ServiceInstaller), otherwise the per-user path.
|
|
static var cacheURL: URL {
|
|
let vUseSystem = ProcessInfo.processInfo.environment["ALLOFIT_SYSTEM_INDEX"] == "1"
|
|
return cacheURL(forSystem: vUseSystem)
|
|
}
|
|
|
|
// returns the cache URL for a specific scope. The GUI uses this to read
|
|
// the system-wide cache when the root daemon owns the index.
|
|
static func cacheURL(forSystem inSystem: Bool) -> URL {
|
|
let vBase: URL
|
|
if inSystem {
|
|
vBase = URL(fileURLWithPath: "/Library/Application Support")
|
|
} else {
|
|
vBase = FileManager.default.urls(
|
|
for: .applicationSupportDirectory,
|
|
in: .userDomainMask
|
|
).first!
|
|
}
|
|
// pure path computation (no disk access: it's called from views);
|
|
// saveImpl creates the folder when it writes
|
|
return vBase
|
|
.appendingPathComponent("Allofit", isDirectory: true)
|
|
.appendingPathComponent("index.bin")
|
|
}
|
|
|
|
// returns the URL appropriate for a given service mode
|
|
static func cacheURL(forServiceMode inMode: Preferences.ServiceMode) -> URL {
|
|
return cacheURL(forSystem: inMode == .rootDaemon)
|
|
}
|
|
|
|
// path of the indexer lock sentinel, alongside the cache
|
|
static func lockURL(forSystem inSystem: Bool) -> URL {
|
|
return cacheURL(forSystem: inSystem)
|
|
.deletingLastPathComponent()
|
|
.appendingPathComponent("indexer.lock")
|
|
}
|
|
|
|
// returns the on-disk size in bytes of the current cache file, or 0
|
|
static func cacheFileSize(at inUrl: URL) -> Int64 {
|
|
guard let vAttrs = try? FileManager.default.attributesOfItem(atPath: inUrl.path) else { return 0 }
|
|
return (vAttrs[.size] as? NSNumber)?.int64Value ?? 0
|
|
}
|
|
|
|
// deletes the cache file at the given URL
|
|
static func clearCache(at inUrl: URL) {
|
|
try? FileManager.default.removeItem(at: inUrl)
|
|
}
|
|
|
|
// ===========================
|
|
// MARK: Save / load
|
|
// ===========================
|
|
|
|
// writes the records and event id atomically to the default cacheURL
|
|
static func save(inRecords: some Collection<FileRecord>, inLastEventId: UInt64) {
|
|
save(inRecords: inRecords, inLastEventId: inLastEventId, to: cacheURL)
|
|
}
|
|
|
|
// writes the records and event id atomically to a specific URL.
|
|
// Wrapped in autoreleasepool because every call autoreleases a number
|
|
// of Foundation objects (the compressed NSData, NSURLs created by
|
|
// FileManager.replaceItem, etc.) - if the caller is a long-running
|
|
// block whose own pool never drains, those would accumulate forever.
|
|
static func save(inRecords: some Collection<FileRecord>, inLastEventId: UInt64, to inUrl: URL) {
|
|
autoreleasepool {
|
|
saveImpl(inRecords: inRecords, inLastEventId: inLastEventId, to: inUrl)
|
|
}
|
|
}
|
|
|
|
// encodes, LZ4-compresses and atomically writes the cache (body of save)
|
|
private static func saveImpl(inRecords: some Collection<FileRecord>, inLastEventId: UInt64, to inUrl: URL) {
|
|
var vPayload = Data()
|
|
vPayload.reserveCapacity(16 + inRecords.count * 80)
|
|
writeU64(into: &vPayload, value: inLastEventId)
|
|
writeU64(into: &vPayload, value: UInt64(inRecords.count))
|
|
for vRecord in inRecords {
|
|
writeU64(into: &vPayload, value: vRecord.id)
|
|
writeI64(into: &vPayload, value: vRecord.size)
|
|
writeF64(into: &vPayload, value: vRecord.dateCreated.timeIntervalSince1970)
|
|
writeF64(into: &vPayload, value: vRecord.dateModified.timeIntervalSince1970)
|
|
var vFlags: UInt8 = 0
|
|
if vRecord.isDirectory { vFlags |= 0x01 }
|
|
vPayload.append(vFlags)
|
|
writeString(into: &vPayload, value: vRecord.name)
|
|
writeString(into: &vPayload, value: vRecord.parentPath)
|
|
}
|
|
|
|
// compress with LZ4 (raw block format, ~70% size reduction on text-heavy paths)
|
|
let vUncompressedSize = UInt64(vPayload.count)
|
|
let vCompressed: Data
|
|
do {
|
|
vCompressed = try (vPayload as NSData).compressed(using: .lz4) as Data
|
|
} catch {
|
|
NSLog("[Allofit] LZ4 compression failed, skipping save: \(error)")
|
|
return
|
|
}
|
|
|
|
var vEnvelope = Data()
|
|
vEnvelope.reserveCapacity(16 + vCompressed.count)
|
|
writeU32(into: &vEnvelope, value: kMagic)
|
|
writeU32(into: &vEnvelope, value: kVersion)
|
|
writeU64(into: &vEnvelope, value: vUncompressedSize)
|
|
vEnvelope.append(vCompressed)
|
|
|
|
// the folder may not exist yet (first save). The system-wide one must
|
|
// stay traversable by the GUI user; the index inside is owner-only
|
|
// (see restrictAccess)
|
|
let vDir = inUrl.deletingLastPathComponent()
|
|
try? FileManager.default.createDirectory(at: vDir, withIntermediateDirectories: true)
|
|
if getuid() == 0 {
|
|
chmod(vDir.path, 0o755)
|
|
}
|
|
// unique per save: two saves running at once (reindex and autosave
|
|
// on different queues) must not write the same temp file
|
|
let vTmp = inUrl.deletingLastPathComponent()
|
|
.appendingPathComponent(".\(inUrl.lastPathComponent).\(UUID().uuidString).tmp")
|
|
do {
|
|
try vEnvelope.write(to: vTmp)
|
|
// owner-only access, set on the temp file *before* it becomes
|
|
// the cache, so the index (a listing of every file name) is
|
|
// never readable by other local users, even for a moment
|
|
restrictAccess(inPath: vTmp.path)
|
|
// rename(2) is atomic and keeps the temp file's owner and mode
|
|
// (FileManager.replaceItem may carry over the old file's)
|
|
guard rename(vTmp.path, inUrl.path) == 0 else {
|
|
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
|
}
|
|
} catch {
|
|
NSLog("[Allofit] cache save failed at %@: %@", inUrl.path, "\(error)")
|
|
try? FileManager.default.removeItem(at: vTmp)
|
|
}
|
|
}
|
|
|
|
// makes a cache file readable by its owner only. The root daemon hands
|
|
// it to the user who installed the service (the GUI reads it as that
|
|
// user); everyone else gets nothing.
|
|
private static func restrictAccess(inPath: String) {
|
|
if getuid() == 0,
|
|
let vOwner = ProcessInfo.processInfo.environment["ALLOFIT_OWNER_USER"],
|
|
let vEntry = getpwnam(vOwner) {
|
|
chown(inPath, vEntry.pointee.pw_uid, vEntry.pointee.pw_gid)
|
|
}
|
|
chmod(inPath, 0o600)
|
|
}
|
|
|
|
// largest uncompressed payload accepted from a cache file (a corrupt or
|
|
// crafted header must not make the loader allocate without bound)
|
|
private static let kMaxPayloadBytes: UInt64 = 8 << 30
|
|
|
|
// reads the persisted index back from the default cacheURL
|
|
static func load() -> LoadResult? {
|
|
return load(from: cacheURL)
|
|
}
|
|
|
|
// reads the persisted index back from a specific URL. Wrapped in
|
|
// autoreleasepool so the decompressed NSData and the per-record
|
|
// String allocations don't linger in the caller's pool.
|
|
static func load(from inUrl: URL) -> LoadResult? {
|
|
return autoreleasepool {
|
|
loadImpl(from: inUrl)
|
|
}
|
|
}
|
|
|
|
// reads, decompresses and decodes the cache (body of load)
|
|
private static func loadImpl(from inUrl: URL) -> LoadResult? {
|
|
guard let vData = try? Data(contentsOf: inUrl) else { return nil }
|
|
var vOffset = 0
|
|
guard let vMagic = readU32(from: vData, offset: &vOffset), vMagic == kMagic else { return nil }
|
|
guard let vVersion = readU32(from: vData, offset: &vOffset), vVersion == kVersion else { return nil }
|
|
guard let vDeclaredSize = readU64(from: vData, offset: &vOffset),
|
|
vDeclaredSize <= kMaxPayloadBytes else { return nil }
|
|
|
|
let vCompressed = vData.subdata(in: vOffset..<vData.count)
|
|
let vPayload: Data
|
|
do {
|
|
vPayload = try (vCompressed as NSData).decompressed(using: .lz4) as Data
|
|
} catch {
|
|
NSLog("[Allofit] LZ4 decompression failed: \(error)")
|
|
return nil
|
|
}
|
|
guard UInt64(vPayload.count) == vDeclaredSize else { return nil }
|
|
|
|
var vP = 0
|
|
guard let vLastEventId = readU64(from: vPayload, offset: &vP) else { return nil }
|
|
guard let vCount = readU64(from: vPayload, offset: &vP) else { return nil }
|
|
// each record takes at least 41 bytes - rejects a corrupt count
|
|
// before it turns into a huge reserveCapacity
|
|
guard vCount <= UInt64(vPayload.count / 41) else { return nil }
|
|
var vRecords: [FileRecord] = []
|
|
vRecords.reserveCapacity(Int(vCount))
|
|
// siblings share one parentPath buffer instead of one copy each
|
|
let vInterner = PathInterner()
|
|
for _ in 0..<Int(vCount) {
|
|
// the stored id is ignored: ids are recomputed from the path so
|
|
// caches written with the old per-launch random hash still load
|
|
guard let _ = readU64(from: vPayload, offset: &vP),
|
|
let vSize = readI64(from: vPayload, offset: &vP),
|
|
let vCreated = readF64(from: vPayload, offset: &vP),
|
|
let vModified = readF64(from: vPayload, offset: &vP),
|
|
let vFlags = readU8(from: vPayload, offset: &vP),
|
|
let vName = readString(from: vPayload, offset: &vP),
|
|
let vParent = readString(from: vPayload, offset: &vP)
|
|
else {
|
|
return nil
|
|
}
|
|
let vShared = vInterner.intern(vParent)
|
|
vRecords.append(FileRecord(
|
|
name: vName,
|
|
parentPath: vShared.path,
|
|
parentLower: vShared.folded,
|
|
size: vSize,
|
|
dateCreated: Date(timeIntervalSince1970: vCreated),
|
|
dateModified: Date(timeIntervalSince1970: vModified),
|
|
isDirectory: (vFlags & 0x01) != 0
|
|
))
|
|
}
|
|
return LoadResult(records: vRecords, lastEventId: vLastEventId)
|
|
}
|
|
|
|
// ===========================
|
|
// MARK: Binary helpers
|
|
// ===========================
|
|
|
|
// appends a little-endian UInt32
|
|
private static func writeU32(into ioData: inout Data, value inValue: UInt32) {
|
|
var vV = inValue.littleEndian
|
|
withUnsafeBytes(of: &vV) { ioData.append(contentsOf: $0) }
|
|
}
|
|
|
|
// appends a little-endian UInt64
|
|
private static func writeU64(into ioData: inout Data, value inValue: UInt64) {
|
|
var vV = inValue.littleEndian
|
|
withUnsafeBytes(of: &vV) { ioData.append(contentsOf: $0) }
|
|
}
|
|
|
|
// appends a little-endian Int64
|
|
private static func writeI64(into ioData: inout Data, value inValue: Int64) {
|
|
var vV = inValue.littleEndian
|
|
withUnsafeBytes(of: &vV) { ioData.append(contentsOf: $0) }
|
|
}
|
|
|
|
// appends a Double as its little-endian bit pattern
|
|
private static func writeF64(into ioData: inout Data, value inValue: Double) {
|
|
var vV = inValue.bitPattern.littleEndian
|
|
withUnsafeBytes(of: &vV) { ioData.append(contentsOf: $0) }
|
|
}
|
|
|
|
// appends a UInt32 byte length followed by the UTF-8 bytes
|
|
private static func writeString(into ioData: inout Data, value inValue: String) {
|
|
let vBytes = Array(inValue.utf8)
|
|
writeU32(into: &ioData, value: UInt32(vBytes.count))
|
|
ioData.append(contentsOf: vBytes)
|
|
}
|
|
|
|
// reads one byte at ioOffset and advances it; nil past the end
|
|
private static func readU8(from inData: Data, offset ioOffset: inout Int) -> UInt8? {
|
|
guard ioOffset + 1 <= inData.count else { return nil }
|
|
let vV = inData[inData.startIndex + ioOffset]
|
|
ioOffset += 1
|
|
return vV
|
|
}
|
|
|
|
// reads a little-endian UInt32 at ioOffset and advances it
|
|
private static func readU32(from inData: Data, offset ioOffset: inout Int) -> UInt32? {
|
|
guard ioOffset + 4 <= inData.count else { return nil }
|
|
let vOffset = ioOffset
|
|
let vV = inData.withUnsafeBytes { (vPtr: UnsafeRawBufferPointer) -> UInt32 in
|
|
vPtr.loadUnaligned(fromByteOffset: vOffset, as: UInt32.self).littleEndian
|
|
}
|
|
ioOffset += 4
|
|
return vV
|
|
}
|
|
|
|
// reads a little-endian UInt64 at ioOffset and advances it
|
|
private static func readU64(from inData: Data, offset ioOffset: inout Int) -> UInt64? {
|
|
guard ioOffset + 8 <= inData.count else { return nil }
|
|
let vOffset = ioOffset
|
|
let vV = inData.withUnsafeBytes { (vPtr: UnsafeRawBufferPointer) -> UInt64 in
|
|
vPtr.loadUnaligned(fromByteOffset: vOffset, as: UInt64.self).littleEndian
|
|
}
|
|
ioOffset += 8
|
|
return vV
|
|
}
|
|
|
|
// reads a little-endian Int64 at ioOffset and advances it
|
|
private static func readI64(from inData: Data, offset ioOffset: inout Int) -> Int64? {
|
|
guard ioOffset + 8 <= inData.count else { return nil }
|
|
let vOffset = ioOffset
|
|
let vV = inData.withUnsafeBytes { (vPtr: UnsafeRawBufferPointer) -> Int64 in
|
|
vPtr.loadUnaligned(fromByteOffset: vOffset, as: Int64.self).littleEndian
|
|
}
|
|
ioOffset += 8
|
|
return vV
|
|
}
|
|
|
|
// reads a Double stored as its little-endian bit pattern
|
|
private static func readF64(from inData: Data, offset ioOffset: inout Int) -> Double? {
|
|
guard let vBits = readU64(from: inData, offset: &ioOffset) else { return nil }
|
|
return Double(bitPattern: vBits)
|
|
}
|
|
|
|
// reads a UInt32-length-prefixed UTF-8 string and advances ioOffset
|
|
private static func readString(from inData: Data, offset ioOffset: inout Int) -> String? {
|
|
guard let vLen = readU32(from: inData, offset: &ioOffset) else { return nil }
|
|
let vLength = Int(vLen)
|
|
guard ioOffset + vLength <= inData.count else { return nil }
|
|
let vStart = ioOffset
|
|
ioOffset += vLength
|
|
// native decode (no Foundation bridging); invalid bytes are repaired
|
|
return inData.withUnsafeBytes { vPtr in
|
|
String(decoding: vPtr[vStart..<(vStart + vLength)], as: UTF8.self)
|
|
}
|
|
}
|
|
}
|