77//! arguments without spawning any processes, so it compiles and can be
88//! unit-tested on every host (Windows, macOS, Linux).
99
10+ use std:: collections:: HashSet ;
11+
1012use wxc_common:: filesystem_resolve:: FsIntent ;
1113use wxc_common:: models:: { ExecutionRequest , NetworkPolicy , ProxyAddress } ;
1214
@@ -97,6 +99,19 @@ const BASELINE_RO_BIND_PATHS: &[&str] = &[
9799 "/mnt/wsl/resolv.conf" ,
98100] ;
99101
102+ /// Build the complete `bwrap` argument list, masking **every** denied path as a
103+ /// directory (`--tmpfs`).
104+ ///
105+ /// This is the pure, classification-free entry point: it performs no filesystem
106+ /// I/O and so stays unit-testable on every host. Unit tests and any caller that
107+ /// has not stat'd the denied paths use it. The Bubblewrap runner uses
108+ /// [`build_args_classified`] instead, passing the set of denied paths it has
109+ /// determined to be files so those are masked with `--ro-bind /dev/null` (a
110+ /// `--tmpfs` over a file would replace it with an empty directory).
111+ pub fn build_args ( request : & ExecutionRequest , proxy_address : Option < & ProxyAddress > ) -> Vec < String > {
112+ build_args_classified ( request, proxy_address, & HashSet :: new ( ) )
113+ }
114+
100115/// Build the complete argument list for `bwrap` from the given request.
101116///
102117/// The returned vector does **not** include the `bwrap` binary name itself —
@@ -110,7 +125,18 @@ const BASELINE_RO_BIND_PATHS: &[&str] = &[
110125/// - strips any caller-supplied `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`
111126/// entries from `request.env`,
112127/// - emits `--setenv` for those keys pointing at the proxy URL.
113- pub fn build_args ( request : & ExecutionRequest , proxy_address : Option < & ProxyAddress > ) -> Vec < String > {
128+ ///
129+ /// `denied_files` is the set of `deniedPaths` entries that must be masked as
130+ /// **files** (`--ro-bind /dev/null <path>`) rather than **directories**
131+ /// (`--tmpfs <path>`); any denied path not in the set is masked as a directory.
132+ /// The runner builds this set by `symlink_metadata`-probing each denied path, so
133+ /// this function itself performs no filesystem I/O and remains unit-testable on
134+ /// every host.
135+ pub fn build_args_classified (
136+ request : & ExecutionRequest ,
137+ proxy_address : Option < & ProxyAddress > ,
138+ denied_files : & HashSet < String > ,
139+ ) -> Vec < String > {
114140 // -- Namespace isolation (all unshared by default) ---------------------
115141 let mut args = vec ! [
116142 "--unshare-user" ,
@@ -182,9 +208,17 @@ pub fn build_args(request: &ExecutionRequest, proxy_address: Option<&ProxyAddres
182208 FsIntent :: ReadOnly => {
183209 args. extend ( [ "--ro-bind" . into ( ) , mount. path . clone ( ) , mount. path . clone ( ) ] ) ;
184210 }
185- // Denied: mask with an empty tmpfs so contents are invisible.
211+ // Denied: mask so contents are invisible. A directory is masked
212+ // with an empty `--tmpfs`; a file must be masked with
213+ // `--ro-bind /dev/null` instead, because `--tmpfs` over a file would
214+ // replace it with an empty *directory* (wrong type). `denied_files`
215+ // holds the paths the runner classified as non-directories.
186216 FsIntent :: Denied => {
187- args. extend ( [ "--tmpfs" . into ( ) , mount. path . clone ( ) ] ) ;
217+ if denied_files. contains ( & mount. path ) {
218+ args. extend ( [ "--ro-bind" . into ( ) , "/dev/null" . into ( ) , mount. path . clone ( ) ] ) ;
219+ } else {
220+ args. extend ( [ "--tmpfs" . into ( ) , mount. path . clone ( ) ] ) ;
221+ }
188222 }
189223 }
190224 }
@@ -374,6 +408,74 @@ mod tests {
374408 ) ;
375409 }
376410
411+ /// A denied path classified as a **directory** (not in `denied_files`) is
412+ /// masked with an empty `--tmpfs`, matching the default `build_args`.
413+ #[ test]
414+ fn denied_directory_is_masked_with_tmpfs ( ) {
415+ let mut r = base_request ( ) ;
416+ r. policy . denied_paths = vec ! [ "/secrets" . into( ) ] ;
417+ let denied_files = HashSet :: new ( ) ;
418+ let args = build_args_classified ( & r, None , & denied_files) ;
419+
420+ // tmpfs at /secrets, and no ro-bind of /dev/null onto it.
421+ policy_mount_pos ( & args, "--tmpfs" , "/secrets" ) ;
422+ assert ! (
423+ args. windows( 3 )
424+ . all( |w| !( w[ 0 ] == "--ro-bind" && w[ 1 ] == "/dev/null" && w[ 2 ] == "/secrets" ) ) ,
425+ "a directory denied path must not be masked with /dev/null: {args:?}"
426+ ) ;
427+ }
428+
429+ /// A denied path classified as a **file** (present in `denied_files`) is
430+ /// masked with `--ro-bind /dev/null`, not `--tmpfs` (which would replace the
431+ /// file with an empty directory).
432+ #[ test]
433+ fn denied_file_is_masked_with_dev_null ( ) {
434+ let mut r = base_request ( ) ;
435+ r. policy . denied_paths = vec ! [ "/etc/shadow" . into( ) ] ;
436+ let denied_files = HashSet :: from ( [ "/etc/shadow" . to_string ( ) ] ) ;
437+ let args = build_args_classified ( & r, None , & denied_files) ;
438+
439+ // `--ro-bind /dev/null /etc/shadow` present ...
440+ let pos = args
441+ . windows ( 3 )
442+ . position ( |w| w[ 0 ] == "--ro-bind" && w[ 1 ] == "/dev/null" && w[ 2 ] == "/etc/shadow" ) ;
443+ assert ! (
444+ pos. is_some( ) ,
445+ "a file denied path must be masked with `--ro-bind /dev/null`: {args:?}"
446+ ) ;
447+ // ... and it is NOT tmpfs-masked.
448+ assert ! (
449+ args. windows( 2 )
450+ . all( |w| !( w[ 0 ] == "--tmpfs" && w[ 1 ] == "/etc/shadow" ) ) ,
451+ "a file denied path must not be tmpfs-masked: {args:?}"
452+ ) ;
453+ }
454+
455+ /// Classification is per-path: in one policy a denied file and a denied
456+ /// directory get their respective masks, and the specificity ordering is
457+ /// still honored (deep file mask emitted after its shallower rw ancestor).
458+ #[ test]
459+ fn mixed_denied_file_and_dir_masks_each_correctly ( ) {
460+ let mut r = base_request ( ) ;
461+ r. policy . readwrite_paths = vec ! [ "/data" . into( ) ] ;
462+ r. policy . denied_paths = vec ! [ "/data/secret.txt" . into( ) , "/cache" . into( ) ] ;
463+ let denied_files = HashSet :: from ( [ "/data/secret.txt" . to_string ( ) ] ) ;
464+ let args = build_args_classified ( & r, None , & denied_files) ;
465+
466+ // File → /dev/null, after the rw /data parent.
467+ let parent = policy_mount_pos ( & args, "--bind" , "/data" ) ;
468+ let file_mask = policy_mount_pos ( & args, "--ro-bind" , "/data/secret.txt" ) ;
469+ assert ! (
470+ file_mask > parent,
471+ "deep denied file mask (pos {file_mask}) must come after rw /data (pos {parent}): {args:?}"
472+ ) ;
473+ assert_eq ! ( args[ file_mask + 1 ] , "/dev/null" ) ;
474+
475+ // Dir → tmpfs.
476+ policy_mount_pos ( & args, "--tmpfs" , "/cache" ) ;
477+ }
478+
377479 #[ test]
378480 fn environment_variables_are_set ( ) {
379481 let mut r = base_request ( ) ;
0 commit comments