use std::{ env, fs, io::Write, path::{Path, PathBuf}, }; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use directories::ProjectDirs; use iroh::SecretKey; const IDENTITY_BACKUP_PREFIX: &str = "appa-identity-v1:"; pub struct AppPaths { pub data_directory: PathBuf, pub(super) database_path: PathBuf, identity_path: PathBuf, } impl AppPaths { pub fn discover() -> anyhow::Result { let data_directory = match env::var_os("APPA_HOME") { Some(path) => PathBuf::from(path), None => ProjectDirs::from("dev", "Appa", "Appa") .ok_or_else(|| anyhow::anyhow!("could not determine Appa data directory"))? .data_local_dir() .to_owned(), }; Self::from_data_directory(data_directory) } pub fn from_data_directory(data_directory: PathBuf) -> anyhow::Result { fs::create_dir_all(&data_directory)?; restrict_directory_permissions(&data_directory)?; let database_path = data_directory.join("state.sqlite3"); ensure_private_file(&database_path)?; Ok(Self { database_path, identity_path: data_directory.join("identity.key"), data_directory, }) } pub fn daemon_socket_path(&self) -> PathBuf { self.data_directory.join("appad.sock") } pub fn load_identity(&self) -> anyhow::Result { if self.identity_path.exists() { return read_identity(&self.identity_path); } let identity = SecretKey::generate(); write_identity(&self.identity_path, &identity)?; Ok(identity) } pub fn export_identity(&self, destination: &Path) -> anyhow::Result<()> { let identity = self.load_identity()?; let contents = format!( "{IDENTITY_BACKUP_PREFIX}{}\n", URL_SAFE_NO_PAD.encode(identity.to_bytes()) ); create_private_file(destination, contents.as_bytes()) } pub fn import_identity(&self, source: &Path, replace_existing: bool) -> anyhow::Result<()> { if self.identity_path.exists() && !replace_existing { anyhow::bail!("an Appa identity already exists; pass --yes to replace it"); } let encoded_identity = fs::read_to_string(source)?; let encoded_identity = encoded_identity .trim() .strip_prefix(IDENTITY_BACKUP_PREFIX) .ok_or_else(|| anyhow::anyhow!("identity backup has an unsupported format"))?; write_identity( &self.identity_path, &identity_from_encoded(encoded_identity)?, ) } } fn read_identity(identity_path: &Path) -> anyhow::Result { identity_from_encoded(fs::read_to_string(identity_path)?.trim()) } fn identity_from_encoded(encoded_identity: &str) -> anyhow::Result { let key_bytes = URL_SAFE_NO_PAD.decode(encoded_identity)?; let key_bytes: [u8; 32] = key_bytes .try_into() .map_err(|_| anyhow::anyhow!("Appa identity has an invalid length"))?; Ok(SecretKey::from_bytes(&key_bytes)) } fn write_identity(identity_path: &Path, identity: &SecretKey) -> anyhow::Result<()> { write_private_file( identity_path, URL_SAFE_NO_PAD.encode(identity.to_bytes()).as_bytes(), ) } fn ensure_private_file(path: &Path) -> anyhow::Result<()> { if !path.exists() { write_private_file(path, b"")?; } restrict_file_permissions(path) } fn create_private_file(path: &Path, contents: &[u8]) -> anyhow::Result<()> { if path.exists() { anyhow::bail!("{} already exists", path.display()); } write_private_file(path, contents) } fn write_private_file(path: &Path, contents: &[u8]) -> anyhow::Result<()> { let parent = path .parent() .ok_or_else(|| anyhow::anyhow!("{} has no parent directory", path.display()))?; fs::create_dir_all(parent)?; restrict_directory_permissions(parent)?; let mut temporary_file = tempfile::NamedTempFile::new_in(parent)?; restrict_file_permissions(temporary_file.path())?; temporary_file.write_all(contents)?; // Sync before rename so a successful replacement never leaves a partially // written identity or state file after a crash. temporary_file.as_file().sync_all()?; temporary_file.persist(path).map_err(|error| error.error)?; restrict_file_permissions(path) } #[cfg(unix)] fn restrict_directory_permissions(path: &Path) -> anyhow::Result<()> { use std::os::unix::fs::PermissionsExt; fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; Ok(()) } #[cfg(not(unix))] fn restrict_directory_permissions(_path: &Path) -> anyhow::Result<()> { Ok(()) } #[cfg(unix)] fn restrict_file_permissions(path: &Path) -> anyhow::Result<()> { use std::os::unix::fs::PermissionsExt; fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; Ok(()) } #[cfg(not(unix))] fn restrict_file_permissions(_path: &Path) -> anyhow::Result<()> { Ok(()) } #[cfg(test)] mod tests { use tempfile::TempDir; use super::AppPaths; #[cfg(unix)] #[test] fn creates_private_state_and_identity_files() -> anyhow::Result<()> { use std::os::unix::fs::PermissionsExt; let directory = TempDir::new()?; let paths = AppPaths::from_data_directory(directory.path().join("state"))?; paths.load_identity()?; let backup = directory.path().join("identity.appa"); paths.export_identity(&backup)?; assert_eq!( std::fs::metadata(&paths.data_directory)? .permissions() .mode() & 0o777, 0o700 ); assert_eq!( std::fs::metadata(&paths.database_path)? .permissions() .mode() & 0o777, 0o600 ); assert_eq!( std::fs::metadata(&paths.identity_path)? .permissions() .mode() & 0o777, 0o600 ); assert_eq!( std::fs::metadata(backup)?.permissions().mode() & 0o777, 0o600 ); Ok(()) } }