Skip to content

Improve file operation safety Part 3 - #239

Merged
5cript merged 62 commits into
mainfrom
fix/file-operation-safety-part3
Oct 16, 2026
Merged

5cript merged 62 commits into
mainfrom
fix/file-operation-safety-part3

Conversation

@5cript

@5cript 5cript commented Oct 16, 2026

Copy link
Copy Markdown
Owner

Safer file operations, part 3 of 3: explorer, file tracking, opener and defaults.

  • Explorer uploads, downloads, overwrite questions, renames, favorites and navigation share one SideModel flow for both sides
  • The filter keeps only selected items that stay visible (SelectionModel); new native nui-sftp-frontend-test target
  • Drops onto .. go to the parent and drops onto a directory link go into it; new names are checked by their own side's rules
  • Side model RPC replies and dialog answers run only while their owner exists
  • File tracking bookkeeping lives in a hidden .nui-sftp-tracking directory; watch handles no longer keep watchers alive
  • File tracking keeps one upload per changed path, handles editors that save through a temp file, and has natively tested helpers
  • Dead tracking instances are cleaned up at startup (after 24 h)
  • Declining to open a risky download removes the download, the watch and the instance
  • The opener uses per-platform risky-file lists and reads its policy from an atomic snapshot
  • Process::spawn uses one launcher call; mistyped command line options are reported
  • Dropped text and the numbered terminal saves are parsed or planned by natively tested helpers
  • Inherit Permissions and Try Continue are on when a profile leaves them unset, and the settings show that
  • Title Case dialog headers, clearer help and dialog texts in both languages, and multi-line toasts
  • Tests compile and pass on Windows

