use std::collections::{BTreeMap, BTreeSet};
use pointlock_ir::{ActionName, FeatureId, Hash, domain_hash};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::manifest::{ActionDefinitionStatic, PlatformKind};
pub const LOCKFILE_DIGEST_DOMAIN_TAG: &str = "pointlock-lockfile/1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct LockfileProvider {
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ProtocolVersion {
pub major: u64,
pub minor: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PeerInfo {
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct LockfileHello {
pub protocol_selected: ProtocolVersion,
pub features_enabled: Vec<FeatureId>,
pub server: PeerInfo,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct LockfileDevice {
pub platform: PlatformKind,
pub actions: Vec<ActionDefinitionStatic>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CapabilityLockfile {
pub provider: LockfileProvider,
pub attested_at: String,
pub hello: LockfileHello,
pub device: LockfileDevice,
pub digest: Hash,
}
impl CapabilityLockfile {
pub fn digest_consistent(&self) -> bool {
lockfile_digest(self) == self.digest
}
pub fn seal(&mut self) {
self.digest = lockfile_digest(self);
}
}
pub fn lockfile_digest(lockfile: &CapabilityLockfile) -> Hash {
let mut content = serde_json::to_value(lockfile).expect("a lockfile serializes to JSON");
let object = content
.as_object_mut()
.expect("a lockfile serializes to a JSON object");
object.remove("digest");
object.remove("attestedAt");
domain_hash(LOCKFILE_DIGEST_DOMAIN_TAG, &content)
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CapabilityAttestation {
pub provider_id: String,
pub protocol_selected: ProtocolVersion,
pub features_enabled: BTreeSet<FeatureId>,
pub actions: BTreeMap<ActionName, ActionDefinitionStatic>,
pub lockfile_digest: Hash,
pub attested_at: String,
}
impl CapabilityAttestation {
pub fn from_lockfile(lockfile: &CapabilityLockfile, attested_at: impl Into<String>) -> Self {
CapabilityAttestation {
provider_id: lockfile.provider.name.clone(),
protocol_selected: lockfile.hello.protocol_selected,
features_enabled: lockfile.hello.features_enabled.iter().cloned().collect(),
actions: lockfile
.device
.actions
.iter()
.map(|action| (action.name.clone(), action.clone()))
.collect(),
lockfile_digest: lockfile.digest.clone(),
attested_at: attested_at.into(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::ActionProtection;
use pointlock_ir::JsonSchemaDocument;
use serde_json::json;
fn placeholder_hash() -> Hash {
Hash::new(format!("sha256:{}", "0".repeat(64))).unwrap()
}
fn sample_lockfile() -> CapabilityLockfile {
let mut lockfile = CapabilityLockfile {
provider: LockfileProvider {
name: "devicerail".to_owned(),
version: "0.1.0".to_owned(),
},
attested_at: "2026-01-01T00:00:00Z".to_owned(),
hello: LockfileHello {
protocol_selected: ProtocolVersion { major: 1, minor: 5 },
features_enabled: vec![
FeatureId::new("device.semanticActions.v1").unwrap(),
FeatureId::new("verdict.record.v1").unwrap(),
],
server: PeerInfo {
name: "devicerail-daemon".to_owned(),
version: "1.5.0".to_owned(),
},
},
device: LockfileDevice {
platform: PlatformKind::Android,
actions: vec![ActionDefinitionStatic {
name: ActionName::new("tapElement").unwrap(),
input_schema: JsonSchemaDocument::new(json!({ "type": "object" })).unwrap(),
output_schema: None,
protection: ActionProtection::Standard,
synthetic: false,
}],
},
digest: placeholder_hash(),
};
lockfile.seal();
lockfile
}
#[test]
fn lockfile_digest_is_deterministic_and_excludes_digest_field() {
let lockfile = sample_lockfile();
assert!(lockfile.digest_consistent());
assert_eq!(lockfile_digest(&lockfile), lockfile.digest);
let mut tampered = lockfile.clone();
tampered.hello.features_enabled.pop();
assert!(!tampered.digest_consistent());
let mut redigested = lockfile.clone();
redigested.digest = placeholder_hash();
assert_eq!(lockfile_digest(&redigested), lockfile.digest);
let mut relocked = lockfile.clone();
relocked.attested_at = "2027-01-01T00:00:00Z".to_owned();
assert_eq!(lockfile_digest(&relocked), lockfile.digest);
}
#[test]
fn lockfile_wire_shape_round_trips() {
let lockfile = sample_lockfile();
let wire = serde_json::to_value(&lockfile).expect("serialize");
assert_eq!(wire["hello"]["protocolSelected"]["minor"], 5);
assert_eq!(
wire["hello"]["featuresEnabled"][0],
"device.semanticActions.v1"
);
assert_eq!(wire["device"]["platform"], "android");
assert_eq!(wire["attestedAt"], "2026-01-01T00:00:00Z");
let back: CapabilityLockfile = serde_json::from_value(wire).expect("deserialize");
assert_eq!(back, lockfile);
}
#[test]
fn attestation_projects_lockfile_and_round_trips() {
let lockfile = sample_lockfile();
let attestation = CapabilityAttestation::from_lockfile(&lockfile, "2026-01-02T00:00:00Z");
assert_eq!(attestation.provider_id, "devicerail");
assert_eq!(attestation.lockfile_digest, lockfile.digest);
assert!(
attestation
.actions
.contains_key(&ActionName::new("tapElement").unwrap())
);
let wire = serde_json::to_value(&attestation).expect("serialize");
assert_eq!(wire["providerId"], "devicerail");
assert_eq!(wire["actions"]["tapElement"]["protection"], "standard");
let back: CapabilityAttestation = serde_json::from_value(wire).expect("deserialize");
assert_eq!(back, attestation);
}
}