Files
Allofit/sources/service/ElevatedAccess.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

84 lines
3.5 KiB
Swift

import Foundation
// ElevatedAccess provides on-demand sudo-backed access to files the GUI
// user can't read directly. Common case: the root LaunchDaemon indexed
// `/Users/<otheruser>/...` (it has Full Disk Access), the GUI runs as
// the current user, and trying to render an inline preview hits a
// permission denial. The user clicks "Authorize" in the preview pane,
// AdminShell prompts for the password once, sudo copies the file to
// the per-user staging directory and chowns it to the GUI user.
//
// The staged copy is owned by the GUI user, lives in
// ~/Library/Caches/Allofit/elevated/
// and is wiped at app launch and at app quit so privileged copies don't
// linger on disk across sessions.
enum ElevatedAccess {
// per-user staging directory; lives under Library/Caches so macOS
// itself may purge it under disk-pressure, and our own cleanup() at
// launch + terminate keeps it from accumulating
static var stagingDirectory: URL {
let vCaches = FileManager.default.urls(
for: .cachesDirectory,
in: .userDomainMask
).first!
return vCaches.appendingPathComponent("Allofit/elevated", isDirectory: true)
}
// true if the current user can read the file at inPath without elevation
static func canRead(path inPath: String) -> Bool {
return FileManager.default.isReadableFile(atPath: inPath)
}
// wipes anything in the staging directory. Called on app launch (so a
// previous session's elevated copies don't survive a relaunch) and on
// app terminate (so they don't survive a clean quit either). Failure
// is silent - if cleanup fails the next launch's cleanup will retry.
static func cleanup() {
try? FileManager.default.removeItem(at: stagingDirectory)
}
// copies inUrl into the staging directory via sudo, chowns it to the
// current user, and returns the staged URL. Caller is responsible for
// catching AdminShell.Error.scriptFailed (cancelled prompt etc).
//
// Throws if the parent staging directory can't be created or the
// admin script fails. Side effect: the system prompts for password
// the first time within the auth-cache window.
static func stage(_ inUrl: URL) throws -> URL {
let vDir = stagingDirectory
// create as the current user so the dir is owned by us; sudo
// only handles the file copy itself
try FileManager.default.createDirectory(
at: vDir,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
// creation attributes don't apply to an existing folder: enforce
// owner-only access every time (the copies may be other users' files)
chmod(vDir.path, 0o700)
// unique destination file, keeping the original extension so the
// QLPreviewView / Launch Services can pick the right renderer
var vDst = vDir.appendingPathComponent(UUID().uuidString)
let vExt = inUrl.pathExtension
if !vExt.isEmpty {
vDst.appendPathExtension(vExt)
}
// Root only *reads* the original; the copy is written by the user
// (sudo -u ... tee), so root never creates, chowns or chmods a path
// inside a user-writable folder. A cp + chown + chmod as root could
// be redirected through a swapped-in symlink / hard link to take
// ownership of any system file. pipefail makes a failed read fail
// the script instead of leaving an empty copy behind.
let vScript = """
set -o pipefail; \
/bin/cat \(AdminShell.quote(inUrl.path)) \
| /usr/bin/sudo -u \(AdminShell.quote(NSUserName())) /usr/bin/tee \(AdminShell.quote(vDst.path)) > /dev/null
"""
_ = try AdminShell.run(vScript)
return vDst
}
}