Skip to content

Commit c7b13a8

Browse files
authored
Merge pull request #54 from RockxyApp/develop
release(app): promote retention, evidence, and workspace refinements
2 parents b2d8036 + 1ab9e42 commit c7b13a8

51 files changed

Lines changed: 4726 additions & 490 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import Foundation
2+
3+
// This file declares the frozen, presentation-neutral value for one selected
4+
// session's retained connection and TLS evidence. Like the tables it reads, it is
5+
// observation-only: it carries the exact retained ``ConnectionSummary`` and
6+
// ``TLSEvidenceSummary`` values a fold already produced, plus the relevant
7+
// capture-level coverage counters — never a rendered label, severity, policy, raw
8+
// bytes, URL, path, SNI or certificate. It adds no analysis; it is a filtered,
9+
// deterministic projection of one immutable ``InvestigationSnapshot``.
10+
11+
// MARK: - ConnectionSelectionCoverage
12+
13+
/// The capture-level connection-table coverage a selected session's presentation
14+
/// needs, carried verbatim from ``ConnectionTable/Snapshot``. These are *global*
15+
/// facts: `omittedSummaryCount` and the counts describe the whole capture, never
16+
/// this one session, so a caller must label them as capture-level and never claim
17+
/// they prove this selected session was omitted.
18+
nonisolated struct ConnectionSelectionCoverage: Hashable, Sendable {
19+
static let empty = ConnectionSelectionCoverage(
20+
omittedSummaryCount: 0,
21+
activeConnectionCount: 0,
22+
publishedSummaryCount: 0,
23+
retainedEventCount: 0,
24+
countersOverflowed: false
25+
)
26+
27+
let omittedSummaryCount: UInt64
28+
let activeConnectionCount: Int
29+
let publishedSummaryCount: Int
30+
let retainedEventCount: Int
31+
let countersOverflowed: Bool
32+
}
33+
34+
// MARK: - TLSSelectionCoverage
35+
36+
/// The capture-level TLS-evidence coverage a selected session's presentation needs,
37+
/// carried verbatim from ``TLSEvidenceTable/Snapshot``. As with the connection
38+
/// coverage these are global capture facts; absence of a retained summary for this
39+
/// session is unknown coverage, never evidence of absence.
40+
nonisolated struct TLSSelectionCoverage: Hashable, Sendable {
41+
static let empty = TLSSelectionCoverage(
42+
omittedObservationCount: 0,
43+
retainedObservationCount: 0,
44+
excludedReassembledRecordCount: 0,
45+
recoveredTruncationIndicatorCount: 0,
46+
decoderTruncatedFrameCount: 0,
47+
capacityReached: false,
48+
countersOverflowed: false
49+
)
50+
51+
let omittedObservationCount: UInt64
52+
let retainedObservationCount: Int
53+
let excludedReassembledRecordCount: UInt64
54+
let recoveredTruncationIndicatorCount: UInt64
55+
let decoderTruncatedFrameCount: UInt64
56+
let capacityReached: Bool
57+
let countersOverflowed: Bool
58+
}
59+
60+
// MARK: - SessionEvidenceSelection
61+
62+
/// The immutable, presentation-neutral view of one selected session's retained
63+
/// connection and TLS evidence.
64+
///
65+
/// A session is tuple-derived, so one session can hold multiple sequential TCP
66+
/// connection incarnations when its tuple is reused. `connections` lists *every*
67+
/// retained incarnation whose tuple-derived session id matches, in the snapshot's
68+
/// existing deterministic (first-observed) order — this value never chooses one as
69+
/// "the connection." `tls` is the zero-or-one retained tuple/session-scoped TLS
70+
/// summary; because current evidence cannot prove which incarnation a tuple-scoped
71+
/// TLS record belongs to, it is shared at session scope and never paired to a
72+
/// specific connection.
73+
///
74+
/// Every per-summary omission, exclusion, truncation, loss, limitation and overflow
75+
/// fact needed by a presentation layer already lives inside the retained
76+
/// ``ConnectionSummary``/``TLSEvidenceSummary`` values carried here; the two
77+
/// coverage members add only the capture-level (global) counters.
78+
nonisolated struct SessionEvidenceSelection: Hashable, Sendable {
79+
/// The tuple-derived session id this selection was projected for.
80+
let sessionID: UUID
81+
/// Every retained connection incarnation for this session in first-observed
82+
/// order. Bounded by the connection table's published-summary bound.
83+
let connections: [ConnectionSummary]
84+
/// The zero-or-one retained tuple/session-scoped TLS summary. Never paired to a
85+
/// connection incarnation.
86+
let tls: TLSEvidenceSummary?
87+
/// Capture-level connection coverage, carried verbatim.
88+
let connectionCoverage: ConnectionSelectionCoverage
89+
/// Capture-level TLS coverage, carried verbatim.
90+
let tlsCoverage: TLSSelectionCoverage
91+
92+
/// Whether nothing was retained for this session. Capture-level coverage is
93+
/// still carried so a caller can present it as global unknown coverage.
94+
var isEmpty: Bool {
95+
connections.isEmpty && tls == nil
96+
}
97+
}
98+
99+
// MARK: - InvestigationSnapshot selection
100+
101+
extension InvestigationSnapshot {
102+
/// Project the retained connection/TLS evidence for one tuple-derived session id.
103+
///
104+
/// Pure and off-main-ready: it filters the wrapped fold's already-produced
105+
/// connection and TLS summaries — no decode, no re-assessment, no copy of the
106+
/// snapshot arrays beyond the bounded matches. The connection incarnations keep
107+
/// the snapshot's deterministic order, and the single tuple-scoped TLS summary is
108+
/// matched by session id, never re-attributed to an incarnation.
109+
nonisolated func selectingSession(_ sessionID: UUID) -> SessionEvidenceSelection {
110+
let connectionSnapshot = connections
111+
let matchedConnections = connectionSnapshot.summaries.filter {
112+
SessionBuilder.sessionID(for: $0.tuple) == sessionID
113+
}
114+
let tlsSnapshot = tlsEvidence
115+
let matchedTLS = tlsSnapshot.summaries.first { $0.sessionID == sessionID }
116+
117+
return SessionEvidenceSelection(
118+
sessionID: sessionID,
119+
connections: matchedConnections,
120+
tls: matchedTLS,
121+
connectionCoverage: ConnectionSelectionCoverage(
122+
omittedSummaryCount: connectionSnapshot.omittedSummaryCount,
123+
activeConnectionCount: connectionSnapshot.activeConnectionCount,
124+
publishedSummaryCount: connectionSnapshot.publishedSummaryCount,
125+
retainedEventCount: connectionSnapshot.retainedEventCount,
126+
countersOverflowed: connectionSnapshot.countersOverflowed
127+
),
128+
tlsCoverage: TLSSelectionCoverage(
129+
omittedObservationCount: tlsSnapshot.omittedObservationCount,
130+
retainedObservationCount: tlsSnapshot.retainedObservationCount,
131+
excludedReassembledRecordCount: tlsSnapshot.excludedReassembledRecordCount,
132+
recoveredTruncationIndicatorCount: tlsSnapshot.recoveredTruncationIndicatorCount,
133+
decoderTruncatedFrameCount: tlsSnapshot.decoderTruncatedFrameCount,
134+
capacityReached: tlsSnapshot.capacityReached,
135+
countersOverflowed: tlsSnapshot.countersOverflowed
136+
)
137+
)
138+
}
139+
}

