mirror of
https://github.com/Jermolene/TiddlyWiki5
synced 2026-08-08 03:48:54 +00:00
* 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:
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user