use crate::{
format::CodeStr,
wiki::{ATTACHMENTS_DIRECTORY_SUFFIX, FilesystemTarget, WIKI_EXTENSION},
};
use colored::ColoredString;
use std::{
collections::{HashMap, HashSet},
ffi::{OsStr, OsString},
fs, io,
path::{Path, PathBuf},
sync::Arc,
};
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct SpelledPath(PathBuf);
impl SpelledPath {
pub fn as_path(&self) -> &Path {
&self.0
}
pub fn is_attachments_directory(&self) -> bool {
self.0.as_os_str().is_empty()
}
pub fn starts_with(&self, other: &SpelledPath) -> bool {
self.0.starts_with(&other.0)
}
pub fn parent(&self) -> Option<SpelledPath> {
self.0.parent().map(|parent| SpelledPath(parent.to_owned()))
}
pub fn file_name(&self) -> Option<&OsStr> {
self.0.file_name()
}
fn spell(
attachments_directory: &Path,
target: &FilesystemTarget,
listings: &mut DirectoryListings,
) -> Result<Self, SpellingError> {
let mut spelled = SpelledPath(PathBuf::new());
for component in target.path().components() {
let name = component.as_os_str();
let directory = attachments_directory.join(&spelled.0);
let names = match listings.entry(directory.clone()).or_insert_with(|| {
fs::read_dir(&directory)
.map(|entries| {
entries
.flatten()
.map(|entry| fs::DirEntry::file_name(&entry))
.collect()
})
.map_err(Arc::new)
}) {
Ok(names) => names,
Err(error) => {
return Err(SpellingError {
message: format!(
"Unable to list {}, so the spelling of {} can't be checked.",
if spelled.is_attachments_directory() {
"the attachments directory".to_owned()
} else {
spelled.code_str().to_string()
},
target.code_str(),
),
reason: Some(error.clone()),
});
}
};
spelled.0.push(name);
if !names.contains(name) {
return Err(SpellingError {
message: format!(
"{} doesn't match the spelling of any name on disk.",
spelled.code_str(),
),
reason: None,
});
}
}
Ok(spelled)
}
}
impl CodeStr for SpelledPath {
fn code_str(&self) -> ColoredString {
code_attachment_path(&self.0)
}
}
#[derive(Debug)]
pub struct SpellingError {
pub message: String,
pub reason: Option<Arc<dyn std::error::Error + Send + Sync>>,
}
#[derive(Clone, Debug)]
pub struct AttachmentsDirectory {
path: PathBuf,
}
impl AttachmentsDirectory {
pub fn new(wiki_path: &Path) -> Result<Self, String> {
let Some(name) = wiki_path.file_name() else {
return Err("The wiki's path must end with a file name.".to_owned());
};
let mut directory_name = if Path::new(name)
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case(WIKI_EXTENSION))
{
Path::new(name)
.file_stem()
.expect("A file name with an extension should have a stem.")
.to_owned()
} else {
name.to_owned()
};
directory_name.push(ATTACHMENTS_DIRECTORY_SUFFIX);
Ok(Self {
path: wiki_path.with_file_name(directory_name),
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn resolve(&self, path: &SpelledPath) -> PathBuf {
self.path.join(&path.0)
}
pub fn entry_path(&self, entry: &ignore::DirEntry) -> SpelledPath {
SpelledPath(
entry
.path()
.strip_prefix(&self.path)
.expect("A walk of the attachments directory should only find entries within it.")
.to_owned(),
)
}
pub fn spell(
&self,
target: &FilesystemTarget,
listings: &mut DirectoryListings,
) -> Result<SpelledPath, SpellingError> {
SpelledPath::spell(&self.path, target, listings)
}
pub fn spell_existing_ancestor(
&self,
target: &FilesystemTarget,
) -> Result<(SpelledPath, PathBuf), SpellingError> {
let mut ancestor = FilesystemTarget::attachments_directory();
for candidate in target.ancestors() {
match self.path.join(candidate.path()).try_exists() {
Ok(true) => {
ancestor = candidate;
break;
}
Ok(false) => {}
Err(error) => {
return Err(SpellingError {
message: format!(
"Unable to access {}.",
if candidate.is_attachments_directory() {
"the attachments directory".to_owned()
} else {
candidate.code_str().to_string()
},
),
reason: Some(Arc::new(error)),
});
}
}
}
Ok((
SpelledPath::spell(&self.path, &ancestor, &mut DirectoryListings::new())?,
target
.path()
.strip_prefix(ancestor.path())
.expect("An ancestor of a path should be a prefix of it.")
.to_owned(),
))
}
}
pub fn code_attachment_path(path: &Path) -> ColoredString {
let components = path
.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>();
format!("/{}", components.join("/")).code_str()
}
pub type DirectoryListings = HashMap<PathBuf, Result<HashSet<OsString>, Arc<io::Error>>>;
#[cfg(unix)]
pub fn entry_identity(path: &Path) -> Option<(u64, u64)> {
use std::os::unix::fs::MetadataExt;
fs::symlink_metadata(path)
.ok()
.map(|metadata| (metadata.dev(), metadata.ino()))
}
#[cfg(not(unix))]
pub fn entry_identity(path: &Path) -> Option<PathBuf> {
fs::canonicalize(path).ok()
}
#[cfg(test)]
mod tests {
use super::{AttachmentsDirectory, DirectoryListings, code_attachment_path};
use crate::wiki::{ContentText, FilesystemTarget};
use std::{env, fs, path::Path, process};
#[test]
fn code_attachment_path_display() {
assert_eq!(format!("{}", code_attachment_path(Path::new(""))), "`/`");
assert_eq!(
format!(
"{}",
code_attachment_path(&Path::new("photos").join("paris")),
),
"`/photos/paris`",
);
}
#[cfg(unix)]
#[test]
fn inaccessible_ancestor() {
use std::os::unix::fs::PermissionsExt;
let directory = env::temp_dir().join(format!("mull-access-{}", process::id()));
let locked = directory.join("wiki_attachments/locked");
fs::create_dir_all(&locked).unwrap();
fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
let attachments_directory =
AttachmentsDirectory::new(&directory.join("wiki.mull")).unwrap();
let result = attachments_directory.spell_existing_ancestor(
&FilesystemTarget::parse(&ContentText::from_source("/locked/inner/file.txt")).unwrap(),
);
let accessible = locked.join("inner").try_exists().is_ok();
fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).unwrap();
fs::remove_dir_all(&directory).unwrap();
if !accessible {
assert_eq!(
result.unwrap_err().message,
"Unable to access `/locked/inner`.",
);
}
}
#[test]
fn missing_component() {
let directory = env::temp_dir().join(format!("mull-spelling-{}", process::id()));
fs::create_dir_all(directory.join("wiki_attachments")).unwrap();
let attachments_directory =
AttachmentsDirectory::new(&directory.join("wiki.mull")).unwrap();
let error = attachments_directory
.spell(
&FilesystemTarget::parse(&ContentText::from_source("/missing/file.txt")).unwrap(),
&mut DirectoryListings::new(),
)
.unwrap_err();
fs::remove_dir_all(&directory).unwrap();
assert_eq!(
error.message,
"`/missing` doesn't match the spelling of any name on disk.",
);
}
}