1use std::collections::{BTreeMap, BTreeSet};
11
12use pointlock_ir::{ActionName, FeatureId, Hash, domain_hash};
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15
16use crate::manifest::{ActionDefinitionStatic, PlatformKind};
17
18pub const LOCKFILE_DIGEST_DOMAIN_TAG: &str = "pointlock-lockfile/1";
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
29#[serde(rename_all = "camelCase", deny_unknown_fields)]
30pub struct LockfileProvider {
31 pub name: String,
33 pub version: String,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
39#[serde(rename_all = "camelCase", deny_unknown_fields)]
40pub struct ProtocolVersion {
41 pub major: u64,
43 pub minor: u64,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
49#[serde(rename_all = "camelCase", deny_unknown_fields)]
50pub struct PeerInfo {
51 pub name: String,
53 pub version: String,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
59#[serde(rename_all = "camelCase", deny_unknown_fields)]
60pub struct LockfileHello {
61 pub protocol_selected: ProtocolVersion,
63 pub features_enabled: Vec<FeatureId>,
65 pub server: PeerInfo,
67}
68
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
72#[serde(rename_all = "camelCase", deny_unknown_fields)]
73pub struct LockfileDevice {
74 pub platform: PlatformKind,
76 pub actions: Vec<ActionDefinitionStatic>,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
85#[serde(rename_all = "camelCase", deny_unknown_fields)]
86pub struct CapabilityLockfile {
87 pub provider: LockfileProvider,
89 pub attested_at: String,
91 pub hello: LockfileHello,
93 pub device: LockfileDevice,
95 pub digest: Hash,
98}
99
100impl CapabilityLockfile {
101 pub fn digest_consistent(&self) -> bool {
104 lockfile_digest(self) == self.digest
105 }
106
107 pub fn seal(&mut self) {
110 self.digest = lockfile_digest(self);
111 }
112}
113
114pub fn lockfile_digest(lockfile: &CapabilityLockfile) -> Hash {
122 let mut content = serde_json::to_value(lockfile).expect("a lockfile serializes to JSON");
123 let object = content
124 .as_object_mut()
125 .expect("a lockfile serializes to a JSON object");
126 object.remove("digest");
127 object.remove("attestedAt");
128 domain_hash(LOCKFILE_DIGEST_DOMAIN_TAG, &content)
129}
130
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
135#[serde(rename_all = "camelCase", deny_unknown_fields)]
136pub struct CapabilityAttestation {
137 pub provider_id: String,
139 pub protocol_selected: ProtocolVersion,
141 pub features_enabled: BTreeSet<FeatureId>,
143 pub actions: BTreeMap<ActionName, ActionDefinitionStatic>,
145 pub lockfile_digest: Hash,
147 pub attested_at: String,
149}
150
151impl CapabilityAttestation {
152 pub fn from_lockfile(lockfile: &CapabilityLockfile, attested_at: impl Into<String>) -> Self {
156 CapabilityAttestation {
157 provider_id: lockfile.provider.name.clone(),
158 protocol_selected: lockfile.hello.protocol_selected,
159 features_enabled: lockfile.hello.features_enabled.iter().cloned().collect(),
160 actions: lockfile
161 .device
162 .actions
163 .iter()
164 .map(|action| (action.name.clone(), action.clone()))
165 .collect(),
166 lockfile_digest: lockfile.digest.clone(),
167 attested_at: attested_at.into(),
168 }
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use crate::manifest::ActionProtection;
176 use pointlock_ir::JsonSchemaDocument;
177 use serde_json::json;
178
179 fn placeholder_hash() -> Hash {
180 Hash::new(format!("sha256:{}", "0".repeat(64))).unwrap()
181 }
182
183 fn sample_lockfile() -> CapabilityLockfile {
184 let mut lockfile = CapabilityLockfile {
185 provider: LockfileProvider {
186 name: "devicerail".to_owned(),
187 version: "0.1.0".to_owned(),
188 },
189 attested_at: "2026-01-01T00:00:00Z".to_owned(),
190 hello: LockfileHello {
191 protocol_selected: ProtocolVersion { major: 1, minor: 5 },
192 features_enabled: vec![
193 FeatureId::new("device.semanticActions.v1").unwrap(),
194 FeatureId::new("verdict.record.v1").unwrap(),
195 ],
196 server: PeerInfo {
197 name: "devicerail-daemon".to_owned(),
198 version: "1.5.0".to_owned(),
199 },
200 },
201 device: LockfileDevice {
202 platform: PlatformKind::Android,
203 actions: vec![ActionDefinitionStatic {
204 name: ActionName::new("tapElement").unwrap(),
205 input_schema: JsonSchemaDocument::new(json!({ "type": "object" })).unwrap(),
206 output_schema: None,
207 protection: ActionProtection::Standard,
208 synthetic: false,
209 }],
210 },
211 digest: placeholder_hash(),
212 };
213 lockfile.seal();
214 lockfile
215 }
216
217 #[test]
218 fn lockfile_digest_is_deterministic_and_excludes_digest_field() {
219 let lockfile = sample_lockfile();
220 assert!(lockfile.digest_consistent());
221 assert_eq!(lockfile_digest(&lockfile), lockfile.digest);
222
223 let mut tampered = lockfile.clone();
224 tampered.hello.features_enabled.pop();
225 assert!(!tampered.digest_consistent());
226
227 let mut redigested = lockfile.clone();
230 redigested.digest = placeholder_hash();
231 assert_eq!(lockfile_digest(&redigested), lockfile.digest);
232
233 let mut relocked = lockfile.clone();
236 relocked.attested_at = "2027-01-01T00:00:00Z".to_owned();
237 assert_eq!(lockfile_digest(&relocked), lockfile.digest);
238 }
239
240 #[test]
241 fn lockfile_wire_shape_round_trips() {
242 let lockfile = sample_lockfile();
243 let wire = serde_json::to_value(&lockfile).expect("serialize");
244 assert_eq!(wire["hello"]["protocolSelected"]["minor"], 5);
245 assert_eq!(
246 wire["hello"]["featuresEnabled"][0],
247 "device.semanticActions.v1"
248 );
249 assert_eq!(wire["device"]["platform"], "android");
250 assert_eq!(wire["attestedAt"], "2026-01-01T00:00:00Z");
251 let back: CapabilityLockfile = serde_json::from_value(wire).expect("deserialize");
252 assert_eq!(back, lockfile);
253 }
254
255 #[test]
256 fn attestation_projects_lockfile_and_round_trips() {
257 let lockfile = sample_lockfile();
258 let attestation = CapabilityAttestation::from_lockfile(&lockfile, "2026-01-02T00:00:00Z");
259 assert_eq!(attestation.provider_id, "devicerail");
260 assert_eq!(attestation.lockfile_digest, lockfile.digest);
261 assert!(
262 attestation
263 .actions
264 .contains_key(&ActionName::new("tapElement").unwrap())
265 );
266 let wire = serde_json::to_value(&attestation).expect("serialize");
267 assert_eq!(wire["providerId"], "devicerail");
268 assert_eq!(wire["actions"]["tapElement"]["protection"], "standard");
269 let back: CapabilityAttestation = serde_json::from_value(wire).expect("deserialize");
270 assert_eq!(back, attestation);
271 }
272}