Skip to content

Commit 2ea3a04

Browse files
zateutschnmetulevCopilotCopilot
authored
Support for long paths in run command (#398)
`winapp run` fails on paths exceeding ~260 chars due to Windows MAX_PATH limits. This adds `longPathAware` manifest support, a `LongPathHelper` utility, and targeted workarounds for the two main failure points: Win32 build tools (MakePri/MakeAppx) and the WinRT `PackageManager` API. ## LongPathHelper - `EnsureExtendedLengthPrefix` — adds `\\?\` (or `\\?\UNC\` for UNC paths) to bypass MAX_PATH in Win32 file I/O - `GetShortPath` — converts long paths to 8.3 form via `GetShortPathName`; preserves trailing directory separators (critical for sparse registration URIs); consistent `<= MaxPath` boundaries throughout - `GetShortPathOrThrow` — throws an actionable `InvalidOperationException` when shortening fails (8.3 disabled, path doesn't exist on disk) - `ValidatePathLength` — fast-fails with a clear remediation message when long paths are unsupported at the system level - `GetShortPathRaw` — correctly restores `\\?\UNC\server\share\...` → `\\server\share\...` (not the invalid `UNC\server\share\...`); handles `DllNotFoundException` gracefully on non-Windows ## RunCommand - Validates `inputFolder.FullName` early (before the status spinner) so long-path errors surface before any file system work - Also validates the resolved manifest path immediately after resolution ## PackageRegistrationService - All three `PackageManager` call sites (`RegisterLooseLayoutAsync`, `RegisterSparseAsync`, `InstallPackageAsync`) use `GetShortPathOrThrow` instead of the silent `GetShortPath` fallback - Sparse registration preserves the trailing separator on the external location URI ## PriService - Uses the extended-prefix `dumpPath` consistently for `File.Exists`, `ReadAllTextAsync`, and `File.Delete` — not the unprefixed `dumpOutputFile` ## Tests Added `LongPathHelperTests` with 16 unit tests covering boundary cases (`== MaxPath`, `> MaxPath`), UNC prefix handling, trailing separator preservation, and `GetShortPathOrThrow` throw behavior. --------- Co-authored-by: Nikola Metulev <nmetulev@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent de23f9c commit 2ea3a04

9 files changed

Lines changed: 453 additions & 11 deletions

File tree

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using WinApp.Cli.Helpers;
5+
6+
namespace WinApp.Cli.Tests;
7+
8+
[TestClass]
9+
public class LongPathHelperTests
10+
{
11+
private const int MaxPath = 260;
12+
13+
// C:\ = 3 chars, .txt = 4 chars
14+
private static readonly string PrefixC = "C:" + Path.DirectorySeparatorChar;
15+
private const string SuffixTxt = ".txt";
16+
17+
/// <summary>Creates a local path of exactly <paramref name="targetLength"/> characters.</summary>
18+
private static string MakeLocalPath(int targetLength)
19+
{
20+
var aCount = targetLength - PrefixC.Length - SuffixTxt.Length;
21+
return PrefixC + new string('a', aCount) + SuffixTxt;
22+
}
23+
24+
#region EnsureExtendedLengthPrefix tests
25+
26+
[TestMethod]
27+
public void EnsureExtendedLengthPrefix_ShortPath_ReturnsUnchanged()
28+
{
29+
var path = @"C:\short\path\file.txt";
30+
Assert.AreEqual(path, LongPathHelper.EnsureExtendedLengthPrefix(path));
31+
}
32+
33+
[TestMethod]
34+
public void EnsureExtendedLengthPrefix_ExactlyMaxPath_ReturnsUnchanged()
35+
{
36+
// A path of exactly MaxPath (260) chars should not get the extended prefix
37+
var path = MakeLocalPath(MaxPath);
38+
Assert.AreEqual(MaxPath, path.Length, "Test path should be exactly MaxPath characters");
39+
Assert.AreEqual(path, LongPathHelper.EnsureExtendedLengthPrefix(path));
40+
}
41+
42+
[TestMethod]
43+
public void EnsureExtendedLengthPrefix_LongLocalPath_AddsPrefix()
44+
{
45+
var path = MakeLocalPath(MaxPath + 1);
46+
Assert.IsTrue(path.Length > MaxPath);
47+
var result = LongPathHelper.EnsureExtendedLengthPrefix(path);
48+
Assert.IsTrue(result.StartsWith(@"\\?\", StringComparison.Ordinal));
49+
Assert.IsTrue(result.Contains(path), "Original path should be embedded in result");
50+
}
51+
52+
[TestMethod]
53+
public void EnsureExtendedLengthPrefix_AlreadyPrefixed_ReturnsUnchanged()
54+
{
55+
var path = @"\\?\" + new string('a', MaxPath) + ".txt";
56+
Assert.AreEqual(path, LongPathHelper.EnsureExtendedLengthPrefix(path));
57+
}
58+
59+
[TestMethod]
60+
public void EnsureExtendedLengthPrefix_LongUncPath_AddsUncPrefix()
61+
{
62+
var path = @"\\server\share\" + new string('a', MaxPath);
63+
Assert.IsTrue(path.Length > MaxPath);
64+
var result = LongPathHelper.EnsureExtendedLengthPrefix(path);
65+
Assert.IsTrue(result.StartsWith(@"\\?\UNC\", StringComparison.Ordinal),
66+
@"UNC paths should use the \\?\UNC\ prefix form");
67+
// \\server\share\... -> \\?\UNC\server\share\...
68+
Assert.IsTrue(result.Contains(@"server\share\"), "Server and share should be preserved");
69+
}
70+
71+
[TestMethod]
72+
public void EnsureExtendedLengthPrefix_ShortUncPath_ReturnsUnchanged()
73+
{
74+
var path = @"\\server\share\file.txt";
75+
Assert.AreEqual(path, LongPathHelper.EnsureExtendedLengthPrefix(path));
76+
}
77+
78+
#endregion
79+
80+
#region ValidatePathLength tests
81+
82+
[TestMethod]
83+
public void ValidatePathLength_ShortPath_DoesNotThrow()
84+
{
85+
var path = @"C:\short\path\file.txt";
86+
LongPathHelper.ValidatePathLength(path); // Should not throw
87+
}
88+
89+
[TestMethod]
90+
public void ValidatePathLength_ExactlyMaxPath_DoesNotThrow()
91+
{
92+
var path = MakeLocalPath(MaxPath);
93+
Assert.AreEqual(MaxPath, path.Length, "Test path should be exactly MaxPath characters");
94+
LongPathHelper.ValidatePathLength(path); // Should not throw at exactly MaxPath
95+
}
96+
97+
[TestMethod]
98+
public void ValidatePathLength_LongPath_WhenLongPathsDisabled_ThrowsWithActionableMessage()
99+
{
100+
var path = MakeLocalPath(MaxPath + 1);
101+
Assert.IsTrue(path.Length > MaxPath);
102+
103+
if (!LongPathHelper.IsSystemLongPathEnabled())
104+
{
105+
var ex = Assert.ThrowsExactly<InvalidOperationException>(() => LongPathHelper.ValidatePathLength(path));
106+
Assert.IsTrue(ex.Message.Contains("MAX_PATH"), "Error message should mention MAX_PATH");
107+
Assert.IsTrue(ex.Message.Contains("LongPathsEnabled") || ex.Message.Contains("reg add"),
108+
"Error message should include actionable guidance");
109+
}
110+
else
111+
{
112+
// Long paths enabled on this machine -- method should not throw
113+
LongPathHelper.ValidatePathLength(path);
114+
}
115+
}
116+
117+
#endregion
118+
119+
#region GetShortPath tests
120+
121+
[TestMethod]
122+
public void GetShortPath_ShortPath_ReturnsUnchanged()
123+
{
124+
var path = @"C:\short\path\file.txt";
125+
Assert.AreEqual(path, LongPathHelper.GetShortPath(path));
126+
}
127+
128+
[TestMethod]
129+
public void GetShortPath_ExactlyMaxPath_ReturnsUnchanged()
130+
{
131+
// Paths at or below MaxPath should be returned as-is without calling GetShortPathName
132+
var path = MakeLocalPath(MaxPath);
133+
Assert.AreEqual(MaxPath, path.Length, "Test path should be exactly MaxPath characters");
134+
Assert.AreEqual(path, LongPathHelper.GetShortPath(path));
135+
}
136+
137+
[TestMethod]
138+
public void GetShortPath_PathWithTrailingSeparator_PreservesTrailingSeparator()
139+
{
140+
// A directory path ending with the platform separator must still end with a separator
141+
// after GetShortPath processes it (whether or not GetShortPathName succeeds).
142+
var sep = Path.DirectorySeparatorChar;
143+
var path = PrefixC + new string('a', MaxPath) + sep;
144+
Assert.IsTrue(Path.EndsInDirectorySeparator(path), "Test path should end with directory separator");
145+
Assert.IsTrue(path.Length > MaxPath, "Test path must exceed MaxPath");
146+
147+
var result = LongPathHelper.GetShortPath(path);
148+
149+
Assert.IsTrue(Path.EndsInDirectorySeparator(result),
150+
"GetShortPath must preserve the trailing directory separator");
151+
}
152+
153+
[TestMethod]
154+
public void GetShortPath_DirectoryPathWithoutTrailingSeparator_DoesNotAddSeparator()
155+
{
156+
var path = @"C:\short\directory";
157+
var result = LongPathHelper.GetShortPath(path);
158+
Assert.IsFalse(Path.EndsInDirectorySeparator(result),
159+
"GetShortPath should not add a trailing separator when input has none");
160+
}
161+
162+
#endregion
163+
164+
#region GetShortPathOrThrow tests
165+
166+
[TestMethod]
167+
public void GetShortPathOrThrow_ShortPath_ReturnsUnchanged()
168+
{
169+
var path = @"C:\short\path\file.txt";
170+
Assert.AreEqual(path, LongPathHelper.GetShortPathOrThrow(path));
171+
}
172+
173+
[TestMethod]
174+
public void GetShortPathOrThrow_ExactlyMaxPath_ReturnsUnchanged()
175+
{
176+
var path = MakeLocalPath(MaxPath);
177+
Assert.AreEqual(MaxPath, path.Length, "Test path should be exactly MaxPath characters");
178+
Assert.AreEqual(path, LongPathHelper.GetShortPathOrThrow(path));
179+
}
180+
181+
[TestMethod]
182+
public void GetShortPathOrThrow_LongPathThatCannotBeShortened_Throws()
183+
{
184+
// A path with a non-existent deeply nested directory cannot be shortened by GetShortPathName.
185+
// GetShortPath returns the original (still-long) path, so GetShortPathOrThrow must throw.
186+
var path = MakeLocalPath(MaxPath + 1);
187+
Assert.IsTrue(path.Length > MaxPath, "Test path must exceed MaxPath");
188+
189+
var ex = Assert.ThrowsExactly<InvalidOperationException>(() => LongPathHelper.GetShortPathOrThrow(path));
190+
Assert.IsTrue(ex.Message.Contains("too long") || ex.Message.Contains("MAX_PATH") || ex.Message.Contains("short"),
191+
"Error message should describe that the path is too long and cannot be shortened to a usable length");
192+
}
193+
194+
#endregion
195+
}

src/winapp-CLI/WinApp.Cli/Commands/RunCommand.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,18 @@ public override async Task<int> InvokeAsync(ParseResult parseResult, Cancellatio
179179
return 1;
180180
}
181181

182+
// Validate the input folder path early so the command fails fast with a clear
183+
// long-path message before any file system operations are attempted.
184+
try
185+
{
186+
LongPathHelper.ValidatePathLength(inputFolder.FullName);
187+
}
188+
catch (InvalidOperationException ex)
189+
{
190+
logger.LogError("{UISymbol} {Message}", UiSymbols.Error, ex.Message);
191+
return 1;
192+
}
193+
182194
uint processId = 0;
183195
string? packageFamilyName = null;
184196
string? packageFullName = null;
@@ -227,6 +239,10 @@ public override async Task<int> InvokeAsync(ParseResult parseResult, Cancellatio
227239
outputAppXDirectory ??= new DirectoryInfo(Path.Combine(inputFolder.FullName, "AppX"));
228240
resolvedOutputDir = outputAppXDirectory;
229241

242+
// Validate that the manifest and output paths are usable (check long path support if needed)
243+
LongPathHelper.ValidatePathLength(resolvedManifest.FullName);
244+
LongPathHelper.ValidatePathLength(outputAppXDirectory.FullName);
245+
230246
// Step 2: Create and register the debug identity
231247
taskContext.AddDebugMessage($"{UiSymbols.Package} Creating debug identity...");
232248
var identityResult = await msixService.AddLooseLayoutIdentityAsync(

0 commit comments

Comments
 (0)