@@ -269,19 +269,7 @@ async fn oci_restore_resumes_vdso_free_program() {
269269 let counter_s = counter. to_str ( ) . unwrap ( ) . to_string ( ) ;
270270 let image = tmp. join ( "image" ) ;
271271
272- fs:: write ( & src, counter_source ( & counter_s) ) . unwrap ( ) ;
273-
274- let cc = if which ( "cc" ) { "cc" } else if which ( "gcc" ) { "gcc" } else {
275- eprintln ! ( "skipping: no C compiler (cc/gcc) available" ) ;
276- let _ = fs:: remove_dir_all ( & tmp) ;
277- return ;
278- } ;
279- let build = Command :: new ( cc)
280- . args ( [ "-static" , "-nostdlib" , "-no-pie" , "-O0" , "-o" ] )
281- . arg ( & bin) . arg ( & src)
282- . output ( ) . unwrap ( ) ;
283- if !build. status . success ( ) {
284- eprintln ! ( "skipping: build failed: {}" , String :: from_utf8_lossy( & build. stderr) ) ;
272+ if !build_counter ( & bin, & src, & counter_s) {
285273 let _ = fs:: remove_dir_all ( & tmp) ;
286274 return ;
287275 }
@@ -383,6 +371,253 @@ async fn oci_restore_resumes_vdso_free_program() {
383371 ) ;
384372}
385373
374+ /// Build the freestanding, vDSO-free counter program (shared with the restore
375+ /// test) into `bin`, writing its counter to the in-sandbox path `out_path`.
376+ /// Returns false (and prints a skip reason) when no C compiler is available or
377+ /// the build fails, so callers can early-return on unsupported hosts.
378+ fn build_counter ( bin : & Path , src : & Path , out_path : & str ) -> bool {
379+ fs:: write ( src, counter_source ( out_path) ) . unwrap ( ) ;
380+ let cc = if which ( "cc" ) {
381+ "cc"
382+ } else if which ( "gcc" ) {
383+ "gcc"
384+ } else {
385+ eprintln ! ( "skipping: no C compiler (cc/gcc) available" ) ;
386+ return false ;
387+ } ;
388+ let build = Command :: new ( cc)
389+ . args ( [ "-static" , "-nostdlib" , "-no-pie" , "-O0" , "-o" ] )
390+ . arg ( bin)
391+ . arg ( src)
392+ . output ( )
393+ . unwrap ( ) ;
394+ if !build. status . success ( ) {
395+ eprintln ! (
396+ "skipping: build failed: {}" ,
397+ String :: from_utf8_lossy( & build. stderr)
398+ ) ;
399+ return false ;
400+ }
401+ true
402+ }
403+
404+ /// End-to-end proof that `sandlock-oci checkpoint` works on a RUNNING container
405+ /// created + started from an OCI bundle.
406+ ///
407+ /// Before the supervisor fix, `supervisor_main` stopped serving the control
408+ /// socket once the child started (it only `wait()`ed), so a `checkpoint` of a
409+ /// running container could not be reached and timed out. This test creates +
410+ /// starts a sandbox running the vDSO-free counter, waits for it to advance
411+ /// (proving it is genuinely RUNNING), then checkpoints it and asserts the
412+ /// checkpoint image (`meta.json`) was written. As a bonus it then `restore`s the
413+ /// image into a second container and proves the restored counter advances,
414+ /// exercising a full OCI checkpoint -> restore round-trip of a running program.
415+ #[ tokio:: test( flavor = "multi_thread" ) ]
416+ async fn oci_checkpoint_of_running_container ( ) {
417+ if cfg ! ( not( target_arch = "x86_64" ) ) {
418+ eprintln ! ( "skipping: injection-based checkpoint/restore is x86_64-only" ) ;
419+ return ;
420+ }
421+ if sandlock_core:: landlock_abi_version ( ) . is_err ( ) {
422+ eprintln ! ( "skipping: Landlock unavailable on this host" ) ;
423+ return ;
424+ }
425+
426+ let tmp = std:: env:: temp_dir ( ) . join ( format ! ( "sandlock-oci-ckpt-{}" , std:: process:: id( ) ) ) ;
427+ fs:: create_dir_all ( & tmp) . unwrap ( ) ;
428+ let src = tmp. join ( "counter.c" ) ;
429+ let bin = tmp. join ( "counter" ) ;
430+
431+ // The container chroots to `rootfs`, so the counter's in-sandbox path
432+ // `/out.cnt` resolves to `rootfs/out.cnt` on the host.
433+ if !build_counter ( & bin, & src, "/out.cnt" ) {
434+ let _ = fs:: remove_dir_all ( & tmp) ;
435+ return ;
436+ }
437+
438+ // Build the OCI bundle: the freestanding binary lives inside rootfs and the
439+ // spec runs it via its in-chroot path.
440+ let bundle = tmp. join ( "bundle" ) ;
441+ let rootfs = bundle. join ( "rootfs" ) ;
442+ fs:: create_dir_all ( & rootfs) . unwrap ( ) ;
443+ let bin_in_rootfs = rootfs. join ( "counter" ) ;
444+ fs:: copy ( & bin, & bin_in_rootfs) . unwrap ( ) ;
445+ {
446+ use std:: os:: unix:: fs:: PermissionsExt ;
447+ fs:: set_permissions ( & bin_in_rootfs, fs:: Permissions :: from_mode ( 0o755 ) ) . unwrap ( ) ;
448+ }
449+ create_bundle ( & bundle, & [ "/counter" ] ) ;
450+
451+ let host_counter = rootfs. join ( "out.cnt" ) ;
452+ let host_counter_s = host_counter. to_str ( ) . unwrap ( ) . to_string ( ) ;
453+ let read_counter = |path : & str | -> Option < u64 > {
454+ fs:: read_to_string ( path)
455+ . ok ( )
456+ . and_then ( |s| s. trim ( ) . parse :: < u64 > ( ) . ok ( ) )
457+ } ;
458+
459+ let root = tempdir ( ) . unwrap ( ) ;
460+ let root_s = root. path ( ) . to_str ( ) . unwrap ( ) . to_string ( ) ;
461+ let id = "oci-ckpt-running" ;
462+ let image = tmp. join ( "image" ) ;
463+
464+ // ── create (daemonizes a supervisor that inherits stdio; redirect + status) ─
465+ let create_log = tmp. join ( "create.log" ) ;
466+ let create_status = Command :: new ( oci_bin ( ) )
467+ . args ( [ "--root" , & root_s, "create" , id, "-b" , bundle. to_str ( ) . unwrap ( ) ] )
468+ . stdout ( std:: process:: Stdio :: from ( fs:: File :: create ( & create_log) . unwrap ( ) ) )
469+ . stderr ( std:: process:: Stdio :: from (
470+ fs:: OpenOptions :: new ( ) . append ( true ) . open ( & create_log) . unwrap ( ) ,
471+ ) )
472+ . status ( )
473+ . expect ( "failed to run sandlock-oci create" ) ;
474+ let create_out = fs:: read_to_string ( & create_log) . unwrap_or_default ( ) ;
475+ assert ! (
476+ create_status. success( ) ,
477+ "create CLI failed (exit {:?}): {}" ,
478+ create_status. code( ) ,
479+ create_out
480+ ) ;
481+
482+ // ── start (releases the parked child to execve) ─────────────────────────────
483+ let start_out = Command :: new ( oci_bin ( ) )
484+ . args ( [ "--root" , & root_s, "start" , id] )
485+ . output ( )
486+ . expect ( "failed to run sandlock-oci start" ) ;
487+ if !start_out. status . success ( ) {
488+ let _ = Command :: new ( oci_bin ( ) )
489+ . args ( [ "--root" , & root_s, "delete" , id, "--force" ] )
490+ . output ( ) ;
491+ let _ = fs:: remove_dir_all ( & tmp) ;
492+ panic ! (
493+ "start CLI failed: {}" ,
494+ String :: from_utf8_lossy( & start_out. stderr)
495+ ) ;
496+ }
497+
498+ // Poll until the running container's counter advances (proves it is RUNNING).
499+ let deadline = std:: time:: Instant :: now ( ) + std:: time:: Duration :: from_secs ( 5 ) ;
500+ let mut baseline = 0u64 ;
501+ let mut running = false ;
502+ while std:: time:: Instant :: now ( ) < deadline {
503+ if let Some ( v) = read_counter ( & host_counter_s) {
504+ if v > 2 {
505+ baseline = v;
506+ running = true ;
507+ break ;
508+ }
509+ }
510+ tokio:: time:: sleep ( std:: time:: Duration :: from_millis ( 50 ) ) . await ;
511+ }
512+
513+ // ── checkpoint the RUNNING container ───────────────────────────────────────
514+ let ckpt_out = if running {
515+ Some (
516+ Command :: new ( oci_bin ( ) )
517+ . args ( [ "--root" , & root_s, "checkpoint" , id, "--image-path" , image. to_str ( ) . unwrap ( ) ] )
518+ . output ( )
519+ . expect ( "failed to run sandlock-oci checkpoint" ) ,
520+ )
521+ } else {
522+ None
523+ } ;
524+ let ckpt_ok = ckpt_out. as_ref ( ) . map ( |o| o. status . success ( ) ) . unwrap_or ( false ) ;
525+ let meta_exists = image. join ( "meta.json" ) . exists ( ) ;
526+
527+ // ── bonus: restore the checkpoint into a second container ───────────────────
528+ let id2 = "oci-ckpt-restored" ;
529+ let mut restored_advanced = None :: < bool > ;
530+ let mut restore_diag = String :: new ( ) ;
531+ if ckpt_ok && meta_exists {
532+ // Stop the original so only a restored process can advance the file, and
533+ // drop a low sentinel to prove the restored process (not a leftover) writes.
534+ let _ = Command :: new ( oci_bin ( ) )
535+ . args ( [ "--root" , & root_s, "delete" , id, "--force" ] )
536+ . output ( ) ;
537+ fs:: write ( & host_counter, b"0\n " ) . unwrap ( ) ;
538+
539+ let restore_log = tmp. join ( "restore.log" ) ;
540+ let restore_status = Command :: new ( oci_bin ( ) )
541+ . args ( [ "--root" , & root_s, "restore" , id2, "--image-path" , image. to_str ( ) . unwrap ( ) ] )
542+ . stdout ( std:: process:: Stdio :: from ( fs:: File :: create ( & restore_log) . unwrap ( ) ) )
543+ . stderr ( std:: process:: Stdio :: from (
544+ fs:: OpenOptions :: new ( ) . append ( true ) . open ( & restore_log) . unwrap ( ) ,
545+ ) )
546+ . status ( )
547+ . expect ( "failed to run sandlock-oci restore" ) ;
548+ if restore_status. success ( ) {
549+ let rdl = std:: time:: Instant :: now ( ) + std:: time:: Duration :: from_secs ( 5 ) ;
550+ let mut adv = false ;
551+ while std:: time:: Instant :: now ( ) < rdl {
552+ if let Some ( v) = read_counter ( & host_counter_s) {
553+ if v > baseline {
554+ adv = true ;
555+ break ;
556+ }
557+ }
558+ tokio:: time:: sleep ( std:: time:: Duration :: from_millis ( 50 ) ) . await ;
559+ }
560+ restored_advanced = Some ( adv) ;
561+ restore_diag = format ! (
562+ "restore_status ok; last_counter={:?}; log: {}" ,
563+ read_counter( & host_counter_s) ,
564+ fs:: read_to_string( & restore_log) . unwrap_or_default( )
565+ ) ;
566+ } else {
567+ restored_advanced = Some ( false ) ;
568+ restore_diag = format ! (
569+ "restore_status FAILED; log: {}" ,
570+ fs:: read_to_string( & restore_log) . unwrap_or_default( )
571+ ) ;
572+ }
573+ }
574+
575+ // ── clean up before asserting so a failure never leaks a process ────────────
576+ let _ = Command :: new ( oci_bin ( ) )
577+ . args ( [ "--root" , & root_s, "delete" , id, "--force" ] )
578+ . output ( ) ;
579+ let _ = Command :: new ( oci_bin ( ) )
580+ . args ( [ "--root" , & root_s, "delete" , id2, "--force" ] )
581+ . output ( ) ;
582+ let _ = fs:: remove_dir_all ( & tmp) ;
583+
584+ assert ! (
585+ running,
586+ "container counter never advanced; create_out: {create_out}"
587+ ) ;
588+ let ckpt_stderr = ckpt_out
589+ . as_ref ( )
590+ . map ( |o| String :: from_utf8_lossy ( & o. stderr ) . to_string ( ) )
591+ . unwrap_or_default ( ) ;
592+ assert ! (
593+ ckpt_ok,
594+ "checkpoint of a RUNNING container must succeed; stderr: {ckpt_stderr}"
595+ ) ;
596+ assert ! (
597+ meta_exists,
598+ "checkpoint must write meta.json to the image dir"
599+ ) ;
600+ // Bonus (non-fatal): a full OCI checkpoint -> restore round-trip. The
601+ // checkpoint-of-running assertions above are the required deliverable. The
602+ // restore engine reopens fds/mappings by their recorded HOST path, which
603+ // collides with the virtual-chroot path rewriting of a bundle-based
604+ // container (the binary at `<rootfs>/counter` gets re-confined under the
605+ // restored chroot and Landlock denies it with EACCES). The standalone
606+ // `oci_restore_resumes_vdso_free_program` test covers restore on its own,
607+ // chroot-free; restore of a *chrooted* checkpoint is a separate limitation
608+ // outside the scope of the serve-while-running fix, so we only report it.
609+ if let Some ( adv) = restored_advanced {
610+ if adv {
611+ eprintln ! ( "bonus: full OCI checkpoint -> restore round-trip advanced the counter" ) ;
612+ } else {
613+ eprintln ! (
614+ "note: bonus round-trip restore of a chrooted checkpoint did not advance \
615+ (restore-under-chroot limitation, orthogonal to this fix): {restore_diag}"
616+ ) ;
617+ }
618+ }
619+ }
620+
386621/// Minimal PATH lookup so the test does not depend on extra crates.
387622fn which ( prog : & str ) -> bool {
388623 std:: env:: var_os ( "PATH" ) . map_or ( false , |paths| {
0 commit comments