diff --git a/.devcontainer/scripts/check-code.sh b/.devcontainer/scripts/check-code.sh new file mode 100755 index 00000000000..9f59fc69304 --- /dev/null +++ b/.devcontainer/scripts/check-code.sh @@ -0,0 +1,31 @@ +#!/bin/bash -i +# SPDX-FileCopyrightText: 2026 Sequent Tech Inc +# +# SPDX-License-Identifier: AGPL-3.0-only + +set -e -o pipefail + +source .devcontainer/.env + +# check hasura formatting (prettier --check) +cd hasura/ +yarn && yarn prettify + +# check rust formatting +cd ../packages/ +cargo fmt -- --check + +# clippy for sequent-core (all features; warnings allowed) +export CARGO_TARGET_DIR="$(pwd)/rust-local-target" +cd ./sequent-core/ +cargo clippy --no-deps --all-features -- -A warnings + +# check Typescript lint & formatting +cd .. +yarn +yarn lint +yarn prettify + +# check java formatting (spotless) +cd ./keycloak-extensions +mvn invoker:run@run-spotless-check diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 32a6d9f985b..bc39820d4d0 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -19,6 +19,9 @@ on: - 'docs/docusaurus/**' - 'docs/api/graphql/**' - 'packages/graphql.schema.json' + - 'packages/**' + - '**/Cargo.toml' + - '**/Cargo.lock' branches: - main - release/* @@ -32,6 +35,9 @@ permissions: jobs: build: runs-on: gcp-selfhosted-ubuntu24 + env: + CARGO_TARGET_DIR: rust-local-target + steps: - uses: actions/checkout@v4 with: @@ -41,7 +47,15 @@ jobs: uses: actions/setup-node@v3 with: node-version: 22.x - + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Generate cargo docs for sequent-core + working-directory: ./packages + run: | + cargo doc -p sequent-core --all-features --no-deps + - name: Set BASE_URL and Build Docusaurus Preview id: build_docusaurus working-directory: ./docs/docusaurus @@ -64,7 +78,14 @@ jobs: yarn install --frozen-lockfile yarn generate-full-docs ls -lah docs/ - cp -r docs ../../docusaurus/build/graphql + mkdir -p ../../docusaurus/build/graphql + cp -r docs/* ../../docusaurus/build/graphql/ + + - name: Copy cargo doc output into published site + run: | + mkdir -p ./docs/docusaurus/build/rust + cp -r ./packages/${CARGO_TARGET_DIR}/doc/* ./docs/docusaurus/build/rust/ + ls -lah ./docs/docusaurus/build/rust - name: Install rsync run: sudo apt-get update && sudo apt-get install -y rsync diff --git a/.github/workflows/lint_prettify.yml b/.github/workflows/lint_prettify.yml index d414bacc4e4..5ae2f853868 100644 --- a/.github/workflows/lint_prettify.yml +++ b/.github/workflows/lint_prettify.yml @@ -77,7 +77,7 @@ jobs: dir: hasura/ rust-fmt: - name: Check Rust format + name: Check Rust format and linting runs-on: gcp-selfhosted-ubuntu24 steps: - name: Check out code @@ -87,11 +87,14 @@ jobs: uses: dtolnay/rust-toolchain@stable with: toolchain: 1.90.0 - components: rustfmt + components: rustfmt,clippy targets: x86_64-unknown-linux-musl - name: Check Rust code formatting run: cd packages/ && cargo fmt -- --check + + - name: Check Rust Sequent Core Clippy + run: cd packages/sequent-core && cargo clippy --no-deps --all-features -- -A warnings java-spotless: name: Check Java format diff --git a/.vscode/tasks.shared.json b/.vscode/tasks.shared.json index a085e448c17..12bafbf9d35 100644 --- a/.vscode/tasks.shared.json +++ b/.vscode/tasks.shared.json @@ -1071,6 +1071,30 @@ }, "problemMatcher": [] }, + { + "label": "check.code", + "command": "devenv", + "args": [ + "shell", + "bash", + "--", + "-c", + "./.devcontainer/scripts/check-code.sh" + ], + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "dedicated", + "showReuseMessage": true, + "clear": false + }, + "isBackground": false, + "runOptions": { + "runOn": "default" + }, + "problemMatcher": [] + }, { "label": "build.sequent-core", "type": "shell", diff --git a/README.md b/README.md index 4ba32759d67..b249341e4d5 100644 --- a/README.md +++ b/README.md @@ -390,3 +390,117 @@ when running in the terminal `devenv shell` or in the `Rebuild Container` vscode [cargo workspace]: https://doc.rust-lang.org/cargo/reference/workspaces.html [yarn workspace]: https://yarnpkg.com/features/workspaces + + +## 🧹 Linting & Code Quality Guide + +This project enforces strict linting rules using Rust built-in lints and Clippy to ensure high code quality, safety, and maintainability. + +### 🚀 Running Clippy + +Run lint checks locally with: + +```bash +cargo clippy --no-deps --all-features -- -A warnings +``` + +Explanation +--no-deps: Lints only this workspace (skips dependencies) +--all-features: Ensures all feature-gated code is checked +-- -A warnings: Allows warnings (only errors will fail) + +- Note: This is convenient for development, but CI should be stricter. + +### Lint Configuration + +Lint rules are defined in Cargo.toml under [lints.*]. + +#### Rustdoc Lints + +The `[lints.rustdoc]` section enforces documentation quality and correctness. + +- `missing_crate_level_docs = "deny"` + Requires top-level crate documentation (e.g. `//!` or `#![doc = include_str!(...)]`). + +- `broken_intra_doc_links = "deny"` + Prevents broken links between documentation items (e.g. invalid `[`TypeName`]` references). + +#### Rust Lints + +The `[lints.rust]` section configures core compiler lints to enforce safety, correctness, and good API design. + +- `missing_docs = "deny"` + Requires documentation for all public items (structs, enums, functions, etc.). + +- `unsafe_code = "forbid"` + Completely disallows usage of `unsafe` code anywhere in the crate. + +- `private_interfaces = "warn"` + Warns when a public item exposes private types in its interface. + +- `private_bounds = "warn"` + Warns when trait bounds reference private types that are not accessible to users. + +- `unnameable_types = "warn"` + Warns when types are exposed that cannot be named or used properly outside the crate. + +- `unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage,coverage_nightly)'] }` + Warns about unknown or unexpected `cfg` conditions and ensures only allowed configuration flags (like `coverage`) are used. + +#### Clippy Lints + +The `[lints.clippy]` section configures additional lint rules provided by Clippy. In this project, these rules are set very strictly to enforce safer, clearer, and more maintainable Rust code. + +- `missing_docs_in_private_items = "deny"` + Requires documentation not only for public items, but also for private structs, enums, functions, constants, and fields. + +- `missing_errors_doc = "deny"` + Requires a `# Errors` section in the documentation of functions that return `Result`. + +- `missing_panics_doc = "deny"` + Requires a `# Panics` section in the documentation of functions that may panic. + +- `doc_markdown = "deny"` + Enforces proper Markdown formatting in documentation comments. + +- `unwrap_used = "deny"` + Disallows use of `.unwrap()` because it may panic. + +- `panic = "deny"` + Disallows explicit `panic!()` calls. + +- `shadow_unrelated = "deny"` + Disallows reusing variable names in unrelated scopes when it may reduce readability. + +- `print_stdout = "deny"` + Disallows `println!` and similar macros for standard output. + +- `print_stderr = "deny"` + Disallows `eprintln!` and similar macros for standard error output. + +- `indexing_slicing = "deny"` + Disallows direct indexing and slicing like `arr[i]` because they may panic at runtime. + +- `missing_const_for_fn = "deny"` + Requires functions to be marked `const` when they can be. + +- `future_not_send = "deny"` + Warns when async code creates futures that are not `Send`, which can break multi-threaded async execution. + +- `arithmetic_side_effects = "deny"` + Flags arithmetic operations that may overflow or have unintended side effects. + +- `suspicious = "deny"` + Enables Clippy’s suspicious code lints to catch code that is likely incorrect. + +- `complexity = "deny"` + Enables lints that detect overly complex code patterns and suggests simpler alternatives. + +- `style = "deny"` + Enforces idiomatic Rust style. + +- `perf = "deny"` + Enables performance-related lints to catch inefficient code patterns. + +- `pedantic = "deny"` + Enables a very strict set of extra Clippy lints for code quality, readability, and correctness. These are more opinionated than the default lints. \ No newline at end of file diff --git a/docs/docusaurus/docs/rust-docs.md b/docs/docusaurus/docs/rust-docs.md new file mode 100644 index 00000000000..5429dfef870 --- /dev/null +++ b/docs/docusaurus/docs/rust-docs.md @@ -0,0 +1,25 @@ +--- +id: rust_docs +title: Rust Docs +sidebar_position: -1 +--- + +import useBaseUrl from '@docusaurus/useBaseUrl'; + + + +export const RustCrateLink = ({ path, children }) => { + const url = useBaseUrl(path); + return ( + + {children} + + ); +}; + +### Crates + +- sequent-core diff --git a/docs/docusaurus/docusaurus.config.js b/docs/docusaurus/docusaurus.config.js index e62aa7fede2..98e284b446f 100644 --- a/docs/docusaurus/docusaurus.config.js +++ b/docs/docusaurus/docusaurus.config.js @@ -37,6 +37,33 @@ const config = { sidebarPath: require.resolve('./sidebars.js'), editUrl: 'https://github.com/sequentech/step/edit/main/docs/docusaurus', + async sidebarItemsGenerator({ + defaultSidebarItemsGenerator, + ...args + }) { + const sidebarItems = await defaultSidebarItemsGenerator(args); + + function removeDoc(items) { + return items + .filter((item) => { + if (item.type === 'doc' && item.id === 'rust_docs') { + return false; + } + return true; + }) + .map((item) => { + if (item.type === 'category' && item.items) { + return { + ...item, + items: removeDoc(item.items), + }; + } + return item; + }); + } + + return removeDoc(sidebarItems); + }, }, // completely remove the blog blog: false, @@ -78,6 +105,12 @@ const config = { position: 'left', target: '_blank', }, + { + type: 'doc', + docId: 'rust_docs', + label: 'Rust Docs', + position: 'left', + }, { href: 'https://github.com/sequentech', label: 'GitHub', diff --git a/docs/docusaurus/yarn.lock b/docs/docusaurus/yarn.lock index 0fefe5705b6..33565c55d65 100644 --- a/docs/docusaurus/yarn.lock +++ b/docs/docusaurus/yarn.lock @@ -1089,24 +1089,16 @@ resolved "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-2.0.5.tgz" integrity sha512-p1ko5eHgV+MgXFVa4STPKpvPxr6ReS8oS2jzTukjR74i5zJNyWO1ZM1m8YKBXnzDKWfBN1ztLYlHxbVemDD88A== -"@csstools/color-helpers@^5.0.2": - version "5.0.2" - resolved "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz" - integrity sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA== +"@csstools/color-helpers@^5.1.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@csstools/color-helpers/-/color-helpers-5.1.0.tgz#106c54c808cabfd1ab4c602d8505ee584c2996ef" + integrity sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA== "@csstools/css-calc@^2.1.4": version "2.1.4" resolved "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz" integrity sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ== -"@csstools/css-color-parser@^3.0.10": - version "3.0.10" - resolved "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz" - integrity sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg== - dependencies: - "@csstools/color-helpers" "^5.0.2" - "@csstools/css-calc" "^2.1.4" - "@csstools/css-color-parser@^3.1.0": version "3.1.0" resolved "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz" @@ -3333,7 +3325,7 @@ acorn-walk@^8.0.0: dependencies: acorn "^8.11.0" -acorn@^8.0.0, acorn@^8.0.4, acorn@^8.11.0, acorn@^8.14.0, acorn@^8.15.0: +acorn@^8.0.0, acorn@^8.0.4, acorn@^8.11.0, acorn@^8.15.0: version "8.15.0" resolved "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz" integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== @@ -4177,10 +4169,10 @@ css-declaration-sorter@^7.2.0: resolved "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.3.1.tgz" integrity sha512-gz6x+KkgNCjxq3Var03pRYLhyNfwhkKF1g/yoLgDNtFvVu0/fOLV9C8fFEZRjACp/XQLumjAYo7JVjzH3wLbxA== -css-has-pseudo@^7.0.2: - version "7.0.2" - resolved "https://registry.npmjs.org/css-has-pseudo/-/css-has-pseudo-7.0.2.tgz" - integrity sha512-nzol/h+E0bId46Kn2dQH5VElaknX2Sr0hFuB/1EomdC7j+OISt2ZzK7EHX9DZDY53WbIVAR7FYKSO2XnSf07MQ== +css-has-pseudo@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/css-has-pseudo/-/css-has-pseudo-7.0.3.tgz#a5ee2daf5f70a2032f3cefdf1e36e7f52a243873" + integrity sha512-oG+vKuGyqe/xvEMoxAQrhi7uY16deJR3i7wwhBerVrGQKSqUC5GiOVxTpM9F9B9hw0J+eKeOWLH7E9gZ1Dr5rA== dependencies: "@csstools/selector-specificity" "^5.0.0" postcss-selector-parser "^7.0.0" @@ -8575,10 +8567,10 @@ reflect-metadata@^0.2.2: resolved "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz" integrity sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q== -regenerate-unicode-properties@^10.2.0: - version "10.2.0" - resolved "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz" - integrity sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA== +regenerate-unicode-properties@^10.2.2: + version "10.2.2" + resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz#aa113812ba899b630658c7623466be71e1f86f66" + integrity sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g== dependencies: regenerate "^1.4.2" @@ -9038,10 +9030,10 @@ shebang-regex@^3.0.0: resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== -shell-quote@^1.8.1: - version "1.8.2" - resolved "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz" - integrity sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA== +shell-quote@^1.8.3: + version "1.8.3" + resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.3.tgz#55e40ef33cf5c689902353a3d8cd1a6725f08b4b" + integrity sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw== side-channel-list@^1.0.0: version "1.0.0" @@ -9542,10 +9534,10 @@ unicode-match-property-ecmascript@^2.0.0: unicode-canonical-property-names-ecmascript "^2.0.0" unicode-property-aliases-ecmascript "^2.0.0" -unicode-match-property-value-ecmascript@^2.1.0: - version "2.2.0" - resolved "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz" - integrity sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg== +unicode-match-property-value-ecmascript@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz#65a7adfad8574c219890e219285ce4c64ed67eaa" + integrity sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg== unicode-property-aliases-ecmascript@^2.0.0: version "2.2.0" diff --git a/packages/admin-portal/rust/sequent-core-0.1.0.tgz b/packages/admin-portal/rust/sequent-core-0.1.0.tgz index bc6c9361075..0e2e7bfc948 100644 Binary files a/packages/admin-portal/rust/sequent-core-0.1.0.tgz and b/packages/admin-portal/rust/sequent-core-0.1.0.tgz differ diff --git a/packages/ballot-verifier/rust/sequent-core-0.1.0.tgz b/packages/ballot-verifier/rust/sequent-core-0.1.0.tgz index bc6c9361075..0e2e7bfc948 100644 Binary files a/packages/ballot-verifier/rust/sequent-core-0.1.0.tgz and b/packages/ballot-verifier/rust/sequent-core-0.1.0.tgz differ diff --git a/packages/sequent-core/Cargo.toml b/packages/sequent-core/Cargo.toml index 7ee9de08c45..01339c63b0a 100644 --- a/packages/sequent-core/Cargo.toml +++ b/packages/sequent-core/Cargo.toml @@ -10,6 +10,9 @@ authors = [ ] license = "AGPL-3.0-only" +[package.metadata.docs.rs] +all-features = true + [lib] crate-type = ["cdylib", "rlib"] @@ -154,3 +157,36 @@ sqlite = ["dep:tokio", "dep:rusqlite"] [target.'cfg(target_arch = "wasm32")'.dependencies] ring = { version = "0.17", features = ["wasm32_unknown_unknown_js"] } + + +[lints.rustdoc] +missing_crate_level_docs = "deny" +broken_intra_doc_links = "deny" + +[lints.rust] +missing_docs = "deny" +unsafe_code = "forbid" +private_interfaces = "warn" +private_bounds = "warn" +unnameable_types = "warn" +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage,coverage_nightly)'] } + +[lints.clippy] +missing_docs_in_private_items = "deny" +missing_errors_doc = "deny" +missing_panics_doc = "deny" +doc_markdown = "deny" +unwrap_used = "deny" +panic = "deny" +shadow_unrelated = "deny" +print_stdout = "deny" +print_stderr = "deny" +indexing_slicing = "deny" +missing_const_for_fn = "deny" +future_not_send = "deny" +arithmetic_side_effects = "deny" +suspicious = "deny" +complexity = "deny" +style = "deny" +perf = "deny" +pedantic = "deny" \ No newline at end of file diff --git a/packages/sequent-core/README.md b/packages/sequent-core/README.md index f81ce650672..0e1de3649bb 100644 --- a/packages/sequent-core/README.md +++ b/packages/sequent-core/README.md @@ -24,6 +24,15 @@ cd packages/sequent-core cargo build ``` +## Generate Rust API docs + +Build the full `rustdoc` output with every feature enabled: + +```bash +cd packages/sequent-core +cargo doc --all-features --no-deps +``` + ## Generate javascript package ```bash diff --git a/packages/sequent-core/flake.lock b/packages/sequent-core/flake.lock index a60268ee275..ad1bb29fd5c 100644 --- a/packages/sequent-core/flake.lock +++ b/packages/sequent-core/flake.lock @@ -6,19 +6,52 @@ "devenv" ], "flake-compat": [ - "devenv" + "devenv", + "flake-compat" ], "git-hooks": [ - "devenv" + "devenv", + "git-hooks" + ], + "nixpkgs": [ + "devenv", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1767714506, + "narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=", + "owner": "cachix", + "repo": "cachix", + "rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "latest", + "repo": "cachix", + "type": "github" + } + }, + "cachix_2": { + "inputs": { + "devenv": [ + "devenv", + "crate2nix" + ], + "flake-compat": [ + "devenv", + "crate2nix" ], + "git-hooks": "git-hooks", "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1728672398, - "narHash": "sha256-KxuGSoVUFnQLB2ZcYODW7AVPAh9JqRlD5BrfsC/Q4qs=", + "lastModified": 1767714506, + "narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=", "owner": "cachix", "repo": "cachix", - "rev": "aac51f698309fd0f381149214b7eee213c66ef0a", + "rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9", "type": "github" }, "original": { @@ -28,20 +61,112 @@ "type": "github" } }, + "cachix_3": { + "inputs": { + "devenv": [ + "devenv", + "crate2nix", + "crate2nix_stable" + ], + "flake-compat": [ + "devenv", + "crate2nix", + "crate2nix_stable" + ], + "git-hooks": "git-hooks_2", + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1767714506, + "narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=", + "owner": "cachix", + "repo": "cachix", + "rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "latest", + "repo": "cachix", + "type": "github" + } + }, + "crate2nix": { + "inputs": { + "cachix": "cachix_2", + "crate2nix_stable": "crate2nix_stable", + "devshell": "devshell_2", + "flake-compat": "flake-compat_2", + "flake-parts": "flake-parts_2", + "nix-test-runner": "nix-test-runner_2", + "nixpkgs": [ + "devenv", + "nixpkgs" + ], + "pre-commit-hooks": "pre-commit-hooks_2" + }, + "locked": { + "lastModified": 1773440526, + "narHash": "sha256-OcX1MYqUdoalY3/vU67PEx8m6RvqGxX0LwKonjzXn7I=", + "owner": "nix-community", + "repo": "crate2nix", + "rev": "e697d3049c909580128caa856ab8eb709556a97b", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "crate2nix", + "type": "github" + } + }, + "crate2nix_stable": { + "inputs": { + "cachix": "cachix_3", + "crate2nix_stable": [ + "devenv", + "crate2nix", + "crate2nix_stable" + ], + "devshell": "devshell", + "flake-compat": "flake-compat", + "flake-parts": "flake-parts", + "nix-test-runner": "nix-test-runner", + "nixpkgs": "nixpkgs_3", + "pre-commit-hooks": "pre-commit-hooks" + }, + "locked": { + "lastModified": 1769627083, + "narHash": "sha256-SUuruvw1/moNzCZosHaa60QMTL+L9huWdsCBN6XZIic=", + "owner": "nix-community", + "repo": "crate2nix", + "rev": "7c33e664668faecf7655fa53861d7a80c9e464a2", + "type": "github" + }, + "original": { + "owner": "nix-community", + "ref": "0.15.0", + "repo": "crate2nix", + "type": "github" + } + }, "devenv": { "inputs": { "cachix": "cachix", - "flake-compat": "flake-compat", - "git-hooks": "git-hooks", + "crate2nix": "crate2nix", + "flake-compat": "flake-compat_3", + "flake-parts": "flake-parts_3", + "git-hooks": "git-hooks_3", "nix": "nix", - "nixpkgs": "nixpkgs_3" + "nixd": "nixd", + "nixpkgs": "nixpkgs_4", + "rust-overlay": "rust-overlay" }, "locked": { - "lastModified": 1738232507, - "narHash": "sha256-AFzzs8DypasqcQZ3vLPxh6uO/ZniPWKk4bJkSAUpgTg=", + "lastModified": 1773895389, + "narHash": "sha256-U8bcmX8F6sherpuDJmAECrfMCcdzBwYZCrYvBH26fd4=", "owner": "cachix", "repo": "devenv", - "rev": "8b611a15df98b6225eec53be7d24bf39d3ab494d", + "rev": "e183f79bf67ace9c21212aa83363a28c941dc4ae", "type": "github" }, "original": { @@ -50,14 +175,87 @@ "type": "github" } }, + "devshell": { + "inputs": { + "nixpkgs": [ + "devenv", + "crate2nix", + "crate2nix_stable", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1768818222, + "narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=", + "owner": "numtide", + "repo": "devshell", + "rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "devshell", + "type": "github" + } + }, + "devshell_2": { + "inputs": { + "nixpkgs": [ + "devenv", + "crate2nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1768818222, + "narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=", + "owner": "numtide", + "repo": "devshell", + "rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "devshell", + "type": "github" + } + }, "flake-compat": { + "locked": { + "lastModified": 1733328505, + "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", + "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", + "revCount": 69, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz" + } + }, + "flake-compat_2": { + "locked": { + "lastModified": 1733328505, + "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", + "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", + "revCount": 69, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz" + } + }, + "flake-compat_3": { "flake": false, "locked": { - "lastModified": 1696426674, - "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", + "lastModified": 1767039857, + "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", "owner": "edolstra", "repo": "flake-compat", - "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", "type": "github" }, "original": { @@ -66,14 +264,14 @@ "type": "github" } }, - "flake-compat_2": { + "flake-compat_4": { "flake": false, "locked": { - "lastModified": 1733328505, - "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", + "lastModified": 1767039857, + "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", "owner": "edolstra", "repo": "flake-compat", - "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", "type": "github" }, "original": { @@ -86,16 +284,60 @@ "inputs": { "nixpkgs-lib": [ "devenv", - "nix", + "crate2nix", + "crate2nix_stable", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1768135262, + "narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-parts_2": { + "inputs": { + "nixpkgs-lib": [ + "devenv", + "crate2nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1768135262, + "narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-parts_3": { + "inputs": { + "nixpkgs-lib": [ + "devenv", "nixpkgs" ] }, "locked": { - "lastModified": 1712014858, - "narHash": "sha256-sB4SWl2lX95bExY2gMFG5HIzvva5AVMJd4Igm+GpZNw=", + "lastModified": 1772408722, + "narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "9126214d0a59633752a136528f5f3b9aa8565b7d", + "rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3", "type": "github" }, "original": { @@ -125,20 +367,83 @@ "git-hooks": { "inputs": { "flake-compat": [ - "devenv" + "devenv", + "crate2nix", + "cachix", + "flake-compat" ], "gitignore": "gitignore", + "nixpkgs": [ + "devenv", + "crate2nix", + "cachix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1765404074, + "narHash": "sha256-+ZDU2d+vzWkEJiqprvV5PR26DVFN2vgddwG5SnPZcUM=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "2d6f58930fbcd82f6f9fd59fb6d13e37684ca529", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "git-hooks_2": { + "inputs": { + "flake-compat": [ + "devenv", + "crate2nix", + "crate2nix_stable", + "cachix", + "flake-compat" + ], + "gitignore": "gitignore_2", + "nixpkgs": [ + "devenv", + "crate2nix", + "crate2nix_stable", + "cachix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1765404074, + "narHash": "sha256-+ZDU2d+vzWkEJiqprvV5PR26DVFN2vgddwG5SnPZcUM=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "2d6f58930fbcd82f6f9fd59fb6d13e37684ca529", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "git-hooks_3": { + "inputs": { + "flake-compat": [ + "devenv", + "flake-compat" + ], + "gitignore": "gitignore_5", "nixpkgs": [ "devenv", "nixpkgs" ] }, "locked": { - "lastModified": 1737301351, - "narHash": "sha256-2UNmLCKORvdBRhPGI8Vx0b6l7M8/QBey/nHLIxOl4jE=", + "lastModified": 1772893680, + "narHash": "sha256-JDqZMgxUTCq85ObSaFw0HhE+lvdOre1lx9iI6vYyOEs=", "owner": "cachix", "repo": "git-hooks.nix", - "rev": "15a87cedeb67e3dbc8d2f7b9831990dffcf4e69f", + "rev": "8baab586afc9c9b57645a734c820e4ac0a604af9", "type": "github" }, "original": { @@ -151,6 +456,8 @@ "inputs": { "nixpkgs": [ "devenv", + "crate2nix", + "cachix", "git-hooks", "nixpkgs" ] @@ -169,62 +476,205 @@ "type": "github" } }, - "libgit2": { - "flake": false, + "gitignore_2": { + "inputs": { + "nixpkgs": [ + "devenv", + "crate2nix", + "crate2nix_stable", + "cachix", + "git-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "gitignore_3": { + "inputs": { + "nixpkgs": [ + "devenv", + "crate2nix", + "crate2nix_stable", + "pre-commit-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "gitignore_4": { + "inputs": { + "nixpkgs": [ + "devenv", + "crate2nix", + "pre-commit-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "gitignore_5": { + "inputs": { + "nixpkgs": [ + "devenv", + "git-hooks", + "nixpkgs" + ] + }, "locked": { - "lastModified": 1697646580, - "narHash": "sha256-oX4Z3S9WtJlwvj0uH9HlYcWv+x1hqp8mhXl7HsLu2f0=", - "owner": "libgit2", - "repo": "libgit2", - "rev": "45fd9ed7ae1a9b74b957ef4f337bc3c8b3df01b5", + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", "type": "github" }, "original": { - "owner": "libgit2", - "repo": "libgit2", + "owner": "hercules-ci", + "repo": "gitignore.nix", "type": "github" } }, "nix": { "inputs": { "flake-compat": [ - "devenv" + "devenv", + "flake-compat" + ], + "flake-parts": [ + "devenv", + "flake-parts" + ], + "git-hooks-nix": [ + "devenv", + "git-hooks" + ], + "nixpkgs": [ + "devenv", + "nixpkgs" ], - "flake-parts": "flake-parts", - "libgit2": "libgit2", - "nixpkgs": "nixpkgs_2", "nixpkgs-23-11": [ "devenv" ], "nixpkgs-regression": [ "devenv" - ], - "pre-commit-hooks": [ - "devenv" ] }, "locked": { - "lastModified": 1734114420, - "narHash": "sha256-n52PUzub5jZWc8nI/sR7UICOheU8rNA+YZ73YaHeCBg=", - "owner": "domenkozar", + "lastModified": 1773859178, + "narHash": "sha256-giDCUHkYOE6YJrhHyesQMmruAv6C0uFCZRIRYV09NC0=", + "owner": "cachix", "repo": "nix", - "rev": "bde6a1a0d1f2af86caa4d20d23eca019f3d57eee", + "rev": "d01902c2a7190060f34939b7ab445a67bb892731", "type": "github" }, "original": { - "owner": "domenkozar", - "ref": "devenv-2.24", + "owner": "cachix", + "ref": "devenv-2.32", "repo": "nix", "type": "github" } }, + "nix-test-runner": { + "flake": false, + "locked": { + "lastModified": 1588761593, + "narHash": "sha256-FKJykltAN/g3eIceJl4SfDnnyuH2jHImhMrXS2KvGIs=", + "owner": "stoeffel", + "repo": "nix-test-runner", + "rev": "c45d45b11ecef3eb9d834c3b6304c05c49b06ca2", + "type": "github" + }, + "original": { + "owner": "stoeffel", + "repo": "nix-test-runner", + "type": "github" + } + }, + "nix-test-runner_2": { + "flake": false, + "locked": { + "lastModified": 1588761593, + "narHash": "sha256-FKJykltAN/g3eIceJl4SfDnnyuH2jHImhMrXS2KvGIs=", + "owner": "stoeffel", + "repo": "nix-test-runner", + "rev": "c45d45b11ecef3eb9d834c3b6304c05c49b06ca2", + "type": "github" + }, + "original": { + "owner": "stoeffel", + "repo": "nix-test-runner", + "type": "github" + } + }, + "nixd": { + "inputs": { + "flake-parts": [ + "devenv", + "flake-parts" + ], + "nixpkgs": [ + "devenv", + "nixpkgs" + ], + "treefmt-nix": "treefmt-nix" + }, + "locked": { + "lastModified": 1773634079, + "narHash": "sha256-49qb4QNMv77VOeEux+sMd0uBhPvvHgVc0r938Bulvbo=", + "owner": "nix-community", + "repo": "nixd", + "rev": "8ecf93d4d93745e05ea53534e8b94f5e9506e6bd", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixd", + "type": "github" + } + }, "nixpkgs": { "locked": { - "lastModified": 1730531603, - "narHash": "sha256-Dqg6si5CqIzm87sp57j5nTaeBbWhHFaVyG7V6L8k3lY=", + "lastModified": 1765186076, + "narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "7ffd9ae656aec493492b44d0ddfb28e79a1ea25d", + "rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8", "type": "github" }, "original": { @@ -234,29 +684,65 @@ "type": "github" } }, + "nixpkgs-src": { + "flake": false, + "locked": { + "lastModified": 1773597492, + "narHash": "sha256-hQ284SkIeNaeyud+LS0WVLX+WL2rxcVZLFEaK0e03zg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "a07d4ce6bee67d7c838a8a5796e75dff9caa21ef", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs_2": { "locked": { - "lastModified": 1717432640, - "narHash": "sha256-+f9c4/ZX5MWDOuB1rKoWj+lBNm0z0rs4CK47HBLxy1o=", + "lastModified": 1765186076, + "narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "88269ab3044128b7c2f4c7d68448b2fb50456870", + "rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8", "type": "github" }, "original": { "owner": "NixOS", - "ref": "release-24.05", + "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" } }, "nixpkgs_3": { "locked": { - "lastModified": 1716977621, - "narHash": "sha256-Q1UQzYcMJH4RscmpTkjlgqQDX5yi1tZL0O345Ri6vXQ=", + "lastModified": 1769433173, + "narHash": "sha256-Gf1dFYgD344WZ3q0LPlRoWaNdNQq8kSBDLEWulRQSEs=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "13b0f9e6ac78abbbb736c635d87845c4f4bee51b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_4": { + "inputs": { + "nixpkgs-src": "nixpkgs-src" + }, + "locked": { + "lastModified": 1773704619, + "narHash": "sha256-LKtmit8Sr81z8+N2vpIaN/fyiQJ8f7XJ6tMSKyDVQ9s=", "owner": "cachix", "repo": "devenv-nixpkgs", - "rev": "4267e705586473d3e5c8d50299e71503f16a6fb6", + "rev": "906534d75b0e2fe74a719559dfb1ad3563485f43", "type": "github" }, "original": { @@ -266,13 +752,13 @@ "type": "github" } }, - "nixpkgs_4": { + "nixpkgs_5": { "locked": { - "lastModified": 1754937576, - "narHash": "sha256-3sWA5WJybUE16kIMZ3+uxcxKZY/JRR4DFBqLdSLBo7w=", + "lastModified": 1767313136, + "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "ddae11e58c0c345bf66efbddbf2192ed0e58f896", + "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", "type": "github" }, "original": { @@ -281,13 +767,13 @@ "type": "indirect" } }, - "nixpkgs_5": { + "nixpkgs_6": { "locked": { - "lastModified": 1736320768, - "narHash": "sha256-nIYdTAiKIGnFNugbomgBJR+Xv5F1ZQU+HfaBqJKroC0=", + "lastModified": 1744536153, + "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "4bc9c909d9ac828a039f288cf872d16d38185db8", + "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", "type": "github" }, "original": { @@ -297,25 +783,104 @@ "type": "github" } }, + "pre-commit-hooks": { + "inputs": { + "flake-compat": [ + "devenv", + "crate2nix", + "crate2nix_stable", + "flake-compat" + ], + "gitignore": "gitignore_3", + "nixpkgs": [ + "devenv", + "crate2nix", + "crate2nix_stable", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1769069492, + "narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=", + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "type": "github" + } + }, + "pre-commit-hooks_2": { + "inputs": { + "flake-compat": [ + "devenv", + "crate2nix", + "flake-compat" + ], + "gitignore": "gitignore_4", + "nixpkgs": [ + "devenv", + "crate2nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1769069492, + "narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=", + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "type": "github" + } + }, "root": { "inputs": { "devenv": "devenv", - "flake-compat": "flake-compat_2", + "flake-compat": "flake-compat_4", "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs_4", - "rust-overlay": "rust-overlay" + "nixpkgs": "nixpkgs_5", + "rust-overlay": "rust-overlay_2" } }, "rust-overlay": { "inputs": { - "nixpkgs": "nixpkgs_5" + "nixpkgs": [ + "devenv", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1773630837, + "narHash": "sha256-zJhgAGnbVKeBMJOb9ctZm4BGS/Rnrz+5lfSXTVah4HQ=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "f600ea449c7b5bb596fa1cf21c871cc5b9e31316", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "rust-overlay_2": { + "inputs": { + "nixpkgs": "nixpkgs_6" }, "locked": { - "lastModified": 1738263856, - "narHash": "sha256-u9nE8Gwc+B3AIy12ZrXXxlFdBouNcB8T6Kf6jX1n2m0=", + "lastModified": 1773889863, + "narHash": "sha256-tSsmZOHBgq4qfu5MNCAEsKZL1cI4avNLw2oUTXWeb74=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "9efb8a111c32f767d158bba7fa130ae0fb5cc4ba", + "rev": "dbfd51be2692cb7022e301d14c139accb4ee63f0", "type": "github" }, "original": { @@ -338,6 +903,28 @@ "repo": "default", "type": "github" } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "devenv", + "nixd", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1772660329, + "narHash": "sha256-IjU1FxYqm+VDe5qIOxoW+pISBlGvVApRjiw/Y/ttJzY=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "3710e0e1218041bbad640352a0440114b1e10428", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } } }, "root": "root", diff --git a/packages/sequent-core/flake.nix b/packages/sequent-core/flake.nix index f86011669fd..e2e6de2de39 100644 --- a/packages/sequent-core/flake.nix +++ b/packages/sequent-core/flake.nix @@ -28,7 +28,7 @@ configureRustTargets = targets : pkgs .rust-bin .nightly - ."2025-01-29" + ."2025-12-08" .default .override { extensions = [ "rust-src" ]; @@ -68,12 +68,16 @@ pkgs.wasm-bindgen-cli pkgs.libiconv pkgs.m4 + pkgs.pkg-config # Add all the necessary LLVM/Clang packages pkgs.llvmPackages_19.clang-unwrapped pkgs.llvmPackages_19.llvm pkgs.llvmPackages_19.libclang ]; + buildInputs = [ + pkgs.openssl + ]; buildPhase = '' echo 'Build: wasm-pack build' wasm-pack build --out-name index --release --target web --features=wasmtest,default_features @@ -95,6 +99,10 @@ src = ./.; nativeBuildInputs = [ rust-system + pkgs.pkg-config + ]; + buildInputs = [ + pkgs.openssl ]; }; # sequent-core is the default package @@ -118,27 +126,36 @@ # Add these two lines for browser testing firefox geckodriver + + openssl ]; shellHook = '' - export CC=${pkgs.llvmPackages_19.clang-unwrapped}/bin/clang - export CXX=${pkgs.llvmPackages_19.clang-unwrapped}/bin/clang++ + export CC=${pkgs.llvmPackages_19.clang}/bin/clang + export CXX=${pkgs.llvmPackages_19.clang}/bin/clang++ export AR=${pkgs.llvmPackages_19.llvm}/bin/llvm-ar export CC_wasm32_unknown_unknown=${pkgs.llvmPackages_19.clang-unwrapped}/bin/clang - - + export CXX_wasm32_unknown_unknown=${pkgs.llvmPackages_19.clang-unwrapped}/bin/clang++ + + export OPENSSL_DIR=${pkgs.openssl.dev} + export OPENSSL_LIB_DIR=${pkgs.openssl.out}/lib + export OPENSSL_INCLUDE_DIR=${pkgs.openssl.dev}/include + export PKG_CONFIG_PATH=${pkgs.openssl.dev}/lib/pkgconfig:$PKG_CONFIG_PATH + # Nix hardening flags are not supported when compiling C code for WebAssembly export NIX_HARDENING_ENABLE="" - - + # Set up the clang resource directory properly CLANG_MAJOR_VERSION="19" CLANG_RESOURCE_DIR="${pkgs.llvmPackages_19.clang-unwrapped}/lib/clang/$CLANG_MAJOR_VERSION" - - + # Use libclang's include directory which has the standard headers LIBCLANG_INCLUDE="${pkgs.llvmPackages_19.libclang.lib}/lib/clang/$CLANG_MAJOR_VERSION/include" - - + export CFLAGS_wasm32_unknown_unknown="-isystem $LIBCLANG_INCLUDE -resource-dir $CLANG_RESOURCE_DIR" export CPPFLAGS="-isystem $LIBCLANG_INCLUDE -resource-dir $CLANG_RESOURCE_DIR" - - + # Debug: Print the paths to verify they exist + echo "Using rustc: $(rustc --version)" echo "Clang resource dir: $CLANG_RESOURCE_DIR" echo "Libclang include dir: $LIBCLANG_INCLUDE" if [ -f "$LIBCLANG_INCLUDE/stddef.h" ]; then @@ -150,4 +167,4 @@ }; } ); -} \ No newline at end of file +} diff --git a/packages/sequent-core/src/ballot.rs b/packages/sequent-core/src/ballot.rs index b110f11328d..260a5486480 100644 --- a/packages/sequent-core/src/ballot.rs +++ b/packages/sequent-core/src/ballot.rs @@ -5,9 +5,6 @@ #![allow(dead_code)] use crate::encrypt::hash_ballot_style; use crate::error::BallotError; -use crate::plaintext::{ - DecodedVoteChoice, DecodedVoteContest, PreferencialOrderErrorType, -}; use crate::serialization::base64::{Base64Deserialize, Base64Serialize}; use crate::serialization::deserialize_with_path::deserialize_value; use crate::types::ceremonies::TallySessionResolutionData; @@ -23,7 +20,6 @@ use chrono::Utc; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_path_to_error::Error; -use std::hash::Hash; use std::ops::Deref; use std::{collections::HashMap, default::Default}; use strand::elgamal::Ciphertext; @@ -35,16 +31,23 @@ use strand::zkp::Schnorr; use strand::{backend::ristretto::RistrettoCtx, context::Ctx}; use strum_macros::{Display, EnumString, IntoStaticStr}; +/// Version number for ballot. pub const TYPES_VERSION: u32 = 1; +/// Internationalized content map, keyed by language code. pub type I18nContent> = HashMap; +/// Annotations for ballots or contests, as key-value pairs. pub type Annotations = HashMap; #[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone)] +/// Represents a choice in a contest. pub struct ReplicationChoice { + /// Encrypted choice. pub ciphertext: Ciphertext, + /// Plaintext value of the choice. pub plaintext: C::P, + /// Randomness used for encryption. pub randomness: C::X, } @@ -59,15 +62,22 @@ pub struct ReplicationChoice { Debug, Clone, )] +/// Configuration for a public key. pub struct PublicKeyConfig { + /// Public key as a string. pub public_key: String, + /// Whether this is a demo key. pub is_demo: bool, } #[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone)] +/// An auditable contest on a ballot. pub struct AuditableBallotContest { + /// Contest identifier. pub contest_id: String, + /// The selected choice for the contest. pub choice: ReplicationChoice, + /// Proof for the choice. pub proof: Schnorr, } /* @@ -81,17 +91,29 @@ pub struct RawAuditableBallot { }*/ #[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +/// An auditable ballot. pub struct AuditableBallot { + /// Ballot version. pub version: u32, + /// Date the ballot was issued. pub issue_date: String, + /// Ballot style configuration. pub config: BallotStyle, + /// Serialized contests. pub contests: Vec, // Vec>, + /// Hash of the ballot. pub ballot_hash: String, + /// Voter's public signing key (if present). pub voter_signing_pk: Option, + /// Voter's ballot signature (if present). pub voter_ballot_signature: Option, } impl AuditableBallot { + /// Deserialize the stored contest strings into a vector of contests ballot. + /// + /// # Errors + /// Returns `BallotError::Serialization` if any contest ballot fails to deserialize. pub fn deserialize_contests( &self, ) -> Result>, BallotError> { @@ -107,14 +129,17 @@ impl AuditableBallot { .collect() } + /// Serialize a slice of contests ballot into base64 strings. + /// + /// # Errors + /// Returns `BallotError::Serialization` if serialization fails. pub fn serialize_contests( - contests: &Vec>, + contests: &[AuditableBallotContest], ) -> Result, BallotError> { contests - .clone() - .into_iter() + .iter() .map(|auditable_ballot_contest| { - Base64Serialize::serialize(&auditable_ballot_contest) + Base64Serialize::serialize(auditable_ballot_contest) }) .collect::>>() .into_iter() @@ -123,42 +148,66 @@ impl AuditableBallot { } #[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone)] +/// Contest data for hashable ballots. pub struct HashableBallotContest { + /// Contest identifier. pub contest_id: String, + /// Encrypted contest data. pub ciphertext: Ciphertext, + /// Proof for the contest. pub proof: Schnorr, } #[derive( BorshSerialize, Serialize, Deserialize, PartialEq, Eq, Debug, Clone, )] +/// Hashable ballot. pub struct HashableBallot { + /// Ballot version. pub version: u32, + /// Date the ballot was issued. pub issue_date: String, + /// Serialized contests. pub contests: Vec, // Vec>, + /// Ballot style configuration as a string. pub config: String, + /// Hash of the ballot style. pub ballot_style_hash: String, } #[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +/// Signed hashable ballot. pub struct SignedHashableBallot { + /// Ballot version. pub version: u32, + /// Date the ballot was issued. pub issue_date: String, + /// Serialized contests (base64-encoded). pub contests: Vec, + /// Configuration as a string. pub config: String, + /// Hash of the ballot style. pub ballot_style_hash: String, + /// Voter's public signing key (if present). pub voter_signing_pk: Option, + /// Voter's ballot signature (if present). pub voter_ballot_signature: Option, } #[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone)] +/// Raw hashable ballot data. pub struct RawHashableBallot { + /// Ballot version. pub version: u32, + /// Date the ballot was issued. pub issue_date: String, + /// Contests data for the ballot. pub contests: Vec>, } impl HashableBallot { + /// # Errors + /// Returns an error if deserialization of any ballot contest fails. pub fn deserialize_contests( &self, ) -> Result>, BallotError> { @@ -174,14 +223,17 @@ impl HashableBallot { .collect() } + /// Serialize a slice of hashable ballot contests. + /// + /// # Errors + /// Returns `BallotError::Serialization` if serialization fails. pub fn serialize_contests( - contests: &Vec>, + contests: &[HashableBallotContest], ) -> Result, BallotError> { contests - .clone() - .into_iter() + .iter() .map(|hashable_ballot_contest| { - Base64Serialize::serialize(&hashable_ballot_contest) + Base64Serialize::serialize(hashable_ballot_contest) }) .collect::>>() .into_iter() @@ -190,6 +242,10 @@ impl HashableBallot { } impl SignedHashableBallot { + /// Deserialize contests from the signed hashable ballot. + /// + /// # Errors + /// Returns `BallotError::Serialization` on deserialization failure. pub fn deserialize_contests( &self, ) -> Result>, BallotError> { @@ -198,8 +254,10 @@ impl SignedHashableBallot { hashable_ballot.deserialize_contests() } + /// # Errors + /// Returns `BallotError::Serialization` if contest serialization fails. pub fn serialize_contests( - contests: &Vec>, + contests: &[HashableBallotContest], ) -> Result, BallotError> { HashableBallot::serialize_contests(contests) } @@ -213,7 +271,7 @@ impl TryFrom<&HashableBallot> for RawHashableBallot { Ok(RawHashableBallot { version: value.version, issue_date: value.issue_date.clone(), - contests: contests, + contests, }) } } @@ -234,9 +292,8 @@ impl TryFrom<&AuditableBallot> for SignedHashableBallot { fn try_from(value: &AuditableBallot) -> Result { if TYPES_VERSION != value.version { return Err(BallotError::Serialization(format!( - "Unexpected version {}, expected {}", - value.version.to_string(), - TYPES_VERSION + "Unexpected version {:?}, expected {}", + value.version, TYPES_VERSION ))); } @@ -245,18 +302,15 @@ impl TryFrom<&AuditableBallot> for SignedHashableBallot { contests .iter() .map(|auditable_ballot_contest| { - let hashable_ballot_contest = - HashableBallotContest::::from( - auditable_ballot_contest, - ); - hashable_ballot_contest + HashableBallotContest::::from( + auditable_ballot_contest, + ) }) .collect(); let ballot_style_hash = hash_ballot_style(&value.config).map_err(|error| { BallotError::Serialization(format!( - "Failed to hash ballot style: {}", - error + "Failed to hash ballot style: {error}" )) })?; Ok(SignedHashableBallot { @@ -266,7 +320,7 @@ impl TryFrom<&AuditableBallot> for SignedHashableBallot { &hashable_ballot_contest, )?, config: value.config.id.clone(), - ballot_style_hash: ballot_style_hash, + ballot_style_hash, voter_signing_pk: value.voter_signing_pk.clone(), voter_ballot_signature: value.voter_ballot_signature.clone(), }) @@ -278,9 +332,8 @@ impl TryFrom<&SignedHashableBallot> for HashableBallot { fn try_from(value: &SignedHashableBallot) -> Result { if TYPES_VERSION != value.version { return Err(BallotError::Serialization(format!( - "Unexpected version {}, expected {}", - value.version.to_string(), - TYPES_VERSION + "Unexpected version {:?}, expected {}", + value.version, TYPES_VERSION ))); } @@ -295,11 +348,16 @@ impl TryFrom<&SignedHashableBallot> for HashableBallot { } #[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +/// Content for a signed ballot, including the public key and signature. pub struct SignedContent { + /// Public key used for signing. pub public_key: String, + /// Signature value. pub signature: String, } +/// # Errors +/// Returns an error if key generation, serialization, or signing fails. pub fn sign_hashable_ballot_with_ephemeral_voter_signing_key( ballot_id: &str, election_id: &str, @@ -338,41 +396,40 @@ pub fn sign_hashable_ballot_with_ephemeral_voter_signing_key( // Returns Some(StrandSignature) if the signature was verified or None if there // was no signature to verify. +/// # Errors +/// Returns an error if signature deserialization, hashing, or verification fails. pub fn verify_ballot_signature( ballot_id: &str, election_id: &str, signed_hashable_ballot: &SignedHashableBallot, ) -> Result, String> { - let (voter_ballot_signature, voter_signing_pk) = - if let (Some(voter_ballot_signature), Some(voter_signing_pk)) = ( - signed_hashable_ballot.voter_ballot_signature.clone(), - signed_hashable_ballot.voter_signing_pk.clone(), - ) { - (voter_ballot_signature, voter_signing_pk) - } else { - return Ok(None); - }; + let Some(voter_ballot_signature) = + signed_hashable_ballot.voter_ballot_signature.as_ref() + else { + return Ok(None); + }; + let Some(voter_signing_pk) = + signed_hashable_ballot.voter_signing_pk.as_ref() + else { + return Ok(None); + }; + let voter_ballot_signature = voter_ballot_signature.clone(); + let voter_signing_pk = voter_signing_pk.clone(); let voter_signing_pk = StrandSignaturePk::from_der_b64_string( &voter_signing_pk, ) .map_err(|err| { - format!( - "Failed to deserialize signature from hashable ballot: {}", - err - ) + format!("Failed to deserialize signature from hashable ballot: {err}") })?; let hashable_ballot: HashableBallot = signed_hashable_ballot.try_into().map_err(|err| { - format!("Failed to convert to hashable ballot: {}", err) + format!("Failed to convert to hashable ballot: {err}") })?; let content = hashable_ballot.strand_serialize().map_err(|err| { - format!( - "Failed to get bytes for signing from hashable ballot: {}", - err - ) + format!("Failed to get bytes for signing from hashable ballot: {err}") })?; let ballot_bytes = @@ -382,10 +439,7 @@ pub fn verify_ballot_signature( &voter_ballot_signature, ) .map_err(|err| { - format!( - "Failed to deserialize signature from hashable ballot: {}", - err - ) + format!("Failed to deserialize signature from hashable ballot: {err}") })?; voter_signing_pk @@ -395,6 +449,8 @@ pub fn verify_ballot_signature( Ok(Some((voter_signing_pk, ballot_signature))) } +#[must_use] +/// Get bytes of ballot content pub fn get_ballot_bytes_for_signing( ballot_id: &str, election_id: &str, @@ -402,24 +458,24 @@ pub fn get_ballot_bytes_for_signing( ) -> Vec { let mut ret: Vec = vec![]; - let bytes = ballot_id.as_bytes(); - let length = (bytes.len() as u64).to_le_bytes(); - ret.extend_from_slice(&length); - ret.extend_from_slice(&bytes); + let ballot_id_bytes = ballot_id.as_bytes(); + let ballot_id_length = (ballot_id_bytes.len() as u64).to_le_bytes(); + ret.extend_from_slice(&ballot_id_length); + ret.extend_from_slice(ballot_id_bytes); - let bytes = election_id.as_bytes(); - let length = (bytes.len() as u64).to_le_bytes(); - ret.extend_from_slice(&length); - ret.extend_from_slice(&bytes); + let election_id_bytes = election_id.as_bytes(); + let election_id_length = (election_id_bytes.len() as u64).to_le_bytes(); + ret.extend_from_slice(&election_id_length); + ret.extend_from_slice(election_id_bytes); - let bytes = content; - let length = (bytes.len() as u64).to_le_bytes(); - ret.extend_from_slice(&length); - ret.extend_from_slice(&bytes); + let content_length = (content.len() as u64).to_le_bytes(); + ret.extend_from_slice(&content_length); + ret.extend_from_slice(content); ret } +/// URL and metadata for a candidate's resource (e.g., website or image). #[derive( BorshSerialize, BorshDeserialize, @@ -432,9 +488,13 @@ pub fn get_ballot_bytes_for_signing( Clone, )] pub struct CandidateUrl { + /// The URL string. pub url: String, + /// The kind/type of the URL. pub kind: Option, + /// The title or label for the URL. pub title: Option, + /// True if the URL points to an image resource. pub is_image: bool, } @@ -450,21 +510,34 @@ pub struct CandidateUrl { Clone, Default, )] +/// Presentation configuration for a candidate, including i18n, status, and display options. pub struct CandidatePresentation { + /// Internationalized content for the candidate. pub i18n: Option>>>, + /// True if the candidate is explicitly marked as invalid. pub is_explicit_invalid: Option, + /// True if the candidate is explicitly marked as blank. pub is_explicit_blank: Option, + /// True if the candidate is disabled. pub is_disabled: Option, + /// True if the candidate is a category list. pub is_category_list: Option, - pub invalid_vote_position: Option, // top|bottom + /// Position for invalid votes ("top" or "bottom"). + pub invalid_vote_position: Option, + /// True if the candidate is a write-in. pub is_write_in: Option, + /// Sort order for display. pub sort_order: Option, + /// List of URLs associated with the candidate. pub urls: Option>, + /// Subtype identifier for the candidate. pub subtype: Option, } impl CandidatePresentation { - pub fn new() -> CandidatePresentation { + /// Create a default candidate presentation config. + #[must_use] + pub const fn new() -> CandidatePresentation { CandidatePresentation { i18n: None, is_explicit_invalid: Some(false), @@ -492,67 +565,87 @@ impl CandidatePresentation { Clone, Default, )] +/// Candidate data structure. pub struct Candidate { + /// Unique candidate identifier. pub id: String, + /// Tenant identifier. pub tenant_id: String, + /// Election event identifier. pub election_event_id: String, + /// Election identifier. pub election_id: String, + /// Contest identifier. pub contest_id: String, + /// Candidate name. pub name: Option, + /// Internationalized candidate name. pub name_i18n: Option, + /// Candidate description. pub description: Option, + /// Internationalized candidate description. pub description_i18n: Option, + /// Candidate alias. pub alias: Option, + /// Internationalized candidate alias. pub alias_i18n: Option, + /// Candidate type. pub candidate_type: Option, + /// Presentation configuration for the candidate. pub presentation: Option, + /// Annotations for the candidate. pub annotations: Option, } impl Candidate { + #[must_use] + /// Checks if the candidate is a category list based on its presentation configuration. pub fn is_category_list(&self) -> bool { self.presentation .as_ref() - .map(|presentation| presentation.is_category_list) - .flatten() + .and_then(|presentation| presentation.is_category_list) .unwrap_or(false) } + #[must_use] + /// Checks if the candidate is explicitly marked as invalid based on its presentation configuration. pub fn is_explicit_invalid(&self) -> bool { self.presentation .as_ref() - .map(|presentation| presentation.is_explicit_invalid) - .flatten() + .and_then(|presentation| presentation.is_explicit_invalid) .unwrap_or(false) } + #[must_use] + /// Checks if the candidate is explicitly marked as blank based on its presentation configuration. pub fn is_explicit_blank(&self) -> bool { self.presentation .as_ref() - .map(|presentation| presentation.is_explicit_blank) - .flatten() + .and_then(|presentation| presentation.is_explicit_blank) .unwrap_or(false) } + #[must_use] + /// Checks if the candidate is disabled based on its presentation configuration. pub fn is_disabled(&self) -> bool { self.presentation .as_ref() - .map(|presentation| presentation.is_disabled) - .flatten() + .and_then(|presentation| presentation.is_disabled) .unwrap_or(false) } + #[must_use] + /// Checks if the candidate is a write-in based on its presentation configuration. pub fn is_write_in(&self) -> bool { self.presentation .as_ref() - .map(|presentation| presentation.is_write_in) - .flatten() + .and_then(|presentation| presentation.is_write_in) .unwrap_or(false) } + /// Sets the write-in status for the candidate. pub fn set_is_write_in(&mut self, is_write_in: bool) { - let mut presentation = - self.presentation.clone().unwrap_or(Default::default()); + let mut presentation = self.presentation.clone().unwrap_or_default(); presentation.is_write_in = Some(is_write_in); self.presentation = Some(presentation); } @@ -572,13 +665,17 @@ impl Candidate { Display, Default, )] +/// Specifies the order in which candidates are displayed on the ballot. pub enum CandidatesOrder { + /// Candidates order is randomized. #[strum(serialize = "random")] #[serde(rename = "random")] Random, + /// Candidates order is custom-defined. #[strum(serialize = "custom")] #[serde(rename = "custom")] Custom, + /// Candidates order is alphabetical (default). #[strum(serialize = "alphabetical")] #[serde(rename = "alphabetical")] #[default] @@ -600,10 +697,13 @@ pub enum CandidatesOrder { Display, Default, )] +/// Policy for allowing or disallowing early voting. pub enum EarlyVotingPolicy { + /// Early voting is allowed. #[strum(serialize = "allow_early_voting")] #[serde(rename = "allow_early_voting")] AllowEarlyVoting, + /// Early voting is not allowed (default). #[strum(serialize = "no_early_voting")] #[serde(rename = "no_early_voting")] #[default] @@ -624,13 +724,17 @@ pub enum EarlyVotingPolicy { Display, Default, )] +/// Specifies the order in which contests are displayed on the ballot. pub enum ContestsOrder { + /// Contests order is randomized. #[strum(serialize = "random")] #[serde(rename = "random")] Random, + /// Contests order is custom-defined. #[strum(serialize = "custom")] #[serde(rename = "custom")] Custom, + /// Contests order is alphabetical (default). #[strum(serialize = "alphabetical")] #[serde(rename = "alphabetical")] #[default] @@ -651,10 +755,13 @@ pub enum ContestsOrder { Display, Default, )] +/// Policy for requiring gold level authentication when casting a vote. pub enum CastVoteGoldLevelPolicy { + /// Gold level Authentication is required for cast vote. #[strum(serialize = "gold-level")] #[serde(rename = "gold-level")] GoldLevel, + /// Gold level Authentication is not required for cast vote (default). #[strum(serialize = "no-gold-level")] #[serde(rename = "no-gold-level")] #[default] @@ -675,11 +782,14 @@ pub enum CastVoteGoldLevelPolicy { Display, Default, )] +/// Policy for the title shown on the start screen of the voting portal. pub enum StartScreenTitlePolicy { + /// Start screen title should be of the election (default). #[strum(serialize = "election")] #[serde(rename = "election")] #[default] Election, + /// Start screen title should be of the election event. #[strum(serialize = "election-event")] #[serde(rename = "election-event")] ElectionEvent, @@ -699,11 +809,14 @@ pub enum StartScreenTitlePolicy { Display, Default, )] +/// Policy for requiring security confirmation before voting. pub enum ESecurityConfirmationPolicy { + /// No security confirmation required (default). #[strum(serialize = "none")] #[serde(rename = "none")] #[default] NONE, + /// Security confirmation is mandatory. #[strum(serialize = "mandatory")] #[serde(rename = "mandatory")] MANDATORY, @@ -724,14 +837,18 @@ pub enum ESecurityConfirmationPolicy { Display, Default, )] +/// Configuration for the audit button in the voting portal. pub enum AuditButtonCfg { + /// Show audit button (default). #[strum(serialize = "show")] #[serde(rename = "show")] #[default] SHOW, + /// Do not show audit button. #[strum(serialize = "not-show")] #[serde(rename = "not-show")] NOT_SHOW, + /// Show audit button in help section. #[strum(serialize = "show-in-help")] #[serde(rename = "show-in-help")] SHOW_IN_HELP, @@ -751,10 +868,13 @@ pub enum AuditButtonCfg { Display, Default, )] +/// Policy for showing or hiding the cast vote logs tab. pub enum ShowCastVoteLogs { + /// Show logs tab. #[strum(serialize = "show-logs-tab")] #[serde(rename = "show-logs-tab")] ShowLogsTab, + /// Hide logs tab (default). #[strum(serialize = "hide-logs-tab")] #[serde(rename = "hide-logs-tab")] #[default] @@ -775,13 +895,17 @@ pub enum ShowCastVoteLogs { Display, Default, )] +/// Policy for the order in which elections are displayed on the ballot. pub enum ElectionsOrder { + /// Elections order is randomized. #[strum(serialize = "random")] #[serde(rename = "random")] Random, + /// Elections order is custom-defined. #[strum(serialize = "custom")] #[serde(rename = "custom")] Custom, + /// Elections order is alphabetical (default). #[strum(serialize = "alphabetical")] #[serde(rename = "alphabetical")] #[default] @@ -799,19 +923,33 @@ pub enum ElectionsOrder { Debug, Clone, )] +/// Election data structure. pub struct Election { + /// Unique election identifier. pub id: String, + /// Election event identifier. pub election_event_id: String, + /// Tenant identifier. pub tenant_id: String, + /// Election name. pub name: Option, + /// Internationalized election name. pub name_i18n: Option, + /// Election description. pub description: Option, + /// Internationalized election description. pub description_i18n: Option, + /// Election alias. pub alias: Option, + /// Internationalized election alias. pub alias_i18n: Option, + /// Image document ID. pub image_document_id: Option, + /// List of contests in the election. pub contests: Vec, + /// Presentation configuration for the election. pub presentation: Option, + /// Annotations for the election. pub annotations: Option, } @@ -830,17 +968,22 @@ pub struct Election { Display, Default, )] +/// Policy for handling invalid votes. pub enum InvalidVotePolicy { + /// Invalid votes are allowed (default). #[strum(serialize = "allowed")] #[serde(rename = "allowed")] #[default] ALLOWED, + /// Warn on invalid votes. #[strum(serialize = "warn")] #[serde(rename = "warn")] WARN, + /// Warn on both implicit and explicit invalid votes. #[strum(serialize = "warn-invalid-implicit-and-explicit")] #[serde(rename = "warn-invalid-implicit-and-explicit")] WARN_INVALID_IMPLICIT_AND_EXPLICIT, + /// Invalid votes are not allowed. #[strum(serialize = "not-allowed")] #[serde(rename = "not-allowed")] NOT_ALLOWED, @@ -859,13 +1002,16 @@ pub enum InvalidVotePolicy { EnumString, Display, )] +/// Policy for candidate selection behavior pub enum CandidatesSelectionPolicy { + /// if you select one, the previously selected one gets unselected #[strum(serialize = "radio")] #[serde(rename = "radio")] - RADIO, // if you select one, the previously selected one gets unselected + RADIO, + /// default behaviour #[strum(serialize = "cumulative")] #[serde(rename = "cumulative")] - CUMULATIVE, // default behaviour + CUMULATIVE, } #[derive( @@ -882,14 +1028,17 @@ pub enum CandidatesSelectionPolicy { Display, Default, )] +/// Policy for the icon used for candidate selection. pub enum CandidatesIconCheckboxPolicy { + /// Checkbox icon by default. #[strum(serialize = "square-checkbox")] #[serde(rename = "square-checkbox")] #[default] - SQUARE_CHECKBOX, // Checkbox icon by default + SQUARE_CHECKBOX, + /// Radio button icon #[strum(serialize = "round-checkbox")] #[serde(rename = "round-checkbox")] - ROUND_CHECKBOX, // RadioButton icon + ROUND_CHECKBOX, } #[allow(non_camel_case_types)] @@ -907,16 +1056,21 @@ pub enum CandidatesIconCheckboxPolicy { Display, Default, )] +/// Policy for whether executing key ceremonies at the election event level +/// or election level. pub enum KeysCeremonyPolicy { + /// Key ceremonies execute in election event level (default). #[strum(serialize = "ELECTION_EVENT")] #[serde(rename = "ELECTION_EVENT")] #[default] ELECTION_EVENT, + /// Key ceremonies execute in election level. #[strum(serialize = "ELECTION")] #[serde(rename = "ELECTION")] ELECTION, } +/// Election event materials configuration. #[derive( BorshSerialize, BorshDeserialize, @@ -929,7 +1083,9 @@ pub enum KeysCeremonyPolicy { Clone, Default, )] +/// Materials configuration for an election event. pub struct ElectionEventMaterials { + /// True if the election event materials are activated. pub activated: Option, } @@ -945,8 +1101,11 @@ pub struct ElectionEventMaterials { Clone, Default, )] +/// Language configuration for an election event. pub struct ElectionEventLanguageConf { + /// List of enabled language codes. pub enabled_language_codes: Option>, + /// Default language code. pub default_language_code: Option, } @@ -962,42 +1121,69 @@ pub struct ElectionEventLanguageConf { Clone, Default, )] +/// Presentation configuration for an election event. pub struct ElectionEventPresentation { + /// Internationalized content for the event. pub i18n: Option>>>, - pub materials: Option, + /// Materials configuration for the event. + pub activated: Option, + /// Language configuration for the event. pub language_conf: Option, + /// Logo URL for the event. pub logo_url: Option, + /// Redirect URL after finishing voting. pub redirect_finish_url: Option, + /// Custom CSS for the event. pub css: Option, + /// True if the election list should be skipped. pub skip_election_list: Option, - pub show_user_profile: Option, // default is true + /// True if the user profile should be shown (default true). + pub show_user_profile: Option, + /// Show cast vote logs configuration. pub show_cast_vote_logs: Option, + /// Order in which elections are displayed. pub elections_order: Option, + /// Countdown policy for the voting portal. pub voting_portal_countdown_policy: Option, + /// Custom URLs for the event. pub custom_urls: Option, + /// Key ceremony policy. pub keys_ceremony_policy: Option, + /// Contest encryption policy. pub contest_encryption_policy: Option, + /// Decoded ballot inclusion policy. pub decoded_ballot_inclusion_policy: Option, + /// Locked down policy. pub locked_down: Option, + /// Publish policy. pub publish_policy: Option, + /// Enrollment policy. pub enrollment: Option, + /// OTP policy. pub otp: Option, + /// Voter signing policy. pub voter_signing_policy: Option, + /// Policy for voter digital certificate. pub voter_digital_cert_policy: Option, + /// Policy for weighted voting. pub weighted_voting_policy: Option, + /// Ceremonies policy. + /// (Whether the ceremonies should be automated) pub ceremonies_policy: Option, + /// Policy for delegated voting. pub delegated_voting_policy: Option, } impl ElectionEvent { + /// Parses the stored JSON presentation value and returns the typed presentation. + /// + /// # Errors + /// Returns an error if deserializing the event `presentation` JSON string fails. pub fn get_presentation( &self, ) -> Result, Error> { - self.presentation - .clone() - .map(|presentation_value| deserialize_value(presentation_value)) - .transpose() + self.presentation.clone().map(deserialize_value).transpose() } } @@ -1015,10 +1201,13 @@ impl ElectionEvent { EnumString, Display, )] +/// Policy for grace period. pub enum EGracePeriodPolicy { + /// No grace period(default). #[strum(serialize = "no-grace-period")] #[serde(rename = "no-grace-period")] NO_GRACE_PERIOD, + /// Grace period without alert. #[strum(serialize = "grace-period-without-alert")] #[serde(rename = "grace-period-without-alert")] GRACE_PERIOD_WITHOUT_ALERT, @@ -1036,8 +1225,11 @@ pub enum EGracePeriodPolicy { Clone, Default, )] +/// Voting period dates. pub struct VotingPeriodDates { + /// Start date of the voting period. pub start_date: Option, + /// End date of the voting period. pub end_date: Option, } @@ -1054,22 +1246,21 @@ pub struct VotingPeriodDates { Clone, EnumString, Display, + Default, )] +/// Policy for whether Initialize Report is required to start voting. pub enum EInitializeReportPolicy { + /// Initialize Report is required. #[strum(serialize = "required")] #[serde(rename = "required")] REQUIRED, + /// Initialize Report is not required (default). #[strum(serialize = "not-required")] #[serde(rename = "not-required")] + #[default] NOT_REQUIRED, } -impl Default for EInitializeReportPolicy { - fn default() -> Self { - EInitializeReportPolicy::NOT_REQUIRED - } -} - #[derive( BorshSerialize, BorshDeserialize, @@ -1082,9 +1273,14 @@ impl Default for EInitializeReportPolicy { Clone, Default, )] +/// Configuration for the voting portal countdown Policy. pub struct VotingPortalCountdownPolicy { + /// Countdown policy pub policy: Option, + /// Countdown anticipation seconds. + /// i.e how many seconds before the countdown should start and for how long. pub countdown_anticipation_secs: Option, + /// Countdown alert anticipation seconds. pub countdown_alert_anticipation_secs: Option, } @@ -1102,9 +1298,13 @@ pub struct VotingPortalCountdownPolicy { EnumString, Display, )] +/// Policy for the voting portal countdown. pub enum ECountdownPolicy { + /// No countdown NO_COUNTDOWN, + /// Show countdown without alert. COUNTDOWN, + /// Show countdown with alert. COUNTDOWN_WITH_ALERT, } @@ -1124,17 +1324,23 @@ pub enum ECountdownPolicy { Display, Default, )] + +/// Policy for undervotes. pub enum EUnderVotePolicy { + /// Undervotes are allowed (default). #[strum(serialize = "allowed")] #[serde(rename = "allowed")] #[default] ALLOWED, + /// Warn on undervotes. #[strum(serialize = "warn")] #[serde(rename = "warn")] WARN, + /// Warn on undervotes only in review screen. #[strum(serialize = "warn-only-in-review")] #[serde(rename = "warn-only-in-review")] WARN_ONLY_IN_REVIEW, + /// Warn on undervotes and show alert. #[strum(serialize = "warn-and-alert")] #[serde(rename = "warn-and-alert")] WARN_AND_ALERT, @@ -1156,17 +1362,22 @@ pub enum EUnderVotePolicy { Display, Default, )] +/// Policy for blank votes. pub enum EBlankVotePolicy { + /// Blank votes are allowed (default). #[strum(serialize = "allowed")] #[serde(rename = "allowed")] #[default] ALLOWED, + /// Warn on blank votes. #[strum(serialize = "warn")] #[serde(rename = "warn")] WARN, + /// Warn on blank votes only in review screen. #[strum(serialize = "warn-only-in-review")] #[serde(rename = "warn-only-in-review")] WARN_ONLY_IN_REVIEW, + /// Blank votes are not allowed. #[strum(serialize = "not-allowed")] #[serde(rename = "not-allowed")] NOT_ALLOWED, @@ -1188,20 +1399,26 @@ pub enum EBlankVotePolicy { Display, Default, )] +/// Policy for overvotes. pub enum EOverVotePolicy { + /// Overvotes are allowed (default). #[strum(serialize = "allowed")] #[serde(rename = "allowed")] ALLOWED, + /// Overvotes are allowed with a message. #[strum(serialize = "allowed-with-msg")] #[serde(rename = "allowed-with-msg")] ALLOWED_WITH_MSG, + /// Overvotes are allowed with a message and alert. #[strum(serialize = "allowed-with-msg-and-alert")] #[serde(rename = "allowed-with-msg-and-alert")] #[default] ALLOWED_WITH_MSG_AND_ALERT, + /// Overvotes are not allowed and show a message with alert. #[strum(serialize = "not-allowed-with-msg-and-alert")] #[serde(rename = "not-allowed-with-msg-and-alert")] NOT_ALLOWED_WITH_MSG_AND_ALERT, + /// Overvotes are not allowed and show a message. #[strum(serialize = "not-allowed-with-msg-and-disable")] #[serde(rename = "not-allowed-with-msg-and-disable")] NOT_ALLOWED_WITH_MSG_AND_DISABLE, @@ -1223,11 +1440,14 @@ pub enum EOverVotePolicy { Display, Default, )] +/// Policy for duplicated ranks in preferential voting. pub enum EDuplicatedRankPolicy { + /// Duplicated ranks are allowed (default) but shows a warning and dialog. #[strum(serialize = "allowed-warn-and-dialog")] #[serde(rename = "allowed-warn-and-dialog")] #[default] ALLOWED_WARN_AND_DIALOG, + /// Duplicated ranks are not allowed and shows a warning and dialog. #[strum(serialize = "not-allowed-warn-and-dialog")] #[serde(rename = "not-allowed-warn-and-dialog")] NOT_ALLOWED_WARN_AND_DIALOG, @@ -1249,11 +1469,14 @@ pub enum EDuplicatedRankPolicy { Display, Default, )] +/// Policy for preference gaps in preferential voting. pub enum EPreferenceGapsPolicy { + /// Preference gaps are allowed (default) but shows a warning and dialog. #[strum(serialize = "allowed-warn-and-dialog")] #[serde(rename = "allowed-warn-and-dialog")] #[default] ALLOWED_WARN_AND_DIALOG, + /// Preference gaps are not allowed and shows a warning and dialog. #[strum(serialize = "not-allowed-warn-and-dialog")] #[serde(rename = "not-allowed-warn-and-dialog")] NOT_ALLOWED_WARN_AND_DIALOG, @@ -1270,35 +1493,56 @@ pub enum EPreferenceGapsPolicy { Debug, Clone, )] +/// Presentation settings for an election event. pub struct ElectionPresentation { + /// Internationalized text for the election. pub i18n: Option>>>, + /// Voting period date configuration. pub dates: Option, + /// Language-specific configuration. pub language_conf: Option, + /// Order in which contests are shown. pub contests_order: Option, + /// Audit button configuration. pub audit_button_cfg: Option, + /// UI sort order. pub sort_order: Option, + /// Whether to show a cast-vote confirm screen. pub cast_vote_confirm: Option, + /// Gold-level policy for cast vote confirmation. pub cast_vote_gold_level: Option, + /// Start screen title policy. pub start_screen_title_policy: Option, + /// Whether grace period is enabled. pub is_grace_priod: Option, + /// Grace period policy. pub grace_period_policy: Option, + /// Grace period duration in seconds. pub grace_period_secs: Option, + /// Initialize report policy. pub init_report: Option, + /// Manual start voting period policy. pub manual_start_voting_period: Option, + /// Voting period end policy. pub voting_period_end: Option, + /// Tally policy. pub tally: Option, + /// Policy for whether Initialize Report is required to start voting. pub initialization_report_policy: Option, + /// Security confirmation policy. pub security_confirmation_policy: Option, + /// Consolidated report policy. pub consolidated_report_policy: Option, } impl core::Election { + /// Returns the election's presentation settings, if configured. + #[must_use] pub fn get_presentation(&self) -> Option { let election_presentation: Option = self .presentation .clone() - .map(|value| deserialize_value(value).ok()) - .flatten(); + .and_then(|value| deserialize_value(value).ok()); election_presentation } @@ -1344,14 +1588,17 @@ impl Default for ElectionPresentation { Clone, Default, )] +/// Presentation settings for an area. pub struct AreaPresentation { + /// Whether early voting is allowed for this area. pub allow_early_voting: Option, } impl AreaPresentation { + /// Returns true if early voting is enabled for this area. + #[must_use] pub fn is_early_voting(&self) -> bool { - self.allow_early_voting.clone().unwrap_or_default() - == EarlyVotingPolicy::AllowEarlyVoting + self.allow_early_voting == Some(EarlyVotingPolicy::AllowEarlyVoting) } } @@ -1367,9 +1614,13 @@ impl AreaPresentation { Clone, Default, )] +/// Presentation settings for a candidate subtype. pub struct SubtypePresentation { + /// Name of the subtype. pub name: Option, + /// Internationalized name of the subtype. pub name_i18n: Option>>, + /// Sort order for the subtype. pub sort_order: Option, } @@ -1385,10 +1636,15 @@ pub struct SubtypePresentation { Clone, Default, )] +/// Presentation settings for a candidate type, including its subtypes. pub struct TypePresentation { + /// Name type. pub name: Option, + /// Internationalized name of type. pub name_i18n: Option>>, + /// Sort order for type. pub sort_order: Option, + /// Presentation settings for the subtypes of this candidate type, if applicable. pub subtypes_presentation: Option>>, } @@ -1404,6 +1660,8 @@ pub struct TypePresentation { Debug, Clone, )] +/// Presentation settings for a contest. +#[allow(missing_docs)] pub struct ContestPresentation { pub i18n: Option>>>, pub allow_writeins: Option, @@ -1430,6 +1688,8 @@ pub struct ContestPresentation { } impl ContestPresentation { + #[must_use] + /// Creates a new `ContestPresentation` instance with default values for all fields. pub fn new() -> ContestPresentation { ContestPresentation { i18n: None, @@ -1438,7 +1698,7 @@ impl ContestPresentation { invalid_vote_policy: Some(InvalidVotePolicy::ALLOWED), blank_vote_policy: Some(EBlankVotePolicy::ALLOWED), over_vote_policy: Some(EOverVotePolicy::ALLOWED), - pagination_policy: Some("".to_owned()), + pagination_policy: Some(String::new()), cumulative_number_of_checkboxes: None, shuffle_categories: Some(false), shuffle_category_list: None, @@ -1464,6 +1724,7 @@ impl Default for ContestPresentation { } } +/// Contest data structure. #[derive( BorshSerialize, BorshDeserialize, @@ -1476,8 +1737,10 @@ impl Default for ContestPresentation { Clone, Default, )] +#[allow(missing_docs)] pub struct Contest { pub id: String, + /// Tenant identifier. pub tenant_id: String, pub election_event_id: String, pub election_id: String, @@ -1501,26 +1764,32 @@ pub struct Contest { } impl Contest { + #[must_use] + /// Return true if the contest presentation is configured to allow write-ins. pub fn allow_writeins(&self) -> bool { self.presentation .as_ref() - .map(|presentation| presentation.allow_writeins) - .flatten() + .and_then(|presentation| presentation.allow_writeins) .unwrap_or(false) } + #[must_use] + /// Get the counting algorithm for the contest. pub fn get_counting_algorithm(&self) -> CountingAlgType { self.counting_algorithm.unwrap_or_default() } + #[must_use] + /// Return true if the contest presentation is configured to allow base32 write-ins, + /// defaulting to true if the presentation or the specific configuration value is not set. pub fn base32_writeins(&self) -> bool { self.presentation .as_ref() - .map(|presentation| presentation.base32_writeins) - .flatten() + .and_then(|presentation| presentation.base32_writeins) .unwrap_or(true) } + #[must_use] /// Get the invalid vote policy configuration value from the presentation. /// If the value or the parent object is not set, return the default value. pub fn get_invalid_vote_policy(&self) -> InvalidVotePolicy { @@ -1534,23 +1803,28 @@ impl Contest { } } + #[must_use] + /// Get the cumulative number of checkboxes for the contest from the presentation. pub fn cumulative_number_of_checkboxes(&self) -> u64 { self.presentation .as_ref() - .map(|presentation| { - presentation.cumulative_number_of_checkboxes.unwrap_or(1) + .and_then(|presentation| { + presentation.cumulative_number_of_checkboxes }) .unwrap_or(1) } + #[must_use] + /// Return true if the contest presentation is configured to show points, false otherwise. pub fn show_points(&self) -> bool { self.presentation .as_ref() - .map(|presentation| presentation.show_points) - .flatten() + .and_then(|presentation| presentation.show_points) .unwrap_or(false) } + #[must_use] + /// Get the all candidate ids that are explicitly marked as invalid. pub fn get_invalid_candidate_ids(&self) -> Vec { self.candidates .iter() @@ -1563,11 +1837,13 @@ impl Contest { /// Get the tie-breaking policy configuration value. /// If the value is not set, return the default value (RANDOM). + #[must_use] pub fn get_tie_breaking_policy(&self) -> TieBreakingPolicy { self.tie_breaking_policy.clone().unwrap_or_default() } /// Get per-round tie resolutions from contest annotations. + #[must_use] pub fn get_tie_resolutions(&self) -> Vec { self.annotations .as_ref() @@ -1582,6 +1858,10 @@ impl Contest { .unwrap_or_default() } + /// Insert tie resolutions into the contest's annotations. + /// + /// # Errors + /// Returns an error if serialization of the tie resolutions fails. pub fn insert_tie_resolutions( contest: &mut Contest, contest_tie_resolutions: &Vec, @@ -1622,13 +1902,16 @@ impl Contest { EnumString, JsonSchema, )] +/// configuration for whether enrollment is enabled or disabled. pub enum Enrollment { #[default] #[strum(serialize = "enabled")] #[serde(rename = "enabled")] + /// Enrollment is enabled. ENABLED, #[strum(serialize = "disabled")] #[serde(rename = "disabled")] + /// Enrollment is disabled. DISABLED, } @@ -1647,13 +1930,16 @@ pub enum Enrollment { EnumString, JsonSchema, )] +/// Configuration for whether OTP is enabled or disabled. pub enum Otp { #[default] #[strum(serialize = "enabled")] #[serde(rename = "enabled")] + /// OTP is enabled. ENABLED, #[strum(serialize = "disabled")] #[serde(rename = "disabled")] + /// OTP is disabled. DISABLED, } @@ -1672,13 +1958,16 @@ pub enum Otp { EnumString, JsonSchema, )] +/// Configuration for whether decoded ballots are included or not. pub enum DecodedBallotsInclusionPolicy { #[strum(serialize = "included")] #[serde(rename = "included")] + /// Decoded ballots are included. INCLUDED, #[default] #[strum(serialize = "not-included")] #[serde(rename = "not-included")] + /// Decoded ballots are not included. NOT_INCLUDED, } @@ -1697,13 +1986,16 @@ pub enum DecodedBallotsInclusionPolicy { EnumString, JsonSchema, )] +/// Configuration for contest encryption policy. pub enum ContestEncryptionPolicy { #[strum(serialize = "multiple-contests")] #[serde(rename = "multiple-contests")] + /// Contests are encrypted together in a single encryption process. MULTIPLE_CONTESTS, #[default] #[strum(serialize = "single-contest")] #[serde(rename = "single-contest")] + /// Each contest is encrypted separately. SINGLE_CONTEST, } @@ -1722,13 +2014,16 @@ pub enum ContestEncryptionPolicy { EnumString, JsonSchema, )] +/// Configuration for voter signing policy. pub enum VoterSigningPolicy { #[default] #[strum(serialize = "no-signature")] #[serde(rename = "no-signature")] + /// Votes are not signed with the voter's signature. NO_SIGNATURE, #[strum(serialize = "with-signature")] #[serde(rename = "with-signature")] + /// Votes are signed with the voter's signature. WITH_SIGNATURE, } @@ -1747,6 +2042,8 @@ pub enum VoterSigningPolicy { EnumString, JsonSchema, )] +/// Configuration for voter digital certificate policy. +#[allow(missing_docs)] pub enum VoterDigitalCertPolicy { #[default] #[strum(serialize = "disabled")] @@ -1772,13 +2069,16 @@ pub enum VoterDigitalCertPolicy { EnumString, JsonSchema, )] +/// Configuration for whether the election event is locked down or not. pub enum LockedDown { #[strum(serialize = "locked-down")] #[serde(rename = "locked-down")] + /// The election event is locked down, meaning that no further changes to the election configuration are allowed. LOCKED_DOWN, #[default] #[strum(serialize = "not-locked-down")] #[serde(rename = "not-locked-down")] + /// The election event is not locked down. NOT_LOCKED_DOWN, } @@ -1797,25 +2097,36 @@ pub enum LockedDown { EnumString, JsonSchema, )] +/// Configuration for whether able to publish. pub enum Publish { #[default] #[strum(serialize = "always")] #[serde(rename = "always")] + /// The election event is always enabled for publishing. ALWAYS, #[strum(serialize = "after-lockdown")] #[serde(rename = "after-lockdown")] + ///Publishing is enabled only after the election event is locked down. AFTER_LOCKDOWN, } #[derive(Serialize, Deserialize, JsonSchema, PartialEq, Eq, Debug, Clone)] #[serde(default)] +/// Status of the voting for the election event pub struct ElectionEventStatus { + /// True if the election event is published, false otherwise. pub is_published: Option, + /// Voting status. pub voting_status: VotingStatus, + /// Kiosk voting status. pub kiosk_voting_status: VotingStatus, + /// Early voting status. pub early_voting_status: VotingStatus, + /// Voting period dates for the online channel. pub voting_period_dates: PeriodDates, + /// Kiosk voting period dates. pub kiosk_voting_period_dates: PeriodDates, + /// Early voting period dates. pub early_voting_period_dates: PeriodDates, } @@ -1826,28 +2137,28 @@ impl Default for ElectionEventStatus { voting_status: VotingStatus::NOT_STARTED, kiosk_voting_status: VotingStatus::NOT_STARTED, early_voting_status: VotingStatus::NOT_STARTED, - voting_period_dates: Default::default(), - kiosk_voting_period_dates: Default::default(), - early_voting_period_dates: Default::default(), + voting_period_dates: PeriodDates::default(), + kiosk_voting_period_dates: PeriodDates::default(), + early_voting_period_dates: PeriodDates::default(), } } } impl ElectionEventStatus { - pub fn status_by_channel( + #[must_use] + /// Returns the voting status for the specified channel. + pub const fn status_by_channel( &self, channel: VotingStatusChannel, ) -> VotingStatus { match channel { - VotingStatusChannel::ONLINE => self.voting_status.clone(), - VotingStatusChannel::KIOSK => self.kiosk_voting_status.clone(), - VotingStatusChannel::EARLY_VOTING => { - self.early_voting_status.clone() - } + VotingStatusChannel::ONLINE => self.voting_status, + VotingStatusChannel::KIOSK => self.kiosk_voting_status, + VotingStatusChannel::EARLY_VOTING => self.early_voting_status, } } - /// Close EARLY_VOTING channel's status automatically if the new online + /// Close `EARLY_VOTING` channel's status automatically if the new online /// status is OPEN or CLOSED pub fn close_early_voting_if_online_status_change( &mut self, @@ -1869,26 +2180,28 @@ impl ElectionEventStatus { } } + /// Sets the voting status for the given channel and updates the + /// corresponding period dates. pub fn set_status_by_channel( &mut self, channel: VotingStatusChannel, new_status: VotingStatus, ) { - let mut period_dates = match channel { + let period_dates = match channel { VotingStatusChannel::ONLINE => { - self.voting_status = new_status.clone(); + self.voting_status = new_status; &mut self.voting_period_dates } VotingStatusChannel::KIOSK => { - self.kiosk_voting_status = new_status.clone(); + self.kiosk_voting_status = new_status; &mut self.kiosk_voting_period_dates } VotingStatusChannel::EARLY_VOTING => { - self.early_voting_status = new_status.clone(); + self.early_voting_status = new_status; &mut self.early_voting_period_dates } }; - period_dates.update_period_dates(&new_status); + period_dates.update_period_dates(new_status); } } @@ -1909,61 +2222,76 @@ impl ElectionEventStatus { JsonSchema, IntoStaticStr, )] +/// Voting status. pub enum VotingStatus { #[default] + /// Voting has not started yet. NOT_STARTED, + /// Voting is currently open. OPEN, + /// Voting is paused. PAUSED, + /// Voting is closed. CLOSED, } impl VotingStatus { - pub fn is_not_started(&self) -> bool { + #[must_use] + /// Returns true if the voting status is `NOT_STARTED`. + pub const fn is_not_started(&self) -> bool { match self { VotingStatus::NOT_STARTED => true, - VotingStatus::OPEN => false, - VotingStatus::PAUSED => false, - VotingStatus::CLOSED => false, + VotingStatus::OPEN + | VotingStatus::PAUSED + | VotingStatus::CLOSED => false, } } - pub fn is_started(&self) -> bool { + #[must_use] + /// Returns true if the voting status is any but `NOT_STARTED`. + pub const fn is_started(&self) -> bool { !self.is_not_started() } - pub fn is_open(&self) -> bool { + #[must_use] + /// Returns true if the voting status is `OPEN` or `PAUSED`. + pub const fn is_open(&self) -> bool { match self { - VotingStatus::NOT_STARTED => false, VotingStatus::OPEN => true, - VotingStatus::PAUSED => true, - VotingStatus::CLOSED => false, + VotingStatus::NOT_STARTED + | VotingStatus::PAUSED + | VotingStatus::CLOSED => false, } } - pub fn is_paused(&self) -> bool { + #[must_use] + /// Returns true if the voting status is `PAUSED`. + pub const fn is_paused(&self) -> bool { match self { - VotingStatus::NOT_STARTED => false, - VotingStatus::OPEN => false, VotingStatus::PAUSED => true, - VotingStatus::CLOSED => false, + VotingStatus::NOT_STARTED + | VotingStatus::OPEN + | VotingStatus::CLOSED => false, } } - pub fn is_closed(&self) -> bool { + #[must_use] + /// Returns true if the voting status is `CLOSED`. + pub const fn is_closed(&self) -> bool { match self { - VotingStatus::NOT_STARTED => false, - VotingStatus::OPEN => false, - VotingStatus::PAUSED => false, VotingStatus::CLOSED => true, + VotingStatus::NOT_STARTED + | VotingStatus::OPEN + | VotingStatus::PAUSED => false, } } - pub fn is_closed_or_never_started(&self) -> bool { + #[must_use] + /// Returns true if the voting status is `NOT_STARTED` or `CLOSED`. + pub const fn is_closed_or_never_started(&self) -> bool { match self { - VotingStatus::NOT_STARTED => true, - VotingStatus::OPEN => false, - VotingStatus::PAUSED => false, - VotingStatus::CLOSED => true, + VotingStatus::NOT_STARTED | VotingStatus::CLOSED => true, + VotingStatus::OPEN | VotingStatus::PAUSED => false, } } } @@ -1984,16 +2312,20 @@ impl VotingStatus { JsonSchema, IntoStaticStr, )] +/// Policy for allowing tally before voting period ends. pub enum AllowTallyStatus { #[default] #[strum(serialize = "allowed")] #[serde(rename = "allowed")] + /// Tally is allowed before voting period ends. ALLOWED, #[strum(serialize = "disallowed")] #[serde(rename = "disallowed")] + /// Tally is not allowed before voting period ends. DISALLOWED, #[strum(serialize = "requires-voting-period-end")] #[serde(rename = "requires-voting-period-end")] + /// Tally is only allowed when voting period ends. REQUIRES_VOTING_PERIOD_END, } @@ -2013,21 +2345,27 @@ pub enum AllowTallyStatus { JsonSchema, IntoStaticStr, )] +/// Voting channels. pub enum VotingStatusChannel { + /// Online voting channel. ONLINE, + /// Kiosk voting channel. KIOSK, + /// Early voting. EARLY_VOTING, } impl VotingStatusChannel { - pub fn channel_from( + #[must_use] + /// Returns the channel status from `VotingChannels`. + pub const fn channel_from( &self, channels: &core::VotingChannels, ) -> Option { match self { - &VotingStatusChannel::ONLINE => channels.online.clone(), - &VotingStatusChannel::KIOSK => channels.kiosk.clone(), - &VotingStatusChannel::EARLY_VOTING => channels.early_voting.clone(), + VotingStatusChannel::ONLINE => channels.online, + VotingStatusChannel::KIOSK => channels.kiosk, + VotingStatusChannel::EARLY_VOTING => channels.early_voting, } } } @@ -2042,8 +2380,11 @@ impl VotingStatusChannel { Debug, Clone, )] +/// Statistics related to an election event. pub struct ElectionEventStatistics { + /// Number of emails sent. pub num_emails_sent: Option, + /// Number of SMS sent. pub num_sms_sent: Option, } @@ -2066,8 +2407,11 @@ impl Default for ElectionEventStatistics { Debug, Clone, )] +/// Statistics related to an election, such as the number of emails and SMS sent. pub struct ElectionStatistics { + /// Number of emails sent. pub num_emails_sent: Option, + /// Number of SMS sent. pub num_sms_sent: Option, } @@ -2095,13 +2439,16 @@ impl Default for ElectionStatistics { EnumString, JsonSchema, )] +/// Policy for initialization report. pub enum InitReport { #[default] #[strum(serialize = "allowed")] #[serde(rename = "allowed")] + /// Initialization report is allowed to be generated. ALLOWED, #[strum(serialize = "disallowed")] #[serde(rename = "disallowed")] + /// Initialization report is not allowed to be generated. DISALLOWED, } @@ -2120,13 +2467,16 @@ pub enum InitReport { EnumString, JsonSchema, )] +/// Policy for manually starting the voting period. pub enum ManualStartVotingPeriod { #[default] #[strum(serialize = "allowed")] #[serde(rename = "allowed")] + /// Manually starting the voting period is allowed. ALLOWED, #[strum(serialize = "only-when-initialization-report-has-been-performed")] #[serde(rename = "only-when-initialization-report-has-been-performed")] + /// Manually starting the voting period is only allowed when the initialization report has been performed. ONLY_WHEN_INITIALIZATION_REPORT_HAS_BEEN_PERFORMED, } @@ -2145,13 +2495,16 @@ pub enum ManualStartVotingPeriod { EnumString, JsonSchema, )] +/// Policy for allowing voting period ends. pub enum VotingPeriodEnd { #[default] #[strum(serialize = "allowed")] #[serde(rename = "allowed")] + /// Voting period end is allowed, meaning that the voting period can be ended. ALLOWED, #[strum(serialize = "disallowed")] #[serde(rename = "disallowed")] + /// Voting period end is not allowed, DISALLOWED, } @@ -2170,25 +2523,36 @@ pub enum VotingPeriodEnd { EnumString, JsonSchema, )] +/// Policy for allowing tally before voting period ends. pub enum Tally { #[default] #[strum(serialize = "always-allow")] #[serde(rename = "always-allow")] + /// Tally is always allowed. ALWAYS_ALLOW, #[strum(serialize = "allow-when-voting-period-ends")] #[serde(rename = "allow-when-voting-period-ends")] + /// Tally is allowed when voting period ends. ONLY_WHEN_VOTING_PERIOD_ENDS, } #[derive( Serialize, Deserialize, PartialEq, Eq, JsonSchema, Debug, Clone, Default, )] +/// Struct to hold the first and last timestamps for each voting +/// status change during a voting period. pub struct PeriodDates { + /// The first time the voting period was started. pub first_started_at: Option>, + /// The last time the voting period was started. pub last_started_at: Option>, + /// The first time the voting period was paused. pub first_paused_at: Option>, + /// The last time the voting period was paused. pub last_paused_at: Option>, + /// The first time the voting period was stopped. pub first_stopped_at: Option>, + /// The last time the voting period was stopped. pub last_stopped_at: Option>, } @@ -2204,25 +2568,25 @@ pub struct PeriodDates { Clone, Default, )] +/// Struct to hold the stringified first and last timestamps for each +/// voting status change during a voting period. pub struct StringifiedPeriodDates { + /// The first time the voting period was started, in string format. pub first_started_at: Option, + /// The last time the voting period was started, in string format. pub last_started_at: Option, + /// The first time the voting period was paused, in string format. pub first_paused_at: Option, + /// The last time the voting period was paused, in string format. pub last_paused_at: Option, + /// The first time the voting period was stopped, in string format. pub first_stopped_at: Option, + /// The last time the voting period was stopped, in string format. pub last_stopped_at: Option, + /// The scheduled event dates, in string format. pub scheduled_event_dates: Option>, } -#[derive( - Serialize, Deserialize, PartialEq, Eq, JsonSchema, Debug, Clone, Default, -)] -pub struct ReportDates { - pub start_date: String, - pub end_date: String, - pub election_date: String, -} - #[derive( BorshSerialize, BorshDeserialize, @@ -2235,13 +2599,17 @@ pub struct ReportDates { Clone, Default, )] +/// Struct to hold the scheduled and stopped timestamps for scheduled events. pub struct ScheduledEventDates { + /// The scheduled time for the event. pub scheduled_at: Option, + /// The time when the scheduled event was stopped. pub stopped_at: Option, } impl PeriodDates { - fn update_period_dates(&mut self, new_status: &VotingStatus) { + /// Updates the period dates based on the new voting status. + fn update_period_dates(&mut self, new_status: VotingStatus) { let (first, last) = match new_status { VotingStatus::NOT_STARTED => { // nothing to do @@ -2259,10 +2627,12 @@ impl PeriodDates { }; *last = Some(Utc::now()); if first.is_none() { - *first = last.clone(); + *first = *last; } } + #[must_use] + /// Converts period dates to string fields. pub fn to_string_fields(&self) -> StringifiedPeriodDates { StringifiedPeriodDates { first_started_at: format_date_opt(&self.first_started_at), @@ -2271,31 +2641,44 @@ impl PeriodDates { last_paused_at: format_date_opt(&self.last_paused_at), first_stopped_at: format_date_opt(&self.first_stopped_at), last_stopped_at: format_date_opt(&self.last_stopped_at), - scheduled_event_dates: Default::default(), + scheduled_event_dates: Option::default(), } } } -// Helper method to format the date or return "-" +/// Helper method to format the date or return `default`. +#[must_use] pub fn format_date(date: &Option>, default: &str) -> String { date.map_or(default.to_string(), |d| d.to_rfc3339()) } +/// Helper method to format the date or return `None`. +#[must_use] pub fn format_date_opt(date: &Option>) -> Option { date.map(|d| d.to_rfc3339()) } #[derive(Serialize, Deserialize, JsonSchema, PartialEq, Eq, Debug, Clone)] #[serde(default)] +/// Struct to hold the election status related to voting and tally. pub struct ElectionStatus { + /// True if the election is published, false otherwise. pub is_published: Option, + /// Voting status for the online channel. pub voting_status: VotingStatus, + /// Policy for initialization report. pub init_report: InitReport, + /// Voting status for the kiosk channel. pub kiosk_voting_status: VotingStatus, + /// Voting status for the early voting channel. pub early_voting_status: VotingStatus, + /// Voting period dates for the online channel. pub voting_period_dates: PeriodDates, + /// Kiosk voting period dates. pub kiosk_voting_period_dates: PeriodDates, + /// Early voting period dates. pub early_voting_period_dates: PeriodDates, + /// Policy for allowing tally before voting period ends. pub allow_tally: AllowTallyStatus, } @@ -2307,28 +2690,30 @@ impl Default for ElectionStatus { init_report: InitReport::ALLOWED, kiosk_voting_status: VotingStatus::NOT_STARTED, early_voting_status: VotingStatus::NOT_STARTED, - voting_period_dates: Default::default(), - kiosk_voting_period_dates: Default::default(), - early_voting_period_dates: Default::default(), - allow_tally: Default::default(), + voting_period_dates: PeriodDates::default(), + kiosk_voting_period_dates: PeriodDates::default(), + early_voting_period_dates: PeriodDates::default(), + allow_tally: AllowTallyStatus::default(), } } } impl ElectionStatus { - pub fn status_by_channel( + #[must_use] + /// Returns the voting status of the given channel. + pub const fn status_by_channel( &self, channel: VotingStatusChannel, ) -> VotingStatus { match channel { - VotingStatusChannel::ONLINE => self.voting_status.clone(), - VotingStatusChannel::KIOSK => self.kiosk_voting_status.clone(), - VotingStatusChannel::EARLY_VOTING => { - self.early_voting_status.clone() - } + VotingStatusChannel::ONLINE => self.voting_status, + VotingStatusChannel::KIOSK => self.kiosk_voting_status, + VotingStatusChannel::EARLY_VOTING => self.early_voting_status, } } + #[must_use] + /// Returns the period dates of the given channel. pub fn dates_by_channel( &self, channel: VotingStatusChannel, @@ -2344,7 +2729,7 @@ impl ElectionStatus { } } - /// Close EARLY_VOTING channel's status automatically if the new online + /// Close `EARLY_VOTING` channel's status automatically if the new online /// status is OPEN or CLOSED pub fn close_early_voting_if_online_status_change( &mut self, @@ -2366,6 +2751,8 @@ impl ElectionStatus { } } + /// Sets the voting status for the given channel and updates the + /// corresponding period dates. pub fn set_status_by_channel( &mut self, channel: VotingStatusChannel, @@ -2373,19 +2760,19 @@ impl ElectionStatus { ) { let period_dates = match channel { VotingStatusChannel::ONLINE => { - self.voting_status = new_status.clone(); + self.voting_status = new_status; &mut self.voting_period_dates } VotingStatusChannel::KIOSK => { - self.kiosk_voting_status = new_status.clone(); + self.kiosk_voting_status = new_status; &mut self.kiosk_voting_period_dates } VotingStatusChannel::EARLY_VOTING => { - self.early_voting_status = new_status.clone(); + self.early_voting_status = new_status; &mut self.early_voting_period_dates } }; - period_dates.update_period_dates(&new_status); + period_dates.update_period_dates(new_status); } } @@ -2399,22 +2786,40 @@ impl ElectionStatus { Debug, Clone, )] +/// Struct representing the ballot style, which includes information +/// about the contests, areas, and presentation settings for a specific ballot. pub struct BallotStyle { + /// Unique identifier for the ballot style. pub id: String, + /// Tenant identifier. pub tenant_id: String, + /// Election event identifier. pub election_event_id: String, + /// Election identifier. pub election_id: String, + /// Number of allowed revotes for this ballot style, if any. pub num_allowed_revotes: Option, + /// Description of the election. pub description: Option, + /// Public key. pub public_key: Option, + /// Unique identifier for the area associated with this ballot style. pub area_id: String, + /// Presentation settings for the area associated with this ballot style. pub area_presentation: Option, + /// List of contests included in this ballot style. pub contests: Vec, + /// Presentation settings for the election event. pub election_event_presentation: Option, + /// Presentation settings for the election. pub election_presentation: Option, + /// Dates related to the election, such as voting period dates. pub election_dates: Option, + /// Annotations for the election event. pub election_event_annotations: Option>, + /// Annotations for the election. pub election_annotations: Option>, + /// Annotations for the election. pub area_annotations: Option, } @@ -2430,9 +2835,13 @@ pub struct BallotStyle { Clone, Default, )] +/// Struct to hold custom URLs for the election event. pub struct CustomUrls { + /// Custom login URL for the election event. pub login: Option, + /// Custom enrollment URL for the election event. pub enrollment: Option, + /// Custom SAML URL for the election event. pub saml: Option, } @@ -2447,11 +2856,13 @@ pub struct CustomUrls { BorshSerialize, BorshDeserialize, )] +/// Struct to represent the weight of an area, which can be used in +/// weighted voting systems. pub struct Weight(Option); impl Default for Weight { fn default() -> Self { - Self { 0: Some(1) } // default weight is 1 + Self(Some(1)) // default weight is 1 } } @@ -2474,18 +2885,29 @@ impl Deref for Weight { BorshDeserialize, Default, )] +/// Struct to hold annotations for an area. pub struct AreaAnnotations { + /// Weight of the area, which can be used in weighted voting systems. pub weight: Option, + /// Tally operation for the area, which can specify how the results for + /// the area should be handled during the tallying process. pub tally_operation: Option, } impl AreaAnnotations { + /// Get the weight of the area, returning the default weight if it is not specified. + #[must_use] pub fn get_weight(&self) -> Weight { self.weight.unwrap_or_default() } } impl Area { + /// Get the annotations for the area, deserializing them from the raw annotations if they are present. + /// If the annotations are not present, return `None`. If deserialization fails, return an error. + /// + /// # Errors + /// Returns an error if deserialization fails. pub fn read_annotations( &self, ) -> Result, Error> { @@ -2515,10 +2937,13 @@ impl Area { Default, JsonSchema, )] +/// Policy to determine whether weighted voting is enabled. pub enum WeightedVotingPolicy { + /// Weighted voting is disabled. #[default] #[serde(rename = "disabled-weighted-voting")] DISABLED_WEIGHTED_VOTING, + /// Weighted voting is allowed for areas. #[serde(rename = "areas-weighted-voting")] AREAS_WEIGHTED_VOTING, } @@ -2537,10 +2962,13 @@ pub enum WeightedVotingPolicy { Default, JsonSchema, )] +/// Policy to determine if and when delegated voting is allowed. pub enum DelegatedVotingPolicy { + /// Delegated voting is not allowed. #[default] #[serde(rename = "disabled")] DISABLED, + /// Delegated voting is allowed. #[serde(rename = "enabled")] ENABLED, } @@ -2559,11 +2987,14 @@ pub enum DelegatedVotingPolicy { Default, JsonSchema, )] +/// Policy to determine if the consolidated report should be generated. pub enum ConsolidatedReportPolicy { + /// The consolidated report will not be generated. #[default] #[strum(serialize = "do-not-generate")] #[serde(rename = "do-not-generate")] DO_NOT_GENERATE, + /// The consolidated report will be generated. #[strum(serialize = "generate")] #[serde(rename = "generate")] GENERATE, @@ -2584,12 +3015,15 @@ pub enum ConsolidatedReportPolicy { EnumString, JsonSchema, )] +/// Policy to determine how ties are broken in the election. pub enum TieBreakingPolicy { #[default] #[strum(serialize = "random")] #[serde(rename = "random")] + /// Ties are broken randomly. RANDOM, #[strum(serialize = "external-procedure")] #[serde(rename = "external-procedure")] + /// Ties are broken using an external procedure. EXTERNAL_PROCEDURE, } diff --git a/packages/sequent-core/src/ballot_codec/bases.rs b/packages/sequent-core/src/ballot_codec/bases.rs index b6e7e0720f0..71b58fe2d80 100644 --- a/packages/sequent-core/src/ballot_codec/bases.rs +++ b/packages/sequent-core/src/ballot_codec/bases.rs @@ -1,12 +1,20 @@ // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only -use crate::{ballot::*, types::ceremonies::CountingAlgType}; -use anyhow::Result; +use crate::ballot::Contest; +use crate::types::ceremonies::CountingAlgType; +use anyhow::{anyhow, Result}; use std::convert::TryInto; +/// Compute raw encoding bases for a contest's ballot representation. +/// +/// Implementors return a vector of per-field bases used when decoding/encoding +/// raw ballots. pub trait BasesCodec { - // get bases (no write-ins) + /// Get bases (no write-ins included by default). + /// + /// # Errors + /// Returns an error if any numeric conversions fail or values overflow. fn get_bases(&self) -> Result>; } @@ -14,17 +22,26 @@ impl BasesCodec for Contest { fn get_bases(&self) -> Result> { // Calculate the base for candidates. It depends on the // `contest.counting_algorithm`: - // - plurality-at-large: base 2 (value can be either 0 o 1) - // - preferential (*bordas*): contest.max + 1 - // - cummulative: contest.extra_options.cumulative_number_of_checkboxes + // - `plurality-at-large`: base 2 (value can be either 0 or 1) + // - `preferential` (`bordas`): `contest.max` + 1 + // - `cumulative`: `contest.extra_options.cumulative_number_of_checkboxes` // + 1 - let candidate_base: u64 = match self.get_counting_algorithm() { CountingAlgType::PluralityAtLarge => 2, - CountingAlgType::Cumulative => { - self.cumulative_number_of_checkboxes() + 1u64 + CountingAlgType::Cumulative => self + .cumulative_number_of_checkboxes() + .checked_add(1) + .ok_or_else(|| { + anyhow!("cumulative_number_of_checkboxes overflow") + })?, + _ => { + let sum = self + .max_votes + .checked_add(1) + .ok_or_else(|| anyhow!("max_votes overflow"))?; + sum.try_into() + .map_err(|e| anyhow!("invalid max_votes: {e}"))? } - _ => (self.max_votes + 1i64).try_into().unwrap(), }; let num_valid_candidates: usize = self @@ -44,7 +61,7 @@ impl BasesCodec for Contest { if self.allow_writeins() { let char_map = self.get_char_map(); let write_in_base = char_map.base(); - for candidate in self.candidates.iter() { + for candidate in &self.candidates { if candidate.is_write_in() { bases.push(write_in_base); } @@ -65,7 +82,7 @@ mod tests { fn test_contest_bases() { let fixtures = get_fixtures(); for fixture in fixtures { - println!("fixture: {}", &fixture.title); + info!("fixture: {}", fixture.title); let expected_error = fixture.expected_errors.and_then(|expected_map| { diff --git a/packages/sequent-core/src/ballot_codec/bigint.rs b/packages/sequent-core/src/ballot_codec/bigint.rs index 298e49f208a..90b35a41833 100644 --- a/packages/sequent-core/src/ballot_codec/bigint.rs +++ b/packages/sequent-core/src/ballot_codec/bigint.rs @@ -1,72 +1,107 @@ // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only -use crate::ballot::*; -use crate::ballot_codec::RawBallotContest; -use crate::ballot_codec::*; +use crate::ballot::Contest; +use crate::ballot_codec::{BasesCodec, RawBallotCodec, RawBallotContest}; use crate::mixed_radix::{decode, encode}; -use crate::plaintext::*; +use crate::plaintext::DecodedVoteContest; use crate::services::error_checker::check_contest; use num_bigint::BigUint; +/// Encode a `BigUint` into a little-endian `Vec`. +/// +/// # Errors +/// Returns an error if the conversion fails. pub fn encode_bigint_to_bytes(b: &BigUint) -> Result, String> { Ok(b.to_radix_le(256)) } + +/// Decode a `BigUint` from little-endian bytes. +/// +/// # Errors +/// Returns an error if the provided byte slice cannot be converted into a +/// `BigUint` (invalid radix conversion). pub fn decode_bigint_from_bytes(b: &[u8]) -> Result { BigUint::from_radix_le(b, 256) - .ok_or(format!("Conversion failed for bytes {:?}", b)) + .ok_or(format!("Conversion failed for bytes {b:?}")) } +/// Codec for encoding/decoding contests to/from `BigUint` using mixed-radix pub trait BigUIntCodec { + /// Encode a plaintext contest into its bigint representation. + /// + /// # Errors + /// Returns an error if encoding to a raw ballot or the mixed-radix + /// encoding fails. fn encode_plaintext_contest_bigint( &self, plaintext: &DecodedVoteContest, ) -> Result; + /// Decode a bigint into a plaintext contest. + /// + /// # Errors + /// Returns an error if the mixed-radix decoding or raw-ballot -> + /// plaintext conversion fails. fn decode_plaintext_contest_bigint( &self, bigint: &BigUint, ) -> Result; + /// Convert a bigint into a `RawBallotContest` (bases + choices). + /// + /// # Errors + /// Returns an error if decoding fails or base data cannot be retrieved. fn bigint_to_raw_ballot( &self, bigint: &BigUint, ) -> Result; + /// Estimate available write-in characters for a plaintext contest. + /// + /// # Errors + /// Returns an error when encoding or size computations fail. fn available_write_in_characters( &self, plaintext: &DecodedVoteContest, ) -> Result; } +/// Remove the last non-zero character from a `RawBallotContest`. fn remove_character(raw_ballot: &RawBallotContest) -> RawBallotContest { let mut bases = raw_ballot.bases.clone(); let mut choices = raw_ballot.choices.clone(); - let mut i = choices.len() - 1; - while 0 == choices[i] { - i -= 1; + if choices.is_empty() { + return RawBallotContest { bases, choices }; } + let i = choices + .iter() + .rposition(|&choice| choice != 0) + .unwrap_or(choices.len().saturating_sub(1)); + choices.remove(i); bases.remove(i); - RawBallotContest { - bases: bases, - choices: choices, - } + + RawBallotContest { bases, choices } } +/// Add a character placeholder before the first trailing zeros. fn add_character(raw_ballot: &RawBallotContest) -> RawBallotContest { let mut bases = raw_ballot.bases.clone(); let mut choices = raw_ballot.choices.clone(); - let mut i = choices.len() - 1; - while 0 == choices[i] && i > 0 { - i -= 1; + + if choices.is_empty() || bases.is_empty() { + return RawBallotContest { bases, choices }; } - choices.insert(i, bases[i] - 1); - bases.insert(i, bases[i]); - RawBallotContest { - bases: bases, - choices: choices, + + let i = choices.iter().rposition(|&c| c != 0).unwrap_or(0); + + if let Some(&base) = bases.get(i) { + choices.insert(i, base.saturating_sub(1)); + bases.insert(i, base); } + + RawBallotContest { bases, choices } } impl BigUIntCodec for Contest { @@ -75,37 +110,34 @@ impl BigUIntCodec for Contest { plaintext: &DecodedVoteContest, ) -> Result { let available_chars_estimate = - self.available_write_in_characters_estimate(&plaintext)?; + self.available_write_in_characters_estimate(plaintext)?; let mut raw_ballot = self.encode_to_raw_ballot(plaintext)?; let mut bigint = encode(&raw_ballot.choices, &raw_ballot.bases)?; let mut bytes_vec = encode_bigint_to_bytes(&bigint)?; if bytes_vec.len() <= 29 { if available_chars_estimate > 10 { - Ok(available_chars_estimate) - } else { - let mut count = 0; - while bytes_vec.len() <= 29 { - count += 1; - raw_ballot = add_character(&raw_ballot); - bigint = encode(&raw_ballot.choices, &raw_ballot.bases)?; - bytes_vec = encode_bigint_to_bytes(&bigint)?; - } - Ok(count - 1) + return Ok(available_chars_estimate); + } + let mut count: i32 = 0; + while bytes_vec.len() <= 29 { + count = count.saturating_add(1); + raw_ballot = add_character(&raw_ballot); + bigint = encode(&raw_ballot.choices, &raw_ballot.bases)?; + bytes_vec = encode_bigint_to_bytes(&bigint)?; } + Ok(count.saturating_sub(1)) + } else if available_chars_estimate < -10 { + Ok(available_chars_estimate) } else { - if available_chars_estimate < -10 { - Ok(available_chars_estimate) - } else { - let mut count = 0; - while bytes_vec.len() > 29 { - count += 1; - raw_ballot = remove_character(&raw_ballot); - bigint = encode(&raw_ballot.choices, &raw_ballot.bases)?; - bytes_vec = encode_bigint_to_bytes(&bigint)?; - } - Ok(-count) + let mut count: i32 = 0; + while bytes_vec.len() > 29 { + count = count.saturating_add(1); + raw_ballot = remove_character(&raw_ballot); + bigint = encode(&raw_ballot.choices, &raw_ballot.bases)?; + bytes_vec = encode_bigint_to_bytes(&bigint)?; } + Ok(count.saturating_sub(1)) } } @@ -123,7 +155,7 @@ impl BigUIntCodec for Contest { ) -> Result { let mut bases = self.get_bases().map_err(|e| e.to_string())?; let last_base = self.get_char_map().base(); - let choices = decode(&bases, &bigint, last_base)?; + let choices = decode(&bases, bigint, last_base)?; while bases.len() < choices.len() { bases.push(last_base); @@ -136,10 +168,9 @@ impl BigUIntCodec for Contest { &self, bigint: &BigUint, ) -> Result { - let raw_ballot = self.bigint_to_raw_ballot(&bigint)?; - + let raw_ballot = self.bigint_to_raw_ballot(bigint)?; let decoded_base = self.decode_from_raw_ballot(&raw_ballot)?; - let with_more_errors = check_contest(&self, &decoded_base); + let with_more_errors = check_contest(self, &decoded_base); Ok(with_more_errors) } } diff --git a/packages/sequent-core/src/ballot_codec/character_map.rs b/packages/sequent-core/src/ballot_codec/character_map.rs index 9db4aea2948..90b0d9ac0e3 100644 --- a/packages/sequent-core/src/ballot_codec/character_map.rs +++ b/packages/sequent-core/src/ballot_codec/character_map.rs @@ -1,11 +1,16 @@ // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only -use crate::ballot::*; +use crate::ballot::Contest; use phf::phf_map; use std::str; impl Contest { + /// Returns the appropriate character map for this contest. + /// + /// # Returns + /// A boxed trait object implementing `CharacterMap`. + #[must_use] pub fn get_char_map(&self) -> Box { if self.base32_writeins() { Box::new(Base32Map) @@ -15,62 +20,93 @@ impl Contest { } } +/// Trait for mapping between characters and bytes for ballot encoding. pub trait CharacterMap { + /// Converts a string to a vector of bytes according to the map. + /// + /// # Errors + /// Returns an error if the string contains characters that cannot be mapped. fn to_bytes(&self, s: &str) -> Result, String>; + + /// Converts a byte slice to a string according to the map. + /// + /// # Errors + /// Returns an error if the byte slice contains bytes that cannot be mapped. fn to_string(&self, bytes: &[u8]) -> Result; + + /// Returns the base (number of possible values) for this character map. fn base(&self) -> u64; } +/// UTF-8 character map for ballot encoding. pub struct Utf8Map; +/// Base32 character map for ballot encoding. pub struct Base32Map; impl CharacterMap for Utf8Map { + /// Converts a string to a vector of UTF-8 bytes. + /// + /// # Errors + /// This implementation never returns an error. fn to_bytes(&self, s: &str) -> Result, String> { Ok(s.as_bytes().to_vec()) } + + /// Converts a UTF-8 byte slice to a string. + /// + /// # Errors + /// Returns an error if the bytes are not valid UTF-8. fn to_string(&self, bytes: &[u8]) -> Result { - str::from_utf8(&bytes) - .map_err(|e| format!("{}", e)) - .map(|s| s.to_string()) + str::from_utf8(bytes) + .map_err(|e| e.to_string()) + .map(str::to_string) } + + /// Returns the base for UTF-8 (256). fn base(&self) -> u64 { 256u64 } } impl CharacterMap for Base32Map { + /// Converts a string to a vector of Base32-mapped bytes. + /// + /// # Errors + /// Returns an error if the string contains characters that cannot be mapped. fn to_bytes(&self, s: &str) -> Result, String> { s.to_uppercase() .chars() .map(|c| { - TO_BYTE - .get(&c) - .ok_or(format!( - "Character '{}' cannot be mapped to byte", - c - )) - .copied() + TO_BYTE.get(&c).copied().ok_or_else(|| { + format!("Character '{c}' cannot be mapped to byte") + }) }) .collect() } + + /// Converts a Base32-mapped byte slice to a string. + /// + /// # Errors + /// Returns an error if the bytes cannot be mapped to characters. fn to_string(&self, bytes: &[u8]) -> Result { let chars: Result, String> = bytes .iter() - .map(|b| { - TO_CHAR - .get(&b) - .ok_or(format!("Byte '{}' cannot be mapped to char", b)) - .copied() + .map(|&b| { + TO_CHAR.get(&b).copied().ok_or_else(|| { + format!("Byte '{b}' cannot be mapped to char") + }) }) .collect(); - Ok(String::from_iter(chars?)) } + + /// Returns the base for Base32 (32). fn base(&self) -> u64 { 32u64 } } +/// PHF map from characters to bytes for Base32 encoding. pub static TO_BYTE: phf::Map = phf_map! { // 0 is reserved for null terminator 'A' => 1u8, @@ -105,6 +141,7 @@ pub static TO_BYTE: phf::Map = phf_map! { '.' => 30u8, ',' => 31u8, }; +/// PHF map from bytes to characters for Base32 encoding. pub static TO_CHAR: phf::Map = phf_map! { // 0 is reserved for null terminator 1u8 => 'A', diff --git a/packages/sequent-core/src/ballot_codec/checker.rs b/packages/sequent-core/src/ballot_codec/checker.rs index e6df8d64427..64392b9a327 100644 --- a/packages/sequent-core/src/ballot_codec/checker.rs +++ b/packages/sequent-core/src/ballot_codec/checker.rs @@ -14,75 +14,85 @@ use crate::{ }; use std::collections::HashMap; +/// Result of a ballot checker operation, containing errors and alerts. #[derive(Default, PartialEq, Eq, Debug, Clone)] pub struct CheckerResult { + /// List of invalid errors found during checking. pub invalid_errors: Vec, + /// List of invalid alerts found during checking. pub invalid_alerts: Vec, } impl DecodedVoteContest { - pub fn update(&mut self, data: CheckerResult) -> () { + /// Update this contest with errors and alerts from a checker result. + pub fn update(&mut self, data: CheckerResult) { self.invalid_errors.extend(data.invalid_errors); self.invalid_alerts.extend(data.invalid_alerts); } } impl DecodedContestChoices { - pub fn update(&mut self, data: CheckerResult) -> () { + /// Update this contest with errors and alerts from a checker result. + pub fn update(&mut self, data: CheckerResult) { self.invalid_errors.extend(data.invalid_errors); self.invalid_alerts.extend(data.invalid_alerts); } } +/// Checks the validity of max and min votes policy. +/// +/// # Returns +/// Tuple of (`max_votes_opt`, `min_votes_opt`, `checker_result`) +#[must_use] pub fn check_max_min_votes_policy( max_votes: i64, min_votes: i64, ) -> (Option, Option, CheckerResult) { - let mut checker_result: CheckerResult = Default::default(); + let mut checker_result = CheckerResult::default(); - let max_votes_opt: Option = match usize::try_from(max_votes) { - Ok(val) => Some(val), - Err(_) => { - checker_result.invalid_errors.push(InvalidPlaintextError { - error_type: InvalidPlaintextErrorType::EncodingError, - candidate_id: None, - message: Some("errors.encoding.invalidMaxVotes".to_string()), - message_map: HashMap::from([( - "max".to_string(), - max_votes.to_string(), - )]), - }); - - None - } + let max_votes_opt = if let Ok(val) = usize::try_from(max_votes) { + Some(val) + } else { + checker_result.invalid_errors.push(InvalidPlaintextError { + error_type: InvalidPlaintextErrorType::EncodingError, + candidate_id: None, + message: Some("errors.encoding.invalidMaxVotes".to_string()), + message_map: HashMap::from([( + "max".to_string(), + max_votes.to_string(), + )]), + }); + None }; - let min_votes_opt: Option = match usize::try_from(min_votes) { - Ok(val) => Some(val), - Err(_) => { - checker_result.invalid_errors.push(InvalidPlaintextError { - error_type: InvalidPlaintextErrorType::EncodingError, - candidate_id: None, - message: Some("errors.encoding.invalidMinVotes".to_string()), - message_map: HashMap::from([( - "min".to_string(), - min_votes.to_string(), - )]), - }); - - None - } + let min_votes_opt = if let Ok(val) = usize::try_from(min_votes) { + Some(val) + } else { + checker_result.invalid_errors.push(InvalidPlaintextError { + error_type: InvalidPlaintextErrorType::EncodingError, + candidate_id: None, + message: Some("errors.encoding.invalidMinVotes".to_string()), + message_map: HashMap::from([( + "min".to_string(), + min_votes.to_string(), + )]), + }); + None }; (max_votes_opt, min_votes_opt, checker_result) } +/// Checks if the number of selected candidates meets the minimum votes policy. +/// +/// # Returns +/// `CheckerResult` with errors if the policy is violated. +#[must_use] pub fn check_min_vote_policy( num_selected_candidates: usize, min_votes: usize, ) -> CheckerResult { - let mut checker_result: CheckerResult = Default::default(); - + let mut checker_result = CheckerResult::default(); if num_selected_candidates < min_votes { checker_result.invalid_errors.push(InvalidPlaintextError { error_type: InvalidPlaintextErrorType::Implicit, @@ -100,16 +110,18 @@ pub fn check_min_vote_policy( checker_result } +/// Checks the blank vote policy for a contest. +/// +/// # Returns +/// `CheckerResult` with errors or alerts if the policy is violated. +#[must_use] pub fn check_blank_vote_policy( presentation: &ContestPresentation, num_selected_candidates: usize, is_explicit_invalid: bool, ) -> CheckerResult { - let mut checker_result: CheckerResult = Default::default(); - - let blank_vote_policy = - presentation.blank_vote_policy.clone().unwrap_or_default(); - + let mut checker_result = CheckerResult::default(); + let blank_vote_policy = presentation.blank_vote_policy.unwrap_or_default(); if num_selected_candidates == 0 && !is_explicit_invalid && EBlankVotePolicy::ALLOWED != blank_vote_policy @@ -134,12 +146,17 @@ pub fn check_blank_vote_policy( checker_result } +/// Checks the over-vote policy for a contest. +/// +/// # Returns +/// `CheckerResult` with errors or alerts if the policy is violated. +#[must_use] pub fn check_over_vote_policy( presentation: &ContestPresentation, num_selected_candidates: usize, max_votes: usize, ) -> CheckerResult { - let mut checker_result: CheckerResult = Default::default(); + let mut checker_result = CheckerResult::default(); if num_selected_candidates == max_votes && presentation.over_vote_policy == Some(EOverVotePolicy::NOT_ALLOWED_WITH_MSG_AND_DISABLE) @@ -176,37 +193,35 @@ pub fn check_over_vote_policy( checker_result.invalid_errors.push(text_error()); match presentation.over_vote_policy.unwrap_or_default() { - EOverVotePolicy::ALLOWED => (), - EOverVotePolicy::ALLOWED_WITH_MSG => { - checker_result.invalid_alerts.push(text_error()) - } - EOverVotePolicy::ALLOWED_WITH_MSG_AND_ALERT => { - checker_result.invalid_alerts.push(text_error()) - } - EOverVotePolicy::NOT_ALLOWED_WITH_MSG_AND_ALERT => { - checker_result.invalid_alerts.push(text_error()); - } - EOverVotePolicy::NOT_ALLOWED_WITH_MSG_AND_DISABLE => { + EOverVotePolicy::ALLOWED => {} + EOverVotePolicy::ALLOWED_WITH_MSG + | EOverVotePolicy::ALLOWED_WITH_MSG_AND_ALERT + | EOverVotePolicy::NOT_ALLOWED_WITH_MSG_AND_ALERT + | EOverVotePolicy::NOT_ALLOWED_WITH_MSG_AND_DISABLE => { checker_result.invalid_alerts.push(text_error()); } - }; + } } checker_result } +/// Checks the under-vote policy for a contest. +/// +/// # Returns +/// `CheckerResult` with alerts if the policy is violated. +#[must_use] pub fn check_under_vote_policy( presentation: &ContestPresentation, num_selected_candidates: usize, max_votes: Option, min_votes: Option, ) -> CheckerResult { - let mut checker_result: CheckerResult = Default::default(); + let mut checker_result = CheckerResult::default(); // Handle undervote alerts. Please note that the case of - // `num_selected_candidates < min_votes` is handle in prev step and + // `num_selected_candidates < min_votes` is handled in prev step and // is independent of `under_vote_policy`, it's an invalid vote no // matter what - let under_vote_policy = - presentation.under_vote_policy.clone().unwrap_or_default(); + let under_vote_policy = presentation.under_vote_policy.unwrap_or_default(); let min_votes = min_votes.unwrap_or(0); if let Some(max_votes) = max_votes { if under_vote_policy != EUnderVotePolicy::ALLOWED @@ -232,14 +247,16 @@ pub fn check_under_vote_policy( checker_result } +/// Checks the duplicated rank policy for a contest. +/// +/// # Returns +/// `CheckerResult` with errors if the policy is violated. +#[must_use] pub fn check_duplicated_rank_policy( presentation: &ContestPresentation, ) -> CheckerResult { - let mut checker_result: CheckerResult = Default::default(); - let policy = presentation - .duplicated_rank_policy - .clone() - .unwrap_or_default(); + let mut checker_result = CheckerResult::default(); + let policy = presentation.duplicated_rank_policy.unwrap_or_default(); let error = InvalidPlaintextError { error_type: InvalidPlaintextErrorType::Implicit, candidate_id: None, @@ -255,14 +272,16 @@ pub fn check_duplicated_rank_policy( checker_result } +/// Checks the preference gaps policy for a contest. +/// +/// # Returns +/// `CheckerResult` with errors if the policy is violated. +#[must_use] pub fn check_preference_gaps_policy( presentation: &ContestPresentation, ) -> CheckerResult { - let mut checker_result: CheckerResult = Default::default(); - let policy = presentation - .preference_gaps_policy - .clone() - .unwrap_or_default(); + let mut checker_result = CheckerResult::default(); + let policy = presentation.preference_gaps_policy.unwrap_or_default(); let error = InvalidPlaintextError { error_type: InvalidPlaintextErrorType::Implicit, candidate_id: None, @@ -278,11 +297,16 @@ pub fn check_preference_gaps_policy( checker_result } +/// Checks the invalid vote policy for a contest. +/// +/// # Returns +/// `CheckerResult` with errors or alerts if the policy is violated. +#[must_use] pub fn check_invalid_vote_policy( presentation: &ContestPresentation, is_explicit_invalid: bool, ) -> CheckerResult { - let mut checker_result: CheckerResult = Default::default(); + let mut checker_result = CheckerResult::default(); let invalid_vote_policy = presentation.invalid_vote_policy.clone().unwrap_or_default(); // explicit invalid error diff --git a/packages/sequent-core/src/ballot_codec/mod.rs b/packages/sequent-core/src/ballot_codec/mod.rs index 23041568a58..ed5fc87cd02 100644 --- a/packages/sequent-core/src/ballot_codec/mod.rs +++ b/packages/sequent-core/src/ballot_codec/mod.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only - +#![allow(missing_docs)] pub mod bases; pub mod bigint; pub mod character_map; diff --git a/packages/sequent-core/src/ballot_codec/multi_ballot.rs b/packages/sequent-core/src/ballot_codec/multi_ballot.rs index bb2638f6a85..b56ec3bbd2a 100644 --- a/packages/sequent-core/src/ballot_codec/multi_ballot.rs +++ b/packages/sequent-core/src/ballot_codec/multi_ballot.rs @@ -6,9 +6,7 @@ use std::num::TryFromIntError; // SPDX-License-Identifier: AGPL-3.0-only use super::bigint; use super::{vec, RawBallotContest}; -use crate::ballot::{ - AreaPresentation, BallotStyle, Candidate, Contest, EUnderVotePolicy, -}; +use crate::ballot::{BallotStyle, Candidate, Contest}; use crate::ballot_codec::{ check_blank_vote_policy, check_invalid_vote_policy, check_max_min_votes_policy, check_min_vote_policy, check_over_vote_policy, @@ -18,7 +16,7 @@ use crate::error::BallotError; use crate::mixed_radix; use crate::plaintext::{ map_decoded_ballot_choices_to_decoded_contests, DecodedVoteContest, - InvalidPlaintextError, InvalidPlaintextErrorType, + InvalidPlaintextError, }; use crate::types::ceremonies::CountingAlgType; use num_bigint::BigUint; @@ -38,7 +36,7 @@ use num_traits::{ToPrimitive, Zero}; /// provided there is sufficient space. /// /// An upper bound on the bytes needed to encode a multi contest ballot -/// can be computed with BallotChoices::maximum_size_bytes, given a list +/// can be computed with [`BallotChoices::maximum_size_bytes`], given a list /// of contests. /// /// This ballot only supports plurality counting @@ -51,7 +49,8 @@ pub struct BallotChoices { pub counting_algorithm: CountingAlgType, } impl BallotChoices { - pub fn new( + #[must_use] + pub const fn new( is_explicit_invalid: bool, choices: Vec, counting_algorithm: CountingAlgType, @@ -74,7 +73,8 @@ pub struct ContestChoices { pub choices: Vec, } impl ContestChoices { - pub fn new(contest_id: String, choices: Vec) -> Self { + #[must_use] + pub const fn new(contest_id: String, choices: Vec) -> Self { ContestChoices { contest_id, // is_explicit_invalid, @@ -82,10 +82,11 @@ impl ContestChoices { } } - /// Return contest choices from a DecodedVoteContest + /// Return contest choices from a `DecodedVoteContest` /// /// Used in testing when generating ballots with the non-sparse - /// encoding (non multi-contest ballots) + /// encoding (non multi-contest ballots). + #[must_use] pub fn from_decoded_vote_contest(dcv: &DecodedVoteContest) -> Self { let choices: Vec = dcv .choices @@ -109,7 +110,7 @@ impl ContestChoices { Serialize, Deserialize, JsonSchema, PartialEq, Eq, Debug, Clone, Hash, )] -/// A single choice within a Contest. +/// A single choice within a `Contest`. /// /// Does not support write-ins. pub struct ContestChoice { @@ -120,7 +121,8 @@ pub struct ContestChoice { pub selected: i64, } impl ContestChoice { - pub fn new(candidate_id: String, selected: i64) -> Self { + #[must_use] + pub const fn new(candidate_id: String, selected: i64) -> Self { ContestChoice { candidate_id, selected, @@ -137,7 +139,8 @@ pub struct DecodedContestChoices { pub invalid_alerts: Vec, } impl DecodedContestChoices { - pub fn new( + #[must_use] + pub const fn new( contest_id: String, choices: Vec, invalid_errors: Vec, @@ -154,7 +157,7 @@ impl DecodedContestChoices { #[derive( Serialize, Deserialize, JsonSchema, PartialEq, Eq, Debug, Clone, Hash, )] -/// A decoded contest choice contains the candidate_id as a String. +/// A decoded contest choice contains the `candidate_id` as a String. pub struct DecodedContestChoice(pub String); /// The choices for the set of contests returned when decoding a multi-content @@ -167,24 +170,28 @@ pub struct DecodedBallotChoices { } impl BallotStyle { - /// Returns Error if all counting algorithms are not the same. + /// Returns the counting algorithm if all contests use the same one. + /// + /// # Errors + /// Returns an error if contests use different counting algorithms. pub fn get_counting_algorithm( &self, ) -> Result { let first_counting_algorithm: CountingAlgType = self .contests .first() - .map(|c| c.get_counting_algorithm()) + .map(Contest::get_counting_algorithm) .unwrap_or_default(); - match self + if self .contests .iter() .all(|c| c.get_counting_algorithm() == first_counting_algorithm) { - true => Ok(first_counting_algorithm), - false => Err(BallotError::ConsistencyCheck( + Ok(first_counting_algorithm) + } else { + Err(BallotError::ConsistencyCheck( "Mixing different counting algorithms".to_string(), - )), + )) } } } @@ -194,23 +201,23 @@ impl BallotChoices { /// /// The following steps take place: /// - /// 1) BallotChoices -> RawBallotContest (this is a mixed-radix structure) - /// 2) RawBallotContest -> BigUint - /// 3) BigUint -> Vec - /// 4) Vec -> [u8; 30] + /// 1) `BallotChoices` -> `RawBallotContest` (this is a mixed-radix structure) + /// 2) `RawBallotContest` -> `BigUint` + /// 3) `BigUint` -> `Vec` + /// 4) `Vec` -> `[u8; 30]` /// /// Returns a fixed-size array of 30 bytes encoding this ballot. + /// + /// # Errors + /// Returns an error if encoding fails at any step. pub fn encode_to_30_bytes( &self, config: &BallotStyle, ) -> Result<[u8; 30], String> { - let raw_ballot = self.encode_to_raw_ballot(&config)?; - + let raw_ballot = self.encode_to_raw_ballot(config)?; let bigint = mixed_radix::encode(&raw_ballot.choices, &raw_ballot.bases)?; - let bytes = bigint::encode_bigint_to_bytes(&bigint)?; - vec::encode_vec_to_array(&bytes) } @@ -220,31 +227,34 @@ impl BallotChoices { /// /// * The plaintexts for a given contest were not found. /// * The length of a contest choice vector was greater than - /// contest.max_votes. + /// `contest.max_votes`. /// * The length of a contest choice vector was smaller than - /// contest.min_votes. + /// `contest.min_votes`. /// * The set choices (!=0) for a contest had duplicates. /// * The number of set choices (!= 0) for a given contest choice vector was - /// smaller than contest.min_votes. + /// smaller than `contest.min_votes`. /// * A choice id in a given contest choice vector was invalid. /// /// The resulting encoded choice vector is a /// contiguous list of contest choices groups, each of - /// size contest.max_votes. An alternative implementation + /// size `contest.max_votes`. An alternative implementation /// could add explicit separators between contest choice /// groups. /// /// Returns the encoded ballot, with n sets of contest choices - /// each of size contest.max_votes, plus one invalid flag. + /// each of size `contest.max_votes`, plus one invalid flag. /// The total number of choices is given by the following: - /// contests.iter().fold(0, |a, b| a + b.max_votes) + 1 + /// contests.iter().fold(0, |a, b| a + b.`max_votes`) + 1 + /// Encodes this ballot into a raw ballot (mixed radix representation). + /// + /// # Errors + /// Returns an error if contest choices are missing, or if the number of choices is out of bounds. fn encode_to_raw_ballot( &self, config: &BallotStyle, ) -> Result { let contests = self.get_contests(config)?; - - let bases = Self::get_bases(&contests).map_err(|e| e.to_string())?; + let bases = Self::get_bases(&contests).map_err(|e| e.clone())?; let mut choices: Vec = vec![]; // Construct a map of plaintexts, this will allow us to @@ -263,28 +273,23 @@ impl BallotChoices { let mut sorted_contests = contests.clone(); sorted_contests.sort_by_key(|c| c.id.clone()); - let invalid_vote: u64 = if self.is_explicit_invalid { 1 } else { 0 }; + let invalid_vote: u64 = u64::from(self.is_explicit_invalid); choices.push(invalid_vote); // Iterate in contest order for contest in sorted_contests { - let plaintext = plaintexts_map.get(&contest.id).ok_or(format!( - "Could not find plaintexts for contest {:?}", - contest + let plaintext = plaintexts_map.get(&contest.id).ok_or_else(|| format!( + "Could not find plaintexts for contest {contest:?}", ))?; - - let contest_choices = self.encode_contest(&contest, &plaintext)?; - - // Accumulate the choices for each contest + let contest_choices = self.encode_contest(&contest, plaintext)?; choices.extend(contest_choices); } - Ok(RawBallotContest { bases, choices }) } /// Encodes one contest in the ballot /// - /// Returns a choice vector of length contest.max_votes, + /// Returns a choice vector of length `contest.max_votes`, /// which the caller will append to the overall ballot choice vector. fn encode_contest( &self, @@ -315,11 +320,11 @@ impl BallotChoices { let max_votes: usize = contest .max_votes .try_into() - .map_err(|_| format!("u64 conversion on contest max_votes"))?; + .map_err(|_| "u64 conversion on contest max_votes".to_string())?; let min_votes: usize = contest .min_votes .try_into() - .map_err(|_| format!("u64 conversion on contest min_votes"))?; + .map_err(|_| "u64 conversion on contest min_votes".to_string())?; if plaintext.choices.len() < min_votes { return Err(format!( @@ -332,31 +337,32 @@ impl BallotChoices { )); } - let choices_order = match self.counting_algorithm.is_preferential() { - true => { - // Setting the choices in order of preference to support - // preferencial multiballot. When decoding, we - // will take the order of the - // vector to determine the order of preference of each choice. - // The invalid ones with seected = -1 will be at the beginning - // but will be ignored when decoding anyway - // because are marked to 0. - let mut pref_choices: Vec = - plaintext.choices.clone(); - pref_choices.sort_by_key(|c| c.selected); - pref_choices - } - false => plaintext.choices.clone(), + let choices_order = if self.counting_algorithm.is_preferential() { + // Setting the choices in order of preference to support + // preferencial multiballot. When decoding, we + // will take the order of the + // vector to determine the order of preference of each choice. + // The invalid ones with seected = -1 will be at the beginning + // but will be ignored when decoding anyway + // because are marked to 0. + let mut pref_choices: Vec = + plaintext.choices.clone(); + pref_choices.sort_by_key(|c| c.selected); + pref_choices + } else { + plaintext.choices.clone() }; // We set all values as unset (0) by default let mut contest_choices = vec![0u64; max_votes]; let mut marked = 0; for p in &choices_order { - let (position, _candidate) = - candidates_map.get(&p.candidate_id).ok_or_else(|| { - "choice id is not a valid candidate".to_string() - })?; + if marked == max_votes { + break; + } + let (position, _candidate) = candidates_map + .get(&p.candidate_id) + .ok_or("choice id is not a valid candidate")?; // The slot's base is // @@ -370,38 +376,45 @@ impl BallotChoices { // list, sorted by id. The same sorting order must be used // to interpret choices when decoding. let mark = if p.selected > -1 { - (position + 1).try_into().map_err(|_| { - format!("u64 conversion on candidate position") + let pos = position.checked_add(1).ok_or_else(|| { + "Overflow in candidate position addition".to_string() + })?; + pos.try_into().map_err(|_| { + "u64 conversion on candidate position".to_string() })? } else { // unset 0 }; - - contest_choices[marked] = mark; - marked += 1; - - if marked == max_votes { - break; + if let Some(slot) = contest_choices.get_mut(marked) { + *slot = mark; + } else { + return Err("Index out of bounds when writing contest_choices" + .to_string()); } + marked = marked + .checked_add(1) + .ok_or_else(|| "Overflow in marked addition".to_string())?; } // There can be no duplicates among the set values (!= 0) let set_values: Vec = contest_choices .iter() - .cloned() + .copied() .filter(|v| *v != 0) .collect(); - let unique: HashSet = - HashSet::from_iter(set_values.iter().cloned()); + let unique: HashSet = set_values.iter().copied().collect(); if unique.len() != set_values.len() { - return Err(format!("Plaintext vector contained duplicate values")); + return Err( + "Plaintext vector contained duplicate values".to_string() + ); } if marked < min_votes { - return Err(format!( + return Err( "Plaintext vector contained fewer than min_votes marks" - )); + .to_string(), + ); } Ok(contest_choices) @@ -411,156 +424,181 @@ impl BallotChoices { /// /// The following steps take place: /// - /// 1) [u8; 30] -> Vec - /// 2) Vec -> BigUint - /// 3) BigUint -> RawBallotContest (this is a mixed-radix structure) - /// 4) RawBallotContest -> DecodedBallotChoices + /// 1) `[u8; 30]` -> `Vec` + /// 2) `Vec` -> `BigUint` + /// 3) `BigUint` -> `RawBallotContest` (this is a mixed-radix structure) + /// 4) `RawBallotContest` -> `DecodedBallotChoices` /// /// The following conditions will return an error. /// - /// ================================= + /// + /// ================================= + /// + /// + /// # Errors + /// Returns an error if decoding fails at any step. /// FIXME /// In the current implementation these errors short /// circuit the operation. /// - /// * choices.len() != expected_choices + 1 - /// * let Some(candidate) = candidate else { - /// return Err(format!( - /// "Candidate selection out of range {} (length: {})", - /// next, - /// sorted_candidates.len() - /// ));}; - /// * let next = usize::try_from(next).map_err(|_| { format!("u64 -> usize - /// conversion on plaintext choice") })?; - /// * is_explicit_invalid && !self.allow_explicit_invalid() { - /// * max_votes: Option = match usize::try_from(self.max_votes) - /// * min_votes: Option = match usize::try_from(self.min_votes) - /// * decoded_contest = handle_over_vote_policy( - /// * num_selected_candidates < min_votes - /// * under_vote_policy != EUnderVotePolicy::ALLOWED && - /// num_selected_candidates < max_votes && num_selected_candidates >= - /// min_votes - /// * if let Some(blank_vote_policy) = presentation.blank_vote_policy { if - /// num_selected_candidates == 0 - /// ================================= + /// * `choices.len() != expected_choices + 1` + /// * `let Some(candidate) = candidate else { return Err(format!("Candidate selection out of range {} (length: {})", next, sorted_candidates.len())); };` + /// * `let next = usize::try_from(next).map_err(|_| { format!("u64 -> usize conversion on plaintext choice") })?;` + /// * `is_explicit_invalid && !self.allow_explicit_invalid()` + /// * `max_votes: Option = match usize::try_from(self.max_votes)` + /// * `min_votes: Option = match usize::try_from(self.min_votes)` + /// * `decoded_contest = handle_over_vote_policy(` + /// * `num_selected_candidates < min_votes` + /// * `under_vote_policy != EUnderVotePolicy::ALLOWED && num_selected_candidates < max_votes && num_selected_candidates >= min_votes` + /// * `if let Some(blank_vote_policy) = presentation.blank_vote_policy { if num_selected_candidates == 0` + /// ================================= /// /// * The number of overall choices does not match the expected value - /// * A contest choice is out of range (larger than the number of - /// candidates) - /// * There are fewer contest choices than contest.min_votes - /// * There is an i64 -> u64 conversion error on - /// * contest.min_votes - /// * contest.max_votes + /// * A contest choice is out of range (larger than the number of candidates) + /// * There are fewer contest choices than `contest.min_votes` + /// * There is an i64 -> u64 conversion error on `contest.min_votes` + /// * `contest.max_votes` /// * There is a u64 -> usize conversion error on a choice /// /// The decoding processes the choices vector as a /// contiguous list of contest choices groups, each of - /// size contest.max_votes. An alternative implementation + /// size `contest.max_votes`. An alternative implementation /// could add explicit separators between contest choice /// groups. /// /// Returns the decoded ballot. Because this is a multi - /// contest ballot, it will have n ContestChoices and + /// contest ballot, it will have n `ContestChoices` and /// an overall invalid flag. pub fn decode_from_30_bytes( bytes: &[u8; 30], style: &BallotStyle, ) -> Result { - let bytes = vec::decode_array_to_vec(&bytes); + let bytes = vec::decode_array_to_vec(bytes); let bigint = bigint::decode_bigint_from_bytes(&bytes)?; Self::decode_from_bigint(&bigint, &style.contests, None) } - /// Returns a decoded ballot from a BigUint + /// Returns a decoded ballot from a `BigUint`. /// - /// Convenience method. + /// # Errors + /// Returns an error if decoding fails. pub fn decode_from_bigint( bigint: &BigUint, - contests: &Vec, + contests: &[Contest], serial_number_counter: Option<&mut u32>, ) -> Result { - let raw_ballot = Self::bigint_to_raw_ballot(&bigint, contests)?; + let raw_ballot = Self::bigint_to_raw_ballot(bigint, contests)?; Self::decode(&raw_ballot, contests, serial_number_counter) } /// Decode a mixed radix representation of the ballot. + /// Decodes a mixed radix representation of the ballot. + /// + /// # Errors + /// Returns an error if decoding fails. pub fn decode( raw_ballot: &RawBallotContest, - contests: &Vec, + contests: &[Contest], serial_number_counter: Option<&mut u32>, ) -> Result { let mut contest_choices: Vec = vec![]; let choices = raw_ballot.choices.clone(); // Each contest contributes max_votes slots - let expected_choices = contests.iter().fold(0, |a, b| a + b.max_votes); + let expected_choices = contests + .iter() + .try_fold(0i64, |a, b| a.checked_add(b.max_votes)) + .ok_or_else(|| "Overflow in sum of max_votes".to_string())?; let expected_choices: usize = expected_choices.try_into().map_err(|_| { - format!("i64 -> usize conversion on contest max_votes") + "i64 -> usize conversion on contest max_votes".to_string() })?; - - // The first slot is used for explicit invalid ballot, so + 1 - if choices.len() != expected_choices + 1 { + // The first slot is used for explicit invalid ballot, so checked add 1 + let expected_choices_plus_1 = expected_choices + .checked_add(1) + .ok_or_else(|| "Overflow in expected_choices + 1".to_string())?; + if choices.len() != expected_choices_plus_1 { return Err(format!( "Unexpected number of choices {} != {}", choices.len(), - expected_choices + expected_choices_plus_1 )); } // The order of the contests is computed sorting by id. // The selections must be encoded to and decoded from a ballot // following this order, given by contest.id. - let mut sorted_contests = contests.clone(); + let mut sorted_contests = contests.to_vec(); sorted_contests.sort_by_key(|c| c.id.clone()); // This explicit invalid flag is at the ballot level - let is_explicit_invalid: bool = !choices.is_empty() && (choices[0] > 0); + let is_explicit_invalid: bool = match choices.first() { + Some(val) => *val > 0, + None => false, + }; // Skip past the explicit invalid slot - let mut choice_index = 1; + let mut choice_index: usize = 1; for contest in sorted_contests { let max_votes: usize = contest.max_votes.try_into().map_err(|_| { - format!("i64 -> usize conversion on contest max_votes") + "i64 -> usize conversion on contest max_votes".to_string() + })?; + let end_index = + choice_index.checked_add(max_votes).ok_or_else(|| { + "Overflow in choice_index + max_votes".to_string() + })?; + if end_index > choices.len() { + return Err("Choice index out of bounds when decoding contest" + .to_string()); + } + let choice_slice = + choices.get(choice_index..end_index).ok_or_else(|| { + "Slicing error: invalid contest choice range".to_string() })?; let next = Self::decode_contest( &contest, - &choices[choice_index..], + choice_slice, is_explicit_invalid, )?; - choice_index += max_votes; + choice_index = end_index; contest_choices.push(next); } let serial_number = match serial_number_counter { Some(serial_number) => { let sn = Some(format!("{:09}", *serial_number)); - *serial_number += 1; + *serial_number = serial_number + .checked_add(1) + .ok_or_else(|| "serial_number overflow".to_string())?; sn } None => None, }; - let ret = DecodedBallotChoices { + Ok(DecodedBallotChoices { is_explicit_invalid, choices: contest_choices, serial_number, - }; - - Ok(ret) + }) } /// Decodes one contest in the ballot /// - /// Returns a ContestChoice for the choices slice argument, - /// which will be read up to position contest.max_votes. This - /// ContestChoice will be added to the overall DecodedBallotChoices. - /// Values set to 0 (unset) will not return a ContestChoice. + /// Returns a `DecodedContestChoices` for the choices slice argument, + /// which will be read up to position `contest.max_votes`. This + /// `DecodedContestChoices` will be added to the overall `DecodedBallotChoices`. + /// Values set to 0 (unset) will not return a `DecodedContestChoice`. /// It is the responsibility of the caller to advance the choice slice /// as choices are decoded. + /// + /// # Errors + /// Returns an error if decoding fails. + /// + /// # Panics + /// Panics if the serial number counter overflows (should not occur in normal operation). fn decode_contest( contest: &Contest, choices: &[u64], @@ -576,55 +614,51 @@ impl BallotChoices { // position in the candidate list, sorted by id. let mut sorted_candidates: Vec = contest .candidates - .clone() - .into_iter() + .iter() .filter(|candidate| !candidate.is_explicit_invalid()) + .cloned() .collect(); - sorted_candidates.sort_by_key(|c| c.id.clone()); let max_votes: usize = contest.max_votes.try_into().map_err(|_| { - format!("i64 -> usize conversion on contest max_votes") + "i64 -> usize conversion on contest max_votes".to_string() })?; let min_votes: usize = contest.min_votes.try_into().map_err(|_| { - format!("i64 -> usize conversion on contest min_votes") + "i64 -> usize conversion on contest min_votes".to_string() })?; let mut next_choices = vec![]; - for i in 0..max_votes { - let next = choices[i]; - let next = usize::try_from(next).map_err(|_| { - format!("u64 -> usize conversion on plaintext choice") + for (_i, &raw_choice) in choices.iter().enumerate().take(max_votes) { + let next = usize::try_from(raw_choice).map_err(|_| { + "u64 -> usize conversion on plaintext choice".to_string() })?; - // Unset if next == 0 { continue; } // choices are offset by 1 to allow for the unset value at 0 - let next = next - 1; + let next = next + .checked_sub(1) + .ok_or_else(|| "Underflow in next - 1".to_string())?; // A choice of a candidate is represented as that // candidate's position in the candidate // list, sorted by id. The same sorting order must be used // to interpret choices when encoding. - let candidate = sorted_candidates.get(next); - let Some(candidate) = candidate else { - return Err(format!( + let candidate = sorted_candidates.get(next).ok_or_else(|| { + format!( "Candidate selection out of range {} (length: {})", next, sorted_candidates.len() - )); - }; - + ) + })?; let choice = DecodedContestChoice(candidate.id.clone()); - next_choices.push(choice); } // Duplicate values will be ignored let unique: HashSet = - HashSet::from_iter(next_choices.iter().cloned()); - decoded_contest.choices = unique.clone().into_iter().collect(); + next_choices.iter().cloned().collect(); + decoded_contest.choices = unique.iter().cloned().collect(); let num_selected_candidates = next_choices.len(); @@ -638,9 +672,10 @@ impl BallotChoices { // The opposite is impossible due to the above // loop's range 0..max_votes if unique.len() < min_votes { - return Err(format!( + return Err( "Raw ballot vector contained fewer than min_votes choices" - )); + .to_string(), + ); } let presentation = contest.presentation.clone().unwrap_or_default(); @@ -653,7 +688,7 @@ impl BallotChoices { check_max_min_votes_policy(contest.max_votes, contest.min_votes); decoded_contest.update(maxmin_errors); - if let Some(max_votes_val) = max_votes_opt.clone() { + if let Some(max_votes_val) = max_votes_opt { let overvote_check = check_over_vote_policy( &presentation, num_selected_candidates, @@ -661,7 +696,7 @@ impl BallotChoices { ); decoded_contest.update(overvote_check); } - if let Some(min_votes_val) = min_votes_opt.clone() { + if let Some(min_votes_val) = min_votes_opt { let min_check = check_min_vote_policy(num_selected_candidates, min_votes_val); decoded_contest.update(min_check); @@ -670,8 +705,8 @@ impl BallotChoices { let under_vote_check = check_under_vote_policy( &presentation, num_selected_candidates, - max_votes_opt.clone(), - min_votes_opt.clone(), + max_votes_opt, + min_votes_opt, ); decoded_contest.update(under_vote_check); @@ -714,9 +749,12 @@ impl BallotChoices { // slots has no meaning. This implementation does not // support contest level invalid flags. // - // Returns the vector of bases for the mixed radix - // representation of this ballot (including a explicit invalid base = 2). - pub fn get_bases(contests: &Vec) -> Result, String> { + /// Returns the vector of bases for the mixed radix + /// representation of this ballot (including a explicit invalid base = 2). + /// + /// # Errors + /// Returns an error if the encoding is not supported or candidate count conversion fails. + pub fn get_bases(contests: &[Contest]) -> Result, String> { // the base for explicit invalid ballot slot is 2: // 0: not invalid, 1: explicit invalid let mut bases: Vec = vec![2]; @@ -731,7 +769,7 @@ impl BallotChoices { // sorting by id. // The selections must be encoded to and decoded from a ballot // following this order, given by contest.id. - let mut sorted_contests = contests.clone(); + let mut sorted_contests = contests.to_vec(); sorted_contests.sort_by_key(|c| c.id.clone()); for contest in sorted_contests { @@ -755,7 +793,9 @@ impl BallotChoices { let max_selections = contest.max_votes; for _ in 1..=max_selections { // + 1: include a per-ballot invalid flag - bases.push(u64::from(num_valid_candidates + 1)); + bases.push(num_valid_candidates.checked_add(1).ok_or_else( + || "Overflow in num_valid_candidates + 1".to_string(), + )?); } } @@ -764,13 +804,15 @@ impl BallotChoices { /// Returns the contests corresponding to the choices in this ballot /// from the given ballot style. + /// + /// # Errors + /// Returns an error if a contest is missing. pub(crate) fn get_contests( &self, style: &BallotStyle, ) -> Result, String> { self.choices - .clone() - .into_iter() + .iter() .map(|choices| { let contest = style .contests @@ -782,30 +824,33 @@ impl BallotChoices { choices.contest_id ) })?; - Ok(contest.clone()) }) .collect() } /// Decodes a bigint into a raw ballot (mixed radix representation). + /// + /// # Errors + /// Returns an error if decoding fails. pub fn bigint_to_raw_ballot( bigint: &BigUint, - contests: &Vec, + contests: &[Contest], ) -> Result { - let bases = Self::get_bases(contests).map_err(|e| e.to_string())?; - - let choices = Self::decode_mixed_radix(&bases, &bigint)?; - + let bases = Self::get_bases(contests).map_err(|e| e.clone())?; + let choices = Self::decode_mixed_radix(&bases, bigint)?; Ok(RawBallotContest { bases, choices }) } /// Decode the choices in the given mixed radix bigint /// - /// This function is adapted from mixed_radix::decode + /// This function is adapted from `mixed_radix::decode` /// to remove its write-in functionality. + /// + /// # Errors + /// Returns an error if decoding fails. pub fn decode_mixed_radix( - bases: &Vec, + bases: &[u64], encoded_value: &BigUint, ) -> Result, String> { let mut values: Vec = vec![]; @@ -813,26 +858,46 @@ impl BallotChoices { let mut index = 0usize; while accumulator > Zero::zero() { - let base: BigUint = bases[index].to_biguint().ok_or_else(|| { - format!( - "Error converting to biguint: bases[index={index:?}]={val}", - val = bases[index] - ) - })?; + let base = bases + .get(index) + .ok_or_else(|| { + format!( + "Index out of bounds in bases vector: index={index}, len={}", + bases.len() + ) + })? + .to_biguint() + .ok_or_else(|| { + "Could not convert base to BigUint".to_string() + })?; + + if base.is_zero() { + return Err(format!("Base cannot be zero at index {index}")); + } + #[allow(clippy::arithmetic_side_effects)] let remainder = &accumulator % &base; + values.push(remainder.to_u64().ok_or_else(|| { format!("Error converting to u64 remainder={remainder}") })?); - accumulator = (&accumulator - &remainder) / &base; - index += 1; + #[allow(clippy::arithmetic_side_effects)] + { + accumulator = (&accumulator - &remainder) / &base; + } + + index = index + .checked_add(1) + .ok_or_else(|| "Overflow in index addition".to_string())?; } // If we didn't run all the bases, fill the rest with zeros while index < bases.len() { values.push(0); - index += 1; + index = index.checked_add(1).ok_or_else(|| { + "Overflow in index addition (padding)".to_string() + })?; } Ok(values) @@ -844,24 +909,23 @@ impl BallotChoices { /// Returns a conservative upper bound, choosing the maximum /// value possible for each base. This value will be greater /// than any valid ballot - pub fn maximum_size_bytes( - contests: &Vec, - ) -> Result { + /// + /// # Errors + /// Returns an error if encoding fails. + pub fn maximum_size_bytes(contests: &[Contest]) -> Result { let bases = Self::get_bases(contests)?; - - let choices: Vec = bases.iter().map(|b| b - 1).collect(); - - let max = RawBallotContest::new(bases, choices); - + let choices: Vec = + bases.iter().map(|b| b.saturating_sub(1)).collect(); + let max = RawBallotContest::new(bases.clone(), choices); let bigint = mixed_radix::encode(&max.choices, &max.bases)?; let bytes = bigint::encode_bigint_to_bytes(&bigint)?; - Ok(bytes.len()) } /// Returns a vector of contest ids for this ballot /// /// Convenience method. + #[must_use] pub fn get_contest_ids(&self) -> Vec { self.choices.iter().map(|c| c.contest_id.clone()).collect() } @@ -869,19 +933,24 @@ impl BallotChoices { /// Returns a bigint representation of this ballot /// /// Convenience method used in velvet test. + /// + /// # Errors + /// Returns an error if encoding fails. pub fn encode_to_bigint( &self, config: &BallotStyle, ) -> Result { - let raw_ballot = self.encode_to_raw_ballot(&config)?; - + let raw_ballot = self.encode_to_raw_ballot(config)?; mixed_radix::encode(&raw_ballot.choices, &raw_ballot.bases) } } /// Test multi-contest reencoding functionality +/// +/// # Errors +/// Returns an error if encoding or decoding fails, or if the input and output contests do not match. pub fn test_multi_contest_reencoding( - decoded_multi_contests: &Vec, + decoded_multi_contests: &[DecodedVoteContest], ballot_style: &BallotStyle, ) -> Result, String> { // encode ballot @@ -890,32 +959,30 @@ pub fn test_multi_contest_reencoding( decoded_multi_contests, ballot_style, ) - .map_err(|err| format!("Error encoded decoded contests {:?}", err))?; + .map_err(|err| format!("Error encoded decoded contests {err}"))?; let decoded_ballot_choices = - BallotChoices::decode_from_30_bytes(&plaintext, ballot_style).map_err( - |err| format!("Error decoding ballot choices {:?}", err), - )?; + BallotChoices::decode_from_30_bytes(&plaintext, ballot_style) + .map_err(|err| format!("Error decoding ballot choices {err}"))?; let output_decoded_contests = map_decoded_ballot_choices_to_decoded_contests( - decoded_ballot_choices, + &decoded_ballot_choices, &ballot_style.contests, ) - .map_err(|err| format!("Error mapping decoded contests {:?}", err))?; + .map_err(|err| format!("Error mapping decoded contests {err}"))?; let input_compare = normalize_election(decoded_multi_contests, ballot_style, true) - .map_err(|err| format!("Error normalizing input {:?}", err))?; + .map_err(|err| format!("Error normalizing input {err}"))?; let output_compare = normalize_election(&output_decoded_contests, ballot_style, true) - .map_err(|err| format!("Error normalizing output {:?}", err))?; + .map_err(|err| format!("Error normalizing output {err}"))?; if input_compare != output_compare { return Err(format!( - "Consistency check failed. Input != Output, {:?} != {:?}", - input_compare, output_compare + "Consistency check failed. Input != Output, {input_compare:?} != {output_compare:?}" )); } @@ -926,7 +993,7 @@ pub fn test_multi_contest_reencoding( mod tests { use super::*; - use crate::ballot::{BallotStyle, Candidate, Contest}; + use crate::ballot::{AreaPresentation, BallotStyle, Candidate, Contest}; use crate::serialization::deserialize_with_path::deserialize_value; use rand::{seq::SliceRandom, Rng}; use serde_json::json; @@ -1196,9 +1263,7 @@ mod tests { description_i18n: None, alias: None, alias_i18n: None, - // set max_votes, - // set min_votes, winning_candidates_num: 0, voting_type: None, @@ -1208,6 +1273,7 @@ mod tests { presentation: None, created_at: None, annotations: None, + tie_breaking_policy: None, } } @@ -1361,7 +1427,7 @@ mod tests { } fn s() -> String { - "foo".to_string() + String::from("foo") } impl Display for BallotChoices { diff --git a/packages/sequent-core/src/ballot_codec/plaintext_contest.rs b/packages/sequent-core/src/ballot_codec/plaintext_contest.rs index c7ba9d6af78..e5180846a0f 100644 --- a/packages/sequent-core/src/ballot_codec/plaintext_contest.rs +++ b/packages/sequent-core/src/ballot_codec/plaintext_contest.rs @@ -1,31 +1,56 @@ // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only -use crate::ballot::*; -use crate::ballot_codec::*; -use crate::plaintext::*; +use crate::ballot::Contest; +use crate::ballot_codec::{ + decode_array_to_vec, decode_bigint_from_bytes, encode_bigint_to_bytes, + encode_vec_to_array, BigUIntCodec, +}; +use crate::plaintext::DecodedVoteContest; use num_bigint::BigUint; +/// Codec for encoding and decoding plaintext contests. pub trait PlaintextCodec { + /// Encodes a plaintext contest into a fixed-size byte array. + /// + /// # Errors + /// Returns an error if encoding fails or the result cannot fit in 30 bytes. fn encode_plaintext_contest( &self, plaintext: &DecodedVoteContest, ) -> Result<[u8; 30], String>; + /// Decodes a plaintext contest from a fixed-size byte array. + /// + /// # Errors + /// Returns an error if decoding fails or the bytes are invalid. fn decode_plaintext_contest( &self, code: &[u8; 30], ) -> Result; + + /// Decodes a `BigUint` from a fixed-size byte array. + /// + /// # Errors + /// Returns an error if the bytes cannot be converted to a `BigUint`. fn decode_plaintext_contest_to_biguint( &self, code: &[u8; 30], ) -> Result; + /// Encodes a plaintext contest into a vector of bytes. + /// + /// # Errors + /// Returns an error if encoding fails. fn encode_plaintext_contest_to_bytes( &self, plaintext: &DecodedVoteContest, ) -> Result, String>; + /// Decodes a plaintext contest from a byte slice. + /// + /// # Errors + /// Returns an error if decoding fails or the bytes are invalid. fn decode_plaintext_contest_from_bytes( &self, bytes: &[u8], @@ -71,7 +96,7 @@ impl PlaintextCodec for Contest { &self, bytes: &[u8], ) -> Result { - let bigint = decode_bigint_from_bytes(&bytes)?; + let bigint = decode_bigint_from_bytes(bytes)?; self.decode_plaintext_contest_bigint(&bigint) } } diff --git a/packages/sequent-core/src/ballot_codec/raw_ballot.rs b/packages/sequent-core/src/ballot_codec/raw_ballot.rs index 541bfe1b080..77ac696375c 100644 --- a/packages/sequent-core/src/ballot_codec/raw_ballot.rs +++ b/packages/sequent-core/src/ballot_codec/raw_ballot.rs @@ -1,9 +1,18 @@ // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only -use crate::ballot::*; -use crate::ballot_codec::*; -use crate::plaintext::*; +use crate::ballot::{Candidate, Contest}; +use crate::ballot_codec::{ + check_blank_vote_policy, check_duplicated_rank_policy, + check_invalid_vote_policy, check_max_min_votes_policy, + check_min_vote_policy, check_over_vote_policy, + check_preference_gaps_policy, check_under_vote_policy, BasesCodec, + CharacterMap, +}; +use crate::plaintext::{ + DecodedVoteChoice, DecodedVoteContest, InvalidPlaintextError, + InvalidPlaintextErrorType, PreferencialOrderErrorType, +}; use crate::types::ceremonies::CountingAlgType; use num_traits::ToPrimitive; use std::collections::HashMap; @@ -16,22 +25,43 @@ pub struct RawBallotContest { impl RawBallotContest { // FIXME add validation (eg all values within range) // FIXME ensure this struct is always created with via RawBallotContest::new - pub fn new(bases: Vec, choices: Vec) -> Self { + #[must_use] + pub const fn new(bases: Vec, choices: Vec) -> Self { RawBallotContest { bases, choices } } } pub trait RawBallotCodec { + /// Helper function to update all policy checks and error/alert fields for a + /// decoded contest. This is used in the `decode_from_raw_ballot`. + fn update_decoded_contest_policies( + &self, + decoded_contest: &mut DecodedVoteContest, + is_explicit_invalid: bool, + ); + + /// Encodes the contest to a raw ballot. + /// + /// # Errors + /// Returns an error if encoding fails. fn encode_to_raw_ballot( &self, plaintext: &DecodedVoteContest, ) -> Result; + /// Decodes a raw ballot to a `DecodedVoteContest`. + /// + /// # Errors + /// Returns an error if decoding fails. fn decode_from_raw_ballot( &self, raw_ballot: &RawBallotContest, ) -> Result; + /// Estimates available write-in characters. + /// + /// # Errors + /// Returns an error if estimation fails. fn available_write_in_characters_estimate( &self, plaintext: &DecodedVoteContest, @@ -43,29 +73,56 @@ impl RawBallotCodec for Contest { &self, plaintext: &DecodedVoteContest, ) -> Result { - let raw_ballot = self.encode_to_raw_ballot(&plaintext)?; + let raw_ballot = self.encode_to_raw_ballot(plaintext)?; + #[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss + )] let used_bits = raw_ballot .bases .iter() .map(|el| (*el as f64).log2().ceil() as u64) - .sum::() as i32; - // we have a maximum of 29 bytes and each character takes 5 bits - let remaining_bits: i32 = 29 * 8 - used_bits; + .sum::(); + let remaining_bits: i32 = 29_i32 + .checked_mul(8) + .and_then(|v| { + v.checked_sub( + i32::try_from(used_bits) + .map_err(|_| "used_bits too large for i32".to_string()) + .ok()?, + ) + }) + .ok_or_else(|| { + "Overflow in remaining_bits calculation".to_string() + })?; let char_map = self.get_char_map(); + #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] let base_bits = (char_map.base() as f64).log2().ceil() as i32; + if base_bits == 0 { + return Err("Base bits cannot be zero".to_string()); + } + if remaining_bits > 0 { - // div_ceil: round up for positive numbers - Ok((remaining_bits as u32).div_ceil(base_bits as u32) as i32) + // div_ceil for positive numbers + let rem = u32::try_from(remaining_bits).map_err(|_| { + "remaining_bits negative in div_ceil".to_string() + })?; + let base = u32::try_from(base_bits) + .map_err(|_| "base_bits negative in div_ceil".to_string())?; + #[allow(clippy::cast_possible_wrap)] + { + Ok(rem.div_ceil(base) as i32) + } } else { - // div_floor: round toward negative infinity for negative numbers - Ok((remaining_bits / base_bits) - - if remaining_bits % base_bits != 0 { - 1 - } else { - 0 - }) + #[allow(clippy::arithmetic_side_effects)] + let div = remaining_bits / base_bits; + #[allow(clippy::arithmetic_side_effects)] + let needs_adjust = remaining_bits % base_bits != 0; + #[allow(clippy::arithmetic_side_effects)] + Ok(div - i32::from(needs_adjust)) } } @@ -92,11 +149,10 @@ impl RawBallotCodec for Contest { // - Invalid vote candidate (if any) // - Write-ins (if any) // - Valid candidates (normal candidates + write-ins if any) - let invalid_vote: u64 = - if plaintext.is_explicit_invalid { 1 } else { 0 }; + let invalid_vote: u64 = u64::from(plaintext.is_explicit_invalid); choices.push(invalid_vote); - for choice in sorted_choices.iter() { + for choice in &sorted_choices { let candidate = candidates_map.get(&choice.id).ok_or_else(|| { "choice id is not a valid candidate".to_string() @@ -104,24 +160,24 @@ impl RawBallotCodec for Contest { if candidate.is_explicit_invalid() { continue; } - match self.get_counting_algorithm() { - CountingAlgType::PluralityAtLarge => { - // We just flag if the candidate was selected or not with 1 - // for selected and 0 otherwise - choices.push(u64::from(choice.selected > -1)); - } - _ => { - // we add 1 because the counting starts with 1, as zero - // means this candidate was not voted / - // ranked (selected was -1). This should work for IRV and - // other preferencial counting algorithms - let value = - (choice.selected + 1).to_u64().ok_or_else(|| { - "selected value must be positive or zero" - .to_string() - })?; - choices.push(value); - } + let alg = self.get_counting_algorithm(); + if alg == CountingAlgType::PluralityAtLarge { + // We just flag if the candidate was selected or not with 1 + // for selected and 0 otherwise + choices.push(u64::from(choice.selected > -1)); + } else { + // we add 1 because the counting starts with 1, as zero + // means this candidate was not voted / + // ranked (selected was -1). This should work for IRV and + // other preferencial counting algorithms + let sel_plus_1 = choice + .selected + .checked_add(1) + .ok_or_else(|| "Overflow in selected+1".to_string())?; + let value = sel_plus_1.to_u64().ok_or_else(|| { + "selected value must be positive or zero".to_string() + })?; + choices.push(value); } } // Populate the bases and the raw_ballot values with the write-ins @@ -130,7 +186,7 @@ impl RawBallotCodec for Contest { // each byte a specific value with base 256 and end each write-in // with a \0 byte. Note that even write-ins. if self.allow_writeins() { - for choice in sorted_choices.iter() { + for choice in &sorted_choices { let candidate = candidates_map.get(&choice.id).ok_or_else(|| { "choice id is not a valid candidate".to_string() @@ -141,25 +197,26 @@ impl RawBallotCodec for Contest { // getBases() to end it with a zero choices.push(0); } - if choice.write_in_text.is_some() && is_write_in { - let text = choice.write_in_text.clone().unwrap(); - if text.is_empty() { - // we don't do a bases.push_back(256) as this is done in - // getBases() to end it with a zero - choices.push(0); - } else { - // MAPPER - let base = char_map.base(); - let bytes = char_map.to_bytes(&text)?; - for byte in bytes { - choices.push(byte as u64); - bases.push(base); - } + if let Some(text) = choice.write_in_text.clone() { + if is_write_in { + if text.is_empty() { + // we don't do a bases.push_back(256) as this is done in + // getBases() to end it with a zero + choices.push(0); + } else { + // MAPPER + let base = char_map.base(); + let bytes = char_map.to_bytes(&text)?; + for byte in bytes { + choices.push(u64::from(byte)); + bases.push(base); + } - // End it with a zero. we don't do a - // bases.push_back(256) as this is - // done in getBases() - choices.push(0); + // End it with a zero. we don't do a + // bases.push_back(256) as this is + // done in getBases() + choices.push(0); + } } } } @@ -181,7 +238,8 @@ impl RawBallotCodec for Contest { // the end of the function let choices = raw_ballot.choices.clone(); - let is_explicit_invalid: bool = !choices.is_empty() && (choices[0] > 0); + let is_explicit_invalid: bool = + !choices.is_empty() && choices.first().is_some_and(|v| *v > 0); // Prepare the return value to pass it around, its values can still be // modified. @@ -209,7 +267,7 @@ impl RawBallotCodec for Contest { .collect(); // 4. Do some verifications on the number of choices: Checking that the // raw_ballot has as many choices as required - if choices.len() < valid_candidates.len() + 1 { + if choices.len() < valid_candidates.len().saturating_add(1) { // Invalid Ballot: Not enough choices to decode decoded_contest.invalid_errors.push(InvalidPlaintextError { error_type: InvalidPlaintextErrorType::EncodingError, @@ -231,113 +289,47 @@ impl RawBallotCodec for Contest { } // TODO: here we do return an error, because it's difficult to // recover from this one - let choice_value = choices[index] - .clone() + let choice_value = choices + .get(index) + .ok_or_else(|| "choice index out of range".to_string())? .to_i64() .ok_or_else(|| "choice out of range".to_string())?; decoded_contest.choices.push(DecodedVoteChoice { id: candidate.id.clone(), - selected: choice_value - 1, + selected: choice_value + .checked_sub(1) + .ok_or_else(|| "Overflow in selected-1".to_string())?, write_in_text: None, }); - index += 1; + index = index + .checked_add(1) + .ok_or_else(|| "Overflow in index+1".to_string())?; } - // 6. Decode the write-in texts into UTF-8 and split by the \0 - // character, finally the text for the write-ins. - let mut write_in_index = index; - for candidate in &write_in_candidates { - if write_in_index >= choices.len() { - break; - } - // collect the string bytes - let mut write_in_bytes: Vec = vec![]; - - while write_in_index < choices.len() && choices[write_in_index] != 0 - { - let value_res = choices[write_in_index] - .to_u8() - .ok_or_else(|| "Write-in choice out of range".to_string()); - - if let Ok(new_value) = value_res { - write_in_bytes.push(new_value); - } else { - decoded_contest.invalid_errors.push( - InvalidPlaintextError { - error_type: - InvalidPlaintextErrorType::EncodingError, - candidate_id: Some(candidate.id.clone()), - message: Some( - "errors.encoding.writeInChoiceOutOfRange" - .to_string(), - ), - message_map: HashMap::from([( - "index".to_string(), - write_in_index.to_string(), - )]), - }, - ); - } - - write_in_index += 1; - } - - // check index is not out of bounds - if write_in_index >= choices.len() { - decoded_contest.invalid_errors.push(InvalidPlaintextError { - error_type: InvalidPlaintextErrorType::EncodingError, - candidate_id: Some(candidate.id.clone()), - message: Some( - "errors.encoding.writeInNotEndInZero".to_string(), - ), - message_map: HashMap::new(), - }); - } - // skip the 0 character - else if choices[write_in_index] == 0 { - write_in_index += 1; - } - - // MAPPER - let write_in_str_res = char_map.to_string(&write_in_bytes); - if write_in_str_res.is_err() { - decoded_contest.invalid_errors.push(InvalidPlaintextError { - error_type: InvalidPlaintextErrorType::EncodingError, - candidate_id: Some(candidate.id.clone()), - message: Some( - "errors.encoding.bytesToUtf8Conversion".to_string(), - ), - message_map: HashMap::from([( - "errorMessage".to_string(), - write_in_str_res.clone().unwrap_err(), - )]), - }); - } - - let write_in_str = write_in_str_res.map(Some).unwrap_or(None); - - // add write_in to choice - let n = decoded_contest - .choices - .iter() - .position(|choice| choice.id == candidate.id) - .unwrap(); - let mut choice = decoded_contest.choices[n].clone(); - choice.write_in_text = write_in_str; - decoded_contest.choices[n] = choice; - } + // 6. Decode the write-in texts into UTF-8 and split by the \0 character + decode_write_ins( + &write_in_candidates, + &mut decoded_contest, + &choices, + char_map.as_ref(), + index, + ); - if write_in_index < choices.len() { - decoded_contest.invalid_errors.push(InvalidPlaintextError { - error_type: InvalidPlaintextErrorType::EncodingError, - candidate_id: None, - message: Some("errors.encoding.ballotTooLarge".to_string()), - message_map: HashMap::new(), - }); - } + self.update_decoded_contest_policies( + &mut decoded_contest, + is_explicit_invalid, + ); + Ok(decoded_contest) + } + /// Updates all policy checks and error/alert fields for a decoded contest. + fn update_decoded_contest_policies( + &self, + decoded_contest: &mut DecodedVoteContest, + is_explicit_invalid: bool, + ) { let presentation = self.presentation.clone().unwrap_or_default(); let invalid_vote_policy_errors = @@ -355,7 +347,7 @@ impl RawBallotCodec for Contest { check_max_min_votes_policy(self.max_votes, self.min_votes); decoded_contest.update(maxmin_errors); - if let Some(max_votes) = max_votes.clone() { + if let Some(max_votes) = max_votes { let overvote_check = check_over_vote_policy( &presentation, num_selected_candidates, @@ -363,7 +355,7 @@ impl RawBallotCodec for Contest { ); decoded_contest.update(overvote_check); } - if let Some(min_votes) = min_votes.clone() { + if let Some(min_votes) = min_votes { let min_check = check_min_vote_policy(num_selected_candidates, min_votes); decoded_contest.update(min_check); @@ -372,8 +364,8 @@ impl RawBallotCodec for Contest { let under_vote_check = check_under_vote_policy( &presentation, num_selected_candidates, - max_votes.clone(), - min_votes.clone(), + max_votes, + min_votes, ); decoded_contest.update(under_vote_check); @@ -406,16 +398,110 @@ impl RawBallotCodec for Contest { } } } - - Ok(decoded_contest) } } +/// Helper to decode write-in candidates for `decode_from_raw_ballot` +fn decode_write_ins( + write_in_candidates: &[&Candidate], + decoded_contest: &mut DecodedVoteContest, + choices: &[u64], + char_map: &dyn CharacterMap, + mut write_in_index: usize, +) { + for candidate in write_in_candidates { + if write_in_index >= choices.len() { + break; + } + // collect the string bytes + let mut write_in_bytes: Vec = vec![]; + + while write_in_index < choices.len() + && choices.get(write_in_index).is_some_and(|v| *v != 0) + { + let value_res = choices + .get(write_in_index) + .ok_or_else(|| "write_in_index out of range".to_string()) + .and_then(|v| { + v.to_u8().ok_or_else(|| { + "Write-in choice out of range".to_string() + }) + }); + + if let Ok(new_value) = value_res { + write_in_bytes.push(new_value); + } else { + decoded_contest.invalid_errors.push(InvalidPlaintextError { + error_type: InvalidPlaintextErrorType::EncodingError, + candidate_id: Some(candidate.id.clone()), + message: Some( + "errors.encoding.writeInChoiceOutOfRange".to_string(), + ), + message_map: HashMap::from([( + "index".to_string(), + write_in_index.to_string(), + )]), + }); + } + + write_in_index = + write_in_index.checked_add(1).unwrap_or(choices.len()); + } + + // check index is not out of bounds + if write_in_index >= choices.len() { + decoded_contest.invalid_errors.push(InvalidPlaintextError { + error_type: InvalidPlaintextErrorType::EncodingError, + candidate_id: Some(candidate.id.clone()), + message: Some( + "errors.encoding.writeInNotEndInZero".to_string(), + ), + message_map: HashMap::new(), + }); + } + // skip the 0 character + else if choices.get(write_in_index).is_some_and(|v| *v == 0) { + write_in_index = + write_in_index.checked_add(1).unwrap_or(choices.len()); + } + + // MAPPER + let write_in_str_res = char_map.to_string(&write_in_bytes); + + if write_in_str_res.is_err() { + decoded_contest.invalid_errors.push(InvalidPlaintextError { + error_type: InvalidPlaintextErrorType::EncodingError, + candidate_id: Some(candidate.id.clone()), + message: Some( + "errors.encoding.bytesToUtf8Conversion".to_string(), + ), + message_map: HashMap::from([( + "errorMessage".to_string(), + write_in_str_res + .clone() + .expect_err("Expected error in write_in_str_res"), + )]), + }); + } + + let write_in_str = write_in_str_res.ok(); + + // add write_in to choice + let n = decoded_contest + .choices + .iter() + .position(|choice| choice.id == candidate.id) + .expect("Choice for write-in candidate not found"); + if let Some(choice_mut) = decoded_contest.choices.get_mut(n) { + choice_mut.write_in_text = write_in_str; + } + } +} #[cfg(test)] mod tests { - use raw_ballot::EUnderVotePolicy; use crate::ballot; + use crate::ballot::EUnderVotePolicy; use crate::ballot_codec::*; use crate::fixtures::ballot_codec::*; use crate::mixed_radix::encode; diff --git a/packages/sequent-core/src/ballot_codec/vec.rs b/packages/sequent-core/src/ballot_codec/vec.rs index 3b6106928b1..f2fad8fbf2e 100644 --- a/packages/sequent-core/src/ballot_codec/vec.rs +++ b/packages/sequent-core/src/ballot_codec/vec.rs @@ -2,53 +2,72 @@ // // SPDX-License-Identifier: AGPL-3.0-only -// similar to ballot_codec::encode_vec_to_array but it doesn't add the size. -pub fn vec_to_30_array(data: &Vec) -> Result<[u8; 30], String> { +/// Similar to `ballot_codec::encode_vec_to_array` but it doesn't add the size. +/// +/// # Errors +/// Returns an error if the input data is longer than 30 bytes. +pub fn vec_to_30_array(data: &[u8]) -> Result<[u8; 30], String> { if data.len() > 30 { return Err(format!( - "Data too long, lenght {} is greater than 29", + "Data too long, length {} is greater than 30", data.len() )); } let mut plaintext_array = [0u8; 30]; - for i in 0..data.len() { - plaintext_array[i] = data[i]; + if let Some(slice) = plaintext_array.get_mut(..data.len()) { + slice.copy_from_slice(data); + } else { + return Err("Internal error: failed to copy data".to_string()); } Ok(plaintext_array) } -/** - * Encode an input vector of bytes into an array of 30 bytes. - * The first byte will indicate the size of the input bytes. - * . Then follows the input bytes, and the remaining are zeroed bytes. - */ -pub fn encode_vec_to_array(data: &Vec) -> Result<[u8; 30], String> { +/// Encode an input byte slice into an array of 30 bytes. +/// The first byte will indicate the size of the input bytes. +/// Then follows the input bytes, and the remaining are zeroed bytes. +/// +/// # Errors +/// Returns an error if the input data is longer than 29 bytes. +pub fn encode_vec_to_array(data: &[u8]) -> Result<[u8; 30], String> { let plaintext_length = data.len(); if plaintext_length > 29 { return Err(format!( - "Plaintext too long, length {} is greater than 29. Data: {:?}", - plaintext_length, data + "Plaintext too long, length {plaintext_length} is greater than 29. Data: {data:?}" )); } let mut plaintext_array = [0u8; 30]; - plaintext_array[0] = plaintext_length as u8; - for i in 0..plaintext_length { - plaintext_array[i + 1] = data[i]; - } + plaintext_array[0] = u8::try_from(plaintext_length) + .map_err(|e| format!("Error converting plaintext length to u8: {e}"))?; + let end = plaintext_length + .checked_add(1) + .ok_or("Overflow in plaintext length addition")?; + let slice = plaintext_array + .get_mut(1..end) + .ok_or_else(|| "Internal error: failed to copy data".to_string())?; + + slice.copy_from_slice(data); Ok(plaintext_array) } /** * Decode an array of 30 bytes into a vector of bytes. - * This is the inverse of encode_vec_to_array and in that way + * This is the inverse of `encode_vec_to_array` and in that way * the first byte indicates the size of the data. */ +#[must_use] pub fn decode_array_to_vec(code: &[u8; 30]) -> Vec { let plaintext_length = code[0] as usize; - - let mut plaintext_bytes: Vec = vec![]; + let mut plaintext_bytes = Vec::new(); for i in 0..plaintext_length { - plaintext_bytes.push(code[i + 1]); + if let Some(idx) = i.checked_add(1) { + if let Some(&value) = code.get(idx) { + plaintext_bytes.push(value); + } else { + break; + } + } else { + break; + } } plaintext_bytes } diff --git a/packages/sequent-core/src/ballot_style.rs b/packages/sequent-core/src/ballot_style.rs index 0bf947ecbb2..233e3b001a0 100644 --- a/packages/sequent-core/src/ballot_style.rs +++ b/packages/sequent-core/src/ballot_style.rs @@ -18,6 +18,8 @@ use std::collections::HashMap; use std::env; use std::str::FromStr; +/// Parse an i18n field. +#[must_use] pub fn parse_i18n_field( i18n_opt: &Option>>>, field: &str, @@ -30,94 +32,93 @@ pub fn parse_i18n_field( for (lang, details) in i18n { if let Some(field_value) = details.get(field) { content.insert(lang.clone(), field_value.clone()); - }; + } } Some(content) } +/// Create a ballot style from the provided parameters. +/// +/// # Errors +/// Returns an error if the ballot style cannot be created due to missing or invalid data. +#[allow(clippy::too_many_arguments)] pub fn create_ballot_style( id: String, - area: hasura_types::Area, // Area - election_event: hasura_types::ElectionEvent, // Election Event - election: hasura_types::Election, // Election - contests: Vec, // Contest - candidates: Vec, // Candidate - election_dates: StringifiedPeriodDates, // Election Dates - public_key: Option, // public key + area: &hasura_types::Area, // Area + election_event: &hasura_types::ElectionEvent, // Election Event + election: &hasura_types::Election, // Election + contests: &[hasura_types::Contest], // Contest + candidates: &[hasura_types::Candidate], // Candidate + election_dates: StringifiedPeriodDates, // Election Dates + public_key: Option, // public key ) -> Result { let mut sorted_contests = contests - .clone() - .into_iter() + .iter() .filter(|contest| contest.election_id == election.id) + .cloned() .collect::>(); sorted_contests.sort_by_key(|k| k.id.clone()); let demo_public_key_env = env::var("DEMO_PUBLIC_KEY") .with_context(|| "DEMO_PUBLIC_KEY env var not found")?; let election_event_presentation: ElectionEventPresentation = election_event .presentation - .clone() - .map(|presentation| deserialize_value(presentation)) + .as_ref() + .map(|v| deserialize_value(v.clone())) .transpose() .map_err(|err| { - anyhow!("Error parsing election Event presentation {:?}", err) + anyhow!("Error parsing election Event presentation {err:?}") })? .unwrap_or_default(); let election_event_annotations: HashMap = election_event .annotations - .clone() - .map(|annotations| deserialize_value(annotations)) + .as_ref() + .map(|v| deserialize_value(v.clone())) .transpose() .map_err(|err| { - anyhow!("Error parsing election Event annotations {:?}", err) + anyhow!("Error parsing election Event annotations {err:?}") })? .unwrap_or_default(); let election_presentation: ElectionPresentation = election .presentation - .clone() - .map(|presentation| deserialize_value(presentation)) + .as_ref() + .map(|v| deserialize_value(v.clone())) .transpose() - .map_err(|err| { - anyhow!("Error parsing election presentation {:?}", err) - })? + .map_err(|err| anyhow!("Error parsing election presentation {err:?}"))? .unwrap_or_default(); let election_annotations: HashMap = election .annotations - .clone() - .map(|annotations| deserialize_value(annotations)) + .as_ref() + .map(|v| deserialize_value(v.clone())) .transpose() - .map_err(|err| anyhow!("Error parsing election annotations {:?}", err))? + .map_err(|err| anyhow!("Error parsing election annotations {err:?}"))? .unwrap_or_default(); let default_language = election.get_default_language(); - let contests: Vec = sorted_contests + let ballot_contests: Vec = sorted_contests .into_iter() .map(|contest| { let election_candidates = candidates - .clone() - .into_iter() + .iter() .filter(|c| c.contest_id == Some(contest.id.clone())) + .cloned() .collect::>(); - create_contest( - contest, - election_candidates, - default_language.clone(), - ) + create_contest(contest, &election_candidates, &default_language) }) .collect::>>()?; - let area_annotations = area.clone().read_annotations()?; + let area_annotations = area.read_annotations()?; let area_presentation: AreaPresentation = area .presentation - .clone() + .as_ref() .map(|presentation| { - deserialize_value(presentation).map_err(|err| { - anyhow!("Error parsing area presentation: {}", err) + deserialize_value(presentation.clone()).map_err(|err| { + anyhow!("Error parsing area presentation: {err}") }) }) .transpose()? @@ -125,25 +126,28 @@ pub fn create_ballot_style( Ok(ballot::BallotStyle { id, - tenant_id: election.tenant_id, - election_event_id: election.election_event_id, - election_id: election.id, + tenant_id: election.tenant_id.clone(), + election_event_id: election.election_event_id.clone(), + election_id: election.id.clone(), num_allowed_revotes: election.num_allowed_revotes, - description: election.description, + description: election.description.clone(), public_key: Some( public_key .map(|key| ballot::PublicKeyConfig { public_key: key, is_demo: false, }) - .unwrap_or(ballot::PublicKeyConfig { - public_key: demo_public_key_env.to_string(), - is_demo: true, - }), + .map_or( + ballot::PublicKeyConfig { + public_key: demo_public_key_env, + is_demo: true, + }, + |cfg| cfg, + ), ), - area_id: area.id, + area_id: area.id.clone(), area_presentation: Some(area_presentation), - contests, + contests: ballot_contests, election_event_presentation: Some(election_event_presentation.clone()), election_presentation: Some(election_presentation), election_dates: Some(election_dates), @@ -153,68 +157,73 @@ pub fn create_ballot_style( }) } +/// Create a contest from receiving data. +/// +/// # Errors +/// Returns an error if deserialization or parsing fails. +#[allow(clippy::too_many_lines)] fn create_contest( contest: hasura_types::Contest, - candidates: Vec, - default_language: String, + candidates: &[hasura_types::Candidate], + default_language: &str, ) -> Result { - let mut sorted_candidates = candidates.clone(); + let mut sorted_candidates = candidates.to_owned(); sorted_candidates.sort_by_key(|k| k.id.clone()); let contest_presentation = contest .presentation - .clone() - .map(|presentation_value| deserialize_value(presentation_value)) - .unwrap_or(Ok(ContestPresentation::new()))?; + .as_ref() + .map(|v| deserialize_value(v.clone())) + .map_or(Ok(ContestPresentation::new()), |r| r)?; let name_i18n = parse_i18n_field(&contest_presentation.i18n, "name"); let description_i18n = parse_i18n_field(&contest_presentation.i18n, "description"); let alias_i18n = parse_i18n_field(&contest_presentation.i18n, "alias"); - let candidates: Vec = sorted_candidates + let ballot_candidates: Vec = sorted_candidates .iter() - .enumerate() - .map(|(_i, candidate)| { + .map(|candidate| { let candidate_presentation = candidate .presentation - .clone() - .map(|presentation_value| deserialize_value(presentation_value)) - .unwrap_or(Ok(CandidatePresentation::new()))?; + .as_ref() + .map(|value| deserialize_value(value.clone())) + .map_or(Ok(CandidatePresentation::new()), |r| r)?; - let name_i18n = + let cand_name_i18n = parse_i18n_field(&candidate_presentation.i18n, "name"); - let description_i18n = + let cand_description_i18n = parse_i18n_field(&candidate_presentation.i18n, "description"); - let alias_i18n = + let cand_alias_i18n = parse_i18n_field(&candidate_presentation.i18n, "alias"); - let candidate_name = name_i18n + let candidate_name = cand_name_i18n .as_ref() - .and_then(|i18n| i18n.get(&default_language)) - .and_then(|name| name.clone()); - let candidate_alias = alias_i18n + .and_then(|i18n| i18n.get(default_language)) + .and_then(Clone::clone); + + let candidate_alias = cand_alias_i18n .as_ref() - .and_then(|i18n| i18n.get(&default_language)) - .and_then(|alias| alias.clone()); + .and_then(|i18n| i18n.get(default_language)) + .and_then(Clone::clone); Ok(ballot::Candidate { id: candidate.id.clone(), - tenant_id: (candidate.tenant_id.clone()), - election_event_id: (candidate.election_event_id.clone()), - election_id: (contest.election_id.clone()), - contest_id: (contest.id.clone()), - name: candidate_name.clone(), - name_i18n, + tenant_id: candidate.tenant_id.clone(), + election_event_id: candidate.election_event_id.clone(), + election_id: contest.election_id.clone(), + contest_id: contest.id.clone(), + name: candidate_name, + name_i18n: cand_name_i18n, description: candidate.description.clone(), - description_i18n, - alias: candidate_alias.clone(), - alias_i18n: alias_i18n, + description_i18n: cand_description_i18n, + alias: candidate_alias, + alias_i18n: cand_alias_i18n, candidate_type: candidate.r#type.clone(), presentation: Some(candidate_presentation), annotations: candidate .annotations - .clone() - .map(|value| deserialize_value(value)) + .as_ref() + .map(|value| deserialize_value(value.clone())) .transpose()?, }) }) @@ -232,12 +241,13 @@ fn create_contest( let contest_name = name_i18n .as_ref() - .and_then(|i18n| i18n.get(&default_language)) - .and_then(|name| name.clone()); + .and_then(|i18n| i18n.get(default_language)) + .and_then(Clone::clone); + let contest_alias = alias_i18n .as_ref() - .and_then(|i18n| i18n.get(&default_language)) - .and_then(|alias| alias.clone()); + .and_then(|i18n| i18n.get(default_language)) + .and_then(Clone::clone); // Extract tie_breaking_policy from tally_configuration JSON let tie_breaking_policy = contest @@ -250,28 +260,28 @@ fn create_contest( Ok(ballot::Contest { id: contest.id.clone(), - tenant_id: (contest.tenant_id), - election_event_id: (contest.election_event_id), - election_id: (contest.election_id.clone()), + tenant_id: contest.tenant_id, + election_event_id: contest.election_event_id, + election_id: contest.election_id.clone(), name: contest_name, name_i18n, description: contest.description, description_i18n, - alias: contest_alias.clone(), + alias: contest_alias, alias_i18n, - max_votes: (contest.max_votes.unwrap_or(0)), - min_votes: (contest.min_votes.unwrap_or(0)), + max_votes: contest.max_votes.unwrap_or(0), + min_votes: contest.min_votes.unwrap_or(0), winning_candidates_num: contest.winning_candidates_num.unwrap_or(1), voting_type: contest.voting_type, counting_algorithm: Some(counting_algorithm), - is_encrypted: (contest.is_encrypted.unwrap_or(false)), - candidates, + is_encrypted: contest.is_encrypted.unwrap_or(false), + candidates: ballot_candidates, presentation: Some(contest_presentation), created_at: contest.created_at.map(|date| date.to_rfc3339()), annotations: contest .annotations - .clone() - .map(|value| deserialize_value(value)) + .as_ref() + .map(|value| deserialize_value(value.clone())) .transpose()?, tie_breaking_policy, }) diff --git a/packages/sequent-core/src/encrypt.rs b/packages/sequent-core/src/encrypt.rs index 50d6802b28d..13a98d0b369 100644 --- a/packages/sequent-core/src/encrypt.rs +++ b/packages/sequent-core/src/encrypt.rs @@ -4,14 +4,18 @@ use strand::backend::ristretto::RistrettoCtx; use strand::context::Ctx; -use strand::elgamal::*; +use strand::elgamal::{Ciphertext, PublicKey}; use strand::hash; use strand::hash::Hash; use strand::serialization::StrandDeserialize; use strand::util::StrandError; use strand::zkp::{Schnorr, Zkp}; -use crate::ballot::*; +use crate::ballot::{ + AuditableBallot, AuditableBallotContest, BallotStyle, HashableBallot, + PublicKeyConfig, RawHashableBallot, ReplicationChoice, + SignedHashableBallot, TYPES_VERSION, +}; use crate::ballot_codec::multi_ballot::BallotChoices; use crate::ballot_codec::multi_ballot::ContestChoices; use crate::ballot_codec::PlaintextCodec; @@ -29,6 +33,7 @@ use base64::engine::general_purpose; use base64::Engine; use strand::serialization::StrandSerialize; +/// Default public key for Ristretto. pub const DEFAULT_PUBLIC_KEY_RISTRETTO_STR: &str = "ajR/I9RqyOwbpsVRucSNOgXVLCvLpfQxCgPoXGQ2RF4"; @@ -36,54 +41,71 @@ pub const DEFAULT_PUBLIC_KEY_RISTRETTO_STR: &str = /// only the first 32 bytes. pub const SHORT_SHA512_HASH_LENGTH_BYTES: usize = 32; +/// Type alias for a shortened SHA-512 hash. pub type ShortHash = [u8; SHORT_SHA512_HASH_LENGTH_BYTES]; -// Labels are used to make the proof of knowledge unique. -// This is a constant for now but when we implement voter signatures this will -// be unique to the voter, and it will include the public key of the voter, -// election event id, election id, contest id etc. +/// Labels are used to make the proof of knowledge unique. +/// This is a constant for now but when we implement voter signatures this will +/// be unique to the voter, and it will include the public key of the voter, +/// election event id, election id, contest id etc. pub const DEFAULT_PLAINTEXT_LABEL: [u8; 0] = []; +/// Returns the default public key for Ristretto. +/// +/// # Panics +/// Panics if the base64 decode or deserialization fails. +#[must_use] pub fn default_public_key_ristretto() -> (String, ::E) { let pk_str: String = DEFAULT_PUBLIC_KEY_RISTRETTO_STR.to_string(); let pk_bytes = general_purpose::STANDARD_NO_PAD .decode(pk_str.clone()) - .unwrap(); - let pk = ::E::strand_deserialize(&pk_bytes).unwrap(); + .expect("Failed to base64 decode DEFAULT_PUBLIC_KEY_RISTRETTO_STR"); + let pk = ::E::strand_deserialize(&pk_bytes) + .expect("Failed to deserialize ristretto public key from bytes"); (pk_str, pk) } - +/// Encrypt a plaintext candidate using the provided public key element and label. +/// +/// # Errors +/// Returns an error if encryption or proof generation fails. +/// # Panics +/// Panics if proof verification fails, which should not happen if the encryption and proof generation are correct. pub fn encrypt_plaintext_candidate( ctx: &C, - public_key_element: ::E, + public_key_element: &::E, plaintext: ::P, label: &[u8], ) -> Result<(ReplicationChoice, Schnorr), BallotError> { // construct a public key from a provided element - let pk = PublicKey::from_element(&public_key_element, ctx); + let pk = PublicKey::from_element(public_key_element, ctx); - let encoded = ctx.encode(&plaintext).unwrap(); + let encoded = ctx.encode(&plaintext).expect("encode plaintext failed"); // encrypt and prove knowledge of plaintext (enc + pok) - let (ciphertext, proof, randomness) = - pk.encrypt_and_pok(&encoded, label).unwrap(); + let (ciphertext, proof, randomness) = pk + .encrypt_and_pok(&encoded, label) + .expect("encrypt_and_pok failed"); // verify let zkp = Zkp::new(ctx); let proof_ok = zkp .encryption_popk_verify(&ciphertext.mhr, &ciphertext.gr, &proof, label) - .unwrap(); + .expect("encryption_popk_verify failed"); assert!(proof_ok); Ok(( ReplicationChoice { - ciphertext: ciphertext, - plaintext: plaintext, - randomness: randomness, + ciphertext, + plaintext, + randomness, }, proof, )) } +/// Parse the public key from the ballot style's public key configuration. +/// +/// # Errors +/// Returns an error if the public key is missing or if deserialization fails. pub fn parse_public_key( election: &BallotStyle, ) -> Result { @@ -96,6 +118,10 @@ pub fn parse_public_key( Base64Deserialize::deserialize(public_key_config.public_key) } +/// Recreate the ciphertext for a given ballot using the public key and plaintext choices. +/// +/// # Errors +/// Returns an error if the ballot is inconsistent or if encryption fails. pub fn recreate_encrypt_cyphertext( ctx: &C, ballot: &AuditableBallot, @@ -112,41 +138,46 @@ pub fn recreate_encrypt_cyphertext( let contests: Vec> = ballot.deserialize_contests::()?; - contests - .clone() + Ok(contests .into_iter() .map(|contests| { recreate_encrypt_candidate(ctx, &public_key, &contests.choice) }) - .collect::, BallotError>>>() - .into_iter() - .collect() + .collect()) } +/// Recreate the ciphertext for a given ballot choice using the public key and plaintext choice. +/// +/// # Errors +/// Returns an error if encoding fails. fn recreate_encrypt_candidate( ctx: &C, public_key_element: &C::E, choice: &ReplicationChoice, -) -> Result, BallotError> { +) -> ReplicationChoice { // construct a public key from a provided element let public_key = PublicKey::from_element(public_key_element, ctx); - let encoded = ctx.encode(&choice.plaintext).unwrap(); + let encoded = ctx.encode(&choice.plaintext).expect("encode failed"); // encrypt / create ciphertext let ciphertext = public_key.encrypt_with_randomness(&encoded, &choice.randomness); // convert to output format - Ok(ReplicationChoice { - ciphertext: ciphertext, + ReplicationChoice { + ciphertext, plaintext: choice.plaintext.clone(), randomness: choice.randomness.clone(), - }) + } } +/// Create ballot choices and encoded plaintext from decoded contests +/// +/// # Errors +/// Returns an error if encoding fails or if the number of contests is inconsistent. pub fn encode_to_plaintext_decoded_multi_contest( - decoded_contests: &Vec, + decoded_contests: &[DecodedVoteContest], config: &BallotStyle, ) -> Result<([u8; 30], BallotChoices), BallotError> { if config.contests.len() != decoded_contests.len() { @@ -174,19 +205,22 @@ pub fn encode_to_plaintext_decoded_multi_contest( ); let plaintext = - ballot_choices.encode_to_30_bytes(&config).map_err(|err| { + ballot_choices.encode_to_30_bytes(config).map_err(|err| { BallotError::Serialization(format!( - "Error encrypting plaintext: {}", - err + "Error encrypting plaintext: {err}" )) })?; Ok((plaintext, ballot_choices)) } +/// Encrypt a decoded multi-contest ballot into an auditable multi-ballot. +/// +/// # Errors +/// Returns an error if encoding fails or if the number of contests is inconsistent. pub fn encrypt_decoded_multi_contest>( ctx: &C, - decoded_contests: &Vec, + decoded_contests: &[DecodedVoteContest], config: &BallotStyle, ) -> Result { if config.contests.len() != decoded_contests.len() { @@ -216,9 +250,13 @@ pub fn encrypt_decoded_multi_contest>( encrypt_multi_ballot(ctx, &ballot, config) } +/// Encrypt a decoded contest ballot into an auditable ballot. +/// +/// # Errors +/// Returns an error if encoding fails or if the number of contests is inconsistent. pub fn encrypt_decoded_contest>( ctx: &C, - decoded_contests: &Vec, + decoded_contests: &[DecodedVoteContest], config: &BallotStyle, ) -> Result { if config.contests.len() != decoded_contests.len() { @@ -229,7 +267,7 @@ pub fn encrypt_decoded_contest>( ))); } - let public_key: C::E = parse_public_key::(&config)?; + let public_key: C::E = parse_public_key::(config)?; let mut contests: Vec> = vec![]; @@ -245,23 +283,22 @@ pub fn encrypt_decoded_contest>( )) })?; let plaintext = contest - .encode_plaintext_contest(&decoded_contest) + .encode_plaintext_contest(decoded_contest) .map_err(|err| { BallotError::Serialization(format!( - "Error encrypting plaintext: {}", - err + "Error encrypting plaintext: {err}" )) })?; let (choice, proof) = encrypt_plaintext_candidate( ctx, - public_key.clone(), + &public_key, plaintext, &DEFAULT_PLAINTEXT_LABEL, )?; contests.push(AuditableBallotContest:: { contest_id: contest.id.clone(), - choice: choice, - proof: proof, + choice, + proof, }); } @@ -269,7 +306,7 @@ pub fn encrypt_decoded_contest>( version: TYPES_VERSION, issue_date: get_current_date(), contests: AuditableBallot::serialize_contests::(&contests)?, - ballot_hash: String::from(""), + ballot_hash: String::new(), config: config.clone(), voter_signing_pk: None, voter_ballot_signature: None, @@ -284,6 +321,10 @@ pub fn encrypt_decoded_contest>( Ok(auditable_ballot) } +/// Hash a ballot style using SHA-512 and return the hash as a hexadecimal string. +/// +/// # Errors +/// Returns an error if serialization fails. pub fn hash_ballot_style_sha512( ballot_style: &BallotStyle, ) -> Result { @@ -291,6 +332,10 @@ pub fn hash_ballot_style_sha512( hash::hash_to_array(&bytes) } +/// Hash a ballot style using SHA-512 and return the truncated hash as a hexadecimal string. +/// +/// # Errors +/// Returns an error if serialization fails. pub fn hash_ballot_style( ballot_style: &BallotStyle, ) -> Result { @@ -300,26 +345,34 @@ pub fn hash_ballot_style( Ok(hex::encode(short_hash)) } +/// Hash a ballot using SHA-512 and return the hash as a hexadecimal string. +/// +/// # Errors +/// Returns an error if serialization fails. pub fn hash_ballot_sha512( hashable_ballot: &HashableBallot, ) -> Result { let raw_hashable_ballot = RawHashableBallot::::try_from(hashable_ballot) - .map_err(|error| StrandError::Generic(format!("{:?}", error)))?; + .map_err(|error| StrandError::Generic(format!("{error:?}")))?; let bytes = raw_hashable_ballot.strand_serialize()?; hash::hash_to_array(&bytes) } +/// Shorten a SHA-512 hash to a shorter hash by taking the first 32 bytes. +#[must_use] pub fn shorten_hash(hash: &Hash) -> ShortHash { let mut shortened: ShortHash = [0u8; SHORT_SHA512_HASH_LENGTH_BYTES]; shortened.copy_from_slice(&hash[0..32]); shortened } -// hash ballot: -// serialize ballot into string, then hash to sha512, truncate to -// 256 bits and serialize to hexadecimal +/// hash ballot: +/// serialize ballot into string, then hash to sha512, truncate to +/// 256 bits and serialize to hexadecimal +/// # Errors +/// Returns an error if serialization fails. pub fn hash_ballot( hashable_ballot: &HashableBallot, ) -> Result { @@ -332,7 +385,11 @@ pub fn hash_ballot( //////////////////////////////////////////////////////////////// /// Multi ballots //////////////////////////////////////////////////////////////// - +/// +/// Encrypt a decoded multi-contest ballot into an auditable multi-ballot. +/// +/// # Errors +/// Returns an error if encoding fails or if the number of contests is inconsistent. pub fn encrypt_multi_ballot>( ctx: &C, ballot_choices: &BallotChoices, @@ -346,19 +403,18 @@ pub fn encrypt_multi_ballot>( ))); } - let public_key: C::E = parse_public_key::(&config)?; + let public_key: C::E = parse_public_key::(config)?; let plaintext = - ballot_choices.encode_to_30_bytes(&config).map_err(|err| { + ballot_choices.encode_to_30_bytes(config).map_err(|err| { BallotError::Serialization(format!( - "Error encrypting plaintext: {}", - err + "Error encrypting plaintext: {err}" )) })?; let contest_ids = ballot_choices.get_contest_ids(); let (choice, proof) = encrypt_plaintext_candidate( ctx, - public_key.clone(), + &public_key, plaintext, &DEFAULT_PLAINTEXT_LABEL, )?; @@ -373,7 +429,7 @@ pub fn encrypt_multi_ballot>( version: TYPES_VERSION, issue_date: get_current_date(), contests: AuditableMultiBallot::serialize_contests::(&contests)?, - ballot_hash: String::from(""), + ballot_hash: String::new(), config: config.clone(), voter_signing_pk: None, voter_ballot_signature: None, @@ -385,6 +441,10 @@ pub fn encrypt_multi_ballot>( Ok(auditable_ballot) } +/// Hash a multi-contest ballot using SHA-512 and return the truncated hash as a hexadecimal string. +/// +/// # Errors +/// Returns an error if serialization fails. pub fn hash_multi_ballot( hashable_ballot: &HashableMultiBallot, ) -> Result { @@ -393,13 +453,16 @@ pub fn hash_multi_ballot( let short_hash = shorten_hash(&sha512_hash); Ok(hex::encode(short_hash)) } - +/// Hash a multi-contest ballot using SHA-512 and return the hash as a hexadecimal string. +/// +/// # Errors +/// Returns an error if serialization fails. pub fn hash_multi_ballot_sha512( hashable_ballot: &HashableMultiBallot, ) -> Result { let raw_hashable_ballot = RawHashableMultiBallot::::try_from(hashable_ballot) - .map_err(|error| StrandError::Generic(format!("{:?}", error)))?; + .map_err(|error| StrandError::Generic(format!("{error:?}")))?; let bytes = raw_hashable_ballot.strand_serialize()?; hash::hash_to_array(&bytes) @@ -431,7 +494,7 @@ mod tests { encrypt::encrypt_plaintext_candidate( &ctx, - pk_element, + &pk_element, plaintext, &encrypt::DEFAULT_PLAINTEXT_LABEL, ) diff --git a/packages/sequent-core/src/error.rs b/packages/sequent-core/src/error.rs index b96111aca00..3657c77f0de 100644 --- a/packages/sequent-core/src/error.rs +++ b/packages/sequent-core/src/error.rs @@ -4,10 +4,15 @@ quick_error! { #[derive(Debug, PartialEq, Eq)] + /// Errors related to ballot processing. pub enum BallotError { + /// Error parsing a big unsigned integer from a string. ParseBigUint(uint_str: String, message: String) {} + /// Error while cryptographic checks. CryptographicCheck(message: String) {} + /// Error during consistency checks. ConsistencyCheck(message: String) {} + /// Error during serialization or deserialization. Serialization(message: String) {} } } diff --git a/packages/sequent-core/src/fixtures/ballot_codec.rs b/packages/sequent-core/src/fixtures/ballot_codec.rs index f858c87e243..644fd41b6d6 100644 --- a/packages/sequent-core/src/fixtures/ballot_codec.rs +++ b/packages/sequent-core/src/fixtures/ballot_codec.rs @@ -1,17 +1,23 @@ -// SPDX-FileCopyrightText: 2025 Sequent Tech Inc +// SPDX-FileCopyrightText: 2023 Felix Robles +// SPDX-FileCopyrightText: 2024 Eduardo Robles // // SPDX-License-Identifier: AGPL-3.0-only -use crate::ballot::BallotStyle; -use crate::ballot::*; +use crate::ballot::{ + BallotStyle, Candidate, CandidatePresentation, CandidateUrl, Contest, + ContestPresentation, EBlankVotePolicy, EOverVotePolicy, EUnderVotePolicy, + InvalidVotePolicy, PublicKeyConfig, +}; use crate::ballot_codec::{vec_to_30_array, RawBallotContest}; use crate::plaintext::{ DecodedVoteChoice, DecodedVoteContest, InvalidPlaintextError, InvalidPlaintextErrorType, }; use crate::types::ceremonies::CountingAlgType; +use std::cmp::Ordering; use std::collections::HashMap; - +/// Fixture struct for ballot codec tests. +#[allow(missing_docs)] pub struct BallotCodecFixture { pub title: String, pub contest: Contest, @@ -21,11 +27,18 @@ pub struct BallotCodecFixture { pub encoded_ballot: [u8; 30], pub expected_errors: Option>, } +/// Fixture struct for bases. +#[allow(missing_docs)] pub struct BasesFixture { pub contest: Contest, pub bases: Vec, } +/// Returns a test Contest configured for plurality voting. +/// +/// # Returns +/// A `Contest` struct for plurality voting. +#[allow(clippy::too_many_lines)] fn get_contest_plurality() -> Contest { Contest { created_at: None, @@ -220,6 +233,10 @@ fn get_contest_plurality() -> Contest { } } +/// Returns a test Contest configured for Borda voting. +/// +/// # Returns +/// A `Contest` struct for Borda voting. fn get_contest_borda() -> Contest { let mut contest = get_contest_plurality(); contest.counting_algorithm = Some(CountingAlgType::Borda); @@ -227,108 +244,8 @@ fn get_contest_borda() -> Contest { contest } -fn get_contest_irv() -> Contest { - let mut contest = get_contest_plurality(); - contest.counting_algorithm = Some(CountingAlgType::InstantRunoff); - contest.max_votes = 3; - contest -} - -pub fn get_irv_fixture_valid_ballot() -> BallotCodecFixture { - BallotCodecFixture { - title: "irv_fixture".to_string(), - contest: get_contest_irv(), - raw_ballot: RawBallotContest { - bases: vec![2u64, 4u64, 4u64, 4u64, 4u64, 4u64], - choices: vec![0u64, 1u64, 2u64, 0u64, 3u64, 0u64], - }, - plaintext: DecodedVoteContest { - contest_id: "1fc963b1-f93b-4151-93d6-bbe0ea5eac46".to_string(), - is_explicit_invalid: false, - choices: vec![ - DecodedVoteChoice { - id: 0.to_string(), - selected: 0, - write_in_text: None, - }, - DecodedVoteChoice { - id: 1.to_string(), - selected: 1, - write_in_text: None, - }, - DecodedVoteChoice { - id: 2.to_string(), - selected: -1, - write_in_text: None, - }, - DecodedVoteChoice { - id: 3.to_string(), - selected: 2, - write_in_text: None, - }, - DecodedVoteChoice { - id: 4.to_string(), - selected: -1, - write_in_text: None, - }, - ], - invalid_errors: vec![], - invalid_alerts: vec![], - }, - encoded_ballot_bigint: "402".to_string(), - encoded_ballot: vec_to_30_array(&vec![2, 146, 1]).unwrap(), - expected_errors: None, - } -} - -/// Invalid ballot due to duplicated position -pub fn get_irv_fixture_invalid_ballot() -> BallotCodecFixture { - BallotCodecFixture { - title: "irv_fixture".to_string(), - contest: get_contest_irv(), - raw_ballot: RawBallotContest { - bases: vec![2u64, 4u64, 4u64, 4u64, 4u64, 4u64], - choices: vec![0u64, 1u64, 2u64, 0u64, 3u64, 0u64], - }, - plaintext: DecodedVoteContest { - contest_id: "1fc963b1-f93b-4151-93d6-bbe0ea5eac46".to_string(), - is_explicit_invalid: false, - choices: vec![ - DecodedVoteChoice { - id: 0.to_string(), - selected: 0, - write_in_text: None, - }, - DecodedVoteChoice { - id: 1.to_string(), - selected: 1, - write_in_text: None, - }, - DecodedVoteChoice { - id: 2.to_string(), - selected: 2, - write_in_text: None, - }, - DecodedVoteChoice { - id: 3.to_string(), - selected: 2, // Duplicated selection - write_in_text: None, - }, - DecodedVoteChoice { - id: 4.to_string(), - selected: -1, - write_in_text: None, - }, - ], - invalid_errors: vec![], - invalid_alerts: vec![], - }, - encoded_ballot_bigint: "402".to_string(), - encoded_ballot: vec_to_30_array(&vec![2, 146, 1]).unwrap(), - expected_errors: None, - } -} - +/// Returns a `DecodedVoteContest`. +#[must_use] pub fn get_test_decoded_vote_contest() -> DecodedVoteContest { DecodedVoteContest { contest_id: "1fc963b1-f93b-4151-93d6-bbe0ea5eac46".to_string(), @@ -355,6 +272,12 @@ pub fn get_test_decoded_vote_contest() -> DecodedVoteContest { } } +/// Returns a `BallotStyle` for write-in ballots. +/// +/// # Returns +/// A `BallotStyle` for write-in ballots. +#[must_use] +#[allow(clippy::too_many_lines)] pub fn get_writein_ballot_style() -> BallotStyle { BallotStyle { id: "9570d82a-d92a-44d7-b483-d5a6c8c398a8".into(), @@ -371,8 +294,8 @@ pub fn get_writein_ballot_style() -> BallotStyle { area_presentation: None, election_event_presentation: None, election_presentation: None, - election_event_annotations: Default::default(), - election_annotations: Default::default(), + election_event_annotations: Option::default(), + election_annotations: Option::default(), election_dates: None, contests: vec![Contest { created_at: None, @@ -560,18 +483,38 @@ pub fn get_writein_ballot_style() -> BallotStyle { } } +/// Returns a `DecodedVoteContest` with a write-in of variable length. +/// +/// # Panics +/// Panics if `increase` is negative and cannot be converted to `usize` or if truncation would result in a negative length. +#[must_use] pub fn get_too_long_writein_plaintext(increase: i64) -> DecodedVoteContest { let write_in = "THERE IS SOME VERY LARGE STRING BEING WRITTEN".to_string(); - - let mod_write_in = if 0 == increase { - write_in - } else if increase > 0 { - write_in + &"Z".repeat(increase as usize) - } else { - let trunc_len: i64 = write_in.len() as i64 + increase; - let mut res = write_in.clone(); - res.truncate(trunc_len as usize); - res + let mod_write_in = match increase.cmp(&0) { + Ordering::Equal => write_in.clone(), + Ordering::Greater => match usize::try_from(increase) { + Ok(inc_usize) => { + let mut s = write_in.clone(); + s.push_str(&"Z".repeat(inc_usize)); + s + } + Err(_) => String::new(), + }, + Ordering::Less => { + let len_i64 = i64::try_from(write_in.len()).unwrap_or(0); + let trunc_len = len_i64.checked_add(increase); + match trunc_len { + Some(tl) if tl > 0 => match usize::try_from(tl) { + Ok(tl_usize) => { + let mut res = write_in.clone(); + res.truncate(tl_usize); + res + } + Err(_) => String::new(), + }, + _ => String::new(), + } + } }; DecodedVoteContest { @@ -604,6 +547,8 @@ pub fn get_too_long_writein_plaintext(increase: i64) -> DecodedVoteContest { } } +/// Returns a `DecodedVoteContest` with a valid write-in. +#[must_use] pub fn get_writein_plaintext() -> DecodedVoteContest { DecodedVoteContest { contest_id: "1c1500ac-173e-4e78-a59d-91bfa3678c5a".to_string(), @@ -635,6 +580,9 @@ pub fn get_writein_plaintext() -> DecodedVoteContest { } } +/// Returns a `Contest` with multiple candidates and descriptions. +#[must_use] +#[allow(clippy::too_many_lines)] pub fn get_test_contest() -> Contest { Contest { created_at:None, @@ -782,6 +730,8 @@ pub fn get_test_contest() -> Contest { } } +/// Returns a configurable `Contest` with multiple candidates. +#[allow(clippy::too_many_lines)] pub(crate) fn get_configurable_contest( max: i64, num_candidates: usize, @@ -1040,29 +990,32 @@ pub(crate) fn get_configurable_contest( contest.counting_algorithm = Some(counting_algorithm); contest.max_votes = max; if enable_writeins { - let mut presentation = - contest.presentation.unwrap_or(ContestPresentation::new()); + let mut presentation = contest.presentation.unwrap_or_default(); presentation.allow_writeins = Some(true); - contest.presentation = Some(presentation); let write_in_indexes = write_in_contests.unwrap_or_else(|| vec![4, 5, 6]); for write_in_index in write_in_indexes { - if write_in_index < contest.candidates.len() { - contest.candidates[write_in_index].set_is_write_in(true); + if let Some(candidate) = contest.candidates.get_mut(write_in_index) + { + candidate.set_is_write_in(true); } } } // set base32_writeins - let mut presentation = - contest.presentation.unwrap_or(ContestPresentation::new()); + let mut presentation = contest.presentation.unwrap_or_default(); presentation.base32_writeins = Some(base32_writeins); contest.presentation = Some(presentation); - contest.candidates = contest.candidates[0..num_candidates].to_vec(); + contest.candidates = match contest.candidates.get(0..num_candidates) { + Some(slice) => slice.to_vec(), + None => Vec::new(), + }; contest } +/// Returns a `Contest` with a specified number of candidates. +#[allow(dead_code)] pub(crate) fn get_contest_candidates_n(num_candidates: usize) -> Contest { let candidates: Vec = (0..num_candidates) .map(|i| Candidate { @@ -1145,13 +1098,21 @@ pub(crate) fn get_contest_candidates_n(num_candidates: usize) -> Contest { }), }; - let mut presentation = - contest.presentation.unwrap_or(ContestPresentation::new()); + let presentation = contest.presentation.unwrap_or_default(); contest.presentation = Some(presentation); contest } +/// Returns a vector of `BallotCodecFixture` with different configurations of contests and ballots. +/// +/// # Returns +/// A vector of `BallotCodecFixture` with various contest and ballot configurations for testing. +/// +/// # Panics +/// This function does not panic. +#[must_use] +#[allow(clippy::too_many_lines)] pub fn get_fixtures() -> Vec { vec![ BallotCodecFixture { @@ -1221,7 +1182,7 @@ pub fn get_fixtures() -> Vec { ], }, encoded_ballot_bigint: "50".to_string(), - encoded_ballot: vec_to_30_array(&vec![1, 50]).unwrap(), + encoded_ballot: vec_to_30_array(&[1, 50]).expect("Failed to convert vector to 30-byte array"), expected_errors: None }, BallotCodecFixture { @@ -1265,7 +1226,7 @@ pub fn get_fixtures() -> Vec { invalid_alerts: vec![], }, encoded_ballot_bigint: "2756".to_string(), - encoded_ballot: vec_to_30_array(&vec![2, 196, 10]).unwrap(), + encoded_ballot: vec_to_30_array(&[2, 196, 10]).expect("Failed to convert vector to 30-byte array"), expected_errors: None }, BallotCodecFixture { @@ -1468,7 +1429,7 @@ pub fn get_fixtures() -> Vec { ], }, encoded_ballot_bigint: "15".to_string(), - encoded_ballot: vec_to_30_array(&vec![1, 15]).unwrap(), + encoded_ballot: vec_to_30_array(&[1, 15]).expect("Failed to convert vector to 30-byte array"), expected_errors: None }, BallotCodecFixture { @@ -1651,7 +1612,7 @@ pub fn get_fixtures() -> Vec { invalid_alerts: vec![], }, encoded_ballot_bigint: "3".to_string(), - encoded_ballot: vec_to_30_array(&vec![1, 3]).unwrap(), + encoded_ballot: vec_to_30_array(&[1, 3]).expect("Failed to convert vector to 30-byte array"), expected_errors: None }, BallotCodecFixture { @@ -1844,7 +1805,7 @@ pub fn get_fixtures() -> Vec { invalid_alerts: vec![], }, encoded_ballot_bigint: "14".to_string(), - encoded_ballot: vec_to_30_array(&vec![1, 14]).unwrap(), + encoded_ballot: vec_to_30_array(&[1, 14]).expect("Failed to convert vector to 30-byte array"), expected_errors: None }, BallotCodecFixture { @@ -2011,7 +1972,7 @@ pub fn get_fixtures() -> Vec { invalid_alerts: vec![], }, encoded_ballot_bigint: "0".to_string(), - encoded_ballot: vec_to_30_array(&vec![1, 0]).unwrap(), + encoded_ballot: vec_to_30_array(&[1, 0]).expect("Failed to convert vector to 30-byte array"), expected_errors: None }, BallotCodecFixture { @@ -2188,7 +2149,7 @@ pub fn get_fixtures() -> Vec { ], }, encoded_ballot_bigint: "0".to_string(), - encoded_ballot: vec_to_30_array(&vec![1, 0]).unwrap(), + encoded_ballot: vec_to_30_array(&[1, 0]).expect("Failed to convert vector to 30-byte array"), expected_errors: None }, BallotCodecFixture { @@ -2364,7 +2325,7 @@ pub fn get_fixtures() -> Vec { invalid_alerts: vec![], }, encoded_ballot_bigint: "0".to_string(), - encoded_ballot: vec_to_30_array(&vec![1, 0]).unwrap(), + encoded_ballot: vec_to_30_array(&[1, 0]).expect("Failed to convert vector to 30-byte array"), expected_errors: None }, BallotCodecFixture { @@ -2542,9 +2503,9 @@ pub fn get_fixtures() -> Vec { invalid_alerts: vec![], }, encoded_ballot_bigint: "16".to_string(), - encoded_ballot: vec_to_30_array(&vec![1, 16]).unwrap(), + encoded_ballot: vec_to_30_array(&[1, 16]).expect("Failed to convert vector to 30-byte array"), expected_errors: Some(HashMap::from([ - ("contest_bases".to_string(), "".to_string()), + ("contest_bases".to_string(), String::new()), ("contest_encode_plaintext".to_string(), "choice id is not a valid candidate".to_string()), ("contest_encode_to_raw_ballot".to_string(), "choice id is not a valid candidate".to_string()), ("contest_decode_plaintext".to_string(), "decode_choices".to_string()), @@ -2602,7 +2563,7 @@ pub fn get_fixtures() -> Vec { ] }, encoded_ballot_bigint: "68".to_string(), - encoded_ballot: vec_to_30_array(&vec![1, 68]).unwrap(), + encoded_ballot: vec_to_30_array(&[1, 68]).expect("Failed to convert vector to 30-byte array"), expected_errors: Some(HashMap::from([ ("contest_decode_plaintext".to_string(), "decode_choices".to_string()), ])) @@ -2658,7 +2619,7 @@ pub fn get_fixtures() -> Vec { ] }, encoded_ballot_bigint: "70".to_string(), - encoded_ballot: vec_to_30_array(&vec![1, 70]).unwrap(), + encoded_ballot: vec_to_30_array(&[1, 70]).expect("Failed to convert vector to 30-byte array"), expected_errors: Some(HashMap::from([ ("contest_decode_plaintext".to_string(), "decode_choices".to_string()), ])) @@ -2714,7 +2675,7 @@ pub fn get_fixtures() -> Vec { ] }, encoded_ballot_bigint: "4122".to_string(), - encoded_ballot: vec_to_30_array(&vec![2, 26, 16]).unwrap(), + encoded_ballot: vec_to_30_array(&[2, 26, 16]).expect("Failed to convert vector to 30-byte array"), expected_errors: None }, BallotCodecFixture { @@ -2750,7 +2711,7 @@ pub fn get_fixtures() -> Vec { ] }, encoded_ballot_bigint: "3".to_string(), - encoded_ballot: vec_to_30_array(&vec![1, 3]).unwrap(), + encoded_ballot: vec_to_30_array(&[1, 3]).expect("Failed to convert vector to 30-byte array"), expected_errors: None }, BallotCodecFixture { @@ -2778,12 +2739,6 @@ pub fn get_fixtures() -> Vec { ("numSelected".to_string(), "3".to_string()), ("max".to_string(), "2".to_string()) ]), - }, - InvalidPlaintextError { - error_type: InvalidPlaintextErrorType::Implicit, - candidate_id: None, - message: Some("errors.implicit.duplicatedPosition".to_string()), - message_map: HashMap::new(), } ], invalid_alerts: vec![], @@ -2816,12 +2771,12 @@ pub fn get_fixtures() -> Vec { DecodedVoteChoice { id: 5.to_string(), selected: -1, - write_in_text: Some("".to_string()) + write_in_text: Some(String::new()) } ] }, encoded_ballot_bigint: "6213".to_string(), - encoded_ballot: vec_to_30_array(&vec![2, 69, 24]).unwrap(), + encoded_ballot: vec_to_30_array(&[2, 69, 24]).expect("Failed to convert vector to 30-byte array"), expected_errors: Some(HashMap::from([ ("contest_bases".to_string(), "bases don't cover write-ins".to_string()), ])) @@ -2867,7 +2822,7 @@ pub fn get_fixtures() -> Vec { DecodedVoteChoice { id: 5.to_string(), selected: -1, - write_in_text: Some("".to_string()), + write_in_text: Some(String::new()), }, DecodedVoteChoice { id: 6.to_string(), @@ -2877,7 +2832,7 @@ pub fn get_fixtures() -> Vec { ] }, encoded_ballot_bigint: "849069737378".to_string(), - encoded_ballot: vec_to_30_array(&vec![5, 162, 5, 128, 176, 197]).unwrap(), + encoded_ballot: vec_to_30_array(&[5, 162, 5, 128, 176, 197]).expect("Failed to convert vector to 30-byte array"), expected_errors: Some(HashMap::from([ ("contest_bases".to_string(), "bases don't cover write-ins".to_string()), ])) @@ -2915,7 +2870,7 @@ pub fn get_fixtures() -> Vec { ] }, encoded_ballot_bigint: "2".to_string(), - encoded_ballot: vec_to_30_array(&vec![1, 2]).unwrap(), + encoded_ballot: vec_to_30_array(&[1, 2]).expect("Failed to convert vector to 30-byte array"), expected_errors: Some(HashMap::from([ ("contest_encode_raw_ballot".to_string(), "Invalid parameters: 'valueList' (size = 3) and 'baseList' (size = 4) must have the same length.".to_string()), ("contest_encode_plaintext".to_string(), "Invalid parameters: 'valueList' (size = 3) and 'baseList' (size = 4) must have the same length.".to_string()), @@ -2954,7 +2909,7 @@ pub fn get_fixtures() -> Vec { ] }, encoded_ballot_bigint: "2".to_string(), - encoded_ballot: vec_to_30_array(&vec![1, 2]).unwrap(), + encoded_ballot: vec_to_30_array(&[1, 2]).expect("Failed to convert vector to 30-byte array"), expected_errors: Some(HashMap::from([ ("contest_bases".to_string(), "bases don't cover write-ins".to_string()), ("contest_encode_to_raw_ballot".to_string(), "disabled".to_string()), @@ -2999,7 +2954,7 @@ pub fn get_fixtures() -> Vec { ] }, encoded_ballot_bigint: "18".to_string(), - encoded_ballot: vec_to_30_array(&vec![1, 18]).unwrap(), + encoded_ballot: vec_to_30_array(&[1, 18]).expect("Failed to convert vector to 30-byte array"), expected_errors: Some(HashMap::from([ ("contest_encode_to_raw_ballot".to_string(), "disabled".to_string()), ("contest_decode_plaintext".to_string(), "invalid_errors, decode_choices".to_string()), @@ -3043,7 +2998,7 @@ pub fn get_fixtures() -> Vec { ] }, encoded_ballot_bigint: "386".to_string(), - encoded_ballot: vec_to_30_array(&vec![2, 130, 1]).unwrap(), + encoded_ballot: vec_to_30_array(&[2, 130, 1]).expect("Failed to convert vector to 30-byte array"), expected_errors: Some(HashMap::from([ ("contest_bases".to_string(), "bases don't cover write-ins".to_string()), ("contest_encode_to_raw_ballot".to_string(), "disabled".to_string()), @@ -3054,6 +3009,8 @@ pub fn get_fixtures() -> Vec { ] } +/// Return `Vec` containing various bases with matching contests. +#[must_use] pub fn bases_fixture() -> Vec { vec![ BasesFixture { diff --git a/packages/sequent-core/src/fixtures/encrypt.rs b/packages/sequent-core/src/fixtures/encrypt.rs index 53cabb67e51..64ae687ed2a 100644 --- a/packages/sequent-core/src/fixtures/encrypt.rs +++ b/packages/sequent-core/src/fixtures/encrypt.rs @@ -5,6 +5,7 @@ use crate::ballot::BallotStyle; use crate::plaintext::DecodedVoteContest; +/// Returns a test fixture of decoded contests and a ballot style for encryption tests. pub fn get_encrypt_decoded_test_fixture( ) -> (Vec, BallotStyle) { let election_str = r#"{ @@ -150,6 +151,7 @@ pub fn get_encrypt_decoded_test_fixture( (decoded_contests, election) } +/// Returns a test fixture of decoded contests and a ballot style for voting portal tests. pub fn default_voting_portal_fixture() -> (Vec, BallotStyle) { let ballot_selection_str = r#"[{"contest_id":"69f2f987-460c-48ac-ac7a-4d44d99b37e6","is_explicit_invalid":false,"invalid_errors":[],"choices":[{"id":"a24303de-5798-47cd-9b3e-4f391d1bae7b","selected":0},{"id":"d9249345-11be-4652-ad04-298d70931610","selected":-1},{"id":"1822089d-ae17-4a03-8935-25164b3f2142","selected":-1}]}]"#; diff --git a/packages/sequent-core/src/fixtures/mod.rs b/packages/sequent-core/src/fixtures/mod.rs index d6eb3a01679..ee912a55d90 100644 --- a/packages/sequent-core/src/fixtures/mod.rs +++ b/packages/sequent-core/src/fixtures/mod.rs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only +#![allow(missing_docs)] pub mod ballot_codec; diff --git a/packages/sequent-core/src/interpret_plaintext.rs b/packages/sequent-core/src/interpret_plaintext.rs index ed683b95c7e..ceb5d6991db 100644 --- a/packages/sequent-core/src/interpret_plaintext.rs +++ b/packages/sequent-core/src/interpret_plaintext.rs @@ -1,13 +1,15 @@ // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only -use crate::ballot::*; -use crate::plaintext::*; +use crate::ballot::Contest; +use crate::plaintext::{DecodedVoteChoice, DecodedVoteContest}; use crate::types::ceremonies::CountingAlgType; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +/// Enum representing the different states of the contest UI. #[derive(Serialize, Deserialize, JsonSchema, PartialEq, Eq, Debug, Clone)] +#[allow(missing_docs)] pub enum ContestState { ElectionChooserScreen, ReceivingElection, @@ -30,13 +32,19 @@ pub enum ContestState { ShowPdf, } +/// Struct representing the layout properties of a contest, including its state and whether it is sorted or ordered. #[derive(Serialize, Deserialize, JsonSchema, PartialEq, Eq, Debug, Clone)] pub struct ContestLayoutProperties { + /// The state of the contest UI. state: ContestState, + /// Whether the contest is sorted. sorted: bool, + /// Whether the contest is ordered. ordered: bool, } +#[must_use] +/// Determines the layout properties of a contest based on its counting algorithm. pub fn get_layout_properties( contest: &Contest, ) -> Option { @@ -66,22 +74,13 @@ pub fn get_layout_properties( sorted: true, ordered: false, }), - CountingAlgType::InstantRunoff => Some(ContestLayoutProperties { - state: ContestState::MultiContest, - sorted: true, - ordered: true, - }), - CountingAlgType::BordaNauru => Some(ContestLayoutProperties { - state: ContestState::MultiContest, - sorted: true, - ordered: true, - }), - CountingAlgType::Borda => Some(ContestLayoutProperties { - state: ContestState::MultiContest, - sorted: true, - ordered: true, - }), - CountingAlgType::BordaMasMadrid => Some(ContestLayoutProperties { + CountingAlgType::InstantRunoff + | CountingAlgType::BordaNauru + | CountingAlgType::Borda + | CountingAlgType::BordaMasMadrid + | CountingAlgType::Desborda3 + | CountingAlgType::Desborda2 + | CountingAlgType::Desborda => Some(ContestLayoutProperties { state: ContestState::MultiContest, sorted: true, ordered: true, @@ -91,21 +90,6 @@ pub fn get_layout_properties( sorted: true, ordered: true, }), - CountingAlgType::Desborda3 => Some(ContestLayoutProperties { - state: ContestState::MultiContest, - sorted: true, - ordered: true, - }), - CountingAlgType::Desborda2 => Some(ContestLayoutProperties { - state: ContestState::MultiContest, - sorted: true, - ordered: true, - }), - CountingAlgType::Desborda => Some(ContestLayoutProperties { - state: ContestState::MultiContest, - sorted: true, - ordered: true, - }), CountingAlgType::Cumulative => Some(ContestLayoutProperties { state: ContestState::SimultaneousContestsScreen, sorted: false, @@ -117,6 +101,7 @@ pub fn get_layout_properties( /** * @returns number of points this ballot is giving to this option */ +#[must_use] pub fn get_points( contest: &Contest, candidate: &DecodedVoteChoice, @@ -130,13 +115,14 @@ pub fn get_points( match contest.get_counting_algorithm() { CountingAlgType::PluralityAtLarge => Some(1), CountingAlgType::Borda => { - Some((contest.max_votes as i64) - candidate.selected) + contest.max_votes.checked_sub(candidate.selected) } // "borda-mas-madrid" => return scope.contest.max - // scope.option.selected - CountingAlgType::BordaNauru => Some(1 + candidate.selected), /* 1 / (1 + candidate. */ + CountingAlgType::BordaNauru | CountingAlgType::Cumulative => { + candidate.selected.checked_add(1) + } /* 1 / (1 + candidate. */ // selected) - CountingAlgType::PairwiseBeta => None, /*"desborda3" => Some(cmp::max( 1, (((contest.num_winners as f64) * 1.3) - (candidate.selected as f64)) @@ -147,13 +133,14 @@ pub fn get_points( (((contest.num_winners as f64) * 1.3) - (candidate.selected as f64)) .trunc() as i64, )),*/ - CountingAlgType::Desborda => Some(80 - candidate.selected), - CountingAlgType::Cumulative => Some(candidate.selected + 1), + CountingAlgType::Desborda => 80i64.checked_sub(candidate.selected), _ => None, } } -pub fn check_is_blank(decoded_contest: DecodedVoteContest) -> bool { +/// Checks if the given decoded contest is blank, meaning it has no explicit invalidity and all choices are unselected. +#[must_use] +pub fn check_is_blank(decoded_contest: &DecodedVoteContest) -> bool { !decoded_contest.is_explicit_invalid && decoded_contest .choices diff --git a/packages/sequent-core/src/lib.docs.md b/packages/sequent-core/src/lib.docs.md new file mode 100644 index 00000000000..d2cb5f2343f --- /dev/null +++ b/packages/sequent-core/src/lib.docs.md @@ -0,0 +1,42 @@ + + +`sequent-core` contains the shared Rust types, ballot-processing primitives, and service helpers used across the `Step` backend crates and frontend packages. + +Most of the crate is feature-gated so consumers can keep their dependency surface small. The full API reference is most useful when documentation is generated with all features enabled. + +## Module Guide + +- `types`: shared domain types that are used across services, reports, and frontend bindings. +- `ballot`, `ballot_style`, `multi_ballot`, `plaintext`, `interpret_plaintext`, `mixed_radix`, and `ballot_codec`: ballot modeling, encoding, decoding, and normalization utilities. +- `encrypt`: ballot encryption helpers built on the shared cryptographic primitives. +- `serialization`: serialization helpers for the crate's public data structures. +- `services`: higher-level business logic used by the surrounding `Step` services. +- `util`: cross-cutting helpers for dates, retries, configuration, MIME types, integrity checks, and related infrastructure concerns. +- `plugins_wit`: `WIT` bindings for plugin integration. +- `signatures`: signature helpers and verification utilities. +- `sqlite`: `SQLite`-backed helpers. +- `temp_path`: report-oriented temporary file helpers. +- `wasm`: the `WebAssembly` API exported to frontend packages. + +## Feature Flags + +- `default_features`: enables the core ballot-processing and service modules used by the main backend and frontend flows. +- `wasm`: enables the `WebAssembly` bindings exposed under `wasm`. +- `reports`: enables report-generation support, including temporary-path helpers. +- `signatures`: enables signature-related helpers. +- `sqlite`: enables `SQLite` support. +- `plugins_wit`: enables plugin `WIT` bindings. + +## Generating Docs + +Build the complete local API reference with all features enabled: + +```bash +cargo doc -p sequent-core --all-features --no-deps +``` + +`docs.rs` is configured to build this crate with all features enabled as well. \ No newline at end of file diff --git a/packages/sequent-core/src/lib.rs b/packages/sequent-core/src/lib.rs index 71fd02b022c..2cd35351302 100644 --- a/packages/sequent-core/src/lib.rs +++ b/packages/sequent-core/src/lib.rs @@ -1,52 +1,71 @@ // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only +#![doc = include_str!("lib.docs.md")] #[macro_use] extern crate quick_error; extern crate cfg_if; +/// Ballot structures and helpers. #[cfg(feature = "default_features")] pub mod ballot; +/// Ballot style models and selection logic. #[cfg(feature = "default_features")] pub mod ballot_style; +/// Shared error types. #[cfg(feature = "default_features")] pub mod error; +/// Multi-ballot container types and helpers. #[cfg(feature = "default_features")] pub mod multi_ballot; +/// Shared domain types. pub mod types; //pub use ballot::*; +/// Ballot encoding and decoding helpers. #[cfg(feature = "default_features")] pub mod ballot_codec; +/// Ballot encryption helpers. #[cfg(feature = "default_features")] pub mod encrypt; +/// Test fixtures and example data. #[cfg(feature = "default_features")] pub mod fixtures; +/// Plaintext ballot interpretation helpers. #[cfg(feature = "default_features")] pub mod interpret_plaintext; +/// Mixed-radix encoding and decoding primitives. #[cfg(feature = "default_features")] pub mod mixed_radix; +/// Plaintext ballot models and validation helpers. #[cfg(feature = "default_features")] pub mod plaintext; +/// `WIT` bindings for plugin integration. #[cfg(feature = "plugins_wit")] pub mod plugins_wit; +/// Shared serialization helpers. #[cfg(feature = "default_features")] pub mod serialization; +/// Domain services and business logic. #[cfg(feature = "default_features")] pub mod services; +/// `SQLite`-backed helpers. #[cfg(feature = "sqlite")] pub mod sqlite; +/// General-purpose utilities. #[cfg(feature = "default_features")] pub mod util; +/// Temporary file and path helpers for reports. #[cfg(all(feature = "reports", feature = "default_features"))] pub mod temp_path; +/// Signature helpers and verification utilities. #[cfg(all(feature = "signatures", feature = "default_features"))] pub mod signatures; -/// Webassembly API. +/// `WebAssembly` bindings exported to frontend packages. #[cfg(all(feature = "wasm", feature = "default_features"))] pub mod wasm; diff --git a/packages/sequent-core/src/main.rs b/packages/sequent-core/src/main.rs index 9dbd4aead58..c06b4ff2543 100644 --- a/packages/sequent-core/src/main.rs +++ b/packages/sequent-core/src/main.rs @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: AGPL-3.0-only +//! Main entry point for the sequent-core crate. + fn main() { //let schema = schema_for!(AuditableBallot); //println!("{}", serde_json::to_string_pretty(&schema).unwrap()); diff --git a/packages/sequent-core/src/mixed_radix.rs b/packages/sequent-core/src/mixed_radix.rs index 254422a1cd2..73812f5d1f3 100644 --- a/packages/sequent-core/src/mixed_radix.rs +++ b/packages/sequent-core/src/mixed_radix.rs @@ -2,9 +2,16 @@ // // SPDX-License-Identifier: AGPL-3.0-only use num_bigint::{BigUint, ToBigUint}; -use num_traits::{One, ToPrimitive, Zero}; +use num_traits::{CheckedAdd, CheckedMul, One, ToPrimitive, Zero}; -pub fn encode(values: &Vec, bases: &Vec) -> Result { +/// Encodes a list of values into a single `BigUint`. +/// +/// # Errors +/// Returns an error if the input slices are not the same length. +/// +/// # Panics +/// Panics if a value or base cannot be converted to `BigUint` (should never happen for u64). +pub fn encode(values: &[u64], bases: &[u64]) -> Result { if bases.len() != values.len() { return Err( format!("Invalid parameters: 'valueList' (size = {}) and 'baseList' (size = {}) must have the same length.", values.len(), bases.len()) @@ -13,15 +20,39 @@ pub fn encode(values: &Vec, bases: &Vec) -> Result { let mut encoded: BigUint = Zero::zero(); let mut acc_base: BigUint = One::one(); - for i in 0..bases.len() { - encoded += &acc_base * values[i].to_biguint().unwrap(); - acc_base = &acc_base * bases[i].to_biguint().unwrap(); + for (&value, &base) in values.iter().zip(bases.iter()) { + let value_bigint = value.to_biguint().ok_or_else(|| { + format!("Failed to convert value {value} to BigUint") + })?; + + let product = acc_base.checked_mul(&value_bigint).ok_or_else(|| { + "Multiplication overflow when encoding value".to_string() + })?; + + encoded = encoded.checked_add(&product).ok_or_else(|| { + "Addition overflow when encoding value".to_string() + })?; + + let base_bigint = base.to_biguint().ok_or_else(|| { + format!("Failed to convert base {base} to BigUint") + })?; + acc_base = acc_base + .checked_mul(&base_bigint) + .expect("Multiplication overflow when encoding base"); } + Ok(encoded) } +/// Decodes a `BigUint` into a list of values. +/// +/// # Errors +/// Returns an error if a value cannot be converted to u64. +/// +/// # Panics +/// Panics if a base cannot be converted to `BigUint` (should never happen for u64). pub fn decode( - bases: &Vec, + bases: &[u64], encoded_value: &BigUint, last_base: u64, ) -> Result, String> { @@ -29,24 +60,31 @@ pub fn decode( let mut accumulator: BigUint = encoded_value.clone(); let mut index = 0usize; + #[allow(clippy::arithmetic_side_effects)] while accumulator > Zero::zero() { - let base: BigUint = (if index < bases.len() { - bases[index] - } else { - last_base - }) - .to_biguint() - .unwrap(); + let base_val = bases.get(index).copied().unwrap_or(last_base); + let base: BigUint = base_val + .to_biguint() + .expect("u64 to BigUint conversion failed"); let remainder = &accumulator % &base; - values.push(remainder.to_u64().unwrap()); + let value = remainder + .to_u64() + .ok_or_else(|| "Failed to convert BigUint to u64".to_string())?; + values.push(value); + accumulator = (&accumulator - &remainder) / &base; - index += 1; + + index = index + .checked_add(1) + .ok_or_else(|| "Index overflow".to_string())?; } // If we didn't run all the bases, fill the rest with zeros while index < bases.len() { values.push(0); - index += 1; + index = index + .checked_add(1) + .ok_or_else(|| "Index overflow".to_string())?; } // finish last write-in with a 0 diff --git a/packages/sequent-core/src/multi_ballot.rs b/packages/sequent-core/src/multi_ballot.rs index e6fd876fdbb..0f74eb8d5cd 100644 --- a/packages/sequent-core/src/multi_ballot.rs +++ b/packages/sequent-core/src/multi_ballot.rs @@ -22,44 +22,52 @@ use strand::signature::StrandSignature; use strand::signature::StrandSignaturePk; use strand::signature::StrandSignatureSk; +/// Represents a fully auditable multi-contest ballot. #[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +#[allow(missing_docs)] pub struct AuditableMultiBallot { pub version: u32, pub issue_date: String, pub config: BallotStyle, - // String serialization of AuditableMultiBallotContests through - // - // self::serialize_contests can be deserialized with - // self::deserialize_contests + /// String serialization of `AuditableMultiBallotContests` through + /// + /// `self::serialize_contests` can be deserialized with + /// `self::deserialize_contests` pub contests: String, pub ballot_hash: String, pub voter_signing_pk: Option, pub voter_ballot_signature: Option, } +/// Holds contest-level cryptographic data for an auditable multi-ballot. #[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone)] +#[allow(missing_docs)] pub struct AuditableMultiBallotContests { pub contest_ids: Vec, pub choice: ReplicationChoice, pub proof: Schnorr, } +/// A multi-contest ballot in a canonical, hashable form for signing and verification. #[derive( BorshSerialize, Serialize, Deserialize, PartialEq, Eq, Debug, Clone, )] +#[allow(missing_docs)] pub struct HashableMultiBallot { pub version: u32, pub issue_date: String, - // String serialization of HashableMultiBallotContests through - // - // self::serialize_contests can be deserialized with - // self::deserialize_contests + /// String serialization of `HashableMultiBallotContests` through + /// + /// `self::serialize_contests` can be deserialized with + /// `self::deserialize_contests` pub contests: String, pub config: String, pub ballot_style_hash: String, } +/// A hashable multi-ballot with attached voter signature and public key. #[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +#[allow(missing_docs)] pub struct SignedHashableMultiBallot { pub version: u32, pub issue_date: String, @@ -70,14 +78,18 @@ pub struct SignedHashableMultiBallot { pub voter_ballot_signature: Option, } +/// Contest-level cryptographic data for a hashable multi-ballot. #[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone)] +#[allow(missing_docs)] pub struct HashableMultiBallotContests { pub contest_ids: Vec, pub ciphertext: Ciphertext, pub proof: Schnorr, } +/// Raw, deserialized form of a hashable multi-ballot. #[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone)] +#[allow(missing_docs)] pub struct RawHashableMultiBallot { pub version: u32, pub issue_date: String, @@ -85,15 +97,21 @@ pub struct RawHashableMultiBallot { } impl AuditableMultiBallot { + /// Deserialize the contests field from base64. + /// + /// # Errors + /// Returns `BallotError::Serialization` if deserialization fails. pub fn deserialize_contests( &self, ) -> Result, BallotError> { - let ret = Base64Deserialize::deserialize(self.contests.clone()) - .map_err(|err| BallotError::Serialization(err.to_string())); - - ret + Base64Deserialize::deserialize(self.contests.clone()) + .map_err(|err| BallotError::Serialization(err.to_string())) } + /// Serialize the contests field to base64. + /// + /// # Errors + /// Returns `BallotError::Serialization` if serialization fails. pub fn serialize_contests( contests: &AuditableMultiBallotContests, ) -> Result { @@ -102,15 +120,21 @@ impl AuditableMultiBallot { } impl HashableMultiBallot { + /// Deserialize the contests field from base64. + /// + /// # Errors + /// Returns `BallotError::Serialization` if deserialization fails. pub fn deserialize_contests( &self, ) -> Result, BallotError> { - let ret = Base64Deserialize::deserialize(self.contests.clone()) - .map_err(|err| BallotError::Serialization(err.to_string())); - - ret + Base64Deserialize::deserialize(self.contests.clone()) + .map_err(|err| BallotError::Serialization(err.to_string())) } + /// Serialize `HashableMultiBallotContests` to base64. + /// + /// # Errors + /// Returns `BallotError::Serialization` if serialization fails. pub fn serialize_contests( contest: &HashableMultiBallotContests, ) -> Result { @@ -119,14 +143,21 @@ impl HashableMultiBallot { } impl SignedHashableMultiBallot { + /// Deserialize `HashableMultiBallot`. + /// + /// # Errors + /// Returns `BallotError::Serialization` if deserialization fails or conversion fails. pub fn deserialize_contests( &self, ) -> Result, BallotError> { let hashable_ballot = HashableMultiBallot::try_from(self)?; - hashable_ballot.deserialize_contests() } + /// Serialize `HashableMultiBallotContests` to base64. + /// + /// # Errors + /// Returns `BallotError::Serialization` if serialization fails. pub fn serialize_contests( contest: &HashableMultiBallotContests, ) -> Result { @@ -137,12 +168,15 @@ impl SignedHashableMultiBallot { impl TryFrom<&AuditableMultiBallot> for HashableMultiBallot { type Error = BallotError; + /// Try to convert an `AuditableMultiBallot` to a `HashableMultiBallot`. + /// + /// # Errors + /// Returns `BallotError::Serialization` if version mismatch, contest deserialization, or hashing fails. fn try_from(value: &AuditableMultiBallot) -> Result { if TYPES_VERSION != value.version { return Err(BallotError::Serialization(format!( "Unexpected version {}, expected {}", - value.version.to_string(), - TYPES_VERSION + value.version, TYPES_VERSION ))); } @@ -153,19 +187,19 @@ impl TryFrom<&AuditableMultiBallot> for HashableMultiBallot { let ballot_style_hash = hash_ballot_style(&value.config).map_err(|error| { BallotError::Serialization(format!( - "Failed to hash ballot style: {}", - error + "Failed to hash ballot style: {error}" )) })?; + let contests_serialized = HashableMultiBallot::serialize_contests::< + RistrettoCtx, + >(&hashable_ballot_contests)?; Ok(HashableMultiBallot { version: TYPES_VERSION, issue_date: value.issue_date.clone(), - contests: HashableMultiBallot::serialize_contests::( - &hashable_ballot_contests, - )?, + contests: contests_serialized, config: value.config.id.clone(), - ballot_style_hash: ballot_style_hash, + ballot_style_hash, }) } } @@ -173,12 +207,15 @@ impl TryFrom<&AuditableMultiBallot> for HashableMultiBallot { impl TryFrom<&AuditableMultiBallot> for SignedHashableMultiBallot { type Error = BallotError; + /// Try to convert an `AuditableMultiBallot` to a `SignedHashableMultiBallot`. + /// + /// # Errors + /// Returns `BallotError::Serialization` if version mismatch, contest deserialization, or hashing fails. fn try_from(value: &AuditableMultiBallot) -> Result { if TYPES_VERSION != value.version { return Err(BallotError::Serialization(format!( "Unexpected version {}, expected {}", - value.version.to_string(), - TYPES_VERSION + value.version, TYPES_VERSION ))); } @@ -189,19 +226,19 @@ impl TryFrom<&AuditableMultiBallot> for SignedHashableMultiBallot { let ballot_style_hash = hash_ballot_style(&value.config).map_err(|error| { BallotError::Serialization(format!( - "Failed to hash ballot style: {}", - error + "Failed to hash ballot style: {error}" )) })?; + let contests_serialized = HashableMultiBallot::serialize_contests::< + RistrettoCtx, + >(&hashable_ballot_contests)?; Ok(SignedHashableMultiBallot { version: TYPES_VERSION, issue_date: value.issue_date.clone(), - contests: HashableMultiBallot::serialize_contests::( - &hashable_ballot_contests, - )?, + contests: contests_serialized, config: value.config.id.clone(), - ballot_style_hash: ballot_style_hash, + ballot_style_hash, voter_signing_pk: value.voter_signing_pk.clone(), voter_ballot_signature: value.voter_ballot_signature.clone(), }) @@ -210,14 +247,18 @@ impl TryFrom<&AuditableMultiBallot> for SignedHashableMultiBallot { impl TryFrom<&SignedHashableMultiBallot> for HashableMultiBallot { type Error = BallotError; + + /// Try to convert a `SignedHashableMultiBallot` to a `HashableMultiBallot`. + /// + /// # Errors + /// Returns `BallotError::Serialization` if version mismatch. fn try_from( value: &SignedHashableMultiBallot, ) -> Result { if TYPES_VERSION != value.version { return Err(BallotError::Serialization(format!( "Unexpected version {}, expected {}", - value.version.to_string(), - TYPES_VERSION + value.version, TYPES_VERSION ))); } @@ -234,12 +275,16 @@ impl TryFrom<&SignedHashableMultiBallot> for HashableMultiBallot { impl TryFrom<&HashableMultiBallot> for RawHashableMultiBallot { type Error = BallotError; + /// Try to convert a `HashableMultiBallot` to a `RawHashableMultiBallot`. + /// + /// # Errors + /// Returns `BallotError::Serialization` if contest deserialization fails. fn try_from(value: &HashableMultiBallot) -> Result { let contests = value.deserialize_contests::()?; Ok(RawHashableMultiBallot { version: value.version, issue_date: value.issue_date.clone(), - contests: contests, + contests, }) } } @@ -258,6 +303,10 @@ impl From<&AuditableMultiBallotContests> } } +/// Sign a hashable multi-ballot with an ephemeral voter signing key. +/// +/// # Errors +/// Returns an error if serialization, key generation, or signing fails. pub fn sign_hashable_multi_ballot_with_ephemeral_voter_signing_key( ballot_id: &str, election_id: &str, @@ -294,38 +343,37 @@ pub fn sign_hashable_multi_ballot_with_ephemeral_voter_signing_key( }) } +/// Verify the signature on a signed hashable multi-ballot. +/// +/// # Errors +/// Returns an error if deserialization, conversion, or signature verification fails. pub fn verify_multi_ballot_signature( ballot_id: &str, election_id: &str, signed_hashable_multi_ballot: &SignedHashableMultiBallot, ) -> Result, String> { - let (signature, public_key) = - if let (Some(voter_ballot_signature), Some(voter_signing_pk)) = ( - signed_hashable_multi_ballot.voter_ballot_signature.clone(), - signed_hashable_multi_ballot.voter_signing_pk.clone(), - ) { - (voter_ballot_signature, voter_signing_pk) - } else { - return Ok(None); - }; + let (Some(signature), Some(public_key)) = ( + signed_hashable_multi_ballot.voter_ballot_signature.clone(), + signed_hashable_multi_ballot.voter_signing_pk.clone(), + ) else { + return Ok(None); + }; let voter_signing_pk = StrandSignaturePk::from_der_b64_string(&public_key) .map_err(|err| { format!( - "Failed to deserialize signature from hashable multi ballot: {}", - err + "Failed to deserialize signature from hashable multi ballot: {err}" ) })?; let hashable_multi_ballot: HashableMultiBallot = signed_hashable_multi_ballot.try_into().map_err(|err| { - format!("Failed to convert to hashable multi ballot: {}", err) + format!("Failed to convert to hashable multi ballot: {err}") })?; let content = hashable_multi_ballot.strand_serialize().map_err(|err| { format!( - "Failed to deserialize signature from hashable multi ballot: {}", - err + "Failed to deserialize signature from hashable multi ballot: {err}" ) })?; @@ -335,8 +383,7 @@ pub fn verify_multi_ballot_signature( let ballot_signature = StrandSignature::from_b64_string(&signature) .map_err(|err| { format!( - "Failed to deserialize signature from hashable multi ballot: {}", - err + "Failed to deserialize signature from hashable multi ballot: {err}" ) })?; diff --git a/packages/sequent-core/src/plaintext.rs b/packages/sequent-core/src/plaintext.rs index a0c7282e13e..19883373421 100644 --- a/packages/sequent-core/src/plaintext.rs +++ b/packages/sequent-core/src/plaintext.rs @@ -1,14 +1,15 @@ // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only +use crate::ballot::{AuditableBallot, Contest, ReplicationChoice}; use crate::ballot_codec::multi_ballot::{ BallotChoices, DecodedBallotChoices, DecodedContestChoice, DecodedContestChoices, }; use crate::ballot_codec::PlaintextCodec; +use crate::multi_ballot::AuditableMultiBallot; use crate::multi_ballot::AuditableMultiBallotContests; use crate::types::ceremonies::CountingAlgType; -use crate::{ballot::*, multi_ballot::AuditableMultiBallot}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -16,6 +17,8 @@ use std::collections::HashSet; use strand::context::Ctx; #[derive(Serialize, Deserialize, JsonSchema, PartialEq, Eq, Debug, Clone)] +/// Represents an invalid plaintext error types. +#[allow(missing_docs)] pub enum InvalidPlaintextErrorType { Explicit, Implicit, @@ -23,12 +26,17 @@ pub enum InvalidPlaintextErrorType { } #[derive(Serialize, Deserialize, JsonSchema, PartialEq, Eq, Debug, Clone)] +/// Represents an error in the preferential order. pub enum PreferencialOrderErrorType { + /// Indicates that there are gaps in the preference order, e.g. 1,2,4 or 1,3,4. PreferenceOrderWithGaps, + /// Indicates that there are duplicated positions in the preference order, e.g. 1,2,2 or 1,1,3. DuplicatedPosition, } #[derive(Serialize, Deserialize, JsonSchema, PartialEq, Eq, Debug, Clone)] +/// Represents an invalid plaintext error details. +#[allow(missing_docs)] pub struct InvalidPlaintextError { pub error_type: InvalidPlaintextErrorType, pub candidate_id: Option, @@ -37,6 +45,8 @@ pub struct InvalidPlaintextError { } #[derive(Serialize, Deserialize, JsonSchema, PartialEq, Eq, Debug, Clone)] +/// Represents a decoded vote contest. +#[allow(missing_docs)] pub struct DecodedVoteContest { pub contest_id: String, pub is_explicit_invalid: bool, @@ -46,9 +56,15 @@ pub struct DecodedVoteContest { } impl DecodedVoteContest { - pub fn is_invalid(&self) -> bool { + /// Check if the contest is invalid, which is true if it is explicitly + /// marked as invalid or if it has any invalid errors. + #[must_use] + pub const fn is_invalid(&self) -> bool { self.is_explicit_invalid || !self.invalid_errors.is_empty() } + /// Check if the contest is blank, which is true + /// if it is not invalid and all choices are unselected. + #[must_use] pub fn is_blank(&self) -> bool { !self.is_invalid() && self @@ -59,12 +75,20 @@ impl DecodedVoteContest { } /// Check the validity of the preference order. - /// Note: PreferenceOrderWithGaps is returned as an error if there are gaps, + /// + /// Note: `PreferenceOrderWithGaps` is returned as an error if there are gaps, /// but this is generally not considered invalid, so the caller can /// handle it depending on the policy or jurisdiction rules. /// Returns Ok if the order is valid after sorting it and if it is /// contiguous, e.g. 1,2,3,4 or 1,4,2,3. /// Returns Err with a Vec of all errors found (may contain multiple variants). + /// + /// # Errors + /// Returns `Err(Vec)` if the order is invalid. + /// + /// # Panics + /// Panics if there are more than `i64::MAX` selected choices, which + /// would cause an overflow when converting from `usize` to `i64`. pub fn validate_preferencial_order( &self, ) -> Result<(), Vec> { @@ -88,11 +112,12 @@ impl DecodedVoteContest { // Check that there are no gaps in the ordered choices let mut ordered_choices = choices_unique_set .into_iter() - .cloned() + .copied() .collect::>(); - ordered_choices.sort(); - let expected_order: Vec = - (0..ordered_choices.len() as i64).collect(); + ordered_choices.sort_unstable(); + let expected_order: Vec = (0..ordered_choices.len()) + .map(|i| i64::try_from(i).expect("failed to convert usize to i64")) + .collect(); if ordered_choices != expected_order { errors.push(PreferencialOrderErrorType::PreferenceOrderWithGaps); @@ -107,18 +132,29 @@ impl DecodedVoteContest { } #[derive(Serialize, Deserialize, JsonSchema, PartialEq, Eq, Debug, Clone)] +/// Represents a decoded vote choice. pub struct DecodedVoteChoice { + /// The candidate ID for this choice. pub id: String, + /// The selection value for this choice, where -1 indicates not selected + /// and any non-negative value indicates selected and depends on the counting algorithm. pub selected: i64, + /// The write-in text for this choice, if applicable. pub write_in_text: Option, } impl DecodedVoteChoice { - pub fn is_selected(&self) -> bool { + /// Check if the choice is selected. + #[must_use] + pub const fn is_selected(&self) -> bool { self.selected >= 0 } } +/// Maps an auditable ballot to decoded contests. +/// +/// # Errors +/// Returns `Err(String)` if the number of contests does not match or if deserialization fails. pub fn map_to_decoded_contest>( ballot: &AuditableBallot, ) -> Result, String> { @@ -132,7 +168,7 @@ pub fn map_to_decoded_contest>( } let ballot_contests = ballot.deserialize_contests().map_err(|err| { - format!("Error deserializing auditable ballot contest {:?}", err) + format!("Error deserializing auditable ballot contest {err:?}") })?; for contest in &ballot_contests { let found_contest = ballot @@ -154,22 +190,25 @@ pub fn map_to_decoded_contest>( Ok(decoded_contests) } +/// Maps decoded ballot choices to decoded contests. +/// +/// # Errors +/// Returns `Err(String)` if a contest cannot be found in the ballot style. pub fn map_decoded_ballot_choices_to_decoded_contests( - decoded_ballot_choices: DecodedBallotChoices, - contests: &Vec, + decoded_ballot_choices: &DecodedBallotChoices, + contests: &[Contest], ) -> Result, String> { let mut decoded_contests = vec![]; for found_contest in contests { - let contest_id = found_contest.id.clone(); + let contest_id = &found_contest.id; let found_ballot_choices = decoded_ballot_choices .choices .iter() - .find(|ballot_choice| ballot_choice.contest_id == contest_id) + .find(|ballot_choice| &ballot_choice.contest_id == contest_id) .ok_or_else(|| { format!( - "Can't find contest with id {} on ballot style", - contest_id + "Can't find contest with id {contest_id} on ballot style" ) })?; @@ -179,8 +218,7 @@ pub fn map_decoded_ballot_choices_to_decoded_contests( let selected = if found_ballot_choices .choices .iter() - .find(|choice| choice.0 == candidate.id) - .is_some() + .any(|choice| choice.0 == candidate.id) { 0 } else { @@ -197,7 +235,7 @@ pub fn map_decoded_ballot_choices_to_decoded_contests( } let decoded_contest = DecodedVoteContest { - contest_id: contest_id, + contest_id: contest_id.clone(), is_explicit_invalid: decoded_ballot_choices.is_explicit_invalid, invalid_errors: found_ballot_choices.invalid_errors.clone(), invalid_alerts: found_ballot_choices.invalid_alerts.clone(), @@ -209,14 +247,17 @@ pub fn map_decoded_ballot_choices_to_decoded_contests( Ok(decoded_contests) } +/// Maps an auditable multi-ballot to decoded contests. +/// +/// # Errors +/// Returns `Err(String)` if the number of contests does not match or if deserialization fails. pub fn map_to_decoded_multi_contest>( ballot: &AuditableMultiBallot, ) -> Result, String> { let ballot_contests: AuditableMultiBallotContests = ballot.deserialize_contests().map_err(|err| { format!( - "Error deserializing auditable multi ballot contest {:?}", - err + "Error deserializing auditable multi ballot contest {err:?}" ) })?; @@ -232,14 +273,7 @@ pub fn map_to_decoded_multi_contest>( &ballot_contests.choice.plaintext, &ballot.config, ) - .map_err(|err| { - format!("Error decoding multi ballot plaintext {:?}", err) - })?; - - let ballot_contests: AuditableMultiBallotContests = - ballot.deserialize_contests().map_err(|err| { - format!("Error deserializing auditable ballot contest {:?}", err) - })?; + .map_err(|err| format!("Error decoding multi ballot plaintext {err:?}"))?; let mapped_contests: Vec = ballot_contests .contest_ids @@ -254,14 +288,13 @@ pub fn map_to_decoded_multi_contest>( .find(|contest_el| contest_el.id == contest_id) .ok_or_else(|| { format!( - "Can't find contest with id {} on ballot style", - contest_id + "Can't find contest with id {contest_id} on ballot style" ) }) }) .collect::, String>>()?; map_decoded_ballot_choices_to_decoded_contests( - decoded_ballot_choices, + &decoded_ballot_choices, &mapped_contests, ) } diff --git a/packages/sequent-core/src/plugins_wit/lib.rs b/packages/sequent-core/src/plugins_wit/lib.rs index e3d7de8105e..d4d5f6139e3 100644 --- a/packages/sequent-core/src/plugins_wit/lib.rs +++ b/packages/sequent-core/src/plugins_wit/lib.rs @@ -1,7 +1,16 @@ -// SPDX-FileCopyrightText: 2025 Sequent Tech +// SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only + +/// Wasmtime component bindings for the plugin interface. +/// +/// This module provides the generated bindings for the WASM plugin interface, +/// including plugin registration, manifest types, and plugin route definitions. +/// Used by the plugin manager and plugin loader to interact with plugins at runtime. +/// +/// Referenced in: `plugin_manager.rs` (for `Manifest`, `PluginRoute`), `plugin.rs` (for `Manifest`, `PluginInterface`). pub mod plugin_bindings { + #![allow(missing_docs)] wasmtime::component::bindgen!({ path: "src/plugins_wit/plugin/plugin-world.wit", world: "plugins-manager:common/plugin", @@ -15,7 +24,15 @@ pub mod plugin_bindings { }); } +/// Wasmtime component bindings for the transactions manager interface. +/// +/// This module provides the generated bindings for the WASM transactions manager, +/// enabling plugins to perform transactional operations via the host. +/// Used by the plugin database manager and plugin logic to coordinate transactional plugin calls. +/// +/// Referenced in: `plugin_db_manager.rs`, `plugin.rs` (for transaction host and linker). pub mod transactions_manager_bindings { + #![allow(missing_docs)] wasmtime::component::bindgen!({ path: "src/plugins_wit/transaction/transaction-world.wit", world: "transactions-manager", @@ -29,7 +46,15 @@ pub mod transactions_manager_bindings { }); } +/// Wasmtime component bindings for the JWT authorization interface. +/// +/// This module provides the generated bindings for JWT-based authorization, +/// allowing plugins to perform authorization checks and interact with JWT claims. +/// Used by the plugin system to add authorization logic to the plugin linker and host. +/// +/// Referenced in: `plugin.rs` (for `add_auth_to_linker`, `HostAuth`). pub mod authorization_bindings { + #![allow(missing_docs)] wasmtime::component::bindgen!({ path: "src/plugins_wit/jwt/jwt-world.wit", world: "jwt", diff --git a/packages/sequent-core/src/plugins_wit/mod.rs b/packages/sequent-core/src/plugins_wit/mod.rs index b824a4f1f6c..579760be809 100644 --- a/packages/sequent-core/src/plugins_wit/mod.rs +++ b/packages/sequent-core/src/plugins_wit/mod.rs @@ -1,4 +1,6 @@ // SPDX-FileCopyrightText: 2025 Sequent Tech // // SPDX-License-Identifier: AGPL-3.0-only +#![allow(missing_docs)] + pub mod lib; diff --git a/packages/sequent-core/src/serialization/base64.rs b/packages/sequent-core/src/serialization/base64.rs index b8b5351a02b..babb93a7d92 100644 --- a/packages/sequent-core/src/serialization/base64.rs +++ b/packages/sequent-core/src/serialization/base64.rs @@ -7,11 +7,21 @@ use strand::serialization::{StrandDeserialize, StrandSerialize}; use crate::error::BallotError; +/// Trait for serializing a type to a base64 string. pub trait Base64Serialize { + /// Serializes the type to a base64 string. + /// + /// # Errors + /// Returns `BallotError` if serialization fails. fn serialize(&self) -> Result; } +/// Trait for deserializing a type from a base64 string. pub trait Base64Deserialize { + /// Deserializes the type from a base64 string. + /// + /// # Errors + /// Returns `BallotError` if decoding or deserialization fails. fn deserialize(value: String) -> Result where Self: Sized; @@ -35,17 +45,13 @@ impl Base64Deserialize for T { .decode(value) .map_err(|error| { BallotError::Serialization(format!( - "Error decoding base64 string: {}", - error + "Error decoding base64 string: {error}" )) })?; - StrandDeserialize::strand_deserialize(&bytes_vec.as_slice()).map_err( - |error| { - BallotError::Serialization(format!( - "Error deserializing borsh/strand bytes: {}", - error - )) - }, - ) + StrandDeserialize::strand_deserialize(&bytes_vec).map_err(|error| { + BallotError::Serialization(format!( + "Error deserializing borsh/strand bytes: {error}" + )) + }) } } diff --git a/packages/sequent-core/src/serialization/deserialize_with_path.rs b/packages/sequent-core/src/serialization/deserialize_with_path.rs index 5c06b325395..e7bca5aa0a8 100644 --- a/packages/sequent-core/src/serialization/deserialize_with_path.rs +++ b/packages/sequent-core/src/serialization/deserialize_with_path.rs @@ -7,6 +7,10 @@ use serde_json::{self, Value}; use serde_path_to_error; use serde_path_to_error::Error; +/// Deserialize a value of type `T` from a JSON string, tracking the path to any error. +/// +/// # Errors +/// Returns an error if deserialization fails, including the path to the error in the JSON structure. pub fn deserialize_str<'de, T>( contents: &'de str, ) -> Result> @@ -14,17 +18,19 @@ where T: Deserialize<'de>, { let jd = &mut serde_json::Deserializer::from_str(contents); - serde_path_to_error::deserialize(jd) } +/// Deserialize a value of type `T` from a `serde_json::Value`, tracking the path to any error. +/// +/// # Errors +/// Returns an error if deserialization fails, including the path to the error in the JSON structure. pub fn deserialize_value(value: Value) -> Result> where T: DeserializeOwned, // Use DeserializeOwned since we consume the Value { // Create a Deserializer from serde_json::Value let jd = value.into_deserializer(); - // Attempt to deserialize into type T, converting any errors using // serde_path_to_error serde_path_to_error::deserialize(jd) diff --git a/packages/sequent-core/src/serialization/mod.rs b/packages/sequent-core/src/serialization/mod.rs index e5bf29cfafa..2a2fe24505b 100644 --- a/packages/sequent-core/src/serialization/mod.rs +++ b/packages/sequent-core/src/serialization/mod.rs @@ -1,6 +1,6 @@ // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only - +#![allow(missing_docs)] pub mod base64; pub mod deserialize_with_path; diff --git a/packages/sequent-core/src/services/area_tree.rs b/packages/sequent-core/src/services/area_tree.rs index f9f596fbd46..92d1eb2a84d 100644 --- a/packages/sequent-core/src/services/area_tree.rs +++ b/packages/sequent-core/src/services/area_tree.rs @@ -1,13 +1,13 @@ // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only -use crate::types::hasura::core::{Area, AreaContest, Contest}; +use crate::types::hasura::core::{Area, AreaContest}; use anyhow::{anyhow, Result}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::{HashMap, HashSet, VecDeque}; -// A tree node that corresponds to an area +/// A tree node that corresponds to an area #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct TreeNodeArea { pub id: String, // area id @@ -22,6 +22,7 @@ pub struct TreeNodeArea { // contests and the contests inherited from their ancestors. #[derive(PartialEq, Eq, Debug, Clone, Default, Serialize, Deserialize)] pub struct ContestsData { + /// Set of contest IDs associated with the area contest_ids: HashSet, } @@ -48,30 +49,33 @@ impl TreeNode where T: Clone + Default, { - // returns all nodes in the tree + /// Returns all nodes in the tree as a flat vector of `TreeNodeArea`. + #[must_use] pub fn get_all_children(&self) -> Vec { let mut children: Vec = vec![]; if let Some(area) = self.area.clone() { children.push(area); - }; + } let sub_children: Vec = self .children .iter() - .map(|child| child.get_all_children()) - .flatten() + .flat_map(TreeNode::get_all_children) .collect(); children.extend(sub_children); children } - // creates a tree from the list of nodes + /// Creates a tree from the list of nodes. + /// + /// # Errors + /// Returns an error if a parent id is not found or a loop is detected. pub fn from_areas(areas: Vec) -> Result> { let mut nodes: HashMap> = HashMap::new(); let mut parent_map: HashMap> = HashMap::new(); let mut root_ids: Vec = Vec::new(); // Initialize TreeNodes and parent map - for area in areas.into_iter() { + for area in areas { let id = area.id.clone(); let parent_id = area.parent_id.clone(); @@ -92,11 +96,10 @@ where } // Ensure all parent_ids are valid - for (parent_id, _) in &parent_map { + for parent_id in parent_map.keys() { if !nodes.contains_key(parent_id) { return Err(anyhow!( - "Parent id {} not found in the tree structure", - parent_id + "Parent id {parent_id} not found in the tree structure" )); } } @@ -124,7 +127,10 @@ where Ok(root_node) } - // internal function used by from_areas + /// Internal function used by `from_areas` to recursively build the tree. + /// + /// # Errors + /// Returns an error if a loop is detected or a node is not found. fn build_tree<'a>( id: &'a str, nodes: &'a HashMap>, @@ -136,7 +142,10 @@ where } visited.insert(id.to_string()); - let node = nodes.get(id).ok_or(anyhow!("Node not found"))?.clone(); + let node = nodes + .get(id) + .ok_or_else(|| anyhow!("Node not found"))? + .clone(); let mut new_node = TreeNode:: { area: node.area.clone(), children: Vec::new(), @@ -156,14 +165,15 @@ where Ok(new_node) } - // find an area in the tree + /// Finds an area in the tree by its id. + #[must_use] pub fn find_area(&self, area_id: &str) -> Option> { if let Some(area) = self.area.clone() { - if &area.id == area_id { + if area.id == area_id { return Some(self.clone()); } } - for leave in self.children.iter() { + for leave in &self.children { if let Some(area) = leave.find_area(area_id) { return Some(area); } @@ -171,6 +181,8 @@ where None } + /// Finds the path from the root to the area with the given id. + #[must_use] pub fn find_path_to_area( &self, area_id: &str, @@ -184,7 +196,7 @@ where } } - // Depth First Helper function to recursively find the path + /// Depth First Helper function to recursively find the path. fn dfs( node: &TreeNode, area_id: &str, @@ -196,7 +208,7 @@ where } // Check if the current node is the target node - if node.area.as_ref().map_or(false, |area| area.id == area_id) { + if node.area.as_ref().is_some_and(|area| area.id == area_id) { return true; } @@ -218,11 +230,11 @@ where // note that areas spread down the tree pub fn get_contests_data_tree( &self, - area_contests: &Vec, + area_contests: &[AreaContest], ) -> TreeNode { // Map> let mut areas_map: HashMap> = HashMap::new(); - for area_contest in area_contests.iter() { + for area_contest in area_contests { areas_map .entry(area_contest.area_id.clone()) .and_modify(|contest_ids| { @@ -234,10 +246,11 @@ where set }); } - let root_data: ContestsData = Default::default(); + let root_data: ContestsData = ContestsData::default(); self.contests_data_tree(&root_data, &areas_map) } + /// Recursively builds a tree of contest data for each node. fn contests_data_tree( &self, parent_data: &ContestsData, @@ -259,25 +272,27 @@ where .map(|child| { child.contests_data_tree( &data, // Map> - &areas_map, + areas_map, ) }) .collect(); TreeNode:: { area: self.area.clone(), - children: children, - data: data, + children, + data, } } } +/// Methods for contest matching on contest data trees. impl TreeNode { - // For a given TreeNode of type ContestsData, return all - // area-contests. Note that this will include - // indirect/inherited ones. + /// For a given `TreeNode` of type `ContestsData`, return all + /// area-contests. Note that this will include indirect/inherited ones. + #[must_use] + #[allow(clippy::used_underscore_binding)] pub fn get_contest_matches( &self, - contest_ids: &HashSet, + _contest_ids: &HashSet, ) -> HashSet { let mut set = HashSet::new(); if let Some(area) = self.area.clone() { @@ -293,15 +308,18 @@ impl TreeNode { .collect(); set.extend(own_area_contests); } - for child in self.children.iter() { - let child_set = child.get_contest_matches(contest_ids); + for child in &self.children { + let child_set = child.get_contest_matches(_contest_ids); set.extend(child_set); } set } } +/// Iterator for traversing a tree node structure. +#[allow(missing_docs)] pub struct TreeNodeIter<'a, T> { + #[allow(missing_docs, clippy::missing_docs_in_private_items)] queue: VecDeque<&'a TreeNode>, } @@ -321,6 +339,8 @@ impl<'a, T> Iterator for TreeNodeIter<'a, T> { } impl TreeNode { + /// Returns an iterator over the tree nodes. + #[must_use] pub fn iter(&self) -> TreeNodeIter { let mut queue = VecDeque::new(); queue.push_back(self); @@ -328,6 +348,15 @@ impl TreeNode { } } +impl<'a, T> IntoIterator for &'a TreeNode { + type Item = &'a TreeNode; + type IntoIter = TreeNodeIter<'a, T>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + #[cfg(test)] mod tests { use crate::services::area_tree::*; diff --git a/packages/sequent-core/src/services/authorization.rs b/packages/sequent-core/src/services/authorization.rs index 6380f59df28..28d011d2161 100644 --- a/packages/sequent-core/src/services/authorization.rs +++ b/packages/sequent-core/src/services/authorization.rs @@ -12,6 +12,11 @@ use std::env; use tracing::{error, info, instrument}; #[instrument(skip(claims))] +#[allow(clippy::needless_pass_by_value)] +/// Authorizes a user based on JWT claims, tenant, and permissions. +/// +/// # Errors +/// Returns an error if the user is not authorized or required environment variables are missing. pub fn authorize( claims: &JwtClaims, allow_super_admin_auth: bool, // Allow authorizing super admin tenant @@ -31,7 +36,7 @@ pub fn authorize( .map_err(|_| { ( Status::Unauthorized, - format!("SUPER_ADMIN_TENANT_ID must be set"), + "SUPER_ADMIN_TENANT_ID must be set".to_string(), ) })?; info!("super_admin_tenant_id: {super_admin_tenant_id}"); @@ -46,7 +51,7 @@ pub fn authorize( tenant_id_opt: {tenant_id_opt:?}, claims tenant_id: {}", claims.hasura_claims.tenant_id ); - return Err((Status::Unauthorized, format!("Unathorized: not a super admin or invalid tenant_id {tenant_id_opt:?}"))); + return Err((Status::Unauthorized, format!("Unauthorized: not a super admin or invalid tenant_id {tenant_id_opt:?}"))); } let perms_str: Vec = permissions @@ -58,18 +63,22 @@ pub fn authorize( let all_contained = perms_str.iter().all(|item| permissions_set.contains(&item)); - if !all_contained { + if all_contained { + Ok(()) + } else { Err(( Status::Unauthorized, format!("Unathorized: {perms_str:?} not in {permissions_set:?}"), )) - } else { - Ok(()) } } -// returns area_id #[instrument(skip(claims))] +/// Authorizes a voter for an election based on JWT claims and permissions. +// / Returns area_id +/// +/// # Errors +/// Returns an error if the voter is not authorized for the election or required claims are missing. pub fn authorize_voter_election( claims: &JwtClaims, permissions: Vec, @@ -85,7 +94,7 @@ pub fn authorize_voter_election( perms_str.iter().all(|item| permissions_set.contains(&item)); if !all_contained { - return Err((Status::Unauthorized, "".into())); + return Err((Status::Unauthorized, String::new())); } let Some(area_id) = claims.hasura_claims.area_id.clone() else { diff --git a/packages/sequent-core/src/services/connection.rs b/packages/sequent-core/src/services/connection.rs index de25f14272e..24624acbac6 100644 --- a/packages/sequent-core/src/services/connection.rs +++ b/packages/sequent-core/src/services/connection.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only -use crate::services::jwt::*; +use crate::services::jwt::{decode_jwt, JwtClaims}; use crate::services::keycloak::{ get_third_party_client_access_token, KeycloakAdminClient, PubKeycloakAdminToken, @@ -19,10 +19,14 @@ use std::sync::RwLock; use std::time::{Duration, Instant}; use tracing::{error, info, instrument, warn}; +/// Header for tenant ID const TENANT_ID_HEADER: &str = "tenant-id"; +/// Header for event ID const EVENT_ID_HEADER: &str = "event-id"; +/// Header for authorization const AUTHORIZATION_HEADER: &str = "authorization"; pub const PRE_EXPIRATION_SECS: i64 = 5; +/// Authentication headers extracted from a request. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct AuthHeaders { pub key: String, @@ -37,18 +41,15 @@ impl<'r> FromRequest<'r> for AuthHeaders { request: &'r Request<'_>, ) -> Outcome { let headers = request.headers().clone(); - if headers.contains("X-Hasura-Admin-Secret") { + if let Some(value) = headers.get_one("X-Hasura-Admin-Secret") { Outcome::Success(AuthHeaders { key: "X-Hasura-Admin-Secret".to_string(), - value: headers - .get_one("X-Hasura-Admin-Secret") - .unwrap() - .to_string(), + value: value.to_string(), }) - } else if headers.contains("authorization") { + } else if let Some(value) = headers.get_one("authorization") { Outcome::Success(AuthHeaders { key: "authorization".to_string(), - value: headers.get_one("authorization").unwrap().to_string(), + value: value.to_string(), }) } else { warn!("AuthHeaders guard: headers: {headers:?}"); @@ -65,31 +66,29 @@ impl<'r> FromRequest<'r> for JwtClaims { request: &'r Request<'_>, ) -> Outcome { let headers = request.headers().clone(); - match headers.get_one("authorization") { - Some(authorization) => { - match authorization.strip_prefix("Bearer ") { - Some(token) => match decode_jwt(token) { - Ok(jwt) => Outcome::Success(jwt), - Err(err) => { - warn!("JwtClaims guard: decode_jwt error {err:?}"); - Outcome::Error((Status::Unauthorized, ())) - } - }, - None => { - warn!("JwtClaims guard: not a bearer token: {authorization:?}"); + if let Some(authorization) = headers.get_one("authorization") { + if let Some(token) = authorization.strip_prefix("Bearer ") { + match decode_jwt(token) { + Ok(jwt) => Outcome::Success(jwt), + Err(err) => { + warn!("JwtClaims guard: decode_jwt error {err:?}"); Outcome::Error((Status::Unauthorized, ())) } } - } - None => { - warn!("JwtClaims guard: headers: {headers:?}"); + } else { + warn!("JwtClaims guard: not a bearer token: {authorization:?}"); Outcome::Error((Status::Unauthorized, ())) } + } else { + warn!("JwtClaims guard: headers: {headers:?}"); + Outcome::Error((Status::Unauthorized, ())) } } } +/// User location information extracted from headers. #[derive(Debug)] +#[allow(missing_docs)] pub struct UserLocation { pub ip: Option, pub country_code: Option, @@ -111,13 +110,15 @@ impl<'r> FromRequest<'r> for UserLocation { let country_code = request .headers() .get_one("CF-IPCountry") - .map(|s| s.to_string()); + .map(str::to_string); Outcome::Success(UserLocation { ip, country_code }) } } +/// Datafix claims extracted from JWT and headers. #[derive(Debug)] +#[allow(missing_docs)] pub struct DatafixClaims { pub jwt_claims: JwtClaims, pub tenant_id: String, @@ -125,16 +126,25 @@ pub struct DatafixClaims { pub datafix_event_id: String, } +/// Datafix credentials for authorization. #[derive(Debug)] +#[allow(missing_docs)] struct DatafixCredentials { + /// Client ID client_id: String, + /// Client secret client_secret: String, } +/// Datafix headers extracted from the request. #[derive(Debug)] +#[allow(missing_docs)] struct DatafixHeaders { + /// Tenant ID tenant_id: String, + /// Event ID event_id: String, + /// Authorization credentials authorization: DatafixCredentials, } @@ -166,18 +176,14 @@ fn parse_datafix_headers(headers: &HeaderMap) -> Option { tenant_id, event_id, authorization ); - let mut auth_collection = authorization.split(":"); - let client_id = auth_collection.nth(0); // get the first item and consumes it - let client_secret = auth_collection.nth(0); + let mut auth_collection = authorization.split(':'); + let client_id = auth_collection.next(); + let client_secret = auth_collection.next(); info!("{:?}:{:?}", client_id, client_secret); - let (client_id, client_secret) = - if let (Some(client_id), Some(client_secret)) = - (client_id, client_secret) - { - (client_id, client_secret) - } else { - return None; - }; + let (Some(client_id), Some(client_secret)) = (client_id, client_secret) + else { + return None; + }; Some(DatafixHeaders { tenant_id: tenant_id.to_string(), @@ -189,14 +195,19 @@ fn parse_datafix_headers(headers: &HeaderMap) -> Option { }) } -/// TokenResponse, timestamp before sending the request and the credentials to +/// `TokenResponse`, timestamp before sending the request and the credentials to /// make sure the requester is the same. #[derive(Debug, Clone)] struct TokenResponseExtended { + /// Access token response from Keycloak token_resp: PubKeycloakAdminToken, + /// Timestamp when the token was requested stamp: Instant, + /// Client ID used to request the token client_id: String, + /// Client secret used to request the token client_secret: String, + /// Tenant ID used to request the token tenant_id: String, } @@ -213,7 +224,9 @@ struct TokenResponseExtended { pub struct LastDatafixAccessToken(RwLock>); impl LastDatafixAccessToken { - pub fn init() -> Self { + /// Initialize a new `LastDatafixAccessToken`. + #[must_use] + pub const fn init() -> Self { LastDatafixAccessToken(RwLock::new(None)) } } @@ -221,6 +234,7 @@ impl LastDatafixAccessToken { /// Reads the access token if it has been requested successfully before and it /// is not expired. #[instrument(skip(lst_acc_tkn))] +#[allow(clippy::cast_sign_loss)] async fn read_access_token( client_id: &str, client_secret: &str, @@ -236,6 +250,7 @@ async fn read_access_token( }; if let Some(data) = token_resp_ext_opt { + #[allow(clippy::arithmetic_side_effects, clippy::cast_possible_wrap)] let pre_expiration_time: i64 = data.token_resp.expires_in as i64 - PRE_EXPIRATION_SECS; // Renew the token 5 seconds before it expires @@ -244,7 +259,7 @@ async fn read_access_token( && data.tenant_id.eq(tenant_id) && pre_expiration_time.is_positive() && data.stamp.elapsed() - < Duration::from_secs(pre_expiration_time as u64) + < Duration::from_secs({ pre_expiration_time as u64 }) { return Some(data.token_resp); } @@ -295,16 +310,17 @@ impl<'r> FromRequest<'r> for DatafixClaims { request: &'r Request<'_>, ) -> Outcome { let (tenant_id, datafix_event_id, authorization) = - match parse_datafix_headers(request.headers()) { - Some(datafix_headers) => ( + if let Some(datafix_headers) = + parse_datafix_headers(request.headers()) + { + ( datafix_headers.tenant_id, datafix_headers.event_id, datafix_headers.authorization, - ), - None => { - error!("DatafixClaims guard: Missing headers!"); - return Outcome::Error((Status::BadRequest, ())); - } + ) + } else { + error!("DatafixClaims guard: Missing headers!"); + return Outcome::Error((Status::BadRequest, ())); }; // Try to read the access token from the cache, if it´s not there or @@ -316,7 +332,7 @@ impl<'r> FromRequest<'r> for DatafixClaims { &authorization.client_id, &authorization.client_secret, &tenant_id, - &lst_acc_tkn, + lst_acc_tkn, ) .await { @@ -326,7 +342,7 @@ impl<'r> FromRequest<'r> for DatafixClaims { authorization.client_id, authorization.client_secret, tenant_id.clone(), - &lst_acc_tkn, + lst_acc_tkn, ) .await { diff --git a/packages/sequent-core/src/services/date.rs b/packages/sequent-core/src/services/date.rs index f1a565b60b8..705b180e9ea 100644 --- a/packages/sequent-core/src/services/date.rs +++ b/packages/sequent-core/src/services/date.rs @@ -6,31 +6,44 @@ use anyhow::{anyhow, Context, Result}; use chrono::{DateTime, Local, LocalResult, TimeZone, Utc}; use time::OffsetDateTime; -// format: 2023-08-10T22:05:22.214163+00:00 +/// format: 2023-08-10T22:05:22.214163+00:00 pub struct ISO8601; impl ISO8601 { + /// Converts an RFC3339 string to a UTC `DateTime`. + /// + /// # Errors + /// Returns an error if the string cannot be parsed as RFC3339. pub fn to_date_utc(date_string: &str) -> Result> { let date_time_utc = DateTime::parse_from_rfc3339(date_string) - .map_err(|err| anyhow!("{:?}", err))?; + .map_err(|err| anyhow!("{err:?}"))?; Ok(date_time_utc.with_timezone(&Utc)) } - // parse something like 2023-08-10T22:05:22.214163+00:00 + /// Parse something like 2023-08-10T22:05:22.214163+00:00 + /// + /// # Errors + /// Returns an error if the string cannot be parsed as RFC3339. pub fn to_date(date_string: &str) -> Result> { let date_time_utc = DateTime::parse_from_rfc3339(date_string) - .map_err(|err| anyhow!("{:?}", err))?; + .map_err(|err| anyhow!("{err:?}"))?; Ok(date_time_utc.with_timezone(&Local)) } + /// Converts a Local `DateTime` to an RFC3339 string. + #[must_use] pub fn to_string(date: &DateTime) -> String { date.to_rfc3339() } + /// Returns the current local `DateTime`. + #[must_use] pub fn now() -> DateTime { Local::now() } + /// Converts a UTC timestamp in milliseconds to a Local `DateTime`. + #[must_use] pub fn timestamp_ms_utc_to_date(millis: i64) -> DateTime { // Convert Unix timestamp in milliseconds to DateTime let date_time_utc = Utc.timestamp_millis_opt(millis).unwrap(); @@ -39,21 +52,28 @@ impl ISO8601 { date_time_utc.with_timezone(&Local) } + /// Converts a UTC timestamp in milliseconds to a Local `DateTime`. + /// + /// # Errors + /// Returns an error if the timestamp cannot be parsed. pub fn timestamp_ms_utc_to_date_opt( millis: i64, ) -> Result> { - // Convert Unix timestamp in milliseconds to DateTime - let date_time_utc = match Utc.timestamp_millis_opt(millis) { - LocalResult::Single(data) => data, - _ => { - return Err(anyhow!("error parsing timestamp")); - } + let LocalResult::Single(date_time_utc) = + Utc.timestamp_millis_opt(millis) + else { + return Err(anyhow!("error parsing timestamp")); }; // Convert Utc DateTime to Local DateTime Ok(date_time_utc.with_timezone(&Local)) } + /// Converts a UTC timestamp in seconds to a Local `DateTime`. + /// + /// # Errors + /// Returns an error if the timestamp cannot be parsed. + #[allow(clippy::arithmetic_side_effects)] pub fn timestamp_secs_utc_to_date_opt( secs: i64, ) -> Result> { @@ -61,7 +81,9 @@ impl ISO8601 { } } -// get the unix timestamp in milliseconds +/// Get the unix timestamp in milliseconds +#[must_use] +#[allow(clippy::arithmetic_side_effects)] pub fn get_now_utc_unix_ms() -> i64 { OffsetDateTime::now_utc().unix_timestamp() * 1000 } diff --git a/packages/sequent-core/src/services/error_checker.rs b/packages/sequent-core/src/services/error_checker.rs index d59ee389901..bcbe767aafd 100644 --- a/packages/sequent-core/src/services/error_checker.rs +++ b/packages/sequent-core/src/services/error_checker.rs @@ -11,14 +11,17 @@ use crate::{ }, }; +/// Checks the maximum selections per candidate type in a contest. +/// +/// # Panics +/// Panics if the count overflows when incrementing selections per type. +#[must_use] pub fn check_max_selections_per_type( contest: &Contest, decoded_vote: &DecodedVoteContest, ) -> Vec { - let presentation = - contest.presentation.clone().unwrap_or(Default::default()); - let Some(max_selections_per_type) = - presentation.max_selections_per_type.clone() + let presentation = contest.presentation.clone().unwrap_or_default(); + let Some(max_selections_per_type) = presentation.max_selections_per_type else { return vec![]; }; @@ -37,19 +40,20 @@ pub fn check_max_selections_per_type( if selection.selected < 0 { continue; } - let Some(candidate_type) = - candidates_type_map.get(&selection.id).clone() + let Some(candidate_type) = candidates_type_map.get(&selection.id) else { continue; }; - let current_count = - type_count.get(candidate_type).clone().unwrap_or(&0); - type_count.insert(candidate_type.clone(), current_count + 1); + let current_count = type_count.get(candidate_type).unwrap_or(&0); + let count_to_insert = current_count + .checked_add(1) + .expect("Overflow when counting selections per type"); + type_count.insert(candidate_type.clone(), count_to_insert); } let mut invalid_errors = vec![]; - for (key, value) in type_count.iter() { + for (key, value) in &type_count { if *value > max_selections_per_type { invalid_errors.push(InvalidPlaintextError { error_type: InvalidPlaintextErrorType::Implicit, @@ -69,6 +73,7 @@ pub fn check_max_selections_per_type( invalid_errors } +#[must_use] pub fn check_contest( contest: &Contest, decoded_vote: &DecodedVoteContest, diff --git a/packages/sequent-core/src/services/generate_urls.rs b/packages/sequent-core/src/services/generate_urls.rs index 990dee492c4..95480a21e43 100644 --- a/packages/sequent-core/src/services/generate_urls.rs +++ b/packages/sequent-core/src/services/generate_urls.rs @@ -3,16 +3,20 @@ // SPDX-License-Identifier: AGPL-3.0-only #[derive(Debug)] +/// Represents the type of authentication action. +#[allow(missing_docs)] pub enum AuthAction { Login, Enroll, } +#[must_use] +/// Generate an authentication URL for a given tenant, event, and action (login or enroll). pub fn get_auth_url( base_url: &str, tenant_id: &str, event_id: &str, - auth_action: AuthAction, + auth_action: &AuthAction, ) -> String { let action_str = match auth_action { AuthAction::Login => "login", diff --git a/packages/sequent-core/src/services/jwt.rs b/packages/sequent-core/src/services/jwt.rs index 07d783ef012..f80d9b019a0 100644 --- a/packages/sequent-core/src/services/jwt.rs +++ b/packages/sequent-core/src/services/jwt.rs @@ -74,23 +74,33 @@ pub struct JwtClaims { } #[instrument(err, skip_all)] +/// Decodes a JWT token string into `JwtClaims`. +/// +/// # Errors +/// Returns an error if the token is malformed, cannot be base64 decoded, is not valid UTF-8, or cannot be deserialized into `JwtClaims`. +#[allow(clippy::arithmetic_side_effects)] pub fn decode_jwt(token: &str) -> Result { let parts: Vec<&str> = token.split('.').collect(); let part = parts.get(1).ok_or(anyhow::anyhow!("Bad token (no '.')"))?; let bytes = general_purpose::URL_SAFE_NO_PAD .decode(part) - .map_err(|err| anyhow!("Error decoding string: {:?}", err))?; + .map_err(|err| anyhow!("Error decoding string: {err:?}"))?; let json = String::from_utf8(bytes) - .map_err(|err| anyhow!("Error decoding bytes to utf8: {:?}", err))?; - debug!("json: {:?}", json); + .map_err(|err| anyhow!("Error decoding bytes to utf8: {err:?}"))?; + debug!("json: {json:?}"); let claims: JwtClaims = serde_json::from_str(&json).map_err(|err| { - anyhow!("Error decoding string into formatted json: {:?}", err) + anyhow!("Error decoding string into formatted json: {err:?}") })?; Ok(claims) } +/// Decodes the permission labels from the JWT claims. +/// +/// # Errors +/// Returns an empty vector if no permission labels are present. #[instrument(skip_all, ret)] +#[allow(clippy::arithmetic_side_effects)] pub fn decode_permission_labels(claims: &JwtClaims) -> Vec { let Some(label_str) = claims.hasura_claims.permission_labels.clone() else { return vec![]; @@ -109,7 +119,7 @@ pub fn decode_permission_labels(claims: &JwtClaims) -> Vec { // Process each item: trim whitespace and surrounding quotes let keys: Vec = items .map(|item| item.trim().trim_matches('"').to_string()) - .filter(|item| item.len() > 0) + .filter(|item| !item.is_empty()) .collect(); keys } @@ -119,6 +129,7 @@ pub fn decode_permission_labels(claims: &JwtClaims) -> Vec { * authentication is fresh, i.e. performed less than 60 seconds ago. */ #[instrument(skip_all)] +#[allow(clippy::arithmetic_side_effects)] pub fn has_gold_permission(claims: &JwtClaims) -> bool { let auth_time_local: DateTime = if let Some(auth_time_int) = claims.auth_time { diff --git a/packages/sequent-core/src/services/keycloak/admin_client.rs b/packages/sequent-core/src/services/keycloak/admin_client.rs index c940bd6ffbd..c1f171ab23b 100644 --- a/packages/sequent-core/src/services/keycloak/admin_client.rs +++ b/packages/sequent-core/src/services/keycloak/admin_client.rs @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2025 Sequent Tech Inc +// SPDX-FileCopyrightText: 2022 Felix Robles // // SPDX-License-Identifier: AGPL-3.0-only use crate::serialization::deserialize_with_path::deserialize_str; @@ -18,7 +18,9 @@ use std::sync::RwLock; use std::time::{Duration, Instant}; use tracing::{event, info, instrument, warn, Level}; +/// `KeycloakAdminToken` is a struct that represents the token response from Keycloak. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[allow(missing_docs)] pub struct PubKeycloakAdminToken { pub access_token: String, pub expires_in: usize, @@ -58,22 +60,32 @@ impl TryFrom for KeycloakAdminToken { } #[derive(Debug)] -struct KeycloakLoginConfig { - url: String, - client_id: String, - client_secret: String, - realm: String, + +/// Configuration for logging into Keycloak using client credentials. +pub struct KeycloakLoginConfig { + /// The base URL of the Keycloak server + pub url: String, + /// The client ID for authentication. + pub client_id: String, + /// The client secret for authentication. + pub client_secret: String, + /// The realm to authenticate against. + pub realm: String, } impl KeycloakLoginConfig { + /// Create a new `KeycloakLoginConfig` from client credentials and tenant ID. + /// + /// # Panics + /// Panics if the `KEYCLOAK_URL` environment variable is not set. + #[must_use] pub fn new( client_id: String, client_secret: String, - tenant_id: String, + tenant_id: &str, ) -> KeycloakLoginConfig { - let url = env::var("KEYCLOAK_URL") - .expect(&format!("KEYCLOAK_URL must be set")); - let realm = get_tenant_realm(&tenant_id); + let url = env::var("KEYCLOAK_URL").expect("KEYCLOAK_URL must be set"); + let realm = get_tenant_realm(tenant_id); Self { url, client_id, @@ -83,37 +95,55 @@ impl KeycloakLoginConfig { } } +/// Get a `KeycloakLoginConfig` for the default client credentials. +/// +/// # Panics +/// Panics if any required environment variable is missing. fn get_keycloak_login_config() -> KeycloakLoginConfig { - let client_id = env::var("KEYCLOAK_CLIENT_ID") - .expect(&format!("KEYCLOAK_CLIENT_ID must be set")); + let client_id = + env::var("KEYCLOAK_CLIENT_ID").expect("KEYCLOAK_CLIENT_ID must be set"); let client_secret = env::var("KEYCLOAK_CLIENT_SECRET") - .expect(&format!("KEYCLOAK_CLIENT_SECRET must be set")); + .expect("KEYCLOAK_CLIENT_SECRET must be set"); let tenant_id = env::var("SUPER_ADMIN_TENANT_ID") - .expect(&format!("SUPER_ADMIN_TENANT_ID must be set")); - KeycloakLoginConfig::new(client_id, client_secret, tenant_id) + .expect("SUPER_ADMIN_TENANT_ID must be set"); + KeycloakLoginConfig::new(client_id, client_secret, &tenant_id) } +/// Get a `KeycloakLoginConfig` for the admin client credentials. +/// +/// # Panics +/// Panics if any required environment variable is missing. fn get_keycloak_login_admin_config() -> KeycloakLoginConfig { let client_id = env::var("KEYCLOAK_ADMIN_CLIENT_ID") - .expect(&format!("KEYCLOAK_ADMIN_CLIENT_ID must be set")); + .expect("KEYCLOAK_ADMIN_CLIENT_ID must be set"); let client_secret = env::var("KEYCLOAK_ADMIN_CLIENT_SECRET") - .expect(&format!("KEYCLOAK_ADMIN_CLIENT_SECRET must be set")); + .expect("KEYCLOAK_ADMIN_CLIENT_SECRET must be set"); let tenant_id = env::var("SUPER_ADMIN_TENANT_ID") - .expect(&format!("SUPER_ADMIN_TENANT_ID must be set")); - KeycloakLoginConfig::new(client_id, client_secret, tenant_id) + .expect("SUPER_ADMIN_TENANT_ID must be set"); + KeycloakLoginConfig::new(client_id, client_secret, &tenant_id) } +/// Acquire credentials from Keycloak using the provided login config. +/// +/// # Panics +/// Panics if serialization of the request body fails. +/// +/// # Errors +/// Returns an error if the HTTP request or response parsing fails. #[instrument(err)] pub async fn get_credentials_inner( login_config: KeycloakLoginConfig, ) -> Result { let body_string = serde_urlencoded::to_string::<[(String, String); 4]>([ - ("client_id".into(), login_config.client_id.clone()), - ("scope".into(), "openid".into()), - ("client_secret".into(), login_config.client_secret.clone()), - ("grant_type".into(), "client_credentials".into()), + ("client_id".to_string(), login_config.client_id.clone()), + ("scope".to_string(), "openid".to_string()), + ( + "client_secret".to_string(), + login_config.client_secret.clone(), + ), + ("grant_type".to_string(), "client_credentials".to_string()), ]) - .unwrap(); + .expect("Failed to serialize Keycloak credentials request body"); let keycloak_endpoint = format!( "{}/realms/{}/protocol/openid-connect/token", @@ -148,8 +178,11 @@ pub async fn get_credentials_inner( res.text().await.map_err(|e| anyhow!(e)) } -// Client Credentials OpenID Authentication flow. -// This enables servers to authenticate, without using a browser. +/// Client Credentials `OpenID` Authentication flow. +/// This enables servers to authenticate, without using a browser. +/// +/// # Errors +/// Returns an error if credentials cannot be acquired or parsed. #[instrument(err)] pub async fn get_client_credentials() -> Result { let login_config = get_keycloak_login_config(); @@ -170,7 +203,10 @@ pub async fn get_client_credentials() -> Result { ), }) } - +/// Acquire admin credentials from Keycloak using the provided login config. +/// +/// # Errors +/// Returns an error if credentials cannot be acquired or parsed. #[instrument(err)] pub async fn get_auth_credentials() -> Result { let login_config = get_keycloak_login_config(); @@ -185,8 +221,10 @@ pub async fn get_auth_credentials() -> Result { Ok(credentials) } -/// Authenticate a party client in keycloak with specific client credentials and -/// tenant_id +/// Authenticate a party client in keycloak with specific client credentials and `tenant_id`. +/// +/// # Errors +/// Returns an error if credentials cannot be acquired or parsed. #[instrument(err)] pub async fn get_third_party_client_access_token( client_id: String, @@ -194,7 +232,7 @@ pub async fn get_third_party_client_access_token( tenant_id: String, ) -> Result { let login_config = - KeycloakLoginConfig::new(client_id, client_secret, tenant_id); + KeycloakLoginConfig::new(client_id, client_secret, &tenant_id); let text = get_credentials_inner(login_config).await?; let keycloak_adm_tkn: KeycloakAdminToken = @@ -208,28 +246,36 @@ pub async fn get_third_party_client_access_token( Ok(keycloak_adm_tkn) } +/// `KeycloakAdminClient` is a wrapper around the `KeycloakAdmin` client. +#[allow(missing_docs)] pub struct KeycloakAdminClient { pub client: KeycloakAdmin, } +/// Struct to hold token and client. +#[allow(missing_docs)] pub struct PubKeycloakAdmin { pub url: String, pub client: reqwest::Client, pub token_supplier: KeycloakAdminToken, } -/// TokenResponse, timestamp before sending the request and url to avoid having +/// `TokenResponse`, timestamp before sending the request and url to avoid having /// to retrieve it again from the ENV. #[derive(Debug, Clone)] struct TokenResponseAdminCli { + /// The token response from Keycloak. token_resp: PubKeycloakAdminToken, + /// The timestamp when the token was acquired. timestamp: Instant, + /// The Keycloak server URL. url: String, } /// Last access token can be reused if it´s not expired, this is to avoid /// requesting a new token to Keycloak everytime. type LastAdminCliToken = RwLock>; +/// Static variable to hold the last access token response for the admin client. static LAST_ADMIN_CLI_TOKEN: LastAdminCliToken = RwLock::new(None); /// Reads the access token if it has been requested successfully before and @@ -245,11 +291,13 @@ async fn read_access_token() -> Option<(PubKeycloakAdminToken, String)> { }; if let Some(data) = token_resp_ext_opt { - let pre_expiration_time: i64 = - data.token_resp.expires_in as i64 - PRE_EXPIRATION_SECS; // Renew the token 5 seconds before it expires + let expires_in = + i64::try_from(data.token_resp.expires_in).unwrap_or(i64::MAX); + let pre_expiration_time = + expires_in.saturating_sub(PRE_EXPIRATION_SECS); if pre_expiration_time.is_positive() && data.timestamp.elapsed() - < Duration::from_secs(pre_expiration_time as u64) + < Duration::from_secs(pre_expiration_time.unsigned_abs()) { return Some((data.token_resp, data.url)); } @@ -280,50 +328,49 @@ async fn write_access_token( impl KeycloakAdminClient { /// Tries to read the token from the cache, if expired requests it to /// Keycloak. + /// + /// # Errors + /// Returns an error if credentials cannot be acquired or parsed. #[instrument(err)] pub async fn new() -> Result { - match read_access_token().await { - Some((token_resp, url)) => { - Self::new_with(token_resp.try_into()?, &url).await - } - None => { - let login_config = get_keycloak_login_admin_config(); - let timestamp: Instant = Instant::now(); // Capture the stamp before sending the request - let client = reqwest::Client::new(); - let admin_token = KeycloakAdminToken::acquire( - &login_config.url, - &login_config.client_id, - &login_config.client_secret, - &client, - ) - .await - .map_err(|err| { - anyhow!("KeycloakAdminToken::acquire error {err:?}") - })?; - info!("Successfully acquired credentials"); - let token_resp: PubKeycloakAdminToken = - admin_token.clone().try_into()?; - write_access_token( - token_resp, - login_config.url.clone(), - timestamp, - ) + if let Some((token_resp, url)) = read_access_token().await { + Self::new_with(token_resp.try_into()?, &url).await + } else { + let login_config = get_keycloak_login_admin_config(); + let timestamp: Instant = Instant::now(); // Capture the stamp before sending the request + let client = reqwest::Client::new(); + let admin_token = KeycloakAdminToken::acquire( + &login_config.url, + &login_config.client_id, + &login_config.client_secret, + &client, + ) + .await + .map_err(|err| { + anyhow!("KeycloakAdminToken::acquire error {err:?}") + })?; + info!("Successfully acquired credentials"); + let token_resp: PubKeycloakAdminToken = + admin_token.clone().try_into()?; + write_access_token(token_resp, login_config.url.clone(), timestamp) .await .map_err(|err| { anyhow!( "KeycloakAdminClient: write_access_token error {err:?}" ) })?; - let keycloak_admin = - KeycloakAdmin::new(&login_config.url, admin_token, client); - Ok(KeycloakAdminClient { - client: keycloak_admin, - }) - } + let keycloak_admin = + KeycloakAdmin::new(&login_config.url, admin_token, client); + Ok(KeycloakAdminClient { + client: keycloak_admin, + }) } } - /// Creates a KeycloakAdminClient via fresh token requesting to Keycloak + /// Creates a `KeycloakAdminClient` via fresh token requesting to Keycloak. + /// + /// # Errors + /// Returns an error if credentials cannot be acquired or parsed. #[instrument(err)] pub async fn new_requested() -> Result { let login_config = get_keycloak_login_admin_config(); @@ -341,6 +388,7 @@ impl KeycloakAdminClient { Ok(KeycloakAdminClient { client }) } + /// Creates a `KeycloakAdminClient` with the provided token and url. #[instrument(err, skip_all)] async fn new_with( admin_token: KeycloakAdminToken, @@ -351,6 +399,10 @@ impl KeycloakAdminClient { Ok(KeycloakAdminClient { client }) } + /// Creates a `PubKeycloakAdmin` with the admin token and client. + /// + /// # Errors + /// Returns an error if acquiring the Keycloak admin token fails. #[instrument(err)] pub async fn pub_new() -> Result { let login_config = get_keycloak_login_admin_config(); @@ -366,7 +418,7 @@ impl KeycloakAdminClient { event!(Level::INFO, "Successfully acquired credentials"); Ok(PubKeycloakAdmin { url: login_config.url, - client: client, + client, token_supplier: admin_token, }) } diff --git a/packages/sequent-core/src/services/keycloak/mod.rs b/packages/sequent-core/src/services/keycloak/mod.rs index 2169c2fe3d1..5779a3d5fee 100644 --- a/packages/sequent-core/src/services/keycloak/mod.rs +++ b/packages/sequent-core/src/services/keycloak/mod.rs @@ -1,11 +1,16 @@ // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only - +//! Keycloak integration modules: admin client, permission, realm, role, and user management. +/// Admin client integration for Keycloak. mod admin_client; +/// Permission management for Keycloak. mod permission; +/// Realm management for Keycloak. mod realm; +/// Role management for Keycloak. mod role; +/// User management for Keycloak. mod user; pub use self::admin_client::*; diff --git a/packages/sequent-core/src/services/keycloak/permission.rs b/packages/sequent-core/src/services/keycloak/permission.rs index 0790775c1db..c1e140ff6a9 100644 --- a/packages/sequent-core/src/services/keycloak/permission.rs +++ b/packages/sequent-core/src/services/keycloak/permission.rs @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0-only use crate::services::keycloak::KeycloakAdminClient; -use crate::types::keycloak::*; +use crate::types::keycloak::Permission; use anyhow::{anyhow, Result}; use keycloak::types::RoleRepresentation; use rocket::futures::future::join_all; @@ -38,6 +38,10 @@ impl From for RoleRepresentation { } impl KeycloakAdminClient { + /// List permissions for a realm, optionally filtered and paginated. + /// + /// # Errors + /// Returns an error if the Keycloak API call fails or if slicing fails due to invalid indices. #[instrument(skip(self), err)] pub async fn list_permissions( self, @@ -50,21 +54,29 @@ impl KeycloakAdminClient { .client .realm_roles_get(realm.clone(), None, None, None, search.clone()) .await - .map_err(|err| anyhow!("{:?}", err))?; + .map_err(|err| anyhow!("{err:?}"))?; let count = role_representations.len(); let start = offset.unwrap_or(0); + #[allow(clippy::arithmetic_side_effects)] let end = match limit { - Some(num) => usize::min(count, start + num), + Some(num) => usize::min(count, start.saturating_add(num)), None => count, }; - let slized_role_representations = &role_representations[start..end]; - let permissions = slized_role_representations - .into_iter() - .map(|role| role.clone().into()) - .collect(); + let permissions = + if let Some(slice) = role_representations.get(start..end) { + slice.iter().map(|role| role.clone().into()).collect() + } else { + return Err(anyhow!( + "Invalid slice indices for role representations" + )); + }; Ok((permissions, count)) } + /// Set a single role permission for a role in a realm. + /// + /// # Errors + /// Returns an error if the Keycloak API call fails. #[instrument(skip(self), err)] pub async fn set_role_permission( self, @@ -76,7 +88,7 @@ impl KeycloakAdminClient { .client .realm_roles_with_role_name_get(realm, permission_name) .await - .map_err(|err| anyhow!("{:?}", err))?; + .map_err(|err| anyhow!("{err:?}"))?; self.client .realm_groups_with_group_id_role_mappings_realm_post( realm, @@ -84,10 +96,14 @@ impl KeycloakAdminClient { vec![role_representation], ) .await - .map_err(|err| anyhow!("{:?}", err))?; + .map_err(|err| anyhow!("{err:?}"))?; Ok(()) } + /// Set multiple role permissions for a role in a realm. + /// + /// # Errors + /// Returns an error if the Keycloak API call fails. #[instrument(skip(self), err)] pub async fn set_role_permissions( self, @@ -96,7 +112,7 @@ impl KeycloakAdminClient { permissions_name: &Vec, ) -> Result<()> { let permission_roles: Vec<_> = permissions_name - .into_iter() + .iter() .map(|permission_name| { self.client .realm_roles_with_role_name_get(realm, permission_name) @@ -112,7 +128,7 @@ impl KeycloakAdminClient { .filter_map(|result| match result { Ok(value) => Some(value), Err(e) => { - eprintln!("Error processing item: {:?}", e); + tracing::error!("Error processing item: {e:?}"); None } }) @@ -124,10 +140,14 @@ impl KeycloakAdminClient { successful_results, ) .await - .map_err(|err| anyhow!("{:?}", err))?; + .map_err(|err| anyhow!("{err:?}"))?; Ok(()) } + /// Delete a single role permission from a role in a realm. + /// + /// # Errors + /// Returns an error if the Keycloak API call fails. #[instrument(skip(self), err)] pub async fn delete_role_permission( self, @@ -139,7 +159,7 @@ impl KeycloakAdminClient { .client .realm_roles_with_role_name_get(realm, permission_name) .await - .map_err(|err| anyhow!("{:?}", err))?; + .map_err(|err| anyhow!("{err:?}"))?; self.client .realm_groups_with_group_id_role_mappings_realm_delete( realm, @@ -147,10 +167,14 @@ impl KeycloakAdminClient { vec![role_representation], ) .await - .map_err(|err| anyhow!("{:?}", err))?; + .map_err(|err| anyhow!("{err:?}"))?; Ok(()) } + /// Delete a permission from a realm. + /// + /// # Errors + /// Returns an error if the Keycloak API call fails. #[instrument(skip(self), err)] pub async fn delete_permission( self, @@ -160,10 +184,14 @@ impl KeycloakAdminClient { self.client .realm_roles_with_role_name_delete(realm, permission_name) .await - .map_err(|err| anyhow!("{:?}", err))?; + .map_err(|err| anyhow!("{err:?}"))?; Ok(()) } + /// Creates a permission in the given realm. + /// + /// # Errors + /// Returns an error if the Keycloak API call fails or the permission cannot be created. pub async fn create_permission( self, realm: &str, @@ -172,7 +200,7 @@ impl KeycloakAdminClient { self.client .realm_roles_post(realm, permission.clone().into()) .await - .map_err(|err| anyhow!("{:?}", err))?; + .map_err(|err| anyhow!("{err:?}"))?; Ok(permission.clone()) } diff --git a/packages/sequent-core/src/services/keycloak/realm.rs b/packages/sequent-core/src/services/keycloak/realm.rs index 8007b6ddd46..9a093951c37 100644 --- a/packages/sequent-core/src/services/keycloak/realm.rs +++ b/packages/sequent-core/src/services/keycloak/realm.rs @@ -6,18 +6,15 @@ use crate::services::uuid_validation::parse_uuid_v4; use crate::services::{ keycloak::KeycloakAdminClient, replace_uuids::replace_uuids, }; -use crate::types::keycloak::{Role, TENANT_ID_ATTR_NAME}; +use crate::types::keycloak::TENANT_ID_ATTR_NAME; use anyhow::{anyhow, Context, Result}; use keycloak::types::{ AuthenticationExecutionInfoRepresentation, GroupRepresentation, RealmRepresentation, RoleRepresentation, }; -use keycloak::{ - KeycloakAdmin, KeycloakAdminToken, KeycloakError, KeycloakTokenSupplier, -}; -use reqwest::Client; -use serde_json::{json, Value}; -use std::collections::{HashMap, HashSet}; +use keycloak::{KeycloakError, KeycloakTokenSupplier}; +use serde_json::json; +use std::collections::HashMap; use std::env; use std::hash::RandomState; use tracing::{error, info, instrument}; @@ -31,14 +28,30 @@ pub enum RoleAction { } impl RoleAction { - fn is_delete(&self) -> bool { + /// Returns true if this action is a delete (remove) action. + #[must_use] + pub const fn is_delete(self) -> bool { matches!(self, RoleAction::Remove) } } +/// Returns the event realm string for a tenant and election event. +/// +/// # Must Use +/// The returned string should be used as a Keycloak realm identifier. +#[must_use] pub fn get_event_realm(tenant_id: &str, election_event_id: &str) -> String { - format!("tenant-{}-event-{}", tenant_id, election_event_id) + format!("tenant-{tenant_id}-event-{election_event_id}") } + +/// Parses a Keycloak realm string into tenant and optional event ID. +/// +/// # Must Use +/// Returns `Some((tenant_id, Some(event_id)))` for event realms, or `Some((tenant_id, None))` for tenant realms. +/// +/// # Panics +/// Panics if the expected tenant or event ID is not present in the realm string. +#[must_use] pub fn parse_realm(realm: &str) -> Option<(String, Option)> { let parts: Vec<&str> = realm.split('-').collect(); @@ -46,43 +59,55 @@ pub fn parse_realm(realm: &str) -> Option<(String, Option)> { // - Tenant realm: "tenant-{tenant_id}" // - Event realm: "tenant-{tenant_id}-event-{election_event_id}" - if parts.len() >= 2 && parts[0] == "tenant" { + if parts.len() >= 2 && parts.first() == Some(&"tenant") { // Check if this is an event realm if let Some(event_idx) = parts.iter().position(|&p| p == "event") { - if event_idx > 1 && event_idx < parts.len() - 1 { - let tenant_id = parts[1..event_idx].join("-"); - let election_event_id = parts[event_idx + 1..].join("-"); + #[allow(clippy::arithmetic_side_effects)] + if event_idx > 1 && event_idx < parts.len().saturating_sub(1) { + let tenant_id = parts + .get(1..event_idx) + .map(|s| s.join("-")) + .expect("Tenant ID should be present"); + let election_event_id = parts + .get(event_idx + 1..) + .map(|s| s.join("-")) + .expect("Election Event ID should be present"); return Some((tenant_id, Some(election_event_id))); } } else { // This is a tenant realm (no "event" found) - let tenant_id = parts[1..].join("-"); + let tenant_id = parts + .get(1..) + .map(|s| s.join("-")) + .expect("Tenant ID should be present"); return Some((tenant_id, None)); } } - None } +#[must_use] pub fn get_tenant_realm(tenant_id: &str) -> String { - format!("tenant-{}", tenant_id) + format!("tenant-{tenant_id}") } -/// Extracts tenant_id and election_event_id replacements from a realm config. +/// Extracts `tenant_id` and `election_event_id` replacements from a realm config. /// -/// This function parses the realm name to extract the old tenant_id and -/// election_event_id, and compares them with the new values to determine if +/// This function parses the realm name to extract the old `tenant_id` and +/// `election_event_id`, and compares them with the new values to determine if /// replacements are needed. /// /// # Arguments /// * `realm_config` - Realm config -/// * `new_tenant_id` - The new tenant_id to use -/// * `new_election_event_id` - Optional new election_event_id to use +/// * `new_tenant_id` - The new `tenant_id` to use +/// * `new_election_event_id` - Optional new `election_event_id` to use /// /// # Returns /// A tuple of: -/// * Optional (old_tenant_id, new_tenant_id) for replacement -/// * Optional (old_event_id, new_event_id) for replacement +/// * Optional (`old_tenant_id`, `new_tenant_id`) for replacement +/// * Optional (`old_event_id`, `new_event_id`) for replacement +#[must_use] +#[allow(clippy::type_complexity)] pub fn extract_realm_replacements( realm_config: &RealmRepresentation, new_tenant_id: &str, @@ -129,15 +154,18 @@ pub fn extract_realm_replacements( /// * `json_realm_config` - The original JSON string representation of the realm /// configuration /// * `keep` - A list of UUID strings that should NOT be replaced with new ones -/// * `tenant_id_replacement` - Optional tuple of (old_tenant_id, new_tenant_id) +/// * `tenant_id_replacement` - Optional tuple of (`old_tenant_id`, `new_tenant_id`) /// for explicit replacement -/// * `election_event_id_replacement` - Optional tuple of (old_event_id, -/// new_event_id) for explicit replacement +/// * `election_event_id_replacement` - Optional tuple of (`old_event_id`, +/// `new_event_id`) for explicit replacement /// /// # Returns /// A tuple containing: /// * The modified JSON string with replaced UUIDs -/// * A HashMap mapping old UUIDs to their new replacements +/// * A `HashMap` mapping old UUIDs to their new replacements +/// +/// # Errors +/// Returns an error if the realm config cannot be deserialized or if replacements fail. #[instrument(err, skip(json_realm_config))] pub fn replace_realm_ids( json_realm_config: &str, @@ -177,7 +205,7 @@ pub fn replace_realm_ids( // Replace all UUIDs in the JSON string except those in the 'keep' list // Returns the modified JSON string and a map of old UUID -> new UUID let (mut new_data, replacement_map) = - replace_uuids(json_realm_config, keep); + replace_uuids(json_realm_config, &keep); // Apply explicit tenant_id replacement if provided if let Some((old_tenant_id, new_tenant_id)) = tenant_id_replacement { @@ -194,6 +222,7 @@ pub fn replace_realm_ids( Ok((new_data, replacement_map)) } +/// Checks the response for errors and returns a `KeycloakError` if not successful. async fn error_check( response: reqwest::Response, ) -> Result { @@ -211,6 +240,10 @@ async fn error_check( } impl KeycloakAdminClient { + /// Gets the realm representation for a board. + /// + /// # Errors + /// Returns an error if the request fails or the token cannot be obtained. pub async fn get_realm( self, client: &PubKeycloakAdmin, @@ -220,7 +253,7 @@ impl KeycloakAdminClient { // see https://docs.rs/keycloak/latest/src/keycloak/rest/generated_rest.rs.html#6315-6334 let mut builder = client .client - .post(&format!( + .post(format!( "{}/admin/realms/{board_name}/partial-export", client.url )) @@ -228,7 +261,7 @@ impl KeycloakAdminClient { client.token_supplier.get(&client.url).await.map_err( |error| { error!("error obtaining token: {error:?}"); - return error; + error }, )?, ); @@ -236,24 +269,28 @@ impl KeycloakAdminClient { builder = builder.query(&[("exportGroupsAndRoles", true)]); let response = builder.send().await.map_err(|error| { error!("error sending built query: {error:?}"); - return error; + error })?; Ok( error_check(response) .await .map_err(|error| { error!("error checking response for realm name {board_name:?}: {error:?}"); - return error; + error })? .json() .await .map_err(|error| { error!("error mapping to json: {error:?}"); - return error; + error })? ) } + /// Gets the flow executions for a board and execution name. + /// + /// # Errors + /// Returns an error if the request fails or the token cannot be obtained. pub async fn get_flow_executions( &self, client: &PubKeycloakAdmin, @@ -277,6 +314,10 @@ impl KeycloakAdminClient { Ok(error_check(response).await?.json().await?) } + /// Upserts a flow execution for a board. + /// + /// # Errors + /// Returns an error if the request fails or the token cannot be obtained. pub async fn upsert_flow_execution( &self, client: &PubKeycloakAdmin, @@ -304,7 +345,7 @@ impl KeycloakAdminClient { .send() .await .with_context(|| { - format!("Error sending update request to '{}'", req_url) + format!("Error sending update request to '{req_url}'") })?; error_check(response).await?; @@ -312,6 +353,10 @@ impl KeycloakAdminClient { Ok(()) } + /// Partially imports a realm with cleanup. + /// + /// # Errors + /// Returns an error if the request fails or the token cannot be obtained. pub async fn partial_import_realm_with_cleanup( &self, client: &PubKeycloakAdmin, @@ -321,7 +366,7 @@ impl KeycloakAdminClient { realm_roles: Vec, if_resource_exists: &str, ) -> Result<()> { - let realm = format!("tenant-{}", tenant_id); + let realm = format!("tenant-{tenant_id}"); // Proceed with partial import let req_url = @@ -349,6 +394,10 @@ impl KeycloakAdminClient { Ok(()) } + /// Deletes a realm. + /// + /// # Errors + /// Returns an error if the request fails or the token cannot be obtained. pub async fn realm_delete( &self, client: &PubKeycloakAdmin, @@ -356,7 +405,7 @@ impl KeycloakAdminClient { delete_by: &str, id: &str, ) -> Result<(), KeycloakError> { - let realm = format!("tenant-{}", tenant_id); + let realm = format!("tenant-{tenant_id}"); let req_url = format!( "{}/admin/realms/{}/{}/{}", client.url, realm, delete_by, id @@ -374,13 +423,17 @@ impl KeycloakAdminClient { Ok(()) } + /// Creates a new group in the realm. + /// + /// # Errors + /// Returns an error if the request fails or the token cannot be obtained. pub async fn create_new_group( &self, tenant_id: &str, group_name: &str, keycloak_client: &PubKeycloakAdmin, ) -> Result, KeycloakError> { - let realm = format!("tenant-{}", tenant_id); + let realm = format!("tenant-{tenant_id}"); let url = format!("{}/admin/realms/{}/groups", keycloak_client.url, realm); @@ -410,7 +463,7 @@ impl KeycloakAdminClient { } })?; // The ID is the trailing part of the URL - if let Some(id) = location_str.split('/').last() { + if let Some(id) = location_str.split('/').next_back() { return Ok(Some(id.to_string())); } } @@ -418,15 +471,19 @@ impl KeycloakAdminClient { Ok(None) } + /// Adds roles to a group in the realm. + /// + /// # Errors + /// Returns an error if the request fails or the token cannot be obtained. pub async fn add_roles_to_group( &self, tenant_id: &str, keycloak_client: &PubKeycloakAdmin, group_id: &str, - roles: &Vec, + roles: &[RoleRepresentation], action: RoleAction, ) -> Result<(), KeycloakError> { - let realm = format!("tenant-{}", tenant_id); + let realm = format!("tenant-{tenant_id}"); let url = format!( "{}/admin/realms/{}/groups/{}/role-mappings/realm", keycloak_client.url, realm, group_id @@ -471,13 +528,17 @@ impl KeycloakAdminClient { Ok(()) } + /// Gets the roles assigned to a group in Keycloak. + /// + /// # Errors + /// Returns an error if the request fails or the token cannot be obtained. pub async fn get_group_assigned_roles( &self, tenant_id: &str, group_id: &str, keycloak_client: &PubKeycloakAdmin, ) -> Result, Box> { - let realm = format!("tenant-{}", tenant_id); + let realm = format!("tenant-{tenant_id}"); let url = format!( "{}/admin/realms/{}/groups/{}/role-mappings/realm", keycloak_client.url, realm, group_id @@ -499,19 +560,29 @@ impl KeycloakAdminClient { Ok(roles) } + /// Updates a group in Keycloak. + /// + /// # Errors + /// Returns an error if the request fails or the token cannot be obtained. + /// + /// # Panics + /// Panics if `group.id` is `None`. pub async fn update_group( &self, tenant_id: &str, group: &GroupRepresentation, ) -> Result<()> { let client = &KeycloakAdminClient::pub_new().await?; - let realm = format!("tenant-{}", tenant_id); + let realm = format!("tenant-{tenant_id}"); let req_url = format!( "{}/admin/realms/{}/groups/{}", client.url, realm, - group.id.as_ref().unwrap() + group + .id + .as_ref() + .expect("group.id must be Some for update_group") ); let response = client .client @@ -529,6 +600,10 @@ impl KeycloakAdminClient { } } + /// Updates localization texts from imported data in Keycloak. + /// + /// # Errors + /// Returns an error if the request fails or the token cannot be obtained. pub async fn update_localization_texts_from_import( &self, imported_localization_texts: Option< @@ -537,11 +612,11 @@ impl KeycloakAdminClient { keycloak_client: &PubKeycloakAdmin, tenant_id: &str, ) -> Result<()> { - let realm = format!("tenant-{}", tenant_id); + let realm = format!("tenant-{tenant_id}"); if let Some(localization_texts) = imported_localization_texts { for (locale, locale_texts) in localization_texts { - println!("Processing locale: {}", locale); + info!("Processing locale: {locale}"); let url = format!( "{}/admin/realms/{}/localization/{}", @@ -555,13 +630,17 @@ impl KeycloakAdminClient { .json(&locale_texts) .send() .await - .context(format!("Failed to send request to update localization texts for locale '{}'", locale))?; + .context(format!("Failed to send request to update localization texts for locale '{locale}'"))?; } } Ok(()) } + /// Upserts a realm in Keycloak. + /// + /// # Errors + /// Returns an error if the request fails or the token cannot be obtained. #[instrument(skip(self, json_realm_config), err)] pub async fn upsert_realm( self, @@ -575,7 +654,7 @@ impl KeycloakAdminClient { let real_get_result = self.client.realm_get(board_name).await; let replaced_ids_config = if replace_ids { let realm_config: RealmRepresentation = - deserialize_str(&json_realm_config)?; + deserialize_str(json_realm_config)?; let (tenant_id_replacement, election_event_id_replacement) = extract_realm_replacements( &realm_config, @@ -605,11 +684,9 @@ impl KeycloakAdminClient { let voting_portal_url_env = env::var("VOTING_PORTAL_URL") .with_context(|| "Error fetching VOTING_PORTAL_URL env var")?; - let login_url = if let Some(election_event_id) = election_event_id { - Some(format!("{voting_portal_url_env}/tenant/{tenant_id}/event/{election_event_id}/login")) - } else { - None - }; + let login_url = election_event_id.map(|election_event_id| format!( + "{voting_portal_url_env}/tenant/{tenant_id}/event/{election_event_id}/login" + )); let ballot_verifier_url = env::var("BALLOT_VERIFIER_URL") .with_context(|| "Error fetching BALLOT_VERIFIER_URL env var")?; @@ -625,7 +702,7 @@ impl KeycloakAdminClient { == Some(String::from("onsite-voting-portal")) { client.root_url = Some(voting_portal_url_env.clone()); - client.base_url = login_url.clone(); + client.base_url.clone_from(&login_url); client.redirect_uris = Some(vec![ "/*".to_string(), format!("{}/*", ballot_verifier_url), @@ -642,13 +719,13 @@ impl KeycloakAdminClient { if client.client_id == Some(String::from("account")) && login_url.is_some() { - client.base_url = login_url.clone(); + client.base_url.clone_from(&login_url); } Ok(client) // Return the modified client }) .collect::>>() .map_err(|err| { - anyhow!("Error setting the voting portal urls: {:?}", err) + anyhow!("Error setting the voting portal urls: {err:?}") })?, ); @@ -677,14 +754,14 @@ impl KeycloakAdminClient { match real_get_result { Ok(_) => self .client - .realm_put(&board_name, realm) + .realm_put(board_name, realm) .await - .map_err(|err| anyhow!("Keycloak error: {:?}", err)), + .map_err(|err| anyhow!("Keycloak error: {err:?}")), Err(_) => self .client .post(realm) .await - .map_err(|err| anyhow!("Keycloak error: {:?}", err)), + .map_err(|err| anyhow!("Keycloak error: {err:?}")), } } } diff --git a/packages/sequent-core/src/services/keycloak/role.rs b/packages/sequent-core/src/services/keycloak/role.rs index 7307e2daeb7..b6f3ec7448a 100644 --- a/packages/sequent-core/src/services/keycloak/role.rs +++ b/packages/sequent-core/src/services/keycloak/role.rs @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0-only use crate::services::keycloak::KeycloakAdminClient; -use crate::types::keycloak::*; +use crate::types::keycloak::Role; use anyhow::{anyhow, Result}; use keycloak::types::GroupRepresentation; use std::convert::From; @@ -39,6 +39,10 @@ impl From for GroupRepresentation { } impl KeycloakAdminClient { + /// Lists roles in the given realm. + /// + /// # Errors + /// Returns an error if the request fails or the API call is unsuccessful. #[instrument(skip(self), err)] pub async fn list_roles( self, @@ -60,25 +64,32 @@ impl KeycloakAdminClient { None, ) .await - .map_err(|err| anyhow!("{:?}", err))?; + .map_err(|err| anyhow!("{err:?}"))?; let count = group_representations.len(); let start = offset.unwrap_or(0); let end = match limit { - Some(num) => usize::min(count, start + num), + Some(num) => count.min(start.saturating_add(num)), None => count, }; - let slized_group_representations = &group_representations[start..end]; + let slized_group_representations = + group_representations.get(start..end).ok_or_else(|| { + anyhow!("Invalid slice range for group representations") + })?; let roles = slized_group_representations - .into_iter() + .iter() .map(|role| role.clone().into()) .collect(); Ok((roles, count)) } + /// Lists user roles in the given realm. + /// + /// # Errors + /// Returns an error if the request fails or the API call is unsuccessful. #[instrument(skip(self), err)] pub async fn list_user_roles( - self, + self: &KeycloakAdminClient, realm: &str, user_id: &str, ) -> Result> { @@ -93,11 +104,15 @@ impl KeycloakAdminClient { None, ) .await - .map_err(|err| anyhow!("{:?}", err))?; - let roles = groups.into_iter().map(|group| group.into()).collect(); + .map_err(|err| anyhow!("{err:?}"))?; + let roles = groups.into_iter().map(std::convert::Into::into).collect(); Ok(roles) } + /// Sets a user role in the given realm. + /// + /// # Errors + /// Returns an error if the request fails or the API call is unsuccessful. #[instrument(skip(self), err)] pub async fn set_user_role( self: &KeycloakAdminClient, @@ -110,10 +125,14 @@ impl KeycloakAdminClient { realm, user_id, role_id, ) .await - .map_err(|err| anyhow!("{:?}", err))?; + .map_err(|err| anyhow!("{err:?}"))?; Ok(()) } + /// Deletes a user role in the given realm. + /// + /// # Errors + /// Returns an error if the request fails or the API call is unsuccessful. #[instrument(skip(self), err)] pub async fn delete_user_role( self, @@ -126,28 +145,40 @@ impl KeycloakAdminClient { realm, user_id, role_id, ) .await - .map_err(|err| anyhow!("{:?}", err))?; + .map_err(|err| anyhow!("{err:?}"))?; Ok(()) } + /// Deletes a role in the given realm. + /// + /// # Errors + /// Returns an error if the request fails or the API call is unsuccessful. #[instrument(skip(self), err)] pub async fn delete_role(self, realm: &str, role_id: &str) -> Result<()> { self.client .realm_groups_with_group_id_delete(realm, role_id) .await - .map_err(|err| anyhow!("{:?}", err))?; + .map_err(|err| anyhow!("{err:?}"))?; Ok(()) } + /// Creates a role in the given realm. + /// + /// # Errors + /// Returns an error if the request fails or the API call is unsuccessful. #[instrument(skip(self), err)] pub async fn create_role(self, realm: &str, role: &Role) -> Result { self.client .realm_groups_post(realm, role.clone().into()) .await - .map_err(|err| anyhow!("{:?}", err))?; + .map_err(|err| anyhow!("{err:?}"))?; Ok(role.clone()) } + /// Gets a role by name in the given realm. + /// + /// # Errors + /// Returns an error if the request fails or the API call is unsuccessful. #[instrument(skip(self), err)] pub async fn get_role_by_name( self, diff --git a/packages/sequent-core/src/services/keycloak/user.rs b/packages/sequent-core/src/services/keycloak/user.rs index ef3528c267c..75f2b2df16a 100644 --- a/packages/sequent-core/src/services/keycloak/user.rs +++ b/packages/sequent-core/src/services/keycloak/user.rs @@ -2,7 +2,12 @@ // // SPDX-License-Identifier: AGPL-3.0-only use crate::services::keycloak::KeycloakAdminClient; -use crate::types::keycloak::*; +use crate::types::keycloak::{ + UPAttributePermissions, UPAttributeRequired, UPAttributeSelector, User, + UserProfileAttribute, VotesInfo, AREA_ID_ATTR_NAME, + AUTHORIZED_ELECTION_IDS_NAME, FIRST_NAME, LAST_NAME, + MOBILE_PHONE_ATTR_NAME, PERMISSION_TO_EDIT, TENANT_ID_ATTR_NAME, +}; use crate::util::convert_vec::convert_map; use anyhow::{anyhow, Result}; use keycloak::{ @@ -27,6 +32,10 @@ pub struct GroupInfo { pub group_name: String, } +/// Checks the error response from Keycloak and returns a `KeycloakError` if not successful. +/// +/// # Errors +/// Returns a `KeycloakError` if the response status is not successful. async fn error_check( response: reqwest::Response, ) -> Result { @@ -44,26 +53,32 @@ async fn error_check( } impl User { + /// Get the user's mobile phone number from their attributes, if it exists. + #[must_use] pub fn get_mobile_phone(&self) -> Option { Some( self.attributes .as_ref()? .get(MOBILE_PHONE_ATTR_NAME)? - .get(0)? - .to_string(), + .first()? + .clone(), ) } + /// Get User's attribute value by attribute name, if it exists. + #[must_use] pub fn get_attribute_val(&self, attribute_name: &String) -> Option { Some( self.attributes .as_ref()? .get(attribute_name)? - .get(0)? - .to_string(), + .first()? + .clone(), ) } + /// Get User's attribute which has multiple values by attribute name, if it exists. + #[must_use] pub fn get_attribute_multival( &self, attribute_name: &String, @@ -73,10 +88,12 @@ impl User { .as_ref()? .get(attribute_name)? .join(MULTIVALUE_USER_ATTRIBUTE_SEPARATOR) - .to_string(), + .clone(), ) } + /// Get the user's authorized election ids from their attributes, if they exist. + #[must_use] pub fn get_authorized_election_ids(&self) -> Option> { let result = self .attributes @@ -84,34 +101,36 @@ impl User { .get(AUTHORIZED_ELECTION_IDS_NAME) .cloned(); - info!("get_authorized_election_ids: {:?}", result); + info!("get_authorized_election_ids: {result:?}"); info!("attributes: {:?}", self.attributes); result } + /// Get the user's area id from their attributes, if it exists. + #[must_use] pub fn get_area_id(&self) -> Option { Some( self.attributes .as_ref()? .get(AREA_ID_ATTR_NAME)? - .get(0)? - .to_string(), + .first()? + .clone(), ) } + /// Get the user's votes info. + #[must_use] pub fn get_votes_info_by_election_id( &self, ) -> Option> { - self.votes_info.as_ref().and_then(|votes_info_vec| { - Some( - votes_info_vec - .iter() - .map(|votes_info| { - (votes_info.election_id.clone(), votes_info.clone()) - }) - .collect::>(), - ) + self.votes_info.as_ref().map(|votes_info_vec| { + votes_info_vec + .iter() + .map(|votes_info| { + (votes_info.election_id.clone(), votes_info.clone()) + }) + .collect::>() }) } } @@ -143,8 +162,8 @@ impl From for User { id: item.id.clone(), attributes: item.attributes.clone(), email: item.email.clone(), - email_verified: item.email_verified.clone(), - enabled: item.enabled.clone(), + email_verified: item.email_verified, + enabled: item.enabled, first_name: item.first_name.clone(), last_name: item.last_name.clone(), username: item.username.clone(), @@ -165,8 +184,8 @@ impl From for UserRepresentation { credentials: None, disableable_credential_types: None, email: item.email.clone(), - email_verified: item.email_verified.clone(), - enabled: item.enabled.clone(), + email_verified: item.email_verified, + enabled: item.enabled, federated_identities: None, federation_link: None, first_name: item.first_name.clone(), @@ -189,7 +208,12 @@ impl From for UserRepresentation { } impl KeycloakAdminClient { + /// Lists users in the given realm. + /// + /// # Errors + /// Returns an error if the request fails or the API call is unsuccessful. #[instrument(skip(self), err)] + #[allow(clippy::too_many_arguments)] pub async fn list_users( self, tenant_id: &str, @@ -209,44 +233,53 @@ impl KeycloakAdminClient { None, None, None, - offset.clone(), + offset, None, None, None, None, - limit.clone(), + limit, None, search.clone(), None, ) .await - .map_err(|err| anyhow!("{:?}", err))?; + .map_err(|err| anyhow!("{err:?}"))?; let count: i32 = self .client .realm_users_count_get( realm, email, None, None, None, None, search, None, None, ) .await - .map_err(|err| anyhow!("{:?}", err))?; + .map_err(|err| anyhow!("{err:?}"))?; let users = user_representations .clone() .into_iter() - .map(|user| user.into()) + .map(std::convert::Into::into) .collect(); Ok((users, count)) } + /// Gets a user by id and given realm. + /// + /// # Errors + /// Returns an error if the request fails or the API call is unsuccessful. #[instrument(skip(self), err)] pub async fn get_user(&self, realm: &str, user_id: &str) -> Result { let current_user: UserRepresentation = self .client .realm_users_with_user_id_get(realm, user_id, None) .await - .map_err(|err| anyhow!("{:?}", err))?; + .map_err(|err| anyhow!("{err:?}"))?; Ok(current_user.into()) } + /// Edits a user in the given realm. + /// + /// # Errors + /// Returns an error if the request fails or the API call is unsuccessful. #[instrument(skip(self, password), err)] + #[allow(clippy::too_many_arguments)] pub async fn edit_user( self, realm: &str, @@ -260,24 +293,17 @@ impl KeycloakAdminClient { password: Option, temporary: Option, ) -> Result { - let credentials = match password { - Some(val) => Some( - [ - // the new credential - vec![CredentialRepresentation { - type_: Some("password".to_string()), - temporary: match temporary { - Some(temportay) => Some(temportay), - _ => Some(true), - }, - value: Some(val), - ..Default::default() - }], - ] - .concat(), - ), - None => None, - }; + let credentials = password.map(|val| { + vec![CredentialRepresentation { + type_: Some("password".to_string()), + temporary: match temporary { + Some(temportay) => Some(temportay), + _ => Some(true), + }, + value: Some(val), + ..Default::default() + }] + }); self.edit_user_with_credentials( realm, @@ -295,6 +321,11 @@ impl KeycloakAdminClient { } #[instrument(skip(self, credentials), err)] + #[allow(clippy::too_many_arguments)] + /// Edits a user with credentials in the given realm. + /// + /// # Errors + /// Returns an error if the request fails or the API call is unsuccessful. pub async fn edit_user_with_credentials( self, realm: &str, @@ -308,12 +339,12 @@ impl KeycloakAdminClient { credentials: Option>, temporary: Option, ) -> Result { - info!("Editing user in keycloak ?: {:?}", attributes); + info!("Editing user in keycloak ?: {attributes:?}"); let mut current_user: UserRepresentation = self .client .realm_users_with_user_id_get(realm, user_id, None) .await - .map_err(|err| anyhow!("{:?}", err))?; + .map_err(|err| anyhow!("{err:?}"))?; current_user.enabled = match enabled { Some(val) => Some(val), @@ -323,8 +354,8 @@ impl KeycloakAdminClient { current_user.attributes = match attributes { Some(val) => { let mut new_attributes = - current_user.attributes.unwrap_or(HashMap::new()); - for (key, value) in val.iter() { + current_user.attributes.unwrap_or_default(); + for (key, value) in &val { new_attributes.insert(key.clone(), value.clone()); } Some(new_attributes) @@ -358,7 +389,7 @@ impl KeycloakAdminClient { // the new credential val, // the filtered list, without password - current_user.credentials.unwrap_or(vec![]).clone(), + current_user.credentials.unwrap_or_default().clone(), ] .concat(), ), @@ -368,21 +399,29 @@ impl KeycloakAdminClient { self.client .realm_users_with_user_id_put(realm, user_id, current_user.clone()) .await - .map_err(|err| anyhow!("{:?}", err))?; + .map_err(|err| anyhow!("{err:?}"))?; Ok(current_user.into()) } #[instrument(skip(self), err)] + /// Deletes a user in the given realm. + /// + /// # Errors + /// Returns an error if the request fails or the API call is unsuccessful. pub async fn delete_user(&self, realm: &str, user_id: &str) -> Result<()> { self.client .realm_users_with_user_id_delete(realm, user_id) .await - .map_err(|err| anyhow!("{:?}", err))?; + .map_err(|err| anyhow!("{err:?}"))?; Ok(()) } #[instrument(skip(self), err)] + /// Creates a user in the given realm. + /// + /// # Errors + /// Returns an error if the request fails or the API call is unsuccessful. pub async fn create_user( self: &KeycloakAdminClient, realm: &str, @@ -391,14 +430,14 @@ impl KeycloakAdminClient { groups: Option>, ) -> Result { let mut new_user_keycloak: UserRepresentation = user.clone().into(); - new_user_keycloak.attributes = attributes.clone(); - info!("Creating user in keycloak ?: {:?}", new_user_keycloak); - new_user_keycloak.groups = groups.clone(); + new_user_keycloak.attributes.clone_from(&attributes); + info!("Creating user in keycloak ?: {new_user_keycloak:?}"); + new_user_keycloak.groups.clone_from(&groups); self.client .realm_users_post(realm, new_user_keycloak.clone()) .await .map_err(|err| { - anyhow!("Failed to create user in keycloak: {:?}", err) + anyhow!("Failed to create user in keycloak: {err:?}") })?; let found_users = self .client @@ -421,7 +460,7 @@ impl KeycloakAdminClient { ) .await .map_err(|err| { - anyhow!("Failed to find user in keycloak: {:?}", err) + anyhow!("Failed to find user in keycloak: {err:?}") })?; match found_users.first() { @@ -431,24 +470,30 @@ impl KeycloakAdminClient { } #[instrument(skip(self), err)] + /// Gets user profile attributes in the given realm. + /// + /// # Errors + /// Returns an error if the request fails or the API call is unsuccessful. pub async fn get_user_profile_attributes( self: &KeycloakAdminClient, realm: &str, ) -> Result> { let response: UPConfig = self .client - .realm_users_profile_get(&realm) + .realm_users_profile_get(realm) .await - .map_err(|err| anyhow!("{:?}", err))?; + .map_err(|err| anyhow!("{err:?}"))?; match response.attributes { - Some(attributes) => { - Ok(Self::get_formatted_attributes(&attributes.clone().into())) - } + Some(attributes) => Ok(Self::get_formatted_attributes(&attributes)), None => Ok(vec![]), } } #[instrument(skip(self), err)] + /// Gets user groups for a user in the given realm. + /// + /// # Errors + /// Returns an error if the request fails or the API call is unsuccessful. pub async fn get_user_groups( self: &KeycloakAdminClient, realm: &str, @@ -457,10 +502,10 @@ impl KeycloakAdminClient { let response: Vec = self .client .realm_users_with_user_id_groups_get( - &realm, user_id, None, None, None, None, + realm, user_id, None, None, None, None, ) .await - .map_err(|err| anyhow!("{:?}", err))?; + .map_err(|err| anyhow!("{err:?}"))?; // Map to custom struct let groups: Vec = response .into_iter() @@ -479,6 +524,7 @@ impl KeycloakAdminClient { Ok(groups) } + #[must_use] pub fn get_attribute_name(name: &Option) -> Option { match name.as_deref() { Some(FIRST_NAME) => Some("first_name".to_string()), @@ -488,24 +534,22 @@ impl KeycloakAdminClient { } } + #[must_use] pub fn get_formatted_attributes( - attributes_res: &Vec, + attributes_res: &[UPAttribute], ) -> Vec { - let formatted_attributes: Vec = attributes_res + attributes_res .iter() .filter(|attr| match (&attr.permissions, &attr.name) { (Some(permissions), Some(name)) => { let has_permission = - permissions.edit.as_ref().map_or(true, |edit| { + permissions.edit.as_ref().is_none_or(|edit| { edit.contains(&PERMISSION_TO_EDIT.to_string()) }); - let is_not_tenant_id = !name.contains(&TENANT_ID_ATTR_NAME.to_string()); - let is_not_area_id = !name.contains(&AREA_ID_ATTR_NAME.to_string()); - has_permission && is_not_tenant_id && is_not_area_id } _ => false, @@ -516,29 +560,25 @@ impl KeycloakAdminClient { group: attr.group.clone(), multivalued: attr.multivalued, name: Self::get_attribute_name(&attr.name), - required: match attr.required.clone() { - Some(required) => Some(UPAttributeRequired { + required: attr.required.clone().map(|required| { + UPAttributeRequired { roles: required.roles, scopes: required.scopes, - }), - None => None, - }, + } + }), validations: attr.validations.clone(), - permissions: match attr.permissions.clone() { - Some(permissions) => Some(UPAttributePermissions { + permissions: attr.permissions.clone().map(|permissions| { + UPAttributePermissions { edit: permissions.edit, view: permissions.view, - }), - None => None, - }, - selector: match attr.selector.clone() { - Some(selector) => Some(UPAttributeSelector { + } + }), + selector: attr.selector.clone().map(|selector| { + UPAttributeSelector { scopes: selector.scopes, - }), - None => None, - }, + } + }), }) - .collect(); - formatted_attributes + .collect() } } diff --git a/packages/sequent-core/src/services/mod.rs b/packages/sequent-core/src/services/mod.rs index 295772c9bd7..95303a1d7e1 100644 --- a/packages/sequent-core/src/services/mod.rs +++ b/packages/sequent-core/src/services/mod.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only - +#![allow(missing_docs)] #[cfg(feature = "areas")] pub mod area_tree; #[cfg(feature = "keycloak")] diff --git a/packages/sequent-core/src/services/pdf.rs b/packages/sequent-core/src/services/pdf.rs index 98f1fd37144..704cb69ea6e 100644 --- a/packages/sequent-core/src/services/pdf.rs +++ b/packages/sequent-core/src/services/pdf.rs @@ -81,15 +81,23 @@ pub mod sync { } impl PdfRenderer { + /// Renders a PDF from HTML and options. + /// + /// # Errors + /// Returns an error if PDF rendering fails or the backend is misconfigured. pub fn render_pdf( html: String, pdf_options: Option, ) -> Result> { let _html_sha256 = sha256::digest(&html); // We call our synchronous do_render_pdf - Ok(PdfRenderer::new()?.do_render_pdf(html, pdf_options)?) + PdfRenderer::new()?.do_render_pdf(html, pdf_options) } + /// Creates a new `PdfRenderer` instance. + /// + /// # Errors + /// Returns an error if the backend is misconfigured or environment variables are missing. pub fn new() -> Result { info!("PdfRenderer::new() [sync] - Starting initialization"); @@ -115,7 +123,7 @@ pub mod sync { } "openwhisk" => { let mut openwhisk_endpoint = std::env::var("OPENWHISK_DOC_RENDERER_ENDPOINT"); - if !openwhisk_endpoint.is_ok() { + if openwhisk_endpoint.is_err() { let openwhisk_api_host = std::env::var("OPENWHISK_API_HOST"); if let Ok(host) = openwhisk_api_host { openwhisk_endpoint = Ok(format!("{host}/api/v1/namespaces/_/actions/pdf-tools/doc_renderer?blocking=true&result=true")); @@ -135,28 +143,32 @@ pub mod sync { Ok(PdfRenderer { transport }) } - /// Synchronous send_request using reqwest::blocking and our own retry + /// Synchronous `send_request` using `reqwest::blocking` and our own retry /// loop. + #[allow(clippy::unused_self)] fn send_request( &self, endpoint: &str, - payload: serde_json::Value, - basic_auth: Option, + payload: &serde_json::Value, + basic_auth: Option<&String>, ) -> Result { let client = reqwest::blocking::Client::builder() .pool_idle_timeout(None) .build()?; - let mut retries = 3; + let mut retries: i32 = 3; let mut delay = Duration::from_millis(100); loop { let mut builder = client.post(endpoint.clone()).json(&payload); - if let Some(ref basic_auth) = basic_auth { + if let Some(basic_auth) = basic_auth { let parts: Vec<&str> = basic_auth.split(':').collect(); - if parts.len() != 2 { + if let (Some(user), Some(pass)) = + (parts.first(), parts.get(1)) + { + builder = builder.basic_auth(user, Some(pass)); + } else { return Err(anyhow!("Invalid basic auth provided")); } - builder = builder.basic_auth(parts[0], Some(parts[1])); } match builder.send() { @@ -169,13 +181,21 @@ pub mod sync { "Request failed: {e:?}. Retrying in {delay:?}..." ); thread::sleep(delay); - delay *= 2; - retries -= 1; + delay = delay.saturating_mul(2); + retries = retries.saturating_sub(1); } } } } + /// Renders HTML to PDF using the configured options. + /// + /// # Errors + /// Returns an error if the HTTP request fails, or PDF generation fails. + /// + /// # Panics + /// Panics if index calculations overflow or if file operations fail unexpectedly. + #[allow(clippy::too_many_lines)] pub fn do_render_pdf( &self, html: String, @@ -241,8 +261,11 @@ pub mod sync { }) }; - let response = - self.send_request(&endpoint, payload, basic_auth)?; + let response = self.send_request( + &endpoint, + &payload, + basic_auth.as_ref(), + )?; if !response.status().is_success() { let error = response.text()?; @@ -254,12 +277,11 @@ pub mod sync { return Err(anyhow!( "AWS Lambda request failed: {error:?}" )); - } else { - error!("OpenWhisk request failed: {error:?}"); - return Err(anyhow!( - "OpenWhisk request failed: {error:?}" - )); } + error!("OpenWhisk request failed: {error:?}"); + return Err(anyhow!( + "OpenWhisk request failed: {error:?}" + )); } match &self.transport { @@ -282,8 +304,9 @@ pub mod sync { PdfTransport::OpenWhisk { .. } => { let response_json = response.json::()?; - let pdf_base64 = response_json["pdf_base64"] - .as_str() + let pdf_base64 = response_json + .get("pdf_base64") + .and_then(|v| v.as_str()) .ok_or_else(|| { anyhow!("Missing pdf_base64 in response") })?; @@ -291,7 +314,7 @@ pub mod sync { .decode(pdf_base64) .map_err(|e| anyhow!("{e:?}")) } - _ => unreachable!(), + PdfTransport::InPlace => unreachable!(), } } PdfTransport::InPlace => { @@ -327,15 +350,22 @@ pub mod sync { /// --- ASYNC VERSION --- impl PdfRenderer { - /// Public async render_pdf that preserves the async signature. + /// Public async `render_pdf` that preserves the async signature. + /// Async wrapper for rendering HTML to PDF. + /// + /// # Errors + /// Returns an error if PDF rendering fails. pub async fn render_pdf( html: String, pdf_options: Option, ) -> Result> { - Ok(PdfRenderer::new()?.do_render_pdf(html, pdf_options).await?) + PdfRenderer::new()?.do_render_pdf(html, pdf_options).await } - /// Creates a new PdfRenderer based on environment configuration. + /// Creates a new `PdfRenderer` based on environment configuration. + /// + /// # Errors + /// Returns an error if the backend is misconfigured or environment variables are missing. pub fn new() -> Result { info!("PdfRenderer::new() [async] - Starting initialization"); @@ -359,7 +389,7 @@ impl PdfRenderer { }, "openwhisk" => { let mut openwhisk_endpoint = std::env::var("OPENWHISK_DOC_RENDERER_ENDPOINT"); - if !openwhisk_endpoint.is_ok() { + if openwhisk_endpoint.is_err() { let openwhisk_api_host = std::env::var("OPENWHISK_API_HOST"); if let Ok(host) = openwhisk_api_host { openwhisk_endpoint = Ok(format!("{host}/api/v1/namespaces/_/actions/pdf-tools/doc_renderer?blocking=true&result=true")); @@ -380,8 +410,15 @@ impl PdfRenderer { Ok(PdfRenderer { transport }) } - /// Async do_render_pdf uses retry_with_exponential_backoff for the HTTP + /// Async `do_render_pdf` uses `retry_with_exponential_backoff` for the HTTP /// request. + /// + /// # Errors + /// Returns an error if the backend fails, the HTTP request fails, or PDF generation fails. + /// + /// # Panics + /// Panics if index calculations overflow or if file operations fail unexpectedly. + #[allow(clippy::too_many_lines)] pub async fn do_render_pdf( &self, html: String, @@ -462,11 +499,14 @@ impl PdfRenderer { client.post(endpoint.clone()).json(&payload); if let Some(basic_auth) = basic_auth { let parts: Vec<&str> = basic_auth.split(':').collect(); - if parts.len() != 2 { + if let (Some(user), Some(pass)) = + (parts.first(), parts.get(1)) + { + request_builder = + request_builder.basic_auth(user, Some(pass)); + } else { return Err(anyhow!("Invalid basic auth provided")); } - request_builder = - request_builder.basic_auth(parts[0], Some(parts[1])); } let response = retry_with_exponential_backoff( @@ -501,12 +541,9 @@ impl PdfRenderer { return Err(anyhow!( "AWS Lambda request failed: {error:?}" )); - } else { - error!("OpenWhisk request failed: {error:?}"); - return Err(anyhow!( - "OpenWhisk request failed: {error:?}" - )); } + error!("OpenWhisk request failed: {error:?}"); + return Err(anyhow!("OpenWhisk request failed: {error:?}")); } match &self.transport { @@ -532,13 +569,15 @@ impl PdfRenderer { PdfTransport::OpenWhisk { .. } => { let response_json = response.json::().await?; - let pdf_base64 = - response_json["pdf_base64"].as_str().ok_or_else( - || anyhow!("Missing pdf_base64 in response"), - )?; + let pdf_base64 = response_json + .get("pdf_base64") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + anyhow!("Missing pdf_base64 in response") + })?; BASE64.decode(pdf_base64).map_err(|e| anyhow!("{e:?}")) } - _ => unreachable!(), + PdfTransport::InPlace => unreachable!(), } } PdfTransport::InPlace => { @@ -573,31 +612,40 @@ impl PdfRenderer { /// S3 helper functions. cfg_if::cfg_if! { if #[cfg(feature = "s3")] { + /// Returns the private S3 bucket name, or None if unavailable. fn s3_private_bucket() -> Option { s3::get_private_bucket().ok() } - fn s3_bucket_path(path: String) -> Option { - Some(path) + /// Returns the S3 bucket path as a string. + const fn s3_bucket_path(path: String) -> String { + path } + /// Retrieves a file from S3 as bytes. async fn get_file_from_s3(bucket: String, output_filename: String) -> Result> { s3::get_file_from_s3(bucket, output_filename) .await .map_err(|err| anyhow!("could not retrieve file from S3: {err:?}")) } } else { + /// Returns None for private S3 bucket when S3 is not enabled. fn s3_private_bucket() -> Option { None } - fn s3_bucket_path(path: String) -> Option { - None + /// Returns an empty string for S3 bucket path when S3 is not enabled. + fn s3_bucket_path(_path: String) -> String { + String::new() } + /// Unimplemented: S3 file retrieval is not available without S3 feature. async fn get_file_from_s3(_bucket: String, _output_filename: String) -> Result> { unimplemented!() } } } -/// Converts HTML to PDF using headless_chrome. +/// Converts HTML to PDF using `headless_chrome`. +/// +/// # Errors +/// Returns an error if file creation, writing, or PDF generation fails. #[instrument(skip_all, err)] pub fn html_to_pdf( html: String, @@ -607,14 +655,16 @@ pub fn html_to_pdf( let dir = tempdir()?; let file_path = dir.path().join("index.html"); let mut file = File::create(file_path.clone())?; - let file_path_str = file_path.to_str().unwrap(); + let file_path_str = file_path + .to_str() + .ok_or_else(|| anyhow!("Failed to convert file path to string"))?; file.write_all(html.as_bytes())?; - let url_path = format!("file://{}", file_path_str); + let url_path = format!("file://{file_path_str}"); info!("html_to_pdf: {url_path:?}"); debug!("options: {options:#?}"); - let pdf_options = options.unwrap_or_else(|| PrintToPdfOptions { + let pdf_options = options.unwrap_or(PrintToPdfOptions { landscape: None, display_header_footer: None, print_background: Some(true), @@ -638,8 +688,8 @@ pub fn html_to_pdf( print_to_pdf(url_path.as_str(), pdf_options, None) } -/// Uses headless_chrome to print the file to PDF, with retry on transient -/// failures. +/// Uses `headless_chrome` to print the file to PDF, with retry on transient +/// failures. #[instrument(skip_all, err)] fn print_to_pdf( file_path: &str, @@ -672,7 +722,7 @@ fn print_to_pdf( retrying in {delay:?}" ); sleep(delay); - delay *= 2; + delay = delay.checked_mul(2).expect("delay overflow"); } Err(e) => return Err(e), } @@ -680,7 +730,7 @@ fn print_to_pdf( unreachable!() } -/// One attempt at printing via headless Chrome. +/// One attempt at printing via `headless_chrome`. #[instrument(skip_all, err)] fn print_to_pdf_once( file_path: &str, @@ -695,7 +745,7 @@ fn print_to_pdf_once( .headless(true) // .enable_logging(true) - .idle_browser_timeout(Duration::from_secs(99999999)) + .idle_browser_timeout(Duration::from_secs(99_999_999)) .args(vec![ std::ffi::OsStr::new("--disable-setuid-sandbox"), std::ffi::OsStr::new("--disable-dev-shm-usage"), @@ -703,7 +753,7 @@ fn print_to_pdf_once( std::ffi::OsStr::new("--no-zygote"), ]) .build() - .expect("Default should not panic"); + .map_err(|_| anyhow!("Default LaunchOptionsBuilder failed"))?; info!("1. Opening browser"); let browser = @@ -712,7 +762,7 @@ fn print_to_pdf_once( info!("2. Opening tab"); let tab = browser.new_tab()?; - tab.set_default_timeout(Duration::from_secs(99999999)); + tab.set_default_timeout(Duration::from_secs(99_999_999)); info!("3. Navigating to tab"); tab.navigate_to(file_path)? .wait_until_navigated() @@ -747,8 +797,7 @@ mod tests { let bytes = html_to_pdf( "

Hello, world!

".to_string(), None, - ) - .unwrap(); + )?; let file_path = Path::new("./res.pdf"); let mut file = OpenOptions::new() diff --git a/packages/sequent-core/src/services/probe.rs b/packages/sequent-core/src/services/probe.rs index d0a42348916..2fb4df7dfa7 100644 --- a/packages/sequent-core/src/services/probe.rs +++ b/packages/sequent-core/src/services/probe.rs @@ -9,30 +9,22 @@ use tokio::sync::Mutex; use warp::Future; use warp::{http::Response, Filter}; +/// Type alias for the probe future function used in liveness/readiness checks. +type ProbeFuture = dyn Fn() -> std::pin::Pin + Send>> + + Send + + Sync; +/// A handler for Kubernetes liveness and readiness probes. pub struct ProbeHandler { + /// The address to bind the probe server to. address: SocketAddr, + /// The path for the liveness probe. live_path: String, + /// The path for the readiness probe. ready_path: String, - is_live: Arc< - Mutex< - Box< - dyn Fn() -> std::pin::Pin< - Box + Send>, - > + Send - + Sync, - >, - >, - >, - is_ready: Arc< - Mutex< - Box< - dyn Fn() -> std::pin::Pin< - Box + Send>, - > + Send - + Sync, - >, - >, - >, + /// The liveness probe function. + is_live: Arc>>, + /// The readiness probe function. + is_ready: Arc>>, } impl ProbeHandler { @@ -54,66 +46,67 @@ impl ProbeHandler { } } + /// Returns a future that runs the probe server. + /// + /// # Panics + /// Panics if the response cannot be built. pub fn future(&self) -> impl Future { let il = Arc::clone(&self.is_live); let ir = Arc::clone(&self.is_ready); - let filter = - warp::get().and( - warp::path(self.live_path.to_string()) - .and_then(move || { - // "Any code greater than or equal to 200 and less than - // 400 indicates success. Any other code indicates failure". https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ - let il = Arc::clone(&il); - async move { - let is_live = il.lock().await; - let is_live_future = is_live(); - if is_live_future.await { - Ok::<_, warp::Rejection>( - Response::builder() - .status(warp::http::StatusCode::OK) - .body("Live") - .unwrap(), - ) - } else { - Ok::<_, warp::Rejection>( - Response::builder() - .status( - warp::http::StatusCode::BAD_REQUEST, - ) - .body("Not live") - .unwrap(), - ) - } + let filter = warp::get().and( + warp::path(self.live_path.clone()) + .and_then(move || { + // "Any code greater than or equal to 200 and less than + // 400 indicates success. Any other code indicates failure". https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ + let il = Arc::clone(&il); + async move { + let is_live = il.lock().await; + let is_live_future = is_live(); + if is_live_future.await { + let response = Response::builder() + .status(warp::http::StatusCode::OK) + .body("Live") + .unwrap_or_else(|_| Response::new("Live")); + Ok::<_, warp::Rejection>(response) + } else { + let response = Response::builder() + .status(warp::http::StatusCode::BAD_REQUEST) + .body("Not live") + .unwrap_or_else(|_| Response::new("Not live")); + Ok::<_, warp::Rejection>(response) } - }) - .or(warp::path(self.ready_path.to_string()).and_then( - move || { - let ir = Arc::clone(&ir); - async move { - let is_ready = ir.lock().await; - let is_ready_future = is_ready(); - if is_ready_future.await { - Ok::<_, warp::Rejection>( - Response::builder() - .status(warp::http::StatusCode::OK) - .body("Ready") - .unwrap(), - ) - } else { - Ok::<_, warp::Rejection>(Response::builder() + } + }) + .or(warp::path(self.ready_path.clone()).and_then(move || { + let ir = Arc::clone(&ir); + async move { + let is_ready = ir.lock().await; + let is_ready_future = is_ready(); + if is_ready_future.await { + let response = Response::builder() + .status(warp::http::StatusCode::OK) + .body("Ready") + .unwrap_or_else(|_| Response::new("Ready")); + Ok::<_, warp::Rejection>(response) + } else { + let response = Response::builder() .status(warp::http::StatusCode::BAD_REQUEST) .body("Not ready") - .unwrap()) - } - } - }, - )), - ); + .unwrap_or_else(|_| Response::new("Not ready")); + Ok::<_, warp::Rejection>(response) + } + } + })), + ); warp::serve(filter).bind(self.address) } + /// Sets the liveness probe function. + /// + /// # Errors + /// This function does not return errors, but the probe function may fail internally. pub async fn set_live( &self, f: impl Fn() -> std::pin::Pin< @@ -126,6 +119,10 @@ impl ProbeHandler { *l = Box::new(f); } + /// Sets the readiness probe function. + /// + /// # Errors + /// This function does not return errors, but the probe function may fail internally. pub async fn set_ready( &self, f: impl Fn() -> std::pin::Pin< diff --git a/packages/sequent-core/src/services/replace_uuids.rs b/packages/sequent-core/src/services/replace_uuids.rs index 677c88d675b..9f9e7ae63ea 100644 --- a/packages/sequent-core/src/services/replace_uuids.rs +++ b/packages/sequent-core/src/services/replace_uuids.rs @@ -9,19 +9,24 @@ use tracing::instrument; use uuid::Uuid; #[instrument(skip(input))] +/// Replaces UUIDs in the input string, except those in the keep list or fixed config. +/// +/// # Panics +/// Panics if the UUID regex is invalid or if a capture group is missing (should not happen). +#[allow(clippy::unwrap_used)] // Safe: regex is a static, known-valid constant; failure would indicate a programming error. pub fn replace_uuids( input: &str, - keep: Vec, + keep: &[String], ) -> (String, HashMap) { let uuid_regex = Regex::new(r"[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}") - .unwrap(); + .expect("Invalid UUID regex"); let fixed_uuids_from_config = env::var("ELECTION_EVENT_FIXED_UUIDS") - .unwrap_or("".to_string()) - .split(",") - .map(|s| s.to_string()) + .unwrap_or_default() + .split(',') + .map(std::string::ToString::to_string) .collect::>(); - let mut keep_all = keep.clone(); + let mut keep_all = keep.to_vec(); keep_all.extend(fixed_uuids_from_config); let mut seen_uuids = HashMap::new(); @@ -29,7 +34,11 @@ pub fn replace_uuids( let result = uuid_regex .replace_all(input, |caps: ®ex::Captures| { - let old_uuid = caps.get(0).unwrap().as_str().to_string(); + let old_uuid = caps + .get(0) + .expect("capture group 0 should always be present") + .as_str() + .to_string(); if keep_set.contains(&old_uuid) { old_uuid.clone() } else { diff --git a/packages/sequent-core/src/services/reports.rs b/packages/sequent-core/src/services/reports.rs index c058379b17e..71c7a71d8fd 100644 --- a/packages/sequent-core/src/services/reports.rs +++ b/packages/sequent-core/src/services/reports.rs @@ -15,6 +15,7 @@ use std::collections::{HashMap, HashSet}; use std::str::FromStr; use tracing::{info, instrument, warn}; +/// Returns a Handlebars registry with all helpers registered. fn get_registry<'reg>() -> Handlebars<'reg> { let mut reg = Handlebars::new(); reg.set_strict_mode(false); @@ -87,6 +88,10 @@ fn get_registry<'reg>() -> Handlebars<'reg> { reg } +/// Renders a Handlebars template from a string and variables map. +/// +/// # Errors +/// Returns an error if template rendering fails. #[instrument(skip_all, err)] pub fn render_template_text( template: &str, @@ -98,7 +103,12 @@ pub fn render_template_text( reg.render_template(template, &json!(variables_map)) } +/// Renders a Handlebars template by name from a template map and variables map. +/// +/// # Errors +/// Returns an error if template registration or rendering fails. #[instrument(skip_all, err)] +#[allow(clippy::implicit_hasher)] pub fn render_template( template_name: &str, template_map: HashMap, @@ -114,16 +124,19 @@ pub fn render_template( reg.render(template_name, &json!(variables_map)) } +#[must_use] pub fn helper_wrapper_or<'a>( func: Box, or_val: String, ) -> Box { struct WrapperHelper<'a> { + /// Inner Handlebars helper; on error. func: Box, + /// Fallback output when the inner helper returns an error. or_val: String, } - impl<'a> HelperDef for WrapperHelper<'a> { + impl HelperDef for WrapperHelper<'_> { fn call<'reg: 'rc, 'rc>( &self, helper: &Helper<'rc>, @@ -158,14 +171,16 @@ pub fn helper_wrapper_or<'a>( Box::new(WrapperHelper { func, or_val }) } +#[must_use] pub fn helper_wrapper<'a>( func: Box, ) -> Box { struct WrapperHelper<'a> { + /// Inner Handlebars helper. func: Box, } - impl<'a> HelperDef for WrapperHelper<'a> { + impl HelperDef for WrapperHelper<'_> { fn call<'reg: 'rc, 'rc>( &self, helper: &Helper<'rc>, @@ -198,6 +213,10 @@ pub fn helper_wrapper<'a>( Box::new(WrapperHelper { func }) } +/// Writes the first parameter as a string to the output. +/// +/// # Errors +/// Returns an error if the parameter is missing or output fails. pub fn expr_helper<'reg, 'rc>( h: &Helper<'rc>, _r: &'reg Handlebars<'reg>, @@ -223,6 +242,13 @@ pub fn expr_helper<'reg, 'rc>( Ok(()) } +/// Sets a variable in the Handlebars render context. +/// +/// # Errors +/// Returns an error if the parameter is missing or of the wrong type. +/// +/// # Panics +/// Panics if the variable cannot be set in the context. pub fn let_helper<'reg, 'rc>( h: &Helper<'rc>, _r: &'reg Handlebars<'reg>, @@ -251,13 +277,21 @@ pub fn let_helper<'reg, 'rc>( .map(|v| v.value().to_owned()) .ok_or_else(|| RenderErrorReason::ParamNotFoundForIndex("let", 2))?; - let block = rc.block_mut().unwrap(); + let block = rc.block_mut().ok_or_else(|| { + RenderErrorReason::Other( + "block_mut should be available in let_helper".to_string(), + ) + })?; block.set_block_param(name_constant, BlockParamHolder::Value(value)); Ok(()) } +/// Sanitizes HTML input. +/// +/// # Errors +/// Returns an error if writing to the output fails. pub fn sanitize_html( helper: &Helper, _: &Handlebars, @@ -271,7 +305,7 @@ pub fn sanitize_html( .unwrap_or(""); let tags: HashSet<&str> = - ["strong", "em", "b", "i", "br"].iter().cloned().collect(); + ["strong", "em", "b", "i", "br"].iter().copied().collect(); let mut builder = ammonia::Builder::default(); let builder = builder.tags(tags); @@ -282,6 +316,10 @@ pub fn sanitize_html( Ok(()) } +/// Parses a u64 value from a JSON value. +/// +/// # Errors +/// Returns an error if the value is not a valid u64 or cannot be parsed as u64. fn parse_u64_value(value: &JsonValue) -> Result { match value { JsonValue::Number(n) => n.as_u64().ok_or_else(|| { @@ -290,7 +328,7 @@ fn parse_u64_value(value: &JsonValue) -> Result { )) }), JsonValue::String(s) => s.parse::().map_err(|_| { - RenderError::new(format!("Failed to parse '{}' as u64", s)) + RenderError::new(format!("Failed to parse '{s}' as u64")) }), _ => Err(RenderError::new( "Expected u64 or a string representing an u64", @@ -298,6 +336,10 @@ fn parse_u64_value(value: &JsonValue) -> Result { } } +/// Formats a u64 value with locale-specific separators. +/// +/// # Errors +/// Returns an error if the parameter is missing, not a valid u64, or output fails. pub fn format_u64( helper: &Helper, _: &Handlebars, @@ -317,6 +359,10 @@ pub fn format_u64( Ok(()) } +/// Parses a f64 value from a JSON value. +/// +/// # Errors +/// Returns an error if the value is not a valid f64 or cannot be parsed as f64. fn parse_f64_value(value: &JsonValue) -> Result { match value { JsonValue::Number(n) => n.as_f64().ok_or_else(|| { @@ -325,7 +371,7 @@ fn parse_f64_value(value: &JsonValue) -> Result { )) }), JsonValue::String(s) => s.parse::().map_err(|_| { - RenderError::new(format!("Failed to parse '{}' as f64", s)) + RenderError::new(format!("Failed to parse '{s}' as f64")) }), _ => Err(RenderError::new( "Expected f64 or a string representing an f64", @@ -457,7 +503,9 @@ impl HelperDef for modulo { return Err(RenderError::new("Modulo by zero")); } - let result = dividend % divisor; + let result = dividend.checked_rem(divisor).ok_or_else(|| { + RenderError::new("Overflow or modulo by zero in modulo helper") + })?; Ok(ScopedJson::Derived(JsonValue::from(result))) } } @@ -492,7 +540,18 @@ impl HelperDef for next { })?; // Calculate next index - let next_index = (index + 1) as usize; + let next_index = index + .checked_add(1) + .ok_or_else(|| { + RenderError::new("Index overflow when calculating next index") + }) + .and_then(|val| { + usize::try_from(val).map_err(|_| { + RenderError::new( + "Index overflow when calculating next index", + ) + }) + })?; // Return the next element or null if it doesn't exist let result = array.get(next_index).cloned().unwrap_or(JsonValue::Null); @@ -528,6 +587,10 @@ impl HelperDef for parse_i64 { } } +/// Formats a decimal as a percentage with two decimal places. +/// +/// # Errors +/// Returns an error if the parameter is missing or not a valid number, or if writing to output fails. pub fn format_dec_percentage( helper: &Helper, _: &Handlebars, @@ -547,13 +610,17 @@ pub fn format_dec_percentage( let val = (val * 100.0).clamp(0.00, 100.00); - let formatted_number = format!("{:.2}", val); + let formatted_number = format!("{val:.2}"); out.write(&formatted_number)?; Ok(()) } +/// Formats a number as a percentage with two decimal places. +/// +/// # Errors +/// Returns an error if the parameter is missing or not a valid number, or if writing to output fails. pub fn format_percentage( helper: &Helper, _: &Handlebars, @@ -571,13 +638,17 @@ pub fn format_percentage( let val = parse_f64_value(val_json)?; - let formatted_number = format!("{:.2}", val); + let formatted_number = format!("{val:.2}"); out.write(&formatted_number)?; Ok(()) } +/// Formats a date string using a dynamic format string. +/// +/// # Errors +/// Returns an error if parameters are missing, not valid strings, or if date parsing/formatting fails. pub fn format_date( helper: &Helper, _: &Handlebars, @@ -593,7 +664,6 @@ pub fn format_date( let date_str: &str = date_json.as_str().ok_or_else(|| { warn!("couldn't parse as &str: date_json={date_json:?}"); - RenderErrorReason::InvalidParamType("couldn't parse as &str") })?; @@ -605,7 +675,6 @@ pub fn format_date( let format_str: &str = format_json.as_str().ok_or_else(|| { warn!("couldn't parse as &str: format_json={format_json:?}"); - RenderErrorReason::InvalidParamType("couldn't parse as &str") })?; @@ -623,7 +692,7 @@ pub fn format_date( // Otherwise, assume it's just a date "YYYY-MM-DD" and add a time // placeholder DateTime::parse_from_str( - &format!("{} 00:00:00", date_str), + &format!("{date_str} 00:00:00"), "%Y-%m-%d %H:%M:%S", ) .map_err(|err| { @@ -643,8 +712,10 @@ pub fn format_date( Ok(()) } -/// Convert unix time to RFC2822 date and time format, like: Tue, 1 Jul 2003 -/// 10:52:37 +0200. +/// Convert unix time to RFC2822 date and time format, like: Tue, 1 Jul 2003 10:52:37 +0200. +/// +/// # Errors +/// Returns an error if the timestamp cannot be parsed or formatted. pub fn timestamp_to_rfc2822(timestamp: i64) -> Result { let dt = DateTime::::from_timestamp(timestamp, 0) .with_context(|| "Error parsing timestamp")?; @@ -654,7 +725,10 @@ pub fn timestamp_to_rfc2822(timestamp: i64) -> Result { Ok(statement_timestamp) } -/// Convert unix time to the given format +/// Convert unix time to the given format. +/// +/// # Errors +/// Returns an error if the timestamp cannot be parsed or formatted. pub fn format_datetime(unix_time: i64, fmt: &str) -> Result { let dt = DateTime::::from_timestamp(unix_time, 0) .with_context(|| "Error parsing creation timestamp")?; @@ -662,6 +736,10 @@ pub fn format_datetime(unix_time: i64, fmt: &str) -> Result { Ok(formatted_str) } +/// Increments an integer by 1 and writes it to output. +/// +/// # Errors +/// Returns an error if the parameter is missing, not a valid integer, or if writing to output fails. pub fn inc( helper: &Helper, _: &Handlebars, @@ -676,13 +754,19 @@ pub fn inc( .as_u64() .ok_or(RenderErrorReason::InvalidParamType("couldn't parse as u64"))?; - let inc_index = index + 1; + let inc_index = index + .checked_add(1) + .ok_or(RenderErrorReason::InvalidParamType("Overflow in inc"))?; out.write(&inc_index.to_string())?; Ok(()) } +/// Increments an integer by 2 and writes it to output. +/// +/// # Errors +/// Returns an error if the parameter is missing, not a valid integer, or if writing to output fails. pub fn inc2( helper: &Helper, _: &Handlebars, @@ -697,13 +781,19 @@ pub fn inc2( .as_u64() .ok_or(RenderErrorReason::InvalidParamType("couldn't parse as u64"))?; - let inc_index = index + 2; + let inc_index = index + .checked_add(2) + .ok_or(RenderErrorReason::InvalidParamType("Overflow in inc2"))?; out.write(&inc_index.to_string())?; Ok(()) } +/// Serializes a parameter to JSON and writes it to output. +/// +/// # Errors +/// Returns an error if serialization or writing to output fails. pub fn to_json( h: &Helper, _: &Handlebars, diff --git a/packages/sequent-core/src/services/s3.rs b/packages/sequent-core/src/services/s3.rs index c3a8b9e0eb4..263adaedcfa 100644 --- a/packages/sequent-core/src/services/s3.rs +++ b/packages/sequent-core/src/services/s3.rs @@ -26,23 +26,35 @@ use tempfile::{NamedTempFile, TempPath}; use tokio::io::{self, AsyncReadExt}; use tracing::{info, instrument}; +/// Maximum chunk size for S3 multipart uploads (16 MiB). const MAX_CHUNK_SIZE: u64 = 16 * 1024 * 1024; +/// Delimiter used in AWS hosted S3 hostnames. const AWS_HOSTED_S3_HOST_DELIMITER: &str = ".s3."; +/// Domain suffix for AWS hosted S3 endpoints. const AWS_HOSTED_S3_DOMAIN_SUFFIX: &str = "amazonaws.com"; +/// Prefix for AWS S3 service host. const AWS_S3_SERVICE_HOST_PREFIX: &str = "s3"; #[derive(Debug, PartialEq, Eq)] + +/// Parts resolved from an S3 list target, including endpoint, bucket, and prefix root. struct ResolvedS3ListTargetParts { + /// The resolved service endpoint, if any. service_endpoint: Option, + /// The bucket name. bucket: String, + /// The prefix root, if any. prefix_root: Option, } /// Carries the resolved S3 client, real bucket name, and optional logical -/// prefix root for list-style operations that must work on both MinIO and AWS. +/// prefix root for list-style operations that must work on both `MinIO` and `AWS`. struct ResolvedS3ListTarget { + /// The S3 client. client: s3::Client, + /// The bucket name. bucket: String, + /// The prefix root, if any. prefix_root: Option, } @@ -81,9 +93,8 @@ fn parse_aws_bucket_endpoint( // string slicing against the raw env var value. let url = reqwest::Url::parse(endpoint_uri) .with_context(|| format!("Invalid S3 endpoint URL `{endpoint_uri}`"))?; - let host = match url.host_str() { - Some(host) => host, - None => return Ok(None), + let Some(host) = url.host_str() else { + return Ok(None); }; // AWS bucket-hosted endpoints look like `.s3.amazonaws.com` or @@ -126,8 +137,10 @@ fn parse_aws_bucket_endpoint( // will use the returned bucket name plus this service endpoint for list and // delete operations that require bucket + prefix semantics on AWS. let mut service_endpoint = format!("{}://{}", url.scheme(), service_host); + if let Some(port) = url.port() { - service_endpoint.push_str(&format!(":{port}")); + use std::fmt::Write as _; + let _ = write!(service_endpoint, ":{port}"); } Ok(Some((service_endpoint, bucket_name.to_string()))) @@ -193,6 +206,9 @@ fn build_s3_config_for_endpoint( /// /// When `use_server_endpoint` is `false`, the helper uses the client endpoint /// instead of the server endpoint. +/// +/// # Errors +/// Returns an error if environment variables are missing or AWS config cannot be loaded. async fn get_s3_list_target( logical_bucket: &str, use_server_endpoint: bool, @@ -205,7 +221,7 @@ async fn get_s3_list_target( let endpoint_uri = env::var(env_var_name) .with_context(|| format!("{env_var_name} must be set"))?; let sdk_config = get_from_env_aws_config().await?; - let aws_region = sdk_config.region().map(|region| region.as_ref()); + let aws_region = sdk_config.region().map(std::convert::AsRef::as_ref); let target_parts = resolve_s3_list_target_parts( &endpoint_uri, logical_bucket, @@ -218,7 +234,7 @@ async fn get_s3_list_target( let config = build_s3_config_for_endpoint(&sdk_config, resolved_endpoint); Ok(ResolvedS3ListTarget { - client: get_s3_client(config).await?, + client: get_s3_client(config)?, bucket: target_parts.bucket, prefix_root: target_parts.prefix_root, }) @@ -227,6 +243,9 @@ async fn get_s3_list_target( #[instrument(err, skip_all)] /// Returns the logical private bucket or root prefix so callers can separate /// storage scope from endpoint selection. +/// +/// # Errors +/// Returns an error if the environment variable is not set. pub fn get_private_bucket() -> Result { let s3_bucket = env::var("AWS_S3_BUCKET") .map_err(|err| anyhow!("AWS_S3_BUCKET must be set: {err}"))?; @@ -236,6 +255,9 @@ pub fn get_private_bucket() -> Result { #[instrument(err, skip_all)] /// Returns the logical public bucket or root prefix used for public assets and /// plugin storage. +/// +/// # Errors +/// Returns an error if the environment variable is not set. pub fn get_public_bucket() -> Result { let s3_bucket = env::var("AWS_S3_PUBLIC_BUCKET") .map_err(|err| anyhow!("AWS_S3_PUBLIC_BUCKET must be set: {err}"))?; @@ -245,6 +267,9 @@ pub fn get_public_bucket() -> Result { #[instrument(skip(client, config))] /// Creates a bucket when running against environments that manage buckets /// directly instead of pre-provisioning them. +/// +/// # Errors +/// Returns an error if the bucket cannot be created or region cannot be determined. async fn create_bucket_if_not_exists( client: &s3::Client, config: &s3::Config, @@ -280,14 +305,16 @@ async fn create_bucket_if_not_exists( .with_context(|| { format!("Error creating bucket with name={bucket_name}") })?; - println!("Bucket {} created", bucket_name); + info!("Bucket {bucket_name} created"); } Ok(()) } /// Wraps S3 client construction so callers rely on one place for config to /// client conversion. -pub async fn get_s3_client(config: s3::Config) -> Result { +/// # Errors +/// This function does not currently return errors, but is defined for consistency. +pub fn get_s3_client(config: s3::Config) -> Result { let client = s3::Client::from_conf(config); Ok(client) } @@ -319,18 +346,21 @@ pub fn get_public_document_key( document_id: &str, name: &str, ) -> String { - format!("tenant-{}/document-{}/{}", tenant_id, document_id, name) + format!("tenant-{tenant_id}/document-{document_id}/{name}") } #[instrument(err)] /// Creates a presigned download URL for a document so clients can fetch files /// without proxying the bytes through the backend. +/// +/// # Errors +/// Returns an error if AWS config, S3 client, or presigning fails. pub async fn get_document_url( key: String, s3_bucket: String, ) -> Result { let config = get_s3_aws_config(/* use_server_endpoint = */ false).await?; - let client = get_s3_client(config).await?; + let client = get_s3_client(config)?; let presigning_config = PresigningConfig::expires_in(Duration::from_secs( get_fetch_expiration_secs()?, @@ -349,20 +379,24 @@ pub async fn get_document_url( #[instrument(err, ret)] /// Creates a presigned upload URL and selects the endpoint that the caller can /// actually reach. +/// +/// # Errors +/// Returns an error if AWS config, S3 client, or presigning fails. pub async fn get_upload_url( key: String, is_public: bool, is_local: bool, ) -> Result { - let s3_bucket = match is_public { - true => get_public_bucket()?, - false => get_private_bucket()?, + let s3_bucket = if is_public { + get_public_bucket()? + } else { + get_private_bucket()? }; // Select the AWS endpoint that the caller can reach: when `is_local` is true // we use the server-only endpoint; `is_public` only determines the upload bucket. let config = get_s3_aws_config(/* use_server_endpoint = */ is_local).await?; - let client = get_s3_client(config.clone()).await?; + let client = get_s3_client(config.clone())?; let presigning_config = PresigningConfig::expires_in(Duration::from_secs( get_upload_expiration_secs()?, @@ -380,6 +414,9 @@ pub async fn get_upload_url( #[instrument(err, skip_all)] /// Downloads one object into a temporary file so downstream code can work with /// a filesystem path instead of holding the full payload in memory. +/// +/// # Errors +/// Returns an error if AWS config, S3 client, download, or file I/O fails. pub async fn get_object_into_temp_file( s3_bucket: &str, key: &str, @@ -389,7 +426,7 @@ pub async fn get_object_into_temp_file( let config = get_s3_aws_config(/* use_server_endpoint = */ true) .await .with_context(|| "Error obtaining aws config")?; - let client = get_s3_client(config.clone()).await?; + let client = get_s3_client(config.clone())?; let response = client .get_object() @@ -412,8 +449,12 @@ pub async fn get_object_into_temp_file( break; // End of file } temp_file - .write_all(&buffer[..size]) - .with_context(|| "Error writting to the text file")?; + .write_all( + buffer + .get(..size) + .ok_or_else(|| anyhow!("Buffer slice out of bounds"))?, + ) + .with_context(|| "Error writing to the temp file")?; } // The file is now downloaded to a temporary file @@ -423,6 +464,9 @@ pub async fn get_object_into_temp_file( #[instrument(err, skip_all)] /// Uploads a file path to S3 and switches to multipart uploads only when the /// payload is large enough to need chunking. +/// +/// # Errors +/// Returns an error if file metadata, S3 upload, or file reading fails. pub async fn upload_file_to_s3( key: String, is_public: bool, @@ -470,8 +514,15 @@ pub async fn upload_file_to_s3( } #[instrument(err, skip_all)] +#[allow(clippy::too_many_arguments)] /// Streams a large file through S3 multipart upload so oversized reports and /// exports do not need to be buffered at once. +/// +/// # Errors +/// Returns an error if S3 upload or file reading fails. +/// +/// # Panics +/// Panics if `ByteStream::read_from().build().await` fails (should be handled). pub async fn upload_multipart_data_to_s3( path: &Path, key: String, @@ -482,18 +533,26 @@ pub async fn upload_multipart_data_to_s3( download_filename: Option, file_size: u64, ) -> Result<()> { - let mut chunk_count = (file_size / MAX_CHUNK_SIZE) + 1; - let mut size_of_last_chunk = file_size % MAX_CHUNK_SIZE; + // --- begin clippy fixes --- + let mut chunk_count = file_size + .checked_div(MAX_CHUNK_SIZE) + .ok_or_else(|| anyhow!("Division by zero in chunk count"))? + .checked_add(1) + .ok_or_else(|| anyhow!("Addition overflow in chunk count"))?; + let mut size_of_last_chunk = file_size + .checked_rem(MAX_CHUNK_SIZE) + .ok_or_else(|| anyhow!("Remainder overflow in size_of_last_chunk"))?; if size_of_last_chunk == 0 { size_of_last_chunk = MAX_CHUNK_SIZE; - chunk_count -= 1; + chunk_count = chunk_count + .checked_sub(1) + .ok_or_else(|| anyhow!("Subtraction overflow in chunk_count"))?; } let config = get_s3_aws_config(!is_public) .await .with_context(|| "Error getting s3 aws config")?; let client = get_s3_client(config.clone()) - .await .with_context(|| "Error getting s3 client")?; let mut multipart_builder = client @@ -526,21 +585,30 @@ pub async fn upload_multipart_data_to_s3( let mut upload_parts: Vec = Vec::new(); for chunk_index in 0..chunk_count { info!("chunk {}", chunk_index); - let this_chunk = if chunk_index == chunk_count - 1 { + let this_chunk = if chunk_index + == chunk_count.checked_sub(1).ok_or_else(|| { + anyhow!("Subtraction overflow in chunk_count for this_chunk") + })? { size_of_last_chunk } else { MAX_CHUNK_SIZE }; + let offset = chunk_index + .checked_mul(MAX_CHUNK_SIZE) + .ok_or_else(|| anyhow!("Multiplication overflow in offset"))?; let stream = ByteStream::read_from() .path(path) - .offset(chunk_index * MAX_CHUNK_SIZE) + .offset(offset) .length(Length::Exact(this_chunk)) .build() .await - .unwrap(); + .expect("Failed to build ByteStream for multipart upload"); // Chunk index needs to start at 0, but part numbers start at 1. - let part_number = (chunk_index as i32) + 1; + let part_number = i32::try_from(chunk_index) + .map_err(|_| anyhow!("Chunk index too large for i32 part_number"))? + .checked_add(1) + .ok_or_else(|| anyhow!("Addition overflow in part_number"))?; let upload_part_res = client .upload_part() .key(&key) @@ -579,6 +647,9 @@ pub async fn upload_multipart_data_to_s3( #[instrument(err, skip_all)] /// Uploads a single in-memory body to S3 for smaller files where multipart /// upload would add unnecessary overhead. +/// +/// # Errors +/// Returns an error if S3 upload fails. pub async fn upload_data_to_s3( data: ByteStream, key: String, @@ -592,7 +663,6 @@ pub async fn upload_data_to_s3( .await .with_context(|| "Error getting s3 aws config")?; let client = get_s3_client(config.clone()) - .await .with_context(|| "Error getting s3 client")?; let mut request = client @@ -619,55 +689,65 @@ pub async fn upload_data_to_s3( Ok(()) } -/// Returns the server-side MinIO URL used by backend services when they need a +/// Returns the server-side `MinIO` URL used by backend services when they need a /// direct path to the public bucket. +/// +/// # Errors +/// Returns an error if required environment variables are not set. pub fn get_minio_url() -> Result { let minio_private_uri = env::var(AWS_S3_PRIVATE_URI_ENV) .map_err(|_err| anyhow!("AWS_S3_PRIVATE_URI must be set"))?; let bucket = get_public_bucket()?; - Ok(format!("{}/{}", minio_private_uri, bucket)) + Ok(format!("{minio_private_uri}/{bucket}")) } -/// Returns the client-facing MinIO URL used when generated links must be +/// Returns the client-facing `MinIO` URL used when generated links must be /// reachable from outside the backend network. +/// +/// # Errors +/// Returns an error if required environment variables are not set. pub fn get_minio_public_url() -> Result { let minio_public_uri = env::var(AWS_S3_PUBLIC_URI_ENV) .map_err(|_err| anyhow!("AWS_S3_PUBLIC_URI must be set"))?; let bucket = get_public_bucket()?; - Ok(format!("{}/{}", minio_public_uri, bucket)) + Ok(format!("{minio_public_uri}/{bucket}")) } -/// Builds the URL for a public asset stored in S3 or MinIO so templates can +/// Builds the URL for a public asset stored in S3 or `MinIO` so templates can /// reference it directly. +/// +/// # Errors +/// Returns an error if the `MinIO` URL or public assets path cannot be fetched. pub fn get_public_asset_file_path(filename: &str) -> Result { let minio_endpoint_base = get_minio_url().with_context(|| "Error fetching get_minio_url")?; let public_asset_path = get_public_assets_path_env_var()?; Ok(format!( - "{}/{}/{}", - minio_endpoint_base, public_asset_path, filename + "{minio_endpoint_base}/{public_asset_path}/{filename}" )) } #[instrument(err)] /// Downloads a file via HTTP into a string for flows that consume public text /// assets rather than raw S3 SDK responses. +/// +/// # Errors +/// Returns an error if the HTTP request or response parsing fails. pub async fn download_s3_file_to_string(file_url: &str) -> Result { let client = reqwest::Client::new(); - info!("Requesting HTTP GET {:?}", file_url); + info!("Requesting HTTP GET {file_url:?}"); let response = client.get(file_url).send().await?; - let unwrapped_response = if response.status() != reqwest::StatusCode::OK { + let unwrapped_response = if response.status() == reqwest::StatusCode::OK { + response + } else { return Err(anyhow!( - "Error during download_s3_file_to_string: {:?}", - response + "Error during download_s3_file_to_string: {response:?}" )); - } else { - response }; let bytes = unwrapped_response.bytes().await?; Ok(String::from_utf8(bytes.to_vec())?) @@ -676,6 +756,9 @@ pub async fn download_s3_file_to_string(file_url: &str) -> Result { #[instrument(err, ret)] /// Deletes every object under a prefix and resolves AWS bucket-hosted endpoints /// into the real bucket plus key prefix before listing. +/// +/// # Errors +/// Returns an error if S3 list or delete operations fail. pub async fn delete_files_from_s3( s3_bucket: String, prefix: String, @@ -710,14 +793,13 @@ pub async fn delete_files_from_s3( } Err(err) => { // Check if it's a NoSuchKey error - let err_str = format!("{:?}", err); + let err_str = format!("{err:?}"); if err_str.contains("NoSuchKey") { - info!("Key already absent in S3; continuing. {:?}", err); + info!("Key already absent in S3; continuing. {err:?}"); return Ok(()); - } else { - // For other errors, fail the operation - return Err(anyhow!("{:?}", err)); } + // For other errors, fail the operation + return Err(anyhow!("{err:?}")); } }; @@ -756,7 +838,7 @@ pub async fn delete_files_from_s3( } Err(err) => { // Check if it's a NoSuchKey error - let err_str = format!("{:?}", err); + let err_str = format!("{err:?}"); if err_str.contains("NoSuchKey") { tracing::warn!( key = %key, @@ -765,8 +847,7 @@ pub async fn delete_files_from_s3( } else { // For other errors, fail the operation return Err(anyhow::Error::from(err).context(format!( - "Failed to delete S3 object: {}", - key + "Failed to delete S3 object: {key}", ))); } } @@ -783,6 +864,9 @@ pub async fn delete_files_from_s3( #[instrument(err)] /// Downloads one object into memory when callers need its bytes immediately. +/// +/// # Errors +/// Returns an error if S3 download or stream reading fails. pub async fn get_file_from_s3( s3_bucket: String, path: String, @@ -791,7 +875,6 @@ pub async fn get_file_from_s3( .await .with_context(|| "Error getting s3 aws config")?; let client = get_s3_client(config.clone()) - .await .with_context(|| "Error getting s3 client")?; let mut object = client @@ -814,6 +897,9 @@ pub async fn get_file_from_s3( #[instrument(err)] /// Lists a prefix and streams each matching file into a temporary path so export /// code can package files without buffering them all in memory. +/// +/// # Errors +/// Returns an error if S3 listing, download, or file I/O fails. pub async fn get_files_from_s3( s3_bucket: String, prefix: String, @@ -834,7 +920,7 @@ pub async fn get_files_from_s3( .send() .await?; - for object in result.contents().iter() { + for object in result.contents() { let key = object.key().ok_or(anyhow!("s3 object key is missing"))?; if !key.contains("export") { @@ -842,6 +928,7 @@ pub async fn get_files_from_s3( let parts: Vec<&str> = key.split('/').collect(); let s3_file_name = parts .last() + .copied() .ok_or(anyhow!("Can't find file name in path"))?; let document_id = parts.iter().find_map(|part| { if part.starts_with("document-") { @@ -861,10 +948,10 @@ pub async fn get_files_from_s3( let s3_body_stream = s3_object.body; - let file_name = document_id - .clone() - .map(|id| format!("document_{}_{}", id, s3_file_name)) - .unwrap_or_else(|| s3_file_name.to_string()); + let file_name = document_id.clone().map_or_else( + || s3_file_name.to_string(), + |id| format!("document_{id}_{s3_file_name}"), + ); let temp_file = generate_temp_file("", &file_name) .context("generating temp file")?; @@ -890,6 +977,9 @@ pub async fn get_files_from_s3( #[instrument(err)] /// Lists a prefix and returns each file as name plus bytes for startup paths, /// such as plugin loading, that need the content in memory. +/// +/// # Errors +/// Returns an error if S3 listing or download fails. pub async fn get_files_names_bytes_from_s3( s3_bucket: String, prefix: String, @@ -912,8 +1002,7 @@ pub async fn get_files_names_bytes_from_s3( .await .with_context(|| { format!( - "Error listing objects in bucket `{}` with prefix `{}`", - bucket_name, list_prefix + "Error listing objects in bucket `{bucket_name}` with prefix `{list_prefix}`" ) })?; @@ -921,7 +1010,9 @@ pub async fn get_files_names_bytes_from_s3( if let Some(contents) = list_output.contents { for object in contents { if let Some(key) = object.key { - let file_name = key.split('/').last().unwrap(); + let file_name = key.split('/').next_back().ok_or(anyhow!( + "Error extracting file name from key `{key}`" + ))?; let get_obj_output = client .get_object() @@ -929,15 +1020,13 @@ pub async fn get_files_names_bytes_from_s3( .key(&key) .send() .await - .with_context(|| { - format!("Error getting object `{}`", key) - })?; + .with_context(|| format!("Error getting object `{key}`"))?; // ByteStream -> Bytes -> Vec let bytes = ByteStream::collect(get_obj_output.body) .await .with_context(|| { - format!("Error streaming object `{}` body", key) + format!("Error streaming object `{key}` body") })? .into_bytes() .to_vec(); diff --git a/packages/sequent-core/src/services/translations.rs b/packages/sequent-core/src/services/translations.rs index e5712043d6a..4f622ee16e7 100644 --- a/packages/sequent-core/src/services/translations.rs +++ b/packages/sequent-core/src/services/translations.rs @@ -12,20 +12,24 @@ use crate::{ types::hasura::core::{Election, ElectionEvent}, }; +/// Default language code to fall back to if no translation is found for a given language. pub const DEFAULT_LANG: &str = "en"; -fn parse_presentation

(presentation: &Option) -> Option

+/// Parses a presentation `Value` into type P. +/// +/// Returns None if the presentation is None or deserialization fails. +fn parse_presentation

(presentation: Option<&serde_json::Value>) -> Option

where P: for<'de> serde::Deserialize<'de>, { - let val = presentation.as_ref()?; + let val = presentation?; deserialize_value::

(val.clone()).ok() } -/// Generic i18n getter for nested shape: I18nContent>> -/// Reads `field` in this order: `language` -> DEFAULT_LANG. +/// Generic i18n getter for nested shape: `I18nContent>>`. +/// Reads `field` in this order: `language` -> `DEFAULT_LANG`. fn i18n_field( - i18n: &Option>>>, + i18n: Option<&I18nContent>>>, language: &str, field: &'static str, ) -> Option { @@ -44,58 +48,87 @@ fn i18n_field( }) } +/// Trait for a name of an entity. pub trait Name { + /// Get the name of an entity in the specified language. fn get_name(&self, language: &str) -> String; } +/// Trait for an alias of an entity. pub trait Alias { + /// Get the alias of an entity in the specified language, falling back to a name if not available. fn get_alias(&self, language: &str) -> Option; } /* ---------------------- ElectionEvent ---------------------- */ impl ElectionEvent { + #[must_use] + /// Get the default language from presentation for the election event, + /// falling back to "en" if not specified. pub fn get_default_language(&self) -> String { - parse_presentation::(&self.presentation) - .and_then(|p| p.language_conf) - .and_then(|lc| lc.default_language_code) - .unwrap_or_else(|| DEFAULT_LANG.into()) + parse_presentation::( + self.presentation.as_ref(), + ) + .and_then(|p| p.language_conf) + .and_then(|lc| lc.default_language_code) + .unwrap_or_else(|| DEFAULT_LANG.into()) } + #[must_use] + /// Get the contest encryption policy from presentation for the election event, + /// falling back to the default policy if not specified. pub fn get_contest_encryption_policy(&self) -> ContestEncryptionPolicy { - parse_presentation::(&self.presentation) - .and_then(|p| p.contest_encryption_policy) - .unwrap_or_default() + parse_presentation::( + self.presentation.as_ref(), + ) + .and_then(|p| p.contest_encryption_policy) + .unwrap_or_default() } + #[must_use] + /// Get the decoded ballots inclusion policy from presentation for the election event, + /// falling back to the default policy if not specified. pub fn get_decoded_ballots_inclusion_policy( &self, ) -> DecodedBallotsInclusionPolicy { - parse_presentation::(&self.presentation) - .and_then(|p| p.decoded_ballot_inclusion_policy) - .unwrap_or_default() + parse_presentation::( + self.presentation.as_ref(), + ) + .and_then(|p| p.decoded_ballot_inclusion_policy) + .unwrap_or_default() } + #[must_use] + /// Get the delegated voting policy from presentation for the election event, + /// falling back to the default policy if not specified. pub fn get_delegated_voting_policy(&self) -> DelegatedVotingPolicy { - parse_presentation::(&self.presentation) - .and_then(|p| p.delegated_voting_policy) - .unwrap_or_default() + parse_presentation::( + self.presentation.as_ref(), + ) + .and_then(|p| p.delegated_voting_policy) + .unwrap_or_default() } } impl Name for ElectionEvent { fn get_name(&self, language: &str) -> String { - parse_presentation::(&self.presentation) - .and_then(|p| i18n_field(&p.i18n, language, "name")) - .unwrap_or_else(|| "-".into()) + parse_presentation::( + self.presentation.as_ref(), + ) + .and_then(|p| i18n_field(p.i18n.as_ref(), language, "name")) + .unwrap_or_else(|| "-".into()) } } /* ------------------------- Election ------------------------- */ impl Election { + #[must_use] + /// Get the default language from presentation for the election, + /// falling back to "en" if not specified. pub fn get_default_language(&self) -> String { - parse_presentation::(&self.presentation) + parse_presentation::(self.presentation.as_ref()) .and_then(|p| p.language_conf) .and_then(|lc| lc.default_language_code) .unwrap_or_else(|| DEFAULT_LANG.into()) @@ -104,8 +137,8 @@ impl Election { impl Name for Election { fn get_name(&self, language: &str) -> String { - parse_presentation::(&self.presentation) - .and_then(|p| i18n_field(&p.i18n, language, "name")) + parse_presentation::(self.presentation.as_ref()) + .and_then(|p| i18n_field(p.i18n.as_ref(), language, "name")) .unwrap_or_else(|| "-".into()) } } @@ -114,8 +147,8 @@ impl Alias for Election { fn get_alias(&self, language: &str) -> Option { let base = Some(self.get_name(language)); - parse_presentation::(&self.presentation) - .and_then(|p| i18n_field(&p.i18n, language, "alias")) + parse_presentation::(self.presentation.as_ref()) + .and_then(|p| i18n_field(p.i18n.as_ref(), language, "alias")) .or(base) } } @@ -124,30 +157,22 @@ impl Alias for Election { impl Name for Contest { fn get_name(&self, language: &str) -> String { - let alias = self - .alias_i18n - .clone() - .map(|alias_i18n| { - alias_i18n - .get(language) - .cloned() - .or(alias_i18n.get(DEFAULT_LANG).cloned()) - .or(Some(self.alias.clone())) - .flatten() - }) - .flatten(); - let name = self - .name_i18n - .clone() - .map(|name_i18n| { - name_i18n - .get(language) - .cloned() - .or(name_i18n.get(DEFAULT_LANG).cloned()) - .or(Some(self.name.clone())) - .flatten() - }) - .flatten(); + let alias = self.alias_i18n.clone().and_then(|alias_i18n| { + alias_i18n + .get(language) + .cloned() + .or(alias_i18n.get(DEFAULT_LANG).cloned()) + .or(Some(self.alias.clone())) + .flatten() + }); + let name = self.name_i18n.clone().and_then(|name_i18n| { + name_i18n + .get(language) + .cloned() + .or(name_i18n.get(DEFAULT_LANG).cloned()) + .or(Some(self.name.clone())) + .flatten() + }); alias.or(name).unwrap_or("-".into()) } diff --git a/packages/sequent-core/src/services/uuid_validation.rs b/packages/sequent-core/src/services/uuid_validation.rs index 57275d86091..21834812f0d 100644 --- a/packages/sequent-core/src/services/uuid_validation.rs +++ b/packages/sequent-core/src/services/uuid_validation.rs @@ -9,27 +9,30 @@ use uuid::Uuid; /// /// Returns the parsed `Uuid` on success, or an error if the string is not /// a valid UUID or is not version 4 (random). +/// +/// # Errors +/// Returns an error if the string is not a valid UUID or is not version 4. pub fn parse_uuid_v4(value: &str) -> anyhow::Result { let uuid = Uuid::parse_str(value) - .map_err(|e| anyhow!("invalid UUID '{}': {}", value, e))?; + .map_err(|e| anyhow!("invalid UUID '{value}': {e}"))?; match uuid.get_version() { Some(uuid::Version::Random) => Ok(uuid), - other => { - Err(anyhow!("UUID '{}' is not v4 (version: {:?})", value, other)) - } + other => Err(anyhow!("UUID '{value}' is not v4 (version: {other:?})")), } } /// Parses and validates that the given string is a valid v4 UUID, /// returning a descriptive error that includes the field name. +/// +/// # Errors +/// Returns an error if the string is not a valid UUID or is not version 4. pub fn parse_uuid_v4_field( value: &str, field_name: &str, ) -> anyhow::Result { - parse_uuid_v4(value).map_err(|e| { - anyhow!("invalid v4 UUID for field '{}': {}", field_name, e) - }) + parse_uuid_v4(value) + .map_err(|e| anyhow!("invalid v4 UUID for field '{field_name}': {e}")) } #[cfg(test)] diff --git a/packages/sequent-core/src/signatures/ecies_encrypt.rs b/packages/sequent-core/src/signatures/ecies_encrypt.rs index 98097b120f5..393cbd4b1fd 100644 --- a/packages/sequent-core/src/signatures/ecies_encrypt.rs +++ b/packages/sequent-core/src/signatures/ecies_encrypt.rs @@ -15,14 +15,22 @@ use strand::hash::hash_sha256; use tempfile::tempdir; use tracing::{info, instrument}; +/// The path to the ECIES tool JAR file. pub const ECIES_TOOL_PATH: &str = "/usr/local/bin/ecies-tool.jar"; #[derive(Debug, Clone, Serialize, Deserialize)] +/// Represents an ECIES key pair. pub struct EciesKeyPair { + /// The private key in PEM format. pub private_key_pem: String, + /// The public key in PEM format. pub public_key_pem: String, } #[instrument(skip(password), err)] +/// Encrypts a string using ECIES with the given public key and password. +/// +/// # Errors +/// Returns an error if encryption fails or file operations fail. pub fn ecies_encrypt_string( public_key_pem: &str, password: &str, @@ -42,19 +50,22 @@ pub fn ecies_encrypt_string( // Encode the &[u8] to a Base64 string let command = format!( - "java -jar {} encrypt {} {}", - ECIES_TOOL_PATH, temp_pem_file_string, password + "java -jar {ECIES_TOOL_PATH} encrypt {temp_pem_file_string} {password}" ); - info!("command: '{}'", command); + info!("command: '{command}'"); - let result = run_shell_command(&command)?.replace("\n", ""); + let result = run_shell_command(&command)?.replace('\n', ""); - info!("ecies_encrypt_string: '{}'", result); + info!("ecies_encrypt_string: '{result}'"); Ok(result) } #[instrument(err)] +/// Generates an ECIES key pair. +/// +/// # Errors +/// Returns an error if key generation fails or file operations fail. pub fn generate_ecies_key_pair() -> Result { let temp_private_pem_file = generate_temp_file("private_key", ".pem")?; let temp_private_pem_file_path = temp_private_pem_file.path(); @@ -67,10 +78,7 @@ pub fn generate_ecies_key_pair() -> Result { temp_public_pem_file_path.to_string_lossy().to_string(); let command = format!( - "java -jar {} create-keys {} {}", - ECIES_TOOL_PATH, - temp_public_pem_file_string, - temp_private_pem_file_string + "java -jar {ECIES_TOOL_PATH} create-keys {temp_public_pem_file_string} {temp_private_pem_file_string}" ); run_shell_command(&command)?; @@ -80,12 +88,16 @@ pub fn generate_ecies_key_pair() -> Result { info!("generate_ecies_key_pair(): public_key_pem: {public_key_pem:?}"); Ok(EciesKeyPair { - private_key_pem: private_key_pem, - public_key_pem: public_key_pem, + private_key_pem, + public_key_pem, }) } #[instrument(skip(data), err)] +/// Signs data using ECIES. +/// +/// # Errors +/// Returns an error if signing fails or file operations fail. pub fn ecies_sign_data( acm_key_pair: &EciesKeyPair, data: &str, @@ -119,24 +131,29 @@ pub fn ecies_sign_data( } let command = format!( - "java -jar {} sign {} {}", - ECIES_TOOL_PATH, temp_pem_file_string, temp_data_file_string + "java -jar {ECIES_TOOL_PATH} sign {temp_pem_file_string} {temp_data_file_string}" ); - let encrypted_base64 = run_shell_command(&command)?.replace("\n", ""); + let encrypted_base64 = run_shell_command(&command)?.replace('\n', ""); - info!("ecies_sign_data: '{}'", encrypted_base64); + info!("ecies_sign_data: '{encrypted_base64}'"); Ok(encrypted_base64) } -// A struct you can use to keep track of each item you want to sign +/// A struct you can use to keep track of each item you want to sign pub struct SignRequest { - pub id: String, // or any key you want, to correlate back - pub data: String, // the sign_data string + /// or any key you want, to correlate back + pub id: String, + /// the sign data string + pub data: String, } #[instrument(skip_all, err)] +/// Signs multiple data items using ECIES in bulk. +/// +/// # Errors +/// Returns an error if signing fails or file operations fail. pub fn ecies_sign_data_bulk( acm_key_pair: &EciesKeyPair, requests: &[SignRequest], @@ -164,14 +181,14 @@ pub fn ecies_sign_data_bulk( // to track (id -> filename). let mut file_map: HashMap = HashMap::new(); for (i, req) in requests.iter().enumerate() { - let filename = format!("sign_{:04}.txt", i); + let filename = format!("sign_{i:04}.txt"); let path = tmp_dir.path().join(&filename); { let mut f = File::create(&path) - .with_context(|| format!("Failed to create {}", filename))?; + .with_context(|| format!("Failed to create {filename}"))?; f.write_all(req.data.as_bytes()) - .with_context(|| format!("Failed to write {}", filename))?; + .with_context(|| format!("Failed to write {filename}"))?; } file_map.insert(req.id.clone(), path); @@ -188,7 +205,7 @@ pub fn ecies_sign_data_bulk( key = private_key_path.to_string_lossy(), folder = tmp_dir.path().to_string_lossy(), ); - info!("Running sign-bulk => {}", cmd); + info!("Running sign-bulk => {cmd}"); // 5. Execute the shell command (similar to your run_shell_command). run_shell_command(&cmd)?; @@ -197,7 +214,7 @@ pub fn ecies_sign_data_bulk( // sign_xxxx.txt, the tool should have produced sign_xxxx.txt.sign We'll // read them into a map of (id -> signature_base64) let mut signature_map = HashMap::new(); - for (id, path) in file_map.iter() { + for (id, path) in &file_map { // the Java tool will create the file with .sign appended let sign_file = path.with_extension("txt.sign"); if !sign_file.exists() { diff --git a/packages/sequent-core/src/signatures/mod.rs b/packages/sequent-core/src/signatures/mod.rs index c05e1a3708a..3e147fd8bca 100644 --- a/packages/sequent-core/src/signatures/mod.rs +++ b/packages/sequent-core/src/signatures/mod.rs @@ -2,5 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0-only +/// This module contains functions for ECIES encryption. pub mod ecies_encrypt; +/// This module contains functions for running shell commands related to signatures. pub mod shell; diff --git a/packages/sequent-core/src/signatures/shell.rs b/packages/sequent-core/src/signatures/shell.rs index a6a0c0382de..71e8d220672 100644 --- a/packages/sequent-core/src/signatures/shell.rs +++ b/packages/sequent-core/src/signatures/shell.rs @@ -6,13 +6,17 @@ use std::process::Command; use tracing::{info, instrument}; #[instrument(err, ret)] +/// Runs a shell command and returns its output as a string. +/// +/// # Errors +/// Returns an error if the command fails to execute or returns a non-zero exit code. pub fn run_shell_command(command: &str) -> Result { // Run the shell command let output = Command::new("sh").arg("-c").arg(command).output()?; // Check if the command was successful if !output.status.success() { - return Err(anyhow::anyhow!("Shell command failed: {:?}", output)); + return Err(anyhow::anyhow!("Shell command failed: {output:?}")); } // Convert the output to a string diff --git a/packages/sequent-core/src/sqlite/area.rs b/packages/sequent-core/src/sqlite/area.rs index ffaf27898ca..1564fe52be3 100644 --- a/packages/sequent-core/src/sqlite/area.rs +++ b/packages/sequent-core/src/sqlite/area.rs @@ -9,7 +9,11 @@ use serde_json::to_string; use tracing::instrument; #[instrument(err, skip_all)] -pub async fn create_area_sqlite( +/// Creates areas in a `SQLite` database. +/// +/// # Errors +/// Returns an error if the table creation or insertion fails. +pub fn create_area_sqlite( sqlite_transaction: &Transaction<'_>, areas: Vec, ) -> Result<()> { @@ -45,8 +49,12 @@ pub async fn create_area_sqlite( area.id, area.tenant_id, area.election_event_id, - area.created_at.as_ref().map(|dt| dt.to_string()), - area.last_updated_at.as_ref().map(|dt| dt.to_string()), + area.created_at + .as_ref() + .map(std::string::ToString::to_string), + area.last_updated_at + .as_ref() + .map(std::string::ToString::to_string), area.labels.as_ref().and_then(|v| to_string(v).ok()), area.annotations.as_ref().and_then(|v| to_string(v).ok()), area.name, diff --git a/packages/sequent-core/src/sqlite/area_contest.rs b/packages/sequent-core/src/sqlite/area_contest.rs index ce2e2fc4941..5a664ea1652 100644 --- a/packages/sequent-core/src/sqlite/area_contest.rs +++ b/packages/sequent-core/src/sqlite/area_contest.rs @@ -9,7 +9,11 @@ use rusqlite::{params, Transaction}; use tracing::instrument; #[instrument(err, skip_all)] -pub async fn create_area_contest_sqlite( +/// Creates area contests in a `SQLite` database. +/// +/// # Errors +/// Returns an error if the table creation or insertion fails. +pub fn create_area_contest_sqlite( sqlite_transaction: &Transaction<'_>, tenant_id: &str, election_event_id: &str, diff --git a/packages/sequent-core/src/sqlite/candidate.rs b/packages/sequent-core/src/sqlite/candidate.rs index 44512600636..7364aa3df34 100644 --- a/packages/sequent-core/src/sqlite/candidate.rs +++ b/packages/sequent-core/src/sqlite/candidate.rs @@ -10,7 +10,11 @@ use rusqlite::{params, Transaction as SqliteTransaction}; use tracing::instrument; #[instrument(err, skip_all)] -pub async fn create_candidate_sqlite( +/// Creates candidates in a `SQLite` database. +/// +/// # Errors +/// Returns an error if the table creation or insertion fails. +pub fn create_candidate_sqlite( sqlite_transaction: &SqliteTransaction<'_>, ) -> Result<()> { sqlite_transaction.execute_batch( @@ -37,11 +41,22 @@ pub async fn create_candidate_sqlite( } #[instrument(err, skip_all)] -pub async fn import_candidate_sqlite( +/// Imports candidates from a CSV file into a `SQLite` database. +/// +/// # Errors +/// Returns an error if the CSV cannot be read or insertion fails. +pub fn import_candidate_sqlite( sqlite_transaction: &SqliteTransaction<'_>, contests_csv: &Path, ) -> Result<()> { tokio::task::block_in_place(|| -> anyhow::Result<()> { + fn opt(i: &str) -> Option { + if i.is_empty() { + None + } else { + Some(i.to_string()) + } + } let mut insert = sqlite_transaction.prepare( "INSERT INTO candidate ( id, tenant_id, election_event_id, contest_id, created_at, last_updated_at, @@ -52,28 +67,19 @@ pub async fn import_candidate_sqlite( let mut rdr = ReaderBuilder::new() .has_headers(true) - .from_path(&contests_csv) + .from_path(contests_csv) .context("opening candidate CSV")?; for record in rdr.records() { let rec = record.context("CSV parse error")?; - fn opt(i: &str) -> Option { - if i.is_empty() { - None - } else { - Some(i.to_string()) - } - } - let is_public = match rec.get(13).unwrap_or("") { "t" | "true" => Some(true), "f" | "false" => Some(false), _ if rec.get(13).unwrap_or("").is_empty() => None, other => { return Err(anyhow!( - "Invalid boolean in is_public column: {}", - other + "Invalid boolean in is_public column: {other}" )); } }; diff --git a/packages/sequent-core/src/sqlite/contests.rs b/packages/sequent-core/src/sqlite/contests.rs index 52076ef4000..efc88c5d04f 100644 --- a/packages/sequent-core/src/sqlite/contests.rs +++ b/packages/sequent-core/src/sqlite/contests.rs @@ -9,7 +9,11 @@ use serde_json::to_string; use tracing::instrument; #[instrument(err, skip_all)] -pub async fn create_contest_sqlite( +/// Creates contests in a `SQLite` database. +/// +/// # Errors +/// Returns an error if the table creation or insertion fails. +pub fn create_contest_sqlite( sqlite_transaction: &Transaction<'_>, contests: Vec, ) -> Result<()> { @@ -64,8 +68,14 @@ CREATE TABLE contest ( contest.tenant_id, contest.election_event_id, contest.election_id, - contest.created_at.as_ref().map(|dt| dt.to_string()), - contest.last_updated_at.as_ref().map(|dt| dt.to_string()), + contest + .created_at + .as_ref() + .map(std::string::ToString::to_string), + contest + .last_updated_at + .as_ref() + .map(std::string::ToString::to_string), contest.labels.as_ref().and_then(|v| to_string(v).ok()), contest.annotations.as_ref().and_then(|v| to_string(v).ok()), contest.is_acclaimed, diff --git a/packages/sequent-core/src/sqlite/election.rs b/packages/sequent-core/src/sqlite/election.rs index 8cabbd2b06e..b30dfd2f0c0 100644 --- a/packages/sequent-core/src/sqlite/election.rs +++ b/packages/sequent-core/src/sqlite/election.rs @@ -9,7 +9,11 @@ use serde_json::to_string; use tracing::instrument; #[instrument(err, skip_all)] -pub async fn create_election_sqlite( +/// Creates elections in a `SQLite` database. +/// +/// # Errors +/// Returns an error if the table creation or insertion fails. +pub fn create_election_sqlite( sqlite_transaction: &Transaction<'_>, elections: Vec, ) -> Result<()> { @@ -67,9 +71,15 @@ pub async fn create_election_sqlite( // 3 election.election_event_id, // 4 - election.created_at.as_ref().map(|dt| dt.to_string()), + election + .created_at + .as_ref() + .map(std::string::ToString::to_string), // 5 - election.last_updated_at.as_ref().map(|dt| dt.to_string()), + election + .last_updated_at + .as_ref() + .map(std::string::ToString::to_string), // 6 election.labels.as_ref().and_then(|v| to_string(v).ok()), // 7 diff --git a/packages/sequent-core/src/sqlite/election_event.rs b/packages/sequent-core/src/sqlite/election_event.rs index e625218b201..b178593e456 100644 --- a/packages/sequent-core/src/sqlite/election_event.rs +++ b/packages/sequent-core/src/sqlite/election_event.rs @@ -9,7 +9,11 @@ use serde_json::to_string; use tracing::instrument; #[instrument(err, skip_all)] -pub async fn create_election_event_sqlite( +/// Creates an election event in a `SQLite` database. +/// +/// # Errors +/// Returns an error if the table creation or insertion fails. +pub fn create_election_event_sqlite( sqlite_transaction: &Transaction<'_>, election_event: ElectionEvent, ) -> Result<()> { diff --git a/packages/sequent-core/src/sqlite/mod.rs b/packages/sequent-core/src/sqlite/mod.rs index ddfd9729211..4354167e9ff 100644 --- a/packages/sequent-core/src/sqlite/mod.rs +++ b/packages/sequent-core/src/sqlite/mod.rs @@ -2,18 +2,47 @@ // // SPDX-License-Identifier: AGPL-3.0-only +/// `SQLite` access layer for areas. pub mod area; + +/// `SQLite` access layer for the relationship between areas and contests. pub mod area_contest; + +/// `SQLite` access layer for candidates participating in contests. pub mod candidate; + +/// `SQLite` access layer for contest. pub mod contests; + +/// `SQLite` access layer for election. pub mod election; + +/// `SQLite` access layer for election events. pub mod election_event; + +/// `SQLite` access layer for aggregated results per area and contest. pub mod results_area_contest; + +/// `SQLite` access layer for candidate-level results within an area contest. pub mod results_area_contest_candidate; + +/// `SQLite` access layer for overall contest results. pub mod results_contest; + +/// `SQLite` access layer for candidate-level results within a contest. pub mod results_contest_candidate; + +/// `SQLite` access layer for overall election results. pub mod results_election; + +/// `SQLite` access layer for results aggregated by election and area. pub mod results_election_area; + +/// `SQLite` access layer for event-based results and updates. pub mod results_event; + +/// `SQLite` access layer for tally session resolutions and outcomes. pub mod tally_session_resolution; + +/// Shared helpers pub mod utils; diff --git a/packages/sequent-core/src/sqlite/results_area_contest.rs b/packages/sequent-core/src/sqlite/results_area_contest.rs index d35a77551d4..8a46e6430bf 100644 --- a/packages/sequent-core/src/sqlite/results_area_contest.rs +++ b/packages/sequent-core/src/sqlite/results_area_contest.rs @@ -9,7 +9,11 @@ use serde_json::to_string; use tracing::instrument; #[instrument(err, skip_all)] -pub async fn create_results_area_contests_sqlite( +/// Creates results area contests in a `SQLite` database. +/// +/// # Errors +/// Returns an error if the table creation or insertion fails. +pub fn create_results_area_contests_sqlite( sqlite_transaction: &Transaction<'_>, area_contests: Vec, ) -> Result> { @@ -99,7 +103,12 @@ pub async fn create_results_area_contests_sqlite( } #[instrument(err, skip_all)] -pub async fn update_results_area_contest_documents_sqlite( +/// Updates the documents for a results area contest in a `SQLite` database. +/// +/// # Errors +/// Returns an error if the update fails. +#[allow(clippy::too_many_arguments)] +pub fn update_results_area_contest_documents_sqlite( sqlite_transaction: &Transaction<'_>, tenant_id: &str, results_event_id: &str, @@ -110,7 +119,7 @@ pub async fn update_results_area_contest_documents_sqlite( documents: &ResultDocuments, ) -> Result<()> { let docs_json = to_string(documents) - .map_err(|e| anyhow!("Failed to serialize documents to JSON: {}", e))?; + .map_err(|e| anyhow!("Failed to serialize documents to JSON: {e}"))?; let insert_count = sqlite_transaction.execute( " @@ -139,8 +148,7 @@ pub async fn update_results_area_contest_documents_sqlite( 1 => Ok(()), 0 => Err(anyhow!("Rows not found in table results_area_contest")), count => Err(anyhow!( - "Too many affected rows in table results_area_contest: {}", - count + "Too many affected rows in table results_area_contest: {count}" )), } } diff --git a/packages/sequent-core/src/sqlite/results_area_contest_candidate.rs b/packages/sequent-core/src/sqlite/results_area_contest_candidate.rs index e797d054c74..5b9e2bddefb 100644 --- a/packages/sequent-core/src/sqlite/results_area_contest_candidate.rs +++ b/packages/sequent-core/src/sqlite/results_area_contest_candidate.rs @@ -8,7 +8,11 @@ use rusqlite::{params, Transaction}; use tracing::instrument; #[instrument(err, skip_all)] -pub async fn create_results_area_contest_candidates_sqlite( +/// Creates results area contest candidates in a `SQLite` database. +/// +/// # Errors +/// Returns an error if the table creation or insertion fails. +pub fn create_results_area_contest_candidates_sqlite( sqlite_transaction: &Transaction<'_>, area_contest_candidates: Vec, ) -> Result<()> { diff --git a/packages/sequent-core/src/sqlite/results_contest.rs b/packages/sequent-core/src/sqlite/results_contest.rs index 37280cdd62c..77ede8df1c9 100644 --- a/packages/sequent-core/src/sqlite/results_contest.rs +++ b/packages/sequent-core/src/sqlite/results_contest.rs @@ -10,7 +10,11 @@ use serde_json::to_string; use tracing::instrument; #[instrument(err, skip_all)] -pub async fn create_results_contest_sqlite( +/// Creates results contests in a `SQLite` database. +/// +/// # Errors +/// Returns an error if the table creation or insertion fails. +pub fn create_results_contest_sqlite( sqlite_transaction: &Transaction<'_>, contests: Vec, ) -> Result<()> { @@ -106,7 +110,11 @@ pub async fn create_results_contest_sqlite( } #[instrument(err, skip_all)] -pub async fn update_results_contest_documents_sqlite( +/// Updates the documents for a results contest in a `SQLite` database. +/// +/// # Errors +/// Returns an error if the update fails. +pub fn update_results_contest_documents_sqlite( sqlite_transaction: &Transaction<'_>, tenant_id: &str, results_event_id: &str, @@ -116,7 +124,7 @@ pub async fn update_results_contest_documents_sqlite( documents: &ResultDocuments, ) -> Result<()> { let docs_json = to_string(documents) - .map_err(|e| anyhow!("Failed to serialize documents to JSON: {}", e))?; + .map_err(|e| anyhow!("Failed to serialize documents to JSON: {e}"))?; let insert_count = sqlite_transaction.execute( " @@ -143,8 +151,7 @@ pub async fn update_results_contest_documents_sqlite( 1 => Ok(()), 0 => Err(anyhow!("Rows not found in table results_contest")), count => Err(anyhow!( - "Too many affected rows in table results_contest: {}", - count + "Too many affected rows in table results_contest: {count}" )), } } diff --git a/packages/sequent-core/src/sqlite/results_contest_candidate.rs b/packages/sequent-core/src/sqlite/results_contest_candidate.rs index 3ff828add6b..1ac3723f5e7 100644 --- a/packages/sequent-core/src/sqlite/results_contest_candidate.rs +++ b/packages/sequent-core/src/sqlite/results_contest_candidate.rs @@ -8,7 +8,11 @@ use rusqlite::{params, Transaction}; use tracing::instrument; #[instrument(err, skip_all)] -pub async fn create_results_contest_candidates_sqlite( +/// Creates results contest candidates in a `SQLite` database. +/// +/// # Errors +/// Returns an error if the table creation or insertion fails. +pub fn create_results_contest_candidates_sqlite( sqlite_transaction: &Transaction<'_>, contest_candidates: Vec, ) -> Result<()> { diff --git a/packages/sequent-core/src/sqlite/results_election.rs b/packages/sequent-core/src/sqlite/results_election.rs index 982794ec5af..b42f5d3415f 100644 --- a/packages/sequent-core/src/sqlite/results_election.rs +++ b/packages/sequent-core/src/sqlite/results_election.rs @@ -9,7 +9,11 @@ use serde_json::to_string; use tracing::instrument; #[instrument(err, skip_all)] -pub async fn create_results_election_sqlite( +/// Creates results elections in a `SQLite` database. +/// +/// # Errors +/// Returns an error if the table creation or insertion fails. +pub fn create_results_election_sqlite( sqlite_transaction: &Transaction<'_>, elections: Vec, ) -> Result<()> { @@ -63,7 +67,11 @@ pub async fn create_results_election_sqlite( } #[instrument(err, skip_all)] -pub async fn update_results_election_documents_sqlite( +/// Updates the documents for a results election in a `SQLite` database. +/// +/// # Errors +/// Returns an error if the update fails. +pub fn update_results_election_documents_sqlite( sqlite_transaction: &Transaction<'_>, tenant_id: &str, results_event_id: &str, @@ -73,7 +81,7 @@ pub async fn update_results_election_documents_sqlite( json_hash: &str, ) -> Result<()> { let docs_json = to_string(documents) - .map_err(|e| anyhow!("Failed to serialize documents to JSON: {}", e))?; + .map_err(|e| anyhow!("Failed to serialize documents to JSON: {e}"))?; let insert_count = sqlite_transaction.execute( " @@ -103,8 +111,7 @@ pub async fn update_results_election_documents_sqlite( 1 => Ok(()), 0 => Err(anyhow!("Rows not found in table results_election")), n => Err(anyhow!( - "Too many affected rows in table results_election: {}", - n + "Too many affected rows in table results_election: {n}" )), } } diff --git a/packages/sequent-core/src/sqlite/results_election_area.rs b/packages/sequent-core/src/sqlite/results_election_area.rs index edd6ac2c637..ae68bd7da5d 100644 --- a/packages/sequent-core/src/sqlite/results_election_area.rs +++ b/packages/sequent-core/src/sqlite/results_election_area.rs @@ -7,8 +7,13 @@ use rusqlite::{params, Transaction}; use serde_json::to_string; use tracing::instrument; +/// Creates results election area in a `SQLite` database. +/// +/// # Errors +/// Returns an error if the table creation or insertion fails. #[instrument(err, skip_all)] -pub async fn create_results_election_area_sqlite( +#[allow(clippy::too_many_arguments)] +pub fn create_results_election_area_sqlite( sqlite_transaction: &Transaction<'_>, tenant_id: &str, results_event_id: &str, @@ -46,7 +51,7 @@ pub async fn create_results_election_area_sqlite( )?; let docs_json = to_string(documents) - .map_err(|e| anyhow!("Failed to serialize documents to JSON: {}", e))?; + .map_err(|e| anyhow!("Failed to serialize documents to JSON: {e}"))?; insert.execute(params![ tenant_id, diff --git a/packages/sequent-core/src/sqlite/results_event.rs b/packages/sequent-core/src/sqlite/results_event.rs index 0477105b45f..c934ca01cd1 100644 --- a/packages/sequent-core/src/sqlite/results_event.rs +++ b/packages/sequent-core/src/sqlite/results_event.rs @@ -11,7 +11,11 @@ use serde_json::to_string; use tracing::instrument; #[instrument(err, skip_all)] -pub async fn create_results_event_sqlite( +/// Creates a results event in a `SQLite` database. +/// +/// # Errors +/// Returns an error if the table creation or insertion fails. +pub fn create_results_event_sqlite( sqlite_transaction: &Transaction<'_>, tenant_id: &str, election_event_id: &str, @@ -48,6 +52,10 @@ pub async fn create_results_event_sqlite( Ok(results_event_id.to_string()) } +/// Finds a results event in a `SQLite` database. +/// +/// # Errors +/// Returns an error if the query fails or the event is not found. pub fn find_results_event_sqlite( sqlite_transaction: &Transaction<'_>, tenant_id: &str, @@ -106,6 +114,10 @@ pub fn find_results_event_sqlite( } #[instrument(err, skip_all)] +/// Updates the documents for a results event in a `SQLite` database. +/// +/// # Errors +/// Returns an error if the update fails. pub fn update_results_event_documents_sqlite( sqlite_transaction: &Transaction<'_>, tenant_id: &str, @@ -114,7 +126,7 @@ pub fn update_results_event_documents_sqlite( documents: &ResultDocuments, ) -> Result<()> { let docs_json = to_string(documents) - .map_err(|e| anyhow!("Failed to serialize documents to JSON: {}", e))?; + .map_err(|e| anyhow!("Failed to serialize documents to JSON: {e}"))?; let insert_count = sqlite_transaction.execute( " @@ -131,8 +143,7 @@ pub fn update_results_event_documents_sqlite( 1 => Ok(()), 0 => Err(anyhow!("Rows not found in table results_event")), count => Err(anyhow!( - "Too many affected rows in table results_event: {}", - count + "Too many affected rows in table results_event: {count}" )), } } diff --git a/packages/sequent-core/src/sqlite/tally_session_resolution.rs b/packages/sequent-core/src/sqlite/tally_session_resolution.rs index 6983f9665d3..be6c9a15b88 100644 --- a/packages/sequent-core/src/sqlite/tally_session_resolution.rs +++ b/packages/sequent-core/src/sqlite/tally_session_resolution.rs @@ -9,7 +9,11 @@ use serde_json::to_string; use tracing::instrument; #[instrument(err, skip_all)] -pub async fn create_tally_session_resolutions_sqlite( +/// Creates tally session resolutions in a `SQLite` database. +/// +/// # Errors +/// Returns an error if the table creation or insertion fails. +pub fn create_tally_session_resolutions_sqlite( sqlite_transaction: &Transaction<'_>, resolutions: Vec, ) -> Result<()> { diff --git a/packages/sequent-core/src/sqlite/utils.rs b/packages/sequent-core/src/sqlite/utils.rs index 7bfab3fc429..e445cf1c4d4 100644 --- a/packages/sequent-core/src/sqlite/utils.rs +++ b/packages/sequent-core/src/sqlite/utils.rs @@ -5,10 +5,14 @@ use ordered_float::NotNan; use serde_json::{to_string, Value}; +#[must_use] +/// Converts an `Option` to an `Option`. pub fn opt_json(opt: &Option) -> Option { opt.as_ref().and_then(|v| to_string(v).ok()) } +#[must_use] +/// Converts an `Option>` to an `Option`. pub fn opt_f64(opt: &Option>) -> Option { - opt.map(|n| n.into_inner()) + opt.map(ordered_float::NotNan::into_inner) } diff --git a/packages/sequent-core/src/temp_path.rs b/packages/sequent-core/src/temp_path.rs index 5e27c90215a..4786e6c3938 100644 --- a/packages/sequent-core/src/temp_path.rs +++ b/packages/sequent-core/src/temp_path.rs @@ -10,26 +10,48 @@ use tempfile::Builder; use tempfile::{NamedTempFile, TempPath}; use tracing::{event, instrument, Level}; -pub const QR_CODE_TEMPLATE: &'static str = "

"; -pub const LOGO_TEMPLATE: &'static str = "
"; -pub const PUBLIC_ASSETS_LOGO_IMG: &'static str = "sequent-logo.svg"; -pub const PUBLIC_ASSETS_QRCODE_LIB: &'static str = "qrcode.min.js"; -pub const PUBLIC_ASSETS_VELVET_BALLOT_IMAGES_TEMPLATE: &'static str = +/// The template for the QR code in the public assets. +pub const QR_CODE_TEMPLATE: &str = "
"; +/// The template html for the logo in the public assets. +pub const LOGO_TEMPLATE: &str = "
"; +/// The filename for the logo image in the public assets. +pub const PUBLIC_ASSETS_LOGO_IMG: &str = "sequent-logo.svg"; +/// The filename for the QR code library in the public assets. +pub const PUBLIC_ASSETS_QRCODE_LIB: &str = "qrcode.min.js"; +/// The template for the ballot images page for the ballot images report. +pub const PUBLIC_ASSETS_VELVET_BALLOT_IMAGES_TEMPLATE: &str = "ballot_images_user.hbs"; -pub const PUBLIC_ASSETS_VELVET_BALLOT_IMAGES_TEMPLATE_SYSTEM: &'static str = + +/// The system template for the ballot images page for the ballot images report. +pub const PUBLIC_ASSETS_VELVET_BALLOT_IMAGES_TEMPLATE_SYSTEM: &str = "ballot_images_system.hbs"; -pub const PUBLIC_ASSETS_VELVET_MC_BALLOT_IMAGES_TEMPLATE: &'static str = + +/// The template for the ballot images page for the MC ballot images report. +pub const PUBLIC_ASSETS_VELVET_MC_BALLOT_IMAGES_TEMPLATE: &str = "mc_ballot_images_user.hbs"; -pub const PUBLIC_ASSETS_EML_BASE_TEMPLATE: &'static str = "eml_base.hbs"; -pub const VELVET_BALLOT_IMAGES_TEMPLATE_TITLE: &'static str = + +/// The base template for EML documents. +pub const PUBLIC_ASSETS_EML_BASE_TEMPLATE: &str = "eml_base.hbs"; + +/// The title for the ballot images template. +pub const VELVET_BALLOT_IMAGES_TEMPLATE_TITLE: &str = "Ballot images - Sequentech"; -pub const PUBLIC_ASSETS_I18N_DEFAULTS: &'static str = "i18n_defaults.json"; -pub const PUBLIC_ASSETS_INITIALIZATION_TEMPLATE_SYSTEM: &'static str = +/// The default i18n JSON file for public assets. +pub const PUBLIC_ASSETS_I18N_DEFAULTS: &str = "i18n_defaults.json"; + +/// The system template for the initialization report. +pub const PUBLIC_ASSETS_INITIALIZATION_TEMPLATE_SYSTEM: &str = "initialization_report_system.hbs"; -pub const PUBLIC_ASSETS_ELECTORAL_RESULTS_TEMPLATE_SYSTEM: &'static str = + +/// The system template for the electoral results report. +pub const PUBLIC_ASSETS_ELECTORAL_RESULTS_TEMPLATE_SYSTEM: &str = "electoral_results_system.hbs"; +/// Gets the public assets path from the environment variable. +/// +/// # Errors +/// Returns an error if the environment variable is not set or invalid. pub fn get_public_assets_path_env_var() -> Result { match env::var("PUBLIC_ASSETS_PATH") { Ok(path) => Ok(path), @@ -37,7 +59,10 @@ pub fn get_public_assets_path_env_var() -> Result { .with_context(|| "Error fetching PUBLIC_ASSETS_PATH env var")?, } } - +/// Gets the file size for the given file path. +/// +/// # Errors +/// Returns an error if the file does not exist or cannot be accessed. pub fn get_file_size(filepath: &str) -> Result { let metadata = fs::metadata(filepath)?; Ok(metadata.len()) @@ -55,8 +80,10 @@ pub fn get_file_size(filepath: &str) -> Result { * caller to control the lifetime of the created temp file. */ #[instrument(skip(data), err)] +/// # Errors +/// Returns an error if the file cannot be created or written. pub fn write_into_named_temp_file( - data: &Vec, + data: &[u8], prefix: &str, suffix: &str, ) -> Result<(TempPath, String, u64)> { @@ -68,7 +95,7 @@ pub fn write_into_named_temp_file( .with_context(|| "Couldn't reopen file for writing")?; let mut buf_writer = BufWriter::new(file2); buf_writer - .write(&data) + .write(data) .with_context(|| "Error writing into named temp file")?; buf_writer .flush() @@ -82,6 +109,10 @@ pub fn write_into_named_temp_file( } // #[instrument(ret)] +/// Generates a named temporary file with the given prefix and suffix. +/// +/// # Errors +/// Returns an error if the file cannot be created. pub fn generate_temp_file(prefix: &str, suffix: &str) -> Result { // Get the system's temporary directory. let temp_dir = env::temp_dir(); @@ -100,6 +131,10 @@ pub fn generate_temp_file(prefix: &str, suffix: &str) -> Result { } #[instrument(err)] +/// Reads the contents of a named temporary file. +/// +/// # Errors +/// Returns an error if the file cannot be read. pub fn read_temp_file(temp_file: &mut NamedTempFile) -> Result> { // Rewind the file to the beginning to read its contents temp_file.rewind()?; @@ -111,6 +146,10 @@ pub fn read_temp_file(temp_file: &mut NamedTempFile) -> Result> { } #[instrument(err)] +/// Reads the contents of a temporary file path. +/// +/// # Errors +/// Returns an error if the file cannot be read. pub fn read_temp_path(temp_path: &TempPath) -> Result> { let mut file = File::open(temp_path)?; let mut buffer = Vec::new(); diff --git a/packages/sequent-core/src/types/ceremonies.rs b/packages/sequent-core/src/types/ceremonies.rs index 2ff8dcbd9dd..d3b2d52ed3c 100644 --- a/packages/sequent-core/src/types/ceremonies.rs +++ b/packages/sequent-core/src/types/ceremonies.rs @@ -10,6 +10,7 @@ use serde_json::Value; use std::default::Default; use strum_macros::{Display, EnumString}; +/// Represents the status of the keys ceremony execution. #[derive( Display, Serialize, @@ -22,46 +23,66 @@ use strum_macros::{Display, EnumString}; Default, )] pub enum KeysCeremonyExecutionStatus { - USER_CONFIGURATION, // user can configure the ceremony at this step + /// User can configure the ceremony at this step + USER_CONFIGURATION, + /// Process starts but the config message hasn't been added to the board. #[default] - STARTED, /* process starts but the config message hasn't - * been added to the board */ - IN_PROGRESS, /* config message has been added to the board and trustees - * are working */ - SUCCESS, // successful completion - CANCELLED, // cancelation + STARTED, + /// Config message has been added to the board and trustees are working. + IN_PROGRESS, + /// Successful completion. + SUCCESS, + /// Ceremony was cancelled. + CANCELLED, } +/// Audit/event log entry for ceremonies, used for timestamped messages in ceremony workflows. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct Log { + /// Date the log was created. pub created_date: String, + /// Log message text. pub log_text: String, } +/// Represents the status of a trustee in the keys ceremony. #[derive( Display, Serialize, Deserialize, Debug, PartialEq, Eq, Clone, EnumString, )] pub enum TrusteeStatus { + /// Trustee is waiting to act. WAITING, + /// Trustee has generated their key. KEY_GENERATED, + /// Trustee has retrieved their key. KEY_RETRIEVED, + /// Trustee has checked their key. KEY_CHECKED, } +/// Represents a participant in the keys ceremony. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct Trustee { + /// Name of the trustee. pub name: String, + /// Status of the trustee. pub status: TrusteeStatus, } +/// Represents the state of a keys ceremony. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct KeysCeremonyStatus { + /// Date the ceremony was stopped, if any. pub stop_date: Option, + /// Public key generated by the ceremony, if any. pub public_key: Option, + /// Logs for the ceremony. pub logs: Vec, + /// Trustees participating in the ceremony. pub trustees: Vec, } +/// Represents the status of the tally execution. #[derive( Display, Serialize, @@ -75,15 +96,22 @@ pub struct KeysCeremonyStatus { JsonSchema, )] pub enum TallyExecutionStatus { + /// Tally process has started. #[default] STARTED, + /// Tally process is connected. CONNECTED, + /// Tally process is in progress. IN_PROGRESS, + /// Tally process is waiting for election officials selection for winner determination. AWAITING_INPUT, + /// Tally process completed successfully. SUCCESS, + /// Tally process was cancelled. CANCELLED, } +/// Status of a trustee in the tally ceremony #[derive( Display, Serialize, @@ -96,17 +124,23 @@ pub enum TallyExecutionStatus { Default, )] pub enum TallyTrusteeStatus { + /// Trustee is waiting to act. #[default] WAITING, + /// Trustee has restored their key. KEY_RESTORED, } +/// Represents a participant in the tally ceremony. #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct TallyTrustee { + /// Name of the trustee. pub name: String, + /// Status of the trustee. pub status: TallyTrusteeStatus, } +/// Status of the tally execution for an election (waiting, mixing, decrypting, success, error). #[derive( Display, Serialize, @@ -119,29 +153,44 @@ pub struct TallyTrustee { Default, )] pub enum TallyElectionStatus { + /// Tally is waiting to start. #[default] WAITING, + /// Tally is mixing ballots. MIXING, + /// Tally is decrypting results. DECRYPTING, + /// Tally completed successfully. SUCCESS, + /// Tally encountered an error. ERROR, } +/// Represents the status of an election in a tally ceremony. #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct TallyElection { + /// Election identifier. pub election_id: String, + /// Status of the election tally. pub status: TallyElectionStatus, + /// Progress of the election tally (0.0 to 1.0). pub progress: f64, } +/// Represents the state of a tally ceremony #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct TallyCeremonyStatus { + /// Date the ceremony was stopped, if any. pub stop_date: Option, + /// Logs for the ceremony. pub logs: Vec, + /// Trustees participating in the ceremony. pub trustees: Vec, + /// Status of each election in the ceremony. pub elections_status: Vec, } +/// Distinguishes the type of tally report generated #[derive( Display, Serialize, @@ -155,19 +204,25 @@ pub struct TallyCeremonyStatus { JsonSchema, )] pub enum TallyType { + /// Electoral results report. #[default] #[strum(serialize = "ELECTORAL_RESULTS")] ELECTORAL_RESULTS, + /// Initialization report. #[strum(serialize = "INITIALIZATION_REPORT")] INITIALIZATION_REPORT, } +/// Holds paths to output documentss generated during a tally session #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] pub struct TallySessionDocuments { + /// Path to the `sqlite` document, if any. pub sqlite: Option, + /// Path to the `xlsx` document, if any. pub xlsx: Option, } +/// Configures whether ceremonies are conducted manually or automatically. #[derive( BorshSerialize, BorshDeserialize, @@ -183,15 +238,18 @@ pub struct TallySessionDocuments { JsonSchema, )] pub enum CeremoniesPolicy { + /// Ceremonies are conducted manually. #[default] #[strum(serialize = "manual-ceremonies")] #[serde(rename = "manual-ceremonies")] MANUAL_CEREMONIES, + /// Ceremonies are conducted automatically. #[strum(serialize = "automated-ceremonies")] #[serde(rename = "automated-ceremonies")] AUTOMATED_CEREMONIES, } +/// Specifies the operation performed during tallying. #[derive( Debug, Display, @@ -206,27 +264,30 @@ pub enum CeremoniesPolicy { BorshDeserialize, )] pub enum TallyOperation { + /// Process ballots to calculate candidate results and participation statistics. #[strum(serialize = "process-ballots-all")] #[serde(rename = "process-ballots-all")] - ProcessBallotsAll, /* Process ballots to calculate Candidate Results - * and participation - * statistics */ + ProcessBallotsAll, + /// Aggregate results that have been processed in every area. #[strum(serialize = "aggregate-results")] #[serde(rename = "aggregate-results")] - AggregateResults, /* Aggregate results that have been processed in - * every area */ + AggregateResults, + /// Calculate participation statistics without candidate results. #[strum(serialize = "skip-candidate-results")] #[serde(rename = "skip-candidate-results")] - SkipCandidateResults, /* Needs the ballots to calculate participation - * statistics but without the Candidate Results */ + SkipCandidateResults, } +/// Specifies whether a tally operation is scoped to an area or a contest. #[derive(Debug, Display)] pub enum ScopeOperation { + /// Operation for an area. Area(TallyOperation), + /// Operation for a contest. Contest(TallyOperation), } +/// Specifies the counting algorithm used for tallying votes in an election. #[derive( Debug, Clone, @@ -238,8 +299,11 @@ pub enum ScopeOperation { PartialEq, Eq, )] +/// Specifies the method used to break ties in preferential voting. pub enum TieBreakingMethod { + /// Random selection among tied candidates. Random, + /// Election officials use hat procedure to select winner among tied candidates. ExternalProcedure, } @@ -254,11 +318,17 @@ pub enum TieBreakingMethod { PartialEq, Eq, )] +/// Data related to the resolution of a tie in a tally session. pub struct TallySessionResolutionData { + /// Round number in which the tie occurred, if applicable. pub round_number: Option, + /// Candidate IDs that were tied. pub tied_candidate_ids: Vec, + /// Number of votes each tied candidate received. pub vote_count: u64, + /// Method used to break the tie. pub method_used: TieBreakingMethod, + /// Candidate ID selected as the winner of the tie, if resolved. pub resolved_by_candidate_id: Option, } @@ -267,7 +337,9 @@ pub struct TallySessionResolutionData { )] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] +/// Types of resolutions that can be applied to a tally session. pub enum TallySessionResolutionType { + /// Resolution based on a IRV tiebreaking method. IrvTieBreak, } @@ -276,32 +348,54 @@ pub enum TallySessionResolutionType { )] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] +/// Status of a tally session resolution. pub enum TallySessionResolutionStatus { + /// Resolution is pending and has not been applied yet. Pending, + /// Resolution has been resolved and applied to the tally session. Resolved, } #[derive(Debug, Clone, Serialize, Deserialize)] +/// Represents the resolution of a tally session, +/// including details about the tie and how it was resolved. pub struct TallySessionResolution { + /// Unique identifier for the tally session resolution. pub id: String, + /// Identifier for the tenant associated with the tally session. pub tenant_id: String, + /// Identifier for the election event associated with the tally session. pub election_event_id: String, + /// Identifier for the tally session associated with this resolution. pub tally_session_id: String, + /// Identifier for the contest associated with this resolution, if applicable. pub contest_id: Option, + /// Timestamp when the tally session resolution was created. pub created_at: Option>, + /// Timestamp when the tally session resolution was last updated. pub last_updated_at: Option>, + /// Type of resolution applied to the tally session. pub resolution_type: TallySessionResolutionType, + /// Status of the tally session resolution. pub status: TallySessionResolutionStatus, + /// Data related to the resolution of the tally session, if applicable. pub resolution_data: Option, + /// Identifier for the user who resolved the tally session. pub resolved_by_user: Option, + /// Timestamp when the tally session resolution was resolved. pub resolved_at: Option>, + /// Labels associated with the tally session resolution. pub labels: Option, + /// Annotations associated with the tally session resolution. pub annotations: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] +/// Represents the resolution of a tally for a specific contest, including the selected candidate. pub struct TallyResolution { + /// contest ID for which the tally resolution applies. pub contest_id: String, + /// candidate ID that was selected as the winner of the contest after resolution. pub selected_candidate_id: String, } @@ -320,35 +414,46 @@ pub struct TallyResolution { Clone, Copy, )] +/// Specifies the counting algorithm used for tallying votes. pub enum CountingAlgType { + /// Plurality-at-large voting. #[strum(serialize = "plurality-at-large")] #[serde(rename = "plurality-at-large")] #[default] PluralityAtLarge, + /// Instant-runoff voting (ranked-choice). #[strum(serialize = "instant-runoff")] #[serde(rename = "instant-runoff")] InstantRunoff, + /// Borda count (Nauru variant). #[strum(serialize = "borda-nauru")] #[serde(rename = "borda-nauru")] BordaNauru, + /// Borda count. #[strum(serialize = "borda")] #[serde(rename = "borda")] Borda, + /// Borda count (Mas Madrid variant). #[strum(serialize = "borda-mas-madrid")] #[serde(rename = "borda-mas-madrid")] BordaMasMadrid, + /// Pairwise beta method. #[strum(serialize = "pairwise-beta")] #[serde(rename = "pairwise-beta")] PairwiseBeta, + /// Desborda3 method. #[strum(serialize = "desborda3")] #[serde(rename = "desborda3")] Desborda3, + /// Desborda2 method. #[strum(serialize = "desborda2")] #[serde(rename = "desborda2")] Desborda2, + /// Desborda method. #[strum(serialize = "desborda")] #[serde(rename = "desborda")] Desborda, + /// Cumulative voting. #[strum(serialize = "cumulative")] #[serde(rename = "cumulative")] Cumulative, @@ -356,7 +461,8 @@ pub enum CountingAlgType { impl CountingAlgType { /// Returns true if the counting algorithm is preferential (ranked-choice). - pub fn is_preferential(&self) -> bool { + #[must_use] + pub const fn is_preferential(&self) -> bool { matches!( self, CountingAlgType::InstantRunoff @@ -370,7 +476,11 @@ impl CountingAlgType { ) } - pub fn get_default_tally_operation_for_contest(&self) -> TallyOperation { + /// Returns the default tally operation for a contest based on the algorithm. + #[must_use] + pub const fn get_default_tally_operation_for_contest( + &self, + ) -> TallyOperation { if self.is_preferential() { TallyOperation::ProcessBallotsAll } else { @@ -378,7 +488,9 @@ impl CountingAlgType { } } - pub fn get_default_tally_operation_for_area(&self) -> TallyOperation { + /// Returns the default tally operation for an area based on the algorithm. + #[must_use] + pub const fn get_default_tally_operation_for_area(&self) -> TallyOperation { if self.is_preferential() { TallyOperation::SkipCandidateResults } else { diff --git a/packages/sequent-core/src/types/date_time.rs b/packages/sequent-core/src/types/date_time.rs index 67a3bd4c9e6..08bd82a54df 100644 --- a/packages/sequent-core/src/types/date_time.rs +++ b/packages/sequent-core/src/types/date_time.rs @@ -4,35 +4,37 @@ use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize, Debug, Clone)] +/// Represents a timezone for date/time formatting and conversion. +/// Used to specify UTC or a fixed offset in hours for formatting timestamps and reports. +#[derive(Serialize, Deserialize, Debug, Clone, Default)] pub enum TimeZone { + /// UTC timezone. + #[default] UTC, - Offset(i32), // Offset in hours, e.g., +1 or -4 + /// Fixed offset in hours from UTC (e.g., +1 or -4). + Offset(i32), } -#[derive(Serialize, Deserialize, Debug, Clone)] +/// Represents a date/time format for displaying or parsing timestamps. +/// Used to control the string format for dates in reports, logs, and receipts. +#[derive(Serialize, Deserialize, Debug, Clone, Default)] pub enum DateFormat { + /// Day/Month/Year (2-digit) Hour:Minute. DdMmYyHhMm, + /// Day/Month/Year (4-digit) Hour:Minute. + #[default] DdMmYyyyHhMm, + /// Month/Day/Year (2-digit) Hour:Minute. MmDdYyHhMm, + /// Month/Day/Year (4-digit) Hour:Minute. MmDdYyyyHhMm, + /// Custom format string. Custom(String), - Default, -} - -impl Default for TimeZone { - fn default() -> Self { - TimeZone::UTC - } -} - -impl Default for DateFormat { - fn default() -> Self { - DateFormat::DdMmYyyyHhMm - } } impl DateFormat { + #[must_use] + /// Converts the `DateFormat` enum variant to a corresponding format string. pub fn to_format_string(&self) -> String { match self { DateFormat::DdMmYyHhMm => "%d/%m/%y %H:%M".to_string(), @@ -40,7 +42,6 @@ impl DateFormat { DateFormat::MmDdYyHhMm => "%m/%d/%y %H:%M".to_string(), DateFormat::MmDdYyyyHhMm => "%m/%d/%Y %H:%M".to_string(), DateFormat::Custom(fmt) => fmt.clone(), - DateFormat::Default => "%d/%m/%Y %H:%M".to_string(), } } } diff --git a/packages/sequent-core/src/types/error.rs b/packages/sequent-core/src/types/error.rs index 541fbc6f89a..99176d37efd 100644 --- a/packages/sequent-core/src/types/error.rs +++ b/packages/sequent-core/src/types/error.rs @@ -3,22 +3,28 @@ // SPDX-License-Identifier: AGPL-3.0-only quick_error! { + #[doc = "Error type used throughout the project for unified error handling. Wraps common error types and provides conversion from anyhow, string, file access, and integer conversion errors."] #[derive(Debug)] pub enum Error { + /// Wrapper for `anyhow::Error`. Anyhow(err: anyhow::Error) { from() } + /// Wrapper for string-based errors. String(err: String) { from() from(err: &str) -> (err.into()) } + /// Error accessing a file, includes path and IO error. FileAccess(path: std::path::PathBuf, err: std::io::Error) { display("An error occurred while accessing the file at '{}': {}", path.display(), err) } + /// Error converting integer types. TryFromIntError(err: std::num::TryFromIntError) { from() } } } +/// Unified result type for sequent-core, using the custom `Error` type by default. pub type Result = std::result::Result; diff --git a/packages/sequent-core/src/types/hasura/core.rs b/packages/sequent-core/src/types/hasura/core.rs index 5db6327a5d0..bf620e6d474 100644 --- a/packages/sequent-core/src/types/hasura/core.rs +++ b/packages/sequent-core/src/types/hasura/core.rs @@ -22,6 +22,8 @@ use crate::{ }, }; +/// Election event preview url and metadata. +#[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct Preview { pub id: String, @@ -34,6 +36,8 @@ pub struct Preview { pub annotations: Option, } +/// Ballot publication metadata. +#[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct BallotPublication { pub id: String, @@ -50,6 +54,8 @@ pub struct BallotPublication { pub election_id: Option, } +/// Ballot style metadata. +#[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct BallotStyle { pub id: String, @@ -68,6 +74,8 @@ pub struct BallotStyle { pub ballot_publication_id: String, } +/// Electoral area or district. +#[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct Area { pub id: String, @@ -84,6 +92,8 @@ pub struct Area { pub presentation: Option, } +/// Election event metadata. +#[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct ElectionEvent { pub id: String, @@ -107,6 +117,8 @@ pub struct ElectionEvent { pub external_id: Option, } +/// Election within an event. +#[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct Election { pub id: String, @@ -134,6 +146,8 @@ pub struct Election { pub keys_ceremony_id: Option, } +/// Contest within an election. +#[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct Contest { pub id: String, @@ -160,6 +174,8 @@ pub struct Contest { pub external_id: Option, } +/// Candidate standing in a contest. +#[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct Candidate { pub id: String, @@ -178,6 +194,8 @@ pub struct Candidate { pub external_id: Option, } +/// Stored document metadata. +#[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct Document { pub id: String, @@ -193,6 +211,8 @@ pub struct Document { pub is_public: Option, } +/// Support material attached to an event. +#[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct SupportMaterial { pub id: String, @@ -208,6 +228,8 @@ pub struct SupportMaterial { pub is_hidden: Option, } +/// Store if voting is enabled in each channel. +#[allow(missing_docs)] #[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] pub struct VotingChannels { pub online: Option, @@ -229,6 +251,8 @@ impl Default for VotingChannels { } } +/// Minimal metadata for an election. +#[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct ElectionType { pub id: String, @@ -258,6 +282,8 @@ pub struct CastVote { } */ +/// Template for generated reports or communications. +#[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct Template { pub alias: String, @@ -272,6 +298,8 @@ pub struct Template { pub r#type: String, } +/// Application submitted by a voter. +#[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct Application { pub id: String, @@ -288,6 +316,8 @@ pub struct Application { pub status: String, } +/// Mapping between area and contest. +#[allow(missing_docs)] #[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, Hash)] pub struct AreaContest { pub id: String, @@ -295,6 +325,8 @@ pub struct AreaContest { pub contest_id: String, } +/// Tally sheet contents and metadata. +#[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct TallySheet { pub id: String, @@ -315,6 +347,8 @@ pub struct TallySheet { pub created_by_user_id: String, } +/// Keys ceremony configuration and state. +#[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct KeysCeremony { pub id: String, @@ -335,34 +369,44 @@ pub struct KeysCeremony { } impl KeysCeremony { + /// Returns true if this is the default ceremony. + #[must_use] pub fn is_default(&self) -> bool { - self.is_default.clone().unwrap_or(true) + self.is_default.unwrap_or(true) } + /// Returns the execution status. + /// + /// # Errors + /// Returns an error if the status string cannot be parsed. pub fn execution_status(&self) -> Result { let execution_status_str = - self.execution_status.clone().unwrap_or_default(); - KeysCeremonyExecutionStatus::from_str(&execution_status_str) - .map_err(|err| anyhow!("{:?}", err)) + self.execution_status.as_deref().unwrap_or(""); + KeysCeremonyExecutionStatus::from_str(execution_status_str) + .map_err(|err| anyhow!("{err:?}")) } + /// # Errors + /// Returns an error if the status value cannot be deserialized. pub fn status(&self) -> Result { deserialize_value(self.status.clone().unwrap_or_default()) - .map_err(|err| anyhow!("{:?}", err)) + .map_err(|err| anyhow!("{err:?}")) } + /// Returns the ceremonies policy, defaulting to manual ceremonies if not set or invalid. + #[must_use] pub fn policy(&self) -> CeremoniesPolicy { let settings = self.settings.as_ref().unwrap_or(&Value::Null); settings .get("policy") - .and_then(|value: &Value| value.as_str()) - .map(|s| s.to_string()) - .unwrap_or_else(|| CeremoniesPolicy::MANUAL_CEREMONIES.to_string()) - .parse::() + .and_then(|value| value.as_str()) + .and_then(|s| s.parse::().ok()) .unwrap_or(CeremoniesPolicy::MANUAL_CEREMONIES) } } +/// Tally session configuration options. +#[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, Default)] pub struct TallySessionConfiguration { pub report_content_template_id: Option, @@ -373,22 +417,32 @@ pub struct TallySessionConfiguration { } impl TallySessionConfiguration { + /// Returns the contest encryption policy. + #[must_use] pub fn get_contest_encryption_policy(&self) -> ContestEncryptionPolicy { self.contest_encryption_policy.clone().unwrap_or_default() } + /// Returns the delegated voting policy. + #[must_use] pub fn get_delegated_voting_policy(&self) -> DelegatedVotingPolicy { self.delegated_voting_policy.clone().unwrap_or_default() } + /// Returns the decoded ballots inclusion policy. + #[must_use] pub fn get_decoded_ballots_policy(&self) -> DecodedBallotsInclusionPolicy { self.decoded_ballots_inclusion_policy .clone() .unwrap_or_default() } + /// Returns the consolidated report policy. + #[must_use] pub fn get_consolidated_report_policy(&self) -> ConsolidatedReportPolicy { self.consolidated_report_policy.clone().unwrap_or_default() } } +/// Tally session record. +#[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct TallySession { pub id: String, @@ -408,6 +462,8 @@ pub struct TallySession { pub tally_type: Option, pub permission_label: Option>, } +/// Aggregate annotations for a session contest. +#[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct TallySessionContestAnnotations { pub elegible_voters: u64, @@ -415,6 +471,8 @@ pub struct TallySessionContestAnnotations { pub casted_ballots: u64, } +/// Contest entry for a tally session. +#[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct TallySessionContest { pub id: String, @@ -431,6 +489,8 @@ pub struct TallySessionContest { pub election_id: String, } +/// Execution details for a tally session. +#[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct TallySessionExecution { pub id: String, @@ -448,6 +508,8 @@ pub struct TallySessionExecution { pub documents: Option, } +/// Task execution record for background tasks. +#[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct TasksExecution { pub id: String, @@ -465,6 +527,8 @@ pub struct TasksExecution { pub executed_by_user: String, } +/// Trustee (key holder) information. +#[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct Trustee { pub id: String, @@ -477,6 +541,8 @@ pub struct Trustee { pub tenant_id: String, } +/// Tenant configuration and metadata. +#[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct Tenant { pub id: String, diff --git a/packages/sequent-core/src/types/hasura/extra.rs b/packages/sequent-core/src/types/hasura/extra.rs index f02ab93157f..78486f2b651 100644 --- a/packages/sequent-core/src/types/hasura/extra.rs +++ b/packages/sequent-core/src/types/hasura/extra.rs @@ -8,13 +8,14 @@ use crate::ballot::{ ElectionEventStatistics, ElectionEventStatus, ElectionPresentation, ElectionStatistics, ElectionStatus, }; -use anyhow::{anyhow, Result}; -use borsh::{BorshDeserialize, BorshSerialize}; +use anyhow::Result; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::default::Default; use strum_macros::{Display, EnumString}; +/// Store if voting is enabled in each channel. +#[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Clone, Deserialize)] pub struct VotingChannels { pub online: Option, @@ -23,6 +24,8 @@ pub struct VotingChannels { pub paper: Option, } +/// Reference to the bulletin board metadata in the database. +#[allow(missing_docs)] #[derive(PartialEq, Eq, Debug, Clone, Deserialize)] pub struct BulletinBoardReference { pub id: i64, @@ -31,6 +34,13 @@ pub struct BulletinBoardReference { } impl ElectionEvent { + /// Validate the content of the election event's necessary fields, such as + /// presentation, voting channels, status, statistics, and bulletin board + /// reference. + /// + /// # Errors + /// Returns an error if any of the JSON fields fail to deserialize into their + /// expected types. pub fn validate(&self) -> Result<()> { if let Some(presentation) = &self.presentation { serde_json::from_value::( @@ -63,6 +73,12 @@ impl ElectionEvent { } impl Election { + /// Validate the content of the election's necessary fields, such as + /// presentation, voting channels, status, and statistics. + /// + /// # Errors + /// Returns an error if any of the JSON fields fail to deserialize into their + /// expected types. pub fn validate(&self) -> Result<()> { if let Some(presentation) = &self.presentation { serde_json::from_value::( @@ -87,6 +103,11 @@ impl Election { } impl Contest { + /// Validate the content of the contest's necessary fields, such as + /// presentation. + /// + /// # Errors + /// Returns an error if the presentation JSON fails to deserialize. pub fn validate(&self) -> Result<()> { if let Some(presentation) = &self.presentation { serde_json::from_value::( @@ -99,6 +120,11 @@ impl Contest { } impl Candidate { + /// Validate the content of the candidate's necessary fields, such as + /// presentation. + /// + /// # Errors + /// Returns an error if the presentation JSON fails to deserialize. pub fn validate(&self) -> Result<()> { if let Some(presentation) = &self.presentation { serde_json::from_value::( @@ -122,6 +148,10 @@ impl Candidate { Default, JsonSchema, )] + +/// Status of task execution. +#[allow(missing_docs)] +#[allow(non_camel_case_types)] pub enum TasksExecutionStatus { #[default] IN_PROGRESS, diff --git a/packages/sequent-core/src/types/hasura/mod.rs b/packages/sequent-core/src/types/hasura/mod.rs index 449b05c17b8..4578f7906ed 100644 --- a/packages/sequent-core/src/types/hasura/mod.rs +++ b/packages/sequent-core/src/types/hasura/mod.rs @@ -2,5 +2,8 @@ // // SPDX-License-Identifier: AGPL-3.0-only +/// Types related to the Hasura GraphQL API. pub mod core; + +/// Additional types to support the Hasura GraphQL API, such as voting channels and bulletin board references. pub mod extra; diff --git a/packages/sequent-core/src/types/keycloak.rs b/packages/sequent-core/src/types/keycloak.rs index 5f091f9bb87..9f152a0fd26 100644 --- a/packages/sequent-core/src/types/keycloak.rs +++ b/packages/sequent-core/src/types/keycloak.rs @@ -12,107 +12,169 @@ use std::collections::HashMap; /// /// A call to Datafix mark-voted. pub const DISABLE_COMMENT: &str = "disable-comment"; +/// Reason string for disabling a voter via Datafix delete-voter endpoint. pub const DISABLE_REASON_DELETE_CALL: &str = "Disable reason: datafix call to delete-voter endpoint"; +/// Reason string for disabling a voter via Datafix mark-voted call. pub const DISABLE_REASON_MARKVOTED_CALL: &str = "Disable reason: Voter marked as voted via other channel"; /// If there is a call to Datafix mark-voted, we disable the voter and set this -/// value to signal the channel e.g "PHONE", "POST"... whatsoever +/// value to signal the channel e.g. `PHONE`, `POST`, etc. /// /// If there is a call to Datafix unmark-voted, we enable the voter and reset -/// this attribute to NONE. +/// this attribute to `NONE`. /// -/// In addition the voter list, when setting the has_voted flag will check if -/// this attribute is set, then set has_voted true. +/// In addition, the voter list, when setting the `has_voted` flag, will check if +/// this attribute is set, then set `has_voted` to true. pub const VOTED_CHANNEL: &str = "voted-channel"; +/// Value for internet voting channel. pub const VOTED_CHANNEL_INTERNET_VALUE: &str = "Internet"; +/// Value used to reset an attribute. pub const ATTR_RESET_VALUE: &str = "NONE"; +/// Attribute name for area ID. pub const AREA_ID_ATTR_NAME: &str = "area-id"; +/// Attribute name for date of birth. pub const DATE_OF_BIRTH: &str = "dateOfBirth"; +/// Attribute name for authorized election IDs. pub const AUTHORIZED_ELECTION_IDS_NAME: &str = "authorized-election-ids"; +/// Attribute name for tenant ID. pub const TENANT_ID_ATTR_NAME: &str = "tenant-id"; +/// Permission name for editing. pub const PERMISSION_TO_EDIT: &str = "admin"; +/// Attribute name for mobile phone number. pub const MOBILE_PHONE_ATTR_NAME: &str = "sequent.read-only.mobile-number"; +/// Attribute name for first name. pub const FIRST_NAME: &str = "firstName"; +/// Attribute name for last name. pub const LAST_NAME: &str = "lastName"; +/// Attribute name for permission labels. pub const PERMISSION_LABELS: &str = "permission_labels"; +/// Represents an area assigned to a user. #[derive(Serialize, Deserialize, JsonSchema, PartialEq, Eq, Debug, Clone)] pub struct UserArea { + /// Area identifier. pub id: Option, + /// Area name. pub name: Option, } +/// Information about votes cast by a user in an election. #[derive(Serialize, Deserialize, JsonSchema, PartialEq, Eq, Debug, Clone)] pub struct VotesInfo { + /// Election identifier. pub election_id: String, + /// Number of votes cast. pub num_votes: usize, + /// Timestamp of last vote cast. pub last_voted_at: String, } +/// Represents a user in Keycloak with profile and voting information. #[derive( Serialize, Deserialize, JsonSchema, PartialEq, Eq, Debug, Clone, Default, )] pub struct User { + /// User identifier. pub id: Option, + /// User attributes. pub attributes: Option>>, + /// User email address. pub email: Option, + /// Whether the email is verified. pub email_verified: Option, + /// Whether the user is enabled. pub enabled: Option, + /// User's first name. pub first_name: Option, + /// User's last name. pub last_name: Option, + /// Username. pub username: Option, + /// Area assigned to the user. pub area: Option, + /// Voting information for the user. pub votes_info: Option>, } #[derive(Serialize, Deserialize, JsonSchema, PartialEq, Eq, Debug, Clone)] +/// Represents a permission in Keycloak. pub struct Permission { + /// Permission identifier. pub id: Option, + /// Permission attributes. pub attributes: Option>>, + /// Container identifier for the permission. pub container_id: Option, + /// Description of the permission. pub description: Option, + /// Name of the permission. pub name: Option, } #[derive(Serialize, Deserialize, JsonSchema, PartialEq, Eq, Debug, Clone)] +/// Represents a role in Keycloak. pub struct Role { + /// Role identifier. pub id: Option, + /// Role name. pub name: Option, + /// Permissions associated with the role. pub permissions: Option>, + /// Access map for the role. pub access: Option>, + /// Role attributes. pub attributes: Option>>, + /// Client roles associated with the role. pub client_roles: Option>>, } +/// Permissions for editing and viewing user profile attributes. #[derive(Serialize, Deserialize, JsonSchema, PartialEq, Eq, Debug, Clone)] pub struct UPAttributePermissions { + /// Roles allowed to edit the attribute. pub edit: Option>, + /// Roles allowed to view the attribute. pub view: Option>, } +/// Selector for user profile attribute scopes. #[derive(Serialize, Deserialize, JsonSchema, PartialEq, Eq, Debug, Clone)] pub struct UPAttributeSelector { + /// Scopes for the attribute selector. pub scopes: Option>, } +/// Required roles and scopes for a user profile attribute. #[derive(Serialize, Deserialize, JsonSchema, PartialEq, Eq, Debug, Clone)] pub struct UPAttributeRequired { + /// Roles required for the attribute. pub roles: Option>, + /// Scopes required for the attribute. pub scopes: Option>, } +/// Represents a user profile attribute in Keycloak. #[derive(Serialize, Deserialize, JsonSchema, PartialEq, Eq, Debug, Clone)] pub struct UserProfileAttribute { + /// Annotations for the attribute. pub annotations: Option>, + /// Display name for the attribute. pub display_name: Option, + /// Group to which the attribute belongs. pub group: Option, + /// Whether the attribute is multivalued. pub multivalued: Option, + /// Name of the attribute. pub name: Option, + /// Required roles and scopes for the attribute. pub required: Option, + /// Validations for the attribute. pub validations: Option>>, + /// Permissions for the attribute. pub permissions: Option, + /// Selector for the attribute. pub selector: Option, } diff --git a/packages/sequent-core/src/types/mod.rs b/packages/sequent-core/src/types/mod.rs index 853307da176..a2d4d30f0e0 100644 --- a/packages/sequent-core/src/types/mod.rs +++ b/packages/sequent-core/src/types/mod.rs @@ -2,18 +2,32 @@ // // SPDX-License-Identifier: AGPL-3.0-only +/// Ceremony-related types and enums. pub mod ceremonies; +/// Date/time formatting and timezone types. pub mod date_time; +/// Unified error type and result alias for sequent-core. pub mod error; + +/// Types for integrating with Hasura. #[cfg(feature = "default_features")] pub mod hasura; +/// Keycloak integration types. pub mod keycloak; +/// Permission and access control types. pub mod permissions; +/// Election tally results types. pub mod results; + +/// Scheduled events types. #[cfg(feature = "default_features")] pub mod scheduled_event; +/// Tally sheet types. pub mod tally_sheets; + +/// Types related to templating and report generation. #[cfg(feature = "reports")] pub mod templates; +/// Utility types for mapping and conversion. pub mod to_map; diff --git a/packages/sequent-core/src/types/permissions.rs b/packages/sequent-core/src/types/permissions.rs index 016b9736a77..a5db4bca8f4 100644 --- a/packages/sequent-core/src/types/permissions.rs +++ b/packages/sequent-core/src/types/permissions.rs @@ -3,7 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0-only use serde::{Deserialize, Serialize}; use strum_macros::{Display, EnumString}; - +#[allow(missing_docs)] #[allow(non_camel_case_types)] #[derive( Display, Serialize, Deserialize, Debug, PartialEq, Eq, Clone, EnumString, @@ -380,6 +380,7 @@ pub enum Permissions { } #[allow(non_camel_case_types)] +#[allow(missing_docs)] #[derive( Display, Serialize, Deserialize, Debug, PartialEq, Eq, Clone, EnumString, )] diff --git a/packages/sequent-core/src/types/results.rs b/packages/sequent-core/src/types/results.rs index 10d41d9532a..d6ffac2d2fa 100644 --- a/packages/sequent-core/src/types/results.rs +++ b/packages/sequent-core/src/types/results.rs @@ -8,32 +8,56 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::default::Default; -// Keys for annotations fields in ResultAreaContest +/// Key for extended metrics in `ResultAreaContest` annotations pub const EXTENDED_METRICS: &str = "extended_metrics"; +/// Key for process results in `ResultAreaContest` annotations pub const PROCESS_RESULTS: &str = "process_results"; +/// Represents the type of result document generated in the Tally ceremony. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub enum ResultDocumentType { + /// JSON format result document. Json, + /// PDF format result document. Pdf, + /// HTML format result document. Html, + /// TAR.GZ archive containing result documents. TarGz, + /// TAR.GZ archive containing original result documents. TarGzOriginal, } +/// Collection of result documents in various formats generated from the Tally ceremony. #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] pub struct ResultDocuments { + /// JSON format result document. pub json: Option, + /// PDF format result document. pub pdf: Option, + /// HTML format result document. pub html: Option, + /// TAR.GZ archive containing result documents. pub tar_gz: Option, + /// TAR.GZ archive containing original result documents. pub tar_gz_original: Option, + /// TAR.GZ archive containing PDFs of result documents. pub tar_gz_pdfs: Option, + /// HTML result document for all areas. pub all_areas_html: Option, + /// JSON result document for all areas. pub all_areas_json: Option, } impl ResultDocuments { + /// Returns the document corresponding to the given type, if available. + /// + /// # Arguments + /// * `doc_type` - The type of document to retrieve. + /// + /// # Returns + /// An `Option` containing the document path. + #[must_use] pub fn get_document_by_type( &self, doc_type: &ResultDocumentType, @@ -48,152 +72,281 @@ impl ResultDocuments { } } +/// Represents a results for election event. #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct ResultsEvent { + /// Unique identifier for the results event. pub id: String, + /// Tenant identifier. pub tenant_id: String, + /// Election event identifier. pub election_event_id: String, + /// Optional name of the event. pub name: Option, + /// Timestamp when the results event was created. pub created_at: Option>, + /// Timestamp when the results event was last updated. pub last_updated_at: Option>, + /// Optional labels for the results event. pub labels: Option, + /// Optional annotations for the results event. pub annotations: Option, + /// Associated result documents. pub documents: Option, } +/// Represents the results for a specific election. #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct ResultsElection { + /// Unique identifier for the election results. pub id: String, + /// Tenant identifier. pub tenant_id: String, + /// Election event identifier. pub election_event_id: String, + /// Election identifier. pub election_id: String, + /// Results event identifier. pub results_event_id: String, + /// Optional name of the election. pub name: Option, + /// Optional eligible census count. pub elegible_census: Option, + /// Optional total number of voters. pub total_voters: Option, + /// Timestamp when the election results were created. pub created_at: Option>, + /// Timestamp when the election results were last updated. pub last_updated_at: Option>, + /// Optional labels for the election results. pub labels: Option, + /// Optional annotations for the election results. pub annotations: Option, + /// Optional percentage of total voters. pub total_voters_percent: Option>, + /// Associated result documents. pub documents: Option, } +/// Represents the results for a specific area within an election. #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct ResultsElectionArea { + /// Unique identifier for the area results. pub id: String, + /// Tenant identifier. pub tenant_id: String, + /// Election event identifier. pub election_event_id: String, + /// Election identifier. pub election_id: String, + /// Area identifier. pub area_id: String, + /// Results event identifier. pub results_event_id: String, + /// Timestamp when the area results were created. pub created_at: Option>, + /// Timestamp when the area results were last updated. pub last_updated_at: Option>, + /// Associated result documents. pub documents: Option, + /// Optional name of the area. pub name: Option, } +/// Represents the results for a specific contest within an election. #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct ResultsContest { + /// Unique identifier for the contest results. pub id: String, + /// Tenant identifier. pub tenant_id: String, + /// Election event identifier. pub election_event_id: String, + /// Election identifier. pub election_id: String, + /// Contest identifier. pub contest_id: String, + /// Results event identifier. pub results_event_id: String, + /// Optional eligible census count. pub elegible_census: Option, + /// Optional total number of valid votes. pub total_valid_votes: Option, + /// Optional explicit invalid votes count. pub explicit_invalid_votes: Option, + /// Optional implicit invalid votes count. pub implicit_invalid_votes: Option, + /// Optional blank votes count. pub blank_votes: Option, + /// Optional voting type. pub voting_type: Option, + /// Optional counting algorithm used. pub counting_algorithm: Option, + /// Optional name of the contest. pub name: Option, + /// Timestamp when the contest results were created. pub created_at: Option>, + /// Timestamp when the contest results were last updated. pub last_updated_at: Option>, + /// Optional labels for the contest results. pub labels: Option, + /// Optional annotations for the contest results. pub annotations: Option, + /// Optional total invalid votes count. pub total_invalid_votes: Option, + /// Optional percentage of total invalid votes. pub total_invalid_votes_percent: Option>, + /// Optional percentage of total valid votes. pub total_valid_votes_percent: Option>, + /// Optional percentage of explicit invalid votes. pub explicit_invalid_votes_percent: Option>, + /// Optional percentage of implicit invalid votes. pub implicit_invalid_votes_percent: Option>, + /// Optional percentage of blank votes. pub blank_votes_percent: Option>, + /// Optional total votes count. pub total_votes: Option, + /// Optional percentage of total votes. pub total_votes_percent: Option>, + /// Associated result documents. pub documents: Option, + /// Optional total auditable votes count. pub total_auditable_votes: Option, + /// Optional percentage of total auditable votes. pub total_auditable_votes_percent: Option>, } +/// Represents the results for a specific candidate within a contest. #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct ResultsContestCandidate { + /// Unique identifier for the candidate results. pub id: String, + /// Tenant identifier. pub tenant_id: String, + /// Election event identifier. pub election_event_id: String, + /// Election identifier. pub election_id: String, + /// Contest identifier. pub contest_id: String, + /// Candidate identifier. pub candidate_id: String, + /// Results event identifier. pub results_event_id: String, + /// Optional number of votes cast for the candidate. pub cast_votes: Option, + /// Optional winning position of the candidate. pub winning_position: Option, + /// Optional points awarded to the candidate. pub points: Option, + /// Timestamp when the candidate results were created. pub created_at: Option>, + /// Timestamp when the candidate results were last updated. pub last_updated_at: Option>, + /// Optional labels for the candidate results. pub labels: Option, + /// Optional annotations for the candidate results. pub annotations: Option, + /// Optional percentage of votes cast for the candidate. pub cast_votes_percent: Option>, + /// Associated result documents. pub documents: Option, } +/// Represents the results for a specific contest within an area. #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct ResultsAreaContest { + /// Unique identifier for the area contest results. pub id: String, + /// Tenant identifier. pub tenant_id: String, + /// Election event identifier. pub election_event_id: String, + /// Election identifier. pub election_id: String, + /// Contest identifier. pub contest_id: String, + /// Area identifier. pub area_id: String, + /// Results event identifier. pub results_event_id: String, + /// Optional eligible census count. pub elegible_census: Option, + /// Optional total number of valid votes. pub total_valid_votes: Option, + /// Optional explicit invalid votes count. pub explicit_invalid_votes: Option, + /// Optional implicit invalid votes count. pub implicit_invalid_votes: Option, + /// Optional blank votes count. pub blank_votes: Option, + /// Timestamp when the area contest results were created. pub created_at: Option>, + /// Timestamp when the area contest results were last updated. pub last_updated_at: Option>, + /// Optional labels for the area contest results. pub labels: Option, + /// Optional annotations for the area contest results. pub annotations: Option, + /// Optional percentage of valid votes. pub total_valid_votes_percent: Option>, + /// Optional total invalid votes count. pub total_invalid_votes: Option, + /// Optional percentage of total invalid votes. pub total_invalid_votes_percent: Option>, + /// Optional percentage of explicit invalid votes. pub explicit_invalid_votes_percent: Option>, + /// Optional percentage of blank votes. pub blank_votes_percent: Option>, + /// Optional percentage of implicit invalid votes. pub implicit_invalid_votes_percent: Option>, + /// Optional total votes count. pub total_votes: Option, + /// Optional percentage of total votes. pub total_votes_percent: Option>, + /// Associated result documents. pub documents: Option, + /// Optional total auditable votes count. pub total_auditable_votes: Option, + /// Optional percentage of total auditable votes. pub total_auditable_votes_percent: Option>, } +/// Represents the results for a specific candidate within a contest area. #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct ResultsAreaContestCandidate { + /// Unique identifier for the area contest candidate results. pub id: String, + /// Tenant identifier. pub tenant_id: String, + /// Election event identifier. pub election_event_id: String, + /// Election identifier. pub election_id: String, + /// Contest identifier. pub contest_id: String, + /// Area identifier. pub area_id: String, + /// Candidate identifier. pub candidate_id: String, + /// Results event identifier. pub results_event_id: String, + /// Optional number of votes cast for the candidate. pub cast_votes: Option, + /// Optional winning position of the candidate. pub winning_position: Option, + /// Optional points awarded to the candidate. pub points: Option, + /// Timestamp when the area contest candidate results were created. pub created_at: Option>, + /// Timestamp when the area contest candidate results were last updated. pub last_updated_at: Option>, + /// Optional labels for the area contest candidate results. pub labels: Option, + /// Optional annotations for the area contest candidate results. pub annotations: Option, + /// Optional percentage of votes cast for the candidate. pub cast_votes_percent: Option>, + /// Associated result documents. pub documents: Option, } diff --git a/packages/sequent-core/src/types/scheduled_event.rs b/packages/sequent-core/src/types/scheduled_event.rs index 6c9d0a9aa35..9b70e43677f 100644 --- a/packages/sequent-core/src/types/scheduled_event.rs +++ b/packages/sequent-core/src/types/scheduled_event.rs @@ -9,7 +9,7 @@ use crate::ballot::VotingPeriodDates; use anyhow::{anyhow, Result}; use chrono::DateTime; use chrono::Utc; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; use std::collections::HashMap; use strum_macros::Display; @@ -26,96 +26,157 @@ use strum_macros::EnumString; EnumString, Hash, )] +/// Enum representing different types of event processors for scheduled events. pub enum EventProcessors { #[strum(serialize = "ALLOW_INIT_REPORT")] + /// Allow Initialization report to be generated. ALLOW_INIT_REPORT, #[strum(serialize = "CREATE_REPORT")] + /// Scheduled event to create a report. CREATE_REPORT, #[strum(serialize = "SEND_TEMPLATE")] + /// Scheduled event to send a template. SEND_TEMPLATE, #[strum(serialize = "START_VOTING_PERIOD")] + /// Start of the voting period. START_VOTING_PERIOD, #[strum(serialize = "END_VOTING_PERIOD")] + /// End of the voting period. END_VOTING_PERIOD, #[strum(serialize = "ALLOW_VOTING_PERIOD_END")] + /// Allow the voting period to end. ALLOW_VOTING_PERIOD_END, #[strum(serialize = "START_ENROLLMENT_PERIOD")] + /// Start of the enrollment period. START_ENROLLMENT_PERIOD, #[strum(serialize = "END_ENROLLMENT_PERIOD")] + /// End of the enrollment period. END_ENROLLMENT_PERIOD, #[strum(serialize = "START_LOCKDOWN_PERIOD")] + /// Start of the lockdown period. START_LOCKDOWN_PERIOD, #[strum(serialize = "END_LOCKDOWN_PERIOD")] + /// End of the lockdown period. END_LOCKDOWN_PERIOD, #[strum(serialize = "ALLOW_TALLY")] + /// Allow the tally to be performed. ALLOW_TALLY, } #[derive(Serialize, Deserialize, Eq, PartialEq, Debug, Clone)] +/// Configuration for a cron job, including the cron expression and the scheduled date. pub struct CronConfig { + /// Cron expression defining the schedule for the event. pub cron: Option, + /// Scheduled date for the event. pub scheduled_date: Option, } #[derive(Serialize, Deserialize, Debug, Clone)] +/// Payload for managing election dates, containing an optional election ID. pub struct ManageElectionDatePayload { + /// Election ID associated with the election date management. pub election_id: Option, } #[derive(Serialize, Deserialize, Debug, Clone)] +/// Payload for managing the allowance of initialization report. pub struct ManageAllowInitPayload { + /// Election ID associated with the initialization report. pub election_id: Option, - #[serde(default = "default_allow_init")] - pub allow_init: Option, + #[serde( + default = "default_allow_init", + deserialize_with = "deserialize_allow_init" + )] + /// Flag indicating whether the initialization report is allowed. Defaults to true. + /// + /// Absent field and JSON `null` deserialize as `true` for compatibility with older payloads. + pub allow_init: bool, } -fn default_allow_init() -> Option { - Some(true) +/// Default value for `allow_init` field in `ManageAllowInitPayload`. +/// +/// Always returns true. +const fn default_allow_init() -> bool { + true +} + +/// Deserialize the `allow_init` field in `ManageAllowInitPayload`. +fn deserialize_allow_init<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let opt = Option::::deserialize(deserializer)?; + Ok(opt.unwrap_or(true)) } #[derive(Serialize, Deserialize, Debug, Clone)] +/// Payload for managing the allowance of voting period end. pub struct ManageAllowVotingPeriodEndPayload { + /// Election ID associated with the voting period end. pub election_id: Option, + /// Flag indicating whether the voting period end is allowed. pub allow_voting_period_end: Option, } #[derive(Serialize, Deserialize, Debug, Clone)] +/// Payload for managing the allowance of tally. pub struct ManageAllowTallyPayload { + /// Election ID associated with the tally. pub election_id: Option, } #[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +/// Represents a scheduled event in the system. pub struct ScheduledEvent { + /// Unique identifier for the scheduled event. pub id: String, + /// Optional tenant ID associated with the event. pub tenant_id: Option, + /// Optional election event ID associated with the event. pub election_event_id: Option, + /// Scheduled creation date for the event, if applicable. pub created_at: Option>, + /// Scheduled stop date for the event, if applicable. pub stopped_at: Option>, + /// Scheduled archive date for the event, if applicable. pub archived_at: Option>, + /// Labels associated with the event. pub labels: Option, + /// Annotations associated with the event. pub annotations: Option, + /// Event processor (type). pub event_processor: Option, + /// Cron configuration for the event. pub cron_config: Option, + /// Event payload. pub event_payload: Option, + /// Task ID associated with the event. pub task_id: Option, } +#[must_use] +/// Generates a task name for managing scheduled dates pub fn generate_manage_date_task_name( tenant_id: &str, election_event_id: &str, election_id: Option<&str>, event_processor: &EventProcessors, ) -> String { - let base = format!("tenant_{}_event_{}_", tenant_id, election_event_id,); + let base = format!("tenant_{tenant_id}_event_{election_event_id}_"); let base_with_election = match election_id { - Some(id) => format!("{}election_{}_", base, id), + Some(id) => format!("{base}election_{id}_"), None => base, }; - format!("{}{}", base_with_election, event_processor,) + format!("{base_with_election}{event_processor}") } +/// Generate voting period dates from scheduled events. +/// +/// # Errors +/// Returns an error if payload serialization or date extraction fails. pub fn generate_voting_period_dates( scheduled_events: Vec, tenant_id: &str, @@ -123,7 +184,7 @@ pub fn generate_voting_period_dates( election_id: Option<&str>, ) -> Result { let payload = ManageElectionDatePayload { - election_id: election_id.map(|s| s.to_string()), + election_id: election_id.map(std::string::ToString::to_string), }; let payload_val = serde_json::to_value(&payload)?; @@ -162,25 +223,22 @@ pub fn generate_voting_period_dates( Ok(VotingPeriodDates { start_date: start_date - .map(|val| val.cron_config.map(|val| val.scheduled_date)) - .flatten() - .flatten(), + .and_then(|val| val.cron_config.and_then(|val| val.scheduled_date)), end_date: end_date - .map(|val| val.cron_config.map(|val| val.scheduled_date)) - .flatten() - .flatten(), + .and_then(|val| val.cron_config.and_then(|val| val.scheduled_date)), }) } /// Converts a list of schedule events to a map of date names and -/// ScheduledEventDates. +/// `ScheduledEventDates`. +/// +/// If `election_id` is None, it will contain only dates schedule for the election event. +/// If the `election_id` is Some(_), it will contain also dates scheduled for this specific election. /// -/// If election_id is None, it will contain only dates schedule for the election -/// event. -/// If the election_id is Some(_), it will contain also dates scheduled for this -/// specific election. +/// # Errors +/// Returns an error if deserialization or parsing fails. pub fn prepare_scheduled_dates( - scheduled_events: Vec, + scheduled_events: &[ScheduledEvent], election_id: Option<&str>, ) -> Result> { // List of event processors related to scheduled event dates @@ -198,9 +256,7 @@ pub fn prepare_scheduled_dates( Ok(scheduled_events .iter() .filter_map(|scheduled_event| { - let Some(ref event_payload) = scheduled_event.event_payload else { - return None; - }; + let event_payload = scheduled_event.event_payload.as_ref()?; let Ok(ManageElectionDatePayload { election_id: se_election_id, .. @@ -208,18 +264,15 @@ pub fn prepare_scheduled_dates( else { return None; }; - let Some(ref event_processor) = scheduled_event.event_processor - else { - return None; - }; - if !date_event_processors.contains(&event_processor) + let event_processor = scheduled_event.event_processor.as_ref()?; + if !date_event_processors.contains(event_processor) || (se_election_id.is_some() && election_id.is_some() && se_election_id.as_deref() != election_id) { return None; } - return Some(( + Some(( event_processor.to_string(), ScheduledEventDates { scheduled_at: scheduled_event @@ -231,7 +284,7 @@ pub fn prepare_scheduled_dates( "-", )), }, - )); + )) }) .collect()) } diff --git a/packages/sequent-core/src/types/tally_sheets.rs b/packages/sequent-core/src/types/tally_sheets.rs index 2c50776cddd..b1d8768c7f3 100644 --- a/packages/sequent-core/src/types/tally_sheets.rs +++ b/packages/sequent-core/src/types/tally_sheets.rs @@ -8,6 +8,7 @@ use std::collections::HashMap; use std::str::FromStr; use strum_macros::{Display, EnumString}; +/// Represents the channel through which voting occurs. #[derive( Display, Serialize, @@ -18,47 +19,62 @@ use strum_macros::{Display, EnumString}; Clone, EnumString, Hash, + Default, )] pub enum VotingChannel { + /// Paper ballot voting channel. + #[default] PAPER, + /// Postal voting channel. POSTAL, + /// In-person voting channel. IN_PERSON, } -impl Default for VotingChannel { - fn default() -> Self { - VotingChannel::PAPER - } -} - impl From> for VotingChannel { fn from(opt: Option) -> Self { opt.and_then(|s| VotingChannel::from_str(&s).ok()) - .unwrap_or_else(|| VotingChannel::default()) + .unwrap_or_default() } } +/// Represents invalid votes in a contest. #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone, Default)] pub struct InvalidVotes { + /// Total number of invalid votes. pub total_invalid: Option, + /// Number of implicit invalid votes. pub implicit_invalid: Option, + /// Number of explicit invalid votes. pub explicit_invalid: Option, } +/// Results for a candidate in a contest. #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)] pub struct CandidateResults { + /// Unique identifier for the candidate. pub candidate_id: String, + /// Total number of votes received by the candidate. pub total_votes: Option, } +/// Results for a contest within a specific area. #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)] pub struct AreaContestResults { + /// Unique identifier for the area. pub area_id: String, + /// Unique identifier for the contest. pub contest_id: String, + /// Total number of votes cast in the contest. pub total_votes: Option, + /// Total number of valid votes in the contest. pub total_valid_votes: Option, + /// Invalid votes breakdown. pub invalid_votes: Option, + /// Total number of blank votes. pub total_blank_votes: Option, + /// Census count for the area. pub census: Option, + /// Results for each candidate in the contest. pub candidate_results: HashMap, } diff --git a/packages/sequent-core/src/types/templates.rs b/packages/sequent-core/src/types/templates.rs index 8a15c51d281..8f7f438facc 100644 --- a/packages/sequent-core/src/types/templates.rs +++ b/packages/sequent-core/src/types/templates.rs @@ -9,14 +9,19 @@ use strum_macros::{Display, EnumString}; #[derive( Display, Serialize, Deserialize, Debug, PartialEq, Eq, Clone, EnumString, )] +/// Audience selection for communication templates. pub enum AudienceSelection { #[strum(serialize = "ALL_USERS")] + /// Template is meant to be sent to all users, regardless of their voting status. ALL_USERS, #[strum(serialize = "NOT_VOTED")] + /// Template is meant to be sent only to users who have not voted yet. NOT_VOTED, #[strum(serialize = "VOTED")] + /// Template is meant to be sent only to users who have voted. VOTED, #[strum(serialize = "SELECTED")] + /// Template is meant to be sent only to selected users. SELECTED, } @@ -24,22 +29,31 @@ pub enum AudienceSelection { #[derive( Display, Serialize, Deserialize, Debug, PartialEq, Eq, Clone, EnumString, )] +/// Template types for communication templates. pub enum TemplateType { #[strum(serialize = "CREDENTIALS")] + /// Template for credentials. CREDENTIALS, #[strum(serialize = "BALLOT_RECEIPT")] + /// Template for ballot receipts. BALLOT_RECEIPT, #[strum(serialize = "PARTICIPATION_REPORT")] + /// Template for participation reports. PARTICIPATION_REPORT, #[strum(serialize = "ELECTORAL_RESULTS")] + /// Template for electoral results. ELECTORAL_RESULTS, #[strum(serialize = "OTP")] + /// Template for one-time passwords. OTP, #[strum(serialize = "TALLY_REPORT")] + /// Template for tally reports. TALLY_REPORT, #[strum(serialize = "MANUALLY_VERIFY_VOTER")] + /// Template for manually verifying voters. MANUALLY_VERIFY_VOTER, #[strum(serialize = "MANUALLY_VERIFY_APPROVAL")] + /// Template for manually verifying approvals. MANUALLY_VERIFY_APPROVAL, } @@ -47,30 +61,41 @@ pub enum TemplateType { #[derive( Display, Serialize, Deserialize, Debug, PartialEq, Eq, Clone, EnumString, )] +/// Communication methods for communication templates. pub enum TemplateMethod { #[strum(serialize = "EMAIL")] + /// Template is meant to be sent via email. EMAIL, #[strum(serialize = "SMS")] + /// Template is meant to be sent via SMS. SMS, #[strum(serialize = "DOCUMENT")] + /// Template is meant to be sent as a document. DOCUMENT, } #[derive(Deserialize, Debug, Serialize, Clone, Default)] +/// Configuration for email templates. pub struct EmailConfig { + /// The subject of the email. pub subject: String, + /// The plaintext body of the email. pub plaintext_body: String, + /// The HTML body of the email. pub html_body: Option, } #[derive(Deserialize, Debug, Serialize, Clone, Default)] +/// Configuration for SMS templates. pub struct SmsConfig { + /// The message of the SMS. pub message: String, } -/// A replica of headless_chrome::types::PrintToPdfOptions version = "1.0.12" +/// A replica of `headless_chrome::types::PrintToPdfOptions` version = "1.0.12" /// that implements Clone #[derive(Deserialize, Debug, Serialize, Clone, Default)] +#[allow(missing_docs)] pub struct PrintToPdfOptionsLocal { pub landscape: Option, pub display_header_footer: Option, @@ -93,8 +118,10 @@ pub struct PrintToPdfOptionsLocal { } impl PrintToPdfOptionsLocal { + #[must_use] + /// Creates a `PrintToPdfOptionsLocal` from a `PrintToPdfOptions` by copying all fields except `transfer_mode`. pub fn from_pdf_options( - pdf_options: PrintToPdfOptions, + pdf_options: &PrintToPdfOptions, ) -> PrintToPdfOptionsLocal { PrintToPdfOptionsLocal { landscape: pdf_options.landscape, @@ -119,6 +146,7 @@ impl PrintToPdfOptionsLocal { } /// Ignores Transfer mode which is private and not clonable + #[must_use] pub fn to_print_to_pdf_options(&self) -> PrintToPdfOptions { PrintToPdfOptions { landscape: self.landscape, @@ -143,40 +171,60 @@ impl PrintToPdfOptionsLocal { } } #[derive(Deserialize, Debug, Serialize, Clone)] +/// Struct for the body of the `send_template` endpoint. pub struct SendTemplateBody { // TODO: Rename this struct + /// The users to send the template to pub audience_selection: Option, + /// Voter IDs to send the template to, if `audience_selection` is `SELECTED` pub audience_voter_ids: Option>, + /// The type of communication method to use for the template pub communication_method: Option, + /// Whether to schedule the template to be sent immediately pub schedule_now: Option, + /// The date to schedule the template to be sent pub schedule_date: Option, + /// Configuration for email templates (if `communication_method` is `EMAIL`) pub email: Option, + /// Configuration for SMS templates (if `communication_method` is `SMS`) pub sms: Option, + /// The document to send with the template pub document: Option, + /// The name of the template pub name: Option, + /// The alias of the template pub alias: Option, + /// PDF options for the template pub pdf_options: Option, + /// Report options for the template pub report_options: Option, } -/// Struct for the DEFAULT extra_config JSON file. +/// Struct for the DEFAULT `extra_config` JSON file. #[derive(Serialize, Deserialize, Debug)] pub struct ReportExtraConfig { + /// PDF options for the report. pub pdf_options: PrintToPdfOptionsLocal, + /// Communications configuration for the report. pub communication_templates: CommunicationTemplatesExtraConfig, + /// Report options for the report. pub report_options: ReportOptions, } -/// Struct for DEFAULT Communication Templates in extra_config JSON file. +/// Struct for DEFAULT Communication Templates in `extra_config` JSON file. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct CommunicationTemplatesExtraConfig { + /// Configuration for email templates. pub email_config: EmailConfig, + /// Configuration for SMS templates. pub sms_config: SmsConfig, } -/// Struct for DEFAULT ReportOptions in extra_config JSON file. +/// Struct for DEFAULT `ReportOptions` in `extra_config` JSON file. #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct ReportOptions { + /// Maximum number of items to include in the report. If `None`, there is no limit. pub max_items_per_report: Option, + /// Maximum number of threads to use when generating the report. pub max_threads: Option, } diff --git a/packages/sequent-core/src/types/to_map.rs b/packages/sequent-core/src/types/to_map.rs index 16fe4dc4148..62d7e80ace4 100644 --- a/packages/sequent-core/src/types/to_map.rs +++ b/packages/sequent-core/src/types/to_map.rs @@ -2,11 +2,17 @@ // // SPDX-License-Identifier: AGPL-3.0-only -use anyhow::{anyhow, Context, Result}; +use anyhow::{anyhow, Result}; use serde::Serialize; use serde_json::{Map, Value}; +/// Trait for converting a type to a JSON map representation. +/// Used for serializing structs and types to a map for flexible processing. pub trait ToMap { + /// Converts the type to a JSON map. + /// + /// # Errors + /// Returns an error if serialization fails or the value cannot be converted to a map. fn to_map(&self) -> Result>; } diff --git a/packages/sequent-core/src/util/aws.rs b/packages/sequent-core/src/util/aws.rs index 41470bde082..cefbbaf0e76 100644 --- a/packages/sequent-core/src/util/aws.rs +++ b/packages/sequent-core/src/util/aws.rs @@ -10,6 +10,9 @@ pub const AWS_S3_PUBLIC_URI_ENV: &str = "AWS_S3_PUBLIC_URI"; /// Resolves the AWS region from the environment and keeps the default chain /// as a fallback so local and deployed runtimes share the same lookup flow. +/// +/// # Errors +/// Returns an error if the region cannot be determined. pub fn get_region() -> Result { let region = RegionProviderChain::first_try(Region::new( std::env::var("AWS_REGION") @@ -23,6 +26,9 @@ pub fn get_region() -> Result { #[instrument(err, skip_all)] /// Loads the shared AWS SDK configuration from the process environment so S3, /// SES, SNS, and STS all use the same credentials and region resolution. +/// +/// # Errors +/// Returns an error if the configuration cannot be loaded from the environment. pub async fn get_from_env_aws_config() -> Result { let region = Region::new( std::env::var("AWS_REGION") @@ -36,6 +42,9 @@ pub async fn get_from_env_aws_config() -> Result { /// /// When `use_server_endpoint` is `false`, the client-facing endpoint is used /// instead of the server-side endpoint. +/// +/// # Errors +/// Returns an error if the configuration cannot be loaded. pub async fn get_s3_aws_config( use_server_endpoint: bool, ) -> Result { @@ -84,6 +93,9 @@ pub async fn get_s3_aws_config( /// Returns the maximum upload size so callers can reject oversized payloads /// before opening a long-running upload flow. +/// +/// # Errors +/// Returns an error if the configuration cannot be loaded. pub fn get_max_upload_size() -> Result { Ok(std::env::var("AWS_S3_MAX_UPLOAD_BYTES") .map_err(|err| { @@ -93,6 +105,9 @@ pub fn get_max_upload_size() -> Result { } /// Returns the upload URL lifetime so presigned uploads expire predictably. +/// +/// # Errors +/// Returns an error if the configuration cannot be loaded. pub fn get_upload_expiration_secs() -> Result { Ok(std::env::var("AWS_S3_UPLOAD_EXPIRATION_SECS") .map_err(|err| { @@ -103,6 +118,9 @@ pub fn get_upload_expiration_secs() -> Result { /// Returns the download URL lifetime so generated fetch URLs match the /// deployment's cache and access expectations. +/// +/// # Errors +/// Returns an error if the configuration cannot be loaded. pub fn get_fetch_expiration_secs() -> Result { Ok(std::env::var("AWS_S3_FETCH_EXPIRATION_SECS") .map_err(|err| { diff --git a/packages/sequent-core/src/util/console_log.rs b/packages/sequent-core/src/util/console_log.rs index ed9178bb5e3..dbacda6fc0f 100644 --- a/packages/sequent-core/src/util/console_log.rs +++ b/packages/sequent-core/src/util/console_log.rs @@ -7,16 +7,26 @@ use wasm_bindgen::prelude::*; extern crate console_error_panic_hook; -#[cfg(feature = "wasm")] -macro_rules! console_log { - ($($t:tt)*) => { - ::web_sys::console::log_1(&format_args!($($t)*).to_string().into()); - } -} - -#[cfg(not(feature = "wasm"))] +/// Logs to the browser console (WASM) or stdout (native). +/// +/// # Examples +/// ``` +/// console_log!("Hello, {}!", "world"); +/// ``` +#[macro_export] macro_rules! console_log { ($($t:tt)*) => { - println!("{}", format_args!($($t)*)); + { + #[cfg(feature = "wasm")] + { + ::web_sys::console::log_1(&format_args!($($t)*).to_string().into()); + } + #[cfg(not(feature = "wasm"))] + // Allow stdout for lightweight, dependency-free debug logging. + #[allow(clippy::print_stdout)] + { + println!("{}", format_args!($($t)*)); + } + } } } diff --git a/packages/sequent-core/src/util/convert_vec.rs b/packages/sequent-core/src/util/convert_vec.rs index 9ae287a920a..656287ec284 100644 --- a/packages/sequent-core/src/util/convert_vec.rs +++ b/packages/sequent-core/src/util/convert_vec.rs @@ -3,9 +3,11 @@ // # SPDX-License-Identifier: AGPL-3.0-only use serde_json::Value; -use std::collections::HashMap; +use std::{collections::HashMap, hash::BuildHasher}; +/// A trait for converting a value into a `Vec`. pub trait IntoVec { + /// Converts a value into a `Vec`. fn into_vec(self) -> Vec; } @@ -40,16 +42,22 @@ impl IntoVec for Value { } } -pub fn convert_map( - original_map: HashMap, -) -> HashMap> { +#[must_use] +/// Converts a `HashMap` to a `HashMap>`, +/// where the `Value` can be either a `String` or an `Array` of `Strings`. +pub fn convert_map( + original_map: HashMap, +) -> HashMap, S> +where + S: BuildHasher + Default, +{ original_map .into_iter() .map(|(key, value)| { let vec = match value { Value::Array(arr) => arr .into_iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) + .filter_map(|v| v.as_str().map(str::to_string)) .collect(), Value::String(s) => vec![s], _ => Vec::new(), diff --git a/packages/sequent-core/src/util/date.rs b/packages/sequent-core/src/util/date.rs index 632a9cc7b5d..b829eaba40f 100644 --- a/packages/sequent-core/src/util/date.rs +++ b/packages/sequent-core/src/util/date.rs @@ -6,16 +6,27 @@ use anyhow::{Context, Result}; use chrono::{DateTime, Duration, Local, Utc}; use std::time::{SystemTime, UNIX_EPOCH}; +/// Get the current system date in the format "day/month/year". +#[must_use] pub fn get_current_date() -> String { let local: DateTime = Local::now(); local.format("%-d/%-m/%Y").to_string() } +/// Get the timestamp for a given number of seconds later than the current time. +/// # Panics +/// If the addition of seconds results in an overflow. +#[must_use] pub fn get_seconds_later(seconds: i64) -> DateTime { let current_time = Utc::now(); - current_time + Duration::seconds(seconds) + current_time + .checked_add_signed(Duration::seconds(seconds)) + .expect("Overflow when adding seconds to current time") } +/// Get the current timestamp in seconds since the UNIX epoch. +/// # Errors +/// If the current system time is before the `UNIX_EPOCH`, which is highly unlikely. pub fn timestamp() -> Result { Ok(SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/packages/sequent-core/src/util/date_time.rs b/packages/sequent-core/src/util/date_time.rs index 8fe5cd7e99e..c9fd72c3484 100644 --- a/packages/sequent-core/src/util/date_time.rs +++ b/packages/sequent-core/src/util/date_time.rs @@ -10,24 +10,29 @@ use chrono::{ pub const PHILIPPINO_TIMEZONE: TimeZone = TimeZone::Offset(8); +/// Get the current system time zone. +#[must_use] pub fn get_system_timezone() -> TimeZone { let now = Local::now(); let offset = now.offset(); - let duration = Duration::seconds(offset.local_minus_utc() as i64); - let hours = duration.num_hours() as i32; + let duration = Duration::seconds(i64::from(offset.local_minus_utc())); + let Ok(hours) = i32::try_from(duration.num_hours()) else { + return TimeZone::UTC; + }; if hours == 0 { TimeZone::UTC } else { TimeZone::Offset(hours) } } - +/// Get the current date and time in RFC 3339 format. +#[must_use] pub fn get_date_and_time() -> String { - let current_date_time = Local::now(); - let printed_datetime = current_date_time.to_rfc3339(); - printed_datetime + Local::now().to_rfc3339() } +/// Generate a timestamp string based on the provided time zone, date format, and date time. +#[must_use] pub fn generate_timestamp( time_zone: Option, date_format: Option, @@ -41,9 +46,13 @@ pub fn generate_timestamp( match time_zone { TimeZone::UTC => now.format(&date_format).to_string(), TimeZone::Offset(offset) => { - let duration = Duration::hours(offset as i64); - let fixed_offset = - FixedOffset::east_opt(duration.num_seconds() as i32); + let duration = Duration::hours(i64::from(offset)); + let fixed_offset = FixedOffset::east_opt( + match i32::try_from(duration.num_seconds()) { + Ok(secs) => secs, + Err(_) => return now.format(&date_format).to_string(), + }, + ); match fixed_offset { Some(fixed) => fixed .from_utc_datetime(&now.naive_utc()) @@ -57,16 +66,27 @@ pub fn generate_timestamp( /// Check if the date is correct, format must be YYYY-MM-DD. /// Date in the future is not valid. +/// +/// # Errors +/// Returns an error if the date format is invalid, or if the date is in the future. pub fn verify_date_format_ymd(date_str: &str) -> Result, String> { let parts: Vec<&str> = date_str.split('-').collect(); if parts.len() != 3 { return Err("Invalid date format".to_string()); } - let year: i32 = parts[0].parse().map_err(|_| "Invalid year".to_string())?; - let month: u32 = - parts[1].parse().map_err(|_| "Invalid month".to_string())?; - let day: u32 = parts[2].parse().map_err(|_| "Invalid day".to_string())?; + let year: i32 = match parts.first() { + Some(y) => y.parse().map_err(|_| "Invalid year".to_string())?, + None => return Err("Invalid year".to_string()), + }; + let month: u32 = match parts.get(1) { + Some(m) => m.parse().map_err(|_| "Invalid month".to_string())?, + None => return Err("Invalid month".to_string()), + }; + let day: u32 = match parts.get(2) { + Some(d) => d.parse().map_err(|_| "Invalid day".to_string())?, + None => return Err("Invalid day".to_string()), + }; let naive_date = NaiveDate::from_ymd_opt(year, month, day) .ok_or_else(|| "Invalid date".to_string())?; diff --git a/packages/sequent-core/src/util/external_config.rs b/packages/sequent-core/src/util/external_config.rs index 0ea62d391f8..4d8fbe1fed4 100644 --- a/packages/sequent-core/src/util/external_config.rs +++ b/packages/sequent-core/src/util/external_config.rs @@ -55,6 +55,10 @@ pub struct GenerateApplications { pub annotations: HashMap, } +/// Loads the external config from the given working directory. +/// +/// # Errors +/// Returns an error if the config file cannot be opened or parsed. pub fn load_external_config( working_dir: &str, ) -> Result> { diff --git a/packages/sequent-core/src/util/float.rs b/packages/sequent-core/src/util/float.rs index d6b5c4ba90f..753dfd54e57 100644 --- a/packages/sequent-core/src/util/float.rs +++ b/packages/sequent-core/src/util/float.rs @@ -4,19 +4,19 @@ use anyhow::{anyhow, Result}; use ordered_float::NotNan; -// Newtype wrapper for f64 +/// Newtype wrapper for f64. pub struct FloatWrapper(pub f64); -// Implement TryFrom for the wrapper type +/// Implements `TryFrom` for the wrapper type. impl TryFrom for NotNan { type Error = anyhow::Error; fn try_from(wrapper: FloatWrapper) -> Result { - NotNan::new(wrapper.0).map_err(|err| anyhow!("{:?}", err)) + NotNan::new(wrapper.0).map_err(|err| anyhow!("{err:?}")) } } -// Optional: Implement From for FloatWrapper for convenience +/// Implements `From` for `FloatWrapper` for convenience. impl From for FloatWrapper { fn from(value: f64) -> Self { FloatWrapper(value) diff --git a/packages/sequent-core/src/util/init_log.rs b/packages/sequent-core/src/util/init_log.rs index 30e838ecc21..3de3e1660b0 100644 --- a/packages/sequent-core/src/util/init_log.rs +++ b/packages/sequent-core/src/util/init_log.rs @@ -9,6 +9,10 @@ use tracing_subscriber::{filter, reload}; use tracing_subscriber::{layer::SubscriberExt, registry::Registry}; use tracing_tree::HierarchicalLayer; +/// Initializes the logging system. +/// +/// # Panics +/// Panics if the log level is invalid, or if setting the global subscriber or initializing the log tracer fails. pub fn init_log(set_global: bool) -> Handle { let layer = HierarchicalLayer::default() .with_writer(std::io::stdout) @@ -21,14 +25,16 @@ pub fn init_log(set_global: bool) -> Handle { .with_targets(false); let level_str = std::env::var("LOG_LEVEL").unwrap_or("info".to_string()); - let level = Level::from_str(&level_str).unwrap(); + let level = Level::from_str(&level_str) + .expect("Invalid log level in LOG_LEVEL env var"); let filter = filter::LevelFilter::from_level(level); let (filter, reload_handle) = reload::Layer::new(filter); let subscriber = Registry::default().with(filter).with(layer); if set_global { - tracing::subscriber::set_global_default(subscriber).unwrap(); + tracing::subscriber::set_global_default(subscriber) + .expect("Failed to set global tracing subscriber"); } - tracing_log::LogTracer::init().unwrap(); + tracing_log::LogTracer::init().expect("Failed to initialize log tracer"); reload_handle } diff --git a/packages/sequent-core/src/util/integrity_check.rs b/packages/sequent-core/src/util/integrity_check.rs index eabed76f2dc..ac014b8c991 100644 --- a/packages/sequent-core/src/util/integrity_check.rs +++ b/packages/sequent-core/src/util/integrity_check.rs @@ -10,22 +10,31 @@ use strum_macros::Display; use tempfile::NamedTempFile; #[derive(Debug, Display)] +/// Errors that can occur during the integrity check of a file. pub enum HashFileVerifyError { #[strum(serialize = "io-error")] - IoError(String, std::io::Error), // Error reading voters file + /// Error reading voters file + IoError(String, std::io::Error), #[strum(serialize = "hash-mismatch")] - HashMismatch(String, String), // Voters file hash does not match + /// Voters file hash does not match + HashMismatch(String, String), #[strum(serialize = "hash-computing-error")] - HashComputingError(String, StrandError), // Error computing the hash + /// Error computing the hash + HashComputingError(String, StrandError), } impl std::error::Error for HashFileVerifyError {} +/// Checks the integrity of a file by comparing its SHA-256 hash. +/// +/// # Errors +/// Returns an error if the file cannot be opened, read, or if the hash does not match. pub fn integrity_check( temp_file_path: &NamedTempFile, sha256: String, ) -> Result<(), HashFileVerifyError> { - let sha256 = sha256.to_lowercase(); + let mut sha256 = sha256; + sha256.make_ascii_lowercase(); let mut file = File::open(temp_file_path).map_err(|err| { HashFileVerifyError::IoError( "Error opening the temp file.".to_string(), diff --git a/packages/sequent-core/src/util/mime.rs b/packages/sequent-core/src/util/mime.rs index 7df2035bb5a..0bd12b724cf 100644 --- a/packages/sequent-core/src/util/mime.rs +++ b/packages/sequent-core/src/util/mime.rs @@ -4,6 +4,7 @@ /// Returns all MIME types for the given file extension. /// Falls back to `["application/json"]` if none is found. +#[must_use] pub fn get_mime_types(extension: &str) -> &'static [&'static str] { // Static array of (extension, list-of-mime-types) tuples. static MIME_TYPES: [(&str, &[&str]); 27] = [ @@ -47,7 +48,7 @@ pub fn get_mime_types(extension: &str) -> &'static [&'static str] { ]; // Simple linear lookup through our static array - for (ext, mimes) in MIME_TYPES.iter() { + for (ext, mimes) in &MIME_TYPES { if *ext == extension { return mimes; } @@ -58,6 +59,7 @@ pub fn get_mime_types(extension: &str) -> &'static [&'static str] { } /// Checks if a given extension is associated with the specified MIME type. +#[must_use] pub fn matches_mime(extension: &str, mime_type: &str) -> bool { get_mime_types(extension).contains(&mime_type) } diff --git a/packages/sequent-core/src/util/mod.rs b/packages/sequent-core/src/util/mod.rs index 5f0f5b59bbe..8a778074078 100644 --- a/packages/sequent-core/src/util/mod.rs +++ b/packages/sequent-core/src/util/mod.rs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only +#![allow(missing_docs)] pub mod convert_vec; pub mod date; pub mod date_time; diff --git a/packages/sequent-core/src/util/normalize_vote.rs b/packages/sequent-core/src/util/normalize_vote.rs index 5b7e0ff27b4..9c9684ffd52 100644 --- a/packages/sequent-core/src/util/normalize_vote.rs +++ b/packages/sequent-core/src/util/normalize_vote.rs @@ -11,11 +11,12 @@ use crate::{ types::ceremonies::CountingAlgType, }; +#[must_use] pub fn normalize_vote_contest( input: &DecodedVoteContest, tally_type: CountingAlgType, remove_errors: bool, - invalid_choice_ids: &Vec, + invalid_choice_ids: &[String], ) -> DecodedVoteContest { let mut original = input.clone(); let filtered_choices: Vec<&DecodedVoteChoice> = original @@ -36,8 +37,12 @@ pub fn normalize_vote_contest( original } +/// Normalizes all contests in an election. +/// +/// # Errors +/// Returns an error if a contest cannot be found in the ballot style. pub fn normalize_election( - input: &Vec, + input: &[DecodedVoteContest], ballot_style: &BallotStyle, remove_errors: bool, ) -> Result> { @@ -48,8 +53,7 @@ pub fn normalize_election( .map(|contest| (contest.id.clone(), contest)) .collect(); let mut result: Vec = input - .clone() - .into_iter() + .iter() .map(|decoded_contest| -> Result { let contest = contest_map .get(&decoded_contest.contest_id) @@ -60,7 +64,7 @@ pub fn normalize_election( ))?; let invalid_candidate_ids = contest.get_invalid_candidate_ids(); Ok(normalize_vote_contest( - &decoded_contest, + decoded_contest, contest.get_counting_algorithm(), remove_errors, &invalid_candidate_ids, @@ -73,6 +77,7 @@ pub fn normalize_election( Ok(result) } +#[must_use] pub fn normalize_vote_choice( input: &DecodedVoteChoice, tally_type: CountingAlgType, @@ -88,15 +93,7 @@ pub fn normalize_vote_choice( }; } - original.write_in_text = match original.write_in_text { - Some(text) => { - if text.len() > 0 { - Some(text) - } else { - None - } - } - None => None, - }; + original.write_in_text = + original.write_in_text.filter(|text| !text.is_empty()); original } diff --git a/packages/sequent-core/src/util/path.rs b/packages/sequent-core/src/util/path.rs index 6a70b8444d6..96877059a2a 100644 --- a/packages/sequent-core/src/util/path.rs +++ b/packages/sequent-core/src/util/path.rs @@ -5,27 +5,25 @@ use std::ffi::OsStr; use std::fs; use std::path::{Path, PathBuf}; +#[must_use] pub fn list_subfolders(path: &Path) -> Vec { - let mut subfolders = Vec::new(); - if let Ok(entries) = fs::read_dir(path) { - for entry in entries { - if let Ok(entry) = entry { - let path = entry.path(); - if path.is_dir() { - subfolders.push(path); - } - } - } - } - subfolders + fs::read_dir(path) + .map(|entries| { + entries + .flatten() + .map(|entry| entry.path()) + .filter(|p| p.is_dir()) + .collect() + }) + .unwrap_or_default() } +#[must_use] pub fn get_folder_name(path: &Path) -> Option { path.components() - .last() - .map(|component| component.as_os_str().to_str()) - .flatten() - .map(|component| component.to_string()) + .next_back() + .and_then(|component| component.as_os_str().to_str()) + .map(std::string::ToString::to_string) } pub fn change_file_extension( diff --git a/packages/sequent-core/src/util/retry.rs b/packages/sequent-core/src/util/retry.rs index 41c05ad0d1c..89b4934d74d 100644 --- a/packages/sequent-core/src/util/retry.rs +++ b/packages/sequent-core/src/util/retry.rs @@ -14,6 +14,9 @@ use tracing::{info, instrument}; /// each time (`initial_backoff`, `2 * initial_backoff`, etc.) /// /// Returns `Ok(T)` on success or the last `Err(E)` on failure. +/// +/// # Errors +/// Returns the last error from the operation if all retries fail, or an error if arithmetic overflows. #[instrument(skip(op))] pub async fn retry_with_exponential_backoff( mut op: F, @@ -36,14 +39,14 @@ where } Err(err) if attempts < max_retries => { // Failure, but we can try again after a backoff delay - attempts += 1; + attempts = attempts.saturating_add(1); info!( "Failed attempt {attempts}, sleeping {:?} ms, error: {:?}", backoff, err ); sleep(backoff).await; // Exponential backoff: double the delay - backoff *= 2; + backoff = backoff.saturating_mul(2); } Err(err) => { info!("Failed attempt {attempts}, run out of retries, error: {:?}", err); diff --git a/packages/sequent-core/src/util/temp_path.rs b/packages/sequent-core/src/util/temp_path.rs index 3f2eb008852..e183dc1713d 100644 --- a/packages/sequent-core/src/util/temp_path.rs +++ b/packages/sequent-core/src/util/temp_path.rs @@ -10,6 +10,10 @@ use std::io::{self, BufWriter, Read, Seek, Write}; use tempfile::Builder; use tempfile::{NamedTempFile, TempPath}; +/// Gets the public assets path from the environment variable. +/// +/// # Errors +/// Returns an error if the environment variable is not set or invalid. pub fn get_public_assets_path_env_var() -> Result { match env::var("PUBLIC_ASSETS_PATH") { Ok(path) => Ok(path), @@ -18,25 +22,30 @@ pub fn get_public_assets_path_env_var() -> Result { } } +/// Gets the file size for the given file path. +/// +/// # Errors +/// Returns an error if the file does not exist or cannot be accessed. pub fn get_file_size(filepath: &str) -> Result { let metadata = fs::metadata(filepath).with_context(|| "Error get file size")?; Ok(metadata.len()) } -/* - * Writes data into a named temp file. The temp file will have the - * specificed prefix and suffix. - * - * Returns the TempPath of the file, the stringified version of the path to - * the file and the bytes size of the file. - * - * NOTE: The file will be dropped when the TempPath goes out of the scope. - * Returning the TempPath, even if the variable goes unused, allows the - * caller to control the lifetime of the created temp file. - */ +/// Writes data into a named temp file. The temp file will have the +/// specificed prefix and suffix. +/// +/// Returns the `TempPath` of the file, the stringified version of the path to +/// the file and the bytes size of the file. +/// +/// NOTE: The file will be dropped when the `TempPath` goes out of the scope. +/// Returning the `TempPath`, even if the variable goes unused, allows the +/// caller to control the lifetime of the created temp file. +/// +/// # Errors +/// Returns an error if the file cannot be created or written. pub fn write_into_named_temp_file( - data: &Vec, + data: &[u8], prefix: &str, suffix: &str, ) -> Result<(TempPath, String, u64)> { @@ -48,7 +57,7 @@ pub fn write_into_named_temp_file( .with_context(|| "Couldn't reopen file for writing")?; let mut buf_writer = BufWriter::new(file2); buf_writer - .write(&data) + .write(data) .with_context(|| "Error writing into named temp file")?; buf_writer .flush() @@ -61,6 +70,10 @@ pub fn write_into_named_temp_file( Ok((temp_path, temp_path_string, file_size)) } +/// Generates a named temporary file with the given prefix and suffix. +/// +/// # Errors +/// Returns an error if the file cannot be created. pub fn generate_temp_file(prefix: &str, suffix: &str) -> Result { // Get the system's temporary directory. let temp_dir = env::temp_dir(); @@ -78,6 +91,10 @@ pub fn generate_temp_file(prefix: &str, suffix: &str) -> Result { Ok(temp_file) } +/// Reads the contents of a named temporary file. +/// +/// # Errors +/// Returns an error if the file cannot be read. pub fn read_temp_file(temp_file: &mut NamedTempFile) -> Result> { // Rewind the file to the beginning to read its contents temp_file @@ -92,6 +109,10 @@ pub fn read_temp_file(temp_file: &mut NamedTempFile) -> Result> { Ok(file_bytes) } +/// Reads the contents of a temporary file path. +/// +/// # Errors +/// Returns an error if the file cannot be read. pub fn read_temp_path(temp_path: &TempPath) -> Result> { let mut file = File::open(temp_path).with_context(|| "Error opening temp file")?; diff --git a/packages/sequent-core/src/util/version.rs b/packages/sequent-core/src/util/version.rs index 101d144b0c0..ba0b39a6e48 100644 --- a/packages/sequent-core/src/util/version.rs +++ b/packages/sequent-core/src/util/version.rs @@ -10,6 +10,10 @@ pub const DEV_APP_VERSION: &str = "dev"; pub const ENV_VAR_APP_VERSION: &str = "APP_VERSION"; pub const ENV_VAR_APP_HASH: &str = "APP_HASH"; +/// Checks if the imported version is compatible with the current version. +/// +/// # Errors +/// Returns an error if the imported version is not compatible with the current version. pub fn check_version_compatibility( imported_version: &str, current_version: &str, @@ -30,32 +34,37 @@ pub fn check_version_compatibility( if imported_version == DEV_APP_VERSION { #[cfg(feature = "log")] info!("Imported version is 'dev' while system is not in dev mode, rejecting import"); - return Err(anyhow!("Imported version is 'dev', which is not compatible with current version {}. Please use a different version.", current_version)); + return Err(anyhow!("Imported version is 'dev', which is not compatible with current version {current_version}. Please use a different version.")); } - let current_major_parsed = extract_major(¤t_version) + let current_major_parsed = extract_major(current_version) .ok_or_else(|| anyhow!("Could not parse current version"))?; let imported_major_parsed = extract_major(imported_version) .ok_or_else(|| anyhow!("Could not parse imported version"))?; if current_major_parsed < imported_major_parsed { return Err(anyhow!( - "Version mismatch: Imported version {} is not compatible with current version {}. Please upgrade your system.", - imported_version, - current_version + "Version mismatch: Imported version {imported_version} is not compatible with current version {current_version}. Please upgrade your system." )); } Ok(()) } +/// Extracts the major version number from a version string. +/// +/// # Arguments +/// * `input` - The version string (e.g., "v1.2.3" or "1.2.3"). +/// +/// # Returns +/// An `Option` containing the major version number if parsing succeeds. fn extract_major(input: &str) -> Option { // Trim optional 'v' or 'V' prefix - let trimmed = input.trim_start_matches(|c| c == 'v' || c == 'V'); + let trimmed = input.trim_start_matches(['v', 'V'].as_ref()); // We take characters from the start as long as they are digits. // This stops at the first dot '.', hyphen '-', or any non-digit. let major_str: String = - trimmed.chars().take_while(|c| c.is_ascii_digit()).collect(); + trimmed.chars().take_while(char::is_ascii_digit).collect(); // Parse the result into a u64 // If the string was empty (e.g., input was "invalid"), this returns None. diff --git a/packages/sequent-core/src/util/voting_screen.rs b/packages/sequent-core/src/util/voting_screen.rs index 4a0a922ceb6..25162cca120 100644 --- a/packages/sequent-core/src/util/voting_screen.rs +++ b/packages/sequent-core/src/util/voting_screen.rs @@ -2,18 +2,30 @@ // // SPDX-License-Identifier: AGPL-3.0-only -use crate::ballot::*; -use crate::plaintext::*; +use crate::ballot::{ + Candidate, CandidatePresentation, Contest, ContestPresentation, + EBlankVotePolicy, EDuplicatedRankPolicy, EOverVotePolicy, + EPreferenceGapsPolicy, EUnderVotePolicy, InvalidVotePolicy, +}; +use crate::plaintext::{ + DecodedVoteChoice, DecodedVoteContest, InvalidPlaintextError, + InvalidPlaintextErrorType, +}; use crate::types::ceremonies::CountingAlgType; use crate::util::console_log; use std::collections::HashMap; -// Function used to decide if the voter needs to change his/her ballot before -// continuing +/// Function used to decide if the voter needs to change his/her ballot before +/// continuing +/// +/// # Panics +/// Panics if `choices_selected` cannot be converted to `i64` (should not happen for valid input). +#[must_use] +#[allow(clippy::implicit_hasher)] pub fn check_voting_not_allowed_next_util( - contests: Vec, - decoded_contests: HashMap, + contests: &[Contest], + decoded_contests: &HashMap, ) -> bool { let voting_not_allowed = contests.iter().any(|contest| { let default_vote_policy = InvalidVotePolicy::default(); @@ -58,7 +70,9 @@ pub fn check_voting_not_allowed_next_util( .iter() .filter(|choice| choice.selected == 0) .count(); - + let choices_selected_i64 = i64::try_from(choices_selected).expect("error convert choices_selected to i64") else { + return false; + }; let invalid_errors: &Vec = &decoded_contest.invalid_errors; @@ -80,7 +94,7 @@ pub fn check_voting_not_allowed_next_util( && *blank_policy == EBlankVotePolicy::NOT_ALLOWED) // - selection is more than maximum and over vote policy is // NOT_ALLOWED_WITH_MSG_AND_ALERT - || (choices_selected as i64 > max + || (choices_selected_i64 > max && over_vote_policy == EOverVotePolicy::NOT_ALLOWED_WITH_MSG_AND_ALERT) // - duplicated rank policy is NOT_ALLOWED and there's a @@ -111,11 +125,16 @@ pub fn check_voting_not_allowed_next_util( voting_not_allowed } -// if returns true, when the user click next, there will be a dialog that -// prompts the user to confirm before going to the next screen +/// if returns true, when the user click next, there will be a dialog that +/// prompts the user to confirm before going to the next screen +/// +/// # Panics +/// Panics if `choices_selected` cannot be converted to `i64` (should not happen for valid input). +#[must_use] +#[allow(clippy::implicit_hasher)] pub fn check_voting_error_dialog_util( - contests: Vec, - decoded_contests: HashMap, + contests: &[Contest], + decoded_contests: &HashMap, ) -> bool { let show_voting_alert = contests.iter().any(|contest| { let invalid_vote_policy = contest @@ -174,6 +193,9 @@ pub fn check_voting_error_dialog_util( console_log!("choices_selected={choices_selected:?}, explicit_invalid={explicit_invalid:?}"); + let choices_selected_i64 = i64::try_from(choices_selected).expect("error convert choices_selected to i64") else { + return false; + }; // Show Alert dialog if: // - there are invalid error and it's not allowed (!invalid_errors.is_empty() @@ -188,15 +210,15 @@ pub fn check_voting_error_dialog_util( && choices_selected == 0) // - more than max choices were selected and over vote policy is // ALLOWED_WITH_MSG_AND_ALERT - || (choices_selected as i64 > max + || (choices_selected_i64 > max && over_vote_policy == EOverVotePolicy::ALLOWED_WITH_MSG_AND_ALERT) // - it's not a blank vote because there is at least one selection, // the selection is less than the maximum (i.e. undervote) and // undervote policy is WARN_AND_ALERT - || ((choices_selected > 0 - && (choices_selected as i64) >= min - && (choices_selected as i64) < max) + || ((choices_selected_i64 > 0 + && choices_selected_i64 >= min + && choices_selected_i64 < max) && under_vote_policy == EUnderVotePolicy::WARN_AND_ALERT) // - duplicated rank policy is WARN_AND_ALERT and there's a // duplicated position error @@ -228,6 +250,10 @@ pub fn check_voting_error_dialog_util( show_voting_alert } +/// This function is used to create a decoded contest with an invalid errors +/// for plurality voting, which can be used in the voting screen tests. +#[must_use] +#[allow(clippy::too_many_lines)] pub fn get_contest_plurality( over_vote_policy: EOverVotePolicy, blank_vote_policy: EBlankVotePolicy, @@ -428,6 +454,9 @@ pub fn get_contest_plurality( } } +/// This function is used to create a decoded contest with an invalid alerts +/// for plurality voting, which can be used in the voting screen tests. +#[must_use] pub fn get_decoded_contest_plurality(contest: &Contest) -> DecodedVoteContest { let message_map = [ ("max".to_string(), "1".to_string()), diff --git a/packages/sequent-core/src/wasm/areas.rs b/packages/sequent-core/src/wasm/areas.rs index 0287036b717..7f44c10c3f7 100644 --- a/packages/sequent-core/src/wasm/areas.rs +++ b/packages/sequent-core/src/wasm/areas.rs @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0-only -use crate::services::area_tree::*; +use crate::services::area_tree::{ContestsData, TreeNode, TreeNodeArea}; use crate::types::hasura::core::AreaContest; use crate::wasm::wasm::IntoResult; use std::collections::HashSet; @@ -15,6 +15,10 @@ use serde_wasm_bindgen::Serializer; use std::collections::HashMap; use std::panic; +/// Creates a tree structure from areas JSON for JS interop. +/// +/// # Errors +/// Returns an error if parsing or serialization fails. #[allow(clippy::all)] #[wasm_bindgen] pub fn create_tree_js( @@ -22,26 +26,29 @@ pub fn create_tree_js( area_contests_json: JsValue, ) -> Result { // parse input - let areas: Vec = serde_wasm_bindgen::from_value(areas_json) - .map_err(|err| { - format!("Error reading javascript areas: {}", err) - })?; + let areas: Vec = + serde_wasm_bindgen::from_value(areas_json) + .map_err(|err| format!("Error reading javascript areas: {err}"))?; let area_contests: Vec = serde_wasm_bindgen::from_value(area_contests_json).map_err(|err| { - format!("Error reading javascript area_contests: {}", err) + format!("Error reading javascript area_contests: {err}") })?; let base_tree = - TreeNode::<()>::from_areas(areas).map_err(|err| format!("{}", err))?; + TreeNode::<()>::from_areas(areas).map_err(|err| format!("{err}"))?; let contests_data_tree = base_tree.get_contests_data_tree(&area_contests); let serializer = Serializer::json_compatible(); contests_data_tree .serialize(&serializer) - .map_err(|err| format!("{:?}", err)) + .map_err(|err| format!("Error serializing contests_data_tree: {err}")) .into_json() } +/// Gets contest matches from a contests tree JS object. +/// +/// # Errors +/// Returns an error if parsing or serialization fails. #[allow(clippy::all)] #[wasm_bindgen] pub fn get_contest_matches_js( @@ -51,11 +58,11 @@ pub fn get_contest_matches_js( // parse input let contests_tree: TreeNode = serde_wasm_bindgen::from_value(contests_tree_js).map_err(|err| { - format!("Error reading javascript contests_tree: {}", err) + format!("Error reading javascript contests_tree: {err}") })?; let contest_id: String = serde_wasm_bindgen::from_value(contest_id_js) .map_err(|err| { - format!("Error reading javascript contest_id_js: {}", err) + format!("Error reading javascript contest_id_js: {err}") })?; let contests_hashset: HashSet = vec![contest_id].into_iter().collect(); @@ -64,6 +71,6 @@ pub fn get_contest_matches_js( let serializer = Serializer::json_compatible(); area_contests .serialize(&serializer) - .map_err(|err| format!("{:?}", err)) + .map_err(|err| format!("Error serializing area_contests: {err}")) .into_json() } diff --git a/packages/sequent-core/src/wasm/mod.rs b/packages/sequent-core/src/wasm/mod.rs index 28cab484b4f..7aecf9171c0 100644 --- a/packages/sequent-core/src/wasm/mod.rs +++ b/packages/sequent-core/src/wasm/mod.rs @@ -2,15 +2,28 @@ // // SPDX-License-Identifier: AGPL-3.0-only +//! This module contains the WebAssembly bindings for the Sequent Core library. +//! It includes TypeScript interfaces and enums that are used to +//! interact with the library from JavaScript/TypeScript code. + +/// `wasm_bindgen` for template related types and interfaces. pub mod templates; +/// `wasm_bindgen` for hasura related types and interfaces. pub mod wasm_hasura_types; +/// `wasm_bindgen` for interpret plaintext related types and interfaces. pub mod wasm_interpret_plaintext; +/// `wasm_bindgen` for Keycloak related types and interfaces. pub mod wasm_keycloak; +/// `wasm_bindgen` for permission related types and interfaces. pub mod wasm_permissions; +/// `wasm_bindgen` for plaintext related types and interfaces. pub mod wasm_plaintext; #[cfg(feature = "wasmtest")] +/// `WebAssembly` bindings exported to frontend packages for accessing area information. pub mod areas; #[cfg(feature = "wasmtest")] +#[allow(clippy::module_inception)] +/// `WebAssembly` bindings exported to frontend packages. pub mod wasm; diff --git a/packages/sequent-core/src/wasm/templates.rs b/packages/sequent-core/src/wasm/templates.rs index f9f70c10ab3..48dfea38dbe 100644 --- a/packages/sequent-core/src/wasm/templates.rs +++ b/packages/sequent-core/src/wasm/templates.rs @@ -15,6 +15,7 @@ enum IAudienceSelection { #[wasm_bindgen] extern "C" { + /// TypeScript interface describing the audience selection for a template. #[wasm_bindgen(typescript_type = "IAudienceSelection")] pub type IAudienceSelection; } @@ -33,6 +34,7 @@ enum ITemplateType { #[wasm_bindgen] extern "C" { + /// TypeScript interface describing the type of a template. #[wasm_bindgen(typescript_type = "ITemplateType")] pub type ITemplateType; } @@ -47,6 +49,7 @@ enum ITemplateMethod { #[wasm_bindgen] extern "C" { + /// TypeScript interface describing the communication method for a template. #[wasm_bindgen(typescript_type = "ITemplateMethod")] pub type ITemplateMethod; } @@ -62,6 +65,7 @@ interface IEmailConfig { #[wasm_bindgen] extern "C" { + /// TypeScript interface describing the email configuration for a template. #[wasm_bindgen(typescript_type = "IEmailConfig")] pub type IEmailConfig; } @@ -75,6 +79,7 @@ interface ISmsConfig { #[wasm_bindgen] extern "C" { + /// TypeScript interface describing the SMS configuration for a template. #[wasm_bindgen(typescript_type = "ISmsConfig")] pub type ISmsConfig; } @@ -89,6 +94,7 @@ interface ICommTemplates { #[wasm_bindgen] extern "C" { + /// TypeScript interface describing the communication configuration for a template. #[wasm_bindgen(typescript_type = "ICommTemplates")] pub type ICommTemplates; } @@ -104,6 +110,7 @@ interface IExtraConfig { #[wasm_bindgen] extern "C" { + /// TypeScript interface describing the extra configuration options for a template. #[wasm_bindgen(typescript_type = "IExtraConfig")] pub type IExtraConfig; } @@ -126,6 +133,7 @@ interface ISendTemplateBody { #[wasm_bindgen] extern "C" { + /// TypeScript interface describing the body of a request to send a template. #[wasm_bindgen(typescript_type = "ISendTemplateBody")] pub type ISendTemplateBody; } diff --git a/packages/sequent-core/src/wasm/wasm.rs b/packages/sequent-core/src/wasm/wasm.rs index 23c50180e39..288384ad805 100644 --- a/packages/sequent-core/src/wasm/wasm.rs +++ b/packages/sequent-core/src/wasm/wasm.rs @@ -1,27 +1,41 @@ // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only -use crate::ballot::*; use crate::ballot::{ sign_hashable_ballot_with_ephemeral_voter_signing_key, - verify_ballot_signature, + verify_ballot_signature, AuditableBallot, BallotStyle, Candidate, + CandidatesOrder, ConsolidatedReportPolicy, Contest, ContestsOrder, + EDuplicatedRankPolicy, EPreferenceGapsPolicy, Election, ElectionsOrder, + HashableBallot, SignedHashableBallot, }; use crate::ballot_codec::bigint::BigUIntCodec; -use crate::ballot_codec::multi_ballot::*; +use crate::ballot_codec::multi_ballot::test_multi_contest_reencoding; use crate::ballot_codec::raw_ballot::RawBallotCodec; use crate::encrypt; -use crate::encrypt::*; -use crate::fixtures::ballot_codec::*; +use crate::encrypt::{ + encrypt_decoded_contest, encrypt_decoded_multi_contest, hash_ballot, + hash_multi_ballot, +}; +use crate::fixtures::ballot_codec::{ + get_writein_ballot_style, get_writein_plaintext, +}; use crate::interpret_plaintext::{ check_is_blank, get_layout_properties, get_points, }; -use crate::multi_ballot::*; -use crate::plaintext::*; +use crate::multi_ballot::{ + sign_hashable_multi_ballot_with_ephemeral_voter_signing_key, + verify_multi_ballot_signature, AuditableMultiBallot, HashableMultiBallot, + SignedHashableMultiBallot, +}; +use crate::plaintext::{ + map_to_decoded_contest, map_to_decoded_multi_contest, DecodedVoteChoice, + DecodedVoteContest, +}; use crate::serialization::deserialize_with_path::deserialize_value; use crate::services::generate_urls::get_auth_url; use crate::services::generate_urls::AuthAction; use crate::types::ceremonies::CountingAlgType; -use crate::util::normalize_vote::*; +use crate::util::normalize_vote::normalize_vote_contest; use strand::backend::ristretto::RistrettoCtx; use wasm_bindgen::prelude::*; extern crate console_error_panic_hook; @@ -48,28 +62,44 @@ use std::panic; // } #[derive(Serialize, Deserialize, JsonSchema, PartialEq, Eq, Debug, Clone)] +/// A structured error status for JS interop, containing an error type and message. pub struct ErrorStatus { + /// The type of error that occurred, categorized by `BallotError`. pub error_type: BallotError, + /// A message providing details about the error. pub error_msg: String, } #[derive(Debug, Serialize, Deserialize, PartialEq, JsonSchema, Clone, Eq)] +/// Error types for ballot processing. pub enum BallotError { + /// An error occurred while parsing the input JSON. PARSE_ERROR, + /// An error occurred while deserializing an auditable ballot. DESERIALIZE_AUDITABLE_ERROR, + /// An error occurred while deserializing a hashable ballot. DESERIALIZE_HASHABLE_ERROR, + /// An error occurred while converting between ballot types. CONVERT_ERROR, + /// An error occurred while serializing a ballot to JSON. SERIALIZE_ERROR, + /// An error occurred while hashing a ballot. INVALID_BALLOT, } impl From for JsValue { fn from(error: ErrorStatus) -> JsValue { - serde_wasm_bindgen::to_value(&error).unwrap() + serde_wasm_bindgen::to_value(&error) + .expect("Failed to convert ErrorStatus to JsValue") } } +/// A trait to convert `Result` into `Result` for better error handling in JS interop. pub trait IntoResult { + /// Converts a `Result` into a `Result`. + /// + /// # Errors + /// Returns an error if the conversion fails. fn into_json(self) -> Result; } @@ -78,8 +108,7 @@ impl IntoResult for Result { self.map_err(|err| { serde_wasm_bindgen::to_value(&err).unwrap_or_else(|serde_err| { JsValue::from_str(&format!( - "Error converting error to JSON: {}", - serde_err + "Error converting error to JSON: {serde_err}" )) }) }) @@ -95,10 +124,15 @@ extern "C" { } #[wasm_bindgen] +/// Sets up panic hooks for better error messages in JS interop. pub fn set_hooks() { panic::set_hook(Box::new(console_error_panic_hook::hook)); } +/// Converts an auditable ballot JSON to a hashable ballot representation for JS interop. +/// +/// # Errors +/// Returns an error if parsing, conversion, or serialization fails. #[allow(clippy::all)] #[wasm_bindgen] pub fn to_hashable_ballot_js( @@ -107,11 +141,11 @@ pub fn to_hashable_ballot_js( // Parse input let auditable_ballot_js: Value = serde_wasm_bindgen::from_value(auditable_ballot_json) - .map_err(|err| format!("Failed to parse auditable ballot: {}", err)) + .map_err(|err| format!("Failed to parse auditable ballot: {err}")) .into_json()?; let auditable_ballot: AuditableBallot = deserialize_value(auditable_ballot_js) - .map_err(|err| format!("Failed to parse auditable ballot: {}", err)) + .map_err(|err| format!("Failed to parse auditable ballot: {err}")) .into_json()?; // Test deserializing auditable ballot contests @@ -121,8 +155,7 @@ pub fn to_hashable_ballot_js( JsValue::from(ErrorStatus { error_type: BallotError::DESERIALIZE_AUDITABLE_ERROR, error_msg: format!( - "Failed to deserialize auditable ballot contests: {}", - err + "Failed to deserialize auditable ballot contests: {err}" ), }) })?; @@ -133,8 +166,7 @@ pub fn to_hashable_ballot_js( JsValue::from(ErrorStatus { error_type: BallotError::CONVERT_ERROR, error_msg: format!( - "Failed to convert auditable ballot to hashable ballot: {}", - err + "Failed to convert auditable ballot to hashable ballot: {err}" ), }) })?; @@ -146,8 +178,7 @@ pub fn to_hashable_ballot_js( JsValue::from(ErrorStatus { error_type: BallotError::DESERIALIZE_HASHABLE_ERROR, error_msg: format!( - "Failed to deserialize hashable ballot contests: {}", - err + "Failed to deserialize hashable ballot contests: {err}" ), }) })?; @@ -157,11 +188,15 @@ pub fn to_hashable_ballot_js( deserialized_ballot.serialize(&serializer).map_err(|err| { JsValue::from(ErrorStatus { error_type: BallotError::SERIALIZE_ERROR, - error_msg: format!("Failed to serialize hashable ballot: {}", err), + error_msg: format!("Failed to serialize hashable ballot: {err}"), }) }) } +/// Converts an auditable multi-ballot JSON to a hashable multi-ballot representation for JS interop. +/// +/// # Errors +/// Returns an error if parsing, conversion, or serialization fails. #[allow(clippy::all)] #[wasm_bindgen] pub fn to_hashable_multi_ballot_js( @@ -171,13 +206,13 @@ pub fn to_hashable_multi_ballot_js( let auditable_multi_ballot_js: Value = serde_wasm_bindgen::from_value(auditable_multi_ballot_json) .map_err(|err| { - format!("Failed to parse auditable multi ballot: {}", err) + format!("Failed to parse auditable multi ballot: {err}") }) .into_json()?; let auditable_multi_ballot: AuditableMultiBallot = deserialize_value(auditable_multi_ballot_js) .map_err(|err| { - format!("Failed to parse auditable multi ballot: {}", err) + format!("Failed to parse auditable multi ballot: {err}") }) .into_json()?; @@ -188,8 +223,7 @@ pub fn to_hashable_multi_ballot_js( JsValue::from(ErrorStatus { error_type: BallotError::DESERIALIZE_AUDITABLE_ERROR, error_msg: format!( - "Failed to deserialize auditable multi ballot contests: {}", - err + "Failed to deserialize auditable multi ballot contests: {err}" ), }) })?; @@ -201,8 +235,7 @@ pub fn to_hashable_multi_ballot_js( JsValue::from(ErrorStatus { error_type: BallotError::CONVERT_ERROR, error_msg: format!( - "Failed to convert auditable multi ballot to hashable multi ballot: {}", - err + "Failed to convert auditable multi ballot to hashable multi ballot: {err}" ), }) }, @@ -215,8 +248,7 @@ pub fn to_hashable_multi_ballot_js( JsValue::from(ErrorStatus { error_type: BallotError::DESERIALIZE_HASHABLE_ERROR, error_msg: format!( - "Failed to deserialize hashable multi ballot contests: {}", - err + "Failed to deserialize hashable multi ballot contests: {err:?}" ), }) })?; @@ -227,13 +259,16 @@ pub fn to_hashable_multi_ballot_js( JsValue::from(ErrorStatus { error_type: BallotError::SERIALIZE_ERROR, error_msg: format!( - "Failed to serialize hashable multi ballot: {}", - err + "Failed to serialize hashable multi ballot: {err:?}" ), }) }) } +/// Computes the hash of an auditable ballot JSON for JS interop. +/// +/// # Errors +/// Returns an error if parsing, conversion, or serialization fails. #[allow(clippy::all)] #[wasm_bindgen] pub fn hash_auditable_ballot_js( @@ -274,6 +309,10 @@ pub fn hash_auditable_ballot_js( .into_json() } +/// Computes the hash of an auditable multi-ballot JSON for JS interop. +/// +/// # Errors +/// Returns an error if parsing, conversion, or serialization fails. #[allow(clippy::all)] #[wasm_bindgen] pub fn hash_auditable_multi_ballot_js( @@ -309,6 +348,10 @@ pub fn hash_auditable_multi_ballot_js( .into_json() } +/// Encrypts a decoded contest JSON for JS interop. +/// +/// # Errors +/// Returns an error if parsing, encryption, or serialization fails. #[allow(clippy::all)] #[wasm_bindgen] pub fn encrypt_decoded_contest_js( @@ -318,17 +361,17 @@ pub fn encrypt_decoded_contest_js( // parse inputs let decoded_contests_js: Value = serde_wasm_bindgen::from_value(decoded_contests_json) - .map_err(|err| format!("Error parsing decoded contests: {}", err)) + .map_err(|err| format!("Error parsing decoded contests: {err}")) .into_json()?; let decoded_contests: Vec = deserialize_value(decoded_contests_js) - .map_err(|err| format!("Error parsing decoded contests: {}", err)) + .map_err(|err| format!("Error parsing decoded contests: {err}")) .into_json()?; let election_js: Value = serde_wasm_bindgen::from_value(election_json) - .map_err(|err| format!("Error parsing election: {}", err)) + .map_err(|err| format!("Error parsing election: {err}")) .into_json()?; let election: BallotStyle = deserialize_value(election_js) - .map_err(|err| format!("Error parsing election: {}", err)) + .map_err(|err| format!("Error parsing election: {err}")) .into_json()?; // create context let ctx = RistrettoCtx; @@ -339,7 +382,7 @@ pub fn encrypt_decoded_contest_js( &decoded_contests, &election, ) - .map_err(|err| format!("Error encrypting decoded contests {:?}", err)) + .map_err(|err| format!("Error encrypting decoded contests {err:?}")) .into_json()?; // convert to json output @@ -347,11 +390,15 @@ pub fn encrypt_decoded_contest_js( auditable_ballot .serialize(&serializer) .map_err(|err| { - format!("Error converting auditable ballot to json {:?}", err) + format!("Error converting auditable ballot to json {err:?}") }) .into_json() } +/// Encrypts a decoded multi-contest JSON for JS interop. +/// +/// # Errors +/// Returns an error if parsing, encryption, or serialization fails. #[allow(clippy::all)] #[wasm_bindgen] pub fn encrypt_decoded_multi_contest_js( @@ -361,17 +408,17 @@ pub fn encrypt_decoded_multi_contest_js( // parse inputs let decoded_multi_contests_js: Value = serde_wasm_bindgen::from_value(decoded_multi_contests_json) - .map_err(|err| format!("Error parsing decoded contests: {}", err)) + .map_err(|err| format!("Error parsing decoded contests: {err}")) .into_json()?; let decoded_multi_contests: Vec = deserialize_value(decoded_multi_contests_js) - .map_err(|err| format!("Error parsing decoded contests: {}", err)) + .map_err(|err| format!("Error parsing decoded contests: {err}")) .into_json()?; let election_js: Value = serde_wasm_bindgen::from_value(election_json) - .map_err(|err| format!("Error parsing election: {}", err)) + .map_err(|err| format!("Error parsing election: {err}")) .into_json()?; let election: BallotStyle = deserialize_value(election_js) - .map_err(|err| format!("Error parsing election: {}", err)) + .map_err(|err| format!("Error parsing election: {err}")) .into_json()?; // create context let ctx = RistrettoCtx; @@ -382,7 +429,7 @@ pub fn encrypt_decoded_multi_contest_js( &decoded_multi_contests, &election, ) - .map_err(|err| format!("Error encrypting decoded contests {:?}", err)) + .map_err(|err| format!("Error encrypting decoded contests {err:?}")) .into_json()?; // convert to json output @@ -390,12 +437,16 @@ pub fn encrypt_decoded_multi_contest_js( auditable_multi_ballot .serialize(&serializer) .map_err(|err| { - format!("Error converting auditable ballot to json {:?}", err) + format!("Error converting auditable ballot to json {err:?}") }) .into_json() } // before: map_to_decoded_ballot +/// Decodes an auditable ballot JSON for JS interop. +/// +/// # Errors +/// Returns an error if parsing or decoding fails. #[allow(clippy::all)] #[wasm_bindgen] pub fn decode_auditable_ballot_js( @@ -405,8 +456,7 @@ pub fn decode_auditable_ballot_js( serde_wasm_bindgen::from_value(auditable_ballot_json) .map_err(|err| { format!( - "Error parsing auditable ballot javascript string: {}", - err + "Error parsing auditable ballot javascript string: {err}" ) }) .into_json()?; @@ -414,8 +464,7 @@ pub fn decode_auditable_ballot_js( deserialize_value(auditable_ballot_js) .map_err(|err| { format!( - "Error parsing auditable ballot javascript string: {}", - err + "Error parsing auditable ballot javascript string: {err}" ) }) .into_json()?; @@ -426,12 +475,16 @@ pub fn decode_auditable_ballot_js( plaintext .serialize(&serializer) .map_err(|err| { - format!("Error converting decoded ballot to json {:?}", err) + format!("Error converting decoded ballot to json {err:?}") }) .into_json() } // before: map_to_decoded_ballot +/// Decodes an auditable multi-ballot JSON for JS interop. +/// +/// # Errors +/// Returns an error if parsing or decoding fails. #[allow(clippy::all)] #[wasm_bindgen] pub fn decode_auditable_multi_ballot_js( @@ -441,8 +494,7 @@ pub fn decode_auditable_multi_ballot_js( serde_wasm_bindgen::from_value(auditable_multi_ballot_json) .map_err(|err| { format!( - "Error parsing auditable ballot javascript string: {}", - err + "Error parsing auditable ballot javascript string: {err}" ) }) .into_json()?; @@ -450,8 +502,7 @@ pub fn decode_auditable_multi_ballot_js( deserialize_value(auditable_multi_ballot_js) .map_err(|err| { format!( - "Error parsing auditable ballot javascript string: {}", - err + "Error parsing auditable ballot javascript string: {err}" ) }) .into_json()?; @@ -464,24 +515,28 @@ pub fn decode_auditable_multi_ballot_js( plaintext .serialize(&serializer) .map_err(|err| { - format!("Error converting decoded multi ballot to json {:?}", err) + format!("Error converting decoded multi ballot to json {err:?}") }) .into_json() } #[wasm_bindgen] +/// Sorts a list of candidates for JS interop. +/// +/// # Errors +/// Returns an error if parsing or sorting fails. pub fn sort_candidates_list_js( - all_candidates: JsValue, - order: JsValue, - apply_random: JsValue, + all_candidates_json: JsValue, + order: &JsValue, + apply_random: &JsValue, ) -> Result { let all_candidates_js: Value = - serde_wasm_bindgen::from_value(all_candidates) - .map_err(|err| format!("Error parsing candidates: {}", err)) + serde_wasm_bindgen::from_value(all_candidates_json) + .map_err(|err| format!("Error parsing candidates: {err}")) .into_json()?; let mut all_candidates: Vec = deserialize_value(all_candidates_js) - .map_err(|err| format!("Error parsing candidates: {}", err)) + .map_err(|err| format!("Error parsing candidates: {err}")) .into_json()?; let order_field: CandidatesOrder = serde_wasm_bindgen::from_value(order.clone()) @@ -534,21 +589,25 @@ pub fn sort_candidates_list_js( let serializer = Serializer::json_compatible(); Serialize::serialize(&all_candidates, &serializer) - .map_err(|err| format!("Error converting array to json {:?}", err)) + .map_err(|err| format!("Error converting array to json {err:?}")) .into_json() } #[wasm_bindgen] +/// Sorts a list of contests. +/// +/// # Errors +/// Returns an error if parsing or sorting fails. pub fn sort_contests_list_js( contests_json: JsValue, - order: JsValue, - apply_random: JsValue, + order: &JsValue, + apply_random: &JsValue, ) -> Result { let contests_js: Value = serde_wasm_bindgen::from_value(contests_json) - .map_err(|err| format!("Error parsing contests: {}", err)) + .map_err(|err| format!("Error parsing contests: {err}")) .into_json()?; let mut all_contests: Vec = deserialize_value(contests_js) - .map_err(|err| format!("Error parsing contests: {}", err)) + .map_err(|err| format!("Error parsing contests: {err}")) .into_json()?; let order_field: ContestsOrder = serde_wasm_bindgen::from_value(order.clone()) @@ -601,21 +660,25 @@ pub fn sort_contests_list_js( let serializer = Serializer::json_compatible(); Serialize::serialize(&all_contests, &serializer) - .map_err(|err| format!("Error converting array to json {:?}", err)) + .map_err(|err| format!("Error converting array to json {err:?}")) .into_json() } #[wasm_bindgen] +/// Sorts a list of elections. +/// +/// # Errors +/// Returns an error if parsing or sorting fails. pub fn sort_elections_list_js( elections_json: JsValue, - order: JsValue, - apply_random: JsValue, + order: &JsValue, + apply_random: &JsValue, ) -> Result { let elections_js: Value = serde_wasm_bindgen::from_value(elections_json) - .map_err(|err| format!("Error parsing elections: {}", err)) + .map_err(|err| format!("Error parsing elections: {err}")) .into_json()?; let mut all_elections: Vec = deserialize_value(elections_js) - .map_err(|err| format!("Error parsing elections: {}", err)) + .map_err(|err| format!("Error parsing elections: {err}")) .into_json()?; let order_field: ElectionsOrder = serde_wasm_bindgen::from_value(order.clone()) @@ -668,67 +731,87 @@ pub fn sort_elections_list_js( let serializer = Serializer::json_compatible(); Serialize::serialize(&all_elections, &serializer) - .map_err(|err| format!("Error converting array to json {:?}", err)) + .map_err(|err| format!("Error converting array to json {err:?}")) .into_json() } #[wasm_bindgen] +/// Gets layout properties from a contest. +/// +/// # Errors +/// Returns an error if parsing or serialization fails. pub fn get_layout_properties_from_contest_js( contest_json: JsValue, ) -> Result { let contests_js: Value = serde_wasm_bindgen::from_value(contest_json) - .map_err(|err| format!("Error parsing contest: {}", err)) + .map_err(|err| format!("Error parsing contest: {err}")) .into_json()?; let contest: Contest = deserialize_value(contests_js) - .map_err(|err| format!("Error parsing contest: {}", err)) + .map_err(|err| format!("Error parsing contest: {err}")) .into_json()?; let properties = get_layout_properties(&contest); let serializer = Serializer::json_compatible(); properties .serialize(&serializer) - .map_err(|err| format!("{:?}", err)) + .map_err(|err| { + format!("Error converting layout properties to json {err:?}") + }) .into_json() } +/// Gets the points for a candidate in a contest. +/// +/// # Errors +/// Returns an error if parsing or serialization fails. #[wasm_bindgen] pub fn get_candidate_points_js( contest_json: JsValue, candidate_val: JsValue, ) -> Result { let contests_js: Value = serde_wasm_bindgen::from_value(contest_json) - .map_err(|err| format!("Error parsing contest: {}", err)) + .map_err(|err| format!("Error parsing contest: {err}")) .into_json()?; let contest: Contest = deserialize_value(contests_js) - .map_err(|err| format!("Error parsing contest: {}", err)) + .map_err(|err| format!("Error parsing contest: {err}")) .into_json()?; let candidate: DecodedVoteChoice = serde_wasm_bindgen::from_value(candidate_val) - .map_err(|err| format!("Error parsing vote choice: {}", err)) + .map_err(|err| format!("Error parsing vote choice: {err}")) .into_json()?; let points = get_points(&contest, &candidate); let serializer = Serializer::json_compatible(); Serialize::serialize(&points, &serializer) - .map_err(|err| format!("{:?}", err)) + .map_err(|err| format!("{err:?}")) .into_json() } +/// Checks if the counting algorithm is preferential. +/// +/// # Errors +/// Returns an error if parsing or serialization fails. #[wasm_bindgen] pub fn is_preferential_js( counting_algorithm_js: JsValue, ) -> Result { let counting_algorithm: CountingAlgType = serde_wasm_bindgen::from_value(counting_algorithm_js) - .map_err(|err| format!("Error parsing counting algorithm: {}", err)) + .map_err(|err| format!("Error parsing counting algorithm: {err}")) .into_json()?; let is_pref = counting_algorithm.is_preferential(); let serializer = Serializer::json_compatible(); Serialize::serialize(&is_pref, &serializer) - .map_err(|err| format!("{:?}", err)) + .map_err(|err| { + format!("Error converting boolean is_preferential to json {err:?}") + }) .into_json() } +/// Tests contest reencoding for consistency. +/// +/// # Errors +/// Returns an error if parsing, encoding, or decoding fails. #[wasm_bindgen] pub fn test_contest_reencoding_js( decoded_contest_json: JsValue, @@ -737,18 +820,18 @@ pub fn test_contest_reencoding_js( // parse inputs let decoded_contest_js: Value = serde_wasm_bindgen::from_value(decoded_contest_json) - .map_err(|err| format!("Error parsing decoded contest: {}", err)) + .map_err(|err| format!("Error parsing decoded contest: {err}")) .into_json()?; let decoded_contest: DecodedVoteContest = deserialize_value(decoded_contest_js) - .map_err(|err| format!("Error parsing decoded contest: {}", err)) + .map_err(|err| format!("Error parsing decoded contest: {err}")) .into_json()?; let ballot_style_js: Value = serde_wasm_bindgen::from_value(ballot_style_json) - .map_err(|err| format!("Error parsing election: {}", err)) + .map_err(|err| format!("Error parsing election: {err}")) .into_json()?; let ballot_style: BallotStyle = deserialize_value(ballot_style_js) - .map_err(|err| format!("Error parsing election: {}", err)) + .map_err(|err| format!("Error parsing election: {err}")) .into_json()?; let contest = ballot_style @@ -785,8 +868,7 @@ pub fn test_contest_reencoding_js( ); if input_compare != output_compare { return Err(format!( - "Consistency check failed. Input =! Output, {:?} != {:?}", - input_compare, output_compare + "Consistency check failed. Input =! Output, {input_compare:?} != {output_compare:?}" )) .into_json(); } @@ -795,11 +877,15 @@ pub fn test_contest_reencoding_js( modified_decoded_contest .serialize(&serializer) .map_err(|err| { - format!("Error converting decoded contest to json {:?}", err) + format!("Error converting decoded contest to json {err:?}") }) .into_json() } +/// Tests multi-contest reencoding for consistency. +/// +/// # Errors +/// Returns an error if parsing, encoding, or decoding fails. #[wasm_bindgen] pub fn test_multi_contest_reencoding_js( decoded_multi_contest_json: JsValue, @@ -808,22 +894,18 @@ pub fn test_multi_contest_reencoding_js( // parse inputs let decoded_multi_contest_js: Value = serde_wasm_bindgen::from_value(decoded_multi_contest_json) - .map_err(|err| { - format!("Error parsing decoded contest vec: {}", err) - }) + .map_err(|err| format!("Error parsing decoded contest vec: {err}")) .into_json()?; let decoded_multi_contests: Vec = deserialize_value(decoded_multi_contest_js) - .map_err(|err| { - format!("Error parsing decoded contest vec: {}", err) - }) + .map_err(|err| format!("Error parsing decoded contest vec: {err}")) .into_json()?; let ballot_style_js: Value = serde_wasm_bindgen::from_value(ballot_style_json) - .map_err(|err| format!("Error parsing election: {}", err)) + .map_err(|err| format!("Error parsing election: {err}")) .into_json()?; let ballot_style: BallotStyle = deserialize_value(ballot_style_js) - .map_err(|err| format!("Error parsing election: {}", err)) + .map_err(|err| format!("Error parsing election: {err}")) .into_json()?; let output_decoded_contests = @@ -834,11 +916,15 @@ pub fn test_multi_contest_reencoding_js( output_decoded_contests .serialize(&serializer) .map_err(|err| { - format!("Error converting decoded contest to json {:?}", err) + format!("Error converting decoded contest to json {err:?}") }) .into_json() } +/// Gets the number of available write-in characters for a contest. +/// +/// # Errors +/// Returns an error if parsing or serialization fails. #[wasm_bindgen] pub fn get_write_in_available_characters_js( decoded_contest_json: JsValue, @@ -847,18 +933,18 @@ pub fn get_write_in_available_characters_js( // parse inputs let decoded_contest_js: Value = serde_wasm_bindgen::from_value(decoded_contest_json) - .map_err(|err| format!("Error parsing decoded contest: {}", err)) + .map_err(|err| format!("Error parsing decoded contest: {err}")) .into_json()?; let decoded_contest: DecodedVoteContest = deserialize_value(decoded_contest_js) - .map_err(|err| format!("Error parsing decoded contest: {}", err)) + .map_err(|err| format!("Error parsing decoded contest: {err}")) .into_json()?; let ballot_style_js: Value = serde_wasm_bindgen::from_value(ballot_style_json) - .map_err(|err| format!("Error parsing ballot style: {}", err)) + .map_err(|err| format!("Error parsing ballot style: {err}")) .into_json()?; let ballot_style: BallotStyle = deserialize_value(ballot_style_js) - .map_err(|err| format!("Error parsing ballot style: {}", err)) + .map_err(|err| format!("Error parsing ballot style: {err}")) .into_json()?; let contest = ballot_style @@ -878,11 +964,18 @@ pub fn get_write_in_available_characters_js( serde_wasm_bindgen::to_value(&num_available_chars) .map_err(|err| { - format!("Error converting decoded contest to json {:?}", err) + format!("Error converting decoded contest to json {err:?}") }) .into_json() } +/// Generates a sample auditable ballot. +/// +/// # Errors +/// Returns an error if encryption or serialization fails. +/// +/// # Panics +/// Panics if encryption fails unexpectedly (should not occur in normal operation). #[wasm_bindgen] pub fn generate_sample_auditable_ballot_js() -> Result { let ctx = RistrettoCtx; @@ -890,37 +983,45 @@ pub fn generate_sample_auditable_ballot_js() -> Result { let decoded_contest = get_writein_plaintext(); let auditable_ballot = encrypt::encrypt_decoded_contest::( &ctx, - &vec![decoded_contest.clone()], + std::slice::from_ref(&decoded_contest), &ballot_style, ) - .unwrap(); + .expect("Failed to encrypt decoded contest for sample auditable ballot"); let serializer = Serializer::json_compatible(); auditable_ballot .serialize(&serializer) .map_err(|err| { - format!("Error converting auditable ballot to json {:?}", err) + format!("Error converting auditable ballot to json {err:?}") }) .into_json() } +/// Checks if a contest is blank. +/// +/// # Errors +/// Returns an error if parsing or serialization fails. #[wasm_bindgen] pub fn check_is_blank_js( decoded_contest_json: JsValue, ) -> Result { let decoded_contest: DecodedVoteContest = serde_wasm_bindgen::from_value(decoded_contest_json) - .map_err(|err| format!("Error parsing decoded contest: {}", err)) + .map_err(|err| format!("Error parsing decoded contest: {err}")) .into_json()?; - let is_blank = check_is_blank(decoded_contest); + let is_blank = check_is_blank(&decoded_contest); serde_wasm_bindgen::to_value(&is_blank) .map_err(|err| { - format!("Error converting boolean is_blank to json {:?}", err) + format!("Error converting boolean is_blank to json {err:?}") }) .into_json() } +/// Checks if voting is not allowed. +/// +/// # Errors +/// Returns an error if parsing fails. #[wasm_bindgen] pub fn check_voting_not_allowed_next( contests: JsValue, @@ -928,22 +1029,25 @@ pub fn check_voting_not_allowed_next( ) -> Result { let all_contests: Vec = serde_wasm_bindgen::from_value(contests) .map_err(|err| { - JsValue::from_str(&format!("Error parsing contests: {}", err)) + JsValue::from_str(&format!("Error parsing contests: {err}")) })?; let all_decoded_contests: HashMap = serde_wasm_bindgen::from_value(decoded_contests).map_err(|err| { - JsValue::from_str(&format!( - "Error parsing decoded contests: {}", - err - )) + JsValue::from_str(&format!("Error parsing decoded contests: {err}")) })?; - let voting_not_allowed = - check_voting_not_allowed_next_util(all_contests, all_decoded_contests); + let voting_not_allowed = check_voting_not_allowed_next_util( + &all_contests, + &all_decoded_contests, + ); Ok(JsValue::from_bool(voting_not_allowed)) } +/// Checks if a voting error dialog should be shown. +/// +/// # Errors +/// Returns an error if parsing fails. #[wasm_bindgen] pub fn check_voting_error_dialog( contests: JsValue, @@ -951,22 +1055,23 @@ pub fn check_voting_error_dialog( ) -> Result { let all_contests: Vec = serde_wasm_bindgen::from_value(contests) .map_err(|err| { - JsValue::from_str(&format!("Error parsing contests: {}", err)) + JsValue::from_str(&format!("Error parsing contests: {err}")) })?; let all_decoded_contests: HashMap = serde_wasm_bindgen::from_value(decoded_contests).map_err(|err| { - JsValue::from_str(&format!( - "Error parsing decoded contests: {}", - err - )) + JsValue::from_str(&format!("Error parsing decoded contests: {err}")) })?; let show_voting_alert = - check_voting_error_dialog_util(all_contests, all_decoded_contests); + check_voting_error_dialog_util(&all_contests, &all_decoded_contests); Ok(JsValue::from_bool(show_voting_alert)) } +/// Gets the authentication URL for JS interop. +/// +/// # Errors +/// Returns an error if parsing or serialization fails. #[allow(clippy::all)] #[wasm_bindgen] pub fn get_auth_url_js( @@ -977,17 +1082,17 @@ pub fn get_auth_url_js( ) -> Result { // parse input let base_url: String = serde_wasm_bindgen::from_value(base_url_json) - .map_err(|err| format!("Error deserializing base_url: {err}",)) + .map_err(|err| format!("Error deserializing base_url: {err}")) .into_json()?; let tenant_id: String = serde_wasm_bindgen::from_value(tenant_id_json) - .map_err(|err| format!("Error deserializing tenant_id: {err}",)) + .map_err(|err| format!("Error deserializing tenant_id: {err}")) .into_json()?; let event_id: String = serde_wasm_bindgen::from_value(event_id_json) - .map_err(|err| format!("Error deserializing event_id: {err}",)) + .map_err(|err| format!("Error deserializing event_id: {err}")) .into_json()?; let auth_action_str: String = serde_wasm_bindgen::from_value(auth_action_json) - .map_err(|err| format!("Error deserializing auth_action: {err}",)) + .map_err(|err| format!("Error deserializing auth_action: {err}")) .into_json()?; let auth_action = match auth_action_str.as_str() { @@ -998,12 +1103,16 @@ pub fn get_auth_url_js( // return result let auth_url: String = - get_auth_url(&base_url, &tenant_id, &event_id, auth_action); + get_auth_url(&base_url, &tenant_id, &event_id, &auth_action); serde_wasm_bindgen::to_value(&auth_url) - .map_err(|err| format!("Error writing javascript string: {err}",)) + .map_err(|err| format!("Error writing javascript string: {err}")) .into_json() } +/// Signs a hashable ballot with an ephemeral voter signing key. +/// +/// # Errors +/// Returns an error if parsing, conversion, or signing fails. #[wasm_bindgen] pub fn sign_hashable_ballot_with_ephemeral_voter_signing_key_js( ballot_id: JsValue, @@ -1018,14 +1127,12 @@ pub fn sign_hashable_ballot_with_ephemeral_voter_signing_key_js( .map_err(|err| format!("Error deserializing election_id: {err}")) .into_json()?; let auditable_ballot_js: Value = serde_wasm_bindgen::from_value(content) - .map_err(|err| { - format!("Failed to parse auditable multi ballot: {}", err) - }) + .map_err(|err| format!("Failed to parse auditable multi ballot: {err}")) .into_json()?; let auditable_ballot: AuditableBallot = deserialize_value(auditable_ballot_js) .map_err(|err| { - format!("Error deserializing auditable multi ballot: {err}",) + format!("Error deserializing auditable multi ballot: {err}") }) .into_json()?; @@ -1055,6 +1162,10 @@ pub fn sign_hashable_ballot_with_ephemeral_voter_signing_key_js( .into_json() } +/// Signs a hashable multi-ballot with an ephemeral voter signing key. +/// +/// # Errors +/// Returns an error if parsing, conversion, or signing fails. #[wasm_bindgen] pub fn sign_hashable_multi_ballot_with_ephemeral_voter_signing_key_js( ballot_id: JsValue, @@ -1072,22 +1183,21 @@ pub fn sign_hashable_multi_ballot_with_ephemeral_voter_signing_key_js( serde_wasm_bindgen::from_value(auditable_multi_ballot_json) .map_err(|err| { format!( - "Error parsing auditable ballot javascript string: {}", - err + "Error parsing auditable ballot javascript string: {err}" ) }) .into_json()?; let auditable_multi_ballot: AuditableMultiBallot = deserialize_value(auditable_multi_ballot_js) .map_err(|err| { - format!("Error deserializing auditable multi ballot: {err}",) + format!("Error deserializing auditable multi ballot: {err}") }) .into_json()?; let hashable_multi_ballot = HashableMultiBallot::try_from(&auditable_multi_ballot).map_err(|err| { format!( - "Error converting auditable ballot into hashable multi ballot: {err}", + "Error converting auditable ballot into hashable multi ballot: {err}" ) }) .into_json()?; @@ -1106,6 +1216,10 @@ pub fn sign_hashable_multi_ballot_with_ephemeral_voter_signing_key_js( .into_json() } +/// Gets the default duplicated rank policy. +/// +/// # Errors +/// Returns an error if serialization fails. #[wasm_bindgen] pub fn get_default_duplicated_rank_policy_js() -> Result { let policy = EDuplicatedRankPolicy::default(); @@ -1116,6 +1230,10 @@ pub fn get_default_duplicated_rank_policy_js() -> Result { }) } +/// Gets the default preference gaps policy. +/// +/// # Errors +/// Returns an error if serialization fails. #[wasm_bindgen] pub fn get_default_preference_gaps_policy_js() -> Result { let policy = EPreferenceGapsPolicy::default(); @@ -1126,8 +1244,11 @@ pub fn get_default_preference_gaps_policy_js() -> Result { }) } -// returns true/false if verified/no-signature, error if the signature can't be -// verified +/// Verifies a ballot signature. +/// Returns true/false if verified/no-signature, error if the signature +/// can't be verified. +/// # Errors +/// Returns an error if parsing or verification fails. #[wasm_bindgen] pub fn verify_ballot_signature_js( ballot_id: JsValue, @@ -1142,21 +1263,19 @@ pub fn verify_ballot_signature_js( .map_err(|err| format!("Error deserializing election_id: {err}")) .into_json()?; let auditable_ballot_js: Value = serde_wasm_bindgen::from_value(content) - .map_err(|err| { - format!("Failed to parse auditable multi ballot: {}", err) - }) + .map_err(|err| format!("Failed to parse auditable multi ballot: {err}")) .into_json()?; let auditable_ballot: AuditableBallot = deserialize_value(auditable_ballot_js) .map_err(|err| { - format!("Error deserializing auditable multi ballot: {err}",) + format!("Error deserializing auditable multi ballot: {err}") }) .into_json()?; let signed_hashable_ballot = SignedHashableBallot::try_from(&auditable_ballot).map_err(|err| { format!( - "Error converting auditable ballot into hashable multi ballot: {err}", + "Error converting auditable ballot into hashable multi ballot: {err}" ) })?; @@ -1169,10 +1288,14 @@ pub fn verify_ballot_signature_js( .map_err(|err| format!("Error verifying the ballot: {err}"))?; serde_wasm_bindgen::to_value(&result.is_some()) - .map_err(|err| format!("Error writing javascript string: {err}",)) + .map_err(|err| format!("Error writing javascript string: {err}")) .into_json() } +/// Verifies a multi-ballot signature. +/// +/// # Errors +/// Returns an error if parsing or verification fails. #[wasm_bindgen] pub fn verify_multi_ballot_signature_js( ballot_id: JsValue, @@ -1190,8 +1313,7 @@ pub fn verify_multi_ballot_signature_js( serde_wasm_bindgen::from_value(auditable_multi_ballot_json) .map_err(|err| { format!( - "Error parsing auditable ballot javascript string: {}", - err + "Error parsing auditable ballot javascript string: {err}" ) }) .into_json()?; @@ -1224,6 +1346,10 @@ pub fn verify_multi_ballot_signature_js( .into_json() } +/// Gets the default consolidated report policy. +/// +/// # Errors +/// Returns an error if serialization fails. #[wasm_bindgen] pub fn get_default_consolidated_report_policy_js() -> Result { let policy: ConsolidatedReportPolicy = ConsolidatedReportPolicy::default(); diff --git a/packages/sequent-core/src/wasm/wasm_hasura_types.rs b/packages/sequent-core/src/wasm/wasm_hasura_types.rs index 52e282134d2..4fdfb8a4886 100644 --- a/packages/sequent-core/src/wasm/wasm_hasura_types.rs +++ b/packages/sequent-core/src/wasm/wasm_hasura_types.rs @@ -4,10 +4,15 @@ use wasm_bindgen::prelude::*; #[derive(PartialEq, Eq, Debug, Clone)] +/// A representation of the voting channels available. pub struct VotingChannels { + /// Whether online voting is available. pub online: Option, + /// Whether kiosk voting is available. pub kiosk: Option, + /// Whether telephone voting is available. pub telephone: Option, + /// Whether paper voting is available. pub paper: Option, } @@ -23,6 +28,7 @@ interface IVotingChannels { #[wasm_bindgen] extern "C" { + /// TypeScript interface describing the voting channels available. #[wasm_bindgen(typescript_type = "IVotingChannels")] pub type IVotingChannels; } diff --git a/packages/sequent-core/src/wasm/wasm_interpret_plaintext.rs b/packages/sequent-core/src/wasm/wasm_interpret_plaintext.rs index 9f30646a06b..c744f290e0d 100644 --- a/packages/sequent-core/src/wasm/wasm_interpret_plaintext.rs +++ b/packages/sequent-core/src/wasm/wasm_interpret_plaintext.rs @@ -30,6 +30,7 @@ enum IContestStateEnum { #[wasm_bindgen] extern "C" { + /// TypeScript interface describing the state of a contest. #[wasm_bindgen(typescript_type = "IContestStateEnum")] pub type IContestStateEnum; } @@ -44,6 +45,7 @@ interface ICandidateProperties { #[wasm_bindgen] extern "C" { + /// TypeScript interface describing the properties of a candidate in a decoded vote choice. #[wasm_bindgen(typescript_type = "ICandidateProperties")] pub type ICandidateProperties; } @@ -59,6 +61,7 @@ interface IContestLayoutProperties { #[wasm_bindgen] extern "C" { + /// TypeScript interface describing the layout properties of a contest. #[wasm_bindgen(typescript_type = "IContestLayoutProperties")] pub type IContestLayoutProperties; } diff --git a/packages/sequent-core/src/wasm/wasm_keycloak.rs b/packages/sequent-core/src/wasm/wasm_keycloak.rs index bb1ed945cac..2ebafb8cf62 100644 --- a/packages/sequent-core/src/wasm/wasm_keycloak.rs +++ b/packages/sequent-core/src/wasm/wasm_keycloak.rs @@ -13,6 +13,7 @@ interface IUserArea { #[wasm_bindgen] extern "C" { + /// TypeScript interface describing a user's area. #[wasm_bindgen(typescript_type = "IUserArea")] pub type IUserArea; } @@ -28,6 +29,7 @@ interface IVotesInfo { #[wasm_bindgen] extern "C" { + /// TypeScript interface describing a user's voting information for a specific election. #[wasm_bindgen(typescript_type = "IVotesInfo")] pub type IVotesInfo; } @@ -51,6 +53,7 @@ interface IUser { #[wasm_bindgen] extern "C" { + /// TypeScript interface describing a Keycloak user. #[wasm_bindgen(typescript_type = "IUser")] pub type IUser; } @@ -68,6 +71,7 @@ interface IPermission { #[wasm_bindgen] extern "C" { + /// TypeScript interface describing a Keycloak permission. #[wasm_bindgen(typescript_type = "IPermission")] pub type IPermission; } @@ -86,6 +90,7 @@ interface IRole { #[wasm_bindgen] extern "C" { + /// TypeScript interface describing a Keycloak role. #[wasm_bindgen(typescript_type = "IRole")] pub type IRole; } diff --git a/packages/sequent-core/src/wasm/wasm_plaintext.rs b/packages/sequent-core/src/wasm/wasm_plaintext.rs index 007dd4e4c5f..c4467e114c8 100644 --- a/packages/sequent-core/src/wasm/wasm_plaintext.rs +++ b/packages/sequent-core/src/wasm/wasm_plaintext.rs @@ -14,6 +14,7 @@ enum IInvalidPlaintextErrorType { #[wasm_bindgen] extern "C" { + /// TypeScript interface describing the type of an invalid plaintext error. #[wasm_bindgen(typescript_type = "IInvalidPlaintextErrorType")] pub type IInvalidPlaintextErrorType; } @@ -30,6 +31,7 @@ interface IInvalidPlaintextError { #[wasm_bindgen] extern "C" { + /// TypeScript interface describing an invalid plaintext error. #[wasm_bindgen(typescript_type = "IInvalidPlaintextError")] pub type IInvalidPlaintextError; } @@ -47,6 +49,7 @@ interface IDecodedVoteContest { #[wasm_bindgen] extern "C" { + /// TypeScript interface describing a decoded vote contest. #[wasm_bindgen(typescript_type = "IDecodedVoteContest")] pub type IDecodedVoteContest; } @@ -62,6 +65,7 @@ interface IDecodedVoteChoice { #[wasm_bindgen] extern "C" { + /// TypeScript interface describing a decoded vote choice. #[wasm_bindgen(typescript_type = "IDecodedVoteChoice")] pub type IDecodedVoteChoice; } diff --git a/packages/sequent-core/tests/mod.rs b/packages/sequent-core/tests/mod.rs index 066d38654f1..d596b5aabec 100644 --- a/packages/sequent-core/tests/mod.rs +++ b/packages/sequent-core/tests/mod.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Sequent Tech Inc // // SPDX-License-Identifier: AGPL-3.0-only - +//! Integration tests for sequent-core. /// Webassembly API. #[cfg(feature = "wasm")] pub mod wasm; diff --git a/packages/ui-core/rust/sequent-core-0.1.0.tgz b/packages/ui-core/rust/sequent-core-0.1.0.tgz index bc6c9361075..0e2e7bfc948 100644 Binary files a/packages/ui-core/rust/sequent-core-0.1.0.tgz and b/packages/ui-core/rust/sequent-core-0.1.0.tgz differ diff --git a/packages/velvet/src/cli/test_all.rs b/packages/velvet/src/cli/test_all.rs index 8a0a7d29936..e7d06a67189 100644 --- a/packages/velvet/src/cli/test_all.rs +++ b/packages/velvet/src/cli/test_all.rs @@ -1850,7 +1850,7 @@ mod tests { let decoded_contest = get_decoded_contest_plurality(&contest1); decoded_contests1.insert(contest1.id.clone(), decoded_contest); - let result = check_voting_not_allowed_next_util(vec![contest1], decoded_contests1); + let result = check_voting_not_allowed_next_util(&vec![contest1], &decoded_contests1); assert_eq!(result, false); // Case 2: EBlankVotePolicy::NOT_ALLOWED and there aren't any votes cast -> true @@ -1864,7 +1864,7 @@ mod tests { let decoded_contest = get_decoded_contest_plurality(&contest2); decoded_contests2.insert(contest2.id.clone(), decoded_contest); - let result = check_voting_not_allowed_next_util(vec![contest2], decoded_contests2); + let result = check_voting_not_allowed_next_util(&vec![contest2], &decoded_contests2); assert_eq!(result, true); // Case 3: EBlankVotePolicy::NOT_ALLOWED but minVotes = 0 and InvalidVotePolicy::NOT_ALLOWED but there aren't any invalid_errors -> false @@ -1878,7 +1878,7 @@ mod tests { let decoded_contest = get_decoded_contest_plurality(&contest3); decoded_contests3.insert(contest3.id.clone(), decoded_contest); - let result = check_voting_not_allowed_next_util(vec![contest3], decoded_contests3); + let result = check_voting_not_allowed_next_util(&vec![contest3], &decoded_contests3); assert_eq!(result, true); // Case 4: EBlankVotePolicy::NOT_ALLOWED and InvalidVotePolicy::NOT_ALLOWED with invalid errors -> true @@ -1892,7 +1892,7 @@ mod tests { let decoded_contest = get_decoded_contest_plurality(&contest4); decoded_contests4.insert(contest4.id.clone(), decoded_contest); - let result = check_voting_not_allowed_next_util(vec![contest4], decoded_contests4); + let result = check_voting_not_allowed_next_util(&vec![contest4], &decoded_contests4); assert_eq!(result, true); } @@ -1909,7 +1909,7 @@ mod tests { let decoded_contest = get_decoded_contest_plurality(&contest1); decoded_contests1.insert(contest1.id.clone(), decoded_contest); - let result = check_voting_error_dialog_util(vec![contest1], decoded_contests1); + let result = check_voting_error_dialog_util(&vec![contest1], &decoded_contests1); assert_eq!(result, true); // Case 2: EBlankVotePolicy::WARN and choices_selected = 0 -> true @@ -1923,7 +1923,7 @@ mod tests { let decoded_contest = get_decoded_contest_plurality(&contest2); decoded_contests2.insert(contest2.id.clone(), decoded_contest); - let result = check_voting_error_dialog_util(vec![contest2], decoded_contests2); + let result = check_voting_error_dialog_util(&vec![contest2], &decoded_contests2); assert_eq!(result, true); // Case 3: EBlankVotePolicy::ALLOWED and minVotes = 0 and InvalidVotePolicy::NOT_ALLOWED -> false @@ -1937,7 +1937,7 @@ mod tests { let decoded_contest = get_decoded_contest_plurality(&contest3); decoded_contests3.insert(contest3.id.clone(), decoded_contest); - let result = check_voting_error_dialog_util(vec![contest3], decoded_contests3); + let result = check_voting_error_dialog_util(&vec![contest3], &decoded_contests3); assert_eq!(result, false); } } diff --git a/packages/velvet/src/pipes/decode_ballots/decode_mcballots.rs b/packages/velvet/src/pipes/decode_ballots/decode_mcballots.rs index e399328dcb1..086aac3d4f7 100644 --- a/packages/velvet/src/pipes/decode_ballots/decode_mcballots.rs +++ b/packages/velvet/src/pipes/decode_ballots/decode_mcballots.rs @@ -154,11 +154,9 @@ impl Pipe for DecodeMCBallots { // accumulate per-contest ballots for dbc in decoded_ballots { - let decoded_contests = map_decoded_ballot_choices_to_decoded_contests( - dbc.clone(), - &contests, - ) - .map_err(|err| Error::UnexpectedError(err))?; + let decoded_contests = + map_decoded_ballot_choices_to_decoded_contests(&dbc, &contests) + .map_err(|err| Error::UnexpectedError(err))?; for decoded_contest in decoded_contests { if !output_map.contains_key(&decoded_contest.contest_id) { diff --git a/packages/velvet/src/pipes/generate_db/generate_db.rs b/packages/velvet/src/pipes/generate_db/generate_db.rs index c73c008efb8..3f2f4f4c3ff 100644 --- a/packages/velvet/src/pipes/generate_db/generate_db.rs +++ b/packages/velvet/src/pipes/generate_db/generate_db.rs @@ -360,9 +360,7 @@ pub async fn generate_results_id_if_necessary( tenant_id, election_event_id, &results_event_id, - ) - .await - .context("Failed to create results event table")?; + )?; Ok(results_event_id) } @@ -570,20 +568,18 @@ pub async fn save_results( } } - create_results_contest_sqlite(sqlite_transaction, results_contests).await?; + create_results_contest_sqlite(sqlite_transaction, results_contests)?; - create_results_area_contests_sqlite(sqlite_transaction, results_area_contests).await?; + create_results_area_contests_sqlite(sqlite_transaction, results_area_contests)?; - create_results_election_sqlite(sqlite_transaction, results_elections).await?; + create_results_election_sqlite(sqlite_transaction, results_elections)?; - create_results_contest_candidates_sqlite(sqlite_transaction, results_contest_candidates) - .await?; + create_results_contest_candidates_sqlite(sqlite_transaction, results_contest_candidates)?; create_results_area_contest_candidates_sqlite( sqlite_transaction, results_area_contest_candidates, - ) - .await?; + )?; Ok(()) } diff --git a/packages/velvet/src/pipes/pipe_inputs.rs b/packages/velvet/src/pipes/pipe_inputs.rs index c3a4058c94a..50e45847293 100644 --- a/packages/velvet/src/pipes/pipe_inputs.rs +++ b/packages/velvet/src/pipes/pipe_inputs.rs @@ -8,7 +8,7 @@ use crate::{ utils::parse_file, }; use sequent_core::{ - ballot::{BallotStyle, Contest, ElectionPresentation, ReportDates, StringifiedPeriodDates}, + ballot::{BallotStyle, Contest, ElectionPresentation, StringifiedPeriodDates}, services::area_tree::TreeNodeArea, util::path::get_folder_name, }; diff --git a/packages/voting-portal/rust/sequent-core-0.1.0.tgz b/packages/voting-portal/rust/sequent-core-0.1.0.tgz index bc6c9361075..0e2e7bfc948 100644 Binary files a/packages/voting-portal/rust/sequent-core-0.1.0.tgz and b/packages/voting-portal/rust/sequent-core-0.1.0.tgz differ diff --git a/packages/windmill/src/services/ballot_styles/ballot_style.rs b/packages/windmill/src/services/ballot_styles/ballot_style.rs index f830e9547ac..c9f3ed992be 100644 --- a/packages/windmill/src/services/ballot_styles/ballot_style.rs +++ b/packages/windmill/src/services/ballot_styles/ballot_style.rs @@ -22,6 +22,7 @@ use chrono::Duration; use deadpool_postgres::{Client as DbClient, Transaction}; use futures::try_join; use rocket::http::Status; +use sequent_core::ballot_style::create_ballot_style; use sequent_core::types::hasura::core::{ self as hasura_type, Area, AreaContest, BallotPublication, BallotStyle, Candidate, Contest, Election, ElectionEvent, KeysCeremony, @@ -156,15 +157,15 @@ pub async fn create_ballot_style_postgres( get_election_dates(election, scheduled_events.clone()).unwrap_or_default(); let ballot_style_id = Uuid::new_v4(); - let election_dto = sequent_core::ballot_style::create_ballot_style( + let election_dto = create_ballot_style( ballot_style_id.clone().to_string(), - area.clone(), - election_event.clone(), - election.clone(), - contests.clone(), - candidates.clone(), - election_dates.clone(), - public_key.clone(), + &area, + &election_event, + election, + contests.as_slice(), + candidates.as_slice(), + election_dates, + public_key, )?; let election_dto_json_string = serde_json::to_string(&election_dto)?; let _created_ballot_style = insert_ballot_style( diff --git a/packages/windmill/src/services/ceremonies/result_documents.rs b/packages/windmill/src/services/ceremonies/result_documents.rs index f4c373c9197..6b9d5013b5d 100644 --- a/packages/windmill/src/services/ceremonies/result_documents.rs +++ b/packages/windmill/src/services/ceremonies/result_documents.rs @@ -542,8 +542,7 @@ impl GenerateResultDocuments for ElectionReportDataComputed { &election_id, &documents, &json_hash, - ) - .await?; + )?; } Ok(documents) @@ -642,8 +641,7 @@ impl GenerateResultDocuments for ReportDataComputed { &contest.id, &area.id, &documents, - ) - .await?; + )?; } } else { update_results_contest_documents( @@ -666,8 +664,7 @@ impl GenerateResultDocuments for ReportDataComputed { &self.election_id, &contest.id, &documents, - ) - .await?; + )?; } } } @@ -908,8 +905,7 @@ async fn save_area_documents( &area.id, &area.name, &documents, - ) - .await?; + )?; } Ok(documents) diff --git a/packages/windmill/src/services/ceremonies/velvet_tally.rs b/packages/windmill/src/services/ceremonies/velvet_tally.rs index c845a7cc530..7f9191dc4b7 100644 --- a/packages/windmill/src/services/ceremonies/velvet_tally.rs +++ b/packages/windmill/src/services/ceremonies/velvet_tally.rs @@ -753,7 +753,6 @@ async fn populate_sqlite_election_event_data( .await .context("Failed to get election event by ID")?; create_election_event_sqlite(&sqlite_transaction, election_event) - .await .context("Failed to create election event table")?; let elections = match election_ids.clone() { @@ -766,7 +765,6 @@ async fn populate_sqlite_election_event_data( .context("Failed to get elections")?; create_election_sqlite(&sqlite_transaction, elections) - .await .context("Failed to create election table")?; let contests = match election_ids { @@ -784,7 +782,6 @@ async fn populate_sqlite_election_event_data( }; create_contest_sqlite(&sqlite_transaction, contests.clone()) - .await .context("Failed to create contest table")?; let contests_ids: Vec = contests.iter().map(|c| c.id.clone()).collect(); @@ -792,7 +789,6 @@ async fn populate_sqlite_election_event_data( // TODO Create csv with candidates create_candidate_sqlite(&sqlite_transaction) - .await .context("Failed to create candidate table")?; let contests_csv_temp = NamedTempFile::new() @@ -811,7 +807,6 @@ async fn populate_sqlite_election_event_data( .context("Failed exporting candidates to csv")?; import_candidate_sqlite(&sqlite_transaction, contests_csv) - .await .context("Failed importing candidates to sqlite database")?; let areas = match areas_ids.clone() { @@ -826,7 +821,6 @@ async fn populate_sqlite_election_event_data( }; create_area_sqlite(&sqlite_transaction, areas) - .await .context("Failed to create area table")?; let area_contests = match areas_ids { @@ -850,7 +844,6 @@ async fn populate_sqlite_election_event_data( election_event_id, area_contests, ) - .await .context("Failed to create area contest table")?; sqlite_transaction.commit()?; diff --git a/packages/windmill/src/services/election_dates.rs b/packages/windmill/src/services/election_dates.rs index 7a94b323e8c..906d2228e16 100644 --- a/packages/windmill/src/services/election_dates.rs +++ b/packages/windmill/src/services/election_dates.rs @@ -113,7 +113,8 @@ pub fn get_election_dates( let period_dates: PeriodDates = status.voting_period_dates; let mut dates = period_dates.to_string_fields(); - if let Ok(scheduled_event_dates) = prepare_scheduled_dates(scheduled_events, Some(&election.id)) + if let Ok(scheduled_event_dates) = + prepare_scheduled_dates(scheduled_events.as_slice(), Some(&election.id)) { dates.scheduled_event_dates = Some(scheduled_event_dates); } diff --git a/packages/windmill/src/tasks/manage_election_init_report.rs b/packages/windmill/src/tasks/manage_election_init_report.rs index 47c69f3a0aa..040b62cf468 100644 --- a/packages/windmill/src/tasks/manage_election_init_report.rs +++ b/packages/windmill/src/tasks/manage_election_init_report.rs @@ -76,7 +76,7 @@ async fn manage_election_init_report_wrapped( // Update init_report based on event_payload let updated_status = ElectionStatus { - init_report: if event_payload.allow_init == Some(true) { + init_report: if event_payload.allow_init == true { InitReport::ALLOWED } else { InitReport::DISALLOWED diff --git a/packages/windmill/src/tasks/post_tally.rs b/packages/windmill/src/tasks/post_tally.rs index 9c42bf33a82..63af3cb968a 100644 --- a/packages/windmill/src/tasks/post_tally.rs +++ b/packages/windmill/src/tasks/post_tally.rs @@ -238,7 +238,7 @@ pub async fn post_tally_task_impl( .await? .ok_or(anyhow!("Could not find options."))?; - let pdf_options = PrintToPdfOptionsLocal::from_pdf_options(pdf_options); + let pdf_options = PrintToPdfOptionsLocal::from_pdf_options(&pdf_options); // Search for all html reports that do not have pdf and generate it find_and_process_html_reports_parallel(tally_path.path(), pdf_options)?; diff --git a/packages/windmill/src/tasks/send_template.rs b/packages/windmill/src/tasks/send_template.rs index bc46eacab17..6ed02340c8f 100644 --- a/packages/windmill/src/tasks/send_template.rs +++ b/packages/windmill/src/tasks/send_template.rs @@ -78,7 +78,7 @@ fn get_variables( .as_str(), &tenant_id, &election_event.id, - auth_action + &auth_action )), ); } diff --git a/packages/yarn.lock b/packages/yarn.lock index 9c9287ab1ab..d4a2a0419ec 100644 --- a/packages/yarn.lock +++ b/packages/yarn.lock @@ -17488,15 +17488,15 @@ sentence-case@^3.0.4: "sequent-core@file:./admin-portal/rust/sequent-core-0.1.0.tgz": version "0.1.0" - resolved "file:./admin-portal/rust/sequent-core-0.1.0.tgz#64424a92cfba20e40ce57c9748859f67f2d17450" + resolved "file:./admin-portal/rust/sequent-core-0.1.0.tgz#90b8f9cbe91daabfa4d4b16cd77438f3e77241a2" "sequent-core@file:./ballot-verifier/rust/sequent-core-0.1.0.tgz": version "0.1.0" - resolved "file:./ballot-verifier/rust/sequent-core-0.1.0.tgz#64424a92cfba20e40ce57c9748859f67f2d17450" + resolved "file:./ballot-verifier/rust/sequent-core-0.1.0.tgz#90b8f9cbe91daabfa4d4b16cd77438f3e77241a2" "sequent-core@file:./voting-portal/rust/sequent-core-0.1.0.tgz": version "0.1.0" - resolved "file:./voting-portal/rust/sequent-core-0.1.0.tgz#64424a92cfba20e40ce57c9748859f67f2d17450" + resolved "file:./voting-portal/rust/sequent-core-0.1.0.tgz#90b8f9cbe91daabfa4d4b16cd77438f3e77241a2" serialize-javascript@^6.0.0, serialize-javascript@^6.0.2: version "6.0.2"