Every config.toml writer wrote a tmp beside the path and renamed it over the path. With config.toml symlinked into a dotfiles repo, the rename replaced the link with a regular file: the repo copy went stale and the link was gone. `config_write_dest` resolves the destination before a writer names its tmp. For the user's own config path a symlink resolves to its referent, so the tmp sits on the referent's filesystem and the rename stays atomic. A dangling link is an error, not a clobber. All eight writers call it: `save_config`, the four MCP writers, and the three import writers. Only the user's config path is followed, and only while `user_kigi_home()` resolves. `kigi mcp add --scope project` and the Claude project import write `.kigi/config.toml` inside the repo; a hostile repo could ship that file as a symlink to any file the user owns. With no home, `kigi_home()` falls back to `./.kigi`, which is that same project tree, so nothing is followed then. Untrusted paths come back unchanged and the link itself is replaced, as before. AGENTS.md records the rule. Tests (unix; run on Linux under WSL): helper cases including the no-home case, a project-scope save that spares the link's target, and an end-to-end save under KIGI_SHARE_DIR. The end-to-end test fails with following disabled. Windows symlinks were not exercised: the host lacks the privilege. Ported from upstream (grok-build config/fs_atomic.rs LeafSymlink::Follow, 1.0.35): the rule, not the 1375-line module.
36 lines
1.4 KiB
Rust
36 lines
1.4 KiB
Rust
//! A symlinked `~/.kigi/config.toml` survives a save: the write lands on the referent.
|
|
#![cfg(unix)]
|
|
|
|
use kigi_shell::util::config::{McpServerConfig, save_mcp_server_config};
|
|
|
|
#[tokio::test]
|
|
async fn user_config_symlink_survives_a_save() {
|
|
let home = tempfile::tempdir().unwrap();
|
|
let dotfiles = tempfile::tempdir().unwrap();
|
|
// SAFETY: the only test in this binary; set before `kigi_home()` first runs.
|
|
unsafe { std::env::set_var("KIGI_SHARE_DIR", home.path()) };
|
|
|
|
let referent = dotfiles.path().join("kigi.toml");
|
|
std::fs::write(&referent, "[ui]\ntheme = \"dark\"\n").unwrap();
|
|
let link = home.path().join("config.toml");
|
|
std::os::unix::fs::symlink(&referent, &link).unwrap();
|
|
|
|
let server: McpServerConfig = toml::from_str("command = \"echo\"\nargs = [\"hi\"]\n").unwrap();
|
|
save_mcp_server_config("demo", &server).await.unwrap();
|
|
|
|
let meta = std::fs::symlink_metadata(&link).unwrap();
|
|
assert!(meta.file_type().is_symlink(), "the link must survive");
|
|
let saved = std::fs::read_to_string(&referent).unwrap();
|
|
assert!(saved.contains("[mcp_servers.demo]"), "{saved}");
|
|
assert!(saved.contains("theme = \"dark\""), "{saved}");
|
|
let leftovers: Vec<_> = std::fs::read_dir(home.path())
|
|
.unwrap()
|
|
.map(|entry| entry.unwrap().file_name())
|
|
.filter(|name| name != "config.toml")
|
|
.collect();
|
|
assert!(
|
|
leftovers.is_empty(),
|
|
"no tmp beside the link: {leftovers:?}"
|
|
);
|
|
}
|