Documentation
¶
Overview ¶
Package filesync keeps a local folder and a remote filex folder in step.
⚠ Not named `sync`: internal/sync already means something else here (walking a storage to refresh the catalog). This package is the Dropbox-shaped thing — a folder on someone's disk mirrored against a server path.
The design is deliberately split in two:
- plan.go is a PURE function. Local snapshot + remote snapshot + the baseline from the last run go in, a list of actions comes out. No disk, no network, no clock. This is where every decision about someone's files is made, and it is decided in code that a test can drive through every branch without a server.
- engine.go performs those actions and writes the new baseline.
Sync code deletes files for a living. The rules below are chosen so that the failure mode is "too many copies", never "the file is gone".
Index ¶
- Constants
- Variables
- func DefaultStoreDir() (string, error)
- func FingerprintSnapshot(s Snapshot) string
- func IgnoredName(name string) bool
- func LocalFingerprint(p Pair) (string, error)
- type Action
- type ActionKind
- type Baseline
- type BaselineEntry
- type BusyError
- type Engine
- type ListedFile
- type Listing
- type LockHolder
- type Node
- type Options
- type Pair
- type PairLock
- type ProgressEvent
- type RemoteFS
- type RemoteLister
- type Result
- type Side
- type Snapshot
- func ListLocalDir(root, dir string) (Snapshot, error)
- func ListRemoteDir(ctx context.Context, api RemoteLister, remoteRoot, dir string) (Snapshot, error)
- func WalkLocal(root string) (Snapshot, []string, error)
- func WalkRemote(ctx context.Context, api RemoteLister, remoteRoot string, ...) (Snapshot, error)
- type Store
- func (s *Store) AddPair(p Pair) (Pair, error)
- func (s *Store) ConfirmHeld(pairID string) (int, error)
- func (s *Store) DiscardHeld(p Pair, now time.Time) (moved, kept int, err error)
- func (s *Store) Holding(pairID string) (bool, error)
- func (s *Store) ListTrash(pairID string) ([]TrashItem, error)
- func (s *Store) LoadBaseline(pairID string) (Baseline, bool, error)
- func (s *Store) LoadHeld(pairID string) (map[string]string, error)
- func (s *Store) LoadPairs() ([]Pair, error)
- func (s *Store) LockPair(id string) (*PairLock, error)
- func (s *Store) MovePairLocal(id, newLocal string) (Pair, error)
- func (s *Store) PruneTrash(pairID string, days int, now time.Time) (int, error)
- func (s *Store) RemovePair(id string) error
- func (s *Store) SaveBaseline(pairID string, b Baseline) error
- func (s *Store) SavePairs(pairs []Pair) error
- func (s *Store) TrashLocal(pairID, root, rel string, now time.Time) error
- type TrashItem
Constants ¶
const (
DefaultCheckpointEvery = 50
)
DefaultCheckpointEvery is how many settled transfers pass between two mid-run baseline writes; checkpointInterval caps the wait in time.
const DefaultTransfers = 4
DefaultTransfers is how many uploads/downloads run concurrently. Four is enough to stop a tree of small files being priced at one full round-trip each, without stampeding a small server.
const MassUploadThreshold = 100
MassUploadThreshold is how many local-only files a FIRST run may upload into a server folder that already has content before it stops and asks.
const TrashRetentionDays = 30
TrashRetentionDays is how long a file deleted by sync is kept on this machine before it is really gone.
Sync deletes files because the OTHER side lost them, which is exactly when a mistake is least visible: a folder emptied on a phone quietly empties the laptop too. The local copy is the last line of defence, so it is kept for a month rather than a few days.
Variables ¶
var ErrLocalChanged = errors.New("changed on this computer while it was being synced")
ErrLocalChanged means a file on this computer changed after the run looked at it, so the action planned for it (replace it with the server's copy, or move it to the trash) was NOT carried out. Same consequence as ErrRemoteChanged, from the other side.
var ErrPairBusy = errors.New("another filex on this computer is syncing this pair")
ErrPairBusy is what a pass returns when another process on this computer holds the pair: nothing was read or written.
var ErrRemoteChanged = errors.New("changed on the server while it was being synced")
ErrRemoteChanged means the server refused a conditional upload: the file was written on the server (typically saved in the browser) after the listing this run planned from. Nothing was written. The path keeps its old baseline row, so the next pass sees BOTH sides changed and keeps both.
var ErrRemoteNotFound = errors.New("remote folder not found")
ErrRemoteNotFound marks a listing error that means "this folder does not exist on the server" — the ONE kind of failure WalkRemote may skip (a folder deleted between its parent's listing and its own). The API adapter wraps a 404 with it.
Every other error fails the walk. A folder that merely could not be listed — a timeout, a 5xx, the half-dead connection this client now detects — must never come back as "gone from the server": the planner would read that as a delete and bin the local copy of the whole subtree, and if it happened in the settle pass the baseline would lose the subtree instead, turning every uploaded file in it into a conflict pair on the next round. The walk is eight listings wide now, so one bad second can touch eight folders at once.
Functions ¶
func DefaultStoreDir ¶
DefaultStoreDir is $FILEX_SYNC_DIR when set, otherwise ~/.filex/sync (beside the CLI's own config, which honours $FILEX_CLI_CONFIG the same way).
⚠ The override is not a convenience. This directory holds the pairs, the per-pair baselines and the local trash — which contains real copies of files the engine deleted. The desktop app's PORTABLE build runs from a USB stick on a machine that is not the user's, and everything it keeps has to live in its own folder so that deleting that folder leaves nothing behind. Without this, a portable copy would seed somebody else's home directory with another person's deleted files.
func FingerprintSnapshot ¶
FingerprintSnapshot digests a snapshot: two equal fingerprints mean the same paths with the same signatures. The watcher keeps one per pair to tell, with a local walk and no request at all, whether this side changed since the last run.
func IgnoredName ¶
IgnoredName reports whether a file or folder name is never sync material: the engine's own state, OS junk, filex's own reserved names (syspath) and its half-written downloads. A file-system watcher uses it to drop events the walk would ignore anyway — above all the `.filex-part-*` temporaries every download creates, which would otherwise wake the engine up once per file it writes.
func LocalFingerprint ¶
LocalFingerprint walks a pair's local side exactly as a run does and fingerprints it. It is the same value a full run reports in Result.LocalFingerprint when nothing changed on this machine in between.
Types ¶
type Action ¶
type Action struct {
Kind ActionKind
Rel string
// ConflictName is set only for ActionConflict: the name the SERVER's copy
// is saved under locally so the user's own edit keeps its filename.
ConflictName string
// Reason is human-readable and shown in `filex sync run` output. Sync is
// the one subsystem where people need to see why their file moved.
Reason string
// RemoteMod carries the server file's mtime (Unix millis) on download
// and conflict actions, so the local copy can be stamped with it. 0 =
// the storage reported none.
RemoteMod int64
// RemoteSize carries the size the server LISTED on download and conflict
// actions. The engine refuses to install a body of any other length: it is
// how a status report sent in place of a file (a 202 "preparing" JSON, a
// proxy's error page) is told apart from the file.
RemoteSize int64
// Mixed marks a conflict between a folder on one side and a file on the
// other. Nothing is downloaded or uploaded for it: the user is told.
Mixed bool
// Hold marks a conflict planned while the pair holds its local-only items
// for a decision (Pair.HoldNew): it is settled if both sides turn out to
// hold the same bytes, and otherwise left exactly as it is.
Hold bool
// LocalSig / RemoteSig are the two sides' signatures AT PLAN TIME
// (Node.Signature(); "" = absent on that side). The plan is a decision
// about the world as it was when the snapshots were taken, and these are
// what let the engine notice that the world moved before the decision
// was carried out:
//
// - a download or local delete only replaces/removes the local file if
// it still carries LocalSig — otherwise it was edited here in the
// meantime and the "changed in both places" rule applies next round;
// - an upload is sent with RemoteSig as its precondition ("expect"), so
// the server refuses it if the file was saved in the browser after the
// listing — same rule, from the other side;
// - the new baseline row is built from them, never from a later
// snapshot (see ledger in engine.go).
//
// ⚠⚠ Measured before this existed: a web edit landing while a run was
// already under way was written into the baseline as "agreed" by the
// settle pass and never downloaded at all — the local file stayed old,
// silently, until the next edit on either side.
LocalSig string
RemoteSig string
}
Action is one unit of work. Rel is always the pair-relative path.
func Plan ¶
Plan decides what to do. It is pure: same inputs, same output, no I/O.
The rules, in the order they are applied per path:
both sides unchanged since the baseline → nothing present on one side only, never synced → copy it to the other side changed on one side only → copy that change over changed on both sides, and differently → conflict (keep both) gone from one side, unchanged on the other → delete the other (not on a first run) gone from one side, CHANGED on the other → resurrect: copy the change back
The last rule is the one that matters most: a delete never beats an edit.
type ActionKind ¶
type ActionKind string
ActionKind is what the engine should do about one path.
const ( ActionUpload ActionKind = "upload" // local → remote ActionDownload ActionKind = "download" // remote → local ActionMkdirRemote ActionKind = "mkdir_remote" // create a folder on the server ActionMkdirLocal ActionKind = "mkdir_local" // create a folder on disk ActionDeleteLocal ActionKind = "delete_local" // remove locally (into local trash) ActionDeleteRemot ActionKind = "delete_remote" // ActionConflict keeps both versions: the remote copy is downloaded beside // the local one under ConflictName, then the local file is uploaded. ActionConflict ActionKind = "conflict" )
type Baseline ¶
type Baseline map[string]BaselineEntry
Baseline is the state carried between runs.
func NextBaseline ¶
NextBaseline rebuilds the carried state from the two snapshots taken AFTER a run. Paths still disagreeing (a conflict nobody resolved) are left out, so the next run looks at them again rather than assuming they are settled.
type BaselineEntry ¶
type BaselineEntry struct {
Local string `json:"local"` // Signature() of the local node
Remote string `json:"remote"` // Signature() of the remote node
IsDir bool `json:"is_dir"`
}
BaselineEntry records what a path looked like on BOTH sides when they were last agreed. Absence from the baseline means "never synced".
type BusyError ¶ added in v0.44.2
type BusyError struct {
Pair string
Holder LockHolder
}
BusyError is ErrPairBusy for one pair, with what the lock file says about its holder.
type Engine ¶
type Engine struct {
Pair Pair
API RemoteFS
Store *Store
// TrashDays is how long locally-deleted files are kept before being
// dropped. Zero means the default (TrashRetentionDays).
TrashDays int
// Transfers caps how many uploads/downloads run at once. 0 means
// DefaultTransfers; 1 restores the fully serial engine.
Transfers int
// CheckpointEvery is how many settled transfers may pass before the
// baseline is written mid-run (see ledger). 0 means
// DefaultCheckpointEvery.
CheckpointEvery int
// Log receives one line per action. Optional.
Log func(string)
// Progress receives one short status line per phase — inventory counts,
// transfer counts, settling — and stays on even when the per-action Log
// is silenced. The desktop app runs the engine with --quiet and mirrors
// the last stdout line into its panel; before this hook a large first
// sync spent its whole inventory phase (minutes of per-folder listings)
// printing nothing, and looked dead enough that people cancelled it.
// Optional.
Progress func(string)
// OnProgress receives the same reports as figures (ProgressEvent). A
// caller that says them in words of its own sets it: `filex sync run
// --json` turns each into an event whose sentence is in the reader's
// language, so the desktop app no longer reads the English line above
// with regular expressions. Optional; independent of Progress.
OnProgress func(ProgressEvent)
// StopOn, when set, is asked about every action that failed; true ends
// the pass at once — the rest of the plan is not attempted, the ledger is
// still written — and the pass returns that error. The CLI sets it to
// "the server no longer accepts this token": a revoked token must not be
// tried once for every remaining file.
StopOn func(error) bool
// Now is injectable for tests.
Now func() time.Time
// Lock is the pair's lock (Store.LockPair) when the caller holds it
// across passes — `filex sync run --watch` does, for as long as it syncs
// the pair. nil: each pass takes the lock itself and gives it back when
// it ends. Either way no pass runs without it: a pair another process on
// this computer is syncing fails the pass with a *BusyError before
// anything is read (lock.go).
Lock *PairLock
// contains filtered or unexported fields
}
Engine runs one pair.
func (*Engine) LocalDirChanged ¶
LocalDirChanged reports whether one local folder's direct children differ from what the baseline last agreed — without touching the network.
⚠ This is how the engine ignores its OWN writes. Every download it makes raises file-system events (the rename into place, the mtime stamp), and a watcher that answered each one with a server listing would spend a round trip per downloaded file finding out that nothing happened. The download records exactly the signature it left on disk, so an unchanged folder is recognised from the disk alone. A genuine edit changes size or mtime and fails this check.
func (*Engine) Run ¶
Run performs one full pass: snapshot both sides, plan, apply, record what was agreed.
A failed action does not abort the run — the rest of the pair still syncs and the failure is reported. A path whose action failed keeps the baseline row it had, so the next pass looks at it again; that is what stops one broken file from permanently wedging a folder, and it is also what makes a change that raced the run (an edit saved while it was going) come back as a change instead of vanishing into the baseline. See ledger.
func (*Engine) RunDirs ¶
RunDirs reconciles only the given folders (pair-relative, "" = the pair's root). It is what a live change triggers; Run stays the full pass.
A pair with no history yet takes a full pass instead — the first run of a pair is a merge of both trees and must see all of them — and so does a pair whose local folder is missing (the full pass owns that refusal).
type ListedFile ¶
type ListedFile struct {
Basename string
IsDir bool
Size int64
LastModified int64 // Unix millis; 0 = the storage does not report one
}
ListedFile is one row of a remote listing.
type Listing ¶
type Listing struct {
Files []ListedFile
}
Listing is one remote directory. It mirrors the server's index response, reduced to what sync actually reads.
type LockHolder ¶ added in v0.44.2
type LockHolder struct {
PID int `json:"pid"`
Exe string `json:"exe,omitempty"`
Since time.Time `json:"since"`
}
LockHolder is who took a pair's lock, as that process wrote it down.
func (LockHolder) String ¶ added in v0.44.2
func (h LockHolder) String() string
String is "process 1234, C:\…\filex.exe", or "" when nothing is known.
type Node ¶
type Node struct {
Rel string
IsDir bool
Size int64
// ModMillis is the modification time in Unix milliseconds. Local and remote
// clocks are NEVER compared to each other — see Signature.
ModMillis int64
}
Node is one file or directory in a snapshot, keyed by its slash-separated path relative to the pair root ("" is the root itself and never appears).
func (Node) Signature ¶
Signature is the cheap "has this changed?" test for one side.
⚠ Local and remote signatures are never compared against each other. An upload gives the server its own mtime, so the two sides legitimately differ the instant after a successful sync; comparing them would make every file look permanently conflicted. Instead each side is compared against what IT looked like at the end of the last run, which is what the baseline stores.
type Options ¶
type Options struct {
// FirstRun suppresses every delete. With no baseline there is no way to
// tell "you deleted this" from "you have not downloaded this yet", and
// guessing wrong empties someone's folder. A first run is a union merge.
FirstRun bool
// Now stamps conflict copy names. Zero value means the caller did not care
// (tests); the engine always sets it.
Now time.Time
}
Options tunes a plan.
type Pair ¶
type Pair struct {
ID string `json:"id"`
Local string `json:"local"` // absolute path on this machine
Remote string `json:"remote"` // adapter://path on the server
// Account is an opaque label the desktop app uses to remember which signed-in
// server this pair belongs to. The engine only carries it.
Account string `json:"account,omitempty"`
Paused bool `json:"paused,omitempty"`
// File marks a single-file pair: Local is a file path and Remote names a
// file on the server. Same planner, same rules, same trash — the
// snapshots just carry one entry.
File bool `json:"file,omitempty"`
// HoldNew says the pair holds this machine's unknown items for a decision
// (hold.go), and Held how many. Both are READ from held/<pair-id>.json by
// Store.LoadPairs and never written to pairs.json (see holdFile): they are
// on the pair only so `sync list --json` — which the desktop app reads —
// carries them.
HoldNew bool `json:"hold_new,omitempty"`
Held int `json:"held,omitempty"`
}
Pair is one folder mapping.
type PairLock ¶ added in v0.44.2
type PairLock struct {
PairID string
// contains filtered or unexported fields
}
PairLock is a held pair lock. Release gives it back; the operating system does the same when the process ends.
func (*PairLock) Release ¶ added in v0.44.2
func (l *PairLock) Release()
Release gives the lock back. Safe to call more than once.
func (*PairLock) Unenforced ¶ added in v0.44.2
Unenforced reports a lock taken on a file system that cannot lock: nothing stops a second engine on this pair.
type ProgressEvent ¶ added in v0.54.0
type ProgressEvent struct {
Phase string
Here int
Listing bool
Listed int
ListedFolders int
Done, Total int
BytesDone int64
BytesTotal int64
ETA time.Duration
HasETA bool
Held int
}
ProgressEvent is one progress report in figures (Engine.OnProgress) — the same report Engine.Progress gets as an English line.
Phase is inventory | plan | transfer | settling | hold:
- inventory: Here is what was found on this computer; once the server listing reports, Listing is true and Listed / ListedFolders are its items and folders so far (Here still set).
- plan: Total is the changes to make.
- transfer: Done / Total actions; BytesDone / BytesTotal once there are bytes to move; ETA the estimate of the time left, HasETA when there is one (the first seconds have none).
- settling: Done of Total changes recorded.
- hold: Held is the items held for a decision.
type RemoteFS ¶
type RemoteFS interface {
RemoteLister
// Download writes the file at remote into w. size is what the listing
// said (-1 = unknown); an implementation should refuse a body of any
// other length before writing it, and the engine checks again after.
Download(ctx context.Context, remote string, size int64, w io.Writer) (int64, error)
// Upload writes localPath to remote. expect is the overwrite precondition
// the server checks before it writes: "none" (the file must not exist
// yet) or the listing signature "<size>:<mtime-ms>" the plan was made
// from. A refusal must come back wrapping ErrRemoteChanged.
//
// It returns the file as the server listed it right after the write, or
// nil when it cannot say — the engine then lists the folder itself. That
// signature is what the baseline records for the server side; see ledger.
Upload(ctx context.Context, localPath, remote, expect string) (*ListedFile, error)
Mkdir(ctx context.Context, remote string) error
Remove(ctx context.Context, remote string) error
}
RemoteFS is everything the engine does to a server. Kept as an interface so the engine can be driven end to end in a test against a fake server, which is the only honest way to test code that deletes files.
type RemoteLister ¶
RemoteLister is the slice of the API client this package needs. Declaring it here rather than importing cliclient.Client keeps the planner and the walkers testable with a fake, and stops this package from growing a dependency on every REST call the CLI happens to support.
type Result ¶
type Result struct {
Planned int
Applied int
Uploaded int
Downloaded int
DeletedLocal int
DeletedRemot int
Conflicts int
Skipped []string
Errors []string
// Raced lists actions that stood down because the other side changed
// while the pass was running (ErrLocalChanged / ErrRemoteChanged). They
// are not failures: nothing was lost, and the next pass keeps both
// versions. Reported apart from Errors so a live client can run that
// next pass at once instead of showing an error.
Raced []string
FirstRun bool
Duration time.Duration
// Identical counts conflicts that turned out to hold the same bytes on
// both sides: no copy was made and nothing was uploaded.
Identical int
// Held counts local items left alone for a decision (hold.go).
Held int
// Retry lists the folders (pair-relative, "" = the pair's root) holding
// an action that failed, so a caller can retry just those (RunDirs)
// instead of walking the whole pair again.
Retry []string
// LocalFingerprint digests the local side as a FULL pass left it: the
// snapshot the plan was made from, with what the pass itself wrote on
// this machine folded in. ⚠ Never a later walk — an edit made while the
// pass ran would be folded in too, and a watcher comparing fingerprints
// would never see it as a change. "" for a targeted pass, and when the
// pass did not get that far.
LocalFingerprint string
}
Result reports what one run did. Every field is a measurement, not an intention: Applied counts actions that actually completed.
type Side ¶
type Side string
Side names the two halves of a pair. Used in conflict copy names so the surviving file says where it came from.
type Snapshot ¶
Snapshot is one side's tree, indexed by relative path.
func ListLocalDir ¶
ListLocalDir snapshots ONE local folder (dir is pair-relative, "" = the root) without descending: the direct children only, keyed by their full pair-relative path. It is the local half of a targeted pass — a change announced for one folder is reconciled by looking at that folder, not by walking the whole tree. The folder itself missing is reported as os.ErrNotExist so the caller can widen the scope to its parent.
func ListRemoteDir ¶
ListRemoteDir is ListLocalDir's server half: one listing, direct children only, keyed by full pair-relative path.
func WalkLocal ¶
WalkLocal snapshots a directory tree. Unreadable entries are skipped rather than failing the run: one locked file must not stop the other thousand from syncing. The names of skipped entries are returned so the caller can report them instead of silently doing less than it claimed.
func WalkRemote ¶
func WalkRemote(ctx context.Context, api RemoteLister, remoteRoot string, progress func(dirsListed, itemsSeen int)) (Snapshot, error)
WalkRemote snapshots a server folder by listing it breadth-first, one level at a time with listWorkers concurrent listings per level. The snapshot itself is only written by this goroutine — workers hand their listings back and the merge stays single-threaded.
remoteRoot is an `adapter://path` prefix; every returned Node.Rel is relative to it, so local and remote snapshots share one key space and the planner can compare them directly.
progress, when non-nil, is called after each merged directory with the running totals.
type Store ¶
type Store struct{ Dir string }
Store holds the on-disk state for every pair: the baseline that makes change detection possible, and the trash that makes deletes recoverable.
Layout under Dir:
pairs.json the configured pairs baseline/<pair-id>.json last agreed state held/<pair-id>.json a hold: local items kept for a decision (hold.go) trash/<pair-id>/<stamp>/... files this engine deleted locally
func (*Store) AddPair ¶
AddPair validates and stores a new mapping.
⚠ Overlapping local roots are refused. Two pairs sharing a directory each see the other's writes as user edits and push them back and forth forever.
func (*Store) ConfirmHeld ¶
ConfirmHeld ends a hold so the next run sends the held items to the server. It returns how many the pair held.
func (*Store) DiscardHeld ¶
DiscardHeld ends a hold by moving the held files into the pair's local sync trash (recoverable for TrashRetentionDays), so the next run makes this side match the server: files that are only here are gone, and a file that differs comes down from the server. A folder that held only such files is removed once empty. A file whose signature changed since it was held is not what the person decided about: it is left in place and counted in kept.
⚠⚠ So is a file that has a baseline row by now. A row means the engine has since recorded it as in step on BOTH sides — an identical copy turned up on the server and was adopted, say — and trashing it here would make the next run carry the deletion to the server.
func (*Store) Holding ¶
Holding reports whether the pair is holding its unknown local items for a decision.
⚠ An unreadable hold file reads as HOLDING. The hold exists to stop a stale mirror being uploaded over a cleaned-up server; failing open would do exactly that. `filex sync confirm` or `discard` rewrite the file either way.
func (*Store) LoadBaseline ¶
LoadBaseline returns the state carried from the previous run. The bool reports whether a baseline existed at all: the first run of a pair must not delete anything, and this is how the engine knows.
func (*Store) LoadHeld ¶
LoadHeld returns the items the pair holds for a decision, keyed by pair-relative path, with the local signature each had then ("dir" for a folder). Nothing held is an empty map.
func (*Store) LoadPairs ¶
LoadPairs reads the configured folder pairs. A missing file is not an error: it means nothing has been paired yet.
func (*Store) LockPair ¶ added in v0.44.2
LockPair takes the pair's lock without waiting. A pair another process holds is a *BusyError (errors.Is ErrPairBusy).
⚠ The same process asking twice is refused too — the lock is per open file, not per process — which is what makes a second lock taken by mistake (an engine that did not get its caller's) fail loudly instead of passing.
⚠ A file system that cannot lock at all is not "busy": refusing there would stop sync for good on a home directory mounted from a server without a lock daemon. The lock is then held in name only, exactly the engine as it was before locks, and Unenforced says so.
func (*Store) MovePairLocal ¶
MovePairLocal repoints a pair at a new local path, keeping its identity — and, crucially, its BASELINE: baselines store relative paths only, so a mirror that physically moved keeps its whole history and the next run is an ordinary incremental pass. The alternative (remove + re-add) throws the baseline away, and the first-run merge that follows conflicts every file whose two sides carry different mtimes — which is every file this machine ever uploaded.
func (*Store) PruneTrash ¶
PruneTrash drops trashed items older than days. Returns how many were removed.
func (*Store) RemovePair ¶
RemovePair forgets a mapping. Files on either side are left exactly where they are — unpairing is not deleting, and a user who wanted the files gone would have deleted them.
func (*Store) TrashLocal ¶
TrashLocal moves a path out of the sync folder instead of deleting it.
The engine NEVER calls os.Remove on user content: everything it removes from disk lands here first, under a per-run timestamp, and is only really gone once PruneTrash finds it older than the retention window.