Skip to main content

pointlock_provider_devicerail/
endpoint.rs

1//! `OpenSessionOptions.endpoint` shapes for this provider (04 §9.1).
2//!
3//! The SPI keeps the endpoint as opaque JSON; this provider deserializes
4//! the `{ spawn: SpawnSpec } | { attach: AttachSpec }` union. M1 implements
5//! the spawn form only — Pointlock owns the daemon lifecycle; the attach
6//! form (debug/shared-device scenarios) is rejected with a typed error
7//! until the client-transport surface for it is wired up.
8
9use std::collections::BTreeMap;
10
11use pointlock_ir::ErrorClass;
12use pointlock_provider_kit::{ProviderError, RetryableSource};
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16/// Default daemon command, resolved through `PATH` (04 §9.1).
17pub const DEFAULT_DAEMON_COMMAND: &str = "devicerail-daemon";
18
19/// Default shutdown grace before the daemon child is killed (04 §9.1).
20pub const DEFAULT_SHUTDOWN_GRACE_MS: u64 = 5_000;
21
22/// The spawn endpoint form: Pointlock owns the daemon process.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "camelCase", deny_unknown_fields)]
25pub struct SpawnSpec {
26    /// Daemon command (default `devicerail-daemon`, `PATH`-resolved).
27    #[serde(default = "default_command")]
28    pub command: String,
29    /// Daemon arguments (default empty: the daemon serves stdio NDJSON
30    /// when started without arguments).
31    #[serde(default)]
32    pub args: Vec<String>,
33    /// Extra environment for the child.
34    #[serde(default)]
35    pub env: BTreeMap<String, String>,
36    /// Working directory for the child.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub cwd: Option<String>,
39    /// Grace period of the exit protocol (stdin EOF → wait → kill).
40    #[serde(default = "default_shutdown_grace_ms")]
41    pub shutdown_grace_ms: u64,
42}
43
44fn default_command() -> String {
45    DEFAULT_DAEMON_COMMAND.to_owned()
46}
47
48fn default_shutdown_grace_ms() -> u64 {
49    DEFAULT_SHUTDOWN_GRACE_MS
50}
51
52/// The endpoint union in its wire shape.
53#[derive(Debug, Clone, PartialEq, Deserialize)]
54#[serde(rename_all = "camelCase", deny_unknown_fields)]
55struct EndpointWire {
56    #[serde(default)]
57    spawn: Option<SpawnSpec>,
58    #[serde(default)]
59    attach: Option<Value>,
60}
61
62/// Parses the opaque SPI endpoint value into the M1-supported spawn form.
63pub(crate) fn parse_spawn_endpoint(endpoint: &Value) -> Result<SpawnSpec, ProviderError> {
64    let invalid = |detail: String| {
65        ProviderError::new(
66            ErrorClass::BindArgumentsInvalid,
67            format!("invalid DeviceRail endpoint: {detail}"),
68            RetryableSource::Classifier,
69        )
70    };
71    let wire: EndpointWire =
72        serde_json::from_value(endpoint.clone()).map_err(|error| invalid(error.to_string()))?;
73    match (wire.spawn, wire.attach) {
74        (Some(spawn), None) => Ok(spawn),
75        (None, Some(_)) => Err(invalid(
76            "the attach endpoint form is reserved for a later milestone; \
77             M1 implements spawn only (04 §9.1)"
78                .to_owned(),
79        )),
80        (Some(_), Some(_)) => Err(invalid(
81            "endpoint must be exactly one of { spawn } | { attach }".to_owned(),
82        )),
83        (None, None) => Err(invalid(
84            "endpoint must carry a { spawn: SpawnSpec } object".to_owned(),
85        )),
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use serde_json::json;
93
94    #[test]
95    fn spawn_endpoint_defaults_apply() {
96        let spec = parse_spawn_endpoint(&json!({ "spawn": {} })).expect("parse");
97        assert_eq!(spec.command, "devicerail-daemon");
98        assert!(spec.args.is_empty());
99        assert_eq!(spec.shutdown_grace_ms, 5_000);
100    }
101
102    #[test]
103    fn spawn_endpoint_round_trips_explicit_fields() {
104        let spec = parse_spawn_endpoint(&json!({
105            "spawn": {
106                "command": "/opt/devicerail/bin/devicerail-daemon",
107                "args": ["--verbose"],
108                "env": { "DEVICERAIL_ANDROID": "off" },
109                "cwd": "/tmp/run",
110                "shutdownGraceMs": 250
111            }
112        }))
113        .expect("parse");
114        assert_eq!(spec.command, "/opt/devicerail/bin/devicerail-daemon");
115        assert_eq!(spec.args, ["--verbose"]);
116        assert_eq!(spec.env["DEVICERAIL_ANDROID"], "off");
117        assert_eq!(spec.cwd.as_deref(), Some("/tmp/run"));
118        assert_eq!(spec.shutdown_grace_ms, 250);
119    }
120
121    #[test]
122    fn attach_and_malformed_endpoints_are_rejected_typed() {
123        for endpoint in [
124            json!({ "attach": { "transport": "socket" } }),
125            json!({}),
126            json!({ "spawn": {}, "attach": {} }),
127            json!({ "spwan": {} }),
128        ] {
129            let error = parse_spawn_endpoint(&endpoint).expect_err("rejected");
130            assert_eq!(error.error_class, ErrorClass::BindArgumentsInvalid);
131        }
132    }
133}