@@ -287,16 +287,28 @@ async fn execute(
287287 let mut layer_progress: std:: collections:: HashMap < String , ( i64 , i64 ) > =
288288 std:: collections:: HashMap :: new ( ) ;
289289 let mut last_progress_log = Instant :: now ( ) ;
290+ let mut pull_error: Option < String > = None ;
290291 while let Some ( progress) = pull. next ( ) . await {
291292 if * cancel. borrow ( ) {
292293 return ( JobConclusion :: Cancelled , None , None ) ;
293294 }
294295 match progress {
295296 Err ( error) => {
296- log . system ( & format ! ( "image pull failed: { error}" ) ) . await ;
297- return fail ( "image_pull_failed" ) ;
297+ pull_error = Some ( error. to_string ( ) ) ;
298+ break ;
298299 }
299300 Ok ( info) => {
301+ // bollard maps `errorDetail` frames with a message to stream
302+ // errors; a message-less one would otherwise slip through.
303+ if let Some ( detail) = & info. error_detail {
304+ pull_error = Some (
305+ detail
306+ . message
307+ . clone ( )
308+ . unwrap_or_else ( || "unknown pull error" . to_string ( ) ) ,
309+ ) ;
310+ break ;
311+ }
300312 if let ( Some ( id) , Some ( detail) ) = ( & info. id , & info. progress_detail )
301313 && let ( Some ( current) , Some ( total) ) = ( detail. current , detail. total )
302314 && total > 0
@@ -323,6 +335,20 @@ async fn execute(
323335 }
324336 }
325337 }
338+ if let Some ( error) = pull_error {
339+ // Pulls fail transiently (registry hiccups, daemon layer-extraction
340+ // errors) even when a usable copy of the image is already on the
341+ // daemon — fall back to it rather than failing the job.
342+ if docker. inspect_image ( & payload. image ) . await . is_ok ( ) {
343+ log. system ( & format ! (
344+ "image pull failed ({error}); using locally cached image"
345+ ) )
346+ . await ;
347+ } else {
348+ log. system ( & format ! ( "image pull failed: {error}" ) ) . await ;
349+ return fail ( "image_pull_failed" ) ;
350+ }
351+ }
326352 metrics. image_pull_ms = Some ( pull_started. elapsed ( ) . as_millis ( ) as u64 ) ;
327353 log. system ( & format ! (
328354 "image ready in {:.1}s" ,
@@ -442,14 +468,15 @@ async fn execute(
442468 // --- checkout (upload source into the container) ------------------------
443469 // Now that /workspace exists inside the container, stream the repackaged
444470 // source into it over the archive API.
445- if let Some ( tar ) = source_tar {
471+ if let Some ( source ) = source_tar {
446472 log. section ( Some ( "checkout" ) , None ) ;
447- if let Err ( error) = upload_source ( & docker, & container, tar) . await {
473+ let files = source. files ;
474+ if let Err ( error) = upload_source ( & docker, & container, source. tar ) . await {
448475 log. system ( & format ! ( "checkout failed: {error:#}" ) ) . await ;
449476 cleanup ( & docker, Some ( & container) , network. as_deref ( ) ) . await ;
450477 return fail ( "checkout_failed" ) ;
451478 }
452- log. system ( "checkout complete" ) . await ;
479+ log. system ( & format ! ( "checkout complete ({files} files)" ) ) . await ;
453480 }
454481
455482 // Resource telemetry for the Performance tab; failures only cost the
@@ -766,16 +793,25 @@ fn format_mib(bytes: i64) -> String {
766793 format ! ( "{:.0} MiB" , bytes. max( 0 ) as f64 / ( 1024.0 * 1024.0 ) )
767794}
768795
796+ /// A repackaged source archive: the uncompressed tar bytes plus how many
797+ /// regular files survived filtering. Zero files means the tarball had an
798+ /// unexpected layout — uploading it would leave /workspace empty while the
799+ /// log claims a successful checkout, so callers treat it as a failure.
800+ struct RepackagedSource {
801+ tar : Vec < u8 > ,
802+ files : u64 ,
803+ }
804+
769805/// Download the repository tarball (size-capped) and repackage it into a
770806/// plain tar with the GitHub top-level directory stripped, ready to stream
771807/// into the container's /workspace. Only plain relative path segments survive
772808/// — entries with `..`, absolute paths, or prefixes are dropped, as are
773809/// symlink/hardlink entries (directory-traversal defense; the path check
774- /// can't validate a link TARGET). Returns the uncompressed tar bytes .
810+ /// can't validate a link TARGET). Fails if no files survive .
775811async fn fetch_and_repackage (
776812 http : & reqwest:: Client ,
777813 checkout : & protocol:: Checkout ,
778- ) -> anyhow:: Result < Vec < u8 > > {
814+ ) -> anyhow:: Result < RepackagedSource > {
779815 let response = http
780816 . get ( & checkout. tarball_url )
781817 . bearer_auth ( & checkout. token )
@@ -805,20 +841,25 @@ async fn fetch_and_repackage(
805841 drop ( file) ;
806842
807843 let tar_path = tarball. path ( ) . to_path_buf ( ) ;
808- tokio:: task:: spawn_blocking ( move || repackage_stripped ( & tar_path) )
844+ let source = tokio:: task:: spawn_blocking ( move || repackage_stripped ( & tar_path) )
809845 . await
810- . context ( "repackage task panicked" ) ?
846+ . context ( "repackage task panicked" ) ??;
847+ if source. files == 0 {
848+ anyhow:: bail!( "repository archive contained no usable files (unexpected tarball layout)" ) ;
849+ }
850+ Ok ( source)
811851}
812852
813853/// Read the downloaded gzip tarball and re-emit an uncompressed tar with the
814854/// top-level `{owner}-{repo}-{sha}/` component stripped and unsafe entries
815855/// dropped. Entry paths are rebuilt with `/` separators so the archive is
816856/// valid for a Linux container regardless of the runner's own platform.
817- fn repackage_stripped ( tar_path : & Path ) -> anyhow:: Result < Vec < u8 > > {
857+ fn repackage_stripped ( tar_path : & Path ) -> anyhow:: Result < RepackagedSource > {
818858 let file = std:: fs:: File :: open ( tar_path) ?;
819859 let mut archive = tar:: Archive :: new ( flate2:: read:: GzDecoder :: new ( file) ) ;
820860 let mut builder = tar:: Builder :: new ( Vec :: new ( ) ) ;
821861 let mut skipped_links: u64 = 0 ;
862+ let mut files: u64 = 0 ;
822863 for entry in archive. entries ( ) ? {
823864 let mut entry = entry?;
824865
@@ -837,7 +878,12 @@ fn repackage_stripped(tar_path: &Path) -> anyhow::Result<Vec<u8>> {
837878 let path = entry. path ( ) ?. into_owned ( ) ;
838879 // Strip GitHub's top-level wrapper, keep only plain segments, and
839880 // rebuild the path with `/` so it is portable into the container.
840- let mut components = path. components ( ) ;
881+ // A leading `./` (some tar producers emit it) is consumed first so
882+ // the strip removes the wrapper dir, not the no-op dot.
883+ let mut components = path. components ( ) . peekable ( ) ;
884+ if matches ! ( components. peek( ) , Some ( Component :: CurDir ) ) {
885+ components. next ( ) ;
886+ }
841887 components. next ( ) ;
842888 let mut rel = String :: new ( ) ;
843889 let mut safe = true ;
@@ -862,12 +908,16 @@ fn repackage_stripped(tar_path: &Path) -> anyhow::Result<Vec<u8>> {
862908 // The cloned header carries the correct size/mode/mtime; append_data
863909 // sets the (stripped) path and copies exactly `size` bytes.
864910 let mut header = entry. header ( ) . clone ( ) ;
911+ if header. entry_type ( ) . is_file ( ) {
912+ files += 1 ;
913+ }
865914 builder. append_data ( & mut header, & rel, & mut entry) ?;
866915 }
867916 if skipped_links > 0 {
868917 tracing:: warn!( count = skipped_links, "skipped link entries in repository tarball" ) ;
869918 }
870- builder. into_inner ( ) . context ( "finalizing source tar failed" )
919+ let tar = builder. into_inner ( ) . context ( "finalizing source tar failed" ) ?;
920+ Ok ( RepackagedSource { tar, files } )
871921}
872922
873923/// Stream a repackaged source tar into the container's /workspace over the
@@ -977,9 +1027,9 @@ mod tests {
9771027 #[ test]
9781028 fn repackage_strips_top_level_and_drops_links ( ) {
9791029 let src = sample_source_tarball ( ) ;
980- let bytes = repackage_stripped ( src. path ( ) ) . unwrap ( ) ;
1030+ let source = repackage_stripped ( src. path ( ) ) . unwrap ( ) ;
9811031
982- let mut archive = tar:: Archive :: new ( & bytes [ ..] ) ;
1032+ let mut archive = tar:: Archive :: new ( & source . tar [ ..] ) ;
9831033 let mut paths: Vec < String > = archive
9841034 . entries ( )
9851035 . unwrap ( )
@@ -989,15 +1039,16 @@ mod tests {
9891039
9901040 // Top-level `repo-sha/` stripped; the symlink is gone.
9911041 assert_eq ! ( paths, vec![ "package.json" . to_string( ) , "src/main.rs" . to_string( ) ] ) ;
1042+ assert_eq ! ( source. files, 2 ) ;
9921043 }
9931044
9941045 #[ test]
9951046 fn repackaged_source_unpacks_to_workspace_root ( ) {
9961047 let src = sample_source_tarball ( ) ;
997- let bytes = repackage_stripped ( src. path ( ) ) . unwrap ( ) ;
1048+ let source = repackage_stripped ( src. path ( ) ) . unwrap ( ) ;
9981049
9991050 let dest = tempfile:: tempdir ( ) . unwrap ( ) ;
1000- tar:: Archive :: new ( & bytes [ ..] ) . unpack ( dest. path ( ) ) . unwrap ( ) ;
1051+ tar:: Archive :: new ( & source . tar [ ..] ) . unpack ( dest. path ( ) ) . unwrap ( ) ;
10011052
10021053 // Exactly where a step running in /workspace expects them.
10031054 assert_eq ! (
@@ -1008,4 +1059,61 @@ mod tests {
10081059 assert ! ( !dest. path( ) . join( "evil" ) . exists( ) ) ;
10091060 }
10101061
1062+ /// `./`-prefixed entries (`./repo-sha/…`) must strip the wrapper dir, not
1063+ /// the no-op dot — otherwise files land at /workspace/repo-sha/… and
1064+ /// steps see an empty workspace root.
1065+ #[ test]
1066+ fn repackage_handles_dot_prefixed_entries ( ) {
1067+ let tmp = tempfile:: NamedTempFile :: new ( ) . unwrap ( ) ;
1068+ let gz = flate2:: write:: GzEncoder :: new (
1069+ std:: fs:: File :: create ( tmp. path ( ) ) . unwrap ( ) ,
1070+ flate2:: Compression :: default ( ) ,
1071+ ) ;
1072+ let mut builder = tar:: Builder :: new ( gz) ;
1073+ for ( name, contents) in [
1074+ ( "./repo-sha/package.json" , & b"{}" [ ..] ) ,
1075+ ( "./repo-sha/src/main.rs" , b"fn main() {}" ) ,
1076+ ] {
1077+ let mut header = tar:: Header :: new_gnu ( ) ;
1078+ header. set_size ( contents. len ( ) as u64 ) ;
1079+ header. set_mode ( 0o644 ) ;
1080+ header. set_entry_type ( tar:: EntryType :: Regular ) ;
1081+ header. set_cksum ( ) ;
1082+ builder. append_data ( & mut header, name, contents) . unwrap ( ) ;
1083+ }
1084+ builder. into_inner ( ) . unwrap ( ) . finish ( ) . unwrap ( ) ;
1085+
1086+ let source = repackage_stripped ( tmp. path ( ) ) . unwrap ( ) ;
1087+ assert_eq ! ( source. files, 2 ) ;
1088+
1089+ let dest = tempfile:: tempdir ( ) . unwrap ( ) ;
1090+ tar:: Archive :: new ( & source. tar [ ..] ) . unpack ( dest. path ( ) ) . unwrap ( ) ;
1091+ assert ! ( dest. path( ) . join( "package.json" ) . is_file( ) ) ;
1092+ assert ! ( dest. path( ) . join( "src/main.rs" ) . is_file( ) ) ;
1093+ assert ! ( !dest. path( ) . join( "repo-sha" ) . exists( ) ) ;
1094+ }
1095+
1096+ /// A tarball whose entries all get filtered out (here: links only) must
1097+ /// report zero files so the caller fails the checkout instead of
1098+ /// uploading an empty archive and logging success.
1099+ #[ test]
1100+ fn repackage_reports_zero_files_when_everything_is_filtered ( ) {
1101+ let tmp = tempfile:: NamedTempFile :: new ( ) . unwrap ( ) ;
1102+ let gz = flate2:: write:: GzEncoder :: new (
1103+ std:: fs:: File :: create ( tmp. path ( ) ) . unwrap ( ) ,
1104+ flate2:: Compression :: default ( ) ,
1105+ ) ;
1106+ let mut builder = tar:: Builder :: new ( gz) ;
1107+ let mut link = tar:: Header :: new_gnu ( ) ;
1108+ link. set_entry_type ( tar:: EntryType :: Symlink ) ;
1109+ link. set_size ( 0 ) ;
1110+ link. set_cksum ( ) ;
1111+ builder
1112+ . append_link ( & mut link, "repo-sha/evil" , "/etc/passwd" )
1113+ . unwrap ( ) ;
1114+ builder. into_inner ( ) . unwrap ( ) . finish ( ) . unwrap ( ) ;
1115+
1116+ let source = repackage_stripped ( tmp. path ( ) ) . unwrap ( ) ;
1117+ assert_eq ! ( source. files, 0 ) ;
1118+ }
10111119}
0 commit comments