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