diff --git a/README.md b/README.md index 8203cdc..0f14fc3 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,12 @@ without acknowledging a deferred head, and [verified directory move recovery](do that preserves subtree identities, current revisions, and pending journal operations. Applications retain their own dispatch policy and durable move intents. +[Protected local operations](docs/protected-local-operations.md) retain one original native object +across a short asynchronous callback, same-object local conversion and Access-only descriptor work. +They preserve unuploaded bytes and pending state without marking in sync or acknowledging changes. +The host owns original evidence, permission policy, verification and recovery; directory mode does +not freeze tree membership. + ## Quick start ### Requirements diff --git a/docs/_toc.yml b/docs/_toc.yml index 4daa4f3..d6c1614 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -12,6 +12,8 @@ href: sync-root-lifecycle.md - name: Placeholders and hydration href: placeholders.md + - name: Protected local operations + href: protected-local-operations.md - name: Local change feed href: local-change-feed.md - name: Remote change application diff --git a/docs/index.md b/docs/index.md index c46bcb6..df6ee3d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,6 +15,7 @@ working directly with `cfapi.h` and `CldApi.dll`. | Upload local changes | [Local change feed](local-change-feed.md) | | Apply remote metadata changes | [Remote change application](remote-change-application.md) | | Choose and protect durable state | [SQLite state](state-store-sqlite.md) | +| Initialize one local object's identity and permissions | [Protected local operations](protected-local-operations.md) | | Check OS and architecture support | [Platform support](platform-support.md) | | Diagnose a failed operation | [Troubleshooting](troubleshooting.md) | | Understand branches and releases | [Release and branch model](releasing.md) | diff --git a/docs/platform-support.md b/docs/platform-support.md index 9e8109f..79a32aa 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -16,6 +16,7 @@ capabilities use additional build or Cloud Files integration thresholds: | Placeholder management policy | Integration 784 or later | | Full-restart hydration and force-convert flags | Integration 1280 or later | | Hydration range information | Integration 1536 or later | +| Protected local callbacks and Access-only descriptors | Windows build 16299 or later; applicable native data/DACL access and verified same-object sharing | Callers should feature-detect optional capabilities and handle the reported unsupported result. @@ -36,3 +37,11 @@ scripts before treating a local build as a supported result. The core API is Windows-only. Desktop Shell behavior, MSIX/Desktop Bridge packaging, Explorer visual evidence, and host registration are validated separately from the library's managed tests. The host application remains responsible for registration and removal UX. + +Protected local file operations reject existing writers, writable mappings and aliases. Local +conversion or file DACL application requires the actual disallowed-placeholder-hardlink policy. +Ordinary-file inspection does not provide hardlink freezing. Cold metadata and DACL work reads no +source content. Directory metadata mode pins one namespace object while members remain free to +change; it provides no tree freeze. Cancellation and short budgets stop new work, then drain actual +callbacks before release. Native driver stalls and uncooperative callbacks can delay completion. +See [Protected local operations](protected-local-operations.md) for receipts and recovery. diff --git a/docs/protected-local-operations.md b/docs/protected-local-operations.md new file mode 100644 index 0000000..0154b46 --- /dev/null +++ b/docs/protected-local-operations.md @@ -0,0 +1,138 @@ +# Protected local operations + +`CloudItem.RunProtectedLocalOperationAsync` awaits short application work while CfSharp retains +facade path admission, official state-store ownership and the original Windows file object. +Capture and durably retain the original `CloudLocalFileBinding` before beginning an operation. +A replacement's newly captured binding does not establish ownership of the old object. + +```csharp +CloudItemSnapshot original = await file.InspectAsync(cancellationToken); +CloudLocalFileBinding binding = original.LocalBinding + ?? throw new NotSupportedException("Complete native IDs are required."); + +// The application durably records its intent and original binding before calling. +CloudProtectedLocalOperationResult receipt = await file.RunProtectedLocalOperationAsync( + new CloudProtectedLocalOperationRequest(binding), + async (scope, stop) => + { + CloudItemSnapshot current = await scope.InspectAsync(stop); + // Await only necessary local work. The original object stays protected across await. + await Task.Yield(); + current = await file.InspectAsync(stop); // Exact-item calls reuse this scope. + }, cancellationToken); +``` + +Exclusive file mode uses one library-owned Windows file object with read/write data and DACL +access and share-none. It rejects existing writers and writable mappings; competing data or +namespace opens receive native sharing failures. No content is read and no source provider is +called. This deliberately avoids composing a CFAPI exclusive oplock with a second DACL writer: +that writer can break the oplock and wait for the very reference its caller is retaining. +CfSharp neither borrows an automatically invalidatable opaque pointer nor exposes an owning handle +to application code. This API requires Windows 10 version 1709 or later and applicable native access. +It does not use the existing `CloudItemLease` as an outer wrapper around path-based conversion. + +`DirectoryMetadata` mode provides same-object metadata access and pins the directory name; +its members, descendant content and enumeration can change. Exclusive mode on a directory is +`Unsupported`. Neither mode provides whole-tree freezing. Ordinary files have no hardlink-freeze +claim: placeholder registration policy cannot protect ordinary-file aliases. + +For a permission initialization that requires placeholder hardlink exclusion, use +`CloudProtectedLocalOperationRequest.ForLocalConversion(binding, localIdentity)`. It checks the +actual root's disallowed-hardlink policy, rejects existing aliases, prepares the exact local +identity and commits its official projection before invoking the callback. The identity must not +contain an accepted remote revision. The native conversion uses the same exclusive file object, +preserving bytes, binding and DACL without marking in sync or dehydrating content. + +`scope.ConvertToPlaceholderAsync` provides the same preparation within a callback. An exact-item +`file.ConvertToPlaceholderAsync` call also reuses the scope, with only content-preserving defaults. +Existing placeholder identities must match exactly; this API cannot replace identity or rekey +known pending work. It preserves prior acknowledged revision and local ID fields, journal rows, +conflicts and checkpoints. No source content is read, including when inspecting a cold placeholder. + +The receipt distinguishes `NativeConverted`, `NativeIdentityPrepared` and +`DurableProjectionCommitted`, retaining the conversion HRESULT and USN when available. After +native success, required projection completes even if cancellation arrives. A failed projection +returns `NativeAppliedProjectionPending` and prevents a preparation-gated callback from starting. +Keep the original binding and identity, then retry a new request after recovery or restart. A +matching already prepared object needs no second conversion; a replacement is rejected. A +transaction-disposal error after commit retains the true committed fact and its original error. +If application code handles a scoped conversion's projection failure, later file DACL application +in that same scope is still rejected until the required projection has recovered. + +Inside the callback, `ReadAccessDescriptorAsync` returns an independent `FileSecurity` or +`DirectorySecurity`. `ApplyAccessDescriptorAsync` snapshots the input, applies only +`AccessControlSections.Access` on the original native file object, then returns a fresh readback. +Owner, group and audit sections are never written, even if supplied. Files require an actual +placeholder under disallowed hardlink policy before application; ordinary-file reads are allowed. +No original handle is exposed, attached to a stream or transferred to application code. Caller +descriptor edits must be synchronized while the input copy is taken. + +`AccessDescriptorApplied` and `AccessDescriptorReadBack` record separate actual facts. An exception +after application does not erase the application receipt or imply rollback. Persist the original +DACL before starting and independently compare the readback against the host's policy. Directory +inheritance may affect descendants; this scope does not freeze those descendants or their membership. +No role SIDs, permission policy, original DACL or credentials are stored in the official database. + +Use scope methods for protected work. Exact-item `InspectAsync` calls reuse the context. +Other nested facade work, another protected callback and owner disposal from the callback fail +immediately instead of waiting for the admission already held by that callback. Scope methods +serialize their native and state phases and are drained on callback exit, including admitted calls +the callback neglected to await. Escaped contexts reject new work after exit. The callback itself +never owns a SQLite transaction. + +Cancellation stops new scope work and requests cooperative callback termination. CfSharp awaits +the callback's actual completion and admitted scope work before releasing native or store resources. +Native mutation boundaries check original request and step cancellation signals directly, so +delayed linked-token propagation cannot start a mutation after cancellation has been requested. +An uncooperative callback or stalled native driver can delay return; there is no safe forced abort. +External owner disposal rejects new public work and drains admitted operations before closing the +state store. Keep callbacks short and local, without network, worker RPC or user interaction. + +Requests have a ten-second cooperative budget, including path admission. `WithBudget` returns an +immutable copy with a positive duration of at most one minute. Callback and context tokens combine +caller cancellation, owner shutdown and budget expiry. `scope.CurrentStage` and `scope.IsDraining` +are safe to observe from another thread: cancellation shows `Draining` while actual work retains +the native object, then `Released` after cleanup. Neither expiry nor cancellation forcibly aborts +arbitrary application work. The returned receipt separates cancellation and budget-expiry facts +from native mutation, durable commit and descriptor application. A delayed callback can return +after the budget without weakening protection. + +Application cancellation handlers run away from the owner's lifecycle lock and are also drained +before native release. A throwing handler is reported as `CancellationCallbackError`; it does not +throw through owner shutdown or erase the original work outcome. Such handlers must also be short +and must not wait for owner disposal. No additional transaction spans the callback or its handlers. + +An admitted scope-method failure remains in the receipt even if application code handles its +exception. Callback-origin errors remain the original exceptions, distinct from translated native +failures. The first failed scope phase is retained while other already admitted calls drain. + +The immutable receipt records the observed binding/snapshot, last work stage, callback progress, +error and elapsed lifetime. It does not declare independently verified permissions, whole-tree +readiness, remote acceptance, synchronization confirmation or journal ACK. The application owns +its intent, original DACL, roles, namespace fence, permission verification and recovery policy. + +## Host initialization example + +The compiled [sample helper](https://github.com/mirrorpulse/cfsharp/blob/develop/samples/CfSharp.SampleProvider/ProtectedLocalInitialization.cs) +uses only public APIs. It begins with an ordinary inspection request, captures the original DACL +under protection, awaits the host's durable-originals delegate, then converts and applies the +host's desired Access descriptor. This ordering records original evidence before any mutation. +Persist intent and complete binding before beginning the scope; serialize descriptor bytes rather +than retaining a mutable .NET object. Local evidence belongs outside the sync root and outside the +official CfSharp state schema. The host verification delegate and its recovery ledger remain +separate from the library receipt. Keep every delegate short and local. + +For an already prepared placeholder, a basic inspection request can perform Access-only work +without replacing identity, including placeholders whose identity carries an accepted revision. +Local conversion requests cannot introduce such a revision. A known ordinary item must retain +its official ItemId and RemoteId when selecting a local identity; do not invent a replacement ID. + +The isolated NuGet consumer exercises conversion, DACL read/apply/readback, cold metadata and root +directory mode through published package surfaces after trim and Native AOT compilation. Synthetic +Windows x64/ARM64 library evidence does not close installed/MSIX, non-administrator, whole-tree or +MirrorPulse product acceptance. Those checks remain the host application's responsibility. + +Direct native mutation and sync-root re-registration must participate in the host's coordination. +The scope protects normal conflicting file opens; it does not provide administrator resistance or +exclusive access to all metadata writers. Retain original evidence and inspect again before later +operations after release, including after Windows converts an edited placeholder back to a file. diff --git a/eng/api-baseline.json b/eng/api-baseline.json index 5e1e531..d4f9a0b 100644 --- a/eng/api-baseline.json +++ b/eng/api-baseline.json @@ -980,6 +980,7 @@ "method System.Threading.Tasks.ValueTask\u003CCfSharp.CloudPlaceholderMutationResult\u003E ConvertToPlaceholderAsync(CfSharp.CloudPlaceholderIdentity identity,CfSharp.CloudPlaceholderConversionOptions options,System.Threading.CancellationToken cancellationToken)", "method System.Threading.Tasks.ValueTask\u003CCfSharp.CloudPlaceholderMutationResult\u003E RevertToRegularItemAsync(System.Threading.CancellationToken cancellationToken)", "method System.Threading.Tasks.ValueTask\u003CCfSharp.CloudPlaceholderMutationResult\u003E UpdatePlaceholderAsync(CfSharp.CloudPlaceholderPatch patch,System.Threading.CancellationToken cancellationToken)", + "method System.Threading.Tasks.ValueTask\u003CCfSharp.CloudProtectedLocalOperationResult\u003E RunProtectedLocalOperationAsync(CfSharp.CloudProtectedLocalOperationRequest request,System.Func\u003CCfSharp.CloudProtectedLocalOperationContext,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask\u003E callback,System.Threading.CancellationToken cancellationToken)", "method System.Threading.Tasks.ValueTask\u003CCfSharp.CloudStateChangeResult\u003E SetInSyncAsync(System.Boolean inSync,CfSharp.CloudInSyncChangeOptions options,System.Threading.CancellationToken cancellationToken)", "method System.Threading.Tasks.ValueTask\u003CCfSharp.CloudStateChangeResult\u003E SetPinStateAsync(CfSharp.CloudPinTarget target,System.Threading.CancellationToken cancellationToken)", "method System.Threading.Tasks.ValueTask\u003CSystem.Int64\u003E ReadUsnAsync(System.Threading.CancellationToken cancellationToken)", @@ -1382,6 +1383,69 @@ "property CfSharp.CloudProgressTarget CurrentHydrationRequest()", "property System.Nullable\u003CCfSharp.CloudProviderRequestId\u003E RequestId()", "property System.UInt32 TargetSessionId()", + "type CfSharp.CloudProtectedLocalOperationContext", + "method System.Threading.Tasks.ValueTask\u003CCfSharp.CloudItemSnapshot\u003E InspectAsync(System.Threading.CancellationToken cancellationToken)", + "method System.Threading.Tasks.ValueTask\u003CCfSharp.CloudPlaceholderMutationResult\u003E ConvertToPlaceholderAsync(CfSharp.CloudPlaceholderIdentity identity,System.Threading.CancellationToken cancellationToken)", + "method System.Threading.Tasks.ValueTask\u003CSystem.Security.AccessControl.FileSystemSecurity\u003E ApplyAccessDescriptorAsync(System.Security.AccessControl.FileSystemSecurity descriptor,System.Threading.CancellationToken cancellationToken)", + "method System.Threading.Tasks.ValueTask\u003CSystem.Security.AccessControl.FileSystemSecurity\u003E ReadAccessDescriptorAsync(System.Threading.CancellationToken cancellationToken)", + "property CfSharp.CloudProtectedLocalOperationStage CurrentStage()", + "property System.Boolean IsDraining()", + "type CfSharp.CloudProtectedLocalOperationMode", + "field CfSharp.CloudProtectedLocalOperationMode DirectoryMetadata", + "field CfSharp.CloudProtectedLocalOperationMode ExclusiveFile", + "field System.Int32 value__", + "type CfSharp.CloudProtectedLocalOperationOutcome", + "field CfSharp.CloudProtectedLocalOperationOutcome Busy", + "field CfSharp.CloudProtectedLocalOperationOutcome CallbackFailed", + "field CfSharp.CloudProtectedLocalOperationOutcome Canceled", + "field CfSharp.CloudProtectedLocalOperationOutcome Completed", + "field CfSharp.CloudProtectedLocalOperationOutcome DeadlineExceeded", + "field CfSharp.CloudProtectedLocalOperationOutcome Failed", + "field CfSharp.CloudProtectedLocalOperationOutcome LocalObjectMismatch", + "field CfSharp.CloudProtectedLocalOperationOutcome NativeAppliedProjectionPending", + "field CfSharp.CloudProtectedLocalOperationOutcome NotApplicable", + "field CfSharp.CloudProtectedLocalOperationOutcome ProtectionLost", + "field CfSharp.CloudProtectedLocalOperationOutcome Unsupported", + "field System.Int32 value__", + "type CfSharp.CloudProtectedLocalOperationRequest", + "ctor CfSharp.CloudProtectedLocalOperationRequest(CfSharp.CloudLocalFileBinding expectedBinding,CfSharp.CloudProtectedLocalOperationMode mode)", + "method CfSharp.CloudProtectedLocalOperationRequest ForLocalConversion(CfSharp.CloudLocalFileBinding expectedBinding,CfSharp.CloudPlaceholderIdentity identity)", + "method CfSharp.CloudProtectedLocalOperationRequest WithBudget(System.TimeSpan budget)", + "property System.TimeSpan Budget()", + "property CfSharp.CloudLocalFileBinding ExpectedBinding()", + "property CfSharp.CloudProtectedLocalOperationMode Mode()", + "property CfSharp.CloudPlaceholderIdentity PreparationIdentity()", + "type CfSharp.CloudProtectedLocalOperationResult", + "property System.Boolean AccessDescriptorApplied()", + "property System.Boolean AccessDescriptorReadBack()", + "property System.Boolean BudgetExpired()", + "property System.Boolean CallbackCompleted()", + "property System.Boolean CallbackStarted()", + "property System.Exception CancellationCallbackError()", + "property System.Boolean CancellationRequested()", + "property System.Boolean Drained()", + "property System.Boolean DurableProjectionCommitted()", + "property System.TimeSpan Elapsed()", + "property System.Exception Error()", + "property CfSharp.CloudLocalFileBinding ExpectedBinding()", + "property System.Boolean NativeConverted()", + "property System.Boolean NativeIdentityPrepared()", + "property CfSharp.CloudProtectedLocalOperationOutcome Outcome()", + "property System.Nullable\u003CSystem.Int32\u003E PreparationHResult()", + "property System.Nullable\u003CSystem.Int64\u003E PreparationUsn()", + "property CfSharp.CloudItemSnapshot Snapshot()", + "property CfSharp.CloudProtectedLocalOperationStage Stage()", + "type CfSharp.CloudProtectedLocalOperationStage", + "field CfSharp.CloudProtectedLocalOperationStage AccessDescriptor", + "field CfSharp.CloudProtectedLocalOperationStage Admission", + "field CfSharp.CloudProtectedLocalOperationStage Callback", + "field CfSharp.CloudProtectedLocalOperationStage Draining", + "field CfSharp.CloudProtectedLocalOperationStage Inspection", + "field CfSharp.CloudProtectedLocalOperationStage NativePreparation", + "field CfSharp.CloudProtectedLocalOperationStage Open", + "field CfSharp.CloudProtectedLocalOperationStage Projection", + "field CfSharp.CloudProtectedLocalOperationStage Released", + "field System.Int32 value__", "type CfSharp.CloudProviderCompletionNotification", "property System.Nullable\u003CCfSharp.CloudCorrelationVector\u003E CorrelationVector()", "property System.ReadOnlyMemory\u003CSystem.Byte\u003E FileIdentity()", diff --git a/eng/docs/build-docs.ps1 b/eng/docs/build-docs.ps1 index efe7f71..9f4546c 100644 --- a/eng/docs/build-docs.ps1 +++ b/eng/docs/build-docs.ps1 @@ -92,6 +92,8 @@ try { href: articles/remote-change-application.md - name: SQLite state store href: articles/state-store-sqlite.md + - name: Protected local operations + href: articles/protected-local-operations.md - name: CfSharp Sample href: articles/sample-provider.md - name: Platform support diff --git a/eng/prepare-preview.ps1 b/eng/prepare-preview.ps1 index 16573fb..43d61df 100644 --- a/eng/prepare-preview.ps1 +++ b/eng/prepare-preview.ps1 @@ -182,6 +182,7 @@ $consumerNuGetConfig = Join-Path $smokeRoot 'NuGet.config' "@ | Set-Content -LiteralPath $consumerProject -Encoding utf8 @' using System.Runtime.Versioning; +using System.Security.AccessControl; using CfSharp; using CfSharp.Storage.Sqlite; @@ -247,12 +248,13 @@ internal static class Smoke bool registered = false; try { + EmptyProvider demand = new(); await using CloudFileSystem system = CloudFileSystem.CreateBuilder(root) .WithStateStore(new SqliteCloudStateStoreFactory(Path.Combine(area, "state.db"))) .WithRegistration(SyncRootRegistrationOptions.CreateBuilder("CfSharp Package Smoke", "1.0-test") .WithProviderId(provider).WithSyncRootIdentity(provider.ToByteArray()) .WithHydrationPolicy(CloudHydrationPolicy.Progressive).WithPopulationPolicy(CloudPopulationPolicy.Partial) - .WithRootMarkedInSync().Build()).WithContentProvider(new EmptyProvider()).Build(); + .WithRootMarkedInSync().Build()).WithContentProvider(demand).Build(); await system.StartAsync(); registered = true; await system.Root.CreatePlaceholderAsync(CloudDirectoryPlaceholderSpec.CreateBuilder("Docs", "directory") @@ -305,6 +307,8 @@ internal static class Smoke await Task.Delay(20, timeout.Token); } } + await feed.DisposeAsync(); + await VerifyProtectedLocalAsync(system, area, demand); } finally { @@ -317,10 +321,103 @@ internal static class Smoke } } + [SupportedOSPlatform("windows10.0.16299")] + private static async Task VerifyProtectedLocalAsync(CloudFileSystem system, string area, EmptyProvider demand) + { + CloudFile file = system.GetFile("protected-local.txt"); + await File.WriteAllTextAsync(file.FullPath, "unuploaded-local"); + CloudItemSnapshot original = await file.InspectAsync(); + CloudLocalFileBinding binding = original.LocalBinding ?? throw new InvalidOperationException("Complete native IDs are missing."); + byte[] originalAccess = new FileInfo(file.FullPath).GetAccessControl(AccessControlSections.Access).GetSecurityDescriptorBinaryForm(); + // Synthetic host evidence is durable and outside the managed root, never in the library store. + using (FileStream evidence = new(Path.Combine(area, "local-originals.bin"), FileMode.CreateNew, FileAccess.Write, FileShare.None)) + using (BinaryWriter write = new(evidence)) + { + write.Write(binding.VolumeSerialNumber); + write.Write(binding.SyncRootFileId.ToByteArray()); + write.Write(binding.LocalFileId.ToByteArray()); + write.Write(originalAccess.Length); + write.Write(originalAccess); + write.Flush(); + evidence.Flush(flushToDisk: true); + } + CloudProtectedLocalOperationResult receipt = await file.RunProtectedLocalOperationAsync( + CloudProtectedLocalOperationRequest.ForLocalConversion(binding, CloudPlaceholderIdentity.Create("local-unuploaded")), + async (scope, stop) => + { + FileSystemSecurity access = await scope.ReadAccessDescriptorAsync(stop); + FileSystemSecurity observed = await scope.ApplyAccessDescriptorAsync(access, stop); + if (!SameDacl(originalAccess, observed.GetSecurityDescriptorBinaryForm())) + { + throw new InvalidOperationException("Package same-object Access readback changed the original DACL."); + } + try + { + await File.WriteAllTextAsync(file.FullPath, "conflicting-write", stop); + throw new InvalidOperationException("Package exclusive scope admitted a competing overwrite."); + } + catch (IOException error) when ((error.HResult & 0xffff) == 32) { } + await Task.Yield(); + CloudItemSnapshot current = await file.InspectAsync(stop); + if (current.LocalBinding != binding || !current.IsPlaceholder || current.RemoteRevision is not null || + current.SynchronizationState == CloudSynchronizationState.InSync) + { + throw new InvalidOperationException("Package preparation lost the original unaccepted local object."); + } + }); + if (receipt.Outcome != CloudProtectedLocalOperationOutcome.Completed || !receipt.NativeConverted || + !receipt.DurableProjectionCommitted || !receipt.AccessDescriptorApplied || !receipt.AccessDescriptorReadBack || + !receipt.Drained || await File.ReadAllTextAsync(file.FullPath) != "unuploaded-local") + { + throw new InvalidOperationException("Package protected initialization failed.", receipt.Error); + } + await system.Root.CreatePlaceholderAsync(CloudFilePlaceholderSpec.CreateBuilder("protected-cold.bin", "cold-local", 4096).Build()); + CloudFile cold = system.GetFile("protected-cold.bin"); + CloudLocalFileBinding coldBinding = (await cold.InspectAsync()).LocalBinding!; + CloudProtectedLocalOperationResult coldReceipt = await cold.RunProtectedLocalOperationAsync(new(coldBinding), async (scope, stop) => + { + await scope.ApplyAccessDescriptorAsync(await scope.ReadAccessDescriptorAsync(stop), stop); + if ((await scope.InspectAsync(stop)).ContentAvailability != CloudContentAvailability.OnlineOnly) + { + throw new InvalidOperationException("Cold package metadata access hydrated content."); + } + }); + CloudDirectory directory = system.Root; + CloudLocalFileBinding rootBinding = (await directory.InspectAsync()).LocalBinding!; + CloudProtectedLocalOperationResult rootReceipt = await directory.RunProtectedLocalOperationAsync( + new(rootBinding, CloudProtectedLocalOperationMode.DirectoryMetadata), async (scope, stop) => + await scope.ApplyAccessDescriptorAsync(await scope.ReadAccessDescriptorAsync(stop), stop)); + if (coldReceipt.Outcome != CloudProtectedLocalOperationOutcome.Completed || rootReceipt.Outcome != CloudProtectedLocalOperationOutcome.Completed || demand.Fetches != 0) + { + throw new InvalidOperationException("Package cold/directory Access-only protection failed or read source content.", coldReceipt.Error ?? rootReceipt.Error); + } + Console.WriteLine("Protected local package smoke passed: same-object conversion, DACL, cold metadata, source reads=0."); + } + + private static bool SameDacl(byte[] expected, byte[] actual) + { + RawSecurityDescriptor before = new(expected, 0); + RawSecurityDescriptor after = new(actual, 0); + if ((before.ControlFlags & ControlFlags.DiscretionaryAclProtected) != (after.ControlFlags & ControlFlags.DiscretionaryAclProtected) || + before.DiscretionaryAcl is not { } first || after.DiscretionaryAcl is not { } second) + { + return false; + } + byte[] firstBytes = new byte[first.BinaryLength]; + byte[] secondBytes = new byte[second.BinaryLength]; + first.GetBinaryForm(firstBytes, 0); + second.GetBinaryForm(secondBytes, 0); + return firstBytes.AsSpan().SequenceEqual(secondBytes); + } + private sealed class EmptyProvider : ICloudFileContentProvider { - public ValueTask OpenReadAsync(CloudFileFetchRequest request, CancellationToken cancellationToken) => - ValueTask.FromResult(new MemoryStream()); + internal int Fetches { get; private set; } + public ValueTask OpenReadAsync(CloudFileFetchRequest request, CancellationToken cancellationToken) + { + Fetches++; + return ValueTask.FromResult(new MemoryStream()); + } } } '@ | Set-Content -LiteralPath $consumerProgram -Encoding utf8 @@ -484,6 +581,7 @@ $publicationChecks | x86 | Not supported | | Native component | CfSharp.Native exposes the complete Cloud Files ABI surface | | Durable state | CfSharp.Storage.Sqlite requires a caller-supplied database path outside the managed sync root | +| Protected local operations | Short same-object callbacks, local conversion and Access-only descriptors; files require native access and alias protection; directories provide metadata only | $(if ($isPreview) { 'This preview is not a stable 1.0.0 compatibility promise. API, native ABI, and behavior changes remain subject to the compatibility policy before stable release.' diff --git a/samples/CfSharp.SampleProvider/ProtectedLocalInitialization.cs b/samples/CfSharp.SampleProvider/ProtectedLocalInitialization.cs new file mode 100644 index 0000000..e80d96b --- /dev/null +++ b/samples/CfSharp.SampleProvider/ProtectedLocalInitialization.cs @@ -0,0 +1,44 @@ +using System.Runtime.Versioning; +using System.Security.AccessControl; + +using CfSharp; + +// A compiled public-API example for hosts that own durable originals and permission policy. +// It adds no sample CLI command or policy and performs no source/provider/network operation. +[SupportedOSPlatform("windows10.0.16299")] +internal static class ProtectedLocalInitialization +{ + internal static async ValueTask RunAsync( + CloudFile file, + CloudLocalFileBinding originalBinding, + CloudPlaceholderIdentity localIdentity, + Func persistOriginals, + Func createDesiredAccess, + Func> verifyAccess, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(file); + ArgumentNullException.ThrowIfNull(originalBinding); + ArgumentNullException.ThrowIfNull(localIdentity); + ArgumentNullException.ThrowIfNull(persistOriginals); + ArgumentNullException.ThrowIfNull(createDesiredAccess); + ArgumentNullException.ThrowIfNull(verifyAccess); + return await file.RunProtectedLocalOperationAsync(new(originalBinding), async (scope, stop) => + { + FileSecurity originalAccess = (FileSecurity)await scope.ReadAccessDescriptorAsync(stop); + // The host must durably serialize its intent, original complete binding and DACL + // outside the sync root before this delegate returns. Never retain only a reference + // to the mutable descriptor or put original ACL/policy data in the CfSharp store. + await persistOriginals(originalBinding, originalAccess, stop); + await scope.ConvertToPlaceholderAsync(localIdentity, stop); + FileSecurity desired = createDesiredAccess(originalAccess); + FileSecurity readback = (FileSecurity)await scope.ApplyAccessDescriptorAsync(desired, stop); + if (!await verifyAccess(readback, stop)) + { + throw new InvalidOperationException("Host permission verification failed; retain originals for recovery."); + } + // This receipt proves no tree readiness or remote acceptance. The host's separate + // verification and recovery ledger decides what may happen after scope release. + }, cancellationToken); + } +} diff --git a/src/CfSharp.Native/Internal/WindowsFileMetadata.cs b/src/CfSharp.Native/Internal/WindowsFileMetadata.cs index 1bb87ad..876405d 100644 --- a/src/CfSharp.Native/Internal/WindowsFileMetadata.cs +++ b/src/CfSharp.Native/Internal/WindowsFileMetadata.cs @@ -25,6 +25,40 @@ internal static SafeFileHandle Open(string path, bool preventDelete = false) return handle; } + internal static SafeFileHandle OpenLocalAccess(string path, bool directory) + { + // A single file object owns both data exclusion and security access. A separate + // WRITE_DAC opener can break a CFAPI oplock and wait for our own referenced owner. + // READ/WRITE_DATA with share-none rejects existing writers and writable sections; + // FILE_LIST_DIRECTORY makes no-delete directory sharing effective on Windows, + // unlike an attribute/security-only open. It performs no enumeration or hydration. + // Directory members remain free to change. + // OPEN_REPARSE_POINT prevents a final symbolic link from redirecting this access. + SafeFileHandle handle = CreateFile(path, directory ? 0x60081u : 0x60083u, + directory ? 3u : 0u, 0, 3, 0x02200000, 0); + if (handle.IsInvalid) + { + int error = Marshal.GetLastPInvokeError(); + handle.Dispose(); + throw new Win32Exception(error); + } + return handle; + } + + internal static FileBasicInfo ReadBasic(nint handle) + { + FileBasicInfo value; + Query(handle, 0, &value, (uint)sizeof(FileBasicInfo)); + return value; + } + + internal static long ReadLength(nint handle) + { + FileStandardInfo value; + Query(handle, 1, &value, (uint)sizeof(FileStandardInfo)); + return value.EndOfFile; + } + internal static FileIdentity ReadIdentity(nint handle) { FileIdentity value; @@ -142,6 +176,16 @@ internal struct FileIdentity internal Guid FileId; } + [StructLayout(LayoutKind.Sequential)] + internal struct FileBasicInfo + { + internal long CreationTime; + internal long LastAccessTime; + internal long LastWriteTime; + internal long ChangeTime; + internal uint Attributes; + } + [StructLayout(LayoutKind.Sequential)] internal struct FileStandardInfo { diff --git a/src/CfSharp/FileSystem/CloudFileSystem.ItemMutations.cs b/src/CfSharp/FileSystem/CloudFileSystem.ItemMutations.cs index aaab72a..b5ff3a7 100644 --- a/src/CfSharp/FileSystem/CloudFileSystem.ItemMutations.cs +++ b/src/CfSharp/FileSystem/CloudFileSystem.ItemMutations.cs @@ -8,6 +8,10 @@ internal async ValueTask ConvertToPlaceholderAsy CloudPlaceholderConversionOptions options, CancellationToken cancellationToken) { + if (s_protectedLocalContext.Value is { } protectedContext) + { + return await protectedContext.ConvertItemAsync(item, identity, options, cancellationToken).ConfigureAwait(false); + } ValidateConversionOptions(item, options); using CloudFileSystemOperationLease operation = await AcquireOperationAsync( [CloudItemOperationScope.Exact(item.FullPath)], @@ -223,7 +227,9 @@ private static async ValueTask PersistIdentityAsync( CloudPlaceholderIdentity identity, CancellationToken cancellationToken, Action? validateBeforeCommit = null, - Action? onCommitted = null) + Action? onCommitted = null, + bool preserveExistingState = false, + long? observedLocalFileId = null) { await using ICloudStateTransaction transaction = await stateStore .BeginTransactionAsync(cancellationToken) @@ -231,6 +237,10 @@ private static async ValueTask PersistIdentityAsync( CloudItemState? existing = await transaction.Items .GetByRelativePathAsync(item.RelativePath, cancellationToken) .ConfigureAwait(false); + if (preserveExistingState) + { + await ValidateProtectedLocalIdentityAsync(transaction, item, identity, cancellationToken).ConfigureAwait(false); + } if (existing is not null && existing.ItemId != identity.ItemId) { await transaction.Items.RemoveAsync(existing.ItemId, cancellationToken) @@ -243,8 +253,8 @@ await transaction.Items.UpsertAsync( identity.RemoteId, item.RelativePath, item.Kind, - identity.RemoteRevision, - localFileId: null, + preserveExistingState ? existing?.RemoteRevision : identity.RemoteRevision, + localFileId: preserveExistingState ? existing?.LocalFileId ?? observedLocalFileId : null, isTombstone: false, DateTimeOffset.UtcNow), cancellationToken).ConfigureAwait(false); diff --git a/src/CfSharp/FileSystem/CloudFileSystem.ProtectedLocalOperations.cs b/src/CfSharp/FileSystem/CloudFileSystem.ProtectedLocalOperations.cs new file mode 100644 index 0000000..bb150de --- /dev/null +++ b/src/CfSharp/FileSystem/CloudFileSystem.ProtectedLocalOperations.cs @@ -0,0 +1,124 @@ +using System.ComponentModel; +using System.Diagnostics; +using CfSharp.Native; +using Microsoft.Win32.SafeHandles; + +namespace CfSharp; + +public sealed partial class CloudFileSystem +{ + private static readonly AsyncLocal s_protectedLocalContext = new(); + private readonly CancellationToken _protectedLocalStopping; + + internal async ValueTask RunProtectedLocalOperationAsync( + CloudItem item, CloudProtectedLocalOperationRequest request, + Func callback, CancellationToken token) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(callback); + RejectProtectedLocalReentry(); + EnsureStarted(); + long started = Stopwatch.GetTimestamp(); + using CloudProtectedLocalOperationLifetime lifetime = new(request, started, token, _protectedLocalStopping); + CancellationToken stop = lifetime.Token; + CloudProtectedLocalOperationStage stage = CloudProtectedLocalOperationStage.Admission; + CloudProtectedLocalOperationContext? context = null; + bool invoked = false; + bool completed = false; + CloudProtectedLocalOperationOutcome outcome = CloudProtectedLocalOperationOutcome.Completed; + Exception? failure = null; + try + { + using CloudFileSystemOperationLease operation = await AcquireOperationAsync( + [CloudItemOperationScope.Exact(item.FullPath)], stop).ConfigureAwait(false); + bool directory = item.Kind == CloudItemKind.Directory; + if (directory != (request.Mode == CloudProtectedLocalOperationMode.DirectoryMetadata)) + { + throw new CloudProtectedLocalRejectedException(CloudProtectedLocalOperationOutcome.Unsupported, + "Use exclusive mode for one file or metadata-only mode for one directory; tree freezing is unavailable."); + } + lifetime.ThrowIfCancellationRequested(); + stage = CloudProtectedLocalOperationStage.Open; + using SafeFileHandle handle = CloudLocalAccessObject.Open(item.FullPath, directory); + context = new(this, item, operation.StateStore, handle, request, lifetime); + try + { + await context.InspectAsync(stop).ConfigureAwait(false); + if (request.PreparationIdentity is { } identity) + { + await context.ConvertToPlaceholderAsync(identity, stop).ConfigureAwait(false); + } + lifetime.ThrowIfCancellationRequested(); + // Invoke application work away from the caller's synchronization context so + // external synchronous disposal can drain without blocking its continuation. + await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + lifetime.ThrowIfCancellationRequested(); + s_protectedLocalContext.Value = context; + try + { + context.SetStage(CloudProtectedLocalOperationStage.Callback); + invoked = true; + await callback(context, stop).ConfigureAwait(false); + completed = true; + } + finally + { + s_protectedLocalContext.Value = null; + } + } + finally + { + await context.CloseAsync().ConfigureAwait(false); + await lifetime.SealAndDrainCancellationAsync().ConfigureAwait(false); + } + if (context.Failure is not null) + { + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw(context.Failure); + } + lifetime.ThrowIfCancellationRequested(); + } + catch (Exception error) + { + bool callbackFault = invoked && !completed && !ReferenceEquals(error, context?.Failure); + stage = callbackFault ? CloudProtectedLocalOperationStage.Callback : ReferenceEquals(error, context?.Failure) + ? context!.FailureStage ?? context.Stage : context?.Stage ?? stage; + failure = callbackFault ? error : TranslateProtectedLocalError(error, item.FullPath, stage); + outcome = error switch + { + _ when context is { NativeIdentityPrepared: true, DurableProjectionCommitted: false } => CloudProtectedLocalOperationOutcome.NativeAppliedProjectionPending, + CloudProtectedLocalRejectedException rejected => rejected.Outcome, + OperationCanceledException when !callbackFault || lifetime.IsCancellationRequested => lifetime.TimedOut + ? CloudProtectedLocalOperationOutcome.DeadlineExceeded : CloudProtectedLocalOperationOutcome.Canceled, + _ when !callbackFault && stage == CloudProtectedLocalOperationStage.Open && failure is CloudFilesException { Win32ErrorCode: 32 or 33 } => CloudProtectedLocalOperationOutcome.Busy, + _ when !callbackFault && failure is CloudFilesException { Win32ErrorCode: 6 } => CloudProtectedLocalOperationOutcome.ProtectionLost, + _ when invoked && !completed => CloudProtectedLocalOperationOutcome.CallbackFailed, + _ => CloudProtectedLocalOperationOutcome.Failed, + }; + if (outcome == CloudProtectedLocalOperationOutcome.DeadlineExceeded) + { + failure = new TimeoutException("The protected local operation budget expired; admitted work has drained.", failure); + } + } + await lifetime.SealAndDrainCancellationAsync().ConfigureAwait(false); + context?.MarkResourcesReleased(); + return new(outcome, failure is not null ? stage : context?.Stage ?? stage, request.ExpectedBinding, context?.LastSnapshot, + invoked, completed, Stopwatch.GetElapsedTime(started), failure, context, lifetime); + } + + private static void RejectProtectedLocalReentry() + { + if (s_protectedLocalContext.Value is not null) + { + throw new InvalidOperationException("Use the protected context for its original object; nested facade work and owner disposal would wait for their own admission."); + } + } + + internal static Exception TranslateProtectedLocalError(Exception error, string path, CloudProtectedLocalOperationStage stage) => + error switch + { + Win32Exception native => CloudFilesException.FromHResult("CloudItem.ProtectedLocal." + stage, path, + unchecked((int)(0x80070000u | (uint)native.NativeErrorCode))), + NativeFileException native => CloudFilesException.FromHResult(native.Operation, path, native.HResult), + _ => error, + }; +} diff --git a/src/CfSharp/FileSystem/CloudFileSystem.ProtectedLocalProjection.cs b/src/CfSharp/FileSystem/CloudFileSystem.ProtectedLocalProjection.cs new file mode 100644 index 0000000..8cecd7a --- /dev/null +++ b/src/CfSharp/FileSystem/CloudFileSystem.ProtectedLocalProjection.cs @@ -0,0 +1,34 @@ +namespace CfSharp; + +public sealed partial class CloudFileSystem +{ + internal static async ValueTask CheckProtectedLocalIdentityAsync(ICloudStateStore store, CloudItem item, + CloudPlaceholderIdentity identity, CancellationToken token) + { + await using ICloudStateTransaction transaction = await store.BeginTransactionAsync(token).ConfigureAwait(false); + await ValidateProtectedLocalIdentityAsync(transaction, item, identity, token).ConfigureAwait(false); + } + + internal static ValueTask PersistProtectedLocalIdentityAsync(ICloudStateStore store, CloudItem item, + CloudPlaceholderIdentity identity, long? localFileId, Action validate, Action committed) => + PersistIdentityAsync(store, item, identity, CancellationToken.None, validate, committed, + preserveExistingState: true, observedLocalFileId: localFileId); + + private static async ValueTask ValidateProtectedLocalIdentityAsync(ICloudStateTransaction transaction, CloudItem item, + CloudPlaceholderIdentity identity, CancellationToken token) + { + CloudItemState? byPath = await transaction.Items.GetByRelativePathAsync(item.RelativePath, token).ConfigureAwait(false); + CloudItemState? byId = await transaction.Items.GetByItemIdAsync(identity.ItemId, token).ConfigureAwait(false); + CloudItemState? byRemote = await transaction.Items.GetByRemoteIdAsync(identity.RemoteId, token).ConfigureAwait(false); + if (byPath is not null && (byPath.ItemId != identity.ItemId || byPath.RemoteId != identity.RemoteId || + byPath.Kind != item.Kind || byPath.IsTombstone) || + byId is not null && !string.Equals(byId.RelativePath, item.RelativePath, StringComparison.OrdinalIgnoreCase) || + byRemote is not null && byRemote.ItemId != identity.ItemId) + { + // A local initializer cannot rekey pending journal/conflict references or adopt + // another official item. Preserve the original row and require explicit recovery. + throw new CloudProtectedLocalRejectedException(CloudProtectedLocalOperationOutcome.NotApplicable, + "Local preparation must preserve any known official item identity, path and pending state."); + } + } +} diff --git a/src/CfSharp/FileSystem/CloudFileSystem.cs b/src/CfSharp/FileSystem/CloudFileSystem.cs index df77f8c..2b4f2fb 100644 --- a/src/CfSharp/FileSystem/CloudFileSystem.cs +++ b/src/CfSharp/FileSystem/CloudFileSystem.cs @@ -66,6 +66,7 @@ private CloudFileSystem( _registration = registration; _contentProvider = contentProvider; _runtime = runtime; + _protectedLocalStopping = _contentConfirmationStopping.Token; } /// Gets the normalized absolute path of the managed sync root. @@ -339,6 +340,7 @@ public void Dispose() /// public async ValueTask DisposeAsync() { + RejectProtectedLocalReentry(); // Serialize disposal attempts independently of admission. The feed must acquire the // lifecycle gate while draining accepted renames, including after public admission stops. await _disposalGate.WaitAsync().ConfigureAwait(false); @@ -399,6 +401,10 @@ internal async ValueTask InspectAsync( CloudItem item, CancellationToken cancellationToken) { + if (s_protectedLocalContext.Value is { } protectedContext) + { + return await protectedContext.InspectItemAsync(item, cancellationToken).ConfigureAwait(false); + } using CloudFileSystemOperationLease operation = await AcquireOperationAsync( [CloudItemOperationScope.Exact(item.FullPath)], cancellationToken).ConfigureAwait(false); @@ -419,6 +425,7 @@ private async ValueTask AcquireOperationCoreAsync bool allowLocalChangeDrain = false, CancellationToken cancellationToken = default) { + RejectProtectedLocalReentry(); ArgumentNullException.ThrowIfNull(scopes); IReadOnlyList requestedScopes = scopes.ToArray(); ICloudStateStore stateStore; @@ -525,7 +532,11 @@ internal static async ValueTask InspectCoreAsync( $"{item.Kind.ToString().ToLowerInvariant()}."); } - return new CloudItemSnapshot( + return CreateSnapshot(item, local, durableState); + } + + internal static CloudItemSnapshot CreateSnapshot(CloudItem item, LocalCloudItemInspection local, CloudItemState? durableState) => + new( item.Kind, local.Exists, local.Attributes, @@ -547,7 +558,6 @@ internal static async ValueTask InspectCoreAsync( durableState, DateTimeOffset.UtcNow, local.LocalBinding); - } private void EnsureStarted() { diff --git a/src/CfSharp/Items/CloudItemInspector.cs b/src/CfSharp/Items/CloudItemInspector.cs index 5ebecb6..a60eb42 100644 --- a/src/CfSharp/Items/CloudItemInspector.cs +++ b/src/CfSharp/Items/CloudItemInspector.cs @@ -103,6 +103,14 @@ internal static unsafe LocalCloudItemInspection Inspect( Marshal.GetHRForLastWin32Error()); } + return Inspect(handle, path, expectedKind, syncRootPath); + } + + // The caller retains the same owned file object throughout this synchronous query. + // No pathname stat or reopen is allowed on a protected local operation's item. + internal static unsafe LocalCloudItemInspection Inspect( + SafeFileHandle handle, string path, CloudItemKind expectedKind, string? syncRootPath = null) + { FileAttributeTagInfo attributeTagInfo; if (!GetFileInformationByHandleEx( handle, @@ -139,11 +147,8 @@ internal static unsafe LocalCloudItemInspection Inspect( throw CloudFilesException.FromHResult("CloudItem.Inspect.State", path, hresult); } - FileSystemInfo fileSystemInfo = actualKind == CloudItemKind.Directory - ? new DirectoryInfo(path) - : new FileInfo(path); - fileSystemInfo.Refresh(); - long? length = actualKind == CloudItemKind.File ? ((FileInfo)fileSystemInfo).Length : null; + WindowsFileMetadata.FileBasicInfo basic = WindowsFileMetadata.ReadBasic(handle.DangerousGetHandle()); + long? length = actualKind == CloudItemKind.File ? WindowsFileMetadata.ReadLength(handle.DangerousGetHandle()) : null; PlaceholderInformation? placeholder = nativeState.HasFlag(CfPlaceholderState.Placeholder) ? ReadPlaceholderInformation(handle, path) : null; @@ -154,9 +159,9 @@ internal static unsafe LocalCloudItemInspection Inspect( Kind = actualKind, Attributes = attributes, Length = length, - CreationTime = ToDateTimeOffset(fileSystemInfo.CreationTimeUtc), - LastWriteTime = ToDateTimeOffset(fileSystemInfo.LastWriteTimeUtc), - LastAccessTime = ToDateTimeOffset(fileSystemInfo.LastAccessTimeUtc), + CreationTime = ToDateTimeOffset(DateTime.FromFileTimeUtc(basic.CreationTime)), + LastWriteTime = ToDateTimeOffset(DateTime.FromFileTimeUtc(basic.LastWriteTime)), + LastAccessTime = ToDateTimeOffset(DateTime.FromFileTimeUtc(basic.LastAccessTime)), PlaceholderState = placeholderState, ContentAvailability = GetContentAvailability( actualKind, diff --git a/src/CfSharp/Items/CloudLocalAccessObject.cs b/src/CfSharp/Items/CloudLocalAccessObject.cs new file mode 100644 index 0000000..82a51f1 --- /dev/null +++ b/src/CfSharp/Items/CloudLocalAccessObject.cs @@ -0,0 +1,10 @@ +using CfSharp.Native; +using Microsoft.Win32.SafeHandles; + +namespace CfSharp; + +internal static class CloudLocalAccessObject +{ + internal static SafeFileHandle Open(string path, bool directory = false) => + WindowsFileMetadata.OpenLocalAccess(path, directory); +} diff --git a/src/CfSharp/Items/CloudProtectedLocalAccess.cs b/src/CfSharp/Items/CloudProtectedLocalAccess.cs new file mode 100644 index 0000000..0889d61 --- /dev/null +++ b/src/CfSharp/Items/CloudProtectedLocalAccess.cs @@ -0,0 +1,130 @@ +using System.Security.AccessControl; +using System.Security.Principal; + +using CfSharp.Native; +using Microsoft.Win32.SafeHandles; + +namespace CfSharp; + +public sealed partial class CloudProtectedLocalOperationContext +{ + internal bool AccessDescriptorApplied { get; private set; } + internal bool AccessDescriptorReadBack { get; private set; } + + /// Reads the original protected object's DACL into an independent .NET descriptor. + /// Token observed before this serialized scope step. + /// A FileSecurity or DirectorySecurity matching the object, containing only Access sections. + /// + /// Reads metadata through the library-owned native file object without hydration, path reopening + /// or source access. No owner, group or SACL is requested. The returned managed copy may outlive + /// the scope but does not retain its protection. Ordinary files can be read without conversion; + /// writing their DACL requires local placeholder preparation to exclude new aliases. + /// + /// The scope has exited. + /// Scope or step cancellation was requested. + /// Windows denies descriptor access. + /// The protected object or its native metadata cannot be verified. + public ValueTask ReadAccessDescriptorAsync(CancellationToken cancellationToken = default) => + ExecuteAsync(token => + { + token.ThrowIfCancellationRequested(); + SetStage(CloudProtectedLocalOperationStage.AccessDescriptor); + ValidateObject(); + FileSystemSecurity descriptor = ReadAccessCore(); + ValidateObject(); + return ValueTask.FromResult(descriptor); + }, cancellationToken); + + /// Applies only the supplied DACL to the original object and returns a fresh native readback. + /// FileSecurity or DirectorySecurity matching the object; only Access is applied. + /// Checked before applying the DACL; readback after application is not interrupted. + /// An independent Access-only managed descriptor read from the same native object after application. + /// + /// Copies the input at admission so later caller changes do not affect the queued application. + /// The caller must synchronize concurrent input edits while this copy is taken. Files must be + /// placeholders under the actual disallowed-hardlink root policy, with no existing aliases. + /// Use a preparation request to convert an ordinary file before callback entry. A failed + /// required identity projection must recover before any later file DACL application. + /// Directory mode provides metadata access only: Windows inheritance may affect descendants; membership and + /// their data are not frozen. No owner, group or SACL is written. The receipt records application + /// separately from readback; a readback is not independent permission verification or product + /// readiness. The application owns original DACL evidence, policy, verification and recovery. + /// + /// The descriptor is null. + /// The descriptor kind does not match the protected object. + /// The scope has exited. + /// Cancellation occurred before application. + /// Windows denies DACL application or readback. + /// The object lacks required protection or native verification fails. + public ValueTask ApplyAccessDescriptorAsync(FileSystemSecurity descriptor, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(descriptor); + bool directory = _item.Kind == CloudItemKind.Directory; + if (directory ? descriptor is not DirectorySecurity : descriptor is not FileSecurity) + { + throw new ArgumentException("The Access descriptor must match the protected object's file-system kind.", nameof(descriptor)); + } + byte[] copy = descriptor.GetSecurityDescriptorBinaryForm(); + return ExecuteAsync(token => + { + SetStage(CloudProtectedLocalOperationStage.AccessDescriptor); + NativeFileMetadata before = ValidateObject(); + if (!directory) + { + if ((NativeConverted || NativeIdentityPrepared) && !DurableProjectionCommitted) + { + throw new CloudProtectedLocalRejectedException(CloudProtectedLocalOperationOutcome.NativeAppliedProjectionPending, + "Recover the required official identity projection before applying file permissions."); + } + RequireHardlinkPolicy(); + if (!before.PlaceholderState.HasFlag(CfPlaceholderState.Placeholder)) + { + throw new CloudProtectedLocalRejectedException(CloudProtectedLocalOperationOutcome.Unsupported, + "File DACL application requires same-object local placeholder preparation before permissions change."); + } + } + LocalAccessDescriptor access = new(_handle, directory); + access.SetSecurityDescriptorBinaryForm(copy, AccessControlSections.Access); + ThrowIfCancellationRequested(cancellationToken); + token.ThrowIfCancellationRequested(); + access.Apply(_handle); + AccessDescriptorApplied = true; + // Once application succeeded, preserve actual readback facts even if cancellation + // arrives. Never release this native object while this admitted step is using it. + ValidateObject(); + FileSystemSecurity observed = ReadAccessCore(); + AccessDescriptorReadBack = true; + ValidateObject(); + return ValueTask.FromResult(observed); + }, cancellationToken); + } + + private FileSystemSecurity ReadAccessCore() + { + bool directory = _item.Kind == CloudItemKind.Directory; + LocalAccessDescriptor native = new(_handle, directory); + FileSystemSecurity copy = directory ? new DirectorySecurity() : new FileSecurity(); + // Expose the BCL descriptor, preserving inherited ACE facts through binary copying. + // The private NativeObjectSecurity adapter never exposes rule-factory reconstructions. + copy.SetSecurityDescriptorBinaryForm(native.GetSecurityDescriptorBinaryForm(), AccessControlSections.Access); + return copy; + } + + private sealed class LocalAccessDescriptor : NativeObjectSecurity + { + internal LocalAccessDescriptor(SafeFileHandle handle, bool directory) + : base(directory, ResourceType.FileObject, handle, AccessControlSections.Access) { } + + internal void Apply(SafeFileHandle handle) => Persist(handle, AccessControlSections.Access); + public override Type AccessRightType => typeof(FileSystemRights); + public override Type AccessRuleType => typeof(FileSystemAccessRule); + public override Type AuditRuleType => typeof(FileSystemAuditRule); + public override AccessRule AccessRuleFactory(IdentityReference identityReference, int accessMask, bool isInherited, + InheritanceFlags inheritanceFlags, PropagationFlags propagationFlags, AccessControlType type) => + new FileSystemAccessRule(identityReference, (FileSystemRights)accessMask, inheritanceFlags, propagationFlags, type); + public override AuditRule AuditRuleFactory(IdentityReference identityReference, int accessMask, bool isInherited, + InheritanceFlags inheritanceFlags, PropagationFlags propagationFlags, AuditFlags flags) => + new FileSystemAuditRule(identityReference, (FileSystemRights)accessMask, inheritanceFlags, propagationFlags, flags); + } +} diff --git a/src/CfSharp/Items/CloudProtectedLocalConversion.cs b/src/CfSharp/Items/CloudProtectedLocalConversion.cs new file mode 100644 index 0000000..fa9faaf --- /dev/null +++ b/src/CfSharp/Items/CloudProtectedLocalConversion.cs @@ -0,0 +1,157 @@ +using CfSharp.Native; + +namespace CfSharp; + +public sealed partial class CloudProtectedLocalOperationRequest +{ + /// Requests local identity preparation and official projection before invoking the callback. + /// Original binding recorded before preparation. + /// Local identity without an accepted remote revision; must match any known official item identity. + /// An exclusive file request that prepares or recovers the exact native identity before callback entry. + /// + /// Requires the actual root to disallow placeholder hardlinks. Existing aliases are rejected + /// before conversion or callback. Bytes and DACL are preserved; no mark, dehydration, ACK or + /// accepted revision is manufactured. Retry uses this same binding and exact identity after + /// native success with failed projection; a replacement cannot inherit the preparation. + /// + /// A required argument is null. + /// The identity contains an accepted remote revision. + public static CloudProtectedLocalOperationRequest ForLocalConversion(CloudLocalFileBinding expectedBinding, + CloudPlaceholderIdentity identity) + { + ArgumentNullException.ThrowIfNull(identity); + if (identity.RemoteRevision is not null) + { + throw new ArgumentException("Local preparation cannot introduce an accepted remote revision.", nameof(identity)); + } + return new(expectedBinding) { PreparationIdentity = identity }; + } + + /// Gets local preparation required before the callback, or null for inspection-only admission. + public CloudPlaceholderIdentity? PreparationIdentity { get; private init; } +} + +public sealed partial class CloudProtectedLocalOperationContext +{ + internal bool NativeIdentityPrepared { get; private set; } + internal bool NativeConverted { get; private set; } + internal bool DurableProjectionCommitted { get; private set; } + internal int? PreparationHResult { get; private set; } + internal long? PreparationUsn { get; private set; } + private CloudPlaceholderIdentity? _preparedIdentity; + private CloudItemState? _lastDurableState; + + /// Prepares a local placeholder identity using the original protected file object and official store. + /// Local identity without a remotely accepted revision. + /// Checked before native mutation; required post-native projection completes without cancellation. + /// A native USN and fresh same-object snapshot after successful projection. + /// + /// Uses content-preserving conversion defaults and the actual disallowed-hardlink root policy. + /// An existing placeholder must already carry the exact identity; this does not replace it. + /// Known official identity, pending journal rows, acknowledged revisions and provenance are + /// preserved. No callback-wide SQLite transaction is retained. Native success with failed + /// projection remains visible in the enclosing receipt; recover by starting a new request + /// with the original binding and identical preparation. Native success never proves upload + /// acceptance. Scope methods serialize, reject scope exit, and drain before native release. + /// + /// The identity is null. + /// An accepted revision was supplied. + /// The scope has exited. + /// Cancellation occurred before native preparation. + /// Native facts or durable projection cannot be verified. + public ValueTask ConvertToPlaceholderAsync(CloudPlaceholderIdentity identity, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(identity); + if (identity.RemoteRevision is not null) + { + throw new ArgumentException("Local preparation cannot introduce an accepted remote revision.", nameof(identity)); + } + return ExecuteAsync(token => PrepareIdentityCoreAsync(identity, token, cancellationToken), cancellationToken); + } + + internal ValueTask ConvertItemAsync(CloudItem item, CloudPlaceholderIdentity identity, + CloudPlaceholderConversionOptions options, CancellationToken token) + { + if (!item.IsOwnedBy(_owner) || item.Kind != _item.Kind || + !string.Equals(item.FullPath, _item.FullPath, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Conversion is limited to this scope's original object."); + } + if (options.MarkInSync || options.Dehydrate || options.ForceConversion || + options.PopulationState is not null || options.ContentMode != CloudPlaceholderConversionOptions.Default.ContentMode) + { + throw new NotSupportedException("Protected local conversion supports only content-preserving local preparation."); + } + return ConvertToPlaceholderAsync(identity, token); + } + + private async ValueTask PrepareIdentityCoreAsync(CloudPlaceholderIdentity identity, + CancellationToken token, CancellationToken stepToken) + { + SetStage(CloudProtectedLocalOperationStage.NativePreparation); + if (_item.Kind != CloudItemKind.File || _preparedIdentity is not null && !_preparedIdentity.Equals(identity)) + { + throw new CloudProtectedLocalRejectedException(CloudProtectedLocalOperationOutcome.Unsupported, + "Prepare one local file identity per scope; directory conversion and identity replacement are unavailable."); + } + NativeFileMetadata before = ValidateObject(); + RequireHardlinkPolicy(); + await CloudFileSystem.CheckProtectedLocalIdentityAsync(_store, _item, identity, token).ConfigureAwait(false); + byte[] encoded = identity.Encode(); + bool placeholder = before.PlaceholderState.HasFlag(CfPlaceholderState.Placeholder); + if (placeholder && !before.PlaceholderIdentity.AsSpan().SequenceEqual(encoded)) + { + throw new CloudProtectedLocalRejectedException(CloudProtectedLocalOperationOutcome.NotApplicable, + "An existing placeholder must carry the exact original local identity."); + } + ThrowIfCancellationRequested(stepToken); + token.ThrowIfCancellationRequested(); + if (!placeholder) + { + PreparationUsn = CloudPlaceholderMutationPlatform.Convert(_handle.DangerousGetHandle(), _item.FullPath, + encoded, CloudPlaceholderConversionOptions.Default); + PreparationHResult = 0; + NativeConverted = true; + } + VerifyPreparedIdentity(); + _preparedIdentity = identity; + NativeIdentityPrepared = true; + // Capture actual native success even if the following database transaction fails. + LastSnapshot = CloudFileSystem.CreateSnapshot(_item, + CloudItemInspector.Inspect(_handle, _item.FullPath, _item.Kind, _item.SyncRootPath), _lastDurableState); + SetStage(CloudProtectedLocalOperationStage.Projection); + await CloudFileSystem.PersistProtectedLocalIdentityAsync(_store, _item, identity, + LastSnapshot.LocalFileId, VerifyPreparedIdentity, () => DurableProjectionCommitted = true).ConfigureAwait(false); + VerifyPreparedIdentity(); + CloudItemSnapshot after = await InspectCoreAsync(CancellationToken.None).ConfigureAwait(false); + SetStage(CloudProtectedLocalOperationStage.Projection); + return new(_item.FullPath, PreparationUsn, after, durableStateUpdated: true); + + void VerifyPreparedIdentity() + { + NativeFileMetadata current = ValidateObject(); + RequireHardlinkPolicy(); + if (!current.PlaceholderState.HasFlag(CfPlaceholderState.Placeholder) || + !current.PlaceholderIdentity.AsSpan().SequenceEqual(encoded)) + { + throw new CloudProtectedLocalRejectedException(CloudProtectedLocalOperationOutcome.ProtectionLost, + "The protected native preparation no longer matches the original local identity."); + } + } + } + + private void RequireHardlinkPolicy() + { + int result = NativeSyncRoot.Query(_handle.DangerousGetHandle(), out NativeSyncRootInfo? actual); + if (result < 0) + { + throw new NativeFileException("CfGetSyncRootInfoByHandle", result); + } + if (actual is null || actual.HardLinkPolicy != CfHardLinkPolicy.None) + { + throw new CloudProtectedLocalRejectedException(CloudProtectedLocalOperationOutcome.Unsupported, + "Placeholder alias protection requires the actual disallowed-hardlink registration policy."); + } + } +} diff --git a/src/CfSharp/Items/CloudProtectedLocalOperation.cs b/src/CfSharp/Items/CloudProtectedLocalOperation.cs new file mode 100644 index 0000000..ea9a05b --- /dev/null +++ b/src/CfSharp/Items/CloudProtectedLocalOperation.cs @@ -0,0 +1,200 @@ +namespace CfSharp; + +/// Selects the protection provided for one local object. +public enum CloudProtectedLocalOperationMode +{ + /// Opens one file with exclusive Windows sharing, rejecting data and namespace competitors. + /// Ordinary files have no hardlink freeze. Opening requires read/write data and DACL access; no content is read. + ExclusiveFile, + /// Pins one directory for metadata and DACL work, permitting child membership changes. + /// This provides no directory content, enumeration, child creation or tree freeze. + DirectoryMetadata, +} + +/// Identifies the last work phase of a protected local operation. +public enum CloudProtectedLocalOperationStage +{ + /// Acquiring facade path admission and store ownership. + Admission, + /// Opening the original native file object. + Open, + /// Inspecting its current binding and native metadata. + Inspection, + /// Preparing native placeholder identity without synchronization acceptance. + NativePreparation, + /// Committing official item state. + Projection, + /// Awaiting the application callback. + Callback, + /// Reading or applying an Access-only descriptor. + AccessDescriptor, + /// Rejecting new scope work while retaining resources for admitted work. + Draining, + /// All callback work drained and native resources released. + Released, +} + +/// Classifies a protected operation without claiming product readiness or remote acceptance. +public enum CloudProtectedLocalOperationOutcome +{ + /// The callback and all admitted scope work completed successfully. + Completed, + /// Windows sharing rejected admission, including an existing writer or writable mapping. + Busy, + /// The requested mode or native capability is unavailable. + Unsupported, + /// The observed original object does not match the caller's binding. + LocalObjectMismatch, + /// The object has unsupported aliases, reparse data or deletion state. + NotApplicable, + /// Native protection or its validated object can no longer be used. + ProtectionLost, + /// Native preparation succeeded, but required durable projection needs recovery. + NativeAppliedProjectionPending, + /// The application callback failed; preceding native facts remain valid receipts. + CallbackFailed, + /// The caller or owner requested cancellation; actual admitted work has drained. + Canceled, + /// The short operation budget expired; actual admitted work has drained. + DeadlineExceeded, + /// An unclassified native or durable operation failed; inspect the error and stage. + Failed, +} + +/// Specifies the original object and protection mode for one awaited local callback. +/// +/// Immutable and safe for concurrent reads. Persist the original binding before calling; a newly +/// captured replacement binding does not establish historical ownership. Windows 10 version 1709 +/// or later and an already started facade are required. This contains no owning or borrowed handle. +/// +public sealed partial class CloudProtectedLocalOperationRequest +{ + /// Creates a request for a previously captured native object. + /// Original complete volume, sync-root and file binding. + /// Exclusive single-file protection or directory metadata access. + /// The binding is null. + /// The mode is undefined. + public CloudProtectedLocalOperationRequest(CloudLocalFileBinding expectedBinding, + CloudProtectedLocalOperationMode mode = CloudProtectedLocalOperationMode.ExclusiveFile) + { + ArgumentNullException.ThrowIfNull(expectedBinding); + if (!Enum.IsDefined(mode)) + { + throw new ArgumentException("Select a defined protection mode.", nameof(mode)); + } + ExpectedBinding = expectedBinding; + Mode = mode; + } + + /// Gets the original binding that must match before the callback begins. + public CloudLocalFileBinding ExpectedBinding { get; } + /// Gets the requested single-object protection. + public CloudProtectedLocalOperationMode Mode { get; } +} + +/// Records immutable facts after a protected operation and its admitted work have drained. +/// +/// Safe for concurrent reads; owns no native or database resource. Callback completion never means +/// independent MP permission verification, whole-tree readiness, upload acceptance or journal ACK. +/// The stage identifies the last work phase, even though resources have been released on return. +/// +public sealed partial class CloudProtectedLocalOperationResult +{ + [System.Runtime.Versioning.SupportedOSPlatform("windows10.0.16299")] + internal CloudProtectedLocalOperationResult(CloudProtectedLocalOperationOutcome outcome, + CloudProtectedLocalOperationStage stage, CloudLocalFileBinding expected, + CloudItemSnapshot? snapshot, bool callbackStarted, bool callbackCompleted, + TimeSpan elapsed, Exception? error, CloudProtectedLocalOperationContext? context, CloudProtectedLocalOperationLifetime lifetime) + { + Outcome = outcome; + Stage = stage; + ExpectedBinding = expected; + Snapshot = snapshot; + CallbackStarted = callbackStarted; + CallbackCompleted = callbackCompleted; + Elapsed = elapsed; + Error = error; + NativeIdentityPrepared = context?.NativeIdentityPrepared ?? false; + NativeConverted = context?.NativeConverted ?? false; + DurableProjectionCommitted = context?.DurableProjectionCommitted ?? false; + PreparationHResult = context?.PreparationHResult; + PreparationUsn = context?.PreparationUsn; + AccessDescriptorApplied = context?.AccessDescriptorApplied ?? false; + AccessDescriptorReadBack = context?.AccessDescriptorReadBack ?? false; + CancellationRequested = lifetime.IsCancellationRequested; + BudgetExpired = lifetime.BudgetExpired; + CancellationCallbackError = lifetime.CancellationCallbackError; + } + + /// Gets the outcome without any synchronization or product-readiness claim. + public CloudProtectedLocalOperationOutcome Outcome { get; } + /// Gets the last work phase, retaining the failure location. + public CloudProtectedLocalOperationStage Stage { get; } + /// Gets the caller's original object binding. + public CloudLocalFileBinding ExpectedBinding { get; } + /// Gets the last successful same-object snapshot, or null before inspection succeeded. + /// A receipt snapshot is historical; inspect again in another protected operation before future changes. + public CloudItemSnapshot? Snapshot { get; } + /// Gets whether the application callback was invoked. + public bool CallbackStarted { get; } + /// Gets whether the application callback returned normally. + public bool CallbackCompleted { get; } + /// Gets elapsed time including admission, callback completion and draining. + public TimeSpan Elapsed { get; } + /// Gets the original callback error or a translated native error preserving its HRESULT. + public Exception? Error { get; } + /// Gets whether the exact native identity was observed under protection. + /// This includes retry of an already prepared native object, and implies no content acceptance. + public bool NativeIdentityPrepared { get; } + /// Gets whether this operation performed native conversion; false on an already prepared retry. + public bool NativeConverted { get; } + /// Gets whether required official identity projection actually committed, independently of later errors. + public bool DurableProjectionCommitted { get; } + /// Gets the conversion HRESULT, or null when no conversion call completed successfully. + public int? PreparationHResult { get; } + /// Gets the USN returned by this operation's native conversion, or null when it did not convert. + public long? PreparationUsn { get; } + /// Gets whether a DACL application succeeded on the original native object, independently of later failures. + public bool AccessDescriptorApplied { get; } + /// Gets whether native readback completed after a DACL application. + /// This is a library readback fact, not independent policy verification or product readiness. + public bool AccessDescriptorReadBack { get; } + /// Gets whether the caller, owner or budget signaled cancellation of this operation. + public bool CancellationRequested { get; } + /// Gets whether the cooperative budget elapsed, independently of the work outcome. + public bool BudgetExpired { get; } + /// Gets a failure from application cancellation registrations, separately from work errors. + /// Such handlers are drained without throwing through owner shutdown or erasing native receipts. + public Exception? CancellationCallbackError { get; } + /// Gets whether all admitted work drained before native and facade resources were released. + public bool Drained { get; } = true; +} + +public abstract partial class CloudItem +{ + /// Awaits a short protected local operation on the caller's original native object. + /// Previously captured binding and requested protection. + /// Application work awaited to its actual completion; keep it local and short. + /// Requests cancellation and stops new scope work without aborting callback draining. + /// A receipt after the callback, admitted scope operations and resources have drained. + /// + /// The library retains one owned Windows file object, path admission and the state-store lifetime + /// across callback awaits. File share-none rejects conflicting data and namespace opens; it does + /// not depend on an automatically invalidatable CFAPI opaque handle. Directory mode provides + /// metadata and DACL access only. Neither mode freezes tree membership or ordinary-file aliases. + /// Use the context for same-object inspection. Unsupported nested facade operations and owner + /// disposal from this callback fail immediately to avoid self-waiting. Escaped contexts reject + /// work after callback exit. An uncooperative callback or native driver may delay cancellation; + /// no native resource is released while admitted work can still use it. No SQLite transaction + /// remains open around application code. No source content, remote credentials or network work + /// is supplied, and this API does not mark in sync or ACK journal entries. + /// + /// A required argument is null. + /// The owner has not started, or the call reenters another protected callback. + /// The owner is stopping or disposed before admission. + public ValueTask RunProtectedLocalOperationAsync( + CloudProtectedLocalOperationRequest request, + Func callback, + CancellationToken cancellationToken = default) => + Owner.RunProtectedLocalOperationAsync(this, request, callback, cancellationToken); +} diff --git a/src/CfSharp/Items/CloudProtectedLocalOperationContext.cs b/src/CfSharp/Items/CloudProtectedLocalOperationContext.cs new file mode 100644 index 0000000..cf0d8a1 --- /dev/null +++ b/src/CfSharp/Items/CloudProtectedLocalOperationContext.cs @@ -0,0 +1,214 @@ +using System.Runtime.Versioning; +using CfSharp.Native; +using Microsoft.Win32.SafeHandles; + +namespace CfSharp; + +/// Provides coordinated same-object operations during one protected callback. +/// +/// Owned by the library. It exposes no native handle and must not be disposed by the caller. +/// Scope methods may run concurrently; native and store phases are serialized on this object. +/// Admitted calls are drained on callback exit, including calls the application did not await. +/// New calls after exit throw ObjectDisposedException. Retained snapshots and descriptors are +/// independent copies, but do not retain protection. Use no network or unbounded work in the scope. +/// Windows 10 version 1709 or later is required. No method reads source content or hydrates data. +/// +[SupportedOSPlatform("windows10.0.16299")] +[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1001:Types that own disposable fields should be disposable", + Justification = "The library drains and closes this callback-owned scope; public disposal would permit premature native release.")] +public sealed partial class CloudProtectedLocalOperationContext +{ + private readonly object _admission = new(); + private readonly SemaphoreSlim _serial = new(1, 1); + private readonly TaskCompletionSource _drained = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly CloudItem _item; + private readonly CloudFileSystem _owner; + private readonly ICloudStateStore _store; + private readonly SafeFileHandle _handle; + private readonly CloudProtectedLocalOperationRequest _request; + private readonly CloudProtectedLocalOperationLifetime _lifetime; + private readonly CancellationToken _stop; + private bool _accepting = true; + private int _active; + private int _stage; + private Exception? _failure; + private CloudProtectedLocalOperationStage? _failureStage; + private int _released; + + /// Gets the current phase, including observable draining and final native release. + /// Safe for concurrent reads, including from an observer outside the callback. + public CloudProtectedLocalOperationStage CurrentStage => Volatile.Read(ref _released) != 0 + ? CloudProtectedLocalOperationStage.Released : IsDraining ? CloudProtectedLocalOperationStage.Draining : Stage; + + /// Gets whether cancellation or scope exit is rejecting new work while resources remain retained. + /// Cancellation never releases a handle still in use by the callback or an admitted scope step. + public bool IsDraining => Volatile.Read(ref _released) == 0 && (_lifetime.IsCancellationRequested || !Volatile.Read(ref _accepting)); + + internal void MarkResourcesReleased() => Volatile.Write(ref _released, 1); + + internal CloudProtectedLocalOperationContext(CloudFileSystem owner, CloudItem item, ICloudStateStore store, + SafeFileHandle handle, CloudProtectedLocalOperationRequest request, CloudProtectedLocalOperationLifetime lifetime) + { + _item = item; + _owner = owner; + _store = store; + _handle = handle; + _request = request; + _lifetime = lifetime; + _stop = lifetime.Token; + } + + internal CloudItemSnapshot? LastSnapshot { get; private set; } + internal Exception? Failure => _failure; + internal CloudProtectedLocalOperationStage? FailureStage => _failureStage; + internal CloudProtectedLocalOperationStage Stage => (CloudProtectedLocalOperationStage)Volatile.Read(ref _stage); + internal void SetStage(CloudProtectedLocalOperationStage stage) => Volatile.Write(ref _stage, (int)stage); + + /// Inspects fresh metadata and official item state through the original protected file object. + /// Token observed before admitting and performing this scope step. + /// A handle-free snapshot owning its identity bytes; content is not read. + /// The callback has exited and scope admission is closed. + /// Scope or step cancellation was requested. + /// The protected object cannot be verified or queried. + public ValueTask InspectAsync(CancellationToken cancellationToken = default) => + ExecuteAsync(InspectCoreAsync, cancellationToken); + + internal ValueTask InspectItemAsync(CloudItem item, CancellationToken token) + { + if (!item.IsOwnedBy(_owner) || item.Kind != _item.Kind || + !string.Equals(item.FullPath, _item.FullPath, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("A protected callback can inspect only its original object."); + } + return InspectAsync(token); + } + + internal NativeFileMetadata ValidateObject() + { + NativeFileMetadata facts = WindowsFileMetadata.Read(_handle.DangerousGetHandle()); + // Reject unsupported native objects before asking CFAPI for their root association. + // Foreign reparse data is not a Cloud Files capability/query failure. + if (facts.Directory != (_item.Kind == CloudItemKind.Directory) || facts.DeletePending || + !facts.Directory && facts.Links != 1 || + (facts.Attributes & (uint)FileAttributes.ReparsePoint) != 0 && + !facts.PlaceholderState.HasFlag(CfPlaceholderState.Placeholder)) + { + throw new CloudProtectedLocalRejectedException(CloudProtectedLocalOperationOutcome.NotApplicable, + "Protection requires a live supported object, without foreign reparse data or existing file aliases."); + } + using SafeFileHandle root = WindowsFileMetadata.Open(_item.SyncRootPath); + int actualResult = NativeSyncRoot.Query(_handle.DangerousGetHandle(), out NativeSyncRootInfo? actualRoot); + int ownedResult = NativeSyncRoot.Query(root.DangerousGetHandle(), out NativeSyncRootInfo? ownedRoot); + if (actualResult < 0 || ownedResult < 0) + { + throw new NativeFileException("CfGetSyncRootInfoByHandle", actualResult < 0 ? actualResult : ownedResult); + } + if (actualRoot is null || ownedRoot is null || actualRoot.FileId != ownedRoot.FileId) + { + throw new CloudProtectedLocalRejectedException(CloudProtectedLocalOperationOutcome.LocalObjectMismatch, + "The native object belongs to a different Cloud Files root."); + } + CloudLocalFileBinding observed = CloudLocalFileBindingPlatform.Read(_handle.DangerousGetHandle(), _item.SyncRootPath); + if (observed != _request.ExpectedBinding || !string.Equals( + WindowsFileMetadata.ReadFinalPath(_handle.DangerousGetHandle()), _item.FullPath, StringComparison.OrdinalIgnoreCase)) + { + throw new CloudProtectedLocalRejectedException(CloudProtectedLocalOperationOutcome.LocalObjectMismatch, + "The original native object or its namespace no longer matches the request."); + } + return facts; + } + + private async ValueTask InspectCoreAsync(CancellationToken token) + { + SetStage(CloudProtectedLocalOperationStage.Inspection); + ValidateObject(); + LocalCloudItemInspection local = CloudItemInspector.Inspect(_handle, _item.FullPath, _item.Kind, _item.SyncRootPath); + await using ICloudStateTransaction transaction = await _store.BeginTransactionAsync(token).ConfigureAwait(false); + CloudItemState? durable = await transaction.Items.GetByRelativePathAsync(_item.RelativePath, token).ConfigureAwait(false); + if (durable is not null && durable.Kind != _item.Kind) + { + throw new InvalidDataException("The official item row has a different object kind."); + } + _lastDurableState = durable; + LastSnapshot = CloudFileSystem.CreateSnapshot(_item, local, durable); + return LastSnapshot; + } + + private async ValueTask ExecuteAsync(Func> work, CancellationToken token) + { + lock (_admission) + { + ObjectDisposedException.ThrowIf(!_accepting, this); + ThrowIfCancellationRequested(token); + _active++; + } + bool entered = false; + try + { + using CancellationTokenSource stop = CancellationTokenSource.CreateLinkedTokenSource(_stop, token); + await _serial.WaitAsync(stop.Token).ConfigureAwait(false); + entered = true; + ThrowIfCancellationRequested(token); + stop.Token.ThrowIfCancellationRequested(); + return await work(stop.Token).ConfigureAwait(false); + } + catch (Exception error) + { + Exception translated = CloudFileSystem.TranslateProtectedLocalError(error, _item.FullPath, Stage); + lock (_admission) + { + if (_failure is null) + { + _failure = translated; + _failureStage = Stage; + } + } + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw(translated); + throw; + } + finally + { + if (entered) + { + _serial.Release(); + } + lock (_admission) + { + _active--; + if (!_accepting && _active == 0) + { + _drained.TrySetResult(); + } + } + } + } + + private void ThrowIfCancellationRequested(CancellationToken stepToken) + { + // CancelAsync marks its source immediately but dispatches linked-token callbacks + // asynchronously. Read the original signals at mutation boundaries so delayed + // propagation cannot admit a native change after cancellation was requested. + _lifetime.ThrowIfCancellationRequested(); + stepToken.ThrowIfCancellationRequested(); + } + + internal async ValueTask CloseAsync() + { + lock (_admission) + { + _accepting = false; + if (_active == 0) + { + _drained.TrySetResult(); + } + } + await _drained.Task.ConfigureAwait(false); + _serial.Dispose(); + } +} + +internal sealed class CloudProtectedLocalRejectedException(CloudProtectedLocalOperationOutcome outcome, string message) + : IOException(message) +{ + internal CloudProtectedLocalOperationOutcome Outcome { get; } = outcome; +} diff --git a/src/CfSharp/Items/CloudProtectedLocalOperationLifetime.cs b/src/CfSharp/Items/CloudProtectedLocalOperationLifetime.cs new file mode 100644 index 0000000..1c62dda --- /dev/null +++ b/src/CfSharp/Items/CloudProtectedLocalOperationLifetime.cs @@ -0,0 +1,115 @@ +using System.Diagnostics; + +namespace CfSharp; + +public sealed partial class CloudProtectedLocalOperationRequest +{ + /// Creates an immutable copy with a short cooperative operation budget. + /// Positive duration no greater than one minute, including path admission. + /// A request preserving its original binding, mode and optional preparation. + /// + /// Expiry stops new scope steps and signals the callback, but does not forcibly abort native + /// calls or application code. The library still drains actual work before releasing resources. + /// Keep callbacks local; the budget cannot guarantee immediate return from an uncooperative + /// callback or a stalled native driver. Query the context's CurrentStage and IsDraining while + /// awaiting completion to observe retained protection during cancellation. + /// + /// The budget is nonpositive or exceeds one minute. + public CloudProtectedLocalOperationRequest WithBudget(TimeSpan budget) + { + if (budget <= TimeSpan.Zero || budget > TimeSpan.FromMinutes(1)) + { + throw new ArgumentOutOfRangeException(nameof(budget), "Use a positive short budget of at most one minute."); + } + return new(ExpectedBinding, Mode) { PreparationIdentity = PreparationIdentity, Budget = budget }; + } + + /// Gets the cooperative admission and callback budget; the default is ten seconds. + /// Expiry never releases a native object still in use by admitted work. + public TimeSpan Budget { get; private init; } = TimeSpan.FromSeconds(10); +} + +internal sealed class CloudProtectedLocalOperationLifetime : IDisposable +{ + private readonly object _cancellation = new(); + private readonly CancellationTokenSource _callback = new(); + private readonly CancellationTokenSource _deadline = new(); + private readonly CancellationTokenSource _signals; + private readonly CancellationTokenRegistration _registration; + private readonly CancellationToken _caller; + private readonly CancellationToken _owner; + private Task _canceling = Task.CompletedTask; + + internal CloudProtectedLocalOperationLifetime(CloudProtectedLocalOperationRequest request, long started, + CancellationToken caller, CancellationToken owner) + { + _caller = caller; + _owner = owner; + TimeSpan remaining = request.Budget - Stopwatch.GetElapsedTime(started); + if (remaining <= TimeSpan.Zero) + { + _deadline.Cancel(); + } + else + { + _deadline.CancelAfter(remaining); + } + _signals = CancellationTokenSource.CreateLinkedTokenSource(caller, owner, _deadline.Token); + // Application cancellation registrations must not run under the owner's lifecycle + // lock or throw through its stopping source. CancelAsync still marks the exposed token + // immediately, while its registered handlers execute separately and are drained below. + _registration = _signals.Token.UnsafeRegister(static state => ((CloudProtectedLocalOperationLifetime)state!).RequestStop(), this); + } + + internal CancellationToken Token => _callback.Token; + internal bool IsCancellationRequested => _callback.IsCancellationRequested || _caller.IsCancellationRequested || + _owner.IsCancellationRequested || _deadline.IsCancellationRequested; + internal bool TimedOut => _deadline.IsCancellationRequested && !_caller.IsCancellationRequested && !_owner.IsCancellationRequested; + internal bool BudgetExpired => _deadline.IsCancellationRequested; + internal Exception? CancellationCallbackError { get; private set; } + + internal void ThrowIfCancellationRequested() + { + _caller.ThrowIfCancellationRequested(); + _owner.ThrowIfCancellationRequested(); + _deadline.Token.ThrowIfCancellationRequested(); + _callback.Token.ThrowIfCancellationRequested(); + } + + private void RequestStop() + { + lock (_cancellation) + { + _canceling = _callback.CancelAsync(); + } + } + + internal async ValueTask SealAndDrainCancellationAsync() + { + // Stop forwarding new signals once callback/scope admission is closed. Dispose waits + // only for our short bridge, then await real registered application handlers while the + // native object and store lease are still alive. + _registration.Dispose(); + Task pending; + lock (_cancellation) + { + pending = _canceling; + } + try + { + await pending.ConfigureAwait(false); + } + catch (Exception error) + { + CancellationCallbackError = error; + } + } + + public void Dispose() + { + _registration.Dispose(); + _signals.Dispose(); + _deadline.Dispose(); + _callback.Dispose(); + } +} diff --git a/src/CfSharp/Placeholders/CloudPlaceholderMutationPlatform.cs b/src/CfSharp/Placeholders/CloudPlaceholderMutationPlatform.cs index f790ff9..a911951 100644 --- a/src/CfSharp/Placeholders/CloudPlaceholderMutationPlatform.cs +++ b/src/CfSharp/Placeholders/CloudPlaceholderMutationPlatform.cs @@ -22,11 +22,17 @@ internal static unsafe long Convert( "CloudItem.ConvertToPlaceholder.Open"); using SafeCloudFilesProtectedHandle.CloudFilesHandleReference handle = protectedHandle.AcquireReference(); + return Convert(handle.Win32Handle, path, encodedIdentity, options); + } + + internal static unsafe long Convert(nint fileHandle, string path, byte[] encodedIdentity, + CloudPlaceholderConversionOptions options) + { fixed (byte* identityPointer = encodedIdentity) { long operationUsn; int result = CfApi.CfConvertToPlaceholder( - handle.Win32Handle, + fileHandle, identityPointer, checked((uint)encodedIdentity.Length), CreateConvertFlags(options), diff --git a/tests/CfSharp.IntegrationTests/CloudContentConfirmationRecoveryTests.cs b/tests/CfSharp.IntegrationTests/CloudContentConfirmationRecoveryTests.cs index d3f4a90..38f8c38 100644 --- a/tests/CfSharp.IntegrationTests/CloudContentConfirmationRecoveryTests.cs +++ b/tests/CfSharp.IntegrationTests/CloudContentConfirmationRecoveryTests.cs @@ -303,12 +303,25 @@ private sealed class ProjectionFault : Exception; private sealed class FaultFactory(string path) : ICloudStateStoreFactory { private readonly SqliteCloudStateStoreFactory _inner = new(path); + private readonly AsyncLocal _requestFlow = new(); + private volatile bool _requestFaultsOnly; internal Exception? NextCommitFault { get; set; } internal Exception? NextDisposeFault { get; set; } internal Func? BeforeTransaction { get; set; } internal Func? BeforeCommit { get; set; } + internal Action? AfterTransactionDispose { get; set; } internal ICloudStateStore Store { get; private set; } = null!; + internal void LimitFaultsToCurrentRequest() + { + // Native completion workers can use this same store concurrently. Request-bound + // fault hooks must not be consumed by their unrelated transactions or disposal. + _requestFaultsOnly = true; + _requestFlow.Value = true; + } + + private bool FaultsEnabled => !_requestFaultsOnly || _requestFlow.Value; + public async ValueTask OpenAsync(CloudStateStoreContext context, CancellationToken cancellationToken = default) => Store = new FaultStore(await _inner.OpenAsync(context, cancellationToken), this); @@ -317,18 +330,19 @@ private sealed class FaultStore(ICloudStateStore inner, FaultFactory faults) : I { public async ValueTask BeginTransactionAsync(CancellationToken cancellationToken = default) { - if (faults.BeforeTransaction is { } before) + bool applyFaults = faults.FaultsEnabled; + if (applyFaults && faults.BeforeTransaction is { } before) { faults.BeforeTransaction = null; await before(); } - return new FaultTransaction(await inner.BeginTransactionAsync(cancellationToken), faults); + return new FaultTransaction(await inner.BeginTransactionAsync(cancellationToken), faults, applyFaults); } public ValueTask DisposeAsync() => inner.DisposeAsync(); } - private sealed class FaultTransaction(ICloudStateTransaction inner, FaultFactory faults) : ICloudStateTransaction + private sealed class FaultTransaction(ICloudStateTransaction inner, FaultFactory faults, bool applyFaults) : ICloudStateTransaction { public ICloudItemStateRepository Items => inner.Items; public ICloudCheckpointRepository Checkpoints => inner.Checkpoints; @@ -339,13 +353,13 @@ private sealed class FaultTransaction(ICloudStateTransaction inner, FaultFactory public async ValueTask CommitAsync(CancellationToken cancellationToken = default) { - if (faults.BeforeCommit is { } before) + if (applyFaults && faults.BeforeCommit is { } before) { faults.BeforeCommit = null; await before(); } - if (faults.NextCommitFault is { } exception) + if (applyFaults && faults.NextCommitFault is { } exception) { faults.NextCommitFault = null; throw exception; @@ -358,7 +372,12 @@ public async ValueTask CommitAsync(CancellationToken cancellationToken = default public async ValueTask DisposeAsync() { await inner.DisposeAsync(); - if (faults.NextDisposeFault is { } exception) + if (applyFaults && faults.AfterTransactionDispose is { } after) + { + faults.AfterTransactionDispose = null; + after(); + } + if (applyFaults && faults.NextDisposeFault is { } exception) { faults.NextDisposeFault = null; throw exception; diff --git a/tests/CfSharp.IntegrationTests/CloudContentConfirmationTests.cs b/tests/CfSharp.IntegrationTests/CloudContentConfirmationTests.cs index eee55e9..c188b65 100644 --- a/tests/CfSharp.IntegrationTests/CloudContentConfirmationTests.cs +++ b/tests/CfSharp.IntegrationTests/CloudContentConfirmationTests.cs @@ -186,7 +186,7 @@ private Fixture(string directory, string root, CloudFileSystem system, Unexpecte private ICloudStateStoreFactory? _restartFactory; internal static async Task StartAsync(Func? createStore = null, - CloudInSyncPolicy inSyncPolicy = CloudInSyncPolicy.None) + CloudInSyncPolicy inSyncPolicy = CloudInSyncPolicy.None, bool allowHardLinks = false) { // This acceptance fixture fails on unavailable Windows capabilities; it never // silently returns success when native confirmation has not actually executed. @@ -199,7 +199,7 @@ internal static async Task StartAsync(Func((await item.InspectAsync()).LocalBinding); + CloudProtectedLocalOperationRequest request = directory + ? new(binding, CloudProtectedLocalOperationMode.DirectoryMetadata) + : CloudProtectedLocalOperationRequest.ForLocalConversion(binding, identity); + CloudProtectedLocalOperationContext? escaped = null; + FileSystemSecurity? saved = null; + CloudProtectedLocalOperationResult result = await item.RunProtectedLocalOperationAsync(request, async (scope, token) => + { + escaped = scope; + FileSystemSecurity access = await scope.ReadAccessDescriptorAsync(token); + Assert.Equal(directory ? typeof(DirectorySecurity) : typeof(FileSecurity), access.GetType()); + AssertSameAccessDacl(original.GetSecurityDescriptorBinaryForm(), access.GetSecurityDescriptorBinaryForm()); + Assert.Equal(original.GetAccessRules(true, true, typeof(SecurityIdentifier)).Cast().Select(rule => rule.IsInherited), + access.GetAccessRules(true, true, typeof(SecurityIdentifier)).Cast().Select(rule => rule.IsInherited)); + // Deliberately include unrelated sections: Access-only application must ignore them. + access.SetOwner(new SecurityIdentifier(WellKnownSidType.NullSid, null)); + access.SetGroup(new SecurityIdentifier(WellKnownSidType.NullSid, null)); + access.AddAuditRule(new FileSystemAuditRule(new SecurityIdentifier(WellKnownSidType.WorldSid, null), + FileSystemRights.WriteData, AuditFlags.Failure)); + saved = await scope.ApplyAccessDescriptorAsync(access, token); + AssertSameAccessDacl(original.GetSecurityDescriptorBinaryForm(), saved.GetSecurityDescriptorBinaryForm()); + await Task.Yield(); + Assert.Equal(binding, (await scope.InspectAsync(token)).LocalBinding); + if (directory) + { + await File.WriteAllTextAsync(Path.Combine(item.FullPath, "unfrozen-child.txt"), "membership can change", token); + Assert.Throws(() => Directory.Move(item.FullPath, item.FullPath + "-renamed")); + } + }); + Assert.True(result.Outcome == CloudProtectedLocalOperationOutcome.Completed, result.Error?.ToString()); + Assert.True(result.AccessDescriptorApplied); + Assert.True(result.AccessDescriptorReadBack); + FileSystemSecurity observed = directory + ? new DirectoryInfo(item.FullPath).GetAccessControl(AccessControlSections.Access | AccessControlSections.Owner | AccessControlSections.Group) + : new FileInfo(item.FullPath).GetAccessControl(AccessControlSections.Access | AccessControlSections.Owner | AccessControlSections.Group); + Assert.Equal(original.GetOwner(typeof(SecurityIdentifier)), observed.GetOwner(typeof(SecurityIdentifier))); + Assert.Equal(original.GetGroup(typeof(SecurityIdentifier)), observed.GetGroup(typeof(SecurityIdentifier))); + AssertSameAccessDacl(original.GetSecurityDescriptorBinaryForm(), observed.GetSecurityDescriptorBinaryForm()); + AssertSameAccessDacl(original.GetSecurityDescriptorBinaryForm(), saved!.GetSecurityDescriptorBinaryForm()); + await Assert.ThrowsAsync(() => escaped!.ReadAccessDescriptorAsync().AsTask()); + await Assert.ThrowsAsync(() => escaped!.ApplyAccessDescriptorAsync(saved).AsTask()); + if (kind == "cold") + { + Assert.Equal(CloudContentAvailability.OnlineOnly, (await item.InspectAsync()).ContentAvailability); + } + else if (!directory) + { + Assert.Equal("local-original", await File.ReadAllTextAsync(item.FullPath)); + } + Assert.Equal(0, fixture.Provider.Fetches); + } + + [Fact] + public async Task ProtectedLocalAccessDoesNotApplyPermissionsBeforeRequiredAliasProtection() + { + await using Fixture fixture = await Fixture.StartAsync(); + await File.WriteAllTextAsync(fixture.File.FullPath, "ordinary"); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + byte[] original = new FileInfo(fixture.File.FullPath).GetAccessControl(AccessControlSections.Access).GetSecurityDescriptorBinaryForm(); + CloudProtectedLocalOperationResult result = await fixture.File.RunProtectedLocalOperationAsync(new(binding), async (scope, token) => + { + FileSystemSecurity descriptor = await scope.ReadAccessDescriptorAsync(token); + await Assert.ThrowsAsync(() => scope.ApplyAccessDescriptorAsync(new DirectorySecurity()).AsTask()); + await scope.ApplyAccessDescriptorAsync(descriptor, token); + }); + Assert.Equal(CloudProtectedLocalOperationOutcome.Unsupported, result.Outcome); + Assert.False(result.AccessDescriptorApplied); + Assert.False(result.AccessDescriptorReadBack); + Assert.Equal(original, new FileInfo(fixture.File.FullPath).GetAccessControl(AccessControlSections.Access).GetSecurityDescriptorBinaryForm()); + Assert.Equal("ordinary", await File.ReadAllTextAsync(fixture.File.FullPath)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ProtectedLocalAccessRejectsExistingAliasesBeforeCallback(bool outsideRoot) + { + await using Fixture fixture = await Fixture.StartAsync(); + await File.WriteAllTextAsync(fixture.File.FullPath, "all-aliases-original"); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + string alias = Path.Combine(outsideRoot ? Path.GetDirectoryName(fixture.Root)! : fixture.Root, "alias.bin"); + using LocalCompetitor link = StartLocalCompetitor(fixture.File.FullPath, alias, "link"); + await link.StartAsync(); + await link.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(0, link.ExitCode); + byte[] original = new FileInfo(alias).GetAccessControl(AccessControlSections.Access).GetSecurityDescriptorBinaryForm(); + CloudProtectedLocalOperationResult result = await fixture.File.RunProtectedLocalOperationAsync( + CloudProtectedLocalOperationRequest.ForLocalConversion(binding, CloudPlaceholderIdentity.Create("aliased-local")), + (_, _) => throw new InvalidOperationException("Aliased files must never enter a permission callback.")); + Assert.Equal(CloudProtectedLocalOperationOutcome.NotApplicable, result.Outcome); + Assert.False(result.CallbackStarted); + Assert.False(result.NativeConverted); + Assert.False(result.AccessDescriptorApplied); + Assert.Equal(original, new FileInfo(alias).GetAccessControl(AccessControlSections.Access).GetSecurityDescriptorBinaryForm()); + Assert.Equal("all-aliases-original", await File.ReadAllTextAsync(alias)); + Assert.Equal("all-aliases-original", await File.ReadAllTextAsync(fixture.File.FullPath)); + } + + [Fact] + public async Task ProtectedLocalAccessRetainsAppliedFactsWhenCallbackFailsAndReleasesTheObject() + { + await using Fixture fixture = await Fixture.StartAsync(); + await File.WriteAllTextAsync(fixture.File.FullPath, "original"); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + IOException failure = new("Application policy verification failed."); + CloudProtectedLocalOperationResult result = await fixture.File.RunProtectedLocalOperationAsync( + CloudProtectedLocalOperationRequest.ForLocalConversion(binding, CloudPlaceholderIdentity.Create("permission-pending")), + async (scope, token) => + { + await scope.ApplyAccessDescriptorAsync(await scope.ReadAccessDescriptorAsync(token), token); + throw failure; + }); + Assert.Equal(CloudProtectedLocalOperationOutcome.CallbackFailed, result.Outcome); + Assert.Same(failure, result.Error); + Assert.True(result.NativeIdentityPrepared); + Assert.True(result.DurableProjectionCommitted); + Assert.True(result.AccessDescriptorApplied); + Assert.True(result.AccessDescriptorReadBack); + Assert.True(result.Drained); + await File.WriteAllTextAsync(fixture.File.FullPath, "editable"); + Assert.Equal(0, fixture.Provider.Fetches); + } + + [Fact] + public async Task ProtectedLocalAccessReportsNativeAccessDenialWithoutInvokingCallback() + { + await using Fixture fixture = await Fixture.StartAsync(); + await File.WriteAllTextAsync(fixture.File.FullPath, "original"); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + FileInfo info = new(fixture.File.FullPath); + FileSecurity original = info.GetAccessControl(AccessControlSections.Access); + FileSecurity denied = info.GetAccessControl(AccessControlSections.Access); + denied.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier(WellKnownSidType.WorldSid, null), + FileSystemRights.ReadData, AccessControlType.Deny)); + try + { + info.SetAccessControl(denied); + CloudProtectedLocalOperationResult result = await fixture.File.RunProtectedLocalOperationAsync(new(binding), + (_, _) => throw new InvalidOperationException("Native denial must prevent callback admission.")); + Assert.Equal(CloudProtectedLocalOperationOutcome.Failed, result.Outcome); + Assert.Equal(CloudProtectedLocalOperationStage.Open, result.Stage); + Assert.Equal(5, Assert.IsType(result.Error).Win32ErrorCode); + Assert.False(result.CallbackStarted); + Assert.False(result.AccessDescriptorApplied); + } + finally + { + original.SetSecurityDescriptorBinaryForm(original.GetSecurityDescriptorBinaryForm(), AccessControlSections.Access); + info.SetAccessControl(original); + } + Assert.Equal("original", await File.ReadAllTextAsync(fixture.File.FullPath)); + Assert.Equal(0, fixture.Provider.Fetches); + } +} diff --git a/tests/CfSharp.IntegrationTests/ProtectedLocalBoundaryTests.cs b/tests/CfSharp.IntegrationTests/ProtectedLocalBoundaryTests.cs new file mode 100644 index 0000000..cc6414e --- /dev/null +++ b/tests/CfSharp.IntegrationTests/ProtectedLocalBoundaryTests.cs @@ -0,0 +1,228 @@ +using System.Diagnostics; +using System.Security.AccessControl; + +namespace CfSharp.IntegrationTests; + +public sealed partial class CloudContentConfirmationTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ProtectedLocalBoundaryShutdownAfterNativePreparationCommitsBeforeStoreClosure(bool ownedFeed) + { + FaultFactory? factory = null; + await using Fixture fixture = await Fixture.StartAsync(path => factory = new FaultFactory(path)); + await File.WriteAllTextAsync(fixture.File.FullPath, "unuploaded"); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + CloudPlaceholderIdentity identity = CloudPlaceholderIdentity.Create("local-shutdown-projection"); + CloudOperationJournalEntry original; + await using (ICloudStateTransaction seed = await factory!.Store.BeginTransactionAsync()) + { + await seed.Items.UpsertAsync(new(identity.ItemId, identity.RemoteId, fixture.File.RelativePath, + CloudItemKind.File, null, null, false, DateTimeOffset.UtcNow)); + original = await seed.Operations.EnqueueAsync(new(Guid.NewGuid(), CloudStateOperationKind.ContentUpdate, + identity.ItemId, [1, 2], DateTimeOffset.UtcNow)); + await seed.CommitAsync(); + } + if (ownedFeed) + { + await fixture.System.CreateLocalChangeFeed().StartAsync(); + } + Task? shutdown = null; + factory!.BeforeCommit = async () => + { + // No public facade reentry: inspect only the native metadata while the current + // transaction commits. A feed notification can also reach this hook, but only + // after the same native conversion; both owners must retain the admitted store. + Assert.True(CloudItemInspector.Inspect(fixture.File.FullPath, CloudItemKind.File).PlaceholderState + .HasFlag(CloudPlaceholderState.Placeholder)); + shutdown = Task.Run(async () => await fixture.System.DisposeAsync()); + long started = Stopwatch.GetTimestamp(); + while (fixture.System.LifecycleState != CloudFileSystemLifecycleState.Stopping) + { + Assert.True(Stopwatch.GetElapsedTime(started) < TimeSpan.FromSeconds(10)); + await Task.Delay(10); + } + Assert.Throws(() => fixture.System.GetFile("rejected.bin")); + }; + CloudProtectedLocalOperationResult result = await fixture.File.RunProtectedLocalOperationAsync( + CloudProtectedLocalOperationRequest.ForLocalConversion(binding, identity), + (_, _) => throw new InvalidOperationException("Stopping must prevent permission callback entry.")); + Assert.Equal(CloudProtectedLocalOperationOutcome.Canceled, result.Outcome); + Assert.True(result.NativeConverted); + Assert.True(result.NativeIdentityPrepared); + Assert.True(result.DurableProjectionCommitted); + Assert.False(result.CallbackStarted); + Assert.True(result.Drained); + Assert.NotNull(shutdown); + await shutdown.WaitAsync(TimeSpan.FromSeconds(15)); + await fixture.RestartAsync(); + await fixture.RestartAsync(); + CloudItemSnapshot recovered = await fixture.File.InspectAsync(); + Assert.Equal(binding, recovered.LocalBinding); + Assert.Equal(identity.ItemId, recovered.ItemId); + Assert.Equal(identity.Encode(), recovered.PlaceholderIdentity.ToArray()); + Assert.NotEqual(CloudSynchronizationState.InSync, recovered.SynchronizationState); + Assert.Null(recovered.RemoteRevision); + Assert.Equal("unuploaded", await File.ReadAllTextAsync(fixture.File.FullPath)); + await using ICloudStateTransaction verify = await factory!.Store.BeginTransactionAsync(); + CloudOperationJournalEntry retained = Assert.IsType(await verify.Operations.GetAsync(original.OperationId)); + Assert.Equal(original.Sequence, retained.Sequence); + Assert.Equal(original.Payload.ToArray(), retained.Payload.ToArray()); + Assert.Equal(original.CreatedAt, retained.CreatedAt); + Assert.Equal(0, fixture.Provider.Fetches); + } + + [Theory] + [InlineData(CloudInSyncPolicy.None)] + [InlineData(CloudInSyncPolicy.TrackAll)] + public async Task ProtectedLocalBoundaryDoesNotIntroduceAcceptanceUnderEitherSyncPolicy(CloudInSyncPolicy policy) + { + await using Fixture fixture = await Fixture.StartAsync(inSyncPolicy: policy); + await File.WriteAllTextAsync(fixture.File.FullPath, "unuploaded"); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + CloudProtectedLocalOperationResult result = await fixture.File.RunProtectedLocalOperationAsync( + CloudProtectedLocalOperationRequest.ForLocalConversion(binding, CloudPlaceholderIdentity.Create("policy-local")), + async (scope, stop) => await scope.ApplyAccessDescriptorAsync(await scope.ReadAccessDescriptorAsync(stop), stop)); + Assert.Equal(CloudProtectedLocalOperationOutcome.Completed, result.Outcome); + Assert.Null(result.Snapshot!.RemoteRevision); + Assert.Equal(CloudSynchronizationState.NotInSync, (await fixture.File.InspectAsync()).SynchronizationState); + Assert.Equal(0, fixture.Provider.Fetches); + } + + [Fact] + public async Task ProtectedLocalBoundaryChecksActualHardlinkPolicyBeforeConversionOrPermissions() + { + await using Fixture fixture = await Fixture.StartAsync(allowHardLinks: true); + await File.WriteAllTextAsync(fixture.File.FullPath, "ordinary"); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + CloudProtectedLocalOperationResult result = await fixture.File.RunProtectedLocalOperationAsync( + CloudProtectedLocalOperationRequest.ForLocalConversion(binding, CloudPlaceholderIdentity.Create("disallowed-request")), + (_, _) => throw new InvalidOperationException("The actual allowed-hardlink policy must prevent callback entry.")); + Assert.Equal(CloudProtectedLocalOperationOutcome.Unsupported, result.Outcome); + Assert.False(result.NativeConverted); + Assert.False(result.CallbackStarted); + Assert.False((await fixture.File.InspectAsync()).IsPlaceholder); + Assert.Equal("ordinary", await File.ReadAllTextAsync(fixture.File.FullPath)); + } + + [Fact] + public async Task ProtectedLocalBoundaryPreservesTheFirstFailureWhenApplicationContinuesInspecting() + { + await using Fixture fixture = await Fixture.StartAsync(); + await File.WriteAllTextAsync(fixture.File.FullPath, "ordinary"); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + Exception? first = null; + CloudProtectedLocalOperationResult result = await fixture.File.RunProtectedLocalOperationAsync(new(binding), async (scope, stop) => + { + first = await Record.ExceptionAsync(() => scope.ApplyAccessDescriptorAsync(new FileSecurity(), stop).AsTask()); + Assert.NotNull(first); + Assert.Equal(binding, (await scope.InspectAsync(stop)).LocalBinding); + }); + Assert.Equal(CloudProtectedLocalOperationOutcome.Unsupported, result.Outcome); + Assert.Equal(CloudProtectedLocalOperationStage.AccessDescriptor, result.Stage); + Assert.True(result.CallbackCompleted); + Assert.Same(first, result.Error); + Assert.False(result.AccessDescriptorApplied); + Assert.Equal("ordinary", await File.ReadAllTextAsync(fixture.File.FullPath)); + } + + [Fact] + public async Task ProtectedLocalBoundaryRejectsForeignReparseDataAndWrongRootBinding() + { + await using Fixture fixture = await Fixture.StartAsync(); + await File.WriteAllTextAsync(fixture.File.FullPath, "original"); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + CloudLocalFileBinding foreignRoot = new(binding.VolumeSerialNumber, Guid.NewGuid(), binding.LocalFileId); + CloudProtectedLocalOperationResult wrongRoot = await fixture.File.RunProtectedLocalOperationAsync(new(foreignRoot), + (_, _) => throw new InvalidOperationException("A wrong root binding cannot be admitted.")); + Assert.Equal(CloudProtectedLocalOperationOutcome.LocalObjectMismatch, wrongRoot.Outcome); + string original = Path.Combine(fixture.Root, "original.bin"); + File.Move(fixture.File.FullPath, original); + // The final reparse component is opened as reparse data, never as its target. + File.CreateSymbolicLink(fixture.File.FullPath, original); + CloudProtectedLocalOperationResult reparse = await fixture.File.RunProtectedLocalOperationAsync(new(binding), + (_, _) => throw new InvalidOperationException("Foreign reparse data cannot enter a protected callback.")); + Assert.False(reparse.CallbackStarted); + Assert.Equal(CloudProtectedLocalOperationOutcome.NotApplicable, reparse.Outcome); + Assert.False(reparse.AccessDescriptorApplied); + Assert.Equal("original", await File.ReadAllTextAsync(original)); + File.Delete(fixture.File.FullPath); + } + + [Fact] + public async Task ProtectedLocalBoundaryPublicRenameAndReplacementCompetitorsCannotChangeTheOriginal() + { + await using Fixture fixture = await Fixture.StartAsync(); + await File.WriteAllTextAsync(fixture.File.FullPath, "original"); + string replacement = Path.Combine(fixture.Root, "replacement.bin"); + await File.WriteAllTextAsync(replacement, "replacement"); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + CloudProtectedLocalOperationResult result = await fixture.File.RunProtectedLocalOperationAsync(new(binding), async (scope, stop) => + { + foreach (string action in NamespaceProtectionCompetitors) + { + string target = action == "rename" ? Path.Combine(fixture.Root, "renamed.bin") : replacement; + using LocalCompetitor competitor = StartLocalCompetitor(fixture.File.FullPath, target, action); + await competitor.StartAsync(); + await competitor.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10), stop); + Assert.True(competitor.ExitCode == 32 || action == "replace" && competitor.ExitCode == 5, + $"Unexpected namespace competitor result: {action}={competitor.ExitCode}"); + Assert.Equal(binding, (await scope.InspectAsync(stop)).LocalBinding); + } + }); + Assert.True(result.Outcome == CloudProtectedLocalOperationOutcome.Completed, result.Error?.ToString()); + Assert.Equal("original", await File.ReadAllTextAsync(fixture.File.FullPath)); + Assert.Equal("replacement", await File.ReadAllTextAsync(replacement)); + } + + private static readonly string[] NamespaceProtectionCompetitors = ["rename", "replace"]; + + [Fact] + public async Task ProtectedLocalBoundaryRootMetadataNeedsNoPopulationOrSourceRead() + { + await using Fixture fixture = await Fixture.StartAsync(); + CloudDirectory root = fixture.System.Root; + CloudLocalFileBinding binding = Assert.IsType((await root.InspectAsync()).LocalBinding); + CloudProtectedLocalOperationResult result = await root.RunProtectedLocalOperationAsync( + new(binding, CloudProtectedLocalOperationMode.DirectoryMetadata), async (scope, stop) => + { + await scope.ApplyAccessDescriptorAsync(await scope.ReadAccessDescriptorAsync(stop), stop); + Assert.Equal(binding, (await root.InspectAsync(stop)).LocalBinding); + }); + Assert.True(result.Outcome == CloudProtectedLocalOperationOutcome.Completed, result.Error?.ToString()); + Assert.True(result.AccessDescriptorApplied); + Assert.Equal(0, fixture.Provider.Fetches); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ProtectedLocalBoundaryRetainsTheParentNamespaceOfItsOriginalObject(bool directory) + { + await using Fixture fixture = await Fixture.StartAsync(); + string parent = Path.Combine(fixture.Root, "Parent"); + Directory.CreateDirectory(parent); + CloudItem item; + if (directory) + { + Directory.CreateDirectory(Path.Combine(parent, "Child")); + item = fixture.System.GetDirectory("Parent\\Child"); + } + else + { + await File.WriteAllTextAsync(Path.Combine(parent, "child.bin"), "original"); + item = fixture.System.GetFile("Parent\\child.bin"); + } + CloudLocalFileBinding binding = Assert.IsType((await item.InspectAsync()).LocalBinding); + CloudProtectedLocalOperationResult result = await item.RunProtectedLocalOperationAsync( + new(binding, directory ? CloudProtectedLocalOperationMode.DirectoryMetadata : CloudProtectedLocalOperationMode.ExclusiveFile), + async (scope, stop) => + { + Assert.Throws(() => Directory.Move(parent, parent + "-renamed")); + Assert.Equal(binding, (await scope.InspectAsync(stop)).LocalBinding); + }); + Assert.True(result.Outcome == CloudProtectedLocalOperationOutcome.Completed, result.Error?.ToString()); + Assert.Equal(0, fixture.Provider.Fetches); + } +} diff --git a/tests/CfSharp.IntegrationTests/ProtectedLocalCancellationTests.cs b/tests/CfSharp.IntegrationTests/ProtectedLocalCancellationTests.cs new file mode 100644 index 0000000..a801038 --- /dev/null +++ b/tests/CfSharp.IntegrationTests/ProtectedLocalCancellationTests.cs @@ -0,0 +1,243 @@ +namespace CfSharp.IntegrationTests; + +public sealed partial class CloudContentConfirmationTests +{ + [Theory] + [InlineData("request")] + [InlineData("scope")] + [InlineData("step")] + public async Task ProtectedLocalCancellationRejectsNativeConversionBeforeLinkedTokensCatchUp(string origin) + { + FaultFactory? factory = null; + await using Fixture fixture = await Fixture.StartAsync(path => factory = new FaultFactory(path)); + await File.WriteAllTextAsync(fixture.File.FullPath, "original"); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + factory!.LimitFaultsToCurrentRequest(); + using CancellationTokenSource requestStop = new(); + using CancellationTokenSource stepStop = new(); + using ManualResetEventSlim release = new(false); + TaskCompletionSource handlerEntered = new(TaskCreationOptions.RunContinuationsAsynchronously); + CancellationTokenRegistration registration = default; + Task canceling = Task.CompletedTask; + try + { + CloudProtectedLocalOperationResult result = await fixture.File.RunProtectedLocalOperationAsync(new(binding), async (scope, stop) => + { + factory.BeforeTransaction = async () => + { + Assert.False(File.GetAttributes(fixture.File.FullPath).HasFlag(FileAttributes.ReparsePoint)); + CancellationToken original = origin == "request" ? requestStop.Token : origin == "scope" ? stop : stepStop.Token; + // Register after the scope's linked-token bridge so CancelAsync's LIFO + // handlers deterministically delay propagation while the source is canceled. + registration = original.Register(() => + { + handlerEntered.TrySetResult(); + release.Wait(); + }); + if (origin == "scope") + { + requestStop.Cancel(); + } + else + { + canceling = (origin == "request" ? requestStop : stepStop).CancelAsync(); + } + await handlerEntered.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.True(original.IsCancellationRequested); + }; + try + { + await Assert.ThrowsAnyAsync(() => scope.ConvertToPlaceholderAsync( + CloudPlaceholderIdentity.Create("cancel-before-native"), origin == "step" ? stepStop.Token : stop).AsTask()); + } + finally + { + release.Set(); + await canceling; + } + }, requestStop.Token); + Assert.Equal(CloudProtectedLocalOperationOutcome.Canceled, result.Outcome); + Assert.False(result.NativeConverted); + Assert.False(result.DurableProjectionCommitted); + Assert.True(result.Drained); + Assert.False((await fixture.File.InspectAsync()).IsPlaceholder); + Assert.Equal("original", await File.ReadAllTextAsync(fixture.File.FullPath)); + Assert.Equal(0, fixture.Provider.Fetches); + } + finally + { + release.Set(); + registration.Dispose(); + } + } + + [Theory] + [InlineData("caller")] + [InlineData("deadline")] + [InlineData("owner")] + public async Task ProtectedLocalCancellationIsObservableAndDrainsAnUncooperativeCallback(string reason) + { + FaultFactory? factory = null; + await using Fixture fixture = await Fixture.StartAsync(path => factory = new FaultFactory(path)); + await File.WriteAllTextAsync(fixture.File.FullPath, "original"); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + using CancellationTokenSource cancellation = new(); + TaskCompletionSource entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource signaled = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + CloudProtectedLocalOperationContext? observed = null; + CloudProtectedLocalOperationRequest request = new(binding); + if (reason == "deadline") + { + request = request.WithBudget(TimeSpan.FromSeconds(2)); + } + Task pending = fixture.File.RunProtectedLocalOperationAsync(request, async (scope, stop) => + { + observed = scope; + using CancellationTokenRegistration registration = stop.Register(() => signaled.TrySetResult()); + entered.SetResult(); + // Deliberately ignore cancellation until the test permits actual completion. + await release.Task; + }, cancellation.Token).AsTask(); + Task? shutdown = null; + try + { + await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)); + if (reason == "caller") + { + cancellation.Cancel(); + } + else if (reason == "owner") + { + shutdown = fixture.System.DisposeAsync().AsTask(); + } + await signaled.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.False(pending.IsCompleted); + Assert.True(observed!.IsDraining); + Assert.Equal(CloudProtectedLocalOperationStage.Draining, observed.CurrentStage); + await Assert.ThrowsAnyAsync(() => observed.InspectAsync().AsTask()); + Assert.Throws(() => File.WriteAllText(fixture.File.FullPath, "contender")); + await using ICloudStateTransaction alive = await factory!.Store.BeginTransactionAsync(); + Assert.Empty(await alive.Operations.ListAsync(4)); + } + finally + { + release.TrySetResult(); + } + CloudProtectedLocalOperationResult result = await pending.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(reason == "deadline" ? CloudProtectedLocalOperationOutcome.DeadlineExceeded : CloudProtectedLocalOperationOutcome.Canceled, result.Outcome); + Assert.True(result.CancellationRequested); + Assert.Equal(reason == "deadline", result.BudgetExpired); + Assert.True(result.CallbackCompleted); + Assert.True(result.Drained); + Assert.Equal(CloudProtectedLocalOperationStage.Released, observed!.CurrentStage); + Assert.False(observed.IsDraining); + if (reason == "deadline") + { + Assert.IsType(result.Error); + } + if (shutdown is not null) + { + await shutdown.WaitAsync(TimeSpan.FromSeconds(10)); + } + Assert.Equal("original", await File.ReadAllTextAsync(fixture.File.FullPath)); + Assert.Equal(0, fixture.Provider.Fetches); + } + + [Fact] + public async Task ProtectedLocalCancellationBudgetIncludesWaitingForPathAdmission() + { + await using Fixture fixture = await Fixture.StartAsync(); + await File.WriteAllTextAsync(fixture.File.FullPath, "original"); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + await using CloudItemLease lease = await fixture.File.AcquireLeaseAsync(CloudItemLeaseOptions.ExclusiveWrite); + CloudProtectedLocalOperationResult result = await fixture.File.RunProtectedLocalOperationAsync( + new CloudProtectedLocalOperationRequest(binding).WithBudget(TimeSpan.FromMilliseconds(100)), + (_, _) => throw new InvalidOperationException("Expired admission cannot enter a callback.")) + .AsTask().WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(CloudProtectedLocalOperationOutcome.DeadlineExceeded, result.Outcome); + Assert.Equal(CloudProtectedLocalOperationStage.Admission, result.Stage); + Assert.False(result.CallbackStarted); + Assert.True(result.Drained); + Assert.Null(result.Snapshot); + } + + [Fact] + public async Task ProtectedLocalCancellationHandlersAreDrainedWithoutPoisoningOwnerShutdown() + { + await using Fixture fixture = await Fixture.StartAsync(); + await File.WriteAllTextAsync(fixture.File.FullPath, "original"); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + TaskCompletionSource entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource callbackRelease = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource callbackExited = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource handlerEntered = new(TaskCreationOptions.RunContinuationsAsynchronously); + using ManualResetEventSlim handlerRelease = new(false); + InvalidOperationException error = new("Synthetic application cancellation handler failure."); + CancellationTokenRegistration registration = default; + Task pending = fixture.File.RunProtectedLocalOperationAsync(new(binding), async (_, stop) => + { + registration = stop.Register(() => + { + handlerEntered.SetResult(); + if (!handlerRelease.Wait(TimeSpan.FromSeconds(10))) + { + throw new TimeoutException("The test did not release its controlled cancellation handler."); + } + throw error; + }); + entered.SetResult(); + await callbackRelease.Task; + callbackExited.SetResult(); + }).AsTask(); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Task shutdown = fixture.System.DisposeAsync().AsTask(); + try + { + await handlerEntered.Task.WaitAsync(TimeSpan.FromSeconds(10)); + callbackRelease.SetResult(); + await callbackExited.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(CloudFileSystemLifecycleState.Stopping, fixture.System.LifecycleState); + Assert.False(shutdown.IsCompleted); + Assert.False(pending.IsCompleted); + Assert.Throws(() => File.WriteAllText(fixture.File.FullPath, "contender")); + Assert.Throws(() => fixture.System.GetFile("new.bin")); + } + finally + { + callbackRelease.TrySetResult(); + handlerRelease.Set(); + } + CloudProtectedLocalOperationResult result = await pending.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(CloudProtectedLocalOperationOutcome.Canceled, result.Outcome); + Assert.Same(error, Assert.IsType(result.CancellationCallbackError).InnerException); + await shutdown.WaitAsync(TimeSpan.FromSeconds(10)); + registration.Dispose(); + Assert.Equal(CloudFileSystemLifecycleState.Disposed, fixture.System.LifecycleState); + Assert.Equal("original", await File.ReadAllTextAsync(fixture.File.FullPath)); + } + + [Fact] + public async Task ProtectedLocalCancellationAfterDescriptorApplicationRetainsNativeReceipts() + { + await using Fixture fixture = await Fixture.StartAsync(); + await File.WriteAllTextAsync(fixture.File.FullPath, "original"); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + using CancellationTokenSource cancellation = new(); + CloudProtectedLocalOperationResult result = await fixture.File.RunProtectedLocalOperationAsync( + CloudProtectedLocalOperationRequest.ForLocalConversion(binding, CloudPlaceholderIdentity.Create("local-cancel-dacl")), + async (scope, stop) => + { + await scope.ApplyAccessDescriptorAsync(await scope.ReadAccessDescriptorAsync(stop), stop); + cancellation.Cancel(); + }, cancellation.Token); + Assert.Equal(CloudProtectedLocalOperationOutcome.Canceled, result.Outcome); + Assert.True(result.NativeConverted); + Assert.True(result.DurableProjectionCommitted); + Assert.True(result.AccessDescriptorApplied); + Assert.True(result.AccessDescriptorReadBack); + Assert.True(result.CallbackCompleted); + Assert.True(result.Drained); + Assert.Equal(0, fixture.Provider.Fetches); + } +} diff --git a/tests/CfSharp.IntegrationTests/ProtectedLocalConversionTests.cs b/tests/CfSharp.IntegrationTests/ProtectedLocalConversionTests.cs new file mode 100644 index 0000000..3211471 --- /dev/null +++ b/tests/CfSharp.IntegrationTests/ProtectedLocalConversionTests.cs @@ -0,0 +1,260 @@ +using System.Security.AccessControl; + +namespace CfSharp.IntegrationTests; + +public sealed partial class CloudContentConfirmationTests +{ + private static readonly string[] LocalProtectionCompetitors = ["overwrite", "link"]; + + [Fact] + public async Task ProtectedLocalConversionDoesNotApplyDaclAfterHandledProjectionFailure() + { + FaultFactory? factory = null; + await using Fixture fixture = await Fixture.StartAsync(path => factory = new FaultFactory(path)); + await File.WriteAllTextAsync(fixture.File.FullPath, "original"); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + byte[] original = new FileInfo(fixture.File.FullPath).GetAccessControl(AccessControlSections.Access).GetSecurityDescriptorBinaryForm(); + ProjectionFault fault = new(); + CloudProtectedLocalOperationResult result = await fixture.File.RunProtectedLocalOperationAsync(new(binding), async (scope, stop) => + { + FileSystemSecurity descriptor = await scope.ReadAccessDescriptorAsync(stop); + factory!.NextCommitFault = fault; + Assert.Same(fault, await Record.ExceptionAsync(() => scope.ConvertToPlaceholderAsync( + CloudPlaceholderIdentity.Create("local-uncommitted"), stop).AsTask())); + Assert.NotNull(await Record.ExceptionAsync(() => scope.ApplyAccessDescriptorAsync(descriptor, stop).AsTask())); + }); + Assert.Equal(CloudProtectedLocalOperationOutcome.NativeAppliedProjectionPending, result.Outcome); + Assert.Equal(CloudProtectedLocalOperationStage.Projection, result.Stage); + Assert.Same(fault, result.Error); + Assert.True(result.CallbackCompleted); + Assert.True(result.NativeConverted); + Assert.False(result.DurableProjectionCommitted); + Assert.False(result.AccessDescriptorApplied); + Assert.False(result.AccessDescriptorReadBack); + Assert.Equal(original, new FileInfo(fixture.File.FullPath).GetAccessControl(AccessControlSections.Access).GetSecurityDescriptorBinaryForm()); + Assert.Equal("original", await File.ReadAllTextAsync(fixture.File.FullPath)); + } + [Theory] + [InlineData(false, 0)] + [InlineData(false, 19)] + [InlineData(true, 0)] + [InlineData(true, 19)] + public async Task ProtectedLocalConversionPreservesBytesBindingAndDacl(bool prepareBeforeCallback, int length) + { + await using Fixture fixture = await Fixture.StartAsync(); + byte[] content = Enumerable.Range(0, length).Select(index => (byte)index).ToArray(); + await File.WriteAllBytesAsync(fixture.File.FullPath, content); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + byte[] originalAccess = new FileInfo(fixture.File.FullPath).GetAccessControl(AccessControlSections.Access).GetSecurityDescriptorBinaryForm(); + CloudPlaceholderIdentity identity = CloudPlaceholderIdentity.Create("local-unuploaded"); + CloudProtectedLocalOperationRequest request = prepareBeforeCallback + ? CloudProtectedLocalOperationRequest.ForLocalConversion(binding, identity) : new(binding); + CloudProtectedLocalOperationResult result = await fixture.File.RunProtectedLocalOperationAsync(request, async (scope, token) => + { + if (!prepareBeforeCallback) + { + // Existing public conversion is dispatched to the active same-object scope. + await fixture.File.ConvertToPlaceholderAsync(identity, cancellationToken: token); + } + CloudItemSnapshot snapshot = await scope.InspectAsync(token); + Assert.True(snapshot.IsPlaceholder); + Assert.Equal(identity.Encode(), snapshot.PlaceholderIdentity.ToArray()); + Assert.Equal(binding, snapshot.LocalBinding); + Assert.NotEqual(CloudSynchronizationState.InSync, snapshot.SynchronizationState); + Assert.Null(snapshot.RemoteRevision); + foreach (string action in LocalProtectionCompetitors) + { + using LocalCompetitor competitor = StartLocalCompetitor(fixture.File.FullPath, Path.Combine(fixture.Root, "alias.bin"), action); + await competitor.StartAsync(); + await competitor.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10), token); + Assert.Equal(action == "link" ? 396 : 32, competitor.ExitCode); + } + await Task.Yield(); + Assert.True((await fixture.File.InspectAsync(token)).IsPlaceholder); + }); + Assert.Equal(CloudProtectedLocalOperationOutcome.Completed, result.Outcome); + Assert.True(result.NativeIdentityPrepared); + Assert.True(result.NativeConverted); + Assert.True(result.DurableProjectionCommitted); + Assert.Equal(0, result.PreparationHResult); + Assert.NotNull(result.PreparationUsn); + Assert.Equal(content, await File.ReadAllBytesAsync(fixture.File.FullPath)); + Assert.Equal(originalAccess, new FileInfo(fixture.File.FullPath).GetAccessControl(AccessControlSections.Access).GetSecurityDescriptorBinaryForm()); + Assert.Equal(0, fixture.Provider.Fetches); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ProtectedLocalConversionRecoversOnlyTheOriginalObjectAfterTwoStoreRestarts(bool replace) + { + FaultFactory? factory = null; + await using Fixture fixture = await Fixture.StartAsync(path => factory = new FaultFactory(path)); + await File.WriteAllTextAsync(fixture.File.FullPath, "unuploaded"); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + CloudPlaceholderIdentity identity = CloudPlaceholderIdentity.Create("local-retry"); + CloudProtectedLocalOperationRequest request = CloudProtectedLocalOperationRequest.ForLocalConversion(binding, identity); + ProjectionFault fault = new(); + factory!.NextCommitFault = fault; + CloudProtectedLocalOperationResult pending = await fixture.File.RunProtectedLocalOperationAsync(request, + (_, _) => throw new InvalidOperationException("Failed preparation must not invoke application work.")); + Assert.Equal(CloudProtectedLocalOperationOutcome.NativeAppliedProjectionPending, pending.Outcome); + Assert.Equal(CloudProtectedLocalOperationStage.Projection, pending.Stage); + Assert.Same(fault, pending.Error); + Assert.True(pending.NativeConverted); + Assert.True(pending.NativeIdentityPrepared); + Assert.False(pending.DurableProjectionCommitted); + Assert.False(pending.CallbackStarted); + Assert.True(pending.Snapshot!.IsPlaceholder); + Assert.Null((await fixture.File.InspectAsync()).ItemId); + await fixture.RestartAsync(); + await fixture.RestartAsync(); + if (replace) + { + File.Move(fixture.File.FullPath, Path.Combine(fixture.Root, "original.bin")); + await File.WriteAllTextAsync(fixture.File.FullPath, "replacement"); + } + CloudProtectedLocalOperationResult retried = await fixture.File.RunProtectedLocalOperationAsync(request, + (_, _) => ValueTask.CompletedTask); + Assert.Equal(replace ? CloudProtectedLocalOperationOutcome.LocalObjectMismatch : CloudProtectedLocalOperationOutcome.Completed, retried.Outcome); + Assert.False(retried.NativeConverted); + Assert.Equal(!replace, retried.DurableProjectionCommitted); + CloudItemSnapshot after = await fixture.File.InspectAsync(); + Assert.Equal(replace ? null : identity.ItemId, after.ItemId); + Assert.Null(after.RemoteRevision); + Assert.NotEqual(CloudSynchronizationState.InSync, after.SynchronizationState); + Assert.Equal(replace ? "replacement" : "unuploaded", await File.ReadAllTextAsync(fixture.File.FullPath)); + Assert.Equal(0, fixture.Provider.Fetches); + } + + [Fact] + public async Task ProtectedLocalConversionReportsARealCommitEvenWhenTransactionDisposalFails() + { + FaultFactory? factory = null; + await using Fixture fixture = await Fixture.StartAsync(path => factory = new FaultFactory(path)); + await File.WriteAllTextAsync(fixture.File.FullPath, "original"); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + CloudPlaceholderIdentity identity = CloudPlaceholderIdentity.Create("local-committed"); + ProjectionFault fault = new(); + factory!.BeforeCommit = () => + { + factory.NextDisposeFault = fault; + return ValueTask.CompletedTask; + }; + CloudProtectedLocalOperationResult result = await fixture.File.RunProtectedLocalOperationAsync( + CloudProtectedLocalOperationRequest.ForLocalConversion(binding, identity), (_, _) => ValueTask.CompletedTask); + Assert.Equal(CloudProtectedLocalOperationOutcome.Failed, result.Outcome); + Assert.True(result.NativeConverted); + Assert.True(result.DurableProjectionCommitted); + Assert.False(result.CallbackStarted); + Assert.Same(fault, result.Error); + await fixture.RestartAsync(); + Assert.Equal(identity.ItemId, (await fixture.File.InspectAsync()).ItemId); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ProtectedLocalConversionCancellationRespectsTheNativeBoundary(bool afterNative) + { + FaultFactory? factory = null; + await using Fixture fixture = await Fixture.StartAsync(path => factory = new FaultFactory(path)); + await File.WriteAllTextAsync(fixture.File.FullPath, "original"); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + CloudPlaceholderIdentity identity = CloudPlaceholderIdentity.Create("local-cancel"); + using CancellationTokenSource cancellation = new(); + factory!.LimitFaultsToCurrentRequest(); + bool canceledBeforeNative = false; + if (afterNative) + { + factory!.BeforeCommit = () => + { + cancellation.Cancel(); + return ValueTask.CompletedTask; + }; + } + else + { + factory!.BeforeTransaction = () => + { + factory.BeforeTransaction = () => + { + factory.AfterTransactionDispose = () => + { + canceledBeforeNative = !File.GetAttributes(fixture.File.FullPath).HasFlag(FileAttributes.ReparsePoint); + cancellation.Cancel(); + }; + return ValueTask.CompletedTask; + }; + return ValueTask.CompletedTask; + }; + } + Task unrelated; + using (ExecutionContext.SuppressFlow()) + { + unrelated = Task.Run(async () => + { + for (int index = 0; index < 2; index++) + { + await using ICloudStateTransaction transaction = await factory.Store.BeginTransactionAsync(); + Assert.Empty(await transaction.Operations.ListAsync(1)); + } + }); + } + await unrelated; + Assert.False(cancellation.IsCancellationRequested); + CloudProtectedLocalOperationResult result = await fixture.File.RunProtectedLocalOperationAsync( + CloudProtectedLocalOperationRequest.ForLocalConversion(binding, identity), (_, _) => ValueTask.CompletedTask, cancellation.Token); + Assert.Equal(CloudProtectedLocalOperationOutcome.Canceled, result.Outcome); + Assert.True(cancellation.IsCancellationRequested); + Assert.Equal(!afterNative, canceledBeforeNative); + Assert.Equal(afterNative, result.NativeConverted); + Assert.Equal(afterNative, result.DurableProjectionCommitted); + Assert.False(result.CallbackStarted); + Assert.Equal(afterNative, (await fixture.File.InspectAsync()).IsPlaceholder); + Assert.Equal("original", await File.ReadAllTextAsync(fixture.File.FullPath)); + } + + [Fact] + public async Task ProtectedLocalConversionPreservesKnownStateAndPendingWorkAndRejectsRekeying() + { + FaultFactory? factory = null; + await using Fixture fixture = await Fixture.StartAsync(path => factory = new FaultFactory(path)); + await File.WriteAllTextAsync(fixture.File.FullPath, "pending-local"); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + CloudPlaceholderIdentity identity = CloudPlaceholderIdentity.Create("known-local"); + DateTimeOffset stamp = DateTimeOffset.UtcNow; + CloudOperationJournalEntry original; + await using (ICloudStateTransaction seed = await factory!.Store.BeginTransactionAsync()) + { + await seed.Items.UpsertAsync(new(identity.ItemId, identity.RemoteId, fixture.File.RelativePath, + CloudItemKind.File, "previous-acknowledgement", 321, false, stamp)); + original = await seed.Operations.EnqueueAsync(new(Guid.NewGuid(), CloudStateOperationKind.ContentUpdate, + identity.ItemId, [1, 2, 3], stamp, 3, stamp.AddMinutes(1))); + await seed.Checkpoints.UpsertAsync(new("local-feed/rescan", [4, 5], stamp)); + await seed.CommitAsync(); + } + CloudProtectedLocalOperationResult rejected = await fixture.File.RunProtectedLocalOperationAsync( + CloudProtectedLocalOperationRequest.ForLocalConversion(binding, CloudPlaceholderIdentity.Create("different-local")), + (_, _) => ValueTask.CompletedTask); + Assert.Equal(CloudProtectedLocalOperationOutcome.NotApplicable, rejected.Outcome); + Assert.False(rejected.NativeConverted); + Assert.False(rejected.CallbackStarted); + CloudProtectedLocalOperationResult result = await fixture.File.RunProtectedLocalOperationAsync( + CloudProtectedLocalOperationRequest.ForLocalConversion(binding, identity), (_, _) => ValueTask.CompletedTask); + Assert.Equal(CloudProtectedLocalOperationOutcome.Completed, result.Outcome); + await fixture.RestartAsync(); + await using ICloudStateTransaction verify = await factory!.Store.BeginTransactionAsync(); + CloudItemState item = Assert.IsType(await verify.Items.GetByItemIdAsync(identity.ItemId)); + Assert.Equal("previous-acknowledgement", item.RemoteRevision); + Assert.Equal(321, item.LocalFileId); + CloudOperationJournalEntry retained = Assert.IsType(await verify.Operations.GetAsync(original.OperationId)); + Assert.Equal(original.Sequence, retained.Sequence); + Assert.Equal(original.Payload.ToArray(), retained.Payload.ToArray()); + Assert.Equal(original.CreatedAt, retained.CreatedAt); + Assert.Equal(original.AttemptCount, retained.AttemptCount); + Assert.Equal(original.RetryAfter, retained.RetryAfter); + Assert.Equal(new byte[] { 4, 5 }, (await verify.Checkpoints.GetAsync("local-feed/rescan"))!.Value.ToArray()); + Assert.Equal(0, fixture.Provider.Fetches); + } +} diff --git a/tests/CfSharp.IntegrationTests/ProtectedLocalNativePrerequisiteTests.cs b/tests/CfSharp.IntegrationTests/ProtectedLocalNativePrerequisiteTests.cs new file mode 100644 index 0000000..1916f3a --- /dev/null +++ b/tests/CfSharp.IntegrationTests/ProtectedLocalNativePrerequisiteTests.cs @@ -0,0 +1,267 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.IO.MemoryMappedFiles; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; + +using Microsoft.Win32.SafeHandles; + +namespace CfSharp.IntegrationTests; + +public sealed partial class CloudContentConfirmationTests +{ + [Theory] + [InlineData("overwrite")] + [InlineData("rename")] + [InlineData("replace")] + [InlineData("link")] + public async Task ProtectedLocalNativeConversionRetainsExclusiveObjectAcrossCompetitors(string action) + { + await using Fixture fixture = await Fixture.StartAsync(); + byte[] content = "original unuploaded local bytes"u8.ToArray(); + await File.WriteAllBytesAsync(fixture.File.FullPath, content); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + byte[] originalDacl = new FileInfo(fixture.File.FullPath).GetAccessControl(AccessControlSections.Access).GetSecurityDescriptorBinaryForm(); + string target = Path.Combine(fixture.Root, "competitor.bin"); + if (action == "replace") + { + await File.WriteAllTextAsync(target, "replacement"); + } + + using SafeFileHandle access = CloudLocalAccessObject.Open(fixture.File.FullPath); + using LocalCompetitor competitor = StartLocalCompetitor(fixture.File.FullPath, target, action); + try + { + CloudPlaceholderIdentity identity = new(Guid.NewGuid(), "local-unuploaded"); + long usn = CloudPlaceholderMutationPlatform.Convert(access.DangerousGetHandle(), fixture.File.FullPath, + identity.Encode(), CloudPlaceholderConversionOptions.Default); + Assert.Equal(binding, CloudLocalFileBindingPlatform.Read(access.DangerousGetHandle(), fixture.Root)); + LocalAccessSecurity descriptor = new(access); + Assert.Equal(originalDacl, descriptor.GetSecurityDescriptorBinaryForm()); + descriptor.Apply(access); + + await competitor.StartAsync(); + // Require the independent kernel operation to finish while the original + // exclusive object remains open. Absence of progress is not protection proof. + await competitor.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + Assert.True(competitor.ExitCode is 32 or 33 or 396 || action == "replace" && competitor.ExitCode == 5, + $"Competitor completed inside protection: {competitor.ExitCode}"); + + CloudItemSnapshot held = await fixture.File.InspectAsync(); + LocalCloudItemInspection sameObject = CloudItemInspector.Inspect(access, fixture.File.FullPath, + fixture.File.Kind, fixture.Root); + Assert.Equal(held.LocalBinding, sameObject.LocalBinding); + Assert.Equal(held.Length, sameObject.Length); + Assert.Equal(held.CreationTime, sameObject.CreationTime); + Assert.Equal(held.PlaceholderIdentity.ToArray(), sameObject.PlaceholderIdentity); + Assert.Equal(binding, held.LocalBinding); + Assert.True(held.IsPlaceholder); + Assert.Equal(content.Length, held.Length); + Assert.Equal(CloudSynchronizationState.NotInSync, held.SynchronizationState); + Assert.Equal(identity.Encode(), held.PlaceholderIdentity.ToArray()); + AssertSameAccessDacl(originalDacl, new LocalAccessSecurity(access).GetSecurityDescriptorBinaryForm()); + Assert.Equal(0, fixture.Provider.Fetches); + output.WriteLine($"OS={Environment.OSVersion.Version}; Architecture={RuntimeInformation.ProcessArchitecture}; Action={action}; ConvertUsn={usn}; HeldExit={(competitor.HasExited ? competitor.ExitCode : null)}"); + + access.Dispose(); + await competitor.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + if (action == "link") + { + Assert.Equal(396, competitor.ExitCode); + Assert.False(File.Exists(target)); + Assert.Equal(content, await File.ReadAllBytesAsync(fixture.File.FullPath)); + } + else if (action == "replace" && competitor.ExitCode == 5) + { + // MoveFileEx may reject replacement of a placeholder with AccessDenied. + // Record that policy result rather than attributing it to a sharing conflict. + Assert.Equal(content, await File.ReadAllBytesAsync(fixture.File.FullPath)); + } + else if (competitor.ExitCode is 32 or 33) + { + Assert.Equal(content, await File.ReadAllBytesAsync(fixture.File.FullPath)); + using LocalCompetitor retry = StartLocalCompetitor(fixture.File.FullPath, target, action); + await retry.StartAsync(); + await retry.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(0, retry.ExitCode); + } + else + { + Assert.Equal(0, competitor.ExitCode); + } + } + finally + { + access.Dispose(); + } + } + + [Theory] + [InlineData("ordinary")] + [InlineData("cold")] + [InlineData("directory")] + public async Task ProtectedLocalNativeAccessGuardPreservesObjectWithoutHydration(string kind) + { + await using Fixture fixture = await Fixture.StartAsync(); + CloudItem item; + if (kind == "cold") + { + await fixture.System.Root.CreatePlaceholderAsync(CloudFilePlaceholderSpec.CreateBuilder("content.bin", "cold-local", 4096).Build()); + item = fixture.File; + } + else if (kind == "directory") + { + Directory.CreateDirectory(Path.Combine(fixture.Root, "directory")); + item = fixture.System.GetDirectory("directory"); + } + else + { + await File.WriteAllTextAsync(fixture.File.FullPath, "local-only"); + item = fixture.File; + } + + CloudItemSnapshot before = await item.InspectAsync(); + CloudLocalFileBinding binding = Assert.IsType(before.LocalBinding); + using SafeFileHandle access = CloudLocalAccessObject.Open(item.FullPath, kind == "directory"); + Assert.Equal(binding, CloudLocalFileBindingPlatform.Read(access.DangerousGetHandle(), fixture.Root)); + LocalAccessSecurity descriptor = new(access, kind == "directory"); + byte[] original = descriptor.GetSecurityDescriptorBinaryForm(); + await Task.Delay(20); + descriptor.Apply(access); + AssertSameAccessDacl(original, new LocalAccessSecurity(access, kind == "directory").GetSecurityDescriptorBinaryForm()); + Assert.Equal(binding, (await item.InspectAsync()).LocalBinding); + Assert.Equal(0, fixture.Provider.Fetches); + if (kind == "cold") + { + Assert.Equal(CloudContentAvailability.OnlineOnly, (await item.InspectAsync()).ContentAvailability); + } + + output.WriteLine($"OS={Environment.OSVersion.Version}; Architecture={RuntimeInformation.ProcessArchitecture}; Kind={kind}; SourceReads={fixture.Provider.Fetches}; SameObjectAccessSucceeded=True"); + } + + [Fact] + public async Task ProtectedLocalNativeExistingAliasesAreRejectedBeforeMutation() + { + await using Fixture fixture = await Fixture.StartAsync(); + byte[] content = "local-only"u8.ToArray(); + await File.WriteAllBytesAsync(fixture.File.FullPath, content); + string alias = Path.Combine(fixture.Root, "alias.bin"); + using LocalCompetitor link = StartLocalCompetitor(fixture.File.FullPath, alias, "link"); + await link.StartAsync(); + await link.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(0, link.ExitCode); + Assert.Throws(() => new CloudProtectedContentSession(fixture.File.FullPath, fixture.Root)); + Assert.False((await fixture.File.InspectAsync()).IsPlaceholder); + Assert.Equal(content, await File.ReadAllBytesAsync(alias)); + Assert.Equal(content, await File.ReadAllBytesAsync(fixture.File.FullPath)); + Assert.Equal(0, fixture.Provider.Fetches); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ProtectedLocalNativeExclusiveAccessRejectsExistingWriterOrMapping(bool mapped) + { + await using Fixture fixture = await Fixture.StartAsync(); + await File.WriteAllTextAsync(fixture.File.FullPath, "local-only"); + using FileStream writer = new(fixture.File.FullPath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete); + using MemoryMappedFile? mapping = mapped + ? MemoryMappedFile.CreateFromFile(writer, null, 0, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, leaveOpen: true) : null; + using MemoryMappedViewAccessor? view = mapping?.CreateViewAccessor(); + if (mapped) + { + writer.Dispose(); + } + Win32Exception error = Assert.Throws(() => CloudLocalAccessObject.Open(fixture.File.FullPath)); + Assert.Equal(32, error.NativeErrorCode); + Assert.Equal(0, fixture.Provider.Fetches); + } + + private static void AssertSameAccessDacl(byte[] expected, byte[] actual) + { + // SetSecurityInfo may normalize SE_DACL_AUTO_INHERITED and returned owner/group + // fields. Compare the complete ordered ACL and its protection bit, rather than + // unrelated descriptor offsets or the kernel's automatic-inheritance bookkeeping. + RawSecurityDescriptor before = new(expected, 0); + RawSecurityDescriptor after = new(actual, 0); + Assert.Equal(before.ControlFlags & ControlFlags.DiscretionaryAclProtected, + after.ControlFlags & ControlFlags.DiscretionaryAclProtected); + RawAcl original = Assert.IsType(before.DiscretionaryAcl); + RawAcl observed = Assert.IsType(after.DiscretionaryAcl); + byte[] originalBytes = new byte[original.BinaryLength]; + byte[] observedBytes = new byte[observed.BinaryLength]; + original.GetBinaryForm(originalBytes, 0); + observed.GetBinaryForm(observedBytes, 0); + Assert.Equal(originalBytes, observedBytes); + } + + private sealed class LocalAccessSecurity : NativeObjectSecurity + { + internal LocalAccessSecurity(SafeFileHandle handle, bool directory = false) + : base(directory, ResourceType.FileObject, handle, AccessControlSections.Access) { } + internal void Apply(SafeFileHandle handle) => Persist(handle, AccessControlSections.Access); + public override Type AccessRightType => typeof(FileSystemRights); + public override Type AccessRuleType => typeof(FileSystemAccessRule); + public override Type AuditRuleType => typeof(FileSystemAuditRule); + public override AccessRule AccessRuleFactory(IdentityReference identityReference, int accessMask, bool isInherited, + InheritanceFlags inheritanceFlags, PropagationFlags propagationFlags, AccessControlType type) => + new FileSystemAccessRule(identityReference, (FileSystemRights)accessMask, inheritanceFlags, propagationFlags, type); + public override AuditRule AuditRuleFactory(IdentityReference identityReference, int accessMask, bool isInherited, + InheritanceFlags inheritanceFlags, PropagationFlags propagationFlags, AuditFlags flags) => + new FileSystemAuditRule(identityReference, (FileSystemRights)accessMask, inheritanceFlags, propagationFlags, flags); + } + + private static LocalCompetitor StartLocalCompetitor(string path, string target, string action) + { + string name = "Local\\CfSharp-local-" + Guid.NewGuid().ToString("N"); + EventWaitHandle ready = new(false, EventResetMode.ManualReset, name); + EventWaitHandle go = new(false, EventResetMode.ManualReset, name + "-go"); + EventWaitHandle attempted = new(false, EventResetMode.ManualReset, name + "-attempted"); + ProcessStartInfo start = new("dotnet") { UseShellExecute = false, CreateNoWindow = true }; + start.ArgumentList.Add(GetHarnessPath()); + start.ArgumentList.Add(path); + start.ArgumentList.Add(name); + start.ArgumentList.Add("protected-local-" + action); + start.ArgumentList.Add(target); + return new LocalCompetitor(Process.Start(start)!, ready, go, attempted); + } + + private sealed class LocalCompetitor(Process process, EventWaitHandle ready, EventWaitHandle go, EventWaitHandle attempted) : IDisposable + { + internal bool HasExited => process.HasExited; + internal int ExitCode => process.ExitCode; + internal Task WaitForExitAsync() => process.WaitForExitAsync(); + internal async Task StartAsync() + { + await WaitAsync(ready); + go.Set(); + await WaitAsync(attempted); + } + private async Task WaitAsync(EventWaitHandle signal) + { + long start = Stopwatch.GetTimestamp(); + while (!signal.WaitOne(0) && Stopwatch.GetElapsedTime(start) < TimeSpan.FromSeconds(10)) + { + if (process.HasExited) + { + Assert.Fail($"Competitor exited early: {process.ExitCode}"); + } + await Task.Delay(10); + } + Assert.True(signal.WaitOne(0)); + } + public void Dispose() + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + process.WaitForExit(); + } + process.Dispose(); + ready.Dispose(); + go.Dispose(); + attempted.Dispose(); + } + } +} diff --git a/tests/CfSharp.IntegrationTests/ProtectedLocalOperationTests.cs b/tests/CfSharp.IntegrationTests/ProtectedLocalOperationTests.cs new file mode 100644 index 0000000..15c90b9 --- /dev/null +++ b/tests/CfSharp.IntegrationTests/ProtectedLocalOperationTests.cs @@ -0,0 +1,202 @@ +using System.IO.MemoryMappedFiles; + +namespace CfSharp.IntegrationTests; + +public sealed partial class CloudContentConfirmationTests +{ + [Fact] + public async Task ProtectedLocalCallbackDrainsAnAdmittedInspectionEvenWhenItWasNotAwaited() + { + FaultFactory? factory = null; + await using Fixture fixture = await Fixture.StartAsync(path => factory = new FaultFactory(path)); + await File.WriteAllTextAsync(fixture.File.FullPath, "original"); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + TaskCompletionSource inspecting = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource callbackExited = new(TaskCreationOptions.RunContinuationsAsynchronously); + Task? child = null; + Task pending = fixture.File.RunProtectedLocalOperationAsync(new(binding), (scope, token) => + { + factory!.BeforeTransaction = () => + { + inspecting.SetResult(); + return new(release.Task); + }; + child = scope.InspectAsync(token).AsTask(); + callbackExited.SetResult(); + return ValueTask.CompletedTask; + }).AsTask(); + try + { + await inspecting.Task.WaitAsync(TimeSpan.FromSeconds(10)); + await callbackExited.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.False(pending.IsCompleted); + Assert.Throws(() => File.WriteAllText(fixture.File.FullPath, "contender")); + } + finally + { + release.TrySetResult(); + } + Assert.Equal(CloudProtectedLocalOperationOutcome.Completed, (await pending).Outcome); + Assert.Equal(binding, (await child!).LocalBinding); + await File.WriteAllTextAsync(fixture.File.FullPath, "released"); + } + + [Fact] + public async Task ProtectedLocalCallbackRetainsOriginalObjectAcrossAwaitAndRejectsWriter() + { + await using Fixture fixture = await Fixture.StartAsync(); + byte[] content = "local-unuploaded"u8.ToArray(); + await File.WriteAllBytesAsync(fixture.File.FullPath, content); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + CloudProtectedLocalOperationResult result = await fixture.File.RunProtectedLocalOperationAsync(new(binding), async (scope, token) => + { + Assert.Equal(binding, (await scope.InspectAsync(token)).LocalBinding); + using LocalCompetitor competitor = StartLocalCompetitor(fixture.File.FullPath, Path.Combine(fixture.Root, "other.bin"), "overwrite"); + await competitor.StartAsync(); + await competitor.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10), token); + Assert.Equal(32, competitor.ExitCode); + await Task.Yield(); + Assert.Equal(binding, (await fixture.File.InspectAsync(token)).LocalBinding); + Assert.Equal(content.Length, (await scope.InspectAsync(token)).Length); + }); + Assert.Equal(CloudProtectedLocalOperationOutcome.Completed, result.Outcome); + Assert.True(result.CallbackStarted); + Assert.True(result.CallbackCompleted); + Assert.True(result.Drained); + Assert.Equal(binding, result.Snapshot!.LocalBinding); + Assert.Equal(content, await File.ReadAllBytesAsync(fixture.File.FullPath)); + await File.WriteAllTextAsync(fixture.File.FullPath, "editable after release"); + Assert.Equal(0, fixture.Provider.Fetches); + } + + [Fact] + public async Task ProtectedLocalCallbackRejectsReplacementBindingAndDirectoryModeOnFile() + { + await using Fixture fixture = await Fixture.StartAsync(); + await File.WriteAllTextAsync(fixture.File.FullPath, "original"); + CloudLocalFileBinding original = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + File.Move(fixture.File.FullPath, Path.Combine(fixture.Root, "original.bin")); + await File.WriteAllTextAsync(fixture.File.FullPath, "replacement"); + int callbacks = 0; + CloudProtectedLocalOperationResult result = await fixture.File.RunProtectedLocalOperationAsync(new(original), (_, _) => + { + callbacks++; + return ValueTask.CompletedTask; + }); + Assert.Equal(CloudProtectedLocalOperationOutcome.LocalObjectMismatch, result.Outcome); + CloudLocalFileBinding replacement = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + result = await fixture.File.RunProtectedLocalOperationAsync(new(replacement, CloudProtectedLocalOperationMode.DirectoryMetadata), (_, _) => + { + callbacks++; + return ValueTask.CompletedTask; + }); + Assert.Equal(CloudProtectedLocalOperationOutcome.Unsupported, result.Outcome); + Assert.Equal(0, callbacks); + Assert.Equal("replacement", await File.ReadAllTextAsync(fixture.File.FullPath)); + Assert.Equal(0, fixture.Provider.Fetches); + } + + [Fact] + public async Task ProtectedLocalCallbackRejectsScopeEscapeReentryAndSelfDisposal() + { + await using Fixture fixture = await Fixture.StartAsync(); + await File.WriteAllTextAsync(fixture.File.FullPath, "original"); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + CloudProtectedLocalOperationContext? escaped = null; + CloudFile other = fixture.System.GetFile("other.bin"); + CloudProtectedLocalOperationResult result = await fixture.File.RunProtectedLocalOperationAsync(new(binding), async (scope, token) => + { + escaped = scope; + await Assert.ThrowsAsync(() => other.InspectAsync().AsTask()); + await Assert.ThrowsAsync(() => fixture.System.DisposeAsync().AsTask()); + await Assert.ThrowsAsync(() => fixture.File.RunProtectedLocalOperationAsync(new(binding), + (_, _) => ValueTask.CompletedTask).AsTask()); + Assert.Equal(binding, (await fixture.File.InspectAsync(token)).LocalBinding); + }); + Assert.Equal(CloudProtectedLocalOperationOutcome.Completed, result.Outcome); + Assert.NotNull(escaped); + await Assert.ThrowsAsync(() => escaped.InspectAsync().AsTask()); + await File.WriteAllTextAsync(fixture.File.FullPath, "released"); + } + + [Theory] + [InlineData("io")] + [InlineData("native")] + [InlineData("cancellation")] + public async Task ProtectedLocalCallbackPreservesFailureAndReleasesAllAdmission(string kind) + { + await using Fixture fixture = await Fixture.StartAsync(); + await File.WriteAllTextAsync(fixture.File.FullPath, "original"); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + Exception error = kind switch + { + "native" => new System.ComponentModel.Win32Exception(32), + "cancellation" => new OperationCanceledException("Application-owned cancellation."), + _ => new IOException("Synthetic callback failure."), + }; + CloudProtectedLocalOperationResult result = await fixture.File.RunProtectedLocalOperationAsync(new(binding), (_, _) => throw error); + Assert.Equal(CloudProtectedLocalOperationOutcome.CallbackFailed, result.Outcome); + Assert.Equal(CloudProtectedLocalOperationStage.Callback, result.Stage); + Assert.Same(error, result.Error); + Assert.False(result.CallbackCompleted); + await File.WriteAllTextAsync(fixture.File.FullPath, "released"); + Assert.Equal(binding, (await fixture.File.InspectAsync()).LocalBinding); + } + + [Fact] + public async Task ProtectedLocalOwnerShutdownDrainsAdmittedCallbackAndRejectsNewWork() + { + await using Fixture fixture = await Fixture.StartAsync(); + await File.WriteAllTextAsync(fixture.File.FullPath, "original"); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + TaskCompletionSource entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + Task pending = fixture.File.RunProtectedLocalOperationAsync(new(binding), async (_, _) => + { + entered.SetResult(); + await release.Task; + }).AsTask(); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Task shutdown = fixture.System.DisposeAsync().AsTask(); + try + { + Assert.Equal(CloudFileSystemLifecycleState.Stopping, fixture.System.LifecycleState); + Assert.False(shutdown.IsCompleted); + Assert.Throws(() => fixture.System.GetFile("new.bin")); + await Assert.ThrowsAsync(() => fixture.File.InspectAsync().AsTask()); + Assert.Throws(() => File.WriteAllText(fixture.File.FullPath, "contender")); + } + finally + { + release.TrySetResult(); + } + Assert.Equal(CloudProtectedLocalOperationOutcome.Canceled, (await pending).Outcome); + await shutdown.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(CloudFileSystemLifecycleState.Disposed, fixture.System.LifecycleState); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ProtectedLocalPublicAdmissionRejectsExistingWriterAndMapping(bool mapped) + { + await using Fixture fixture = await Fixture.StartAsync(); + await File.WriteAllTextAsync(fixture.File.FullPath, "local-only"); + CloudLocalFileBinding binding = Assert.IsType((await fixture.File.InspectAsync()).LocalBinding); + using FileStream writer = new(fixture.File.FullPath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete); + using MemoryMappedFile? mapping = mapped + ? MemoryMappedFile.CreateFromFile(writer, null, 0, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, true) : null; + using MemoryMappedViewAccessor? view = mapping?.CreateViewAccessor(); + if (mapped) + { + writer.Dispose(); + } + CloudProtectedLocalOperationResult result = await fixture.File.RunProtectedLocalOperationAsync(new(binding), (_, _) => + throw new InvalidOperationException("A busy object must never enter the callback.")); + Assert.Equal(CloudProtectedLocalOperationOutcome.Busy, result.Outcome); + Assert.Equal(32, Assert.IsType(result.Error).Win32ErrorCode); + Assert.False(result.CallbackStarted); + Assert.Equal(0, fixture.Provider.Fetches); + } +} diff --git a/tests/CfSharp.Storage.Sqlite.CrashHarness/Program.cs b/tests/CfSharp.Storage.Sqlite.CrashHarness/Program.cs index 76b080a..b8353d8 100644 --- a/tests/CfSharp.Storage.Sqlite.CrashHarness/Program.cs +++ b/tests/CfSharp.Storage.Sqlite.CrashHarness/Program.cs @@ -1,6 +1,11 @@ using CfSharp; using CfSharp.Storage.Sqlite; +if (args.Length == 4 && args[2].StartsWith("protected-local-", StringComparison.Ordinal)) +{ + return ProtectedLocalCompetitor.Run(args[0], args[1], args[2]["protected-local-".Length..], args[3]); +} + if (args.Length == 5 && args[4] == "namespace-consumer") { return NamespaceConsumer.Run(args[0], args[1], args[2], args[3]); diff --git a/tests/CfSharp.Storage.Sqlite.CrashHarness/ProtectedLocalCompetitor.cs b/tests/CfSharp.Storage.Sqlite.CrashHarness/ProtectedLocalCompetitor.cs new file mode 100644 index 0000000..72c48e2 --- /dev/null +++ b/tests/CfSharp.Storage.Sqlite.CrashHarness/ProtectedLocalCompetitor.cs @@ -0,0 +1,52 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; + +internal static class ProtectedLocalCompetitor +{ + internal static int Run(string path, string eventName, string operation, string target) + { + using EventWaitHandle ready = EventWaitHandle.OpenExisting(eventName); + using EventWaitHandle go = EventWaitHandle.OpenExisting(eventName + "-go"); + using EventWaitHandle attempted = EventWaitHandle.OpenExisting(eventName + "-attempted"); + ready.Set(); + if (!go.WaitOne(TimeSpan.FromSeconds(15))) + { + return 2; + } + + try + { + attempted.Set(); + switch (operation) + { + case "overwrite": + File.WriteAllText(path, "replacement"); + break; + case "rename": + File.Move(path, target); + break; + case "replace": + File.Move(target, path, overwrite: true); + break; + case "link": + if (!CreateHardLink(target, path, 0)) + { + return Marshal.GetLastPInvokeError(); + } + break; + default: + return 2; + } + + return 0; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or Win32Exception) + { + return exception.HResult & 0xffff; + } + } + + [DllImport("kernel32.dll", EntryPoint = "CreateHardLinkW", SetLastError = true, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateHardLink(string link, string existing, nint security); +} diff --git a/tests/CfSharp.Tests/ProtectedLocalOperationRequestTests.cs b/tests/CfSharp.Tests/ProtectedLocalOperationRequestTests.cs new file mode 100644 index 0000000..7a3e16e --- /dev/null +++ b/tests/CfSharp.Tests/ProtectedLocalOperationRequestTests.cs @@ -0,0 +1,45 @@ +namespace CfSharp.Tests; + +public sealed class ProtectedLocalOperationRequestTests +{ + [Fact] + public void RequestRequiresAnOriginalBindingAndDefinedMode() + { + CloudLocalFileBinding binding = new(1, Guid.NewGuid(), Guid.NewGuid()); + CloudProtectedLocalOperationRequest request = new(binding); + Assert.Same(binding, request.ExpectedBinding); + Assert.Equal(CloudProtectedLocalOperationMode.ExclusiveFile, request.Mode); + Assert.Throws(() => new CloudProtectedLocalOperationRequest(null!)); + Assert.Throws(() => new CloudProtectedLocalOperationRequest(binding, (CloudProtectedLocalOperationMode)999)); + } + + [Fact] + public void LocalPreparationDoesNotAcceptAnUploadedRevision() + { + CloudLocalFileBinding binding = new(1, Guid.NewGuid(), Guid.NewGuid()); + CloudPlaceholderIdentity identity = CloudPlaceholderIdentity.Create("local-pending"); + CloudProtectedLocalOperationRequest request = CloudProtectedLocalOperationRequest.ForLocalConversion(binding, identity); + Assert.Same(identity, request.PreparationIdentity); + Assert.Equal(CloudProtectedLocalOperationMode.ExclusiveFile, request.Mode); + Assert.Throws(() => CloudProtectedLocalOperationRequest.ForLocalConversion(binding, null!)); + Assert.Throws(() => CloudProtectedLocalOperationRequest.ForLocalConversion(binding, + CloudPlaceholderIdentity.Create("local-pending", "uploaded-revision"))); + } + + [Fact] + public void AShortBudgetPreservesPreparationAndRejectsUnboundedRequests() + { + CloudLocalFileBinding binding = new(1, Guid.NewGuid(), Guid.NewGuid()); + CloudPlaceholderIdentity identity = CloudPlaceholderIdentity.Create("local-budget"); + CloudProtectedLocalOperationRequest original = CloudProtectedLocalOperationRequest.ForLocalConversion(binding, identity); + CloudProtectedLocalOperationRequest bounded = original.WithBudget(TimeSpan.FromSeconds(1)); + Assert.Same(binding, bounded.ExpectedBinding); + Assert.Same(identity, bounded.PreparationIdentity); + Assert.Equal(original.Mode, bounded.Mode); + Assert.Equal(TimeSpan.FromSeconds(1), bounded.Budget); + Assert.Equal(TimeSpan.FromSeconds(10), original.Budget); + Assert.Throws(() => original.WithBudget(TimeSpan.Zero)); + Assert.Throws(() => original.WithBudget(Timeout.InfiniteTimeSpan)); + Assert.Throws(() => original.WithBudget(TimeSpan.FromMinutes(2))); + } +}