From abf8ffdbca225798973ff6c56991484d5390e863 Mon Sep 17 00:00:00 2001 From: Mario Pietsch Date: Wed, 22 Jul 2026 13:45:25 +0200 Subject: [PATCH] Fix 9814 file system paths "pragma: \ define" tiddler (#9815) * New test for all "forbidden" directory characters * Fix for 9814 Pragma: \define creates dir problem * improve handling of tiddlers in root/community directory * Add ChangeNote * Apply suggestion from @saqimtiaz * Apply suggestion from @saqimtiaz --------- Co-authored-by: Saq Imtiaz --- core-server/filesystem.js | 20 ++++- .../test/tiddlers/tests/test-filesystem.js | 83 +++++++++++++++++++ .../5.5.0/#9815-filesystempaths-backslash.tid | 13 +++ 3 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 editions/test/tiddlers/tests/test-filesystem.js create mode 100644 editions/tw5.com/tiddlers/releasenotes/5.5.0/#9815-filesystempaths-backslash.tid diff --git a/core-server/filesystem.js b/core-server/filesystem.js index e24e3fa67b..165291444b 100644 --- a/core-server/filesystem.js +++ b/core-server/filesystem.js @@ -217,6 +217,11 @@ exports.generateTiddlerFileInfo = function(tiddler,options) { fileInfo.isEditableFile = true; fileInfo.originalpath = options.fileInfo.originalpath; } + // Propagate the pinFilepath flag (so generateTiddlerFilepath can short-circuit + // FileSystemPaths and write back to the original location) + if(options.fileInfo && !!options.fileInfo.pinFilepath) { + fileInfo.pinFilepath = true; + } // Check if the tiddler has any unsafe fields that can't be expressed in a .tid or .meta file: containing control characters, or leading/trailing whitespace var hasUnsafeFields = false; $tw.utils.each(tiddler.getFieldStrings(),function(value,fieldName) { @@ -319,9 +324,16 @@ exports.generateTiddlerFilepath = function(title,options) { extension = options.extension || "", originalpath = (options.fileInfo && options.fileInfo.originalpath) ? options.fileInfo.originalpath : "", overwrite = options.fileInfo && options.fileInfo.overwrite || false, + pinFilepath = !!(options.fileInfo && options.fileInfo.pinFilepath), filepath; + // If the tiddler's filepath is pinned via tiddlywiki.files, the originalpath + // wins and the path filters are skipped entirely. + if(pinFilepath && originalpath) { + var pinnedExt = path.extname(originalpath); + filepath = originalpath.substring(0,originalpath.length - pinnedExt.length); + } // Check if any of the pathFilters applies - if(options.pathFilters && options.wiki) { + if(!filepath && options.pathFilters && options.wiki) { $tw.utils.each(options.pathFilters,function(filter) { if(!filepath) { var source = options.wiki.makeTiddlerIterator([title]), @@ -352,8 +364,10 @@ exports.generateTiddlerFilepath = function(title,options) { } // Replace any Unicode control codes filepath = filepath.replace(/[\x00-\x1f\x80-\x9f]/g,"_"); - // Replace any characters that can't be used in cross-platform filenames - filepath = $tw.utils.transliterate(filepath.replace(/<|>|~|\:|\"|\||\?|\*|\^/g,"_")); + // Replace any characters that can't be used in cross-platform filenames. + if(!pinFilepath) { + filepath = $tw.utils.transliterate(filepath.replace(/<|>|~|\:|\"|\||\?|\*|\^|\\/g,"_")); + } // Replace any dots or spaces at the end of the extension with the same number of underscores extension = extension.replace(/[\. ]+$/, function (u) { return u.replace(/[\. ]/g, "_");}); // Truncate the extension if it is too long diff --git a/editions/test/tiddlers/tests/test-filesystem.js b/editions/test/tiddlers/tests/test-filesystem.js new file mode 100644 index 0000000000..23f6f0a7e6 --- /dev/null +++ b/editions/test/tiddlers/tests/test-filesystem.js @@ -0,0 +1,83 @@ +/*\ +title: test-filesystem.js +type: application/javascript +tags: [[$:/tags/test-spec]] + +Tests for $:/core-server filesystem utilities. + +\*/ +"use strict"; + +if($tw.node) { + + var path = require("path"); + + describe("generateTiddlerFilepath", function() { + + var directory = path.resolve("/tmp/tw5-test-filesystem"); + + // Characters stripped by the cross-platform-filename regex at + // core-server/filesystem.js:356. Each entry is [char, description]. + // The forbidden set includes all characters disallowed by Windows + // (< > : " | ? *), backslash (directory separator on Windows), + // tilde (legacy 8.3 short-name marker), and caret (disallowed in + // some shell/FS contexts). + var forbiddenChars = [ + ["<","less-than"], + [">","greater-than"], + ["~","tilde"], + [":","colon"], + ["\"","double-quote"], + ["|","pipe"], + ["?","question-mark"], + ["*","asterisk"], + ["^","caret"], + ["\\","backslash"] + ]; + + // Use originalpath so we exercise the line-356 regex directly. + // The title-branch at line ~342 pre-strips "/" and "\" before the + // main regex runs, which would mask the backslash case. + function filepathFromOriginalpath(title,extension) { + return $tw.utils.generateTiddlerFilepath(title,{ + extension: extension, + directory: directory, + fileInfo: { + overwrite: true, + originalpath: title + extension + } + }); + } + + forbiddenChars.forEach(function(entry) { + var char = entry[0], name = entry[1]; + it("should replace " + name + " (" + char + ") with underscore", function() { + var result = filepathFromOriginalpath("a" + char + "b",".tid"); + expect(path.dirname(result)).toBe(directory); + expect(path.basename(result)).toBe("a_b.tid"); + }); + }); + + it("should replace every forbidden char in a dense string", function() { + // Prefix with "x" so the sanitized result isn't all underscores, + // which would trigger the charcode-fallback branch at line ~371. + var title = "x" + forbiddenChars.map(function(e) { return e[0]; }).join(""); + var expected = "x" + forbiddenChars.map(function() { return "_"; }).join(""); + var result = filepathFromOriginalpath(title,".tid"); + expect(path.dirname(result)).toBe(directory); + expect(path.basename(result)).toBe(expected + ".tid"); + }); + + it("should sanitize real-world 'Pragma: \\define' title without creating a subdirectory", function() { + // Issue for editions/tw5.com titles like "Pragma: \define". + // Before the fix, backslash survived sanitization and path.resolve + // treated it as a separator on Windows, producing a "Pragma_ " + // subdir containing "define.tid". + var result = filepathFromOriginalpath("Pragma: \\define",".tid"); + expect(path.dirname(result)).toBe(directory); + expect(path.basename(result)).toBe("Pragma_ _define.tid"); + }); + + }); + +} diff --git a/editions/tw5.com/tiddlers/releasenotes/5.5.0/#9815-filesystempaths-backslash.tid b/editions/tw5.com/tiddlers/releasenotes/5.5.0/#9815-filesystempaths-backslash.tid new file mode 100644 index 0000000000..d7255e3a9c --- /dev/null +++ b/editions/tw5.com/tiddlers/releasenotes/5.5.0/#9815-filesystempaths-backslash.tid @@ -0,0 +1,13 @@ +change-category: nodejs +change-type: bugfix +created: 20260711175727000 +description: Tiddler titles containing a backslash no longer break generated file paths +github-contributors: pmario +github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9815 +release: 5.5.0 +tags: $:/tags/ChangeNote +title: $:/changenotes/5.5.0/#9815 +type: text/vnd.tiddlywiki + +* Titles containing a backslash, such as `pragma: \define`, get the backslash replaced like other forbidden filename characters instead of it acting as a Windows path separator (fixes [[Issue #9814|https://github.com/TiddlyWiki/TiddlyWiki5/issues/9814]]) +* Tiddler files declared in `tiddlywiki.files` are pinned to their original location and skip the `$:/config/FileSystemPaths` filters and filename sanitising