prns-core 0.3.4

Pure Reticulum engine and wire contract for Personal Reticulum
use std::fs;
use std::io::{ErrorKind, Read, Write};
use std::path::{Path, PathBuf};

#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};

use crate::identity::vault::{IdentityLabel, IdentitySecretKey, IdentityVault, Removal};
use crate::identity::{Zeroizing, IDENTITY_SECRET_KEY_LEN};

pub struct FileVault {
    dir: PathBuf,
    dir_ready: bool,
}

#[derive(Debug)]
pub enum FileVaultError {
    Io(std::io::Error),
    MalformedLength { found: u64 },
    BlobOutgrewBuffer { blob_len: usize, buffer_len: usize },
}

impl FileVault {
    pub fn new(dir: impl Into<PathBuf>) -> Self {
        Self {
            dir: dir.into(),
            dir_ready: false,
        }
    }

    pub fn dir(&self) -> &Path {
        &self.dir
    }

    fn path_for(&self, label: &IdentityLabel) -> PathBuf {
        self.dir.join(label.as_str())
    }

    fn ensure_dir(&mut self) -> Result<(), FileVaultError> {
        if self.dir_ready {
            return Ok(());
        }
        fs::create_dir_all(&self.dir)?;
        #[cfg(unix)]
        let _ = fs::set_permissions(&self.dir, fs::Permissions::from_mode(0o700));
        self.dir_ready = true;
        Ok(())
    }
}

impl IdentityVault for FileVault {
    type Error = FileVaultError;

    fn load(&self, label: &IdentityLabel) -> Result<Option<IdentitySecretKey>, Self::Error> {
        read_identity_file(&self.path_for(label))
    }

    fn store(
        &mut self,
        label: &IdentityLabel,
        secret: &[u8; IDENTITY_SECRET_KEY_LEN],
    ) -> Result<(), Self::Error> {
        self.ensure_dir()?;
        let final_path = self.path_for(label);
        let staging_path = self.dir.join(format!(
            ".{}.{}.staging",
            label.as_str(),
            std::process::id()
        ));

        let staged = stage_bytes(&staging_path, secret)
            .and_then(|()| fs::rename(&staging_path, &final_path).map_err(FileVaultError::from));
        if staged.is_err() {
            let _ = fs::remove_file(&staging_path);
        }
        staged
    }

    fn remove(&mut self, label: &IdentityLabel) -> Result<Removal, Self::Error> {
        match fs::remove_file(self.path_for(label)) {
            Ok(()) => Ok(Removal::Removed),
            Err(error) if error.kind() == ErrorKind::NotFound => Ok(Removal::NothingStored),
            Err(error) => Err(error.into()),
        }
    }

    fn stored_blob_len(&self, label: &IdentityLabel) -> Result<Option<usize>, Self::Error> {
        match fs::metadata(self.path_for(label)) {
            Ok(metadata) => Ok(Some(metadata.len() as usize)),
            Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
            Err(error) => Err(error.into()),
        }
    }

    fn load_blob<'b>(
        &self,
        label: &IdentityLabel,
        buf: &'b mut [u8],
    ) -> Result<Option<&'b [u8]>, Self::Error> {
        let mut file = match fs::File::open(self.path_for(label)) {
            Ok(file) => file,
            Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
            Err(error) => return Err(error.into()),
        };
        let blob_len = file.metadata()?.len() as usize;
        if buf.len() < blob_len {
            return Err(FileVaultError::BlobOutgrewBuffer {
                blob_len,
                buffer_len: buf.len(),
            });
        }
        file.read_exact(&mut buf[..blob_len])?;
        Ok(Some(&buf[..blob_len]))
    }

    fn store_blob(&mut self, label: &IdentityLabel, blob: &[u8]) -> Result<(), Self::Error> {
        self.ensure_dir()?;
        let final_path = self.path_for(label);
        let staging_path = self.dir.join(format!(
            ".{}.{}.staging",
            label.as_str(),
            std::process::id()
        ));

        let staged = stage_bytes(&staging_path, blob)
            .and_then(|()| fs::rename(&staging_path, &final_path).map_err(FileVaultError::from));
        if staged.is_err() {
            let _ = fs::remove_file(&staging_path);
        }
        staged
    }
}

