auv-daemon 0.0.22

Server-side SDK for hosting an AUV daemon
//! Private persistence implementation for paired-Device authentication data.

use std::collections::{HashMap, HashSet};
use std::fs::{self, File, OpenOptions};
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, RwLock};

use fs2::FileExt;

use super::{PairingError, PairingRecord};

const STORE_VERSION: u32 = 1;

pub(super) struct FileStore {
  path: PathBuf,
  _lifetime_lock: File,
  snapshot: RwLock<StoreFile>,
  mutation: Mutex<()>,
}

impl FileStore {
  pub(super) fn open(path: PathBuf) -> Result<Self, PairingError> {
    let parent = path.parent().ok_or_else(|| update_error(&path, "pairing store path has no parent"))?;
    fs::create_dir_all(parent).map_err(|error| update_error(&path, format!("failed to create parent directory: {error}")))?;
    set_private_directory(parent)?;
    let lock_path = path.with_extension("lock");
    let lock = OpenOptions::new()
      .create(true)
      .truncate(false)
      .read(true)
      .write(true)
      .open(&lock_path)
      .map_err(|error| update_error(&path, format!("failed to open lock {}: {error}", lock_path.display())))?;
    set_private_file(&lock_path)?;
    lock
      .try_lock_exclusive()
      .map_err(|error| update_error(&path, format!("another pairing-store owner holds {}: {error}", lock_path.display())))?;
    let snapshot = read_store(&path)?;
    Ok(Self {
      path,
      _lifetime_lock: lock,
      snapshot: RwLock::new(snapshot),
      mutation: Mutex::new(()),
    })
  }

  pub(super) fn devices(&self) -> Vec<PairingRecord> {
    self.snapshot.read().expect("pairing snapshot lock poisoned").devices.clone()
  }

  pub(super) fn with_snapshot<T>(&self, read: impl FnOnce(&StoreFile) -> T) -> T {
    read(&self.snapshot.read().expect("pairing snapshot lock poisoned"))
  }

  pub(super) fn update<T>(&self, mutate: impl FnOnce(&mut StoreFile) -> Result<T, PairingError>) -> Result<T, PairingError> {
    let _mutation = self.mutation.lock().expect("pairing mutation lock poisoned");
    let mut next = self.snapshot.read().expect("pairing snapshot lock poisoned").clone();
    let result = mutate(&mut next)?;
    next.revision = next.revision.checked_add(1).ok_or_else(|| update_error(&self.path, "pairing store revision overflow"))?;
    validate_store(&next)?;
    let persistence = write_store(&self.path, &next);
    if persistence.is_ok() || matches!(persistence, Err(PairingError::CommittedButDurabilityUnknown { .. })) {
      *self.snapshot.write().expect("pairing snapshot lock poisoned") = next;
    }
    persistence.map(|()| result)
  }
}

#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
pub(super) struct StoreFile {
  version: u32,
  pub(super) revision: u64,
  pub(super) devices: Vec<PairingRecord>,
  #[serde(default)]
  pub(super) tokens: Vec<PairingTokenRecord>,
}

#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
pub(super) struct PairingTokenRecord {
  pub(super) digest: String,
  pub(super) expires_at: Option<u64>,
}

impl Default for StoreFile {
  fn default() -> Self {
    Self {
      version: STORE_VERSION,
      revision: 0,
      devices: Vec::new(),
      tokens: Vec::new(),
    }
  }
}

