diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fcbf95d..64d9b3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: name: Build Swift package runs-on: macos-15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: install swift-format run: brew install swift-format # The Homebrew binary by name, not `swift format`: on macos-15 the latter diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc34dbf..df6bc49 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,7 +76,7 @@ jobs: skip: ${{ steps.decide.outputs.skip }} version: ${{ steps.decide.outputs.version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - id: decide run: | base="$(tr -d '[:space:]' < mac-app/VERSION)" @@ -127,7 +127,7 @@ jobs: # written into mac-app/VERSION, which would mark every release dirty. CM_RELEASE_VERSION: ${{ needs.decide.outputs.version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 @@ -257,7 +257,7 @@ jobs: - name: Upload artifacts (dry run) if: env.IS_RELEASE != 'true' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: cloudmachine-${{ env.VERSION }}-dry-run path: | @@ -267,7 +267,7 @@ jobs: - name: Publish GitHub Release if: env.IS_RELEASE == 'true' - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: # Creates the tag on this commit when the run did not start from one. tag_name: v${{ env.VERSION }} @@ -290,7 +290,7 @@ jobs: - name: Check out tap if: env.IS_RELEASE == 'true' - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: RenaCode/homebrew-tap token: ${{ secrets.HOMEBREW_TAP_TOKEN }} diff --git a/README.md b/README.md index 8dd0b91..f4c0e99 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,11 @@ It works two ways: `brew upgrade` replaces the app without restarting the Google Drive mount. When Homebrew reopens the app, it reloads the background agents so they run -the new version; `cloudmachine-agent drive-status` shows `Agents: OK`. If a +the new version; `cloudmachine-agent drive-status` shows `Agents: OK`. Changes +to the mount itself wait for its next start - a restart of the Mac, with +`prepare-shutdown` before it. One such change is rclone's control interface +moving to a private socket: until the restart `drive-status` reports +`Remote control: OPEN on 127.0.0.1:5572`. If a new version does not show up, run `brew update` first - Homebrew refreshes the tap only now and then. Neither `brew uninstall` nor `--zap` touches the launchd agents or the upload buffer in `~/.cloudmachine`, diff --git a/docs/design.md b/docs/design.md index 865bf45..82cf718 100644 --- a/docs/design.md +++ b/docs/design.md @@ -11,7 +11,7 @@ Measured on a Mac Studio, 332 Mbit/s uplink: | Full backup | 210 GiB, about two hours | | Incremental backup | ~370 MB of new data, a few minutes | | Google Drive used | 589 GiB (29 Sep 2026) — of which only 417 GiB is live data | -| Actually uploaded per day | **327–595 GB** — see below | +| Actually uploaded per day | **327–793 GB** — see below | That last row is not a typo, and it is the number that surprises people. What Time Machine *writes* and what rclone *sends* are different quantities, because @@ -25,6 +25,16 @@ September 2026 that put 823 GB on the wire in 24 hours for roughly 45 GB of real change — past Google's 750 GB/day write ceiling, which blocked *all* uploads for several hours. +It is not history either. Counted from `rclone.log` on 9 October 2026, with +`--vfs-write-back 10m` in place: 630 GB went out in the last 24 hours, and the +highest rolling 24-hour window over eight days reached **793 GB**; 36 of the +193 hourly windows were above 750 GB. Not a single upload-limit error followed, +so Google is not enforcing the ceiling on this account right now — but nothing +here warns before it starts to: the app only notices the limit once uploads +have stalled on it (`logShowsUploadStalled`). The count is `Copied` lines × 32 +MiB, so a share of smaller-than-a-band uploads may put the true figure +somewhat lower. + So the daily cap is not just a first-backup concern, and it does not require a source larger than 750 GB. A 265 GiB backup reached it. `--vfs-write-back` is the lever that keeps it in check — see [Why the pieces are what they @@ -104,6 +114,26 @@ expiries forward through rclone's `vfs/queue-set-expiry`, so detach still drains in seconds. Attach does the same before waiting, since `hdiutil` on FUSE-T rejects mounts more often while rclone is busy. +**Remote control on a private socket.** The queue, the expiries and the buffer +guard all go through rclone's remote control interface, which can also delete +anything in the backup folder (`operations/purge`) — with the trash off, for +good. Until 9 October 2026 it listened on `127.0.0.1:5572` without a password, +and loopback is not private: a web page can POST a form there without a CORS +preflight, and rclone does not check `Origin`. It now listens only on +`~/.cloudmachine/run/rc.sock`, in a directory only the owner can enter, which a +browser cannot reach at all. A mount started by an older version keeps the TCP +address until it restarts; `drive-status` says so on its "Remote control" line. + +The Drive trash stays off, after weighing it as a second line. Bands are +rewritten in place, so uploads would not fill it; deletions would — and the big +ones are deliberate: re-creating the image, clearing an old folder. Each would +keep hundreds of GB counted against the account for 30 days, where running out +of space stops the mount (`storageQuotaExceeded`), and the free-space alarm +(`operations/about` counts the trash) would report space no folder shows. What +the trash would still catch after the socket is a process of this same user +deleting through the mount — and such a process can just as well run its own +`rclone purge --drive-use-trash=false` with the same `rclone.conf`. + **Its own rclone.** The Homebrew build is compiled without FUSE and refuses to mount outright. CloudMachine installs the official binary beside it, verified by SHA256. diff --git a/docs/operations.md b/docs/operations.md index 2689f33..71e9a16 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -23,6 +23,7 @@ cloudmachine-agent drive-status ``` Tools: OK Drive mount: OK +Remote control: private socket Drive folder: gdrive:CloudMachine/mac-studio Image attached: OK (/Volumes/CloudMachine) Cache on disk: 103 GB of 100G diff --git a/gdrive/README.md b/gdrive/README.md index 900bbe5..0149ee0 100644 --- a/gdrive/README.md +++ b/gdrive/README.md @@ -157,5 +157,3 @@ swift run cloudmachine-poc pullplug --clean - Whether `tmutil setdestination` accepts a destination outside `/Volumes`. That determines whether the need for manual intervention after an unclean detach can be removed. -- Whether `--rc-no-auth` on the loopback interface is acceptable. Any local - process can control the mount through that interface. diff --git a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift index ad5eaef..f0db660 100644 --- a/mac-app/Sources/CloudMachineAgent/DriveCommands.swift +++ b/mac-app/Sources/CloudMachineAgent/DriveCommands.swift @@ -356,6 +356,10 @@ struct DriveStatus: AsyncParsableCommand { ? "OK" : L10n.tr("missing: %@", readiness.missing.joined(separator: ", ")))) let mounted = DriveBufferService.mountedState() print(L10n.tr("Drive mount: %@", StatusLines.mounted(mounted))) + print( + L10n.tr( + "Remote control: %@", + StatusLines.remoteControl(DriveBufferService.rcTransport, mounted: mounted))) print( L10n.tr( "Drive folder: %@", diff --git a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift index 1fc71af..3a599e5 100644 --- a/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift +++ b/mac-app/Sources/CloudMachineApp/Models/AppStatus.swift @@ -83,6 +83,25 @@ struct BufferStatus: Equatable { var draining: Bool { uploadsInProgress > 0 || uploadsQueued > 0 } + /// The "Cloud sync queue" row. + /// + /// An empty queue is "everything uploaded" only when rclone abandoned + /// nothing on the way: an abandoned band drops out of the queue exactly like + /// an uploaded one (see `DriveBufferService.QueueStats.isQuiet`). Until + /// 09.10.2026 the row then said "Everything uploaded" - in red, above a row + /// counting the errors. + var queueSummary: String { + guard queueKnown else { return L10n.tr("not read") } + if draining { + return L10n.tr( + "%@ in progress, %@ queued", "\(uploadsInProgress)", "\(uploadsQueued)") + } + if erroredFiles > 0 { + return L10n.tr("%@ fragments abandoned - only on this Mac", "\(erroredFiles)") + } + return L10n.tr("Everything uploaded") + } + /// The single source of truth on whether the backup reaches the Drive - and why not. var uploadState: UploadState { UploadState.from( @@ -278,4 +297,18 @@ final class AppStatus: ObservableObject { else { return false } return true } + + /// Whether "Back up now" can do anything: the backup has somewhere to go. + /// + /// NOT `healthy`, which until 09.10.2026 decided this too. `healthy` also + /// asks for a fresh backup - so after two days without one, with every + /// device in place, the only button that fixes that was grey. Likewise with + /// an unread queue or the Google daily limit: Time Machine still writes into + /// the buffer then. Only what makes `tmutil startbackup` pointless blocks it. + var canStartBackup: Bool { + guard case .ready = dependencyState, remoteConfigured, buffer.mounted, buffer.imageAttached, + case .registered = timeMachineState, !isBusy + else { return false } + return true + } } diff --git a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift index d8ebdfa..bc2ba5f 100644 --- a/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift +++ b/mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift @@ -26,6 +26,8 @@ final class CloudMachineController: ObservableObject { /// good as one from five seconds ago. private static let backupCycleInterval: TimeInterval = 300 private var backupCycleCheckedAt: Date? + /// One failed read of the Time Machine preferences is not "no Full Disk Access". + private var preferencesReads = BackupHealth.ReadConfirmation() // MARK: - Refresh cycle @@ -101,14 +103,20 @@ final class CloudMachineController: ObservableObject { let readiness = CMTooling.checkReadiness() status.dependencyState = readiness.ready ? .ready : .missing(readiness.missing, readiness.remedies) - status.remoteConfigured = await RemoteConfigurer.isConfigured( + // No answer keeps what we knew: flipping to "not connected" would offer + // the "Connect Google Drive" card on a working installation. + if let configured = await RemoteConfigurer.isConfigured( remoteName: DriveBufferService.remoteName) + { + status.remoteConfigured = configured + } // A REAL read of the file that actually matters - see // `BackupHealth.preferencesReadable`. Previously this was // `isReadableFile` (that is, `access(R_OK)`) on the DIRECTORY // `~/Library/Application Support/com.apple.TCC`: the wrong path and a check // that proves nothing under TCC. - status.hasFullDiskAccess = BackupHealth.preferencesReadable() + status.hasFullDiskAccess = preferencesReads.readable( + after: BackupHealth.preferencesReadable(), shown: status.hasFullDiskAccess) status.driveFolderPath = "\(DriveBufferService.remoteName):\(DriveBufferService.remotePath)" if !status.remoteConfigured, status.suggestedDriveFolder.isEmpty { let key = await MachineIdentity.currentKey() diff --git a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift index 06ed943..4baa4f3 100644 --- a/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift +++ b/mac-app/Sources/CloudMachineApp/Views/DashboardView.swift @@ -192,7 +192,7 @@ private struct DashboardContent: View { case .storage: let tone = uploadTone(status.buffer.uploadState) .worst(freeSpaceTone) - .worst(status.budgetLimitGB == nil || budgetOK ? .success : .warning) + .worst(budgetTone == .neutral ? .success : budgetTone) return tone == .success ? nil : tone } } @@ -539,7 +539,7 @@ private struct DashboardContent: View { } } .buttonStyle(PrimaryGradientButtonStyle()) - .disabled(!status.healthy || status.isBusy) + .disabled(!status.canStartBackup) } else { Button(action: { Task { await controller.stopBackup() } }) { HStack(spacing: 6) { @@ -912,7 +912,7 @@ private struct DashboardContent: View { row( L10n.tr("Used on Google Drive"), MachineBudget.summary(limitGB: status.budgetLimitGB, usage: status.budgetUsage), - tone: budgetOK ? .success : .danger) + tone: budgetTone) if let usage = status.budgetUsage { Text( @@ -970,13 +970,21 @@ private struct DashboardContent: View { commandBox(command) } } - .toneCard(budgetOK ? .neutral : .warning) + .toneCard(budgetTone == .success || budgetTone == .neutral ? .neutral : .warning) } - private var budgetOK: Bool { - guard let limit = status.budgetLimitGB, let usage = status.budgetUsage - else { return status.budgetLimitGB != nil } - return MachineBudget.level(usageBytes: usage.bytes, limitGB: limit) == .ok + /// Green only for a measurement that is recent and within the limit. Until + /// 09.10.2026 "no measurement" was green as well, with the text "usage not + /// measured yet" next to it, and a measurement from days ago passed as + /// current. No limit is a choice, not a fault - neutral, as the sidebar + /// already treated it. + private var budgetTone: StatusTone { + switch MachineBudget.standing(limitGB: status.budgetLimitGB, usage: status.budgetUsage) { + case .notSet: return .neutral + case .unmeasured, .near: return .warning + case .ok: return .success + case .over: return .danger + } } // MARK: - Buffer and Upload Details @@ -1019,20 +1027,15 @@ private struct DashboardContent: View { row( L10n.tr("Free space on the local volume"), status.buffer.freeDiskGB.map { L10n.tr("%@ GB", "\($0)") } ?? L10n.tr("not measured"), - tone: (status.buffer.freeDiskGB ?? 0) > 80 ? .success : .danger) + tone: freeSpaceTone) Divider().background(RenaCodeTheme.borderGlass) + // The tone of the upload verdict, not its own: a queue at zero with the + // daily limit used up is not green - nothing is going out. row( - L10n.tr("Cloud sync queue"), - !status.buffer.queueKnown - ? L10n.tr("not read") - : (status.buffer.draining - ? L10n.tr( - "%@ in progress, %@ queued", "\(status.buffer.uploadsInProgress)", - "\(status.buffer.uploadsQueued)") - : L10n.tr("Everything uploaded")), - tone: status.buffer.queueKnown && status.buffer.erroredFiles == 0 ? .success : .danger) + L10n.tr("Cloud sync queue"), status.buffer.queueSummary, + tone: uploadTone(status.buffer.uploadState)) if status.buffer.erroredFiles > 0 { Divider().background(RenaCodeTheme.borderGlass) diff --git a/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift b/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift index 942130a..35daeb9 100644 --- a/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift +++ b/mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift @@ -150,7 +150,7 @@ private struct MenuBarPanel: View { } } .buttonStyle(PrimaryGradientButtonStyle()) - .disabled(!status.healthy) + .disabled(!status.canStartBackup) } else { Button(action: { Task { await controller.stopBackup() } }) { HStack { diff --git a/mac-app/Sources/CloudMachineCore/BackupHealth.swift b/mac-app/Sources/CloudMachineCore/BackupHealth.swift index ebe92f8..0d70e1a 100644 --- a/mac-app/Sources/CloudMachineCore/BackupHealth.swift +++ b/mac-app/Sources/CloudMachineCore/BackupHealth.swift @@ -450,6 +450,62 @@ public enum BackupHealth { (try? Data(contentsOf: URL(fileURLWithPath: preferencesFile))) != nil } + /// How many times the Time Machine preferences are read before "cannot read" + /// counts, and how long to wait between the reads. + /// + /// One failed read is not evidence: on 08.10.2026 at 19:52, two minutes into + /// a backup, the watchdog could not read the file and reported "most often + /// Full Disk Access is missing" - while the runs 30 minutes before and after + /// read it fine. backupd rewrites this file during a backup, and a read can + /// land in the middle. Missing Full Disk Access fails EVERY read, so asking + /// again costs a real alarm only these seconds, never the alarm itself. + public static let preferencesReadAttempts = 3 + public static let preferencesRetryPause: TimeInterval = 5 + + /// The file read and parsed; `nil` for either failure. + static func loadPreferences(_ path: String) -> [String: Any]? { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return nil } + return (try? PropertyListSerialization.propertyList(from: data, format: nil)) + as? [String: Any] + } + + /// Reads until one read works, at most `attempts` times. `nil` only when + /// every read failed. Pure in its inputs, so the confirmation can be tested. + static func readPreferences( + _ read: () -> [String: Any]?, attempts: Int = preferencesReadAttempts, + pause: () async -> Void + ) async -> [String: Any]? { + for attempt in 1...max(1, attempts) { + if let plist = read() { return plist } + if attempt < attempts { await pause() } + } + return nil + } + + /// The panel's "Full Disk Access" answer, which reads the file every 10 s: + /// it says "missing" only after `required` failed reads in a row, and + /// "granted" again at the first good one. Without it a read landing in a + /// backupd rewrite brought back the "Grant Full Disk Access" setup step for + /// one refresh. Until the first failure is confirmed the earlier answer + /// stands - and at launch that is `false`, so a missing permission still + /// shows from the start. + public struct ReadConfirmation: Equatable, Sendable { + public let required: Int + public private(set) var failuresInARow = 0 + + public init(required: Int = 2) { self.required = required } + + /// The answer to show after this read, given the one shown so far. + public mutating func readable(after readSucceeded: Bool, shown: Bool) -> Bool { + if readSucceeded { + failuresInARow = 0 + return true + } + failuresInARow += 1 + return failuresInARow >= required ? false : shown + } + } + /// `preferencesFile` can be replaced so that the WHOLE watchdog path can be /// run on a known bad sample - reading the file, parsing, choosing the /// destination, assessment, reporting, exit code - without breaking the @@ -458,14 +514,14 @@ public enum BackupHealth { /// failures in this project were. public static func currentReport( now: Date = Date(), maxAgeHours: Double = BackupHealth.maxAgeHours, - preferencesFile: String = BackupHealth.preferencesPath + preferencesFile: String = BackupHealth.preferencesPath, + preferencesRetryPause: TimeInterval = BackupHealth.preferencesRetryPause ) async -> Report { - let plist = - (try? Data(contentsOf: URL(fileURLWithPath: preferencesFile))) - .flatMap { - try? PropertyListSerialization.propertyList(from: $0, format: nil) as? [String: Any] - } - ?? nil + let plist = await readPreferences( + { loadPreferences(preferencesFile) }, + pause: { + try? await Task.sleep(nanoseconds: UInt64(preferencesRetryPause * 1_000_000_000)) + }) guard let plist else { return Report( diff --git a/mac-app/Sources/CloudMachineCore/DriveBufferService.swift b/mac-app/Sources/CloudMachineCore/DriveBufferService.swift index c9081b9..a6eb85c 100644 --- a/mac-app/Sources/CloudMachineCore/DriveBufferService.swift +++ b/mac-app/Sources/CloudMachineCore/DriveBufferService.swift @@ -52,10 +52,67 @@ public enum DriveBufferService { /// otherwise a detach would wait as long as this delay. public static let writeBackSeconds = 600 - /// Address of rclone's remote control interface. It listens on loopback only, - /// but any local process can control the mount through it - if we ever decide - /// that is too loose, `--rc-user`/`--rc-pass` have to be added. - public static let rcAddress = "127.0.0.1:5572" + /// rclone's remote control interface: a unix socket in a directory only this + /// user can enter. + /// + /// Until 09.10.2026 it was `127.0.0.1:5572` with `--rc-no-auth`. "Loopback + /// only" did not mean "only us": any web page open in a browser can send a + /// form POST there (a "simple request" goes out without a CORS preflight, + /// and rclone does not check `Origin`). The answer stays unreadable to the + /// page, but the command runs - and the interface offers `operations/purge` + /// on the backup folder, with `--drive-use-trash=false`, so nothing to undo. + /// Checked on the running mount: `config/listremotes` answered a form POST + /// with `Origin: https://evil.example`. + /// + /// A browser cannot reach a unix socket at all, and other users cannot enter + /// `runDir` (0700). `--rc-no-auth` stays: `vfs/queue-set-expiry`, which every + /// detach needs, requires it or a password. A password would add nothing + /// against the one remaining caller, a process of this same user - it can + /// read `rclone.conf`, token included, directly - and every restart that + /// rotated it would be a new way for the readers to lose the queue. + public static var runDir: URL { root.appendingPathComponent("run") } + public static var rcSocket: URL { runDir.appendingPathComponent("rc.sock") } + + /// Where a mount started by CloudMachine 1.3.7 or older still listens. Such a + /// mount survives an upgrade (`gdrive-buffer` is deliberately not + /// restarted - see `AgentRepair`), and until it is restarted it is the only + /// interface there is. Without this fallback the upgrade would leave the + /// buffer watchdog and every detach without a queue until the next restart. + public static let legacyRCAddress = "127.0.0.1:5572" + + /// Longest path a unix socket address can hold on macOS (`sun_path`, with the + /// terminating zero). rclone fails to start with `bind: invalid argument` + /// above it. + static let socketPathLimit = 103 + + public enum RCTransport: Equatable, Sendable { + /// The private socket - a mount started by this version. + case socket + /// The old TCP address - a mount from before the socket, still running. + case legacyTCP + } + + /// Which interface the running mount listens on. The socket file is there + /// exactly as long as a mount of this version has it (`prepare` removes a + /// stale one before every start). + public static var rcTransport: RCTransport { + FileManager.default.fileExists(atPath: rcSocket.path) ? .socket : .legacyTCP + } + + /// Arguments that point `rclone rc` at the running mount. + static func rcClientArguments(_ transport: RCTransport) -> [String] { + switch transport { + case .socket: return ["--unix-socket", rcSocket.path] + case .legacyTCP: return ["--url", legacyRCAddress] + } + } + + /// Calls the running mount's remote control interface. The ONLY way to it - + /// so that no caller is left on the old address. + static func rc(_ method: [String], timeout: TimeInterval) async throws -> ProcessResult { + try await CMTooling.runRclone( + ["rc"] + rcClientArguments(rcTransport) + method, timeout: timeout) + } /// Above this size the rclone log is trimmed at start-up. rclone does not /// rotate its own log, and this project has already lost 3.3 GiB once to a log @@ -148,13 +205,18 @@ public enum DriveBufferService { // Chunk size matched to the image's band size. "--drive-chunk-size", "32M", // Without this, deleted bands go to Drive's trash and keep counting - // towards the storage limit. + // towards the storage limit. Kept after 09.10.2026, when the trash was + // weighed as a second line against a stray `operations/purge`: a + // deliberate deletion (re-creating the image) would then hold hundreds + // of GB for 30 days on an account where running out of space stops the + // mount. The private socket (see `rcSocket`) keeps such calls out; a + // process of this user can bypass the trash with its own rclone anyway. "--drive-use-trash=false", // After exceeding the daily 750 GB limit rclone is to stop, not spin in // 403s until the end of the world. "--drive-stop-on-upload-limit", "--volname", remoteName, - "--rc", "--rc-addr", rcAddress, "--rc-no-auth", + "--rc", "--rc-addr", "unix://\(rcSocket.path)", "--rc-no-auth", "--log-file", logFile.path, "--log-level", "INFO", ] @@ -166,10 +228,67 @@ public enum DriveBufferService { public static func prepare() throws -> [String] { try FileManager.default.createDirectory(at: mountPoint, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) + try prepareRCSocketDirectory() rotateLogIfLarge() return mountArguments() } + public enum PrepareError: LocalizedError { + case socketPathTooLong(String) + + public var errorDescription: String? { + switch self { + case .socketPathTooLong(let path): + return L10n.tr( + "The remote control socket path is too long for macOS (%@ characters, at most %@): %@", + "\(path.utf8.count)", "\(socketPathLimit)", path) + } + } + } + + /// The socket's directory, private, and no stale socket in it. + /// + /// A socket file outlives an rclone that crashed or was killed, and rclone + /// does not remove it: the next start ends with `bind: address already in + /// use` (checked on rclone 1.75.1). Under launchd's KeepAlive that is a + /// mount that never comes back. So a socket nobody answers on is removed; + /// one that answers belongs to an rclone that is still running, and is left + /// alone - the new start then fails exactly as a second mount on the same + /// TCP port used to. + static func prepareRCSocketDirectory( + _ directory: URL = runDir, socket: URL = rcSocket + ) throws { + let path = socket.path + guard path.utf8.count <= socketPathLimit else { throw PrepareError.socketPathTooLong(path) } + let fm = FileManager.default + try fm.createDirectory( + at: directory, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + // `createDirectory` leaves an existing directory's permissions as they are. + try fm.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) + if fm.fileExists(atPath: path), !socketAnswers(path) { + try? fm.removeItem(atPath: path) + } + } + + /// Whether a process is listening on the unix socket at `path`. + static func socketAnswers(_ path: String) -> Bool { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return false } + defer { close(fd) } + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let bytes = Array(path.utf8) + guard bytes.count < MemoryLayout.size(ofValue: address.sun_path) else { return false } + withUnsafeMutableBytes(of: &address.sun_path) { raw in + raw.copyBytes(from: bytes) + raw[bytes.count] = 0 + } + let length = socklen_t(MemoryLayout.size) + return withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(fd, $0, length) == 0 } + } + } + private static func rotateLogIfLarge() { guard let attrs = try? FileManager.default.attributesOfItem(atPath: logFile.path), @@ -247,7 +366,7 @@ public enum DriveBufferService { /// /// NOTE: `--rc-no-auth` is a SERVER flag. The `rclone rc` client does not /// accept it and ends with an "unknown flag" error - this has already cost one - /// silent breakage of the status view. + /// silent breakage of the status view. Hence one way to the interface: `rc`. /// /// Time limit 60 s, not 30 s: on 23.09.2026 the same call took **36.7 s** with /// a clogged buffer (the next one 0.03 s - so sporadic, under load). With 30 s @@ -262,8 +381,7 @@ public enum DriveBufferService { /// second of a frozen window, and the answer will not come anyway. public static func queueStats() async -> QueueStats? { guard - let result = try? await CMTooling.runRclone( - ["rc", "--url", rcAddress, "vfs/stats"], timeout: 60), + let result = try? await rc(["vfs/stats"], timeout: 60), result.succeeded else { return nil } return parseQueueStats(result.stdout) @@ -441,8 +559,7 @@ public enum DriveBufferService { @discardableResult public static func expireQueuedUploads() async -> ExpiryOutcome? { guard - let result = try? await CMTooling.runRclone( - ["rc", "--url", rcAddress, "vfs/queue"], timeout: 60), + let result = try? await rc(["vfs/queue"], timeout: 60), result.succeeded, let ids = parseQueueIDs(result.stdout) else { return nil } @@ -450,9 +567,8 @@ public enum DriveBufferService { var moved = 0 for id in ids { // A large negative number instead of zero - that is how rclone itself describes it. - let response = try? await CMTooling.runRclone( - ["rc", "--url", rcAddress, "vfs/queue-set-expiry", "id=\(id)", "expiry=-1000000000"], - timeout: 30) + let response = try? await rc( + ["vfs/queue-set-expiry", "id=\(id)", "expiry=-1000000000"], timeout: 30) if response?.succeeded == true { moved += 1 } } return ExpiryOutcome(queued: ids.count, moved: moved) diff --git a/mac-app/Sources/CloudMachineCore/DriveFolder.swift b/mac-app/Sources/CloudMachineCore/DriveFolder.swift index b5b6b45..02f6d6e 100644 --- a/mac-app/Sources/CloudMachineCore/DriveFolder.swift +++ b/mac-app/Sources/CloudMachineCore/DriveFolder.swift @@ -47,7 +47,9 @@ public enum DriveFolder { /// backup lives under the legacy name. Weaker traces were rejected: the /// buffer directory appears after any mount attempt, and the old /// `mount-desired.state` is no longer written by anything. - static func hasLegacyInstallation() async -> Bool { + /// + /// `nil` = rclone did not answer; `decide` then refuses rather than guess. + static func hasLegacyInstallation() async -> Bool? { await RemoteConfigurer.isConfigured(remoteName: DriveBufferService.remoteName) } @@ -65,10 +67,11 @@ public enum DriveFolder { /// - `existing`: the name already stored, if any. /// - `requested`: a name passed with `--folder`, if any. /// - `legacyEvidence`: `hasLegacyInstallation()`, asked before the remote - /// is created. + /// is created. `nil` = not known; that refuses whenever the answer would + /// decide the name. /// - `machineKey`: `MachineIdentity` key, the default for a new Mac. public static func decide( - existing: String?, requested: String?, legacyEvidence: Bool, machineKey: String + existing: String?, requested: String?, legacyEvidence: Bool?, machineKey: String ) -> Decision { if let requested, !isValid(requested) { return .refuse( @@ -82,6 +85,15 @@ public enum DriveFolder { "This Mac already backs up to folder '%@'. Switching to '%@' would start a new, empty backup and orphan the existing one, so nothing was changed.", existing, requested)) } + // Not knowing whether this is an old installation is not "it is a new + // one": a wrong guess gives a backup that lives in `mac-studio` a new, + // empty folder - the very loss this file exists to prevent. + guard let legacyEvidence else { + return .refuse( + L10n.tr( + "Could not check whether this Mac already has a CloudMachine installation (rclone did not answer), so no folder was chosen. Try again in a moment." + )) + } if legacyEvidence { guard let requested, requested != legacyName else { return .assign(legacyName) } return .refuse( diff --git a/mac-app/Sources/CloudMachineCore/L10nPolish+Agent.swift b/mac-app/Sources/CloudMachineCore/L10nPolish+Agent.swift index 9181e09..4c6eef7 100644 --- a/mac-app/Sources/CloudMachineCore/L10nPolish+Agent.swift +++ b/mac-app/Sources/CloudMachineCore/L10nPolish+Agent.swift @@ -187,6 +187,14 @@ extension L10nPolish { "Nazwa folderu tego Maca na Google Drive (domyślnie z nazwy komputera). Ustawiana raz; później nie da się jej zmienić.", "Image attached: %@": "Obraz podpięty: %@", + "Remote control: %@": + "Sterowanie rc: %@", + "private socket": + "prywatne gniazdo", + "OPEN on %@ - the mount was started by an older version, and any web page can send it commands. It switches to the private socket on its next start: run prepare-shutdown, then restart the Mac.": + "OTWARTE na %@ - montowanie uruchomiła starsza wersja i każda strona WWW może wysyłać mu polecenia. Przejdzie na prywatne gniazdo przy następnym starcie: uruchom prepare-shutdown, potem zrestartuj Maca.", + "no socket - the mount is not running": + "brak gniazda - montowanie nie działa", "Cache on disk: %@": "Cache na dysku: %@", "To upload: %@": diff --git a/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift b/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift index a459d60..06c289a 100644 --- a/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift +++ b/mac-app/Sources/CloudMachineCore/L10nPolish+App.swift @@ -131,6 +131,7 @@ extension L10nPolish { "not read": "nie odczytano", "%@ in progress, %@ queued": "%@ w toku, %@ w kolejce", "Everything uploaded": "Wszystko wysłane", + "%@ fragments abandoned - only on this Mac": "%@ fragmentów porzuconych - tylko na tym Macu", "File upload errors": "Błędy wysyłki plików", "Space on Google Drive": "Miejsce na Google Drive", "Out of space": "Brak miejsca", diff --git a/mac-app/Sources/CloudMachineCore/L10nPolish+System.swift b/mac-app/Sources/CloudMachineCore/L10nPolish+System.swift index 5ad8162..ce620c4 100644 --- a/mac-app/Sources/CloudMachineCore/L10nPolish+System.swift +++ b/mac-app/Sources/CloudMachineCore/L10nPolish+System.swift @@ -86,6 +86,12 @@ extension L10nPolish { "Nie można uruchomić %@: %@", "Remote '%@' already exists and was NOT touched.\nOverwriting it replaces the token and the permission scope; a credential with the 'drive.file' scope does not see files created by the previous one, so the existing backup becomes unreachable.\nIf you really want to replace it, first back up ~/.config/rclone/rclone.conf and run again with --replace-existing.": "Remote '%@' już istnieje i NIE został ruszony.\nNadpisanie go podmienia token i zakres uprawnień; poświadczenie z zakresem 'drive.file' nie widzi plików założonych przez poprzednie, więc istniejący backup staje się nieosiągalny.\nJeśli naprawdę chcesz go zastąpić, zrób najpierw kopię ~/.config/rclone/rclone.conf i uruchom ponownie z --replace-existing.", + "Could not check whether remote '%@' already exists (rclone listremotes did not answer or failed), so nothing was changed. Run `%@ listremotes` to see the error, then try again.": + "Nie udało się sprawdzić, czy remote '%@' już istnieje (rclone listremotes nie odpowiedział albo zgłosił błąd), więc nic nie zostało zmienione. Uruchom `%@ listremotes`, żeby zobaczyć błąd, i spróbuj ponownie.", + "Could not check whether this Mac already has a CloudMachine installation (rclone did not answer), so no folder was chosen. Try again in a moment.": + "Nie udało się sprawdzić, czy ten Mac ma już instalację CloudMachine (rclone nie odpowiedział), więc folder nie został wybrany. Spróbuj ponownie za chwilę.", + "The remote control socket path is too long for macOS (%@ characters, at most %@): %@": + "Ścieżka gniazda zdalnego sterowania jest za długa dla macOS (%@ znaków, najwyżej %@): %@", "rclone authorize failed (%@). If that binary is missing, start with: cloudmachine-agent install-rclone.": "rclone authorize nie powiodło się (%@). Jeśli tej binarki nie ma, zacznij od: cloudmachine-agent install-rclone.", "Could not read the token from the output of rclone authorize.": diff --git a/mac-app/Sources/CloudMachineCore/MachineBudget.swift b/mac-app/Sources/CloudMachineCore/MachineBudget.swift index bfa18e3..2e0749a 100644 --- a/mac-app/Sources/CloudMachineCore/MachineBudget.swift +++ b/mac-app/Sources/CloudMachineCore/MachineBudget.swift @@ -28,9 +28,22 @@ public enum MachineBudget { // MARK: - The limit /// The limit of the Mac whose folder is `folder`, if one is set. - public static func limitGB(in config: MachinesConfig, folder: String = DriveFolder.name) -> Int? { - guard let limit = config.limitGB(forMachineKey: folder), limit > 0 else { return nil } - return limit + /// + /// Also under `machineKey`, the way `machines.json` was keyed before the + /// folder became the key. On a Mac set up then, the two differ: on this + /// project's own Mac the entry is `marcin-mac-studio-3` with 3500 GB, while + /// the folder is `mac-studio`. Until 09.10.2026 only the folder was looked + /// up, so the limit was there in the file and nowhere in effect: no usage + /// measurement, no 90% / 100% alarm. The folder's own entry wins - it is the + /// one `set-limit` writes. + public static func limitGB( + in config: MachinesConfig, folder: String = DriveFolder.name, + machineKey: String? = MachineIdentity.storedKey + ) -> Int? { + for key in [folder, machineKey].compactMap({ $0 }) { + if let limit = config.limitGB(forMachineKey: key), limit > 0 { return limit } + } + return nil } public static func limitGB() -> Int? { limitGB(in: ConfigStore.load()) } @@ -45,13 +58,27 @@ public enum MachineBudget { case .missing: config = .empty case .corrupt: throw BudgetError.configUnreadable } - config = withLimit(gb, folder: folder, in: config) + config = withLimit(gb, folder: folder, machineKey: MachineIdentity.storedKey, in: config) try ConfigStore.save(config) CMLogger.log("Space limit for Drive folder '\(folder)' set to \(gb) GB") } - static func withLimit(_ gb: Int, folder: String, in config: MachinesConfig) -> MachinesConfig { + /// Stores the limit under the folder. An old entry under the machine key is + /// moved, not left behind: two entries for one Mac would count twice in + /// `allocatedGB` and could disagree. + static func withLimit( + _ gb: Int, folder: String, machineKey: String? = nil, in config: MachinesConfig + ) -> MachinesConfig { var config = config + if let machineKey, machineKey != folder, + let legacy = config.machines.firstIndex(where: { $0.key == machineKey }) + { + let entry = config.machines.remove(at: legacy) + if !config.machines.contains(where: { $0.key == folder }) { + config.machines.append( + MachineEntry(key: folder, displayName: entry.displayName, limitGB: gb)) + } + } if let index = config.machines.firstIndex(where: { $0.key == folder }) { config.machines[index].limitGB = gb } else { @@ -182,6 +209,33 @@ public enum MachineBudget { } } + /// After this long a measurement no longer describes the folder: the + /// watchdog measures every `measurementMaxAge`, so twice that means the + /// measuring itself has stopped. + public static let usageStaleAfter: TimeInterval = 2 * measurementMaxAge + + /// Where this Mac stands against its limit, for the panel. + public enum Standing: Equatable { + case notSet + /// No measurement, or one too old to say anything about now. + case unmeasured + case ok + case near + case over + } + + public static func standing(limitGB: Int?, usage: Usage?, now: Date = Date()) -> Standing { + guard let limitGB else { return .notSet } + guard let usage, now.timeIntervalSince(usage.measuredAt) <= usageStaleAfter else { + return .unmeasured + } + switch level(usageBytes: usage.bytes, limitGB: limitGB) { + case .ok: return .ok + case .near: return .near + case .over: return .over + } + } + /// Whether the Time Machine quota matches the limit. `nil` quota = none set. public static func quotaMatches(currentQuotaGB: Double?, limitGB: Int) -> Bool { guard let currentQuotaGB else { return false } diff --git a/mac-app/Sources/CloudMachineCore/MachineIdentity.swift b/mac-app/Sources/CloudMachineCore/MachineIdentity.swift index b60a6dd..a8183de 100644 --- a/mac-app/Sources/CloudMachineCore/MachineIdentity.swift +++ b/mac-app/Sources/CloudMachineCore/MachineIdentity.swift @@ -16,6 +16,15 @@ public enum MachineIdentity { /// machines.json, and the whole existing backup under the old key is left /// orphaned (and still counts against the quota). Once written, the key /// survives every later rename of the Mac. + /// The key already written to disk, without deriving one. `nil` = none + /// stored yet. For synchronous readers that only LOOK FOR existing data + /// under the key (see `MachineBudget.limitGB`). + public static var storedKey: String? { + guard let saved = try? String(contentsOf: identityFilePath, encoding: .utf8) else { return nil } + let trimmed = saved.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + public static func currentKey() async -> String { if let saved = try? String(contentsOf: identityFilePath, encoding: .utf8) { let trimmed = saved.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/mac-app/Sources/CloudMachineCore/ProcessRunner.swift b/mac-app/Sources/CloudMachineCore/ProcessRunner.swift index 74c6ae3..b2522fe 100644 --- a/mac-app/Sources/CloudMachineCore/ProcessRunner.swift +++ b/mac-app/Sources/CloudMachineCore/ProcessRunner.swift @@ -59,6 +59,85 @@ final class ContinuationGuard: @unchecked Sendable { } } +/// Reads one pipe of a child process. Lives on the serial queue it is given - +/// every method must be called on that queue, which is what makes the final +/// `finish()` see every byte read before it. +/// +/// A dispatch read source instead of `FileHandle.readabilityHandler`: the +/// handler runs on a Foundation queue of its own, so "read, then hand over" +/// could not be ordered against the process exit. +final class PipeReader: @unchecked Sendable { + private let pipe: Pipe + private let fd: Int32 + private let source: DispatchSourceRead + private var data = Data() + private var discarding = false + private var finished = false + private var closed = false + + init(_ pipe: Pipe, queue: DispatchQueue) { + self.pipe = pipe + fd = pipe.fileHandleForReading.fileDescriptor + // Non-blocking, so that reading "everything there is" stops at an empty + // pipe instead of waiting for EOF - see `terminationHandler`. + _ = fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK) + source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue) + // A strong reference on purpose: the reader has to outlive the call while + // a `--daemon` child still writes into the pipe, and an uncancelled source + // whose handler did nothing would fire on the unread data without end. + // The cycle ends at `cancel()` (EOF), when dispatch drops the handler. + source.setEventHandler { self.drain() } + // The pipe (and with it the descriptor) stays alive until the source is + // cancelled - closing it earlier would leave the source watching a number + // that the next `open` may reuse. + source.setCancelHandler { [pipe] in _ = pipe } + source.resume() + } + + /// Reads whatever is in the pipe right now. + private func drain() { + var buffer = [UInt8](repeating: 0, count: 65536) + while !closed { + let count = read(fd, &buffer, buffer.count) + if count > 0 { + if !discarding { data.append(buffer, count: count) } + } else if count == 0 { + // EOF: every holder of the write end has closed it. + closed = true + source.cancel() + } else if errno == EINTR { + continue + } else { + // EAGAIN - nothing more for now; anything else - nothing more ever. + if errno != EAGAIN { + closed = true + source.cancel() + } + return + } + } + } + + /// The final read after the process has exited, and everything collected. + /// Stops reading only when EOF came: otherwise a child that inherited the + /// pipe (`--daemon`) still writes into it, and with nobody reading, a full + /// pipe would block it on `write()`. What it writes from now on is dropped. + func finish() -> Data { + if !finished { + finished = true + drain() + discarding = true + } + return data + } + + /// The result will not be read (timed out) - keep the pipe empty only. + func discardFromNowOn() { + discarding = true + data = Data() + } +} + /// A thin layer over `Process` for running external tools (rclone, tmutil, /// hdiutil, diskutil...) - shared by the GUI and the CLI. Previously it lived /// only in the GUI as `Shell.run`; moved here so that the CLI watchdogs have @@ -86,28 +165,21 @@ public enum ProcessRunner { process.standardError = stderrPipe process.standardInput = FileHandle.nullDevice + // Everything that touches the collected output runs on this ONE serial + // queue: the reads as data arrives, the final drain and building the + // result. Until 09.10.2026 the reads ran in `readabilityHandler` on a + // queue of their own and only the append came here; `terminationHandler` + // removed the handlers and built the result at once. What was still in + // the pipe, or read but not yet appended, was lost - an EMPTY stdout with + // exit code 0 (measured: 2 in 5000 calls at rest, 970 in 4000 with eight + // at a time). Callers took "" for an answer: `destinationinfo` -> "the + // destination is not registered", `listremotes` -> "there is no remote". let queue = DispatchQueue(label: "com.renacode.cloudmachine.process-pipe") - var stdoutData = Data() - var stderrData = Data() + let stdoutReader = PipeReader(stdoutPipe, queue: queue) + let stderrReader = PipeReader(stderrPipe, queue: queue) let resumeGuard = ContinuationGuard() - stdoutPipe.fileHandleForReading.readabilityHandler = { handle in - let data = handle.availableData - if !data.isEmpty { - queue.async { stdoutData.append(data) } - } - } - stderrPipe.fileHandleForReading.readabilityHandler = { handle in - let data = handle.availableData - if !data.isEmpty { - queue.async { stderrData.append(data) } - } - } - process.terminationHandler = { proc in - stdoutPipe.fileHandleForReading.readabilityHandler = nil - stderrPipe.fileHandleForReading.readabilityHandler = nil - // IMPORTANT: readDataToEndOfFile() must NOT be called here - it blocks // until the write end of the pipe is closed by ALL of its holders. // Processes started with "--daemon" (e.g. `rclone nfsmount --daemon`) @@ -115,13 +187,15 @@ public enum ProcessRunner { // closes them - the terminationHandler of the immediate parent process // fires normally, but readDataToEndOfFile() then hangs forever, // because EOF never arrives (observed for real: a watchdog stuck for - // >10 min after every fresh rclone start). `readabilityHandler` already - // collects everything as the data arrives - no extra, blocking final - // read is needed. + // >10 min after every fresh rclone start). + // + // The final drain is a NON-BLOCKING read instead: the process has + // exited, so everything it wrote is already in the pipe, and the read + // stops at "nothing more right now" rather than at EOF. queue.async { let result = ProcessResult( - stdout: String(data: stdoutData, encoding: .utf8) ?? "", - stderr: String(data: stderrData, encoding: .utf8) ?? "", + stdout: String(data: stdoutReader.finish(), encoding: .utf8) ?? "", + stderr: String(data: stderrReader.finish(), encoding: .utf8) ?? "", exitCode: proc.terminationStatus ) if resumeGuard.claim() { @@ -168,22 +242,19 @@ public enum ProcessRunner { // double resume if `terminationHandler` fires later). DispatchQueue.global().asyncAfter(deadline: .now() + timeout + 10) { if resumeGuard.claim() { - // IMPORTANT: we do NOT reset the handler to `nil` - that leaves the - // pipe with NO reader at all. If the process survived even SIGKILL - // (stuck in the kernel in uninterruptible I/O - see the comment - // above) and does resume some day, it may still write to - // stdout/stderr; without a reader, a full pipe buffer would block - // it on `write()` FOREVER, turning "a harmless orphaned process" - // into a permanently stuck zombie that never gets cleaned up. So we - // replace the handler with one that keeps draining and discarding - // the data - the result will not be read anyway (the continuation - // below resumes with the timeout error), but the orphaned process - // can freely finish writing and exit on its own. - stdoutPipe.fileHandleForReading.readabilityHandler = { handle in - _ = handle.availableData - } - stderrPipe.fileHandleForReading.readabilityHandler = { handle in - _ = handle.availableData + // IMPORTANT: we do NOT stop reading - that leaves the pipe with NO + // reader at all. If the process survived even SIGKILL (stuck in the + // kernel in uninterruptible I/O - see the comment above) and does + // resume some day, it may still write to stdout/stderr; without a + // reader, a full pipe buffer would block it on `write()` FOREVER, + // turning "a harmless orphaned process" into a permanently stuck + // zombie that never gets cleaned up. So the readers keep draining + // and discard the data - the result will not be read anyway (the + // continuation below resumes with the timeout error), but the + // orphaned process can freely finish writing and exit on its own. + queue.async { + stdoutReader.discardFromNowOn() + stderrReader.discardFromNowOn() } continuation.resume(throwing: ProcessRunnerError.timedOut(executable)) } diff --git a/mac-app/Sources/CloudMachineCore/RemoteConfigurer.swift b/mac-app/Sources/CloudMachineCore/RemoteConfigurer.swift index e32489a..da5920a 100644 --- a/mac-app/Sources/CloudMachineCore/RemoteConfigurer.swift +++ b/mac-app/Sources/CloudMachineCore/RemoteConfigurer.swift @@ -12,11 +12,26 @@ public enum RemoteConfigurer { /// read the same configuration file, but the rest of the system runs on ours /// - and the state shown to the user must describe what we really use, not /// an incidental second installation that may one day not be there. - public static func isConfigured(remoteName: String) async -> Bool { - guard let result = try? await CMTooling.runRclone(["listremotes"], timeout: 30) else { - return false + /// + /// `nil` = rclone DID NOT ANSWER (time limit, a failed start, an unreadable + /// or broken `rclone.conf` - which rclone reports with exit code 1). Until + /// 09.10.2026 all of that came out as `false`, i.e. "there is no remote", + /// and the two guards built on this answer opened on it: `connect` went on + /// to overwrite the working token, and `DriveFolder` took an installation + /// whose backup lives in `mac-studio` for a new Mac and gave it a new, empty + /// folder. + public static func isConfigured(remoteName: String) async -> Bool? { + remoteListed(remoteName, in: try? await CMTooling.runRclone(["listremotes"], timeout: 30)) + } + + /// Pure reading of `rclone listremotes`, so the "no answer" cases can be + /// tested. One remote per line, as `name:`; a whole line must match, so that + /// `mygdrive:` is not taken for `gdrive:`. + static func remoteListed(_ remoteName: String, in result: ProcessResult?) -> Bool? { + guard let result, result.succeeded else { return nil } + return result.stdout.split(whereSeparator: \.isNewline).contains { + $0.trimmingCharacters(in: .whitespaces) == "\(remoteName):" } - return result.stdout.contains("\(remoteName):") } /// Keychain service under which the own OAuth credentials are stored. @@ -79,7 +94,18 @@ public enum RemoteConfigurer { // backup image, created by the previous credential, then becomes // invisible and the mount stops finding it. The backup is intact, but // inaccessible, which in practice means the same thing. - if !replaceExisting, await isConfigured(remoteName: remoteName) { + let configured = await isConfigured(remoteName: remoteName) + // "Could not check" stops here as firmly as "it exists": both mean we do + // not know that there is nothing to overwrite. Even with + // `--replace-existing` - the folder decision below needs this answer too. + guard let configured else { + return CMActionResult( + succeeded: false, + message: L10n.tr( + "Could not check whether remote '%@' already exists (rclone listremotes did not answer or failed), so nothing was changed. Run `%@ listremotes` to see the error, then try again.", + remoteName, CMTooling.managedRclonePath.path)) + } + if !replaceExisting, configured { return CMActionResult( succeeded: false, message: L10n.tr( diff --git a/mac-app/Sources/CloudMachineCore/StatusLines.swift b/mac-app/Sources/CloudMachineCore/StatusLines.swift index 9c6832a..0ed58d1 100644 --- a/mac-app/Sources/CloudMachineCore/StatusLines.swift +++ b/mac-app/Sources/CloudMachineCore/StatusLines.swift @@ -26,6 +26,27 @@ public enum StatusLines { } } + /// The "Remote control" line. + /// + /// A mount started before 09.10.2026 keeps listening on TCP with no password + /// until it is restarted, and an upgrade deliberately does not restart it. + /// Until then any web page can delete the backup through it - which is why + /// that state is named, with the way out, instead of quietly working. + public static func remoteControl(_ transport: DriveBufferService.RCTransport, mounted: Bool?) + -> String + { + switch transport { + case .socket: + return L10n.tr("private socket") + case .legacyTCP where mounted == true: + return L10n.tr( + "OPEN on %@ - the mount was started by an older version, and any web page can send it commands. It switches to the private socket on its next start: run prepare-shutdown, then restart the Mac.", + DriveBufferService.legacyRCAddress) + case .legacyTCP: + return L10n.tr("no socket - the mount is not running") + } + } + /// The "Free on disk" line. /// /// `nil` MUST be named. Not `Optional(427)` (because that looks like a diff --git a/mac-app/Sources/CloudMachineCore/TimeMachineStatus.swift b/mac-app/Sources/CloudMachineCore/TimeMachineStatus.swift index 43be8a7..6e45470 100644 --- a/mac-app/Sources/CloudMachineCore/TimeMachineStatus.swift +++ b/mac-app/Sources/CloudMachineCore/TimeMachineStatus.swift @@ -59,7 +59,13 @@ public enum TimeMachineStatus { do { let result = try await ProcessRunner.run( "/usr/bin/tmutil", arguments, timeout: commandTimeout) - return result.stdout + guard let answer = answer(from: result) else { + CMLogger.log( + "tmutil \(arguments.joined(separator: " ")): NO ANSWER - exit code \(result.exitCode), \(result.stdout.isEmpty ? "empty output" : "\(result.stdout.utf8.count) B of output")" + ) + return nil + } + return answer } catch { CMLogger.log( "tmutil \(arguments.joined(separator: " ")): NO ANSWER - \(error.localizedDescription)" @@ -68,6 +74,28 @@ public enum TimeMachineStatus { } } + /// Which `tmutil` results count as an answer. Pure, so it can be tested. + /// + /// Until 09.10.2026 the exit code was not looked at at all, and an EMPTY + /// stdout was parsed like any other: `destinationinfo` -> `.none`, i.e. the + /// "destination not registered" alarm; `status` -> "no backup in progress". + /// Both are answers about Time Machine that tmutil never gave. Every + /// subcommand used here prints something when it works - even with no + /// destination there is the sentence below - so "nothing" is not an answer. + /// + /// The one failure that IS an answer: with no destination registered, + /// `destinationinfo` says so in words, and we do not rely on which exit + /// code it pairs that with across macOS versions. + static func answer(from result: ProcessResult) -> String? { + if (result.stdout + result.stderr).contains("No destinations configured") { + return result.stdout + result.stderr + } + guard result.succeeded, + !result.stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return nil } + return result.stdout + } + /// Whether a backup is in progress. `nil` = tmutil did not answer, i.e. /// UNKNOWN. /// diff --git a/mac-app/Tests/CloudMachineAppTests/AppStatusHealthTests.swift b/mac-app/Tests/CloudMachineAppTests/AppStatusHealthTests.swift index 69c0e72..4042e3f 100644 --- a/mac-app/Tests/CloudMachineAppTests/AppStatusHealthTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/AppStatusHealthTests.swift @@ -55,6 +55,60 @@ final class AppStatusHealthTests: XCTestCase { XCTAssertEqual(status.buffer.uploadState, .queueUnknown) } + // MARK: - "Back up now" + + /// REGRESSION 09.10.2026: the button followed `healthy`, so an OLD backup - + /// the very thing it fixes - greyed it out. + func testStaleBackupStillAllowsBackingUp() { + let status = healthy() + status.backupCycle = BackupCycleStatus( + known: true, lastSuccess: Date().addingTimeInterval(-2 * 86400), problems: [], + checkedAt: Date()) + XCTAssertFalse(status.healthy) + XCTAssertTrue(status.canStartBackup) + } + + func testUnreadQueueStillAllowsBackingUp() { + let status = healthy() + status.buffer.queueKnown = false + XCTAssertTrue(status.canStartBackup) + } + + func testNowhereToBackUpToBlocksTheButton() { + for breakIt in [ + { (s: AppStatus) in s.buffer.mounted = false }, + { (s: AppStatus) in s.buffer.imageAttached = false }, + { (s: AppStatus) in s.timeMachineState = .notRegistered }, + { (s: AppStatus) in s.timeMachineState = .noAnswer }, + { (s: AppStatus) in s.remoteConfigured = false }, + { (s: AppStatus) in s.isBusy = true }, + ] { + let status = healthy() + breakIt(status) + XCTAssertFalse(status.canStartBackup) + } + } + + // MARK: - "Cloud sync queue" row + + /// REGRESSION 09.10.2026: an empty queue with abandoned bands read + /// "Everything uploaded" - for data that exists only on this Mac. + func testAbandonedBandsAreNotEverythingUploaded() { + var buffer = healthy().buffer + buffer.erroredFiles = 3 + XCTAssertNotEqual(buffer.queueSummary, L10n.tr("Everything uploaded")) + XCTAssertTrue(buffer.queueSummary.contains("3")) + } + + func testQueueRowStates() { + var buffer = healthy().buffer + XCTAssertEqual(buffer.queueSummary, L10n.tr("Everything uploaded")) + buffer.uploadsQueued = 5 + XCTAssertEqual(buffer.queueSummary, L10n.tr("%@ in progress, %@ queued", "0", "5")) + buffer.queueKnown = false + XCTAssertEqual(buffer.queueSummary, L10n.tr("not read")) + } + func testHealthyStateIsHealthy() { let status = healthy() XCTAssertTrue(status.healthy) diff --git a/mac-app/Tests/CloudMachineAppTests/BackupHealthTests.swift b/mac-app/Tests/CloudMachineAppTests/BackupHealthTests.swift index b60cf42..b951bc7 100644 --- a/mac-app/Tests/CloudMachineAppTests/BackupHealthTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/BackupHealthTests.swift @@ -250,12 +250,62 @@ final class BackupHealthTests: XCTestCase { /// An unreadable file must NOT look like a healthy cycle. func testUnreadableFileDoesNotPassForSuccess() async { let report = await BackupHealth.currentReport( - preferencesFile: "/no/such/file.plist") + preferencesFile: "/no/such/file.plist", preferencesRetryPause: 0) XCTAssertFalse(report.healthy) XCTAssertTrue( report.problems.contains { $0.summary.contains("Time Machine preferences") }) } + // MARK: - Confirming an unreadable file + + /// REGRESSION 08.10.2026 19:52: one failed read during a backup gave "most + /// often Full Disk Access is missing", with the permission in place. + func testOneFailedReadIsNotAnAlarm() async { + var reads = 0 + var pauses = 0 + let plist = await BackupHealth.readPreferences( + { + reads += 1 + return reads == 1 ? nil : ["ok": true] + }, pause: { pauses += 1 }) + XCTAssertNotNil(plist) + XCTAssertEqual(reads, 2) + XCTAssertEqual(pauses, 1) + } + + /// A missing permission fails every read - the alarm still comes, after the + /// same number of attempts every time. + func testEveryReadFailingIsStillAnAlarm() async { + var reads = 0 + let plist = await BackupHealth.readPreferences( + { + reads += 1 + return nil + }, pause: {}) + XCTAssertNil(plist) + XCTAssertEqual(reads, BackupHealth.preferencesReadAttempts) + XCTAssertGreaterThan(BackupHealth.preferencesReadAttempts, 1) + } + + func testPanelNeedsTwoFailedReadsInARow() { + var confirmation = BackupHealth.ReadConfirmation() + var shown = confirmation.readable(after: true, shown: false) + XCTAssertTrue(shown) + shown = confirmation.readable(after: false, shown: shown) + XCTAssertTrue(shown, "one failed read flipped the panel to 'no Full Disk Access'") + shown = confirmation.readable(after: true, shown: shown) + shown = confirmation.readable(after: false, shown: shown) + XCTAssertTrue(shown, "failures that are not in a row must not add up") + shown = confirmation.readable(after: false, shown: shown) + XCTAssertFalse(shown) + } + + /// At launch nothing has been read yet - a missing permission must show at once. + func testPanelStartsFromMissing() { + var confirmation = BackupHealth.ReadConfirmation() + XCTAssertFalse(confirmation.readable(after: false, shown: false)) + } + // MARK: - Reporting /// A message with a quote must get through AppleScript without breaking the diff --git a/mac-app/Tests/CloudMachineAppTests/DriveFolderTests.swift b/mac-app/Tests/CloudMachineAppTests/DriveFolderTests.swift index 1033567..5ea4a1b 100644 --- a/mac-app/Tests/CloudMachineAppTests/DriveFolderTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/DriveFolderTests.swift @@ -35,6 +35,28 @@ final class DriveFolderTests: XCTestCase { else { return XCTFail("a legacy installation was moved to a new, empty folder") } } + /// REGRESSION 09.10.2026: `rclone listremotes` timing out or failing used to + /// read as "no remote", i.e. "a new Mac" - and this Mac, whose backup lives + /// in `mac-studio`, would have been given its machine key as a new, empty + /// folder. + func testUnknownLegacyEvidenceRefusesInsteadOfGuessing() { + for requested in [nil, "mac-studio", "marcin-mac-studio-3"] { + guard + case .refuse = DriveFolder.decide( + existing: nil, requested: requested, legacyEvidence: nil, + machineKey: "marcin-mac-studio-3") + else { return XCTFail("chose a folder without knowing whether a backup already exists") } + } + } + + /// A stored name does not need the answer at all. + func testUnknownLegacyEvidenceDoesNotMatterOnceStored() { + XCTAssertEqual( + DriveFolder.decide( + existing: "macbook-pro", requested: nil, legacyEvidence: nil, machineKey: "x"), + .keep("macbook-pro")) + } + func testStoredFolderIsKept() { XCTAssertEqual( DriveFolder.decide( diff --git a/mac-app/Tests/CloudMachineAppTests/DriveLayerTests.swift b/mac-app/Tests/CloudMachineAppTests/DriveLayerTests.swift index 472b6c2..97ee0f7 100644 --- a/mac-app/Tests/CloudMachineAppTests/DriveLayerTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/DriveLayerTests.swift @@ -98,6 +98,146 @@ final class DriveLayerTests: XCTestCase { XCTAssertTrue(args.contains("--rc")) } + // MARK: - Remote control interface + + /// REGRESSION 09.10.2026: the interface listened on 127.0.0.1:5572 with no + /// password, so any web page could POST `operations/purge` on the backup + /// folder. It must listen on the private socket and nowhere else. + func testRemoteControlListensOnlyOnThePrivateSocket() { + let args = DriveBufferService.mountArguments() + func value(_ flag: String) -> String? { + args.firstIndex(of: flag).map { args[$0 + 1] } + } + XCTAssertEqual(value("--rc-addr"), "unix://\(DriveBufferService.rcSocket.path)") + XCTAssertEqual( + DriveBufferService.rcSocket.deletingLastPathComponent().path, DriveBufferService.runDir.path) + XCTAssertFalse( + args.contains { + $0.contains("127.0.0.1") || $0.contains(":5572") || $0.contains("localhost") + }, + "the mount still listens on TCP: \(args)") + XCTAssertEqual(args.filter { $0 == "--rc-addr" }.count, 1) + } + + /// The clients must go where the server listens; the old address only while + /// a mount from before the socket is still running. + func testClientsFollowTheMount() { + XCTAssertEqual( + DriveBufferService.rcClientArguments(.socket), + ["--unix-socket", DriveBufferService.rcSocket.path]) + XCTAssertEqual(DriveBufferService.rcClientArguments(.legacyTCP), ["--url", "127.0.0.1:5572"]) + } + + /// rclone cannot bind a longer path - the mount would never start. + func testSocketPathFitsMacOS() { + XCTAssertLessThanOrEqual( + DriveBufferService.rcSocket.path.utf8.count, DriveBufferService.socketPathLimit) + } + + /// The source has one way to the interface. A call built by hand would keep + /// talking to the old address, i.e. a watchdog blind after the next restart. + func testNoCallerBuildsItsOwnRCAddress() throws { + let sources = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("Sources") + let files = FileManager.default.enumerator(at: sources, includingPropertiesForKeys: nil)! + .compactMap { $0 as? URL }.filter { $0.pathExtension == "swift" } + XCTAssertFalse(files.isEmpty) + for file in files { + let text = try String(contentsOf: file, encoding: .utf8) + for line in text.split(separator: "\n") + where !line.trimmingCharacters(in: .whitespaces).hasPrefix("//") { + XCTAssertFalse( + line.contains("[\"rc\", "), + "\(file.lastPathComponent): rclone rc called directly: \(line)") + } + } + } + + private func shortTempDir() throws -> URL { + // A socket path must stay under 104 bytes, so not the long scratch paths. + let dir = URL(fileURLWithPath: "/tmp/cm-\(UUID().uuidString.prefix(8))") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private func bindSocket(_ path: String, listening: Bool) -> Int32 { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let bytes = Array(path.utf8) + withUnsafeMutableBytes(of: &address.sun_path) { raw in + raw.copyBytes(from: bytes) + raw[bytes.count] = 0 + } + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + XCTAssertEqual(bound, 0) + if listening { XCTAssertEqual(Darwin.listen(fd, 1), 0) } + return fd + } + + /// A socket left by an rclone that crashed makes the next start fail with + /// "address already in use" - under KeepAlive, a mount that never returns. + func testStaleSocketIsRemovedBeforeStart() throws { + let base = try shortTempDir() + defer { try? FileManager.default.removeItem(at: base) } + let run = base.appendingPathComponent("run") + let sock = run.appendingPathComponent("rc.sock") + try FileManager.default.createDirectory(at: run, withIntermediateDirectories: true) + close(bindSocket(sock.path, listening: false)) + XCTAssertTrue(FileManager.default.fileExists(atPath: sock.path)) + + try DriveBufferService.prepareRCSocketDirectory(run, socket: sock) + XCTAssertFalse(FileManager.default.fileExists(atPath: sock.path)) + } + + /// A socket someone listens on belongs to a running rclone - not ours to take. + func testLiveSocketIsLeftAlone() throws { + let base = try shortTempDir() + defer { try? FileManager.default.removeItem(at: base) } + let run = base.appendingPathComponent("run") + let sock = run.appendingPathComponent("rc.sock") + try FileManager.default.createDirectory(at: run, withIntermediateDirectories: true) + let fd = bindSocket(sock.path, listening: true) + defer { close(fd) } + + try DriveBufferService.prepareRCSocketDirectory(run, socket: sock) + XCTAssertTrue(FileManager.default.fileExists(atPath: sock.path)) + } + + /// Other users must not reach the socket - also when the directory existed + /// with looser permissions. + func testSocketDirectoryIsPrivate() throws { + let base = try shortTempDir() + defer { try? FileManager.default.removeItem(at: base) } + let run = base.appendingPathComponent("run") + try FileManager.default.createDirectory( + at: run, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o755]) + + try DriveBufferService.prepareRCSocketDirectory( + run, socket: run.appendingPathComponent("rc.sock")) + let mode = try FileManager.default.attributesOfItem(atPath: run.path)[.posixPermissions] as? Int + XCTAssertEqual(mode, 0o700) + } + + func testTooLongSocketPathStopsTheStart() { + let run = URL(fileURLWithPath: "/tmp/" + String(repeating: "x", count: 120)) + XCTAssertThrowsError( + try DriveBufferService.prepareRCSocketDirectory( + run, socket: run.appendingPathComponent("rc.sock"))) + } + + func testStatusNamesTheOldOpenInterface() { + XCTAssertTrue( + StatusLines.remoteControl(.legacyTCP, mounted: true).contains("OPEN")) + XCTAssertFalse(StatusLines.remoteControl(.socket, mounted: true).contains("OPEN")) + XCTAssertFalse(StatusLines.remoteControl(.legacyTCP, mounted: false).contains("OPEN")) + } + /// 02.10.2026: change notifications from Drive (every minute by default) /// invalidated the `bands` directory after every upload of our own, and /// reloading it held the lock for ~42 s - the whole mount stood still every diff --git a/mac-app/Tests/CloudMachineAppTests/MachineBudgetTests.swift b/mac-app/Tests/CloudMachineAppTests/MachineBudgetTests.swift index 0faf1fe..fe8a88f 100644 --- a/mac-app/Tests/CloudMachineAppTests/MachineBudgetTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/MachineBudgetTests.swift @@ -82,4 +82,69 @@ final class MachineBudgetTests: XCTestCase { MachineBudget.summary(limitGB: 1000, usage: .init(bytes: 589 * gib, measuredAt: Date())), "589 of 1000 GB used (59%)") } + + // MARK: - Entries from before the folder was the key + + private func productionLikeConfig() -> MachinesConfig { + var config = MachinesConfig.empty + config.machines = [ + MachineEntry(key: "marcin-mac-studio-3", displayName: "Marcin Mac Studio 3", limitGB: 3500) + ] + return config + } + + /// REGRESSION 09.10.2026: the limit stood in machines.json under the machine + /// key and was looked up only under the folder - so it did nothing. + func testLimitUnderTheMachineKeyCounts() { + XCTAssertEqual( + MachineBudget.limitGB( + in: productionLikeConfig(), folder: "mac-studio", machineKey: "marcin-mac-studio-3"), + 3500) + } + + func testFolderEntryWinsOverTheMachineKey() { + var config = productionLikeConfig() + config.machines.append(MachineEntry(key: "mac-studio", displayName: "x", limitGB: 1000)) + XCTAssertEqual( + MachineBudget.limitGB(in: config, folder: "mac-studio", machineKey: "marcin-mac-studio-3"), + 1000) + } + + func testAnotherMacsEntryIsNotThisMacsLimit() { + XCTAssertNil( + MachineBudget.limitGB(in: productionLikeConfig(), folder: "imac", machineKey: "imac")) + XCTAssertNil(MachineBudget.limitGB(in: productionLikeConfig(), folder: "imac", machineKey: nil)) + } + + /// Setting the limit moves the old entry, so one Mac is not counted twice. + func testSettingTheLimitMovesTheOldEntry() { + let config = MachineBudget.withLimit( + 3000, folder: "mac-studio", machineKey: "marcin-mac-studio-3", in: productionLikeConfig()) + XCTAssertEqual(config.machines.map(\.key), ["mac-studio"]) + XCTAssertEqual(config.machines.first?.limitGB, 3000) + XCTAssertEqual(config.machines.first?.displayName, "Marcin Mac Studio 3") + XCTAssertEqual(config.allocatedGB, 3000) + } + + // MARK: - Standing for the panel + + /// REGRESSION 09.10.2026: "usage not measured yet" was shown green, and a + /// measurement from three days before passed for a current one. + func testNoOrOldMeasurementIsNotGreen() { + let now = Date() + XCTAssertEqual(MachineBudget.standing(limitGB: 3500, usage: nil, now: now), .unmeasured) + let old = MachineBudget.Usage(bytes: 10 * gib, measuredAt: now.addingTimeInterval(-3 * 86400)) + XCTAssertEqual(MachineBudget.standing(limitGB: 3500, usage: old, now: now), .unmeasured) + } + + func testRecentMeasurementIsJudged() { + let now = Date() + func usage(_ gb: UInt64) -> MachineBudget.Usage { + .init(bytes: gb * gib, measuredAt: now.addingTimeInterval(-3600)) + } + XCTAssertEqual(MachineBudget.standing(limitGB: 1000, usage: usage(500), now: now), .ok) + XCTAssertEqual(MachineBudget.standing(limitGB: 1000, usage: usage(950), now: now), .near) + XCTAssertEqual(MachineBudget.standing(limitGB: 1000, usage: usage(1100), now: now), .over) + XCTAssertEqual(MachineBudget.standing(limitGB: nil, usage: usage(1100), now: now), .notSet) + } } diff --git a/mac-app/Tests/CloudMachineAppTests/ProcessRunnerOutputTests.swift b/mac-app/Tests/CloudMachineAppTests/ProcessRunnerOutputTests.swift new file mode 100644 index 0000000..fb1439c --- /dev/null +++ b/mac-app/Tests/CloudMachineAppTests/ProcessRunnerOutputTests.swift @@ -0,0 +1,71 @@ +import XCTest + +@testable import CloudMachineCore + +/// REGRESSION 09.10.2026: `ProcessRunner` sometimes returned an EMPTY stdout +/// with exit code 0 - measured 2 in 5000 and 1 in 2000 calls of `/bin/cat`. +/// `terminationHandler` removed the readability handlers before the last data +/// in the pipe had been read, and an append still on its way to the serial +/// queue could land after the result had been built. Callers took "" for an +/// answer: `destinationinfo` -> "destination not registered", `listremotes` +/// -> "no remote", which is the way to overwriting a working token. +final class ProcessRunnerOutputTests: XCTestCase { + private var fixture: URL! + private var expected = "" + + override func setUpWithError() throws { + // A few kB, several lines - the size of a `tmutil destinationinfo` or + // `rclone listremotes` answer that the callers parse. + expected = (0..<120).map { "line \($0) of the expected output\n" }.joined() + fixture = FileManager.default.temporaryDirectory + .appendingPathComponent("cm-process-runner-\(UUID().uuidString).txt") + try expected.write(to: fixture, atomically: true, encoding: .utf8) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: fixture) + } + + /// Many short processes, several at a time - the load under which the race + /// shows up. On the old code this loses whole outputs; every call must return + /// exactly what `cat` wrote. + func testShortProcessNeverLosesItsOutput() async throws { + let path = fixture.path + let want = expected + let lost = try await withThrowingTaskGroup(of: Int.self) { group in + for _ in 0..<8 { + group.addTask { + var bad = 0 + for _ in 0..<500 { + let result = try await ProcessRunner.run("/bin/cat", [path], timeout: 30) + if result.succeeded && result.stdout != want { bad += 1 } + } + return bad + } + } + return try await group.reduce(0, +) + } + XCTAssertEqual(lost, 0, "\(lost) of 4000 calls ended with code 0 and incomplete stdout") + } + + /// stderr goes through the same path and must not be cut short either. + func testStderrIsCompleteToo() async throws { + for _ in 0..<200 { + let result = try await ProcessRunner.run( + "/bin/sh", ["-c", "cat \"$0\" >&2; exit 3", fixture.path], timeout: 30) + XCTAssertEqual(result.exitCode, 3) + XCTAssertEqual(result.stderr, expected) + } + } + + /// The reason the final read must not wait for EOF: a child that inherits + /// stdout (`rclone ... --daemon`) keeps the pipe open after the parent has + /// exited. The result must come back right away, with what the parent wrote. + func testInheritedPipeDoesNotHoldTheResult() async throws { + let started = Date() + let result = try await ProcessRunner.run( + "/bin/sh", ["-c", "echo parent; /bin/sleep 20 & exit 0"], timeout: 60) + XCTAssertEqual(result.stdout, "parent\n") + XCTAssertLessThan(Date().timeIntervalSince(started), 5) + } +} diff --git a/mac-app/Tests/CloudMachineAppTests/RemoteConfigurerTests.swift b/mac-app/Tests/CloudMachineAppTests/RemoteConfigurerTests.swift index 03a7451..429b58b 100644 --- a/mac-app/Tests/CloudMachineAppTests/RemoteConfigurerTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/RemoteConfigurerTests.swift @@ -19,4 +19,37 @@ final class RemoteConfigurerTests: XCTestCase { func testExtractToken_onlyStartMarker() { XCTAssertNil(RemoteConfigurer.extractToken(from: "text ---> rest without an end")) } + + // MARK: - Is the remote there + + /// REGRESSION 09.10.2026: no answer from rclone read as "no remote", and + /// `connect` went on to overwrite the working token. + func testNoAnswerIsUnknownNotAbsent() { + XCTAssertNil(RemoteConfigurer.remoteListed("gdrive", in: nil)) + } + + /// An unreadable or broken `rclone.conf` makes rclone print nothing on + /// stdout and exit with 1 (checked on rclone 1.75.1). + func testFailedListremotesIsUnknown() { + let result = ProcessResult( + stdout: "", stderr: "CRITICAL: Failed to load config file: permission denied", exitCode: 1) + XCTAssertNil(RemoteConfigurer.remoteListed("gdrive", in: result)) + } + + func testListedRemoteIsFound() { + let result = ProcessResult(stdout: "other:\ngdrive:\n", stderr: "", exitCode: 0) + XCTAssertEqual(RemoteConfigurer.remoteListed("gdrive", in: result), true) + } + + func testOnlyAWholeLineMatches() { + let result = ProcessResult(stdout: "mygdrive:\n", stderr: "", exitCode: 0) + XCTAssertEqual(RemoteConfigurer.remoteListed("gdrive", in: result), false) + } + + func testAnsweredWithoutTheRemoteIsAbsent() { + XCTAssertEqual( + RemoteConfigurer.remoteListed( + "gdrive", in: ProcessResult(stdout: "", stderr: "", exitCode: 0)), + false) + } } diff --git a/mac-app/Tests/CloudMachineAppTests/TimeMachineStatusTests.swift b/mac-app/Tests/CloudMachineAppTests/TimeMachineStatusTests.swift index 287e494..80c8e02 100644 --- a/mac-app/Tests/CloudMachineAppTests/TimeMachineStatusTests.swift +++ b/mac-app/Tests/CloudMachineAppTests/TimeMachineStatusTests.swift @@ -121,4 +121,40 @@ final class TimeMachineStatusTests: XCTestCase { TimeMachineStatus.allDestinationIDs(destinationInfoOutput: twoDestinationsInfo), ["DEAD2007-8BC5-4D7B-BCF3-A5646B636CCD", "C4B0056F-6CE8-486E-8726-75B45E2E7A56"]) } + + // MARK: - What counts as an answer + + /// REGRESSION 09.10.2026: an empty stdout (lost by `ProcessRunner`, or from a + /// failed tmutil) was parsed as "no destination" and raised the "destination + /// changed" alarm. + func testEmptyOutputIsNoAnswer() { + XCTAssertNil(TimeMachineStatus.answer(from: ProcessResult(stdout: "", stderr: "", exitCode: 0))) + XCTAssertNil( + TimeMachineStatus.answer(from: ProcessResult(stdout: "\n", stderr: "", exitCode: 0))) + } + + func testFailedTmutilIsNoAnswerEvenWithOutput() { + XCTAssertNil( + TimeMachineStatus.answer( + from: ProcessResult(stdout: idleStatus, stderr: "tmutil: error", exitCode: 1))) + } + + func testWorkingTmutilIsAnAnswer() { + XCTAssertEqual( + TimeMachineStatus.answer( + from: ProcessResult(stdout: twoDestinationsInfo, stderr: "", exitCode: 0)), + twoDestinationsInfo) + } + + /// "No destination" is an answer whatever exit code tmutil pairs it with - + /// otherwise a Mac without a destination would read as "tmutil is silent". + func testNoDestinationsSentenceIsAnAnswer() { + for code: Int32 in [0, 1] { + let answer = TimeMachineStatus.answer( + from: ProcessResult( + stdout: "tmutil: No destinations configured.\n", stderr: "", exitCode: code)) + XCTAssertNotNil(answer) + XCTAssertNil(TimeMachineStatus.currentDestinationMountPoint(destinationInfoOutput: answer!)) + } + } }