Skip to content
Closed
36 changes: 26 additions & 10 deletions Strand/System/ReportReviewGate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,40 @@ struct ReportReviewGate {

init(entries: [FileExport.BundleEntry]) { self.entries = entries }

/// The bundle files that are NEVER shown inline in the review sheet by NAME: the binary screenshot plus
/// the large raw research streams (WHOOP raw-capture + the Oura Tier-B sidecars — each up to the 20 MB
/// cap). Rendering megabytes of text as a single SwiftUI `Text` pins the main thread in CoreText glyph
/// layout — a ~12 MB `oura-raw.jsonl` allocated >1 GB and hung the review sheet indefinitely. They are
/// already PII-scrubbed and are NAMED in the "attached" note below, so the review stays honest without
/// trying to lay them out.
static let notShownInline: Set<String> = [
"screenshot.png", "raw-capture.jsonl", "oura-raw.jsonl", "oura-ibihr.jsonl", "oura-activity.jsonl",
]

/// Belt-and-braces size guard: ANY entry larger than this is named, never inlined, even one NOT in
/// `notShownInline` (a future stream, or a pathologically large report.txt). 1 MiB sits far above a
/// normal report.txt / meta.json (so those still show in full) yet well below the layout-choke point,
/// so no single Text can ever pin the main thread again regardless of which files a branch attaches.
static let maxInlineBytes = 1024 * 1024

/// Every text file the user is about to share, shown in the review sheet so they can read the WHOLE
/// bundle (not just report.txt) and cancel if anything looks personal , the gate promises the user sees
/// exactly what they share. Each text entry is prefixed with a `=== <name> ===` header so the three
/// files (report.txt, meta.json, and last-crash.txt when present) are clearly delimited. The
/// raw-capture stream is excluded: it is the bounded binary capture (up to the 20 MB cap), not a report
/// surface, and is already PII-scrubbed by the assembler. Order is the natural bundle order. Empty
/// string if there is nothing text-decodable to show.
/// exactly what they share. Each text entry is prefixed with a `=== <name> ===` header so the files
/// (report.txt, meta.json, and last-crash.txt when present) are clearly delimited. The large raw streams
/// (by name, or anything over `maxInlineBytes`) are excluded: they are bounded research captures, not a
/// report surface, already PII-scrubbed, and far too large to lay out inline. Order is the natural
/// bundle order. Empty string if there is nothing text-decodable to show.
var previewText: String {
let textBlocks = entries.compactMap { entry -> String? in
guard entry.name != "raw-capture.jsonl",
guard !Self.notShownInline.contains(entry.name), entry.data.count <= Self.maxInlineBytes,
let text = String(data: entry.data, encoding: .utf8) else { return nil }
return "=== \(entry.name) ===\n\(text)"
}.joined(separator: "\n\n")
// Binary attachments (the Display mode's screenshot.png, raw-capture.jsonl) are not text, so they
// cannot be shown inline. Name them so the review is honest about EVERYTHING in the bundle: the
// user sees that a screenshot is attached and can cancel if they don't want to share it.
// Binary / large attachments (screenshot.png, raw-capture.jsonl, the oura-*.jsonl sidecars, anything
// oversized) are not shown inline. Name them so the review is honest about EVERYTHING in the bundle:
// the user sees that a screenshot / raw capture / ring dump is attached and can cancel if unwanted.
let binaryNames = entries.compactMap { entry -> String? in
if entry.name == "raw-capture.jsonl" || entry.name == "screenshot.png" { return entry.name }
if Self.notShownInline.contains(entry.name) || entry.data.count > Self.maxInlineBytes { return entry.name }
return String(data: entry.data, encoding: .utf8) == nil ? entry.name : nil
}
guard !binaryNames.isEmpty else { return textBlocks }
Expand Down
96 changes: 83 additions & 13 deletions Strand/System/TestBundleAssembler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ enum TestBundleAssembler {
/// The redaction stamp written into meta.json so a maintainer knows the whole-bundle scrub ran.
static let redactionVersion = "v2"

/// The bundle files that may be trimmed to fit the cap (newest-tail kept). The strap-log tail and
/// meta.json are already bounded, so only these raw research streams can blow the budget: the WHOOP
/// frame capture plus the Oura ring's Tier-B JSONL sidecars (raw notifications / IBI-HR / activity MET).
/// Everything NOT listed here is kept whole and its bytes are reserved before the trimmable group is
/// given the remainder. These are the NORMALIZED entry names (the ring id is dropped from the filename).
static let trimmableNames: Set<String> = [
"raw-capture.jsonl", "oura-raw.jsonl", "oura-ibihr.jsonl", "oura-activity.jsonl",
]

/// Re-run the redaction sink over every entry. Text entries are decoded as UTF-8, scrubbed via the same
/// LiveState.redactPii used by the live sink, and re-encoded. A non-UTF-8 entry (none today) passes
/// through untouched rather than risk corrupting binary. meta.json and report.txt have no PII shapes so
Expand All @@ -27,24 +36,32 @@ enum TestBundleAssembler {
}

/// Hard cap the bundle at `capBytes` (20 MB default, under GitHub's 25 MB; spec section 5.4). The
/// strap-log tail is already bounded, so only raw-capture can exceed. We keep the MOST-RECENT tail of
/// raw-capture.jsonl (newest data is the most diagnostic) and trim from the front. Returns the capped
/// entries plus whether any truncation happened, which the caller writes to meta.truncated.
/// strap-log tail and meta.json are already bounded, so only the `trimmableNames` research streams can
/// exceed. We reserve the whole size of every non-trimmable file, then split the remaining budget across
/// the trimmable files IN PROPORTION to their size, keeping the MOST-RECENT tail of each (newest data is
/// the most diagnostic) and trimming from the front. With a single trimmable file this is identical to
/// the prior raw-capture-only behaviour (its share is the whole remainder). Returns the capped entries
/// plus whether any truncation happened, which the caller writes to meta.truncated.
static func capEntries(_ entries: [FileExport.BundleEntry],
capBytes: Int = 20 * 1024 * 1024) -> (entries: [FileExport.BundleEntry], truncated: Bool) {
let total = entries.reduce(0) { $0 + $1.data.count }
guard total > capBytes else { return (entries, false) }
// Budget for everything that is NOT raw-capture (kept whole), then give raw-capture the remainder.
let rawName = "raw-capture.jsonl"
let nonRaw = entries.filter { $0.name != rawName }.reduce(0) { $0 + $1.data.count }
let budget = max(0, capBytes - nonRaw)
// Reserve the non-trimmable files (kept whole), then share the remainder across the trimmable ones.
let nonTrimmable = entries.filter { !trimmableNames.contains($0.name) }.reduce(0) { $0 + $1.data.count }
let budget = max(0, capBytes - nonTrimmable)
let trimmableTotal = entries.filter { trimmableNames.contains($0.name) }.reduce(0) { $0 + $1.data.count }
var truncated = false
let capped = entries.map { entry -> FileExport.BundleEntry in
guard entry.name == rawName, entry.data.count > budget else { return entry }
guard trimmableNames.contains(entry.name) else { return entry }
// Each trimmable file gets a byte share proportional to its size (integer floor keeps the sum
// under budget, so the bundle never breaches the cap). Kept whole if already within its share.
let share = trimmableTotal > 0
? Int(Double(budget) * Double(entry.data.count) / Double(trimmableTotal))
: 0
guard entry.data.count > share else { return entry }
truncated = true
// Keep the tail (most recent): the last `budget` bytes.
let tail = entry.data.suffix(budget)
return FileExport.BundleEntry(name: entry.name, data: Data(tail))
// Keep the tail (most recent): the last `share` bytes.
return FileExport.BundleEntry(name: entry.name, data: Data(entry.data.suffix(share)))
}
return (capped, truncated)
}
Expand Down Expand Up @@ -130,12 +147,22 @@ enum TestBundleAssembler {
let crash: FileExport.BundleEntry? = crashLogURL()
.flatMap { fileEntry(at: $0, name: "last-crash.txt") }

// 2. Redact the TEXT files (report.txt, raw-capture.jsonl, last-crash.txt), then cap. The screenshot
// 1e. Oura ring diagnostics: the Tier-B JSONL sidecars (raw notifications / IBI-HR / activity MET)
// the ring writes to <App Support>/OpenWhoop/Diagnostics whenever it connects. Attached WHEN
// PRESENT — exactly like raw-capture.jsonl, NOT behind a test domain: they cut across Sleep /
// HRV / Connection / Sources, and file presence is the honest gate (no ring used → no files →
// nothing attached, so an Experimental ring needs no separate brand check here). They are TEXT
// (JSON lines) whose only PII is the ring UUID inside each line, which the redactEntries pass
// below masks to <device>; the ENTRY name is normalized (id dropped) since redaction never
// touches names. Trimmed to the cap alongside raw-capture via `trimmableNames`.
let ouraDiagnostics = ouraDiagnosticEntries()

// 2. Redact the TEXT files (report.txt, raw-capture.jsonl, last-crash.txt, oura-*.jsonl), then cap. The screenshot
// is included in the cap input (NOT the redact input) so its bytes COUNT against the 20 MB cap:
// capEntries budgets raw-capture as capBytes - (everything else), so a large/retina PNG shrinks
// the raw-capture tail rather than breaching the cap. Only raw-capture is trimmed; report.txt and
// last-crash are bounded and the PNG is kept whole.
let textEntries = [reportEntry] + (rawCapture.map { [$0] } ?? []) + (crash.map { [$0] } ?? [])
let textEntries = [reportEntry] + (rawCapture.map { [$0] } ?? []) + (crash.map { [$0] } ?? []) + ouraDiagnostics
let redacted = redactEntries(textEntries)
let (capped, truncated) = capEntries(redacted + (shot.map { [$0] } ?? []))
var entries = capped
Expand Down Expand Up @@ -223,6 +250,49 @@ enum TestBundleAssembler {
return FileExport.BundleEntry(name: name, data: data)
}

/// The `OpenWhoop/Diagnostics` directory the Oura dumps write into, mirroring the dumps' own resolver
/// (`OuraRawDump.resolveURL` et al.): Application Support + `OpenWhoop/Diagnostics`. Read-only — never
/// creates the directory (a bundle build must not have side effects), so it returns nil when nothing has
/// ever been captured. iOS and macOS both resolve Application Support here.
static func ouraDiagnosticsDir() -> URL? {
guard let base = try? FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask,
appropriateFor: nil, create: false) else { return nil }
return base.appendingPathComponent("OpenWhoop/Diagnostics", isDirectory: true)
}

/// Map a diagnostics filename to a NORMALIZED bundle entry name that drops the ring id, or nil when the
/// file is not one of the three Oura sidecars. `oura-<type>-<ringId>.jsonl` → `oura-<type>.jsonl`, which
/// (a) keeps the ring id out of the bundle's filenames (redaction scrubs content, never names) and
/// (b) lands on the `trimmableNames` set so the cap can trim it. Pure/string-only for unit testing.
static func normalizedOuraEntryName(forFile filename: String) -> String? {
for kind in ["raw", "ibihr", "activity"] {
if filename.hasPrefix("oura-\(kind)-"), filename.hasSuffix(".jsonl") {
return "oura-\(kind).jsonl"
}
}
return nil
}

/// Gather the Oura ring's Tier-B JSONL sidecars as bundle entries, normalized names and RAW bytes (the
/// caller redacts + caps). Enumerates the Diagnostics dir rather than reconstructing per-ring filenames,
/// so it needs no active-ring id and picks up whatever was captured. If two rings produced the same kind
/// (rare), the entry names would collide; we keep the LARGEST file per normalized name (the fuller
/// capture is the more useful one) so the bundle never carries duplicate names.
static func ouraDiagnosticEntries() -> [FileExport.BundleEntry] {
guard let dir = ouraDiagnosticsDir(),
let files = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)
else { return [] }
var byName: [String: FileExport.BundleEntry] = [:]
for url in files.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
guard let name = normalizedOuraEntryName(forFile: url.lastPathComponent),
let entry = fileEntry(at: url, name: name) else { continue }
if let existing = byName[name], existing.data.count >= entry.data.count { continue }
byName[name] = entry
}
// Stable order (raw, ibihr, activity) so the bundle listing is deterministic.
return ["oura-raw.jsonl", "oura-ibihr.jsonl", "oura-activity.jsonl"].compactMap { byName[$0] }
}

/// The on-disk path a crash report would live at, when a crash handler is wired. There is no producer
/// yet, so this normally points at a non-existent file and `fileEntry` returns nil. Centralised here so a
/// future handler need only write to this path for the crash log to start attaching (fully redacted)
Expand Down
26 changes: 26 additions & 0 deletions StrandTests/ReportReviewGateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,30 @@ final class ReportReviewGateTests: XCTestCase {
gate.confirm()
XCTAssertTrue(gate.isCleared)
}

/// The large raw research streams (raw-capture + the Oura Tier-B sidecars) must NEVER be inlined into
/// the preview: laying out a multi-MB `oura-raw.jsonl` as one SwiftUI Text pinned the main thread in
/// CoreText glyph layout (>1 GB, hung). They are still NAMED so the review stays honest.
func testLargeRawStreamsAreNamedNotInlined() {
let bigOura = String(repeating: "A", count: 2 * 1024 * 1024) // 2 MB of would-be inline text
let entries = [
FileExport.BundleEntry(name: "report.txt", data: Data("visible report body".utf8)),
FileExport.BundleEntry(name: "raw-capture.jsonl", data: Data("raw whoop bytes".utf8)),
FileExport.BundleEntry(name: "oura-raw.jsonl", data: Data(bigOura.utf8)),
FileExport.BundleEntry(name: "oura-ibihr.jsonl", data: Data("ring hr".utf8)),
FileExport.BundleEntry(name: "oura-activity.jsonl", data: Data("ring met".utf8)),
]
let preview = ReportReviewGate(entries: entries).previewText
// report.txt is shown in full…
XCTAssertTrue(preview.contains("visible report body"))
// …but none of the large stream CONTENT is inlined (the 2 MB blob never reaches the Text).
XCTAssertFalse(preview.contains(bigOura))
XCTAssertFalse(preview.contains("=== oura-raw.jsonl ===\nA"))
// Every excluded stream is still NAMED under the attached note, so the review is honest.
for name in ["raw-capture.jsonl", "oura-raw.jsonl", "oura-ibihr.jsonl", "oura-activity.jsonl"] {
XCTAssertTrue(preview.contains(name), "\(name) must be named in the review")
}
// The whole preview stays tiny regardless of the 2 MB stream.
XCTAssertLessThan(preview.count, 10_000)
}
}
47 changes: 47 additions & 0 deletions StrandTests/TestBundleAssemblerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,51 @@ final class TestBundleAssemblerTests: XCTestCase {
XCTAssertFalse(truncated)
XCTAssertEqual(capped, entries)
}

