From 23695f415b5585cdbfb5c6a31c4988f782495e80 Mon Sep 17 00:00:00 2001 From: Yousef Date: Fri, 24 Nov 2023 07:33:44 -0500 Subject: [PATCH] Add `-n, --names` option to specify space names to export (#14) * Add exportPath option to export-spaces command * Add export name option to export-spaces command * Update README.md for new export options * Add option to export specific space names * Update README.md for "-n" option * Add file name validation and error handling for ExportSpaces * Update export path option * Add check for permissions for write file and create directory * Update export-spaces option; file-path * Add file name validation * Add file path validation * Add tests for UtilityHelper.IsValidFileName and UtilityHelper.IsValidFilePath * Fix invalid file and path name checks --- README.md | 3 + src/Explore.Cli/Program.cs | 81 +++++++++-- src/Explore.Cli/UtilityHelper.cs | 135 ++++++++++++++++--- test/Explore.Cli.Tests/UtilityHelperTests.cs | 59 +++++++- 4 files changed, 249 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 9c69117..df518fa 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,9 @@ From SwaggerHub Explore, navigate to your browser development tools, locate the **Options:** > -ec, --explore-cookie (REQUIRED) A valid and active SwaggerHub Explore session cookie + > -fp, --file-path The path to the directory used for exporting data. It can be either relative or absolute + > -en, --export-name The name of the exported file + > -n, --names A comma-separated list of space names to export > -v, --verbose Include verbose output during processing > -?, -h, --help Show help and usage information diff --git a/src/Explore.Cli/Program.cs b/src/Explore.Cli/Program.cs index a325ed0..3a81ed5 100644 --- a/src/Explore.Cli/Program.cs +++ b/src/Explore.Cli/Program.cs @@ -24,8 +24,17 @@ public static async Task Main(string[] args) var exploreCookie = new Option(name: "--explore-cookie", description: "A valid and active SwaggerHub Explore session cookie") { IsRequired = true }; exploreCookie.AddAlias("-ec"); - var filePath = new Option(name: "--file-path", description: "The path to the file used for importing data") { IsRequired = true }; - filePath.AddAlias("-fp"); + var importFilePath = new Option(name: "--file-path", description: "The path to the file used for importing data") { IsRequired = true }; + importFilePath.AddAlias("-fp"); + + var exportFilePath = new Option(name: "--file-path", description: "The path for exporting files. It can be either a relative or absolute path") { IsRequired = false }; + exportFilePath.AddAlias("-fp"); + + var exportFileName = new Option(name: "--export-name", description: "The name of the file to export") { IsRequired = false }; + exportFileName.AddAlias("-en"); + + var names = new Option(name: "--names", description: "The names of the spaces to export") { IsRequired = false }; + names.AddAlias("-n"); var verbose = new Option(name: "--verbose", description: "Include verbose output during processing") { IsRequired = false }; verbose.AddAlias("-v"); @@ -45,19 +54,19 @@ public static async Task Main(string[] args) await ImportFromInspector(u, ic, ec); }, username, inspectorCookie, exploreCookie); - var exportSpacesCommand = new Command("export-spaces") { exploreCookie, verbose }; + var exportSpacesCommand = new Command("export-spaces") { exploreCookie, exportFilePath, exportFileName, names, verbose }; exportSpacesCommand.Description = "Export SwaggerHub Explore spaces to filesystem"; rootCommand.Add(exportSpacesCommand); - exportSpacesCommand.SetHandler(async (ec, v) => - { await ExportSpaces(ec, v); }, exploreCookie, verbose); + exportSpacesCommand.SetHandler(async (ec, fp, en, n, v) => + { await ExportSpaces(ec, fp, en, n, v); }, exploreCookie, exportFilePath, exportFileName, names, verbose); - var importSpacesCommand = new Command("import-spaces") { exploreCookie, filePath, verbose }; + var importSpacesCommand = new Command("import-spaces") { exploreCookie, importFilePath, verbose }; importSpacesCommand.Description = "Import SwaggerHub Explore spaces from a file"; rootCommand.Add(importSpacesCommand); importSpacesCommand.SetHandler(async (ec, fp, v) => - { await ImportSpaces(ec, fp, v); }, exploreCookie, filePath, verbose); + { await ImportSpaces(ec, fp, v); }, exploreCookie, importFilePath, verbose); AnsiConsole.Write(new FigletText("Explore.Cli").Color(new Color(133, 234, 45))); @@ -230,7 +239,7 @@ internal static async Task ImportFromInspector(string inspectorUsername, string } } - internal static async Task ExportSpaces(string exploreCookie, bool? verboseOutput) + internal static async Task ExportSpaces(string exploreCookie, string filePath, string exportFileName, string names, bool? verboseOutput) { var httpClient = new HttpClient { @@ -252,17 +261,53 @@ internal static async Task ExportSpaces(string exploreCookie, bool? verboseOutpu return; } + var namesList = names?.Split(',') + .Select(name => name.Trim()) + .ToList(); var spaces = await spacesResponse.Content.ReadFromJsonAsync(); var panel = new Panel($"You have [green]{spaces!.Embedded!.Spaces!.Count} spaces[/] in explore"); panel.Width = 100; panel.Header = new PanelHeader("SwaggerHub Explore Data").Centered(); + + // validate the file name if provided + if (string.IsNullOrEmpty(exportFileName)) + { + // use default if not provided + exportFileName = "ExploreSpaces.json"; + } + else if (!UtilityHelper.IsValidFileName(ref exportFileName)) + { + return; // file name is invalid, exit + } + + // validate the export path if provided + // string filePath; + if (string.IsNullOrEmpty(filePath)) + { + // use default (current directory) if not provided + filePath = Path.Combine(Environment.CurrentDirectory, exportFileName); + } + else if (!UtilityHelper.IsValidFilePath(ref filePath)) + { + return; // file path is invalid, exit + } + + // combine the path and filename + filePath = Path.Combine(filePath, exportFileName); + AnsiConsole.Write(panel); + Console.WriteLine(namesList?.Count > 0 ? $"Exporting spaces: {string.Join(", ", namesList)}" : "Exporting all spaces"); Console.WriteLine("processing..."); var spacesToExport = new List(); foreach (var space in spaces.Embedded.Spaces) { + if (namesList?.Count > 0 && space.Name != null && !namesList.Contains(space.Name)) + { + continue; + } + var resultTable = new Table() { Title = new TableTitle(text: $"PROCESSING [green]{space.Name}[/]"), Width = 100, UseSafeBorder = true }; resultTable.AddColumn("Result"); resultTable.AddColumn(new TableColumn("Details").Centered()); @@ -348,13 +393,25 @@ internal static async Task ExportSpaces(string exploreCookie, bool? verboseOutpu ExploreSpaces = spacesToExport }; + // export the file string exploreSpacesJson = JsonSerializer.Serialize(export); - var filePath = Path.Combine(Environment.CurrentDirectory, "ExploreSpaces.json"); - - using (StreamWriter streamWriter = new StreamWriter(filePath)) + try + { + using (StreamWriter streamWriter = new StreamWriter(filePath)) + { + streamWriter.Write(exploreSpacesJson); + } + } + catch (UnauthorizedAccessException) { - streamWriter.Write(exploreSpacesJson); + AnsiConsole.MarkupLine($"[red]Access to {filePath} is denied. Please review file permissions any try again.[/]"); + return; + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]An error occurred accessing the file: {ex.Message}[/]"); + return; } AnsiConsole.MarkupLine($"[green] All done! {spacesToExport.Count()} of {spaces!.Embedded!.Spaces!.Count} spaces exported to: {filePath} [/]"); diff --git a/src/Explore.Cli/UtilityHelper.cs b/src/Explore.Cli/UtilityHelper.cs index abb4021..90d5760 100644 --- a/src/Explore.Cli/UtilityHelper.cs +++ b/src/Explore.Cli/UtilityHelper.cs @@ -3,39 +3,41 @@ using System.Text.RegularExpressions; using Microsoft.AspNetCore.StaticFiles; using NJsonSchema; +using Spectre.Console; public static class UtilityHelper { public static string CleanString(string? inputName) { - if(string.IsNullOrEmpty(inputName)) + if (string.IsNullOrEmpty(inputName)) { return string.Empty; } - try + try { - return Regex.Replace(inputName, @"[^a-zA-Z0-9 ._-]", "", RegexOptions.None, TimeSpan.FromSeconds(2)); + return Regex.Replace(inputName, @"[^a-zA-Z0-9 ._-]", "", RegexOptions.None, TimeSpan.FromSeconds(2)); } // return empty string rather than timeout - catch (RegexMatchTimeoutException) { - return String.Empty; + catch (RegexMatchTimeoutException) + { + return String.Empty; } } public static bool IsContentTypeExpected(HttpContentHeaders? headers, string expectedContentType) { - if(headers == null) + if (headers == null) { return false; } - - foreach(var header in headers) + + foreach (var header in headers) { - if(string.Equals(header.Key, "Content-Type", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(header.Key, "Content-Type", StringComparison.OrdinalIgnoreCase)) { - if(string.Equals(header.Value.FirstOrDefault(), expectedContentType, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(header.Value.FirstOrDefault(), expectedContentType, StringComparison.OrdinalIgnoreCase)) { return true; } @@ -58,8 +60,8 @@ public static bool IsJsonFile(string filePath) if (!provider.TryGetContentType(filePath, out string contentType)) { return false; - } - + } + return string.Equals(contentType, MediaTypeNames.Application.Json, StringComparison.OrdinalIgnoreCase); } @@ -67,7 +69,7 @@ public static bool IsJsonFile(string filePath) } public static async Task ValidateSchema(string jsonAsString, string schemaName) - { + { var validationResult = new SchemaValidationResult(); var schemaAsString = @"{ ""$schema"": ""https://json-schema.org/draft/2019-09/schema"", @@ -168,20 +170,121 @@ public static async Task ValidateSchema(string jsonAsStr var schema = await JsonSchema.FromJsonAsync(schemaAsString); var errors = schema.Validate(jsonAsString); - if(errors.Any()) + if (errors.Any()) { var msg = $"‣ {errors.Count} total errors\n" + string.Join("", errors .Select(e => $" ‣ {e}[/] at " + $"{e.LineNumber}:{e.LinePosition}[/]\n")); - + validationResult.Message = msg; } else { validationResult.isValid = true; - } + } return validationResult; } + + public static bool IsValidFileName(ref string fileName) + { + char[] invalidFileNameChars = new char[] + { + '<', '>', ':', '"', '/', '\\', '|', '?', '*', '\0', + // Control characters (0x00-0x1F) + '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', + '\x08', '\x09', '\x0A', '\x0B', '\x0C', '\x0D', '\x0E', '\x0F', + '\x10', '\x11', '\x12', '\x13', '\x14', '\x15', '\x16', '\x17', + '\x18', '\x19', '\x1A', '\x1B', '\x1C', '\x1D', '\x1E', '\x1F' + }; + + if (fileName == null) + { + return false; + } + + if (fileName.IndexOfAny(invalidFileNameChars) > 0) + { + AnsiConsole.MarkupLine($"[red]The file name '{fileName}' contains invalid characters. Please review.[/]"); + return false; + } + + if (string.IsNullOrWhiteSpace(fileName)) + { + AnsiConsole.MarkupLine($"[red]The file name cannot be empty. Please review.[/]"); + return false; + } + + if (fileName.Contains('.')) + { + if (fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + fileName = $"{fileName}"; + return true; + } + else + { + AnsiConsole.MarkupLine($"[red]The file name '{fileName}' has an invalid extension. Please review.[/]"); + return false; + } + } + else + { + fileName = $"{fileName}.json"; + } + + return true; + } + + public static bool IsValidFilePath(ref string filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + AnsiConsole.MarkupLine($"[red]The file path cannot be empty. Please review.[/]"); + return false; + } + + char[] invalidChars = new char[] + { + '<', '>', ':', '"', '|', '?', '*', '\0', + // Control characters (0x00-0x1F) + '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', + '\x08', '\x09', '\x0A', '\x0B', '\x0C', '\x0D', '\x0E', '\x0F', + '\x10', '\x11', '\x12', '\x13', '\x14', '\x15', '\x16', '\x17', + '\x18', '\x19', '\x1A', '\x1B', '\x1C', '\x1D', '\x1E', '\x1F' + }; + + if (filePath.IndexOfAny(invalidChars) > 0) + { + AnsiConsole.MarkupLine($"[red]The file path '{filePath}' contains invalid characters. Please review.[/]"); + return false; + } + + // check if the exportPath is an absolute path + if (!Path.IsPathRooted(filePath)) + { + // if not, make it relative to the current directory + filePath = Path.Combine(Environment.CurrentDirectory, filePath); + } + + if (!Directory.Exists(filePath)) + { + try + { + Directory.CreateDirectory(filePath); + } + catch (UnauthorizedAccessException) + { + AnsiConsole.MarkupLine($"[red]Access to {filePath} is denied. Please review file permissions any try again.[/]"); + return false; + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]An error occurred accessing the file: {ex.Message}[/]"); + return false; + } + } + return true; + } } \ No newline at end of file diff --git a/test/Explore.Cli.Tests/UtilityHelperTests.cs b/test/Explore.Cli.Tests/UtilityHelperTests.cs index d47c6df..3389a30 100644 --- a/test/Explore.Cli.Tests/UtilityHelperTests.cs +++ b/test/Explore.Cli.Tests/UtilityHelperTests.cs @@ -57,5 +57,62 @@ public void IsContentTypeExpected_Should_Fail() var actual = UtilityHelper.IsContentTypeExpected(message.Content.Headers, "text/html"); Assert.False(actual); - } + } + + [Theory] + [InlineData(" ")] + [InlineData("")] + [InlineData(null)] + [InlineData("test/")] + [InlineData("test\\")] + [InlineData("test:")] + [InlineData("test*")] + [InlineData("test?")] + [InlineData("test\"")] + [InlineData("test<")] + [InlineData("test>")] + [InlineData("test|")] + [InlineData("test.")] + [InlineData("test.txt")] + public void IsValidFileName_Should_Fail(string input) + { + Assert.False(UtilityHelper.IsValidFileName(ref input)); + } + + [Theory] + [InlineData("test", "test.json")] + [InlineData("test-test", "test-test.json")] + [InlineData("test_test", "test_test.json")] + [InlineData("test.json", "test.json")] + [InlineData("test.JSON", "test.JSON")] + public void IsValidFileName_Should_Pass(string input, string expected) + { + Assert.True(UtilityHelper.IsValidFileName(ref input)); + Assert.Equal(input, expected); + } + + [Theory] + [InlineData(" ")] + [InlineData("")] + [InlineData(null)] + [InlineData("test:")] + [InlineData("test*")] + [InlineData("test?")] + [InlineData("test\"")] + [InlineData("test<")] + [InlineData("test>")] + [InlineData("test|")] + public void IsValidFilePath_Should_Fail_With_Invalid_Chars(string input) + { + Assert.False(UtilityHelper.IsValidFilePath(ref input)); + } + + [Theory] + [InlineData("test")] + [InlineData("test/test")] + [InlineData("test.test")] + public void IsValidFilePath_Should_Pass(string input) + { + Assert.True(UtilityHelper.IsValidFilePath(ref input)); + } } \ No newline at end of file