use std::path::PathBuf;
use super::session::dirs_state_dir;
use crate::error::JoyError;
const KEY_SIZE: usize = 32;
fn delegation_dir(project_id: &str) -> Result<PathBuf, JoyError> {
Ok(dirs_state_dir()?
.join("joy")
.join("delegations")
.join(sanitize(project_id)))
}
pub fn delegation_key_path(project_id: &str, ai_member: &str) -> Result<PathBuf, JoyError> {
Ok(delegation_dir(project_id)?.join(format!("{}.key", sanitize(ai_member))))
}
pub fn save_delegation_key(
project_id: &str,
ai_member: &str,
seed: &[u8; KEY_SIZE],
) -> Result<(), JoyError> {
let dir = delegation_dir(project_id)?;
std::fs::create_dir_all(&dir).map_err(|e| JoyError::CreateDir {
path: dir.clone(),
source: e,
})?;
let path = dir.join(format!("{}.key", sanitize(ai_member)));
std::fs::write(&path, seed).map_err(|e| JoyError::WriteFile {
path: path.clone(),
source: e,
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(&path, perms).map_err(|e| JoyError::WriteFile {
path: path.clone(),
source: e,
})?;
}
Ok(())
}
pub fn load_delegation_key(
project_id: &str,
ai_member: &str,
) -> Result<Option<[u8; KEY_SIZE]>, JoyError> {
let path = delegation_key_path(project_id, ai_member)?;
if !path.exists() {
return Ok(None);
}
let bytes = std::fs::read(&path).map_err(|e| JoyError::ReadFile {
path: path.clone(),
source: e,
})?;
if bytes.len() != KEY_SIZE {
return Err(JoyError::AuthFailed(format!(
"delegation key file {} is corrupt: expected {} bytes, got {}",
path.display(),
KEY_SIZE,
bytes.len()
)));
}
let mut seed = [0u8; KEY_SIZE];
seed.copy_from_slice(&bytes);
Ok(Some(seed))
}
pub fn rename_project_delegations(
old_project_id: &str,
new_project_id: &str,
) -> Result<(), JoyError> {
if old_project_id == new_project_id {
return Ok(());
}
let src = delegation_dir(old_project_id)?;
if !src.exists() {
return Ok(());
}
let dst = delegation_dir(new_project_id)?;
if dst.exists() {
return Err(JoyError::AuthFailed(format!(
"cannot migrate delegation directory: {} already exists. \
Resolve manually (inspect both directories and merge or remove one) \
before renaming the project acronym.",
dst.display()
)));
}
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent).map_err(|e| JoyError::CreateDir {
path: parent.to_path_buf(),
source: e,
})?;
}
std::fs::rename(&src, &dst).map_err(|e| JoyError::WriteFile {
path: dst.clone(),
source: e,
})?;
Ok(())
}
pub fn remove_delegation_key(project_id: &str, ai_member: &str) -> Result<(), JoyError> {
let path = delegation_key_path(project_id, ai_member)?;
if path.exists() {
std::fs::remove_file(&path).map_err(|e| JoyError::WriteFile {
path: path.clone(),
source: e,
})?;
}
Ok(())
}
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn with_state_dir<F: FnOnce()>(f: F) {
let _guard = super::super::STATE_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempdir().unwrap();
unsafe { std::env::set_var("XDG_STATE_HOME", dir.path()) };
f();
unsafe { std::env::remove_var("XDG_STATE_HOME") };
}
#[test]
fn sanitize_replaces_special_chars() {
assert_eq!(sanitize("ai:claude@joy"), "ai_claude_joy");
assert_eq!(sanitize("plain"), "plain");
assert_eq!(sanitize("with-dash_und.dot"), "with-dash_und_dot");
}
#[test]
fn save_load_roundtrip() {
with_state_dir(|| {
let seed = [42u8; 32];
save_delegation_key("TST", "ai:claude@joy", &seed).unwrap();
let loaded = load_delegation_key("TST", "ai:claude@joy")
.unwrap()
.unwrap();
assert_eq!(loaded, seed);
});
}
#[test]
fn load_missing_returns_none() {
with_state_dir(|| {
let res = load_delegation_key("TST", "ai:absent@joy").unwrap();
assert!(res.is_none());
});
}
#[test]
fn remove_deletes_file() {
with_state_dir(|| {
let seed = [1u8; 32];
save_delegation_key("TST", "ai:claude@joy", &seed).unwrap();
assert!(load_delegation_key("TST", "ai:claude@joy")
.unwrap()
.is_some());
remove_delegation_key("TST", "ai:claude@joy").unwrap();
assert!(load_delegation_key("TST", "ai:claude@joy")
.unwrap()
.is_none());
});
}
#[test]
fn remove_missing_is_noop() {
with_state_dir(|| {
remove_delegation_key("TST", "ai:never@joy").unwrap();
});
}
#[test]
fn projects_are_isolated() {
with_state_dir(|| {
let seed_a = [7u8; 32];
let seed_b = [9u8; 32];
save_delegation_key("AAA", "ai:claude@joy", &seed_a).unwrap();
save_delegation_key("BBB", "ai:claude@joy", &seed_b).unwrap();
assert_eq!(
load_delegation_key("AAA", "ai:claude@joy").unwrap(),
Some(seed_a)
);
assert_eq!(
load_delegation_key("BBB", "ai:claude@joy").unwrap(),
Some(seed_b)
);
});
}
#[cfg(unix)]
#[test]
fn file_mode_is_0600() {
use std::os::unix::fs::PermissionsExt;
with_state_dir(|| {
let seed = [3u8; 32];
save_delegation_key("TST", "ai:claude@joy", &seed).unwrap();
let path = delegation_key_path("TST", "ai:claude@joy").unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
});
}
#[test]
fn rename_moves_directory() {
with_state_dir(|| {
save_delegation_key("OLD", "ai:claude@joy", &[1u8; 32]).unwrap();
rename_project_delegations("OLD", "NEW").unwrap();
assert_eq!(
load_delegation_key("NEW", "ai:claude@joy").unwrap(),
Some([1u8; 32])
);
assert!(load_delegation_key("OLD", "ai:claude@joy")
.unwrap()
.is_none());
});
}
#[test]
fn rename_missing_source_is_noop() {
with_state_dir(|| {
rename_project_delegations("NONE", "TARGET").unwrap();
assert!(!delegation_dir("TARGET").unwrap().exists());
});
}
#[test]
fn rename_target_exists_errors() {
with_state_dir(|| {
save_delegation_key("SRC", "ai:a@joy", &[1u8; 32]).unwrap();
save_delegation_key("DST", "ai:b@joy", &[2u8; 32]).unwrap();
let err = rename_project_delegations("SRC", "DST").unwrap_err();
assert!(
matches!(&err, JoyError::AuthFailed(msg) if msg.contains("already exists")),
"expected AuthFailed with 'already exists', got: {err:?}"
);
assert!(load_delegation_key("SRC", "ai:a@joy").unwrap().is_some());
assert!(load_delegation_key("DST", "ai:b@joy").unwrap().is_some());
});
}
#[test]
fn rename_same_id_is_noop() {
with_state_dir(|| {
save_delegation_key("SAME", "ai:claude@joy", &[3u8; 32]).unwrap();
rename_project_delegations("SAME", "SAME").unwrap();
assert_eq!(
load_delegation_key("SAME", "ai:claude@joy").unwrap(),
Some([3u8; 32])
);
});
}
#[test]
fn corrupt_file_rejected() {
with_state_dir(|| {
let dir = delegation_dir("TST").unwrap();
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("ai_claude_joy.key");
std::fs::write(&path, b"too-short").unwrap();
let res = load_delegation_key("TST", "ai:claude@joy");
assert!(res.is_err());
});
}
}