Skip to main content

fur_cli/security/
unlock.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::{crypto, io, state};
9
10#[derive(Parser, Clone)]
11pub struct UnlockArgs {
12    #[arg(long)]
13    pub hide: bool,
14}
15
16pub fn run_unlock(args: UnlockArgs) {
17    if !state::is_locked() {
18        println!("🔓 Project already unlocked.");
19        return;
20    }
21
22    let _ = args.hide;
23
24    let pass = io::read_password("🔑 Enter password: ");
25
26    if !verify_password(&pass) {
27        println!("❌ Incorrect password. Project remains locked.");
28        return;
29    }
30
31    decrypt_project(&pass);
32
33    remove_sentinel();
34    state::remove_lock();
35
36    println!("🔓 Project decrypted.");
37}
38
39/// Verify in order of durability: the checker beside the data, then the one in
40/// `.fur/`, then — if both are gone — a trial decryption of a real document.
41///
42/// viceroy: the trial path is the recovery case. An archive locked before this
43/// fix has both checkers only inside `.fur/`, so deleting the index left the
44/// password unverifiable and the data unreachable.
45fn verify_password(password: &str) -> bool {
46    for check_path in [
47        Path::new("chats").join(LOCK_CHECK),
48        Path::new(".fur/.lockcheck").to_path_buf(),
49    ] {
50        if let Ok(bytes) = fs::read(&check_path) {
51            if let Ok(decrypted) = crypto::decrypt(&bytes, password) {
52                if std::str::from_utf8(&decrypted)
53                    .map(|t| t.trim() == "FUR_LOCK_CHECK_V1")
54                    .unwrap_or(false)
55                {
56                    return true;
57                }
58            }
59            return false;
60        }
61    }
62
63    trial_decrypt(password)
64}
65
66/// Last resort: if any encrypted document decrypts cleanly, the password is
67/// right. Nothing is written here — this only answers yes or no.
68fn trial_decrypt(password: &str) -> bool {
69    for entry in WalkDir::new("chats")
70        .follow_links(false)
71        .into_iter()
72        .filter_map(|e| e.ok())
73    {
74        let p = entry.path();
75
76        if !p.is_file() || is_sentinel(p) {
77            continue;
78        }
79
80        if let Ok(bytes) = fs::read(p) {
81            if crypto::decrypt(&bytes, password).is_ok() {
82                return true;
83            }
84        }
85    }
86
87    eprintln!("⚠ No verifiable encrypted content found under chats/.");
88    false
89}
90
91/// Removed only after decryption succeeds, so an interrupted unlock still
92/// leaves the archive correctly marked as locked.
93fn remove_sentinel() {
94    for name in [LOCK_SENTINEL, LOCK_CHECK] {
95        let path = Path::new("chats").join(name);
96
97        if path.exists() {
98            let _ = fs::remove_file(path);
99        }
100    }
101}
102
103fn decrypt_project(password: &str) {
104    decrypt_dir(".fur/messages", password);
105    decrypt_dir(".fur/threads", password);
106
107    decrypt_file(".fur/index.json", password);
108    decrypt_file(".fur/.lockcheck", password);
109
110    decrypt_markdowns(password);
111}
112
113fn decrypt_dir(dir: &str, password: &str) {
114    let path = Path::new(dir);
115
116    if let Ok(entries) = fs::read_dir(path) {
117        for e in entries.flatten() {
118            let p = e.path();
119
120            if p.is_file() {
121                io::decrypt_file(&p, password);
122            }
123        }
124    }
125}
126
127fn decrypt_file(path: &str, password: &str) {
128    let p = Path::new(path);
129
130    if p.exists() {
131        io::decrypt_file(p, password);
132    }
133}
134
135/// viceroy: mirrors the recursion fix in `lock.rs` — a flat walk would leave
136/// `chats/<slug>/` documents encrypted forever.
137fn decrypt_markdowns(password: &str) {
138    let chats = Path::new("chats");
139
140    if !chats.exists() {
141        return;
142    }
143
144    for entry in WalkDir::new(chats)
145        .follow_links(false)
146        .into_iter()
147        .filter_map(|e| e.ok())
148    {
149        let p = entry.path();
150
151        if !p.is_file() || is_sentinel(p) {
152            continue;
153        }
154
155        io::decrypt_file(p, password);
156    }
157}
158
159fn is_sentinel(path: &Path) -> bool {
160    matches!(
161        path.file_name().and_then(|f| f.to_str()),
162        Some(LOCK_SENTINEL) | Some(LOCK_CHECK)
163    )
164}