github axpdev-lab/aeroftp v4.2.0
AeroFTP v4.2.0

5 hours ago

[4.2.0] - 2026-09-21

Measured Against rclone, Read Commit by Commit

A cycle of tuning and repair rather than of features, and the longest since the project started: 165 tracked changes over 169 commits in three weeks, most of them fixes and many of them found by measuring rather than by reading. The transfer engine went through a live review battery against rclone on the same links, payloads and servers, and every gap it found (a speed limit most providers ignored, one-way sync re-uploading unchanged trees, a new SSH handshake for every SFTP file, serial scans and serial reads) was closed and measured again, so the DAG engine's performance is confirmed rather than assumed. Filebase joins as a production-ready S3 preset, S3 learns delta uploads, and AeroCrypt reaches its base goal on Linux and, above all, on Windows, where the Rust test suite now runs for the first time and a key-authenticated delta finally transfers only the changed bytes.

Every commit of the cycle was read by several independent reviewers, and the finding they returned most often had one shape: an answer that looked complete while part of the work had not been seen. That is why so many entries below are about scans, deletes and exit codes that now say what they did not cover, and it is also why the last work of the cycle went into the download path: a file assembled from several streams could be handed back built out of two different versions of the same object, with the right length and nothing to signal it, and the defect was reproduced on a live server before it was fixed. Nothing here is claimed that was not measured or pinned by a test, and where something is still open it is written down rather than left out.

Every change listed here is tracked, with its commit and its discussion, in the release tracker: AeroFTP 4.2.0, known issues, patches and updates.

What changes for you

Most of this release is repair, so the clearest way to read it is by what stops going wrong. The list below is the short version for people who use AeroFTP rather than read its code; everything in it appears again further down with the detail and the commit.

In the app

  • The comparison panel says how much is really out of sync. AeroSync Compare divided by the rows it lists, and it never lists identical files, so any two folders with a single difference read as 100% out of sync. It now divides by everything the scan examined: two folders that differ by one file out of two read as 50%, by count and by size, on local folders and against every remote and encrypted connection. A path that is a file on one side and a folder on the other is counted as the difference it is, instead of disappearing into the identical total.
  • Large downloads are faster, and they check what they hand you. A download above 250 MiB now runs on several connections by default, on numbers measured per protocol: 8 on SFTP, WebDAV and FTP, 4 on S3. That part is new in this release, and so is the check that goes with it: if the file changes on the server while it is being read, the download refuses to publish the result and reads it again on one connection instead of handing you a file built out of two versions.
  • A one-way mirror does not delete what it never synced. In receive-only, a local-only file used to be skipped on the first cycle and deleted on the second; in send-only the same happened to a file another person added to a shared folder. Both are fixed, and more broadly a sync that could not read part of a tree no longer deletes, downloads or uploads anything under the part it could not see.
  • Interrupted work resumes instead of starting over. The sync journal survives a run that ends with failed files, so Resume is still offered, and resuming transfers the files that had not been reached rather than repeating the ones that had.
  • Cancelling a large upload no longer leaves pieces behind on the server, and "Remove application data" in the Windows uninstaller now does what it says.
  • A 1 GiB upload peaks at about half the memory it used to, and a speed limit now actually applies to the cloud providers that used to ignore it.
  • Trash and restore behave across providers: Dropbox tells folders from files and restore no longer fails, Jottacloud can purge a single file, a whole folder or the entire bin, and the providers that cannot offer a recycle bin say so as their own limit instead of looking like a broken login.
  • Immich albums show their photos again, and "All assets" and "Favorites" list everything instead of the first page.
  • New connections: Filebase arrives as a ready S3 preset, Cloudflare R2 buckets created in a jurisdiction are reachable, and a Backblaze B2 bucket says on its card whether its default encryption is on, off, or not readable with that key.
  • About names the people behind the project, in all 47 languages, and About > Dependencies lists every direct dependency at the version the build actually used.

On the command line

  • Answers that tell a complete result from a partial one. check, cryptcheck, reconcile and sync-doctor used to give a verdict on a tree they had not fully read. They now report what they covered and exit 4 when the answer is partial, which is the difference between "these trees match" and "the part I could see matches".
  • sync --files-from bounds both sides of the run. It used to filter the local walk and not the remote one, so --direction upload --delete treated every unlisted remote file as an orphan to delete.
  • Speed limits reach every provider. --limit-rate and --bwlimit now cap S3, WebDAV, FTP and FTPS, Azure, Backblaze B2 and the HTTP cloud providers that kept their own byte loops.
  • --s3-upload-concurrency reaches the engine that uploads. It was accepted and ignored: measured over a 100 ms link with a 300 MB upload, the engine took 2.19 s at one part and 2.18 s at eight, which is no difference at all; it is now 6.03 s at one, 2.20 s at four and 1.99 s at eight, with the same default of four as before.
  • aeroftp-cli pwd prints the folder that relative paths resolve against, reading the saved profile, so it answers while the server is down.
  • Transfers that used to need a second attempt: an interrupted SFTP upload resumes with --partial, a put into a folder that does not exist creates it on SFTP, FTP, FTPS and WebDAV, and get --partial over a download that had already finished is a no-op again instead of an error.
  • hashsum --download hashes the bytes it downloaded rather than repeating the server's digest, which is the only version of that command that can detect a bad transfer.
  • Profiles exported to other tools point at the server the connection actually uses. The exporters were rebuilding addresses by hand: bookmarks carried a URL as the hostname, MinIO exports pointed at AWS, explicit-TLS FTP profiles were exported as cleartext. Every exporter now asks the connection, and an export that could not include a profile says so instead of dropping it.

