SwiftsyncShellby

One Sync Protocol, Two Tech Stacks — And a Contract zlib Forced Me to Change

A Shellby postmortem: a six-platform SSH client needs cross-ecosystem sync (Apple is SwiftData, Flutter is drift), over the user's own third-party storage, no server, end-to-end encrypted. The hard part is making 'the same data consistent after sync on both stacks' — and mid-implementation, one zlib detail overturned the 'byte-identical ciphertext' contract.

Shellby is a six-platform SSH client: Apple side in Swift + SwiftData, Flutter side in drift (SQLite), covering Android / Windows / Linux / HarmonyOS. The iCloud setup only syncs between Apple devices; an iPhone + Android user has to connect the two ends through their own third-party storage (WebDAV / S3), with no self-hosted server.

The storage is zero-trust — end-to-end encrypted before upload, it only sees ciphertext. The real difficulty is elsewhere: how do you make “the same data” stay consistent after syncing across two completely different tech stacks? This post covers that cross-stack sync design, and how one zlib detail mid-implementation forced me to change the original contract.

The contract is not “byte-identical ciphertext”

The natural first idea: both stacks encrypt with the same format, produce byte-identical ciphertext, and consistency follows automatically. That idea survived until implementation, then died on compression.

The archive is compressed with deflate. And deflate output can’t be guaranteed byte-identical across zlib implementations: Dart (dart:io) bundles Chromium’s zlib, whose level-6 deflate output is one byte shorter than the system/vanilla zlib — I tested the entire parameter space of Python 1.2.12 and Apple’s system zlib and could reproduce none of Dart’s output.

That closed the door on “byte-identical ciphertext” as a contract. And one more layer: in production, every encryption uses a random content key and nonce, so the two stacks never produce identical ciphertext anyway — ciphertext equality was never a system invariant that could hold.

So the contract became three more essential clauses:

  1. both stacks can decrypt the same shared vector into the same canonical plaintext;
  2. each stack, encrypting with the same parameters, is self-consistent-deterministic;
  3. the header and the wrapped content key (wrappedCEK) are byte-identical across stacks.

In one line: the contract is “mutually decryptable + each-side deterministic”, not “byte-identical output”. A knock-on decision: the Swift side links system libz directly, not Apple’s Compression framework (its own deflate, inconsistent with zlib); and the gzip header’s MTIME / XFL / OS are all pinned, because zlib writes the OS byte differently per platform.

Three layers: separating “language-neutral” from “platform adaptation”

Consistency ultimately rests on three things: canonical JSON byte-replicated across stacks, a pure-function deterministic merge, and a pure mapping from domain record to payload row. The code is cut into three layers, Swift and Flutter each mirroring the other:

  • SyncKit: the language-neutral sync core — payload model, record-level LWW merge, envelope-encrypted archive, WebDAV / S3 providers, sync engine. No SwiftData dependency, only Foundation / CryptoKit / zlib;
  • SyncBridge: the pure mapping between domain record and payload row. A set of pure value types HostRecord / GroupRecord / … and their encode/decode to a {id, updatedAt, data} row, touching no persistence framework;
  • SyncAppKit: the SwiftData adapter — reads models like SSHHost into domain records for SyncBridge, handles reconcile diffing and engine orchestration.

The key is why the middle layer is pure. SyncBridge is pure value types plus pure functions, which buys two things: it’s unit-testable without spinning up a ModelContainer (SwiftData’s container-level tests crash inside an SPM bundle, a known limitation, so the logic must be verifiable outside a container); and the pure mapping’s output is itself the cross-stack contract bytes — Swift and Dart each encoding the same record must yield the same row, and the vectors in Tests/Fixtures/ are shared by both stacks.

Two unglamorous alignments in the pure mapping that drift if you miss them: tags serialized as a JSON string (not an array) to align with Flutter drift’s TEXT column; and a credential-reference convention like host.<id>.password so both stacks map the same ref to the same credential.

Canonical JSON: you can’t use JSONEncoder

The first hurdle to byte-identical cross-stack output is serialization. JSONEncoder won’t do — you hand-write it, because it disagrees with Dart’s json.encode in three places:

  • .sortedKeys sorts recursively at all levels, but Dart only sorts the “row” top-level keys and preserves the nested data’s original order;
  • JSONEncoder escapes / as \/ by default, Dart doesn’t;
  • you need precise control over compact, whitespace-free formatting.

So you build an ordered JSON value model (objects carried as [(key, value)], insertion order preserved) that faithfully replicates Dart’s escaping. A row’s top-level keys end up sorted (data / id / updatedAt), but data’s inside stays in declaration order (name / hostname / port …, non-alphabetical) — this “top sorted, nested preserved” detail is a precondition for the deterministic merge below.

Deterministic merge: any order converges to the same result

Multiple devices edit offline; merging can’t be “last-write-overwrites the whole blob” — that loses whole-package data under concurrency. It uses a record-level deterministic LWW: take the newer updatedAt, and any device merging in any order converges to the same result, with no server arbiter. This happens to align with Apple CloudKit’s field-level “last-write-wins” semantics, unifying both stacks.

