Skip to main content

pointlock_provider_devicerail/
provider.rs

1//! `DeviceRailProvider`: the `Provider` implementation — static manifest
2//! plus the atomic `openSession` sequence (04 §2.1 / §9.3):
3//!
4//! ```text
5//! DeviceRailClient.spawn → system.hello → devices.list → device.select
6//!   → device.connect → device.capabilities (→ attestation) → session.start
7//! ```
8//!
9//! Returning from `open_session` means every step succeeded and the live
10//! world matched `lockfileDigest`; on any failure the spawned daemon is
11//! torn down before the error propagates — never a half-open session.
12
13use std::collections::BTreeSet;
14use std::time::Duration;
15
16use async_trait::async_trait;
17use devicerail_client::protocol::{ActionDefinition, DeviceId, DeviceInfo, DeviceSelectParams};
18use devicerail_client::{CallOptions, DeviceRailClient, SpawnConfig, methods};
19use pointlock_ir::ErrorClass;
20use pointlock_provider_kit::lockfile::{CapabilityAttestation, CapabilityLockfile};
21use pointlock_provider_kit::manifest::ProviderManifest;
22use pointlock_provider_kit::{
23    OpenSessionOptions, Provider, ProviderError, ProviderSession, RetryableSource,
24};
25
26use crate::budget::{DEFAULT_CALL_BUDGET_MS, bounded, envelope_options};
27use crate::convert::now_utc_iso;
28use crate::endpoint::{SpawnSpec, parse_spawn_endpoint};
29use crate::lock::{hello_params, lockfile_provider_identity, make_lockfile};
30use crate::manifest::{PROVIDER_NAME, devicerail_manifest};
31use crate::session::DeviceRailSession;
32
33/// The DeviceRail provider. Holds the capability lockfile the flow was
34/// compiled against; `open_session` re-attests the live world against it.
35#[derive(Debug, Clone)]
36pub struct DeviceRailProvider {
37    lockfile: CapabilityLockfile,
38}
39
40impl DeviceRailProvider {
41    /// Creates a provider around the held lockfile, verifying its
42    /// self-consistency (a tampered or hand-edited lockfile fails closed).
43    pub fn new(lockfile: CapabilityLockfile) -> Result<Self, ProviderError> {
44        if lockfile.provider.name != PROVIDER_NAME {
45            return Err(ProviderError::new(
46                ErrorClass::CapabilityDrift,
47                format!(
48                    "lockfile was produced by provider `{}`; this provider is `{PROVIDER_NAME}`",
49                    lockfile.provider.name
50                ),
51                RetryableSource::Classifier,
52            ));
53        }
54        if !lockfile.digest_consistent() {
55            return Err(ProviderError::new(
56                ErrorClass::CapabilityDrift,
57                "lockfile digest is inconsistent with its content; re-run `pointlock lock`",
58                RetryableSource::Classifier,
59            ));
60        }
61        Ok(DeviceRailProvider { lockfile })
62    }
63
64    /// The held lockfile baseline.
65    pub fn lockfile(&self) -> &CapabilityLockfile {
66        &self.lockfile
67    }
68
69    async fn open_inner(
70        &self,
71        client: &DeviceRailClient,
72        opts: &OpenSessionOptions,
73    ) -> Result<DeviceRailSession, ProviderError> {
74        let hello = client
75            .negotiated_hello()
76            .map_err(|error| crate::error_map::provider_error_from_client(error, "system.hello"))?;
77
78        let (device, actions) = connect_device(client, &opts.device_id).await?;
79
80        // Attestation (04 §9.2): re-synthesize the canonical lockfile form
81        // from the live world (identity fields from the held baseline) and
82        // compare digests. Any drift — negotiated protocol, feature set,
83        // server identity, platform, action catalog — changes the digest.
84        let live = make_lockfile(
85            self.lockfile.provider.clone(),
86            self.lockfile.attested_at.clone(),
87            &hello,
88            &device,
89            &actions,
90        )?;
91        if live.digest != opts.lockfile_digest {
92            return Err(ProviderError::new(
93                ErrorClass::CapabilityDrift,
94                format!(
95                    "attestation mismatch: the live world's canonical digest {} does not match \
96                     the expected lockfileDigest {}; {} — re-run `pointlock lock` and recompile",
97                    live.digest,
98                    opts.lockfile_digest,
99                    describe_drift(&self.lockfile, &live)
100                ),
101                RetryableSource::Classifier,
102            ));
103        }
104
105        // session.start (§9.3 ordering: after connect, before any action).
106        let session = bounded(
107            client.call::<methods::SessionStart>(methods::NoParams, CallOptions::default()),
108        )
109        .await
110        .map_err(|error| error.into_provider_error("session.start"))?;
111
112        let attestation = CapabilityAttestation::from_lockfile(&live, now_utc_iso());
113        Ok(DeviceRailSession::new(
114            client.clone(),
115            attestation,
116            session.id,
117        ))
118    }
119}
120
121#[async_trait]
122impl Provider for DeviceRailProvider {
123    fn manifest(&self) -> &ProviderManifest {
124        devicerail_manifest()
125    }
126
127    async fn open_session(
128        &self,
129        opts: OpenSessionOptions,
130    ) -> Result<Box<dyn ProviderSession>, ProviderError> {
131        // The IR must have been compiled against the very lockfile this
132        // provider holds; otherwise the baseline for attestation is wrong.
133        if opts.lockfile_digest != self.lockfile.digest {
134            return Err(ProviderError::new(
135                ErrorClass::CapabilityDrift,
136                format!(
137                    "the IR's lockfileDigest {} does not match the held lockfile digest {}; \
138                     recompile against the current lockfile",
139                    opts.lockfile_digest, self.lockfile.digest
140                ),
141                RetryableSource::Classifier,
142            ));
143        }
144        let spawn = parse_spawn_endpoint(&opts.endpoint)?;
145        let client = spawn_client(&spawn, &opts.required_features).await?;
146        match self.open_inner(&client, &opts).await {
147            Ok(session) => Ok(Box::new(session)),
148            Err(error) => {
149                // Atomicity (04 §2.1): clean up the spawned daemon; never
150                // return a half-open session.
151                let _ = client.close().await;
152                Err(error)
153            }
154        }
155    }
156}
157
158/// Spawns the daemon and negotiates `system.hello` (04 §9.1 / §9.2). The
159/// required set is the flow's `requiredFeatures` plus the provider
160/// infrastructure three; protocol semantics fail the handshake when any
161/// required feature is unmet (free enforcement, spine §4.1).
162async fn spawn_client(
163    spawn: &SpawnSpec,
164    required_features: &[pointlock_ir::FeatureId],
165) -> Result<DeviceRailClient, ProviderError> {
166    let mut config = SpawnConfig::new(&spawn.command, hello_params(required_features));
167    config = config.args(spawn.args.iter());
168    for (key, value) in &spawn.env {
169        config = config.env(key, value);
170    }
171    if let Some(cwd) = &spawn.cwd {
172        config = config.cwd(cwd);
173    }
174    // Exit-protocol grace (04 §9.1): stdin EOF → wait → kill. The client
175    // collapses the SIGTERM step into its kill path (documented divergence).
176    config.client.close_grace = Duration::from_millis(spawn.shutdown_grace_ms.max(1));
177    DeviceRailClient::spawn(config).await.map_err(|error| {
178        crate::error_map::provider_error_from_client(error, "spawn daemon / system.hello")
179    })
180}
181
182/// The device-binding leg of §9.3: devices.list (verify the requested
183/// device exists) → device.select → device.connect → device.capabilities.
184/// Explicit selection only — the protocol's single-device lazy routing is
185/// deliberately not used (04 §9.2: implicit routing contradicts
186/// attestation determinism).
187async fn connect_device(
188    client: &DeviceRailClient,
189    device_id: &str,
190) -> Result<(DeviceInfo, Vec<ActionDefinition>), ProviderError> {
191    let devices =
192        bounded(client.call::<methods::DevicesList>(methods::NoParams, CallOptions::default()))
193            .await
194            .map_err(|error| error.into_provider_error("devices.list"))?;
195    let wanted = DeviceId::new(device_id);
196    if !devices.devices.iter().any(|device| device.id == wanted) {
197        let known: Vec<String> = devices
198            .devices
199            .iter()
200            .map(|device| device.id.to_string())
201            .collect();
202        // Device unavailable → retryable (04 §2.1 failure classes).
203        return Err(ProviderError::new(
204            ErrorClass::ActionFailedRetryable,
205            format!("device `{device_id}` is not present in devices.list (known: {known:?})"),
206            RetryableSource::Classifier,
207        ));
208    }
209    bounded(client.call::<methods::DeviceSelect>(
210        DeviceSelectParams { device_id: wanted },
211        CallOptions::default(),
212    ))
213    .await
214    .map_err(|error| error.into_provider_error("device.select"))?;
215    let device = bounded(client.call::<methods::DeviceConnect>(
216        methods::NoParams,
217        envelope_options(DEFAULT_CALL_BUDGET_MS),
218    ))
219    .await
220    .map_err(|error| error.into_provider_error("device.connect"))?;
221    let actions = bounded(client.call::<methods::DeviceCapabilities>(
222        methods::NoParams,
223        envelope_options(DEFAULT_CALL_BUDGET_MS),
224    ))
225    .await
226    .map_err(|error| error.into_provider_error("device.capabilities"))?;
227    Ok((device, actions))
228}
229
230/// Runs the `pointlock lock` wire sequence against a freshly spawned daemon
231/// and freezes the outcome into a sealed [`CapabilityLockfile`]
232/// (04 §10.2; CLI wiring lands with `pointlock lock`).
233pub async fn lock_via_spawn(
234    spawn: &SpawnSpec,
235    device_id: &str,
236) -> Result<CapabilityLockfile, ProviderError> {
237    lock_via_spawn_at(spawn, device_id, now_utc_iso()).await
238}
239
240/// [`lock_via_spawn`] with a caller-pinned `attestedAt`. The digest domain
241/// already excludes `attestedAt` (spine §4.1, 04 §10.2), so pinning is
242/// never needed for digest stability — it remains useful only for
243/// byte-identical lockfile artifacts / pinned provenance in fixtures.
244pub async fn lock_via_spawn_at(
245    spawn: &SpawnSpec,
246    device_id: &str,
247    attested_at: impl Into<String>,
248) -> Result<CapabilityLockfile, ProviderError> {
249    let client = spawn_client(spawn, &[]).await?;
250    let result = async {
251        let hello = client
252            .negotiated_hello()
253            .map_err(|error| crate::error_map::provider_error_from_client(error, "system.hello"))?;
254        let (device, actions) = connect_device(&client, device_id).await?;
255        make_lockfile(
256            lockfile_provider_identity(),
257            attested_at,
258            &hello,
259            &device,
260            &actions,
261        )
262    }
263    .await;
264    let _ = client.close().await;
265    result
266}
267
268/// Human-readable drift summary for the attestation failure report
269/// (04 §9.2: list what appeared, what vanished, what changed).
270fn describe_drift(expected: &CapabilityLockfile, live: &CapabilityLockfile) -> String {
271    let mut notes: Vec<String> = Vec::new();
272    if expected.hello.protocol_selected != live.hello.protocol_selected {
273        notes.push(format!(
274            "protocol {}.{} -> {}.{}",
275            expected.hello.protocol_selected.major,
276            expected.hello.protocol_selected.minor,
277            live.hello.protocol_selected.major,
278            live.hello.protocol_selected.minor,
279        ));
280    }
281    if expected.hello.server != live.hello.server {
282        notes.push(format!(
283            "server {}@{} -> {}@{}",
284            expected.hello.server.name,
285            expected.hello.server.version,
286            live.hello.server.name,
287            live.hello.server.version,
288        ));
289    }
290    let expected_features: BTreeSet<&str> = expected
291        .hello
292        .features_enabled
293        .iter()
294        .map(|feature| feature.as_str())
295        .collect();
296    let live_features: BTreeSet<&str> = live
297        .hello
298        .features_enabled
299        .iter()
300        .map(|feature| feature.as_str())
301        .collect();
302    let missing: Vec<&&str> = expected_features.difference(&live_features).collect();
303    if !missing.is_empty() {
304        notes.push(format!("features lost: {missing:?}"));
305    }
306    let gained: Vec<&&str> = live_features.difference(&expected_features).collect();
307    if !gained.is_empty() {
308        notes.push(format!("features gained: {gained:?}"));
309    }
310    if expected.device.platform != live.device.platform {
311        notes.push(format!(
312            "platform {:?} -> {:?}",
313            expected.device.platform, live.device.platform
314        ));
315    }
316    let expected_actions: BTreeSet<&str> = expected
317        .device
318        .actions
319        .iter()
320        .map(|action| action.name.as_str())
321        .collect();
322    let live_actions: BTreeSet<&str> = live
323        .device
324        .actions
325        .iter()
326        .map(|action| action.name.as_str())
327        .collect();
328    let vanished: Vec<&&str> = expected_actions.difference(&live_actions).collect();
329    if !vanished.is_empty() {
330        notes.push(format!("actions vanished: {vanished:?}"));
331    }
332    let appeared: Vec<&&str> = live_actions.difference(&expected_actions).collect();
333    if !appeared.is_empty() {
334        notes.push(format!("actions appeared: {appeared:?}"));
335    }
336    let changed: Vec<&str> = expected
337        .device
338        .actions
339        .iter()
340        .filter_map(|action| {
341            live.device
342                .actions
343                .iter()
344                .find(|live_action| live_action.name == action.name)
345                .filter(|live_action| *live_action != action)
346                .map(|_| action.name.as_str())
347        })
348        .collect();
349    if !changed.is_empty() {
350        notes.push(format!("action definitions changed: {changed:?}"));
351    }
352    if notes.is_empty() {
353        // The content diff is invisible at this granularity (e.g. only the
354        // action order changed); the digests still disagree.
355        "no field-level diff detected at summary granularity".to_owned()
356    } else {
357        format!("drift: {}", notes.join("; "))
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use pointlock_ir::{FeatureId, Hash};
364    use pointlock_provider_kit::lockfile::{
365        LockfileDevice, LockfileHello, LockfileProvider, PeerInfo, ProtocolVersion,
366    };
367    use pointlock_provider_kit::manifest::PlatformKind;
368
369    use super::*;
370
371    fn sample_lockfile(name: &str) -> CapabilityLockfile {
372        let mut lockfile = CapabilityLockfile {
373            provider: LockfileProvider {
374                name: name.to_owned(),
375                version: "0.1.0".to_owned(),
376            },
377            attested_at: "2026-01-01T00:00:00Z".to_owned(),
378            hello: LockfileHello {
379                protocol_selected: ProtocolVersion { major: 1, minor: 5 },
380                features_enabled: vec![FeatureId::new("events.snapshot.v1").unwrap()],
381                server: PeerInfo {
382                    name: "devicerail-daemon".to_owned(),
383                    version: "0.9.0".to_owned(),
384                },
385            },
386            device: LockfileDevice {
387                platform: PlatformKind::Linux,
388                actions: Vec::new(),
389            },
390            digest: Hash::new(format!("sha256:{}", "0".repeat(64))).unwrap(),
391        };
392        lockfile.seal();
393        lockfile
394    }
395
396    #[test]
397    fn provider_construction_fails_closed_on_bad_lockfiles() {
398        // Wrong provider name.
399        let error = DeviceRailProvider::new(sample_lockfile("fake")).expect_err("name check");
400        assert_eq!(error.error_class, ErrorClass::CapabilityDrift);
401
402        // Tampered digest.
403        let mut tampered = sample_lockfile(PROVIDER_NAME);
404        tampered.digest = Hash::new(format!("sha256:{}", "f".repeat(64))).unwrap();
405        let error = DeviceRailProvider::new(tampered).expect_err("digest check");
406        assert_eq!(error.error_class, ErrorClass::CapabilityDrift);
407
408        // A consistent devicerail lockfile constructs.
409        assert!(DeviceRailProvider::new(sample_lockfile(PROVIDER_NAME)).is_ok());
410    }
411
412    #[tokio::test]
413    async fn open_session_rejects_a_foreign_lockfile_digest_before_spawning() {
414        let provider = DeviceRailProvider::new(sample_lockfile(PROVIDER_NAME)).unwrap();
415        let opts = OpenSessionOptions {
416            // A command that must never run: the digest gate fires first.
417            endpoint: serde_json::json!({ "spawn": { "command": "/nonexistent/devicerail" } }),
418            device_id: "mock-1".to_owned(),
419            required_features: Vec::new(),
420            lockfile_digest: Hash::new(format!("sha256:{}", "e".repeat(64))).unwrap(),
421        };
422        let error = match provider.open_session(opts).await {
423            Err(error) => error,
424            Ok(_) => panic!("the digest gate must fire before spawning"),
425        };
426        assert_eq!(error.error_class, ErrorClass::CapabilityDrift);
427        assert!(error.message.contains("recompile"));
428    }
429
430    #[test]
431    fn drift_description_names_the_differences() {
432        let expected = sample_lockfile(PROVIDER_NAME);
433        let mut live = expected.clone();
434        live.hello.features_enabled = vec![
435            FeatureId::new("events.snapshot.v1").unwrap(),
436            FeatureId::new("media.stream.v1").unwrap(),
437        ];
438        live.hello.server.version = "1.0.0".to_owned();
439        live.seal();
440        let summary = describe_drift(&expected, &live);
441        assert!(summary.contains("features gained"), "{summary}");
442        assert!(summary.contains("media.stream.v1"), "{summary}");
443        assert!(summary.contains("server"), "{summary}");
444    }
445}