Files
webmail/lib/__tests__/file-name-rules.test.ts
T
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

128 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest';
import { acceptedFileName, fileNameProblem, fileNameRulesFrom } from '../file-name-rules';
const RESERVED = [
'.', '..', 'CON', 'PRN', 'AUX', 'NUL', 'COM0', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7',
'COM8', 'COM9', 'LPT0', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
];
// The filenode capability Stalwart 0.16.23 publishes.
const STALWART_016 = fileNameRulesFrom({
maxFileNodeDepth: null,
maxSizeFileNodeName: 255,
forbiddenNameChars: '/<>:"\\|?*',
forbiddenNodeNames: RESERVED,
});
// Stalwart 1.0 publishes the same rules (plus a depth limit) but also refuses
// reserved stems and control characters, which the capability doesn't say.
const STALWART_10 = fileNameRulesFrom({
maxFileNodeDepth: 64,
maxSizeFileNodeName: 255,
forbiddenNameChars: '/<>:"\\|?*',
forbiddenNodeNames: RESERVED,
});
describe('file name rules', () => {
it('has no rules for servers that publish none (Stalwart before 0.16.6)', () => {
expect(fileNameRulesFrom({ maxSizeFileNodeName: 255 })).toBeNull();
expect(fileNameProblem('a:b', null)).toBeNull();
expect(acceptedFileName('a:b', null)).toBe('a:b');
});
it('reads the byte limit from the capability', () => {
expect(STALWART_10?.maxNameBytes).toBe(255);
expect(fileNameRulesFrom({ forbiddenNameChars: '/', maxSizeFileNodeName: 100 })?.maxNameBytes).toBe(100);
expect(fileNameRulesFrom({ forbiddenNameChars: '/' })?.maxNameBytes).toBe(255);
});
it('reports forbidden characters and reserved names on 0.16', () => {
expect(fileNameProblem('bad:name?.txt', STALWART_016)).toEqual({ kind: 'chars', chars: ': ?' });
expect(fileNameProblem('con', STALWART_016)).toEqual({ kind: 'reserved' });
expect(fileNameProblem('report.pdf', STALWART_016)).toBeNull();
});
// The cases of Stalwart 1.0's own `reserved_names` test (jmap/src/file/node.rs).
it('refuses reserved names and reserved stems the way Stalwart 1.0 does', () => {
for (const name of ['CON', 'con.txt', 'Aux.tar.gz', '..', '.', 'LPT9.log']) {
expect(fileNameProblem(name, STALWART_10), name).toEqual({ kind: 'reserved' });
}
for (const name of ['console.txt', '.bashrc', 'auxiliary', 'com10', 'a.con', 'ok name (1).txt']) {
expect(fileNameProblem(name, STALWART_10), name).toBeNull();
}
});
it('applies the stem rule on 0.16 too, as 1.0 would refuse the name later', () => {
expect(fileNameProblem('CON.txt', STALWART_016)).toEqual({ kind: 'reserved' });
});
it('refuses control characters (Rust char::is_control) as Stalwart 1.0 does', () => {
expect(fileNameProblem('a\u0001b', STALWART_10)).toEqual({ kind: 'control' });
// macOS stores custom folder icons as "Icon\r".
expect(fileNameProblem('Icon\r', STALWART_10)).toEqual({ kind: 'control' });
expect(fileNameProblem('a\u007fb', STALWART_10)).toEqual({ kind: 'control' });
expect(fileNameProblem('next\u0085line', STALWART_10)).toEqual({ kind: 'control' });
// Not a control character.
expect(fileNameProblem('no break', STALWART_10)).toBeNull();
});
it('refuses names over 255 bytes of UTF-8, as every Stalwart release does', () => {
expect(fileNameProblem('a'.repeat(256), STALWART_10)).toEqual({ kind: 'tooLong' });
expect(fileNameProblem('a'.repeat(255), STALWART_10)).toBeNull();
// 128 x 2 bytes.
expect(fileNameProblem('é'.repeat(128), STALWART_016)).toEqual({ kind: 'tooLong' });
expect(fileNameProblem('é'.repeat(127), STALWART_016)).toBeNull();
});
it('turns an uploaded name into one the server accepts', () => {
expect(acceptedFileName('bad:name?.txt', STALWART_10)).toBe('bad_name_.txt');
expect(acceptedFileName('NUL', STALWART_10)).toBe('NUL_');
expect(acceptedFileName('fine.txt', STALWART_10)).toBe('fine.txt');
});
it('puts the "_" after a reserved stem, where Stalwart 1.0 looks', () => {
expect(acceptedFileName('con.txt', STALWART_10)).toBe('con_.txt');
expect(acceptedFileName('Aux.tar.gz', STALWART_10)).toBe('Aux_.tar.gz');
expect(acceptedFileName('LPT9.log', STALWART_10)).toBe('LPT9_.log');
expect(acceptedFileName('..', STALWART_10)).toBe('.._');
expect(acceptedFileName('.', STALWART_10)).toBe('._');
for (const name of ['console.txt', '.bashrc', 'auxiliary', 'com10', 'a.con']) {
expect(acceptedFileName(name, STALWART_10), name).toBe(name);
}
});
it('replaces control characters', () => {
expect(acceptedFileName('Icon\r', STALWART_10)).toBe('Icon_');
expect(acceptedFileName('a\u0085b.txt', STALWART_10)).toBe('a_b.txt');
});
it('shortens a name to 255 bytes and keeps its extension', () => {
const long = acceptedFileName(`${'a'.repeat(300)}.txt`, STALWART_10);
expect(new TextEncoder().encode(long).length).toBe(255);
expect(long.endsWith('.txt')).toBe(true);
// Whole characters only: a 2-byte "é" is not split.
const accented = acceptedFileName(`${'é'.repeat(200)}.pdf`, STALWART_10);
expect(accented).toBe(`${'é'.repeat(125)}.pdf`);
// An "extension" too long to keep goes with the rest.
expect(acceptedFileName(`a.${'b'.repeat(300)}`, STALWART_10)).toBe(`a.${'b'.repeat(253)}`);
});
it('keeps a name at the byte limit acceptable when its stem is reserved', () => {
// 3 + 1 + 251 = 255 bytes: no room to add "_" after "con".
const accepted = acceptedFileName(`con.${'x'.repeat(251)}`, STALWART_10);
expect(accepted).toBe(`co_.${'x'.repeat(251)}`);
expect(fileNameProblem(accepted, STALWART_10)).toBeNull();
});
it('only produces names the rules accept', () => {
const awkward = [
'con.txt', 'Icon\r', 'a:b|c', '..', `${'z'.repeat(400)}.docx`, `CON.${'y'.repeat(260)}`, 'tab\there', 'prn',
];
for (const name of awkward) {
expect(fileNameProblem(acceptedFileName(name, STALWART_10), STALWART_10), JSON.stringify(name)).toBeNull();
}
});
});