use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const RECORD: &str = "session.toml";
const PAYLOAD_DIR: &str = "payload";
const ATTEMPTS: u32 = 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Record {
pub container: PathBuf,
pub payload: String,
pub started: u64,
pub write_backs: u64,
pub agreed: Option<u32>,
}
#[derive(Debug, Clone)]
pub struct Session {
dir: PathBuf,
record: Record,
}
impl Session {
#[must_use]
pub fn dir(&self) -> &Path {
&self.dir
}
#[must_use]
pub fn record(&self) -> &Record {
&self.record
}
#[must_use]
pub fn payload_dir(&self) -> PathBuf {
self.dir.join(PAYLOAD_DIR)
}
#[must_use]
pub fn payload_path(&self) -> PathBuf {
self.payload_dir().join(&self.record.payload)
}
pub fn note_write_back(&mut self) -> io::Result<()> {
self.record.write_backs += 1;
write_record(&self.dir, &self.record)
}
pub fn note_agreement(&mut self, crc: u32) -> io::Result<()> {
self.record.agreed = Some(crc);
write_record(&self.dir, &self.record)
}
pub fn remove(self) -> io::Result<()> {
fs::remove_dir_all(&self.dir)
}
}
pub fn default_root() -> io::Result<PathBuf> {
let base = platform_base().ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"no per-user state directory: set XDG_STATE_HOME, HOME, or LOCALAPPDATA",
)
})?;
Ok(base.join("slipcase-open").join("sessions"))
}
#[cfg(target_os = "linux")]
fn platform_base() -> Option<PathBuf> {
if let Some(x) = std::env::var_os("XDG_STATE_HOME").filter(|v| !v.is_empty()) {
return Some(PathBuf::from(x));
}
Some(PathBuf::from(std::env::var_os("HOME")?).join(".local/state"))
}
#[cfg(target_os = "macos")]
fn platform_base() -> Option<PathBuf> {
Some(PathBuf::from(std::env::var_os("HOME")?).join("Library/Application Support"))
}
#[cfg(target_os = "windows")]
fn platform_base() -> Option<PathBuf> {
package_store().or_else(|| std::env::var_os("LOCALAPPDATA").map(PathBuf::from))
}
#[cfg(target_os = "windows")]
fn package_store() -> Option<PathBuf> {
if !packaged() {
return None;
}
std::thread::spawn(|| {
use windows::Storage::ApplicationData;
apartment();
let path = ApplicationData::Current()
.ok()?
.LocalCacheFolder()
.ok()?
.Path()
.ok()?;
Some(PathBuf::from(path.to_os_string()))
})
.join()
.ok()
.flatten()
}
#[cfg(target_os = "windows")]
#[allow(unsafe_code)]
fn packaged() -> bool {
use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER;
use windows_sys::Win32::Storage::Packaging::Appx::GetCurrentPackageFamilyName;
let mut len: u32 = 0;
let how = unsafe { GetCurrentPackageFamilyName(&raw mut len, std::ptr::null_mut()) };
how == ERROR_INSUFFICIENT_BUFFER
}
#[cfg(target_os = "windows")]
#[allow(unsafe_code)]
fn apartment() {
use windows::Win32::System::Com::{CoInitializeEx, COINIT_MULTITHREADED};
let _ = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) };
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn platform_base() -> Option<PathBuf> {
None
}
pub fn create(root: &Path, container: &Path, payload: &str) -> io::Result<Session> {
let container = fs::canonicalize(container)?;
let started = seconds_since_epoch();
create_private_dir_all(root)?;
let mut made = None;
for n in 0..ATTEMPTS {
let candidate = root.join(format!("{started:x}-{n}"));
match fs::create_dir(&candidate) {
Ok(()) => {
made = Some(candidate);
break;
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(e),
}
}
let Some(dir) = made else {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{ATTEMPTS} session directories already exist for this second"),
));
};
private(&dir)?;
let session = Session {
record: Record {
container,
payload: payload.to_string(),
started,
write_backs: 0,
agreed: None,
},
dir,
};
create_private_dir_all(&session.payload_dir())?;
write_record(&session.dir, &session.record)?;
Ok(session)
}
pub fn scan(root: &Path) -> io::Result<Vec<Session>> {
let entries = match fs::read_dir(root) {
Ok(e) => e,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let mut found: Vec<Session> = entries
.flatten()
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.filter_map(|e| {
let dir = e.path();
read_record(&dir).ok().map(|record| Session { dir, record })
})
.collect();
found.sort_by(|a, b| a.dir.cmp(&b.dir));
Ok(found)
}
pub fn find(root: &Path, id: &str) -> io::Result<Session> {
if id.is_empty() || id.contains(['/', '\\']) || id == "." || id == ".." {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("no session {id}"),
));
}
let dir = root.join(id);
let record = read_record(&dir)?;
Ok(Session { dir, record })
}
fn seconds_since_epoch() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
fn create_private_dir_all(at: &Path) -> io::Result<()> {
fs::create_dir_all(at)?;
private(at)
}
#[cfg(unix)]
fn private(at: &Path) -> io::Result<()> {
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(at, fs::Permissions::from_mode(0o700))
}
#[allow(clippy::unnecessary_wraps)]
#[cfg(not(unix))]
fn private(_at: &Path) -> io::Result<()> {
Ok(())
}
fn write_record(dir: &Path, record: &Record) -> io::Result<()> {
let mut doc = toml_edit::DocumentMut::new();
let container = record.container.to_str().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!(
"container path is not Unicode: {}",
record.container.display()
),
)
})?;
doc["container"] = toml_edit::value(container);
doc["payload"] = toml_edit::value(record.payload.as_str());
doc["started"] = toml_edit::value(i64::try_from(record.started).unwrap_or(i64::MAX));
doc["write_backs"] = toml_edit::value(i64::try_from(record.write_backs).unwrap_or(i64::MAX));
if let Some(agreed) = record.agreed {
doc["agreed"] = toml_edit::value(i64::from(agreed));
}
fs::write(dir.join(RECORD), doc.to_string())
}
fn read_record(dir: &Path) -> io::Result<Record> {
let text = fs::read_to_string(dir.join(RECORD))?;
let doc: toml_edit::DocumentMut = text
.parse()
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("{RECORD}: {e}")))?;
let mut want = BTreeMap::new();
for key in ["container", "payload"] {
let v = doc.get(key).and_then(|v| v.as_str()).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("{RECORD}: no string `{key}`"),
)
})?;
want.insert(key, v.to_string());
}
let number = |key: &str| -> u64 {
doc.get(key)
.and_then(toml_edit::Item::as_integer)
.and_then(|n| u64::try_from(n).ok())
.unwrap_or_default()
};
Ok(Record {
container: PathBuf::from(&want["container"]),
payload: want["payload"].clone(),
started: number("started"),
write_backs: number("write_backs"),
agreed: doc
.get("agreed")
.and_then(toml_edit::Item::as_integer)
.and_then(|n| u32::try_from(n).ok()),
})
}
#[cfg(test)]
mod tests {
use super::{create, default_root, scan, PAYLOAD_DIR, RECORD};
use std::fs;
fn a_container(at: &std::path::Path) -> std::path::PathBuf {
let p = at.join("report.pdf.slpc");
fs::write(&p, b"not a real container").unwrap();
p
}
#[test]
fn a_session_holds_its_record_and_a_payload_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = a_container(tmp.path());
let s = create(&root, &c, "report.pdf").unwrap();
assert!(s.dir().join(RECORD).is_file());
assert!(s.payload_dir().is_dir());
assert_eq!(s.payload_dir().file_name().unwrap(), PAYLOAD_DIR);
assert_eq!(s.payload_path(), s.payload_dir().join("report.pdf"));
}
#[test]
fn the_payload_sits_below_the_record_rather_than_beside_it() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = a_container(tmp.path());
let s = create(&root, &c, RECORD).unwrap();
fs::write(s.payload_path(), b"payload").unwrap();
assert!(s.dir().join(RECORD).is_file());
assert!(fs::read_to_string(s.dir().join(RECORD))
.unwrap()
.contains("payload ="));
assert_eq!(fs::read(s.payload_path()).unwrap(), b"payload");
}
#[test]
fn the_container_path_is_resolved_before_it_is_written_down() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = a_container(tmp.path());
let previous = std::env::current_dir().unwrap();
std::env::set_current_dir(tmp.path()).unwrap();
let s = create(&root, std::path::Path::new("report.pdf.slpc"), "report.pdf");
std::env::set_current_dir(previous).unwrap();
let s = s.unwrap();
assert!(s.record().container.is_absolute());
assert_eq!(s.record().container, fs::canonicalize(&c).unwrap());
}
#[test]
fn two_sessions_on_the_same_container_get_directories_of_their_own() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = a_container(tmp.path());
let a = create(&root, &c, "report.pdf").unwrap();
let b = create(&root, &c, "report.pdf").unwrap();
assert_ne!(a.dir(), b.dir());
}
#[test]
fn a_session_survives_being_written_and_read_back() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = a_container(tmp.path());
let mut s = create(&root, &c, "a \"quoted\" \\ name.pdf").unwrap();
s.note_write_back().unwrap();
s.note_write_back().unwrap();
let found = scan(&root).unwrap();
assert_eq!(found.len(), 1);
assert_eq!(found[0].record(), s.record());
assert_eq!(found[0].record().write_backs, 2);
}
#[test]
fn a_write_back_count_is_on_disk_before_the_call_returns() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = a_container(tmp.path());
let mut s = create(&root, &c, "report.pdf").unwrap();
s.note_write_back().unwrap();
assert_eq!(scan(&root).unwrap()[0].record().write_backs, 1);
}
#[test]
fn scanning_a_root_that_is_not_there_finds_nothing_rather_than_failing() {
let tmp = tempfile::tempdir().unwrap();
assert!(scan(&tmp.path().join("never-used")).unwrap().is_empty());
}
#[test]
fn a_directory_with_no_readable_record_is_skipped_rather_than_fatal() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = a_container(tmp.path());
let good = create(&root, &c, "report.pdf").unwrap();
fs::create_dir(root.join("half-made")).unwrap();
fs::write(root.join("truncated"), b"not a directory").unwrap();
fs::create_dir(root.join("garbled")).unwrap();
fs::write(root.join("garbled").join(RECORD), b"= not toml =").unwrap();
let found = scan(&root).unwrap();
assert_eq!(found.len(), 1);
assert_eq!(found[0].dir(), good.dir());
}
#[test]
fn removing_a_session_takes_the_payload_with_it() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = a_container(tmp.path());
let s = create(&root, &c, "report.pdf").unwrap();
fs::write(s.payload_path(), b"edited").unwrap();
let dir = s.dir().to_path_buf();
s.remove().unwrap();
assert!(!dir.exists());
assert!(scan(&root).unwrap().is_empty());
}
#[cfg(unix)]
#[test]
fn the_tree_is_owner_only_whatever_the_umask_says() {
use std::os::unix::fs::PermissionsExt as _;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = a_container(tmp.path());
let s = create(&root, &c, "report.pdf").unwrap();
for d in [&root, &s.dir().to_path_buf(), &s.payload_dir()] {
let mode = fs::metadata(d).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700, "{}", d.display());
}
}
#[test]
fn the_default_root_is_under_the_platforms_state_directory() {
let root = default_root().unwrap();
assert!(root.ends_with("slipcase-open/sessions"));
assert!(!root.starts_with(std::env::temp_dir()));
}
#[cfg(windows)]
#[test]
fn with_no_package_around_it_the_root_is_the_one_the_environment_names() {
assert!(super::package_store().is_none());
let named = std::path::PathBuf::from(std::env::var_os("LOCALAPPDATA").unwrap());
assert_eq!(
default_root().unwrap(),
named.join("slipcase-open").join("sessions")
);
}
#[test]
fn removing_a_session_takes_the_payload_and_the_record_with_it() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = a_container(tmp.path());
let s = create(&root, &c, "report.pdf").unwrap();
fs::write(s.payload_path(), b"something").unwrap();
let dir = s.dir().to_path_buf();
s.remove().unwrap();
assert!(!dir.exists());
assert!(scan(&root).unwrap().is_empty());
}
}