Skip to main content

fur_cli/security/
lock.rs

1use std::fs;
2use std::path::Path;
3
4use clap::Parser;
5use walkdir::WalkDir;
6
7use crate::schema::rebuild::{LOCK_CHECK, LOCK_SENTINEL};
8use crate::security::{io, state};
9
10#[derive(Parser, Clone)]
11pub struct LockArgs {
12    #[arg(long)]
13    pub hide: bool,
14}
15
16pub fn run_lock(args: LockArgs) {
17    if state::is_locked() {
18        println!("🔒 Project already locked.");
19        return;
20    }
21
22    // viceroy: `--hide` is now a no-op kept for compatibility; input is
23    // always hidden, and lock always confirms.
24    let _ = args.hide;
25
26    let Some(pass) = io::read_password_confirmed() else {
27        return;
28    };
29
30    create_lockcheck();
31    create_sentinel(&pass);
32
33    encrypt_project(&pass);
34
35    state::write_lock();
36
37    println!("🔐 Project encrypted.");
38}
39
40fn create_lockcheck() {
41    let path = Path::new(".fur/.lockcheck");
42    fs::write(path, "FUR_LOCK_CHECK_V1").unwrap();
43}
44
45/// Lock artifacts that live with the data they describe.
46///
47/// `.fur-locked` is plaintext, so a rebuild that finds only `chats/` knows the
48/// documents are ciphertext and refuses to parse them. `.fur-lockcheck` is the
49/// encrypted verifier, duplicated out of `.fur/` so a deleted index cannot
50/// strand an encrypted archive.
51fn create_sentinel(password: &str) {
52    let chats = Path::new("chats");
53
54    if !chats.exists() {
55        return;
56    }
57
58    let _ = fs::write(chats.join(LOCK_SENTINEL), "FUR_LOCKED_V1\n");
59
60    let check = chats.join(LOCK_CHECK);
61    let _ = fs::write(&check, "FUR_LOCK_CHECK_V1");
62    io::encrypt_file(&check, password);
63}
64
65fn encrypt_project(password: &str) {
66    encrypt_dir(".fur/messages", password);
67    encrypt_dir(".fur/threads", password);
68
69    encrypt_file(".fur/index.json", password);
70    encrypt_file(".fur/.lockcheck", password);
71
72    encrypt_markdowns(password);
73}
74
75fn encrypt_dir(dir: &str, password: &str) {
76    let path = Path::new(dir);
77
78    if let Ok(entries) = fs::read_dir(path) {
79        for e in entries.flatten() {
80            let p = e.path();
81
82            if p.is_file() {
83                io::encrypt_file(&p, password);
84            }
85        }
86    }
87}
88
89fn encrypt_file(path: &str, password: &str) {
90    let p = Path::new(path);
91
92    if p.exists() {
93        io::encrypt_file(p, password);
94    }
95}
96
97/// viceroy: was a flat `read_dir` over `chats/`. Conversation documents now
98/// live in `chats/<slug>/`, so a flat walk encrypted nothing and silently left
99/// the whole archive in plaintext. Recurses now, skipping the sentinel.
100fn encrypt_markdowns(password: &str) {
101    let chats = Path::new("chats");
102
103    if !chats.exists() {
104        return;
105    }
106
107    for entry in WalkDir::new(chats)
108        .follow_links(false)
109        .into_iter()
110        .filter_map(|e| e.ok())
111    {
112        let p = entry.path();
113
114        if !p.is_file() || is_sentinel(p) {
115            continue;
116        }
117
118        io::encrypt_file(p, password);
119    }
120}
121
122fn is_sentinel(path: &Path) -> bool {
123    matches!(
124        path.file_name().and_then(|f| f.to_str()),
125        Some(LOCK_SENTINEL) | Some(LOCK_CHECK)
126    )
127}