Skip to main content

sui_spec/
worker_protocol.rs

1//! Typed border for the nix-daemon worker protocol.
2//!
3//! When a nix client (`nix build`, `nix-env`, `nix-store --realise`)
4//! needs to mutate the store, it doesn't write `/nix/store` itself
5//! — it connects to the `nix-daemon` over a unix socket and speaks
6//! the *worker protocol*.  cppnix's `libstore/worker-protocol.cc`
7//! defines ~30 opcodes covering the full read+write surface of the
8//! store.
9//!
10//! Today sui-daemon implements this protocol in Rust; this module
11//! names the wire contract as a typed Lisp spec so any future client
12//! (or any third-party daemon) rides on the same authored shape.
13//! Both engines (sui's client side + sui-daemon's server side) drive
14//! the same spec — they cannot drift.
15//!
16//! ## Authoring surface
17//!
18//! Two keyword forms compose:
19//!
20//! - `(defworker-protocol ...)` declares the protocol version +
21//!   handshake (magic bytes, version negotiation).
22//! - `(defworker-opcode ...)` declares ONE opcode per form: numeric
23//!   code, direction, request-field types, response-field types,
24//!   feature gate.  ~30 opcodes today; new ones land as additional
25//!   `(defworker-opcode)` forms.
26//!
27//! Example:
28//!
29//! ```lisp
30//! (defworker-protocol
31//!   :name "cppnix-worker-protocol"
32//!   :version 35
33//!   :magic-client "0x6e697863"   ;; "nixc"
34//!   :magic-server "0x6478696f")  ;; "dxio"
35//!
36//! (defworker-opcode
37//!   :name "QueryPathInfo"
38//!   :code 29
39//!   :direction ClientToDaemon
40//!   :request-fields  (StorePath)
41//!   :response-fields (PathInfo)
42//!   :since-version 17)
43//! ```
44
45use serde::{Deserialize, Serialize};
46use tatara_lisp::DeriveTataraDomain;
47
48use crate::SpecError;
49
50// ── Typed border — protocol envelope ──────────────────────────────
51
52/// One worker-protocol version.  Typically there's one entry per
53/// major nix release (v23, v25, v27, v33, v35).
54#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
55#[tatara(keyword = "defworker-protocol")]
56pub struct WorkerProtocol {
57    pub name: String,
58    pub version: u32,
59    /// Client→daemon handshake magic, hex literal.
60    #[serde(rename = "magicClient")]
61    pub magic_client: String,
62    /// Daemon→client handshake magic, hex literal.
63    #[serde(rename = "magicServer")]
64    pub magic_server: String,
65}
66
67// ── Typed border — opcodes ─────────────────────────────────────────
68
69/// One worker-protocol opcode.
70#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
71#[tatara(keyword = "defworker-opcode")]
72pub struct WorkerOpcode {
73    pub name: String,
74    pub code: u32,
75    pub direction: OpcodeDirection,
76    /// Field types serialised in the request body (in order).
77    #[serde(rename = "requestFields")]
78    pub request_fields: Vec<WireType>,
79    /// Field types serialised in the response body (in order).
80    #[serde(default, rename = "responseFields")]
81    pub response_fields: Vec<WireType>,
82    /// Earliest protocol version that supports this opcode.
83    #[serde(default, rename = "sinceVersion")]
84    pub since_version: u32,
85    /// `true` if cppnix has deprecated this opcode.
86    #[serde(default)]
87    pub deprecated: bool,
88}
89
90/// Direction of an opcode call.
91#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
92pub enum OpcodeDirection {
93    /// Client initiates; daemon responds.
94    ClientToDaemon,
95    /// Daemon initiates a callback (rare; used for build progress).
96    DaemonToClient,
97}
98
99/// Wire-level field type — the primitive types the worker protocol
100/// can serialise.  Length-prefixed strings + 8-byte LE integers
101/// per cppnix's `worker-protocol.cc` `WORKER_MAGIC` framing.
102#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
103pub enum WireType {
104    /// 8-byte little-endian unsigned integer.
105    U64,
106    /// Length-prefixed UTF-8 string (8-byte LE length + padded bytes).
107    Str,
108    /// Length-prefixed byte array.
109    Bytes,
110    /// 0 = false, 1 = true; encoded as U64.
111    Bool,
112    /// Store path — Str semantically, but the spec calls it out
113    /// so the type checker can validate path-shape.
114    StorePath,
115    /// List of strings (8-byte LE count + N Str entries).
116    StringList,
117    /// List of store paths (8-byte LE count + N StorePath).
118    StorePathList,
119    /// `PathInfo` structured response (multi-field).
120    PathInfo,
121    /// `ValidPathInfo` (a richer PathInfo variant).
122    ValidPathInfo,
123    /// `DerivationOutputs` map (output-name → output-path).
124    DerivationOutputs,
125    /// `RealisationsMap`.
126    RealisationsMap,
127    /// `KeyedBuildResult` (status enum + log + per-output info).
128    KeyedBuildResult,
129    /// `Substitutables` — the substituter-side path-info map.
130    Substitutables,
131    /// Free-form attrset of key=value lines.
132    KeyValueAttrs,
133    /// Build-mode enum (Normal / Repair / Check).
134    BuildMode,
135}
136
137// ── Canonical spec ─────────────────────────────────────────────────
138
139pub const CANONICAL_WORKER_PROTOCOL_LISP: &str =
140    include_str!("../specs/worker_protocol.lisp");
141
142/// Compile the canonical worker-protocol version envelope(s).
143///
144/// # Errors
145///
146/// Returns an error if the Lisp source fails to parse.
147pub fn load_canonical_protocols() -> Result<Vec<WorkerProtocol>, SpecError> {
148    crate::loader::load_all::<WorkerProtocol>(CANONICAL_WORKER_PROTOCOL_LISP)
149}
150
151/// Compile every authored opcode.
152///
153/// # Errors
154///
155/// Returns an error if the Lisp source fails to parse.
156pub fn load_canonical_opcodes() -> Result<Vec<WorkerOpcode>, SpecError> {
157    crate::loader::load_all::<WorkerOpcode>(CANONICAL_WORKER_PROTOCOL_LISP)
158}
159
160/// Return the opcode whose `name` matches.
161///
162/// # Errors
163///
164/// Returns an error if the spec fails to parse or `name` is missing.
165pub fn load_opcode_named(name: &str) -> Result<WorkerOpcode, SpecError> {
166    load_canonical_opcodes()?
167        .into_iter()
168        .find(|o| o.name == name)
169        .ok_or_else(|| SpecError::Load(format!("no (defworker-opcode) with :name {name:?}")))
170}
171
172/// Return the opcode with the given `code`.
173///
174/// # Errors
175///
176/// Returns an error if the spec fails to parse or `code` doesn't
177/// match any authored opcode.
178pub fn load_opcode_by_code(code: u32) -> Result<WorkerOpcode, SpecError> {
179    load_canonical_opcodes()?
180        .into_iter()
181        .find(|o| o.code == code)
182        .ok_or_else(|| SpecError::Load(format!("no (defworker-opcode) with :code {code}")))
183}
184
185// ── M3.0 dispatch interpreter ──────────────────────────────────────
186
187/// Result of dispatching a worker-protocol opcode against a handler.
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub struct DispatchOutcome {
190    /// The opcode that was dispatched.
191    pub opcode_name: String,
192    /// The numeric code.
193    pub opcode_code: u32,
194    /// Response body bytes the handler produced.  Empty for
195    /// opcodes with no `response_fields`.
196    pub response_body: Vec<u8>,
197}
198
199/// Abstract handler for the worker protocol's server side.  Each
200/// opcode dispatches to a corresponding method here; the default
201/// implementation returns a typed `unhandled-opcode` error so an
202/// incomplete handler surfaces missing opcodes loudly.
203///
204/// In production, sui-daemon implements this trait against its
205/// real store.  Tests can provide a mock that records dispatched
206/// opcodes for verification.
207pub trait WorkerProtocolHandler {
208    /// Catch-all handler.  Default returns `unhandled-opcode`
209    /// error.  Concrete impls override per opcode they support.
210    ///
211    /// # Errors
212    ///
213    /// Default impl always.  Concrete handlers return per-opcode
214    /// errors.
215    fn dispatch(&self, opcode: &WorkerOpcode, body: &[u8])
216        -> Result<Vec<u8>, String>
217    {
218        Err(format!(
219            "unhandled opcode `{}` (code {}) — handler must override dispatch \
220             or implement an explicit case",
221            opcode.name, opcode.code,
222        ))
223    }
224}
225
226/// Dispatch one wire-arriving opcode against a handler.  Looks up
227/// the opcode by code in the canonical catalog, then routes to
228/// `handler.dispatch`.
229///
230/// # Errors
231///
232/// - `unknown-opcode` if `code` doesn't appear in the canonical
233///   opcode set.
234/// - `dispatch-failed` if the handler returns an error.
235pub fn apply<H: WorkerProtocolHandler>(
236    code: u32,
237    body: &[u8],
238    handler: &H,
239) -> Result<DispatchOutcome, SpecError> {
240    let opcode = load_opcode_by_code(code).map_err(|_| SpecError::Interp {
241        phase: "unknown-opcode".into(),
242        message: format!(
243            "received opcode {code} but no (defworker-opcode :code {code}) \
244             is authored in the canonical worker-protocol spec",
245        ),
246    })?;
247    let response_body = handler.dispatch(&opcode, body).map_err(|e| SpecError::Interp {
248        phase: "dispatch-failed".into(),
249        message: format!("opcode `{}` (code {code}): {e}", opcode.name),
250    })?;
251    Ok(DispatchOutcome {
252        opcode_name: opcode.name,
253        opcode_code: code,
254        response_body,
255    })
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use std::collections::{HashMap, HashSet};
262
263    #[test]
264    fn canonical_protocol_parses() {
265        let protos = load_canonical_protocols().unwrap();
266        assert!(!protos.is_empty());
267    }
268
269    #[test]
270    fn canonical_opcodes_parse() {
271        let opcodes = load_canonical_opcodes().unwrap();
272        assert!(
273            opcodes.len() >= 25,
274            "canonical worker protocol must declare at least 25 opcodes, got {}",
275            opcodes.len(),
276        );
277    }
278
279    #[test]
280    fn opcodes_have_unique_codes() {
281        let opcodes = load_canonical_opcodes().unwrap();
282        let mut by_code: HashMap<u32, Vec<&str>> = HashMap::new();
283        for op in &opcodes {
284            by_code.entry(op.code).or_default().push(op.name.as_str());
285        }
286        for (code, names) in &by_code {
287            assert_eq!(
288                names.len(),
289                1,
290                "opcode {code} has multiple authored entries: {names:?}",
291            );
292        }
293    }
294
295    #[test]
296    fn essential_opcodes_present() {
297        let opcodes = load_canonical_opcodes().unwrap();
298        let names: HashSet<&str> = opcodes.iter().map(|o| o.name.as_str()).collect();
299        // The opcodes every client speaks at least once per session.
300        for required in [
301            "IsValidPath",
302            "QueryPathInfo",
303            "AddToStore",
304            "BuildPaths",
305            "BuildDerivation",
306            "QueryReferrers",
307            "QueryValidPaths",
308            "CollectGarbage",
309            "NarFromPath",
310            "AddTempRoot",
311        ] {
312            assert!(
313                names.contains(required),
314                "canonical worker protocol missing opcode `{required}`",
315            );
316        }
317    }
318
319    #[test]
320    fn query_path_info_has_correct_shape() {
321        let op = load_opcode_named("QueryPathInfo").unwrap();
322        assert_eq!(op.code, 26);
323        assert_eq!(op.direction, OpcodeDirection::ClientToDaemon);
324        assert_eq!(op.request_fields, vec![WireType::StorePath]);
325    }
326
327    #[test]
328    fn ca_realisation_opcodes_present() {
329        // CA-drv realisation lookup landed in protocol v32+.
330        let names: HashSet<String> = load_canonical_opcodes()
331            .unwrap()
332            .into_iter()
333            .map(|o| o.name)
334            .collect();
335        assert!(names.contains("QueryRealisation"));
336        assert!(names.contains("RegisterDrvOutput"));
337    }
338
339    // ── M3.0 dispatch tests ────────────────────────────────────
340
341    use std::cell::RefCell;
342
343    struct LoggingHandler {
344        seen: RefCell<Vec<(String, u32)>>,
345        response_for: HashMap<u32, Vec<u8>>,
346    }
347
348    impl LoggingHandler {
349        fn new() -> Self {
350            Self {
351                seen: RefCell::new(Vec::new()),
352                response_for: HashMap::new(),
353            }
354        }
355        fn responds(mut self, code: u32, body: &[u8]) -> Self {
356            self.response_for.insert(code, body.to_vec());
357            self
358        }
359    }
360
361    impl WorkerProtocolHandler for LoggingHandler {
362        fn dispatch(&self, opcode: &WorkerOpcode, _body: &[u8])
363            -> Result<Vec<u8>, String>
364        {
365            self.seen.borrow_mut().push((opcode.name.clone(), opcode.code));
366            self.response_for
367                .get(&opcode.code)
368                .cloned()
369                .ok_or_else(|| format!("no response configured for code {}", opcode.code))
370        }
371    }
372
373    #[test]
374    fn dispatch_known_opcode_succeeds() {
375        let handler = LoggingHandler::new().responds(1, b"\x01");  // IsValidPath
376        let outcome = apply(1, b"", &handler).unwrap();
377        assert_eq!(outcome.opcode_name, "IsValidPath");
378        assert_eq!(outcome.opcode_code, 1);
379        assert_eq!(outcome.response_body, vec![1u8]);
380        let log = handler.seen.borrow();
381        assert_eq!(log.len(), 1);
382        assert_eq!(log[0].0, "IsValidPath");
383    }
384
385    #[test]
386    fn dispatch_unknown_opcode_errors() {
387        let handler = LoggingHandler::new();
388        let err = apply(9999, b"", &handler).unwrap_err();
389        match err {
390            SpecError::Interp { phase, message } => {
391                assert_eq!(phase, "unknown-opcode");
392                assert!(message.contains("9999"));
393            }
394            _ => panic!("expected unknown-opcode"),
395        }
396    }
397
398    #[test]
399    fn dispatch_handler_failure_surfaces_as_dispatch_failed() {
400        // LoggingHandler returns Err when no response is configured.
401        let handler = LoggingHandler::new();  // no responses
402        let err = apply(1, b"", &handler).unwrap_err();
403        match err {
404            SpecError::Interp { phase, message } => {
405                assert_eq!(phase, "dispatch-failed");
406                assert!(message.contains("IsValidPath"));
407            }
408            _ => panic!("expected dispatch-failed"),
409        }
410    }
411
412    #[test]
413    fn load_opcode_by_code_finds_known() {
414        let op = load_opcode_by_code(26).unwrap();  // QueryPathInfo
415        assert_eq!(op.name, "QueryPathInfo");
416    }
417
418    #[test]
419    fn load_opcode_by_code_errors_on_missing() {
420        let err = load_opcode_by_code(99999).unwrap_err();
421        match err {
422            SpecError::Load(msg) => assert!(msg.contains("99999")),
423            _ => panic!("expected Load error"),
424        }
425    }
426
427    #[test]
428    fn default_handler_returns_unhandled() {
429        struct Empty;
430        impl WorkerProtocolHandler for Empty {}
431        let err = apply(1, b"", &Empty).unwrap_err();
432        match err {
433            SpecError::Interp { phase, .. } => assert_eq!(phase, "dispatch-failed"),
434            _ => panic!("expected dispatch-failed"),
435        }
436    }
437}