Module import from a URL and through Content Hub (#10 part 2c) #90

Merged
claude-bot merged 12 commits from feat/module-import into main 2026-10-28 10:51:53 +00:00
Collaborator

Part of #10 (part 2c).

What

  • Modules page: Import (in the bar's new ⋮ menu, beside Refresh) installs a module zip from an https address or, on Ubuntu Touch, a file picked through Content Hub.
  • Imports show as rows with progress, Retry and Cancel; they resume after a kill. A download announcing more than 1 GiB is refused.
  • Any install (import or repository) is refused when its DataPath is where another installed module's data is, so no package replaces another module's files.
  • Text fields get a press-and-hold edit menu with Paste (qml/controls/TextEntry); page bars get a ⋮ menu. New content_exchange AppArmor policy group.

Blast radius

Tier: trunk
Trunk paths touched: core/src/db/ (migration 004), core/src/tasks/, sword/src/install* (package, package/data_path, http), qt/ glue, utstudybible.apparmor, tools/e2e/
Testing tier: elevated (migration, downloads, writes to the module folder), also: standard UI
Gate / rollback: Migrations are forward-only; app.sqlite3 is backed up (VACUUM INTO) before migration 004. Rollback: reinstall the previous .click plus that backup.

Proof

  • CI on head e0ae57f: all 6 success (rust: fmt, clippy, tests+coverage, release, deny, audit; app: lint, -Werror, clang-tidy, mem-check; e2e incl. import-url; click; presets). First e2e attempt failed one unrelated case (typed-reference menu tap), green on re-run: #98.
  • cargo make locally on e0ae57f: green, 87 sword + 234 core tests.
  • Fails without the change: download cap (each check_size removed → 1 test fails); check_url allowing http → 2 fail; valid_name accepting a leading dot → 1 fails; clash check disabled → the sword and core clash tests fail.
  • No false refusals: CrossWire main + Beta confs (434 modules) through the same overlap rule → 0 overlapping pairs.
  • Phone (owner, Plinius; comments 2065/2076): URL import, already-installed refusal, bad address with Retry/Cancel, Content Hub picker, offline greying: passed. Kill mid-import not tried on the phone (core test an_import_killed_mid_download_goes_on_from_its_part). The clash fix is Rust-only and not tried on the phone.
  • Desktop preview and shots of the ⋮ menu looked at by the owner.

Design notes

  • sword: Staged::any + commit installs a zip of an unknown module (one conf, name from the conf, same checks); commit first refuses a DataPath overlapping another installed conf's (DataPath::overlaps, now in package/data_path.rs).
  • core: migration 004 (tasks gain kind 'import', source, nullable module), Command::ImportUrl / ImportFile, resumable; the file is copied to <cache>/imports on the tasks thread before it is queued.
  • QML: PageBar.menu (⋮, Lucide import/refresh-cw icons); shared controls live in qml/controls/.

Review

Reviewer: fresh-context code-review at high (282) + delta/re-reviews at medium (283–286): 10 findings, 7 fixed, 3 declined (283-3: covered by the http layer's cut tests; 286-3: mirrors owns, no CrossWire pair affected; 286-4: an unparseable conf is no module our reader opens), 0 deferred, 2 fixed after the delta review (286-1, 286-2 in e0ae57f), 0 left open. The first audit's missed defect (import over another module's data) is fixed in 681ac3e.
Review-Confidence: 70/100, review of the clash fix stopped at 681ac3e (e0ae57f is unreviewed trunk) and 286-4's skip path has no test.

Confidence by part

  • Download cap, URL check, migration 004, import tasks: high (unit tests with fails-without evidence, CI green, phone).
  • Clash check (Staged::commit, DataPath::overlaps): medium; tested, the CrossWire check found no false refusals, but e0ae57f had no reviewer. Read package.rs clash().
  • Import dialog, ⋮ menu, TextEntry: high (e2e import-url, owner's phone and desktop checks).
  • Content Hub picker and the apparmor group: medium (phone only; picker came up and imported).

🤖 Generated with Claude Code

Part of #10 (part 2c). ## What - Modules page: Import (in the bar's new ⋮ menu, beside Refresh) installs a module zip from an https address or, on Ubuntu Touch, a file picked through Content Hub. - Imports show as rows with progress, Retry and Cancel; they resume after a kill. A download announcing more than 1 GiB is refused. - Any install (import or repository) is refused when its `DataPath` is where another installed module's data is, so no package replaces another module's files. - Text fields get a press-and-hold edit menu with Paste (`qml/controls/TextEntry`); page bars get a ⋮ menu. New `content_exchange` AppArmor policy group. ## Blast radius **Tier:** trunk **Trunk paths touched:** `core/src/db/` (migration 004), `core/src/tasks/`, `sword/src/install*` (package, package/data_path, http), `qt/` glue, `utstudybible.apparmor`, `tools/e2e/` **Testing tier:** elevated (migration, downloads, writes to the module folder), also: standard UI **Gate / rollback:** Migrations are forward-only; `app.sqlite3` is backed up (`VACUUM INTO`) before migration 004. Rollback: reinstall the previous `.click` plus that backup. ## Proof - CI on head e0ae57f: all 6 success (rust: fmt, clippy, tests+coverage, release, deny, audit; app: lint, -Werror, clang-tidy, mem-check; e2e incl. `import-url`; click; presets). First e2e attempt failed one unrelated case (typed-reference menu tap), green on re-run: #98. - `cargo make` locally on e0ae57f: green, 87 sword + 234 core tests. - Fails without the change: download cap (each `check_size` removed → 1 test fails); `check_url` allowing http → 2 fail; `valid_name` accepting a leading dot → 1 fails; clash check disabled → the sword and core clash tests fail. - No false refusals: CrossWire main + Beta confs (434 modules) through the same overlap rule → 0 overlapping pairs. - Phone (owner, Plinius; comments 2065/2076): URL import, already-installed refusal, bad address with Retry/Cancel, Content Hub picker, offline greying: passed. Kill mid-import not tried on the phone (core test `an_import_killed_mid_download_goes_on_from_its_part`). The clash fix is Rust-only and not tried on the phone. - Desktop preview and shots of the ⋮ menu looked at by the owner. ## Design notes - sword: `Staged::any` + `commit` installs a zip of an unknown module (one conf, name from the conf, same checks); `commit` first refuses a `DataPath` overlapping another installed conf's (`DataPath::overlaps`, now in `package/data_path.rs`). - core: migration 004 (tasks gain kind 'import', `source`, nullable module), `Command::ImportUrl` / `ImportFile`, resumable; the file is copied to `<cache>/imports` on the tasks thread before it is queued. - QML: `PageBar.menu` (⋮, Lucide `import`/`refresh-cw` icons); shared controls live in `qml/controls/`. ## Review **Reviewer:** fresh-context `code-review` at high (282) + delta/re-reviews at medium (283–286): 10 findings, 7 fixed, 3 declined (283-3: covered by the http layer's cut tests; 286-3: mirrors `owns`, no CrossWire pair affected; 286-4: an unparseable conf is no module our reader opens), 0 deferred, 2 fixed after the delta review (286-1, 286-2 in e0ae57f), 0 left open. The first audit's missed defect (import over another module's data) is fixed in 681ac3e. **Review-Confidence:** 70/100, review of the clash fix stopped at 681ac3e (e0ae57f is unreviewed trunk) and 286-4's skip path has no test. ## Confidence by part - Download cap, URL check, migration 004, import tasks: high (unit tests with fails-without evidence, CI green, phone). - Clash check (`Staged::commit`, `DataPath::overlaps`): medium; tested, the CrossWire check found no false refusals, but e0ae57f had no reviewer. Read `package.rs` `clash()`. - Import dialog, ⋮ menu, TextEntry: high (e2e `import-url`, owner's phone and desktop checks). - Content Hub picker and the apparmor group: medium (phone only; picker came up and imported). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Docs: import in F1 and the status line (#10 part 2c)
Some checks failed
CI / rust (pull_request) Has been cancelled
CI / audit (pull_request) Has been cancelled
CI / app (pull_request) Has been cancelled
CI / e2e (pull_request) Has been cancelled
CI / click (pull_request) Has been cancelled
Presets / preset-check (pull_request) Has been cancelled
25057629fb
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude-review left a comment

Review level: high (trunk). Reviewed commit: 25057629fb. Range: origin/main...HEAD.

Files read in context: core/src/db.rs, db/004_task_import.sql, db/tasks.rs, paths.rs, tasks.rs, tasks/exec.rs, tasks/source.rs, tasks/progress.rs, sword/src/install/package.rs, qt/src/installer.rs, qt/src/module_manager.rs, qml/platform/FileImport.qml, qml/platform/ut/FileImport.qml, ModulesPage.qml (import parts), utstudybible.apparmor, tools/e2e/cases/import-url.sh. Docs, po and tests skimmed.

No high or medium defects found. Checked: migration 004 table rebuild keeps rows, CHECKs and both unique indexes; resume after a kill between set_task_module and commit (staging is rebuilt from the kept zip or copy); cancel before and after the Named event; retry of a named failed import by its source; orphan sweep of ~import-N files and the imports folder; http only allowed in a dev build with the test repo; Staged::any name and DataPath checks.

Finding outside the diff (low):

  • sword/src/install/http.rs download: there is no cap on the bytes fetched. Before this PR the URL was always a CrossWire-style repository; ImportUrl now downloads any https address the user types, so a hostile or mistaken URL (a large file, an endless stream) fills the phone's storage before unpack's LIMITS can apply. It is user-initiated and cancellable, and the part file is cleaned on fail/cancel, so I do not block on it. A ceiling on the total (the Content-Length, or bytes written) for imports would close it.
Review level: high (trunk). Reviewed commit: 25057629fb44f5fdfbacc49a3b7a514bdeb89eea. Range: origin/main...HEAD. Files read in context: core/src/db.rs, db/004_task_import.sql, db/tasks.rs, paths.rs, tasks.rs, tasks/exec.rs, tasks/source.rs, tasks/progress.rs, sword/src/install/package.rs, qt/src/installer.rs, qt/src/module_manager.rs, qml/platform/FileImport.qml, qml/platform/ut/FileImport.qml, ModulesPage.qml (import parts), utstudybible.apparmor, tools/e2e/cases/import-url.sh. Docs, po and tests skimmed. No high or medium defects found. Checked: migration 004 table rebuild keeps rows, CHECKs and both unique indexes; resume after a kill between set_task_module and commit (staging is rebuilt from the kept zip or copy); cancel before and after the Named event; retry of a named failed import by its source; orphan sweep of `~import-N` files and the imports folder; http only allowed in a dev build with the test repo; Staged::any name and DataPath checks. Finding outside the diff (low): - sword/src/install/http.rs download: there is no cap on the bytes fetched. Before this PR the URL was always a CrossWire-style repository; ImportUrl now downloads any https address the user types, so a hostile or mistaken URL (a large file, an endless stream) fills the phone's storage before unpack's LIMITS can apply. It is user-initiated and cancellable, and the part file is cleaned on fail/cancel, so I do not block on it. A ceiling on the total (the Content-Length, or bytes written) for imports would close it.
Refuse a download that announces more than 1 GiB (#10 part 2c review)
All checks were successful
CI / rust (pull_request) Successful in 4m56s
Presets / preset-check (pull_request) Successful in 2m29s
CI / audit (pull_request) Successful in 12s
CI / app (pull_request) Successful in 3m18s
CI / e2e (pull_request) Successful in 7m39s
CI / click (pull_request) Successful in 2m26s
3db8f6d183
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Author
Collaborator

Re the low finding (sword/src/install/http.rs, no byte cap on ImportUrl): fixed in 3db8f6d. A download that announces more than 1 GiB (Content-Range total or Content-Length) is now refused as a fatal protocol error before any body byte is stored. 1 GiB matches the unpack limit, so no valid module zip is affected. Test: an_answer_larger_than_the_cap_is_refused_before_any_byte_is_stored. cargo make is green. A server that sends a body beyond its announced length is already bounded by the per-chunk read of want bytes.

Re the low finding (sword/src/install/http.rs, no byte cap on ImportUrl): fixed in 3db8f6d. A download that announces more than 1 GiB (Content-Range total or Content-Length) is now refused as a fatal protocol error before any body byte is stored. 1 GiB matches the unpack limit, so no valid module zip is affected. Test: an_answer_larger_than_the_cap_is_refused_before_any_byte_is_stored. `cargo make` is green. A server that sends a body beyond its announced length is already bounded by the per-chunk read of `want` bytes.
Author
Collaborator

Phone try (docs/09). PR head 3db8f6d is installed on the Plinius over USB. The app starts: the journal shows feat/module-import@3db8f6d18 and no AppArmor denials. I put ASV.zip (CrossWire rawzip, 3.3 MB) in ~/Downloads for the file test.

Checklist (footer menu → Modules → Import):

  1. URL import: paste https://crosswire.org/ftpmirror/pub/sword/packages/rawzip/YLT.zip and tap Import. A row "Importing …" shows progress, then YLT is installed and listed in the footer's Bible switcher.
  2. Already installed: import https://crosswire.org/ftpmirror/pub/sword/packages/rawzip/KJV.zip. It is refused with "KJV is already installed; remove it first", and KJV still opens.
  3. Bad address: import https://crosswire.org/ftpmirror/pub/sword/packages/rawzip/NOPE.zip. The row fails with a plain error and offers Retry and Cancel; Cancel removes it.
  4. Content Hub: tap "Choose a file…". The peer picker opens; pick the File Manager, then Downloads/ASV.zip. ASV installs and opens.
  5. Offline: switch on airplane mode. The URL Import is greyed out, while "Choose a file…" still works (remove ASV and pick it again).
  6. Kill mid-import (optional): start a URL import and swipe the app away during the download. On reopening, it carries on.

🤖 Generated with Claude Code

**Phone try (docs/09).** PR head 3db8f6d is installed on the Plinius over USB. The app starts: the journal shows `feat/module-import@3db8f6d18` and no AppArmor denials. I put `ASV.zip` (CrossWire rawzip, 3.3 MB) in `~/Downloads` for the file test. Checklist (footer menu → Modules → **Import**): 1. **URL import:** paste `https://crosswire.org/ftpmirror/pub/sword/packages/rawzip/YLT.zip` and tap Import. A row "Importing …" shows progress, then YLT is installed and listed in the footer's Bible switcher. 2. **Already installed:** import `https://crosswire.org/ftpmirror/pub/sword/packages/rawzip/KJV.zip`. It is refused with "KJV is already installed; remove it first", and KJV still opens. 3. **Bad address:** import `https://crosswire.org/ftpmirror/pub/sword/packages/rawzip/NOPE.zip`. The row fails with a plain error and offers Retry and Cancel; Cancel removes it. 4. **Content Hub:** tap "Choose a file…". The peer picker opens; pick the File Manager, then `Downloads/ASV.zip`. ASV installs and opens. 5. **Offline:** switch on airplane mode. The URL Import is greyed out, while "Choose a file…" still works (remove ASV and pick it again). 6. **Kill mid-import (optional):** start a URL import and swipe the app away during the download. On reopening, it carries on. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
claude-review requested changes 2026-10-27 21:55:06 +00:00
Dismissed
claude-review left a comment

Review level: medium (delta + previously unreviewed test files).
Reviewed commit: 3db8f6d183
Range: 2505762..3db8f6d, plus origin/main...HEAD for the four trunk test files.

Files reviewed: sword/src/install/http.rs, sword/src/install/http/tests.rs, sword/src/install/package/tests.rs, core/src/db/tasks/tests.rs, core/src/tasks/tests.rs (plus core/src/tasks/exec.rs import_file/enqueue_import for ordering).

Findings

  1. MEDIUM - sword/src/install/http/tests.rs:346. The cap test calls check_size() directly. Nothing exercises it through download(), so deleting either call (http.rs:392 on the 206 path, http.rs:423 on the 200 path) leaves every test green. The name says "refused before any byte is stored", but no test checks that. Scenario: a later refactor of accept_range/accept_whole drops the check, a server announces a 5 GiB Content-Length, and the whole file is written to disk with CI green. Add two server tests, one for a 206 with Content-Range total > cap and one for a 200 with Content-Length > cap. The body should be small or absent. Assert Error::Protocol and no .part bytes.

  2. LOW - sword/src/install/http.rs:342-356 (ordering). For a 200 whose Content-Length is above the chunk size, a second GET with a whole_timeout(total) budget is issued before accept_whole runs check_size. A server that ignores Range and announces more than the cap is therefore asked twice, with a recv budget of total/SLOWEST seconds, before it is refused. Call check_size(total) in the if let (200, Some(total)) block before re-requesting.

  3. LOW - core/src/tasks/tests.rs:747-775 (an_import_killed_mid_download_goes_on_from_its_part). The "kill" is runner.pause() followed by drop, a graceful stop that syncs the part file. It does not cover a write cut off mid-chunk. The resume check (bytes={len}-) is real. The file name also hard-codes task id 1 (~import-1.zip.part).

  4. LOW - core/src/tasks/tests.rs:743. assert!(n >= 1) on the return of send() is vacuous. The second refusal (ftp://) only waits for any ImportRefused and does not check its reason.

Checked and fine: migration 004 test (data kept, .bak-v3, constraint rebuild); the already-installed refusal (bytes are compared against a different-size import, so it can fail); copy-before-queue (exec.rs:370-374 copies and syncs before enqueue_import, and the test checks that the copy survives deleting the original and a restart); the bad-zip cases (each fails on a distinct Staged::any rule); package limits and name tests.

Review level: medium (delta + previously unreviewed test files). Reviewed commit: 3db8f6d183c154abdf345ad92ce4884b0c1ee3ec Range: 2505762..3db8f6d, plus origin/main...HEAD for the four trunk test files. Files reviewed: sword/src/install/http.rs, sword/src/install/http/tests.rs, sword/src/install/package/tests.rs, core/src/db/tasks/tests.rs, core/src/tasks/tests.rs (plus core/src/tasks/exec.rs import_file/enqueue_import for ordering). Findings 1. MEDIUM - sword/src/install/http/tests.rs:346. The cap test calls check_size() directly. Nothing exercises it through download(), so deleting either call (http.rs:392 on the 206 path, http.rs:423 on the 200 path) leaves every test green. The name says "refused before any byte is stored", but no test checks that. Scenario: a later refactor of accept_range/accept_whole drops the check, a server announces a 5 GiB Content-Length, and the whole file is written to disk with CI green. Add two server tests, one for a 206 with Content-Range total > cap and one for a 200 with Content-Length > cap. The body should be small or absent. Assert Error::Protocol and no .part bytes. 2. LOW - sword/src/install/http.rs:342-356 (ordering). For a 200 whose Content-Length is above the chunk size, a second GET with a whole_timeout(total) budget is issued before accept_whole runs check_size. A server that ignores Range and announces more than the cap is therefore asked twice, with a recv budget of total/SLOWEST seconds, before it is refused. Call check_size(total) in the `if let (200, Some(total))` block before re-requesting. 3. LOW - core/src/tasks/tests.rs:747-775 (an_import_killed_mid_download_goes_on_from_its_part). The "kill" is runner.pause() followed by drop, a graceful stop that syncs the part file. It does not cover a write cut off mid-chunk. The resume check (bytes={len}-) is real. The file name also hard-codes task id 1 (~import-1.zip.part). 4. LOW - core/src/tasks/tests.rs:743. `assert!(n >= 1)` on the return of send() is vacuous. The second refusal (ftp://) only waits for any ImportRefused and does not check its reason. Checked and fine: migration 004 test (data kept, .bak-v3, constraint rebuild); the already-installed refusal (bytes are compared against a different-size import, so it can fail); copy-before-queue (exec.rs:370-374 copies and syncs before enqueue_import, and the test checks that the copy survives deleting the original and a restart); the bad-zip cases (each fails on a distinct Staged::any rule); package limits and name tests.
Download cap: an option, checked before asking again, tested through download()
Some checks failed
CI / rust (pull_request) Successful in 5m28s
CI / audit (pull_request) Has been cancelled
CI / app (pull_request) Has been cancelled
CI / e2e (pull_request) Has been cancelled
CI / click (pull_request) Has been cancelled
Presets / preset-check (pull_request) Has been cancelled
4e3ff2a996
The 1 GiB cap is now Options::max_download, so tests run download() against
a small cap on the 206 path, the 200 path and a 200 within one chunk; each of
the three checks has a test that fails without it. A 200 announcing too much
is refused before the whole file is requested again. The https-refusal test
now checks each reason. (Delta review on PR #90.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Collaborator

Replies to the delta review (review 283). Fixes are in 4e3ff2a.

  1. MEDIUM, the cap was only tested through check_size: fixed. The cap is now Options::max_download, defaulting to 1 GiB. Three tests run download() against a 4000- or 500-byte cap: a 206 Range answer, a 200 whole answer larger than a chunk, and a 200 within one chunk. Each asserts Error::Protocol, an empty or absent .part and no destination file. A fourth test downloads a file exactly at the cap. Fails without the fix: I removed each of the three check_size calls in turn, and each time one test failed (25 passed; 1 failed).
  2. LOW, a 200 over the cap was asked for twice: fixed. The size is now checked before the whole-file GET is repeated, and a_whole_answer_larger_than_the_cap_is_refused_without_asking_again asserts a single request.
  3. LOW, the import kill test is a graceful stop: left as is. A write cut mid-chunk is the http layer's job, and its existing tests already cover it (cut_next_after). This test is about the task layer resuming a URL import from its part. The part name is the task's key, which is what the test checks.
  4. LOW, assert!(n >= 1) and an unchecked ftp:// reason: fixed. The useless assert is gone, and both refusals now check their sentence. Doing that showed that ftp:// gets "That is not a web address.", which is the intended reason.

cargo make passes (233 core and 83 sword tests).

Replies to the delta review (review 283). Fixes are in 4e3ff2a. 1. **MEDIUM, the cap was only tested through `check_size`:** fixed. The cap is now `Options::max_download`, defaulting to 1 GiB. Three tests run `download()` against a 4000- or 500-byte cap: a 206 Range answer, a 200 whole answer larger than a chunk, and a 200 within one chunk. Each asserts `Error::Protocol`, an empty or absent `.part` and no destination file. A fourth test downloads a file exactly at the cap. Fails without the fix: I removed each of the three `check_size` calls in turn, and each time one test failed (`25 passed; 1 failed`). 2. **LOW, a 200 over the cap was asked for twice:** fixed. The size is now checked before the whole-file GET is repeated, and `a_whole_answer_larger_than_the_cap_is_refused_without_asking_again` asserts a single request. 3. **LOW, the import kill test is a graceful stop:** left as is. A write cut mid-chunk is the http layer's job, and its existing tests already cover it (`cut_next_after`). This test is about the task layer resuming a URL import from its part. The part name is the task's key, which is what the test checks. 4. **LOW, `assert!(n >= 1)` and an unchecked ftp:// reason:** fixed. The useless assert is gone, and both refusals now check their sentence. Doing that showed that ftp:// gets "That is not a web address.", which is the intended reason. `cargo make` passes (233 core and 83 sword tests).
claude-review left a comment

Level: medium re-review (answers review 283).
Reviewed commit: 4e3ff2a996
Range: 3db8f6d..4e3ff2a
Files reviewed: sword/src/install.rs, sword/src/install/http.rs (with chunk, accept_range, accept_whole, copy_body, run), sword/src/install/http/tests.rs, core/src/tasks/tests.rs; grep of other Options users (core/src/catalog, core/src/tasks.rs, qt/, tools/, spikes/).

Findings: none (no defects with a concrete failure scenario).

Checked:

  • Refusals precede any write on every path. 206: accept_range calls check_size before the state is saved or the body copied (http.rs:393). 200 with Content-Length above one chunk: checked at http.rs:335 before the plain re-request. 200 within one chunk: accept_whole checks at http.rs:424 before restart(), state save and copy_body. 200 without Content-Length is a fatal Protocol error, so it is not unchecked. A resumed state with a stored total is covered, because every response's own total is checked before it is compared with s.total.
  • Tests can fail: the 200-over-chunk test asserts srv.log().len() == 1, so removing the new pre-check fails it. The 206 test fails if accept_range stops checking. The within-chunk test exercises accept_whole. The at-cap test guards an off-by-one. The no-bytes-stored assertion runs in run_capped.
  • Other Options users: core/src/catalog and the http tests use Options::default() or struct update (1 GiB default). core/src/tasks/tests.rs sets max_download: 1 << 30. render::Options and spikes/s4-modules/fetch Options are separate types. Nothing in qt/ or tools/ builds this struct.
  • The tightened https-refusal test now asserts the exact reasons for both URLs.

Not run: cargo build/test (read-only review).

Level: medium re-review (answers review 283). Reviewed commit: 4e3ff2a996e85016da5a5a373bfc68673899ec49 Range: 3db8f6d..4e3ff2a Files reviewed: sword/src/install.rs, sword/src/install/http.rs (with chunk, accept_range, accept_whole, copy_body, run), sword/src/install/http/tests.rs, core/src/tasks/tests.rs; grep of other Options users (core/src/catalog, core/src/tasks.rs, qt/, tools/, spikes/). Findings: none (no defects with a concrete failure scenario). Checked: - Refusals precede any write on every path. 206: accept_range calls check_size before the state is saved or the body copied (http.rs:393). 200 with Content-Length above one chunk: checked at http.rs:335 before the plain re-request. 200 within one chunk: accept_whole checks at http.rs:424 before restart(), state save and copy_body. 200 without Content-Length is a fatal Protocol error, so it is not unchecked. A resumed state with a stored total is covered, because every response's own total is checked before it is compared with s.total. - Tests can fail: the 200-over-chunk test asserts srv.log().len() == 1, so removing the new pre-check fails it. The 206 test fails if accept_range stops checking. The within-chunk test exercises accept_whole. The at-cap test guards an off-by-one. The no-bytes-stored assertion runs in run_capped. - Other Options users: core/src/catalog and the http tests use Options::default() or struct update (1 GiB default). core/src/tasks/tests.rs sets max_download: 1 << 30. render::Options and spikes/s4-modules/fetch Options are separate types. Nothing in qt/ or tools/ builds this struct. - The tightened https-refusal test now asserts the exact reasons for both URLs. Not run: cargo build/test (read-only review).
Owner

Feedback:

Copy-pasting from the browser doesn't work: I get no option to paste when I long press, therefore I can't test 1-3.

5 works. Didn't test 6.

Feedback: Copy-pasting from the browser doesn't work: I get no option to paste when I long press, therefore I can't test 1-3. 5 works. Didn't test 6.
Author
Collaborator

Thanks. There was no paste because Controls 2 text fields have no edit menu, and the phone keyboard has no paste key. In c02b581, pressing and holding a text field opens a menu with Cut, Copy, Paste and Select all. Import's address field, the module search and the typed reference in Go to all have it, with German strings.

c02b581 is installed on the phone. Please open the app from the launcher: starting it over adb can't reach the display. Then retry steps 1–3:

  • long-press the address field → Paste (it is greyed out if the clipboard is empty);
  • YLT should install, KJV should be refused, and NOPE should fail with Retry and Cancel.

Could you also confirm step 4: did picking Downloads/ASV.zip through "Choose a file…" install ASV?

Thanks. There was no paste because Controls 2 text fields have no edit menu, and the phone keyboard has no paste key. In c02b581, pressing and holding a text field opens a menu with Cut, Copy, Paste and Select all. Import's address field, the module search and the typed reference in Go to all have it, with German strings. c02b581 is installed on the phone. Please open the app from the launcher: starting it over adb can't reach the display. Then retry steps 1–3: - long-press the address field → **Paste** (it is greyed out if the clipboard is empty); - YLT should install, KJV should be refused, and NOPE should fail with Retry and Cancel. Could you also confirm step 4: did picking `Downloads/ASV.zip` through "Choose a file…" install ASV?
Text fields get a press-and-hold edit menu with Paste (owner's phone check, PR #90)
Some checks failed
CI / rust (pull_request) Successful in 5m45s
Presets / preset-check (pull_request) Successful in 3m0s
CI / audit (pull_request) Successful in 13s
CI / app (pull_request) Has been cancelled
CI / e2e (pull_request) Has been cancelled
CI / click (pull_request) Has been cancelled
c02b581a25
Controls 2 has no edit menu and the phone keyboard no paste key, so a copied
address could not go into Import. TextEntry adds Cut, Copy, Paste and Select
all on press and hold; Import's address, the module search and the typed
reference use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shared controls go in qml/controls/; TextEntry is the first
Some checks failed
CI / rust (pull_request) Successful in 6m3s
Presets / preset-check (pull_request) Successful in 3m4s
CI / audit (pull_request) Successful in 12s
CI / app (pull_request) Successful in 3m22s
CI / e2e (pull_request) Has been cancelled
CI / click (pull_request) Has been cancelled
edcaee5e7e
Owner's call on PR #90: our own tweaks of Controls 2 (an edit menu now,
more later) are kept as shared components rather than per page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner

All three checks succeeded.

File picker also successfully came up in the 4th case.

Additional feedback: multiple text options in our limited space menus (in this case: Import Refresh) are ugly. Since we don't work with multiple bars, we likely need another context menu. Other UI/UX suggestions would be good here if anything comes to mind.

All three checks succeeded. File picker also successfully came up in the 4th case. Additional feedback: multiple text options in our limited space menus (in this case: Import Refresh) are ugly. Since we don't work with multiple bars, we likely need another context menu. Other UI/UX suggestions would be good here if anything comes to mind.
Page bars get a ⋮ menu; Modules' Import and Refresh move into it
Some checks failed
CI / rust (pull_request) Has been cancelled
CI / audit (pull_request) Has been cancelled
CI / app (pull_request) Has been cancelled
CI / e2e (pull_request) Has been cancelled
CI / click (pull_request) Has been cancelled
Presets / preset-check (pull_request) Has been cancelled
c1e080c063
Two text buttons crowded the Modules bar (owner's phone check, PR #90).
PageBar now takes MenuItems in `menu` and shows a ⋮ last, one touch
target wide like the reading pane's, so `e2e_menu <name>` opens either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Collaborator

Thanks, all three checks and the picker passing is great.

Import / Refresh → a ⋮ menu (c1e080c). PageBar now has a shared ⋮ page menu. A page passes menu: [MenuItem…] and the ⋮ shows last in the bar, one touch target wide, the same as on the reading pane. The Modules bar now reads ‹ Back · Modules · ⋮, and the menu has Import a module… and Refresh the lists (greyed while loading), each touch-height. Any page that gets a second action later uses the same menu, so this is the first piece of #91. The e2e cases open it with the same e2e_menu <name> as the reading pane's. import-url, module-manager and modules-offline pass, and I looked at the shots (the menu opens above the bar at the right edge).

Other UX ideas (not in this PR; say which you want and I'll file them):

  1. Pull to refresh on the module list. The lists already fetch when the page opens, so Refresh is rarely needed. Pull-down would make the menu entry only a fallback.
  2. Import where it's looked for: when a search finds nothing, show a line under it: "Not listed? Import a module from a link or file". That's the moment someone wants import.
  3. Long-press a module row for a context menu (Remove, Update, About this module) instead of the text button at the row's right. Rows get the full width for the description, and it fits the "menus, not bars" direction.
  4. Icons in the ⋮ menu (download/refresh), as the reading pane's menu has, so the two menus look alike.

Desktop preview: good idea, noted as a working rule. Before the next UI change reaches CI or the phone, I'll start clickable desktop here so you can look at it first. The phone install stays for what only shows there (Suru, AppArmor).

Note: locally, mem-check went over its 165 MB start-up budget, but an interleaved A/B showed the base QML over by the same amount (166–173 MB vs 167–169 MB), so the host was busy. The change doesn't load at start-up (pages load when pushed), and CI's app job passed mem-check on edcaee5.

Thanks, all three checks and the picker passing is great. **Import / Refresh → a ⋮ menu (c1e080c).** `PageBar` now has a shared ⋮ page menu. A page passes `menu: [MenuItem…]` and the ⋮ shows last in the bar, one touch target wide, the same as on the reading pane. The Modules bar now reads `‹ Back · Modules · ⋮`, and the menu has **Import a module…** and **Refresh the lists** (greyed while loading), each touch-height. Any page that gets a second action later uses the same menu, so this is the first piece of #91. The e2e cases open it with the same `e2e_menu <name>` as the reading pane's. import-url, module-manager and modules-offline pass, and I looked at the shots (the menu opens above the bar at the right edge). **Other UX ideas** (not in this PR; say which you want and I'll file them): 1. **Pull to refresh** on the module list. The lists already fetch when the page opens, so Refresh is rarely needed. Pull-down would make the menu entry only a fallback. 2. **Import where it's looked for**: when a search finds nothing, show a line under it: "Not listed? Import a module from a link or file". That's the moment someone wants import. 3. **Long-press a module row** for a context menu (Remove, Update, About this module) instead of the text button at the row's right. Rows get the full width for the description, and it fits the "menus, not bars" direction. 4. **Icons in the ⋮ menu** (download/refresh), as the reading pane's menu has, so the two menus look alike. **Desktop preview:** good idea, noted as a working rule. Before the next UI change reaches CI or the phone, I'll start `clickable desktop` here so you can look at it first. The phone install stays for what only shows there (Suru, AppArmor). Note: locally, mem-check went over its 165 MB start-up budget, but an interleaved A/B showed the base QML over by the same amount (166–173 MB vs 167–169 MB), so the host was busy. The change doesn't load at start-up (pages load when pushed), and CI's app job passed mem-check on edcaee5.
Modules' ⋮ menu: icons and short labels, as the reading view's
All checks were successful
Presets / preset-check (pull_request) Successful in 2m43s
CI / rust (pull_request) Successful in 5m26s
CI / audit (pull_request) Successful in 11s
CI / app (pull_request) Successful in 3m7s
CI / e2e (pull_request) Successful in 7m47s
CI / click (pull_request) Successful in 2m4s
97f46c23a5
Owner's desktop look (PR #90): Lucide import and refresh-cw (0.468.0,
ISC, NOTICES.md), rendered by tools/icons/render.sh; "Import" and
"Refresh" instead of the longer labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude-review left a comment

Delta review (medium), commit 97f46c23a5, range 4e3ff2a..97f46c2

No high, medium or low findings. Checked: PageBar menu alias/Menu.count, the pageMenu e2e log lines against e2e_menu/e2e_tap_menu, removal of the "modules importbtn/refresh" report lines (no remaining consumers), qrc entries for the icons and controls/qmldir + TextEntry.qml, TextEntry (pressAndHold, canPaste), po/pot msgids against qsTr use, docs and NOTICES claims.

Files reviewed:
NOTICES.md
assets/icons/import.svg
assets/icons/refresh-cw.svg
docs/04-features.md
docs/06-sources.md
po/de.po
po/utstudybible.schaefera.pot
qml/controls/TextEntry.qml
qml/controls/qmldir
qml/icons/import.png
qml/icons/refresh-cw.png
qml/pages/ModulesPage.qml
qml/pages/PageBar.qml
qml/pages/PickerPage.qml
qml/qml.qrc
tools/e2e/cases/import-url.sh
tools/e2e/lib.sh (context)
qml/pages/ReadingPane.qml (context)

Delta review (medium), commit 97f46c23a5cbbefc92f36f8f65e4415f5c67bfd2, range 4e3ff2a..97f46c2 No high, medium or low findings. Checked: PageBar menu alias/Menu.count, the pageMenu e2e log lines against e2e_menu/e2e_tap_menu, removal of the "modules importbtn/refresh" report lines (no remaining consumers), qrc entries for the icons and controls/qmldir + TextEntry.qml, TextEntry (pressAndHold, canPaste), po/pot msgids against qsTr use, docs and NOTICES claims. Files reviewed: NOTICES.md assets/icons/import.svg assets/icons/refresh-cw.svg docs/04-features.md docs/06-sources.md po/de.po po/utstudybible.schaefera.pot qml/controls/TextEntry.qml qml/controls/qmldir qml/icons/import.png qml/icons/refresh-cw.png qml/pages/ModulesPage.qml qml/pages/PageBar.qml qml/pages/PickerPage.qml qml/qml.qrc tools/e2e/cases/import-url.sh tools/e2e/lib.sh (context) qml/pages/ReadingPane.qml (context)
A package whose DataPath is another installed module's is refused
Some checks failed
CI / rust (pull_request) Has been cancelled
CI / audit (pull_request) Has been cancelled
CI / app (pull_request) Has been cancelled
CI / e2e (pull_request) Has been cancelled
CI / click (pull_request) Has been cancelled
Presets / preset-check (pull_request) Has been cancelled
681ac3ebf1
The review audit of PR #90 found that an imported zip could name an
installed module's folder as its DataPath ([Evil] with
./modules/texts/ztext/kjv/kjv): the checks passed, the commit renamed
over KJV's files, and removing Evil would take KJV's folder. Staged::commit
now reads the installed confs first and refuses, with nothing moved, when
another module's DataPath overlaps this one's; the module's own conf (an
update, a rerun after a kill) is passed over. Installs from a repository
get the same check.

DataPath moves to package/data_path.rs (package.rs was past 300 lines)
and gains overlaps(). Fails without: with the check disabled, both new
clash tests (sword and core) fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude-review left a comment

Delta review, level medium
Commit: 681ac3ebf1
Range: 97f46c2..681ac3e

No high or medium findings. The clash check runs in commit, so it covers downloads and imports. A kill mid-commit and a reinstall are handled: the module's own conf is skipped by case-insensitive name, and the conf is written last. Three low findings, two inline and one below.

Files reviewed: core/src/tasks/tests.rs, docs/04-features.md, sword/src/install/package.rs, sword/src/install/package/data_path.rs, sword/src/install/package/tests.rs, core/src/tasks/exec.rs (callers), CLAUDE.md

Low, outside the diff:

  • low · sword/src/install/package.rs:109-121 · A conf that doesn't parse, or whose DataPath is refused, is skipped. Its data may still be on disk, so a new package that targets that folder gets through the check. Unlikely, since our reader doesn't open such a conf either.
  • Test gap: no test covers an installed module whose conf is skipped (a broken conf), or a conf whose file name differs in case from its [Name].
Delta review, level medium Commit: 681ac3ebf117aa906959dbc83b7023e1e999c05d Range: 97f46c2..681ac3e No high or medium findings. The clash check runs in commit, so it covers downloads and imports. A kill mid-commit and a reinstall are handled: the module's own conf is skipped by case-insensitive name, and the conf is written last. Three low findings, two inline and one below. Files reviewed: core/src/tasks/tests.rs, docs/04-features.md, sword/src/install/package.rs, sword/src/install/package/data_path.rs, sword/src/install/package/tests.rs, core/src/tasks/exec.rs (callers), CLAUDE.md Low, outside the diff: - low · sword/src/install/package.rs:109-121 · A conf that doesn't parse, or whose DataPath is refused, is skipped. Its data may still be on disk, so a new package that targets that folder gets through the check. Unlikely, since our reader doesn't open such a conf either. - Test gap: no test covers an installed module whose conf is skipped (a broken conf), or a conf whose file name differs in case from its [Name].
@ -55,0 +109,4 @@
fn clash(&self, sword: &Path) -> Result<Option<String>> {
let Ok(entries) = fs::read_dir(sword.join("mods.d")) else { return Ok(None) };
for entry in entries {
let path = entry?.path();
Collaborator

low · entry? aborts the whole install with an I/O error if one directory entry is unreadable, while an unreadable or unparseable conf is skipped (.ok()). The error handling is inconsistent. Treat an unreadable entry as a skip, or an unreadable conf as an error.

low · `entry?` aborts the whole install with an I/O error if one directory entry is unreadable, while an unreadable or unparseable conf is skipped (`.ok()`). The error handling is inconsistent. Treat an unreadable entry as a skip, or an unreadable conf as an error.
@ -55,0 +110,4 @@
let Ok(entries) = fs::read_dir(sword.join("mods.d")) else { return Ok(None) };
for entry in entries {
let path = entry?.path();
if path.extension().is_none_or(|e| e != "conf") {
Collaborator

low · The conf extension test is case-sensitive, so an installed FOO.CONF is never checked for a clash. installed() and conf_path use lower-case names, so this only affects confs placed by other tools.

low · The `conf` extension test is case-sensitive, so an installed `FOO.CONF` is never checked for a clash. `installed()` and `conf_path` use lower-case names, so this only affects confs placed by other tools.
@ -0,0 +96,4 @@
(None, Some(tree)) => self.folder().starts_with(tree),
(None, None) => match (self, other) {
(DataPath::Prefix { dir: a, stem: s }, DataPath::Prefix { dir: b, stem: t }) => {
a == b && (s.starts_with(t.as_str()) || t.starts_with(s.as_str()))
Collaborator

low · In a shared folder, stems strongs and strongsgreek count as overlapping and the later install is refused. remove_data only deletes stem and stem.*, so removal would not clash. This is consistent with owns, which matches by stem prefix. It could wrongly refuse a legitimate pair of modules whose stems share a prefix in one shared folder. Worth a comment, or a check against CrossWire's real confs.

low · In a shared folder, stems `strongs` and `strongsgreek` count as overlapping and the later install is refused. `remove_data` only deletes `stem` and `stem.*`, so removal would not clash. This is consistent with `owns`, which matches by stem prefix. It could wrongly refuse a legitimate pair of modules whose stems share a prefix in one shared folder. Worth a comment, or a check against CrossWire's real confs.
Clash check: unreadable entries are passed over, .CONF in any case (review 286)
All checks were successful
Presets / preset-check (pull_request) Successful in 1m45s
CI / rust (pull_request) Successful in 3m28s
CI / audit (pull_request) Successful in 9s
CI / app (pull_request) Successful in 2m23s
CI / e2e (pull_request) Successful in 7m27s
CI / click (pull_request) Successful in 1m21s
e0ae57ff76
Low 1: one unreadable mods.d entry no longer aborts the install while an
unreadable conf is skipped; clash() passes over all of them alike and is
infallible. Low 2: a conf another tool named FOO.CONF is still checked
(test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Collaborator

Audit (score 60) missed defect — an imported zip whose DataPath names an installed module's folder was renamed over it: fixed in 681ac3e. Staged::commit refuses, nothing moved, when another installed conf's DataPath overlaps (installs from a repo get it too). Tests: sword a_package_whose_data_is_where_an_installed_modules_is_is_refused (Dir, the audit's prefix case, sub-folder), core an_import_whose_data_is_where_an_installed_modules_is_is_refused; both fail with the check disabled. Checked for false refusals: CrossWire main + Beta, 434 confs, 0 overlapping pairs under the same rule.

Review 286 (delta, 681ac3e):

  1. low, mixed error handling — fixed in e0ae57f: every unreadable entry/conf is passed over alike.
  2. low, .CONF case — fixed in e0ae57f, test a_conf_another_tool_named_in_capitals_is_still_seen.
  3. low, stem-prefix overlaps in a shared folder — left: it mirrors owns, which lets a package with stem strongs ship strongsgreek.dat, so treating them as overlapping is what keeps that from replacing files; the CrossWire check above shows no real pair is refused.
  4. low, unparseable confs skipped — left by design: our reader doesn't open such a conf, so there is no module of it to protect.
**Audit (score 60) missed defect** — an imported zip whose `DataPath` names an installed module's folder was renamed over it: fixed in 681ac3e. `Staged::commit` refuses, nothing moved, when another installed conf's `DataPath` overlaps (installs from a repo get it too). Tests: sword `a_package_whose_data_is_where_an_installed_modules_is_is_refused` (Dir, the audit's prefix case, sub-folder), core `an_import_whose_data_is_where_an_installed_modules_is_is_refused`; both fail with the check disabled. Checked for false refusals: CrossWire main + Beta, 434 confs, 0 overlapping pairs under the same rule. **Review 286 (delta, 681ac3e)**: 1. low, mixed error handling — fixed in e0ae57f: every unreadable entry/conf is passed over alike. 2. low, `.CONF` case — fixed in e0ae57f, test `a_conf_another_tool_named_in_capitals_is_still_seen`. 3. low, stem-prefix overlaps in a shared folder — left: it mirrors `owns`, which lets a package with stem `strongs` ship `strongsgreek.dat`, so treating them as overlapping is what keeps that from replacing files; the CrossWire check above shows no real pair is refused. 4. low, unparseable confs skipped — left by design: our reader doesn't open such a conf, so there is no module of it to protect.
claude-bot changed title from WIP: Module import from a URL and through Content Hub (#10 part 2c) to Module import from a URL and through Content Hub (#10 part 2c) 2026-10-28 10:50:38 +00:00
claude-review left a comment

Merge gate at e0ae57f (trunk, elevated + standard UI). Approved.

  1. High/medium findings: 282 (download cap) fixed in 3db8f6d, with check_size at every size point in sword/src/install/http.rs; 283 medium (cap tested only via check_size) fixed in 4e3ff2a; 283-3 refuted soundly (the http layer's cut tests cover mid-chunk cuts); the first audit's missed defect (DataPath over another module's folder) fixed in 681ac3e. 284 and 285 had no findings. The 286 lows are fixed (1, 2) or declined with reasons (3, 4). Nothing high or medium is open.
  2. e0ae57f read in full. clash() is now infallible. read_dir failure, unreadable entries, unreadable or unparseable confs and refused DataPaths are all passed over. The extension match is case-insensitive. The module's own name is excluded, case-insensitively, so updates and reruns after a kill are not clashes. I found no concrete failure scenario. The commit adds a test for OTHER.CONF that asserts the refusal and that nothing was installed. The skip path of 286-4 is still untested, but it is a conservative skip and the 70 confidence score already counts that.
  3. CI on head: all 6 statuses success (preset-check, rust, audit, app, e2e, click).
  4. Proof: no unrun elevated, maximum or AI-behavior gate. The elevated target was the owner's phone and the e2e import-url case. Fails-without evidence is given for the cap, URL check, name check and clash. The 434-conf CrossWire check found no false refusals. The owner ran the phone check. Kill-mid-import was not tried on the phone but is covered by the core test an_import_killed_mid_download_goes_on_from_its_part, and the PR body says so. That is acceptable under testing-tiers.md.
Merge gate at e0ae57f (trunk, elevated + standard UI). Approved. 1. High/medium findings: 282 (download cap) fixed in 3db8f6d, with check_size at every size point in sword/src/install/http.rs; 283 medium (cap tested only via check_size) fixed in 4e3ff2a; 283-3 refuted soundly (the http layer's cut tests cover mid-chunk cuts); the first audit's missed defect (DataPath over another module's folder) fixed in 681ac3e. 284 and 285 had no findings. The 286 lows are fixed (1, 2) or declined with reasons (3, 4). Nothing high or medium is open. 2. e0ae57f read in full. clash() is now infallible. read_dir failure, unreadable entries, unreadable or unparseable confs and refused DataPaths are all passed over. The extension match is case-insensitive. The module's own name is excluded, case-insensitively, so updates and reruns after a kill are not clashes. I found no concrete failure scenario. The commit adds a test for OTHER.CONF that asserts the refusal and that nothing was installed. The skip path of 286-4 is still untested, but it is a conservative skip and the 70 confidence score already counts that. 3. CI on head: all 6 statuses success (preset-check, rust, audit, app, e2e, click). 4. Proof: no unrun elevated, maximum or AI-behavior gate. The elevated target was the owner's phone and the e2e import-url case. Fails-without evidence is given for the cap, URL check, name check and clash. The 434-conf CrossWire check found no false refusals. The owner ran the phone check. Kill-mid-import was not tried on the phone but is covered by the core test an_import_killed_mid_download_goes_on_from_its_part, and the PR body says so. That is acceptable under testing-tiers.md.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
schaefera/utStudyBIble!90
No description provided.