feat: v2 Phase 1 — foundation, proto schemas, RPC framework, SDK skeleton

New workspace structure with 9 crates. Adds:

- proto/qpq/v1/*.proto: 11 protobuf schemas covering all 33 RPC methods
- quicproquo-proto: dual codegen (capnp legacy + prost v2)
- quicproquo-rpc: QUIC RPC framework (framing, server, client, middleware)
- quicproquo-sdk: client SDK (QpqClient, events, conversation store)
- quicproquo-server/domain/: protocol-agnostic domain types and services
- justfile: build commands

Wire format: [method_id:u16][req_id:u32][len:u32][protobuf] per QUIC stream.
All 151 existing tests pass. Backward compatible with v1 capnp code.
This commit is contained in:
2026-03-04 12:02:07 +01:00
parent 394199b19b
commit a5864127d1
37 changed files with 3115 additions and 2778 deletions

View File

@@ -1,22 +1,28 @@
[package]
name = "quicproquo-proto"
version = "0.1.0"
edition = "2021"
description = "Cap'n Proto schemas, generated types, and serialisation helpers for quicproquo. No crypto, no I/O."
license = "MIT"
name = "quicproquo-proto"
version = "0.2.0"
edition = "2021"
description = "Protocol types for quicproquo — v1 Cap'n Proto (legacy) + v2 Protobuf (prost)"
# build.rs invokes capnpc to generate Rust source from .capnp schemas.
build = "build.rs"
build = "build.rs"
[dependencies]
# v1 legacy (Cap'n Proto) — used by existing server/client until rewrite
capnp = { workspace = true }
# v2 (Protobuf via prost) — new RPC types
prost = { workspace = true }
prost-types = { workspace = true }
bytes = { workspace = true }
[build-dependencies]
capnpc = { workspace = true }
prost-build = { workspace = true }
protobuf-src = "2"
[lints.rust]
unsafe_code = "warn"
[lints.clippy]
# Generated Cap'n Proto code uses patterns that trigger clippy lints.
unwrap_used = "allow"
[build-dependencies]
capnpc = { workspace = true }

View File

@@ -1,51 +1,30 @@
//! Build script for quicproquo-proto.
//!
//! Invokes the `capnp` compiler to generate Rust types from `.capnp` schemas
//! located in the workspace-root `schemas/` directory.
//!
//! # Prerequisites
//!
//! The `capnp` CLI must be installed and on `PATH`.
//!
//! Debian/Ubuntu: apt-get install capnproto
//! macOS: brew install capnp
//! Docker: see docker/Dockerfile
//! Runs two code generators:
//! 1. Cap'n Proto (v1 legacy) — from `schemas/*.capnp`
//! 2. Protobuf/prost (v2) — from `proto/qpq/v1/*.proto`
use std::{env, path::PathBuf};
fn main() {
let manifest_dir =
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set by Cargo"));
// Vendor protoc from protobuf-src so the build doesn't require system protoc.
std::env::set_var("PROTOC", protobuf_src::protoc());
// Workspace root is two levels above this crate (quicproquo/crates/quicproquo-proto).
let manifest_dir =
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
let workspace_root = manifest_dir
.join("../..")
.canonicalize()
.expect("could not canonicalize workspace root path");
.expect("canonicalize workspace root");
// ── v1: Cap'n Proto codegen ──────────────────────────────────────────────
let schemas_dir = workspace_root.join("schemas");
// Re-run this build script whenever any schema file changes.
println!(
"cargo:rerun-if-changed={}",
schemas_dir.join("auth.capnp").display()
);
println!(
"cargo:rerun-if-changed={}",
schemas_dir.join("delivery.capnp").display()
);
println!(
"cargo:rerun-if-changed={}",
schemas_dir.join("node.capnp").display()
);
println!(
"cargo:rerun-if-changed={}",
schemas_dir.join("federation.capnp").display()
);
for schema in &["auth.capnp", "delivery.capnp", "node.capnp", "federation.capnp"] {
println!("cargo:rerun-if-changed={}", schemas_dir.join(schema).display());
}
capnpc::CompilerCommand::new()
// Treat `schemas/` as the include root so that inter-schema imports
// resolve correctly.
.src_prefix(&schemas_dir)
.file(schemas_dir.join("auth.capnp"))
.file(schemas_dir.join("delivery.capnp"))
@@ -56,4 +35,32 @@ fn main() {
"Cap'n Proto schema compilation failed. \
Is `capnp` installed? (apt-get install capnproto / brew install capnp)",
);
// ── v2: Protobuf/prost codegen ───────────────────────────────────────────
let proto_dir = workspace_root.join("proto");
let proto_files = [
"qpq/v1/common.proto",
"qpq/v1/auth.proto",
"qpq/v1/delivery.proto",
"qpq/v1/keys.proto",
"qpq/v1/channel.proto",
"qpq/v1/user.proto",
"qpq/v1/blob.proto",
"qpq/v1/device.proto",
"qpq/v1/p2p.proto",
"qpq/v1/federation.proto",
"qpq/v1/push.proto",
];
let full_paths: Vec<PathBuf> = proto_files.iter().map(|f| proto_dir.join(f)).collect();
for path in &full_paths {
println!("cargo:rerun-if-changed={}", path.display());
}
prost_build::Config::new()
.out_dir(PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")))
.compile_protos(&full_paths, &[&proto_dir])
.expect("prost compile_protos failed");
}

View File

@@ -1,56 +1,38 @@
//! Cap'n Proto schemas, generated types, and serialisation helpers for quicproquo.
//! Protocol types for quicproquo.
//!
//! This crate contains both:
//! - **v1 (legacy)**: Cap'n Proto generated types from `schemas/*.capnp`
//! - **v2**: Protobuf generated types from `proto/qpq/v1/*.proto`
//!
//! Generated Cap'n Proto code emits unnecessary parentheses; allow per coding standards.
#![allow(unused_parens)]
//! # Design constraints
//!
//! This crate is intentionally restricted:
//! - **No crypto** — key material never enters this crate.
//! - **No I/O** — callers own transport; this crate only converts bytes types.
//! - **No I/O** — callers own transport; this crate only converts bytes <-> types.
//! - **No async** — pure synchronous data-layer code.
//!
//! # Generated code
//!
//! `build.rs` invokes `capnpc` at compile time and writes generated Rust source
//! into `$OUT_DIR`. The `include!` macros below splice that code in as a module.
// ── Generated types ───────────────────────────────────────────────────────────
// ════════════════════════════════════════════════════════════════════════════
// v1 (legacy): Cap'n Proto generated types
// ════════════════════════════════════════════════════════════════════════════
#![allow(unused_parens)]
/// Cap'n Proto generated types for `schemas/auth.capnp`.
///
/// Do not edit this module by hand — it is entirely machine-generated.
pub mod auth_capnp {
include!(concat!(env!("OUT_DIR"), "/auth_capnp.rs"));
}
/// Cap'n Proto generated types for `schemas/delivery.capnp`.
///
/// Do not edit this module by hand — it is entirely machine-generated.
pub mod delivery_capnp {
include!(concat!(env!("OUT_DIR"), "/delivery_capnp.rs"));
}
/// Cap'n Proto generated types for `schemas/node.capnp`.
///
/// Do not edit this module by hand — it is entirely machine-generated.
pub mod node_capnp {
include!(concat!(env!("OUT_DIR"), "/node_capnp.rs"));
}
/// Cap'n Proto generated types for `schemas/federation.capnp`.
///
/// Do not edit this module by hand — it is entirely machine-generated.
pub mod federation_capnp {
include!(concat!(env!("OUT_DIR"), "/federation_capnp.rs"));
}
// ── Low-level byte ↔ message conversions ──────────────────────────────────────
/// Serialise a Cap'n Proto message builder to unpacked wire bytes.
///
/// The output includes the segment table header. For transport, the
/// `quicproquo-core` frame codec prepends a 4-byte little-endian length field.
pub fn to_bytes<A: capnp::message::Allocator>(
msg: &capnp::message::Builder<A>,
) -> Result<Vec<u8>, capnp::Error> {
@@ -59,25 +41,17 @@ pub fn to_bytes<A: capnp::message::Allocator>(
Ok(buf)
}
/// Deserialise unpacked wire bytes into a message with owned segments.
///
/// Uses a stricter default traversal limit of 1 Mi words (~8 MiB) instead
/// of the Cap'n Proto default of 64 MiB, reducing DoS amplification from
/// untrusted input. Use [`from_bytes_with_options`] if you need a custom limit.
/// Deserialise unpacked wire bytes into a Cap'n Proto message.
pub fn from_bytes(
bytes: &[u8],
) -> Result<capnp::message::Reader<capnp::serialize::OwnedSegments>, capnp::Error> {
let mut options = capnp::message::ReaderOptions::new();
options.traversal_limit_in_words(Some(1_048_576)); // 1 Mi words = ~8 MiB
options.traversal_limit_in_words(Some(1_048_576));
let mut cursor = std::io::Cursor::new(bytes);
capnp::serialize::read_message(&mut cursor, options)
}
/// Deserialise unpacked wire bytes with caller-specified [`ReaderOptions`].
///
/// Prefer [`from_bytes`] for typical use. Use this variant when you need to
/// raise the traversal limit for large messages (e.g. blob transfers) or
/// lower it further for tighter validation.
/// Deserialise with custom [`ReaderOptions`].
pub fn from_bytes_with_options(
bytes: &[u8],
options: capnp::message::ReaderOptions,
@@ -85,3 +59,79 @@ pub fn from_bytes_with_options(
let mut cursor = std::io::Cursor::new(bytes);
capnp::serialize::read_message(&mut cursor, options)
}
// ════════════════════════════════════════════════════════════════════════════
// v2: Protobuf (prost) generated types
// ════════════════════════════════════════════════════════════════════════════
/// Protobuf types for the v2 RPC protocol.
pub mod qpq {
pub mod v1 {
include!(concat!(env!("OUT_DIR"), "/qpq.v1.rs"));
}
}
/// Method ID constants for the v2 RPC dispatch table.
pub mod method_ids {
// Auth (100-103)
pub const OPAQUE_REGISTER_START: u16 = 100;
pub const OPAQUE_REGISTER_FINISH: u16 = 101;
pub const OPAQUE_LOGIN_START: u16 = 102;
pub const OPAQUE_LOGIN_FINISH: u16 = 103;
// Delivery (200-205)
pub const ENQUEUE: u16 = 200;
pub const FETCH: u16 = 201;
pub const FETCH_WAIT: u16 = 202;
pub const PEEK: u16 = 203;
pub const ACK: u16 = 204;
pub const BATCH_ENQUEUE: u16 = 205;
// Keys (300-304)
pub const UPLOAD_KEY_PACKAGE: u16 = 300;
pub const FETCH_KEY_PACKAGE: u16 = 301;
pub const UPLOAD_HYBRID_KEY: u16 = 302;
pub const FETCH_HYBRID_KEY: u16 = 303;
pub const FETCH_HYBRID_KEYS: u16 = 304;
// Channel (400)
pub const CREATE_CHANNEL: u16 = 400;
// User (500-501)
pub const RESOLVE_USER: u16 = 500;
pub const RESOLVE_IDENTITY: u16 = 501;
// Blob (600-601)
pub const UPLOAD_BLOB: u16 = 600;
pub const DOWNLOAD_BLOB: u16 = 601;
// Device (700-702)
pub const REGISTER_DEVICE: u16 = 700;
pub const LIST_DEVICES: u16 = 701;
pub const REVOKE_DEVICE: u16 = 702;
// P2P (800-802)
pub const PUBLISH_ENDPOINT: u16 = 800;
pub const RESOLVE_ENDPOINT: u16 = 801;
pub const HEALTH: u16 = 802;
// Federation (900-905)
pub const RELAY_ENQUEUE: u16 = 900;
pub const RELAY_BATCH_ENQUEUE: u16 = 901;
pub const PROXY_FETCH_KEY_PACKAGE: u16 = 902;
pub const PROXY_FETCH_HYBRID_KEY: u16 = 903;
pub const PROXY_RESOLVE_USER: u16 = 904;
pub const FEDERATION_HEALTH: u16 = 905;
// Account (950)
pub const DELETE_ACCOUNT: u16 = 950;
// Push event types (1000+)
pub const PUSH_NEW_MESSAGE: u16 = 1000;
pub const PUSH_TYPING: u16 = 1001;
pub const PUSH_PRESENCE: u16 = 1002;
pub const PUSH_MEMBERSHIP: u16 = 1003;
}
pub use prost;
pub use bytes;