Skip to content

Commit 00ba7fa

Browse files
committed
fix(module): never delete a staged directory, and fall back to the mirror
Disabling or deleting a module unlinked <config>/modules/<id> with a helper that recursively deletes a real directory, which is a developer's own staged build and their only copy; those paths now refuse it and say so, while pkg enable keeps replacing it as documented. The artifact loop also treated an HTTP error as a successful download, so a deleted release asset was 'downloaded' as an error page and the mirror after it was never tried.
1 parent 86c31b1 commit 00ba7fa

3 files changed

Lines changed: 100 additions & 9 deletions

File tree

rust/crates/spicetify/src/module/mod.rs

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,15 @@ pub(crate) fn install(paths: &ModulePaths, id: &StoreIdentifier) -> Result<()> {
116116
// Artifacts are listed in preference order and later entries are
117117
// mirrors of the same bytes, so a host that has gone away costs an
118118
// attempt rather than the install.
119-
match client.get(artifact).send().and_then(reqwest::blocking::Response::bytes) {
119+
// error_for_status first: reqwest calls a 404 Ok, so without it a
120+
// deleted release asset would be "downloaded" as an error page and
121+
// the mirror after it in the list would never be tried.
122+
match client
123+
.get(artifact)
124+
.send()
125+
.and_then(reqwest::blocking::Response::error_for_status)
126+
.and_then(reqwest::blocking::Response::bytes)
127+
{
120128
Ok(bytes) => {
121129
downloaded = Some(bytes);
122130
break;
@@ -176,12 +184,15 @@ pub(crate) fn enable(paths: &ModulePaths, id: &StoreIdentifier) -> Result<()> {
176184
}
177185

178186
let link = id.module_link_path(&paths.modules_root);
179-
if let Err(e) = crate::util::remove_dir_link(&link) {
180-
tracing::warn!(error = %e, path = %link.display(), "failed to remove link");
181-
}
182-
if enabled.is_some() {
187+
if let Some(_version) = enabled.as_ref() {
188+
// create_dir_link replaces whatever is there, including a real
189+
// directory: switching a hand-staged build over to a store version is
190+
// what `pkg enable` is for.
183191
let src = id.store_path(&paths.store_root);
184192
super::util::link::create_dir_link(&src, &link)?;
193+
} else if let Err(e) = crate::util::remove_link_only(&link) {
194+
// Disabling must never delete a developer's staged directory.
195+
tracing::warn!(error = %e, path = %link.display(), "failed to remove link");
185196
}
186197
vault::save(&paths.vault_path, &v)?;
187198
Ok(())
@@ -194,7 +205,9 @@ pub(crate) fn delete(paths: &ModulePaths, id: &StoreIdentifier) -> Result<()> {
194205
module.enabled = None;
195206
if !id.module_identifier.contains('/') {
196207
let link = id.module_link_path(&paths.modules_root);
197-
if let Err(e) = crate::util::remove_dir_link(&link) {
208+
// Deleting a package unlinks it; it never deletes a real
209+
// directory, which is a developer's own staged build.
210+
if let Err(e) = crate::util::remove_link_only(&link) {
198211
tracing::warn!(error = %e, path = %link.display(), "failed to remove link");
199212
}
200213
}

rust/crates/spicetify/src/util/link.rs

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

rust/crates/spicetify/src/util/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ pub(crate) mod archive;
44
pub(crate) mod link;
55

66
pub(crate) use archive::{untar_zst_bytes, unzip_file};
7-
pub(crate) use link::{create_dir_link, remove_dir_link};
7+
pub(crate) use link::{create_dir_link, remove_link_only};
88

99
#[derive(Debug, Error)]
1010
pub(crate) enum ArchiveError {

0 commit comments

Comments
 (0)