use std::collections::{BTreeMap, HashMap};
use std::fs::{File, TryLockError};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result, bail};
use sandfs_core::{VPath, Workspace};
use serde::{Deserialize, Serialize};
use crate::CrossDevice;
use crate::host::{mount_source, mount_table};
use crate::persist::{Restored, Saved, is_object_gone};
pub const ROOT: &str = "/mnt";
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Base {
Dir { path: PathBuf },
Git {
repo: PathBuf,
commit: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
branch: Option<String>,
},
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Record {
pub source: String,
pub base: Base,
pub created: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub read_only: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub updated: BTreeMap<String, (u64, u32)>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub folder: Option<PathBuf>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub allow_copy: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MountInfo {
pub pid: u32,
pub mountpoint: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub save_port: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub save_secret: Option<String>,
#[serde(default)]
pub id: u64,
}
pub struct Opened {
pub ws: Workspace,
pub saved: Saved,
pub restored: Restored,
pub record: Record,
}
pub struct Writer {
_file: File,
}
#[derive(Clone, Debug)]
pub struct Home {
root: PathBuf,
}
const LOCK_RETRIES: u32 = 20;
const LOCK_RETRY_WAIT: Duration = Duration::from_millis(10);
impl Home {
pub fn locate() -> Result<Home> {
let root = if let Some(dir) = std::env::var_os("SANDFS_HOME") {
PathBuf::from(dir)
} else if let Some(dir) = std::env::var_os("XDG_DATA_HOME") {
PathBuf::from(dir).join("sandfs")
} else if let Some(home) = std::env::home_dir() {
home.join(".local/share/sandfs")
} else {
bail!("set SANDFS_HOME so sandfs knows where to keep workspaces");
};
Ok(Home { root: std::path::absolute(root)? })
}
pub fn at(root: impl Into<PathBuf>) -> Home {
Home { root: root.into() }
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn workspace_dir(&self, name: &str) -> PathBuf {
self.root.join("workspaces").join(name)
}
pub fn run_dir(&self) -> PathBuf {
self.root.join("run")
}
pub fn create(&self, name: &str, record: &Record) -> Result<PathBuf> {
check_name(name)?;
let dir = self.workspace_dir(name);
std::fs::create_dir_all(dir.parent().unwrap_or(&self.root))?;
match std::fs::create_dir(&dir) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
bail!("a workspace named {name:?} already exists; pick another with --name")
}
Err(err) => return Err(err).context("creating the workspace"),
}
if let Err(err) = std::fs::write(dir.join("workspace.json"), serde_json::to_vec_pretty(record)?) {
let _ = std::fs::remove_dir_all(&dir);
return Err(err).context("creating the workspace");
}
Ok(dir)
}
pub fn exists(&self, name: &str) -> bool {
check_name(name).is_ok() && self.workspace_dir(name).join("workspace.json").is_file()
}
pub fn set_record(&self, name: &str, record: &Record) -> Result<()> {
check_name(name)?;
let dir = self.workspace_dir(name);
let tmp = dir.join("workspace.json.tmp");
std::fs::write(&tmp, serde_json::to_vec_pretty(record)?).context("writing workspace.json")?;
std::fs::rename(&tmp, dir.join("workspace.json")).context("writing workspace.json")
}
pub fn record(&self, name: &str) -> Result<Record> {
check_name(name)?;
let path = self.workspace_dir(name).join("workspace.json");
let data = std::fs::read(&path).with_context(|| format!("no workspace named {name:?}"))?;
serde_json::from_slice(&data).with_context(|| format!("reading {}", path.display()))
}
pub fn names(&self) -> Result<Vec<String>> {
let mut names = Vec::new();
let Ok(entries) = std::fs::read_dir(self.root.join("workspaces")) else { return Ok(names) };
for entry in entries {
if let Some(name) = entry?.file_name().to_str()
&& self.exists(name)
{
names.push(name.to_owned());
}
}
names.sort();
Ok(names)
}
pub fn mount_info(&self, name: &str) -> Option<MountInfo> {
self.mount_info_in(name, &mount_table())
}
pub fn mount_info_in(&self, name: &str, table: &HashMap<PathBuf, String>) -> Option<MountInfo> {
self.attached_in(name, table).filter(|info| crate::host::alive(info.pid))
}
pub fn attached_mount(&self, name: &str) -> Option<MountInfo> {
let info = self.recorded_mount(name)?;
(mount_source(&info.mountpoint)? == format!("sandfs:{name}")).then_some(info)
}
pub fn mount_holding(&self, path: &Path) -> Option<String> {
let table = mount_table();
path.ancestors().find_map(|dir| self.mounted_at(dir, &table))
}
pub fn mounted_at(&self, dir: &Path, table: &HashMap<PathBuf, String>) -> Option<String> {
let name = table.get(dir)?.strip_prefix("sandfs:")?;
check_name(name).ok()?;
self.attached_in(name, table).filter(|info| info.mountpoint == dir).map(|_| name.to_owned())
}
fn attached_in(&self, name: &str, table: &HashMap<PathBuf, String>) -> Option<MountInfo> {
let info = self.recorded_mount(name)?;
(*table.get(&info.mountpoint)? == format!("sandfs:{name}")).then_some(info)
}
pub fn recorded_mount(&self, name: &str) -> Option<MountInfo> {
serde_json::from_slice(&std::fs::read(self.workspace_dir(name).join("mount.json")).ok()?).ok()
}
pub fn set_mount_info(&self, name: &str, info: Option<&MountInfo>) -> Result<()> {
let path = self.workspace_dir(name).join("mount.json");
match info {
Some(info) => {
let mut file = File::options();
file.write(true).create(true).truncate(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut file, 0o600);
let mut file = file.open(&path)?;
#[cfg(unix)]
file.set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600))?;
std::io::Write::write_all(&mut file, &serde_json::to_vec(info)?)?;
}
None => {
let _ = std::fs::remove_file(path);
}
}
Ok(())
}
pub fn find(&self, what: &str) -> Result<String> {
if self.exists(what) {
return Ok(what.to_owned());
}
match self.mounted(what).or_else(|| crate::folder::find(self, Path::new(what))) {
Some(name) => Ok(name),
None => bail!("{what:?} is neither a workspace name nor a mounted workspace"),
}
}
pub fn mounted(&self, dir: &str) -> Option<String> {
let dir = Path::new(dir).canonicalize().or_else(|_| {
let dir = std::path::absolute(dir)?;
Ok::<_, std::io::Error>(match (dir.parent().map(Path::canonicalize), dir.file_name()) {
(Some(Ok(parent)), Some(name)) => parent.join(name),
_ => dir,
})
});
dir.ok().and_then(|dir| self.mounted_at(&dir, &mount_table()))
}
#[cfg(mount)]
pub fn locked_by(&self, name: &str, pid: u32) -> bool {
let dir = self.workspace_dir(name);
let held = match File::options().write(true).open(dir.join("lock")) {
Ok(file) => !matches!(file.try_lock(), Ok(())),
Err(_) => true,
};
held && std::fs::read_to_string(dir.join("holder"))
.is_ok_and(|holder| holder.ends_with(&format!("(process {pid})")))
}
pub fn repos_lock(&self, exclusive: bool) -> Result<File> {
let repos = self.root.join("repos");
std::fs::create_dir_all(&repos).with_context(|| format!("creating {}", repos.display()))?;
let path = repos.join(".lock");
let file = File::options()
.write(true)
.create(true)
.truncate(false)
.open(&path)
.with_context(|| format!("opening {}", path.display()))?;
if exclusive { file.lock() } else { file.lock_shared() }
.with_context(|| format!("locking {}", path.display()))?;
Ok(file)
}
pub fn lock(&self, name: &str, holder: &str) -> Result<Writer> {
check_name(name)?;
let path = self.workspace_dir(name).join("lock");
let file = File::options()
.write(true)
.create(true)
.truncate(false)
.open(&path)
.with_context(|| format!("opening {}", path.display()))?;
let holder_path = self.workspace_dir(name).join("holder");
let mut attempt = 0;
let locked = loop {
match file.try_lock() {
Err(TryLockError::WouldBlock) if attempt < LOCK_RETRIES => {
attempt += 1;
std::thread::sleep(LOCK_RETRY_WAIT);
}
result => break result,
}
};
match locked {
Ok(()) => {}
Err(TryLockError::WouldBlock) => {
let held_by = std::fs::read_to_string(&holder_path).unwrap_or_default();
let held_by = if held_by.trim().is_empty() { "another sandfs process" } else { held_by.trim() };
bail!("{name} is in use by {held_by}");
}
Err(TryLockError::Error(err)) => return Err(err).with_context(|| format!("locking {}", path.display())),
}
std::fs::write(&holder_path, holder).with_context(|| format!("writing {}", holder_path.display()))?;
Ok(Writer { _file: file })
}
pub fn open(&self, name: &str) -> Result<Opened> {
let record = self.record(name)?;
let base = open_base(&record.base, &self.root)?;
self.load(name, record, &base)
}
pub fn open_with_start(&self, name: &str, base: Option<Workspace>) -> Result<(Opened, Workspace)> {
let record = self.record(name)?;
let base = match base {
Some(base) => base,
None => open_base(&record.base, &self.root)?,
};
let mut start = base.fork();
let dir = self.start_dir(name);
if dir.join("changes.json").exists() {
Saved::new(dir).load(&mut start, &VPath::parse(ROOT)?)?;
}
Ok((self.load(name, record, &base)?, start))
}
pub fn base(&self, name: &str) -> Result<Workspace> {
open_base(&self.record(name)?.base, &self.root)
}
pub fn open_over(&self, name: &str, base: &Workspace) -> Result<Opened> {
let record = self.record(name)?;
self.load(name, record, base)
}
pub fn keep_start(&self, name: &str) -> Result<()> {
Saved::new(self.workspace_dir(name)).copy_to(&self.start_dir(name))
}
pub fn copy_start(&self, from: &str, to: &str) -> Result<()> {
if !self.start_dir(from).join("changes.json").exists() {
return Ok(());
}
Saved::new(self.start_dir(from)).copy_to(&self.start_dir(to))
}
fn start_dir(&self, name: &str) -> PathBuf {
self.workspace_dir(name).join("start")
}
pub fn open_pair(&self, name: &str, other: &str) -> Result<(Opened, Opened)> {
let (record, other_record) = (self.record(name)?, self.record(other)?);
if record.base != other_record.base {
bail!("{name} and {other} have different sources ({} and {})", record.source, other_record.source);
}
let base = open_base(&record.base, &self.root)?;
Ok((self.load(name, record, &base)?, self.load(other, other_record, &base)?))
}
#[cfg(mount)]
pub fn open_shared(&self, name: &str, bases: &Arc<Bases>) -> Result<(Opened, Lease)> {
let record = self.record(name)?;
let (base, lease) = bases.lease(&record.base, &self.root)?;
Ok((self.load(name, record, &base)?, lease))
}
fn load(&self, name: &str, record: Record, base: &Workspace) -> Result<Opened> {
let mut attempts = 0;
loop {
let mut ws = base.fork();
let mut saved = Saved::new(self.workspace_dir(name));
match saved.load(&mut ws, &VPath::parse(ROOT)?) {
Ok(restored) => {
for path in &record.read_only {
ws.set_read_only(&format!("{ROOT}/{path}"), true)?;
}
return Ok(Opened { ws, saved, restored, record });
}
Err(err) if is_object_gone(&err) && attempts < 10 => attempts += 1,
Err(err) => return Err(err),
}
}
}
pub fn remove(&self, name: &str) -> Result<()> {
check_name(name)?;
std::fs::remove_dir_all(self.workspace_dir(name)).with_context(|| format!("removing workspace {name:?}"))
}
pub fn freeze(&self, folder: &Path, allow_copy: bool) -> Result<Record> {
let created = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
if crate::resolve_new(&self.root)?.starts_with(folder) {
bail!(
"{} contains sandfs's own state ({}); set SANDFS_HOME to a folder outside it",
folder.display(),
self.root.display()
);
}
let copies = self.copies_for(folder)?;
let copy = tempfile::Builder::new()
.prefix("copy-")
.tempdir_in(&copies)
.with_context(|| format!("making room in {}", copies.display()))?;
let own = folder.canonicalize().map(|folder| folder.join(SHARED)).ok();
let skip = own.filter(|own| copies.starts_with(own)).map(|_| folder.join(SHARED));
clone_tree(folder, copy.path(), &self.root, allow_copy, skip.as_deref())?;
let path = copy.keep();
let source = folder.display().to_string();
Ok(Record {
source,
base: Base::Dir { path },
created,
read_only: Vec::new(),
updated: BTreeMap::new(),
folder: None,
allow_copy,
})
}
pub fn holds_copy(&self, path: &Path) -> bool {
let canonical = |path: &Path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let path = canonical(path);
self.all_copies_dirs().iter().any(|dir| path.starts_with(canonical(dir)))
}
fn copies_dir(&self) -> PathBuf {
self.root.join("copies")
}
fn all_copies_dirs(&self) -> Vec<PathBuf> {
let mut dirs = vec![self.copies_dir()];
dirs.extend(self.copies_elsewhere());
dirs
}
fn copies_elsewhere(&self) -> Vec<PathBuf> {
let listed = std::fs::read_to_string(self.root.join("copies-elsewhere")).unwrap_or_default();
listed.lines().filter(|line| !line.is_empty()).map(PathBuf::from).collect()
}
fn copies_for(&self, folder: &Path) -> Result<PathBuf> {
let home = self.copies_dir();
std::fs::create_dir_all(&home).with_context(|| format!("creating {}", home.display()))?;
let Some(disk) = device(folder).filter(|disk| device(&home) != Some(*disk)) else { return Ok(home) };
if let Some(dir) =
self.copies_elsewhere().into_iter().find(|dir| device(dir) == Some(disk) && self.private(dir))
{
return Ok(dir);
}
let Some(top) = disk_top(folder) else { return Ok(home) };
let shared = top.join(SHARED).join("copies");
if std::fs::create_dir_all(&shared).is_err() || !self.private(&shared.join("home")) {
return Ok(home);
}
let made = tempfile::Builder::new().prefix("home-").tempdir_in(&shared).map(tempfile::TempDir::keep);
let Ok(dir) = made else { return Ok(home) };
let mut listed = File::options().create(true).append(true).open(self.root.join("copies-elsewhere"))?;
std::io::Write::write_all(&mut listed, format!("{}\n", dir.display()).as_bytes())?;
Ok(dir)
}
#[cfg(unix)]
fn private(&self, dir: &Path) -> bool {
use std::os::unix::fs::MetadataExt;
let Ok(owner) = std::fs::metadata(&self.root).map(|meta| meta.uid()) else { return false };
let ours = |dir: &Path| {
std::fs::symlink_metadata(dir)
.is_ok_and(|meta| meta.is_dir() && meta.uid() == owner && meta.mode() & 0o022 == 0)
};
let (Some(copies), Some(shared), Some(top)) =
(dir.parent(), dir.parent().and_then(Path::parent), dir.ancestors().nth(3))
else {
return false;
};
let top = std::fs::metadata(top).is_ok_and(|meta| meta.mode() & 0o022 == 0 || meta.mode() & 0o1000 != 0);
let dir = std::fs::symlink_metadata(dir).map_or(true, |meta| meta.is_dir());
dir && ours(copies) && ours(shared) && top
}
#[cfg(not(unix))]
fn private(&self, _dir: &Path) -> bool {
false
}
pub fn hold_copy(&self, path: &Path) -> Result<File> {
let held = held_path(path);
let file = File::options().write(true).create(true).truncate(false).open(&held)?;
file.lock_shared().with_context(|| format!("locking {}", held.display()))?;
Ok(file)
}
pub fn remove_unused_copies(&self) -> Result<()> {
let mut used = Vec::new();
for name in self.names()? {
if let Ok(Record { base: Base::Dir { path }, .. }) = self.record(&name)
&& let Ok(path) = path.canonicalize()
{
used.push(path);
}
}
for dir in self.all_copies_dirs() {
if dir != self.copies_dir() && !self.private(&dir) {
continue;
}
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for entry in entries {
let path = entry?.path();
if path.extension().is_some_and(|ext| ext == "held") || used.contains(&path.canonicalize()?) {
continue;
}
let held = held_path(&path);
let hold = File::options().write(true).open(&held);
match hold {
Ok(hold) if hold.try_lock().is_err() => continue,
Ok(_) => std::fs::remove_file(&held)?,
Err(_) => {}
}
remove_copy(&path)?;
}
}
let elsewhere = self.copies_elsewhere();
let kept: Vec<_> = elsewhere
.iter()
.filter(|dir| {
let Some(shared) = dir.parent() else { return true };
let gone = std::fs::remove_dir(dir).is_ok() || (shared.exists() && !dir.exists());
if gone {
let _ = std::fs::remove_dir(shared)
.and_then(|()| std::fs::remove_dir(shared.parent().unwrap_or(shared)));
}
!gone
})
.collect();
if kept.len() != elsewhere.len() {
let listed: String = kept.iter().map(|dir| format!("{}\n", dir.display())).collect();
let (file, tmp) = (self.root.join("copies-elsewhere"), self.root.join("copies-elsewhere.tmp"));
std::fs::write(&tmp, listed).context("writing copies-elsewhere")?;
std::fs::rename(&tmp, file).context("writing copies-elsewhere")?;
}
Ok(())
}
pub fn resolve(&self, source: &str) -> Result<Record> {
let created = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
let spec = source.strip_prefix("git:").or_else(|| source.contains("://").then_some(source));
let mut named = source.to_owned();
let base = if let Some(spec) = spec {
if let Some(repo) = crate::git::local_repo(spec)
&& let Some(name) = self.mount_holding(&repo)
{
bail!(
"{} is in the mount of workspace {name}, and git can't be trusted to read a mount's \
repository; `sandfs fork {name} DIR` copies it instead",
repo.display()
);
}
let (repo, commit, branch) = crate::git::resolve_branch(&self.root.join("repos"), spec)?;
Base::Git { repo, commit, branch }
} else {
named = std::path::absolute(expand(source)).map_or(named, |path| path.display().to_string());
let path = expand(source).canonicalize().with_context(|| format!("no folder at {source}"))?;
if !path.is_dir() {
bail!("{} is not a folder", path.display());
}
if crate::resolve_new(&self.root)?.starts_with(&path) {
bail!(
"{} contains sandfs's own state ({}); set SANDFS_HOME to a folder outside it",
path.display(),
self.root.display()
);
}
Base::Dir { path }
};
Ok(Record {
source: named,
base,
created,
read_only: Vec::new(),
updated: BTreeMap::new(),
folder: None,
allow_copy: false,
})
}
pub fn resolve_checkout(&self, top: &Path) -> Result<Record> {
let created = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
let (repo, commit, branch) = crate::git::resolve_head(top)?;
let source = format!("git:{}", repo.display());
Ok(Record {
source,
base: Base::Git { repo, commit, branch },
created,
read_only: Vec::new(),
updated: BTreeMap::new(),
folder: None,
allow_copy: false,
})
}
}
fn clone_tree(top: &Path, to: &Path, home: &Path, allow_copy: bool, skip: Option<&Path>) -> Result<()> {
let mut folders = vec![(top.to_path_buf(), to.to_path_buf())];
let copy_disk = device(to);
let mut made = Vec::new();
while let Some((from, to)) = folders.pop() {
for entry in std::fs::read_dir(&from).with_context(|| format!("reading {}", from.display()))? {
let entry = entry?;
let (path, copy) = (entry.path(), to.join(entry.file_name()));
let kind = entry.file_type()?;
if skip == Some(path.as_path()) {
continue;
} else if kind.is_dir() {
std::fs::create_dir(©).with_context(|| format!("creating {}", copy.display()))?;
folders.push((path, copy));
} else if kind.is_symlink() {
symlink(&std::fs::read_link(&path)?, ©)?;
} else if kind.is_file()
&& entry.file_name() == ".git"
&& let Some(shown) = linked_worktree(top, &from, &path)?
{
std::fs::write(©, shown).with_context(|| format!("writing {}", copy.display()))?;
keep_times(&path, ©)?;
} else if kind.is_file() {
let cloned = if allow_copy {
reflink_copy::reflink_or_copy(&path, ©).map(drop)
} else {
reflink_copy::reflink(&path, ©)
};
cloned.map_err(|err| -> anyhow::Error {
if err.kind() == std::io::ErrorKind::CrossesDevices {
let same = device(top).is_some() && device(top) == copy_disk;
let folder = if same { &from } else { top };
return CrossDevice { folder: folder.to_path_buf(), home: home.to_path_buf() }.into();
}
anyhow::anyhow!(
"can't clone {} into {}: {err}. Forking a plain folder clones its files, which \
needs the folder and sandfs's home on one disk that clones files, such as APFS, \
btrfs or XFS (ext4 doesn't); `sandfs fork --allow-copy` copies them in full \
instead, and `sandfs mount {} DIR` mounts the folder itself, showing its later \
changes too",
path.display(),
home.display(),
top.display()
)
})?;
keep_times(&path, ©)?;
} else {
eprintln!("sandfs: leaving out {}, which is neither a file, a link nor a folder", path.display());
}
}
made.push((from, to));
}
for (folder, copy) in made {
keep_times(&folder, ©)?;
}
Ok(())
}
fn linked_worktree(top: &Path, folder: &Path, path: &Path) -> Result<Option<Vec<u8>>> {
if std::fs::metadata(path).is_ok_and(|meta| meta.len() > 4096) {
return Ok(None);
}
let contents = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
let at = folder.strip_prefix(top).unwrap_or(Path::new(""));
Ok(crate::gitfile::rewrite(&contents, at, top))
}
pub(crate) const SHARED: &str = ".sandfs";
pub(crate) fn disk_top(path: &Path) -> Option<PathBuf> {
let path = path.canonicalize().ok()?;
let disk = device(&path)?;
path.ancestors().take_while(|dir| device(dir) == Some(disk)).last().map(Path::to_path_buf)
}
#[cfg(unix)]
fn device(path: &Path) -> Option<u64> {
Some(std::os::unix::fs::MetadataExt::dev(&std::fs::metadata(path).ok()?))
}
#[cfg(not(unix))]
fn device(_path: &Path) -> Option<u64> {
None
}
fn held_path(path: &Path) -> PathBuf {
let mut held = path.as_os_str().to_owned();
held.push(".held");
PathBuf::from(held)
}
pub fn remove_copy(path: &Path) -> Result<()> {
if std::fs::remove_dir_all(path).is_err() {
let mut folders = vec![path.to_path_buf()];
while let Some(folder) = folders.pop() {
let mut perms = std::fs::metadata(&folder)?.permissions();
#[allow(clippy::permissions_set_readonly_false)]
perms.set_readonly(false);
std::fs::set_permissions(&folder, perms)?;
for entry in std::fs::read_dir(&folder)?.flatten() {
if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
folders.push(entry.path());
}
}
}
std::fs::remove_dir_all(path).with_context(|| format!("removing {}", path.display()))?;
}
Ok(())
}
pub(crate) fn keep_times(path: &Path, copy: &Path) -> Result<()> {
let meta = std::fs::metadata(path)?;
let file = open_to_set_times(copy)?;
file.set_modified(meta.modified()?).with_context(|| format!("setting the time of {}", copy.display()))?;
std::fs::set_permissions(copy, meta.permissions())
.with_context(|| format!("setting the mode of {}", copy.display()))
}
#[cfg(not(windows))]
fn open_to_set_times(path: &Path) -> std::io::Result<File> {
File::open(path)
}
#[cfg(windows)]
fn open_to_set_times(path: &Path) -> std::io::Result<File> {
use std::os::windows::fs::OpenOptionsExt;
const FILE_WRITE_ATTRIBUTES: u32 = 0x100;
const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
std::fs::OpenOptions::new().access_mode(FILE_WRITE_ATTRIBUTES).custom_flags(FILE_FLAG_BACKUP_SEMANTICS).open(path)
}
#[cfg(unix)]
pub(crate) fn symlink(target: &Path, path: &Path) -> Result<()> {
std::os::unix::fs::symlink(target, path).with_context(|| format!("linking {}", path.display()))
}
#[cfg(not(unix))]
pub(crate) fn symlink(_target: &Path, path: &Path) -> Result<()> {
bail!("{} is a symbolic link, which this system can't copy", path.display())
}
pub fn open_base(base: &Base, home: &Path) -> Result<Workspace> {
let mut ws = Workspace::new();
match base {
Base::Dir { path } => ws.mount(ROOT, Arc::new(crate::gitfile::FolderSource::new(path)?))?,
Base::Git { repo, commit, branch } => {
let (files, git_dir) = crate::git::open(repo, commit, branch.as_deref(), home)?;
ws.mount(ROOT, files)?;
ws.mount(&format!("{ROOT}/.git"), git_dir)?;
}
}
Ok(ws)
}
#[cfg(mount)]
#[derive(Default)]
pub struct Bases {
open: Mutex<HashMap<Base, (Workspace, usize)>>,
}
#[cfg(mount)]
pub struct Lease {
bases: Arc<Bases>,
base: Option<Base>,
}
#[cfg(mount)]
impl Bases {
fn lease(self: &Arc<Self>, base: &Base, home: &Path) -> Result<(Workspace, Lease)> {
if !matches!(base, Base::Git { .. }) {
return Ok((open_base(base, home)?, Lease { bases: self.clone(), base: None }));
}
let lease = || Lease { bases: self.clone(), base: Some(base.clone()) };
if let Some((ws, uses)) = self.open.lock().expect("bases lock").get_mut(base) {
*uses += 1;
return Ok((ws.fork(), lease()));
}
let opened = open_base(base, home)?;
let mut open = self.open.lock().expect("bases lock");
let (ws, uses) = open.entry(base.clone()).or_insert((opened, 0));
*uses += 1;
Ok((ws.fork(), lease()))
}
}
#[cfg(mount)]
impl Drop for Lease {
fn drop(&mut self) {
let Some(base) = self.base.take() else { return };
let mut open = self.bases.open.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some((_, uses)) = open.get_mut(&base) else { return };
*uses -= 1;
if *uses == 0 {
let closed = open.remove(&base);
drop(open);
drop(closed);
}
}
}
pub fn expand(path: &str) -> PathBuf {
let rest = path.strip_prefix("~/").or_else(|| if cfg!(windows) { path.strip_prefix("~\\") } else { None });
match (rest, std::env::home_dir()) {
(Some(rest), Some(home)) => home.join(rest),
_ => PathBuf::from(path),
}
}
pub fn check_name(name: &str) -> Result<()> {
let ok = !name.is_empty()
&& !name.starts_with('.')
&& name.len() <= 100
&& name.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c));
if ok { Ok(()) } else { bail!("invalid workspace name {name:?}: use letters, digits, '.', '_' and '-'") }
}
pub(crate) fn subtree_keys<K: Ord + Clone + std::borrow::Borrow<str>, T>(
map: &BTreeMap<K, T>,
prefix: &VPath,
) -> Vec<K> {
use std::ops::Bound;
let p = prefix.as_str();
let mut keys: Vec<K> = map.get_key_value(p).map(|(key, _)| key.clone()).into_iter().collect();
let (start, end) = (format!("{p}/"), format!("{p}0"));
let below = (Bound::Included(start.as_str()), Bound::Excluded(end.as_str()));
keys.extend(map.range::<str, _>(below).map(|(key, _)| key.clone()));
keys
}
pub fn is_mounted(dir: &Path) -> bool {
mount_source(dir).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_are_plain() {
assert!(check_name("fix-search_2.0").is_ok());
for bad in ["", ".hidden", "a/b", "..", "a b"] {
assert!(check_name(bad).is_err(), "{bad:?}");
}
}
#[cfg(unix)]
#[test]
fn mount_records_are_private() {
use std::os::unix::fs::PermissionsExt;
let (state, data) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let home = Home::at(state.path());
home.create("ws", &home.resolve(data.path().to_str().unwrap()).unwrap()).unwrap();
let path = home.workspace_dir("ws").join("mount.json");
std::fs::write(&path, "{}").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let info = MountInfo {
pid: 1,
mountpoint: data.path().to_path_buf(),
save_port: Some(1),
save_secret: Some("00".into()),
id: 1,
};
home.set_mount_info("ws", Some(&info)).unwrap();
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
}
#[cfg(unix)]
fn other_disk(near: &Path) -> Option<tempfile::TempDir> {
let here = device(near)?;
["/dev/shm", "/Volumes/RAMDisk"]
.iter()
.filter(|path| device(Path::new(path)).is_some_and(|dev| dev != here))
.find_map(|path| tempfile::tempdir_in(path).ok())
}
#[cfg(unix)]
#[test]
fn a_folder_on_another_disk_is_copied_on_that_disk() {
use std::os::unix::fs::PermissionsExt;
let state = tempfile::tempdir().unwrap();
let Some(data) = other_disk(state.path()) else { return };
let home = Home::at(state.path());
let folder = data.path().join("notes");
std::fs::create_dir_all(folder.join("drafts")).unwrap();
std::fs::write(folder.join("drafts/plan.md"), "plan\n").unwrap();
std::fs::write(folder.join("run.sh"), "#!/bin/sh\n").unwrap();
std::fs::set_permissions(folder.join("run.sh"), std::fs::Permissions::from_mode(0o755)).unwrap();
let old = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000_000);
File::options().write(true).open(folder.join("run.sh")).unwrap().set_modified(old).unwrap();
let top = disk_top(&folder).unwrap();
let ours = |home: &Home| home.copies_elsewhere().into_iter().next();
let record = match home.freeze(&folder, false) {
Ok(record) => record,
Err(err) => {
assert!(err.downcast_ref::<CrossDevice>().is_none(), "{err}");
assert!(err.to_string().contains("can't clone"), "{err}");
assert_eq!(std::fs::read_dir(ours(&home).unwrap()).unwrap().count(), 0);
home.freeze(&folder, true).unwrap()
}
};
let Base::Dir { path } = &record.base else { panic!("{:?}", record.base) };
assert!(path.starts_with(top.join(SHARED).join("copies")), "{}", path.display());
assert_eq!(device(path), device(&folder));
assert_eq!(std::fs::read_to_string(path.join("drafts/plan.md")).unwrap(), "plan\n");
let meta = std::fs::metadata(path.join("run.sh")).unwrap();
assert_eq!(meta.permissions().mode() & 0o777, 0o755);
assert_eq!(meta.modified().unwrap(), old);
assert!(home.holds_copy(path));
let dir = ours(&home).unwrap();
home.remove_unused_copies().unwrap();
assert!(!path.exists() && !dir.exists());
assert_eq!(ours(&home), None);
}
#[cfg(unix)]
#[test]
fn copies_go_on_another_disk_only_where_no_one_else_can_move_them() {
use std::os::unix::fs::PermissionsExt;
let (state, top) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let home = Home::at(state.path());
std::fs::create_dir_all(state.path()).unwrap();
let copies = top.path().join(".sandfs/copies");
std::fs::create_dir_all(&copies).unwrap();
let mode = |path: &Path, mode| std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
mode(top.path(), 0o755);
mode(&copies, 0o755);
mode(&top.path().join(".sandfs"), 0o755);
let dir = copies.join("home-x");
assert!(home.private(&dir));
mode(&copies, 0o777);
assert!(!home.private(&dir));
mode(&copies, 0o755);
mode(top.path(), 0o777);
assert!(!home.private(&dir));
mode(top.path(), 0o1777);
assert!(home.private(&dir));
std::fs::rename(top.path().join(".sandfs"), top.path().join("moved")).unwrap();
std::os::unix::fs::symlink(top.path().join("moved"), top.path().join(".sandfs")).unwrap();
assert!(!home.private(&dir));
}
#[test]
fn a_folder_s_copies_of_its_disk_are_never_copied() {
let (data, copy) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
std::fs::create_dir_all(data.path().join(".sandfs/copies/home-x/copy-y")).unwrap();
std::fs::create_dir_all(data.path().join("sub/.sandfs")).unwrap();
std::fs::write(data.path().join("notes.md"), "notes\n").unwrap();
let skip = data.path().join(SHARED);
clone_tree(data.path(), copy.path(), data.path(), true, Some(&skip)).unwrap();
assert!(!copy.path().join(SHARED).exists());
assert!(copy.path().join("sub/.sandfs").is_dir());
assert!(copy.path().join("notes.md").is_file());
}
#[test]
fn one_writer_at_a_time() {
let (state, data) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let home = Home::at(state.path());
home.create("ws", &home.resolve(data.path().to_str().unwrap()).unwrap()).unwrap();
let first = home.lock("ws", "the first writer").unwrap();
let refused = home.lock("ws", "the second writer").err().unwrap();
assert_eq!(refused.to_string(), "ws is in use by the first writer");
drop(first);
let _second = home.lock("ws", "the second writer").unwrap();
assert!(home.lock("missing", "a writer").is_err(), "locking never creates a workspace");
assert!(!home.workspace_dir("missing").exists());
}
#[test]
fn readers_load_while_a_writer_keeps_saving() {
use std::collections::{BTreeMap, BTreeSet};
use crate::persist::Snapshot;
let (state, data) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let home = Home::at(state.path());
home.create("ws", &home.resolve(data.path().to_str().unwrap()).unwrap()).unwrap();
let root = VPath::parse(ROOT).unwrap();
let mut writer = home.open("ws").unwrap();
writer.ws.write("/mnt/count.txt", "0").unwrap();
let snap = |ws: &Workspace| Snapshot { ws: ws.fork(), times: BTreeMap::new(), dirs: BTreeSet::new() };
writer.saved.save(&snap(&writer.ws), &root, false).unwrap();
std::thread::scope(|scope| {
scope.spawn(|| {
for i in 1..=300 {
writer.ws.write("/mnt/count.txt", i.to_string()).unwrap();
writer.saved.save(&snap(&writer.ws), &root, false).unwrap();
}
});
for _ in 0..300 {
let read = loop {
match home.open("ws").unwrap().ws.read_text("/mnt/count.txt") {
Ok(read) => break read,
Err(err) => assert!(crate::persist::is_object_gone(&err.into())),
}
};
assert!(read.parse::<u32>().is_ok(), "{read:?}");
}
});
}
#[test]
fn records_round_trip_through_a_home() {
let home = tempfile::tempdir().unwrap();
let data = tempfile::tempdir().unwrap();
std::fs::write(data.path().join("f.txt"), "f").unwrap();
let home = Home::at(home.path());
let record = home.resolve(data.path().to_str().unwrap()).unwrap();
assert!(home.resolve(data.path().join("f.txt").to_str().unwrap()).is_err(), "a file is not a source");
home.create("one", &record).unwrap();
assert!(home.create("one", &record).is_err(), "names are unique");
assert_eq!(home.names().unwrap(), ["one"]);
assert_eq!(home.find("one").unwrap(), "one");
let opened = home.open("one").unwrap();
assert_eq!(opened.ws.read_text("/mnt/f.txt").unwrap(), "f");
home.remove("one").unwrap();
assert!(home.names().unwrap().is_empty());
let inside = Home::at(data.path().join(".local/share/sandfs"));
assert!(inside.resolve(data.path().to_str().unwrap()).is_err());
assert!(!data.path().join(".local").exists(), "nothing is created in the source");
}
}