1use std::fs::{self, File, OpenOptions, TryLockError};
5use std::io::{self, Read};
6use std::os::unix::fs::OpenOptionsExt;
7use std::path::{Component, Path, PathBuf};
8
9use rusqlite::{
10 Connection, OpenFlags,
11 backup::{Backup, StepResult},
12};
13use sha2::{Digest, Sha256};
14use thiserror::Error;
15
16#[derive(Debug, Error)]
17pub enum DataMigrationError {
18 #[error("Could not {operation} at {path}: {source}. The original data has been retained.")]
19 Io {
20 operation: &'static str,
21 path: PathBuf,
22 source: io::Error,
23 },
24 #[error(
25 "Could not migrate database {path}: {source}. Close the old app and retry; original data is retained."
26 )]
27 Database {
28 path: PathBuf,
29 source: rusqlite::Error,
30 },
31 #[error("Could not verify migrated storage at {path}: {source}. Original data is retained.")]
32 Store {
33 path: PathBuf,
34 source: crate::StoreError,
35 },
36 #[error(
37 "Could not verify migrated transcripts from {path}: {source}. Original data is retained."
38 )]
39 Transcript {
40 path: PathBuf,
41 source: crate::TranscriptError,
42 },
43 #[error("Data at {0} is in use. Close the other app and retry.")]
44 InUse(PathBuf),
45 #[error("Cannot migrate {path}: {reason}. Original data is retained.")]
46 Invalid { path: PathBuf, reason: &'static str },
47}
48
49fn io_at<T>(
50 path: &Path,
51 operation: &'static str,
52 result: io::Result<T>,
53) -> Result<T, DataMigrationError> {
54 result.map_err(|source| DataMigrationError::Io {
55 operation,
56 path: path.into(),
57 source,
58 })
59}
60
61struct Ownership(File);
62impl Drop for Ownership {
63 fn drop(&mut self) {
64 if let Err(error) = self.0.unlock() {
65 tracing::warn!(%error, "could not release data migration ownership");
66 }
67 }
68}
69
70fn lock(path: &Path) -> Result<Ownership, DataMigrationError> {
71 let file = io_at(
72 path,
73 "open ownership lock",
74 OpenOptions::new()
75 .read(true)
76 .write(true)
77 .create(true)
78 .truncate(false)
79 .mode(0o600)
80 .custom_flags(libc::O_NOFOLLOW)
81 .open(path),
82 )?;
83 file.try_lock().map_err(|error| match error {
84 TryLockError::WouldBlock => DataMigrationError::InUse(path.into()),
85 TryLockError::Error(source) => DataMigrationError::Io {
86 operation: "lock data",
87 path: path.into(),
88 source,
89 },
90 })?;
91 Ok(Ownership(file))
92}
93
94fn directory_exists(path: &Path) -> Result<bool, DataMigrationError> {
95 match fs::symlink_metadata(path) {
96 Ok(metadata) if metadata.is_dir() => Ok(true),
97 Ok(_) => Err(DataMigrationError::Invalid {
98 path: path.into(),
99 reason: "expected a directory, not a symlink or file",
100 }),
101 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
102 Err(error) => io_at(path, "inspect directory", Err(error)),
103 }
104}
105
106pub fn migrate_data_directory(legacy: &Path, destination: &Path) -> Result<(), DataMigrationError> {
116 for path in [legacy, destination] {
117 if !path.is_absolute() || path.components().any(|c| matches!(c, Component::ParentDir)) {
118 return Err(DataMigrationError::Invalid {
119 path: path.into(),
120 reason: "use an absolute path without parent traversal",
121 });
122 }
123 }
124 if directory_exists(destination)? {
125 return Ok(());
126 }
127 let parent = destination
128 .parent()
129 .ok_or_else(|| DataMigrationError::Invalid {
130 path: destination.into(),
131 reason: "destination needs a parent directory",
132 })?;
133 io_at(
134 parent,
135 "create destination parent",
136 fs::create_dir_all(parent),
137 )?;
138 let _migration = lock(&parent.join(".handshake-migration.lock"))?;
139 if directory_exists(destination)? {
140 return Ok(());
141 }
142 let exists = directory_exists(legacy)?;
143 if exists {
144 let source = io_at(legacy, "resolve source", legacy.canonicalize())?;
145 let target = io_at(parent, "resolve destination parent", parent.canonicalize())?;
146 if target.starts_with(&source) {
147 return Err(DataMigrationError::Invalid {
148 path: destination.into(),
149 reason: "destination must be outside the legacy directory",
150 });
151 }
152 }
153 let staging = io_at(
154 parent,
155 "create private migration copy",
156 tempfile::Builder::new()
157 .prefix(".handshake-migration-")
158 .tempdir_in(parent),
159 )?;
160 let mut _source_ownership = None;
161 let mut _database_ownership = None;
162 if exists {
163 let transcripts = legacy.join("transcripts");
164 if !directory_exists(&transcripts)? {
165 io_at(
166 &transcripts,
167 "create transcript ownership directory",
168 fs::create_dir(&transcripts),
169 )?;
170 }
171 _source_ownership = Some(lock(&transcripts.join("owner.lock"))?);
172 let database = legacy.join("twine.db");
173 _database_ownership = match fs::symlink_metadata(&database) {
176 Ok(_) => Some(snapshot_database(
177 &database,
178 &staging.path().join("handshake.db"),
179 )?),
180 Err(error) if error.kind() == io::ErrorKind::NotFound => None,
181 Err(error) => return io_at(&database, "inspect database", Err(error)),
182 };
183 copy_directory(legacy, staging.path(), true)?;
184 }
185 crate::store::Store::open(&staging.path().join("handshake.db"))
187 .and_then(|store| store.verify_schema())
188 .map_err(|source| DataMigrationError::Store {
189 path: legacy.into(),
190 source,
191 })?;
192 let transcripts = crate::terminal::TranscriptRecorder::open(
193 &staging.path().join("transcripts"),
194 )
195 .map_err(|source| DataMigrationError::Transcript {
196 path: legacy.into(),
197 source,
198 })?;
199 transcripts.shutdown();
200 sync_tree(staging.path())?;
201 match rustix::fs::renameat_with(
202 rustix::fs::CWD,
203 staging.path(),
204 rustix::fs::CWD,
205 destination,
206 rustix::fs::RenameFlags::NOREPLACE,
207 ) {
208 Ok(()) => {
209 let _published = staging.keep();
210 }
211 Err(rustix::io::Errno::EXIST) if directory_exists(destination)? => return Ok(()),
212 Err(error) => return io_at(destination, "publish verified migration", Err(error.into())),
213 }
214 io_at(
215 parent,
216 "sync migration publication",
217 File::open(parent).and_then(|f| f.sync_all()),
218 )
219}
220
221fn snapshot_database(source: &Path, destination: &Path) -> Result<Connection, DataMigrationError> {
222 if !io_at(
223 source,
224 "inspect legacy database",
225 fs::symlink_metadata(source),
226 )?
227 .is_file()
228 {
229 return Err(DataMigrationError::Invalid {
230 path: source.into(),
231 reason: "database must be a regular file",
232 });
233 }
234 let resolved = io_at(source, "resolve database", source.canonicalize())?;
235 let snapshot = || -> rusqlite::Result<Connection> {
236 let writer = Connection::open_with_flags(
237 &resolved,
238 OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NOFOLLOW,
239 )?;
240 writer.busy_timeout(std::time::Duration::ZERO)?;
241 writer.execute_batch("BEGIN IMMEDIATE")?;
242 let reader = Connection::open_with_flags(
243 &resolved,
244 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NOFOLLOW,
245 )?;
246 let mut output = Connection::open(destination)?;
247 let result = Backup::new(&reader, &mut output)?.step(-1)?;
248 if result != StepResult::Done {
249 return Err(rusqlite::Error::SqliteFailure(
250 rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY),
251 None,
252 ));
253 }
254 let check: String = output.query_row("PRAGMA quick_check", [], |row| row.get(0))?;
255 if check != "ok" {
256 return Err(rusqlite::Error::InvalidQuery);
257 }
258 Ok(writer)
259 };
260 snapshot().map_err(|source_error| DataMigrationError::Database {
261 path: source.into(),
262 source: source_error,
263 })
264}
265
266fn copy_directory(source: &Path, destination: &Path, root: bool) -> Result<(), DataMigrationError> {
267 for entry in io_at(source, "read data directory", fs::read_dir(source))? {
268 let entry = io_at(source, "read directory entry", entry)?;
269 let name = entry.file_name();
270 if root && name == "memories" && entry.path().join("bindings.sqlite").is_file() {
274 continue;
275 }
276 if root
277 && [
278 "twine.db",
279 "twine.db-wal",
280 "twine.db-shm",
281 "twine.db-journal",
282 ]
283 .iter()
284 .any(|n| name == *n)
285 {
286 continue;
287 }
288 let target = destination.join(if root && name == "twine.trace-details" {
289 std::ffi::OsStr::new("handshake.trace-details")
290 } else {
291 &name
292 });
293 let kind = io_at(&entry.path(), "inspect data entry", entry.file_type())?;
294 if kind.is_dir() {
295 io_at(&target, "create copied directory", fs::create_dir(&target))?;
296 copy_directory(&entry.path(), &target, false)?;
297 } else if kind.is_file() {
298 if target.exists() {
299 return Err(DataMigrationError::Invalid {
300 path: entry.path(),
301 reason: "conflicting migration filenames",
302 });
303 }
304 io_at(&target, "copy data file", fs::copy(entry.path(), &target))?;
305 if digest(&entry.path())? != digest(&target)? {
306 return Err(DataMigrationError::Invalid {
307 path: target,
308 reason: "copied bytes did not verify",
309 });
310 }
311 } else {
312 return Err(DataMigrationError::Invalid {
313 path: entry.path(),
314 reason: "symlinks and special files require manual migration",
315 });
316 }
317 }
318 Ok(())
319}
320
321fn digest(path: &Path) -> Result<Vec<u8>, DataMigrationError> {
322 let mut input = io_at(path, "verify copied bytes", File::open(path))?;
323 let mut hash = Sha256::new();
324 let mut buffer = [0; 8192];
325 loop {
326 let count = io_at(path, "verify copied bytes", input.read(&mut buffer))?;
327 if count == 0 {
328 break;
329 }
330 hash.update(&buffer[..count]);
331 }
332 Ok(hash.finalize().to_vec())
333}
334
335fn sync_tree(path: &Path) -> Result<(), DataMigrationError> {
336 for entry in io_at(path, "sync copied directory", fs::read_dir(path))? {
337 let entry = io_at(path, "sync directory entry", entry)?;
338 if entry
339 .file_type()
340 .map_err(|source| DataMigrationError::Io {
341 operation: "inspect copied entry",
342 path: entry.path(),
343 source,
344 })?
345 .is_dir()
346 {
347 sync_tree(&entry.path())?;
348 } else {
349 io_at(
350 &entry.path(),
351 "sync copied file",
352 File::open(entry.path()).and_then(|f| f.sync_all()),
353 )?;
354 }
355 }
356 io_at(
357 path,
358 "sync copied directory",
359 File::open(path).and_then(|f| f.sync_all()),
360 )
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use crate::config::Config;
367 use crate::{
368 Application, Command, CommandDisposition, RequestId, TerminalSize, TraceActivityId,
369 TranscriptRead, WorkflowKind,
370 };
371 use std::fmt::Write as _;
372 use std::time::{Duration, Instant};
373
374 fn command(app: &Application, command: Command) {
375 assert_eq!(
376 app.handle_command(RequestId(1), command)
377 .unwrap()
378 .disposition,
379 CommandDisposition::Accepted
380 );
381 }
382
383 #[test]
384 fn retains_the_registered_memory_library_without_cloning_native_profiles() {
385 let root = tempfile::tempdir().unwrap();
386 let legacy = root.path().join("Twine");
387 let destination = root.path().join("Handshake");
388 let memory = legacy.join("memories");
389 fs::create_dir_all(&memory).unwrap();
390 fs::write(memory.join("bindings.sqlite"), b"receiver-owned library").unwrap();
391 std::os::unix::fs::symlink("native-profile", memory.join("native-link")).unwrap();
392 migrate_data_directory(&legacy, &destination).unwrap();
393 assert_eq!(
394 fs::read(memory.join("bindings.sqlite")).unwrap(),
395 b"receiver-owned library"
396 );
397 assert!(
398 memory
399 .join("native-link")
400 .symlink_metadata()
401 .unwrap()
402 .file_type()
403 .is_symlink()
404 );
405 assert!(!destination.join("memories").exists());
406 assert!(destination.join("handshake.db").exists());
407 }
408
409 fn recorded_legacy(source: &Path, folder: &Path) -> (crate::TerminalId, TranscriptRead) {
410 let app = Application::with_config(source, Config::default()).unwrap();
411 command(
412 &app,
413 Command::OpenFolder {
414 path: folder.into(),
415 },
416 );
417 command(
418 &app,
419 Command::CreateWorkflow {
420 folder: folder.into(),
421 session_id: None,
422 kind: WorkflowKind::Terminal,
423 roles: vec![],
424 size: TerminalSize {
425 rows: 24,
426 columns: 80,
427 pixel_width: 800,
428 pixel_height: 480,
429 },
430 },
431 );
432 let id = app
433 .snapshot()
434 .unwrap()
435 .workflows
436 .workflows
437 .last()
438 .unwrap()
439 .terminal_ids()[0];
440 app.write_terminal_input(id, b"printf 'migration-output\\n'; exit\n")
441 .unwrap();
442 let deadline = Instant::now() + Duration::from_secs(5);
443 loop {
444 let page = app.read_terminal_transcript(id, 0, 65536).unwrap();
445 if let TranscriptRead::Output(ref output) = page
446 && String::from_utf8_lossy(&output.bytes).contains("migration-output\r\n")
447 {
448 drop(app);
449 let recorder =
450 crate::terminal::TranscriptRecorder::open(&source.join("transcripts")).unwrap();
451 return (id, recorder.read(id, 0, 65536).unwrap());
452 }
453 assert!(Instant::now() < deadline, "shell output did not arrive");
454 std::thread::sleep(Duration::from_millis(5));
455 }
456 }
457
458 #[test]
459 fn migrates_wal_sessions_transcripts_details_and_presentation_without_losing_the_source() {
460 let root = tempfile::tempdir().unwrap();
461 let source = root.path().join("Twine");
462 let destination = root.path().join("Handshake");
463 let folder = tempfile::tempdir().unwrap();
464 let (terminal, bytes) = recorded_legacy(&source, folder.path());
465 let database = Connection::open(source.join("twine.db")).unwrap();
466 database
467 .execute_batch("PRAGMA wal_autocheckpoint=0; UPDATE sessions SET name='WAL session';")
468 .unwrap();
469 let detail = "native trace output ☃";
470 let mut key = String::new();
471 for byte in Sha256::digest(detail.as_bytes()) {
472 write!(&mut key, "{byte:02x}").unwrap();
473 }
474 fs::write(source.join("twine.trace-details").join(&key), detail).unwrap();
475 database.execute("INSERT INTO trace_activities(id,span_id,source_id,kind,title,status,input,output,output_key,output_bytes)
476 VALUES(4242,(SELECT id FROM trace_spans LIMIT 1),'migration','tool','Saved tool','completed','','preview',?1,?2)",
477 rusqlite::params![key, i64::try_from(detail.len()).unwrap()]).unwrap();
478 assert!(source.join("twine.db-wal").metadata().unwrap().len() > 0);
479 fs::write(source.join("workflow-layouts.json"), b"presentation state").unwrap();
480 migrate_data_directory(&source, &destination).unwrap();
481 assert!(source.join("twine.db").exists());
482 assert!(source.join("twine.trace-details").join(&key).exists());
483 assert!(!destination.join("twine.db").exists());
484 assert_eq!(
485 fs::read(destination.join("workflow-layouts.json")).unwrap(),
486 b"presentation state"
487 );
488 let app = Application::with_config(&destination, Config::default()).unwrap();
489 assert!(
490 app.snapshot()
491 .unwrap()
492 .workflows
493 .sessions
494 .iter()
495 .any(|session| session.name == "WAL session")
496 );
497 assert_eq!(
498 app.read_terminal_transcript(terminal, 0, 65536).unwrap(),
499 bytes
500 );
501 assert_eq!(
502 app.trace_detail(TraceActivityId(4242), true, 0, 65536)
503 .unwrap()
504 .text,
505 detail
506 );
507 assert_ne!(
508 app.workflow_trace(crate::WorkflowId(1), None, 10)
509 .unwrap()
510 .spans
511 .len(),
512 0
513 );
514 drop(app);
515 migrate_data_directory(&source, &destination).unwrap();
516 let reopened = Application::with_config(&destination, Config::default()).unwrap();
517 assert_eq!(
518 reopened
519 .read_terminal_transcript(terminal, 0, 65536)
520 .unwrap(),
521 bytes
522 );
523 assert_eq!(
524 reopened
525 .trace_detail(TraceActivityId(4242), true, 0, 65536)
526 .unwrap()
527 .text,
528 detail
529 );
530 }
531
532 #[test]
533 fn fresh_install_is_isolated_and_an_established_destination_wins() {
534 let root = tempfile::tempdir().unwrap();
535 let legacy = root.path().join("missing-legacy");
536 let destination = root.path().join("Handshake");
537 migrate_data_directory(&legacy, &destination).unwrap();
538 assert!(!legacy.exists());
539 assert!(destination.join("handshake.db").is_file());
540 let database = fs::read(destination.join("handshake.db")).unwrap();
541 fs::write(&legacy, b"unavailable source").unwrap();
542 migrate_data_directory(&legacy, &destination).unwrap();
543 assert_eq!(
544 fs::read(destination.join("handshake.db")).unwrap(),
545 database
546 );
547 let isolated = root.path().join("debug");
548 drop(Application::with_config(&isolated, Config::default()).unwrap());
549 assert!(!isolated.join("handshake.db").exists());
550 }
551
552 #[test]
553 fn refuses_live_cores_and_sqlite_writers() {
554 let root = tempfile::tempdir().unwrap();
555 let legacy = root.path().join("Twine");
556 let destination = root.path().join("Handshake");
557 let app = Application::with_config(&legacy, Config::default()).unwrap();
558 assert!(matches!(
559 migrate_data_directory(&legacy, &destination),
560 Err(DataMigrationError::InUse(_))
561 ));
562 assert!(!destination.exists());
563 drop(app);
564 let writer = Connection::open(legacy.join("twine.db")).unwrap();
565 writer.execute_batch("BEGIN IMMEDIATE").unwrap();
566 assert!(matches!(
567 migrate_data_directory(&legacy, &destination),
568 Err(DataMigrationError::Database { .. })
569 ));
570 assert!(!destination.exists());
571 drop(writer);
572 migrate_data_directory(&legacy, &destination).unwrap();
573 }
574
575 #[test]
576 fn interrupted_staging_and_failed_copies_retry_without_overwriting_data() {
577 let root = tempfile::tempdir().unwrap();
578 let legacy = root.path().join("Twine");
579 let destination = root.path().join("Handshake");
580 drop(Application::with_config(&legacy, Config::default()).unwrap());
581 let interrupted = root.path().join(".handshake-migration-interrupted");
582 fs::create_dir(&interrupted).unwrap();
583 fs::write(interrupted.join("handshake.db"), b"partial").unwrap();
584 std::os::unix::fs::symlink("/missing", legacy.join("unavailable")).unwrap();
585 assert!(migrate_data_directory(&legacy, &destination).is_err());
586 assert!(!destination.exists());
587 assert!(legacy.join("twine.db").exists());
588 fs::remove_file(legacy.join("unavailable")).unwrap();
589 migrate_data_directory(&legacy, &destination).unwrap();
590 assert!(Application::with_config(&destination, Config::default()).is_ok());
591 }
592
593 #[test]
594 fn corrupt_domain_schema_is_rejected_before_publication_and_can_be_repaired() {
595 let root = tempfile::tempdir().unwrap();
596 let legacy = root.path().join("Twine");
597 let destination = root.path().join("Handshake");
598 drop(Application::with_config(&legacy, Config::default()).unwrap());
599 let path = legacy.join("twine.db");
600 let original = fs::read(&path).unwrap();
601 let database = Connection::open(&path).unwrap();
602 database.execute_batch("DROP TABLE sessions").unwrap();
603 assert_eq!(
604 database
605 .query_row("PRAGMA quick_check", [], |row| row.get::<_, String>(0))
606 .unwrap(),
607 "ok"
608 );
609 drop(database);
610 assert!(migrate_data_directory(&legacy, &destination).is_err());
611 assert!(!destination.exists());
612 fs::write(&path, original).unwrap();
613 migrate_data_directory(&legacy, &destination).unwrap();
614 assert!(Application::with_config(&destination, Config::default()).is_ok());
615 }
616
617 #[test]
618 fn corrupt_transcript_metadata_is_not_published_and_repair_can_retry() {
619 let root = tempfile::tempdir().unwrap();
620 let legacy = root.path().join("Twine");
621 let destination = root.path().join("Handshake");
622 drop(Application::with_config(&legacy, Config::default()).unwrap());
623 let metadata = legacy.join("transcripts/metadata");
624 let original = fs::read(&metadata).unwrap();
625 fs::write(&metadata, "corrupt").unwrap();
626 assert!(matches!(
627 migrate_data_directory(&legacy, &destination),
628 Err(DataMigrationError::Transcript { .. })
629 ));
630 assert!(!destination.exists());
631 assert_eq!(fs::read(&metadata).unwrap(), b"corrupt");
632 fs::write(&metadata, original).unwrap();
633 migrate_data_directory(&legacy, &destination).unwrap();
634 assert!(Application::with_config(&destination, Config::default()).is_ok());
635 }
636
637 #[test]
638 fn a_dangling_database_link_is_not_treated_as_a_fresh_install() {
639 let root = tempfile::tempdir().unwrap();
640 let legacy = root.path().join("Twine");
641 let destination = root.path().join("Handshake");
642 fs::create_dir(&legacy).unwrap();
643 std::os::unix::fs::symlink("/missing", legacy.join("twine.db")).unwrap();
644 assert!(matches!(
645 migrate_data_directory(&legacy, &destination),
646 Err(DataMigrationError::Invalid { .. })
647 ));
648 assert!(!destination.exists());
649 assert!(legacy.join("twine.db").is_symlink());
650 }
651
652 #[test]
653 fn invalid_source_destination_and_corrupt_database_are_actionable() {
654 let root = tempfile::tempdir().unwrap();
655 let legacy = root.path().join("Twine");
656 let destination = root.path().join("Handshake");
657 fs::write(&legacy, "not a directory").unwrap();
658 assert!(migrate_data_directory(&legacy, &destination).is_err());
659 fs::remove_file(&legacy).unwrap();
660 fs::create_dir(&legacy).unwrap();
661 fs::write(legacy.join("twine.db"), b"invalid sqlite").unwrap();
662 let error = migrate_data_directory(&legacy, &destination).unwrap_err();
663 assert!(error.to_string().contains("retained"));
664 assert!(!destination.exists());
665 fs::write(&destination, "occupied").unwrap();
666 assert!(migrate_data_directory(&legacy, &destination).is_err());
667 assert_eq!(fs::read(&destination).unwrap(), b"occupied");
668 assert!(migrate_data_directory(&legacy, &legacy.join("nested")).is_err());
669 assert!(migrate_data_directory(Path::new("relative"), &destination).is_err());
670 }
671}