Files
webmail/lib/file-name-rules.ts
Linus Rath e712454ec2 fix: store awkward file names and explain file errors on Stalwart 1.0
Stalwart 1.0 checks names more strictly than its capability says: a
reserved name may not be the stem either (`con.txt`, `Aux.tar.gz`), and
control characters are refused (macOS `Icon\r`). Names over 255 bytes
of UTF-8 were never accepted. Uploaded names are now made acceptable
before they are sent: control characters become "_", a reserved stem
gets "_" after it (`con.txt` becomes `con_.txt`), and a long name is
shortened, keeping its extension. The rename and new-folder dialogs
refuse such names with their own messages. The rules apply wherever the
server publishes naming rules, so 0.16.6 to 0.16.23 get `con_.txt` too.

One refused file used to abort a whole upload. Uploads now go on and
report the files that failed at the end, and a folder upload skips the
contents of a folder it could not create instead of dropping them into
the folder the upload started in. What would sit deeper than 1.0's
`maxFileNodeDepth` (64) is left out before it is uploaded, and a folder
copy that would go past it fails before the first create.

File errors keep their JMAP type, and the Files toasts now say why an
action failed: too many files or folders (`overQuota`), folders nested
too deeply, a refused name, missing rights, or a file too large. New
and copied files get a bare lowercase media type, which 1.0 requires.
2026-09-25 18:37:41 +02:00

133 lines
5.3 KiB
TypeScript

/**
* FileNode naming rules a server publishes in its filenode capability
* (`forbiddenNameChars`, `forbiddenNodeNames`, `maxSizeFileNodeName`;
* Stalwart 0.16.6+). A name that breaks them is refused ("Name contains a
* forbidden character." / "Name is reserved and cannot be used."), which used
* to surface only after an upload had started.
*
* Stalwart 1.0 checks more than its capability says (`validate_name`,
* jmap/src/file/node.rs), on every name set over JMAP:
* - no control characters (Rust's `char::is_control`: U+0000-001F, U+007F-009F);
* - a reserved name may not be the stem either, the text before the first
* "." (unless that is empty): "con.txt" and "Aux.tar.gz" are refused too.
* 1.0's rules take in 0.16's, so they are applied wherever the server
* publishes rules; on 0.16.6-0.16.23 "con.txt" is renamed although it would
* have been taken.
*/
export interface FileNameRules {
forbiddenChars: string;
forbiddenNames: string[];
/** Longest name, in bytes of UTF-8. */
maxNameBytes: number;
}
export type FileNameProblem =
| { kind: 'chars'; chars: string }
| { kind: 'control' }
| { kind: 'tooLong' }
| { kind: 'reserved' };
// Every Stalwart release refuses names over 255 bytes.
const DEFAULT_MAX_NAME_BYTES = 255;
export function fileNameRulesFrom(capability: unknown): FileNameRules | null {
if (!capability || typeof capability !== 'object') return null;
const cap = capability as { forbiddenNameChars?: unknown; forbiddenNodeNames?: unknown; maxSizeFileNodeName?: unknown };
const forbiddenChars = typeof cap.forbiddenNameChars === 'string' ? cap.forbiddenNameChars : '';
const forbiddenNames = Array.isArray(cap.forbiddenNodeNames)
? cap.forbiddenNodeNames.filter((n): n is string => typeof n === 'string')
: [];
if (!forbiddenChars && forbiddenNames.length === 0) return null;
const maxNameBytes = typeof cap.maxSizeFileNodeName === 'number' && cap.maxSizeFileNodeName > 0
? cap.maxSizeFileNodeName
: DEFAULT_MAX_NAME_BYTES;
return { forbiddenChars, forbiddenNames, maxNameBytes };
}
function isControlChar(ch: string): boolean {
const code = ch.codePointAt(0) ?? 0;
return code <= 0x1f || (code >= 0x7f && code <= 0x9f);
}
function utf8Length(text: string): number {
return new TextEncoder().encode(text).length;
}
// Stalwart compares reserved names ignoring ASCII case only.
function asciiLower(text: string): string {
return text.replace(/[A-Z]/g, c => c.toLowerCase());
}
function nameStem(name: string): string {
const dot = name.indexOf('.');
return dot > 0 ? name.slice(0, dot) : name;
}
function isReservedName(name: string, reserved: string[]): boolean {
const whole = asciiLower(name);
const stem = asciiLower(nameStem(name));
return reserved.some(n => asciiLower(n) === whole || asciiLower(n) === stem);
}
export function fileNameProblem(name: string, rules: FileNameRules | null): FileNameProblem | null {
if (!rules) return null;
if (utf8Length(name) > rules.maxNameBytes) return { kind: 'tooLong' };
const bad = [...new Set([...name].filter(c => rules.forbiddenChars.includes(c)))];
if (bad.length) return { kind: 'chars', chars: bad.join(' ') };
if ([...name].some(isControlChar)) return { kind: 'control' };
if (isReservedName(name, rules.forbiddenNames)) return { kind: 'reserved' };
return null;
}
/** The longest start of `text` that fits in `maxBytes` of UTF-8, whole characters only. */
function cutToBytes(text: string, maxBytes: number): string {
let out = '';
let used = 0;
for (const ch of text) {
const bytes = utf8Length(ch);
if (used + bytes > maxBytes) break;
out += ch;
used += bytes;
}
return out;
}
/** `name` cut to `maxBytes`, keeping the extension when it leaves room for a stem. */
function fitToBytes(name: string, maxBytes: number): string {
if (utf8Length(name) <= maxBytes) return name;
const dot = name.lastIndexOf('.');
const ext = dot > 0 ? name.slice(dot) : '';
const extBytes = utf8Length(ext);
if (!ext || extBytes >= maxBytes) return cutToBytes(name, maxBytes);
return cutToBytes(name.slice(0, dot), maxBytes - extBytes) + ext;
}
/**
* A reserved name made acceptable: "_" after the stem ("con.txt" becomes
* "con_.txt", "CON" becomes "CON_", ".." becomes ".._"). A name already at
* the byte limit has the stem's last character replaced instead.
*/
function unreserved(name: string, maxBytes: number): string {
const stemEnd = nameStem(name).length;
const marked = `${name.slice(0, stemEnd)}_${name.slice(stemEnd)}`;
if (utf8Length(marked) <= maxBytes) return marked;
const stem = [...name.slice(0, stemEnd)];
stem[stem.length - 1] = '_';
return stem.join('') + name.slice(stemEnd);
}
/**
* A variant of `name` the server accepts: forbidden and control characters
* become "_", a reserved name or stem gets "_" after the stem, and a name over
* the byte limit is shortened, keeping its extension. Used for uploads,
* where the name comes from the local file system and cannot be asked for
* again.
*/
export function acceptedFileName(name: string, rules: FileNameRules | null): string {
if (!rules) return name;
let out = [...name].map(c => (isControlChar(c) || rules.forbiddenChars.includes(c) ? '_' : c)).join('');
out = fitToBytes(out, rules.maxNameBytes);
if (isReservedName(out, rules.forbiddenNames)) out = unreserved(out, rules.maxNameBytes);
return out;
}