Skip to main content

warrant_core/
lock.rs

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    // The private key is authoritative; warn on public-key drift but continue with derived key.
128    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}