use std::cell::RefCell;
use std::fs;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use camino::Utf8PathBuf;
use ed25519_dalek::SigningKey;
use void_core::collab::manifest::{NostrPubKey, RecipientPubKey, SigningPubKey};
use void_core::collab::{decrypt_identity_keys, encrypt_identity_keys, Identity};
use void_core::{cid, refs};
use crate::output::CliError;
#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
pub type Result<T> = std::result::Result<T, CliError>;
const MAX_USERNAME_LEN: usize = 64;
thread_local! {
static VOID_HOME_OVERRIDE: RefCell<Option<PathBuf>> = RefCell::new(None);
}
#[cfg(test)]
pub struct VoidHomeGuard(());
#[cfg(test)]
impl VoidHomeGuard {
pub fn new(home_dir: impl Into<PathBuf>) -> Self {
let void_home = home_dir.into().join(".void");
VOID_HOME_OVERRIDE.with(|cell| {
*cell.borrow_mut() = Some(void_home);
});
VoidHomeGuard(())
}
}
#[cfg(test)]
impl Drop for VoidHomeGuard {
fn drop(&mut self) {
VOID_HOME_OVERRIDE.with(|cell| {
*cell.borrow_mut() = None;
});
}
}
pub fn find_void_dir(path: &Path) -> Result<PathBuf> {
let mut current = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
loop {
let void_dir = current.join(".void");
if void_dir.is_dir() {
return Ok(void_dir);
}
match current.parent() {
Some(parent) => current = parent.to_path_buf(),
None => {
return Err(CliError::not_initialized(
"not a void repository (or any parent up to mount point)",
))
}
}
}
}
pub fn open_repo(cwd: &Path) -> Result<void_core::sdk::Repo> {
use void_core::sdk::Repo;
if let Ok(token_b64) = std::env::var("VOID_MACHINE_TOKEN") {
let token = void_core::crypto::machine_token::MachineToken::from_base64(&token_b64)
.map_err(|e| CliError::internal(format!("invalid machine token: {}", e)))?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if token.is_expired(now) {
return Err(CliError::internal("machine token has expired"));
}
return Repo::builder(cwd)
.token(token)
.build()
.map_err(void_err_to_cli);
}
let identity = load_identity_cached()?;
Repo::builder(cwd)
.identity(identity)
.build()
.map_err(void_err_to_cli)
}
pub fn void_err_to_cli(err: void_core::VoidError) -> CliError {
use void_core::VoidError;
match err {
VoidError::NotInitialized => {
CliError::not_initialized("not a void repository (or any parent up to mount point)")
}
VoidError::NotFound(msg) => CliError::not_found(msg),
VoidError::NothingToCommit(msg) => CliError::invalid_args(msg),
VoidError::InvalidPattern(msg) => CliError::invalid_args(msg),
other => CliError::internal(other.to_string()),
}
}
pub fn resolve_ref(void_dir: impl AsRef<Path>, ref_str: &str) -> Result<void_core::crypto::CommitCid> {
let void_dir = Utf8PathBuf::try_from(void_dir.as_ref().to_path_buf())
.map_err(|e| CliError::internal(format!("invalid path: {}", e)))?;
if ref_str == "HEAD" {
return refs::resolve_head(&void_dir)
.map_err(void_err_to_cli)?
.ok_or_else(|| CliError::not_found("HEAD is not set"));
}
if let Ok(Some(commit_cid)) = refs::read_branch(&void_dir, ref_str) {
return Ok(commit_cid);
}
if let Ok(Some(commit_cid)) = refs::read_tag(&void_dir, ref_str) {
return Ok(commit_cid);
}
cid::parse(ref_str)
.map(|c| void_core::crypto::CommitCid::from_bytes(cid::to_bytes(&c)))
.map_err(|_| CliError::not_found(format!("unknown reference: {}", ref_str)))
}
pub fn signing_key_exists() -> bool {
get_identity_dir().join("keys.enc").exists()
}
pub fn load_signing_key() -> Result<SigningKey> {
let identity = load_identity_cached()?;
Ok(identity.signing_key().clone())
}
pub fn get_void_home() -> PathBuf {
let overridden = VOID_HOME_OVERRIDE.with(|cell| cell.borrow().clone());
if let Some(path) = overridden {
return path;
}
std::env::var("HOME")
.ok()
.map(PathBuf::from)
.or_else(dirs::home_dir)
.unwrap_or_else(|| PathBuf::from("."))
.join(".void")
}
pub fn get_identity_dir() -> PathBuf {
get_void_home().join("identity")
}
pub fn load_identity_cached() -> Result<Identity> {
let identity_dir = get_identity_dir();
let signing_pub_path = identity_dir.join("signing.pub");
if !signing_pub_path.exists() {
return Err(CliError::not_found(
"identity not initialized, run 'void identity init'",
));
}
let signing_pubkey_hex = fs::read_to_string(&signing_pub_path)
.map_err(|e| CliError::io_error(e.to_string()))?
.trim()
.to_string();
if let Some(identity) = crate::keyring::load_cached_keys(&signing_pubkey_hex) {
return Ok(identity);
}
let pin = prompt_pin()?;
let identity = load_identity_with_pin(&pin)?;
crate::keyring::cache_keys(&signing_pubkey_hex, &identity);
Ok(identity)
}
pub fn load_identity_with_pin(pin: &str) -> Result<Identity> {
let identity_dir = get_identity_dir();
let keys_path = identity_dir.join("keys.enc");
if !keys_path.exists() {
return Err(CliError::not_found(
"identity not initialized, run 'void identity init'",
));
}
let encrypted = fs::read(&keys_path).map_err(|e| CliError::io_error(e.to_string()))?;
let (signing_secret, recipient_secret, nostr_secret) = decrypt_identity_keys(&encrypted, pin)
.map_err(|e| CliError::internal(format!("failed to decrypt identity: {}", e)))?;
Ok(match nostr_secret {
Some(nostr) => Identity::from_bytes_with_nostr(&signing_secret, &recipient_secret, nostr),
None => Identity::from_bytes(&signing_secret, &recipient_secret),
})
}
pub fn load_public_identity(
) -> Result<(Option<String>, SigningPubKey, RecipientPubKey, Option<NostrPubKey>)> {
let identity_dir = get_identity_dir();
let signing_pub_path = identity_dir.join("signing.pub");
let recipient_pub_path = identity_dir.join("recipient.pub");
if !signing_pub_path.exists() || !recipient_pub_path.exists() {
return Err(CliError::not_found(
"identity not initialized, run 'void identity init'",
));
}
let signing_hex =
fs::read_to_string(&signing_pub_path).map_err(|e| CliError::io_error(e.to_string()))?;
let recipient_hex =
fs::read_to_string(&recipient_pub_path).map_err(|e| CliError::io_error(e.to_string()))?;
let signing_bytes: [u8; 32] = hex::decode(signing_hex.trim())
.map_err(|e| CliError::internal(format!("invalid signing pubkey hex: {}", e)))?
.try_into()
.map_err(|_| CliError::internal("signing pubkey must be 32 bytes"))?;
let recipient_bytes: [u8; 32] = hex::decode(recipient_hex.trim())
.map_err(|e| CliError::internal(format!("invalid recipient pubkey hex: {}", e)))?
.try_into()
.map_err(|_| CliError::internal("recipient pubkey must be 32 bytes"))?;
let nostr_pub_path = identity_dir.join("nostr.pub");
let nostr_pubkey = if nostr_pub_path.exists() {
let nostr_hex =
fs::read_to_string(&nostr_pub_path).map_err(|e| CliError::io_error(e.to_string()))?;
let nostr_bytes: [u8; 32] = hex::decode(nostr_hex.trim())
.map_err(|e| CliError::internal(format!("invalid nostr pubkey hex: {}", e)))?
.try_into()
.map_err(|_| CliError::internal("nostr pubkey must be 32 bytes"))?;
Some(NostrPubKey::from_bytes(nostr_bytes))
} else {
None
};
let username = load_username(&identity_dir);
Ok((
username,
SigningPubKey::from_bytes(signing_bytes),
RecipientPubKey::from_bytes(recipient_bytes),
nostr_pubkey,
))
}
pub fn save_identity(
identity: &Identity,
username: &str,
pin: &str,
email: Option<&str>,
signal: Option<&str>,
) -> Result<()> {
validate_identity_username(username)?;
let identity_dir = get_identity_dir();
fs::create_dir_all(&identity_dir).map_err(|e| CliError::io_error(e.to_string()))?;
set_mode_if_unix(&identity_dir, 0o700)?;
fs::write(
identity_dir.join("signing.pub"),
identity.signing_pubkey().to_hex(),
)
.map_err(|e| CliError::io_error(e.to_string()))?;
set_mode_if_unix(&identity_dir.join("signing.pub"), 0o644)?;
fs::write(
identity_dir.join("recipient.pub"),
identity.recipient_pubkey().to_hex(),
)
.map_err(|e| CliError::io_error(e.to_string()))?;
set_mode_if_unix(&identity_dir.join("recipient.pub"), 0o644)?;
let mut profile = serde_json::json!({ "username": username });
if let Some(e) = email {
profile["email"] = serde_json::Value::String(e.to_string());
}
if let Some(s) = signal {
profile["signal"] = serde_json::Value::String(s.to_string());
}
fs::write(
identity_dir.join("profile.json"),
serde_json::to_string_pretty(&profile).map_err(|e| CliError::internal(e.to_string()))?,
)
.map_err(|e| CliError::io_error(e.to_string()))?;
set_mode_if_unix(&identity_dir.join("profile.json"), 0o644)?;
let signing_secret = identity.signing_key_bytes();
let recipient_secret = identity.recipient_key_bytes();
let nostr_secret = identity
.nostr_key_bytes()
.unwrap_or_else(|| void_core::collab::NostrSecretKey::from_bytes(rand::random()));
if let Some(nostr_pub) = identity.nostr_pubkey() {
fs::write(
identity_dir.join("nostr.pub"),
nostr_pub.to_hex(),
)
.map_err(|e| CliError::io_error(e.to_string()))?;
set_mode_if_unix(&identity_dir.join("nostr.pub"), 0o644)?;
}
let encrypted = encrypt_identity_keys(&signing_secret, &recipient_secret, &nostr_secret, pin)
.map_err(|e| CliError::internal(format!("failed to encrypt identity: {}", e)))?;
let keys_path = identity_dir.join("keys.enc");
let mut open_opts = fs::OpenOptions::new();
open_opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
open_opts.mode(0o600);
}
let mut keys_file = open_opts
.open(&keys_path)
.map_err(|e| CliError::io_error(e.to_string()))?;
use std::io::Write;
keys_file
.write_all(&encrypted)
.map_err(|e| CliError::io_error(e.to_string()))?;
set_mode_if_unix(&keys_path, 0o600)?;
Ok(())
}
pub fn identity_exists() -> bool {
get_identity_dir().join("keys.enc").exists()
}
pub fn prompt_pin() -> Result<String> {
if !std::io::stdin().is_terminal() {
return Err(CliError::io_error(
"PIN required but no TTY available. Run 'void identity unlock' in a terminal first.",
));
}
let pin = rpassword::prompt_password("Enter PIN: ")
.map_err(|e| CliError::io_error(format!("failed to read PIN: {}", e)))?;
if pin.is_empty() {
return Err(CliError::invalid_args("PIN must not be empty"));
}
Ok(pin)
}
pub fn validate_identity_username(username: &str) -> Result<()> {
if !is_valid_identity_username(username) {
return Err(CliError::invalid_args(
"invalid username: use 1-64 characters matching [A-Za-z0-9_.-]",
));
}
Ok(())
}
fn is_valid_identity_username(username: &str) -> bool {
!username.is_empty()
&& username.len() <= MAX_USERNAME_LEN
&& username
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
}
fn load_username(identity_dir: &Path) -> Option<String> {
let profile_path = identity_dir.join("profile.json");
let content = fs::read_to_string(profile_path).ok()?;
let value: serde_json::Value = serde_json::from_str(&content).ok()?;
let username = value.get("username")?.as_str()?;
if !is_valid_identity_username(username) {
return None;
}
Some(username.to_string())
}
#[cfg(unix)]
fn set_mode_if_unix(path: &Path, mode: u32) -> Result<()> {
let perms = fs::Permissions::from_mode(mode);
fs::set_permissions(path, perms).map_err(|e| CliError::io_error(e.to_string()))
}
#[cfg(not(unix))]
fn set_mode_if_unix(_path: &Path, _mode: u32) -> Result<()> {
Ok(())
}
#[cfg(test)]
pub fn setup_test_manifest(void_dir: &Path, key: &[u8; 32], home_dir: &Path) -> VoidHomeGuard {
use std::time::{SystemTime, UNIX_EPOCH};
use void_core::collab::manifest::{
ecies_wrap_key, save_manifest, Contributor, ContributorId, Manifest, RepoKey,
};
use void_core::collab::Identity;
let identity = Identity::generate();
let signing_pub = identity.signing_pubkey();
let recipient_pub = identity.recipient_pubkey();
let identity_dir = home_dir.join(".void").join("identity");
fs::create_dir_all(&identity_dir).unwrap();
fs::write(identity_dir.join("signing.pub"), signing_pub.to_hex()).unwrap();
fs::write(identity_dir.join("recipient.pub"), recipient_pub.to_hex()).unwrap();
fs::write(
identity_dir.join("profile.json"),
r#"{"username":"test-user"}"#,
)
.unwrap();
let signing_secret = identity.signing_key_bytes();
let recipient_secret = identity.recipient_key_bytes();
let nostr_secret = identity
.nostr_key_bytes()
.unwrap_or_else(|| void_core::collab::NostrSecretKey::from_bytes(rand::random()));
let pin = "test-pin";
let encrypted =
void_core::collab::encrypt_identity_keys(&signing_secret, &recipient_secret, &nostr_secret, pin)
.unwrap();
fs::write(identity_dir.join("keys.enc"), &encrypted).unwrap();
let guard = VoidHomeGuard::new(home_dir);
crate::keyring::cache_keys(&signing_pub.to_hex(), &identity);
let mut manifest = Manifest::new(signing_pub.clone(), None);
let wrapped = ecies_wrap_key(&RepoKey::from_bytes(*key), &recipient_pub).unwrap();
manifest.read_keys.wrapped.insert(signing_pub.clone(), wrapped);
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
manifest.contributors.push(Contributor {
identity: ContributorId::new(signing_pub.clone(), recipient_pub),
name: Some("test-user".to_string()),
nostr_pubkey: None,
added_at: timestamp,
added_by: signing_pub,
signature: vec![],
});
save_manifest(void_dir, &manifest).unwrap();
guard
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
use void_core::collab::Identity;
#[test]
fn test_save_and_load_identity() {
let dir = tempdir().unwrap();
let identity_dir = dir.path().join("identity");
let identity = Identity::generate();
let pin = "test-pin-123";
fs::create_dir_all(&identity_dir).unwrap();
fs::write(
identity_dir.join("signing.pub"),
identity.signing_pubkey().to_hex(),
)
.unwrap();
fs::write(
identity_dir.join("recipient.pub"),
identity.recipient_pubkey().to_hex(),
)
.unwrap();
let profile = serde_json::json!({ "username": "alice" });
fs::write(
identity_dir.join("profile.json"),
serde_json::to_string_pretty(&profile).unwrap(),
)
.unwrap();
let signing_secret = identity.signing_key_bytes();
let recipient_secret = identity.recipient_key_bytes();
let nostr_secret = identity
.nostr_key_bytes()
.unwrap_or_else(|| void_core::collab::NostrSecretKey::from_bytes([0xbb; 32]));
let encrypted = void_core::collab::encrypt_identity_keys(
&signing_secret,
&recipient_secret,
&nostr_secret,
pin,
)
.unwrap();
fs::write(identity_dir.join("keys.enc"), &encrypted).unwrap();
let loaded_encrypted = fs::read(identity_dir.join("keys.enc")).unwrap();
let (dec_signing, dec_recipient, dec_nostr) =
void_core::collab::decrypt_identity_keys(&loaded_encrypted, pin).unwrap();
let loaded = match dec_nostr {
Some(nostr) => {
Identity::from_bytes_with_nostr(&dec_signing, &dec_recipient, nostr)
}
None => Identity::from_bytes(&dec_signing, &dec_recipient),
};
assert_eq!(identity.signing_pubkey(), loaded.signing_pubkey());
assert_eq!(identity.recipient_pubkey(), loaded.recipient_pubkey());
let signing_hex = fs::read_to_string(identity_dir.join("signing.pub")).unwrap();
let recipient_hex = fs::read_to_string(identity_dir.join("recipient.pub")).unwrap();
assert_eq!(signing_hex, identity.signing_pubkey().to_hex());
assert_eq!(recipient_hex, identity.recipient_pubkey().to_hex());
let loaded_username = load_username(&identity_dir);
assert_eq!(loaded_username, Some("alice".to_string()));
}
#[test]
fn test_load_username_missing_file() {
let dir = tempdir().unwrap();
assert_eq!(load_username(dir.path()), None);
}
#[test]
fn test_load_username_invalid_json() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("profile.json"), "not json").unwrap();
assert_eq!(load_username(dir.path()), None);
}
#[test]
fn test_load_username_invalid_format() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("profile.json"),
r#"{"username":"alice@invalid"}"#,
)
.unwrap();
assert_eq!(load_username(dir.path()), None);
}
#[test]
fn test_validate_identity_username() {
assert!(validate_identity_username("alice").is_ok());
assert!(validate_identity_username("alice-01.dev").is_ok());
assert!(validate_identity_username("alice@dev").is_err());
assert!(validate_identity_username("").is_err());
}
#[test]
fn test_find_void_dir_at_root() {
let dir = tempdir().unwrap();
let void_dir = dir.path().join(".void");
fs::create_dir(&void_dir).unwrap();
let found = find_void_dir(dir.path()).unwrap();
assert_eq!(
found.canonicalize().unwrap(),
void_dir.canonicalize().unwrap()
);
}
#[test]
fn test_find_void_dir_from_subdir() {
let dir = tempdir().unwrap();
let void_dir = dir.path().join(".void");
fs::create_dir(&void_dir).unwrap();
let subdir = dir.path().join("src").join("lib");
fs::create_dir_all(&subdir).unwrap();
let found = find_void_dir(&subdir).unwrap();
assert_eq!(
found.canonicalize().unwrap(),
void_dir.canonicalize().unwrap()
);
}
#[test]
fn test_find_void_dir_not_found() {
let dir = tempdir().unwrap();
let result = find_void_dir(dir.path());
assert!(result.is_err());
}
#[test]
fn test_open_repo_success() {
let dir = tempdir().unwrap();
let void_dir = dir.path().join(".void");
fs::create_dir(&void_dir).unwrap();
let cfg = void_core::config::Config::default();
void_core::config::save(&void_dir, &cfg).unwrap();
let key = [0x42u8; 32];
let home = tempdir().unwrap();
let _guard = setup_test_manifest(&void_dir, &key, home.path());
let repo = open_repo(dir.path()).unwrap();
let plaintext = b"test commit data";
let sealed = repo.vault().seal_commit(plaintext).unwrap();
let (decrypted, _reader) = repo.vault().open_commit(&sealed).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_open_repo_no_manifest() {
let dir = tempdir().unwrap();
let void_dir = dir.path().join(".void");
fs::create_dir(&void_dir).unwrap();
let home = tempdir().unwrap();
let identity = Identity::generate();
let identity_dir = home.path().join(".void").join("identity");
fs::create_dir_all(&identity_dir).unwrap();
fs::write(identity_dir.join("signing.pub"), identity.signing_pubkey().to_hex()).unwrap();
fs::write(identity_dir.join("recipient.pub"), identity.recipient_pubkey().to_hex()).unwrap();
fs::write(identity_dir.join("profile.json"), r#"{"username":"test"}"#).unwrap();
let signing_secret = identity.signing_key_bytes();
let recipient_secret = identity.recipient_key_bytes();
let nostr_secret = identity.nostr_key_bytes()
.unwrap_or_else(|| void_core::collab::NostrSecretKey::from_bytes(rand::random()));
let encrypted = void_core::collab::encrypt_identity_keys(
&signing_secret, &recipient_secret, &nostr_secret, "test-pin",
).unwrap();
fs::write(identity_dir.join("keys.enc"), &encrypted).unwrap();
let _guard = VoidHomeGuard::new(home.path());
crate::keyring::cache_keys(&identity.signing_pubkey().to_hex(), &identity);
let result = open_repo(dir.path());
assert!(result.is_err());
}
#[test]
fn test_machine_token_via_sdk_builder() {
use void_core::crypto::machine_token::MachineToken;
use void_core::sdk::Repo;
let dir = tempdir().unwrap();
let void_dir = dir.path().join(".void");
fs::create_dir_all(void_dir.join("objects")).unwrap();
let cfg = serde_json::json!({
"version": 1,
"created": "2026-01-01T00:00:00Z",
"repo_id": "test-repo-id",
});
fs::write(void_dir.join("config.json"), serde_json::to_string(&cfg).unwrap()).unwrap();
let signing = void_core::collab::SigningSecretKey::from_bytes([0x42u8; 32]);
let recipient = void_core::collab::RecipientSecretKey::from_bytes([0x43u8; 32]);
let content_key = [0x44u8; 32];
let token = MachineToken::new(
&signing,
&recipient,
&content_key,
vec![1, 2, 3],
Some("test-repo".to_string()),
u64::MAX,
);
let repo = Repo::builder(dir.path())
.token(token)
.build();
assert!(repo.is_ok(), "machine token repo should build: {:?}", repo.err());
let repo = repo.unwrap();
assert!(repo.vault().is_content_key_mode(), "vault should be content-key mode");
}
#[test]
fn test_machine_token_expired() {
use void_core::crypto::machine_token::MachineToken;
let signing = void_core::collab::SigningSecretKey::from_bytes([0x42u8; 32]);
let recipient = void_core::collab::RecipientSecretKey::from_bytes([0x43u8; 32]);
let token = MachineToken::new(
&signing,
&recipient,
&[0x44u8; 32],
vec![1, 2, 3],
None,
1, );
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
assert!(token.is_expired(now), "token should be expired");
}
#[test]
fn test_machine_token_invalid_base64() {
use void_core::crypto::machine_token::MachineToken;
let result = MachineToken::from_base64("not-valid-base64!!!");
assert!(result.is_err(), "invalid base64 should be rejected");
}
}