weavatrix_rust_refactor/
token.rs1use 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
15const TOKEN_TTL: Duration = Duration::from_secs(5 * 60);
18
19pub 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#[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
41fn 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 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 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 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 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}