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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

A Rust workspace beside the Node app, built and tested on the runner

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

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.
Cargo.tomlnew+46−0
justfile+13−0