use std::{
collections::HashMap,
path::{Path, PathBuf},
time::UNIX_EPOCH,
};
use parking_lot::Mutex;
use crate::error::NpxcError;
const EXDEV: i32 = 18;
pub type PublicationKey = (PathBuf, u64);
#[derive(Debug, Clone)]
pub struct PublishedFile {
pub uuid: String,
pub basename: String,
pub container_path: String,
pub host_dir: PathBuf,
pub host_path: PathBuf,
}
#[derive(Debug)]
pub struct PublicationCache {
entries: HashMap<PublicationKey, PublishedFile>,
reverse: HashMap<String, PathBuf>,
}
impl Default for PublicationCache {
fn default() -> Self {
Self::new()
}
}
impl PublicationCache {
#[must_use]
pub fn new() -> Self {
Self {
entries: HashMap::new(),
reverse: HashMap::new(),
}
}
#[must_use]
pub fn get(&self, canonical_path: &Path, mtime_nanos: u64) -> Option<&PublishedFile> {
self.entries
.get(&(canonical_path.to_path_buf(), mtime_nanos))
}
pub fn insert(&mut self, key: PublicationKey, file: PublishedFile) {
self.reverse
.insert(file.container_path.clone(), file.host_path.clone());
self.entries.insert(key, file);
}
#[must_use]
pub fn reverse_snapshot(&self) -> Vec<(String, String)> {
self.reverse
.iter()
.map(|(container, host)| (container.clone(), host.to_string_lossy().into_owned()))
.collect()
}
}
pub async fn publish_file(
canonical_path: &Path,
session_dir: &Path,
cache: &Mutex<PublicationCache>,
) -> Result<String, NpxcError> {
let metadata = std::fs::metadata(canonical_path)?;
let modified = metadata.modified()?;
let mtime_nanos = modified
.duration_since(UNIX_EPOCH)
.map_or(0, |d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX));
let key: PublicationKey = (canonical_path.to_path_buf(), mtime_nanos);
{
let guard = cache.lock();
if let Some(published) = guard.get(canonical_path, mtime_nanos) {
return Ok(published.container_path.clone());
}
}
let uuid = uuid::Uuid::new_v4().to_string();
let basename = canonical_path
.file_name()
.map_or_else(|| "file".to_owned(), |n| n.to_string_lossy().into_owned());
let host_dir = session_dir.join(&uuid);
let dest = host_dir.join(&basename);
let container_path = format!("/workspace/{uuid}/{basename}");
tokio::fs::create_dir_all(&host_dir).await?;
if let Err(link_err) = tokio::fs::hard_link(canonical_path, &dest).await {
if link_err.raw_os_error() == Some(EXDEV) {
let copy_src = canonical_path.to_path_buf();
let copy_dst = dest.clone();
tokio::task::spawn_blocking(move || std::fs::copy(©_src, ©_dst))
.await
.map_err(|e| NpxcError::Runtime(e.to_string()))??;
} else {
return Err(link_err.into());
}
}
let published = PublishedFile {
uuid,
basename,
container_path: container_path.clone(),
host_dir: host_dir.clone(),
host_path: canonical_path.to_path_buf(),
};
let racing_winner: Option<String> = {
let mut guard = cache.lock();
if let Some(existing) = guard.get(canonical_path, mtime_nanos) {
Some(existing.container_path.clone())
} else {
guard.insert(key, published);
None
}
};
if let Some(winner_path) = racing_winner {
let _ = tokio::fs::remove_dir_all(&host_dir).await;
return Ok(winner_path);
}
Ok(container_path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use tempfile::TempDir;
fn tmp() -> TempDir {
tempfile::tempdir().unwrap()
}
#[test]
fn cache_get_miss() {
let cache = PublicationCache::new();
assert!(cache.get(Path::new("/no/such"), 0).is_none());
}
#[test]
fn cache_insert_and_get() {
let mut cache = PublicationCache::new();
let path = PathBuf::from("/host/file.txt");
let key = (path.clone(), 12345_u64);
let file = PublishedFile {
uuid: "u1".into(),
basename: "file.txt".into(),
container_path: "/workspace/u1/file.txt".into(),
host_dir: PathBuf::from("/session/u1"),
host_path: path.clone(),
};
cache.insert(key, file);
let hit = cache.get(&path, 12345).unwrap();
assert_eq!(hit.container_path, "/workspace/u1/file.txt");
}
#[test]
fn cache_miss_on_stale_mtime() {
let mut cache = PublicationCache::new();
let path = PathBuf::from("/host/file.txt");
let key = (path.clone(), 100_u64);
let file = PublishedFile {
uuid: "u1".into(),
basename: "file.txt".into(),
container_path: "/workspace/u1/file.txt".into(),
host_dir: PathBuf::from("/session/u1"),
host_path: path.clone(),
};
cache.insert(key, file);
assert!(cache.get(&path, 200).is_none());
}
#[test]
fn reverse_snapshot_contents() {
let mut cache = PublicationCache::new();
let path = PathBuf::from("/host/report.pdf");
let key = (path.clone(), 42_u64);
let file = PublishedFile {
uuid: "abc".into(),
basename: "report.pdf".into(),
container_path: "/workspace/abc/report.pdf".into(),
host_dir: PathBuf::from("/session/abc"),
host_path: path.clone(),
};
cache.insert(key, file);
let snap = cache.reverse_snapshot();
assert_eq!(snap.len(), 1);
let (cont, host) = &snap[0];
assert_eq!(cont, "/workspace/abc/report.pdf");
assert_eq!(host, "/host/report.pdf");
}
#[tokio::test]
async fn publish_file_hardlinks_and_deduplicates() {
let src_dir = tmp();
let session_dir = tmp();
let src_file = src_dir.path().join("data.bin");
std::fs::write(&src_file, b"hello").unwrap();
let canonical = std::fs::canonicalize(&src_file).unwrap();
let cache: Arc<Mutex<PublicationCache>> = Arc::new(Mutex::new(PublicationCache::new()));
let path1 = publish_file(&canonical, session_dir.path(), &cache)
.await
.unwrap();
assert!(path1.starts_with("/workspace/"));
let path2 = publish_file(&canonical, session_dir.path(), &cache)
.await
.unwrap();
assert_eq!(path1, path2);
}
}