diff --git a/docs/articles/vfs.md b/docs/articles/vfs.md index 0f8438995..4f7e6d2f2 100644 --- a/docs/articles/vfs.md +++ b/docs/articles/vfs.md @@ -132,6 +132,10 @@ vfs.rename( from, to, RenameOptions{ true } ); vfs.canonicalize( uri ); // resolves symlinks/aliases; URIs are never canonicalized implicitly ``` +`readAll()` reserves a known file length but keeps each handle read bounded to 256 KiB. An SFTP +operation deadline therefore applies to each chunk, rather than the whole file; a single chunk +can still time out. Explicit size limits, including `UINT64_MAX`, are checked without overflow. + `FileHandle` uses explicit offsets (`read( offset, span )`, `write( offset, span )`). `VFSFileInfo` is a plain value. Unknown metadata stays unknown (`std::optional` accessors). It does @@ -160,7 +164,7 @@ transfer.completion().then( uiExecutor, []( VFSResult&& r ) { .. transfer.cancel(); ``` -Moves use a native rename when both sides are on the same provider/authority and fall back to copy +Moves use a native rename when both sides share a provider filesystem namespace and fall back to copy plus removal of the entries that were completely copied; a source is never removed after a partial transfer. The copy-and-remove fallback requires the source provider's `Remove` capability. Without it (for example, on `zip://` and `res://`), the move returns `Unsupported` before modifying the @@ -174,6 +178,14 @@ Revision checks are best effort: SFTP v3 revisions have second-resolution timest and a pathname can still change between the final stat and removal. This is not an atomic conditional-delete operation. Native same-provider renames do not need these copy/delete checks. +Transfers resolve whether both authorities share a filesystem namespace. Equivalent local +authorities (`file:///` and `file://localhost/`) and SSH aliases sharing a resolved session are +checked using canonical paths before a destination can be modified. Self-transfers return +`InvalidOperation`. Unknown identity across authorities returns `Unsupported` conservatively; +every provider implements `sameFileSystem()` to resolve aliases or identify disjoint namespaces. +This contract also applies to same-authority transfers; authority text alone does not decide identity. +Namespace identity does not promise a rename across device boundaries. + Directory copies through one provider also compare canonical paths, resolving the nearest existing destination ancestor before checking containment. This rejects a destination reached through a symlink back into the source. Providers without canonicalization retain the lexical containment @@ -219,8 +231,9 @@ re-list the directory. `WatchHandle::status()` reports `Active`, `Polling`, `Rec The generic polling backend refuses an initial snapshot above 100,000 entries with `Unsupported`. If an existing watch exceeds that limit, it reports `Failed` and one `RescanRequired` event, then stops polling that scope. Register smaller scopes to restore coverage. Partial snapshots are never -reported as complete watches. Local native events buffered during registration likewise emit -`RescanRequired` if their bounded buffer overflows. +reported as complete watches. Any local native event received during registration causes +`RescanRequired`. Early event payloads are not replayed because newer live events can arrive +concurrently with publication of the registration. Backends: diff --git a/include/eepp/ui/uihelper.hpp b/include/eepp/ui/uihelper.hpp index 2bee2d18b..bcb2f97e1 100644 --- a/include/eepp/ui/uihelper.hpp +++ b/include/eepp/ui/uihelper.hpp @@ -151,6 +151,7 @@ enum UINodeType { UI_TYPE_DATETIMEPICKER, UI_TYPE_ANCHOR, UI_TYPE_ANCHORSPAN, + UI_TYPE_TABLEROW, UI_TYPE_MODULES = 10000, UI_TYPE_TERMINAL = 10001, UI_TYPE_USER = 200000, diff --git a/include/eepp/ui/uitablerow.hpp b/include/eepp/ui/uitablerow.hpp index 53a4ea29d..fe76d9cf0 100644 --- a/include/eepp/ui/uitablerow.hpp +++ b/include/eepp/ui/uitablerow.hpp @@ -14,6 +14,12 @@ class EE_API UITableRow : public UIWidget { static UITableRow* New() { return eeNew( UITableRow, () ); } + Uint32 getType() const { return UI_TYPE_TABLEROW; } + + bool isType( const Uint32& type ) const { + return UITableRow::getType() == type ? true : UIWidget::isType( type ); + } + ModelIndex getCurIndex() const { return mCurIndex; } void setCurIndex( const ModelIndex& curIndex ) { mCurIndex = curIndex; } diff --git a/src/modules/vfs/include/eepp/vfs/provider.hpp b/src/modules/vfs/include/eepp/vfs/provider.hpp index 9094ad0d6..04200ab0b 100644 --- a/src/modules/vfs/include/eepp/vfs/provider.hpp +++ b/src/modules/vfs/include/eepp/vfs/provider.hpp @@ -69,6 +69,15 @@ class EE_VFS_API VFSProvider { */ virtual VFSTask canonicalize( const URI& uri, CancellationToken token ) = 0; + /** Whether canonical paths from these URIs belong to one resource namespace. + * Called only for URIs served by this provider instance. True makes canonical paths + * comparable even when authorities differ; false must mean disjoint namespaces. + * Resolve authority aliases here. Return Unsupported if identity is uncertain, so a + * transfer fails before modifying its destination. This does not promise native rename + * support or imply that both paths are on one device. */ + virtual VFSTask sameFileSystem( const URI& from, const URI& to, + CancellationToken token ) = 0; + /** Optional: sets POSIX permission bits (VFSCapability::Permissions). */ virtual VFSTask setPermissions( const URI& uri, Uint32 permissions, CancellationToken /*token*/ ) { diff --git a/src/modules/vfs/include/eepp/vfs/providers/archiveprovider.hpp b/src/modules/vfs/include/eepp/vfs/providers/archiveprovider.hpp index 4026580dd..20fe623cb 100644 --- a/src/modules/vfs/include/eepp/vfs/providers/archiveprovider.hpp +++ b/src/modules/vfs/include/eepp/vfs/providers/archiveprovider.hpp @@ -74,6 +74,9 @@ class EE_VFS_API ArchiveProvider : public VFSProvider { VFSTask canonicalize( const URI& uri, CancellationToken token ) override; + VFSTask sameFileSystem( const URI& from, const URI& to, + CancellationToken token ) override; + private: std::shared_ptr mImpl; }; diff --git a/src/modules/vfs/include/eepp/vfs/providers/localprovider.hpp b/src/modules/vfs/include/eepp/vfs/providers/localprovider.hpp index 4df69e8b0..c252ad9a0 100644 --- a/src/modules/vfs/include/eepp/vfs/providers/localprovider.hpp +++ b/src/modules/vfs/include/eepp/vfs/providers/localprovider.hpp @@ -50,6 +50,11 @@ class EE_VFS_API LocalProvider : public VFSProvider { VFSTask canonicalize( const URI& uri, CancellationToken token ) override; + /** Whether canonical paths from these authorities share one resource namespace. + * Unknown identity returns Unsupported; this does not promise native rename support. */ + VFSTask sameFileSystem( const URI& from, const URI& to, + CancellationToken token ) override; + VFSTask setPermissions( const URI& uri, Uint32 permissions, CancellationToken token ) override; diff --git a/src/modules/vfs/include/eepp/vfs/providers/memoryprovider.hpp b/src/modules/vfs/include/eepp/vfs/providers/memoryprovider.hpp index c082c74ae..cd23702c4 100644 --- a/src/modules/vfs/include/eepp/vfs/providers/memoryprovider.hpp +++ b/src/modules/vfs/include/eepp/vfs/providers/memoryprovider.hpp @@ -52,6 +52,11 @@ class EE_VFS_API MemoryProvider : public VFSProvider { VFSTask canonicalize( const URI& uri, CancellationToken token ) override; + /** Whether canonical paths from these authorities share one resource namespace. + * Unknown identity returns Unsupported; this does not promise native rename support. */ + VFSTask sameFileSystem( const URI& from, const URI& to, + CancellationToken token ) override; + VFSTask setPermissions( const URI& uri, Uint32 permissions, CancellationToken token ) override; diff --git a/src/modules/vfs/include/eepp/vfs/providers/resourceprovider.hpp b/src/modules/vfs/include/eepp/vfs/providers/resourceprovider.hpp index b67e8a975..7a68bda0a 100644 --- a/src/modules/vfs/include/eepp/vfs/providers/resourceprovider.hpp +++ b/src/modules/vfs/include/eepp/vfs/providers/resourceprovider.hpp @@ -45,6 +45,9 @@ class EE_VFS_API ResourceProvider : public VFSProvider { VFSTask canonicalize( const URI& uri, CancellationToken token ) override; + VFSTask sameFileSystem( const URI& from, const URI& to, + CancellationToken token ) override; + /** @return The pack-relative path for a res:// URI ("" for the root). */ static std::string toPackPath( const URI& uri ); diff --git a/src/modules/vfs/include/eepp/vfs/providers/zipprovider.hpp b/src/modules/vfs/include/eepp/vfs/providers/zipprovider.hpp index 87c77f940..54e13ac4b 100644 --- a/src/modules/vfs/include/eepp/vfs/providers/zipprovider.hpp +++ b/src/modules/vfs/include/eepp/vfs/providers/zipprovider.hpp @@ -57,6 +57,9 @@ class EE_VFS_API ZipProvider : public VFSProvider { VFSTask canonicalize( const URI& uri, CancellationToken token ) override; + VFSTask sameFileSystem( const URI& from, const URI& to, + CancellationToken token ) override; + private: std::shared_ptr mImpl; }; diff --git a/src/modules/vfs/include/eepp/vfs/sftp/sftpprovider.hpp b/src/modules/vfs/include/eepp/vfs/sftp/sftpprovider.hpp index df54c300b..f20d6c2ec 100644 --- a/src/modules/vfs/include/eepp/vfs/sftp/sftpprovider.hpp +++ b/src/modules/vfs/include/eepp/vfs/sftp/sftpprovider.hpp @@ -149,6 +149,11 @@ class EE_VFS_API SFTPProvider : public VFSProvider { VFSTask canonicalize( const URI& uri, CancellationToken token ) override; + /** Whether canonical paths from these authorities share one resource namespace. + * Unknown identity returns Unsupported; this does not promise native rename support. */ + VFSTask sameFileSystem( const URI& from, const URI& to, + CancellationToken token ) override; + VFSTask setPermissions( const URI& uri, Uint32 permissions, CancellationToken token ) override; diff --git a/src/modules/vfs/src/eepp/vfs/providers/archiveprovider.cpp b/src/modules/vfs/src/eepp/vfs/providers/archiveprovider.cpp index 6403d8d7e..aa45ed55d 100644 --- a/src/modules/vfs/src/eepp/vfs/providers/archiveprovider.cpp +++ b/src/modules/vfs/src/eepp/vfs/providers/archiveprovider.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -694,4 +695,18 @@ VFSTask ArchiveProvider::canonicalize( const URI& uri, CancellationToken to } ); } +VFSTask ArchiveProvider::sameFileSystem( const URI& from, const URI& to, + CancellationToken token ) { + if ( token.isCancellationRequested() ) + return readyError( cancelledError( from ) ); + if ( !VFSPath::validURI( from ) || !VFSPath::validURI( to ) ) + return readyError( VFSErrorCode::InvalidURI, to, "NUL in provider URI" ); + // Canonical URI paths include the backing archive path and the member path. Different + // archive files remain comparable within one authority; UNC host aliases are uncertain. + if ( from.hasSameAuthority( to ) ) + return readyValue( true ); + return readyError( VFSErrorCode::Unsupported, to, + "Cannot determine archive identity across provider authorities" ); +} + }} // namespace EE::VFS diff --git a/src/modules/vfs/src/eepp/vfs/providers/localprovider.cpp b/src/modules/vfs/src/eepp/vfs/providers/localprovider.cpp index 2695f524a..cdcc59ab5 100644 --- a/src/modules/vfs/src/eepp/vfs/providers/localprovider.cpp +++ b/src/modules/vfs/src/eepp/vfs/providers/localprovider.cpp @@ -1,5 +1,7 @@ #include +#include + namespace EE { namespace VFS { LocalProvider::LocalProvider( std::shared_ptr scheduler ) : @@ -7,4 +9,28 @@ LocalProvider::LocalProvider( std::shared_ptr scheduler ) : LocalProvider::~LocalProvider() = default; +VFSTask LocalProvider::sameFileSystem( const URI& from, const URI& to, + CancellationToken token ) { + if ( token.isCancellationRequested() ) + return readyError( cancelledError( from ) ); + if ( from.hasSameAuthority( to ) ) + return readyValue( true ); + auto source = toNativePath( from ); + if ( !source ) + return readyError( std::move( source ).error() ); + auto destination = toNativePath( to ); + if ( !destination ) + return readyError( std::move( destination ).error() ); + const std::string_view sourceHost = + from.getHost() == "localhost" ? std::string_view{} : std::string_view( from.getHost() ); + const std::string_view destinationHost = + to.getHost() == "localhost" ? std::string_view{} : std::string_view( to.getHost() ); + // Local paths share a namespace even across devices. Different UNC hosts may still be + // aliases of one server; without resolving that identity, reject conservatively. + if ( String::iequals( sourceHost, destinationHost ) ) + return readyValue( true ); + return readyError( VFSErrorCode::Unsupported, to, + "Cannot determine identity across UNC hosts" ); +} + }} // namespace EE::VFS diff --git a/src/modules/vfs/src/eepp/vfs/providers/memoryprovider.cpp b/src/modules/vfs/src/eepp/vfs/providers/memoryprovider.cpp index 6324c1619..a2a544c4e 100644 --- a/src/modules/vfs/src/eepp/vfs/providers/memoryprovider.cpp +++ b/src/modules/vfs/src/eepp/vfs/providers/memoryprovider.cpp @@ -390,6 +390,14 @@ VFSTask MemoryProvider::canonicalize( const URI& uri, CancellationToken ) { return complete( Ok( std::move( canonical ) ) ); } +VFSTask MemoryProvider::sameFileSystem( const URI& from, const URI& to, + CancellationToken token ) { + if ( token.isCancellationRequested() ) + return readyError( cancelledError( from ) ); + // The host is part of the in-memory node key; different hosts cannot alias. + return readyValue( from.getHost() == to.getHost() ); +} + VFSTask MemoryProvider::setPermissions( const URI& uri, Uint32 permissions, CancellationToken ) { std::lock_guard lock( mMutex ); diff --git a/src/modules/vfs/src/eepp/vfs/providers/resourceprovider.cpp b/src/modules/vfs/src/eepp/vfs/providers/resourceprovider.cpp index ca8a1f385..0225b333b 100644 --- a/src/modules/vfs/src/eepp/vfs/providers/resourceprovider.cpp +++ b/src/modules/vfs/src/eepp/vfs/providers/resourceprovider.cpp @@ -1,7 +1,8 @@ +#include + #include #include #include -#include namespace EE { namespace VFS { @@ -161,4 +162,14 @@ VFSTask ResourceProvider::canonicalize( const URI& uri, CancellationToken ) return readyValue( resourceURI( toPackPath( uri ) ) ); } +VFSTask ResourceProvider::sameFileSystem( const URI& from, const URI& to, + CancellationToken token ) { + if ( token.isCancellationRequested() ) + return readyError( cancelledError( from ) ); + if ( !VFSPath::validURI( from ) || !VFSPath::validURI( to ) ) + return readyError( VFSErrorCode::InvalidURI, to, "NUL in provider URI" ); + // All authorities are path prefixes in the same PackFileIndex namespace. + return readyValue( true ); +} + }} // namespace EE::VFS diff --git a/src/modules/vfs/src/eepp/vfs/providers/zipprovider.cpp b/src/modules/vfs/src/eepp/vfs/providers/zipprovider.cpp index 56523dcbf..84c4b7872 100644 --- a/src/modules/vfs/src/eepp/vfs/providers/zipprovider.cpp +++ b/src/modules/vfs/src/eepp/vfs/providers/zipprovider.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -520,4 +521,18 @@ VFSTask ZipProvider::canonicalize( const URI& uri, CancellationToken token } ); } +VFSTask ZipProvider::sameFileSystem( const URI& from, const URI& to, + CancellationToken token ) { + if ( token.isCancellationRequested() ) + return readyError( cancelledError( from ) ); + if ( !VFSPath::validURI( from ) || !VFSPath::validURI( to ) ) + return readyError( VFSErrorCode::InvalidURI, to, "NUL in provider URI" ); + // Canonical URI paths include the backing archive path and the member path. Different + // archive files remain comparable within one authority; UNC host aliases are uncertain. + if ( from.hasSameAuthority( to ) ) + return readyValue( true ); + return readyError( VFSErrorCode::Unsupported, to, + "Cannot determine archive identity across provider authorities" ); +} + }} // namespace EE::VFS diff --git a/src/modules/vfs/src/eepp/vfs/sftp/sftpprovider.cpp b/src/modules/vfs/src/eepp/vfs/sftp/sftpprovider.cpp index f5bee0bbd..c73a868a8 100644 --- a/src/modules/vfs/src/eepp/vfs/sftp/sftpprovider.cpp +++ b/src/modules/vfs/src/eepp/vfs/sftp/sftpprovider.cpp @@ -1,3 +1,5 @@ +#include + #include "sftpproviderimpl.hpp" #include "sshconnection.hpp" #include @@ -5,7 +7,6 @@ #include #include #include -#include #include #include @@ -815,6 +816,44 @@ VFSTask SFTPProvider::canonicalize( const URI& uri, CancellationToken token } ); } +VFSTask SFTPProvider::sameFileSystem( const URI& from, const URI& to, + CancellationToken token ) { + if ( token.isCancellationRequested() ) + return readyError( cancelledError( from ) ); + if ( from.hasSameAuthority( to ) ) + return readyValue( true ); + return mImpl->connection( from ).then( + [impl = mImpl, to, + token]( VFSResult>&& source ) mutable -> VFSTask { + if ( !source ) + return readyError( std::move( source ).error() ); + return impl->connection( to ).then( + // Keep provider state and the source session alive while resolving the destination. + [impl = std::move( impl ), source = std::move( source ).value(), + to = std::move( to ), token]( + VFSResult>&& destination ) -> VFSResult { + if ( token.isCancellationRequested() ) + return Err( cancelledError( to ) ); + if ( !destination ) + return Err( std::move( destination ).error() ); + // Pooling already resolves aliases, users, ports, proxy routes and trust + // policy. + if ( source == destination.value() ) + return Ok( true ); + const auto& a = source->config(); + const auto& b = destination.value()->config(); + // Different users, ports or authentication policies on one host can still + // expose the same files. Distinct sessions cannot establish that identity. + if ( a.hostName == b.hostName ) { + return Err( VFSError( VFSErrorCode::Unsupported, to, + "Cannot verify resource identity across distinct SSH " + "sessions on the same endpoint" ) ); + } + return Ok( false ); + } ); + } ); +} + VFSTask SFTPProvider::setPermissions( const URI& uri, Uint32 permissions, CancellationToken token ) { return withConnection( diff --git a/src/modules/vfs/src/eepp/vfs/transferengine.cpp b/src/modules/vfs/src/eepp/vfs/transferengine.cpp index 4ed796787..ba94896fd 100644 --- a/src/modules/vfs/src/eepp/vfs/transferengine.cpp +++ b/src/modules/vfs/src/eepp/vfs/transferengine.cpp @@ -1,7 +1,8 @@ +#include + #include #include #include -#include #include namespace EE { namespace VFS { @@ -54,9 +55,30 @@ VFSResult TransferEngine::run() { "Source and destination are the same" ) ); } - mSameProvider = mSrcProvider == mDstProvider && mSource.hasSameAuthority( mDestination ); + if ( mSrcProvider == mDstProvider ) { + auto same = await( mSrcProvider->sameFileSystem( mSource, mDestination, mToken ) ); + if ( !same ) + return Err( std::move( same ).error() ); + mSameFileSystem = same.value(); + } + // Equivalent authorities can name the very same file. Check before even attempting a + // native move; copy/delete must never publish onto, or remove, its own source. + if ( mSameFileSystem && !mSource.hasSameAuthority( mDestination ) ) { + auto destination = await( mDstProvider->stat( mDestination, {}, mToken ) ); + if ( destination ) { + auto same = isSameFile( mSource, mDestination ); + if ( !same ) + return Err( std::move( same ).error() ); + if ( same.value() ) { + return Err( VFSError( VFSErrorCode::InvalidOperation, mDestination, + "Source and destination are the same file" ) ); + } + } else if ( !destination.error().is( VFSErrorCode::NotFound ) ) { + return Err( std::move( destination ).error() ); + } + } - if ( mMove && mSameProvider ) { + if ( mMove && mSameFileSystem ) { auto renamed = await( mSrcProvider->rename( mSource, mDestination, RenameOptions{ mOptions.overwrite == OverwritePolicy::Overwrite }, mToken ) ); @@ -104,7 +126,7 @@ VFSResult TransferEngine::run() { return Err( std::move( rootInfo ).error() ); } - if ( rootInfo.value().isDirectory() && mSameProvider ) { + if ( rootInfo.value().isDirectory() && mSameFileSystem ) { if ( auto checked = checkDirectoryDestination(); !checked ) return Err( std::move( checked ).error() ); } @@ -184,6 +206,9 @@ VFSResult TransferEngine::checkDirectoryDestination() { for ( auto it = missing.rbegin(); it != missing.rend(); ++it ) ancestor = ancestor.withAppendedSegment( *it ); ancestor.normalize(); + // Both paths have already been resolved in the same provider namespace. Use one authority + // for the containment comparison so spelling an SSH alias cannot bypass it. + ancestor.setAuthority( source.value().getAuthority() ); if ( ancestor.isSameOrChildOf( source.value() ) ) return invalid(); return Ok(); @@ -270,7 +295,7 @@ VFSResult TransferEngine::copyNode( const Node& node, const URI& source, advanceFiles( info ); return Ok( false ); } - if ( mSameProvider ) { + if ( mSameFileSystem ) { auto same = isSameFile( source, destination ); if ( !same ) return Err( std::move( same ).error() ); @@ -383,7 +408,7 @@ VFSResult TransferEngine::isSameFile( const URI& source, const URI& destin auto canonicalDestination = await( mDstProvider->canonicalize( destination, mToken ) ); if ( !canonicalDestination ) return Err( std::move( canonicalDestination ).error() ); - return Ok( canonicalSource.value() == canonicalDestination.value() ); + return Ok( canonicalSource.value().getPath() == canonicalDestination.value().getPath() ); } VFSResult TransferEngine::copyFile( const VFSFileInfo& info, const URI& source, diff --git a/src/modules/vfs/src/eepp/vfs/transferengine.hpp b/src/modules/vfs/src/eepp/vfs/transferengine.hpp index 6fb0ec4ed..96f85d8ed 100644 --- a/src/modules/vfs/src/eepp/vfs/transferengine.hpp +++ b/src/modules/vfs/src/eepp/vfs/transferengine.hpp @@ -18,7 +18,7 @@ class TransferState; } /** Recursive copy or move of one entry, across providers when necessary. A move within one - * provider and authority is a rename when the provider can do it. */ + * provider filesystem namespace is a rename when the provider can do it. */ class TransferEngine { public: /** Runs a copy (or a move) on pool and returns its handle. Without a pool (the VFS is shutting @@ -77,8 +77,8 @@ class TransferEngine { VFSProvider* mDstProvider{ nullptr }; TransferResult mResult; bool mMove; - /** Source and destination are served by the same provider and authority. */ - bool mSameProvider{ false }; + /** Source and destination canonical paths belong to the same provider namespace. */ + bool mSameFileSystem{ false }; }; }} // namespace EE::VFS diff --git a/src/modules/vfs/src/eepp/vfs/virtualfilesystem.cpp b/src/modules/vfs/src/eepp/vfs/virtualfilesystem.cpp index 77994b202..7e98ca86e 100644 --- a/src/modules/vfs/src/eepp/vfs/virtualfilesystem.cpp +++ b/src/modules/vfs/src/eepp/vfs/virtualfilesystem.cpp @@ -298,7 +298,7 @@ VFSTask> VirtualFileSystem::readAll( const URI& uri, Cancella std::vector data; auto info = await( handle.stat() ); if ( info && info.value().size() ) { - if ( *info.value().size() > maxSize ) { + if ( *info.value().size() > maxSize || *info.value().size() > data.max_size() ) { handle.close(); return Err( VFSError( VFSErrorCode::InvalidOperation, uri, "File exceeds the maximum read size" ) ); @@ -312,11 +312,17 @@ VFSTask> VirtualFileSystem::readAll( const URI& uri, Cancella return Err( cancelledError( uri ) ); } size_t old = data.size(); - size_t chunk = FileWriter::IOChunkSize; - if ( data.capacity() > old && data.capacity() - old >= 1 ) - chunk = std::max( chunk, data.capacity() - old ); - if ( old + chunk > maxSize + 1 ) - chunk = static_cast( maxSize + 1 - old ); + // Capacity reservation must not turn a bounded I/O call into a whole-file SFTP + // operation. Keep enough room for one overflow byte, without maxSize + 1 wrapping. + const Uint64 remaining = maxSize - old; + size_t chunk = remaining < FileWriter::IOChunkSize + ? static_cast( remaining ) + 1 + : FileWriter::IOChunkSize; + if ( chunk > data.max_size() - old ) { + handle.close(); + return Err( VFSError( VFSErrorCode::InvalidOperation, uri, + "File exceeds the maximum buffer size" ) ); + } data.resize( old + chunk ); auto read = await( handle.read( offset, std::span( data.data() + old, chunk ), token ) ); diff --git a/src/modules/vfs/src/eepp/vfs/watchers/earlywatchevents.hpp b/src/modules/vfs/src/eepp/vfs/watchers/earlywatchevents.hpp index 3d6763a88..274c6f7ab 100644 --- a/src/modules/vfs/src/eepp/vfs/watchers/earlywatchevents.hpp +++ b/src/modules/vfs/src/eepp/vfs/watchers/earlywatchevents.hpp @@ -4,51 +4,27 @@ #include #include #include -#include namespace EE { namespace VFS { namespace detail { -/** Callbacks received before efsw returns the id that the adapter must register. */ +/** Records loss of incremental ordering before a native watch registration is published. */ class EarlyWatchEvents { public: - static constexpr size_t MaxEvents = 4096; - - struct Event { - std::string dir; - std::string filename; - std::string oldFilename; - efsw::Action action; - }; - - void push( const std::string& dir, const std::string& filename, efsw::Action action, - const std::string& oldFilename ) { - if ( mOverflow ) - return; - if ( mEvents.size() == MaxEvents ) { - invalidate(); - return; - } - mEvents.push_back( { dir, filename, oldFilename, action } ); + void push( const std::string&, const std::string&, efsw::Action, const std::string& ) { + invalidate(); } - void invalidate() { - mEvents.clear(); - mOverflow = true; - } + void invalidate() { mPending = true; } - /** An invalidation replaces the truncated stream; it must never look complete. */ - template void replay( WatchSink& sink, Dispatch&& dispatch ) const { - if ( mOverflow ) { + /** Live events can arrive as soon as registration is published. Invalidate instead of + * replaying older events after them; no early payloads or callbacks need to be retained. */ + void replay( WatchSink& sink ) const { + if ( mPending ) sink.rescanRequired(); - } else { - for ( const auto& event : mEvents ) - dispatch( event ); - } } private: - std::vector mEvents; - bool mOverflow{ false }; + bool mPending{ false }; }; }}} // namespace EE::VFS::detail diff --git a/src/modules/vfs/src/eepp/vfs/watchers/localwatchbackend.cpp b/src/modules/vfs/src/eepp/vfs/watchers/localwatchbackend.cpp index d832c26d2..81d354fcd 100644 --- a/src/modules/vfs/src/eepp/vfs/watchers/localwatchbackend.cpp +++ b/src/modules/vfs/src/eepp/vfs/watchers/localwatchbackend.cpp @@ -102,9 +102,7 @@ class LocalWatchBackendImpl : public std::enable_shared_from_this mReadDelay{ 0 }; std::atomic mStopping{ false }; std::atomic mDropping{ false }; }; @@ -511,6 +518,64 @@ UTEST( SFTP, conformanceOverRealServer ) { EXPECT_TRUE( space.value().capacity > 0 ); } +UTEST( SFTP, aliasesCannotCopyOrMoveOntoTheSameRemoteFile ) { + REQUIRE_SSHD(); + SFTPFixture fx; + const std::string name = uniqueName( "alias-identity" ); + const URI source = server.uri( "testhost", name ); + // Both names inherit the same HostName, User, Port and authentication policy. + const URI alias = server.uri( "testhost-alias", name ); + FileSystem::fileWrite( server.remoteRoot() + "/" + name, std::string_view( "keep" ) ); + const auto before = + waitFor( fx.vfs.stat( URI::fromFSPath( server.remoteRoot() + "/" + name ) ) ) + .value() + .revision; + CopyOptions options; + options.overwrite = OverwritePolicy::Overwrite; + for ( const bool reverse : { false, true } ) { + const URI& from = reverse ? alias : source; + const URI& to = reverse ? source : alias; + EXPECT_EQ( VFSErrorCode::InvalidOperation, + codeOf( waitFor( fx.vfs.copy( from, to, options ).completion() ) ) ); + EXPECT_EQ( VFSErrorCode::InvalidOperation, + codeOf( waitFor( fx.vfs.move( from, to, options ).completion() ) ) ); + EXPECT_STDSTREQ( "keep", readString( fx.vfs, source ) ); + EXPECT_TRUE( before.matches( + waitFor( fx.vfs.stat( URI::fromFSPath( server.remoteRoot() + "/" + name ) ) ) + .value() + .revision ) ); + } + // The pool counts authority entries, including aliases sharing the same session. + EXPECT_EQ( 2u, fx.sftp->connectionCount() ); + const std::string directory = uniqueName( "alias-tree" ); + FileSystem::makeDir( server.remoteRoot() + "/" + directory ); + const URI nested = server.uri( "testhost-alias", directory + "/nested" ); + EXPECT_EQ( VFSErrorCode::InvalidOperation, + codeOf( waitFor( + fx.vfs.copy( server.uri( "testhost", directory ), nested ).completion() ) ) ); + EXPECT_EQ( VFSErrorCode::NotFound, codeOf( waitFor( fx.vfs.stat( nested ) ) ) ); + // Canonical paths through a host alias remain independently usable for different files. + const URI target = server.uri( "testhost-alias", name + "-copy" ); + ASSERT_TRUE( waitFor( fx.vfs.copy( source, target ).completion() ) ); + EXPECT_STDSTREQ( "keep", readString( fx.vfs, target ) ); + // The same host with a different authentication policy uses a separate session. Its + // namespace might coincide, so reject even a new destination before attempting a write. + const URI uncertain = server.uri( "edkey", name ); + EXPECT_EQ( VFSErrorCode::Unsupported, + codeOf( waitFor( fx.vfs.copy( source, uncertain, options ).completion() ) ) ); + EXPECT_EQ( VFSErrorCode::Unsupported, + codeOf( waitFor( fx.vfs.move( source, uncertain, options ).completion() ) ) ); + const URI absent = server.uri( "edkey", name + "-uncertain" ); + EXPECT_EQ( VFSErrorCode::Unsupported, + codeOf( waitFor( fx.vfs.copy( source, absent ).completion() ) ) ); + EXPECT_EQ( VFSErrorCode::NotFound, codeOf( waitFor( fx.vfs.stat( absent ) ) ) ); + EXPECT_STDSTREQ( "keep", readString( fx.vfs, source ) ); + EXPECT_TRUE( before.matches( + waitFor( fx.vfs.stat( URI::fromFSPath( server.remoteRoot() + "/" + name ) ) ) + .value() + .revision ) ); +} + UTEST( SFTP, replacementWithoutPosixRenamePreservesBothFiles ) { auto& server = TestSSHServer::withoutPosixRename(); if ( !server.available() ) { @@ -540,6 +605,34 @@ UTEST( SFTP, replacementWithoutPosixRenamePreservesBothFiles ) { EXPECT_STDSTREQ( "precious old data", readString( vfs, URI::fromFSPath( dst ) ) ); } +UTEST( SFTP, progressingReadAllCanOutlastTheOperationDeadline ) { + REQUIRE_SSHD(); + BlackholeProxy proxy( server.port() ); + ASSERT_NE( 0, proxy.port() ); + SFTPProviderConfig config; + // Scale the deadline down so this regression does not add a 30-second delay to every run. + config.operationTimeout = System::Seconds( 1 ); + VirtualFileSystem vfs; + auto provider = server.provider( config ); + vfs.registerProvider( provider ); + const std::string name = uniqueName( "slow-progress" ); + ASSERT_TRUE( FileSystem::fileWrite( server.remoteRoot() + "/" + name, + std::string( 4 * 1024 * 1024, 'x' ) ) ); + URI uri = server.uri( "testhost", name ); + uri.setPort( static_cast( proxy.port() ) ); + REQUIRE_OK( waitFor( vfs.stat( uri ) ) ); + proxy.readDelay( 10 ); + const auto start = std::chrono::steady_clock::now(); + auto data = waitFor( vfs.readAll( uri ) ); + REQUIRE_OK( data ); + EXPECT_EQ( 4u * 1024 * 1024, data.value().size() ); + EXPECT_TRUE( std::all_of( data.value().begin(), data.value().end(), + []( Uint8 c ) { return c == 'x'; } ) ); + EXPECT_TRUE( std::chrono::steady_clock::now() - start > std::chrono::seconds( 1 ) ); + REQUIRE_OK( waitFor( vfs.stat( uri ) ) ); + EXPECT_EQ( 1u, provider->connectionCount() ); +} + UTEST( SFTP, blackholedReadyOperationsFailTheSessionAndReconnect ) { REQUIRE_SSHD(); for ( bool keepalive : { false, true } ) { diff --git a/src/tests/unit_tests/ui_tab_focus_tests.cpp b/src/tests/unit_tests/ui_tab_focus_tests.cpp index bd0928625..2548c634b 100644 --- a/src/tests/unit_tests/ui_tab_focus_tests.cpp +++ b/src/tests/unit_tests/ui_tab_focus_tests.cpp @@ -649,6 +649,14 @@ UTEST( TabFocus, ModelViewsDoNotTabThroughRecycledCells ) { EXPECT_TRUE( view->isTabFocusable() ); auto* cell = view->getCellFromIndex( view->getModel()->index( 0 ) ); ASSERT_TRUE( cell != nullptr ); + // Recycled rows remain discoverable through Node's type API, as cells already are. + auto* row = cell->getParent(); + ASSERT_TRUE( row != nullptr ); + EXPECT_EQ( UI_TYPE_TABLEROW, row->getType() ); + EXPECT_TRUE( row->isType( UI_TYPE_TABLEROW ) ); + EXPECT_TRUE( row->isType( UI_TYPE_WIDGET ) ); + EXPECT_TRUE( row->isType( UI_TYPE_UINODE ) ); + EXPECT_FALSE( row->isType( UI_TYPE_TABLECELL ) ); EXPECT_FALSE( cell->isTabFocusable() ); EXPECT_FALSE( view->getVerticalScrollBar()->getSlider()->isTabFocusable() ); EXPECT_FALSE( view->getHorizontalScrollBar()->getSlider()->isTabFocusable() ); diff --git a/src/tests/unit_tests/vfs_review_tests.cpp b/src/tests/unit_tests/vfs_review_tests.cpp index 029c83f24..f3d1475a9 100644 --- a/src/tests/unit_tests/vfs_review_tests.cpp +++ b/src/tests/unit_tests/vfs_review_tests.cpp @@ -1,10 +1,13 @@ #include "utest.hpp" #include "vfs_test_utils.hpp" +#include +#include #include #include #include #include #include +#include #include using namespace EE; @@ -255,6 +258,188 @@ UTEST( VFSPath, nulLocalURIsNeverAliasTheNativePrefix ) { EXPECT_EQ( VFSErrorCode::InvalidURI, codeOf( LocalProvider::toNativePath( host ) ) ); } +UTEST( VFSSafety, equivalentLocalAuthoritiesNeverReplaceTheSource ) { + TempDir temp; + VirtualFileSystem vfs; + const URI file = temp.uri().withAppendedSegment( "file" ); + const URI link = temp.uri().withAppendedSegment( "hardlink" ); + ASSERT_TRUE( waitFor( vfs.writeAll( file, "keep" ) ) ); + std::error_code error; + const std::string nativeFile = file.getFSPath(), nativeLink = link.getFSPath(); + std::filesystem::create_hard_link( + std::filesystem::path( std::u8string( nativeFile.begin(), nativeFile.end() ) ), + std::filesystem::path( std::u8string( nativeLink.begin(), nativeLink.end() ) ), error ); + ASSERT_FALSE( error ); + URI alias = file; + alias.setHost( "localhost" ); + const auto revision = waitFor( vfs.stat( file ) ).value().revision; + CopyOptions options; + options.overwrite = OverwritePolicy::Overwrite; + for ( const bool reverse : { false, true } ) { + const URI& source = reverse ? alias : file; + const URI& destination = reverse ? file : alias; + EXPECT_EQ( VFSErrorCode::InvalidOperation, + codeOf( waitFor( vfs.copy( source, destination, options ).completion() ) ) ); + EXPECT_TRUE( revision.matches( waitFor( vfs.stat( file ) ).value().revision ) ); + EXPECT_EQ( VFSErrorCode::InvalidOperation, + codeOf( waitFor( vfs.move( source, destination, options ).completion() ) ) ); + EXPECT_TRUE( revision.matches( waitFor( vfs.stat( file ) ).value().revision ) ); + EXPECT_TRUE( revision.matches( waitFor( vfs.stat( link ) ).value().revision ) ); + EXPECT_STDSTREQ( "keep", readString( vfs, file ) ); + EXPECT_STDSTREQ( "keep", readString( vfs, link ) ); + } + URI nested = temp.uri().withAppendedSegment( "nested" ); + nested.setHost( "localhost" ); + EXPECT_EQ( VFSErrorCode::InvalidOperation, + codeOf( waitFor( vfs.copy( temp.uri(), nested, options ).completion() ) ) ); + EXPECT_EQ( VFSErrorCode::NotFound, codeOf( waitFor( vfs.stat( nested ) ) ) ); + // Distinct files through those same authorities remain transferable. + URI destination = temp.uri().withAppendedSegment( "copy" ); + destination.setHost( "localhost" ); + ASSERT_TRUE( waitFor( vfs.copy( file, destination ).completion() ) ); + EXPECT_STDSTREQ( "keep", readString( vfs, destination ) ); +} + +UTEST( VFSProvider, allProvidersExposeCancellableNamespaceIdentity ) { + TempDir temp; + VirtualFileSystem vfs; + vfs.registerProvider( std::make_shared() ); + CancellationSource cancelled; + cancelled.cancel(); + const URI locations[]{ + temp.uri(), + URI( "mem://test/file" ), + URI( "res://fonts/file" ), + ZipProvider::makeURI( temp.uri().withAppendedSegment( "file.zip" ) ), + ArchiveProvider::makeURI( temp.uri().withAppendedSegment( "file.tar" ) ), +#ifdef EE_VFS_SFTP + URI( "sftp://unused-host/file" ), +#endif + }; + for ( const URI& uri : locations ) { + auto provider = vfs.providerFor( uri ); + ASSERT_TRUE( provider ); + auto identity = waitFor( provider->sameFileSystem( uri, uri, {} ) ); + ASSERT_TRUE( identity ); + EXPECT_TRUE( identity.value() ); + EXPECT_EQ( VFSErrorCode::Cancelled, + codeOf( waitFor( provider->sameFileSystem( uri, uri, cancelled.token() ) ) ) ); + } + // res:// authorities are convenience path prefixes in the global pack namespace. + auto resources = vfs.providerFor( URI( "res:///" ) ); + EXPECT_TRUE( waitFor( resources->sameFileSystem( URI( "res://fonts/file" ), + URI( "res:///fonts/file" ), {} ) ) + .value() ); +} + +UTEST( VFSSafety, transfersUseTheProvidersNamespaceDecision ) { + class IdentityProvider : public MemoryProvider { + public: + VFSTask sameFileSystem( const URI& from, const URI&, CancellationToken ) override { + return readyError( VFSErrorCode::Unsupported, from, "Identity is uncertain" ); + } + }; + VirtualFileSystem vfs; + auto provider = std::make_shared(); + vfs.registerProvider( provider ); + const URI source( "mem://one/source" ), destination( "mem://one/destination" ); + provider->putFile( source, "keep" ); + // The authorities match, but only the provider can decide whether paths are comparable. + EXPECT_EQ( VFSErrorCode::Unsupported, + codeOf( waitFor( vfs.copy( source, destination ).completion() ) ) ); + EXPECT_EQ( VFSErrorCode::Unsupported, + codeOf( waitFor( vfs.move( source, destination ).completion() ) ) ); + EXPECT_EQ( VFSErrorCode::NotFound, codeOf( waitFor( vfs.stat( destination ) ) ) ); + EXPECT_STDSTREQ( "keep", readString( vfs, source ) ); +} + +UTEST( VFSSafety, inMemoryAuthoritiesRemainSeparateNamespaces ) { + VirtualFileSystem vfs; + auto provider = std::make_shared(); + vfs.registerProvider( provider ); + provider->putFile( URI( "mem://one/file" ), "one" ); + provider->putDirectory( URI( "mem://two/" ) ); + ASSERT_TRUE( + waitFor( vfs.move( URI( "mem://one/file" ), URI( "mem://two/file" ) ).completion() ) ); + EXPECT_STDSTREQ( "one", readString( vfs, URI( "mem://two/file" ) ) ); + EXPECT_EQ( VFSErrorCode::NotFound, codeOf( waitFor( vfs.stat( URI( "mem://one/file" ) ) ) ) ); +} + +UTEST( VFSOrchestration, knownSizeNeverExpandsTheReadRequest ) { + class GeneratedFile : public FileHandleImpl { + public: + explicit GeneratedFile( Uint64 length, bool known ) : size( length ), knownSize( known ) {} + + VFSTask read( Uint64 offset, std::span data, CancellationToken ) override { + largestRead = std::max( largestRead, data.size() ); + ++reads; + const size_t count = + static_cast( std::min( data.size(), size - offset ) ); + std::memset( data.data(), 'x', count ); + return readyValue( count ); + } + + VFSTask write( Uint64, std::span, CancellationToken ) override { + return readyError( VFSErrorCode::ReadOnly ); + } + + VFSTask truncate( Uint64 ) override { + return readyError( VFSErrorCode::ReadOnly ); + } + + VFSTask flush() override { return readyValue(); } + + VFSTask close() override { return readyValue(); } + + VFSTask stat() override { + VFSFileInfo info; + if ( knownSize ) + info.setSize( size ); + return readyValue( std::move( info ) ); + } + + ProviderCapabilities capabilities() const override { return VFSCapability::Read; } + + Uint64 size; + size_t largestRead{ 0 }; + size_t reads{ 0 }; + bool knownSize; + }; + class GeneratedProvider : public MemoryProvider { + public: + VFSTask open( const URI& uri, const OpenOptions&, CancellationToken ) override { + return readyValue( uri, file ); + } + + std::shared_ptr file; + }; + VirtualFileSystem vfs; + auto provider = std::make_shared(); + vfs.registerProvider( provider ); + const URI file( "mem://test/generated" ); + provider->file = std::make_shared( 64 * 1024 * 1024, true ); + auto read = waitFor( vfs.readAll( file ) ); + ASSERT_TRUE( read ); + EXPECT_EQ( 64u * 1024 * 1024, read.value().size() ); + EXPECT_LE( provider->file->largestRead, 256u * 1024 ); + EXPECT_GT( provider->file->reads, 1u ); + EXPECT_EQ( int( 'x' ), int( read.value().back() ) ); + for ( const bool known : { false, true } ) { + provider->file = std::make_shared( 10, known ); + auto unlimited = waitFor( vfs.readAll( file, {}, std::numeric_limits::max() ) ); + ASSERT_TRUE( unlimited ); + EXPECT_EQ( 10u, unlimited.value().size() ); + EXPECT_EQ( VFSErrorCode::InvalidOperation, + codeOf( waitFor( vfs.readAll( file, {}, 9 ) ) ) ); + EXPECT_EQ( 10u, waitFor( vfs.readAll( file, {}, 10 ) ).value().size() ); + provider->file = std::make_shared( 0, known ); + EXPECT_TRUE( waitFor( vfs.readAll( file, {}, 0 ) ).value().empty() ); + provider->file = std::make_shared( 1, known ); + EXPECT_EQ( VFSErrorCode::InvalidOperation, + codeOf( waitFor( vfs.readAll( file, {}, 0 ) ) ) ); + } +} + UTEST( VFSPath, basenameValidationUsesTheDestinationRules ) { const URI local = URI::fromFSPath( System::Sys::getTempPath() ); const URI remote( "sftp://host/folder" ); @@ -430,30 +615,60 @@ UTEST( VFSSafety, directoryAliasesCannotBypassTheSelfCopyGuard ) { } } -UTEST( VFSWatch, earlyNativeOverflowRequiresARescan ) { - EE::VFS::detail::EarlyWatchEvents pending; - for ( size_t i = 0; i < EE::VFS::detail::EarlyWatchEvents::MaxEvents + 8; ++i ) - pending.push( "/root/", "entry", efsw::Actions::Add, {} ); +UTEST( VFSWatch, earlyNativeEventsAlwaysRequireARescan ) { RecordingSink sink; - unsigned delivered = 0; - pending.replay( sink, [&]( const auto& ) { ++delivered; } ); - EXPECT_EQ( 0u, delivered ); - ASSERT_EQ( 1u, sink.events.size() ); - EXPECT_EQ( WatchEventType::RescanRequired, sink.events[0].type ); - // An explicit missed-actions callback before registration has the same contract. + EE::VFS::detail::EarlyWatchEvents clean; + clean.replay( sink ); + EXPECT_TRUE( sink.events.empty() ); + for ( const size_t count : { size_t( 1 ), size_t( 5000 ) } ) { + EE::VFS::detail::EarlyWatchEvents pending; + for ( size_t i = 0; i < count; ++i ) + pending.push( "/root/", "older", efsw::Actions::Add, {} ); + pending.replay( sink ); + } EE::VFS::detail::EarlyWatchEvents missed; missed.invalidate(); - missed.push( "/root/", "later", efsw::Actions::Add, {} ); - missed.replay( sink, [&]( const auto& ) { ++delivered; } ); - EXPECT_EQ( 0u, delivered ); - EXPECT_EQ( 2u, sink.events.size() ); - // A new registration starts with a clean buffer and preserves the full bounded stream. - EE::VFS::detail::EarlyWatchEvents complete; - for ( size_t i = 0; i < EE::VFS::detail::EarlyWatchEvents::MaxEvents; ++i ) - complete.push( "/root/", "entry", efsw::Actions::Add, {} ); - complete.replay( sink, [&]( const auto& ) { ++delivered; } ); - EXPECT_EQ( EE::VFS::detail::EarlyWatchEvents::MaxEvents, delivered ); - EXPECT_EQ( 2u, sink.events.size() ); + missed.replay( sink ); + ASSERT_EQ( 3u, sink.events.size() ); + for ( const auto& event : sink.events ) + EXPECT_EQ( WatchEventType::RescanRequired, event.type ); +} + +UTEST( VFSWatch, liveEventsDuringRegistrationCannotBeFollowedByStaleReplay ) { + class PausedSink : public RecordingSink { + public: + void rescanRequired() override { + std::unique_lock lock( mutex ); + replaying = true; + wake.notify_all(); + wake.wait( lock, [&] { return release; } ); + RecordingSink::rescanRequired(); + } + + std::mutex mutex; + std::condition_variable wake; + bool replaying{ false }; + bool release{ false }; + }; + EE::VFS::detail::EarlyWatchEvents pending; + pending.push( "/root/", "entry", efsw::Actions::Add, {} ); + PausedSink sink; + std::thread registration( [&] { pending.replay( sink ); } ); + bool replaying; + { + std::unique_lock lock( sink.mutex ); + replaying = + sink.wake.wait_for( lock, std::chrono::seconds( 2 ), [&] { return sink.replaying; } ); + // The newer native event arrives after publication while registration is paused. + sink.push( WatchEvent{ "entry", {}, WatchEventType::Removed } ); + sink.release = true; + } + sink.wake.notify_all(); + registration.join(); + ASSERT_TRUE( replaying ); + ASSERT_EQ( 2u, sink.events.size() ); + EXPECT_EQ( WatchEventType::Removed, sink.events[0].type ); + EXPECT_EQ( WatchEventType::RescanRequired, sink.events[1].type ); } UTEST( VFSWatch, oversizedInitialPollingSnapshotIsRejected ) { diff --git a/src/tools/eefm/fileview.cpp b/src/tools/eefm/fileview.cpp index e78bb98b4..e064d92c5 100644 --- a/src/tools/eefm/fileview.cpp +++ b/src/tools/eefm/fileview.cpp @@ -432,8 +432,8 @@ std::optional FileTableView::rowOf( Node* node ) const { ModelIndex index; if ( node->isType( UI_TYPE_TABLECELL ) ) { index = node->asType()->getCurIndex(); - } else if ( auto* row = dynamic_cast( node ) ) { - index = row->getCurIndex(); + } else if ( node->isType( UI_TYPE_TABLEROW ) ) { + index = node->asType()->getCurIndex(); } else { node = node->getParent(); continue;