1use 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};
11pub 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#[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 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
200fn 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 #[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 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}