Skip to main content

weavatrix_rust_refactor/
token.rs

1//! Gate three: a confirmation bound to one plan, one repository, and one use.
2//!
3//! The token is not a password — it is proof that the exact plan being applied is the one a
4//! preview already checked against the working tree. So it carries a fingerprint of the plan
5//! rather than a random value alone, and presenting it for a different plan fails as loudly as
6//! presenting no token at all.
7
8use blazingly_json::{Value, json};
9use std::collections::HashMap;
10use std::path::Path;
11use std::sync::Mutex;
12use std::time::{Duration, SystemTime, UNIX_EPOCH};
13use weavatrix_refactor_plan::EditPlan;
14
15/// How long a confirmation stays valid. Long enough to read a preview, short enough that a stale
16/// tree cannot hide behind it.
17const TOKEN_TTL: Duration = Duration::from_secs(5 * 60);
18
19/// A confirmation handed to the caller after a successful preview.
20pub struct ConfirmToken {
21    pub value: String,
22    pub expires_at: u64,
23}
24
25struct Issued {
26    fingerprint: String,
27    repository: String,
28    expires_at: u64,
29}
30
31/// The issued confirmations of one server process.
32#[derive(Default)]
33pub struct TokenStore(Mutex<HashMap<String, Issued>>);
34
35fn now() -> u64 {
36    SystemTime::now()
37        .duration_since(UNIX_EPOCH)
38        .map_or(0, |elapsed| elapsed.as_secs())
39}
40
41/// A stable fingerprint of everything in the plan that decides what gets written.
42///
43/// Ordering, paths, hashes, ranges and both texts all take part: change any of them and the
44/// token stops matching, which is exactly the property that makes it a proof rather than a
45/// formality.
46fn fingerprint(plan: &EditPlan) -> String {
47    let mut material = String::from(&plan.operation);
48    for file in &plan.files {
49        material.push('\u{1}');
50        material.push_str(&file.path);
51        material.push('\u{2}');
52        material.push_str(&file.sha256);
53        for edit in &file.edits {
54            use std::fmt::Write as _;
55            material.push('\u{3}');
56            let _ = write!(
57                material,
58                "{}:{}:{}:{}:{}:{}:{}",
59                edit.start_line,
60                edit.start_char,
61                edit.end_line,
62                edit.end_char,
63                edit.before,
64                edit.after,
65                edit.provenance.as_str()
66            );
67        }
68    }
69    // A non-cryptographic digest is enough: this value never leaves the process and an attacker
70    // who can call this server can call the planner too.
71    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
72    for byte in material.as_bytes() {
73        hash ^= u64::from(*byte);
74        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
75    }
76    format!("{hash:016x}")
77}
78
79impl TokenStore {
80    /// Issues a confirmation for a previewed plan.
81    pub fn issue(&self, plan: &EditPlan, repository: &Path) -> ConfirmToken {
82        let expires_at = now() + TOKEN_TTL.as_secs();
83        let fingerprint = fingerprint(plan);
84        let value = format!(
85            "{fingerprint}{:016x}",
86            now().wrapping_mul(0x9e37_79b9_7f4a_7c15)
87        );
88        if let Ok(mut issued) = self.0.lock() {
89            issued.retain(|_, token| token.expires_at > now());
90            issued.insert(
91                value.clone(),
92                Issued {
93                    fingerprint,
94                    repository: repository.display().to_string(),
95                    expires_at,
96                },
97            );
98        }
99        ConfirmToken { value, expires_at }
100    }
101
102    /// Consumes a confirmation, or returns the refusal that says why it could not be.
103    ///
104    /// Consuming happens whether or not the checks pass: a token that was presented is spent,
105    /// so a caller cannot probe with the same value twice.
106    pub fn consume(
107        &self,
108        presented: Option<&str>,
109        plan: &EditPlan,
110        repository: &Path,
111    ) -> Option<Value> {
112        let Some(presented) = presented else {
113            return Some(json!({
114                "status": "TOKEN_UNKNOWN",
115                "reason": "mode=\"apply\" requires the confirm_token issued by a preview of this \
116                           exact plan. Nothing was written.",
117            }));
118        };
119        let mut issued = self.0.lock().ok()?;
120        let Some(token) = issued.remove(presented) else {
121            return Some(json!({
122                "status": "TOKEN_UNKNOWN",
123                "reason": "the confirmation is not one this server issued, or it was already used. \
124                           Nothing was written.",
125            }));
126        };
127        if token.expires_at <= now() {
128            return Some(json!({
129                "status": "TOKEN_EXPIRED",
130                "reason": "the confirmation expired; preview again to get a fresh one. Nothing was written.",
131            }));
132        }
133        if token.repository != repository.display().to_string() {
134            return Some(json!({
135                "status": "TOKEN_REPOSITORY_MISMATCH",
136                "reason": "the confirmation belongs to a different repository. Nothing was written.",
137            }));
138        }
139        if token.fingerprint != fingerprint(plan) {
140            return Some(json!({
141                "status": "TOKEN_PLAN_MISMATCH",
142                "reason": "the plan changed after it was previewed; the confirmation proves a \
143                           different plan. Nothing was written.",
144            }));
145        }
146        None
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::TokenStore;
153    use blazingly_json::Value;
154    use std::path::Path;
155    use weavatrix_refactor_plan::{EditPlan, FileEdit, Provenance, TextEdit};
156
157    fn plan(after: &str) -> EditPlan {
158        EditPlan::new(
159            "rename_symbol",
160            vec![FileEdit::new(
161                "src/a.rs",
162                "0".repeat(64),
163                vec![TextEdit {
164                    start_line: 1,
165                    start_char: 0,
166                    end_line: 1,
167                    end_char: 3,
168                    before: "one".to_owned(),
169                    after: after.to_owned(),
170                    provenance: Provenance::new(Provenance::EXACT_LSP),
171                    extensions: std::collections::BTreeMap::new(),
172                }],
173            )],
174        )
175    }
176
177    fn status(value: Option<&Value>) -> Option<&str> {
178        value?.get("status")?.as_str()
179    }
180
181    #[test]
182    fn a_previewed_plan_applies_once() {
183        let store = TokenStore::default();
184        let repository = Path::new("/repo");
185        let token = store.issue(&plan("two"), repository);
186        assert!(
187            store
188                .consume(Some(&token.value), &plan("two"), repository)
189                .is_none()
190        );
191        // The same value a second time is spent, whatever it proved the first time.
192        let replay = store.consume(Some(&token.value), &plan("two"), repository);
193        assert_eq!(status(replay.as_ref()), Some("TOKEN_UNKNOWN"));
194    }
195
196    #[test]
197    fn a_token_does_not_travel_to_another_plan() {
198        let store = TokenStore::default();
199        let repository = Path::new("/repo");
200        let token = store.issue(&plan("two"), repository);
201        let refusal = store.consume(Some(&token.value), &plan("three"), repository);
202        assert_eq!(status(refusal.as_ref()), Some("TOKEN_PLAN_MISMATCH"));
203    }
204
205    #[test]
206    fn a_token_does_not_travel_to_another_repository() {
207        let store = TokenStore::default();
208        let token = store.issue(&plan("two"), Path::new("/repo"));
209        let refusal = store.consume(Some(&token.value), &plan("two"), Path::new("/other"));
210        assert_eq!(status(refusal.as_ref()), Some("TOKEN_REPOSITORY_MISMATCH"));
211    }
212
213    #[test]
214    fn applying_without_a_confirmation_is_refused() {
215        let store = TokenStore::default();
216        let refusal = store.consume(None, &plan("two"), Path::new("/repo"));
217        assert_eq!(status(refusal.as_ref()), Some("TOKEN_UNKNOWN"));
218    }
219
220    #[test]
221    fn an_invented_confirmation_is_refused() {
222        let store = TokenStore::default();
223        let refusal = store.consume(Some("deadbeef"), &plan("two"), Path::new("/repo"));
224        assert_eq!(status(refusal.as_ref()), Some("TOKEN_UNKNOWN"));
225    }
226}