prns-core 0.3.4

Pure Reticulum engine and wire contract for Personal Reticulum
use std::fs;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};

use crate::interface_discovery::{
    AdvertisedInterfaceType, DiscoveryCatalogRestoreError, StampValueError,
};

use super::DISCOVERED_INTERFACES_FILE;

pub(super) fn replace_archive_file(path: &Path, bytes: &[u8]) -> Result<(), DiscoveryArchiveError> {
    let parent = path.parent().unwrap_or_else(|| Path::new("."));
    fs::create_dir_all(parent).map_err(|source| DiscoveryArchiveError::Io {
        operation: ArchiveFileOperation::CreateDirectory,
        path: parent.to_path_buf(),
        source,
    })?;
    let file_name = path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or(DISCOVERED_INTERFACES_FILE);
    let staging = parent.join(format!(".{file_name}.{}.staging", std::process::id()));
    if let Err(source) = stage(&staging, bytes) {
        let _ = fs::remove_file(&staging);
        return Err(DiscoveryArchiveError::Io {
            operation: ArchiveFileOperation::Stage,
            path: staging,
            source,
        });
    }
    match replace_file(&staging, path) {
        Ok(()) => Ok(()),
        Err(source) => {
            let _ = fs::remove_file(&staging);
            Err(DiscoveryArchiveError::Io {
                operation: ArchiveFileOperation::Replace,
                path: path.to_path_buf(),
                source,
            })
        }
    }
}

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

fn replace_file(staging: &Path, final_path: &Path) -> std::io::Result<()> {
    fs::rename(staging, final_path)
}

pub(super) fn encode_hex(bytes: &[u8]) -> String {
    const ALPHABET: &[u8; 16] = b"0123456789abcdef";
    let mut encoded = String::with_capacity(bytes.len() * 2);
    for byte in bytes {
        encoded.push(char::from(ALPHABET[usize::from(byte >> 4)]));
        encoded.push(char::from(ALPHABET[usize::from(byte & 0x0f)]));
    }
    encoded
}

pub(super) fn decode_hex<const N: usize>(encoded: &str) -> Result<[u8; N], HexDecodeError> {
    let expected = N * 2;
    if encoded.len() != expected {
        return Err(HexDecodeError::Length {
            expected,
            actual: encoded.len(),
        });
    }
    let mut decoded = [0u8; N];
    for (index, pair) in encoded.as_bytes().chunks_exact(2).enumerate() {
        let high = decode_nibble(pair[0], index * 2)?;
        let low = decode_nibble(pair[1], index * 2 + 1)?;
        decoded[index] = (high << 4) | low;
    }
    Ok(decoded)
}

fn decode_nibble(byte: u8, index: usize) -> Result<u8, HexDecodeError> {
    match byte {
        b'0'..=b'9' => Ok(byte - b'0'),
        b'a'..=b'f' => Ok(byte - b'a' + 10),
        b'A'..=b'F' => Ok(byte - b'A' + 10),
        _ => Err(HexDecodeError::Digit { index, byte }),
    }
}

#[derive(Debug)]
pub enum DiscoveryArchiveError {
    Io {
        operation: ArchiveFileOperation,
        path: PathBuf,
        source: std::io::Error,
    },
    Decode {
        path: PathBuf,
        source: serde_json::Error,
    },
    Encode {
        path: PathBuf,
        source: serde_json::Error,
    },
    TooLarge {
        path: PathBuf,
        bytes: usize,
        maximum: usize,
    },
    UnsupportedFormat {
        path: PathBuf,
        found: u32,
        supported: u32,
    },
    InvalidRecord {
        path: PathBuf,
        id: String,
        source: ArchiveRecordError,
    },
}

impl DiscoveryArchiveError {
    pub(super) fn read(path: PathBuf, source: std::io::Error) -> Self {
        Self::Io {
            operation: ArchiveFileOperation::Read,
            path,
            source,
        }
    }
}

