Skip to main content

handshake_core/
data_migration.rs

1//! Explicit migration of application data. Platform paths come from the caller; ordinary
2//! development/test constructors never search for or adopt another data directory.
3
4use 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
106/// Prepares `destination` from legacy application data, retaining the original directory.
107/// An existing destination wins without merging. Copies are verified privately and published
108/// atomically; an interrupted copy is never treated as an established destination.
109/// Call this only for production paths, before opening a runtime. Explicit overrides, tests,
110/// development builds and previews must continue using their own directories directly.
111///
112/// # Errors
113/// Returns an actionable error for live writers, unavailable paths, unsupported file types,
114/// corrupt databases, or failed copy/verification/publication. No existing destination is replaced.
115pub 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        // Hold a write reservation on a separate connection while the backup reader snapshots
174        // committed main/WAL pages. No SQLite sidecar is copied as an ordinary file.
175        _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    // Validate the schema too, and create a fresh database for an empty/fresh installation.
186    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        // The cross-agent library has absolute native bindings, its own receiver lock,
271        // live SQLite state and native profile symlinks. It is retained in place and
272        // selected by memories::handshake, not copied into a second writable library.
273        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}