Skip to main content

execsurface_baseline/
lib.rs

1//! Deterministic, content-addressed baseline lockfiles.
2//!
3//! The baseline records an accepted canonical execution surface. It contains
4//! no policy and no candidate-vs-baseline diff semantics.
5
6use std::fmt;
7use std::fs::{self, File, OpenOptions};
8use std::io::{self, Write};
9use std::path::{Path, PathBuf};
10
11use execsurface_model::canonical::{
12    CanonicalExecutable, CanonicalSurface, CANONICAL_SURFACE_SCHEMA_VERSION,
13};
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16
17pub const LOCK_SCHEMA_VERSION: u32 = 2;
18pub const DIGEST_FORMAT_VERSION: u32 = 2;
19pub const DEFAULT_LOCKFILE_NAME: &str = "execsurface.lock.json";
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct BaselineLock {
23    pub schema_version: u32,
24    pub baseline_digest: String,
25    pub payload: BaselinePayload,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct BaselinePayload {
30    pub digest_format_version: u32,
31    pub tool: ToolIdentity,
32    pub command: CommandIdentity,
33    pub platform: PlatformIdentity,
34    pub observer: ObserverIdentity,
35    pub canonical_surface: CanonicalSurface,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct ToolIdentity {
40    pub name: String,
41    pub version: String,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct CommandIdentity {
46    pub executable: CanonicalExecutable,
47    pub argument_count: u32,
48    pub label: Option<String>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct PlatformIdentity {
53    pub os: String,
54    pub architecture: String,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct ObserverIdentity {
59    pub name: String,
60    pub capabilities: Vec<String>,
61    pub limitations: Vec<String>,
62}
63
64#[derive(Serialize)]
65struct DigestEnvelope<'a> {
66    schema_version: u32,
67    payload: &'a BaselinePayload,
68}
69
70#[derive(Debug)]
71pub enum BaselineError {
72    UnsupportedLockSchema(u32),
73    UnsupportedDigestFormat(u32),
74    UnsupportedCanonicalSchema(u32),
75    DigestMismatch { expected: String, actual: String },
76    Serialization(serde_json::Error),
77    Io(io::Error),
78    TargetExists(PathBuf),
79    TemporaryPathExhausted(PathBuf),
80}
81
82impl fmt::Display for BaselineError {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        match self {
85            Self::UnsupportedLockSchema(version) => {
86                write!(f, "unsupported lock schema version: {version}")
87            }
88            Self::UnsupportedDigestFormat(version) => {
89                write!(f, "unsupported digest format version: {version}")
90            }
91            Self::UnsupportedCanonicalSchema(version) => {
92                write!(f, "unsupported canonical surface schema version: {version}")
93            }
94            Self::DigestMismatch { expected, actual } => {
95                write!(
96                    f,
97                    "baseline digest mismatch: expected {expected}, calculated {actual}"
98                )
99            }
100            Self::Serialization(error) => write!(f, "baseline serialization error: {error}"),
101            Self::Io(error) => write!(f, "baseline I/O error: {error}"),
102            Self::TargetExists(path) => {
103                write!(f, "lockfile already exists: {}", path.display())
104            }
105            Self::TemporaryPathExhausted(path) => write!(
106                f,
107                "could not reserve a temporary lockfile beside {}",
108                path.display()
109            ),
110        }
111    }
112}
113
114impl std::error::Error for BaselineError {}
115
116impl From<serde_json::Error> for BaselineError {
117    fn from(value: serde_json::Error) -> Self {
118        Self::Serialization(value)
119    }
120}
121
122impl From<io::Error> for BaselineError {
123    fn from(value: io::Error) -> Self {
124        Self::Io(value)
125    }
126}
127
128impl BaselinePayload {
129    pub fn new(
130        tool: ToolIdentity,
131        command: CommandIdentity,
132        platform: PlatformIdentity,
133        observer: ObserverIdentity,
134        canonical_surface: CanonicalSurface,
135    ) -> Self {
136        Self {
137            digest_format_version: DIGEST_FORMAT_VERSION,
138            tool,
139            command,
140            platform,
141            observer,
142            canonical_surface,
143        }
144    }
145}
146
147pub fn build_lock(mut payload: BaselinePayload) -> Result<BaselineLock, BaselineError> {
148    validate_payload_versions(&payload)?;
149    normalize_payload_order(&mut payload);
150    let baseline_digest = digest_payload(&payload)?;
151    Ok(BaselineLock {
152        schema_version: LOCK_SCHEMA_VERSION,
153        baseline_digest,
154        payload,
155    })
156}
157
158pub fn verify_lock(lock: &BaselineLock) -> Result<(), BaselineError> {
159    if lock.schema_version != LOCK_SCHEMA_VERSION {
160        return Err(BaselineError::UnsupportedLockSchema(lock.schema_version));
161    }
162    validate_payload_versions(&lock.payload)?;
163
164    let actual = digest_payload(&lock.payload)?;
165    if actual != lock.baseline_digest {
166        return Err(BaselineError::DigestMismatch {
167            expected: lock.baseline_digest.clone(),
168            actual,
169        });
170    }
171    Ok(())
172}
173
174pub fn parse_and_verify(bytes: &[u8]) -> Result<BaselineLock, BaselineError> {
175    let lock: BaselineLock = serde_json::from_slice(bytes)?;
176    verify_lock(&lock)?;
177    Ok(lock)
178}
179
180pub fn serialize_lock(lock: &BaselineLock) -> Result<Vec<u8>, BaselineError> {
181    verify_lock(lock)?;
182    let mut bytes = serde_json::to_vec_pretty(lock)?;
183    bytes.push(b'\n');
184    Ok(bytes)
185}
186
187pub fn digest_input_bytes(payload: &BaselinePayload) -> Result<Vec<u8>, BaselineError> {
188    validate_payload_versions(payload)?;
189    Ok(serde_json::to_vec(&DigestEnvelope {
190        schema_version: LOCK_SCHEMA_VERSION,
191        payload,
192    })?)
193}
194
195pub fn digest_payload(payload: &BaselinePayload) -> Result<String, BaselineError> {
196    let bytes = digest_input_bytes(payload)?;
197    let digest = Sha256::digest(bytes);
198    let mut hex = String::with_capacity(64);
199    for byte in digest {
200        use std::fmt::Write as _;
201        write!(&mut hex, "{byte:02x}").expect("writing to String cannot fail");
202    }
203    Ok(format!("sha256:{hex}"))
204}
205
206pub fn write_lockfile(
207    path: &Path,
208    lock: &BaselineLock,
209    overwrite: bool,
210) -> Result<(), BaselineError> {
211    let bytes = serialize_lock(lock)?;
212    atomic_write(path, &bytes, overwrite)
213}
214
215fn validate_payload_versions(payload: &BaselinePayload) -> Result<(), BaselineError> {
216    if payload.digest_format_version != DIGEST_FORMAT_VERSION {
217        return Err(BaselineError::UnsupportedDigestFormat(
218            payload.digest_format_version,
219        ));
220    }
221    if payload.canonical_surface.schema_version != CANONICAL_SURFACE_SCHEMA_VERSION {
222        return Err(BaselineError::UnsupportedCanonicalSchema(
223            payload.canonical_surface.schema_version,
224        ));
225    }
226    Ok(())
227}
228
229fn normalize_payload_order(payload: &mut BaselinePayload) {
230    payload.observer.capabilities.sort();
231    payload.observer.capabilities.dedup();
232    payload.observer.limitations.sort();
233    payload.observer.limitations.dedup();
234
235    payload
236        .canonical_surface
237        .normalization
238        .semantic_roots
239        .sort();
240    payload
241        .canonical_surface
242        .normalization
243        .semantic_roots
244        .dedup();
245
246    payload.canonical_surface.effects.sort();
247    payload.canonical_surface.effects.dedup();
248}
249
250fn atomic_write(path: &Path, bytes: &[u8], overwrite: bool) -> Result<(), BaselineError> {
251    let parent = path
252        .parent()
253        .filter(|parent| !parent.as_os_str().is_empty())
254        .unwrap_or(Path::new("."));
255    let filename = path
256        .file_name()
257        .ok_or_else(|| {
258            io::Error::new(io::ErrorKind::InvalidInput, "lockfile path has no filename")
259        })?
260        .to_string_lossy();
261
262    let mut temp = None;
263    for attempt in 0..128_u32 {
264        let candidate = parent.join(format!(
265            ".{filename}.tmp.{}.{}",
266            std::process::id(),
267            attempt
268        ));
269        match OpenOptions::new()
270            .write(true)
271            .create_new(true)
272            .open(&candidate)
273        {
274            Ok(file) => {
275                temp = Some((candidate, file));
276                break;
277            }
278            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
279            Err(error) => return Err(BaselineError::Io(error)),
280        }
281    }
282
283    let (temp_path, mut file) =
284        temp.ok_or_else(|| BaselineError::TemporaryPathExhausted(path.to_path_buf()))?;
285
286    let result = (|| -> Result<(), BaselineError> {
287        file.write_all(bytes)?;
288        file.sync_all()?;
289        drop(file);
290
291        if overwrite {
292            fs::rename(&temp_path, path)?;
293        } else {
294            match fs::hard_link(&temp_path, path) {
295                Ok(()) => {
296                    fs::remove_file(&temp_path)?;
297                }
298                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
299                    return Err(BaselineError::TargetExists(path.to_path_buf()));
300                }
301                Err(error) => return Err(BaselineError::Io(error)),
302            }
303        }
304
305        File::open(parent)?.sync_all()?;
306        Ok(())
307    })();
308
309    if result.is_err() {
310        let _ = fs::remove_file(&temp_path);
311    }
312    result
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use execsurface_model::canonical::{
319        CanonicalEffect, CanonicalNetworkEndpoint, CanonicalPath, NormalizationMetadata, PathClass,
320        PathResolution,
321    };
322
323    fn fixture_payload() -> BaselinePayload {
324        BaselinePayload::new(
325            ToolIdentity {
326                name: "execsurface".to_owned(),
327                version: "0.0.1".to_owned(),
328            },
329            CommandIdentity {
330                executable: CanonicalExecutable {
331                    path: CanonicalPath {
332                        value: "/usr/bin/python3".to_owned(),
333                        class: PathClass::System,
334                        resolution: PathResolution::Lexical,
335                    },
336                    family: "python3".to_owned(),
337                },
338                argument_count: 2,
339                label: None,
340            },
341            PlatformIdentity {
342                os: "linux".to_owned(),
343                architecture: "x86_64".to_owned(),
344            },
345            ObserverIdentity {
346                name: "linux-ptrace-metadata-only".to_owned(),
347                capabilities: vec![
348                    "connect_destination".to_owned(),
349                    "descendant_tracking".to_owned(),
350                ],
351                limitations: vec!["example limitation".to_owned()],
352            },
353            CanonicalSurface {
354                schema_version: 2,
355                normalization: NormalizationMetadata {
356                    profile_version: 2,
357                    semantic_roots: vec!["home".to_owned(), "workspace".to_owned()],
358                },
359                effects: vec![],
360            },
361        )
362    }
363
364    #[test]
365    fn sha256_matches_standard_abc_vector() {
366        let digest = Sha256::digest(b"abc");
367        assert_eq!(
368            format!("{digest:x}"),
369            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
370        );
371    }
372
373    #[test]
374    fn digest_serialization_vector_is_frozen() {
375        let payload = fixture_payload();
376        let bytes = digest_input_bytes(&payload).expect("digest bytes");
377        let expected = concat!(
378            "{\"schema_version\":2,\"payload\":{",
379            "\"digest_format_version\":2,",
380            "\"tool\":{\"name\":\"execsurface\",\"version\":\"0.0.1\"},",
381            "\"command\":{\"executable\":{\"path\":{\"value\":\"/usr/bin/python3\",",
382            "\"class\":\"system\",\"resolution\":\"lexical\"},\"family\":\"python3\"},",
383            "\"argument_count\":2,\"label\":null},",
384            "\"platform\":{\"os\":\"linux\",\"architecture\":\"x86_64\"},",
385            "\"observer\":{\"name\":\"linux-ptrace-metadata-only\",",
386            "\"capabilities\":[\"connect_destination\",\"descendant_tracking\"],",
387            "\"limitations\":[\"example limitation\"]},",
388            "\"canonical_surface\":{\"schema_version\":2,",
389            "\"normalization\":{\"profile_version\":2,",
390            "\"semantic_roots\":[\"home\",\"workspace\"]},\"effects\":[]}}}"
391        );
392        assert_eq!(bytes, expected.as_bytes());
393        assert_eq!(
394            digest_payload(&payload).expect("digest"),
395            "sha256:34c6428da9ce58aebd17fe47470c154d1f2eb99aff2d37ba0282a678f7dcb067"
396        );
397    }
398
399    #[test]
400    fn controlled_effect_change_changes_digest() {
401        let first = build_lock(fixture_payload()).expect("first");
402
403        let mut second_payload = fixture_payload();
404        second_payload
405            .canonical_surface
406            .effects
407            .push(CanonicalEffect::NetworkConnectAttempt {
408                actor: None,
409                execution_chain: vec![],
410                endpoint: CanonicalNetworkEndpoint::Inet {
411                    ip: "192.0.2.10".to_owned(),
412                    port: 443,
413                },
414            });
415        let second = build_lock(second_payload).expect("second");
416
417        assert_ne!(first.baseline_digest, second.baseline_digest);
418    }
419
420    #[test]
421    fn unordered_observer_metadata_is_normalized_before_hashing() {
422        let first = build_lock(fixture_payload()).expect("first");
423
424        let mut second_payload = fixture_payload();
425        second_payload.observer.capabilities.reverse();
426        second_payload
427            .observer
428            .capabilities
429            .push("connect_destination".to_owned());
430        let second = build_lock(second_payload).expect("second");
431
432        assert_eq!(first.baseline_digest, second.baseline_digest);
433        assert_eq!(
434            serialize_lock(&first).expect("first bytes"),
435            serialize_lock(&second).expect("second bytes")
436        );
437    }
438
439    #[test]
440    fn corrupted_digest_is_rejected() {
441        let mut lock = build_lock(fixture_payload()).expect("lock");
442        lock.baseline_digest = "sha256:deadbeef".to_owned();
443        assert!(matches!(
444            verify_lock(&lock),
445            Err(BaselineError::DigestMismatch { .. })
446        ));
447    }
448
449    #[test]
450    fn atomic_writer_refuses_silent_overwrite() {
451        let dir =
452            std::env::temp_dir().join(format!("execsurface-baseline-unit-{}", std::process::id()));
453        fs::create_dir_all(&dir).expect("create dir");
454        let path = dir.join("lock.json");
455        let _ = fs::remove_file(&path);
456
457        let lock = build_lock(fixture_payload()).expect("lock");
458        write_lockfile(&path, &lock, false).expect("first write");
459        assert!(matches!(
460            write_lockfile(&path, &lock, false),
461            Err(BaselineError::TargetExists(_))
462        ));
463        write_lockfile(&path, &lock, true).expect("explicit overwrite");
464
465        let bytes = fs::read(&path).expect("read");
466        parse_and_verify(&bytes).expect("verify");
467        let _ = fs::remove_file(&path);
468        let _ = fs::remove_dir(&dir);
469    }
470    #[test]
471    fn legacy_lock_schema_is_rejected_instead_of_reinterpreted() {
472        let mut lock = build_lock(fixture_payload()).expect("lock");
473        lock.schema_version = 1;
474        assert!(matches!(
475            verify_lock(&lock),
476            Err(BaselineError::UnsupportedLockSchema(1))
477        ));
478    }
479}