From 381108af2ce87145f63357dc022221c79be6c1ef Mon Sep 17 00:00:00 2001 From: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:45:39 -0700 Subject: [PATCH 01/11] Unify UI and sandbox state under the user .winapp directory Keep operational state independent of cache overrides, preserve coordination, and share the physical root across packaged and unpackaged processes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/sandbox-execution.md | 3 + docs/ui-automation.md | 3 + docs/usage.md | 20 +++ .../InteractiveDesktopPathsHardeningTests.cs | 24 ++++ .../InteractiveDesktopStoreTests.cs | 25 ++++ .../TargetStateDirectoryProviderTests.cs | 127 +++++++++++++++--- .../WinappDirectoryServiceTests.cs | 49 +++++++ .../TargetStateDirectoryProvider.cs | 75 ++++------- .../WinApp.Cli/Helpers/PathSafety.cs | 2 +- src/winapp-CLI/WinApp.Cli/NativeMethods.txt | 2 + .../InteractiveDesktopPaths.cs | 50 +++---- .../Services/WinappDirectoryService.cs | 43 +++++- 12 files changed, 325 insertions(+), 98 deletions(-) diff --git a/docs/sandbox-execution.md b/docs/sandbox-execution.md index 8c619934a..11da9e118 100644 --- a/docs/sandbox-execution.md +++ b/docs/sandbox-execution.md @@ -52,6 +52,9 @@ Developer Mode, and an inbound firewall rule. winapp does not stop an adopted in or remove unrelated apps. There is **no silent host fallback**: a command requesting Sandbox runs there or fails. +Host-side ownership and deployment records use [shared runtime state](usage.md#shared-runtime-state), +independently of the configured cache directory. + ## Running and rebuilding ```powershell diff --git a/docs/ui-automation.md b/docs/ui-automation.md index 3f2c87f8d..cf358cc9a 100644 --- a/docs/ui-automation.md +++ b/docs/ui-automation.md @@ -114,6 +114,9 @@ other, dismiss a menu the other just opened, or move a target out from under a p turn, with no setup and no way to switch it off, so two agents can never type into each other's windows. Read-only commands keep running concurrently. +Coordination uses [shared runtime state](usage.md#shared-runtime-state), independently +of the configured cache directory. + **Continuity between commands is opt-in.** By default each command is a self-contained one-shot: it waits its turn, does its work, and releases the desktop immediately. To keep the desktop across several commands, give them all the same workflow id: diff --git a/docs/usage.md b/docs/usage.md index abac16612..9bb247bf3 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1987,6 +1987,26 @@ $env:WINAPP_CLI_CACHE_DIRECTORY=d:\temp\.winapp Winapp will create this directory automatically when you run commands like `init` or `restore`. +### Shared Runtime State + +`winapp ui` and commands using `--on sandbox` keep shared state under +`%USERPROFILE%\.winapp\state`: + +| Directory | Contents | +|---|---| +| `ui` | Desktop coordination state and locks, separated by Windows sign-in session | +| `targets\` | Sandbox ownership, connection and deployment records, bootstrap files, and lifecycle locks | + +This location is fixed independently of `WINAPP_CLI_CACHE_DIRECTORY`. Changing the +cache location does not give a process a separate desktop or Sandbox. Packaged and +unpackaged winapp installations use the same state location. + +Keep this directory on a writable local drive; network paths are rejected. If winapp +cannot access it, check the directory's permissions and any filesystem links that +redirect it. Do not delete shared state while UI workflows or a managed Sandbox are +running. Save guest work and [end the Sandbox](sandbox-execution.md#removing-an-app-and-ending-the-sandbox) +before removing its state. + ### Update Checks The winapp CLI periodically checks for new versions and displays a one-line notice when an update is available. This check runs in the background and adds no latency to commands. diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopPathsHardeningTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopPathsHardeningTests.cs index a0d50dfdc..f8cf3d5ea 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopPathsHardeningTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopPathsHardeningTests.cs @@ -82,6 +82,30 @@ private static bool HasForeignGrant(string path) .Any(r => r.IdentityReference is SecurityIdentifier sid && sid != currentUser); } + [TestMethod] + public void SecuringUiState_DoesNotChangeCacheOrSiblingTargetPermissions() + { + var cache = Directory.CreateDirectory(Path.Join(_root, ".winapp")); + var targets = Directory.CreateDirectory(Path.Join(cache.FullName, "state", "targets")); + var cacheMarker = Path.Join(cache.FullName, "cache-marker.txt"); + var targetMarker = Path.Join(targets.FullName, "target-state.json"); + File.WriteAllText(cacheMarker, "cache"); + File.WriteAllText(targetMarker, "target"); + var cachePermissions = cache.GetAccessControl().GetSecurityDescriptorSddlForm(AccessControlSections.Access); + var targetPermissions = targets.GetAccessControl().GetSecurityDescriptorSddlForm(AccessControlSections.Access); + Environment.SetEnvironmentVariable( + InteractiveDesktopPaths.LockDirectoryOverrideVariable, Path.Join(cache.FullName, "state", "ui")); + + var paths = new InteractiveDesktopPaths(new ProcessInspector()); + paths.EnsureDirectories(); + + Assert.IsTrue(new DirectoryInfo(paths.LockDirectory).GetAccessControl().AreAccessRulesProtected); + Assert.AreEqual(cachePermissions, cache.GetAccessControl().GetSecurityDescriptorSddlForm(AccessControlSections.Access)); + Assert.AreEqual(targetPermissions, targets.GetAccessControl().GetSecurityDescriptorSddlForm(AccessControlSections.Access)); + Assert.AreEqual("cache", File.ReadAllText(cacheMarker)); + Assert.AreEqual("target", File.ReadAllText(targetMarker)); + } + [TestMethod] public void APreSeededStateFileWithAnEveryoneGrantIsDiscarded() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs index e014a1a5d..522d1aeb6 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs @@ -566,6 +566,31 @@ public void Lease_FileNameRoundTripsThroughTheProcessIdentity() // ------------------------------------------------------------------------- lock directory setup + [TestMethod] + public void Paths_DefaultDirectory_IsSharedUserStateRegardlessOfCacheOverride() + { + var previousCache = Environment.GetEnvironmentVariable("WINAPP_CLI_CACHE_DIRECTORY"); + Environment.SetEnvironmentVariable(InteractiveDesktopPaths.LockDirectoryOverrideVariable, null); + try + { + var expected = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".winapp", "state", "ui"); + + foreach (var cache in new[] { Path.Join(_lockDirectory, "cache-one"), Path.Join(_lockDirectory, "cache-two") }) + { + Environment.SetEnvironmentVariable("WINAPP_CLI_CACHE_DIRECTORY", cache); + var paths = new InteractiveDesktopPaths(_inspector); + + Assert.AreEqual(expected, paths.LockDirectory); + Assert.AreEqual(Path.Join(expected, "participants"), paths.ParticipantsDirectory); + } + } + finally + { + Environment.SetEnvironmentVariable("WINAPP_CLI_CACHE_DIRECTORY", previousCache); + } + } + [TestMethod] public void EnsureDirectories_RepairsAnExistingDirectoryWithInheritedPermissions() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/TargetStateDirectoryProviderTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/TargetStateDirectoryProviderTests.cs index 9c4e43890..c89d1a73b 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/TargetStateDirectoryProviderTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/TargetStateDirectoryProviderTests.cs @@ -1,60 +1,155 @@ // Copyright (c) Microsoft Corporation and Contributors. All rights reserved. // Licensed under the MIT License. +using WinApp.Cli.ExecutionTargets.Abstractions; using WinApp.Cli.ExecutionTargets.Orchestration; using WinApp.Cli.ExecutionTargets.WindowsSandbox; namespace WinApp.Cli.Tests; [TestClass] +[DoNotParallelize] // The root and cache environment overrides are process-wide. public class TargetStateDirectoryProviderTests { + private string? _previousRoot; + + [TestInitialize] + public void Setup() + { + _previousRoot = Environment.GetEnvironmentVariable(TargetStateDirectoryProvider.RootOverrideVariable); + Environment.SetEnvironmentVariable(TargetStateDirectoryProvider.RootOverrideVariable, null); + } + + [TestCleanup] + public void Cleanup() => + Environment.SetEnvironmentVariable(TargetStateDirectoryProvider.RootOverrideVariable, _previousRoot); + + [TestMethod] + public void DefaultDirectory_IsSharedUserStateRegardlessOfCacheOverride() + { + var previousCache = Environment.GetEnvironmentVariable("WINAPP_CLI_CACHE_DIRECTORY"); + try + { + var expected = Path.Join( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".winapp", "state", "targets", WindowsSandboxTarget.Default.StateKey); + foreach (var cache in new[] { Path.Join(Path.GetTempPath(), "cache-one"), Path.Join(Path.GetTempPath(), "cache-two") }) + { + Environment.SetEnvironmentVariable("WINAPP_CLI_CACHE_DIRECTORY", cache); + var provider = new TargetStateDirectoryProvider(); + + Assert.AreEqual(expected, provider.GetTargetRoot(WindowsSandboxTarget.Default, create: false).FullName); + } + } + finally + { + Environment.SetEnvironmentVariable("WINAPP_CLI_CACHE_DIRECTORY", previousCache); + } + } + [TestMethod] - public void PackagedProcess_UsesPhysicalLocalAppDataPath() + public void DefaultDirectory_UsesProfileRootWithoutCreatingIt() { - var localCache = Path.Join(Path.GetTempPath(), "Packages", "winapp", "LocalCache"); + var profile = Path.Join(Path.GetTempPath(), $"winapp-profile-{Guid.NewGuid():N}"); var provider = new TargetStateDirectoryProvider { - PackagedLocalAppDataProvider = () => Path.Join(localCache, "Local"), - LocalAppDataProvider = () => throw new AssertFailedException("The unpackaged path must not be used."), + UserProfileProvider = () => profile, }; var root = provider.GetTargetRoot(WindowsSandboxTarget.Default, create: false); Assert.AreEqual( - Path.Join(localCache, "Local", "Microsoft", "WinApp", "Targets", WindowsSandboxTarget.Default.StateKey), + Path.Join(profile, ".winapp", "state", "targets", WindowsSandboxTarget.Default.StateKey), root.FullName); + Assert.IsFalse(Directory.Exists(profile)); } [TestMethod] - public void UnpackagedProcess_UsesOrdinaryLocalAppDataPath() + public void DefaultDirectory_CreatesTargetUnderUserState() { - var localAppData = Path.Join(Path.GetTempPath(), "Local"); + var profile = Path.Join(Path.GetTempPath(), $"winapp-profile-{Guid.NewGuid():N}"); var provider = new TargetStateDirectoryProvider { - PackagedLocalAppDataProvider = () => null, - LocalAppDataProvider = () => localAppData, + UserProfileProvider = () => profile, }; - var root = provider.GetTargetRoot(WindowsSandboxTarget.Default, create: false); + try + { + var root = provider.GetTargetRoot(WindowsSandboxTarget.Default); - Assert.AreEqual( - Path.Join(localAppData, "Microsoft", "WinApp", "Targets", WindowsSandboxTarget.Default.StateKey), - root.FullName); + Assert.AreEqual( + Path.Join(profile, ".winapp", "state", "targets", WindowsSandboxTarget.Default.StateKey), + root.FullName); + Assert.IsTrue(root.Exists); + } + finally + { + if (Directory.Exists(profile)) + { + Directory.Delete(profile, recursive: true); + } + } } [TestMethod] - public void ExplicitOverride_WinsOverPackagedPath() + public void ExplicitOverride_WinsOverEnvironmentAndProfile() { var rootOverride = Path.Join(Path.GetTempPath(), "override"); + Environment.SetEnvironmentVariable(TargetStateDirectoryProvider.RootOverrideVariable, @"\\server\unused"); var provider = new TargetStateDirectoryProvider(rootOverride) { - PackagedLocalAppDataProvider = () => throw new AssertFailedException("The packaged path must not be consulted."), - LocalAppDataProvider = () => throw new AssertFailedException("The unpackaged path must not be consulted."), + UserProfileProvider = () => throw new AssertFailedException("The profile must not be consulted."), + }; + + var root = provider.GetTargetRoot(WindowsSandboxTarget.Default, create: false); + + Assert.AreEqual(Path.Join(rootOverride, WindowsSandboxTarget.Default.StateKey), root.FullName); + } + + [TestMethod] + public void EnvironmentOverride_WinsOverProfile() + { + var rootOverride = Path.Join(Path.GetTempPath(), "override"); + Environment.SetEnvironmentVariable(TargetStateDirectoryProvider.RootOverrideVariable, rootOverride); + var provider = new TargetStateDirectoryProvider + { + UserProfileProvider = () => throw new AssertFailedException("The profile must not be consulted."), }; var root = provider.GetTargetRoot(WindowsSandboxTarget.Default, create: false); Assert.AreEqual(Path.Join(rootOverride, WindowsSandboxTarget.Default.StateKey), root.FullName); } + + [TestMethod] + [DataRow("")] + [DataRow("relative\\profile")] + [DataRow(@"\\server\share\profile")] + public void InvalidProfile_FailsWithoutFallingBack(string profile) + { + var provider = new TargetStateDirectoryProvider { UserProfileProvider = () => profile }; + + var ex = Assert.ThrowsExactly( + () => provider.GetTargetRoot(WindowsSandboxTarget.Default, create: false)); + + Assert.AreEqual(ExecutionTargetErrorCodes.TargetStale, ex.Error.Code); + StringAssert.Contains(ex.Error.UserAction, "%USERPROFILE%\\.winapp\\state"); + } + + [TestMethod] + [DataRow("relative\\targets")] + [DataRow(@"\\server\share\targets")] + [DataRow(@"\\?\UNC\server\share\targets")] + public void InvalidOverride_FailsWithoutFallingBack(string rootOverride) + { + var provider = new TargetStateDirectoryProvider(rootOverride) + { + UserProfileProvider = () => throw new AssertFailedException("An invalid override must not fall back."), + }; + + var ex = Assert.ThrowsExactly( + () => provider.GetTargetRoot(WindowsSandboxTarget.Default, create: false)); + + Assert.AreEqual(ExecutionTargetErrorCodes.TargetStale, ex.Error.Code); + } } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/WinappDirectoryServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/WinappDirectoryServiceTests.cs index a8da45f9a..8af7756e7 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/WinappDirectoryServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/WinappDirectoryServiceTests.cs @@ -203,4 +203,53 @@ public void GetLocalWinappDirectory_WhenGlobalIsInParentDir_DontReturnGlobalAsLo Assert.AreEqual(topWinAppDir.FullName, result.FullName, "Should return the top .winapp directory when local and global are the same"); } + + [TestMethod] + public void GetLocalWinappDirectory_WithRelocatedCache_SkipsProfileStateDirectory() + { + var profile = _tempDirectory.CreateSubdirectory("profile"); + profile.CreateSubdirectory(".winapp").CreateSubdirectory("state"); + var project = profile.CreateSubdirectory("project"); + var directoryService = new WinappDirectoryService(GetRequiredService()) + { + UserProfileProvider = () => profile.FullName, + }; + directoryService.SetCacheDirectoryForTesting(_tempDirectory.CreateSubdirectory("cache")); + + var result = directoryService.GetLocalWinappDirectory(project); + + Assert.AreEqual(_testWinappDirectory.FullName, result.FullName, + "Creating shared state must not make the home .winapp a project workspace."); + } + + [TestMethod] + public void GetUserStateDirectory_ResolvesWithoutCreatingDirectories() + { + var profile = Path.Join(_tempDirectory.FullName, "unused-profile"); + + Assert.AreEqual(Path.Join(profile, ".winapp", "state"), WinappDirectoryService.GetUserStateDirectory(profile)); + Assert.IsFalse(Directory.Exists(profile)); + } + + [TestMethod] + [DataRow(@"\\?\")] + [DataRow(@"\\.\")] + public void ValidateStateDirectory_AcceptsExtendedLocalDrivePaths(string prefix) + { + var path = prefix + Path.Join(_tempDirectory.FullName, ".winapp", "state"); + + Assert.AreEqual(Path.GetFullPath(path), WinappDirectoryService.ValidateStateDirectory(path)); + } + + [TestMethod] + [DataRow("")] + [DataRow(" ")] + [DataRow("relative\\profile")] + [DataRow(@"C:profile")] + [DataRow(@"\\server\share\profile")] + [DataRow(@"\\?\UNC\server\share\profile")] + public void GetUserStateDirectory_RejectsUnavailableOrNonlocalProfile(string profile) + { + Assert.ThrowsExactly(() => WinappDirectoryService.GetUserStateDirectory(profile)); + } } diff --git a/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/TargetStateDirectoryProvider.cs b/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/TargetStateDirectoryProvider.cs index df8576ad7..f89aa1a9d 100644 --- a/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/TargetStateDirectoryProvider.cs +++ b/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/TargetStateDirectoryProvider.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using WinApp.Cli.ExecutionTargets.Abstractions; +using WinApp.Cli.Services; namespace WinApp.Cli.ExecutionTargets.Orchestration; @@ -16,13 +17,11 @@ internal interface ITargetStateDirectoryProvider } /// -/// Default provider rooted at the physical equivalent of -/// %LOCALAPPDATA%\Microsoft\WinApp\Targets (spec §"Host coordination and state"). +/// Default provider rooted at %USERPROFILE%\.winapp\state\targets. /// /// -/// This deliberately differs from the repository's usual %USERPROFILE%\.winapp cache root: -/// the spec pins this location, and giving each target its own state root is what allows future -/// targets to mutate concurrently without sharing a lock or a state file. +/// State is independent of the cache override and package identity so every process managing a +/// target shares its ownership record and locks. Each target has its own subdirectory. /// /// The root can be redirected two ways. Tests pass directly, which /// keeps them isolated under the assembly's method-level parallelism; CI and end-to-end runs set @@ -31,29 +30,16 @@ internal interface ITargetStateDirectoryProvider /// /// /// -/// Explicit targets root. When null the environment variable, then %LOCALAPPDATA%, is used. +/// Explicit targets root. When null the environment variable, then the user state root, is used. /// internal sealed class TargetStateDirectoryProvider(string? rootOverride = null) : ITargetStateDirectoryProvider { /// Environment override for the state root. internal const string RootOverrideVariable = "WINAPP_TARGET_STATE_ROOT"; - /// - /// Resolves the physical packaged-app equivalent of %LOCALAPPDATA%, or null when the - /// process has no package identity. - /// - /// - /// A full-trust packaged process sees the ordinary LocalAppData path, but writes beneath it are - /// redirected to LocalCache\Local. Passing the logical path to an out-of-package broker - /// such as wsb.exe therefore points it at a directory that does not exist. Using the - /// physical path keeps state shared with earlier packaged builds while making mapped folders - /// visible across the process boundary. - /// - internal Func PackagedLocalAppDataProvider { get; set; } = ResolvePackagedLocalAppData; - - /// Unpackaged LocalAppData lookup, exposed as a test seam. - internal Func LocalAppDataProvider { get; set; } = - () => Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + /// Profile lookup shared by packaged and unpackaged processes; test seam. + internal Func UserProfileProvider { get; set; } = + () => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); /// public DirectoryInfo GetTargetRoot(ExecutionTargetRef target, bool create = true) @@ -73,38 +59,29 @@ public DirectoryInfo GetTargetRoot(ExecutionTargetRef target, bool create = true private string GetTargetsRoot() { - if (!string.IsNullOrWhiteSpace(rootOverride)) - { - return rootOverride; - } - - var environmentRoot = Environment.GetEnvironmentVariable(RootOverrideVariable); - if (!string.IsNullOrWhiteSpace(environmentRoot)) + try { - return environmentRoot; - } + if (!string.IsNullOrWhiteSpace(rootOverride)) + { + return WinappDirectoryService.ValidateStateDirectory(rootOverride); + } - var localAppData = PackagedLocalAppDataProvider() ?? LocalAppDataProvider(); - return TargetPathSafety.CombineInsideRoot(localAppData, "Microsoft", "WinApp", "Targets"); - } + var environmentRoot = Environment.GetEnvironmentVariable(RootOverrideVariable); + if (!string.IsNullOrWhiteSpace(environmentRoot)) + { + return WinappDirectoryService.ValidateStateDirectory(environmentRoot); + } - private static string? ResolvePackagedLocalAppData() - { - try - { - var localCache = Windows.Storage.ApplicationData.Current.LocalCacheFolder.Path; - return string.IsNullOrWhiteSpace(localCache) - ? null - : TargetPathSafety.CombineInsideRoot(localCache, "Local"); - } - catch (InvalidOperationException) - { - return null; + return WinappDirectoryService.ValidateStateDirectory( + Path.Combine(WinappDirectoryService.GetUserStateDirectory(UserProfileProvider()), "targets")); } - catch (System.Runtime.InteropServices.COMException ex) - when (ex.HResult == unchecked((int)0x80073D54)) // APPMODEL_ERROR_NO_PACKAGE + catch (IOException ex) { - return null; + throw ExecutionTargetException.Create( + ExecutionTargetErrorCodes.TargetStale, + $"The execution-target state directory could not be resolved: {ex.Message}", + userAction: "Ensure %USERPROFILE%\\.winapp\\state is on a writable local drive. If WINAPP_TARGET_STATE_ROOT is set, use the same fully qualified local directory in every winapp process.", + innerException: ex); } } } diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/PathSafety.cs b/src/winapp-CLI/WinApp.Cli/Helpers/PathSafety.cs index 945ffefb5..4e2b91242 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/PathSafety.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/PathSafety.cs @@ -172,7 +172,7 @@ internal static bool RedirectsToNetwork( } string? root = Path.GetPathRoot(full); - if (string.IsNullOrEmpty(root)) + if (string.IsNullOrEmpty(root) || PInvoke.GetDriveType(root) == PInvoke.DRIVE_REMOTE) { return true; } diff --git a/src/winapp-CLI/WinApp.Cli/NativeMethods.txt b/src/winapp-CLI/WinApp.Cli/NativeMethods.txt index b3467b350..f05a73f8a 100644 --- a/src/winapp-CLI/WinApp.Cli/NativeMethods.txt +++ b/src/winapp-CLI/WinApp.Cli/NativeMethods.txt @@ -44,6 +44,8 @@ DBG_CONTINUE DBG_EXCEPTION_NOT_HANDLED GetShortPathName GetFullPathName +GetDriveType +DRIVE_REMOTE GetForegroundWindow GetDpiForWindow GetWindowDpiAwarenessContext diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs index 892ae9c0e..02e151b9b 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs @@ -4,14 +4,13 @@ using System.Globalization; using System.Security.AccessControl; using System.Security.Principal; -using WinApp.Cli.Helpers; namespace WinApp.Cli.Services.InteractiveDesktop; /// /// Resolves the coordination file set for the current user and Windows session (spec §7): /// -/// %LOCALAPPDATA%\Microsoft\WinAppCli\locks\ +/// %USERPROFILE%\.winapp\state\ui\ /// interactive-desktop-{session}.state.lock /// interactive-desktop-{session}.state.json /// interactive-desktop-{session}.active.lock @@ -225,47 +224,38 @@ private static string ResolveLockDirectory() return ValidateLockDirectory(overridePath.Trim(), LockDirectoryOverrideVariable); } - var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - if (string.IsNullOrWhiteSpace(localAppData)) + try + { + return ValidateLockDirectory( + Path.Combine(WinappDirectoryService.GetUserStateDirectory(), "ui"), + "%USERPROFILE%\\.winapp\\state"); + } + catch (IOException ex) { throw new UiCoordinationException( UiCoordinationErrorCodes.Unavailable, - "The local application data folder could not be resolved, so UI turn coordination has nowhere to store its state.", - "Ensure LOCALAPPDATA is set for this user, or set WINAPP_UI_LOCK_DIRECTORY to the same fully qualified local directory for every winapp process on this desktop."); + $"The UI coordination directory could not be resolved: {ex.Message}", + "Ensure %USERPROFILE%\\.winapp\\state is on a writable local drive, or set WINAPP_UI_LOCK_DIRECTORY to the same fully qualified local directory for every winapp process on this desktop."); } - - return ValidateLockDirectory( - Path.Combine(localAppData, "Microsoft", "WinAppCli", "locks"), - "LOCALAPPDATA"); } private static string ValidateLockDirectory(string path, string source) { - // A relative path would resolve against the caller's working directory, so two processes in - // different directories would coordinate against different files and silently not cooperate. - if (!Path.IsPathFullyQualified(path)) + try { - throw new UiCoordinationException( - UiCoordinationErrorCodes.Unavailable, - $"The UI coordination directory resolved from {source} is not a fully qualified path.", - "Set WINAPP_UI_LOCK_DIRECTORY to the same fully qualified local directory for every winapp process on this desktop, such as C:\\Temp\\winapp-locks."); + return WinappDirectoryService.ValidateStateDirectory(path); } - - // Byte-range locking over SMB is advisory and unreliable for the exclusive-share protocol this - // coordinator depends on, so a network path would produce silent overlap instead of exclusion. - if (PathSafety.IsNetworkPath(path)) + catch (IOException ex) { throw new UiCoordinationException( UiCoordinationErrorCodes.Unavailable, - $"The UI coordination directory resolved from {source} is a network path, which cannot provide reliable exclusive file locks.", - "Set WINAPP_UI_LOCK_DIRECTORY to a fully qualified path on a local drive."); + $"The UI coordination directory resolved from {source} is unavailable: {ex.Message}", + "Set WINAPP_UI_LOCK_DIRECTORY to the same fully qualified local directory for every winapp process on this desktop."); } - - return Path.GetFullPath(path); } /// - /// The parent (%LOCALAPPDATA%) is already restricted to the current user, but that is not + /// The user profile normally restricts access to the current user, but that is not /// enough on its own: WINAPP_UI_LOCK_DIRECTORY can point at a shared location such as /// C:\Temp, and a directory created by an earlier run may have inherited permissive rules. /// Coordination state is not a secret, but a foreign writer could corrupt it or hold a lease and @@ -358,7 +348,7 @@ private static void RepairAccessRulesIfNeeded(DirectoryInfo directoryInfo) throw new UiCoordinationException( UiCoordinationErrorCodes.Unavailable, $"The UI coordination directory '{directoryInfo.FullName}' is still owned or reachable by another user after repair.", - "Point WINAPP_UI_LOCK_DIRECTORY at a directory this user owns, or remove the override to use the default location under %LOCALAPPDATA%."); + "Point WINAPP_UI_LOCK_DIRECTORY at a directory this user owns, or remove the override to use %USERPROFILE%\\.winapp\\state\\ui."); } // Repairing the directory does NOT repair what was already inside it. An explicit ACE on a @@ -367,7 +357,7 @@ private static void RepairAccessRulesIfNeeded(DirectoryInfo directoryInfo) // whoever placed it, and coordination would keep reading and trusting it. // // Only reached when the directory actually needed repair, which for the default location - // under %LOCALAPPDATA% is the first run and never again. + // under the user profile is the first run and never again. DiscardUntrustedArtifacts(directoryInfo); } catch (Exception ex) when (ex is UnauthorizedAccessException or PrivilegeNotHeldException or InvalidOperationException) @@ -377,7 +367,7 @@ private static void RepairAccessRulesIfNeeded(DirectoryInfo directoryInfo) throw new UiCoordinationException( UiCoordinationErrorCodes.Unavailable, $"The UI coordination directory '{directoryInfo.FullName}' could not be restricted to the current user: {ex.Message}", - "Point WINAPP_UI_LOCK_DIRECTORY at a directory this user owns, or remove the override to use the default location under %LOCALAPPDATA%."); + "Point WINAPP_UI_LOCK_DIRECTORY at a directory this user owns, or remove the override to use %USERPROFILE%\\.winapp\\state\\ui."); } } @@ -422,7 +412,7 @@ private static void DiscardUntrustedArtifacts(DirectoryInfo directoryInfo) throw new UiCoordinationException( UiCoordinationErrorCodes.Unavailable, $"The UI coordination directory '{directoryInfo.FullName}' could not be inspected after its permissions were repaired: {ex.Message}", - "Point WINAPP_UI_LOCK_DIRECTORY at a directory this user owns, or remove the override to use the default location under %LOCALAPPDATA%."); + "Point WINAPP_UI_LOCK_DIRECTORY at a directory this user owns, or remove the override to use %USERPROFILE%\\.winapp\\state\\ui."); } foreach (var artifact in artifacts) diff --git a/src/winapp-CLI/WinApp.Cli/Services/WinappDirectoryService.cs b/src/winapp-CLI/WinApp.Cli/Services/WinappDirectoryService.cs index cab03c16b..a50596948 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WinappDirectoryService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WinappDirectoryService.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation and Contributors. All rights reserved. // Licensed under the MIT License. +using WinApp.Cli.Helpers; + namespace WinApp.Cli.Services; /// @@ -10,6 +12,9 @@ internal class WinappDirectoryService(ICurrentDirectoryProvider currentDirectory { private DirectoryInfo? _globalOverride; + internal Func UserProfileProvider { get; set; } = + () => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + /// /// Method to override the cache directory for testing purposes /// @@ -34,16 +39,49 @@ public DirectoryInfo GetGlobalWinappDirectory() return new DirectoryInfo(cacheDirectory); } - var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var userProfile = UserProfileProvider(); var winappDir = Path.Combine(userProfile, ".winapp"); return new DirectoryInfo(winappDir); } + /// + /// Shared operational state, independent of cache overrides and package identity. + /// Unlike LocalAppData, the profile-root .winapp directory is not MSIX-virtualized. + /// + internal static string GetUserStateDirectory(string? userProfile = null) + { + userProfile ??= Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrWhiteSpace(userProfile) || !Path.IsPathFullyQualified(userProfile)) + { + throw new IOException("The user profile folder could not be resolved to a fully qualified path."); + } + + return ValidateStateDirectory(Path.Combine(userProfile, ".winapp", "state")); + } + + internal static string ValidateStateDirectory(string path) + { + if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path)) + { + throw new IOException("The state directory must be a fully qualified local path."); + } + + if (PathSafety.IsNetworkPath(path) + || PathSafety.RedirectsToNetwork(path)) + { + throw new IOException( + $"The state directory '{path}' is not a verifiable local path. Network storage cannot safely coordinate winapp processes."); + } + + return Path.GetFullPath(path); + } + public DirectoryInfo GetLocalWinappDirectory(DirectoryInfo? baseDirectory = null) { baseDirectory ??= new DirectoryInfo(currentDirectoryProvider.GetCurrentDirectory()); DirectoryInfo globalWinappDirectory = GetGlobalWinappDirectory(); + var userWinappDirectory = Path.Combine(UserProfileProvider(), ".winapp"); var originalBaseDir = new DirectoryInfo(baseDirectory.FullName); var dir = originalBaseDir; @@ -53,7 +91,8 @@ public DirectoryInfo GetLocalWinappDirectory(DirectoryInfo? baseDirectory = null if (Directory.Exists(winappDirectory)) { bool isGlobalWinAppDir = - string.Equals(winappDirectory, globalWinappDirectory.FullName, StringComparison.OrdinalIgnoreCase); + string.Equals(winappDirectory, globalWinappDirectory.FullName, StringComparison.OrdinalIgnoreCase) + || string.Equals(winappDirectory, userWinappDirectory, StringComparison.OrdinalIgnoreCase); if (isGlobalWinAppDir) { // We don't currently allow the global winapp directory to be used as a local winapp directory, From 684e78af3760eb73feef643163e2ce4b39b56244 Mon Sep 17 00:00:00 2001 From: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:32:53 -0700 Subject: [PATCH 02/11] Keep winapp useful when global storage is restricted Add safe invocation-local cache fallback, preserve NuGet configuration and warm read-only packages, allow detached UI observations, and move layout locks beside their protected outputs. Keep required coordination fail-closed and preserve structured diagnostics and clean path output. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/sandbox-execution.md | 6 +- docs/ui-automation.md | 7 +- docs/usage.md | 75 ++- .../winapp/skills/winapp-find-api/SKILL.md | 2 +- plugins/winapp/skills/winapp-find-ui/SKILL.md | 2 + plugins/winapp/skills/winapp-setup/SKILL.md | 4 + .../skills/winapp-troubleshoot/SKILL.md | 5 + .../skills/winapp-ui-automation/SKILL.md | 4 + .../references/ui-json-envelope.md | 7 +- .../AzureSignToolServiceTests.cs | 2 + .../WinApp.Cli.Tests/CacheResilienceTests.cs | 433 +++++++++++++ .../WinApp.Cli.Tests/FakeDotNetService.cs | 3 +- .../WinApp.Cli.Tests/FakeNugetService.cs | 14 +- .../FakeWinappDirectoryService.cs | 3 + .../FindApiStorageFailureTests.cs | 39 ++ .../WinApp.Cli.Tests/FirstRunServiceTests.cs | 31 +- .../GetWinappPathCommandErrorTests.cs | 2 + .../GetWinappPathCommandTests.cs | 15 + .../InteractiveDesktopLockTests.ReadOnly.cs | 435 +++++++++++++ .../InteractiveDesktopStoreTests.cs | 12 +- .../WinApp.Cli.Tests/LayoutLeaseTests.cs | 277 +++++++++ .../LayoutLockExclusionTests.cs | 314 ++++++++++ .../MsixServiceIdentityTests.cs | 13 +- .../WinApp.Cli.Tests/NugetFeedTestHelpers.cs | 21 +- .../NugetPackageDownloaderCoverageTests.cs | 6 +- .../WinApp.Cli.Tests/NugetStorageTests.cs | 581 ++++++++++++++++++ .../PackageInstallationServiceTests.cs | 95 ++- .../PackagedSandboxMutationLockTests.cs | 1 - .../WinApp.Cli.Tests/ProgramTests.cs | 40 ++ .../WinApp.Cli.Tests/RunCommandTests.cs | 1 - .../RuntimeFrameworkResolverTests.cs | 16 + .../StorageDiagnosticsTests.cs | 70 +++ .../StubWinappDirectoryService.cs | 2 + .../TemplateUpdateCheckThrottleTests.cs | 33 + .../UpdateNotificationServiceCoverageTests.cs | 23 + .../WinApp.Cli/Commands/FindApiCommand.cs | 3 +- .../WinApp.Cli/Commands/FindApiShared.cs | 12 + .../WinApp.Cli/Commands/FindApiVerbs.cs | 27 +- .../Commands/GetWinappPathCommand.cs | 28 +- .../WinApp.Cli/Commands/NewCommand.cs | 4 + .../WinApp.Cli/Commands/RunCommand.Target.cs | 1 - .../WinApp.Cli/Commands/RunCommand.cs | 2 - .../Orchestration/DotNetLayout.cs | 34 +- .../Orchestration/HostSourceWalker.cs | 19 + .../Orchestration/RuntimeFrameworkResolver.cs | 64 +- .../Orchestration/VcLibsPayloadAcquirer.cs | 14 +- .../Helpers/HostBuilderExtensions.cs | 1 + src/winapp-CLI/WinApp.Cli/Program.cs | 18 +- .../Services/ApiSearch/ApiCacheBuilder.cs | 16 +- .../Services/ApiSearch/ApiMetadataService.cs | 62 +- .../Services/ApiSearch/ApiQueryEngine.cs | 8 +- .../WinApp.Cli/Services/CacheStorage.cs | 214 +++++++ .../Services/Controls/ControlsCacheIo.cs | 5 +- .../Controls/ControlsSearchService.cs | 7 +- .../Services/Controls/SearchProvider.cs | 137 +++-- .../WinApp.Cli/Services/DotNetService.cs | 68 +- .../WinApp.Cli/Services/FirstRunService.cs | 38 +- .../Services/ITemplateUpdateCheckThrottle.cs | 3 + .../Services/IWinappDirectoryService.cs | 4 + .../Services/IncrementalCopyHelper.cs | 6 + .../InteractiveDesktop/CoordinationLockIo.cs | 3 +- .../IInteractiveDesktopLock.cs | 3 + .../InteractiveDesktopLock.cs | 77 ++- .../InteractiveDesktopPaths.cs | 40 +- .../InteractiveDesktopStateStore.cs | 20 +- .../InteractiveDesktop/ParticipantRegistry.cs | 30 +- .../InteractiveDesktop/UiCoordinationTypes.cs | 12 + .../WinApp.Cli/Services/LayoutLease.cs | 132 +++- .../WinApp.Cli/Services/MSStoreCLIService.cs | 69 +-- .../Services/MsixService.Identity.cs | 21 +- .../WinApp.Cli/Services/MsixService.cs | 15 +- .../Services/NugetPackageDownloader.cs | 13 +- .../WinApp.Cli/Services/NugetService.cs | 69 +-- .../Services/NugetSourceProvider.cs | 235 ++++++- .../Services/PackageInstallationService.cs | 73 ++- .../WinApp.Cli/Services/StorageDiagnostics.cs | 69 +++ .../Services/TemplateUpdateCheckThrottle.cs | 36 +- .../Services/UpdateNotificationService.cs | 31 +- .../Services/WinDbgJsProviderAcquirer.cs | 67 +- .../Services/WinappDirectoryService.cs | 40 +- .../Services/WorkspaceSetupService.cs | 5 +- .../WinApp.Cli/Services/XamlTriageBinaries.cs | 16 +- .../WinApp.Cli/Services/XamlTriageService.cs | 66 +- 83 files changed, 4021 insertions(+), 510 deletions(-) create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/CacheResilienceTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/FindApiStorageFailureTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.ReadOnly.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/LayoutLeaseTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/LayoutLockExclusionTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/NugetStorageTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/StorageDiagnosticsTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/CacheStorage.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/StorageDiagnostics.cs diff --git a/docs/sandbox-execution.md b/docs/sandbox-execution.md index 11da9e118..24138b3e0 100644 --- a/docs/sandbox-execution.md +++ b/docs/sandbox-execution.md @@ -53,7 +53,11 @@ or remove unrelated apps. There is **no silent host fallback**: a command reques Sandbox runs there or fails. Host-side ownership and deployment records use [shared runtime state](usage.md#shared-runtime-state), -independently of the configured cache directory. +independently of the configured cache directory. That state must remain accessible; +an inaccessible ownership record is not treated as permission to start managing an +unrelated Sandbox. Disposable runtime caches can use +[current-directory fallback storage](usage.md#restricted-filesystem-access), but +that does not replace the shared ownership and lifecycle locks. ## Running and rebuilding diff --git a/docs/ui-automation.md b/docs/ui-automation.md index cf358cc9a..2b1ccd38d 100644 --- a/docs/ui-automation.md +++ b/docs/ui-automation.md @@ -115,7 +115,11 @@ turn, with no setup and no way to switch it off, so two agents can never type in windows. Read-only commands keep running concurrently. Coordination uses [shared runtime state](usage.md#shared-runtime-state), independently -of the configured cache directory. +of the configured cache directory. If that storage is inaccessible, read-only +observation can continue with a warning, but it does not participate in workflow +ordering. Mutations and captures still require coordination. See +[restricted filesystem access](usage.md#restricted-filesystem-access) for fallback +and diagnostic behavior. **Continuity between commands is opt-in.** By default each command is a self-contained one-shot: it waits its turn, does its work, and releases the desktop immediately. To keep the desktop across @@ -204,6 +208,7 @@ record a workflow driving an app. Two caveats: recording and the command says so in its output; even same-workflow input will wait. Errors you may see: `invalid_ui_workflow_id` (the variable is set but empty or over 256 characters), +`invalid_ui_lock_directory` (an explicit coordination path is invalid; correct or remove the override), `desktop_coordination_unavailable` (coordination state is unreadable and cannot be safely rebuilt, or was written by a newer `winapp`), `queue_capacity_exceeded` (64 commands from **other** workflows are already waiting — the limit counts live foreign waiters, not processes you have started, so entries diff --git a/docs/usage.md b/docs/usage.md index 9bb247bf3..9102047e3 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1985,7 +1985,69 @@ In **PowerShell** and **pwsh**: $env:WINAPP_CLI_CACHE_DIRECTORY=d:\temp\.winapp ``` -Winapp will create this directory automatically when you run commands like `init` or `restore`. +Winapp creates cache directories when a command needs to save data. SDK packages +use NuGet's package cache; project headers, libraries and bindings use the project's +`.winapp` directory. + +### Restricted Filesystem Access + +```powershell +winapp find-ui Button +winapp ui list-windows --json +``` + +These commands can still be useful in an environment that permits access only to +the current directory. When the default global cache is inaccessible, cache-backed +features use `.winapp\cache` directly beneath the current directory and report the +fallback. They do not search parent directories or follow filesystem links outside +that directory. An existing readable cache can be used without requiring writes. + +| Operation | When global storage is unavailable | +|---|---| +| `find-ui` | Uses local caching, uncached fetched results, or embedded data | +| `find-api` | Builds a local index from accessible project/package metadata; fails if it cannot supply a valid index | +| Tool acquisition and Sandbox runtime payloads | Uses permitted cache storage; required tools and payloads must still pass verification | +| Additional WinUI crash analysis | Can be skipped with a warning without discarding the available dump or ordinary analysis | +| Automatic CLI/template update notices | Skipped when their bookkeeping cannot be saved | +| Read-only UI observation | Can continue without workflow ordering when shared state is inaccessible | +| UI mutations, captures and Sandbox management | Require accessible shared coordination; they never silently run without it | + +An explicit `WINAPP_CLI_CACHE_DIRECTORY` is authoritative: if that location is +invalid or cannot support the requested operation, fix it or remove the override +rather than expecting an automatic redirect. To select an allowed directory: + +```powershell +$env:WINAPP_CLI_CACHE_DIRECTORY = Join-Path (Get-Location) '.winapp\cache' +``` + +`get-winapp-path --global` still refers to the configured global directory, not to +a command's local fallback. + +Storage warnings go to **stderr**, leaving paths and other results on stdout +unchanged. With `--json`, a successful degraded operation writes warning objects +as JSON lines on stderr, for example: + +```json +{"warning":{"code":"cache-fallback","message":"Using an accessible local cache."}} +``` + +A failed command retains its normal error output and nonzero exit code instead of +publishing successful-fallback warnings. `--quiet` suppresses optional storage +warnings. Help, version output and path lookup do not run first-run bookkeeping. + +NuGet package storage and configuration are separate dependencies. A default +package-cache fallback does not change package feeds, source mappings, credentials +or signature requirements. Explicit package-cache settings remain authoritative. +If required configuration or package files are inaccessible, supply an allowed +configuration/cache or allow access; winapp does not silently substitute public feeds. +Child `dotnet` commands keep using a readable package cache even when it is read-only. +If a child later needs to write there and fails, choose a permitted `NUGET_PACKAGES` +directory before retrying. winapp does not automatically replay builds or applications +that may already have performed work. + +Filesystem fallback does not grant access to SDKs, certificate stores, Windows package +registration, authentication or the desktop. Commands that require those facilities +still need the corresponding permissions. ### Shared Runtime State @@ -2007,6 +2069,17 @@ redirect it. Do not delete shared state while UI workflows or a managed Sandbox running. Save guest work and [end the Sandbox](sandbox-execution.md#removing-an-app-and-ending-the-sandbox) before removing its state. +Read-only UI commands may continue when this storage is inaccessible, with a warning +that workflow ordering is unavailable. Invalid explicit coordination paths remain +errors. Mutations, screenshots and recordings still require coordination. + +AppX layout locks are separate from desktop and Sandbox state. They live beside the +layout they protect, in a reserved `.winapp-layout-locks` directory, so local runs do +not require a global cache merely to lock a build output. Processes targeting the +same layout use the same lock regardless of their cache settings or working directory. +These lock artifacts are excluded from package and deployment payloads. An inaccessible +lock is reported as a storage error, not as another process using the layout. + ### Update Checks The winapp CLI periodically checks for new versions and displays a one-line notice when an update is available. This check runs in the background and adds no latency to commands. diff --git a/plugins/winapp/skills/winapp-find-api/SKILL.md b/plugins/winapp/skills/winapp-find-api/SKILL.md index f57582a51..25f1810a0 100644 --- a/plugins/winapp/skills/winapp-find-api/SKILL.md +++ b/plugins/winapp/skills/winapp-find-api/SKILL.md @@ -134,7 +134,7 @@ small API. A filter that matches nothing exits `0` and says so explicitly — th - **Querying a project:** run from (or point `--project-dir` at) a project that has been **restored** — the index is built from `project.assets.json` and the restored NuGet/SDK packages. If the project has never been restored, run `winapp restore` (or `dotnet restore`) first. A solution directory works too: run from the folder holding the `.sln`/`.slnx` and the projects it builds are indexed and answer the query. - **Querying with no project:** nothing is required. From a directory with no project and no solution, `find-api` answers from the machine-wide **SDK scope** (Windows SDK + Windows App SDK), so an agent can explore the API surface *before* scaffolding an app. No network access is needed in either case. - The first query builds the index automatically (this can take a few seconds for a large SDK like WindowsAppSDK); subsequent queries are served from the warm cache. The project index refreshes automatically when the project is re-restored. -- No setup is needed beyond a restored project — the index lives under the global `.winapp` cache (`cache/find-api/`) and is shared across projects. +- No setup is needed beyond a restored project — the index normally lives under the global `.winapp` cache (`cache/find-api/`) and is shared across projects. For current-directory-only environments, see [restricted filesystem access](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#restricted-filesystem-access). ## Common patterns diff --git a/plugins/winapp/skills/winapp-find-ui/SKILL.md b/plugins/winapp/skills/winapp-find-ui/SKILL.md index 035bf2bc7..bc8e629cf 100644 --- a/plugins/winapp/skills/winapp-find-ui/SKILL.md +++ b/plugins/winapp/skills/winapp-find-ui/SKILL.md @@ -129,6 +129,8 @@ winapp find-ui "color picker" --json every 24 hours, or on demand with `--refresh`); the built-in corpus is a floor, never a ceiling, so live data always wins. `--source core` searches the curated built-in patterns and never touches the network at all. +- For blocked global caches and fallback diagnostics, see + [restricted filesystem access](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#restricted-filesystem-access). - **Check the corpus provenance when it matters.** `--json` carries `"corpus"`: `"network"` (fetched this run), `"cache"` (this machine's earlier fetch), or `"embedded"` (served from the corpus built into the CLI — either the fetch failed diff --git a/plugins/winapp/skills/winapp-setup/SKILL.md b/plugins/winapp/skills/winapp-setup/SKILL.md index b37b2794c..b6f6f1d4c 100644 --- a/plugins/winapp/skills/winapp-setup/SKILL.md +++ b/plugins/winapp/skills/winapp-setup/SKILL.md @@ -140,6 +140,10 @@ Use `restore` when you clone a repo that already has `winapp.yaml` but no `.wina ### Private or custom NuGet feeds +If an agent environment restricts filesystem access, see +[restricted filesystem access](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#restricted-filesystem-access) +before changing cache settings. Preserve the project's feed and credential configuration. + `init`, `restore`, and `update` download the SDK packages through NuGet, honoring your standard `nuget.config` hierarchy. Private feeds and mirrors, feed credentials (including credential providers), and a custom `globalPackagesFolder` all work as they do for `dotnet restore`. To use only your feed, `` the inherited sources first: ```xml diff --git a/plugins/winapp/skills/winapp-troubleshoot/SKILL.md b/plugins/winapp/skills/winapp-troubleshoot/SKILL.md index 4649011b9..4489f5b97 100644 --- a/plugins/winapp/skills/winapp-troubleshoot/SKILL.md +++ b/plugins/winapp/skills/winapp-troubleshoot/SKILL.md @@ -11,6 +11,11 @@ Use this skill when: ## Common errors & solutions +For blocked cache or coordination directories, follow +[restricted filesystem access](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#restricted-filesystem-access). +Do not treat a sandbox filesystem restriction as a reason to elevate, disable +coordination, or redirect different desktop-driving processes to separate lock roots. + | Error | Cause | Solution | |-------|-------|----------| | "winapp.yaml not found" | Running `restore` or `update` without config | Run `winapp init` first, or `cd` to the directory containing `winapp.yaml` | diff --git a/plugins/winapp/skills/winapp-ui-automation/SKILL.md b/plugins/winapp/skills/winapp-ui-automation/SKILL.md index d1f4b079f..097bde81b 100644 --- a/plugins/winapp/skills/winapp-ui-automation/SKILL.md +++ b/plugins/winapp/skills/winapp-ui-automation/SKILL.md @@ -23,6 +23,10 @@ therefore makes desktop-driving commands take **cooperative turns** so concurren steal each other's focus or dismiss each other's menus. That is always on. Read-only commands never wait. +For read-only observation when shared storage is blocked, and for operations that +must still fail closed, see +[restricted filesystem access](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#restricted-filesystem-access). + Keeping the desktop *across* commands is opt-in — without an id, each command is a one-shot that releases the desktop the moment it finishes: diff --git a/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md b/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md index ae877328b..88020a95a 100644 --- a/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md +++ b/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md @@ -294,17 +294,22 @@ they do not apply. ### Desktop coordination Concurrent `winapp ui` workflows take cooperative turns on the shared -desktop (see the skill's coordination section). Four additional codes can +desktop (see the skill's coordination section). These additional codes can appear: | `code` | Meaning | |---|---| | `invalid_ui_workflow_id` | `WINAPP_UI_WORKFLOW_ID` is set but empty/whitespace or longer than 256 characters. Fails before any UI side effect. | +| `invalid_ui_lock_directory` | An explicitly configured coordination directory is invalid. Correct or remove the override; read-only commands do not hide configuration errors. | | `desktop_coordination_unavailable` | Coordination state could not be read, published, or safely rebuilt — including state written by a newer `winapp`. Mutating commands fail closed rather than acting uncoordinated. | | `queue_capacity_exceeded` | 64 commands from other workflows are already waiting for the desktop. Counts live foreign waiters, so entries left by commands that exited or were killed do not occupy a slot. | | `ui_turn_busy` | `ui yield` was run while this same workflow still has a command running or queued, so its turn is not idle. Nothing was released, and the running command is unaffected. Distinct from `invalid_arguments` (the request was well formed) and from `desktop_coordination_unavailable` (coordination is working — this is a valid request at an unsafe moment). Carries a `recoveryHint`: wait for or stop this workflow's other `winapp ui` commands — typically a `record` started with the same `WINAPP_UI_WORKFLOW_ID` — then retry `yield`. | | `cancelled` | Native Ctrl+C while the command was still waiting for its turn. The command never ran, so it has no UI side effects. Exit code **130**. | +Successful read-only observations may instead report a storage warning on stderr. +See [restricted filesystem access](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#restricted-filesystem-access) +for the warning envelope and the distinction from failed commands. + `ui yield` also emits the command-level `invalid_arguments` when `WINAPP_UI_WORKFLOW_ID` is not set at all — deliberately not `invalid_ui_workflow_id`, which means the variable is present but malformed. On success it writes `{ "released": true }`, or `{ "released": false }` when this workflow diff --git a/src/winapp-CLI/WinApp.Cli.Tests/AzureSignToolServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/AzureSignToolServiceTests.cs index da72be96f..cf3ba530f 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/AzureSignToolServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/AzureSignToolServiceTests.cs @@ -331,6 +331,8 @@ public bool IsPackageInstalled(string packageName, string version) internal sealed class FakeSignToolWinappDirectoryService(DirectoryInfo globalDir) : IWinappDirectoryService { + public bool IsGlobalCacheOverridden => true; + public DirectoryInfo GetLocalCacheDirectory() => new(Path.Combine(globalDir.FullName, "local-cache")); public DirectoryInfo GetGlobalWinappDirectory() => globalDir; public DirectoryInfo GetLocalWinappDirectory(DirectoryInfo? baseDirectory = null) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/CacheResilienceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/CacheResilienceTests.cs new file mode 100644 index 000000000..ff0f3a67d --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/CacheResilienceTests.cs @@ -0,0 +1,433 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Spectre.Console.Testing; +using WinApp.Cli.ConsoleTasks; +using WinApp.Cli.ExecutionTargets.Orchestration; +using WinApp.Cli.Services; +using WinApp.Cli.Services.ApiSearch; +using WinApp.Cli.Services.Controls; + +namespace WinApp.Cli.Tests; + +[TestClass] +public sealed class CacheResilienceTests +{ + private string _root = null!; + private string _cwd = null!; + private string _profile = null!; + private WinappDirectoryService _directories = null!; + private string Global => Path.Combine(_profile, ".winapp"); + private string Local => Path.Combine(_cwd, ".winapp", "cache"); + + [TestInitialize] + public void Initialize() + { + _root = Path.Combine(Directory.GetCurrentDirectory(), $"cache-resilience-{Guid.NewGuid():N}"); + _cwd = Path.Combine(_root, "project"); + _profile = Path.Combine(_root, "profile"); + Directory.CreateDirectory(_cwd); + Directory.CreateDirectory(_profile); + _directories = new WinappDirectoryService(new CurrentDirectoryProvider(_cwd)) + { + UserProfileProvider = () => _profile, + CacheOverrideProvider = () => null, + }; + } + + [TestCleanup] + public void Cleanup() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [TestMethod] + public void LocalDirectory_DoesNotWalkToParentOrCreateDirectories() + { + Directory.CreateDirectory(Path.Combine(_root, ".winapp", "cache")); + Assert.AreEqual(Local, _directories.GetLocalCacheDirectory().FullName); + Assert.IsFalse(Directory.Exists(Path.Combine(_cwd, ".winapp"))); + } + + [TestMethod] + public void CacheConsumers_ConstructWithoutResolvingOrProbingCache() + { + _directories.CacheOverrideProvider = () => throw new InvalidOperationException("must remain lazy"); + using var controls = new ControlsSearchService(_directories); + _ = new ApiMetadataService(_directories, new CurrentDirectoryProvider(_cwd), + new NoSdkDownloads(), NullLogger.Instance); + _ = new MSStoreCLIService(_directories, NullLogger.Instance); + _ = new XamlTriageService(NullLogger.Instance, _directories, null!); + _ = new VcLibsPayloadAcquirer(_directories); + _ = new RuntimeFrameworkResolver(null!, null!, _directories); + Assert.IsFalse(Directory.Exists(Global)); + Assert.IsFalse(Directory.Exists(Local)); + } + + [TestMethod] + public void LocalCacheProbe_DoesNotRequireAccessToAncestorsAboveCwd() + { + var probes = new List(); + CacheStorage.InspectAncestors(Path.Combine(Local, "find-api"), path => + { + probes.Add(path); + if (path.Equals(_cwd, StringComparison.OrdinalIgnoreCase)) + { + return FileAttributes.Directory; + } + if (!path.StartsWith(_cwd + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + throw new UnauthorizedAccessException("Outside allowed working directory"); + } + throw new DirectoryNotFoundException(); + }); + + Assert.AreEqual(_cwd, probes[^1]); + Assert.IsTrue(probes.All(path => path.Equals(_cwd, StringComparison.OrdinalIgnoreCase) + || path.StartsWith(_cwd + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))); + } + + [TestMethod] + public void TestOverrideInspection_DoesNotResolveConfigurationOrTouchStorage() + { + _directories.CacheOverrideProvider = () => throw new InvalidOperationException("must remain lazy"); + Assert.IsNull(_directories.CacheDirectoryOverrideForTesting); + var expected = new DirectoryInfo(Path.Combine(_root, "test-override")); + _directories.SetCacheDirectoryForTesting(expected); + Assert.AreSame(expected, _directories.CacheDirectoryOverrideForTesting); + Assert.IsFalse(expected.Exists); + _directories.SetCacheDirectoryForTesting(null); + Assert.IsNull(_directories.CacheDirectoryOverrideForTesting); + } + + [TestMethod] + [DataRow("")] + [DataRow(" ")] + [DataRow("relative-cache")] + public void InvalidExplicitOverride_IsNotRedirected(string value) + { + _directories.CacheOverrideProvider = () => value; + var cache = Storage(); + var error = Assert.Throws(() => cache.Run(path => path)); + StringAssert.Contains(error.Message, "WINAPP_CLI_CACHE_DIRECTORY"); + Assert.IsFalse(Directory.Exists(Local)); + } + + [TestMethod] + public void DeniedAncestor_RetriesOnlyInsideInvocationDirectory_AndWarns() + { + using var warnings = new StringWriter(); + var cache = Storage(new StorageDiagnostics(warnings, json: true)); + var defaultProbes = 0; + cache.InspectDirectory = path => + { + if (path.StartsWith(_profile, StringComparison.OrdinalIgnoreCase)) + { + defaultProbes++; + throw new UnauthorizedAccessException("Denied profile ancestor"); + } + CacheStorage.InspectAncestors(path); + }; + var result = cache.Run(path => + { + Directory.CreateDirectory(path); + File.WriteAllText(Path.Combine(path, "entry"), "value"); + return path; + }); + Assert.AreEqual(Path.Combine(Local, "test"), result); + Assert.AreEqual(result, cache.Run(path => path)); + Assert.AreEqual(1, defaultProbes, "A later operation must not retry the already inaccessible default."); + Assert.IsFalse(Directory.Exists(Global)); + using var warning = JsonDocument.Parse(warnings.ToString()); + Assert.AreEqual("cache-fallback", warning.RootElement.GetProperty("warning").GetProperty("code").GetString()); + StringAssert.Contains(warning.RootElement.GetProperty("warning").GetProperty("message").GetString()!, result); + Assert.AreEqual(Global, _directories.GetGlobalWinappDirectory().FullName); + } + + [TestMethod] + public void BothLocationsDenied_ReportsActionableFailure() + { + var cache = Storage(); + cache.InspectDirectory = _ => throw new UnauthorizedAccessException("denied"); + var error = Assert.Throws(() => cache.Run(path => path)); + StringAssert.Contains(error.Message, "Neither the default"); + StringAssert.Contains(error.Message, "WINAPP_CLI_CACHE_DIRECTORY"); + } + + [TestMethod] + public void ExplicitOverrideWriteDenied_DoesNotRedirect() + { + _directories.CacheOverrideProvider = () => Global; + var cache = Storage(); + var calls = 0; + var error = Assert.Throws(() => cache.Run(_ => + { + calls++; + throw new UnauthorizedAccessException("write denied"); + })); + Assert.AreEqual(1, calls); + StringAssert.Contains(error.Message, "configured WINAPP_CLI_CACHE_DIRECTORY"); + Assert.IsFalse(Directory.Exists(Local)); + } + + [TestMethod] + public async Task CorruptOrSignatureFailure_DoesNotRetryAnotherCache() + { + var cache = Storage(); + var calls = 0; + await Assert.ThrowsAsync(() => cache.RunAsync(_ => + { + calls++; + throw new InvalidDataException("corrupt archive"); + })); + Assert.Throws(() => cache.Run(_ => + { + calls++; + throw new InvalidOperationException("invalid signature"); + })); + Assert.AreEqual(2, calls); + Assert.IsFalse(Directory.Exists(Local)); + } + + [TestMethod] + public void LocalCache_RejectsEscapingLink() + { + var outside = Path.Combine(_root, "outside"); + Directory.CreateDirectory(outside); + var link = Path.Combine(_cwd, ".winapp"); + try { Directory.CreateSymbolicLink(link, outside); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Assert.Inconclusive($"Symbolic links unavailable: {ex.Message}"); + } + try + { + Assert.Throws(() => _directories.GetLocalCacheDirectory()); + Assert.IsFalse(Directory.Exists(Path.Combine(outside, "cache"))); + } + finally { Directory.Delete(link); } + } + + [TestMethod] + public void LocalCache_RejectsLinkInFeatureSubtree() + { + Directory.CreateDirectory(Path.Combine(Local, "test")); + var outside = Path.Combine(_root, "outside"); + Directory.CreateDirectory(outside); + var link = Path.Combine(Local, "test", "nested"); + try { Directory.CreateSymbolicLink(link, outside); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Assert.Inconclusive($"Symbolic links unavailable: {ex.Message}"); + } + try + { + File.WriteAllText(Global, "blocks default cache"); + Assert.Throws(() => Storage().Run(path => path)); + } + finally { Directory.Delete(link); } + } + + [TestMethod] + public async Task Controls_ReadableWriteLockedWarmCache_DoesNotFetchOrWrite() + { + var provider = new TestProvider { Storage = Storage() }; + await provider.LoadAsync(); + var directory = Path.Combine(Global, "cache", "test", "test-provider"); + var locks = Directory.GetFiles(directory) + .Select(path => File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read)).ToArray(); + try + { + var data = await provider.LoadAsync(); + Assert.AreEqual(CorpusOrigin.Cache, data.Origin); + Assert.AreEqual(1, provider.Fetches); + Assert.IsFalse(Directory.Exists(Local)); + } + finally + { + foreach (var file in locks) + { + file.Dispose(); + } + } + } + + [TestMethod] + public async Task Controls_RefreshWithBothCachesBlocked_FailsInsteadOfReportingCached() + { + File.WriteAllText(Global, "blocked"); + File.WriteAllText(Path.Combine(_cwd, ".winapp"), "blocked"); + using var warnings = new StringWriter(); + var provider = new TestProvider { Storage = Storage(new StorageDiagnostics(warnings)) }; + var data = await provider.LoadAsync(); + Assert.AreEqual(CorpusOrigin.Network, data.Origin); + StringAssert.Contains(warnings.ToString(), "continuing without caching"); + Assert.IsFalse(warnings.ToString().Contains("Using cache", StringComparison.Ordinal)); + await Assert.ThrowsAsync(() => provider.LoadAsync(forceRefresh: true)); + } + + [TestMethod] + public async Task Controls_ClearCache_ClearsBothRoots() + { + var provider = new TestProvider { Storage = Storage() }; + await provider.LoadAsync(); + var localProvider = Path.Combine(Local, "test", "test-provider"); + Directory.CreateDirectory(localProvider); + File.WriteAllText(Path.Combine(localProvider, "entry"), "old"); + provider.ClearCache(); + Assert.IsFalse(Directory.Exists(localProvider)); + Assert.IsFalse(Directory.Exists(Path.Combine(Global, "cache", "test", "test-provider"))); + } + + [TestMethod] + public async Task Controls_ClearDeniedGlobal_DoesNotClaimItClearedLocalOnly() + { + File.WriteAllText(Global, "blocked"); + var provider = new TestProvider { Storage = Storage() }; + await provider.LoadAsync(); + Assert.Throws(provider.ClearCache); + Assert.IsTrue(Directory.Exists(Path.Combine(Local, "test", "test-provider"))); + } + + [TestMethod] + public async Task Store_UsesReadableLocalToolWhenDefaultAncestorIsBlocked() + { + File.WriteAllText(Global, "blocked"); + var toolDir = Path.Combine(Local, "tools", "msstore"); + Directory.CreateDirectory(toolDir); + var exe = Path.Combine(toolDir, "msstore.exe"); + File.WriteAllText(exe, "tool"); + using var held = File.Open(exe, FileMode.Open, FileAccess.Read, FileShare.Read); + using var warnings = new StringWriter(); + var service = new MSStoreCLIService(_directories, NullLogger.Instance, new StorageDiagnostics(warnings)); + await service.EnsureMSStoreCLIAvailableAsync(); + Assert.AreEqual(exe, service.GetMSStoreCLIPath()); + StringAssert.Contains(warnings.ToString(), "default winapp cache is inaccessible"); + } + + [TestMethod] + public void Api_UsesLocalSdkManifestWhenDefaultAncestorIsBlocked() + { + File.WriteAllText(Global, "blocked"); + var cacheDir = Path.Combine(Local, "find-api"); + Directory.CreateDirectory(cacheDir); + var path = ApiCachePaths.SdkManifestPath(cacheDir); + File.WriteAllText(path, JsonSerializer.Serialize(new ProjectManifest + { + ProjectName = ApiCachePaths.SdkScopeName, + ProjectDir = "", + ProjectFile = "", + Packages = [], + GeneratedAt = DateTime.UtcNow.ToString("o"), + }, ApiSearchJsonContext.Default.ProjectManifest)); + using var held = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read); + var service = new ApiMetadataService(_directories, new CurrentDirectoryProvider(_cwd), + new NoSdkDownloads(), NullLogger.Instance); + Assert.AreEqual(ApiQueryOutcome.Ok, service.Stats(new ApiRequestScope(null, "sdk")).Outcome); + } + + [TestMethod] + public void Api_RequiredIndexWriteInBothBlockedLocations_FailsClearly() + { + File.WriteAllText(Global, "blocked"); + File.WriteAllText(Path.Combine(_cwd, ".winapp"), "blocked"); + var service = new ApiMetadataService(_directories, new CurrentDirectoryProvider(_cwd), + new NoSdkDownloads(), NullLogger.Instance); + var error = Assert.Throws(() => service.Refresh(new ApiRequestScope(null, "sdk"), scan: false)); + StringAssert.Contains(error.Message, "Neither the default"); + } + + [TestMethod] + public void Api_StaleIndexAndBlockedCaches_NeverReturnsOldMetadata() + { + var cacheDir = Path.Combine(Global, "cache", "find-api"); + Directory.CreateDirectory(Path.Combine(cacheDir, "projects")); + Directory.CreateDirectory(Path.Combine(cacheDir, ".lock")); + File.WriteAllText(Path.Combine(_cwd, ".winapp"), "blocked fallback"); + var projectPath = Path.Combine(_cwd, "App.csproj"); + File.WriteAllText(projectPath, ""); + var manifestPath = Path.Combine(cacheDir, "projects", ApiCacheBuilder.ManifestName(projectPath) + ".json"); + File.WriteAllText(manifestPath, JsonSerializer.Serialize(new ProjectManifest + { + ProjectName = "App", ProjectDir = _cwd, ProjectFile = "App.csproj", Packages = [], + GeneratedAt = DateTime.UtcNow.AddDays(-1).ToString("o"), + }, ApiSearchJsonContext.Default.ProjectManifest)); + File.SetLastWriteTimeUtc(manifestPath, DateTime.UtcNow.AddDays(-1)); + Directory.CreateDirectory(Path.Combine(_cwd, "obj")); + File.WriteAllText(Path.Combine(_cwd, "obj", "project.assets.json"), + "{\"version\":3,\"targets\":{},\"libraries\":{},\"project\":{\"restore\":{\"projectPath\":" + + JsonSerializer.Serialize(projectPath) + "},\"frameworks\":{}}}"); + + var service = new ApiMetadataService(_directories, new CurrentDirectoryProvider(_cwd), + new NoSdkDownloads(), NullLogger.Instance); + var error = Assert.Throws(() => service.Stats(new ApiRequestScope(null, "App"))); + StringAssert.Contains(error.Message, "Neither the default"); + } + + [TestMethod] + public async Task VcLibs_LocalFallbackStillChecksSignatureBeforePublishing() + { + File.WriteAllText(Global, "blocked"); + string? verified = null; + var service = new VcLibsPayloadAcquirer(_directories) + { + CacheDirectories = () => [], + Downloader = (_, _) => Task.FromResult(new byte[] { 1, 2, 3 }), + SignatureVerifier = path => { verified = path; return false; }, + }; + var payload = await service.TryAcquireAsync( + new RuntimePackageRequirement + { + Name = "Microsoft.VCLibs.140.00.UWPDesktop", MinVersion = "14.0.0.0", + Architecture = "x64", Publisher = VcLibsPayloadAcquirer.MicrosoftPublisher, + }, + new DirectoryInfo(_cwd), + new TaskContext(new GroupableTask("cache-test", null), null, new TestConsole(), NullLogger.Instance, new Lock()), + CancellationToken.None); + Assert.IsNull(payload); + Assert.IsNotNull(verified); + StringAssert.StartsWith(verified, Path.Combine(Local, "framework-packages") + Path.DirectorySeparatorChar); + Assert.IsEmpty(Directory.GetFiles(Local, "*.appx", SearchOption.AllDirectories)); + } + + [TestMethod] + public async Task XamlTriage_BothCachesUnavailable_SkipsWithWarning() + { + File.WriteAllText(Global, "blocked"); + File.WriteAllText(Path.Combine(_cwd, ".winapp"), "blocked"); + using var warnings = new StringWriter(); + var service = new XamlTriageService(NullLogger.Instance, + _directories, null!, new StorageDiagnostics(warnings)); + var result = await service.TryAnalyzeAsync("unused.dmp", useSymbols: false); + Assert.AreEqual(XamlTriageOutcome.Skipped, result.Outcome); + StringAssert.Contains(result.LogText!, "cache is unavailable"); + StringAssert.Contains(warnings.ToString(), "continuing without caching"); + } + + private CacheStorage Storage(IStorageDiagnostics? diagnostics = null) => + new(_directories, Path.Combine("cache", "test"), "test", diagnostics); + + private sealed class NoSdkDownloads : ISdkPackageSource + { + public List GetSdkPackages() => []; + } + + private sealed class TestProvider() : CachedProviderBase("") + { + public int Fetches { get; private set; } + public override string Id => "test-provider"; + public override string DisplayName => "Test"; + protected override Task FetchAsync(CancellationToken cancellationToken) + { + Fetches++; + return Task.FromResult(new ProviderData( + [new Scenario { Id = "test-button", ControlId = "button", ControlName = "Button", HeaderText = "Button", Source = Id }], + new() { ["button"] = ["button"] }, new())); + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeDotNetService.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeDotNetService.cs index 398f06966..12ea69b68 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FakeDotNetService.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeDotNetService.cs @@ -13,7 +13,8 @@ namespace WinApp.Cli.Tests; /// internal class FakeDotNetService : IDotNetService { - private readonly DotNetService _real = new(); + private readonly DotNetService _real = new( + new NugetSourceProvider(new CurrentDirectoryProvider(Environment.CurrentDirectory))); /// /// Tracks packages added via AddOrUpdatePackageReferenceAsync diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeNugetService.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeNugetService.cs index d49c330c0..5dc9f25b6 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FakeNugetService.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeNugetService.cs @@ -15,6 +15,7 @@ internal class FakeNugetService : INugetService public string DefaultVersion { get; set; } = "1.6.0"; public List QueriedPackages { get; } = []; public List<(string Package, string Version)> InstalledPackages { get; } = []; + public Action? BeforeInstall { get; set; } /// /// Set this to the test cache directory to enable NuGet cache path resolution in tests. @@ -63,19 +64,24 @@ public Task GetLatestVersionAsync(string packageName, SdkInstallMode sdk public Task> InstallPackageAsync(string package, string version, TaskContext taskContext, CancellationToken cancellationToken = default) { + BeforeInstall?.Invoke(package, version); InstalledPackages.Add((package, version)); + var installed = InstallReturns.TryGetValue(package, out var configured) + ? new Dictionary(configured) + : new Dictionary { [package] = version }; // When a cache directory is configured, create the package folder AND the completion marker so // subsequent "already present" checks (INugetService.IsPackageInstalled) behave like a real, // fully-extracted NuGet cache entry. if (CacheDirectory != null) { - MarkInstalled(package, version); + foreach (var (installedPackage, installedVersion) in installed) + { + MarkInstalled(installedPackage, installedVersion); + } } - return Task.FromResult(InstallReturns.TryGetValue(package, out var configured) - ? new Dictionary(configured) - : new Dictionary { [package] = version }); + return Task.FromResult(installed); } /// diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeWinappDirectoryService.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeWinappDirectoryService.cs index 4a850cd6d..f9c737a06 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FakeWinappDirectoryService.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeWinappDirectoryService.cs @@ -13,6 +13,9 @@ namespace WinApp.Cli.Tests; internal sealed class FakeWinappDirectoryService(DirectoryInfo globalDirectory) : IWinappDirectoryService { public DirectoryInfo GlobalDirectory { get; set; } = globalDirectory; + public bool IsGlobalCacheOverridden { get; set; } = true; + public DirectoryInfo? LocalCacheDirectory { get; set; } + public DirectoryInfo GetLocalCacheDirectory() => LocalCacheDirectory ?? new(Path.Combine(GlobalDirectory.FullName, "local-cache")); public DirectoryInfo GetGlobalWinappDirectory() => GlobalDirectory; diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FindApiStorageFailureTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/FindApiStorageFailureTests.cs new file mode 100644 index 000000000..eff78a99f --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/FindApiStorageFailureTests.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Text.Json; +using WinApp.Cli.Commands; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class FindApiStorageFailureTests : BaseCommandTests +{ + [TestMethod] + [DataRow("Button")] + [DataRow("projects")] + [DataRow("refresh")] + [DataRow("members Button")] + [DataRow("check-property Button Background")] + [DataRow("types Microsoft.UI.Xaml")] + [DataRow("enums Microsoft.UI.Xaml.Visibility")] + [DataRow("namespaces")] + [DataRow("packages")] + [DataRow("stats")] + public async Task ExplicitCacheFailure_IsOneFlatJsonError_NotAStackTrace(string commandLine) + { + var blocked = Path.Join(_tempDirectory.FullName, "cache-is-a-file"); + File.WriteAllText(blocked, "unchanged"); + GetRequiredService().SetCacheDirectoryForTesting(new DirectoryInfo(blocked)); + var arguments = commandLine.Split(' ').Append("--json").ToArray(); + + var exit = await ParseAndInvokeWithCaptureAsync(GetRequiredService(), arguments); + + Assert.AreEqual(1, exit); + using var document = JsonDocument.Parse(TestAnsiConsole.Output); + StringAssert.Contains(document.RootElement.GetProperty("error").GetString()!, "WINAPP_CLI_CACHE_DIRECTORY"); + Assert.AreEqual(string.Empty, ConsoleStdErr.ToString()); + Assert.AreEqual("unchanged", File.ReadAllText(blocked)); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FirstRunServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/FirstRunServiceTests.cs index f4584e568..3405ca0a6 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FirstRunServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FirstRunServiceTests.cs @@ -16,14 +16,14 @@ public class FirstRunServiceTests private DirectoryInfo _tempDir = null!; private DirectoryInfo _globalDir = null!; - private FirstRunService CreateService(CapturingLogger logger) + private FirstRunService CreateService(CapturingLogger logger, TextWriter? error = null) { // WinappDirectoryService.SetCacheDirectoryForTesting overrides the value // returned by GetGlobalWinappDirectory, letting us point the marker file // at a throwaway directory instead of the real ~/.winapp. var dirService = new WinappDirectoryService(new CurrentDirectoryProvider(_tempDir.FullName)); dirService.SetCacheDirectoryForTesting(_globalDir); - return new FirstRunService(dirService, logger); + return new FirstRunService(dirService, logger, new StorageDiagnostics(error ?? new StringWriter())); } [TestInitialize] @@ -86,7 +86,23 @@ public void CheckAndDisplayFirstRunNotice_MarkerAlreadyExists_ReturnsFalseAndSta } [TestMethod] - public void CheckAndDisplayFirstRunNotice_MarkerPathBlockedByDirectory_LogsWarningButReportsFirstRun() + public void InvalidCacheConfiguration_DoesNotThrowBeforeTheCommandRuns() + { + var directories = new WinappDirectoryService(new CurrentDirectoryProvider(_tempDir.FullName)) + { + CacheOverrideProvider = () => "relative-cache", + }; + using var error = new StringWriter(); + var logger = new CapturingLogger(); + var service = new FirstRunService(directories, logger, new StorageDiagnostics(error)); + + Assert.IsFalse(service.CheckAndDisplayFirstRunNotice()); + StringAssert.Contains(error.ToString(), "First-run bookkeeping is unavailable"); + Assert.IsFalse(logger.Has(LogLevel.Information, "anonymous usage data")); + } + + [TestMethod] + public void CheckAndDisplayFirstRunNotice_MarkerPathBlockedByDirectory_WarnsOnErrorStream() { // Create a *directory* where the marker *file* is expected. FileInfo.Exists is // false for a directory, so the first-run branch runs, but File.Create then @@ -94,13 +110,14 @@ public void CheckAndDisplayFirstRunNotice_MarkerPathBlockedByDirectory_LogsWarni Directory.CreateDirectory(Path.Combine(_globalDir.FullName, ".first-run-complete")); var logger = new CapturingLogger(); - var service = CreateService(logger); + using var error = new StringWriter(); + var service = CreateService(logger, error); var result = service.CheckAndDisplayFirstRunNotice(); Assert.IsTrue(result, "Notice is still considered shown even if the marker can't be persisted."); - Assert.IsTrue( - logger.Has(LogLevel.Warning, "Failed to create first run marker"), - "Marker-write failure must be logged as a warning."); + StringAssert.Contains(error.ToString(), "Cannot save the first-run marker"); + Assert.IsFalse(logger.Has(LogLevel.Warning, "marker"), + "Storage diagnostics must not use the logger's stdout warning channel."); } } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/GetWinappPathCommandErrorTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/GetWinappPathCommandErrorTests.cs index 528101d39..9ebf94c2b 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/GetWinappPathCommandErrorTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/GetWinappPathCommandErrorTests.cs @@ -53,6 +53,8 @@ public async Task GetWinappPath_Global_WhenServiceThrows_ReturnsErrorExitCode() private sealed class ThrowingWinappDirectoryService : IWinappDirectoryService { + public bool IsGlobalCacheOverridden => true; + public DirectoryInfo GetLocalCacheDirectory() => throw new InvalidOperationException("simulated cache failure"); public DirectoryInfo GetGlobalWinappDirectory() => throw new InvalidOperationException("simulated failure resolving the global directory"); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/GetWinappPathCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/GetWinappPathCommandTests.cs index 3a7300f42..4dab8495d 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/GetWinappPathCommandTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/GetWinappPathCommandTests.cs @@ -82,6 +82,21 @@ public async Task GetWinappPath_LocalDirectoryMissing_FallsBackToGlobal_WithWarn "Warning text must not pollute stdout — scripts capturing stdout should get only the path."); } + [TestMethod] + public async Task GetWinappPath_GlobalPathIsFile_FailsWithoutPrintingAFallback() + { + var path = Path.Join(_tempDirectory.FullName, "not-a-directory"); + File.WriteAllText(path, "sentinel"); + GetRequiredService().SetCacheDirectoryForTesting(new DirectoryInfo(path)); + + var exitCode = await ParseAndInvokeWithCaptureAsync(GetRequiredService(), ["--global"]); + + Assert.AreEqual(1, exitCode); + Assert.AreEqual(string.Empty, TestAnsiConsole.Output); + StringAssert.Contains(ConsoleStdErr.ToString(), "not a directory"); + Assert.AreEqual("sentinel", File.ReadAllText(path)); + } + [TestMethod] public async Task GetWinappPath_GlobalDirectoryMissing_ReturnsErrorExitCode() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.ReadOnly.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.ReadOnly.cs new file mode 100644 index 000000000..c1b6b5bd8 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.ReadOnly.cs @@ -0,0 +1,435 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Spectre.Console.Testing; +using WinApp.Cli.Commands; +using WinApp.Cli.Helpers; +using WinApp.Cli.Services.InteractiveDesktop; + +namespace WinApp.Cli.Tests; + +public partial class InteractiveDesktopLockTests +{ + [TestMethod] + public async Task Observe_BlockedParentAndMissingDirectory_RunsOnceWithoutCreatingState() + { + Directory.CreateDirectory(_lockDirectory); + var blockedParent = Path.Combine(_lockDirectory, "blocked-parent"); + File.WriteAllText(blockedParent, "unchanged"); + Environment.SetEnvironmentVariable( + InteractiveDesktopPaths.LockDirectoryOverrideVariable, Path.Combine(blockedParent, "ui")); + var calls = 0; + var error = new StringWriter(); + var output = new StringWriter(); + var parse = ParseObservation(error, output, "--json"); + UiCoordinationTelemetryScope.Begin(); + + var exit = await _coordinator.RunCoordinatedAsync( + UiTurnMode.Observe, "ui inspect", parse, (_, _) => + { + calls++; + parse.InvocationConfiguration.Output.WriteLine("""{"elements":[]}"""); + return Task.FromResult(0); + }, CancellationToken.None); + + Assert.AreEqual(0, exit); + Assert.AreEqual(1, calls); + Assert.AreEqual("""{"elements":[]}""" + Environment.NewLine, output.ToString()); + AssertStorageWarning(error); + Assert.AreEqual("unchanged", File.ReadAllText(blockedParent)); + CollectionAssert.AreEqual(new[] { blockedParent }, Directory.GetFileSystemEntries(_lockDirectory)); + Assert.AreEqual(UiTurnAction.Detached, UiCoordinationTelemetryScope.Current!.TurnAction); + Assert.AreEqual(UiCoordinationOutcome.Completed, UiCoordinationTelemetryScope.Current.Outcome); + } + + [TestMethod] + [DataRow((int)UiTurnMode.TurnShared)] + [DataRow((int)UiTurnMode.DesktopExclusive)] + public async Task StorageUnavailable_ParticipatingCommandsFailClosed(int mode) + { + var store = new FailingStorage { LockFailure = StorageFailure() }; + var error = new StringWriter(); + var calls = 0; + var action = new ReadOnlyProbeAction(CreateReadOnlyCoordinator(store), (UiTurnMode)mode, (_, _) => + { + calls++; + return Task.FromResult(0); + }); + + var exit = await action.InvokeAsync(ParseObservation(error, new StringWriter(), "--json")); + + Assert.AreEqual(1, exit); + Assert.AreEqual(0, calls, "mutation, capture and recording must never bypass coordination"); + Assert.AreEqual(1, store.LockAttempts); + Assert.AreEqual(0, store.Publishes); + using var document = JsonDocument.Parse(error.ToString()); + Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, + document.RootElement.GetProperty("error").GetProperty("code").GetString()); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task Observe_StorageWarningHonorsQuiet(bool json) + { + var store = new FailingStorage { LockFailure = StorageFailure() }; + var error = new StringWriter(); + var output = new StringWriter(); + var args = json ? new[] { "--json", "--quiet" } : new[] { "--quiet" }; + var parse = ParseObservation(error, output, args); + + var exit = await CreateReadOnlyCoordinator(store).RunCoordinatedAsync( + UiTurnMode.Observe, "ui status", parse, (_, _) => Task.FromResult(0), CancellationToken.None); + + Assert.AreEqual(0, exit); + Assert.AreEqual(string.Empty, error.ToString()); + Assert.AreEqual(string.Empty, output.ToString()); + Assert.AreEqual(1, store.LockAttempts); + Assert.AreEqual(0, store.Publishes); + } + + [TestMethod] + public async Task Observe_HumanStorageWarningUsesStderrOnly() + { + var error = new StringWriter(); + var output = new StringWriter(); + var coordinator = CreateReadOnlyCoordinator(new FailingStorage { LockFailure = StorageFailure() }); + + Assert.AreEqual(0, await coordinator.RunCoordinatedAsync( + UiTurnMode.Observe, "ui inspect", ParseObservation(error, output), + (_, _) => Task.FromResult(0), CancellationToken.None)); + + StringAssert.Contains(error.ToString(), "without desktop ordering or workflow continuity"); + Assert.AreEqual(1, error.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries).Length); + Assert.AreEqual(string.Empty, output.ToString()); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task Observe_BodyStorageExceptionIsNeverReplayed(bool admissionUnavailable) + { + var store = new FailingStorage { LockFailure = admissionUnavailable ? StorageFailure() : null }; + var coordinator = CreateReadOnlyCoordinator(store); + var error = new StringWriter(); + var expected = StorageFailure(); + var calls = 0; + + var actual = await Assert.ThrowsExactlyAsync(() => + coordinator.RunCoordinatedAsync( + UiTurnMode.Observe, "ui inspect", ParseObservation(error, new StringWriter(), "--json"), + (_, _) => + { + calls++; + throw expected; + }, CancellationToken.None)); + + Assert.AreSame(expected, actual); + Assert.AreEqual(1, calls, "storage failure handling must not encompass the command body"); + Assert.AreEqual(1, store.LockAttempts, "a failed detached body must not retry storage on teardown"); + Assert.AreEqual(0, store.Publishes); + Assert.AreEqual(string.Empty, error.ToString(), "do not add a success warning to a failing command"); + } + + [TestMethod] + public async Task Observe_FailedBodyPreservesExitAndDoesNotWriteStateOrWarning() + { + var store = new FailingStorage { LockFailure = StorageFailure() }; + var error = new StringWriter(); + var calls = 0; + + var exit = await CreateReadOnlyCoordinator(store).RunCoordinatedAsync( + UiTurnMode.Observe, "ui get-value", ParseObservation(error, new StringWriter(), "--json"), + (_, _) => + { + calls++; + return Task.FromResult(7); + }, CancellationToken.None); + + Assert.AreEqual(7, exit); + Assert.AreEqual(1, calls); + Assert.AreEqual(1, store.LockAttempts); + Assert.AreEqual(0, store.Publishes); + Assert.AreEqual(string.Empty, error.ToString()); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task Observe_RejectsDesktopSectionsEvenWhenDetached(bool unavailable) + { + var store = new FailingStorage { LockFailure = unavailable ? StorageFailure() : null }; + var coordinator = CreateReadOnlyCoordinator(store); + var error = new StringWriter(); + + var ex = await Assert.ThrowsExactlyAsync(() => + coordinator.RunCoordinatedAsync( + UiTurnMode.Observe, "ui inspect", ParseObservation(error, new StringWriter(), "--json"), + async (turn, token) => + { + await using var section = await turn.EnterAsync(token); + Assert.Fail("an observation must never acquire an input or capture section"); + return 0; + }, CancellationToken.None)); + + Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code); + Assert.IsFalse(ex.IsStorageUnavailable, "misuse of the observation turn is not storage degradation"); + Assert.IsFalse(File.Exists(_paths.ActiveLockPath)); + Assert.AreEqual(string.Empty, error.ToString()); + } + + [TestMethod] + public async Task Observe_ReadStorageFailureDetachesWithoutPublishing() + { + var store = new FailingStorage { ReadFailure = StorageFailure() }; + var error = new StringWriter(); + + Assert.AreEqual(0, await CreateReadOnlyCoordinator(store).RunCoordinatedAsync( + UiTurnMode.Observe, "ui list-windows", ParseObservation(error, new StringWriter(), "--json"), + (_, _) => Task.FromResult(0), CancellationToken.None)); + + Assert.AreEqual(1, store.LockAttempts); + Assert.AreEqual(1, store.Reads); + Assert.AreEqual(0, store.Publishes); + AssertStorageWarning(error); + } + + [TestMethod] + public async Task Observe_PublishFailureClosesLeaseWithoutRetryingState() + { + var owner = new UiOwnerResolver().Resolve(); + var state = InteractiveDesktopState.CreateFresh(); + state.Owner = new OwnerRecord { Kind = owner.Kind, Key = owner.Key }; + state.IdleExpiresTick64 = Environment.TickCount64 + 60_000; + var store = new FailingStorage { State = state, PublishFailure = StorageFailure() }; + var error = new StringWriter(); + + Assert.AreEqual(0, await CreateReadOnlyCoordinator(store).RunCoordinatedAsync( + UiTurnMode.Observe, "ui inspect", ParseObservation(error, new StringWriter(), "--json"), + (_, _) => Task.FromResult(0), CancellationToken.None)); + + Assert.AreEqual(1, store.LockAttempts, "no cleanup transaction may retry unavailable storage"); + Assert.AreEqual(1, store.Publishes); + Assert.IsFalse(_participants.AnyLiveParticipant(), "a failed admission must not leave a live lease"); + AssertStorageWarning(error); + } + + [TestMethod] + public async Task Observe_ProgrammerErrorsInAdmissionAreNotStorageFallback() + { + var expected = new InvalidOperationException("broken coordinator"); + var store = new FailingStorage { LockFailure = expected }; + var calls = 0; + + var actual = await Assert.ThrowsExactlyAsync(() => + CreateReadOnlyCoordinator(store).RunCoordinatedAsync( + UiTurnMode.Observe, "ui inspect", Parse(), (_, _) => + { + calls++; + return Task.FromResult(0); + }, CancellationToken.None)); + + Assert.AreSame(expected, actual); + Assert.AreEqual(0, calls); + Assert.AreEqual(0, store.Publishes); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task Observe_LiveStateAmbiguityDoesNotBecomeStorageFallback(bool corrupt) + { + _paths.EnsureDirectories(); + using var lease = _participants.OpenLease(424242, 12345678); + if (corrupt) + { + File.WriteAllText(_paths.StatePath, "{broken"); + } + + var calls = 0; + var ex = await Assert.ThrowsExactlyAsync(() => + RunAsync(UiTurnMode.Observe, "ui inspect", (_, _) => + { + calls++; + return Task.FromResult(0); + })); + + Assert.AreEqual(0, calls); + Assert.IsFalse(ex.IsStorageUnavailable); + Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code); + if (corrupt) + { + Assert.AreEqual("{broken", File.ReadAllText(_paths.StatePath)); + } + else + { + Assert.IsFalse(File.Exists(_paths.StatePath)); + } + } + + [TestMethod] + public async Task Observe_CancellationAfterDetachingDoesNotRetryStorage() + { + var store = new FailingStorage { LockFailure = StorageFailure() }; + var error = new StringWriter(); + using var cancellation = new CancellationTokenSource(); + UiCoordinationTelemetryScope.Begin(); + + var exit = await CreateReadOnlyCoordinator(store).RunCoordinatedAsync( + UiTurnMode.Observe, "ui wait-for", ParseObservation(error, new StringWriter(), "--json"), + (_, token) => + { + cancellation.Cancel(); + token.ThrowIfCancellationRequested(); + return Task.FromResult(0); + }, cancellation.Token); + + Assert.AreEqual(InteractiveDesktopLock.CancelledExitCode, exit); + Assert.AreEqual(1, store.LockAttempts); + Assert.AreEqual(0, store.Publishes); + using var document = JsonDocument.Parse(error.ToString()); + Assert.AreEqual(UiCoordinationErrorCodes.Cancelled, + document.RootElement.GetProperty("error").GetProperty("code").GetString()); + Assert.AreEqual(UiCoordinationOutcome.Cancelled, UiCoordinationTelemetryScope.Current!.Outcome); + } + + [TestMethod] + [DataRow("relative\\locks")] + [DataRow(@"\\server\share\locks")] + [DataRow(" ")] + public async Task InvalidExplicitDirectory_IsDeferredToStructuredCommandError(string invalidDirectory) + { + Environment.SetEnvironmentVariable(InteractiveDesktopPaths.LockDirectoryOverrideVariable, invalidDirectory); + _ = new InteractiveDesktopPaths(new ProcessInspector()); + var error = new StringWriter(); + var calls = 0; + var action = new ReadOnlyProbeAction(_coordinator, UiTurnMode.Observe, (_, _) => + { + calls++; + return Task.FromResult(0); + }); + + var exit = await action.InvokeAsync(ParseObservation(error, new StringWriter(), "--json")); + + Assert.AreEqual(1, exit); + Assert.AreEqual(0, calls); + using var document = JsonDocument.Parse(error.ToString()); + Assert.AreEqual(UiCoordinationErrorCodes.InvalidLockDirectory, + document.RootElement.GetProperty("error").GetProperty("code").GetString()); + Assert.IsFalse(Directory.Exists(_lockDirectory)); + } + + [TestMethod] + public async Task InvalidLocalArguments_AreRejectedBeforeDirectoryResolution() + { + Environment.SetEnvironmentVariable(InteractiveDesktopPaths.LockDirectoryOverrideVariable, "relative\\locks"); + var error = new StringWriter(); + var action = new ReadOnlyProbeAction(_coordinator, UiTurnMode.Observe, + (_, _) => throw new AssertFailedException("preflight must prevent execution")) + { + PreflightResult = 9, + }; + + Assert.AreEqual(9, await action.InvokeAsync(ParseObservation(error, new StringWriter(), "--json"))); + Assert.AreEqual(string.Empty, error.ToString()); + Assert.IsFalse(Directory.Exists(_lockDirectory)); + } + + private InteractiveDesktopLock CreateReadOnlyCoordinator(IInteractiveDesktopStateStore store) + => new(store, _paths, _participants, new UiOwnerResolver(), new ProcessInspector(), + new TickCountClock(), new FakePollDelay(), _signals, new TestConsole(), + NullLogger.Instance); + + private static UiCoordinationException StorageFailure() + => UiCoordinationException.StorageUnavailable("The coordination directory is not writable."); + + private static ParseResult ParseObservation(TextWriter error, TextWriter output, params string[] args) + { + var command = new Command("probe"); + command.Options.Add(WinAppRootCommand.JsonOption); + command.Options.Add(WinAppRootCommand.QuietOption); + command.Options.Add(WinAppRootCommand.VerboseOption); + var parse = command.Parse(args); + parse.InvocationConfiguration.Error = error; + parse.InvocationConfiguration.Output = output; + return parse; + } + + private static void AssertStorageWarning(StringWriter error) + { + using var document = JsonDocument.Parse(error.ToString()); + Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, + document.RootElement.GetProperty("warning").GetProperty("code").GetString()); + StringAssert.Contains(error.ToString(), "without desktop ordering or workflow continuity"); + } + + private sealed class ReadOnlyProbeAction( + IInteractiveDesktopLock coordinator, + UiTurnMode mode, + Func> body) + : UiCoordinatedAction(coordinator, NullLogger.Instance) + { + public int? PreflightResult { get; init; } + + protected override string Operation => "ui inspect"; + + protected override int? Preflight(ParseResult parseResult) => PreflightResult; + + protected override UiTurnMode ResolveMode(ParseResult parseResult) => mode; + + protected override Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + => body(parseResult, cancellationToken); + } + + private sealed class FailingStorage : IInteractiveDesktopStateStore + { + public Exception? LockFailure { get; init; } + public Exception? ReadFailure { get; init; } + public Exception? PublishFailure { get; init; } + public InteractiveDesktopState State { get; init; } = InteractiveDesktopState.CreateFresh(); + public int LockAttempts { get; private set; } + public int Reads { get; private set; } + public int Publishes { get; private set; } + + public IDisposable AcquireStateLock(CancellationToken cancellationToken) + { + LockAttempts++; + if (LockFailure is { } failure) + { + throw failure; + } + + return new NoopStateLock(); + } + + public StateReadResult Read() + { + Reads++; + if (ReadFailure is { } failure) + { + throw failure; + } + + return new StateReadResult(State, false, false); + } + + public void Publish(InteractiveDesktopState state) + { + Publishes++; + if (PublishFailure is { } failure) + { + throw failure; + } + } + + public bool IsActiveLockFree() => true; + + private sealed class NoopStateLock : IDisposable + { + public void Dispose() { } + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs index 522d1aeb6..e3d1336ad 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs @@ -625,8 +625,10 @@ public void Paths_RejectARelativeOverrideDirectory() // A relative path resolves against the caller's working directory, so two winapp processes // started in different folders would silently coordinate against different files. - var ex = Assert.ThrowsExactly(() => new InteractiveDesktopPaths(_inspector)); - Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code); + var paths = new InteractiveDesktopPaths(_inspector); + var ex = Assert.ThrowsExactly(() => _ = paths.LockDirectory); + Assert.AreEqual(UiCoordinationErrorCodes.InvalidLockDirectory, ex.Code); + Assert.IsFalse(ex.IsStorageUnavailable, "invalid explicit configuration must not trigger observation fallback"); } [TestMethod] @@ -636,8 +638,10 @@ public void Paths_RejectANetworkOverrideDirectory() InteractiveDesktopPaths.LockDirectoryOverrideVariable, @"\\server\share\locks"); // SMB byte-range locking is advisory, so exclusive-share semantics would silently not exclude. - var ex = Assert.ThrowsExactly(() => new InteractiveDesktopPaths(_inspector)); - Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code); + var paths = new InteractiveDesktopPaths(_inspector); + var ex = Assert.ThrowsExactly(() => _ = paths.LockDirectory); + Assert.AreEqual(UiCoordinationErrorCodes.InvalidLockDirectory, ex.Code); + Assert.IsFalse(ex.IsStorageUnavailable); } [TestMethod] diff --git a/src/winapp-CLI/WinApp.Cli.Tests/LayoutLeaseTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/LayoutLeaseTests.cs new file mode 100644 index 000000000..b49f5073a --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/LayoutLeaseTests.cs @@ -0,0 +1,277 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Text; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +[TestClass] +[DoNotParallelize] +public class LayoutLeaseTests +{ + private DirectoryInfo _root = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void Setup() + { + _root = Directory.CreateDirectory(Path.Combine(Environment.CurrentDirectory, $"layout-lease-tests-{Guid.NewGuid():N}")); + } + + [TestCleanup] + public void Cleanup() => _root.Delete(recursive: true); + + [TestMethod] + public void LockPath_DependsOnlyOnTheLayout_NotWorkingDirectory() + { + var layout = new DirectoryInfo(Path.Combine(_root.FullName, "output", "AppX")); + var originalDirectory = Environment.CurrentDirectory; + var otherDirectory = _root.CreateSubdirectory("other-working-directory"); + var expectedPath = LayoutLease.GetLockPath(layout); + + using var first = LayoutLease.Acquire(layout, TestContext.CancellationToken); + try + { + Environment.CurrentDirectory = otherDirectory.FullName; + var otherSpelling = new DirectoryInfo(Path.Combine(layout.Parent!.FullName, ".", "APPX") + "\\"); + Assert.AreEqual(expectedPath, LayoutLease.GetLockPath(otherSpelling), ignoreCase: true); + Assert.ThrowsExactly(() => + LayoutLease.Acquire(otherSpelling, TestContext.CancellationToken, TimeSpan.Zero)); + Assert.IsEmpty(otherDirectory.GetFileSystemInfos()); + } + finally + { + Environment.CurrentDirectory = originalDirectory; + } + + Assert.AreEqual(Path.Combine(layout.Parent!.FullName, LayoutLease.LockDirectoryName), + Path.GetDirectoryName(expectedPath)); + Assert.IsFalse(layout.Exists, "Acquiring the lease must not materialize the layout."); + } + + [TestMethod] + public void LockPath_NormalizesExtendedPrefixAndCase_AndHasFixedLengthName() + { + var layout = new DirectoryInfo(Path.Combine(_root.FullName, new string('a', 150), "AppX")); + var extended = new DirectoryInfo(@"\\?\" + layout.FullName.ToUpperInvariant()); + var path = LayoutLease.GetLockPath(layout); + + Assert.AreEqual(path, LayoutLease.GetLockPath(extended), ignoreCase: true); + Assert.AreEqual(69, Path.GetFileName(path).Length); + using (LayoutLease.Acquire(layout, TestContext.CancellationToken)) + { + Assert.IsTrue(File.Exists(path)); + } + Assert.IsFalse(File.Exists(path)); + } + + [TestMethod] + public async Task ConcurrentInstances_WaitUntilTheFirstIsReleasedAcrossAnAwait() + { + var layout = new DirectoryInfo(Path.Combine(_root.FullName, "AppX")); + using var first = LayoutLease.Acquire(layout, TestContext.CancellationToken); + using var attempted = new ManualResetEventSlim(); + var acquired = false; + var waiting = Task.Run(() => + { + using var second = LayoutLease.Acquire(layout, TestContext.CancellationToken, + TimeSpan.FromSeconds(10), path => + { + attempted.Set(); + return new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, + FileShare.None, 1, FileOptions.DeleteOnClose); + }); + acquired = true; + }, TestContext.CancellationToken); + + try + { + Assert.IsTrue(attempted.Wait(TimeSpan.FromSeconds(5), TestContext.CancellationToken)); + Assert.IsFalse(waiting.IsCompleted); + } + finally + { + await Task.Run(first.Dispose, TestContext.CancellationToken); + await waiting; + } + + Assert.IsTrue(acquired); + Assert.IsFalse(File.Exists(LayoutLease.GetLockPath(layout))); + } + + [TestMethod] + [DataRow(true)] + [DataRow(false)] + public void StorageFailure_IsReportedImmediatelyWithoutRetry(bool accessDenied) + { + var layout = new DirectoryInfo(Path.Combine(_root.FullName, "AppX")); + Exception cause = accessDenied + ? new UnauthorizedAccessException("layout storage denied") + : new IOException("disk is full", unchecked((int)0x80070070)); + var attempts = 0; + var elapsed = Stopwatch.StartNew(); + + void Acquire() => + LayoutLease.Acquire(layout, TestContext.CancellationToken, TimeSpan.FromSeconds(10), _ => + { + attempts++; + throw cause; + }); + Exception actual = accessDenied + ? Assert.ThrowsExactly(Acquire) + : Assert.ThrowsExactly(Acquire); + + Assert.AreSame(cause, actual); + Assert.AreEqual(1, attempts); + Assert.IsLessThan(TimeSpan.FromSeconds(2), elapsed.Elapsed); + } + + [TestMethod] + public void ReadOnlyLockFile_ReportsAccessDeniedWithoutAContentionTimeout() + { + var layout = new DirectoryInfo(Path.Combine(_root.FullName, "AppX")); + var lockPath = LayoutLease.GetLockPath(layout); + Directory.CreateDirectory(Path.GetDirectoryName(lockPath)!); + File.WriteAllText(lockPath, ""); + File.SetAttributes(lockPath, FileAttributes.ReadOnly); + try + { + Assert.ThrowsExactly(() => + LayoutLease.Acquire(layout, TestContext.CancellationToken, TimeSpan.Zero)); + } + finally + { + File.SetAttributes(lockPath, FileAttributes.Normal); + } + } + + [TestMethod] + [DataRow(unchecked((int)0x80070020), true)] + [DataRow(unchecked((int)0x80070021), true)] + [DataRow(unchecked((int)0x80070005), false)] + [DataRow(unchecked((int)0x80070003), false)] + [DataRow(unchecked((int)0x80070070), false)] + [DataRow(unchecked((int)0x80131620), false)] + public void OnlyWindowsSharingAndLockViolationsAreContention(int hresult, bool expected) + { + Assert.AreEqual(expected, LayoutLease.IsContention(new IOException("storage error", hresult))); + } + + [TestMethod] + public async Task ContentionWait_IsCancellable() + { + var layout = new DirectoryInfo(Path.Combine(_root.FullName, "AppX")); + using var first = LayoutLease.Acquire(layout, TestContext.CancellationToken); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.CancellationToken); + cancellation.CancelAfter(TimeSpan.FromMilliseconds(50)); + var elapsed = Stopwatch.StartNew(); + + await Assert.ThrowsExactlyAsync(() => Task.Run(() => + LayoutLease.Acquire(layout, cancellation.Token, TimeSpan.FromSeconds(10)), TestContext.CancellationToken)); + + Assert.IsLessThan(TimeSpan.FromSeconds(2), elapsed.Elapsed); + } + + [TestMethod] + public void AlreadyCancelled_DoesNotCreateLockArtifacts() + { + var layout = new DirectoryInfo(Path.Combine(_root.FullName, "AppX")); + Assert.ThrowsExactly(() => + LayoutLease.Acquire(layout, new CancellationToken(canceled: true))); + Assert.IsEmpty(_root.GetFileSystemInfos()); + } + + [TestMethod] + public void ExistingUnlockedFile_IsReusable_AndDisposalRemovesOnlyThatFile() + { + var layout = new DirectoryInfo(Path.Combine(_root.FullName, "AppX")); + var lockPath = LayoutLease.GetLockPath(layout); + Directory.CreateDirectory(Path.GetDirectoryName(lockPath)!); + File.WriteAllText(lockPath, "old state"); + var other = new DirectoryInfo(Path.Combine(_root.FullName, "other")); + using var otherLease = LayoutLease.Acquire(other, TestContext.CancellationToken); + + using (var lease = LayoutLease.Acquire(layout, TestContext.CancellationToken, TimeSpan.Zero)) + { + lease.Dispose(); + lease.Dispose(); + } + + Assert.IsFalse(File.Exists(lockPath)); + Assert.IsTrue(File.Exists(LayoutLease.GetLockPath(other))); + using var next = LayoutLease.Acquire(layout, TestContext.CancellationToken, TimeSpan.Zero); + } + + [TestMethod] + public async Task OtherProcess_WithDifferentWorkingAndCacheDirectories_BlocksUntilKilled() + { + var layout = new DirectoryInfo(Path.Combine(_root.FullName, "AppX")); + var lockPath = LayoutLease.GetLockPath(layout); + Directory.CreateDirectory(Path.GetDirectoryName(lockPath)!); + var childWorkingDirectory = _root.CreateSubdirectory("child-working-directory"); + var childCacheDirectory = _root.CreateSubdirectory("child-cache"); + const string script = """ + $ErrorActionPreference = 'Stop' + $stream = [System.IO.FileStream]::new($env:WINAPP_TEST_LAYOUT_LOCK, + [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, + [System.IO.FileShare]::None, 1, [System.IO.FileOptions]::DeleteOnClose) + [Console]::Out.WriteLine('locked') + [Console]::Out.Flush() + [Console]::In.ReadLine() | Out-Null + $stream.Dispose() + """; + var start = new ProcessStartInfo + { + FileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), + @"WindowsPowerShell\v1.0\powershell.exe"), + WorkingDirectory = childWorkingDirectory.FullName, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + start.ArgumentList.Add("-NoProfile"); + start.ArgumentList.Add("-NonInteractive"); + start.ArgumentList.Add("-EncodedCommand"); + start.ArgumentList.Add(Convert.ToBase64String(Encoding.Unicode.GetBytes(script))); + start.Environment["WINAPP_TEST_LAYOUT_LOCK"] = lockPath; + start.Environment["LOCALAPPDATA"] = childCacheDirectory.FullName; + using var child = Process.Start(start)!; + try + { + using var readyTimeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.CancellationToken); + readyTimeout.CancelAfter(TimeSpan.FromSeconds(15)); + Assert.AreEqual("locked", await child.StandardOutput.ReadLineAsync(readyTimeout.Token)); + + Assert.ThrowsExactly(() => + LayoutLease.Acquire(layout, TestContext.CancellationToken, TimeSpan.Zero)); + } + finally + { + if (!child.HasExited) + { + child.Kill(entireProcessTree: true); + } + await child.WaitForExitAsync(TestContext.CancellationToken); + } + + using (LayoutLease.Acquire(layout, TestContext.CancellationToken, TimeSpan.FromSeconds(1))) + { + Assert.IsTrue(File.Exists(lockPath)); + } + Assert.IsFalse(File.Exists(lockPath)); + } + + [TestMethod] + public void ReservedLayoutPath_IsRefusedBeforeCreatingAnything() + { + var layout = new DirectoryInfo(Path.Combine(_root.FullName, LayoutLease.LockDirectoryName, "AppX")); + Assert.ThrowsExactly(() => + LayoutLease.Acquire(layout, TestContext.CancellationToken)); + Assert.IsEmpty(_root.GetFileSystemInfos()); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/LayoutLockExclusionTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/LayoutLockExclusionTests.cs new file mode 100644 index 000000000..419851cba --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/LayoutLockExclusionTests.cs @@ -0,0 +1,314 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Reflection; +using System.Xml.Linq; +using Microsoft.Extensions.Logging.Abstractions; +using Spectre.Console.Testing; +using WinApp.Cli.ConsoleTasks; +using WinApp.Cli.ExecutionTargets.Abstractions; +using WinApp.Cli.ExecutionTargets.Orchestration; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class LayoutLockExclusionTests +{ + private DirectoryInfo _root = null!; + private TaskContext _taskContext = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void Setup() + { + _root = Directory.CreateDirectory(Path.Combine(Environment.CurrentDirectory, $"layout-exclusion-tests-{Guid.NewGuid():N}")); + _taskContext = new TaskContext(new GroupableTask("layout exclusions", null), null, + new TestConsole(), NullLogger.Instance, new Lock()); + } + + [TestCleanup] + public void Cleanup() => _root.Delete(recursive: true); + + [TestMethod] + [DataRow("AppX", "Exact")] + [DataRow("AppX", "Additive")] + [DataRow(@"publish\custom-layout", "Exact")] + [DataRow(@"publish\custom-layout", "Additive")] + public void LooseLayout_DefaultAndExplicitNestedOutputs_NeverCopyLocks( + string relativeLayout, string mode) + { + var reconciliation = Enum.Parse(mode); + var source = _root.CreateSubdirectory("source"); + WritePayload(source); + var manifest = WriteFile(_root, "Package.appxmanifest", ""); + var layout = new DirectoryInfo(Path.Combine(source.FullName, relativeLayout)); + using var lease = LayoutLease.Acquire(layout, TestContext.CancellationToken); + using var nestedLease = LayoutLease.Acquire( + new DirectoryInfo(Path.Combine(source.FullName, "nested", "another-layout")), TestContext.CancellationToken); + + Sync(source, layout, manifest, reconciliation); + var obsolete = WriteFile(layout, "obsolete.dll", "old build"); + Sync(source, layout, manifest, reconciliation); + + AssertPayloadOnly(layout); + Assert.AreEqual(reconciliation == LayoutReconciliation.Additive, File.Exists(obsolete.FullName)); + Assert.IsTrue(File.Exists(LayoutLease.GetLockPath(layout)), "Copying must not consume the live lease."); + } + + [TestMethod] + public void SharedMsixAndBundleStaging_ExcludesLocksAtEveryDepth_NotWinappOrNestedAppX() + { + var source = _root.CreateSubdirectory("source"); + WritePayload(source); + WriteFile(source, @"AppX\not-packaged.txt", "old generated layout"); + WriteFile(source, @"ordinary\AppX\keep.txt", "nested payload, not the top-level exclusion"); + var destination = new DirectoryInfo(Path.Combine(_root.FullName, "staging")); + using var lease = LayoutLease.Acquire(new DirectoryInfo(Path.Combine(source.FullName, "AppX")), + TestContext.CancellationToken); + using var nestedLease = LayoutLease.Acquire( + new DirectoryInfo(Path.Combine(source.FullName, "nested", "other")), TestContext.CancellationToken); + + Invoke("CopyDirectoryRecursive", source, destination, + new HashSet(StringComparer.OrdinalIgnoreCase) { "AppX" }); + + AssertPayloadOnly(destination); + Assert.IsFalse(Directory.Exists(Path.Combine(destination.FullName, "AppX"))); + Assert.AreEqual("nested payload, not the top-level exclusion", + File.ReadAllText(Path.Combine(destination.FullName, @"ordinary\AppX\keep.txt"))); + } + + [TestMethod] + public async Task DeploymentSnapshot_DoesNotReadOrShipLiveLocks_ButIncludesBindings() + { + var source = _root.CreateSubdirectory("source"); + WritePayload(source); + using var lease = LayoutLease.Acquire(new DirectoryInfo(Path.Combine(source.FullName, "AppX")), + TestContext.CancellationToken); + using var nestedLease = LayoutLease.Acquire( + new DirectoryInfo(Path.Combine(source.FullName, "nested", "other")), TestContext.CancellationToken); + + var snapshot = await DeploymentPlanner.CreateSnapshotAsync(source, "deployment", TestContext.CancellationToken); + + var expected = new[] { "app.exe", "ordinary.lock", @".winapp\bindings\addon.node", @".winapp-layout-locks-backup\keep.txt" }; + CollectionAssert.AreEquivalent(expected, snapshot.Files.Select(file => file.RelativePath).ToArray()); + } + + [TestMethod] + [DataRow("Skip")] + [DataRow("Reject")] + public void DeploymentAndPush_WalksExcludeReservedDirectories(string policy) + { + var source = _root.CreateSubdirectory("source"); + WritePayload(source); + WriteFile(source, @".WINAPP-LAYOUT-LOCKS\ignore.lock", "bookkeeping"); + WriteFile(source, @"nested\.winapp-layout-locks\ignore.lock", "bookkeeping"); + + var files = HostSourceWalker.EnumerateFiles(source.FullName, Enum.Parse(policy), TestContext.CancellationToken); + + Assert.AreEqual(4, files.Count); + Assert.IsFalse(files.Any(file => LayoutLease.IsArtifactPath(file.FullName))); + } + + [TestMethod] + [DataRow("Exact")] + [DataRow("Additive")] + [DataRow("None")] + public void ExistingLockStateInsideLayout_IsRefusedBeforeCopyingOrPruning(string mode) + { + var reconciliation = Enum.Parse(mode); + var source = _root.CreateSubdirectory("source"); + WriteFile(source, "app.exe", "new app"); + var manifest = WriteFile(_root, "Package.appxmanifest", ""); + var layout = _root.CreateSubdirectory("layout"); + WriteFile(layout, "app.exe", "old app"); + var unrelated = WriteFile(layout, @"nested\.winapp-layout-locks\personal.txt", "do not delete"); + using var otherLease = LayoutLease.Acquire( + new DirectoryInfo(Path.Combine(layout.FullName, "nested", "other")), TestContext.CancellationToken); + + var failure = Assert.ThrowsExactly(() => + Sync(source, layout, manifest, reconciliation)); + + StringAssert.Contains(failure.Message, LayoutLease.LockDirectoryName, StringComparison.Ordinal); + Assert.AreEqual("old app", File.ReadAllText(Path.Combine(layout.FullName, "app.exe"))); + Assert.AreEqual("do not delete", File.ReadAllText(unrelated.FullName)); + Assert.IsFalse(File.Exists(Path.Combine(layout.FullName, "appxmanifest.xml"))); + } + + [TestMethod] + [DataRow("Exact")] + [DataRow("Additive")] + [DataRow("None")] + public async Task Recipe_ReusedLayoutWithReservedState_IsNeverPruned(string mode) + { + var reconciliation = Enum.Parse(mode); + var layout = _root.CreateSubdirectory("layout"); + var source = WriteFile(_root, "app.exe", "new app"); + var old = WriteFile(layout, "app.exe", "old app"); + var state = WriteFile(layout, @".winapp-layout-locks\unrelated.txt", "keep"); + var recipe = WriteRecipe((source.FullName, "app.exe")); + + await Assert.ThrowsExactlyAsync(() => CopyRecipe(recipe, layout, reconciliation)); + + Assert.AreEqual("old app", File.ReadAllText(old.FullName)); + Assert.AreEqual("keep", File.ReadAllText(state.FullName)); + } + + [TestMethod] + [DataRow(false, "Exact")] + [DataRow(true, "Exact")] + [DataRow(false, "Additive")] + [DataRow(true, "Additive")] + [DataRow(false, "None")] + [DataRow(true, "None")] + public async Task ExplicitRecipeLockReference_IsRejectedBeforeAnyCopy( + bool reservedSource, string mode) + { + var reconciliation = Enum.Parse(mode); + var layout = new DirectoryInfo(Path.Combine(_root.FullName, "layout")); + var ordinary = WriteFile(_root, "ordinary.txt", "payload"); + var reserved = WriteFile(_root, @".winapp-layout-locks\state.lock", "bookkeeping"); + var recipe = WriteRecipe( + (ordinary.FullName, "ordinary.txt"), + (reservedSource ? reserved.FullName : ordinary.FullName, + reservedSource ? "renamed.txt" : @"nested\.WINAPP-LAYOUT-LOCKS\state.lock")); + + var failure = await Assert.ThrowsExactlyAsync(() => + CopyRecipe(recipe, layout, reconciliation)); + + StringAssert.Contains(failure.Message, LayoutLease.LockDirectoryName, StringComparison.Ordinal); + Assert.IsFalse(Directory.Exists(layout.FullName)); + } + + [TestMethod] + public async Task Recipe_OrdinaryWinappBindingsArePayload() + { + var layout = new DirectoryInfo(Path.Combine(_root.FullName, "layout")); + var binding = WriteFile(_root, @".winapp\bindings\addon.node", "binding"); + var recipe = WriteRecipe((binding.FullName, @".winapp\bindings\addon.node")); + + await CopyRecipe(recipe, layout, LayoutReconciliation.Exact); + + Assert.AreEqual("binding", File.ReadAllText(Path.Combine(layout.FullName, @".winapp\bindings\addon.node"))); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void ManifestAssetCopy_RejectsExplicitLockPathsBeforeAnyCopy(bool reservedSource) + { + var source = _root.CreateSubdirectory("source"); + var destination = new DirectoryInfo(Path.Combine(_root.FullName, "destination")); + var binding = WriteFile(source, @".winapp\bindings\addon.node", "binding"); + var reserved = WriteFile(source, @".winapp-layout-locks\state.lock", "bookkeeping"); + var entries = new List<(FileInfo, string)> + { + (binding, @".winapp\bindings\addon.node"), + (reservedSource ? reserved : binding, + reservedSource ? "renamed.txt" : @"nested\.winapp-layout-locks\state.lock"), + }; + + Assert.ThrowsExactly(() => IncrementalCopyHelper.CopyFiles(entries, destination)); + Assert.IsFalse(Directory.Exists(destination.FullName)); + IncrementalCopyHelper.CopyFiles([(binding, @".winapp\bindings\addon.node")], destination); + Assert.AreEqual("binding", File.ReadAllText(Path.Combine(destination.FullName, @".winapp\bindings\addon.node"))); + } + + [TestMethod] + public void NonImageManifestReference_CannotAddAReservedArtifactBackToStaging() + { + var source = _root.CreateSubdirectory("source"); + var destination = _root.CreateSubdirectory("destination"); + const string reservedPath = @"nested\.winapp-layout-locks\state.lock"; + WriteFile(source, reservedPath, "bookkeeping"); + + Assert.ThrowsExactly(() => + Invoke("CopyManifestReferencedFiles", new HashSet { reservedPath }, + source, source, destination, _taskContext, TestContext.CancellationToken)); + Assert.IsEmpty(destination.GetFileSystemInfos()); + } + + [TestMethod] + public void ExplicitReservedSourceRoot_IsRejectedRatherThanRenamedIntoPayload() + { + var source = _root.CreateSubdirectory(LayoutLease.LockDirectoryName); + WriteFile(source, "state.lock", "bookkeeping"); + var destination = new DirectoryInfo(Path.Combine(_root.FullName, "destination")); + + Assert.ThrowsExactly(() => + Invoke("CopyDirectoryRecursive", source, destination, null)); + Assert.ThrowsExactly(() => + HostSourceWalker.EnumerateFiles(source.FullName, HostReparsePolicy.Reject, TestContext.CancellationToken)); + Assert.IsFalse(Directory.Exists(destination.FullName)); + } + + [TestMethod] + public void ExplicitReservedFile_CannotBypassTheDeploymentWalk() + { + var source = _root.CreateSubdirectory("source"); + var file = WriteFile(source, @".winapp-layout-locks\state.lock", "bookkeeping"); + + var failure = Assert.ThrowsExactly(() => + HostSourceWalker.EnsureNoLinkOnPath(source.FullName, file.FullName)); + + Assert.AreEqual(ExecutionTargetErrorCodes.DeploymentDirty, failure.Error.Code); + } + + private void Sync(DirectoryInfo source, DirectoryInfo layout, FileInfo manifest, LayoutReconciliation reconciliation) => + Invoke("SyncFilesToOutputDirectory", source, layout, manifest, _taskContext, reconciliation); + + private Task CopyRecipe(FileInfo recipe, DirectoryInfo layout, LayoutReconciliation reconciliation) => + (Task)Invoke("CopyFilesFromRecipeAsync", recipe, layout, _taskContext, reconciliation, TestContext.CancellationToken)!; + + private static object? Invoke(string name, params object?[] arguments) + { + try + { + return typeof(MsixService).GetMethod(name, BindingFlags.NonPublic | BindingFlags.Static)!.Invoke(null, arguments); + } + catch (TargetInvocationException exception) when (exception.InnerException is not null) + { + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(exception.InnerException).Throw(); + throw; + } + } + + private FileInfo WriteRecipe(params (string Source, string Destination)[] entries) + { + XNamespace ns = "http://schemas.microsoft.com/developer/msbuild/2003"; + var doc = new XDocument(new XElement(ns + "Project", + new XElement(ns + "ItemGroup", entries.Select(entry => + new XElement(ns + "AppxPackagedFile", + new XAttribute("Include", entry.Source), + new XElement(ns + "PackagePath", entry.Destination)))))); + return WriteFile(_root, "app.build.appxrecipe", doc.ToString()); + } + + private static FileInfo WriteFile(DirectoryInfo root, string relativePath, string contents) + { + var path = Path.Combine(root.FullName, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, contents); + return new FileInfo(path); + } + + private static void WritePayload(DirectoryInfo root) + { + WriteFile(root, "app.exe", "app"); + WriteFile(root, "ordinary.lock", "payload with a lock extension"); + WriteFile(root, @".winapp\bindings\addon.node", "binding"); + WriteFile(root, @".winapp-layout-locks-backup\keep.txt", "similarly named payload"); + } + + private static void AssertPayloadOnly(DirectoryInfo layout) + { + Assert.AreEqual("app", File.ReadAllText(Path.Combine(layout.FullName, "app.exe"))); + Assert.AreEqual("payload with a lock extension", File.ReadAllText(Path.Combine(layout.FullName, "ordinary.lock"))); + Assert.AreEqual("binding", File.ReadAllText(Path.Combine(layout.FullName, @".winapp\bindings\addon.node"))); + Assert.AreEqual("similarly named payload", + File.ReadAllText(Path.Combine(layout.FullName, @".winapp-layout-locks-backup\keep.txt"))); + Assert.IsFalse(layout.EnumerateFileSystemInfos("*", SearchOption.AllDirectories) + .Any(entry => LayoutLease.IsArtifactPath(entry.FullName))); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceIdentityTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceIdentityTests.cs index dfef21a12..5acc4742f 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceIdentityTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceIdentityTests.cs @@ -1086,10 +1086,9 @@ public void LayoutLease_SecondHolderOnTheSameLayout_IsRefusedWithAWayForward() { var layout = _tempDirectory.CreateSubdirectory("leased-layout"); - using var first = LayoutLease.Acquire(_testCacheDirectory, layout, TestContext.CancellationToken); + using var first = LayoutLease.Acquire(layout, TestContext.CancellationToken); var second = Assert.ThrowsExactly(() => LayoutLease.Acquire( - _testCacheDirectory, new DirectoryInfo(layout.FullName.ToUpperInvariant()), TestContext.CancellationToken, TimeSpan.FromMilliseconds(200))); @@ -1104,8 +1103,8 @@ public void LayoutLease_DifferentLayouts_DoNotBlockEachOther() var first = _tempDirectory.CreateSubdirectory("layout-a"); var second = _tempDirectory.CreateSubdirectory("layout-b"); - using var leaseA = LayoutLease.Acquire(_testCacheDirectory, first, TestContext.CancellationToken); - using var leaseB = LayoutLease.Acquire(_testCacheDirectory, second, TestContext.CancellationToken); + using var leaseA = LayoutLease.Acquire(first, TestContext.CancellationToken); + using var leaseB = LayoutLease.Acquire(second, TestContext.CancellationToken); } /// @@ -1117,7 +1116,7 @@ public void LayoutLease_WritesNothingIntoTheLayout() { var layout = _tempDirectory.CreateSubdirectory("layout-c"); - using (var lease = LayoutLease.Acquire(_testCacheDirectory, layout, TestContext.CancellationToken)) + using (var lease = LayoutLease.Acquire(layout, TestContext.CancellationToken)) { Assert.AreEqual(0, layout.GetFileSystemInfos().Length, "The lease must not write into the layout"); } @@ -1134,13 +1133,13 @@ public void LayoutLease_ReleasedTwice_IsHarmlessAndFreesTheLayout() { var layout = _tempDirectory.CreateSubdirectory("layout-d"); - var lease = LayoutLease.Acquire(_testCacheDirectory, layout, TestContext.CancellationToken); + var lease = LayoutLease.Acquire(layout, TestContext.CancellationToken); lease.Dispose(); lease.Dispose(); // A second run must be able to claim it immediately, without waiting out the timeout. using var next = LayoutLease.Acquire( - _testCacheDirectory, layout, TestContext.CancellationToken, TimeSpan.FromMilliseconds(200)); + layout, TestContext.CancellationToken, TimeSpan.FromMilliseconds(200)); } /// diff --git a/src/winapp-CLI/WinApp.Cli.Tests/NugetFeedTestHelpers.cs b/src/winapp-CLI/WinApp.Cli.Tests/NugetFeedTestHelpers.cs index c415c770e..2351f92fa 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/NugetFeedTestHelpers.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/NugetFeedTestHelpers.cs @@ -18,32 +18,13 @@ namespace WinApp.Cli.Tests; /// internal static class NugetFeedTestHelpers { - /// - /// whose global directory is the real default - /// (%USERPROFILE%\.winapp), so does NOT treat it as a test - /// override and instead resolves the global packages folder from the supplied nuget.config - /// (exercising SettingsUtility.GetGlobalPackagesFolder). - /// - private sealed class DefaultWinappDirectoryService : IWinappDirectoryService - { - public DirectoryInfo GetGlobalWinappDirectory() => - new(Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".winapp")); - - public DirectoryInfo GetLocalWinappDirectory(DirectoryInfo? baseDirectory = null) => - new(Path.Join((baseDirectory ?? new DirectoryInfo(Directory.GetCurrentDirectory())).FullName, ".winapp")); - - public void SetCacheDirectoryForTesting(DirectoryInfo? cacheDirectory) - { - } - } - internal static NugetSourceProvider CreateSourceProviderRootedAt(DirectoryInfo root) => new(new CurrentDirectoryProvider(root.FullName)); internal static NugetService CreateServiceRootedAt(DirectoryInfo root) { var sourceProvider = CreateSourceProviderRootedAt(root); - return new NugetService(new DefaultWinappDirectoryService(), sourceProvider, new NugetPackageDownloader(sourceProvider)); + return new NugetService(sourceProvider, new NugetPackageDownloader(sourceProvider)); } internal static DirectoryInfo CreateFeedTestDirectory() diff --git a/src/winapp-CLI/WinApp.Cli.Tests/NugetPackageDownloaderCoverageTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/NugetPackageDownloaderCoverageTests.cs index bec087d30..42ef69bba 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/NugetPackageDownloaderCoverageTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/NugetPackageDownloaderCoverageTests.cs @@ -98,7 +98,7 @@ public async Task DownloadPackageAsync_TempFileCleanupFails_SwallowsErrorAndStil { // Force the best-effort temp-file cleanup to throw after the package has transferred. // Capture the path first so this test can delete the temp file itself — otherwise disabling - // the product's cleanup would orphan the downloaded .nupkg under %TEMP% on every run. + // the product's cleanup would orphan the download in the selected packages folder. DeleteTempFile = path => { leakedTempFile = path; @@ -118,11 +118,13 @@ public async Task DownloadPackageAsync_TempFileCleanupFails_SwallowsErrorAndStil "The package must have extracted into the global packages folder despite the temp-cleanup failure."); Assert.IsNotNull(leakedTempFile, "The temp-file cleanup seam must have been invoked with a real temp-file path."); + Assert.AreEqual(packages.FullName, Path.GetDirectoryName(leakedTempFile), + "Package staging must use the selected cache rather than an unrelated TEMP directory."); } finally { // The product's cleanup was deliberately disabled above, so delete the orphaned temp file here to - // avoid leaking a .nupkg into the system temp directory on every test run. + // avoid leaking a download into the packages directory on every test run. if (leakedTempFile is not null) { try diff --git a/src/winapp-CLI/WinApp.Cli.Tests/NugetStorageTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/NugetStorageTests.cs new file mode 100644 index 000000000..484ff131e --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/NugetStorageTests.cs @@ -0,0 +1,581 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.IO.Compression; +using System.Xml.Linq; +using Microsoft.Extensions.Logging.Abstractions; +using NuGet.Configuration; +using Spectre.Console.Testing; +using WinApp.Cli.ConsoleTasks; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class NugetStorageTests +{ + private static readonly string[] PrivateAndMirrorSources = ["private", "mirror"]; + private static readonly string[] PrivateSourceOnly = ["private"]; + + private DirectoryInfo _root = null!; + private DirectoryInfo _invocation = null!; + private string _defaultPackages = null!; + private RecordingDiagnostics _diagnostics = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void Initialize() + { + _root = Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), $".nuget-storage-test-{Guid.NewGuid():N}")); + _invocation = _root.CreateSubdirectory("invocation"); + _defaultPackages = Path.Combine(_root.FullName, "default-packages"); + _diagnostics = new RecordingDiagnostics(); + WriteConfig(_invocation); + } + + [TestCleanup] + public void Cleanup() => _root.Delete(recursive: true); + + private NugetSourceProvider CreateProvider() + { + return new NugetSourceProvider(new CurrentDirectoryProvider(_invocation.FullName), storageDiagnostics: _diagnostics) + { + LoadSettings = root => Settings.LoadSpecificSettings(root, "nuget.config"), + GetEnvironmentVariable = _ => null, + ResolveGlobalPackagesFolder = _ => _defaultPackages, + }; + } + + private string LocalPackages => Path.Combine(_invocation.FullName, ".winapp", "cache", "nuget", "packages"); + + private static void WriteConfig(DirectoryInfo directory, string extra = "") + { + var document = XDocument.Parse( + ""); + foreach (var section in XElement.Parse($"{extra}").Elements()) + { + var existing = document.Root!.Element(section.Name); + if (existing is null) + { + document.Root.Add(new XElement(section)); + } + else + { + existing.ReplaceWith(new XElement(section)); + } + } + document.Save(Path.Combine(directory.FullName, "nuget.config")); + } + + private void DenyDefaultReads(NugetSourceProvider provider) + { + var read = provider.ReadPackagesDirectory; + provider.ReadPackagesDirectory = path => + { + if (path.Equals(_defaultPackages, StringComparison.OrdinalIgnoreCase)) + { + throw new UnauthorizedAccessException("default storage denied"); + } + read(path); + }; + } + + [TestMethod] + public void DeniedDefault_UsesInvocationDirectoryAndWarnsOnce() + { + _root.CreateSubdirectory(".winapp").CreateSubdirectory("cache"); + var provider = CreateProvider(); + DenyDefaultReads(provider); + + Assert.AreEqual(LocalPackages, provider.GetPackagesDirectory().FullName); + Assert.AreEqual(LocalPackages, provider.GetPackagesDirectory(requireWrite: true).FullName); + Assert.HasCount(1, _diagnostics.Messages); + StringAssert.Contains(_diagnostics.Messages[0], LocalPackages); + Assert.IsFalse(Directory.Exists(Path.Combine(_root.FullName, ".winapp", "cache", "nuget"))); + } + + [TestMethod] + public void ExplicitEnvironment_DeniedFolderFailsWithoutFallback() + { + var provider = CreateProvider(); + provider.GetEnvironmentVariable = key => key == "NUGET_PACKAGES" ? _defaultPackages : null; + DenyDefaultReads(provider); + + var error = Assert.ThrowsExactly(() => provider.GetPackagesDirectory()); + + StringAssert.Contains(error.Message, "explicitly configured"); + Assert.IsEmpty(_diagnostics.Messages); + Assert.IsFalse(Directory.Exists(LocalPackages)); + } + + [TestMethod] + [DataRow("")] + [DataRow("relative-packages")] + public void ExplicitEnvironment_InvalidPathFailsWithoutFallback(string value) + { + var provider = CreateProvider(); + provider.GetEnvironmentVariable = key => key == "NUGET_PACKAGES" ? value : null; + + var error = Assert.ThrowsExactly(() => provider.GetPackagesDirectory()); + + StringAssert.Contains(error.Message, "fully qualified"); + Assert.IsEmpty(_diagnostics.Messages); + Assert.IsFalse(Directory.Exists(LocalPackages)); + } + + [TestMethod] + public void ExplicitConfiguration_DeniedFolderFailsWithoutFallback() + { + WriteConfig(_invocation, $""""""); + var provider = CreateProvider(); + DenyDefaultReads(provider); + + var error = Assert.ThrowsExactly(() => provider.GetPackagesDirectory()); + + StringAssert.Contains(error.Message, "explicitly configured"); + Assert.IsEmpty(_diagnostics.Messages); + Assert.IsFalse(Directory.Exists(LocalPackages)); + } + + [TestMethod] + public void ExplicitConfiguration_EmptyFolderFailsWithoutFallback() + { + WriteConfig(_invocation, """"""); + var provider = CreateProvider(); + + var error = Assert.ThrowsExactly(() => provider.GetPackagesDirectory()); + + StringAssert.Contains(error.Message, "globalPackagesFolder"); + Assert.IsFalse(Directory.Exists(LocalPackages)); + } + + [TestMethod] + public void InstanceTestingOverride_RemainsIsolatedFromConfiguredGlobalStorage() + { + var currentDirectory = new CurrentDirectoryProvider(_invocation.FullName); + var directories = new WinappDirectoryService(currentDirectory); + var isolated = _root.CreateSubdirectory("isolated-cache"); + directories.SetCacheDirectoryForTesting(isolated); + var provider = new NugetSourceProvider(currentDirectory, directories, _diagnostics) + { + LoadSettings = _ => throw new AssertFailedException("An instance test cache must not access machine configuration for its package path."), + }; + + Assert.AreEqual(Path.Combine(isolated.FullName, "packages"), provider.GetPackagesDirectory().FullName); + Assert.IsEmpty(_diagnostics.Messages); + } + + [TestMethod] + public async Task WarmReadOnlyPackage_InstallsWithoutAWriteProbeOrFallback() + { + var package = Directory.CreateDirectory(Path.Combine(_defaultPackages, "warm.package", "1.0.0")); + File.WriteAllText(Path.Combine(package.FullName, ".nupkg.metadata"), "{}"); + File.WriteAllText(Path.Combine(package.FullName, "warm.package.nuspec"), Nuspec("Warm.Package")); + var provider = CreateProvider(); + provider.WritePackagesDirectory = _ => Assert.Fail("A warm readable package must not need writable storage."); + var service = new NugetService(provider, new NugetPackageDownloader(provider)); + + var installed = await service.InstallPackageAsync("Warm.Package", "1.0.0", CreateTaskContext(), TestContext.CancellationToken); + + Assert.AreEqual("1.0.0", installed["Warm.Package"]); + Assert.AreEqual(package.FullName, service.GetNuGetPackageDir("Warm.Package", "1.0.0").FullName); + Assert.IsEmpty(_diagnostics.Messages); + Assert.IsFalse(Directory.Exists(LocalPackages)); + } + + [TestMethod] + public void RequiredConfigurationDenied_FailsWithoutReplacingPrivateFeeds() + { + var provider = CreateProvider(); + provider.LoadSettings = _ => throw new UnauthorizedAccessException("private nuget.config denied"); + + var error = Assert.ThrowsExactly(() => provider.GetPackagesDirectory()); + + StringAssert.Contains(error.Message, "required NuGet configuration"); + StringAssert.Contains(error.Message, "configured feeds and credentials cannot be replaced"); + Assert.IsEmpty(_diagnostics.Messages); + Assert.IsFalse(Directory.Exists(LocalPackages)); + } + + [TestMethod] + public void DefaultAndLocalUnavailable_ReportsBothWithoutSuccessWarning() + { + var provider = CreateProvider(); + DenyDefaultReads(provider); + provider.WritePackagesDirectory = _ => throw new UnauthorizedAccessException("local storage denied"); + + var error = Assert.ThrowsExactly(() => provider.GetPackagesDirectory()); + + StringAssert.Contains(error.Message, "default NuGet packages folder"); + StringAssert.Contains(error.Message, ".winapp\\cache\\nuget\\packages could not be used"); + Assert.IsEmpty(_diagnostics.Messages); + } + + [TestMethod] + public void Fallback_PreservesPrivateSourceOrderMappingCredentialsAndSignaturePolicy() + { + WriteConfig(_invocation, """ + + + + + + + + + + + + + + + """); + var provider = CreateProvider(); + var settings = provider.Settings; + DenyDefaultReads(provider); + + Assert.AreEqual(LocalPackages, provider.GetPackagesDirectory().FullName); + + Assert.AreSame(settings, provider.Settings); + var sources = new PackageSourceProvider(settings).LoadPackageSources().ToList(); + CollectionAssert.AreEqual(PrivateAndMirrorSources, sources.Select(source => source.Name).ToArray()); + Assert.AreEqual("test-user", sources[0].Credentials!.Username); + Assert.AreEqual("test-password", sources[0].Credentials!.PasswordText); + CollectionAssert.AreEqual(PrivateSourceOnly, + provider.GetRepositoriesForPackage("Private.Package").Select(source => source.PackageSource.Name).ToArray()); + Assert.AreEqual("require", settings.GetSection("config")!.Items.OfType() + .Single(item => item.Key == "signatureValidationMode").Value); + } + + [TestMethod] + public void ScopeKey_ChangesWithSelectedFolderAndRemainsInstanceIsolated() + { + Directory.CreateDirectory(_defaultPackages); + var provider = CreateProvider(); + var original = provider.ConfigScopeKey; + var write = provider.WritePackagesDirectory; + provider.WritePackagesDirectory = path => + { + if (path == _defaultPackages) + { + throw new UnauthorizedAccessException("read only"); + } + write(path); + }; + + provider.GetPackagesDirectory(requireWrite: true); + + Assert.AreNotEqual(original, provider.ConfigScopeKey); + StringAssert.Contains(provider.ConfigScopeKey, $"gpf={LocalPackages}"); + Assert.AreEqual(original, CreateProvider().ConfigScopeKey); + } + + [TestMethod] + public void ChildRestore_UsesFallbackWithoutChangingProcessEnvironment() + { + var provider = CreateProvider(); + DenyDefaultReads(provider); + var original = Environment.GetEnvironmentVariable("NUGET_PACKAGES"); + var child = new ProcessStartInfo("dotnet") { WorkingDirectory = _invocation.FullName }; + var dotnet = new DotNetService(provider); + + dotnet.ConfigurePackageEnvironment(child, ["restore", "App.csproj"]); + + Assert.AreEqual(LocalPackages, child.Environment["NUGET_PACKAGES"]); + Assert.AreEqual(original, Environment.GetEnvironmentVariable("NUGET_PACKAGES")); + Assert.AreEqual(provider.GetPackagesDirectory().FullName, child.Environment["NUGET_PACKAGES"]); + } + + [TestMethod] + [DataRow(false, "restore")] + [DataRow(false, "build")] + [DataRow(true, "restore")] + [DataRow(true, "build")] + public void ChildWarmReadOnlyCache_DoesNotProbeWrites(bool explicitlyConfigured, string verb) + { + var package = Directory.CreateDirectory(Path.Combine(_defaultPackages, "warm.package", "1.0.0")); + File.WriteAllText(Path.Combine(package.FullName, ".nupkg.metadata"), "{}"); + File.WriteAllText(Path.Combine(package.FullName, "warm.package.nuspec"), Nuspec("Warm.Package")); + if (explicitlyConfigured) + { + WriteConfig(_invocation, $""""""); + } + var provider = CreateProvider(); + provider.WritePackagesDirectory = _ => Assert.Fail("Launching dotnet with a warm readable cache must not probe writes."); + var child = new ProcessStartInfo("dotnet") { WorkingDirectory = _invocation.FullName }; + + new DotNetService(provider).ConfigurePackageEnvironment(child, [verb, "App.csproj"]); + + Assert.AreEqual(_defaultPackages, child.Environment["NUGET_PACKAGES"]); + Assert.IsEmpty(_diagnostics.Messages); + Assert.IsFalse(Directory.Exists(LocalPackages)); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task ChildProcess_ReceivesTheSelectedPackagesFolder(bool tokenArguments) + { + var provider = CreateProvider(); + DenyDefaultReads(provider); + var project = Path.Combine(_invocation.FullName, "Environment.proj"); + File.WriteAllText(project, """ + + """); + var dotnet = new DotNetService(provider); + + var result = tokenArguments + ? await dotnet.RunDotnetCommandAsync(_invocation, ["msbuild", project, "-t:Report", "-nologo"], + cancellationToken: TestContext.CancellationToken) + : await dotnet.RunDotnetCommandAsync(_invocation, $"msbuild \"{project}\" -t:Report -nologo", + TestContext.CancellationToken); + + Assert.AreEqual(0, result.ExitCode, result.Error + result.Output); + StringAssert.Contains(result.Output, $"PACKAGES={LocalPackages}"); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task ChildStorageFailure_PreservesErrorAndDoesNotReplay(bool tokenArguments) + { + Directory.CreateDirectory(_defaultPackages); + var provider = CreateProvider(); + provider.WritePackagesDirectory = _ => Assert.Fail("Child storage selection must not probe writes."); + var project = Path.Combine(_invocation.FullName, "Failure.proj"); + File.WriteAllText(project, """ + + + + + """); + var dotnet = new DotNetService(provider); + + var result = tokenArguments + ? await dotnet.RunDotnetCommandAsync(_invocation, ["msbuild", project, "-t:Report", "-nologo"], + cancellationToken: TestContext.CancellationToken) + : await dotnet.RunDotnetCommandAsync(_invocation, $"msbuild \"{project}\" -t:Report -nologo", + TestContext.CancellationToken); + + Assert.AreNotEqual(0, result.ExitCode); + StringAssert.Contains(result.Output + result.Error, "Access to NuGet packages is denied."); + Assert.HasCount(1, File.ReadAllLines(Path.Combine(_invocation.FullName, "attempts.txt"))); + Assert.IsEmpty(_diagnostics.Messages, "Failure guidance belongs to the command error, not buffered success warnings."); + StringAssert.Contains(result.Error, "set NUGET_PACKAGES to a permitted writable directory"); + Assert.IsFalse(Directory.Exists(LocalPackages)); + } + + [TestMethod] + public void ChildNoRestoreBuildInAnotherDirectory_ReusesSelectedFallback() + { + Directory.CreateDirectory(_defaultPackages); + var provider = CreateProvider(); + var write = provider.WritePackagesDirectory; + provider.WritePackagesDirectory = path => + { + if (path == _defaultPackages) + { + throw new UnauthorizedAccessException("read only"); + } + write(path); + }; + provider.GetPackagesDirectory(requireWrite: true); + var project = _root.CreateSubdirectory("project"); + WriteConfig(project); + var child = new ProcessStartInfo("dotnet") { WorkingDirectory = project.FullName }; + + new DotNetService(provider).ConfigurePackageEnvironment(child, ["build", "--no-restore"]); + + Assert.AreEqual(LocalPackages, child.Environment["NUGET_PACKAGES"]); + Assert.HasCount(1, _diagnostics.Messages); + } + + [TestMethod] + public void ChildExplicitConfiguration_IsNotReplacedByPreviouslySelectedFallback() + { + var provider = CreateProvider(); + DenyDefaultReads(provider); + provider.GetPackagesDirectory(requireWrite: true); + var project = _root.CreateSubdirectory("project"); + WriteConfig(project, $""""""); + var child = new ProcessStartInfo("dotnet") { WorkingDirectory = project.FullName }; + + var error = Assert.ThrowsExactly( + () => new DotNetService(provider).ConfigurePackageEnvironment(child, ["publish"])); + + StringAssert.Contains(error.Message, "explicitly configured"); + } + + [TestMethod] + public void FallbackPackageIdJunction_BlocksChildAndInProcessAccess() + { + var provider = CreateProvider(); + DenyDefaultReads(provider); + provider.GetPackagesDirectory(); + var target = _root.CreateSubdirectory("outside-package"); + var targetFile = Path.Combine(target.FullName, "sentinel.txt"); + File.WriteAllText(targetFile, "unchanged"); + var link = Path.Combine(LocalPackages, "linked.package"); + using (var process = Process.Start(new ProcessStartInfo("cmd.exe") + { + ArgumentList = { "/c", "mklink", "/J", link, target.FullName }, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + })!) + { + process.WaitForExit(); + Assert.AreEqual(0, process.ExitCode, process.StandardError.ReadToEnd()); + } + try + { + using (File.Open(targetFile, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + { + AssertFallbackLinkRejected(provider); + } + Assert.AreEqual("unchanged", File.ReadAllText(targetFile)); + Assert.HasCount(1, target.GetFileSystemInfos()); + } + finally + { + Directory.Delete(link); + } + } + + [TestMethod] + public void FallbackLeafLink_BlocksChildAndInProcessAccess() + { + var provider = CreateProvider(); + DenyDefaultReads(provider); + provider.GetPackagesDirectory(); + var target = Path.Combine(_root.FullName, "outside.nuspec"); + File.WriteAllText(target, "must not read or overwrite"); + var package = Directory.CreateDirectory(Path.Combine(LocalPackages, "linked.package", "1.0.0")); + File.WriteAllText(Path.Combine(package.FullName, ".nupkg.metadata"), "{}"); + var link = Path.Combine(package.FullName, "linked.package.nuspec"); + try + { + File.CreateSymbolicLink(link, target); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Assert.Inconclusive($"File symbolic links are unavailable: {ex.Message}"); + } + try + { + using (File.Open(target, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + { + AssertFallbackLinkRejected(provider); + var error = Assert.ThrowsExactly( + () => provider.ValidatePackagePath(LocalPackages, package.FullName)); + StringAssert.Contains(error.Message, "link or reparse point"); + } + Assert.AreEqual("must not read or overwrite", File.ReadAllText(target)); + } + finally + { + File.Delete(link); + } + } + + private void AssertFallbackLinkRejected(NugetSourceProvider provider) + { + var child = new ProcessStartInfo("dotnet") { WorkingDirectory = _invocation.FullName }; + child.Environment.Remove("NUGET_PACKAGES"); + var childError = Assert.ThrowsExactly( + () => new DotNetService(provider).ConfigurePackageEnvironment(child, ["restore"])); + StringAssert.Contains(childError.Message, "link or reparse point"); + Assert.IsFalse(child.Environment.ContainsKey("NUGET_PACKAGES"), "Unsafe local storage must not be exposed to a child."); + var service = new NugetService(provider, new NugetPackageDownloader(provider)); + var inProcessError = Assert.ThrowsExactly( + () => service.GetNuGetPackageDir("Linked.Package", "1.0.0")); + StringAssert.Contains(inProcessError.Message, "link or reparse point"); + } + + [TestMethod] + [DataRow("--version", null)] + [DataRow("--info", null)] + [DataRow("new", "list")] + [DataRow("new", "search")] + [DataRow("sln", "list")] + [DataRow("build", "--help")] + public void UnrelatedDotnetCommands_DoNotLoadNuGetOrCreateStorage(string verb, string? argument) + { + var provider = CreateProvider(); + provider.LoadSettings = _ => throw new AssertFailedException("NuGet settings must remain lazy."); + var child = new ProcessStartInfo("dotnet") { WorkingDirectory = _invocation.FullName }; + + new DotNetService(provider).ConfigurePackageEnvironment(child, argument is null ? [verb] : [verb, argument]); + + Assert.IsFalse(Directory.Exists(LocalPackages)); + Assert.IsFalse(Directory.Exists(_defaultPackages)); + } + + [TestMethod] + public async Task MissingDependencyInReadOnlyCache_MovesTheWholeGraphToFallback() + { + var feed = _root.CreateSubdirectory("feed"); + WritePackage(feed, "Root.Package", "Child.Package"); + WritePackage(feed, "Child.Package"); + WriteConfig(_invocation, $""""""); + var cachedRoot = Directory.CreateDirectory(Path.Combine(_defaultPackages, "root.package", "1.0.0")); + File.WriteAllText(Path.Combine(cachedRoot.FullName, ".nupkg.metadata"), "{}"); + File.WriteAllText(Path.Combine(cachedRoot.FullName, "root.package.nuspec"), Nuspec("Root.Package", "Child.Package")); + var provider = CreateProvider(); + var write = provider.WritePackagesDirectory; + provider.WritePackagesDirectory = path => + { + if (path == _defaultPackages) + { + throw new UnauthorizedAccessException("read only"); + } + write(path); + }; + var service = new NugetService(provider, new NugetPackageDownloader(provider)); + + var graph = await service.InstallPackageAsync("Root.Package", "1.0.0", CreateTaskContext(), TestContext.CancellationToken); + + Assert.HasCount(2, graph); + Assert.AreEqual(LocalPackages, service.GetNuGetGlobalPackagesDir().FullName); + foreach (var package in graph) + { + Assert.IsTrue(service.IsPackageInstalled(package.Key, package.Value)); + StringAssert.StartsWith(service.GetNuGetPackageDir(package.Key, package.Value).FullName, LocalPackages); + } + Assert.IsEmpty(Directory.GetFiles(LocalPackages, ".winapp-download-*")); + } + + private static TaskContext CreateTaskContext() + { + var console = new TestConsole(); + return new TaskContext(new GroupableTask("NuGet storage test", null), null, console, NullLogger.Instance, new Lock()); + } + + private static string Nuspec(string id, string? dependency = null) => $""" + + {id}1.0.0winapp-testsStorage test + {(dependency is null ? "" : $"")} + + + """; + + private static void WritePackage(DirectoryInfo feed, string id, string? dependency = null) + { + using var archive = ZipFile.Open(Path.Combine(feed.FullName, $"{id}.1.0.0.nupkg"), ZipArchiveMode.Create); + using (var writer = new StreamWriter(archive.CreateEntry($"{id}.nuspec").Open())) + { + writer.Write(Nuspec(id, dependency)); + } + using var content = new StreamWriter(archive.CreateEntry("lib/net10.0/test.txt").Open()); + content.Write("test"); + } + + private sealed class RecordingDiagnostics : IStorageDiagnostics + { + public List Messages { get; } = []; + public void Warning(string code, string message) => Messages.Add(message); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/PackageInstallationServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/PackageInstallationServiceTests.cs index 1452415a6..3c90e24e5 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/PackageInstallationServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/PackageInstallationServiceTests.cs @@ -20,6 +20,10 @@ namespace WinApp.Cli.Tests; [TestClass] public class PackageInstallationServiceTests { + private static readonly string[] ReplayedRoots = ["Pkg.A", "Pkg.B"]; + private static readonly (string Package, string Version)[] ReplayedInstalls = + [("Pkg.A", "1.6.0"), ("Pkg.B", "1.6.0"), ("Pkg.A", "1.6.0"), ("Pkg.B", "1.6.0")]; + private DirectoryInfo _tempDir = null!; private DirectoryInfo _cacheDir = null!; private DirectoryInfo _rootDir = null!; @@ -84,7 +88,7 @@ public void InitializeWorkspace_ExistingDirectory_NoThrow() #region EnsurePackageAsync [TestMethod] - public async Task EnsurePackageAsync_Success_ReturnsTrue_AndCreatesWorkspace() + public async Task EnsurePackageAsync_Success_DoesNotCreateUnrelatedGlobalWorkspace() { _nuget.DefaultVersion = "1.6.0"; @@ -92,10 +96,23 @@ public async Task EnsurePackageAsync_Success_ReturnsTrue_AndCreatesWorkspace() Assert.IsTrue(ok); _rootDir.Refresh(); - Assert.IsTrue(_rootDir.Exists); + Assert.IsFalse(_rootDir.Exists); CollectionAssert.Contains(_nuget.InstalledPackages, ("Pkg.X", "1.6.0")); } + [TestMethod] + public async Task EnsurePackageAsync_BlockedGlobalWorkspace_UsesNugetCache() + { + File.WriteAllText(_rootDir.FullName, "not a directory"); + + var ok = await _service.EnsurePackageAsync( + _rootDir, "Pkg.X", _taskContext, version: "2.0.0"); + + Assert.IsTrue(ok); + CollectionAssert.Contains(_nuget.InstalledPackages, ("Pkg.X", "2.0.0")); + Assert.AreEqual("not a directory", File.ReadAllText(_rootDir.FullName)); + } + [TestMethod] public async Task EnsurePackageAsync_ExplicitVersion_DoesNotQueryLatest() { @@ -219,6 +236,80 @@ public async Task InstallPackagesAsync_MergesInstalledVersions_LowerDoesNotDowng #endregion + [TestMethod] + public async Task InstallPackagesAsync_CacheChangesOnSecondRoot_ReplaysResolvedVersionsAndReturnsOnlyFinalGraph() + { + var fallback = _tempDir.CreateSubdirectory("fallback-cache"); + _nuget.InstallReturns["Pkg.A"] = new() { ["Pkg.A"] = "1.6.0", ["Old.Dependency"] = "9.0.0" }; + var switched = false; + _nuget.BeforeInstall = (package, _) => + { + if (package == "Pkg.B" && !switched) + { + switched = true; + _nuget.CacheDirectory = fallback; + _nuget.DefaultVersion = "2.0.0"; + _nuget.InstallReturns["Pkg.A"] = new() { ["Pkg.A"] = "1.6.0", ["Final.Dependency"] = "1.0.0" }; + } + }; + + var enumerations = 0; + IEnumerable RequestedPackages() + { + Assert.AreEqual(1, ++enumerations, "The requested roots must be materialized once, not enumerated again for replay."); + yield return "Pkg.A"; + yield return "Pkg.B"; + } + + var result = await _service.InstallPackagesAsync(_rootDir, RequestedPackages(), _taskContext); + + CollectionAssert.AreEqual(ReplayedRoots, _nuget.QueriedPackages, + "Replay must use the resolved versions even if the latest version changes."); + CollectionAssert.AreEqual(ReplayedInstalls, _nuget.InstalledPackages); + Assert.IsFalse(result.ContainsKey("Old.Dependency"), "The abandoned cache's aggregate graph must be discarded."); + Assert.AreEqual("1.0.0", result["Final.Dependency"]); + Assert.HasCount(3, result); + Assert.AreEqual(Path.Combine(fallback.FullName, "packages"), _nuget.GetNuGetGlobalPackagesDir().FullName); + foreach (var (package, version) in result) + { + Assert.IsTrue(_nuget.IsPackageInstalled(package, version), $"{package} {version} must exist in the final root."); + } + Assert.IsFalse(Directory.Exists(_rootDir.FullName), "Batch replay must not initialize the unrelated global workspace."); + } + + [TestMethod] + public async Task InstallPackagesAsync_CacheChangesAgainDuringReplay_FailsAfterOneRetry() + { + var switches = 0; + _nuget.BeforeInstall = (package, _) => + { + if (package == "Pkg.B") + { + _nuget.CacheDirectory = _tempDir.CreateSubdirectory($"changed-cache-{++switches}"); + } + }; + + var error = await Assert.ThrowsExactlyAsync( + () => _service.InstallPackagesAsync(_rootDir, ["Pkg.A", "Pkg.B"], _taskContext)); + + StringAssert.Contains(error.Message, "NuGet packages folder changed again"); + Assert.AreEqual(2, switches); + Assert.HasCount(4, _nuget.InstalledPackages, "Root instability must not trigger an unbounded retry loop."); + Assert.HasCount(2, _nuget.QueriedPackages, "A retry must not resolve different requested versions."); + } + + [TestMethod] + public async Task InstallPackagesAsync_EmptyBatch_DoesNotAccessPackageStorage() + { + _nuget.CacheDirectory = null; + + var result = await _service.InstallPackagesAsync(_rootDir, [], _taskContext); + + Assert.IsEmpty(result); + Assert.IsEmpty(_nuget.QueriedPackages); + Assert.IsEmpty(_nuget.InstalledPackages); + } + private sealed class FakeConfigService : IConfigService { public FileInfo ConfigPath { get; set; } = new(Path.Join(Path.GetTempPath(), "winapp.yaml")); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/PackagedSandboxMutationLockTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/PackagedSandboxMutationLockTests.cs index a7aa92155..3b34949b8 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/PackagedSandboxMutationLockTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/PackagedSandboxMutationLockTests.cs @@ -864,7 +864,6 @@ public RunHarness( orchestrator, runner, runtimeService, - new WinappDirectoryService(currentDirectoryProvider), logger); } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/ProgramTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/ProgramTests.cs index 68af6dae7..ebd8ace98 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/ProgramTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/ProgramTests.cs @@ -139,6 +139,46 @@ public void Cleanup() try { Directory.Delete(_tempCacheDir, recursive: true); } catch { /* best effort */ } } + [TestMethod] + [DataRow("--help")] + [DataRow("--version")] + public async Task Main_InformationalCommand_DoesNotAccessBlockedBookkeeping(string option) + { + var blocker = Path.Join(_tempCacheDir, "blocker"); + File.WriteAllText(blocker, "not a directory"); + Environment.SetEnvironmentVariable("WINAPP_CLI_CACHE_DIRECTORY", Path.Join(blocker, "cache")); + + var (stdout, stderr, exitCode) = await ProgramMainTestHarness.InvokeProgramAsync([option]); + + Assert.AreEqual(0, exitCode); + Assert.IsFalse(stdout.Contains("Welcome to", StringComparison.Ordinal)); + Assert.IsFalse(stderr.Contains("marker", StringComparison.Ordinal)); + Assert.AreEqual("not a directory", File.ReadAllText(blocker)); + } + + [TestMethod] + public async Task Main_GetGlobalPath_DoesNotPrependFirstRunNotice() + { + File.Delete(Path.Join(_tempCacheDir, ".first-run-complete")); + Directory.CreateDirectory(Path.Join(_tempCacheDir, ".first-run-complete")); + var previousConsole = Spectre.Console.AnsiConsole.Console; + var console = new Spectre.Console.Testing.TestConsole(); + console.Profile.Width = 20; + try + { + Spectre.Console.AnsiConsole.Console = console; + var (_, stderr, exitCode) = await ProgramMainTestHarness.InvokeProgramAsync(["get-winapp-path", "--global"]); + + Assert.AreEqual(0, exitCode); + Assert.AreEqual(_tempCacheDir, console.Output.Trim()); + Assert.AreEqual(string.Empty, stderr); + } + finally + { + Spectre.Console.AnsiConsole.Console = previousConsole; + } + } + [TestMethod] public async Task Main_NoArguments_ShowsBannerAndHelp_ReturnsZero() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/RunCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/RunCommandTests.cs index fbab4fb62..1e079b331 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/RunCommandTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/RunCommandTests.cs @@ -590,7 +590,6 @@ public async Task RunCommand_LongPathValidation_WithJson_EmitsJsonError() GetRequiredService(), GetRequiredService(), GetRequiredService(), - GetRequiredService(), GetRequiredService>()); // Act diff --git a/src/winapp-CLI/WinApp.Cli.Tests/RuntimeFrameworkResolverTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/RuntimeFrameworkResolverTests.cs index fe9d326d5..b926ccd97 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/RuntimeFrameworkResolverTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/RuntimeFrameworkResolverTests.cs @@ -112,6 +112,22 @@ public async Task Resolve_PrefersACompleteInstallationTheHostAlreadyHas() Assert.Contains($"shared/{Core}/10.0.4/.version", ReadEntries(payload.Archive)); } + [TestMethod] + public async Task Resolve_UnreadableHostAssembly_IsNotReportedAsACacheFailure() + { + var installed = TestPaths.Under(_root, "dotnet"); + WriteInstallation(installed, "10.0.4"); + _resolver.HostDotNetRoots = () => [installed]; + var source = Path.Combine(installed, "shared", Core, "10.0.4", "System.Private.CoreLib.dll"); + using var denyRead = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.None); + + var payload = await ResolveAsync(Core, "10.0.0", "x64"); + + Assert.IsNull(payload, "An already-provisioned guest can continue without an unreadable host payload."); + Assert.AreEqual(0, Directory.EnumerateFiles(_winappCache, "*.zip", SearchOption.AllDirectories).Count()); + Assert.AreEqual(0, Directory.EnumerateFiles(_winappCache, "*.tmp", SearchOption.AllDirectories).Count()); + } + [TestMethod] public async Task Resolve_IgnoresAHostInstallationForAnotherArchitecture() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/StorageDiagnosticsTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/StorageDiagnosticsTests.cs new file mode 100644 index 000000000..f3c9825b6 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/StorageDiagnosticsTests.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Text.Json; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class StorageDiagnosticsTests +{ + [TestMethod] + public void Warning_DeduplicatesConcurrentIdenticalReports() + { + using var error = new StringWriter(); + var diagnostics = new StorageDiagnostics(error); + + Parallel.For(0, 20, _ => diagnostics.Warning("cache_fallback", "Using the local cache.")); + + Assert.AreEqual($"Warning: Using the local cache.{Environment.NewLine}", error.ToString()); + } + + [TestMethod] + public void Warning_Json_IsOneStructuredDocumentPerDiagnostic() + { + using var error = new StringWriter(); + var diagnostics = new StorageDiagnostics(error, json: true); + + diagnostics.Warning("cache_fallback", "Using C:\\work\\\"cache\"."); + + using var doc = JsonDocument.Parse(error.ToString()); + Assert.AreEqual("cache_fallback", doc.RootElement.GetProperty("warning").GetProperty("code").GetString()); + Assert.AreEqual("Using C:\\work\\\"cache\".", doc.RootElement.GetProperty("warning").GetProperty("message").GetString()); + } + + [TestMethod] + [DataRow(true)] + [DataRow(false)] + public void DeferredWarnings_AreOnlyPublishedAfterSuccess(bool succeeded) + { + using var error = new StringWriter(); + var diagnostics = new StorageDiagnostics(error, json: true, deferWarnings: true); + diagnostics.Warning("cache_fallback", "Using the local cache."); + Assert.AreEqual(string.Empty, error.ToString()); + + diagnostics.Complete(succeeded); + var output = error.ToString(); + diagnostics.Complete(succeeded); + diagnostics.Warning("late_warning", "Must not change a completed invocation."); + + Assert.AreEqual(output, error.ToString()); + Assert.AreEqual(succeeded, output.Length > 0); + if (succeeded) + { + using var document = JsonDocument.Parse(output); + Assert.AreEqual("cache_fallback", document.RootElement.GetProperty("warning").GetProperty("code").GetString()); + } + } + + [TestMethod] + public void Warning_Quiet_DoesNotWrite() + { + using var error = new StringWriter(); + var diagnostics = new StorageDiagnostics(error, quiet: true); + + diagnostics.Warning("cache_fallback", "Using local cache."); + + Assert.AreEqual(string.Empty, error.ToString()); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/StubWinappDirectoryService.cs b/src/winapp-CLI/WinApp.Cli.Tests/StubWinappDirectoryService.cs index bc1b67b55..c908ac04c 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/StubWinappDirectoryService.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/StubWinappDirectoryService.cs @@ -11,6 +11,8 @@ namespace WinApp.Cli.Tests; /// internal sealed class StubWinappDirectoryService(DirectoryInfo global) : IWinappDirectoryService { + public bool IsGlobalCacheOverridden => true; + public DirectoryInfo GetLocalCacheDirectory() => new(Path.Combine(global.FullName, "local-cache")); public DirectoryInfo GetGlobalWinappDirectory() => global; public DirectoryInfo GetLocalWinappDirectory(DirectoryInfo? baseDirectory = null) => global; public void SetCacheDirectoryForTesting(DirectoryInfo? cacheDirectory) { } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/TemplateUpdateCheckThrottleTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/TemplateUpdateCheckThrottleTests.cs index e48500fe4..b9d53c908 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/TemplateUpdateCheckThrottleTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/TemplateUpdateCheckThrottleTests.cs @@ -41,6 +41,39 @@ private TemplateUpdateCheckThrottle CreateThrottle(Func? clock = UtcNowProvider = clock ?? (() => DateTimeOffset.UtcNow), }; + [TestMethod] + public void CanCheckForUpdates_UnavailableBookkeeping_SkipsCheckWithWarning() + { + Directory.CreateDirectory(Path.Join(_globalDir.FullName, ".template-update-check")); + using var error = new StringWriter(); + var throttle = new TemplateUpdateCheckThrottle( + new FakeWinappDirectoryService(_globalDir), + NullLogger.Instance, + new StorageDiagnostics(error)); + + Assert.IsFalse(throttle.CanCheckForUpdates()); + StringAssert.Contains(error.ToString(), "Skipping the automatic template update check"); + } + + [TestMethod] + public void CanCheckForUpdates_ReadableCache_IsNotTruncated() + { + var throttle = CreateThrottle(); + throttle.Record("1.0.0", "1.2.0"); + var path = Path.Join(_globalDir.FullName, ".template-update-check"); + var before = File.ReadAllText(path); + + Assert.IsTrue(throttle.CanCheckForUpdates()); + Assert.AreEqual(before, File.ReadAllText(path)); + } + + [TestMethod] + public void CanCheckForUpdates_NoCache_DoesNotPublishAnEmptyResult() + { + Assert.IsTrue(CreateThrottle().CanCheckForUpdates()); + Assert.AreEqual(0, Directory.GetFiles(_globalDir.FullName).Length); + } + [TestMethod] public void TryGetRecentLatest_NoCache_ReturnsFalse() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationServiceCoverageTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationServiceCoverageTests.cs index bec668af1..337cca755 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationServiceCoverageTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationServiceCoverageTests.cs @@ -66,6 +66,29 @@ private static HttpClient FakeGitHub(HttpStatusCode status, string content) return new HttpClient(handler); } + [TestMethod] + public void CheckAndNotify_BlockedBookkeeping_DoesNotStartNetworkRefresh() + { + Directory.CreateDirectory(Path.Join(_testCacheDirectory.FullName, ".update-check")); + using var error = new StringWriter(); + var handler = new FakeHttpMessageHandler().WhenUriContains( + "releases/latest", HttpStatusCode.OK, """{"tag_name":"v0.0.1"}"""); + using var client = new HttpClient(handler); + var service = new UpdateNotificationService( + GetRequiredService(), + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, + new StorageDiagnostics(error)) + { + Http = client, + }; + + service.CheckAndNotify(); + + Assert.AreEqual(0, handler.Requests.Count); + StringAssert.Contains(error.ToString(), "bookkeeping"); + Assert.IsFalse(Directory.EnumerateFiles(_testCacheDirectory.FullName, "*.tmp").Any()); + } + [TestMethod] public async Task GetLatestVersionAsync_OnSuccess_ReturnsParsedVersion() { diff --git a/src/winapp-CLI/WinApp.Cli/Commands/FindApiCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/FindApiCommand.cs index f50e99777..51112603b 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/FindApiCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/FindApiCommand.cs @@ -78,7 +78,8 @@ public FindApiCommand( public sealed class Handler(IApiMetadataService service, IAnsiConsole console) : AsynchronousCommandLineAction { - public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) => Task.FromResult(Execute(parseResult)); + public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) => + Task.FromResult(FindApiShared.Invoke(console, parseResult, () => Execute(parseResult))); private int Execute(ParseResult parseResult) { diff --git a/src/winapp-CLI/WinApp.Cli/Commands/FindApiShared.cs b/src/winapp-CLI/WinApp.Cli/Commands/FindApiShared.cs index ed2aa876f..a2bc4e352 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/FindApiShared.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/FindApiShared.cs @@ -24,6 +24,18 @@ namespace WinApp.Cli.Commands; /// internal static class FindApiShared { + internal static int Invoke(IAnsiConsole console, ParseResult parseResult, Func execute) + { + try + { + return execute(); + } + catch (Exception ex) when (CacheStorage.IsStorageFailure(ex) || ex is InvalidOperationException) + { + return Fail(console, parseResult.GetValue(WinAppRootCommand.JsonOption), ex.Message); + } + } + /// /// The scope options are declared once and shared by find-api and every verb /// under it — the pattern the CLI already uses for --verbose and --quiet. diff --git a/src/winapp-CLI/WinApp.Cli/Commands/FindApiVerbs.cs b/src/winapp-CLI/WinApp.Cli/Commands/FindApiVerbs.cs index 44cd6a144..516ff3ad0 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/FindApiVerbs.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/FindApiVerbs.cs @@ -44,7 +44,8 @@ public FindApiMembersCommand() public sealed class Handler(IApiMetadataService service, IAnsiConsole console) : AsynchronousCommandLineAction { - public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) => Task.FromResult(Execute(parseResult)); + public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) => + Task.FromResult(FindApiShared.Invoke(console, parseResult, () => Execute(parseResult))); private int Execute(ParseResult parseResult) { @@ -115,7 +116,8 @@ public FindApiCheckPropertyCommand() public sealed class Handler(IApiMetadataService service, IAnsiConsole console) : AsynchronousCommandLineAction { - public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) => Task.FromResult(Execute(parseResult)); + public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) => + Task.FromResult(FindApiShared.Invoke(console, parseResult, () => Execute(parseResult))); private int Execute(ParseResult parseResult) { @@ -182,7 +184,8 @@ public FindApiTypesCommand() public sealed class Handler(IApiMetadataService service, IAnsiConsole console) : AsynchronousCommandLineAction { - public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) => Task.FromResult(Execute(parseResult)); + public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) => + Task.FromResult(FindApiShared.Invoke(console, parseResult, () => Execute(parseResult))); private int Execute(ParseResult parseResult) { @@ -230,7 +233,8 @@ public FindApiEnumsCommand() public sealed class Handler(IApiMetadataService service, IAnsiConsole console) : AsynchronousCommandLineAction { - public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) => Task.FromResult(Execute(parseResult)); + public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) => + Task.FromResult(FindApiShared.Invoke(console, parseResult, () => Execute(parseResult))); private int Execute(ParseResult parseResult) { @@ -291,7 +295,8 @@ public FindApiNamespacesCommand() public sealed class Handler(IApiMetadataService service, IAnsiConsole console) : AsynchronousCommandLineAction { - public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) => Task.FromResult(Execute(parseResult)); + public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) => + Task.FromResult(FindApiShared.Invoke(console, parseResult, () => Execute(parseResult))); private int Execute(ParseResult parseResult) { @@ -321,7 +326,8 @@ public FindApiPackagesCommand() public sealed class Handler(IApiMetadataService service, IAnsiConsole console) : AsynchronousCommandLineAction { - public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) => Task.FromResult(Execute(parseResult)); + public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) => + Task.FromResult(FindApiShared.Invoke(console, parseResult, () => Execute(parseResult))); private int Execute(ParseResult parseResult) { @@ -350,7 +356,8 @@ public FindApiStatsCommand() public sealed class Handler(IApiMetadataService service, IAnsiConsole console) : AsynchronousCommandLineAction { - public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) => Task.FromResult(Execute(parseResult)); + public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) => + Task.FromResult(FindApiShared.Invoke(console, parseResult, () => Execute(parseResult))); private int Execute(ParseResult parseResult) { @@ -382,7 +389,8 @@ public FindApiProjectsCommand() public sealed class Handler(IApiMetadataService service, IAnsiConsole console) : AsynchronousCommandLineAction { - public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) => Task.FromResult(Execute(parseResult)); + public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) => + Task.FromResult(FindApiShared.Invoke(console, parseResult, () => Execute(parseResult))); private int Execute(ParseResult parseResult) { @@ -416,7 +424,8 @@ public FindApiRefreshCommand() public sealed class Handler(IApiMetadataService service, IAnsiConsole console) : AsynchronousCommandLineAction { - public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) => Task.FromResult(Execute(parseResult)); + public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) => + Task.FromResult(FindApiShared.Invoke(console, parseResult, () => Execute(parseResult))); private int Execute(ParseResult parseResult) { diff --git a/src/winapp-CLI/WinApp.Cli/Commands/GetWinappPathCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/GetWinappPathCommand.cs index 64d502614..7fb75fbb6 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/GetWinappPathCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/GetWinappPathCommand.cs @@ -69,23 +69,41 @@ public override Task InvokeAsync(ParseResult parseResult, CancellationToken } // For global directories, check if they exist - if (global && !winappDir.Exists) + if (global) { - logger.LogError("{UISymbol} {DirectoryType} .winapp directory not found: {WinappDir}", UiSymbols.Error, directoryType, winappDir); - logger.LogError(" Make sure to run 'winapp init' first"); - return Task.FromResult(1); + try + { + if (!File.GetAttributes(winappDir.FullName).HasFlag(FileAttributes.Directory)) + { + logger.LogError("{UISymbol} The global cache path is not a directory: {WinappDir}", + UiSymbols.Error, winappDir); + return Task.FromResult(1); + } + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + logger.LogError("{UISymbol} {DirectoryType} .winapp directory not found: {WinappDir}", UiSymbols.Error, directoryType, winappDir); + logger.LogError("Choose an existing, permitted directory with WINAPP_CLI_CACHE_DIRECTORY."); + return Task.FromResult(1); + } } // Output just the path for easy consumption by scripts. Use IAnsiConsole // directly (rather than ILogger) so the path lands cleanly on stdout without // any logger formatting and so tests can capture it via TestAnsiConsole. - console.WriteLine(winappDir.FullName); + console.Profile.Out.Writer.WriteLine(winappDir.FullName); var status = winappDir.Exists ? "exists" : "does not exist"; logger.LogDebug("{UISymbol} {DirectoryType} .winapp directory: {WinappDir} ({Status})", UiSymbols.Folder, directoryType, winappDir, status); return Task.FromResult(0); } + catch (UnauthorizedAccessException ex) + { + logger.LogError("{UISymbol} Cannot access the {DirectoryType} winapp directory: {ErrorMessage}. Allow access or set WINAPP_CLI_CACHE_DIRECTORY to a permitted directory.", + UiSymbols.Error, global ? "global" : "local", ex.Message); + return Task.FromResult(1); + } catch (Exception ex) { logger.LogError("{UISymbol} Error getting {DirectoryType} winapp directory: {ErrorMessage}", UiSymbols.Error, (global ? "global" : "local"), ex.Message); diff --git a/src/winapp-CLI/WinApp.Cli/Commands/NewCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/NewCommand.cs index 5404b8442..de71541a7 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/NewCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/NewCommand.cs @@ -852,6 +852,10 @@ private static (VersionMode Mode, string? ExplicitVersion) ClassifyVersion(strin { latest = cachedLatest; } + else if (!templateUpdateThrottle.CanCheckForUpdates()) + { + latest = null; + } else { var (checkSucceeded, feedLatest) = await WithSpinnerAsync( diff --git a/src/winapp-CLI/WinApp.Cli/Commands/RunCommand.Target.cs b/src/winapp-CLI/WinApp.Cli/Commands/RunCommand.Target.cs index 6ae2828c7..48583bf88 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/RunCommand.Target.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/RunCommand.Target.cs @@ -67,7 +67,6 @@ private async Task ExecutePackagedTargetRunAsync( LongPathHelper.ValidatePathLength(layout.FullName); layoutLease = LayoutLease.Acquire( - winappDirectoryService.GetGlobalWinappDirectory(), layout, cancellationToken); diff --git a/src/winapp-CLI/WinApp.Cli/Commands/RunCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/RunCommand.cs index 37c6ce9eb..7f8f1a32f 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/RunCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/RunCommand.cs @@ -278,7 +278,6 @@ public partial class Handler( ExecutionTargetOrchestrator executionTargetOrchestrator, GuestApplicationRunner guestApplicationRunner, TargetRuntimeService targetRuntimeService, - IWinappDirectoryService winappDirectoryService, ILogger logger) : AsynchronousCommandLineAction { // Test seams for the execution-alias launch path. They isolate the two operating-system @@ -810,7 +809,6 @@ internal async Task ExecuteRunPipelineAsync( // register the other one's app. It is released before the run waits on the // application, so an open app never blocks another run against this build output. using var layoutLease = LayoutLease.Acquire( - winappDirectoryService.GetGlobalWinappDirectory(), outputAppXDirectory, cancellationToken); diff --git a/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/DotNetLayout.cs b/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/DotNetLayout.cs index 653d522db..58ab54093 100644 --- a/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/DotNetLayout.cs +++ b/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/DotNetLayout.cs @@ -278,15 +278,43 @@ public static async Task BuildArchiveAsync( await using (var stream = new FileStream(staged, FileMode.CreateNew, FileAccess.Write, FileShare.None)) using (var archive = new ZipArchive(stream, ZipArchiveMode.Create)) { + var buffer = new byte[81920]; foreach (var entry in source.Entries) { cancellationToken.ThrowIfCancellationRequested(); var created = archive.CreateEntry(entry.EntryPath, CompressionLevel.Fastest); - await using var content = File.OpenRead(entry.SourcePath); - await using var target = created.Open(); - await content.CopyToAsync(target, cancellationToken).ConfigureAwait(false); + FileStream content; + try + { + content = File.OpenRead(entry.SourcePath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw new InvalidDataException($"The host runtime input '{entry.SourcePath}' could not be read.", ex); + } + await using (content) + await using (var target = created.Open()) + { + while (true) + { + int count; + try + { + count = await content.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw new InvalidDataException($"The host runtime input '{entry.SourcePath}' could not be read.", ex); + } + if (count == 0) + { + break; + } + await target.WriteAsync(buffer.AsMemory(0, count), cancellationToken).ConfigureAwait(false); + } + } } } diff --git a/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/HostSourceWalker.cs b/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/HostSourceWalker.cs index d916e6997..343081e31 100644 --- a/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/HostSourceWalker.cs +++ b/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/HostSourceWalker.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using WinApp.Cli.ExecutionTargets.Abstractions; +using WinApp.Cli.Services; namespace WinApp.Cli.ExecutionTargets.Orchestration; @@ -74,6 +75,7 @@ public static List EnumerateFiles( ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootPath)); + RejectLockArtifact(root); // The root is checked before anything is walked, and independently of the policy. Descending // into a linked root would make the entire tree beneath it appear to be inside the folder @@ -111,6 +113,11 @@ private static void Collect( { cancellationToken.ThrowIfCancellationRequested(); + if (LayoutLease.IsArtifactPath(entry.FullName)) + { + continue; + } + // Checked for every entry, directory and file alike, and before any decision to descend. // This single test is what the whole class exists for. if (entry.Attributes.HasFlag(FileAttributes.ReparsePoint)) @@ -176,6 +183,7 @@ public static void EnsureNoLinkOnPath(string rootPath, string fullPath) ArgumentException.ThrowIfNullOrWhiteSpace(fullPath); var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootPath)); + RejectLockArtifact(Path.GetFullPath(fullPath)); if (!TargetPathSafety.IsInsideRoot(root, fullPath)) { @@ -246,6 +254,17 @@ public static bool IsLink(FileSystemInfo entry) return entry.Exists && entry.Attributes.HasFlag(FileAttributes.ReparsePoint); } + private static void RejectLockArtifact(string path) + { + if (LayoutLease.IsArtifactPath(path)) + { + throw ExecutionTargetException.Create( + ExecutionTargetErrorCodes.DeploymentDirty, + $"'{path}' is reserved layout coordination state, not application payload.", + userAction: "Choose a source outside the .winapp-layout-locks directory."); + } + } + private static ExecutionTargetException LinkRejected(FileSystemInfo entry) => ExecutionTargetException.Create( ExecutionTargetErrorCodes.DeploymentDirty, diff --git a/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/RuntimeFrameworkResolver.cs b/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/RuntimeFrameworkResolver.cs index 02203b7fb..cdfc36310 100644 --- a/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/RuntimeFrameworkResolver.cs +++ b/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/RuntimeFrameworkResolver.cs @@ -68,8 +68,10 @@ internal interface IRuntimeFrameworkResolver internal sealed class RuntimeFrameworkResolver( INugetService nugetService, IPackageInstallationService packageInstallationService, - IWinappDirectoryService winappDirectoryService) : IRuntimeFrameworkResolver + IWinappDirectoryService winappDirectoryService, + IStorageDiagnostics? diagnostics = null) : IRuntimeFrameworkResolver { + private readonly CacheStorage _cache = new(winappDirectoryService, Path.Combine("cache", CacheFolderName), CacheFolderName, diagnostics); /// Folder inside the shared winapp cache that built layouts are kept in. internal const string CacheFolderName = "dotnet-layouts"; @@ -222,41 +224,43 @@ await packageInstallationService return null; } - var cache = new DirectoryInfo(Path.Join( - winappDirectoryService.GetGlobalWinappDirectory().FullName, "cache", CacheFolderName)); - - cache.Create(); - - var archive = new FileInfo(Path.Join( - cache.FullName, - TargetPathSafety.EnsureSafeSegment( - $"{requirement.Name}_{source.Version}_{requirement.Architecture}.zip"))); - - if (!archive.Exists) + return await _cache.RunAsync(async cache => { - try + var archive = new FileInfo(Path.Join( + cache, + TargetPathSafety.EnsureSafeSegment( + $"{requirement.Name}_{source.Version}_{requirement.Architecture}.zip"))); + + if (!archive.Exists) { - await DotNetLayout.BuildArchiveAsync(source, archive.FullName, cancellationToken).ConfigureAwait(false); + try + { + Directory.CreateDirectory(cache); + await DotNetLayout.BuildArchiveAsync(source, archive.FullName, cancellationToken).ConfigureAwait(false); + } + catch (InvalidDataException ex) + { + taskContext.AddDebugMessage( + $"{UiSymbols.Note} The {requirement.Name} layout could not be assembled: {ex.Message}"); + + return null; + } + archive.Refresh(); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException) + else { - taskContext.AddDebugMessage( - $"{UiSymbols.Note} The {requirement.Name} layout could not be assembled: {ex.Message}"); - - return null; + using var readable = archive.Open(FileMode.Open, FileAccess.Read, FileShare.Read); } - archive.Refresh(); - } - - return new RuntimeFrameworkPayload( - archive, - requirement.Name, - source.Version.ToString(), - requirement.Architecture) - { - Dependencies = dependencies, - }; + return new RuntimeFrameworkPayload( + archive, + requirement.Name, + source.Version.ToString(), + requirement.Architecture) + { + Dependencies = dependencies, + }; + }).ConfigureAwait(false); } private static string PackId(RuntimeFrameworkRequirement requirement) => diff --git a/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/VcLibsPayloadAcquirer.cs b/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/VcLibsPayloadAcquirer.cs index 355515c1a..f7a882bff 100644 --- a/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/VcLibsPayloadAcquirer.cs +++ b/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/VcLibsPayloadAcquirer.cs @@ -48,8 +48,11 @@ internal interface IVcLibsPayloadAcquirer /// closed rather than putting an unknown package into a guest. /// /// -internal sealed class VcLibsPayloadAcquirer(IWinappDirectoryService winappDirectoryService) : IVcLibsPayloadAcquirer +internal sealed class VcLibsPayloadAcquirer( + IWinappDirectoryService winappDirectoryService, + IStorageDiagnostics? diagnostics = null) : IVcLibsPayloadAcquirer { + private readonly CacheStorage _cache = new(winappDirectoryService, Path.Combine("cache", CacheFolderName), CacheFolderName, diagnostics); /// /// Package identities this acquirer will fetch, and the official address each comes from. /// @@ -105,12 +108,10 @@ internal sealed class VcLibsPayloadAcquirer(IWinappDirectoryService winappDirect ArgumentNullException.ThrowIfNull(projectRoot); ArgumentNullException.ThrowIfNull(taskContext); - var hostCache = HostCacheDirectory(); - // Host caches first, including winapp's own: a payload fetched by a previous run is the same // official bytes, and re-downloading tens of megabytes on every run would make an offline // Sandbox session fail for no reason. - if (FindInCaches(requirement, projectRoot, hostCache) is { } cached) + if (_cache.Run(root => FindInCaches(requirement, projectRoot, new DirectoryInfo(root))) is { } cached) { return cached; } @@ -147,7 +148,8 @@ internal sealed class VcLibsPayloadAcquirer(IWinappDirectoryService winappDirect return null; } - return await PublishAsync(requirement, payload, hostCache, taskContext, cancellationToken).ConfigureAwait(false); + return await _cache.RunAsync(root => + PublishAsync(requirement, payload, new DirectoryInfo(root), taskContext, cancellationToken)).ConfigureAwait(false); } /// @@ -288,8 +290,6 @@ private static bool IsOfficialAndSatisfying(RuntimePayload payload, RuntimePacka private static string StagedName(RuntimePackageRequirement requirement) => TargetPathSafety.EnsureSafeSegment($"{requirement.Name}_{requirement.Architecture}.appx"); - private DirectoryInfo HostCacheDirectory() => - new(Path.Join(winappDirectoryService.GetGlobalWinappDirectory().FullName, "cache", CacheFolderName)); /// /// The Windows SDK's own copies of the VC framework packages. diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs index 965829f2a..fa9a75dda 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs @@ -45,6 +45,7 @@ public static IServiceCollection ConfigureServices(this IServiceCollection servi .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton(_ => new StorageDiagnostics(Console.Error)) .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/src/winapp-CLI/WinApp.Cli/Program.cs b/src/winapp-CLI/WinApp.Cli/Program.cs index 9cbffb8e1..fa97fa9a6 100644 --- a/src/winapp-CLI/WinApp.Cli/Program.cs +++ b/src/winapp-CLI/WinApp.Cli/Program.cs @@ -53,9 +53,11 @@ internal static async Task Main(string[] args) // Check if this is a completion request - completions must be fast and silent bool isCompleteMode = args.Length > 0 && args[0] == "complete"; + var storageDiagnostics = new StorageDiagnostics(Console.Error, json, quiet, deferWarnings: true); var services = new ServiceCollection() .ConfigureServices() .ConfigureCommands() + .AddSingleton(storageDiagnostics) .AddLogging(b => { b.ClearProviders(); @@ -126,9 +128,9 @@ internal static async Task Main(string[] args) } } - // Skip first-run notice for machine-readable output modes and completions + // Informational commands must not need storage or prepend bookkeeping to their result. var didShowFirstRunNotice = false; - if (!isCliSchemaMode && !isCompleteMode && !json) + if (!isCliSchemaMode && !isCompleteMode && !json && !quiet && !IsInformationalInvocation(parseResult)) { var firstRunService = serviceProvider.GetRequiredService(); didShowFirstRunNotice = firstRunService.CheckAndDisplayFirstRunNotice(); @@ -152,6 +154,7 @@ internal static async Task Main(string[] args) // Show help by invoking with --help await rootCommand.Parse(["--help"], WinAppParserConfiguration.Default).InvokeAsync(); + storageDiagnostics.Complete(succeeded: true); return 0; } @@ -239,7 +242,7 @@ internal static async Task Main(string[] args) } } - return await RunWithTelemetryAsync(parsedArgs, isCompleteMode, () => + var exitCode = await RunWithTelemetryAsync(parsedArgs, isCompleteMode, () => { // Target selection is settled before anything else, and settled for every command. // A command that cannot honour --on says so, and a selector that names nothing usable @@ -284,8 +287,17 @@ internal static async Task Main(string[] args) return parsedArgs.InvokeAsync(); }); + storageDiagnostics.Complete(succeeded: exitCode == 0); + return exitCode; } + private static bool IsInformationalInvocation(System.CommandLine.ParseResult? parseResult) => + parseResult is not null + && (parseResult.CommandResult.Command is GetWinappPathCommand + || parseResult.Errors.Count > 0 + || parseResult.Tokens.Any(token => token.Type == System.CommandLine.Parsing.TokenType.Option + && token.Value is "--help" or "-h" or "-?" or "/?" or "--version")); + /// /// Reports positional values that were really misspelt options, and returns the exit code. /// diff --git a/src/winapp-CLI/WinApp.Cli/Services/ApiSearch/ApiCacheBuilder.cs b/src/winapp-CLI/WinApp.Cli/Services/ApiSearch/ApiCacheBuilder.cs index 3f3b4c9d1..4250e4bdb 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/ApiSearch/ApiCacheBuilder.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/ApiSearch/ApiCacheBuilder.cs @@ -477,7 +477,7 @@ private static bool TryExportPackageCache( ExportPackageCache(package, cacheDir); return true; } - catch (Exception ex) when (IsPackageReadFailure(ex)) + catch (Exception ex) when (ex is not CacheWriteException && IsPackageReadFailure(ex)) { string reason = Unwrap(ex).Message; failures.Add((PackageKey(package.Id, package.Version), $"Skipped {package.Id} {package.Version}: {reason}")); @@ -600,7 +600,8 @@ private static void ReportFailures(ConcurrentBag<(string Key, string Message)> f private static void ExportPackageCache(PackageWithWinMd package, string cacheDir) { string typesDir = Path.Combine(cacheDir, "types"); - Directory.CreateDirectory(typesDir); + try { Directory.CreateDirectory(typesDir); } + catch (Exception ex) when (CacheStorage.IsStorageFailure(ex)) { throw new CacheWriteException(typesDir, ex); } var types = new List(); var parseErrors = new List(); @@ -702,8 +703,15 @@ private static void ExportPackageCache(PackageWithWinMd package, string cacheDir private static void WriteFileAtomic(string path, string content) { string dir = Path.GetDirectoryName(path)!; - Directory.CreateDirectory(dir); - PathSafety.AtomicWriteAllText(path, content); + try + { + Directory.CreateDirectory(dir); + PathSafety.AtomicWriteAllText(path, content); + } + catch (Exception ex) when (CacheStorage.IsStorageFailure(ex)) + { + throw new CacheWriteException(path, ex); + } } /// diff --git a/src/winapp-CLI/WinApp.Cli/Services/ApiSearch/ApiMetadataService.cs b/src/winapp-CLI/WinApp.Cli/Services/ApiSearch/ApiMetadataService.cs index 6a56662f5..4972b0cfd 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/ApiSearch/ApiMetadataService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/ApiSearch/ApiMetadataService.cs @@ -66,10 +66,32 @@ internal sealed class ApiMetadataService( IWinappDirectoryService directoryService, ICurrentDirectoryProvider currentDirectory, ISdkPackageSource sdkPackages, - ILogger logger) : IApiMetadataService + ILogger logger, + IStorageDiagnostics? diagnostics = null) : IApiMetadataService { - private string GetCacheDir() => - Path.Combine(directoryService.GetGlobalWinappDirectory().FullName, "cache", "find-api"); + private readonly CacheStorage _cache = new(directoryService, Path.Combine("cache", "find-api"), "find-api", diagnostics); + + private T WithCache(ApiRequestScope scope, Func operation) + { + string? firstRoot = null; + string? namedProject = null; + return _cache.Run(root => + { + if (firstRoot is null) + { + firstRoot = root; + if (scope.Project is not null && !IsSdkScopeName(scope.Project)) + { + namedProject = TryResolveNamedProjectDir(scope.Project, root); + } + } + // Keep the requested project's identity when rebuilding into an empty local cache. + var effectiveScope = firstRoot != root && namedProject is not null + ? new ApiRequestScope(namedProject, null) + : scope; + return operation(effectiveScope, root); + }); + } public ApiQueryResult Search(string query, int maxResults, ApiRequestScope scope) => WithManifest(scope, (cacheDir, manifest) => ApiQueryEngine.Search(query, maxResults, cacheDir, manifest)); @@ -107,11 +129,13 @@ public ApiQueryResult Packages(ApiRequestScope scope) => public ApiQueryResult Stats(ApiRequestScope scope) => WithManifest(scope, (cacheDir, manifest) => ApiQueryEngine.Stats(cacheDir, manifest)); - public ApiProjectsOutput Projects() => ApiQueryEngine.Projects(GetCacheDir()); + public ApiProjectsOutput Projects() => _cache.Run(ApiQueryEngine.Projects); + + public ApiQueryResult Refresh(ApiRequestScope scope, bool scan, Action? onProgress = null, bool force = false) => + WithCache(scope, (resolvedScope, cacheDir) => RefreshCore(resolvedScope, cacheDir, scan, onProgress, force)); - public ApiQueryResult Refresh(ApiRequestScope scope, bool scan, Action? onProgress = null, bool force = false) + private ApiQueryResult RefreshCore(ApiRequestScope scope, string cacheDir, bool scan, Action? onProgress = null, bool force = false) { - string cacheDir = GetCacheDir(); string? runtimePath = ApiCacheBuilder.DetectWinAppSdkRuntime(); // 'refresh --project sdk' rebuilds the machine-wide scope explicitly (the @@ -230,9 +254,12 @@ private static bool IsManifestForProject(string manifestPath, string projectName /// result with actionable guidance. /// private ApiQueryResult WithManifest(ApiRequestScope scope, Func> query) + where T : class => + WithCache(scope, (resolvedScope, cacheDir) => WithManifestCore(resolvedScope, cacheDir, query)); + + private ApiQueryResult WithManifestCore(ApiRequestScope scope, string cacheDir, Func> query) where T : class { - string cacheDir = GetCacheDir(); string? indexError = AutoIndexIfStale(scope, cacheDir); if (indexError is not null) { @@ -270,6 +297,14 @@ private ApiQueryResult WithManifest(ApiRequestScope scope, Func keys, Func Result)>> query) + where T : class => + WithCache(scope, (resolvedScope, cacheDir) => WithManifestBatchCore(resolvedScope, cacheDir, keys, query)); + + private List<(string Key, ApiQueryResult Result)> WithManifestBatchCore( + ApiRequestScope scope, + string cacheDir, + IReadOnlyList keys, + Func Result)>> query) where T : class { static List<(string, ApiQueryResult)> FailAll(IReadOnlyList keys, string message) @@ -282,7 +317,6 @@ private ApiQueryResult WithManifest(ApiRequestScope scope, Func logger.LogInformation("{Message}", msg)); + var result = RefreshCore(indexScope, cacheDir, scan: false, onProgress: msg => logger.LogInformation("{Message}", msg)); if (result.Outcome != ApiQueryOutcome.Ok) { logger.LogWarning("Failed to index API metadata: {Message}", result.Message); return result.Message ?? "Failed to index API metadata for this project."; } } - catch (Exception ex) when (ex is not OperationCanceledException) + catch (Exception ex) when (ex is not OperationCanceledException && !CacheStorage.IsStorageFailure(ex)) { // Cancellation is deliberately excluded so Ctrl+C is not reported // as an indexing failure. @@ -660,7 +694,7 @@ private static bool IsCachedIndexStale(string manifestPath, string projectDir, s { return new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); } - catch (IOException) + catch (IOException ex) when ((ex.HResult & 0xffff) is 32 or 33) { return null; } @@ -994,7 +1028,7 @@ private ResolvedScope ResolveSdkScope(string cacheDir) logger.LogInformation("No project here — indexing {Scope} metadata…", ApiCachePaths.SdkScopeName); ApiCacheBuilder.BuildSdkCache(cacheDir, packages, onProgress: msg => logger.LogInformation("{Message}", msg)); } - catch (Exception ex) when (ex is not OperationCanceledException) + catch (Exception ex) when (ex is not OperationCanceledException && !CacheStorage.IsStorageFailure(ex)) { // Indexing the machine-wide SDK is best-effort; cancellation still // propagates so Ctrl+C is not reported as an SDK failure. @@ -1027,7 +1061,7 @@ private static string AvailableProjects(IEnumerable files) => { return JsonSerializer.Deserialize(File.ReadAllText(path), ApiSearchJsonContext.Default.ProjectManifest); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException or JsonException) { // A missing or corrupt manifest reads as "no manifest" so the caller can // report an unindexed project instead of crashing. diff --git a/src/winapp-CLI/WinApp.Cli/Services/ApiSearch/ApiQueryEngine.cs b/src/winapp-CLI/WinApp.Cli/Services/ApiSearch/ApiQueryEngine.cs index be55f6bc1..ff88e9705 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/ApiSearch/ApiQueryEngine.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/ApiSearch/ApiQueryEngine.cs @@ -611,7 +611,7 @@ public static ApiQueryResult Packages(string cacheDir, Projec Status = incomplete ? "incomplete" : "ok", }); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException or JsonException or KeyNotFoundException or InvalidOperationException or FormatException) { // Unreadable or malformed meta.json is reported per package rather than @@ -650,7 +650,7 @@ public static ApiQueryResult Stats(string cacheDir, ProjectManif winmds += winMdFiles.GetArrayLength(); } } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException or JsonException or KeyNotFoundException or InvalidOperationException or FormatException) { // Skip a package whose meta.json cannot be read or parsed and keep @@ -1621,7 +1621,7 @@ private static List GetPackageCacheDirs(string cacheDir, ProjectManifest { return JsonSerializer.Deserialize(File.ReadAllText(path), typeInfo); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException or JsonException) { // A missing or corrupt cache file reads as "no data" so the caller can // fall back to reindexing instead of crashing. @@ -1635,7 +1635,7 @@ private static List GetPackageCacheDirs(string cacheDir, ProjectManifest { return JsonSerializer.Deserialize(File.ReadAllText(path), ApiSearchJsonContext.Default.ProjectManifest); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException or JsonException) { // A missing or corrupt manifest reads as "no manifest" so the caller can // report an unindexed project instead of crashing. diff --git a/src/winapp-CLI/WinApp.Cli/Services/CacheStorage.cs b/src/winapp-CLI/WinApp.Cli/Services/CacheStorage.cs new file mode 100644 index 000000000..5a4a77516 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/CacheStorage.cs @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Services; + +/// Retries cache storage operations in the invocation directory, never configuration or integrity failures. +internal sealed class CacheStorage( + IWinappDirectoryService directories, + string globalRelativePath, + string localRelativePath, + IStorageDiagnostics? diagnostics = null) +{ + private bool _local; + + // A read-only probe: readable warm caches do not need writable directories. + internal Action InspectDirectory { get; set; } = InspectAncestors; + + public string DirectoryPath => ResolvePath(); + public bool IsExplicit => directories.IsGlobalCacheOverridden; + + public void Clear(Action clear) + { + var global = Path.Combine(directories.GetGlobalWinappDirectory().FullName, globalRelativePath); + InspectDirectory(global); + clear(global); + if (!IsExplicit) + { + var root = directories.GetLocalCacheDirectory().FullName; + var local = Path.Combine(root, localRelativePath); + ValidateLocalPath(local, root); + ValidateLocalTree(local); + if (!local.Equals(global, StringComparison.OrdinalIgnoreCase)) + { + clear(local); + } + } + } + + public T Run(Func operation) + { + try + { + var path = ResolvePath(); + var result = operation(path); + ReportFallback(path); + return result; + } + catch (Exception ex) when (IsStorageFailure(ex)) + { + SwitchToLocal(ex); + try + { + var path = ResolvePath(); + var result = operation(path); + ReportFallback(path); + return result; + } + catch (Exception localError) when (IsStorageFailure(localError)) + { + throw Unavailable(localError); + } + } + } + + public async Task RunAsync(Func> operation) + { + try + { + var path = ResolvePath(); + var result = await operation(path).ConfigureAwait(false); + ReportFallback(path); + return result; + } + catch (Exception ex) when (IsStorageFailure(ex)) + { + SwitchToLocal(ex); + try + { + var path = ResolvePath(); + var result = await operation(path).ConfigureAwait(false); + ReportFallback(path); + return result; + } + catch (Exception localError) when (IsStorageFailure(localError)) + { + throw Unavailable(localError); + } + } + } + + public void WarnUnavailable(Exception error) => + diagnostics?.Warning("cache-unavailable", $"Cache storage is unavailable; continuing without caching. {error.Message}"); + + internal static bool IsStorageFailure(Exception error) => + error is UnauthorizedAccessException or IOException + || error is AggregateException aggregate && aggregate.InnerExceptions.Count > 0 + && aggregate.InnerExceptions.All(IsStorageFailure); + + private string ResolvePath() + { + string path; + if (_local) + { + var root = directories.GetLocalCacheDirectory().FullName; + path = Path.GetFullPath(Path.Combine(root, localRelativePath)); + ValidateLocalPath(path, root); + ValidateLocalTree(path); + } + else + { + path = Path.Combine(directories.GetGlobalWinappDirectory().FullName, globalRelativePath); + } + InspectDirectory(path); + return path; + } + + private void SwitchToLocal(Exception error) + { + if (directories.IsGlobalCacheOverridden) + { + throw new IOException( + $"The configured WINAPP_CLI_CACHE_DIRECTORY cannot be used. Correct the configured path or its permissions. {error.Message}", error); + } + if (_local) + { + throw Unavailable(error); + } + _local = true; + } + + private void ReportFallback(string path) + { + if (_local) + { + diagnostics?.Warning("cache-fallback", + $"The default winapp cache is inaccessible. Using cache '{path}'."); + } + } + + private static IOException Unavailable(Exception error) => + new($"Neither the default winapp cache nor the invocation directory's .winapp\\cache can be used. " + + $"Grant access to one of these locations or set WINAPP_CLI_CACHE_DIRECTORY to an accessible directory. {error.Message}", error); + + internal static void InspectAncestors(string path) => InspectAncestors(path, File.GetAttributes); + + internal static void InspectAncestors(string path, Func attributesForPath) + { + for (var dir = new DirectoryInfo(path); dir is not null; dir = dir.Parent) + { + try + { + var attributes = attributesForPath(dir.FullName); + if ((attributes & FileAttributes.Directory) == 0) + { + throw new IOException($"Cache directory '{dir.FullName}' is a file."); + } + return; + } + catch (FileNotFoundException) { } + catch (DirectoryNotFoundException) { } + } + } + + internal static void ValidateLocalPath(string path, string root) + { + root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); + path = Path.GetFullPath(path); + if (!path.Equals(root, StringComparison.OrdinalIgnoreCase) + && !path.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + throw new IOException("The local cache must stay inside the invocation directory."); + } + var relative = Path.GetRelativePath(root, path); + var current = root; + foreach (var segment in relative.Split(Path.DirectorySeparatorChar)) + { + current = Path.Combine(current, segment); + try + { + if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) + { + throw new IOException($"Local cache path '{current}' is a link or reparse point."); + } + } + catch (FileNotFoundException) { break; } + catch (DirectoryNotFoundException) { break; } + } + } + + internal static void ValidateLocalTree(string path) + { + if (!Directory.Exists(path)) + { + return; + } + foreach (var entry in Directory.EnumerateFileSystemEntries(path)) + { + var attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new IOException($"Local cache entry '{entry}' is a link or reparse point."); + } + if ((attributes & FileAttributes.Directory) != 0) + { + ValidateLocalTree(entry); + } + } + } +} + +internal sealed class CacheWriteException(string path, Exception inner) + : IOException($"Could not write cache '{path}': {inner.Message}", inner) +{ +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/Controls/ControlsCacheIo.cs b/src/winapp-CLI/WinApp.Cli/Services/Controls/ControlsCacheIo.cs index 204da3cc9..b837efbf1 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/Controls/ControlsCacheIo.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/Controls/ControlsCacheIo.cs @@ -18,7 +18,6 @@ internal static class ControlsCacheIo { try { - if (!File.Exists(path)) return null; var text = File.ReadAllText(path).Trim(); if (DateTime.TryParse( text, @@ -30,6 +29,8 @@ internal static class ControlsCacheIo } return null; } - catch { return null; } + catch (FileNotFoundException) { return null; } + catch (DirectoryNotFoundException) { return null; } + catch (Exception ex) when (!CacheStorage.IsStorageFailure(ex)) { return null; } } } diff --git a/src/winapp-CLI/WinApp.Cli/Services/Controls/ControlsSearchService.cs b/src/winapp-CLI/WinApp.Cli/Services/Controls/ControlsSearchService.cs index b96cf8349..94b3327d0 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/Controls/ControlsSearchService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/Controls/ControlsSearchService.cs @@ -97,10 +97,11 @@ internal sealed class ControlsSearchService : IControlsSearchService, IDisposabl /// Production constructor: providers are rooted at the managed /// global .winapp cache directory so environment/test path overrides /// (WINAPP_CLI_CACHE_DIRECTORY) and repo-wide path policy apply. - public ControlsSearchService(IWinappDirectoryService directoryService) - : this(ProviderRegistry.CreateProviders( - Path.Combine(directoryService.GetGlobalWinappDirectory().FullName, "cache", "find-ui"))) + public ControlsSearchService(IWinappDirectoryService directoryService, IStorageDiagnostics? diagnostics = null) + : this(ProviderRegistry.CreateProviders(string.Empty)) { + var storage = new CacheStorage(directoryService, Path.Combine("cache", "find-ui"), "find-ui", diagnostics); + foreach (var provider in _providers.OfType()) provider.Storage = storage; } /// Test seam: inject providers directly (e.g. fakes with a temp cache). diff --git a/src/winapp-CLI/WinApp.Cli/Services/Controls/SearchProvider.cs b/src/winapp-CLI/WinApp.Cli/Services/Controls/SearchProvider.cs index 832b1c3eb..e016db850 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/Controls/SearchProvider.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/Controls/SearchProvider.cs @@ -84,6 +84,7 @@ internal interface ISearchProvider internal abstract class CachedProviderBase : ISearchProvider { private readonly string _cacheRoot; + internal CacheStorage? Storage { get; set; } protected CachedProviderBase(string cacheRoot) { @@ -151,7 +152,7 @@ public async Task LoadAsync(bool forceRefresh = false, Action 0) { - await TryWriteCacheAsync(fetched, cancellationToken).ConfigureAwait(false); + await TryWriteCacheAsync(fetched, forceRefresh, cancellationToken).ConfigureAwait(false); return fetched with { Origin = CorpusOrigin.Network }; } @@ -201,16 +202,26 @@ private ProviderData PreferNewerOf(ProviderData cached, DateTime cacheWrittenAt) private (ProviderData Data, DateTime WrittenAt)? TryReadCache(bool ignoreTtl = false) { - var scenariosPath = Path.Combine(CacheDir, "scenarios.json"); - var tagsPath = Path.Combine(CacheDir, "tags.json"); - var keywordsPath = Path.Combine(CacheDir, "keywords.json"); - var timestampPath = Path.Combine(CacheDir, "last-updated.txt"); - var versionPath = Path.Combine(CacheDir, "schema-version.txt"); - - if (!File.Exists(scenariosPath) || !File.Exists(tagsPath) - || !File.Exists(timestampPath) || !File.Exists(versionPath)) + try + { + return Storage is null + ? ReadCache(CacheDir, ignoreTtl) + : Storage.Run(root => ReadCache(Path.Combine(root, Id), ignoreTtl)); + } + catch (Exception ex) when (CacheStorage.IsStorageFailure(ex) && Storage?.IsExplicit != true) + { + Storage?.WarnUnavailable(ex); return null; + } + } + private (ProviderData Data, DateTime WrittenAt)? ReadCache(string cacheDir, bool ignoreTtl) + { + var scenariosPath = Path.Combine(cacheDir, "scenarios.json"); + var tagsPath = Path.Combine(cacheDir, "tags.json"); + var keywordsPath = Path.Combine(cacheDir, "keywords.json"); + var timestampPath = Path.Combine(cacheDir, "last-updated.txt"); + var versionPath = Path.Combine(cacheDir, "schema-version.txt"); try { if (File.ReadAllText(versionPath).Trim() != CacheVersion.Current) return null; @@ -236,61 +247,75 @@ private ProviderData PreferNewerOf(ProviderData cached, DateTime cacheWrittenAt) keywords = JsonSerializer.Deserialize( File.ReadAllText(keywordsPath), ControlsJsonContext.Default.DictionaryStringStringArray); } - catch { keywords = null; } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException or JsonException) { keywords = null; } } return (new ProviderData(scenarios, NormalizeTagsOnRead(tags), keywords ?? new(), CorpusOrigin.Cache), lastUpdated.Value); } - catch { return null; } + catch (FileNotFoundException) { return null; } + catch (DirectoryNotFoundException) { return null; } + catch (JsonException) { return null; } } - private async Task TryWriteCacheAsync(ProviderData data, CancellationToken cancellationToken) + private async Task TryWriteCacheAsync(ProviderData data, bool required, CancellationToken cancellationToken) { try { - var scenariosPath = Path.Combine(CacheDir, "scenarios.json"); - var tagsPath = Path.Combine(CacheDir, "tags.json"); - var keywordsPath = Path.Combine(CacheDir, "keywords.json"); - var timestampPath = Path.Combine(CacheDir, "last-updated.txt"); - var versionPath = Path.Combine(CacheDir, "schema-version.txt"); - - Directory.CreateDirectory(CacheDir); - - // Invalidate the freshness marker BEFORE mutating any data file. On a - // refresh of an already-fresh cache the old timestamp would otherwise - // still be valid, so a crash after rewriting some (but not all) data - // files would pair mismatched generations under a "fresh" stamp for up - // to the TTL. Removing it first means any mid-write crash leaves no - // valid timestamp ⇒ next read misses ⇒ clean re-fetch. - if (File.Exists(timestampPath)) + if (Storage is null) await WriteCacheAsync(CacheDir, data, cancellationToken).ConfigureAwait(false); + else await Storage.RunAsync(async root => { - File.Delete(timestampPath); - } + await WriteCacheAsync(Path.Combine(root, Id), data, cancellationToken).ConfigureAwait(false); + return true; + }).ConfigureAwait(false); + } + catch (Exception ex) when (CacheStorage.IsStorageFailure(ex) && !required && Storage?.IsExplicit != true) + { + Storage?.WarnUnavailable(ex); + } + } - // Atomic per-file writes (temp + rename via the shared PathSafety - // helper). Order: data first, version next, timestamp LAST, so a - // partially-written set is detected as still-stale on the next read - // (no fresh timestamp ⇒ cache miss ⇒ re-fetch). - await PathSafety.AtomicWriteAllTextAsync(scenariosPath, - JsonSerializer.Serialize(data.Scenarios, ControlsJsonContext.Default.ScenarioArray), Utf8NoBom, cancellationToken).ConfigureAwait(false); - await PathSafety.AtomicWriteAllTextAsync(tagsPath, - JsonSerializer.Serialize(data.Tags, ControlsJsonContext.Default.DictionaryStringStringArray), Utf8NoBom, cancellationToken).ConfigureAwait(false); - if (data.Keywords.Count > 0) - { - await PathSafety.AtomicWriteAllTextAsync(keywordsPath, - JsonSerializer.Serialize(data.Keywords, ControlsJsonContext.Default.DictionaryStringStringArray), Utf8NoBom, cancellationToken).ConfigureAwait(false); - } - else if (File.Exists(keywordsPath)) - { - // A refresh with no keywords must not leave a stale keywords.json behind. - File.Delete(keywordsPath); - } - await PathSafety.AtomicWriteAllTextAsync(versionPath, CacheVersion.Current, Utf8NoBom, cancellationToken).ConfigureAwait(false); - await PathSafety.AtomicWriteAllTextAsync(timestampPath, DateTime.UtcNow.ToString("o"), Utf8NoBom, cancellationToken).ConfigureAwait(false); + private static async Task WriteCacheAsync(string cacheDir, ProviderData data, CancellationToken cancellationToken) + { + var scenariosPath = Path.Combine(cacheDir, "scenarios.json"); + var tagsPath = Path.Combine(cacheDir, "tags.json"); + var keywordsPath = Path.Combine(cacheDir, "keywords.json"); + var timestampPath = Path.Combine(cacheDir, "last-updated.txt"); + var versionPath = Path.Combine(cacheDir, "schema-version.txt"); + + Directory.CreateDirectory(cacheDir); + + // Invalidate the freshness marker BEFORE mutating any data file. On a + // refresh of an already-fresh cache the old timestamp would otherwise + // still be valid, so a crash after rewriting some (but not all) data + // files would pair mismatched generations under a "fresh" stamp for up + // to the TTL. Removing it first means any mid-write crash leaves no + // valid timestamp ⇒ next read misses ⇒ clean re-fetch. + if (File.Exists(timestampPath)) + { + File.Delete(timestampPath); } - catch (OperationCanceledException) { throw; } - catch { /* cache write is best-effort */ } + + // Atomic per-file writes (temp + rename via the shared PathSafety + // helper). Order: data first, version next, timestamp LAST, so a + // partially-written set is detected as still-stale on the next read + // (no fresh timestamp ⇒ cache miss ⇒ re-fetch). + await PathSafety.AtomicWriteAllTextAsync(scenariosPath, + JsonSerializer.Serialize(data.Scenarios, ControlsJsonContext.Default.ScenarioArray), Utf8NoBom, cancellationToken).ConfigureAwait(false); + await PathSafety.AtomicWriteAllTextAsync(tagsPath, + JsonSerializer.Serialize(data.Tags, ControlsJsonContext.Default.DictionaryStringStringArray), Utf8NoBom, cancellationToken).ConfigureAwait(false); + if (data.Keywords.Count > 0) + { + await PathSafety.AtomicWriteAllTextAsync(keywordsPath, + JsonSerializer.Serialize(data.Keywords, ControlsJsonContext.Default.DictionaryStringStringArray), Utf8NoBom, cancellationToken).ConfigureAwait(false); + } + else if (File.Exists(keywordsPath)) + { + // A refresh with no keywords must not leave a stale keywords.json behind. + File.Delete(keywordsPath); + } + await PathSafety.AtomicWriteAllTextAsync(versionPath, CacheVersion.Current, Utf8NoBom, cancellationToken).ConfigureAwait(false); + await PathSafety.AtomicWriteAllTextAsync(timestampPath, DateTime.UtcNow.ToString("o"), Utf8NoBom, cancellationToken).ConfigureAwait(false); } private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); @@ -299,7 +324,15 @@ public void ClearCache() { try { - if (Directory.Exists(CacheDir)) Directory.Delete(CacheDir, recursive: true); + if (Storage is null) + { + if (Directory.Exists(CacheDir)) Directory.Delete(CacheDir, recursive: true); + } + else Storage.Clear(root => + { + var path = Path.Combine(root, Id); + if (Directory.Exists(path)) Directory.Delete(path, recursive: true); + }); } catch (Exception ex) { diff --git a/src/winapp-CLI/WinApp.Cli/Services/DotNetService.cs b/src/winapp-CLI/WinApp.Cli/Services/DotNetService.cs index b7f6dcdd9..6f7e93623 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/DotNetService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/DotNetService.cs @@ -14,7 +14,7 @@ namespace WinApp.Cli.Services; /// /// Service for detecting and working with .NET projects, using the dotnet CLI /// -internal partial class DotNetService : IDotNetService +internal partial class DotNetService(NugetSourceProvider sourceProvider) : IDotNetService { /// /// Minimum Windows SDK version that supports WinAppSDK @@ -418,7 +418,7 @@ public Task RunDotnetInheritedAsync( /// child inherits winapp's console handles (no redirection, no read pumps) so dotnet sees a real TTY /// and its native terminal logger renders live; the callbacks are ignored in that mode. /// - private static async Task RunDotnetCoreAsync( + private async Task RunDotnetCoreAsync( DirectoryInfo workingDirectory, string arguments, Action? onOutputLine, @@ -438,8 +438,10 @@ private static async Task RunDotnetCoreAsync( UseShellExecute = false, CreateNoWindow = !inheritStdio }; + ConfigurePackageEnvironment(processStartInfo, arguments.Split(' ', StringSplitOptions.RemoveEmptyEntries)); using var process = new Process { StartInfo = processStartInfo }; + var storageFailure = 0; if (!inheritStdio) { @@ -447,6 +449,10 @@ private static async Task RunDotnetCoreAsync( { if (e.Data != null) { + if (IsStorageAccessFailure(e.Data)) + { + Interlocked.Exchange(ref storageFailure, 1); + } onOutputLine?.Invoke(e.Data); } }; @@ -455,6 +461,10 @@ private static async Task RunDotnetCoreAsync( { if (e.Data != null) { + if (IsStorageAccessFailure(e.Data)) + { + Interlocked.Exchange(ref storageFailure, 1); + } onErrorLine?.Invoke(e.Data); } }; @@ -497,11 +507,16 @@ private static async Task RunDotnetCoreAsync( throw; } + if (process.ExitCode != 0 && storageFailure != 0 + && NugetSourceProvider.GetChildPackageStorageGuidance(processStartInfo) is { } guidance) + { + onErrorLine?.Invoke(guidance); + } return process.ExitCode; } /// - public Task<(int ExitCode, string Output, string Error)> RunDotnetCommandAsync( + public async Task<(int ExitCode, string Output, string Error)> RunDotnetCommandAsync( DirectoryInfo workingDirectory, IReadOnlyList arguments, IReadOnlyDictionary? environmentOverrides = null, @@ -535,10 +550,55 @@ private static async Task RunDotnetCoreAsync( processStartInfo.Environment[key] = value; } } + // A caller's explicit child-only packages setting takes precedence just like NUGET_PACKAGES + // inherited from the invocation. Let dotnet validate it rather than replacing it with a fallback. + if (environmentOverrides?.Keys.Any(key => key.Equals("NUGET_PACKAGES", StringComparison.OrdinalIgnoreCase)) != true) + { + ConfigurePackageEnvironment(processStartInfo, arguments); + } + + var result = await RunDotnetProcessAsync(processStartInfo, cancellationToken, onOutputLine: onOutputLine, onErrorLine: onErrorLine); + if (result.ExitCode != 0 && (IsStorageAccessFailure(result.Output) || IsStorageAccessFailure(result.Error)) + && NugetSourceProvider.GetChildPackageStorageGuidance(processStartInfo) is { } guidance) + { + onErrorLine?.Invoke(guidance); + return (result.ExitCode, result.Output, result.Error + Environment.NewLine + guidance); + } + return result; + } + + internal void ConfigurePackageEnvironment(ProcessStartInfo startInfo, IReadOnlyList arguments) + { + if (arguments.Count == 0) + { + return; + } + + var verb = arguments[0].ToLowerInvariant(); + var projectOperation = verb is "restore" or "build" or "publish" or "run" or "add" or "msbuild" + || (verb is "package" or "list" && arguments.Any(arg => arg.Equals("package", StringComparison.OrdinalIgnoreCase) + || arg.Equals("list", StringComparison.OrdinalIgnoreCase))); + if (verb == "new") + { + projectOperation = arguments.Count > 1 + && arguments[1] is not ("list" or "search" or "details" or "install" or "uninstall" or "update") + && !arguments[1].StartsWith('-'); + } + + if (!projectOperation || arguments.Any(arg => arg is "--help" or "-h" or "-?")) + { + return; + } - return RunDotnetProcessAsync(processStartInfo, cancellationToken, onOutputLine: onOutputLine, onErrorLine: onErrorLine); + sourceProvider.ConfigureChildProcessPackages(startInfo); } + private static bool IsStorageAccessFailure(string message) => + message.Contains("UnauthorizedAccessException", StringComparison.OrdinalIgnoreCase) + || message.Contains("permission denied", StringComparison.OrdinalIgnoreCase) + || message.Contains("read-only file system", StringComparison.OrdinalIgnoreCase) + || message.Contains("access", StringComparison.OrdinalIgnoreCase) && message.Contains("denied", StringComparison.OrdinalIgnoreCase); + internal static async Task<(int ExitCode, string Output, string Error)> RunDotnetProcessAsync( ProcessStartInfo processStartInfo, CancellationToken cancellationToken, diff --git a/src/winapp-CLI/WinApp.Cli/Services/FirstRunService.cs b/src/winapp-CLI/WinApp.Cli/Services/FirstRunService.cs index 417f938d9..279cd8bb7 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/FirstRunService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/FirstRunService.cs @@ -9,20 +9,35 @@ namespace WinApp.Cli.Services; internal class FirstRunService : IFirstRunService { private const string FirstRunMarkerFileName = ".first-run-complete"; - private readonly FileInfo _firstRunMarkerFile; + private readonly IWinappDirectoryService _directoryService; private readonly ILogger _logger; + private readonly IStorageDiagnostics _diagnostics; - public FirstRunService(IWinappDirectoryService directoryService, ILogger logger) + public FirstRunService( + IWinappDirectoryService directoryService, + ILogger logger, + IStorageDiagnostics? diagnostics = null) { - var globalWinappDirectory = directoryService.GetGlobalWinappDirectory(); - _firstRunMarkerFile = new FileInfo(Path.Combine(globalWinappDirectory.FullName, FirstRunMarkerFileName)); + _directoryService = directoryService; _logger = logger; + _diagnostics = diagnostics ?? new StorageDiagnostics(Console.Error); } public bool CheckAndDisplayFirstRunNotice() { - _firstRunMarkerFile.Refresh(); - if (!_firstRunMarkerFile.Exists) + FileInfo marker; + try + { + marker = new FileInfo(Path.Combine(_directoryService.GetGlobalWinappDirectory().FullName, FirstRunMarkerFileName)); + marker.Refresh(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or InvalidOperationException) + { + _diagnostics.Warning("optional_storage_unavailable", $"First-run bookkeeping is unavailable: {ex.Message}"); + return false; + } + + if (!marker.Exists) { BannerHelper.DisplayBanner(); @@ -32,13 +47,14 @@ public bool CheckAndDisplayFirstRunNotice() try { - _firstRunMarkerFile.Directory?.Create(); - using var fs = _firstRunMarkerFile.Create(); - _firstRunMarkerFile.Attributes |= FileAttributes.Hidden; + marker.Directory?.Create(); + using var fs = marker.Create(); + marker.Attributes |= FileAttributes.Hidden; } - catch (Exception ex) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - _logger.LogWarning("Failed to create first run marker file: {ErrorMessage}", ex.Message); + _diagnostics.Warning("optional_storage_unavailable", + $"Cannot save the first-run marker at '{marker.FullName}'. Continuing without saving it: {ex.Message}"); } return true; diff --git a/src/winapp-CLI/WinApp.Cli/Services/ITemplateUpdateCheckThrottle.cs b/src/winapp-CLI/WinApp.Cli/Services/ITemplateUpdateCheckThrottle.cs index 784029fc5..3c2d36ad3 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/ITemplateUpdateCheckThrottle.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/ITemplateUpdateCheckThrottle.cs @@ -19,6 +19,9 @@ internal interface ITemplateUpdateCheckThrottle /// bool TryGetRecentLatest(string installedVersion, out string? latestVersion); + /// Whether an automatic check can persist its throttle without relocating bookkeeping. + bool CanCheckForUpdates(); + /// /// Records that a staleness check just ran for , remembering the /// newest available version (, or /empty when diff --git a/src/winapp-CLI/WinApp.Cli/Services/IWinappDirectoryService.cs b/src/winapp-CLI/WinApp.Cli/Services/IWinappDirectoryService.cs index 8faa5e981..1493a29da 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/IWinappDirectoryService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/IWinappDirectoryService.cs @@ -9,6 +9,10 @@ namespace WinApp.Cli.Services; internal interface IWinappDirectoryService { DirectoryInfo GetGlobalWinappDirectory(); + /// Whether cache configuration is authoritative and must not silently fall back. + bool IsGlobalCacheOverridden { get; } + /// Validates and resolves invocation-CWD .winapp\cache without creating it or searching parents. + DirectoryInfo GetLocalCacheDirectory(); DirectoryInfo GetLocalWinappDirectory(DirectoryInfo? baseDirectory = null); void SetCacheDirectoryForTesting(DirectoryInfo? cacheDirectory); } diff --git a/src/winapp-CLI/WinApp.Cli/Services/IncrementalCopyHelper.cs b/src/winapp-CLI/WinApp.Cli/Services/IncrementalCopyHelper.cs index 50b54c1bb..d20e25a38 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/IncrementalCopyHelper.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/IncrementalCopyHelper.cs @@ -20,6 +20,12 @@ internal static (int Copied, int Skipped) CopyFiles( { int copied = 0, skipped = 0; + foreach (var (sourceFile, relativePath) in files) + { + LayoutLease.ThrowIfArtifactPath(sourceFile.FullName); + LayoutLease.ThrowIfArtifactPath(Path.GetFullPath(Path.Combine(targetDir.FullName, relativePath))); + } + foreach (var (sourceFile, relativePath) in files) { var targetFile = new FileInfo(Path.Combine(targetDir.FullName, relativePath)); diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/CoordinationLockIo.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/CoordinationLockIo.cs index bf8da8bd5..b3b9bc336 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/CoordinationLockIo.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/CoordinationLockIo.cs @@ -30,8 +30,7 @@ internal static bool IsContention(IOException exception) /// The failure reported when a lock cannot be opened for a non-contention reason. internal static UiCoordinationException CannotOpen(string path, IOException exception) - => new( - UiCoordinationErrorCodes.Unavailable, + => UiCoordinationException.StorageUnavailable( $"The UI coordination lock '{path}' could not be opened: {exception.Message}", "Check that the coordination directory is on a healthy, reachable volume, then retry."); } diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IInteractiveDesktopLock.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IInteractiveDesktopLock.cs index 48b5037d6..fa8b54165 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IInteractiveDesktopLock.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IInteractiveDesktopLock.cs @@ -19,6 +19,7 @@ internal interface IDesktopSection { /// /// Acquires active.lock for the duration of the returned scope. + /// Observation turns cannot enter a desktop section. /// /// /// @@ -82,6 +83,8 @@ internal interface IInteractiveDesktopLock /// never open a lease, take a ticket, or join an indefinite queue (spec §10). The forward barrier /// wraps ; active.lock is not held across it — the body takes /// it only for its desktop-sensitive section via . + /// An observation may run detached when coordination storage is unavailable, without ordering or + /// workflow continuity. Invalid configuration and ambiguous state still fail closed. /// /// The command's coordination mode. /// Command name for diagnostics, e.g. ui click. Never arguments. diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs index 0d98f3f94..ee0fffdb2 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs @@ -251,8 +251,7 @@ private async Task AcquireActiveLockAsync(string activeLockPath, Can } catch (UnauthorizedAccessException ex) { - throw new UiCoordinationException( - UiCoordinationErrorCodes.Unavailable, + throw UiCoordinationException.StorageUnavailable( $"The UI desktop lock could not be opened: {ex.Message}", "Check that the current user can write to the coordination directory."); } @@ -313,7 +312,9 @@ private sealed class CoordinatedExecution( private readonly SemaphoreSlim _sectionGate = new(1, 1); private IParticipantLease? _lease; - private FileStream? _activeLock; private bool _detached; + private FileStream? _activeLock; + private bool _detached; + private UiCoordinationException? _storageDegradation; private bool _recoveredFromCorruption; private long? _ticket; private UiTurnAction _turnAction = UiTurnAction.New; @@ -350,13 +351,32 @@ public async Task RunAsync( try { - Register(cancellationToken); + try + { + cancellationToken.ThrowIfCancellationRequested(); + Register(cancellationToken); - if (!_detached) + if (!_detached) + { + await WaitUntilRunnableAsync(cancellationToken).ConfigureAwait(false); + } + } + catch (UiCoordinationException ex) when (Mode == UiTurnMode.Observe && ex.IsStorageUnavailable) { - await WaitUntilRunnableAsync(cancellationToken).ConfigureAwait(false); + // Only admission may degrade. The body below is never retried, even if it throws + // an identical storage exception after contacting the target app. + _lease?.Dispose(); + _lease = null; + _detached = true; + _turnAction = UiTurnAction.Detached; + _turnStartedTick64 = null; + _recoveredFromCorruption = false; + _waitWatch.Stop(); + WaitedMs = _waitWatch.ElapsedMilliseconds; + _storageDegradation = ex; } + cancellationToken.ThrowIfCancellationRequested(); try { var exitCode = await body(this, cancellationToken).ConfigureAwait(false); @@ -367,6 +387,15 @@ public async Task RunAsync( // Commands that must not renew let the cancellation propagate instead, which is why // handler catch-alls are filtered with UiCoordinatedAction.IsCoordinationFault. bodyCompletedNormally = true; + if (exitCode == 0 && _storageDegradation is { } degradation && !outputMode.Quiet) + { + StorageDiagnostics.WriteWarning( + parseResult.InvocationConfiguration.Error, + outputMode.Json, + UiCoordinationErrorCodes.Unavailable, + $"This observation ran without desktop ordering or workflow continuity because UI coordination storage is unavailable. {degradation.Message} Restore access to the shared coordination directory before running commands that change or capture the desktop."); + } + return exitCode; } finally @@ -741,6 +770,15 @@ private UiWaitDiagnostics BuildDiagnostics(InteractiveDesktopState state, OwnerC public async Task EnterAsync(CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); + if (Mode == UiTurnMode.Observe) + { + throw new UiCoordinationException( + UiCoordinationErrorCodes.Unavailable, + "An observation cannot enter a desktop input or capture section.", + "Use a coordinated command that requests a shared or exclusive desktop turn."); + } + // Serialize within this command first, then take the cross-process lock. Both are required: // the gate stops two concurrent tasks in this command from racing, and active.lock stops // other winapp processes from acting on the desktop at the same time. @@ -824,24 +862,29 @@ private void EmitCancellation(bool cancelledWhileQueued) var waitedMs = _waitWatch.ElapsedMilliseconds; int? queuePosition = null; - try + if (_lease is not null && _ticket is not null) { - using var stateLock = coordinator._store.AcquireStateLock(CancellationToken.None); - var read = coordinator._store.Read(); - if (read.State is { } state && _ticket is { } ticket - && InteractiveDesktopScheduler.FindWaiter(state, participant) is not null) + try { - queuePosition = InteractiveDesktopScheduler.QueuePositionOf(state, _probe, ticket); + using var stateLock = coordinator._store.AcquireStateLock(CancellationToken.None); + var read = coordinator._store.Read(); + if (read.State is { } state && _ticket is { } ticket + && InteractiveDesktopScheduler.FindWaiter(state, participant) is not null) + { + queuePosition = InteractiveDesktopScheduler.QueuePositionOf(state, _probe, ticket); + } + } + catch (Exception ex) when (ex is UiCoordinationException or IOException) + { + coordinator._logger.LogDebug("Queue position could not be read while cancelling: {Message}", ex.Message); } - } - catch (Exception ex) when (ex is UiCoordinationException or IOException) - { - coordinator._logger.LogDebug("Queue position could not be read while cancelling: {Message}", ex.Message); } var message = cancelledWhileQueued ? "UI turn wait was cancelled." - : "The command was cancelled after it acquired the desktop; any UI changes it had already made remain."; + : _detached + ? "The observation was cancelled." + : "The command was cancelled after it acquired the desktop; any UI changes it had already made remain."; UiJsonError.Emit( outputMode.Json, diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs index 02e151b9b..f5a5b950d 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs @@ -73,28 +73,24 @@ internal sealed class InteractiveDesktopPaths : IInteractiveDesktopPaths private const string LeaseExtension = ".lease"; private readonly string _sessionToken; + private readonly Lazy _lockDirectory = new(ResolveLockDirectory); private bool _directoriesVerified; public InteractiveDesktopPaths(IProcessInspector processInspector) { _sessionToken = processInspector.CurrentSessionId.ToString(CultureInfo.InvariantCulture); - LockDirectory = ResolveLockDirectory(); - ParticipantsDirectory = Path.Combine(LockDirectory, "participants"); - StateLockPath = Path.Combine(LockDirectory, $"{FilePrefix}{_sessionToken}.state.lock"); - StatePath = Path.Combine(LockDirectory, $"{FilePrefix}{_sessionToken}.state.json"); - ActiveLockPath = Path.Combine(LockDirectory, $"{FilePrefix}{_sessionToken}.active.lock"); LeaseSearchPattern = $"{FilePrefix}{_sessionToken}-*{LeaseExtension}"; } - public string LockDirectory { get; } + public string LockDirectory => _lockDirectory.Value; - public string ParticipantsDirectory { get; } + public string ParticipantsDirectory => Path.Combine(LockDirectory, "participants"); - public string StateLockPath { get; } + public string StateLockPath => Path.Combine(LockDirectory, $"{FilePrefix}{_sessionToken}.state.lock"); - public string StatePath { get; } + public string StatePath => Path.Combine(LockDirectory, $"{FilePrefix}{_sessionToken}.state.json"); - public string ActiveLockPath { get; } + public string ActiveLockPath => Path.Combine(LockDirectory, $"{FilePrefix}{_sessionToken}.active.lock"); public string LeaseSearchPattern { get; } @@ -219,21 +215,19 @@ private static UiCoordinationException UntrustedArtifact(string path, string rea private static string ResolveLockDirectory() { var overridePath = Environment.GetEnvironmentVariable(LockDirectoryOverrideVariable); - if (!string.IsNullOrWhiteSpace(overridePath)) + if (overridePath is not null) { return ValidateLockDirectory(overridePath.Trim(), LockDirectoryOverrideVariable); } try { - return ValidateLockDirectory( - Path.Combine(WinappDirectoryService.GetUserStateDirectory(), "ui"), - "%USERPROFILE%\\.winapp\\state"); + return WinappDirectoryService.ValidateStateDirectory( + Path.Combine(WinappDirectoryService.GetUserStateDirectory(), "ui")); } catch (IOException ex) { - throw new UiCoordinationException( - UiCoordinationErrorCodes.Unavailable, + throw UiCoordinationException.StorageUnavailable( $"The UI coordination directory could not be resolved: {ex.Message}", "Ensure %USERPROFILE%\\.winapp\\state is on a writable local drive, or set WINAPP_UI_LOCK_DIRECTORY to the same fully qualified local directory for every winapp process on this desktop."); } @@ -245,11 +239,11 @@ private static string ValidateLockDirectory(string path, string source) { return WinappDirectoryService.ValidateStateDirectory(path); } - catch (IOException ex) + catch (Exception ex) when (ex is IOException or ArgumentException or NotSupportedException) { throw new UiCoordinationException( - UiCoordinationErrorCodes.Unavailable, - $"The UI coordination directory resolved from {source} is unavailable: {ex.Message}", + UiCoordinationErrorCodes.InvalidLockDirectory, + $"The UI coordination directory configured by {source} is invalid: {ex.Message}", "Set WINAPP_UI_LOCK_DIRECTORY to the same fully qualified local directory for every winapp process on this desktop."); } } @@ -360,12 +354,11 @@ private static void RepairAccessRulesIfNeeded(DirectoryInfo directoryInfo) // under the user profile is the first run and never again. DiscardUntrustedArtifacts(directoryInfo); } - catch (Exception ex) when (ex is UnauthorizedAccessException or PrivilegeNotHeldException or InvalidOperationException) + catch (Exception ex) when (ex is UnauthorizedAccessException or PrivilegeNotHeldException or IOException) { // The directory is reachable but cannot be secured — for example it belongs to another user. // Coordinating through storage a third party can tamper with is worse than not running. - throw new UiCoordinationException( - UiCoordinationErrorCodes.Unavailable, + throw UiCoordinationException.StorageUnavailable( $"The UI coordination directory '{directoryInfo.FullName}' could not be restricted to the current user: {ex.Message}", "Point WINAPP_UI_LOCK_DIRECTORY at a directory this user owns, or remove the override to use %USERPROFILE%\\.winapp\\state\\ui."); } @@ -547,8 +540,7 @@ private static DirectorySecurity BuildCurrentUserOnlySecurity() } private static UiCoordinationException Unavailable(string path, Exception ex) - => new( - UiCoordinationErrorCodes.Unavailable, + => UiCoordinationException.StorageUnavailable( $"The UI coordination directory '{path}' could not be created: {ex.Message}", "Check that the current user can write to the directory, or set WINAPP_UI_LOCK_DIRECTORY to a writable local directory."); } diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopStateStore.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopStateStore.cs index d1baa7736..8278a3135 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopStateStore.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopStateStore.cs @@ -97,8 +97,7 @@ public IDisposable AcquireStateLock(CancellationToken cancellationToken) } catch (UnauthorizedAccessException ex) { - throw new UiCoordinationException( - UiCoordinationErrorCodes.Unavailable, + throw UiCoordinationException.StorageUnavailable( $"The UI coordination state lock '{paths.StateLockPath}' could not be opened: {ex.Message}", "Check that the current user can write to the coordination directory."); } @@ -118,15 +117,19 @@ public IDisposable AcquireStateLock(CancellationToken cancellationToken) public StateReadResult Read() { string? raw; - var fileExists = File.Exists(paths.StatePath); + var fileExists = true; try { - raw = fileExists ? File.ReadAllText(paths.StatePath) : null; + raw = File.ReadAllText(paths.StatePath); } - catch (IOException ex) + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) { - throw new UiCoordinationException( - UiCoordinationErrorCodes.Unavailable, + fileExists = false; + raw = null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw UiCoordinationException.StorageUnavailable( $"The UI coordination state could not be read: {ex.Message}", "Retry the command. If it keeps failing, close other winapp ui processes and retry."); } @@ -428,8 +431,7 @@ public void Publish(InteractiveDesktopState state) } TryDeleteTemp(tempPath); - throw new UiCoordinationException( - UiCoordinationErrorCodes.Unavailable, + throw UiCoordinationException.StorageUnavailable( $"UI coordination state could not be published: {lastFailure?.Message ?? "unknown error"}", "Retry the command. If it keeps failing, check that the coordination directory is on a local writable drive and not being scanned by another tool."); } diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ParticipantRegistry.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ParticipantRegistry.cs index 90a61bff1..fa1edbcfa 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ParticipantRegistry.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ParticipantRegistry.cs @@ -74,15 +74,13 @@ public IParticipantLease OpenLease(int processId, long startTicksUtc) } catch (IOException ex) { - throw new UiCoordinationException( - UiCoordinationErrorCodes.Unavailable, + throw UiCoordinationException.StorageUnavailable( $"The UI coordination participant lease '{path}' could not be opened: {ex.Message}", "Retry the command. If it keeps failing, check that the coordination directory is on a local writable drive."); } catch (UnauthorizedAccessException ex) { - throw new UiCoordinationException( - UiCoordinationErrorCodes.Unavailable, + throw UiCoordinationException.StorageUnavailable( $"The UI coordination participant lease '{path}' could not be opened: {ex.Message}", "Check that the current user can write to the coordination directory."); } @@ -120,27 +118,25 @@ public bool IsParticipantLive(int processId, long startTicksUtc) public bool AnyLiveParticipant() { - if (!Directory.Exists(paths.ParticipantsDirectory)) - { - return false; - } - - IEnumerable leaseFiles; try { - leaseFiles = Directory.EnumerateFiles(paths.ParticipantsDirectory, paths.LeaseSearchPattern); + foreach (var leaseFile in Directory.EnumerateFiles(paths.ParticipantsDirectory, paths.LeaseSearchPattern)) + { + if (IsLeaseFileHeld(leaseFile)) + { + return true; + } + } } catch (DirectoryNotFoundException) { return false; } - - foreach (var leaseFile in leaseFiles) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - if (IsLeaseFileHeld(leaseFile)) - { - return true; - } + throw UiCoordinationException.StorageUnavailable( + $"The UI coordination participants could not be inspected: {ex.Message}", + "Check that the current user can read the coordination directory."); } return false; diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs index 4e8f52f71..df965bd39 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs @@ -13,6 +13,9 @@ internal static class UiCoordinationErrorCodes /// WINAPP_UI_WORKFLOW_ID was set but empty/whitespace or longer than 256 UTF-16 units. public const string InvalidWorkflowId = "invalid_ui_workflow_id"; + /// An explicit coordination directory is not a fully qualified local path. + public const string InvalidLockDirectory = "invalid_ui_lock_directory"; + /// /// Coordination state could not be read, published, or safely recovered — for example an unknown /// newer schema version, or corrupt state while a live participant may exist. Turn-participating @@ -45,6 +48,15 @@ internal sealed class UiCoordinationException(string code, string message, strin /// Optional actionable next step surfaced alongside the error. public string? RecoveryHint { get; } = recoveryHint; + + /// + /// A filesystem availability failure, not invalid configuration, ambiguous state, or a + /// scheduling error. Only observations may detach, and only before their body starts. + /// + public bool IsStorageUnavailable { get; private init; } + + internal static UiCoordinationException StorageUnavailable(string message, string? recoveryHint = null) + => new(UiCoordinationErrorCodes.Unavailable, message, recoveryHint) { IsStorageUnavailable = true }; } /// diff --git a/src/winapp-CLI/WinApp.Cli/Services/LayoutLease.cs b/src/winapp-CLI/WinApp.Cli/Services/LayoutLease.cs index 4c371b5b4..c4e7c7392 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/LayoutLease.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/LayoutLease.cs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft Corporation and Contributors. All rights reserved. // Licensed under the MIT License. +using System.Diagnostics; using System.Security.Cryptography; using System.Text; +using WinApp.Cli.Helpers; namespace WinApp.Cli.Services; @@ -24,12 +26,13 @@ namespace WinApp.Cli.Services; /// mutation lease already draws. /// /// -/// The lock file lives in winapp's state directory rather than in the layout: anything inside the -/// layout is app payload, and would be packaged and registered along with it. +/// The lock lives beside the layout, in a reserved directory excluded from app payload. Its location +/// depends only on the layout, never the caller's working directory or cache configuration. /// /// internal sealed class LayoutLease : IDisposable { + internal const string LockDirectoryName = ".winapp-layout-locks"; private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(60); private readonly FileStream _stream; @@ -41,21 +44,23 @@ internal sealed class LayoutLease : IDisposable /// /// Another winapp process held the layout for too long. internal static LayoutLease Acquire( - DirectoryInfo winappStateRoot, DirectoryInfo layoutDirectory, CancellationToken cancellationToken, - TimeSpan? timeout = null) + TimeSpan? timeout = null, + Func? openLock = null) { - var stateDirectory = Path.Combine(winappStateRoot.FullName, "layout-locks"); - Directory.CreateDirectory(stateDirectory); + cancellationToken.ThrowIfCancellationRequested(); + var lockPath = LongPathHelper.EnsureExtendedLengthPrefix(GetLockPath(layoutDirectory)); + var lockDirectory = Path.GetDirectoryName(lockPath)!; + Directory.CreateDirectory(lockDirectory); - // Hashed so the name is a fixed length no matter how deep the layout is, and - // case-insensitively, so two spellings of one Windows path do not become two locks. - var canonical = Path.TrimEndingDirectorySeparator(Path.GetFullPath(layoutDirectory.FullName)); - var key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical.ToUpperInvariant()))); - var lockPath = Path.Combine(stateDirectory, key + ".lock"); - - var deadline = DateTime.UtcNow + (timeout ?? DefaultTimeout); + // A redirected lock directory would no longer be bookkeeping beside this resource. + RejectLinkedLockPath(lockDirectory); + RejectLinkedLockPath(lockPath); + var elapsed = Stopwatch.StartNew(); + var waitLimit = timeout ?? DefaultTimeout; + openLock ??= path => new FileStream( + path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, bufferSize: 1, FileOptions.DeleteOnClose); while (true) { @@ -63,28 +68,103 @@ internal static LayoutLease Acquire( try { - // DeleteOnClose keeps the state directory from growing a file per layout ever built. - return new LayoutLease(new FileStream( - lockPath, - FileMode.OpenOrCreate, - FileAccess.ReadWrite, - FileShare.None, - bufferSize: 1, - FileOptions.DeleteOnClose)); + // The kernel releases the handle (and removes the file) even if a process is killed. + // Never delete the directory on release: another layout or waiter may be using it. + return new LayoutLease(openLock(lockPath)); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + catch (IOException ex) when (IsContention(ex)) { - if (DateTime.UtcNow >= deadline) + if (elapsed.Elapsed >= waitLimit) { throw new TimeoutException( - $"Another winapp process is using the app layout at '{canonical}'. Wait for it to finish, " + - $"or use --output-appx-directory to give this run a layout of its own."); + $"Another winapp process is using the app layout at '{layoutDirectory.FullName}'. Wait for it to finish, " + + "or use --output-appx-directory to give this run a layout of its own.", ex); } - Thread.Sleep(100); + var remaining = waitLimit - elapsed.Elapsed; + cancellationToken.WaitHandle.WaitOne( + TimeSpan.FromMilliseconds(Math.Clamp(remaining.TotalMilliseconds, 0, 100))); } } } + internal static string GetLockPath(DirectoryInfo layoutDirectory) + { + var canonical = Path.TrimEndingDirectorySeparator( + Path.GetFullPath(LongPathHelper.StripExtendedPrefix(layoutDirectory.FullName))); + ThrowIfArtifactPath(canonical); + var parent = Path.GetDirectoryName(canonical); + if (string.IsNullOrEmpty(parent)) + { + throw new InvalidOperationException("A drive or share root cannot be used as an app layout. Choose a subdirectory."); + } + + // Fixed-length names and extended-length I/O keep bookkeeping usable even when a layout + // near MAX_PATH leaves no room for an appended suffix. + var key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical.ToUpperInvariant()))); + return Path.Combine(parent, LockDirectoryName, key + ".lock"); + } + + internal static bool IsContention(IOException exception) => + exception.HResult is unchecked((int)0x80070020) or unchecked((int)0x80070021); + + internal static bool IsArtifactPath(string path) => + path.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries) + .Any(segment => string.Equals(segment, LockDirectoryName, StringComparison.OrdinalIgnoreCase)); + + internal static void ThrowIfArtifactPath(string path) + { + if (IsArtifactPath(path)) + { + throw new InvalidOperationException( + $"'{path}' uses '{LockDirectoryName}', which is reserved for layout coordination and cannot be app payload. " + + "Choose a different layout or payload path."); + } + } + + /// + /// Existing lock state in a destination may belong to another layout. Never prune it, copy over + /// it, or register it as payload; refuse the ambiguous layout without deleting anything. + /// + internal static void EnsureNoArtifactsInLayout(DirectoryInfo layoutDirectory) + { + ThrowIfArtifactPath(layoutDirectory.FullName); + layoutDirectory.Refresh(); + if (!layoutDirectory.Exists) + { + return; + } + + var pending = new Stack(); + pending.Push(layoutDirectory); + while (pending.TryPop(out var directory)) + { + foreach (var entry in directory.EnumerateFileSystemInfos()) + { + ThrowIfArtifactPath(entry.FullName); + if (entry is DirectoryInfo child && !child.Attributes.HasFlag(FileAttributes.ReparsePoint)) + { + pending.Push(child); + } + } + } + } + + private static void RejectLinkedLockPath(string path) + { + try + { + if (File.GetAttributes(path).HasFlag(FileAttributes.ReparsePoint)) + { + throw new InvalidOperationException( + $"Layout lock path '{path}' is a symbolic link or junction. Use a real layout lock directory."); + } + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + // The lock file is normally absent until it is acquired. + } + } + public void Dispose() => _stream.Dispose(); } diff --git a/src/winapp-CLI/WinApp.Cli/Services/MSStoreCLIService.cs b/src/winapp-CLI/WinApp.Cli/Services/MSStoreCLIService.cs index 58aa7a3ca..c9d33fb7b 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/MSStoreCLIService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/MSStoreCLIService.cs @@ -9,8 +9,12 @@ namespace WinApp.Cli.Services; -internal class MSStoreCLIService(IWinappDirectoryService winappDirectoryService, ILogger logger) : IMSStoreCLIService +internal class MSStoreCLIService( + IWinappDirectoryService winappDirectoryService, + ILogger logger, + IStorageDiagnostics? diagnostics = null) : IMSStoreCLIService { + private readonly CacheStorage _cache = new(winappDirectoryService, Path.Combine("tools", "msstore"), Path.Combine("tools", "msstore"), diagnostics); private static readonly HttpClient SharedHttp = new(); // Test seam: HttpClient for the GitHub release API and asset downloads. Defaults to @@ -28,7 +32,7 @@ internal class MSStoreCLIService(IWinappDirectoryService winappDirectoryService, public async Task EnsureMSStoreCLIAvailableAsync(CancellationToken cancellationToken = default) { - if (!IsMSStoreCLIAvailable()) + if (!_cache.Run(IsMSStoreCLIAvailable)) { logger.LogInformation("MSStoreCLI not found. Downloading and installing MSStore Developer CLI..."); @@ -54,38 +58,29 @@ private async Task DownloadAndInstallAsync(CancellationToken cancellationToken) logger.LogInformation("Downloading MSStoreCLI {Version} from {Url}", version, downloadUrl); - var installDir = GetInstallDirectory(); - Directory.CreateDirectory(installDir); - - var zipPath = Path.Combine(installDir, "MSStoreCLI.zip"); - - try + var bytes = await Http.GetByteArrayAsync(downloadUrl, cancellationToken); + VerifyHash(Convert.ToHexString(SHA256.HashData(bytes)), expectedHash); + await _cache.RunAsync(async installDir => { - using (var response = await Http.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken)) + Directory.CreateDirectory(installDir); + var zipPath = Path.Combine(installDir, "MSStoreCLI.zip"); + try { - response.EnsureSuccessStatusCode(); - await using var fs = File.Create(zipPath); - await response.Content.CopyToAsync(fs, cancellationToken); - } - - VerifyFileHash(zipPath, expectedHash); + await File.WriteAllBytesAsync(zipPath, bytes, cancellationToken); + VerifyFileHash(zipPath, expectedHash); - logger.LogDebug("Extracting MSStoreCLI to {InstallDir}", installDir); - await ZipFile.ExtractToDirectoryAsync(zipPath, installDir, overwriteFiles: true, cancellationToken: cancellationToken); + logger.LogDebug("Extracting MSStoreCLI to {InstallDir}", installDir); + await ZipFile.ExtractToDirectoryAsync(zipPath, installDir, overwriteFiles: true, cancellationToken: cancellationToken); - logger.LogDebug("MSStoreCLI {Version} installed to {InstallDir}", version, installDir); - } - finally - { - try - { - File.Delete(zipPath); + logger.LogDebug("MSStoreCLI {Version} installed to {InstallDir}", version, installDir); } - catch + finally { - // Best effort cleanup + try { File.Delete(zipPath); } + catch { /* Best effort cleanup. */ } } - } + return true; + }); } /// @@ -179,14 +174,18 @@ private void VerifyFileHash(string filePath, string expectedHash) { var actualHash = ComputeSha256Hash(filePath); + VerifyHash(actualHash, expectedHash); + logger.LogDebug("SHA-256 hash verified for {FilePath}", filePath); + } + + private static void VerifyHash(string actualHash, string expectedHash) + { if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException( $"SHA-256 hash mismatch for downloaded MSStoreCLI. Expected: {expectedHash}, Actual: {actualHash}. " + "The downloaded file may be corrupted or tampered with."); } - - logger.LogDebug("SHA-256 hash verified for {FilePath}", filePath); } private static string ComputeSha256Hash(string filePath) @@ -198,20 +197,16 @@ private static string ComputeSha256Hash(string filePath) public string GetMSStoreCLIPath() { - return Path.Combine(GetInstallDirectory(), ExeName); - } - - private string GetInstallDirectory() - { - return Path.Combine(winappDirectoryService.GetGlobalWinappDirectory().FullName, "tools", "msstore"); + return _cache.Run(root => Path.Combine(root, ExeName)); } - private bool IsMSStoreCLIAvailable() + private bool IsMSStoreCLIAvailable(string installDir) { - var exePath = GetMSStoreCLIPath(); + var exePath = Path.Combine(installDir, ExeName); var exists = File.Exists(exePath); if (exists) { + using var readable = File.Open(exePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); logger.LogDebug("MSStoreCLI found at {ExePath}", exePath); } return exists; diff --git a/src/winapp-CLI/WinApp.Cli/Services/MsixService.Identity.cs b/src/winapp-CLI/WinApp.Cli/Services/MsixService.Identity.cs index 3b3ea4972..4518bf47a 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/MsixService.Identity.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/MsixService.Identity.cs @@ -516,6 +516,7 @@ private static void EnsureLayoutPathHasNoReparsePoint(DirectoryInfo layout) /// private static List ReadAndValidateRecipe(FileInfo recipeFile, DirectoryInfo outputDir, string recipeContent) { + LayoutLease.ThrowIfArtifactPath(recipeFile.FullName); System.Xml.Linq.XDocument recipeDoc; try { @@ -568,6 +569,7 @@ private static List ReadAndValidateRecipe(FileInfo recipeFile, Dire // The destination must land inside the layout even after the path is resolved, so a // traversal segment cannot make a copy (or a later prune) reach outside it. var destinationPath = Path.GetFullPath(Path.Combine(outputDir.FullName, packagePath)); + LayoutLease.ThrowIfArtifactPath(destinationPath); if (!IsPathInsideDirectory(destinationPath, outputDir.FullName)) { throw new InvalidOperationException( @@ -582,6 +584,8 @@ private static List ReadAndValidateRecipe(FileInfo recipeFile, Dire "file. Rebuild the project and try again."); } + LayoutLease.ThrowIfArtifactPath(Path.GetFullPath(sourcePath)); + if (byPackagePath.TryGetValue(packagePath, out var previousSource)) { // Two sources for one destination is ambiguous, and on Windows two spellings that @@ -668,6 +672,8 @@ private static async Task CopyFilesFromRecipeCoreAsync( LayoutReconciliation reconciliation, CancellationToken cancellationToken) { + LayoutLease.EnsureNoArtifactsInLayout(outputDir); + // A linked ancestor makes destructive reconciliation unsafe, but it does not make additive // publication unsafe: the caller intentionally named that resolved path, and no existing // content is removed. Fall back rather than rejecting common junction-backed source trees. @@ -927,6 +933,7 @@ private static (int Removed, List Unremovable) PruneLayout( HashSet desired, TaskContext taskContext) { + LayoutLease.EnsureNoArtifactsInLayout(outputDir); var removed = 0; var unremovable = new List(); var emptiedDirectories = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -1041,6 +1048,7 @@ private static IEnumerable EnumerateLayoutFiles(DirectoryInfo root, Li foreach (var subdirectory in directory.EnumerateDirectories()) { + LayoutLease.ThrowIfArtifactPath(subdirectory.FullName); if (subdirectory.Attributes.HasFlag(FileAttributes.ReparsePoint)) { linkedDirectories.Add(subdirectory); @@ -1052,6 +1060,7 @@ private static IEnumerable EnumerateLayoutFiles(DirectoryInfo root, Li foreach (var file in directory.EnumerateFiles()) { + LayoutLease.ThrowIfArtifactPath(file.FullName); yield return file; } } @@ -1120,6 +1129,9 @@ private static void RemoveEmptiedDirectories(DirectoryInfo root, IEnumerable private static void SyncFilesToOutputDirectory(DirectoryInfo inputDirectory, DirectoryInfo outputAppXDirectory, FileInfo appxManifestPath, TaskContext taskContext, LayoutReconciliation reconciliation) { + LayoutLease.EnsureNoArtifactsInLayout(outputAppXDirectory); + LayoutLease.ThrowIfArtifactPath(appxManifestPath.FullName); + // A `None` layout is a staging directory winapp just created, commonly under the system temp // directory, which on some machines is reached through a junction. Nothing there is pruned, // so the link checks that make deletion safe would only reject a legitimate path. @@ -1347,6 +1359,7 @@ private static void RemoveCompetingLayoutManifests(DirectoryInfo outputAppXDirec private static List EnumerateInputFilesForLayout( DirectoryInfo inputDirectory, DirectoryInfo outputAppXDirectory) { + LayoutLease.ThrowIfArtifactPath(inputDirectory.FullName); var entries = new List(); var pending = new Stack(); pending.Push(inputDirectory); @@ -1357,7 +1370,8 @@ private static List EnumerateInputFilesForLayout( foreach (var subdirectory in directory.EnumerateDirectories()) { - if (IsPathInsideDirectory(subdirectory.FullName, outputAppXDirectory.FullName)) + if (LayoutLease.IsArtifactPath(subdirectory.FullName) || + IsPathInsideDirectory(subdirectory.FullName, outputAppXDirectory.FullName)) { continue; } @@ -1372,6 +1386,11 @@ private static List EnumerateInputFilesForLayout( foreach (var file in directory.EnumerateFiles()) { + if (LayoutLease.IsArtifactPath(file.FullName)) + { + continue; + } + // A linked file is refused for the same reason a linked directory is: its content // comes from outside the folder being packaged, so the layout would not be built // from the app it claims to describe. diff --git a/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs b/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs index b3e3457dc..76bbf477b 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs @@ -659,6 +659,7 @@ private async Task SignMsixPackageAsync(DirectoryInfo outputFolder, string certi private async Task CreateMsixPackageFromFolderAsync(DirectoryInfo inputFolder, FileInfo outputMsixPath, TaskContext taskContext, CancellationToken cancellationToken) { + LayoutLease.EnsureNoArtifactsInLayout(inputFolder); // Create MSIX package var inputPath = LongPathHelper.EnsureExtendedLengthPrefix(Path.TrimEndingDirectorySeparator(inputFolder.FullName)); var outputPath = LongPathHelper.EnsureExtendedLengthPrefix(outputMsixPath.FullName); @@ -721,20 +722,28 @@ private static HashSet BuildStagingExclusions(DirectoryInfo inputFolder, /// /// Recursively copies all files and subdirectories from source to destination, - /// skipping any top-level directories whose names appear in . + /// skipping reserved layout lock state at every depth and any top-level directories whose names + /// appear in . /// private static void CopyDirectoryRecursive(DirectoryInfo source, DirectoryInfo destination, HashSet? excludedDirectories = null) { + LayoutLease.ThrowIfArtifactPath(source.FullName); destination.Create(); foreach (var file in source.EnumerateFiles()) { + if (LayoutLease.IsArtifactPath(file.FullName)) + { + continue; + } + file.CopyTo(Path.Combine(destination.FullName, file.Name), overwrite: true); } foreach (var subDir in source.EnumerateDirectories()) { - if (excludedDirectories != null && excludedDirectories.Contains(subDir.Name)) + if (LayoutLease.IsArtifactPath(subDir.FullName) || + (excludedDirectories != null && excludedDirectories.Contains(subDir.Name))) { continue; } @@ -769,6 +778,7 @@ private static void CopyManifestReferencedFiles( cancellationToken.ThrowIfCancellationRequested(); var stagingPath = Path.GetFullPath(Path.Combine(stagingDir.FullName, relativePath)); + LayoutLease.ThrowIfArtifactPath(stagingPath); var stagingRoot = Path.GetFullPath(stagingDir.FullName).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; // Verify the destination stays within the staging directory @@ -805,6 +815,7 @@ private static void CopyManifestReferencedFiles( // Security: verify the resolved source path stays within the allowed roots. // This prevents symlinks/junctions from escaping the project directory. var resolvedSourcePath = Path.GetFullPath(sourceFile.FullName); + LayoutLease.ThrowIfArtifactPath(resolvedSourcePath); var manifestRoot = Path.GetFullPath(manifestDir.FullName).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; var inputRoot = Path.GetFullPath(inputFolder.FullName).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; diff --git a/src/winapp-CLI/WinApp.Cli/Services/NugetPackageDownloader.cs b/src/winapp-CLI/WinApp.Cli/Services/NugetPackageDownloader.cs index eadec263c..2504035df 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/NugetPackageDownloader.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/NugetPackageDownloader.cs @@ -43,6 +43,8 @@ internal async Task DownloadPackageAsync(PackageIdentity identity, string global var package = identity.Id; var version = identity.Version.ToNormalizedString(); var clientPolicyContext = ClientPolicyContext.GetClientPolicy(_sourceProvider.Settings, Logger); + _sourceProvider.ValidatePackagePath(globalPackagesFolder, + new VersionFolderPathResolver(globalPackagesFolder).GetInstallPath(identity.Id, identity.Version)); var repos = _sourceProvider.GetRepositoriesForPackage(package); Exception? lastError = null; @@ -52,11 +54,10 @@ internal async Task DownloadPackageAsync(PackageIdentity identity, string global { cancellationToken.ThrowIfCancellationRequested(); - // Buffer to a temp file rather than memory: SDK packages (e.g. Windows App SDK) are large. - // Use a random temp path instead of Path.GetTempFileName(): the latter eagerly creates an - // empty file (which File.Create below immediately overwrites) and throws once ~65,535 temp - // files already exist in the directory. - var tempFile = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); + // Stage alongside the selected packages, not in an unrelated (possibly denied) TEMP folder. + // A random name and CreateNew prevent replacing another invocation's in-flight download. + Directory.CreateDirectory(globalPackagesFolder); + var tempFile = Path.Join(globalPackagesFolder, $".winapp-download-{Guid.NewGuid():N}"); try { bool copied; @@ -66,7 +67,7 @@ internal async Task DownloadPackageAsync(PackageIdentity identity, string global // false instead of throwing; with NullLogger that detail would be lost and the failure // misreported as "not found". var downloadLogger = new CollectingLogger(); - await using (var fileStream = File.Create(tempFile)) + await using (var fileStream = new FileStream(tempFile, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { try { diff --git a/src/winapp-CLI/WinApp.Cli/Services/NugetService.cs b/src/winapp-CLI/WinApp.Cli/Services/NugetService.cs index e9fed919c..8df97f21e 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/NugetService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/NugetService.cs @@ -4,7 +4,6 @@ using System.Collections.Concurrent; using System.Xml; using NuGet.Common; -using NuGet.Configuration; using NuGet.Packaging; using NuGet.Packaging.Core; using NuGet.Protocol; @@ -29,16 +28,13 @@ internal partial class NugetService : INugetService private static readonly ILogger Logger = NullLogger.Instance; private static readonly ConcurrentDictionary> DependencyCache = new(StringComparer.OrdinalIgnoreCase); - private readonly IWinappDirectoryService _winappDirectoryService; private readonly NugetSourceProvider _sourceProvider; private readonly NugetPackageDownloader _downloader; public NugetService( - IWinappDirectoryService winappDirectoryService, NugetSourceProvider sourceProvider, NugetPackageDownloader downloader) { - _winappDirectoryService = winappDirectoryService; _sourceProvider = sourceProvider; _downloader = downloader; } @@ -62,31 +58,7 @@ public NugetService( $"{BuildToolsService.CPP_SDK_PACKAGE}.arm64" ]; - public DirectoryInfo GetNuGetGlobalPackagesDir() - { - // In test mode (cache override set), use a "packages" subdir of the override directory - var globalDir = _winappDirectoryService.GetGlobalWinappDirectory(); - if (IsTestOverride(globalDir)) - { - var overrideDir = new DirectoryInfo(Path.Join(globalDir.FullName, "packages")); - if (!overrideDir.Exists) - { - overrideDir.Create(); - } - return overrideDir; - } - - // Resolve the global packages folder from the user's NuGet configuration. This honors the - // NUGET_PACKAGES environment variable and the `globalPackagesFolder` setting in nuget.config, - // falling back to %USERPROFILE%/.nuget/packages. - var globalPackagesFolder = SettingsUtility.GetGlobalPackagesFolder(_sourceProvider.Settings); - var nugetDir = new DirectoryInfo(globalPackagesFolder); - if (!nugetDir.Exists) - { - nugetDir.Create(); - } - return nugetDir; - } + public DirectoryInfo GetNuGetGlobalPackagesDir() => _sourceProvider.GetPackagesDirectory(); public DirectoryInfo GetNuGetPackageDir(string packageName, string version) { @@ -106,7 +78,9 @@ public DirectoryInfo GetNuGetPackageDir(string packageName, string version) // Resolve the on-disk folder the same way the global-packages writer does, so the path matches // regardless of how the version string is expressed (NuGet stores e.g. "1.0" under "1.0.0"). var resolver = new VersionFolderPathResolver(cache.FullName); - return new DirectoryInfo(resolver.GetInstallPath(packageName, parsed)); + var packagePath = resolver.GetInstallPath(packageName, parsed); + _sourceProvider.ValidatePackagePath(cache.FullName, packagePath); + return new DirectoryInfo(packagePath); } /// @@ -153,16 +127,6 @@ private static NuGetVersion ParseVersion(string packageId, string version) return parsed; } - /// - /// Detects whether the global winapp directory is a test override (not the real user profile .winapp). - /// - private static bool IsTestOverride(DirectoryInfo globalDir) - { - var defaultWinapp = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".winapp"); - return !string.Equals(globalDir.FullName, defaultWinapp, StringComparison.OrdinalIgnoreCase) - && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WINAPP_CLI_CACHE_DIRECTORY")); - } - /// /// Mutable state for a single walk: what has been selected, what each /// selected version requires, and what failed. Bundled rather than passed as separate parameters so the @@ -319,9 +283,18 @@ public void PruneUnreachablePackages() public async Task> InstallPackageAsync(string package, string version, TaskContext taskContext, CancellationToken cancellationToken = default) { NugetSourceProvider.EnsureCredentialService(); - var graph = new InstallGraph(package); using var cacheContext = new SourceCacheContext(); - await InstallPackageRecursiveAsync(package, version, graph, taskContext, cacheContext, cancellationToken); + InstallGraph graph; + string packagesFolder; + do + { + packagesFolder = GetNuGetGlobalPackagesDir().FullName; + graph = new InstallGraph(package); + await InstallPackageRecursiveAsync(package, version, graph, taskContext, cacheContext, cancellationToken); + // A warm read-only cache can satisfy the entire graph without writes. If a missing dependency + // selects local storage, walk the graph again there so no returned package points at the old root. + } + while (!string.Equals(packagesFolder, GetNuGetGlobalPackagesDir().FullName, StringComparison.OrdinalIgnoreCase)); // A downloaded root package with unresolvable/uninstallable REQUIRED transitive dependencies is an // incomplete install, not a success. Each gap was surfaced as a warning above (and the rest of the @@ -378,7 +351,17 @@ private async Task InstallPackageRecursiveAsync(string package, string version, // global packages folder (using the standard NuGet on-disk layout). Throws with the // underlying source error if no configured source can provide the package. var identity = new PackageIdentity(package, ParseVersion(package, normalizedVersion)); - await _downloader.DownloadPackageAsync(identity, GetNuGetGlobalPackagesDir().FullName, cacheContext, cancellationToken); + var packagesFolder = _sourceProvider.GetPackagesDirectory(requireWrite: true).FullName; + try + { + await _downloader.DownloadPackageAsync(identity, packagesFolder, cacheContext, cancellationToken); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) + { + packagesFolder = _sourceProvider.UseLocalPackagesDirectoryAfterFailure(ex, packagesFolder).FullName; + await _downloader.DownloadPackageAsync(identity, packagesFolder, cacheContext, cancellationToken); + } + packageDir = GetNuGetPackageDir(package, normalizedVersion); graph.Installed[package] = normalizedVersion; taskContext.AddStatusMessage($"{UiSymbols.Check} Installed {package} {normalizedVersion}"); diff --git a/src/winapp-CLI/WinApp.Cli/Services/NugetSourceProvider.cs b/src/winapp-CLI/WinApp.Cli/Services/NugetSourceProvider.cs index 8e48344c6..ac3d7a018 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/NugetSourceProvider.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/NugetSourceProvider.cs @@ -6,6 +6,7 @@ using NuGet.Credentials; using NuGet.Protocol; using NuGet.Protocol.Core.Types; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; namespace WinApp.Cli.Services; @@ -46,12 +47,35 @@ internal sealed class NugetSourceProvider }); private readonly ICurrentDirectoryProvider _currentDirectoryProvider; + private readonly IWinappDirectoryService? _directoryService; + private readonly IStorageDiagnostics? _storageDiagnostics; + private readonly object _packagesLock; + private readonly Dictionary _fallbackPackagesFolders; + private readonly HashSet _writablePackagesFolders; + private readonly Dictionary _childProviders = new(StringComparer.OrdinalIgnoreCase); + + internal Func LoadSettings { get; set; } = + root => NuGet.Configuration.Settings.LoadDefaultSettings(root); + internal Func GetEnvironmentVariable { get; set; } = Environment.GetEnvironmentVariable; + internal Func ResolveGlobalPackagesFolder { get; set; } = SettingsUtility.GetGlobalPackagesFolder; + internal Action ReadPackagesDirectory { get; set; } = path => + { + using var entries = Directory.EnumerateFileSystemEntries(path).GetEnumerator(); + _ = entries.MoveNext(); + }; + internal Action WritePackagesDirectory { get; set; } = path => + { + Directory.CreateDirectory(path); + using var probe = new FileStream( + Path.Combine(path, $".winapp-write-{Guid.NewGuid():N}"), + FileMode.CreateNew, FileAccess.Write, FileShare.None, 1, FileOptions.DeleteOnClose); + }; // The directory the nuget.config hierarchy is resolved from. Null means "use the process working // directory"; SetConfigRoot overrides it for commands that select an explicit project/config dir. private DirectoryInfo? _configRoot; - // All three caches are Lazy (default ExecutionAndPublication mode: thread-safe, initialized exactly + // Configuration caches are Lazy (default ExecutionAndPublication mode: thread-safe, initialized exactly // once) rather than plain '??=' fields. NugetSourceProvider is a DI singleton and WorkspaceSetupService // resolves versions for many packages concurrently (Task.WhenAll over GetLatestVersionAsync), so a // bare '??=' could race two threads into building duplicate providers/mappings or observing a @@ -60,31 +84,83 @@ internal sealed class NugetSourceProvider private Lazy _sourceRepositoryProvider; private Lazy _packageSourceMapping; private Lazy _configScopeKey; + private Lazy<(string Path, bool Explicit)> _packagesLocation; + + public NugetSourceProvider( + ICurrentDirectoryProvider currentDirectoryProvider, + IWinappDirectoryService? directoryService = null, + IStorageDiagnostics? storageDiagnostics = null) + : this(currentDirectoryProvider, directoryService, storageDiagnostics, null) + { + } - public NugetSourceProvider(ICurrentDirectoryProvider currentDirectoryProvider) + private NugetSourceProvider( + ICurrentDirectoryProvider currentDirectoryProvider, + IWinappDirectoryService? directoryService, + IStorageDiagnostics? storageDiagnostics, + NugetSourceProvider? storageOwner) { _currentDirectoryProvider = currentDirectoryProvider; + _directoryService = directoryService; + _storageDiagnostics = storageDiagnostics; + _packagesLock = storageOwner?._packagesLock ?? new(); + _fallbackPackagesFolders = storageOwner?._fallbackPackagesFolders ?? new(StringComparer.OrdinalIgnoreCase); + _writablePackagesFolders = storageOwner?._writablePackagesFolders ?? new(StringComparer.OrdinalIgnoreCase); InitializeCaches(); } - [MemberNotNull(nameof(_settings), nameof(_sourceRepositoryProvider), nameof(_packageSourceMapping), nameof(_configScopeKey))] + [MemberNotNull(nameof(_settings), nameof(_sourceRepositoryProvider), nameof(_packageSourceMapping), nameof(_configScopeKey), nameof(_packagesLocation))] private void InitializeCaches() { _settings = new Lazy(() => - NuGet.Configuration.Settings.LoadDefaultSettings( - root: _configRoot?.FullName ?? _currentDirectoryProvider.GetCurrentDirectory())); + { + var root = _configRoot?.FullName ?? _currentDirectoryProvider.GetCurrentDirectory(); + try + { + return LoadSettings(root); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NuGetConfigurationException) + { + throw new InvalidOperationException( + $"Could not load the required NuGet configuration for '{root}': {NugetErrorMessage.Redact(ex.Message)} " + + "Restore access to the nuget.config hierarchy; configured feeds and credentials cannot be replaced."); + } + }); + _packagesLocation = new Lazy<(string, bool)>(() => + { + if ((_directoryService as WinappDirectoryService)?.CacheDirectoryOverrideForTesting is { } testCache) + { + return (Path.Combine(testCache.FullName, "packages"), true); + } + + var environmentFolder = GetEnvironmentVariable("NUGET_PACKAGES"); + if (environmentFolder is not null + && (string.IsNullOrWhiteSpace(environmentFolder) || !Path.IsPathFullyQualified(environmentFolder))) + { + throw new InvalidOperationException("NUGET_PACKAGES must specify a fully qualified packages directory. Correct the explicit setting."); + } + + var configuredFolder = Settings.GetSection("config")?.Items.OfType() + .FirstOrDefault(item => string.Equals(item.Key, "globalPackagesFolder", StringComparison.OrdinalIgnoreCase)); + if (environmentFolder is null && configuredFolder is not null && string.IsNullOrWhiteSpace(configuredFolder.Value)) + { + throw new InvalidOperationException("globalPackagesFolder in nuget.config must specify a packages directory. Correct the explicit setting."); + } + + var explicitlyConfigured = environmentFolder is not null || configuredFolder is not null; + return (Path.GetFullPath(ResolveGlobalPackagesFolder(Settings)), explicitlyConfigured); + }); _sourceRepositoryProvider = new Lazy(() => new SourceRepositoryProvider(new PackageSourceProvider(Settings), Repository.Provider.GetCoreV3())); _packageSourceMapping = new Lazy(() => NuGet.Configuration.PackageSourceMapping.GetPackageSourceMapping(Settings)); - // A stable fingerprint of the effective source set + global packages folder + the FULL + // A stable fingerprint of the effective source set + the FULL // packageSourceMapping rules, so callers that keep a process-wide cache keyed only by package/version // (e.g. the dependency cache in NugetService) can additionally scope it to THIS configuration and // never serve results resolved against a different config root, private feed, global folder or // package-to-source mapping after SetConfigRoot switches it. _configScopeKey = new Lazy(() => { - var globalFolder = SettingsUtility.GetGlobalPackagesFolder(Settings); // Preserve source ORDER: dependency resolution returns the graph from the FIRST eligible source // that has the package (see NugetService.FetchDirectDependenciesAsync), so two configs with the // same feeds listed in a different order can resolve DIFFERENT dependency graphs and must not @@ -111,7 +187,7 @@ private void InitializeCaches() new PackageSourceMappingProvider(Settings).GetPackageSourceMappingItems() .OrderBy(m => m.Key, StringComparer.OrdinalIgnoreCase) .Select(m => $"{m.Key}=>{string.Join(",", m.Patterns.Select(p => p.Pattern).OrderBy(p => p, StringComparer.OrdinalIgnoreCase))}")); - return $"gpf={globalFolder}\nsources={sources}\nmapping={mapping}"; + return $"sources={sources}\nmapping={mapping}"; }); } @@ -126,6 +202,7 @@ private void InitializeCaches() internal void SetConfigRoot(DirectoryInfo configRoot) { _configRoot = configRoot; + _childProviders.Clear(); InitializeCaches(); } @@ -141,9 +218,147 @@ internal void SetConfigRoot(DirectoryInfo configRoot) /// process-wide, static cache keyed only by package identity use this to additionally scope entries to the /// current config root/feed set, so a cache populated under one nuget.config is never reused under /// another after switches it. Source order is part of the fingerprint because - /// dependency resolution is first-source-wins. Recomputed whenever the caches are re-created. + /// dependency resolution is first-source-wins. The selected packages folder is added at lookup time: + /// switching to local storage must not reuse a graph cached for the default folder. /// - internal string ConfigScopeKey => _configScopeKey.Value; + internal string ConfigScopeKey => $"gpf={GetPackagesDirectory().FullName}\n{_configScopeKey.Value}"; + + internal DirectoryInfo GetPackagesDirectory(bool requireWrite = false) + { + lock (_packagesLock) + { + var (configuredPath, explicitlyConfigured) = _packagesLocation.Value; + var path = !explicitlyConfigured && _fallbackPackagesFolders.TryGetValue(configuredPath, out var selected) + ? selected : configuredPath; + try + { + if (!string.Equals(path, configuredPath, StringComparison.OrdinalIgnoreCase)) + { + path = GetLocalPackagesDirectory().FullName; + } + EnsurePackagesDirectory(path, requireWrite); + return new DirectoryInfo(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return UseLocalPackagesDirectoryAfterFailure(ex, path); + } + } + } + + internal DirectoryInfo UseLocalPackagesDirectoryAfterFailure(Exception error, string? failedPackagesFolder = null) + { + lock (_packagesLock) + { + var (configuredPath, explicitlyConfigured) = _packagesLocation.Value; + if (!explicitlyConfigured && _fallbackPackagesFolders.TryGetValue(configuredPath, out var selected) + && failedPackagesFolder is not null && !string.Equals(selected, failedPackagesFolder, StringComparison.OrdinalIgnoreCase)) + { + return GetPackagesDirectory(requireWrite: true); + } + if (explicitlyConfigured || _fallbackPackagesFolders.ContainsKey(configuredPath)) + { + var path = explicitlyConfigured ? configuredPath : _fallbackPackagesFolders[configuredPath]; + throw new InvalidOperationException( + $"The {(explicitlyConfigured ? "explicitly configured" : "local fallback")} NuGet packages folder '{path}' is unavailable: " + + $"{NugetErrorMessage.Redact(error.Message)} Fix its permissions or the NUGET_PACKAGES/globalPackagesFolder setting."); + } + + DirectoryInfo fallback; + try + { + fallback = GetLocalPackagesDirectory(); + EnsurePackagesDirectory(fallback.FullName, requireWrite: true); + } + catch (Exception fallbackError) when (fallbackError is IOException or UnauthorizedAccessException) + { + throw new InvalidOperationException( + $"The default NuGet packages folder '{configuredPath}' is unavailable, and the invocation directory's .winapp\\cache\\nuget\\packages could not be used: " + + $"{NugetErrorMessage.Redact(fallbackError.Message)} Restore access or configure a writable NUGET_PACKAGES folder."); + } + + _fallbackPackagesFolders[configuredPath] = fallback.FullName; + _storageDiagnostics?.Warning("nuget-packages-fallback", + $"The default NuGet packages folder '{configuredPath}' is unavailable. Using '{fallback.FullName}' for this invocation."); + return fallback; + } + } + + private void EnsurePackagesDirectory(string path, bool requireWrite) + { + try + { + ReadPackagesDirectory(path); + } + catch (DirectoryNotFoundException) + { + requireWrite = true; + } + + if (requireWrite && !_writablePackagesFolders.Contains(path)) + { + WritePackagesDirectory(path); + _writablePackagesFolders.Add(path); + } + } + + private DirectoryInfo GetLocalPackagesDirectory() + { + var directories = _directoryService ?? new WinappDirectoryService(_currentDirectoryProvider); + var root = directories.GetLocalCacheDirectory(); + var path = Path.Combine(root.FullName, "nuget", "packages"); + CacheStorage.ValidateLocalPath(path, root.FullName); + CacheStorage.ValidateLocalTree(path); + return new DirectoryInfo(path); + } + + internal void ValidatePackagePath(string packagesFolder, string packagePath) + { + lock (_packagesLock) + { + if (_fallbackPackagesFolders.Values.Contains(packagesFolder, StringComparer.OrdinalIgnoreCase)) + { + CacheStorage.ValidateLocalPath(packagePath, GetLocalPackagesDirectory().FullName); + CacheStorage.ValidateLocalTree(packagePath); + } + } + } + + internal void ConfigureChildProcessPackages(ProcessStartInfo startInfo) + { + NugetSourceProvider child; + lock (_packagesLock) + { + var root = Path.GetFullPath(startInfo.WorkingDirectory); + if (string.Equals(root, _configRoot?.FullName ?? _currentDirectoryProvider.GetCurrentDirectory(), StringComparison.OrdinalIgnoreCase)) + { + child = this; + } + else if (!_childProviders.TryGetValue(root, out child!)) + { + child = new NugetSourceProvider(_currentDirectoryProvider, _directoryService, _storageDiagnostics, this) + { + LoadSettings = LoadSettings, + GetEnvironmentVariable = GetEnvironmentVariable, + ResolveGlobalPackagesFolder = ResolveGlobalPackagesFolder, + ReadPackagesDirectory = ReadPackagesDirectory, + WritePackagesDirectory = WritePackagesDirectory, + }; + child.SetConfigRoot(new DirectoryInfo(root)); + _childProviders.Add(root, child); + } + } + + // A fully restored project can use a readable read-only cache. Only dotnet knows whether this + // invocation needs additional packages; do not switch roots merely because it may perform restore. + startInfo.Environment["NUGET_PACKAGES"] = child.GetPackagesDirectory().FullName; + } + + internal static string? GetChildPackageStorageGuidance(ProcessStartInfo startInfo) => + startInfo.Environment.TryGetValue("NUGET_PACKAGES", out var packagesFolder) + ? $"If NuGet needs to write to the packages folder '{packagesFolder}', set NUGET_PACKAGES to a permitted writable directory and retry. " + + "The dotnet command was not retried automatically." + : null; /// /// Configures NuGet's default credential service so authenticated (private) feeds work using diff --git a/src/winapp-CLI/WinApp.Cli/Services/PackageInstallationService.cs b/src/winapp-CLI/WinApp.Cli/Services/PackageInstallationService.cs index 09f8563d6..8dcc2c0bc 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/PackageInstallationService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/PackageInstallationService.cs @@ -84,7 +84,13 @@ public async Task> InstallPackagesAsync( bool ignoreConfig = false, CancellationToken cancellationToken = default) { - var allInstalledVersions = new Dictionary(StringComparer.OrdinalIgnoreCase); + var requestedPackages = packages.ToArray(); + if (requestedPackages.Length == 0) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + var requestedVersions = new List<(string Package, string Version)>(requestedPackages.Length); // Load pinned config if available WinappConfig? pinnedConfig = null; @@ -93,8 +99,10 @@ public async Task> InstallPackagesAsync( pinnedConfig = configService.Load(); } - foreach (var packageName in packages) + foreach (var packageName in requestedPackages) { + cancellationToken.ThrowIfCancellationRequested(); + // Resolve version: check pinned config first, then get latest string version; if (pinnedConfig != null && !ignoreConfig) @@ -119,36 +127,55 @@ public async Task> InstallPackagesAsync( // concatenating this value, so a shorthand pin would otherwise point them at a folder the // NuGet writer never created. version = NugetService.NormalizeVersion(version); + requestedVersions.Add((packageName, version)); + } - // Install the package (and its transitive graph). InstallPackageAsync already short-circuits a - // fully-cached package via the completion marker and, on that marker hit, reads its dependency - // LIST from the package's extracted local .nuspec rather than from the configured feeds. Each - // dependency's declared range is still resolved to a concrete version against the feeds, falling - // back to a completed cache entry when they cannot answer, so an already-extracted graph restores - // offline. Resolving cache hits here through the feed-based GetPackageDependenciesAsync instead - // would break documented cache reuse under a private nuget.config (a cached id/version absent or - // unmapped on the current feed would fail the restore) and could install a graph that diverges - // from what is actually on disk. - taskContext.AddStatusMessage($"{UiSymbols.Bullet} {packageName} {version}"); - - var installedVersions = await nugetService.InstallPackageAsync(packageName, version, taskContext, cancellationToken); - foreach (var (pkg, ver) in installedVersions) + for (var attempt = 0; ; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + var packagesRoot = nugetService.GetNuGetGlobalPackagesDir().FullName; + var allInstalledVersions = new Dictionary(StringComparer.OrdinalIgnoreCase); + var rootChanged = false; + + foreach (var (packageName, version) in requestedVersions) { - if (allInstalledVersions.TryGetValue(pkg, out var existingVersion)) + cancellationToken.ThrowIfCancellationRequested(); + + // Cached roots must still go through installation so their entire dependency graph is + // verified from the extracted nuspec, including required packages that are missing. + taskContext.AddStatusMessage($"{UiSymbols.Bullet} {packageName} {version}"); + var installedVersions = await nugetService.InstallPackageAsync(packageName, version, taskContext, cancellationToken); + var selectedRoot = nugetService.GetNuGetGlobalPackagesDir().FullName; + if (!string.Equals(packagesRoot, selectedRoot, StringComparison.OrdinalIgnoreCase)) { - if (NugetService.CompareVersions(ver, existingVersion) > 0) + if (attempt != 0) { - allInstalledVersions[pkg] = ver; + throw new InvalidOperationException( + $"The NuGet packages folder changed again while retrying package installation ('{packagesRoot}' to '{selectedRoot}'). " + + "Restore stable access to the packages folder and retry."); } + + // Earlier roots may exist only in the old read-only cache. Replay every requested root + // at its already-resolved version, discarding the old cache's partial aggregate graph. + rootChanged = true; + break; } - else + + foreach (var (pkg, ver) in installedVersions) { - allInstalledVersions[pkg] = ver; + if (!allInstalledVersions.TryGetValue(pkg, out var existingVersion) + || NugetService.CompareVersions(ver, existingVersion) > 0) + { + allInstalledVersions[pkg] = ver; + } } } - } - return allInstalledVersions; + if (!rootChanged) + { + return allInstalledVersions; + } + } } /// @@ -170,8 +197,6 @@ public async Task EnsurePackageAsync( { try { - InitializeWorkspace(rootDirectory); - await InstallPackageAsync( rootDirectory, packageName, diff --git a/src/winapp-CLI/WinApp.Cli/Services/StorageDiagnostics.cs b/src/winapp-CLI/WinApp.Cli/Services/StorageDiagnostics.cs new file mode 100644 index 000000000..6edd6714a --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/StorageDiagnostics.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace WinApp.Cli.Services; + +internal interface IStorageDiagnostics +{ + void Warning(string code, string message); +} + +internal sealed class StorageDiagnostics( + TextWriter error, bool json = false, bool quiet = false, bool deferWarnings = false) : IStorageDiagnostics +{ + private readonly Lock _gate = new(); + private readonly HashSet<(string Code, string Message)> _reported = []; + private bool _completed; + + public void Warning(string code, string message) + { + if (quiet) + { + return; + } + + lock (_gate) + { + if (!_completed && _reported.Add((code, message)) && !deferWarnings) + { + WriteWarning(error, json, code, message); + } + } + } + + internal void Complete(bool succeeded) + { + lock (_gate) + { + if (!_completed && deferWarnings && succeeded && !quiet) + { + foreach (var warning in _reported) + { + WriteWarning(error, json, warning.Code, warning.Message); + } + } + _completed = true; + } + } + + internal static void WriteWarning(TextWriter error, bool json, string code, string message) + { + error.WriteLine(json + ? JsonSerializer.Serialize( + new StorageWarningEnvelope(new StorageWarning(code, message)), + StorageWarningJsonContext.Default.StorageWarningEnvelope) + : $"Warning: {message}"); + } +} + +internal sealed record StorageWarning(string Code, string Message); +internal sealed record StorageWarningEnvelope(StorageWarning Warning); + +[JsonSerializable(typeof(StorageWarningEnvelope))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +internal partial class StorageWarningJsonContext : JsonSerializerContext +{ +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/TemplateUpdateCheckThrottle.cs b/src/winapp-CLI/WinApp.Cli/Services/TemplateUpdateCheckThrottle.cs index df4b94f97..a5a00befe 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/TemplateUpdateCheckThrottle.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/TemplateUpdateCheckThrottle.cs @@ -14,8 +14,10 @@ namespace WinApp.Cli.Services; /// internal sealed class TemplateUpdateCheckThrottle( IWinappDirectoryService winappDirectoryService, - ILogger logger) : ITemplateUpdateCheckThrottle + ILogger logger, + IStorageDiagnostics? diagnostics = null) : ITemplateUpdateCheckThrottle { + private readonly IStorageDiagnostics _diagnostics = diagnostics ?? new StorageDiagnostics(Console.Error); private const string CacheFileName = ".template-update-check"; private const int CheckIntervalHours = 24; @@ -46,13 +48,41 @@ public bool TryGetRecentLatest(string installedVersion, out string? latestVersio latestVersion = string.IsNullOrEmpty(cache.LatestVersion) ? null : cache.LatestVersion; return true; } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or FormatException) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or FormatException or InvalidOperationException) { logger.LogDebug(ex, "Failed to read template update-check cache; treating as due."); return false; } } + public bool CanCheckForUpdates() + { + try + { + var file = GetCacheFile(); + file.Directory?.Create(); + try + { + using var existing = new FileStream(file.FullName, FileMode.Open, FileAccess.ReadWrite, FileShare.Read); + return true; + } + catch (FileNotFoundException) + { + // Probe a sibling without creating a result that a failed network check never earned. + } + using var probe = new FileStream( + file.FullName + $".{Guid.NewGuid():N}.probe", + FileMode.CreateNew, FileAccess.Write, FileShare.None, 1, FileOptions.DeleteOnClose); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or InvalidOperationException) + { + _diagnostics.Warning("optional_storage_unavailable", + $"Skipping the automatic template update check because its bookkeeping is unavailable: {ex.Message}"); + return false; + } + } + public void Record(string installedVersion, string? latestVersion) { try @@ -64,6 +94,8 @@ public void Record(string installedVersion, string? latestVersion) catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { logger.LogDebug(ex, "Failed to write template update-check cache."); + _diagnostics.Warning("optional_storage_unavailable", + "The template update check completed, but its result could not be saved."); } } diff --git a/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs b/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs index 0d79122b4..6362b92ae 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/UpdateNotificationService.cs @@ -12,8 +12,10 @@ namespace WinApp.Cli.Services; internal class UpdateNotificationService( IWinappDirectoryService winappDirectoryService, - ILogger logger) : IUpdateNotificationService + ILogger logger, + IStorageDiagnostics? diagnostics = null) : IUpdateNotificationService { + private readonly IStorageDiagnostics _diagnostics = diagnostics ?? new StorageDiagnostics(Console.Error); private static readonly HttpClient SharedHttp = new() { Timeout = TimeSpan.FromSeconds(10) }; private static int _refreshScheduled; // guarded by Interlocked; see NotScheduled/Scheduled constants private const int NotScheduled = 0; @@ -87,12 +89,12 @@ public void CheckAndNotify() || (DateTimeOffset.UtcNow - cache.LastCheck.Value).TotalHours >= CheckIntervalHours) && Interlocked.CompareExchange(ref _refreshScheduled, Scheduled, NotScheduled) == NotScheduled) { - // On first run (no cache), write a placeholder synchronously so subsequent - // invocations see a valid LastCheck and don't re-race while the network call - // is in flight. The actual version will be filled in once the refresh completes. - if (!cache.LastCheck.HasValue) + // Persist the throttle before starting a network request. A restricted invocation + // must not repeatedly fetch an optional update it cannot remember. + if (!WriteCacheFile(cacheFile, new UpdateCheckCache(DateTimeOffset.UtcNow, cache.LatestVersion, cache.LastShownDate))) { - WriteCacheFile(cacheFile, new UpdateCheckCache(DateTimeOffset.UtcNow, cache.LatestVersion, cache.LastShownDate)); + Interlocked.Exchange(ref _refreshScheduled, NotScheduled); + return; } var refreshTask = Task.Run(async () => @@ -118,9 +120,9 @@ public void CheckAndNotify() } } } - catch + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or InvalidOperationException) { - // Silent failure — never disrupt the user's command + _diagnostics.Warning("optional_storage_unavailable", $"Skipping the automatic CLI update check: {ex.Message}"); } } @@ -385,24 +387,25 @@ internal static UpdateCheckCache ReadCache(FileInfo cacheFile) } } - private void WriteCacheFile(FileInfo cacheFile, UpdateCheckCache cache) + private bool WriteCacheFile(FileInfo cacheFile, UpdateCheckCache cache) { try { cacheFile.Directory?.Create(); - // Write to a temp file then move for atomic replacement - var tempPath = cacheFile.FullName + ".tmp"; var content = $"{cache.LastCheck?.ToString("O", CultureInfo.InvariantCulture) ?? ""}\n{cache.LatestVersion ?? ""}\n{cache.LastShownDate ?? ""}"; - File.WriteAllText(tempPath, content); - File.Move(tempPath, cacheFile.FullName, overwrite: true); + AtomicFile.WriteAllText(cacheFile.FullName, content); cacheFile.Refresh(); cacheFile.Attributes |= FileAttributes.Hidden; + return true; } - catch (Exception ex) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { logger.LogDebug(ex, "Failed to write update check cache."); + _diagnostics.Warning("optional_storage_unavailable", + $"CLI update bookkeeping at '{cacheFile.FullName}' is unavailable. The automatic update check may be skipped."); + return false; } } diff --git a/src/winapp-CLI/WinApp.Cli/Services/WinDbgJsProviderAcquirer.cs b/src/winapp-CLI/WinApp.Cli/Services/WinDbgJsProviderAcquirer.cs index d5021f752..9206fa561 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WinDbgJsProviderAcquirer.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WinDbgJsProviderAcquirer.cs @@ -88,45 +88,52 @@ internal static async Task TryAcquireCoreAsync( return false; } - Directory.CreateDirectory(destDir.FullName); - var targetPath = Path.Combine(destDir.FullName, TargetFileName); - - // Stage to a temp file, verify it, then atomically publish. Writing the DLL directly to its - // final path would let a concurrent run (or this run's later ResolveExisting) observe and - // .load a partially-written or not-yet-verified DLL. - var stagedPath = await AtomicFile.WriteStagedAsync(targetPath, bytes, cancellationToken); - - // Defense-in-depth: this DLL is loaded into the debugger process, so verify it carries a - // valid Authenticode signature from Microsoft before trusting it (the download is HTTPS - // from an official host, but this guards against tampering / a compromised mirror). - if (!SignatureVerifier(stagedPath, logger)) + try { - logger.LogDebug("Discarding {File}: it is not validly signed by Microsoft.", TargetFileName); - AtomicFile.DiscardStaged(stagedPath); - return false; - } + Directory.CreateDirectory(destDir.FullName); + var targetPath = Path.Combine(destDir.FullName, TargetFileName); + + // Stage to a temp file, verify it, then atomically publish. Writing the DLL directly to its + // final path would let a concurrent run (or this run's later ResolveExisting) observe and + // .load a partially-written or not-yet-verified DLL. + var stagedPath = await AtomicFile.WriteStagedAsync(targetPath, bytes, cancellationToken); + + // Defense-in-depth: this DLL is loaded into the debugger process, so verify it carries a + // valid Authenticode signature from Microsoft before trusting it (the download is HTTPS + // from an official host, but this guards against tampering / a compromised mirror). + if (!SignatureVerifier(stagedPath, logger)) + { + logger.LogDebug("Discarding {File}: it is not validly signed by Microsoft.", TargetFileName); + AtomicFile.DiscardStaged(stagedPath); + return false; + } - // The staged JsProvider must match the already-present engine build; loading a mismatched - // provider crashes the triage child with STATUS_BREAKPOINT. Reject a mismatch here (fail - // closed) rather than publishing a provider that would silently break triage — this guards - // against a future engine bump that outpaces the pinned bundle. - if (!EngineCompatibilityVerifier(destDir.FullName, stagedPath, logger)) - { - logger.LogDebug("Discarding {File}: its build does not match the debugging engine.", TargetFileName); - AtomicFile.DiscardStaged(stagedPath); - return false; - } + // The staged JsProvider must match the already-present engine build; loading a mismatched + // provider crashes the triage child with STATUS_BREAKPOINT. Reject a mismatch here (fail + // closed) rather than publishing a provider that would silently break triage — this guards + // against a future engine bump that outpaces the pinned bundle. + if (!EngineCompatibilityVerifier(destDir.FullName, stagedPath, logger)) + { + logger.LogDebug("Discarding {File}: its build does not match the debugging engine.", TargetFileName); + AtomicFile.DiscardStaged(stagedPath); + return false; + } - AtomicFile.Publish(stagedPath, targetPath); + AtomicFile.Publish(stagedPath, targetPath); - logger.LogDebug("Acquired {File} ({Size} bytes) from WinDbg bundle into {Dir}.", TargetFileName, bytes.Length, destDir.FullName); - return true; + logger.LogDebug("Acquired {File} ({Size} bytes) from WinDbg bundle into {Dir}.", TargetFileName, bytes.Length, destDir.FullName); + return true; + } + catch (Exception ex) when (CacheStorage.IsStorageFailure(ex)) + { + throw new CacheWriteException(destDir.FullName, ex); + } } catch (OperationCanceledException) { throw; } - catch (Exception ex) + catch (Exception ex) when (ex is not CacheWriteException) { logger.LogDebug(ex, "Failed to acquire {File} from the WinDbg bundle.", TargetFileName); return false; diff --git a/src/winapp-CLI/WinApp.Cli/Services/WinappDirectoryService.cs b/src/winapp-CLI/WinApp.Cli/Services/WinappDirectoryService.cs index a50596948..d489d8141 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WinappDirectoryService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WinappDirectoryService.cs @@ -11,10 +11,16 @@ namespace WinApp.Cli.Services; internal class WinappDirectoryService(ICurrentDirectoryProvider currentDirectoryProvider) : IWinappDirectoryService { private DirectoryInfo? _globalOverride; + internal DirectoryInfo? CacheDirectoryOverrideForTesting => _globalOverride; internal Func UserProfileProvider { get; set; } = () => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + internal Func CacheOverrideProvider { get; set; } = + () => Environment.GetEnvironmentVariable("WINAPP_CLI_CACHE_DIRECTORY"); + + public bool IsGlobalCacheOverridden => _globalOverride is not null || CacheOverrideProvider() is not null; + /// /// Method to override the cache directory for testing purposes /// @@ -33,17 +39,45 @@ public DirectoryInfo GetGlobalWinappDirectory() } // Allow override via environment variable (useful for CI/CD) - var cacheDirectory = Environment.GetEnvironmentVariable("WINAPP_CLI_CACHE_DIRECTORY"); - if (!string.IsNullOrEmpty(cacheDirectory)) + var cacheDirectory = CacheOverrideProvider(); + if (cacheDirectory is not null) { - return new DirectoryInfo(cacheDirectory); + if (string.IsNullOrWhiteSpace(cacheDirectory) || !Path.IsPathFullyQualified(cacheDirectory)) + { + throw new InvalidOperationException("WINAPP_CLI_CACHE_DIRECTORY must be a fully qualified directory path."); + } + try { return new DirectoryInfo(cacheDirectory); } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException) + { + throw new InvalidOperationException("WINAPP_CLI_CACHE_DIRECTORY is not a valid directory path.", ex); + } } var userProfile = UserProfileProvider(); + if (string.IsNullOrWhiteSpace(userProfile) || !Path.IsPathFullyQualified(userProfile)) + { + throw new IOException("The user profile folder could not be resolved for the default winapp cache."); + } var winappDir = Path.Combine(userProfile, ".winapp"); return new DirectoryInfo(winappDir); } + public DirectoryInfo GetLocalCacheDirectory() + { + var cwd = Path.GetFullPath(currentDirectoryProvider.GetCurrentDirectory()); + // The invocation directory is the sandbox boundary. Do not inspect its parents. + if (PathSafety.IsNetworkPath(cwd) + || Windows.Win32.PInvoke.GetDriveType(Path.GetPathRoot(cwd)!) == Windows.Win32.PInvoke.DRIVE_REMOTE + || PathSafety.IsReparsePoint(cwd)) + { + throw new IOException("The invocation directory is not a verifiable local cache location."); + } + + var path = Path.Combine(cwd, ".winapp", "cache"); + CacheStorage.ValidateLocalPath(path, cwd); + return new DirectoryInfo(path); + } + /// /// Shared operational state, independent of cache overrides and package identity. /// Unlike LocalAppData, the profile-root .winapp directory is not MSIX-virtualized. diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs index 5e44a52d9..08e2ac722 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs @@ -222,9 +222,6 @@ public async Task SetupWorkspaceAsync(WorkspaceSetupOptions options, Cancel logger.LogDebug("{UISymbol} Local workspace → {LocalWinappDir}", UiSymbols.Folder, localWinappDir); } - // First ensure basic workspace (for global packages) - logger.LogDebug("{UISymbol} Initializing workspace at {LocalWinappDir}", UiSymbols.Sync, localWinappDir); - packageInstallationService.InitializeWorkspace(globalWinappDir); } } else if (options.SdkInstallMode == SdkInstallMode.None) @@ -537,6 +534,8 @@ await taskContext.AddSubTaskAsync("Configuring developer mode", async (taskConte return (1, "Error installing packages."); } + nugetCacheDir = nugetService.GetNuGetGlobalPackagesDir(); + // Step 5: Run cppwinrt and set up projections var cppWinrtExe = cppWinrtService.FindCppWinrtExe(nugetCacheDir, usedVersions); if (cppWinrtExe is null) diff --git a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs index e2f27a3c6..2134e47a8 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs @@ -399,7 +399,7 @@ public static async Task TryAcquireFromNuGetAsync( acquired++; } } - catch (Exception ex) when (ex is not OperationCanceledException) + catch (Exception ex) when (ex is not OperationCanceledException and not CacheWriteException) { logger.LogDebug(ex, "Failed to acquire debugging component {Package} from NuGet.", package); } @@ -442,7 +442,11 @@ internal static bool TryCopyFromGlobalCache( foreach (var file in files) { - AtomicFile.Copy(Path.Combine(archDir, file), Path.Combine(cacheBinDir.FullName, file)); + try { AtomicFile.Copy(Path.Combine(archDir, file), Path.Combine(cacheBinDir.FullName, file)); } + catch (Exception ex) when (CacheStorage.IsStorageFailure(ex)) + { + throw new CacheWriteException(cacheBinDir.FullName, ex); + } } if (!versionDir.Name.Equals(pinnedVersion, StringComparison.OrdinalIgnoreCase)) @@ -492,10 +496,10 @@ internal static async Task TryMaterializePackageAsync( return false; } - var tempPkgDir = Path.Combine(Path.GetTempPath(), $"winapp-dbgtools-{id}-{Guid.NewGuid():N}"); - Directory.CreateDirectory(tempPkgDir); + var tempPkgDir = Path.Combine(cacheBinDir.FullName, $".staging-{id}-{Guid.NewGuid():N}"); try { + Directory.CreateDirectory(tempPkgDir); using (var nupkgStream = new MemoryStream(nupkgBytes, writable: false)) using (var archive = new ZipArchive(nupkgStream, ZipArchiveMode.Read)) { @@ -520,6 +524,10 @@ internal static async Task TryMaterializePackageAsync( return copied == files.Length; } + catch (Exception ex) when (CacheStorage.IsStorageFailure(ex)) + { + throw new CacheWriteException(cacheBinDir.FullName, ex); + } finally { try { Directory.Delete(tempPkgDir, recursive: true); } catch { /* best effort */ } diff --git a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs index 585442403..4e49a6fc6 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs @@ -17,8 +17,10 @@ namespace WinApp.Cli.Services; internal sealed partial class XamlTriageService( ILogger logger, IWinappDirectoryService winappDirectoryService, - INugetService nugetService) : IXamlTriageService + INugetService nugetService, + IStorageDiagnostics? diagnostics = null) : IXamlTriageService { + private readonly CacheStorage _cache = new(winappDirectoryService, "dbgtools", "dbgtools", diagnostics); // Pinned WinUI debugger extension (microsoft/microsoft-ui-xaml). See plan / docs for rationale. private const string ExtCommit = "29d537445eaa34d47e66ab8859583ae953c62dd1"; private const string ExtRepoPath = "dbgext/publicXamlThread/winui-dbgext.js"; @@ -59,32 +61,37 @@ public async Task TryAnalyzeAsync(string dumpPath, bool useSym { try { - var dbgToolsRoot = new DirectoryInfo(Path.Combine( - winappDirectoryService.GetGlobalWinappDirectory().FullName, "dbgtools")); - var cacheBinDir = new DirectoryInfo(Path.Combine(dbgToolsRoot.FullName, XamlTriageBinaries.KitsArch)); + var (binaries, extPath) = await _cache.RunAsync(async root => + { + var dbgToolsRoot = new DirectoryInfo(root); + var cacheBinDir = new DirectoryInfo(Path.Combine(dbgToolsRoot.FullName, XamlTriageBinaries.KitsArch)); - ResolvedTriageBinaries? ResolveExisting(DirectoryInfo dir) => - (BinariesResolverOverride ?? (d => XamlTriageBinaries.ResolveExisting(d, logger)))(dir); + ResolvedTriageBinaries? ResolveExisting(DirectoryInfo dir) => + (BinariesResolverOverride ?? (d => XamlTriageBinaries.ResolveExisting(d, logger)))(dir); - // Resolve an existing debugger layout; if none, populate the download-on-first-use cache: - // engine bits from NuGet (global cache or download) and JsProvider.dll from the WinDbg bundle. - var binaries = ResolveExisting(cacheBinDir); - if (binaries == null && !XamlTriageBinaries.IsEnvOverrideSet) - { - // Only populate the download-on-first-use cache when no authoritative override is set; - // with an override configured, ResolveExisting never consults the cache, so acquiring - // into it would waste the download and still report triage as unavailable. - var nugetCacheDir = TryGetNuGetCacheDir(); - await XamlTriageBinaries.TryAcquireFromNuGetAsync(cacheBinDir, nugetCacheDir, logger, cancellationToken); - - // JsProvider.dll only ships in the WinDbg bundle; acquire it once the engine is present. - if (XamlTriageBinaries.HasEngine(cacheBinDir)) + // Resolve an existing debugger layout; if none, populate the download-on-first-use cache: + // engine bits from NuGet (global cache or download) and JsProvider.dll from the WinDbg bundle. + var resolvedBinaries = ResolveExisting(cacheBinDir); + if (resolvedBinaries == null && !XamlTriageBinaries.IsEnvOverrideSet) { - await WinDbgJsProviderAcquirer.TryAcquireAsync(cacheBinDir, logger, cancellationToken); + // Only populate the download-on-first-use cache when no authoritative override is set; + // with an override configured, ResolveExisting never consults the cache, so acquiring + // into it would waste the download and still report triage as unavailable. + var nugetCacheDir = TryGetNuGetCacheDir(); + await XamlTriageBinaries.TryAcquireFromNuGetAsync(cacheBinDir, nugetCacheDir, logger, cancellationToken); + + // JsProvider.dll only ships in the WinDbg bundle; acquire it once the engine is present. + if (XamlTriageBinaries.HasEngine(cacheBinDir)) + { + await WinDbgJsProviderAcquirer.TryAcquireAsync(cacheBinDir, logger, cancellationToken); + } + + resolvedBinaries = ResolveExisting(cacheBinDir); } - binaries = ResolveExisting(cacheBinDir); - } + var extension = resolvedBinaries is null ? null : await EnsureExtensionAsync(dbgToolsRoot, cancellationToken); + return (resolvedBinaries, extension); + }); if (binaries == null) { @@ -92,7 +99,6 @@ public async Task TryAnalyzeAsync(string dumpPath, bool useSym return XamlTriageResult.Skipped(UnavailableNote()); } - var extPath = await EnsureExtensionAsync(dbgToolsRoot, cancellationToken); if (extPath == null) { logger.LogDebug("WinUI triage skipped: could not obtain {Ext}.", ExtFileName); @@ -145,6 +151,11 @@ public async Task TryAnalyzeAsync(string dumpPath, bool useSym logger.LogDebug("WinUI triage pass timed out acquiring debugging tools; skipping triage."); return XamlTriageResult.None; } + catch (Exception ex) when (CacheStorage.IsStorageFailure(ex)) + { + _cache.WarnUnavailable(ex); + return XamlTriageResult.Skipped($"WinUI Triage: skipped — debugging tool cache is unavailable. {ex.Message}"); + } catch (Exception ex) { logger.LogWarning(ex, "WinUI triage pass failed."); @@ -403,7 +414,6 @@ private static void TryKill(Process process) internal async Task EnsureExtensionAsync(DirectoryInfo dbgToolsRoot, CancellationToken cancellationToken) { var extDir = Path.Combine(dbgToolsRoot.FullName, "ext"); - Directory.CreateDirectory(extDir); var extPath = Path.Combine(extDir, ExtFileName); bool MatchesHash(byte[] content) => (ExtensionHashValidatorOverride ?? MatchesPinnedExtensionHash)(content); @@ -413,10 +423,11 @@ private static void TryKill(Process process) return extPath; } + byte[] bytes; try { var url = $"https://raw.githubusercontent.com/microsoft/microsoft-ui-xaml/{ExtCommit}/{ExtRepoPath}"; - var bytes = await (ExtensionBytesDownloader ?? DownloadExtensionBytesAsync)(url, cancellationToken); + bytes = await (ExtensionBytesDownloader ?? DownloadExtensionBytesAsync)(url, cancellationToken); if (!MatchesHash(bytes)) { @@ -425,14 +436,15 @@ private static void TryKill(Process process) return null; } - await File.WriteAllBytesAsync(extPath, bytes, cancellationToken); - return extPath; } catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogDebug(ex, "Failed to download {Ext}.", ExtFileName); return null; } + Directory.CreateDirectory(extDir); + await File.WriteAllBytesAsync(extPath, bytes, cancellationToken); + return extPath; } /// Real GitHub download boundary for the debugger extension; seamed via . From 065d7227ed5a25ad7c0adc4013bf13fe3d1298f1 Mon Sep 17 00:00:00 2001 From: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:15:37 -0700 Subject: [PATCH 03/11] Fix update-notice CI coverage and review cleanup findings Exercise ordinary offline discovery for update notices and separately verify informational commands suppress them. Dispose test writers, protect partially acquired lock collections, and narrow MSStore archive cleanup exceptions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../WinApp.Cli.Tests/CacheResilienceTests.cs | 7 ++- .../WinApp.Cli.Tests/FirstRunServiceTests.cs | 2 +- .../InteractiveDesktopLockTests.ReadOnly.cs | 48 +++++++++---------- .../UpdateNotificationGatingTests.cs | 31 +++++++++--- .../WinApp.Cli/Services/MSStoreCLIService.cs | 10 +++- 5 files changed, 62 insertions(+), 36 deletions(-) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/CacheResilienceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/CacheResilienceTests.cs index ff0f3a67d..ab6d169b0 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/CacheResilienceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/CacheResilienceTests.cs @@ -239,10 +239,13 @@ public async Task Controls_ReadableWriteLockedWarmCache_DoesNotFetchOrWrite() var provider = new TestProvider { Storage = Storage() }; await provider.LoadAsync(); var directory = Path.Combine(Global, "cache", "test", "test-provider"); - var locks = Directory.GetFiles(directory) - .Select(path => File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read)).ToArray(); + var locks = new List(); try { + foreach (var path in Directory.GetFiles(directory)) + { + locks.Add(File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read)); + } var data = await provider.LoadAsync(); Assert.AreEqual(CorpusOrigin.Cache, data.Origin); Assert.AreEqual(1, provider.Fetches); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FirstRunServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/FirstRunServiceTests.cs index 3405ca0a6..d9a3b4f9e 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FirstRunServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FirstRunServiceTests.cs @@ -23,7 +23,7 @@ private FirstRunService CreateService(CapturingLogger logger, T // at a throwaway directory instead of the real ~/.winapp. var dirService = new WinappDirectoryService(new CurrentDirectoryProvider(_tempDir.FullName)); dirService.SetCacheDirectoryForTesting(_globalDir); - return new FirstRunService(dirService, logger, new StorageDiagnostics(error ?? new StringWriter())); + return new FirstRunService(dirService, logger, new StorageDiagnostics(error ?? TextWriter.Null)); } [TestInitialize] diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.ReadOnly.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.ReadOnly.cs index c1b6b5bd8..9027fee87 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.ReadOnly.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.ReadOnly.cs @@ -22,8 +22,8 @@ public async Task Observe_BlockedParentAndMissingDirectory_RunsOnceWithoutCreati Environment.SetEnvironmentVariable( InteractiveDesktopPaths.LockDirectoryOverrideVariable, Path.Combine(blockedParent, "ui")); var calls = 0; - var error = new StringWriter(); - var output = new StringWriter(); + using var error = new StringWriter(); + using var output = new StringWriter(); var parse = ParseObservation(error, output, "--json"); UiCoordinationTelemetryScope.Begin(); @@ -51,7 +51,7 @@ public async Task Observe_BlockedParentAndMissingDirectory_RunsOnceWithoutCreati public async Task StorageUnavailable_ParticipatingCommandsFailClosed(int mode) { var store = new FailingStorage { LockFailure = StorageFailure() }; - var error = new StringWriter(); + using var error = new StringWriter(); var calls = 0; var action = new ReadOnlyProbeAction(CreateReadOnlyCoordinator(store), (UiTurnMode)mode, (_, _) => { @@ -59,7 +59,7 @@ public async Task StorageUnavailable_ParticipatingCommandsFailClosed(int mode) return Task.FromResult(0); }); - var exit = await action.InvokeAsync(ParseObservation(error, new StringWriter(), "--json")); + var exit = await action.InvokeAsync(ParseObservation(error, TextWriter.Null, "--json")); Assert.AreEqual(1, exit); Assert.AreEqual(0, calls, "mutation, capture and recording must never bypass coordination"); @@ -76,8 +76,8 @@ public async Task StorageUnavailable_ParticipatingCommandsFailClosed(int mode) public async Task Observe_StorageWarningHonorsQuiet(bool json) { var store = new FailingStorage { LockFailure = StorageFailure() }; - var error = new StringWriter(); - var output = new StringWriter(); + using var error = new StringWriter(); + using var output = new StringWriter(); var args = json ? new[] { "--json", "--quiet" } : new[] { "--quiet" }; var parse = ParseObservation(error, output, args); @@ -94,8 +94,8 @@ public async Task Observe_StorageWarningHonorsQuiet(bool json) [TestMethod] public async Task Observe_HumanStorageWarningUsesStderrOnly() { - var error = new StringWriter(); - var output = new StringWriter(); + using var error = new StringWriter(); + using var output = new StringWriter(); var coordinator = CreateReadOnlyCoordinator(new FailingStorage { LockFailure = StorageFailure() }); Assert.AreEqual(0, await coordinator.RunCoordinatedAsync( @@ -114,13 +114,13 @@ public async Task Observe_BodyStorageExceptionIsNeverReplayed(bool admissionUnav { var store = new FailingStorage { LockFailure = admissionUnavailable ? StorageFailure() : null }; var coordinator = CreateReadOnlyCoordinator(store); - var error = new StringWriter(); + using var error = new StringWriter(); var expected = StorageFailure(); var calls = 0; var actual = await Assert.ThrowsExactlyAsync(() => coordinator.RunCoordinatedAsync( - UiTurnMode.Observe, "ui inspect", ParseObservation(error, new StringWriter(), "--json"), + UiTurnMode.Observe, "ui inspect", ParseObservation(error, TextWriter.Null, "--json"), (_, _) => { calls++; @@ -138,11 +138,11 @@ public async Task Observe_BodyStorageExceptionIsNeverReplayed(bool admissionUnav public async Task Observe_FailedBodyPreservesExitAndDoesNotWriteStateOrWarning() { var store = new FailingStorage { LockFailure = StorageFailure() }; - var error = new StringWriter(); + using var error = new StringWriter(); var calls = 0; var exit = await CreateReadOnlyCoordinator(store).RunCoordinatedAsync( - UiTurnMode.Observe, "ui get-value", ParseObservation(error, new StringWriter(), "--json"), + UiTurnMode.Observe, "ui get-value", ParseObservation(error, TextWriter.Null, "--json"), (_, _) => { calls++; @@ -163,11 +163,11 @@ public async Task Observe_RejectsDesktopSectionsEvenWhenDetached(bool unavailabl { var store = new FailingStorage { LockFailure = unavailable ? StorageFailure() : null }; var coordinator = CreateReadOnlyCoordinator(store); - var error = new StringWriter(); + using var error = new StringWriter(); var ex = await Assert.ThrowsExactlyAsync(() => coordinator.RunCoordinatedAsync( - UiTurnMode.Observe, "ui inspect", ParseObservation(error, new StringWriter(), "--json"), + UiTurnMode.Observe, "ui inspect", ParseObservation(error, TextWriter.Null, "--json"), async (turn, token) => { await using var section = await turn.EnterAsync(token); @@ -185,10 +185,10 @@ public async Task Observe_RejectsDesktopSectionsEvenWhenDetached(bool unavailabl public async Task Observe_ReadStorageFailureDetachesWithoutPublishing() { var store = new FailingStorage { ReadFailure = StorageFailure() }; - var error = new StringWriter(); + using var error = new StringWriter(); Assert.AreEqual(0, await CreateReadOnlyCoordinator(store).RunCoordinatedAsync( - UiTurnMode.Observe, "ui list-windows", ParseObservation(error, new StringWriter(), "--json"), + UiTurnMode.Observe, "ui list-windows", ParseObservation(error, TextWriter.Null, "--json"), (_, _) => Task.FromResult(0), CancellationToken.None)); Assert.AreEqual(1, store.LockAttempts); @@ -205,10 +205,10 @@ public async Task Observe_PublishFailureClosesLeaseWithoutRetryingState() state.Owner = new OwnerRecord { Kind = owner.Kind, Key = owner.Key }; state.IdleExpiresTick64 = Environment.TickCount64 + 60_000; var store = new FailingStorage { State = state, PublishFailure = StorageFailure() }; - var error = new StringWriter(); + using var error = new StringWriter(); Assert.AreEqual(0, await CreateReadOnlyCoordinator(store).RunCoordinatedAsync( - UiTurnMode.Observe, "ui inspect", ParseObservation(error, new StringWriter(), "--json"), + UiTurnMode.Observe, "ui inspect", ParseObservation(error, TextWriter.Null, "--json"), (_, _) => Task.FromResult(0), CancellationToken.None)); Assert.AreEqual(1, store.LockAttempts, "no cleanup transaction may retry unavailable storage"); @@ -274,12 +274,12 @@ public async Task Observe_LiveStateAmbiguityDoesNotBecomeStorageFallback(bool co public async Task Observe_CancellationAfterDetachingDoesNotRetryStorage() { var store = new FailingStorage { LockFailure = StorageFailure() }; - var error = new StringWriter(); + using var error = new StringWriter(); using var cancellation = new CancellationTokenSource(); UiCoordinationTelemetryScope.Begin(); var exit = await CreateReadOnlyCoordinator(store).RunCoordinatedAsync( - UiTurnMode.Observe, "ui wait-for", ParseObservation(error, new StringWriter(), "--json"), + UiTurnMode.Observe, "ui wait-for", ParseObservation(error, TextWriter.Null, "--json"), (_, token) => { cancellation.Cancel(); @@ -304,7 +304,7 @@ public async Task InvalidExplicitDirectory_IsDeferredToStructuredCommandError(st { Environment.SetEnvironmentVariable(InteractiveDesktopPaths.LockDirectoryOverrideVariable, invalidDirectory); _ = new InteractiveDesktopPaths(new ProcessInspector()); - var error = new StringWriter(); + using var error = new StringWriter(); var calls = 0; var action = new ReadOnlyProbeAction(_coordinator, UiTurnMode.Observe, (_, _) => { @@ -312,7 +312,7 @@ public async Task InvalidExplicitDirectory_IsDeferredToStructuredCommandError(st return Task.FromResult(0); }); - var exit = await action.InvokeAsync(ParseObservation(error, new StringWriter(), "--json")); + var exit = await action.InvokeAsync(ParseObservation(error, TextWriter.Null, "--json")); Assert.AreEqual(1, exit); Assert.AreEqual(0, calls); @@ -326,14 +326,14 @@ public async Task InvalidExplicitDirectory_IsDeferredToStructuredCommandError(st public async Task InvalidLocalArguments_AreRejectedBeforeDirectoryResolution() { Environment.SetEnvironmentVariable(InteractiveDesktopPaths.LockDirectoryOverrideVariable, "relative\\locks"); - var error = new StringWriter(); + using var error = new StringWriter(); var action = new ReadOnlyProbeAction(_coordinator, UiTurnMode.Observe, (_, _) => throw new AssertFailedException("preflight must prevent execution")) { PreflightResult = 9, }; - Assert.AreEqual(9, await action.InvokeAsync(ParseObservation(error, new StringWriter(), "--json"))); + Assert.AreEqual(9, await action.InvokeAsync(ParseObservation(error, TextWriter.Null, "--json"))); Assert.AreEqual(string.Empty, error.ToString()); Assert.IsFalse(Directory.Exists(_lockDirectory)); } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationGatingTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationGatingTests.cs index e8c823052..a3ad8e028 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationGatingTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationGatingTests.cs @@ -10,9 +10,8 @@ namespace WinApp.Cli.Tests; /// Integration tests verifying that the Program-level gating logic correctly /// suppresses update notifications for --json, --quiet, and --cli-schema modes, /// and that --caller plumbs through to the notification hint text. -/// Tests that exercise suppression invoke Program.Main directly. -/// Tests that verify notification content use the service layer with env vars -/// matching what Program.Main would set. +/// All cases invoke Program.Main; normal-mode cases use an offline discovery command +/// rather than an informational command that intentionally suppresses bookkeeping. /// [TestClass] [DoNotParallelize] // Modifies static Console streams and environment variables @@ -72,8 +71,9 @@ public void Cleanup() [TestMethod] public async Task JsonMode_SuppressesUpdateNotice_StdoutHasNoNotice() { - var (stdout, stderr, _) = await ProgramMainTestHarness.InvokeProgramAsync(["get-winapp-path", "--global", "--json"]); + var (stdout, stderr, exit) = await ProgramMainTestHarness.InvokeProgramAsync(["find-ui", "jumplist", "--source", "core", "--json"]); + Assert.AreEqual(0, exit); Assert.IsFalse(stdout.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), $"--json stdout must not contain update notice. Got stdout: {stdout}"); Assert.IsFalse(stderr.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), @@ -83,8 +83,9 @@ public async Task JsonMode_SuppressesUpdateNotice_StdoutHasNoNotice() [TestMethod] public async Task QuietMode_SuppressesUpdateNotice() { - var (stdout, stderr, _) = await ProgramMainTestHarness.InvokeProgramAsync(["get-winapp-path", "--global", "--quiet"]); + var (stdout, stderr, exit) = await ProgramMainTestHarness.InvokeProgramAsync(["find-ui", "jumplist", "--source", "core", "--quiet"]); + Assert.AreEqual(0, exit); Assert.IsFalse(stdout.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), $"--quiet stdout must not contain update notice. Got stdout: {stdout}"); Assert.IsFalse(stderr.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), @@ -107,8 +108,9 @@ public async Task NormalMode_ShowsUpdateNotice_OnStderr() { // Invoke through the real entrypoint — the notification should appear on stderr, // never stdout. We capture stderr via Console.SetError. - var (stdout, stderr, _) = await ProgramMainTestHarness.InvokeProgramAsync(["get-winapp-path", "--global"]); + var (stdout, stderr, exit) = await ProgramMainTestHarness.InvokeProgramAsync(["find-ui", "jumplist", "--source", "core"]); + Assert.AreEqual(0, exit); Assert.IsFalse(stdout.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), $"Update notice must not appear on stdout. Got stdout: {stdout}"); Assert.IsTrue(stderr.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), @@ -120,12 +122,27 @@ public async Task CallerNpm_ProducesNpmHint() { // --caller npm should set WINAPP_CLI_CALLER=npm which makes the update notice // include the npm update hint. - var (_, stderr, _) = await ProgramMainTestHarness.InvokeProgramAsync(["get-winapp-path", "--global", "--caller", "npm"]); + var (_, stderr, exit) = await ProgramMainTestHarness.InvokeProgramAsync(["find-ui", "jumplist", "--source", "core", "--caller", "npm"]); + Assert.AreEqual(0, exit); Assert.IsTrue(stderr.Contains("npm update", StringComparison.OrdinalIgnoreCase), $"With --caller npm, notice should contain npm update hint. Got stderr: {stderr}"); } + [TestMethod] + [DataRow("get-winapp-path --global")] + [DataRow("--help")] + [DataRow("--version")] + [DataRow("ui --help")] + public async Task InformationalCommands_SuppressEvenACachedUpdateNotice(string commandLine) + { + var (stdout, stderr, exit) = await ProgramMainTestHarness.InvokeProgramAsync(commandLine.Split(' ')); + + Assert.AreEqual(0, exit); + Assert.IsFalse(stdout.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase)); + Assert.IsFalse(stderr.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase)); + } + /// /// Seeds the .update-check file with a timestamp (now) and a specified "latest" version /// so the notification fires immediately without needing network access. diff --git a/src/winapp-CLI/WinApp.Cli/Services/MSStoreCLIService.cs b/src/winapp-CLI/WinApp.Cli/Services/MSStoreCLIService.cs index c9d33fb7b..19fd8a8ad 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/MSStoreCLIService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/MSStoreCLIService.cs @@ -76,8 +76,14 @@ await _cache.RunAsync(async installDir => } finally { - try { File.Delete(zipPath); } - catch { /* Best effort cleanup. */ } + try + { + File.Delete(zipPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + logger.LogDebug("Could not remove downloaded MSStore archive '{ZipPath}': {Message}", zipPath, ex.Message); + } } return true; }); From e62a0ffec0b1f886737acef1406824216a95fd59 Mon Sep 17 00:00:00 2001 From: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:46:45 -0700 Subject: [PATCH 04/11] Protect target state and classify storage failures precisely Validate namespace ownership, ancestor permissions and existing artifacts before target state access; create private user/SYSTEM state compatible with Sandbox folder mapping. Reject untrusted state without repairing exposed keys, add sandbox_state_unavailable, and validate cache-relative path invariants. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/sandbox-execution.md | 1 + .../WinApp.Cli.Tests/CacheResilienceTests.cs | 23 ++ .../ExecutionTargetErrorTests.cs | 1 + .../TargetStateDirectoryProviderTests.cs | 58 ++- .../TargetStateDirectorySecurityTests.cs | 358 ++++++++++++++++++ .../Abstractions/ExecutionTargetErrorCodes.cs | 4 + .../TargetStateDirectoryProvider.cs | 54 ++- .../TargetStateDirectorySecurity.cs | 192 ++++++++++ .../WinApp.Cli/Services/CacheStorage.cs | 24 +- 9 files changed, 672 insertions(+), 43 deletions(-) create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/TargetStateDirectorySecurityTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/TargetStateDirectorySecurity.cs diff --git a/docs/sandbox-execution.md b/docs/sandbox-execution.md index 24138b3e0..cc973a145 100644 --- a/docs/sandbox-execution.md +++ b/docs/sandbox-execution.md @@ -351,6 +351,7 @@ copying a suggestion keeps it on the same execution target. | `sandbox_agent_incompatible` | Follow the version error; upgrade the installed CLI using its installation method if requested, then close/retry only with consent | | `sandbox_agent_busy` | Wait for another command to finish, then retry | | `sandbox_terminated`, `sandbox_target_stale`, `sandbox_stale_handle` | Rerun the app and rediscover guest PIDs/windows | +| `sandbox_state_unavailable` | Fix the reported state path: use writable local storage without junctions or symbolic links, owned and accessible only by you (SYSTEM and Administrators are trusted). Parent directories must prevent other users from replacing it. Existing exposed state is not repaired or deleted; safely stop the affected Sandbox before replacing that state and its connection keys in a secure directory. | | `sandbox_deployment_dirty`, `sandbox_transfer_interrupted` | Retry the deployment or transfer | | `sandbox_runtime_provision_failed` | Resolve the named dependency or unsupported runtime configuration; see [Shared runtimes](#shared-runtimes) | | `sandbox_package_conflict`, `sandbox_provisioned_package_conflict` | Follow the package-specific action; do not remove unrelated or inbox packages | diff --git a/src/winapp-CLI/WinApp.Cli.Tests/CacheResilienceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/CacheResilienceTests.cs index ab6d169b0..23c0c5d23 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/CacheResilienceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/CacheResilienceTests.cs @@ -118,6 +118,29 @@ public void InvalidExplicitOverride_IsNotRedirected(string value) Assert.IsFalse(Directory.Exists(Local)); } + [TestMethod] + [DataRow("")] + [DataRow(" ")] + [DataRow(@"C:\outside")] + [DataRow(@"C:outside")] + [DataRow(@"\outside")] + [DataRow(@"..\outside")] + [DataRow(@"cache\..\outside")] + [DataRow(@"cache\.. \outside")] + [DataRow(@"cache\name:stream")] + public void CacheSubdirectories_RejectRootedOrTraversingPathsBeforeStorageAccess(string path) + { + _directories.CacheOverrideProvider = () => throw new AssertFailedException("Invalid subpaths must not resolve storage."); + + var global = Assert.Throws(() => new CacheStorage(_directories, path, "test")); + var local = Assert.Throws(() => new CacheStorage(_directories, Path.Join("cache", "test"), path)); + + Assert.AreEqual("globalRelativePath", global.ParamName); + Assert.AreEqual("localRelativePath", local.ParamName); + Assert.IsFalse(Directory.Exists(Global)); + Assert.IsFalse(Directory.Exists(Local)); + } + [TestMethod] public void DeniedAncestor_RetriesOnlyInsideInvocationDirectory_AndWarns() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/ExecutionTargetErrorTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/ExecutionTargetErrorTests.cs index 1f8c021c4..ff7741584 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/ExecutionTargetErrorTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/ExecutionTargetErrorTests.cs @@ -35,6 +35,7 @@ public class ExecutionTargetErrorTests "sandbox_provisioned_package_conflict", "sandbox_target_ambiguous", "sandbox_target_stale", + "sandbox_state_unavailable", "sandbox_stale_handle", "sandbox_artifact_failed", "sandbox_setup_required", diff --git a/src/winapp-CLI/WinApp.Cli.Tests/TargetStateDirectoryProviderTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/TargetStateDirectoryProviderTests.cs index c89d1a73b..bc1352774 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/TargetStateDirectoryProviderTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/TargetStateDirectoryProviderTests.cs @@ -12,17 +12,23 @@ namespace WinApp.Cli.Tests; public class TargetStateDirectoryProviderTests { private string? _previousRoot; + private string _testRoot = null!; [TestInitialize] public void Setup() { + _testRoot = Path.GetFullPath($"target-state-provider-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_testRoot); _previousRoot = Environment.GetEnvironmentVariable(TargetStateDirectoryProvider.RootOverrideVariable); Environment.SetEnvironmentVariable(TargetStateDirectoryProvider.RootOverrideVariable, null); } [TestCleanup] - public void Cleanup() => + public void Cleanup() + { Environment.SetEnvironmentVariable(TargetStateDirectoryProvider.RootOverrideVariable, _previousRoot); + Directory.Delete(_testRoot, recursive: true); + } [TestMethod] public void DefaultDirectory_IsSharedUserStateRegardlessOfCacheOverride() @@ -30,13 +36,13 @@ public void DefaultDirectory_IsSharedUserStateRegardlessOfCacheOverride() var previousCache = Environment.GetEnvironmentVariable("WINAPP_CLI_CACHE_DIRECTORY"); try { + var profile = Path.Join(_testRoot, "profile"); var expected = Path.Join( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".winapp", "state", "targets", WindowsSandboxTarget.Default.StateKey); - foreach (var cache in new[] { Path.Join(Path.GetTempPath(), "cache-one"), Path.Join(Path.GetTempPath(), "cache-two") }) + profile, ".winapp", "state", "targets", WindowsSandboxTarget.Default.StateKey); + foreach (var cache in new[] { Path.Join(_testRoot, "cache-one"), Path.Join(_testRoot, "cache-two") }) { Environment.SetEnvironmentVariable("WINAPP_CLI_CACHE_DIRECTORY", cache); - var provider = new TargetStateDirectoryProvider(); + var provider = new TargetStateDirectoryProvider { UserProfileProvider = () => profile }; Assert.AreEqual(expected, provider.GetTargetRoot(WindowsSandboxTarget.Default, create: false).FullName); } @@ -50,7 +56,7 @@ public void DefaultDirectory_IsSharedUserStateRegardlessOfCacheOverride() [TestMethod] public void DefaultDirectory_UsesProfileRootWithoutCreatingIt() { - var profile = Path.Join(Path.GetTempPath(), $"winapp-profile-{Guid.NewGuid():N}"); + var profile = Path.Join(_testRoot, "profile"); var provider = new TargetStateDirectoryProvider { UserProfileProvider = () => profile, @@ -67,7 +73,7 @@ public void DefaultDirectory_UsesProfileRootWithoutCreatingIt() [TestMethod] public void DefaultDirectory_CreatesTargetUnderUserState() { - var profile = Path.Join(Path.GetTempPath(), $"winapp-profile-{Guid.NewGuid():N}"); + var profile = Path.Join(_testRoot, "profile"); var provider = new TargetStateDirectoryProvider { UserProfileProvider = () => profile, @@ -94,7 +100,7 @@ public void DefaultDirectory_CreatesTargetUnderUserState() [TestMethod] public void ExplicitOverride_WinsOverEnvironmentAndProfile() { - var rootOverride = Path.Join(Path.GetTempPath(), "override"); + var rootOverride = Path.Join(_testRoot, "override"); Environment.SetEnvironmentVariable(TargetStateDirectoryProvider.RootOverrideVariable, @"\\server\unused"); var provider = new TargetStateDirectoryProvider(rootOverride) { @@ -109,7 +115,7 @@ public void ExplicitOverride_WinsOverEnvironmentAndProfile() [TestMethod] public void EnvironmentOverride_WinsOverProfile() { - var rootOverride = Path.Join(Path.GetTempPath(), "override"); + var rootOverride = Path.Join(_testRoot, "override"); Environment.SetEnvironmentVariable(TargetStateDirectoryProvider.RootOverrideVariable, rootOverride); var provider = new TargetStateDirectoryProvider { @@ -132,11 +138,13 @@ public void InvalidProfile_FailsWithoutFallingBack(string profile) var ex = Assert.ThrowsExactly( () => provider.GetTargetRoot(WindowsSandboxTarget.Default, create: false)); - Assert.AreEqual(ExecutionTargetErrorCodes.TargetStale, ex.Error.Code); + Assert.AreEqual(ExecutionTargetErrorCodes.StateUnavailable, ex.Error.Code); StringAssert.Contains(ex.Error.UserAction, "%USERPROFILE%\\.winapp\\state"); } [TestMethod] + [DataRow("")] + [DataRow(" ")] [DataRow("relative\\targets")] [DataRow(@"\\server\share\targets")] [DataRow(@"\\?\UNC\server\share\targets")] @@ -150,6 +158,34 @@ public void InvalidOverride_FailsWithoutFallingBack(string rootOverride) var ex = Assert.ThrowsExactly( () => provider.GetTargetRoot(WindowsSandboxTarget.Default, create: false)); - Assert.AreEqual(ExecutionTargetErrorCodes.TargetStale, ex.Error.Code); + Assert.AreEqual(ExecutionTargetErrorCodes.StateUnavailable, ex.Error.Code); + } + + [TestMethod] + public void InvalidEnvironmentOverride_FailsWithoutFallingBack() + { + Environment.SetEnvironmentVariable(TargetStateDirectoryProvider.RootOverrideVariable, " "); + var provider = new TargetStateDirectoryProvider + { + UserProfileProvider = () => throw new AssertFailedException("An invalid override must not fall back."), + }; + + var ex = Assert.ThrowsExactly( + () => provider.GetTargetRoot(WindowsSandboxTarget.Default, create: false)); + + Assert.AreEqual(ExecutionTargetErrorCodes.StateUnavailable, ex.Error.Code); + } + + [TestMethod] + public void FileBlockingDirectory_ReportsStateUnavailable() + { + var blocked = Path.Join(_testRoot, "targets"); + File.WriteAllText(blocked, "keep"); + + var ex = Assert.ThrowsExactly( + () => new TargetStateDirectoryProvider(blocked).GetTargetRoot(WindowsSandboxTarget.Default)); + + Assert.AreEqual(ExecutionTargetErrorCodes.StateUnavailable, ex.Error.Code); + Assert.AreEqual("keep", File.ReadAllText(blocked)); } } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/TargetStateDirectorySecurityTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/TargetStateDirectorySecurityTests.cs new file mode 100644 index 000000000..3a7b550c4 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/TargetStateDirectorySecurityTests.cs @@ -0,0 +1,358 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Security.AccessControl; +using System.Security.Principal; +using WinApp.Cli.ExecutionTargets.Abstractions; +using WinApp.Cli.ExecutionTargets.Orchestration; +using WinApp.Cli.ExecutionTargets.WindowsSandbox; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class TargetStateDirectorySecurityTests +{ + private string _root = null!; + private SecurityIdentifier _user = null!; + private static readonly SecurityIdentifier ForeignUser = new("S-1-5-21-111111111-222222222-333333333-1001"); + + [TestInitialize] + public void Setup() + { + using var identity = WindowsIdentity.GetCurrent(); + _user = identity.User!; + _root = Path.Combine(Path.GetTempPath(), $"target-state-acl-tests-{Guid.NewGuid():N}"); + new DirectoryInfo(_root).Create(PrivateSecurity()); + } + + [TestCleanup] + public void Cleanup() => Directory.Delete(_root, recursive: true); + + [TestMethod] + public void NewNamespace_HasProtectedCurrentUserAndSystemPermissions() + { + var targets = Path.Join(_root, "targets"); + var result = Provider(targets).GetTargetRoot(WindowsSandboxTarget.Default); + + foreach (var path in new[] { targets, result.FullName }) + { + var security = new DirectoryInfo(path).GetAccessControl(); + Assert.IsTrue(security.AreAccessRulesProtected); + Assert.AreEqual(_user, security.GetOwner(typeof(SecurityIdentifier))); + Assert.IsTrue(TargetStateDirectorySecurity.IsTrusted(security, _user)); + var allowed = security.GetAccessRules(true, true, typeof(SecurityIdentifier)) + .Cast() + .Where(rule => rule.AccessControlType == AccessControlType.Allow) + .ToArray(); + Assert.AreEqual(2, allowed.Length); + Assert.IsTrue(allowed.Any(rule => rule.IdentityReference.Equals(_user))); + Assert.IsTrue(allowed.Any(rule => ((SecurityIdentifier)rule.IdentityReference) + .IsWellKnown(WellKnownSidType.LocalSystemSid))); + } + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void InheritedTrustedState_IsPreservedWithoutRewritingPermissions(bool create) + { + var targets = Directory.CreateDirectory(Path.Join(_root, "targets")); + var target = Directory.CreateDirectory(Path.Join(targets.FullName, WindowsSandboxTarget.Default.StateKey)); + var file = Path.Join(target.FullName, "target-state.json"); + File.WriteAllText(file, "keep"); + var before = Snapshot(targets.FullName, target.FullName, file); + Assert.IsFalse(target.GetAccessControl().AreAccessRulesProtected); + + Provider(targets.FullName + Path.DirectorySeparatorChar).GetTargetRoot(WindowsSandboxTarget.Default, create); + + CollectionAssert.AreEqual(before, Snapshot(targets.FullName, target.FullName, file)); + Assert.AreEqual("keep", File.ReadAllText(file)); + } + + [TestMethod] + public void ReadOnlyMissingTarget_DoesNotCreateOrChangeAnything() + { + var before = Snapshot(_root); + var targets = Path.Join(_root, "missing", "targets"); + + var result = Provider(targets).GetTargetRoot(WindowsSandboxTarget.Default, create: false); + + Assert.IsFalse(result.Exists); + Assert.AreEqual(0, Directory.GetFileSystemEntries(_root).Length); + CollectionAssert.AreEqual(before, Snapshot(_root)); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void ExistingForeignDirectoryGrant_FailsWithoutRepairOrDeletion(bool create) + { + var targets = Directory.CreateDirectory(Path.Join(_root, "targets")); + AddGrant(targets, ForeignUser, FileSystemRights.ReadAndExecute); + var before = Snapshot(targets.FullName); + + AssertUnavailable(() => Provider(targets.FullName).GetTargetRoot(WindowsSandboxTarget.Default, create)); + + CollectionAssert.AreEqual(before, Snapshot(targets.FullName)); + Assert.AreEqual(0, targets.GetFileSystemInfos().Length); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void ExistingExposedConnection_FailsEvenUnderPrivateDirectories(bool create) + { + var targets = Path.Join(_root, "targets"); + var target = Provider(targets).GetTargetRoot(WindowsSandboxTarget.Default); + var bootstrap = Directory.CreateDirectory(Path.Join(target.FullName, "bootstrap-123")); + var connection = new FileInfo(Path.Join(bootstrap.FullName, "connection.json")); + File.WriteAllText(connection.FullName, "{\"sharedKey\":\"previously-exposed\"}"); + AddGrant(connection, ForeignUser, FileSystemRights.ReadData); + var before = Snapshot(targets, target.FullName, bootstrap.FullName, connection.FullName); + + AssertUnavailable(() => Provider(targets).GetTargetRoot(WindowsSandboxTarget.Default, create)); + + CollectionAssert.AreEqual(before, Snapshot(targets, target.FullName, bootstrap.FullName, connection.FullName)); + StringAssert.Contains(File.ReadAllText(connection.FullName), "previously-exposed"); + } + + [TestMethod] + public void SharedWritableAncestor_CannotReplacePrivateNamespace() + { + var shared = Directory.CreateDirectory(Path.Join(_root, "shared")); + var targets = Path.Join(shared.FullName, "targets"); + Provider(targets).GetTargetRoot(WindowsSandboxTarget.Default); + AddGrant(shared, ForeignUser, FileSystemRights.DeleteSubdirectoriesAndFiles); + var before = Snapshot(shared.FullName, targets); + + AssertUnavailable(() => Provider(targets).GetTargetRoot(WindowsSandboxTarget.Default, create: false)); + + CollectionAssert.AreEqual(before, Snapshot(shared.FullName, targets)); + } + + [TestMethod] + public void PublicReadOnlyAncestor_DoesNotExposeProtectedState() + { + AddGrant(new DirectoryInfo(_root), ForeignUser, FileSystemRights.ReadAndExecute); + + Assert.IsTrue(Provider(Path.Join(_root, "targets")).GetTargetRoot(WindowsSandboxTarget.Default).Exists); + } + + [TestMethod] + public void SecuringTargetState_LeavesCacheAndUiSiblingPermissionsUnchanged() + { + var cache = Directory.CreateDirectory(Path.Join(_root, ".winapp")); + var cacheData = Directory.CreateDirectory(Path.Join(cache.FullName, "packages")); + AddGrant(cacheData, ForeignUser, FileSystemRights.FullControl); + var state = Directory.CreateDirectory(Path.Join(cache.FullName, "state")); + var ui = Directory.CreateDirectory(Path.Join(state.FullName, "ui")); + var marker = Path.Join(cacheData.FullName, "keep.txt"); + File.WriteAllText(marker, "cache"); + var before = Snapshot(cache.FullName, cacheData.FullName, state.FullName, ui.FullName, marker); + + Provider(Path.Join(state.FullName, "targets")).GetTargetRoot(WindowsSandboxTarget.Default); + + CollectionAssert.AreEqual(before, Snapshot(cache.FullName, cacheData.FullName, state.FullName, ui.FullName, marker)); + Assert.AreEqual("cache", File.ReadAllText(marker)); + } + + [TestMethod] + public void LocalJunction_IsRejectedWithoutTouchingItsDestination() + { + var destination = Directory.CreateDirectory(Path.Join(_root, "shared")); + AddGrant(destination, ForeignUser, FileSystemRights.FullControl); + var junction = Path.Join(_root, ".winapp"); + var before = Snapshot(destination.FullName); + using var process = Process.Start(new ProcessStartInfo("cmd.exe") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + ArgumentList = { "/c", "mklink", "/J", junction, destination.FullName }, + })!; + process.WaitForExit(); + Assert.AreEqual(0, process.ExitCode, "The test-owned junction must be created."); + + try + { + AssertUnavailable(() => Provider(Path.Join(junction, "state", "targets")) + .GetTargetRoot(WindowsSandboxTarget.Default)); + Assert.AreEqual(0, destination.GetFileSystemInfos().Length); + CollectionAssert.AreEqual(before, Snapshot(destination.FullName)); + } + finally + { + Directory.Delete(junction); + } + } + + [TestMethod] + public void ReparsePointInsideState_IsRejected() + { + var targets = Path.Join(_root, "targets"); + var target = Provider(targets).GetTargetRoot(WindowsSandboxTarget.Default); + var destination = Directory.CreateDirectory(Path.Join(_root, "outside")); + var junction = Path.Join(target.FullName, "bootstrap-123"); + using var process = Process.Start(new ProcessStartInfo("cmd.exe") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + ArgumentList = { "/c", "mklink", "/J", junction, destination.FullName }, + })!; + process.WaitForExit(); + Assert.AreEqual(0, process.ExitCode); + + try + { + AssertUnavailable(() => Provider(targets).GetTargetRoot(WindowsSandboxTarget.Default, create: false)); + } + finally + { + Directory.Delete(junction); + } + } + + [TestMethod] + public void ConcurrentCreators_VerifyTheSamePrivateNamespace() + { + var targets = Path.Join(_root, "targets"); + + Parallel.For(0, 12, _ => + Assert.IsTrue(Provider(targets).GetTargetRoot(WindowsSandboxTarget.Default).Exists)); + + Assert.IsTrue(TargetStateDirectorySecurity.IsTrusted(new DirectoryInfo(targets).GetAccessControl(), _user)); + } + + [TestMethod] + public void SystemAndAdministratorsGrants_DoNotInvalidateState() + { + var targets = Path.Join(_root, "targets"); + var target = Provider(targets).GetTargetRoot(WindowsSandboxTarget.Default); + var file = new FileInfo(Path.Join(target.FullName, "target-state.json")); + File.WriteAllText(file.FullName, "keep"); + foreach (var sid in new[] { WellKnownSidType.LocalSystemSid, WellKnownSidType.BuiltinAdministratorsSid }) + { + AddGrant(target, new SecurityIdentifier(sid, null), FileSystemRights.FullControl); + AddGrant(file, new SecurityIdentifier(sid, null), FileSystemRights.FullControl); + } + + Provider(targets).GetTargetRoot(WindowsSandboxTarget.Default, create: false); + + Assert.AreEqual("keep", File.ReadAllText(file.FullName)); + } + + [TestMethod] + public void ForeignOwner_IsUntrustedEvenWithPrivateDacl() + { + var security = PrivateSecurity(); + security.SetOwner(ForeignUser); + + Assert.IsFalse(TargetStateDirectorySecurity.IsTrusted(security, _user)); + Assert.IsFalse(TargetStateDirectorySecurity.IsTrusted(security, _user, allowAncestorAccess: true)); + } + + [TestMethod] + public void ExistingForeignOwner_FailsWithoutRepairWhenOwnershipCanBeAssigned() + { + var targets = Path.Join(_root, "targets"); + var target = Provider(targets).GetTargetRoot(WindowsSandboxTarget.Default); + var security = target.GetAccessControl(); + security.SetOwner(ForeignUser); + try + { + target.SetAccessControl(security); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or PrivilegeNotHeldException or InvalidOperationException) + { + Assert.Inconclusive($"Windows did not permit assigning the test's foreign owner: {ex.Message}"); + } + + try + { + Assert.AreEqual(ForeignUser, target.GetAccessControl().GetOwner(typeof(SecurityIdentifier))); + AssertUnavailable(() => Provider(targets).GetTargetRoot(WindowsSandboxTarget.Default, create: false)); + Assert.AreEqual(ForeignUser, target.GetAccessControl().GetOwner(typeof(SecurityIdentifier))); + } + finally + { + security.SetOwner(_user); + target.SetAccessControl(security); + } + } + + [TestMethod] + [DataRow(WellKnownSidType.LocalSystemSid)] + [DataRow(WellKnownSidType.BuiltinAdministratorsSid)] + public void PrivilegedOwner_IsTrusted(WellKnownSidType owner) + { + var security = PrivateSecurity(); + security.SetOwner(new SecurityIdentifier(owner, null)); + + Assert.IsTrue(TargetStateDirectorySecurity.IsTrusted(security, _user)); + } + + [TestMethod] + public void NullDacl_IsNotPrivate() + { + var security = new DirectorySecurity(); + security.SetSecurityDescriptorSddlForm($"O:{_user.Value}D:NO_ACCESS_CONTROL"); + + Assert.IsFalse(TargetStateDirectorySecurity.IsTrusted(security, _user)); + } + + private static TargetStateDirectoryProvider Provider(string targets) => new(targets) + { + UserProfileProvider = () => throw new AssertFailedException("Tests must never consult the live user profile."), + }; + + private DirectorySecurity PrivateSecurity() + { + var security = new DirectorySecurity(); + security.SetOwner(_user); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.AddAccessRule(new FileSystemAccessRule( + _user, FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, AccessControlType.Allow)); + return security; + } + + private static void AssertUnavailable(Action action) + { + var exception = Assert.ThrowsExactly(action); + Assert.AreEqual(ExecutionTargetErrorCodes.StateUnavailable, exception.Error.Code); + StringAssert.Contains(exception.Error.UserAction, "secure"); + StringAssert.Contains(exception.Error.UserAction, "connection keys"); + Assert.IsNull(exception.Error.NextCommand); + } + + private static string[] Snapshot(params string[] paths) => + paths.Select(path => + { + FileSystemSecurity security = Directory.Exists(path) + ? new DirectoryInfo(path).GetAccessControl() + : new FileInfo(path).GetAccessControl(); + return security.GetSecurityDescriptorSddlForm(AccessControlSections.Owner | AccessControlSections.Access); + }).ToArray(); + + private static void AddGrant(FileSystemInfo item, SecurityIdentifier sid, FileSystemRights rights) + { + if (item is DirectoryInfo directory) + { + var security = directory.GetAccessControl(); + security.AddAccessRule(new FileSystemAccessRule(sid, rights, AccessControlType.Allow)); + directory.SetAccessControl(security); + } + else + { + var file = (FileInfo)item; + var security = file.GetAccessControl(); + security.AddAccessRule(new FileSystemAccessRule(sid, rights, AccessControlType.Allow)); + file.SetAccessControl(security); + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Abstractions/ExecutionTargetErrorCodes.cs b/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Abstractions/ExecutionTargetErrorCodes.cs index dae63c154..1bc6532c2 100644 --- a/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Abstractions/ExecutionTargetErrorCodes.cs +++ b/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Abstractions/ExecutionTargetErrorCodes.cs @@ -78,6 +78,9 @@ internal static class ExecutionTargetErrorCodes /// Persisted target state refers to an instance that no longer exists. public const string TargetStale = "sandbox_target_stale"; + /// Target state storage cannot be resolved, accessed, or trusted for this user. + public const string StateUnavailable = "sandbox_state_unavailable"; + /// A process ID or window handle from a previous epoch was supplied. public const string StaleHandle = "sandbox_stale_handle"; @@ -139,6 +142,7 @@ internal static class ExecutionTargetErrorCodes ProvisionedPackageConflict, TargetAmbiguous, TargetStale, + StateUnavailable, StaleHandle, ArtifactFailed, SetupRequired, diff --git a/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/TargetStateDirectoryProvider.cs b/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/TargetStateDirectoryProvider.cs index f89aa1a9d..574f72c20 100644 --- a/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/TargetStateDirectoryProvider.cs +++ b/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/TargetStateDirectoryProvider.cs @@ -11,7 +11,8 @@ internal interface ITargetStateDirectoryProvider { /// /// Returns the state root for , creating it when - /// is true. + /// is true. Existing state and its namespace must be private + /// to this user; neither reads nor writes repair or discard untrusted state. /// DirectoryInfo GetTargetRoot(ExecutionTargetRef target, bool create = true); } @@ -46,42 +47,39 @@ public DirectoryInfo GetTargetRoot(ExecutionTargetRef target, bool create = true { ArgumentNullException.ThrowIfNull(target); - var root = TargetPathSafety.CombineInsideRoot(GetTargetsRoot(), target.StateKey); - var directory = new DirectoryInfo(root); - if (create && !directory.Exists) + try { - directory.Create(); - directory.Refresh(); + var targetsRoot = GetTargetsRoot(); + var root = TargetPathSafety.CombineInsideRoot(targetsRoot, target.StateKey); + return TargetStateDirectorySecurity.EnsureTrusted(targetsRoot, root, create); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException + or ArgumentException or NotSupportedException or System.Security.SecurityException) + { + throw ExecutionTargetException.Create( + ExecutionTargetErrorCodes.StateUnavailable, + $"The execution-target state directory is unavailable or untrusted: {ex.Message}", + userAction: "Ensure %USERPROFILE%\\.winapp\\state is a writable local path without junctions or symbolic links, and secure its ownership and permissions against other users. " + + "If WINAPP_TARGET_STATE_ROOT is set, use the same private, fully qualified local directory in every winapp process. " + + "Do not reuse exposed connection keys: after safely stopping any affected Sandbox, replace its exposed state in a secure directory. Existing state has not been repaired or deleted.", + innerException: ex); } - - return directory; } private string GetTargetsRoot() { - try + if (rootOverride is not null) { - if (!string.IsNullOrWhiteSpace(rootOverride)) - { - return WinappDirectoryService.ValidateStateDirectory(rootOverride); - } - - var environmentRoot = Environment.GetEnvironmentVariable(RootOverrideVariable); - if (!string.IsNullOrWhiteSpace(environmentRoot)) - { - return WinappDirectoryService.ValidateStateDirectory(environmentRoot); - } - - return WinappDirectoryService.ValidateStateDirectory( - Path.Combine(WinappDirectoryService.GetUserStateDirectory(UserProfileProvider()), "targets")); + return WinappDirectoryService.ValidateStateDirectory(rootOverride); } - catch (IOException ex) + + var environmentRoot = Environment.GetEnvironmentVariable(RootOverrideVariable); + if (environmentRoot is not null) { - throw ExecutionTargetException.Create( - ExecutionTargetErrorCodes.TargetStale, - $"The execution-target state directory could not be resolved: {ex.Message}", - userAction: "Ensure %USERPROFILE%\\.winapp\\state is on a writable local drive. If WINAPP_TARGET_STATE_ROOT is set, use the same fully qualified local directory in every winapp process.", - innerException: ex); + return WinappDirectoryService.ValidateStateDirectory(environmentRoot); } + + return WinappDirectoryService.ValidateStateDirectory( + TargetPathSafety.CombineInsideRoot(WinappDirectoryService.GetUserStateDirectory(UserProfileProvider()), "targets")); } } diff --git a/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/TargetStateDirectorySecurity.cs b/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/TargetStateDirectorySecurity.cs new file mode 100644 index 000000000..7791c2aac --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/TargetStateDirectorySecurity.cs @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Security.AccessControl; +using System.Security.Principal; + +namespace WinApp.Cli.ExecutionTargets.Orchestration; + +/// Verifies the namespace and secrets before target state can be used. +internal static class TargetStateDirectorySecurity +{ + // The default Windows volume root belongs to TrustedInstaller, a privileged system service. + private static readonly SecurityIdentifier TrustedInstaller = new( + "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"); + + internal static DirectoryInfo EnsureTrusted(string targetsRoot, string targetRoot, bool create) + { + targetsRoot = Path.TrimEndingDirectorySeparator(targetsRoot); + using var identity = WindowsIdentity.GetCurrent(); + var user = identity.User ?? throw new IOException("The current Windows user could not be identified."); + var target = new DirectoryInfo(targetRoot); + var ancestors = new Stack(); + for (DirectoryInfo? directory = target; directory is not null; directory = directory.Parent) + { + ancestors.Push(directory); + } + + // Check from the volume down: a private leaf is not safe when another user can replace + // a parent. Never follow a reparse point before checking the next path component. + while (ancestors.TryPop(out var directory)) + { + if (!TryGetAttributes(directory.FullName, out var attributes)) + { + if (!create) + { + return target; + } + + directory.Create(PrivateStateSecurity(user)); + attributes = File.GetAttributes(directory.FullName); + } + + RejectReparsePoint(directory.FullName, attributes); + if (!attributes.HasFlag(FileAttributes.Directory)) + { + throw new IOException($"'{directory.FullName}' is not a directory."); + } + + Verify(directory, user, + allowAncestorAccess: !TargetPathSafety.IsInsideRoot(targetsRoot, directory.FullName)); + } + + VerifyContents(target, user); + target.Refresh(); + return target; + } + + private static DirectorySecurity PrivateStateSecurity(SecurityIdentifier user) + { + var security = new DirectorySecurity(); + security.SetOwner(user); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.AddAccessRule(new FileSystemAccessRule( + user, FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, AccessControlType.Allow)); + // The Sandbox broker maps bootstrap/result folders as SYSTEM, not as the host CLI user. + security.AddAccessRule(new FileSystemAccessRule( + new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, AccessControlType.Allow)); + return security; + } + + private static void VerifyContents(DirectoryInfo target, SecurityIdentifier user) + { + var pending = new Stack(); + pending.Push(target); + while (pending.TryPop(out var item)) + { + try + { + if (!TryGetAttributes(item.FullName, out var attributes)) + { + continue; + } + + RejectReparsePoint(item.FullName, attributes); + if (attributes.HasFlag(FileAttributes.Directory)) + { + var directory = new DirectoryInfo(item.FullName); + Verify(directory, user, allowAncestorAccess: false); + foreach (var child in directory.EnumerateFileSystemInfos()) + { + pending.Push(child); + } + } + else + { + Verify(new FileInfo(item.FullName), user, allowAncestorAccess: false); + } + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + // Another trusted winapp process may prune a bootstrap or atomically replace state. + } + } + } + + private static void Verify(FileSystemInfo item, SecurityIdentifier user, bool allowAncestorAccess) + { + FileSystemSecurity security = item is DirectoryInfo directory + ? directory.GetAccessControl(AccessControlSections.Owner | AccessControlSections.Access) + : ((FileInfo)item).GetAccessControl(AccessControlSections.Owner | AccessControlSections.Access); + if (!IsTrusted(security, user, allowAncestorAccess)) + { + throw new IOException($"'{item.FullName}' is owned by or grants unsafe access to another user."); + } + } + + internal static bool IsTrusted(FileSystemSecurity security, SecurityIdentifier user, bool allowAncestorAccess = false) + { + if (security.GetOwner(typeof(SecurityIdentifier)) is not SecurityIdentifier owner + || !(IsSelfOrPrivileged(owner, user) || (allowAncestorAccess && owner == TrustedInstaller))) + { + return false; + } + + // An absent (NULL) DACL grants everybody access; an empty rule enumeration is not proof + // of privacy. Inherited trusted rules, however, need no repair when all ancestors are safe. + if (new RawSecurityDescriptor(security.GetSecurityDescriptorBinaryForm(), 0).DiscretionaryAcl is null) + { + return false; + } + + foreach (FileSystemAccessRule rule in security.GetAccessRules(true, true, typeof(SecurityIdentifier))) + { + if (rule.AccessControlType != AccessControlType.Allow + || rule.IdentityReference is SecurityIdentifier sid + && (IsSelfOrPrivileged(sid, user) || (allowAncestorAccess && sid == TrustedInstaller))) + { + continue; + } + + if (!allowAncestorAccess) + { + return false; + } + + // Public traversal/read access to C:\ and Users is normal. Creating sibling directories + // alone cannot replace a verified child; CreateDirectory installs its DACL atomically, + // and the checks above also verify an existing directory when another creator wins. + // Inherit-only rules do not grant access to this ancestor itself. + const FileSystemRights harmlessAncestorRights = FileSystemRights.ReadAndExecute + | FileSystemRights.Synchronize | FileSystemRights.CreateDirectories; + if (!rule.PropagationFlags.HasFlag(PropagationFlags.InheritOnly) + && (rule.FileSystemRights & ~harmlessAncestorRights) != 0) + { + return false; + } + } + + return true; + } + + private static bool IsSelfOrPrivileged(SecurityIdentifier sid, SecurityIdentifier user) => + sid == user + || sid.IsWellKnown(WellKnownSidType.LocalSystemSid) + || sid.IsWellKnown(WellKnownSidType.BuiltinAdministratorsSid); + + private static void RejectReparsePoint(string path, FileAttributes attributes) + { + if (attributes.HasFlag(FileAttributes.ReparsePoint)) + { + throw new IOException($"'{path}' is a junction or symbolic link. Target state requires a direct local path."); + } + } + + private static bool TryGetAttributes(string path, out FileAttributes attributes) + { + try + { + attributes = File.GetAttributes(path); + return true; + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + attributes = default; + return false; + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/CacheStorage.cs b/src/winapp-CLI/WinApp.Cli/Services/CacheStorage.cs index 5a4a77516..6aa2a8b7e 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/CacheStorage.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/CacheStorage.cs @@ -10,6 +10,8 @@ internal sealed class CacheStorage( string localRelativePath, IStorageDiagnostics? diagnostics = null) { + private readonly string _globalRelativePath = RequireRelativePath(globalRelativePath, nameof(globalRelativePath)); + private readonly string _localRelativePath = RequireRelativePath(localRelativePath, nameof(localRelativePath)); private bool _local; // A read-only probe: readable warm caches do not need writable directories. @@ -20,13 +22,13 @@ internal sealed class CacheStorage( public void Clear(Action clear) { - var global = Path.Combine(directories.GetGlobalWinappDirectory().FullName, globalRelativePath); + var global = Path.Combine(directories.GetGlobalWinappDirectory().FullName, _globalRelativePath); InspectDirectory(global); clear(global); if (!IsExplicit) { var root = directories.GetLocalCacheDirectory().FullName; - var local = Path.Combine(root, localRelativePath); + var local = Path.Combine(root, _localRelativePath); ValidateLocalPath(local, root); ValidateLocalTree(local); if (!local.Equals(global, StringComparison.OrdinalIgnoreCase)) @@ -102,18 +104,32 @@ private string ResolvePath() if (_local) { var root = directories.GetLocalCacheDirectory().FullName; - path = Path.GetFullPath(Path.Combine(root, localRelativePath)); + path = Path.GetFullPath(Path.Combine(root, _localRelativePath)); ValidateLocalPath(path, root); ValidateLocalTree(path); } else { - path = Path.Combine(directories.GetGlobalWinappDirectory().FullName, globalRelativePath); + path = Path.Combine(directories.GetGlobalWinappDirectory().FullName, _globalRelativePath); } InspectDirectory(path); return path; } + private static string RequireRelativePath(string path, string parameterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path, parameterName); + if (Path.IsPathRooted(path) + || path.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries) + .Any(segment => segment is "." or ".." + || segment.EndsWith(' ') || segment.EndsWith('.') + || segment.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)) + { + throw new ArgumentException("A cache subdirectory must be relative and cannot contain parent traversal or invalid path segments.", parameterName); + } + return path; + } + private void SwitchToLocal(Exception error) { if (directories.IsGlobalCacheOverridden) From e75c664a7fcda8b91d345134ef8565fbb818dc3a Mon Sep 17 00:00:00 2001 From: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:45:47 -0700 Subject: [PATCH 05/11] Address independent review findings in restricted storage flows Authenticate repository-local tool and debugger payloads; avoid new NuGet prerequisites during evaluation; reuse completed fallback packages; preserve scaffold JSON failures; detect inaccessible shared scratch before NuGet retries; and keep stable layout lock files for reliable handoff. Isolate trusted-state test fixtures from shared CI drives. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/usage.md | 13 + .../WinApp.Cli.Tests/CacheResilienceTests.cs | 11 +- .../WinApp.Cli.Tests/LayoutLeaseTests.cs | 42 ++- .../MSStoreCLIServiceOfflineTests.cs | 111 ++++++++ .../NewCommandHandlerTests.cs | 84 ++++++ .../NugetScratchStorageTests.cs | 109 ++++++++ .../WinApp.Cli.Tests/NugetStorageTests.cs | 253 +++++++++++++++++- .../TargetStateDirectoryProviderTests.cs | 2 +- .../XamlTriageBinariesTests.cs | 52 ++++ .../WinApp.Cli/Commands/NewCommand.cs | 57 ++-- .../WinApp.Cli/Services/CacheStorage.cs | 1 + .../WinApp.Cli/Services/DotNetService.cs | 25 +- .../WinApp.Cli/Services/LayoutLease.cs | 7 +- .../WinApp.Cli/Services/MSStoreCLIService.cs | 33 ++- .../Services/NugetPackageDownloader.cs | 1 + .../WinApp.Cli/Services/NugetService.cs | 51 ++-- .../Services/NugetSourceProvider.cs | 64 ++++- .../Services/NugetStorageException.cs | 9 + .../WinApp.Cli/Services/XamlTriageBinaries.cs | 37 ++- .../WinApp.Cli/Services/XamlTriageService.cs | 2 +- 20 files changed, 876 insertions(+), 88 deletions(-) create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/NugetScratchStorageTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/NugetStorageException.cs diff --git a/docs/usage.md b/docs/usage.md index 9102047e3..5e684e55a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -2012,6 +2012,11 @@ that directory. An existing readable cache can be used without requiring writes. | Read-only UI observation | Can continue without workflow ordering when shared state is inaccessible | | UI mutations, captures and Sandbox management | Require accessible shared coordination; they never silently run without it | +Store CLI and debugger executables/DLLs reused from the automatic local fallback +must have valid Microsoft signatures. If a local Store tool is rejected, remove +that tool cache and retry to download a verified copy. Unverifiable debugger +layouts are skipped without discarding the available crash diagnostics. + An explicit `WINAPP_CLI_CACHE_DIRECTORY` is authoritative: if that location is invalid or cannot support the requested operation, fix it or remove the override rather than expecting an automatic redirect. To select an allowed directory: @@ -2045,6 +2050,12 @@ If a child later needs to write there and fails, choose a permitted `NUGET_PACKA directory before retrying. winapp does not automatically replay builds or applications that may already have performed work. +NuGet also requires writable scratch storage for configuration and installation +locks. If that storage is blocked, winapp stops promptly with `NUGET_SCRATCH` +guidance rather than entering NuGet's long lock retry. Use one fully qualified, +permitted scratch directory consistently for every process sharing a package cache. +winapp does not automatically choose a different lock directory for one process. + Filesystem fallback does not grant access to SDKs, certificate stores, Windows package registration, authentication or the desktop. Commands that require those facilities still need the corresponding permissions. @@ -2079,6 +2090,8 @@ not require a global cache merely to lock a build output. Processes targeting th same layout use the same lock regardless of their cache settings or working directory. These lock artifacts are excluded from package and deployment payloads. An inaccessible lock is reported as a storage error, not as another process using the layout. +The lock files can remain after a run; their presence does not mean a process holds +the layout. Exclude `.winapp-layout-locks/` from version control. ### Update Checks diff --git a/src/winapp-CLI/WinApp.Cli.Tests/CacheResilienceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/CacheResilienceTests.cs index 23c0c5d23..5885f2969 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/CacheResilienceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/CacheResilienceTests.cs @@ -321,7 +321,7 @@ public async Task Controls_ClearDeniedGlobal_DoesNotClaimItClearedLocalOnly() } [TestMethod] - public async Task Store_UsesReadableLocalToolWhenDefaultAncestorIsBlocked() + public async Task Store_RejectsUnsignedLocalToolWhenDefaultAncestorIsBlocked() { File.WriteAllText(Global, "blocked"); var toolDir = Path.Combine(Local, "tools", "msstore"); @@ -329,11 +329,10 @@ public async Task Store_UsesReadableLocalToolWhenDefaultAncestorIsBlocked() var exe = Path.Combine(toolDir, "msstore.exe"); File.WriteAllText(exe, "tool"); using var held = File.Open(exe, FileMode.Open, FileAccess.Read, FileShare.Read); - using var warnings = new StringWriter(); - var service = new MSStoreCLIService(_directories, NullLogger.Instance, new StorageDiagnostics(warnings)); - await service.EnsureMSStoreCLIAvailableAsync(); - Assert.AreEqual(exe, service.GetMSStoreCLIPath()); - StringAssert.Contains(warnings.ToString(), "default winapp cache is inaccessible"); + var service = new MSStoreCLIService(_directories, NullLogger.Instance); + var error = await Assert.ThrowsAsync(() => service.EnsureMSStoreCLIAvailableAsync()); + StringAssert.Contains(error.Message, "not validly signed by Microsoft"); + Assert.Throws(() => service.GetMSStoreCLIPath()); } [TestMethod] diff --git a/src/winapp-CLI/WinApp.Cli.Tests/LayoutLeaseTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/LayoutLeaseTests.cs index b49f5073a..6c7b9786c 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/LayoutLeaseTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/LayoutLeaseTests.cs @@ -2,14 +2,17 @@ // Licensed under the MIT License. using System.Diagnostics; +using System.Runtime.InteropServices; using System.Text; +using Microsoft.Win32.SafeHandles; +using WinApp.Cli.Helpers; using WinApp.Cli.Services; namespace WinApp.Cli.Tests; [TestClass] [DoNotParallelize] -public class LayoutLeaseTests +public partial class LayoutLeaseTests { private DirectoryInfo _root = null!; @@ -65,7 +68,7 @@ public void LockPath_NormalizesExtendedPrefixAndCase_AndHasFixedLengthName() { Assert.IsTrue(File.Exists(path)); } - Assert.IsFalse(File.Exists(path)); + Assert.IsTrue(File.Exists(path)); } [TestMethod] @@ -82,7 +85,7 @@ public async Task ConcurrentInstances_WaitUntilTheFirstIsReleasedAcrossAnAwait() { attempted.Set(); return new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, - FileShare.None, 1, FileOptions.DeleteOnClose); + FileShare.None, 1, FileOptions.None); }); acquired = true; }, TestContext.CancellationToken); @@ -99,7 +102,7 @@ public async Task ConcurrentInstances_WaitUntilTheFirstIsReleasedAcrossAnAwait() } Assert.IsTrue(acquired); - Assert.IsFalse(File.Exists(LayoutLease.GetLockPath(layout))); + Assert.IsTrue(File.Exists(LayoutLease.GetLockPath(layout))); } [TestMethod] @@ -185,7 +188,7 @@ public void AlreadyCancelled_DoesNotCreateLockArtifacts() } [TestMethod] - public void ExistingUnlockedFile_IsReusable_AndDisposalRemovesOnlyThatFile() + public void ExistingUnlockedFile_IsReusable_AndDisposalReleasesOnlyItsLease() { var layout = new DirectoryInfo(Path.Combine(_root.FullName, "AppX")); var lockPath = LayoutLease.GetLockPath(layout); @@ -200,8 +203,10 @@ public void ExistingUnlockedFile_IsReusable_AndDisposalRemovesOnlyThatFile() lease.Dispose(); } - Assert.IsFalse(File.Exists(lockPath)); + Assert.IsTrue(File.Exists(lockPath)); Assert.IsTrue(File.Exists(LayoutLease.GetLockPath(other))); + Assert.ThrowsExactly(() => + LayoutLease.Acquire(other, TestContext.CancellationToken, TimeSpan.Zero)); using var next = LayoutLease.Acquire(layout, TestContext.CancellationToken, TimeSpan.Zero); } @@ -217,7 +222,7 @@ public async Task OtherProcess_WithDifferentWorkingAndCacheDirectories_BlocksUnt $ErrorActionPreference = 'Stop' $stream = [System.IO.FileStream]::new($env:WINAPP_TEST_LAYOUT_LOCK, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, - [System.IO.FileShare]::None, 1, [System.IO.FileOptions]::DeleteOnClose) + [System.IO.FileShare]::None, 1, [System.IO.FileOptions]::None) [Console]::Out.WriteLine('locked') [Console]::Out.Flush() [Console]::In.ReadLine() | Out-Null @@ -263,7 +268,23 @@ public async Task OtherProcess_WithDifferentWorkingAndCacheDirectories_BlocksUnt { Assert.IsTrue(File.Exists(lockPath)); } - Assert.IsFalse(File.Exists(lockPath)); + Assert.IsTrue(File.Exists(lockPath)); + } + + [TestMethod] + public void MetadataObserver_DoesNotBreakLeaseHandoff() + { + var layout = new DirectoryInfo(Path.Combine(_root.FullName, "AppX")); + using var first = LayoutLease.Acquire(layout, TestContext.CancellationToken); + using var observer = OpenMetadataHandle( + LongPathHelper.EnsureExtendedLengthPrefix(LayoutLease.GetLockPath(layout)), 0, (uint)(FileShare.ReadWrite | FileShare.Delete), + 0, (uint)FileMode.Open, 0, 0); + Assert.IsFalse(observer.IsInvalid, $"Metadata open failed with {Marshal.GetLastPInvokeError()}."); + + first.Dispose(); + + using var second = LayoutLease.Acquire(layout, TestContext.CancellationToken, TimeSpan.Zero); + Assert.IsTrue(File.Exists(LayoutLease.GetLockPath(layout))); } [TestMethod] @@ -274,4 +295,9 @@ public void ReservedLayoutPath_IsRefusedBeforeCreatingAnything() LayoutLease.Acquire(layout, TestContext.CancellationToken)); Assert.IsEmpty(_root.GetFileSystemInfos()); } + + [LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + private static partial SafeFileHandle OpenMetadataHandle( + string path, uint access, uint share, nint security, uint disposition, uint flags, nint template); } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/MSStoreCLIServiceOfflineTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/MSStoreCLIServiceOfflineTests.cs index 1a9333db1..be0b5309e 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/MSStoreCLIServiceOfflineTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/MSStoreCLIServiceOfflineTests.cs @@ -5,6 +5,7 @@ using System.Net; using System.Runtime.InteropServices; using System.Security.Cryptography; +using Microsoft.Extensions.Logging.Abstractions; using WinApp.Cli.Services; namespace WinApp.Cli.Tests; @@ -45,6 +46,116 @@ private static byte[] BuildExeZip() private static string Sha256Hex(byte[] data) => Convert.ToHexString(SHA256.HashData(data)); + private MSStoreCLIService NewLocalService(FakeHttpMessageHandler handler, out DirectoryInfo installDir) + { + var root = _tempDirectory.CreateSubdirectory("local-store-" + Guid.NewGuid().ToString("N")); + var profile = root.CreateSubdirectory("profile"); + File.WriteAllText(Path.Combine(profile.FullName, ".winapp"), "blocked default"); + var cwd = root.CreateSubdirectory("project"); + var directories = new WinappDirectoryService(new CurrentDirectoryProvider(cwd.FullName)) + { + UserProfileProvider = () => profile.FullName, + CacheOverrideProvider = () => null, + }; + installDir = cwd.CreateSubdirectory(Path.Combine(".winapp", "cache", "tools", "msstore")); + return new MSStoreCLIService(directories, NullLogger.Instance) + { + Http = new HttpClient(handler), + OsArchitectureProvider = () => Architecture.X64, + }; + } + + [TestMethod] + public async Task LocalCache_TrustedToolAndDependencies_AreVerifiedAgainAtUse() + { + var svc = NewLocalService(new FakeHttpMessageHandler(), out var dir); + var exe = Path.Combine(dir.FullName, "msstore.exe"); + var dll = Path.Combine(dir.FullName, "msalruntime.dll"); + File.WriteAllText(exe, "fixture"); + File.WriteAllText(dll, "fixture"); + var verified = new List(); + svc.SignatureVerifier = (path, _) => { verified.Add(path); return true; }; + + await svc.EnsureMSStoreCLIAvailableAsync(TestContext.CancellationToken); + Assert.AreEqual(exe, svc.GetMSStoreCLIPath()); + + Assert.AreEqual(2, verified.Count(path => path == exe)); + Assert.AreEqual(2, verified.Count(path => path == dll)); + svc.SignatureVerifier = (_, _) => false; + Assert.Throws(() => svc.GetMSStoreCLIPath(), + "A prior successful verification must not authorize changed bytes."); + } + + [TestMethod] + public async Task LocalCache_UntrustedDependency_IsRejectedEvenWhenExecutableIsTrusted() + { + var svc = NewLocalService(new FakeHttpMessageHandler(), out var dir); + File.WriteAllText(Path.Combine(dir.FullName, "msstore.exe"), "fixture"); + var dll = Path.Combine(dir.FullName, "msalruntime.dll"); + File.WriteAllText(dll, "fixture"); + svc.SignatureVerifier = (path, _) => path != dll; + + var error = await Assert.ThrowsAsync(() => + svc.EnsureMSStoreCLIAvailableAsync(TestContext.CancellationToken)); + + StringAssert.Contains(error.Message, dll); + StringAssert.Contains(error.Message, "not validly signed by Microsoft"); + } + + [TestMethod] + public async Task ExplicitCache_ReadableTool_DoesNotApplyLocalSignaturePolicy() + { + var svc = NewService(new FakeHttpMessageHandler(), out var dir); + dir.Create(); + File.WriteAllText(Path.Combine(dir.FullName, "msstore.exe"), "fixture"); + svc.SignatureVerifier = (_, _) => throw new AssertFailedException("Only local fallback changes trust policy."); + + await svc.EnsureMSStoreCLIAvailableAsync(TestContext.CancellationToken); + + Assert.AreEqual(Path.Combine(dir.FullName, "msstore.exe"), svc.GetMSStoreCLIPath()); + } + + [TestMethod] + public async Task DefaultGlobalCache_ReadableTool_DoesNotApplyLocalSignaturePolicy() + { + var profile = _tempDirectory.CreateSubdirectory("global-store-" + Guid.NewGuid().ToString("N")); + var dir = profile.CreateSubdirectory(Path.Combine(".winapp", "tools", "msstore")); + var exe = Path.Combine(dir.FullName, "msstore.exe"); + File.WriteAllText(exe, "fixture"); + var directories = new WinappDirectoryService(new CurrentDirectoryProvider(_tempDirectory.FullName)) + { + UserProfileProvider = () => profile.FullName, + CacheOverrideProvider = () => null, + }; + var svc = new MSStoreCLIService(directories, NullLogger.Instance) + { + SignatureVerifier = (_, _) => throw new AssertFailedException("Only local fallback changes trust policy."), + }; + + await svc.EnsureMSStoreCLIAvailableAsync(TestContext.CancellationToken); + + Assert.AreEqual(exe, svc.GetMSStoreCLIPath()); + } + + [TestMethod] + public async Task LocalCache_FreshDownload_StillRequiresAuthenticExecutable() + { + var zip = BuildExeZip(); + var handler = new FakeHttpMessageHandler() + .WhenUriContains(ReleaseApi, HttpStatusCode.OK, + ReleaseJson("v1.2.3", "x64", "https://dl.test/local.zip", "https://dl.test/local.sha256")) + .WhenUriContains("/local.zip", HttpStatusCode.OK, zip) + .WhenUriContains("/local.sha256", HttpStatusCode.OK, $"{Sha256Hex(zip)} MSStoreCLI-win-x64.zip"); + var svc = NewLocalService(handler, out var dir); + + var error = await Assert.ThrowsAsync(() => + svc.EnsureMSStoreCLIAvailableAsync(TestContext.CancellationToken)); + + StringAssert.Contains(error.Message, "not validly signed by Microsoft"); + Assert.IsFalse(File.Exists(Path.Combine(dir.FullName, "MSStoreCLI.zip"))); + Assert.Throws(() => svc.GetMSStoreCLIPath()); + } + private static string ReleaseJson(string tag, string arch, string? zipUrl, string? checksumUrl) { var assets = new List(); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/NewCommandHandlerTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/NewCommandHandlerTests.cs index 0f9df3db2..64dd7e56f 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/NewCommandHandlerTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/NewCommandHandlerTests.cs @@ -518,6 +518,90 @@ public async Task Handler_ScaffoldFails_ReturnsScaffoldFailed() Assert.AreEqual(NewCommand.ExitScaffoldFailed, exitCode); } + [TestMethod] + [DataRow("io", true)] + [DataRow("access", true)] + [DataRow("configuration", true)] + [DataRow("configuration", false)] + public async Task Handler_ScaffoldStoragePreparationFails_ReturnsJsonAndScaffoldFailed(string failureKind, bool isJson) + { + _dotnet.RunDotnetArgumentListHandler = args => + { + if (args[0] == "--version") + { + return (0, "9.0.100\n", string.Empty); + } + if (args.Count >= 2 && args[1] == "uninstall") + { + return (0, BuildUninstallOutput("0.0.6-alpha"), string.Empty); + } + if (args.Count >= 2 && args[1] == "list") + { + return (0, SampleListOutput, string.Empty); + } + throw failureKind switch + { + "access" => new UnauthorizedAccessException("NUGET_PACKAGES is unreadable; fix its permissions."), + "configuration" => new NugetStorageException("The explicitly configured NuGet packages folder is unavailable; fix its permissions."), + _ => new IOException("NuGet package storage is unavailable; fix its permissions."), + }; + }; + var command = GetRequiredService(); + + string[] args = ["--template", "winui", "--name", "StorageProbe", "--template-version", "installed", "--use-defaults"]; + var exitCode = await ParseAndInvokeWithCaptureAsync(command, isJson ? [.. args, "--json"] : args); + + Assert.AreEqual(NewCommand.ExitScaffoldFailed, exitCode); + if (isJson) + { + var json = ParseJson(TestAnsiConsole.Output); + Assert.IsFalse(json.GetProperty("Created").GetBoolean()); + Assert.AreEqual("StorageProbe", json.GetProperty("Name").GetString()); + StringAssert.Contains(json.GetProperty("Error").GetString()!, "fix its permissions"); + } + else + { + StringAssert.Contains($"{ConsoleStdOut}{ConsoleStdErr}", "fix its permissions"); + } + Assert.AreEqual(1, _dotnet.ArgumentListInvocations.Count(args => args.Count > 1 && args[1] == "winui"), + "A pre-launch failure must not retry scaffolding."); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task Handler_ScaffoldUnexpectedFailureOrCancellation_IsNotConvertedToStorageFailure(bool cancelled) + { + ScriptHappyPath(); + var respond = _dotnet.RunDotnetArgumentListHandler!; + _dotnet.RunDotnetArgumentListHandler = args => + { + if (args.Count > 1 && args[0] == "new" && args[1] == "winui") + { + if (cancelled) + { + throw new OperationCanceledException("cancelled scaffold"); + } + throw new InvalidOperationException("unexpected scaffold defect"); + } + return respond(args); + }; + var command = GetRequiredService(); + var handler = GetRequiredService(); + var parsed = command.Parse(["--template", "winui", "--name", "StorageProbe", "--use-defaults", "--json"]); + + if (cancelled) + { + await Assert.ThrowsExactlyAsync(() => handler.InvokeAsync(parsed, TestContext.CancellationToken)); + } + else + { + await Assert.ThrowsExactlyAsync(() => handler.InvokeAsync(parsed, TestContext.CancellationToken)); + } + Assert.IsFalse(TestAnsiConsole.Output.Contains("\"Created\"", StringComparison.Ordinal), + "Unexpected defects and cancellation must not produce an ordinary scaffold result."); + } + [TestMethod] public async Task Handler_OwnershipUnverifiable_FailsClosedInsteadOfOfferingUnknownTemplates() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/NugetScratchStorageTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/NugetScratchStorageTests.cs new file mode 100644 index 000000000..8f46cee29 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/NugetScratchStorageTests.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Diagnostics; +using NuGet.Configuration; +using NuGet.Packaging.Core; +using NuGet.Protocol.Core.Types; +using NuGet.Versioning; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class NugetScratchStorageTests +{ + private string _root = null!; + + [TestInitialize] + public void Setup() => + _root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), $"nuget-scratch-tests-{Guid.NewGuid():N}")).FullName; + + [TestCleanup] + public void Cleanup() => Directory.Delete(_root, recursive: true); + + [TestMethod] + public void BlockedScratch_FailsBeforeNugetCanLockConfiguration() + { + var blocker = Path.Combine(_root, "blocked"); + File.WriteAllText(blocker, "keep"); + var loaded = false; + var provider = new NugetSourceProvider(new CurrentDirectoryProvider(_root)) + { + ScratchDirectoryProvider = () => Path.Combine(blocker, "scratch"), + LoadSettings = _ => + { + loaded = true; + return NullSettings.Instance; + }, + }; + var elapsed = Stopwatch.StartNew(); + + var error = Assert.ThrowsExactly(() => _ = provider.Settings); + + Assert.IsFalse(loaded, "NuGet's configuration loader must not enter its lengthy lock retry."); + Assert.IsLessThan(TimeSpan.FromSeconds(2), elapsed.Elapsed); + StringAssert.Contains(error.Message, "NUGET_SCRATCH"); + StringAssert.Contains(error.Message, "all processes sharing the same packages cache"); + Assert.AreEqual("keep", File.ReadAllText(blocker)); + } + + [TestMethod] + public void WritableScratch_UsesSelectedLockNamespaceAndRemovesProbe() + { + var scratch = Path.Combine(_root, "selected-scratch"); + var previousSetting = Environment.GetEnvironmentVariable("NUGET_SCRATCH"); + var provider = new NugetSourceProvider(new CurrentDirectoryProvider(_root)) + { + ScratchDirectoryProvider = () => scratch, + }; + + provider.EnsureScratchStorage(); + + Assert.IsTrue(Directory.Exists(Path.Combine(scratch, "lock"))); + Assert.IsEmpty(Directory.GetFiles(scratch, "*", SearchOption.AllDirectories)); + Assert.AreEqual(previousSetting, Environment.GetEnvironmentVariable("NUGET_SCRATCH")); + Assert.IsFalse(Directory.Exists(Path.Combine(_root, ".winapp"))); + } + + [TestMethod] + public async Task Download_RechecksScratchAfterConfigurationWasCached() + { + var provider = new NugetSourceProvider(new CurrentDirectoryProvider(_root)) + { + ScratchDirectoryProvider = () => Path.Combine(_root, "scratch"), + LoadSettings = _ => NullSettings.Instance, + }; + _ = provider.Settings; + var blocker = Path.Combine(_root, "blocked"); + File.WriteAllText(blocker, "keep"); + provider.ScratchDirectoryProvider = () => Path.Combine(blocker, "scratch"); + var packages = Path.Combine(_root, "packages"); + using var cacheContext = new SourceCacheContext(); + + var error = await Assert.ThrowsExactlyAsync(() => + new NugetPackageDownloader(provider).DownloadPackageAsync( + new PackageIdentity("Probe.Package", new NuGetVersion("1.0.0")), + packages, cacheContext, CancellationToken.None)); + + StringAssert.Contains(error.Message, "scratch locks"); + Assert.IsFalse(Directory.Exists(packages)); + } + + [TestMethod] + [DataRow("")] + [DataRow(" ")] + [DataRow("relative-scratch")] + public void InvalidScratch_IsNotSilentlyReplaced(string path) + { + var provider = new NugetSourceProvider(new CurrentDirectoryProvider(_root)) + { + ScratchDirectoryProvider = () => path, + }; + + var error = Assert.ThrowsExactly(provider.EnsureScratchStorage); + + StringAssert.Contains(error.Message, "fully qualified"); + Assert.IsEmpty(Directory.GetFileSystemEntries(_root)); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/NugetStorageTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/NugetStorageTests.cs index 484ff131e..ac61f5980 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/NugetStorageTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/NugetStorageTests.cs @@ -103,7 +103,7 @@ public void ExplicitEnvironment_DeniedFolderFailsWithoutFallback() provider.GetEnvironmentVariable = key => key == "NUGET_PACKAGES" ? _defaultPackages : null; DenyDefaultReads(provider); - var error = Assert.ThrowsExactly(() => provider.GetPackagesDirectory()); + var error = Assert.ThrowsExactly(() => provider.GetPackagesDirectory()); StringAssert.Contains(error.Message, "explicitly configured"); Assert.IsEmpty(_diagnostics.Messages); @@ -118,7 +118,7 @@ public void ExplicitEnvironment_InvalidPathFailsWithoutFallback(string value) var provider = CreateProvider(); provider.GetEnvironmentVariable = key => key == "NUGET_PACKAGES" ? value : null; - var error = Assert.ThrowsExactly(() => provider.GetPackagesDirectory()); + var error = Assert.ThrowsExactly(() => provider.GetPackagesDirectory()); StringAssert.Contains(error.Message, "fully qualified"); Assert.IsEmpty(_diagnostics.Messages); @@ -132,7 +132,7 @@ public void ExplicitConfiguration_DeniedFolderFailsWithoutFallback() var provider = CreateProvider(); DenyDefaultReads(provider); - var error = Assert.ThrowsExactly(() => provider.GetPackagesDirectory()); + var error = Assert.ThrowsExactly(() => provider.GetPackagesDirectory()); StringAssert.Contains(error.Message, "explicitly configured"); Assert.IsEmpty(_diagnostics.Messages); @@ -145,7 +145,7 @@ public void ExplicitConfiguration_EmptyFolderFailsWithoutFallback() WriteConfig(_invocation, """"""); var provider = CreateProvider(); - var error = Assert.ThrowsExactly(() => provider.GetPackagesDirectory()); + var error = Assert.ThrowsExactly(() => provider.GetPackagesDirectory()); StringAssert.Contains(error.Message, "globalPackagesFolder"); Assert.IsFalse(Directory.Exists(LocalPackages)); @@ -191,7 +191,7 @@ public void RequiredConfigurationDenied_FailsWithoutReplacingPrivateFeeds() var provider = CreateProvider(); provider.LoadSettings = _ => throw new UnauthorizedAccessException("private nuget.config denied"); - var error = Assert.ThrowsExactly(() => provider.GetPackagesDirectory()); + var error = Assert.ThrowsExactly(() => provider.GetPackagesDirectory()); StringAssert.Contains(error.Message, "required NuGet configuration"); StringAssert.Contains(error.Message, "configured feeds and credentials cannot be replaced"); @@ -206,7 +206,7 @@ public void DefaultAndLocalUnavailable_ReportsBothWithoutSuccessWarning() DenyDefaultReads(provider); provider.WritePackagesDirectory = _ => throw new UnauthorizedAccessException("local storage denied"); - var error = Assert.ThrowsExactly(() => provider.GetPackagesDirectory()); + var error = Assert.ThrowsExactly(() => provider.GetPackagesDirectory()); StringAssert.Contains(error.Message, "default NuGet packages folder"); StringAssert.Contains(error.Message, ".winapp\\cache\\nuget\\packages could not be used"); @@ -368,13 +368,27 @@ public async Task ChildStorageFailure_PreservesErrorAndDoesNotReplay(bool tokenA } [TestMethod] - public void ChildNoRestoreBuildInAnotherDirectory_ReusesSelectedFallback() + public void ChildNoRestoreBuildInSelectedProjectScope_ReusesFallbackWithoutProbes() { Directory.CreateDirectory(_defaultPackages); var provider = CreateProvider(); + var forbidProbes = false; + var load = provider.LoadSettings; + provider.LoadSettings = path => + { + Assert.IsFalse(forbidProbes, "An already selected project scope must not reload configuration."); + return load(path); + }; + var read = provider.ReadPackagesDirectory; + provider.ReadPackagesDirectory = path => + { + Assert.IsFalse(forbidProbes, "A no-restore child must not probe previously selected package storage."); + read(path); + }; var write = provider.WritePackagesDirectory; provider.WritePackagesDirectory = path => { + Assert.IsFalse(forbidProbes, "A no-restore child must not probe writes."); if (path == _defaultPackages) { throw new UnauthorizedAccessException("read only"); @@ -384,14 +398,51 @@ public void ChildNoRestoreBuildInAnotherDirectory_ReusesSelectedFallback() provider.GetPackagesDirectory(requireWrite: true); var project = _root.CreateSubdirectory("project"); WriteConfig(project); + var dotnet = new DotNetService(provider); + dotnet.ConfigurePackageEnvironment(new ProcessStartInfo("dotnet") { WorkingDirectory = project.FullName }, ["restore"]); + forbidProbes = true; var child = new ProcessStartInfo("dotnet") { WorkingDirectory = project.FullName }; - new DotNetService(provider).ConfigurePackageEnvironment(child, ["build", "--no-restore"]); + dotnet.ConfigurePackageEnvironment(child, ["build", "--no-restore"]); Assert.AreEqual(LocalPackages, child.Environment["NUGET_PACKAGES"]); Assert.HasCount(1, _diagnostics.Messages); } + [TestMethod] + public void EvaluationInUnselectedProjectScope_DoesNotOverrideProjectConfiguration() + { + var provider = CreateProvider(); + DenyDefaultReads(provider); + provider.GetPackagesDirectory(); + var project = _root.CreateSubdirectory("project"); + var projectPackages = Path.Combine(project.FullName, "project-packages"); + WriteConfig(project, $""""""); + provider.LoadSettings = _ => throw new AssertFailedException("Evaluation must leave an unseen project's NuGet configuration to dotnet."); + var child = new ProcessStartInfo("dotnet") { WorkingDirectory = project.FullName }; + child.Environment.Remove("NUGET_PACKAGES"); + + new DotNetService(provider).ConfigurePackageEnvironment(child, ["msbuild", "App.csproj", "--getProperty:TargetPath"]); + + Assert.IsFalse(child.Environment.ContainsKey("NUGET_PACKAGES"), "The invocation's fallback must not override an unseen project's explicit package folder."); + } + + [TestMethod] + public void EvaluationInInvocationScope_ReusesSelectedFallbackWithoutProbes() + { + var provider = CreateProvider(); + DenyDefaultReads(provider); + provider.GetPackagesDirectory(); + provider.LoadSettings = _ => throw new AssertFailedException("Evaluation must not reload configuration."); + provider.ReadPackagesDirectory = _ => Assert.Fail("Evaluation must not probe storage."); + provider.WritePackagesDirectory = _ => Assert.Fail("Evaluation must not probe storage."); + var child = new ProcessStartInfo("dotnet") { WorkingDirectory = _invocation.FullName }; + + new DotNetService(provider).ConfigurePackageEnvironment(child, ["msbuild", "App.csproj", "--getProperty:TargetPath"]); + + Assert.AreEqual(LocalPackages, child.Environment["NUGET_PACKAGES"]); + } + [TestMethod] public void ChildExplicitConfiguration_IsNotReplacedByPreviouslySelectedFallback() { @@ -402,7 +453,7 @@ public void ChildExplicitConfiguration_IsNotReplacedByPreviouslySelectedFallback WriteConfig(project, $""""""); var child = new ProcessStartInfo("dotnet") { WorkingDirectory = project.FullName }; - var error = Assert.ThrowsExactly( + var error = Assert.ThrowsExactly( () => new DotNetService(provider).ConfigurePackageEnvironment(child, ["publish"])); StringAssert.Contains(error.Message, "explicitly configured"); @@ -485,12 +536,12 @@ private void AssertFallbackLinkRejected(NugetSourceProvider provider) { var child = new ProcessStartInfo("dotnet") { WorkingDirectory = _invocation.FullName }; child.Environment.Remove("NUGET_PACKAGES"); - var childError = Assert.ThrowsExactly( + var childError = Assert.ThrowsExactly( () => new DotNetService(provider).ConfigurePackageEnvironment(child, ["restore"])); StringAssert.Contains(childError.Message, "link or reparse point"); Assert.IsFalse(child.Environment.ContainsKey("NUGET_PACKAGES"), "Unsafe local storage must not be exposed to a child."); var service = new NugetService(provider, new NugetPackageDownloader(provider)); - var inProcessError = Assert.ThrowsExactly( + var inProcessError = Assert.ThrowsExactly( () => service.GetNuGetPackageDir("Linked.Package", "1.0.0")); StringAssert.Contains(inProcessError.Message, "link or reparse point"); } @@ -514,6 +565,186 @@ public void UnrelatedDotnetCommands_DoNotLoadNuGetOrCreateStorage(string verb, s Assert.IsFalse(Directory.Exists(_defaultPackages)); } + [TestMethod] + [DataRow("build", "--no-restore")] + [DataRow("publish", "--no-restore")] + [DataRow("publish", "--no-build")] + [DataRow("run", "--no-build")] + [DataRow("run", "--no-restore")] + [DataRow("msbuild", "--getProperty:TargetPath")] + [DataRow("msbuild", "-getProperty:TargetPath")] + [DataRow("msbuild", "/getProperty:TargetPath")] + [DataRow("msbuild", "--getItem:Compile")] + [DataRow("build", "--getProperty:TargetPath")] + public void EvaluationOrNoRestore_DoesNotLoadNuGetOrProbeStorage(string verb, string argument) + { + var provider = CreateProvider(); + provider.LoadSettings = _ => throw new AssertFailedException("Evaluation without restore must not load NuGet configuration."); + provider.ReadPackagesDirectory = _ => Assert.Fail("Evaluation without restore must not probe package storage."); + provider.WritePackagesDirectory = _ => Assert.Fail("Evaluation without restore must not probe package storage."); + var child = new ProcessStartInfo("dotnet") { WorkingDirectory = _invocation.FullName }; + child.Environment.Remove("NUGET_PACKAGES"); + + new DotNetService(provider).ConfigurePackageEnvironment(child, [verb, "App.csproj", argument]); + + Assert.IsFalse(child.Environment.ContainsKey("NUGET_PACKAGES")); + Assert.IsFalse(Directory.Exists(LocalPackages)); + Assert.IsFalse(Directory.Exists(_defaultPackages)); + } + + [TestMethod] + [DataRow("build")] + [DataRow("publish")] + [DataRow("run")] + public void RestoreDisabledWithRuntimeArgument_DoesNotSelectPackages(string verb) + { + var provider = CreateProvider(); + provider.LoadSettings = _ => throw new AssertFailedException("-r is a runtime identifier, not an MSBuild restore request."); + var child = new ProcessStartInfo("dotnet") { WorkingDirectory = _invocation.FullName }; + + new DotNetService(provider).ConfigurePackageEnvironment(child, + [verb, "App.csproj", "--no-restore", "-r", "win-arm64"]); + + Assert.IsFalse(Directory.Exists(_defaultPackages)); + } + + [TestMethod] + public void RunApplicationArguments_DoNotDisablePackagePreparation() + { + var provider = CreateProvider(); + var child = new ProcessStartInfo("dotnet") { WorkingDirectory = _invocation.FullName }; + + new DotNetService(provider).ConfigurePackageEnvironment(child, + ["run", "--", "--no-build", "--no-restore"]); + + Assert.AreEqual(_defaultPackages, child.Environment["NUGET_PACKAGES"]); + } + + [TestMethod] + [DataRow("-t:Restore")] + [DataRow("--target:Build")] + [DataRow("/target:Build")] + [DataRow("-restore")] + [DataRow("/r")] + [DataRow("--getTargetResult:Build")] + [DataRow("@restore.rsp")] + public void PropertyQueryWithTargetOrRestore_StillSelectsPackageStorage(string option) + { + var provider = CreateProvider(); + var child = new ProcessStartInfo("dotnet") { WorkingDirectory = _invocation.FullName }; + + new DotNetService(provider).ConfigurePackageEnvironment(child, + ["msbuild", "App.csproj", "--getProperty:TargetPath", option]); + + Assert.AreEqual(_defaultPackages, child.Environment["NUGET_PACKAGES"]); + } + + [TestMethod] + [DataRow(false, "msbuild")] + [DataRow(true, "msbuild")] + [DataRow(false, "build")] + [DataRow(true, "build")] + public async Task PropertyEvaluation_StandardSdkSucceedsWithoutNuGetConfiguration(bool tokenArguments, string verb) + { + var project = Path.Combine(_invocation.FullName, "App.csproj"); + File.WriteAllText(project, """ + + net10.0Exe + + + """); + var direct = new ProcessStartInfo("dotnet") + { + WorkingDirectory = _invocation.FullName, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + ArgumentList = { verb, project, "--getProperty:TargetPath" }, + }; + var baseline = await DotNetService.RunDotnetProcessAsync(direct, TestContext.CancellationToken); + Assert.AreEqual(0, baseline.ExitCode, baseline.Error + baseline.Output); + StringAssert.Contains(baseline.Output, "App.dll"); + + var provider = CreateProvider(); + provider.LoadSettings = _ => throw new UnauthorizedAccessException("user nuget.config denied"); + var dotnet = new DotNetService(provider); + var result = tokenArguments + ? await dotnet.RunDotnetCommandAsync(_invocation, [verb, project, "--getProperty:TargetPath"], + cancellationToken: TestContext.CancellationToken) + : await dotnet.RunDotnetCommandAsync(_invocation, $"{verb} \"{project}\" --getProperty:TargetPath", + TestContext.CancellationToken); + + Assert.AreEqual(0, result.ExitCode, result.Error + result.Output); + Assert.AreEqual(baseline.Output, result.Output); + Assert.IsFalse(Directory.Exists(LocalPackages)); + } + + [TestMethod] + [DoNotParallelize] + [DataRow(false)] + [DataRow(true)] + public async Task CompletedFallbackGraph_IsReusedAfterStorageSwitch(bool failDuringDownload) + { + var feed = _root.CreateSubdirectory("feed"); + WritePackage(feed, "Root.Package", "Child.Package"); + WritePackage(feed, "Child.Package"); + WriteConfig(_invocation, $""""""); + var previousProvider = CreateProvider(); + DenyDefaultReads(previousProvider); + var previous = new NugetService(previousProvider, new NugetPackageDownloader(previousProvider)); + await previous.InstallPackageAsync("Root.Package", "1.0.0", CreateTaskContext(), TestContext.CancellationToken); + WriteConfig(_invocation, $""" + + + """); + + Directory.CreateDirectory(_defaultPackages); + var provider = CreateProvider(); + if (failDuringDownload) + { + File.WriteAllText(Path.Combine(_defaultPackages, "root.package"), "blocks package extraction"); + } + else + { + feed.Delete(recursive: true); + var write = provider.WritePackagesDirectory; + provider.WritePackagesDirectory = path => + { + if (path == _defaultPackages) + { + throw new UnauthorizedAccessException("read only"); + } + write(path); + }; + } + var downloadAttempts = 0; + var downloader = new NugetPackageDownloader(provider) + { + DeleteTempFile = path => + { + downloadAttempts++; + File.Delete(path); + if (feed.Exists) + { + feed.Delete(recursive: true); + } + }, + }; + var service = new NugetService(provider, downloader); + + var graph = await service.InstallPackageAsync("Root.Package", "1.0", CreateTaskContext(), TestContext.CancellationToken); + + Assert.AreEqual(failDuringDownload ? 1 : 0, downloadAttempts, "No download may be attempted after selecting the completed fallback."); + Assert.HasCount(2, graph); + Assert.AreEqual(LocalPackages, service.GetNuGetGlobalPackagesDir().FullName); + foreach (var package in graph) + { + Assert.AreEqual("1.0.0", package.Value); + Assert.IsTrue(service.IsPackageInstalled(package.Key, package.Value)); + StringAssert.StartsWith(service.GetNuGetPackageDir(package.Key, package.Value).FullName, LocalPackages); + } + } + [TestMethod] public async Task MissingDependencyInReadOnlyCache_MovesTheWholeGraphToFallback() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/TargetStateDirectoryProviderTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/TargetStateDirectoryProviderTests.cs index bc1352774..cc390201b 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/TargetStateDirectoryProviderTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/TargetStateDirectoryProviderTests.cs @@ -17,7 +17,7 @@ public class TargetStateDirectoryProviderTests [TestInitialize] public void Setup() { - _testRoot = Path.GetFullPath($"target-state-provider-tests-{Guid.NewGuid():N}"); + _testRoot = Path.Combine(Path.GetTempPath(), $"target-state-provider-tests-{Guid.NewGuid():N}"); Directory.CreateDirectory(_testRoot); _previousRoot = Environment.GetEnvironmentVariable(TargetStateDirectoryProvider.RootOverrideVariable); Environment.SetEnvironmentVariable(TargetStateDirectoryProvider.RootOverrideVariable, null); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs index b05e67d77..ba84731fc 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/XamlTriageBinariesTests.cs @@ -189,6 +189,58 @@ public void ResolveExisting_JsProviderFailsVerification_ReturnsNull() Assert.IsNull(resolved, "A JsProvider.dll that fails signature verification must not resolve."); } + [TestMethod] + [DataRow("dbgeng.dll")] + [DataRow("dbghelp.dll")] + [DataRow("dbgcore.dll")] + [DataRow("dbgmodel.dll")] + [DataRow("msdia140.dll")] + [DataRow("symsrv.dll")] + [DataRow("winext/JsProvider.dll")] + [DataRow("extra.dll")] + public void LocalCacheLayout_RejectsAnyUntrustedLoadable(string untrusted) + { + var files = new[] { "dbgeng.dll", "dbghelp.dll", "dbgcore.dll", "dbgmodel.dll", + "msdia140.dll", "symsrv.dll", "winext/JsProvider.dll", "extra.dll" }; + Directory.CreateDirectory(Path.Combine(_tempDir, "winext")); + foreach (var file in files) + { + File.WriteAllText(Path.Combine(_tempDir, file), "fixture"); + } + var untrustedPath = Path.GetFullPath(Path.Combine(_tempDir, untrusted)); + var verified = new List(); + + var trusted = XamlTriageBinaries.IsTrustedCacheLayout(_tempDir, NullLogger.Instance, + (path, _) => { verified.Add(path); return path != untrustedPath; }); + + Assert.IsFalse(trusted, "A valid provider or matching editable version must not authorize another DLL."); + CollectionAssert.Contains(verified, untrustedPath); + } + + [TestMethod] + public void LocalCacheLayout_AcceptsAuthenticatedCompleteLayout() + { + var files = new[] { "dbgeng.dll", "dbghelp.dll", "dbgcore.dll", "dbgmodel.dll", "msdia140.dll", "JsProvider.dll" }; + foreach (var file in files) + { + File.WriteAllText(Path.Combine(_tempDir, file), "fixture"); + } + var verified = new List(); + + Assert.IsTrue(XamlTriageBinaries.IsTrustedCacheLayout(_tempDir, NullLogger.Instance, + (path, _) => { verified.Add(Path.GetFileName(path)); return true; })); + CollectionAssert.AreEquivalent(files, verified); + } + + [TestMethod] + public void LocalCacheLayout_MissingRequiredEngineDependency_IsRejected() + { + File.WriteAllText(Path.Combine(_tempDir, "dbgeng.dll"), "fixture"); + File.WriteAllText(Path.Combine(_tempDir, "JsProvider.dll"), "fixture"); + + Assert.IsFalse(XamlTriageBinaries.IsTrustedCacheLayout(_tempDir, NullLogger.Instance, (_, _) => true)); + } + [TestMethod] public void ResolveExisting_JsProviderInRoot_PrefersRootPath() { diff --git a/src/winapp-CLI/WinApp.Cli/Commands/NewCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/NewCommand.cs index de71541a7..7fc50da35 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/NewCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/NewCommand.cs @@ -700,31 +700,40 @@ private async Task InvokeCoreAsync(ParseResult parseResult, InvocationTelem int exitCode; string stdout; string stderr; - if (logger.IsEnabled(LogLevel.Debug)) - { - // Verbose: stream dotnet new's output live so its post-creation actions (restore, - // package add, etc.) are visible as they run. Buffering them behind the spinner hides - // the very output needed to diagnose a failing post action (#753). The lines are still - // captured into stdout/stderr so a non-zero exit can surface a concise failure detail - // below, and LogDotnetOutput is skipped to avoid echoing the same text twice. Streaming - // the real output also supersedes the spinner's delayed "restoring…" status message. - logger.LogDebug("dotnet {Args}", string.Join(' ', args)); - (exitCode, stdout, stderr) = await dotNetService.RunDotnetCommandAsync( - workingDir, - args, - onOutputLine: line => logger.LogDebug("{Output}", line), - onErrorLine: line => logger.LogDebug("{Output}", line), - cancellationToken: cancellationToken); - logger.LogDebug("dotnet new exited with code {ExitCode}", exitCode); + string? preparationError = null; + try + { + if (logger.IsEnabled(LogLevel.Debug)) + { + // Verbose: stream dotnet new's output live so its post-creation actions (restore, + // package add, etc.) are visible as they run. Buffering them behind the spinner hides + // the very output needed to diagnose a failing post action (#753). The lines are still + // captured into stdout/stderr so a non-zero exit can surface a concise failure detail + // below, and LogDotnetOutput is skipped to avoid echoing the same text twice. Streaming + // the real output also supersedes the spinner's delayed "restoring…" status message. + logger.LogDebug("dotnet {Args}", string.Join(' ', args)); + (exitCode, stdout, stderr) = await dotNetService.RunDotnetCommandAsync( + workingDir, + args, + onOutputLine: line => logger.LogDebug("{Output}", line), + onErrorLine: line => logger.LogDebug("{Output}", line), + cancellationToken: cancellationToken); + logger.LogDebug("dotnet new exited with code {ExitCode}", exitCode); + } + else + { + (exitCode, stdout, stderr) = await WithSpinnerAsync( + scaffoldStatus, + "Setting up the project; missing NuGet packages are restoring…", + ScaffoldStatusDelay, + () => dotNetService.RunDotnetCommandAsync(workingDir, args, cancellationToken: cancellationToken)); + LogDotnetOutput(args, exitCode, stdout, stderr); + } } - else + catch (Exception ex) when (ex is NugetStorageException or IOException or UnauthorizedAccessException) { - (exitCode, stdout, stderr) = await WithSpinnerAsync( - scaffoldStatus, - "Setting up the project; missing NuGet packages are restoring…", - ScaffoldStatusDelay, - () => dotNetService.RunDotnetCommandAsync(workingDir, args, cancellationToken: cancellationToken)); - LogDotnetOutput(args, exitCode, stdout, stderr); + preparationError = $"Could not prepare dotnet new: {NugetErrorMessage.Redact(ex.Message)}"; + (exitCode, stdout, stderr) = (ExitScaffoldFailed, string.Empty, preparationError); } if (exitCode != 0) { @@ -732,7 +741,7 @@ private async Task InvokeCoreAsync(ParseResult parseResult, InvocationTelem if (isJson) { PrintJson(false, entry.ShortName, name!, outputDir.FullName, - $"dotnet new failed (exit code {exitCode}): {detail}", entry.IsExperimental); + preparationError ?? $"dotnet new failed (exit code {exitCode}): {detail}", entry.IsExperimental); } else { diff --git a/src/winapp-CLI/WinApp.Cli/Services/CacheStorage.cs b/src/winapp-CLI/WinApp.Cli/Services/CacheStorage.cs index 6aa2a8b7e..da0c7c968 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/CacheStorage.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/CacheStorage.cs @@ -19,6 +19,7 @@ internal sealed class CacheStorage( public string DirectoryPath => ResolvePath(); public bool IsExplicit => directories.IsGlobalCacheOverridden; + internal bool IsLocalFallback => _local; public void Clear(Action clear) { diff --git a/src/winapp-CLI/WinApp.Cli/Services/DotNetService.cs b/src/winapp-CLI/WinApp.Cli/Services/DotNetService.cs index 6f7e93623..91d2aca83 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/DotNetService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/DotNetService.cs @@ -590,7 +590,30 @@ internal void ConfigurePackageEnvironment(ProcessStartInfo startInfo, IReadOnlyL return; } - sourceProvider.ConfigureChildProcessPackages(startInfo); + var commandArguments = arguments.TakeWhile(arg => arg != "--").ToArray(); + var options = commandArguments + .Where(arg => arg.StartsWith('-') || arg.StartsWith('/')) + .Select(arg => arg.TrimStart('-', '/')) + .ToArray(); + static bool IsOption(string option, string name) => + option.Equals(name, StringComparison.OrdinalIgnoreCase) + || option.StartsWith(name + ":", StringComparison.OrdinalIgnoreCase); + + var hasTargetsOrRestore = options.Any(option => + IsOption(option, "target") || IsOption(option, "t") + || IsOption(option, "restore") || verb == "msbuild" && IsOption(option, "r") + || IsOption(option, "getTargetResult")); + var evaluationOnly = verb is "msbuild" or "build" + && options.Any(option => IsOption(option, "getProperty") || IsOption(option, "getItem")) + && !hasTargetsOrRestore; + var restoreDisabled = verb is "build" or "publish" or "run" or "add" or "list" or "package" + && (commandArguments.Contains("--no-restore") + || verb is "run" or "publish" && commandArguments.Contains("--no-build")) + && !hasTargetsOrRestore; + // Queries without targets and restore-disabled operations must not acquire a new NuGet + // prerequisite. Still propagate storage already selected for this exact project scope. + var reuseOnly = (evaluationOnly || restoreDisabled) && !commandArguments.Any(arg => arg.StartsWith('@')); + sourceProvider.ConfigureChildProcessPackages(startInfo, selectPackages: !reuseOnly); } private static bool IsStorageAccessFailure(string message) => diff --git a/src/winapp-CLI/WinApp.Cli/Services/LayoutLease.cs b/src/winapp-CLI/WinApp.Cli/Services/LayoutLease.cs index c4e7c7392..e96c22577 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/LayoutLease.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/LayoutLease.cs @@ -60,7 +60,7 @@ internal static LayoutLease Acquire( var elapsed = Stopwatch.StartNew(); var waitLimit = timeout ?? DefaultTimeout; openLock ??= path => new FileStream( - path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, bufferSize: 1, FileOptions.DeleteOnClose); + path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, bufferSize: 1, FileOptions.None); while (true) { @@ -68,8 +68,9 @@ internal static LayoutLease Acquire( try { - // The kernel releases the handle (and removes the file) even if a process is killed. - // Never delete the directory on release: another layout or waiter may be using it. + // The kernel releases the exclusive handle even if a process is killed. Keep the + // file: DeleteOnClose can make the next opener get access-denied while metadata + // handles held by a scanner or watcher keep deletion pending. return new LayoutLease(openLock(lockPath)); } catch (IOException ex) when (IsContention(ex)) diff --git a/src/winapp-CLI/WinApp.Cli/Services/MSStoreCLIService.cs b/src/winapp-CLI/WinApp.Cli/Services/MSStoreCLIService.cs index 19fd8a8ad..0cd62e0d4 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/MSStoreCLIService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/MSStoreCLIService.cs @@ -6,6 +6,7 @@ using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text.Json; +using WinApp.Cli.Helpers; namespace WinApp.Cli.Services; @@ -27,6 +28,8 @@ internal class MSStoreCLIService( // drive both the arch-specific asset selection and the unsupported-architecture path. internal Func OsArchitectureProvider { get; set; } = () => RuntimeInformation.OSArchitecture; + internal Func SignatureVerifier { get; set; } = AuthenticodeVerifier.IsTrustedMicrosoftSigned; + private const string ExeName = "msstore.exe"; private const string GitHubApiLatestRelease = "https://api.github.com/repos/microsoft/msstore-cli/releases/latest"; @@ -72,6 +75,7 @@ await _cache.RunAsync(async installDir => logger.LogDebug("Extracting MSStoreCLI to {InstallDir}", installDir); await ZipFile.ExtractToDirectoryAsync(zipPath, installDir, overwriteFiles: true, cancellationToken: cancellationToken); + VerifyLocalTool(installDir); logger.LogDebug("MSStoreCLI {Version} installed to {InstallDir}", version, installDir); } finally @@ -203,7 +207,11 @@ private static string ComputeSha256Hash(string filePath) public string GetMSStoreCLIPath() { - return _cache.Run(root => Path.Combine(root, ExeName)); + return _cache.Run(root => + { + VerifyLocalTool(root); + return Path.Combine(root, ExeName); + }); } private bool IsMSStoreCLIAvailable(string installDir) @@ -213,8 +221,31 @@ private bool IsMSStoreCLIAvailable(string installDir) if (exists) { using var readable = File.Open(exePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + VerifyLocalTool(installDir); logger.LogDebug("MSStoreCLI found at {ExePath}", exePath); } return exists; } + + private void VerifyLocalTool(string installDir) + { + if (!_cache.IsLocalFallback) + { + return; + } + + // A repository can prepopulate the fallback cache. Verify the actual executable and + // co-located native dependencies on every use, not a repository-provided receipt. + // The official Windows MSStoreCLI distribution is Microsoft Authenticode-signed. + foreach (var path in new[] { Path.Combine(installDir, ExeName) } + .Concat(Directory.EnumerateFiles(installDir, "*.dll", SearchOption.AllDirectories))) + { + if (!SignatureVerifier(path, logger)) + { + throw new InvalidOperationException( + $"MSStoreCLI local cache file '{path}' is not validly signed by Microsoft, so the tool was not run. " + + $"Remove the local tool cache '{installDir}' and retry to download a verified copy."); + } + } + } } diff --git a/src/winapp-CLI/WinApp.Cli/Services/NugetPackageDownloader.cs b/src/winapp-CLI/WinApp.Cli/Services/NugetPackageDownloader.cs index 2504035df..220ab7e50 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/NugetPackageDownloader.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/NugetPackageDownloader.cs @@ -43,6 +43,7 @@ internal async Task DownloadPackageAsync(PackageIdentity identity, string global var package = identity.Id; var version = identity.Version.ToNormalizedString(); var clientPolicyContext = ClientPolicyContext.GetClientPolicy(_sourceProvider.Settings, Logger); + _sourceProvider.EnsureScratchStorage(); _sourceProvider.ValidatePackagePath(globalPackagesFolder, new VersionFolderPathResolver(globalPackagesFolder).GetInstallPath(identity.Id, identity.Version)); diff --git a/src/winapp-CLI/WinApp.Cli/Services/NugetService.cs b/src/winapp-CLI/WinApp.Cli/Services/NugetService.cs index 8df97f21e..d9d5db309 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/NugetService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/NugetService.cs @@ -338,33 +338,44 @@ private async Task InstallPackageRecursiveAsync(string package, string version, // can leave a partial folder with no ".nupkg.metadata" marker. Accepting that corrupt entry would let // ReadDependenciesFromNuspec return an empty set and restore report a truncated graph as success. When // the marker is missing, fall through so the downloader re-extracts and completes the entry. - if (HasCompletionMarker(packageDir)) + var downloaded = false; + if (!HasCompletionMarker(packageDir)) { - taskContext.AddDebugMessage($"{UiSymbols.Skip} {package} {normalizedVersion} already present"); - graph.Installed[package] = normalizedVersion; - // Still resolve dependencies to populate installed dictionary - await ResolveDependenciesAsync(packageDir, package, normalizedVersion, graph, taskContext, cacheContext, cancellationToken); - return; + var identity = new PackageIdentity(package, ParseVersion(package, normalizedVersion)); + var packagesFolder = _sourceProvider.GetPackagesDirectory(requireWrite: true).FullName; + // Selecting writable storage can switch roots. The fallback may already contain the + // complete package from an earlier invocation, even when every feed is now offline. + packageDir = GetNuGetPackageDir(package, normalizedVersion); + if (!HasCompletionMarker(packageDir)) + { + try + { + await _downloader.DownloadPackageAsync(identity, packagesFolder, cacheContext, cancellationToken); + downloaded = true; + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) + { + packagesFolder = _sourceProvider.UseLocalPackagesDirectoryAfterFailure(ex, packagesFolder).FullName; + packageDir = GetNuGetPackageDir(package, normalizedVersion); + if (!HasCompletionMarker(packageDir)) + { + await _downloader.DownloadPackageAsync(identity, packagesFolder, cacheContext, cancellationToken); + downloaded = true; + } + } + packageDir = GetNuGetPackageDir(package, normalizedVersion); + } } - // Download and extract the package from the user's configured NuGet sources into the - // global packages folder (using the standard NuGet on-disk layout). Throws with the - // underlying source error if no configured source can provide the package. - var identity = new PackageIdentity(package, ParseVersion(package, normalizedVersion)); - var packagesFolder = _sourceProvider.GetPackagesDirectory(requireWrite: true).FullName; - try + graph.Installed[package] = normalizedVersion; + if (downloaded) { - await _downloader.DownloadPackageAsync(identity, packagesFolder, cacheContext, cancellationToken); + taskContext.AddStatusMessage($"{UiSymbols.Check} Installed {package} {normalizedVersion}"); } - catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) + else { - packagesFolder = _sourceProvider.UseLocalPackagesDirectoryAfterFailure(ex, packagesFolder).FullName; - await _downloader.DownloadPackageAsync(identity, packagesFolder, cacheContext, cancellationToken); + taskContext.AddDebugMessage($"{UiSymbols.Skip} {package} {normalizedVersion} already present"); } - packageDir = GetNuGetPackageDir(package, normalizedVersion); - - graph.Installed[package] = normalizedVersion; - taskContext.AddStatusMessage($"{UiSymbols.Check} Installed {package} {normalizedVersion}"); // Recursively install dependencies await ResolveDependenciesAsync(packageDir, package, normalizedVersion, graph, taskContext, cacheContext, cancellationToken); diff --git a/src/winapp-CLI/WinApp.Cli/Services/NugetSourceProvider.cs b/src/winapp-CLI/WinApp.Cli/Services/NugetSourceProvider.cs index ac3d7a018..6c77819d7 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/NugetSourceProvider.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/NugetSourceProvider.cs @@ -53,11 +53,14 @@ internal sealed class NugetSourceProvider private readonly Dictionary _fallbackPackagesFolders; private readonly HashSet _writablePackagesFolders; private readonly Dictionary _childProviders = new(StringComparer.OrdinalIgnoreCase); + private string? _selectedPackagesPath; internal Func LoadSettings { get; set; } = root => NuGet.Configuration.Settings.LoadDefaultSettings(root); internal Func GetEnvironmentVariable { get; set; } = Environment.GetEnvironmentVariable; internal Func ResolveGlobalPackagesFolder { get; set; } = SettingsUtility.GetGlobalPackagesFolder; + internal Func ScratchDirectoryProvider { get; set; } = + () => NuGetEnvironment.GetFolderPath(NuGetFolderPath.Temp); internal Action ReadPackagesDirectory { get; set; } = path => { using var entries = Directory.EnumerateFileSystemEntries(path).GetEnumerator(); @@ -112,16 +115,18 @@ private NugetSourceProvider( [MemberNotNull(nameof(_settings), nameof(_sourceRepositoryProvider), nameof(_packageSourceMapping), nameof(_configScopeKey), nameof(_packagesLocation))] private void InitializeCaches() { + _selectedPackagesPath = null; _settings = new Lazy(() => { var root = _configRoot?.FullName ?? _currentDirectoryProvider.GetCurrentDirectory(); try { + EnsureScratchStorage(); return LoadSettings(root); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NuGetConfigurationException) { - throw new InvalidOperationException( + throw new NugetStorageException( $"Could not load the required NuGet configuration for '{root}': {NugetErrorMessage.Redact(ex.Message)} " + "Restore access to the nuget.config hierarchy; configured feeds and credentials cannot be replaced."); } @@ -137,14 +142,14 @@ private void InitializeCaches() if (environmentFolder is not null && (string.IsNullOrWhiteSpace(environmentFolder) || !Path.IsPathFullyQualified(environmentFolder))) { - throw new InvalidOperationException("NUGET_PACKAGES must specify a fully qualified packages directory. Correct the explicit setting."); + throw new NugetStorageException("NUGET_PACKAGES must specify a fully qualified packages directory. Correct the explicit setting."); } var configuredFolder = Settings.GetSection("config")?.Items.OfType() .FirstOrDefault(item => string.Equals(item.Key, "globalPackagesFolder", StringComparison.OrdinalIgnoreCase)); if (environmentFolder is null && configuredFolder is not null && string.IsNullOrWhiteSpace(configuredFolder.Value)) { - throw new InvalidOperationException("globalPackagesFolder in nuget.config must specify a packages directory. Correct the explicit setting."); + throw new NugetStorageException("globalPackagesFolder in nuget.config must specify a packages directory. Correct the explicit setting."); } var explicitlyConfigured = environmentFolder is not null || configuredFolder is not null; @@ -212,6 +217,33 @@ internal void SetConfigRoot(DirectoryInfo configRoot) /// internal ISettings Settings => _settings.Value; + /// NuGet uses this namespace for both configuration and package installation locks. + internal void EnsureScratchStorage() + { + var scratch = ScratchDirectoryProvider(); + if (string.IsNullOrWhiteSpace(scratch) || !Path.IsPathFullyQualified(scratch)) + { + throw new NugetStorageException( + "NuGet scratch storage must be a fully qualified writable directory. Correct NUGET_SCRATCH before retrying."); + } + + var locks = Path.Combine(scratch, "lock"); + try + { + Directory.CreateDirectory(locks); + using var probe = new FileStream( + Path.Combine(locks, $"winapp-probe-{Guid.NewGuid():N}"), + FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, 1, FileOptions.DeleteOnClose); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + throw new NugetStorageException( + $"NuGet requires writable scratch locks at '{locks}', but that location is unavailable: {NugetErrorMessage.Redact(ex.Message)} " + + "Allow access or set NUGET_SCRATCH to one permitted writable directory used consistently by all processes sharing the same packages cache. " + + "winapp does not silently relocate shared NuGet locks."); + } + } + /// /// A stable fingerprint of the effective configuration (global packages folder, enabled sources in their /// configured order and the full <packageSourceMapping> entries). Consumers that maintain a @@ -237,6 +269,7 @@ internal DirectoryInfo GetPackagesDirectory(bool requireWrite = false) path = GetLocalPackagesDirectory().FullName; } EnsurePackagesDirectory(path, requireWrite); + _selectedPackagesPath = path; return new DirectoryInfo(path); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) @@ -259,7 +292,7 @@ internal DirectoryInfo UseLocalPackagesDirectoryAfterFailure(Exception error, st if (explicitlyConfigured || _fallbackPackagesFolders.ContainsKey(configuredPath)) { var path = explicitlyConfigured ? configuredPath : _fallbackPackagesFolders[configuredPath]; - throw new InvalidOperationException( + throw new NugetStorageException( $"The {(explicitlyConfigured ? "explicitly configured" : "local fallback")} NuGet packages folder '{path}' is unavailable: " + $"{NugetErrorMessage.Redact(error.Message)} Fix its permissions or the NUGET_PACKAGES/globalPackagesFolder setting."); } @@ -272,12 +305,13 @@ internal DirectoryInfo UseLocalPackagesDirectoryAfterFailure(Exception error, st } catch (Exception fallbackError) when (fallbackError is IOException or UnauthorizedAccessException) { - throw new InvalidOperationException( + throw new NugetStorageException( $"The default NuGet packages folder '{configuredPath}' is unavailable, and the invocation directory's .winapp\\cache\\nuget\\packages could not be used: " + $"{NugetErrorMessage.Redact(fallbackError.Message)} Restore access or configure a writable NUGET_PACKAGES folder."); } _fallbackPackagesFolders[configuredPath] = fallback.FullName; + _selectedPackagesPath = fallback.FullName; _storageDiagnostics?.Warning("nuget-packages-fallback", $"The default NuGet packages folder '{configuredPath}' is unavailable. Using '{fallback.FullName}' for this invocation."); return fallback; @@ -324,13 +358,26 @@ internal void ValidatePackagePath(string packagesFolder, string packagePath) } } - internal void ConfigureChildProcessPackages(ProcessStartInfo startInfo) + internal void ConfigureChildProcessPackages(ProcessStartInfo startInfo, bool selectPackages = true) { NugetSourceProvider child; lock (_packagesLock) { - var root = Path.GetFullPath(startInfo.WorkingDirectory); - if (string.Equals(root, _configRoot?.FullName ?? _currentDirectoryProvider.GetCurrentDirectory(), StringComparison.OrdinalIgnoreCase)) + var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(startInfo.WorkingDirectory)); + var configuredRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath( + _configRoot?.FullName ?? _currentDirectoryProvider.GetCurrentDirectory())); + if (!selectPackages) + { + var selected = string.Equals(root, configuredRoot, StringComparison.OrdinalIgnoreCase) + ? _selectedPackagesPath + : _childProviders.GetValueOrDefault(root)?._selectedPackagesPath; + if (selected is not null) + { + startInfo.Environment["NUGET_PACKAGES"] = selected; + } + return; + } + if (string.Equals(root, configuredRoot, StringComparison.OrdinalIgnoreCase)) { child = this; } @@ -341,6 +388,7 @@ internal void ConfigureChildProcessPackages(ProcessStartInfo startInfo) LoadSettings = LoadSettings, GetEnvironmentVariable = GetEnvironmentVariable, ResolveGlobalPackagesFolder = ResolveGlobalPackagesFolder, + ScratchDirectoryProvider = ScratchDirectoryProvider, ReadPackagesDirectory = ReadPackagesDirectory, WritePackagesDirectory = WritePackagesDirectory, }; diff --git a/src/winapp-CLI/WinApp.Cli/Services/NugetStorageException.cs b/src/winapp-CLI/WinApp.Cli/Services/NugetStorageException.cs new file mode 100644 index 000000000..d99f21264 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/NugetStorageException.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Services; + +/// Required NuGet storage or configuration is unavailable or explicitly invalid. +internal sealed class NugetStorageException(string message) : InvalidOperationException(message) +{ +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs index 2134e47a8..1107ffaee 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageBinaries.cs @@ -133,15 +133,43 @@ private static readonly (string Package, string Version, string Sha512, string[] /// it is loaded into the debugger process, and a copy that was replaced on disk, or that drifted /// from the engine build (which crashes the triage child with STATUS_BREAKPOINT), must be rejected /// so the cache self-heals instead of silently breaking triage. + /// When is set, the repository-local cache additionally + /// requires authentication of the engine and its co-located loadable dependencies. /// /// - public static ResolvedTriageBinaries? ResolveExisting(DirectoryInfo cacheBinDir, ILogger logger) => + public static ResolvedTriageBinaries? ResolveExisting(DirectoryInfo cacheBinDir, ILogger logger, bool requireTrustedCache = false) => ResolveExisting(cacheBinDir, logger, b => AuthenticodeVerifier.IsTrustedMicrosoftSigned(b.JsProviderPath, logger) - && IsProviderCompatibleWithEngine(b.BinDir, b.JsProviderPath, logger)); + && IsProviderCompatibleWithEngine(b.BinDir, b.JsProviderPath, logger) + && (!requireTrustedCache + || !Path.GetFullPath(b.BinDir).Equals(cacheBinDir.FullName, StringComparison.OrdinalIgnoreCase) + || IsTrustedCacheLayout(b.BinDir, logger, AuthenticodeVerifier.IsTrustedMicrosoftSigned))); /// - /// Testable core of with an injectable + /// A repository-local cache is executable input, not just cached data. Product versions + /// establish compatibility, never authenticity. Require the engine payload and authenticate + /// every co-located DLL before a triage child can load any of them. + /// + internal static bool IsTrustedCacheLayout(string binDir, ILogger logger, Func signatureVerifier) + { + if (NuGetComponents[0].Files.Any(file => !File.Exists(Path.Combine(binDir, file)))) + { + return false; + } + + foreach (var path in Directory.EnumerateFiles(binDir, "*.dll", SearchOption.AllDirectories)) + { + if (!signatureVerifier(path, logger)) + { + logger.LogDebug("Rejecting local WinUI triage cache: {Path} is not validly signed by Microsoft.", path); + return false; + } + } + return true; + } + + /// + /// Testable core of with an injectable /// so unit tests can exercise resolution without requiring a real /// Authenticode-signed, version-matched JsProvider.dll. /// @@ -298,7 +326,8 @@ internal static bool VersionsMatch(string? a, string? b) /// Returns true when exists and looks like an intact PE image (starts /// with the MZ signature and is not implausibly small). Used to detect a truncated/corrupt /// cached engine DLL so it is re-acquired instead of poisoning the cache across runs — unlike - /// JsProvider.dll, the engine DLLs are not otherwise re-verified on a cache hit. + /// JsProvider.dll, engine DLLs outside the repository-local fallback cache are not otherwise + /// re-verified on a cache hit. /// private static bool IsUsablePeFile(string path) { diff --git a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs index 4e49a6fc6..73c7c1b36 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/XamlTriageService.cs @@ -67,7 +67,7 @@ public async Task TryAnalyzeAsync(string dumpPath, bool useSym var cacheBinDir = new DirectoryInfo(Path.Combine(dbgToolsRoot.FullName, XamlTriageBinaries.KitsArch)); ResolvedTriageBinaries? ResolveExisting(DirectoryInfo dir) => - (BinariesResolverOverride ?? (d => XamlTriageBinaries.ResolveExisting(d, logger)))(dir); + (BinariesResolverOverride ?? (d => XamlTriageBinaries.ResolveExisting(d, logger, _cache.IsLocalFallback)))(dir); // Resolve an existing debugger layout; if none, populate the download-on-first-use cache: // engine bits from NuGet (global cache or download) and JsProvider.dll from the WinDbg bundle. From 4fe57567b6d208d19bc61f644b65ed799be0495c Mon Sep 17 00:00:00 2001 From: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:49:49 -0700 Subject: [PATCH 06/11] Make UI timing tests assert deadline-aware behavior Preserve real ownership handoff and replay coverage, then exercise a deliberately expired observation instead of assuming another process starts within idle grace. Add scheduler boundary coverage for retained and expired turns. Verify native getter failures enter the retry path even when a slow read exhausts the timeout, and separately require complete constrained-query replay. Cover the slow-read failure deterministically without changing production deadlines or policies. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../InteractiveDesktopRealAppTests.cs | 23 +++++++---- .../InteractiveDesktopSchedulerTests.cs | 37 ++++++++++++++++++ .../UiCommandTests.Query.Strings.cs | 15 +++++-- .../WinApp.Cli.Tests/UiCommandTests.Query.cs | 39 +++++++++++++++++++ 4 files changed, 103 insertions(+), 11 deletions(-) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopRealAppTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopRealAppTests.cs index 4bdab6982..02434e4b1 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopRealAppTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopRealAppTests.cs @@ -399,16 +399,23 @@ await WaitForStateAsync(s => s.Owner?.Key == KeyOf(OwnerB), timeoutMs: 5_000), // by A again). await OpenMenuAsOwnerAsync(OwnerA); - // Now that A owns the turn again its Observe pins rather than detaches, so the UI it just - // restored is still standing afterwards. Running the same inspect while A was a non-owner - // would have been detached — no ticket, no lease, nothing holding the desktop — which is why - // the menu could not be expected to survive it before this point. + // Process startup and UIA work need not fit inside the renewed grace. Exercise a late + // observation deliberately: it must not reclaim the expired turn or disturb the restored + // menu. Ownership immediately after the replay was asserted above; observation pinning + // before expiry is covered by the burst test and deterministic scheduler boundary tests. + var replayDeadline = ReadState().IdleExpiresTick64; + while (Environment.TickCount64 < replayDeadline) + { + await Task.Delay(50); + } + var (replayExit, replayOutput) = await RunAgentAsync(OwnerA, WithTarget("ui", "inspect")); - Assert.AreEqual(0, replayExit, $"agent A must be able to replay after the handover. Output: {replayOutput}"); + Assert.AreEqual(0, replayExit, $"agent A must be able to inspect after its replay grace expires. Output: {replayOutput}"); Assert.IsTrue(_fixture.IsFileMenuOpen, "agent A's restored transient UI must survive its own observation"); - Assert.AreEqual( - KeyOf(OwnerA), ReadState().Owner?.Key, - "agent A must still hold the turn it reacquired"); + var observedState = ReadState(); + Assert.IsNull(observedState.Owner, "a late observation must not reclaim agent A's expired turn"); + Assert.IsEmpty(observedState.OwnerCommands, "a detached observation must not register a command"); + Assert.IsEmpty(observedState.Waiters, "a detached observation must not queue"); } // ------------------------------------------------------------------------------ §18.3 (c) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs index b63d072ec..662040457 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs @@ -204,6 +204,43 @@ public void BeginObserve_CurrentOwner_PinsTheTurnAndRunsImmediately() Assert.AreEqual(OwnerA.Key, state.Owner!.Key); } + [TestMethod] + [DataRow(InteractiveDesktopScheduler.IdleGraceMs - 1, false)] + [DataRow(InteractiveDesktopScheduler.IdleGraceMs, true)] + [DataRow(InteractiveDesktopScheduler.IdleGraceMs + 1, true)] + public void BeginObserve_AfterReplay_OnlyPinsBeforeTheIdleDeadline(int elapsedMs, bool detached) + { + var state = InteractiveDesktopState.CreateFresh(); + var first = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerA, first, UiTurnMode.DesktopExclusive); + _scheduler.CompleteCommand(state, _probe, first, OwnerA, renewGrace: true); + _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs); + + var other = Participant(200); + _scheduler.BeginParticipating(state, _probe, OwnerB, other, UiTurnMode.DesktopExclusive); + _scheduler.CompleteCommand(state, _probe, other, OwnerB, renewGrace: true); + _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs); + + var replay = Participant(101); + _scheduler.BeginParticipating(state, _probe, OwnerA, replay, UiTurnMode.DesktopExclusive); + _scheduler.CompleteCommand(state, _probe, replay, OwnerA, renewGrace: true); + Assert.AreEqual(OwnerA.Key, state.Owner!.Key); + _clock.Advance(elapsedMs); + + var observation = Participant(102, "ui inspect"); + var result = _scheduler.BeginObserve(state, _probe, OwnerA, observation); + + Assert.AreEqual(detached ? UiAdmission.Detached : UiAdmission.OwnerCommandRunning, result.Admission); + Assert.AreEqual(detached ? UiTurnAction.Detached : UiTurnAction.Continuation, result.TurnAction); + Assert.AreEqual(detached ? null : OwnerA.Key, state.Owner?.Key); + Assert.AreEqual(detached ? 0 : 1, state.OwnerCommands.Count); + Assert.IsEmpty(state.Waiters); + _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs); + _scheduler.Normalize(state, _probe); + Assert.AreEqual(detached ? null : OwnerA.Key, state.Owner?.Key, + "an admitted observation pins its turn; a late observation cannot reclaim an expired turn"); + } + [TestMethod] public void CompletingAnObservation_StartsAFreshGrace() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Query.Strings.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Query.Strings.cs index 859e5d956..1a3b3be54 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Query.Strings.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Query.Strings.cs @@ -18,7 +18,8 @@ public partial class UiCommandTests [DataRow("UIA_AutomationIdPropertyId", unchecked((int)0x80004005))] [DataRow("UIA_ClassNamePropertyId", unchecked((int)0x80040201))] [DataRow("UIA_ClassNamePropertyId", unchecked((int)0x80004005))] - public async Task QueryOptions_RealGetterFailureIsNeverGone(string property, int hresult) + [DataRow("UIA_NamePropertyId", unchecked((int)0x80040201), 1100)] + public async Task QueryOptions_RealGetterFailureIsNeverGone(string property, int hresult, int firstReadDelayMs = 0) { if (!Environment.UserInteractive) { Assert.Inconclusive("Requires an interactive desktop."); } using var fx = new UiaTestFixture(); @@ -30,7 +31,11 @@ public async Task QueryOptions_RealGetterFailureIsNeverGone(string property, int { UiAutomationService.s_getCurrentBstr = (element, requested) => { - if (requested.ToString() == property) { reads++; throw new COMException("Getter failed.", hresult); } + if (requested.ToString() == property) + { + if (++reads == 1 && firstReadDelayMs > 0) { Thread.Sleep(firstReadDelayMs); } + throw new COMException("Getter failed.", hresult); + } return nativeGetter(element, requested); }; UiAutomationService.s_findAllDescendants = (_, _) => null; @@ -46,12 +51,16 @@ public async Task QueryOptions_RealGetterFailureIsNeverGone(string property, int if (hresult == unchecked((int)0x80040201)) { StringAssert.Contains(TestAnsiConsole.Output, "\"timedOut\": true"); - Assert.IsTrue(reads > 1, "Unavailable getters must retry the complete query."); + Assert.IsTrue(reads > 0, "The query must exercise the failing getter."); + // A native read can exhaust the timeout before another poll is possible. + Assert.AreEqual(reads, _fakePollDelay.CallCount, + "Every unavailable getter must enter the retry path rather than report absence."); } else { AssertJsonErrorCode("stale_element"); Assert.AreEqual(1, reads, "Arbitrary provider faults must fail immediately."); + Assert.AreEqual(0, _fakePollDelay.CallCount); } } finally { UiAutomationService.ResetNativeSeams(); } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Query.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Query.cs index b701ebb84..0a82b0579 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Query.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Query.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.CommandLine; +using Microsoft.Extensions.Logging; using WinApp.Cli.Commands; namespace WinApp.Cli.Tests; @@ -71,6 +72,44 @@ public async Task QueryOptions_Wait_ReadReplacementRetriesFullQuery(bool propert StringAssert.Contains(TestAnsiConsole.Output, "txt-new-b234"); } + [TestMethod] + public async Task QueryOptions_Wait_UnavailableLookupRetriesFullQueryBeforeReportingGone() + { + _fakeUia.FindSingleResult = null; + var service = PropertyProxy((method, args) => method.Name switch + { + nameof(IUiAutomation.FindSingleElementAsync) => FindAsync( + (UiTarget)args![0]!, (UiSelector)args[1]!, (CancellationToken)args[2]!), + _ => throw new NotSupportedException(method.Name), + }); + Task FindAsync(UiTarget target, UiSelector selector, CancellationToken ct) + { + _fakeUia.FindSingleThrow = _fakeUia.Queries.Count == 0 + ? new System.Runtime.InteropServices.COMException("Replaced.", unchecked((int)0x80040201)) + : null; + return _fakeUia.FindSingleElementAsync(target, selector, ct); + } + + var command = QueryCommand("wait-for"); + var handler = new UiWaitForCommand.Handler( + _fakeTargetResolver, service, new UiSelectorParser(), _fakePollDelay, + TestAnsiConsole, _fakeDesktopLock, GetRequiredService>()); + command.SetAction((result, ct) => handler.InvokeAsync(result, ct)); + + var exit = await ParseAndInvokeWithCaptureAsync(command, + ["Welcome", "-a", "TestApp", "--root", "MailRow", "--type", "Edit", + "--class-name", "Literal.*", "--gone", "--timeout", "2000", "--json"]); + + Assert.AreEqual(0, exit, $"{TestAnsiConsole.Output} {ConsoleStdErr}"); + Assert.HasCount(2, _fakeUia.Queries); + Assert.IsTrue(_fakeUia.Queries.All(q => + q.Query == "Welcome" && q.Root?.Query == "MailRow" + && q.ControlType == "Edit" && q.ClassName == "Literal.*")); + Assert.AreEqual(1, _fakePollDelay.CallCount); + StringAssert.Contains(TestAnsiConsole.Output, "\"found\": false"); + Assert.DoesNotContain("\"timedOut\": true", TestAnsiConsole.Output); + } + [TestMethod] [DataRow(false, false)] [DataRow(false, true)] From dbec8ba0f0eba48b678b0efe95426d870b1f1b47 Mon Sep 17 00:00:00 2001 From: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:59:53 -0700 Subject: [PATCH 07/11] Make filesystem test fixtures independent of legacy path policy Create junctions with the Windows reparse-point API and hold child-process leases through architecture-neutral native file handles. Cover extended paths and report child exit codes and stderr on readiness failure without weakening security or locking assertions. Qualify command-specific cache locations as defaults and link the canonical restricted-filesystem guidance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/usage.md | 4 +- .../WinApp.Cli.Tests/LayoutLeaseTests.cs | 100 +++++++++++++++--- .../WinApp.Cli.Tests/NugetStorageTests.cs | 24 +++-- .../WinApp.Cli.Tests/TestJunction.cs | 72 +++++++++++++ 4 files changed, 174 insertions(+), 26 deletions(-) create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/TestJunction.cs diff --git a/docs/usage.md b/docs/usage.md index b1ffec88b..d7fc03142 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1771,7 +1771,7 @@ Search **WinUI** controls and samples for a working code example. WinUI-only: th winapp find-ui "" [options] ``` -The Gallery, Toolkit, and Reactor corpora ship **inside the CLI**, so `find-ui` works with no network access — including on a first run in an agent sandbox or behind a corporate proxy that blocks `raw.githubusercontent.com`. When GitHub *is* reachable the CLI refreshes from it and caches the result per-user under `/cache/find-ui`; the built-in corpus is only a floor, never a ceiling. Cached data is refreshed at most every 24 hours, or on demand with `--refresh`. +The Gallery, Toolkit, and Reactor corpora ship **inside the CLI**, so `find-ui` works with no network access — including on a first run in an agent sandbox or behind a corporate proxy that blocks `raw.githubusercontent.com`. When GitHub *is* reachable the CLI refreshes from it and normally caches the result per-user under `/cache/find-ui`; the built-in corpus is only a floor, never a ceiling. For fallback cache locations, see [Restricted Filesystem Access](#restricted-filesystem-access). Cached data is refreshed at most every 24 hours, or on demand with `--refresh`. The built-in corpus is re-fetched from GitHub every time a stable release is built, and a refresh that fails **stops the release build** rather than quietly shipping older data — the baker fetches through the same code path `--refresh` uses, so a failure there means the live refresh is broken too and is worth investigating before shipping. A release can still be cut against the previously committed corpus, but only as an explicit override. When results are served from the built-in copy of the Gallery/Toolkit/Reactor corpora, `find-ui` says so on stderr and `--json` output carries `"corpus": "embedded"` (other values: `"network"` for a fresh fetch, `"cache"` for the local cache). A core-only request — `--source core`, or an `--id` set that is all core patterns — reports `"embedded"` too, because the curated core patterns are compiled into the CLI and never fetched; it prints no staleness notice, since `--refresh` cannot change them. The `corpus` field is reported whenever results were served; it is absent only when no corpus could be loaded at all. @@ -1824,7 +1824,7 @@ winapp find-api "" [options] winapp find-api [command] [options] ``` -The index is built from the project's restored NuGet/SDK packages (via `project.assets.json`) on first use and refreshed automatically when the project is restored. It lives under the global `.winapp` cache (`cache/find-api/`) and is shared across projects. Restore the project first (`winapp restore` or `dotnet restore`). +The index is built from the project's restored NuGet/SDK packages (via `project.assets.json`) on first use and refreshed automatically when the project is restored. By default, it lives under the global `.winapp` cache (`cache/find-api/`) and is shared across projects. For fallback cache locations, see [Restricted Filesystem Access](#restricted-filesystem-access). Restore the project first (`winapp restore` or `dotnet restore`). Each match is listed under its namespace with the package that ships it and a one-line summary of what it does, so a result is usable without a second `members` call: diff --git a/src/winapp-CLI/WinApp.Cli.Tests/LayoutLeaseTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/LayoutLeaseTests.cs index 6c7b9786c..8a98e4908 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/LayoutLeaseTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/LayoutLeaseTests.cs @@ -211,22 +211,48 @@ public void ExistingUnlockedFile_IsReusable_AndDisposalReleasesOnlyItsLease() } [TestMethod] - public async Task OtherProcess_WithDifferentWorkingAndCacheDirectories_BlocksUntilKilled() + [DataRow(false)] + [DataRow(true)] + public async Task OtherProcess_WithDifferentWorkingAndCacheDirectories_BlocksUntilKilled(bool longPath) { - var layout = new DirectoryInfo(Path.Combine(_root.FullName, "AppX")); + var layout = new DirectoryInfo(longPath + ? Path.Combine(_root.FullName, new string('p', 180), "AppX") + : Path.Combine(_root.FullName, "AppX")); var lockPath = LayoutLease.GetLockPath(layout); + if (longPath) + { + Assert.IsGreaterThan(260, lockPath.Length, "The child must hold an extended-length lock path."); + } Directory.CreateDirectory(Path.GetDirectoryName(lockPath)!); var childWorkingDirectory = _root.CreateSubdirectory("child-working-directory"); var childCacheDirectory = _root.CreateSubdirectory("child-cache"); const string script = """ $ErrorActionPreference = 'Stop' - $stream = [System.IO.FileStream]::new($env:WINAPP_TEST_LAYOUT_LOCK, - [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, - [System.IO.FileShare]::None, 1, [System.IO.FileOptions]::None) - [Console]::Out.WriteLine('locked') - [Console]::Out.Flush() - [Console]::In.ReadLine() | Out-Null - $stream.Dispose() + Add-Type -TypeDefinition @' + using System; + using System.Runtime.InteropServices; + using Microsoft.Win32.SafeHandles; + public static class LayoutLeaseTestFile + { + [DllImport("kernel32.dll", EntryPoint = "CreateFileW", CharSet = CharSet.Unicode, SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + public static extern SafeFileHandle Open( + string path, uint access, uint share, IntPtr security, + uint disposition, uint flags, IntPtr template); + } + '@ + $handle = [LayoutLeaseTestFile]::Open($env:WINAPP_TEST_LAYOUT_LOCK, + 3221225472, 0, [IntPtr]::Zero, 4, 128, [IntPtr]::Zero) + if ($handle.IsInvalid) { + throw [System.ComponentModel.Win32Exception]::new( + [System.Runtime.InteropServices.Marshal]::GetLastWin32Error()) + } + try { + [Console]::Out.WriteLine('locked') + [Console]::Out.Flush() + [Console]::In.ReadLine() | Out-Null + } + finally { $handle.Dispose() } """; var start = new ProcessStartInfo { @@ -243,14 +269,14 @@ public async Task OtherProcess_WithDifferentWorkingAndCacheDirectories_BlocksUnt start.ArgumentList.Add("-NonInteractive"); start.ArgumentList.Add("-EncodedCommand"); start.ArgumentList.Add(Convert.ToBase64String(Encoding.Unicode.GetBytes(script))); - start.Environment["WINAPP_TEST_LAYOUT_LOCK"] = lockPath; + // The native open avoids Windows PowerShell's .NET Framework path-policy dependency. + start.Environment["WINAPP_TEST_LAYOUT_LOCK"] = LongPathHelper.EnsureExtendedLengthPrefix(lockPath); start.Environment["LOCALAPPDATA"] = childCacheDirectory.FullName; using var child = Process.Start(start)!; + var errorOutput = child.StandardError.ReadToEndAsync(TestContext.CancellationToken); try { - using var readyTimeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.CancellationToken); - readyTimeout.CancelAfter(TimeSpan.FromSeconds(15)); - Assert.AreEqual("locked", await child.StandardOutput.ReadLineAsync(readyTimeout.Token)); + await AssertChildReadyAsync(child, errorOutput, TestContext.CancellationToken); Assert.ThrowsExactly(() => LayoutLease.Acquire(layout, TestContext.CancellationToken, TimeSpan.Zero)); @@ -271,6 +297,54 @@ public async Task OtherProcess_WithDifferentWorkingAndCacheDirectories_BlocksUnt Assert.IsTrue(File.Exists(lockPath)); } + [TestMethod] + public async Task ChildReadinessFailure_ReportsExitCodeAndStandardError() + { + var start = new ProcessStartInfo(TestPaths.SystemExecutable("cmd.exe")) + { + ArgumentList = { "/d", "/c", "echo fixture-startup-failed 1>&2 & exit /b 42" }, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + using var child = Process.Start(start)!; + var errorOutput = child.StandardError.ReadToEndAsync(TestContext.CancellationToken); + + var failure = await Assert.ThrowsExactlyAsync( + () => AssertChildReadyAsync(child, errorOutput, TestContext.CancellationToken)); + + StringAssert.Contains(failure.Message, "exit code 42"); + StringAssert.Contains(failure.Message, "fixture-startup-failed"); + } + + private static async Task AssertChildReadyAsync(Process child, Task errorOutput, CancellationToken ct) + { + using var readyTimeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + readyTimeout.CancelAfter(TimeSpan.FromSeconds(15)); + string? ready; + try + { + ready = await child.StandardOutput.ReadLineAsync(readyTimeout.Token); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + ready = "timed out waiting for readiness"; + } + if (ready == "locked") + { + return; + } + + if (!child.HasExited) + { + child.Kill(entireProcessTree: true); + } + await child.WaitForExitAsync(ct); + Assert.Fail($"Lock-holder child did not become ready; exit code {child.ExitCode}, " + + $"stdout: {ready ?? ""}, stderr: {await errorOutput}"); + } + [TestMethod] public void MetadataObserver_DoesNotBreakLeaseHandoff() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/NugetStorageTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/NugetStorageTests.cs index ac61f5980..698bd5db2 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/NugetStorageTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/NugetStorageTests.cs @@ -460,8 +460,15 @@ public void ChildExplicitConfiguration_IsNotReplacedByPreviouslySelectedFallback } [TestMethod] - public void FallbackPackageIdJunction_BlocksChildAndInProcessAccess() + [DataRow(false)] + [DataRow(true)] + public void FallbackPackageIdJunction_BlocksChildAndInProcessAccess(bool longPath) { + if (longPath) + { + _invocation = _invocation.CreateSubdirectory(new string('p', 180)); + WriteConfig(_invocation); + } var provider = CreateProvider(); DenyDefaultReads(provider); provider.GetPackagesDirectory(); @@ -469,20 +476,15 @@ public void FallbackPackageIdJunction_BlocksChildAndInProcessAccess() var targetFile = Path.Combine(target.FullName, "sentinel.txt"); File.WriteAllText(targetFile, "unchanged"); var link = Path.Combine(LocalPackages, "linked.package"); - using (var process = Process.Start(new ProcessStartInfo("cmd.exe") - { - ArgumentList = { "/c", "mklink", "/J", link, target.FullName }, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - })!) + if (longPath) { - process.WaitForExit(); - Assert.AreEqual(0, process.ExitCode, process.StandardError.ReadToEnd()); + Assert.IsGreaterThan(260, link.Length, "The junction must exercise an extended-length path."); } + TestJunction.Create(link, target.FullName); try { + Assert.AreEqual(FileAttributes.ReparsePoint, File.GetAttributes(link) & FileAttributes.ReparsePoint); + Assert.AreEqual("unchanged", File.ReadAllText(Path.Combine(link, "sentinel.txt"))); using (File.Open(targetFile, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) { AssertFallbackLinkRejected(provider); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/TestJunction.cs b/src/winapp-CLI/WinApp.Cli.Tests/TestJunction.cs new file mode 100644 index 000000000..15fcedcad --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/TestJunction.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Buffers.Binary; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; +using WinApp.Cli.Helpers; + +namespace WinApp.Cli.Tests; + +internal static partial class TestJunction +{ + public static unsafe void Create(string linkPath, string targetPath) + { + linkPath = Path.GetFullPath(linkPath); + targetPath = Path.GetFullPath(targetPath); + if (Path.Exists(linkPath)) + { + throw new IOException($"Junction path already exists: '{linkPath}'."); + } + if (!Directory.Exists(targetPath)) + { + throw new DirectoryNotFoundException(targetPath); + } + + var substituteName = targetPath.StartsWith(@"\\?\", StringComparison.Ordinal) + ? @"\??\" + targetPath[4..] + : targetPath.StartsWith(@"\\", StringComparison.Ordinal) + ? @"\??\UNC\" + targetPath[2..] + : @"\??\" + targetPath; + var substitute = Encoding.Unicode.GetBytes(substituteName); + var printName = Encoding.Unicode.GetBytes(targetPath); + var buffer = new byte[16 + substitute.Length + 2 + printName.Length + 2]; + + // Mount-point reparse data uses byte offsets into the two null-terminated UTF-16 names. + BinaryPrimitives.WriteUInt32LittleEndian(buffer, 0xA0000003); // IO_REPARSE_TAG_MOUNT_POINT + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(4), checked((ushort)(buffer.Length - 8))); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(10), checked((ushort)substitute.Length)); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(12), checked((ushort)(substitute.Length + 2))); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(14), checked((ushort)printName.Length)); + substitute.CopyTo(buffer, 16); + printName.CopyTo(buffer, 16 + substitute.Length + 2); + + Directory.CreateDirectory(linkPath); + using var handle = OpenDirectory( + LongPathHelper.EnsureExtendedLengthPrefix(linkPath), 0x40000000, 0, 0, 3, 0x02200000, 0); + if (handle.IsInvalid) + { + throw new Win32Exception(Marshal.GetLastPInvokeError(), $"Cannot open junction directory '{linkPath}'."); + } + fixed (byte* data = buffer) + { + if (SetReparsePoint(handle, 0x000900A4, data, (uint)buffer.Length, 0, 0, out _, 0) == 0) + { + throw new Win32Exception(Marshal.GetLastPInvokeError(), $"Cannot create junction '{linkPath}'."); + } + } + } + + [LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + private static partial SafeFileHandle OpenDirectory( + string path, uint access, uint share, nint security, uint disposition, uint flags, nint template); + + [LibraryImport("kernel32.dll", EntryPoint = "DeviceIoControl", SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + private static unsafe partial int SetReparsePoint( + SafeFileHandle handle, uint code, byte* input, uint inputSize, + nint output, uint outputSize, out uint bytesReturned, nint overlapped); +} From 7e517e11fc4683ffd6daa1c9d0041c86fe790842 Mon Sep 17 00:00:00 2001 From: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:59:53 -0700 Subject: [PATCH 08/11] Preserve deployment snapshots during concurrent state reads Use File.Replace for existing deployment records only under the store's existing writer lease. Windows overwrite-by-move rejects even delete-sharing readers; generic unleased cache publication retains its prior behavior. Keep revision arbitration and access failures intact. Add deterministic open-reader, read-only destination, and non-delete-sharing protection regressions; retain the concurrent-writer state test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../WinApp.Cli.Tests/AtomicFileTests.cs | 48 +++++++++++++++++++ .../DeploymentStateStoreTests.cs | 18 +++++++ .../Orchestration/DeploymentState.cs | 4 +- .../WinApp.Cli/Helpers/AtomicFile.cs | 18 ++++++- 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/AtomicFileTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/AtomicFileTests.cs index 0821e3cf1..06d7fb846 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/AtomicFileTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/AtomicFileTests.cs @@ -53,6 +53,54 @@ public void Copy_OverwritesExistingDestinationAtomically() Assert.AreEqual(0, Directory.GetFiles(_tempDir, "*.tmp").Length); } + [TestMethod] + public async Task WriteAllText_WithDeleteSharingReader_PreservesBothSnapshots() + { + var dest = Path.Combine(_tempDir, "state.json"); + File.WriteAllText(dest, "old snapshot"); + using var stream = new FileStream(dest, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete); + using var reader = new StreamReader(stream); + + AtomicFile.WriteAllText(dest, "new snapshot", replaceExistingUnderLease: true); + + Assert.AreEqual("new snapshot", File.ReadAllText(dest)); + Assert.AreEqual("old snapshot", await reader.ReadToEndAsync()); + Assert.IsEmpty(Directory.GetFiles(_tempDir, "*.tmp")); + } + + [TestMethod] + public void WriteAllText_ReadOnlyDestination_FailsWithoutChangingContent() + { + var dest = Path.Combine(_tempDir, "readonly.json"); + File.WriteAllText(dest, "old"); + File.SetAttributes(dest, FileAttributes.ReadOnly); + try + { + Assert.ThrowsExactly( + () => AtomicFile.WriteAllText(dest, "new", replaceExistingUnderLease: true)); + Assert.AreEqual("old", File.ReadAllText(dest)); + Assert.IsEmpty(Directory.GetFiles(_tempDir, "*.tmp")); + } + finally + { + File.SetAttributes(dest, FileAttributes.Normal); + } + } + + [TestMethod] + public void WriteAllText_ReaderDenyingDeletion_IsNotBypassed() + { + var dest = Path.Combine(_tempDir, "held.json"); + File.WriteAllText(dest, "old"); + using var held = new FileStream(dest, FileMode.Open, FileAccess.Read, FileShare.Read); + + Assert.ThrowsExactly( + () => AtomicFile.WriteAllText(dest, "new", replaceExistingUnderLease: true)); + + Assert.AreEqual("old", File.ReadAllText(dest)); + Assert.IsEmpty(Directory.GetFiles(_tempDir, "*.tmp")); + } + [TestMethod] public async Task WriteStagedAsync_DoesNotPublishUntilPublishCalled() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/DeploymentStateStoreTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/DeploymentStateStoreTests.cs index 068630b2d..40dff93fb 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/DeploymentStateStoreTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/DeploymentStateStoreTests.cs @@ -61,6 +61,24 @@ public async Task Commit_ConcurrentWritersCannotPublishTheSameRevision() Assert.AreEqual(successful[0], current.TrackedOperationProcessId); } + [TestMethod] + public void Commit_WithAnOpenReader_PublishesWithoutInvalidatingTheReader() + { + var original = Seed(); + var stateFile = Path.Join(_root, Target.StateKey, DeploymentStateStore.DeploymentsFolder, "same-app.json"); + using var stream = new FileStream(stateFile, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete); + using var reader = new StreamReader(stream); + var originalJson = reader.ReadToEnd(); + stream.Position = 0; + reader.DiscardBufferedData(); + + var committed = CreateStore().Commit(Target, original with { Dirty = true }, original.Revision); + + Assert.AreEqual(original.Revision + 1, committed.Revision); + Assert.IsTrue(CreateStore().Read(Target, original.DeploymentId)!.Dirty); + Assert.AreEqual(originalJson, reader.ReadToEnd(), "The existing reader must retain the previous committed snapshot."); + } + [TestMethod] public async Task Commit_DoesNotCheckOrReplaceStateWhileAnotherWriterHoldsItsLease() { diff --git a/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/DeploymentState.cs b/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/DeploymentState.cs index c00280172..65e6f47b5 100644 --- a/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/DeploymentState.cs +++ b/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/DeploymentState.cs @@ -250,7 +250,9 @@ public DeploymentState Commit(ExecutionTargetRef target, DeploymentState state, UpdatedUtc = DateTimeOffset.UtcNow, }; - AtomicFile.WriteAllText(file, JsonSerializer.Serialize(committed, DeploymentStateJsonContext.Default.DeploymentState)); + AtomicFile.WriteAllText( + file, JsonSerializer.Serialize(committed, DeploymentStateJsonContext.Default.DeploymentState), + replaceExistingUnderLease: current is not null); return committed; } diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/AtomicFile.cs b/src/winapp-CLI/WinApp.Cli/Helpers/AtomicFile.cs index 633bf5487..13b823083 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/AtomicFile.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/AtomicFile.cs @@ -28,13 +28,27 @@ public static async Task WriteAllBytesAsync(string destinationPath, byte[] bytes } /// Writes to atomically. - public static void WriteAllText(string destinationPath, string content) + /// The final file path. + /// The complete new contents. + /// + /// Preserves open, delete-sharing readers of an existing destination. The caller must hold + /// a writer lease covering the existence check and replacement: File.Replace does not + /// support competing publishers. Other callers retain overwrite-by-move publication. + /// + public static void WriteAllText(string destinationPath, string content, bool replaceExistingUnderLease = false) { var tempPath = MakeTempPath(destinationPath); try { File.WriteAllText(tempPath, content); - File.Move(tempPath, destinationPath, overwrite: true); + if (replaceExistingUnderLease) + { + File.Replace(tempPath, destinationPath, destinationBackupFileName: null); + } + else + { + File.Move(tempPath, destinationPath, overwrite: true); + } } finally { From 0b5a05d771ff875474ed900ce451cfa39b60d57b Mon Sep 17 00:00:00 2001 From: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:44:42 -0700 Subject: [PATCH 09/11] Publish target state with a single atomic Windows rename ReplaceFile exposes transient exclusive-handle and missing-name windows to new readers. Use FileRenameInfoEx for state publication so delete-sharing readers retain their snapshots and the destination name stays bound to a complete record. Keep revision leases, namespace validation, ACL enforcement, read-only protection and non-delete-sharing locks intact. Leave generic cache publication unchanged. Cover overlapping readers/writers, concurrent publishers and long paths on ARM64 and x64. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../WinApp.Cli.Tests/AtomicFileTests.cs | 39 ++++++++-- .../DeploymentStateStoreTests.cs | 76 +++++++++++++++++++ .../WinApp.Cli.Tests/TargetStateStoreTests.cs | 14 ++++ .../Orchestration/DeploymentState.cs | 2 +- .../Orchestration/TargetStateStore.cs | 4 +- .../WinApp.Cli/Helpers/AtomicFile.cs | 72 ++++++++++++++++-- 6 files changed, 192 insertions(+), 15 deletions(-) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/AtomicFileTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/AtomicFileTests.cs index 06d7fb846..a74c38e53 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/AtomicFileTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/AtomicFileTests.cs @@ -54,17 +54,46 @@ public void Copy_OverwritesExistingDestinationAtomically() } [TestMethod] - public async Task WriteAllText_WithDeleteSharingReader_PreservesBothSnapshots() + [DataRow(false)] + [DataRow(true)] + public async Task WriteAllText_WithDeleteSharingReader_PreservesBothSnapshots(bool longPath) { - var dest = Path.Combine(_tempDir, "state.json"); + var directory = longPath + ? Path.Combine(_tempDir, new string('p', 180), new string('q', 120)) + : _tempDir; + Directory.CreateDirectory(directory); + var dest = Path.Combine(directory, "state.json"); + if (longPath) + { + Assert.IsGreaterThan(260, dest.Length); + } File.WriteAllText(dest, "old snapshot"); using var stream = new FileStream(dest, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete); using var reader = new StreamReader(stream); - AtomicFile.WriteAllText(dest, "new snapshot", replaceExistingUnderLease: true); + AtomicFile.WriteAllText(dest, "new snapshot", preserveReaders: true); Assert.AreEqual("new snapshot", File.ReadAllText(dest)); Assert.AreEqual("old snapshot", await reader.ReadToEndAsync()); + Assert.IsEmpty(Directory.GetFiles(directory, "*.tmp")); + } + + [TestMethod] + public async Task WriteAllText_PreservingReaders_SupportsConcurrentPublishers() + { + var dest = Path.Combine(_tempDir, "shared.json"); + var contents = Enumerable.Range(0, 16).Select(value => new string((char)('a' + value), 4096)).ToArray(); + using var start = new ManualResetEventSlim(); + var writers = contents.Select(content => Task.Run(() => + { + start.Wait(); + AtomicFile.WriteAllText(dest, content, preserveReaders: true); + })).ToArray(); + + start.Set(); + await Task.WhenAll(writers); + + CollectionAssert.Contains(contents, File.ReadAllText(dest)); Assert.IsEmpty(Directory.GetFiles(_tempDir, "*.tmp")); } @@ -77,7 +106,7 @@ public void WriteAllText_ReadOnlyDestination_FailsWithoutChangingContent() try { Assert.ThrowsExactly( - () => AtomicFile.WriteAllText(dest, "new", replaceExistingUnderLease: true)); + () => AtomicFile.WriteAllText(dest, "new", preserveReaders: true)); Assert.AreEqual("old", File.ReadAllText(dest)); Assert.IsEmpty(Directory.GetFiles(_tempDir, "*.tmp")); } @@ -95,7 +124,7 @@ public void WriteAllText_ReaderDenyingDeletion_IsNotBypassed() using var held = new FileStream(dest, FileMode.Open, FileAccess.Read, FileShare.Read); Assert.ThrowsExactly( - () => AtomicFile.WriteAllText(dest, "new", replaceExistingUnderLease: true)); + () => AtomicFile.WriteAllText(dest, "new", preserveReaders: true)); Assert.AreEqual("old", File.ReadAllText(dest)); Assert.IsEmpty(Directory.GetFiles(_tempDir, "*.tmp")); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/DeploymentStateStoreTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/DeploymentStateStoreTests.cs index 40dff93fb..1a788956b 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/DeploymentStateStoreTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/DeploymentStateStoreTests.cs @@ -13,6 +13,8 @@ public class DeploymentStateStoreTests private readonly string _root = TestPaths.TempRoot("DeploymentState"); private static readonly ExecutionTargetRef Target = WindowsSandboxTarget.Default; + public TestContext TestContext { get; set; } = null!; + [TestCleanup] public void Cleanup() { @@ -79,6 +81,80 @@ public void Commit_WithAnOpenReader_PublishesWithoutInvalidatingTheReader() Assert.AreEqual(originalJson, reader.ReadToEnd(), "The existing reader must retain the previous committed snapshot."); } + [TestMethod] + public async Task Read_PersistentSharingFailure_RemainsAnError() + { + var original = Seed(); + var stateFile = Path.Join(_root, Target.StateKey, DeploymentStateStore.DeploymentsFolder, "same-app.json"); + using var publisher = new FileStream(stateFile, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + + var error = await Assert.ThrowsExactlyAsync(() => + Task.Run(() => CreateStore().Read(Target, original.DeploymentId), TestContext.CancellationToken) + .WaitAsync(TimeSpan.FromSeconds(10), TestContext.CancellationToken)); + + Assert.AreEqual(ExecutionTargetErrorCodes.DeploymentDirty, error.Error.Code); + Assert.IsInstanceOfType(error.InnerException); + Assert.AreEqual(32, error.InnerException.HResult & 0xffff); + } + + [TestMethod] + public void Read_CorruptState_IsNotRetried() + { + var original = Seed(); + var stateFile = Path.Join(_root, Target.StateKey, DeploymentStateStore.DeploymentsFolder, "same-app.json"); + File.WriteAllText(stateFile, "{"); + var lookups = 0; + var store = new DeploymentStateStore(new ObservingDirectoryProvider(_root, () => lookups++)); + + var error = Assert.ThrowsExactly(() => store.Read(Target, original.DeploymentId)); + + Assert.AreEqual(ExecutionTargetErrorCodes.DeploymentDirty, error.Error.Code); + Assert.IsInstanceOfType(error.InnerException); + Assert.AreEqual(1, lookups); + } + + [TestMethod] + public async Task Read_ConcurrentPublications_ReturnsCompleteCommittedRecords() + { + var original = Seed(); + using var start = new ManualResetEventSlim(); + var writer = Task.Run(() => + { + start.Wait(TestContext.CancellationToken); + var current = original; + for (var i = 0; i < 100; i++) + { + current = CreateStore().Commit(Target, current with { TrackedOperationProcessId = i }, current.Revision); + } + }, TestContext.CancellationToken); + var reader = Task.Run(() => + { + start.Wait(TestContext.CancellationToken); + for (var i = 0; i < 100; i++) + { + var current = CreateStore().Read(Target, original.DeploymentId); + Assert.IsNotNull(current, "Publishing a new revision must not look like a missing deployment."); + Assert.AreEqual(original.DeploymentId, current.DeploymentId); + Assert.AreEqual(original.TargetEpoch, current.TargetEpoch); + } + }, TestContext.CancellationToken); + + start.Set(); + await Task.WhenAll(writer, reader).WaitAsync(TimeSpan.FromSeconds(30), TestContext.CancellationToken); + Assert.AreEqual(original.Revision + 100, CreateStore().Read(Target, original.DeploymentId)!.Revision); + } + + private sealed class ObservingDirectoryProvider(string root, Action onLookup) : ITargetStateDirectoryProvider + { + private readonly TargetStateDirectoryProvider _inner = new(root); + + public DirectoryInfo GetTargetRoot(ExecutionTargetRef target, bool create = true) + { + onLookup(); + return _inner.GetTargetRoot(target, create); + } + } + [TestMethod] public async Task Commit_DoesNotCheckOrReplaceStateWhileAnotherWriterHoldsItsLease() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/TargetStateStoreTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/TargetStateStoreTests.cs index b601fa072..49e887c7e 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/TargetStateStoreTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/TargetStateStoreTests.cs @@ -141,6 +141,20 @@ public void Commit_IncrementsRevisionMonotonically() Assert.AreEqual("instance-3", _store.Read(_target)!.InstanceId); } + [TestMethod] + public void Commit_PreservesAnOpenReaderAndPublishesTheNewSnapshot() + { + var original = _store.Commit(_target, NewState(), expectedRevision: 0); + using var stream = new FileStream(StateFilePath, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete); + using var reader = new StreamReader(stream); + + var updated = _store.Commit(_target, original with { InstanceId = "instance-2" }, original.Revision); + + Assert.AreEqual(original.Revision + 1, updated.Revision); + Assert.AreEqual("instance-2", _store.Read(_target)!.InstanceId); + StringAssert.Contains(reader.ReadToEnd(), "\"instance-1\""); + } + [TestMethod] public void Commit_StaleRevision_FailsClosedWithoutOverwriting() { diff --git a/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/DeploymentState.cs b/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/DeploymentState.cs index 65e6f47b5..8bf7abe8e 100644 --- a/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/DeploymentState.cs +++ b/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/DeploymentState.cs @@ -252,7 +252,7 @@ public DeploymentState Commit(ExecutionTargetRef target, DeploymentState state, AtomicFile.WriteAllText( file, JsonSerializer.Serialize(committed, DeploymentStateJsonContext.Default.DeploymentState), - replaceExistingUnderLease: current is not null); + preserveReaders: true); return committed; } diff --git a/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/TargetStateStore.cs b/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/TargetStateStore.cs index fe7478d09..a291cde31 100644 --- a/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/TargetStateStore.cs +++ b/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/TargetStateStore.cs @@ -147,7 +147,9 @@ public TargetState Commit(ExecutionTargetRef target, TargetState state, long exp }; var file = GetStateFile(target, create: true); - AtomicFile.WriteAllText(file, JsonSerializer.Serialize(committed, TargetStateJsonContext.Default.TargetState)); + AtomicFile.WriteAllText( + file, JsonSerializer.Serialize(committed, TargetStateJsonContext.Default.TargetState), + preserveReaders: true); return committed; } diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/AtomicFile.cs b/src/winapp-CLI/WinApp.Cli/Helpers/AtomicFile.cs index 13b823083..387acc812 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/AtomicFile.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/AtomicFile.cs @@ -1,6 +1,11 @@ // Copyright (c) Microsoft Corporation and Contributors. All rights reserved. // Licensed under the MIT License. +using System.Buffers.Binary; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + namespace WinApp.Cli.Helpers; /// @@ -10,7 +15,7 @@ namespace WinApp.Cli.Helpers; /// the debugger cache) therefore only ever observes the final path as either absent or fully written /// — never partially written or not-yet-verified. /// -internal static class AtomicFile +internal static partial class AtomicFile { /// Writes to atomically. public static async Task WriteAllBytesAsync(string destinationPath, byte[] bytes, CancellationToken cancellationToken) @@ -30,20 +35,19 @@ public static async Task WriteAllBytesAsync(string destinationPath, byte[] bytes /// Writes to atomically. /// The final file path. /// The complete new contents. - /// - /// Preserves open, delete-sharing readers of an existing destination. The caller must hold - /// a writer lease covering the existence check and replacement: File.Replace does not - /// support competing publishers. Other callers retain overwrite-by-move publication. + /// + /// Uses a single Windows rename for local state files, preserving open delete-sharing readers + /// without File.Replace's missing-name and exclusive-handle windows. /// - public static void WriteAllText(string destinationPath, string content, bool replaceExistingUnderLease = false) + public static void WriteAllText(string destinationPath, string content, bool preserveReaders = false) { var tempPath = MakeTempPath(destinationPath); try { File.WriteAllText(tempPath, content); - if (replaceExistingUnderLease) + if (preserveReaders) { - File.Replace(tempPath, destinationPath, destinationBackupFileName: null); + PublishPreservingReaders(tempPath, destinationPath); } else { @@ -90,6 +94,58 @@ public static void Publish(string stagedPath, string destinationPath) => /// Deletes a staged temp file that will not be published. Best effort. public static void DiscardStaged(string stagedPath) => TryDeleteLeftoverTemp(stagedPath); + private static unsafe void PublishPreservingReaders(string stagedPath, string destinationPath) + { + const uint deleteAccess = 0x00010000; + const uint openReparsePoint = 0x00200000; + // This handle survives the rename; write access or exclusive sharing would block new readers. + using var handle = OpenForRename( + LongPathHelper.EnsureExtendedLengthPrefix(Path.GetFullPath(stagedPath)), + deleteAccess, (uint)(FileShare.ReadWrite | FileShare.Delete), 0, (uint)FileMode.Open, openReparsePoint, 0); + if (handle.IsInvalid) + { + ThrowRenameError(destinationPath, Marshal.GetLastPInvokeError()); + } + + var destination = LongPathHelper.EnsureExtendedLengthPrefix(Path.GetFullPath(destinationPath)); + var name = MemoryMarshal.AsBytes(destination.AsSpan()); + // FILE_RENAME_INFO contains a DWORD, an aligned HANDLE, a DWORD byte length, then UTF-16. + var nameOffset = 2 * IntPtr.Size + sizeof(uint); + var buffer = new byte[checked(nameOffset + name.Length + sizeof(char))]; + const uint replaceIfExists = 0x00000001; + const uint posixSemantics = 0x00000002; + BinaryPrimitives.WriteUInt32LittleEndian(buffer, replaceIfExists | posixSemantics); + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(2 * IntPtr.Size), (uint)name.Length); + name.CopyTo(buffer.AsSpan(nameOffset)); + fixed (byte* data = buffer) + { + const int fileRenameInfoEx = 22; + if (RenameByHandle(handle, fileRenameInfoEx, data, (uint)buffer.Length) == 0) + { + ThrowRenameError(destinationPath, Marshal.GetLastPInvokeError()); + } + } + } + + private static void ThrowRenameError(string destinationPath, int error) + { + var message = $"Could not atomically publish '{destinationPath}': {new Win32Exception(error).Message}"; + if (error == 5) + { + throw new UnauthorizedAccessException(message); + } + throw new IOException(message, unchecked((int)(0x80070000u | (uint)error))); + } + + [LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + private static partial SafeFileHandle OpenForRename( + string path, uint access, uint share, nint security, uint disposition, uint flags, nint template); + + [LibraryImport("kernel32.dll", EntryPoint = "SetFileInformationByHandle", SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + private static unsafe partial int RenameByHandle(SafeFileHandle handle, int informationClass, byte* buffer, uint length); + private static string MakeTempPath(string destinationPath) => destinationPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; From 000558b30676963def44811b64e34a032fa33a8c Mon Sep 17 00:00:00 2001 From: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:08:09 -0700 Subject: [PATCH 10/11] Exercise MP4 completion with a short frame sequence The existing single-frame encoder fixture intermittently reaches Finalize with no compressed samples, as recorded on main in #834. Feed one second at 30 fps while retaining short-buffer validation, idempotent completion and nonempty publication checks; also assert staging is consumed only at completion. Validated the targeted suite in Windows Sandbox and verified the retained MP4 contains exactly 30 video samples. No production encoder changes, added skips or swallowed failures. Refs #834 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Mp4SinkWriterEncoderTests.cs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/Mp4SinkWriterEncoderTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/Mp4SinkWriterEncoderTests.cs index 9181221d5..dac5e3bcf 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/Mp4SinkWriterEncoderTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/Mp4SinkWriterEncoderTests.cs @@ -11,6 +11,8 @@ namespace WinApp.Cli.Tests; [TestClass] public class Mp4SinkWriterEncoderTests { + private const int FramesPerSecond = 30; + private static string CreateScratchDirectory() { var dir = Path.Join(Path.GetTempPath(), "winapp-mp4-" + Guid.NewGuid().ToString("N")); @@ -53,22 +55,31 @@ public void Mp4SinkWriterEncoder_RealEncoderCoversValidationAndSuccessfulComplet var dir = CreateScratchDirectory(); try { - var path = Path.Join(dir, "one-frame.mp4"); + var path = Path.Join(dir, "short-recording.mp4"); using var encoder = CreateEncoderOrInconclusive(path); Assert.AreEqual(64, encoder.Width); Assert.AreEqual(64, encoder.Height); + const long frameDurationHns = 10_000_000 / FramesPerSecond; var shortFrame = new byte[63 * 64 * 4]; var ex = Assert.ThrowsExactly( - () => encoder.WriteFrame(shortFrame, 0, 10_000_000)); + () => encoder.WriteFrame(shortFrame, 0, frameDurationHns)); StringAssert.Contains(ex.Message, "expected 16384"); - encoder.WriteFrame(Enumerable.Repeat((byte)0x22, 64 * 64 * 4).ToArray(), 0, 10_000_000); + // H.264 encoders buffer input; one sample need not produce a compressed frame (#834). + var frame = Enumerable.Repeat((byte)0x22, 64 * 64 * 4).ToArray(); + for (var i = 0; i < FramesPerSecond; i++) + { + encoder.WriteFrame(frame, i * frameDurationHns, frameDurationHns); + } + Assert.IsFalse(File.Exists(path), "Frames must remain staged until completion."); encoder.Complete(); encoder.Complete(); Assert.IsTrue(File.Exists(path)); Assert.IsTrue(new FileInfo(path).Length > 0, "completed MP4 must be published to the final path"); + CollectionAssert.AreEquivalent(new[] { path }, Directory.GetFiles(dir, "*.mp4"), + "Successful completion must consume the staged recording."); } finally { @@ -80,7 +91,7 @@ private static Mp4SinkWriterEncoder CreateEncoderOrInconclusive(string path) { try { - return new Mp4SinkWriterEncoder(path, 64, 64, 1, 1_000_000); + return new Mp4SinkWriterEncoder(path, 64, 64, FramesPerSecond, 1_000_000); } catch (Mp4EncoderInitializationException ex) { From 79b663a8508871738ac8fa7a472d799bdb6203e5 Mon Sep 17 00:00:00 2001 From: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:03:18 -0700 Subject: [PATCH 11/11] Validate UI coordination namespace before touching state Share the existing target-state ancestor trust policy with UI coordination. Reject local reparse points and replaceable ancestors before leaf permission repair or artifact access, and never treat an untrusted path as permission to run an observation detached. Cover junctions at each relevant boundary, mutable ancestors, linked state files, unchanged destination ACLs/content and all turn modes. Preserve existing target-state behavior and document the fail-closed recovery path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/ui-automation.md | 5 +- docs/usage.md | 7 + .../references/ui-json-envelope.md | 2 +- .../InteractiveDesktopLockTests.ReadOnly.cs | 35 +++++ .../InteractiveDesktopPathsHardeningTests.cs | 138 ++++++++++++++++++ .../TargetStateDirectorySecurityTests.cs | 13 +- .../TargetStateDirectorySecurity.cs | 57 +------- .../WinApp.Cli/Helpers/StatePathSecurity.cs | 114 +++++++++++++++ .../InteractiveDesktopPaths.cs | 35 ++++- 9 files changed, 337 insertions(+), 69 deletions(-) create mode 100644 src/winapp-CLI/WinApp.Cli/Helpers/StatePathSecurity.cs diff --git a/docs/ui-automation.md b/docs/ui-automation.md index 72ba1a50c..4628581bf 100644 --- a/docs/ui-automation.md +++ b/docs/ui-automation.md @@ -209,8 +209,9 @@ record a workflow driving an app. Two caveats: Errors you may see: `invalid_ui_workflow_id` (the variable is set but empty or over 256 characters), `invalid_ui_lock_directory` (an explicit coordination path is invalid; correct or remove the override), -`desktop_coordination_unavailable` (coordination state is unreadable and cannot be safely rebuilt, or -was written by a newer `winapp`), `queue_capacity_exceeded` (64 commands from **other** workflows are +`desktop_coordination_unavailable` (coordination state is unreadable and cannot be safely rebuilt, +was written by a newer `winapp`, or its [storage path is untrusted](usage.md#restricted-filesystem-access)), +`queue_capacity_exceeded` (64 commands from **other** workflows are already waiting — the limit counts live foreign waiters, not processes you have started, so entries belonging to commands that have exited or been killed do not occupy a slot, and your own workflow's commands queue behind each other rather than against this limit), `ui_turn_busy` (`yield` while your diff --git a/docs/usage.md b/docs/usage.md index 2df63dcc4..a8671c80e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -2131,6 +2131,13 @@ that directory. An existing readable cache can be used without requiring writes. | Read-only UI observation | Can continue without workflow ordering when shared state is inaccessible | | UI mutations, captures and Sandbox management | Require accessible shared coordination; they never silently run without it | +UI coordination requires a direct local path without junctions or symbolic links, +and existing parent directories must not let other users replace that path. +An untrusted coordination path is rejected even for read-only commands; it is not +treated as an inaccessible cache or a reason to bypass coordination. Correct the +path or its parent permissions before retrying. Ancestor permissions are never +changed automatically. + Store CLI and debugger executables/DLLs reused from the automatic local fallback must have valid Microsoft signatures. If a local Store tool is rejected, remove that tool cache and retry to download a verified copy. Unverifiable debugger diff --git a/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md b/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md index d3640991b..749b0c659 100644 --- a/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md +++ b/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md @@ -309,7 +309,7 @@ appear: |---|---| | `invalid_ui_workflow_id` | `WINAPP_UI_WORKFLOW_ID` is set but empty/whitespace or longer than 256 characters. Fails before any UI side effect. | | `invalid_ui_lock_directory` | An explicitly configured coordination directory is invalid. Correct or remove the override; read-only commands do not hide configuration errors. | -| `desktop_coordination_unavailable` | Coordination state could not be read, published, or safely rebuilt — including state written by a newer `winapp`. Mutating commands fail closed rather than acting uncoordinated. | +| `desktop_coordination_unavailable` | Coordination state could not be read, published, or safely rebuilt, or its storage path is untrusted — including state written by a newer `winapp`. Mutating commands fail closed; an untrusted path also blocks observations. Follow `recoveryHint` and the [restricted filesystem guidance](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#restricted-filesystem-access). | | `queue_capacity_exceeded` | 64 commands from other workflows are already waiting for the desktop. Counts live foreign waiters, so entries left by commands that exited or were killed do not occupy a slot. | | `ui_turn_busy` | `ui yield` was run while this same workflow still has a command running or queued, so its turn is not idle. Nothing was released, and the running command is unaffected. Distinct from `invalid_arguments` (the request was well formed) and from `desktop_coordination_unavailable` (coordination is working — this is a valid request at an unsafe moment). Carries a `recoveryHint`: wait for or stop this workflow's other `winapp ui` commands — typically a `record` started with the same `WINAPP_UI_WORKFLOW_ID` — then retry `yield`. | | `cancelled` | Native Ctrl+C while the command was still waiting for its turn. The command never ran, so it has no UI side effects. Exit code **130**. | diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.ReadOnly.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.ReadOnly.cs index 9027fee87..e0af145d0 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.ReadOnly.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.ReadOnly.cs @@ -13,6 +13,41 @@ namespace WinApp.Cli.Tests; public partial class InteractiveDesktopLockTests { + [TestMethod] + [DataRow((int)UiTurnMode.Observe)] + [DataRow((int)UiTurnMode.TurnShared)] + [DataRow((int)UiTurnMode.DesktopExclusive)] + public async Task UntrustedLocalJunction_DoesNotRunTheCommand(int mode) + { + Directory.CreateDirectory(_lockDirectory); + var outside = Directory.CreateDirectory(Path.Join(_lockDirectory, "outside")); + var link = Path.Join(_lockDirectory, "state"); + TestJunction.Create(link, outside.FullName); + Environment.SetEnvironmentVariable(InteractiveDesktopPaths.LockDirectoryOverrideVariable, Path.Join(link, "ui")); + using var error = new StringWriter(); + var calls = 0; + var action = new ReadOnlyProbeAction(_coordinator, (UiTurnMode)mode, (_, _) => + { + calls++; + return Task.FromResult(0); + }); + try + { + var exit = await action.InvokeAsync(ParseObservation(error, TextWriter.Null, "--json")); + + Assert.AreEqual(1, exit); + Assert.AreEqual(0, calls, "Even observations must reject an untrusted coordination namespace."); + using var document = JsonDocument.Parse(error.ToString()); + Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, + document.RootElement.GetProperty("error").GetProperty("code").GetString()); + Assert.IsEmpty(outside.GetFileSystemInfos()); + } + finally + { + Directory.Delete(link); + } + } + [TestMethod] public async Task Observe_BlockedParentAndMissingDirectory_RunsOnceWithoutCreatingState() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopPathsHardeningTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopPathsHardeningTests.cs index f8cf3d5ea..d49172b5b 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopPathsHardeningTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopPathsHardeningTests.cs @@ -82,6 +82,144 @@ private static bool HasForeignGrant(string path) .Any(r => r.IdentityReference is SecurityIdentifier sid && sid != currentUser); } + [TestMethod] + [DataRow("ancestor")] + [DataRow("lock-directory")] + [DataRow("participants")] + public void LocalJunction_IsRejectedBeforeChangingItsDestination(string location) + { + Directory.CreateDirectory(_root); + var destination = Directory.CreateDirectory(Path.Join(_root, "outside")); + var marker = Path.Join(destination.FullName, "keep.txt"); + File.WriteAllText(marker, "untouched"); + var acl = destination.GetAccessControl(); + acl.AddAccessRule(new FileSystemAccessRule( + new SecurityIdentifier(WellKnownSidType.WorldSid, null), + FileSystemRights.FullControl, AccessControlType.Allow)); + destination.SetAccessControl(acl); + var before = destination.GetAccessControl().GetSecurityDescriptorSddlForm(AccessControlSections.Owner | AccessControlSections.Access); + var locks = Path.Join(_root, "ui"); + var link = location switch + { + "ancestor" => Path.Join(_root, "state"), + "lock-directory" => locks, + _ => Path.Join(locks, "participants"), + }; + Directory.CreateDirectory(Path.GetDirectoryName(link)!); + TestJunction.Create(link, destination.FullName); + Environment.SetEnvironmentVariable(InteractiveDesktopPaths.LockDirectoryOverrideVariable, + location == "ancestor" ? Path.Join(link, "ui") : locks); + try + { + var error = Assert.ThrowsExactly( + () => new InteractiveDesktopPaths(new ProcessInspector()).EnsureDirectories()); + + Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, error.Code); + Assert.IsFalse(error.IsStorageUnavailable, "An untrusted namespace must not permit detached observation."); + StringAssert.Contains(error.Message, "reparse point"); + Assert.AreEqual(before, destination.GetAccessControl().GetSecurityDescriptorSddlForm(AccessControlSections.Owner | AccessControlSections.Access)); + Assert.AreEqual("untouched", File.ReadAllText(marker)); + CollectionAssert.AreEquivalent(new[] { marker }, Directory.GetFileSystemEntries(destination.FullName)); + } + finally + { + Directory.Delete(link); + } + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void ReplaceableAncestor_IsRejectedWithoutRepair(bool existingLockDirectory) + { + var shared = Directory.CreateDirectory(Path.Join(_root, "shared")); + var locks = Path.Join(shared.FullName, "ui"); + if (existingLockDirectory) + { + Directory.CreateDirectory(locks); + } + var security = shared.GetAccessControl(); + security.AddAccessRule(new FileSystemAccessRule( + new SecurityIdentifier(WellKnownSidType.BuiltinGuestsSid, null), + FileSystemRights.DeleteSubdirectoriesAndFiles, AccessControlType.Allow)); + shared.SetAccessControl(security); + var before = shared.GetAccessControl().GetSecurityDescriptorSddlForm(AccessControlSections.Owner | AccessControlSections.Access); + Environment.SetEnvironmentVariable(InteractiveDesktopPaths.LockDirectoryOverrideVariable, locks); + + var error = Assert.ThrowsExactly( + () => new InteractiveDesktopPaths(new ProcessInspector()).EnsureDirectories()); + + Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, error.Code); + Assert.IsFalse(error.IsStorageUnavailable); + Assert.AreEqual(before, shared.GetAccessControl().GetSecurityDescriptorSddlForm(AccessControlSections.Owner | AccessControlSections.Access)); + Assert.AreEqual(existingLockDirectory, Directory.Exists(locks)); + Assert.IsFalse(Directory.Exists(Path.Join(locks, "participants"))); + } + + [TestMethod] + public void ReadOnlyAncestorGrant_PermitsPrivateCoordinationWithoutChangingTheAncestor() + { + var parent = Directory.CreateDirectory(Path.Join(_root, "parent")); + var security = parent.GetAccessControl(); + security.AddAccessRule(new FileSystemAccessRule( + new SecurityIdentifier(WellKnownSidType.BuiltinGuestsSid, null), + FileSystemRights.ReadAndExecute, AccessControlType.Allow)); + parent.SetAccessControl(security); + var before = parent.GetAccessControl().GetSecurityDescriptorSddlForm(AccessControlSections.Owner | AccessControlSections.Access); + Environment.SetEnvironmentVariable(InteractiveDesktopPaths.LockDirectoryOverrideVariable, Path.Join(parent.FullName, "ui")); + var paths = new InteractiveDesktopPaths(new ProcessInspector()); + + paths.EnsureDirectories(); + paths.EnsureDirectories(); + + Assert.IsTrue(Directory.Exists(paths.ParticipantsDirectory)); + Assert.IsTrue(InteractiveDesktopPaths.IsCurrentUserOnly( + new DirectoryInfo(paths.LockDirectory).GetAccessControl(), WindowsIdentity.GetCurrent().User!)); + Assert.AreEqual(before, parent.GetAccessControl().GetSecurityDescriptorSddlForm(AccessControlSections.Owner | AccessControlSections.Access)); + } + + [TestMethod] + [DataRow("state")] + [DataRow("state-lock")] + [DataRow("active-lock")] + public void LinkedCoordinationFile_IsRejectedWithoutReadingOrChangingTheTarget(string kind) + { + var paths = new InteractiveDesktopPaths(new ProcessInspector()); + paths.EnsureDirectories(); + var target = Path.Join(_root, "outside.txt"); + File.WriteAllText(target, "unchanged"); + var link = kind switch + { + "state" => paths.StatePath, + "state-lock" => paths.StateLockPath, + _ => paths.ActiveLockPath, + }; + try + { + File.CreateSymbolicLink(link, target); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Assert.Inconclusive($"File symbolic links are unavailable: {ex.Message}"); + } + try + { + using (File.Open(target, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + { + var error = Assert.ThrowsExactly( + () => new InteractiveDesktopPaths(new ProcessInspector()).EnsureDirectories()); + Assert.IsFalse(error.IsStorageUnavailable); + StringAssert.Contains(error.Message, "reparse point"); + } + Assert.AreEqual("unchanged", File.ReadAllText(target)); + Assert.IsTrue(File.Exists(link)); + } + finally + { + File.Delete(link); + } + } + [TestMethod] public void SecuringUiState_DoesNotChangeCacheOrSiblingTargetPermissions() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/TargetStateDirectorySecurityTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/TargetStateDirectorySecurityTests.cs index 3a7b550c4..0f5bf7204 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/TargetStateDirectorySecurityTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/TargetStateDirectorySecurityTests.cs @@ -7,6 +7,7 @@ using WinApp.Cli.ExecutionTargets.Abstractions; using WinApp.Cli.ExecutionTargets.Orchestration; using WinApp.Cli.ExecutionTargets.WindowsSandbox; +using WinApp.Cli.Helpers; namespace WinApp.Cli.Tests; @@ -40,7 +41,7 @@ public void NewNamespace_HasProtectedCurrentUserAndSystemPermissions() var security = new DirectoryInfo(path).GetAccessControl(); Assert.IsTrue(security.AreAccessRulesProtected); Assert.AreEqual(_user, security.GetOwner(typeof(SecurityIdentifier))); - Assert.IsTrue(TargetStateDirectorySecurity.IsTrusted(security, _user)); + Assert.IsTrue(StatePathSecurity.IsTrusted(security, _user)); var allowed = security.GetAccessRules(true, true, typeof(SecurityIdentifier)) .Cast() .Where(rule => rule.AccessControlType == AccessControlType.Allow) @@ -224,7 +225,7 @@ public void ConcurrentCreators_VerifyTheSamePrivateNamespace() Parallel.For(0, 12, _ => Assert.IsTrue(Provider(targets).GetTargetRoot(WindowsSandboxTarget.Default).Exists)); - Assert.IsTrue(TargetStateDirectorySecurity.IsTrusted(new DirectoryInfo(targets).GetAccessControl(), _user)); + Assert.IsTrue(StatePathSecurity.IsTrusted(new DirectoryInfo(targets).GetAccessControl(), _user)); } [TestMethod] @@ -251,8 +252,8 @@ public void ForeignOwner_IsUntrustedEvenWithPrivateDacl() var security = PrivateSecurity(); security.SetOwner(ForeignUser); - Assert.IsFalse(TargetStateDirectorySecurity.IsTrusted(security, _user)); - Assert.IsFalse(TargetStateDirectorySecurity.IsTrusted(security, _user, allowAncestorAccess: true)); + Assert.IsFalse(StatePathSecurity.IsTrusted(security, _user)); + Assert.IsFalse(StatePathSecurity.IsTrusted(security, _user, allowAncestorAccess: true)); } [TestMethod] @@ -292,7 +293,7 @@ public void PrivilegedOwner_IsTrusted(WellKnownSidType owner) var security = PrivateSecurity(); security.SetOwner(new SecurityIdentifier(owner, null)); - Assert.IsTrue(TargetStateDirectorySecurity.IsTrusted(security, _user)); + Assert.IsTrue(StatePathSecurity.IsTrusted(security, _user)); } [TestMethod] @@ -301,7 +302,7 @@ public void NullDacl_IsNotPrivate() var security = new DirectorySecurity(); security.SetSecurityDescriptorSddlForm($"O:{_user.Value}D:NO_ACCESS_CONTROL"); - Assert.IsFalse(TargetStateDirectorySecurity.IsTrusted(security, _user)); + Assert.IsFalse(StatePathSecurity.IsTrusted(security, _user)); } private static TargetStateDirectoryProvider Provider(string targets) => new(targets) diff --git a/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/TargetStateDirectorySecurity.cs b/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/TargetStateDirectorySecurity.cs index 7791c2aac..280929475 100644 --- a/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/TargetStateDirectorySecurity.cs +++ b/src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/TargetStateDirectorySecurity.cs @@ -3,16 +3,13 @@ using System.Security.AccessControl; using System.Security.Principal; +using WinApp.Cli.Helpers; namespace WinApp.Cli.ExecutionTargets.Orchestration; /// Verifies the namespace and secrets before target state can be used. internal static class TargetStateDirectorySecurity { - // The default Windows volume root belongs to TrustedInstaller, a privileged system service. - private static readonly SecurityIdentifier TrustedInstaller = new( - "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"); - internal static DirectoryInfo EnsureTrusted(string targetsRoot, string targetRoot, bool create) { targetsRoot = Path.TrimEndingDirectorySeparator(targetsRoot); @@ -112,62 +109,12 @@ private static void Verify(FileSystemInfo item, SecurityIdentifier user, bool al FileSystemSecurity security = item is DirectoryInfo directory ? directory.GetAccessControl(AccessControlSections.Owner | AccessControlSections.Access) : ((FileInfo)item).GetAccessControl(AccessControlSections.Owner | AccessControlSections.Access); - if (!IsTrusted(security, user, allowAncestorAccess)) + if (!StatePathSecurity.IsTrusted(security, user, allowAncestorAccess)) { throw new IOException($"'{item.FullName}' is owned by or grants unsafe access to another user."); } } - internal static bool IsTrusted(FileSystemSecurity security, SecurityIdentifier user, bool allowAncestorAccess = false) - { - if (security.GetOwner(typeof(SecurityIdentifier)) is not SecurityIdentifier owner - || !(IsSelfOrPrivileged(owner, user) || (allowAncestorAccess && owner == TrustedInstaller))) - { - return false; - } - - // An absent (NULL) DACL grants everybody access; an empty rule enumeration is not proof - // of privacy. Inherited trusted rules, however, need no repair when all ancestors are safe. - if (new RawSecurityDescriptor(security.GetSecurityDescriptorBinaryForm(), 0).DiscretionaryAcl is null) - { - return false; - } - - foreach (FileSystemAccessRule rule in security.GetAccessRules(true, true, typeof(SecurityIdentifier))) - { - if (rule.AccessControlType != AccessControlType.Allow - || rule.IdentityReference is SecurityIdentifier sid - && (IsSelfOrPrivileged(sid, user) || (allowAncestorAccess && sid == TrustedInstaller))) - { - continue; - } - - if (!allowAncestorAccess) - { - return false; - } - - // Public traversal/read access to C:\ and Users is normal. Creating sibling directories - // alone cannot replace a verified child; CreateDirectory installs its DACL atomically, - // and the checks above also verify an existing directory when another creator wins. - // Inherit-only rules do not grant access to this ancestor itself. - const FileSystemRights harmlessAncestorRights = FileSystemRights.ReadAndExecute - | FileSystemRights.Synchronize | FileSystemRights.CreateDirectories; - if (!rule.PropagationFlags.HasFlag(PropagationFlags.InheritOnly) - && (rule.FileSystemRights & ~harmlessAncestorRights) != 0) - { - return false; - } - } - - return true; - } - - private static bool IsSelfOrPrivileged(SecurityIdentifier sid, SecurityIdentifier user) => - sid == user - || sid.IsWellKnown(WellKnownSidType.LocalSystemSid) - || sid.IsWellKnown(WellKnownSidType.BuiltinAdministratorsSid); - private static void RejectReparsePoint(string path, FileAttributes attributes) { if (attributes.HasFlag(FileAttributes.ReparsePoint)) diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/StatePathSecurity.cs b/src/winapp-CLI/WinApp.Cli/Helpers/StatePathSecurity.cs new file mode 100644 index 000000000..8b1a8f4ca --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Helpers/StatePathSecurity.cs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Security.AccessControl; +using System.Security.Principal; + +namespace WinApp.Cli.Helpers; + +internal static class StatePathSecurity +{ + private static readonly SecurityIdentifier TrustedInstaller = new( + "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"); + + internal static void VerifyAncestors(string path) + { + using var identity = WindowsIdentity.GetCurrent(); + var user = identity.User ?? throw new UntrustedStatePathException("The current Windows user could not be identified."); + var leaf = new DirectoryInfo(path); + var ancestors = new Stack(); + for (DirectoryInfo? directory = leaf; directory is not null; directory = directory.Parent) + { + ancestors.Push(directory); + } + + while (ancestors.TryPop(out var directory)) + { + FileAttributes attributes; + try + { + attributes = File.GetAttributes(directory.FullName); + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + return; + } + RejectReparsePoint(directory.FullName, attributes); + if (!attributes.HasFlag(FileAttributes.Directory)) + { + throw new IOException($"'{directory.FullName}' is not a directory."); + } + // The caller secures its leaf. Ancestors must never be repaired: they may hold unrelated data. + if (directory.FullName != leaf.FullName + && !IsTrusted(directory.GetAccessControl(AccessControlSections.Owner | AccessControlSections.Access), + user, allowAncestorAccess: true)) + { + throw new UntrustedStatePathException($"State ancestor '{directory.FullName}' is owned by or grants unsafe access to another user."); + } + } + } + + internal static void RejectReparsePoint(string path) + { + try + { + RejectReparsePoint(path, File.GetAttributes(path)); + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + // The caller can create this path after validating its existing ancestors. + } + } + + private static void RejectReparsePoint(string path, FileAttributes attributes) + { + if (attributes.HasFlag(FileAttributes.ReparsePoint)) + { + throw new UntrustedStatePathException($"State path '{path}' is a link or reparse point."); + } + } + + internal static bool IsTrusted(FileSystemSecurity security, SecurityIdentifier user, bool allowAncestorAccess = false) + { + if (security.GetOwner(typeof(SecurityIdentifier)) is not SecurityIdentifier owner + || !(IsSelfOrPrivileged(owner, user) || (allowAncestorAccess && owner == TrustedInstaller))) + { + return false; + } + if (new RawSecurityDescriptor(security.GetSecurityDescriptorBinaryForm(), 0).DiscretionaryAcl is null) + { + return false; + } + + foreach (FileSystemAccessRule rule in security.GetAccessRules(true, true, typeof(SecurityIdentifier))) + { + if (rule.AccessControlType != AccessControlType.Allow + || rule.IdentityReference is SecurityIdentifier sid + && (IsSelfOrPrivileged(sid, user) || (allowAncestorAccess && sid == TrustedInstaller))) + { + continue; + } + if (!allowAncestorAccess) + { + return false; + } + + // Public traversal and sibling creation are safe; replacing a verified child is not. + const FileSystemRights harmlessAncestorRights = FileSystemRights.ReadAndExecute + | FileSystemRights.Synchronize | FileSystemRights.CreateDirectories; + if (!rule.PropagationFlags.HasFlag(PropagationFlags.InheritOnly) + && (rule.FileSystemRights & ~harmlessAncestorRights) != 0) + { + return false; + } + } + return true; + } + + private static bool IsSelfOrPrivileged(SecurityIdentifier sid, SecurityIdentifier user) => + sid == user + || sid.IsWellKnown(WellKnownSidType.LocalSystemSid) + || sid.IsWellKnown(WellKnownSidType.BuiltinAdministratorsSid); +} + +internal sealed class UntrustedStatePathException(string message) : IOException(message); diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs index f5a5b950d..85382fdc9 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.Security.AccessControl; using System.Security.Principal; +using WinApp.Cli.Helpers; namespace WinApp.Cli.Services.InteractiveDesktop; @@ -125,16 +126,40 @@ public bool TryParseLeaseFileName(string fileName, out int processId, out long s public void EnsureDirectories() { - // Verified once per process: the check is a DACL read per directory, and every state-lock - // acquisition and lease open calls this. + // Verified once per process; trusted ancestors prevent another user replacing the secured leaf. if (_directoriesVerified) { return; } - EnsureRestrictedDirectory(LockDirectory); - EnsureRestrictedDirectory(ParticipantsDirectory); - EnsureTrustedStateFiles(); + try + { + StatePathSecurity.VerifyAncestors(LockDirectory); + StatePathSecurity.RejectReparsePoint(ParticipantsDirectory); + foreach (var path in new[] { StatePath, StateLockPath, ActiveLockPath }) + { + StatePathSecurity.RejectReparsePoint(path); + } + + EnsureRestrictedDirectory(LockDirectory); + // Creating the leaf can also create intermediate directories. Check their inherited + // permissions before placing or trusting coordination artifacts beneath them. + StatePathSecurity.VerifyAncestors(LockDirectory); + EnsureRestrictedDirectory(ParticipantsDirectory); + EnsureTrustedStateFiles(); + } + catch (UntrustedStatePathException ex) + { + throw new UiCoordinationException( + UiCoordinationErrorCodes.Unavailable, + $"The UI coordination namespace is untrusted: {ex.Message}", + "Use a direct local state path whose ancestors cannot be replaced by other users. " + + "If WINAPP_UI_LOCK_DIRECTORY is set, use the same trusted directory for every winapp process on this desktop."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw Unavailable(LockDirectory, ex); + } _directoriesVerified = true; }