Skip to main content

mj_controller/
checkpoint_transfer.rs

1//! Controller-side checkpoint transport and verified teardown gates.
2use crate::targets::{
3    CommandExecutor, CommandPlan, CommandSpec, SshTarget, TargetLocator, join_remote_command,
4    worker_root,
5};
6use anyhow::{Context, Result, bail, ensure};
7use mj_checkpoint::archive::validate_component;
8use mj_checkpoint::checkpoint::*;
9use std::fs;
10use std::path::{Path, PathBuf};
11/// Export by streaming the spec to the worker's standard input.
12///
13/// Every wrapper this builds keeps the target's stdin attached: the container
14/// engines are invoked with `exec -i` and `ssh` forwards stdin by default.
15pub fn export_stdin_command(locator: &TargetLocator, session_id: &str) -> Result<CommandSpec> {
16    export_command(locator, session_id, EXPORT_SPEC_STDIN)
17}
18
19pub fn capture_stdin_command(locator: &TargetLocator, session_id: &str) -> Result<CommandSpec> {
20    checkpoint_stdin_command(
21        locator,
22        session_id,
23        "capture-checkpoint",
24        "capture target checkpoint",
25    )
26}
27
28pub fn pack_stdin_command(locator: &TargetLocator, session_id: &str) -> Result<CommandSpec> {
29    checkpoint_stdin_command(
30        locator,
31        session_id,
32        "pack-checkpoint",
33        "pack target checkpoint",
34    )
35}
36
37fn checkpoint_stdin_command(
38    locator: &TargetLocator,
39    session_id: &str,
40    subcommand: &str,
41    purpose: &str,
42) -> Result<CommandSpec> {
43    let root = worker_root(locator, session_id)?;
44    let args = vec![format!("{root}/hel"), "worker".into(), subcommand.into()];
45    crate::targets::command_on_locator(locator, session_id, args, purpose)
46}
47
48pub fn export_command(
49    locator: &TargetLocator,
50    session_id: &str,
51    spec_path: &str,
52) -> Result<CommandSpec> {
53    validate_remote_path(spec_path)?;
54    let root = worker_root(locator, session_id)?;
55    let args = vec![
56        format!("{root}/hel"),
57        "worker".into(),
58        "export-checkpoint".into(),
59        "--spec".into(),
60        spec_path.into(),
61    ];
62    crate::targets::command_on_locator(locator, session_id, args, "export target checkpoint")
63}
64
65pub fn restore_command(
66    locator: &TargetLocator,
67    session_id: &str,
68    spec_path: &str,
69) -> Result<CommandSpec> {
70    validate_remote_path(spec_path)?;
71    let root = worker_root(locator, session_id)?;
72    let args = vec![
73        format!("{root}/hel"),
74        "worker".into(),
75        "restore-checkpoint".into(),
76        "--spec".into(),
77        spec_path.into(),
78    ];
79    crate::targets::command_on_locator(locator, session_id, args, "restore target checkpoint")
80}
81
82#[derive(Debug, Clone)]
83pub struct CheckpointTransfer<'a> {
84    pub locator: &'a TargetLocator,
85    pub session_id: &'a str,
86    pub operation_id: &'a str,
87    pub remote_archive: &'a str,
88    pub destination: &'a Path,
89    pub expected_sha256: &'a str,
90    pub expected_event_frontier: u64,
91    pub expected_event_frontier_digest: &'a str,
92}
93
94/// Unforgeable outside this module: proof that a controller-local archive has
95/// the exact digest reported by the target after its atomic install.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct VerifiedCheckpoint {
98    session_id: String,
99    archive_path: PathBuf,
100    sha256: String,
101    event_frontier: u64,
102    event_frontier_digest: String,
103}
104
105impl VerifiedCheckpoint {
106    pub fn archive_path(&self) -> &Path {
107        &self.archive_path
108    }
109    pub fn sha256(&self) -> &str {
110        &self.sha256
111    }
112    pub fn event_frontier(&self) -> u64 {
113        self.event_frontier
114    }
115    pub fn event_frontier_digest(&self) -> &str {
116        &self.event_frontier_digest
117    }
118    pub const fn teardown_allowed(&self) -> bool {
119        true
120    }
121}
122
123impl CheckpointTransfer<'_> {
124    pub fn execute(&self, executor: &impl CommandExecutor) -> Result<VerifiedCheckpoint> {
125        validate_remote_path(self.remote_archive)?;
126        let parent = self.destination.parent().unwrap_or_else(|| Path::new("."));
127        fs::create_dir_all(parent)?;
128        let temporary = tempfile::Builder::new()
129            .prefix(".hel-checkpoint-")
130            .tempfile_in(parent)?;
131        let path = temporary.path().to_path_buf();
132        let staging = remote_staging_path(self.session_id, self.operation_id)?;
133        let transfer_result = transfer_plan(
134            self.locator,
135            self.session_id,
136            self.remote_archive,
137            &path,
138            &staging,
139        )?
140        .execute(executor)
141        .context("download target checkpoint");
142        let staging_cleanup_result = cleanup_transfer_staging(self.locator, &staging, executor);
143        if let Err(error) = transfer_result {
144            return match staging_cleanup_result {
145                Ok(()) => Err(error),
146                Err(cleanup) => Err(error.context(format!(
147                    "clean target checkpoint host staging also failed: {cleanup:#}"
148                ))),
149            };
150        }
151        staging_cleanup_result.context("clean target checkpoint host staging")?;
152        let sha256 = checkpoint_sha256(&path).context("hash downloaded checkpoint")?;
153        ensure!(
154            sha256 == self.expected_sha256,
155            "target and controller checkpoint checksums differ for complete checkpoint archive: \
156             session={}, operation={}, expected_sha256={}, downloaded_sha256={}, downloaded_bytes={}; \
157             target archive retained at {}. The previous verified checkpoint was not replaced and \
158             the source workspace was not removed. Preserve the source and retry a fresh export; \
159             if this repeats, inspect the retained archive and its transfer path",
160            self.session_id,
161            self.operation_id,
162            self.expected_sha256,
163            sha256,
164            fs::metadata(&path)
165                .context("stat downloaded checkpoint")?
166                .len(),
167            self.remote_archive,
168        );
169        temporary
170            .persist(self.destination)
171            .map_err(|error| error.error)?;
172        // The bytes were already checksum-verified in this same directory and
173        // the rename is atomic, so installation only has to make the copy
174        // private and durable; re-reading it would hash the same archive again.
175        let post_install = (|| -> Result<()> {
176            restrict_permissions(self.destination)?;
177            sync_directory(parent)
178        })();
179        if let Err(error) = post_install {
180            return Err(remove_failed_checkpoint_install(self.destination, error));
181        }
182        Ok(VerifiedCheckpoint {
183            session_id: self.session_id.to_owned(),
184            archive_path: self.destination.to_path_buf(),
185            sha256,
186            event_frontier: self.expected_event_frontier,
187            event_frontier_digest: self.expected_event_frontier_digest.to_owned(),
188        })
189    }
190
191    pub fn cleanup_plan(&self, gate: &VerifiedCheckpoint) -> Result<CommandPlan> {
192        ensure!(
193            gate.session_id == self.session_id,
194            "checkpoint gate belongs to another session"
195        );
196        cleanup_plan(self.locator, self.session_id, self.remote_archive)
197    }
198}
199
200/// SSH container transfers use a host-side copy because `scp` cannot address
201/// a path inside the container. The copy is disposable and must be removed as
202/// soon as it has been downloaded; the in-container archive remains gated by
203/// [`CheckpointTransfer::cleanup_plan`] until the local copy is verified.
204fn cleanup_transfer_staging(
205    locator: &TargetLocator,
206    staging: &str,
207    executor: &impl CommandExecutor,
208) -> Result<()> {
209    validate_remote_path(staging)?;
210    let command = match locator {
211        TargetLocator::SshPodman { ssh, .. } | TargetLocator::SshDocker { ssh, .. } => Some(
212            ssh_command(ssh, ["rm", "-f", "--", staging])
213                .purpose("remove remote checkpoint staging"),
214        ),
215        _ => None,
216    };
217    if let Some(command) = command {
218        let output = executor.execute(&command)?;
219        if output.status != 0 {
220            bail!(
221                "{} failed with status {}: {}",
222                command.purpose,
223                output.status,
224                String::from_utf8_lossy(&output.stderr)
225            );
226        }
227    }
228    Ok(())
229}
230
231pub fn transfer_plan(
232    locator: &TargetLocator,
233    session_id: &str,
234    remote_archive: &str,
235    local_temporary: &Path,
236    staging: &str,
237) -> Result<CommandPlan> {
238    validate_remote_path(remote_archive)?;
239    validate_remote_path(staging)?;
240    ensure!(
241        local_temporary.is_absolute(),
242        "local temporary path must be absolute"
243    );
244    worker_root(locator, session_id)?;
245    let local = local_temporary.to_string_lossy().into_owned();
246    let mut commands = match locator {
247        TargetLocator::LocalBare { .. } => vec![
248            CommandSpec::new("cp", [remote_archive, local.as_str()])
249                .purpose("copy local bare checkpoint"),
250        ],
251        TargetLocator::LocalPodman { container_id, .. } => vec![
252            CommandSpec::new(
253                "podman",
254                ["cp", &format!("{container_id}:{remote_archive}"), &local],
255            )
256            .purpose("download checkpoint from local Podman"),
257        ],
258        TargetLocator::LocalDocker { container_id } => vec![
259            CommandSpec::new(
260                "docker",
261                ["cp", &format!("{container_id}:{remote_archive}"), &local],
262            )
263            .purpose("download checkpoint from local Docker"),
264        ],
265        TargetLocator::AppleContainer { container_id } => vec![
266            CommandSpec::new(
267                "container",
268                ["cp", &format!("{container_id}:{remote_archive}"), &local],
269            )
270            .purpose("download checkpoint from Apple container"),
271        ],
272        TargetLocator::AwsEc2 { ssh, .. } | TargetLocator::SshBare { ssh, .. } => {
273            vec![scp_command(ssh, remote_archive, &local).purpose("download checkpoint over SSH")]
274        }
275        TargetLocator::SshPodman {
276            ssh, container_id, ..
277        }
278        | TargetLocator::SshDocker { ssh, container_id } => {
279            vec![
280                ssh_command(ssh, ["mkdir", "-p", ".local/share/hel/transfers"])
281                    .purpose("create remote checkpoint staging directory"),
282                ssh_command(
283                    ssh,
284                    [
285                        locator.container_engine().expect("remote container"),
286                        "cp",
287                        &format!("{container_id}:{remote_archive}"),
288                        staging,
289                    ],
290                )
291                .purpose("stage remote container checkpoint"),
292            ]
293        }
294    };
295    if let TargetLocator::SshPodman { ssh, .. } | TargetLocator::SshDocker { ssh, .. } = locator {
296        commands.push(
297            scp_command(ssh, staging, &local)
298                .purpose("download remote container checkpoint over SSH"),
299        );
300    }
301    Ok(CommandPlan {
302        description: format!("download checkpoint for {session_id}"),
303        commands,
304    })
305}
306
307fn cleanup_plan(locator: &TargetLocator, session_id: &str, remote: &str) -> Result<CommandPlan> {
308    validate_remote_path(remote)?;
309    worker_root(locator, session_id)?;
310    let commands = match locator {
311        TargetLocator::LocalBare { .. } => vec![
312            CommandSpec::new("rm", ["-f", "--", remote])
313                .purpose("remove local bare checkpoint staging"),
314        ],
315        TargetLocator::LocalPodman { container_id, .. } => vec![container_exec(
316            "podman",
317            container_id,
318            ["rm", "-f", "--", remote],
319        )],
320        TargetLocator::LocalDocker { container_id } => vec![container_exec(
321            "docker",
322            container_id,
323            ["rm", "-f", "--", remote],
324        )],
325        TargetLocator::AppleContainer { container_id } => vec![container_exec(
326            "container",
327            container_id,
328            ["rm", "-f", "--", remote],
329        )],
330        TargetLocator::AwsEc2 { ssh, .. } | TargetLocator::SshBare { ssh, .. } => {
331            vec![ssh_command(ssh, ["rm", "-f", "--", remote])]
332        }
333        TargetLocator::SshPodman {
334            ssh, container_id, ..
335        }
336        | TargetLocator::SshDocker { ssh, container_id } => vec![ssh_command(
337            ssh,
338            [
339                locator.container_engine().expect("remote container"),
340                "exec",
341                container_id,
342                "rm",
343                "-f",
344                "--",
345                remote,
346            ],
347        )],
348    };
349    Ok(CommandPlan {
350        description: format!("clean checkpoint for {session_id}"),
351        commands,
352    })
353}
354
355fn remote_staging_path(session_id: &str, operation_id: &str) -> Result<String> {
356    validate_component(session_id, "session ID")?;
357    validate_component(operation_id, "checkpoint operation ID")?;
358    Ok(format!(
359        ".local/share/hel/transfers/{session_id}-{operation_id}.hel.zip"
360    ))
361}
362
363fn scp_command(ssh: &SshTarget, remote: &str, local: &str) -> CommandSpec {
364    let mut args = ssh.ssh_args.clone();
365    for argument in &mut args {
366        if argument == "-p" {
367            *argument = "-P".into();
368        }
369    }
370    args.push(format!("{}:{remote}", ssh.destination));
371    args.push(local.into());
372    CommandSpec::new("scp", args)
373}
374
375fn ssh_command(ssh: &SshTarget, args: impl IntoIterator<Item = impl AsRef<str>>) -> CommandSpec {
376    let remote = args
377        .into_iter()
378        .map(|arg| arg.as_ref().to_owned())
379        .collect::<Vec<_>>();
380    let mut command = ssh.ssh_args.clone();
381    command.push(ssh.destination.clone());
382    command.push(join_remote_command(&remote));
383    CommandSpec::new("ssh", command)
384}
385
386fn container_exec(
387    engine: &str,
388    id: &str,
389    args: impl IntoIterator<Item = impl Into<String>>,
390) -> CommandSpec {
391    let mut command = vec!["exec".into(), "-i".into(), id.into()];
392    command.extend(args.into_iter().map(Into::into));
393    CommandSpec::new(engine, command)
394}
395
396fn validate_remote_path(path: &str) -> Result<()> {
397    ensure!(!path.is_empty());
398    ensure!(
399        path.bytes()
400            .all(|byte| byte.is_ascii_alphanumeric()
401                || matches!(byte, b'/' | b'~' | b'.' | b'-' | b'_')),
402        "unsafe remote path"
403    );
404    ensure!(
405        !path.split('/').any(|component| component == ".."),
406        "remote path traverses parent"
407    );
408    Ok(())
409}
410
411#[cfg(test)]
412mod tests {
413    use mj_core::config::HarnessKind;
414    use serde_json::json;
415
416    use super::*;
417    use mj_checkpoint::archive::*;
418    use mj_worker::checkpoint::*;
419    use std::cell::RefCell;
420    use std::process::Command;
421
422    use mj_checkpoint::archive::{
423        CanonicalExecutionState, CanonicalQueuedCommandKind, CanonicalQueuedPrompt,
424        CanonicalSessionState, CanonicalTranscriptItem,
425    };
426    use mj_core::targets::CommandOutput;
427
428    const SESSION: &str = "018f9dd2-a3b4-7c8d-9000-123456789abc";
429    const NATIVE: &str = "0190aabb-ccdd-7eef-9000-abcdef012345";
430
431    fn ssh() -> SshTarget {
432        SshTarget {
433            destination: "dev@example.test".into(),
434            ssh_args: vec!["-p".into(), "2222".into()],
435        }
436    }
437
438    fn locators() -> Vec<TargetLocator> {
439        let name = mj_core::targets::resource_name(SESSION).unwrap();
440        vec![
441            TargetLocator::LocalBare {
442                worker_root: format!("/var/lib/hel/workers/{SESSION}"),
443            },
444            TargetLocator::LocalPodman {
445                container_id: name.clone(),
446                workspace_storage: Default::default(),
447            },
448            TargetLocator::AppleContainer {
449                container_id: name.clone(),
450            },
451            TargetLocator::AwsEc2 {
452                profile: "default".into(),
453                region: "us-east-1".into(),
454                instance_id: "i-0123456789abcdef0".into(),
455                ssh: ssh(),
456                workspace: format!("~/hel/{SESSION}"),
457            },
458            TargetLocator::SshBare {
459                worker_id: None,
460                ssh: ssh(),
461                workspace: format!("~/hel/{SESSION}"),
462            },
463            TargetLocator::SshPodman {
464                ssh: ssh(),
465                container_id: name,
466                workspace_storage: Default::default(),
467            },
468        ]
469    }
470
471    #[test]
472    fn transfer_plans_cover_all_target_boundaries() {
473        let locators = locators();
474        let plans = locators
475            .iter()
476            .map(|locator| {
477                transfer_plan(
478                    locator,
479                    SESSION,
480                    "/var/lib/hel/workers/checkpoint.hel.zip",
481                    Path::new("/var/tmp/checkpoint.zip"),
482                    &remote_staging_path(SESSION, "test-transfer").unwrap(),
483                )
484                .unwrap()
485            })
486            .collect::<Vec<_>>();
487        assert_eq!(plans[0].commands[0].program, "cp");
488        assert_eq!(plans[1].commands[0].program, "podman");
489        assert_eq!(plans[2].commands[0].program, "container");
490        assert_eq!(plans[3].commands[0].program, "scp");
491        assert_eq!(plans[4].commands[0].program, "scp");
492        assert_eq!(plans[5].commands.len(), 3);
493        assert!(
494            plans[5].commands[1]
495                .args
496                .last()
497                .unwrap()
498                .contains("'podman' 'cp'")
499        );
500        assert!(
501            !plans[5]
502                .commands
503                .iter()
504                .flat_map(|command| &command.args)
505                .any(|arg| arg == "--remote")
506        );
507        assert!(plans[3].commands[0].args.contains(&"-P".into()));
508    }
509
510    fn git(repository: &Path, args: &[&str]) -> String {
511        let output = Command::new("git")
512            .args(args)
513            .current_dir(repository)
514            .output()
515            .unwrap();
516        assert!(
517            output.status.success(),
518            "git {args:?}: {}",
519            String::from_utf8_lossy(&output.stderr)
520        );
521        String::from_utf8(output.stdout).unwrap().trim().into()
522    }
523
524    fn fixture(temp: &Path) -> (CheckpointExportSpec, PathBuf) {
525        let worker_root = temp.join("worker");
526        fs::create_dir_all(&worker_root).unwrap();
527        let harness_home = temp.join("codex");
528        let native = harness_home.join("sessions/2026/08/09");
529        fs::create_dir_all(&native).unwrap();
530        fs::write(native.join(format!("rollout-{NATIVE}.jsonl")), b"native").unwrap();
531        let workspace = temp.join("workspace");
532        let repository = workspace.join("app");
533        fs::create_dir_all(&repository).unwrap();
534        git(&repository, &["init"]);
535        git(&repository, &["config", "user.email", "hel@example.test"]);
536        git(&repository, &["config", "user.name", "Hel Test"]);
537        fs::write(repository.join("README.md"), b"hello").unwrap();
538        git(&repository, &["add", "."]);
539        git(&repository, &["commit", "-m", "base"]);
540        git(
541            &repository,
542            &[
543                "remote",
544                "add",
545                "origin",
546                "https://github.com/example/app.git",
547            ],
548        );
549        let base = git(&repository, &["rev-parse", "HEAD"]);
550        let output = worker_root.join("source.hel.zip");
551        (
552            CheckpointExportSpec {
553                protocol_version: CHECKPOINT_EXPORT_PROTOCOL_VERSION,
554                session: SessionManifest {
555                    id: SESSION.into(),
556                    title: "test".into(),
557                    harness_kind: HarnessKind::Codex,
558                    profile_id: "codex-1".into(),
559                    native_session_id: NATIVE.into(),
560                    created_at: "2026-08-09T00:00:00Z".into(),
561                    checkpointed_at: "2026-08-09T00:01:00Z".into(),
562                    hel_version: "0.1.0".into(),
563                    relay_version: "0.1.0".into(),
564                    adapter_version: "test".into(),
565                },
566                target: TargetManifest {
567                    template_id: "local".into(),
568                    target_kind: "podman".into(),
569                    details: Default::default(),
570                },
571                bundle: BundleManifest {
572                    id: "bundle".into(),
573                    primary_repository: "app".into(),
574                },
575                relay_root: worker_root,
576                harness_home,
577                workspace_root: workspace,
578                repositories: vec![CheckpointRepositorySpec {
579                    id: "app".into(),
580                    relative_destination: "app".into(),
581                    capture: CheckpointRepositoryCapture::DeltaFrom { base_commit: base },
582                    origin_override: None,
583                }],
584                canonical_session: CanonicalSessionSnapshot {
585                    event_frontier: 1,
586                    event_frontier_digest: "a".repeat(64),
587                    session: CanonicalSessionState {
588                        execution: CanonicalExecutionState::Idle,
589                        last_activity_at_ms: Some(1),
590                        session_title: Some("test".into()),
591                        configuration: Default::default(),
592                    },
593                    transcript: vec![CanonicalTranscriptItem {
594                        stable_id: "user-1".into(),
595                        position: 1,
596                        latest_content_event_ordinal: None,
597                        created_at_ms: 1,
598                        last_changed_at_ms: 1,
599                        body: CanonicalTranscriptBody::User {
600                            content: vec![json!({"type": "text", "text": "hello"})],
601                        },
602                    }],
603                    queued_prompts: vec![CanonicalQueuedPrompt {
604                        command_id: "queued-1".into(),
605                        kind: CanonicalQueuedCommandKind::Prompt,
606                        content: vec![json!({"type": "text", "text": "next"})],
607                        queued_at_ms: 2,
608                    }],
609                },
610                output_path: output.clone(),
611            },
612            output,
613        )
614    }
615
616    struct CopyExecutor {
617        source: PathBuf,
618        calls: RefCell<usize>,
619    }
620    impl CommandExecutor for CopyExecutor {
621        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
622            *self.calls.borrow_mut() += 1;
623            fs::copy(
624                &self.source,
625                command.args.last().context("missing destination")?,
626            )?;
627            Ok(CommandOutput {
628                status: 0,
629                stdout: vec![],
630                stderr: vec![],
631            })
632        }
633    }
634
635    struct SshDockerTransferExecutor {
636        archive: Vec<u8>,
637        commands: RefCell<Vec<CommandSpec>>,
638        fail_download: bool,
639        fail_staging_cleanup: bool,
640    }
641
642    impl SshDockerTransferExecutor {
643        fn new(archive: Vec<u8>) -> Self {
644            Self {
645                archive,
646                commands: RefCell::new(Vec::new()),
647                fail_download: false,
648                fail_staging_cleanup: false,
649            }
650        }
651    }
652
653    impl CommandExecutor for SshDockerTransferExecutor {
654        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
655            self.commands.borrow_mut().push(command.clone());
656            let remote = command.args.last().map(String::as_str).unwrap_or_default();
657            if command.program == "scp" {
658                if self.fail_download {
659                    return Ok(CommandOutput {
660                        status: 23,
661                        stdout: Vec::new(),
662                        stderr: b"scp unavailable".to_vec(),
663                    });
664                }
665                fs::write(
666                    command
667                        .args
668                        .last()
669                        .context("missing local checkpoint path")?,
670                    &self.archive,
671                )?;
672            }
673            if command.program == "ssh"
674                && remote.contains("'rm' '-f' '--' '.local/share/hel/transfers/")
675                && self.fail_staging_cleanup
676            {
677                return Ok(CommandOutput {
678                    status: 19,
679                    stdout: Vec::new(),
680                    stderr: b"staging cleanup unavailable".to_vec(),
681                });
682            }
683            Ok(CommandOutput {
684                status: 0,
685                stdout: Vec::new(),
686                stderr: Vec::new(),
687            })
688        }
689    }
690
691    fn ssh_docker_locator() -> TargetLocator {
692        TargetLocator::SshDocker {
693            ssh: ssh(),
694            container_id: mj_core::targets::resource_name(SESSION).unwrap(),
695        }
696    }
697
698    #[test]
699    fn export_and_transfer_only_gate_after_local_verification() {
700        let temp = tempfile::tempdir().unwrap();
701        let (spec, source) = fixture(temp.path());
702        let target = export_checkpoint(&spec).unwrap();
703        assert_eq!(target.event_frontier, 1);
704        assert_eq!(
705            target.event_frontier_digest,
706            spec.canonical_session.event_frontier_digest
707        );
708        let destination = temp.path().join("controller/session.hel.zip");
709        let locator = &locators()[0];
710        let gate = CheckpointTransfer {
711            locator,
712            session_id: SESSION,
713            operation_id: "test-transfer",
714            remote_archive: "/var/lib/hel/workers/source.hel.zip",
715            destination: &destination,
716            expected_sha256: &target.sha256,
717            expected_event_frontier: 1,
718            expected_event_frontier_digest: &spec.canonical_session.event_frontier_digest,
719        }
720        .execute(&CopyExecutor {
721            source,
722            calls: RefCell::new(0),
723        })
724        .unwrap();
725        assert!(gate.teardown_allowed());
726        assert_eq!(gate.event_frontier(), 1);
727        assert_eq!(
728            gate.event_frontier_digest(),
729            spec.canonical_session.event_frontier_digest
730        );
731        assert_eq!(
732            read_archive_verified(&destination).unwrap().archive_sha256,
733            gate.sha256()
734        );
735    }
736
737    /// The controller streams the export spec to save a round trip to the
738    /// target. Both spellings have to produce the same archive.
739    #[test]
740    fn a_streamed_spec_exports_the_same_archive_as_a_spec_file() {
741        let temp = tempfile::tempdir().unwrap();
742        let (mut spec, _) = fixture(temp.path());
743        let from_file = export_from_spec_file(&spec.output_path.with_extension("spec.json"))
744            .err()
745            .map(|error| format!("{error:#}"));
746        assert!(
747            from_file.is_some_and(|error| error.contains("read checkpoint export spec")),
748            "a missing spec file must still be reported as a read failure"
749        );
750
751        let spec_path = temp.path().join("checkpoint-spec.json");
752        spec.write(&spec_path).unwrap();
753        let from_file = export_from_spec_file(&spec_path).unwrap();
754        let file_archive = fs::read(&spec.output_path).unwrap();
755
756        spec.output_path = temp.path().join("worker/streamed.hel.zip");
757        let body = serde_json::to_vec(&spec).unwrap();
758        let streamed = export_from_spec_reader(&mut body.as_slice()).unwrap();
759        let streamed_archive = fs::read(&spec.output_path).unwrap();
760
761        assert_eq!(streamed.sha256, from_file.sha256);
762        assert_eq!(streamed.event_frontier, from_file.event_frontier);
763        assert_eq!(
764            streamed.event_frontier_digest,
765            from_file.event_frontier_digest
766        );
767        assert_eq!(streamed_archive, file_archive);
768        assert_eq!(
769            read_archive_verified(&spec.output_path)
770                .unwrap()
771                .archive_sha256,
772            streamed.sha256
773        );
774    }
775
776    #[test]
777    fn transfer_rejects_a_target_checksum_mismatch() {
778        let temp = tempfile::tempdir().unwrap();
779        let (spec, source) = fixture(temp.path());
780        export_checkpoint(&spec).unwrap();
781        let destination = temp.path().join("controller/session.hel.zip");
782        let unexpected_sha256 = "b".repeat(64);
783
784        let error = CheckpointTransfer {
785            locator: &locators()[0],
786            session_id: SESSION,
787            operation_id: "test-transfer",
788            remote_archive: "/var/lib/hel/workers/source.hel.zip",
789            destination: &destination,
790            expected_sha256: &unexpected_sha256,
791            expected_event_frontier: 1,
792            expected_event_frontier_digest: &spec.canonical_session.event_frontier_digest,
793        }
794        .execute(&CopyExecutor {
795            source,
796            calls: RefCell::new(0),
797        })
798        .unwrap_err();
799
800        assert!(format!("{error:#}").contains("checkpoint checksums differ"));
801        assert!(!destination.exists());
802    }
803
804    #[test]
805    fn ssh_docker_failed_hash_cleans_host_staging_but_preserves_container_archive() {
806        let temp = tempfile::tempdir().unwrap();
807        let destination = temp.path().join("controller/session.hel.zip");
808        let executor = SshDockerTransferExecutor::new(vec![b'x'; 128 * 1024]);
809        let error = CheckpointTransfer {
810            locator: &ssh_docker_locator(),
811            session_id: SESSION,
812            operation_id: "test-transfer",
813            remote_archive: "/var/lib/hel/workers/source.hel.zip",
814            destination: &destination,
815            expected_sha256: &"0".repeat(64),
816            expected_event_frontier: 1,
817            expected_event_frontier_digest: &"a".repeat(64),
818        }
819        .execute(&executor)
820        .unwrap_err();
821
822        assert!(format!("{error:#}").contains("checkpoint checksums differ"));
823        assert!(!destination.exists());
824        let commands = executor.commands.borrow();
825        assert!(commands.iter().any(|command| {
826            command.program == "ssh"
827                && command.args.last().is_some_and(|remote| {
828                    remote.contains("'rm' '-f' '--' '.local/share/hel/transfers/")
829                })
830        }));
831        assert!(!commands.iter().any(|command| {
832            command
833                .args
834                .last()
835                .is_some_and(|remote| remote.contains("'docker' 'exec'"))
836        }));
837    }
838
839    #[test]
840    fn ssh_docker_download_and_staging_cleanup_errors_keep_the_original_failure() {
841        let temp = tempfile::tempdir().unwrap();
842        let destination = temp.path().join("controller/session.hel.zip");
843        let mut executor = SshDockerTransferExecutor::new(vec![b'x'; 128 * 1024]);
844        executor.fail_download = true;
845        executor.fail_staging_cleanup = true;
846        let error = CheckpointTransfer {
847            locator: &ssh_docker_locator(),
848            session_id: SESSION,
849            operation_id: "test-transfer",
850            remote_archive: "/var/lib/hel/workers/source.hel.zip",
851            destination: &destination,
852            expected_sha256: &"0".repeat(64),
853            expected_event_frontier: 1,
854            expected_event_frontier_digest: &"a".repeat(64),
855        }
856        .execute(&executor)
857        .unwrap_err();
858
859        let text = format!("{error:#}");
860        assert!(text.contains("download target checkpoint"), "{text}");
861        assert!(text.contains("scp unavailable"), "{text}");
862        assert!(
863            text.contains("clean target checkpoint host staging also failed")
864                && text.contains("staging cleanup unavailable"),
865            "{text}"
866        );
867        let commands = executor.commands.borrow();
868        assert!(commands.iter().any(|command| {
869            command.program == "ssh"
870                && command.args.last().is_some_and(|remote| {
871                    remote.contains("'rm' '-f' '--' '.local/share/hel/transfers/")
872                })
873        }));
874        assert!(!commands.iter().any(|command| {
875            command
876                .args
877                .last()
878                .is_some_and(|remote| remote.contains("'docker' 'exec'"))
879        }));
880    }
881
882    #[test]
883    fn overlapping_remote_transfers_keep_their_own_bytes_and_cleanup() {
884        use std::collections::BTreeMap;
885        use std::sync::{Condvar, Mutex};
886        use std::time::Duration;
887
888        #[derive(Default)]
889        struct Staging {
890            files: BTreeMap<String, Vec<u8>>,
891            copies: usize,
892            first_cleaned: bool,
893        }
894        struct InterleavedExecutor<'a> {
895            staging: &'a (Mutex<Staging>, Condvar),
896            archive: &'a [u8],
897            first: bool,
898        }
899        impl CommandExecutor for InterleavedExecutor<'_> {
900            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
901                let (mutex, changed) = self.staging;
902                let remote = command.args.last().unwrap();
903                // Fixture paths contain no quotes or spaces. Inspect the final
904                // remote argument, so a shared staging path really collides.
905                let remote_path = || {
906                    remote
907                        .rsplit(' ')
908                        .next()
909                        .unwrap()
910                        .trim_matches('\'')
911                        .to_owned()
912                };
913                match command.purpose.as_str() {
914                    "stage remote container checkpoint" => {
915                        let mut state = mutex.lock().unwrap();
916                        state.files.insert(remote_path(), self.archive.to_vec());
917                        state.copies += 1;
918                        changed.notify_all();
919                    }
920                    "download remote container checkpoint over SSH" => {
921                        let (state, timeout) = changed
922                            .wait_timeout_while(
923                                mutex.lock().unwrap(),
924                                Duration::from_secs(5),
925                                |state| state.copies < 2 || (!self.first && !state.first_cleaned),
926                            )
927                            .unwrap();
928                        ensure!(!timeout.timed_out(), "interleaved transfer stalled");
929                        let source = command.args[command.args.len() - 2]
930                            .split_once(':')
931                            .unwrap()
932                            .1;
933                        let bytes = state
934                            .files
935                            .get(source)
936                            .context("other transfer removed staging")?;
937                        fs::write(remote, bytes)?;
938                    }
939                    "remove remote checkpoint staging" => {
940                        let mut state = mutex.lock().unwrap();
941                        state.files.remove(&remote_path());
942                        if self.first {
943                            state.first_cleaned = true;
944                            changed.notify_all();
945                        }
946                    }
947                    "create remote checkpoint staging directory" => {}
948                    purpose => bail!("unexpected transfer command: {purpose}"),
949                }
950                Ok(CommandOutput {
951                    status: 0,
952                    stdout: Vec::new(),
953                    stderr: Vec::new(),
954                })
955            }
956        }
957
958        for locator in [locators().pop().unwrap(), ssh_docker_locator()] {
959            let directory = tempfile::tempdir().unwrap();
960            let staging = (Mutex::new(Staging::default()), Condvar::new());
961            let first = vec![b'a'; 192 * 1024];
962            let second = vec![b'b'; 256 * 1024];
963            std::thread::scope(|scope| {
964                let run = |operation, bytes: &[u8], is_first| {
965                    let source = directory.path().join(format!("{operation}-source.zip"));
966                    fs::write(&source, bytes).unwrap();
967                    let destination = directory.path().join(format!("{operation}-verified.zip"));
968                    let digest = checkpoint_sha256(&source).unwrap();
969                    let gate = CheckpointTransfer {
970                        locator: &locator,
971                        session_id: SESSION,
972                        operation_id: operation,
973                        remote_archive: &format!("/workers/{operation}.zip"),
974                        destination: &destination,
975                        expected_sha256: &digest,
976                        expected_event_frontier: 1,
977                        expected_event_frontier_digest: &"a".repeat(64),
978                    }
979                    .execute(&InterleavedExecutor {
980                        staging: &staging,
981                        archive: bytes,
982                        first: is_first,
983                    })
984                    .unwrap();
985                    assert_eq!(fs::read(gate.archive_path()).unwrap(), bytes);
986                    assert_eq!(gate.sha256(), digest);
987                };
988                let first_run = scope.spawn(move || run("first", &first, true));
989                let second_run = scope.spawn(move || run("second", &second, false));
990                first_run.join().unwrap();
991                second_run.join().unwrap();
992            });
993            assert!(staging.0.lock().unwrap().files.is_empty());
994        }
995    }
996
997    #[test]
998    fn corrupt_or_truncated_transfer_preserves_previous_checkpoint_and_reports_evidence() {
999        let temp = tempfile::tempdir().unwrap();
1000        let source = temp.path().join("source.zip");
1001        let original = vec![b'a'; 192 * 1024];
1002        fs::write(&source, &original).unwrap();
1003        let expected_sha256 = checkpoint_sha256(&source).unwrap();
1004        let mut corrupt = original.clone();
1005        corrupt[80 * 1024] = b'b';
1006        for bytes in [corrupt, original[..1024].to_vec()] {
1007            let downloaded = temp.path().join("download.zip");
1008            fs::write(&downloaded, &bytes).unwrap();
1009            let actual_sha256 = checkpoint_sha256(&downloaded).unwrap();
1010            let destination = temp.path().join("previous.zip");
1011            fs::write(&destination, b"previous verified checkpoint").unwrap();
1012            let error = CheckpointTransfer {
1013                locator: &ssh_docker_locator(),
1014                session_id: SESSION,
1015                operation_id: "failed-export",
1016                remote_archive: "/workers/failed-export.zip",
1017                destination: &destination,
1018                expected_sha256: &expected_sha256,
1019                expected_event_frontier: 1,
1020                expected_event_frontier_digest: &"a".repeat(64),
1021            }
1022            .execute(&SshDockerTransferExecutor::new(bytes.clone()))
1023            .unwrap_err();
1024            let detail = format!("{error:#}");
1025            for expected in [
1026                "complete checkpoint archive",
1027                SESSION,
1028                "failed-export",
1029                &expected_sha256,
1030                &actual_sha256,
1031                &format!("downloaded_bytes={}", bytes.len()),
1032                "target archive retained at /workers/failed-export.zip",
1033                "retry a fresh export",
1034            ] {
1035                assert!(
1036                    detail.contains(expected),
1037                    "missing {expected:?} from {detail}"
1038                );
1039            }
1040            assert_eq!(
1041                fs::read(&destination).unwrap(),
1042                b"previous verified checkpoint"
1043            );
1044            assert_eq!(fs::read(&source).unwrap(), original);
1045        }
1046    }
1047}