And the same hole at the snapshot level

`Snapshot` declares fifteen properties and the encoder named eight, so
an export forgot the account's own settings as well as the contents of
its messages: unsent mail in the outbox, half-written drafts, who your
stories go to, who you had muted, your own story archive, and whether
the roster is being kept off the server.

Same defect as the message fields, one level up, and it survived for the
same reason: everything it drops is state you set once and rarely look
at, so a restore that lost it looks like an account that never had it.

The source check now reads `Snapshot` too. Its collections are encoded
by name rather than through an accessor, so they are listed as checked
by the round trips instead of by the source scan, with the reason
written down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-10 17:36:24 -04:00
co-authored by Claude Opus 5
parent fe44e63740
commit b0809ddda6
2 changed files with 106 additions and 5 deletions
+57
View File
@@ -90,6 +90,30 @@ enum E2EBackupPayload {
root["pins"] = out
}
// Everything below is state somebody set on purpose, and an export
// that quietly leaves it behind restores an account that has forgotten
// your choices without ever saying so.
if let outbox = snapshot.outbox, !outbox.isEmpty {
root["outbox"] = outbox.map { e -> [String: Any] in
var one: [String: Any] = ["to": e.to, "body": e.body, "mid": e.mid, "at": millis(e.at)]
if let n = e.notBefore { one["notBefore"] = millis(n) }
if let gid = e.gid { one["gid"] = gid }
if let wire = e.wire { one["wire"] = wire }
return one
}
}
if let audience = snapshot.storyAudience {
root["storyAudience"] = ["mode": audience.mode.rawValue, "ids": audience.userIDs.sorted()]
}
if let muted = snapshot.mutedStoryAuthors, !muted.isEmpty {
root["mutedStoryAuthors"] = muted.sorted()
}
if let archive = snapshot.storyArchive, !archive.isEmpty {
root["storyArchive"] = archive.map(story(_:))
}
if let off = snapshot.rosterOffServer { root["rosterOffServer"] = off }
if let drafts = snapshot.drafts, !drafts.isEmpty { root["drafts"] = drafts }
// Sorted keys so the same history seals to the same bytes twice, which
// is what makes a vector pinnable at all.
return try JSONSerialization.data(withJSONObject: root, options: [.sortedKeys])
@@ -283,6 +307,39 @@ enum E2EBackupPayload {
if !out.isEmpty { snapshot.groupSecrets = out }
}
if let outbox = root["outbox"] as? [[String: Any]] {
let entries = outbox.compactMap { e -> E2EOutboxEntry? in
guard let to = e["to"] as? Int, let mid = e["mid"] as? String,
let at = e["at"] as? Int
else { return nil }
var one = E2EOutboxEntry(
to: to, body: e["body"] as? String ?? "", mid: mid, at: date(at))
one.notBefore = (e["notBefore"] as? Int).map(date(_:))
one.gid = e["gid"] as? String
one.wire = e["wire"] as? String
return one
}
if !entries.isEmpty { snapshot.outbox = entries }
}
if let a = root["storyAudience"] as? [String: Any],
let mode = (a["mode"] as? String).flatMap(E2EStoryAudience.Mode.init(rawValue:))
{
var audience = E2EStoryAudience()
audience.mode = mode
audience.userIDs = Set(a["ids"] as? [Int] ?? [])
snapshot.storyAudience = audience
}
if let muted = root["mutedStoryAuthors"] as? [Int], !muted.isEmpty {
snapshot.mutedStoryAuthors = muted
}
if let archive = root["storyArchive"] as? [[String: Any]] {
let entries = archive.compactMap(readStory(_:))
if !entries.isEmpty { snapshot.storyArchive = entries }
}
if let off = root["rosterOffServer"] as? Bool { snapshot.rosterOffServer = off }
if let drafts = root["drafts"] as? [String: String], !drafts.isEmpty {
snapshot.drafts = drafts
}
if let pins = root["pins"] as? [String: [String: Any]] {
var out: [String: E2EPin] = [:]
for (thread, p) in pins {
+49 -5
View File
@@ -108,6 +108,32 @@ struct BackupCarriesEveryFieldTests {
#expect(one.authorID == 9)
}
@Test("the account's own settings come back, not just its conversations")
func snapshotSettingsSurvive() throws {
var snapshot = E2EHistoryStore.Snapshot()
snapshot.outbox = [
E2EOutboxEntry(
to: 5, body: "not sent yet", mid: "o-1",
at: Date(timeIntervalSince1970: 1_757_000_000))
]
var audience = E2EStoryAudience()
audience.mode = .except
audience.userIDs = [8, 2]
snapshot.storyAudience = audience
snapshot.mutedStoryAuthors = [11]
snapshot.rosterOffServer = true
snapshot.drafts = ["3": "half a thought"]
let back = try E2EBackupPayload.decode(E2EBackupPayload.encode(snapshot))
#expect(back.outbox?.first?.mid == "o-1")
#expect(back.outbox?.first?.body == "not sent yet")
#expect(back.storyAudience?.mode == .except)
#expect(back.storyAudience?.userIDs == [2, 8])
#expect(back.mutedStoryAuthors == [11])
#expect(back.rosterOffServer == true)
#expect(back.drafts?["3"] == "half a thought")
}
/// Reading the source, because no round trip can fail for a property that
/// does not exist yet. Adding one to `E2EMessage` now means either carrying
/// it or naming it here as deliberately left behind.
@@ -124,14 +150,30 @@ struct BackupCarriesEveryFieldTests {
// `id` is a per-device local key the merge rekeys on content, and
// `date` and `isMine` are written positionally rather than by name.
let notCarried: Set<String> = ["id", "date", "isMine"]
// `version` is written as the payload's own "v", and `threads`,
// `groupThreads` and the rest of Snapshot's collections are encoded by
// name rather than through an `m.` accessor, so they are listed here
// and checked by the round trips above instead.
let notCarried: Set<String> = [
"id", "date", "isMine", "version", "threads", "groupThreads", "calls", "stories",
"roster", "groupNames", "groupSecrets", "pins",
]
for type in ["E2EMessage", "E2EGroupMessage"] {
guard let start = inbox.range(of: "struct \(type):") else {
// `Snapshot` had the same hole, wider: it declares fifteen properties
// and the encoder named eight, so an export also forgot your unsent
// messages, your drafts, your story audience and who you had muted.
let store = try String(
contentsOf: root.appendingPathComponent("Sources/E2E/E2EHistoryStore.swift"),
encoding: .utf8)
for (type, source) in [
("E2EMessage", inbox), ("E2EGroupMessage", inbox), ("Snapshot", store),
] {
guard let start = source.range(of: "struct \(type):") else {
Issue.record("\(type) is not where this test expects it")
continue
}
let body = inbox[start.upperBound...].prefix(while: { $0 != "}" })
let body = source[start.upperBound...].prefix(while: { $0 != "}" })
var missing: [String] = []
for line in body.split(separator: "\n") {
let t = line.trimmingCharacters(in: .whitespaces)
@@ -139,7 +181,9 @@ struct BackupCarriesEveryFieldTests {
let name = String(
t.dropFirst(4).prefix(while: { $0.isLetter || $0.isNumber || $0 == "_" }))
guard !name.isEmpty, !notCarried.contains(name) else { continue }
if !payload.contains("m.\(name)") { missing.append(name) }
if !payload.contains("m.\(name)") && !payload.contains("snapshot.\(name)") {
missing.append(name)
}
}
// `Comment` is only expressible by a literal, so a message that
// names the offending fields has to be built as one.