use std::collections::HashMap;
use std::fs::{self, File, OpenOptions};
use std::io::{ErrorKind, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use auv::devices::DeviceEntryErrorReason;
use auv::devices::EnrollmentState;
use fs2::FileExt;
use serde::{Deserialize, Serialize};
use super::policy::Enrollment;
pub(super) struct MetadataStore {
path: PathBuf,
state: Mutex<State>,
poisoned: AtomicBool,
_process_lock: File,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct State {
#[serde(default = "default_enabled")]
enabled: bool,
#[serde(default)]
enrollments: HashMap<String, StoredEnrollment>,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct StoredEnrollment {
user: String,
generation: u64,
state: StoredState,
}
#[derive(Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
enum StoredState {
Pending,
Ready,
Suspended,
Removed,
}
fn default_enabled() -> bool {
true
}
impl MetadataStore {
pub(super) fn open(root: &Path) -> Result<Self, DeviceEntryErrorReason> {
#[cfg(unix)]
private_directory(root)?;
let process_lock = {
let lock_path = root.join("device-entry-policy.lock");
let mut lock_options = OpenOptions::new();
lock_options.create(true).truncate(false).read(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
lock_options.mode(0o600).custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
}
lock_options.open(lock_path).map_err(|_| DeviceEntryErrorReason::ServiceUnavailable)?
};
#[cfg(unix)]
private_file(&process_lock)?;
process_lock.try_lock_exclusive().map_err(|_| DeviceEntryErrorReason::ServiceUnavailable)?;
let path = root.join("device-entry-policy.json");
let state = match read_private_file(&path)? {
Some(bytes) => serde_json::from_slice(&bytes).map_err(|_| DeviceEntryErrorReason::ServiceUnavailable)?,
None => State {
enabled: true,
enrollments: HashMap::new(),
},
};
Ok(Self {
path,
state: Mutex::new(state),
poisoned: AtomicBool::new(false),
_process_lock: process_lock,
})
}
pub(super) fn set_enabled(&self, enabled: bool) -> Result<(), DeviceEntryErrorReason> {
self.update(|state| {
state.enabled = enabled;
Ok(())
})
}
pub(super) fn invalidate_for_enroll(&self, user: &str, os_account_id: &str) -> Result<(), DeviceEntryErrorReason> {
if user.is_empty() || os_account_id.is_empty() {
return Err(DeviceEntryErrorReason::ServiceUnavailable);
}
self.update(|state| {
let next = next_generation(state.enrollments.get(os_account_id))?;
state.enrollments.insert(
os_account_id.to_owned(),
StoredEnrollment {
user: user.to_owned(),
generation: next,
state: StoredState::Suspended,
},
);
Ok(())
})
}
pub(super) fn publish_pending(&self, user: &str, os_account_id: &str) -> Result<Enrollment, DeviceEntryErrorReason> {
if user.is_empty() || os_account_id.is_empty() {
return Err(DeviceEntryErrorReason::ServiceUnavailable);
}
self.update(|state| {
let current = state.enrollments.get(os_account_id).ok_or(DeviceEntryErrorReason::ServiceUnavailable)?;
if current.user != user || !matches!(current.state, StoredState::Suspended) {
return Err(DeviceEntryErrorReason::ServiceUnavailable);
}
let next = next_generation(Some(current))?;
state.enrollments.insert(
os_account_id.to_owned(),
StoredEnrollment {
user: user.to_owned(),
generation: next,
state: StoredState::Pending,
},
);
Ok(Enrollment {
user: user.to_owned(),
os_account_id: os_account_id.to_owned(),
generation: next,
state: EnrollmentState::Pending,
})
})
}
pub(super) fn remove(&self, os_account_id: &str) -> Result<(), DeviceEntryErrorReason> {
self.update(|state| {
if let Some(record) = state.enrollments.get_mut(os_account_id) {
record.state = StoredState::Removed;
}
Ok(())
})
}
pub(super) fn list_enrollments(&self) -> Result<Vec<Enrollment>, DeviceEntryErrorReason> {
self.check_health()?;
let state = self.state.lock().map_err(|_| DeviceEntryErrorReason::ServiceUnavailable)?;
let mut records = state.enrollments.iter().filter_map(|(id, record)| enrollment_from_record(id, record)).collect::<Vec<_>>();
records.sort_by(|left, right| left.os_account_id.cmp(&right.os_account_id));
Ok(records)
}
fn update<T>(&self, operation: impl FnOnce(&mut State) -> Result<T, DeviceEntryErrorReason>) -> Result<T, DeviceEntryErrorReason> {
self.check_health()?;
let mut state = self.state.lock().map_err(|_| DeviceEntryErrorReason::ServiceUnavailable)?;
let value = operation(&mut state)?;
if self.write(&state).is_err() {
self.poisoned.store(true, Ordering::Release);
return Err(DeviceEntryErrorReason::ServiceUnavailable);
}
Ok(value)
}
fn check_health(&self) -> Result<(), DeviceEntryErrorReason> {
if self.poisoned.load(Ordering::Acquire) {
Err(DeviceEntryErrorReason::ServiceUnavailable)
} else {
Ok(())
}
}
fn write(&self, state: &State) -> std::io::Result<()> {
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
let root = self.path.parent().expect("policy path has parent");
#[cfg(unix)]
private_directory(root).map_err(|_| std::io::Error::from(ErrorKind::PermissionDenied))?;
let temp = root.join(format!(".device-entry-policy-{}.tmp", uuid::Uuid::now_v7()));
let mut file = {
let mut options = OpenOptions::new();
options.create_new(true).write(true);
#[cfg(unix)]
options.mode(0o600).custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
options.open(&temp)?
};
let result = (|| {
serde_json::to_writer(&mut file, state)?;
file.write_all(b"\n")?;
file.sync_all()?;
drop(file);
#[cfg(unix)]
fs::rename(&temp, &self.path)?;
#[cfg(unix)]
File::open(root)?.sync_all()?;
#[cfg(windows)]
crate::durable_windows::replace_file(&temp, &self.path)?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(&temp);
}
result
}
}
impl MetadataStore {
pub(super) fn enabled(&self) -> Result<bool, DeviceEntryErrorReason> {
self.check_health()?;
Ok(self.state.lock().map_err(|_| DeviceEntryErrorReason::ServiceUnavailable)?.enabled)
}
pub(super) fn enrollment(&self, os_account_id: &str) -> Result<Option<Enrollment>, DeviceEntryErrorReason> {
self.check_health()?;
let state = self.state.lock().map_err(|_| DeviceEntryErrorReason::ServiceUnavailable)?;
Ok(state.enrollments.get(os_account_id).and_then(|record| enrollment_from_record(os_account_id, record)))
}
pub(super) fn promote_ready(&self, os_account_id: &str, generation: u64) -> Result<(), DeviceEntryErrorReason> {
self.update(|state| {
let record = state.enrollments.get_mut(os_account_id).ok_or(DeviceEntryErrorReason::Unenrolled)?;
if record.generation != generation || !matches!(record.state, StoredState::Pending) {
return Err(DeviceEntryErrorReason::Unenrolled);
}
record.state = StoredState::Ready;
Ok(())
})
}
pub(super) fn suspend(&self, os_account_id: &str, generation: u64) -> Result<(), DeviceEntryErrorReason> {
self.update(|state| {
let record = state.enrollments.get_mut(os_account_id).ok_or(DeviceEntryErrorReason::Unenrolled)?;
if record.generation != generation {
return Err(DeviceEntryErrorReason::Unenrolled);
}
record.state = StoredState::Suspended;
Ok(())
})
}
}
fn next_generation(current: Option<&StoredEnrollment>) -> Result<u64, DeviceEntryErrorReason> {
match current {
None => Ok(1),
Some(current) => current.generation.checked_add(1).ok_or(DeviceEntryErrorReason::ServiceUnavailable),
}
}
fn enrollment_from_record(os_account_id: &str, record: &StoredEnrollment) -> Option<Enrollment> {
let state = match record.state {
StoredState::Pending => EnrollmentState::Pending,
StoredState::Ready => EnrollmentState::Ready,
StoredState::Suspended => EnrollmentState::Suspended,
StoredState::Removed => return None,
};
Some(Enrollment {
user: record.user.clone(),
os_account_id: os_account_id.to_owned(),
generation: record.generation,
state,
})
}
#[cfg(unix)]
fn private_directory(root: &Path) -> Result<(), DeviceEntryErrorReason> {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let metadata = fs::symlink_metadata(root).map_err(|_| DeviceEntryErrorReason::ServiceUnavailable)?;
if !metadata.file_type().is_dir() || metadata.uid() != current_euid() || metadata.permissions().mode() & 0o077 != 0 {
return Err(DeviceEntryErrorReason::ServiceUnavailable);
}
Ok(())
}
#[cfg(unix)]
fn private_file(file: &File) -> Result<(), DeviceEntryErrorReason> {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let metadata = file.metadata().map_err(|_| DeviceEntryErrorReason::ServiceUnavailable)?;
if !metadata.file_type().is_file() || metadata.uid() != current_euid() || metadata.permissions().mode() & 0o077 != 0 {
return Err(DeviceEntryErrorReason::ServiceUnavailable);
}
Ok(())
}
fn read_private_file(path: &Path) -> Result<Option<Vec<u8>>, DeviceEntryErrorReason> {
#[cfg(unix)]
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
#[cfg(unix)]
let opened = {
let mut options = OpenOptions::new();
options.read(true).custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
options.open(path)
};
#[cfg(windows)]
let opened = File::open(path);
let bytes = match opened {
Ok(file) => {
let metadata = file.metadata().map_err(|_| DeviceEntryErrorReason::ServiceUnavailable)?;
if !metadata.file_type().is_file() {
return Err(DeviceEntryErrorReason::ServiceUnavailable);
}
#[cfg(unix)]
if metadata.uid() != current_euid() || metadata.permissions().mode() & 0o077 != 0 {
return Err(DeviceEntryErrorReason::ServiceUnavailable);
}
let mut bytes = Vec::new();
file.take(1024 * 1024 + 1).read_to_end(&mut bytes).map_err(|_| DeviceEntryErrorReason::ServiceUnavailable)?;
if bytes.len() > 1024 * 1024 {
return Err(DeviceEntryErrorReason::ServiceUnavailable);
}
bytes
}
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
Err(_) => return Err(DeviceEntryErrorReason::ServiceUnavailable),
};
Ok(Some(bytes))
}
#[cfg(unix)]
fn current_euid() -> u32 {
unsafe { libc::geteuid() }
}