use std::path::{Path, PathBuf};
const SOURCE: [&str; 3] = ["storage", "app", "public"];
const LINK: [&str; 2] = ["public", "storage"];
fn under(root: &Path, parts: &[&str]) -> PathBuf {
parts
.iter()
.fold(root.to_path_buf(), |path, part| path.join(part))
}
fn shown(parts: &[&str]) -> String {
parts.join("/")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinkKind {
Symlink,
Junction,
}
impl LinkKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Symlink => "symlink",
Self::Junction => "directory junction",
}
}
}
pub fn run() -> Result<(), StorageLinkError> {
let root = std::env::current_dir().map_err(|source| StorageLinkError::Io {
path: PathBuf::from("."),
source,
})?;
let kind = link(&root)?;
println!("{} -> {} ({})", shown(&LINK), shown(&SOURCE), kind.as_str());
Ok(())
}
pub fn link(root: &Path) -> Result<LinkKind, StorageLinkError> {
let source = under(root, &SOURCE);
let destination = under(root, &LINK);
std::fs::create_dir_all(&source).map_err(|source_error| StorageLinkError::Io {
path: source.clone(),
source: source_error,
})?;
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent).map_err(|source_error| StorageLinkError::Io {
path: parent.to_path_buf(),
source: source_error,
})?;
}
if std::fs::symlink_metadata(&destination).is_ok() {
return Err(StorageLinkError::Occupied { path: destination });
}
create_link(&source, &destination)
}
#[cfg(windows)]
fn create_link(source: &Path, destination: &Path) -> Result<LinkKind, StorageLinkError> {
let symlink_error = match std::os::windows::fs::symlink_dir(source, destination) {
Ok(()) => return Ok(LinkKind::Symlink),
Err(error) => error,
};
match junction(source, destination) {
Ok(()) => Ok(LinkKind::Junction),
Err(junction_error) => Err(StorageLinkError::NotPermitted {
path: destination.to_path_buf(),
symlink_error,
junction_error,
}),
}
}
#[cfg(windows)]
fn junction(source: &Path, destination: &Path) -> Result<(), String> {
let output = std::process::Command::new("cmd")
.arg("/C")
.arg("mklink")
.arg("/J")
.arg(destination)
.arg(source)
.output()
.map_err(|error| format!("could not run `cmd /C mklink /J`: {error}"))?;
if output.status.success() {
return Ok(());
}
let message = String::from_utf8_lossy(&output.stderr);
let message = message.trim();
if message.is_empty() {
Err(format!("`mklink /J` failed with {}", output.status))
} else {
Err(message.to_string())
}
}
#[cfg(not(windows))]
fn create_link(source: &Path, destination: &Path) -> Result<LinkKind, StorageLinkError> {
match std::os::unix::fs::symlink(source, destination) {
Ok(()) => Ok(LinkKind::Symlink),
Err(symlink_error) => Err(StorageLinkError::NotPermitted {
path: destination.to_path_buf(),
symlink_error,
junction_error: String::from("this platform has no directory-junction equivalent"),
}),
}
}
#[derive(Debug)]
pub enum StorageLinkError {
Occupied { path: PathBuf },
NotPermitted {
path: PathBuf,
symlink_error: std::io::Error,
junction_error: String,
},
Io {
path: PathBuf,
source: std::io::Error,
},
}
impl std::fmt::Display for StorageLinkError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Occupied { path } => write!(
formatter,
"{} already exists; remove it and run `arc storage:link` again \
(if it is already the link you want, there is nothing to do)",
path.display()
),
Self::NotPermitted {
path,
symlink_error,
junction_error,
} => write!(
formatter,
"could not link {}: creating a symlink failed ({symlink_error}) \
and the fallback failed ({junction_error}). {}",
path.display(),
remedy()
),
Self::Io { path, source } => {
write!(formatter, "could not prepare {}: {source}", path.display())
}
}
}
}
impl std::error::Error for StorageLinkError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. }
| Self::NotPermitted {
symlink_error: source,
..
} => Some(source),
Self::Occupied { .. } => None,
}
}
}
#[cfg(windows)]
fn remedy() -> &'static str {
"Turn on Developer Mode (Settings > System > For developers > Developer Mode) \
or run this command from an elevated terminal. Arcature will not copy the \
files instead: a copy goes stale the moment something is uploaded."
}
#[cfg(not(windows))]
fn remedy() -> &'static str {
"Check that you own the `public/` directory and that the filesystem supports \
symbolic links. Arcature will not copy the files instead: a copy goes stale \
the moment something is uploaded."
}
#[cfg(test)]
mod tests {
use super::*;
fn linking_is_permitted(root: &Path) -> bool {
let probe = root.join("probe");
std::fs::create_dir_all(probe.join("target")).expect("probe target");
create_link(&probe.join("target"), &probe.join("link")).is_ok()
}
#[test]
fn linking_creates_the_source_directory_it_points_at() {
let dir = tempfile::tempdir().expect("tempdir");
if !linking_is_permitted(dir.path()) {
return;
}
let kind = link(dir.path()).expect("linked");
assert!(matches!(kind, LinkKind::Symlink | LinkKind::Junction));
assert!(under(dir.path(), &SOURCE).is_dir());
assert!(std::fs::symlink_metadata(under(dir.path(), &LINK)).is_ok());
}
#[test]
fn the_link_reaches_the_files_behind_it() {
let dir = tempfile::tempdir().expect("tempdir");
if !linking_is_permitted(dir.path()) {
return;
}
link(dir.path()).expect("linked");
std::fs::write(under(dir.path(), &SOURCE).join("avatar.txt"), "bytes").expect("write");
let through_link =
std::fs::read_to_string(under(dir.path(), &LINK).join("avatar.txt")).expect("read");
assert_eq!(through_link, "bytes");
}
#[test]
fn an_existing_public_storage_is_refused_rather_than_replaced() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(under(dir.path(), &LINK)).expect("existing");
std::fs::write(under(dir.path(), &LINK).join("keep.txt"), "mine").expect("write");
let error = link(dir.path()).expect_err("occupied");
assert!(matches!(error, StorageLinkError::Occupied { .. }));
assert!(error.to_string().contains("already exists"));
assert!(under(dir.path(), &LINK).join("keep.txt").exists());
}
#[test]
fn a_failure_to_link_never_leaves_a_copy_behind() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(under(dir.path(), &SOURCE)).expect("source");
std::fs::write(under(dir.path(), &SOURCE).join("avatar.txt"), "bytes").expect("write");
std::fs::create_dir_all(under(dir.path(), &LINK)).expect("occupied");
assert!(link(dir.path()).is_err());
assert!(!under(dir.path(), &LINK).join("avatar.txt").exists());
}
#[test]
fn the_remedy_tells_the_developer_what_to_change() {
assert!(remedy().contains("not copy"));
}
}