//! The files under `memory/` and the memory block (M5a spec, sections 3 and 4). `memoryd` creates //! what is missing and never overwrites anything; files are 0600 in a 0700 directory. use std::fs::{DirBuilder, OpenOptions, Permissions}; use std::io::Write; use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}; use std::path::{Path, PathBuf}; use proto::MemoryFile; use crate::check::{Refused, check}; use crate::entries; /// The most characters `soul.md` may have. pub const SOUL_LIMIT: usize = 4_000; /// `n` with commas between thousands: `2,200`. pub fn grouped(n: usize) -> String { // `let digits = n.to_string()`. Walk its chars with their index i; before each char where i > 0 // and `(digits.len() - i).is_multiple_of(3)`, push a comma. So 2200 is "2,200" and 999 is // "999". let digits = n.to_string(); let mut out = String::with_capacity(digits.len() + digits.len() / 3); for (i, c) in digits.chars().enumerate() { if i > 0 && (digits.len() - i).is_multiple_of(3) { out.push(','); } out.push(c); } out } /// The memory directory. #[derive(Debug, Clone)] pub struct Store { dir: PathBuf, } /// The block for a new session, and the identity entries left out of it because they failed the /// check (for `memoryd` to log). #[derive(Debug, Clone, PartialEq, Eq)] pub struct Block { pub text: String, pub left_out: Vec<(MemoryFile, Refused)>, } fn fail(path: &Path, e: std::io::Error) -> String { format!("{}: {e}", path.display()) } impl Store { pub fn new(dir: &Path) -> Store { Store { dir: dir.to_path_buf(), } } pub fn path(&self, name: &str) -> PathBuf { self.dir.join(name) } /// Create `memory/` (0700) and each missing file (0600): `soul.md` from `starter`, the others /// empty. Nothing that exists is changed. The error names the path. pub fn init(&self, starter: &Path) -> Result<(), String> { // 1. `DirBuilder::new().recursive(true).mode(0o700).create(&self.dir)`, then // `set_permissions(&self.dir, Permissions::from_mode(0o700))`. // 2. When `self.path("soul.md")` does not exist: `read_to_string(starter)`, then // `self.create(&soul, &text)`. // 3. Each of "memory.md", "user.md", "notes.md" that does not exist: `self.create(&path, // "")`. Every error through `fail(path, e)`. Nothing that exists is changed. Ok(()). DirBuilder::new() .recursive(true) .mode(0o700) .create(&self.dir) .map_err(|e| fail(&self.dir, e))?; std::fs::set_permissions(&self.dir, Permissions::from_mode(0o700)) .map_err(|e| fail(&self.dir, e))?; if !self.path("soul.md").exists() { let text = std::fs::read_to_string(starter).map_err(|e| fail(starter, e))?; let soul = self.path("soul.md"); self.create(&soul, &text)?; } for name in ["memory.md", "user.md", "notes.md"] { let path = self.path(name); if !path.exists() { self.create(&path, "")?; } } Ok(()) } /// Create `path` at 0600 with `text`; an existing file is an error, never overwritten. fn create(&self, path: &Path, text: &str) -> Result<(), String> { // `OpenOptions::new().write(true).create_new(true).mode(0o600).open(path)`, then // `write_all(text.as_bytes())`. Errors through `fail(path, e)`. create_new makes an // existing file an error: it is never overwritten. let mut file = OpenOptions::new() .write(true) .create_new(true) .mode(0o600) .open(path) .map_err(|e| fail(path, e))?; file.write_all(text.as_bytes()).map_err(|e| fail(path, e))?; Ok(()) } /// The entries of an identity file. pub fn entries(&self, file: MemoryFile) -> Result, String> { // `read_to_string(self.path(file.name()))` (an error through `fail`), then // `entries::parse(&text)`. let path = self.path(file.name()); let text = std::fs::read_to_string(&path).map_err(|e| fail(&path, e))?; Ok(entries::parse(&text)) } /// Replace an identity file's entries: written to a temporary file at 0600, then renamed. pub fn write_entries(&self, file: MemoryFile, list: &[String]) -> Result<(), String> { // The file is `self.path(file.name())`; the temporary file is // `self.path(&format!(".{}.tmp", file.name()))`. Remove the temporary file if it is there // (ignore the result), `self.create(&temp, &entries::render(list))?`, then // `std::fs::rename(&temp, &path)` (an error through `fail(&path, e)`). let path = self.path(file.name()); let temp = self.path(&format!(".{}.tmp", file.name())); let _ = std::fs::remove_file(&temp); self.create(&temp, &entries::render(list))?; std::fs::rename(&temp, &path).map_err(|e| fail(&path, e))?; Ok(()) } /// Append `text` to a file (0600, created if missing), as one write. pub fn append(&self, name: &str, text: &str) -> Result<(), String> { // `OpenOptions::new().append(true).create(true).mode(0o600).open(&path)` for // `self.path(name)`, then one `write_all(text.as_bytes())`. Errors through `fail`. let path = self.path(name); let mut file = OpenOptions::new() .append(true) .create(true) .mode(0o600) .open(&path) .map_err(|e| fail(&path, e))?; file.write_all(text.as_bytes()) .map_err(|e| fail(&path, e))?; Ok(()) } /// The block for a new session (M5a spec, section 4). A file over its limit, or unreadable, /// is an error: no session starts without its memory and no sign of it. Identity entries that /// fail the check are left out and reported; `soul.md` is the owner's and is not checked. pub fn block(&self) -> Result { // 1. Read `self.path("soul.md")` (an error through `fail`) and trim() it. When its // chars().count() > SOUL_LIMIT: Err(format!("{}: {} characters; its limit is {}", // path.display(), grouped(chars), grouped(SOUL_LIMIT))). // 2. `text` starts as format!("# soul.md\n{}", soul), with "(empty)" in place of an empty // soul. // 3. For file in [MemoryFile::Memory, MemoryFile::User]: `self.entries(file)?`; `size = // entries::size(&list)`; when size > file.limit(), the same Err sentence with the path, // size and limit of that file. Each entry: `check(&entry)` Ok -> kept; Err(refused) -> // push (file, refused) onto `left_out`. The body is "(empty)" when nothing is kept, else // `entries::render(&kept).trim_end()`. Append format!("\n\n# {} ({} of {} // characters)\n{body}", file.name(), grouped(size), grouped(file.limit())) to `text`. // `size` counts the whole file, entries left out included. // 4. Ok(Block { text, left_out }). let soul_path = self.path("soul.md"); let soul = std::fs::read_to_string(&soul_path).map_err(|e| fail(&soul_path, e))?; let soul = soul.trim(); let chars = soul.chars().count(); if chars > SOUL_LIMIT { return Err(format!( "{}: {} characters; its limit is {}", soul_path.display(), grouped(chars), grouped(SOUL_LIMIT) )); } let mut text = if soul.is_empty() { "# soul.md\n(empty)".to_string() } else { format!("# soul.md\n{soul}") }; let mut left_out = Vec::new(); for file in [MemoryFile::Memory, MemoryFile::User] { let list = self.entries(file)?; let size = entries::size(&list); if size > file.limit() { let path = self.path(file.name()); return Err(format!( "{}: {} characters; its limit is {}", path.display(), grouped(size), grouped(file.limit()) )); } let mut kept = Vec::new(); for entry in &list { match check(entry) { Ok(()) => kept.push(entry.clone()), Err(refused) => left_out.push((file, refused)), } } let body = if kept.is_empty() { "(empty)".to_string() } else { entries::render(&kept).trim_end().to_string() }; text.push_str(&format!( "\n\n# {} ({} of {} characters)\n{body}", file.name(), grouped(size), grouped(file.limit()) )); } Ok(Block { text, left_out }) } }