mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 08:56:59 +01:00
66 comments cited `bugs.md B<n>`, the ledger's old name; they now say `docs/BUGS.md B<n>`, and every cited number has an entry. About 90 more cited offline.md, devices.md, features.md, mount-architecture.md, plan.md and improvements.md, planning notes that were never in the repository. Those citations are removed; the comments keep their text.
401 lines
14 KiB
Rust
401 lines
14 KiB
Rust
//! Selective-sync ignore rules for mirror folders.
|
|
//!
|
|
//! A mirror folder otherwise uploads every path beneath its root, which for a
|
|
//! project directory means `node_modules/`, `.git/`, and `target/` — quota
|
|
//! spent on files nobody wants on Drive, and enough API traffic to invite rate
|
|
//! limiting.
|
|
//!
|
|
//! Rules come from two places, unioned:
|
|
//!
|
|
//! * `.pdfsignore` (or `.protonignore`) at the root of the synced folder,
|
|
//! gitignore syntax.
|
|
//! * A global pattern list from the config, applied to every synced folder.
|
|
//!
|
|
//! Both are read fresh at the start of each reconcile pass, so editing the file
|
|
//! takes effect on the next pass without restarting the daemon.
|
|
//!
|
|
//! # Ignoring is not deleting
|
|
//!
|
|
//! A path that becomes ignored after it was already synced keeps its remote
|
|
//! copy and its baseline row. The sync engine drops ignored paths from
|
|
//! classification entirely rather than treating them as locally deleted —
|
|
//! see the union filter in `pdfs-fuse`'s `do_reconcile`. Editing a text file
|
|
//! must never trash data on Drive.
|
|
|
|
use std::path::Path;
|
|
|
|
use ignore::gitignore::{Gitignore, GitignoreBuilder};
|
|
|
|
/// Patterns applied to every synced folder when the config does not override
|
|
/// them. Version-control metadata, dependency and build trees, editor
|
|
/// leftovers, and OS turds — none of which belong on Drive, all of which are
|
|
/// large or rewritten constantly.
|
|
pub const DEFAULT_IGNORE_PATTERNS: &[&str] = &[
|
|
".git/",
|
|
".hg/",
|
|
".svn/",
|
|
"node_modules/",
|
|
"target/",
|
|
".venv/",
|
|
"__pycache__/",
|
|
"*~",
|
|
"*.swp",
|
|
"*.tmp",
|
|
".DS_Store",
|
|
"Thumbs.db",
|
|
];
|
|
|
|
/// Suffixes that mean a file is incomplete *by construction*: only the program
|
|
/// writing it gives it this name, only while the write is in flight, and the
|
|
/// finished article always arrives under a different one. On an on-demand mount
|
|
/// these should never seal a remote revision — the app renames the finished file
|
|
/// to its real name, and only that belongs on Drive. See [`is_transient_name`]
|
|
/// and docs/BUGS.md B70.
|
|
///
|
|
/// `.tmp`/`.temp` are deliberately **not** here, though they are in
|
|
/// [`DEFAULT_IGNORE_PATTERNS`]. The two lists answer different questions. Skipping
|
|
/// a `report.tmp` during a mirror reconcile costs nothing — the file stays on
|
|
/// disk, and a later pass picks it up if the rules change. Parking one on the
|
|
/// on-demand mount is irreversible in practice: the only exit is a rename to a
|
|
/// finished name, and a file that is simply *called* `index.tmp` — a static-site
|
|
/// template, a tool's output — never gets one. It then never reaches Drive at
|
|
/// all, silently, while its bytes pin `staging/`. That is what a real Takeout
|
|
/// tree of `index.tmp`/`preview.tmp` gallery templates did: 43 creates parked
|
|
/// forever. The generic-scratch case they were added for renames within
|
|
/// milliseconds, so it never needed the park; the download suffixes below, which
|
|
/// stay in flight for minutes, are what B70 was actually about.
|
|
const TRANSIENT_SUFFIXES: &[&str] = &[
|
|
".crdownload", // Chromium/Brave/Edge
|
|
".part", // Firefox, wget, aria2c
|
|
".partial", // some downloaders / older IE
|
|
".download", // Safari / macOS
|
|
".swp", // vim swap
|
|
".swx", // vim swap (secondary)
|
|
];
|
|
|
|
/// Whether `name` is a transient scratch/in-flight file whose bytes should not
|
|
/// be uploaded on an on-demand mount until it is renamed to its finished name.
|
|
///
|
|
/// Recognises browser download temps, editor swap/backup files, and lock files.
|
|
/// Case-insensitive on the suffix so `FOO.CRDOWNLOAD` counts too. This is the
|
|
/// on-demand-write counterpart to [`DEFAULT_IGNORE_PATTERNS`] (which only covers
|
|
/// mirror-folder reconcile); keep the two conceptually in step.
|
|
pub fn is_transient_name(name: &str) -> bool {
|
|
let lower = name.to_ascii_lowercase();
|
|
if TRANSIENT_SUFFIXES.iter().any(|s| lower.ends_with(s)) {
|
|
return true;
|
|
}
|
|
// Emacs/gedit/kate backup copies.
|
|
if name.ends_with('~') {
|
|
return true;
|
|
}
|
|
// LibreOffice lock files (`.~lock.<doc>#`) and MS Office temps (`~$doc`).
|
|
if name.starts_with(".~lock.") || name.starts_with("~$") {
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Whether `name` is scratch another program writes and throws away: the
|
|
/// [`is_transient_name`] set, plus the save-by-rename temporaries of GTK, Qt,
|
|
/// Chromium and Electron, generic `.tmp` files and Emacs lock files. None of
|
|
/// them is a file the user made, so a front-end can hide them from a history
|
|
/// of what happened to the user's files.
|
|
pub fn is_scratch_name(name: &str) -> bool {
|
|
if is_transient_name(name) {
|
|
return true;
|
|
}
|
|
let lower = name.to_ascii_lowercase();
|
|
lower.ends_with(".tmp")
|
|
|| lower.ends_with(".temp")
|
|
|| name.starts_with(".goutputstream-")
|
|
|| name.starts_with(".org.chromium.")
|
|
|| name.starts_with(".com.google.chrome.")
|
|
|| name.starts_with(".#")
|
|
|| (name.starts_with('#') && name.ends_with('#') && name.len() > 1)
|
|
|| (name.starts_with('.') && name.contains(".tmp."))
|
|
}
|
|
|
|
/// Filenames accepted as a folder's ignore file, in precedence order.
|
|
const IGNORE_FILE_NAMES: &[&str] = &[".pdfsignore", ".protonignore"];
|
|
|
|
/// Compiled ignore rules for one synced folder.
|
|
pub struct IgnoreRules {
|
|
matcher: Option<Gitignore>,
|
|
}
|
|
|
|
impl IgnoreRules {
|
|
/// Build the rules for a folder rooted at `root`: `globals` first, then any
|
|
/// ignore file found at the root, so a folder-local rule can negate a
|
|
/// global one with `!pattern`.
|
|
///
|
|
/// Malformed patterns are skipped individually rather than failing the
|
|
/// load — a typo in `.pdfsignore` must not take the folder's sync down.
|
|
pub fn load(root: &Path, globals: &[String]) -> Self {
|
|
let mut builder = GitignoreBuilder::new(root);
|
|
let mut any = false;
|
|
|
|
for pattern in globals {
|
|
if builder.add_line(None, pattern).is_ok() {
|
|
any = true;
|
|
}
|
|
}
|
|
|
|
for name in IGNORE_FILE_NAMES {
|
|
let path = root.join(name);
|
|
if !path.is_file() {
|
|
continue;
|
|
}
|
|
// `add` returns the first parse error but still takes the lines it
|
|
// could read, so a bad line costs that line and nothing more.
|
|
let _ = builder.add(&path);
|
|
any = true;
|
|
break;
|
|
}
|
|
|
|
if !any {
|
|
return Self { matcher: None };
|
|
}
|
|
Self {
|
|
matcher: builder.build().ok(),
|
|
}
|
|
}
|
|
|
|
/// Rules matching nothing, for callers with no folder context.
|
|
pub fn empty() -> Self {
|
|
Self { matcher: None }
|
|
}
|
|
|
|
/// Whether any rule is loaded. When false, [`is_ignored`](Self::is_ignored)
|
|
/// is always false and callers can skip filtering entirely.
|
|
pub fn is_empty(&self) -> bool {
|
|
self.matcher.is_none()
|
|
}
|
|
|
|
/// Whether `rel` — a `/`-joined path relative to the folder root — is
|
|
/// ignored.
|
|
///
|
|
/// A path under an ignored directory is ignored too. `Gitignore` matches
|
|
/// one path against one rule set and does not walk parents itself, so each
|
|
/// ancestor is tested as a directory. This is what makes filtering the
|
|
/// classification union sound: the engine can ask about any path in
|
|
/// isolation, in any order, without having walked its parents first.
|
|
pub fn is_ignored(&self, rel: &str, is_dir: bool) -> bool {
|
|
let Some(matcher) = &self.matcher else {
|
|
return false;
|
|
};
|
|
if rel.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
let mut prefix = String::new();
|
|
let mut parts = rel.split('/').peekable();
|
|
while let Some(part) = parts.next() {
|
|
if !prefix.is_empty() {
|
|
prefix.push('/');
|
|
}
|
|
prefix.push_str(part);
|
|
let last = parts.peek().is_none();
|
|
// Every ancestor is a directory; only the final component's kind
|
|
// is the caller's to say.
|
|
let as_dir = if last { is_dir } else { true };
|
|
if matcher.matched(&prefix, as_dir).is_ignore() {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for IgnoreRules {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("IgnoreRules")
|
|
.field("active", &!self.is_empty())
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A unique temp directory removed on drop; avoids a dev-dependency, as in
|
|
/// [`crate::cache`]'s tests.
|
|
struct TempDir(std::path::PathBuf);
|
|
impl TempDir {
|
|
fn new() -> Self {
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
static N: AtomicU64 = AtomicU64::new(0);
|
|
let p = std::env::temp_dir().join(format!(
|
|
"pdfs-ignore-{}-{}",
|
|
std::process::id(),
|
|
N.fetch_add(1, Ordering::Relaxed)
|
|
));
|
|
std::fs::create_dir_all(&p).unwrap();
|
|
TempDir(p)
|
|
}
|
|
fn path(&self) -> &Path {
|
|
&self.0
|
|
}
|
|
}
|
|
impl Drop for TempDir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
fn globals() -> Vec<String> {
|
|
DEFAULT_IGNORE_PATTERNS
|
|
.iter()
|
|
.map(|s| (*s).to_string())
|
|
.collect()
|
|
}
|
|
|
|
fn write(root: &Path, name: &str, body: &str) {
|
|
std::fs::write(root.join(name), body).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn no_rules_at_all_ignores_nothing() {
|
|
let dir = TempDir::new();
|
|
let rules = IgnoreRules::load(dir.path(), &[]);
|
|
assert!(rules.is_empty());
|
|
assert!(!rules.is_ignored("anything", false));
|
|
assert!(!rules.is_ignored("deep/nested/path.rs", false));
|
|
}
|
|
|
|
#[test]
|
|
fn global_defaults_match_without_an_ignore_file() {
|
|
let dir = TempDir::new();
|
|
let rules = IgnoreRules::load(dir.path(), &globals());
|
|
assert!(rules.is_ignored("node_modules", true));
|
|
assert!(rules.is_ignored("target", true));
|
|
assert!(rules.is_ignored("notes.txt~", false));
|
|
assert!(!rules.is_ignored("src/main.rs", false));
|
|
}
|
|
|
|
#[test]
|
|
fn a_path_under_an_ignored_directory_is_ignored() {
|
|
let dir = TempDir::new();
|
|
let rules = IgnoreRules::load(dir.path(), &globals());
|
|
// The engine asks about deep paths in isolation, so each must answer
|
|
// correctly without its parents having been tested first.
|
|
assert!(rules.is_ignored("node_modules/left-pad/index.js", false));
|
|
assert!(rules.is_ignored("node_modules/left-pad", true));
|
|
assert!(rules.is_ignored("target/debug/build/x/out", false));
|
|
}
|
|
|
|
#[test]
|
|
fn a_folder_ignore_file_adds_to_the_globals() {
|
|
let dir = TempDir::new();
|
|
write(dir.path(), ".pdfsignore", "secrets/\n*.log\n");
|
|
let rules = IgnoreRules::load(dir.path(), &globals());
|
|
assert!(rules.is_ignored("secrets/key.pem", false));
|
|
assert!(rules.is_ignored("run.log", false));
|
|
// Globals still apply alongside the file.
|
|
assert!(rules.is_ignored("node_modules", true));
|
|
assert!(!rules.is_ignored("run.txt", false));
|
|
}
|
|
|
|
#[test]
|
|
fn a_folder_rule_can_negate_a_global_one() {
|
|
let dir = TempDir::new();
|
|
write(dir.path(), ".pdfsignore", "!keep.tmp\n");
|
|
let rules = IgnoreRules::load(dir.path(), &globals());
|
|
assert!(rules.is_ignored("scratch.tmp", false));
|
|
assert!(!rules.is_ignored("keep.tmp", false));
|
|
}
|
|
|
|
#[test]
|
|
fn protonignore_is_accepted_as_an_alias() {
|
|
let dir = TempDir::new();
|
|
write(dir.path(), ".protonignore", "build/\n");
|
|
let rules = IgnoreRules::load(dir.path(), &[]);
|
|
assert!(rules.is_ignored("build/out.o", false));
|
|
}
|
|
|
|
#[test]
|
|
fn pdfsignore_wins_when_both_files_exist() {
|
|
let dir = TempDir::new();
|
|
write(dir.path(), ".pdfsignore", "from-pdfs/\n");
|
|
write(dir.path(), ".protonignore", "from-proton/\n");
|
|
let rules = IgnoreRules::load(dir.path(), &[]);
|
|
assert!(rules.is_ignored("from-pdfs/x", false));
|
|
assert!(!rules.is_ignored("from-proton/x", false));
|
|
}
|
|
|
|
#[test]
|
|
fn a_malformed_pattern_does_not_take_the_rest_down() {
|
|
let dir = TempDir::new();
|
|
// An unclosed character class is not a valid glob.
|
|
write(dir.path(), ".pdfsignore", "[unclosed\nvalid/\n");
|
|
let rules = IgnoreRules::load(dir.path(), &globals());
|
|
assert!(rules.is_ignored("valid/x", false));
|
|
assert!(rules.is_ignored("node_modules", true));
|
|
}
|
|
|
|
#[test]
|
|
fn the_root_itself_is_never_ignored() {
|
|
let dir = TempDir::new();
|
|
write(dir.path(), ".pdfsignore", "*\n");
|
|
let rules = IgnoreRules::load(dir.path(), &[]);
|
|
// A blanket rule must not classify the folder root, or the pass would
|
|
// have nothing to reconcile against.
|
|
assert!(!rules.is_ignored("", true));
|
|
}
|
|
|
|
#[test]
|
|
fn transient_download_and_editor_names_are_recognised() {
|
|
for name in [
|
|
"Unconfirmed 540477.crdownload",
|
|
"movie.mkv.part",
|
|
"big.iso.partial",
|
|
"photo.jpg.download",
|
|
".notes.txt.swp",
|
|
"doc.txt~",
|
|
".~lock.report.odt#",
|
|
"~$budget.xlsx",
|
|
"FOO.CRDOWNLOAD",
|
|
] {
|
|
assert!(is_transient_name(name), "{name} should be transient");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn save_by_rename_temporaries_are_scratch() {
|
|
for name in [
|
|
".goutputstream-4XK2B2",
|
|
".org.chromium.Chromium.aBc123",
|
|
"~$report.docx",
|
|
".~lock.budget.ods#",
|
|
".#notes.org",
|
|
"#notes.org#",
|
|
"cache.tmp",
|
|
"page.html.crdownload",
|
|
] {
|
|
assert!(is_scratch_name(name), "{name}");
|
|
}
|
|
for name in ["report.docx", "notes.org", "#1 hits.txt", ".bashrc"] {
|
|
assert!(!is_scratch_name(name), "{name}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn finished_names_are_not_transient() {
|
|
for name in [
|
|
"teamspeak.tar.gz",
|
|
"nils.zip",
|
|
"report.xml",
|
|
"archive.partly.zip", // ".partly" is not ".part"
|
|
"template.dotx",
|
|
"readme",
|
|
// A file that is *named* `.tmp` and never renamed: parking it would
|
|
// keep it off Drive for good.
|
|
"index.tmp",
|
|
"notes.TEMP",
|
|
] {
|
|
assert!(!is_transient_name(name), "{name} should not be transient");
|
|
}
|
|
}
|
|
}
|