diff --git a/src/modules/imageresizer/ImageResizerCLI/Program.cs b/src/modules/imageresizer/ImageResizerCLI/Program.cs index 2876caa32a..e5615d9d89 100644 --- a/src/modules/imageresizer/ImageResizerCLI/Program.cs +++ b/src/modules/imageresizer/ImageResizerCLI/Program.cs @@ -4,7 +4,7 @@ using System; using System.Globalization; -using System.Text; +using System.IO; using ImageResizer.Cli; using ImageResizer.Cli.Telemetry; @@ -30,7 +30,15 @@ internal static class Program // Ignore invalid culture and fall back to default. } - Console.InputEncoding = Encoding.Unicode; + if (Console.IsInputRedirected) + { + // Redirecting shells write pipeline data using their output encoding, which can + // differ from the console input encoding used by Console.In. + Console.SetIn(new StreamReader( + Console.OpenStandardInput(), + Console.OutputEncoding, + detectEncodingFromByteOrderMarks: true)); + } // Initialize logger to file (same as other modules) CliLogger.Initialize("\\Image Resizer\\CLI"); diff --git a/src/modules/imageresizer/tests/Cli/ImageResizerCliExecutorTests.cs b/src/modules/imageresizer/tests/Cli/ImageResizerCliExecutorTests.cs new file mode 100644 index 0000000000..982ad4e98d --- /dev/null +++ b/src/modules/imageresizer/tests/Cli/ImageResizerCliExecutorTests.cs @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; +using System.IO.Pipes; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using ImageResizer.Models; +using ImageResizer.Properties; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ImageResizer.Cli +{ + [TestClass] + public class ImageResizerCliExecutorTests + { + [TestMethod] + public void GetShrinkOnlyPercentWarning_ReturnsWarningForEffectivePercentSize() + { + var settings = SettingsWithSize(ResizeUnit.Percent); + settings.ShrinkOnly = true; + + var warning = ImageResizerCliExecutor.GetShrinkOnlyPercentWarning(settings); + + Assert.AreEqual(Resources.CLI_WarningShrinkOnlyPercent, warning); + } + + [TestMethod] + public void GetShrinkOnlyPercentWarning_ReturnsNullForPixelSize() + { + var settings = SettingsWithSize(ResizeUnit.Pixel); + settings.ShrinkOnly = true; + + var warning = ImageResizerCliExecutor.GetShrinkOnlyPercentWarning(settings); + + Assert.IsNull(warning); + } + + [TestMethod] + public void GetShrinkOnlyPercentWarning_ReturnsNullWhenShrinkOnlyIsDisabled() + { + var settings = SettingsWithSize(ResizeUnit.Percent); + settings.ShrinkOnly = false; + + var warning = ImageResizerCliExecutor.GetShrinkOnlyPercentWarning(settings); + + Assert.IsNull(warning); + } + + [TestMethod] + public void GetEffectiveSizeValidationError_RejectsHeightOnlyPercentFit() + { + var options = new CliOptions { Height = 50, Unit = ResizeUnit.Percent, Fit = ResizeFit.Fit }; + var settings = SettingsWithSize(ResizeUnit.Pixel); + CliSettingsApplier.Apply(options, settings); + + var error = ImageResizerCliExecutor.GetEffectiveSizeValidationError(options, settings); + + Assert.AreEqual(Resources.CLI_ErrorPercentWidthRequired, error); + } + + [TestMethod] + public void GetEffectiveSizeValidationError_AllowsHeightOnlyPercentStretch() + { + var options = new CliOptions { Height = 50, Unit = ResizeUnit.Percent, Fit = ResizeFit.Stretch }; + var settings = SettingsWithSize(ResizeUnit.Pixel); + CliSettingsApplier.Apply(options, settings); + + var error = ImageResizerCliExecutor.GetEffectiveSizeValidationError(options, settings); + + Assert.IsNull(error); + } + + [TestMethod] + public void GetEffectiveSizeValidationError_AllowsPositiveWidthPercentFit() + { + var options = new CliOptions { Width = 50, Unit = ResizeUnit.Percent, Fit = ResizeFit.Fit }; + var settings = SettingsWithSize(ResizeUnit.Pixel); + CliSettingsApplier.Apply(options, settings); + + var error = ImageResizerCliExecutor.GetEffectiveSizeValidationError(options, settings); + + Assert.IsNull(error); + } + + [TestMethod] + public void GetSizeIndexValidationError_RejectsOutOfRangePreset() + { + var options = new CliOptions { SizeIndex = 999 }; + var settings = SettingsWithSize(ResizeUnit.Pixel); + + var error = ImageResizerCliExecutor.GetSizeIndexValidationError(options, settings); + + StringAssert.Contains(error, "999"); + } + + [TestMethod] + public void GetSizeIndexValidationError_AllowsExistingPreset() + { + var options = new CliOptions { SizeIndex = 0 }; + var settings = SettingsWithSize(ResizeUnit.Pixel); + + var error = ImageResizerCliExecutor.GetSizeIndexValidationError(options, settings); + + Assert.IsNull(error); + } + + [TestMethod] + public void Run_WithOutOfRangePresetAndReplace_DoesNotModifySource() + { + using var directory = new TestDirectory(); + var testDirectory = Path.GetDirectoryName(typeof(ImageResizerCliExecutorTests).Assembly.Location); + var source = Path.Combine(directory, "source.png"); + File.Copy(Path.Combine(testDirectory, "Test.png"), source); + var hashBefore = File.ReadAllBytes(source); + + var exitCode = new ImageResizerCliExecutor().Run(["--size", "999", "--replace", source]); + + Assert.AreEqual(1, exitCode); + CollectionAssert.AreEqual(hashBefore, File.ReadAllBytes(source)); + Assert.AreEqual(1, directory.FileNames.Count()); + } + + [TestMethod] + [Timeout(10000)] + public async Task Run_WithEmptyNamedPipe_ReturnsError() + { + var pipeName = $"ImageResizer-{Guid.NewGuid():N}"; + using var pipe = new NamedPipeServerStream( + pipeName, + PipeDirection.Out, + 1, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous); + var writeTask = CompleteEmptyPipeAsync(pipe); + var executor = new ImageResizerCliExecutor(); + + var exitCode = executor.Run([$@"\\.\pipe\{pipeName}"]); + await writeTask; + + Assert.AreEqual(1, exitCode); + Assert.AreEqual("error", executor.CommandName); + } + + private static Settings SettingsWithSize(ResizeUnit unit) + { + var settings = new Settings(); + settings.CustomSize.Unit = unit; + settings.SelectedSizeIndex = settings.Sizes.Count; + return settings; + } + + private static async Task CompleteEmptyPipeAsync(NamedPipeServerStream pipe) + { + await pipe.WaitForConnectionAsync().ConfigureAwait(false); + using var writer = new StreamWriter(pipe, Encoding.Unicode); + } + } +} diff --git a/src/modules/imageresizer/tests/Models/CliOptionsTests.cs b/src/modules/imageresizer/tests/Models/CliOptionsTests.cs index 3c88a100ba..6ec5e62ffb 100644 --- a/src/modules/imageresizer/tests/Models/CliOptionsTests.cs +++ b/src/modules/imageresizer/tests/Models/CliOptionsTests.cs @@ -3,6 +3,8 @@ // See the LICENSE file in the project root for more information. using System; +using System.Globalization; +using System.IO; using System.Linq; using ImageResizer.Cli.Commands; using ImageResizer.Models; @@ -264,5 +266,270 @@ namespace ImageResizer.Tests.Models Assert.AreEqual(ResizeUnit.Pixel, options.Unit); Assert.AreEqual(ResizeFit.Fit, options.Fit); } + + [TestMethod] + public void Parse_WithUnknownLongOption_ReturnsError() + { + var options = CliOptions.ParseForCli(["--bogus", "test.jpg"]); + + Assert.AreEqual(1, options.ParseErrors.Count); + StringAssert.Contains(options.ParseErrors[0], "--bogus"); + } + + [TestMethod] + public void Parse_WithUnknownOptionAfterFile_ReturnsError() + { + var options = CliOptions.ParseForCli(["test.jpg", "--bogus"]); + + Assert.AreEqual(1, options.ParseErrors.Count); + StringAssert.Contains(options.ParseErrors[0], "--bogus"); + } + + [TestMethod] + public void Parse_WithRootRelativeSlashPath_AllowsFile() + { + var options = CliOptions.ParseForCli(["/bogus", "test.jpg"]); + + Assert.AreEqual(0, options.ParseErrors.Count); + CollectionAssert.Contains(options.Files.ToList(), "/bogus"); + } + + [TestMethod] + public void Parse_WithPrefixedDashFileName_AllowsFile() + { + var options = CliOptions.ParseForCli([@".\-photo.jpg"]); + + Assert.AreEqual(0, options.ParseErrors.Count); + CollectionAssert.Contains(options.Files.ToList(), @".\-photo.jpg"); + } + + [TestMethod] + public void Parse_WithEndOfOptions_AllowsOptionLikeFileName() + { + var options = CliOptions.ParseForCli(["--", "--photo.jpg"]); + + Assert.AreEqual(0, options.ParseErrors.Count); + CollectionAssert.Contains(options.Files.ToList(), "--photo.jpg"); + } + + [TestMethod] + public void Parse_LenientModePreservesOptionLikeFileForGui() + { + var options = CliOptions.Parse(["--photo.jpg"]); + + Assert.AreEqual(0, options.ParseErrors.Count); + CollectionAssert.Contains(options.Files.ToList(), "--photo.jpg"); + } + + [TestMethod] + public void Parse_WithUnknownBundledOption_ReturnsError() + { + var options = CliOptions.ParseForCli(["-replace", "test.jpg"]); + + Assert.AreEqual(1, options.ParseErrors.Count); + StringAssert.Contains(options.ParseErrors[0], "-replace"); + } + + [TestMethod] + public void Parse_WithBundledValueOption_PreservesCompatibility() + { + var options = CliOptions.ParseForCli(["-w100", "test.jpg"]); + + Assert.AreEqual(0, options.ParseErrors.Count); + Assert.AreEqual(100.0, options.Width); + } + + [TestMethod] + public void Parse_WithBundledFlagAndValueOption_PreservesCompatibility() + { + var options = CliOptions.ParseForCli(["-rq85", "test.jpg"]); + + Assert.AreEqual(0, options.ParseErrors.Count); + Assert.AreEqual(true, options.Replace); + Assert.AreEqual(85, options.JpegQualityLevel); + } + + [TestMethod] + public void Parse_WithInvalidBundleInResponseFile_ReturnsError() + { + var responseFile = Path.Combine(Path.GetTempPath(), $"ImageResizer-{Guid.NewGuid():N}.rsp"); + try + { + File.WriteAllLines(responseFile, ["-rphoto.jpg", "photo.jpg"]); + + var options = CliOptions.ParseForCli(["@" + responseFile]); + + Assert.AreEqual(1, options.ParseErrors.Count); + StringAssert.Contains(options.ParseErrors[0], "-rphoto.jpg"); + } + finally + { + File.Delete(responseFile); + } + } + + [TestMethod] + public void Parse_WithEndOfOptionsInResponseFile_AllowsOptionLikeFiles() + { + var responseFile = Path.Combine(Path.GetTempPath(), $"ImageResizer-{Guid.NewGuid():N}.rsp"); + try + { + File.WriteAllLines(responseFile, ["--"]); + + var options = CliOptions.ParseForCli(["@" + responseFile, "-rphoto.jpg", "photo.jpg"]); + + Assert.AreEqual(0, options.ParseErrors.Count); + CollectionAssert.Contains(options.Files.ToList(), "-rphoto.jpg"); + CollectionAssert.Contains(options.Files.ToList(), "photo.jpg"); + } + finally + { + File.Delete(responseFile); + } + } + + [TestMethod] + public void Parse_WithConversionError_PreservesFilesAndPipeForLenientCallers() + { + var options = CliOptions.Parse(["--width", "abc", "good.jpg", @"\\.\pipe\ImageResizerTest"]); + + Assert.IsTrue(options.ParseErrors.Count > 0); + CollectionAssert.Contains(options.Files.ToList(), "good.jpg"); + Assert.AreEqual("ImageResizerTest", options.PipeName); + } + + [TestMethod] + public void Parse_WithConversionError_PreservesValidDestinationForLenientCallers() + { + var options = CliOptions.Parse(["/d", @"C:\Output", "--width", "bad", "good.jpg"]); + + Assert.IsTrue(options.ParseErrors.Count > 0); + Assert.AreEqual(@"C:\Output", options.DestinationDirectory); + CollectionAssert.Contains(options.Files.ToList(), "good.jpg"); + } + + [TestMethod] + public void Parse_WithOptionLikeFilenameValue_DoesNotReportBundleError() + { + var options = CliOptions.ParseForCli(["--filename", "-rphoto.jpg", "photo.jpg"]); + + Assert.AreEqual(0, options.ParseErrors.Count); + Assert.AreEqual("-rphoto.jpg", options.FileName); + } + + [TestMethod] + public void Parse_WithEmptyLongOptionSeparator_ConsumesFollowingOptionLikeValue() + { + var options = CliOptions.ParseForCli(["--filename=", "-rphoto.jpg", "photo.jpg"]); + + Assert.AreEqual(0, options.ParseErrors.Count); + Assert.AreEqual("-rphoto.jpg", options.FileName); + CollectionAssert.Contains(options.Files.ToList(), "photo.jpg"); + } + + [TestMethod] + public void Parse_WithEmptyShortOptionSeparator_ConsumesFollowingOptionLikeValue() + { + var options = CliOptions.ParseForCli(["-n=", "-rphoto.jpg", "photo.jpg"]); + + Assert.AreEqual(0, options.ParseErrors.Count); + Assert.AreEqual("-rphoto.jpg", options.FileName); + CollectionAssert.Contains(options.Files.ToList(), "photo.jpg"); + } + + [TestMethod] + public void Parse_WithExplicitBundledBooleanValue_PreservesCompatibility() + { + var options = CliOptions.ParseForCli(["-r=false", "photo.jpg"]); + + Assert.AreEqual(0, options.ParseErrors.Count); + Assert.IsNull(options.Replace); + CollectionAssert.Contains(options.Files.ToList(), "photo.jpg"); + } + + [DataTestMethod] + [DataRow("-rtrue")] + [DataRow("-rTRUE")] + public void Parse_WithAttachedTrueBooleanValue_PreservesCompatibility(string argument) + { + var options = CliOptions.ParseForCli([argument, "-q85", "photo.jpg"]); + + Assert.AreEqual(0, options.ParseErrors.Count); + Assert.AreEqual(true, options.Replace); + Assert.AreEqual(85, options.JpegQualityLevel); + CollectionAssert.Contains(options.Files.ToList(), "photo.jpg"); + } + + [TestMethod] + public void Parse_WithBundledEmptyValue_DoesNotSkipFollowingInvalidBundle() + { + var options = CliOptions.ParseForCli(["-rn=", "-rphoto.jpg", "photo.jpg"]); + + Assert.AreEqual(1, options.ParseErrors.Count); + StringAssert.Contains(options.ParseErrors[0], "-rphoto.jpg"); + } + + [TestMethod] + public void Parse_WithNegativeWidth_ReturnsError() + { + var options = CliOptions.Parse(["--width", "-100", "--height", "100", "test.jpg"]); + + Assert.AreEqual(1, options.ParseErrors.Count); + } + + [DataTestMethod] + [DataRow("NaN")] + [DataRow("Infinity")] + [DataRow("-Infinity")] + public void Parse_WithNonFiniteDimension_ReturnsError(string value) + { + var options = CliOptions.Parse(["--width", value, "--height", "100", "test.jpg"]); + + Assert.IsTrue(options.ParseErrors.Count > 0); + } + + [TestMethod] + public void Parse_WithBothCustomDimensionsZero_ReturnsError() + { + var options = CliOptions.Parse(["--width", "0", "--height", "0", "test.jpg"]); + + Assert.AreEqual(1, options.ParseErrors.Count); + } + + [TestMethod] + public void Parse_WithOnlyZeroWidth_ReturnsError() + { + var options = CliOptions.Parse(["--width", "0", "test.jpg"]); + + Assert.AreEqual(1, options.ParseErrors.Count); + } + + [TestMethod] + public void Parse_WithDimensionAboveInt32Range_ReturnsError() + { + var options = CliOptions.Parse(["--width", "2147483648", "--height", "100", "test.jpg"]); + + Assert.AreEqual(1, options.ParseErrors.Count); + } + + [DataTestMethod] + [DataRow("en-US")] + [DataRow("en-HK")] + public void Parse_WithGroupedDimensionAboveInt32Range_ReturnsError(string cultureName) + { + var originalCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(cultureName); + + var options = CliOptions.Parse(["--width", "2,147,483,648", "--height", "100", "test.jpg"]); + + Assert.AreEqual(1, options.ParseErrors.Count); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + } + } } } diff --git a/src/modules/imageresizer/tests/Models/ResizeBatchTests.cs b/src/modules/imageresizer/tests/Models/ResizeBatchTests.cs index 12b2cb4830..377a8f3242 100644 --- a/src/modules/imageresizer/tests/Models/ResizeBatchTests.cs +++ b/src/modules/imageresizer/tests/Models/ResizeBatchTests.cs @@ -8,11 +8,15 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; +using System.IO.Pipes; using System.Linq; +using System.Runtime.InteropServices; +using System.Text; using System.Threading; using System.Threading.Tasks; using ImageResizer.Properties; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.Win32.SafeHandles; using Moq; using Moq.Protected; @@ -91,6 +95,225 @@ namespace ImageResizer.Models Assert.AreEqual(2, calls.Count); } + [TestMethod] + public void FromCliOptionsWithDiagnostics_ReportsMissingAndUnsupportedInputs() + { + using var directory = new TestDirectory(); + var unsupported = Path.Combine(directory, "notes.txt"); + var missing = Path.Combine(directory, "missing.png"); + File.WriteAllText(unsupported, "not an image"); + var options = Options(unsupported, missing); + + var batch = ResizeBatch.FromCliOptionsWithDiagnostics(null, options); + + Assert.AreEqual(0, batch.Files.Count); + Assert.AreEqual(2, batch.InputErrors.Count); + CollectionAssert.AreEquivalent( + new[] { unsupported, missing }, + batch.InputErrors.Select(error => error.File).ToArray()); + } + + [TestMethod] + public void FromCliOptionsWithDiagnostics_ResolvesWildcardMatches() + { + using var directory = new TestDirectory(); + var first = CopyTestImage(directory, "first.jpg"); + var second = CopyTestImage(directory, "second.jpg"); + var options = Options(Path.Combine(directory, "*.jpg")); + + var batch = ResizeBatch.FromCliOptionsWithDiagnostics(null, options); + + Assert.AreEqual(0, batch.InputErrors.Count); + CollectionAssert.AreEquivalent(new[] { first, second }, batch.Files.ToArray()); + } + + [TestMethod] + public void FromCliOptionsWithDiagnostics_DeduplicatesExplicitAndOverlappingWildcardPaths() + { + using var directory = new TestDirectory(); + var file = CopyTestImage(directory, "overlap.jpg"); + var options = Options(file, file.ToUpperInvariant(), Path.Combine(directory, "*.jpg")); + + var batch = ResizeBatch.FromCliOptionsWithDiagnostics(null, options); + + Assert.AreEqual(0, batch.InputErrors.Count); + CollectionAssert.AreEqual(new[] { file }, batch.Files.ToArray()); + } + + [TestMethod] + public void FromCliOptionsWithDiagnostics_DeduplicatesExtendedPathAlias() + { + using var directory = new TestDirectory(); + var file = CopyTestImage(directory, "alias.jpg"); + var options = Options(file, ToExtendedPath(file)); + + var batch = ResizeBatch.FromCliOptionsWithDiagnostics(null, options); + + Assert.AreEqual(0, batch.InputErrors.Count); + CollectionAssert.AreEqual(new[] { file }, batch.Files.ToArray()); + } + + [TestMethod] + public void FromCliOptionsWithDiagnostics_DeduplicatesLongNormalAndExtendedPathAliases() + { + using var directory = new TestDirectory(); + var longDirectory = Path.Combine(directory, new string('a', 120), new string('b', 120)); + Directory.CreateDirectory(longDirectory); + var sourceDirectory = Path.GetDirectoryName(typeof(ResizeBatchTests).Assembly.Location); + var file = Path.Combine(longDirectory, "long-path.jpg"); + File.Copy(Path.Combine(sourceDirectory, "Test.jpg"), file); + Assert.IsTrue(file.Length > 260); + var normalAlias = file.ToUpperInvariant(); + + var batch = ResizeBatch.FromCliOptionsWithDiagnostics( + null, + Options(normalAlias, ToExtendedPath(file))); + + Assert.AreEqual(0, batch.InputErrors.Count); + CollectionAssert.AreEqual(new[] { normalAlias }, batch.Files.ToArray()); + } + + [TestMethod] + public void FromCliOptionsWithDiagnostics_PreservesCaseOnlyFilesInCaseSensitiveDirectory() + { + using var directory = new TestDirectory(); + if (!TryEnableCaseSensitivity(directory, out var errorCode)) + { + if (errorCode is 1 or 50 or 87) + { + Assert.Inconclusive($"The test filesystem does not support per-directory case sensitivity (Win32 error {errorCode})."); + } + + Assert.Fail($"Failed to enable per-directory case sensitivity (Win32 error {errorCode})."); + } + + var first = CopyTestImage(directory, "Photo.jpg"); + var second = CopyTestImage(directory, "photo.jpg"); + + var batch = ResizeBatch.FromCliOptionsWithDiagnostics(null, Options(first, second)); + + Assert.AreEqual(0, batch.InputErrors.Count); + CollectionAssert.AreEquivalent(new[] { first, second }, batch.Files.ToArray()); + } + + [TestMethod] + [Timeout(10000)] + public async Task FromCliOptionsWithDiagnostics_NamedPipeReportsInvalidInputsAndDeduplicatesValidFiles() + { + using var directory = new TestDirectory(); + var valid = CopyTestImage(directory, "pipe.jpg"); + var unsupported = Path.Combine(directory, "notes.txt"); + var missing = Path.Combine(directory, "missing.jpg"); + File.WriteAllText(unsupported, "not an image"); + var pipeName = $"ImageResizer-{Guid.NewGuid():N}"; + using var pipe = CreatePipeServer(pipeName); + var writeTask = WritePipeLinesAsync(pipe, ToExtendedPath(valid), unsupported, missing); + var options = Options(valid); + options.PipeName = pipeName; + + var batch = ResizeBatch.FromCliOptionsWithDiagnostics(null, options); + await writeTask; + + CollectionAssert.AreEqual(new[] { valid }, batch.Files.ToArray()); + CollectionAssert.AreEquivalent( + new[] { unsupported, missing }, + batch.InputErrors.Select(error => error.File).ToArray()); + } + + [TestMethod] + [Timeout(10000)] + public async Task FromCliOptions_NamedPipeRemainsLenient() + { + using var directory = new TestDirectory(); + var valid = CopyTestImage(directory, "pipe.jpg"); + var missing = Path.Combine(directory, "missing.jpg"); + var pipeName = $"ImageResizer-{Guid.NewGuid():N}"; + using var pipe = CreatePipeServer(pipeName); + var writeTask = WritePipeLinesAsync(pipe, valid, missing); + var options = Options(); + options.PipeName = pipeName; + + var batch = ResizeBatch.FromCliOptions(null, options); + await writeTask; + + CollectionAssert.AreEqual(new[] { valid }, batch.Files.ToArray()); + Assert.AreEqual(0, batch.InputErrors.Count); + } + + [TestMethod] + public void FromCliOptionsWithDiagnostics_ReportsWildcardWithNoMatches() + { + using var directory = new TestDirectory(); + var pattern = Path.Combine(directory, "*.jpg"); + var options = Options(pattern); + + var batch = ResizeBatch.FromCliOptionsWithDiagnostics(null, options); + + Assert.AreEqual(0, batch.Files.Count); + Assert.AreEqual(1, batch.InputErrors.Count); + Assert.AreEqual(pattern, batch.InputErrors[0].File); + } + + [TestMethod] + public void FromCliOptionsWithDiagnostics_PreservesValidFilesInMixedInput() + { + using var directory = new TestDirectory(); + var valid = CopyTestImage(directory, "valid.jpg"); + var missing = Path.Combine(directory, "missing.jpg"); + var options = Options(valid, missing); + + var batch = ResizeBatch.FromCliOptionsWithDiagnostics(null, options); + + CollectionAssert.AreEqual(new[] { valid }, batch.Files.ToArray()); + Assert.AreEqual(1, batch.InputErrors.Count); + Assert.AreEqual(missing, batch.InputErrors[0].File); + } + + [TestMethod] + public void FromCliOptions_RemainsLenientForInvalidInputs() + { + using var directory = new TestDirectory(); + var options = Options(Path.Combine(directory, "missing.jpg")); + + var batch = ResizeBatch.FromCliOptions(null, options); + + Assert.AreEqual(0, batch.Files.Count); + Assert.AreEqual(0, batch.InputErrors.Count); + } + + [TestMethod] + public async Task ProcessIncludesStrictInputDiagnostics() + { + using var directory = new TestDirectory(); + var firstMissing = Path.Combine(directory, "first-missing.jpg"); + var secondMissing = Path.Combine(directory, "second-missing.jpg"); + var batch = ResizeBatch.FromCliOptionsWithDiagnostics(null, Options(firstMissing, secondMissing)); + + var errors = (await batch.ProcessAsync((_, __) => { }, CancellationToken.None)).ToList(); + + Assert.AreEqual(2, errors.Count); + CollectionAssert.AreEqual(new[] { firstMissing, secondMissing }, errors.Select(error => error.File).ToArray()); + } + + [TestMethod] + public void FormatErrorMessage_PreservesNonEmptyMessage() + { + const string message = "Decoder failed."; + + var result = ResizeBatch.FormatErrorMessage(new InvalidOperationException(message)); + + Assert.AreEqual(message, result); + } + + [TestMethod] + public void FormatErrorMessage_UsesTypeAndHResultWhenMessageIsEmpty() + { + var result = ResizeBatch.FormatErrorMessage(new EmptyMessageException()); + + StringAssert.Contains(result, nameof(EmptyMessageException)); + StringAssert.Contains(result, "0x88982F60"); + } + private static ResizeBatch CreateBatch(Action executeAction) { var mock = new Mock { CallBase = true }; @@ -104,5 +327,119 @@ namespace ImageResizer.Models return mock.Object; } + + private static CliOptions Options(params string[] files) + { + var options = new CliOptions(); + foreach (var file in files) + { + options.Files.Add(file); + } + + return options; + } + + private static string CopyTestImage(TestDirectory directory, string fileName) + { + var sourceDirectory = Path.GetDirectoryName(typeof(ResizeBatchTests).Assembly.Location); + var destination = Path.Combine(directory, fileName); + File.Copy(Path.Combine(sourceDirectory, "Test.jpg"), destination); + return destination; + } + + private static string ToExtendedPath(string path) + => path.StartsWith(@"\\", StringComparison.Ordinal) + ? string.Concat(@"\\?\UNC\", path.AsSpan(2)) + : @"\\?\" + path; + + private static NamedPipeServerStream CreatePipeServer(string pipeName) + => new NamedPipeServerStream( + pipeName, + PipeDirection.Out, + 1, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous); + + private static async Task WritePipeLinesAsync(NamedPipeServerStream pipe, params string[] lines) + { + await pipe.WaitForConnectionAsync().ConfigureAwait(false); + using var writer = new StreamWriter(pipe, Encoding.Unicode); + foreach (var line in lines) + { + await writer.WriteLineAsync(line).ConfigureAwait(false); + } + } + + private static bool TryEnableCaseSensitivity(string directory, out int errorCode) + { + const uint fileWriteAttributes = 0x00000100; + const uint fileShareRead = 0x00000001; + const uint fileShareWrite = 0x00000002; + const uint fileShareDelete = 0x00000004; + const uint openExisting = 3; + const uint fileFlagBackupSemantics = 0x02000000; + const uint fileCaseSensitiveDirectory = 0x00000001; + + using SafeFileHandle handle = CreateFile( + directory, + fileWriteAttributes, + fileShareRead | fileShareWrite | fileShareDelete, + IntPtr.Zero, + openExisting, + fileFlagBackupSemantics, + IntPtr.Zero); + if (handle.IsInvalid) + { + errorCode = Marshal.GetLastWin32Error(); + return false; + } + + var info = new FileCaseSensitiveInfo { Flags = fileCaseSensitiveDirectory }; + var result = SetFileInformationByHandle( + handle, + FileInfoByHandleClass.FileCaseSensitiveInfo, + ref info, + (uint)Marshal.SizeOf()); + errorCode = result ? 0 : Marshal.GetLastWin32Error(); + return result; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetFileInformationByHandle( + SafeFileHandle fileHandle, + FileInfoByHandleClass fileInformationClass, + ref FileCaseSensitiveInfo fileInformation, + uint bufferSize); + + private sealed class EmptyMessageException : Exception + { + public EmptyMessageException() + : base(string.Empty) + { + HResult = unchecked((int)0x88982F60); + } + } + + private enum FileInfoByHandleClass + { + FileCaseSensitiveInfo = 23, + } + + [StructLayout(LayoutKind.Sequential)] + private struct FileCaseSensitiveInfo + { + public uint Flags; + } } } diff --git a/src/modules/imageresizer/tests/Models/ResizeOperationTests.cs b/src/modules/imageresizer/tests/Models/ResizeOperationTests.cs index d4ab696f32..3e1ecb577e 100644 --- a/src/modules/imageresizer/tests/Models/ResizeOperationTests.cs +++ b/src/modules/imageresizer/tests/Models/ResizeOperationTests.cs @@ -76,7 +76,8 @@ namespace ImageResizer.Models { var path = Path.Combine(_directory, "Test.png"); File.Copy("Test.png", path); - + var expectedDateModified = new DateTime(2001, 2, 3, 4, 5, 6, DateTimeKind.Utc); + File.SetLastWriteTimeUtc(path, expectedDateModified); var originalDateModified = File.GetLastWriteTimeUtc(path); var operation = new ResizeOperation( @@ -91,7 +92,9 @@ namespace ImageResizer.Models await operation.ExecuteAsync(); + Assert.AreEqual(expectedDateModified, originalDateModified); Assert.AreEqual(originalDateModified, File.GetLastWriteTimeUtc(_directory.File())); + await AssertEx.ImageAsync(_directory.File(), decoder => Assert.AreEqual(96u, decoder.PixelWidth)); } [TestMethod] @@ -334,6 +337,44 @@ namespace ImageResizer.Models decoder => Assert.AreEqual(96u, decoder.PixelHeight)); } + [TestMethod] + public async Task TransformRejectsScaledDimensionsAboveInt32RangeBeforeWriting() + { + var operation = new ResizeOperation( + "Test.png", + _directory, + Settings( + settings => + { + settings.SelectedSize.Fit = ResizeFit.Stretch; + settings.SelectedSize.Width = (double)int.MaxValue + 1; + settings.SelectedSize.Height = 100; + })); + + await Assert.ThrowsExactlyAsync(() => operation.ExecuteAsync()); + + Assert.AreEqual(0, _directory.FileNames.Count()); + } + + [TestMethod] + public async Task TransformRejectsNegativeFractionalDimensionsBeforeRounding() + { + var operation = new ResizeOperation( + "Test.png", + _directory, + Settings( + settings => + { + settings.SelectedSize.Fit = ResizeFit.Stretch; + settings.SelectedSize.Width = -0.4; + settings.SelectedSize.Height = 100; + })); + + await Assert.ThrowsExactlyAsync(() => operation.ExecuteAsync()); + + Assert.AreEqual(0, _directory.FileNames.Count()); + } + [TestMethod] public async Task TransformHonorsUnit() { @@ -395,6 +436,31 @@ namespace ImageResizer.Models }); } + [TestMethod] + public async Task TransformRoundsPositiveFractionalFillDimensionsToAtLeastOnePixel() + { + var operation = new ResizeOperation( + "TestPortrait.png", + _directory, + Settings( + settings => + { + settings.SelectedSize.Fit = ResizeFit.Fill; + settings.SelectedSize.Width = 0.4; + settings.SelectedSize.Height = 100; + })); + + await operation.ExecuteAsync(); + + await AssertEx.ImageAsync( + _directory.File(), + decoder => + { + Assert.AreEqual(1u, decoder.PixelWidth); + Assert.AreEqual(100u, decoder.PixelHeight); + }); + } + [TestMethod] public async Task TransformHonorsFitWhenStretch() { diff --git a/src/modules/imageresizer/ui/Cli/ImageResizerCliExecutor.cs b/src/modules/imageresizer/ui/Cli/ImageResizerCliExecutor.cs index 00d593344a..90ec9ae869 100644 --- a/src/modules/imageresizer/ui/Cli/ImageResizerCliExecutor.cs +++ b/src/modules/imageresizer/ui/Cli/ImageResizerCliExecutor.cs @@ -31,7 +31,7 @@ namespace ImageResizer.Cli /// Exit code. public int Run(string[] args) { - var cliOptions = CliOptions.Parse(args); + var cliOptions = CliOptions.ParseForCli(args); if (cliOptions.ParseErrors.Count > 0) { @@ -60,7 +60,9 @@ namespace ImageResizer.Cli return 0; } - if (cliOptions.Files.Count == 0 && string.IsNullOrEmpty(cliOptions.PipeName)) + if (cliOptions.Files.Count == 0 && + string.IsNullOrEmpty(cliOptions.PipeName) && + !Console.IsInputRedirected) { Console.WriteLine(Resources.CLI_NoInputFiles); CliOptions.PrintUsage(); @@ -73,10 +75,44 @@ namespace ImageResizer.Cli private async Task RunSilentModeAsync(CliOptions cliOptions) { - var batch = ResizeBatch.FromCliOptions(Console.In, cliOptions); + var batch = ResizeBatch.FromCliOptionsWithDiagnostics(Console.In, cliOptions); + if (batch.Files.Count == 0 && + batch.InputErrors.Count == 0) + { + Console.WriteLine(Resources.CLI_NoInputFiles); + CliOptions.PrintUsage(); + CommandName = "error"; + return 1; + } + var settings = Settings.Default; + var sizeIndexValidationError = GetSizeIndexValidationError(cliOptions, settings); + if (sizeIndexValidationError != null) + { + Console.Error.WriteLine(sizeIndexValidationError); + CliLogger.Error($"Validation error: {sizeIndexValidationError}"); + CommandName = "error"; + return 1; + } + CliSettingsApplier.Apply(cliOptions, settings); + var sizeValidationError = GetEffectiveSizeValidationError(cliOptions, settings); + if (sizeValidationError != null) + { + Console.Error.WriteLine(sizeValidationError); + CliLogger.Error($"Validation error: {sizeValidationError}"); + CommandName = "error"; + return 1; + } + + var compatibilityWarning = GetShrinkOnlyPercentWarning(settings); + if (compatibilityWarning != null) + { + Console.Error.WriteLine(compatibilityWarning); + CliLogger.Warn(compatibilityWarning); + } + CliLogger.Info($"CLI mode: processing {batch.Files.Count} files"); // Use accessible line-based progress if requested or detected @@ -130,5 +166,38 @@ namespace ImageResizer.Cli Console.WriteLine(Resources.CLI_AllFilesProcessed); return 0; } + + internal static string GetShrinkOnlyPercentWarning(Settings settings) + => settings.ShrinkOnly && settings.SelectedSize.Unit == ResizeUnit.Percent + ? Resources.CLI_WarningShrinkOnlyPercent + : null; + + internal static string GetEffectiveSizeValidationError(CliOptions options, Settings settings) + { + if ((options.Width.HasValue || options.Height.HasValue) && + settings.SelectedSize.Unit == ResizeUnit.Percent && + settings.SelectedSize.Fit != ResizeFit.Stretch && + settings.SelectedSize.Width <= 0) + { + return Resources.CLI_ErrorPercentWidthRequired; + } + + return null; + } + + internal static string GetSizeIndexValidationError(CliOptions options, Settings settings) + { + if (options.SizeIndex.HasValue && + (options.SizeIndex.Value < 0 || options.SizeIndex.Value >= settings.Sizes.Count)) + { + return string.Format( + CultureInfo.InvariantCulture, + Resources.CLI_ErrorSizeIndexOutOfRange, + options.SizeIndex.Value, + settings.Sizes.Count - 1); + } + + return null; + } } } diff --git a/src/modules/imageresizer/ui/Cli/Options/DimensionOptionValidator.cs b/src/modules/imageresizer/ui/Cli/Options/DimensionOptionValidator.cs new file mode 100644 index 0000000000..1d8ee6894a --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/DimensionOptionValidator.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace ImageResizer.Cli.Options +{ + internal static class DimensionOptionValidator + { + internal static string Validate(string valueText) + { + if (string.IsNullOrWhiteSpace(valueText)) + { + return null; + } + + if (!double.TryParse(valueText, out var value)) + { + // Leave type-conversion errors to System.CommandLine. + return null; + } + + if (double.IsNaN(value) || double.IsInfinity(value) || value < 0) + { + return Properties.Resources.CLI_ErrorInvalidDimension; + } + + if (value > int.MaxValue) + { + return Properties.Resources.Error_DimensionOutOfRange; + } + + return null; + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/HeightOption.cs b/src/modules/imageresizer/ui/Cli/Options/HeightOption.cs index 7abbff7cf6..73a4f1cddf 100644 --- a/src/modules/imageresizer/ui/Cli/Options/HeightOption.cs +++ b/src/modules/imageresizer/ui/Cli/Options/HeightOption.cs @@ -13,6 +13,14 @@ namespace ImageResizer.Cli.Options public HeightOption() : base(_aliases, Properties.Resources.CLI_Option_Height) { + AddValidator(result => + { + var error = DimensionOptionValidator.Validate(result.Tokens.Count == 1 ? result.Tokens[0].Value : null); + if (error != null) + { + result.ErrorMessage = error; + } + }); } } } diff --git a/src/modules/imageresizer/ui/Cli/Options/WidthOption.cs b/src/modules/imageresizer/ui/Cli/Options/WidthOption.cs index 64b8a2091d..a49da85a11 100644 --- a/src/modules/imageresizer/ui/Cli/Options/WidthOption.cs +++ b/src/modules/imageresizer/ui/Cli/Options/WidthOption.cs @@ -13,6 +13,14 @@ namespace ImageResizer.Cli.Options public WidthOption() : base(_aliases, Properties.Resources.CLI_Option_Width) { + AddValidator(result => + { + var error = DimensionOptionValidator.Validate(result.Tokens.Count == 1 ? result.Tokens[0].Value : null); + if (error != null) + { + result.ErrorMessage = error; + } + }); } } } diff --git a/src/modules/imageresizer/ui/Models/CliOptions.cs b/src/modules/imageresizer/ui/Models/CliOptions.cs index e188e77b3e..e940289c79 100644 --- a/src/modules/imageresizer/ui/Models/CliOptions.cs +++ b/src/modules/imageresizer/ui/Models/CliOptions.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.CommandLine.Parsing; using System.Globalization; +using System.Linq; using ImageResizer.Cli.Commands; using ImageResizer.Helpers; @@ -61,63 +62,284 @@ namespace ImageResizer.Models private static bool? ToBoolOrNull(bool value) => value ? true : null; public static CliOptions Parse(string[] args) + => ParseCore(args, rejectOptionLikeFiles: false); + + internal static CliOptions ParseForCli(string[] args) + => ParseCore(args, rejectOptionLikeFiles: true); + + private static CliOptions ParseCore(string[] args, bool rejectOptionLikeFiles) { var options = new CliOptions(); var cmd = new ImageResizerRootCommand(); var parseResult = new Parser(cmd).Parse(args); + var errors = new List(parseResult.Errors.Count + 1); - if (parseResult.Errors.Count > 0) + foreach (var error in parseResult.Errors) { - var errors = new List(parseResult.Errors.Count); - foreach (var error in parseResult.Errors) - { - errors.Add(error.Message); - } + errors.Add(error.Message); + } + var files = parseResult.GetValueForArgument(cmd.FilesArgument); + PopulateInputs(options, files); + PopulateOptionValues(options, cmd, parseResult); + + if (errors.Count > 0) + { + options.ParseErrors = new ReadOnlyCollection(errors); + return options; + } + + if ((options.Width.HasValue || options.Height.HasValue) && + (options.Width ?? 0) == 0 && + (options.Height ?? 0) == 0) + { + errors.Add(Properties.Resources.CLI_ErrorZeroDimensions); options.ParseErrors = new ReadOnlyCollection(errors); } - options.ShowHelp = parseResult.GetValueForOption(cmd.HelpOption); - options.ShowConfig = parseResult.GetValueForOption(cmd.ShowConfigOption); - options.DestinationDirectory = parseResult.GetValueForOption(cmd.DestinationOption); - options.Width = parseResult.GetValueForOption(cmd.WidthOption); - options.Height = parseResult.GetValueForOption(cmd.HeightOption); - options.Unit = parseResult.GetValueForOption(cmd.UnitOption); - options.Fit = parseResult.GetValueForOption(cmd.FitOption); - options.SizeIndex = parseResult.GetValueForOption(cmd.SizeOption); - - options.ShrinkOnly = ToBoolOrNull(parseResult.GetValueForOption(cmd.ShrinkOnlyOption)); - options.Replace = ToBoolOrNull(parseResult.GetValueForOption(cmd.ReplaceOption)); - options.IgnoreOrientation = ToBoolOrNull(parseResult.GetValueForOption(cmd.IgnoreOrientationOption)); - options.RemoveMetadata = ToBoolOrNull(parseResult.GetValueForOption(cmd.RemoveMetadataOption)); - options.KeepDateModified = ToBoolOrNull(parseResult.GetValueForOption(cmd.KeepDateModifiedOption)); - options.ProgressLines = ToBoolOrNull(parseResult.GetValueForOption(cmd.ProgressLinesOption)); - - options.JpegQualityLevel = parseResult.GetValueForOption(cmd.QualityOption); - - options.FileName = parseResult.GetValueForOption(cmd.FileNameOption); - - var files = parseResult.GetValueForArgument(cmd.FilesArgument); - if (files != null) + if (rejectOptionLikeFiles) { - const string pipeNamePrefix = "\\\\.\\pipe\\"; - foreach (var file in files) + var validationArgs = ExpandTokensForValidation(args); + AddOptionLikeFileErrors(validationArgs, parseResult, cmd.Options, options.Files, errors); + if (errors.Count > 0) { - if (file.StartsWith(pipeNamePrefix, StringComparison.OrdinalIgnoreCase)) - { - options.PipeName = file.Substring(pipeNamePrefix.Length); - } - else - { - options.Files.Add(file); - } + options.ParseErrors = new ReadOnlyCollection(errors); } } return options; } + private static void PopulateOptionValues(CliOptions options, ImageResizerRootCommand command, ParseResult parseResult) + { + options.ShowHelp = GetValidOptionValue(parseResult, command.HelpOption); + options.ShowConfig = GetValidOptionValue(parseResult, command.ShowConfigOption); + options.DestinationDirectory = GetValidOptionValue(parseResult, command.DestinationOption); + options.Width = GetValidOptionValue(parseResult, command.WidthOption); + options.Height = GetValidOptionValue(parseResult, command.HeightOption); + options.Unit = GetValidOptionValue(parseResult, command.UnitOption); + options.Fit = GetValidOptionValue(parseResult, command.FitOption); + options.SizeIndex = GetValidOptionValue(parseResult, command.SizeOption); + + options.ShrinkOnly = ToBoolOrNull(GetValidOptionValue(parseResult, command.ShrinkOnlyOption)); + options.Replace = ToBoolOrNull(GetValidOptionValue(parseResult, command.ReplaceOption)); + options.IgnoreOrientation = ToBoolOrNull(GetValidOptionValue(parseResult, command.IgnoreOrientationOption)); + options.RemoveMetadata = ToBoolOrNull(GetValidOptionValue(parseResult, command.RemoveMetadataOption)); + options.KeepDateModified = ToBoolOrNull(GetValidOptionValue(parseResult, command.KeepDateModifiedOption)); + options.ProgressLines = ToBoolOrNull(GetValidOptionValue(parseResult, command.ProgressLinesOption)); + + options.JpegQualityLevel = GetValidOptionValue(parseResult, command.QualityOption); + options.FileName = GetValidOptionValue(parseResult, command.FileNameOption); + } + + private static T GetValidOptionValue(ParseResult parseResult, System.CommandLine.Option option) + { + var optionResult = parseResult.FindResultFor(option); + if (optionResult != null && !string.IsNullOrEmpty(optionResult.ErrorMessage)) + { + return default; + } + + try + { + return parseResult.GetValueForOption(option); + } + catch (InvalidOperationException) + { + return default; + } + } + + private static void AddOptionLikeFileErrors( + IReadOnlyList args, + ParseResult parseResult, + IReadOnlyList options, + ICollection files, + ICollection errors) + { + var escapedCounts = new Dictionary(StringComparer.Ordinal); + var afterEndOfOptions = false; + foreach (var token in parseResult.Tokens) + { + if (!afterEndOfOptions && token.Type == TokenType.DoubleDash) + { + afterEndOfOptions = true; + continue; + } + + if (afterEndOfOptions && LooksLikeOption(token.Value)) + { + escapedCounts.TryGetValue(token.Value, out var count); + escapedCounts[token.Value] = count + 1; + } + } + + var optionLikeErrors = new List(); + foreach (var file in files.Reverse()) + { + if (!LooksLikeOption(file)) + { + continue; + } + + if (escapedCounts.TryGetValue(file, out var count) && count > 0) + { + escapedCounts[file] = count - 1; + continue; + } + + optionLikeErrors.Add(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ErrorUnknownOption, file)); + } + + optionLikeErrors.Reverse(); + foreach (var error in optionLikeErrors) + { + errors.Add(error); + } + + AddInvalidBundleErrors(args, options, errors); + } + + private static bool LooksLikeOption(string value) + => value?.Length > 1 && value[0] == '-'; + + private static void AddInvalidBundleErrors( + IReadOnlyList args, + IReadOnlyList options, + ICollection errors) + { + var aliasMap = options + .SelectMany(option => option.Aliases.Select(alias => (Alias: alias, Option: option))) + .ToDictionary(item => item.Alias, item => item.Option, StringComparer.Ordinal); + var shortOptions = aliasMap + .Where(item => item.Key.Length == 2 && item.Key[0] == '-' && item.Key[1] != '-') + .ToDictionary(item => item.Key[1], item => item.Value); + + for (var argumentIndex = 0; argumentIndex < args.Count; argumentIndex++) + { + var arg = args[argumentIndex]; + if (arg == "--") + { + break; + } + + if (aliasMap.TryGetValue(arg, out var exactOption)) + { + if (exactOption.ValueType != typeof(bool) && argumentIndex + 1 < args.Count) + { + argumentIndex++; + } + + continue; + } + + var handledSeparatedOption = false; + foreach (var (alias, option) in aliasMap) + { + if (arg.Length > alias.Length && + arg.StartsWith(alias, StringComparison.Ordinal) && + (arg[alias.Length] == '=' || arg[alias.Length] == ':')) + { + var hasAttachedValue = arg.Length > alias.Length + 1; + if (option.ValueType != typeof(bool) && !hasAttachedValue && argumentIndex + 1 < args.Count) + { + argumentIndex++; + } + + handledSeparatedOption = true; + break; + } + } + + if (handledSeparatedOption) + { + continue; + } + + if (arg.StartsWith("--", StringComparison.Ordinal) || arg.StartsWith('/')) + { + continue; + } + + if (arg.Length <= 2 || arg[0] != '-' || arg[1] == '-') + { + continue; + } + + var consumedFlag = false; + for (var index = 1; index < arg.Length; index++) + { + if (!shortOptions.TryGetValue(arg[index], out var option)) + { + if (consumedFlag) + { + errors.Add(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ErrorUnknownOption, arg)); + } + + break; + } + + if (option.ValueType != typeof(bool)) + { + var attachedValue = arg.Substring(index + 1); + if (attachedValue.Length == 0 && argumentIndex + 1 < args.Count) + { + argumentIndex++; + } + + // The remainder, when present, is the attached value for this option. + break; + } + + consumedFlag = true; + if (bool.TryParse(arg.AsSpan(index + 1), out _)) + { + // System.CommandLine accepts an explicit boolean value attached to a + // short option (for example, -rtrue). The value consumes the remainder. + break; + } + } + } + } + + private static IReadOnlyList ExpandTokensForValidation(string[] args) + { + var tokenizerCommand = new System.CommandLine.RootCommand(); + var tokenizerArgument = new System.CommandLine.Argument("tokens") + { + Arity = System.CommandLine.ArgumentArity.ZeroOrMore, + }; + tokenizerCommand.AddArgument(tokenizerArgument); + + return new Parser(tokenizerCommand) + .Parse(args) + .Tokens + .Select(token => token.Type == TokenType.DoubleDash ? "--" : token.Value) + .ToList(); + } + + private static void PopulateInputs(CliOptions options, string[] files) + { + if (files == null) + { + return; + } + + const string pipeNamePrefix = "\\\\.\\pipe\\"; + foreach (var file in files) + { + if (file.StartsWith(pipeNamePrefix, StringComparison.OrdinalIgnoreCase)) + { + options.PipeName = file.Substring(pipeNamePrefix.Length); + } + else + { + options.Files.Add(file); + } + } + } + public static void PrintConfig(ImageResizer.Properties.Settings settings) { var getString = ResourceLoaderInstance.GetString; diff --git a/src/modules/imageresizer/ui/Models/ResizeBatch.cs b/src/modules/imageresizer/ui/Models/ResizeBatch.cs index 34053db5ad..2e90c17676 100644 --- a/src/modules/imageresizer/ui/Models/ResizeBatch.cs +++ b/src/modules/imageresizer/ui/Models/ResizeBatch.cs @@ -7,26 +7,34 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.IO.Abstractions; using System.IO.Pipes; +using System.Linq; +using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; using ImageResizer.Properties; using ImageResizer.Services; +using Microsoft.Win32.SafeHandles; namespace ImageResizer.Models { public class ResizeBatch { private readonly IFileSystem _fileSystem = new FileSystem(); + private readonly List _inputErrors = []; + private readonly HashSet _resolvedInputPaths = new(StringComparer.Ordinal); private static IAISuperResolutionService _aiSuperResolutionService; public string DestinationDirectory { get; set; } public ICollection Files { get; } = new List(); + internal IReadOnlyList InputErrors => _inputErrors; + public static void SetAiSuperResolutionService(IAISuperResolutionService service) { _aiSuperResolutionService = service; @@ -72,6 +80,18 @@ namespace ImageResizer.Models /// The parsed CLI options. /// A ResizeBatch instance. public static ResizeBatch FromCliOptions(TextReader standardInput, CliOptions options) + => FromCliOptionsCore(standardInput, options, reportInvalidInputs: false); + + /// + /// Creates a batch for the public CLI and preserves a diagnostic for every rejected input. + /// + /// Standard input stream for reading additional file paths. + /// The parsed CLI options. + /// A resize batch containing valid files and input diagnostics. + internal static ResizeBatch FromCliOptionsWithDiagnostics(TextReader standardInput, CliOptions options) + => FromCliOptionsCore(standardInput, options, reportInvalidInputs: true); + + private static ResizeBatch FromCliOptionsCore(TextReader standardInput, CliOptions options, bool reportInvalidInputs) { var batch = new ResizeBatch { @@ -80,11 +100,13 @@ namespace ImageResizer.Models foreach (var file in options.Files) { - // Convert relative paths to absolute paths - var absolutePath = Path.IsPathRooted(file) ? file : Path.GetFullPath(file); - if (IsValidImagePath(absolutePath)) + if (reportInvalidInputs) { - batch.Files.Add(absolutePath); + AddStrictInput(batch, file); + } + else + { + AddLenientInput(batch, file); } } @@ -97,11 +119,13 @@ namespace ImageResizer.Models { while ((file = standardInput.ReadLine()) != null) { - // Convert relative paths to absolute paths - var absolutePath = Path.IsPathRooted(file) ? file : Path.GetFullPath(file); - if (IsValidImagePath(absolutePath)) + if (reportInvalidInputs) { - batch.Files.Add(absolutePath); + AddStrictInput(batch, file); + } + else + { + AddLenientInput(batch, file); } } } @@ -121,8 +145,13 @@ namespace ImageResizer.Models // Read file paths from the named pipe while ((file = sr.ReadLine()) != null) { - if (IsValidImagePath(file)) + if (reportInvalidInputs) { + AddStrictInput(batch, file); + } + else if (IsValidImagePath(file)) + { + // Preserve the legacy GUI/context-menu behavior for named-pipe input. batch.Files.Add(file); } } @@ -133,6 +162,232 @@ namespace ImageResizer.Models return batch; } + private static void AddLenientInput(ResizeBatch batch, string input) + { + // Keep the GUI and context-menu behavior unchanged: unsupported selections are ignored. + var absolutePath = Path.IsPathRooted(input) ? input : Path.GetFullPath(input); + if (IsValidImagePath(absolutePath)) + { + batch.Files.Add(absolutePath); + } + } + + private static void AddStrictInput(ResizeBatch batch, string input) + { + if (string.IsNullOrWhiteSpace(input)) + { + AddInputError(batch, input ?? string.Empty, Resources.CLI_ErrorFileNotFound); + return; + } + + try + { + if (ContainsWildcard(input)) + { + AddWildcardMatches(batch, input); + } + else + { + AddResolvedInput(batch, input, Path.GetFullPath(input)); + } + } + catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) + { + AddInputError( + batch, + input, + string.Format(CultureInfo.InvariantCulture, Resources.CLI_ErrorInvalidInputPath, ex.Message)); + } + } + + private static void AddWildcardMatches(ResizeBatch batch, string input) + { + var absolutePattern = Path.GetFullPath(input); + var directory = Path.GetDirectoryName(absolutePattern); + var pattern = Path.GetFileName(absolutePattern); + + if (string.IsNullOrEmpty(directory) || ContainsWildcard(directory)) + { + AddInputError(batch, input, Resources.CLI_ErrorWildcardInDirectory); + return; + } + + List matches = Directory.Exists(directory) + ? Directory.EnumerateFiles(directory, pattern, SearchOption.TopDirectoryOnly) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToList() + : []; + + if (matches.Count == 0) + { + AddInputError(batch, input, Resources.CLI_ErrorNoWildcardMatches); + return; + } + + foreach (var match in matches) + { + AddResolvedInput(batch, match, match); + } + } + + private static void AddResolvedInput(ResizeBatch batch, string input, string absolutePath) + { + var normalizedPath = Path.GetFullPath(absolutePath); + if (!File.Exists(normalizedPath)) + { + AddInputError(batch, input, Resources.CLI_ErrorFileNotFound); + return; + } + + if (!ValidImageExtensions.Contains(Path.GetExtension(normalizedPath))) + { + AddInputError(batch, input, Resources.CLI_ErrorUnsupportedFileType); + return; + } + + if (batch.TryAddResolvedInput(normalizedPath)) + { + batch.Files.Add(normalizedPath); + } + } + + private bool TryAddResolvedInput(string path) + => _resolvedInputPaths.Add(GetCanonicalPathKey(path)); + + private static string GetCanonicalPathKey(string path) + { + var fullPath = Path.GetFullPath(path); + var apiPath = AddExtendedPathPrefix(fullPath); + var finalPath = TryGetFinalPath(apiPath); + if (finalPath != null) + { + return finalPath; + } + + var longPath = TryGetLongPath(fullPath) ?? TryGetLongPath(apiPath) ?? fullPath; + + return RemoveExtendedPathPrefix(longPath); + } + + private static string AddExtendedPathPrefix(string path) + { + const string extendedPathPrefix = @"\\?\"; + const string devicePathPrefix = @"\\.\"; + + if (path.StartsWith(extendedPathPrefix, StringComparison.Ordinal) || + path.StartsWith(devicePathPrefix, StringComparison.Ordinal)) + { + return path; + } + + if (path.StartsWith(@"\\", StringComparison.Ordinal)) + { + return string.Concat(@"\\?\UNC\", path.AsSpan(2)); + } + + return path.Length >= 3 && + char.IsAsciiLetter(path[0]) && + path[1] == ':' && + path[2] == '\\' + ? extendedPathPrefix + path + : path; + } + + private static string TryGetLongPath(string path) + { + var buffer = new StringBuilder(path.Length + 1); + while (true) + { + var length = GetLongPathNameW(path, buffer, (uint)buffer.Capacity); + if (length == 0) + { + return null; + } + + if (length < buffer.Capacity) + { + return buffer.ToString(); + } + + buffer.EnsureCapacity(checked((int)length)); + } + } + + private static string TryGetFinalPath(string path) + { + const uint fileShareRead = 0x00000001; + const uint fileShareWrite = 0x00000002; + const uint fileShareDelete = 0x00000004; + const uint openExisting = 3; + const uint fileFlagOpenReparsePoint = 0x00200000; + const uint volumeNameNt = 0x00000002; + + // Resolve parent-directory aliases and filesystem casing while preserving the + // final directory entry (for example, a hard link or symbolic link) as distinct. + using SafeFileHandle handle = CreateFileW( + path, + 0, + fileShareRead | fileShareWrite | fileShareDelete, + IntPtr.Zero, + openExisting, + fileFlagOpenReparsePoint, + IntPtr.Zero); + if (handle.IsInvalid) + { + return null; + } + + var buffer = new StringBuilder(path.Length + 1); + while (true) + { + var length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, volumeNameNt); + if (length == 0) + { + return null; + } + + if (length < buffer.Capacity) + { + return buffer.ToString(); + } + + buffer.EnsureCapacity(checked((int)length)); + } + } + + private static string RemoveExtendedPathPrefix(string path) + { + const string extendedUncPrefix = @"\\?\UNC\"; + const string extendedPathPrefix = @"\\?\"; + + if (path.StartsWith(extendedUncPrefix, StringComparison.OrdinalIgnoreCase)) + { + return string.Concat(@"\\", path.AsSpan(extendedUncPrefix.Length)); + } + + if (path.Length >= extendedPathPrefix.Length + 3 && + path.StartsWith(extendedPathPrefix, StringComparison.OrdinalIgnoreCase) && + char.IsAsciiLetter(path[extendedPathPrefix.Length]) && + path[extendedPathPrefix.Length + 1] == ':' && + path[extendedPathPrefix.Length + 2] == '\\') + { + path = path.Substring(extendedPathPrefix.Length); + } + + return path.Length >= 2 && path[1] == ':' + ? char.ToUpperInvariant(path[0]) + path.Substring(1) + : path; + } + + private static void AddInputError(ResizeBatch batch, string input, string message) + => batch._inputErrors.Add(new ResizeError(input, message)); + + private static bool ContainsWildcard(string value) + { + var startIndex = value.StartsWith(@"\\?\", StringComparison.Ordinal) ? 4 : 0; + return value.IndexOf('*', startIndex) >= 0 || value.IndexOf('?', startIndex) >= 0; + } + public static ResizeBatch FromCommandLine(TextReader standardInput, string[] args) { var options = CliOptions.Parse(args); @@ -151,7 +406,7 @@ namespace ImageResizer.Models { double total = Files.Count; int completed = 0; - var errors = new ConcurrentBag(); + var processingErrors = new ConcurrentBag(); await Parallel.ForEachAsync( Files, @@ -167,14 +422,28 @@ namespace ImageResizer.Models } catch (Exception ex) { - errors.Add(new ResizeError(_fileSystem.Path.GetFileName(file), ex.Message)); + processingErrors.Add(new ResizeError(_fileSystem.Path.GetFileName(file), FormatErrorMessage(ex))); } Interlocked.Increment(ref completed); reportProgress(completed, total); }); - return errors; + return _inputErrors.Concat(processingErrors); + } + + internal static string FormatErrorMessage(Exception exception) + { + if (!string.IsNullOrWhiteSpace(exception.Message)) + { + return exception.Message; + } + + return string.Format( + CultureInfo.InvariantCulture, + Resources.CLI_ErrorProcessingFallback, + exception.GetType().Name, + exception.HResult); } protected virtual async Task ExecuteAsync(string file, Settings settings) @@ -182,5 +451,28 @@ namespace ImageResizer.Models var aiService = _aiSuperResolutionService ?? NoOpAiSuperResolutionService.Instance; await new ResizeOperation(file, DestinationDirectory, settings, aiService).ExecuteAsync(); } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + private static extern SafeFileHandle CreateFileW( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + private static extern uint GetFinalPathNameByHandleW( + SafeFileHandle fileHandle, + StringBuilder filePath, + uint filePathLength, + uint flags); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + private static extern uint GetLongPathNameW( + string shortPath, + StringBuilder longPath, + uint bufferLength); } } diff --git a/src/modules/imageresizer/ui/Models/ResizeOperation.cs b/src/modules/imageresizer/ui/Models/ResizeOperation.cs index 11d26d62b9..4a8dbc8dfb 100644 --- a/src/modules/imageresizer/ui/Models/ResizeOperation.cs +++ b/src/modules/imageresizer/ui/Models/ResizeOperation.cs @@ -62,6 +62,9 @@ namespace ImageResizer.Models public async Task ExecuteAsync() { + var originalLastWriteTimeUtc = _settings.KeepDateModified + ? _fileSystem.File.GetLastWriteTimeUtc(_file) + : (DateTime?)null; string path; using (var inputStream = _fileSystem.File.OpenRead(_file)) @@ -143,15 +146,21 @@ namespace ImageResizer.Models } } - if (_settings.KeepDateModified) - { - _fileSystem.File.SetLastWriteTimeUtc(path, _fileSystem.File.GetLastWriteTimeUtc(_file)); - } - + string backup = null; if (_settings.Replace) { - var backup = GetBackupPath(); + backup = GetBackupPath(); _fileSystem.File.Replace(path, _file, backup, ignoreMetadataErrors: true); + path = _file; + } + + if (originalLastWriteTimeUtc.HasValue) + { + _fileSystem.File.SetLastWriteTimeUtc(path, originalLastWriteTimeUtc.Value); + } + + if (backup != null) + { FileSystem.DeleteFile(backup, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin); } } @@ -476,30 +485,46 @@ namespace ImageResizer.Models } // Calculate scaled dimensions - uint scaledWidth = (uint)Math.Max(1, (int)Math.Round(originalWidth * scaleX)); - uint scaledHeight = (uint)Math.Max(1, (int)Math.Round(originalHeight * scaleY)); + uint scaledWidth = GetValidatedScaledDimension(originalWidth * scaleX); + uint scaledHeight = GetValidatedScaledDimension(originalHeight * scaleY); // Apply the centered crop for Fill mode, if necessary. - if (_settings.SelectedSize.Fit == ResizeFit.Fill - && (scaledWidth > (uint)width || scaledHeight > (uint)height)) + if (_settings.SelectedSize.Fit == ResizeFit.Fill) { - uint cropX = (uint)(((originalWidth * scaleX) - width) / 2); - uint cropY = (uint)(((originalHeight * scaleY) - height) / 2); + uint targetWidth = GetValidatedScaledDimension(width); + uint targetHeight = GetValidatedScaledDimension(height); - var cropBounds = new BitmapBounds + if (scaledWidth > targetWidth || scaledHeight > targetHeight) { - X = cropX, - Y = cropY, - Width = (uint)width, - Height = (uint)height, - }; + uint cropX = (scaledWidth - targetWidth) / 2; + uint cropY = (scaledHeight - targetHeight) / 2; - return (scaledWidth, scaledHeight, cropBounds, false); + var cropBounds = new BitmapBounds + { + X = cropX, + Y = cropY, + Width = targetWidth, + Height = targetHeight, + }; + + return (scaledWidth, scaledHeight, cropBounds, false); + } } return (scaledWidth, scaledHeight, null, false); } + private static uint GetValidatedScaledDimension(double value) + { + if (!double.IsFinite(value) || value < 0 || value > int.MaxValue) + { + throw new ArgumentOutOfRangeException(nameof(value), Resources.Error_DimensionOutOfRange); + } + + var roundedValue = Math.Round(value); + return (uint)Math.Max(1, (int)roundedValue); + } + private async Task CreateFreshEncoderAsync(Guid encoderGuid, IRandomAccessStream outputStream) { var propertySet = GetEncoderPropertySet(encoderGuid); diff --git a/src/modules/imageresizer/ui/Properties/Resources.cs b/src/modules/imageresizer/ui/Properties/Resources.cs index 66641bc8ae..34af7c645a 100644 --- a/src/modules/imageresizer/ui/Properties/Resources.cs +++ b/src/modules/imageresizer/ui/Properties/Resources.cs @@ -2,6 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System; + using ImageResizer.Helpers; namespace ImageResizer.Properties @@ -71,5 +73,59 @@ namespace ImageResizer.Properties public static string CLI_WarningInvalidSizeIndex => ResourceLoaderInstance.GetString("CLI_WarningInvalidSizeIndex"); public static string CLI_NoInputFiles => ResourceLoaderInstance.GetString("CLI_NoInputFiles"); + + public static string CLI_ErrorUnknownOption => GetStringOrDefault( + "CLI_ErrorUnknownOption", + "Unrecognized option '{0}'. Use '--' before a file name that starts with '-', or prefix it with '.\\'."); + + public static string CLI_ErrorInvalidDimension => GetStringOrDefault( + "CLI_ErrorInvalidDimension", + "Width and height must be finite numbers greater than or equal to zero."); + + public static string CLI_ErrorZeroDimensions => GetStringOrDefault( + "CLI_ErrorZeroDimensions", + "Width and height cannot both be zero for a custom size."); + + public static string CLI_ErrorPercentWidthRequired => GetStringOrDefault( + "CLI_ErrorPercentWidthRequired", + "A positive width is required for percentage-based Fit and Fill sizes."); + + public static string CLI_ErrorSizeIndexOutOfRange => GetStringOrDefault( + "CLI_ErrorSizeIndexOutOfRange", + "Size index {0} is out of range. The maximum valid index is {1}."); + + public static string Error_DimensionOutOfRange => GetStringOrDefault( + "Error_DimensionOutOfRange", + "The requested output dimensions are outside the supported range."); + + public static string CLI_ErrorFileNotFound => GetStringOrDefault("CLI_ErrorFileNotFound", "Input file not found."); + + public static string CLI_ErrorUnsupportedFileType => GetStringOrDefault("CLI_ErrorUnsupportedFileType", "Unsupported image file type."); + + public static string CLI_ErrorInvalidInputPath => GetStringOrDefault("CLI_ErrorInvalidInputPath", "Invalid input path: {0}"); + + public static string CLI_ErrorWildcardInDirectory => GetStringOrDefault( + "CLI_ErrorWildcardInDirectory", + "Wildcards are supported only in the file name portion of a path."); + + public static string CLI_ErrorNoWildcardMatches => GetStringOrDefault( + "CLI_ErrorNoWildcardMatches", + "No files matched the wildcard pattern."); + + public static string CLI_ErrorProcessingFallback => GetStringOrDefault( + "CLI_ErrorProcessingFallback", + "Image processing failed with {0} (HRESULT 0x{1:X8})."); + + public static string CLI_WarningShrinkOnlyPercent => GetStringOrDefault( + "CLI_WarningShrinkOnlyPercent", + "Warning: Shrink-only is ignored for percentage-based sizes."); + + private static string GetStringOrDefault(string key, string defaultValue) + { + var value = ResourceLoaderInstance.GetString(key); + return string.IsNullOrWhiteSpace(value) || string.Equals(value, key, StringComparison.Ordinal) + ? defaultValue + : value; + } } } diff --git a/src/modules/imageresizer/ui/Strings/en-us/Resources.resw b/src/modules/imageresizer/ui/Strings/en-us/Resources.resw index 8a1f26771f..0c0840418d 100644 --- a/src/modules/imageresizer/ui/Strings/en-us/Resources.resw +++ b/src/modules/imageresizer/ui/Strings/en-us/Resources.resw @@ -302,6 +302,45 @@ No input files or pipe specified. Showing usage. + + Unrecognized option '{0}'. Use '--' before a file name that starts with '-', or prefix it with '.\'. + + + Width and height must be finite numbers greater than or equal to zero. + + + Width and height cannot both be zero for a custom size. + + + A positive width is required for percentage-based Fit and Fill sizes. + + + Size index {0} is out of range. The maximum valid index is {1}. + + + The requested output dimensions are outside the supported range. + + + Input file not found. + + + Unsupported image file type. + + + Invalid input path: {0} + + + Wildcards are supported only in the file name portion of a path. + + + No files matched the wildcard pattern. + + + Image processing failed with {0} (HRESULT 0x{1:X8}). + + + Warning: Shrink-only is ignored for percentage-based sizes. + ImageResizer - Current Configuration @@ -375,7 +414,7 @@ PowerToys.ImageResizerCLI.exe --width 800 --height 600 image.jpg - PowerToys.ImageResizerCLI.exe -w 50 -h 50 -u Percent *.jpg + PowerToys.ImageResizerCLI.exe -w 50 -h 50 -u Percent "*.jpg" PowerToys.ImageResizerCLI.exe --size 0 -d "C:\Output" photo.png @@ -387,7 +426,7 @@ Set output filename format (%1=original name, %2=size name) - Image files to resize + Image files to resize (wildcards are supported in file names) Set fit mode (Fill, Fit, Stretch) @@ -414,7 +453,7 @@ Show current configuration - Only shrink images, don't enlarge + Only shrink images for non-percentage sizes Remove metadata from resized images @@ -437,4 +476,4 @@ Resize - \ No newline at end of file +