mirror of
https://github.com/SpartanJ/eepp.git
synced 2026-10-25 15:53:11 +03:00
fix(vfs): guard aliased transfers, invalidate early watch events and bound reads
- Resolve equivalent local and SSH authorities before modifying transfer destinations. - Reject uncertain cross-authority identity before copying or removing files. - Emit RescanRequired for early native watch events instead of replaying stale payloads. - Cap readAll requests at 256 KiB independently of reserved capacity. - Handle UINT64_MAX read limits without overflow. - Add regressions for aliases, watch ordering, bounded reads and throttled SFTP transfers.
This commit is contained in:
+16
-3
@@ -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<TransferResult>&& 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:
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -69,6 +69,15 @@ class EE_VFS_API VFSProvider {
|
||||
*/
|
||||
virtual VFSTask<URI> 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<bool> sameFileSystem( const URI& from, const URI& to,
|
||||
CancellationToken token ) = 0;
|
||||
|
||||
/** Optional: sets POSIX permission bits (VFSCapability::Permissions). */
|
||||
virtual VFSTask<void> setPermissions( const URI& uri, Uint32 permissions,
|
||||
CancellationToken /*token*/ ) {
|
||||
|
||||
@@ -74,6 +74,9 @@ class EE_VFS_API ArchiveProvider : public VFSProvider {
|
||||
|
||||
VFSTask<URI> canonicalize( const URI& uri, CancellationToken token ) override;
|
||||
|
||||
VFSTask<bool> sameFileSystem( const URI& from, const URI& to,
|
||||
CancellationToken token ) override;
|
||||
|
||||
private:
|
||||
std::shared_ptr<ArchiveProviderImpl> mImpl;
|
||||
};
|
||||
|
||||
@@ -50,6 +50,11 @@ class EE_VFS_API LocalProvider : public VFSProvider {
|
||||
|
||||
VFSTask<URI> 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<bool> sameFileSystem( const URI& from, const URI& to,
|
||||
CancellationToken token ) override;
|
||||
|
||||
VFSTask<void> setPermissions( const URI& uri, Uint32 permissions,
|
||||
CancellationToken token ) override;
|
||||
|
||||
|
||||
@@ -52,6 +52,11 @@ class EE_VFS_API MemoryProvider : public VFSProvider {
|
||||
|
||||
VFSTask<URI> 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<bool> sameFileSystem( const URI& from, const URI& to,
|
||||
CancellationToken token ) override;
|
||||
|
||||
VFSTask<void> setPermissions( const URI& uri, Uint32 permissions,
|
||||
CancellationToken token ) override;
|
||||
|
||||
|
||||
@@ -45,6 +45,9 @@ class EE_VFS_API ResourceProvider : public VFSProvider {
|
||||
|
||||
VFSTask<URI> canonicalize( const URI& uri, CancellationToken token ) override;
|
||||
|
||||
VFSTask<bool> 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 );
|
||||
|
||||
|
||||
@@ -57,6 +57,9 @@ class EE_VFS_API ZipProvider : public VFSProvider {
|
||||
|
||||
VFSTask<URI> canonicalize( const URI& uri, CancellationToken token ) override;
|
||||
|
||||
VFSTask<bool> sameFileSystem( const URI& from, const URI& to,
|
||||
CancellationToken token ) override;
|
||||
|
||||
private:
|
||||
std::shared_ptr<ZipProviderImpl> mImpl;
|
||||
};
|
||||
|
||||
@@ -149,6 +149,11 @@ class EE_VFS_API SFTPProvider : public VFSProvider {
|
||||
|
||||
VFSTask<URI> 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<bool> sameFileSystem( const URI& from, const URI& to,
|
||||
CancellationToken token ) override;
|
||||
|
||||
VFSTask<void> setPermissions( const URI& uri, Uint32 permissions,
|
||||
CancellationToken token ) override;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <eepp/vfs/archive/libarchive.hpp>
|
||||
#include <eepp/vfs/detail/archivelocation.hpp>
|
||||
#include <eepp/vfs/detail/memoryhandles.hpp>
|
||||
#include <eepp/vfs/path.hpp>
|
||||
#include <eepp/vfs/providers/localprovider.hpp>
|
||||
#include <limits>
|
||||
#include <mutex>
|
||||
@@ -694,4 +695,18 @@ VFSTask<URI> ArchiveProvider::canonicalize( const URI& uri, CancellationToken to
|
||||
} );
|
||||
}
|
||||
|
||||
VFSTask<bool> ArchiveProvider::sameFileSystem( const URI& from, const URI& to,
|
||||
CancellationToken token ) {
|
||||
if ( token.isCancellationRequested() )
|
||||
return readyError<bool>( cancelledError( from ) );
|
||||
if ( !VFSPath::validURI( from ) || !VFSPath::validURI( to ) )
|
||||
return readyError<bool>( 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<bool>( true );
|
||||
return readyError<bool>( VFSErrorCode::Unsupported, to,
|
||||
"Cannot determine archive identity across provider authorities" );
|
||||
}
|
||||
|
||||
}} // namespace EE::VFS
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include <eepp/vfs/providers/localprovider.hpp>
|
||||
|
||||
#include <eepp/core/string.hpp>
|
||||
|
||||
namespace EE { namespace VFS {
|
||||
|
||||
LocalProvider::LocalProvider( std::shared_ptr<LocalIOScheduler> scheduler ) :
|
||||
@@ -7,4 +9,28 @@ LocalProvider::LocalProvider( std::shared_ptr<LocalIOScheduler> scheduler ) :
|
||||
|
||||
LocalProvider::~LocalProvider() = default;
|
||||
|
||||
VFSTask<bool> LocalProvider::sameFileSystem( const URI& from, const URI& to,
|
||||
CancellationToken token ) {
|
||||
if ( token.isCancellationRequested() )
|
||||
return readyError<bool>( cancelledError( from ) );
|
||||
if ( from.hasSameAuthority( to ) )
|
||||
return readyValue<bool>( true );
|
||||
auto source = toNativePath( from );
|
||||
if ( !source )
|
||||
return readyError<bool>( std::move( source ).error() );
|
||||
auto destination = toNativePath( to );
|
||||
if ( !destination )
|
||||
return readyError<bool>( 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<bool>( true );
|
||||
return readyError<bool>( VFSErrorCode::Unsupported, to,
|
||||
"Cannot determine identity across UNC hosts" );
|
||||
}
|
||||
|
||||
}} // namespace EE::VFS
|
||||
|
||||
@@ -390,6 +390,14 @@ VFSTask<URI> MemoryProvider::canonicalize( const URI& uri, CancellationToken ) {
|
||||
return complete<URI>( Ok( std::move( canonical ) ) );
|
||||
}
|
||||
|
||||
VFSTask<bool> MemoryProvider::sameFileSystem( const URI& from, const URI& to,
|
||||
CancellationToken token ) {
|
||||
if ( token.isCancellationRequested() )
|
||||
return readyError<bool>( cancelledError( from ) );
|
||||
// The host is part of the in-memory node key; different hosts cannot alias.
|
||||
return readyValue<bool>( from.getHost() == to.getHost() );
|
||||
}
|
||||
|
||||
VFSTask<void> MemoryProvider::setPermissions( const URI& uri, Uint32 permissions,
|
||||
CancellationToken ) {
|
||||
std::lock_guard<std::mutex> lock( mMutex );
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#include <eepp/vfs/providers/resourceprovider.hpp>
|
||||
|
||||
#include <eepp/system/packfileindex.hpp>
|
||||
#include <eepp/vfs/detail/memoryhandles.hpp>
|
||||
#include <eepp/vfs/path.hpp>
|
||||
#include <eepp/vfs/providers/resourceprovider.hpp>
|
||||
|
||||
namespace EE { namespace VFS {
|
||||
|
||||
@@ -161,4 +162,14 @@ VFSTask<URI> ResourceProvider::canonicalize( const URI& uri, CancellationToken )
|
||||
return readyValue<URI>( resourceURI( toPackPath( uri ) ) );
|
||||
}
|
||||
|
||||
VFSTask<bool> ResourceProvider::sameFileSystem( const URI& from, const URI& to,
|
||||
CancellationToken token ) {
|
||||
if ( token.isCancellationRequested() )
|
||||
return readyError<bool>( cancelledError( from ) );
|
||||
if ( !VFSPath::validURI( from ) || !VFSPath::validURI( to ) )
|
||||
return readyError<bool>( VFSErrorCode::InvalidURI, to, "NUL in provider URI" );
|
||||
// All authorities are path prefixes in the same PackFileIndex namespace.
|
||||
return readyValue<bool>( true );
|
||||
}
|
||||
|
||||
}} // namespace EE::VFS
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <eepp/system/zip.hpp>
|
||||
#include <eepp/vfs/detail/archivelocation.hpp>
|
||||
#include <eepp/vfs/detail/memoryhandles.hpp>
|
||||
#include <eepp/vfs/path.hpp>
|
||||
#include <eepp/vfs/providers/localprovider.hpp>
|
||||
#include <limits>
|
||||
#include <mutex>
|
||||
@@ -520,4 +521,18 @@ VFSTask<URI> ZipProvider::canonicalize( const URI& uri, CancellationToken token
|
||||
} );
|
||||
}
|
||||
|
||||
VFSTask<bool> ZipProvider::sameFileSystem( const URI& from, const URI& to,
|
||||
CancellationToken token ) {
|
||||
if ( token.isCancellationRequested() )
|
||||
return readyError<bool>( cancelledError( from ) );
|
||||
if ( !VFSPath::validURI( from ) || !VFSPath::validURI( to ) )
|
||||
return readyError<bool>( 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<bool>( true );
|
||||
return readyError<bool>( VFSErrorCode::Unsupported, to,
|
||||
"Cannot determine archive identity across provider authorities" );
|
||||
}
|
||||
|
||||
}} // namespace EE::VFS
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#include <eepp/vfs/sftp/sftpprovider.hpp>
|
||||
|
||||
#include "sftpproviderimpl.hpp"
|
||||
#include "sshconnection.hpp"
|
||||
#include <eepp/core/string.hpp>
|
||||
@@ -5,7 +7,6 @@
|
||||
#include <eepp/vfs/detail/await.hpp>
|
||||
#include <eepp/vfs/path.hpp>
|
||||
#include <eepp/vfs/sftp/remotewatchbackend.hpp>
|
||||
#include <eepp/vfs/sftp/sftpprovider.hpp>
|
||||
#include <eepp/vfs/virtualfilesystem.hpp>
|
||||
#include <eepp/vfs/watchers/pollingwatchbackend.hpp>
|
||||
|
||||
@@ -815,6 +816,44 @@ VFSTask<URI> SFTPProvider::canonicalize( const URI& uri, CancellationToken token
|
||||
} );
|
||||
}
|
||||
|
||||
VFSTask<bool> SFTPProvider::sameFileSystem( const URI& from, const URI& to,
|
||||
CancellationToken token ) {
|
||||
if ( token.isCancellationRequested() )
|
||||
return readyError<bool>( cancelledError( from ) );
|
||||
if ( from.hasSameAuthority( to ) )
|
||||
return readyValue<bool>( true );
|
||||
return mImpl->connection( from ).then(
|
||||
[impl = mImpl, to,
|
||||
token]( VFSResult<std::shared_ptr<SSHConnection>>&& source ) mutable -> VFSTask<bool> {
|
||||
if ( !source )
|
||||
return readyError<bool>( 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<std::shared_ptr<SSHConnection>>&& destination ) -> VFSResult<bool> {
|
||||
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<void> SFTPProvider::setPermissions( const URI& uri, Uint32 permissions,
|
||||
CancellationToken token ) {
|
||||
return withConnection<void>(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#include <eepp/vfs/transferengine.hpp>
|
||||
|
||||
#include <eepp/vfs/detail/filewriter.hpp>
|
||||
#include <eepp/vfs/detail/transferstate.hpp>
|
||||
#include <eepp/vfs/path.hpp>
|
||||
#include <eepp/vfs/transferengine.hpp>
|
||||
#include <eepp/vfs/virtualfilesystem.hpp>
|
||||
|
||||
namespace EE { namespace VFS {
|
||||
@@ -54,9 +55,30 @@ VFSResult<TransferResult> 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<TransferResult> 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<void> 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<bool> 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<bool> 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<void> TransferEngine::copyFile( const VFSFileInfo& info, const URI& source,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -298,7 +298,7 @@ VFSTask<std::vector<Uint8>> VirtualFileSystem::readAll( const URI& uri, Cancella
|
||||
std::vector<Uint8> 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<std::vector<Uint8>> 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<size_t>( chunk, data.capacity() - old );
|
||||
if ( old + chunk > maxSize + 1 )
|
||||
chunk = static_cast<size_t>( 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<size_t>( 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<Uint8>( data.data() + old, chunk ), token ) );
|
||||
|
||||
@@ -4,51 +4,27 @@
|
||||
#include <eepp/vfs/watch.hpp>
|
||||
#include <efsw/efsw.hpp>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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 <typename Dispatch> 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<Event> mEvents;
|
||||
bool mOverflow{ false };
|
||||
bool mPending{ false };
|
||||
};
|
||||
|
||||
}}} // namespace EE::VFS::detail
|
||||
|
||||
@@ -102,9 +102,7 @@ class LocalWatchBackendImpl : public std::enable_shared_from_this<LocalWatchBack
|
||||
mEarly.erase( it );
|
||||
}
|
||||
}
|
||||
early.replay( *sink, [&]( const EarlyWatchEvents::Event& event ) {
|
||||
dispatch( key, id, event.dir, event.filename, event.action, event.oldFilename );
|
||||
} );
|
||||
early.replay( *sink );
|
||||
return Ok( std::make_pair( token, ( key & GenericBit ) != 0 ) );
|
||||
}
|
||||
|
||||
|
||||
@@ -322,6 +322,8 @@ class BlackholeProxy {
|
||||
|
||||
void drop( bool enabled ) { mDropping = enabled; }
|
||||
|
||||
void readDelay( unsigned milliseconds ) { mReadDelay = milliseconds; }
|
||||
|
||||
private:
|
||||
void forward( int client ) {
|
||||
int upstream = ::socket( AF_INET, SOCK_STREAM, 0 );
|
||||
@@ -352,6 +354,10 @@ class BlackholeProxy {
|
||||
}
|
||||
if ( mDropping.load() )
|
||||
continue;
|
||||
if ( i == 1 && mReadDelay.load() ) {
|
||||
std::this_thread::sleep_for(
|
||||
std::chrono::milliseconds( mReadDelay.load() ) );
|
||||
}
|
||||
ssize_t sent = 0;
|
||||
while ( sent < size ) {
|
||||
#ifdef MSG_NOSIGNAL
|
||||
@@ -378,6 +384,7 @@ class BlackholeProxy {
|
||||
int mUpstreamPort;
|
||||
int mListener{ -1 };
|
||||
int mPort{ 0 };
|
||||
std::atomic<unsigned> mReadDelay{ 0 };
|
||||
std::atomic<bool> mStopping{ false };
|
||||
std::atomic<bool> 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<Uint16>( 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 } ) {
|
||||
|
||||
@@ -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() );
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
#include "utest.hpp"
|
||||
#include "vfs_test_utils.hpp"
|
||||
#include <condition_variable>
|
||||
#include <cstring>
|
||||
#include <eepp/vfs/path.hpp>
|
||||
#include <eepp/vfs/watchers/earlywatchevents.hpp>
|
||||
#include <eepp/vfs/watchers/pollingwatchbackend.hpp>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <limits>
|
||||
#include <utility>
|
||||
|
||||
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<MemoryProvider>() );
|
||||
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<bool> sameFileSystem( const URI& from, const URI&, CancellationToken ) override {
|
||||
return readyError<bool>( VFSErrorCode::Unsupported, from, "Identity is uncertain" );
|
||||
}
|
||||
};
|
||||
VirtualFileSystem vfs;
|
||||
auto provider = std::make_shared<IdentityProvider>();
|
||||
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<MemoryProvider>();
|
||||
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<size_t> read( Uint64 offset, std::span<Uint8> data, CancellationToken ) override {
|
||||
largestRead = std::max( largestRead, data.size() );
|
||||
++reads;
|
||||
const size_t count =
|
||||
static_cast<size_t>( std::min<Uint64>( data.size(), size - offset ) );
|
||||
std::memset( data.data(), 'x', count );
|
||||
return readyValue<size_t>( count );
|
||||
}
|
||||
|
||||
VFSTask<size_t> write( Uint64, std::span<const Uint8>, CancellationToken ) override {
|
||||
return readyError<size_t>( VFSErrorCode::ReadOnly );
|
||||
}
|
||||
|
||||
VFSTask<void> truncate( Uint64 ) override {
|
||||
return readyError<void>( VFSErrorCode::ReadOnly );
|
||||
}
|
||||
|
||||
VFSTask<void> flush() override { return readyValue<void>(); }
|
||||
|
||||
VFSTask<void> close() override { return readyValue<void>(); }
|
||||
|
||||
VFSTask<VFSFileInfo> stat() override {
|
||||
VFSFileInfo info;
|
||||
if ( knownSize )
|
||||
info.setSize( size );
|
||||
return readyValue<VFSFileInfo>( 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<FileHandle> open( const URI& uri, const OpenOptions&, CancellationToken ) override {
|
||||
return readyValue<FileHandle>( uri, file );
|
||||
}
|
||||
|
||||
std::shared_ptr<GeneratedFile> file;
|
||||
};
|
||||
VirtualFileSystem vfs;
|
||||
auto provider = std::make_shared<GeneratedProvider>();
|
||||
vfs.registerProvider( provider );
|
||||
const URI file( "mem://test/generated" );
|
||||
provider->file = std::make_shared<GeneratedFile>( 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<GeneratedFile>( 10, known );
|
||||
auto unlimited = waitFor( vfs.readAll( file, {}, std::numeric_limits<Uint64>::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<GeneratedFile>( 0, known );
|
||||
EXPECT_TRUE( waitFor( vfs.readAll( file, {}, 0 ) ).value().empty() );
|
||||
provider->file = std::make_shared<GeneratedFile>( 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<std::mutex> 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<std::mutex> 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 ) {
|
||||
|
||||
@@ -432,8 +432,8 @@ std::optional<size_t> FileTableView::rowOf( Node* node ) const {
|
||||
ModelIndex index;
|
||||
if ( node->isType( UI_TYPE_TABLECELL ) ) {
|
||||
index = node->asType<UITableCell>()->getCurIndex();
|
||||
} else if ( auto* row = dynamic_cast<UITableRow*>( node ) ) {
|
||||
index = row->getCurIndex();
|
||||
} else if ( node->isType( UI_TYPE_TABLEROW ) ) {
|
||||
index = node->asType<UITableRow>()->getCurIndex();
|
||||
} else {
|
||||
node = node->getParent();
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user