use super::{Session, SessionWriterLease};
use crate::persistence::{CrossProcessFileLock, atomic_write_with_permissions};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::{
fs,
io::{self, Read},
path::{Path, PathBuf},
time::Duration,
};
const OWNER_READ_BYTES: u64 = 256;
const OWNER_FRESHNESS: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum SessionOwnershipHint {
DaemonOwned,
Busy,
Unowned,
Unknown,
}
impl SessionOwnershipHint {
pub(crate) fn label(self) -> &'static str {
match self {
Self::DaemonOwned => "daemon-owned",
Self::Busy => "busy",
Self::Unowned => "unowned",
Self::Unknown => "unknown",
}
}
}
impl Session {
pub(super) fn acquire_frontend_writer(
&self,
daemon: bool,
) -> anyhow::Result<Option<SessionWriterLease>> {
let root = self
.path
.parent()
.ok_or_else(|| anyhow::anyhow!("missing root"))?;
super::super::store::prepare_session_root(root)?;
let writer = CrossProcessFileLock::try_acquire(&self.path.with_extension("writer"))?;
if writer.is_some() && daemon {
if let Ok((contents, _)) = read_owner_file(&self.writer_lock_path()) {
let marker = daemon_marker(&contents);
let _ = atomic_write_with_permissions(
&self.path.with_extension("writer-owner"),
marker.as_bytes(),
Some(super::super::store::SESSION_FILE_MODE),
);
}
}
Ok(writer)
}
pub(crate) fn frontend_ownership_hint(&self) -> SessionOwnershipHint {
let Some(root) = self.path.parent() else {
return SessionOwnershipHint::Unknown;
};
if super::super::store::validate_session_root(root).is_err() {
return SessionOwnershipHint::Unknown;
}
let (contents, metadata) = match read_owner_file(&self.writer_lock_path()) {
Ok(value) => value,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return SessionOwnershipHint::Unowned;
}
Err(_) => return SessionOwnershipHint::Unknown,
};
if !valid_fresh_lock(&contents, &metadata) {
return SessionOwnershipHint::Unknown;
}
match read_owner_file(&self.path.with_extension("writer-owner")) {
Ok((marker, _)) if marker == daemon_marker(&contents).as_bytes() => {
SessionOwnershipHint::DaemonOwned
}
Ok(_) => SessionOwnershipHint::Busy,
Err(error) if error.kind() == io::ErrorKind::NotFound => SessionOwnershipHint::Busy,
Err(_) => SessionOwnershipHint::Unknown,
}
}
fn writer_lock_path(&self) -> PathBuf {
self.path
.with_file_name(format!(".{}.writer.lock", self.id()))
}
}
fn daemon_marker(contents: &[u8]) -> String {
use base64::Engine;
format!(
"daemon-v1:{}\n",
base64::engine::general_purpose::STANDARD.encode(Sha256::digest(contents))
)
}
fn valid_fresh_lock(contents: &[u8], metadata: &fs::Metadata) -> bool {
let Ok(contents) = std::str::from_utf8(contents) else {
return false;
};
let mut lines = contents.lines();
let valid_pid = lines
.next()
.and_then(|line| line.strip_prefix("pid="))
.and_then(|pid| pid.parse::<u32>().ok())
.is_some_and(|pid| pid > 0);
let valid_token = lines
.next()
.and_then(|line| line.strip_prefix("token="))
.is_some_and(|token| !token.is_empty());
valid_pid
&& valid_token
&& lines.next().is_none()
&& metadata
.modified()
.ok()
.and_then(|time| time.elapsed().ok())
.is_some_and(|age| age <= OWNER_FRESHNESS)
}
fn read_owner_file(path: &Path) -> io::Result<(Vec<u8>, fs::Metadata)> {
let metadata = fs::symlink_metadata(path)?;
if !metadata.is_file() || metadata.len() > OWNER_READ_BYTES {
return Err(io::Error::other("invalid ownership metadata"));
}
let mut options = fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
}
let file = options.open(path)?;
let metadata = file.metadata()?;
if !metadata.is_file() || metadata.len() > OWNER_READ_BYTES {
return Err(io::Error::other("invalid ownership metadata"));
}
let mut contents = Vec::new();
file.take(OWNER_READ_BYTES + 1).read_to_end(&mut contents)?;
if contents.len() as u64 > OWNER_READ_BYTES {
return Err(io::Error::other("ownership metadata exceeds read limit"));
}
Ok((contents, metadata))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sessions::SessionManager;
fn test_session(root: &Path) -> Session {
super::super::super::store::prepare_session_root(root).unwrap();
SessionManager::new(root.to_owned())
.open("hint-test")
.unwrap()
}
#[test]
fn hints_never_acquire_or_override_writer_authority() {
let temp = tempfile::tempdir().unwrap();
let session = test_session(&temp.path().join("sessions"));
assert_eq!(
session.frontend_ownership_hint(),
SessionOwnershipHint::Unowned
);
assert!(!session.writer_lock_path().exists());
let daemon = session.try_daemon_writer().unwrap().unwrap();
assert_eq!(
session.frontend_ownership_hint(),
SessionOwnershipHint::DaemonOwned
);
session
.append(&crate::sessions::SessionEvent::new(
"diagnostic",
session.id().to_owned(),
temp.path().to_owned(),
serde_json::json!({}),
))
.unwrap();
let manager = SessionManager::new(temp.path().join("sessions"));
let list = serde_json::to_value(manager.frontend_list(None, 1).unwrap()).unwrap();
assert_eq!(list["sessions"][0]["ownership_hint"], "daemon-owned");
assert_eq!(list["sessions"][0].as_object().unwrap().len(), 4);
assert!(session.try_frontend_writer().unwrap().is_none());
drop(daemon);
assert_eq!(
session.frontend_ownership_hint(),
SessionOwnershipHint::Unowned
);
let local = session.try_frontend_writer().unwrap().unwrap();
assert_eq!(
session.frontend_ownership_hint(),
SessionOwnershipHint::Busy
);
assert!(session.try_daemon_writer().unwrap().is_none());
drop(local);
}
#[test]
fn stale_corrupt_and_oversized_evidence_is_read_only() {
let temp = tempfile::tempdir().unwrap();
let session = test_session(&temp.path().join("sessions"));
let path = session.writer_lock_path();
for contents in [b"bad".to_vec(), vec![b'x'; 257]] {
fs::write(&path, &contents).unwrap();
assert_eq!(
session.frontend_ownership_hint(),
SessionOwnershipHint::Unknown
);
assert_eq!(fs::read(&path).unwrap(), contents);
}
let contents = b"pid=1\ntoken=old-owner\n";
fs::write(&path, contents).unwrap();
fs::write(
session.path.with_extension("writer-owner"),
daemon_marker(contents),
)
.unwrap();
fs::File::options()
.write(true)
.open(&path)
.unwrap()
.set_modified(std::time::SystemTime::UNIX_EPOCH)
.unwrap();
assert_eq!(
session.frontend_ownership_hint(),
SessionOwnershipHint::Unknown
);
assert_eq!(fs::read(&path).unwrap(), contents);
}
#[cfg(unix)]
#[test]
fn ownership_reads_reject_symlinks_and_special_files() {
let temp = tempfile::tempdir().unwrap();
let session = test_session(&temp.path().join("sessions"));
let path = session.writer_lock_path();
std::os::unix::fs::symlink("/dev/zero", &path).unwrap();
assert_eq!(
session.frontend_ownership_hint(),
SessionOwnershipHint::Unknown
);
fs::remove_file(&path).unwrap();
fs::create_dir(&path).unwrap();
assert_eq!(
session.frontend_ownership_hint(),
SessionOwnershipHint::Unknown
);
}
}