Milestone 1: workspace skeleton, wire protocol, socket round-trip

Three-crate workspace per SPECS.md. varde-proto defines the full JSON
Lines protocol (requests, envelopes, structured errors, events) with
string-typed hashes so the crate carries no iroh dependency. The daemon
binds its unix socket, loads config with flags > env > file > defaults
precedence, and answers status/list; everything else returns a
structured "unimplemented" error. varde-ctl maps subcommands 1:1 onto
requests and round-trips status against a real daemon in the tests.

Dependencies: serde/serde_json (wire format), tokio (async runtime and
unix sockets), tracing/tracing-subscriber (structured logging), toml
(config file), anyhow (binary-edge errors), thiserror (reserved for
library errors), clap (ctl flag parsing, per spec), tempfile (dev-only,
ephemeral test dirs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 20:10:29 +02:00
commit 4e1a95613b
19 changed files with 2466 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "varde-proto"
description = "Wire types for the varde socket API: requests, responses, events. No I/O."
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
[dependencies]
serde = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+407
View File
@@ -0,0 +1,407 @@
//! Wire types for the varde socket API.
//!
//! The protocol is JSON Lines over a unix stream socket: the client writes
//! one [`Request`] per line, the daemon answers with one [`Envelope`] per
//! line. A [`Request::Subscribe`] switches the connection into event mode,
//! after which the daemon writes one [`Event`] per line until the client
//! disconnects.
//!
//! Hashes, tickets and node ids travel as strings (BLAKE3 hex, iroh ticket
//! base32, z-base-32 NodeId respectively) so this crate stays free of iroh
//! dependencies and the protocol stays trivially consumable from any
//! language.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
/// Version of the socket protocol described by this crate.
///
/// Bumped on incompatible changes; reported in [`GlobalStatus`].
pub const PROTOCOL_VERSION: u32 = 1;
/// A request from a client to the daemon. One JSON object per line.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Request {
/// Import a file or directory at `path` into the store.
///
/// `path` must be absolute (the daemon has no meaningful cwd).
/// Returns [`ResponseData::Added`] with the root hash — for a directory
/// this is the HashSeq root, for a file the blob hash.
Add {
/// Absolute path of the file or directory to import.
path: String,
/// Import directories recursively. Adding a directory with
/// `recursive: false` is an error.
#[serde(default)]
recursive: bool,
},
/// Standing intent: keep this content, fetch it if absent, serve it to
/// peers according to `policy`.
Pin {
/// BLAKE3 hash (hex) of the blob or HashSeq root to pin.
hash: String,
/// Replication policy for this pin.
#[serde(default)]
policy: PinPolicy,
},
/// Remove a standing pin. Does not delete data — see [`Request::Gc`].
Unpin {
/// Hash (hex) of the pin to remove.
hash: String,
},
/// Export content from the store to a filesystem path.
Materialize {
/// Hash (hex) of the blob or HashSeq root to export.
hash: String,
/// Absolute destination path.
dest: String,
/// How to get bytes out of the store.
#[serde(default)]
mode: MaterializeMode,
},
/// Query daemon status, globally or for one hash.
Status {
/// If set, report on this hash; otherwise report global status.
#[serde(default, skip_serializing_if = "Option::is_none")]
hash: Option<String>,
},
/// List all pins with their policies and completeness.
List {},
/// Produce an iroh blob ticket for out-of-band sharing of `hash`.
TicketExport {
/// Hash (hex) of the content to share.
hash: String,
},
/// Import an iroh blob ticket: pin the content and fetch it from the
/// provider embedded in the ticket.
TicketImport {
/// The ticket string, as produced by [`Request::TicketExport`].
ticket: String,
/// Policy for the pin created by the import.
#[serde(default)]
pin_policy: PinPolicy,
},
/// Trust a peer: content is served to and fetched from trusted peers
/// only (unless a pin is `open_lan`).
PeerTrust {
/// The peer's iroh NodeId (z-base-32).
node_id: String,
},
/// Revoke trust in a peer.
PeerUntrust {
/// The peer's iroh NodeId (z-base-32).
node_id: String,
},
/// List trusted peers and their current connectivity.
PeerList {},
/// Drop all blobs not reachable from a pin. Explicit, never automatic.
Gc {},
/// Switch this connection to an event stream. The daemon acknowledges
/// with an ok envelope, then writes one [`Event`] per line.
Subscribe {},
}
/// Replication policy attached to a pin.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PinPolicy {
/// Serve this content to *any* LAN peer, not just trusted ones.
/// Defaults to off: trust gates participation.
#[serde(default)]
pub open_lan: bool,
}
/// How [`Request::Materialize`] gets bytes out of the store.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MaterializeMode {
/// Try a reflink (`FICLONE`) first, fall back to a plain copy.
#[default]
ReflinkOrCopy,
/// Always copy bytes.
Copy,
}
/// The envelope wrapped around every daemon reply.
///
/// `ok: true` replies carry `data`, `ok: false` replies carry `error`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Envelope {
/// Whether the request succeeded.
pub ok: bool,
/// Payload on success.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<ResponseData>,
/// Structured error on failure.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<ApiError>,
}
impl Envelope {
/// Build a success envelope carrying `data`.
pub fn ok(data: ResponseData) -> Self {
Envelope {
ok: true,
data: Some(data),
error: None,
}
}
/// Build an error envelope.
pub fn err(code: ErrorCode, message: impl Into<String>) -> Self {
Envelope {
ok: false,
data: None,
error: Some(ApiError {
code,
message: message.into(),
}),
}
}
}
/// A structured error. Never a bare string.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApiError {
/// Machine-readable error class.
pub code: ErrorCode,
/// Human-readable detail.
pub message: String,
}
/// Machine-readable error classes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
/// The request line was not valid JSON or not a known request.
BadRequest,
/// A hash, ticket or node id failed to parse.
InvalidArgument,
/// The referenced content is not in the store.
NotFound,
/// Filesystem-level failure (permissions, missing path, ...).
Io,
/// The store or endpoint failed internally.
Internal,
/// The feature exists in the protocol but this daemon build or
/// milestone does not implement it yet.
Unimplemented,
}
/// Success payloads, tagged by response kind.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ResponseData {
/// Content was imported. Reply to [`Request::Add`].
Added {
/// Root hash (hex) of the imported content.
hash: String,
/// Total bytes imported.
bytes: u64,
},
/// A pin was created or updated. Reply to [`Request::Pin`] and
/// [`Request::TicketImport`].
Pinned {
/// The pinned root hash (hex).
hash: String,
},
/// Generic acknowledgement for requests with nothing to report
/// (`Unpin`, `PeerTrust`, `PeerUntrust`, `Subscribe` ack).
Done {},
/// Content was exported. Reply to [`Request::Materialize`].
Materialized {
/// Bytes written to the destination.
bytes: u64,
/// Whether at least one file was reflinked rather than copied.
reflinked: bool,
},
/// Global daemon status. Reply to [`Request::Status`] without a hash.
Status(GlobalStatus),
/// Per-hash status. Reply to [`Request::Status`] with a hash.
HashStatus(HashStatus),
/// All pins. Reply to [`Request::List`].
Pins {
/// One entry per pin.
pins: Vec<PinInfo>,
},
/// A serialized iroh ticket. Reply to [`Request::TicketExport`].
Ticket {
/// The ticket string.
ticket: String,
},
/// Trusted peers. Reply to [`Request::PeerList`].
Peers {
/// One entry per trusted peer.
peers: Vec<PeerInfo>,
},
/// Garbage collection result. Reply to [`Request::Gc`].
GcDone {
/// Number of blobs removed.
blobs_removed: u64,
},
}
/// Global daemon status.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GlobalStatus {
/// Daemon version (crate version).
pub version: String,
/// Socket protocol version, see [`PROTOCOL_VERSION`].
pub protocol: u32,
/// This daemon's iroh NodeId (z-base-32), if the endpoint is up.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_id: Option<String>,
/// Path of the blob store directory.
pub store_path: String,
/// Number of standing pins.
pub pins: u64,
/// Number of connected trusted peers.
pub connected_peers: u64,
}
/// Status of one hash.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HashStatus {
/// The hash (hex) this reports on.
pub hash: String,
/// Bytes present locally.
pub have_bytes: u64,
/// Total size if known (unknown while a fetch has not seen the size).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_bytes: Option<u64>,
/// Whether the content is fully present and verified.
pub complete: bool,
/// Whether a pin exists for this hash.
pub pinned: bool,
/// Node ids of peers currently transferring this hash with us.
pub active_peers: Vec<String>,
}
/// One pin as reported by [`ResponseData::Pins`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PinInfo {
/// The pinned root hash (hex).
pub hash: String,
/// The pin's replication policy.
pub policy: PinPolicy,
/// Whether the content is fully present locally.
pub complete: bool,
/// Bytes present locally.
pub have_bytes: u64,
/// Total size if known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_bytes: Option<u64>,
}
/// One trusted peer as reported by [`ResponseData::Peers`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerInfo {
/// The peer's iroh NodeId (z-base-32).
pub node_id: String,
/// Whether we currently hold a connection to this peer.
pub connected: bool,
}
/// Events streamed after [`Request::Subscribe`]. One JSON object per line.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum Event {
/// Bytes moved for a hash.
TransferProgress {
/// The hash being transferred (hex).
hash: String,
/// Direction of the transfer.
direction: Direction,
/// Bytes transferred so far.
bytes_done: u64,
/// Total bytes if known.
#[serde(default, skip_serializing_if = "Option::is_none")]
bytes_total: Option<u64>,
},
/// A trusted peer became reachable.
PeerJoined {
/// The peer's NodeId (z-base-32).
node_id: String,
},
/// A trusted peer went away.
PeerLeft {
/// The peer's NodeId (z-base-32).
node_id: String,
},
/// A pin finished fetching and verified completely.
PinComplete {
/// The pinned root hash (hex).
hash: String,
},
/// A signed announcement was received on a subscribed discovery topic.
Announcement {
/// The announced HashSeq root (hex).
root: String,
/// The announcing author's ed25519 public key (hex).
author: String,
/// Free-form metadata carried by the announcement.
meta: BTreeMap<String, String>,
},
}
/// Transfer direction for [`Event::TransferProgress`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Direction {
/// We are fetching.
Download,
/// We are serving.
Upload,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_wire_shape() {
let req = Request::Add {
path: "/tmp/x".into(),
recursive: true,
};
let json = serde_json::to_string(&req).unwrap();
assert_eq!(json, r#"{"type":"add","path":"/tmp/x","recursive":true}"#);
let back: Request = serde_json::from_str(&json).unwrap();
assert_eq!(back, req);
}
#[test]
fn status_request_defaults() {
let req: Request = serde_json::from_str(r#"{"type":"status"}"#).unwrap();
assert_eq!(req, Request::Status { hash: None });
}
#[test]
fn envelope_ok_carries_flag() {
let env = Envelope::ok(ResponseData::Done {});
let json = serde_json::to_string(&env).unwrap();
assert!(json.starts_with(r#"{"ok":true"#));
let back: Envelope = serde_json::from_str(&json).unwrap();
assert!(back.ok && back.error.is_none());
}
#[test]
fn envelope_err_is_structured() {
let env = Envelope::err(ErrorCode::NotFound, "no such hash");
let json = serde_json::to_string(&env).unwrap();
let back: Envelope = serde_json::from_str(&json).unwrap();
let err = back.error.unwrap();
assert_eq!(err.code, ErrorCode::NotFound);
assert!(!back.ok);
}
#[test]
fn pin_policy_defaults_closed() {
let req: Request = serde_json::from_str(r#"{"type":"pin","hash":"abc"}"#).unwrap();
match req {
Request::Pin { policy, .. } => assert!(!policy.open_lan),
_ => panic!("wrong variant"),
}
}
}