@@ -777,6 +777,61 @@ async fn probe_cli_version(image_ref: &str, docker: &Docker) -> Result<Version,
777777 . map_err ( |e| Error :: TagListUnavailable ( format ! ( "unparseable version {stdout:?}: {e}" ) ) )
778778}
779779
780+ /// Probe the image for the toolchain rustup uses by default, so it can be
781+ /// pinned via `RUSTUP_TOOLCHAIN` (see `run_in_container`). Overrides the
782+ /// entrypoint to run `rustup show active-toolchain` and returns the toolchain
783+ /// name — the first whitespace-delimited token, dropping any trailing
784+ /// `(default)` marker (e.g. `1.93.0-x86_64-unknown-linux-gnu`). Returns `None`
785+ /// on any failure (e.g. an image without rustup), so the build proceeds without
786+ /// the pin rather than failing.
787+ async fn probe_active_toolchain ( image_ref : & str , docker : & Docker ) -> Option < String > {
788+ let config = ContainerCreateBody {
789+ image : Some ( image_ref. to_string ( ) ) ,
790+ entrypoint : Some ( vec ! [ "rustup" . to_string( ) ] ) ,
791+ cmd : Some ( vec ! [ "show" . to_string( ) , "active-toolchain" . to_string( ) ] ) ,
792+ attach_stdout : Some ( true ) ,
793+ attach_stderr : Some ( true ) ,
794+ host_config : Some ( HostConfig {
795+ auto_remove : Some ( true ) ,
796+ ..Default :: default ( )
797+ } ) ,
798+ ..Default :: default ( )
799+ } ;
800+ let created = docker
801+ . create_container ( None :: < CreateContainerOptions > , config)
802+ . await
803+ . ok ( ) ?;
804+ let attached = docker
805+ . attach_container (
806+ & created. id ,
807+ Some ( AttachContainerOptions {
808+ stdout : true ,
809+ stderr : true ,
810+ stream : true ,
811+ ..Default :: default ( )
812+ } ) ,
813+ )
814+ . await
815+ . ok ( ) ?;
816+ docker
817+ . start_container ( & created. id , None :: < StartContainerOptions > )
818+ . await
819+ . ok ( ) ?;
820+
821+ let mut stdout = String :: new ( ) ;
822+ let mut output = attached. output ;
823+ while let Some ( chunk) = output. next ( ) . await {
824+ if let Ok ( bollard:: container:: LogOutput :: StdOut { message } ) = chunk {
825+ stdout. push_str ( & String :: from_utf8_lossy ( & message) ) ;
826+ }
827+ }
828+
829+ let mut wait = docker. wait_container ( & created. id , None :: < WaitContainerOptions > ) ;
830+ while wait. next ( ) . await . is_some ( ) { }
831+
832+ stdout. split_whitespace ( ) . next ( ) . map ( str:: to_string)
833+ }
834+
780835/// Render the per-package `stellar contract build …` commands into a single
781836/// `sh -c` script (`stellar … && stellar …`), shell-escaping every token so meta
782837/// values with spaces survive. Used when more than one package is built so they
@@ -817,10 +872,22 @@ async fn run_in_container(
817872) -> Result < ( ) , Error > {
818873 let bind = format ! ( "{}:/source" , workspace_root. display( ) ) ;
819874
875+ // Pin rustup to the image's own toolchain (per SEP-58): without this, a
876+ // `rust-toolchain.toml` in the source could make rustup switch toolchains
877+ // mid-build, defeating the digest-pinned image. Probe the image for its
878+ // active toolchain and pass it through with `-e`, unless the caller already
879+ // set RUSTUP_TOOLCHAIN. Skipped silently when the image has no rustup.
880+ let mut env = env. to_vec ( ) ;
881+ if !env. iter ( ) . any ( |e| e. starts_with ( "RUSTUP_TOOLCHAIN=" ) ) {
882+ if let Some ( toolchain) = probe_active_toolchain ( image_ref, docker) . await {
883+ env. push ( format ! ( "RUSTUP_TOOLCHAIN={toolchain}" ) ) ;
884+ }
885+ }
886+
820887 // `-e KEY=VALUE` flags for the reproduce command, mirroring the env passed
821888 // to the container below.
822889 let mut env_flags = String :: new ( ) ;
823- for e in env {
890+ for e in & env {
824891 env_flags. push_str ( " -e " ) ;
825892 env_flags. push_str ( & shell_escape:: escape ( e. as_str ( ) . into ( ) ) ) ;
826893 }
@@ -852,7 +919,7 @@ async fn run_in_container(
852919 image : Some ( image_ref. to_string ( ) ) ,
853920 entrypoint,
854921 cmd : Some ( cmd) ,
855- env : ( !env. is_empty ( ) ) . then ( || env. to_vec ( ) ) ,
922+ env : ( !env. is_empty ( ) ) . then ( || env. clone ( ) ) ,
856923 working_dir : Some ( "/source" . to_string ( ) ) ,
857924 attach_stdout : Some ( true ) ,
858925 attach_stderr : Some ( true ) ,
@@ -908,24 +975,27 @@ async fn run_in_container(
908975 }
909976 }
910977
911- let mut wait = docker. wait_container ( & created. id , None :: < WaitContainerOptions > ) ;
978+ wait_for_container_exit ( docker, & created. id , & reproduce) . await
979+ }
980+
981+ /// Block until the container exits, mapping a non-zero exit code (whether
982+ /// reported as a successful wait or as a `DockerContainerWaitError`) to
983+ /// `ContainerExit` carrying the reproduce command.
984+ async fn wait_for_container_exit ( docker : & Docker , id : & str , reproduce : & str ) -> Result < ( ) , Error > {
985+ let mut wait = docker. wait_container ( id, None :: < WaitContainerOptions > ) ;
912986 while let Some ( item) = wait. next ( ) . await {
913- match item {
914- Ok ( r) if r. status_code == 0 => { }
915- Ok ( r) => {
916- return Err ( Error :: ContainerExit {
917- status : r. status_code ,
918- command : reproduce. clone ( ) ,
919- } ) ;
920- }
921- Err ( bollard:: errors:: Error :: DockerContainerWaitError { code : 0 , .. } ) => { }
922- Err ( bollard:: errors:: Error :: DockerContainerWaitError { code, .. } ) => {
923- return Err ( Error :: ContainerExit {
924- status : code,
925- command : reproduce. clone ( ) ,
926- } ) ;
927- }
987+ // Both a successful wait and a `DockerContainerWaitError` carry an exit
988+ // code; normalize to it (other errors are genuine failures).
989+ let status = match item {
990+ Ok ( r) => r. status_code ,
991+ Err ( bollard:: errors:: Error :: DockerContainerWaitError { code, .. } ) => code,
928992 Err ( e) => return Err ( e. into ( ) ) ,
993+ } ;
994+ if status != 0 {
995+ return Err ( Error :: ContainerExit {
996+ status,
997+ command : reproduce. to_string ( ) ,
998+ } ) ;
929999 }
9301000 }
9311001
0 commit comments