Added

  • The people behind AeroFTP are named in the app and in the repository. A Team block in the About window, translated into all 47 locales, and a TEAM.md that lists who does what.
  • Filebase is a production-ready S3 preset. Quick Connect asks for the three fields the Filebase console names, with a catalog card, a grid tile and translations in all 47 locales. Filebase answers 501 to UploadPartCopy, so copies take the plain CopyObject path, and versioning and object tagging report "not supported" instead of failing on the wire. Endpoint classification now matches the host at a domain boundary, so a lookalike host is not taken for Filebase, and an rclone remote imported as provider = Other lands on the Filebase preset when its endpoint says so. Verified live against s3.filebase.io: 1 MB and 150 MB multipart round trips with matching SHA-256, server-side move and copy, and a sync upload.
  • S3 uploads of a large object this client uploaded before send only the parts that changed. For files above 200 MiB the client keeps a local record of per-part digests of what it uploaded (written only after the server's ETag certifies the upload, capped at 10,000 entries), matches a new version against it with no remote read, and assembles the object from server-side UploadPartCopy ranges, pinned to the previous object with x-amz-copy-source-if-match, plus ordinary part uploads for the changed ranges. The result is a plain object, byte-identical to the local file and carrying the source modification time. Any refusal other than an authentication or permission error falls back to a normal upload, a backend that refuses ranged copy is remembered for five minutes, and a crypt overlay never takes this path. Verified against MinIO for an edit in the middle and an append; an insertion at the start of the file is refused before any request, by design.
  • Cloudflare R2 buckets created in a jurisdiction are reachable. Such a bucket answers only on its own host; a picker next to the Account ID offers the EU and US jurisdictions and shows the host each produces, and the choice reaches the CLI, the MCP pool and schedulers through the profile. A saved R2 endpoint in the older format still has its Account ID recognised.
  • Jottacloud trash can purge a trashed folder and empty the whole bin. No JFS verb addresses a folder in the Trash view, so the purge goes through the folder's original path, guarded by a tombstone read-back so a live folder is refused; emptying uses the service's own purge call, behind a confirmation, and reports the counts it returns (@EhudKirsh, #397).
  • A Backblaze B2 bucket's default encryption is shown on its card. Three answers rather than a yes or no: enabled (with a lock whose strength comes from the algorithm the service named), not enabled, and unknown when the key is not allowed to read the setting, which is common with keys restricted to one bucket. No extra request is made: the value arrives with the listing read at connect.
  • aeroftp-cli pwd prints the folder every relative path resolves against, without connecting. It reads the saved profile, so it answers while the server is down; --json carries the profile name and id, protocol, host, base and resolved root.
  • AeroAgent records verified model capabilities and can send compatible OpenAI models through the native Responses API. Unknown models stay unverified and are treated conservatively, and tool continuations still use Chat Completions.
  • An Auto-Routing rule's fallback model now answers, and Ollama can be a routing target. The fallback runs when the preferred model fails with an error of its endpoint, never on a budget the user set, a cancelled request or once a token has streamed. A streaming failure now rejects instead of resolving quietly, and the local Ollama provider is no longer filtered out of the routing lists for having no API key.
  • Agents and MCP clients can tell an encrypted remote from a plain one. profiles --json, agent-bootstrap, agent-info and the MCP tool that lists saved servers (aeroftp_list_servers, also answering as server_list_saved and remote_list_servers) carry cryptOverlay (aerocrypt, rclone-crypt or null) and protocolClass, always present, while the binding's scopes never leave the vault.

Fixed

  • The comparison panel no longer reports every comparison as 100% out of sync. Reported against 4.1.9 with two local folders, two files in the first and one of the two in the second: the honest reading is one difference out of two entries, and the panel answered that everything was out of sync, on local folders and on every remote backend including the encrypted overlays. The comparison deliberately sends no row for an identical file, because a large tree would ship tens of thousands of rows nobody reads, and the panel derived its percentage from exactly those rows, so the identical share was always zero and numerator and denominator were the same number: 100% was not a measurement, it was the shape of the arithmetic. The three compare commands now return what the scan examined, in entries and in bytes, next to the differences, and the panel divides by that, so the count and the size figures both hold. Nothing unobserved is counted as identical, because the scan boundary already withdraws it before the comparison runs, which is the same rule that stops a partial scan from authorising a delete. Two defects found while closing it go with it: a path that is a file on one side and a folder on the other was discarded as directory noise and came back inside the identical total, so a real conflict could read as 0% out of sync, and each section's count was taken from the rows it carries rather than from what the scan counted, so the identical section showed zero next to a size that was not zero.
  • A file replaced by a symbolic link is no longer read as a deletion, and a download with no validator is checked against the size the server reports. Three defects found by the pre-release review, two of them older than this release. A local symbolic link is skipped when a folder is scanned, which is correct, but the skip was not recorded as a gap: AeroCloud then read that absence as a deletion on its next two-way cycle and removed the remote file, and the compare used by FTP and FTPS sessions and by the two-folder local view did the same, so a Mirror preset deleted the twin of a file or a folder someone had replaced with a link. Both now apply the same scan boundary the command line sync already used, on both sides, before anything is compared. The third is newer and belongs to the multi-stream work in this release: on WebDAV and Koofr, an object that publishes neither an entity tag nor a modification time skipped the check entirely, when the total size from the range response was available all along, so a replacement finished before the transfer ended was published at the old length with bytes from two versions.
  • A download that runs on several streams can no longer hand back a file made of two versions of the object. Each stream reads a different window, and nothing checked that the windows came from the same object: replace it while they are in flight, on the server or from another client, and the pieces are assembled into one file that has the right length, passes every check and is simply wrong. The object is now read before the windows start and again before the file is published, and the two readings are compared on the validator the service publishes, then on size, then on modification time, so a refusal says what changed. Where the protocol has a validator the windows carry it too: on S3 every range asks for the version the first reading saw, and the server refuses a window from a newer one rather than serving it. Two more things refuse instead of guessing: a plan whose size no longer matches the object never starts, so a file cannot be published cut to an old length, and a file that cannot be read again after the transfer is not published at all. A refusal is not a failed download: every surface falls back to a single stream, which reads one consistent view, so a file that grows while it is read still arrives. This covers every path that assembles a file out of parallel windows, named rather than counted because counting them is the part that went wrong twice: the shared transfer engine used by the queue, by sync and by cross-profile copies; the S3 and Backblaze downloaders; the shared HTTP path that WebDAV and Koofr take; the three SFTP paths, pooled, read-ahead and pipelined; the FTP one; and the command line's pget. Reproduced live over SFTP before the fix: a 300 MiB file replaced on the server while it was being downloaded arrived with a checksum matching neither the version before nor the one after, and the command reported success. On services that publish no validator, a replacement with the same size and the same timestamp to the second is still not detected, and a file being appended to while it downloads now uses one stream rather than several, because a size that moved cannot be told from a rewrite by reading the file alone.
  • That check now covers every path that reads a file on several connections at once, and it no longer costs a failed download. A machine review over the work above returned seven findings and all are closed. Backblaze refused where it should have fallen back, so a download failed instead of finishing on a single stream. The SFTP pipelined read, an opt-in path that opens several handles on different parts of one file, had been left without the check its sibling got. The progress bar froze for the whole of a restarted download, because the callback had been consumed by the attempt that was refused; it is shared between the two now. And a refusal is recognised by its type rather than by a sentence inside an error message, so an unrelated failure that happens to quote that sentence is no longer mistaken for one: a server answering 403 with those words used to send the transfer quietly to the fallback, and now fails as it should.
  • A download's stream count now travels with the reason it was chosen, and cannot be inherited from the transfer before it. The request carried an absent value that meant both "choose for me" and "one stream on purpose", so nothing below could tell the two apart; it now says which of the three it is (automatic, an explicit count, or one stream with a reason), and the resolved number reaches the transfer log, the MCP sync JSON and the shared command-line batch JSON together with where it came from. The numbers themselves do not change: 8 streams on SFTP, WebDAV, FTP and FTPS, 4 on S3, 1 elsewhere. Two real defects go with it: a provider left on multi-stream by an earlier transfer answered a later single-stream request with the old setting, because the stream state was set and never re-armed; and the shared command-line executor was documented as single-stream while it resolved to the measured provider default. On FTP the two download sites that feed the legacy executor now declare one stream with a reason instead of announcing an eight that executor never reads, while the measured default of eight stays on the provider path that does segment.
  • --s3-upload-concurrency reaches the engine that does the uploading. The number of parts in flight came from a constant the S3 and Azure providers published, so the setting only ever changed the older upload path and was accepted in silence everywhere else. Measured over a 100 ms link with a 300 MB upload: before, the engine took 2.19 s at 1 part and 2.18 s at 8, that is no difference; after, 6.03 s at 1, 2.20 s at 4 and 1.99 s at 8. The default is the same 4 it always was, on S3 and on Azure, so an upload that sets nothing behaves exactly as before.
  • Profiles exported to other tools point at the server and TLS mode the connection uses. The exporters rebuilt addresses from the host field instead of asking the connection: Cyberduck bookmarks carried a full URL as the hostname, or none for MinIO so they pointed at AWS, and no path-style for servers that answer only path-style; mc wrote https://https://, duplicacy s3://https://, lftp https://user@https://, s3cmd and kopia put the scheme inside the host, the aws config lost the MinIO endpoint, and restic mapped WebDAV to its REST server. FTP profiles that connect with explicit TLS were exported as cleartext to rclone, WinSCP, Cyberduck and Dreamweaver, and as plain FTP to MobaXterm, because the exporters read a key no connection reads. Every exporter now resolves the address through the same builders the connection uses, single-target formats report the profiles they could not include instead of dropping them, and an export that refused every profile exits 4. Verified by reading the exported bookmarks back with Cyberduck's command-line client against R2, FTPS, MinIO, Nextcloud and WebDAV.
  • Five findings from the tester lane: the OAuth edit form, Crypt secrets, catalog tiers, AeroCrypt v4 sizes and the S3 icon. Save on an OAuth profile now sees changes to wrappers and overlays and applies the overlay safeguards, while Save on the two-factor pages stays enabled with a typed code; a saved Crypt password and salt can be revealed and copied, read from the vault on demand; the All tab shows the union of the per-tab tiers (MEGA, pCloud and Yandex were wrong); AeroCrypt v4 (keyslots) sizes are mapped in the GUI Compare, the CLI check and reconcile and the MCP check_tree; and S3 is drawn as a bucket wherever connection methods are shown (@EhudKirsh, #347).
  • An AeroCloud one-way mirror no longer deletes, on its second cycle, a file it never synced. In receive-only a local-only file was skipped on cycle 1 and deleted locally on cycle 2; in send-only a remote-only file, for example one another person added to a shared folder, was deleted on the server. A skipped file was being recorded in the baseline, which made it read as previously synced; a skip now neither adds nor removes an entry, and a real deletion still propagates. A folder present on both sides also stops reporting a transfer every cycle with nothing moving. Both defects predate this release.
  • A recursive delete on S3 no longer removes a file that shares the folder's name. reports/ and reports are two objects and both were deleted, reproduced live on Cloudflare R2; the slashless key is now removed only when a HEAD confirms a zero-byte directory marker. A denied HEAD no longer blocks the delete inside the prefix, a refused individual DELETE in the sequential fallback is reported instead of passing, and a fresh listing reports an error when objects remain. Exported rclone S3 remotes set directory_markers = true, so rclone sees AeroFTP's empty folders, and Backblaze S3 exports use provider = Other, since rclone has no Backblaze S3 provider.
  • sync --files-from bounds both sides of the run, so --direction upload --delete no longer deletes every remote file outside the list. The list filtered the local walk and the S3 --fast-list branch but not the pooled remote walk every other provider uses, so the planner compared a bounded local tree with the whole remote one: in upload every unlisted remote file was an orphan to delete (bounded only by the delete cap), in download an unlisted remote file was planned over the local file of the same name, and in both an unlisted file known to the bisync snapshot was planned for deletion. A watch cycle's precomputed local list and a --from-reconcile plan ignored the list as well. The list is now applied once to everything that reaches the planner, whatever produced it; a bisync snapshot saved by a listed run keeps its entries for the paths outside the list; and sync-doctor counts only the listed files, names the list in its checks and in the delete risk, and keeps it in the dry run it suggests.
  • A sync no longer deletes, downloads or uploads anything at or under a path its scan could not see. A symbolic link the walk did not follow, a directory that failed to list, a directory at the depth limit, an entry that could not be stat'ed and a flat listing the provider cut short are now left out of both sides, for copies as well as deletes, while the rest of the run proceeds; a gap the scan cannot even name (an unreadable root, a cancel, the 500,000-entry cap) refuses the run instead of planning half of it. Reproduced before the fix with download --delete over a local link/x.txt, which was deleted. The MCP sync_tree result lists skipped_links and unseen_paths (capped at 1000 each, totals kept under summary_only), and the compare view withdraws the directory rows above a bounded path so a Mirror or Pull preset cannot turn them into a recursive delete.
  • The CLI sync and reconcile apply the same bound to their own planners. Every path under something a scan did not reach is left out of the plan, a gap that cannot be named refuses the run, and a skipped link's type is read from its target (or recorded as unknown) instead of being assumed. reconcile now reports the boundaries it applied, and the entry cap is checked after the exclude rules and --files-from, so a file that would never be copied can no longer trip it. Behaviour change: with --fast-list, an S3 listing that reaches the provider's entry cap now refuses the run instead of syncing the part it saw.
  • sync --watch, sync --delete and reconcile refuse to delete when part of the local tree could not be read. The watch loop skipped read errors, so on the next cycle the files it had failed to read looked deleted and were removed from the remote; an incomplete local scan now forces a full cycle and derives no delete (exit 4). A reconcile plan built over a partial scan is refused by sync --from-reconcile --delete, the refusal for a partial remote scan exits 4 instead of 5, and a plan loaded from reconcile no longer requests the same delete twice.
  • A compare under a crypt overlay that is not open no longer plans the delete a boundary exists to prevent. The bound ran before the remote names were decrypted, so ciphertext boundaries matched nothing; decoding now comes first, using the entry type the scan recorded, a boundary whose name cannot be decoded refuses the run, and a test holds the order. The used-bytes scan also counts directories against its cap and honours a cancel that arrives during the listing.
  • check and cryptcheck no longer give a verdict on a tree they did not fully read. A gap the scan can name, including a link the walk did not follow, makes the result partial (exit 4) with the paths listed in local_scan_boundaries and remote_scan_boundaries; a gap it cannot name refuses the verdict. A run over two readable trees reports exactly as before.
  • sync-doctor no longer previews a sync over a tree it could not read. It now walks the local side with the same scan sync runs, counts a remote directory that fails to list, and applies the entry cap before each insertion rather than only when a directory opens. The report says which side is partial (local_scan_complete, remote_scan_complete and six summary fields, none renamed). Behaviour change: an incomplete scan now reports attention and exits 4 where it used to report ok.
  • Replacing a remote file in place works on SFTP and WebDAV, where it had never worked. Plain SFTP rename is specified to refuse an existing destination, so edit (CLI and MCP), the AeroCrypt marker publish and the crypt configuration writes failed on every SFTP server; WebDAV had the same hole through a fixed Overwrite: F. Replacing is now its own operation, using posix-rename@openssh.com on SFTP and Overwrite: T on WebDAV, checked before anything is staged, while rename keeps refusing so an ordinary mv cannot destroy a file nobody named. Verified on three OpenSSH servers (the CI fixture, 9.6p1 and 8.4p1 on a NAS). On FTP the answer depends on the server (IIS FTP refuses an occupied name), which is now documented as such.
  • Cancelling a large S3 upload from the GUI no longer leaves its parts on the backend. Stop drops the transfer future, and dropping a future runs no error branch, so every explicit abort was unreachable from the path users take and the parts of an upload above 200 MiB stayed until the bucket lifecycle rule removed them. A guard now aborts the multipart session on any exit that is not a proven completion, on both multipart paths.
  • SFTP and MEGA no longer report a path as missing when they could not tell. A permission refusal, a lost session or an I/O error on the existence check came back as "not there", so a root under a parent the account cannot traverse read as absent: a sync writing to it planned against an empty folder, and one reading from it was refused as "source missing". These now reach the caller as errors, while a genuine absence is still an absence; across the providers this was the only remaining instance of the shape.
  • A failed directory listing on Cloudinary and Filen is no longer reported as an empty folder. Five call sites turned the error into an empty listing, so a restricted folder looked empty and a recursive delete walked past it.
  • On Windows, a file inside a subfolder of an AeroCloud sync is no longer compared as two files. The local scanner keyed paths with \ while every remote scanner uses /, so one file on both sides became two entries and an upload could address an object with a backslash in its name. Keys are normalised at the source, the saved index is migrated on read, and the first cycle after the migration cannot propagate a delete on a key it has just rewritten; reproduced and verified against a real SFTP server on Windows 11.
  • On Windows, a key-authenticated native delta over SFTP transfers only the changed bytes. libssh2 was built on WinCNG, which cannot negotiate Ed25519 or ECDSA, so the delta was refused at handshake and the whole file was re-uploaded while the command exited 0; it now builds on the OpenSSL already vendored. Measured on Windows 11 with an 8 MiB file and 4 KiB changed: put --delta 8.0 MB to 27.3 KB on the wire, get --delta 8.0 MB to 22.8 KB.
  • sync --delta now moves only the changed bytes when downloading, too. The recursive download leg transferred whole files; it now uses the delta batch for files that already exist locally and the classic path for new ones. Measured with an 8 MiB file and a 4 KB change: 8,428,937 bytes with 4.1.9, 18,829 on the wire now, with the same result on Linux, Windows 10 and Windows 11. size on a scan the user cancelled exits 4 with "status": "partial" instead of 0, and the delta fallback message no longer names password authentication as a reason.
  • An interrupted SFTP upload resumes from the CLI with --partial. SFTP implemented the append but answered "no" to the capability the CLI asks, so the GUI resumed and the CLI restarted from zero.
  • The GUI sync journal is kept when a run finishes with failed files, so Resume is still offered. A non-cancelled run used to mark the journal completed and delete it even when files had failed or failed verification, which is the case where a resume is wanted. Cancellation still keeps the journal; a fully successful run still deletes it.
  • Resuming a cancelled GUI sync transfers the files that had not been reached. Cancellation and the transfer budget used to mark remaining journal entries skipped, and resume treated skipped like completed, so Resume after Cancel transferred nothing more. Remaining entries now stay pending; resume also retries skipped, so journals already saved with that status from an older cancel still continue.
  • Multi-file SFTP transfers reuse their SSH connections across files instead of dialling a new one per file. The shared batch executor recycles a warm worker only for providers that opt in; FTP did, SFTP did not, so put -r, get -r and sync on SFTP paid a full key exchange and authentication for every file. The DAG engine review battery of 2026-09-05 measured it on 5000 small files over a 53 ms link: about 3 files per second with four leases, most of it handshake. SFTP now opts in on the same terms as FTP (a worker is parked only after a successful transfer; paths are absolute; a dead channel fails its next file loudly), so one batch pays one handshake per lease.
  • A warm transfer connection is retired after 128 files. Multi-file transfers reuse a clone worker's connection across files (FTP already did, SFTP since this release). A session that lives across thousands of files accumulates state no single transfer sees: the review battery of 2026-09-06 hit the SFTP client's open-handle counter on 4 of 5000 downloads with unbounded reuse, even with every read handle closed and awaited. A worker that has served 128 files is now dropped and the next file dials afresh, the way rclone's pools retire connections; the handshake stays amortised to well under one percent per file on a WAN link.
  • SFTP file handles are closed and awaited on every exit, not only on success. russh-sftp's Drop only queues close_nowait, so a warm worker that skipped the awaited close on a failed read, a cap, a cancelled range or a refused write could outrun the server's per-session handle limit. Nine open sites now close on every exit after the open, read-ahead and pipelined downloads close every handle they opened (four of them never awaited it, not even on success), and a reader or writer error no longer drops its sibling readers before they close.
  • One-way sync no longer re-transfers every unchanged file on backends that do not preserve modification time. The CLI planner skipped a file only when size and mtime were exactly equal; S3-compatible stores report the upload time as the object's mtime, so equality never held and a second sync --direction upload re-uploaded the whole tree (the DAG engine review battery of 2026-09-05 measured 5000 files re-uploaded in 84 s on a run that should have moved nothing; rclone's no-op took 34 s). In one-way mode a destination of the same size now counts as current when it is at least as new as the source, within a 2 s tolerance for FAT and FTP MDTM granularity: a copy written after the source last changed already holds it, and a source edited later is still transferred. Bidirectional sync keeps exact equality, where a difference is a conflict to resolve. --skip-matching is unchanged.
  • --limit-rate and --bwlimit now cap S3, WebDAV, FTP/FTPS, Azure, Backblaze B2 and every HTTP cloud provider, not only SFTP and MEGA. The flag used to call the provider's own set_speed_limit, which only SFTP and MEGA implement; the others answered NotSupported and the CLI discarded the answer, so a 20 MiB S3 download asked to run at 2 MiB/s ran at line speed (measured at 9.2 MB/s in the DAG engine review of 2026-09-05). The limit is now armed once on the process-global transfer governor as a pair of directional caps, and the shared byte loops charge them where the bytes move: the resumable HTTP download stream, the S3, WebDAV and Azure download loops, the segmented range writer, the S3 request bodies, B2 part bodies, every multipart PartBody, the ReaderStream whole-file uploads and the four FTP data-channel loops; the SFTP loops charge the same caps alongside their own pacing. Concurrent files and parts share one budget. The GUI speed-limit setting lands on the same governor, so a provider without its own pacing no longer reports "Failed to set speed limit". The providers whose download or upload loop does not go through these shared paths are covered by the entry on the HTTP cloud providers above.
  • The speed limit now also holds on the HTTP cloud providers that kept their own byte loops: Cloudinary, Filen, GitHub, ImageKit, Immich, Koofr, MEGA, Uploadcare downloads and Cloudinary, Dropbox, FileLu, 4shared, ImageKit, kDrive, Koofr, OneDrive, pCloud, Uploadcare, Yandex Disk and Zoho WorkDrive uploads. The governor caps introduced earlier in this release for --limit-rate and --bwlimit were charged in the shared HTTP loops (S3, WebDAV, Azure, B2, the resumable download helper, multipart parts); the providers listed above stream through code of their own, which the DAG engine review counted as the remaining siblings (10 download loops, 12 upload bodies). Each of those streams is now wrapped by the same pacing helper, so one budget covers every provider, uploads and downloads alike.
  • Multipart uploads no longer hold every in-flight part in memory twice. The retry helper copied each S3 request body up front; the payload is now shared and the request rebuilt per attempt. Peak RSS of a 300 MiB upload: 395 MB to 206 MB with 16 MiB parts, 562 MB to 335 MB with 64 MiB parts.
  • Transfers with no speed limit no longer pay the bandwidth governor on every chunk. Each 4 KiB chunk took two locks to reach a decision already made; with no cap the stream is now returned untouched. Measured at +17% on a mixed-size S3 upload (7.5 s to 8.8 s), now gone.
  • Multi-file downloads no longer pay one extra server round trip per file on S3, Backblaze B2, WebDAV and Koofr. With the multi-stream default of 4, those providers asked the server for each file's size (a HEAD, a stat, a Range: bytes=0-0 probe) before deciding whether to split it, on every file, including the thousands that are nowhere near the 250 MiB cutoff; on a 47 ms link a 5000-file get -r went from 68 s to 127 s while rclone held at 60 s. The batch executor, sync and a single get already know the size from the listing or their own stat, so they pass it down (download_with_size_hint) and the provider skips the probe when the size is below the cutoff; an unknown size still probes. FTP and SFTP were not affected: they read the size before the single-stream path anyway.
  • The sync command's remote scan now really runs on the list pool, and so does every other caller of the shared walker. The #758 entry under Changed named sync, but the command walked the remote tree with a third walker of its own (an inline loop in the CLI), and the library sync core, the DAG sync and the MCP tools called the shared walker's serial branch; only reconcile, check and cryptcheck had moved (the test station measured check at 5 SSH sessions and sync still at 1, and check halving from 11.4 s to 5.8 s on 5000 files). The shared scan_remote_tree_checked now parks the caller's provider behind the crate's fail-closed placeholder, resolves the list pool and scans through it, so every path inherits the pool and --checkers; the CLI sync loop is gone. Tests run the command's own code path (sync_tree_core dry run) against a provider that only finishes when 8 listings are in flight together.
  • The CLI put no longer sleeps half a second after every upload on providers that do not need it. The post-upload settle exists because russh buffers SFTP writes and disconnecting before the flush produced 0-byte files, but it ran unconditionally for every provider: an FTP upload already blocks on the server's 226 and the HTTP providers on the final response, so for them the sleep was dead time. The DAG engine review measured it as the fixed floor under every FTPS upload: 2.35 s for an uncapped 1 MiB upload whose transfer is 12 ms, of which the sleep is 0.50 s (rclone in plain FTP, which pays no TLS, floors at 1.87 s on the same server). The settle now runs for SFTP only. The regression test times the real aeroftp-cli put command against a loopback FTP server, not the CLI's own elapsed_secs, which is closed before the sleep and would read green on both sides of the fix.
  • FTP transfers set the binary transfer type once per session, not once per file. connect() already sends TYPE I at login, and nothing in the provider ever sets a non-binary type, yet seven per-transfer sites (upload, download, both resume paths, in-memory read, range read, pooled range download) re-issued it before every transfer: one dead control round trip per file in each direction. On the DAG engine review's lab link (47.36 ms RTT) that is 47 ms per file, so a folder of N small files paid N round trips against a transfer cost of 12 ms per MiB. The per-transfer TYPE commands are gone; the session-level one stays. The regression test records the wire of a session that uploads and downloads and asserts exactly one TYPE crosses it.
  • FTP over TLS no longer sleeps up to two seconds at the end of every upload. Before letting the library close the data connection, the upload path slept min(2 s, size / 4096 ms) on TLS connections so the kernel could drain the last records: a fixed two seconds on every upload of 8 MiB or more, invisible on small files and read by the DAG engine review as a constant 3 s overshoot of the rate cap on a 10 s transfer (rclone: 0.8 s). The sleep is replaced by the signal it was guessing: our own close_notify and FIN, then a bounded read until the server closes the data connection, then the 226. Measured against the lab FTPS from a laptop: a 20 MiB upload capped at 500 KiB/s went from 42.75 s to 40.85 s (target 41.9 with the bucket's initial burst), uncapped from 4.81 s to 2.61 s, bytes read back identical.
  • Small-file SFTP downloads overlap STAT and OPEN when a listing size is available. The server still supplies a fresh size for termination, progress, throttling and resume; the hint only selects speculative opening of the file. If the fresh size selects read-ahead or another parallel path, the speculative handle is closed first. This removes one sequential exchange from the small-file path without trusting stale listing metadata. The expected gain on the 5000-file benchmark is about 23 seconds at the measured ten active sessions; the end-to-end improvement remains subject to the integrity-checked benchmark. A speculative open the server refuses no longer fails the download: the serial path opens again and reports a genuine failure as before. Regression tests observe OPEN before the delayed STAT response and verify stale hints, short reads and handle cleanup.
  • A single-file put into a remote folder that does not exist yet creates the folder on SFTP, FTP/FTPS and WebDAV instead of failing with "Failed to create remote file: No such file". put -r already created every missing ancestor and rclone's copyto creates parents as a matter of course; the single-file path did neither. The parent is now checked once and, only when missing, created top-down with the same ladder the recursive path uses; object stores, which have no folders to create, are untouched. Found by the DAG engine review harness of 2026-09-05 on the Hetzner lab SFTP.
  • get --partial over a download that had already finished is a no-op again instead of failing with "Range not satisfiable: file may have changed on server" (exit 4). The resume path took the bytes already on disk as the offset and asked every HTTP backend for Range: bytes=<size>-, which a complete object answers with 416; the same happened when the local file was longer than the remote one. The CLI now compares the local byte count with the remote size before asking for a range and decides the way rclone does: equal is complete (a leftover .aerotmp is finalised in place), longer is stale and restarts from scratch, shorter resumes, unknown remote size still asks for the tail. Reproduced against MinIO and a local S3 endpoint during the DAG engine review of 2026-09-05; the provider 416 arms are unchanged as the second line of defence.
  • Immich 3.x albums show their photos again, and "All assets" and "Favorites" list every item instead of the first 1000. Since Immich 3.0.0 an album no longer carries its assets, so every album read as empty with no error, while the server reported the real count in the same answer; and the asset searches read only the first page, so a library above 1000 items looked complete and a download sync with delete could treat the rest as orphans. Albums are now read through the metadata search, every search follows the server's pages to the end, and a page that cannot be read or a pagination that points backwards fails the listing instead of shortening it. Verified against a live Immich 3.2.2; the same requests work on 2.7.
  • A put --delta to Filebase goes straight to a normal upload. Filebase answers 501 to the ranged server-side copy an S3 delta is made of, so every invocation opened a multipart session, took the refusal, aborted and fell back; the delta is now declined before any request. In the same pass the CLI help names the right GUI default for --sftp-download-preset (fast, eight connections), the 99 and 130 rows are back inside the exit-code table of --help, and the banner takes its protocol and integration counts from the generated provider inventory instead of a hand-written "23 providers".
  • export rclone keeps an SFTP profile's host-key trust and every path-rooted profile's starting folder. The exported sftp remote carried no known_hosts_file, so rclone performed no host-key validation and warned about it on every run, while AeroFTP checks the same server against ~/.ssh/known_hosts; the remote now points at that file. And a profile that opens in a sub-folder (sftp, ftp, ftps, webdav) exported to the account root, because those rclone backends have no start-folder key; the folder is now carried as an alias remote (<name>-path, remote = <name>:<folder>), the same shape the exporter already uses for a pinned S3 bucket, absolute for sftp and relative to the URL root for ftp and webdav. Found while exporting the review's nine test profiles to rclone: all nine listed, two landed in the wrong folder and three warned.
  • S3 uploads carry the source file's modification time as object metadata, the way rclone does, and stat reads it back. S3 has no mtime of its own: Last-Modified is the upload time, so a timestamp comparison against an S3 object could never find equality, and a downloaded object could not get its original mtime back. Single PUTs and multipart initiations now write x-amz-meta-mtime (unix seconds with nanoseconds, the value rclone writes and reads), stat prefers that metadata over Last-Modified when present (integer and fractional forms accepted, garbage ignored), and a server-side copy keeps it through the metadata directive. Objects written by rclone therefore read back their real mtime in AeroFTP and vice versa. Listings still show the upload time, because S3 listings do not carry object metadata: the one-way sync rule from the DAG engine review keeps converging on listing data, and a stat-based comparison is exact.
  • S3 no longer reports an MD5 checksum that is not one. Any 32-hex ETag was read as the content MD5, but per the AWS S3 API reference the ETag is the MD5 only for single-part objects stored plaintext or under SSE-S3: SSE-KMS and SSE-C make it opaque, and multipart ETags carry a -N part count. stat now skips the derivation when the response declares SSE-KMS (or DSSE) or SSE-C, listings skip it when the profile itself writes SSE-KMS, and the multipart shape stays excluded, so checksum and checksum-based comparisons omit the hash instead of promising a verification that would guess or falsely mismatch, the way rclone does.
  • An FTP session never hands the next command a reply that belongs to another. A server that refuses and then hangs up sends 550 and 421 together, and the next command read the goodbye as its own answer; a resumed upload that failed partway left the transfer's completion reply queued for the next listing. The session is now dropped whenever a data connection is refused or a reply is left owing, and a redial returns to the directory the caller had changed into instead of the login directory. A resumed upload (APPE) to a server that accepts it and never answers now ends inside the data-open budget instead of waiting as long as the process lives.
  • --immutable is honoured by cp and mv. Both ignored the flag, so a copy onto an existing target overwrote it and exited 0; they now skip with exit 9 like put, and under --immutable a destination that cannot be checked refuses the write.
  • hashsum --download hashes the bytes it downloaded instead of returning the server's digest. The flag was parsed and ignored. The download now streams to a temporary file hashed in 1 MB chunks, so there is no size cap and a failed transfer produces no digest.
  • --conflict-mode newer no longer decides in opposite directions when no timestamp can decide. Without a usable modification time an upload copied and a download skipped, so a two-way run quietly became "the local side always wins"; the larger file now wins in both directions, and the skip reason says the timestamp was missing. Behaviour change on backends that report no modification times.
  • Four CLI answers that read the same whether the work was done or not. A reached --max-transfer budget now has its own over_budget count and a partial status instead of ok with nothing skipped; get -r on a path it cannot list exits non-zero instead of printing Downloaded 0/0; --password-stdin is read once and reused, so a sync no longer fails its later connections with an empty password; and the exit-code precedence of sync is written down.
  • Used-space scans report what they covered. size --max-depth on an object store summed the whole bucket; the requested depth is now applied and reported as depth_limited, not as a truncation. A scan the user cancelled is labelled cancelled, not truncated, in size, df --scan and the desktop, and the desktop quota no longer saves as the profile's used figure a listing the provider cut or a scan that was stopped.
  • CLI and AeroCloud rough edges found by using AeroFTP to develop AeroFTP. A subcommand's -h now shows its own options first (profile-export -h went from 186 lines to 21, and a test caps short help at 60), profile-export --ids accepts names through the same selector every other command uses, and profile-import -h says on its first line that existing profiles are skipped. An AeroCloud pair whose remote folder sits below a missing parent now creates the whole path instead of failing every upload under it; aerocloud show reports the protocol of the assigned profile; --direction accepts LocalToRemote and RemoteToLocal, the names its own logs print; and the skipped count now lists each path with its reason and whether it is a folder.
  • The command-line agent can run app_info and hash_file, the two tools it offered and refused. They are answered by the CLI implementations instead of being routed to the desktop ones, and thirty-seven routes of an older table that could never be reached were removed.
  • The AI agent's local file tools say what they could not read. local_list, local_tree, local_grep, local_disk_usage, local_search and local_find_duplicates presented an unreadable entry as an empty file and a locked directory as an empty one; each now counts, on every answer, what it could not read.
  • The MCP aeroftp_check_tree tool reports how much of each tree it compared. A directory it could not read, or one behind a link no walk follows, was indistinguishable from one that is absent; the result now carries per-side completeness counters and the unseen paths, and the tool description warns that a file missing under an incomplete scan must not become a delete.
  • The MCP pool can open Immich, ImageKit, Uploadcare, Cloudinary and native B2 profiles. The resources surface announced them and the pool could not build them; a hand-typed protocol such as image-kit or backblaze_b2 now resolves too.
  • aeroftp_reconcile honours checksum. Two files of the same size and different content are compared by BLAKE3 instead of being reported equal, and aeroftp_hashsum now asks for the tighter hashing memory cap instead of the general 500 MB download.
  • Dropbox trash tells folders from files, and restore no longer fails with 409. A tombstone carries no kind, so the listing guessed it: folders showed as files and a restore could pick a revision that was not restorable. The kind now comes from the revision history, folder restore says that Dropbox has no API for it, and a throttled probe fails the listing instead of reporting "unknown". Verified against a live account, red on the old build and green on this one (@EhudKirsh, #397).
  • OneDrive Personal reports that its recycle bin is not reachable through Microsoft Graph instead of "400 Bad Request" with raw JSON; business drives keep the button (@EhudKirsh, #397).
  • pCloud's refused trash reads as a pCloud limit, not a broken login. pCloud refuses its trash to OAuth tokens; all four trash commands now say so and point at pcloud.com, while a genuinely bad token is still an authentication failure. On the three trash mutations every result code is now classified: missing items answer "not found" and the throttle code becomes a rate-limit error (@EhudKirsh, #397).
  • Jottacloud purges an individual trashed file. The fallback from the Trash view to the original path now runs only after a deleted tombstone is confirmed, and a live object is refused (@EhudKirsh, #397).
  • A keystore export and import no longer restores an older My Servers list. The migration copied the SQLite -wal and -shm sidecars with the database, and SQLite replayed the stale log over the restored data, bringing back renamed and deleted profiles (@EhudKirsh, #736).
  • With Crypt off, deleting a plaintext file inside the Crypt scope is allowed. The guard against writing unreadable data was applied to an operation that removes data; the write guards are unchanged (@EhudKirsh, #390).
  • Importing an rclone remote with its crypt binds the crypt. The import produced two profiles with neither bound; the binding is now stored with its password and optional salt in the vault, verified both ways against rclone 1.74.3 (@EhudKirsh, #347).
  • An exported AeroSync template follows the Plan and Sync tabs. The compare fields followed the named preset instead of the verify policy, and the exclusions came from the preset instead of the user's list (@EhudKirsh, #514).
  • Yandex Disk mkdir, rename, server-side copy and upload-by-URL wait for the operation to finish. A 202 acknowledges a queued mutation, and only delete waited for it, so the others reported success before the operation had completed or failed (@EhudKirsh, #368).
  • The benchmark labels pCloud Drive and the two MEGA entries correctly, counts only runnable profiles and reports pCloud's progress incrementally (@EhudKirsh, #368).
  • Profile import no longer writes twice on a double click or reports "1 updated" after a failed vault write. An empty vault is no longer mistaken for a missing one, and an import no longer proceeds without its backup.
  • A Jottacloud profile shared between two stations no longer fails with "Login token expired or already used". The CLI and the GUI rotated two different refresh chains for the same profile and a refused chain was only half cleared, so an export could carry a dead one; both chains are now tried and a refused one is removed everywhere.
  • The kDrive token field says which scopes the token needs. A token with the drive scope alone connects and lists, and only Fetch is refused because it reads the account profile; the hint, in all 47 locales, now says so before the token is created (@EhudKirsh, #650).
  • A missing path on Box, FileLu, Filen, 4shared, Jottacloud and MEGA (both the MEGAcmd and the native backend) no longer reports "Path not found: Path not found: ...". Those providers built ProviderError::NotFound with the prefix already in the payload, and the variant's Display adds the same prefix again, so the user read it twice. The constructors now carry the bare path and leave the prefix to Display, which is the single place that owns it. A regression test drives the MEGA native provider's path resolution and counts one prefix in the rendered error.
  • Error messages no longer say the same thing twice. Fifty-nine payloads repeated what their error type already prints (Path not found: File not found: ..., Parse error: XML parse error: ...) and now drop it, with a test that fails on any payload repeating its own prefix; GitHub errors no longer write their label twice, and a GitHub 404 now says whether the repository or the file is missing. On kDrive the wrapper had also turned an authentication failure into a connection failure, which is fixed.
  • A Backblaze B2 token failure is recognised by its type, not by the words in the message, so a change of wording on the server cannot silently disable re-authentication.
  • About > Dependencies lists every direct dependency at the version AeroFTP builds with. 17 were missing and 13 showed another version; the list is now derived from the manifests at build time, and updates are classified with Cargo's semver rules against the crates.io sparse index, pins included.
  • "Remove application data" in the Windows uninstaller takes effect. Since the identifier change the uninstaller could not take that branch, so a user who ticked it got the same three prompts as one who did not, and the legacy data directory with the AI chat history survived. The decision now reads the checkbox itself, and both data directories are removed; the vault keeps its own prompt.
  • The server panel no longer shows a move cursor for files dragged in from outside, and Full Backup in the keystore settings scrolls to its panel.
  • The --tui menu of profiles, groups and users offers every verb the -i prompt has. It used to be a second, hand-kept list that had fallen behind the action bar: Help, refresh, New, Groups, Users, Add and remove were reachable at the prompt but not from the menu, so -i was more capable than --tui. The menu is now derived from the same verb table, in the same order and with the same keys (? and 0 included), and a test pins that the two cannot drift apart again. Verbs that take no selector send the bare command, so Help prints and the menu reopens; membership and add/remove get the prompt their line-mode syntax needs. Alongside, the action bar capitalises a label only when the verb is invoked by its first letter: refresh(.) and remove(X) now read like re-index(#), and that rule is a test rather than a convention. Both menus also open on an empty vault, where two guards used to exit before any menu showed (@EhudKirsh, #347).
  • Jottacloud folder restore from the recycle bin works, because it stopped asking for a verb that does not exist. Probed against a real directory tombstone, JFS has no command that restores a directory: ?restore=true is rejected as an unknown command on both the Trash view and the original path, and ?mv=/?mvDir= out of Trash 404 because the Trash listing is a virtual view whose entries no verb can address. The restore is now composed from the primitives that already carry file restore: the tombstone at the original path still lists the deleted tree with every child's deleted stamp and revision, each tombstoned file is revived with ?cphash=true against its own path, which revives the ancestor folders along with it, and only the folders no file could bring back, the empty ones, are recreated with ?mkDir=true. The reported result counts only what the server confirmed: children found already live are listed as already present rather than claimed, and a restore that stops partway is an error carrying the confirmed counts and the paths that failed, never a quiet success. Re-running a restore restores nothing twice (@EhudKirsh, #397).

Changed

  • One copy of ChaCha20-Poly1305 instead of two, because the crate holding it back is ours. aerovault 0.6.4 depended on version 0.10 of that cipher, so the application pinned the same 0.10 for its own use and carried both in the tree. aerovault 0.6.6 moves to 0.11 with no change to the vault format, which its own cross-implementation tests assert by opening a container written months earlier and reproducing it byte for byte, so existing .aerovault files stay readable and new ones are identical. The only place here that uses the cipher directly is CryptoLab, where the new interface also removed a panic: a nonce that was not twelve bytes used to kill the call, and now returns an error.
  • A large upload keeps about half the memory it used to. The transfer engine holds a bounded number of part buffers (four times 16 MiB on top of a 65 MB baseline), but on a many-core machine the C library kept the freed buffers in per-thread pools instead of returning them, so the process looked like it was holding 300 MB. AeroFTP now asks the allocator, at startup, to take buffers of this size straight from the kernel and give them straight back. Measured on a 1 GiB S3 upload, three runs: peak memory 295 to 312 MB before, 139 to 141 MB after, with the same wall time. It applies where that allocator is used and does nothing elsewhere; no transfer path changes.
  • GUI downloads use multiple streams by default, per protocol, on the measured numbers: SFTP 8 connections (the toolbar preset now defaults to Fast), WebDAV 8, FTP/FTPS 8, S3 4; providers without a measurement stay on one stream. The setting on Auto sent nothing to the backend, which then used one stream everywhere, and the single-file and folder download paths armed the provider's own multi-stream download only for SFTP with a preset: S3, WebDAV, B2 and Koofr implement it and the GUI never called it for them. Both paths now go through one helper that arms every provider (preset, explicit setting, or the measured default, above the shared 250 MiB cutoff). Measured on the lab (300 MiB, wired gigabit): SFTP 144.75 s to 25.46 s, WebDAV 37.60 s to 13.45 s, FTP 52.95 s to 13.50 s, S3 36.83 s to 31.50 s. These are the desktop numbers: the desktop plans its own ranges, while the command line asked the provider to split and, on S3, was answered by a size probe that always read zero, which the entry below fixes. The toolbar speed button on S3 and WebDAV still shows one level; making it reach those providers follows separately.
  • The crate's own unit tests derive keys with the cheapest Argon2id profile the vault formats accept (8 MiB, t=1) instead of the audited 128 MiB / t=4. The tests derive keys thousands of times per run and those derivations were 92% of the suite's execution time (27 of 41 minutes in CI); what they exercise is wrapping, MACs, slots and formats, none of which depends on the cost. Production builds, the CLI's tests and every binary keep the audited profile, which is now also pinned by name and by a full-cost known answer in one test. Measured on the affected modules: 1867 s of execution down to 287 s.
  • The CLI remote scan of reconcile, check and cryptcheck runs on the provider's list pool, up to --checkers directories at once (sync follows in the entry under Fixed). The CLI walked the remote tree with a serial walker of its own while the GUI scan already listed on clone workers; on the lab SFTP server a 5000-file sync spent 10.8 s in a no-op run and 14.7 s in a delta run against rclone's 4.2 s and 5.4 s, all of it in the scan. The three commands now use the shared walker (same filters, same completeness accounting, spinner fed by its progress hook), so a provider that lists on independent connections (SFTP, S3, WebDAV, B2, the HTTP clouds) lists in parallel and a single-session provider walks as before. --checkers therefore bounds something real now; its help text says so. Also fixed in the shared walker: a symlink to a directory is listed but no longer walked (the CLI walker already refused it, the GUI walker did not).
  • The GUI sync scan lists SFTP directories on up to eight connections (the scan asks for eight checkers; the SFTP pool ceiling is 16 since the entry below, it was 4 when this landed), and scan workers stay warm across directories. The parallel scanner cloned a fresh worker for every directory, which is a client copy for HTTP providers but would be a full SSH handshake on SFTP, so SFTP was left on the single locked session (the DAG engine review measured 4 sequential round trips per directory: 15 to 18 s for 5000 files in 51 folders, rclone 4 s). SFTP now exposes the clone-pool scan hooks once a connection exists, and the scanner keeps a worker that listed a directory cleanly for the next directory instead of re-dialling (HTTP clones do not opt in and keep their per-directory clone). The CLI sync, check and cryptcheck run on the same scanner since the entry above.
  • Multi-file SFTP transfers honour --parallel up to 16 connections, and the CLI says so when a provider ceiling binds a higher request. The SFTP pool ceiling was a literal 4, so --parallel 16 delivered 4 connections without a word. The DAG engine review battery on the Hetzner lab (wired gigabit, 5000 x 4 KiB files) measured what that cost: from 4 to 16 rclone gained 1.9x on upload and 3.6x on download, AeroFTP moved 1%. The ceiling is now 16, the same range as --sftp-concurrency; the effective count stays the lower of the ceiling and --parallel, so the default of 4 connections is unchanged. On every provider, put -r and get -r now print a note when the provider ceiling is lower than the requested --parallel (text mode; quiet and JSON stay silent).
  • SFTP single-stream downloads read ahead by default (window 32), instead of one 256 KiB read per round trip. The read-ahead tier existed behind --sftp-readahead and AEROFTP_SFTP_READAHEAD; the DAG engine review measured what the default cost on a 53 ms link with a 300 MiB file: 118 to 129 s serial, 38.8 s with a window of 16, 34.5 s with 32, 35.6 s with 64, rclone 30 s. The window now defaults to 32 when nothing asks for one; AEROFTP_SFTP_READAHEAD=off (or 0) restores the serial read, an explicit --sftp-readahead or preset still wins, a bandwidth cap still takes the serial loop that owns the precise throttle, and the job-wide buffer budget still bounds the window per connection.
  • put -r --skip-restricted uploads a tree whose names the destination partly forbids, setting aside and reporting the offending entries instead of refusing the whole batch. The default is unchanged and deliberate: one restricted name (a tab in a file name bound for S3, for instance) still stops the batch before anything is created. The review battery of 2026-09-05 measured the two policies side by side on the same 20-file tree: AeroFTP uploaded 0 files, rclone 19 and reported the one it could not. With the flag a folder with a restricted name takes its children along, every skip is listed once, and the run ends partial with exit 4 because not everything requested landed.
  • Single-file downloads are multi-threaded by default: files of 250 MiB or more are fetched with 4 concurrent range streams, the same stream count as rclone's default (4 streams above 256Mi; AeroFTP's cutoff stays 250 MiB, so files between the two thresholds split here and not there). --multi-thread-streams used to default to 1, so the DAG range scheduler, the strict 206 probe and the single-stream fallback all existed but only ran when a user found the flag; the May 2026 live matrix showed rclone ahead on every 1 GiB download because of that default alone, while the shared harness measured the two engines within noise once the streams were equal. The gate is unchanged: the option reaches the providers that implement it (S3, Backblaze B2, SFTP and FTP on independent connections, WebDAV and Koofr after a live probe), everything else and every file below --multi-thread-cutoff stays single-stream, and --multi-thread-streams 1 or AEROFTP_MULTI_THREAD_STREAMS=1 restores the old behaviour. pget is unaffected.
  • An S3 endpoint that is plain HTTP and not on this machine now needs the profile's explicit consent, and a connection to one is refused until it is given. Signing a request authenticates it, it does not encrypt it: over cleartext the Authorization header, the STS session token when a role is assumed, and every file byte are readable by anything on the path. The other two ways in were already closed, redirects are refused outright and no preset ships a cleartext remote endpoint, so what remained was an endpoint typed by hand or carried in from another tool's configuration file. Loopback is exempt and unaffected, because those bytes never reach a network: local bridges such as the Filen Desktop gateway keep working untouched. What changes for someone pointing at MinIO or a gateway over http:// on a LAN is one checkbox, offered in the connection form next to the endpoint precisely when it applies, and readable as allow_cleartext_endpoint on the profile for the CLI, the MCP pool and schedulers that never see a form. The import listings now mark such a profile as it arrives, which is where the endpoint is still legible, rather than leaving the surprise for a connection weeks later. This mirrors the consent Swift already asks for the same reason, and the two predicates that decide "local" are deliberately kept apart: the one that trusts a self-signed certificate accepts .local mDNS names, and the one that decides whether bytes stay off the wire does not.
  • The application identifier is now app.aeroftp.AeroFTP, the reverse DNS of a domain the project owns, and an update keeps what the old identifier held. The vault, profiles and databases are stored under a name, not under the identifier, so nothing needs to be re-entered, and the keyring entry that holds the saved vault passphrase deliberately keeps its old name. The locations the framework derives from the identifier stay where earlier releases put them: the web view storage that holds the interface preferences (language, theme, AeroFile tabs, custom icons, AI, OAuth and terminal settings), the downloaded speech model and the log folder; the window size and position carry over; on macOS, where the web view store follows the bundle identifier, it is copied once to the new one before the first window opens; and the one-time import of a configuration folder from before the data-root migration reads the old identifier again. The desktop file, AppStream metadata (which now declares the old identifier as replaced) and Flatpak manifest are renamed, "make default" still finds a desktop file installed under the old name, and on macOS the Finder Sync extension follows the new identifier. The privacy notice lists the data directories under both identifiers and names the keyring entry that deleting them leaves behind.
  • Quick Connect and AeroSync, from the wishlist. The rclone crypt salt field is called password2 in Quick Connect as in the unlock dialog; the AeroCrypt block leads with the default-salt toggle and folds the keyfile into a disclosure that states whether one is set; the AeroSync template name and description have visible labels; a canary dry run keeps the Compare scan instead of discarding it; and the AeroSync dialog closes when its session disconnects (@EhudKirsh, #347).
  • Exit 8 has one documented meaning: stopped at a limit the user set, with nothing failed. A reached transfer budget and a timeout are both limits; which one it was is in the JSON (over_budget). The help, its compact table, the CLI guide and the agent metadata, which still said "8": "timeout", now agree.
  • The native rsync engine no longer depends on the application, and the compiler proves it. Over four tranches the aerorsync module went from 42 application import lines to none, and a fifth added a CI lane that re-emits it as a standalone crate and builds it on Linux, Windows and macOS (both architectures), in both profiles and under the product's own rustflags and lockfile. Its real-rsync oracle tests, which had been skipping silently in CI and fresh checkouts, now run, and the crate declares MPL-2.0 OR GPL-3.0-or-later in every file.
  • Dependencies. Every Cargo and npm dependency takes the newest release its requirement admits (207 crates updated in the lock, the tauri pin kept), and rust-version states the real floor, 1.93. argon2 0.6 (the known-answer test still derives the same key), sevenz-rust2 0.22 and zstd 0.14 land with no source change; ten Dependabot bumps were merged as one branch; der and libssh2-sys moved off releases yanked upstream; vite 8.3.0, toml 1.1.6 and autoprefixer 10.5.6. quick-xml 0.42 is left out: it is an API migration, not a bump.
  • Backblaze's SSE-B2 default for new uploads, from 2026-09-14, needs no client change, measured rather than assumed. On buckets with and without default encryption the digests B2 reports, native and S3-compatible, were identical, and the B2 tests now assert that the server still publishes the digest sync, dedupe and verify rely on.
  • The Rust test suite runs on Windows. Every cargo test used to execute on a Linux runner, so "green on three platforms" meant "builds and packages" on two of them. The Windows lane now builds and runs the library suite under MSVC (3899 passed on its first green run), and the first execution found and fixed seven platform assumptions in tests.
  • CI wall clock. Linux lint and tests run beside packaging instead of in front of it, and a tag cannot publish while they are red; superseded runs are cancelled; docs-only changes skip the four-platform build; coverage and cargo install smoke run on main only (the CLI lane per pull request from 77 to 110 minutes down to 25 to 35); rust-cache is saved only from main, so pull requests cannot evict it; and the cargo-audit binary is cached instead of compiled on every run.
  • Release checks. The version sites live in one list with three callers, after v4.1.9 shipped with package-lock.json still declaring 4.1.8 (148 of 166 tags carried the same drift); the AppStream metainfo and desktop entry are validated on every pull request; CI pins rustc 1.97.1 instead of floating stable; and CI moves from Node 20 to Node 24 LTS with vitest 5.
  • Smaller internal changes. CI no longer fails on preinstalled Microsoft and Google apt repositories it does not use, the SFTP rsync and FTP MLSD lanes retry for real after a timeout, the standalone engine lane also runs when only the lockfile changes, and the sign-off check accepts GitHub App commits by name. Tests: the native rsync settings tests work on a file of their own instead of the machine's real settings file, which they shared on Windows and macOS, the dispatcher tests no longer draw a directory name another test can draw in the same nanosecond and delete mid-run, nor run a helper binary that another test's forked child still holds open for writing, a flaky S3 log test replaced, the FTP put timing measured on the server, three ACL tests answered per platform, an S3 mock that now reads the body it is sent, and three live FTP stall-and-retry tests restored. Docs: AGENTS.md is the CLI integration guide again, docs/TESTING.md points at npm run smoke, and stale code comments were corrected. The SourceForge publishing script confirms a delete by listing instead of trusting the exit code. The AeroRsync capture harness generates its SSH key at each run instead of keeping one in the repository, and the legacy configuration import runs once per data root instead of at every start.

Security

  • Findings of the pre-release security audit, all older than v4.1.9, are closed rather than carried. An automated audit with parallel reviewers raised leads across the app; each was validated against the code, and every confirmed one is fixed with a test that failed on the previous code. The entries below are the user-visible ones.
  • On Linux the app verifies that the UI server on 127.0.0.1:14321 is its own before any window loads it. A process of another account that took the fixed port first would have been loaded with the app's full command surface; the server now marks its responses with a secret generated at each start, the app refuses to open a window when the answer does not carry it, and says why. The origin, and with it every saved preference, is unchanged.
  • The in-app updater refuses an update whose signature does not verify. A missing Sigstore bundle, one that does not parse, and a verification error used to let the install proceed; each now deletes the download and stops, and the Windows installer path checks the registry of verified downloads like the Linux and macOS ones.
  • Commands that hand out secrets or a shell answer the main window only, and AeroAgent's write and shell tools always go through the backend grant. The extract window could reach the vault and the terminal commands; shell_execute was also registered as a command outside the approval path; and remote_download, checkpoint creation and agent memory writes did not require the grant. The agents' path denylist now fails closed on paths that do not exist yet (a write to ~/.ssh/nested/key passed) and applies on Windows, where it read HOME only.
  • Archives cannot write outside the destination through mixed separators or a ...zip name. An entry mixing / and \ could carry a parent component past the check on Windows, and an archive named ...zip or ...tar.gz extracted into the parent of the chosen folder when a subfolder was requested.
  • AeroCloud no longer deletes a local file because a provider's listing left it out. A listing the provider marks as not authoritative was treated as complete, also through the crypt and compress overlays.
  • A credentials-off profile export no longer carries the AeroShare peer ticket, and the RPM package ships the update helper and data files the DEB ships.
  • A 22-character rclone crypt password that decodes as empty is no longer replaced by an empty password. rclone's obscure format makes some 22-character values reveal as the empty string (one in sixteen of the 22-character passwords over [A-Za-z0-9_-], an alphabet password managers generate in), and the content was then encrypted under a key derived from nothing, unopenable by rclone. The reveal now depends on the field: an empty result is kept as the literal password, while for the salt it still means rclone's default (@EhudKirsh, #600).
  • An S3 request is never signed for a cleartext endpoint without consent, whichever path signs it. The consent introduced in this release sat in connect(), while Quick Connect's bucket discovery signed a ListBuckets against the typed endpoint directly; the check now sits where every S3 signature is made, a presigned share link is covered too, and a role profile no longer spends an AssumeRole and an MFA code on an endpoint it has not accepted.
  • API keys sent in non-standard headers no longer follow a redirect to another origin or to plain HTTP. reqwest strips only standard credential headers on a cross-origin redirect, so the Immich, GitLab and InfiniCLOUD keys and the AI clients' API keys travelled to wherever a redirect pointed, the class rclone fixed in 1.75.1. Same-origin redirects still work; a server that redirects to another host now fails with the redirect status, so configure the final address.
  • Restricted characters are refused on the MCP and agent write path before anything reaches the wire. A retried authentication, an unresolved provider type, a missing restricted parent before mkdir and the staging file edit writes no longer bypass the check.
  • rustls 0.23.45 for RUSTSEC-2026-0285, TLS 1.3 handshake messages accepted across encryption level boundaries; postcss-selector-parser 6.1.4 for an uncontrolled recursion in a development dependency.
  • The plausible findings of the v4.1.9 audits are closed. The sign-off check matched Signed-off-by: anywhere in a message and now requires a trailer line; cargo audit reported an unrun yank check as success and the authoritative lanes now run it with --deny warnings; and an FTP delete or rmdir of a name containing CR or LF is refused as an invalid path instead of reading as a retryable connection error.

Contributors

Seven of the issues closed in this release were opened by @EhudKirsh, who keeps finding the cases that only appear when the software is actually used: the trash that behaves differently on every provider, the import form that would not save, the receipt field whose name did not match its label. A report that names the steps is worth more than the fix it produces, because it usually finds the next one too: the last fix to land in this release came from one of his, sent while the release was being prepared, and closing it turned up two further defects nobody had seen.

Thank you

To everyone who uses AeroFTP, at home or at work, and to everyone who opens an issue, tests a build, asks a question or sends a patch: thank you. This release is three weeks of repair, and nearly all of it exists because somebody noticed something and said so. Keep doing that.

Downloads:

  • Windows: .msi installer, .exe, or .zip portable (no installation required)
  • macOS: .dmg disk image
  • Linux: .deb, .rpm, .snap, or .AppImage

Don't miss a new aeroftp release

NewReleases is sending notifications on new releases.