Tracexy/Core/Capture/LiveCaptureSpool.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,16 @@ actor LiveCaptureSpool {
161161
guard epoch == self.epoch else {
162162
throw Failure.staleEvidence
163163
}
164+
return try readCurrentSource(locator, capturedLength: capturedLength)
165+
}
166+
167+
/// Read one locator from the spool source that is current now, regardless of
168+
/// the coordinator generation used to publish it. Stopping a capture advances
169+
/// the coordinator generation without replacing the spool; the opaque source
170+
/// token remains the authority for whether a locator still belongs here.
171+
/// A reset mints a new token, so evidence from every superseded spool still
172+
/// fails as stale before any offset is read.
173+
func readCurrentSource(_ locator: SessionEvidenceLocator, capturedLength: Int) throws -> [UInt8] {
164174
guard let token = sourceToken, locator.sourceToken == token else {
165175
throw Failure.staleEvidence
166176
}

Tracexy/Core/Capture/SavedCaptureStreamLoader.swift

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,54 @@ nonisolated enum CaptureEvidenceReader {
9696
}
9797
}
9898

99+
// MARK: - CitedFrameReferenceError
100+
101+
/// Why an exact cited-frame reference could not be constructed for a saved source.
102+
/// Both cases are controlled, presentation-neutral failures: neither carries a path
103+
/// or the internal source token, so error text derived from them exposes neither.
104+
nonisolated enum CitedFrameReferenceError: Error, Equatable {
105+
/// The provenance carried no locator, so there is no exact frame to cite. A
106+
/// caller treats this as an explicit unavailable state, never a fallback read.
107+
case missingLocator
108+
/// The locator's source token was not minted from the currently adopted file
109+
/// identity — a locator from a different or replaced source.
110+
case sourceTokenMismatch
111+
}
112+
113+
extension SavedCaptureStreamLoader {
114+
/// Build the exact ``CaptureEvidenceReference`` for one cited frame, but only
115+
/// after validating that the provenance locator's source token was minted from
116+
/// `identity` (the currently adopted file). The frame coordinates come straight
117+
/// from the provenance the fold already retained.
118+
///
119+
/// This validates *source identity* only; the byte-level identity/length/overrun
120+
/// checks still happen in ``CaptureEvidenceReader/read(_:from:maxCapturedLength:)``
121+
/// when the reference is read, so a replaced, truncated or short file is caught
122+
/// there. Constructs no reference and reads nothing on a wrong token or an
123+
/// absent locator.
124+
static func citedEvidenceReference(
125+
for provenance: SessionFrameProvenance,
126+
matching identity: PcapFileIdentity
127+
)
128+
throws -> CaptureEvidenceReference
129+
{
130+
guard let locator = provenance.locator else {
131+
throw CitedFrameReferenceError.missingLocator
132+
}
133+
guard locator.sourceToken == sourceToken(for: identity) else {
134+
throw CitedFrameReferenceError.sourceTokenMismatch
135+
}
136+
return CaptureEvidenceReference(
137+
identity: identity,
138+
payloadOffset: locator.offset,
139+
capturedLength: provenance.capturedLength,
140+
originalLength: provenance.originalLength,
141+
timestamp: provenance.timestamp,
142+
linkType: provenance.linkType
143+
)
144+
}
145+
}
146+
99147
// MARK: - CaptureLoadCompleteness
100148

