0
2
A Rust workspace beside the Node app, built and tested on the runner
Only what this pull adds. It is built on grain runs on a Rust server modelled on knotless, without hatk.
.knotless/workflows/rust.ymlnew+20−0
| @@ -0,0 +1,20 @@ | ||
# The Rust workspace: formatting, clippy with warnings denied, and the | ||
# tests, as `just lint` and `just t` run them. Kept apart from check.yml | ||
# and test.yml, which run the SvelteKit app and hatk's server in Node, | ||
# until the move off hatk folds them together. | ||
# | ||
# The build, the registry and the toolchain's components are kept between | ||
# runs, so a push builds only what it changed. | ||
image: rust:1.98 | ||
runners: [runner.knotless.dev] | ||
on: | ||
push: { refs: [refs/heads/main] } | ||
pull: { base: [refs/heads/main, refs/heads/feature/*] } | ||
cache: [target, /usr/local/cargo/registry, /usr/local/rustup] | ||
steps: | ||
- name: fmt | ||
run: rustup component add rustfmt && cargo fmt --all --check | ||
- name: clippy | ||
run: rustup component add clippy && cargo clippy --workspace --all-targets --locked -- -D warnings | ||
- name: test | ||
run: cargo test --workspace --locked | ||
crates/grain/src/bin/grain.rsnew+24−0
| @@ -0,0 +1,24 @@ | ||
//! The `grain` command line. | ||
| ||
use std::process::ExitCode; | ||
| ||
const USAGE: &str = "usage: grain --version"; | ||
| ||
fn main() -> ExitCode { | ||
let args: Vec<String> = std::env::args().skip(1).collect(); | ||
match args | ||
.iter() | ||
.map(String::as_str) | ||
.collect::<Vec<_>>() | ||
.as_slice() | ||
{ | ||
["--version"] => { | ||
println!("grain {}", env!("CARGO_PKG_VERSION")); | ||
ExitCode::SUCCESS | ||
} | ||
_ => { | ||
eprintln!("{USAGE}"); | ||
ExitCode::from(2) | ||
} | ||
} | ||
} | ||
crates/grain/src/base32.rsnew+81−0
| @@ -0,0 +1,81 @@ | ||
//! Unpadded base32 over an arbitrary 32-symbol alphabet. | ||
| ||
/// The sortable alphabet atproto uses for TIDs. | ||
pub const SORTABLE: &[u8; 32] = b"234567abcdefghijklmnopqrstuvwxyz"; | ||
/// RFC 4648 base32, lowercased. | ||
pub const LOWER: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567"; | ||
| ||
/// Encodes `bytes` most significant bit first, without padding. | ||
pub fn encode(alphabet: &[u8; 32], bytes: &[u8]) -> String { | ||
let mut out = String::with_capacity(bytes.len() * 8 / 5 + 1); | ||
let mut buffer: u32 = 0; | ||
let mut bits = 0; | ||
for &byte in bytes { | ||
buffer = (buffer << 8) | u32::from(byte); | ||
bits += 8; | ||
while bits >= 5 { | ||
bits -= 5; | ||
out.push(alphabet[((buffer >> bits) & 31) as usize] as char); | ||
} | ||
} | ||
if bits > 0 { | ||
out.push(alphabet[((buffer << (5 - bits)) & 31) as usize] as char); | ||
} | ||
out | ||
} | ||
| ||
/// Encodes a u64 as 13 symbols, so encodings sort the way the integers do. | ||
pub fn encode_u64(alphabet: &[u8; 32], value: u64) -> String { | ||
(0..13) | ||
.map(|i: u32| alphabet[((value >> (60 - 5 * i)) & 31) as usize] as char) | ||
.collect() | ||
} | ||
| ||
/// Inverts [`encode_u64`]. Returns `None` for anything that is not 13 symbols | ||
/// of `alphabet` or does not fit a u64. | ||
pub fn decode_u64(alphabet: &[u8; 32], text: &str) -> Option<u64> { | ||
if text.len() != 13 { | ||
return None; | ||
} | ||
let mut value: u64 = 0; | ||
for (i, c) in text.bytes().enumerate() { | ||
let symbol = alphabet.iter().position(|&a| a == c)? as u64; | ||
if i == 0 && symbol > 15 { | ||
return None; | ||
} | ||
value = (value << 5) | symbol; | ||
} | ||
Some(value) | ||
} | ||
| ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
| ||
#[test] | ||
fn rfc4648_vectors() { | ||
assert_eq!(encode(LOWER, b""), ""); | ||
assert_eq!(encode(LOWER, b"f"), "my"); | ||
assert_eq!(encode(LOWER, b"fo"), "mzxq"); | ||
assert_eq!(encode(LOWER, b"foo"), "mzxw6"); | ||
assert_eq!(encode(LOWER, b"foobar"), "mzxw6ytboi"); | ||
} | ||
| ||
#[test] | ||
fn u64_round_trips_and_sorts() { | ||
for v in [0u64, 1, 31, 32, 1 << 40, u64::MAX >> 1, u64::MAX] { | ||
let s = encode_u64(SORTABLE, v); | ||
assert_eq!(s.len(), 13); | ||
assert_eq!(decode_u64(SORTABLE, &s), Some(v)); | ||
} | ||
assert!(encode_u64(SORTABLE, 5) < encode_u64(SORTABLE, 6)); | ||
assert!(encode_u64(SORTABLE, 1 << 50) > encode_u64(SORTABLE, (1 << 50) - 1)); | ||
} | ||
| ||
#[test] | ||
fn decode_rejects_garbage() { | ||
assert_eq!(decode_u64(SORTABLE, "short"), None); | ||
assert_eq!(decode_u64(SORTABLE, "1111111111111"), None); | ||
assert_eq!(decode_u64(SORTABLE, "z222222222222"), None); | ||
} | ||
} | ||
crates/grain/src/drisl.rsnew+109−0
| @@ -0,0 +1,109 @@ | ||
//! DRISL, atproto's deterministic CBOR (DAG-CBOR), as the JSON atproto | ||
//! writes records in. | ||
| ||
/// The CID a DAG-CBOR link holds, as bytes. | ||
pub fn link(value: &ciborium::Value) -> Option<&[u8]> { | ||
match value { | ||
ciborium::Value::Tag(42, inner) => inner.as_bytes()?.strip_prefix(&[0]), | ||
_ => None, | ||
} | ||
} | ||
| ||
/// A DAG-CBOR record as its JSON: links and bytes as atproto writes them. | ||
/// `None` for what no record holds, as floats, for it to be fetched. | ||
pub fn record(block: &[u8]) -> Option<serde_json::Value> { | ||
json(&ciborium::from_reader(block).ok()?) | ||
} | ||
| ||
fn json(value: &ciborium::Value) -> Option<serde_json::Value> { | ||
use base64::Engine; | ||
use ciborium::Value as V; | ||
use serde_json::{Value as J, json}; | ||
Some(match value { | ||
V::Null => J::Null, | ||
V::Bool(b) => J::Bool(*b), | ||
V::Integer(i) => J::from(i64::try_from(*i).ok()?), | ||
V::Text(t) => J::String(t.clone()), | ||
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::Array(items) => J::Array(items.iter().map(json).collect::<Option<_>>()?), | ||
V::Map(pairs) => J::Object( | ||
pairs | ||
.iter() | ||
.map(|(k, v)| Some((k.as_text()?.to_string(), json(v)?))) | ||
.collect::<Option<_>>()?, | ||
), | ||
_ => return None, | ||
}) | ||
} | ||
| ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
use ciborium::Value as V; | ||
use serde_json::json; | ||
| ||
fn cbor(value: &V) -> Vec<u8> { | ||
let mut out = Vec::new(); | ||
ciborium::into_writer(value, &mut out).unwrap(); | ||
out | ||
} | ||
| ||
/// A DAG-CBOR link: tag 42 over a zero byte and a CIDv1 (dag-cbor, | ||
/// sha-256), whose base32 form always starts `bafyrei`. | ||
fn cid_link() -> V { | ||
let mut bytes = vec![0x00, 0x01, 0x71, 0x12, 0x20]; | ||
bytes.extend([0x11; 32]); | ||
V::Tag(42, Box::new(V::Bytes(bytes))) | ||
} | ||
| ||
#[test] | ||
fn records_read_as_atproto_json() { | ||
let block = cbor(&V::Map(vec![ | ||
( | ||
V::Text("$type".into()), | ||
V::Text("social.grain.photo".into()), | ||
), | ||
(V::Text("width".into()), V::Integer(4032.into())), | ||
(V::Text("alt".into()), V::Null), | ||
(V::Text("nsfw".into()), V::Bool(false)), | ||
(V::Text("thumb".into()), V::Bytes(vec![1, 2, 3])), | ||
( | ||
V::Text("tags".into()), | ||
V::Array(vec![V::Text("film".into())]), | ||
), | ||
(V::Text("ref".into()), cid_link()), | ||
])); | ||
let record = record(&block).unwrap(); | ||
assert_eq!(record["$type"], "social.grain.photo"); | ||
assert_eq!(record["width"], 4032); | ||
assert_eq!(record["alt"], json!(null)); | ||
assert_eq!(record["nsfw"], false); | ||
assert_eq!(record["thumb"], json!({"$bytes": "AQID"})); | ||
assert_eq!(record["tags"], json!(["film"])); | ||
let link = record["ref"]["$link"].as_str().unwrap(); | ||
assert!(link.starts_with("bafyrei"), "{link}"); | ||
} | ||
| ||
#[test] | ||
fn links_are_the_cid_without_its_zero_byte() { | ||
assert_eq!(link(&cid_link()).unwrap()[..4], [0x01, 0x71, 0x12, 0x20]); | ||
assert_eq!(link(&V::Bytes(vec![0, 1])), None); | ||
assert_eq!(link(&V::Tag(42, Box::new(V::Bytes(vec![1, 2])))), None); | ||
} | ||
| ||
#[test] | ||
fn what_no_record_holds_is_refused() { | ||
assert_eq!(record(&cbor(&V::Float(1.5))), None); | ||
let float_inside = V::Map(vec![(V::Text("x".into()), V::Float(1.5))]); | ||
assert_eq!(record(&cbor(&float_inside)), None); | ||
let key_not_text = V::Map(vec![(V::Integer(1.into()), V::Null)]); | ||
assert_eq!(record(&cbor(&key_not_text)), None); | ||
assert_eq!(record(b"\xff"), None); | ||
} | ||
} | ||
crates/grain/src/lib.rsnew+8−0
| @@ -0,0 +1,8 @@ | ||
//! grain's core: what `grain-serve`, the `grain` command line and MCP | ||
//! share, so each write is one function that all of them call. | ||
//! | ||
//! Its modules start from knotless's (`crates/knotless/src` there), taken | ||
//! over as grain needs them rather than all at once. | ||
| ||
pub mod base32; | ||
pub mod drisl; | ||
crates/grain/Cargo.tomlnew+12−0
| @@ -0,0 +1,12 @@ | ||
[package] | ||
name = "grain" | ||
description = "grain's core library and command line: what the server, the CLI and MCP share" | ||
version.workspace = true | ||
edition.workspace = true | ||
license.workspace = true | ||
rust-version.workspace = true | ||
| ||
[dependencies] | ||
base64.workspace = true | ||
ciborium.workspace = true | ||
serde_json.workspace = true | ||
crates/grain-serve/src/lib.rsnew+26−0
| @@ -0,0 +1,26 @@ | ||
//! grain's server. For now it answers only atproto's health check; the | ||
//! index, the queries, ingest, login and push join it pull by pull on | ||
//! `feature/rust`, as the plan orders them. | ||
| ||
use axum::{Json, Router, routing::get}; | ||
use serde_json::{Value, json}; | ||
| ||
/// The routes the server answers. | ||
pub fn router() -> Router { | ||
Router::new().route("/xrpc/_health", get(health)) | ||
} | ||
| ||
/// `/xrpc/_health`, as atproto services answer it: the running version. | ||
async fn health() -> Json<Value> { | ||
Json(json!({ "version": env!("CARGO_PKG_VERSION") })) | ||
} | ||
| ||
/// Serves on `listener` until the process is told to stop. | ||
pub async fn serve(listener: tokio::net::TcpListener) -> anyhow::Result<()> { | ||
axum::serve(listener, router()) | ||
.with_graceful_shutdown(async { | ||
let _ = tokio::signal::ctrl_c().await; | ||
}) | ||
.await?; | ||
Ok(()) | ||
} | ||
crates/grain-serve/src/main.rsnew+16−0
| @@ -0,0 +1,16 @@ | ||
//! `grain-serve`: listens on `GRAIN_LISTEN`, 127.0.0.1:2600 unless set. | ||
| ||
use anyhow::Context; | ||
| ||
#[tokio::main] | ||
async fn main() -> anyhow::Result<()> { | ||
tracing_subscriber::fmt() | ||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) | ||
.init(); | ||
let addr = std::env::var("GRAIN_LISTEN").unwrap_or_else(|_| "127.0.0.1:2600".into()); | ||
let listener = tokio::net::TcpListener::bind(&addr) | ||
.await | ||
.with_context(|| format!("listening on {addr}"))?; | ||
tracing::info!(%addr, "serving"); | ||
grain_serve::serve(listener).await | ||
} | ||
crates/grain-serve/tests/health.rsnew+20−0
| @@ -0,0 +1,20 @@ | ||
//! The server answers atproto's health check with its version. | ||
| ||
#[tokio::test] | ||
async fn health_names_the_version() { | ||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); | ||
let addr = listener.local_addr().unwrap(); | ||
tokio::spawn(grain_serve::serve(listener)); | ||
| ||
let res = reqwest::get(format!("http://{addr}/xrpc/_health")) | ||
.await | ||
.unwrap(); | ||
assert_eq!(res.status(), 200); | ||
let body: serde_json::Value = res.json().await.unwrap(); | ||
assert_eq!(body["version"], env!("CARGO_PKG_VERSION")); | ||
| ||
let missing = reqwest::get(format!("http://{addr}/xrpc/social.grain.nothing")) | ||
.await | ||
.unwrap(); | ||
assert_eq!(missing.status(), 404); | ||
} | ||
crates/grain-serve/Cargo.tomlnew+19−0
| @@ -0,0 +1,19 @@ | ||
[package] | ||
name = "grain-serve" | ||
description = "grain's server: its appview, the web's server side, push and backfill" | ||
version.workspace = true | ||
edition.workspace = true | ||
license.workspace = true | ||
rust-version.workspace = true | ||
| ||
[dependencies] | ||
grain.workspace = true | ||
anyhow.workspace = true | ||
axum.workspace = true | ||
serde_json.workspace = true | ||
tokio.workspace = true | ||
tracing.workspace = true | ||
tracing-subscriber.workspace = true | ||
| ||
[dev-dependencies] | ||
reqwest.workspace = true | ||
.gitignore+1−0
Cargo.locknew+1727−0
1727 lines changed: not loaded until asked for.