mirror of
https://github.com/icsharpcode/ILSpy.git
synced 2026-10-12 02:15:58 +00:00
Fix #3775: escape reserved Windows device names in output file names
Windows maps CON, PRN, AUX, NUL, COM1-9 and LPT1-9 to devices -- on many builds even with an extension appended, so a type named Con made both whole-project export and the save dialog fail with IOException '\\.\Con'. CleanUpName only checked for reserved names after re-appending the file extension, where they never match, and the save-dialog default-name helpers did not check them at all. The escape appends the underscore to the base name (con_.txt, not con.txt_) because device-name parsing ignores everything after the first dot, and is applied per path segment so reserved directory names produced by namespaces are covered too. The ILSpy.Tests.Windows fixture verifies on a real Windows filesystem that the escaped names are creatable. Assisted-by: Claude:claude-fable-5:Claude Code
This commit is contained in:
committed by
Siegfried Pammer
parent
9352a58289
commit
83879c4a07
@@ -148,6 +148,7 @@
|
||||
<Compile Include="Output\ILAmbienceTests.cs" />
|
||||
<Compile Include="Output\InsertParenthesesVisitorTests.cs" />
|
||||
<Compile Include="Output\TextTokenWriterTests.cs" />
|
||||
<Compile Include="ProjectDecompiler\ReservedFileSystemNameTests.cs" />
|
||||
<Compile Include="ProjectDecompiler\TargetFrameworkTests.cs" />
|
||||
<Compile Include="ProjectDecompiler\WholeProjectDecompilerTests.cs" />
|
||||
<Compile Include="Properties\AssemblyInfo.cs" />
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
||||
// software and associated documentation files (the "Software"), to deal in the Software
|
||||
// without restriction, including without limitation the rights to use, copy, modify, merge,
|
||||
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
|
||||
// to whom the Software is furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all copies or
|
||||
// substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
|
||||
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
// DEALINGS IN THE SOFTWARE.
|
||||
|
||||
using System.IO;
|
||||
|
||||
using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
|
||||
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace ICSharpCode.Decompiler.Tests.ProjectDecompiler;
|
||||
|
||||
/// <summary>
|
||||
/// Windows reserves the device names below as file/directory names — both bare ("CON") and,
|
||||
/// on many Windows versions, with an extension appended ("CON.cs" resolves to \\.\CON).
|
||||
/// Every sanitizer in <see cref="WholeProjectDecompiler"/> must escape the base name (the part
|
||||
/// before the first dot) with a trailing underscore so the decompiled output is creatable on
|
||||
/// Windows (issue #3775).
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class ReservedFileSystemNameTests
|
||||
{
|
||||
static readonly string[] ReservedNames = {
|
||||
"AUX", "CON", "NUL", "PRN",
|
||||
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
||||
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||
};
|
||||
|
||||
static readonly char Sep = Path.DirectorySeparatorChar;
|
||||
|
||||
[Test]
|
||||
public void CleanUpFileNameEscapesReservedNames([ValueSource(nameof(ReservedNames))] string name)
|
||||
{
|
||||
Assert.That(WholeProjectDecompiler.CleanUpFileName(name, ".cs"), Is.EqualTo($"{name}_.cs"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CleanUpFileNameEscapesReservedNamesCaseInsensitively([ValueSource(nameof(ReservedNames))] string name)
|
||||
{
|
||||
string lower = name.ToLowerInvariant();
|
||||
Assert.That(WholeProjectDecompiler.CleanUpFileName(lower, ".cs"), Is.EqualTo($"{lower}_.cs"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CleanUpFileNameEscapesTheBaseNameOfMultiDotNames()
|
||||
{
|
||||
// The underscore must land on the base name: Windows device-name parsing ignores
|
||||
// everything after the first dot, so "con.fig.cs_" would still resolve to \\.\CON.
|
||||
Assert.That(WholeProjectDecompiler.CleanUpFileName("con.fig", ".cs"), Is.EqualTo("con_.fig.cs"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SanitizeFileNameEscapesReservedNames([ValueSource(nameof(ReservedNames))] string name)
|
||||
{
|
||||
Assert.That(WholeProjectDecompiler.SanitizeFileName(name + ".txt"), Is.EqualTo($"{name}_.txt"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SanitizeFileNameEscapesReservedDirectorySegments([ValueSource(nameof(ReservedNames))] string name)
|
||||
{
|
||||
Assert.That(WholeProjectDecompiler.SanitizeFileName(name + "/data.bin"), Is.EqualTo($"{name}_{Sep}data.bin"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SanitizeFileNameEscapesReservedFinalSegments([ValueSource(nameof(ReservedNames))] string name)
|
||||
{
|
||||
Assert.That(WholeProjectDecompiler.SanitizeFileName($"dir/{name}.png"), Is.EqualTo($"dir{Sep}{name}_.png"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CleanUpDirectoryNameEscapesReservedNames([ValueSource(nameof(ReservedNames))] string name)
|
||||
{
|
||||
Assert.That(WholeProjectDecompiler.CleanUpDirectoryName(name), Is.EqualTo(name + "_"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CleanUpPathEscapesReservedLeadingSegments([ValueSource(nameof(ReservedNames))] string name)
|
||||
{
|
||||
Assert.That(WholeProjectDecompiler.CleanUpPath(name + ".Foo"), Is.EqualTo($"{name}_{Sep}Foo"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CleanUpPathEscapesReservedTrailingSegments([ValueSource(nameof(ReservedNames))] string name)
|
||||
{
|
||||
Assert.That(WholeProjectDecompiler.CleanUpPath("Foo." + name), Is.EqualTo($"Foo{Sep}{name}_"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase("Console")]
|
||||
[TestCase("CONtoso")]
|
||||
[TestCase("con1")]
|
||||
[TestCase("com10")]
|
||||
[TestCase("lpt10")]
|
||||
public void NamesMerelyStartingWithReservedNamesAreNotEscaped(string name)
|
||||
{
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(WholeProjectDecompiler.CleanUpFileName(name, ".cs"), Is.EqualTo(name + ".cs"));
|
||||
Assert.That(WholeProjectDecompiler.SanitizeFileName(name + ".txt"), Is.EqualTo(name + ".txt"));
|
||||
Assert.That(WholeProjectDecompiler.CleanUpDirectoryName(name), Is.EqualTo(name));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OrdinaryPathsPassThroughUnchanged()
|
||||
{
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(WholeProjectDecompiler.SanitizeFileName("config.txt"), Is.EqualTo("config.txt"));
|
||||
Assert.That(WholeProjectDecompiler.SanitizeFileName("dir/file.png"), Is.EqualTo($"dir{Sep}file.png"));
|
||||
Assert.That(WholeProjectDecompiler.CleanUpPath("Foo.Bar"), Is.EqualTo($"Foo{Sep}Bar"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -693,6 +693,8 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler
|
||||
// Whitelist allowed characters, replace everything else:
|
||||
StringBuilder b = new StringBuilder(text.Length + (extension?.Length ?? 0));
|
||||
bool countBytes = !RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
|
||||
int segmentStart = 0;
|
||||
int baseNameEnd = -1;
|
||||
foreach (var c in text)
|
||||
{
|
||||
if (char.IsLetterOrDigit(c) || c == '-' || c == '_')
|
||||
@@ -709,19 +711,32 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler
|
||||
else if (c == '.' && b.Length > 0 && b[^1] != '.')
|
||||
{
|
||||
currentSegmentLength++;
|
||||
// if the current segment exceeds maxSegmentLength characters,
|
||||
// skip until the end of the segment.
|
||||
if (separateAtDots || currentSegmentLength <= maxSegmentLength)
|
||||
b.Append('.'); // allow dot, but never two in a row
|
||||
|
||||
// Reset length at end of segment.
|
||||
if (separateAtDots)
|
||||
{
|
||||
// The dot ends the current segment.
|
||||
EscapeReservedFileSystemName(b, segmentStart, baseNameEnd);
|
||||
b.Append('.');
|
||||
segmentStart = b.Length;
|
||||
baseNameEnd = -1;
|
||||
// Reset length at end of segment.
|
||||
currentSegmentLength = 0;
|
||||
}
|
||||
else if (currentSegmentLength <= maxSegmentLength)
|
||||
{
|
||||
// The first dot ends the segment's base name, the part Windows
|
||||
// device-name parsing looks at.
|
||||
if (baseNameEnd < 0)
|
||||
baseNameEnd = b.Length;
|
||||
b.Append('.'); // allow dot, but never two in a row
|
||||
}
|
||||
}
|
||||
else if (treatAsPath && (c is '/' or '\\') && currentSegmentLength > 0)
|
||||
{
|
||||
// if we treat this as a file name, we've started a new segment
|
||||
EscapeReservedFileSystemName(b, segmentStart, baseNameEnd);
|
||||
b.Append(Path.DirectorySeparatorChar);
|
||||
segmentStart = b.Length;
|
||||
baseNameEnd = -1;
|
||||
currentSegmentLength = 0;
|
||||
}
|
||||
else
|
||||
@@ -741,6 +756,7 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler
|
||||
}
|
||||
if (b.Length == 0)
|
||||
b.Append('-');
|
||||
EscapeReservedFileSystemName(b, segmentStart, baseNameEnd);
|
||||
string name = b.ToString();
|
||||
if (extension != null)
|
||||
{
|
||||
@@ -752,9 +768,7 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler
|
||||
name += extension;
|
||||
}
|
||||
|
||||
if (IsReservedFileSystemName(name))
|
||||
return name + "_";
|
||||
else if (name == ".")
|
||||
if (name == ".")
|
||||
return "_";
|
||||
else
|
||||
return name;
|
||||
@@ -774,6 +788,25 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler
|
||||
.Replace('.', Path.DirectorySeparatorChar);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends an underscore to the segment [<paramref name="segmentStart"/>..) of
|
||||
/// <paramref name="b"/> if its base name (the part before <paramref name="baseNameEnd"/>,
|
||||
/// or the whole segment when there was no dot) is a reserved Windows device name
|
||||
/// (CON, PRN, AUX, NUL, COM1-9, LPT1-9): "con" becomes "con_" and "con.txt" becomes
|
||||
/// "con_.txt". Windows device-name parsing ignores everything after the first dot, so the
|
||||
/// underscore must be inserted before it, not appended at the end.
|
||||
/// </summary>
|
||||
static void EscapeReservedFileSystemName(StringBuilder b, int segmentStart, int baseNameEnd)
|
||||
{
|
||||
if (baseNameEnd < 0)
|
||||
baseNameEnd = b.Length;
|
||||
int baseNameLength = baseNameEnd - segmentStart;
|
||||
// All reserved names are 3 or 4 characters long; checking the length first avoids
|
||||
// allocating a substring for segments that cannot be reserved anyway.
|
||||
if (baseNameLength is 3 or 4 && IsReservedFileSystemName(b.ToString(segmentStart, baseNameLength)))
|
||||
b.Insert(baseNameEnd, '_');
|
||||
}
|
||||
|
||||
static bool IsReservedFileSystemName(string name)
|
||||
{
|
||||
switch (name.ToUpperInvariant())
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
||||
// software and associated documentation files (the "Software"), to deal in the Software
|
||||
// without restriction, including without limitation the rights to use, copy, modify, merge,
|
||||
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
|
||||
// to whom the Software is furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all copies or
|
||||
// substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
|
||||
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
// DEALINGS IN THE SOFTWARE.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
using AwesomeAssertions;
|
||||
|
||||
using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
|
||||
|
||||
using ILSpy.Commands;
|
||||
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace ICSharpCode.ILSpy.Tests.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies on a real Windows filesystem that the sanitized names produced for reserved device
|
||||
/// names (CON, PRN, AUX, NUL, COM1-9, LPT1-9) are actually creatable (issue #3775: a type named
|
||||
/// "Con" crashed saving with IOException '\\.\Con'). The cross-platform expectations live in
|
||||
/// ICSharpCode.Decompiler.Tests (ReservedFileSystemNameTests) and ILSpy.Tests
|
||||
/// (SuggestedFileNameTests); this fixture only proves the escaped output round-trips through
|
||||
/// File/Directory creation. It deliberately does not assert that the raw names fail to be
|
||||
/// created: whether "con.cs" is rejected depends on the Windows version (Windows 11 allows
|
||||
/// reserved names with extensions), and bare-name behavior is already covered by the escape.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ReservedFileSystemNameTests
|
||||
{
|
||||
static readonly string[] ReservedNames = [
|
||||
"AUX", "CON", "NUL", "PRN",
|
||||
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
||||
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||
];
|
||||
|
||||
string tempDir = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
tempDir = Path.Combine(Path.GetTempPath(), "ILSpyReserved_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (Directory.Exists(tempDir))
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Sanitized_Code_File_Names_Are_Creatable([ValueSource(nameof(ReservedNames))] string name)
|
||||
{
|
||||
var fileName = WholeProjectDecompiler.CleanUpFileName(name, ".cs");
|
||||
var path = Path.Combine(tempDir, fileName);
|
||||
|
||||
File.WriteAllText(path, "// " + name);
|
||||
|
||||
File.Exists(path).Should().BeTrue();
|
||||
// Enumerate to guard against the name silently resolving to a device instead of a file.
|
||||
Directory.EnumerateFiles(tempDir).Select(Path.GetFileName).Should().Contain(fileName);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Sanitized_Directory_Names_Are_Creatable([ValueSource(nameof(ReservedNames))] string name)
|
||||
{
|
||||
var dirName = WholeProjectDecompiler.CleanUpDirectoryName(name);
|
||||
var dir = Path.Combine(tempDir, dirName);
|
||||
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllText(Path.Combine(dir, "data.bin"), name);
|
||||
|
||||
File.Exists(Path.Combine(dir, "data.bin")).Should().BeTrue();
|
||||
Directory.EnumerateDirectories(tempDir).Select(Path.GetFileName).Should().Contain(dirName);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Sanitized_Resource_Paths_Are_Creatable([ValueSource(nameof(ReservedNames))] string name)
|
||||
{
|
||||
// Resource names can carry directory structure; both the directory and the file segment
|
||||
// must come out creatable.
|
||||
var relativePath = WholeProjectDecompiler.SanitizeFileName(name + "/" + name + ".png");
|
||||
var path = Path.Combine(tempDir, relativePath);
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.WriteAllText(path, name);
|
||||
|
||||
File.Exists(path).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Save_Code_Default_File_Name_For_A_Type_Named_Con_Is_Creatable()
|
||||
{
|
||||
// The exact crash from issue #3775: the save dialog suggested "Con.cs" and the
|
||||
// StreamWriter in SaveCodeHelper.WriteNodeToFile failed with '\\.\Con'.
|
||||
var path = Path.Combine(tempDir, SaveCodeHelper.SuggestedFileName("Con", ".cs"));
|
||||
|
||||
using (var writer = new StreamWriter(path))
|
||||
{
|
||||
writer.WriteLine("// decompiled output");
|
||||
}
|
||||
|
||||
File.Exists(path).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
||||
// software and associated documentation files (the "Software"), to deal in the Software
|
||||
// without restriction, including without limitation the rights to use, copy, modify, merge,
|
||||
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
|
||||
// to whom the Software is furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all copies or
|
||||
// substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
|
||||
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
// DEALINGS IN THE SOFTWARE.
|
||||
|
||||
using AwesomeAssertions;
|
||||
|
||||
using ILSpy.Commands;
|
||||
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace ICSharpCode.ILSpy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The default file name offered by the save dialog must never be a reserved Windows device
|
||||
/// name (CON, PRN, AUX, NUL, COM1-9, LPT1-9) — Windows cannot create such files, so a type
|
||||
/// named "Con" would make the save crash with an IOException (issue #3775). The suggestion is
|
||||
/// produced by <c>WholeProjectDecompiler.CleanUpFileName</c>, whose sanitization rules are
|
||||
/// covered in depth by ICSharpCode.Decompiler.Tests (ReservedFileSystemNameTests); these tests
|
||||
/// pin the delegation and the fallback for nodes without usable text.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class SuggestedFileNameTests
|
||||
{
|
||||
static readonly string[] ReservedNames = [
|
||||
"AUX", "CON", "NUL", "PRN",
|
||||
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
||||
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||
];
|
||||
|
||||
[Test]
|
||||
public void Save_Code_Escapes_Reserved_Device_Names([ValueSource(nameof(ReservedNames))] string name)
|
||||
{
|
||||
// "Con" + ".cs" would resolve to \\.\CON on Windows, so the escape must end up on the
|
||||
// base name, before the extension.
|
||||
SaveCodeHelper.SuggestedFileName(name, ".cs").Should().Be(name + "_.cs");
|
||||
SaveCodeHelper.SuggestedFileName(name.ToLowerInvariant(), ".cs").Should().Be(name.ToLowerInvariant() + "_.cs");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Save_Code_Keeps_Ordinary_Type_Names_Unchanged()
|
||||
{
|
||||
SaveCodeHelper.SuggestedFileName("Console", ".cs").Should().Be("Console.cs");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Save_Code_Replaces_Invalid_File_Name_Characters()
|
||||
{
|
||||
SaveCodeHelper.SuggestedFileName("a/b", ".cs").Should().Be("a-b.cs");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Save_Code_Falls_Back_To_Output_For_Unusable_Text()
|
||||
{
|
||||
SaveCodeHelper.SuggestedFileName(null, ".cs").Should().Be("output.cs");
|
||||
SaveCodeHelper.SuggestedFileName(" ", ".cs").Should().Be("output.cs");
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using ICSharpCode.Decompiler;
|
||||
using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
|
||||
using ICSharpCode.ILSpy.Properties;
|
||||
using ICSharpCode.ILSpyX;
|
||||
|
||||
@@ -77,7 +78,7 @@ namespace ILSpy.Commands
|
||||
return;
|
||||
var watch = Stopwatch.StartNew();
|
||||
Exception? ex = null;
|
||||
var path = Path.Combine(OutputDir, asm.ShortName + ".cs");
|
||||
var path = Path.Combine(OutputDir, WholeProjectDecompiler.CleanUpFileName(asm.ShortName, ".cs"));
|
||||
try
|
||||
{
|
||||
using var writer = new StreamWriter(path);
|
||||
@@ -144,8 +145,8 @@ namespace ILSpy.Commands
|
||||
return;
|
||||
var watch = Stopwatch.StartNew();
|
||||
Exception? ex = null;
|
||||
var safeName = (asm.Text?.ToString() ?? asm.ShortName).Replace("(", "").Replace(")", "").Replace(' ', '_');
|
||||
var path = Path.Combine(OutputDir, safeName + ".il");
|
||||
var safeName = WholeProjectDecompiler.CleanUpFileName(asm.Text?.ToString() ?? asm.ShortName, ".il");
|
||||
var path = Path.Combine(OutputDir, safeName);
|
||||
try
|
||||
{
|
||||
using var writer = new StreamWriter(path);
|
||||
|
||||
@@ -23,6 +23,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using ICSharpCode.Decompiler;
|
||||
using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
|
||||
using ICSharpCode.ILSpy.Properties;
|
||||
|
||||
using ILSpy.Docking;
|
||||
@@ -60,7 +61,7 @@ namespace ILSpy.Commands
|
||||
return;
|
||||
|
||||
var language = languageService.CurrentLanguage;
|
||||
var defaultName = SuggestedFileName(node) + language.FileExtension;
|
||||
var defaultName = SuggestedFileName(node.Text?.ToString(), language.FileExtension);
|
||||
var path = await FilePickers.SaveAsync(
|
||||
$"{language.Name} (*{language.FileExtension})|*{language.FileExtension}|All files|*.*",
|
||||
defaultName).ConfigureAwait(true);
|
||||
@@ -125,18 +126,14 @@ namespace ILSpy.Commands
|
||||
}
|
||||
}
|
||||
|
||||
// A reasonable default file name for the save dialog: the node's text, cleaned of characters
|
||||
// that aren't valid in a file name; falls back to "output" when nothing usable remains.
|
||||
static string SuggestedFileName(ILSpyTreeNode node)
|
||||
// A reasonable default file name for the save dialog: the node's text run through the
|
||||
// project decompiler's file-name cleanup (which also escapes reserved Windows device names
|
||||
// like "Con"); falls back to "output" when the node has no usable text.
|
||||
internal static string SuggestedFileName(string? text, string extension)
|
||||
{
|
||||
var text = node.Text?.ToString();
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return "output";
|
||||
var clean = text;
|
||||
foreach (var c in Path.GetInvalidFileNameChars())
|
||||
clean = clean.Replace(c, '_');
|
||||
clean = clean.Trim();
|
||||
return string.IsNullOrEmpty(clean) ? "output" : clean;
|
||||
text = "output";
|
||||
return WholeProjectDecompiler.CleanUpFileName(text, extension);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ using System.Runtime.InteropServices;
|
||||
[assembly: NeutralResourcesLanguage("en-US")]
|
||||
|
||||
[assembly: InternalsVisibleTo("ILSpy.Tests")]
|
||||
[assembly: InternalsVisibleTo("ILSpy.Tests.Windows")]
|
||||
|
||||
[assembly: SuppressMessage("Microsoft.Usage", "CA2243:AttributeStringLiteralsShouldParseCorrectly",
|
||||
Justification = "AssemblyInformationalVersion does not need to be a parsable version")]
|
||||
|
||||
@@ -28,6 +28,7 @@ using Avalonia.Controls.Documents;
|
||||
using Avalonia.Media;
|
||||
|
||||
using ICSharpCode.Decompiler;
|
||||
using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
|
||||
using ICSharpCode.Decompiler.Metadata;
|
||||
using ICSharpCode.Decompiler.TypeSystem;
|
||||
using ICSharpCode.ILSpyX;
|
||||
@@ -182,11 +183,10 @@ namespace ILSpy.TreeNodes
|
||||
|
||||
async Task SaveAsProjectOrSingleFileAsync(Language language)
|
||||
{
|
||||
var shortName = CleanSuggestedFileName(assembly.ShortName);
|
||||
var filter = $"{language.Name} project (*{language.ProjectFileExtension})|*{language.ProjectFileExtension}"
|
||||
+ $"|{language.Name} (*{language.FileExtension})|*{language.FileExtension}"
|
||||
+ "|All files (*.*)|*.*";
|
||||
var defaultName = shortName + language.ProjectFileExtension;
|
||||
var defaultName = WholeProjectDecompiler.CleanUpFileName(assembly.ShortName, language.ProjectFileExtension);
|
||||
var path = await Commands.FilePickers.SaveAsync(filter, defaultName, "Save Code").ConfigureAwait(false);
|
||||
if (string.IsNullOrEmpty(path))
|
||||
return;
|
||||
@@ -217,15 +217,6 @@ namespace ILSpy.TreeNodes
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
static string CleanSuggestedFileName(string name)
|
||||
{
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
var clean = name;
|
||||
foreach (var c in invalid)
|
||||
clean = clean.Replace(c, '_');
|
||||
return clean;
|
||||
}
|
||||
|
||||
static LanguageService? TryGetLanguageService()
|
||||
{
|
||||
try
|
||||
|
||||
Reference in New Issue
Block a user