pub fn read_identity_file(path: &Path) -> Result<Option<IdentitySecretKey>, FileVaultError> {
    let mut file = match fs::File::open(path) {
        Ok(file) => file,
        Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
        Err(error) => return Err(error.into()),
    };
    let length = file.metadata()?.len();
    if length != IDENTITY_SECRET_KEY_LEN as u64 {
        return Err(FileVaultError::MalformedLength { found: length });
    }
    let mut secret = Zeroizing::new([0u8; IDENTITY_SECRET_KEY_LEN]);
    file.read_exact(&mut secret[..])?;
    Ok(Some(secret))
}

fn stage_bytes(staging_path: &Path, bytes: &[u8]) -> Result<(), FileVaultError> {
    let mut options = fs::OpenOptions::new();
    options.write(true).create(true).truncate(true);
    #[cfg(unix)]
    options.mode(0o600);
    let mut file = options.open(staging_path)?;
    file.write_all(bytes)?;
    file.sync_all()?;
    Ok(())
}

impl From<std::io::Error> for FileVaultError {
    fn from(error: std::io::Error) -> Self {
        FileVaultError::Io(error)
    }
}

impl core::fmt::Display for FileVaultError {
    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            FileVaultError::Io(error) => write!(formatter, "{error}"),
            FileVaultError::MalformedLength { found } => write!(
                formatter,
                "identity file holds {found} bytes, expected {IDENTITY_SECRET_KEY_LEN}"
            ),
            FileVaultError::BlobOutgrewBuffer {
                blob_len,
                buffer_len,
            } => write!(
                formatter,
                "stored blob holds {blob_len} bytes, the buffer holds {buffer_len}"
            ),
        }
    }
}

