From 4172b32902d4074e0a1cc10f92e8db6e04d5d2da Mon Sep 17 00:00:00 2001 From: Quaternion8192 <70364461+Quaternion8192@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:50:24 +0800 Subject: [PATCH] fix(remote-changes): recover interrupted remote file moves Retain original batch, policy, namespace, and item evidence before a file rename, then reconcile that evidence before replay revision checks. Commit path and revision together and remove the preparation only with the original batch outcome; preserve uncertainty and local journal work. Fence the checkpoint protocol with SQLite schema 7 and add native ordinary/case-only reopen, interruption, cancellation, and ABI coverage. Refs: MirrorPulse CF-007 --- docs/remote-change-application.md | 98 ++++ docs/state-store-sqlite.md | 6 +- .../Internal/WindowsFileMetadata.cs | 73 ++- .../SqliteCloudStateStoreFactory.cs | 12 +- .../CloudFileSystem.RemoteChanges.cs | 61 ++- .../FileSystem/CloudFileSystem.RemoteMoves.cs | 358 ++++++++++++++ .../CloudLocalChangeFeed.Scans.cs | 2 + .../LocalChanges/CloudLocalChangeFeed.cs | 21 +- .../RemoteChanges/CloudRemoteChangeModels.cs | 6 +- src/CfSharp/RemoteChanges/RemoteMoveIntent.cs | 194 ++++++++ .../RemoteMoveRecoveryTests.cs | 456 ++++++++++++++++++ tests/CfSharp.Native.AbiProbe/main.cpp | 4 + .../AbiProbeComparisonTests.cs | 4 + .../WindowsFileMetadataTests.cs | 67 +++ .../CloudLocalChangeScanFenceTests.cs | 37 +- .../SqliteCloudStateStoreTests.cs | 28 +- .../Persistence/RemoteMoveIntentTests.cs | 75 +++ 17 files changed, 1463 insertions(+), 39 deletions(-) create mode 100644 src/CfSharp/FileSystem/CloudFileSystem.RemoteMoves.cs create mode 100644 src/CfSharp/RemoteChanges/RemoteMoveIntent.cs create mode 100644 tests/CfSharp.IntegrationTests/RemoteMoveRecoveryTests.cs create mode 100644 tests/CfSharp.Tests/Persistence/RemoteMoveIntentTests.cs diff --git a/docs/remote-change-application.md b/docs/remote-change-application.md index 0429cfd..b271ed6 100644 --- a/docs/remote-change-application.md +++ b/docs/remote-change-application.md @@ -55,5 +55,103 @@ The local feed retains observations made during an uncommitted creation separate The official SQLite store upgrades to schema 5 to prevent older versions from ignoring this recovery protocol. Do not downgrade an upgraded database. Custom transactional stores remain supported using existing repositories, but all accessing runtimes must use the new protocol version together. Creation transactions contain a synchronous placeholder creation, not hydration or application callbacks. +## Recovering remote file moves (CF-007) + +File moves and case-only file renames submitted in an immutable batch now retain a versioned +preparation before namespace mutation. Replay the **same batch**, including its ID, entry order, +fingerprint, IDs, paths, opaque revisions, and cursor envelope. Keep the original +`PreserveUnsynchronizedLocalContent`, `SuppressLocalEcho`, echo lifetime, and observation count. +`MaximumEntries` may change; a resolver instance is not part of the persisted policy. + +The preparation belongs to the original store and root. It captures the original official item +state, native placeholder identity separately from official revision, complete volume/root/file +binding, creation/write times and length, and source/destination ancestor bindings and identities. +The native rename uses the inspected object handle with replacement disabled. It requests no data +access and performs no content reads, identity rewrite, hydration, or in-sync transition. Revisions +are opaque: recovery also works when previous and incoming revisions are equal. +Preparation and recovery hold source/destination ancestors with directory-list/read-attribute +access and delete sharing disabled. These handles prevent replacement during native and durable +work without enumerating children. Insufficient access or file-ID/USN support is an explicit error. + +Replay authenticates the preparation **before** applying ordinary stale-revision checks: + +| Durable/native boundary | Replay behavior | +| --- | --- | +| Preparation committed, original object still at source | Revalidate original conditions and issue the original rename. | +| Original object at exact target, item projection not committed | Verify original binding, identity, metadata, ancestry, and local journal; commit path/revision together without another rename. | +| Item projection committed, original batch outcome missing | Revalidate the same object and projected state; commit the missing original outcome without another rename. | +| Outcome committed, reply lost | Read the original committed prefix and return `AlreadyApplied`. | +| Object, ancestry, state, or local work changed | Return `Failed` with `Error`, `RequiresRetry=true`, and the unchanged safe prefix. Retain evidence for reconciliation; do not manufacture a stale conflict. | + +Mismatched policy, store/root, or corrupted preparation is rejected with `InvalidDataException` +before normalizing progress. A changed batch fingerprint is rejected with `ArgumentException`. + +Recovered entries return `Applied` only after their outcome commits. `AppliedEntryCount` counts +committed outcomes, **including legitimate conflicts**. A fully processed batch has `Status=Applied` +and `RequiresRetry=false`; callers still inspect `Entries` and `ConflictIds`. An unchanged completed +prefix returns `AlreadyApplied` on replay, using the existing batch semantics; this status certifies +the recorded outcome, not that every historical entry was conflict-free. `SafeCursor` advances only +with that durable prefix. CfSharp never publishes an application's separate Adapter cursor. + +The projected preparation also captures the post-rename native USN after closing rename-overlapping +handles, so NTFS's accumulated close record is included. Replay rejects subsequent native changes. +`TrackAll` can clear in-sync because of the rename itself; recovery preserves that state rather than +marking the object clean. Before a projected USN exists, recovery requires the original in-sync +condition under the conservative policy. Thus an early interrupted rename that clears in-sync +remains explicitly unresolved; roots using `PreserveForSyncEngine` can preserve that proof boundary. +The conservative policy rejects nonzero modified content ranges; an explicit force policy retains +its original dirty content and pending journal without acknowledging or marking them clean. + +The path/revision projection and echo suppression commit together. Preparation cleanup and original +outcome/progress commit together. Any pre-commit fault leaves evidence; a late exception after commit +is resolved by rereading durable state on the next call. Entry storage failures retain a failed, +retryable prefix and their original exception in `Error`. Outcome storage failures propagate, so a +caller must replay after reopening rather than assume either rollback or success. Storage errors +do not acquire native HRESULTs. Native rename errors preserve their actual HRESULT. + +Cancellation before native mutation leaves an unchanged object and any committed preparation. +After native success, item projection finishes without cancellation under the admitted owner +lifetime; cancellation may leave batch outcome publication for replay. One owner's calls serialize, +and disposal waits for admitted work, including recovery transactions. Use one active SDK owner +per root/store. Reopening that pair is supported; simultaneous owners are not a distributed lock. +No conflict resolver or content-provider callback runs inside the rename/projection transaction. + +Local feed observations at either pending path are durably retained and require a full rescan. +Replay first, reconcile the root, then acknowledge the rescan. Acknowledgement cannot discard a +pending move. Existing journal rows are never acknowledged by recovery; a changed journal or dirty +object leaves the operation unresolved. An explicit force policy may retain its original pending +operations while moving, but recovery rejects later operations. This fix conservatively refuses +to settle a move after later edits instead of clearing or overwriting them. + +### Compatibility and limits + +The official SQLite provider upgrades schemas 0–6 in place to **schema 7**, retaining existing +repositories and enforcing foreign keys. Older providers reject that version before ignoring +pending file-move evidence. Use matching core/provider packages; never downgrade the schema marker. +Custom stores use the existing public repositories and must atomically isolate transactions across +all repositories, preserve opaque checkpoints and their ordinal prefix listing, and fence older +runtimes themselves. Stores lacking those contracts cannot support this protocol. Only metadata +queries and synchronous rename occur inside the native/projection transaction. + +This recovery protocol covers **files** submitted through `ApplyRemoteChangesAsync`. Directory +movement keeps its existing subtree/proof APIs; this fix does not claim automatic recovery of a +directory's missing remote-batch outcome. It does not add a public preparation token or infer an +outcome from matching present-day paths/revisions. Direct lower-level file moves and explicit +standalone conflict resolutions keep their existing contracts. + +Preview.5 file gaps without original preparations remain conservative conflicts. Existing durable +conflicts are preserved for review, not silently reclassified during upgrade. Preserve the original +batch, native object, local work, and application evidence for operator reconciliation. Supported +conflict decisions remain explicit policy, not proof that a historical move succeeded. If proof +cannot be restored, retire that store only after application reconciliation and rebuild state in a +new caller-selected store through supported APIs; do not edit private checkpoints or mark a batch +complete manually. This can require reprocessing earlier work and does not complete product cursors. + +Native regression tests cover ordinary/case-only moves with SQLite reopen, equal revisions, +preparation/projection/outcome interruptions, ambiguous commits, repeated recovery faults, +replacement/parent changes, dirty state, and pending edits. In-process exceptions followed by +normal disposal are distinct from power loss or hard process termination. Architecture/ordinary-user +results and remaining acceptance-matrix cases must be reported with their actual executed evidence. + diff --git a/docs/state-store-sqlite.md b/docs/state-store-sqlite.md index 1287797..76a0ec5 100644 --- a/docs/state-store-sqlite.md +++ b/docs/state-store-sqlite.md @@ -49,13 +49,15 @@ Pages own no reader or transaction and do not acknowledge operations or update i The legacy `ICloudOperationJournal` contract is unchanged: custom stores may opt into paging on their repository, preserving monotonic enqueue sequences and transactional read semantics. -### Schema 6 recovery fence +### Schema 7 recovery fence Schema 6 preserves the existing table layout and data while fencing directory object provenance, move preparation, and completion-receipt recovery. It includes schema 5's remote placeholder creation and observation-reconciliation fence. Versions 0 through 5 upgrade in place, retaining item IDs, revisions, tombstones, journal sequences and payloads, suppressions, and checkpoints. -Older SQLite packages reject version 6 before accessing its recovery metadata. Do not downgrade +Schema 7 additionally fences original remote-file-move preparations and feed observation recovery. +Versions 0 through 6 upgrade in place without fabricating proof for legacy interrupted file moves. +Older SQLite packages reject version 7 before accessing its recovery metadata. Do not downgrade the schema number manually. Back up state before upgrading and use matching core and SQLite packages. Custom stores must enforce the same protocol boundary when opening persistent state with older diff --git a/src/CfSharp.Native/Internal/WindowsFileMetadata.cs b/src/CfSharp.Native/Internal/WindowsFileMetadata.cs index 876405d..d683074 100644 --- a/src/CfSharp.Native/Internal/WindowsFileMetadata.cs +++ b/src/CfSharp.Native/Internal/WindowsFileMetadata.cs @@ -25,6 +25,59 @@ internal static SafeFileHandle Open(string path, bool preventDelete = false) return handle; } + internal static SafeFileHandle OpenForRename(string path) + { + // DELETE addresses rename permission; no data access is requested, so an online-only + // placeholder stays unhydrated. Denying delete sharing excludes competing rename/delete + // owners while this handle itself retains DELETE access for the inspected-object rename. + SafeFileHandle handle = CreateFile(path, 0x10080, 3, 0, 3, 0x02200000, 0); + if (handle.IsInvalid) + { + int error = Marshal.GetLastPInvokeError(); + handle.Dispose(); + throw new Win32Exception(error); + } + + return handle; + } + + internal static SafeFileHandle OpenDirectoryGuard(string path) + { + // FILE_LIST_DIRECTORY makes no-delete sharing effective for a directory. An + // attribute-only directory opener does not prevent all ancestor rename races. + // Opening this access performs no enumeration and never requests file content. + SafeFileHandle handle = CreateFile(path, 0x81, 3, 0, 3, 0x02200000, 0); + if (handle.IsInvalid) + { + int error = Marshal.GetLastPInvokeError(); + handle.Dispose(); + throw new Win32Exception(error); + } + + return handle; + } + + internal static void Rename(nint handle, string destination) + { + // FILE_RENAME_INFO has a DWORD-sized union, a naturally aligned HANDLE, a byte + // length, and trailing UTF-16 characters. RootDirectory and replacement stay zero. + // Use the ABI field offset, not sizeof(header), which includes trailing padding. + // https://learn.microsoft.com/windows/win32/api/winbase/ns-winbase-file_rename_info + FileRenameInformation layout = default; + int offset = checked((int)((byte*)&layout.FileName - (byte*)&layout)); + int nameBytes = checked(destination.Length * sizeof(char)); + byte[] buffer = new byte[checked(offset + nameBytes + sizeof(char))]; + fixed (byte* pointer = buffer) + { + ((FileRenameInformation*)pointer)->FileNameLength = checked((uint)nameBytes); + destination.AsSpan().CopyTo(new Span(pointer + offset, destination.Length)); + if (!SetFileInformationByHandle(handle, 3, pointer, checked((uint)buffer.Length))) + { + throw new Win32Exception(Marshal.GetLastPInvokeError()); + } + } + } + internal static SafeFileHandle OpenLocalAccess(string path, bool directory) { // A single file object owns both data exclusion and security access. A separate @@ -123,6 +176,7 @@ internal static NativeFileMetadata Read(nint handle, nint protectedHandle = 0) attributes.Attributes, attributes.ReparseTag); byte[] identity = []; long onDisk = 0; + long modified = 0; bool inSync = false; if (state != CfPlaceholderState.Invalid && state.HasFlag(CfPlaceholderState.Placeholder)) { @@ -150,13 +204,14 @@ internal static NativeFileMetadata Read(nint handle, nint protectedHandle = 0) identity = new ReadOnlySpan(info->FileIdentity, (int)info->FileIdentityLength).ToArray(); onDisk = info->OnDiskDataSize; + modified = info->ModifiedDataSize; inSync = info->InSyncState == CfInSyncState.InSync; } } return new NativeFileMetadata(ReadIdentity(handle), standard.EndOfFile, standard.NumberOfLinks, standard.DeletePending != 0, standard.Directory != 0, - attributes.Attributes, attributes.ReparseTag, state, onDisk, inSync, identity); + attributes.Attributes, attributes.ReparseTag, state, onDisk, inSync, identity, modified); } private static void Query(nint handle, int informationClass, void* value, uint size) @@ -203,6 +258,15 @@ internal struct FileAttributeTagInfo internal uint ReparseTag; } + [StructLayout(LayoutKind.Sequential)] + internal struct FileRenameInformation + { + internal uint Flags; + internal nint RootDirectory; + internal uint FileNameLength; + internal char FileName; + } + [LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvStdcall) })] private static partial SafeFileHandle CreateFile(string path, uint access, uint share, @@ -213,6 +277,11 @@ private static partial SafeFileHandle CreateFile(string path, uint access, uint [return: MarshalAs(UnmanagedType.Bool)] private static partial bool GetFileInformationByHandleEx(nint handle, int informationClass, void* value, uint size); + [LibraryImport("kernel32.dll", SetLastError = true)] + [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvStdcall) })] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool SetFileInformationByHandle(nint handle, int informationClass, void* value, uint size); + [LibraryImport("kernel32.dll", EntryPoint = "GetFinalPathNameByHandleW", SetLastError = true)] [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvStdcall) })] private static partial uint GetFinalPathNameByHandle(nint handle, char* path, uint capacity, uint flags); @@ -220,7 +289,7 @@ private static partial SafeFileHandle CreateFile(string path, uint access, uint internal sealed record NativeFileMetadata(WindowsFileMetadata.FileIdentity Identity, long Length, uint Links, bool DeletePending, bool Directory, uint Attributes, uint ReparseTag, - CfPlaceholderState PlaceholderState, long OnDiskDataSize, bool InSync, byte[] PlaceholderIdentity); + CfPlaceholderState PlaceholderState, long OnDiskDataSize, bool InSync, byte[] PlaceholderIdentity, long ModifiedDataSize); internal sealed class NativeFileException : IOException { diff --git a/src/CfSharp.Storage.Sqlite/SqliteCloudStateStoreFactory.cs b/src/CfSharp.Storage.Sqlite/SqliteCloudStateStoreFactory.cs index 555adb2..9b5a13c 100644 --- a/src/CfSharp.Storage.Sqlite/SqliteCloudStateStoreFactory.cs +++ b/src/CfSharp.Storage.Sqlite/SqliteCloudStateStoreFactory.cs @@ -777,7 +777,7 @@ private struct FileAttributeTagInfo internal static class SqliteSchema { - internal const int CurrentVersion = 6; + internal const int CurrentVersion = 7; internal const string PathCollationName = "CFSHARP_UNICODE_NOCASE"; internal static async Task InitializeAsync( @@ -879,6 +879,16 @@ await MigrateVersionFourAsync(connection, cancellationToken) await upgrade.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } + if (version < 7) + { + // File-move preparations bridge native rename, item projection, and original + // batch outcome commits using checkpoints. Fence older runtimes before they + // can ignore pending evidence or deliver its observations as local uploads. + await using SqliteCommand upgrade = connection.CreateCommand(); + upgrade.CommandText = "UPDATE cfsharp_schema SET version = 7 WHERE singleton = 1;"; + await upgrade.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + await ValidateCurrentVersionAsync( connection, databasePath, diff --git a/src/CfSharp/FileSystem/CloudFileSystem.RemoteChanges.cs b/src/CfSharp/FileSystem/CloudFileSystem.RemoteChanges.cs index 50b58bb..5b5b54e 100644 --- a/src/CfSharp/FileSystem/CloudFileSystem.RemoteChanges.cs +++ b/src/CfSharp/FileSystem/CloudFileSystem.RemoteChanges.cs @@ -24,6 +24,9 @@ public sealed partial class CloudFileSystem /// /// The batch is null. /// The batch conflicts with existing durable progress. + /// + /// A retained file-move preparation is corrupt or belongs to another policy, store, or root. + /// /// The file system has not started. /// Windows rejects a requested namespace mutation. /// Cancellation was observed at a safe boundary. @@ -32,6 +35,20 @@ public sealed partial class CloudFileSystem /// metadata, identity, and revision while leaving hydration to the configured provider. A /// durable batch record is written after each entry, so a process restart resumes from the /// last committed entry without publishing an unsafe cursor. + /// File moves retain versioned original object, ancestry, batch, and apply-policy evidence + /// before native mutation. Replay authenticates that evidence before checking previous + /// revisions, including when both revisions are equal. Successful recovery reports + /// after durable outcome publication; + /// completed batch prefixes report . + /// Changed objects, ancestry, or local work leave the entry failed and retryable with its + /// preparation retained. Mismatched policy or preparation is rejected before progress changes. + /// Storage failures during preparation reads or outcome publication propagate to the + /// caller; replay rereads durable state, including when a commit completed before throwing. + /// Cancellation after native success finishes item projection without cancellation and may + /// leave outcome publication for replay. This protocol covers file moves submitted through + /// this method; it cannot authenticate historical file moves without original evidence or + /// settle a directory move's missing remote-batch outcome. Use one active file-system owner + /// per root/store; calls on that owner serialize and disposal drains admitted work. /// public async ValueTask ApplyRemoteChangesAsync( CloudRemoteChangeBatch batch, @@ -73,6 +90,7 @@ private async ValueTask ApplyRemoteChangesCoreAsync( CloudRemoteBatchState progress = await ReadOrInitializeRemoteBatchAsync( batch, + selectedOptions, cancellationToken) .ConfigureAwait(false); List results = []; @@ -103,13 +121,21 @@ private async ValueTask ApplyRemoteChangesCoreAsync( cancellationToken.ThrowIfCancellationRequested(); int index = progress.AppliedEntryCount; CloudRemoteChange change = batch.Changes[index]; + RemoteMoveRequest moveRequest = new(batch, index, selectedOptions); + // Reject a mismatched pending preparation before changing any batch progress. + RemoteMoveIntent? pendingMove = change.Kind == CloudRemoteChangeKind.Move && change.ItemKind == CloudItemKind.File + ? await ReadRemoteMoveIntentAsync(moveRequest, cancellationToken).ConfigureAwait(false) + : null; RemoteEntryOutcome outcome; try { - outcome = await ApplyRemoteEntryAsync( + outcome = pendingMove is not null + ? await CompleteRemoteFileMoveAsync(change, pendingMove, cancellationToken).ConfigureAwait(false) + : await ApplyRemoteEntryAsync( change, selectedOptions, - cancellationToken) + cancellationToken, + moveRequest) .ConfigureAwait(false); } catch (OperationCanceledException) @@ -286,6 +312,7 @@ await transaction.Conflicts.RemoveAsync(durableState.ConflictId, cancellationTok private async ValueTask ReadOrInitializeRemoteBatchAsync( CloudRemoteChangeBatch batch, + CloudRemoteApplyOptions options, CancellationToken cancellationToken) { await using ICloudStateTransaction transaction = await (_stateStore ?? throw new InvalidOperationException( @@ -311,6 +338,13 @@ private async ValueTask ReadOrInitializeRemoteBatchAsync( $"Remote batch '{batch.BatchId}' has invalid durable progress."); } + if (existing.AppliedEntryCount < batch.Changes.Count && + batch.Changes[existing.AppliedEntryCount] is { Kind: CloudRemoteChangeKind.Move, ItemKind: CloudItemKind.File }) + { + await ReadRemoteMoveIntentCoreAsync(transaction, new(batch, existing.AppliedEntryCount, options), cancellationToken) + .ConfigureAwait(false); + } + if (existing.Status is CloudRemoteBatchStatus.Applied) { if (existing.AppliedEntryCount != batch.Changes.Count || @@ -380,9 +414,10 @@ await transaction.RemoteBatches.UpsertAsync(normalized, cancellationToken) private async ValueTask ApplyRemoteEntryAsync( CloudRemoteChange change, CloudRemoteApplyOptions options, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + RemoteMoveRequest? moveRequest = null) { - RemoteEntryOutcome outcome = await ApplyRemoteEntryCoreAsync(change, options, cancellationToken) + RemoteEntryOutcome outcome = await ApplyRemoteEntryCoreAsync(change, options, cancellationToken, moveRequest) .ConfigureAwait(false); if (outcome.Conflict is null || options.ConflictResolver is null) { @@ -416,14 +451,16 @@ options with PreserveUnsynchronizedLocalContent = false, ConflictResolver = null, }, - cancellationToken) + cancellationToken, + moveRequest) .ConfigureAwait(false); } private async ValueTask ApplyRemoteEntryCoreAsync( CloudRemoteChange change, CloudRemoteApplyOptions options, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + RemoteMoveRequest? moveRequest = null) { if (change.Kind is CloudRemoteChangeKind.FileUpsert or CloudRemoteChangeKind.DirectoryUpsert) { @@ -440,7 +477,7 @@ private async ValueTask ApplyRemoteEntryCoreAsync( .ConfigureAwait(false); if (change.Kind is CloudRemoteChangeKind.Move) { - return await ApplyRemoteMoveAsync(change, context, options, cancellationToken) + return await ApplyRemoteMoveAsync(change, context, options, moveRequest, cancellationToken) .ConfigureAwait(false); } @@ -616,6 +653,7 @@ private async ValueTask ApplyRemoteMoveAsync( CloudRemoteChange change, RemoteEntryContext context, CloudRemoteApplyOptions options, + RemoteMoveRequest? moveRequest, CancellationToken cancellationToken) { CloudItemState? localState = context.LocalState; @@ -696,6 +734,13 @@ private async ValueTask ApplyRemoteMoveAsync( string destinationParentPath = Path.GetDirectoryName(change.RelativePath) ?? string.Empty; CloudDirectory destination = GetDirectory(destinationParentPath); string destinationName = Path.GetFileName(change.RelativePath); + if (moveRequest is not null && change.ItemKind == CloudItemKind.File) + { + RemoteMoveIntent intent = await PrepareRemoteFileMoveAsync(change, localState, options, moveRequest, + cancellationToken).ConfigureAwait(false); + return await CompleteRemoteFileMoveAsync(change, intent, cancellationToken).ConfigureAwait(false); + } + Guid? suppressionId = null; if (options.SuppressLocalEcho) { @@ -1202,6 +1247,8 @@ await transaction.Conflicts.UpsertAsync( await transaction.RemoteBatches.UpsertAsync(next, cancellationToken).ConfigureAwait(false); await transaction.Checkpoints.RemoveAsync(RemoteCreationIntent.Name(change), cancellationToken) .ConfigureAwait(false); + await transaction.Checkpoints.RemoveAsync(RemoteMoveIntent.Name(batch.BatchId, appliedEntryCount - 1), cancellationToken) + .ConfigureAwait(false); await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); return next; } diff --git a/src/CfSharp/FileSystem/CloudFileSystem.RemoteMoves.cs b/src/CfSharp/FileSystem/CloudFileSystem.RemoteMoves.cs new file mode 100644 index 0000000..d1dcea4 --- /dev/null +++ b/src/CfSharp/FileSystem/CloudFileSystem.RemoteMoves.cs @@ -0,0 +1,358 @@ +using System.ComponentModel; + +using CfSharp.Native; + +using Microsoft.Win32.SafeHandles; + +namespace CfSharp; + +public sealed partial class CloudFileSystem +{ + private async ValueTask ReadRemoteMoveIntentAsync(RemoteMoveRequest request, + CancellationToken cancellationToken) + { + await using ICloudStateTransaction transaction = await _stateStore!.BeginTransactionAsync(cancellationToken) + .ConfigureAwait(false); + return await ReadRemoteMoveIntentCoreAsync(transaction, request, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask ReadRemoteMoveIntentCoreAsync(ICloudStateTransaction transaction, + RemoteMoveRequest request, CancellationToken cancellationToken) + { + CloudStateCheckpoint? checkpoint = await transaction.Checkpoints.GetAsync( + RemoteMoveIntent.Name(request.Batch.BatchId, request.EntryIndex), cancellationToken).ConfigureAwait(false); + if (checkpoint is null) + { + return null; + } + + RemoteMoveIntent intent = RemoteMoveIntent.Decode(checkpoint.Value); + CloudRemoteChange change = request.Batch.Changes[request.EntryIndex]; + CloudPlaceholderIdentity identity = CloudPlaceholderIdentity.Decode(intent.Object.Identity); + if (!intent.Matches(request.Batch, request.EntryIndex, request.Options) || + intent.Original.RemoteId != change.RemoteId || identity.RemoteId != change.RemoteId || + (change.ItemId.HasValue && change.ItemId != intent.Original.ItemId) || + !CloudDirectoryStateProjection.SamePath(intent.Original.RelativePath, change.PreviousRelativePath!) || + !CloudDirectoryStateProjection.SamePath(intent.Object.RelativePath, change.PreviousRelativePath!) || + !CloudDirectoryStateProjection.SamePath(intent.TargetRelativePath, change.RelativePath) || + Path.GetFileName(intent.TargetRelativePath) != Path.GetFileName(change.RelativePath) || + !string.Equals(intent.RootPath, SyncRootPath, StringComparison.OrdinalIgnoreCase) || + intent.StoreScope != await CloudDirectoryMoveEvidence.GetScopeAsync(transaction, false, cancellationToken).ConfigureAwait(false)) + { + throw new InvalidDataException("The remote move preparation does not match this batch, policy, store, or root."); + } + + return intent; + } + + private async ValueTask PrepareRemoteFileMoveAsync(CloudRemoteChange change, + CloudItemState original, CloudRemoteApplyOptions effectiveOptions, RemoteMoveRequest request, + CancellationToken cancellationToken) + { + string source = ToFullPath(change.PreviousRelativePath!); + string target = ToFullPath(change.RelativePath); + using CloudPathHandleLease chains = CloudPathHandleLease.OpenParentChains(SyncRootPath, [source, target]); + using RemoteMoveParentLease parentLease = HoldRemoteMoveParents(source, target); + using SafeFileHandle guard = WindowsFileMetadata.Open(source, preventDelete: true); + RemoteMoveObject observed = ObserveRemoteMoveObject(guard, directory: false, effectiveOptions.PreserveUnsynchronizedLocalContent, + preserveContent: effectiveOptions.PreserveUnsynchronizedLocalContent); + CloudPlaceholderIdentity identity = CloudPlaceholderIdentity.Decode(observed.Identity); + if (original.IsTombstone || original.Kind != CloudItemKind.File || original.RemoteId != change.RemoteId || + (change.ItemId.HasValue && change.ItemId != original.ItemId) || identity.ItemId != original.ItemId || + identity.RemoteId != original.RemoteId || !CloudDirectoryStateProjection.SamePath(observed.RelativePath, original.RelativePath)) + { + throw new IOException("The remote move source does not match its original durable identity."); + } + + IReadOnlyList parents = parentLease.Parents; + string destinationParent = Path.GetDirectoryName(target)!; + using SafeFileHandle parentHandle = WindowsFileMetadata.Open(destinationParent, preventDelete: true); + string actualTarget = Path.Combine(WindowsFileMetadata.ReadFinalPath(parentHandle.DangerousGetHandle()), Path.GetFileName(target)); + await using ICloudStateTransaction transaction = await _stateStore!.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + if (!SameRemoteMoveItem(await transaction.Items.GetByItemIdAsync(original.ItemId, cancellationToken).ConfigureAwait(false), original)) + { + throw new IOException("The remote move source state changed during preparation."); + } + + foreach (CloudStateCheckpoint checkpoint in await transaction.Checkpoints.ListAsync(RemoteMoveIntent.Prefix, cancellationToken).ConfigureAwait(false)) + { + RemoteMoveIntent pending = RemoteMoveIntent.Decode(checkpoint.Value); + if (pending.Original.ItemId == original.ItemId) + { + throw new IOException("Replay the original pending remote move before preparing another move for this item."); + } + } + + CloudRemoteApplyOptions policy = request.Options; + IReadOnlyList localOperations = await transaction.Operations.ListByItemIdAsync( + original.ItemId, int.MaxValue, cancellationToken).ConfigureAwait(false); + if (effectiveOptions.PreserveUnsynchronizedLocalContent && localOperations.Count != 0) + { + throw new IOException("Local operations changed during remote move preparation."); + } + + Guid scope = await CloudDirectoryMoveEvidence.GetScopeAsync(transaction, true, cancellationToken).ConfigureAwait(false); + RemoteMoveIntent intent = new(scope, SyncRootPath, request.Batch.BatchId, request.EntryIndex, + request.Batch.Fingerprint.ToArray(), Path.GetRelativePath(SyncRootPath, actualTarget), + policy.PreserveUnsynchronizedLocalContent, policy.SuppressLocalEcho, + policy.EchoSuppressionLifetime.Ticks, policy.EchoSuppressionObservationCount, + original, observed, parents, null, effectiveOptions.PreserveUnsynchronizedLocalContent, + RemoteMoveIntent.FingerprintOperations(localOperations), null); + await transaction.Checkpoints.UpsertAsync(new(RemoteMoveIntent.Name(intent.BatchId, intent.EntryIndex), + intent.Encode(), DateTimeOffset.UtcNow), cancellationToken).ConfigureAwait(false); + ValidateRemoteMoveParents(intent, source, target); + RequireSameRemoteMoveObject(observed, ObserveRemoteMoveObject(guard, false, intent.RequireInSync, intent.RequireInSync)); + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + return intent; + } + + private async ValueTask CompleteRemoteFileMoveAsync(CloudRemoteChange change, + RemoteMoveIntent intent, CancellationToken cancellationToken) + { + string source = ToFullPath(intent.Object.RelativePath); + string target = ToFullPath(intent.TargetRelativePath); + using CloudPathHandleLease chains = CloudPathHandleLease.OpenParentChains(SyncRootPath, [source, target]); + using RemoteMoveParentLease parentLease = HoldRemoteMoveParents(source, target); + ValidateRemoteMoveParents(intent, source, target); + bool requireInSync = intent.RequireInSync && intent.ProjectedAtTicks is null; + using SafeFileHandle? sourceHandle = TryOpenRemoteMoveObject(source); + using SafeFileHandle? targetHandle = TryOpenRemoteMoveObject(target); + RemoteMoveObject? atSource = sourceHandle is null ? null : ObserveRemoteMoveObject(sourceHandle, false, requireInSync, intent.RequireInSync); + RemoteMoveObject? atTarget = targetHandle is null ? null : ObserveRemoteMoveObject(targetHandle, false, requireInSync, intent.RequireInSync); + bool moved = atTarget is not null && SameRemoteMoveObject(intent.Object, atTarget) && + atTarget.RelativePath == intent.TargetRelativePath; + bool notMoved = atSource is not null && SameRemoteMoveObject(intent.Object, atSource) && + atSource.RelativePath == intent.Object.RelativePath; + bool samePath = CloudDirectoryStateProjection.SamePath(intent.Object.RelativePath, intent.TargetRelativePath); + if ((!moved && !notMoved) || (!samePath && moved && atSource is not null) || + (!samePath && notMoved && atTarget is not null) || (intent.ProjectedAtTicks.HasValue && !moved)) + { + throw RemoteMoveUnresolved(intent, "native observation"); + } + + if (intent.ProjectedUsn is long usn && WindowsFileUsn.Read(target, expectedDirectory: false) != usn) + { + throw RemoteMoveUnresolved(intent, "post-projection native USN validation"); + } + + // Holding one transaction excludes feed/custom-store writers across native rename and + // projection. This synchronous metadata-only operation invokes no application callback. + // A failed/ambiguous commit leaves the preparation, or its committed projected stage, + // for the next call to reread; never assume rollback from the thrown exception alone. + await using ICloudStateTransaction transaction = await _stateStore!.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + string name = RemoteMoveIntent.Name(intent.BatchId, intent.EntryIndex); + CloudStateCheckpoint? retained = await transaction.Checkpoints.GetAsync(name, cancellationToken).ConfigureAwait(false); + if (retained is null || !retained.Value.Span.SequenceEqual(intent.Encode())) + { + throw RemoteMoveUnresolved(intent, "preparation authentication"); + } + + CloudItemState? current = await transaction.Items.GetByItemIdAsync(intent.Original.ItemId, cancellationToken).ConfigureAwait(false); + CloudItemState expected = intent.ProjectedAtTicks is long ticks + ? new(intent.Original.ItemId, change.RemoteId, change.RelativePath, CloudItemKind.File, + change.RemoteRevision, intent.Original.LocalFileId, false, new DateTimeOffset(ticks, TimeSpan.Zero)) + : intent.Original; + CloudItemState? occupant = await transaction.Items.GetByRelativePathAsync(change.RelativePath, cancellationToken).ConfigureAwait(false); + if (!SameRemoteMoveItem(current, expected) || (occupant is not null && occupant.ItemId != expected.ItemId) || + !RemoteMoveIntent.FingerprintOperations(await transaction.Operations.ListByItemIdAsync(expected.ItemId, int.MaxValue, + cancellationToken).ConfigureAwait(false)).AsSpan().SequenceEqual(intent.LocalOperationsFingerprint)) + { + throw RemoteMoveUnresolved(intent, "durable projection validation"); + } + + if (!moved) + { + cancellationToken.ThrowIfCancellationRequested(); + using SafeFileHandle rename = WindowsFileMetadata.OpenForRename(source); + RequireSameRemoteMoveObject(intent.Object, ObserveRemoteMoveObject(rename, false, intent.RequireInSync, intent.RequireInSync)); + if (Path.GetRelativePath(SyncRootPath, WindowsFileMetadata.ReadFinalPath(rename.DangerousGetHandle())) != intent.Object.RelativePath) + { + throw RemoteMoveUnresolved(intent, "source path validation"); + } + + try + { + WindowsFileMetadata.Rename(rename.DangerousGetHandle(), target); + } + catch (Win32Exception exception) + { + throw CloudFilesException.FromHResult("CloudFileSystem.ApplyRemoteMove", source, + unchecked((int)(0x80070000u | (uint)exception.NativeErrorCode))); + } + } + + // Once native work starts, finish its durable projection with the admitted operation's + // lifetime, even if cancellation or disposal was requested. Outcome publication may be + // canceled separately; its unchanged original preparation remains available for replay. + // Close every handle that overlapped rename before recording its final USN. NTFS can + // append the accumulated CLOSE reason when the last such file object is released. + sourceHandle?.Dispose(); + targetHandle?.Dispose(); + using SafeFileHandle final = WindowsFileMetadata.Open(target, preventDelete: true); + // TrackAll can clear in-sync solely because this rename changed the name. Do not mark + // it in sync or guess that a later dirty object is ours. Retain the observed post-rename + // USN with projection; replay rejects any subsequent native change, even an equal-size + // content write whose timestamps were restored. Earlier gaps still require in-sync proof. + RemoteMoveObject completed = ObserveRemoteMoveObject(final, false, requireInSync: false, preserveContent: intent.RequireInSync); + RequireSameRemoteMoveObject(intent.Object, completed); + long completedUsn = WindowsFileUsn.Read(target, expectedDirectory: false); + if (intent.ProjectedUsn.HasValue && intent.ProjectedUsn != completedUsn) + { + throw RemoteMoveUnresolved(intent, "final native USN validation"); + } + if (completed.RelativePath != intent.TargetRelativePath) + { + throw RemoteMoveUnresolved(intent, "exact target spelling"); + } + + ValidateRemoteMoveParents(intent, source, target); + + if (intent.ProjectedAtTicks is null) + { + DateTimeOffset now = DateTimeOffset.UtcNow; + await transaction.Items.UpsertAsync(new(expected.ItemId, change.RemoteId, change.RelativePath, + CloudItemKind.File, change.RemoteRevision, expected.LocalFileId, false, now), CancellationToken.None).ConfigureAwait(false); + await CloudDirectoryProvenance.RelocateRetainedPathsAsync(transaction, [expected], expected.RelativePath, + change.RelativePath, CancellationToken.None).ConfigureAwait(false); + if (intent.SuppressEcho) + { + await transaction.EchoSuppressions.UpsertAsync(new(Guid.NewGuid(), expected.ItemId, + CloudStateOperationKind.Move, change.RelativePath, [], now + TimeSpan.FromTicks(intent.EchoLifetimeTicks), + change.PreviousRelativePath, intent.EchoObservations), CancellationToken.None).ConfigureAwait(false); + } + + await transaction.Checkpoints.UpsertAsync(new(name, (intent with { ProjectedAtTicks = now.UtcTicks, ProjectedUsn = completedUsn }).Encode(), now), + CancellationToken.None).ConfigureAwait(false); + RequireSameRemoteMoveObject(intent.Object, ObserveRemoteMoveObject(final, false, requireInSync: false, preserveContent: intent.RequireInSync)); + if (WindowsFileUsn.Read(target, expectedDirectory: false) != completedUsn) + { + throw RemoteMoveUnresolved(intent, "post-rename native USN validation"); + } + await transaction.CommitAsync(CancellationToken.None).ConfigureAwait(false); + } + + return RemoteEntryOutcome.AppliedResult; + } + + private string[] GetRemoteMoveParentPaths(string source, string target) + { + HashSet paths = new(StringComparer.OrdinalIgnoreCase) { SyncRootPath }; + foreach (string path in new[] { source, target }) + { + string? parent = Path.GetDirectoryName(path); + while (parent is not null && !string.Equals(parent, SyncRootPath, StringComparison.OrdinalIgnoreCase)) + { + paths.Add(parent); + parent = Path.GetDirectoryName(parent); + } + } + + return paths.Order(StringComparer.OrdinalIgnoreCase).ToArray(); + } + + private RemoteMoveParentLease HoldRemoteMoveParents(string source, string target) + { + List handles = []; + try + { + foreach (string path in GetRemoteMoveParentPaths(source, target)) + { + handles.Add(WindowsFileMetadata.OpenDirectoryGuard(path)); + } + + return new(handles, handles.Select(handle => ObserveRemoteMoveObject(handle, directory: true, requireInSync: false)).ToArray()); + } + catch + { + foreach (SafeFileHandle handle in handles) + { + handle.Dispose(); + } + + throw; + } + } + + private void ValidateRemoteMoveParents(RemoteMoveIntent intent, string source, string target) + { + using RemoteMoveParentLease lease = HoldRemoteMoveParents(source, target); + RemoteMoveObject[] parents = lease.Parents; + if (parents.Length != intent.Parents.Count || parents.Where((parent, index) => + parent.RelativePath != intent.Parents[index].RelativePath || parent.Binding != intent.Parents[index].Binding || + parent.CreationTime != intent.Parents[index].CreationTime || + !parent.Identity.AsSpan().SequenceEqual(intent.Parents[index].Identity)).Any()) + { + throw RemoteMoveUnresolved(intent, "parent provenance validation"); + } + } + + private RemoteMoveObject ObserveRemoteMoveObject(SafeFileHandle handle, bool directory, bool requireInSync, bool preserveContent = true) + { + NativeFileMetadata metadata = WindowsFileMetadata.Read(handle.DangerousGetHandle()); + bool placeholder = metadata.PlaceholderState != CfPlaceholderState.Invalid && + metadata.PlaceholderState.HasFlag(CfPlaceholderState.Placeholder); + if (metadata.Directory != directory || metadata.DeletePending || (!directory && (!placeholder || metadata.Links != 1)) || + (metadata.ReparseTag != 0 && !placeholder) || (requireInSync && !metadata.InSync) || + (!directory && preserveContent && metadata.ModifiedDataSize != 0)) + { + throw new IOException("Remote move recovery requires the unchanged original object and safe ancestry."); + } + + string path = WindowsFileMetadata.ReadFinalPath(handle.DangerousGetHandle()); + if (!string.Equals(path, SyncRootPath, StringComparison.OrdinalIgnoreCase) && !IsStrictDescendant(path, SyncRootPath)) + { + throw new IOException("The observed remote move object is outside its original root."); + } + + WindowsFileMetadata.FileBasicInfo basic = WindowsFileMetadata.ReadBasic(handle.DangerousGetHandle()); + return new(string.Equals(path, SyncRootPath, StringComparison.OrdinalIgnoreCase) ? string.Empty : Path.GetRelativePath(SyncRootPath, path), + CloudLocalFileBindingPlatform.Read(handle.DangerousGetHandle(), SyncRootPath), metadata.PlaceholderIdentity, + basic.CreationTime, basic.LastWriteTime, metadata.Length); + } + + private static SafeFileHandle? TryOpenRemoteMoveObject(string path) + { + try + { + return WindowsFileMetadata.Open(path); + } + catch (Win32Exception exception) when (exception.NativeErrorCode is 2 or 3) + { + return null; + } + } + + private static bool SameRemoteMoveObject(RemoteMoveObject expected, RemoteMoveObject current) => + expected.Binding == current.Binding && expected.Identity.AsSpan().SequenceEqual(current.Identity) && + expected.CreationTime == current.CreationTime && expected.LastWriteTime == current.LastWriteTime && expected.Length == current.Length; + + private static void RequireSameRemoteMoveObject(RemoteMoveObject expected, RemoteMoveObject current) + { + if (!SameRemoteMoveObject(expected, current)) + { + throw new IOException("The native object no longer matches the retained remote move preparation."); + } + } + + private static bool SameRemoteMoveItem(CloudItemState? current, CloudItemState expected) => + current is not null && current.ItemId == expected.ItemId && current.RemoteId == expected.RemoteId && + current.RelativePath == expected.RelativePath && current.Kind == expected.Kind && + current.RemoteRevision == expected.RemoteRevision && current.LocalFileId == expected.LocalFileId && + current.IsTombstone == expected.IsTombstone && current.UpdatedAt == expected.UpdatedAt; + + private static IOException RemoteMoveUnresolved(RemoteMoveIntent intent, string stage) => + new($"Remote batch '{intent.BatchId}' entry {intent.EntryIndex} remains pending at {stage}; " + + "retain the original batch and reconcile the namespace before retrying."); + + private sealed class RemoteMoveParentLease(List handles, RemoteMoveObject[] parents) : IDisposable + { + internal RemoteMoveObject[] Parents => parents; + public void Dispose() + { + foreach (SafeFileHandle handle in handles) + { + handle.Dispose(); + } + } + } +} diff --git a/src/CfSharp/LocalChanges/CloudLocalChangeFeed.Scans.cs b/src/CfSharp/LocalChanges/CloudLocalChangeFeed.Scans.cs index bba8194..b422853 100644 --- a/src/CfSharp/LocalChanges/CloudLocalChangeFeed.Scans.cs +++ b/src/CfSharp/LocalChanges/CloudLocalChangeFeed.Scans.cs @@ -151,6 +151,8 @@ private static async ValueTask RequiresCreationReconciliationAsync(ICloudS .ConfigureAwait(false)).Count != 0 || (await transaction.Checkpoints.ListAsync(NamespaceObservationsPrefix, cancellationToken) .ConfigureAwait(false)).Count != 0 || + (await transaction.Checkpoints.ListAsync(RemoteMoveIntent.Prefix, cancellationToken) + .ConfigureAwait(false)).Count != 0 || (await transaction.Checkpoints.ListAsync(RemoteCreationIntent.Prefix, cancellationToken) .ConfigureAwait(false)).Any(value => !RemoteCreationIntent.Decode(value.Value).Committed); } diff --git a/src/CfSharp/LocalChanges/CloudLocalChangeFeed.cs b/src/CfSharp/LocalChanges/CloudLocalChangeFeed.cs index d8127c6..f7f6ac1 100644 --- a/src/CfSharp/LocalChanges/CloudLocalChangeFeed.cs +++ b/src/CfSharp/LocalChanges/CloudLocalChangeFeed.cs @@ -260,7 +260,8 @@ await transaction.Operations.RemoveAsync(acknowledgement.OperationId, cancellati /// /// Token that cancels before the marker commit. /// - /// A creation remains pending, a loss signal is not yet durable, or new loss raced this acknowledgement. + /// A remote creation or file move remains pending, a loss signal is not yet durable, or new + /// loss raced this acknowledgement. /// Finish recovery and retry after the worker has persisted its signal. /// public async ValueTask AcknowledgeFullRescanAsync(CancellationToken cancellationToken = default) @@ -271,9 +272,10 @@ public async ValueTask AcknowledgeFullRescanAsync(CancellationToken cancellation .BeginTransactionAsync(cancellationToken).ConfigureAwait(false); IReadOnlyList creations = await transaction.Checkpoints .ListAsync(RemoteCreationIntent.Prefix, cancellationToken).ConfigureAwait(false); - if (creations.Any(value => !RemoteCreationIntent.Decode(value.Value).Committed)) + if (creations.Any(value => !RemoteCreationIntent.Decode(value.Value).Committed) || + (await transaction.Checkpoints.ListAsync(RemoteMoveIntent.Prefix, cancellationToken).ConfigureAwait(false)).Count != 0) { - throw new InvalidOperationException("Replay pending remote creations before acknowledging reconciliation."); + throw new InvalidOperationException("Replay pending remote creations and moves before acknowledging reconciliation."); } foreach (CloudStateCheckpoint observation in await transaction.Checkpoints @@ -752,16 +754,23 @@ private async ValueTask PersistChangeCoreAsync(CloudLocalChangeKind kind, CloudI bool pendingCreation = creations.Select(value => RemoteCreationIntent.Decode(value.Value)).Any(intent => !intent.Committed && (string.Equals(intent.RelativePath, path.RelativePath, StringComparison.OrdinalIgnoreCase) || string.Equals(intent.RelativePath, previousPath?.RelativePath, StringComparison.OrdinalIgnoreCase))); - if (pendingCreation) + IReadOnlyList moves = await transaction.Checkpoints + .ListAsync(RemoteMoveIntent.Prefix, cancellationToken).ConfigureAwait(false); + bool pendingMove = moves.Select(value => RemoteMoveIntent.Decode(value.Value)).Any(intent => + CloudDirectoryStateProjection.SamePath(intent.Object.RelativePath, path.RelativePath) || + CloudDirectoryStateProjection.SamePath(intent.TargetRelativePath, path.RelativePath) || + (previousPath is not null && (CloudDirectoryStateProjection.SamePath(intent.Object.RelativePath, previousPath.Value.RelativePath) || + CloudDirectoryStateProjection.SamePath(intent.TargetRelativePath, previousPath.Value.RelativePath)))); + if (pendingCreation || pendingMove) { long generation = SignalLoss(); - // Do not assign a local identity while native creation is uncommitted. Retain + // Do not assign or relocate a local identity while native remote work is pending. Retain // the observation durably and require reconciliation after remote replay; its // origin cannot be proved solely from a path, timestamp, or notification kind. LocalChangePayload uncertain = new(path.RelativePath, previousPath?.RelativePath, Directory.Exists(path.FullPath), observedAt); await transaction.Checkpoints.UpsertAsync(new CloudStateCheckpoint( - RemoteCreationIntent.ObservationsPrefix + "/" + kind + "/" + Guid.NewGuid().ToString("N"), + (pendingMove ? NamespaceObservationsPrefix : RemoteCreationIntent.ObservationsPrefix) + "/" + kind + "/" + Guid.NewGuid().ToString("N"), uncertain.Encode(), observedAt), cancellationToken).ConfigureAwait(false); await UpsertCheckpointAsync(transaction, observation, true, observedAt, cancellationToken).ConfigureAwait(false); await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/CfSharp/RemoteChanges/CloudRemoteChangeModels.cs b/src/CfSharp/RemoteChanges/CloudRemoteChangeModels.cs index 350ec4b..0c5e714 100644 --- a/src/CfSharp/RemoteChanges/CloudRemoteChangeModels.cs +++ b/src/CfSharp/RemoteChanges/CloudRemoteChangeModels.cs @@ -655,7 +655,11 @@ internal CloudRemoteApplyResult( /// Gets whether the caller should retry or resume the batch. public bool RequiresRetry { get; } - /// Gets the number of entries durably applied or already applied. + /// + /// Gets the committed entry prefix, including durable conflict outcomes. An applied batch + /// may contain conflicts; inspect entry outcomes and conflict identifiers before treating + /// every remote mutation as successful. + /// public int AppliedEntryCount { get; } /// Gets durable conflict identifiers created or reused by this call. diff --git a/src/CfSharp/RemoteChanges/RemoteMoveIntent.cs b/src/CfSharp/RemoteChanges/RemoteMoveIntent.cs new file mode 100644 index 0000000..eb8480a --- /dev/null +++ b/src/CfSharp/RemoteChanges/RemoteMoveIntent.cs @@ -0,0 +1,194 @@ +using System.Security.Cryptography; +using System.Text; + +namespace CfSharp; + +// This is SDK coordination state, not provider data. The key identifies the original batch +// entry; the batch fingerprint also binds both paths, opaque revisions, IDs, and cursor envelope. +internal sealed record RemoteMoveIntent( + Guid StoreScope, string RootPath, string BatchId, int EntryIndex, byte[] Fingerprint, string TargetRelativePath, + bool PreserveContent, bool SuppressEcho, long EchoLifetimeTicks, int EchoObservations, + CloudItemState Original, RemoteMoveObject Object, IReadOnlyList Parents, + long? ProjectedAtTicks, bool RequireInSync, byte[] LocalOperationsFingerprint, long? ProjectedUsn) +{ + internal const string Prefix = "cfsharp/remote-move/v1/"; + + internal static string Name(string batchId, int index) => Prefix + + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(batchId))) + "/" + + index.ToString(System.Globalization.CultureInfo.InvariantCulture); + + internal bool Matches(CloudRemoteChangeBatch batch, int index, CloudRemoteApplyOptions options) => + BatchId == batch.BatchId && EntryIndex == index && Fingerprint.AsSpan().SequenceEqual(batch.Fingerprint.Span) && + PreserveContent == options.PreserveUnsynchronizedLocalContent && SuppressEcho == options.SuppressLocalEcho && + EchoLifetimeTicks == options.EchoSuppressionLifetime.Ticks && EchoObservations == options.EchoSuppressionObservationCount; + + internal byte[] Encode() + { + using MemoryStream stream = new(); + using BinaryWriter writer = new(stream, CloudDirectoryEvidenceCodec.Utf8, leaveOpen: true); + writer.Write(1); + writer.Write(StoreScope.ToByteArray()); + WriteText(writer, RootPath); + WriteText(writer, BatchId); + writer.Write(EntryIndex); + writer.Write(Fingerprint); + WriteText(writer, TargetRelativePath); + writer.Write(RequireInSync); + writer.Write(LocalOperationsFingerprint); + writer.Write(PreserveContent); + writer.Write(SuppressEcho); + writer.Write(EchoLifetimeTicks); + writer.Write(EchoObservations); + writer.Write(Original.ItemId.ToByteArray()); + WriteText(writer, Original.RemoteId); + WriteText(writer, Original.RelativePath); + writer.Write(Original.RemoteRevision is not null); + if (Original.RemoteRevision is not null) + { + WriteText(writer, Original.RemoteRevision); + } + + writer.Write(Original.LocalFileId.HasValue); + if (Original.LocalFileId is long localId) + { + writer.Write(localId); + } + + writer.Write(Original.UpdatedAt.UtcTicks); + WriteObject(writer, Object); + writer.Write(Parents.Count); + foreach (RemoteMoveObject parent in Parents) + { + WriteObject(writer, parent); + } + + writer.Write(ProjectedAtTicks.HasValue); + if (ProjectedAtTicks is long projected) + { + writer.Write(projected); + writer.Write(ProjectedUsn!.Value); + } + + return stream.ToArray(); + } + + internal static RemoteMoveIntent Decode(ReadOnlyMemory value) + { + if (value.Length is < 200 or > 4 * 1024 * 1024) + { + throw new InvalidDataException("The remote move preparation length is invalid."); + } + + try + { + using MemoryStream stream = new(value.ToArray(), writable: false); + using BinaryReader reader = new(stream, CloudDirectoryEvidenceCodec.Utf8); + if (reader.ReadInt32() != 1) + { + throw new InvalidDataException("The remote move preparation version is unsupported."); + } + + Guid scope = CloudDirectoryEvidenceCodec.ReadGuid(reader); + string root = ReadText(reader); + string batch = ReadText(reader); + int index = reader.ReadInt32(); + byte[] fingerprint = reader.ReadBytes(32); + string target = CloudRemotePathValidation.Canonicalize(ReadText(reader), "target"); + bool requireInSync = reader.ReadBoolean(); + byte[] operations = reader.ReadBytes(32); + bool preserve = reader.ReadBoolean(); + bool echo = reader.ReadBoolean(); + long lifetime = reader.ReadInt64(); + int observations = reader.ReadInt32(); + Guid id = CloudDirectoryEvidenceCodec.ReadGuid(reader); + string remote = ReadText(reader); + string path = ReadText(reader); + string? revision = reader.ReadBoolean() ? ReadText(reader) : null; + long? localId = reader.ReadBoolean() ? reader.ReadInt64() : null; + CloudItemState original = new(id, remote, path, CloudItemKind.File, revision, localId, false, + new DateTimeOffset(reader.ReadInt64(), TimeSpan.Zero)); + RemoteMoveObject item = ReadObject(reader); + int count = reader.ReadInt32(); + if (count is < 1 or > 1024) + { + throw new InvalidDataException("The remote move parent count is invalid."); + } + + List parents = new(count); + for (int parent = 0; parent < count; parent++) + { + parents.Add(ReadObject(reader)); + } + + long? projected = reader.ReadBoolean() ? reader.ReadInt64() : null; + long? projectedUsn = projected.HasValue ? reader.ReadInt64() : null; + if (stream.Position != stream.Length || scope == Guid.Empty || !Path.IsPathFullyQualified(root) || + string.IsNullOrWhiteSpace(batch) || index < 0 || lifetime <= 0 || observations <= 0 || + fingerprint.Length != 32 || item.Identity.Length == 0 || + CloudPlaceholderIdentity.Decode(item.Identity).ItemId != id || + parents.Select(parent => parent.RelativePath).Distinct(StringComparer.OrdinalIgnoreCase).Count() != count) + { + throw new InvalidDataException("The remote move preparation is malformed."); + } + + return new(scope, root, batch, index, fingerprint, target, preserve, echo, lifetime, observations, + original, item, parents.AsReadOnly(), projected, requireInSync, operations, projectedUsn); + } + catch (Exception exception) when (exception is ArgumentException or EndOfStreamException or DecoderFallbackException) + { + throw new InvalidDataException("The remote move preparation is malformed.", exception); + } + } + + private static void WriteObject(BinaryWriter writer, RemoteMoveObject item) + { + WriteText(writer, item.RelativePath); + writer.Write(item.Binding.VolumeSerialNumber); + writer.Write(item.Binding.SyncRootFileId.ToByteArray()); + writer.Write(item.Binding.LocalFileId.ToByteArray()); + writer.Write(item.Identity.Length); + writer.Write(item.Identity); + writer.Write(item.CreationTime); + writer.Write(item.LastWriteTime); + writer.Write(item.Length); + } + + private static RemoteMoveObject ReadObject(BinaryReader reader) + { + string path = ReadText(reader); + if (path.Length != 0) + { + path = CloudRemotePathValidation.Canonicalize(path, "path"); + } + + CloudLocalFileBinding binding = new(reader.ReadUInt64(), CloudDirectoryEvidenceCodec.ReadGuid(reader), + CloudDirectoryEvidenceCodec.ReadGuid(reader)); + byte[] identity = CloudDirectoryEvidenceCodec.ReadBytes(reader, SyncRootRegistrationOptions.MaxFileIdentityLength); + return new(path, binding, identity, reader.ReadInt64(), reader.ReadInt64(), reader.ReadInt64()); + } + + private static void WriteText(BinaryWriter writer, string value) => CloudDirectoryEvidenceCodec.WriteText(writer, value); + private static string ReadText(BinaryReader reader) => CloudDirectoryEvidenceCodec.ReadText(reader); + + internal static byte[] FingerprintOperations(IReadOnlyList operations) + { + using MemoryStream stream = new(); + using BinaryWriter writer = new(stream); + foreach (CloudOperationJournalEntry operation in operations.OrderBy(operation => operation.OperationId)) + { + writer.Write(operation.OperationId.ToByteArray()); + writer.Write((int)operation.Kind); + writer.Write(operation.Sequence); + writer.Write(operation.CreatedAt.UtcTicks); + writer.Write(operation.Payload.Length); + writer.Write(operation.Payload.Span); + } + + return SHA256.HashData(stream.ToArray()); + } +} + +internal sealed record RemoteMoveObject(string RelativePath, CloudLocalFileBinding Binding, byte[] Identity, + long CreationTime, long LastWriteTime, long Length); + +internal sealed record RemoteMoveRequest(CloudRemoteChangeBatch Batch, int EntryIndex, CloudRemoteApplyOptions Options); diff --git a/tests/CfSharp.IntegrationTests/RemoteMoveRecoveryTests.cs b/tests/CfSharp.IntegrationTests/RemoteMoveRecoveryTests.cs new file mode 100644 index 0000000..42a331b --- /dev/null +++ b/tests/CfSharp.IntegrationTests/RemoteMoveRecoveryTests.cs @@ -0,0 +1,456 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using CfSharp.Storage.Sqlite; +using Microsoft.Win32.SafeHandles; + +namespace CfSharp.IntegrationTests; + +[SupportedOSPlatform("windows10.0.16299")] +public sealed partial class RemoteMoveRecoveryTests +{ + [Theory] + [InlineData(false, false, "outcome")] + [InlineData(true, false, "outcome")] + [InlineData(false, true, "outcome")] + [InlineData(true, true, "outcome")] + [InlineData(false, false, "projection")] + [InlineData(true, false, "projection")] + [InlineData(false, false, "prepare-after")] + [InlineData(true, false, "prepare-after")] + [InlineData(false, false, "projection-after")] + [InlineData(true, false, "projection-after")] + [InlineData(false, false, "outcome-after")] + [InlineData(true, false, "outcome-after")] + [InlineData(false, false, "outcome-default")] + [InlineData(true, false, "outcome-default")] + public async Task OriginalBatchRecoversAcrossSqliteReopenWithoutRenamingOrHydratingAgain( + bool caseOnly, bool equalRevisions, string boundary) + { + await using MoveRoot root = await MoveRoot.OpenAsync(caseOnly, equalRevisions, preserveInSync: boundary != "outcome-default"); + CloudItemSnapshot original = await root.FileSystem.GetFile("Docs/remote.bin").InspectAsync(); + root.Factory.Boundary = boundary == "outcome-default" ? "outcome" : boundary; + root.Factory.FailuresRemaining = 1; + if (boundary.StartsWith("outcome", StringComparison.Ordinal)) + { + Assert.Same(root.Factory.Error, await Assert.ThrowsAsync(() => root.ApplyAsync())); + } + else + { + CloudRemoteApplyResult interrupted = await root.ApplyAsync(); + Assert.True(interrupted.RequiresRetry); + Assert.Equal(0, interrupted.AppliedEntryCount); + Assert.Empty(interrupted.SafeCursor.ToArray()); + Assert.Same(root.Factory.Error, interrupted.Entries[0].Error); + } + + Assert.Equal(1, root.Factory.FailuresObserved); + await root.RestartAsync(); + bool moved = boundary != "prepare-after"; + if (moved) + { + CloudItemSnapshot pending = await root.FileSystem.GetFile(root.Target).InspectAsync(); + Assert.Equal(original.LocalBinding, pending.LocalBinding); + Assert.Equal(original.PlaceholderIdentity.ToArray(), pending.PlaceholderIdentity.ToArray()); + Assert.Equal(CloudContentAvailability.OnlineOnly, pending.ContentAvailability); + await AssertExactTargetAsync(root); + } + + await using (ICloudStateTransaction verify = await root.Factory.Store!.BeginTransactionAsync()) + { + CloudRemoteBatchState progress = (await verify.RemoteBatches.GetAsync(root.Batch.BatchId))!; + Assert.Equal(boundary == "outcome-after" ? 1 : 0, progress.AppliedEntryCount); + Assert.Equal(root.Batch.Fingerprint.ToArray(), progress.Fingerprint.ToArray()); + Assert.Empty(await verify.Conflicts.ListAsync()); + Assert.Equal(boundary == "outcome-after" ? 0 : 1, + (await verify.Checkpoints.ListAsync(RemoteMoveIntent.Prefix)).Count); + } + + // A no-delete target handle makes a second native rename fail, including a case-only + // redispatch through the source alias. Recovery only observes and commits evidence. + using (var noRename = moved ? OpenNoRename(Path.Combine(root.Path, root.Target)) : null) + { + Assert.False(noRename?.IsInvalid ?? false); + CloudRemoteApplyResult replay = await root.ApplyAsync(); + Assert.True(!replay.RequiresRetry, replay.Entries[0].Error?.ToString()); + Assert.Equal(1, replay.AppliedEntryCount); + Assert.Empty(replay.ConflictIds); + Assert.Equal(new byte[] { 9 }, replay.SafeCursor.ToArray()); + Assert.Equal(boundary == "outcome-after" ? CloudRemoteApplyEntryStatus.AlreadyApplied : CloudRemoteApplyEntryStatus.Applied, + replay.Entries[0].Status); + } + + await root.RestartAsync(); + Assert.Equal(CloudRemoteApplyEntryStatus.AlreadyApplied, (await root.ApplyAsync()).Entries[0].Status); + Assert.Equal(original.LocalBinding, (await root.FileSystem.GetFile(root.Target).InspectAsync()).LocalBinding); + await AssertExactTargetAsync(root); + await using ICloudStateTransaction complete = await root.Factory.Store!.BeginTransactionAsync(); + Assert.Empty(await complete.Checkpoints.ListAsync(RemoteMoveIntent.Prefix)); + Assert.Empty(await complete.Operations.ListAsync(100)); + CloudItemState item = (await complete.Items.GetByItemIdAsync(root.ItemId))!; + Assert.Equal(root.Target, item.RelativePath); + Assert.Equal(equalRevisions ? "v1" : "v2", item.RemoteRevision); + Assert.Equal(0, root.Provider.Reads); + } + + [Theory] + [InlineData("outcome")] + [InlineData("projection")] + public async Task SecondRecoveryCommitFailureKeepsOriginalEvidenceAndCursor(string boundary) + { + await using MoveRoot root = await MoveRoot.OpenAsync(false, false); + root.Factory.Boundary = boundary; + root.Factory.FailuresRemaining = 2; + for (int attempt = 0; attempt < 2; attempt++) + { + if (boundary == "outcome") + { + Assert.Same(root.Factory.Error, await Assert.ThrowsAsync(() => root.ApplyAsync())); + } + else + { + CloudRemoteApplyResult failed = await root.ApplyAsync(); + Assert.True(failed.RequiresRetry); + Assert.Equal(0, failed.AppliedEntryCount); + Assert.Same(root.Factory.Error, failed.Entries[0].Error); + } + + await root.RestartAsync(); + await using ICloudStateTransaction transaction = await root.Factory.Store!.BeginTransactionAsync(); + Assert.Single(await transaction.Checkpoints.ListAsync(RemoteMoveIntent.Prefix)); + Assert.Equal(0, (await transaction.RemoteBatches.GetAsync(root.Batch.BatchId))!.AppliedEntryCount); + Assert.Empty(await transaction.Conflicts.ListAsync()); + } + + Assert.False((await root.ApplyAsync()).RequiresRetry); + Assert.Equal(2, root.Factory.FailuresObserved); + Assert.Equal(0, root.Provider.Reads); + } + + [Fact] + public async Task MultiEntryRecoveryRetainsCommittedPrefixAndDoesNotProcessLaterEntriesEarly() + { + await using MoveRoot root = await MoveRoot.OpenAsync(false, false); + CloudRemoteChange first = new("first", CloudRemoteChangeKind.FileUpsert, "first", "v1", CloudItemKind.File, + "first.bin", length: 512, metadata: CloudPlaceholderMetadata.CreateFileBuilder().Build(), cursorAfter: new byte[] { 1 }); + CloudRemoteChange last = new("last", CloudRemoteChangeKind.FileUpsert, "last", "v1", CloudItemKind.File, + "last.bin", length: 512, metadata: CloudPlaceholderMetadata.CreateFileBuilder().Build()); + root.UseBatch(new(root.Batch.BatchId, ReadOnlyMemory.Empty, [first, root.Batch.Changes[0], last], new byte[] { 9 })); + root.Factory.Boundary = "outcome"; + root.Factory.OutcomeCount = 2; + root.Factory.FailuresRemaining = 1; + await Assert.ThrowsAsync(() => root.ApplyAsync()); + await root.RestartAsync(); + Assert.False(File.Exists(Path.Combine(root.Path, "last.bin"))); + await using (ICloudStateTransaction prefix = await root.Factory.Store!.BeginTransactionAsync()) + { + CloudRemoteBatchState batch = (await prefix.RemoteBatches.GetAsync(root.Batch.BatchId))!; + Assert.Equal(1, batch.AppliedEntryCount); + Assert.Equal(new byte[] { 1 }, batch.Cursor.ToArray()); + } + + CloudRemoteApplyResult recovered = await root.FileSystem.ApplyRemoteChangesAsync(root.Batch, new() { MaximumEntries = 1 }); + Assert.True(recovered.RequiresRetry); + Assert.Equal(2, recovered.AppliedEntryCount); + Assert.Equal(CloudRemoteApplyEntryStatus.AlreadyApplied, recovered.Entries[0].Status); + Assert.Equal(CloudRemoteApplyEntryStatus.Applied, recovered.Entries[1].Status); + Assert.Equal(CloudRemoteApplyEntryStatus.NotProcessed, recovered.Entries[2].Status); + Assert.False(File.Exists(Path.Combine(root.Path, "last.bin"))); + await root.RestartAsync(); + CloudRemoteApplyResult complete = await root.ApplyAsync(); + Assert.False(complete.RequiresRetry); + Assert.Equal(3, complete.AppliedEntryCount); + Assert.Equal(new byte[] { 9 }, complete.SafeCursor.ToArray()); + Assert.True(File.Exists(Path.Combine(root.Path, "last.bin"))); + Assert.Equal(0, root.Provider.Reads); + } + + [Theory] + [InlineData("prepare-after")] + [InlineData("projection-after")] + public async Task CancellationAfterDurableBoundaryRetainsRecoverableOriginalEntry(string boundary) + { + await using MoveRoot root = await MoveRoot.OpenAsync(false, false); + using CancellationTokenSource cancellation = new(); + root.Factory.Boundary = boundary; + root.Factory.FailuresRemaining = 1; + root.Factory.Cancel = cancellation.Cancel; + await Assert.ThrowsAnyAsync(() => root.FileSystem.ApplyRemoteChangesAsync( + root.Batch, cancellationToken: cancellation.Token).AsTask()); + await root.RestartAsync(); + Assert.False((await root.ApplyAsync()).RequiresRetry); + Assert.Equal(0, root.Provider.Reads); + } + + [Fact] + public async Task ExplicitForceMovePreservesItsOriginalDirtyStateAndPendingJournalOnRecovery() + { + await using MoveRoot root = await MoveRoot.OpenAsync(false, false); + await root.FileSystem.GetFile("Docs/remote.bin").UpdatePlaceholderAsync( + CloudPlaceholderPatch.CreateBuilder().WithInSyncState(false).Build()); + Guid operationId = Guid.NewGuid(); + await using (ICloudStateTransaction seed = await root.Factory.Store!.BeginTransactionAsync()) + { + await seed.Operations.EnqueueAsync(new(operationId, CloudStateOperationKind.ContentUpdate, + root.ItemId, new byte[] { 6, 7 }, DateTimeOffset.UtcNow)); + await seed.CommitAsync(); + } + + CloudRemoteApplyOptions policy = new() { PreserveUnsynchronizedLocalContent = false }; + root.Factory.Boundary = "outcome"; + root.Factory.FailuresRemaining = 1; + await Assert.ThrowsAsync(() => root.FileSystem.ApplyRemoteChangesAsync(root.Batch, policy).AsTask()); + await root.RestartAsync(); + CloudRemoteApplyResult recovered = await root.FileSystem.ApplyRemoteChangesAsync(root.Batch, policy); + Assert.False(recovered.RequiresRetry); + Assert.Equal(CloudRemoteApplyEntryStatus.Applied, recovered.Entries[0].Status); + Assert.Equal(CloudSynchronizationState.NotInSync, (await root.FileSystem.GetFile(root.Target).InspectAsync()).SynchronizationState); + await using ICloudStateTransaction verify = await root.Factory.Store!.BeginTransactionAsync(); + Assert.Equal(new byte[] { 6, 7 }, (await verify.Operations.GetAsync(operationId))!.Payload.ToArray()); + Assert.Equal(0, root.Provider.Reads); + } + + [Theory] + [InlineData("replacement")] + [InlineData("source-occupant")] + [InlineData("parent-replaced")] + [InlineData("pending-edit")] + [InlineData("dirty")] + [InlineData("policy")] + public async Task ChangedEvidenceRemainsPendingWithoutInventingAConflictOrOverwritingLaterWork(string change) + { + await using MoveRoot root = await MoveRoot.OpenAsync(false, false); + root.Factory.Boundary = "outcome"; + root.Factory.FailuresRemaining = 1; + await Assert.ThrowsAsync(() => root.ApplyAsync()); + await root.RestartAsync(); + Guid? journalId = null; + switch (change) + { + case "replacement": + File.Move(Path.Combine(root.Path, root.Target), Path.Combine(root.Path, "original.bin")); + // Copying the exact identity still cannot adopt a different native object. + await root.FileSystem.GetDirectory("Docs/right").CreatePlaceholderAsync( + CloudFilePlaceholderSpec.CreateBuilder("remote.bin", root.Identity, 512).WithInSyncState(true).Build()); + break; + case "source-occupant": + await File.WriteAllTextAsync(Path.Combine(root.Path, "Docs/remote.bin"), "retain me"); + break; + case "parent-replaced": + Directory.Move(Path.Combine(root.Path, "Docs/right"), Path.Combine(root.Path, "Docs/retained")); + Directory.CreateDirectory(Path.Combine(root.Path, "Docs/right")); + break; + case "pending-edit": + await using (ICloudStateTransaction edit = await root.Factory.Store!.BeginTransactionAsync()) + { + journalId = Guid.NewGuid(); + await edit.Operations.EnqueueAsync(new(journalId.Value, CloudStateOperationKind.ContentUpdate, + root.ItemId, new byte[] { 4, 2 }, DateTimeOffset.UtcNow)); + await edit.CommitAsync(); + } + + break; + case "dirty": + await root.FileSystem.GetFile(root.Target).UpdatePlaceholderAsync( + CloudPlaceholderPatch.CreateBuilder().WithInSyncState(false).Build()); + break; + } + + if (change == "policy") + { + await Assert.ThrowsAsync(() => root.FileSystem.ApplyRemoteChangesAsync(root.Batch, + new CloudRemoteApplyOptions { SuppressLocalEcho = false }).AsTask()); + } + else + { + CloudRemoteApplyResult unresolved = await root.ApplyAsync(); + Assert.True(unresolved.RequiresRetry); + Assert.Equal(0, unresolved.AppliedEntryCount); + Assert.Equal(CloudRemoteApplyEntryStatus.Failed, unresolved.Entries[0].Status); + Assert.NotNull(unresolved.Entries[0].Error); + Assert.Empty(unresolved.ConflictIds); + Assert.Empty(unresolved.SafeCursor.ToArray()); + } + await using ICloudStateTransaction verify = await root.Factory.Store!.BeginTransactionAsync(); + Assert.Single(await verify.Checkpoints.ListAsync(RemoteMoveIntent.Prefix)); + Assert.Empty(await verify.Conflicts.ListAsync()); + if (journalId is Guid id) + { + Assert.Equal(new byte[] { 4, 2 }, (await verify.Operations.GetAsync(id))!.Payload.ToArray()); + } + + if (change == "source-occupant") + { + Assert.Equal("retain me", await File.ReadAllTextAsync(Path.Combine(root.Path, "Docs/remote.bin"))); + } + + Assert.Equal(0, root.Provider.Reads); + } + + private static async Task AssertExactTargetAsync(MoveRoot root) + { + List children = []; + await foreach (CloudItem item in root.FileSystem.GetDirectory(Path.GetDirectoryName(root.Target)!).EnumerateLocalChildrenAsync()) + { + children.Add(item.RelativePath); + } + + Assert.Contains(root.Target, children, StringComparer.Ordinal); + } + + private static SafeFileHandle OpenNoRename(string path) => CreateFile(path, 0x80, 3, 0, 3, 0x02200000, 0); + + [LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + private static partial SafeFileHandle CreateFile(string path, uint access, uint share, nint security, uint disposition, uint flags, nint template); + + private sealed class MoveRoot(string area, CloudFileSystem fileSystem, MoveFactory factory, + RejectingProvider provider, CloudPlaceholderIdentity identity, CloudRemoteChangeBatch batch, string target) : IAsyncDisposable + { + internal CloudFileSystem FileSystem => fileSystem; + internal string Path => fileSystem.SyncRootPath; + internal MoveFactory Factory => factory; + internal RejectingProvider Provider => provider; + internal CloudPlaceholderIdentity Identity => identity; + internal Guid ItemId => identity.ItemId; + internal CloudRemoteChangeBatch Batch => batch; + internal void UseBatch(CloudRemoteChangeBatch value) => batch = value; + internal string Target => target; + internal Task ApplyAsync() => fileSystem.ApplyRemoteChangesAsync(batch).AsTask(); + + internal async Task RestartAsync() + { + string root = Path; + await fileSystem.DisposeAsync(); + fileSystem = CloudFileSystem.CreateBuilder(root).WithStateStore(factory).WithContentProvider(provider).Build(); + await fileSystem.StartAsync(); + } + + internal static async Task OpenAsync(bool caseOnly, bool equalRevisions, bool preserveInSync = true) + { + string area = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "CfSharp-remote-move", Guid.NewGuid().ToString("N")); + string root = System.IO.Path.Combine(area, "root"); + Directory.CreateDirectory(root); + MoveFactory factory = new(new SqliteCloudStateStoreFactory(System.IO.Path.Combine(area, "state.db"))); + RejectingProvider provider = new(); + CloudFileSystem fileSystem = CloudFileSystem.CreateBuilder(root).WithStateStore(factory) + .WithRegistration(SyncRootRegistrationOptions.CreateBuilder("CfSharp Remote Move Test", "1.0") + .WithProviderId(Guid.NewGuid()).WithRootMarkedInSync() + .WithInSyncPolicy(preserveInSync ? CloudInSyncPolicy.TrackAll | CloudInSyncPolicy.PreserveForSyncEngine : CloudInSyncPolicy.TrackAll) + .Build()).WithContentProvider(provider).Build(); + bool registered = false; + try + { + await fileSystem.StartAsync(); + registered = true; + Directory.CreateDirectory(System.IO.Path.Combine(root, "Docs/right")); + CloudPlaceholderIdentity identity = new(Guid.NewGuid(), "remote-file", "v1"); + await fileSystem.GetDirectory("Docs").CreatePlaceholderAsync( + CloudFilePlaceholderSpec.CreateBuilder("remote.bin", identity, 512).WithInSyncState(true).Build()); + string target = caseOnly ? "Docs\\REMOTE.bin" : "Docs\\right\\remote.bin"; + CloudRemoteChange move = new("move", CloudRemoteChangeKind.Move, identity.RemoteId, + equalRevisions ? "v1" : "v2", CloudItemKind.File, target, identity.ItemId, + previousRemoteRevision: "v1", previousRelativePath: "Docs/remote.bin"); + CloudRemoteChangeBatch batch = new("stable-original-batch", ReadOnlyMemory.Empty, [move], new byte[] { 9 }); + return new(area, fileSystem, factory, provider, identity, batch, target); + } + catch + { + await fileSystem.DisposeAsync(); + if (registered) + { + CloudSyncRoot.Open(root).Unregister(); + } + + Directory.Delete(area, recursive: true); + throw; + } + } + + public async ValueTask DisposeAsync() + { + await fileSystem.DisposeAsync(); + CloudSyncRoot.Open(Path).Unregister(); + Directory.Delete(area, recursive: true); + } + } + + private sealed class RejectingProvider : ICloudFileContentProvider + { + internal int Reads { get; private set; } + public ValueTask OpenReadAsync(CloudFileFetchRequest request, CancellationToken cancellationToken) + { + Reads++; + throw new InvalidOperationException("Remote move recovery must never hydrate content."); + } + } + + private sealed class MoveFactory(ICloudStateStoreFactory inner) : ICloudStateStoreFactory + { + internal ICloudStateStore? Store { get; private set; } + internal string Boundary { get; set; } = string.Empty; + internal int FailuresRemaining { get; set; } + internal int OutcomeCount { get; set; } = 1; + internal Action? Cancel { get; set; } + internal int FailuresObserved { get; private set; } + internal IOException Error { get; } = new("Synthetic remote move commit interruption."); + + public async ValueTask OpenAsync(CloudStateStoreContext context, CancellationToken cancellationToken = default) => + Store = new WrappedStore(await inner.OpenAsync(context, cancellationToken), this); + + private sealed class WrappedStore(ICloudStateStore store, MoveFactory owner) : ICloudStateStore + { + public async ValueTask BeginTransactionAsync(CancellationToken cancellationToken = default) => + new Transaction(await store.BeginTransactionAsync(cancellationToken), owner); + public ValueTask DisposeAsync() => store.DisposeAsync(); + } + + private sealed class Transaction(ICloudStateTransaction transaction, MoveFactory owner) : ICloudStateTransaction + { + public ICloudItemStateRepository Items => transaction.Items; + public ICloudCheckpointRepository Checkpoints => transaction.Checkpoints; + public ICloudOperationJournal Operations => transaction.Operations; + public ICloudConflictRepository Conflicts => transaction.Conflicts; + public ICloudRemoteBatchRepository RemoteBatches => transaction.RemoteBatches; + public ICloudEchoSuppressionRepository EchoSuppressions => transaction.EchoSuppressions; + + public async ValueTask CommitAsync(CancellationToken cancellationToken = default) + { + bool inject = false; + if (owner.FailuresRemaining > 0) + { + CloudRemoteBatchState? batch = await RemoteBatches.GetAsync("stable-original-batch", cancellationToken); + IReadOnlyList preparations = await Checkpoints.ListAsync(RemoteMoveIntent.Prefix, cancellationToken); + bool prepared = preparations.Count != 0; + bool projected = prepared && RemoteMoveIntent.Decode(preparations[0].Value).ProjectedAtTicks.HasValue; + inject = owner.Boundary.StartsWith("outcome", StringComparison.Ordinal) ? batch?.AppliedEntryCount == owner.OutcomeCount : + owner.Boundary.StartsWith("projection", StringComparison.Ordinal) ? projected : prepared; + if (inject) + { + owner.FailuresRemaining--; + owner.FailuresObserved++; + } + } + + if (inject && owner.Cancel is not null) + { + await transaction.CommitAsync(cancellationToken); + owner.Cancel(); + return; + } + + if (inject && !owner.Boundary.EndsWith("-after", StringComparison.Ordinal)) + { + throw owner.Error; + } + + await transaction.CommitAsync(cancellationToken); + if (inject) + { + throw owner.Error; + } + } + + public ValueTask RollbackAsync(CancellationToken cancellationToken = default) => transaction.RollbackAsync(cancellationToken); + public ValueTask DisposeAsync() => transaction.DisposeAsync(); + } + } +} diff --git a/tests/CfSharp.Native.AbiProbe/main.cpp b/tests/CfSharp.Native.AbiProbe/main.cpp index 0a8f263..f5fff64 100644 --- a/tests/CfSharp.Native.AbiProbe/main.cpp +++ b/tests/CfSharp.Native.AbiProbe/main.cpp @@ -25,6 +25,10 @@ int main() << " \"fileStandardInfoDeleteOffset\": " << offsetof(FILE_STANDARD_INFO, DeletePending) << ",\n" << " \"fileStandardInfoDirectoryOffset\": " << offsetof(FILE_STANDARD_INFO, Directory) << ",\n" << " \"fileAttributeTagInfoSize\": " << sizeof(FILE_ATTRIBUTE_TAG_INFO) << ",\n" + << " \"fileRenameInfoSize\": " << sizeof(FILE_RENAME_INFO) << ",\n" + << " \"fileRenameInfoRootOffset\": " << offsetof(FILE_RENAME_INFO, RootDirectory) << ",\n" + << " \"fileRenameInfoLengthOffset\": " << offsetof(FILE_RENAME_INFO, FileNameLength) << ",\n" + << " \"fileRenameInfoNameOffset\": " << offsetof(FILE_RENAME_INFO, FileName) << ",\n" << " \"readFileUsnDataControlCode\": " << FSCTL_READ_FILE_USN_DATA << ",\n" << " \"readFileUsnDataSize\": " << sizeof(READ_FILE_USN_DATA) << ",\n" << " \"readFileUsnDataMaxMajorVersionOffset\": " << offsetof(READ_FILE_USN_DATA, MaxMajorVersion) << ",\n" diff --git a/tests/CfSharp.Native.Tests/AbiProbeComparisonTests.cs b/tests/CfSharp.Native.Tests/AbiProbeComparisonTests.cs index 1358c5e..fd17652 100644 --- a/tests/CfSharp.Native.Tests/AbiProbeComparisonTests.cs +++ b/tests/CfSharp.Native.Tests/AbiProbeComparisonTests.cs @@ -44,6 +44,10 @@ public void ManagedLayoutsAndConstantsMatchNativeProbe() AssertOffset(probe, "fileStandardInfoDeleteOffset", "DeletePending"); AssertOffset(probe, "fileStandardInfoDirectoryOffset", "Directory"); AssertProbe(probe, "fileAttributeTagInfoSize", Marshal.SizeOf()); + AssertProbe(probe, "fileRenameInfoSize", Marshal.SizeOf()); + AssertOffset(probe, "fileRenameInfoRootOffset", "RootDirectory"); + AssertOffset(probe, "fileRenameInfoLengthOffset", "FileNameLength"); + AssertOffset(probe, "fileRenameInfoNameOffset", "FileName"); AssertProbe(probe, "readFileUsnDataSize", Marshal.SizeOf()); AssertOffset(probe, "readFileUsnDataMaxMajorVersionOffset", "MaxMajorVersion"); AssertProbe(probe, "usnRecordV2UsnOffset", WindowsFileUsn.Version2UsnOffset); diff --git a/tests/CfSharp.Native.Tests/WindowsFileMetadataTests.cs b/tests/CfSharp.Native.Tests/WindowsFileMetadataTests.cs index 6a53a6d..c9d0ad0 100644 --- a/tests/CfSharp.Native.Tests/WindowsFileMetadataTests.cs +++ b/tests/CfSharp.Native.Tests/WindowsFileMetadataTests.cs @@ -7,6 +7,73 @@ namespace CfSharp.Native.Tests; public sealed class WindowsFileMetadataTests { + [Fact] + public void DirectoryGuardPreventsAncestorReplacementUntilReleased() + { + string area = Path.Combine(Path.GetTempPath(), "CfSharp-parent-guard", Guid.NewGuid().ToString("N")); + string parent = Path.Combine(area, "parent"); + Directory.CreateDirectory(parent); + try + { + using (var handle = WindowsFileMetadata.OpenDirectoryGuard(parent)) + { + Assert.Throws(() => Directory.Move(parent, Path.Combine(area, "replaced"))); + Assert.True(Directory.Exists(parent)); + } + + Directory.Move(parent, Path.Combine(area, "replaced")); + } + finally + { + Directory.Delete(area, recursive: true); + } + } + + [Fact] + public unsafe void RenameInformationUsesTheNativePointerAlignmentAndUtf16TrailingField() + { + WindowsFileMetadata.FileRenameInformation value = default; + byte* start = (byte*)&value; + Assert.Equal(0, (byte*)&value.Flags - start); + Assert.Equal(IntPtr.Size, (byte*)&value.RootDirectory - start); + Assert.Equal(2 * IntPtr.Size, (byte*)&value.FileNameLength - start); + Assert.Equal((2 * IntPtr.Size) + 4, (byte*)&value.FileName - start); + MethodInfo method = typeof(WindowsFileMetadata).GetMethod("SetFileInformationByHandle", BindingFlags.NonPublic | BindingFlags.Static)!; + Assert.True(method.GetCustomAttribute()!.SetLastError); + Assert.Equal(new[] { typeof(CallConvStdcall) }, method.GetCustomAttribute()!.CallConvs); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void RenameUsesTheOpenedObjectAndNeverReplacesAnOccupant(bool caseOnly) + { + string area = Path.Combine(Path.GetTempPath(), "CfSharp-handle-rename", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(area); + string source = Path.Combine(area, "source.txt"); + string target = Path.Combine(area, caseOnly ? "SOURCE.txt" : "target.txt"); + File.WriteAllText(source, "original"); + try + { + using var handle = WindowsFileMetadata.OpenForRename(source); + Guid id = WindowsFileMetadata.ReadIdentity(handle.DangerousGetHandle()).FileId; + Assert.Throws(() => File.Move(source, Path.Combine(area, "competing.txt"))); + WindowsFileMetadata.Rename(handle.DangerousGetHandle(), target); + Assert.Equal(target, WindowsFileMetadata.ReadFinalPath(handle.DangerousGetHandle())); + Assert.Equal(id, WindowsFileMetadata.ReadIdentity(handle.DangerousGetHandle()).FileId); + string occupied = Path.Combine(area, "occupied.txt"); + File.WriteAllText(occupied, "retain"); + Assert.Throws(() => WindowsFileMetadata.Rename(handle.DangerousGetHandle(), occupied)); + handle.Dispose(); + Assert.Equal("retain", File.ReadAllText(occupied)); + Assert.Equal("original", File.ReadAllText(target)); + } + finally + { + Directory.Delete(area, recursive: true); + } + } + [Fact] public void FinalPathImportHasExplicitUtf16PointerAndWindowsCallingConvention() { diff --git a/tests/CfSharp.Storage.Sqlite.Tests/CloudLocalChangeScanFenceTests.cs b/tests/CfSharp.Storage.Sqlite.Tests/CloudLocalChangeScanFenceTests.cs index bafacc0..7bcdbd1 100644 --- a/tests/CfSharp.Storage.Sqlite.Tests/CloudLocalChangeScanFenceTests.cs +++ b/tests/CfSharp.Storage.Sqlite.Tests/CloudLocalChangeScanFenceTests.cs @@ -280,25 +280,48 @@ await source.EmitAsync(new(watcherError ? LocalChangeSourceAction.Error : LocalC } } - [Fact] - public async Task CreationIntentAndRetainedObservationFenceScansUntilExplicitRecovery() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RemoteIntentAndRetainedObservationFenceScansUntilExplicitRecovery(bool move) { await using ICloudStateStore store = await OpenStoreAsync(); await SeedChangesAsync(store, 2); - await using CloudLocalChangeFeed feed = CreateFeed(store, new FakeSource()); + FakeSource source = new(); + await using CloudLocalChangeFeed feed = CreateFeed(store, source); await feed.StartAsync(); CloudLocalChangeScan original = await feed.BeginScanAsync(); - string intentName = RemoteCreationIntent.Prefix + "/test"; - string observationName = RemoteCreationIntent.ObservationsPrefix + "/test"; + string intentName = move ? RemoteMoveIntent.Name("batch", 0) : RemoteCreationIntent.Prefix + "/test"; + string observationName = (move ? CloudLocalChangeFeed.NamespaceObservationsPrefix : RemoteCreationIntent.ObservationsPrefix) + "/test"; await using (ICloudStateTransaction write = await store.BeginTransactionAsync()) { RemoteCreationIntent intent = new("pending", CloudItemKind.Directory, new CloudPlaceholderIdentity(Guid.NewGuid(), "pending-remote"), new byte[32], false); - await write.Checkpoints.UpsertAsync(new CloudStateCheckpoint(intentName, intent.Encode(), DateTimeOffset.UtcNow)); + byte[] evidence = intent.Encode(); + if (move) + { + CloudLocalFileBinding binding = new(1, Guid.NewGuid(), Guid.NewGuid()); + CloudItemState originalItem = new(intent.Identity.ItemId, "pending-remote", "source.bin", + CloudItemKind.File, "v1", null, false, DateTimeOffset.UtcNow); + RemoteMoveObject item = new(originalItem.RelativePath, binding, intent.Identity.Encode(), 10, 20, 512); + RemoteMoveIntent preparation = new(Guid.NewGuid(), _syncRootPath, "batch", 0, new byte[32], "target.bin", + true, true, TimeSpan.FromMinutes(2).Ticks, 4, originalItem, item, [new(string.Empty, binding, [], 5, 6, 0)], + null, true, RemoteMoveIntent.FingerprintOperations([]), null); + evidence = preparation.Encode(); + } + + await write.Checkpoints.UpsertAsync(new CloudStateCheckpoint(intentName, evidence, DateTimeOffset.UtcNow)); await write.Checkpoints.UpsertAsync(new CloudStateCheckpoint(observationName, [1], DateTimeOffset.UtcNow)); await write.CommitAsync(); } + if (move) + { + await source.EmitAsync(new(LocalChangeSourceAction.Modified, "target.bin")); + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10)); + await WaitForLossPersistenceAsync(feed, timeout.Token); + } + Assert.True((await feed.BeginScanAsync()).RequiresFullRescan); Assert.True((await feed.ReadPageAsync(original, 0, 1)).RequiresFullRescan); await Assert.ThrowsAsync(async () => await feed.AcknowledgeFullRescanAsync()); @@ -308,7 +331,7 @@ public async Task CreationIntentAndRetainedObservationFenceScansUntilExplicitRec await recover.CommitAsync(); } - // Removing a completed creation intent does not implicitly accept its uncertain observations. + // Removing a completed intent does not implicitly accept its uncertain observations. Assert.True((await feed.ReadPageAsync(original, 0, 1)).RequiresFullRescan); await feed.AcknowledgeFullRescanAsync(); Assert.True((await feed.ReadPageAsync(original, 0, 1)).RequiresFullRescan); diff --git a/tests/CfSharp.Storage.Sqlite.Tests/SqliteCloudStateStoreTests.cs b/tests/CfSharp.Storage.Sqlite.Tests/SqliteCloudStateStoreTests.cs index 06e0c8b..ac65d87 100644 --- a/tests/CfSharp.Storage.Sqlite.Tests/SqliteCloudStateStoreTests.cs +++ b/tests/CfSharp.Storage.Sqlite.Tests/SqliteCloudStateStoreTests.cs @@ -375,7 +375,7 @@ version INTEGER NOT NULL } Assert.Equal( - 6L, + 7L, await ExecuteScalarInt64Async( _databasePath, "SELECT version FROM cfsharp_schema WHERE singleton = 1;")); @@ -387,7 +387,7 @@ await ExecuteScalarInt64Async( } [Fact] - public async Task VersionOneRemoteBatchSchemaMigratesToVersionSix() + public async Task VersionOneRemoteBatchSchemaMigratesToVersionSeven() { await using (ICloudStateStore store = await CreateFactory().OpenAsync(CreateContext())) { @@ -402,7 +402,7 @@ await ExecuteSqlAsync( } Assert.Equal( - 6L, + 7L, await ExecuteScalarInt64Async( _databasePath, "SELECT version FROM cfsharp_schema WHERE singleton = 1;")); @@ -415,7 +415,7 @@ await ExecuteScalarInt64Async( } [Fact] - public async Task VersionTwoEchoSuppressionSchemaMigratesToVersionSix() + public async Task VersionTwoEchoSuppressionSchemaMigratesToVersionSeven() { await using (ICloudStateStore store = await CreateFactory().OpenAsync(CreateContext())) { @@ -430,7 +430,7 @@ await ExecuteSqlAsync( } Assert.Equal( - 6L, + 7L, await ExecuteScalarInt64Async( _databasePath, "SELECT version FROM cfsharp_schema WHERE singleton = 1;")); @@ -459,12 +459,14 @@ public async Task VersionFourUpgradePreservesRecoveryMetadata() Assert.Equal(new byte[] { 7 }, (await transaction.Checkpoints.GetAsync("existing/checkpoint"))!.Value.ToArray()); } - Assert.Equal(6L, await ExecuteScalarInt64Async(_databasePath, + Assert.Equal(7L, await ExecuteScalarInt64Async(_databasePath, "SELECT version FROM cfsharp_schema WHERE singleton = 1;")); } - [Fact] - public async Task VersionFiveUpgradePreservesCoordinationRowsAndSequenceAllocation() + [Theory] + [InlineData(5)] + [InlineData(6)] + public async Task PreviousProtocolUpgradePreservesCoordinationRowsAndSequenceAllocation(int previousVersion) { Guid itemId = Guid.NewGuid(); Guid operationId = Guid.NewGuid(); @@ -488,7 +490,7 @@ await write.EchoSuppressions.UpsertAsync(new CloudEchoSuppressionState(suppressi await write.CommitAsync(); } - await ExecuteSqlAsync(_databasePath, "UPDATE cfsharp_schema SET version = 5 WHERE singleton = 1;"); + await ExecuteSqlAsync(_databasePath, $"UPDATE cfsharp_schema SET version = {previousVersion} WHERE singleton = 1;"); await using (ICloudStateStore reopened = await CreateFactory().OpenAsync(CreateContext())) await using (ICloudStateTransaction verify = await reopened.BeginTransactionAsync()) { @@ -522,12 +524,12 @@ await write.EchoSuppressions.UpsertAsync(new CloudEchoSuppressionState(suppressi await verify.CommitAsync(); } - Assert.Equal(6L, await ExecuteScalarInt64Async(_databasePath, + Assert.Equal(7L, await ExecuteScalarInt64Async(_databasePath, "SELECT version FROM cfsharp_schema WHERE singleton = 1;")); } [Fact] - public async Task VersionSixFenceSurvivesProcessExitBeforeApplicationCommit() + public async Task VersionSevenFenceSurvivesProcessExitBeforeApplicationCommit() { await using (ICloudStateStore store = await CreateFactory().OpenAsync(CreateContext())) await using (ICloudStateTransaction seed = await store.BeginTransactionAsync()) @@ -540,7 +542,7 @@ public async Task VersionSixFenceSurvivesProcessExitBeforeApplicationCommit() (int exitCode, _) = await RunCrashHarnessAsync("before-commit", _databasePath, _syncRootPath); Assert.NotEqual(0, exitCode); Assert.True(File.Exists(_databasePath + ".before-commit.started")); - Assert.Equal(6L, await ExecuteScalarInt64Async(_databasePath, + Assert.Equal(7L, await ExecuteScalarInt64Async(_databasePath, "SELECT version FROM cfsharp_schema WHERE singleton = 1;")); await using ICloudStateStore recovered = await CreateFactory().OpenAsync(CreateContext()); await using ICloudStateTransaction verify = await recovered.BeginTransactionAsync(); @@ -558,7 +560,7 @@ CREATE TABLE cfsharp_schema ( singleton INTEGER NOT NULL PRIMARY KEY CHECK (singleton = 1), version INTEGER NOT NULL ); - INSERT INTO cfsharp_schema(singleton, version) VALUES(1, 7); + INSERT INTO cfsharp_schema(singleton, version) VALUES(1, 8); """); SqliteCloudStateStoreException exception = await Assert.ThrowsAsync< diff --git a/tests/CfSharp.Tests/Persistence/RemoteMoveIntentTests.cs b/tests/CfSharp.Tests/Persistence/RemoteMoveIntentTests.cs new file mode 100644 index 0000000..9386e4f --- /dev/null +++ b/tests/CfSharp.Tests/Persistence/RemoteMoveIntentTests.cs @@ -0,0 +1,75 @@ +namespace CfSharp.Tests.Persistence; + +public sealed class RemoteMoveIntentTests +{ + [Fact] + public void PreparationRoundTripsOriginalIdentityAndProjectedBoundary() + { + (RemoteMoveIntent intent, CloudRemoteChangeBatch batch) = CreateIntent(); + RemoteMoveIntent recovered = RemoteMoveIntent.Decode(intent.Encode()); + Assert.Equal(intent.Encode(), recovered.Encode()); + Assert.True(recovered.Matches(batch, 0, CloudRemoteApplyOptions.Default)); + Assert.Equal("v1", recovered.Original.RemoteRevision); + Assert.Equal("native-v0", CloudPlaceholderIdentity.Decode(recovered.Object.Identity).RemoteRevision); + Assert.Equal(intent.Object.Binding, recovered.Object.Binding); + RemoteMoveIntent projected = recovered with { ProjectedAtTicks = DateTimeOffset.UtcNow.UtcTicks, ProjectedUsn = 456 }; + Assert.Equal(projected.Encode(), RemoteMoveIntent.Decode(projected.Encode()).Encode()); + } + + [Fact] + public void PreparationBindsBatchEntryAndPolicyButAllowsDifferentPerCallLimit() + { + (RemoteMoveIntent intent, CloudRemoteChangeBatch batch) = CreateIntent(); + Assert.False(intent.Matches(batch, 1, CloudRemoteApplyOptions.Default)); + Assert.False(intent.Matches(new("another-batch", batch.InitialCursor, batch.Changes, batch.FinalCursor), 0, CloudRemoteApplyOptions.Default)); + Assert.False(intent.Matches(batch, 0, new() { SuppressLocalEcho = false })); + Assert.False(intent.Matches(batch, 0, new() { PreserveUnsynchronizedLocalContent = false })); + Assert.False(intent.Matches(batch, 0, new() { EchoSuppressionLifetime = TimeSpan.FromMinutes(1) })); + Assert.False(intent.Matches(batch, 0, new() { EchoSuppressionObservationCount = 2 })); + Assert.True(intent.Matches(batch, 0, new() { MaximumEntries = 1 })); + } + + [Fact] + public void TruncatedFutureAndTrailingPreparationDataAreRejected() + { + (RemoteMoveIntent intent, _) = CreateIntent(); + byte[] value = intent.Encode(); + for (int length = 0; length < value.Length; length++) + { + Assert.Throws(() => RemoteMoveIntent.Decode(value.AsMemory(0, length))); + } + + byte[] trailing = [.. value, 0]; + Assert.Throws(() => RemoteMoveIntent.Decode(trailing)); + value[0] = 2; + Assert.Throws(() => RemoteMoveIntent.Decode(value)); + } + + [Fact] + public void LocalOperationFingerprintPreservesAcknowledgementIdentityAndDetectsChangedWork() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + Guid item = Guid.NewGuid(); + CloudOperationJournalEntry first = new(Guid.NewGuid(), CloudStateOperationKind.ContentUpdate, item, [1], now, sequence: 1); + CloudOperationJournalEntry second = new(Guid.NewGuid(), CloudStateOperationKind.MetadataUpdate, item, [2], now, sequence: 2); + Assert.Equal(RemoteMoveIntent.FingerprintOperations([first, second]), RemoteMoveIntent.FingerprintOperations([second, first])); + CloudOperationJournalEntry edited = new(first.OperationId, first.Kind, item, [3], now, sequence: 1); + Assert.NotEqual(RemoteMoveIntent.FingerprintOperations([first]), RemoteMoveIntent.FingerprintOperations([edited])); + Assert.NotEqual(RemoteMoveIntent.FingerprintOperations([first]), RemoteMoveIntent.FingerprintOperations([])); + } + + private static (RemoteMoveIntent, CloudRemoteChangeBatch) CreateIntent() + { + CloudPlaceholderIdentity identity = new(Guid.NewGuid(), "remote", "native-v0"); + CloudRemoteChange move = new("move", CloudRemoteChangeKind.Move, "remote", "v2", CloudItemKind.File, + "right/file.bin", identity.ItemId, previousRemoteRevision: "v1", previousRelativePath: "left/file.bin"); + CloudRemoteChangeBatch batch = new("batch", ReadOnlyMemory.Empty, [move], new byte[] { 1 }); + CloudLocalFileBinding binding = new(123, Guid.NewGuid(), Guid.NewGuid()); + CloudItemState original = new(identity.ItemId, "remote", move.PreviousRelativePath!, CloudItemKind.File, "v1", 23, false, DateTimeOffset.UtcNow); + RemoteMoveObject file = new(original.RelativePath, binding, identity.Encode(), 100, 200, 512); + RemoteMoveObject root = new(string.Empty, binding, [], 50, 60, 0); + return (new(Guid.NewGuid(), "C:\\root", batch.BatchId, 0, batch.Fingerprint.ToArray(), move.RelativePath, + true, true, TimeSpan.FromMinutes(2).Ticks, 4, original, file, [root], null, true, + RemoteMoveIntent.FingerprintOperations([]), null), batch); + } +}