mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
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.
128 lines
5.8 KiB
TypeScript
128 lines
5.8 KiB
TypeScript
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();
|
||
}
|
||
});
|
||
});
|