1use 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}