Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268//! Filesystem traversal, safe path resolution, and change watching.
use std::{ fs::{self, File}, path::{Component, Path, PathBuf}, sync::mpsc::{self, Receiver},};
use anyhow::Context;use ignore::gitignore::{Gitignore, GitignoreBuilder};use notify::{EventKind, RecursiveMode, Watcher};
pub const IGNORE_FILE_NAME: &str = ".appaignore";const PROCESS_LOCK_FILE_NAME: &str = "appa.lock";
pub struct IgnoreRules { matcher: Gitignore,}
impl IgnoreRules { pub fn load(folder_path: &Path) -> anyhow::Result<Self> { let ignore_file = folder_path.join(IGNORE_FILE_NAME); let mut builder = GitignoreBuilder::new(folder_path); if ignore_file.exists() && let Some(error) = builder.add(ignore_file) { return Err(error.into()); } Ok(Self { matcher: builder.build()?, }) }
pub fn ignores(&self, path: &Path, is_directory: bool) -> bool { path.file_name() .is_some_and(|name| name == IGNORE_FILE_NAME) || self .matcher .matched_path_or_any_parents(path, is_directory) .is_ignore() }}
pub struct CollectedEntries { pub files: Vec<PathBuf>, pub directories: Vec<PathBuf>,}
pub fn collect_entries( directory: &Path, ignore_rules: &IgnoreRules,) -> anyhow::Result<CollectedEntries> { let mut entries = CollectedEntries { files: Vec::new(), directories: Vec::new(), }; collect_entries_into(directory, ignore_rules, &mut entries) .with_context(|| format!("could not scan {}", directory.display()))?; Ok(entries)}
pub fn relative_path(folder: &Path, file_path: &Path) -> anyhow::Result<String> { let relative_path = file_path .strip_prefix(folder) .with_context(|| { format!( "{} is not inside synced folder {}", file_path.display(), folder.display() ) })? .to_str() .ok_or_else(|| anyhow::anyhow!("path contains non-Unicode characters: {file_path:?}"))?; Ok(relative_path.replace('\\', "/"))}
pub fn safe_destination(folder_path: &Path, relative_path: &str) -> anyhow::Result<PathBuf> { let relative_path = Path::new(relative_path); if relative_path.as_os_str().is_empty() || relative_path .components() .any(|component| !matches!(component, Component::Normal(_))) { // Remote manifest paths must never escape the folder or address a special path. anyhow::bail!("peer sent an unsafe path: {relative_path:?}"); } let destination = folder_path.join(relative_path); reject_symlink_ancestors(folder_path, &destination)?; Ok(destination)}
pub fn remove_path(path: &Path) -> anyhow::Result<()> { match fs::symlink_metadata(path) { Ok(metadata) if metadata.file_type().is_dir() => fs::remove_dir_all(path)?, Ok(_) => fs::remove_file(path)?, Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(error.into()), } Ok(())}
pub struct FolderChangeWatcher { _watcher: notify::RecommendedWatcher, events: Receiver<()>,}
pub fn watch_folder(path: &Path) -> anyhow::Result<FolderChangeWatcher> { let (sender, receiver) = mpsc::channel(); let mut watcher = notify::recommended_watcher(move |result: notify::Result<notify::Event>| { if matches!( result.map(|event| event.kind), Ok(EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)) ) { let _ = sender.send(()); } })?; watcher .watch(path, RecursiveMode::Recursive) .with_context(|| format!("could not watch {}", path.display()))?; Ok(FolderChangeWatcher { _watcher: watcher, events: receiver, })}
impl FolderChangeWatcher { pub fn has_pending_events(&self) -> bool { let mut has_pending_events = false; while self.events.try_recv().is_ok() { has_pending_events = true; } has_pending_events }}
pub struct FolderLock { _file: File,}
pub fn lock_app_process(lock_directory: &Path) -> anyhow::Result<FolderLock> { fs::create_dir_all(lock_directory).with_context(|| { format!( "could not create Appa lock directory {}", lock_directory.display() ) })?; let lock_path = lock_directory.join(PROCESS_LOCK_FILE_NAME); let file = File::create(&lock_path) .with_context(|| format!("could not open Appa process lock {}", lock_path.display()))?; file.try_lock() .map_err(|error| anyhow::anyhow!("another Appa process is already running: {error}"))?; Ok(FolderLock { _file: file })}
fn collect_entries_into( directory: &Path, ignore_rules: &IgnoreRules, entries: &mut CollectedEntries,) -> anyhow::Result<()> { for entry in fs::read_dir(directory) .with_context(|| format!("could not read directory {}", directory.display()))? { let path = entry .with_context(|| format!("could not read an entry in {}", directory.display()))? .path(); let metadata = fs::symlink_metadata(&path) .with_context(|| format!("could not inspect {}", path.display()))?; if metadata.file_type().is_symlink() { tracing::warn!(path = %path.display(), "Skipping symlink in synced folder"); continue; } let is_directory = metadata.is_dir(); if ignore_rules.ignores(&path, is_directory) { continue; } if is_directory { entries.directories.push(path.clone()); collect_entries_into(&path, ignore_rules, entries)?; } else if metadata.is_file() { entries.files.push(path); } else { tracing::warn!(path = %path.display(), "Skipping unsupported filesystem entry in synced folder"); } } Ok(())}
fn reject_symlink_ancestors(folder_path: &Path, destination: &Path) -> anyhow::Result<()> { let relative_path = destination.strip_prefix(folder_path)?; let mut current_path = folder_path.to_owned(); for component in relative_path.components() { current_path.push(component); let metadata = match fs::symlink_metadata(¤t_path) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, Err(error) => return Err(error.into()), }; if metadata.file_type().is_symlink() { anyhow::bail!("synced path contains a symlink: {}", current_path.display()); } } Ok(())}
#[cfg(test)]mod tests { use std::fs;
use tempfile::TempDir;
use super::{IgnoreRules, collect_entries, remove_path, safe_destination};
#[test] fn excludes_matching_paths_and_the_local_ignore_file() -> anyhow::Result<()> { let directory = TempDir::new()?; fs::write(directory.path().join(".appaignore"), "private/\n*.tmp\n")?; fs::create_dir(directory.path().join("private"))?; fs::write(directory.path().join("private/secret.txt"), "hidden")?; fs::write(directory.path().join("draft.tmp"), "hidden")?; fs::write(directory.path().join("shared.txt"), "shared")?; let rules = IgnoreRules::load(directory.path())?;
let files = collect_entries(directory.path(), &rules)?.files; let names = files .iter() .map(|path| path.file_name().expect("file name").to_owned()) .collect::<Vec<_>>();
assert_eq!(names, vec!["shared.txt"]); Ok(()) }
#[cfg(unix)] #[test] fn skips_symlinks_and_rejects_symlinked_destinations() -> anyhow::Result<()> { use std::os::unix::fs::symlink;
let folder = TempDir::new()?; let outside = TempDir::new()?; fs::write(outside.path().join("secret.txt"), "outside")?; symlink(outside.path(), folder.path().join("outside"))?; let rules = IgnoreRules::load(folder.path())?;
assert!(collect_entries(folder.path(), &rules)?.files.is_empty()); assert!(safe_destination(folder.path(), "outside/secret.txt").is_err()); Ok(()) }
#[cfg(unix)] #[test] fn removes_a_symlink_without_following_its_target() -> anyhow::Result<()> { use std::os::unix::fs::symlink;
let directory = TempDir::new()?; let target = directory.path().join("target"); let link = directory.path().join("link"); fs::create_dir(&target)?; fs::write(target.join("preserved.txt"), "contents")?; symlink(&target, &link)?;
remove_path(&link)?;
assert!(!link.exists()); assert!(target.join("preserved.txt").exists()); Ok(()) }}