@@ -77,7 +77,7 @@ mod tests {
7777}
7878
7979#[ cfg( windows) ]
80- pub ( crate ) fn remove_dir_link ( link : & Path ) -> Result < ( ) > {
80+ fn remove_dir_link ( link : & Path ) -> Result < ( ) > {
8181 if let Err ( e) = std:: fs:: remove_dir ( link)
8282 && e. kind ( ) != std:: io:: ErrorKind :: NotFound
8383 {
@@ -86,12 +86,24 @@ pub(crate) fn remove_dir_link(link: &Path) -> Result<()> {
8686 Ok ( ( ) )
8787}
8888
89+ /// See the unix definition. `remove_dir` removes a junction but refuses a
90+ /// non-empty real directory, so the guarantee holds here without a check.
91+ #[ cfg( windows) ]
92+ pub ( crate ) fn remove_link_only ( link : & Path ) -> Result < ( ) > {
93+ remove_dir_link ( link)
94+ }
95+
8996/// Removes `link`, whether it is a symlink or a real directory left behind by
9097/// an older install. Branches on `symlink_metadata` rather than on the error
9198/// from `remove_file`, which is `IsADirectory` on Linux but `PermissionDenied`
9299/// on macOS.
100+ ///
101+ /// Replacing a real directory here is intended: `pkg enable` switching a
102+ /// hand-staged build over to a store version is the documented way to do it,
103+ /// and the user asked for it. Removal on its own goes through
104+ /// `remove_link_only`.
93105#[ cfg( not( windows) ) ]
94- pub ( crate ) fn remove_dir_link ( link : & Path ) -> Result < ( ) > {
106+ fn remove_dir_link ( link : & Path ) -> Result < ( ) > {
95107 let meta = match std:: fs:: symlink_metadata ( link) {
96108 Ok ( meta) => meta,
97109 Err ( e) if e. kind ( ) == std:: io:: ErrorKind :: NotFound => return Ok ( ( ) ) ,
@@ -103,3 +115,69 @@ pub(crate) fn remove_dir_link(link: &Path) -> Result<()> {
103115 std:: fs:: remove_file ( link) . map_err ( Into :: into)
104116 }
105117}
118+
119+ /// Unlinks `link` without ever deleting a real directory.
120+ ///
121+ /// `<config>/modules/<id>` is a link into the store for anything the store
122+ /// installed, but it is a real directory when a developer staged a build
123+ /// there by hand, and that directory is their source. Disabling or deleting a
124+ /// module must not take it: the caller is told instead, so the removal stays
125+ /// a deliberate act.
126+ #[ cfg( not( windows) ) ]
127+ pub ( crate ) fn remove_link_only ( link : & Path ) -> Result < ( ) > {
128+ let meta = match std:: fs:: symlink_metadata ( link) {
129+ Ok ( meta) => meta,
130+ Err ( e) if e. kind ( ) == std:: io:: ErrorKind :: NotFound => return Ok ( ( ) ) ,
131+ Err ( e) => return Err ( e. into ( ) ) ,
132+ } ;
133+ if meta. is_dir ( ) {
134+ anyhow:: bail!(
135+ "{} is a real directory, not a link into the store; refusing to delete it" ,
136+ link. display( )
137+ ) ;
138+ }
139+ std:: fs:: remove_file ( link) . map_err ( Into :: into)
140+ }
141+
142+ #[ cfg( all( test, not( windows) ) ) ]
143+ mod removal_tests {
144+ use super :: * ;
145+
146+ fn scratch ( name : & str ) -> std:: path:: PathBuf {
147+ let dir =
148+ std:: env:: temp_dir ( ) . join ( format ! ( "spicetify-rm-{name}-{}" , std:: process:: id( ) ) ) ;
149+ let _ = std:: fs:: remove_dir_all ( & dir) ;
150+ std:: fs:: create_dir_all ( & dir) . expect ( "temp dir" ) ;
151+ dir
152+ }
153+
154+ #[ test]
155+ fn remove_link_only_refuses_a_developers_staged_directory ( ) {
156+ let dir = scratch ( "staged" ) ;
157+ let staged = dir. join ( "my-mod" ) ;
158+ std:: fs:: create_dir_all ( & staged) . expect ( "staged dir" ) ;
159+ std:: fs:: write ( staged. join ( "mod.tsx" ) , "source that exists nowhere else" ) . expect ( "source" ) ;
160+
161+ let err = remove_link_only ( & staged) . expect_err ( "a real directory must not be deleted" ) ;
162+ assert ! ( err. to_string( ) . contains( "refusing to delete" ) , "{err}" ) ;
163+ assert ! ( staged. join( "mod.tsx" ) . is_file( ) , "the developer's source survives" ) ;
164+
165+ std:: fs:: remove_dir_all ( & dir) . expect ( "cleanup" ) ;
166+ }
167+
168+ #[ test]
169+ fn remove_link_only_removes_a_store_link_and_leaves_its_target ( ) {
170+ let dir = scratch ( "store-link" ) ;
171+ let target = dir. join ( "store-copy" ) ;
172+ let link = dir. join ( "my-mod" ) ;
173+ std:: fs:: create_dir_all ( & target) . expect ( "target" ) ;
174+ std:: fs:: write ( target. join ( "index.js" ) . as_path ( ) , "built" ) . expect ( "target file" ) ;
175+ std:: os:: unix:: fs:: symlink ( & target, & link) . expect ( "link" ) ;
176+
177+ remove_link_only ( & link) . expect ( "a link is removable" ) ;
178+ assert ! ( std:: fs:: symlink_metadata( & link) . is_err( ) , "link is gone" ) ;
179+ assert ! ( target. join( "index.js" ) . is_file( ) , "the store copy is untouched" ) ;
180+
181+ std:: fs:: remove_dir_all ( & dir) . expect ( "cleanup" ) ;
182+ }
183+ }
0 commit comments