use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::Serialize;
use arcbox_constants::paths::DOCKER_CLI_PLUGINS;
#[derive(Debug, Default, Serialize)]
pub struct Outcome {
pub symlinks: Vec<PathBuf>,
pub config_updated: bool,
}
#[derive(Debug, Default, Serialize)]
pub struct RegistrationStatus {
pub symlinked: Vec<String>,
pub extra_dirs_entry_present: bool,
}
pub fn default_docker_config_dir() -> Result<PathBuf> {
resolve_docker_config_dir(std::env::var_os("DOCKER_CONFIG"))
}
fn resolve_docker_config_dir(env_override: Option<std::ffi::OsString>) -> Result<PathBuf> {
if let Some(value) = env_override {
if !value.is_empty() {
return Ok(PathBuf::from(value));
}
}
dirs::home_dir()
.map(|h| h.join(".docker"))
.context("could not determine home directory")
}
pub async fn register(user_bin: &Path, docker_config_dir: &Path) -> Result<Outcome> {
let mut outcome = Outcome::default();
let plugins_dir = docker_config_dir.join("cli-plugins");
tokio::fs::create_dir_all(&plugins_dir)
.await
.with_context(|| format!("failed to create {}", plugins_dir.display()))?;
let mut any_plugin_present = false;
for plugin in DOCKER_CLI_PLUGINS {
let target = user_bin.join(plugin);
if !target.exists() {
continue;
}
any_plugin_present = true;
let link = plugins_dir.join(plugin);
match tokio::fs::symlink_metadata(&link).await {
Ok(meta) if meta.file_type().is_symlink() => {
if let Ok(existing) = tokio::fs::read_link(&link).await {
if existing == target {
continue;
}
if !is_arcbox_bin_target(&existing, user_bin, &link) {
continue;
}
tokio::fs::remove_file(&link).await.ok();
}
}
Ok(_) => {
continue;
}
Err(_) => {}
}
#[cfg(unix)]
{
tokio::fs::symlink(&target, &link).await.with_context(|| {
format!(
"failed to create plugin symlink {} -> {}",
link.display(),
target.display()
)
})?;
outcome.symlinks.push(link);
}
#[cfg(not(unix))]
{
let _ = link;
}
}
if any_plugin_present {
let config_path = docker_config_dir.join("config.json");
let user_bin_str = user_bin.to_string_lossy().into_owned();
outcome.config_updated = update_extra_dirs(&config_path, &user_bin_str, true).await?;
}
Ok(outcome)
}
pub async fn unregister(user_bin: &Path, docker_config_dir: &Path) -> Result<Outcome> {
let mut outcome = Outcome::default();
let plugins_dir = docker_config_dir.join("cli-plugins");
if plugins_dir.is_dir() {
for plugin in DOCKER_CLI_PLUGINS {
let link = plugins_dir.join(plugin);
let Ok(meta) = tokio::fs::symlink_metadata(&link).await else {
continue;
};
if !meta.file_type().is_symlink() {
continue;
}
let Ok(target) = tokio::fs::read_link(&link).await else {
continue;
};
if !is_arcbox_bin_target(&target, user_bin, &link) {
continue;
}
if tokio::fs::remove_file(&link).await.is_ok() {
outcome.symlinks.push(link);
}
}
}
let config_path = docker_config_dir.join("config.json");
let user_bin_str = user_bin.to_string_lossy().into_owned();
outcome.config_updated = update_extra_dirs(&config_path, &user_bin_str, false).await?;
Ok(outcome)
}
pub async fn status(user_bin: &Path, docker_config_dir: &Path) -> RegistrationStatus {
let mut result = RegistrationStatus::default();
let plugins_dir = docker_config_dir.join("cli-plugins");
for plugin in DOCKER_CLI_PLUGINS {
let link = plugins_dir.join(plugin);
if let Ok(target) = tokio::fs::read_link(&link).await {
if is_arcbox_bin_target(&target, user_bin, &link) {
result.symlinked.push((*plugin).to_string());
}
}
}
let config_path = docker_config_dir.join("config.json");
if let Ok(content) = tokio::fs::read_to_string(&config_path).await {
if let Ok(serde_json::Value::Object(obj)) =
serde_json::from_str::<serde_json::Value>(&content)
{
if let Some(serde_json::Value::Array(arr)) = obj.get("cliPluginsExtraDirs") {
let user_bin_str = user_bin.to_string_lossy();
result.extra_dirs_entry_present = arr
.iter()
.any(|v| v.as_str() == Some(user_bin_str.as_ref()));
}
}
}
result
}
fn is_arcbox_bin_target(target: &Path, user_bin: &Path, link: &Path) -> bool {
let resolved = if target.is_absolute() {
target.to_path_buf()
} else if let Some(parent) = link.parent() {
parent.join(target)
} else {
return false;
};
lexical_normalize(&resolved).starts_with(lexical_normalize(user_bin))
}
fn lexical_normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
out.pop();
}
std::path::Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
async fn update_extra_dirs(config_path: &Path, user_bin: &str, insert: bool) -> Result<bool> {
let mut value: serde_json::Value = match tokio::fs::read_to_string(config_path).await {
Ok(s) if s.trim().is_empty() => serde_json::json!({}),
Ok(s) => serde_json::from_str(&s)
.with_context(|| format!("failed to parse {}", config_path.display()))?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if !insert {
return Ok(false);
}
serde_json::json!({})
}
Err(e) => {
return Err(
anyhow::Error::from(e).context(format!("failed to read {}", config_path.display()))
);
}
};
let obj = value
.as_object_mut()
.context("Docker config.json is not a JSON object")?;
let modified = if insert {
let entry = obj
.entry("cliPluginsExtraDirs")
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
let Some(array) = entry.as_array_mut() else {
return Ok(false);
};
if array.iter().any(|v| v.as_str() == Some(user_bin)) {
false
} else {
array.push(serde_json::Value::String(user_bin.to_string()));
true
}
} else {
let Some(entry) = obj.get_mut("cliPluginsExtraDirs") else {
return Ok(false);
};
let Some(array) = entry.as_array_mut() else {
return Ok(false);
};
let before = array.len();
array.retain(|v| v.as_str() != Some(user_bin));
let changed = array.len() != before;
if array.is_empty() {
obj.remove("cliPluginsExtraDirs");
}
changed
};
if !modified {
return Ok(false);
}
if let Some(parent) = config_path.parent() {
tokio::fs::create_dir_all(parent).await.ok();
}
let serialized = serde_json::to_string_pretty(&value)?;
atomic_write(config_path, format!("{serialized}\n").as_bytes()).await?;
Ok(true)
}
async fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> {
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("arcbox-tmp");
let tmp_name = format!(".{}.{}.tmp", file_name, std::process::id());
let tmp_path = path.with_file_name(tmp_name);
if let Err(e) = tokio::fs::write(&tmp_path, contents).await {
tokio::fs::remove_file(&tmp_path).await.ok();
return Err(
anyhow::Error::from(e).context(format!("failed to write {}", tmp_path.display()))
);
}
tokio::fs::rename(&tmp_path, path).await.with_context(|| {
format!(
"failed to rename {} -> {}",
tmp_path.display(),
path.display()
)
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn touch_exe(dir: &Path, name: &str) -> PathBuf {
let path = dir.join(name);
fs::write(&path, b"#!/bin/sh\nexit 0\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perm = fs::metadata(&path).unwrap().permissions();
perm.set_mode(0o755);
fs::set_permissions(&path, perm).unwrap();
}
path
}
#[tokio::test]
async fn register_creates_symlinks_and_extra_dirs() {
let tmp = tempdir().unwrap();
let user_bin = tmp.path().join("arcbox-bin");
let docker_cfg = tmp.path().join("docker");
fs::create_dir_all(&user_bin).unwrap();
touch_exe(&user_bin, "docker-compose");
touch_exe(&user_bin, "docker-buildx");
let outcome = register(&user_bin, &docker_cfg).await.unwrap();
assert_eq!(outcome.symlinks.len(), 2);
assert!(outcome.config_updated);
let compose_link = docker_cfg.join("cli-plugins/docker-compose");
assert_eq!(
fs::read_link(&compose_link).unwrap(),
user_bin.join("docker-compose")
);
let cfg: serde_json::Value =
serde_json::from_str(&fs::read_to_string(docker_cfg.join("config.json")).unwrap())
.unwrap();
let dirs = cfg
.get("cliPluginsExtraDirs")
.and_then(|v| v.as_array())
.unwrap();
assert_eq!(dirs.len(), 1);
assert_eq!(dirs[0].as_str(), Some(user_bin.to_string_lossy().as_ref()));
}
#[tokio::test]
async fn register_is_idempotent() {
let tmp = tempdir().unwrap();
let user_bin = tmp.path().join("bin");
let docker_cfg = tmp.path().join("docker");
fs::create_dir_all(&user_bin).unwrap();
touch_exe(&user_bin, "docker-compose");
touch_exe(&user_bin, "docker-buildx");
let first = register(&user_bin, &docker_cfg).await.unwrap();
assert!(first.config_updated);
assert_eq!(first.symlinks.len(), 2);
let second = register(&user_bin, &docker_cfg).await.unwrap();
assert!(!second.config_updated);
assert!(second.symlinks.is_empty());
}
#[tokio::test]
async fn register_preserves_other_config_keys() {
let tmp = tempdir().unwrap();
let user_bin = tmp.path().join("bin");
let docker_cfg = tmp.path().join("docker");
fs::create_dir_all(&user_bin).unwrap();
fs::create_dir_all(&docker_cfg).unwrap();
touch_exe(&user_bin, "docker-compose");
fs::write(
docker_cfg.join("config.json"),
r#"{"currentContext":"desktop-linux","auths":{"ghcr.io":{}}}"#,
)
.unwrap();
register(&user_bin, &docker_cfg).await.unwrap();
let cfg: serde_json::Value =
serde_json::from_str(&fs::read_to_string(docker_cfg.join("config.json")).unwrap())
.unwrap();
assert_eq!(cfg["currentContext"].as_str(), Some("desktop-linux"));
assert!(cfg["auths"]["ghcr.io"].is_object());
assert!(cfg["cliPluginsExtraDirs"].is_array());
}
#[tokio::test]
async fn register_skips_foreign_symlinks() {
let tmp = tempdir().unwrap();
let user_bin = tmp.path().join("bin");
let docker_cfg = tmp.path().join("docker");
let other_bin = tmp.path().join("other");
fs::create_dir_all(&user_bin).unwrap();
fs::create_dir_all(&other_bin).unwrap();
fs::create_dir_all(docker_cfg.join("cli-plugins")).unwrap();
touch_exe(&user_bin, "docker-compose");
let foreign_target = touch_exe(&other_bin, "docker-compose");
let foreign_link = docker_cfg.join("cli-plugins/docker-compose");
std::os::unix::fs::symlink(&foreign_target, &foreign_link).unwrap();
let outcome = register(&user_bin, &docker_cfg).await.unwrap();
assert_eq!(fs::read_link(&foreign_link).unwrap(), foreign_target);
assert!(!outcome.symlinks.contains(&foreign_link));
}
#[tokio::test]
async fn register_without_plugin_binaries_leaves_config_untouched() {
let tmp = tempdir().unwrap();
let user_bin = tmp.path().join("bin");
let docker_cfg = tmp.path().join("docker");
fs::create_dir_all(&user_bin).unwrap();
let outcome = register(&user_bin, &docker_cfg).await.unwrap();
assert!(outcome.symlinks.is_empty());
assert!(
!outcome.config_updated,
"config.json must not be mutated when no plugins are present"
);
assert!(
!docker_cfg.join("config.json").exists(),
"config.json must not be created when nothing is registered"
);
}
#[tokio::test]
async fn register_skips_missing_plugin_binary() {
let tmp = tempdir().unwrap();
let user_bin = tmp.path().join("bin");
let docker_cfg = tmp.path().join("docker");
fs::create_dir_all(&user_bin).unwrap();
touch_exe(&user_bin, "docker-compose");
let outcome = register(&user_bin, &docker_cfg).await.unwrap();
assert_eq!(outcome.symlinks.len(), 1);
assert!(!docker_cfg.join("cli-plugins/docker-buildx").exists());
}
#[tokio::test]
async fn unregister_removes_only_our_symlinks() {
let tmp = tempdir().unwrap();
let user_bin = tmp.path().join("bin");
let docker_cfg = tmp.path().join("docker");
let other_bin = tmp.path().join("other");
fs::create_dir_all(&user_bin).unwrap();
fs::create_dir_all(&other_bin).unwrap();
fs::create_dir_all(docker_cfg.join("cli-plugins")).unwrap();
touch_exe(&user_bin, "docker-compose");
touch_exe(&user_bin, "docker-buildx");
let foreign_target = touch_exe(&other_bin, "docker-buildx");
register(&user_bin, &docker_cfg).await.unwrap();
let buildx_link = docker_cfg.join("cli-plugins/docker-buildx");
fs::remove_file(&buildx_link).unwrap();
std::os::unix::fs::symlink(&foreign_target, &buildx_link).unwrap();
let outcome = unregister(&user_bin, &docker_cfg).await.unwrap();
assert!(
!docker_cfg.join("cli-plugins/docker-compose").exists(),
"our compose symlink should be gone"
);
assert_eq!(fs::read_link(&buildx_link).unwrap(), foreign_target);
assert_eq!(outcome.symlinks.len(), 1);
}
#[tokio::test]
async fn unregister_removes_only_our_extra_dirs_entry() {
let tmp = tempdir().unwrap();
let user_bin = tmp.path().join("bin");
let docker_cfg = tmp.path().join("docker");
fs::create_dir_all(&user_bin).unwrap();
fs::create_dir_all(&docker_cfg).unwrap();
touch_exe(&user_bin, "docker-compose");
fs::write(
docker_cfg.join("config.json"),
format!(
r#"{{"cliPluginsExtraDirs":["/opt/other/cli-plugins","{}"]}}"#,
user_bin.to_string_lossy()
),
)
.unwrap();
unregister(&user_bin, &docker_cfg).await.unwrap();
let cfg: serde_json::Value =
serde_json::from_str(&fs::read_to_string(docker_cfg.join("config.json")).unwrap())
.unwrap();
let dirs = cfg["cliPluginsExtraDirs"].as_array().unwrap();
assert_eq!(dirs.len(), 1);
assert_eq!(dirs[0].as_str(), Some("/opt/other/cli-plugins"));
}
#[tokio::test]
async fn unregister_drops_empty_extra_dirs_key() {
let tmp = tempdir().unwrap();
let user_bin = tmp.path().join("bin");
let docker_cfg = tmp.path().join("docker");
fs::create_dir_all(&user_bin).unwrap();
touch_exe(&user_bin, "docker-compose");
register(&user_bin, &docker_cfg).await.unwrap();
unregister(&user_bin, &docker_cfg).await.unwrap();
let cfg: serde_json::Value =
serde_json::from_str(&fs::read_to_string(docker_cfg.join("config.json")).unwrap())
.unwrap();
assert!(
cfg.get("cliPluginsExtraDirs").is_none(),
"empty extraDirs array should be removed"
);
}
#[tokio::test]
async fn unregister_is_idempotent_on_missing_config() {
let tmp = tempdir().unwrap();
let user_bin = tmp.path().join("bin");
let docker_cfg = tmp.path().join("docker");
fs::create_dir_all(&user_bin).unwrap();
let outcome = unregister(&user_bin, &docker_cfg).await.unwrap();
assert!(outcome.symlinks.is_empty());
assert!(!outcome.config_updated);
}
#[tokio::test]
async fn status_reports_registration() {
let tmp = tempdir().unwrap();
let user_bin = tmp.path().join("bin");
let docker_cfg = tmp.path().join("docker");
fs::create_dir_all(&user_bin).unwrap();
touch_exe(&user_bin, "docker-compose");
touch_exe(&user_bin, "docker-buildx");
let before = status(&user_bin, &docker_cfg).await;
assert!(before.symlinked.is_empty());
assert!(!before.extra_dirs_entry_present);
register(&user_bin, &docker_cfg).await.unwrap();
let after = status(&user_bin, &docker_cfg).await;
assert_eq!(after.symlinked.len(), 2);
assert!(after.extra_dirs_entry_present);
}
#[test]
fn resolve_docker_config_dir_honors_env_override() {
let custom = PathBuf::from("/tmp/custom-docker");
let resolved = resolve_docker_config_dir(Some(custom.as_os_str().to_os_string())).unwrap();
assert_eq!(resolved, custom);
}
#[test]
fn resolve_docker_config_dir_ignores_empty_env() {
let resolved = resolve_docker_config_dir(Some(std::ffi::OsString::new())).unwrap();
assert!(resolved.ends_with(".docker"));
}
#[tokio::test]
async fn atomic_write_leaves_no_tmp_files_on_success() {
let tmp = tempdir().unwrap();
let target = tmp.path().join("config.json");
atomic_write(&target, b"{}\n").await.unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "{}\n");
let leftover = fs::read_dir(tmp.path())
.unwrap()
.filter_map(Result::ok)
.any(|e| e.file_name().to_string_lossy().ends_with(".tmp"));
assert!(!leftover, "atomic_write must clean up its tmp file");
}
#[tokio::test]
async fn register_preserves_top_level_key_order() {
let tmp = tempdir().unwrap();
let user_bin = tmp.path().join("bin");
let docker_cfg = tmp.path().join("docker");
fs::create_dir_all(&user_bin).unwrap();
fs::create_dir_all(&docker_cfg).unwrap();
touch_exe(&user_bin, "docker-compose");
fs::write(
docker_cfg.join("config.json"),
r#"{"currentContext":"desktop","credsStore":"osxkeychain","auths":{}}"#,
)
.unwrap();
register(&user_bin, &docker_cfg).await.unwrap();
let rewritten = fs::read_to_string(docker_cfg.join("config.json")).unwrap();
let ctx = rewritten.find("currentContext").unwrap();
let creds = rewritten.find("credsStore").unwrap();
let auths = rewritten.find("auths").unwrap();
let extra = rewritten.find("cliPluginsExtraDirs").unwrap();
assert!(ctx < creds && creds < auths && auths < extra);
}
#[tokio::test]
async fn register_tolerates_non_array_extra_dirs() {
let tmp = tempdir().unwrap();
let user_bin = tmp.path().join("bin");
let docker_cfg = tmp.path().join("docker");
fs::create_dir_all(&user_bin).unwrap();
fs::create_dir_all(&docker_cfg).unwrap();
touch_exe(&user_bin, "docker-compose");
fs::write(
docker_cfg.join("config.json"),
r#"{"cliPluginsExtraDirs":"/opt/plugins","auths":{"ghcr.io":{}}}"#,
)
.unwrap();
let outcome = register(&user_bin, &docker_cfg).await.unwrap();
assert_eq!(outcome.symlinks.len(), 1);
assert!(!outcome.config_updated);
let cfg: serde_json::Value =
serde_json::from_str(&fs::read_to_string(docker_cfg.join("config.json")).unwrap())
.unwrap();
assert_eq!(cfg["cliPluginsExtraDirs"].as_str(), Some("/opt/plugins"));
assert!(cfg["auths"]["ghcr.io"].is_object());
}
#[test]
fn is_arcbox_bin_target_recognizes_relative_symlinks() {
let user_bin = PathBuf::from("/home/u/.arcbox/bin");
let link = PathBuf::from("/home/u/.docker/cli-plugins/docker-compose");
let relative = PathBuf::from("../../.arcbox/bin/docker-compose");
assert!(is_arcbox_bin_target(&relative, &user_bin, &link));
let foreign = PathBuf::from("../../somewhere/else/docker-compose");
assert!(!is_arcbox_bin_target(&foreign, &user_bin, &link));
let absolute = user_bin.join("docker-compose");
assert!(is_arcbox_bin_target(&absolute, &user_bin, &link));
}
}