1use anyhow::{Context, Result};
6use rusqlite::{params, Connection};
7use serde::Deserialize;
8use serde_json::Value;
9use std::collections::HashMap;
10use std::fs;
11use std::path::Path;
12
13pub fn update_storage_json<P: AsRef<Path>>(
19 storage_path: P,
20 old_uri: &str,
21 new_uri: &str,
22 dry_run: bool,
23) -> Result<bool> {
24 let storage_path = storage_path.as_ref();
25
26 if !storage_path.exists() {
27 return Ok(false);
28 }
29
30 let content = fs::read_to_string(storage_path)
31 .with_context(|| format!("Failed to read: {}", storage_path.display()))?;
32
33 let mut json: Value = serde_json::from_str(&content).context("Failed to parse storage.json")?;
34
35 let folders_modified = json
37 .get_mut("backupWorkspaces")
38 .and_then(|b| b.get_mut("folders"))
39 .and_then(|f| f.as_array_mut())
40 .map(|arr| {
41 arr.iter_mut()
42 .filter_map(|folder| folder.get_mut("folderUri"))
43 .filter(|uri| uri.as_str() == Some(old_uri))
44 .fold(false, |_, uri| {
45 *uri = Value::String(new_uri.to_string());
46 true
47 })
48 })
49 .unwrap_or(false);
50
51 let assoc_modified = json
53 .get_mut("profileAssociations")
54 .and_then(|a| a.get_mut("workspaces"))
55 .and_then(|w| w.as_object_mut())
56 .and_then(|obj| obj.remove(old_uri).map(|v| (obj, v)))
57 .map(|(obj, value)| {
58 obj.insert(new_uri.to_string(), value);
59 true
60 })
61 .unwrap_or(false);
62
63 let modified = folders_modified || assoc_modified;
64
65 if modified && !dry_run {
66 let new_content = serde_json::to_string_pretty(&json)?;
67 fs::write(storage_path, new_content)
68 .with_context(|| format!("Failed to write: {}", storage_path.display()))?;
69 }
70
71 Ok(modified)
72}
73
74#[allow(clippy::too_many_arguments)]
83pub fn update_global_state_db<P: AsRef<Path>>(
84 state_db: P,
85 old_path: &str,
86 new_path: &str,
87 old_uri: &str,
88 new_uri: &str,
89 old_workspace_hash: &str,
90 new_workspace_hash: &str,
91 dry_run: bool,
92) -> Result<bool> {
93 let state_db = state_db.as_ref();
94
95 if !state_db.exists() {
96 return Ok(false);
97 }
98
99 let conn = Connection::open(state_db)
100 .with_context(|| format!("Failed to open global state DB: {}", state_db.display()))?;
101
102 let mut modified = false;
103
104 let replacements = [
108 (old_path, new_path),
109 (old_uri, new_uri),
110 (old_workspace_hash, new_workspace_hash),
111 ];
112 let normalized_replacements: Vec<(String, String)> = replacements
113 .iter()
114 .map(|(old, new)| (old.to_string(), new.to_string()))
115 .collect();
116 let targets = [
117 ("ItemTable", "key"),
118 ("ItemTable", "value"),
119 ("cursorDiskKV", "key"),
120 ("cursorDiskKV", "value"),
121 ];
122
123 for (table, column) in targets {
124 if !table_exists(&conn, table)? || !column_exists(&conn, table, column)? {
125 continue;
126 }
127
128 let query = format!("SELECT rowid, {column} FROM {table}");
129 let mut stmt = conn.prepare(&query)?;
130 let mut rows = stmt.query([])?;
131 let mut pending_updates: Vec<(i64, String)> = Vec::new();
132
133 while let Some(row) = rows.next()? {
134 let rowid: i64 = row.get(0)?;
135 let value: Option<String> = row.get(1)?;
136
137 let Some(value) = value else {
138 continue;
139 };
140
141 if !has_workspace_scoped_reference(&value, &normalized_replacements) {
142 continue;
143 }
144
145 let normalized = normalize_text_replacements(&value, &normalized_replacements);
146
147 if normalized == value {
148 continue;
149 }
150
151 if dry_run {
152 modified = true;
153 continue;
154 }
155
156 pending_updates.push((rowid, normalized));
157 }
158
159 for (rowid, normalized) in pending_updates {
160 conn.execute(
161 &format!("UPDATE {table} SET {column} = ?1 WHERE rowid = ?2"),
162 params![normalized, rowid],
163 )
164 .with_context(|| {
165 format!("Failed to update {table}.{column} for row {rowid} in global state DB")
166 })?;
167 modified = true;
168 }
169 }
170
171 Ok(modified)
172}
173
174fn has_workspace_scoped_reference(value: &str, replacements: &[(String, String)]) -> bool {
175 let candidates: Vec<&str> = replacements
176 .iter()
177 .filter_map(|(old, new)| (old != new).then_some(old.as_str()))
178 .collect();
179
180 candidates
181 .iter()
182 .any(|old| has_safe_suffix_match(value, old))
183}
184
185fn has_safe_suffix_match(value: &str, pattern: &str) -> bool {
186 if pattern.is_empty() {
187 return false;
188 }
189
190 let mut offset = 0usize;
191 while let Some(pos) = value[offset..].find(pattern) {
192 let absolute_pos = offset + pos;
193 let suffix = value[absolute_pos + pattern.len()..].chars().next();
194
195 if is_workspace_value_suffix_terminator(suffix) {
196 return true;
197 }
198
199 offset = absolute_pos + 1;
200 }
201
202 false
203}
204
205fn is_workspace_value_suffix_terminator(suffix: Option<char>) -> bool {
206 match suffix {
207 None => true,
208 Some(suffix) => {
209 !suffix.is_ascii_alphanumeric()
210 && suffix != '_'
211 && suffix != '-'
212 && suffix != '.'
213 && suffix != '%'
214 }
215 }
216}
217
218fn normalize_text_replacements(value: &str, replacements: &[(String, String)]) -> String {
219 let normalized_replacements: Vec<(&str, &str)> = replacements
220 .iter()
221 .filter_map(|(old, new)| (old != new).then_some((old.as_str(), new.as_str())))
222 .collect();
223
224 if normalized_replacements.is_empty() {
225 return value.to_string();
226 }
227
228 let mut token_seed = 0usize;
229 let mut tokens = Vec::new();
230
231 let mut staged = value.to_string();
232 for (old, _) in &normalized_replacements {
233 let mut token = format!("__CURSOR_HELPER_REPLACE_TOKEN_{token_seed}__");
234 while staged.contains(&token) || tokens.iter().any(|(old_token, _)| old_token == &token) {
235 token_seed += 1;
236 token = format!("__CURSOR_HELPER_REPLACE_TOKEN_{token_seed}__");
237 }
238
239 staged = replace_workspace_scoped_matches(&staged, old, &token);
240 tokens.push((token, *old));
241 token_seed += 1;
242 }
243
244 let mut normalized = staged;
245 for (token, old_value) in tokens {
246 if let Some((_, new_value)) = normalized_replacements
247 .iter()
248 .find(|(old, _)| old == &old_value)
249 {
250 normalized = normalized.replace(&token, new_value);
251 }
252 }
253
254 normalized
255}
256
257fn replace_workspace_scoped_matches(value: &str, pattern: &str, replacement: &str) -> String {
258 if pattern.is_empty() {
259 return value.to_string();
260 }
261
262 let mut offset = 0usize;
263 let mut normalized = String::with_capacity(value.len());
264
265 while let Some(pos) = value[offset..].find(pattern) {
266 let absolute_pos = offset + pos;
267
268 normalized.push_str(&value[offset..absolute_pos]);
269
270 let next_offset = absolute_pos + pattern.len();
271 let suffix = value[next_offset..].chars().next();
272 if is_workspace_value_suffix_terminator(suffix) {
273 normalized.push_str(replacement);
274 } else {
275 normalized.push_str(pattern);
276 }
277
278 offset = next_offset;
279 }
280
281 normalized.push_str(&value[offset..]);
282 normalized
283}
284
285fn table_exists(conn: &Connection, table: &str) -> Result<bool> {
286 let count: i64 = conn
287 .query_row(
288 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name = ?1",
289 params![table],
290 |row| row.get(0),
291 )
292 .context("Failed to query sqlite_master")?;
293
294 Ok(count > 0)
295}
296
297fn column_exists(conn: &Connection, table: &str, column: &str) -> Result<bool> {
298 let query = format!("PRAGMA table_info({})", table);
299 let mut stmt = conn
300 .prepare(&query)
301 .with_context(|| format!("Failed to read schema for table: {table}"))?;
302
303 let mut rows = stmt
304 .query([])
305 .with_context(|| format!("Failed to query columns for {table}"))?;
306 while let Some(row) = rows.next().context("Failed to iterate table info")? {
307 let col_name: String = row.get(1)?;
308 if col_name == column {
309 return Ok(true);
310 }
311 }
312
313 Ok(false)
314}
315
316#[derive(Debug, Deserialize)]
318#[allow(dead_code)]
319pub struct StorageJson {
320 #[serde(rename = "backupWorkspaces")]
321 pub backup_workspaces: Option<BackupWorkspaces>,
322
323 #[serde(rename = "profileAssociations")]
324 pub profile_associations: Option<ProfileAssociations>,
325}
326
327#[derive(Debug, Deserialize)]
328#[allow(dead_code)]
329pub struct BackupWorkspaces {
330 pub folders: Option<Vec<FolderEntry>>,
331}
332
333#[derive(Debug, Deserialize)]
334#[allow(dead_code)]
335pub struct FolderEntry {
336 #[serde(rename = "folderUri")]
337 pub folder_uri: String,
338}
339
340#[derive(Debug, Deserialize)]
341#[allow(dead_code)]
342pub struct ProfileAssociations {
343 pub workspaces: Option<HashMap<String, String>>,
344}
345
346impl StorageJson {
347 #[allow(dead_code)]
349 pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
350 let content = fs::read_to_string(path.as_ref())
351 .with_context(|| format!("Failed to read: {}", path.as_ref().display()))?;
352 serde_json::from_str(&content).context("Failed to parse storage.json")
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use std::io::Write;
360 use tempfile::NamedTempFile;
361 use tempfile::TempDir;
362
363 #[test]
364 fn test_update_storage_json() {
365 let mut file = NamedTempFile::new().unwrap();
366 write!(
367 file,
368 r#"{{
369 "backupWorkspaces": {{
370 "folders": [
371 {{ "folderUri": "file:///old/path" }},
372 {{ "folderUri": "file:///other/path" }}
373 ]
374 }},
375 "profileAssociations": {{
376 "workspaces": {{
377 "file:///old/path": "__default__profile__"
378 }}
379 }}
380}}"#
381 )
382 .unwrap();
383
384 let modified =
385 update_storage_json(file.path(), "file:///old/path", "file:///new/path", false)
386 .unwrap();
387
388 assert!(modified);
389
390 let content = fs::read_to_string(file.path()).unwrap();
392 assert!(content.contains("file:///new/path"));
393 assert!(!content.contains("file:///old/path"));
394 }
395
396 #[test]
397 fn test_update_global_state_db() {
398 let temp_dir = TempDir::new().unwrap();
399 let db_path = temp_dir.path().join("state.vscdb");
400
401 let conn = Connection::open(&db_path).unwrap();
402 conn.execute(
403 "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)",
404 [],
405 )
406 .unwrap();
407 conn.execute(
408 "CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT)",
409 [],
410 )
411 .unwrap();
412 conn.execute(
413 "INSERT INTO ItemTable(key, value) VALUES (?1, ?2)",
414 (
415 "workspace.key.file:///old/path",
416 "meta:file:///old/path/hash-old",
417 ),
418 )
419 .unwrap();
420 conn.execute(
421 "INSERT INTO cursorDiskKV(key, value) VALUES (?1, ?2)",
422 ("composer:hash-old", "file:///old/path"),
423 )
424 .unwrap();
425 drop(conn);
426
427 let modified = update_global_state_db(
428 &db_path,
429 "file:///old/path",
430 "file:///new/path",
431 "file:///old/path",
432 "file:///new/path",
433 "hash-old",
434 "hash-new",
435 false,
436 )
437 .unwrap();
438
439 assert!(modified);
440
441 let conn = Connection::open(&db_path).unwrap();
442 let item_key: String = conn
443 .query_row(
444 "SELECT key FROM ItemTable WHERE value LIKE '%new/path%'",
445 [],
446 |row| row.get(0),
447 )
448 .unwrap();
449 let item_value: String = conn
450 .query_row(
451 "SELECT value FROM ItemTable WHERE key LIKE '%new/path%'",
452 [],
453 |row| row.get(0),
454 )
455 .unwrap();
456 let disk_key: String = conn
457 .query_row(
458 "SELECT key FROM cursorDiskKV WHERE value LIKE '%new/path%'",
459 [],
460 |row| row.get(0),
461 )
462 .unwrap();
463 let disk_value: String = conn
464 .query_row(
465 "SELECT value FROM cursorDiskKV WHERE key LIKE '%hash-new%'",
466 [],
467 |row| row.get(0),
468 )
469 .unwrap();
470
471 assert_eq!(item_key, "workspace.key.file:///new/path");
472 assert_eq!(item_value, "meta:file:///new/path/hash-new");
473 assert_eq!(disk_key, "composer:hash-new");
474 assert_eq!(disk_value, "file:///new/path");
475 }
476
477 #[test]
478 fn test_update_global_state_db_special_chars() {
479 let temp_dir = TempDir::new().unwrap();
480 let db_path = temp_dir.path().join("state-special.vscdb");
481
482 let conn = Connection::open(&db_path).unwrap();
483 conn.execute(
484 "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)",
485 [],
486 )
487 .unwrap();
488 conn.execute(
489 "INSERT INTO ItemTable(key, value) VALUES (?1, ?2)",
490 (
491 "workspace.key.file:///old%20path%2Fwith%25percent/hash_old",
492 "meta:file:///old%20path%2Fwith%25percent",
493 ),
494 )
495 .unwrap();
496 drop(conn);
497
498 let modified = update_global_state_db(
499 &db_path,
500 "file:///old%20path%2Fwith%25percent",
501 "file:///new%20path%2Fwith%25percent",
502 "file:///old%20path%2Fwith%25percent",
503 "file:///new%20path%2Fwith%25percent",
504 "hash_old",
505 "hash-new",
506 false,
507 )
508 .unwrap();
509
510 assert!(modified);
511
512 let conn = Connection::open(&db_path).unwrap();
513 let item: (String, String) = conn
514 .query_row(
515 "SELECT key, value FROM ItemTable WHERE value LIKE 'meta:file:///new%';",
516 [],
517 |row| Ok((row.get(0).unwrap(), row.get(1).unwrap())),
518 )
519 .unwrap();
520
521 assert_eq!(
522 item.0,
523 "workspace.key.file:///new%20path%2Fwith%25percent/hash-new"
524 );
525 assert_eq!(item.1, "meta:file:///new%20path%2Fwith%25percent");
526 }
527
528 #[test]
529 fn test_update_global_state_db_path_then_uri_update_is_safe() {
530 let temp_dir = TempDir::new().unwrap();
531 let db_path = temp_dir.path().join("state-order.vscdb");
532
533 let conn = Connection::open(&db_path).unwrap();
534 conn.execute(
535 "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)",
536 [],
537 )
538 .unwrap();
539 conn.execute(
540 "INSERT INTO ItemTable(key, value) VALUES (?1, ?2)",
541 (
542 "workspace.key.file:///home/user/project",
543 "file:///home/user/project/hash-old",
544 ),
545 )
546 .unwrap();
547 drop(conn);
548
549 let modified = update_global_state_db(
550 &db_path,
551 "/home/user/project",
552 "/home/user/project-copy",
553 "file:///home/user/project",
554 "file:///home/user/project-copy",
555 "hash-old",
556 "hash-new",
557 false,
558 )
559 .unwrap();
560 assert!(modified);
561
562 let conn = Connection::open(&db_path).unwrap();
563 let item_key: String = conn
564 .query_row("SELECT key FROM ItemTable", [], |row| row.get(0))
565 .unwrap();
566 let item_value: String = conn
567 .query_row("SELECT value FROM ItemTable", [], |row| row.get(0))
568 .unwrap();
569
570 assert_eq!(item_key, "workspace.key.file:///home/user/project-copy");
571 assert_eq!(item_value, "file:///home/user/project-copy/hash-new");
572 assert!(!item_value.contains("project-copy-copy"));
573 assert!(!item_key.contains("project-copy-copy"));
574 assert!(modified);
575 }
576
577 #[test]
578 fn test_update_global_state_db_does_not_modify_workspace_prefix_matches() {
579 let temp_dir = TempDir::new().unwrap();
580 let db_path = temp_dir.path().join("state-prefix.vscdb");
581
582 let conn = Connection::open(&db_path).unwrap();
583 conn.execute(
584 "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)",
585 [],
586 )
587 .unwrap();
588 conn.execute(
589 "INSERT INTO ItemTable(key, value) VALUES (?1, ?2)",
590 (
591 "workspace.key.file:///home/user/project",
592 "meta:file:///home/user/project/hash-old",
593 ),
594 )
595 .unwrap();
596 conn.execute(
597 "INSERT INTO ItemTable(key, value) VALUES (?1, ?2)",
598 (
599 "workspace.key.file:///home/user/projects/foo",
600 "meta:file:///home/user/projects/foo/hash-other",
601 ),
602 )
603 .unwrap();
604 drop(conn);
605
606 let modified = update_global_state_db(
607 &db_path,
608 "/home/user/project",
609 "/home/user/project-copy",
610 "file:///home/user/project",
611 "file:///home/user/project-copy",
612 "hash-old",
613 "hash-new",
614 false,
615 )
616 .unwrap();
617 assert!(modified);
618
619 let conn = Connection::open(&db_path).unwrap();
620 let updated_value: String = conn
621 .query_row(
622 "SELECT value FROM ItemTable WHERE key = 'workspace.key.file:///home/user/project-copy'",
623 [],
624 |row| row.get(0),
625 )
626 .unwrap();
627 let untouched_value: String = conn
628 .query_row(
629 "SELECT value FROM ItemTable WHERE key = 'workspace.key.file:///home/user/projects/foo'",
630 [],
631 |row| row.get(0),
632 )
633 .unwrap();
634
635 assert_eq!(
636 updated_value,
637 "meta:file:///home/user/project-copy/hash-new"
638 );
639 assert_eq!(
640 untouched_value,
641 "meta:file:///home/user/projects/foo/hash-other"
642 );
643 }
644
645 #[test]
646 fn test_update_global_state_db_does_not_corrupt_prefix_values_in_same_cell() {
647 let temp_dir = TempDir::new().unwrap();
648 let db_path = temp_dir.path().join("state-prefix-cell.vscdb");
649
650 let conn = Connection::open(&db_path).unwrap();
651 conn.execute(
652 "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)",
653 [],
654 )
655 .unwrap();
656 conn.execute(
657 "INSERT INTO ItemTable(key, value) VALUES (?1, ?2)",
658 (
659 "workspace.key.file:///home/user/project",
660 r#"{"active":"file:///home/user/project","other":"file:///home/user/projects/foo","cache":"hash-old"}"#,
661 ),
662 )
663 .unwrap();
664 drop(conn);
665
666 let modified = update_global_state_db(
667 &db_path,
668 "/home/user/project",
669 "/home/user/project-copy",
670 "file:///home/user/project",
671 "file:///home/user/project-copy",
672 "hash-old",
673 "hash-new",
674 false,
675 )
676 .unwrap();
677 assert!(modified);
678
679 let conn = Connection::open(&db_path).unwrap();
680 let row_key: String = conn
681 .query_row("SELECT key FROM ItemTable", [], |row| row.get(0))
682 .unwrap();
683 let row_value: String = conn
684 .query_row("SELECT value FROM ItemTable", [], |row| row.get(0))
685 .unwrap();
686
687 assert_eq!(row_key, "workspace.key.file:///home/user/project-copy");
688 assert_eq!(
689 row_value,
690 r#"{"active":"file:///home/user/project-copy","other":"file:///home/user/projects/foo","cache":"hash-new"}"#
691 );
692 assert!(!row_value.contains("project-copy-copy"));
693 }
694}