1919//! shared directory cannot open the file before the caller narrows the
2020//! final permissions via `set_permissions` (issue #10011). The same
2121//! `nofollow` flag refuses to truncate through a symlink that may have
22- //! been swapped in at the destination path.
22+ //! been swapped in at the destination path, and `exclusive` refuses to
23+ //! open *any* pre-existing name, including a hard link to a file the
24+ //! caller did not create.
2325
2426use std:: fs:: File ;
2527use std:: io;
@@ -38,6 +40,10 @@ const DEST_FLAGS: OFlags = OFlags::WRONLY
3840 . union ( OFlags :: CREATE )
3941 . union ( OFlags :: TRUNC )
4042 . union ( OFlags :: CLOEXEC ) ;
43+ const DEST_EXCL_FLAGS : OFlags = OFlags :: WRONLY
44+ . union ( OFlags :: CREATE )
45+ . union ( OFlags :: EXCL )
46+ . union ( OFlags :: CLOEXEC ) ;
4147
4248/// Open `path` for reading, optionally with `O_NOFOLLOW`.
4349///
@@ -70,8 +76,25 @@ pub fn open_source<P: AsRef<Path>>(path: P, nofollow: bool) -> io::Result<File>
7076/// who plants `path` as a symlink between the caller's check and this
7177/// open can redirect the truncate (and the subsequent write) to any file
7278/// the caller has permission to write.
73- pub fn create_dest_restrictive < P : AsRef < Path > > ( path : P , nofollow : bool ) -> io:: Result < File > {
74- let mut flags = DEST_FLAGS ;
79+ ///
80+ /// With `exclusive = true`, the call carries `O_EXCL` and fails with
81+ /// `EEXIST` instead of opening an existing name at all. Pass `true`
82+ /// whenever the caller has just unlinked `path` and intends to create a
83+ /// fresh inode: `nofollow` alone still opens a hard link planted in that
84+ /// window, which would truncate (and later chown and chmod) a file the
85+ /// caller did not create. A symlink also fails under `O_EXCL`, so
86+ /// `nofollow` is subsumed when `exclusive` is set; the reverse is not
87+ /// true, since `O_NOFOLLOW` still opens a pre-existing regular file.
88+ pub fn create_dest_restrictive < P : AsRef < Path > > (
89+ path : P ,
90+ nofollow : bool ,
91+ exclusive : bool ,
92+ ) -> io:: Result < File > {
93+ let mut flags = if exclusive {
94+ DEST_EXCL_FLAGS
95+ } else {
96+ DEST_FLAGS
97+ } ;
7598 if nofollow {
7699 flags |= OFlags :: NOFOLLOW ;
77100 }
@@ -138,7 +161,7 @@ mod tests {
138161 fn create_dest_uses_restrictive_initial_mode ( ) {
139162 let dir = tempdir ( ) . unwrap ( ) ;
140163 let path = dir. path ( ) . join ( "new" ) ;
141- let f = create_dest_restrictive ( & path, false ) . unwrap ( ) ;
164+ let f = create_dest_restrictive ( & path, false , false ) . unwrap ( ) ;
142165 let mode = f. metadata ( ) . unwrap ( ) . mode ( ) & 0o777 ;
143166 assert_eq ! ( mode, DEST_INITIAL_MODE ) ;
144167 }
@@ -159,7 +182,7 @@ mod tests {
159182 }
160183 // Re-open via the helper — mode of the existing inode stays 0o644,
161184 // only the contents are truncated.
162- create_dest_restrictive ( & path, false ) . unwrap ( ) ;
185+ create_dest_restrictive ( & path, false , false ) . unwrap ( ) ;
163186 let mode = std:: fs:: metadata ( & path) . unwrap ( ) . mode ( ) & 0o777 ;
164187 assert_eq ! ( mode, 0o644 ) ;
165188 assert_eq ! ( std:: fs:: metadata( & path) . unwrap( ) . len( ) , 0 ) ;
@@ -177,11 +200,26 @@ mod tests {
177200 std:: fs:: write ( & victim, b"do not truncate me" ) . unwrap ( ) ;
178201 symlink ( & victim, & dst) . unwrap ( ) ;
179202
180- let err = create_dest_restrictive ( & dst, true ) . unwrap_err ( ) ;
203+ let err = create_dest_restrictive ( & dst, true , false ) . unwrap_err ( ) ;
181204 assert_eq ! (
182205 err. raw_os_error( ) ,
183206 Some ( rustix:: io:: Errno :: LOOP . raw_os_error( ) )
184207 ) ;
185208 assert_eq ! ( std:: fs:: read( & victim) . unwrap( ) , b"do not truncate me" ) ;
186209 }
210+
211+ #[ test]
212+ fn create_dest_exclusive_refuses_existing_and_hard_linked ( ) {
213+ // An attacker who plants a hard link in the window between the
214+ // caller's unlink and this create must not get the victim truncated
215+ // and later chowned/chmoded. O_EXCL refuses the existing name.
216+ let dir = tempdir ( ) . unwrap ( ) ;
217+ let victim = dir. path ( ) . join ( "victim" ) ;
218+ std:: fs:: write ( & victim, b"SECRET" ) . unwrap ( ) ;
219+ std:: fs:: hard_link ( & victim, dir. path ( ) . join ( "planted" ) ) . unwrap ( ) ;
220+
221+ let err = create_dest_restrictive ( dir. path ( ) . join ( "planted" ) , true , true ) . unwrap_err ( ) ;
222+ assert_eq ! ( err. kind( ) , io:: ErrorKind :: AlreadyExists ) ;
223+ assert_eq ! ( std:: fs:: read( & victim) . unwrap( ) , b"SECRET" ) ;
224+ }
187225}
0 commit comments