From 157f597eed3fa2b46b9446892170036cc2598ecf Mon Sep 17 00:00:00 2001 From: yyc12345 Date: Sun, 4 Oct 2026 16:16:01 +0800 Subject: [PATCH] fix(export): sanitize the suggested file name and harden Markdown escaping --- YKanBan.Tests/Export/MarkdownExporterTests.cs | 63 ++++++++++++++- YKanBan/Assets/Locales/Resources.resx | 3 - YKanBan/Assets/Locales/Resources.zh-Hans.resx | 3 - YKanBan/Export/MarkdownExporter.cs | 79 +++++++++++++++---- 4 files changed, 122 insertions(+), 26 deletions(-) diff --git a/YKanBan.Tests/Export/MarkdownExporterTests.cs b/YKanBan.Tests/Export/MarkdownExporterTests.cs index 6c98aa7..03a695e 100644 --- a/YKanBan.Tests/Export/MarkdownExporterTests.cs +++ b/YKanBan.Tests/Export/MarkdownExporterTests.cs @@ -81,8 +81,8 @@ public class MarkdownExporterTests { /// The UI culture. /// The expected Markdown document. private static string BuildExpected(CultureInfo culture) { - string created = Local(2026, 9, 1, 10, 0).ToString(Resources.Export_CardTimePattern, culture); - string updated = Local(2026, 9, 28, 18, 30).ToString(Resources.Export_CardTimePattern, culture); + string created = Local(2026, 9, 1, 10, 0).ToString(Resources.Export_TimePattern, culture); + string updated = Local(2026, 9, 28, 18, 30).ToString(Resources.Export_TimePattern, culture); var builder = new StringBuilder(); @@ -181,7 +181,64 @@ public class MarkdownExporterTests { [(tag, 0L)], ExportedAt); - StringAssert.Contains(markdown, "| a\\|b | #112233 | first second | 0 |"); + StringAssert.Contains(markdown, "| a\\|b | #112233 | first
second | 0 |"); + } finally { + Resources.Culture = original; + } + } + + [TestMethod] + public void TagTableCellsEscapeBackslashes() { + CultureInfo? original = Resources.Culture; + try { + Resources.Culture = new CultureInfo("en"); + var tag = new TagModel(1, "a\\b", RgbColor.Parse("#112233"), "c\\|d"); + string markdown = MarkdownExporter.Export( + "W", + [new ColumnModel(1, "C", string.Empty, 1, 1)], + [], + [(tag, 0L)], + ExportedAt); + + StringAssert.Contains(markdown, "| a\\\\b | #112233 | c\\\\\\|d | 0 |"); + } finally { + Resources.Culture = original; + } + } + + [TestMethod] + public void TagNameWithBacktickUsesLongerFence() { + CultureInfo? original = Resources.Culture; + try { + Resources.Culture = new CultureInfo("en"); + var tag = new TagModel(1, "a`b", RgbColor.Parse("#112233"), string.Empty); + string markdown = MarkdownExporter.Export( + "W", + [new ColumnModel(1, "C", string.Empty, 1, 1)], + [new CardModel(1, 1, "T", string.Empty, 1, 1, [tag])], + [(tag, 1L)], + ExportedAt); + + StringAssert.Contains(markdown, "``a`b``"); + } finally { + Resources.Culture = original; + } + } + + [TestMethod] + public void FileNameDropsInvalidCharactersAndFallsBackToAppName() { + CultureInfo? original = Resources.Culture; + try { + Resources.Culture = new CultureInfo("en"); + + // A drive root workspace name "D:\" must not leak ':' or '\' into the file name. + string name = MarkdownExporter.BuildFileName("D:\\", ExportedAt); + Assert.IsFalse(name.Contains(':')); + Assert.IsFalse(name.Contains('\\')); + + // A name made only of invalid characters falls back to the application name. + string fallback = MarkdownExporter.BuildFileName("///", ExportedAt); + StringAssert.StartsWith(fallback, Resources.App_Name); } finally { Resources.Culture = original; } diff --git a/YKanBan/Assets/Locales/Resources.resx b/YKanBan/Assets/Locales/Resources.resx index c2c72d5..71f9f26 100644 --- a/YKanBan/Assets/Locales/Resources.resx +++ b/YKanBan/Assets/Locales/Resources.resx @@ -415,9 +415,6 @@ yyyy-MM-dd HH:mm:ss - - yyyy-MM-dd HH:mm - # {0} diff --git a/YKanBan/Assets/Locales/Resources.zh-Hans.resx b/YKanBan/Assets/Locales/Resources.zh-Hans.resx index aeee3f3..55924e0 100644 --- a/YKanBan/Assets/Locales/Resources.zh-Hans.resx +++ b/YKanBan/Assets/Locales/Resources.zh-Hans.resx @@ -415,9 +415,6 @@ yyyy年M月d日 HH:mm - - yyyy年M月d日 HH:mm - # {0} diff --git a/YKanBan/Export/MarkdownExporter.cs b/YKanBan/Export/MarkdownExporter.cs index 5576cb0..d2277b0 100644 --- a/YKanBan/Export/MarkdownExporter.cs +++ b/YKanBan/Export/MarkdownExporter.cs @@ -7,10 +7,14 @@ namespace YKanBan.Export; /// /// Generates the human-readable Markdown snapshot of a whole board. All -/// template text and time formats come from the ResX resources in the current -/// UI language; the export timestamp uses the per-language pattern, card -/// timestamps use the shared minute-precision pattern. The output is meant for -/// reading only — there is no re-import. +/// template text and the one time format come from the ResX resources in the +/// current UI language; every timestamp (export time and card created/modified +/// times) uses that per-language pattern. The document uses the platform's +/// newline. Card bodies and column descriptions are emitted verbatim; column and +/// card titles are collapsed to one line; tag names become inline code with a +/// long enough backtick fence; tag table cells escape '\' and '|' and turn line +/// breaks into <br>. The output is meant for reading only — there is no +/// re-import. /// public static class MarkdownExporter { /// @@ -33,17 +37,19 @@ public static class MarkdownExporter { var builder = new StringBuilder(); // Header: workspace name and the export timestamp in the UI language's format. - builder.AppendLine(string.Format(culture, Resources.Export_WorkspaceHeading, workspaceName)); + builder.AppendLine(string.Format(culture, Resources.Export_WorkspaceHeading, SingleLine(workspaceName))); builder.AppendLine(string.Format( culture, Resources.Export_ExportedAtLine, exportedAt.ToString(Resources.Export_TimePattern, culture))); builder.AppendLine(); - // Board body: every column in id order, empty ones included. - foreach (ColumnModel column in columns) { - IReadOnlyList cardsInColumn = cards.Where(card => card.ColumnId == column.Id).ToArray(); - builder.AppendLine(string.Format(culture, Resources.Export_ColumnHeading, column.Title, cardsInColumn.Count)); + // Board body: every column in id order, empty ones included; independent of + // the board's search filter and card sort option. + foreach (ColumnModel column in columns.OrderBy(column => column.Id)) { + IReadOnlyList cardsInColumn = + cards.Where(card => card.ColumnId == column.Id).OrderBy(card => card.Id).ToArray(); + builder.AppendLine(string.Format(culture, Resources.Export_ColumnHeading, SingleLine(column.Title), cardsInColumn.Count)); if (column.Description.Length > 0) { - builder.AppendLine(SingleLine(column.Description)); + builder.AppendLine(column.Description); } builder.AppendLine(); @@ -58,16 +64,32 @@ public static class MarkdownExporter { /// /// Builds the default export file name in the UI language, for example - /// "MyRepo-export-20260930-142537.md". + /// "MyRepo-export-20260930-142537.md". Characters that are not allowed in a + /// file name on any supported platform are dropped from the workspace name + /// (a workspace at a drive root is named "D:\"); a name left empty falls + /// back to the application name. /// /// Workspace display name. /// Export moment. /// The file name. public static string BuildFileName(string workspaceName, DateTimeOffset exportedAt) { CultureInfo culture = Resources.Culture ?? CultureInfo.CurrentUICulture; - return string.Format(culture, Resources.Export_FileNamePattern, workspaceName, exportedAt); + string safeName = new(workspaceName.Where(character => !IsInvalidFileNameCharacter(character)).ToArray()); + if (safeName.Length == 0) { + safeName = Resources.App_Name; + } + return string.Format(culture, Resources.Export_FileNamePattern, safeName, exportedAt); } + /// + /// Tells whether a character is invalid in a file name on Windows, Linux or + /// macOS; the Windows set is the widest, so the result is the same on every platform. + /// + /// The character to test. + /// when the character must not appear in a file name. + private static bool IsInvalidFileNameCharacter(char character) => + character < ' ' || character is '<' or '>' or ':' or '"' or '/' or '\\' or '|' or '?' or '*'; + /// /// Appends one card block: heading, body, tags line and timestamps line. /// @@ -85,12 +107,12 @@ public static class MarkdownExporter { } if (card.Tags.Count > 0) { - string names = string.Join(" ", card.Tags.Select(tag => $"`{tag.Name}`")); + string names = string.Join(" ", card.Tags.Select(tag => InlineCode(tag.Name))); builder.AppendLine(string.Format(culture, Resources.Export_TagsLine, names)); } - string created = ToLocalTime(card.CreatedAt).ToString(Resources.Export_CardTimePattern, culture); - string updated = ToLocalTime(card.UpdatedAt).ToString(Resources.Export_CardTimePattern, culture); + string created = ToLocalTime(card.CreatedAt).ToString(Resources.Export_TimePattern, culture); + string updated = ToLocalTime(card.UpdatedAt).ToString(Resources.Export_TimePattern, culture); builder.AppendLine(string.Format(culture, Resources.Export_CreatedModifiedLine, created, updated)); builder.AppendLine(); } @@ -115,11 +137,34 @@ public static class MarkdownExporter { } /// - /// Formats a table cell: single line with escaped pipe characters. + /// Formats a table cell: escaped backslashes and pipe characters, line breaks as <br>. /// /// The raw cell text. /// The escaped single-line text. - private static string Cell(string text) => SingleLine(text).Replace("|", "\\|"); + private static string Cell(string text) => + // Backslashes first: the table splitter reads "\\" as an escaped backslash, so the + // '|' after a literal backslash would otherwise end the cell. + text.Replace("\\", "\\\\").Replace("|", "\\|").Replace("\r\n", "
").Replace("\r", "
").Replace("\n", "
"); + + /// + /// Renders text as an inline code span whose backtick fence is one longer than + /// the longest backtick run inside, padded with spaces when the text starts or + /// ends with a backtick. + /// + /// The raw text. + /// The inline code span. + private static string InlineCode(string text) { + int longestRun = 0; + int run = 0; + foreach (char character in text) { + run = character == '`' ? run + 1 : 0; + longestRun = Math.Max(longestRun, run); + } + + string fence = new('`', longestRun + 1); + string padding = text.StartsWith('`') || text.EndsWith('`') ? " " : string.Empty; + return fence + padding + text + padding + fence; + } /// /// Collapses line breaks so the text stays on one Markdown line.