From cecff55db6d4458f2b907f06f54c659a95705c17 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Fri, 30 Oct 2026 20:07:14 -0600 Subject: [PATCH 1/5] test(just): mutation recipes for host, package, vault and cli (P12 step 1) Each recipe mutates one crate and runs its own tests plus ledger-cli's end-to-end suites without the viewer, as mutants-mcp does, so a mutant in a crate whose behavior is reached only through the one executable is still caught. Local runs only (D-59): no shard goes into scheduled.yml. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- justfile | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/justfile b/justfile index f61ddc3..9d4b375 100644 --- a/justfile +++ b/justfile @@ -209,6 +209,38 @@ mutants-mcp shard="0/1": sweep-test-tmp MCP_CASES=16 PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-mcp --test-package ledger-mcp --test-package ledger-cli --no-default-features -j 4 --timeout-multiplier 3 --minimum-test-timeout 600 --shard {{shard}} -o mutants-mcp @just sweep-test-tmp +# Mutation testing of the host (the books home, the lease, the update channel, the bridge): every mutant runs its own tests and the +# suites that drive a real books server (ledger-cli's, D-44), built without +# the viewer, as for the MCP server. A local run, never CI (D-59). +mutants-host shard="0/1": sweep-test-tmp + mkdir -p {{mutants_tmp}} + PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-host --test-package ledger-host --test-package ledger-cli --no-default-features -j 4 --timeout-multiplier 3 --minimum-test-timeout 300 --shard {{shard}} -o mutants-host + @just sweep-test-tmp + +# Mutation testing of the CPA package and the 1099 worksheet: every mutant runs its own tests and the +# suites that drive a real books server (ledger-cli's, D-44), built without +# the viewer, as for the MCP server. A local run, never CI (D-59). +mutants-package shard="0/1": sweep-test-tmp + mkdir -p {{mutants_tmp}} + PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-package --test-package ledger-package --test-package ledger-cli --no-default-features -j 4 --timeout-multiplier 3 --minimum-test-timeout 300 --shard {{shard}} -o mutants-package + @just sweep-test-tmp + +# Mutation testing of the TIN vault: every mutant runs its own tests and the +# suites that drive a real books server (ledger-cli's, D-44), built without +# the viewer, as for the MCP server. A local run, never CI (D-59). +mutants-vault shard="0/1": sweep-test-tmp + mkdir -p {{mutants_tmp}} + PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-vault --test-package ledger-vault --test-package ledger-cli --no-default-features -j 4 --timeout-multiplier 3 --minimum-test-timeout 120 --shard {{shard}} -o mutants-vault + @just sweep-test-tmp + +# Mutation testing of the one executable (its argument handling, the server and bridge modes): every mutant runs its own tests and the +# suites that drive a real books server (ledger-cli's, D-44), built without +# the viewer, as for the MCP server. A local run, never CI (D-59). +mutants-cli shard="0/1": sweep-test-tmp + mkdir -p {{mutants_tmp}} + PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-cli --test-package ledger-cli --no-default-features -j 4 --timeout-multiplier 3 --minimum-test-timeout 600 --shard {{shard}} -o mutants-cli + @just sweep-test-tmp + # No mutation runs on the viewer (owner, 2026-10-18: they crushed the # machine, and for the GUI it's "not as critical"). Its two musts are # tested directly instead: it never writes the books (tests/read_only.rs) From 4b0196e416f4714731b491916445c55a934a4796 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Fri, 30 Oct 2026 20:07:14 -0600 Subject: [PATCH 2/5] docs(testing): P12 checklists, one per crate (P12 step 1) The For P12 paragraphs, each left where its unit wrote it with a pointer, consolidated into one mutation checklist per crate, each item keeping its origin unit in brackets, so the run walks a crate at a time. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- docs/testing.md | 542 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 542 insertions(+) diff --git a/docs/testing.md b/docs/testing.md index 403f142..2ae5941 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -622,6 +622,7 @@ scratch (`ledger-store` `backup.rs` and `root.rs`, `ledger-core` `settings.rs`, `ledger-mcp` `backup.rs`, `tools/backup.rs`, `local.rs`, `admin.rs`, `ledger-host` `commands.rs`, `ledger-cli`). The survivor lists are kept with the run's output, outside the repository. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P3: odd cents (2026-10-20) @@ -729,6 +730,7 @@ parse; a tag that isn't text; the feature declaration); fields; `Currency::allocation_places`. P3's accept counts `ledger-core` and `ledger-import` (R-6); the store and MCP mutants above are for the whole pass. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P3: the IIF and as-of (2026-10-20) @@ -934,6 +936,7 @@ condition and `receivable_payable`; `fonts.rs` and `theme.rs` `font_definitions`; the README's A/R and A/P sentence; `ledger-core` `parties.rs` `tin_separator`'s arms and `tin_invisible`, and the gap that doesn't count them. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P3: cash flow (defect 1, 2026-10-20) @@ -1080,6 +1083,7 @@ the end. `export_cash_flow` and `settings_problems`; zero unexplained survivors (plan P3 accept). Likely equivalent, to adjudicate: `min` against `max` in `take` where both bounds coincide, and the kind order's tie-breaks. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P3: rename, defaults and tax categories (2026-10-20) @@ -1267,6 +1271,7 @@ range, `export_journal`'s period, `described`, `uncategorized_warning`, `created_uncategorized`, the digest's `uncategorized_accounts`, `handbook::period_rule`. The viewer is not mutated (owner, 2026-10-18): `view_model.rs` and `show.rs` cover it. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P3: parties (defects 14, 24, 11; 2026-10-20) @@ -1316,6 +1321,7 @@ cash, in_kind) and `update_party`'s creditor_of diff; the viewer's `party_what_for` and `share_text`. Expected equivalent: `Party::tin_last4`'s `or_else` order swapped (TIN facts and W-9 facts never disagree, parties INVARIANT 2a, enforced on replay). +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P3: 1099s (defects 23, 12 and 18; 2026-10-20) @@ -1375,6 +1381,7 @@ unchanged path, `set_vendor_w9_flag`, `add_instrument`, first, only when the flag changes); `ledger-package` `vendor_1099.rs` (the CSV columns, every note's guard, `may_file`, `missing_w9`'s suffix) and `package.rs` (tin_gaps, the README line). The viewer has no mutation job (its musts are tested). +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P3: parties and 1099s landed together (2026-10-21) @@ -1398,6 +1405,7 @@ fallback); the server's `vendor_1099` (`through`'s year check, the unknown-code check, the accounts passed) and `update_party`'s feature declaration; the bookspec loader's party, payee, decision and payment method setup. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P3: depreciation and schedules (defects 22 and 9, 2026-10-20) @@ -1584,6 +1592,7 @@ version's journal format (rules.md B-27). Mutation testing waits for P12 (`consent_now`, `upgrade_format`, `offer_upgrades`, `offer_upgrade`, `upgrade`, `book_line`, `upgraded_lines`, `upgrade_explained`, `ASKED_UPGRADES`). None run. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P3: asset basis and tax-basis capital (2026-10-20) @@ -1715,6 +1724,7 @@ reversal's take-back) and `entry_property` (the mismatch branch, the unknown sum), the open-item conditions (`in_service`, the disposal window), and the reconciliation's signs. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P3: the seams between the pieces (2026-10-21) @@ -1818,6 +1828,7 @@ before); the bookspec loader's exact-spelling filters and `check_k1`'s `cash_accounts` check; `documented_tools` (its own sample checks it). The earlier P3 sections' lists stand (one name corrected: `Ledger::depreciation_runs`, which was `depreciation_runs_ever`). +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P3: projects (2026-10-21) @@ -1957,6 +1968,7 @@ balance-sheet zero; `project_set_of` (newer vs corrupt); `set_project`'s unchanged short-cut; `subject_feature`; `check_link_to`'s feature check; the decision `projects` omit-when-empty; `late_tag_note`'s `is_over`; `remember_footer`'s threshold. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P3: the project snapshot (2026-10-22) @@ -2068,6 +2080,7 @@ fail. **For P12 (mutation targets):** `contribution_margin_pct`'s `<=` and rounding strategy; `project_figures`' opening net and running sum; `profit_and_loss_counted`'s counts; `Standing::of`'s order. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## Invoices and bills take a project (2026-10-22) @@ -2178,6 +2191,7 @@ order, the `contains` test); `add_document_parts`' tag index; `document_notes`' dedup and its `invoice` flag; `on_spec_invoice_note`'s kind test; `document_projects_rule`'s branch; the viewer's `document_of` (invoice before bill). +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## After P3: the cutover review's open points 1 and 2 (2026-10-22) @@ -2650,6 +2664,7 @@ amount match and one-line rule; `refresh_doc`'s settled sum; `doc_status`'s Overpaid and Unattached arms; the aging's `<= 0`; `ensure_payment_features`' comparison; `check_document_reversal`'s date check; `control_check`'s chain netting. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P4b S1, part B: void, control check, and each payment's details (2026-10-24) @@ -2702,6 +2717,7 @@ line and nothing needing attention, each field's mapping, the invoice/bill kind); `control_problems` (one message per unattached line and per document, both loops); the `digest` that includes it and the `verify` check that refuses a nonzero difference. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P4b S2: reading documents, in the tools and in the viewer (2026-10-24) @@ -2758,6 +2774,7 @@ Overdue's `overdue_days > 0`, Paid, Void, All), a document after the day shown only under All (`Ok(None)`), the list's `(due, date, name)` sort and the timeline's movement×reversal labels with their `(reversed later)` condition (`!t.reversal && !stands_on(t.entry, b.today)`). +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P4b S3: the invoice PDF (2026-10-24) @@ -2811,6 +2828,7 @@ one does: `PartiallyPaid` (no sample is part paid and not yet due) and `Draft` (only S4's preview reaches it; the renderer's own test renders it but asserts only that it succeeds). ## P7: the TIN scanner's separators (2026-10-26) +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* rules.md B-26; plan 8.2 P7, with vault v2 closed by D-48 (nothing of HPKE, keyslots or human pages is built here). The scanner @@ -2851,6 +2869,7 @@ Deleting any one of them leaves a TIN that the old scanner passed either readable or unmasked, which the truth table and the property catch. ## P13: bookspec, the checker crate and the runner (2026-10-26) +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* bookspec's example schema moved out of the loader into `bookspec/checker`, its own Cargo workspace inside the repository (P13 @@ -2918,6 +2937,7 @@ example" arm; the adapter's `event_of`, its `depreciation_schedule` month span (acquisition through as-of, 0.00 where the schedule reaches no month) and every `answer_*` figure against the loader's same check (the two must agree on all 56 examples). +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P13 steps 4 and 5: the specification text, the laws, the derivation, GnuCash (2026-10-29) @@ -2983,6 +3003,7 @@ condition), `Model::post`'s close (a debit-normal balance credits, the equity line's sign), `signed`'s date window and `closings` flag, and the accounting-equation check; the spec test's pin search (a mutant that skips the quote check passes only if `every_citation_resolves` has no bad pin). +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## U0b: one attention engine over `PackageBooks` (2026-10-26) @@ -3038,6 +3059,7 @@ negative-zero test; `filter(|a| a.tool.is_some()).take(12)` and intact-blob set (a damaged blob reported per attachment) and `Store:: disk_check`'s snapshot load and `Unreadable` arm; the viewer wrapper's `Target` arms. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P9a: self-host, the move, and the static bearer over a tunnel (2026-10-26) @@ -3118,6 +3140,7 @@ and `BooksServer::new`'s copy (a dropped field must fail `isolation_label_is_enforced_under_the_unit`); the host's `server_flags` extension and `parse_args`' passthrough of `--isolation` and its optional `--http` value. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P4b S4.5: the issued PDF, the viewer and the handbook (2026-10-29) @@ -3182,6 +3205,7 @@ arms and `InvoiceVersion::Current { as_of }`; the viewer's `details` number-else-id and `issued_copy`'s PDF-and-description match; the handbook's `documents_v2_rule` both arms and the server's `documents_v2` feature test. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P4b S5: sent tracking (2026-10-27) @@ -3246,6 +3270,7 @@ predicates in `ledger-package::attention`: `invoice.number.is_some()` `checked_add_days` `unsent_days` comparison (`>`), `overdue_days > 0`, the `sends.max()` last send, and the `reminder_days` comparison (`>`), plus each item's target (`Target::Invoice`) and `books.mark_sent` step args. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P4b S6: paying on terms (2026-10-27) @@ -3323,6 +3348,7 @@ arithmetic; `bills_due`'s `through` filter, the discount-window test and the running-total grouping; `aging_documents`'s outstanding filter and bucket; `aging_csv`'s tie-out columns and `aging_statement`'s totals; the viewer's `discount_by` and `days_overdue` flags. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P4b S7: bad debts (write-offs), 2026-10-27 @@ -3400,6 +3426,7 @@ the blob-before-journal order, the `File`/`Existing` link steps) and the feature gate; the MCP handler's amount default, the exactly-one evidence check, and the preview's clone; attention's year comparison and its `stands_on` filter; the bookspec loader's `may_refuse` arm. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P6: the evidence pipeline (2026-10-27) @@ -3532,6 +3559,7 @@ arms, the 8 KiB cut, the 64 KiB hand-over threshold and `page - 1`. `ledger-package::attention`: the extraction `seq` comparison and the `Failed` filter, the owed-account name test, the credit side and the `settles_entry` skip. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P11a step 0: keyring stores for macOS and Windows (2026-10-29) @@ -3556,6 +3584,7 @@ Secret Service run needs a desktop): the CI run is the evidence. **For P12 (mutation targets, none run, D-33):** `tokens.rs`'s `os` modules are not reachable by mutation testing (they need the real OS store); the `Items` logic they share is, through the file store. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## U0e: tokens in a file, the OS keyring removed (2026-10-29, D-63) @@ -3594,6 +3623,7 @@ token" must die on `an_unreadable_token_file_is_an_error_never_no_token`), `Keeper::delete`'s `NotFound` arm, and `Account::key`'s three parts (`an_account_names_the_root_by_hash_and_the_book_and_token`); `bridge.rs::token_file_refusal`'s `holds_token` guard. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P4: draft states, the evidence gate, the preview digest (2026-10-29) @@ -3669,6 +3699,7 @@ it); the digest's `since_seq` comparison (`>`), its `as_of` bound, the `zero_net_accounts` branch and the `never_reconciled` split; `report_status`'s three open conditions; the open-drafts refusal's `<= as_of` and its decision; the attention `awaiting_evidence` branch. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P11c: the native Windows port (2026-10-29, D-62) @@ -3727,6 +3758,7 @@ arm; `Listener`'s `IDLE_INSTANCES` pool (a mutant that makes it 1 passes every test but `many_clients_at_once_all_connect`); `start.rs::host_env`'s list. None of the Windows arms is reachable by `cargo mutants` on Linux: they run on the Windows job only. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P11a steps 2 and 3: update, rollback, the Linux packages (2026-10-29) @@ -3822,6 +3854,7 @@ covers the feature one; a case with a higher format number is a gap to add), `swap_in`'s temporary-then-rename (`update_is_atomic`), `healthy` (`failed_update_rolls_back`), `install_kind` (the marker test), and `parse_version`'s rejections. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P5: the agent surface (2026-10-29) @@ -3909,6 +3942,7 @@ equal-source refusals and the empty-reason refusals; the three in `a_source_only_revision_is_journaled`); `books.start`'s fit order and `reconciliation` split (`reconciled_through.is_some()`). ## P8: audit and attestations (2026-10-29) +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* An audit run is a deterministic programme of checks over the books through a day, filed as a JSON and a PDF workpaper; attestations are claims by a @@ -3999,6 +4033,7 @@ export gate (`cpa_audit_standing`'s four conditions and its feature test, the `refuse`/`warn` split, the reason's trim); `audit_items` in Needs attention (the declared test, the attested set, the last-ended fiscal year, the three year conditions). +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P10: the re-audit runbook's mirror @@ -4029,6 +4064,7 @@ sign-off) is the owner's and has no CI equivalent. lines above it) and the scopes-line scan in `defect_20_*`. A mutant that widens the window or drops the `ignore` check survives only if no ignored or untested name is in the table; the table is the oracle. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* ## P13 step 6: the specification in its own repository (2026-10-29) @@ -4125,3 +4161,509 @@ brightening; `Session::resize`'s equality early return; the panel's open/closed ids (a remembered size must not leak between them); `Welcome::steps`' choice of `now` (each arm), the passphrase length and equality gates, the `agent_later` flag. +*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)* + +## P12: the checklists + +The **For P12** paragraphs above (kept where they were written, with their +units' histories) consolidated into one checklist per crate, so the run +(step 2 of P12, D-33) walks a crate at a time. Each item names a target and +the mutants worth thinking about; its origin unit is in brackets. "None run" +holds for all of them: every survivor is killed by a test or recorded below +as equivalent, with its reason, when the run is made. The recipes are +`just mutants`, `mutants-store`, `mutants-import`, `mutants-config`, +`mutants-mcp`, and (P12 step 1) `mutants-host`, `mutants-package`, +`mutants-vault`, `mutants-cli`: each runs the crate's own tests and +`ledger-cli`'s end-to-end suites without the viewer, `-j 4` by default, one +run at a time, local only (D-59). `ledger-gui` has no mutation run (its two +musts are `tests/read_only.rs` and `tests/view_model.rs`). The `bookspec` +items are for that repository's own check (D-34 step 6). The whole diff to mutate: from `168494b` (main before P2 slice 3). + +The fuzz targets P12 step 1 added: `fuzz_http_mcp_request` (arbitrary method, +path, headers and body into the in-process router, `ledger-mcp`'s +`local::fuzz_door`, behind its `fuzzing` feature: no token live for the book +is never a 2xx, a valid one never makes the door fail) and `fuzz_upload_put` +(bytes come in only if they are the declared ones, from the token the upload +was granted to, once). `fuzz_op_sequence::Op` gained the invoice, bill, +payment, settlement, write-off, void and draft-state ops and a control-check +invariant (control accounts equal their documents). + +### ledger-core + +- [P2 slices 3 to 8] `settings.rs` from scratch. +- [P3 odd cents] `kernel::apportion_cumulative` (length and range guards; + `checked_sub` for `checked_add`; the subtraction skipped); + `periods::allocate_cumulative` (the sign; `given` not negated for a loss; + `places` ignored; the exactness filter, zero products included); + `Ledger::members_regime` and `regime_where` (skip versus break at a close to + anything else; the order-free compare made ordered; `<=` for `<` in the date + filter; `given` added to the wrong member; the count's order); + `Regime::allocate` (the order); `Ledger::members_split` and + `close_period_to_steps` (the start check; the `posts` gate; the recorded + rule; the sweeps before the parts); `member_close_run`; `member_close_split`; + `reports::members_equity` (`allocate(C)` replaced by `given`; `places`; + "other"'s order); `k1::share_property`; `Currency::allocation_places`. + Accept counts for `ledger-core` and `ledger-import` are R-6. +- [P3 cash flow] `cashflow.rs` (`cash_flow`, `analyse`, `pairs`, `label`, + `class_of`, `reconcile`, `warnings`, `bill_splits`, `role_problems`, `foot`, + `on_behalf`); `kernel.rs`'s `take`, `apply_cash`, `entry_flows`, + `constructive`, `exchanged`. Likely equivalent, to adjudicate: `min` against + `max` in `take` where both bounds coincide; the kind order's tie-breaks. +- [P3 rename and defaults] `calendar::{fiscal_year_start_unclamped, + fiscal_year_unclamped, fiscal_year_start_on, period_defaults}`, + `PeriodDefaults::{statement, statement_range, as_of, calendar_year}`, + `PeriodKind::describe`, `Company::fiscal_year_containing`, + `ChartOfAccounts::set_name`, `Ledger::{add_account (its new-name checks), + add_control_account, check_account_name, rename_account, + check_rename_account, replay_rename_account, check_rename_reason, + check_tax_category_required, check_tax_category_kept}`, + `tax::{needs_category, account_needs_category, uncategorized_accounts, + on_form_4797}` and `tax_summary`'s Form 4797 section, + `Settings::require_tax_category`. +- [P3 parties] `kernel::party_target` (each branch, the `!= 0` and `< 0` + tests, the property and money clamps, the neither-when-nothing-counts test), + `kernel::reverse_target`, `Direction::{flip, of}`; `shares::target` (the + closing check, the reversal arm and its parity, members_only, the equity + filter and the retained-earnings exclusion, the property test, the own + sums); `shares::split` (the member-order tie-break, the cash shortcut, the + zero sign); `parties::{flow, root_original, money_accounts, card_accounts, + MoneyAccounts::for_parties}` (the negation of what_for, the walk's guard, + cards and financing as money, the gift-card exclusion); `activity_with` + (the target before the split, the skip of nothing-counts, the bucket by side + and basis, the overflow checks); `Activity::totals`; `Basis::parse`; + `Ledger::{set_party_creditor, check_set_party_creditor, creditors_of, + is_closing_entry}`. Expected equivalent: `Party::tin_last4`'s `or_else` + order swapped (TIN and W-9 facts never disagree, parties INVARIANT 2a, + enforced on replay). +- [P3 1099s] `form1099.rs` (`plan_decision`, `apply_decision`, + `check_new_decision`'s decided-before test, `in_force`/`in_force_before` + (`<` against `<=`), `vendor_1099_report`'s status branches (each `>=`, `<`, + `max(.,0)`, `min(.,0)`), the listing filter, the not-reportable and + undetermined routing, the R6 test (`< 0`, credit side, `refund_accounts` per + account type), `Roles::new` (which methods' accounts are financing, which a + card's), `split_share` (the floor corrections, the sign), `vendor_part`, the + unresolved fallback, `decisions_during`, `untagged_payments` (`> 0`, each + skip, each debit kind), `decision_changes`); `kernel.rs` (`payment_bucket`, + `split_payment`, `net_buckets`); `instruments.rs`, + `Ledger::check_add_instrument` (each refusal), `set_entry_instrument` (the + chain), the reversal step's copy, `set_vendor_w9_flag`. +- [P3 parties and 1099s landed together] `parties::card_accounts` (the + recorded-cards branch, the gift and other-instruments exclusions). +- [P3 asset basis] `kernel::basis_figures` (min for max, the sign of the gain, + purchase to a contribution); `Ledger::accumulated_posted` and + `assets::run_chain_entries` (`<=` for `<` on the entry and reversal dates, + the disposed-to-0 branch, the alternating sign); `live_depreciation_run`'s + reversal test; `check_depreciation_run`'s arms; the register filter + `acquired <= as_of` and `asset_acquired_date`'s min; `AssetBasis::check`, + `check_journaled`, `check_legacy_journaled`; the acquisition and in-service + date refusals; the disposal floor; `tax_capital::RunningSplit` (the sign, the + zero case, the snap to ten places, `unsplit`), `tax_capital::entry_parts` + (the member set, the date order, the reversal's take-back), `entry_property` + (the mismatch branch, the unknown sum); the open-item conditions + (`in_service`, the disposal window); the reconciliation's signs. +- [P3 surface review] `kernel::entry_movements` and `kernel::flows_of` (the + split of `entry_flows`; the same mutants as before; the earlier P3 lists + stand, `Ledger::depreciation_runs` was `depreciation_runs_ever`). +- [P3 projects] `check_set_project`'s arms (ended_on with active/paused, `<` + vs `<=` on the dates, the duplicate name's self-exception, case folding), + `ProjectFacts::check` (the link limit, whitespace, duplicates); + `kernel::unrecovered_cost` (`max`, the sign); `project_figures`' class + filter, 240-month window and closing-entry exclusion; `project_entries`' + normal-side sign and balance-sheet zero; `check_link_to`'s feature check. +- [P3 project snapshot] `contribution_margin_pct`'s `<=` and rounding + strategy; `project_figures`' opening net and running sum; + `profit_and_loss_counted`'s counts; `Standing::of`'s order. +- [Documents take a project] `document_parts` (first-appearance order, the + `contains` test); `ensure_document_lines`' `any`; + `parse_document_lines`' arms (null, absent, out of `u32`); + `document_projects_rule`'s branch. +- [P4b S1 A] `stands_on`'s count and in-order `take_while`; `doc_state`'s + movement arms and `overdue_days`; `doc_status`'s order and its Overpaid and + Unattached arms; `check_hand_line`/`check_control_lines` (the + reversal-mirror exemption); `control_role_of`'s two clauses and the + exemption's even-depth test; `check_assign_role`'s SCENARIO 6 and its + `controls` filter; `standing_payments`' `!reversal`; `holds_role`'s type + check; `bounds_kept`'s `>`; `payment_room`'s `from`; `tag_replayed`'s side, + amount match and one-line rule; `refresh_doc`'s settled sum; the aging's + `<= 0`; `check_document_reversal`'s date check; `control_check`'s chain + netting. +- [P4b S1 B] `PaymentDetails::check` (the newline test, the 200-character + bound, a blank reference as none); `PaymentDetails::description` (the + " (ref R)" suffix, the trim, a blank reference leaving the base). +- [P4b S5] `Ledger::mark_sent`'s refusals (unknown invoice, a send dated + before it, `DocumentVoid`, empty `to`, each of the three `check_text` calls; + the note's check is stricter than the plan, to keep a full TIN out of the + journal); `SentVia::parse`'s arms and `other:` prefix; `SendKind::parse`; + `last_sent`'s `max`. +- [P4b S6] `kernel::settle`'s length check, each negative check, the + fee-above-cash test and both checked adds; `Ledger::settlement_lines`'s + kind/party checks, duplicate-document check, per-application `cash + + discount` bound, discount window (`<= max`, `<= by`, "has a reason"), + asset-bill loop, fee-on-bill refusal and row order (payment lines, + discounts, fee, cash); `check_discount`'s `within` conjunction and `because` + arms; `replay_*_payment_at`'s line/amount/account/side match and the discount + line's tag; `bills_due`'s `through` filter, discount-window test and + running-total grouping; `aging_documents`'s outstanding filter and bucket. +- [P4b S7] `Ledger::write_off_steps`' cash-basis comparison + (`eq_ignore_ascii_case`, the early return), order of checks (amount before + the day, the day before the room), the `amount > room` bound and its `room < + owed` message arm, the `WriteOff` tag, `least_owed_from`'s `max` and its + `min` over later days (shared with `payment_room`), + `replay_invoice_write_off`'s movement, `doc_state`'s `written_off` arm. +- [P4] `evidence::requirement`'s exemption arms (reversal, closing, schedule + month, depreciation run, disposal origin, payroll remittance origin, a + tagged document line, a payroll run) and their order, the split-group branch + taken only for a `Draft`, `Policy::reason_for`'s three comparisons (required + account, `<=` the threshold, every code exempt); `Ledger::origin_exemption`; + the replay arms that note `AssetDisposal` and `PayrollRemittance`; + `Ledger::discard_draft`'s empty-reason and posted refusals and the side + tables it clears; `set_draft_state`'s `check_text`; `stamp_unstamped` and + `expect_posted_seq` (a mutant that stamps the wrong line shows in + `posted_seq_is_the_posting_lines_seq`). +- [P6] `ledger-core::evidence`: `checked`'s trims, the zero number, the + negative amount and the empty-annotation refusal; `next_receipt_number`'s + `saturating_add`. +- [P7] `parties.rs` `tin_gap` (the space-run arm, so four spaces count once; + `count <= MAX_TIN_GAP`; the invisible skip; the `grouped` arm), `tin_space`, + `grouped_tin_separator` (each of `,`, `(` and `)`), `grouped_tin_runs` (each + shape, the `n > 0` join, the nine-digit check), `full_tin_runs`'s + `extend(grouped_tin_runs(..))`. +- [P3 IIF] `form1099.rs`'s `through` bound; `parties.rs` `tin_separator`'s + arms and `tin_invisible`, and the gap that doesn't count them (with [P7]'s + scanner items above). +- [P13] the bookspec loader's party, payee, decision and payment-method + setup; its exact-spelling filters; `check_k1`'s `cash_accounts` check; + `documented_tools` (its own sample checks it); `may_refuse` for write-offs. + +### ledger-store + +- [P2 slices 3 to 8] its 34 triaged survivors; slices 7 and 8 from scratch: + `backup.rs`, `root.rs`. +- [P3 odd cents] `close_to_from_journal` and `close_period_to` (the tag + mapping; `since_seq`'s parse; a tag that isn't text; the feature + declaration); `format::declared_knowing`. +- [P3 rename and defaults] `Store::{ensure_feature, ensure_features, + journal_settings (its feature), add_account, add_account_with_tax, + journal_account_added, ensure_control_account, rename_account, + set_account_tax, journal_account_tax}`, the `account.renamed` replay and + projection arms, `format::setting_feature`, `ops::report_columns`. +- [P3 parties] `Store::{set_party_creditor, ensure_feature}`. +- [P3 1099s] `form1099.rs` (each decode check, `newer_content`), `store.rs` + (`ensure_feature`, `set_vendor_1099`'s unchanged path, `set_vendor_w9_flag`, + `add_instrument`, `set_entry_instrument`'s no-op, the replay and projection + arms, `sqlite_reversal_chain`). +- [P3 format upgrade] `format.rs` (`current`, `upgrade_to`, `adds`, + `require_format`, `require_feature`), `store.rs` (`create_in_format`, + `ensure_format`, `ensure_feature`, `upgrade_format`), `root.rs` (adoption's + upgrade and its verify), `backup.rs` (`prove_archive`, `held_files`). +- [P3 asset basis] `asset_basis_from_journal` (newer vs corrupt); the + projection loader's two key sets. +- [P3 projects] `project_set_of` (newer vs corrupt); `Store::add_project`'s + "facts beyond the default" test (one line vs two); `set_project`'s unchanged + short-cut; `subject_feature`. +- [Documents take a project] `add_document_parts`' tag index. +- [P4b S6] the `documents_v2` condition (more than one application, any + discount, a fee) and the `line`/`discount_line`/`fee_line` arithmetic. +- [P4b S7] `Store::write_off_invoice`'s evidence arms (the `check_linkable` + call, the already-linked-to-the-invoice check, the blob-before-journal + order, the `File`/`Existing` link steps) and the feature gate. +- [P6] `attach`'s live-record lookup and link arms, `extract_and_journal`'s + text/blob branch and status mapping, `extraction_for`'s triple (attachment, + extractor, version), `withdraw_attachment_as`'s `duplicate_of` arm and + feature check, the `verify` text-blob loop, the projection arms' conflict + keys. +- [P8] `Store::figure_head` (the `NOT_FIGURES` list: a type wrongly in it + lets a stale audit stand, a type wrongly out of it makes an audit stale at + its own filing), `find_audit_run`'s four equalities, `record_audit_run`'s + idempotence return, `attest_receipt`'s amount rule (the debit total or any + one line) and date equality, `run_for_period`'s `through_seq == head` and + `through >= period.end`; `Integrity::check`'s intact-blob set and + `Store::disk_check`'s snapshot load and `Unreadable` arm [U0b]. + +### ledger-import + +- [P3 IIF and as-of] `iif.rs` (each `breaks` arm, the control, quote and + `None` arms of `each_piece`, the deunicode post-mapping, `segment`'s colon, + trim, cut and "?" fallback, `tag`, `with_tag`'s room, the clash loop's depth + bound, rounds and tag, `journal_to_iif`'s clash and length refusals, the + number rules, the memo cut, each `changes` comparison and its order, + `warnings`' grouping, order and masks, `shown`, DOCNUM, `EOL`); `pdf.rs`'s + fallback font; `iif_accounts`' type filter on parents and `apart_from`, + `parents_first`, the `AtTopLevel` change, the TIN note's condition and + `receivable_payable`; `fonts.rs`. +- [P3 cash flow] `report::cash_flow`. +- [P6] `extract`: the caps' comparisons (`MAX_BYTES`, `MAX_PAGES`, + `MAX_TEXT_BYTES`, the `cap_text` boundary walk), `extract`'s order (PDF by + type or magic, XLSX by type or extension, then plain text), `found`'s + blank-text arm; `pdf_lines`' page `take`, baseline rounding and + left-to-right sort; `page_png`'s scale floor and page bounds; `xlsx_text`'s + shared-string index, the `t="s"`/`inlineStr`/`b` arms and the sheet-name + filter. + +### ledger-config + +- No unit recorded a target for this crate since its 2026-10-14 run (55 caught + of 55 viable); the run (`just mutants-config`) confirms it still holds. + +### ledger-mcp + +- [P2 slices 3 to 8] `local.rs` (45 survivors) and `server.rs` (31); slices 7 + and 8 from scratch: `backup.rs`, `tools/backup.rs`, `local.rs`, `admin.rs`. +- [P3 odd cents] the MCP's `close_period` and `list_periods` fields. +- [P3 IIF and as-of] `server.rs` (the iif report, range and as_of checks, + `warnings`, the package note). +- [P3 cash flow] `cash_flow_out`, `cash_flow_csv`, `export_cash_flow`, + `settings_problems`. +- [P3 rename and defaults] `show` (the option refusals, the resolve, the + `shown` wording), `show_report_dates`, `rename_account` (its notes), + `add_account`, `import_chart_csv` (both loops), `profit_and_loss_by_class`'s + range, `export_journal`'s period, `described`, `uncategorized_warning`, + `created_uncategorized`, the digest's `uncategorized_accounts`, + `handbook::period_rule`. +- [P3 parties] the server's `parse_basis`, `party_activity`, + `entry_parties_out` (direction, cash, in_kind) and `update_party`'s + creditor_of diff. +- [P3 1099s] `tools/instruments.rs` (`decision_in`'s refusals, + `filed_year_warnings`' closed and due tests, `resolve_instrument`, + `add_instrument`'s warnings), `server.rs`'s `set_vendor_1099` (the W-9-only + path) and `set_w9` (the feature declared first, only when the flag changes). +- [P3 parties and 1099s together] the server's `vendor_1099` (`through`'s + year check, the unknown-code check, the accounts passed) and `update_party`'s + feature declaration. +- [P3 format upgrade] `backup.rs` (`upgrade_format`, + `UpgradeFailed::parts`), `local.rs` (`upgrade_format`, the List's + `format_upgrade`, `waiting`), `server.rs` (`with_store_mut`), + `tools/kept.rs` (the first next step, `format_upgrade`), `admin.rs`. +- [P3 asset basis] the MCP `aliased` logic (both given and differing). +- [P3 projects] the decision `projects` omit-when-empty; `late_tag_note`'s + `is_over`; `remember_footer`'s threshold. +- [Documents take a project] `document_notes`' dedup and its `invoice` flag; + `on_spec_invoice_note`'s kind test; `document_line_json`'s `if let`. +- [P4b S1 B] the server's `payment_details` (id or label resolution, an + unknown instrument refused); `payment_out` (as of the end of the books, + `remaining` from outstanding, `status` from `key()`); `doc_ref` (trim and + case, the two kinds, anything else refused); the `void_document` handler + (the status it answers, `map_or("void", ..)` after the day); + `control_check_out` (`agrees` as zero difference and no unattached line and + nothing needing attention, each field's mapping, the invoice/bill kind); + `control_problems` (one message per unattached line and per document, both + loops); the digest that includes it and the `verify` check that refuses a + nonzero difference. +- [P4b S2] the `documents` handler's status arms (`"all"`; `"open"` as + `!st.outstanding.is_zero() && key != "void"`; `"overdue"` as + `st.overdue_days > 0`; `key == other`), the party compare, `due_by`'s `>`, + the `(due, id)` sort, the `outstanding_total` sum; the `document` handler's + `l.date <= as_of` timeline filter, `stands`/`reversal` from `stands_on`, + `movement_key`'s arms, `parse_doc_ref`'s split, parse and kind; + `DocStatus::key`'s arms. +- [P4b S4.5] the handbook's `documents_v2_rule` both arms and the server's + `documents_v2` feature test. +- [P4b S5] the handler's `kind` default (`original` when `sends` is empty, + `reminder` after), its `to` fallback (bill-to, then party, then refused) and + `by` fallback; `sent_via`'s arms and empty fallback; `send_kind`'s error + arm; `invoice_id`'s `invoice:` strip; the replay reader's `newer_content` + arms for an unknown channel or kind. +- [P4b S6] `aging_csv`'s tie-out columns and `aging_statement`'s totals. +- [P4b S7] the handler's amount default, exactly-one evidence check, and the + preview's clone. +- [P6] `evidence`: `find_attachment`'s exactly-one selector, the hex check and + the text length floor; `consolidate_duplicates`' grouping (`< 2`), the + keep/dups split, the evidence `left == 0` gate, the keep `withdrawn` test, + the unlink dedup and the `withdrawn` count; the annotation amount/date parse + and the next number; `get_receipt`'s view arms, the 8 KiB cut, the 64 KiB + hand-over threshold and `page - 1`. +- [P7] `tin_files::tin_shaped`'s comma in each rendering and + `holds_printed_tin`'s comma join; `access::masked`'s string arm. A deleted + arm leaves a TIN the old scanner passed readable or unmasked; the truth + table and the property catch it. +- [P9a] `Isolation::parse`'s `same_user`/`advisory` and + `service_user`/`enforced` arms; `Isolation::label`'s two strings; + `start_out`'s `self.isolation.label()` (a mutant restoring a literal must + fail the two-door test); `local::tunnel_address`'s `is_loopback` test (must + fail `the_tunnel_door_binds_loopback_only`, in both halves); the `--http` + arm's `args.peek()` guard and default `127.0.0.1:7411`; `tunnel.is_some()` + and the bind failure path (ready after the try; a failed bind not fatal); + `open_with`'s `isolation` into `Served` and `BooksServer::new`'s copy (must + fail `isolation_label_is_enforced_under_the_unit`). +- [P4] `Preview::operation` (the keys it drops: `confirmed`, `preview`, + nulls) and `Preview::check`'s three arms (missing, equal, different); + `post_entry`'s draft payload (each field); the digest's `since_seq` + comparison (`>`), its `as_of` bound, the `zero_net_accounts` branch and the + `never_reconciled` split; `report_status`'s three open conditions; the + open-drafts refusal's `<= as_of` and its decision. +- [P5] `next_steps::apply`'s three branches (an error, a result without an + object, the normal path) and the `mutating` test that gives `head`; + `tool_steps`'s confirm branch (`!confirmed` and a `preview`), the + `receipt_missing` pair before it, each tool's arm; `attention_steps`'s + subject match (entry against invoice) and its `Problem` severity for + `required`; `subject_of`'s precedence; `guards::from_refusal`'s structured + and worded branches and `Guard::remedy_tool`'s table; `prompts::checked`'s + token test and each `Check` shape (month length and calendar, year length, + the word list); `prompt_get`'s extra-argument and missing-argument + refusals; `describe::subjects`'s per-prefix id choice, `describe`'s + `settings.set` key list and TIN mask, `history`'s `>` on `after_seq`, its + `limit` cut and `more`; `check_convention_source`'s three equalities, + `check_decision_source` and `check_open_item_source`'s equal-source and + empty-reason refusals; the three `apply_*_source` arms (a mutant that also + bumps `revision` or `seq` shows in `a_source_only_revision_is_journaled`); + `books.start`'s fit order and `reconciliation` split. +- [P8] `audit::statements`' order of checks and `sort`; each `check_*` + predicate's comparison (`check_trial_balance`'s `balanced`, + `check_articulation`'s `!=`, `check_split`'s `> unit`, `check_register`'s two + conditions, `check_cash_flow`'s three arms, `check_control`'s `difference`, + unattached and `Overpaid` arms); `evidence`'s three arms (Required, the + reversal exemption and the policy test that lists it, the plain exemption) + and its receipt-set lookup; `closed_net`'s sign for revenue and expense; + `AttestationBar::counts` (each of the four conditions, `>=`, the `full` + branch, the `disagrees` early return) and `parse`; `Scope::full_bar` and + `serves_handoff`; `integrity_findings`'s eight arms; `backup_findings`' + three conditions; the export gate (`cpa_audit_standing`'s four conditions + and its feature test, the `refuse`/`warn` split, the reason's trim). +- [P10] the `DEFECTS` table's file/name lookup (a `starts_with` on + `fn (`; the `#[test]`/`#[ignore]` window of eight lines above it) and + the scopes-line scan in `defect_20_*`: a mutant that widens the window or + drops the `ignore` check survives only if no ignored or untested name is in + the table, so the table is the oracle. + +### ledger-package + +- [P3 IIF and as-of] `iif.rs` (`<=` in the as_of filter, the closes filter, + the FIXASSET date filter); `package.rs` (the cash and financing resolution, + the register's presence, the 1099 `through`, the README's IIF line, closes + sentence, warnings block and 1099 wording). +- [P3 rename and defaults] `statement_range`, `as_of_date`, `pnl_statement`, + `bs_statement`, `cash_flow_statement`, `year_earnings`, `build_package`'s + fiscal year. +- [P3 1099s] `vendor_1099.rs` (the CSV columns, every note's guard, + `may_file`, `missing_w9`'s suffix), `package.rs` (tin_gaps, the README line). +- [P3 parties and 1099s together] `Settings::with_accounts` (each list's + fallback). +- [P4b S4.5] `invoice::payment_methods` (the `qr` flag read, `link` appended + as a last line, the QR only when a link exists); `page_of`'s + snapshot-else-party choice for the bill-to, the contact, address, phone, + email order of the bill-to lines, the seller email's snapshot-else-company + fallback, the blank-footer filter; `invoice_document`'s stamp arms and + `InvoiceVersion::Current { as_of }`. +- [P4b S3] `InvoiceStamp::text`'s five arms and `template_data`'s + `stamp_draft` test (`== Draft`); `invoice_document`'s stamp match (Void, then + Paid with `last_paid`, then `overdue_days > 0` before PartiallyPaid, then + none); the settled loop (`movement == Payment`, `!reversal`, `l.date > + as_of`, `!stands_on`, the negated amount, the method and " · ref " suffix + parsed back out of the description, `last_paid`); the re-created-copy branch + (`number: format!("Invoice {id}")`, `terms: None`, the `note` naming + `today`); `dollars`' sign handling and `format_amount`'s parenthesized + negatives; `template_data`'s no-lines refusal. Expect survivors in + `PartiallyPaid` (no sample is part paid and not yet due) and `Draft` (only + S4's preview reaches it) until a test asserts them. +- [P4b S4.5] `invoice_pdf::dollars` (the negative arm) and `unit_price` + (`scale() <= 2`, the exact arm), `quantity_detail`'s both-or-neither match, + `note_text`'s three arms (re-created, both, note only). +- [P4b S5] attention: `invoice.number.is_some()` (the legacy exemption), + `Open | Partial`, `sends.is_empty()`, the `checked_add_days` `unsent_days` + comparison (`>`), `overdue_days > 0`, the `sends.max()` last send, the + `reminder_days` comparison (`>`), each item's target (`Target::Invoice`) and + its `books.mark_sent` step args. +- [P6] attention: the extraction `seq` comparison and the `Failed` filter, the + owed-account name test, the credit side and the `settles_entry` skip. +- [U0b] `attention`: `origin`'s arm order (a reversal before its schedule, a + bill before its payment) and `entry_refs`' keys; `missing_receipt`'s + conjunction (the `Journal entry | Recurring` origin, `reversal_of` none, no + receipts, the policy); the `> 3` combine and its `try_fold`; `w9_missing`'s + three clauses (no W-9, files a 1099, the vendor not marked on file) and + `files_1099`'s `any`; `paid_this_year`'s `max` (today) and `min` (Dec 31) and + its `paid - received`; `depreciation_due`'s `in_effect` filter, + `period_month` (`1..=12`) and `!is_zero`; `period_month`'s split; the + severity arms (undetermined 1099, refuse tax category, parties require); + `dollars`' thousands grouping and negative-zero test. + +### ledger-host + +- [P2 slices 3 to 8] pass 2; `commands.rs`. +- [P3 format upgrade] `host.rs` (`consent`, `consented`), `commands.rs` + (`consent_now`, `upgrade_format`, `offer_upgrades`, `offer_upgrade`, + `upgrade`, `book_line`, `upgraded_lines`, `upgrade_explained`, + `ASKED_UPGRADES`). +- [P9a] the host's `server_flags` extension and `parse_args`' passthrough of + `--isolation` and its optional `--http` value. +- [P11a step 0] `tokens.rs`'s `os` modules are not reachable by mutation + testing (they need the real OS store); the `Items` logic they share is, + through the file store. +- [U0e] `tokens.rs`'s `Keeper::set` (the temporary file's mode and the + rename: a mutant that writes the final file directly or wider must die on + `a_token_file_is_owner_only`), `Keeper::get`'s `NotFound` arm (must die on + `an_unreadable_token_file_is_an_error_never_no_token`), `Keeper::delete`'s + `NotFound` arm, `Account::key`'s three parts + (`an_account_names_the_root_by_hash_and_the_book_and_token`); + `bridge.rs::token_file_refusal`'s `holds_token` guard. +- [P11c] `winsec::is_private`'s three refusals (owner, missing DACL, an allow + entry for anyone else) and the ACE types it skips; `ipc::Endpoint::new`'s + hash input (a mutant that drops the lower-casing or the prefix strip splits + one root into two pipes: found only by two processes spelling the path + differently); the Windows `StdStream::read_some`'s deadline comparison and + `BROKEN_PIPE` arm; `Listener`'s `IDLE_INSTANCES` pool (a mutant making it 1 + passes every test but `many_clients_at_once_all_connect`); + `start.rs::host_env`'s list. None of the Windows arms is reachable by + `cargo mutants` on Linux: they run on the Windows job only. +- [P11a steps 2 and 3] `update.rs`'s `verify` (a mutant that drops the sha or + signature check must die on `unsigned_update_refused`'s sha and key cases), + `refuse_if_consented`'s two comparisons (the feature one is covered by + `rollback_refuses_after_a_book_consented_to_a_newer_format`; a case with a + higher format number is a gap to add), `swap_in`'s temporary-then-rename + (`update_is_atomic`), `healthy` (`failed_update_rolls_back`), + `install_kind` (the marker test), `parse_version`'s rejections. +- [P11b] `commands::make_books_home_in` (the `!path.exists()` test that keeps + a person's `AGENTS.md`; the merge that dedupes the allow rule; + `BookId::parse` before any path is joined); `find_agent_in` (the order PATH + then the installer folders; `is_program`'s mode test); `start_agent_in` (the + `Missing` arm); `first_run_state`'s three fields; `set_first_passphrase`'s + already-set refusal. + +### ledger-vault + +- No target of its own was recorded by any unit. The recipe `mutants-vault` + runs the crate's `tests/vault.rs` and the `ledger-cli` suites that reach the + vault (`spec_vault.rs`, `tin_files.rs`); whatever survives is judged in the + run. + +### ledger-cli + +- [P2 slices 3 to 8] `ledger-cli` from scratch (the argument handling, the + server and bridge modes). The run is `just mutants-cli`. +- [P9a] the `--isolation` and `--http` flags as the executable passes them on. + +### Not mutated + +- [P11b] `ledger-gui`: `dock::key_bytes` (the modifier parameter's arithmetic, + the Ctrl-letter byte, the app-cursor switch, the Alt escape), `dock::color`'s + cube and gray ramps and its bold brightening, `Session::resize`'s equality + early return, the panel's open/closed ids (a remembered size must not leak + between them), `Welcome::steps`' choice of `now` (each arm), the passphrase + length and equality gates, the `agent_later` flag. The viewer has no + mutation run (owner, 2026-10-18); these are for the viewer's own tests. +- [P3 rename and defaults, P3 1099s, P3 IIF, P4b S2 to S4.5, P7] the viewer + items (`view_model.rs`, `show.rs`, `owed_to_member`, `party_what_for`, + `share_text`, `Filter::keeps` and its sorts and labels, `document_of`, + `details`, `invoice_name`, `issued_copy`, `viewer discount_by` and + `days_overdue`, `menu.rs`'s note, `theme.rs` `font_definitions`): covered by + `view_model.rs`, `show.rs` and `read_only.rs`, not mutated. + +### bookspec (its own repository since P13 step 6) + +- [P13] `rules::build`'s area ordinals; `RuleId::parse`'s prefix, area, + digit, leading-zero and zero checks; `rule_for_refusal`'s mapping arms; + `schema::is_exact_decimal`, `is_date`, `is_month`, `is_month_day`, + `check_amount`'s positivity; `Example::validate`'s closed-list arms, the + one-kind rule, `reverses < n`, shares summing to 1, `after` bounds, the + command's accepted-or-refused exclusivity, the `only` table; + `Query::is_complete`'s membership; `Refusal::of_reason`'s fallback; the + runner's `compare`, `Outcome`'s mapping and `run`'s "claimed but no example" + arm; the adapter's `event_of`, its `depreciation_schedule` month span and + every `answer_*` figure against the loader's same check (the two must agree + on all examples). +- [P13 steps 4 and 5] the adapter's `refusal` arms (`UnbalancedEntry`, + `NegativeAmountLine`, `ZeroAmountLine`, `FinerThanMinorUnit`, + `ParentTypeMismatch`, `EntryAlreadyReversed`, `ClosedPeriod`), each pinned by + the example that expects it, and the `money` closure's positivity test; + `rule_for_refusal`'s new arms; `check_amount`'s `positive` flag; `derive`'s + `refusal_justified` arms, `Model::post`'s close, `signed`'s date window and + `closings` flag, the accounting-equation check; the spec test's pin search. From 5e474cd1ac245cf9b6c2c26d02ad7cedd343bb0f Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 5 Nov 2026 08:36:03 -0700 Subject: [PATCH 3/5] feat(mcp): the books door served in-process for fuzzing (P12) The local socket and the tunnel already mount the same four routes; naming that table `router` gives the fuzz targets the very door a client reaches, not a copy of it. `local/fuzz_door` (behind the `fuzzing` feature, off in every shipped build) builds a book, two live tokens and that router, and answers one request through `tower::oneshot`: no socket, no port, no clock. The target's oracle is then the code that enforces it: INVARIANT 8 (a book only behind a live bearer token) is checked by asking `Books::bearer`, not by a second model of the rule that could drift from it. --- crates/ledger-mcp/Cargo.toml | 6 + crates/ledger-mcp/src/local.rs | 24 ++- crates/ledger-mcp/src/local/fuzz_door.rs | 187 +++++++++++++++++++++++ 3 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 crates/ledger-mcp/src/local/fuzz_door.rs diff --git a/crates/ledger-mcp/Cargo.toml b/crates/ledger-mcp/Cargo.toml index 4f4495f..a648a70 100644 --- a/crates/ledger-mcp/Cargo.toml +++ b/crates/ledger-mcp/Cargo.toml @@ -39,12 +39,18 @@ toml = "1" # The vault's TINs, decoded for the filing scan (tin_files.rs), are wiped # from memory when dropped, as the vault's own are. zeroize = "1" +tempfile = { version = "3", optional = true } # The socket's peer credentials (SO_PEERCRED, getpeereid) for local.rs's # same-user check: std has no call for them. [target.'cfg(unix)'.dependencies] libc = "0.2" +[features] +# The door in-process, for fuzz/ (local.rs fuzz_door). Off in every build +# but the fuzz workspace's. +fuzzing = ["dep:tempfile"] + [lints] workspace = true diff --git a/crates/ledger-mcp/src/local.rs b/crates/ledger-mcp/src/local.rs index 56f60a6..7200c34 100644 --- a/crates/ledger-mcp/src/local.rs +++ b/crates/ledger-mcp/src/local.rs @@ -1730,6 +1730,22 @@ async fn mcp( }) } +/// The one router both doors serve (the local socket and the tunnel). +fn router(books: Arc) -> axum::Router { + axum::Router::new() + .route("/b/{book}/mcp", axum::routing::any(mcp)) + .route("/b/{book}/u/{upload}", axum::routing::put(upload)) + .route("/b/{book}/a/{attachment}", axum::routing::get(download)) + .route("/b/{book}/x/{export}", axum::routing::get(export)) + .fallback(not_found) + .with_state(books) +} + +/// The door in-process, for the fuzz targets (P12): the same router, fed +/// bytes without a socket. Not built unless asked for. +#[cfg(feature = "fuzzing")] +pub mod fuzz_door; + /// Anything but `/b/{book}/mcp`. async fn not_found() -> (axum::http::StatusCode, &'static str) { (axum::http::StatusCode::NOT_FOUND, "not found") @@ -2080,13 +2096,7 @@ async fn serve( } }); - let app = axum::Router::new() - .route("/b/{book}/mcp", axum::routing::any(mcp)) - .route("/b/{book}/u/{upload}", axum::routing::put(upload)) - .route("/b/{book}/a/{attachment}", axum::routing::get(download)) - .route("/b/{book}/x/{export}", axum::routing::get(export)) - .fallback(not_found) - .with_state(books.clone()); + let app = router(books.clone()); // The HTTP side keeps answering (refusing, once closing) until the // close is done: a client is never cut off mid-request by it. let shut = CancellationToken::new(); diff --git a/crates/ledger-mcp/src/local/fuzz_door.rs b/crates/ledger-mcp/src/local/fuzz_door.rs new file mode 100644 index 0000000..ffa2b34 --- /dev/null +++ b/crates/ledger-mcp/src/local/fuzz_door.rs @@ -0,0 +1,187 @@ +//! The books server's HTTP door, in-process, for the fuzz targets +//! (`fuzz_http_mcp_request`, `fuzz_upload_put`; P12). It is the very router +//! `serve` mounts (`router`), over a real book in a folder of its own, fed +//! requests by `tower::oneshot`: no socket, no port. Behind the `fuzzing` +//! feature, which only the fuzz workspace turns on. +//! +//! Oracle: whatever the bytes, the door answers (no panic, no hang), and +//! never lets in a request without a token live for the book (INVARIANT 8) +//! or bytes that are not the ones declared (INVARIANT 9). + +use super::*; +use tower::ServiceExt; + +/// One book `acme`, two tokens (`bearer`, `other`), and the router. +pub struct FuzzDoor { + rt: tokio::runtime::Runtime, + books: Arc, + app: axum::Router, + bearer: String, + token_id: String, + other: String, + _home: tempfile::TempDir, +} + +impl FuzzDoor { + /// A fresh book in a fresh folder. + pub fn new() -> Self { + let home = tempfile::tempdir().expect("a folder"); + let (root, _) = + Root::setup(home.path(), Some(&home.path().join("root")), None).expect("a data root"); + let books = Arc::new(Books { + root, + held: RwLock::new(BTreeMap::new()), + sessions: CancellationToken::new(), + closing: CancellationToken::new(), + in_flight: AtomicUsize::new(0), + finished: tokio::sync::Notify::new(), + last_arrival: std::sync::atomic::AtomicU64::new(0), + started: Instant::now(), + principals: RwLock::new(Err(String::new())), + session_tokens: Mutex::new(BTreeMap::new()), + backup_quiet: BACKUP_QUIET, + drill_first: DRILL_FIRST, + restoring: Mutex::new(()), + isolation: Isolation::SameUser, + }); + let loaded = books.load_principals(); + *books.principals.write().unwrap() = loaded; + match books.admin(Request::Create { + id: "acme".into(), + currency: "USD".into(), + }) { + Response::Created { problem: None, .. } => {} + other => panic!("{other:?}"), + } + let token = |name: &str| match books.admin(Request::TokenCreate { + book: "acme".into(), + name: name.into(), + scopes: ["read", "write", "post", "admin"] + .map(String::from) + .to_vec(), + replace: true, + }) { + Response::TokenCreated { token, text } => (token.id, text.0), + other => panic!("{other:?}"), + }; + let (token_id, bearer) = token("fuzz"); + let (_, other) = token("fuzz-other"); + let app = router(books.clone()); + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .expect("a runtime"); + FuzzDoor { + rt, + books, + app, + bearer, + token_id, + other, + _home: home, + } + } + + /// The text of the token that is live for the book. + pub fn bearer(&self) -> &str { + &self.bearer + } + + /// The text of a second live token (the one an upload is not its). + pub fn other_bearer(&self) -> &str { + &self.other + } + + /// An upload granted to the live token: its id, or why not. + pub fn grant(&self, sha256: &str, size: u64) -> Result { + let held = self.books.held(); + let Some(Book::Open { uploads, .. }) = held.get("acme") else { + return Err("acme isn't open".into()); + }; + uploads + .grant( + &self.token_id, + sha256, + size, + "application/pdf", + "fuzz.pdf", + "document", + chrono::Utc::now().timestamp(), + ) + .map(|g| g.id) + .map_err(|e| e.to_string()) + } + + /// Whether the door would take one of these headers as a bearer token + /// live for the book: the same check every request passes, so a target + /// never has to model the rule a second time. + pub fn carries_a_token(&self, headers: &[(Vec, Vec)]) -> bool { + let mut map = axum::http::HeaderMap::new(); + for (name, value) in headers { + let (Ok(name), Ok(value)) = ( + axum::http::HeaderName::from_bytes(name), + axum::http::HeaderValue::from_bytes(value), + ) else { + return false; + }; + map.append(name, value); + } + self.books.bearer(&map).is_ok() + } + + /// Send a request as the router receives it; the status and the body + /// of the answer, or `None` when the pieces do not make an HTTP request + /// at all (a method or header the HTTP types refuse never reaches the + /// door in life either). + pub fn send( + &self, + method: &[u8], + path: &str, + headers: &[(Vec, Vec)], + body: Vec, + ) -> Option<(u16, Vec)> { + let mut request = axum::http::Request::builder() + .method(axum::http::Method::from_bytes(method).ok()?) + .uri( + format!("http://localhost{path}") + .parse::() + .ok()?, + ); + for (name, value) in headers { + request = request.header( + axum::http::HeaderName::from_bytes(name).ok()?, + axum::http::HeaderValue::from_bytes(value).ok()?, + ); + } + let request = request.body(axum::body::Body::from(body)).ok()?; + let app = self.app.clone(); + Some(self.rt.block_on(async move { + let response = app.oneshot(request).await.unwrap_or_else(|e| match e {}); + let status = response.status().as_u16(); + // An event stream never ends on its own: read what comes in a + // moment, as a client that hangs up would. + let bytes = tokio::time::timeout( + Duration::from_millis(200), + axum::body::to_bytes(response.into_body(), 1 << 20), + ) + .await + .ok() + .and_then(Result::ok) + .unwrap_or_default(); + (status, bytes.to_vec()) + })) + } + + /// Sessions the door has bound (they only grow, so the target starts + /// a fresh door now and then). + pub fn sessions(&self) -> usize { + self.books.session_tokens.lock().unwrap().len() + } +} + +impl Default for FuzzDoor { + fn default() -> Self { + Self::new() + } +} From 7002e89efd5b425433a8be4f2be5cc925cac59cb Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 5 Nov 2026 08:36:07 -0700 Subject: [PATCH 4/5] test(fuzz): arbitrary bytes into the HTTP door and the upload path (P12) P2 promised these two targets and never added them. `fuzz_http_mcp_request` feeds a method, a path, headers (token present, absent, wrong, raw) and a JSON-RPC body drawn from the real method and tool names into the in-process router. Whatever arrives, the door answers: no token live for the book is refused 4xx, never run (INVARIANT 8), and a live token never makes the door fail (no 5xx on the MCP route). Whether a token is live is asked of the door itself (`FuzzDoor::carries_a_token`), so a token smuggled in an extra header is judged by the real rule rather than by the target's guess. `fuzz_upload_put` declares a size and a hash and sends bytes: taken only when both are exactly what was declared, from the token the grant names, and only once (INVARIANT 9); a 200 for anything else is the bug. Both join the weekly matrix in scheduled.yml; fuzz/Cargo.lock gains ledger-mcp's dependency closure because the targets drive its router. --- .github/workflows/scheduled.yml | 2 +- fuzz/Cargo.lock | 935 ++++++++++++++++++++- fuzz/Cargo.toml | 18 + fuzz/fuzz_targets/fuzz_http_mcp_request.rs | 169 ++++ fuzz/fuzz_targets/fuzz_upload_put.rs | 130 +++ 5 files changed, 1252 insertions(+), 2 deletions(-) create mode 100644 fuzz/fuzz_targets/fuzz_http_mcp_request.rs create mode 100644 fuzz/fuzz_targets/fuzz_upload_put.rs diff --git a/.github/workflows/scheduled.yml b/.github/workflows/scheduled.yml index 210fcea..de9e821 100644 --- a/.github/workflows/scheduled.yml +++ b/.github/workflows/scheduled.yml @@ -33,7 +33,7 @@ jobs: strategy: fail-fast: false matrix: - target: [fuzz_journal_line, fuzz_entry_validate, fuzz_op_sequence, fuzz_core_serde, fuzz_store_open, fuzz_store_replay, fuzz_settings_replay, fuzz_import_csv, fuzz_config_parse, fuzz_extract_pdf, fuzz_extract_xlsx] + target: [fuzz_journal_line, fuzz_entry_validate, fuzz_op_sequence, fuzz_core_serde, fuzz_store_open, fuzz_store_replay, fuzz_settings_replay, fuzz_import_csv, fuzz_config_parse, fuzz_extract_pdf, fuzz_extract_xlsx, fuzz_http_mcp_request, fuzz_upload_put] steps: - uses: actions/checkout@v4 - uses: jdx/mise-action@v2 diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 99e0016..3cedeb2 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -8,6 +8,28 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.1", + "zeroize", +] + [[package]] name = "ahash" version = "0.8.12" @@ -104,12 +126,81 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "az" version = "1.3.0" @@ -212,6 +303,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", + "zeroize", ] [[package]] @@ -303,6 +395,15 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + [[package]] name = "cc" version = "1.8.0" @@ -327,6 +428,30 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", +] + [[package]] name = "chinese-number" version = "0.7.8" @@ -386,6 +511,17 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", +] + [[package]] name = "citationberg" version = "0.7.0" @@ -460,6 +596,21 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb6feb175df65d0a3ccd3e73484e940d0ac664e8034f0519fd49a93db3071d9" +dependencies = [ + "rustversion", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -475,6 +626,12 @@ dependencies = [ "libm", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -549,7 +706,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -582,12 +741,52 @@ dependencies = [ "cmov", ] +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.9", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.9", +] + [[package]] name = "data-url" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + [[package]] name = "defmt" version = "1.2.0" @@ -659,8 +858,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", + "const-oid", "crypto-common 0.2.2", "ctutils", + "zeroize", ] [[package]] @@ -695,6 +896,12 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "ecow" version = "0.2.6" @@ -736,6 +943,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "euclid" version = "0.22.14" @@ -889,12 +1106,71 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -907,8 +1183,13 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -956,6 +1237,7 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -1272,6 +1554,60 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hybrid-array" version = "0.4.15" @@ -1281,6 +1617,40 @@ dependencies = [ "typenum", ] +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hypher" version = "0.1.8" @@ -1507,6 +1877,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1581,6 +1957,15 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +[[package]] +name = "inout" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" +dependencies = [ + "hybrid-array", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1754,6 +2139,7 @@ dependencies = [ "ledger-config", "ledger-core", "ledger-import", + "ledger-mcp", "ledger-store", "libfuzzer-sys", "rust_decimal", @@ -1782,6 +2168,52 @@ dependencies = [ "typst-pdf", ] +[[package]] +name = "ledger-mcp" +version = "0.1.0" +dependencies = [ + "axum", + "base64 0.22.1", + "chrono", + "futures-util", + "http-body-util", + "ledger-config", + "ledger-core", + "ledger-import", + "ledger-package", + "ledger-store", + "ledger-vault", + "libc", + "rmcp", + "rust_decimal", + "schemars", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "thiserror", + "tokio", + "tokio-util", + "toml 1.1.8+spec-1.1.0", + "tower", + "zeroize", +] + +[[package]] +name = "ledger-package" +version = "0.1.0" +dependencies = [ + "chrono", + "ledger-config", + "ledger-core", + "ledger-import", + "ledger-store", + "ledger-vault", + "rust_decimal", + "serde_json", + "zip 9.1.3", +] + [[package]] name = "ledger-reader" version = "0.1.0" @@ -1809,11 +2241,33 @@ dependencies = [ "rust_decimal", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "subtle", "thiserror", + "windows-sys", ] +[[package]] +name = "ledger-vault" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "chacha20poly1305", + "getrandom 0.2.17", + "hex", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror", + "zeroize", +] + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f454fd6831a29c0e827ed075bca2a15be201bd57f4168f0fd1041124eab17e" + [[package]] name = "libc" version = "0.2.190" @@ -1868,6 +2322,12 @@ version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "lipsum" version = "0.9.1" @@ -1902,6 +2362,21 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lzma-rust2" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c46f3fcd2e888afdb01a07efc071cb21300e07c68d32c3e8b01b5c8cb1fcaa" +dependencies = [ + "sha2 0.11.1", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.3" @@ -1917,6 +2392,12 @@ dependencies = [ "libc", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1937,6 +2418,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -2092,6 +2584,22 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac", +] + [[package]] name = "pdf-writer" version = "0.15.0" @@ -2231,6 +2739,16 @@ dependencies = [ "miniz_oxide 0.8.9", ] +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.1", + "universal-hash", +] + [[package]] name = "polycool" version = "0.4.0" @@ -2282,6 +2800,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" +[[package]] +name = "ppmd-rust" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd4acc8d264ea385db64ccfdcdfd5909ee9eda0975666723d59f3bfec80b3611" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2404,6 +2928,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2442,6 +2977,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rayon" version = "1.12.0" @@ -2513,6 +3054,26 @@ dependencies = [ "thiserror", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "regex" version = "1.13.1" @@ -2568,6 +3129,52 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "rmcp" +version = "3.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8be15815186873ea063bf005011d16c356804973489e40c39116ae3a2878d1f" +dependencies = [ + "async-trait", + "base64 0.23.1", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "indexmap", + "libc", + "pastey", + "pin-project-lite", + "rand 0.10.3", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "sse-stream", + "thiserror", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "3.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b0748e054a5f5598b3213cf72099ae6b2745ba4d996c59ccf455150a7dbb839" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 3.0.9", +] + [[package]] name = "roman-numerals-rs" version = "3.1.0" @@ -2635,6 +3242,19 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -2674,6 +3294,32 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.9", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2716,6 +3362,17 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "serde_json" version = "1.0.154" @@ -2758,6 +3415,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -2771,6 +3440,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2782,12 +3462,33 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -2860,12 +3561,35 @@ version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "spin" version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +[[package]] +name = "sse-stream" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2894,6 +3618,12 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.27.2" @@ -2965,6 +3695,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.14.0" @@ -2997,6 +3733,19 @@ dependencies = [ "yaml-rust", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thin-vec" version = "0.2.21" @@ -3030,6 +3779,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", + "js-sys", "num-conv", "powerfmt", "serde_core", @@ -3096,6 +3846,59 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "libc", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -3188,6 +3991,66 @@ version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -3658,6 +4521,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -3723,6 +4596,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -4145,6 +5029,15 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "serde", +] + [[package]] name = "zerotrie" version = "0.2.5" @@ -4202,15 +5095,27 @@ version = "9.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb" dependencies = [ + "aes", + "bzip2", + "constant_time_eq", "crc32fast", + "deflate64", "flate2", "getrandom 0.4.3", + "hmac", "indexmap", + "lzma-rust2", "memchr", "nix", + "pbkdf2", + "ppmd-rust", + "sha1", + "time", "typed-path", "windows-sys", + "zeroize", "zopfli", + "zstd", ] [[package]] @@ -4237,6 +5142,34 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "zstd" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.2+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zune-core" version = "0.5.3" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 381a730..5988cde 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -24,6 +24,10 @@ path = "../crates/ledger-store" [dependencies.ledger-import] path = "../crates/ledger-import" +[dependencies.ledger-mcp] +path = "../crates/ledger-mcp" +features = ["fuzzing"] + [dependencies.ledger-config] path = "../crates/ledger-config" @@ -105,3 +109,17 @@ path = "fuzz_targets/fuzz_settings_replay.rs" test = false doc = false bench = false + +[[bin]] +name = "fuzz_http_mcp_request" +path = "fuzz_targets/fuzz_http_mcp_request.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_upload_put" +path = "fuzz_targets/fuzz_upload_put.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/fuzz_http_mcp_request.rs b/fuzz/fuzz_targets/fuzz_http_mcp_request.rs new file mode 100644 index 0000000..6ed94b2 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_http_mcp_request.rs @@ -0,0 +1,169 @@ +// Fuzz target: the books server's HTTP door takes arbitrary requests. +// Method, path, headers and body are all untrusted; the door is the very +// router the server mounts (ledger-mcp local.rs `router`), in-process over a +// real book, with no socket (`fuzz_door`). Whatever arrives: the door +// answers (no panic, no hang), and a request that carries no token live +// for the book is refused 4xx and never run (INVARIANT 8, local.rs); one +// that carries it never makes the door fail (no 5xx on the MCP route). +#![no_main] + +use arbitrary::Arbitrary; +use ledger_mcp::local::fuzz_door::FuzzDoor; +use libfuzzer_sys::fuzz_target; +use std::cell::RefCell; + +#[derive(Arbitrary, Debug)] +enum Auth { + None, + Valid, + Other, + /// `Authorization` with whatever text. + Raw(Vec), +} + +#[derive(Arbitrary, Debug)] +enum Body { + Raw(Vec), + /// A JSON-RPC message the server could understand. + Rpc { + method: u8, + id: u8, + params: String, + }, + /// `tools/call` of a name picked from a few real ones, arguments as text. + Call { + tool: u8, + arguments: String, + }, +} + +#[derive(Arbitrary, Debug)] +struct Request { + method: Vec, + verb: u8, + route: u8, + book: u8, + suffix: String, + auth: Auth, + session: Option, + content_type: u8, + accept: bool, + extra: Vec<(Vec, Vec)>, + body: Body, +} + +const METHODS: [&str; 6] = ["POST", "GET", "DELETE", "PUT", "OPTIONS", "PATCH"]; +const RPC: [&str; 8] = [ + "initialize", + "notifications/initialized", + "tools/list", + "ping", + "resources/list", + "prompts/list", + "tools/call", + "logging/setLevel", +]; +const TOOLS: [&str; 6] = [ + "books.start", + "books.trial_balance", + "books.draft_entry", + "books.begin_upload", + "books.settings", + "books.handbook", +]; + +thread_local! { + /// The door, and how many requests it has had: a fresh book every so + /// often, because sessions and the access log only grow. + static DOOR: RefCell> = const { RefCell::new(None) }; +} + +fn clean(s: &str) -> String { + s.chars() + .filter(|c| c.is_ascii_alphanumeric() || "-_.".contains(*c)) + .take(40) + .collect() +} + +fuzz_target!(|r: Request| { + DOOR.with(|cell| { + let mut cell = cell.borrow_mut(); + if cell.as_ref().is_none_or(|(d, n)| *n > 3000 || d.sessions() > 500) { + *cell = Some((FuzzDoor::new(), 0)); + } + let (door, n) = cell.as_mut().unwrap(); + *n += 1; + + let method: Vec = if r.verb % 4 == 0 { + r.method.clone() + } else { + METHODS[r.verb as usize % METHODS.len()].as_bytes().to_vec() + }; + let book = match r.book % 4 { + 0 | 1 => "acme".to_string(), + 2 => "nobody".to_string(), + _ => clean(&r.suffix), + }; + let path = match r.route % 6 { + 0..=2 => format!("/b/{book}/mcp"), + 3 => format!("/b/{book}/u/{}", clean(&r.suffix)), + 4 => format!("/b/{book}/a/{}", clean(&r.suffix)), + _ => format!("/b/{book}/x/{}", clean(&r.suffix)), + }; + let mut headers: Vec<(Vec, Vec)> = Vec::new(); + match &r.auth { + Auth::None => {} + Auth::Valid => { + headers.push((b"authorization".to_vec(), format!("Bearer {}", door.bearer()).into_bytes())); + } + Auth::Other => { + headers.push((b"authorization".to_vec(), format!("Bearer {}x", door.other_bearer()).into_bytes())); + } + Auth::Raw(v) => { + headers.push((b"authorization".to_vec(), v.clone())); + } + } + if let Some(s) = &r.session { + headers.push((b"mcp-session-id".to_vec(), clean(s).into_bytes())); + } + headers.push(( + b"content-type".to_vec(), + ["application/json", "text/plain", "application/octet-stream", ""][r.content_type as usize % 4] + .as_bytes() + .to_vec(), + )); + if r.accept { + headers.push((b"accept".to_vec(), b"application/json, text/event-stream".to_vec())); + } + headers.extend(r.extra.iter().take(4).cloned()); + // The door's own rule, asked directly: any header (the authorization + // one, or a smuggled extra) can carry a token live for the book. + let live = door.carries_a_token(&headers); + let body = match &r.body { + Body::Raw(b) => b.clone(), + Body::Rpc { method, id, params } => { + let params = serde_json::from_str::(params).unwrap_or_default(); + serde_json::json!({"jsonrpc": "2.0", "id": id, "method": RPC[*method as usize % RPC.len()], "params": params}) + .to_string() + .into_bytes() + } + Body::Call { tool, arguments } => { + let args = serde_json::from_str::(arguments).unwrap_or_default(); + serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": TOOLS[*tool as usize % TOOLS.len()], "arguments": args}}) + .to_string() + .into_bytes() + } + }; + + let Some((status, _)) = door.send(&method, &path, &headers, body) else { + return; // not an HTTP request at all + }; + if !live && book == "acme" && path.ends_with("/mcp") { + assert!((400..500).contains(&status), "no token, yet {status} for {path}"); + } + if live && path.ends_with("/mcp") && book == "acme" { + assert!(status < 500, "a valid token made the door fail: {status}"); + } + }); +}); diff --git a/fuzz/fuzz_targets/fuzz_upload_put.rs b/fuzz/fuzz_targets/fuzz_upload_put.rs new file mode 100644 index 0000000..820a1b7 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_upload_put.rs @@ -0,0 +1,130 @@ +// Fuzz target: a file's bytes come in only by `PUT /b/{book}/u/{upload}` +// (ledger-mcp local.rs INVARIANT 9), through the same in-process router as +// fuzz_http_mcp_request. The body, the declared size and hash, the upload +// id and the token are all arbitrary. Whatever arrives: the door answers, +// and bytes are taken only if they are exactly the declared ones, from the +// token the upload was granted to, once: a 200 for anything else is a bug. +#![no_main] + +use arbitrary::Arbitrary; +use ledger_mcp::local::fuzz_door::FuzzDoor; +use libfuzzer_sys::fuzz_target; +use std::cell::RefCell; + +#[derive(Arbitrary, Debug)] +enum Declared { + Honest, + /// The size off by this much (never 0). + SizeOff(i8), + /// The hash of other bytes. + HashOf(Vec), + /// A hash that is not 64 hex digits. + BadHash(String), +} + +#[derive(Arbitrary, Debug)] +enum Who { + Owner, + Other, + Nobody, +} + +#[derive(Arbitrary, Debug)] +struct Put { + body: Vec, + declared: Declared, + who: Who, + /// Use an id that was never granted (this text, cleaned). + unknown_id: Option, + /// Send it a second time too. + again: bool, + /// And a body of another length the second time. + second: Vec, +} + +thread_local! { + static DOOR: RefCell> = const { RefCell::new(None) }; +} + +fuzz_target!(|p: Put| { + DOOR.with(|cell| { + let mut cell = cell.borrow_mut(); + if cell.as_ref().is_none_or(|(_, n)| *n > 1500) { + *cell = Some((FuzzDoor::new(), 0)); + } + let (door, n) = cell.as_mut().unwrap(); + *n += 1; + + let sha = ledger_store::journal::sha256_hex(&p.body); + let (grant_sha, grant_size) = match &p.declared { + Declared::Honest => (sha.clone(), p.body.len() as u64), + Declared::SizeOff(d) => { + let d = if *d == 0 { 1 } else { i64::from(*d) }; + (sha.clone(), (p.body.len() as i64 + d).max(0) as u64) + } + Declared::HashOf(other) => ( + ledger_store::journal::sha256_hex(other), + p.body.len() as u64, + ), + Declared::BadHash(s) => (s.clone(), p.body.len() as u64), + }; + let size_ok = grant_size == p.body.len() as u64 && grant_sha == sha; + + let granted = door.grant(&grant_sha, grant_size); + let id = match (&p.unknown_id, &granted) { + (Some(s), _) => { + let s: String = s + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '_') + .take(40) + .collect(); + if s.is_empty() { "up_x".to_string() } else { s } + } + (None, Ok(id)) => id.clone(), + (None, Err(_)) => "up_none".to_string(), + }; + let real = p.unknown_id.is_none() && granted.is_ok(); + let bearer = match p.who { + Who::Owner => Some(door.bearer().to_string()), + Who::Other => Some(door.other_bearer().to_string()), + Who::Nobody => None, + }; + let mut headers = Vec::new(); + if let Some(b) = &bearer { + headers.push(( + b"authorization".to_vec(), + format!("Bearer {b}").into_bytes(), + )); + } + let path = format!("/b/acme/u/{id}"); + let Some((status, text)) = door.send(b"PUT", &path, &headers, p.body.clone()) else { + return; + }; + let may_succeed = real && matches!(p.who, Who::Owner) && size_ok; + if status == 200 { + assert!( + may_succeed, + "bytes taken that should not be: {:?} {:?}", + p.who, p.declared + ); + let said = String::from_utf8_lossy(&text); + assert!( + said.contains(&sha), + "the answer doesn't name the bytes' hash: {said}" + ); + } else if may_succeed { + // Everything lined up: the only way not to be taken is a + // failure of the door itself. + panic!( + "honest upload by its owner refused: {status} {}", + String::from_utf8_lossy(&text) + ); + } + if status == 200 && p.again { + let Some((again, _)) = door.send(b"PUT", &path, &headers, p.second.clone()) else { + return; + }; + assert_ne!(again, 200, "an upload taken twice"); + } + }); +}); From 48062b89f0c1d4f256e5e802916b627ec8242541 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 5 Nov 2026 08:36:10 -0700 Subject: [PATCH 5/5] test(fuzz): the operation sequence gains documents, settlements and drafts (P12) The sequence only knew accounts, lines, periods and closes, but that is no longer where money moves: invoices, bills, their payments, write-offs and voids are now first class, and the draft-state commands decide whether an entry exists at all. Op gains each of them, on the same Ledger the store wraps, with random parties, dates and amounts (including the raw-decimal extremes). The invariant after every sequence gains B-38: each control account equals its documents as of three dates, and a control check too large to compute is a refusal, never a difference. That is the identity that would catch a payment tagged to the wrong document, or one that went unmatched, which the trial balance alone cannot see. --- fuzz/fuzz_targets/fuzz_op_sequence.rs | 350 ++++++++++++++++++++++++-- 1 file changed, 327 insertions(+), 23 deletions(-) diff --git a/fuzz/fuzz_targets/fuzz_op_sequence.rs b/fuzz/fuzz_targets/fuzz_op_sequence.rs index 470aab2..e753fae 100644 --- a/fuzz/fuzz_targets/fuzz_op_sequence.rs +++ b/fuzz/fuzz_targets/fuzz_op_sequence.rs @@ -2,27 +2,112 @@ // balanced pairs, foreign-currency lines, periods, and closes, with // amounts built from raw decimal bits (huge, tiny, odd scales). Refusals // are fine; panics, hangs, and broken books are not. After any sequence: -// the trial balance balances, every posted entry validates, and no draft -// sits in a closed period (the Lean invariant Inv). +// the trial balance balances, every posted entry validates, no draft sits +// in a closed period (the Lean invariant Inv), and the control accounts +// equal their documents (control_check's difference is zero, B-38). +// +// The documents (invoices, bills), their settlements and payments, write-offs +// and voids, and the draft-state ops (P12) run on the Ledger the store wraps: +// the store adds the journal, the engine is the state machine fuzzed here. #![no_main] use arbitrary::Arbitrary; use chrono::NaiveDate; -use ledger_core::{AccountType, Currency, EntryStatus, Ledger, Money, Side}; +use ledger_core::arap::{DocRef, DocumentLine}; +use ledger_core::{ + AccountType, Application, Currency, DraftState, EntryStatus, Ledger, Money, PaymentDetails, + SettleParams, Side, +}; use libfuzzer_sys::fuzz_target; use rust_decimal::Decimal; #[derive(Arbitrary, Debug, Clone, Copy)] enum Op { - AddAccount { code: u8, typ: u8, placeholder: bool }, - CreateEntry { day: u8 }, - AddLine { entry: u8, account: u8, debit: bool, amount: Amount }, - Pair { entry: u8, debit: u8, credit: u8, amount: Amount }, - AddLineFx { entry: u8, account: u8, debit: bool, foreign: Amount, rate: Amount }, - PostEntry { entry: u8 }, - ReverseEntry { entry: u8, day: u8 }, - CreatePeriod { start: u8, len: u8 }, - ClosePeriod { period: u8 }, + AddAccount { + code: u8, + typ: u8, + placeholder: bool, + }, + CreateEntry { + day: u8, + }, + AddLine { + entry: u8, + account: u8, + debit: bool, + amount: Amount, + }, + Pair { + entry: u8, + debit: u8, + credit: u8, + amount: Amount, + }, + AddLineFx { + entry: u8, + account: u8, + debit: bool, + foreign: Amount, + rate: Amount, + }, + PostEntry { + entry: u8, + }, + ReverseEntry { + entry: u8, + day: u8, + }, + CreatePeriod { + start: u8, + len: u8, + }, + ClosePeriod { + period: u8, + }, + CreateInvoice { + day: u8, + terms: u8, + amount: Amount, + }, + CreateBill { + day: u8, + terms: u8, + amount: Amount, + }, + /// A payment on a document (mod the documents) from the bank or a + /// fuzzed account, with a reference or not. + Pay { + doc: u8, + day: u8, + amount: Amount, + from: u8, + reference: bool, + }, + /// One payment settling two documents, the first with a discount. + Settle { + a: u8, + b: u8, + day: u8, + cash: Amount, + discount: Amount, + }, + WriteOff { + doc: u8, + day: u8, + amount: Amount, + }, + VoidDocument { + doc: u8, + day: u8, + }, + SetDraftState { + entry: u8, + awaiting: bool, + note: u8, + }, + DiscardDraft { + entry: u8, + }, } /// A decimal from raw parts: any mantissa up to 96 bits, any scale 0-28. @@ -42,7 +127,13 @@ impl Amount { // Mostly ordinary money, so entries balance and post. return Decimal::new(i64::from(self.lo % 1_000_000), 2); } - Decimal::from_parts(self.lo, self.mid, self.hi, self.negative, u32::from(self.scale % 29)) + Decimal::from_parts( + self.lo, + self.mid, + self.hi, + self.negative, + u32::from(self.scale % 29), + ) } } @@ -63,34 +154,85 @@ fn day(d: u8) -> NaiveDate { fuzz_target!(|ops: Vec| { let mut l = Ledger::new(Currency::USD); let mut entries: Vec = Vec::new(); + let mut docs: Vec = Vec::new(); + // The books' fixed parts: the control accounts documents post to, a bank, + // a customer and a vendor. + let _ = ( + l.add_control_account(&ledger_core::RECEIVABLES), + l.add_control_account(&ledger_core::PAYABLES), + l.add_control_account(&ledger_core::BAD_DEBTS), + ); + let bank = l + .add_account("1900", "Bank", AccountType::Asset, None, false) + .ok(); + let customer = l.add_customer("Fernhill Labs").ok(); + let vendor = l.add_vendor("Tobin Harlow Supply").ok(); + let doc_line = |a: Amount| { + vec![DocumentLine { + description: "work".into(), + amount: a.get(), + class_id: None, + account: None, + quantity: None, + unit_price: None, + }] + }; let pick = |v: &[u64], i: u8| v.get(i as usize % v.len().max(1)).copied(); for op in ops.into_iter().take(200) { let accounts: Vec = l.chart().all().map(|a| a.id).collect(); match op { - Op::AddAccount { code, typ, placeholder } => { - let _ = l.add_account(&format!("{}", 1000 + u32::from(code % 40)), "f", account_type(typ), None, placeholder); + Op::AddAccount { + code, + typ, + placeholder, + } => { + let _ = l.add_account( + &format!("{}", 1000 + u32::from(code % 40)), + "f", + account_type(typ), + None, + placeholder, + ); } Op::CreateEntry { day: d } => { if let Ok(id) = l.try_create_entry(day(d), "f") { entries.push(id); } } - Op::AddLine { entry, account, debit, amount } => { + Op::AddLine { + entry, + account, + debit, + amount, + } => { if let (Some(e), Some(a)) = (pick(&entries, entry), pick(&accounts, account)) { let side = if debit { Side::Debit } else { Side::Credit }; let _ = l.add_line(e, a, side, Money::new(amount.get(), Currency::USD)); } } - Op::Pair { entry, debit, credit, amount } => { - if let (Some(e), Some(a), Some(b)) = - (pick(&entries, entry), pick(&accounts, debit), pick(&accounts, credit)) - { + Op::Pair { + entry, + debit, + credit, + amount, + } => { + if let (Some(e), Some(a), Some(b)) = ( + pick(&entries, entry), + pick(&accounts, debit), + pick(&accounts, credit), + ) { let m = Money::new(amount.get(), Currency::USD); let _ = l.add_line(e, a, Side::Debit, m); let _ = l.add_line(e, b, Side::Credit, m); } } - Op::AddLineFx { entry, account, debit, foreign, rate } => { + Op::AddLineFx { + entry, + account, + debit, + foreign, + rate, + } => { if let (Some(e), Some(a)) = (pick(&entries, entry), pick(&accounts, account)) { let side = if debit { Side::Debit } else { Side::Credit }; let _ = l.add_line_fx(e, a, side, Currency::EUR, foreign.get(), rate.get()); @@ -111,6 +253,147 @@ fuzz_target!(|ops: Vec| { Op::CreatePeriod { start, len } => { let _ = l.create_period("p", day(start), day(start.saturating_add(len % 60))); } + Op::CreateInvoice { + day: d, + terms, + amount, + } => { + if let Some(c) = customer + && let Ok(id) = l.create_invoice_with( + c, + day(d), + day(d) + chrono::Days::new(u64::from(terms)), + doc_line(amount), + Default::default(), + ) + { + docs.push(DocRef::Invoice(id)); + } + } + Op::CreateBill { + day: d, + terms, + amount, + } => { + if let Some(v) = vendor + && let Ok(id) = l.create_bill_with( + v, + day(d), + day(d) + chrono::Days::new(u64::from(terms)), + doc_line(amount), + Default::default(), + ) + { + docs.push(DocRef::Bill(id)); + } + } + Op::Pay { + doc, + day: d, + amount, + from, + reference, + } => { + if !docs.is_empty() { + let cash = if from % 4 == 0 { + pick(&accounts, from) + } else { + bank + }; + let details = PaymentDetails { + instrument: None, + reference: reference.then(|| format!("trace {doc}{d}")), + }; + if let Some(cash) = cash { + let _ = match docs[doc as usize % docs.len()] { + DocRef::Invoice(id) => { + l.receive_payment_with(id, day(d), amount.get(), cash, &details) + } + DocRef::Bill(id) => { + l.pay_bill_with(id, day(d), amount.get(), cash, &details) + } + }; + } + } + } + Op::Settle { + a, + b, + day: d, + cash, + discount, + } => { + if let (false, Some(cash_account)) = (docs.is_empty(), bank) { + let first = docs[a as usize % docs.len()]; + let second = docs[b as usize % docs.len()]; + let party = match first { + DocRef::Invoice(id) => l.get_invoice(id).map(|i| i.customer_id), + DocRef::Bill(id) => l.get_bill(id).map(|v| v.vendor_id), + }; + if let Some(party) = party { + let _ = l.settle(SettleParams { + date: day(d), + description: "two documents, one payment".into(), + cash_account, + party, + applications: vec![ + Application { + doc: first, + cash: cash.get(), + discount: discount.get(), + discount_reason: Some("fuzz".into()), + }, + Application { + doc: second, + cash: cash.get(), + discount: Decimal::ZERO, + discount_reason: None, + }, + ], + fee: None, + instrument: None, + reference: None, + }); + } + } + } + Op::WriteOff { + doc, + day: d, + amount, + } => { + if !docs.is_empty() + && let DocRef::Invoice(id) = docs[doc as usize % docs.len()] + { + let _ = l.write_off(id, day(d), amount.get(), "uncollectible"); + } + } + Op::VoidDocument { doc, day: d } => { + if !docs.is_empty() { + let _ = l.void_document(docs[doc as usize % docs.len()], day(d), "duplicate"); + } + } + Op::SetDraftState { + entry, + awaiting, + note, + } => { + if let Some(e) = pick(&entries, entry) { + let state = if awaiting { + DraftState::AwaitingEvidence { + note: format!("receipt {note}"), + } + } else { + DraftState::Building + }; + let _ = l.set_draft_state(e, state); + } + } + Op::DiscardDraft { entry } => { + if let Some(e) = pick(&entries, entry) { + let _ = l.discard_draft(e, "fuzz"); + } + } Op::ClosePeriod { period } => { let ids: Vec = l.list_periods().iter().map(|p| p.id).collect(); if let Some(p) = pick(&ids, period) { @@ -122,13 +405,34 @@ fuzz_target!(|ops: Vec| { let tb = ledger_core::reports::trial_balance(&l); assert_eq!(tb.total_debits, tb.total_credits, "trial balance broken"); for e in l.posted_entries() { - assert!(e.validate(l.chart()).is_ok(), "posted entry {} invalid", e.id()); + assert!( + e.validate(l.chart()).is_ok(), + "posted entry {} invalid", + e.id() + ); + } + // The control accounts equal their documents, at any day (B-38): too + // large to compute is a refusal, never a difference. + for d in [0u8, 60, 199] { + if let Ok(checks) = l.control_check(day(d)) { + for c in checks { + assert_eq!( + c.difference, + Decimal::ZERO, + "{} differs from its documents", + c.role + ); + } + } } for id in 1..l.next_entry_id() { if let Some(e) = l.get_entry(id) && e.status() == EntryStatus::Draft { - assert!(l.closed_period_containing(e.date()).is_none(), "draft in a closed period"); + assert!( + l.closed_period_containing(e.date()).is_none(), + "draft in a closed period" + ); } } });