@@ -25,6 +25,7 @@ use nativelink_proto::build::bazel::remote::execution::v2::{
2525 Directory as ProtoDirectory , DirectoryNode , FileNode , SymlinkNode ,
2626} ;
2727use nativelink_store:: ac_utils:: get_and_decode_digest;
28+ use nativelink_store:: cas_utils:: is_zero_digest;
2829use nativelink_util:: common:: DigestInfo ;
2930use nativelink_util:: fs_util:: {
3031 CloneMethod , hardlink_directory_tree, set_readonly_recursive, set_readwrite_recursive,
@@ -299,17 +300,29 @@ impl DirectoryCache {
299300
300301 trace ! ( ?file_path, ?digest, "Creating file" ) ;
301302
302- // Fetch file content from CAS
303- let data = self
304- . cas_store
305- . get_part_unchunked ( StoreKey :: Digest ( digest) , 0 , None )
306- . await
307- . err_tip ( || format ! ( "Failed to fetch file: {}" , file_path. display( ) ) ) ?;
303+ // Zero-byte files (digest af1349b9...-0) are not stored in
304+ // FilesystemStore / many CAS backends, so a get_part_unchunked here
305+ // returns NotFound. In Bazel-style trees these show up frequently as
306+ // empty marker / config files (.linksearchpaths, empty .env, .toml,
307+ // etc.), and a single failure aborts the whole DirectoryCache
308+ // construction. Short-circuit and write the empty file directly.
309+ if is_zero_digest ( digest) {
310+ fs:: write ( & file_path, b"" )
311+ . await
312+ . err_tip ( || format ! ( "Failed to write empty file: {}" , file_path. display( ) ) ) ?;
313+ } else {
314+ // Fetch file content from CAS
315+ let data = self
316+ . cas_store
317+ . get_part_unchunked ( StoreKey :: Digest ( digest) , 0 , None )
318+ . await
319+ . err_tip ( || format ! ( "Failed to fetch file: {}" , file_path. display( ) ) ) ?;
308320
309- // Write to disk
310- fs:: write ( & file_path, data. as_ref ( ) )
311- . await
312- . err_tip ( || format ! ( "Failed to write file: {}" , file_path. display( ) ) ) ?;
321+ // Write to disk
322+ fs:: write ( & file_path, data. as_ref ( ) )
323+ . await
324+ . err_tip ( || format ! ( "Failed to write file: {}" , file_path. display( ) ) ) ?;
325+ }
313326
314327 // Set permissions
315328 #[ cfg( unix) ]
@@ -601,4 +614,67 @@ mod tests {
601614
602615 Ok ( ( ) )
603616 }
617+
618+ /// A Directory containing a zero-byte file must be constructible even when
619+ /// the CAS has no entry for the zero-byte digest. In production CAS
620+ /// backends (FilesystemStore in particular) refuse to store zero-byte
621+ /// blobs, so without the short-circuit this is a NotFound error and 30%+
622+ /// of cache constructions fail (per PR #2243).
623+ #[ nativelink_test]
624+ async fn test_directory_cache_zero_byte_file ( ) -> Result < ( ) , Error > {
625+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
626+ let cache_root = temp_dir. path ( ) . join ( "cache" ) ;
627+ let store = Store :: new ( MemoryStore :: new ( & MemorySpec :: default ( ) ) ) ;
628+
629+ // RFC 6234 / Bazel zero-byte SHA-256 digest, hash for b"".
630+ let zero_digest = DigestInfo :: try_new (
631+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" ,
632+ 0 ,
633+ )
634+ . unwrap ( ) ;
635+ // Deliberately do NOT upload the zero-byte blob — that's the whole
636+ // point: real CAS backends won't have it.
637+
638+ let directory = ProtoDirectory {
639+ files : vec ! [ FileNode {
640+ name: "empty.txt" . to_string( ) ,
641+ digest: Some ( zero_digest. into( ) ) ,
642+ is_executable: false ,
643+ ..Default :: default ( )
644+ } ] ,
645+ directories : vec ! [ ] ,
646+ symlinks : vec ! [ ] ,
647+ ..Default :: default ( )
648+ } ;
649+ let mut dir_data = Vec :: new ( ) ;
650+ directory. encode ( & mut dir_data) . unwrap ( ) ;
651+ let dir_digest = DigestInfo :: try_new (
652+ "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" ,
653+ dir_data. len ( ) as i64 ,
654+ )
655+ . unwrap ( ) ;
656+ store
657+ . as_store_driver_pin ( )
658+ . update_oneshot ( dir_digest. into ( ) , dir_data. into ( ) )
659+ . await
660+ . unwrap ( ) ;
661+
662+ let config = DirectoryCacheConfig {
663+ max_entries : 10 ,
664+ max_size_bytes : 1024 * 1024 ,
665+ cache_root,
666+ } ;
667+ let cache = DirectoryCache :: new ( config, store) . await ?;
668+
669+ let dest = temp_dir. path ( ) . join ( "dest_empty" ) ;
670+ let hit = cache. get_or_create ( dir_digest, & dest) . await ?;
671+ assert ! ( !hit, "First construction should be a cache miss" ) ;
672+
673+ let empty_path = dest. join ( "empty.txt" ) ;
674+ assert ! ( empty_path. exists( ) , "zero-byte file should be created" ) ;
675+ let metadata = fs:: metadata ( & empty_path) . await . unwrap ( ) ;
676+ assert_eq ! ( metadata. len( ) , 0 , "zero-byte file must be 0 bytes" ) ;
677+
678+ Ok ( ( ) )
679+ }
604680}
0 commit comments