aboutsummaryrefslogtreecommitdiff
path: root/src/integration
diff options
context:
space:
mode:
authorakbash <akbash@herdr.dev>2026-09-14 23:13:52 +0300
committerGitHub <noreply@github.com>2026-09-14 22:13:52 +0200
commit5e38e5fb5fc2cc5d30844df71432a60742bcb572 (patch)
tree31896855b510cf03c57e19e4e3ef454bef028a59 /src/integration
parentb0f21ed2cb85613050b794bf6c78a7c367a94e0d (diff)
fix: protect integration configs from failed writes (#3973)
* fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 --------- Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com>
Diffstat (limited to 'src/integration')
-rw-r--r--src/integration/config_file.rs147
-rw-r--r--src/integration/config_file/tests.rs243
-rw-r--r--src/integration/mod.rs1
-rw-r--r--src/integration/opencode_config.rs51
-rw-r--r--src/integration/targets.rs98
-rw-r--r--src/integration/tests.rs83
6 files changed, 586 insertions, 37 deletions
diff --git a/src/integration/config_file.rs b/src/integration/config_file.rs
new file mode 100644
index 00000000..11e34c3d
--- /dev/null
+++ b/src/integration/config_file.rs
@@ -0,0 +1,147 @@
+//! Protected writes for user-owned integration configuration, not managed assets.
+
+use std::fs::{self, OpenOptions};
+use std::io;
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicU64, Ordering};
+
+#[cfg(test)]
+mod tests;
+
+static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
+
+/// Check before changing assets as well as immediately before replacing a config.
+/// This is deliberately not config parsing or a transaction across multiple files.
+pub(super) fn check_config_targets(dir: &Path, names: &[&str]) -> io::Result<()> {
+ for name in names {
+ check_config_target(&dir.join(name))?;
+ }
+ Ok(())
+}
+
+pub(super) fn check_config_target(path: &Path) -> io::Result<()> {
+ reject_hard_links(path)?;
+ crate::platform::check_config_write_target(&resolve_target(path)?)
+}
+
+fn reject_hard_links(path: &Path) -> io::Result<()> {
+ let metadata = match fs::metadata(path) {
+ Ok(metadata) => metadata,
+ Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
+ Err(error) => return Err(error),
+ };
+ if metadata.is_file() && crate::platform::config_file_link_count(path)? > 1 {
+ return Err(io::Error::other(format!(
+ "cannot update {}: config has multiple hard links; use a separate file or a symlink before retrying",
+ path.display()
+ )));
+ }
+ Ok(())
+}
+
+// Unlike canonicalize, this also follows dangling symlinks on a first install.
+fn resolve_target(path: &Path) -> io::Result<PathBuf> {
+ let mut current = path.to_path_buf();
+ for _ in 0..40 {
+ match fs::symlink_metadata(&current) {
+ Ok(metadata) if metadata.file_type().is_symlink() => {
+ let link = fs::read_link(&current)?;
+ current = if link.is_absolute() {
+ link
+ } else {
+ current.parent().unwrap_or(Path::new(".")).join(link)
+ };
+ }
+ Ok(metadata) if !metadata.is_file() => {
+ return Err(io::Error::other(format!(
+ "cannot update {}: config is not a regular file",
+ path.display()
+ )));
+ }
+ Ok(_) => return Ok(current),
+ Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(current),
+ Err(error) => return Err(error),
+ }
+ }
+ Err(io::Error::other(format!(
+ "cannot update {}: too many symbolic links",
+ path.display()
+ )))
+}
+
+pub(super) fn write_config(path: &Path, contents: impl AsRef<[u8]>) -> io::Result<()> {
+ check_config_target(path)?;
+ let target = resolve_target(path)?;
+ if crate::platform::write_existing_config(&target, contents.as_ref())? {
+ return Ok(());
+ }
+ let replacement = Replacement::prepare(&target, contents.as_ref())?;
+ replacement.commit()
+}
+
+struct Replacement {
+ target: PathBuf,
+ temporary: PathBuf,
+}
+
+impl Replacement {
+ fn prepare(path: &Path, contents: &[u8]) -> io::Result<Self> {
+ reject_hard_links(path)?;
+ let target = resolve_target(path)?;
+ let existing = match fs::metadata(&target) {
+ Ok(_) => {
+ // A writable parent must not let rename bypass file write permissions.
+ OpenOptions::new().read(true).write(true).open(&target)?;
+ Some(target.as_path())
+ }
+ Err(error) if error.kind() == io::ErrorKind::NotFound => None,
+ Err(error) => return Err(error),
+ };
+ let parent = target
+ .parent()
+ .filter(|p| !p.as_os_str().is_empty())
+ .unwrap_or(Path::new("."));
+ for _ in 0..128 {
+ let sequence = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
+ let temporary = parent.join(format!(
+ ".herdr-config-{}-{sequence}.tmp",
+ std::process::id()
+ ));
+ // Existing configs can contain secrets. Start their staging file private;
+ // the platform writer preserves the original permissions before publication.
+ // New configs retain ordinary create/umask/inherited-ACL defaults.
+ let created = crate::platform::create_config_temporary(&temporary, existing.is_some());
+ match created {
+ Ok(file) => drop(file),
+ Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
+ Err(error) => return Err(error),
+ }
+ let replacement = Self {
+ target: target.clone(),
+ temporary,
+ };
+ crate::platform::write_config_temporary(existing, &replacement.temporary, contents)?;
+ return Ok(replacement);
+ }
+ Err(io::Error::new(
+ io::ErrorKind::AlreadyExists,
+ "could not allocate a unique config temporary file",
+ ))
+ }
+
+ fn commit(self) -> io::Result<()> {
+ reject_hard_links(&self.target)?;
+ crate::platform::replace_file(&self.temporary, &self.target)
+ }
+}
+
+impl Drop for Replacement {
+ fn drop(&mut self) {
+ // After publication the temporary name is absent. Never remove the target.
+ if let Err(error) = fs::remove_file(&self.temporary) {
+ if error.kind() != io::ErrorKind::NotFound {
+ tracing::warn!(path = %self.temporary.display(), %error, "failed to remove integration config temporary file");
+ }
+ }
+ }
+}
diff --git a/src/integration/config_file/tests.rs b/src/integration/config_file/tests.rs
new file mode 100644
index 00000000..916edc67
--- /dev/null
+++ b/src/integration/config_file/tests.rs
@@ -0,0 +1,243 @@
+use super::*;
+
+struct Directory(PathBuf);
+
+impl Directory {
+ fn new() -> Self {
+ let path = std::env::temp_dir().join(format!(
+ "herdr-config-write-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ ));
+ fs::create_dir(&path).unwrap();
+ Self(path)
+ }
+}
+
+impl Drop for Directory {
+ fn drop(&mut self) {
+ let _ = fs::remove_dir_all(&self.0);
+ }
+}
+
+// Windows existing files have separate native backup/recovery coverage.
+#[cfg(unix)]
+const ATOMIC_CASES: &[bool] = &[false, true];
+#[cfg(windows)]
+const ATOMIC_CASES: &[bool] = &[false];
+
+#[test]
+fn config_publication_keeps_old_content_until_commit() {
+ let dir = Directory::new();
+ for &existing in ATOMIC_CASES {
+ let path = dir.0.join(if existing { "existing" } else { "new" });
+ if existing {
+ fs::write(&path, b"old preferences").unwrap();
+ }
+ let staged = Replacement::prepare(&path, b"complete new preferences").unwrap();
+ if existing {
+ assert_eq!(fs::read(&path).unwrap(), b"old preferences");
+ } else {
+ assert!(!path.exists());
+ }
+ assert_eq!(
+ fs::read(&staged.temporary).unwrap(),
+ b"complete new preferences"
+ );
+ staged.commit().unwrap();
+ assert_eq!(fs::read(&path).unwrap(), b"complete new preferences");
+ }
+ assert_eq!(fs::read_dir(&dir.0).unwrap().count(), ATOMIC_CASES.len());
+}
+
+#[test]
+fn abandoned_and_failed_publication_leave_config_unchanged() {
+ for &existing in ATOMIC_CASES {
+ let dir = Directory::new();
+ let path = dir.0.join("config");
+ if existing {
+ fs::write(&path, b"original").unwrap();
+ }
+ drop(Replacement::prepare(&path, b"new").unwrap());
+ let staged = Replacement::prepare(&path, b"new").unwrap();
+ fs::remove_file(&staged.temporary).unwrap();
+ assert_eq!(staged.commit().unwrap_err().kind(), io::ErrorKind::NotFound);
+ if existing {
+ assert_eq!(fs::read(&path).unwrap(), b"original");
+ } else {
+ assert!(!path.exists());
+ }
+ assert_eq!(fs::read_dir(&dir.0).unwrap().count(), usize::from(existing));
+ assert!(write_config(&dir.0, b"not a file").is_err());
+ assert!(dir.0.is_dir());
+ }
+}
+
+#[test]
+fn hard_links_are_rejected_before_staging_and_rechecked_before_commit() {
+ let dir = Directory::new();
+ let path = dir.0.join("config");
+ let alias = dir.0.join("alias");
+ fs::write(&path, b"original").unwrap();
+ #[cfg(unix)]
+ let staged = Replacement::prepare(&path, b"new").unwrap();
+ fs::hard_link(&path, &alias).unwrap();
+ #[cfg(unix)]
+ assert!(staged
+ .commit()
+ .unwrap_err()
+ .to_string()
+ .contains("multiple hard links"));
+ for candidate in [&path, &alias] {
+ let error = write_config(candidate, b"new").unwrap_err().to_string();
+ assert!(error.contains(&candidate.display().to_string()));
+ assert_eq!(fs::read(candidate).unwrap(), b"original");
+ assert_eq!(
+ crate::platform::config_file_link_count(candidate).unwrap(),
+ 2
+ );
+ }
+ assert_eq!(fs::read_dir(&dir.0).unwrap().count(), 2);
+}
+
+#[cfg(unix)]
+fn symlink(target: &Path, link: &Path) {
+ std::os::unix::fs::symlink(target, link).unwrap();
+}
+#[cfg(windows)]
+fn symlink(target: &Path, link: &Path) {
+ std::os::windows::fs::symlink_file(target, link).unwrap();
+}
+
+#[test]
+fn symlink_chains_and_dangling_targets_preserve_links() {
+ let dir = Directory::new();
+ let other = dir.0.join("other");
+ fs::create_dir(&other).unwrap();
+ let target = other.join("preferences");
+ let intermediate = other.join("link");
+ let entry = dir.0.join("config");
+ symlink(&target, &intermediate);
+ // A Windows reparse target needs native separators, unlike ordinary Win32 paths.
+ let relative_target = Path::new("other").join("link");
+ symlink(&relative_target, &entry);
+ let original_intermediate = fs::read_link(&intermediate).unwrap();
+ assert_eq!(
+ fs::metadata(&entry).unwrap_err().kind(),
+ io::ErrorKind::NotFound
+ );
+ write_config(&entry, b"first install").unwrap();
+ write_config(&entry, b"second install").unwrap();
+ assert_eq!(fs::read(&target).unwrap(), b"second install");
+ assert_eq!(fs::read_link(&entry).unwrap(), relative_target);
+ assert_eq!(fs::read_link(&intermediate).unwrap(), original_intermediate);
+ assert_eq!(fs::read_dir(&other).unwrap().count(), 2);
+ let alias = other.join("hard-link");
+ fs::hard_link(&target, &alias).unwrap();
+ assert!(write_config(&entry, b"must not change").is_err());
+ assert_eq!(fs::read(&alias).unwrap(), b"second install");
+ assert_eq!(fs::read_link(&entry).unwrap(), relative_target);
+
+ let cycle = dir.0.join("cycle");
+ symlink(Path::new("cycle"), &cycle);
+ assert!(write_config(&cycle, b"must not replace the link").is_err());
+ assert_eq!(fs::read_link(&cycle).unwrap(), Path::new("cycle"));
+}
+
+#[test]
+fn existing_permissions_and_new_file_defaults_are_preserved() {
+ let dir = Directory::new();
+ let path = dir.0.join("existing");
+ fs::write(&path, b"original").unwrap();
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
+ }
+ let permissions = fs::metadata(&path).unwrap().permissions();
+ write_config(&path, b"new").unwrap();
+ assert_eq!(fs::metadata(&path).unwrap().permissions(), permissions);
+ let ordinary = dir.0.join("ordinary");
+ let new = dir.0.join("new");
+ fs::write(&ordinary, b"ordinary creation").unwrap();
+ write_config(&new, b"atomic creation").unwrap();
+ assert_eq!(
+ fs::metadata(&ordinary).unwrap().permissions(),
+ fs::metadata(&new).unwrap().permissions()
+ );
+}
+
+#[cfg(unix)]
+#[test]
+fn writable_directory_does_not_bypass_read_only_config() {
+ use std::os::unix::{fs::PermissionsExt, process::CommandExt};
+ const CHILD: &str = "HERDR_CONFIG_READ_ONLY_TEST";
+ if let Some(path) = std::env::var_os(CHILD) {
+ let error = write_config(Path::new(&path), b"must not replace").unwrap_err();
+ assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
+ println!("read-only rejection executed");
+ return;
+ }
+ let dir = Directory::new();
+ let path = dir.0.join("read-only");
+ fs::write(&path, b"original").unwrap();
+ fs::set_permissions(&path, fs::Permissions::from_mode(0o444)).unwrap();
+ fs::set_permissions(&dir.0, fs::Permissions::from_mode(0o777)).unwrap();
+ let mut child = std::process::Command::new(std::env::current_exe().unwrap());
+ child
+ .args([
+ "--exact",
+ "integration::config_file::tests::writable_directory_does_not_bypass_read_only_config",
+ "--nocapture",
+ ])
+ .env(CHILD, &path);
+ // Root bypasses Unix mode checks. Test the real user path in a child instead.
+ if unsafe { libc::geteuid() } == 0 {
+ child.gid(65534).uid(65534);
+ }
+ let output = child.output().unwrap();
+ assert!(output.status.success(), "child failed: {output:?}");
+ assert!(String::from_utf8_lossy(&output.stdout).contains("read-only rejection executed"));
+ assert_eq!(fs::read(&path).unwrap(), b"original");
+ assert_eq!(fs::read_dir(&dir.0).unwrap().count(), 1);
+}
+
+#[cfg(unix)]
+#[test]
+fn partial_write_errors_preserve_files_and_do_not_remove_collisions() {
+ const CHILD: &str = "HERDR_CONFIG_PARTIAL_WRITE_TEST";
+ if let Some(path) = std::env::var_os(CHILD) {
+ let dir = PathBuf::from(path);
+ // This process runs only this test. A collision must neither be used nor removed.
+ NEXT_TEMP.store(0, Ordering::Relaxed);
+ let collision = dir.join(format!(".herdr-config-{}-0.tmp", std::process::id()));
+ fs::write(&collision, b"unrelated file").unwrap();
+ for name in ["existing", "new"] {
+ let error = write_config(&dir.join(name), vec![b'x'; 8192]).unwrap_err();
+ assert_eq!(error.raw_os_error(), Some(libc::EFBIG));
+ }
+ assert_eq!(fs::read(collision).unwrap(), b"unrelated file");
+ println!("partial-write paths executed");
+ return;
+ }
+ let dir = Directory::new();
+ fs::write(dir.0.join("existing"), b"original").unwrap();
+ let output = std::process::Command::new("bash")
+ .args(["-c", "trap '' XFSZ; ulimit -f 1; exec \"$@\"", "herdr-test"])
+ .arg(std::env::current_exe().unwrap())
+ .args(["--exact", "integration::config_file::tests::partial_write_errors_preserve_files_and_do_not_remove_collisions", "--nocapture"])
+ .env(CHILD, &dir.0)
+ .output().unwrap();
+ assert!(output.status.success(), "child failed: {output:?}");
+ assert!(String::from_utf8_lossy(&output.stdout).contains("partial-write paths executed"));
+ assert_eq!(fs::read(dir.0.join("existing")).unwrap(), b"original");
+ assert!(!dir.0.join("new").exists());
+ assert_eq!(
+ fs::read_dir(&dir.0).unwrap().count(),
+ 2,
+ "only original and collision may remain"
+ );
+}
diff --git a/src/integration/mod.rs b/src/integration/mod.rs
index 97163da5..daa714e0 100644
--- a/src/integration/mod.rs
+++ b/src/integration/mod.rs
@@ -2,6 +2,7 @@ mod actions;
mod claude_settings;
mod command;
mod config_edit;
+mod config_file;
mod env;
mod file_ops;
mod opencode_config;
diff --git a/src/integration/opencode_config.rs b/src/integration/opencode_config.rs
index 2ab91e84..b062365b 100644
--- a/src/integration/opencode_config.rs
+++ b/src/integration/opencode_config.rs
@@ -6,6 +6,8 @@ use jsonc_parser::cst::{CstInputValue, CstRootNode};
use jsonc_parser::ParseOptions;
use serde_json::Value;
+use super::config_file::{check_config_target, write_config};
+
const TUI_CONFIG_NAME: &str = "tui.jsonc";
pub(crate) fn tui_config_path(config_dir: &Path) -> PathBuf {
@@ -44,6 +46,7 @@ pub(crate) fn add_cli_plugin(
plugin_spec: &str,
) -> io::Result<Option<PathBuf>> {
let path = config_dir.join("cli.json");
+ check_config_target(&path)?;
// OpenCode imports V1 TUI preferences (`tui.json`, `kv.json`) into cli.json on
// its first V2 start, but only while cli.json is absent. Defer registration
// while those sources still exist so we do not skip the migration; otherwise
@@ -60,6 +63,7 @@ fn cli_migration_pending(config_dir: &Path, state_dir: &Path) -> bool {
}
fn add_plugin(config_path: PathBuf, key: &str, plugin_spec: &str) -> io::Result<PathBuf> {
+ check_config_target(&config_path)?;
let content = if config_path.is_file() {
fs::read_to_string(&config_path)?
} else {
@@ -90,7 +94,7 @@ fn add_plugin(config_path: PathBuf, key: &str, plugin_spec: &str) -> io::Result<
}
}
- fs::write(&config_path, root.to_string())?;
+ write_config(&config_path, root.to_string())?;
Ok(config_path)
}
@@ -103,6 +107,7 @@ pub(crate) fn remove_cli_plugin(config_dir: &Path, plugin_spec: &str) -> io::Res
}
fn remove_plugin(config_path: &Path, key: &str, plugin_spec: &str) -> io::Result<bool> {
+ check_config_target(config_path)?;
if !config_path.is_file() {
return Ok(false);
}
@@ -133,7 +138,7 @@ fn remove_plugin(config_path: &Path, key: &str, plugin_spec: &str) -> io::Result
property.remove();
}
- fs::write(config_path, root.to_string())?;
+ write_config(config_path, root.to_string())?;
Ok(true)
}
@@ -245,6 +250,48 @@ mod tests {
.unwrap()
}
+ #[cfg(unix)]
+ #[test]
+ fn failed_cli_registration_preserves_existing_config() {
+ const CHILD_CONFIG: &str = "HERDR_TEST_3970_CONFIG_DIR";
+ if let Some(dir) = std::env::var_os(CHILD_CONFIG) {
+ let dir = PathBuf::from(dir);
+ let result = add_cli_plugin(&dir, &dir.join("state"), "./herdr-opencode");
+ assert_eq!(result.unwrap_err().raw_os_error(), Some(libc::EFBIG));
+ println!("registration reached the file-size limit");
+ return;
+ }
+
+ let dir = unique_dir();
+ let path = dir.join("cli.json");
+ let original = r#"{"theme":{"name":"catppuccin"},"plugins":["example"]}"#;
+ fs::write(&path, original).unwrap();
+ // Apply the limit only to a child, after seeding the existing preferences.
+ // Ignoring SIGXFSZ makes the kernel return EFBIG instead of killing it.
+ let output = std::process::Command::new("bash")
+ .args(["-c", "trap '' XFSZ; ulimit -f 0; exec \"$@\"", "herdr-test"])
+ .arg(std::env::current_exe().unwrap())
+ .args([
+ "--exact",
+ "integration::opencode_config::tests::failed_cli_registration_preserves_existing_config",
+ "--nocapture",
+ ])
+ .env(CHILD_CONFIG, &dir)
+ .output()
+ .unwrap();
+ let actual = fs::read_to_string(&path).unwrap();
+ let remaining_files = fs::read_dir(&dir).unwrap().count();
+ fs::remove_dir_all(&dir).unwrap();
+ assert!(output.status.success(), "child failed: {output:?}");
+ assert!(String::from_utf8_lossy(&output.stdout)
+ .contains("registration reached the file-size limit"));
+ assert_eq!(
+ actual, original,
+ "failed registration must preserve preferences"
+ );
+ assert_eq!(remaining_files, 1, "temporary files must be cleaned up");
+ }
+
#[test]
fn add_and_remove_tui_plugin_preserves_jsonc_config() {
let dir = unique_dir();
diff --git a/src/integration/targets.rs b/src/integration/targets.rs
index cab8f7ee..6cb90c18 100644
--- a/src/integration/targets.rs
+++ b/src/integration/targets.rs
@@ -19,6 +19,7 @@ use super::config_edit::{
remove_direct_hook_commands, remove_flat_command_hook, remove_hermes_plugin_enabled,
remove_hook_commands, remove_kimi_config_block, remove_simple_command_hook,
};
+use super::config_file::{check_config_targets, write_config};
use super::env::{
antigravity_cli_dir, claude_dir, codex_dir, copilot_dir, cursor_dir, devin_dir, droid_dir,
grok_dir, hermes_dir, hermes_plugin_dir, kilo_dir, kimi_dir, letta_dir, mastracode_dir,
@@ -122,6 +123,7 @@ pub(crate) fn remove_legacy_pi_extension_from_omp_dir(dir: &Path) -> io::Result<
pub(crate) fn install_claude() -> io::Result<ClaudeInstallPaths> {
let dir = claude_dir()?;
+ check_config_targets(&dir, &["settings.json"])?;
if !dir.is_dir() {
return Err(io::Error::other(format!(
"claude directory not found at {}. install claude code first",
@@ -146,7 +148,7 @@ pub(crate) fn install_claude() -> io::Result<ClaudeInstallPaths> {
remove_legacy_bash_hook_file(&hook_path)?;
if updated_settings != existing_settings {
- fs::write(&settings_path, updated_settings)?;
+ write_config(&settings_path, updated_settings)?;
}
Ok(ClaudeInstallPaths {
@@ -157,6 +159,7 @@ pub(crate) fn install_claude() -> io::Result<ClaudeInstallPaths> {
pub(crate) fn install_codex() -> io::Result<CodexInstallPaths> {
let dir = codex_dir()?;
+ check_config_targets(&dir, &["hooks.json", "config.toml"])?;
if !dir.is_dir() {
return Err(io::Error::other(format!(
"codex config directory not found at {}. install codex first",
@@ -198,7 +201,7 @@ pub(crate) fn install_codex() -> io::Result<CodexInstallPaths> {
)?;
remove_legacy_bash_hook_file(&hook_path)?;
- fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
+ write_config(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
let config_path = dir.join("config.toml");
let existing_config = if config_path.is_file() {
@@ -208,7 +211,7 @@ pub(crate) fn install_codex() -> io::Result<CodexInstallPaths> {
};
let new_config = build_codex_config_with_hooks(&existing_config);
if new_config != existing_config {
- fs::write(&config_path, new_config)?;
+ write_config(&config_path, new_config)?;
}
Ok(CodexInstallPaths {
@@ -220,6 +223,7 @@ pub(crate) fn install_codex() -> io::Result<CodexInstallPaths> {
pub(crate) fn install_kimi() -> io::Result<KimiInstallPaths> {
let dir = kimi_dir()?;
+ check_config_targets(&dir, &["config.toml"])?;
if !dir.is_dir() {
return Err(io::Error::other(format!(
"kimi code config directory not found at {}. install kimi code first",
@@ -242,7 +246,7 @@ pub(crate) fn install_kimi() -> io::Result<KimiInstallPaths> {
};
let new_config = build_kimi_config_with_hooks(&existing_config, &hook_path);
if new_config != existing_config {
- fs::write(&config_path, new_config)?;
+ write_config(&config_path, new_config)?;
}
remove_legacy_bash_hook_file(&hook_path)?;
@@ -254,6 +258,7 @@ pub(crate) fn install_kimi() -> io::Result<KimiInstallPaths> {
pub(crate) fn install_copilot() -> io::Result<CopilotInstallPaths> {
let dir = copilot_dir()?;
+ check_config_targets(&dir, &["settings.json"])?;
if !dir.is_dir() {
return Err(io::Error::other(format!(
"copilot config directory not found at {}. install github copilot cli first",
@@ -298,7 +303,7 @@ pub(crate) fn install_copilot() -> io::Result<CopilotInstallPaths> {
}
remove_legacy_bash_hook_file(&hook_path)?;
- fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+ write_config(&settings_path, serde_json::to_string_pretty(&settings)?)?;
Ok(CopilotInstallPaths {
hook_path,
@@ -308,6 +313,7 @@ pub(crate) fn install_copilot() -> io::Result<CopilotInstallPaths> {
pub(crate) fn install_devin() -> io::Result<DevinInstallPaths> {
let dir = devin_dir()?;
+ check_config_targets(&dir, &["config.json"])?;
if !dir.is_dir() {
return Err(io::Error::other(format!(
"devin config directory not found at {}. install devin cli first",
@@ -354,7 +360,7 @@ pub(crate) fn install_devin() -> io::Result<DevinInstallPaths> {
}
remove_legacy_bash_hook_file(&hook_path)?;
- fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+ write_config(&settings_path, serde_json::to_string_pretty(&settings)?)?;
Ok(DevinInstallPaths {
hook_path,
@@ -364,6 +370,7 @@ pub(crate) fn install_devin() -> io::Result<DevinInstallPaths> {
pub(crate) fn install_droid() -> io::Result<DroidInstallPaths> {
let dir = droid_dir()?;
+ check_config_targets(&dir, &["settings.json", "hooks.json"])?;
if !dir.is_dir() {
return Err(io::Error::other(format!(
"droid config directory not found at {}. install droid first",
@@ -414,7 +421,7 @@ pub(crate) fn install_droid() -> io::Result<DroidInstallPaths> {
}
remove_legacy_bash_hook_file(&hook_path)?;
- fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+ write_config(&settings_path, serde_json::to_string_pretty(&settings)?)?;
let hooks_path = dir.join("hooks.json");
let mut updated_legacy_hooks = false;
@@ -440,7 +447,7 @@ pub(crate) fn install_droid() -> io::Result<DroidInstallPaths> {
}
}
if updated_legacy_hooks {
- fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
+ write_config(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
}
}
@@ -454,6 +461,7 @@ pub(crate) fn install_droid() -> io::Result<DroidInstallPaths> {
pub(crate) fn install_opencode() -> io::Result<OpenCodeInstallPaths> {
let dir = opencode_dir()?;
+ check_config_targets(&dir, &["tui.jsonc", "cli.json"])?;
if !dir.is_dir() {
return Err(io::Error::other(format!(
"opencode config directory not found at {}. install opencode first",
@@ -507,6 +515,7 @@ pub(crate) fn install_kilo() -> io::Result<KiloInstallPaths> {
pub(crate) fn install_hermes() -> io::Result<HermesInstallPaths> {
let dir = hermes_dir()?;
+ check_config_targets(&dir, &["config.yaml"])?;
if !dir.is_dir() {
return Err(io::Error::other(format!(
"hermes config directory not found at {}. install hermes agent first",
@@ -533,7 +542,7 @@ pub(crate) fn install_hermes() -> io::Result<HermesInstallPaths> {
};
let new_config = ensure_hermes_plugin_enabled(&existing_config);
if new_config != existing_config {
- fs::write(&config_path, new_config)?;
+ write_config(&config_path, new_config)?;
}
Ok(HermesInstallPaths {
@@ -563,8 +572,10 @@ pub(crate) fn uninstall_omp() -> io::Result<OmpUninstallResult> {
}
pub(crate) fn uninstall_claude() -> io::Result<ClaudeUninstallResult> {
- let hook_path = claude_dir()?.join("hooks").join(CLAUDE_HOOK_INSTALL_NAME);
- let settings_path = claude_dir()?.join("settings.json");
+ let dir = claude_dir()?;
+ check_config_targets(&dir, &["settings.json"])?;
+ let hook_path = dir.join("hooks").join(CLAUDE_HOOK_INSTALL_NAME);
+ let settings_path = dir.join("settings.json");
let mut updated_settings = false;
if settings_path.is_file() {
@@ -573,7 +584,7 @@ pub(crate) fn uninstall_claude() -> io::Result<ClaudeUninstallResult> {
uninstall_claude_settings(&existing_settings, &settings_path, &hook_path)?;
updated_settings = new_settings != existing_settings;
if updated_settings {
- fs::write(&settings_path, new_settings)?;
+ write_config(&settings_path, new_settings)?;
}
}
@@ -590,6 +601,7 @@ pub(crate) fn uninstall_claude() -> io::Result<ClaudeUninstallResult> {
pub(crate) fn uninstall_codex() -> io::Result<CodexUninstallResult> {
let codex_dir = codex_dir()?;
+ check_config_targets(&codex_dir, &["hooks.json"])?;
let hook_path = codex_dir.join(CODEX_HOOK_INSTALL_NAME);
let hooks_path = codex_dir.join("hooks.json");
let config_path = codex_dir.join("config.toml");
@@ -620,7 +632,7 @@ pub(crate) fn uninstall_codex() -> io::Result<CodexUninstallResult> {
}
if updated_hooks {
- fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
+ write_config(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
}
}
@@ -638,6 +650,7 @@ pub(crate) fn uninstall_codex() -> io::Result<CodexUninstallResult> {
pub(crate) fn uninstall_kimi() -> io::Result<KimiUninstallResult> {
let kimi_dir = kimi_dir()?;
+ check_config_targets(&kimi_dir, &["config.toml"])?;
let hook_path = kimi_dir.join("hooks").join(KIMI_HOOK_INSTALL_NAME);
let config_path = kimi_dir.join("config.toml");
let mut updated_config = false;
@@ -646,7 +659,7 @@ pub(crate) fn uninstall_kimi() -> io::Result<KimiUninstallResult> {
let existing_config = fs::read_to_string(&config_path)?;
let new_config = remove_kimi_config_block(&existing_config);
if new_config != existing_config {
- fs::write(&config_path, new_config)?;
+ write_config(&config_path, new_config)?;
updated_config = true;
}
}
@@ -664,6 +677,7 @@ pub(crate) fn uninstall_kimi() -> io::Result<KimiUninstallResult> {
pub(crate) fn uninstall_copilot() -> io::Result<CopilotUninstallResult> {
let copilot_dir = copilot_dir()?;
+ check_config_targets(&copilot_dir, &["settings.json"])?;
let hook_path = copilot_dir.join("hooks").join(COPILOT_HOOK_INSTALL_NAME);
let settings_path = copilot_dir.join("settings.json");
let mut updated_settings = false;
@@ -692,7 +706,7 @@ pub(crate) fn uninstall_copilot() -> io::Result<CopilotUninstallResult> {
}
if updated_settings {
- fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+ write_config(&settings_path, serde_json::to_string_pretty(&settings)?)?;
}
}
@@ -709,6 +723,7 @@ pub(crate) fn uninstall_copilot() -> io::Result<CopilotUninstallResult> {
pub(crate) fn uninstall_devin() -> io::Result<DevinUninstallResult> {
let devin_dir = devin_dir()?;
+ check_config_targets(&devin_dir, &["config.json"])?;
let hook_path = devin_dir.join(DEVIN_HOOK_INSTALL_NAME);
let settings_path = devin_dir.join("config.json");
let mut updated_settings = false;
@@ -737,7 +752,7 @@ pub(crate) fn uninstall_devin() -> io::Result<DevinUninstallResult> {
}
if updated_settings {
- fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+ write_config(&settings_path, serde_json::to_string_pretty(&settings)?)?;
}
}
@@ -754,6 +769,7 @@ pub(crate) fn uninstall_devin() -> io::Result<DevinUninstallResult> {
pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> {
let droid_dir = droid_dir()?;
+ check_config_targets(&droid_dir, &["settings.json", "hooks.json"])?;
let hook_path = droid_dir.join("hooks").join(DROID_HOOK_INSTALL_NAME);
let hooks_path = droid_dir.join("hooks.json");
let settings_path = droid_dir.join("settings.json");
@@ -781,7 +797,7 @@ pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> {
}
if updated_hooks {
- fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
+ write_config(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
}
}
@@ -809,7 +825,7 @@ pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> {
}
if updated_settings {
- fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+ write_config(&settings_path, serde_json::to_string_pretty(&settings)?)?;
}
}
@@ -828,6 +844,7 @@ pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> {
pub(crate) fn uninstall_opencode() -> io::Result<OpenCodeUninstallResult> {
let dir = opencode_dir()?;
+ check_config_targets(&dir, &["tui.jsonc", "cli.json"])?;
let tui_config_path = tui_config_path(&dir);
let plugin_path = dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME);
let tui_plugin_path = dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME);
@@ -883,6 +900,7 @@ pub(crate) fn uninstall_kilo() -> io::Result<KiloUninstallResult> {
pub(crate) fn uninstall_hermes() -> io::Result<HermesUninstallResult> {
let dir = hermes_dir()?;
+ check_config_targets(&dir, &["config.yaml"])?;
let plugin_dir = hermes_plugin_dir()?;
let config_path = dir.join("config.yaml");
@@ -892,7 +910,7 @@ pub(crate) fn uninstall_hermes() -> io::Result<HermesUninstallResult> {
let existing_config = fs::read_to_string(&config_path)?;
let new_config = remove_hermes_plugin_enabled(&existing_config);
if new_config != existing_config {
- fs::write(&config_path, new_config)?;
+ write_config(&config_path, new_config)?;
updated_config = true;
}
}
@@ -907,6 +925,7 @@ pub(crate) fn uninstall_hermes() -> io::Result<HermesUninstallResult> {
pub(crate) fn install_qodercli() -> io::Result<QodercliInstallPaths> {
let dir = qodercli_dir()?;
+ check_config_targets(&dir, &["settings.json"])?;
if !dir.is_dir() {
return Err(io::Error::other(format!(
"qodercli config directory not found at {}. install qodercli first",
@@ -961,7 +980,7 @@ pub(crate) fn install_qodercli() -> io::Result<QodercliInstallPaths> {
}
remove_legacy_bash_hook_file(&hook_path)?;
- fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+ write_config(&settings_path, serde_json::to_string_pretty(&settings)?)?;
Ok(QodercliInstallPaths {
hook_path,
@@ -971,6 +990,7 @@ pub(crate) fn install_qodercli() -> io::Result<QodercliInstallPaths> {
pub(crate) fn install_qwen() -> io::Result<QwenInstallPaths> {
let dir = qwen_dir()?;
+ check_config_targets(&dir, &["settings.json"])?;
if !dir.is_dir() {
return Err(io::Error::other(format!(
"qwen code config directory not found at {}. install qwen code first",
@@ -1014,7 +1034,7 @@ pub(crate) fn install_qwen() -> io::Result<QwenInstallPaths> {
)?;
}
- fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+ write_config(&settings_path, serde_json::to_string_pretty(&settings)?)?;
Ok(QwenInstallPaths {
hook_path,
@@ -1225,6 +1245,7 @@ pub(crate) fn install_letta() -> io::Result<LettaInstallPaths> {
pub(crate) fn install_cursor() -> io::Result<CursorInstallPaths> {
let dir = cursor_dir()?;
+ check_config_targets(&dir, &["hooks.json"])?;
if !dir.is_dir() {
return Err(io::Error::other(format!(
"cursor config directory not found at {}. install cursor agent cli first",
@@ -1271,7 +1292,7 @@ pub(crate) fn install_cursor() -> io::Result<CursorInstallPaths> {
remove_simple_command_hook(hooks, "sessionEnd", &session_command)?;
ensure_simple_command_hook(hooks, "sessionStart", session_command)?;
- fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
+ write_config(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
Ok(CursorInstallPaths {
hook_path,
@@ -1280,10 +1301,10 @@ pub(crate) fn install_cursor() -> io::Result<CursorInstallPaths> {
}
pub(crate) fn uninstall_qodercli() -> io::Result<QodercliUninstallResult> {
- let hook_path = qodercli_dir()?
- .join("hooks")
- .join(QODERCLI_HOOK_INSTALL_NAME);
- let settings_path = qodercli_dir()?.join("settings.json");
+ let dir = qodercli_dir()?;
+ check_config_targets(&dir, &["settings.json"])?;
+ let hook_path = dir.join("hooks").join(QODERCLI_HOOK_INSTALL_NAME);
+ let settings_path = dir.join("settings.json");
let mut updated_settings = false;
if settings_path.is_file() {
@@ -1310,7 +1331,7 @@ pub(crate) fn uninstall_qodercli() -> io::Result<QodercliUninstallResult> {
}
if updated_settings {
- fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+ write_config(&settings_path, serde_json::to_string_pretty(&settings)?)?;
}
}
@@ -1326,8 +1347,10 @@ pub(crate) fn uninstall_qodercli() -> io::Result<QodercliUninstallResult> {
}
pub(crate) fn uninstall_qwen() -> io::Result<QwenUninstallResult> {
- let hook_path = qwen_dir()?.join("hooks").join(QWEN_HOOK_INSTALL_NAME);
- let settings_path = qwen_dir()?.join("settings.json");
+ let dir = qwen_dir()?;
+ check_config_targets(&dir, &["settings.json"])?;
+ let hook_path = dir.join("hooks").join(QWEN_HOOK_INSTALL_NAME);
+ let settings_path = dir.join("settings.json");
let mut updated_settings = false;
if settings_path.is_file() {
@@ -1351,7 +1374,7 @@ pub(crate) fn uninstall_qwen() -> io::Result<QwenUninstallResult> {
}
if updated_settings {
- fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+ write_config(&settings_path, serde_json::to_string_pretty(&settings)?)?;
}
}
@@ -1407,6 +1430,7 @@ pub(crate) fn uninstall_letta() -> io::Result<LettaUninstallResult> {
pub(crate) fn uninstall_cursor() -> io::Result<CursorUninstallResult> {
let cursor_home = cursor_dir()?;
+ check_config_targets(&cursor_home, &["hooks.json"])?;
let hook_path = cursor_home.join(CURSOR_HOOK_INSTALL_NAME);
let hooks_path = cursor_home.join("hooks.json");
let mut updated_hooks = false;
@@ -1436,7 +1460,7 @@ pub(crate) fn uninstall_cursor() -> io::Result<CursorUninstallResult> {
}
if updated_hooks {
- fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
+ write_config(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
}
}
@@ -1463,6 +1487,7 @@ pub(crate) fn mastracode_hook_command(hook_path: &Path, action: &str) -> String
pub(crate) fn install_mastracode() -> io::Result<MastracodeInstallPaths> {
let mastracode_home = mastracode_dir()?;
+ check_config_targets(&mastracode_home, &["hooks.json"])?;
let hook_dir = mastracode_home.join("hooks");
fs::create_dir_all(&hook_dir)?;
@@ -1500,7 +1525,7 @@ pub(crate) fn install_mastracode() -> io::Result<MastracodeInstallPaths> {
)?;
}
- fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
+ write_config(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
Ok(MastracodeInstallPaths {
hook_path,
@@ -1510,6 +1535,7 @@ pub(crate) fn install_mastracode() -> io::Result<MastracodeInstallPaths> {
pub(crate) fn uninstall_mastracode() -> io::Result<MastracodeUninstallResult> {
let mastracode_home = mastracode_dir()?;
+ check_config_targets(&mastracode_home, &["hooks.json"])?;
let hook_path = mastracode_home
.join("hooks")
.join(MASTRACODE_HOOK_INSTALL_NAME);
@@ -1542,7 +1568,7 @@ pub(crate) fn uninstall_mastracode() -> io::Result<MastracodeUninstallResult> {
}
if updated_hooks {
- fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
+ write_config(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
}
}
@@ -1558,6 +1584,7 @@ pub(crate) fn uninstall_mastracode() -> io::Result<MastracodeUninstallResult> {
pub(crate) fn install_antigravity_cli() -> io::Result<AntigravityCliInstallPaths> {
let dir = antigravity_cli_dir()?;
+ check_config_targets(&dir, &["hooks.json"])?;
if !dir.is_dir() {
return Err(io::Error::other(format!(
"antigravity cli config directory not found at {}. install antigravity cli first",
@@ -1595,7 +1622,7 @@ pub(crate) fn install_antigravity_cli() -> io::Result<AntigravityCliInstallPaths
antigravity_cli_hook_block(&hook_path),
);
- fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
+ write_config(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
Ok(AntigravityCliInstallPaths {
hook_path,
@@ -1633,6 +1660,7 @@ fn antigravity_cli_hook_block(hook_path: &Path) -> Value {
pub(crate) fn uninstall_antigravity_cli() -> io::Result<AntigravityCliUninstallResult> {
let dir = antigravity_cli_dir()?;
+ check_config_targets(&dir, &["hooks.json"])?;
let hook_path = dir.join("hooks").join(ANTIGRAVITY_CLI_HOOK_INSTALL_NAME);
let hooks_path = dir.join("hooks.json");
let mut updated_hooks = false;
@@ -1653,7 +1681,7 @@ pub(crate) fn uninstall_antigravity_cli() -> io::Result<AntigravityCliUninstallR
updated_hooks = hooks.remove(ANTIGRAVITY_CLI_HOOK_BLOCK_NAME).is_some();
if updated_hooks {
- fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
+ write_config(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
}
}
diff --git a/src/integration/tests.rs b/src/integration/tests.rs
index eb310637..1a102992 100644
--- a/src/integration/tests.rs
+++ b/src/integration/tests.rs
@@ -2396,6 +2396,89 @@ fn opencode_v2_install_status_and_uninstall_preserve_cli_preferences() {
}
#[test]
+fn opencode_hard_link_rejection_precedes_install_and_uninstall_asset_changes() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let dir = home.join(".config/opencode");
+ fs::create_dir_all(dir.join("plugins")).unwrap();
+ std::env::set_var("HOME", &home);
+ let plugin = dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME);
+ fs::write(&plugin, "previous integration").unwrap();
+ let config = dir.join("cli.json");
+ let original = r#"{"plugins":["./herdr-opencode"],"theme":"system"}"#;
+ fs::write(&config, original).unwrap();
+ let alias = base.join("linked-config");
+ fs::hard_link(&config, &alias).unwrap();
+ let target = crate::api::schema::IntegrationTarget::Opencode;
+ for error in [
+ install_target(target).unwrap_err(),
+ uninstall_target(target).unwrap_err(),
+ ] {
+ assert!(error.to_string().contains("multiple hard links"));
+ assert!(error.to_string().contains("cli.json"));
+ }
+ assert_eq!(fs::read_to_string(&plugin).unwrap(), "previous integration");
+ assert_eq!(fs::read_to_string(&alias).unwrap(), original);
+ assert_eq!(crate::platform::config_file_link_count(&config).unwrap(), 2);
+ assert!(!dir.join("tui.jsonc").exists());
+ assert!(!dir.join(OPENCODE_V2_TUI_PLUGIN_DIR).exists());
+ std::env::remove_var("HOME");
+ fs::remove_dir_all(base).unwrap();
+}
+
+#[cfg(windows)]
+#[test]
+fn opencode_recovery_copy_blocks_retry_before_parsing_or_asset_changes() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let dir = home.join(".config/opencode");
+ fs::create_dir_all(dir.join("plugins")).unwrap();
+ std::env::set_var("HOME", &home);
+ let plugin = dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME);
+ fs::write(&plugin, "previous integration").unwrap();
+ let config = dir.join("cli.json");
+ let backup = dir.join("cli.json.herdr-backup");
+ let original = r#"{"plugins":["./herdr-opencode"],"theme":"system"}"#;
+ fs::write(&backup, original).unwrap();
+ let target = crate::api::schema::IntegrationTarget::Opencode;
+ for contents in [Some("{"), Some(original), None] {
+ if let Some(contents) = contents {
+ fs::write(&config, contents).unwrap();
+ } else {
+ fs::remove_file(&config).unwrap();
+ }
+ for error in [
+ install_target(target).unwrap_err(),
+ uninstall_target(target).unwrap_err(),
+ ] {
+ assert!(error.to_string().contains("recovery copy"), "{error}");
+ assert!(error.to_string().contains("cli.json.herdr-backup"));
+ }
+ assert_eq!(fs::read_to_string(&backup).unwrap(), original);
+ if contents.is_none() {
+ assert!(!config.exists());
+ }
+ }
+ // A symlink invocation must discover the referent's recovery copy too.
+ let referent = base.join("preferences.json");
+ fs::write(&referent, "{").unwrap();
+ let linked_backup = base.join("preferences.json.herdr-backup");
+ fs::rename(&backup, &linked_backup).unwrap();
+ std::os::windows::fs::symlink_file(&referent, &config).unwrap();
+ let link_before = fs::read_link(&config).unwrap();
+ let error = install_target(target).unwrap_err();
+ assert!(error.to_string().contains("preferences.json.herdr-backup"));
+ assert_eq!(fs::read_link(&config).unwrap(), link_before);
+ assert_eq!(fs::read_to_string(&plugin).unwrap(), "previous integration");
+ assert!(!dir.join("tui.jsonc").exists());
+ assert!(!dir.join(OPENCODE_V2_TUI_PLUGIN_DIR).exists());
+ std::env::remove_var("HOME");
+ fs::remove_dir_all(base).unwrap();
+}
+
+#[test]
fn opencode_invalid_cli_config_does_not_overwrite_existing_plugins() {
let _lock = integration_env_lock();
let base = unique_base();