From abf134afe44b3f9148d6476daa0ecb3d8dae019a Mon Sep 17 00:00:00 2001 From: Mario Pietsch Date: Tue, 28 Jul 2026 12:34:09 +0200 Subject: [PATCH] Fix regression introduced by PR #9815 (#9944) * Keep retained tiddler subdirectories on save Make generateTiddlerFilepath keep a retained tiddler's recorded subdirectory and produce a valid filename for every path segment. * Normalise originalpath separators to "/" before the sanitiser, so recorded subdirectories survive * Validate each path segment (reserved names, trailing dots or spaces), so "sub/CON" is valid on every OS * Run that pass after transliteration, which can itself create a reserved name * Add filesystem and adversarial specs, covering generateTiddlerFileInfo and generateTiddlerExtension * update changenote --- core-server/filesystem.js | 18 +- .../tests/test-filesystem-adversarial.js | 173 +++++++++ .../test/tiddlers/tests/test-filesystem.js | 335 ++++++++++++++++-- .../5.5.0/#9815-filesystempaths-backslash.tid | 9 +- 4 files changed, 501 insertions(+), 34 deletions(-) create mode 100644 editions/test/tiddlers/tests/test-filesystem-adversarial.js diff --git a/core-server/filesystem.js b/core-server/filesystem.js index 165291444b..3d546cee50 100644 --- a/core-server/filesystem.js +++ b/core-server/filesystem.js @@ -348,13 +348,14 @@ exports.generateTiddlerFilepath = function(title,options) { //Use the originalpath without the extension var ext = path.extname(originalpath); filepath = originalpath.substring(0,originalpath.length - ext.length); + // normalise "\" to "/" so the sanitiser keeps subdirectories + // (it strips "\", not "/") instead of flattening them. + filepath = filepath.split(path.sep).join("/"); } else if(!filepath) { filepath = title; // Remove any forward or backward slashes so we don't create directories filepath = filepath.replace(/\/|\\/g,"_"); } - // Replace any Windows control codes - filepath = filepath.replace(/^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i,"_$1_"); // Replace any leading spaces with the same number of underscores filepath = filepath.replace(/^ +/,function (u) { return u.replace(/ /g, "_");}); //If the path does not start with "." or ".." && a path seperator, then @@ -368,6 +369,19 @@ exports.generateTiddlerFilepath = function(title,options) { if(!pinFilepath) { filepath = $tw.utils.transliterate(filepath.replace(/<|>|~|\:|\"|\||\?|\*|\^|\\/g,"_")); } + // Per segment (catches "sub/CON"), MUST be after transliterate which can create a + // reserved name. Reserved names are wrapped on every path incl pinned; + // trailing dots/spaces (Windows strips) fixed for generated names, not pinned. + filepath = filepath.split("/").map(function(segment) { + if(segment === "" || segment === "." || segment === "..") { + return segment; + } + segment = segment.replace(/^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i,"_$1_"); + if(!pinFilepath) { + segment = segment.replace(/[. ]+$/,function(u) { return u.replace(/[. ]/g,"_"); }); + } + return segment; + }).join("/"); // 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-adversarial.js b/editions/test/tiddlers/tests/test-filesystem-adversarial.js new file mode 100644 index 0000000000..ab96e3a31c --- /dev/null +++ b/editions/test/tiddlers/tests/test-filesystem-adversarial.js @@ -0,0 +1,173 @@ +/*\ +title: test-filesystem-adversarial.js +type: application/javascript +tags: [[$:/tags/test-spec]] + +Adversarial tests for generateTiddlerFilepath: hostile titles and recorded +originalpaths (path traversal, absolute paths, control codes, reserved device +names, over-long and non-ASCII input) must still yield a filename valid on every +supported OS, without throwing or leaking a forbidden character. Only the +filename is sanitised; the tiddler title itself is never altered. + +generateTiddlerFilepath is node-only (utils-node), so reproduce from the CLI, not +the browser. Save as probe.js in the repo root and run `node probe.js`: + + var path = require("path"); + var $tw = require("./boot/boot.js").TiddlyWiki(); + $tw.boot.argv = ["./editions/test"]; + $tw.boot.boot(function() { + var dir = path.resolve("/tmp/tw5-probe"); + function base(t) { return path.basename($tw.utils.generateTiddlerFilepath(t,{extension: ".tid", directory: dir, fileInfo: {}})); } + console.log(base("con")); // expected: _con_.tid (reserved device name wrapped) + console.log(base("ac")); // expected: a_b_c.tid (forbidden chars replaced) + console.log(base("trailing ")); // expected: trailing_.tid (trailing space replaced) + process.exit(0); + }); + +Control codes in the specs are built with String.fromCharCode so this source +stays pure ASCII. All path-logic tests use the real-save default overwrite:false. + +\*/ +"use strict"; + +if($tw.node) { + + var fs = require("fs"); + var path = require("path"); + + describe("generateTiddlerFilepath (adversarial)", function() { + + var directory = path.resolve("/tmp/tw5-test-filesystem-adv"); + + beforeEach(function() { + fs.rmSync(directory,{recursive: true, force: true}); + fs.mkdirSync(directory,{recursive: true}); + }); + afterAll(function() { + fs.rmSync(directory,{recursive: true, force: true}); + }); + + function fromTitle(title) { + return $tw.utils.generateTiddlerFilepath(title,{ + extension: ".tid", + directory: directory, + fileInfo: {} + }); + } + function fromOriginalpath(originalpath) { + return $tw.utils.generateTiddlerFilepath("plain-title",{ + extension: ".tid", + directory: directory, + fileInfo: {originalpath: originalpath} + }); + } + + // A saved path segment must contain no character Windows forbids in a + // filename and no control code. "/" and "\" are separators between + // segments; "." and ".." are dot segments, not filenames. + function hasControlCode(seg) { + for(var i = 0; i < seg.length; i++) { + if(seg.charCodeAt(i) < 0x20) { + return true; + } + } + return false; + } + function eachSegment(result,callback) { + path.relative(directory,result).split(/[\\/]/).forEach(function(seg) { + if(seg !== "" && seg !== "." && seg !== "..") { + callback(seg); + } + }); + } + function expectCleanSegments(result) { + eachSegment(result,function(seg) { + expect(seg).not.toMatch(/[<>:"|?*]/); // Windows-forbidden chars + expect(hasControlCode(seg)).toBe(false); // control codes + expect(seg).not.toMatch(/^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i); // reserved device name + expect(seg).not.toMatch(/[. ]$/); // trailing dot or space + }); + } + + // Control codes are built at runtime so the source stays pure ASCII. + var NUL = String.fromCharCode(0), BEL = String.fromCharCode(7), US = String.fromCharCode(0x1f); + + // Battery of hostile inputs, fed through both the title and originalpath + // channels. + var hostile = [ + "../../../etc/passwd", + "..\\..\\evil", + "ac:d\"e|f?g*h", + "a" + NUL + "b" + BEL + "c" + US + "d", + "con", "PRN", "nul", "COM1", "LPT9", + "a/nul/b", + " leading", + "trailing ", + "dots...", + "café naïve", + "中文テスト", + new Array(400).join("z"), + "<>:\"|?*", + "//..//..//", + "a/b\\c/d", + "", + ".", + "..", + "::::", + " " + ]; + + it("never leaks a forbidden character or throws, for any hostile input", function() { + hostile.forEach(function(input) { + [fromTitle, function(t) { return fromOriginalpath(t + ".tid"); }].forEach(function(fn) { + var result; + expect(function() { result = fn(input); }).not.toThrow(); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + expectCleanSegments(result); + }); + }); + }); + + it("replaces control codes and NUL in the filename with underscore", function() { + expect(path.basename(fromTitle("a" + NUL + "b" + US + "c"))).toBe("a_b_c.tid"); + }); + + it("wraps a reserved Windows device name used as the whole title", function() { + expect(path.basename(fromTitle("CON"))).toBe("_CON_.tid"); + expect(path.basename(fromTitle("nul"))).toBe("_nul_.tid"); + expect(path.basename(fromTitle("COM1"))).toBe("_COM1_.tid"); + }); + + it("bounds the filename length for an over-long title", function() { + var result = fromTitle(new Array(400).join("z")); + expect(path.basename(result).length).toBeLessThanOrEqual(200 + ".tid".length); + }); + + it("transliterates non-ASCII to ASCII in every segment", function() { + expect(path.basename(fromTitle("café"))).toBe("cafe.tid"); + eachSegment(fromOriginalpath("café/naïve.tid"),function(seg) { + for(var i = 0; i < seg.length; i++) { + expect(seg.charCodeAt(i)).toBeLessThan(128); + } + }); + }); + + it("gives dots-only titles a valid, distinct, non-empty filename", function() { + // "." and ".." are legal tiddler titles but not legal bare filenames. + // The contract is a non-empty, forbidden-char-free name that is not a + // hidden dotfile, and two different titles must not map to one file. + // The exact fallback encoding is an implementation detail, not asserted. + var dot = path.basename(fromTitle(".")); + var dotdot = path.basename(fromTitle("..")); + [dot,dotdot].forEach(function(name) { + expect(name.length).toBeGreaterThan(".tid".length); + expect(name).not.toMatch(/[<>:"|?*]/); + expect(name).not.toMatch(/^\.+/); + }); + expect(dot).not.toBe(dotdot); + }); + + }); + +} diff --git a/editions/test/tiddlers/tests/test-filesystem.js b/editions/test/tiddlers/tests/test-filesystem.js index 23f6f0a7e6..8df4fb99d9 100644 --- a/editions/test/tiddlers/tests/test-filesystem.js +++ b/editions/test/tiddlers/tests/test-filesystem.js @@ -3,25 +3,62 @@ title: test-filesystem.js type: application/javascript tags: [[$:/tags/test-spec]] -Tests for $:/core-server filesystem utilities. +Tests for the $:/core-server filesystem utilities: generateTiddlerFilepath, +generateTiddlerFileInfo and generateTiddlerExtension. These are node-only +(module-type utils-node), so they run under `npm test` and are absent in the +browser: reproduce them from the CLI, not the F12 console. + +Reproduce a case by hand. Save as probe.js in the repo root, run `node probe.js`, +and swap in the input and expected value from any spec below: + + var path = require("path"); + var $tw = require("./boot/boot.js").TiddlyWiki(); + $tw.boot.argv = ["./editions/test"]; + $tw.boot.boot(function() { + var dir = path.resolve("/tmp/tw5-probe"); + var r = $tw.utils.generateTiddlerFilepath("a>b",{extension: ".tid", directory: dir, fileInfo: {}}); + console.log(path.basename(r)); // expected: a_b.tid + process.exit(0); + }); + +Reproduce the real client/server save (the retain-original-tiddler-path case). +From the repo root run: + + node ./tiddlywiki.js ./editions/tw5.com-server --listen + +then in the browser edit a tiddler whose file lives in a subfolder, for example +examples/ButtonWidget/Popup.tid, save, and confirm on disk that the file stays in +examples/ButtonWidget/ rather than being flattened to the tiddlers root. \*/ "use strict"; if($tw.node) { + var fs = require("fs"); 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). + // Real saves use overwrite:false, which runs the uniquifier and probes the + // filesystem. Start every test from an empty directory so the computed + // filenames are deterministic. Only the dedicated overwrite test below + // passes overwrite:true. + beforeEach(function() { + fs.rmSync(directory,{recursive: true, force: true}); + fs.mkdirSync(directory,{recursive: true}); + }); + afterAll(function() { + fs.rmSync(directory,{recursive: true, force: true}); + }); + + // Characters illegal in a filename on at least one supported OS (Windows + // forbids < > : " / \ | ? *). generateTiddlerFilepath replaces each with + // "_" so a tiddler file committed on one OS still checks out on every + // other: an unsanitised "a>b.tid" would break `git checkout` on Windows. + // Only the filename is sanitised; the tiddler title keeps the character. var forbiddenChars = [ ["<","less-than"], [">","greater-than"], @@ -35,49 +72,291 @@ if($tw.node) { ["\\","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) { + // Title channel: a title is plain text, so its slashes and backslashes are + // not directory separators and every forbidden character is replaced. + function filepathFromTitle(title) { return $tw.utils.generateTiddlerFilepath(title,{ - extension: extension, + extension: ".tid", directory: directory, - fileInfo: { - overwrite: true, - originalpath: title + extension - } + fileInfo: {} + }); + } + + // OTP channel: originalpath ($:/config/OriginalTiddlerPaths) is a real + // recorded relative location. Its separators are preserved; a forbidden + // character inside a segment is still replaced. A distinct title proves the + // filename derives from originalpath, not the title. + function filepathFromOriginalpath(originalpath) { + return $tw.utils.generateTiddlerFilepath("WrongIfUsed",{ + extension: ".tid", + directory: directory, + fileInfo: {originalpath: originalpath} + }); + } + + // FileSystemPaths channel: a $:/config/FileSystemPaths filter builds the + // path from the title, so a backslash leaking in from the title must be + // sanitised. "[[...]]" echoes the literal. + function filepathFromPathFilter(title,filter) { + return $tw.utils.generateTiddlerFilepath(title,{ + extension: ".tid", + directory: directory, + wiki: $tw.wiki, + pathFilters: [filter], + fileInfo: {} }); } 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"); + it("replaces " + name + " (" + char + ") in a title with underscore", function() { + var result = filepathFromTitle("a" + char + "b"); 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. + it("replaces every forbidden char in a dense title", function() { + // Prefix "x" so the result is not all underscores, which would trigger + // the charcode-fallback branch in generateTiddlerFilepath. var title = "x" + forbiddenChars.map(function(e) { return e[0]; }).join(""); var expected = "x" + forbiddenChars.map(function() { return "_"; }).join(""); - var result = filepathFromOriginalpath(title,".tid"); + var result = filepathFromTitle(title); 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"); + it("does not overwrite a different tiddler that sanitises to the same filename", function() { + // "a>b" and "a/b" are distinct titles that both sanitise to "a_b.tid". + // With overwrite off (the real-save default) the second file gets a + // uniquifier ("_1") rather than clobbering the first. + fs.writeFileSync(path.resolve(directory,"a_b.tid"),""); + var result = filepathFromTitle("a/b"); + expect(path.basename(result)).toBe("a_b_1.tid"); + }); + + it("reuses the target filename when overwrite is set (the --save path)", function() { + // overwrite:true (used by the --save command) writes a specific file + // even if it already exists, so no uniquifier is added. This is the + // only test allowed to pass overwrite:true. + fs.writeFileSync(path.resolve(directory,"a_b.tid"),""); + var result = $tw.utils.generateTiddlerFilepath("a/b",{ + extension: ".tid", + directory: directory, + fileInfo: {overwrite: true} + }); + expect(path.basename(result)).toBe("a_b.tid"); + }); + + it("sanitises a backslash from a FileSystemPaths filter without making a subdir (#9814)", function() { + // A title such as "Pragma: \define" reaching the filename via a + // FileSystemPaths filter must not act as a Windows path separator. + var result = filepathFromPathFilter("Pragma: \\define","[[Pragma: \\define]]"); expect(path.dirname(result)).toBe(directory); expect(path.basename(result)).toBe("Pragma_ _define.tid"); }); + it("keeps the subdirectory of a retained originalpath (native separators)", function() { + // Regression: on Windows path.relative yields backslash separators, so + // originalpath is e.g. "examples\ButtonWidget\Popup.tid". They must be + // preserved so the tiddler saves back to its folder rather than being + // flattened to "examples_ButtonWidget_Popup.tid" at the tiddlers root. + var original = ["examples","ButtonWidget","Popup"].join(path.sep) + ".tid"; + var result = filepathFromOriginalpath(original); + expect(path.dirname(result)).toBe(path.resolve(directory,"examples","ButtonWidget")); + expect(path.basename(result)).toBe("Popup.tid"); + }); + + it("keeps the subdirectory of a retained originalpath (forward slashes)", function() { + var result = filepathFromOriginalpath("examples/ButtonWidget/Popup.tid"); + expect(path.dirname(result)).toBe(path.resolve(directory,"examples","ButtonWidget")); + expect(path.basename(result)).toBe("Popup.tid"); + }); + + it("still sanitises a forbidden char inside an originalpath segment", function() { + // ">" is a legal filename char on Linux and macOS but forbidden on + // Windows. The sanitiser always targets the strictest OS, so a file + // committed on Linux still checks out on Windows: an unsanitised + // "a>b.tid" would break `git checkout` there and brick the clone. + // The ">" is replaced while the "sub/" directory is kept. + var result = filepathFromOriginalpath("sub/a>b.tid"); + expect(path.dirname(result)).toBe(path.resolve(directory,"sub")); + expect(path.basename(result)).toBe("a_b.tid"); + }); + + it("replaces trailing dots and spaces in a filename", function() { + // Windows silently strips a trailing dot or space from a filename, so + // they are replaced to keep the name stable across platforms, in a + // title and inside an originalpath segment alike. + expect(path.basename(filepathFromTitle("foo."))).toBe("foo_.tid"); + expect(path.basename(filepathFromTitle("foo "))).toBe("foo_.tid"); + expect(path.basename(filepathFromOriginalpath("sub/foo..tid"))).toBe("foo_.tid"); + }); + + it("uses a tiddlywiki.files pinned path verbatim, skipping sanitisation", function() { + // A pinFilepath (tiddlywiki.files) path is author-controlled and + // trusted: separators are kept and the forbidden-char sanitiser is + // skipped, so a tilde (which it would otherwise replace) survives. + var result = $tw.utils.generateTiddlerFilepath("WrongIfUsed",{ + extension: ".tid", + directory: directory, + fileInfo: {pinFilepath: true, originalpath: "pinned/a~b.tid"} + }); + expect(path.dirname(result)).toBe(path.resolve(directory,"pinned")); + expect(path.basename(result)).toBe("a~b.tid"); + }); + + it("falls back to originalpath when no FileSystemPaths filter matches", function() { + // A filter that selects nothing for this tiddler leaves the recorded + // originalpath in charge of the location. + var result = $tw.utils.generateTiddlerFilepath("WrongIfUsed",{ + extension: ".tid", + directory: directory, + wiki: $tw.wiki, + pathFilters: ["[tag[__no_such_tag__]]"], + fileInfo: {originalpath: "examples/ButtonWidget/Popup.tid"} + }); + expect(path.dirname(result)).toBe(path.resolve(directory,"examples","ButtonWidget")); + expect(path.basename(result)).toBe("Popup.tid"); + }); + + it("replaces leading spaces in a title with underscores", function() { + expect(path.basename(filepathFromTitle(" foo"))).toBe("__foo.tid"); + }); + + it("replaces leading dots so the file is not hidden on unix", function() { + expect(path.basename(filepathFromTitle(".hidden"))).toBe("_hidden.tid"); + expect(path.basename(filepathFromTitle("..twodots"))).toBe("__twodots.tid"); + }); + + it("replaces trailing dots or spaces in the extension", function() { + var result = $tw.utils.generateTiddlerFilepath("foo",{ + extension: ".tid.", + directory: directory, + fileInfo: {} + }); + expect(path.basename(result)).toBe("foo.tid_"); + }); + + it("truncates an over-long extension to 32 characters", function() { + var longExt = "." + new Array(50).join("x"); + var result = $tw.utils.generateTiddlerFilepath("foo",{ + extension: longExt, + directory: directory, + fileInfo: {} + }); + expect(path.basename(result)).toBe("foo" + longExt.substr(0,32)); + }); + + it("does not double the extension when the title already ends in it", function() { + // "notes.tid" with extension ".tid" must yield "notes.tid", not + // "notes.tid.tid". + expect(path.basename(filepathFromTitle("notes.tid"))).toBe("notes.tid"); + }); + + it("keeps its own filename when an existing tiddler is re-saved", function() { + // The tiddler already owns "a_b.tid" (fileInfo.filepath). Re-saving + // reuses it instead of appending a "_1" uniquifier, which would + // otherwise orphan a copy on every save. + var owned = path.resolve(directory,"a_b.tid"); + fs.writeFileSync(owned,""); + var result = $tw.utils.generateTiddlerFilepath("a/b",{ + extension: ".tid", + directory: directory, + fileInfo: {filepath: owned} + }); + expect(path.basename(result)).toBe("a_b.tid"); + }); + + it("URI-encodes the filename after a write error", function() { + // fileInfo.writeError forces the encoded fallback so a retry can write + // a name the filesystem will accept. + var result = $tw.utils.generateTiddlerFilepath("plain",{ + extension: ".tid", + directory: directory, + fileInfo: {writeError: true} + }); + expect(path.dirname(result)).toBe(directory); + expect(path.basename(result)).not.toBe("plain.tid"); + expect(path.basename(result)).toContain("plain.tid"); + }); + + it("folds a path that escapes all allowed roots back into the directory", function() { + // An originalpath resolving outside directory / wikiTiddlersPath is + // encoded into a single filename inside directory rather than escaping. + var result = $tw.utils.generateTiddlerFilepath("plain",{ + extension: ".tid", + directory: directory, + fileInfo: {originalpath: "../../../outside/evil.tid"} + }); + expect(path.dirname(result)).toBe(directory); + }); + + }); + + describe("generateTiddlerFileInfo", function() { + + var directory = path.resolve("/tmp/tw5-test-filesystem"); + + function fileInfoFor(fields,existing) { + return $tw.utils.generateTiddlerFileInfo(new $tw.Tiddler(fields),{ + directory: directory, + wiki: $tw.wiki, + fileInfo: existing || {} + }); + } + + it("saves a wikitext tiddler as a single .tid file", function() { + var fi = fileInfoFor({title: "Foo", text: "body", type: "text/vnd.tiddlywiki"}); + expect(fi.type).toBe("application/x-tiddler"); + expect(fi.hasMetaFile).toBe(false); + }); + + it("saves a non-wikitext tiddler as a body file plus a .meta file", function() { + var fi = fileInfoFor({title: "Foo", text: "body", type: "text/plain"}); + expect(fi.type).toBe("text/plain"); + expect(fi.hasMetaFile).toBe(true); + }); + + it("saves a tiddler with an unsafe field value as JSON", function() { + // A field value with leading whitespace cannot round-trip through a + // .tid header, so the whole tiddler is written as JSON. + var fi = fileInfoFor({title: "Foo", text: "body", type: "text/vnd.tiddlywiki", custom: " leading"}); + expect(fi.type).toBe("application/json"); + expect(fi.hasMetaFile).toBe(false); + }); + + it("keeps a tiddler with a _canonical_uri as a .tid file", function() { + var fi = fileInfoFor({title: "Img", type: "image/png", _canonical_uri: "images/x.png"}); + expect(fi.type).toBe("application/x-tiddler"); + }); + + it("propagates isEditableFile and originalpath from the existing fileInfo", function() { + var fi = fileInfoFor({title: "Foo", text: "b", type: "text/vnd.tiddlywiki"},{isEditableFile: true, originalpath: "sub/Foo.tid"}); + expect(fi.isEditableFile).toBe(true); + expect(fi.originalpath).toBe("sub/Foo.tid"); + }); + + }); + + describe("generateTiddlerExtension", function() { + + it("returns the extension from the first matching extFilter", function() { + var ext = $tw.utils.generateTiddlerExtension("Foo",{ + extFilters: ["[[Foo]then[.md]]"], + wiki: $tw.wiki + }); + expect(ext).toBe(".md"); + }); + + it("returns undefined when no extFilter matches", function() { + var ext = $tw.utils.generateTiddlerExtension("Foo",{ + extFilters: ["[tag[__no_such_tag__]then[.md]]"], + wiki: $tw.wiki + }); + expect(ext).toBeUndefined(); + }); + }); } 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 index d7255e3a9c..894806ce7a 100644 --- 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 @@ -1,13 +1,14 @@ change-category: nodejs change-type: bugfix created: 20260711175727000 -description: Tiddler titles containing a backslash no longer break generated file paths +description: Keep retained subdirectory tiddlers and sanitise file names per path segment github-contributors: pmario -github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9815 +github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9815 https://github.com/TiddlyWiki/TiddlyWiki5/pull/9944 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 +* A tiddler saved with `retain-original-tiddler-path` keeps its recorded subdirectory instead of being flattened to the tiddlers root on Windows +* File names are sanitised per path segment, so a forbidden character or reserved device name is replaced at every level (for example `sub/CON`), and a title containing a backslash such as `pragma: \define` no longer acts 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 forbidden-character sanitising