11// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22// SPDX-License-Identifier: Apache-2.0
33
4- use std:: io:: Write ;
4+ use std:: io:: { Read , Write } ;
55use std:: path:: Path ;
66
77const LOCAL_NO_PROXY : & str = "127.0.0.1,localhost,::1" ;
@@ -11,6 +11,12 @@ const ENSURE_STANDARD_SBIN_PATHS_SCRIPT: &str = "for dir in /usr/local/sbin /usr
1111const STARTUP_SNIPPET_MARKER : & str = "# OpenShell standard sbin PATH" ;
1212const PROFILE_D_SNIPPET_PATH : & str = "/etc/profile.d/openshell-standard-sbin-path.sh" ;
1313
14+ enum StartupFile {
15+ Missing ,
16+ Regular ( String ) ,
17+ Unsafe ,
18+ }
19+
1420pub fn child_path_from_env ( ) -> String {
1521 std:: env:: var ( "PATH" )
1622 . ok ( )
@@ -71,34 +77,158 @@ fn startup_snippet() -> String {
7177}
7278
7379fn write_profile_snippet ( path : & Path ) -> std:: io:: Result < ( ) > {
74- let snippet = startup_snippet ( ) ;
75- if std:: fs:: read_to_string ( path) . is_ok_and ( |content| content == snippet) {
76- return Ok ( ( ) ) ;
77- }
7880 if let Some ( parent) = path. parent ( ) {
79- std:: fs:: create_dir_all ( parent) ?;
81+ ensure_directory_without_symlink ( parent) ?;
82+ }
83+
84+ let snippet = startup_snippet ( ) ;
85+ match read_startup_file ( path) ? {
86+ StartupFile :: Regular ( content) if content == snippet => return Ok ( ( ) ) ,
87+ StartupFile :: Regular ( _) | StartupFile :: Missing => { }
88+ StartupFile :: Unsafe => return Ok ( ( ) ) ,
8089 }
81- std :: fs :: write ( path, snippet)
90+ write_startup_file ( path, & snippet)
8291}
8392
8493fn append_startup_snippet ( path : & Path ) -> std:: io:: Result < ( ) > {
85- let existing = match std:: fs:: read_to_string ( path) {
86- Ok ( content) => content,
87- Err ( error) if error. kind ( ) == std:: io:: ErrorKind :: NotFound => return Ok ( ( ) ) ,
88- Err ( error) => return Err ( error) ,
94+ if let Some ( parent) = path. parent ( )
95+ && !existing_directory_without_symlink ( parent) ?
96+ {
97+ return Ok ( ( ) ) ;
98+ }
99+
100+ let existing = match read_startup_file ( path) ? {
101+ StartupFile :: Regular ( content) => content,
102+ StartupFile :: Missing | StartupFile :: Unsafe => return Ok ( ( ) ) ,
89103 } ;
90104 if existing. contains ( STARTUP_SNIPPET_MARKER ) {
91105 return Ok ( ( ) ) ;
92106 }
93107
94108 let snippet = startup_snippet ( ) ;
95- let mut file = std :: fs :: OpenOptions :: new ( ) . append ( true ) . open ( path) ?;
109+ let mut file = open_startup_file_for_append ( path) ?;
96110 if !existing. is_empty ( ) && !existing. ends_with ( '\n' ) {
97111 file. write_all ( b"\n " ) ?;
98112 }
99113 file. write_all ( snippet. as_bytes ( ) )
100114}
101115
116+ fn read_startup_file ( path : & Path ) -> std:: io:: Result < StartupFile > {
117+ let metadata = match std:: fs:: symlink_metadata ( path) {
118+ Ok ( metadata) => metadata,
119+ Err ( error) if error. kind ( ) == std:: io:: ErrorKind :: NotFound => {
120+ return Ok ( StartupFile :: Missing ) ;
121+ }
122+ Err ( error) => return Err ( error) ,
123+ } ;
124+ let file_type = metadata. file_type ( ) ;
125+ if file_type. is_symlink ( ) || !file_type. is_file ( ) {
126+ tracing:: debug!(
127+ path = %path. display( ) ,
128+ "skipping OpenShell PATH startup repair for non-regular file"
129+ ) ;
130+ return Ok ( StartupFile :: Unsafe ) ;
131+ }
132+
133+ let mut content = String :: new ( ) ;
134+ open_startup_file_for_read ( path) ?. read_to_string ( & mut content) ?;
135+ Ok ( StartupFile :: Regular ( content) )
136+ }
137+
138+ fn ensure_directory_without_symlink ( path : & Path ) -> std:: io:: Result < ( ) > {
139+ match std:: fs:: symlink_metadata ( path) {
140+ Ok ( metadata) if metadata. file_type ( ) . is_symlink ( ) => Err ( std:: io:: Error :: other ( format ! (
141+ "directory '{}' is a symlink" ,
142+ path. display( )
143+ ) ) ) ,
144+ Ok ( metadata) if metadata. is_dir ( ) => Ok ( ( ) ) ,
145+ Ok ( _) => Err ( std:: io:: Error :: other ( format ! (
146+ "'{}' is not a directory" ,
147+ path. display( )
148+ ) ) ) ,
149+ Err ( error) if error. kind ( ) == std:: io:: ErrorKind :: NotFound => {
150+ if let Some ( parent) = path. parent ( ) {
151+ ensure_directory_without_symlink ( parent) ?;
152+ }
153+ std:: fs:: create_dir ( path)
154+ }
155+ Err ( error) => Err ( error) ,
156+ }
157+ }
158+
159+ fn existing_directory_without_symlink ( path : & Path ) -> std:: io:: Result < bool > {
160+ if let Some ( parent) = path. parent ( )
161+ && parent != path
162+ && !parent. as_os_str ( ) . is_empty ( )
163+ && !existing_directory_without_symlink ( parent) ?
164+ {
165+ return Ok ( false ) ;
166+ }
167+
168+ match std:: fs:: symlink_metadata ( path) {
169+ Ok ( metadata) if metadata. file_type ( ) . is_symlink ( ) => {
170+ tracing:: debug!(
171+ path = %path. display( ) ,
172+ "skipping OpenShell PATH startup repair through symlink directory"
173+ ) ;
174+ Ok ( false )
175+ }
176+ Ok ( metadata) if metadata. is_dir ( ) => Ok ( true ) ,
177+ Ok ( _) => {
178+ tracing:: debug!(
179+ path = %path. display( ) ,
180+ "skipping OpenShell PATH startup repair through non-directory path"
181+ ) ;
182+ Ok ( false )
183+ }
184+ Err ( error) if error. kind ( ) == std:: io:: ErrorKind :: NotFound => Ok ( false ) ,
185+ Err ( error) => Err ( error) ,
186+ }
187+ }
188+
189+ fn write_startup_file ( path : & Path , content : & str ) -> std:: io:: Result < ( ) > {
190+ let mut file = no_follow_options ( )
191+ . write ( true )
192+ . create ( true )
193+ . truncate ( true )
194+ . open ( path) ?;
195+ ensure_opened_file_is_regular ( & file, path) ?;
196+ file. write_all ( content. as_bytes ( ) )
197+ }
198+
199+ fn open_startup_file_for_read ( path : & Path ) -> std:: io:: Result < std:: fs:: File > {
200+ let file = no_follow_options ( ) . read ( true ) . open ( path) ?;
201+ ensure_opened_file_is_regular ( & file, path) ?;
202+ Ok ( file)
203+ }
204+
205+ fn open_startup_file_for_append ( path : & Path ) -> std:: io:: Result < std:: fs:: File > {
206+ let file = no_follow_options ( ) . append ( true ) . open ( path) ?;
207+ ensure_opened_file_is_regular ( & file, path) ?;
208+ Ok ( file)
209+ }
210+
211+ fn ensure_opened_file_is_regular ( file : & std:: fs:: File , path : & Path ) -> std:: io:: Result < ( ) > {
212+ let metadata = file. metadata ( ) ?;
213+ if metadata. is_file ( ) {
214+ return Ok ( ( ) ) ;
215+ }
216+ Err ( std:: io:: Error :: other ( format ! (
217+ "'{}' is not a regular file" ,
218+ path. display( )
219+ ) ) )
220+ }
221+
222+ fn no_follow_options ( ) -> std:: fs:: OpenOptions {
223+ let mut options = std:: fs:: OpenOptions :: new ( ) ;
224+ #[ cfg( unix) ]
225+ {
226+ use std:: os:: unix:: fs:: OpenOptionsExt as _;
227+ options. custom_flags ( libc:: O_NOFOLLOW | libc:: O_CLOEXEC ) ;
228+ }
229+ options
230+ }
231+
102232pub fn proxy_env_vars ( proxy_url : & str ) -> [ ( & ' static str , String ) ; 9 ] {
103233 [
104234 ( "ALL_PROXY" , proxy_url. to_owned ( ) ) ,
@@ -211,6 +341,96 @@ mod tests {
211341 assert ! ( bashrc. contains( "export PATH=\" /sandbox/.venv/bin:/usr/local/bin:/usr/bin:/bin\" " ) ) ;
212342 }
213343
344+ #[ cfg( unix) ]
345+ #[ test]
346+ fn profile_snippet_skips_symlink ( ) {
347+ use std:: os:: unix:: fs:: symlink;
348+
349+ let dir = tempfile:: tempdir ( ) . expect ( "tempdir" ) ;
350+ let target = dir. path ( ) . join ( "target" ) ;
351+ std:: fs:: write ( & target, "keep me" ) . expect ( "write target" ) ;
352+ let profile_path = dir. path ( ) . join ( "openshell-path.sh" ) ;
353+ symlink ( & target, & profile_path) . expect ( "symlink profile" ) ;
354+
355+ write_profile_snippet ( & profile_path) . expect ( "skip symlink profile" ) ;
356+
357+ assert_eq ! (
358+ std:: fs:: read_to_string( & target) . expect( "read target" ) ,
359+ "keep me"
360+ ) ;
361+ assert ! (
362+ std:: fs:: symlink_metadata( & profile_path)
363+ . expect( "profile metadata" )
364+ . file_type( )
365+ . is_symlink( )
366+ ) ;
367+ }
368+
369+ #[ cfg( unix) ]
370+ #[ test]
371+ fn profile_snippet_rejects_symlink_parent ( ) {
372+ use std:: os:: unix:: fs:: symlink;
373+
374+ let dir = tempfile:: tempdir ( ) . expect ( "tempdir" ) ;
375+ let target_dir = dir. path ( ) . join ( "target-dir" ) ;
376+ std:: fs:: create_dir ( & target_dir) . expect ( "target dir" ) ;
377+ let parent = dir. path ( ) . join ( "profile.d" ) ;
378+ symlink ( & target_dir, & parent) . expect ( "symlink parent" ) ;
379+ let profile_path = parent. join ( "openshell-path.sh" ) ;
380+
381+ let error = write_profile_snippet ( & profile_path) . expect_err ( "reject symlink parent" ) ;
382+
383+ assert ! ( error. to_string( ) . contains( "symlink" ) ) ;
384+ assert ! ( !target_dir. join( "openshell-path.sh" ) . exists( ) ) ;
385+ }
386+
387+ #[ cfg( unix) ]
388+ #[ test]
389+ fn bashrc_snippet_skips_symlink ( ) {
390+ use std:: os:: unix:: fs:: symlink;
391+
392+ let dir = tempfile:: tempdir ( ) . expect ( "tempdir" ) ;
393+ let target = dir. path ( ) . join ( "target" ) ;
394+ std:: fs:: write ( & target, "keep me" ) . expect ( "write target" ) ;
395+ let bashrc_path = dir. path ( ) . join ( ".bashrc" ) ;
396+ symlink ( & target, & bashrc_path) . expect ( "symlink bashrc" ) ;
397+
398+ append_startup_snippet ( & bashrc_path) . expect ( "skip symlink bashrc" ) ;
399+
400+ assert_eq ! (
401+ std:: fs:: read_to_string( & target) . expect( "read target" ) ,
402+ "keep me"
403+ ) ;
404+ assert ! (
405+ std:: fs:: symlink_metadata( & bashrc_path)
406+ . expect( "bashrc metadata" )
407+ . file_type( )
408+ . is_symlink( )
409+ ) ;
410+ }
411+
412+ #[ cfg( unix) ]
413+ #[ test]
414+ fn bashrc_snippet_skips_symlink_parent ( ) {
415+ use std:: os:: unix:: fs:: symlink;
416+
417+ let dir = tempfile:: tempdir ( ) . expect ( "tempdir" ) ;
418+ let target_dir = dir. path ( ) . join ( "target-dir" ) ;
419+ std:: fs:: create_dir ( & target_dir) . expect ( "target dir" ) ;
420+ std:: fs:: write (
421+ target_dir. join ( ".bashrc" ) ,
422+ "export PATH=\" /sandbox/.venv/bin:/usr/local/bin:/usr/bin:/bin\" \n " ,
423+ )
424+ . expect ( "target bashrc" ) ;
425+ let home = dir. path ( ) . join ( "home" ) ;
426+ symlink ( & target_dir, & home) . expect ( "symlink home" ) ;
427+
428+ append_startup_snippet ( & home. join ( ".bashrc" ) ) . expect ( "skip symlink home" ) ;
429+
430+ let bashrc = std:: fs:: read_to_string ( target_dir. join ( ".bashrc" ) ) . expect ( "read target" ) ;
431+ assert ! ( !bashrc. contains( STARTUP_SNIPPET_MARKER ) ) ;
432+ }
433+
214434 #[ test]
215435 fn apply_proxy_env_includes_node_proxy_opt_in_and_local_bypass ( ) {
216436 let mut cmd = Command :: new ( "/usr/bin/env" ) ;
0 commit comments