fn validate_store(store: &StoreFile) -> Result<(), PairingError> {
  if store.version != STORE_VERSION {
    return Err(PairingError::UnsupportedVersion {
      version: store.version,
      path: PathBuf::from("<pairing-store>"),
    });
  }
  let mut pair_ids = HashSet::new();
  let mut credential_digests = HashMap::new();
  for record in &store.devices {
    if record.pair_id.trim().is_empty() {
      return Err(PairingError::EmptyPairId);
    }
    if !pair_ids.insert(record.pair_id.clone()) {
      return Err(PairingError::DuplicatePairId(record.pair_id.clone()));
    }
    for credential in &record.device_credentials {
      if credential.credential_sha256.len() != 64 || !credential.credential_sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
        return Err(update_error(Path::new("<pairing-store>"), "invalid Device credential digest"));
      }
      if let Some(existing) = credential_digests.insert(credential.credential_sha256.clone(), record.pair_id.clone()) {
        return Err(PairingError::DuplicateCredential(existing));
      }
    }
  }
  let mut token_digests = HashSet::new();
  for token in &store.tokens {
    if token.digest.len() != 64 || !token.digest.bytes().all(|byte| byte.is_ascii_hexdigit()) || !token_digests.insert(token.digest.clone())
    {
      return Err(update_error(Path::new("<pairing-store>"), "invalid or duplicate pairing token digest"));
    }
  }
  Ok(())
}

fn read_store(path: &Path) -> Result<StoreFile, PairingError> {
  let bytes = match fs::read(path) {
    Ok(bytes) => bytes,
    Err(source) if source.kind() == ErrorKind::NotFound => return Ok(StoreFile::default()),
    Err(source) => {
      return Err(PairingError::Read {
        path: path.to_path_buf(),
        source,
      });
    }
  };
  let store = serde_json::from_slice::<StoreFile>(&bytes).map_err(|source| PairingError::Decode {
    path: path.to_path_buf(),
    source,
  })?;
  if store.version != STORE_VERSION {
    return Err(PairingError::UnsupportedVersion {
      version: store.version,
      path: path.to_path_buf(),
    });
  }
  validate_store(&store)?;
  Ok(store)
}

fn write_store(path: &Path, store: &StoreFile) -> Result<(), PairingError> {
  let temporary_path = path.with_extension(format!("tmp-{}", uuid::Uuid::now_v7()));
  let mut temporary = TemporaryStore::new(temporary_path.clone());
  let mut file = OpenOptions::new()
    .write(true)
    .create_new(true)
    .open(&temporary_path)
    .map_err(|error| update_error(path, format!("failed to create temporary store: {error}")))?;
  set_private_file(&temporary_path)?;
  serde_json::to_writer_pretty(&mut file, store).map_err(|error| update_error(path, format!("failed to encode store: {error}")))?;
  file.write_all(b"\n").and_then(|_| file.sync_all()).map_err(|error| update_error(path, format!("failed to sync store: {error}")))?;
  drop(file);
  fs::rename(&temporary_path, path).map_err(|error| update_error(path, format!("failed to publish store: {error}")))?;
  temporary.committed = true;
  let parent = path.parent().expect("validated pairing-store parent");
  File::open(parent).and_then(|directory| directory.sync_all()).map_err(|error| PairingError::CommittedButDurabilityUnknown {
    revision: store.revision,
    message: error.to_string(),
  })
}

struct TemporaryStore {
  path: PathBuf,
  committed: bool,
}

impl TemporaryStore {
  fn new(path: PathBuf) -> Self {
    Self {
      path,
      committed: false,
    }
  }
}

impl Drop for TemporaryStore {
  fn drop(&mut self) {
    if !self.committed {
      let _ = fs::remove_file(&self.path);
    }
  }
}

fn update_error(path: &Path, message: impl Into<String>) -> PairingError {
  PairingError::Update {
    path: path.to_path_buf(),
    message: message.into(),
  }
}

#[cfg(unix)]
fn set_private_directory(path: &Path) -> Result<(), PairingError> {
  use std::os::unix::fs::PermissionsExt;
  fs::set_permissions(path, fs::Permissions::from_mode(0o700))
    .map_err(|error| update_error(path, format!("failed to set directory permissions: {error}")))
}

#[cfg(not(unix))]
fn set_private_directory(_path: &Path) -> Result<(), PairingError> {
  Ok(())
}

#[cfg(unix)]
fn set_private_file(path: &Path) -> Result<(), PairingError> {
  use std::os::unix::fs::PermissionsExt;
  fs::set_permissions(path, fs::Permissions::from_mode(0o600))
    .map_err(|error| update_error(path, format!("failed to set file permissions: {error}")))
}

#[cfg(not(unix))]
fn set_private_file(_path: &Path) -> Result<(), PairingError> {
  Ok(())
}