Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions Strand/Collect/RawHistoryArchive.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,38 @@ struct RawHistoryArchive {
VersionKey(family: family.rawValue, version: version)
}

#if os(iOS)
/// iOS files default to `NSFileProtectionComplete`, which makes them cryptographically UNREADABLE
/// while the device is locked. This archive lives outside `OpenWhoop`'s protected App Support tree
/// (see `StorePaths.defaultDatabasePath()`, #222) and did NOT inherit that store's protection
/// downgrade — so `archiveRejectedFrames` throws while locked, and `BLEManager` treats that as
/// `.failed`, holding the trim ack so the strap re-sends the same chunk in a loop (#649). Mirrors
/// `StorePaths.swift` exactly: drop to `completeUntilFirstUserAuthentication` (readable after the
/// first unlock-since-boot — the correct level for background BLE collection — and still encrypted
/// at rest) on the directory (so files created afterward inherit it) AND on the archive file itself
/// (for a file that already existed before this fix shipped).
private func applyDataProtection() {
let protection: [FileAttributeKey: Any] =
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication]
try? FileManager.default.setAttributes(protection, ofItemAtPath: directory.path)
let path = fileURL.path
if FileManager.default.fileExists(atPath: path) {
try? FileManager.default.setAttributes(protection, ofItemAtPath: path)
}
}
#endif

/// Creates `directory` if needed and (on iOS) applies the data-protection downgrade described in
/// `applyDataProtection` to it — and to the archive file if one already exists there. Callers that
/// go on to CREATE a new file must call `applyDataProtection()` again afterward so the freshly
/// written file (which iOS would otherwise default to `NSFileProtectionComplete`) is covered too.
private func ensureProtectedDirectory() throws {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
#if os(iOS)
applyDataProtection()
#endif
}

/// Durably append `frames` as JSONL. `trim`/`family` tag each line so the corpus is replayable.
/// Empty input is a no-op success. See `Result` for the ack contract.
///
Expand Down Expand Up @@ -138,8 +170,11 @@ struct RawHistoryArchive {
let kept = RawHistoryArchive.evictLines(existing + newLines, maxBytes: maxBytes, floor: perVersionFloor)
let data = Data(kept.joined().utf8)
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try ensureProtectedDirectory()
try data.write(to: url, options: .atomic) // atomic rewrite; durable before the ack
#if os(iOS)
applyDataProtection() // the rewrite may have (re)created the file — cover it too
#endif
return .written(count: frames.count)
} catch {
return .failed
Expand All @@ -148,7 +183,7 @@ struct RawHistoryArchive {

/// Append `data` to `url`, fsyncing before returning so it is durable BEFORE the trim ack.
private func appendDurably(_ data: Data, to url: URL) throws {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try ensureProtectedDirectory()
if FileManager.default.fileExists(atPath: url.path) {
let handle = try FileHandle(forWritingTo: url)
defer { try? handle.close() }
Expand All @@ -157,6 +192,9 @@ struct RawHistoryArchive {
try handle.synchronize() // durable BEFORE the ack — the point of the archive
} else {
try data.write(to: url, options: .atomic)
#if os(iOS)
applyDataProtection() // brand-new file — cover it since it defaulted to Complete
#endif
}
}

Expand Down
50 changes: 50 additions & 0 deletions StrandTests/RawHistoryArchiveDataProtectionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import XCTest
@testable import Strand
import WhoopProtocol

/// #649: the reject archive lives outside `OpenWhoop`'s protected App Support tree
/// (`StorePaths.defaultDatabasePath()`, #222) and did not inherit that store's Data Protection
/// downgrade. On iOS, files default to `NSFileProtectionComplete` — cryptographically UNREADABLE while
/// the device is locked. `BLEManager.archiveRejectedFrames` writes here to durably bank a frame BEFORE
/// acking the strap's historical-data trim; a write that throws because the phone is locked trips the
/// `.failed` path and holds the ack, so the strap re-sends the same chunk in a loop. This test asserts
/// the fix: after a successful `archive(...)` write, the directory AND the file carry the same
/// `completeUntilFirstUserAuthentication` protection class `StorePaths` sets on the main SQLite store.
///
/// iOS-only: the code under test is itself `#if os(iOS)`-gated (mirroring `StorePaths.swift`, since
/// `FileProtectionType`/`.protectionKey` are only meaningfully enforced under iOS's Data Protection
/// entitlement). `StrandTests` runs on the macOS scheme (see `project.yml`), so this assertion can't
/// execute there today — it skips cleanly on macOS and is ready for the first iOS-capable test run.
final class RawHistoryArchiveDataProtectionTests: XCTestCase {

private func tmpDir(_ tag: String) -> URL {
URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("noop-protect-\(tag)-\(UUID().uuidString)", isDirectory: true)
}

func testArchiveDirectoryAndFileGetDataProtectionAfterWrite() throws {
#if os(iOS)
let dir = tmpDir("protect")
defer { try? FileManager.default.removeItem(at: dir) }
let archive = RawHistoryArchive(directory: dir)

let frame: [UInt8] = [0xAA, 0x01, 0x00, 0x00, 47, 18] + [UInt8](repeating: 0, count: 24)
let result = archive.archive([frame], trim: 1, family: .whoop4)
guard case .written = result else {
return XCTFail("expected a successful write, got \(result)")
}

let dirAttrs = try FileManager.default.attributesOfItem(atPath: dir.path)
XCTAssertEqual(dirAttrs[.protectionKey] as? FileProtectionType,
.completeUntilFirstUserAuthentication,
"archive directory must not default to NSFileProtectionComplete (#649)")

let fileAttrs = try FileManager.default.attributesOfItem(atPath: archive.fileURL.path)
XCTAssertEqual(fileAttrs[.protectionKey] as? FileProtectionType,
.completeUntilFirstUserAuthentication,
"archive file must not default to NSFileProtectionComplete (#649)")
#else
throw XCTSkip("File Data Protection is iOS-only; StrandTests runs on the macOS scheme.")
#endif
}
}