// MARK: - Oura diagnostics attachment (#Test-Centre Oura sidecars)

func testNormalizedOuraEntryNameDropsRingId() {
// The three sidecars normalize to id-free names; the ring UUID is gone from the filename.
XCTAssertEqual(TestBundleAssembler.normalizedOuraEntryName(
forFile: "oura-raw-5C4C0BF8-2DF6-1B3A-18D0-3DF0B3590148.jsonl"), "oura-raw.jsonl")
XCTAssertEqual(TestBundleAssembler.normalizedOuraEntryName(
forFile: "oura-ibihr-5C4C0BF8-2DF6-1B3A-18D0-3DF0B3590148.jsonl"), "oura-ibihr.jsonl")
XCTAssertEqual(TestBundleAssembler.normalizedOuraEntryName(
forFile: "oura-activity-oura-5C4C0BF8.jsonl"), "oura-activity.jsonl")
// Non-sidecar files are ignored.
XCTAssertNil(TestBundleAssembler.normalizedOuraEntryName(forFile: "raw-capture.jsonl"))
XCTAssertNil(TestBundleAssembler.normalizedOuraEntryName(forFile: "whoop.sqlite"))
XCTAssertNil(TestBundleAssembler.normalizedOuraEntryName(forFile: "oura-raw.txt"))
}

