@@ -11,6 +11,7 @@ use std::{
1111} ;
1212
1313use serde:: Deserialize ;
14+ use sha2:: { Digest , Sha256 } ;
1415use soar_utils:: fs:: is_elf;
1516use tracing:: { debug, trace} ;
1617use ureq:: http:: header:: { ACCEPT , AUTHORIZATION , ETAG , IF_RANGE , RANGE } ;
@@ -153,14 +154,46 @@ impl OciLayer {
153154/// Only the final path component of the title is used, so titles such as
154155/// `../../etc/passwd`, `/etc/passwd`, or `..` cannot escape `output_dir`.
155156fn safe_layer_path ( output_dir : & Path , title : & str ) -> Result < PathBuf , DownloadError > {
156- let name = Path :: new ( title)
157- . file_name ( )
158- . ok_or_else ( || DownloadError :: UnsafeLayerPath {
157+ let name = Path :: new ( title) . file_name ( ) . ok_or_else ( || {
158+ DownloadError :: UnsafeLayerPath {
159159 title : title. to_string ( ) ,
160- } ) ?;
160+ }
161+ } ) ?;
161162 Ok ( output_dir. join ( name) )
162163}
163164
165+ /// Verifies the file at `path` against an OCI content-addressable digest
166+ /// (e.g. `sha256:<hex>`). On mismatch the file is removed and an error returned.
167+ fn verify_layer_digest ( path : & Path , digest : & str ) -> Result < ( ) , DownloadError > {
168+ let expected = digest. strip_prefix ( "sha256:" ) . unwrap_or ( digest) ;
169+
170+ let mut file = File :: open ( path) ?;
171+ let mut hasher = Sha256 :: new ( ) ;
172+ let mut buffer = [ 0u8 ; 8192 ] ;
173+ loop {
174+ let n = file. read ( & mut buffer) ?;
175+ if n == 0 {
176+ break ;
177+ }
178+ hasher. update ( & buffer[ ..n] ) ;
179+ }
180+ let got: String = hasher
181+ . finalize ( )
182+ . iter ( )
183+ . map ( |b| format ! ( "{b:02x}" ) )
184+ . collect ( ) ;
185+
186+ if got. eq_ignore_ascii_case ( expected) {
187+ Ok ( ( ) )
188+ } else {
189+ std:: fs:: remove_file ( path) . ok ( ) ;
190+ Err ( DownloadError :: DigestMismatch {
191+ expected : expected. to_string ( ) ,
192+ got,
193+ } )
194+ }
195+ }
196+
164197#[ derive( Clone ) ]
165198pub struct OciDownload {
166199 reference : OciReference ,
@@ -483,6 +516,7 @@ impl OciDownload {
483516 if path. is_file ( ) {
484517 if let Ok ( metadata) = path. metadata ( ) {
485518 if metadata. len ( ) == layer. size {
519+ verify_layer_digest ( & path, & layer. digest ) ?;
486520 downloaded += layer. size ;
487521 if let Some ( ref cb) = self . on_progress {
488522 cb ( Progress :: Chunk {
@@ -590,6 +624,10 @@ impl OciDownload {
590624 if path. is_file ( ) {
591625 if let Ok ( metadata) = path. metadata ( ) {
592626 if metadata. len ( ) == layer. size {
627+ if let Err ( e) = verify_layer_digest ( & path, & layer. digest ) {
628+ errors. lock ( ) . unwrap ( ) . push ( format ! ( "{e}" ) ) ;
629+ continue ;
630+ }
593631 let current = downloaded
594632 . fetch_add ( layer. size , Ordering :: Relaxed )
595633 + layer. size ;
@@ -766,6 +804,8 @@ impl OciDownload {
766804
767805 let path = dl. execute ( ) ?;
768806
807+ verify_layer_digest ( & path, & self . reference . tag ) ?;
808+
769809 Ok ( vec ! [ path] )
770810 }
771811
@@ -957,6 +997,8 @@ fn download_layer_impl(
957997 }
958998 }
959999
1000+ verify_layer_digest ( path, & layer. digest ) ?;
1001+
9601002 if is_elf ( path) {
9611003 trace ! ( path = %path. display( ) , "setting executable permissions on ELF binary" ) ;
9621004 std:: fs:: set_permissions ( path, Permissions :: from_mode ( 0o755 ) ) ?;
@@ -975,6 +1017,37 @@ fn download_layer_impl(
9751017mod tests {
9761018 use super :: * ;
9771019
1020+ #[ test]
1021+ fn verify_layer_digest_accepts_correct_and_rejects_wrong ( ) {
1022+ use std:: io:: Write ;
1023+
1024+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
1025+ let path = dir. path ( ) . join ( "layer.bin" ) ;
1026+ {
1027+ let mut f = File :: create ( & path) . unwrap ( ) ;
1028+ f. write_all ( b"layer-bytes" ) . unwrap ( ) ;
1029+ }
1030+
1031+ let mut hasher = Sha256 :: new ( ) ;
1032+ hasher. update ( b"layer-bytes" ) ;
1033+ let hex: String = hasher
1034+ . finalize ( )
1035+ . iter ( )
1036+ . map ( |b| format ! ( "{b:02x}" ) )
1037+ . collect ( ) ;
1038+
1039+ assert ! ( verify_layer_digest( & path, & format!( "sha256:{hex}" ) ) . is_ok( ) ) ;
1040+ assert ! ( path. exists( ) ) ;
1041+
1042+ match verify_layer_digest ( & path, "sha256:00ff" ) {
1043+ Err ( DownloadError :: DigestMismatch {
1044+ ..
1045+ } ) => { }
1046+ other => panic ! ( "expected DigestMismatch, got {other:?}" ) ,
1047+ }
1048+ assert ! ( !path. exists( ) , "file should be removed on digest mismatch" ) ;
1049+ }
1050+
9781051 #[ test]
9791052 fn safe_layer_path_keeps_plain_name_inside_output_dir ( ) {
9801053 let out = Path :: new ( "/tmp/soar-out" ) ;
0 commit comments