101149
/// Whether the saved-open walk reached the file's end cleanly, or stopped on a
@@ -221,6 +269,22 @@ nonisolated final class SavedCaptureStreamLoader {
221269
let isCancelled: @Sendable () -> Bool
222270
}
223271

272+
/// A deterministic, opaque source token derived only from the opened file's
273+
/// identity. Two loads of the same unchanged file yield the same token; a
274+
/// replaced or resized file yields a different one. It carries no path and no
275+
/// bytes — it only lets a consumer holding the same capture-local identity
276+
/// correlate an evidence locator's offset back to this file.
277+
///
278+
/// Internal so an evidence consumer holding the currently adopted file identity
279+
/// can validate that a locator's source token was minted from *this* file before
280+
/// resolving it (see ``citedEvidenceReference(for:matching:)``).
281+
static func sourceToken(for identity: PcapFileIdentity) -> UUID {
282+
SessionBuilder.stableID(
283+
"savedsource|\(identity.size)|\(identity.device)|\(identity.inode)"
284+
+ "|\(identity.modifiedAt?.timeIntervalSince1970 ?? -1)"
285+
)
286+
}
287+
224288
/// Fold the entire file into one result.
225289
///
226290
/// - Parameter onProgress: coalesced, monotonic byte-progress callbacks. Never
@@ -298,18 +362,6 @@ nonisolated final class SavedCaptureStreamLoader {
298362
private let configuration: Configuration
299363
private let reader: CaptureStreamReader
300364

301-
/// A deterministic, opaque source token derived only from the opened file's
302-
/// identity. Two loads of the same unchanged file yield the same token; a
303-
/// replaced or resized file yields a different one. It carries no path and no
304-
/// bytes — it only lets a consumer holding the same capture-local identity
305-
/// correlate an evidence locator's offset back to this file.
306-
private static func sourceToken(for identity: PcapFileIdentity) -> UUID {
307-
SessionBuilder.stableID(
308-
"savedsource|\(identity.size)|\(identity.device)|\(identity.inode)"
309-
+ "|\(identity.modifiedAt?.timeIntervalSince1970 ?? -1)"
310-
)
311-
}
312-
313365
/// Drive the reader to its terminal, folding each frame exactly once. Kept
314366
/// separate so `load` reads as open → walk → finalize.
315367
private func walk(

0 commit comments

Comments
 (0)