func testNormalizedOuraNamesAreAllTrimmable() {
// Every normalized sidecar name must be in the cap's trimmable set, else a big night's dump could
// blow the 20 MB cap instead of being tail-trimmed.
for kind in ["raw", "ibihr", "activity"] {
XCTAssertTrue(TestBundleAssembler.trimmableNames.contains("oura-\(kind).jsonl"))
}
}

func testCapSharesBudgetAcrossOuraAndRawCaptureKeepingTails() {
// report.txt kept whole; raw-capture + oura-ibihr together blow the cap → BOTH trimmed to a
// proportional tail, bundle stays under cap, and each keeps its most-recent byte.
let small = FileExport.BundleEntry(name: "report.txt", data: Data("small".utf8))
let raw = FileExport.BundleEntry(name: "raw-capture.jsonl",
data: Data((String(repeating: "r", count: 30 * 1024 * 1024) + "R").utf8))
let ibi = FileExport.BundleEntry(name: "oura-ibihr.jsonl",
data: Data((String(repeating: "i", count: 10 * 1024 * 1024) + "I").utf8))
let cap = 20 * 1024 * 1024
let (capped, truncated) = TestBundleAssembler.capEntries([small, raw, ibi], capBytes: cap)
XCTAssertTrue(truncated)
XCTAssertLessThanOrEqual(capped.reduce(0) { $0 + $1.data.count }, cap)
XCTAssertEqual(capped.first { $0.name == "report.txt" }?.data, small.data) // whole
let cappedRaw = capped.first { $0.name == "raw-capture.jsonl" }!
let cappedIbi = capped.first { $0.name == "oura-ibihr.jsonl" }!
XCTAssertLessThan(cappedRaw.data.count, raw.data.count) // trimmed
XCTAssertLessThan(cappedIbi.data.count, ibi.data.count) // trimmed
XCTAssertEqual(cappedRaw.data.last, raw.data.last) // newest kept
XCTAssertEqual(cappedIbi.data.last, ibi.data.last)
// Bigger stream gets the bigger share (raw was 3× ibi).
XCTAssertGreaterThan(cappedRaw.data.count, cappedIbi.data.count)
}
}
Loading
Loading