The hard part is the tie — how to deterministically pick one when updatedAt is equal:

func pickSurvivor(_ a: JSONValue?, _ b: JSONValue?) -> JSONValue? {
    let ua = recordUpdatedAt(a), ub = recordUpdatedAt(b)
    if ua != ub { return ua > ub ? a : b }
    // Tie: deterministically pick the larger JSON (top-level-sorted view), avoiding cross-device divergence.
    let ja = SyncPayload.sortTopLevel(a).canonicalString()
    let jb = SyncPayload.sortTopLevel(b).canonicalString()
    return compareUTF16(ja, jb) >= 0 ? a : b
}

Two deeply buried prerequisites: the comparison must be by UTF-16 code unit (replicating Dart’s String.compareTo) — use Swift’s default String < (Unicode-normalized comparison) and a record with a Chinese hostname would diverge from Dart, so two devices merge to different results and never converge; and, as noted, data stays in declaration order so the tie comparison’s bytes are identical across stacks. A single string-comparison convention decides whether a distributed merge converges.

reconcile: no CRUD hooks, snapshot diffing instead

The Apple side has a real constraint: the live model carries a CloudKit mirror, and adding sync fields to it means migration and risk. So it doesn’t hook the existing CRUD paths; it uses snapshot diffing — sync metadata lives in a separate local container (cloudKitDatabase: .none, not in the main schema, zero migration risk), storing {version, contentHash, deleted, deletedAt} per record.

  • Change: on snapshot, compute a content fingerprint per row (FNV-1a of data’s canonical bytes, excluding the top-level version) and compare to last time. Changed → bump version monotonically to max(now, oldVersion + 1);
  • Delete: iterate metadata; “was synced, but the current domain row is gone” → generate a tombstone with deletedAt = max(now, version + 1), guaranteeing the deletion timestamp exceeds that record’s last synced version so deletion wins on merge;
  • No loop: after applying remote rows, recompute the fingerprint from the re-fetched landed models and write it back to metadata, so the next snapshot judges “unchanged” — otherwise it would treat a just-received remote update as a local change and push it back.
public static func contentHash(_ row: JSONValue) -> String {
    let data = row["data"]?.canonicalBytes() ?? []   // exclude top version: bump only on content change
    var h: UInt64 = 0xcbf29ce484222325
    for b in data { h ^= UInt64(b); h = h &* 0x100000001b3 }
    return String(h, radix: 16)
}

An honest side effect to note: the fingerprint excludes the top-level version, but data’s lastConnectedAt (which changes on every connection) is included — so connecting to a host once bumps that host’s sync version and re-archives it next snapshot. That’s a facet of the “version tracks content only” design.

Push uses conditional write as CAS

Multiple devices pushing the same blob concurrently rely on conditional writes: a push carries If-Match: <ETag> (WebDAV) or the S3 equivalent, and if it returns 412 (the other side wrote first), you re-pull, re-merge, and retry with bounded backoff. This is compare-and-swap on one blob, safely supporting concurrent multi-device access with no server logic at all. (How to degrade for backends like Alibaba OSS that don’t support conditional writes is another post’s story.)

Choose the protocol, not the vendor

One last product judgment worth recording: the providers implement only two protocols, WebDAV and S3 — no Jianguoyun, Alibaba Drive, or Dropbox integrations. The reasoning: rather than maintain five vendor SDKs across six platforms, adopt two open protocols — each already has both domestic (Jianguoyun / Alibaba OSS) and international (Nextcloud / R2) first-rate options, all pure HTTP, ~200 lines each in Swift and Dart, no vendor SDK, bring-your-own account.

Takeaways

  • The consistency you can guarantee across implementations is much weaker than “byte-identical output” — and much more realistic. deflate isn’t reproducible across zlib, and encryption uses a random key each time — defining the contract as “mutually decryptable + each-side deterministic” is both correct and less work. Before fixing a contract, ask whether that “equality” actually holds across all implementations;
  • The heart of a cross-stack contract is one pure mapping layer. Make “domain model ↔ wire format” a pure value type independent of any persistence framework, and its output is the contract bytes, verified by both stacks against the same fixtures — pure is what makes it testable and alignable;
  • A single string-comparison convention can decide whether a distributed merge converges. UTF-16 versus Unicode-normalized comparison for LWW ties is the difference between “both ends converge” and “diverge forever” on non-ASCII data. Every step of a deterministic merge must be pinned to the code-unit level;
  • For a model you can’t change, use side-channel snapshot diffing rather than hooking CRUD. Store sync metadata separately, judge change by content fingerprint, deletion by tombstone, recompute the fingerprint after landing to prevent loops — a whole sync layer without touching the main schema;
  • Adopt protocols, not vendors. Open protocols pull in “domestic + international” and “multiple first-rate options” at once, and spare you the debt of maintaining several SDKs per platform.

Comments

  • Loading…

Comments are reviewed before publishing; email is visible only to me.