From c7e2f1264aaf9581fc95e0176f97583b4300c23d Mon Sep 17 00:00:00 2001 From: Quaternion8192 <70364461+Quaternion8192@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:05:27 +0800 Subject: [PATCH 01/13] test(native): verify protected local operation prerequisites --- .../ProtectedLocalNativePrerequisiteTests.cs | 263 ++++++++++++++++++ .../Program.cs | 5 + .../ProtectedLocalCompetitor.cs | 52 ++++ 3 files changed, 320 insertions(+) create mode 100644 tests/CfSharp.IntegrationTests/ProtectedLocalNativePrerequisiteTests.cs create mode 100644 tests/CfSharp.Storage.Sqlite.CrashHarness/ProtectedLocalCompetitor.cs diff --git a/tests/CfSharp.IntegrationTests/ProtectedLocalNativePrerequisiteTests.cs b/tests/CfSharp.IntegrationTests/ProtectedLocalNativePrerequisiteTests.cs new file mode 100644 index 0000000..98da080 --- /dev/null +++ b/tests/CfSharp.IntegrationTests/ProtectedLocalNativePrerequisiteTests.cs @@ -0,0 +1,263 @@ +using System.Diagnostics; +using System.IO.MemoryMappedFiles; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; + +using CfSharp.Native; + +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 = NativeLocalAccessOpen(fixture.File.FullPath, 0x00060083, 0); + Assert.False(access.IsInvalid, $"Access open error={Marshal.GetLastPInvokeError()}"); + using LocalCompetitor competitor = StartLocalCompetitor(fixture.File.FullPath, target, action); + try + { + CloudPlaceholderIdentity identity = new(Guid.NewGuid(), "local-unuploaded"); + long usn = NativeLocalConvert(access.DangerousGetHandle(), identity.Encode()); + 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(); + 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()); + Assert.Equal(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 = NativeLocalAccessOpen(item.FullPath, + kind == "directory" ? 0x00060080u : 0x00060083u, kind == "directory" ? 3u : 0u); + Assert.False(access.IsInvalid, $"Access open error={Marshal.GetLastPInvokeError()}"); + 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); + Assert.Equal(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(); + } + using SafeFileHandle access = NativeLocalAccessOpen(fixture.File.FullPath, 0x00060083, 0); + int error = Marshal.GetLastPInvokeError(); + Assert.True(access.IsInvalid, "Writable section or writer admitted an exclusive object handle."); + Assert.Equal(32, error); + Assert.Equal(0, fixture.Provider.Fetches); + } + + private static unsafe long NativeLocalConvert(nint handle, byte[] identity) + { + fixed (byte* pointer = identity) + { + long usn = 0; + int result = CfApi.CfConvertToPlaceholder(handle, pointer, (uint)identity.Length, CfConvertFlags.None, &usn, null); + Assert.True(result >= 0, $"CfConvertToPlaceholder HRESULT=0x{result:X8}"); + return usn; + } + } + + [DllImport("kernel32.dll", EntryPoint = "CreateFileW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle NativeLocalAccessOpen(string path, uint access = 0x00060080, + uint share = 3, nint security = 0, uint creation = 3, uint flags = 0x02200000, nint template = 0); + + 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.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); +} From d94bc2a09f66dd367a06f71edfcea69b4b241624 Mon Sep 17 00:00:00 2001 From: Quaternion8192 <70364461+Quaternion8192@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:18:03 +0800 Subject: [PATCH 02/13] refactor(items): reuse handle-based local inspection and conversion --- .../Internal/WindowsFileMetadata.cs | 42 +++++++++++++++++++ src/CfSharp/Items/CloudItemInspector.cs | 21 ++++++---- src/CfSharp/Items/CloudLocalAccessObject.cs | 10 +++++ .../CloudPlaceholderMutationPlatform.cs | 8 +++- .../ProtectedLocalNativePrerequisiteTests.cs | 40 ++++++------------ 5 files changed, 85 insertions(+), 36 deletions(-) create mode 100644 src/CfSharp/Items/CloudLocalAccessObject.cs diff --git a/src/CfSharp.Native/Internal/WindowsFileMetadata.cs b/src/CfSharp.Native/Internal/WindowsFileMetadata.cs index 1bb87ad..9ade89c 100644 --- a/src/CfSharp.Native/Internal/WindowsFileMetadata.cs +++ b/src/CfSharp.Native/Internal/WindowsFileMetadata.cs @@ -25,6 +25,38 @@ 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; + // metadata-only directory access pins its name without freezing its membership. + // OPEN_REPARSE_POINT prevents a final symbolic link from redirecting this access. + SafeFileHandle handle = CreateFile(path, directory ? 0x60080u : 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 +174,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/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/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/ProtectedLocalNativePrerequisiteTests.cs b/tests/CfSharp.IntegrationTests/ProtectedLocalNativePrerequisiteTests.cs index 98da080..6c43590 100644 --- a/tests/CfSharp.IntegrationTests/ProtectedLocalNativePrerequisiteTests.cs +++ b/tests/CfSharp.IntegrationTests/ProtectedLocalNativePrerequisiteTests.cs @@ -1,11 +1,10 @@ +using System.ComponentModel; using System.Diagnostics; using System.IO.MemoryMappedFiles; using System.Runtime.InteropServices; using System.Security.AccessControl; using System.Security.Principal; -using CfSharp.Native; - using Microsoft.Win32.SafeHandles; namespace CfSharp.IntegrationTests; @@ -30,13 +29,13 @@ public async Task ProtectedLocalNativeConversionRetainsExclusiveObjectAcrossComp await File.WriteAllTextAsync(target, "replacement"); } - using SafeFileHandle access = NativeLocalAccessOpen(fixture.File.FullPath, 0x00060083, 0); - Assert.False(access.IsInvalid, $"Access open error={Marshal.GetLastPInvokeError()}"); + 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 = NativeLocalConvert(access.DangerousGetHandle(), identity.Encode()); + 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()); @@ -50,6 +49,12 @@ public async Task ProtectedLocalNativeConversionRetainsExclusiveObjectAcrossComp $"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); @@ -118,9 +123,7 @@ public async Task ProtectedLocalNativeAccessGuardPreservesObjectWithoutHydration CloudItemSnapshot before = await item.InspectAsync(); CloudLocalFileBinding binding = Assert.IsType(before.LocalBinding); - using SafeFileHandle access = NativeLocalAccessOpen(item.FullPath, - kind == "directory" ? 0x00060080u : 0x00060083u, kind == "directory" ? 3u : 0u); - Assert.False(access.IsInvalid, $"Access open error={Marshal.GetLastPInvokeError()}"); + 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(); @@ -170,28 +173,11 @@ public async Task ProtectedLocalNativeExclusiveAccessRejectsExistingWriterOrMapp { writer.Dispose(); } - using SafeFileHandle access = NativeLocalAccessOpen(fixture.File.FullPath, 0x00060083, 0); - int error = Marshal.GetLastPInvokeError(); - Assert.True(access.IsInvalid, "Writable section or writer admitted an exclusive object handle."); - Assert.Equal(32, error); + Win32Exception error = Assert.Throws(() => CloudLocalAccessObject.Open(fixture.File.FullPath)); + Assert.Equal(32, error.NativeErrorCode); Assert.Equal(0, fixture.Provider.Fetches); } - private static unsafe long NativeLocalConvert(nint handle, byte[] identity) - { - fixed (byte* pointer = identity) - { - long usn = 0; - int result = CfApi.CfConvertToPlaceholder(handle, pointer, (uint)identity.Length, CfConvertFlags.None, &usn, null); - Assert.True(result >= 0, $"CfConvertToPlaceholder HRESULT=0x{result:X8}"); - return usn; - } - } - - [DllImport("kernel32.dll", EntryPoint = "CreateFileW", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern SafeFileHandle NativeLocalAccessOpen(string path, uint access = 0x00060080, - uint share = 3, nint security = 0, uint creation = 3, uint flags = 0x02200000, nint template = 0); - private sealed class LocalAccessSecurity : NativeObjectSecurity { internal LocalAccessSecurity(SafeFileHandle handle, bool directory = false) From f30696bc3a997a6a55f96c30d12bd4f2549b6ecc Mon Sep 17 00:00:00 2001 From: Quaternion8192 <70364461+Quaternion8192@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:35:06 +0800 Subject: [PATCH 03/13] test(native): compare access descriptors by DACL facts --- .../ProtectedLocalNativePrerequisiteTests.cs | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/tests/CfSharp.IntegrationTests/ProtectedLocalNativePrerequisiteTests.cs b/tests/CfSharp.IntegrationTests/ProtectedLocalNativePrerequisiteTests.cs index 6c43590..1916f3a 100644 --- a/tests/CfSharp.IntegrationTests/ProtectedLocalNativePrerequisiteTests.cs +++ b/tests/CfSharp.IntegrationTests/ProtectedLocalNativePrerequisiteTests.cs @@ -60,7 +60,7 @@ public async Task ProtectedLocalNativeConversionRetainsExclusiveObjectAcrossComp Assert.Equal(content.Length, held.Length); Assert.Equal(CloudSynchronizationState.NotInSync, held.SynchronizationState); Assert.Equal(identity.Encode(), held.PlaceholderIdentity.ToArray()); - Assert.Equal(originalDacl, new LocalAccessSecurity(access).GetSecurityDescriptorBinaryForm()); + 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)}"); @@ -129,7 +129,7 @@ public async Task ProtectedLocalNativeAccessGuardPreservesObjectWithoutHydration byte[] original = descriptor.GetSecurityDescriptorBinaryForm(); await Task.Delay(20); descriptor.Apply(access); - Assert.Equal(original, new LocalAccessSecurity(access, kind == "directory").GetSecurityDescriptorBinaryForm()); + AssertSameAccessDacl(original, new LocalAccessSecurity(access, kind == "directory").GetSecurityDescriptorBinaryForm()); Assert.Equal(binding, (await item.InspectAsync()).LocalBinding); Assert.Equal(0, fixture.Provider.Fetches); if (kind == "cold") @@ -178,6 +178,24 @@ public async Task ProtectedLocalNativeExclusiveAccessRejectsExistingWriterOrMapp 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) From 0ac27af184886402cb254355c31bf3b82304fd19 Mon Sep 17 00:00:00 2001 From: Quaternion8192 <70364461+Quaternion8192@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:05:09 +0800 Subject: [PATCH 04/13] feat(items): add protected local operation callbacks --- docs/_toc.yml | 2 + docs/protected-local-operations.md | 59 +++++ eng/api-baseline.json | 45 ++++ ...loudFileSystem.ProtectedLocalOperations.cs | 107 ++++++++++ src/CfSharp/FileSystem/CloudFileSystem.cs | 13 +- .../Items/CloudProtectedLocalOperation.cs | 166 ++++++++++++++ .../CloudProtectedLocalOperationContext.cs | 188 ++++++++++++++++ .../ProtectedLocalOperationTests.cs | 202 ++++++++++++++++++ .../ProtectedLocalOperationRequestTests.cs | 15 ++ 9 files changed, 795 insertions(+), 2 deletions(-) create mode 100644 docs/protected-local-operations.md create mode 100644 src/CfSharp/FileSystem/CloudFileSystem.ProtectedLocalOperations.cs create mode 100644 src/CfSharp/Items/CloudProtectedLocalOperation.cs create mode 100644 src/CfSharp/Items/CloudProtectedLocalOperationContext.cs create mode 100644 tests/CfSharp.IntegrationTests/ProtectedLocalOperationTests.cs create mode 100644 tests/CfSharp.Tests/ProtectedLocalOperationRequestTests.cs 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/protected-local-operations.md b/docs/protected-local-operations.md new file mode 100644 index 0000000..4f6d720 --- /dev/null +++ b/docs/protected-local-operations.md @@ -0,0 +1,59 @@ +# 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. + +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. +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. + +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. diff --git a/eng/api-baseline.json b/eng/api-baseline.json index 5e1e531..14af0e0 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,50 @@ "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)", + "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)", + "property CfSharp.CloudLocalFileBinding ExpectedBinding()", + "property CfSharp.CloudProtectedLocalOperationMode Mode()", + "type CfSharp.CloudProtectedLocalOperationResult", + "property System.Boolean CallbackCompleted()", + "property System.Boolean CallbackStarted()", + "property System.Boolean Drained()", + "property System.TimeSpan Elapsed()", + "property System.Exception Error()", + "property CfSharp.CloudLocalFileBinding ExpectedBinding()", + "property CfSharp.CloudProtectedLocalOperationOutcome Outcome()", + "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/src/CfSharp/FileSystem/CloudFileSystem.ProtectedLocalOperations.cs b/src/CfSharp/FileSystem/CloudFileSystem.ProtectedLocalOperations.cs new file mode 100644 index 0000000..0db08ec --- /dev/null +++ b/src/CfSharp/FileSystem/CloudFileSystem.ProtectedLocalOperations.cs @@ -0,0 +1,107 @@ +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(); + + internal async ValueTask RunProtectedLocalOperationAsync( + CloudItem item, CloudProtectedLocalOperationRequest request, + Func callback, CancellationToken token) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(callback); + RejectProtectedLocalReentry(); + EnsureStarted(); + long started = Stopwatch.GetTimestamp(); + 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)], token).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."); + } + token.ThrowIfCancellationRequested(); + stage = CloudProtectedLocalOperationStage.Open; + using SafeFileHandle handle = CloudLocalAccessObject.Open(item.FullPath, directory); + context = new(this, item, operation.StateStore, handle, request, token); + try + { + await context.InspectAsync(token).ConfigureAwait(false); + token.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); + s_protectedLocalContext.Value = context; + try + { + context.SetStage(CloudProtectedLocalOperationStage.Callback); + invoked = true; + await callback(context, token).ConfigureAwait(false); + completed = true; + } + finally + { + s_protectedLocalContext.Value = null; + } + } + finally + { + await context.CloseAsync().ConfigureAwait(false); + } + if (context.Failure is not null) + { + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw(context.Failure); + } + token.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 + { + CloudProtectedLocalRejectedException rejected => rejected.Outcome, + OperationCanceledException when !callbackFault || token.IsCancellationRequested => 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, + }; + } + return new(outcome, failure is not null ? stage : context?.Stage ?? stage, request.ExpectedBinding, context?.LastSnapshot, + invoked, completed, Stopwatch.GetElapsedTime(started), failure); + } + + 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.cs b/src/CfSharp/FileSystem/CloudFileSystem.cs index df77f8c..a5398cb 100644 --- a/src/CfSharp/FileSystem/CloudFileSystem.cs +++ b/src/CfSharp/FileSystem/CloudFileSystem.cs @@ -339,6 +339,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 +400,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 +424,7 @@ private async ValueTask AcquireOperationCoreAsync bool allowLocalChangeDrain = false, CancellationToken cancellationToken = default) { + RejectProtectedLocalReentry(); ArgumentNullException.ThrowIfNull(scopes); IReadOnlyList requestedScopes = scopes.ToArray(); ICloudStateStore stateStore; @@ -525,7 +531,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 +557,6 @@ internal static async ValueTask InspectCoreAsync( durableState, DateTimeOffset.UtcNow, local.LocalBinding); - } private void EnsureStarted() { diff --git a/src/CfSharp/Items/CloudProtectedLocalOperation.cs b/src/CfSharp/Items/CloudProtectedLocalOperation.cs new file mode 100644 index 0000000..dc4cbc0 --- /dev/null +++ b/src/CfSharp/Items/CloudProtectedLocalOperation.cs @@ -0,0 +1,166 @@ +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 +{ + internal CloudProtectedLocalOperationResult(CloudProtectedLocalOperationOutcome outcome, + CloudProtectedLocalOperationStage stage, CloudLocalFileBinding expected, + CloudItemSnapshot? snapshot, bool callbackStarted, bool callbackCompleted, + TimeSpan elapsed, Exception? error) + { + Outcome = outcome; + Stage = stage; + ExpectedBinding = expected; + Snapshot = snapshot; + CallbackStarted = callbackStarted; + CallbackCompleted = callbackCompleted; + Elapsed = elapsed; + Error = error; + } + + /// 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 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..672b8ea --- /dev/null +++ b/src/CfSharp/Items/CloudProtectedLocalOperationContext.cs @@ -0,0 +1,188 @@ +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 CancellationToken _stop; + private bool _accepting = true; + private int _active; + private int _stage; + private Exception? _failure; + private CloudProtectedLocalOperationStage? _failureStage; + + internal CloudProtectedLocalOperationContext(CloudFileSystem owner, CloudItem item, ICloudStateStore store, + SafeFileHandle handle, CloudProtectedLocalOperationRequest request, CancellationToken stop) + { + _item = item; + _owner = owner; + _store = store; + _handle = handle; + _request = request; + _stop = stop; + } + + 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()); + 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."); + } + 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."); + } + 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."); + } + LastSnapshot = CloudFileSystem.CreateSnapshot(_item, local, durable); + return LastSnapshot; + } + + private async ValueTask ExecuteAsync(Func> work, CancellationToken token) + { + lock (_admission) + { + ObjectDisposedException.ThrowIf(!_accepting, this); + _stop.ThrowIfCancellationRequested(); + token.ThrowIfCancellationRequested(); + _active++; + } + bool entered = false; + try + { + using CancellationTokenSource stop = CancellationTokenSource.CreateLinkedTokenSource(_stop, token); + await _serial.WaitAsync(stop.Token).ConfigureAwait(false); + entered = true; + 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(); + } + } + } + } + + 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/tests/CfSharp.IntegrationTests/ProtectedLocalOperationTests.cs b/tests/CfSharp.IntegrationTests/ProtectedLocalOperationTests.cs new file mode 100644 index 0000000..a53c806 --- /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.Completed, (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.Tests/ProtectedLocalOperationRequestTests.cs b/tests/CfSharp.Tests/ProtectedLocalOperationRequestTests.cs new file mode 100644 index 0000000..576ae37 --- /dev/null +++ b/tests/CfSharp.Tests/ProtectedLocalOperationRequestTests.cs @@ -0,0 +1,15 @@ +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)); + } +} From a6f741806853fc5898acec6a846d22cf45bca892 Mon Sep 17 00:00:00 2001 From: Quaternion8192 <70364461+Quaternion8192@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:12:26 +0800 Subject: [PATCH 05/13] feat(items): convert regular files within protected operations --- docs/protected-local-operations.md | 21 ++ eng/api-baseline.json | 8 + .../CloudFileSystem.ItemMutations.cs | 16 +- ...loudFileSystem.ProtectedLocalOperations.cs | 7 +- ...loudFileSystem.ProtectedLocalProjection.cs | 34 +++ .../Items/CloudProtectedLocalConversion.cs | 156 +++++++++++++ .../Items/CloudProtectedLocalOperation.cs | 19 +- .../CloudProtectedLocalOperationContext.cs | 1 + .../CloudContentConfirmationRecoveryTests.cs | 6 + .../ProtectedLocalConversionTests.cs | 209 ++++++++++++++++++ .../ProtectedLocalOperationRequestTests.cs | 13 ++ 11 files changed, 485 insertions(+), 5 deletions(-) create mode 100644 src/CfSharp/FileSystem/CloudFileSystem.ProtectedLocalProjection.cs create mode 100644 src/CfSharp/Items/CloudProtectedLocalConversion.cs create mode 100644 tests/CfSharp.IntegrationTests/ProtectedLocalConversionTests.cs diff --git a/docs/protected-local-operations.md b/docs/protected-local-operations.md index 4f6d720..00d96a5 100644 --- a/docs/protected-local-operations.md +++ b/docs/protected-local-operations.md @@ -36,6 +36,27 @@ its members, descendant content and enumeration can change. Exclusive mode on a `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. + 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 diff --git a/eng/api-baseline.json b/eng/api-baseline.json index 14af0e0..f9c535e 100644 --- a/eng/api-baseline.json +++ b/eng/api-baseline.json @@ -1385,6 +1385,7 @@ "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)", "type CfSharp.CloudProtectedLocalOperationMode", "field CfSharp.CloudProtectedLocalOperationMode DirectoryMetadata", "field CfSharp.CloudProtectedLocalOperationMode ExclusiveFile", @@ -1404,16 +1405,23 @@ "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)", "property CfSharp.CloudLocalFileBinding ExpectedBinding()", "property CfSharp.CloudProtectedLocalOperationMode Mode()", + "property CfSharp.CloudPlaceholderIdentity PreparationIdentity()", "type CfSharp.CloudProtectedLocalOperationResult", "property System.Boolean CallbackCompleted()", "property System.Boolean CallbackStarted()", "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", 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 index 0db08ec..d2100b7 100644 --- a/src/CfSharp/FileSystem/CloudFileSystem.ProtectedLocalOperations.cs +++ b/src/CfSharp/FileSystem/CloudFileSystem.ProtectedLocalOperations.cs @@ -41,6 +41,10 @@ internal async ValueTask RunProtectedLocalOp try { await context.InspectAsync(token).ConfigureAwait(false); + if (request.PreparationIdentity is { } identity) + { + await context.ConvertToPlaceholderAsync(identity, token).ConfigureAwait(false); + } token.ThrowIfCancellationRequested(); // Invoke application work away from the caller's synchronization context so // external synchronous disposal can drain without blocking its continuation. @@ -76,6 +80,7 @@ internal async ValueTask RunProtectedLocalOp 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 || token.IsCancellationRequested => CloudProtectedLocalOperationOutcome.Canceled, _ when !callbackFault && stage == CloudProtectedLocalOperationStage.Open && failure is CloudFilesException { Win32ErrorCode: 32 or 33 } => CloudProtectedLocalOperationOutcome.Busy, @@ -85,7 +90,7 @@ internal async ValueTask RunProtectedLocalOp }; } return new(outcome, failure is not null ? stage : context?.Stage ?? stage, request.ExpectedBinding, context?.LastSnapshot, - invoked, completed, Stopwatch.GetElapsedTime(started), failure); + invoked, completed, Stopwatch.GetElapsedTime(started), failure, context); } private static void RejectProtectedLocalReentry() 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/Items/CloudProtectedLocalConversion.cs b/src/CfSharp/Items/CloudProtectedLocalConversion.cs new file mode 100644 index 0000000..45c713f --- /dev/null +++ b/src/CfSharp/Items/CloudProtectedLocalConversion.cs @@ -0,0 +1,156 @@ +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); + } + + 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) + { + 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."); + } + 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 index dc4cbc0..ba95a25 100644 --- a/src/CfSharp/Items/CloudProtectedLocalOperation.cs +++ b/src/CfSharp/Items/CloudProtectedLocalOperation.cs @@ -100,10 +100,11 @@ public CloudProtectedLocalOperationRequest(CloudLocalFileBinding expectedBinding /// 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) + TimeSpan elapsed, Exception? error, CloudProtectedLocalOperationContext? context) { Outcome = outcome; Stage = stage; @@ -113,6 +114,11 @@ internal CloudProtectedLocalOperationResult(CloudProtectedLocalOperationOutcome CallbackCompleted = callbackCompleted; Elapsed = elapsed; Error = error; + NativeIdentityPrepared = context?.NativeIdentityPrepared ?? false; + NativeConverted = context?.NativeConverted ?? false; + DurableProjectionCommitted = context?.DurableProjectionCommitted ?? false; + PreparationHResult = context?.PreparationHResult; + PreparationUsn = context?.PreparationUsn; } /// Gets the outcome without any synchronization or product-readiness claim. @@ -132,6 +138,17 @@ internal CloudProtectedLocalOperationResult(CloudProtectedLocalOperationOutcome 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 all admitted work drained before native and facade resources were released. public bool Drained { get; } = true; } diff --git a/src/CfSharp/Items/CloudProtectedLocalOperationContext.cs b/src/CfSharp/Items/CloudProtectedLocalOperationContext.cs index 672b8ea..6d6b8ed 100644 --- a/src/CfSharp/Items/CloudProtectedLocalOperationContext.cs +++ b/src/CfSharp/Items/CloudProtectedLocalOperationContext.cs @@ -113,6 +113,7 @@ private async ValueTask InspectCoreAsync(CancellationToken to { throw new InvalidDataException("The official item row has a different object kind."); } + _lastDurableState = durable; LastSnapshot = CloudFileSystem.CreateSnapshot(_item, local, durable); return LastSnapshot; } diff --git a/tests/CfSharp.IntegrationTests/CloudContentConfirmationRecoveryTests.cs b/tests/CfSharp.IntegrationTests/CloudContentConfirmationRecoveryTests.cs index d3f4a90..612c4ae 100644 --- a/tests/CfSharp.IntegrationTests/CloudContentConfirmationRecoveryTests.cs +++ b/tests/CfSharp.IntegrationTests/CloudContentConfirmationRecoveryTests.cs @@ -307,6 +307,7 @@ private sealed class FaultFactory(string path) : ICloudStateStoreFactory 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!; public async ValueTask OpenAsync(CloudStateStoreContext context, @@ -358,6 +359,11 @@ public async ValueTask CommitAsync(CancellationToken cancellationToken = default public async ValueTask DisposeAsync() { await inner.DisposeAsync(); + if (faults.AfterTransactionDispose is { } after) + { + faults.AfterTransactionDispose = null; + after(); + } if (faults.NextDisposeFault is { } exception) { faults.NextDisposeFault = null; diff --git a/tests/CfSharp.IntegrationTests/ProtectedLocalConversionTests.cs b/tests/CfSharp.IntegrationTests/ProtectedLocalConversionTests.cs new file mode 100644 index 0000000..376634e --- /dev/null +++ b/tests/CfSharp.IntegrationTests/ProtectedLocalConversionTests.cs @@ -0,0 +1,209 @@ +using System.Security.AccessControl; + +namespace CfSharp.IntegrationTests; + +public sealed partial class CloudContentConfirmationTests +{ + private static readonly string[] LocalProtectionCompetitors = ["overwrite", "link"]; + [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(); + if (afterNative) + { + factory!.BeforeCommit = () => + { + cancellation.Cancel(); + return ValueTask.CompletedTask; + }; + } + else + { + factory!.BeforeTransaction = () => + { + factory.BeforeTransaction = () => + { + factory.AfterTransactionDispose = cancellation.Cancel; + return ValueTask.CompletedTask; + }; + return ValueTask.CompletedTask; + }; + } + CloudProtectedLocalOperationResult result = await fixture.File.RunProtectedLocalOperationAsync( + CloudProtectedLocalOperationRequest.ForLocalConversion(binding, identity), (_, _) => ValueTask.CompletedTask, cancellation.Token); + Assert.Equal(CloudProtectedLocalOperationOutcome.Canceled, result.Outcome); + 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.Tests/ProtectedLocalOperationRequestTests.cs b/tests/CfSharp.Tests/ProtectedLocalOperationRequestTests.cs index 576ae37..81ed580 100644 --- a/tests/CfSharp.Tests/ProtectedLocalOperationRequestTests.cs +++ b/tests/CfSharp.Tests/ProtectedLocalOperationRequestTests.cs @@ -12,4 +12,17 @@ public void RequestRequiresAnOriginalBindingAndDefinedMode() 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"))); + } } From fb64047784e5aaaabe906b4bb54c08faddcf2097 Mon Sep 17 00:00:00 2001 From: Quaternion8192 <70364461+Quaternion8192@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:21:42 +0800 Subject: [PATCH 06/13] feat(items): expose scoped access descriptor operations --- docs/protected-local-operations.md | 14 ++ eng/api-baseline.json | 4 + .../Internal/WindowsFileMetadata.cs | 6 +- .../Items/CloudProtectedLocalAccess.cs | 123 +++++++++++ .../Items/CloudProtectedLocalOperation.cs | 7 + .../ProtectedLocalAccessTests.cs | 200 ++++++++++++++++++ 6 files changed, 352 insertions(+), 2 deletions(-) create mode 100644 src/CfSharp/Items/CloudProtectedLocalAccess.cs create mode 100644 tests/CfSharp.IntegrationTests/ProtectedLocalAccessTests.cs diff --git a/docs/protected-local-operations.md b/docs/protected-local-operations.md index 00d96a5..17d7813 100644 --- a/docs/protected-local-operations.md +++ b/docs/protected-local-operations.md @@ -57,6 +57,20 @@ Keep the original binding and identity, then retry a new request after recovery 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. +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 diff --git a/eng/api-baseline.json b/eng/api-baseline.json index f9c535e..91a7c97 100644 --- a/eng/api-baseline.json +++ b/eng/api-baseline.json @@ -1386,6 +1386,8 @@ "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)", "type CfSharp.CloudProtectedLocalOperationMode", "field CfSharp.CloudProtectedLocalOperationMode DirectoryMetadata", "field CfSharp.CloudProtectedLocalOperationMode ExclusiveFile", @@ -1410,6 +1412,8 @@ "property CfSharp.CloudProtectedLocalOperationMode Mode()", "property CfSharp.CloudPlaceholderIdentity PreparationIdentity()", "type CfSharp.CloudProtectedLocalOperationResult", + "property System.Boolean AccessDescriptorApplied()", + "property System.Boolean AccessDescriptorReadBack()", "property System.Boolean CallbackCompleted()", "property System.Boolean CallbackStarted()", "property System.Boolean Drained()", diff --git a/src/CfSharp.Native/Internal/WindowsFileMetadata.cs b/src/CfSharp.Native/Internal/WindowsFileMetadata.cs index 9ade89c..876405d 100644 --- a/src/CfSharp.Native/Internal/WindowsFileMetadata.cs +++ b/src/CfSharp.Native/Internal/WindowsFileMetadata.cs @@ -30,9 +30,11 @@ 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; - // metadata-only directory access pins its name without freezing its membership. + // 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 ? 0x60080u : 0x60083u, + SafeFileHandle handle = CreateFile(path, directory ? 0x60081u : 0x60083u, directory ? 3u : 0u, 0, 3, 0x02200000, 0); if (handle.IsInvalid) { diff --git a/src/CfSharp/Items/CloudProtectedLocalAccess.cs b/src/CfSharp/Items/CloudProtectedLocalAccess.cs new file mode 100644 index 0000000..338a35c --- /dev/null +++ b/src/CfSharp/Items/CloudProtectedLocalAccess.cs @@ -0,0 +1,123 @@ +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. 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) + { + 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); + 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/CloudProtectedLocalOperation.cs b/src/CfSharp/Items/CloudProtectedLocalOperation.cs index ba95a25..40fa967 100644 --- a/src/CfSharp/Items/CloudProtectedLocalOperation.cs +++ b/src/CfSharp/Items/CloudProtectedLocalOperation.cs @@ -119,6 +119,8 @@ internal CloudProtectedLocalOperationResult(CloudProtectedLocalOperationOutcome DurableProjectionCommitted = context?.DurableProjectionCommitted ?? false; PreparationHResult = context?.PreparationHResult; PreparationUsn = context?.PreparationUsn; + AccessDescriptorApplied = context?.AccessDescriptorApplied ?? false; + AccessDescriptorReadBack = context?.AccessDescriptorReadBack ?? false; } /// Gets the outcome without any synchronization or product-readiness claim. @@ -149,6 +151,11 @@ internal CloudProtectedLocalOperationResult(CloudProtectedLocalOperationOutcome 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 all admitted work drained before native and facade resources were released. public bool Drained { get; } = true; } diff --git a/tests/CfSharp.IntegrationTests/ProtectedLocalAccessTests.cs b/tests/CfSharp.IntegrationTests/ProtectedLocalAccessTests.cs new file mode 100644 index 0000000..87bb2c3 --- /dev/null +++ b/tests/CfSharp.IntegrationTests/ProtectedLocalAccessTests.cs @@ -0,0 +1,200 @@ +using System.Security.AccessControl; +using System.Security.Principal; + +namespace CfSharp.IntegrationTests; + +public sealed partial class CloudContentConfirmationTests +{ + [Theory] + [InlineData("ordinary")] + [InlineData("cold")] + [InlineData("directory")] + [InlineData("managed-directory")] + public async Task ProtectedLocalAccessUsesOnlyOriginalDaclWithoutHydration(string kind) + { + await using Fixture fixture = await Fixture.StartAsync(); + CloudItem item; + CloudPlaceholderIdentity identity; + if (kind == "cold") + { + await fixture.System.Root.CreatePlaceholderAsync(CloudFilePlaceholderSpec.CreateBuilder("content.bin", "cold-local", 4096).Build()); + item = fixture.File; + identity = CloudPlaceholderIdentity.Decode((await item.InspectAsync()).PlaceholderIdentity.Span); + } + else if (kind is "directory" or "managed-directory") + { + if (kind == "managed-directory") + { + await fixture.System.Root.CreatePlaceholderAsync(CloudDirectoryPlaceholderSpec.CreateBuilder("directory", "cold-directory").Build()); + } + else + { + Directory.CreateDirectory(Path.Combine(fixture.Root, "directory")); + } + item = fixture.System.GetDirectory("directory"); + identity = CloudPlaceholderIdentity.Create("unused-directory-identity"); + } + else + { + await File.WriteAllTextAsync(fixture.File.FullPath, "local-original"); + item = fixture.File; + identity = CloudPlaceholderIdentity.Create("local-access"); + } + bool directory = item.Kind == CloudItemKind.Directory; + FileSystemSecurity original = directory + ? new DirectoryInfo(item.FullPath).GetAccessControl(AccessControlSections.Access | AccessControlSections.Owner | AccessControlSections.Group) + : new FileInfo(item.FullPath).GetAccessControl(AccessControlSections.Access | AccessControlSections.Owner | AccessControlSections.Group); + CloudLocalFileBinding binding = Assert.IsType((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); + } +} From 78aabe148a71ce84861757c869ef71837f83dfba Mon Sep 17 00:00:00 2001 From: Quaternion8192 <70364461+Quaternion8192@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:30:53 +0800 Subject: [PATCH 07/13] feat(items): report protected operation cancellation and drain --- docs/protected-local-operations.md | 14 ++ eng/api-baseline.json | 7 + ...loudFileSystem.ProtectedLocalOperations.cs | 32 +++- src/CfSharp/FileSystem/CloudFileSystem.cs | 1 + .../Items/CloudProtectedLocalOperation.cs | 12 +- .../CloudProtectedLocalOperationContext.cs | 12 ++ .../CloudProtectedLocalOperationLifetime.cs | 106 +++++++++++ .../ProtectedLocalCancellationTests.cs | 174 ++++++++++++++++++ .../ProtectedLocalOperationTests.cs | 2 +- .../ProtectedLocalOperationRequestTests.cs | 17 ++ 10 files changed, 365 insertions(+), 12 deletions(-) create mode 100644 src/CfSharp/Items/CloudProtectedLocalOperationLifetime.cs create mode 100644 tests/CfSharp.IntegrationTests/ProtectedLocalCancellationTests.cs diff --git a/docs/protected-local-operations.md b/docs/protected-local-operations.md index 17d7813..519d080 100644 --- a/docs/protected-local-operations.md +++ b/docs/protected-local-operations.md @@ -84,6 +84,20 @@ An uncooperative callback or stalled native driver can delay return; there is no 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. diff --git a/eng/api-baseline.json b/eng/api-baseline.json index 91a7c97..d4f9a0b 100644 --- a/eng/api-baseline.json +++ b/eng/api-baseline.json @@ -1388,6 +1388,8 @@ "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", @@ -1408,14 +1410,19 @@ "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()", diff --git a/src/CfSharp/FileSystem/CloudFileSystem.ProtectedLocalOperations.cs b/src/CfSharp/FileSystem/CloudFileSystem.ProtectedLocalOperations.cs index d2100b7..51b487e 100644 --- a/src/CfSharp/FileSystem/CloudFileSystem.ProtectedLocalOperations.cs +++ b/src/CfSharp/FileSystem/CloudFileSystem.ProtectedLocalOperations.cs @@ -8,6 +8,7 @@ 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, @@ -18,6 +19,8 @@ internal async ValueTask RunProtectedLocalOp 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; @@ -27,34 +30,35 @@ internal async ValueTask RunProtectedLocalOp try { using CloudFileSystemOperationLease operation = await AcquireOperationAsync( - [CloudItemOperationScope.Exact(item.FullPath)], token).ConfigureAwait(false); + [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."); } - token.ThrowIfCancellationRequested(); + stop.ThrowIfCancellationRequested(); stage = CloudProtectedLocalOperationStage.Open; using SafeFileHandle handle = CloudLocalAccessObject.Open(item.FullPath, directory); - context = new(this, item, operation.StateStore, handle, request, token); + context = new(this, item, operation.StateStore, handle, request, stop); try { - await context.InspectAsync(token).ConfigureAwait(false); + await context.InspectAsync(stop).ConfigureAwait(false); if (request.PreparationIdentity is { } identity) { - await context.ConvertToPlaceholderAsync(identity, token).ConfigureAwait(false); + await context.ConvertToPlaceholderAsync(identity, stop).ConfigureAwait(false); } - token.ThrowIfCancellationRequested(); + stop.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); + stop.ThrowIfCancellationRequested(); s_protectedLocalContext.Value = context; try { context.SetStage(CloudProtectedLocalOperationStage.Callback); invoked = true; - await callback(context, token).ConfigureAwait(false); + await callback(context, stop).ConfigureAwait(false); completed = true; } finally @@ -65,12 +69,13 @@ internal async ValueTask RunProtectedLocalOp finally { await context.CloseAsync().ConfigureAwait(false); + await lifetime.SealAndDrainCancellationAsync().ConfigureAwait(false); } if (context.Failure is not null) { System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw(context.Failure); } - token.ThrowIfCancellationRequested(); + stop.ThrowIfCancellationRequested(); } catch (Exception error) { @@ -82,15 +87,22 @@ internal async ValueTask RunProtectedLocalOp { _ when context is { NativeIdentityPrepared: true, DurableProjectionCommitted: false } => CloudProtectedLocalOperationOutcome.NativeAppliedProjectionPending, CloudProtectedLocalRejectedException rejected => rejected.Outcome, - OperationCanceledException when !callbackFault || token.IsCancellationRequested => CloudProtectedLocalOperationOutcome.Canceled, + 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); + invoked, completed, Stopwatch.GetElapsedTime(started), failure, context, lifetime); } private static void RejectProtectedLocalReentry() diff --git a/src/CfSharp/FileSystem/CloudFileSystem.cs b/src/CfSharp/FileSystem/CloudFileSystem.cs index a5398cb..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. diff --git a/src/CfSharp/Items/CloudProtectedLocalOperation.cs b/src/CfSharp/Items/CloudProtectedLocalOperation.cs index 40fa967..ea9a05b 100644 --- a/src/CfSharp/Items/CloudProtectedLocalOperation.cs +++ b/src/CfSharp/Items/CloudProtectedLocalOperation.cs @@ -104,7 +104,7 @@ public sealed partial class CloudProtectedLocalOperationResult internal CloudProtectedLocalOperationResult(CloudProtectedLocalOperationOutcome outcome, CloudProtectedLocalOperationStage stage, CloudLocalFileBinding expected, CloudItemSnapshot? snapshot, bool callbackStarted, bool callbackCompleted, - TimeSpan elapsed, Exception? error, CloudProtectedLocalOperationContext? context) + TimeSpan elapsed, Exception? error, CloudProtectedLocalOperationContext? context, CloudProtectedLocalOperationLifetime lifetime) { Outcome = outcome; Stage = stage; @@ -121,6 +121,9 @@ internal CloudProtectedLocalOperationResult(CloudProtectedLocalOperationOutcome 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. @@ -156,6 +159,13 @@ internal CloudProtectedLocalOperationResult(CloudProtectedLocalOperationOutcome /// 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; } diff --git a/src/CfSharp/Items/CloudProtectedLocalOperationContext.cs b/src/CfSharp/Items/CloudProtectedLocalOperationContext.cs index 6d6b8ed..4dabb74 100644 --- a/src/CfSharp/Items/CloudProtectedLocalOperationContext.cs +++ b/src/CfSharp/Items/CloudProtectedLocalOperationContext.cs @@ -32,6 +32,18 @@ public sealed partial class CloudProtectedLocalOperationContext 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 && (_stop.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, CancellationToken stop) diff --git a/src/CfSharp/Items/CloudProtectedLocalOperationLifetime.cs b/src/CfSharp/Items/CloudProtectedLocalOperationLifetime.cs new file mode 100644 index 0000000..d2781ae --- /dev/null +++ b/src/CfSharp/Items/CloudProtectedLocalOperationLifetime.cs @@ -0,0 +1,106 @@ +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; + internal bool TimedOut => _deadline.IsCancellationRequested && !_caller.IsCancellationRequested && !_owner.IsCancellationRequested; + internal bool BudgetExpired => _deadline.IsCancellationRequested; + internal Exception? CancellationCallbackError { get; private set; } + + 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/tests/CfSharp.IntegrationTests/ProtectedLocalCancellationTests.cs b/tests/CfSharp.IntegrationTests/ProtectedLocalCancellationTests.cs new file mode 100644 index 0000000..520ebc7 --- /dev/null +++ b/tests/CfSharp.IntegrationTests/ProtectedLocalCancellationTests.cs @@ -0,0 +1,174 @@ +namespace CfSharp.IntegrationTests; + +public sealed partial class CloudContentConfirmationTests +{ + [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/ProtectedLocalOperationTests.cs b/tests/CfSharp.IntegrationTests/ProtectedLocalOperationTests.cs index a53c806..15c90b9 100644 --- a/tests/CfSharp.IntegrationTests/ProtectedLocalOperationTests.cs +++ b/tests/CfSharp.IntegrationTests/ProtectedLocalOperationTests.cs @@ -171,7 +171,7 @@ public async Task ProtectedLocalOwnerShutdownDrainsAdmittedCallbackAndRejectsNew { release.TrySetResult(); } - Assert.Equal(CloudProtectedLocalOperationOutcome.Completed, (await pending).Outcome); + Assert.Equal(CloudProtectedLocalOperationOutcome.Canceled, (await pending).Outcome); await shutdown.WaitAsync(TimeSpan.FromSeconds(10)); Assert.Equal(CloudFileSystemLifecycleState.Disposed, fixture.System.LifecycleState); } diff --git a/tests/CfSharp.Tests/ProtectedLocalOperationRequestTests.cs b/tests/CfSharp.Tests/ProtectedLocalOperationRequestTests.cs index 81ed580..7a3e16e 100644 --- a/tests/CfSharp.Tests/ProtectedLocalOperationRequestTests.cs +++ b/tests/CfSharp.Tests/ProtectedLocalOperationRequestTests.cs @@ -25,4 +25,21 @@ public void LocalPreparationDoesNotAcceptAnUploadedRevision() 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))); + } } From 7d260bea1fb834a3aec71b90ce75cfdb0d43f855 Mon Sep 17 00:00:00 2001 From: Quaternion8192 <70364461+Quaternion8192@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:42:04 +0800 Subject: [PATCH 08/13] test(items): verify protected operation failure and restart boundaries --- .../CloudContentConfirmationTests.cs | 4 +- .../ProtectedLocalBoundaryTests.cs | 228 ++++++++++++++++++ 2 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 tests/CfSharp.IntegrationTests/ProtectedLocalBoundaryTests.cs 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 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.True(reparse.Outcome is CloudProtectedLocalOperationOutcome.LocalObjectMismatch or CloudProtectedLocalOperationOutcome.NotApplicable); + 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); + } +} From a4ad8828059f6a3b4ae4ffd337c793a989ed03ad Mon Sep 17 00:00:00 2001 From: Quaternion8192 <70364461+Quaternion8192@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:45:50 +0800 Subject: [PATCH 09/13] docs(items): demonstrate protected local initialization --- README.md | 6 + docs/index.md | 1 + docs/platform-support.md | 9 ++ docs/protected-local-operations.md | 21 ++++ eng/docs/build-docs.ps1 | 2 + eng/prepare-preview.ps1 | 104 +++++++++++++++++- .../ProtectedLocalInitialization.cs | 44 ++++++++ 7 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 samples/CfSharp.SampleProvider/ProtectedLocalInitialization.cs 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/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 index 519d080..0b2e782 100644 --- a/docs/protected-local-operations.md +++ b/docs/protected-local-operations.md @@ -106,3 +106,24 @@ The immutable receipt records the observed binding/snapshot, last work stage, ca 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. 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); + } +} From 473c1cbab4919a0ece1e59e4fd5136fc927d472c Mon Sep 17 00:00:00 2001 From: Quaternion8192 <70364461+Quaternion8192@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:54:27 +0800 Subject: [PATCH 10/13] fix(items): require recovered local projection before DACL writes --- docs/protected-local-operations.md | 7 +++++ .../Items/CloudProtectedLocalAccess.cs | 10 +++++-- .../ProtectedLocalConversionTests.cs | 29 +++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/docs/protected-local-operations.md b/docs/protected-local-operations.md index 0b2e782..8f9ffb1 100644 --- a/docs/protected-local-operations.md +++ b/docs/protected-local-operations.md @@ -56,6 +56,8 @@ returns `NativeAppliedProjectionPending` and prevents a preparation-gated callba 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 @@ -127,3 +129,8 @@ The isolated NuGet consumer exercises conversion, DACL read/apply/readback, cold 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/src/CfSharp/Items/CloudProtectedLocalAccess.cs b/src/CfSharp/Items/CloudProtectedLocalAccess.cs index 338a35c..a437be9 100644 --- a/src/CfSharp/Items/CloudProtectedLocalAccess.cs +++ b/src/CfSharp/Items/CloudProtectedLocalAccess.cs @@ -43,8 +43,9 @@ public ValueTask ReadAccessDescriptorAsync(CancellationToken /// 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. Directory mode - /// provides metadata access only: Windows inheritance may affect descendants; membership and + /// 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. @@ -71,6 +72,11 @@ public ValueTask ApplyAccessDescriptorAsync(FileSystemSecuri 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)) { diff --git a/tests/CfSharp.IntegrationTests/ProtectedLocalConversionTests.cs b/tests/CfSharp.IntegrationTests/ProtectedLocalConversionTests.cs index 376634e..db39bd0 100644 --- a/tests/CfSharp.IntegrationTests/ProtectedLocalConversionTests.cs +++ b/tests/CfSharp.IntegrationTests/ProtectedLocalConversionTests.cs @@ -5,6 +5,35 @@ 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)] From a8c49c57772103fea0cd9bc6c1d5c4aa28d740a4 Mon Sep 17 00:00:00 2001 From: Quaternion8192 <70364461+Quaternion8192@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:56:43 +0800 Subject: [PATCH 11/13] fix(items): reject unsupported objects before root queries --- .../CloudProtectedLocalOperationContext.cs | 18 ++++++++++-------- .../ProtectedLocalBoundaryTests.cs | 2 +- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/CfSharp/Items/CloudProtectedLocalOperationContext.cs b/src/CfSharp/Items/CloudProtectedLocalOperationContext.cs index 4dabb74..1d6ebe4 100644 --- a/src/CfSharp/Items/CloudProtectedLocalOperationContext.cs +++ b/src/CfSharp/Items/CloudProtectedLocalOperationContext.cs @@ -84,6 +84,16 @@ internal ValueTask InspectItemAsync(CloudItem item, Cancellat 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); @@ -103,14 +113,6 @@ internal NativeFileMetadata ValidateObject() throw new CloudProtectedLocalRejectedException(CloudProtectedLocalOperationOutcome.LocalObjectMismatch, "The original native object or its namespace no longer matches the request."); } - 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."); - } return facts; } diff --git a/tests/CfSharp.IntegrationTests/ProtectedLocalBoundaryTests.cs b/tests/CfSharp.IntegrationTests/ProtectedLocalBoundaryTests.cs index 44858c9..cc6414e 100644 --- a/tests/CfSharp.IntegrationTests/ProtectedLocalBoundaryTests.cs +++ b/tests/CfSharp.IntegrationTests/ProtectedLocalBoundaryTests.cs @@ -144,7 +144,7 @@ public async Task ProtectedLocalBoundaryRejectsForeignReparseDataAndWrongRootBin CloudProtectedLocalOperationResult reparse = await fixture.File.RunProtectedLocalOperationAsync(new(binding), (_, _) => throw new InvalidOperationException("Foreign reparse data cannot enter a protected callback.")); Assert.False(reparse.CallbackStarted); - Assert.True(reparse.Outcome is CloudProtectedLocalOperationOutcome.LocalObjectMismatch or CloudProtectedLocalOperationOutcome.NotApplicable); + Assert.Equal(CloudProtectedLocalOperationOutcome.NotApplicable, reparse.Outcome); Assert.False(reparse.AccessDescriptorApplied); Assert.Equal("original", await File.ReadAllTextAsync(original)); File.Delete(fixture.File.FullPath); From 443ad8abcb9a466b9004817a97fc3694f231e630 Mon Sep 17 00:00:00 2001 From: Quaternion8192 <70364461+Quaternion8192@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:27:18 +0800 Subject: [PATCH 12/13] test(items): isolate cancellation faults from provider work --- .../CloudContentConfirmationRecoveryTests.cs | 27 ++++++++++++++----- .../ProtectedLocalConversionTests.cs | 24 ++++++++++++++++- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/tests/CfSharp.IntegrationTests/CloudContentConfirmationRecoveryTests.cs b/tests/CfSharp.IntegrationTests/CloudContentConfirmationRecoveryTests.cs index 612c4ae..38f8c38 100644 --- a/tests/CfSharp.IntegrationTests/CloudContentConfirmationRecoveryTests.cs +++ b/tests/CfSharp.IntegrationTests/CloudContentConfirmationRecoveryTests.cs @@ -303,6 +303,8 @@ 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; } @@ -310,6 +312,16 @@ private sealed class FaultFactory(string path) : ICloudStateStoreFactory 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); @@ -318,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; @@ -340,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; @@ -359,12 +372,12 @@ public async ValueTask CommitAsync(CancellationToken cancellationToken = default public async ValueTask DisposeAsync() { await inner.DisposeAsync(); - if (faults.AfterTransactionDispose is { } after) + if (applyFaults && faults.AfterTransactionDispose is { } after) { faults.AfterTransactionDispose = null; after(); } - if (faults.NextDisposeFault is { } exception) + if (applyFaults && faults.NextDisposeFault is { } exception) { faults.NextDisposeFault = null; throw exception; diff --git a/tests/CfSharp.IntegrationTests/ProtectedLocalConversionTests.cs b/tests/CfSharp.IntegrationTests/ProtectedLocalConversionTests.cs index db39bd0..3211471 100644 --- a/tests/CfSharp.IntegrationTests/ProtectedLocalConversionTests.cs +++ b/tests/CfSharp.IntegrationTests/ProtectedLocalConversionTests.cs @@ -163,6 +163,8 @@ public async Task ProtectedLocalConversionCancellationRespectsTheNativeBoundary( 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 = () => @@ -177,15 +179,35 @@ public async Task ProtectedLocalConversionCancellationRespectsTheNativeBoundary( { factory.BeforeTransaction = () => { - factory.AfterTransactionDispose = cancellation.Cancel; + 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); From 0dec5452b310876e771ba8b35b958b05d98fa3f6 Mon Sep 17 00:00:00 2001 From: Quaternion8192 <70364461+Quaternion8192@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:41:39 +0800 Subject: [PATCH 13/13] fix(items): honor cancellation before linked signals propagate --- docs/protected-local-operations.md | 2 + ...loudFileSystem.ProtectedLocalOperations.cs | 10 +-- .../Items/CloudProtectedLocalAccess.cs | 1 + .../Items/CloudProtectedLocalConversion.cs | 5 +- .../CloudProtectedLocalOperationContext.cs | 21 ++++-- .../CloudProtectedLocalOperationLifetime.cs | 11 ++- .../ProtectedLocalCancellationTests.cs | 69 +++++++++++++++++++ 7 files changed, 106 insertions(+), 13 deletions(-) diff --git a/docs/protected-local-operations.md b/docs/protected-local-operations.md index 8f9ffb1..0154b46 100644 --- a/docs/protected-local-operations.md +++ b/docs/protected-local-operations.md @@ -82,6 +82,8 @@ 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. diff --git a/src/CfSharp/FileSystem/CloudFileSystem.ProtectedLocalOperations.cs b/src/CfSharp/FileSystem/CloudFileSystem.ProtectedLocalOperations.cs index 51b487e..bb150de 100644 --- a/src/CfSharp/FileSystem/CloudFileSystem.ProtectedLocalOperations.cs +++ b/src/CfSharp/FileSystem/CloudFileSystem.ProtectedLocalOperations.cs @@ -37,10 +37,10 @@ internal async ValueTask RunProtectedLocalOp throw new CloudProtectedLocalRejectedException(CloudProtectedLocalOperationOutcome.Unsupported, "Use exclusive mode for one file or metadata-only mode for one directory; tree freezing is unavailable."); } - stop.ThrowIfCancellationRequested(); + lifetime.ThrowIfCancellationRequested(); stage = CloudProtectedLocalOperationStage.Open; using SafeFileHandle handle = CloudLocalAccessObject.Open(item.FullPath, directory); - context = new(this, item, operation.StateStore, handle, request, stop); + context = new(this, item, operation.StateStore, handle, request, lifetime); try { await context.InspectAsync(stop).ConfigureAwait(false); @@ -48,11 +48,11 @@ internal async ValueTask RunProtectedLocalOp { await context.ConvertToPlaceholderAsync(identity, stop).ConfigureAwait(false); } - stop.ThrowIfCancellationRequested(); + 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); - stop.ThrowIfCancellationRequested(); + lifetime.ThrowIfCancellationRequested(); s_protectedLocalContext.Value = context; try { @@ -75,7 +75,7 @@ internal async ValueTask RunProtectedLocalOp { System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw(context.Failure); } - stop.ThrowIfCancellationRequested(); + lifetime.ThrowIfCancellationRequested(); } catch (Exception error) { diff --git a/src/CfSharp/Items/CloudProtectedLocalAccess.cs b/src/CfSharp/Items/CloudProtectedLocalAccess.cs index a437be9..0889d61 100644 --- a/src/CfSharp/Items/CloudProtectedLocalAccess.cs +++ b/src/CfSharp/Items/CloudProtectedLocalAccess.cs @@ -86,6 +86,7 @@ public ValueTask ApplyAccessDescriptorAsync(FileSystemSecuri } LocalAccessDescriptor access = new(_handle, directory); access.SetSecurityDescriptorBinaryForm(copy, AccessControlSections.Access); + ThrowIfCancellationRequested(cancellationToken); token.ThrowIfCancellationRequested(); access.Apply(_handle); AccessDescriptorApplied = true; diff --git a/src/CfSharp/Items/CloudProtectedLocalConversion.cs b/src/CfSharp/Items/CloudProtectedLocalConversion.cs index 45c713f..fa9faaf 100644 --- a/src/CfSharp/Items/CloudProtectedLocalConversion.cs +++ b/src/CfSharp/Items/CloudProtectedLocalConversion.cs @@ -67,7 +67,7 @@ public ValueTask ConvertToPlaceholderAsync(Cloud { throw new ArgumentException("Local preparation cannot introduce an accepted remote revision.", nameof(identity)); } - return ExecuteAsync(token => PrepareIdentityCoreAsync(identity, token), cancellationToken); + return ExecuteAsync(token => PrepareIdentityCoreAsync(identity, token, cancellationToken), cancellationToken); } internal ValueTask ConvertItemAsync(CloudItem item, CloudPlaceholderIdentity identity, @@ -87,7 +87,7 @@ internal ValueTask ConvertItemAsync(CloudItem it } private async ValueTask PrepareIdentityCoreAsync(CloudPlaceholderIdentity identity, - CancellationToken token) + CancellationToken token, CancellationToken stepToken) { SetStage(CloudProtectedLocalOperationStage.NativePreparation); if (_item.Kind != CloudItemKind.File || _preparedIdentity is not null && !_preparedIdentity.Equals(identity)) @@ -105,6 +105,7 @@ private async ValueTask PrepareIdentityCoreAsync throw new CloudProtectedLocalRejectedException(CloudProtectedLocalOperationOutcome.NotApplicable, "An existing placeholder must carry the exact original local identity."); } + ThrowIfCancellationRequested(stepToken); token.ThrowIfCancellationRequested(); if (!placeholder) { diff --git a/src/CfSharp/Items/CloudProtectedLocalOperationContext.cs b/src/CfSharp/Items/CloudProtectedLocalOperationContext.cs index 1d6ebe4..cf0d8a1 100644 --- a/src/CfSharp/Items/CloudProtectedLocalOperationContext.cs +++ b/src/CfSharp/Items/CloudProtectedLocalOperationContext.cs @@ -26,6 +26,7 @@ public sealed partial class CloudProtectedLocalOperationContext 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; @@ -41,19 +42,20 @@ public sealed partial class CloudProtectedLocalOperationContext /// 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 && (_stop.IsCancellationRequested || !Volatile.Read(ref _accepting)); + 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, CancellationToken stop) + SafeFileHandle handle, CloudProtectedLocalOperationRequest request, CloudProtectedLocalOperationLifetime lifetime) { _item = item; _owner = owner; _store = store; _handle = handle; _request = request; - _stop = stop; + _lifetime = lifetime; + _stop = lifetime.Token; } internal CloudItemSnapshot? LastSnapshot { get; private set; } @@ -137,8 +139,7 @@ private async ValueTask ExecuteAsync(Func> lock (_admission) { ObjectDisposedException.ThrowIf(!_accepting, this); - _stop.ThrowIfCancellationRequested(); - token.ThrowIfCancellationRequested(); + ThrowIfCancellationRequested(token); _active++; } bool entered = false; @@ -147,6 +148,7 @@ private async ValueTask ExecuteAsync(Func> 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); } @@ -181,6 +183,15 @@ private async ValueTask ExecuteAsync(Func> } } + 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) diff --git a/src/CfSharp/Items/CloudProtectedLocalOperationLifetime.cs b/src/CfSharp/Items/CloudProtectedLocalOperationLifetime.cs index d2781ae..1c62dda 100644 --- a/src/CfSharp/Items/CloudProtectedLocalOperationLifetime.cs +++ b/src/CfSharp/Items/CloudProtectedLocalOperationLifetime.cs @@ -62,11 +62,20 @@ internal CloudProtectedLocalOperationLifetime(CloudProtectedLocalOperationReques } internal CancellationToken Token => _callback.Token; - internal bool IsCancellationRequested => _callback.IsCancellationRequested; + 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) diff --git a/tests/CfSharp.IntegrationTests/ProtectedLocalCancellationTests.cs b/tests/CfSharp.IntegrationTests/ProtectedLocalCancellationTests.cs index 520ebc7..a801038 100644 --- a/tests/CfSharp.IntegrationTests/ProtectedLocalCancellationTests.cs +++ b/tests/CfSharp.IntegrationTests/ProtectedLocalCancellationTests.cs @@ -2,6 +2,75 @@ 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")]