Skip to main content

cursor_helper/cursor/
storage.rs

1//! Global storage operations
2//!
3//! Handles updates to ~/Library/Application Support/Cursor/User/globalStorage/storage.json
4
5use 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
13/// Update workspace references in storage.json
14///
15/// This updates:
16/// - backupWorkspaces.folders[].folderUri
17/// - profileAssociations.workspaces (key rename)
18pub 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    // Update backupWorkspaces.folders[].folderUri
36    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    // Update profileAssociations.workspaces (rename key)
52    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/// Update workspace references embedded in global `state.vscdb`.
75///
76/// Cursor stores several workspace mappings and references in the global database:
77/// - `ItemTable.value` / `ItemTable.key`
78/// - `cursorDiskKV.key` / `cursorDiskKV.value`
79///
80/// Updates any exact path/hash strings from old values to new ones, which
81/// helps keep move/copy operations from leaving stale workspace IDs behind.
82#[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    // Replace path, URI, and workspace hash references in all known text columns.
105    // This is intentionally conservative to avoid schema-specific assumptions and
106    // avoids overlap by applying all replacements through placeholders in-memory.
107    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/// A simpler representation of storage.json for reading
317#[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    /// Read storage.json from a file
348    #[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        // Verify changes
391        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}