Files
proton-drive-linux/crates/pdfs-core/src/syncignore.rs
T
nvrlandClaude Opus 5 bca0fe34bc fix(fuse,core): key conflict detection on revision id, stop transient-name seals (B69, B70)
B69 — spurious `(sync-conflict)` copies came from `(mtime, size)` being used as a
revision identity. The server re-stamps a sealed revision with its own commit
time, so the drain diverted a write into a conflict copy of its own base; and
`plan_file` classified a no-baseline (local, remote) pair as `Conflict`, forking
every pre-existing file on first reconcile.

- `Baseline` carries `revision_id` (from the SDK's new `active_revision_id`),
  captured at write-open and preserved across supersede/rebaseline.
  `revision_changed` trusts the id and only falls back to `(mtime, size)` for
  old sidecars.
- Planner gains `FilePlan::AdoptBaseline`: equal-size no-baseline pairs are
  recorded, not conflicted. Divergent sizes stay a real conflict.
- New `sweep.rs` (`pdfs-conflict-sweep`, 5-min cadence) removes conflict copies
  proven identical by size + `content_sha1` and surfaces divergent ones once as
  an `ActivityKind::Conflict` entry. Never removes what it cannot prove.

B70 — browsers downloading into an on-demand mount had their in-flight temps
sealed as canonical revisions, exiling the finished file to a conflict copy.
`syncignore::is_transient_name` now recognises download/scratch temps, and the
on-demand `release`/`rename` paths hold them (`set_create_hold`) instead of
sealing until the name is finalized.

Workspace deps bumped to proton-sdk/proton-drive-rs 0.2.2 (registry, no patch).

Live validation against a real account still pending for both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8P42asymU158CPnhjRwri
2026-07-25 15:24:31 +02:00

348 lines
12 KiB
Rust

//! Selective-sync ignore rules for mirror folders (features.md 1.1).
//!
//! 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 of files that are, by convention, incomplete or throwaway while they
/// wear the name: a download in flight, an editor's swap or backup, an atomic
/// write's scratch copy. 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.
const TRANSIENT_SUFFIXES: &[&str] = &[
".crdownload", // Chromium/Brave/Edge
".part", // Firefox, wget, aria2c
".partial", // some downloaders / older IE
".download", // Safari / macOS
".tmp", // generic scratch
".temp", // generic scratch
".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
}
/// 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",
"export.xml.tmp",
"notes.TEMP",
".notes.txt.swp",
"doc.txt~",
".~lock.report.odt#",
"~$budget.xlsx",
"FOO.CRDOWNLOAD",
] {
assert!(is_transient_name(name), "{name} should be transient");
}
}
#[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",
] {
assert!(!is_transient_name(name), "{name} should not be transient");
}
}
}