diff options
19 files changed, 245 insertions, 7 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index c89be6d..f152883 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -2982,6 +2982,15 @@ requirements, not compatibility notes. | **Watcher reliability** | Change notification drops events under load on Windows, and inotify is unreliable on a FUSE mount. **Periodic reconciliation is mandatory on both platforms** | | **No symlinks, no POSIX permissions** | Simplifications: nothing to defend against, and the node runs as the user anyway | +**The client makes a name writable when it saves, and says so.** A node serves +the name its disk gave the file and never rewrites it — that is the string that +opens it — so a single file, a zip's entries and the zip's own name are passed +through `portable-name.js` at the moment of saving (reserved characters become +`_`, a trailing dot or space goes, a reserved stem gains `_`), and the transfer's +row names the original when it changed. The rule is `paths.sanitize_for_download`, +and the two are held byte-identical by a parity test. Two different names can +still become one — a zip keeps both entries under it. + **Case folding is for comparisons the code makes itself** — index identity, collision reporting, root names, nesting checks. It is *not* needed for the no-overwrite rule, where the filesystem's own case-insensitive `stat()` already diff --git a/packages/meshbay-common/src/meshbay_common/paths.py b/packages/meshbay-common/src/meshbay_common/paths.py index 45bf34e..b673649 100644 --- a/packages/meshbay-common/src/meshbay_common/paths.py +++ b/packages/meshbay-common/src/meshbay_common/paths.py @@ -137,7 +137,9 @@ def sanitize_for_download(name: str, *, replacement: str = "_") -> str: For the client saving a file, never for the node storing one. Returns the name unchanged when it is already portable, so the common case is identity - and the caller can tell whether it renamed anything by comparing. + and the caller can tell whether it renamed anything by comparing. The + browser's copy is `static/portable-name.js`, held to this one by + `test_portable_name_parity.py`. """ if is_portable_name(name): return name diff --git a/packages/meshbay-common/tests/test_portable_name_parity.py b/packages/meshbay-common/tests/test_portable_name_parity.py new file mode 100644 index 0000000..3a491a7 --- /dev/null +++ b/packages/meshbay-common/tests/test_portable_name_parity.py @@ -0,0 +1,78 @@ +""" +The browser makes a name writable everywhere exactly as Python does. + +`static/portable-name.js` renames a file at the moment a member saves it; +`meshbay_common.paths.sanitize_for_download` is the same rule in Python. Two +copies of a rule that differ decide differently which files get renamed, so the +real module runs under node here against the real Python. + +Skipped when node is unavailable; that is a coverage gap, not a pass. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest +from meshbay_common.paths import is_portable_name, sanitize_for_download + +PORTABLE_JS = (Path(__file__).resolve().parents[2] + / "meshbay-hub" / "src" / "meshbay_hub" / "static" / "portable-name.js") + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not PORTABLE_JS.exists(), + reason="node or portable-name.js unavailable — parity cannot be checked", +) + +NAMES = [ + "plain.txt", "Réunion 12:30.pdf", 'a<b>c:d"e/f\\g|h?i*j.txt', "tab\tnew\nline", + "ends with dot.", "ends with space ", "trailing . . ", "CON", "con.txt", "aux.tar.gz", + "COM1", "com10.txt", "LPT9.log", "nul.", ".", "..", "", " ", ".bashrc", "...", + "名前:ファイル.mkv", "emoji 🙂?.png", "\x01\x1f.bin", "prn .txt", "Con", + "a.b.c", "COM1.", "normal name (2).mp4", +] + +_HARNESS = r""" +const fs = require('fs'); +const src = fs.readFileSync(process.argv[2], 'utf8').replace(/^export /gm, ''); +const M = new Function(src + '\nreturn { portableName, portablePath };')(); +const names = JSON.parse(fs.readFileSync(process.argv[3], 'utf8')); +process.stdout.write(JSON.stringify({ + names: names.map((n) => M.portableName(n)), + path: M.portablePath('Top:Folder/sub//aux.txt'), +})); +""" + + +@pytest.fixture(scope="module") +def js(tmp_path_factory): + d = tmp_path_factory.mktemp("portable") + (d / "harness.js").write_text(_HARNESS, encoding="utf-8") + (d / "names.json").write_text(json.dumps(NAMES), encoding="utf-8") + proc = subprocess.run( + ["node", str(d / "harness.js"), str(PORTABLE_JS), str(d / "names.json")], + capture_output=True, text=True, encoding="utf-8", timeout=60) + if proc.returncode != 0: + pytest.fail(f"node harness failed:\n{proc.stderr}") + return json.loads(proc.stdout) + + +@pytest.mark.parametrize("i,name", list(enumerate(NAMES))) +def test_the_browser_renames_as_python_does(i, name, js): + assert js["names"][i] == sanitize_for_download(name) + + +@pytest.mark.parametrize("name", NAMES) +def test_what_comes_out_can_be_written_everywhere(name): + out = sanitize_for_download(name) + assert is_portable_name(out), (name, out) + assert sanitize_for_download(out) == out + + +def test_a_portable_name_is_left_alone(): + assert sanitize_for_download("plain.txt") == "plain.txt" + + +def test_a_path_is_made_portable_segment_by_segment(js): + assert js["path"] == "Top_Folder/sub//aux_.txt" diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index bcd5999..875b1aa 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -303,6 +303,7 @@ function TransferRow({ it }) { </button> `} </div> + ${it.note && html`<div class="transfer-meta transfer-note">${it.note}</div>`} ${it.status === 'preparing' ? html` ${/* Not a progress bar at 0%: nothing is wrong and nothing is diff --git a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js index bdfba6e..255c162 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js @@ -3,6 +3,7 @@ import * as platform from './platform.js'; import { t } from './i18n.js'; import { ask } from './ask.js'; import { ZipStream, entriesUnder } from './zipstream.js'; +import { portableName, portablePath } from './portable-name.js'; const FILE_ICONS = { video: '\u{1F3AC}', audio: '\u{1F3B5}', image: '\u{1F5BC}', @@ -428,6 +429,9 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk */ async function downloadEntry(transfers, transport, gek, entry) { const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); + // Saved under a name every platform can write, and the row says so when that + // is not the node's (portable-name.js, docs/MESHBAY_DESIGN.md §10). + const saveName = portableName(entry.name); const openRef = { url: null }; let target = null; // The in-memory fallback's accumulator, held out here so a pause does not @@ -435,14 +439,15 @@ async function downloadEntry(transfers, transport, gek, entry) { const memoryChunks = new Array(totalChunks); transfers.start({ - kind: 'download', name: entry.name, total: entry.size, transport, + kind: 'download', name: saveName, total: entry.size, transport, + note: saveName !== entry.name ? t('transfers.renamed', { name: entry.name }) : '', // The row exists from the click. Opening a target is what takes the time — // the streamed path waits for the worker (twice), a Save As dialog waits // for a person — and doing it before the row meant three clicks produced no // panel at all and then several rows at once. prepare: async () => { - target = await _openTargetInTurn(entry.name, entry.size); + target = await _openTargetInTurn(saveName, entry.size); // Dismissed: nothing was started, so nothing is left on screen. if (target === false) return false; // `pausable` travels with the target, because only the target knows. The @@ -489,7 +494,7 @@ async function downloadEntry(transfers, transport, gek, entry) { transport, gek, entry.id, totalChunks, onChunk, null, signal, lease && lease.tr, from, memoryChunks); const blob = new Blob(chunks); - _saveBlob(blob, entry.name); + _saveBlob(blob, saveName); openRef.url = URL.createObjectURL(blob); } }, @@ -523,7 +528,10 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE return; } const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0); - const suggested = (dir.split('/').pop() || 'files') + '.zip'; + const suggested = portableName(dir.split('/').pop() || 'files') + '.zip'; + // Every name in the archive is made writable everywhere, or a Windows + // extraction refuses it; the row says how many changed. + const renamed = files.filter(f => portablePath(f.name) !== f.name).length; // Checked here rather than by disabling the button: Files zips a whole // multi-directory selection in one click (`for (const d of selectedDirs)`), @@ -548,6 +556,7 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE transfers.start({ kind: 'download', name: suggested, total: totalBytes, transport, + note: renamed ? t('transfers.renamed_n', { n: renamed }) : '', // Same order as downloadEntry: the row first, then the target, then the // slot. A folder of forty files is exactly where the wait is longest. @@ -584,7 +593,7 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE }); for (const { entry, name } of files) { - await zip.begin(name, entry.size, + await zip.begin(portablePath(name), entry.size, new Date((entry.added_at || 0) * 1000)); // A zero-byte file has no chunk to ask for; the header and an empty // descriptor are the whole entry. diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index b84da2b..a83f44b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -1218,4 +1218,8 @@ export default { 'admin.reports_review_threshold': "Mitglieder bis zur Prüfung", 'admin.reports_auto_block': "Ohne Prüfung sperren (1 = ja, 0 = nein)", 'admin.reports_save': "Meldeeinstellungen speichern", + + // Names made writable everywhere when saved (portable-name.js) + 'transfers.renamed': "Umbenannt: „{name}“ ist nicht auf jedem System ein gültiger Name", + 'transfers.renamed_n': "Namen geändert, damit sie auf jedem System gültig sind: {n}", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 7cbd830..46c5094 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -1199,4 +1199,8 @@ export default { 'admin.reports_review_threshold': "Members before review", 'admin.reports_auto_block': "Block without review (1 = yes, 0 = no)", 'admin.reports_save': "Save report settings", + + // Names made writable everywhere when saved (portable-name.js) + 'transfers.renamed': "Renamed: “{name}” is not a valid name on every system", + 'transfers.renamed_n': "Names changed to be valid on every system: {n}", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 081b1f2..0d0e865 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -1212,4 +1212,8 @@ export default { 'admin.reports_review_threshold': "Miembros antes de la revisión", 'admin.reports_auto_block': "Bloquear sin revisión (1 = sí, 0 = no)", 'admin.reports_save': "Guardar ajustes de denuncias", + + // Names made writable everywhere when saved (portable-name.js) + 'transfers.renamed': "Renombrado: «{name}» no es un nombre válido en todos los sistemas", + 'transfers.renamed_n': "Nombres cambiados para ser válidos en todos los sistemas: {n}", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 04218d6..3c86cd7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -1227,4 +1227,8 @@ export default { 'admin.reports_review_threshold': "Membres avant examen", 'admin.reports_auto_block': "Bloquer sans examen (1 = oui, 0 = non)", 'admin.reports_save': "Enregistrer les réglages de signalement", + + // Names made writable everywhere when saved (portable-name.js) + 'transfers.renamed': "Renommé : « {name} » n’est pas un nom valide sur tous les systèmes", + 'transfers.renamed_n': "Noms modifiés pour être valides sur tous les systèmes : {n}", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 857bbc2..3a74b4d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -1226,4 +1226,8 @@ export default { 'admin.reports_review_threshold': "Membri prima dell’esame", 'admin.reports_auto_block': "Blocca senza esame (1 = sì, 0 = no)", 'admin.reports_save': "Salva impostazioni segnalazioni", + + // Names made writable everywhere when saved (portable-name.js) + 'transfers.renamed': "Rinominato: «{name}» non è un nome valido su tutti i sistemi", + 'transfers.renamed_n': "Nomi modificati per essere validi su tutti i sistemi: {n}", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 2d9830b..e0c38de 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -1210,4 +1210,8 @@ export default { 'admin.reports_review_threshold': "確認までのメンバー数", 'admin.reports_auto_block': "確認せずにブロック(1 = はい、0 = いいえ)", 'admin.reports_save': "報告の設定を保存", + + // Names made writable everywhere when saved (portable-name.js) + 'transfers.renamed': "名前を変更しました:「{name}」はすべてのシステムで有効な名前ではありません", + 'transfers.renamed_n': "すべてのシステムで有効になるよう変更した名前:{n}", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 8635995..83b9eed 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -1228,4 +1228,8 @@ export default { 'admin.reports_review_threshold': "Leden vóór beoordeling", 'admin.reports_auto_block': "Blokkeren zonder beoordeling (1 = ja, 0 = nee)", 'admin.reports_save': "Meldingsinstellingen opslaan", + + // Names made writable everywhere when saved (portable-name.js) + 'transfers.renamed': "Hernoemd: ‘{name}’ is niet op elk systeem een geldige naam", + 'transfers.renamed_n': "Namen aangepast zodat ze op elk systeem geldig zijn: {n}", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index f988e5e..3edba2d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -1254,4 +1254,8 @@ export default { 'admin.reports_review_threshold': "Członkowie przed oceną", 'admin.reports_auto_block': "Blokuj bez oceny (1 = tak, 0 = nie)", 'admin.reports_save': "Zapisz ustawienia zgłoszeń", + + // Names made writable everywhere when saved (portable-name.js) + 'transfers.renamed': "Zmieniono nazwę: „{name}” nie jest prawidłową nazwą w każdym systemie", + 'transfers.renamed_n': "Nazwy zmienione, by były prawidłowe w każdym systemie: {n}", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index d761dd3..3f44570 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -1213,4 +1213,8 @@ export default { 'admin.reports_review_threshold': "Membros antes da análise", 'admin.reports_auto_block': "Bloquear sem análise (1 = sim, 0 = não)", 'admin.reports_save': "Salvar configurações de denúncias", + + // Names made writable everywhere when saved (portable-name.js) + 'transfers.renamed': "Renomeado: “{name}” não é um nome válido em todos os sistemas", + 'transfers.renamed_n': "Nomes alterados para serem válidos em todos os sistemas: {n}", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 0d9afab..1168460 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -1199,4 +1199,8 @@ export default { 'admin.reports_review_threshold': "进入审核所需人数", 'admin.reports_auto_block': "无需审核直接屏蔽(1 = 是,0 = 否)", 'admin.reports_save': "保存举报设置", + + // Names made writable everywhere when saved (portable-name.js) + 'transfers.renamed': "已重命名:“{name}”并非在所有系统上都是有效的名称", + 'transfers.renamed_n': "为在所有系统上有效而修改的名称:{n}", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/portable-name.js b/packages/meshbay-hub/src/meshbay_hub/static/portable-name.js new file mode 100644 index 0000000..bb2438c --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/portable-name.js @@ -0,0 +1,51 @@ +/** + * A name that can be written on every platform a member saves to + * (docs/MESHBAY_DESIGN.md §10). + * + * A node serves a file under the name its own disk gave it, and a name that is + * fine on ext4 can be impossible on Windows or on an exFAT drive: reserved + * characters, a trailing dot or space, `CON` or `aux.txt`. The node never + * rewrites a name — it is the string that opens the file — so the client does, + * at the moment it saves, and says so. + * + * The same rules as `meshbay_common.paths.sanitize_for_download`, and held to + * them by `test_portable_name_parity.py`: two copies of a rule that differ + * decide differently which files get renamed. + */ + +const WINDOWS_RESERVED = new Set([ + 'CON', 'PRN', 'AUX', 'NUL', + ...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => `COM${i}`), + ...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => `LPT${i}`), +]); + +const RESERVED_CHARS = new Set('<>:"/\\|?*'); + +const reserved = (c) => RESERVED_CHARS.has(c) || c.charCodeAt(0) < 32; + +function isPortable(name) { + if (!name || name === '.' || name === '..') return false; + for (const c of name) if (reserved(c)) return false; + if (name.endsWith(' ') || name.endsWith('.')) return false; + return !WINDOWS_RESERVED.has(name.split('.', 1)[0].toUpperCase()); +} + +/** `name` if it can be written everywhere, otherwise the nearest name that can. */ +export function portableName(name, replacement = '_') { + const text = String(name ?? ''); + if (isPortable(text)) return text; + let out = Array.from(text).map((c) => (reserved(c) ? replacement : c)).join(''); + out = out.replace(/[ .]+$/, ''); + const dot = out.indexOf('.'); + let stem = dot === -1 ? out : out.slice(0, dot); + const rest = dot === -1 ? '' : out.slice(dot); + if (WINDOWS_RESERVED.has(stem.toUpperCase())) stem += replacement; + out = stem + rest; + return out || 'unnamed'; +} + +/** Every segment of a relative path made portable, keeping `/` between them. */ +export function portablePath(path) { + return String(path ?? '').split('/') + .map((segment) => (segment ? portableName(segment) : segment)).join('/'); +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 7471e31..fc91596 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -2588,6 +2588,7 @@ a.transfer-name { margin-top: 3px; } .transfer-failed { color: var(--error); } +.transfer-note { justify-content: flex-start; font-style: italic; } /* ── File selection ──────────────────────────────────────────────────────── */ diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js index bde382f..0d012ed 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js @@ -73,6 +73,7 @@ export class TransferStore { queuedByOwnLimit: Boolean( it.lease && it.lease.cap && it.lease.used >= it.lease.cap), error: it.error || '', + note: it.note || '', speed: this._speed(it), // The ETA is drawn only once the window holds a few seconds of real // measurement -- see etaSeconds. @@ -134,10 +135,14 @@ export class TransferStore { * there is somewhere to write — see file-utils.js's downloadEntry. */ start({ kind, name, total = 0, transport = null, run, open = null, - lease = null, prepare = null, makeLease = null, pausable = false }) { + lease = null, prepare = null, makeLease = null, pausable = false, + note = '' }) { const item = { id: _nextId++, kind, name, total, transport, open, lease, + // One line said under the name for the life of the row — that a name was + // changed to be written here, for instance. + note, done: 0, // A transfer that has to wait for a slot starts as 'queued', not // 'running'. Two different things are true of it — nothing is moving, and diff --git a/packages/meshbay-hub/tests/test_portable_save_names.py b/packages/meshbay-hub/tests/test_portable_save_names.py new file mode 100644 index 0000000..05a4af7 --- /dev/null +++ b/packages/meshbay-hub/tests/test_portable_save_names.py @@ -0,0 +1,42 @@ +""" +Every way a file is saved goes through a name that can be written everywhere +(docs/MESHBAY_DESIGN.md §10, `static/portable-name.js`). + +The rule itself is held to Python's by `test_portable_name_parity.py`. What this +holds is that the save paths use it: a single file under `portableName`, the +entries of a folder's zip and the archive's own name under `portablePath` / +`portableName`, and the node's own name never handed to a save target directly. +Read from the source, because these paths need a browser and a disk to run. +""" + +import re +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +FILE_UTILS = (STATIC / "file-utils.js").read_text(encoding="utf-8") + + +def _body(name: str) -> str: + m = re.search(rf"^async function {name}\(.*?^\}}", FILE_UTILS, re.M | re.S) + assert m, f"file-utils.js no longer has {name}" + return m.group(0) + + +def test_a_single_file_is_saved_under_a_portable_name(): + body = _body("downloadEntry") + assert "portableName(entry.name)" in body + assert "_openTargetInTurn(saveName" in body + assert "_saveBlob(blob, saveName)" in body + assert "_openTargetInTurn(entry.name" not in body + assert "_saveBlob(blob, entry.name)" not in body + + +def test_a_zip_writes_portable_names_and_is_named_portably(): + body = _body("downloadDirectory") + assert "zip.begin(portablePath(name)" in body + assert "portableName(dir.split('/').pop()" in body + + +def test_a_renamed_file_is_said_so_on_its_row(): + assert "t('transfers.renamed'" in _body("downloadEntry") + assert "t('transfers.renamed_n'" in _body("downloadDirectory") |