1use 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 plan: EditPlan,
35}
36
37#[derive(Default)]
39pub struct TokenStore(Mutex<HashMap<String, Issued>>);
40
41fn now() -> u64 {
42 SystemTime::now()
43 .duration_since(UNIX_EPOCH)
44 .map_or(0, |elapsed| elapsed.as_secs())
45}
46
47fn fingerprint(plan: &EditPlan) -> String {
53 let mut material = String::from(&plan.operation);
54 for file in &plan.files {
55 material.push('\u{1}');
56 material.push_str(&file.path);
57 material.push('\u{2}');
58 material.push_str(&file.sha256);
59 for edit in &file.edits {
60 use std::fmt::Write as _;
61 material.push('\u{3}');
62 let _ = write!(
63 material,
64 "{}:{}:{}:{}:{}:{}:{}",
65 edit.start_line,
66 edit.start_char,
67 edit.end_line,
68 edit.end_char,
69 edit.before,
70 edit.after,
71 edit.provenance.as_str()
72 );
73 }
74 }
75 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
78 for byte in material.as_bytes() {
79 hash ^= u64::from(*byte);
80 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
81 }
82 format!("{hash:016x}")
83}
84
85impl TokenStore {
86 pub fn issue(&self, plan: &EditPlan, repository: &Path) -> ConfirmToken {
88 let expires_at = now() + TOKEN_TTL.as_secs();
89 let fingerprint = fingerprint(plan);
90 let value = format!(
91 "{fingerprint}{:016x}",
92 now().wrapping_mul(0x9e37_79b9_7f4a_7c15)
93 );
94 if let Ok(mut issued) = self.0.lock() {
95 issued.retain(|_, token| token.expires_at > now());
96 issued.insert(
97 value.clone(),
98 Issued {
99 fingerprint,
100 repository: repository.display().to_string(),
101 expires_at,
102 plan: plan.clone(),
103 },
104 );
105 }
106 ConfirmToken { value, expires_at }
107 }
108
109 pub fn consume_for_plan(
121 &self,
122 presented: Option<&str>,
123 repository: &Path,
124 ) -> Result<EditPlan, Value> {
125 let Some(presented) = presented else {
126 return Err(json!({
127 "status": "TOKEN_UNKNOWN",
128 "reason": "mode=\"apply\" requires the confirm_token issued by a preview. \
129 Nothing was written.",
130 }));
131 };
132 let Ok(mut issued) = self.0.lock() else {
133 return Err(json!({
134 "status": "TOKEN_UNKNOWN",
135 "reason": "the token store is unavailable. Nothing was written.",
136 }));
137 };
138 let Some(token) = issued.remove(presented) else {
139 return Err(json!({
140 "status": "TOKEN_UNKNOWN",
141 "reason": "the confirmation is not one this server issued, or it was already \
142 used. Nothing was written.",
143 }));
144 };
145 if token.expires_at <= now() {
146 return Err(json!({
147 "status": "TOKEN_EXPIRED",
148 "reason": "the confirmation expired; preview again to get a fresh one. Nothing \
149 was written.",
150 }));
151 }
152 if token.repository != repository.display().to_string() {
153 return Err(json!({
154 "status": "TOKEN_REPOSITORY_MISMATCH",
155 "reason": "the confirmation belongs to a different repository. Nothing was written.",
156 }));
157 }
158 Ok(token.plan)
159 }
160
161 pub fn consume(
166 &self,
167 presented: Option<&str>,
168 plan: &EditPlan,
169 repository: &Path,
170 ) -> Option<Value> {
171 let Some(presented) = presented else {
172 return Some(json!({
173 "status": "TOKEN_UNKNOWN",
174 "reason": "mode=\"apply\" requires the confirm_token issued by a preview of this \
175 exact plan. Nothing was written.",
176 }));
177 };
178 let mut issued = self.0.lock().ok()?;
179 let Some(token) = issued.remove(presented) else {
180 return Some(json!({
181 "status": "TOKEN_UNKNOWN",
182 "reason": "the confirmation is not one this server issued, or it was already used. \
183 Nothing was written.",
184 }));
185 };
186 if token.expires_at <= now() {
187 return Some(json!({
188 "status": "TOKEN_EXPIRED",
189 "reason": "the confirmation expired; preview again to get a fresh one. Nothing was written.",
190 }));
191 }
192 if token.repository != repository.display().to_string() {
193 return Some(json!({
194 "status": "TOKEN_REPOSITORY_MISMATCH",
195 "reason": "the confirmation belongs to a different repository. Nothing was written.",
196 }));
197 }
198 if token.fingerprint != fingerprint(plan) {
199 return Some(json!({
200 "status": "TOKEN_PLAN_MISMATCH",
201 "reason": "the plan changed after it was previewed; the confirmation proves a \
202 different plan. Nothing was written.",
203 }));
204 }
205 None
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::TokenStore;
212 use blazingly_json::Value;
213 use std::path::Path;
214 use weavatrix_refactor_plan::{EditPlan, FileEdit, Provenance, TextEdit};
215
216 fn plan(after: &str) -> EditPlan {
217 EditPlan::new(
218 "rename_symbol",
219 vec![FileEdit::new(
220 "src/a.rs",
221 "0".repeat(64),
222 vec![TextEdit {
223 start_line: 1,
224 start_char: 0,
225 end_line: 1,
226 end_char: 3,
227 before: "one".to_owned(),
228 after: after.to_owned(),
229 provenance: Provenance::new(Provenance::EXACT_LSP),
230 extensions: std::collections::BTreeMap::new(),
231 }],
232 )],
233 )
234 }
235
236 fn status(value: Option<&Value>) -> Option<&str> {
237 value?.get("status")?.as_str()
238 }
239
240 #[test]
241 fn a_previewed_plan_applies_once() {
242 let store = TokenStore::default();
243 let repository = Path::new("/repo");
244 let token = store.issue(&plan("two"), repository);
245 assert!(
246 store
247 .consume(Some(&token.value), &plan("two"), repository)
248 .is_none()
249 );
250 let replay = store.consume(Some(&token.value), &plan("two"), repository);
252 assert_eq!(status(replay.as_ref()), Some("TOKEN_UNKNOWN"));
253 }
254
255 #[test]
256 fn a_token_does_not_travel_to_another_plan() {
257 let store = TokenStore::default();
258 let repository = Path::new("/repo");
259 let token = store.issue(&plan("two"), repository);
260 let refusal = store.consume(Some(&token.value), &plan("three"), repository);
261 assert_eq!(status(refusal.as_ref()), Some("TOKEN_PLAN_MISMATCH"));
262 }
263
264 #[test]
265 fn a_token_does_not_travel_to_another_repository() {
266 let store = TokenStore::default();
267 let token = store.issue(&plan("two"), Path::new("/repo"));
268 let refusal = store.consume(Some(&token.value), &plan("two"), Path::new("/other"));
269 assert_eq!(status(refusal.as_ref()), Some("TOKEN_REPOSITORY_MISMATCH"));
270 }
271
272 #[test]
273 fn applying_without_a_confirmation_is_refused() {
274 let store = TokenStore::default();
275 let refusal = store.consume(None, &plan("two"), Path::new("/repo"));
276 assert_eq!(status(refusal.as_ref()), Some("TOKEN_UNKNOWN"));
277 }
278
279 #[test]
280 fn an_invented_confirmation_is_refused() {
281 let store = TokenStore::default();
282 let refusal = store.consume(Some("deadbeef"), &plan("two"), Path::new("/repo"));
283 assert_eq!(status(refusal.as_ref()), Some("TOKEN_UNKNOWN"));
284 }
285}