1use std::path::Path;
2
3use base64::Engine;
4use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
5use ed25519_dalek::Signer;
6use serde_json::Value as JsonValue;
7use toml::Table;
8
9use crate::canonical::canonical_json_bytes;
10use crate::error::{Error, Result};
11use crate::model::ParsedWarrant;
12use crate::parser::parse_toml_warrant;
13use crate::paths::{ToolId, ToolPaths};
14use crate::payload::unsigned_payload_from_root;
15use crate::store::{
16 ensure_signing_keys, read_signing_key, read_verifying_key, read_version_state,
17 write_text_atomic, write_version_state,
18};
19
20const MAX_VERSION: u64 = 1_000_000;
21
22#[derive(Debug, Clone, Default)]
23pub struct LockOptions {
24 pub create_keys_if_missing: bool,
25}
26
27#[derive(Debug, Clone)]
28pub struct LockResult {
29 pub version: u64,
30 pub installed_warrant_path: std::path::PathBuf,
31 pub version_state_path: std::path::PathBuf,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum ReviewDiffKind {
36 Added,
37 Removed,
38 Changed,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ReviewDiffEntry {
43 pub path: String,
44 pub kind: ReviewDiffKind,
45 pub before: Option<String>,
46 pub after: Option<String>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct LockReview {
51 pub current_version: Option<u64>,
52 pub incoming_version: u64,
53 pub version_ready: bool,
54 pub diff_entries: Vec<ReviewDiffEntry>,
55}
56
57pub fn review_lock_from_draft_path(draft_path: &Path, paths: &ToolPaths) -> Result<LockReview> {
58 let draft_text = std::fs::read_to_string(draft_path)?;
59 let draft_root = parse_draft_root(&draft_text)?;
60 let incoming_version = extract_incoming_version(&draft_root)?;
61 let incoming_payload = unsigned_payload_from_root(&draft_root)?;
62 let current_version = read_version_state(&paths.version_state_path)?;
63
64 let installed_payload = if paths.installed_warrant_path.exists() {
65 let installed = std::fs::read_to_string(&paths.installed_warrant_path)?;
66 let parsed = parse_toml_warrant(&installed)?;
67 parsed.verify_signature()?;
68 Some(parsed.unsigned_payload)
69 } else {
70 None
71 };
72
73 let diff_entries = diff_payloads(installed_payload.as_ref(), &incoming_payload);
74 let version_ready = match current_version {
75 Some(current) => incoming_version > current,
76 None => true,
77 };
78
79 Ok(LockReview {
80 current_version,
81 incoming_version,
82 version_ready,
83 diff_entries,
84 })
85}
86
87pub fn lock_warrant_from_draft_path(
88 draft_path: &Path,
89 paths: &ToolPaths,
90 options: &LockOptions,
91) -> Result<LockResult> {
92 if options.create_keys_if_missing {
93 ensure_signing_keys(
94 &paths.signing_private_key_path,
95 &paths.signing_public_key_path,
96 )?;
97 }
98
99 let draft_text = std::fs::read_to_string(draft_path)?;
100 let mut root = parse_draft_root(&draft_text)?;
101 let incoming_version = extract_incoming_version(&root)?;
102 if incoming_version > MAX_VERSION {
103 return Err(Error::VersionExceedsMaximum {
104 version: incoming_version,
105 max: MAX_VERSION,
106 });
107 }
108 let incoming_tool = extract_incoming_tool(&root)?;
109 ensure_expected_tool(&incoming_tool, &paths.tool_id)?;
110
111 if let Some(current) = read_version_state(&paths.version_state_path)?
112 && incoming_version <= current
113 {
114 return Err(Error::NonIncrementalVersion {
115 current,
116 incoming: incoming_version,
117 });
118 }
119
120 let unsigned_json = unsigned_payload_from_root(&root)?;
121 let canonical = canonical_json_bytes(&unsigned_json)?;
122
123 let signing_key = read_signing_key(&paths.signing_private_key_path)?;
124 let signature = signing_key.sign(&canonical);
125 let derived_verifying_key = signing_key.verifying_key();
126
127 match read_verifying_key(&paths.signing_public_key_path) {
129 Ok(stored) => {
130 if stored != derived_verifying_key {
131 eprintln!(
132 "warrant-core warning: signing public key does not match private key at {}; using private-key derived public key",
133 paths.signing_public_key_path.display()
134 );
135 }
136 }
137 Err(err) => eprintln!(
138 "warrant-core warning: could not validate signing public key at {} ({err}); using private-key derived public key",
139 paths.signing_public_key_path.display()
140 ),
141 }
142
143 let signature_table = Table::from_iter([
144 (
145 "algorithm".to_string(),
146 toml::Value::String("ed25519".to_string()),
147 ),
148 (
149 "public_key".to_string(),
150 toml::Value::String(BASE64_STANDARD.encode(derived_verifying_key.to_bytes())),
151 ),
152 (
153 "value".to_string(),
154 toml::Value::String(BASE64_STANDARD.encode(signature.to_bytes())),
155 ),
156 ]);
157 root.as_table_mut()
158 .ok_or(Error::InvalidRoot)?
159 .insert("signature".to_string(), toml::Value::Table(signature_table));
160
161 let serialized = toml::to_string_pretty(&root).map_err(|_| Error::InvalidFieldType {
162 section: "warrant",
163 field: "document",
164 expected: "serializable toml",
165 })?;
166
167 let parsed = parse_toml_warrant(&serialized)?;
168 ensure_expected_tool(&parsed.meta.tool, &paths.tool_id)?;
169 parsed.verify_signature()?;
170
171 write_text_atomic(&paths.installed_warrant_path, &serialized)?;
172 write_version_state(&paths.version_state_path, incoming_version)?;
173
174 Ok(LockResult {
175 version: incoming_version,
176 installed_warrant_path: paths.installed_warrant_path.clone(),
177 version_state_path: paths.version_state_path.clone(),
178 })
179}
180
181fn parse_draft_root(draft_text: &str) -> Result<toml::Value> {
182 let root: toml::Value = toml::from_str(draft_text)?;
183 let root_table = root.as_table().ok_or(Error::InvalidRoot)?;
184 let _ = root_table
185 .get("warrant")
186 .ok_or(Error::MissingSection("warrant"))?
187 .as_table()
188 .ok_or(Error::InvalidSectionType("warrant"))?;
189 if !root_table.contains_key("capabilities") {
190 return Err(Error::MissingSection("capabilities"));
191 }
192 Ok(root)
193}
194
195fn extract_incoming_version(root: &toml::Value) -> Result<u64> {
196 let root_table = root.as_table().ok_or(Error::InvalidRoot)?;
197 let warrant_table = root_table
198 .get("warrant")
199 .ok_or(Error::MissingSection("warrant"))?
200 .as_table()
201 .ok_or(Error::InvalidSectionType("warrant"))?;
202 let incoming_version_i64 = warrant_table
203 .get("version")
204 .ok_or(Error::MissingField {
205 section: "warrant",
206 field: "version",
207 })?
208 .as_integer()
209 .ok_or(Error::InvalidFieldType {
210 section: "warrant",
211 field: "version",
212 expected: "integer",
213 })?;
214 u64::try_from(incoming_version_i64).map_err(|_| Error::InvalidVersion)
215}
216
217fn extract_incoming_tool(root: &toml::Value) -> Result<String> {
218 let root_table = root.as_table().ok_or(Error::InvalidRoot)?;
219 let warrant_table = root_table
220 .get("warrant")
221 .ok_or(Error::MissingSection("warrant"))?
222 .as_table()
223 .ok_or(Error::InvalidSectionType("warrant"))?;
224 let tool = warrant_table
225 .get("tool")
226 .ok_or(Error::MissingField {
227 section: "warrant",
228 field: "tool",
229 })?
230 .as_str()
231 .ok_or(Error::InvalidFieldType {
232 section: "warrant",
233 field: "tool",
234 expected: "string",
235 })?;
236 Ok(tool.to_string())
237}
238
239fn ensure_expected_tool(actual: &str, expected: &ToolId) -> Result<()> {
240 if actual != expected.as_str() {
241 return Err(Error::ToolMismatch {
242 expected: expected.as_str().to_string(),
243 found: actual.to_string(),
244 });
245 }
246 Ok(())
247}
248
249fn diff_payloads(current: Option<&JsonValue>, incoming: &JsonValue) -> Vec<ReviewDiffEntry> {
250 let mut out = Vec::new();
251 match current {
252 Some(existing) => diff_values("", Some(existing), Some(incoming), &mut out),
253 None => diff_values("", None, Some(incoming), &mut out),
254 }
255 out
256}
257
258fn diff_values(
259 path: &str,
260 before: Option<&JsonValue>,
261 after: Option<&JsonValue>,
262 out: &mut Vec<ReviewDiffEntry>,
263) {
264 match (before, after) {
265 (Some(JsonValue::Object(before_obj)), Some(JsonValue::Object(after_obj))) => {
266 let mut keys: Vec<&String> = before_obj.keys().chain(after_obj.keys()).collect();
267 keys.sort_unstable();
268 keys.dedup();
269 for key in keys {
270 let next_path = if path.is_empty() {
271 key.to_string()
272 } else {
273 format!("{path}.{key}")
274 };
275 diff_values(&next_path, before_obj.get(key), after_obj.get(key), out);
276 }
277 }
278 (Some(b), Some(a)) if b == a => {}
279 (Some(b), Some(a)) => out.push(ReviewDiffEntry {
280 path: path.to_string(),
281 kind: ReviewDiffKind::Changed,
282 before: Some(render_json_value(b)),
283 after: Some(render_json_value(a)),
284 }),
285 (None, Some(a)) => collect_leaf_entries(path, a, ReviewDiffKind::Added, out),
286 (Some(b), None) => collect_leaf_entries(path, b, ReviewDiffKind::Removed, out),
287 (None, None) => {}
288 }
289}
290
291fn collect_leaf_entries(
292 path: &str,
293 value: &JsonValue,
294 kind: ReviewDiffKind,
295 out: &mut Vec<ReviewDiffEntry>,
296) {
297 if let JsonValue::Object(obj) = value {
298 for (key, child) in obj {
299 let next_path = if path.is_empty() {
300 key.to_string()
301 } else {
302 format!("{path}.{key}")
303 };
304 collect_leaf_entries(&next_path, child, kind.clone(), out);
305 }
306 return;
307 }
308
309 out.push(match kind {
310 ReviewDiffKind::Added => ReviewDiffEntry {
311 path: path.to_string(),
312 kind,
313 before: None,
314 after: Some(render_json_value(value)),
315 },
316 ReviewDiffKind::Removed => ReviewDiffEntry {
317 path: path.to_string(),
318 kind,
319 before: Some(render_json_value(value)),
320 after: None,
321 },
322 ReviewDiffKind::Changed => ReviewDiffEntry {
323 path: path.to_string(),
324 kind,
325 before: None,
326 after: Some(render_json_value(value)),
327 },
328 });
329}
330
331fn render_json_value(value: &JsonValue) -> String {
332 serde_json::to_string(value).unwrap_or_else(|_| "<unrenderable>".to_string())
333}
334
335pub fn load_installed_warrant(paths: &ToolPaths) -> Result<ParsedWarrant> {
336 let content = std::fs::read_to_string(&paths.installed_warrant_path)?;
337 let parsed = parse_toml_warrant(&content)?;
338 parsed.verify_signature()?;
339
340 if let Some(recorded) = read_version_state(&paths.version_state_path)?
341 && parsed.meta.version < recorded
342 {
343 return Err(Error::RollbackDetected {
344 warrant: parsed.meta.version,
345 recorded,
346 });
347 }
348 Ok(parsed)
349}
350
351pub fn load_installed_warrant_for_tool(
352 paths: &ToolPaths,
353 expected_tool: &str,
354) -> Result<ParsedWarrant> {
355 let parsed = load_installed_warrant(paths)?;
356 if parsed.meta.tool != expected_tool {
357 return Err(Error::ToolMismatch {
358 expected: expected_tool.to_string(),
359 found: parsed.meta.tool.clone(),
360 });
361 }
362 Ok(parsed)
363}
364
365#[cfg(test)]
366mod tests {
367 use std::path::PathBuf;
368
369 use tempfile::TempDir;
370
371 use crate::paths::ToolPaths;
372
373 use super::{
374 LockOptions, ReviewDiffKind, load_installed_warrant, load_installed_warrant_for_tool,
375 lock_warrant_from_draft_path, review_lock_from_draft_path,
376 };
377
378 fn temp_paths(base: &std::path::Path) -> ToolPaths {
379 ToolPaths {
380 tool_id: crate::paths::ToolId::parse("demo").expect("tool"),
381 installed_warrant_path: base.join("etc").join("warrant.toml"),
382 version_state_path: base.join("etc").join("signing").join("version"),
383 signing_private_key_path: base.join("etc").join("signing").join("private.key"),
384 signing_public_key_path: base.join("etc").join("signing").join("public.key"),
385 host_secret_path: base.join("etc").join("host.key"),
386 session_dir_path: base.join("run").join("demo"),
387 }
388 }
389
390 fn write_draft(path: PathBuf, version: u64, read: bool) {
391 let content = format!(
392 r#"
393[warrant]
394version = {version}
395tool = "demo"
396created = 2026-02-16T08:00:00Z
397issuer = "root@devbox"
398
399[capabilities]
400read = {read}
401"#
402 );
403 std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
404 std::fs::write(path, content).expect("write draft");
405 }
406
407 #[test]
408 fn lock_and_load_round_trip() {
409 let dir = TempDir::new().expect("tempdir");
410 let paths = temp_paths(dir.path());
411 let draft = dir.path().join("draft").join("warrant.toml");
412 write_draft(draft.clone(), 1, true);
413
414 let result = lock_warrant_from_draft_path(
415 &draft,
416 &paths,
417 &LockOptions {
418 create_keys_if_missing: true,
419 },
420 )
421 .expect("lock");
422 assert_eq!(result.version, 1);
423
424 let loaded = load_installed_warrant(&paths).expect("load");
425 assert_eq!(loaded.meta.version, 1);
426 assert_eq!(loaded.meta.tool, "demo");
427 let loaded_for_tool = load_installed_warrant_for_tool(&paths, "demo").expect("tool match");
428 assert_eq!(loaded_for_tool.meta.tool, "demo");
429 }
430
431 #[test]
432 fn load_rejects_wrong_tool_identifier() {
433 let dir = TempDir::new().expect("tempdir");
434 let paths = temp_paths(dir.path());
435 let draft = dir.path().join("draft").join("warrant.toml");
436 write_draft(draft.clone(), 1, true);
437 lock_warrant_from_draft_path(
438 &draft,
439 &paths,
440 &LockOptions {
441 create_keys_if_missing: true,
442 },
443 )
444 .expect("lock");
445
446 let err = load_installed_warrant_for_tool(&paths, "other-tool").expect_err("wrong tool");
447 assert!(
448 err.to_string().contains("warrant tool mismatch"),
449 "unexpected error: {err}"
450 );
451 }
452
453 #[test]
454 fn lock_rejects_non_incremental_versions() {
455 let dir = TempDir::new().expect("tempdir");
456 let paths = temp_paths(dir.path());
457 let draft = dir.path().join("draft").join("warrant.toml");
458 write_draft(draft.clone(), 1, true);
459 lock_warrant_from_draft_path(
460 &draft,
461 &paths,
462 &LockOptions {
463 create_keys_if_missing: true,
464 },
465 )
466 .expect("initial lock");
467
468 write_draft(draft.clone(), 1, false);
469 let err = lock_warrant_from_draft_path(
470 &draft,
471 &paths,
472 &LockOptions {
473 create_keys_if_missing: true,
474 },
475 )
476 .expect_err("non-incremental must fail");
477 assert!(
478 err.to_string()
479 .contains("incoming version must be strictly greater")
480 );
481 }
482
483 #[test]
484 fn lock_rejects_versions_above_maximum() {
485 let dir = TempDir::new().expect("tempdir");
486 let paths = temp_paths(dir.path());
487 let draft = dir.path().join("draft").join("warrant.toml");
488 write_draft(draft.clone(), 1_000_001, true);
489
490 let err = lock_warrant_from_draft_path(
491 &draft,
492 &paths,
493 &LockOptions {
494 create_keys_if_missing: true,
495 },
496 )
497 .expect_err("over-maximum version must fail");
498 assert_eq!(
499 err.to_string(),
500 "Version 1000001 exceeds maximum allowed (1000000). Use emergency reset if needed."
501 );
502 }
503
504 #[test]
505 fn load_rejects_tampered_policy() {
506 let dir = TempDir::new().expect("tempdir");
507 let paths = temp_paths(dir.path());
508 let draft = dir.path().join("draft").join("warrant.toml");
509 write_draft(draft.clone(), 1, true);
510 lock_warrant_from_draft_path(
511 &draft,
512 &paths,
513 &LockOptions {
514 create_keys_if_missing: true,
515 },
516 )
517 .expect("initial lock");
518
519 let tampered = std::fs::read_to_string(&paths.installed_warrant_path)
520 .expect("read installed")
521 .replace("read = true", "read = false");
522 std::fs::write(&paths.installed_warrant_path, tampered).expect("tamper");
523 let err = load_installed_warrant(&paths).expect_err("tampered must fail");
524 assert!(err.to_string().contains("signature verification failed"));
525 }
526
527 #[test]
528 fn load_rejects_rollback_against_version_state() {
529 let dir = TempDir::new().expect("tempdir");
530 let paths = temp_paths(dir.path());
531 let draft = dir.path().join("draft").join("warrant.toml");
532 write_draft(draft.clone(), 2, true);
533 lock_warrant_from_draft_path(
534 &draft,
535 &paths,
536 &LockOptions {
537 create_keys_if_missing: true,
538 },
539 )
540 .expect("initial lock");
541
542 std::fs::write(&paths.version_state_path, "3\n").expect("bump recorded version");
543 let err = load_installed_warrant(&paths).expect_err("rollback must fail");
544 assert!(err.to_string().contains("rollback detected"));
545 }
546
547 #[test]
548 fn review_for_first_lock_marks_all_fields_added() {
549 let dir = TempDir::new().expect("tempdir");
550 let paths = temp_paths(dir.path());
551 let draft = dir.path().join("draft").join("warrant.toml");
552 write_draft(draft.clone(), 2, true);
553
554 let review = review_lock_from_draft_path(&draft, &paths).expect("review");
555 assert_eq!(review.current_version, None);
556 assert_eq!(review.incoming_version, 2);
557 assert!(review.version_ready);
558 assert!(
559 review
560 .diff_entries
561 .iter()
562 .any(|entry| entry.path == "capabilities.read"
563 && entry.kind == ReviewDiffKind::Added)
564 );
565 assert!(
566 review
567 .diff_entries
568 .iter()
569 .any(|entry| entry.path == "warrant.version"
570 && entry.kind == ReviewDiffKind::Added)
571 );
572 }
573
574 #[test]
575 fn review_reports_changes_against_installed_warrant() {
576 let dir = TempDir::new().expect("tempdir");
577 let paths = temp_paths(dir.path());
578 let draft = dir.path().join("draft").join("warrant.toml");
579 write_draft(draft.clone(), 1, true);
580 lock_warrant_from_draft_path(
581 &draft,
582 &paths,
583 &LockOptions {
584 create_keys_if_missing: true,
585 },
586 )
587 .expect("initial lock");
588
589 write_draft(draft.clone(), 2, false);
590 let review = review_lock_from_draft_path(&draft, &paths).expect("review");
591 assert_eq!(review.current_version, Some(1));
592 assert_eq!(review.incoming_version, 2);
593 assert!(review.version_ready);
594
595 let change = review
596 .diff_entries
597 .iter()
598 .find(|entry| entry.path == "capabilities.read")
599 .expect("capability change");
600 assert_eq!(change.kind, ReviewDiffKind::Changed);
601 assert_eq!(change.before.as_deref(), Some("true"));
602 assert_eq!(change.after.as_deref(), Some("false"));
603 }
604
605 #[test]
606 fn lock_fails_when_draft_missing_tool() {
607 let dir = TempDir::new().expect("tempdir");
608 let paths = temp_paths(dir.path());
609 let draft = dir.path().join("draft").join("warrant.toml");
610 let content = r#"
611[warrant]
612version = 1
613created = 2026-02-16T08:00:00Z
614issuer = "root@devbox"
615
616[capabilities]
617read = true
618"#;
619 std::fs::create_dir_all(draft.parent().expect("parent")).expect("mkdir");
620 std::fs::write(&draft, content).expect("write draft");
621
622 let err = lock_warrant_from_draft_path(
623 &draft,
624 &paths,
625 &LockOptions {
626 create_keys_if_missing: true,
627 },
628 )
629 .expect_err("missing tool must fail");
630 assert!(err.to_string().contains("[warrant].tool"));
631 assert!(!paths.installed_warrant_path.exists());
632 assert!(!paths.version_state_path.exists());
633 }
634
635 #[test]
636 fn lock_fails_when_draft_tool_does_not_match_expected_tool() {
637 let dir = TempDir::new().expect("tempdir");
638 let paths = temp_paths(dir.path());
639 let draft = dir.path().join("draft").join("warrant.toml");
640 let content = r#"
641[warrant]
642version = 1
643tool = "other"
644created = 2026-02-16T08:00:00Z
645issuer = "root@devbox"
646
647[capabilities]
648read = true
649"#;
650 std::fs::create_dir_all(draft.parent().expect("parent")).expect("mkdir");
651 std::fs::write(&draft, content).expect("write draft");
652
653 let err = lock_warrant_from_draft_path(
654 &draft,
655 &paths,
656 &LockOptions {
657 create_keys_if_missing: true,
658 },
659 )
660 .expect_err("wrong tool must fail");
661 assert!(err.to_string().contains("warrant tool mismatch"));
662 assert!(!paths.installed_warrant_path.exists());
663 assert!(!paths.version_state_path.exists());
664 }
665
666 #[test]
667 fn lock_fails_when_draft_missing_capabilities() {
668 let dir = TempDir::new().expect("tempdir");
669 let paths = temp_paths(dir.path());
670 let draft = dir.path().join("draft").join("warrant.toml");
671 let content = r#"
672[warrant]
673version = 1
674tool = "demo"
675created = 2026-02-16T08:00:00Z
676issuer = "root@devbox"
677"#;
678 std::fs::create_dir_all(draft.parent().expect("parent")).expect("mkdir");
679 std::fs::write(&draft, content).expect("write draft");
680
681 let err = lock_warrant_from_draft_path(
682 &draft,
683 &paths,
684 &LockOptions {
685 create_keys_if_missing: true,
686 },
687 )
688 .expect_err("missing capabilities must fail");
689 assert!(
690 err.to_string()
691 .contains("missing required section [capabilities]")
692 );
693 assert!(!paths.installed_warrant_path.exists());
694 assert!(!paths.version_state_path.exists());
695 }
696}