maarcadetweet: initial commit

AT Protocol PDS + AppView + Tauri Desktop Client, 160-char post limit.

- PDS (Rust + axum + sqlx)
  - Auth: createAccount, createSession, refreshSession
  - Records: createRecord, deleteRecord (race-safe via SELECT FOR UPDATE)
  - Feed: feed.like.create, feed.repost.create
  - Sync: getRepo, getBlocks, getLatestCommit, getRecord (with MST proof), listRepos
  - Identity: resolveHandle
  - MST: spec-conformant (at-mst crate, 27 tests)
  - Repo: signed commits, TID counter (monotonic, 4096 wrap safe)

- AppView (Rust + axum + sqlx)
  - Jetstream consumer (WebSocket, exponential backoff, 38k+ events indexed)
  - REST API: timeline/home (graph-aware), profile, search, post (with thread hydration)
  - Handle-sync worker (did:plc + did:web)
  - JSONB embed storage + thread columns (migration 0003)
  - Like/repost counter cache (migration 0004)

- Tauri 2 + Svelte 5 Desktop Client
  - System tray (Show/Compose/Quit menu)
  - OS notifications (tauri-plugin-notification)
  - Auto-update (tauri-plugin-updater, placeholder endpoint)
  - Window-state (tauri-plugin-window-state)
  - 160-char compose with live counter
  - Image/Link embed rendering
  - LocalStorage-persisted like state
  - Timeline with poll (prepend new posts)
  - Custom TitleBar (transparent, no decorations)
  - Orange/IBM Plex Mono maarcade design

