use std::cell::RefCell;
use std::fs;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::sync::Arc;
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::crypto::KeyVault;
use void_core::support::void_context::{CryptoContext, NetworkConfig, RepoPaths, RepoMeta, SealConfig};
use void_core::{cid, config, refs, VoidContext};
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 build_void_context(cwd: &Path) -> Result<VoidContext> {
let void_dir = find_void_dir(cwd)?;
let root = void_dir
.parent()
.ok_or_else(|| CliError::internal("void_dir has no parent"))?
.to_path_buf();
let identity = load_identity_cached()?;
let repo_key = void_core::collab::manifest::load_repo_key(&void_dir, Some(&identity))
.map_err(void_err_to_cli)?;
let key_bytes = *repo_key.as_bytes();
let vault = Arc::new(
KeyVault::new(key_bytes).map_err(|e| CliError::internal(e.to_string()))?,
);
let cfg = config::load(&void_dir).map_err(|e| CliError::internal(e.to_string()))?;
let secret = config::load_repo_secret(&void_dir, &vault)
.map_err(|e| CliError::internal(e.to_string()))?;
let signing_key = try_load_signing_key_cached();
let root_utf8 =
Utf8PathBuf::try_from(root).map_err(|e| CliError::internal(e.to_string()))?;
let void_dir_utf8 = Utf8PathBuf::try_from(void_dir)
.map_err(|e| CliError::internal(e.to_string()))?;
let workspace_dir = void_dir_utf8.clone();
Ok(VoidContext {
paths: RepoPaths {
root: root_utf8,
void_dir: void_dir_utf8,
workspace_dir,
},
crypto: CryptoContext {
vault,
epoch: 0, signing_key,
},
repo: RepoMeta {
id: cfg.repo_id.clone(),
name: cfg.repo_name.clone(),
secret,
},
seal: SealConfig::from(&cfg),
network: NetworkConfig::from(&cfg),
user: cfg.user.clone(),
})
}
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())
}
fn try_load_signing_key_cached() -> Option<Arc<SigningKey>> {
let identity_dir = get_identity_dir();
let signing_pub_path = identity_dir.join("signing.pub");
let signing_pubkey_hex = std::fs::read_to_string(&signing_pub_path).ok()?.trim().to_string();
let identity = crate::keyring::load_cached_keys(&signing_pubkey_hex)?;
Some(Arc::new(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 username = "alice";
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": username });
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_build_void_context_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 ctx = build_void_context(dir.path()).unwrap();
let plaintext = b"test commit data";
let sealed = ctx.crypto.vault.seal_commit(plaintext).unwrap();
let (decrypted, _reader) = ctx.crypto.vault.open_commit(&sealed).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_build_void_context_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 = build_void_context(dir.path());
assert!(result.is_err());
}
}