@@ -318,6 +318,13 @@ final class TestHarness {
318318 log. warning ( " OMEMO fixture for \( credential. label) is malformed; ignoring and allowing fresh identity generation " )
319319 return false
320320 }
321+ // Refuse to seed identity material that was captured for a different
322+ // JID — silent reuse across accounts would mask a renamed/recycled
323+ // fixture file.
324+ if !Self. fixtureJIDMatchesCredential ( fixture. accountJID, credentialJID: credential. jid) {
325+ log. warning ( " OMEMO fixture for \( credential. label) was captured for a different JID; ignoring and allowing fresh identity generation " )
326+ return false
327+ }
321328
322329 let identity = OMEMOStoredIdentity (
323330 accountJID: credential. jid,
@@ -349,8 +356,11 @@ final class TestHarness {
349356
350357 /// Captures the freshly-generated OMEMO identity for `credential` to the
351358 /// path resolved by `fixtureURL(for:)` so later runs can reuse it via
352- /// `loadOMEMOFixture`. No-ops when a well-formed fixture is already on
353- /// disk; overwrites a malformed one.
359+ /// `loadOMEMOFixture`. Writes only when the encoded bytes differ from
360+ /// the on-disk file: idempotent runs stay byte-stable (no spurious git
361+ /// diffs in CI), but a test that consumes prekeys flips an `isUsed`
362+ /// flag, the encoded bytes diverge from disk, and the fixture is
363+ /// rewritten. A malformed fixture is also overwritten and logged.
354364 ///
355365 /// `OMEMOService.handleConnected` persists identity, prekeys, and signed
356366 /// prekey via a detached task that outlives `.rosterLoaded`, so this method
@@ -359,64 +369,135 @@ final class TestHarness {
359369 /// run regenerates.
360370 private func captureOMEMOFixture( for credential: TestCredentials . Credential ) async throws {
361371 let fixtureURL = Self . fixtureURL ( for: credential. label)
362-
363- if let existing = try ? Data ( contentsOf: fixtureURL) {
364- if let decoded = try ? JSONDecoder ( ) . decode ( FixtureOMEMOIdentity . self, from: existing) ,
365- decoded. passesShapeInvariants {
372+ // Distinguish "file not present" (continue and write fresh) from
373+ // "file present but unreadable" (bail rather than clobber). A
374+ // transient I/O error reading a well-formed fixture must not result
375+ // in silently overwriting it on disk.
376+ let fileExists = FileManager . default. fileExists ( atPath: fixtureURL. path)
377+ let existing : Data ?
378+ if fileExists {
379+ do {
380+ existing = try Data ( contentsOf: fixtureURL)
381+ } catch {
382+ log. warning ( " OMEMO fixture for \( credential. label) at \( fixtureURL. path) is present but unreadable ( \( error. localizedDescription) ); skipping capture to avoid clobbering it " )
366383 return
367384 }
368- log. info ( " Overwriting malformed OMEMO fixture for \( credential. label) at \( fixtureURL. path) " )
385+ } else {
386+ existing = nil
387+ }
388+ let existingDecoded = existing. flatMap { try ? JSONDecoder ( ) . decode ( FixtureOMEMOIdentity . self, from: $0) }
389+ let existingIsMalformed = existing != nil && existingDecoded? . passesShapeInvariants != true
390+ // Refuse to overwrite a well-formed fixture whose accountJID disagrees
391+ // with the credential — `loadOMEMOFixture` already rejected it as a
392+ // mismatch and let connect generate fresh material; silently writing
393+ // the new identity over the existing file would permanently destroy
394+ // the original-account fixture without warning.
395+ if let existingDecoded,
396+ existingDecoded. passesShapeInvariants,
397+ !Self. fixtureJIDMatchesCredential ( existingDecoded. accountJID, credentialJID: credential. jid) {
398+ log. warning ( " OMEMO fixture for \( credential. label) at \( fixtureURL. path) was captured for a different JID; preserving existing file. Move or delete it manually if you intend to reuse the path for \( credential. jid) . " )
399+ return
369400 }
370401
371- guard await waitForSignedPreKey ( for: credential) else {
372- log. warning ( " OMEMO signed prekey for \( credential. label) did not land in store within 10s; skipping fixture capture " )
402+ guard let fixture = try await buildFixture ( for: credential) else { return }
403+ let encoded = try Self . encodeFixture ( fixture)
404+
405+ // Idempotency guard: if the on-disk bytes already match the fixture we
406+ // would write, skip the write. Idempotent test runs stay byte-stable
407+ // (no spurious git diffs in CI), but a test that consumes prekeys —
408+ // flipping their `isUsed` flags in memory — will diff against the
409+ // on-disk copy and trigger a rewrite.
410+ if existing == encoded {
411+ log. debug ( " OMEMO fixture for \( credential. label) unchanged; skipping write at \( fixtureURL. path) " )
373412 return
374413 }
375414
415+ try writeFixture ( encoded, to: fixtureURL)
416+ if existingIsMalformed {
417+ log. info ( " Overwrote malformed OMEMO fixture for \( credential. label) at \( fixtureURL. path) " )
418+ } else {
419+ log. debug ( " OMEMO fixture for \( credential. label) changed; wrote at \( fixtureURL. path) " )
420+ }
421+ }
422+
423+ /// Builds a `FixtureOMEMOIdentity` from the OMEMO store after polling the
424+ /// detached persistence task. Returns `nil` when the store has not
425+ /// finished writing within 10s — the next run will regenerate.
426+ private func buildFixture(
427+ for credential: TestCredentials . Credential
428+ ) async throws -> FixtureOMEMOIdentity ? {
429+ guard await waitForSignedPreKey ( for: credential) else {
430+ log. warning ( " OMEMO signed prekey for \( credential. label) did not land in store within 10s; skipping fixture capture " )
431+ return nil
432+ }
376433 guard let storedIdentity = try await omemoStore. loadIdentity ( for: credential. jid) else {
377434 log. warning ( " OMEMO identity for \( credential. label) missing at capture time; skipping fixture capture " )
378- return
435+ return nil
379436 }
380437 let storedPreKeys = try await omemoStore. loadPreKeys ( for: credential. jid)
381438 guard let storedSignedPreKey = try await omemoStore. loadSignedPreKey ( for: credential. jid) else {
382439 log. warning ( " OMEMO signed prekey for \( credential. label) missing at capture time; skipping fixture capture " )
383- return
440+ return nil
384441 }
385-
386- let fixture = FixtureOMEMOIdentity (
442+ return FixtureOMEMOIdentity (
387443 deviceID: storedIdentity. deviceID,
388444 identityKeyRaw: Array ( storedIdentity. identityKeyData) ,
389445 signedPreKeyID: storedSignedPreKey. keyID,
390446 signedPreKeyRaw: Array ( storedSignedPreKey. keyData) ,
391447 signedPreKeySignature: Array ( storedSignedPreKey. signature) ,
392- preKeys: storedPreKeys. map {
393- FixtureOMEMOIdentity . PreKey ( keyID: $0. keyID, keyRaw: Array ( $0. keyData) , isUsed: $0. isUsed)
394- }
448+ preKeys: Self . sortedFixturePreKeys ( storedPreKeys) ,
449+ accountJID: credential. jid
395450 )
451+ }
396452
397- // Create the directory with owner-only access (0700) and write the
398- // fixture with owner-only permissions (0600) so the long-term identity
399- // keys don't land at the umask default (0755/0644) on multi-user hosts.
400- // `createDirectory(attributes:)` is a no-op when the directory already
401- // exists, so re-apply 0700 explicitly afterward — otherwise a dir
402- // created by an older harness version at default 0755 stays that way.
453+ /// Writes the encoded fixture to disk with owner-only directory and
454+ /// file permissions so the long-term identity keys don't land at the
455+ /// umask default (0755/0644) on multi-user hosts.
456+ /// `createDirectory(attributes:)` is a no-op when the directory already
457+ /// exists, so re-apply 0700 explicitly afterward — otherwise a dir
458+ /// created by an older harness version at default 0755 stays that way.
459+ private func writeFixture( _ encoded: Data , to fixtureURL: URL ) throws {
403460 let directory = fixtureURL. deletingLastPathComponent ( )
404461 try FileManager . default. createDirectory (
405462 at: directory,
406463 withIntermediateDirectories: true ,
407464 attributes: [ . posixPermissions: 0o700 ]
408465 )
409466 try ? FileManager . default. setAttributes ( [ . posixPermissions: 0o700 ] , ofItemAtPath: directory. path)
467+ try encoded. write ( to: fixtureURL, options: . atomic)
468+ try ? FileManager . default. setAttributes ( [ . posixPermissions: 0o600 ] , ofItemAtPath: fixtureURL. path)
469+ }
410470
471+ /// Deterministic JSON encoding for `FixtureOMEMOIdentity`. Used both for
472+ /// writing the fixture and for the on-disk-vs-in-memory diff in
473+ /// `captureOMEMOFixture` — both sides must agree on key ordering and
474+ /// formatting, otherwise a byte-stable run would false-positive the diff.
475+ /// Pretty-printed JSON honors `OMEMOFixtureFormat`'s "visible to the
476+ /// naked eye when inspecting fixture drift" docstring.
477+ nonisolated static func encodeFixture( _ fixture: FixtureOMEMOIdentity ) throws -> Data {
411478 let encoder = JSONEncoder ( )
412- // Pretty-printed JSON honors `OMEMOFixtureFormat`'s "visible to the
413- // naked eye when inspecting fixture drift" docstring.
414479 encoder. outputFormatting = [ . prettyPrinted, . sortedKeys]
415- let encoded = try encoder. encode ( fixture)
416- try encoded. write ( to: fixtureURL, options: . atomic)
417- try ? FileManager . default. setAttributes ( [ . posixPermissions: 0o600 ] , ofItemAtPath: fixtureURL. path)
480+ return try encoder. encode ( fixture)
481+ }
482+
483+ /// Per-keyID-sorted serialization of stored prekeys, matching
484+ /// `captureOMEMOFixture`'s in-memory-side serialization. Exposed so unit
485+ /// tests can assert byte-stability without driving the whole capture flow.
486+ nonisolated static func sortedFixturePreKeys(
487+ _ storedPreKeys: [ OMEMOStoredPreKey ]
488+ ) -> [ FixtureOMEMOIdentity . PreKey ] {
489+ storedPreKeys
490+ . sorted { $0. keyID < $1. keyID }
491+ . map { FixtureOMEMOIdentity . PreKey ( keyID: $0. keyID, keyRaw: Array ( $0. keyData) , isUsed: $0. isUsed) }
492+ }
418493
419- log. info ( " Captured generated OMEMO identity for \( credential. label) at \( fixtureURL. path) " )
494+ /// Treats a missing (legacy) `captured` as a match (legacy fixtures
495+ /// pre-date the field and load until rewritten). When present, compares
496+ /// as parsed `BareJID` so localpart/domainpart case normalization doesn't
497+ /// trigger a spurious mismatch.
498+ nonisolated static func fixtureJIDMatchesCredential( _ captured: String ? , credentialJID: String ) -> Bool {
499+ guard let captured else { return true }
500+ return BareJID . parse ( captured) == BareJID . parse ( credentialJID)
420501 }
421502
422503 /// Polls the OMEMO store for `credential`'s signed-prekey up to 10 s.
0 commit comments