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.
111 lines
4.2 KiB
Rust
111 lines
4.2 KiB
Rust
//! Where a config write lands when the path is a symlink.
|
|
|
|
use std::io;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// The user's own config.toml keeps its symlink; every other path comes back unchanged.
|
|
pub(crate) fn config_write_dest(path: &Path) -> io::Result<PathBuf> {
|
|
// With no home, `kigi_home()` is `./.kigi`: the project's own tree, never trusted.
|
|
let user_config = kigi_config::user_kigi_home().map(|home| home.join("config.toml"));
|
|
write_dest(path, user_config.as_deref())
|
|
}
|
|
|
|
/// A project config is repo-controlled: following its symlink would overwrite any file it names.
|
|
fn write_dest(path: &Path, user_config: Option<&Path>) -> io::Result<PathBuf> {
|
|
if user_config != Some(path) {
|
|
return Ok(path.to_path_buf());
|
|
}
|
|
match std::fs::symlink_metadata(path) {
|
|
Ok(meta) if meta.file_type().is_symlink() => dunce::canonicalize(path).map_err(|e| {
|
|
let context = format!("config symlink {} does not resolve: {e}", path.display());
|
|
io::Error::new(e.kind(), context)
|
|
}),
|
|
Ok(_) => Ok(path.to_path_buf()),
|
|
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(path.to_path_buf()),
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
#[cfg(all(test, unix))]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::util::config::{McpServerConfig, save_mcp_server_config_at};
|
|
|
|
fn symlinked_config(dir: &Path, referent_content: &str) -> (PathBuf, PathBuf) {
|
|
let referent = dir.join("dotfiles.toml");
|
|
std::fs::write(&referent, referent_content).unwrap();
|
|
let link = dir.join("config.toml");
|
|
std::os::unix::fs::symlink(&referent, &link).unwrap();
|
|
(link, referent)
|
|
}
|
|
|
|
#[test]
|
|
fn user_config_symlink_resolves_to_its_referent() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let (link, referent) = symlinked_config(dir.path(), "");
|
|
|
|
let dest = write_dest(&link, Some(&link)).unwrap();
|
|
assert_eq!(dest, dunce::canonicalize(&referent).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn regular_and_missing_user_config_keep_their_path() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let regular = dir.path().join("config.toml");
|
|
std::fs::write(®ular, "").unwrap();
|
|
let missing = dir.path().join("absent.toml");
|
|
|
|
assert_eq!(write_dest(®ular, Some(®ular)).unwrap(), regular);
|
|
assert_eq!(write_dest(&missing, Some(&missing)).unwrap(), missing);
|
|
}
|
|
|
|
#[test]
|
|
fn dangling_user_config_symlink_is_an_error_not_a_clobber() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let link = dir.path().join("config.toml");
|
|
std::os::unix::fs::symlink(dir.path().join("gone.toml"), &link).unwrap();
|
|
|
|
let err = write_dest(&link, Some(&link)).unwrap_err();
|
|
assert_eq!(err.kind(), io::ErrorKind::NotFound);
|
|
assert!(err.to_string().contains("does not resolve"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn project_config_symlink_is_never_followed() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let (link, _referent) = symlinked_config(dir.path(), "");
|
|
let user_config = dir.path().join("home").join("config.toml");
|
|
|
|
assert_eq!(write_dest(&link, Some(&user_config)).unwrap(), link);
|
|
}
|
|
|
|
#[test]
|
|
fn nothing_is_followed_when_no_user_home_resolves() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let (link, _referent) = symlinked_config(dir.path(), "");
|
|
|
|
assert_eq!(write_dest(&link, None).unwrap(), link);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn project_scope_save_replaces_the_link_and_spares_its_target() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let (link, target) = symlinked_config(dir.path(), "victim = true\n");
|
|
|
|
let server: McpServerConfig =
|
|
toml::from_str("command = \"echo\"\nargs = [\"hi\"]\n").unwrap();
|
|
save_mcp_server_config_at(&link, "demo", &server)
|
|
.await
|
|
.unwrap();
|
|
|
|
let meta = std::fs::symlink_metadata(&link).unwrap();
|
|
assert!(
|
|
meta.file_type().is_file(),
|
|
"the link is replaced, not followed"
|
|
);
|
|
assert_eq!(std::fs::read_to_string(&target).unwrap(), "victim = true\n");
|
|
let saved = std::fs::read_to_string(&link).unwrap();
|
|
assert!(saved.contains("[mcp_servers.demo]"), "{saved}");
|
|
}
|
|
}
|