impl core::fmt::Display for DiscoveryArchiveError {
    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            Self::Io {
                operation,
                path,
                source,
            } => write!(
                formatter,
                "could not {operation} discovery archive {}: {source}",
                path.display()
            ),
            Self::Decode { path, source } => write!(
                formatter,
                "could not decode discovery archive {}: {source}",
                path.display()
            ),
            Self::Encode { path, source } => write!(
                formatter,
                "could not encode discovery archive {}: {source}",
                path.display()
            ),
            Self::TooLarge {
                path,
                bytes,
                maximum,
            } => write!(
                formatter,
                "discovery archive {} is {bytes} bytes, exceeding the {maximum}-byte limit",
                path.display()
            ),
            Self::UnsupportedFormat {
                path,
                found,
                supported,
            } => write!(
                formatter,
                "discovery archive {} uses format {found}, but this build supports format {supported}",
                path.display()
            ),
            Self::InvalidRecord { path, id, source } => write!(
                formatter,
                "discovery archive {} has invalid record {id}: {source}",
                path.display()
            ),
        }
    }
}

impl std::error::Error for DiscoveryArchiveError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Io { source, .. } => Some(source),
            Self::Decode { source, .. } | Self::Encode { source, .. } => Some(source),
            Self::InvalidRecord { source, .. } => Some(source),
            Self::TooLarge { .. } | Self::UnsupportedFormat { .. } => None,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArchiveFileOperation {
    CreateDirectory,
    Read,
    Stage,
    Replace,
}

impl core::fmt::Display for ArchiveFileOperation {
    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            Self::CreateDirectory => formatter.write_str("create the directory for"),
            Self::Read => formatter.write_str("read"),
            Self::Stage => formatter.write_str("stage"),
            Self::Replace => formatter.write_str("replace"),
        }
    }
}

#[derive(Debug)]
pub enum ArchiveRecordError {
    EmptyName,
    ZeroObservationCount,
    InvalidHex {
        field: &'static str,
        source: HexDecodeError,
    },
    UnsupportedInterfaceType {
        value: String,
    },
    MismatchedDetails {
        interface_type: AdvertisedInterfaceType,
    },
    InvalidFloat {
        field: &'static str,
        value: String,
    },
    InvalidReachableAddress {
        value: String,
    },
    StampValue(StampValueError),
    Catalog(DiscoveryCatalogRestoreError),
}

impl core::fmt::Display for ArchiveRecordError {
    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            Self::EmptyName => formatter.write_str("interface name is empty"),
            Self::ZeroObservationCount => formatter.write_str("observation count is zero"),
            Self::InvalidHex { field, source } => write!(formatter, "invalid {field}: {source}"),
            Self::UnsupportedInterfaceType { value } => {
                write!(formatter, "unsupported interface type {value}")
            }
            Self::MismatchedDetails { interface_type } => write!(
                formatter,
                "advertisement details do not match {}",
                interface_type.rns_name()
            ),
            Self::InvalidFloat { field, value } => {
                write!(formatter, "invalid {field} value {value}")
            }
            Self::InvalidReachableAddress { value } => {
                write!(formatter, "invalid reachable address {value}")
            }
            Self::StampValue(source) => source.fmt(formatter),
            Self::Catalog(source) => source.fmt(formatter),
        }
    }
}

impl std::error::Error for ArchiveRecordError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::InvalidHex { source, .. } => Some(source),
            Self::StampValue(source) => Some(source),
            Self::Catalog(source) => Some(source),
            Self::EmptyName
            | Self::ZeroObservationCount
            | Self::UnsupportedInterfaceType { .. }
            | Self::MismatchedDetails { .. }
            | Self::InvalidFloat { .. }
            | Self::InvalidReachableAddress { .. } => None,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HexDecodeError {
    Length { expected: usize, actual: usize },
    Digit { index: usize, byte: u8 },
}

impl core::fmt::Display for HexDecodeError {
    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            Self::Digit { index, byte } => write!(
                formatter,
                "byte {:?} at hexadecimal position {index} is not a hexadecimal digit",
                char::from(*byte)
            ),
            Self::Length { expected, actual } => write!(
                formatter,
                "expected {expected} hexadecimal characters, found {actual}"
            ),
        }
    }
}

impl std::error::Error for HexDecodeError {}