~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

grain-serve backfills the index from hubble, or each account's PDS

Merged chadtmiller.com asked to merge into feature/rust from grain.social/grain:backfill/worker

Only what this pull adds. It is built on grain runs on a Rust server modelled on knotless, without hatk.

crates/grain/src/repo/car.rsnew+168−0
@@ -0,0 +1,168 @@
//! A repo as a CAR (CARv1), as a PDS's `com.atproto.sync.getRepo` answers:
//! a header naming the commit's block, then blocks, each its CID and its
//! bytes, in whatever order the PDS wrote them.
//!
//! Each block is held to its CID as it comes, and kept in a spill file,
//! only where each is held in memory. Once all are in, the commit's
//! signature is checked, and its tree walked from the root in key order,
//! each record given out as [`super::star::StarLite`] gives them out and
//! rebuilt into a root of its own the same way: a tree a PDS shaped other
//! than atproto would is refused, whatever its blocks hash to.
use super::mst::{Node, RootBuilder};
use super::{Commit, Frames, MAX_RECORD, Record, Spill, verify_commit};
use crate::crypto::PublicKey;
use crate::drisl;
use anyhow::{Context, Result, ensure};
use ciborium::Value as V;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::io::{BufWriter, Read, Seek, SeekFrom, Write};
/// The most a CAR's header may be.
const MAX_HEADER: usize = 4096;
/// The most a CAR's blocks may come to in all, spilled: past any repo
/// the network has.
pub const MAX_CAR: u64 = 16 << 30;
/// The CAR's blocks, in the spill, by CID: where each starts and how long.
struct Blocks {
spill: Box<dyn Spill>,
at: HashMap<Vec<u8>, (u64, usize)>,
}
impl Blocks {
fn get(&mut self, cid: &[u8]) -> Result<Vec<u8>> {
let what = || format!("a CAR without the block {}", drisl::cid_text(cid));
let &(from, length) = self.at.get(cid).with_context(what)?;
self.spill.seek(SeekFrom::Start(from))?;
let mut block = vec![0; length];
self.spill.read_exact(&mut block)?;
Ok(block)
}
}
/// What is left of the tree to walk, last first.
enum Next {
Node(Vec<u8>),
Record(String, Vec<u8>),
}
/// A CAR being read.
pub struct Car {
blocks: Blocks,
commit: Commit,
walk: Vec<Next>,
tree: RootBuilder,
}
impl Car {
/// Reads `did`'s CAR from `r`, its blocks into `spill`, and checks that
/// `key` signed the commit it names.
pub fn read(r: impl Read, did: &str, key: &PublicKey, spill: Box<dyn Spill>) -> Result<Car> {
let mut frames = Frames::new(r);
let header = frames
.frame(MAX_HEADER, "a CAR header")?
.unwrap_or_default();
let header: V = ciborium::from_reader(&header[..]).context("not a CAR")?;
let field = |name: &str| {
let pairs = header.as_map()?;
pairs
.iter()
.find(|(k, _)| k.as_text() == Some(name))
.map(|(_, v)| v)
};
let version = field("version").and_then(V::as_integer);
ensure!(version == Some(1.into()), "not a CAR of version 1");
let root = field("roots").and_then(|r| drisl::link(r.as_array()?.first()?));
let root = root.context("a CAR with no root")?.to_vec();
let mut spill = BufWriter::new(spill);
let (mut at, mut end) = (HashMap::new(), 0);
while let Some(block) = frames.frame(MAX_RECORD + 36, "a CAR block")? {
ensure!(
block.len() >= 36
&& block[0] == 1
&& [0x71, 0x55].contains(&block[1])
&& block[2..4] == [0x12, 0x20],
"a CAR block whose CID is not atproto's"
);
let (cid, bytes) = block.split_at(36);
ensure!(
Sha256::digest(bytes)[..] == cid[4..],
"a CAR block that is not what its CID says"
);
ensure!(
end + bytes.len() as u64 <= MAX_CAR,
"a CAR of more than {MAX_CAR} bytes"
);
spill.write_all(bytes)?;
at.insert(cid.to_vec(), (end, bytes.len()));
end += bytes.len() as u64;
}
let spill = spill.into_inner().map_err(|e| e.into_error())?;
let mut blocks = Blocks { spill, at };
let commit: V =
ciborium::from_reader(&blocks.get(&root)?[..]).context("a commit that is not CBOR")?;
let commit = verify_commit(commit, did, key)?;
Ok(Car {
blocks,
walk: vec![Next::Node(commit.data.clone())],
commit,
tree: RootBuilder::new(),
})
}
pub fn commit(&self) -> &Commit {
&self.commit
}
/// The next record, its key and its DRISL; none at the end.
pub fn record(&mut self) -> Result<Option<Record>> {
while let Some(next) = self.walk.pop() {
match next {
Next::Node(cid) => {
let node = Node::decode(&self.blocks.get(&cid)?)?;
for entry in node.entries.into_iter().rev() {
self.walk.extend(entry.right.map(Next::Node));
self.walk.push(Next::Record(entry.key, entry.value));
}
self.walk.extend(node.left.map(Next::Node));
}
Next::Record(key, cid) => {
let record = self.blocks.get(&cid)?;
self.tree.add(&key, &record)?;
return Ok(Some((key, record)));
}
}
}
Ok(None)
}
/// The commit, once the records read make the tree it signed.
pub fn finish(self) -> Result<Commit> {
let root = self.tree.root();
ensure!(
root == self.commit.data,
"the records do not make the tree the commit signed"
);
Ok(self.commit)
}
}
/// Writes a CAR of `blocks` naming `root` to `out`, each block's CID
/// worked out from it as DRISL.
pub fn write(out: &mut impl Write, root: &[u8], blocks: &[Vec<u8>]) -> std::io::Result<()> {
let header = V::Map(vec![
(
V::Text("roots".into()),
V::Array(vec![drisl::link_to(root)]),
),
(V::Text("version".into()), V::Integer(1.into())),
]);
super::star::frame(out, &drisl::encode(&header))?;
for block in blocks {
super::star::frame(out, &[drisl::cid(block), block.clone()].concat())?;
}
Ok(())
}
crates/grain/src/repo/mod.rsnew+229−0
@@ -0,0 +1,229 @@
//! A whole repo, read as an export and checked as it is read: every record
//! of it in key order, the tree's root rebuilt from them and held to the
//! root the account's commit names, and the commit held to the account's
//! signing key.
//!
//! An export is STAR-lite ([`star`]), as microcosm's hubble serves it, or
//! a CAR ([`car`]), as any PDS's `com.atproto.sync.getRepo` does; zstd it
//! may come in. [`open`] tells which by its first bytes. STAR-lite streams:
//! a record at a time, nothing of the repo held but one tree node a layer.
//! A CAR's blocks come in no order a reader can rely on, so they are kept
//! in a spill file, a few bytes each held in memory, and walked from its
//! commit once all are in.
//!
//! The parts are grain's own and depend on nothing of the rest of it but
//! [`crate::drisl`] and [`crate::crypto`], for knotless to take back.
pub mod car;
pub mod mst;
pub mod star;
use crate::crypto::PublicKey;
use crate::drisl;
use anyhow::{Context, Result, anyhow, ensure};
use ciborium::Value as V;
use std::io::{BufReader, Read, Seek, Write};
/// The most a record's key may be: a collection's 317 bytes, a slash and
/// a record key's 512.
pub const MAX_KEY: usize = 830;
/// The most a record may be, as atproto recommends.
pub const MAX_RECORD: usize = 1_048_576;
/// The most a commit may be.
pub const MAX_COMMIT: usize = 4096;
/// The bytes a zstd frame starts with.
pub const ZSTD: [u8; 4] = [0x28, 0xb5, 0x2f, 0xfd];
/// A record as a repo gives it out: its key, `collection/rkey`, and its
/// DRISL.
pub type Record = (String, Vec<u8>);
/// An account's commit, checked: whose repo it is, at which revision, and
/// the root of the tree it signed.
#[derive(Debug, Clone, PartialEq)]
pub struct Commit {
pub did: String,
pub rev: String,
/// The tree's root, a CID as bytes.
pub data: Vec<u8>,
/// The commit as signed, less `data`: as STAR-lite carries it.
pub partial: V,
}
/// `commit`, a commit's DRISL map, if it is `did`'s and `key` signed it.
pub fn verify_commit(commit: V, did: &str, key: &PublicKey) -> Result<Commit> {
let V::Map(mut fields) = commit else {
return Err(anyhow!("a commit that is not a map"));
};
let at =
|fields: &[(V, V)], name: &str| fields.iter().position(|(k, _)| k.as_text() == Some(name));
let sig = at(&fields, "sig").map(|i| fields.remove(i).1);
let sig = sig
.and_then(|s| s.into_bytes().ok())
.context("a commit with no signature")?;
let text = |name: &str| {
at(&fields, name)
.and_then(|i| fields[i].1.as_text())
.map(str::to_string)
};
let theirs = text("did").context("a commit with no DID")?;
ensure!(theirs == did, "a commit for {theirs}, not {did}");
let rev = text("rev").filter(|r| crate::lexicon::valid_tid(r));
let rev = rev.context("a commit with no revision")?;
let data = at(&fields, "data").and_then(|i| drisl::link(&fields[i].1).map(<[u8]>::to_vec));
let data = data.context("a commit with no tree")?;
let signed = drisl::encode(&V::Map(fields.clone()));
key.verify(&signed, &sig)
.context("a commit the account's key did not sign")?;
fields.retain(|(k, _)| k.as_text() != Some("data"));
fields.push((V::Text("sig".into()), V::Bytes(sig)));
Ok(Commit {
did: theirs,
rev,
data,
partial: V::Map(fields),
})
}
/// A stream of frames, each an unsigned varint length and that many
/// bytes, as STAR-lite and CAR both write them (and knotless's STAR v1).
pub struct Frames<R> {
r: BufReader<R>,
}
impl<R: Read> Frames<R> {
pub fn new(r: R) -> Frames<R> {
Frames {
r: BufReader::new(r),
}
}
/// The next length, in its shortest form: seven bits a byte, least
/// significant first, the high bit set on all but the last. None
/// where the stream ends before it.
pub fn length(&mut self) -> Result<Option<usize>> {
let (mut length, mut i) = (0, 0);
loop {
ensure!(i < 6, "a length too long");
let Some(byte) = self.r.by_ref().bytes().next().transpose()? else {
ensure!(i == 0, "it ends inside a length");
return Ok(None);
};
length += usize::from(byte & 0x7f) << (7 * i);
if byte < 0x80 {
ensure!(byte != 0 || i == 0, "a length not in its shortest form");
return Ok(Some(length));
}
i += 1;
}
}
/// Exactly `n` bytes, of `what`.
pub fn exact(&mut self, n: usize, what: &str) -> Result<Vec<u8>> {
let mut bytes = vec![0; n];
self.r.read_exact(&mut bytes).map_err(|e| match e.kind() {
std::io::ErrorKind::UnexpectedEof => anyhow!("it ends inside {what}"),
_ => e.into(),
})?;
Ok(bytes)
}
/// The next frame, of `what`, at most `max` bytes; none at the end.
pub fn frame(&mut self, max: usize, what: &str) -> Result<Option<Vec<u8>>> {
let Some(length) = self.length()? else {
return Ok(None);
};
ensure!(length <= max, "{what} of more than {max} bytes");
self.exact(length, what).map(Some)
}
}
/// An unsigned varint, as [`Frames::length`] reads it.
pub fn varint(mut n: usize) -> Vec<u8> {
let mut out = Vec::new();
while n >= 0x80 {
out.push((n as u8 & 0x7f) | 0x80);
n >>= 7;
}
out.push(n as u8);
out
}
/// Where a CAR's blocks are kept while it is read: a file, or for tests
/// memory.
pub trait Spill: Read + Write + Seek + Send {}
impl<T: Read + Write + Seek + Send> Spill for T {}
/// A body read from.
pub type Body = Box<dyn Read + Send>;
/// A repo export being read: its commit, checked, then its records in key
/// order, then its tree's root checked by [`Archive::finish`]. Until then
/// nothing read from it is proven, so nothing is to be kept for good.
pub enum Archive {
Star(star::StarLite<Body>),
Car(car::Car),
}
impl Archive {
/// The commit, its signature checked.
pub fn commit(&self) -> &Commit {
match self {
Archive::Star(s) => s.commit(),
Archive::Car(c) => c.commit(),
}
}
/// The next record: its key, `<collection>/<rkey>`, and its DRISL.
pub fn record(&mut self) -> Result<Option<Record>> {
match self {
Archive::Star(s) => s.record(),
Archive::Car(c) => c.record(),
}
}
/// The commit, once every record has been read and they make the tree
/// it signed.
pub fn finish(self) -> Result<Commit> {
match self {
Archive::Star(s) => s.finish(),
Archive::Car(c) => c.finish(),
}
}
}
/// `did`'s repo, from `body`, a STAR-lite archive or a CAR, zstd'd or not,
/// its commit checked against `key`. A CAR's blocks go to `spill`.
pub fn open(
body: impl Read + Send + 'static,
did: &str,
key: &PublicKey,
spill: Box<dyn Spill>,
) -> Result<Archive> {
let (head, body) = peek(Box::new(body))?;
let body: Body = if head.starts_with(&ZSTD) {
Box::new(zstd::stream::read::Decoder::new(body)?)
} else {
body
};
let (head, body) = peek(body)?;
Ok(if head.starts_with(&star::MAGIC) {
Archive::Star(star::StarLite::open(body, did, key)?)
} else {
Archive::Car(car::Car::read(body, did, key, spill)?)
})
}
/// The first bytes of `body`, and `body` as it was.
fn peek(body: Body) -> Result<(Vec<u8>, Body)> {
let mut head = Vec::new();
let mut body = body;
body.by_ref().take(4).read_to_end(&mut head)?;
let again = std::io::Cursor::new(head.clone()).chain(body);
Ok((head, Box::new(again)))
}
#[cfg(test)]
pub(crate) mod tests;
crates/grain/src/repo/mst.rsnew+264−0
@@ -0,0 +1,264 @@
//! A repo's Merkle Search Tree: its root rebuilt from the keys and records
//! in key order, holding one node a layer at a time, and its nodes read
//! back from their blocks. As opensocial's `RootBuilder`
//! (`packages/group-sites/src/repoArchive.ts`), after STAR-lite's
//! reference algorithm.
use crate::drisl;
use anyhow::{Context, Result, ensure};
use ciborium::Value as V;
use sha2::{Digest, Sha256};
/// How many layers up the tree `key` sits: its SHA-256's leading zero
/// bits, two to a layer, as atproto's fanout of four puts it.
pub fn layer(key: &[u8]) -> u32 {
let mut zeros = 0;
for byte in Sha256::digest(key) {
zeros += byte.leading_zeros();
if byte != 0 {
break;
}
}
zeros / 2
}
/// A node's entry: a key, its record's CID, and the subtree right of it.
#[derive(Debug, Clone, PartialEq)]
pub struct Entry {
pub key: String,
pub value: Vec<u8>,
pub right: Option<Vec<u8>>,
}
/// A tree node: the subtree left of its first entry, and its entries.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Node {
pub left: Option<Vec<u8>>,
pub entries: Vec<Entry>,
}
impl Node {
fn is_empty(&self) -> bool {
self.left.is_none() && self.entries.is_empty()
}
/// The node as atproto writes it: each key after the first as how much
/// of the key before it it shares, and the rest.
pub fn encode(&self) -> Vec<u8> {
let link = |cid: &Option<Vec<u8>>| cid.as_deref().map_or(V::Null, drisl::link_to);
let mut last: &[u8] = &[];
let entries = self.entries.iter().map(|e| {
let key = e.key.as_bytes();
let shared = last.iter().zip(key).take_while(|(a, b)| a == b).count();
last = key;
V::Map(vec![
(V::Text("k".into()), V::Bytes(key[shared..].to_vec())),
(V::Text("p".into()), V::Integer((shared as u64).into())),
(V::Text("t".into()), link(&e.right)),
(V::Text("v".into()), drisl::link_to(&e.value)),
])
});
let node = V::Map(vec![
(V::Text("e".into()), V::Array(entries.collect())),
(V::Text("l".into()), link(&self.left)),
]);
drisl::encode(&node)
}
/// A node from its block, its keys made whole again.
pub fn decode(block: &[u8]) -> Result<Node> {
let node: V = ciborium::from_reader(block).context("a tree node that is not CBOR")?;
let field = |map: &V, name: &str| {
let pairs = map.as_map()?;
pairs
.iter()
.find(|(k, _)| k.as_text() == Some(name))
.map(|(_, v)| v.clone())
};
let link = |v: Option<V>| v.as_ref().and_then(drisl::link).map(<[u8]>::to_vec);
let entries = field(&node, "e").and_then(|e| e.into_array().ok());
let entries = entries.context("a tree node with no entries")?;
let mut last = Vec::new();
let mut node = Node {
left: link(field(&node, "l")),
entries: Vec::with_capacity(entries.len()),
};
for e in &entries {
let shared =
field(e, "p").and_then(|p| usize::try_from(i128::from(p.as_integer()?)).ok());
let suffix = field(e, "k").and_then(|k| k.into_bytes().ok());
let (shared, suffix) = shared.zip(suffix).context("a tree entry with no key")?;
ensure!(
shared <= last.len(),
"a tree key sharing more than the key before it"
);
last.truncate(shared);
last.extend(suffix);
node.entries.push(Entry {
key: String::from_utf8(last.clone()).context("a tree key that is not text")?,
value: link(field(e, "v")).context("a tree entry with no record")?,
right: link(field(e, "t")),
});
}
Ok(node)
}
}
/// The tree's root, rebuilt from its keys and records in order. What is
/// left of the stack is finished, so only one node a layer is held.
#[derive(Default)]
pub struct RootBuilder {
stack: Vec<Node>,
prev: Option<u32>,
last: String,
}
impl RootBuilder {
pub fn new() -> RootBuilder {
RootBuilder::default()
}
/// Adds the record `record` at `key`, which must come after the last.
pub fn add(&mut self, key: &str, record: &[u8]) -> Result<()> {
ensure!(key > self.last.as_str(), "keys out of order at {key}");
self.last = key.to_string();
let layer = layer(key.as_bytes());
let at = layer as usize;
if self.stack.len() <= at {
self.stack.resize_with(at + 1, Node::default);
}
if self.prev.is_none_or(|prev| layer > prev) {
self.fold(at);
}
self.stack[at].entries.push(Entry {
key: key.to_string(),
value: drisl::cid(record),
right: None,
});
self.prev = Some(layer);
Ok(())
}
/// Finishes every node below `layer`, each hung under the one above:
/// as its left subtree, or right of its last entry.
fn fold(&mut self, layer: usize) {
for i in 0..layer {
let node = std::mem::take(&mut self.stack[i]);
if node.is_empty() {
continue;
}
let cid = drisl::cid(&node.encode());
let parent = &mut self.stack[i + 1];
match parent.entries.last_mut() {
Some(last) => last.right = Some(cid),
None => parent.left = Some(cid),
}
}
}
/// The root's CID: of one empty node when no key was added, as
/// atproto writes an empty repo.
pub fn root(mut self) -> Vec<u8> {
let top = self.stack.len().saturating_sub(1);
self.fold(top);
drisl::cid(&self.stack.pop().unwrap_or_default().encode())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layers() {
// atproto's interop vectors for the fanout of four.
assert_eq!(layer(b""), 0);
assert_eq!(layer(b"asdf"), 0);
assert_eq!(layer(b"blue"), 1);
assert_eq!(layer(b"2653ae71"), 0);
assert_eq!(layer(b"88bfafc7"), 2);
assert_eq!(layer(b"2a92d355"), 4);
assert_eq!(layer(b"884976f5"), 6);
assert_eq!(layer(b"app.bsky.feed.post/454397e440ec"), 4);
assert_eq!(layer(b"app.bsky.feed.post/9adeb165882c"), 8);
}
#[test]
fn the_empty_tree() {
let root = drisl::cid_text(&RootBuilder::new().root());
assert_eq!(
root,
"bafyreie5737gdxlw5i64vzichcalba3z2v5n6icifvx5xytvske7mr3hpm"
);
}
/// The root of a tree of one key, worked by hand: a node of one entry.
/// Deeper trees are checked against the reference PDS's.
#[test]
fn known_roots() {
let record = drisl::encode(&V::Map(vec![(
V::Text("name".into()),
V::Text("value".into()),
)]));
let one = |keys: &[&str]| {
let mut tree = RootBuilder::new();
for key in keys {
tree.add(key, &record).unwrap();
}
drisl::cid_text(&tree.root())
};
assert_eq!(
one(&["com.example.record/3jqfcqzm3fo2j"]),
"bafyreidgbec6p2odwcdq6phvbl2mwo66e63aurdl5z6bnfhgiilqlzlyx4"
);
}
#[test]
fn nodes_round_trip() {
let mut tree = RootBuilder::new();
tree.add("a/1", &[0xa0]).unwrap();
assert!(tree.add("a/1", &[0xa0]).is_err(), "a key again");
assert!(tree.add("a/0", &[0xa0]).is_err(), "a key before the last");
let node = Node {
left: Some(drisl::cid(b"l")),
entries: vec![
Entry {
key: "app.x/abc".into(),
value: drisl::cid(b"1"),
right: None,
},
Entry {
key: "app.x/abd".into(),
value: drisl::cid(b"2"),
right: Some(drisl::cid(b"r")),
},
],
};
assert_eq!(Node::decode(&node.encode()).unwrap(), node);
let bad = |node: V| Node::decode(&drisl::encode(&node)).unwrap_err().to_string();
let entry = |p: i64, k: &[u8], v: V| {
V::Map(vec![
(V::Text("k".into()), V::Bytes(k.to_vec())),
(V::Text("p".into()), V::Integer(p.into())),
(V::Text("v".into()), v),
])
};
let node = |entries: Vec<V>| V::Map(vec![(V::Text("e".into()), V::Array(entries))]);
let v = drisl::link_to(&drisl::cid(b"1"));
assert_eq!(bad(V::Null), "a tree node with no entries");
assert_eq!(bad(node(vec![V::Null])), "a tree entry with no key");
assert_eq!(
bad(node(vec![entry(1, b"a", v.clone())])),
"a tree key sharing more than the key before it"
);
assert_eq!(
bad(node(vec![entry(0, b"\xff", v)])),
"a tree key that is not text"
);
assert_eq!(
bad(node(vec![entry(0, b"a", V::Null)])),
"a tree entry with no record"
);
assert!(Node::decode(b"\xff").is_err());
}
}
crates/grain/src/repo/star.rsnew+118−0
@@ -0,0 +1,118 @@
//! STAR-lite, version 0: microcosm's export of a whole repo, every record
//! in key order after its commit, with no tree. The tree's root is rebuilt
//! from the records as they stream past ([`super::mst::RootBuilder`]) and
//! must come out as the root the commit signed.
//!
//! ```text
//! *l\0 | root CID (36 bytes) | len | commit less `data` | (len | key | len | record)*
//! ```
//!
//! The format: <https://tangled.org/microcosm.blue/star/tree/main/v0-star/star-lite>.
//! Its frames are knotless's STAR v1's (`space_sync.rs` there), whose magic
//! is `*l\x01\x00` and whose records hash to a set rather than a tree.
use super::mst::RootBuilder;
use super::{Commit, Frames, MAX_COMMIT, MAX_KEY, MAX_RECORD, Record, varint, verify_commit};
use crate::crypto::PublicKey;
use crate::drisl;
use anyhow::{Context, Result, ensure};
use ciborium::Value as V;
use std::io::{Read, Write};
/// `*l\0`: STAR, lite, version 0.
pub const MAGIC: [u8; 3] = [0x2a, 0x6c, 0x00];
/// The media type hubble serves it as.
pub const MEDIA_TYPE: &str = "application/x.microcosm.star-lite";
/// A STAR-lite archive being read.
pub struct StarLite<R> {
frames: Frames<R>,
commit: Commit,
tree: RootBuilder,
}
impl<R: Read> StarLite<R> {
/// Reads the header of `did`'s archive from `r`, and checks that `key`
/// signed its commit, with the root it opens with as the commit's
/// `data`.
pub fn open(r: R, did: &str, key: &PublicKey) -> Result<StarLite<R>> {
let mut frames = Frames::new(r);
let magic = frames.exact(MAGIC.len(), "its magic")?;
ensure!(magic == MAGIC, "not a STAR-lite archive");
let root = frames.exact(36, "its root")?;
ensure!(
root.starts_with(&drisl::CID_PREFIX),
"a root that is not a tree's CID"
);
let commit = frames.frame(MAX_COMMIT, "a commit")?.unwrap_or_default();
ensure!(
!commit.is_empty(),
"no commit, so nothing to check it against"
);
let commit: V = ciborium::from_reader(&commit[..]).context("a commit that is not CBOR")?;
let mut commit = commit
.into_map()
.ok()
.context("a commit that is not a map")?;
ensure!(
commit.iter().all(|(k, _)| k.as_text() != Some("data")),
"a commit naming a tree of its own"
);
commit.push((V::Text("data".into()), drisl::link_to(&root)));
Ok(StarLite {
frames,
commit: verify_commit(V::Map(commit), did, key)?,
tree: RootBuilder::new(),
})
}
pub fn commit(&self) -> &Commit {
&self.commit
}
/// The next record, its key and its DRISL; none at the end.
pub fn record(&mut self) -> Result<Option<Record>> {
let Some(key) = self.frames.frame(MAX_KEY, "a key")? else {
return Ok(None);
};
let key = String::from_utf8(key).context("a key that is not text")?;
let record = self.frames.frame(MAX_RECORD, "a record")?;
let record = record.with_context(|| format!("{key} with no record"))?;
self.tree.add(&key, &record)?;
Ok(Some((key, record)))
}
/// The commit, once the records read make the tree it signed.
pub fn finish(self) -> Result<Commit> {
let root = self.tree.root();
ensure!(
root == self.commit.data,
"the records do not make the tree the commit signed"
);
Ok(self.commit)
}
}
/// Writes `commit`'s repo as STAR-lite to `out`, from `records`, its keys
/// and DRISL in key order.
pub fn write(
out: &mut impl Write,
commit: &Commit,
records: impl IntoIterator<Item = Record>,
) -> std::io::Result<()> {
out.write_all(&MAGIC)?;
out.write_all(&commit.data)?;
frame(out, &drisl::encode(&commit.partial))?;
for (key, record) in records {
frame(out, key.as_bytes())?;
frame(out, &record)?;
}
Ok(())
}
/// Writes `bytes` to `out` as a frame: its length, then it.
pub fn frame(out: &mut impl Write, bytes: &[u8]) -> std::io::Result<()> {
out.write_all(&varint(bytes.len()))?;
out.write_all(bytes)
}
crates/grain/src/repo/tests.rsnew+439−0
439 lines changed: not loaded until asked for.
crates/grain/src/drisl.rs+70−6
@@ -1,5 +1,55 @@
//! DRISL, atproto's deterministic CBOR (DAG-CBOR), as the JSON atproto
//! writes records in.
//! DRISL, atproto's deterministic CBOR (DAG-CBOR): records as the JSON
//! atproto writes them in, CIDs, and values encoded as they are hashed.
use sha2::{Digest, Sha256};
/// The bytes a CIDv1 of DRISL hashed with SHA-256 starts with: version,
/// codec (dag-cbor) and multihash (sha2-256, 32 bytes).
pub const CID_PREFIX: [u8; 4] = [0x01, 0x71, 0x12, 0x20];
/// The CID of `block`, a DRISL encoding, as bytes.
pub fn cid(block: &[u8]) -> Vec<u8> {
[&CID_PREFIX[..], &Sha256::digest(block)].concat()
}
/// A CID's bytes as atproto writes them in text: base32, `b` first.
pub fn cid_text(cid: &[u8]) -> String {
format!("b{}", crate::base32::encode(crate::base32::LOWER, cid))
}
/// A DAG-CBOR link to `cid`: tag 42 over a zero byte and the CID.
pub fn link_to(cid: &[u8]) -> ciborium::Value {
ciborium::Value::Tag(42, Box::new(ciborium::Value::Bytes([&[0], cid].concat())))
}
/// `value` as DRISL: each map's keys shortest first, then bytewise, as
/// a commit is signed and a tree node hashed.
pub fn encode(value: &ciborium::Value) -> Vec<u8> {
let mut out = Vec::new();
ciborium::into_writer(&canonical(value), &mut out).expect("a Vec takes any write");
out
}
fn canonical(value: &ciborium::Value) -> ciborium::Value {
use ciborium::Value as V;
let order = |k: &V| {
let text = k.as_text().unwrap_or_default();
(text.len(), text.to_string())
};
match value {
V::Map(pairs) => {
let mut pairs: Vec<_> = pairs
.iter()
.map(|(k, v)| (k.clone(), canonical(v)))
.collect();
pairs.sort_by_key(|(k, _)| order(k));
V::Map(pairs)
}
V::Array(items) => V::Array(items.iter().map(canonical).collect()),
V::Tag(tag, inner) => V::Tag(*tag, Box::new(canonical(inner))),
other => other.clone(),
}
}
/// The CID a DAG-CBOR link holds, as bytes.
pub fn link(value: &ciborium::Value) -> Option<&[u8]> {
@@ -27,10 +77,7 @@ fn json(value: &ciborium::Value) -> Option<serde_json::Value> {
V::Bytes(b) => {
json!({"$bytes": base64::engine::general_purpose::STANDARD_NO_PAD.encode(b)})
}
V::Tag(42, _) => {
let cid = crate::base32::encode(crate::base32::LOWER, link(value)?);
json!({ "$link": format!("b{cid}") })
}
V::Tag(42, _) => json!({ "$link": cid_text(link(value)?) }),
V::Array(items) => J::Array(items.iter().map(json).collect::<Option<_>>()?),
V::Map(pairs) => J::Object(
pairs
@@ -97,6 +144,23 @@ mod tests {
assert_eq!(link(&V::Tag(42, Box::new(V::Bytes(vec![1, 2])))), None);
}
#[test]
fn cids_and_encodings() {
// The empty map, {}: as atproto's CID for it.
assert_eq!(
cid_text(&cid(&[0xa0])),
"bafyreigbtj4x7ip5legnfznufuopl4sg4knzc2cof6duas4b3q2fy6swua"
);
assert_eq!(link(&link_to(&cid(b"x"))).unwrap(), cid(b"x"));
// Keys shortest first, then bytewise, in maps however deep.
let text = |t: &str| V::Text(t.into());
let map = |keys: &[&str]| V::Map(keys.iter().map(|k| (text(k), V::Null)).collect());
let nested = V::Tag(7, Box::new(V::Array(vec![map(&["bb", "a", "ab", "c"])])));
let sorted = V::Tag(7, Box::new(V::Array(vec![map(&["a", "c", "ab", "bb"])])));
assert_eq!(encode(&nested), cbor(&sorted));
assert_eq!(encode(&V::Integer(1.into())), vec![1]);
}
#[test]
fn what_no_record_holds_is_refused() {
assert_eq!(record(&cbor(&V::Float(1.5))), None);
crates/grain/src/lib.rs+1−0
@@ -13,6 +13,7 @@ pub mod identity;
pub mod keys;
pub mod lexicon;
pub mod oauth;
pub mod repo;
pub mod session;
pub mod store;
pub mod telemetry;
crates/grain/tests/repo.rsnew+145−0
@@ -0,0 +1,145 @@
//! Repos as the reference PDS exports them: its CARs read and checked
//! against the account's key from the PLC directory, the same repo as
//! STAR-lite, as hubble would serve it, and either tampered with refused.
mod common;
use common::*;
use grain::crypto::PublicKey;
use grain::identity;
use grain::repo::{self, Commit, Record, star};
use serde_json::{Value, json};
/// An account with `n` galleries and a profile, and its session token.
async fn account_with(n: usize) -> (Account, String) {
let a = account().await;
let http = reqwest::Client::new();
let session: Value = http
.post(format!(
"{}/xrpc/com.atproto.server.createSession",
net().pds
))
.json(&json!({"identifier": a.did, "password": a.password}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let token = session["accessJwt"].as_str().unwrap().to_string();
let mut writes: Vec<Value> = (0..n)
.map(|i| {
json!({
"$type": "com.atproto.repo.applyWrites#create",
"collection": "social.grain.gallery",
"value": {"$type": "social.grain.gallery", "title": format!("roll {i}"),
"createdAt": "2026-10-31T12:00:00.000Z"},
})
})
.collect();
writes.push(json!({
"$type": "com.atproto.repo.applyWrites#create",
"collection": "social.grain.actor.profile",
"rkey": "self",
"value": {"$type": "social.grain.actor.profile", "displayName": "Film"},
}));
let res = http
.post(format!("{}/xrpc/com.atproto.repo.applyWrites", net().pds))
.bearer_auth(&token)
.json(&json!({"repo": a.did, "writes": writes}))
.send()
.await
.unwrap();
assert!(res.status().is_success(), "{}", res.text().await.unwrap());
(a, token)
}
async fn car_of(did: &str) -> Vec<u8> {
let url = format!("{}/xrpc/com.atproto.sync.getRepo?did={did}", net().pds);
let res = reqwest::get(url).await.unwrap().error_for_status().unwrap();
res.bytes().await.unwrap().to_vec()
}
async fn key_of(did: &str) -> PublicKey {
let dir = tempfile::tempdir().unwrap();
let doc = identity::document(&ctx(dir.path(), &[]), did)
.await
.unwrap();
identity::signing_key(&doc).unwrap()
}
fn read(bytes: Vec<u8>, did: &str, key: &PublicKey) -> anyhow::Result<(Commit, Vec<Record>)> {
let spill = Box::new(tempfile::tempfile().unwrap());
let mut archive = repo::open(std::io::Cursor::new(bytes), did, key, spill)?;
let mut records = Vec::new();
while let Some(record) = archive.record()? {
records.push(record);
}
Ok((archive.finish()?, records))
}
#[tokio::test]
async fn the_reference_pdss_repos_read_and_check() {
let (a, token) = account_with(150).await;
let key = key_of(&a.did).await;
let car = car_of(&a.did).await;
let (commit, records) = read(car.clone(), &a.did, &key).unwrap();
let latest: Value = reqwest::get(format!(
"{}/xrpc/com.atproto.sync.getLatestCommit?did={}",
net().pds,
a.did
))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(commit.rev, latest["rev"]);
assert_eq!(grain::drisl::cid_text(&commit.data).len(), 59);
assert_eq!(records.len(), 151);
assert!(records.windows(2).all(|w| w[0].0 < w[1].0));
assert_eq!(records[0].0, "social.grain.actor.profile/self");
let profile = grain::drisl::record(&records[0].1).unwrap();
assert_eq!(profile["displayName"], "Film");
// The same repo as STAR-lite, plain and zstd'd.
let mut lite = Vec::new();
star::write(&mut lite, &commit, records.clone()).unwrap();
assert!(lite.len() < car.len(), "STAR-lite carries no tree");
let (again, same) = read(lite.clone(), &a.did, &key).unwrap();
let parts = |c: &Commit| (c.did.clone(), c.rev.clone(), c.data.clone());
assert_eq!((parts(&again), &same), (parts(&commit), &records));
let zstd = zstd::encode_all(&lite[..], 3).unwrap();
assert_eq!(read(zstd, &a.did, &key).unwrap().1, records);
// Tampered with, or cut short, either is refused.
let mut forged = records.clone();
forged[3].1 =
grain::drisl::encode(&ciborium::Value::serialized(&json!({"title": "x"})).unwrap());
let mut lite_forged = Vec::new();
star::write(&mut lite_forged, &commit, forged).unwrap();
let why = read(lite_forged, &a.did, &key).unwrap_err().to_string();
assert_eq!(why, "the records do not make the tree the commit signed");
assert!(read(lite[..lite.len() - 1].to_vec(), &a.did, &key).is_err());
assert!(read(car[..car.len() - 1].to_vec(), &a.did, &key).is_err());
let mut flipped = car.clone();
let at = flipped.len() / 2;
flipped[at] ^= 0x40;
assert!(read(flipped, &a.did, &key).is_err());
let other = key_of(&account().await.did).await;
assert!(read(car.clone(), &a.did, &other).is_err());
// A record deleted: the next export is at a later revision, without it.
let (collection, rkey) = records[0].0.split_once('/').unwrap();
let res = reqwest::Client::new()
.post(format!("{}/xrpc/com.atproto.repo.deleteRecord", net().pds))
.bearer_auth(&token)
.json(&json!({"repo": a.did, "collection": collection, "rkey": rkey}))
.send()
.await
.unwrap();
assert!(res.status().is_success());
let (later, fewer) = read(car_of(&a.did).await, &a.did, &key).unwrap();
assert!(later.rev > commit.rev);
assert_eq!(fewer[..], records[1..]);
}
crates/grain/Cargo.toml+1−0
@@ -23,6 +23,7 @@ sha2.workspace = true
tokio.workspace = true
tracing.workspace = true
unicode-segmentation.workspace = true
zstd.workspace = true
[dev-dependencies]
axum.workspace = true
crates/grain-serve/src/index/backfill.rsnew+147−0
@@ -0,0 +1,147 @@
//! Backfill's side of the index: the queue of accounts waiting to be read,
//! kept in `repo`, and a repo read whole made the index's, what is newer
//! standing ([`super::schema`]'s `record_rev`).
use super::{Index, records};
use rusqlite::{OptionalExtension, params};
use std::collections::HashSet;
/// Where an account stands in backfill's queue.
#[derive(Debug, Clone, PartialEq)]
pub struct Queued {
/// `pending`, `active`, `failed` or `skipped`.
pub state: String,
/// The revision its repo was last read at.
pub rev: Option<String>,
pub retry_count: i64,
/// When it is next tried, in seconds since the epoch.
pub retry_after: i64,
pub last_error: Option<String>,
}
impl Index {
/// Up to `limit` accounts waiting to be read by `now`, in seconds
/// since the epoch, the longest waiting first: of those whose host
/// does not say they are gone.
pub fn pending(&self, now: i64, limit: usize) -> rusqlite::Result<Vec<String>> {
let db = self.db.lock().unwrap();
let mut stmt = db.prepare_cached(
"SELECT did FROM repo WHERE state = 'pending' AND retry_after <= ?1
AND host_status IS NULL ORDER BY retry_after LIMIT ?2",
)?;
let dids = stmt.query_map(params![now, limit as i64], |r| r.get(0))?;
dids.collect()
}
/// Where `did` stands in backfill's queue, if it is tracked.
pub fn queued(&self, did: &str) -> rusqlite::Result<Option<Queued>> {
let db = self.db.lock().unwrap();
let sql =
"SELECT state, rev, retry_count, retry_after, last_error FROM repo WHERE did = ?1";
db.query_row(sql, [did], |r| {
Ok(Queued {
state: r.get(0)?,
rev: r.get(1)?,
retry_count: r.get(2)?,
retry_after: r.get(3)?,
last_error: r.get(4)?,
})
})
.optional()
}
/// Queues `did` to be read again now, tracking it if it was not.
pub fn resync(&self, did: &str) -> rusqlite::Result<()> {
let db = self.db.lock().unwrap();
db.execute(
"INSERT INTO repo (did) VALUES (?1) ON CONFLICT (did) DO UPDATE SET
state = 'pending', retry_count = 0, retry_after = 0, last_error = NULL",
[did],
)?;
self.tracked.lock().unwrap().insert(did.to_string());
Ok(())
}
/// A read of `did` that failed, for `error`: tried again at
/// `retry_after`, or given up on (`failed`) when that is none.
pub fn backfill_failed(
&self,
did: &str,
error: &str,
retry_after: Option<i64>,
) -> rusqlite::Result<()> {
let db = self.db.lock().unwrap();
db.execute(
"UPDATE repo SET retry_count = retry_count + 1, last_error = ?2,
state = CASE WHEN ?3 IS NULL THEN 'failed' ELSE state END,
retry_after = coalesce(?3, retry_after)
WHERE did = ?1",
params![did, error, retry_after],
)?;
Ok(())
}
/// `did` left unread, for `why`: its host is one backfill skips.
pub fn backfill_skipped(&self, did: &str, why: &str) -> rusqlite::Result<()> {
let db = self.db.lock().unwrap();
let sql = "UPDATE repo SET state = 'skipped', last_error = ?2 WHERE did = ?1";
db.execute(sql, [did, why])?;
Ok(())
}
/// `did`'s repo, read whole at `rev`, holds `present`: the URIs of its
/// records of the collections the index keeps, each put already with
/// [`Index::put_rev`]. What the index has of `did` that it does not, and
/// had from before `rev`, was deleted since, and is deleted here; the
/// deletions `rev` covers are forgotten; and the account is backfilled.
/// How many records were deleted.
pub fn backfilled(
&self,
did: &str,
rev: &str,
present: &HashSet<String>,
) -> rusqlite::Result<usize> {
let mut db = self.db.lock().unwrap();
let tx = db.transaction()?;
let mut gone = Vec::new();
for (_, table) in records::COLLECTIONS {
let sql = format!(
"SELECT t.uri FROM {table} t LEFT JOIN record_rev r ON r.uri = t.uri
WHERE t.did = ?1 AND (r.rev IS NULL OR r.rev < ?2)"
);
let mut stmt = tx.prepare_cached(&sql)?;
let uris = stmt.query_map(params![did, rev], |r| r.get::<_, String>(0))?;
for uri in uris {
let uri = uri?;
if !present.contains(&uri) {
gone.push((table, uri));
}
}
}
for (table, uri) in &gone {
tx.execute(&format!("DELETE FROM {table} WHERE uri = ?1"), [uri])?;
tx.execute("DELETE FROM self_label WHERE uri = ?1", [uri])?;
tx.execute("DELETE FROM record_rev WHERE uri = ?1", [uri])?;
}
let (from, to) = (format!("at://{did}/"), format!("at://{did}0"));
tx.execute(
"DELETE FROM record_rev WHERE uri >= ?1 AND uri < ?2 AND gone = 1 AND rev <= ?3",
[&from, &to, rev],
)?;
tx.execute(
"UPDATE repo SET state = 'active', rev = max(coalesce(rev, ''), ?2),
backfilled_at = ?3, retry_count = 0, retry_after = 0, last_error = NULL
WHERE did = ?1",
[did, rev, &grain::keys::now_iso()],
)?;
tx.commit()?;
Ok(gone.len())
}
/// Forgets the deletions from before the revision `rev`, which no repo
/// read now is as old as; how many.
pub fn forget_deletions(&self, rev: &str) -> rusqlite::Result<usize> {
let db = self.db.lock().unwrap();
db.execute("DELETE FROM record_rev WHERE gone = 1 AND rev < ?1", [rev])
}
}
crates/grain-serve/src/index/mod.rs+76−0
@@ -8,7 +8,12 @@
//! is applied once, and one deleted from its PDS is deleted here. Each is
//! checked against its lexicon first ([`grain::lexicon`]), and refused if
//! it does not fit.
//!
//! Each write keeps the revision it was made at ([`schema`]'s
//! `record_rev`): backfill writes at the revision of the repo it read, and
//! leaves alone what the index has from a later one ([`Applied::Stale`]).
pub mod backfill;
pub mod records;
pub mod schema;
@@ -38,6 +43,9 @@ pub enum Applied {
Updated,
/// It had this version already, by its CID.
Unchanged,
/// It had a version, or the record's deletion, from the same revision
/// or a later one, which stands.
Stale,
}
/// Why a record was not put.
@@ -127,6 +135,29 @@ fn migrate(db: &mut Connection, steps: &[&str]) -> anyhow::Result<()> {
Ok(())
}
/// Whether a write at `rev` is past what the index has for `uri`. One
/// that names no revision always is: it is the stream's, as it happens.
fn newer(tx: &Connection, uri: &str, rev: Option<&str>) -> rusqlite::Result<bool> {
let Some(rev) = rev else {
return Ok(true);
};
let sql = "SELECT rev FROM record_rev WHERE uri = ?1";
let held: Option<String> = tx.query_row(sql, [uri], |r| r.get(0)).optional()?;
Ok(held.is_none_or(|held| held.as_str() < rev))
}
/// Keeps that `uri` was written, or deleted (`gone`), at `rev`, or now if
/// it names none: never back from a later revision than it has.
fn stamp(tx: &Connection, uri: &str, rev: Option<&str>, gone: bool) -> rusqlite::Result<()> {
let rev = rev.map_or_else(grain::keys::tid, str::to_string);
tx.execute(
"INSERT INTO record_rev VALUES (?1, ?2, ?3)
ON CONFLICT (uri) DO UPDATE SET rev = max(rev, excluded.rev), gone = excluded.gone",
params![uri, rev, gone],
)?;
Ok(())
}
impl Index {
/// The index at `path`, made or migrated to this version's tables.
pub fn open(path: &Path) -> anyhow::Result<Index> {
@@ -173,6 +204,28 @@ impl Index {
cid: &str,
record: &Value,
indexed_at: &str,
) -> Result<Applied, PutError> {
self.write(uri, cid, record, indexed_at, None)
}
/// [`Index::put`] of the version the repo had at revision `rev`.
pub fn put_rev(
&self,
uri: &str,
cid: &str,
record: &Value,
rev: &str,
) -> Result<Applied, PutError> {
self.write(uri, cid, record, &grain::keys::now_iso(), Some(rev))
}
fn write(
&self,
uri: &str,
cid: &str,
record: &Value,
indexed_at: &str,
rev: Option<&str>,
) -> Result<Applied, PutError> {
let (did, collection, _) =
record_uri(uri).ok_or_else(|| refused("", "not a record URI"))?;
@@ -193,9 +246,14 @@ impl Index {
let row = row.map_err(|e| refused("", e.to_string()))?;
let mut db = self.db.lock().unwrap();
let tx = db.transaction()?;
if !newer(&tx, uri, rev)? {
return Ok(Applied::Stale);
}
stamp(&tx, uri, rev, false)?;
let held = format!("SELECT cid FROM {table} WHERE uri = ?1");
let was: Option<String> = tx.query_row(&held, [uri], |r| r.get(0)).optional()?;
if was.as_deref() == Some(cid) {
tx.commit()?;
return Ok(Applied::Unchanged);
}
let columns = row.columns();
@@ -225,12 +283,26 @@ impl Index {
/// Deletes the record at `uri`; whether the index had it.
pub fn delete(&self, uri: &str) -> rusqlite::Result<bool> {
self.remove(uri, None)
}
/// [`Index::delete`], as of the repo's revision `rev`: not where the
/// index has the record from that revision or a later one.
pub fn delete_rev(&self, uri: &str, rev: &str) -> rusqlite::Result<bool> {
self.remove(uri, Some(rev))
}
fn remove(&self, uri: &str, rev: Option<&str>) -> rusqlite::Result<bool> {
let table = record_uri(uri).and_then(|(_, c, _)| records::table_of(c));
let Some(table) = table else {
return Ok(false);
};
let mut db = self.db.lock().unwrap();
let tx = db.transaction()?;
if !newer(&tx, uri, rev)? {
return Ok(false);
}
stamp(&tx, uri, rev, true)?;
tx.execute("DELETE FROM self_label WHERE uri = ?1", [uri])?;
let deleted = tx.execute(&format!("DELETE FROM {table} WHERE uri = ?1"), [uri])?;
tx.commit()?;
@@ -320,6 +392,10 @@ impl Index {
"DELETE FROM self_label WHERE uri >= ?1 AND uri < ?2",
[&from, &to],
)?;
tx.execute(
"DELETE FROM record_rev WHERE uri >= ?1 AND uri < ?2",
[&from, &to],
)?;
let mut purged = 0;
for (_, table) in records::COLLECTIONS {
purged += tx.execute(&format!("DELETE FROM {table} WHERE did = ?1"), [did])?;
crates/grain-serve/src/index/schema.rs+24−5
crates/grain-serve/src/index/tests.rs+177−12
crates/grain-serve/src/backfill.rsnew+720−0
720 lines changed: not loaded until asked for.
crates/grain-serve/src/lib.rs+19−0
crates/grain-serve/src/main.rs+6−2
crates/grain-serve/tests/backfill.rsnew+570−0
570 lines changed: not loaded until asked for.
crates/grain-serve/Cargo.toml+4−2
Cargo.lock+43−0
Cargo.toml+2−0