impl std::error::Error for FileVaultError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            FileVaultError::Io(error) => Some(error),
            FileVaultError::MalformedLength { .. } | FileVaultError::BlobOutgrewBuffer { .. } => {
                None
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::identity::vault::{load_or_generate, IdentityOrigin};
    use std::sync::atomic::{AtomicU32, Ordering};

    struct TempDir {
        path: PathBuf,
    }

    impl TempDir {
        fn new() -> Self {
            static COUNTER: AtomicU32 = AtomicU32::new(0);
            let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
            let path =
                std::env::temp_dir().join(format!("prns-vault-{}-{}", std::process::id(), unique));
            Self { path }
        }
    }

    impl Drop for TempDir {
        fn drop(&mut self) {
            let _ = fs::remove_dir_all(&self.path);
        }
    }

    fn label(text: &str) -> IdentityLabel {
        IdentityLabel::new(text).unwrap()
    }

    fn secret(fill: u8) -> [u8; IDENTITY_SECRET_KEY_LEN] {
        let mut bytes = [0u8; IDENTITY_SECRET_KEY_LEN];
        bytes[..32].fill(fill);
        bytes[32..].fill(fill.wrapping_add(1));
        bytes
    }

    #[test]
    fn a_stored_secret_round_trips_byte_for_byte() {
        let temp = TempDir::new();
        let mut vault = FileVault::new(&temp.path);
        let label = label("primary");
        let written = secret(0xA1);
        vault.store(&label, &written).unwrap();
        let read = vault.load(&label).unwrap().unwrap();
        assert_eq!(*read, written);
    }

    #[test]
    fn a_missing_file_is_a_clean_miss_not_an_error() {
        let temp = TempDir::new();
        let vault = FileVault::new(&temp.path);
        assert!(vault.load(&label("absent")).unwrap().is_none());
    }

    #[cfg(unix)]
    #[test]
    fn a_stored_secret_is_owner_only_on_disk() {
        let temp = TempDir::new();
        let mut vault = FileVault::new(&temp.path);
        let label = label("primary");
        vault.store(&label, &secret(0x22)).unwrap();
        let mode = fs::metadata(temp.path.join("primary"))
            .unwrap()
            .permissions()
            .mode();
        assert_eq!(mode & 0o777, 0o600);
    }

    #[test]
    fn a_foreign_written_reticulum_file_loads_unchanged() {
        let temp = TempDir::new();
        fs::create_dir_all(&temp.path).unwrap();
        let raw = secret(0x5E);
        fs::write(temp.path.join("identity"), raw).unwrap();
        let vault = FileVault::new(&temp.path);
        let read = vault.load(&label("identity")).unwrap().unwrap();
        assert_eq!(*read, raw);
    }

    #[test]
    fn a_wrong_length_file_is_reported_as_malformed() {
        let temp = TempDir::new();
        fs::create_dir_all(&temp.path).unwrap();
        fs::write(temp.path.join("primary"), [0u8; 10]).unwrap();
        let vault = FileVault::new(&temp.path);
        match vault.load(&label("primary")) {
            Err(FileVaultError::MalformedLength { found }) => assert_eq!(found, 10),
            other => panic!("expected MalformedLength, got {other:?}"),
        }
    }

    #[test]
    fn a_stored_blob_round_trips_and_reports_its_length() {
        let temp = TempDir::new();
        let mut vault = FileVault::new(&temp.path);
        let label = label("ratchets.a1b2");
        let blob = [0x5Eu8; 113];
        vault.store_blob(&label, &blob).unwrap();
        assert_eq!(vault.stored_blob_len(&label).unwrap(), Some(blob.len()));
        let mut buf = [0u8; 256];
        assert_eq!(vault.load_blob(&label, &mut buf).unwrap(), Some(&blob[..]));
        assert_eq!(vault.remove(&label).unwrap(), Removal::Removed);
        assert_eq!(vault.stored_blob_len(&label).unwrap(), None);
        assert_eq!(vault.load_blob(&label, &mut buf).unwrap(), None);
    }

    #[test]
    fn a_blob_buffer_too_short_is_an_error_never_a_truncation() {
        let temp = TempDir::new();
        let mut vault = FileVault::new(&temp.path);
        let label = label("ratchets.a1b2");
        vault.store_blob(&label, &[0x11; 64]).unwrap();
        let mut short = [0u8; 32];
        match vault.load_blob(&label, &mut short) {
            Err(FileVaultError::BlobOutgrewBuffer {
                blob_len,
                buffer_len,
            }) => {
                assert_eq!(blob_len, 64);
                assert_eq!(buffer_len, 32);
            }
            other => panic!("expected BlobOutgrewBuffer, got {other:?}"),
        }
    }

    #[test]
    fn remove_reports_presence_then_absence() {
        let temp = TempDir::new();
        let mut vault = FileVault::new(&temp.path);
        let label = label("primary");
        vault.store(&label, &secret(0x10)).unwrap();
        assert_eq!(vault.remove(&label).unwrap(), Removal::Removed);
        assert_eq!(vault.remove(&label).unwrap(), Removal::NothingStored);
    }

    #[test]
    fn load_or_generate_mints_once_then_loads_through_the_file() {
        let temp = TempDir::new();
        let mut vault = FileVault::new(&temp.path);
        let label = label("primary");
        let fill = |bytes: &mut [u8]| {
            for (offset, byte) in bytes.iter_mut().enumerate() {
                *byte = 0x40u8.wrapping_add(offset as u8);
            }
        };
        let (minted, origin) = load_or_generate(&mut vault, &label, fill).unwrap();
        assert_eq!(origin, IdentityOrigin::Generated);
        let (reloaded, origin) = load_or_generate(&mut vault, &label, fill).unwrap();
        assert_eq!(origin, IdentityOrigin::Loaded);
        assert_eq!(*minted, *reloaded);
    }
}