char/avril
(wip) why'd you have to go and make things so complicaaaaated?
git clone https://git.t4t.associates/char/avril
99412b5
main
1//! Anchored no-follow filesystem primitives for npm storage. 2//! 3//! All descents start from an open directory handle and never follow 4//! symlinks; regular files are confirmed with `fstat` on the opened file. 5 6use std:: io::{ self , Read , Write }; 7use std:: path::{ Component , Path }; 8use std:: sync:: atomic::{ AtomicU64 , Ordering }; 9 10use cap_fs_ext::{ DirExt , FollowSymlinks , OpenOptionsFollowExt , OpenOptionsSyncExt }; 11# [ cfg ( unix )] 12use cap_std:: fs:: Permissions ; 13use cap_std:: fs::{ Dir , File , OpenOptions }; 14 15// The configured anchor may contain system symlinks (e.g. macOS /tmp). 16// Below that anchor, all traversal must use no-follow handles. 17pub fn open_root ( path : & Path ) -> io:: Result < Dir > { 18if !path. is_absolute () { 19return Err ( io:: Error :: other ( "npm path must be absolute and canonical" )); 20} 21if path 22. components () 23. any ( |part|matches! ( part, Component :: CurDir |Component :: ParentDir )) 24{ 25return Err ( io:: Error :: other ( "npm path must be absolute and canonical" )); 26} 27Dir :: open_ambient_dir ( path, cap_std:: ambient_authority ()) 28} 29 30/// Descend normal components without following symlinks. 31pub fn walk ( root : & Dir , rel : & Path ) -> io:: Result < Dir > { 32let mut directory = root. try_clone () ?; 33for partin rel. components () { 34let Component :: Normal ( name) = partelse { 35return Err ( io:: Error :: other ( "npm path escapes its anchor" )); 36}; 37 directory = directory. open_dir_nofollow ( name) ?; 38} 39Ok ( directory) 40} 41 42/// Descend, creating missing directories. Present non-directories fail. 43pub fn mkdirs ( root : & Dir , rel : & Path ) -> io:: Result < Dir > { 44let mut directory = root. try_clone () ?; 45for partin rel. components () { 46let Component :: Normal ( name) = partelse { 47return Err ( io:: Error :: other ( "npm path escapes its anchor" )); 48}; 49 directory =match directory. open_dir_nofollow ( name) { 50Ok ( child) => child, 51Err ( error) if error. kind () == io:: ErrorKind :: NotFound =>{ 52match directory. create_dir ( name) { 53Ok (()) =>{} 54Err ( error) if error. kind () == io:: ErrorKind :: AlreadyExists =>{} 55Err ( error) =>return Err ( error), 56} 57 directory. open_dir_nofollow ( name) ? 58} 59Err ( error) =>return Err ( error), 60}; 61} 62Ok ( directory) 63} 64 65fn single_component ( name : & str ) -> io:: Result <()> { 66if Path :: new ( name). components (). count () !=1 67 || !matches! ( 68Path :: new ( name). components (). next (), 69Some ( Component :: Normal ( _)) 70) 71{ 72return Err ( io:: Error :: other ( 73"npm file name must be one normal component" , 74)); 75} 76Ok (()) 77} 78 79/// Open one file for reading without following symlinks. Non-blocking mode 80/// keeps FIFOs from hanging the caller; [`read_bounded`] rejects them. 81pub fn open_file ( directory : & Dir , name : & str ) -> io:: Result < File > { 82single_component ( name) ?; 83let mut options =OpenOptions :: new (); 84 options. read ( true ). follow ( FollowSymlinks :: No ). nonblock ( true ); 85 directory. open_with ( name, & options) 86} 87 88/// Read at most `max + 1` bytes after confirming a regular file via `fstat`. 89pub fn read_bounded ( file : & File , max : u64 , what : & str ) -> io:: Result < Vec < u8 >> { 90let metadata = file. metadata () ?; 91if !metadata. is_file () { 92return Err ( io:: Error :: other ( format! ( "{what} is not a regular file" ))); 93} 94// Bounded readers otherwise grow from tiny buffers, even for known-size files. 95let capacity = usize:: try_from ( metadata. len (). min ( max)). unwrap_or ( 0 ); 96let mut bytes =Vec :: with_capacity ( capacity); 97 file. take ( max +1 ). read_to_end ( & mut bytes) ?; 98if bytes. len () as u64 > max{ 99return Err ( io:: Error :: other ( format! ( "{what} is too large" ))); 100} 101Ok ( bytes) 102} 103 104/// Create a new file, failing if anything already exists there. 105pub fn write_new ( directory : & Dir , name : & str , bytes : & [ u8 ]) -> io:: Result < File > { 106let mut options =OpenOptions :: new (); 107 options 108. write ( true ) 109. create_new ( true ) 110. follow ( FollowSymlinks :: No ); 111let mut file = directory. open_with ( name, & options) ?; 112 file. write_all ( bytes) ?; 113Ok ( file) 114} 115 116/// Atomically replace or create a file via a staged sibling plus rename. 117pub fn write_atomic ( directory : & Dir , name : & str , bytes : & [ u8 ]) -> io:: Result <()> { 118for _in 0 ..8 { 119let staging =unique_tmp (); 120let staged =( || -> io:: Result <()> { 121let file =write_new ( directory, & staging, bytes) ?; 122set_readable ( & file) ?; 123 file. sync_all () ?; 124 directory. rename ( & staging, directory, name) 125})(); 126match staged{ 127Ok (()) =>return Ok (()), 128Err ( error) if error. kind () == io:: ErrorKind :: AlreadyExists =>continue , 129Err ( error) =>{ 130remove_all ( directory, & staging); 131return Err ( error); 132} 133} 134} 135Err ( io:: Error :: other ( "npm staging collided repeatedly" )) 136} 137 138/// Best-effort staging cleanup. 139pub fn remove_all ( directory : & Dir , name : & str ) { 140let _ = directory. remove_dir_all ( name); 141let _ = directory. remove_file ( name); 142} 143 144# [ cfg ( unix )] 145pub fn set_readable ( file : & File ) -> io:: Result <()> { 146use std:: os:: unix:: fs:: PermissionsExt ; 147 file. set_permissions ( Permissions :: from_std ( std:: fs:: Permissions :: from_mode ( 1480o644 , 149))) 150} 151 152# [ cfg ( not ( unix ))] 153pub fn set_readable ( _file : & File ) -> io:: Result <()> { 154Ok (()) 155} 156 157/// Random staging name; callers use create-new semantics and retry, so a 158/// collision (or stale entry after PID reuse) fails closed instead of 159/// overwriting. 160pub fn unique_tmp () ->String { 161let mut buf =[ 0u8 ; 8 ]; 162if getrandom:: fill ( & mut buf). is_ok () { 163return format! ( ".tmp.{:016x}" , u64 :: from_ne_bytes ( buf)); 164} 165static COUNTER : AtomicU64 =AtomicU64 :: new ( 0 ); 166let id =COUNTER . fetch_add ( 1 , Ordering :: Relaxed ); 167format! ( ".tmp.{}-{id}" , std:: process:: id ()) 168} 169 170/// Advisory coordination only; published content still needs verification. 171pub struct Guard { 172_file : std:: fs:: File , 173} 174 175/// Open (creating) a guard file without following symlinks and lock it. 176/// Non-blocking open plus a regular-file check keeps FIFOs from hanging. 177pub fn lock_file ( directory : & Dir , name : & str ) -> io:: Result < Guard > { 178single_component ( name) ?; 179let mut options =OpenOptions :: new (); 180 options 181. write ( true ) 182. create ( true ) 183. follow ( FollowSymlinks :: No ) 184. nonblock ( true ); 185let file = directory. open_with ( name, & options) ?; 186if !file. metadata () ?. is_file () { 187return Err ( io:: Error :: other ( "npm lock is not a regular file" )); 188} 189let std_file = file. into_std (); 190 std_file. lock () ?; 191Ok ( Guard { _file : std_file}) 192} 193 194# [ cfg ( test )] 195mod tests{ 196use super :: * ; 197 198# [ test ] 199fn bounded_reads_preserve_bytes_and_enforce_limits () { 200let temp = tempfile:: tempdir (). unwrap (); 201let root =open_root ( temp. path ()). unwrap (); 202for lengthin [ 0usize , 1 , 32 , 4096 , 65537 ] { 203let expected: Vec < u8 > =( 0 ..length). map ( |i|( i %251 ) as u8 ). collect (); 204 std:: fs:: write ( temp. path (). join ( "data" ), & expected). unwrap (); 205for maxin [ 0 , length. saturating_sub ( 1 ), length, length +1 ] { 206let file =open_file ( & root, "data" ). unwrap (); 207let result =read_bounded ( & file, maxas u64 , "data" ); 208if length <= max{ 209assert_eq! ( result. unwrap (), expected); 210} else { 211assert! ( result. unwrap_err (). to_string (). contains ( "too large" )); 212} 213} 214} 215let sparse = std:: fs:: File :: create ( temp. path (). join ( "sparse" )). unwrap (); 216 sparse. set_len ( 1 <<30 ). unwrap (); 217let file =open_file ( & root, "sparse" ). unwrap (); 218assert! ( read_bounded ( & file, 64 , "sparse" ). is_err ()); 219} 220}