use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::{AgdError, ErrorCode};
use super::engine::{JournalImage, WriteEngine};
use crate::document::sha256_hex;
impl WriteEngine {
pub(super) fn lock_paths(&self, paths: &[PathBuf]) -> Result<Vec<File>, AgdError> {
let mut sorted = paths.to_vec();
sorted.sort();
sorted.dedup();
let mut locks = Vec::with_capacity(sorted.len());
for path in &sorted {
locks.push(self.lock_target(path)?);
refuse_emacs_lock(path)?;
}
self.recover_files(&sorted)?;
Ok(locks)
}
pub(super) fn lock_target(&self, path: &Path) -> Result<File, AgdError> {
let directory = self.lock_directory();
create_private_directory(&directory)?;
let lock_path = directory.join(format!(
"{}.lock",
sha256_hex(path.as_os_str().as_encoded_bytes())
));
let lock = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&lock_path)
.map_err(|error| {
AgdError::with_source(
ErrorCode::LockFailed,
format!("cannot open write lock {}: {error}", lock_path.display()),
error,
)
})?;
lock.lock().map_err(|error| {
AgdError::with_source(
ErrorCode::LockFailed,
format!("cannot lock {}: {error}", lock_path.display()),
error,
)
})?;
Ok(lock)
}
fn lock_directory(&self) -> PathBuf {
self.journal_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join("locks")
}
}
pub(super) fn replace_all_or_restore(images: &[JournalImage<'_>]) -> Result<(), AgdError> {
for (position, image) in images.iter().enumerate() {
if let Err(error) = atomic_replace(image.path, image.post_image.as_bytes()) {
for written in images[..position].iter().rev() {
let _ = atomic_replace(written.path, written.pre_image.as_bytes());
}
return Err(error);
}
}
Ok(())
}
pub(super) fn read_org_file(path: &Path) -> Result<String, AgdError> {
match fs::read(path) {
Ok(bytes) => String::from_utf8(bytes).map_err(|error| {
AgdError::with_source(
ErrorCode::InvalidUtf8,
format!("org file is not UTF-8: {}", path.display()),
error,
)
}),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(error) => Err(AgdError::from(error)),
}
}
pub(super) fn refuse_emacs_lock(path: &Path) -> Result<(), AgdError> {
let parent = path
.parent()
.ok_or_else(|| AgdError::new(ErrorCode::InvalidPath, "target has no parent"))?;
let name = path
.file_name()
.ok_or_else(|| AgdError::new(ErrorCode::InvalidPath, "target has no file name"))?;
let lock = parent.join(format!(".#{}", name.to_string_lossy()));
if fs::symlink_metadata(&lock).is_ok() {
let owner = fs::read_link(&lock)
.map(|target| target.to_string_lossy().into_owned())
.or_else(|_| fs::read_to_string(&lock))
.unwrap_or_else(|_| "ownership unavailable".to_string());
return Err(AgdError::new(
ErrorCode::EditorConflict,
format!(
"Emacs lock {} exists ({owner}); save or close the buffer",
lock.display()
),
));
}
Ok(())
}
pub(super) fn atomic_replace(path: &Path, bytes: &[u8]) -> Result<(), AgdError> {
let parent = path
.parent()
.ok_or_else(|| AgdError::new(ErrorCode::InvalidPath, "target has no parent"))?;
let permissions = fs::metadata(path)
.ok()
.map(|metadata| metadata.permissions());
let temporary = parent.join(format!(".agd-{}.tmp", uuid::Uuid::new_v4()));
let written = write_synced_new_file(&temporary, bytes, permissions)
.and_then(|()| fs::rename(&temporary, path).map_err(AgdError::from));
if let Err(error) = written {
let _ = fs::remove_file(&temporary);
return Err(error);
}
sync_parent_directory(parent)
}
fn write_synced_new_file(
path: &Path,
bytes: &[u8],
permissions: Option<fs::Permissions>,
) -> Result<(), AgdError> {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.map_err(AgdError::from)?;
file.write_all(bytes).map_err(AgdError::from)?;
if let Some(permissions) = permissions {
file.set_permissions(permissions).map_err(AgdError::from)?;
}
file.sync_all().map_err(AgdError::from)
}
#[cfg(not(target_os = "windows"))]
fn sync_parent_directory(parent: &Path) -> Result<(), AgdError> {
File::open(parent)
.and_then(|directory| directory.sync_all())
.map_err(AgdError::from)
}
#[cfg(target_os = "windows")]
fn sync_parent_directory(_: &Path) -> Result<(), AgdError> {
Ok(())
}
pub(super) fn create_private_directory(directory: &Path) -> Result<(), AgdError> {
fs::create_dir_all(directory).map_err(AgdError::from)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(directory, fs::Permissions::from_mode(0o700))
.map_err(AgdError::from)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
use crate::CaptureDraft;
use crate::write::test_support::{context, first_ref, settings, state_change, tree};
#[test]
fn write_locks_live_in_the_data_directory_not_beside_org_files() {
let org = tempfile::tempdir().unwrap();
let data = tempfile::tempdir().unwrap();
let mut settings = settings(org.path());
settings.captures[0].file = Some("inbox.org".to_string());
let engine = WriteEngine::new(settings, data.path().join("journal.jsonl")).unwrap();
let draft = CaptureDraft {
title: "Written".to_string(),
..CaptureDraft::default()
};
engine
.capture("t", &draft, "", &BTreeMap::new(), false, &context())
.unwrap();
assert_eq!(tree(org.path()), ["inbox.org"]);
let data_paths = tree(data.path());
assert_eq!(data_paths.len(), 3, "{data_paths:?}");
assert_eq!(data_paths[0], "journal.jsonl");
assert_eq!(data_paths[1], "locks");
assert!(data_paths[2].starts_with("locks/") && data_paths[2].ends_with(".lock"));
}
#[cfg(unix)]
#[test]
fn new_files_get_the_default_mode_and_rewrites_keep_theirs() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().unwrap();
let probe = temp.path().join("probe.txt");
fs::File::create(&probe).unwrap();
let default_mode = fs::metadata(&probe).unwrap().permissions().mode() & 0o777;
let mut settings = settings(temp.path());
settings.captures[0].file = Some("inbox.org".to_string());
let engine = WriteEngine::new(settings, temp.path().join("journal.jsonl")).unwrap();
let draft = CaptureDraft {
title: "First".to_string(),
..CaptureDraft::default()
};
engine
.capture("t", &draft, "", &BTreeMap::new(), false, &context())
.unwrap();
let inbox = temp.path().join("inbox.org");
let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&inbox), default_mode);
fs::set_permissions(&inbox, fs::Permissions::from_mode(0o640)).unwrap();
engine
.capture("t", &draft, "", &BTreeMap::new(), false, &context())
.unwrap();
assert_eq!(mode(&inbox), 0o640);
let leftovers: Vec<String> = tree(temp.path())
.into_iter()
.filter(|path| path.starts_with(".agd-"))
.collect();
assert!(leftovers.is_empty(), "{leftovers:?}");
}
#[test]
fn refuses_editor_locks() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("tasks.org");
fs::write(&path, "* TODO Task\n").unwrap();
let settings = settings(temp.path());
let item_ref = first_ref(&settings);
let engine = WriteEngine::new(settings.clone(), temp.path().join("journal.jsonl")).unwrap();
fs::write(temp.path().join(".#tasks.org"), "user@host.123").unwrap();
let error = engine
.edit(&item_ref, &state_change("DONE"), false, &context())
.unwrap_err();
assert_eq!(error.code().as_str(), "editor_conflict");
}
}