aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_zip_size_limit.py
blob: 26c5552eb0b5ad9875204cf1aec8930c45ff1eb1 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
"""
The arbitrary ceiling on a directory zip.

`downloadDirectory` is the one implementation behind every "download this
folder as a zip" button — Files' single folder, Files' multi-folder selection,
and the Photos album button (docs/MESHBAY_DESIGN.md §9.9) — so the limit is checked
once, there, and holds for all of them.

Three things are worth pinning. That an oversized folder is refused *before*
`_openDownloadTarget`, because a save dialog for an archive that will never be
written is worse than no dialog at all. That a folder at exactly the limit
still goes through, since an off-by-one here silently costs a whole megabyte
of allowance and nobody would ever notice. And that the two limits in play do
not contradict each other: ZIP_MAX_BYTES (512 MB) bounds the archive, while
MEMORY_CEILING (100 MB, test_memory_ceiling.py) bounds what may be built in the
page — so a 400 MB zip is allowed when there is somewhere to stream it and
refused when the only route left is memory. The `confirm()` that offers the
build-in-memory path therefore only ever appears below the ceiling.
"""

import json
import shutil
import subprocess
from pathlib import Path

import pytest

STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
FILE_UTILS = STATIC / "file-utils.js"

pytestmark = pytest.mark.skipif(
    shutil.which("node") is None or not FILE_UTILS.exists(),
    reason="node or the SPA sources are not available")

MIB = 1024 * 1024


def _run(total_bytes, tmp_path, picker=False):
    """
    Call downloadDirectory over one folder holding `total_bytes`, and report
    what it did: the errors it set, how many times it put a question to the
    person, and how many transfers it started.
    """
    sandbox = tmp_path / "static"
    sandbox.mkdir()
    for src in STATIC.glob("*.js"):
        (sandbox / src.name).write_text(src.read_text(encoding="utf-8"),
                                        encoding="utf-8")
    (tmp_path / "package.json").write_text('{"type":"module"}')

    script = tmp_path / "case.mjs"
    picker_js = "true" if picker else "false"
    script.write_text(f"""
const store = new Map();
globalThis.localStorage = {{
  getItem: k => (store.has(k) ? store.get(k) : null),
  setItem: (k, v) => store.set(k, String(v)),
  removeItem: k => store.delete(k),
}};
// Node 22 defines `navigator` itself, so it is left alone; `window` is what
// platform.js reaches for to decide it is not running in the desktop app.
globalThis.window = globalThis;
// stdout carries the outcome and nothing else, so file-utils' own logging goes
// to stderr -- where it is still shown when a case fails. It logs before every
// save dialog, which is exactly what this harness provokes.
console.info = (...a) => console.error(...a);
const out = {{ errors: [], started: 0, asked: 0, dropped: 0 }};
// Reached only once the size check has passed: with no File System Access API
// under Node, downloadDirectory falls through to its build-in-memory path and
// asks first. Answering yes is what lets the at-the-limit case get as far as
// starting a transfer, and `asked` is how the refusal proves it never did.
globalThis.confirm = () => {{ out.asked += 1; return true; }};
// With `picker`, the browser can stream to a file the person chooses, which is
// the only legal route for an archive over MEMORY_CEILING. Never exercised —
// the stubbed `transfers.start` below does not run the job — it just has to be
// a target rather than null.
if ({picker_js}) {{
  window.showSaveFilePicker = async () => ({{
    name: 'album.zip',
    createWritable: async () => ({{ write: async () => {{}}, close: async () => {{}},
                                   abort: async () => {{}} }}),
  }});
}}

const M = await import('{(sandbox / "file-utils.js").as_posix()}');

// Faithful enough to the real store: it runs `prepare` and honours what it
// returns. The target is opened there now — the row exists from the click and
// the slow part happens behind it — so a stub that only counts calls would
// never reach the size check this file is about.
const transfers = {{ start: (opts) => {{
  out.started += 1;
  if (!opts.prepare) return;
  Promise.resolve()
    .then(() => opts.prepare())
    .then((ready) => {{ if (ready === false) {{ out.started -= 1; out.dropped += 1; }} }})
    .catch((e) => {{ out.started -= 1; out.errors.push(e.message); }});
}} }};
// A transport hands out transfer slots now (transfers.py's leases). The stub
// grants at once, which is what a node with no caps does: what this file is
// about is the archive limit, not the queue.
const transport = {{
  connected: true,
  openTransfer: () => ({{
    tr: 'stub', state: 'granted', ahead: 0,
    acquire: () => Promise.resolve(),
    release: () => {{}},
  }}),
}};
// One file, in the folder itself — entriesUnder keys on `path`.
const entries = [{{ id: 'f1', name: 'big.bin', path: 'album',
                   size: {total_bytes}, added_at: 0 }}];

await M.downloadDirectory(transfers, transport, null, entries, 'album', {{
  setError: (m) => out.errors.push(m),
}});

// `prepare` runs on a microtask, so let it.
await new Promise(r => setTimeout(r, 10));
out.limit = M.ZIP_MAX_BYTES;
console.log(JSON.stringify(out));
""", encoding="utf-8")

    proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
    assert proc.returncode == 0, proc.stderr
    return json.loads(proc.stdout)


def test_the_limit_is_512_mib(tmp_path):
    """The number the refusal quotes is the number the module enforces."""
    assert _run(0, tmp_path)["limit"] == 512 * MIB


def test_an_oversized_folder_is_refused_before_anything_opens(tmp_path):
    """
    One byte over. Nothing is started, and the person is told why — a silent
    return would read as a dead button.
    """
    result = _run(512 * MIB + 1, tmp_path)
    assert result["started"] == 0, "no transfer may begin"
    assert result["asked"] == 0, "nor may a save dialog have been put up first"
    assert result["errors"] == ["group.zip_too_large"], (
        "the refusal must name its own key; t() falls back to the key with no "
        "catalogue loaded, and test_locales.py holds the ten translations of it")


def test_a_folder_exactly_at_the_limit_still_downloads(tmp_path):
    """The bound is inclusive: `> ZIP_MAX_BYTES`, not `>=`.

    Given somewhere to stream to, because 512 MB is five times MEMORY_CEILING
    and building it in the page is no longer a route this code will take. That
    is what the next test is about; this one is still only about the off-by-one.
    """
    result = _run(512 * MIB, tmp_path, picker=True)
    assert result["errors"] == []
    assert result["started"] == 1
    assert result["asked"] == 0, "nothing is built in memory when it can stream"


def test_a_zip_over_the_memory_ceiling_is_refused_when_nothing_streams(tmp_path):
    """
    Between the two limits — larger than the page may hold, smaller than the
    archive limit — and no way to stream it. Before the ceiling existed this
    asked "build it in memory?" and, on yes, held 400 MB in the tab.

    The refusal names the memory ceiling, not the zip limit: quoting 512 MB at
    someone whose folder is under 512 MB would be a message about the wrong
    rule.
    """
    result = _run(400 * MIB, tmp_path)
    assert result["started"] == 0
    assert result["asked"] == 0, (
        "the person must not be offered a build-in-memory path above the ceiling")
    assert result["errors"] and "group.zip_too_large" not in result["errors"][0]


def test_a_small_folder_may_still_be_built_in_memory(tmp_path):
    """The floor is intact below the ceiling — that is what it is for."""
    result = _run(4 * MIB, tmp_path)
    assert result["errors"] == []
    assert result["asked"] == 1 and result["started"] == 1