5cript added 30 commits October 16, 2026 16:18
Changing or clearing the explorer filter keeps only the items that were
selected and visible, so items a filter hid while Ctrl+A or a shift
range selected them never become selected when they show again; the
index logic of the selection is a SelectionModel with native tests in
the new nui-sftp-frontend-test
Uploads and downloads from the explorer share one flow in SideModel: the
candidates, the transfer question, the existence check
(FileEngine::existsBatchLocal for local destinations) and the overwrite
questions decided by OverwriteDecisions, which accepts an absent
destination without consent to overwrite and "All" only for existing
ones; the question for a single download names the destination like the
one for an upload
Renaming in the explorer is one flow in SideModel for both sides: the
local side renames through FileEngine::renameLocal like the remote side
through FileEngine::rename, both ask before replacing an existing item
and show failures with the texts of their own side, the local side no
longer with the remote side's texts
The side models share favorites, their context menu entries and
directory navigation in SideModel, both go through the checked item
paths so a name that is no single path segment is refused instead of
navigated to or added; local listings and properties read entries with
one directoryEntryOf, backendCompressionCode lives next to the archive
codecs, and opening a remote file with or without watching is one
function
Items dropped on the ".." entry go into the parent directory and items
dropped or pasted onto a link to a directory go into it; a drop or paste
onto a directory whose name is not valid UTF-8 is refused with a message
instead of going elsewhere
Navigating normalizes the path with the side's path type, so a path
typed with a trailing separator lists the directory without it and ".."
leads to its parent; each side computes the parent with
RemotePath::parent or LocalPath::parent, which gives a drive root like
C:/ on Windows; a failed listing shows the listed directory and its
items again, sorted, without listing it again on either side
Synchronize is offered only for directories whose names are valid UTF-8,
so a sync never starts on the display name of a directory that cannot be
named in a path
Listings move their entries into the explorer items instead of copying
them, and the explorer sorts with one comparator that reads names as
string views, puts the parent link first by rotating it to the front and
orders directories before files; the order is NuiFileExplorer::sortItems
with native tests
New items and renames check the name with the rules of their own side,
so a remote name may contain a backslash and a Windows name is refused
for the Windows reasons, and the invalid name dialog says which rule the
name breaks; both sides ask for a new item through
SideModel::promptNewItem with their own texts
Delete confirmations list the full path of every item on both sides, and
a local listing reply reaches the explorer only through
SideModel::onDirectoryListing, which drops a reply for a directory the
side has left and shows a failed listing with the same dialog as the
remote side
RPC replies and dialog answers of the side models, the file engine's
channel creation, the frontend operation queue and opening a local file
run only while their owner exists, so closing a session while a listing,
a transfer request or a confirmation is pending no longer touches the
closed session
Archive names are built with fmt, the side models fill places, path
suggestions and bulk delete entries with algorithms, "All" in the
non-empty directory question takes the remaining directories in one
insert, and the archive transfer docs describe what the dialog does
The lock and metadata of a file tracking instance live in its hidden
.nui-sftp-tracking directory, so a tracked remote file named
metadata.json or <id>.lock no longer collides with them; instance
directories of older builds are still recognized and cleaned up by their
legacy lock and metadata, which get the death timestamp mirrored, and
fileStates lists directories and leaves the bookkeeping out
An efsw watch handle no longer keeps the watcher of its instance alive,
so destroying or cleaning up an instance stops its watcher before the
listener goes away, and assigning a watch handle removes the watch it
held
File tracking has pure, natively tested helpers for its bookkeeping:
serverChanged compares a known server state with the current one,
remotePathOfChange maps a local change, meaningOfMove tells a rename
from a save through a temporary file, KnownServerContent holds what the
server has and PendingUploads keeps one upload per path and holds back
changes below a directory being uploaded
A confirm dialog that another one replaces runs its onClose without a
button before the new one shows, so every caller hears exactly once how
its dialog ended
FileTracking::createInstance replies with the canonical instance
directory, spelled like the paths of the change events and of fileStates
that the frontend maps onto it
File tracking keeps one upload per changed path from the change until
the server's new state is recorded, so a save during an upload no longer
asks falsely and the status shows Uploading for the whole upload;
server-changed questions wait for each other and one replaced by another
dialog clears its upload; a move over a known file (an editor saving
through a temporary file) uploads the destination and removes the
source's server copy instead of a failing rename; a new directory is
uploaded once with the changes below it waiting for it; deletes are
mirrored only for what the server is known to have; entries live in a
map by instance id and every callback runs only while the panel exists
The opener judges risky files with shared, natively tested helpers and
per-platform lists: Windows adds Java, Python, ClickOnce, MSIX/AppX and
settings shortcuts to AssocIsDangerous, macOS adds location files,
preference panes, screen savers and configuration profiles and judges
and opens a symlink as its target, Linux counts an execute bit only for
ELF or #! content and adds deb, rpm, flatpak and snap installers
RpcFilesystem::open reads the risky-file policy from an atomic snapshot
that every state save refreshes, instead of reading the state cache from
its strand while a save replaces it
The help texts name what the settings do: marking downloads covers every
download and lists the Mark of the Web and quarantine effects (warnings,
Office Protected View with macros blocked, PowerShell RemoteSigned,
Gatekeeper) and no effect on Linux; the risky-file policy covers Open
With and the Linux execute-bit rule; the PTY option says what a Windows
session without a PTY cannot do
Process::spawn builds the child with one launcher call, the platform's
default launcher with CREATE_NO_WINDOW and wide arguments on Windows,
finds the executable in one const step, and its Windows exit wait holds
the process only weakly
parseProgramOptions returns the help or a parse error with the help
instead of printing it, a parse error exits with 1, and on Windows main
attaches the console it was started from for --console or, lazily, for
that message, so a mistyped option is reported
Dropped text is parsed by natively tested helpers: text/plain splits
into trimmed lines so paths with spaces stay whole, a file URL with an
empty or localhost authority names a local path and another host a UNC
path on Windows only, the drive letter is unwrapped only on Windows and
only for /X:, decoded paths with NUL or invalid UTF-8 are refused, and
plain text counts only as an absolute path of the backend's platform
Saving all terminals of a closed session plans the numbered names with a
natively tested NumberedSaves state object, gives every terminal its own
attempts at a free name, and tells the user which names were written and
which terminals could not be saved; the chain holds the save state, no
longer the panel
Dialog headers are Title Case in English and the texts of this branch
say what the code does: skipped sync entries are counted and explained
in one short notice, block reasons name special files, Windows links,
case and normalization, refusals and partial deletes say that nothing or
everything else was deleted, the risky-file, Mark of the Web and file
tracking texts read plainly in both languages, and the overwrite
question drops its unused description
Toasts keep the line breaks of multi-line messages and show at most
eight lines, the notification log has the rest
The backend helpers exposed for tests live in named namespaces like the
others there: BulkTransferDetail::treeEntryPaths and
ScanProgressDetail::sequentialScanProgress instead of a global Detail
Removed the includes of paths/native.hpp from the operation queue,
session, bulk upload and transfer, delete operation and Windows drag and
drop sources, none of which use it
The terminal exit callback names and the node command lines are built
with fmt::format
5cript added 28 commits October 16, 2026 16:18
FileTracking::discardContent removes declined content of a tracking
instance: the watches end first, links are removed and never followed,
paths outside the instance directory or in its bookkeeping are refused,
and an instance left without content is destroyed
File tracking rows are found by their instance, and
FileTrackingPanel::discard ends a watch like Stop and removes the
declined download through FileTracking::discardContent
Declining to open a downloaded file that can run code removes the
download: Open and Open and Watch end the watch, remove the local copy
and end the tracking instance, a local file opened in place stays
The orphaned count of File Tracking follows Stop and declined downloads
Transfers inherit the source's mode when a profile does not set Inherit
Permissions, like the seeded default profile: a downloaded and
re-uploaded 0644 file stays 0644 and a script keeps its execute bits;
Persistence::defaultInheritPermissions is the one default for the
operation options, the seeded profile and the queue, and an explicit
false still uses the configured or default modes
Inherit Permissions shows as on in the settings while a profile does not
set it, the effective default, and engaging it starts from on; a
disengageable setting can be given the value its unset state stands for
with Setting::fallback
Transfers continue a partial temp file when a profile does not set Try
Continue, like the seeded default profile, and the settings show Try
Continue and Mark Downloads as Internet Files as on while unset;
Persistence::defaultTryContinue and defaultMarkAsDownloaded are the one
default for the operation options, the seeded profile, the archive
download and the settings
markAsDownloaded has tests: it keeps the content, Windows reads back the
internet zone, macOS the quarantine attribute and a missing file's
error, and Linux leaves files unmarked
The command store tests compile on Windows: the read-only checks ask
whether they run as root only where there is a root
The LocalPath JSON round trip test uses an absolute path of the platform
it runs on, and the Linux execute-bit risk test does not run on Windows,
where every file reads as executable
@5cript
5cript enabled auto-merge (squash) October 16, 2026 14:22
@5cript
5cript merged commit baff639 into main Oct 16, 2026
3 checks passed
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