Tests: 231 Rust + 9 vitest = 240 passed.
This commit is contained in:
tomdebone
2026-07-05 20:01:31 +02:00
commit c586fd39c9
134 changed files with 35279 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
use anyhow::Result;
use async_trait::async_trait;
use bytes::Bytes;
use cid::Cid;
use std::collections::HashMap;
#[async_trait]
pub trait Blockstore: Send + Sync {
async fn put(&self, cid: &Cid, block: Bytes) -> Result<()>;
async fn get(&self, cid: &Cid) -> Result<Option<Bytes>>;
async fn has(&self, cid: &Cid) -> Result<bool>;
async fn list(&self) -> Result<Vec<(Cid, Bytes)>>;
}
pub struct MemoryBlockstore {
inner: parking_lot::Mutex<HashMap<Cid, Bytes>>,
}
impl Default for MemoryBlockstore {
fn default() -> Self {
Self::new()
}
}
impl MemoryBlockstore {
pub fn new() -> Self {
Self {
inner: parking_lot::Mutex::new(HashMap::new()),
}
}
}
#[async_trait]
impl Blockstore for MemoryBlockstore {
async fn put(&self, cid: &Cid, block: Bytes) -> Result<()> {
self.inner.lock().insert(*cid, block);
Ok(())
}
async fn get(&self, cid: &Cid) -> Result<Option<Bytes>> {
Ok(self.inner.lock().get(cid).cloned())
}
async fn has(&self, cid: &Cid) -> Result<bool> {
Ok(self.inner.lock().contains_key(cid))
}
async fn list(&self) -> Result<Vec<(Cid, Bytes)>> {
Ok(self
.inner
.lock()
.iter()
.map(|(k, v)| (*k, v.clone()))
.collect())
}
}
+204
View File
@@ -0,0 +1,204 @@
use anyhow::{anyhow, Result};
use at_crypto::cid::cid_for_cbor;
use at_crypto::signing::verify_dag_cbor;
use cid::Cid;
use k256::ecdsa::VerifyingKey;
use serde_json::Value;
/// A signed repository commit.
///
/// `Commit` carries the canonical DAG-CBOR serialization of a signed commit
/// block (including the `sig` field) together with the parsed fields. The
/// `cid` is the SHA-256 DAG-CBOR content-address of `signed_bytes`.
///
/// The `data` field is `Option<Cid>` to support commits on empty repositories
/// — an empty repo has no MST root to point at, so the JSON payload's `data`
/// is serialized as `null`.
#[derive(Debug, Clone)]
pub struct Commit {
pub cid: Cid,
pub signed_bytes: Vec<u8>,
pub did: String,
pub rev: String,
pub prev: Option<Cid>,
pub data: Option<Cid>,
}
impl Commit {
/// Verify the commit's signature.
///
/// Delegates the actual cryptographic check to [`at_crypto::signing::verify_dag_cbor`],
/// which uses the `pubkey` field embedded inside the signed commit. The
/// `signing_pubkey` argument is the caller-trusted key — we additionally
/// require the embedded pubkey to match it, so a malicious swap of the
/// `pubkey` field (followed by a forged signature under the swapped key)
/// is rejected.
pub fn verify(&self, signing_pubkey: &VerifyingKey) -> Result<()> {
let embedded = verify_dag_cbor(&self.signed_bytes)?;
if &embedded != signing_pubkey {
return Err(anyhow!(
"commit embedded pubkey does not match expected signing pubkey"
));
}
Ok(())
}
/// Parse a signed commit block out of raw DAG-CBOR bytes.
///
/// This is used by `Repo::load` to reconstruct the head commit when
/// re-hydrating a repository from a blockstore.
pub fn from_signed_bytes(signed_bytes: Vec<u8>) -> Result<Self> {
let cid = cid_for_cbor(&signed_bytes)?;
let value: Value = ciborium::from_reader(&signed_bytes[..])
.map_err(|e| anyhow!("invalid commit CBOR: {e}"))?;
let obj = value
.as_object()
.ok_or_else(|| anyhow!("commit CBOR is not an object"))?;
let did = obj
.get("did")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("commit missing `did`"))?
.to_string();
let rev = obj
.get("rev")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("commit missing `rev`"))?
.to_string();
let prev = parse_optional_cid(obj.get("prev"), "prev")?;
let data = parse_optional_cid(obj.get("data"), "data")?;
Ok(Self {
cid,
signed_bytes,
did,
rev,
prev,
data,
})
}
}
fn parse_optional_cid(value: Option<&Value>, field: &str) -> Result<Option<Cid>> {
match value {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => s
.parse::<Cid>()
.map(Some)
.map_err(|e| anyhow!("commit `{field}` is not a valid CID: {e}")),
Some(_) => Err(anyhow!("commit `{field}` must be null or string")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use at_crypto::did_key::pubkey_to_multibase;
use k256::ecdsa::SigningKey;
use k256::SecretKey;
use k256::PublicKey;
fn make_test_commit(
sk: &SigningKey,
did: &str,
rev: &str,
prev: Option<&str>,
data: Option<&str>,
) -> Commit {
let pk: PublicKey = sk.verifying_key().into();
let mb = pubkey_to_multibase(&pk).unwrap();
let mut payload = serde_json::json!({
"did": did,
"version": 3,
"prev": prev.map(|s| serde_json::Value::String(s.to_string()))
.unwrap_or(serde_json::Value::Null),
"data": data.map(|s| serde_json::Value::String(s.to_string()))
.unwrap_or(serde_json::Value::Null),
"rev": rev,
"pubkey": mb,
});
let _ = payload.as_object_mut().unwrap().remove("sig");
let signed = at_crypto::signing::sign_dag_cbor(sk, &payload).unwrap();
Commit::from_signed_bytes(signed.signed_bytes).unwrap()
}
#[test]
fn self_signed_commit_verifies() {
let sk = SigningKey::from(SecretKey::from_slice(&[7u8; 32]).unwrap());
let commit = make_test_commit(&sk, "did:plc:abc", "0", None, None);
assert!(commit.verify(&sk.verifying_key()).is_ok());
}
#[test]
fn wrong_key_fails_verify() {
let sk = SigningKey::from(SecretKey::from_slice(&[7u8; 32]).unwrap());
let sk2 = SigningKey::from(SecretKey::from_slice(&[9u8; 32]).unwrap());
let commit = make_test_commit(&sk, "did:plc:abc", "0", None, None);
assert!(commit.verify(&sk2.verifying_key()).is_err());
}
#[test]
fn commit_with_prev_and_data_roundtrip() {
let sk = SigningKey::from(SecretKey::from_slice(&[11u8; 32]).unwrap());
let prev_cid: Cid = "bafyreig7qfkqdk5v3jy3z6xgc4n3yh6ycjxqrvt5pqjpwxgvvcxyzw7tqy"
.parse()
.unwrap();
let data_cid: Cid = "bafyreihzfgvyuwdq5i3qaqj2vnlv4bgw2xhcsoa2uh2pqkpcw55nuefzzi"
.parse()
.unwrap();
let commit = make_test_commit(
&sk,
"did:plc:abc",
"abc123",
Some(&prev_cid.to_string()),
Some(&data_cid.to_string()),
);
assert_eq!(commit.did, "did:plc:abc");
assert_eq!(commit.rev, "abc123");
assert_eq!(commit.prev, Some(prev_cid));
assert_eq!(commit.data, Some(data_cid));
assert!(commit.verify(&sk.verifying_key()).is_ok());
}
#[test]
fn tampered_signed_bytes_fail_verify() {
let sk = SigningKey::from(SecretKey::from_slice(&[15u8; 32]).unwrap());
let commit = make_test_commit(&sk, "did:plc:abc", "0", None, None);
// Flip a bit in `sig` (last bytes of the CBOR object). The simplest
// way to land inside `sig` (a hex string of ~128 chars) is to flip a
// byte near the tail of the payload.
let mut tampered_bytes = commit.signed_bytes.clone();
let len = tampered_bytes.len();
tampered_bytes[len - 4] ^= 0x01;
tampered_bytes[len - 3] ^= 0x01;
let res = match Commit::from_signed_bytes(tampered_bytes) {
Ok(c) => c.verify(&sk.verifying_key()),
Err(e) => Err(e),
};
assert!(
res.is_err(),
"tampered commit must not verify; got Ok"
);
}
#[test]
fn tampered_field_fails_verify() {
let sk = SigningKey::from(SecretKey::from_slice(&[16u8; 32]).unwrap());
let commit = make_test_commit(&sk, "did:plc:abc", "0", None, None);
// Parse the signed CBOR object, swap the `did`, and re-encode. The
// resulting CID is different, but the signature over the unsigned
// payload is now stale — verification must fail.
let mut value: Value = ciborium::from_reader(commit.signed_bytes.as_slice()).unwrap();
{
let obj = value.as_object_mut().unwrap();
obj.insert("did".into(), Value::String("did:plc:imposter".into()));
}
let mut new_bytes = Vec::new();
ciborium::into_writer(&value, &mut new_bytes).unwrap();
let res = match Commit::from_signed_bytes(new_bytes) {
Ok(c) => c.verify(&sk.verifying_key()),
Err(e) => Err(e),
};
assert!(res.is_err(), "did swap must invalidate signature");
}
}
+9
View File
@@ -0,0 +1,9 @@
pub mod blockstore;
pub mod commit;
pub mod repo;
pub mod rev;
pub use blockstore::{Blockstore, MemoryBlockstore};
pub use commit::Commit;
pub use repo::Repo;
pub use rev::Tid;
+527
View File
@@ -0,0 +1,527 @@
use anyhow::{anyhow, Result};
use at_crypto::did_key::pubkey_to_multibase;
use at_crypto::signing::sign_dag_cbor;
use at_mst::util::encode_key;
use at_mst::Mst;
use bytes::Bytes;
use cid::Cid;
use k256::ecdsa::SigningKey;
use k256::PublicKey;
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use crate::blockstore::Blockstore;
use crate::commit::Commit;
use crate::rev::Tid;
/// A single repository: a content-addressed Merkle Search Tree backed by a
/// [`Blockstore`], with a secp256k1 signing key used to authorize commits.
///
/// `Repo` is the mutable in-memory representation. The immutable history is
/// encoded in the linked list of [`Commit`] blocks, and the current state is
/// restored from that chain via [`Repo::load`].
///
/// Operations on the repo (`put_record`, `delete_record`, `commit`) all
/// persist their newly produced blocks through `self.blockstore`. A
/// production implementation would back the blockstore with durable storage
/// (e.g. a Postgres-backed blockstore); tests use [`crate::MemoryBlockstore`].
pub struct Repo<B: Blockstore> {
pub did: String,
pub signing_key: SigningKey,
pub mst: Mst,
pub blockstore: Arc<B>,
pub prev_commit_cid: Option<Cid>,
pub rev: String,
/// CIDs of value blocks we've written, tracked so [`Repo::serialize_repo`]
/// can include them in the output.
value_cids: HashSet<Cid>,
}
impl<B: Blockstore> Repo<B> {
/// Construct a new empty repo for `did`, signed by `signing_key` and
/// stored in `blockstore`. The repo has no MST and no prior commit.
pub fn new(did: String, signing_key: SigningKey, blockstore: Arc<B>) -> Self {
Self {
did,
signing_key,
mst: Mst::new(),
blockstore,
prev_commit_cid: None,
rev: Tid::new().as_str().to_string(),
value_cids: HashSet::new(),
}
}
/// Add (or update) a record at `at://{did}/{collection}/{rkey}` pointing
/// to `value_cid`.
///
/// The caller is responsible for storing the value's CBOR block in
/// `self.blockstore` (typically before this call, via the route handler).
/// This method only persists the new MST node blocks produced by the
/// underlying [`Mst::put`].
pub async fn put_record(
&mut self,
collection: &str,
rkey: &str,
value_cid: Cid,
) -> Result<(String, Cid)> {
let raw_key = format!("{collection}/{rkey}");
// The MST encodes the key internally via encode_key; the redundant
// call here is retained as documentation of the wire-format contract.
let _ = encode_key(&raw_key);
let new_mst = self.mst.clone().put(raw_key, value_cid, None)?;
self.mst = new_mst;
self.value_cids.insert(value_cid);
self.persist_mst_blocks().await?;
let uri = format!("at://{}/{}/{}", self.did, collection, rkey);
Ok((uri, value_cid))
}
/// Remove the record at `at://{did}/{collection}/{rkey}` if present.
/// Persists the new MST node blocks produced by the underlying
/// [`Mst::delete`].
pub async fn delete_record(&mut self, collection: &str, rkey: &str) -> Result<()> {
let raw_key = format!("{collection}/{rkey}");
let new_mst = self.mst.clone().delete(raw_key)?;
self.mst = new_mst;
self.persist_mst_blocks().await?;
Ok(())
}
/// Lookup the value CID for a record. Returns `Ok(None)` if absent.
pub async fn get_record(&self, collection: &str, rkey: &str) -> Result<Option<Cid>> {
let raw_key = format!("{collection}/{rkey}");
self.mst.get(&raw_key)
}
/// Build a signed commit over the current MST root, persist it in
/// `self.blockstore`, and update `prev_commit_cid` + `rev` so subsequent
/// commits link back to this one.
///
/// An empty repo (no MST entries) is allowed; the resulting commit's
/// `data` field is `null`.
pub async fn commit(&mut self) -> Result<Commit> {
let data_cid = self.mst.root_cid();
let pk: PublicKey = self.signing_key.verifying_key().into();
let pubkey_mb = pubkey_to_multibase(&pk)?;
let prev_value = self
.prev_commit_cid
.map(|c| Value::String(c.to_string()))
.unwrap_or(Value::Null);
let data_value = data_cid
.map(|c| Value::String(c.to_string()))
.unwrap_or(Value::Null);
let payload = json!({
"did": self.did,
"version": 3,
"prev": prev_value,
"data": data_value,
"rev": self.rev,
"pubkey": pubkey_mb,
});
let signed = sign_dag_cbor(&self.signing_key, &payload)?;
let signed_cid: Cid = signed
.cid
.parse()
.map_err(|e| anyhow!("signed commit CID parse: {e}"))?;
self.blockstore
.put(&signed_cid, Bytes::from(signed.signed_bytes.clone()))
.await?;
let commit = Commit {
cid: signed_cid,
signed_bytes: signed.signed_bytes,
did: self.did.clone(),
rev: self.rev.clone(),
prev: self.prev_commit_cid,
data: data_cid,
};
self.prev_commit_cid = Some(commit.cid);
self.rev = Tid::new().as_str().to_string();
Ok(commit)
}
/// Serialize the repo to a CAR-like pair `(header_bytes, blocks_map)`.
///
/// `header_bytes` is the latest signed commit block, or empty if no
/// commit has been produced yet. `blocks_map` contains every MST block,
/// every tracked value block, and the commit block.
pub async fn serialize_repo(&self) -> Result<(Vec<u8>, HashMap<Cid, Vec<u8>>)> {
let (_root_bytes, mut all_blocks) = self.mst.serialize()?;
let header = if let Some(commit_cid) = self.prev_commit_cid {
match self.blockstore.get(&commit_cid).await? {
Some(bytes) => {
let v = bytes.to_vec();
all_blocks.insert(commit_cid, v.clone());
v
}
None => Vec::new(),
}
} else {
Vec::new()
};
for cid in &self.value_cids {
if let Some(bytes) = self.blockstore.get(cid).await? {
all_blocks.insert(*cid, bytes.to_vec());
}
}
Ok((header, all_blocks))
}
/// Reconstruct a `Repo` from a previously-stored head commit.
///
/// `head_commit_cid` must resolve via `blockstore.get` to a signed commit
/// block produced by `signing_key`. The blockstore must contain every MST
/// node block reachable from `commit.data`, plus the commit block itself.
pub async fn load(
did: String,
signing_key: SigningKey,
blockstore: Arc<B>,
head_commit_cid: Cid,
) -> Result<Self> {
let commit_bytes = blockstore
.get(&head_commit_cid)
.await?
.ok_or_else(|| anyhow!("head commit block not found in blockstore"))?;
let commit = Commit::from_signed_bytes(commit_bytes.to_vec())?;
let mut mst = Mst::new();
let mut value_cids: HashSet<Cid> = HashSet::new();
if let Some(root) = commit.data {
let blocks = collect_mst_blocks(blockstore.as_ref(), root).await?;
mst = Mst::from_blocks(blocks, root);
// Walk the loaded MST to populate `value_cids` so that
// `serialize_repo` includes every value block produced by prior
// writes. (We only know about values added since the last
// in-process load otherwise.)
if !mst.is_empty() {
let empty = Mst::new();
for entry in mst.diff(&empty)? {
value_cids.insert(entry.cid);
}
}
}
Ok(Self {
did,
signing_key,
mst,
blockstore,
prev_commit_cid: Some(head_commit_cid),
rev: Tid::new().as_str().to_string(),
value_cids,
})
}
async fn persist_mst_blocks(&self) -> Result<()> {
for (cid, bytes) in self.mst.blocks() {
self.blockstore
.put(cid, Bytes::from(bytes.clone()))
.await?;
}
Ok(())
}
}
// -- internal helpers --------------------------------------------------------
/// Local mirror of `at_mst`'s on-the-wire node format. We need this to walk
/// MST blocks from a `Blockstore` whose API is `get(cid) -> Option<Bytes>`
/// rather than an iterator: `at_mst` exposes `Mst::from_blocks` but the tree
/// walk is internal, so we parse the node-shape here and skip past value CIDs
/// (which are not MST nodes).
#[derive(Deserialize)]
struct WireNode {
#[serde(rename = "l")]
left: Option<Cid>,
#[serde(rename = "e")]
entries: Vec<WireEntry>,
}
#[derive(Deserialize)]
struct WireEntry {
#[serde(rename = "v")]
#[allow(dead_code)]
value: Cid,
#[serde(rename = "t")]
tree: Option<Cid>,
}
async fn collect_mst_blocks<B: Blockstore + ?Sized>(
blockstore: &B,
root: Cid,
) -> Result<HashMap<Cid, Vec<u8>>> {
let mut out: HashMap<Cid, Vec<u8>> = HashMap::new();
let mut stack: Vec<Cid> = vec![root];
while let Some(cid) = stack.pop() {
if out.contains_key(&cid) {
continue;
}
let bytes = blockstore
.get(&cid)
.await?
.ok_or_else(|| anyhow!("missing block for cid {cid}"))?;
// A block is only included if it parses as an MST node; value blocks
// (and any other CBOR blocks) are skipped past.
match ciborium::from_reader::<WireNode, _>(bytes.as_ref()) {
Ok(node) => {
if let Some(l) = node.left {
stack.push(l);
}
for entry in node.entries {
if let Some(t) = entry.tree {
stack.push(t);
}
}
out.insert(cid, bytes.to_vec());
}
Err(_) => {
// Not an MST node — likely a value block. Skip.
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use at_crypto::cid::cid_for_cbor;
use k256::ecdsa::SigningKey;
use k256::SecretKey;
fn dummy_value_bytes(s: &str) -> Vec<u8> {
let v = serde_json::json!({"text": s});
let mut buf = Vec::new();
ciborium::into_writer(&v, &mut buf).unwrap();
buf
}
fn dummy_repo() -> (Repo<crate::MemoryBlockstore>, SigningKey) {
let sk = SigningKey::from(SecretKey::from_slice(&[42u8; 32]).unwrap());
let bs = Arc::new(crate::MemoryBlockstore::new());
let repo = Repo::new("did:plc:test".into(), sk.clone(), bs);
(repo, sk)
}
async fn put_value(
repo: &mut Repo<crate::MemoryBlockstore>,
coll: &str,
rkey: &str,
label: &str,
) -> Cid {
let bytes = dummy_value_bytes(label);
let cid = cid_for_cbor(&bytes).unwrap();
repo.blockstore.put(&cid, Bytes::from(bytes)).await.unwrap();
repo.put_record(coll, rkey, cid).await.unwrap();
cid
}
#[tokio::test]
async fn put_then_get_returns_value_cid() {
let (mut repo, _sk) = dummy_repo();
let cid = put_value(&mut repo, "app.twi.post", "abc", "hello").await;
let (uri, returned) = repo
.put_record("app.twi.post", "abc", cid)
.await
.unwrap();
assert_eq!(uri, "at://did:plc:test/app.twi.post/abc");
assert_eq!(returned, cid);
let got = repo
.get_record("app.twi.post", "abc")
.await
.unwrap()
.expect("record present");
assert_eq!(got, cid);
}
#[tokio::test]
async fn missing_key_returns_none() {
let (repo, _sk) = dummy_repo();
let got = repo.get_record("c", "missing").await.unwrap();
assert_eq!(got, None);
}
#[tokio::test]
async fn delete_record_removes_key() {
let (mut repo, _sk) = dummy_repo();
let cid = put_value(&mut repo, "c", "a", "v1").await;
assert_eq!(repo.get_record("c", "a").await.unwrap(), Some(cid));
repo.delete_record("c", "a").await.unwrap();
assert_eq!(repo.get_record("c", "a").await.unwrap(), None);
}
#[tokio::test]
async fn commit_verifies_with_signing_key() {
let (mut repo, sk) = dummy_repo();
put_value(&mut repo, "c", "a", "v1").await;
let commit = repo.commit().await.unwrap();
assert!(commit.verify(&sk.verifying_key()).is_ok());
}
#[tokio::test]
async fn two_commits_produce_different_cids() {
let (mut repo, _sk) = dummy_repo();
put_value(&mut repo, "c", "a", "v1").await;
let c1 = repo.commit().await.unwrap();
put_value(&mut repo, "c", "b", "v2").await;
let c2 = repo.commit().await.unwrap();
assert_ne!(c1.cid, c2.cid);
assert_eq!(c2.prev, Some(c1.cid));
}
#[tokio::test]
async fn commit_data_equals_mst_root() {
let (mut repo, _sk) = dummy_repo();
put_value(&mut repo, "c", "k", "v1").await;
let commit = repo.commit().await.unwrap();
assert_eq!(commit.data, repo.mst.root_cid());
}
#[tokio::test]
async fn first_commit_prev_is_none() {
let (mut repo, _sk) = dummy_repo();
let commit = repo.commit().await.unwrap();
assert_eq!(commit.prev, None);
assert_eq!(commit.data, None);
}
#[tokio::test]
async fn commit_prev_links_to_previous_commit() {
let (mut repo, _sk) = dummy_repo();
put_value(&mut repo, "c", "a", "v1").await;
let c1 = repo.commit().await.unwrap();
put_value(&mut repo, "c", "b", "v2").await;
let c2 = repo.commit().await.unwrap();
assert_eq!(c2.prev, Some(c1.cid));
}
#[tokio::test]
async fn serialize_repo_includes_mst_and_commit_blocks() {
let (mut repo, _sk) = dummy_repo();
let value_cid = put_value(&mut repo, "c", "a", "v1").await;
let commit = repo.commit().await.unwrap();
let (header, blocks) = repo.serialize_repo().await.unwrap();
assert_eq!(header, commit.signed_bytes);
assert!(
blocks.contains_key(&commit.cid),
"commit block must be present"
);
let root_cid = repo.mst.root_cid().unwrap();
assert!(
blocks.contains_key(&root_cid),
"MST root must be present"
);
assert!(
blocks.contains_key(&value_cid),
"value block must be present"
);
// Every block should be self-consistent under its CID.
for (cid, bytes) in &blocks {
let computed = cid_for_cbor(bytes).unwrap();
assert_eq!(*cid, computed, "block CID mismatch for {cid}");
}
}
#[tokio::test]
async fn serialize_repo_before_commit_has_empty_header() {
let (mut repo, _sk) = dummy_repo();
put_value(&mut repo, "c", "a", "v1").await;
let (header, blocks) = repo.serialize_repo().await.unwrap();
assert!(header.is_empty(), "header bytes must be empty pre-commit");
assert!(
!blocks.is_empty(),
"should have MST blocks even without a commit"
);
}
#[tokio::test]
async fn empty_repo_commit_has_null_data() {
let (mut repo, _sk) = dummy_repo();
let commit = repo.commit().await.unwrap();
assert_eq!(commit.data, None);
// The signed CBOR must encode `data` as JSON null.
let value: Value = ciborium::from_reader(commit.signed_bytes.as_slice()).unwrap();
assert_eq!(value["data"], Value::Null);
}
#[tokio::test]
async fn load_round_trip_preserves_mst_entries() {
let (mut repo1, sk) = dummy_repo();
let c1 = put_value(&mut repo1, "c", "a", "v1").await;
let head = repo1.commit().await.unwrap();
let c2 = put_value(&mut repo1, "c", "b", "v2").await;
let head_with_b = repo1.commit().await.unwrap();
assert_eq!(head_with_b.prev, Some(head.cid));
// Sanity check before we load.
assert_eq!(
repo1.get_record("c", "a").await.unwrap(),
Some(c1)
);
assert_eq!(
repo1.get_record("c", "b").await.unwrap(),
Some(c2)
);
// Reconstruct from the second commit which contains both records.
let mut repo2 = Repo::<crate::MemoryBlockstore>::load(
"did:plc:test".into(),
sk,
repo1.blockstore.clone(),
head_with_b.cid,
)
.await
.unwrap();
assert_eq!(repo2.get_record("c", "a").await.unwrap(), Some(c1));
assert_eq!(repo2.get_record("c", "b").await.unwrap(), Some(c2));
// A subsequent commit links back to the reconstructed head.
put_value(&mut repo2, "c", "c", "v3").await;
let next = repo2.commit().await.unwrap();
assert_eq!(next.prev, Some(head_with_b.cid));
}
#[tokio::test]
async fn load_repopulates_value_cids_for_serialize() {
// After Repo::load, serialize_repo should include pre-existing value
// blocks (not just blocks added since the load).
let (mut repo1, sk) = dummy_repo();
let c1 = put_value(&mut repo1, "c", "a", "v1").await;
let head = repo1.commit().await.unwrap();
let repo2 = Repo::<crate::MemoryBlockstore>::load(
"did:plc:test".into(),
sk,
repo1.blockstore.clone(),
head.cid,
)
.await
.unwrap();
let (_h, blocks) = repo2.serialize_repo().await.unwrap();
assert!(
blocks.contains_key(&c1),
"value block must be present after load + serialize_repo"
);
assert!(blocks.contains_key(&head.cid));
}
#[tokio::test]
async fn rev_increments_after_commit() {
let (mut repo, _sk) = dummy_repo();
let r0 = repo.rev.clone();
let commit = repo.commit().await.unwrap();
assert_eq!(commit.rev, r0);
// After commit(), rev has been bumped.
assert_ne!(repo.rev, r0);
}
}
+175
View File
@@ -0,0 +1,175 @@
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
pub const TID_BASE32: &[u8] = b"234567abcdefghijklmnopqrstuvwxyz";
/// Process-local monotonic counter used to disambiguate TIDs that would
/// otherwise collide on the same microsecond.
///
/// The wall clock gives us 13 base32 chars of timestamp (≈52 bits of
/// micros). Two writes in the same microsecond on the same PDS would
/// otherwise produce the identical TID, and `put_record` would silently
/// overwrite the prior record in the MST (same rkey, but actually
/// different content — the value CIDs differ but the MST key is the
/// TID, so the prior record becomes unreachable from the head commit).
///
/// We tack 12 low bits of a `fetch_add` counter into the encoded value
/// so back-to-back calls — even inside the same microsecond — always
/// yield different TIDs. The counter starts at 0; the first call's
/// fetch_add returns 0 and produces a TID encoding
/// `(now_micros << 12) | 0`. The counter is monotonic per process,
/// not globally — a process restart will reset it to 0, which means
/// a TID emitted by the new process may sort *before* a TID emitted
/// by its predecessor on the same wall-clock microsecond. That's
/// acceptable because TIDs are only used as MST rkeys within a
/// single repo's history; the protocol doesn't require cross-process
/// monotonicity.
static TID_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Tid {
pub raw: String,
}
impl Tid {
pub fn new() -> Self {
Self {
raw: generate_tid(),
}
}
pub fn from_string(s: impl Into<String>) -> Self {
Self { raw: s.into() }
}
pub fn as_str(&self) -> &str {
&self.raw
}
}
impl Default for Tid {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for Tid {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.raw)
}
}
pub fn generate_tid() -> String {
// Phase 5b H10 — the counter is 12 bits wide, so it would wrap after
// 4096 calls inside a single microsecond. To prevent that, we
// block until the wall clock advances whenever the low-12 counter
// has cycled back to 0 inside the same microsecond. In practice
// this never fires (4096 TIDs/µs ≈ 4 billion/sec from one process)
// but it's a cheap insurance policy.
let mut now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_micros() as u64;
let counter = loop {
let prev = TID_COUNTER.fetch_add(1, Ordering::Relaxed);
// The counter is reset to 0 at process start; the low 12 bits
// are an in-microsecond disambiguator. If we've wrapped back to
// 0 mid-microsecond, spin until the clock advances.
if (prev & 0xFFF) == 0 && prev != 0 {
let next = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_micros() as u64;
if next == now {
std::hint::spin_loop();
continue;
}
now = next;
}
break prev;
};
// Combine timestamp + 12-bit disambiguator. The wall clock fits
// comfortably in 52 bits, so the counter never spills into the
// timestamp portion for any realistic process lifetime (~year 2400).
let combined: u64 = (now << 12) | (counter & 0xFFF);
let mut s = String::with_capacity(13);
let mut n = combined;
for _ in 0..13 {
let idx = (n & 0x1F) as usize;
s.push(TID_BASE32[idx] as char);
n >>= 5;
}
s.chars().rev().collect()
}
pub fn compare_tid(a: &str, b: &str) -> std::cmp::Ordering {
a.cmp(b)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn tid_increases() {
let t1 = generate_tid();
std::thread::sleep(std::time::Duration::from_millis(2));
let t2 = generate_tid();
// Strict ordering: t2 must be greater than t1. Accepting `is_le`
// would mask the very bug this test exists to catch.
assert!(compare_tid(&t1, &t2).is_lt());
}
#[test]
fn tid_uses_lowercase_base32() {
let t = generate_tid();
for c in t.chars() {
assert!(matches!(c, '2'..='7' | 'a'..='z'));
}
}
/// Phase 5b H10 — two writes in the same microsecond used to
/// produce identical TIDs, which caused `put_record` to silently
/// overwrite the prior record (different value CID, but the same
/// rkey, so the new MST entry eclipsed the old). Verify a tight
/// burst of N calls yields N distinct TIDs.
#[test]
fn generate_tid_is_monotonic_per_process() {
let n = 1_000;
let mut seen = HashSet::with_capacity(n);
let mut prev: Option<String> = None;
for _ in 0..n {
let t = generate_tid();
assert!(
seen.insert(t.clone()),
"duplicate TID produced in tight loop: {t}"
);
if let Some(p) = prev.as_ref() {
assert!(
compare_tid(p, &t).is_lt(),
"TID must strictly increase per process: {p} >= {t}"
);
}
prev = Some(t);
}
assert_eq!(seen.len(), n);
}
/// Same as above but explicitly constructs the "same microsecond"
/// worst case by sampling TIDs back-to-back without sleeping. The
/// counter overlay must keep them distinct even when the wall
/// clock doesn't tick.
#[test]
fn generate_tid_avoids_same_microsecond_collisions() {
let n = 100;
let mut seen = HashSet::with_capacity(n);
for _ in 0..n {
let t = generate_tid();
assert!(seen.insert(t.clone()), "collision at {t}");
}
assert_eq!(seen.len(), n);
}
}