@@ -465,10 +465,16 @@ pub async fn atomic_write_bytes_preserving_mode(
465465/// already exists (`/dev/stdout`, a FIFO, a character device) has no inode to
466466/// swap, so it is written in place: on Windows a DOS device name (`NUL`,
467467/// `CON`, `COM1`) or a `\\.\` device path such as a named pipe, which std
468- /// reports as a file. Never captured by an open group commit: the path is
469- /// the user's output, not one of the run's commit points.
468+ /// reports as a file. A path that is, or links through, `/dev/…` or
469+ /// `/proc/…` (`/dev/stdout`, `/dev/fd/1`, `/proc/self/fd/1`) is a stream
470+ /// too, even when the descriptor behind it is a regular file
471+ /// (`--output /dev/stdout > vex.json`): renaming over its target would
472+ /// detach the caller's open descriptor, and on macOS `/dev/fd` holds no
473+ /// stage. Never captured by an open group commit: the path is the user's
474+ /// output, not one of the run's commit points.
470475pub async fn write_user_output ( path : & Path , content : & [ u8 ] ) -> std:: io:: Result < ( ) > {
471476 let special = ( cfg ! ( windows) && is_windows_device_path ( path) )
477+ || links_through_dev_or_proc ( path)
472478 || matches ! (
473479 tokio:: fs:: metadata( path) . await ,
474480 Ok ( meta) if !meta. is_file( ) && !meta. is_dir( )
@@ -490,6 +496,30 @@ pub async fn write_user_output(path: &Path, content: &[u8]) -> std::io::Result<(
490496 . await
491497}
492498
499+ /// Whether `path`, or any hop of the symlink chain its final component
500+ /// starts, names something under `/dev/` or `/proc/`. The chain is read link
501+ /// by link, never canonicalized: `/proc/self/fd/1` resolves to the regular
502+ /// file behind the descriptor, which is exactly what must not be renamed
503+ /// over.
504+ fn links_through_dev_or_proc ( path : & Path ) -> bool {
505+ let under = |p : & Path | p. starts_with ( "/dev" ) || p. starts_with ( "/proc" ) ;
506+ let mut hop = path. to_path_buf ( ) ;
507+ // The kernel's own loop limit for symlink resolution.
508+ for _ in 0 ..40 {
509+ if under ( & hop) {
510+ return true ;
511+ }
512+ let Ok ( target) = std:: fs:: read_link ( & hop) else {
513+ return false ;
514+ } ;
515+ hop = match hop. parent ( ) {
516+ Some ( dir) if target. is_relative ( ) => dir. join ( target) ,
517+ _ => target,
518+ } ;
519+ }
520+ false
521+ }
522+
493523/// Whether Windows resolves `path` to a device rather than a file: the
494524/// `\\.\` device namespace (named pipes, `\\.\COM1`), or a final
495525/// component whose name before the first dot, trailing spaces dropped, is a
@@ -1100,6 +1130,67 @@ mod tests {
11001130 assert_eq ! ( std:: fs:: read( & fresh) . unwrap( ) , b"doc" ) ;
11011131 }
11021132
1133+ /// `--output /dev/stdout > vex.json`: the descriptor behind a `/dev` or
1134+ /// `/proc` path is written in place even when it is a regular file, so
1135+ /// the caller's open file keeps the bytes; a user's link to such a path
1136+ /// is a stream too (review on #1262).
1137+ #[ cfg( target_os = "linux" ) ]
1138+ #[ tokio:: test]
1139+ async fn user_output_writes_descriptor_paths_in_place ( ) {
1140+ use std:: io:: { Read as _, Seek as _} ;
1141+ use std:: os:: fd:: AsRawFd as _;
1142+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
1143+ let captured = tmp. path ( ) . join ( "captured.json" ) ;
1144+ let mut file = std:: fs:: OpenOptions :: new ( )
1145+ . read ( true )
1146+ . write ( true )
1147+ . create ( true )
1148+ . truncate ( true )
1149+ . open ( & captured)
1150+ . unwrap ( ) ;
1151+ let fd_path = PathBuf :: from ( format ! ( "/proc/self/fd/{}" , file. as_raw_fd( ) ) ) ;
1152+ assert ! ( links_through_dev_or_proc( & fd_path) ) ;
1153+ write_user_output ( & fd_path, b"doc" ) . await . unwrap ( ) ;
1154+ let mut seen = String :: new ( ) ;
1155+ file. rewind ( ) . unwrap ( ) ;
1156+ file. read_to_string ( & mut seen) . unwrap ( ) ;
1157+ assert_eq ! ( seen, "doc" , "the open descriptor must see the bytes" ) ;
1158+
1159+ let link = tmp. path ( ) . join ( "out.vex.json" ) ;
1160+ std:: os:: unix:: fs:: symlink ( & fd_path, & link) . unwrap ( ) ;
1161+ assert ! ( links_through_dev_or_proc( & link) ) ;
1162+ write_user_output ( & link, b"doc2" ) . await . unwrap ( ) ;
1163+ assert ! ( std:: fs:: symlink_metadata( & link)
1164+ . unwrap( )
1165+ . file_type( )
1166+ . is_symlink( ) ) ;
1167+ seen. clear ( ) ;
1168+ file. rewind ( ) . unwrap ( ) ;
1169+ file. read_to_string ( & mut seen) . unwrap ( ) ;
1170+ assert_eq ! ( seen, "doc2" ) ;
1171+ let names = std:: fs:: read_dir ( tmp. path ( ) ) . unwrap ( ) . count ( ) ;
1172+ assert_eq ! ( names, 2 , "no stage left beside the captured file" ) ;
1173+ }
1174+
1175+ #[ cfg( unix) ]
1176+ #[ test]
1177+ fn dev_and_proc_paths_are_streams ( ) {
1178+ for stream in [ "/dev/stdout" , "/dev/stderr" , "/dev/fd/1" , "/proc/self/fd/1" ] {
1179+ assert ! ( links_through_dev_or_proc( Path :: new( stream) ) , "{stream}" ) ;
1180+ }
1181+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
1182+ let file = tmp. path ( ) . join ( "vex.json" ) ;
1183+ std:: fs:: write ( & file, b"{}" ) . unwrap ( ) ;
1184+ assert ! ( !links_through_dev_or_proc( & file) ) ;
1185+ assert ! ( !links_through_dev_or_proc( & tmp. path( ) . join( "missing.json" ) ) ) ;
1186+ let relative = tmp. path ( ) . join ( "rel.json" ) ;
1187+ std:: os:: unix:: fs:: symlink ( "vex.json" , & relative) . unwrap ( ) ;
1188+ assert ! ( !links_through_dev_or_proc( & relative) ) ;
1189+ let looped = tmp. path ( ) . join ( "loop" ) ;
1190+ std:: os:: unix:: fs:: symlink ( "loop" , & looped) . unwrap ( ) ;
1191+ assert ! ( !links_through_dev_or_proc( & looped) ) ;
1192+ }
1193+
11031194 /// Windows device names and the `\\.\` namespace are written in place
11041195 /// (Bugbot on #1262: std reports `NUL` and named pipes as files there).
11051196 #[ test]
0 commit comments