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
This commit is contained in:
Mario Pietsch
2026-07-28 11:34:09 +01:00
committed by GitHub
parent 45d2ca948a
commit abf134afe4
4 changed files with 501 additions and 34 deletions
+16 -2
View File
@@ -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
@@ -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("a<b>c")); // 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",
"a<b>c: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);
});
});
}
+307 -28
View File
@@ -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();
});
});
}
@@ -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