Skip to content

fix(sync): copy peer files from SAF dir into internal dir before pull - #292

Closed
TimeToBuildBob wants to merge 1 commit into
ActivityWatch:masterfrom
TimeToBuildBob:fix/saf-reverse-mirror-for-peer-pull
Closed

TimeToBuildBob wants to merge 1 commit into
ActivityWatch:masterfrom
TimeToBuildBob:fix/saf-reverse-mirror-for-peer-pull

Conversation

@TimeToBuildBob

Copy link
Copy Markdown
Contributor

Problem

When using a SAF/Syncthing folder for sync (Android 11+ setup), pull_all inside syncBoth always reports "pulled 0 peers" regardless of how many peers have written their databases into the Syncthing-shared folder.

Root cause: copySyncFilesToSafDir mirrors internal → SAF (making our data visible to Syncthing) but nothing mirrors SAF → internal before syncBoth runs. The app-private syncDir only ever contains our own staging files, so list_remote_dbs finds zero peers.

Reported in #291.

Fix

Add copyPeerFilesFromSafDir() that reads all hostname directories from the SAF tree into the internal syncDir before syncBoth is called:

  • Skips our own hostname directory (our live staging is authoritative; the SAF copy is one cycle stale)
  • Per-entry errors are logged and skipped — a partially-accessible SAF dir does not abort a healthy sync
  • Called in the syncBothAsync lambda, inside the executor thread, immediately before syncBoth

The reverse-copy helper copyFromSafDirectory mirrors DocumentFile → File using ContentResolver.openInputStream + FileOutputStream, structurally parallel to the existing mirrorDirectory (File → DocumentFile).

Testing

Manual: configure Syncthing on two devices pointing at the same SAF-granted folder. After this change, syncBoth on either device should report non-zero peer count and actually pull events from the other device's database.

When using a SAF/Syncthing directory for sync, copySyncFilesToSafDir mirrors
internal → SAF (outbound) but nothing copies other hosts' databases FROM the
SAF directory INTO the app-private syncDir before syncBoth runs. So pull_all
always finds zero peers — the internal dir only ever contains our own staging.

Add copyPeerFilesFromSafDir() that mirrors SAF → internal for all hostname
directories except our own (our own staging is authoritative in the internal
dir). Call it in syncBothAsync's lambda before syncBoth, inside the executor
thread. Errors per entry are logged and skipped so a partially-accessible SAF
directory does not abort an otherwise healthy sync pass.

Fixes ActivityWatch#291.

Git-Session-Id: 0e07
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Closing for now per #291 (comment): Erik asked whether Android pull should wait for sync v2 (JSONL + zst compression + read-from-sync-staging) to avoid blowing up app storage. The architectural fix here (SAF → internal pre-copy before pull) is correct and ready, but should land only once the sync v2 storage model is in place.

Will reopen when sync v2 is ready.

@greptile-apps

greptile-apps Bot commented Sep 18, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 2/5

This PR is not safe to merge until peer files are copied atomically and SAF-derived destination paths are confined to the internal sync directory.

Findings

  1. P1 Partial Copies Replace Valid Data
  2. P1 Security SAF Names Escape Sync Directory
  3. P2 Removed Peers Remain Active

Summary

This PR adds an inbound SAF-to-internal pre-copy so native synchronization can discover peer databases before pulling them.

  • Recursively copies peer hostname trees from the configured SAF directory.
  • Skips the current device’s hostname and tolerates per-entry read failures.
  • Introduces unsafe direct replacement, unconfined provider-derived paths, and no reconciliation of stale peer files.

Diagram

sequenceDiagram
    participant P as SAF provider
    participant C as Peer pre-copy
    participant I as Internal syncDir
    participant S as syncBoth / pull_all
    P->>C: Enumerate peer DocumentFiles
    C->>I: Copy peer databases
    C->>S: Invoke syncBoth
    S->>I: Discover and import peer databases
Loading

Reviews (1) · Last reviewed commit: "fix(sync): copy peer files from SAF dir ..."

errors++
continue
}
inp.use { FileOutputStream(File(destDir, name)).use { out -> it.copyTo(out) } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Partial Copies Replace Valid Data

If a SAF read fails or is interrupted, FileOutputStream has already truncated the existing peer database. The error handlers leave that partial file in place, and syncBoth immediately tries to consume it. A transient provider error or a file being updated by Syncthing can therefore replace the last valid peer snapshot with a corrupt SQLite database and cause the pull to fail. Copy to a temporary file and replace the destination only after the copy succeeds.

errors++
continue
}
inp.use { FileOutputStream(File(destDir, name)).use { out -> it.copyTo(out) } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security SAF Names Escape Sync Directory

Provider-controlled DocumentFile.name values are used directly in File paths at the hostname, directory, and file levels. Because a user-selected third-party document provider can return names containing .. or path separators, a malicious or nonconforming provider can make this write escape syncDir and overwrite other app files, including app-private files when the internal-storage fallback is active. Reject unsafe path components and verify that every canonical destination remains inside the intended root.

How this was verified: SAF display names flow unchanged through File(destDir, name) into a writable file sink, while .. resolves outside the internal sync directory.


val localHostDir = File(destRoot, hostname)
localHostDir.mkdirs()
val (c, e) = copyFromSafDirectory(hostDir, localHostDir)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Removed Peers Remain Active

The inbound copy creates and overwrites entries but never removes internal peer files that have disappeared from the SAF tree. Since syncDir persists between runs and remote discovery scans retained hostname and device databases, removing a peer or renaming its hostname leaves the old snapshot participating in later pulls and accumulating indefinitely. Reconcile copied peer directories with the current SAF tree while preserving the intentionally skipped local hostname.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant