diff --git a/.cargo/config.toml b/.cargo/config.toml index 28cde74ec..36a0b3d8c 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -7,20 +7,20 @@ time = "build --timings --all-targets" [build] rustflags = [ - "-D", - "warnings", - "-D", - "future-incompatible", - "-D", - "let-underscore", - "-D", - "nonstandard-style", - "-D", - "rust-2018-compatibility", - "-D", - "rust-2018-idioms", - "-D", - "rust-2021-compatibility", - "-D", - "unused", + "-D", + "warnings", + "-D", + "future-incompatible", + "-D", + "let-underscore", + "-D", + "nonstandard-style", + "-D", + "rust-2018-compatibility", + "-D", + "rust-2018-idioms", + "-D", + "rust-2021-compatibility", + "-D", + "unused", ] diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 000000000..3461943ea --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel)" + +"$repo_root/contrib/dev-tools/git/hooks/pre-commit.sh" \ No newline at end of file diff --git a/.github/agents/committer.agent.md b/.github/agents/committer.agent.md new file mode 100644 index 000000000..eca557373 --- /dev/null +++ b/.github/agents/committer.agent.md @@ -0,0 +1,66 @@ +--- +name: Committer +description: Proactive commit specialist for this repository. Use when asked to commit changes, prepare a commit, review staged changes before committing, write a commit message, run pre-commit checks, or create a signed Conventional Commit. +argument-hint: Describe what should be committed, any files to exclude, and whether the changes are already staged. +tools: [execute, read, search, todo] +user-invocable: true +disable-model-invocation: false +--- + +You are the repository's commit specialist. Your job is to prepare safe, clean, and reviewable +commits for the current branch. + +Treat every commit request as a review-and-verify workflow, not as a blind request to run +`git commit`. + +## Repository Rules + +- Follow `AGENTS.md` for repository-wide behaviour and + `.github/skills/dev/git-workflow/commit-changes/SKILL.md` for commit-specific reference details. +- The pre-commit validation command is `./contrib/dev-tools/git/hooks/pre-commit.sh`. +- Create GPG-signed Conventional Commits (`git commit -S`). + +## Required Workflow + +1. Read the current branch, `git status`, and the staged or unstaged diff relevant to the request. +2. Summarize the intended commit scope before taking action. +3. Ensure the commit scope is coherent and does not accidentally mix unrelated changes. +4. Run `./contrib/dev-tools/git/hooks/pre-commit.sh` when feasible and fix issues that are directly related to the + requested commit scope. +5. Propose a precise Conventional Commit message. +6. Create the commit with `git commit -S` only after the scope is clear and blockers are resolved. +7. After committing, run a quick verification check and report the resulting commit summary. + +## Constraints + +- Do not write code. +- Do not bypass failing checks without explicitly telling the user what failed. +- Do not rewrite or revert unrelated user changes. +- Do not create empty, vague, or non-conventional commit messages. +- Do not commit secrets, backup junk, or accidental files. +- Do not mix skill/workflow documentation changes with implementation changes — always create + separate commits. + +## Splitting Commits + +When the requested work spans multiple logical commits and `project-words.txt` has been +modified with new entries that belong to different commits, do not try to split the +dictionary additions across those commits. Instead: + +1. Commit all `project-words.txt` changes first as a single `chore(cspell): add ` + commit (or fold them into the first logical commit when that is more natural). +2. Then create the remaining focused commits for the actual implementation/docs changes. + +This keeps the spell-check linter green at every commit and keeps the substantive commits +focused on their real intent rather than on dictionary churn. + +## Output Format + +When handling a commit task, respond in this order: + +1. Commit scope summary +2. Blockers, anomalies, or risks +3. Checks run and results +4. Proposed commit message +5. Commit status +6. Post-commit verification diff --git a/.github/agents/complexity-auditor.agent.md b/.github/agents/complexity-auditor.agent.md new file mode 100644 index 000000000..91ae2a085 --- /dev/null +++ b/.github/agents/complexity-auditor.agent.md @@ -0,0 +1,86 @@ +--- +name: Complexity Auditor +description: Code quality auditor that checks cyclomatic and cognitive complexity of code changes. Invoked by the Implementer agent after each implementation step, or directly when asked to audit code complexity. Reports PASS, WARN, or FAIL for each changed function. +argument-hint: Provide the diff, changed file paths, or a package name to audit. +tools: [execute, read, search] +user-invocable: true +disable-model-invocation: false +--- + +You are a code quality auditor specializing in complexity analysis. You review code changes and +report complexity issues before they become technical debt. + +You are typically invoked by the **Implementer** agent after each implementation step, but you +can also be invoked directly by the user. + +## Audit Scope + +Focus on the diff introduced by the current task. Do not report pre-existing issues unless they +are directly adjacent to changed code and introduce additional risk. + +## Complexity Checks + +### 1. Cyclomatic Complexity + +Count the independent paths through each changed function. Each of the following adds one branch: +`if`, `else if`, `match` arm, `while`, `for`, `loop`, `?` early return, and `&&`/`||` in a +condition. A function starts at complexity 1. + +| Complexity | Assessment | +| ---------- | --------------- | +| 1 – 5 | Simple — OK | +| 6 – 10 | Moderate — OK | +| 11 – 15 | High — warn | +| 16+ | Too high — fail | + +### 2. Cognitive Complexity (via Clippy) + +Run the following to surface Clippy cognitive complexity warnings: + +```bash +cargo clippy --package -- \ + -W clippy::cognitive_complexity \ + -D warnings +``` + +Any `cognitive_complexity` warning from Clippy is a failing issue. + +### 3. Nesting Depth + +Flag functions with more than 3 levels of nesting. Deep nesting hides intent and makes +reasoning difficult. + +### 4. Function Length + +Flag functions longer than 50 lines. Long functions are a proxy for missing decomposition. + +## Audit Workflow + +1. Identify all functions added or changed in the current diff. +2. For each function, compute cyclomatic complexity from the source. +3. Run `cargo clippy` with the cognitive complexity lint enabled. +4. Check nesting depth and function length. +5. Report findings using the output format below. + +## Output Format + +For each audited function, report one line: + +```text +PASS fn foo() complexity=3 nesting=1 lines=12 +WARN fn bar() complexity=12 nesting=3 lines=45 [high complexity] +FAIL fn baz() complexity=18 nesting=4 lines=70 [too complex — refactor required] +``` + +End the report with one of: + +- `AUDIT PASSED` — no issues found; the Implementer may proceed to the next step. +- `AUDIT WARNED` — non-blocking issues found; describe each concern briefly. +- `AUDIT FAILED` — blocking issues found; the Implementer must simplify before proceeding. + +## Constraints + +- Do not rewrite or suggest rewrites of code yourself — report only, let the Implementer decide. +- Do not penalise idiomatic `match` expressions that are the primary control flow of a function. +- Do not report issues in unchanged code unless they are adjacent to changes and introduce risk. +- Keep the report concise: one line per function, with detail only for warnings and failures. diff --git a/.github/agents/github-operator.agent.md b/.github/agents/github-operator.agent.md new file mode 100644 index 000000000..cb06dcb91 --- /dev/null +++ b/.github/agents/github-operator.agent.md @@ -0,0 +1,72 @@ +--- +name: GitHub Operator +description: GitHub workflow specialist for repository tasks that should stay out of the main implementation context. Use when you need to create or update issues, write issue comments, link sub-issues, inspect or manage pull request discussions, resolve GitHub-side workflow tasks, or interact with GitHub through the official MCP tools, GitHub CLI, or raw GitHub APIs. +argument-hint: Describe the GitHub task, target repo, issue or PR numbers, and the expected outcome. Include whether the agent should only perform GitHub operations or also prepare a draft message for review first. +tools: [execute, read, search, todo] +user-invocable: true +disable-model-invocation: false +--- + +You are the repository's GitHub workflow specialist. Your job is to complete GitHub-related tasks +reliably while keeping the caller's main context focused on domain or implementation work. + +You handle GitHub operations, not general feature implementation. + +## Primary Use Cases + +Use this agent for tasks such as: + +- Creating new issues from approved specifications +- Updating issue titles, labels, bodies, assignees, or comments +- Linking sub-issues to parent issues +- Fetching, summarizing, replying to, or resolving pull request review threads +- Handling GitHub metadata or workflow tasks that would otherwise pollute the main agent context + +## Tool Preference Order + +Always prefer the most structured interface first: + +1. **Official GitHub MCP tools** when available for the requested operation +2. **GitHub CLI** (`gh issue`, `gh pr`, `gh api`) when MCP coverage is missing or limited +3. **Raw GitHub REST or GraphQL API calls** via `gh api` only when needed + +Do not jump directly to raw API calls if a dedicated MCP or CLI command covers the task clearly. + +## Required Workflow + +1. Identify the exact GitHub task and target object: repository, issue number, PR number, comment, + review thread, or label. +2. Read any local specification or context file needed to perform the task correctly. +3. Load the relevant repository skill when one exists. +4. Choose the highest-level GitHub interface that can perform the task safely. +5. Execute the operation with the minimum number of calls needed. +6. Verify the result by reading the updated GitHub object or returned URL. +7. Report only the outcome and key identifiers back to the caller. + +## Repository Guidance + +- Follow `AGENTS.md` for repository-wide standards. +- Prefer these skills when relevant: + - `.github/skills/dev/planning/create-issue/SKILL.md` for issue creation workflow + - `.github/skills/dev/github/link-subissue-to-parent-issue/SKILL.md` for parent/sub-issue linking + - `.github/skills/dev/pr-reviews/fetch-review-threads/SKILL.md` for review thread retrieval + - `.github/skills/dev/pr-reviews/resolve-review-threads/SKILL.md` for closing review threads + +## Important Rules + +- Do not guess repository names, labels, issue numbers, PR numbers, or comment IDs. +- Do not assume the visible issue number is the same identifier required by a GitHub API. +- For sub-issue linking, remember that the REST API expects the child issue's internal GitHub ID, + not its visible issue number. +- Do not mix GitHub task execution with unrelated code changes. +- If a PR review comment requires code changes, stop after identifying the actionable request and + hand control back to the caller or a code-focused agent. +- Keep the workflow deterministic: inspect, act, verify. + +## Output Expectations + +When finishing a task, return: + +1. What was changed or verified +2. The key GitHub identifiers or URLs +3. Any blockers, permissions issues, or follow-up needed diff --git a/.github/agents/implementer.agent.md b/.github/agents/implementer.agent.md new file mode 100644 index 000000000..822abbf28 --- /dev/null +++ b/.github/agents/implementer.agent.md @@ -0,0 +1,98 @@ +--- +name: Implementer +description: Software implementer that applies Test-Driven Development and seeks simple solutions. Use when asked to implement a feature, fix a bug, or work through an issue spec. Follows a structured process: analyse the task, decompose into small steps, implement with TDD, audit complexity after each step, then commit. +argument-hint: Describe the task or link the issue spec document. Clarify any constraints or acceptance criteria. +tools: [execute, read, search, edit, todo, agent] +user-invocable: true +disable-model-invocation: false +--- + +You are the repository's software implementer. Your job is to implement tasks correctly, simply, +and verifiably. + +You apply Test-Driven Development (TDD) whenever practical and always seek the simplest solution +that makes the tests pass. + +## Guiding Principles + +Follow **Beck's Four Rules of Simple Design** (in priority order): + +1. **Passes the tests** — the code must work as intended; testing is a first-class activity. +2. **Reveals intention** — code should be easy to understand, expressing purpose clearly. +3. **No duplication** — apply DRY; eliminating duplication drives out good designs. +4. **Fewest elements** — remove anything that does not serve the prior three rules. + +Reference: [Beck Design Rules](https://martinfowler.com/bliki/BeckDesignRules.html) + +## Repository Rules + +- Follow `AGENTS.md` for repository-wide conventions. +- The pre-commit validation command is `./contrib/dev-tools/git/hooks/pre-commit.sh`. +- Relevant skills to load when needed: + - `.github/skills/dev/testing/write-unit-test/SKILL.md` — test naming and Arrange/Act/Assert pattern. + - `.github/skills/dev/rust-code-quality/handle-errors-in-code/SKILL.md` — error handling. + - `.github/skills/dev/git-workflow/commit-changes/SKILL.md` — commit conventions. + +### ADR Discoverability Convention + +When a change introduces or updates an ADR that affects a specific code area: + +- Link the ADR to the key affected code files (for example in an "Affected Code" + section). +- Add concise module-level comments in those code files that link back to the + ADR. + +Goal: contributors can discover the relationship from either side (code-first +or docs-first) without prior context. + +## Required Workflow + +### Step 1 — Analyse the Task + +Before writing any code: + +1. Read `AGENTS.md` and any relevant skill files for the area being changed. +2. Read the issue spec or task description in full. +3. Identify the scope: what must change and what must not change. +4. Ask a clarifying question rather than guessing when a decision matters. + +### Step 2 — Decompose into Small Steps + +Break the task into the smallest independent, verifiable steps possible. Use the todo list to +track progress. Each step should: + +- Have a single, clear intent. +- Be verifiable by a test or observable behaviour. +- Be committable independently when complete. + +### Step 3 — Implement Each Step (TDD Preferred) + +For each step: + +1. **Write a failing test first** (red) — express the expected behaviour in a test. +2. **Write minimal production code** to make the test pass (green). +3. **Refactor** to remove duplication and improve clarity, keeping tests green. +4. Verify with `cargo test -p ` before moving on. + +When TDD is not practical (e.g. CLI wiring, configuration plumbing), implement defensively and +add tests as a close follow-up step. + +### Step 4 — Audit After Each Step + +After completing each step, invoke the **Complexity Auditor** (`@complexity-auditor`) to verify +the current changes. Do not proceed to the next step until the auditor reports no blocking issues. + +If the auditor raises a blocking issue, simplify the implementation before continuing. + +### Step 5 — Commit When Ready + +When a coherent, passing set of changes is ready, invoke the **Committer** (`@committer`) with a +description of what was implemented. Do not commit directly — always delegate to the Committer. + +## Constraints + +- Do not implement more than was asked — scope creep is a defect. +- Do not suppress compiler warnings or clippy lints without a documented reason. +- Do not add dependencies without running `cargo machete` afterward. +- Do not commit code that fails `./contrib/dev-tools/git/hooks/pre-commit.sh`. +- Do not skip the audit step, even for small changes. diff --git a/.github/skills/add-new-skill/SKILL.md b/.github/skills/add-new-skill/SKILL.md new file mode 100644 index 000000000..d99b4e3c9 --- /dev/null +++ b/.github/skills/add-new-skill/SKILL.md @@ -0,0 +1,146 @@ +--- +name: add-new-skill +description: Guide for creating effective Agent Skills for the torrust-tracker project. Use when you need to create a new skill (or update an existing skill) that extends AI agent capabilities with specialized knowledge, workflows, or tool integrations. Triggers on "create skill", "add new skill", "how to add skill", or "skill creation". +metadata: + author: torrust + version: "1.0" +--- + +# Creating New Agent Skills + +This skill guides you through creating effective Agent Skills for the Torrust Tracker project. + +## About Skills + +**What are Agent Skills?** + +Agent Skills are specialized instruction sets that extend AI agent capabilities with domain-specific +knowledge, workflows, and tool integrations. They follow the [agentskills.io](https://agentskills.io) +open format and work with multiple AI coding agents (Claude Code, VS Code Copilot, Cursor, Windsurf). + +### Progressive Disclosure + +Skills use a three-level loading strategy to minimize context window usage: + +1. **Metadata** (~100 tokens): `name` and `description` loaded at startup for all skills +2. **SKILL.md Body** (<5000 tokens): Loaded when a task matches the skill's description +3. **Bundled Resources**: Loaded on-demand only when referenced (scripts, references, assets) + +### When to Create a Skill vs Updating AGENTS.md + +| Use AGENTS.md for... | Use Skills for... | +| ------------------------------- | ------------------------------- | +| Always-on rules and constraints | On-demand workflows | +| "Always do X, never do Y" | Multi-step repeatable processes | +| Baseline conventions | Specialist domain knowledge | +| Rarely changes | Can be added/refined frequently | + +**Example**: "Use lowercase for skill filenames" → AGENTS.md rule. +"How to run pre-commit checks" → Skill. + +## Core Principles + +### 1. Concise is Key + +**Context window is shared** between system prompt, conversation history, other skills, +and your actual request. Only add context the agent doesn't already have. + +### 2. Set Appropriate Degrees of Freedom + +Match specificity to task fragility: + +- **High freedom** (text-based instructions): multiple approaches valid, context-dependent +- **Medium freedom** (pseudocode): preferred pattern exists, some variation acceptable +- **Low freedom** (specific scripts): operations are fragile, sequence must be followed + +### 3. Anatomy of a Skill + +A skill consists of: + +- **SKILL.md**: Frontmatter (metadata) + body (instructions) +- **Optional bundled resources**: `scripts/`, `references/`, `assets/` + +Keep SKILL.md concise (<500 lines). Move detailed content to reference files. + +### 4. Progressive Disclosure + +Split detailed content into reference files loaded on-demand: + +```markdown +## Advanced Features + +See [specification.md](references/specification.md) for Agent Skills spec. +See [patterns.md](references/patterns.md) for workflow patterns. +``` + +### 5. Content Strategy + +- **Include in SKILL.md**: essential commands and step-by-step workflows +- **Put in `references/`**: detailed descriptions, config options, troubleshooting +- **Link to official docs**: architecture docs, ADRs, contributing guides + +## Skill Creation Process + +### Step 1: Plan the Skill + +Answer: + +- What specific queries should trigger this skill? +- What tasks does it help accomplish? +- Does a similar skill already exist? + +### Step 2: Choose the Location + +Follow the directory layout: + +```text +.github/skills/ + add-new-skill/ + dev/ + git-workflow/ + maintenance/ + planning/ + rust-code-quality/ + testing/ +``` + +### Step 3: Write the SKILL.md + +Frontmatter rules: + +- `name`: lowercase letters, numbers, hyphens only; max 64 chars; no consecutive hyphens +- `description`: max 1024 chars; include trigger phrases; describe WHAT and WHEN +- `metadata.author`: `torrust` +- `metadata.version`: `"1.0"` + +### Step 4: Validate and Commit + +```bash +# Check spelling and markdown +linter cspell +linter markdown + +# Run all linters +linter all + +# Commit +git add .github/skills/ +git commit -S -m "docs(skills): add {skill-name} skill" +``` + +## Directory Layout + +```text +.github/skills/ + / + SKILL.md ← Required + references/ ← Optional: detailed docs + scripts/ ← Optional: executable scripts + assets/ ← Optional: templates, data +``` + +## References + +- Agent Skills specification: [references/specification.md](references/specification.md) +- Skill patterns: [references/patterns.md](references/patterns.md) +- Real examples: [references/examples.md](references/examples.md) diff --git a/.github/skills/add-new-skill/references/specification.md b/.github/skills/add-new-skill/references/specification.md new file mode 100644 index 000000000..90e73b8a6 --- /dev/null +++ b/.github/skills/add-new-skill/references/specification.md @@ -0,0 +1,65 @@ +# Agent Skills Specification Reference + +This document provides a reference to the Agent Skills specification from [agentskills.io](https://agentskills.io). + +## What is Agent Skills? + +Agent Skills is an open format for extending AI agent capabilities with specialized knowledge and +workflows. It's vendor-neutral and works with Claude Code, VS Code Copilot, Cursor, and Windsurf. + +## Core Concepts + +### Progressive Disclosure + +```text +Level 1: Metadata (name + description) - ~100 tokens - Loaded at startup for ALL skills +Level 2: SKILL.md body - <5000 tokens - Loaded when skill matches task +Level 3: Bundled resources - On-demand - Loaded only when referenced +``` + +### Directory Structure + +```text +.github/ +└── skills/ + └── skill-name/ + ├── SKILL.md # Required: frontmatter + instructions + ├── README.md # Optional: human-readable documentation + ├── scripts/ # Optional: executable code + ├── references/ # Optional: detailed docs loaded on-demand + └── assets/ # Optional: templates, images, data +``` + +## SKILL.md Format + +### Frontmatter (YAML) + +```yaml +--- +name: skill-name +description: | + What the skill does and when to use it. Include trigger phrases. +metadata: + author: torrust + version: "1.0" +--- +``` + +### Frontmatter Validation Rules + +**name**: + +- Required; max 64 characters +- Lowercase letters, numbers, hyphens only +- Cannot contain consecutive hyphens or XML tags + +**description**: + +- Required; max 1024 characters +- Should describe WHAT the skill does AND WHEN to use it +- Include trigger phrases/keywords + +## References + +- Official spec: +- GitHub Copilot skills docs: diff --git a/.github/skills/dev/git-workflow/commit-changes/SKILL.md b/.github/skills/dev/git-workflow/commit-changes/SKILL.md new file mode 100644 index 000000000..5d3995d54 --- /dev/null +++ b/.github/skills/dev/git-workflow/commit-changes/SKILL.md @@ -0,0 +1,155 @@ +--- +name: commit-changes +description: Guide for committing changes in the torrust-tracker project. Covers conventional commit format, pre-commit verification checklist, GPG signing, and commit quality guidelines. Use when committing code, running pre-commit checks, or following project commit standards. Triggers on "commit", "commit changes", "how to commit", "pre-commit", "commit message", "commit format", or "conventional commits". +metadata: + author: torrust + version: "1.0" +--- + +# Committing Changes + +This skill guides you through the complete commit process for the Torrust Tracker project. + +## Quick Reference + +```bash +# One-time setup: install the pre-commit Git hook +./contrib/dev-tools/git/install-git-hooks.sh + +# Stage changes +git add + +# Commit with conventional format and GPG signature (MANDATORY) +# The pre-commit hook runs ./contrib/dev-tools/git/hooks/pre-commit.sh automatically +git commit -S -m "[()]: " +``` + +## Conventional Commit Format + +We follow [Conventional Commits](https://www.conventionalcommits.org/) specification. + +### Commit Message Structure + +```text +[optional scope]: + +[optional body] + +[optional footer(s)] +``` + +Scope should reflect the affected package or area (e.g., `tracker-core`, `udp-protocol`, `ci`, `docs`). + +### Commit Types + +| Type | Description | Example | +| ---------- | ------------------------------------- | ------------------------------------------------------------ | +| `feat` | New feature or enhancement | `feat(tracker-core): add peer expiry grace period` | +| `fix` | Bug fix | `fix(udp-protocol): resolve endianness in announce response` | +| `docs` | Documentation changes | `docs(agents): add root AGENTS.md` | +| `style` | Code style changes (formatting, etc.) | `style: apply rustfmt to all source files` | +| `refactor` | Code refactoring | `refactor(tracker-core): extract peer list to own module` | +| `test` | Adding or updating tests | `test(http-tracker-core): add announce response tests` | +| `chore` | Maintenance tasks | `chore: update dependencies` | +| `ci` | CI/CD related changes | `ci: add workflow for container publishing` | +| `perf` | Performance improvements | `perf(torrent-repository): switch to dashmap` | + +## GPG Commit Signing (MANDATORY) + +**All commits must be GPG signed.** Use the `-S` flag: + +```bash +git commit -S -m "your commit message" +``` + +## Pre-commit Verification (MANDATORY) + +### Git Hook + +The repository ships a `pre-commit` Git hook that runs `./contrib/dev-tools/git/hooks/pre-commit.sh` +automatically on every `git commit`. Install it once after cloning: + +```bash +./contrib/dev-tools/git/install-git-hooks.sh +``` + +Once installed, the hook fires on every commit and you do not need to run the script manually. + +### Automated Checks + +If the hook is not installed, run the script explicitly before committing. +**It must exit with code `0`.** + +> **⏱️ Expected runtime: ~3 minutes** on a modern developer machine. AI agents must set a +> command timeout of **at least 5 minutes** before invoking this script. + +```bash +./contrib/dev-tools/git/hooks/pre-commit.sh +``` + +The script runs: + +1. `cargo machete` — unused dependency check +2. `linter all` — all linters (markdown, YAML, TOML, clippy, rustfmt, shellcheck, cspell) +3. `cargo test --doc --workspace` — documentation tests +4. `cargo test --tests --benches --examples --workspace --all-targets --all-features` — all tests + +### Manual Checks (Cannot Be Automated) + +Verify these by hand before committing: + +- **Self-review the diff**: read through `git diff --staged` and check for obvious mistakes, + debug artifacts, or unintended changes +- **Documentation updated**: if public API or behaviour changed, doc comments and any relevant + `docs/` pages reflect the change +- **`AGENTS.md` updated**: if architecture, package structure, or key workflows changed, the + relevant `AGENTS.md` file is updated +- **New technical terms added to `project-words.txt`**: any new jargon or identifiers that + cspell does not know about are added alphabetically + +### Debugging a Failing Run + +```bash +linter markdown # Markdown +linter yaml # YAML +linter toml # TOML +linter clippy # Rust code analysis +linter rustfmt # Rust formatting +linter shellcheck # Shell scripts +linter cspell # Spell checking +``` + +Fix Rust formatting automatically: + +```bash +cargo fmt +``` + +## Hashtag Usage Warning + +**Only use `#` when intentionally referencing a GitHub issue.** + +GitHub auto-links `#NUMBER` to issues. Avoid accidental references: + +- ✅ `feat(tracker-core): add feature (see #42)` — intentional reference +- ❌ `fix: make feature #1 priority` — accidentally links to issue #1 + +Use ordered Markdown lists or plain numbers instead of `#N` step labels. + +## Commit Quality Guidelines + +### Good Commits (✅) + +- **Atomic**: Each commit represents one logical change +- **Descriptive**: Clear, concise description of what changed +- **Tested**: All tests pass +- **Linted**: All linters pass +- **Conventional**: Follows conventional commit format +- **Signed**: GPG signature present + +### Commits to Avoid (❌) + +- Too large: multiple unrelated changes in one commit +- Vague messages like "fix stuff" or "WIP" +- Missing scope when a package is clearly affected +- Unsigned commits diff --git a/.github/skills/dev/git-workflow/create-feature-branch/SKILL.md b/.github/skills/dev/git-workflow/create-feature-branch/SKILL.md new file mode 100644 index 000000000..bb2c82a55 --- /dev/null +++ b/.github/skills/dev/git-workflow/create-feature-branch/SKILL.md @@ -0,0 +1,113 @@ +--- +name: create-feature-branch +description: Guide for creating feature branches following the torrust-tracker branching conventions. Covers branch naming format, lifecycle, and common patterns. Use when creating branches for issues, starting work on tasks, or setting up development branches. Triggers on "create branch", "new branch", "checkout branch", "branch for issue", or "start working on issue". +metadata: + author: torrust + version: "1.0" +--- + +# Creating Feature Branches + +This skill guides you through creating feature branches following the Torrust Tracker branching +conventions. + +## Branch Naming Convention + +**Format**: `{issue-number}-{short-description}` (preferred) + +Alternative formats (no tracked issue): + +- `feat/{short-description}` +- `fix/{short-description}` +- `chore/{short-description}` + +**Rules**: + +- Always start with the GitHub issue number when one exists +- Use lowercase letters only +- Separate words with hyphens (not underscores) +- Keep description concise but descriptive + +## Creating a Branch + +### Standard Workflow + +```bash +# Ensure you're on latest develop +git checkout develop +git pull --ff-only + +# Create and checkout branch for issue #42 +git checkout -b 42-add-peer-expiry-grace-period +``` + +### With MCP GitHub Tools + +1. Get the issue number and title +2. Format the branch name: `{number}-{kebab-case-description}` +3. Create the branch from `develop` +4. Checkout locally: `git fetch && git checkout {branch-name}` + +## Branch Naming Examples + +✅ **Good branch names**: + +- `42-add-peer-expiry-grace-period` +- `156-refactor-udp-server-socket-binding` +- `203-add-e2e-mysql-tests` +- `1697-ai-agent-configuration` + +❌ **Avoid**: + +- `my-feature` — no issue number +- `FEATURE-123` — all caps +- `fix_bug` — underscores instead of hyphens +- `42_add_support` — underscores + +## Complete Branch Lifecycle + +### 1. Create Branch from `develop` + +```bash +git checkout develop +git pull --ff-only +git checkout -b 42-add-peer-expiry-grace-period +``` + +### 2. Develop + +Make commits following [commit conventions](../commit-changes/SKILL.md). + +### 3. Pre-commit Checks + +```bash +cargo machete +linter all +cargo test --doc --workspace +cargo test --tests --benches --examples --workspace --all-targets --all-features +``` + +### 4. Push to Your Fork + +```bash +git push {your-fork-remote} 42-add-peer-expiry-grace-period +``` + +### 5. Create Pull Request + +Target branch: `torrust/torrust-tracker:develop` + +### 6. Cleanup After Merge + +```bash +git checkout develop +git pull --ff-only +git branch -d 42-add-peer-expiry-grace-period +``` + +## Converting Issue Title to Branch Name + +1. Get issue number (e.g., #42) +2. Take issue title (e.g., "Add Peer Expiry Grace Period") +3. Convert to lowercase kebab-case: `add-peer-expiry-grace-period` +4. Prefix with issue number: `42-add-peer-expiry-grace-period` diff --git a/.github/skills/dev/git-workflow/open-pull-request/SKILL.md b/.github/skills/dev/git-workflow/open-pull-request/SKILL.md new file mode 100644 index 000000000..04074a383 --- /dev/null +++ b/.github/skills/dev/git-workflow/open-pull-request/SKILL.md @@ -0,0 +1,84 @@ +--- +name: open-pull-request +description: Open a pull request from a feature branch using GitHub CLI (preferred) or GitHub MCP tools. Covers pre-flight checks, correct base/head configuration for fork workflows, title/body conventions, and post-creation validation. Use when asked to "open PR", "create pull request", or "submit branch for review". +metadata: + author: torrust + version: "1.0" +--- + +# Open a Pull Request + +## CLI vs MCP Decision Rule + +- **Inner loop (fast local branch work):** prefer GitHub CLI (`gh pr create`). +- **Outer loop (cross-system coordination):** use MCP tools for structured/authenticated access. + +## Pre-flight Checks + +Before opening a PR: + +- [ ] Working tree is clean (`git status`) +- [ ] Upstream target repository confirmed from workspace metadata (`Cargo.toml` → `repository`) +- [ ] Branch is pushed to your fork remote +- [ ] Commits are GPG signed (`git log --show-signature -n 1`) +- [ ] All pre-commit checks passed (`linter all`, `cargo machete`, tests) + +> Important: always open the PR in the **upstream repository**, not in your fork. +> Resolve upstream from `Cargo.toml` (`repository = "https://github.com/torrust/torrust-tracker"`) and use that value for `gh pr create --repo ...`. + +## Title and Description Convention + +PR title: use Conventional Commit style, include issue reference. + +Examples: + +- `feat(tracker-core): [#42] add peer expiry grace period` +- `docs(agents): set up basic AI agent configuration (#1697)` + +PR body must include: + +- Summary of changes +- Files/packages touched +- Validation performed +- Issue link (`Closes #`) + +## Option A (Preferred): GitHub CLI + +```bash +gh pr create \ + --repo / \ + --base develop \ + --head : \ + --title "" \ + --body "<body>" +``` + +Example upstream resolution from `Cargo.toml`: + +```bash +UPSTREAM_REPO=$(grep '^repository\s*=\s*"https://github.com/' Cargo.toml | sed -E 's#.*github.com/([^\"]+).*#\1#') +gh pr create --repo "$UPSTREAM_REPO" --base develop --head <fork-owner>:<branch-name> --title "<title>" --body "<body>" +``` + +If successful, `gh` prints the PR URL. + +## Option B: GitHub MCP Tools + +When MCP pull request management tools are available, create the PR with: + +- `base`: `develop` +- `head`: `<fork-owner>:<branch-name>` +- Capture and share the resulting PR URL. + +## Post-creation Validation + +- [ ] PR targets `torrust/torrust-tracker:develop` +- [ ] Head branch is correct +- [ ] CI workflows started +- [ ] Issue linked in description + +## Troubleshooting + +- `fatal: ... does not appear to be a git repository`: push to correct remote (`git remote -v`) +- `A pull request already exists`: open existing PR URL instead of creating new +- Permission errors on upstream: use `owner:branch` fork syntax diff --git a/.github/skills/dev/git-workflow/release-new-version/SKILL.md b/.github/skills/dev/git-workflow/release-new-version/SKILL.md new file mode 100644 index 000000000..f30898511 --- /dev/null +++ b/.github/skills/dev/git-workflow/release-new-version/SKILL.md @@ -0,0 +1,147 @@ +--- +name: release-new-version +description: Guide for releasing a new version of the Torrust Tracker using the standard staging branch, tag, and crate publication workflow. Covers version bump, release commit, staging branch promotion, PR to main, release branch/tag creation, crate publication, and merge-back to develop. Use when asked to "release", "cut a version", "publish a new version", or "create release vX.Y.Z". +metadata: + author: torrust + version: "1.0" +--- + +# Release New Version + +Primary reference: [`docs/release_process.md`](../../../../../docs/release_process.md) + +## Release Steps (Mandatory Order) + +1. Stage `develop` → `staging/main` +2. Create release commit (bump version) +3. PR `staging/main` → `main` +4. Push `main` → `releases/vX.Y.Z` +5. Create signed tag `vX.Y.Z` on that branch +6. Verify deployment workflow + crate publication +7. Create GitHub release +8. Stage `main` → `staging/develop` (merge-back) +9. Bump next dev version, PR `staging/develop` → `develop` + +Do not reorder these steps. + +## Version Naming Rules + +- Version in code: `X.Y.Z` (release) or `X.Y.Z-develop` (development) +- Git tag: `vX.Y.Z` +- Release branch: `releases/vX.Y.Z` +- Staging branches: `staging/main`, `staging/develop` + +## Pre-Flight Checklist + +Before starting: + +- [ ] Clean working tree (`git status`) +- [ ] `develop` branch is up to date with `torrust/develop` +- [ ] All CI checks pass on `develop` +- [ ] Working version in manifests is `X.Y.Z-develop` + +## Commands + +### 1) Stage develop → staging/main + +```bash +git fetch --all +git push --force torrust develop:staging/main +``` + +### 2) Create Release Commit + +```bash +git stash +git switch staging/main +git reset --hard torrust/staging/main +# Edit version in all Cargo.toml files: +# change X.Y.Z-develop → X.Y.Z +git add -A +git commit -S -m "release: version X.Y.Z" +git push torrust +``` + +Edit `version` in: + +- `Cargo.toml` (workspace) +- All packages under `packages/` that publish crates +- `console/tracker-client/Cargo.toml` +- `contrib/bencode/Cargo.toml` + +Also update any internal path dependency `version` constraints. + +### 3) PR staging/main → main + +Create PR: "Release Version X.Y.Z" (title format) +Base: `torrust/torrust-tracker:main` +Head: `staging/main` +Merge after CI passes. + +### 4) Push releases/vX.Y.Z branch + +```bash +git fetch --all +git push torrust main:releases/vX.Y.Z +``` + +### 5) Create Signed Tag + +```bash +git switch releases/vX.Y.Z +git reset --hard torrust/releases/vX.Y.Z +git tag --sign vX.Y.Z +git push --tags torrust +``` + +### 6) Verify Deployment Workflow + +Check the +[deployment workflow](https://github.com/torrust/torrust-tracker/actions/workflows/deployment.yaml) +ran successfully and the following crates were published: + +- `torrust-tracker-contrib-bencode` +- `torrust-tracker-located-error` +- `torrust-tracker-primitives` +- `torrust-tracker-clock` +- `torrust-tracker-configuration` +- `torrust-tracker-torrent-repository` +- `torrust-tracker-test-helpers` +- `torrust-tracker` + +Crates must be published in dependency order. Each must be indexed on crates.io before the next +publishes. + +### 7) Create GitHub Release + +Create a release from tag `vX.Y.Z` after the deployment workflow passes. + +### 8) Merge-back: Stage main → staging/develop + +```bash +git fetch --all +git push --force torrust main:staging/develop +``` + +### 9) Bump Next Dev Version + +```bash +git stash +git switch staging/develop +git reset --hard torrust/staging/develop +# Edit version in all Cargo.toml files: +# change X.Y.Z → (next)X.Y.Z-develop (e.g. 3.0.0 → 3.0.1-develop) +git add -A +git commit -S -m "develop: bump to version (next)X.Y.Z-develop" +git push torrust +``` + +Create PR: "Version X.Y.Z was Released" +Base: `torrust/torrust-tracker:develop` +Head: `staging/develop` + +## Failure Handling + +- **Deployment workflow failed**: fix and rerun on same release branch +- **Crate already published**: do not republish; cut a patch release +- **Partial state (tag exists but branch doesn't)**: investigate before proceeding diff --git a/.github/skills/dev/git-workflow/review-pr/SKILL.md b/.github/skills/dev/git-workflow/review-pr/SKILL.md new file mode 100644 index 000000000..da4be9ca3 --- /dev/null +++ b/.github/skills/dev/git-workflow/review-pr/SKILL.md @@ -0,0 +1,66 @@ +--- +name: review-pr +description: Review a pull request for the torrust-tracker project. Covers checklist-based PR quality verification, code style standards, test requirements, documentation, and how to submit review feedback. Use when asked to review a PR, check a pull request, or provide feedback on code changes. Triggers on "review PR", "review pull request", "check PR quality", or "code review". +metadata: + author: torrust + version: "1.0" +--- + +# Reviewing a Pull Request + +## Quick Overview Approach + +1. Read the PR title and description for context +2. Check the diff for scope of change +3. Identify the affected packages and components +4. Apply the checklist below + +## PR Review Checklist + +### PR Metadata + +- [ ] Title follows Conventional Commits format +- [ ] Description clearly explains what changes were made and why +- [ ] Issue is linked (`Closes #<number>` or `Refs #<number>`) +- [ ] Target branch is `develop` (not `main`) + +### Code Quality + +- [ ] Code follows existing patterns in affected packages +- [ ] No unused imports, variables, or functions +- [ ] No `#[allow(...)]` suppressions unless clearly justified with a comment +- [ ] Errors handled properly (use `thiserror` for structured errors, avoid `.unwrap()`) +- [ ] No security vulnerabilities (OWASP Top 10 awareness) + +### Tests + +- [ ] New functionality has unit tests +- [ ] Integration tests added if applicable +- [ ] All existing tests still pass +- [ ] Test code is clean, readable, and maintainable + +### Documentation + +- [ ] Public API items have doc comments +- [ ] `AGENTS.md` updated if architecture changed +- [ ] Markdown docs updated if user-facing behavior changed +- [ ] Spell check: new technical terms added to `project-words.txt` + +### Rust-Specific + +- [ ] Imports grouped: std → external → internal +- [ ] Line length within `max_width = 130` +- [ ] GPG-signed commits + +## Providing Feedback + +Categorize comments to help the author prioritize: + +- **Blocker** — must fix before merge (correctness, security, breaking changes) +- **Suggestion** — improvement recommended but not blocking +- **Nit** — minor style/readability point + +## Standards Reference + +All code quality standards are defined in the root `AGENTS.md`. When pointing to a +standard, reference the relevant section of `AGENTS.md`. diff --git a/.github/skills/dev/git-workflow/run-linters/SKILL.md b/.github/skills/dev/git-workflow/run-linters/SKILL.md new file mode 100644 index 000000000..c779b413f --- /dev/null +++ b/.github/skills/dev/git-workflow/run-linters/SKILL.md @@ -0,0 +1,121 @@ +--- +name: run-linters +description: Run code quality checks and linters for the torrust-tracker project. Includes Rust clippy, rustfmt, markdown, YAML, TOML, spell checking, and shellcheck. Use when asked to lint code, check formatting, fix code quality issues, or prepare for commit. Triggers on "lint", "run linters", "check code quality", "fix formatting", "run clippy", "run rustfmt", or "pre-commit checks". +metadata: + author: torrust + version: "1.0" +--- + +# Run Linters + +## Quick Reference + +### Run All Linters + +```bash +linter all +``` + +**Always run `linter all` before every commit. It must exit with code `0`.** + +### Run a Single Linter + +```bash +linter markdown # Markdown (markdownlint) +linter yaml # YAML (yamllint) +linter toml # TOML (taplo) +linter cspell # Spell checker (cspell) +linter clippy # Rust code analysis (clippy) +linter rustfmt # Rust formatting (rustfmt) +linter shellcheck # Shell scripts (shellcheck) +``` + +## Common Workflows + +### Before Any Commit + +```bash +linter all # Must pass with exit code 0 +``` + +### Debug a Failing Full Run + +```bash +# Identify which linter is failing +linter markdown +linter yaml +linter toml +linter cspell +linter clippy +linter rustfmt +linter shellcheck +``` + +### During Development (Rust only) + +```bash +linter clippy # Check logic and code quality +linter rustfmt # Check formatting +``` + +## Fixing Common Issues + +### Rust Formatting Errors (rustfmt) + +```bash +cargo fmt # Auto-fix all Rust source files +``` + +Formatting rules from `rustfmt.toml`: + +- `max_width = 130` +- `group_imports = "StdExternalCrate"` +- `imports_granularity = "Module"` + +### Rust Clippy Errors + +Warnings are **errors** (configured as `-D warnings` in `.cargo/config.toml`). +Fix the underlying issue — do not `#[allow(...)]` unless truly unavoidable. + +Example: unused variable → use `_var` prefix or actually use the value. + +### Markdown Errors (markdownlint) + +Common issues: + +- Trailing whitespace +- Missing blank line before headings +- Incorrect heading levels +- Lines exceeding 120 characters + +Configuration in `.markdownlint.json`. + +### YAML Errors (yamllint) + +Common issues: + +- Trailing spaces +- Inconsistent indentation (2 spaces expected) +- Missing newline at end of file + +Configuration in `.yamllint-ci.yml`. + +### TOML Errors (taplo) + +```bash +taplo fmt **/*.toml # Auto-fix TOML formatting +``` + +### Spell Check Errors (cspell) + +For legitimate technical terms not in dictionaries, add them to `project-words.txt` +(alphabetical order, one per line). + +### Shell Script Errors (shellcheck) + +Fix the reported issue in the shell script. Common: use `[[ ]]` instead of `[ ]`, +quote variables, avoid `eval`. + +## Linter Details + +See [references/linters.md](references/linters.md) for detailed documentation on each linter. diff --git a/.github/skills/dev/git-workflow/run-linters/references/linters.md b/.github/skills/dev/git-workflow/run-linters/references/linters.md new file mode 100644 index 000000000..40b3ee5fb --- /dev/null +++ b/.github/skills/dev/git-workflow/run-linters/references/linters.md @@ -0,0 +1,85 @@ +# Linter Documentation + +This document provides detailed documentation for each linter used in the Torrust Tracker project. + +## Overview + +The project uses the `linter` binary from +[torrust/torrust-linting](https://github.com/torrust/torrust-linting) as a unified wrapper around +all linters. + +Install: `cargo install --locked --git https://github.com/torrust/torrust-linting --bin linter` + +## Rust Linters + +### clippy + +**Tool**: Rust's official linter. +**Config**: `.cargo/config.toml` (global `rustflags`) +**Run**: `linter clippy` + +Warnings are treated as errors via `-D warnings` in `.cargo/config.toml`. +Do not suppress warnings with `#[allow(...)]` unless absolutely necessary. + +**Critical flags** (from `.cargo/config.toml`): + +- `-D warnings` — all warnings are errors +- `-D unused` — unused items are errors +- `-D rust-2018-idioms` — enforces Rust 2018 idioms +- `-D future-incompatible` + +### rustfmt + +**Tool**: Rust code formatter. +**Config**: `rustfmt.toml` +**Run**: `linter rustfmt` +**Auto-fix**: `cargo fmt` + +Key formatting settings: + +- `max_width = 130` +- `group_imports = "StdExternalCrate"` +- `imports_granularity = "Module"` + +## Documentation Linters + +### markdownlint + +**Tool**: markdownlint +**Config**: `.markdownlint.json` +**Run**: `linter markdown` + +### cspell (Spell Checker) + +**Tool**: cspell +**Config**: `cspell.json` +**Dictionary**: `project-words.txt` +**Run**: `linter cspell` + +Add technical terms to `project-words.txt` (alphabetical order, one per line). + +## Configuration Linters + +### yamllint + +**Tool**: yamllint +**Config**: `.yamllint-ci.yml` +**Run**: `linter yaml` + +Expected: 2-space indentation, no trailing whitespace, newline at EOF. + +### taplo + +**Tool**: taplo +**Config**: `.taplo.toml` +**Run**: `linter toml` +**Auto-fix**: `taplo fmt **/*.toml` + +## Script Linters + +### shellcheck + +**Tool**: shellcheck +**Run**: `linter shellcheck` + +Checks all shell scripts. Use `[[ ]]` over `[ ]`, quote variables (`"$var"`), and avoid `eval`. diff --git a/.github/skills/dev/git-workflow/run-pre-commit-checks/SKILL.md b/.github/skills/dev/git-workflow/run-pre-commit-checks/SKILL.md new file mode 100644 index 000000000..371c27dfc --- /dev/null +++ b/.github/skills/dev/git-workflow/run-pre-commit-checks/SKILL.md @@ -0,0 +1,88 @@ +--- +name: run-pre-commit-checks +description: Run all mandatory pre-commit verification steps for the torrust-tracker project. Covers the pre-commit script (automated checks), manual review steps, and individual linter commands for debugging. Use before any commit or PR to ensure all quality gates pass. Triggers on "pre-commit checks", "run all checks", "verify before commit", or "check everything". +metadata: + author: torrust + version: "1.0" +--- + +# Run Pre-commit Checks + +## Git Hook (Recommended Setup) + +The repository ships a `pre-commit` Git hook that runs `./contrib/dev-tools/git/hooks/pre-commit.sh` +automatically on every `git commit`. Install it once after cloning: + +```bash +./contrib/dev-tools/git/install-git-hooks.sh +``` + +After installation the hook fires automatically; you do not need to invoke the script +manually before each commit. + +## Automated Checks + +> **⏱️ Expected runtime: ~3 minutes** on a modern developer machine. AI agents must set a +> command timeout of **at least 5 minutes** before invoking `./contrib/dev-tools/git/hooks/pre-commit.sh`. Agents +> with a default per-command timeout below 5 minutes will likely time out and report a false +> failure. + +Run the pre-commit script. **It must exit with code `0` before every commit.** + +```bash +./contrib/dev-tools/git/hooks/pre-commit.sh +``` + +The script runs these steps in order: + +1. `cargo machete` — unused dependency check +2. `linter all` — all linters (markdown, YAML, TOML, clippy, rustfmt, shellcheck, cspell) +3. `cargo test --doc --workspace` — documentation tests +4. `cargo test --tests --benches --examples --workspace --all-targets --all-features` — all tests + +> **MySQL tests**: MySQL-specific tests require a running instance and a feature flag: +> +> ```bash +> TORRUST_TRACKER_CORE_RUN_MYSQL_DRIVER_TEST=true cargo test --package bittorrent-tracker-core +> ``` +> +> These are not run by the pre-commit script. + +## Manual Checks (Cannot Be Automated) + +Verify these by hand before committing: + +- **Self-review the diff**: read through `git diff --staged` for debug artifacts or unintended changes +- **Documentation updated**: if public API or behaviour changed, doc comments and `docs/` pages reflect it +- **`AGENTS.md` updated**: if architecture or key workflows changed, the relevant `AGENTS.md` is updated +- **New technical terms in `project-words.txt`**: new jargon added alphabetically + +## Before Opening a PR (Recommended) + +```bash +cargo +nightly doc --no-deps --bins --examples --workspace --all-features +``` + +## Debugging Individual Linters + +Run individual linters to isolate a failure: + +```bash +linter markdown # Markdown +linter yaml # YAML +linter toml # TOML +linter clippy # Rust code analysis +linter rustfmt # Rust formatting +linter shellcheck # Shell scripts +linter cspell # Spell checking +``` + +| Failure | Fix | +| ------------------- | --------------------------------------- | +| Unused dependency | Remove from `Cargo.toml` | +| Clippy warning | Fix the underlying issue | +| rustfmt error | Run `cargo fmt` | +| Markdown lint error | Fix formatting per `.markdownlint.json` | +| Spell check error | Add term to `project-words.txt` | +| Test failure | Fix the failing test or code | +| Doc build error | Fix Rust doc comment | diff --git a/.github/skills/dev/github/link-subissue-to-parent-issue/SKILL.md b/.github/skills/dev/github/link-subissue-to-parent-issue/SKILL.md new file mode 100644 index 000000000..891196ea1 --- /dev/null +++ b/.github/skills/dev/github/link-subissue-to-parent-issue/SKILL.md @@ -0,0 +1,126 @@ +--- +name: link-subissue-to-parent-issue +description: Guide for linking an existing GitHub issue as a sub-issue of a parent issue in the torrust-tracker project. Covers the GitHub REST API flow, the required internal issue ID for the child issue, verification, and common failure modes. Use when setting a parent issue for a sub-issue, attaching a child issue to an epic, or linking an existing issue under another issue. Triggers on "set parent issue", "link subissue", "add sub-issue", "attach child issue", or "make issue a subissue". +metadata: + author: torrust + version: "1.0" +--- + +# Linking a Sub-Issue to a Parent Issue + +This skill covers the workflow for linking an existing GitHub issue under a parent issue. + +## When to Use + +Use this when: + +- A child issue already exists and needs to be attached to an epic or parent issue +- You need to set or fix the parent issue of an existing sub-issue +- You want to verify that a sub-issue link was created correctly + +## Important Detail + +The GitHub sub-issues REST API expects the **internal GitHub issue ID** for the child issue, +not the visible issue number. + +- Issue number example: `1715` +- Internal issue ID example: `4349463336` + +If you send the issue number as `sub_issue_id`, GitHub returns a `422` validation error. + +## Standard Workflow + +### 1. Confirm the parent and child issue numbers + +Decide which issue is the parent and which is the child. + +- Parent issue number: the epic or container issue +- Child issue number: the issue to attach under the parent + +### 2. Get the internal ID for the child issue + +```bash +gh api /repos/torrust/torrust-tracker/issues/{child-issue-number} --jq '.id' +``` + +Example: + +```bash +gh api /repos/torrust/torrust-tracker/issues/1715 --jq '.id' +``` + +### 3. Link the child issue to the parent issue + +```bash +gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + /repos/torrust/torrust-tracker/issues/{parent-issue-number}/sub_issues \ + --input - <<'EOF' +{"sub_issue_id": {child-internal-id}} +EOF +``` + +Example: + +```bash +gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + /repos/torrust/torrust-tracker/issues/1525/sub_issues \ + --input - <<'EOF' +{"sub_issue_id": 4349463336} +EOF +``` + +### 4. Verify the link + +Check the child issue's `parent_issue_url`: + +```bash +gh api /repos/torrust/torrust-tracker/issues/{child-issue-number} --jq '.parent_issue_url' +``` + +Example: + +```bash +gh api /repos/torrust/torrust-tracker/issues/1715 --jq '.parent_issue_url' +``` + +Expected result: + +```text +https://api.github.com/repos/torrust/torrust-tracker/issues/1525 +``` + +## Common Failure Modes + +### `422` Invalid property `/sub_issue_id` + +Cause: you passed the child issue number instead of the child's internal issue ID. + +Fix: fetch the child issue with `gh api ... --jq '.id'` and use that value. + +### `404 Not Found` + +Possible causes: + +- Wrong repository path +- Wrong parent issue number +- Missing permissions for sub-issue management +- The repository or issue does not support the operation in the current context + +Fix: verify the repo, the parent issue number, and your GitHub permissions. + +## Optional MCP Alternative + +If GitHub MCP tools are available, prefer the dedicated sub-issue tool over raw API calls. +Still make sure you pass the **internal issue ID** for the child issue, not the issue number. + +## Notes for Torrust Tracker + +- Parent issues are often EPICs in `docs/issues/` +- Child issues usually have their own spec file and implementation branch +- After creating and linking a new issue, rename the local spec file to include the assigned issue number diff --git a/.github/skills/dev/maintenance/install-linter/SKILL.md b/.github/skills/dev/maintenance/install-linter/SKILL.md new file mode 100644 index 000000000..9112acd31 --- /dev/null +++ b/.github/skills/dev/maintenance/install-linter/SKILL.md @@ -0,0 +1,62 @@ +--- +name: install-linter +description: Install the torrust-linting `linter` binary and its external tool dependencies. Use when setting up a new development environment, after a fresh clone, or when the `linter` binary is missing. Triggers on "install linter", "setup linter", "linter not found", "install torrust-linting", "missing linter binary", or "set up development environment". +metadata: + author: torrust + version: "1.0" +--- + +# Install the Linter + +The project uses a unified `linter` binary from +[torrust/torrust-linting](https://github.com/torrust/torrust-linting) to run all quality checks. + +## Install the `linter` Binary + +```bash +cargo install --locked --git https://github.com/torrust/torrust-linting --bin linter +``` + +Verify the installation: + +```bash +linter --version +``` + +## Install External Tool Dependencies + +The `linter` binary delegates to external tools. Install them if they are not already present: + +| Linter | Tool | Install command | +| ----------- | ---------------- | ------------------------------------- | +| Markdown | markdownlint-cli | `npm install -g markdownlint-cli` | +| YAML | yamllint | `pip3 install yamllint` | +| TOML | taplo | `cargo install taplo-cli --locked` | +| Spell check | cspell | `npm install -g cspell` | +| Shell | shellcheck | `apt install shellcheck` | +| Rust | clippy / rustfmt | bundled with `rustup` (no extra step) | + +> The `linter` binary will attempt to install missing npm-based tools automatically on first run. +> System-packaged tools (`yamllint`, `shellcheck`) must be installed manually. + +## Configuration Files + +The linters read configuration from files in the project root. These are already present in the +repository — no manual setup is needed: + +| File | Used by | +| -------------------- | ------------ | +| `.markdownlint.json` | markdownlint | +| `.yamllint-ci.yml` | yamllint | +| `.taplo.toml` | taplo | +| `cspell.json` | cspell | + +## Verify Full Setup + +After installing the binary and its dependencies, run all linters to confirm everything works: + +```bash +linter all +``` + +It must exit with code `0`. See the `run-linters` skill for day-to-day usage. diff --git a/.github/skills/dev/maintenance/setup-dev-environment/SKILL.md b/.github/skills/dev/maintenance/setup-dev-environment/SKILL.md new file mode 100644 index 000000000..dae36c068 --- /dev/null +++ b/.github/skills/dev/maintenance/setup-dev-environment/SKILL.md @@ -0,0 +1,123 @@ +--- +name: setup-dev-environment +description: Set up a local development environment for torrust-tracker from scratch. Covers system dependencies, Rust toolchain, storage directories, linter binary, git hooks, and smoke tests. Use when onboarding to the project, setting up a new machine, or after a fresh clone. Triggers on "setup dev environment", "fresh clone", "onboarding", "install dependencies", "set up environment", or "getting started". +metadata: + author: torrust + version: "1.0" +--- + +# Set Up the Development Environment + +Full setup guide for a fresh clone of `torrust-tracker`. Follow the steps in order. + +Reference: [How to Set Up the Development Environment](https://torrust.com/blog/how-to-setup-the-development-environment) + +## Step 1: System Dependencies + +Install the required system packages (Debian/Ubuntu): + +```bash +sudo apt-get install libsqlite3-dev pkg-config libssl-dev make +``` + +> For other distributions, install the equivalent packages for SQLite3 development headers, OpenSSL +> development headers, `pkg-config`, and `make`. + +## Step 2: Rust Toolchain + +```bash +rustup show # Confirm toolchain is active +rustup update # Update to latest stable +rustup toolchain install nightly # Required for docs generation +``` + +The project MSRV is **1.72**. The nightly toolchain is needed only for +`cargo +nightly doc` and certain pre-commit hook checks. + +## Step 3: Build + +```bash +cargo build +``` + +This compiles all workspace crates and verifies that all dependencies resolve correctly. + +## Step 4: Create Storage Directories + +The tracker writes runtime data (databases, logs, TLS certs, config) to `storage/`, which is +git-ignored. Create the required folders once: + +```bash +mkdir -p ./storage/tracker/lib/database +mkdir -p ./storage/tracker/lib/tls +mkdir -p ./storage/tracker/etc +``` + +## Step 5: Install the Linter Binary + +```bash +cargo install --locked --git https://github.com/torrust/torrust-linting --bin linter +``` + +See the `install-linter` skill for external tool dependencies (markdownlint, yamllint, etc.). + +## Step 6: Install Additional Cargo Tools + +```bash +cargo install cargo-machete # Unused dependency checker +``` + +## Step 7: Install Git Hooks + +Install the project pre-commit hook (one-time, re-run after hook changes): + +```bash +./contrib/dev-tools/git/install-git-hooks.sh +``` + +The hook runs `./contrib/dev-tools/git/hooks/pre-commit.sh` automatically on every `git commit`. + +## Step 8: Smoke Test + +Run the tracker with the default development configuration to confirm the build works: + +```bash +cargo run +``` + +Expected output includes lines like: + +```text +Loading configuration from default configuration file: `./share/default/config/tracker.development.sqlite3.toml` +[UDP TRACKER] Starting on: udp://0.0.0.0:6969 +[HTTP TRACKER] Started on: http://0.0.0.0:7070 +[API] Started on http://127.0.0.1:1212 +[HEALTH CHECK API] Started on: http://127.0.0.1:1313 +``` + +Press `Ctrl-C` to stop. + +## Step 9: Verify Full Test Suite + +```bash +cargo test --doc --workspace +cargo test --tests --benches --examples --workspace --all-targets --all-features +``` + +Both commands must exit `0` before any commit. + +## Custom Configuration (Optional) + +To run with a custom config instead of the default template: + +```bash +cp share/default/config/tracker.development.sqlite3.toml storage/tracker/etc/tracker.toml +# Edit storage/tracker/etc/tracker.toml as needed +TORRUST_TRACKER_CONFIG_TOML_PATH="./storage/tracker/etc/tracker.toml" cargo run +``` + +## Useful Development Tools + +- **DB Browser for SQLite** — inspect and edit SQLite databases: <https://sqlitebrowser.org/> +- **qBittorrent** — BitTorrent client for manual testing: <https://www.qbittorrent.org/> +- **imdl** — torrent file editor (`cargo install imdl`): <https://github.com/casey/intermodal> diff --git a/.github/skills/dev/maintenance/update-dependencies/SKILL.md b/.github/skills/dev/maintenance/update-dependencies/SKILL.md new file mode 100644 index 000000000..121c99fbb --- /dev/null +++ b/.github/skills/dev/maintenance/update-dependencies/SKILL.md @@ -0,0 +1,120 @@ +--- +name: update-dependencies +description: Guide for updating project dependencies in the torrust-tracker project. Covers the manual cargo update workflow including branch creation, running checks, committing, and pushing. Distinguishes trivial updates (Cargo.lock only) from breaking-change updates (code rework needed). Use when updating dependencies, running cargo update, or bumping deps. Triggers on "update dependencies", "cargo update", "update deps", or "bump dependencies". +metadata: + author: torrust + version: "1.0" +--- + +# Updating Dependencies + +This skill guides you through updating project dependencies for the Torrust Tracker project. + +## Update Categories + +Before starting, decide which category the update falls into: + +| Category | Description | Branch / Issue | +| ------------ | -------------------------------------------- | -------------------------------------------------------------- | +| **Trivial** | `cargo update` only — no code changes needed | Timestamped branch, no issue required | +| **Breaking** | Dependency change requires code rework | If small: same branch. If large: open a separate issue per dep | + +Use `cargo update --dry-run` or read the dependency changelog to classify before starting. + +## Quick Reference + +```bash +# Get a timestamp (YYYYMMDD) +TIMESTAMP=$(date +%Y%m%d) + +# Create branch +git checkout develop && git pull --ff-only +git checkout -b "${TIMESTAMP}-update-dependencies" + +# Update dependencies +cargo update 2>&1 | tee /tmp/cargo-update.txt + +# If Cargo.lock has no changes, nothing to do — stop here. + +# Verify +./contrib/dev-tools/git/hooks/pre-commit.sh + +# Commit and push +git add Cargo.lock +git commit -S -m "chore: update dependencies" -m "$(cat /tmp/cargo-update.txt)" +git push {your-fork-remote} "${TIMESTAMP}-update-dependencies" +``` + +## Complete Workflow + +### Step 1: Create a Branch + +Generate a timestamp prefix to avoid branch name conflicts across repeated runs: + +```bash +TIMESTAMP=$(date +%Y%m%d) +git checkout develop +git pull --ff-only +git checkout -b "${TIMESTAMP}-update-dependencies" +``` + +For breaking-change updates that require a tracked issue: + +```bash +git checkout -b {issue-number}-update-dependencies +``` + +### Step 2: Run Cargo Update + +```bash +cargo update 2>&1 | tee /tmp/cargo-update.txt +``` + +If `Cargo.lock` has no changes, there is nothing to update — exit early. + +Review `/tmp/cargo-update.txt` to identify any major version bumps that may be breaking. + +### Step 3: Handle Breaking Changes + +If any updated dependency introduced a breaking API change: + +- **Small rework** (a few lines, no design decisions): fix it in this branch and continue. +- **Large rework** (architectural impact or significant effort): revert that specific dependency + in `Cargo.toml`, keep the other trivial updates, and open a new issue for the breaking + dependency separately. + +```bash +# Revert a single crate to its current locked version to defer it +cargo update --precise {old-version} {crate-name} +``` + +### Step 4: Verify + +```bash +cargo machete +./contrib/dev-tools/git/hooks/pre-commit.sh +``` + +Fix any failures before proceeding. + +### Step 5: Commit and Push + +```bash +git add Cargo.lock +git commit -S -m "chore: update dependencies" -m "$(cat /tmp/cargo-update.txt)" +git push {your-fork-remote} "${TIMESTAMP}-update-dependencies" +``` + +### Step 6: Open PR + +Target: `torrust/torrust-tracker:develop` +Title: `chore: update dependencies` + +## Decision Guide + +| Scenario | Action | +| ---------------------------------------------- | ---------------------------------------------------------- | +| `cargo update` with no code changes | Trivial — timestamped branch, no issue | +| Breaking change, small rework (< 1 hour) | Fix in the same branch, note in PR description | +| Breaking change, large rework (> 1 hour) | Defer: revert that dep, open a separate issue, separate PR | +| Multiple breaking deps, independent migrations | One issue + PR per dependency to keep diffs reviewable | diff --git a/.github/skills/dev/planning/cleanup-completed-issues/SKILL.md b/.github/skills/dev/planning/cleanup-completed-issues/SKILL.md new file mode 100644 index 000000000..a4c7b3966 --- /dev/null +++ b/.github/skills/dev/planning/cleanup-completed-issues/SKILL.md @@ -0,0 +1,88 @@ +--- +name: cleanup-completed-issues +description: Guide for cleaning up completed and closed issues in the torrust-tracker project. Covers removing issue documentation files from docs/issues/ and committing the cleanup. Supports single issue cleanup or batch cleanup. Use when cleaning up closed issues, removing issue docs, or maintaining the docs/issues/ folder. Triggers on "cleanup issue", "remove issue", "clean completed issues", "delete closed issue", or "maintain issue docs". +metadata: + author: torrust + version: "1.0" +--- + +# Cleaning Up Completed Issues + +## When to Clean Up + +- **After PR merge**: Remove the issue file when its PR is merged +- **Batch cleanup**: Periodically clean up multiple closed issues during maintenance +- **Before releases**: Tidy documentation before major releases + +## Cleanup Approaches + +### Option 1: Single Issue Cleanup (Recommended) + +1. Verify the issue is closed on GitHub +2. Remove the issue file from `docs/issues/` +3. Commit and push changes + +### Option 2: Batch Cleanup + +1. List all issue files in `docs/issues/` +2. Check status of each issue on GitHub +3. Remove all closed issue files +4. Commit and push with a descriptive message + +## Step-by-Step Process + +### Step 1: Verify Issue is Closed on GitHub + +**Single issue:** + +```bash +gh issue view {issue-number} --json state --jq .state +``` + +Expected: `CLOSED` + +**Batch:** + +```bash +for issue in 21 22 23 24; do + state=$(gh issue view "$issue" --json state --jq .state 2>/dev/null || echo "NOT_FOUND") + echo "$issue:$state" +done +``` + +### Step 2: Remove Issue Documentation File + +```bash +# Single issue +git rm docs/issues/42-add-peer-expiry-grace-period.md + +# Batch +git rm docs/issues/21-some-old-issue.md \ + docs/issues/22-another-old-issue.md +``` + +### Step 3: Commit and Push + +```bash +# Single issue +git commit -S -m "chore(issues): remove closed issue #42 documentation" + +# Batch +git commit -S -m "chore(issues): remove documentation for closed issues #21, #22, #23" + +git push {your-fork-remote} {branch} +``` + +## Determining If an Issue File Should Stay + +Keep issue files when: + +- The issue is still open +- The PR is open (still being worked on) +- The specification is referenced from other active docs + +Remove issue files when: + +- The issue is **closed** +- The implementing PR is **merged** +- The file is no longer referenced by active work diff --git a/.github/skills/dev/planning/create-adr/SKILL.md b/.github/skills/dev/planning/create-adr/SKILL.md new file mode 100644 index 000000000..0438b1800 --- /dev/null +++ b/.github/skills/dev/planning/create-adr/SKILL.md @@ -0,0 +1,127 @@ +--- +name: create-adr +description: Guide for creating Architectural Decision Records (ADRs) in the torrust-tracker project. Covers the timestamp-based file naming convention, free-form structure, index registration in the docs/adrs/README.md index table, and commit workflow. Use when documenting architectural decisions, recording design choices, or adding decision records. Triggers on "create ADR", "add ADR", "new decision record", "architectural decision", "document decision", or "add decision". +metadata: + author: torrust + version: "1.0" +--- + +# Creating Architectural Decision Records + +## Quick Reference + +```bash +# 1. Generate the filename prefix +date -u +"%Y%m%d%H%M%S" +# e.g. 20241115093012 + +# 2. Create the ADR file +# Format: YYYYMMDDHHMMSS_snake_case_title.md +touch docs/adrs/20241115093012_your_decision_title.md + +# 3. Update the index +# Add entry to docs/adrs/index.md + +# 4. Validate and commit +linter markdown +linter cspell +git commit -S -m "docs(adrs): add ADR for {short description}" +``` + +## When to Create an ADR + +Create an ADR when making a decision that: + +- Affects the project's architecture or design patterns +- Chooses one approach over alternatives that were considered +- Has consequences worth documenting for future contributors +- Answers "why was this done this way?" + +Do **not** create an ADR for trivial implementation choices or style preferences covered by linting. + +## File Naming Convention + +**Format**: `YYYYMMDDHHMMSS_snake_case_title.md` + +Generate the timestamp prefix: + +```bash +date -u +"%Y%m%d%H%M%S" +``` + +**Examples**: + +- `20240227164834_use_plural_for_modules_containing_collections.md` +- `20241115093012_adopt_axum_for_http_server.md` + +Location: `docs/adrs/` + +## ADR Structure + +There is no rigid template — derive structure from context. Use +[docs/templates/ADR.md](../../../docs/templates/ADR.md) as a starting point. + +Optional sections to add when relevant: + +- **Alternatives Considered**: other options explored and why they were rejected +- **Consequences**: positive and negative effects of the decision + +## Step-by-Step Process + +### Step 1: Generate Filename + +```bash +PREFIX=$(date -u +"%Y%m%d%H%M%S") +TITLE="your_decision_title" # snake_case +echo "docs/adrs/${PREFIX}_${TITLE}.md" +``` + +### Step 2: Write the ADR + +- **Description**: Explain the problem thoroughly — enough context for future contributors +- **Agreement**: State clearly what was decided and why +- **Date**: Today's date (`date -u +"%Y-%m-%d"`) +- **References**: Issues, PRs, external docs + +### Step 3: Update the Index + +Add a row to the index table in `docs/adrs/index.md`: + +```markdown +| [YYYYMMDDHHMMSS](YYYYMMDDHHMMSS_your_title.md) | YYYY-MM-DD | Short Title | One-sentence description. | +``` + +- The first column links to the ADR file using the timestamp as display text. +- The short description should allow a reader to understand the decision without opening the file. + +### Step 3.5: Cross-link ADR and Affected Code + +When an ADR affects a specific area of code, keep discovery bidirectional: + +- Add a short "Affected Code" section in the ADR with links to key files + (module entry points, traits, setup/wiring files). +- Add concise module-level doc comments in those code files pointing back to + the ADR. + +This keeps rationale discoverable whether a contributor starts from docs or +from code. + +### Step 4: Validate and Commit + +```bash +linter markdown +linter cspell +linter all # full check + +git add docs/adrs/ +git commit -S -m "docs(adrs): add ADR for {short description}" +git push {your-fork-remote} {branch} +``` + +If code comments were added to establish ADR links, include those files in the +same commit when practical. + +## Example ADR + +For a real example, see +[20240227164834_use_plural_for_modules_containing_collections.md](../../../docs/adrs/20240227164834_use_plural_for_modules_containing_collections.md). diff --git a/.github/skills/dev/planning/create-issue/SKILL.md b/.github/skills/dev/planning/create-issue/SKILL.md new file mode 100644 index 000000000..ed38c9933 --- /dev/null +++ b/.github/skills/dev/planning/create-issue/SKILL.md @@ -0,0 +1,101 @@ +--- +name: create-issue +description: Guide for creating GitHub issues in the torrust-tracker project. Covers the full workflow from specification drafting, user review, to GitHub issue creation with proper documentation and file naming. Supports task, bug, feature, and epic issue types. Use when creating issues, opening tickets, filing bugs, proposing tasks, or adding features. Triggers on "create issue", "open issue", "new issue", "file bug", "add task", "create epic", or "open ticket". +metadata: + author: torrust + version: "1.0" +--- + +# Creating Issues + +## Issue Types + +| Type | Label | When to Use | +| ----------- | --------- | -------------------------------------------- | +| **Task** | `task` | Single implementable unit of work | +| **Bug** | `bug` | Something broken that needs fixing | +| **Feature** | `feature` | New capability or enhancement | +| **Epic** | `epic` | Major feature area containing multiple tasks | + +## Workflow Overview + +The process is **spec-first**: write and review a specification before creating the GitHub issue. + +1. **Draft specification** document in `docs/issues/` (no template — write from scratch) +2. **User reviews** the draft specification +3. **Create GitHub issue** +4. **Rename spec file** to include the issue number +5. **Pre-commit checks** and commit the spec + +**Never create the GitHub issue before the user reviews and approves the specification.** + +## Step-by-Step Process + +### Step 1: Draft Issue Specification + +Create a specification file with a **temporary name** (no issue number yet): + +```bash +touch docs/issues/{short-description}.md +``` + +Use [docs/templates/ISSUE.md](../../../docs/templates/ISSUE.md) as the starting structure. +Use **placeholders** for the issue number until after creation (e.g., `[To be assigned]`). + +After drafting, run linters: + +```bash +linter markdown +linter cspell +``` + +### Step 2: User Reviews the Draft + +**STOP HERE** — present the draft to the user. Iterate until approved. + +### Step 3: Create the GitHub Issue + +After user approval, create the GitHub issue. Options: + +**GitHub CLI:** + +```bash +gh issue create \ + --repo torrust/torrust-tracker \ + --title "{title}" \ + --body "{body}" \ + --label "{label}" +``` + +**MCP GitHub tools** (if available): use `mcp_github_github_issue_write` with `title`, `body`, and `labels`. + +### Step 4: Rename the Spec File + +Rename using the assigned issue number: + +```bash +git mv docs/issues/{short-description}.md \ + docs/issues/{number}-{short-description}.md +``` + +Update any issue number placeholders inside the file. + +### Step 5: Commit and Push + +```bash +linter all # Must pass + +git add docs/issues/ +git commit -S -m "docs(issues): add issue specification for #{number}" +git push {your-fork-remote} {branch} +``` + +## Naming Convention + +File name format: `{number}-{short-description}.md` + +Examples: + +- `1697-ai-agent-configuration.md` +- `42-add-peer-expiry-grace-period.md` +- `523-internal-linting-tool.md` diff --git a/.github/skills/dev/planning/write-markdown-docs/SKILL.md b/.github/skills/dev/planning/write-markdown-docs/SKILL.md new file mode 100644 index 000000000..a2c166efa --- /dev/null +++ b/.github/skills/dev/planning/write-markdown-docs/SKILL.md @@ -0,0 +1,70 @@ +--- +name: write-markdown-docs +description: Guide for writing Markdown documentation in this project. Covers GitHub Flavored Markdown pitfalls, especially the critical #NUMBER pattern that auto-links to GitHub issues and PRs (NEVER use #1, #2, #3 as step/list numbers). Use ordered lists or plain numbers instead. Covers intentional vs accidental autolinks for issues, @mentions, and commit SHAs. Use when writing .md files, documentation, issue descriptions, PR descriptions, or README updates. Triggers on "markdown", "write docs", "documentation", "#number", "github markdown", "autolink", "markdown pitfall", or "GFM". +metadata: + author: torrust + version: "1.0" +--- + +# Writing Markdown Documentation + +## Critical: #NUMBER Auto-links to GitHub Issues + +**GitHub automatically converts `#NUMBER` → link to issue/PR/discussion.** + +```markdown +❌ Bad: accidentally links to issues + +- Task #1: Set up infrastructure ← links to GitHub issue #1 +- Task #2: Configure database ← links to GitHub issue #2 + +Step #1: Install dependencies ← links to GitHub issue #1 +``` + +The links pollute the referenced issues with unrelated backlinks and confuse readers. + +### Fix: Use Ordered Lists or Plain Numbers + +```markdown +✅ Solution 1: Ordered list (automatic numbering) + +1. Set up infrastructure +2. Configure database +3. Deploy application + +✅ Solution 2: Plain numbers (no hash) + +- Task 1: Set up infrastructure +- Task 2: Configure database + +✅ Solution 3: Alternative formats + +- Task (1): Set up infrastructure +- Task [1]: Set up infrastructure +``` + +## When #NUMBER IS Intentional + +Use `#NUMBER` only when you explicitly want to link to that GitHub issue/PR: + +```markdown +✅ Intentional: referencing issue +This implements the behavior described in #42. +Closes #1697. +``` + +## Other GFM Auto-links to Know + +```markdown +@username → links to GitHub user profile (use intentionally for mentions) +abc1234 (SHA) → links to commit (useful for references) +owner/repo#42 → cross-repo issue link +``` + +## Checklist Before Committing Docs + +- [ ] No `#NUMBER` patterns used for enumeration or step numbering +- [ ] Ordered lists use Markdown syntax (`1.` `2.` `3.`) +- [ ] Any `#NUMBER` present is an intentional issue/PR reference +- [ ] Tables are consistently formatted +- [ ] `linter markdown` and `linter cspell` pass diff --git a/.github/skills/dev/pr-reviews/fetch-review-threads/SKILL.md b/.github/skills/dev/pr-reviews/fetch-review-threads/SKILL.md new file mode 100644 index 000000000..012aadb20 --- /dev/null +++ b/.github/skills/dev/pr-reviews/fetch-review-threads/SKILL.md @@ -0,0 +1,91 @@ +--- +name: fetch-review-threads +description: Fetch unresolved GitHub pull request review thread IDs for the torrust-tracker project. Use when asked to find open PR review threads, list unresolved review comments, collect thread IDs before resolving suggestions, or inspect Copilot review feedback. Triggers on "fetch review threads", "list unresolved PR comments", "get review thread IDs", or "find open review suggestions". +metadata: + author: torrust + version: "1.0" +--- + +# Fetching PR Review Threads + +Use this skill before resolving review feedback. Its purpose is to collect the unresolved +review thread IDs and enough context to decide whether each thread should stay open or be closed. + +## Preferred Sources + +Use one of these approaches: + +1. Active pull request tools when they are available in the environment. +2. GitHub CLI GraphQL when you need a terminal-based fallback. + +Prefer the active PR tools first because they provide thread metadata together with file paths, +resolution state, and comments. + +## What to Collect + +For each unresolved thread, capture: + +- thread ID +- file path +- `isResolved` +- `canResolve` +- comment author +- comment body + +Only unresolved threads should be considered for follow-up work. + +## Active PR Tool Workflow + +1. Read the active PR. +2. Inspect the `reviewThreads` array. +3. Filter to threads where `isResolved == false`. +4. Group them by file if you plan to address them in code. + +## GitHub CLI GraphQL Fallback + +Use GitHub CLI if you need to retrieve threads directly from the terminal. + +```bash +gh api graphql \ + -F owner=torrust \ + -F repo=torrust-tracker \ + -F pullNumber=1707 \ + -f query='query($owner: String!, $repo: String!, $pullNumber: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pullNumber) { + reviewThreads(first: 100) { + nodes { + id + isResolved + isOutdated + comments(first: 20) { + nodes { + author { + login + } + body + path + } + } + } + } + } + } + }' +``` + +Then filter for unresolved threads. + +## Practical Guidance + +- Do not guess thread IDs. +- Do not resolve a thread immediately after fetching it. First confirm the fix exists. +- If a thread is outdated but unresolved, still read it before deciding what to do. +- If there are more than 100 threads, paginate instead of assuming the first page is complete. + +## Completion Checklist + +- [ ] Unresolved thread IDs were collected from the current PR state +- [ ] Each thread has enough context for triage +- [ ] Already resolved threads were excluded from action items +- [ ] The result is ready to hand off to a fix or resolution workflow diff --git a/.github/skills/dev/pr-reviews/resolve-review-threads/SKILL.md b/.github/skills/dev/pr-reviews/resolve-review-threads/SKILL.md new file mode 100644 index 000000000..6033a7ccd --- /dev/null +++ b/.github/skills/dev/pr-reviews/resolve-review-threads/SKILL.md @@ -0,0 +1,77 @@ +--- +name: resolve-review-threads +description: Resolve addressed GitHub pull request review threads for the torrust-tracker project. Use when asked to mark PR suggestions as resolved, resolve review comments, close addressed review threads, or clear Copilot review feedback after fixes are pushed. Triggers on "resolve PR threads", "mark suggestions as resolved", "resolve review comments", or "close addressed review threads". +metadata: + author: torrust + version: "1.0" +--- + +# Resolving PR Review Threads + +Use this skill after the requested code or documentation changes are already implemented, +validated, committed, and pushed. + +## Preconditions + +- The feedback has actually been addressed in the branch. +- Validation has been run for the touched scope (`linter all`, tests, or a targeted executable check). +- You have the target PR number and unresolved review thread IDs. + +Do not resolve a thread just because a suggestion exists. Resolve it only when the underlying +concern is fixed or intentionally declined with a clear reason. + +## Workflow + +1. Read the active PR and collect unresolved review threads. +2. Group threads by file and confirm each one is truly addressed. +3. Implement and validate any missing fixes before resolving anything. +4. Resolve the addressed threads. +5. Re-check the PR state if needed. + +## Preferred Resolution Path + +If PR tools are available, first gather thread IDs from the active pull request metadata. + +- Use the active PR tools to identify unresolved `reviewThreads`. +- Resolve only threads where `isResolved == false` and the fix is already on the branch. + +## GitHub CLI GraphQL Command + +Use GitHub CLI GraphQL when you need to resolve a thread directly from the terminal: + +```bash +gh api graphql \ + -F threadId=THREAD_ID \ + -f query='mutation($threadId: ID!) { resolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } } }' +``` + +Successful output should report `isResolved: true`. + +## Batch Pattern + +For multiple threads, resolve them one by one and check each result: + +```bash +for thread_id in \ + THREAD_ID_1 \ + THREAD_ID_2 +do + gh api graphql \ + -F threadId="$thread_id" \ + -f query='mutation($threadId: ID!) { resolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } } }' +done +``` + +## Notes + +- Thread IDs are GraphQL node IDs, not PR numbers or comment IDs. +- This resolves the review thread, not the entire review. +- If a thread should remain open, leave it open and explain why. +- If you do not know the thread IDs yet, query the active PR first instead of guessing. + +## Completion Checklist + +- [ ] All targeted threads were verified against the current branch state +- [ ] Validation passed before resolution +- [ ] Each resolved mutation returned `isResolved: true` +- [ ] Any intentionally unresolved feedback is documented with reasoning diff --git a/.github/skills/dev/rust-code-quality/handle-errors-in-code/SKILL.md b/.github/skills/dev/rust-code-quality/handle-errors-in-code/SKILL.md new file mode 100644 index 000000000..7b326ce60 --- /dev/null +++ b/.github/skills/dev/rust-code-quality/handle-errors-in-code/SKILL.md @@ -0,0 +1,114 @@ +--- +name: handle-errors-in-code +description: Guide for error handling in this Rust project. Covers the four principles (clarity, context, actionability, explicit enums over anyhow), the thiserror pattern for structured errors, including what/where/when/why context, writing actionable help text, and avoiding vague errors. Also covers the located-error package for errors with source location. Use when writing error types, handling Results, adding error variants, or reviewing error messages. Triggers on "error handling", "error type", "Result", "thiserror", "anyhow", "error enum", "error message", "handle error", "add error variant", or "located-error". +metadata: + author: torrust + version: "1.0" +--- + +# Handling Errors in Code + +## Core Principles + +1. **Clarity** — Users immediately understand what went wrong +2. **Context** — Include what/where/when/why +3. **Actionability** — Tell users how to fix it +4. **Explicit enums over `anyhow`** — Prefer structured errors for pattern matching + +## Prefer Explicit Enum Errors + +```rust +// ✅ Correct: explicit, matchable, clear +#[derive(Debug, thiserror::Error)] +pub enum TrackerError { + #[error("Torrent '{info_hash}' not found in whitelist")] + TorrentNotWhitelisted { info_hash: InfoHash }, + + #[error("Peer limit exceeded for torrent '{info_hash}': max {limit}")] + PeerLimitExceeded { info_hash: InfoHash, limit: usize }, +} + +// ❌ Wrong: opaque, hard to match +return Err(anyhow::anyhow!("Something went wrong")); +return Err("Invalid input".into()); +``` + +## Include Actionable Fix Instructions in Display + +When the error is user-facing, add instructions: + +```rust +#[error( + "Configuration file not found at '{path}'.\n\ + Copy the default: cp share/default/config/tracker.toml {path}" +)] +ConfigNotFound { path: PathBuf }, +``` + +## Context Requirements + +Each error should answer: + +- **What**: What operation was being performed? +- **Where**: Which component, file, or resource? +- **When**: Under what conditions? +- **Why**: What caused the failure? + +```rust +// ✅ Good: full context +#[error("UDP socket bind failed for '{addr}': {source}. Is port {port} already in use?")] +SocketBindFailed { addr: SocketAddr, port: u16, source: std::io::Error }, + +// ❌ Bad: no context +return Err("bind failed".into()); +``` + +## The `located-error` Package + +For errors that benefit from source location tracking, use the `located-error` package: + +```toml +[dependencies] +torrust-tracker-located-error = { workspace = true } +``` + +```rust +use torrust_tracker_located_error::Located; + +// Wraps any error with file and line information +let err = Located(my_error).into(); +``` + +## Unwrap and Expect Policy + +| Context | `.unwrap()` | `.expect("msg")` | `?` / `Result` | +| ---------------------- | ----------- | ----------------------------------------- | -------------- | +| Production code | Never | Only when failure is logically impossible | Default | +| Tests and doc examples | Acceptable | Preferred when message adds clarity | — | + +```rust +// ✅ Production: propagate errors with ? +fn load_config(path: &Path) -> Result<Config, ConfigError> { + let content = std::fs::read_to_string(path) + .map_err(|e| ConfigError::FileAccess { path: path.to_path_buf(), source: e })?; + toml::from_str(&content) + .map_err(|e| ConfigError::InvalidToml { path: path.to_path_buf(), source: e }) +} + +// ✅ Tests: unwrap() is fine +#[test] +fn it_should_parse_valid_config() { + let config = Config::parse(VALID_TOML).unwrap(); + assert_eq!(config.http_api.bind_address, "127.0.0.1:1212"); +} +``` + +## Quick Checklist + +- [ ] Error type uses `thiserror::Error` derive +- [ ] Error message includes specific context (names, paths, addresses, values) +- [ ] Error message includes fix instructions where possible +- [ ] Prefer `enum` over `Box<dyn Error>` or `anyhow` in library code +- [ ] No vague messages like "invalid input" or "error occurred" +- [ ] No `.unwrap()` in production code (tests and doc examples are fine) +- [ ] Consider `located-error` for diagnostics-rich errors diff --git a/.github/skills/dev/rust-code-quality/handle-secrets/SKILL.md b/.github/skills/dev/rust-code-quality/handle-secrets/SKILL.md new file mode 100644 index 000000000..b3e6e5d43 --- /dev/null +++ b/.github/skills/dev/rust-code-quality/handle-secrets/SKILL.md @@ -0,0 +1,87 @@ +--- +name: handle-secrets +description: Guide for handling sensitive data (secrets) in this Rust project. NEVER use plain String for API tokens, passwords, or other credentials. Use the secrecy crate's Secret<T> wrapper to prevent accidental exposure through Debug output, logs, and error messages. Call .expose_secret() only when the actual value is needed. Use when working with credentials, API keys, tokens, passwords, or any sensitive configuration. Triggers on "secret", "API token", "password", "credential", "sensitive data", "secrecy", or "expose secret". +metadata: + author: torrust + version: "1.0" +--- + +# Handling Sensitive Data (Secrets) + +## Core Rule + +**NEVER use plain `String` for sensitive data.** Wrap secrets in `secrecy::Secret<String>` +(or similar) to prevent accidental exposure. + +```rust +// ❌ WRONG: secret leaked in Debug output +pub struct ApiConfig { + pub token: String, +} +println!("{config:?}"); // → ApiConfig { token: "secret_abc123" } — LEAKED! +``` + +```rust +// ✅ CORRECT: secret redacted in Debug +use secrecy::Secret; +pub struct ApiConfig { + pub token: Secret<String>, +} +println!("{config:?}"); // → ApiConfig { token: Secret([REDACTED]) } +``` + +## Using the `secrecy` Crate + +Add the dependency: + +```toml +[dependencies] +secrecy = { workspace = true } +``` + +Basic usage: + +```rust +use secrecy::{Secret, ExposeSecret}; + +// Wrap the secret +let token = Secret::new(String::from("my-api-token")); + +// Access the value only when truly needed (e.g., making the actual API call) +let token_str: &str = token.expose_secret(); +``` + +## What to Protect + +Wrap with `Secret<T>` when the value is: + +- API tokens (REST API admin token, external service tokens) +- Passwords (database credentials, service accounts) +- Private keys or certificates + +## Rules for `.expose_secret()` + +- Call **as late as possible** — only at the point where the value is required +- **Never** call in `log!`, `debug!`, `info!`, `warn!`, `error!` macros +- **Never** call in `Display` or `Debug` implementations +- **Never** include in error messages that may be logged or shown to users + +```rust +// ✅ Correct: called at last moment for HTTP header +let response = client + .get(url) + .header("Authorization", format!("Bearer {}", token.expose_secret())) + .send() + .await?; + +// ❌ Wrong: exposed in log +tracing::debug!("Using token: {}", token.expose_secret()); +``` + +## Checklist + +- [ ] No plain `String` fields for tokens, passwords, or private keys +- [ ] `Secret<String>` (or equivalent) used for all sensitive values +- [ ] `.expose_secret()` called only at the last moment +- [ ] No `.expose_secret()` in log statements or error messages +- [ ] No sensitive values in `Display` or `Debug` output diff --git a/.github/skills/dev/testing/write-unit-test/SKILL.md b/.github/skills/dev/testing/write-unit-test/SKILL.md new file mode 100644 index 000000000..5ba1a8381 --- /dev/null +++ b/.github/skills/dev/testing/write-unit-test/SKILL.md @@ -0,0 +1,221 @@ +--- +name: write-unit-test +description: Guide for writing unit tests following project conventions including behavior-driven naming (it*should*\*), AAA pattern, MockClock for deterministic time testing, and parameterized tests with rstest. Use when adding tests for domain entities, value objects, utilities, or tracker logic. Triggers on "write unit test", "add test", "test coverage", "unit testing", or "add unit tests". +metadata: + author: torrust + version: "1.0" +--- + +# Writing Unit Tests + +## Core Principles + +Unit tests in this project are written against the **Test Desiderata** — the 12 properties that +make tests valuable, defined by Kent Beck. Not every property applies equally to every test, but +treat them as the standard to reason about and optimize for. + +| Property | What it means | +| ------------------------- | ----------------------------------------------------------------------------------- | +| **Isolated** | Tests return the same result regardless of run order. No shared mutable state. | +| **Composable** | Different dimensions of variability can be tested separately and results combined. | +| **Deterministic** | Same inputs always produce the same result. No randomness, no wall-clock time. | +| **Fast** | Tests run in milliseconds. Unit tests must never block on I/O or sleep. | +| **Writable** | Writing the test should cost much less than writing the code it covers. | +| **Readable** | A reader can understand what behaviour is being tested and why, without context. | +| **Behavioral** | Tests are sensitive to changes in observable behaviour, not internal structure. | +| **Structure-insensitive** | Refactoring the implementation should not break tests that test the same behaviour. | +| **Automated** | Tests run without human intervention (`cargo test`). | +| **Specific** | When a test fails, the cause is immediately obvious from the failure message. | +| **Predictive** | Passing tests give genuine confidence the code is ready for production. | +| **Inspiring** | Passing the full suite inspires confidence to ship. | + +Some properties support each other (automation makes tests faster). Some trade off against each +other (more predictive tests tend to be slower). Use composability to resolve apparent conflicts. + +Reference: <https://testdesiderata.com/> and Kent Beck's original papers on +[Test Desiderata](https://medium.com/@kentbeck_7670/test-desiderata-94150638a4b3) and +[Programmer Test Principles](https://medium.com/@kentbeck_7670/programmer-test-principles-d01c064d7934). + +### Project-specific conventions + +- **Behavior-driven naming** — test names document what the code does +- **AAA Pattern** — Arrange → Act → Assert (clear structure) +- **Deterministic** — use `MockClock` instead of real time (see Phase 2) +- **Isolated** — no shared mutable state between tests +- **Fast** — unit tests run in milliseconds + +## Phase 1: Basic Unit Test + +### Naming Convention + +**Format**: `it_should_{expected_behavior}_when_{condition}` + +- Always use the `it_should_` prefix +- Never use the `test_` prefix +- Use `when_` or `given_` for conditions +- Be specific and descriptive + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_return_error_when_info_hash_is_invalid() { + // Arrange + let invalid_hash = "not-a-valid-hash"; + + // Act + let result = InfoHash::from_str(invalid_hash); + + // Assert + assert!(result.is_err()); + } + + #[test] + fn it_should_parse_valid_info_hash() { + // Arrange + let valid_hex = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + // Act + let result = InfoHash::from_str(valid_hex); + + // Assert + assert!(result.is_ok()); + } +} +``` + +### Running Tests + +```bash +# Run all tests in a package +cargo test -p bittorrent-tracker-core + +# Run specific test by name +cargo test it_should_return_error_when_info_hash_is_invalid + +# Run tests in a module +cargo test info_hash::tests + +# Run with output +cargo test -- --nocapture +``` + +## Phase 2: Deterministic Time with `clock::Stopped` + +The `clock` workspace package provides `clock::Stopped` for deterministic time testing. +Never call `std::time::SystemTime::now()` or `chrono::Utc::now()` directly in production code +that needs testing. Instead, use the type-level clock abstraction. + +### Use the Type-Level Clock Alias + +Copy the following boilerplate into each crate that needs a clock. The `CurrentClock` alias +automatically selects `Working` in production and `Stopped` in tests: + +```rust +/// Working version, for production. +#[cfg(not(test))] +pub(crate) type CurrentClock = torrust_tracker_clock::clock::Working; + +/// Stopped version, for testing. +#[cfg(test)] +pub(crate) type CurrentClock = torrust_tracker_clock::clock::Stopped; +``` + +In production code, obtain the current time via the `Time` trait: + +```rust +use torrust_tracker_clock::clock::Time as _; + +pub fn is_peer_expired(last_seen: std::time::Duration, ttl: u32) -> bool { + let now = CurrentClock::now(); // returns DurationSinceUnixEpoch (= std::time::Duration) + now.saturating_sub(last_seen) > std::time::Duration::from_secs(u64::from(ttl)) +} +``` + +### Control Time in Tests + +Use `clock::Stopped::local_set` to pin the clock to a specific instant. The stopped clock is +thread-local, so tests are isolated from each other by default. + +```rust +#[cfg(test)] +mod tests { + use std::time::Duration; + + use torrust_tracker_clock::clock::{stopped::Stopped as _, Time as _}; + use torrust_tracker_clock::clock::Stopped; + + use super::*; + + #[test] + fn it_should_mark_peer_as_expired_when_ttl_has_elapsed() { + // Arrange — pin the clock to a known instant + let fixed_time = Duration::from_secs(1_700_000_100); + Stopped::local_set(&fixed_time); + + let last_seen = Duration::from_secs(1_700_000_000); + let ttl = 60u32; + + // Act + let expired = is_peer_expired(last_seen, ttl); + + // Assert + assert!(expired); + + // Clean up — reset to zero so other tests start from a clean state + Stopped::local_reset(); + } +} +``` + +> **Key points** +> +> - `Stopped::now()` defaults to `Duration::ZERO` at the start of each test thread. +> - `Stopped::local_set(&duration)` sets the current time for the calling thread only. +> - `Stopped::local_reset()` resets back to `Duration::ZERO`. +> - `Stopped::local_add(&duration)` advances the clock by the given amount. +> - Import the `Stopped` trait (`use …::stopped::Stopped as _`) to bring its methods into scope. + +## Phase 3: Parameterized Tests with rstest + +Use `rstest` for multiple input/output combinations to avoid repetition. + +```toml +[dev-dependencies] +rstest = { workspace = true } +``` + +```rust +use rstest::rstest; + +#[rstest] +#[case("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true)] +#[case("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", true)] +#[case("not-a-hash", false)] +#[case("", false)] +fn it_should_validate_info_hash(#[case] input: &str, #[case] is_valid: bool) { + let result = InfoHash::from_str(input); + assert_eq!(result.is_ok(), is_valid, "input: {input}"); +} +``` + +## Phase 4: Test Helpers + +The `test-helpers` workspace package provides shared test utilities. + +```toml +[dev-dependencies] +torrust-tracker-test-helpers = { workspace = true } +``` + +Check the package for available mock servers, fixture generators, and utility types. + +## Quick Checklist + +- [ ] Test name uses `it_should_` prefix +- [ ] Test follows AAA pattern with comments (`// Arrange`, `// Act`, `// Assert`) +- [ ] No `std::time::SystemTime::now()` in production code — use the `CurrentClock` type alias instead +- [ ] No shared mutable state between tests +- [ ] `cargo test -p <package>` passes diff --git a/.github/workflows/container.yaml b/.github/workflows/container.yaml index 7416df71e..7e8ffa442 100644 --- a/.github/workflows/container.yaml +++ b/.github/workflows/container.yaml @@ -26,11 +26,11 @@ jobs: steps: - id: setup name: Setup Toolchain - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - id: build name: Build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: file: ./Containerfile push: false @@ -80,9 +80,15 @@ jobs: echo "continue=true" >> $GITHUB_OUTPUT echo "On \`develop\` Branch, Type: \`development\`" - elif [[ $(echo "${{ github.ref }}" | grep -P '^(refs\/heads\/releases\/)(v)(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$') ]]; then + elif [[ "${{ github.ref }}" =~ ^refs/heads/releases/ ]]; then + semver_regex='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$' + version=$(echo "${{ github.ref }}" | sed -n -E 's#^refs/heads/releases/##p') + + if [[ ! "$version" =~ $semver_regex ]]; then + echo "Not a valid release branch semver. Will Not Continue" + exit 0 + fi - version=$(echo "${{ github.ref }}" | sed -n -E 's/^(refs\/heads\/releases\/)//p') echo "version=$version" >> $GITHUB_OUTPUT echo "type=release" >> $GITHUB_OUTPUT echo "continue=true" >> $GITHUB_OUTPUT @@ -108,7 +114,7 @@ jobs: steps: - id: meta name: Docker Meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: | "${{ secrets.DOCKER_HUB_USERNAME }}/${{secrets.DOCKER_HUB_REPOSITORY_NAME }}" @@ -117,17 +123,17 @@ jobs: - id: login name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - id: setup name: Setup Toolchain - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build and push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: file: ./Containerfile push: true @@ -146,7 +152,7 @@ jobs: steps: - id: meta name: Docker Meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: | "${{ secrets.DOCKER_HUB_USERNAME }}/${{secrets.DOCKER_HUB_REPOSITORY_NAME }}" @@ -158,17 +164,17 @@ jobs: - id: login name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - id: setup name: Setup Toolchain - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build and push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: file: ./Containerfile push: true diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 000000000..4b9e90407 --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,53 @@ +name: "Copilot Setup Steps" + +# Automatically run the setup steps when they are changed to allow for easy +# validation, and allow manual testing through the repository's "Actions" tab. +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + - contrib/dev-tools/git/install-git-hooks.sh + - contrib/dev-tools/git/hooks/pre-commit.sh + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + - contrib/dev-tools/git/install-git-hooks.sh + - contrib/dev-tools/git/hooks/pre-commit.sh + +jobs: + # The job MUST be called `copilot-setup-steps` or it will not be picked up + # by Copilot. + copilot-setup-steps: + runs-on: ubuntu-latest + timeout-minutes: 30 + + # Set the permissions to the lowest permissions possible needed for your + # steps. Copilot will be given its own token for its operations. + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Enable Rust cache + uses: Swatinem/rust-cache@v2 + + - name: Build workspace + run: cargo build --workspace + + - name: Install linter + run: cargo install --locked --git https://github.com/torrust/torrust-linting --bin linter + + - name: Install cargo-machete + run: cargo install cargo-machete + + - name: Install Git pre-commit hooks + run: ./contrib/dev-tools/git/install-git-hooks.sh + + - name: Smoke-check — run all linters + run: linter all diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 2c8d63d6c..ada96f77f 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -44,14 +44,14 @@ jobs: - id: coverage name: Generate Coverage Report run: | - cargo clean + cargo clean cargo llvm-cov --all-features --workspace --codecov --output-path ./codecov.json - id: upload name: Upload Coverage Report - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: verbose: true token: ${{ secrets.CODECOV_TOKEN }} files: ${{ github.workspace }}/codecov.json - fail_ci_if_error: true \ No newline at end of file + fail_ci_if_error: true diff --git a/.github/workflows/generate_coverage_pr.yaml b/.github/workflows/generate_coverage_pr.yaml index f762207cf..e07a5a755 100644 --- a/.github/workflows/generate_coverage_pr.yaml +++ b/.github/workflows/generate_coverage_pr.yaml @@ -44,7 +44,7 @@ jobs: - id: coverage name: Generate Coverage Report run: | - cargo clean + cargo clean cargo llvm-cov --all-features --workspace --codecov --output-path ./codecov.json - name: Store PR number and commit SHA @@ -59,13 +59,13 @@ jobs: # Triggered sub-workflow is not able to detect the original commit/PR which is available # in this workflow. - name: Store PR number - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: pr_number path: pr_number.txt - name: Store commit SHA - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: commit_sha path: commit_sha.txt @@ -74,7 +74,7 @@ jobs: # is executed by a different workflow `upload_coverage.yml`. The reason for this # split is because `on.pull_request` workflows don't have access to secrets. - name: Store coverage report in artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: codecov_report path: ./codecov.json diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index c9328d890..0d5753e5d 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -33,9 +33,10 @@ jobs: run: cargo fmt --check check: - name: Static Analysis + name: Linting runs-on: ubuntu-latest needs: format + timeout-minutes: 15 strategy: matrix: @@ -51,39 +52,25 @@ jobs: uses: dtolnay/rust-toolchain@stable with: toolchain: ${{ matrix.toolchain }} - components: clippy + components: clippy, rustfmt + + - id: node + name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "20" - id: cache name: Enable Workflow Cache uses: Swatinem/rust-cache@v2 - id: tools - name: Install Tools - uses: taiki-e/install-action@v2 - with: - tool: cargo-machete - - - id: check - name: Run Build Checks - run: cargo check --tests --benches --examples --workspace --all-targets --all-features + name: Install Internal Linter + run: cargo install --locked --git https://github.com/torrust/torrust-linting --bin linter - id: lint - name: Run Lint Checks - run: cargo clippy --tests --benches --examples --workspace --all-targets --all-features - - - id: docs - name: Lint Documentation - env: - RUSTDOCFLAGS: "-D warnings" - run: cargo doc --no-deps --bins --examples --workspace --all-features - - - id: clean - name: Clean Build Directory - run: cargo clean - - - id: deps - name: Check Unused Dependencies - run: cargo machete + name: Run All Linters + run: linter all build: name: Build on ${{ matrix.os }} (${{ matrix.toolchain }}) @@ -146,35 +133,68 @@ jobs: name: Run Unit Tests run: cargo test --tests --benches --examples --workspace --all-targets --all-features + database-compatibility: + name: Database Compatibility (${{ matrix.mysql-version }}) + runs-on: ubuntu-latest + needs: unit + + strategy: + matrix: + mysql-version: ["8.0", "8.4"] + + steps: + - id: checkout + name: Checkout Repository + uses: actions/checkout@v6 + + - id: setup + name: Setup Toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - id: cache + name: Enable Job Cache + uses: Swatinem/rust-cache@v2 + - id: database - name: Run MySQL Database Tests - run: TORRUST_TRACKER_CORE_RUN_MYSQL_DRIVER_TEST=true cargo test --package bittorrent-tracker-core + name: Run Database Compatibility Test + env: + TORRUST_TRACKER_CORE_RUN_MYSQL_DRIVER_TEST: "true" + TORRUST_TRACKER_CORE_MYSQL_DRIVER_IMAGE_TAG: ${{ matrix.mysql-version }} + run: cargo test -p bittorrent-tracker-core --features db-compatibility-tests run_mysql_driver_tests -- --nocapture e2e: name: E2E runs-on: ubuntu-latest - needs: unit + needs: database-compatibility + timeout-minutes: 45 strategy: matrix: toolchain: [nightly, stable] steps: - - id: setup + - id: setup-e2e-toolchain name: Setup Toolchain uses: dtolnay/rust-toolchain@stable with: toolchain: ${{ matrix.toolchain }} components: llvm-tools-preview - - id: cache + - id: enable-e2e-job-cache name: Enable Job Cache uses: Swatinem/rust-cache@v2 - - id: checkout + - id: checkout-repository name: Checkout Repository uses: actions/checkout@v6 - - id: test + - id: run-tracker-e2e-tests name: Run E2E Tests run: cargo run --bin e2e_tests_runner -- --config-toml-path "./share/default/config/tracker.e2e.container.sqlite3.toml" + + - id: run-qbittorrent-e2e-test + if: matrix.toolchain == 'stable' + name: Run qBittorrent E2E Test + run: cargo run --bin qbittorrent_e2e_runner -- --compose-file ./compose.qbittorrent-e2e.yaml --timeout-seconds 600 diff --git a/.github/workflows/upload_coverage_pr.yaml b/.github/workflows/upload_coverage_pr.yaml index 8b0006a6d..442afe31b 100644 --- a/.github/workflows/upload_coverage_pr.yaml +++ b/.github/workflows/upload_coverage_pr.yaml @@ -1,7 +1,7 @@ name: Upload Coverage Report (PR) on: - # This workflow is triggered after every successfull execution + # This workflow is triggered after every successful execution # of `Generate Coverage Report` workflow. workflow_run: workflows: ["Generate Coverage Report (PR)"] @@ -22,7 +22,7 @@ jobs: steps: - name: "Download existing coverage report" id: prepare_report - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | var fs = require('fs'); @@ -102,7 +102,7 @@ jobs: path: repo_root - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: verbose: true token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index fd83ee918..e6d0a9bfc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ *.code-workspace **/*.rs.bk /.coverage/ +/.benchmarks/ /.idea/ /.vscode/launch.json /data.db @@ -17,4 +18,5 @@ codecov.json integration_tests_sqlite3.db lcov.info perf.data* +repomix-output.xml rustc-ice-*.txt diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 000000000..19ec47c2e --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,18 @@ +{ + "default": true, + "MD013": false, + "MD031": true, + "MD032": true, + "MD040": true, + "MD022": true, + "MD009": true, + "MD007": { + "indent": 2 + }, + "MD026": false, + "MD041": false, + "MD034": false, + "MD024": false, + "MD033": false, + "MD060": false +} diff --git a/.taplo.toml b/.taplo.toml new file mode 100644 index 000000000..0168711e8 --- /dev/null +++ b/.taplo.toml @@ -0,0 +1,27 @@ +# Taplo configuration file for TOML formatting +# Used by the "Even Better TOML" VS Code extension + +# Exclude generated and runtime folders from linting +exclude = [ ".coverage/**", "storage/**", "target/**" ] + +[formatting] +# Preserve blank lines that exist +allowed_blank_lines = 1 +# Don't reorder keys to maintain structure +reorder_keys = false +# Array formatting +array_auto_collapse = false +array_auto_expand = false +array_trailing_comma = true +# Inline table formatting +compact_arrays = false +compact_inline_tables = false +inline_table_expand = false +# Alignment +align_comments = true +align_entries = false +# Indentation +indent_entries = false +indent_tables = false +# Other +trailing_newline = true diff --git a/.yamllint-ci.yml b/.yamllint-ci.yml new file mode 100644 index 000000000..9380b592a --- /dev/null +++ b/.yamllint-ci.yml @@ -0,0 +1,16 @@ +extends: default + +rules: + line-length: + max: 200 # More reasonable for infrastructure code + comments: + min-spaces-from-content: 1 # Allow single space before comments + document-start: disable # Most project YAML files don't require --- + truthy: + allowed-values: ["true", "false", "yes", "no", "on", "off"] # Allow common GitHub Actions values + +# Ignore generated/runtime directories +ignore: | + target/** + storage/** + .coverage/** diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..cda2ae240 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,402 @@ +# Torrust Tracker — AI Assistant Instructions + +**Repository**: [torrust/torrust-tracker](https://github.com/torrust/torrust-tracker) + +## 📋 Project Overview + +**Torrust Tracker** is a high-quality, production-grade BitTorrent tracker written in Rust. It +matchmakes peers and collects statistics, supporting the UDP, HTTP, and TLS socket types with +native IPv4/IPv6 support, private/whitelisted mode, and a management REST API. + +- **Language**: Rust (edition 2021, MSRV 1.72) +- **License**: AGPL-3.0-only +- **Version**: 3.0.0-develop +- **Web framework**: [Axum](https://github.com/tokio-rs/axum) +- **Async runtime**: Tokio +- **Protocols**: BitTorrent UDP (BEP 15), HTTP (BEP 3/23), REST management API +- **Databases**: SQLite3, MySQL +- **Workspace type**: Cargo workspace (multi-crate monorepo) + +## 🏗️ Tech Stack + +- **Languages**: Rust, YAML, TOML, Markdown, Shell scripts +- **Web framework**: Axum (HTTP server + REST API) +- **Async runtime**: Tokio (multi-thread) +- **Testing**: testcontainers (E2E) +- **Databases**: SQLite3, MySQL +- **Containerization**: Docker / Podman (`Containerfile`) +- **CI**: GitHub Actions +- **Linting tools**: markdownlint, yamllint, taplo, cspell, shellcheck, clippy, rustfmt (unified + under the `linter` binary from [torrust/torrust-linting](https://github.com/torrust/torrust-linting)) + +## 📁 Key Directories + +- `src/` — Main binary and library entry points (`main.rs`, `lib.rs`, `app.rs`, `container.rs`) +- `src/bin/` — Additional binary targets (`e2e_tests_runner`, `http_health_check`, `profiling`) +- `src/bootstrap/` — Application bootstrap logic +- `src/console/` — Console entry points +- `packages/` — Cargo workspace packages (all domain logic lives here; see package catalog below) +- `console/` — Console tools (e.g., `tracker-client`) +- `contrib/` — Community-contributed utilities (`bencode`) and developer tooling +- `contrib/dev-tools/` — Developer tools: git hooks (`pre-commit.sh`, `pre-push.sh`, `install-git-hooks.sh`), + container scripts, and init scripts +- `tests/` — Integration tests (`integration.rs`, `servers/`) +- `docs/` — Project documentation, ADRs, issue specs, and benchmarking guides +- `docs/adrs/` — Architectural Decision Records +- `docs/issues/` — Issue specs / implementation plans +- `share/default/` — Default configuration files and fixtures +- `storage/` — Runtime data (git-ignored); databases, logs, config +- `.github/workflows/` — CI/CD workflows (testing, coverage, container, deployment) +- `.github/skills/` — Agent Skills for specialized workflows and task-specific guidance +- `.github/agents/` — Custom Copilot agents and their repository-specific definitions + +## 📦 Package Catalog + +All packages live under `packages/`. The workspace version is `3.0.0-develop`. + +| Package | Prefix / Layer | Description | +| --------------------------------- | -------------- | ------------------------------------------------ | +| `axum-server` | `axum-*` | Base Axum HTTP server infrastructure | +| `axum-http-tracker-server` | `axum-*` | BitTorrent HTTP tracker server (BEP 3/23) | +| `axum-rest-tracker-api-server` | `axum-*` | Management REST API server | +| `axum-health-check-api-server` | `axum-*` | Health monitoring endpoint | +| `http-tracker-core` | `*-core` | HTTP-specific tracker domain logic | +| `udp-tracker-core` | `*-core` | UDP-specific tracker domain logic | +| `tracker-core` | `*-core` | Central tracker peer-management logic | +| `http-protocol` | `*-protocol` | HTTP tracker protocol (BEP 3/23) parsing | +| `udp-protocol` | `*-protocol` | UDP tracker protocol (BEP 15) framing/parsing | +| `swarm-coordination-registry` | domain | Torrent/peer coordination registry | +| `configuration` | domain | Config file parsing, environment variables | +| `primitives` | domain | Core domain types (InfoHash, PeerId, …) | +| `clock` | utilities | Mockable time source for deterministic testing | +| `located-error` | utilities | Diagnostic errors with source locations | +| `test-helpers` | utilities | Mock servers, test data generation | +| `server-lib` | shared | Shared server library utilities | +| `tracker-client` | client tools | CLI tracker interaction/testing client | +| `rest-tracker-api-client` | client tools | REST API client library | +| `rest-tracker-api-core` | client tools | REST API core logic | +| `udp-tracker-server` | server | UDP tracker server implementation | +| `torrent-repository` | domain | Torrent metadata storage and InfoHash management | +| `events` | domain | Domain event definitions | +| `metrics` | domain | Prometheus metrics integration | +| `torrent-repository-benchmarking` | benchmarking | Torrent storage benchmarks | + +**Console tools** (under `console/`): + +| Tool | Description | +| ---------------- | ------------------------------------ | +| `tracker-client` | Client for interacting with trackers | + +**Community contributions** (under `contrib/`): + +| Crate | Description | +| --------- | ------------------------------- | +| `bencode` | Bencode encode/decode utilities | + +## 🏷️ Package Naming Conventions + +| Prefix | Responsibility | Dependencies | +| ------------ | -------------------------------------- | ------------------------ | +| `axum-*` | HTTP server components using Axum | Axum framework | +| `*-server` | Server implementations | Corresponding `*-core` | +| `*-core` | Domain logic and business rules | Protocol implementations | +| `*-protocol` | BitTorrent protocol implementations | BEP specifications | +| `udp-*` | UDP protocol-specific implementations | Tracker core | +| `http-*` | HTTP protocol-specific implementations | Tracker core | + +## 📄 Key Configuration Files + +| File | Used by | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `.markdownlint.json` | markdownlint | +| `.yamllint-ci.yml` | yamllint | +| `.taplo.toml` | taplo (TOML formatting) | +| `cspell.json` | cspell (spell checker) configuration | +| `project-words.txt` | cspell project-specific dictionary | +| `rustfmt.toml` | rustfmt (`group_imports = "StdExternalCrate"`, `max_width = 130`) | +| `.cargo/config.toml` | Cargo aliases (`cov`, `cov-lcov`, `cov-html`, `time`) and global `rustflags` (`-D warnings`, `-D unused`, `-D rust-2018-idioms`, …) | +| `Cargo.toml` | Cargo workspace root | +| `compose.yaml` | Docker Compose for local dev and demo | +| `Containerfile` | Container image definition | +| `codecov.yaml` | Code coverage configuration | + +## 🧪 Build & Test + +### Setup + +```sh +rustup show # Check active toolchain +rustup update # Update toolchain +rustup toolchain install nightly # Optional: needed for manual cargo +nightly commands and the repo pre-push checks (fmt/check/doc) +``` + +### Build + +```sh +cargo build # Build all workspace crates +cargo build --release # Release build +cargo build --package <pkg> # Build a specific package +``` + +### Test + +```sh +cargo test --doc --workspace # Documentation tests +cargo test --tests --benches --examples --workspace \ + --all-targets --all-features # All tests +cargo test -p <package-name> # Single package + +# MySQL-specific tests (requires a running MySQL instance) +TORRUST_TRACKER_CORE_RUN_MYSQL_DRIVER_TEST=true \ + cargo test --package bittorrent-tracker-core + +# Integration tests (root) +cargo test --test integration # tests/integration.rs +``` + +### E2E Tests + +```sh +cargo run --bin e2e_tests_runner -- \ + --config-toml-path "./share/default/config/tracker.e2e.container.sqlite3.toml" +``` + +### Documentation + +```sh +cargo +nightly doc --no-deps --bins --examples --workspace --all-features +``` + +### Benchmarks + +```sh +cargo bench --package torrent-repository-benchmarking +``` + +See [docs/benchmarking.md](docs/benchmarking.md) and [docs/profiling.md](docs/profiling.md). + +## 🔍 Lint Commands + +The project uses the `linter` binary from +[torrust/torrust-linting](https://github.com/torrust/torrust-linting). + +Agent reminder: + +- When asked to lint, prefer loading the `run-linters` skill at + `.github/skills/dev/git-workflow/run-linters/SKILL.md`. +- Start with `linter all`. +- To lint only markdown files, run `linter markdown`. +- To isolate a failing tool, run the individual linters directly: + `linter markdown`, `linter yaml`, `linter toml`, `linter cspell`, `linter clippy`, + `linter rustfmt`, `linter shellcheck`. +- If `linter all` fails or appears inconclusive, use the individual commands above before editing + files so the failing linter is explicit. +- Treat `linter all` passing with exit code `0` as the required pre-commit gate. + +```sh +# Install the linter binary +cargo install --locked --git https://github.com/torrust/torrust-linting --bin linter + +# Run all linters (MANDATORY before every commit and PR) +linter all + +# Run individual linters +linter markdown # markdownlint +linter yaml # yamllint +linter toml # taplo +linter cspell # spell checker +linter clippy # Rust linter +linter rustfmt # Rust formatter check +linter shellcheck # shell scripts +``` + +**`linter all` must exit with code `0` before every commit. PRs that fail CI linting are +rejected without review.** + +## 🔗 Dependencies Check + +```sh +cargo machete # Check for unused dependencies (mandatory before commits) +``` + +Install via: `cargo install cargo-machete` + +## 🎨 Code Style + +- **rustfmt**: Format with `cargo fmt` before committing. Config: `rustfmt.toml` + (`group_imports = "StdExternalCrate"`, `imports_granularity = "Module"`, `max_width = 130`). +- **Compile flags**: `.cargo/config.toml` enables strict global `rustflags` (`-D warnings`, + `-D unused`, `-D rust-2018-idioms`, `-D future-incompatible`, and others). All code must + compile cleanly with these flags — no suppressions unless absolutely necessary. +- **clippy**: No warnings allowed (`cargo clippy -- -D warnings`). +- **Imports**: All imports at the top of the file, grouped (std → external crates → internal + crate). Prefer short imported names over fully-qualified paths + (e.g., `Arc<MyType>` not `std::sync::Arc<crate::my::MyType>`). Use full paths only to + disambiguate naming conflicts. +- **TOML**: Must pass `taplo fmt --check **/*.toml`. Auto-fix with `taplo fmt **/*.toml`. +- **Markdown**: Must pass markdownlint. +- **YAML**: Must pass `yamllint -c .yamllint-ci.yml`. +- **Spell checking**: Add new technical terms to `project-words.txt` (one word per line, + alphabetical order). + +## 🤝 Collaboration Principles + +These rules apply repository-wide to every assistant, including custom agents. + +When acting as an assistant in this repository: + +- Do not flatter the user or agree with weak ideas by default. +- Push back when a request, diff, or proposed commit looks wrong. +- Flag unclear but important points before they become problems. +- Ask a clarifying question instead of making a random choice when the decision matters. +- Call out likely misses: naming inconsistencies, accidental generated files, + staged-versus-unstaged mismatches, missing docs updates, or suspicious commit scope. + +When raising a likely mistake or blocker, say so clearly and early instead of burying it after +routine status updates. + +## 🔧 Essential Rules + +1. **Linting gate**: `linter all` must exit `0` before every commit. No exceptions. +2. **GPG commit signing**: All commits **must** be signed with GPG (`git commit -S`). +3. **Never commit `storage/` or `target/`**: These directories contain runtime data and build + artifacts. They are git-ignored; never force-add them. +4. **Unused dependencies**: Run `cargo machete` before committing. Remove any unused + dependencies immediately. +5. **Rust imports**: All imports at the top of the file, grouped (std → external crates → + internal crate). Prefer short imported names over fully-qualified paths. +6. **Continuous self-review**: Review your own work against project quality standards. Apply + self-review at three levels: + - **Mandatory** — before opening a pull request + - **Strongly recommended** — before each commit + - **Recommended** — after completing each small, independent, deployable change +7. **Security**: Do not report security vulnerabilities through public GitHub issues. Send an + email to `info@nautilus-cyberneering.de` instead. See [SECURITY.md](SECURITY.md). + +## 🌿 Git Workflow + +**Branch naming**: + +```text +<issue-number>-<short-description> # e.g. 1697-ai-agent-configuration (preferred) +feat/<short-description> # for features without a tracked issue +fix/<short-description> # for bug fixes +chore/<short-description> # for maintenance tasks +``` + +**Commit messages** follow [Conventional Commits](https://www.conventionalcommits.org/): + +```text +feat(<scope>): add X +fix(<scope>): resolve Y +chore(<scope>): update Z +docs(<scope>): document W +refactor(<scope>): restructure V +ci(<scope>): adjust pipeline U +test(<scope>): add tests for T +``` + +Scope should reflect the affected package or area (e.g., `tracker-core`, `udp-protocol`, `ci`, `docs`). + +**Branch strategy**: + +- Feature branches are cut from `develop` +- PRs target `develop` +- `develop` → `staging/main` → `main` (release pipeline) +- PRs must pass all CI status checks before merge + +See [docs/release_process.md](docs/release_process.md) for the full release workflow. + +## 🧭 Development Principles + +For detailed information see [`docs/`](docs/). + +**Core Principles:** + +- **Observability**: If it happens, we can see it — even after it happens (deep traceability) +- **Testability**: Every component must be testable in isolation and as part of the whole +- **Modularity**: Clear package boundaries; servers contain only network I/O logic +- **Extensibility**: Core logic is framework-agnostic for easy protocol additions + +**Code Quality Standards** — both production and test code must be: + +- **Clean**: Well-structured with clear naming and minimal complexity +- **Maintainable**: Easy to modify and extend without breaking existing functionality +- **Readable**: Clear intent that can be understood by other developers +- **Testable**: Designed to support comprehensive testing at all levels + +**Beck's Four Rules of Simple Design** (in priority order): + +1. **Passes the tests**: The code must work as intended — testing is a first-class activity +2. **Reveals intention**: Code should be easy to understand, expressing purpose clearly +3. **No duplication**: Apply DRY — eliminating duplication drives out good designs +4. **Fewest elements**: Remove anything that doesn't serve the prior three rules + +Reference: [Beck Design Rules](https://martinfowler.com/bliki/BeckDesignRules.html) + +## 🐳 Container / Docker + +```sh +# Run the latest image +docker run -it torrust/tracker:latest +# or with Podman +podman run -it docker.io/torrust/tracker:latest + +# Build and run via Docker Compose +docker compose up -d # Start all services (detached) +docker compose logs -f tracker # Follow tracker logs +docker compose down # Stop and remove containers +``` + +**Volume mappings** (local `storage/` → container paths): + +```text +./storage/tracker/lib → /var/lib/torrust/tracker +./storage/tracker/log → /var/log/torrust/tracker +./storage/tracker/etc → /etc/torrust/tracker +``` + +**Ports**: UDP tracker: `6969`, HTTP tracker: `7070`, REST API: `1212` + +See [docs/containers.md](docs/containers.md) for detailed container documentation. + +## 🎯 Auto-Invoke Skills + +Agent Skills live under [`.github/skills/`](.github/skills/). Each skill is a `SKILL.md` file +with YAML frontmatter and Markdown instructions covering a repeatable workflow. + +> Skills supplement (not replace) the rules in this file. Rules apply always; skills activate +> when their workflows are needed. + +**For VS Code**: Enable `chat.useAgentSkills` in settings to activate skill discovery. + +**Learn more**: See [Agent Skills Specification (agentskills.io)](https://agentskills.io/specification). + +## 📚 Documentation + +- [Documentation Index](docs/index.md) +- [Package Architecture](docs/packages.md) +- [Benchmarking](docs/benchmarking.md) +- [Profiling](docs/profiling.md) +- [Containers](docs/containers.md) +- [Release Process](docs/release_process.md) +- [ADRs](docs/adrs/README.md) +- [Issues / Implementation Plans](docs/issues/) +- [API docs (docs.rs)](https://docs.rs/torrust-tracker/) +- [Report a security vulnerability](SECURITY.md) + +### Quick Navigation + +| Task | Start Here | +| ------------------------------------ | ---------------------------------------------------- | +| Understand the architecture | [`docs/packages.md`](docs/packages.md) | +| Run the tracker in a container | [`docs/containers.md`](docs/containers.md) | +| Read all docs | [`docs/index.md`](docs/index.md) | +| Understand an architectural decision | [`docs/adrs/README.md`](docs/adrs/README.md) | +| Read or write an issue spec | [`docs/issues/`](docs/issues/) | +| Run benchmarks | [`docs/benchmarking.md`](docs/benchmarking.md) | +| Run profiling | [`docs/profiling.md`](docs/profiling.md) | +| Understand the release process | [`docs/release_process.md`](docs/release_process.md) | +| Report a security vulnerability | [`SECURITY.md`](SECURITY.md) | +| Agent skills reference | [`.github/skills/`](.github/skills/) | +| Custom agents reference | [`.github/agents/`](.github/agents/) | diff --git a/Cargo.lock b/Cargo.lock index 146da3a18..c6ed71ced 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,17 +17,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "ahash" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "version_check", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -84,9 +73,9 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -99,15 +88,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -134,9 +123,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "approx" @@ -175,24 +164,18 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.8.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" dependencies = [ "rustversion", ] -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - [[package]] name = "astral-tokio-tar" -version = "0.5.6" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec179a06c1769b1e42e1e2cbe74c7dcdb3d6383c838454d063eaac5bbb7ebbe5" +checksum = "3c23f3af104b40a3430ccb90ed5f7bd877a8dc5c26fc92fde51a22b40890dcf9" dependencies = [ "filetime", "futures-core", @@ -239,9 +222,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.37" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d10e4f991a553474232bc0a31799f6d24b034a84c0971d80d2e2f78b2e576e40" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" dependencies = [ "compression-codecs", "compression-core", @@ -251,9 +234,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.13.3" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", @@ -354,7 +337,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -371,7 +354,16 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", ] [[package]] @@ -397,9 +389,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.15.4" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" dependencies = [ "aws-lc-sys", "zeroize", @@ -407,9 +399,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.37.0" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" dependencies = [ "cc", "cmake", @@ -419,9 +411,9 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "axum-macros", @@ -483,9 +475,9 @@ dependencies = [ [[package]] name = "axum-extra" -version = "0.12.5" +version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76" +checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970" dependencies = [ "axum", "axum-core", @@ -508,13 +500,13 @@ dependencies = [ [[package]] name = "axum-macros" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -576,17 +568,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "bigdecimal" -version = "0.4.10" +name = "base64ct" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" -dependencies = [ - "autocfg", - "libm", - "num-bigint", - "num-integer", - "num-traits", -] +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "binascii" @@ -594,24 +579,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "bitflags", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "proc-macro2", - "quote", - "regex", - "rustc-hash", - "shlex", - "syn 2.0.114", -] - [[package]] name = "bit-vec" version = "0.4.4" @@ -620,9 +587,12 @@ checksum = "02b4ff8b16e6076c3e14220b39fbc1fabb6737522281a388998046859400895f" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] [[package]] name = "bittorrent-http-tracker-core" @@ -712,18 +682,19 @@ dependencies = [ name = "bittorrent-tracker-core" version = "3.0.0-develop" dependencies = [ + "anyhow", "aquatic_udp_protocol", + "async-trait", "bittorrent-primitives", "chrono", + "clap", "derive_more", "local-ip-address", "mockall", - "r2d2", - "r2d2_mysql", - "r2d2_sqlite", - "rand 0.9.2", + "rand 0.10.1", "serde", "serde_json", + "sqlx", "testcontainers", "thiserror 2.0.18", "tokio", @@ -756,7 +727,7 @@ dependencies = [ "futures", "lazy_static", "mockall", - "rand 0.9.2", + "rand 0.10.1", "serde", "thiserror 2.0.18", "tokio", @@ -782,24 +753,21 @@ dependencies = [ ] [[package]] -name = "bitvec" -version = "1.0.1" +name = "block-buffer" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "funty", - "radium", - "tap", - "wyz", + "generic-array", ] [[package]] name = "block-buffer" -version = "0.10.4" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] @@ -826,9 +794,9 @@ dependencies = [ [[package]] name = "blowfish" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" dependencies = [ "byteorder", "cipher", @@ -836,9 +804,9 @@ dependencies = [ [[package]] name = "bollard" -version = "0.19.4" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87a52479c9237eb04047ddb94788c41ca0d26eaff8b697ecfbb4c32f7fdc3b1b" +checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" dependencies = [ "async-stream", "base64 0.22.1", @@ -846,7 +814,6 @@ dependencies = [ "bollard-buildkit-proto", "bollard-stubs", "bytes", - "chrono", "futures-core", "futures-util", "hex", @@ -861,17 +828,16 @@ dependencies = [ "log", "num", "pin-project-lite", - "rand 0.9.2", + "rand 0.9.4", "rustls", "rustls-native-certs", - "rustls-pemfile", "rustls-pki-types", "serde", "serde_derive", "serde_json", - "serde_repr", "serde_urlencoded", "thiserror 2.0.18", + "time", "tokio", "tokio-stream", "tokio-util", @@ -896,42 +862,18 @@ dependencies = [ [[package]] name = "bollard-stubs" -version = "1.49.1-rc.28.4.0" +version = "1.52.1-rc.29.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5731fe885755e92beff1950774068e0cae67ea6ec7587381536fca84f1779623" +checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" dependencies = [ "base64 0.22.1", "bollard-buildkit-proto", "bytes", - "chrono", "prost", "serde", "serde_json", "serde_repr", - "serde_with", -] - -[[package]] -name = "borsh" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" -dependencies = [ - "borsh-derive", - "cfg_aliases", -] - -[[package]] -name = "borsh-derive" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" -dependencies = [ - "once_cell", - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.114", + "time", ] [[package]] @@ -955,54 +897,17 @@ dependencies = [ "alloc-stdlib", ] -[[package]] -name = "btoi" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd6407f73a9b8b6162d8a2ef999fe6afd7cc15902ebf42c5cd296addf17e0ad" -dependencies = [ - "num-traits", -] - -[[package]] -name = "bufstream" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40e38929add23cdf8a366df9b0e088953150724bcbe5fc330b0d8eb3b328eec8" - [[package]] name = "bumpalo" -version = "3.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" - -[[package]] -name = "bytecheck" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" -dependencies = [ - "bytecheck_derive", - "ptr_meta", - "simdutf8", -] - -[[package]] -name = "bytecheck_derive" -version = "0.6.12" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytemuck" -version = "1.24.0" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "byteorder" @@ -1012,9 +917,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "camino" @@ -1042,9 +947,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.54" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", "jobserver", @@ -1058,15 +963,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom", -] - [[package]] name = "cfg-if" version = "1.0.4" @@ -1079,11 +975,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "num-traits", @@ -1120,30 +1027,19 @@ dependencies = [ [[package]] name = "cipher" -version = "0.4.4" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea" dependencies = [ - "crypto-common", + "crypto-common 0.2.1", "inout", ] -[[package]] -name = "clang-sys" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" -dependencies = [ - "glob", - "libc", - "libloading", -] - [[package]] name = "clap" -version = "4.5.54" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -1151,9 +1047,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.54" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -1163,36 +1059,42 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "clap_lex" -version = "0.7.7" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cmake" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" @@ -1219,9 +1121,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.36" +version = "0.4.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" dependencies = [ "brotli", "compression-core", @@ -1246,6 +1148,18 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "convert_case" version = "0.10.0" @@ -1290,6 +1204,30 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -1329,16 +1267,16 @@ dependencies = [ [[package]] name = "criterion" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d883447757bb0ee46f233e9dc22eb84d93a9508c9b868687b274fc431d886bf" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" dependencies = [ "alloca", "anes", "cast", "ciborium", "clap", - "criterion-plot 0.8.1", + "criterion-plot 0.8.2", "itertools 0.13.0", "num-traits", "oorandom", @@ -1365,36 +1303,14 @@ dependencies = [ [[package]] name = "criterion-plot" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed943f81ea2faa8dcecbbfa50164acf95d555afec96a27871663b300e387b2e4" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" dependencies = [ "cast", "itertools 0.13.0", ] -[[package]] -name = "crossbeam" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" -dependencies = [ - "crossbeam-channel", - "crossbeam-deque", - "crossbeam-epoch", - "crossbeam-queue", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -1455,6 +1371,24 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.20.11" @@ -1467,12 +1401,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -1486,21 +1420,20 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "darling_core" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1511,18 +1444,18 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "darling_macro" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core 0.21.3", + "darling_core 0.23.0", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1539,11 +1472,22 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", "serde_core", @@ -1567,7 +1511,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1577,7 +1521,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1599,21 +1543,10 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.114", + "syn 2.0.117", "unicode-xid", ] -[[package]] -name = "derive_utils" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccfae181bab5ab6c5478b2ccb69e4c68a02f8c3ec72f6616bfec9dbc599d2ee0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "diff" version = "0.1.13" @@ -1626,8 +1559,22 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "const-oid 0.10.2", + "crypto-common 0.2.1", + "ctutils", ] [[package]] @@ -1638,7 +1585,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1652,6 +1599,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "downcast" version = "0.11.0" @@ -1675,6 +1628,9 @@ name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] [[package]] name = "encoding_rs" @@ -1686,15 +1642,25 @@ dependencies = [ ] [[package]] -name = "env_logger" -version = "0.8.4" +name = "env_filter" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a19187fea3ac7e84da7dacf48de0c45d63c6a76f9490dae389aead16c243fce3" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" dependencies = [ "log", "regex", ] +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "env_filter", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1711,6 +1677,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + [[package]] name = "etcetera" version = "0.11.0" @@ -1749,31 +1726,19 @@ dependencies = [ ] [[package]] -name = "fallible-iterator" -version = "0.3.0" +name = "fastrand" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" +name = "ferroid" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "ferroid" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb330bbd4cb7a5b9f559427f06f98a4f853a137c8298f3bd3f8ca57663e21986" +checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4" dependencies = [ "portable-atomic", - "rand 0.9.2", + "rand 0.10.1", "web-time", ] @@ -1806,21 +1771,31 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flate2" -version = "1.1.8" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", - "libz-sys", "miniz_oxide", ] +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1833,12 +1808,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - [[package]] name = "foreign-types" version = "0.3.2" @@ -1885,71 +1854,18 @@ dependencies = [ [[package]] name = "fragile" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" - -[[package]] -name = "frunk" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28aef0f9aa070bce60767c12ba9cb41efeaf1a2bc6427f87b7d83f11239a16d7" -dependencies = [ - "frunk_core", - "frunk_derives", - "frunk_proc_macros", - "serde", -] - -[[package]] -name = "frunk_core" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "476eeaa382e3462b84da5d6ba3da97b5786823c2d0d3a0d04ef088d073da225c" -dependencies = [ - "serde", -] - -[[package]] -name = "frunk_derives" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0b4095fc99e1d858e5b8c7125d2638372ec85aa0fe6c807105cf10b0265ca6c" -dependencies = [ - "frunk_proc_macro_helpers", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "frunk_proc_macro_helpers" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1952b802269f2db12ab7c0bd328d0ae8feaabf19f352a7b0af7bb0c5693abfce" -dependencies = [ - "frunk_core", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "frunk_proc_macros" -version = "0.1.4" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3462f590fa236005bd7ca4847f81438bd6fe0febd4d04e11968d4c2e96437e78" +checksum = "8878864ba14bb86e818a412bfd6f18f9eabd4ec0f008a28e8f7eb61db532fcf9" dependencies = [ - "frunk_core", - "frunk_proc_macro_helpers", - "quote", - "syn 2.0.114", + "futures-core", ] [[package]] name = "fs-err" -version = "3.2.2" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf68cef89750956493a66a10f512b9e58d9db21f2a573c079c0bdf1207a54a7" +checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" dependencies = [ "autocfg", "tokio", @@ -1961,17 +1877,11 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -1984,9 +1894,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -1994,26 +1904,37 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", "futures-util", ] +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -2030,26 +1951,26 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-timer" @@ -2059,9 +1980,9 @@ checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -2071,7 +1992,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -2107,11 +2027,25 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + [[package]] name = "getset" version = "0.1.6" @@ -2121,7 +2055,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2160,7 +2094,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.13.0", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -2175,7 +2109,7 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", - "zerocopy 0.8.34", + "zerocopy 0.8.48", ] [[package]] @@ -2183,9 +2117,6 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -dependencies = [ - "ahash", -] [[package]] name = "hashbrown" @@ -2201,25 +2132,22 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.1.5", + "foldhash", ] [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "foldhash 0.2.0", -] +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "hashlink" -version = "0.11.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.15.5", ] [[package]] @@ -2246,6 +2174,33 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.2", +] + [[package]] name = "home" version = "0.5.12" @@ -2300,11 +2255,20 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -2317,7 +2281,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -2340,15 +2303,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -2369,14 +2331,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -2385,7 +2346,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2", "system-configuration", "tokio", "tower-service", @@ -2410,9 +2371,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -2434,12 +2395,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -2447,9 +2409,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -2460,9 +2422,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2474,15 +2436,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -2494,15 +2456,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -2513,6 +2475,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -2553,12 +2521,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -2571,33 +2539,24 @@ checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" [[package]] name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - -[[package]] -name = "io-enum" -version = "1.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d197db2f7ebf90507296df3aebaf65d69f5dce8559d8dbd82776a6cadab61bbf" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ - "derive_utils", + "hybrid-array", ] [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.10" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", @@ -2655,9 +2614,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jni" @@ -2668,7 +2627,7 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.1", "log", "thiserror 1.0.69", "walkdir", @@ -2677,9 +2636,31 @@ dependencies = [ [[package]] name = "jni-sys" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] [[package]] name = "jobserver" @@ -2693,10 +2674,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -2715,22 +2698,21 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] -name = "libc" -version = "0.2.180" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] -name = "libloading" -version = "0.8.9" +name = "libc" +version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] name = "libm" @@ -2740,31 +2722,21 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ "bitflags", "libc", - "redox_syscall 0.7.0", + "plain", + "redox_syscall 0.7.4", ] [[package]] name = "libsqlite3-sys" -version = "0.36.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "libz-sys" -version = "1.1.23" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ "cc", "pkg-config", @@ -2773,21 +2745,21 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "local-ip-address" -version = "0.6.9" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92488bc8a0f99ee9f23577bdd06526d49657df8bd70504c61f812337cdad01ab" +checksum = "d7b0187df4e614e42405b49511b82ff7a1774fbd9a816060ee465067847cac22" dependencies = [ "libc", "neli", @@ -2812,15 +2784,6 @@ dependencies = [ "value-bag", ] -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "lru-slab" version = "0.1.2" @@ -2833,11 +2796,21 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "miette" @@ -2866,7 +2839,7 @@ checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2876,10 +2849,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "minimal-lexical" -version = "0.2.1" +name = "mime_guess" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] [[package]] name = "miniz_oxide" @@ -2893,9 +2870,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", @@ -2925,7 +2902,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2938,150 +2915,49 @@ dependencies = [ ] [[package]] -name = "mysql" -version = "25.0.1" +name = "native-tls" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6ad644efb545e459029b1ffa7c969d830975bd76906820913247620df10050b" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ - "bufstream", - "bytes", - "crossbeam", - "flate2", - "io-enum", "libc", - "lru", - "mysql_common", - "named_pipe", - "native-tls", - "pem", - "percent-encoding", - "serde", - "serde_json", - "socket2 0.5.10", - "twox-hash", - "url", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", ] [[package]] -name = "mysql-common-derive" -version = "0.31.2" +name = "neli" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63c3512cf11487168e0e9db7157801bf5273be13055a9cc95356dc9e0035e49c" +checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" dependencies = [ - "darling 0.20.11", - "heck", - "num-bigint", - "proc-macro-crate", - "proc-macro-error2", - "proc-macro2", - "quote", - "syn 2.0.114", - "termcolor", - "thiserror 1.0.69", + "bitflags", + "byteorder", + "derive_builder", + "getset", + "libc", + "log", + "neli-proc-macros", + "parking_lot", ] [[package]] -name = "mysql_common" -version = "0.32.4" +name = "neli-proc-macros" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "478b0ff3f7d67b79da2b96f56f334431aef65e15ba4b29dd74a4236e29582bdc" -dependencies = [ - "base64 0.21.7", - "bigdecimal", - "bindgen", - "bitflags", - "bitvec", - "btoi", - "byteorder", - "bytes", - "cc", - "cmake", - "crc32fast", - "flate2", - "frunk", - "lazy_static", - "mysql-common-derive", - "num-bigint", - "num-traits", - "rand 0.8.5", - "regex", - "rust_decimal", - "saturating", - "serde", - "serde_json", - "sha1", - "sha2", - "smallvec", - "subprocess", - "thiserror 1.0.69", - "time", - "uuid", - "zstd", -] - -[[package]] -name = "named_pipe" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad9c443cce91fc3e12f017290db75dde490d685cdaaf508d7159d7cf41f0eb2b" -dependencies = [ - "winapi", -] - -[[package]] -name = "native-tls" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe 0.1.6", - "openssl-sys", - "schannel", - "security-framework 2.11.1", - "security-framework-sys", - "tempfile", -] - -[[package]] -name = "neli" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e23bebbf3e157c402c4d5ee113233e5e0610cc27453b2f07eefce649c7365dcc" -dependencies = [ - "bitflags", - "byteorder", - "derive_builder", - "getset", - "libc", - "log", - "neli-proc-macros", - "parking_lot", -] - -[[package]] -name = "neli-proc-macros" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609" +checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609" dependencies = [ "either", "proc-macro2", "quote", "serde", - "syn 2.0.114", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", + "syn 2.0.117", ] [[package]] @@ -3123,6 +2999,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -3134,9 +3026,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" @@ -3176,6 +3068,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -3189,9 +3082,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -3207,9 +3100,9 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "openssl" -version = "0.10.75" +version = "0.10.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" dependencies = [ "bitflags", "cfg-if", @@ -3228,15 +3121,9 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - [[package]] name = "openssl-probe" version = "0.2.1" @@ -3245,9 +3132,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.111" +version = "0.9.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" dependencies = [ "cc", "libc", @@ -3257,9 +3144,9 @@ dependencies = [ [[package]] name = "owo-colors" -version = "4.2.3" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "page_size" @@ -3322,7 +3209,17 @@ dependencies = [ "regex", "regex-syntax", "structmeta", - "syn 2.0.114", + "syn 2.0.117", +] + +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.2", + "hmac 0.13.0", ] [[package]] @@ -3345,17 +3242,16 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] -name = "pem" -version = "3.0.6" +name = "pem-rfc7468" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" dependencies = [ - "base64 0.22.1", - "serde_core", + "base64ct", ] [[package]] @@ -3390,7 +3286,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -3404,29 +3300,29 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -3436,20 +3332,47 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "piper" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", "fastrand", "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plotters" @@ -3495,24 +3418,24 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ "portable-atomic", ] [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -3529,14 +3452,14 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy 0.8.34", + "zerocopy 0.8.48", ] [[package]] name = "predicates" -version = "3.1.3" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" dependencies = [ "anstyle", "predicates-core", @@ -3544,15 +3467,15 @@ dependencies = [ [[package]] name = "predicates-core" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" [[package]] name = "predicates-tree" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" dependencies = [ "predicates-core", "termtree", @@ -3568,13 +3491,23 @@ dependencies = [ "yansi", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.23.10+spec-1.0.0", + "toml_edit 0.25.11+spec-1.1.0", ] [[package]] @@ -3596,7 +3529,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3616,7 +3549,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "version_check", "yansi", ] @@ -3641,7 +3574,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3653,35 +3586,15 @@ dependencies = [ "prost", ] -[[package]] -name = "ptr_meta" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" -dependencies = [ - "ptr_meta_derive", -] - -[[package]] -name = "ptr_meta_derive" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "quickcheck" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "588f6378e4dd99458b60ec275b4477add41ce4fa9f64dcba6f15adccb19b50d6" +checksum = "95c589f335db0f6aaa168a7cd27b1fc6920f5e1470c804f814d9cd6e62a0f70b" dependencies = [ "env_logger", "log", - "rand 0.8.5", + "rand 0.10.1", ] [[package]] @@ -3697,7 +3610,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.2", + "socket2", "thiserror 2.0.18", "tokio", "tracing", @@ -3706,15 +3619,15 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -3735,16 +3648,16 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.2", + "socket2", "tracing", "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -3756,48 +3669,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] -name = "r2d2" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" -dependencies = [ - "log", - "parking_lot", - "scheduled-thread-pool", -] - -[[package]] -name = "r2d2_mysql" -version = "25.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93963fe09ca35b0311d089439e944e42a6cb39bf8ea323782ddb31240ba2ae87" -dependencies = [ - "mysql", - "r2d2", -] - -[[package]] -name = "r2d2_sqlite" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2ebd03c29250cdf191da93a35118b4567c2ef0eacab54f65e058d6f4c9965f6" -dependencies = [ - "r2d2", - "rusqlite", - "uuid", -] - -[[package]] -name = "radium" -version = "0.7.0" +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -3806,14 +3687,25 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -3852,11 +3744,17 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -3883,9 +3781,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.0" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ "bitflags", ] @@ -3907,14 +3805,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -3924,9 +3822,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -3935,9 +3833,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "relative-path" @@ -3945,25 +3843,17 @@ version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" -[[package]] -name = "rend" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" -dependencies = [ - "bytecheck", -] - [[package]] name = "reqwest" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -3974,6 +3864,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "percent-encoding", "pin-project-lite", "quinn", @@ -3982,6 +3873,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -4020,42 +3912,23 @@ dependencies = [ ] [[package]] -name = "rkyv" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" -dependencies = [ - "bitvec", - "bytecheck", - "bytes", - "hashbrown 0.12.3", - "ptr_meta", - "rend", - "rkyv_derive", - "seahash", - "tinyvec", - "uuid", -] - -[[package]] -name = "rkyv_derive" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "rsqlite-vfs" -version = "0.1.0" +name = "rsa" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "hashbrown 0.16.1", - "thiserror 2.0.18", + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", ] [[package]] @@ -4095,7 +3968,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.114", + "syn 2.0.117", "unicode-ident", ] @@ -4113,41 +3986,10 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.114", + "syn 2.0.117", "unicode-ident", ] -[[package]] -name = "rusqlite" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" -dependencies = [ - "bitflags", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", - "sqlite-wasm-rs", -] - -[[package]] -name = "rust_decimal" -version = "1.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" -dependencies = [ - "arrayvec", - "borsh", - "bytes", - "num-traits", - "rand 0.8.5", - "rkyv", - "serde", - "serde_json", -] - [[package]] name = "rustc-demangle" version = "0.1.27" @@ -4156,9 +3998,9 @@ checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -4171,9 +4013,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags", "errno", @@ -4184,9 +4026,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.36" +version = "0.23.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" dependencies = [ "aws-lc-rs", "log", @@ -4204,19 +4046,10 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe 0.2.1", + "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.5.1", -] - -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", + "security-framework", ] [[package]] @@ -4244,7 +4077,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework 3.5.1", + "security-framework", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -4258,9 +4091,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -4276,9 +4109,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -4289,30 +4122,15 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "saturating" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71" - [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "scheduled-thread-pool" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" -dependencies = [ - "parking_lot", -] - [[package]] name = "schemars" version = "0.9.0" @@ -4327,9 +4145,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "ref-cast", @@ -4343,30 +4161,11 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "seahash" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" - -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - [[package]] name = "security-framework" -version = "3.5.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", "core-foundation 0.10.1", @@ -4377,9 +4176,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -4387,9 +4186,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -4438,7 +4237,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4448,7 +4247,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" dependencies = [ "form_urlencoded", - "indexmap 2.13.0", + "indexmap 2.14.0", "itoa", "ryu", "serde_core", @@ -4460,7 +4259,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -4487,7 +4286,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4501,9 +4300,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -4522,17 +4321,17 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.16.1" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" dependencies = [ "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.13.0", + "indexmap 2.14.0", "schemars 0.9.0", - "schemars 1.2.0", + "schemars 1.2.1", "serde_core", "serde_json", "serde_with_macros", @@ -4541,14 +4340,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.16.1" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" dependencies = [ - "darling 0.21.3", + "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4558,8 +4357,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", ] [[package]] @@ -4569,8 +4379,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", ] [[package]] @@ -4599,65 +4420,258 @@ dependencies = [ ] [[package]] -name = "simd-adler32" -version = "0.3.8" +name = "signature" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] [[package]] -name = "simdutf8" -version = "0.1.5" +name = "simd-adler32" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "siphasher" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "socket2" -version = "0.5.10" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] -name = "socket2" -version = "0.6.2" +name = "spin" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" dependencies = [ - "libc", - "windows-sys 0.60.2", + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener 5.4.1", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap 2.14.0", + "log", + "memchr", + "native-tls", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.117", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.117", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "bytes", + "crc", + "digest 0.10.7", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac 0.12.1", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "serde", + "sha1 0.10.6", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera 0.8.0", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac 0.12.1", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.6", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "whoami", ] [[package]] -name = "sqlite-wasm-rs" -version = "0.5.2" +name = "sqlx-sqlite" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ - "cc", - "js-sys", - "rsqlite-vfs", - "wasm-bindgen", + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", ] [[package]] @@ -4672,6 +4686,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -4687,7 +4712,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta-derive", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4698,17 +4723,7 @@ checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", -] - -[[package]] -name = "subprocess" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75238edb5be30a9ea3035b945eb9c319dde80e879411cdc9a8978e1ac822960" -dependencies = [ - "libc", - "winapi", + "syn 2.0.117", ] [[package]] @@ -4751,9 +4766,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -4777,14 +4792,14 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags", "core-foundation 0.9.4", @@ -4801,12 +4816,6 @@ dependencies = [ "libc", ] -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "tdyne-peer-id" version = "1.0.2" @@ -4826,34 +4835,25 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "terminal_size" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4864,9 +4864,9 @@ checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" [[package]] name = "testcontainers" -version = "0.26.3" +version = "0.27.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a81ec0158db5fbb9831e09d1813fe5ea9023a2b5e6e8e0a5fe67e2a820733629" +checksum = "bfd5785b5483672915ed5fe3cddf9f546802779fc1eceff0a6fb7321fac81c1e" dependencies = [ "astral-tokio-tar", "async-trait", @@ -4874,9 +4874,10 @@ dependencies = [ "bytes", "docker_credential", "either", - "etcetera", + "etcetera 0.11.0", "ferroid", "futures", + "http", "itertools 0.14.0", "log", "memchr", @@ -4928,7 +4929,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4939,7 +4940,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4953,9 +4954,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.46" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", @@ -4974,9 +4975,9 @@ checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.26" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -4984,9 +4985,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -5004,9 +5005,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -5019,29 +5020,29 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.49.0" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", "mio", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.2", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5092,17 +5093,17 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.11+spec-1.1.0" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "serde_core", - "serde_spanned 1.0.4", + "serde_spanned 1.1.1", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -5123,39 +5124,48 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", ] [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ - "indexmap 2.13.0", - "toml_datetime 0.7.5+spec-1.1.0", + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.2", ] [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.2", ] [[package]] @@ -5166,15 +5176,15 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tonic" -version = "0.14.2" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203" +checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" dependencies = [ "async-trait", "axum", @@ -5189,7 +5199,7 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "socket2 0.6.2", + "socket2", "sync_wrapper", "tokio", "tokio-stream", @@ -5201,9 +5211,9 @@ dependencies = [ [[package]] name = "tonic-prost" -version = "0.14.2" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66bd50ad6ce1252d87ef024b3d64fe4c3cf54a86fb9ef4c631fdd0ded7aeaa67" +checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" dependencies = [ "bytes", "prost", @@ -5255,7 +5265,7 @@ dependencies = [ "hyper", "local-ip-address", "percent-encoding", - "rand 0.9.2", + "rand 0.10.1", "reqwest", "serde", "serde_bencode", @@ -5387,6 +5397,7 @@ version = "3.0.0-develop" dependencies = [ "anyhow", "axum-server", + "base64 0.22.1", "bittorrent-http-tracker-core", "bittorrent-primitives", "bittorrent-tracker-client", @@ -5396,14 +5407,19 @@ dependencies = [ "clap", "local-ip-address", "mockall", - "rand 0.9.2", + "pbkdf2", + "rand 0.10.1", "regex", "reqwest", "serde", "serde_json", + "sha1 0.11.0", + "sha2 0.11.0", + "tempfile", "thiserror 2.0.18", "tokio", "tokio-util", + "toml 0.9.12+spec-1.1.0", "torrust-axum-health-check-api-server", "torrust-axum-http-tracker-server", "torrust-axum-rest-tracker-api-server", @@ -5466,7 +5482,7 @@ dependencies = [ "serde_json", "serde_with", "thiserror 2.0.18", - "toml 0.9.11+spec-1.1.0", + "toml 0.9.12+spec-1.1.0", "torrust-tracker-located-error", "tracing", "tracing-subscriber", @@ -5478,7 +5494,7 @@ dependencies = [ name = "torrust-tracker-contrib-bencode" version = "3.0.0-develop" dependencies = [ - "criterion 0.8.1", + "criterion 0.8.2", "thiserror 2.0.18", ] @@ -5542,11 +5558,11 @@ dependencies = [ "async-std", "bittorrent-primitives", "chrono", - "criterion 0.8.1", + "criterion 0.8.2", "crossbeam-skiplist", "futures", "mockall", - "rand 0.9.2", + "rand 0.10.1", "rstest 0.26.1", "serde", "thiserror 2.0.18", @@ -5565,7 +5581,7 @@ dependencies = [ name = "torrust-tracker-test-helpers" version = "3.0.0-develop" dependencies = [ - "rand 0.9.2", + "rand 0.10.1", "torrust-tracker-configuration", "tracing", "tracing-subscriber", @@ -5578,7 +5594,7 @@ dependencies = [ "aquatic_udp_protocol", "async-std", "bittorrent-primitives", - "criterion 0.8.1", + "criterion 0.8.2", "crossbeam-skiplist", "dashmap", "futures", @@ -5605,7 +5621,7 @@ dependencies = [ "futures-util", "local-ip-address", "mockall", - "rand 0.9.2", + "rand 0.10.1", "ringbuf", "serde", "thiserror 2.0.18", @@ -5633,7 +5649,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.13.0", + "indexmap 2.14.0", "pin-project-lite", "slab", "sync_wrapper", @@ -5700,7 +5716,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5736,9 +5752,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "nu-ansi-term", "serde", @@ -5757,22 +5773,11 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "twox-hash" -version = "1.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" -dependencies = [ - "cfg-if", - "rand 0.8.5", - "static_assertions", -] - [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "uncased" @@ -5783,11 +5788,23 @@ dependencies = [ "version_check", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-linebreak" @@ -5795,11 +5812,26 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" @@ -5827,9 +5859,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.1.4" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ "base64 0.22.1", "log", @@ -5837,15 +5869,14 @@ dependencies = [ "rustls", "rustls-pki-types", "ureq-proto", - "utf-8", - "webpki-roots", + "utf8-zero", ] [[package]] name = "ureq-proto" -version = "0.5.3" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ "base64 0.22.1", "http", @@ -5867,10 +5898,10 @@ dependencies = [ ] [[package]] -name = "utf-8" -version = "0.7.6" +name = "utf8-zero" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" [[package]] name = "utf8_iter" @@ -5886,13 +5917,12 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.20.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.2", "js-sys", - "rand 0.9.2", "wasm-bindgen", ] @@ -5947,18 +5977,33 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", @@ -5969,23 +6014,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.58" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5993,31 +6034,65 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + [[package]] name = "web-sys" -version = "0.3.85" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ "js-sys", "wasm-bindgen", @@ -6035,20 +6110,21 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.5" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] [[package]] -name = "webpki-roots" -version = "1.0.5" +name = "whoami" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" dependencies = [ - "rustls-pki-types", + "libredox", + "wasite", ] [[package]] @@ -6103,7 +6179,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -6114,7 +6190,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -6161,6 +6237,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -6203,6 +6288,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -6242,6 +6342,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -6260,6 +6366,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -6278,6 +6390,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -6308,6 +6426,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -6326,6 +6450,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -6344,6 +6474,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -6362,6 +6498,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -6376,9 +6518,18 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" dependencies = [ "memchr", ] @@ -6388,22 +6539,101 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] [[package]] -name = "writeable" -version = "0.6.2" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] -name = "wyz" -version = "0.5.1" +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ - "tap", + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "xattr" version = "1.6.1" @@ -6422,9 +6652,9 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -6433,13 +6663,13 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] @@ -6455,11 +6685,11 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.34" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71ddd76bcebeed25db614f82bf31a9f4222d3fbba300e6fb6c00afa26cbd4d9d" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ - "zerocopy-derive 0.8.34", + "zerocopy-derive 0.8.48", ] [[package]] @@ -6470,38 +6700,38 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "zerocopy-derive" -version = "0.8.34" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8187381b52e32220d50b255276aa16a084ec0a9017a0ca2152a1f55c539758d" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] @@ -6513,9 +6743,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -6524,9 +6754,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -6535,20 +6765,20 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "zmij" -version = "1.0.17" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zstd" diff --git a/Cargo.toml b/Cargo.toml index dbc39bdf8..19bf5867c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,13 +19,13 @@ version.workspace = true name = "torrust_tracker_lib" [workspace.package] -authors = ["Nautilus Cyberneering <info@nautilus-cyberneering.de>, Mick van Dijke <mick@dutchbits.nl>"] -categories = ["network-programming", "web-programming"] +authors = [ "Nautilus Cyberneering <info@nautilus-cyberneering.de>, Mick van Dijke <mick@dutchbits.nl>" ] +categories = [ "network-programming", "web-programming" ] description = "A feature rich BitTorrent tracker." documentation = "https://docs.rs/crate/torrust-tracker/" edition = "2021" homepage = "https://torrust.com/" -keywords = ["bittorrent", "file-sharing", "peer-to-peer", "torrent", "tracker"] +keywords = [ "bittorrent", "file-sharing", "peer-to-peer", "torrent", "tracker" ] license = "AGPL-3.0-only" publish = true repository = "https://github.com/torrust/torrust-tracker" @@ -34,24 +34,31 @@ version = "3.0.0-develop" [dependencies] anyhow = "1" -axum-server = { version = "0", features = ["tls-rustls-no-provider"] } +axum-server = { version = "0", features = [ "tls-rustls-no-provider" ] } +base64 = "0.22.1" bittorrent-http-tracker-core = { version = "3.0.0-develop", path = "packages/http-tracker-core" } bittorrent-tracker-core = { version = "3.0.0-develop", path = "packages/tracker-core" } bittorrent-udp-tracker-core = { version = "3.0.0-develop", path = "packages/udp-tracker-core" } -chrono = { version = "0", default-features = false, features = ["clock"] } -clap = { version = "4", features = ["derive", "env"] } +chrono = { version = "0", default-features = false, features = [ "clock" ] } +clap = { version = "4", features = [ "derive", "env" ] } +pbkdf2 = "0.13.0" rand = "0" regex = "1" -reqwest = { version = "0", features = ["json"] } -serde = { version = "1", features = ["derive"] } -serde_json = { version = "1", features = ["preserve_order"] } +reqwest = { version = "0", features = [ "json", "multipart" ] } +serde = { version = "1", features = [ "derive" ] } +serde_json = { version = "1", features = [ "preserve_order" ] } +sha1 = "0.11.0" +sha2 = "0.11.0" +tempfile = "3.27.0" thiserror = "2.0.12" -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +tokio = { version = "1", features = [ "macros", "net", "rt-multi-thread", "signal", "sync" ] } tokio-util = "0.7.15" +toml = "0" torrust-axum-health-check-api-server = { version = "3.0.0-develop", path = "packages/axum-health-check-api-server" } torrust-axum-http-tracker-server = { version = "3.0.0-develop", path = "packages/axum-http-tracker-server" } torrust-axum-rest-tracker-api-server = { version = "3.0.0-develop", path = "packages/axum-rest-tracker-api-server" } torrust-axum-server = { version = "3.0.0-develop", path = "packages/axum-server" } +torrust-rest-tracker-api-client = { version = "3.0.0-develop", path = "packages/rest-tracker-api-client" } torrust-rest-tracker-api-core = { version = "3.0.0-develop", path = "packages/rest-tracker-api-core" } torrust-server-lib = { version = "3.0.0-develop", path = "packages/server-lib" } torrust-tracker-clock = { version = "3.0.0-develop", path = "packages/clock" } @@ -59,18 +66,17 @@ torrust-tracker-configuration = { version = "3.0.0-develop", path = "packages/co torrust-tracker-swarm-coordination-registry = { version = "3.0.0-develop", path = "packages/swarm-coordination-registry" } torrust-udp-tracker-server = { version = "3.0.0-develop", path = "packages/udp-tracker-server" } tracing = "0" -tracing-subscriber = { version = "0", features = ["json"] } +tracing-subscriber = { version = "0", features = [ "json" ] } [dev-dependencies] bittorrent-primitives = "0.1.0" bittorrent-tracker-client = { version = "3.0.0-develop", path = "packages/tracker-client" } local-ip-address = "0" mockall = "0" -torrust-rest-tracker-api-client = { version = "3.0.0-develop", path = "packages/rest-tracker-api-client" } torrust-tracker-test-helpers = { version = "3.0.0-develop", path = "packages/test-helpers" } [workspace] -members = ["console/tracker-client", "packages/torrent-repository-benchmarking"] +members = [ "console/tracker-client", "packages/torrent-repository-benchmarking" ] [profile.dev] debug = 1 diff --git a/README.md b/README.md index bb102355b..2fe28db08 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Others: <https://github.com/orgs/torrust/projects/10/views/6> ## Implemented BitTorrent Enhancement Proposals (BEPs) -> + > _[Learn more about BitTorrent Enhancement Proposals][BEP 00]_ - [BEP 03]: The BitTorrent Protocol. @@ -113,8 +113,8 @@ podman run -it docker.io/torrust/tracker:develop ### Development Version -- Please ensure you have the _**[latest stable (or nightly) version of rust][rust]___. -- Please ensure that your computer has enough RAM. _**Recommended 16GB.___ +- Please ensure you have the \_\*\*[latest stable (or nightly) version of rust][rust]\_\_\_. +- Please ensure that your computer has enough RAM. \_\*\*Recommended 16GB.\_\_\_ #### Checkout, Test and Run @@ -217,7 +217,7 @@ This program is free software: you can redistribute it and/or modify it under th This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the [GNU Affero General Public License][AGPL_3_0] for more details. -You should have received a copy of the *GNU Affero General Public License* along with this program. If not, see <https://www.gnu.org/licenses/>. +You should have received a copy of the _GNU Affero General Public License_ along with this program. If not, see <https://www.gnu.org/licenses/>. Some files include explicit copyright notices and/or license notices. @@ -250,18 +250,14 @@ This project was a joint effort by [Nautilus Cyberneering GmbH][nautilus] and [D [deployment_wf_b]: ../../actions/workflows/deployment.yaml/badge.svg [testing_wf]: ../../actions/workflows/testing.yaml [testing_wf_b]: ../../actions/workflows/testing.yaml/badge.svg - [bittorrent]: http://bittorrent.org/ [rust]: https://www.rust-lang.org/ [axum]: https://github.com/tokio-rs/axum [newtrackon]: https://newtrackon.com/ [coverage]: https://app.codecov.io/gh/torrust/torrust-tracker [torrust]: https://torrust.com/ - [dockerhub]: https://hub.docker.com/r/torrust/tracker/tags - [torrent_source_felid]: https://github.com/qbittorrent/qBittorrent/discussions/19406 - [BEP 00]: https://www.bittorrent.org/beps/bep_0000.html [BEP 03]: https://www.bittorrent.org/beps/bep_0003.html [BEP 07]: https://www.bittorrent.org/beps/bep_0007.html @@ -269,24 +265,18 @@ This project was a joint effort by [Nautilus Cyberneering GmbH][nautilus] and [D [BEP 23]: https://www.bittorrent.org/beps/bep_0023.html [BEP 27]: https://www.bittorrent.org/beps/bep_0027.html [BEP 48]: https://www.bittorrent.org/beps/bep_0048.html - [containers.md]: ./docs/containers.md - [docs]: https://docs.rs/torrust-tracker/latest/ [api]: https://docs.rs/torrust-tracker/latest/torrust_tracker/servers/apis/v1 [http]: https://docs.rs/torrust-tracker/latest/torrust_tracker/servers/http [udp]: https://docs.rs/torrust-tracker/latest/torrust_tracker/servers/udp - [good first issues]: https://github.com/torrust/torrust-tracker/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22 [discussions]: https://github.com/torrust/torrust-tracker/discussions - [guide.md]: https://github.com/torrust/.github/blob/main/info/contributing.md [agreement.md]: https://github.com/torrust/.github/blob/main/info/licensing/contributor_agreement_v01.md - [AGPL_3_0]: ./docs/licenses/LICENSE-AGPL_3_0 [MIT_0]: ./docs/licenses/LICENSE-MIT_0 [FSF]: https://www.fsf.org/ - [nautilus]: https://github.com/orgs/Nautilus-Cyberneering/ [Dutch Bits]: https://dutchbits.nl [Naim A.]: https://github.com/naim94a/udpt diff --git a/cSpell.json b/cSpell.json deleted file mode 100644 index 81421e050..000000000 --- a/cSpell.json +++ /dev/null @@ -1,208 +0,0 @@ -{ - "words": [ - "Addrs", - "adduser", - "alekitto", - "appuser", - "Arvid", - "ASMS", - "asyn", - "autoclean", - "AUTOINCREMENT", - "automock", - "Avicora", - "Azureus", - "bdecode", - "bencode", - "bencoded", - "bencoding", - "beps", - "binascii", - "binstall", - "Bitflu", - "bools", - "Bragilevsky", - "bufs", - "buildid", - "Buildx", - "byteorder", - "callgrind", - "camino", - "canonicalize", - "canonicalized", - "certbot", - "chrono", - "Cinstrument", - "ciphertext", - "clippy", - "cloneable", - "codecov", - "codegen", - "completei", - "Condvar", - "connectionless", - "Containerfile", - "conv", - "curr", - "cvar", - "Cyberneering", - "dashmap", - "datagram", - "datetime", - "debuginfo", - "Deque", - "Dijke", - "distroless", - "dockerhub", - "downloadedi", - "dtolnay", - "elif", - "endianness", - "Eray", - "filesd", - "flamegraph", - "formatjson", - "Freebox", - "Frostegård", - "gecos", - "Gibibytes", - "Grcov", - "hasher", - "healthcheck", - "heaptrack", - "hexlify", - "hlocalhost", - "Hydranode", - "hyperthread", - "Icelake", - "iiiiiiiiiiiiiiiiiiiid", - "imdl", - "impls", - "incompletei", - "infohash", - "infohashes", - "infoschema", - "Intermodal", - "intervali", - "Joakim", - "kallsyms", - "Karatay", - "kcachegrind", - "kexec", - "keyout", - "Kibibytes", - "kptr", - "lcov", - "leecher", - "leechers", - "libsqlite", - "libtorrent", - "libz", - "LOGNAME", - "Lphant", - "matchmakes", - "Mebibytes", - "metainfo", - "middlewares", - "misresolved", - "mockall", - "multimap", - "myacicontext", - "ñaca", - "Naim", - "nanos", - "newkey", - "nextest", - "nocapture", - "nologin", - "nonroot", - "Norberg", - "numwant", - "nvCFlJCq7fz7Qx6KoKTDiMZvns8l5Kw7", - "oneshot", - "ostr", - "Pando", - "peekable", - "peerlist", - "programatik", - "proot", - "proto", - "Quickstart", - "Radeon", - "Rakshasa", - "Rasterbar", - "realpath", - "reannounce", - "Registar", - "repr", - "reqs", - "reqwest", - "rerequests", - "ringbuf", - "ringsize", - "rngs", - "rosegment", - "routable", - "rstest", - "rusqlite", - "rustc", - "RUSTDOCFLAGS", - "RUSTFLAGS", - "rustfmt", - "Rustls", - "Ryzen", - "Seedable", - "serde", - "Shareaza", - "sharktorrent", - "SHLVL", - "skiplist", - "slowloris", - "socketaddr", - "sqllite", - "subsec", - "Swatinem", - "Swiftbit", - "taiki", - "tdyne", - "Tebibytes", - "tempfile", - "testcontainers", - "thiserror", - "tlsv", - "Torrentstorm", - "torrust", - "torrustracker", - "trackerid", - "Trackon", - "typenum", - "udpv", - "Unamed", - "underflows", - "Unsendable", - "untuple", - "uroot", - "Vagaa", - "valgrind", - "Vitaly", - "vmlinux", - "Vuze", - "Weidendorfer", - "Werror", - "whitespaces", - "Xacrimon", - "XBTT", - "Xdebug", - "Xeon", - "Xtorrent", - "Xunlei", - "xxxxxxxxxxxxxxxxxxxxd", - "yyyyyyyyyyyyyyyyyyyyd", - "zerocopy" - ], - "enableFiletypes": [ - "dockerfile", - "shellscript", - "toml" - ] -} diff --git a/compose.qbittorrent-e2e.yaml b/compose.qbittorrent-e2e.yaml new file mode 100644 index 000000000..228133705 --- /dev/null +++ b/compose.qbittorrent-e2e.yaml @@ -0,0 +1,67 @@ +name: qbittorrent-e2e + +services: + tracker: + build: + context: . + dockerfile: Containerfile + target: release + image: ${QBT_E2E_TRACKER_IMAGE:?QBT_E2E_TRACKER_IMAGE is required} + restart: "no" + volumes: + - type: bind + source: ${QBT_E2E_TRACKER_CONFIG_PATH:?QBT_E2E_TRACKER_CONFIG_PATH is required} + target: /etc/torrust/tracker/tracker.toml + read_only: true + - type: bind + source: ${QBT_E2E_TRACKER_STORAGE_PATH:?QBT_E2E_TRACKER_STORAGE_PATH is required} + target: /var/lib/torrust/tracker + ports: + - "0:${QBT_E2E_TRACKER_HTTP_TRACKER_PORT:?QBT_E2E_TRACKER_HTTP_TRACKER_PORT is required}" + - "0:${QBT_E2E_TRACKER_UDP_PORT:?QBT_E2E_TRACKER_UDP_PORT is required}/udp" + - "0:${QBT_E2E_TRACKER_HTTP_API_PORT:?QBT_E2E_TRACKER_HTTP_API_PORT is required}" + - "0:${QBT_E2E_TRACKER_HEALTH_CHECK_API_PORT:?QBT_E2E_TRACKER_HEALTH_CHECK_API_PORT is required}" + + qbittorrent-seeder: + image: ${QBT_E2E_QBITTORRENT_IMAGE:?QBT_E2E_QBITTORRENT_IMAGE is required} + restart: "no" + environment: + WEBUI_PORT: "8080" + PUID: "1000" + PGID: "1000" + TZ: "UTC" + QBT_LEGAL_NOTICE: "confirm" + volumes: + - type: bind + source: ${QBT_E2E_SEEDER_CONFIG_PATH:?QBT_E2E_SEEDER_CONFIG_PATH is required} + target: /config + - type: bind + source: ${QBT_E2E_SEEDER_DOWNLOADS_PATH:?QBT_E2E_SEEDER_DOWNLOADS_PATH is required} + target: /downloads + - type: bind + source: ${QBT_E2E_SHARED_PATH:?QBT_E2E_SHARED_PATH is required} + target: /shared + ports: + - "0:8080" + + qbittorrent-leecher: + image: ${QBT_E2E_QBITTORRENT_IMAGE:?QBT_E2E_QBITTORRENT_IMAGE is required} + restart: "no" + environment: + WEBUI_PORT: "8080" + PUID: "1000" + PGID: "1000" + TZ: "UTC" + QBT_LEGAL_NOTICE: "confirm" + volumes: + - type: bind + source: ${QBT_E2E_LEECHER_CONFIG_PATH:?QBT_E2E_LEECHER_CONFIG_PATH is required} + target: /config + - type: bind + source: ${QBT_E2E_LEECHER_DOWNLOADS_PATH:?QBT_E2E_LEECHER_DOWNLOADS_PATH is required} + target: /downloads + - type: bind + source: ${QBT_E2E_SHARED_PATH:?QBT_E2E_SHARED_PATH is required} + target: /shared + ports: + - "0:8080" diff --git a/console/tracker-client/Cargo.toml b/console/tracker-client/Cargo.toml index d4ab7c9e3..8c12227e9 100644 --- a/console/tracker-client/Cargo.toml +++ b/console/tracker-client/Cargo.toml @@ -1,6 +1,6 @@ [package] description = "A collection of console clients to make requests to BitTorrent trackers." -keywords = ["bittorrent", "client", "tracker"] +keywords = [ "bittorrent", "client", "tracker" ] license = "LGPL-3.0" name = "torrust-tracker-client" readme = "README.md" @@ -19,21 +19,21 @@ anyhow = "1" aquatic_udp_protocol = "0" bittorrent-primitives = "0.1.0" bittorrent-tracker-client = { version = "3.0.0-develop", path = "../../packages/tracker-client" } -clap = { version = "4", features = ["derive", "env"] } +clap = { version = "4", features = [ "derive", "env" ] } futures = "0" hex-literal = "1" hyper = "1" -reqwest = { version = "0", features = ["json"] } -serde = { version = "1", features = ["derive"] } +reqwest = { version = "0", features = [ "json" ] } +serde = { version = "1", features = [ "derive" ] } serde_bencode = "0" serde_bytes = "0" -serde_json = { version = "1", features = ["preserve_order"] } +serde_json = { version = "1", features = [ "preserve_order" ] } thiserror = "2" -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +tokio = { version = "1", features = [ "macros", "net", "rt-multi-thread", "signal", "sync" ] } torrust-tracker-configuration = { version = "3.0.0-develop", path = "../../packages/configuration" } tracing = "0" -tracing-subscriber = { version = "0", features = ["json"] } -url = { version = "2", features = ["serde"] } +tracing-subscriber = { version = "0", features = [ "json" ] } +url = { version = "2", features = [ "serde" ] } [package.metadata.cargo-machete] -ignored = ["serde_bytes"] +ignored = [ "serde_bytes" ] diff --git a/contrib/bencode/Cargo.toml b/contrib/bencode/Cargo.toml index f6355b6fc..5fab1792d 100644 --- a/contrib/bencode/Cargo.toml +++ b/contrib/bencode/Cargo.toml @@ -1,10 +1,10 @@ [package] description = "(contrib) Efficient decoding and encoding for bencode." -keywords = ["bencode", "contrib", "library"] +keywords = [ "bencode", "contrib", "library" ] name = "torrust-tracker-contrib-bencode" readme = "README.md" -authors = ["Nautilus Cyberneering <info@nautilus-cyberneering.de>, Andrew <amiller4421@gmail.com>"] +authors = [ "Nautilus Cyberneering <info@nautilus-cyberneering.de>, Andrew <amiller4421@gmail.com>" ] license = "Apache-2.0" repository = "https://github.com/torrust/bittorrent-infrastructure-project" diff --git a/contrib/bencode/README.md b/contrib/bencode/README.md index 7a203082b..81c09f691 100644 --- a/contrib/bencode/README.md +++ b/contrib/bencode/README.md @@ -1,4 +1,5 @@ # Bencode + This library allows for the creation and parsing of bencode encodings. -Bencode is the binary encoding used throughout bittorrent technologies from metainfo files to DHT messages. Bencode types include integers, byte arrays, lists, and dictionaries, of which the last two can hold any bencode type (they could be recursively constructed). \ No newline at end of file +Bencode is the binary encoding used throughout bittorrent technologies from metainfo files to DHT messages. Bencode types include integers, byte arrays, lists, and dictionaries, of which the last two can hold any bencode type (they could be recursively constructed). diff --git a/contrib/dev-tools/debugging/README.md b/contrib/dev-tools/debugging/README.md new file mode 100644 index 000000000..73b9d36f7 --- /dev/null +++ b/contrib/dev-tools/debugging/README.md @@ -0,0 +1,14 @@ +## Debugging Tools + +This directory contains developer-facing scripts for investigating problems that +are easier to isolate outside the normal test and CI flows. + +These scripts are useful when you need to: + +- reproduce a failure manually before changing Rust code +- inspect container logs, mounted files, and published ports +- validate assumptions about third-party tools such as qBittorrent +- confirm a fix in a smaller environment before running the full E2E runner + +Subdirectories group scripts by topic. qBittorrent-specific helpers live in +`qbt/`. diff --git a/contrib/dev-tools/debugging/qbt/README.md b/contrib/dev-tools/debugging/qbt/README.md new file mode 100644 index 000000000..1f8507f96 --- /dev/null +++ b/contrib/dev-tools/debugging/qbt/README.md @@ -0,0 +1,111 @@ +## qBittorrent Debugging + +These scripts help debug the qBittorrent-based E2E workflow without running the +entire Rust runner. + +Available scripts: + +- `qbittorrent-login-probe.sh`: starts an isolated qBittorrent 5.1.4 container, + prepares a `/config` mount, and probes WebUI authentication behavior. Use it + to debug browser access, CSRF header handling, Host validation, and temporary + password behavior. +- `check-qbittorrent-e2e-compose.sh`: validates and brings up the full compose + stack to confirm container startup, port publishing, and image wiring before + debugging orchestration logic in Rust. + +Suggested workflow: + +1. Use `qbittorrent-login-probe.sh` when the WebUI itself is failing. +2. Use `check-qbittorrent-e2e-compose.sh` when the isolated UI works but the + full stack still fails. +3. Run the Rust `qbittorrent_e2e_runner` only after the smaller debugging steps + pass. + +## Troubleshooting + +### WebUI returns Unauthorized in browser + +Symptom: + +- Opening the leecher WebUI on the published host port (for example, + `http://127.0.0.1:32867`) shows Unauthorized. +- Browser private mode does not help. +- API login to that host port can return `401 Unauthorized` even with valid + credentials. + +Observed cause: + +- qBittorrent accepts authentication only when the request Host/Origin/Referer + match `localhost:8080` in this setup. +- The E2E stack publishes container WebUI port `8080` to a random host port + (for example, `32867`), which can trigger this mismatch. + +How to verify: + +1. Confirm the leecher port mapping. +2. Compare login responses with and without host header override. + + docker compose -f ./compose.qbittorrent-e2e.yaml -p <project> port qbittorrent-leecher 8080 + curl -i -X POST http://127.0.0.1:<host-port>/api/v2/auth/login \ + --data 'username=admin&password=adminadmin' + curl -i -X POST http://127.0.0.1:<host-port>/api/v2/auth/login \ + -H 'Host: localhost:8080' \ + -H 'Referer: http://localhost:8080' \ + -H 'Origin: http://localhost:8080' \ + --data 'username=admin&password=adminadmin' + +Expected result: + +- First login can return `401 Unauthorized`. +- Second login should return `200 OK` with body `Ok.` + +Important: + +- Do not treat HTTP status code alone as success. qBittorrent can return + `200 OK` with body `Fails.` when credentials are wrong. +- Successful login response body is exactly `Ok.` + +Workaround for manual browser inspection: + +1. Forward local port `8080` to the published leecher host port. + + socat TCP-LISTEN:8080,reuseaddr,fork TCP:127.0.0.1:<host-port> + +2. Open `http://localhost:8080`. +3. Log in with the leecher credentials configured by the E2E workflow: + `admin` / `leecher-pass`. +4. Stop the forwarder with `Ctrl+C` when done. + +Notes: + +- If needed, install socat with your system package manager (for example, + `sudo apt-get install -y socat`). +- This is a debugging workaround for manual inspection. Keep using the runner + logs as the source of truth for automated pass/fail checks. + +### Repeated login attempts lead to temporary IP ban + +Symptom: + +- Login requests start returning `403 Forbidden`. +- Response body contains: `Your IP address has been banned after too many +failed authentication attempts.` + +Observed cause: + +- Multiple failed login attempts from the same client IP quickly trigger + qBittorrent WebUI protection. + +How to verify safely: + +1. Recreate a fresh stack before re-testing auth. +2. Make one login attempt only. +3. Check both status and body: + - success: `200 OK` + `Ok.` + - wrong credentials: `200 OK` + `Fails.` + - banned: `403 Forbidden` + ban message above + +Recommended practice: + +- Prefer one controlled API login check first, then browser login. +- Avoid trying fallback credentials repeatedly on the same running stack. diff --git a/contrib/dev-tools/debugging/qbt/check-qbittorrent-e2e-compose.sh b/contrib/dev-tools/debugging/qbt/check-qbittorrent-e2e-compose.sh new file mode 100755 index 000000000..ce57b1066 --- /dev/null +++ b/contrib/dev-tools/debugging/qbt/check-qbittorrent-e2e-compose.sh @@ -0,0 +1,182 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" + +COMPOSE_FILE="$REPO_ROOT/compose.qbittorrent-e2e.yaml" +TRACKER_IMAGE="torrust-tracker:qbt-e2e-local" +QBITTORRENT_IMAGE="lscr.io/linuxserver/qbittorrent:5.1.4" +PROJECT_NAME="qbt-e2e-composecheck-$(date +%s)" +KEEP_STACK=0 +SKIP_BUILD=0 + +usage() { + cat <<'EOF' +Usage: check-qbittorrent-e2e-compose.sh [options] + +Validate that the qBittorrent E2E compose stack can be rendered, started, and +inspected before debugging the Rust runner. + +Options: + --project-name <name> Docker compose project name. + --compose-file <path> Compose file to validate and run. + --tracker-image <image> Tracker image tag. + --qb-image <image> qBittorrent image tag. + --skip-build Skip building tracker image when missing. + --keep-stack Keep containers up after checks. + -h, --help Show this help message. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --project-name) + PROJECT_NAME="$2" + shift 2 + ;; + --compose-file) + COMPOSE_FILE="$2" + shift 2 + ;; + --tracker-image) + TRACKER_IMAGE="$2" + shift 2 + ;; + --qb-image) + QBITTORRENT_IMAGE="$2" + shift 2 + ;; + --skip-build) + SKIP_BUILD=1 + shift + ;; + --keep-stack) + KEEP_STACK=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ ! -f "$COMPOSE_FILE" ]]; then + echo "Compose file not found: $COMPOSE_FILE" >&2 + exit 1 +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "docker command not found" >&2 + exit 1 +fi + +TMP_DIR="$(mktemp -d)" +TRACKER_CONFIG_SOURCE="$REPO_ROOT/share/default/config/tracker.e2e.container.sqlite3.toml" +TRACKER_CONFIG_PATH="$TMP_DIR/tracker-config.toml" +TRACKER_STORAGE_PATH="$TMP_DIR/tracker-storage" +SHARED_PATH="$TMP_DIR/shared" +SEEDER_CONFIG_PATH="$TMP_DIR/seeder-config" +LEECHER_CONFIG_PATH="$TMP_DIR/leecher-config" +SEEDER_DOWNLOADS_PATH="$TMP_DIR/seeder-downloads" +LEECHER_DOWNLOADS_PATH="$TMP_DIR/leecher-downloads" + +cleanup() { + if [[ "$KEEP_STACK" -eq 0 ]]; then + QBT_E2E_TRACKER_IMAGE="$TRACKER_IMAGE" \ + QBT_E2E_QBITTORRENT_IMAGE="$QBITTORRENT_IMAGE" \ + QBT_E2E_TRACKER_CONFIG_PATH="$TRACKER_CONFIG_PATH" \ + QBT_E2E_TRACKER_STORAGE_PATH="$TRACKER_STORAGE_PATH" \ + QBT_E2E_SHARED_PATH="$SHARED_PATH" \ + QBT_E2E_SEEDER_CONFIG_PATH="$SEEDER_CONFIG_PATH" \ + QBT_E2E_LEECHER_CONFIG_PATH="$LEECHER_CONFIG_PATH" \ + QBT_E2E_SEEDER_DOWNLOADS_PATH="$SEEDER_DOWNLOADS_PATH" \ + QBT_E2E_LEECHER_DOWNLOADS_PATH="$LEECHER_DOWNLOADS_PATH" \ + docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" down --volumes --remove-orphans || true + fi + + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +if [[ ! -f "$TRACKER_CONFIG_SOURCE" ]]; then + echo "Tracker config template not found: $TRACKER_CONFIG_SOURCE" >&2 + exit 1 +fi + +mkdir -p \ + "$TRACKER_STORAGE_PATH" \ + "$SHARED_PATH" \ + "$SEEDER_CONFIG_PATH" \ + "$LEECHER_CONFIG_PATH" \ + "$SEEDER_DOWNLOADS_PATH" \ + "$LEECHER_DOWNLOADS_PATH" +cp "$TRACKER_CONFIG_SOURCE" "$TRACKER_CONFIG_PATH" + +if [[ "$SKIP_BUILD" -eq 0 ]] && ! docker image inspect "$TRACKER_IMAGE" >/dev/null 2>&1; then + echo "Building tracker image: $TRACKER_IMAGE" + docker build -f "$REPO_ROOT/Containerfile" --target release -t "$TRACKER_IMAGE" "$REPO_ROOT" +fi + +echo "Validating compose config" +QBT_E2E_TRACKER_IMAGE="$TRACKER_IMAGE" \ +QBT_E2E_QBITTORRENT_IMAGE="$QBITTORRENT_IMAGE" \ +QBT_E2E_TRACKER_CONFIG_PATH="$TRACKER_CONFIG_PATH" \ +QBT_E2E_TRACKER_STORAGE_PATH="$TRACKER_STORAGE_PATH" \ +QBT_E2E_SHARED_PATH="$SHARED_PATH" \ +QBT_E2E_SEEDER_CONFIG_PATH="$SEEDER_CONFIG_PATH" \ +QBT_E2E_LEECHER_CONFIG_PATH="$LEECHER_CONFIG_PATH" \ +QBT_E2E_SEEDER_DOWNLOADS_PATH="$SEEDER_DOWNLOADS_PATH" \ +QBT_E2E_LEECHER_DOWNLOADS_PATH="$LEECHER_DOWNLOADS_PATH" \ + docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" config -q + +echo "Bringing stack up" +QBT_E2E_TRACKER_IMAGE="$TRACKER_IMAGE" \ +QBT_E2E_QBITTORRENT_IMAGE="$QBITTORRENT_IMAGE" \ +QBT_E2E_TRACKER_CONFIG_PATH="$TRACKER_CONFIG_PATH" \ +QBT_E2E_TRACKER_STORAGE_PATH="$TRACKER_STORAGE_PATH" \ +QBT_E2E_SHARED_PATH="$SHARED_PATH" \ +QBT_E2E_SEEDER_CONFIG_PATH="$SEEDER_CONFIG_PATH" \ +QBT_E2E_LEECHER_CONFIG_PATH="$LEECHER_CONFIG_PATH" \ +QBT_E2E_SEEDER_DOWNLOADS_PATH="$SEEDER_DOWNLOADS_PATH" \ +QBT_E2E_LEECHER_DOWNLOADS_PATH="$LEECHER_DOWNLOADS_PATH" \ + docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" up -d + +echo "Container status" +QBT_E2E_TRACKER_IMAGE="$TRACKER_IMAGE" \ +QBT_E2E_QBITTORRENT_IMAGE="$QBITTORRENT_IMAGE" \ +QBT_E2E_TRACKER_CONFIG_PATH="$TRACKER_CONFIG_PATH" \ +QBT_E2E_TRACKER_STORAGE_PATH="$TRACKER_STORAGE_PATH" \ +QBT_E2E_SHARED_PATH="$SHARED_PATH" \ +QBT_E2E_SEEDER_CONFIG_PATH="$SEEDER_CONFIG_PATH" \ +QBT_E2E_LEECHER_CONFIG_PATH="$LEECHER_CONFIG_PATH" \ +QBT_E2E_SEEDER_DOWNLOADS_PATH="$SEEDER_DOWNLOADS_PATH" \ +QBT_E2E_LEECHER_DOWNLOADS_PATH="$LEECHER_DOWNLOADS_PATH" \ + docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" ps -a + +for service in qbittorrent-seeder qbittorrent-leecher; do + echo "Resolving port mapping for ${service}:8080" + QBT_E2E_TRACKER_IMAGE="$TRACKER_IMAGE" \ + QBT_E2E_QBITTORRENT_IMAGE="$QBITTORRENT_IMAGE" \ + QBT_E2E_TRACKER_CONFIG_PATH="$TRACKER_CONFIG_PATH" \ + QBT_E2E_TRACKER_STORAGE_PATH="$TRACKER_STORAGE_PATH" \ + QBT_E2E_SHARED_PATH="$SHARED_PATH" \ + QBT_E2E_SEEDER_CONFIG_PATH="$SEEDER_CONFIG_PATH" \ + QBT_E2E_LEECHER_CONFIG_PATH="$LEECHER_CONFIG_PATH" \ + QBT_E2E_SEEDER_DOWNLOADS_PATH="$SEEDER_DOWNLOADS_PATH" \ + QBT_E2E_LEECHER_DOWNLOADS_PATH="$LEECHER_DOWNLOADS_PATH" \ + docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" port "$service" 8080 + +done + +echo "Compose check completed successfully" +if [[ "$KEEP_STACK" -eq 1 ]]; then + echo "Stack kept running (project: $PROJECT_NAME)" +fi diff --git a/contrib/dev-tools/debugging/qbt/qbittorrent-login-probe.sh b/contrib/dev-tools/debugging/qbt/qbittorrent-login-probe.sh new file mode 100755 index 000000000..df60fc6a3 --- /dev/null +++ b/contrib/dev-tools/debugging/qbt/qbittorrent-login-probe.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE="lscr.io/linuxserver/qbittorrent:5.1.4" +CONTAINER_NAME="qbt-login-probe" +DEFAULT_PASSWORD="adminadmin" +KEEP_ARTIFACTS=0 +HOST_PORT="" + +usage() { + cat <<'EOF' +qBittorrent login probe utility. + +Starts an isolated qBittorrent container with an explicit /config mount, then +runs login probes against /api/v2/auth/login with different CSRF headers. + +Use this script when the WebUI does not load in a browser, login returns 401, +or you need to confirm how qBittorrent validates Host, Referer, and Origin. + +Usage: + qbittorrent-login-probe.sh [options] + +Options: + --image <image> qBittorrent image to run. + Default: lscr.io/linuxserver/qbittorrent:5.1.4 + --name <container> Container name. + Default: qbt-login-probe + --password <password> Password candidate to test. + Default: adminadmin + --host-port <port> Publish WebUI on a fixed host port. + Use 8080 for browser access. + --keep Keep container and temp directory for manual inspection. + -h, --help Show this help. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --image) + IMAGE="$2" + shift 2 + ;; + --name) + CONTAINER_NAME="$2" + shift 2 + ;; + --password) + DEFAULT_PASSWORD="$2" + shift 2 + ;; + --host-port) + HOST_PORT="$2" + shift 2 + ;; + --keep) + KEEP_ARTIFACTS=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +WORKDIR="$(mktemp -d /tmp/qbt-login-probe.XXXXXX)" +CONFIG_ROOT="$WORKDIR/config" +DOWNLOADS_DIR="$WORKDIR/downloads" + +cleanup() { + if [[ "$KEEP_ARTIFACTS" -eq 0 ]]; then + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + rm -rf "$WORKDIR" + else + echo "Keeping artifacts for inspection:" + echo " WORKDIR=$WORKDIR" + echo " CONTAINER=$CONTAINER_NAME" + fi +} +trap cleanup EXIT + +mkdir -p \ + "$CONFIG_ROOT/qBittorrent" \ + "$CONFIG_ROOT/qBittorrent/BT_backup" \ + "$CONFIG_ROOT/.cache/qBittorrent" \ + "$DOWNLOADS_DIR" + +cat > "$CONFIG_ROOT/qBittorrent/qBittorrent.conf" <<'EOF' +[BitTorrent] +Session\AddTorrentStopped=false +Session\DefaultSavePath=/downloads +Session\TempPath=/downloads/temp +[Preferences] +WebUI\LocalHostAuth=false +WebUI\Port=8080 +WebUI\Username=admin +WebUI\AuthSubnetWhitelistEnabled=true +WebUI\AuthSubnetWhitelist=0.0.0.0/0,::/0 +EOF + +docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + +PORT_MAPPING="0:8080" +if [[ -n "$HOST_PORT" ]]; then + PORT_MAPPING="${HOST_PORT}:8080" +fi + +docker run -d --rm \ + --name "$CONTAINER_NAME" \ + -e WEBUI_PORT=8080 \ + -e PUID=1000 \ + -e PGID=1000 \ + -e TZ=UTC \ + -e QBT_LEGAL_NOTICE=confirm \ + -v "$CONFIG_ROOT:/config" \ + -v "$DOWNLOADS_DIR:/downloads" \ + -p "$PORT_MAPPING" \ + "$IMAGE" >/dev/null + +for _ in $(seq 1 60); do + if docker port "$CONTAINER_NAME" 8080/tcp >/dev/null 2>&1; then + break + fi + sleep 1 +done + +HOST_PORT="$(docker port "$CONTAINER_NAME" 8080/tcp | awk -F: '{print $2}')" +BASE_URL="http://127.0.0.1:${HOST_PORT}" + +echo "Probe container: $CONTAINER_NAME" +echo "Image: $IMAGE" +echo "Base URL: $BASE_URL" +echo "Workdir: $WORKDIR" + +for _ in $(seq 1 60); do + if docker logs "$CONTAINER_NAME" 2>&1 | grep -q "WebUI will be started shortly\|A temporary password is provided for this session:"; then + break + fi + sleep 1 +done + +echo +echo "=== Container logs (tail) ===" +docker logs "$CONTAINER_NAME" 2>&1 | tail -60 + +TEMP_PASSWORD="$(docker logs "$CONTAINER_NAME" 2>&1 | sed -n 's/.*A temporary password is provided for this session:[[:space:]]*//p' | tail -1)" +PASSWORDS=("$DEFAULT_PASSWORD") +if [[ -n "$TEMP_PASSWORD" ]]; then + PASSWORDS+=("$TEMP_PASSWORD") +fi + +probe_login() { + local label="$1" + local password="$2" + shift 2 + local outfile + outfile="$(mktemp /tmp/qbt-probe-body.XXXXXX)" + + local status + status="$(curl -sS -o "$outfile" -w '%{http_code}' \ + -X POST "$BASE_URL/api/v2/auth/login" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + "$@" \ + --data "username=admin&password=${password}")" + + local body + body="$(cat "$outfile")" + rm -f "$outfile" + + echo "$label | password='${password}' | HTTP=${status} | body='${body}'" +} + +echo +echo "=== Login probes ===" +for password in "${PASSWORDS[@]}"; do + probe_login "no-referer" "$password" + probe_login "referer-base" "$password" -H "Referer: $BASE_URL" + probe_login "origin-base" "$password" -H "Origin: $BASE_URL" + probe_login "host+referer-localhost-8080" "$password" -H "Host: localhost:8080" -H "Referer: http://localhost:8080" + probe_login "host+origin-localhost-8080" "$password" -H "Host: localhost:8080" -H "Origin: http://localhost:8080" + probe_login "host+referer-127-8080" "$password" -H "Host: 127.0.0.1:8080" -H "Referer: http://127.0.0.1:8080" + probe_login "host+origin-127-8080" "$password" -H "Host: 127.0.0.1:8080" -H "Origin: http://127.0.0.1:8080" +done + +echo +echo "Done." diff --git a/contrib/dev-tools/git/hooks/pre-commit.sh b/contrib/dev-tools/git/hooks/pre-commit.sh index c1b183fde..b26bcdb1c 100755 --- a/contrib/dev-tools/git/hooks/pre-commit.sh +++ b/contrib/dev-tools/git/hooks/pre-commit.sh @@ -1,10 +1,83 @@ -#!/bin/bash - -cargo +nightly fmt --check && - cargo +nightly check --tests --benches --examples --workspace --all-targets --all-features && - cargo +nightly doc --no-deps --bins --examples --workspace --all-features && - cargo +nightly machete && - cargo +stable build && - CARGO_INCREMENTAL=0 cargo +stable clippy --no-deps --tests --benches --examples --workspace --all-targets --all-features -- -D clippy::correctness -D clippy::suspicious -D clippy::complexity -D clippy::perf -D clippy::style -D clippy::pedantic && - cargo +stable test --doc --workspace && - cargo +stable test --tests --benches --examples --workspace --all-targets --all-features +#!/usr/bin/env bash +# Pre-commit verification script +# Run all mandatory checks before committing changes. +# +# Usage: +# ./contrib/dev-tools/git/hooks/pre-commit.sh +# +# Expected runtime: ~3 minutes on a modern developer machine. +# AI agents: set a per-command timeout of at least 5 minutes before invoking this script. +# +# All steps must pass (exit 0) before committing. + +set -euo pipefail + +# ============================================================================ +# STEPS +# ============================================================================ +# Each step: "description|success_message|command" + +declare -a STEPS=( + "Checking for unused dependencies (cargo machete)|No unused dependencies found|cargo machete" + "Running all linters|All linters passed|linter all" + "Running documentation tests|Documentation tests passed|cargo test --doc --workspace" + "Running all tests|All tests passed|cargo test --tests --benches --examples --workspace --all-targets --all-features" +) + +# ============================================================================ +# HELPER FUNCTIONS +# ============================================================================ + +format_time() { + local total_seconds=$1 + local minutes=$((total_seconds / 60)) + local seconds=$((total_seconds % 60)) + if [ "$minutes" -gt 0 ]; then + echo "${minutes}m ${seconds}s" + else + echo "${seconds}s" + fi +} + +run_step() { + local step_number=$1 + local total_steps=$2 + local description=$3 + local success_message=$4 + local command=$5 + + echo "[Step ${step_number}/${total_steps}] ${description}..." + + local step_start=$SECONDS + local -a cmd_array + read -ra cmd_array <<< "${command}" + "${cmd_array[@]}" + local step_elapsed=$((SECONDS - step_start)) + + echo "PASSED: ${success_message} ($(format_time "${step_elapsed}"))" + echo +} + +trap 'echo ""; echo "=========================================="; echo "FAILED: Pre-commit checks failed!"; echo "Fix the errors above before committing."; echo "=========================================="; exit 1' ERR + +# ============================================================================ +# MAIN +# ============================================================================ + +TOTAL_START=$SECONDS +TOTAL_STEPS=${#STEPS[@]} + +echo "Running pre-commit checks..." +echo + +for i in "${!STEPS[@]}"; do + IFS='|' read -r description success_message command <<< "${STEPS[$i]}" + run_step $((i + 1)) "${TOTAL_STEPS}" "${description}" "${success_message}" "${command}" +done + +TOTAL_ELAPSED=$((SECONDS - TOTAL_START)) +echo "==========================================" +echo "SUCCESS: All pre-commit checks passed! ($(format_time "${TOTAL_ELAPSED}"))" +echo "==========================================" +echo +echo "You can now safely stage and commit your changes." diff --git a/contrib/dev-tools/git/hooks/pre-push.sh b/contrib/dev-tools/git/hooks/pre-push.sh index 593068cee..f03c6d5cd 100755 --- a/contrib/dev-tools/git/hooks/pre-push.sh +++ b/contrib/dev-tools/git/hooks/pre-push.sh @@ -1,11 +1,88 @@ -#!/bin/bash - -cargo +nightly fmt --check && - cargo +nightly check --tests --benches --examples --workspace --all-targets --all-features && - cargo +nightly doc --no-deps --bins --examples --workspace --all-features && - cargo +nightly machete && - cargo +stable build && - CARGO_INCREMENTAL=0 cargo +stable clippy --no-deps --tests --benches --examples --workspace --all-targets --all-features -- -D clippy::correctness -D clippy::suspicious -D clippy::complexity -D clippy::perf -D clippy::style -D clippy::pedantic && - cargo +stable test --doc --workspace && - cargo +stable test --tests --benches --examples --workspace --all-targets --all-features && - cargo +stable run --bin e2e_tests_runner -- --config-toml-path "./share/default/config/tracker.e2e.container.sqlite3.toml" +#!/usr/bin/env bash +# Pre-push verification script +# Run comprehensive checks before pushing changes, including nightly toolchain +# validation and end-to-end tests. +# +# Usage: +# ./contrib/dev-tools/git/hooks/pre-push.sh +# +# Expected runtime: ~15 minutes on a modern developer machine. +# AI agents: set a per-command timeout of at least 30 minutes before invoking this script. +# +# All steps must pass (exit 0) before pushing. + +set -euo pipefail + +# ============================================================================ +# STEPS +# ============================================================================ +# Each step: "description|success_message|command" + +declare -a STEPS=( + "Checking for unused dependencies (cargo machete)|No unused dependencies found|cargo +stable machete" + "Running all linters|All linters passed|linter all" + "Checking format with nightly toolchain|Nightly format check passed|cargo +nightly fmt --check" + "Checking workspace with nightly toolchain|Nightly check passed|cargo +nightly check --tests --benches --examples --workspace --all-targets --all-features" + "Building documentation with nightly toolchain|Nightly documentation built|cargo +nightly doc --no-deps --bins --examples --workspace --all-features" + "Running documentation tests|Documentation tests passed|cargo +stable test --doc --workspace" + "Running all tests|All tests passed|cargo +stable test --tests --benches --examples --workspace --all-targets --all-features" + "Running E2E tests|E2E tests passed|cargo +stable run --bin e2e_tests_runner -- --config-toml-path ./share/default/config/tracker.e2e.container.sqlite3.toml" +) + +# ============================================================================ +# HELPER FUNCTIONS +# ============================================================================ + +format_time() { + local total_seconds=$1 + local minutes=$((total_seconds / 60)) + local seconds=$((total_seconds % 60)) + if [ "$minutes" -gt 0 ]; then + echo "${minutes}m ${seconds}s" + else + echo "${seconds}s" + fi +} + +run_step() { + local step_number=$1 + local total_steps=$2 + local description=$3 + local success_message=$4 + local command=$5 + + echo "[Step ${step_number}/${total_steps}] ${description}..." + + local step_start=$SECONDS + local -a cmd_array + read -ra cmd_array <<< "${command}" + "${cmd_array[@]}" + local step_elapsed=$((SECONDS - step_start)) + + echo "PASSED: ${success_message} ($(format_time "${step_elapsed}"))" + echo +} + +trap 'echo ""; echo "=========================================="; echo "FAILED: Pre-push checks failed!"; echo "Fix the errors above before pushing."; echo "=========================================="; exit 1' ERR + +# ============================================================================ +# MAIN +# ============================================================================ + +TOTAL_START=$SECONDS +TOTAL_STEPS=${#STEPS[@]} + +echo "Running pre-push checks..." +echo + +for i in "${!STEPS[@]}"; do + IFS='|' read -r description success_message command <<< "${STEPS[$i]}" + run_step $((i + 1)) "${TOTAL_STEPS}" "${description}" "${success_message}" "${command}" +done + +TOTAL_ELAPSED=$((SECONDS - TOTAL_START)) +echo "==========================================" +echo "SUCCESS: All pre-push checks passed! ($(format_time "${TOTAL_ELAPSED}"))" +echo "==========================================" +echo +echo "You can now safely push your changes." diff --git a/contrib/dev-tools/git/install-git-hooks.sh b/contrib/dev-tools/git/install-git-hooks.sh new file mode 100755 index 000000000..16de7fe5a --- /dev/null +++ b/contrib/dev-tools/git/install-git-hooks.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Install project Git hooks from .githooks/ into .git/hooks/. +# +# Usage: +# ./contrib/dev-tools/git/install-git-hooks.sh +# +# Run once after cloning the repository. Re-run to update hooks after +# they change. + +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +HOOKS_SRC="${REPO_ROOT}/.githooks" +HOOKS_DST="$(git rev-parse --git-path hooks)" +mkdir -p "${HOOKS_DST}" + +if [ ! -d "${HOOKS_SRC}" ]; then + echo "ERROR: .githooks/ directory not found at ${HOOKS_SRC}" + exit 1 +fi + +installed=0 + +for hook in "${HOOKS_SRC}"/*; do + hook_name="$(basename "${hook}")" + dest="${HOOKS_DST}/${hook_name}" + + cp "${hook}" "${dest}" + chmod +x "${dest}" + + echo "Installed: ${hook_name} → .git/hooks/${hook_name}" + installed=$((installed + 1)) +done + +echo "" +echo "==========================================" +echo "SUCCESS: ${installed} hook(s) installed." +echo "==========================================" diff --git a/contrib/dev-tools/su-exec/README.md b/contrib/dev-tools/su-exec/README.md index 2b0517377..1dd4108ac 100644 --- a/contrib/dev-tools/su-exec/README.md +++ b/contrib/dev-tools/su-exec/README.md @@ -1,4 +1,5 @@ # su-exec + switch user and group id, setgroups and exec ## Purpose @@ -21,7 +22,7 @@ name separated with colon (e.g. `nobody:ftp`). Numeric uid/gid values can be used instead of names. Example: ```shell -$ su-exec apache:1000 /usr/sbin/httpd -f /opt/www/httpd.conf +su-exec apache:1000 /usr/sbin/httpd -f /opt/www/httpd.conf ``` ## TTY & parent/child handling @@ -43,4 +44,3 @@ PID USER TIME COMMAND This does more or less exactly the same thing as [gosu](https://github.com/tianon/gosu) but it is only 10kb instead of 1.8MB. - diff --git a/cspell.json b/cspell.json new file mode 100644 index 000000000..876291c36 --- /dev/null +++ b/cspell.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", + "version": "0.2", + "dictionaryDefinitions": [ + { + "name": "project-words", + "path": "./project-words.txt", + "addWords": true + } + ], + "dictionaries": [ + "project-words" + ], + "enableFiletypes": [ + "dockerfile", + "shellscript", + "toml" + ], + "ignorePaths": [ + "target", + "docs/media/*.svg", + "contrib/bencode/benches/*.bencode", + "contrib/dev-tools/su-exec/**", + "packages/tracker-core/docs/benchmarking/machine/*.txt", + ".github/labels.json", + "/project-words.txt", + "repomix-output.xml", + "TEMP-*.md" + ] +} \ No newline at end of file diff --git a/docs/adrs/20260420200013_adopt_custom_github_copilot_aligned_agent_framework.md b/docs/adrs/20260420200013_adopt_custom_github_copilot_aligned_agent_framework.md new file mode 100644 index 000000000..556e131fb --- /dev/null +++ b/docs/adrs/20260420200013_adopt_custom_github_copilot_aligned_agent_framework.md @@ -0,0 +1,86 @@ +# Adopt a Custom, GitHub-Copilot-Aligned Agent Framework + +## Description + +As AI coding agents become a more common part of the development workflow, the project needs a +clear strategy for how agents should interact with the codebase. Several third-party "agent +frameworks" exist that promise to give agents structure and purpose, but they each come with +trade-offs that may not fit the tracker's needs. + +This ADR records the decision to build a lightweight, first-party agent framework using the +open standards that GitHub Copilot already supports natively: `AGENTS.md`, Agent Skills, and +Custom Agent profiles. + +## Agreement + +We adopt a custom, GitHub-Copilot-aligned agent framework consisting of: + +- **`AGENTS.md`** at the repository root (and in key subdirectories) — following the + [agents.md](https://agents.md/) open standard stewarded by the Agentic AI Foundation under the + Linux Foundation. Provides AI coding agents with project context, build steps, test commands, + conventions, and essential rules. +- **Agent Skills** under `.github/skills/` — following the + [Agent Skills specification](https://agentskills.io/specification). Each skill is a directory + containing a `SKILL.md` file with YAML frontmatter and Markdown instructions, covering + repeatable tasks such as committing changes, running linters, creating ADRs, or setting up the + development environment. +- **Custom Agent profiles** under `.github/agents/` — Markdown files with YAML frontmatter + defining specialised Copilot agents (e.g. `committer`, `implementer`, `complexity-auditor`) + that can be invoked directly or as subagents. +- **`copilot-setup-steps.yml`** workflow — prepares the GitHub Copilot cloud agent environment + before it starts working on any task. + +### Alternatives Considered + +**[obra/superpowers](https://github.com/obra/superpowers)** + +A framework that adds "superpowers" to coding agents through a set of conventions and tools. +Not adopted for the following reasons: + +1. **Complexity mismatch** — introduces abstractions heavier than what tracker development needs. +1. **Precision requirements** — the tracker involves low-level Rust programming where agent work + must be reviewed carefully; generic productivity frameworks are not designed for that + constraint. +1. **Tooling churn risk** — depending on a third-party framework risks forced refactoring if + that framework is deprecated or pivots. + +**[gsd-build/get-shit-done](https://github.com/gsd-build/get-shit-done)** + +A productivity-oriented agent framework with opinionated workflows. +Not adopted for the same reasons as above, plus: + +1. **GitHub-first ecosystem** — the tracker is hosted on GitHub and makes intensive use of + GitHub resources (Actions, Copilot, MCP tools). Staying aligned with GitHub Copilot avoids + unnecessary integration friction. + +### Why the Custom Approach + +1. **Tailored fit** — shaped precisely to Torrust conventions, commit style, linting gates, and + package structure from day one. +1. **Proven in practice** — the same approach has already been validated during the development + of `torrust-tracker-deployer`. +1. **Agent-agnostic by design** — expressed as plain Markdown files (`AGENTS.md`, `SKILL.md`, + agent profiles), decoupled from any single agent product. Migration or multi-agent use is + straightforward. +1. **Incremental adoption** — individual skills, custom agents, or patterns from evaluated + frameworks can still be cherry-picked and integrated progressively if specific value is + identified. +1. **Stability** — a first-party approach is more stable than depending on a third-party + framework whose roadmap we do not control. + +## Date + +2026-04-20 + +## References + +- Issue: https://github.com/torrust/torrust-tracker/issues/1697 +- PR: https://github.com/torrust/torrust-tracker/pull/1699 +- AGENTS.md specification: https://agents.md/ +- Agent Skills specification: https://agentskills.io/specification +- GitHub Copilot — About agent skills: https://docs.github.com/en/copilot/concepts/agents/about-agent-skills +- GitHub Copilot — About custom agents: https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-custom-agents +- Customize the Copilot cloud agent environment: https://docs.github.com/en/copilot/how-tos/use-copilot-agents/cloud-agent/customize-the-agent-environment +- obra/superpowers: https://github.com/obra/superpowers +- gsd-build/get-shit-done: https://github.com/gsd-build/get-shit-done +- torrust-tracker-deployer (validated reference implementation): https://github.com/torrust/torrust-tracker-deployer diff --git a/docs/adrs/20260429000000_keep_database_as_aggregate_supertrait.md b/docs/adrs/20260429000000_keep_database_as_aggregate_supertrait.md new file mode 100644 index 000000000..b6c606534 --- /dev/null +++ b/docs/adrs/20260429000000_keep_database_as_aggregate_supertrait.md @@ -0,0 +1,112 @@ +# Keep `Database` as an Aggregate Supertrait + +## Description + +The persistence layer used a single monolithic `Database` trait with 18 methods +spanning four distinct concerns: schema lifecycle, torrent metrics, whitelist +management, and authentication keys. Consumers that only needed one concern +(e.g. `DatabaseKeyRepository`) were forced to depend on the full 18-method +interface, making tests harder to write and clouding the intent of each service. + +The question was how to split the trait while preserving a single, discoverable +contract that all database drivers must satisfy. + +## Agreement + +Split `Database` into four narrow context traits: + +- `SchemaMigrator` — `create_database_tables`, `drop_database_tables` +- `TorrentMetricsStore` — load/save/increase per-torrent and global download counters (7 methods) +- `WhitelistStore` — load/get/add/remove infohash whitelist entries (4 required + 1 default method) +- `AuthKeyStore` — load/get/add/remove authentication keys (4 methods) + +Keep `Database` as an **empty aggregate supertrait** with a blanket implementation: + +```rust +pub trait Database: Sync + Send + SchemaMigrator + TorrentMetricsStore + WhitelistStore + AuthKeyStore {} + +impl<T> Database for T where T: Sync + Send + SchemaMigrator + TorrentMetricsStore + WhitelistStore + AuthKeyStore {} +``` + +`Database` is a **private, internal compile-time contract** for driver +completeness only. External consumers (services, repositories, tests) will +progress toward using only the narrow traits they actually need. That migration +happens in future subissues and does not require changing any consumer in this +step. + +### Alternatives Considered + +**Independent traits only (no `Database` supertrait)** — Each driver would +implement four separate traits; consumers would receive `Arc<Box<dyn AuthKeyStore>>` +etc. instead of `Arc<Box<dyn Database>>`. + +Rejected because: + +1. There would be no single place to verify that a driver implements the + complete persistence contract — the compiler can no longer catch a partially + implemented driver as one unit. +2. Changing every call site (container wiring, factory, tests) all at once + would turn this structural step into a much larger, riskier diff. The + aggregate supertrait lets the split land cleanly first; consumer migration + follows in subsequent subissues. + +Note on trait-object upcasting: migrating consumers to narrow traits does **not** +require upcasting (`dyn Database` → `dyn WhitelistStore`). The factory will +construct the concrete driver type (e.g. `Arc<Sqlite>`) and coerce it directly +into each narrow trait object (`Arc<dyn WhitelistStore>`, etc.). Coercion from +a sized type to a trait object is available on all Rust versions; upcasting +between two trait objects would be a different story, but is not needed here. + +### Consequences + +#### Positive + +- Each narrow trait expresses a single context; services and tests can depend + only on the interface they actually need. +- `#[automock]` on each narrow trait generates focused mocks (`MockAuthKeyStore` + etc.) instead of one 18-method mega-mock. +- The blanket impl makes it impossible to partially implement `Database`: + the compiler enforces completeness of all four narrow traits together. + +### Negative + +- Tests that previously used `MockDatabase` must be updated to use the + appropriate narrow mock (`MockWhitelistStore`, `MockAuthKeyStore`, etc.). + This is actually simpler — each mock covers only the methods the test cares + about — but it is a mechanical change across test files. +- `Database` will persist as long as `Arc<Box<dyn Database>>` wiring exists. + That wiring will be replaced in subissue #1525-04b + ([docs/issues/1715-1525-04b-migrate-consumers-to-narrow-traits.md](../issues/1715-1525-04b-migrate-consumers-to-narrow-traits.md)) + by a plain `DatabaseStores` struct (one `Arc<dyn XxxStore>` field per + context). `TrackerCoreContainer` will hold `DatabaseStores` instead of + `Arc<Box<dyn Database>>`; each service is wired at construction time by + passing only the narrow store it needs. At that point `Database` can be + made fully private or removed. + +### Clarification And Revisit Criteria + +For now, `TorrentMetricsStore` keeps both per-torrent downloads (stored in +`torrents`) and the global aggregate metric `TORRENTS_DOWNLOADS_TOTAL` +(stored in `torrent_aggregate_metrics`). This is intentional: in the current +domain model there is only one persisted per-torrent metric and one persisted +global metric, and they are strongly related. + +There is no near-term plan to add more tables, fields, or persisted objects in +this area. Therefore, introducing another split (for example, +`TorrentAggregateMetricStore`) is deferred to avoid extra API churn without +clear short-term benefit. + +This decision should be reconsidered if persistence scope changes, especially +if aggregate metrics grow and are no longer torrent-specific (for example, +global tracker metrics such as total unique peers that ever announced), or if +method count/responsibility in `TorrentMetricsStore` increases materially. + +## Date + +2026-04-29 + +## References + +- Issue spec: [docs/issues/1713-1525-04-split-persistence-traits.md](../issues/1713-1525-04-split-persistence-traits.md) +- GitHub issue: <https://github.com/torrust/torrust-tracker/issues/1713> +- EPIC: [docs/issues/1525-overhaul-persistence.md](../issues/1525-overhaul-persistence.md) diff --git a/docs/adrs/README.md b/docs/adrs/README.md index 85986fc36..5fd40aa24 100644 --- a/docs/adrs/README.md +++ b/docs/adrs/README.md @@ -1,23 +1,32 @@ # Architectural Decision Records (ADRs) -This directory contains the architectural decision records (ADRs) for the -project. ADRs are a way to document the architectural decisions made in the -project. +This directory contains the architectural decision records (ADRs) for the project. +ADRs document architectural decisions — what was decided, why, and what alternatives +were considered. More info: <https://adr.github.io/>. -## How to add a new record +See [index.md](index.md) for the full list of ADRs. -For the prefix: +## How to Add a New ADR -```s +Generate the timestamp prefix (UTC): + +```shell date -u +"%Y%m%d%H%M%S" ``` -Then you can create a new markdown file with the following format: +Create a new Markdown file using the format `YYYYMMDDHHMMSS_snake_case_title.md`: -```s +```shell 20230510152112_title.md ``` -For the time being, we are not following any specific template. +Then add a row to the [Index](index.md) table. + +There is no rigid template. A typical ADR includes: + +- **Description** — the problem or context motivating the decision +- **Agreement** — what was decided and why +- **Date** — decision date (`YYYY-MM-DD`) +- **References** — related issues, PRs, external docs diff --git a/docs/adrs/index.md b/docs/adrs/index.md new file mode 100644 index 000000000..0b8e1c393 --- /dev/null +++ b/docs/adrs/index.md @@ -0,0 +1,7 @@ +# ADR Index + +| ADR | Date | Title | Short Description | +| --------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [20240227164834](20240227164834_use_plural_for_modules_containing_collections.md) | 2024-02-27 | Use plural for modules containing collections | Module names should use plural when they contain multiple types with the same responsibility (e.g. `requests/`, `responses/`). | +| [20260420200013](20260420200013_adopt_custom_github_copilot_aligned_agent_framework.md) | 2026-04-20 | Adopt a custom, GitHub-Copilot-aligned agent framework | Use AGENTS.md, Agent Skills, and Custom Agent profiles instead of third-party agent frameworks. | +| [20260429000000](20260429000000_keep_database_as_aggregate_supertrait.md) | 2026-04-29 | Keep `Database` as an aggregate supertrait | Split the 18-method monolithic `Database` trait into four narrow context traits (`SchemaMigrator`, `TorrentMetricsStore`, `WhitelistStore`, `AuthKeyStore`) while keeping `Database` as an empty aggregate supertrait with a blanket impl. | diff --git a/docs/containers.md b/docs/containers.md index cddd2ba98..a7754d8aa 100644 --- a/docs/containers.md +++ b/docs/containers.md @@ -248,6 +248,10 @@ driver = "mysql" path = "mysql://db_user:db_user_secret_password@mysql:3306/torrust_tracker" ``` +Important: if the MySQL password contains reserved URL characters (for example `+`, `/`, `@`, or `:`), it must be percent-encoded in the DSN password component. For example, if the raw password is `a+b/c`, use `a%2Bb%2Fc` in the DSN. + +When generating secrets automatically, prefer URL-safe passwords (`A-Z`, `a-z`, `0-9`, `-`, `_`) to avoid DSN parsing issues. + ### Build and Run: ```sh @@ -292,7 +296,7 @@ These are some useful commands for MySQL. Open a shell in the MySQL container using docker or docker-compose. ```s -docker exec -it torrust-mysql-1 /bin/bash +docker exec -it torrust-mysql-1 /bin/bash docker compose exec mysql /bin/bash ``` diff --git a/docs/issues/1525-06-introduce-schema-migrations.md b/docs/issues/1525-06-introduce-schema-migrations.md new file mode 100644 index 000000000..b04ab7942 --- /dev/null +++ b/docs/issues/1525-06-introduce-schema-migrations.md @@ -0,0 +1,429 @@ +# Subissue Draft for #1525-06: Introduce Schema Migrations + +## Goal + +Replace the raw DDL calls in the async drivers with `sqlx`'s versioned migration framework, +making schema evolution explicit, reproducible, and aligned across all SQL backends. + +## Why + +After subissue `1525-05` the drivers still manage their schema through hand-written +`CREATE TABLE IF NOT EXISTS ...` statements executed by `create_database_tables()`. That approach +has no history, no ordering guarantees, and no way to apply incremental schema changes safely to +an existing database. `sqlx::migrate!()` gives us versioned SQL files, automatic up-migration on +startup, and a `_sqlx_migrations` tracking table — a foundation required before PostgreSQL can +be added (subissue `1525-08`). + +## Proposed Branch + +- `1525-06-introduce-schema-migrations` + +## Background + +### Starting point + +By the time this subissue is implemented, subissue `1525-05` will have delivered async SQLite +and MySQL drivers backed by `sqlx`. Each driver has an `ensure_schema()` latch that calls +`create_database_tables()` lazily. That method currently issues raw `sqlx::query()` DDL. This +subissue replaces that raw DDL path with `sqlx::migrate!()`. + +There are already 3 migration files under `packages/tracker-core/migrations/` (both `sqlite/` +and `mysql/` subdirectories) that capture the schema history: + +```text +20240730183000_torrust_tracker_create_all_tables.sql +20240730183500_torrust_tracker_keys_valid_until_nullable.sql +20250527093000_torrust_tracker_new_torrent_aggregate_metrics_table.sql +``` + +These files were written for users to run manually. The tracker has never executed them +automatically. This subissue is the first time they are wired into the application startup path. + +### Current code behavior + +The current `create_database_tables()` method issues `CREATE TABLE IF NOT EXISTS` for all four +tables (`whitelist`, `torrents`, `torrent_aggregate_metrics`, `keys`) using hardcoded DDL that +already reflects the final schema state (nullable `valid_until`, all four tables present). The +current `drop_database_tables()` drops `whitelist`, `torrents`, and `keys` but **not** +`torrent_aggregate_metrics`, which leaks across test drop/create cycles. + +This gives two distinct behaviors today: + +- **New (empty) database**: all four tables are created in the final schema state — equivalent + to having run all three migrations in sequence. The database is immediately usable. +- **Existing database (no `_sqlx_migrations` table)**: `IF NOT EXISTS` silently skips tables + that already exist. Migration 2's `ALTER TABLE` (making `valid_until` nullable) never runs, + so an old `keys` table with `valid_until NOT NULL` stays broken. Migration 3's + `torrent_aggregate_metrics` table is created if absent (it did not exist before migration 3). + The user is expected to run the missing migrations manually, as documented in + `packages/tracker-core/migrations/README.md`. + +### How sqlx migrations work + +`sqlx::migrate!("path/to/migrations")` is a compile-time macro that embeds all `.sql` files +found under the given directory into the binary. At runtime, calling `MIGRATOR.run(&pool)` +applies any unapplied migrations in timestamp order and records them in the `_sqlx_migrations` +tracking table. Each migration is applied exactly once; on subsequent runs its checksum is +verified but it is not re-applied. Migrations are irreversible by default (no down migrations). + +The `macros` feature of `sqlx` is required for the `sqlx::migrate!()` macro. + +Because the migration files are embedded at compile time, the running binary carries all +migrations and does not need the `.sql` files on disk at runtime. No special deployment +packaging is required beyond distributing the binary. + +### Migration file layout + +```text +packages/tracker-core/migrations/ + sqlite/ + 20240730183000_torrust_tracker_create_all_tables.sql + 20240730183500_torrust_tracker_keys_valid_until_nullable.sql + 20250527093000_torrust_tracker_new_torrent_aggregate_metrics_table.sql + mysql/ + 20240730183000_torrust_tracker_create_all_tables.sql + 20240730183500_torrust_tracker_keys_valid_until_nullable.sql + 20250527093000_torrust_tracker_new_torrent_aggregate_metrics_table.sql + postgresql/ ← added in subissue 1525-08; see "PostgreSQL migration alignment" below + ... +``` + +Each backend has its own directory because SQL dialects differ. + +### History-alignment pattern + +All backends must have the **same set of migration filenames** with the same timestamps. When a +schema change is not needed for a specific backend (e.g., a column-type widening that the +backend's native type system already handles), the migration file still exists for that backend +but contains only a comment: + +```sql +-- This migration is intentionally a no-op for this backend. +-- The migration file exists to keep the version history aligned +-- with the other backends. +``` + +This keeps the `_sqlx_migrations` version history identical across backends, which simplifies +reasoning about compatibility and avoids gaps in the timestamp sequence. + +### PostgreSQL migration alignment + +When subissue `1525-08` adds the PostgreSQL driver, its migration directory must contain the +**same set of migration filenames** as SQLite and MySQL, starting from migration 1 — treating +PostgreSQL as if it existed in the project from the beginning. This keeps the +`_sqlx_migrations` version history identical across all three backends. + +Concretely, PostgreSQL's migration 1 creates the original schema (same initial table definitions +as SQLite and MySQL migration 1), and the subsequent migrations apply the same schema changes in +order. Any migration that is a no-op for PostgreSQL follows the history-alignment pattern +(comment-only file) rather than being omitted. + +This means no additional "catch-up" migration is needed when PostgreSQL is added: the full +history starts from migration 1, identical to the other backends. + +### Legacy upgrade path + +When a v4 tracker starts against a database that was managed by an older tracker version, the +`_sqlx_migrations` table will not yet exist. Calling `MIGRATOR.run(&pool)` blindly on such a +database would try to re-apply migration 1 (`CREATE TABLE IF NOT EXISTS ...`) which is harmless +for `whitelist` and `torrents`, but migration 2's `ALTER TABLE` would fail because the +columns it targets are already in their expected state (on a fully-updated old schema) or in an +inconsistent state (on a partially-updated one). + +**Decision: legacy bootstrap with a v4 upgrade pre-condition.** + +The v4 changelog requires that users running an older tracker must apply all three existing +manual migrations before upgrading to v4. Once that pre-condition is met, the driver can +safely detect the legacy state and bootstrap the tracking table automatically: + +1. If `_sqlx_migrations` does **not** exist and the schema tables (`whitelist`, `torrents`, + `keys`, `torrent_aggregate_metrics`) do exist → **legacy bootstrap path**: + - Create the `_sqlx_migrations` table (via `MIGRATOR.ensure_migrations_table(&pool)`). + - Insert fake-applied rows for the three pre-existing migrations (correct versions and + checksums from the embedded `MIGRATOR`), marking them as already executed. + - Call `MIGRATOR.run(&pool)` to apply any migrations added after those three. +2. If `_sqlx_migrations` exists → **normal path**: call `MIGRATOR.run(&pool)` directly; sqlx + skips already-applied migrations. +3. If no tables exist at all → **fresh database path**: `MIGRATOR.run(&pool)` creates + `_sqlx_migrations` and applies all migrations from scratch. + +This logic lives in a helper function called before `MIGRATOR.run(&pool)` inside +`create_database_tables()`. + +### Effect on `ensure_schema()` / `create_database_tables()` + +After this subissue, `SchemaMigrator::create_database_tables()` calls the legacy-bootstrap +helper and then `MIGRATOR.run(&pool)` instead of issuing raw DDL. `drop_database_tables()` +(used only in tests) must also drop the `_sqlx_migrations` and `torrent_aggregate_metrics` +tables (fixing the pre-existing omission) so that the drop/create cycle used in the test suite +works correctly. + +## Tasks + +### Task 1 — Verify existing migration files + +The three migration files already exist under `packages/tracker-core/migrations/`. Verify that +their SQL content is correct and consistent with the current schema produced by the hardcoded +DDL in `1525-05`. Do not change existing file timestamps or names. Fix content only if a +discrepancy is found. + +**Outcome**: all three migration files are verified correct; nothing else changes yet. + +### Task 2 — Enable `sqlx` `macros` feature and add `MIGRATOR` statics + +In `packages/tracker-core/Cargo.toml`, add the `macros` feature to the existing `sqlx` +dependency: + +```toml +sqlx = { version = "...", features = ["sqlite", "mysql", "macros", "runtime-tokio-native-tls"] } +``` + +In each driver file add a static migrator: + +```rust +use sqlx::migrate::Migrator; + +// SQLite driver +static MIGRATOR: Migrator = sqlx::migrate!("migrations/sqlite"); + +// MySQL driver +static MIGRATOR: Migrator = sqlx::migrate!("migrations/mysql"); +``` + +Add `Error::migration_error()` to `databases/error.rs` to wrap `sqlx::migrate::MigrateError`. + +**Outcome**: project compiles with migration statics defined but not yet called. + +### Task 3 — Wire migrations into `create_database_tables()` and `drop_database_tables()` + +#### Legacy bootstrap helper + +Add a private async helper function `bootstrap_legacy_schema` to each driver. This function +detects whether the database is in the legacy state (user-managed schema, no +`_sqlx_migrations` table) and, if so, fake-applies the three pre-existing migrations so that +`MIGRATOR.run()` can continue with only the new ones: + +```rust +async fn bootstrap_legacy_schema(pool: &Pool) -> Result<(), Error> { + // Check whether _sqlx_migrations already exists. + let migrations_table_exists: bool = /* backend-appropriate query */; + if migrations_table_exists { + return Ok(()); // normal path — nothing to do here + } + + // Check whether the legacy tables exist (whitelist is a reliable sentinel). + let legacy_tables_exist: bool = /* backend-appropriate query */; + if !legacy_tables_exist { + return Ok(()); // fresh database — MIGRATOR.run() will handle it + } + + // PRECONDITION GUARD: before fake-applying, verify that migration 2 (nullable + // valid_until) and migration 3 (torrent_aggregate_metrics table) were applied. + // If not, return a descriptive error rather than silently bootstrapping a broken schema. + // SQLite: use `PRAGMA table_info(keys)` and `sqlite_master`. + // MySQL: use `information_schema.columns` and `information_schema.tables`. + let migration_2_applied: bool = /* check keys.valid_until is nullable */; + let migration_3_applied: bool = /* check torrent_aggregate_metrics table exists */; + if !migration_2_applied || !migration_3_applied { + return Err(Error::migration_error( + DRIVER, + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Legacy database is not fully migrated. Apply all three manual migrations \ + listed in packages/tracker-core/migrations/README.md before upgrading to v4.", + ), + )); + } + + // PRECONDITION: all three manual migrations have been verified as applied: + // (1) whitelist/torrents/keys tables exist (whitelist sentinel check above) + // (2) keys.valid_until is nullable (verified above) + // (3) torrent_aggregate_metrics table exists (verified above) + // The v4 upgrade guide requires the user to have applied all three manual migrations + // before upgrading to v4. + MIGRATOR + .ensure_migrations_table(pool) + .await + .map_err(|e| Error::migration_error(DRIVER, e))?; + for migration in MIGRATOR.iter() { + if migration.version <= 20_250_527_093_000 { + // sqlx 0.8 does not expose a public `apply_fake()` API on `Migrator`. + // Fake-apply by inserting directly into `_sqlx_migrations`. The `checksum` + // field MUST equal the value embedded in the compiled binary (from + // `migration.checksum`) so that subsequent `MIGRATOR.run()` calls pass the + // checksum-verification step and do not raise a mismatch error. + // + // The INSERT uses `?` placeholders, valid for both SQLite and MySQL (this + // function lives in the driver-specific file, not in shared code). + sqlx::query( + "INSERT INTO _sqlx_migrations \ + (version, description, installed_on, success, checksum, execution_time) \ + VALUES (?, ?, CURRENT_TIMESTAMP, TRUE, ?, 0)", + ) + .bind(migration.version) + .bind(migration.description.as_ref()) + .bind(migration.checksum.as_ref()) + .execute(pool) + .await + .map_err(|e| Error::migration_error(DRIVER, e))?; + } + } + Ok(()) +} +``` + +#### Updated `create_database_tables()` + +```rust +async fn create_database_tables(&self) -> Result<(), Error> { + bootstrap_legacy_schema(&self.pool).await?; + MIGRATOR.run(&self.pool).await.map_err(|e| Error::migration_error(DRIVER, e))?; + Ok(()) +} +``` + +#### Updated `drop_database_tables()` + +Fix the pre-existing omission: drop `torrent_aggregate_metrics` and `_sqlx_migrations` in +addition to the existing drops so that the test setup cycle (drop → create) works correctly. + +Use `DROP TABLE IF EXISTS` for all five drops. This matches the reference implementation and +is the safer choice for test teardown (avoids errors on a partially torn-down database). + +```rust +// Example using DROP TABLE IF EXISTS for all five drops: +sqlx::query("DROP TABLE IF EXISTS _sqlx_migrations").execute(&self.pool).await...?; +sqlx::query("DROP TABLE IF EXISTS torrent_aggregate_metrics").execute(&self.pool).await...?; +sqlx::query("DROP TABLE IF EXISTS whitelist").execute(&self.pool).await...?; +sqlx::query("DROP TABLE IF EXISTS torrents").execute(&self.pool).await...?; +sqlx::query("DROP TABLE IF EXISTS keys").execute(&self.pool).await...?; +``` + +#### Legacy bootstrap precondition guard + +The `bootstrap_legacy_schema()` helper must verify the critical schema elements before +fake-applying migrations. If any element is absent, it must return an error rather than +silently bootstrapping a broken schema. Add the precondition checks described in the code +block above (migration 2 nullable check and migration 3 table existence check) and document +the verified state with a comment: + +```rust +// PRECONDITION: all three manual migrations have been verified as applied: +// (1) whitelist/torrents/keys tables exist (whitelist sentinel check above) +// (2) keys.valid_until is nullable (verified above) +// (3) torrent_aggregate_metrics table exists (verified above) +// The v4 upgrade guide requires the user to have applied all three manual migrations +// before upgrading to v4. +``` + +#### Update `migrations/README.md` + +Update `packages/tracker-core/migrations/README.md` to replace the stale content (currently: +"We don't support automatic migrations yet") with accurate documentation covering: + +- Migrations are now applied automatically on startup via `sqlx::migrate!()`. +- The `_sqlx_migrations` table tracks which migrations have run. +- To add a new migration: create a `.sql` file with the next timestamp in all applicable backend + directories, following the history-alignment pattern. +- v4 upgrade requirement: users on a pre-v4 tracker must apply all three manual migrations before + upgrading to v4. The automatic bootstrap handles the rest. +- **Migration file immutability**: once a migration file has been deployed, it must never be + modified. `sqlx` records each migration's checksum in `_sqlx_migrations`; editing a committed + migration file causes a checksum-mismatch error on the next startup for any database that has + already applied that migration. + +The `ensure_schema()` latch remains in place — it now guards the +`bootstrap_legacy_schema()` + `MIGRATOR.run()` sequence. + +**Outcome**: `cargo test --workspace --all-targets` passes. Schema is owned by migration files. +The README accurately reflects the new automatic migration behavior. + +### Task 4 — Validate migration behavior + +Add or extend tests that verify: + +- **Fresh database**: a single `create_database_tables()` call runs all migrations and + leaves the database in the correct final schema state. +- **Idempotency**: calling `create_database_tables()` a second time on an already-migrated + database is a no-op (all migrations already recorded in `_sqlx_migrations`). +- **Drop/create cycle**: `drop_database_tables()` followed by `create_database_tables()` + produces a clean schema (all tables including `_sqlx_migrations` and + `torrent_aggregate_metrics` are dropped and recreated). +- **Legacy bootstrap**: a database that has the pre-existing three tables (created without + `_sqlx_migrations`) is correctly bootstrapped — `_sqlx_migrations` is created, the three + migrations are marked fake-applied, and any new migrations are applied. +- **Partial-migration guard**: a database that has the schema tables but is missing + `torrent_aggregate_metrics` (migration 3 not applied) must cause `bootstrap_legacy_schema()` + to return an error, not silently proceed. + +These tests can live alongside the existing behavioral tests in the driver `#[cfg(test)]` +modules. + +## Out of Scope + +- PostgreSQL migration files — those are added in subissue `1525-08`. The + [PostgreSQL migration alignment](#postgresql-migration-alignment) section above specifies + the history-alignment requirement: PostgreSQL must start from migration 1 (not a catch-up + migration) to keep version history identical across all backends. +- Down migrations (rollback) — not needed at this stage. +- Handling legacy databases where not all three manual migrations were applied — the v4 + changelog must state that all three migrations must be applied before upgrading to v4. + The legacy bootstrap path verifies this precondition and returns an error if it is not met + (see the precondition guard above). +- **Migration file integrity check in CI** — `sqlx migrate check` (or an equivalent + step that connects to a fresh database and verifies checksums) can detect if a deployed + migration file has been edited after deployment. This requires a live database in CI and + is a follow-up improvement. It is out of scope here but worth adding once a database + service is reliably available in the CI pipeline (e.g., after subissue `1525-08` wires in + the PostgreSQL service). + +## Acceptance Criteria + +- [ ] The three existing migration files under `migrations/sqlite/` and `migrations/mysql/` are + confirmed correct and match the final schema produced by the hardcoded DDL in `1525-05`. +- [ ] `sqlx::migrate!()` (`macros` feature) is used in both drivers; no raw DDL remains in + `create_database_tables()`. +- [ ] `drop_database_tables()` drops `_sqlx_migrations` **and** `torrent_aggregate_metrics` + (fixing the pre-existing omission) so the test cycle works. All five drops use + `DROP TABLE IF EXISTS`. +- [ ] `bootstrap_legacy_schema()` verifies that migrations 2 and 3 were applied before + fake-applying, and returns a descriptive error if the precondition is not met. +- [ ] `Error::migration_error()` wraps `sqlx::migrate::MigrateError`. +- [ ] `packages/tracker-core/migrations/README.md` is updated to document automatic migration + behavior and the v4 upgrade requirement. +- [ ] Guidance for `1525-08`: PostgreSQL migration files start from migration 1 following the + history-alignment pattern, with the same filenames/timestamps as SQLite and MySQL. +- [ ] Legacy bootstrap: a database with the pre-existing tables but no `_sqlx_migrations` is + correctly detected; the three pre-existing migrations are fake-applied; new migrations + run normally. +- [ ] Fresh database: `create_database_tables()` runs all migrations from scratch via + `MIGRATOR.run()`. +- [ ] Migration idempotency is verified by tests (second call is a no-op). +- [ ] Drop/create cycle is verified by tests (all tables cleaned up and recreated). +- [ ] Legacy bootstrap scenario is verified by a test (fully-migrated legacy database is + bootstrapped correctly). +- [ ] Partial-migration guard is verified by a test (database missing `torrent_aggregate_metrics` + causes an error rather than silent bootstrap). +- [ ] Existing behavioral tests continue to pass. +- [ ] The v4 changelog or upgrade guide documents the pre-upgrade requirement: apply all three + manual migrations before upgrading to v4. +- [ ] Persistence benchmarking (see subissue `1525-03`) shows no regression against the committed + baseline. +- [ ] `cargo test --workspace --all-targets` passes. +- [ ] `linter all` exits with code `0`. + +## References + +- EPIC: `#1525` +- Subissue `1525-05`: `docs/issues/1525-05-migrate-sqlite-and-mysql-to-sqlx.md` — must be + completed first +- Subissue `1525-03`: `docs/issues/1525-03-persistence-benchmarking.md` — benchmark baseline +- Reference PR: `#1695` +- Reference implementation branch: `josecelano:pr-1684-review` — see EPIC for checkout + instructions (`docs/issues/1525-overhaul-persistence.md`) +- Reference files (migration files and driver wiring): + - `packages/tracker-core/migrations/sqlite/` + - `packages/tracker-core/migrations/mysql/` + - `packages/tracker-core/src/databases/driver/sqlite.rs` + - `packages/tracker-core/src/databases/driver/mysql.rs` +- Existing migration README: `packages/tracker-core/migrations/README.md` diff --git a/docs/issues/1525-07-align-rust-and-db-types.md b/docs/issues/1525-07-align-rust-and-db-types.md new file mode 100644 index 000000000..9b869af34 --- /dev/null +++ b/docs/issues/1525-07-align-rust-and-db-types.md @@ -0,0 +1,228 @@ +# Subissue 1525-07: Align Rust and Database Types + +## Goal + +Widen the download-counter type in Rust from `u32` to `u64` and widen the corresponding +database columns from `INTEGER` (32-bit, MySQL) to `BIGINT` (64-bit), delivered as a versioned +`sqlx` migration so the change is explicit, testable, and tracked as a forward schema change. + +## Background + +### Current state + +By the time this subissue is implemented, subissue `1525-06` will have wired `sqlx::migrate!()` +into both drivers. The schema at that point contains: + +- `torrents.completed` — `INTEGER` in MySQL (32-bit signed, max ≈ 2.1 billion), `INTEGER` in + SQLite (storage is already 64-bit for any integer value). +- `torrent_aggregate_metrics.value` — same types as above. + +The Rust type alias is `NumberOfDownloads = u32` in +`packages/primitives/src/lib.rs`. The `SwarmMetadata.downloaded` field also uses this type. +The drivers read the column as `i64` (sqlx always returns integer columns as `i64`) and +immediately narrow-cast to `u32`. + +### Why this is a problem + +The MySQL `INT` column type is **signed 32-bit** (max 2,147,483,647). Writing a `u32` value +above that limit silently overflows or errors. Practically, the counter saturates at the same +point as the UDP scrape wire format (`completed` is `i32` in BEP 15), but the correct fix is +to widen the storage type rather than rely on implicit saturation in the driver. + +`u32::MAX` (4,294,967,295) is already higher than the `i32::MAX` wire limit, so protocol +saturation happens before storage overflow today. However, aligning storage to `BIGINT` and the +Rust type to `u64` makes the storage contract explicit and decoupled from any particular +protocol encoding. Future protocol changes or a direct-database query tool cannot accidentally +exceed a silently-constrained column. + +**Protocol encoding** (read-only, no changes needed in this subissue): + +- UDP scrape response (`i32` wire field): the existing conversion from `NumberOfDownloads` to + `i32` already saturates at `i32::MAX`. This remains unchanged. +- HTTP scrape response (bencoded `i64`): `bencode_download_count()` saturates at `i64::MAX`. + This remains unchanged. + +### Why migrations first (1525-06 before 1525-07) + +The column-widening change must be delivered as a versioned migration rather than an ad hoc DDL +update. Having the migration framework from `1525-06` in place ensures the change is tracked in +`_sqlx_migrations`, tested like any other migration, and can be reasoned about in production +upgrade scenarios. + +## Proposed Branch + +- `1525-07-align-rust-and-db-types` + +## What Changes + +### Migration files + +Add the fourth migration to both existing backends: + +```text +packages/tracker-core/migrations/sqlite/20260409120000_torrust_tracker_widen_download_counters.sql +packages/tracker-core/migrations/mysql/20260409120000_torrust_tracker_widen_download_counters.sql +``` + +**SQLite** — no-op (SQLite already stores any `INTEGER` value as a 64-bit signed integer): + +```sql +-- SQLite stores INTEGER values as signed 64-bit integers already. +-- This migration is intentionally a no-op so the migration history stays +-- aligned with the MySQL backend. +``` + +**MySQL** — widen both download-counter columns: + +```sql +ALTER TABLE torrents + MODIFY completed BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE torrent_aggregate_metrics + MODIFY value BIGINT NOT NULL DEFAULT 0; +``` + +PostgreSQL migration files are not created here. They will be added in subissue `1525-08` when +the PostgreSQL driver is introduced. Following the +[history-alignment pattern](1525-06-introduce-schema-migrations.md#history-alignment-pattern) +established in `1525-06`, subissue `1525-08` creates **all four** migration files for +PostgreSQL starting from migration 1. PostgreSQL's migration 1 creates the columns as +`INTEGER` (matching the original schema from the other backends), and migration 4 widens them +to `BIGINT` using PostgreSQL-specific `ALTER COLUMN ... TYPE BIGINT` syntax. Migration 4 is +not a no-op for PostgreSQL. + +### Rust type changes + +**`packages/primitives/src/lib.rs`** — widen the type alias: + +```rust +// Before +pub type NumberOfDownloads = u32; + +// After +pub type NumberOfDownloads = u64; +``` + +**`packages/primitives/src/swarm_metadata.rs`** — `downloaded` field currently uses the bare +`u32`. Update it to use `NumberOfDownloads` explicitly: + +```rust +// Before +pub downloaded: u32, + +// After +pub downloaded: NumberOfDownloads, +``` + +Also update the `downloads()` method return type to `NumberOfDownloads`. + +### Driver conversion changes + +After `1525-05`, the sqlx drivers read counter columns as `i64`. With `NumberOfDownloads = u32` +the read path does `u32::try_from(i64_value)`. After this subissue it becomes +`u64::try_from(i64_value)`. + +Because the database column type is `BIGINT` (signed), the **write path** must also encode +`u64 → i64`. Values above `i64::MAX` (≈ 9.2 × 10¹⁸) cannot be stored and must return an +error rather than silently truncate. Add named helper methods to each driver to make the +conversion explicit and consistent: + +```rust +fn decode_counter(value: i64) -> Result<NumberOfDownloads, Error> { + u64::try_from(value).map_err(|err| Error::invalid_query(DRIVER, err)) +} + +fn encode_counter(value: NumberOfDownloads) -> Result<i64, Error> { + i64::try_from(value).map_err(|err| Error::invalid_query(DRIVER, err)) +} +``` + +Use these helpers in every place a counter column is read from or written to the database. + +### Cascade compilation fixes + +Widening `NumberOfDownloads` from `u32` to `u64` will produce compilation errors wherever the +old `u32` range was assumed. Fix all errors; do not add `as u32` casts or `allow` attributes +to suppress them. + +## Tasks + +### Task 1 — Add migration files + +Create the two new migration files listed above. Do not modify any existing migration file. + +**Outcome**: `packages/tracker-core/migrations/` has four files in each of `sqlite/` and +`mysql/`. The fourth file is verified by running the migration against a fresh test database +of each type. + +### Task 2 — Widen `NumberOfDownloads` and fix cascade + +Change `NumberOfDownloads = u32 → u64` in `packages/primitives/src/lib.rs` and update +`SwarmMetadata.downloaded` to use the alias. Fix all resulting compilation errors across the +workspace (driver conversion logic, scrape response encoding, announce handler arithmetic, +etc.). + +Add `decode_counter` / `encode_counter` helpers to both driver files as described above. + +**Outcome**: `cargo build --workspace` succeeds with no warnings or errors. + +### Task 3 — Validate migration and type alignment + +Add or extend tests that verify: + +- **MySQL migration**: running the migration on a database with the pre-migration `INT` column + produces a `BIGINT` column, and writing and reading a value larger than `2^31 − 1` round-trips + correctly. +- **SQLite no-op**: the migration applies cleanly (recorded in `_sqlx_migrations`) and the + column already accepts large values. +- **Boundary encode**: writing a `u64` counter value of exactly `i64::MAX` succeeds; writing + `i64::MAX + 1` returns an appropriate error rather than panicking or wrapping. + +These tests extend the existing driver `#[cfg(test)]` modules. + +**Outcome**: `cargo test --workspace --all-targets` passes. + +## Out of Scope + +- PostgreSQL migration files — added in subissue `1525-08`. +- Down migrations (rollback) — not needed at this stage. +- Trait splitting or other structural refactoring. +- Other numeric types beyond `NumberOfDownloads` / download counters. + +## Acceptance Criteria + +- [ ] `packages/tracker-core/migrations/sqlite/20260409120000_torrust_tracker_widen_download_counters.sql` + exists and is a comment-only no-op. +- [ ] `packages/tracker-core/migrations/mysql/20260409120000_torrust_tracker_widen_download_counters.sql` + exists and widens `torrents.completed` and `torrent_aggregate_metrics.value` to `BIGINT`. +- [ ] `NumberOfDownloads = u64` in `packages/primitives/src/lib.rs`. +- [ ] `SwarmMetadata.downloaded` uses `NumberOfDownloads`; bare `u32` is removed from that field. +- [ ] Both driver files use explicit `decode_counter` / `encode_counter` helpers for all + counter-column reads and writes. +- [ ] `encode_counter` returns an error (not a panic, not silent truncation) for values + above `i64::MAX`. +- [ ] A test verifies round-trip of a value larger than `u32::MAX` for each backend. +- [ ] A test verifies the encode error path for values above `i64::MAX`. +- [ ] No `as u32` casts or compiler-suppression attributes introduced by this subissue. +- [ ] Persistence benchmarking (see subissue `1525-03`) shows no regression against the + committed baseline. +- [ ] `cargo test --workspace --all-targets` passes. +- [ ] `linter all` exits with code `0`. + +## References + +- EPIC: `#1525` +- Subissue `1525-06`: `docs/issues/1525-06-introduce-schema-migrations.md` — must be completed + first (provides the migration framework) +- Subissue `1525-08`: `docs/issues/1525-08-add-postgresql-driver.md` — adds PostgreSQL + migration files including the history-aligned no-op for this migration +- Subissue `1525-03`: `docs/issues/1525-03-persistence-benchmarking.md` — benchmark baseline +- Reference implementation branch: `josecelano:pr-1684-review` — see EPIC for checkout + instructions (`docs/issues/1525-overhaul-persistence.md`) +- Reference files: + - `packages/tracker-core/migrations/sqlite/20260409120000_torrust_tracker_widen_download_counters.sql` + - `packages/tracker-core/migrations/mysql/20260409120000_torrust_tracker_widen_download_counters.sql` + - `packages/primitives/src/lib.rs` (type alias change) + - `packages/primitives/src/swarm_metadata.rs` (field type change) + - `packages/tracker-core/src/databases/driver/sqlite.rs` (decode/encode helpers) + - `packages/tracker-core/src/databases/driver/mysql.rs` (decode/encode helpers) diff --git a/docs/issues/1525-08-add-postgresql-driver.md b/docs/issues/1525-08-add-postgresql-driver.md new file mode 100644 index 000000000..9eeedff98 --- /dev/null +++ b/docs/issues/1525-08-add-postgresql-driver.md @@ -0,0 +1,790 @@ +# Subissue 1525-08: Add PostgreSQL Driver + +## Goal + +Add PostgreSQL as a third production SQL backend by implementing an async `sqlx`-backed +driver, wiring it into the configuration and factory, creating all four migration files +(starting from migration 1, history-aligned with SQLite and MySQL), and extending the +existing QA harnesses so PostgreSQL receives the same test coverage as the other backends. + +## Why Last + +PostgreSQL is the feature goal of the EPIC, but adding it first would have meant building on +an ad hoc, sync, pre-migration foundation. By the time this subissue is implemented, the +persistence layer is async (`1525-05`), schema-managed (`1525-06`), and correctly typed +(`1525-07`). PostgreSQL can now land as a first-class backend with no special-casing. + +## Proposed Branch + +- `1525-08-add-postgresql-driver` + +## Background + +### Starting point + +By the time this subissue is implemented: + +- **1525-04** has split the monolithic `Database` trait into four narrow context traits + (`SchemaMigrator`, `TorrentMetricsStore`, `WhitelistStore`, `AuthKeyStore`) plus a blanket + `Database` aggregate supertrait. Both existing drivers (`Sqlite`, `Mysql`) satisfy `Database` + through the blanket impl. Consumers hold `Arc<Box<dyn Database>>`. + +- **1525-05** has moved SQLite and MySQL to async `sqlx` connection pools. `r2d2`, `r2d2_sqlite`, + `rusqlite`, and the `mysql` crate are gone. The `sqlx` dependency has `sqlite` and `mysql` + features but not yet `postgres`. + +- **1525-06** has replaced the raw DDL in `create_database_tables()` with `sqlx::migrate!()`. + Each driver has a `static MIGRATOR` pointing to its backend-specific migration directory and + a `bootstrap_legacy_schema()` helper for upgrading pre-v4 databases. Both backends have three + migration files. + +- **1525-07** has widened `NumberOfDownloads` from `u32` to `u64`, added a fourth migration to + SQLite and MySQL, and added `decode_counter`/`encode_counter` helpers to both drivers. The + migration file layout at the end of `1525-07` is: + + ```text + packages/tracker-core/migrations/ + sqlite/ + 20240730183000_torrust_tracker_create_all_tables.sql + 20240730183500_torrust_tracker_keys_valid_until_nullable.sql + 20250527093000_torrust_tracker_new_torrent_aggregate_metrics_table.sql + 20260409120000_torrust_tracker_widen_download_counters.sql + mysql/ + 20240730183000_torrust_tracker_create_all_tables.sql + 20240730183500_torrust_tracker_keys_valid_until_nullable.sql + 20250527093000_torrust_tracker_new_torrent_aggregate_metrics_table.sql + 20260409120000_torrust_tracker_widen_download_counters.sql + ``` + + No `postgresql/` directory exists yet. + +### Driver enum locations + +Two separate `Driver` enums exist and both must be extended: + +- **Configuration** — `packages/configuration/src/v2_0_0/database.rs`: user-facing config + file value. Holds `Sqlite3`, `MySQL`. Used by the tracker to select which driver to build. +- **Databases factory** — `packages/tracker-core/src/databases/driver/mod.rs`: internal + dispatch enum. Holds `Sqlite3`, `MySQL`. `build()` matches on this to construct the driver. + `databases/setup.rs` converts from the configuration enum to this internal enum. + +### No legacy bootstrap for PostgreSQL + +The `bootstrap_legacy_schema()` helper introduced in `1525-06` exists to upgrade databases +that were managed manually before v4. PostgreSQL was never supported before this subissue, so +no pre-existing PostgreSQL tracker databases exist. The PostgreSQL `create_database_tables()` +implementation skips the legacy bootstrap and calls `MIGRATOR.run()` directly. + +### Connection string format + +PostgreSQL uses the same `path` field as MySQL in the configuration — a single URL string: + +```toml +[core.database] +driver = "postgresql" +path = "postgresql://user:password@host:port/dbname" +``` + +The `mask_secrets()` function in the configuration package must be extended to parse and +redact the password from this URL, mirroring the existing MySQL URL masking logic. + +### Database pre-creation requirement + +Unlike SQLite (which creates its file on first connection), PostgreSQL requires the target +database to already exist before `sqlx` can connect. The `torrust_tracker` database referenced +in the connection URL must be created before the tracker starts: + +```sql +CREATE DATABASE torrust_tracker; +``` + +**Test containers**: the `PostgresConfiguration.database` field (`torrust_tracker_test` by +default) is passed as the `POSTGRES_DB` env var to the PostgreSQL container. The official +`postgres` Docker image creates this database automatically — no manual `CREATE DATABASE` +call is needed in test code. + +**Container config** (`tracker.container.postgresql.toml`): the URL points to +`postgresql://postgres:postgres@postgres:5432/torrust_tracker`. The accompanying compose file +or deployment guide must ensure the `torrust_tracker` database exists — either by setting +`POSTGRES_DB=torrust_tracker` on the PostgreSQL service, or by running a setup step before the +tracker starts. Without it, the tracker will exit on startup with a `sqlx` connection error +that does not clearly identify the missing database as the cause. + +## What Changes + +### Migration files + +Create a `postgresql/` directory under `packages/tracker-core/migrations/` with all four +migration files. The timestamps are shared with the SQLite and MySQL backends, keeping the +`_sqlx_migrations` version history identical across all three backends. Migration 4 is **not** +a no-op for PostgreSQL — PostgreSQL's migration 1 creates the columns as `INTEGER` (matching +the other backends at their migration-1 state), and migration 4 widens them to `BIGINT` using +PostgreSQL-specific `ALTER COLUMN` syntax. + +**`20240730183000_torrust_tracker_create_all_tables.sql`**: + +```sql +CREATE TABLE IF NOT EXISTS whitelist ( + id SERIAL PRIMARY KEY, + info_hash VARCHAR(40) NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS torrents ( + id SERIAL PRIMARY KEY, + info_hash VARCHAR(40) NOT NULL UNIQUE, + completed INTEGER DEFAULT 0 NOT NULL +); + +CREATE TABLE IF NOT EXISTS keys ( + id SERIAL PRIMARY KEY, + key VARCHAR(32) NOT NULL UNIQUE, + valid_until INTEGER NOT NULL +); +``` + +PostgreSQL differences from MySQL and SQLite: `SERIAL` instead of `AUTO_INCREMENT` or +`INTEGER PRIMARY KEY AUTOINCREMENT`; no backtick quoting; parameter placeholders are `$1`, +`$2`, … in DML queries (not `?`). + +**`20240730183500_torrust_tracker_keys_valid_until_nullable.sql`**: + +```sql +ALTER TABLE keys ALTER COLUMN valid_until DROP NOT NULL; +``` + +**`20250527093000_torrust_tracker_new_torrent_aggregate_metrics_table.sql`**: + +```sql +CREATE TABLE IF NOT EXISTS torrent_aggregate_metrics ( + id SERIAL PRIMARY KEY, + metric_name VARCHAR(50) NOT NULL UNIQUE, + value INTEGER DEFAULT 0 NOT NULL +); +``` + +**`20260409120000_torrust_tracker_widen_download_counters.sql`**: + +```sql +ALTER TABLE torrents + ALTER COLUMN completed TYPE BIGINT, + ALTER COLUMN completed SET DEFAULT 0, + ALTER COLUMN completed SET NOT NULL; + +ALTER TABLE torrent_aggregate_metrics + ALTER COLUMN value TYPE BIGINT, + ALTER COLUMN value SET DEFAULT 0, + ALTER COLUMN value SET NOT NULL; +``` + +### Configuration package + +In `packages/configuration/src/v2_0_0/database.rs`: + +- Add `PostgreSQL` variant to the `Driver` enum: + + ```rust + #[derive(Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Debug, Hash, Clone)] + #[serde(rename_all = "lowercase")] + pub enum Driver { + Sqlite3, + MySQL, + PostgreSQL, // new + } + ``` + +- Extend `mask_secrets()` to handle the PostgreSQL URL. MySQL and PostgreSQL both use a URL + `path`; the masking code can share a branch: + + ```rust + Driver::MySQL | Driver::PostgreSQL => { + let mut url = Url::parse(&self.path)?; + url.set_password(Some("***")).ok(); + self.path = url.to_string(); + } + ``` + +- Add a test: + + ```rust + fn it_should_allow_masking_the_postgresql_user_password() + ``` + +### `tracker-core` Cargo.toml + +Add `"postgres"` to the `sqlx` features list: + +```toml +sqlx = { version = "...", features = [ + "sqlite", "mysql", "postgres", "macros", "runtime-tokio-native-tls" +] } +``` + +### PostgreSQL driver + +New file: `packages/tracker-core/src/databases/driver/postgres.rs`. + +**Driver struct and constructor**: + +```rust +use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; +use sqlx::{ConnectOptions, PgPool, Row}; +use std::sync::atomic::{AtomicBool, Ordering}; +use tokio::sync::Mutex; + +const DRIVER: &str = "postgresql"; + +static MIGRATOR: Migrator = sqlx::migrate!("migrations/postgresql"); + +pub(crate) struct Postgres { + pool: PgPool, + schema_ready: AtomicBool, + schema_lock: Mutex<()>, +} + +impl Postgres { + pub fn new(db_path: &str) -> Result<Self, Error> { + let options = db_path + .parse::<PgConnectOptions>() + .map_err(|e| Error::connection_error(DRIVER, e))? + .disable_statement_logging(); + let pool = PgPoolOptions::new().connect_lazy_with(options); + Ok(Self { + pool, + schema_ready: AtomicBool::new(false), + schema_lock: Mutex::new(()), + }) + } +} +``` + +**Lazy migration latch** (same double-checked pattern as SQLite and MySQL): + +```rust +async fn ensure_schema(&self) -> Result<(), Error> { + if self.schema_ready.load(Ordering::Acquire) { + return Ok(()); + } + let _guard = self.schema_lock.lock().await; + if self.schema_ready.load(Ordering::Acquire) { + return Ok(()); + } + self.create_database_tables().await?; + self.schema_ready.store(true, Ordering::Release); + Ok(()) +} +``` + +**`SchemaMigrator` implementation**: + +`create_database_tables()` skips the legacy bootstrap (PostgreSQL has no pre-v4 databases) +and calls `MIGRATOR.run()` directly: + +```rust +async fn create_database_tables(&self) -> Result<(), Error> { + // PostgreSQL is a new backend — no legacy databases exist without _sqlx_migrations. + // MIGRATOR.run() always takes the fresh-database path. + MIGRATOR + .run(&self.pool) + .await + .map_err(|e| Error::migration_error(DRIVER, e))?; + Ok(()) +} +``` + +`drop_database_tables()` drops all five tables including `_sqlx_migrations` so the +drop/create cycle used in the test suite works correctly. Use `DROP TABLE IF EXISTS` +consistently for all drops, matching the style established in `1525-06`: + +```rust +async fn drop_database_tables(&self) -> Result<(), Error> { + sqlx::query("DROP TABLE IF EXISTS _sqlx_migrations") + .execute(&self.pool).await?; + sqlx::query("DROP TABLE IF EXISTS torrent_aggregate_metrics") + .execute(&self.pool).await?; + sqlx::query("DROP TABLE IF EXISTS whitelist") + .execute(&self.pool).await?; + sqlx::query("DROP TABLE IF EXISTS torrents") + .execute(&self.pool).await?; + sqlx::query("DROP TABLE IF EXISTS keys") + .execute(&self.pool).await?; + Ok(()) +} +``` + +**SQL syntax differences from SQLite and MySQL**: + +| Aspect | SQLite / MySQL | PostgreSQL | +| --------------------- | ----------------------------------------------------------------- | ---------------------------------------------------- | +| Parameter placeholder | `?` | `$1`, `$2`, … | +| Upsert | `ON DUPLICATE KEY UPDATE` (MySQL) or `INSERT OR REPLACE` (SQLite) | `ON CONFLICT (col) DO UPDATE SET col = EXCLUDED.col` | +| Auto-increment (DDL) | `AUTO_INCREMENT` / `AUTOINCREMENT` | `SERIAL` (in migration files only) | + +**Counter encode/decode helpers** (identical contract to SQLite and MySQL): + +```rust +fn decode_counter(value: i64) -> Result<NumberOfDownloads, Error> { + u64::try_from(value).map_err(|err| Error::invalid_query(DRIVER, err)) +} + +fn encode_counter(value: NumberOfDownloads) -> Result<i64, Error> { + i64::try_from(value).map_err(|err| Error::invalid_query(DRIVER, err)) +} +``` + +Use these helpers in every place a counter column is read from or written to the database. +Do not use bare `as i64` casts or `as u64` casts. + +**`TorrentMetricsStore`, `WhitelistStore`, `AuthKeyStore` implementations**: Follow the same +structure as the SQLite and MySQL drivers, substituting `$1`/`$2` placeholders and the +PostgreSQL upsert syntax. There are no behavior differences relative to the other backends. + +### Driver factory + +In `packages/tracker-core/src/databases/driver/mod.rs`: + +- Add `PostgreSQL` variant to the `Driver` enum. +- Add a `pub mod postgres;` declaration. +- Add a match arm in `build()`: + + ```rust + Driver::PostgreSQL => { + let backend = Postgres::new(db_path)?; + Ok(Arc::new(Box::new(backend) as Box<dyn Database>)) + } + ``` + +### Database setup + +In `packages/tracker-core/src/databases/setup.rs`, extend the configuration-to-internal +driver enum conversion: + +```rust +torrust_tracker_configuration::Driver::PostgreSQL => Driver::PostgreSQL, +``` + +### Default configuration file + +Add `share/default/config/tracker.container.postgresql.toml` modelled on the existing MySQL +container config. The PostgreSQL connection string points to a service named `postgres`: + +```toml +[core.database] +driver = "postgresql" +path = "postgresql://postgres:postgres@postgres:5432/torrust_tracker" +``` + +All other sections remain the same as the existing container configs. + +### Driver tests + +Add an inline `#[cfg(test)]` module in `postgres.rs`. The test is guarded by an environment +variable to avoid requiring a PostgreSQL container in every `cargo test` run. + +**Environment variables**: + +| Variable | Purpose | Default | +| ------------------------------------------------ | ------------------------------------------ | ------------------------- | +| `TORRUST_TRACKER_CORE_RUN_POSTGRES_DRIVER_TEST` | Enable the test (must be set to any value) | unset → test is skipped | +| `TORRUST_TRACKER_CORE_POSTGRES_DRIVER_URL` | Use an already-running PostgreSQL instance | unset → start a container | +| `TORRUST_TRACKER_CORE_POSTGRES_DRIVER_IMAGE` | PostgreSQL Docker image name | `postgres` | +| `TORRUST_TRACKER_CORE_POSTGRES_DRIVER_IMAGE_TAG` | PostgreSQL Docker image tag | `16` | + +**Test container defaults** (when no URL is provided): + +```text +internal port: 5432 +database: torrust_tracker_test +user: postgres +password: test +``` + +Start the container using `testcontainers::GenericImage` (already a dev-dependency from +MySQL tests). Set container env vars `POSTGRES_PASSWORD`, `POSTGRES_USER`, `POSTGRES_DB`. + +**Test function skeleton**: + +```rust +#[tokio::test] +async fn run_postgres_driver_tests() -> Result<(), Box<dyn std::error::Error + 'static>> { + if std::env::var("TORRUST_TRACKER_CORE_RUN_POSTGRES_DRIVER_TEST").is_err() { + return Ok(()); + } + let db_url = /* resolve from env or start container */; + let driver = Postgres::new(&db_url)?; + super::tests::run_tests(&driver).await; + Ok(()) +} +``` + +**Shared test suite**: reuse the `tests::run_tests()` function already used by the SQLite and +MySQL test modules. All three backends must pass the same set of behavioral scenarios (torrent +CRUD, whitelist CRUD, auth key CRUD, schema drop/create cycle). + +## Tasks + +### Task 1 — Add `Driver::PostgreSQL` to the configuration package + +Steps: + +- Add `PostgreSQL` variant to the `Driver` enum in + `packages/configuration/src/v2_0_0/database.rs`. +- Extend `mask_secrets()` to handle the PostgreSQL URL (share a branch with the MySQL case). +- Add test `it_should_allow_masking_the_postgresql_user_password`. + +Acceptance criteria: + +- [ ] `Driver::PostgreSQL` serializes as `"postgresql"` in TOML. +- [ ] `mask_secrets()` correctly redacts the password in a PostgreSQL URL. +- [ ] The new test passes. + +### Task 2 — Add sqlx `postgres` feature and create PostgreSQL migration files + +Steps: + +- Add `"postgres"` to the `sqlx` features in `packages/tracker-core/Cargo.toml`. +- Create `packages/tracker-core/migrations/postgresql/` with the four migration files listed + in the "What Changes" section above. +- Verify the SQL content is correct by running each migration in sequence against a temporary + PostgreSQL database and confirming the expected schema is produced. + +Acceptance criteria: + +- [ ] `packages/tracker-core/migrations/postgresql/` contains exactly four files with the + same timestamps as the SQLite and MySQL directories. +- [ ] Migration 1 creates `whitelist`, `torrents`, and `keys` with PostgreSQL DDL (`SERIAL`, + no backtick quoting, `$1`/`$2` placeholders in DML). +- [ ] Migration 2 makes `keys.valid_until` nullable. +- [ ] Migration 3 creates `torrent_aggregate_metrics`. +- [ ] Migration 4 widens `torrents.completed` and `torrent_aggregate_metrics.value` to + `BIGINT` using `ALTER COLUMN ... TYPE BIGINT` syntax. +- [ ] Running all four migrations in sequence produces a schema consistent with the SQLite + and MySQL schemas after their four migrations. + +### Task 3 — Implement the PostgreSQL driver + +Create `packages/tracker-core/src/databases/driver/postgres.rs` with: + +- `Postgres` struct (pool, `schema_ready` latch, `schema_lock` mutex). +- `Postgres::new(db_path: &str) -> Result<Self, Error>` using `PgConnectOptions` and + `PgPoolOptions::connect_lazy_with()`. +- `static MIGRATOR: Migrator = sqlx::migrate!("migrations/postgresql");` +- `ensure_schema()` latch — same double-checked pattern as SQLite and MySQL. +- `SchemaMigrator` impl: `create_database_tables()` (MIGRATOR.run() only, no legacy + bootstrap) and `drop_database_tables()` (all five tables with `DROP TABLE IF EXISTS`). +- `TorrentMetricsStore`, `WhitelistStore`, `AuthKeyStore` impls — same semantics as the + other backends, using `$1`/`$2` placeholders and PostgreSQL upsert syntax. +- `decode_counter`/`encode_counter` helpers. + +Acceptance criteria: + +- [ ] `Postgres` satisfies the `Database` aggregate supertrait through the blanket impl + (no manual `impl Database for Postgres {}` block). +- [ ] `create_database_tables()` calls `MIGRATOR.run()` with no legacy bootstrap. +- [ ] `drop_database_tables()` drops all five tables including `_sqlx_migrations`. +- [ ] All counter reads use `decode_counter`; all counter writes use `encode_counter`. +- [ ] No bare `as i64` or `as u64` casts in the driver. + +### Task 4 — Wire the PostgreSQL driver into the factory and setup + +Steps: + +- In `packages/tracker-core/src/databases/driver/mod.rs`: + - Add `PostgreSQL` to the `Driver` enum. + - Add `pub mod postgres;`. + - Add the `Driver::PostgreSQL` arm in `build()`. +- In `packages/tracker-core/src/databases/setup.rs`: + - Add `torrust_tracker_configuration::Driver::PostgreSQL => Driver::PostgreSQL`. + +Acceptance criteria: + +- [ ] `cargo build --workspace` succeeds with `driver = "postgresql"` in a config file. +- [ ] `databases/setup.rs` correctly dispatches to the PostgreSQL driver when the + configuration specifies `driver = "postgresql"`. + +### Task 5 — Add the PostgreSQL driver tests + +Add an inline `#[cfg(test)]` module to `postgres.rs` as described in the "Driver tests" +section above. + +Steps: + +- Implement `run_postgres_driver_tests` guarded by + `TORRUST_TRACKER_CORE_RUN_POSTGRES_DRIVER_TEST`. +- Support both a pre-existing PostgreSQL instance (via + `TORRUST_TRACKER_CORE_POSTGRES_DRIVER_URL`) and a `testcontainers` container started + on demand. +- Default container tag: `16`. Image tag injection via + `TORRUST_TRACKER_CORE_POSTGRES_DRIVER_IMAGE_TAG` (enables the compatibility matrix loop + in Task 6). +- Call `tests::run_tests(&driver).await` — the shared test suite used by all backends. + +Acceptance criteria: + +- [ ] `TORRUST_TRACKER_CORE_RUN_POSTGRES_DRIVER_TEST` is unset → test returns immediately + without error. +- [ ] When the env var is set, the test starts a PostgreSQL container (or connects to the + provided URL), runs the shared test suite, and passes. +- [ ] The container started by the test is removed unconditionally on completion or failure. + +### Task 6 — Extend the compatibility matrix (completing subissue 1525-01) + +Steps: + +- In `contrib/dev-tools/qa/run-db-compatibility-matrix.sh`, add: + - A test for the PostgreSQL configuration URL masking (after the existing protocol tests): + + ```bash + cargo test -p torrust-tracker-configuration postgresql_user_password -- --nocapture + ``` + + - A PostgreSQL versions loop after the MySQL loop: + + ```bash + POSTGRES_VERSIONS_STRING="${POSTGRES_VERSIONS:-14 15 16 17}" + read -r -a POSTGRES_VERSIONS <<< "$POSTGRES_VERSIONS_STRING" + + for version in "${POSTGRES_VERSIONS[@]}"; do + print_heading "PostgreSQL ${version}" + docker pull "postgres:${version}" + TORRUST_TRACKER_CORE_RUN_POSTGRES_DRIVER_TEST=1 \ + TORRUST_TRACKER_CORE_POSTGRES_DRIVER_IMAGE_TAG="${version}" \ + cargo test -p bittorrent-tracker-core run_postgres_driver_tests -- --nocapture + done + ``` + + - `POSTGRES_VERSIONS` defaults to `14 15 16 17`; override via env var. + +- The script already has `set -euo pipefail`; failures in the PostgreSQL loop will abort + the script with the failing version visible in the output. + +Acceptance criteria: + +- [ ] The script runs the PostgreSQL driver test for each version in `POSTGRES_VERSIONS`. +- [ ] The `POSTGRES_VERSIONS` set is overridable via env var. +- [ ] The script fails fast on the first failing backend/version combination. +- [ ] The script runs successfully end-to-end in a clean environment; a passing run log is + included in the PR description. +- [ ] The compatibility matrix exercises PostgreSQL 14, 15, 16, and 17 by default. + +### Task 7 — Extend the qBittorrent E2E runner with PostgreSQL (completing subissue 1525-02) + +The qBittorrent E2E runner introduced in subissue `1525-02` uses SQLite only. This task +extends it to support PostgreSQL and MySQL. MySQL E2E support (`--db-driver mysql`) is new +work introduced here — it was explicitly out of scope in `1525-02`. It is included here to +avoid a fourth subissue for a minor change and to keep all three backends consistent. + +Steps: + +- Add a `--db-driver` CLI argument to the E2E runner binary. Accept `sqlite3`, `mysql`, and + `postgresql`. Default: `sqlite3` (preserving existing behavior). +- When `--db-driver postgresql` is specified: + - Start a PostgreSQL container via `testcontainers::GenericImage` (or a `DockerCompose` + stack if a compose file is preferred). Wait for the container to be ready before starting + the tracker. Readiness can be checked by attempting a database connection or by running + `pg_isready` inside the container via `docker exec`. + - Generate a tracker config with `driver = "postgresql"` and the appropriate connection URL. + - Run the rest of the E2E scenario unchanged (seeder → tracker → leecher flow is + database-agnostic). +- Reuse the `Drop` guard pattern from the existing runner for unconditional PostgreSQL + container cleanup. +- Add a CI step (or extend the existing E2E step) that exercises `--db-driver postgresql`. +- Document the `--db-driver` argument in the binary's module doc comment. + +Acceptance criteria: + +- [ ] The E2E runner completes a full seeder → leecher download with PostgreSQL as the + backend. +- [ ] No orphaned containers remain on success or failure. +- [ ] The `--db-driver` argument is documented in the binary's module doc comment. + +### Task 8 — Extend the benchmark runner with PostgreSQL (completing subissue 1525-03) + +The benchmark runner introduced in subissue `1525-03` supports SQLite and MySQL. Extend it to +also benchmark PostgreSQL. + +Steps: + +- Add `postgresql` as an accepted value for `--dbs` in the benchmark runner CLI. +- Add `contrib/dev-tools/bench/compose.bench-postgresql.yaml` following the same structure as + the MySQL compose file: tracker service + PostgreSQL service, parameterized tracker image tag + via env var, no fixed host ports, `healthcheck` defined for each service. +- Wire the PostgreSQL compose file into the runner's per-suite lifecycle (same as MySQL/SQLite: + `DockerCompose::up()`, port discovery, workloads, `DockerCompose::down()` via `Drop` guard). +- Re-run the benchmark with both SQLite, MySQL, and PostgreSQL and update + `docs/benchmarks/baseline.md` and `docs/benchmarks/baseline.json` with the new results. + +Acceptance criteria: + +- [ ] `--dbs postgresql` produces benchmark results. +- [ ] `compose.bench-postgresql.yaml` starts and stops cleanly with no orphaned resources. +- [ ] `docs/benchmarks/baseline.md` is updated and includes PostgreSQL results. + +### Task 9 — Add the default PostgreSQL container config, update docs, and fix spell-check + +Steps: + +- Add `share/default/config/tracker.container.postgresql.toml` as described in the + "What Changes" section. + +- Update `share/container/entry_script_sh` to handle `postgresql` alongside the existing + `sqlite3` and `mysql` branches. Add an `elif` branch immediately after the `mysql` branch: + + ```sh + elif cmp_lc "$TORRUST_TRACKER_CONFIG_OVERRIDE_CORE__DATABASE__DRIVER" "postgresql"; then + + # (no database file needed for PostgreSQL) + + # Select default PostgreSQL configuration + default_config="/usr/share/torrust/default/config/tracker.container.postgresql.toml" + ``` + + Also update the error message in the `else` branch to list all three supported backends: + + ```sh + echo "Please Note: Supported Database Types: \"sqlite3\", \"mysql\", \"postgresql\"." + ``` + + The `Containerfile` already copies this file via + `COPY --chmod=0555 ./share/container/entry_script_sh /usr/local/bin/entry.sh`; no + `Containerfile` changes are needed. + +- Update `compose.yaml` to support the PostgreSQL backend alongside the existing MySQL + service: + - Add a `postgres` service using `image: postgres:16`: + + ```yaml + postgres: + image: postgres:16 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 3s + retries: 5 + start_period: 30s + environment: + - POSTGRES_PASSWORD=postgres + - POSTGRES_USER=postgres + - POSTGRES_DB=torrust_tracker + networks: + - server_side + volumes: + - postgres_data:/var/lib/postgresql/data + ``` + + - Add `postgres` to the tracker service's `depends_on` list (alongside `mysql`) so the + tracker waits for whichever backend is healthy. Both DB services start; the tracker + connects to whichever backend the `TORRUST_TRACKER_CONFIG_OVERRIDE_CORE__DATABASE__DRIVER` + env var selects. This is acceptable for a demo / developer compose file. + + - Add a `postgres_data` named volume to the `volumes:` section. + +- Update user-facing documentation to document PostgreSQL as a supported backend: + - `README.md` — add `postgresql` to the list of supported database backends. + - `docs/containers.md` — add a section (or extend the existing database section) describing + how to run the tracker with PostgreSQL, including the `POSTGRES_DB` pre-creation + requirement and a reference to the new container config file. + +- Run `linter cspell` and add any new technical terms to `project-words.txt` in alphabetical + order. Terms likely to be flagged: `postgresql` (lowercase), `isready`, and any other + identifiers used in scripts or code comments. + +Acceptance criteria: + +- [ ] `share/default/config/tracker.container.postgresql.toml` exists and is valid TOML. +- [ ] `share/container/entry_script_sh` has a `postgresql` branch that selects + `tracker.container.postgresql.toml`; the `else` error message lists all three supported + backends. +- [ ] `compose.yaml` has a `postgres` service; the tracker service's `depends_on` includes + both `mysql` and `postgres`; a `postgres_data` volume is declared. +- [ ] `docker compose up` with + `TORRUST_TRACKER_CONFIG_OVERRIDE_CORE__DATABASE__DRIVER=postgresql` starts the tracker + successfully against the PostgreSQL container. +- [ ] The container configuration or its companion documentation (compose file or README) + creates the `torrust_tracker` database (via `POSTGRES_DB` env var or equivalent) before + the tracker is started. +- [ ] The tracker starts successfully when pointed at this config with a running PostgreSQL + container named `postgres`. +- [ ] `README.md` lists PostgreSQL as a supported database backend. +- [ ] `docs/containers.md` documents how to run the tracker with PostgreSQL and states the + database pre-creation requirement. +- [ ] `linter cspell` reports no new failures. + +## Out of Scope + +- Changing consumer wiring from `Arc<Box<dyn Database>>` to narrow trait objects. Deferred + until the MSRV reaches 1.76 (trait-object upcasting). +- PostgreSQL-specific performance tuning or connection pool size configuration beyond the + default `PgPoolOptions` settings. +- Down migrations (rollback support). +- TLS configuration for the PostgreSQL connection (can be expressed in the URL without code + changes). +- Any persistence redesign not required for the driver to work. +- UDP E2E testing against PostgreSQL (can be added later without redesigning the E2E setup). + +## Acceptance Criteria + +- [ ] `Driver::PostgreSQL` serializes as `"postgresql"` in TOML; the configuration package + compiles cleanly. +- [ ] `mask_secrets()` redacts the password from a PostgreSQL URL. +- [ ] `packages/tracker-core/migrations/postgresql/` contains four migration files with the + same timestamps as SQLite and MySQL. +- [ ] Migration 1 creates the tables with PostgreSQL DDL (`SERIAL`, no backtick quoting). +- [ ] Migration 4 widens `torrents.completed` and `torrent_aggregate_metrics.value` to + `BIGINT` using `ALTER COLUMN ... TYPE BIGINT` syntax. +- [ ] `packages/tracker-core/src/databases/driver/postgres.rs` exists and satisfies + `Database` through the blanket impl (no manual `impl Database for Postgres {}`). +- [ ] `create_database_tables()` calls `MIGRATOR.run()` with no legacy bootstrap. +- [ ] `drop_database_tables()` drops all five tables including `_sqlx_migrations`. +- [ ] All counter reads/writes use `decode_counter`/`encode_counter`; no bare truncating + casts. +- [ ] The shared driver test suite passes against PostgreSQL when + `TORRUST_TRACKER_CORE_RUN_POSTGRES_DRIVER_TEST` is set. +- [ ] `TORRUST_TRACKER_CORE_POSTGRES_DRIVER_IMAGE_TAG` controls the PostgreSQL version used + in tests, enabling the compatibility matrix loop. +- [ ] `run-db-compatibility-matrix.sh` loops over `POSTGRES_VERSIONS` (default: + `14 15 16 17`). +- [ ] The qBittorrent E2E runner completes a full download cycle with PostgreSQL. +- [ ] The benchmark runner produces results for PostgreSQL; `docs/benchmarks/baseline.md` + is updated. +- [ ] `share/default/config/tracker.container.postgresql.toml` exists and is valid TOML. +- [ ] `share/container/entry_script_sh` has a `postgresql` branch; the `else` error message + lists all three supported backends. +- [ ] `compose.yaml` has a `postgres` service; the tracker service's `depends_on` includes + both `mysql` and `postgres`; `docker compose up` with + `TORRUST_TRACKER_CONFIG_OVERRIDE_CORE__DATABASE__DRIVER=postgresql` starts the tracker + successfully. +- [ ] `project-words.txt` is up to date; `linter cspell` reports no failures. +- [ ] `README.md` lists PostgreSQL as a supported database backend. +- [ ] `docs/containers.md` documents how to run the tracker with PostgreSQL and states the + database pre-creation requirement. +- [ ] Persistence benchmarking shows no regression for SQLite or MySQL against the committed + baseline. +- [ ] `cargo test --workspace --all-targets` passes. +- [ ] `cargo machete` reports no unused dependencies. +- [ ] `linter all` exits with code `0`. + +## References + +- EPIC: `#1525` — `docs/issues/1525-overhaul-persistence.md` +- Subissue `1525-01`: `docs/issues/1525-01-persistence-test-coverage.md` — compatibility + matrix structure (PostgreSQL loop deferred here) +- Subissue `1525-02`: `docs/issues/1706-1525-02-qbittorrent-e2e.md` — E2E runner (PostgreSQL + deferred here) +- Subissue `1525-03`: `docs/issues/1525-03-persistence-benchmarking.md` — benchmark runner + (PostgreSQL deferred here) +- Subissue `1525-06`: `docs/issues/1525-06-introduce-schema-migrations.md` — migration + framework and history-alignment pattern +- Subissue `1525-07`: `docs/issues/1525-07-align-rust-and-db-types.md` — fourth migration + and `NumberOfDownloads = u64` +- Reference PR: `#1695` +- Reference implementation branch: `josecelano:pr-1684-review` — see EPIC for checkout + instructions +- Reference files: + - `packages/configuration/src/v2_0_0/database.rs` (`Driver::PostgreSQL`, URL masking) + - `packages/tracker-core/src/databases/driver/postgres.rs` (full driver) + - `packages/tracker-core/src/databases/driver/mod.rs` (`Driver::PostgreSQL` in `build()`) + - `packages/tracker-core/src/databases/setup.rs` (PostgreSQL dispatch) + - `packages/tracker-core/migrations/postgresql/` (all four migration files) + - `share/default/config/tracker.container.postgresql.toml` + - `contrib/dev-tools/qa/run-db-compatibility-matrix.sh` (PostgreSQL versions loop) + - `contrib/dev-tools/qa/run-qbittorrent-e2e.py` (E2E reference with PostgreSQL) + - `contrib/dev-tools/qa/run-before-after-db-benchmark.py` (benchmark with PostgreSQL) diff --git a/docs/issues/1525-overhaul-persistence.md b/docs/issues/1525-overhaul-persistence.md new file mode 100644 index 000000000..b114573da --- /dev/null +++ b/docs/issues/1525-overhaul-persistence.md @@ -0,0 +1,156 @@ +# Issue #1525 Implementation Plan (Overhaul Persistence) + +## Goal + +Redesign the persistence layer progressively so PostgreSQL support can be added safely, with each step independently reviewable and mergeable. + +## Scope + +- Target issue: https://github.com/torrust/torrust-tracker/issues/1525 +- Reference PR: https://github.com/torrust/torrust-tracker/pull/1695 +- Review record PR: https://github.com/torrust/torrust-tracker/pull/1700 +- Key review comment: https://github.com/torrust/torrust-tracker/pull/1695#pullrequestreview-4127741472 +- Reference branch for existing implementation work: `review/pr-1695` + +## Context + +This EPIC was created in May 2025, almost a year before the current implementation effort. The problems it describes were identified early, and the opening of PR #1695 (PostgreSQL support) is what turned the plan into an active priority — but PostgreSQL is not the only driver. + +### Original motivations (from issue #1525) + +- **No migrations**: The tracker has no schema migration mechanism. As more tables are planned (e.g. extended metrics from issue #1437), the absence of migrations becomes increasingly risky. +- **Wrong crate for the job**: `r2d2` is a synchronous connection-pool library. It is not clear it is still the best fit; `sqlx` is already used in the Index project and supports async natively. The issue references SeaORM as an alternative worth researching. +- **Adding a new driver is too hard**: The `Database` trait is too wide. Adding PostgreSQL support (issue #462) was confirmed to be tricky with the current `r2d2`-based abstraction — the trait must be split before new backends can be added cleanly. + +### Immediate trigger + +PR #1695 demonstrates that the PostgreSQL work is feasible, but bundled the entire redesign into one large diff. This plan re-delivers that work incrementally so every step is independently reviewable and mergeable. + +### Why now + +The PostgreSQL PR created momentum and a concrete reference implementation. Leaving the redesign for later would mean adding more complexity on top of a layer that is already known to be the wrong shape. + +## Delivery Strategy + +Apply the redesign in small steps that can be merged independently into `develop`. + +### Phase 1: Make the change easy + +1. Add a DB compatibility matrix across supported database versions. +2. Add an end-to-end test with a real BitTorrent client. +3. Add before/after persistence benchmarking so later changes can be compared against a concrete baseline. +4. Split the persistence traits to reduce coupling. +5. Migrate existing SQL backends to the new async `sqlx` substrate without introducing PostgreSQL yet. +6. Introduce schema migrations and align schema ownership with migrations. +7. Align Rust types with the actual SQL storage model. This step may require schema changes (e.g. widening 32-bit counter columns to 64-bit), so it belongs after migrations are in place. + +### Phase 2: Make the easy change + +1. Add PostgreSQL as a first-class backend on top of the refactored persistence layer. + +## Working Rules + +- Treat `review/pr-1695` as a read-only reference branch. +- Do not try to preserve the original PR commit structure. +- Port useful code selectively from the reference branch into clean subissue branches. +- New QA and tooling code should be written in Rust unless there is a strong reason not to. +- Every subissue should produce a PR that is reviewable on its own and safe to merge before PostgreSQL support is complete. + +## Reference Implementation + +PR #1695 was authored on the fork `josecelano/torrust-tracker`, branch `pr-1684-review`. +The reference implementation lives at: + +```text +https://github.com/josecelano/torrust-tracker/tree/pr-1684-review +``` + +This branch should be treated as a **read-only reference** — a prototype that demonstrates +feasibility. Implementation work is done in dedicated subissue branches cut from `develop`. + +### Checking out the reference branch locally + +To inspect the reference implementation without affecting your current checkout, clone the +fork into a separate directory: + +```bash +git clone --branch pr-1684-review \ + https://github.com/josecelano/torrust-tracker.git \ + /path/to/torrust-tracker-pr-1700 +``` + +Replace `/path/to/torrust-tracker-pr-1700` with any directory outside your main checkout. +You can then browse or search it while working in the main repository. + +## Proposed Subissues + +### 1) Add DB compatibility matrix + +- Spec file: `docs/issues/1703-1525-01-persistence-test-coverage.md` +- Outcome: compatibility matrix exercises SQLite and multiple MySQL versions; PostgreSQL slot + reserved for subissue 8 + +### 2) Add qBittorrent end-to-end test + +- Spec file: `docs/issues/1706-1525-02-qbittorrent-e2e.md` +- Outcome: one complete seeder/leecher torrent-sharing scenario using real containerized clients + and docker compose, with SQLite as the backend + +### 3) Add persistence benchmarking + +- Spec file: `docs/issues/1525-03-persistence-benchmarking.md` +- Outcome: reproducible before/after performance measurements across supported backends + +### 4) Split the persistence traits by context + +- Spec file: `docs/issues/1713-1525-04-split-persistence-traits.md` +- Outcome: smaller interfaces with lower coupling and clearer responsibilities + +### 4b) Migrate consumers to narrow persistence traits + +- Spec file: `docs/issues/1715-1525-04b-migrate-consumers-to-narrow-traits.md` +- Outcome: every consumer holds only the narrow trait(s) it uses; `Database` + becomes a private compile-time guard inside `databases/` + +### 5) Migrate SQLite and MySQL drivers to async `sqlx` + +- Spec file: `docs/issues/1525-05-migrate-sqlite-and-mysql-to-sqlx.md` +- Outcome: shared async persistence substrate without adding PostgreSQL yet + +### 6) Introduce schema migrations + +- Spec file: `docs/issues/1525-06-introduce-schema-migrations.md` +- Outcome: schema changes become explicit, versioned, and testable + +### 7) Align persisted counters and Rust/SQL type boundaries + +- Spec file: `docs/issues/1525-07-align-rust-and-db-types.md` +- Outcome: explicit contract for persisted counters and numeric ranges, with any needed schema + changes delivered through migrations + +### 8) Add PostgreSQL driver support + +- Spec file: `docs/issues/1525-08-add-postgresql-driver.md` +- Outcome: PostgreSQL support lands on top of the refactored and migration-backed persistence + layer; PostgreSQL is added to the compatibility matrix (subissue 1) and qBittorrent E2E + (subissue 2) test harnesses + +## PR Strategy + +- Current branch for the planning docs: `1525-persistence-plan` +- Merge this planning PR into `develop` first. +- After the planning PR is merged, create one branch per subissue from `develop`. +- Keep the PRs narrow and link them back to this EPIC. + +## Acceptance Criteria + +- [ ] The EPIC plan is merged into `develop`. +- [ ] Each subissue has its own specification file in `docs/issues/`. +- [ ] The implementation order is explicit and justified. +- [ ] The plan references PR #1695 and PR #1700 as historical context, not as the delivery vehicle. + +## References + +- Related issue: #1525 +- Related PRs: #1695, #1700 +- Related discussion: PostgreSQL support request #462 diff --git a/docs/issues/1697-ai-agent-configuration.md b/docs/issues/1697-ai-agent-configuration.md new file mode 100644 index 000000000..3d38eb003 --- /dev/null +++ b/docs/issues/1697-ai-agent-configuration.md @@ -0,0 +1,358 @@ +# Set Up Basic AI Agent Configuration + +## Goal + +Set up the foundational configuration files in this repository to enable effective collaboration with AI coding agents. This includes adding an `AGENTS.md` file to guide agents on project conventions, adding agent skills for repeatable specialized tasks, and defining custom agents for project-specific workflows. + +## References + +- **AGENTS.md specification**: https://agents.md/ +- **Agent Skills specification**: https://agentskills.io/specification +- **GitHub Copilot — About agent skills**: https://docs.github.com/en/copilot/concepts/agents/about-agent-skills +- **GitHub Copilot — About custom agents**: https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-custom-agents + +## Background + +### AGENTS.md + +`AGENTS.md` is an open, plain-Markdown format stewarded by the [Agentic AI Foundation](https://aaif.io/) under the Linux Foundation. It acts as a "README for agents": a single, predictable file where coding agents look first for project-specific context (build steps, test commands, conventions, security considerations) that would otherwise clutter the human-focused `README.md`. + +It is supported by a wide ecosystem of tools including GitHub Copilot (VS Code), Cursor, Windsurf, OpenAI Codex, Claude Code, Jules (Google), Warp, and many others. In monorepos, nested `AGENTS.md` files can be placed inside each package; the closest file to the file being edited takes precedence. + +### Agent Skills + +Agent Skills (https://agentskills.io/specification) are directories of instructions, scripts, and resources that an agent can load to perform specialized, repeatable tasks. Each skill lives in a folder named after the skill and contains at minimum a `SKILL.md` file with YAML frontmatter (`name`, `description`, optional `license`, `compatibility`, `metadata`, `allowed-tools`) followed by Markdown instructions. + +GitHub Copilot supports: + +- **Project skills** stored in the repository at `.github/skills/`, `.claude/skills/`, or `.agents/skills/` +- **Personal skills** stored in the home directory at `~/.copilot/skills/`, `~/.claude/skills/`, or `~/.agents/skills/` + +### Custom Agents + +Custom agents are specialized versions of GitHub Copilot that can be tailored to project-specific workflows. They are defined as Markdown files with YAML frontmatter (agent profiles) stored at: + +- **Repository level**: `.github/agents/CUSTOM-AGENT-NAME.md` +- **Organization/enterprise level**: `/agents/CUSTOM-AGENT-NAME.md` inside a `.github-private` repository + +An agent profile includes a `name`, `description`, optional `tools`, and optional `mcp-servers` configurations. The Markdown body of the file acts as the agent's prompt (it is not a YAML frontmatter key). The main Copilot agent can run custom agents as subagents in isolated context windows, including in parallel. + +## Tasks + +### Task 0: Create a local branch + +- Approved branch name: `<issue-number>-ai-agent-configuration` +- Commands: + - `git fetch --all --prune` + - `git checkout develop` + - `git pull --ff-only` + - `git checkout -b <issue-number>-ai-agent-configuration` +- Checkpoint: `git branch --show-current` should output `<issue-number>-ai-agent-configuration`. + +--- + +### Task 1: Add `AGENTS.md` at the repository root + +Provide AI coding agents with a clear, predictable source of project context so they can work +effectively without requiring repeated manual instructions. + +**Inspiration / reference AGENTS.md files from other Torrust projects**: + +- https://raw.githubusercontent.com/torrust/torrust-tracker-deployer/refs/heads/main/AGENTS.md +- https://raw.githubusercontent.com/torrust/torrust-linting/refs/heads/main/AGENTS.md + +Create `AGENTS.md` in the repository root, adapting the above files to the tracker. At minimum +the file must cover: + +- [x] Repository link and project overview (language, license, MSRV, web framework, protocols, databases) +- [x] Tech stack (languages, frameworks, databases, containerization, linting tools) +- [x] Key directories (`src/`, `src/bin/`, `packages/`, `console/`, `contrib/`, `tests/`, `docs/`, `share/`, `storage/`, `.github/workflows/`) +- [x] Package catalog (all workspace packages with their layer and description) +- [x] Package naming conventions (`axum-*`, `*-server`, `*-core`, `*-protocol`) +- [x] Key configuration files (`.markdownlint.json`, `.yamllint-ci.yml`, `.taplo.toml`, `cspell.json`, `rustfmt.toml`, etc.) +- [x] Build & test commands (`cargo build`, `cargo test --doc`, `cargo test --all-targets`, E2E runner, benchmarks) +- [x] Lint commands (`linter all` and individual linters; how to install the `linter` binary) +- [x] Dependencies check (`cargo machete`) +- [x] Code style (rustfmt rules, clippy policy, import grouping, per-format rules) +- [x] Collaboration principles (no flattery, push back on weak ideas, flag blockers early) +- [x] Essential rules (linting gate, GPG commit signing, no `storage/`/`target/` commits, `cargo machete`) +- [x] Git workflow (branch naming, Conventional Commits, branch strategy: `develop` → `staging/main` → `main`) +- [x] Development principles (observability, testability, modularity, extensibility; Beck's four rules) +- [x] Container / Docker (key commands, ports, volume mount paths) +- [x] Auto-invoke skills placeholder (to be filled in when `.github/skills/` is populated) +- [x] Documentation quick-navigation table +- [x] Add a brief entry to `docs/index.md` pointing contributors to `AGENTS.md`, `.github/skills/`, and `.github/agents/` + +Commit message: `docs(agents): add root AGENTS.md` + +Checkpoint: + +- `linter all` exits with code `0`. +- At least one AI agent (GitHub Copilot, Cursor, etc.) can be confirmed to pick up the file. + +**References**: + +- https://agents.md/ +- https://github.com/openai/codex/blob/-/AGENTS.md (real-world example) +- https://github.com/apache/airflow/blob/-/AGENTS.md (real-world monorepo example) + +--- + +### Task 2: Add Agent Skills + +Define reusable, project-specific skills that agents can load to perform specialized tasks on +this repository consistently. + +- [x] Create `.github/skills/` directory +- [x] Review and confirm the candidate skills listed below (add, remove, or adjust before starting implementation) +- [x] For each skill, create a directory with: + - `SKILL.md` — YAML frontmatter (`name`, `description`, optional `license`, `compatibility`) + step-by-step instructions + - `scripts/` (optional) — executable scripts the agent can run + - `references/` (optional) — additional reference documentation +- [x] Validate skill files against the Agent Skills spec (name rules: lowercase, hyphens, no consecutive hyphens, max 64 chars; description: max 1024 chars) + +**Candidate initial skills** (ported / adapted from `torrust-tracker-deployer`): + +The skills below are modelled on the skills already proven in +[torrust-tracker-deployer](https://github.com/torrust/torrust-tracker-deployer) +(`.github/skills/`). Deployer-specific skills (Ansible, Tera templates, LXD, SDK, +deployer CLI architecture) are excluded because they have no equivalent in the tracker. + +Directory layout to mirror the deployer structure: + +```text +.github/skills/ + add-new-skill/ + dev/ + git-workflow/ + maintenance/ + planning/ + rust-code-quality/ + testing/ +``` + +**`add-new-skill`** ✅ — meta-skill: guide for creating new Agent Skills for this repository. + +**`dev/git-workflow/`**: + +- `commit-changes` ✅ — commit following Conventional Commits; pre-commit verification checklist. +- `create-feature-branch` ✅ — branch naming convention and lifecycle. +- `open-pull-request` ✅ — open a PR via GitHub CLI or GitHub MCP tool; pre-flight checks. +- `release-new-version` ✅ — version bump, signed release commit, signed tag, CI verification. +- `review-pr` ✅ — review a PR against Torrust quality standards and checklist. +- `run-linters` ✅ — run the full linting suite (`linter all`); fix individual linter failures. +- `run-pre-commit-checks` ✅ — mandatory quality gates before every commit. + +**`dev/maintenance/`**: + +- `install-linter` ✅ — install the `linter` binary and its external tool dependencies. +- `setup-dev-environment` ✅ — full onboarding guide: system deps, Rust toolchain, storage dirs, linter, git hooks, smoke test. +- `update-dependencies` ✅ — run `cargo update`, create branch, commit, push, open PR. + +**`dev/planning/`**: + +- `create-adr` ✅ — create an Architectural Decision Record in `docs/adrs/`. +- `create-issue` ✅ — draft and open a GitHub issue following project conventions. +- `write-markdown-docs` ✅ — GFM pitfalls (auto-links, ordered list numbering, etc.). +- `cleanup-completed-issues` ✅ — remove issue doc files and update roadmap after PR merge. + +**`dev/rust-code-quality/`**: + +- `handle-errors-in-code` ✅ — `thiserror`-based structured errors; what/where/when/why context. +- `handle-secrets` ✅ — wrapper types for tokens/passwords; never use plain `String` for secrets. + +**`dev/testing/`**: + +- `write-unit-test` ✅ — `it_should_*` naming, AAA pattern, `MockClock`, `TempDir`, `rstest`. + +Commit message: `docs(agents): add initial agent skills under .github/skills/` + +Checkpoint: + +- `linter all` exits with code `0`. +- At least one skill can be successfully activated by GitHub Copilot. + +**References**: + +- https://agentskills.io/specification +- https://docs.github.com/en/copilot/concepts/agents/about-agent-skills +- https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-skills +- https://github.com/anthropics/skills (community skills examples) +- https://github.com/github/awesome-copilot (community collection) + +--- + +### Task 3: Add Custom Agents + +Define custom GitHub Copilot agents tailored to Torrust project workflows so that specialized +tasks can be delegated to focused agents with the right prompt context. + +- [x] Create `.github/agents/` directory +- [x] Identify workflows that benefit from a dedicated agent +- [x] For each agent, create `.github/agents/<agent-name>.md` with: + - YAML frontmatter: `name` (optional), `description`, optional `tools` + - Prompt body: role definition, scope, constraints, and step-by-step instructions +- [x] Test each custom agent by assigning it to a task or issue in GitHub Copilot CLI + +**Candidate initial agents**: + +- `committer` ✅ — commit specialist: reads branch/diff, runs pre-commit checks + (`./contrib/dev-tools/git/hooks/pre-commit.sh`), proposes a GPG-signed Conventional Commit message, and creates + the commit only after scope and checks are clear. Reference: + [`torrust-tracker-demo/.github/agents/commiter.agent.md`](https://raw.githubusercontent.com/torrust/torrust-tracker-demo/refs/heads/main/.github/agents/commiter.agent.md) +- `implementer` ✅ — software implementer that applies Test-Driven Development and seeks the + simplest solution. Follows a structured process: analyse → decompose into small steps → + implement with TDD → call the Complexity Auditor after each step → call the Committer when + ready. Guided by Beck's Four Rules of Simple Design. +- `complexity-auditor` ✅ — code quality auditor that checks cyclomatic and cognitive complexity + of changes after each implementation step. Reports PASS/WARN/FAIL per function using thresholds + and Clippy's `cognitive_complexity` lint. Called by the Implementer; can also be invoked + directly. + +**Future agents** (not yet implemented): + +- `issue-planner` — given a GitHub issue, produces a detailed implementation plan document + (like those in `docs/issues/`) including branch name, task breakdown, checkpoints, and commit + message suggestions. +- `code-reviewer` — reviews PRs against Torrust coding conventions, clippy rules, and security + considerations. +- `docs-writer` — creates or updates documentation files following the existing docs structure. + +Commit message: `docs(agents): add initial custom agents under .github/agents/` + +Checkpoint: + +- `linter all` exits with code `0`. +- At least one custom agent can be assigned to a task in GitHub Copilot CLI. + +**References**: + +- https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-custom-agents +- https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/create-custom-agents-for-cli +- https://docs.github.com/en/copilot/reference/customization-cheat-sheet + +--- + +### Task 4 (optional / follow-up): Add nested `AGENTS.md` files in packages + +Once the root file is stable, evaluate whether any workspace packages have sufficiently different +conventions or setup to warrant their own `AGENTS.md`. This can be tracked as a separate follow-up +issue. + +- [x] Evaluate workspace packages for package-specific conventions +- [x] Add `packages/AGENTS.md` — guidance scoped to all workspace packages +- [x] Add `src/AGENTS.md` — guidance scoped to the main binary/library source + +> **Note**: Completed as part of Task 1. `packages/AGENTS.md` and `src/AGENTS.md` were added +> alongside the root `AGENTS.md`. + +--- + +### Task 5: Add `copilot-setup-steps.yml` workflow + +Create `.github/workflows/copilot-setup-steps.yml` so that the GitHub Copilot cloud agent gets a +fully prepared development environment before it starts working on any task. Without this file, +Copilot discovers and installs dependencies itself via trial-and-error, which is slow and +unreliable. + +The workflow must contain a single `copilot-setup-steps` job (the exact job name is required by +Copilot). Steps run in GitHub Actions before Copilot starts; the file is also automatically +executed as a normal CI workflow whenever it changes, providing built-in validation. + +**Reference example** (from `torrust-tracker-deployer`): +https://raw.githubusercontent.com/torrust/torrust-tracker-deployer/refs/heads/main/.github/workflows/copilot-setup-steps.yml + +Minimum steps to include: + +- [x] Trigger on `workflow_dispatch`, `push` and `pull_request` (scoped to the workflow file path) +- [x] `copilot-setup-steps` job on `ubuntu-latest`, `timeout-minutes: 30`, `permissions: contents: read` +- [x] `actions/checkout@v6` — check out the repository (verify this is still the latest stable + version on the GitHub Marketplace before merging) +- [x] `dtolnay/rust-toolchain@stable` — install the stable Rust toolchain (pin MSRV if needed) +- [x] `Swatinem/rust-cache@v2` — cache `target/` and `~/.cargo` between runs +- [x] `cargo build` warm-up — build the workspace (or key packages) so incremental compilation is + ready when Copilot starts editing +- [x] Install the `linter` binary — + `cargo install --locked --git https://github.com/torrust/torrust-linting --bin linter` +- [x] Install `cargo-machete` — `cargo install cargo-machete`; ensures Copilot can run unused + dependency checks (`cargo machete`) as required by the essential rules +- [x] Smoke-check: run `linter all` to confirm the environment is healthy before Copilot begins +- [x] Install Git pre-commit hooks — `./contrib/dev-tools/git/install-git-hooks.sh` + +Commit message: `ci(copilot): add copilot-setup-steps workflow` + +Checkpoint: + +- The workflow runs successfully via the repository's **Actions** tab (manual dispatch or push to + the file). +- `linter all` exits with code `0` inside the workflow. + +**References**: + +- https://docs.github.com/en/copilot/how-tos/use-copilot-agents/cloud-agent/customize-the-agent-environment +- https://raw.githubusercontent.com/torrust/torrust-tracker-deployer/refs/heads/main/.github/workflows/copilot-setup-steps.yml + +--- + +### Task 6: Create an ADR for the AI agent framework approach + +> **Note**: This task documents the decision that underlies the whole issue. It can be done +> before Tasks 1–5 if preferred — recording the decision first and then implementing it is +> the conventional ADR practice. + +Document the decision to build a custom, GitHub-Copilot-aligned agent framework (AGENTS.md + +Agent Skills + Custom Agents) rather than adopting one of the existing pre-defined agent +frameworks that were evaluated. + +**Frameworks evaluated and not adopted**: + +- [obra/superpowers](https://github.com/obra/superpowers) +- [gsd-build/get-shit-done](https://github.com/gsd-build/get-shit-done) + +**Reasons for not adopting them**: + +1. Complexity mismatch — they introduce abstractions that are heavier than what tracker + development needs. +2. Precision requirements — the tracker involves low-level programming where agent work must be + reviewed carefully; generic productivity frameworks are not designed around that constraint. +3. GitHub-first ecosystem — the tracker is hosted on GitHub and makes intensive use of GitHub + resources (Actions, Copilot, MCP tools, etc.). Staying aligned with GitHub Copilot avoids + unnecessary integration friction. +4. Tooling churn — the AI agent landscape is evolving rapidly; depending on a third-party + framework risks forced refactoring when that framework is deprecated or pivots. A first-party + approach is more stable. +5. Tailored fit — a custom solution can be shaped precisely to Torrust conventions, commit style, + linting gates, and package structure from day one. +6. Proven in practice — the same approach has already been validated during the development of + `torrust-tracker-deployer`. +7. Agent-agnostic by design — keeping the framework expressed as plain Markdown files + (AGENTS.md, SKILL.md, agent profiles) decouples it from any single agent product, making + migration or multi-agent use straightforward. +8. Incremental adoption — individual skills, custom agents, or patterns from those frameworks can + still be cherry-picked and integrated progressively if specific value is identified. + +- [x] Create `docs/adrs/<YYYYMMDDHHMMSS>_ai-agent-framework-approach.md` using the `create-adr` skill +- [x] Record the decision, the alternatives considered, and the reasoning above + +Commit message: `docs(adrs): add ADR for AI agent framework approach` + +Checkpoint: + +- `linter all` exits with code `0`. + +**References**: + +- `docs/adrs/README.md` — ADR naming convention for this repository +- https://adr.github.io/ + +--- + +## Acceptance Criteria + +- [x] `AGENTS.md` exists at the repo root and contains accurate, up-to-date project guidance. +- [x] At least one skill is available under `.github/skills/` and can be successfully activated by GitHub Copilot. +- [x] At least one custom agent is available under `.github/agents/` and can be assigned to a task. +- [x] `copilot-setup-steps.yml` exists, the workflow runs successfully in the **Actions** tab, and `linter all` exits with code `0` inside it. +- [x] An ADR exists in `docs/adrs/` documenting the decision to use a custom GitHub-Copilot-aligned agent framework. +- [x] All files pass spelling checks (`cspell`) and markdown linting. +- [x] A brief entry in `docs/index.md` points contributors to `AGENTS.md`, `.github/skills/`, and `.github/agents/`. diff --git a/docs/issues/1703-1525-01-persistence-test-coverage.md b/docs/issues/1703-1525-01-persistence-test-coverage.md new file mode 100644 index 000000000..be5ada114 --- /dev/null +++ b/docs/issues/1703-1525-01-persistence-test-coverage.md @@ -0,0 +1,150 @@ +# Subissue #1703 (Draft for #1525-01): Add DB Compatibility Matrix + +- Issue: https://github.com/torrust/torrust-tracker/issues/1703 + +## Goal + +Establish a compatibility matrix that exercises persistence-layer tests across supported database +versions before any refactoring begins. + +## Why First + +The later refactors change persistence architecture, async behavior, schema setup, and backend +implementations. Running the tests against multiple database versions first gives a baseline to +detect regressions early and narrows review scope to behavior rather than guesswork. + +## Scope + +- Bash is acceptable for low-complexity orchestration. +- Focus only on the database compatibility matrix; end-to-end real-client testing is covered by + subissue #1525-02. + +## Testing Principles + +The implementation must follow these quality rules for all new and modified tests. + +- **Isolation**: Each test run must be independent. Tests that spin up database containers via + `testcontainers` already get their own ephemeral container; the bash matrix script achieves + isolation by running one matrix cell at a time in a fresh process, each with an exclusively + allocated container. +- **Independent system resources**: Tests must not hard-code host ports. `testcontainers` binds + containers to random free host ports automatically — do not override this with fixed bindings. + Temporary files or directories, if needed, must be created under a `tempfile`-managed path so + they are always removed on exit. +- **Cleanup**: After each test (success or failure) all containers, volumes, and temporary files + must be released. `testcontainers` handles containers automatically when the handle is dropped; + ensure `Drop` is not suppressed. +- **Behavior, not implementation**: Tests must assert observable outcomes (e.g. the driver + correctly inserts and retrieves a torrent entry) rather than internal state (e.g. a specific SQL + query was issued). +- **Verified before done**: No test is considered complete until it has been executed and passes + in a clean environment. Include confirmation of a passing run in the PR description. + +## Reference QA Workflow + +The PR #1695 review branch includes a QA script that defines the expected behavior: + +- `database-compatibility` job in `.github/workflows/testing.yaml`: + executes a compatibility matrix across SQLite, multiple MySQL versions, and multiple PostgreSQL + versions. + +This should be treated as a reference prototype, not a production artifact. The goal is to +re-implement it in a form that integrates with the repository's normal test strategy. + +## Dependency Note + +PostgreSQL is not implemented yet, so this subissue cannot require successful execution against +PostgreSQL. The structure should make it easy to add PostgreSQL combinations in subissue +`#1525-08` once the driver exists. + +## Proposed Branch + +- `1525-01-db-compatibility-matrix` + +## Tasks + +### 1) Port the compatibility matrix workflow + +Add a low-complexity bash compatibility-matrix runner that exercises persistence-related tests +across supported database versions. + +Tests to orchestrate: + +- `cargo check --workspace --all-targets` +- configuration coverage for PostgreSQL connection settings +- large-download counter saturation tests in the HTTP protocol layer +- large-download counter saturation tests in the UDP protocol layer +- SQLite driver tests +- MySQL driver tests across selected MySQL versions + +Note: PostgreSQL version-matrix execution is deferred to subissue #1525-08, once the +PostgreSQL driver exists. + +Steps: + +- Modify current DB driver tests so the DB image version can be injected through environment + variables: + - MySQL: `TORRUST_TRACKER_CORE_MYSQL_DRIVER_IMAGE_TAG` + - PostgreSQL (reserved for subissue #1525-08): `TORRUST_TRACKER_CORE_POSTGRES_DRIVER_IMAGE_TAG` + + When `TORRUST_TRACKER_CORE_MYSQL_DRIVER_IMAGE_TAG` is not set, the test falls back to the + current hardcoded default (e.g. `8.0`), preserving existing behavior. The CI matrix job sets + this variable explicitly for each version in the loop, so unset means "run as today" and the + matrix just expands that into multiple combinations. + +- Add a dedicated `database-compatibility` workflow job (between unit and e2e) with matrix values for MySQL versions: + - include matrix values for at least `8.0` and `8.4` + - run `cargo test -p bittorrent-tracker-core --features db-compatibility-tests run_mysql_driver_tests -- --nocapture` + - set `TORRUST_TRACKER_CORE_RUN_MYSQL_DRIVER_TEST=true` + - set `TORRUST_TRACKER_CORE_MYSQL_DRIVER_IMAGE_TAG=<version>` + - keep the test logic in Rust; use workflow matrix for version fan-out +- Replace the current single MySQL `database` step in `.github/workflows/testing.yaml` with a + dedicated `database-compatibility` job. + +Acceptance criteria: + +- [ ] DB image version injection is supported via `TORRUST_TRACKER_CORE_MYSQL_DRIVER_IMAGE_TAG` + (and a reserved `POSTGRES` equivalent for subissue #1525-08). +- [ ] `database-compatibility` workflow job runs successfully for each configured MySQL version. +- [ ] The workflow matrix exercises at least two MySQL versions by default. +- [ ] Failures identify the backend/version combination that broke. +- [ ] The dedicated `database-compatibility` job in `.github/workflows/testing.yaml` replaces the + old single-version MySQL command. +- [ ] The workflow matrix structure allows PostgreSQL to be added in subissue #1525-08 without a + redesign. +- [ ] Tests do not hard-code host ports; `testcontainers` assigns random ports automatically. +- [ ] All containers started by tests are removed unconditionally on test completion or failure. + +### 2) Document the workflow + +Steps: + +- Document the local invocation command for the compatibility test using explicit feature + env + vars. +- Document that CI runs the same test through the `database-compatibility` workflow job matrix. + +Acceptance criteria: + +- [ ] The compatibility test command is documented and runnable without ad hoc manual steps. + +## Out of Scope + +- qBittorrent end-to-end testing (covered by subissue #1525-02). +- Adding PostgreSQL support itself. +- Refactoring the production persistence interfaces. +- Performance benchmarking, before/after comparison, and benchmark reporting. + +## Definition of Done + +- [ ] `cargo test --workspace --all-targets` passes. +- [ ] `linter all` exits with code `0`. +- [ ] The `database-compatibility` workflow job has been executed successfully in a clean + environment; a passing run log is included in the PR description. + +## References + +- EPIC: #1525 +- Reference PR: #1695 +- Reference implementation branch: `josecelano:pr-1684-review` — see EPIC for checkout + instructions (`docs/issues/1525-overhaul-persistence.md`) +- Reference job: `.github/workflows/testing.yaml` `database-compatibility` diff --git a/docs/issues/1706-1525-02-qbittorrent-e2e.md b/docs/issues/1706-1525-02-qbittorrent-e2e.md new file mode 100644 index 000000000..519038315 --- /dev/null +++ b/docs/issues/1706-1525-02-qbittorrent-e2e.md @@ -0,0 +1,326 @@ +# Subissue Draft for #1525-02: Add qBittorrent End-to-End Test + +- GitHub issue: #1706 + +## Goal + +Add a high-level end-to-end test that validates tracker behavior through a complete torrent-sharing +scenario using real containerized BitTorrent clients, covering scenarios that lower-level unit and +integration tests cannot reach. + +## Why Before the Refactor + +The persistence refactor changes storage behavior underneath the tracker. Having a real-client +scenario that exercises a full download cycle (seeder uploads → leecher downloads → tracker +records completion) gives a regression backstop that is not possible with protocol-level tests +alone. + +## Scope + +- Follow the same pattern as the existing `e2e_tests_runner` binary + (`src/console/ci/e2e/runner.rs`): a Rust binary that drives the whole scenario using + `std::process::Command` to invoke `docker compose` and any container-side commands. +- Use SQLite as the database backend; database compatibility across multiple versions is already + covered by subissue #1525-01. +- Cover one complete scenario: a seeder sharing a torrent that a leecher downloads in full. +- The binary is responsible for scaffolding (generating a temporary config and torrent file), + starting the services, sending commands into the qBittorrent containers (via their WebUI API + or `docker exec`), polling for completion, asserting the result, and tearing down. +- Do not re-test things already covered at a lower level: announce parsing, scrape format, + whitelist/key logic, or multi-database compatibility. + +## Testing Principles + +The implementation must follow these quality rules. + +- **Isolation**: Each run of the E2E binary must be isolated from any other concurrently running + instance. Achieve this by using a unique Docker Compose project name per run (e.g. + `--project-name qbt-e2e-<random-suffix>`) so container names, networks, and volumes never + collide with a parallel run. +- **Independent system resources**: Do not bind services to fixed host ports. Let Docker assign + ephemeral host ports and discover them from the compose output, so two simultaneous runs cannot + conflict. Place all temporary files (tracker config, payload, `.torrent` file) in a + `tempfile`-managed directory created at runner start and deleted on exit. +- **Cleanup**: `docker compose down --volumes` must be called unconditionally — on success, on + assertion failure, and on panic. Use a Rust `Drop` guard or equivalent to guarantee teardown + even when the runner exits unexpectedly. +- **Mock time when possible**: Use a configurable timeout (CLI argument or env var) for the + leecher-completion poll rather than a hard-coded sleep. If any logic depends on wall-clock time + (e.g. stale peer detection), inject a mockable clock consistent with the `clock` package used + elsewhere in the codebase. +- **Behavior, not implementation**: Assert the outcome the user cares about — the leecher holds a + complete, byte-identical copy of the payload — not which internal tracker counters changed or + which announce endpoints were called. +- **Verified before done**: The binary must be executed end-to-end and produce a passing result in + a clean environment before the subissue is closed. Include a run log in the PR description. + +## Reference QA Workflow + +`contrib/dev-tools/debugging/qbt/run-qbittorrent-e2e.py` in the PR #1695 review branch demonstrates the +scenario (seeder + leecher + tracker via Python subprocess). Treat it as a behavioral reference +only; the implementation here will use `docker compose` instead of manual container management. + +## Proposed Branch + +- `1525-02-qbittorrent-e2e` + +## Tasks + +### 1) Add a docker compose file for the E2E scenario + +Add a compose file (e.g., `compose.qbittorrent-e2e.yaml`) that defines: + +- the tracker service configured with SQLite +- a qbittorrent-seeder container +- a qbittorrent-leecher container + +Steps: + +- Define a tracker service mounting a SQLite config file (generated by the runner). +- Define seeder and leecher services using a suitable qBittorrent image. +- Configure a shared network so all containers can reach each other and the tracker. +- Define any volumes needed to mount the payload and torrent file into each client container. +- Ensure `docker compose up --wait` exits cleanly when services are healthy. +- Ensure `docker compose down --volumes` removes all containers and volumes. + +Acceptance criteria: + +- [x] `docker compose -f compose.qbittorrent-e2e.yaml up --wait` starts all services without error. +- [x] `docker compose -f compose.qbittorrent-e2e.yaml down --volumes` leaves no orphaned resources. + +### 2) Implement the Rust runner binary + +Add a new binary (e.g., `src/bin/qbittorrent_e2e_runner.rs`) that follows the same structure as +`src/console/ci/e2e/runner.rs`: + +- Parses CLI arguments or environment variables (compose file path, payload size, timeout). +- Generates scaffolding: a temporary tracker config (SQLite) and a small deterministic payload + with its `.torrent` file. +- Calls `docker compose up` via `std::process::Command`. +- Seeds the payload: injects the torrent and payload into the seeder container via the qBittorrent + WebUI REST API (or `docker exec` as a fallback) and starts seeding. +- Leaches the payload: injects the `.torrent` file into the leecher container and starts + downloading. +- Polls for completion: queries the leecher's WebUI API until the torrent state reaches + `uploading` (100 % downloaded) or a timeout expires. +- Asserts payload integrity: compares the downloaded file against the original (hash or byte + comparison). +- Calls `docker compose down --volumes` unconditionally (even on assertion failure), mirroring + the cleanup pattern in `tracker_container.rs`. + +Steps: + +- Add a shared `docker compose` wrapper at `src/console/ci/compose.rs` (see below). This + module is not specific to qBittorrent and is reused by the benchmark runner in subissue + `#1525-03`. +- Add a `qbittorrent` module under `src/console/ci/` (parallel to `e2e/`) containing: + - `runner.rs` — main orchestration logic + - `qbittorrent_client.rs` — HTTP calls to the qBittorrent WebUI API +- **`src/console/ci/compose.rs` wrapper** — mirrors `docker.rs` but targets `docker compose` + subcommands. Design it around a `DockerCompose` struct that holds the compose file path and + project name: + - `DockerCompose::new(file: &Path, project: &str) -> Self` + - `up(&self) -> io::Result<()>` — runs `docker compose -f <file> -p <project> up --wait --detach` + - `down(&self) -> io::Result<()>` — runs `docker compose -f <file> -p <project> down --volumes` + - `port(&self, service: &str, container_port: u16) -> io::Result<u16>` — runs + `docker compose -f <file> -p <project> port <service> <port>` and parses the host port so + the runner never hard-codes ports + - `exec(&self, service: &str, cmd: &[&str]) -> io::Result<Output>` — wraps + `docker compose -f <file> -p <project> exec <service> <cmd…>` for injecting commands into + running containers + - Implement `Drop` on a `RunningCompose` guard returned by `up` that calls `down` + unconditionally, matching the `RunningContainer::drop` pattern in `docker.rs` + - Use `tracing` for progress output consistent with the rest of the runner +- Generate a fixed small payload (e.g., 1 MiB of deterministic bytes) at runtime; store the + `.torrent` file in a `tempfile` directory so it is cleaned up automatically. +- Re-use `tracing` for progress output, consistent with the existing runner. + +Acceptance criteria: + +- [x] The runner completes a full seeder → leecher download using the containerized tracker. +- [x] Leecher torrent progress reaches 100% before the runner declares success. +- [x] Downloaded file is verified against the original payload (hash or byte comparison). +- [x] The runner can be executed repeatedly without manual setup or teardown. +- [x] No orphaned containers or volumes remain on success or failure. +- [x] The binary is documented in the top-level module doc comment with an example invocation. +- [x] Each invocation uses a unique compose project name so parallel runs do not conflict. +- [x] All temporary files are placed in a managed temp directory and deleted on exit. +- [x] No fixed host ports are used; ports are discovered dynamically from the compose output. +- [x] `docker compose down --volumes` is called unconditionally via a `Drop` guard. +- [x] A `--keep-containers` flag is provided for debugging (leaves containers running for manual inspection). + +### 3) Verify leecher download completion and payload integrity + +Add validation to ensure the leecher has fully downloaded the payload and verify its integrity. + +Steps: + +- Query the leecher's WebUI API to fetch the torrent details (progress, downloaded bytes, state). +- Poll until the torrent state indicates 100% completion (e.g., `uploading` state or + downloaded bytes = file size). +- After confirmed completion, retrieve the downloaded file from the leecher container + (it should be in the downloads directory via the volume mount). +- Compute a hash (SHA1 or SHA256) of both the original payload and the downloaded copy. +- Compare the hashes; error if they do not match. +- Alternatively, perform a byte-for-byte comparison of the files. + +Acceptance criteria: + +- [x] The runner polls leecher torrent progress until reaching 100%. +- [x] The runner retrieves the downloaded file from the leecher container. +- [x] The runner verifies the downloaded file matches the original payload (hash or byte comparison). +- [x] The runner errors if completion or verification fails within the timeout window. +- [x] The runner logs progress at each step for debugging. + +### 4) Document the E2E workflow and GitHub Actions integration + +Steps: + +- Document the local invocation command (e.g., `cargo run --bin qbittorrent_e2e_runner`). +- Document any prerequisites (Docker, image availability, open ports). +- Clarify that this test is not run in the standard `cargo test` suite due to resource requirements. +- Describe how the E2E runner will be triggered in CI: create or update a GitHub Actions workflow + (either integrated into the existing testing workflow or as a new separate opt-in job) that: + - Runs the E2E runner on push and pull requests (or opt-in via environment variable / workflow + dispatch). + - Logs output and failures for debugging. + - Does not block other tests if it fails (can be marked as non-blocking initially). + - Note: The GitHub Actions workflow step (`run-qbittorrent-e2e-test`) is implemented in + `.github/workflows/testing.yaml`. + +Acceptance criteria: + +- [x] The test is documented and runnable without ad hoc manual steps. +- [x] GitHub Actions workflow integration is implemented in `.github/workflows/testing.yaml`. + +## Out of Scope + +- Testing multiple database backends (covered by subissue #1525-01). +- Testing announce or scrape protocol correctness at the protocol level. +- UDP tracker E2E (can be added later without redesigning the compose setup). + +## Definition of Done + +- [x] Leecher torrent progress verification implemented and tested. +- [x] Downloaded file integrity verification (hash/byte comparison) implemented and tested. +- [x] `cargo test --workspace --all-targets` passes (or the E2E test is explicitly excluded with a + documented opt-in flag). +- [x] `linter all` exits with code `0`. +- [x] The E2E runner has been executed successfully in a clean environment; a passing run log is + included in the PR description. +- [x] GitHub Actions workflow integration is implemented in `.github/workflows/testing.yaml`. + +## References + +- GitHub issue: #1706 +- EPIC: #1525 +- Reference PR: #1695 +- Reference implementation branch: `josecelano:pr-1684-review` — see EPIC for checkout + instructions (`docs/issues/1525-overhaul-persistence.md`) +- Reference script: `contrib/dev-tools/debugging/qbt/run-qbittorrent-e2e.py` +- Existing runner pattern: `src/console/ci/e2e/runner.rs` +- Docker command wrapper: `src/console/ci/e2e/docker.rs` +- Existing container wrapper patterns: `src/console/ci/e2e/tracker_container.rs` + +## Implementation Notes + +### Current Status + +**Completed (in this commit):** + +- Docker Compose file with tracker, seeder, and leecher services +- Rust runner binary with full scaffolding and orchestration +- Torrent upload to both clients via qBittorrent WebUI API +- Polling loop to wait for torrents to appear on both clients (fixes race condition) +- Polling loop to wait for leecher torrent progress to reach 100% +- Payload integrity verification: reads downloaded file from leecher volume mount, + compares byte-for-byte against original, logs SHA1 hash on success +- RAII-based automatic cleanup via `docker compose down --volumes` +- `--keep-containers` debug flag for post-run inspection +- All linting checks passing; runner exits code 0 + +**Pending (follow-up tasks):** + +- GitHub Actions workflow integration + +### Race Condition Resolution + +The qBittorrent REST API's `add_torrent` endpoint returns immediately (HTTP 200) before the +client has fully processed and indexed the torrent. Polling `list_torrents` immediately after +upload returns 0 torrents. This was addressed by implementing a polling loop in +`wait_for_torrent_counts()` that: + +- Polls both seeder and leecher until each reports ≥ 1 torrent +- Retries every 500 ms with a configurable total timeout (default 180 s) +- Errors if the timeout expires without reaching the target count +- Logs each poll attempt for debugging + +### Debugging Flag: `--keep-containers` + +To support post-run inspection of logs and container state (especially when debugging +failures), a `--keep-containers` flag was added to the runner. When set: + +- The RAII guard is disarmed, preventing automatic `docker compose down` +- The runner logs the exact project name and cleanup commands +- User can then manually inspect logs with `docker compose -p <project-name> logs` +- User manually cleans up with `docker compose -p <project-name> down --volumes` + +Usage: + +```sh +cargo run --bin qbittorrent_e2e_runner -- \ + --compose-file ./compose.qbittorrent-e2e.yaml \ + --timeout-seconds 300 \ + --keep-containers +``` + +### Verification + +A passing run log demonstrating core functionality: + +1. **Exit code 0** — Binary exits successfully +2. **Torrent counts verified** — Polling detects both clients reach ≥ 1 torrent +3. **Leecher reaches 100%** — Progress polling logs each step until `stalledUP` +4. **Payload integrity verified** — SHA1 hash of downloaded file matches original +5. **Containers cleaned up** — RAII guard executes `docker compose down --volumes` on exit + +Example output excerpt: + +```text +Seeder has 0 torrent(s), leecher has 0 torrent(s) +Seeder has 1 torrent(s), leecher has 1 torrent(s) +Both clients have at least one torrent — upload confirmed +Leecher torrent progress: 0.0% (state: queuedDL) +Leecher torrent progress: 0.0% (state: stalledDL) +Leecher torrent progress: 100.0% (state: stalledUP) +Leecher torrent download complete (100%) +Payload integrity verified: SHA1 c2fc4cb20f1301a6b0dd211c19e69a13925dbe40 (1048576 bytes match) +``` + +All linting checks (`linter all`) pass with exit code 0. + +### Session Progress Update (2026-04-22) + +Additional validation completed in this session: + +- Re-ran `qbittorrent_e2e_runner` with `--keep-containers` to preserve the stack for manual checks. +- Confirmed leecher WebUI access and authentication on a fresh environment. +- Manually verified in leecher UI that `payload.bin` reached `100%` and moved to `Seeding` state. +- Re-ran `linter all` after documentation updates; all linters pass. + +Operational troubleshooting findings captured during validation: + +- qBittorrent login success must be validated using response body (`Ok.`), not only status code. + Wrong credentials can return `200 OK` with body `Fails.`. +- Repeated failed login attempts trigger temporary IP bans (`403 Forbidden`). +- For manual browser inspection via random host port mappings, forwarding + `localhost:8080` to the published leecher port with `socat` provides a stable access path. + +These findings are documented in `contrib/dev-tools/debugging/qbt/README.md` under +Troubleshooting. + +### GitHub Actions Integration + +The E2E runner is integrated into GitHub Actions via a `run-qbittorrent-e2e-test` step in +`.github/workflows/testing.yaml`. The step runs on push and pull requests with a 600-second +timeout. It is currently non-blocking so it does not gate PR merges while the step stabilizes. diff --git a/docs/issues/1710-1525-03-persistence-benchmarking.md b/docs/issues/1710-1525-03-persistence-benchmarking.md new file mode 100644 index 000000000..2da0a7e8b --- /dev/null +++ b/docs/issues/1710-1525-03-persistence-benchmarking.md @@ -0,0 +1,257 @@ +# Issue #1710 / Subissue #1525-03: Add Persistence Benchmarking + +## Goal + +Establish reproducible before/after persistence benchmarks so later refactors can be evaluated +against a concrete performance baseline. + +## Why After Testing + +Correctness comes first. Benchmarking is useful only after the core persistence behaviors are +already covered by tests, otherwise performance comparisons risk masking regressions in behavior. + +## Scope + +- Implement the benchmark runner as a binary inside `packages/tracker-core`, the package + that owns the persistence layer. No Docker Compose, no image building or swapping. +- Keep the benchmark helper modules private to the binary target instead of exposing them from + the `bittorrent-tracker-core` library API. This keeps development tooling out of the + production module surface while still allowing `cargo run` execution from the same package. +- Benchmark every method of the `Database` trait directly, using real driver instances + (SQLite file on disk; MySQL container via testcontainers — the same mechanism already used + in the package's integration tests). +- Run the benchmark against SQLite and MySQL only. PostgreSQL is not available yet; the runner + must be designed so PostgreSQL can be added in subissue #1525-08 without redesign. +- One invocation produces results for one driver/version combination. Run it three times to + cover `sqlite3`, `mysql:8.0`, and `mysql:8.4`. +- Commit one JSON report per combination under `packages/tracker-core/docs/benchmarking/runs/` + as the baseline. Re-run and update the reports in each subsequent subissue that changes + persistence behavior. The git diff of those JSON files is the before/after comparison. + +## Measurement Tool Rationale + +**Why not Criterion?** `criterion` is a micro-benchmark framework designed for in-process +function calls. It is the right tool for the existing `torrent-repository-benchmarking` crate +(in-memory data structures). It is the wrong tool here because: + +- Each operation involves a real database round-trip via an `r2d2` connection pool. The + overhead and variance are orders of magnitude larger than what criterion's sampling model + expects. +- The before/after comparison spans different branches (and later, different driver + implementations), not two functions in the same process — criterion has no model for that. + +**What to use instead**: `std::time::Instant` per-call timing, collected into a `Vec<Duration>`, +then sorted to extract `best`, `median`, and `worst`. No external stats crate is needed. +Output is JSON only (via `serde_json`). + +## What Gets Measured + +Every method on the `Database` trait, grouped by category: + +| Category | Methods | +| ----------------- | ------------------------------------------------------------------------------------------------------------------- | +| Torrent metrics | `save_torrent_downloads`, `load_torrent_downloads`, `load_all_torrents_downloads`, `increase_downloads_for_torrent` | +| Aggregate metrics | `save_global_downloads`, `load_global_downloads`, `increase_global_downloads` | +| Whitelist | `add_info_hash_to_whitelist`, `get_info_hash_from_whitelist`, `load_whitelist`, `remove_info_hash_from_whitelist` | +| Auth keys | `add_key_to_keys`, `get_key_from_keys`, `load_keys`, `remove_key_from_keys` | + +Each method is called `--ops N` times (default `100`). The collected `Vec<Duration>` is sorted +to produce `count`, `best`, `median`, and `worst` per operation. + +A default of `100` matches the committed baseline reports and produces stable medians. +Pass a larger `--ops` value when tighter statistics are needed. + +## What Is NOT Measured + +- **Startup time** — not a persistence-layer concern; constant across persistence refactors. +- **Concurrent throughput** — the existing drivers are synchronous (`r2d2`); a single-threaded + loop gives stable, comparable numbers. Concurrent load is relevant after the async `sqlx` + migration (subissue #1525-05), but even then the comparison should be single-threaded first. +- **HTTP roundtrip latency** — noise relative to what is being refactored. +- **Before/after image swapping** — the benchmark runs once per branch; the committed report + is the baseline; the git diff is the comparison. + +## Proposed Branch + +- `1710-add-persistence-benchmarking` + +## Testing Principles + +- **Real drivers**: SQLite uses a temporary file on disk; MySQL uses a testcontainers + `GenericImage` — the same mechanism already present in the package's integration tests. +- **MySQL container lifecycle**: reuse the retry logic in + `packages/tracker-core/src/databases/driver/mod.rs` to wait for container readiness. +- **Cleanup**: the testcontainers container is dropped (and therefore stopped) automatically + when the `RunningMysqlContainer` goes out of scope. +- **Verified before done**: run the benchmark in a clean environment and include a copy of + the console output in the PR description alongside the committed JSON reports. + +## Tasks + +### 1) Implement the benchmark runner binary inside `packages/tracker-core` + +Add a new binary and binary-private support module tree to the `bittorrent-tracker-core` +package. + +**Module placement rationale:** + +- Do **not** expose the benchmark implementation from `packages/tracker-core/src/lib.rs`. + Benchmark orchestration is a developer tool, not part of the production library API. +- Do **not** place this implementation under `packages/tracker-core/benches/`. In this + repository, `benches/` is used for Criterion-style `cargo bench` targets. This persistence + runner is different: it has a CLI, writes JSON files, selects database drivers and versions, + and is intended to be run manually with `cargo run`. +- Therefore, keep the executable in `src/bin/` and place its helper modules under a + binary-private directory next to it. + +**New files:** + +```text +packages/tracker-core/src/bin/persistence_benchmark_runner.rs ← thin entry point (3 lines) +packages/tracker-core/src/bin/persistence_benchmark/ + mod.rs ← module doc, re-exports + runner.rs ← CLI args (clap), orchestration, tracing init + driver_bench.rs ← driver setup, measurement loops, RawResults + metrics.rs ← Vec<Duration> → OperationStats (count, best, median, worst) + report.rs ← OperationStats → JSON (serde_json) + types.rs ← newtype wrappers (BenchDriver, Ops, …) +``` + +**Dependencies** — add only to `packages/tracker-core/Cargo.toml` (not the workspace root): + +```toml +clap = { version = "...", features = ["derive"] } +serde_json = { version = "..." } # already present; confirm it is not dev-only +anyhow = { version = "..." } +tracing = { version = "..." } # already present +``` + +Run `cargo machete` after to verify no unused dependencies remain. + +**CLI:** + +```text +cargo run -p bittorrent-tracker-core --bin persistence_benchmark_runner -- \ + --driver sqlite3|mysql # exactly one driver per run + --db-version 8.4 # DB image tag; ignored for sqlite3; default "8.4" for mysql + --ops 100 # samples per operation; default 100 + # JSON report is printed to stdout; redirect to save it +``` + +**Driver setup:** + +- `sqlite3` — create a temporary file path; build the `r2d2_sqlite` pool; create tables. +- `mysql` — start a testcontainers `GenericImage` with the requested `--db-version` tag; + reuse the container readiness retry logic from + `packages/tracker-core/src/databases/driver/mod.rs`. + +**Measurement loop** (per operation): + +1. Prepare realistic input data (a random `InfoHash`, `AuthKey`, etc.). +2. Time each call with `std::time::Instant`. +3. Repeat `--ops` times; collect into a `Vec<Duration>`. +4. Sort and derive `count`, `best`, `median`, `worst`. + +**JSON output schema:** + +```json +{ + "meta": { + "git_revision": "<sha>", + "driver": "sqlite3", + "db_version": "-", + "ops": 100, + "timestamp": "2026-04-28T12:00:00Z" + }, + "operations": [ + { + "name": "add_info_hash_to_whitelist", + "count": 10, + "best_us": 42, + "median_us": 55, + "worst_us": 120 + } + ] +} +``` + +Acceptance criteria: + +- [ ] `cargo run -p bittorrent-tracker-core --bin persistence_benchmark_runner -- --driver sqlite3` + runs to completion and prints a JSON report to stdout. +- [ ] `cargo run -p bittorrent-tracker-core --bin persistence_benchmark_runner -- --driver mysql --db-version 8.4` + runs to completion and prints a JSON report to stdout. +- [ ] JSON schema matches the structure above. +- [ ] `cargo machete` reports no unused dependencies. + +### 2) Commit the baseline benchmark reports + +Run the binary once per driver/version combination on the current branch HEAD and commit the +resulting JSON files. Each subsequent subissue reruns the same commands and commits updated +reports alongside the code change. The git diff is the before/after comparison. + +```bash +cargo run -p bittorrent-tracker-core --bin persistence_benchmark_runner -- \ + --driver sqlite3 \ + > packages/tracker-core/docs/benchmarking/runs/$(date +%F)/sqlite3.json + +cargo run -p bittorrent-tracker-core --bin persistence_benchmark_runner -- \ + --driver mysql --db-version 8.0 \ + > packages/tracker-core/docs/benchmarking/runs/$(date +%F)/mysql-8.0.json + +cargo run -p bittorrent-tracker-core --bin persistence_benchmark_runner -- \ + --driver mysql --db-version 8.4 \ + > packages/tracker-core/docs/benchmarking/runs/$(date +%F)/mysql-8.4.json +``` + +Acceptance criteria: + +- [ ] `packages/tracker-core/docs/benchmarking/runs/<date>/sqlite3.json`, + `mysql-8.0.json`, and `mysql-8.4.json` are committed. +- [ ] Each file identifies the git revision, driver, db-version, ops count, and timestamp. + +### 3) Document the workflow + +- Add a section to `docs/benchmarking.md` explaining how to invoke the benchmark locally, how + to interpret the JSON output, and how to produce an updated report after each subsequent + subissue. +- Note that PostgreSQL support will be added in subissue #1525-08. + +Acceptance criteria: + +- [ ] `docs/benchmarking.md` documents the full workflow without ad hoc manual steps. + +## Out of Scope + +- PostgreSQL support (reserved for subissue #1525-08). +- Concurrent throughput measurement (deferred until after the async `sqlx` migration in + subissue #1525-05). +- Startup time measurement (not a persistence-layer concern). +- HTTP-level benchmarking (noise relative to what is being refactored). +- Defining hard performance gates for CI. +- Replacing correctness-focused tests. +- The existing `torrent-repository-benchmarking` criterion micro-benchmarks (those measure + in-memory data structures, not the full persistence stack). + +## Definition of Done + +- [ ] `cargo run -p bittorrent-tracker-core --bin persistence_benchmark_runner -- --driver sqlite3` + runs to completion and prints a summary. +- [ ] `cargo run -p bittorrent-tracker-core --bin persistence_benchmark_runner -- --driver mysql --db-version 8.4` + runs to completion and prints a summary. +- [ ] `packages/tracker-core/docs/benchmarking/runs/<date>/sqlite3.json`, + `mysql-8.0.json`, and `mysql-8.4.json` are committed. +- [ ] `docs/benchmarking.md` documents the workflow. +- [ ] `cargo test --workspace --all-targets` passes. +- [ ] `linter all` exits with code `0`. +- [ ] A passing run log is included in the PR description. + +## References + +- EPIC: #1525 +- GitHub issue: #1710 +- Existing driver test infrastructure: `packages/tracker-core/src/databases/driver/mod.rs` +- MySQL container helper: `packages/tracker-core/src/databases/driver/mysql.rs` + (`StoppedMysqlContainer`, `RunningMysqlContainer`) +- Style reference for binary layout: `src/console/ci/qbittorrent_e2e/runner.rs` +- Benchmarking docs: `docs/benchmarking.md` diff --git a/docs/issues/1713-1525-04-split-persistence-traits.md b/docs/issues/1713-1525-04-split-persistence-traits.md new file mode 100644 index 000000000..c73ad31a4 --- /dev/null +++ b/docs/issues/1713-1525-04-split-persistence-traits.md @@ -0,0 +1,298 @@ +# Issue #1713 (Subissue of #1525-04): Split Persistence Traits by Context + +## Goal + +Decompose the monolithic `Database` trait into four focused context traits while +keeping `Database` as the unified driver contract, and write an ADR to record the +decision. + +## Background + +`packages/tracker-core/src/databases/mod.rs` defines a single `Database` trait with +19 methods covering four unrelated concerns: schema management, torrent metrics, +whitelist, and authentication keys. This makes the trait long and conflates distinct +responsibilities in one place. + +Two options were considered: + +1. **Replace `Database` with four independent traits** — consumers hold + `Arc<dyn WhitelistStore>` etc. directly. Clean interface segregation, but it loses + the single place that tells a new driver implementor exactly what to build, and it + changes every consumer at once. + +2. **Keep `Database` as an aggregate supertrait** (chosen) — the four narrow traits + exist independently; `Database` is defined as: + + ```rust + pub trait Database: + Sync + Send + SchemaMigrator + TorrentMetricsStore + WhitelistStore + AuthKeyStore {} + ``` + + A blanket impl means any type that implements all four narrow traits automatically + satisfies `Database`. Existing consumers (`Arc<Box<dyn Database>>`) are untouched. + +This preserves both goals: + +- **One place to discover the full driver contract**: `Database` and its four supertrait + bounds tell a new implementor exactly what to write. +- **Compiler-enforced completeness**: adding a fifth supertrait later causes a compile + error in every driver that does not yet implement it. +- **Interface segregation at the consumer level**: the four narrow traits can be used + directly in tests (`MockWhitelistStore` etc.) and optionally as dependency types once + the MSRV allows trait-object upcasting (stabilised in Rust 1.76; current MSRV is 1.72). + +## Proposed Branch + +- `1713-1525-04-split-persistence-traits` + +## Current State + +The starting point (before this subissue): + +```text +packages/tracker-core/src/databases/ + mod.rs ← Database trait (19 methods, all concerns in one block) + driver/ + mod.rs + sqlite.rs ← impl Database for Sqlite { ... 19 methods ... } + mysql.rs ← impl Database for Mysql { ... 19 methods ... } + error.rs + setup.rs +``` + +The four context groups already exist as doc-comment markers inside the trait +(`# Context: Schema`, `# Context: Torrent Metrics`, etc.) — this subissue makes those +boundaries structural. + +## Target State + +```text +packages/tracker-core/src/databases/ + mod.rs ← module declarations, re-exports + database.rs ← Database aggregate trait + blanket impl + schema.rs ← SchemaMigrator trait + torrent_metrics.rs ← TorrentMetricsStore trait + whitelist.rs ← WhitelistStore trait + auth_keys.rs ← AuthKeyStore trait + driver/ + mod.rs + sqlite.rs ← impl SchemaMigrator + TorrentMetricsStore + + WhitelistStore + AuthKeyStore for Sqlite + mysql.rs ← same for Mysql + error.rs + setup.rs +``` + +## Tasks + +### 1) Write the ADR + +Create `docs/adrs/<timestamp>_keep_database_as_aggregate_supertrait.md` recording: + +- The problem (19-method monolith, unclear per-context boundaries). +- The two options considered (independent traits vs. aggregate supertrait). +- The decision and rationale (aggregate supertrait — see Background above). +- The known constraint: trait-object upcasting from `dyn Database` to a narrow + `dyn XxxStore` requires Rust ≥ 1.76; the MSRV today is 1.72, so consumer wiring + stays as `Arc<Box<dyn Database>>` for now. + +Add a row to `docs/adrs/index.md`. + +### 2) Introduce the four narrow traits + +Create one file per trait. Each file contains only that trait's methods, moved verbatim +from `Database` (doc-comments included), plus `#[automock]` for mockall. + +**`databases/schema.rs`** — `SchemaMigrator`: + +```rust +#[automock] +pub trait SchemaMigrator: Sync + Send { + fn create_database_tables(&self) -> Result<(), Error>; + fn drop_database_tables(&self) -> Result<(), Error>; +} +``` + +**`databases/torrent_metrics.rs`** — `TorrentMetricsStore`: + +```rust +#[automock] +pub trait TorrentMetricsStore: Sync + Send { + fn load_all_torrents_downloads(&self) -> Result<NumberOfDownloadsBTreeMap, Error>; + fn load_torrent_downloads(&self, info_hash: &InfoHash) -> Result<Option<NumberOfDownloads>, Error>; + fn save_torrent_downloads(&self, info_hash: &InfoHash, downloaded: NumberOfDownloads) -> Result<(), Error>; + fn increase_downloads_for_torrent(&self, info_hash: &InfoHash) -> Result<(), Error>; + fn load_global_downloads(&self) -> Result<Option<NumberOfDownloads>, Error>; + fn save_global_downloads(&self, downloaded: NumberOfDownloads) -> Result<(), Error>; + fn increase_global_downloads(&self) -> Result<(), Error>; +} +``` + +**`databases/whitelist.rs`** — `WhitelistStore`: + +```rust +#[automock] +pub trait WhitelistStore: Sync + Send { + fn load_whitelist(&self) -> Result<Vec<InfoHash>, Error>; + fn get_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result<Option<InfoHash>, Error>; + fn add_info_hash_to_whitelist(&self, info_hash: InfoHash) -> Result<usize, Error>; + fn remove_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result<usize, Error>; + fn is_info_hash_whitelisted(&self, info_hash: InfoHash) -> Result<bool, Error> { + Ok(self.get_info_hash_from_whitelist(info_hash)?.is_some()) + } +} +``` + +**`databases/auth_keys.rs`** — `AuthKeyStore`: + +```rust +#[automock] +pub trait AuthKeyStore: Sync + Send { + fn load_keys(&self) -> Result<Vec<authentication::PeerKey>, Error>; + fn get_key_from_keys(&self, key: &Key) -> Result<Option<authentication::PeerKey>, Error>; + fn add_key_to_keys(&self, auth_key: &authentication::PeerKey) -> Result<usize, Error>; + fn remove_key_from_keys(&self, key: &Key) -> Result<usize, Error>; +} +``` + +### 3) Introduce the `Database` aggregate trait + +Create `databases/database.rs`: + +```rust +use super::{AuthKeyStore, SchemaMigrator, TorrentMetricsStore, WhitelistStore}; + +/// The full driver contract. +/// +/// A new database driver must implement all four supertrait bounds. The blanket +/// impl below means that any type satisfying all four automatically satisfies +/// `Database` — no separate `impl Database for MyDriver {}` is needed. +/// +/// `Arc<Box<dyn Database>>` continues to be the wiring type used by driver +/// setup and consumer repositories. Direct use of the narrow traits as +/// dependency types will become practical once the MSRV reaches 1.76 +/// (trait-object upcasting). +pub trait Database: + Sync + Send + SchemaMigrator + TorrentMetricsStore + WhitelistStore + AuthKeyStore +{ +} + +impl<T> Database for T where + T: Sync + Send + SchemaMigrator + TorrentMetricsStore + WhitelistStore + AuthKeyStore +{ +} +``` + +Remove the `#[automock]` from the old `Database` trait definition — mocking now happens +through the four narrow traits. + +### 4) Update the drivers + +In `driver/sqlite.rs` and `driver/mysql.rs`: + +- Remove `impl Database for <Driver> { ... }` (the blanket impl replaces it). +- Add four separate `impl` blocks — one per narrow trait — containing the same method + bodies that were previously in the single `impl Database` block. +- No logic changes. This is a mechanical redistribution of existing code. + +Example structure after the change: + +```rust +impl SchemaMigrator for Sqlite { + fn create_database_tables(&self) -> Result<(), Error> { ... } + fn drop_database_tables(&self) -> Result<(), Error> { ... } +} + +impl TorrentMetricsStore for Sqlite { + fn load_all_torrents_downloads(&self) -> Result<NumberOfDownloadsBTreeMap, Error> { ... } + // ... remaining 6 methods +} + +impl WhitelistStore for Sqlite { + // ... 5 methods +} + +impl AuthKeyStore for Sqlite { + // ... 4 methods +} +``` + +If the driver file becomes unwieldy, the four `impl` blocks can be moved into a +`driver/sqlite/` submodule — but that is optional and not required by this subissue. + +### 5) Update `mod.rs` + +- Declare the four new submodules. +- Re-export the traits and the `MockXxx` types so existing `use +crate::databases::Database` imports continue to work. +- Remove the method bodies and imports that were previously inlined in `mod.rs`. + +After the change, `mod.rs` should be a thin index: + +```rust +pub mod auth_keys; +pub mod database; +pub mod driver; +pub mod error; +pub mod schema; +pub mod setup; +pub mod torrent_metrics; +pub mod whitelist; + +pub use auth_keys::{AuthKeyStore, MockAuthKeyStore}; +pub use database::Database; +pub use schema::{MockSchemaMigrator, SchemaMigrator}; +pub use torrent_metrics::{MockTorrentMetricsStore, TorrentMetricsStore}; +pub use whitelist::{MockWhitelistStore, WhitelistStore}; +``` + +## Implementation Notes + +- **`mockall` dependency**: Already present in `[dependencies]` of `tracker-core/Cargo.toml`. + No change needed. + +- **ADR timestamp**: Use the date the ADR is authored (`YYYYMMDDHHMMSS` format, today's date). + +- **Consumer file changes**: The spirit of this subissue is not to mix refactorings — keep the + focus on the structural split. However, if test-only code (e.g. `MockDatabase` usage in + `handler.rs`) must be updated to compile after `MockDatabase` is removed, that change is + acceptable. Production consumer files (`persisted.rs`, `downloads.rs`, etc.) must not change. + +- **Method signatures**: Follow the actual code in `mod.rs` — the spec snippets are suggestions + and may have drifted. In particular, `save_torrent_downloads` takes `completed: u32` (not + `NumberOfDownloads`) in the current code. + +## Out of Scope + +- Changing consumer wiring from `Arc<Box<dyn Database>>` to narrow trait objects. + That is blocked by the MSRV constraint and is deferred. +- Async trait methods. That is subissue #1525-05. +- Schema migrations. That is subissue #1525-06. +- PostgreSQL support. That is subissue #1525-08. + +## Acceptance Criteria + +- [ ] ADR is written and added to `docs/adrs/index.md`. +- [ ] Four narrow traits exist in separate files under `databases/`. +- [ ] `Database` is an empty aggregate supertrait with a blanket impl. +- [ ] Both drivers (`Sqlite`, `Mysql`) compile through the blanket impl with no manual + `impl Database for <Driver>` block. +- [ ] Production consumer files (`persisted.rs`, `downloads.rs`, etc.) are not changed. +- [ ] Test code that used `MockDatabase` is updated to use the appropriate narrow mock type. +- [ ] `#[automock]` is on the four narrow traits; `MockDatabase` is removed. +- [ ] No behavior change — existing tests pass without modification. +- [ ] Persistence benchmarking (see subissue #1525-03) shows no regression against the + committed baseline. +- [ ] `cargo test --workspace --all-targets` passes. +- [ ] `linter all` exits with code `0`. + +## References + +- EPIC: #1525 +- Reference PR: #1695 +- Reference implementation branch: `josecelano:pr-1684-review` — see EPIC for checkout + instructions (`docs/issues/1525-overhaul-persistence.md`) +- `packages/tracker-core/src/databases/mod.rs` — current monolithic `Database` trait +- `packages/tracker-core/src/whitelist/repository/persisted.rs` — example consumer +- `packages/tracker-core/src/statistics/persisted/downloads.rs` — example consumer +- `packages/tracker-core/src/authentication/key/repository/persisted.rs` — example consumer diff --git a/docs/issues/1715-1525-04b-migrate-consumers-to-narrow-traits.md b/docs/issues/1715-1525-04b-migrate-consumers-to-narrow-traits.md new file mode 100644 index 000000000..d1ed29a07 --- /dev/null +++ b/docs/issues/1715-1525-04b-migrate-consumers-to-narrow-traits.md @@ -0,0 +1,171 @@ +# Subissue Draft for #1525-04b: Migrate Consumers to Narrow Persistence Traits + +## Goal + +Replace every use of `Arc<Box<dyn Database>>` in production and test code with +the specific narrow trait the consumer actually needs (`AuthKeyStore`, +`TorrentMetricsStore`, `WhitelistStore`, or `SchemaMigrator`). After this +subissue the `Database` aggregate supertrait becomes a purely internal +compile-time guard that is no longer part of the public surface of +`tracker-core`. + +## Background + +Subissue #1525-04 (GitHub [#1713](https://github.com/torrust/torrust-tracker/issues/1713)) +introduced the four narrow traits and kept `Database` as an aggregate supertrait +so that consumer call sites did not need to change. + +Now that the structural split is in place, this subissue wires consumers to the +narrow traits they actually need. No upcasting is required: the factory will +construct the concrete driver (`Sqlite`, `Mysql`) and coerce it directly into +each narrow `Arc<dyn XxxStore>`. Coercion from a sized type to a trait object is +available on all Rust versions. + +## Proposed Branch + +- `1525-04b-migrate-consumers-to-narrow-traits` + +## Current State + +All consumers depend on `Arc<Box<dyn Database>>` for everything, regardless of +which methods they actually call: + +| Consumer | Methods actually used | +| -------------------------------------------------- | ----------------------------------------------------------- | +| `DatabaseKeyRepository` | `AuthKeyStore` methods only | +| `DatabaseDownloadsMetricRepository` | `TorrentMetricsStore` methods only | +| `whitelist::setup::initialize_whitelist_manager` | `WhitelistStore` methods only | +| `databases::driver::build` / `initialize_database` | `SchemaMigrator::create_database_tables` only | +| `bin/persistence_benchmark` | All four concerns — uses `Database` as a convenience bundle | +| `container::TrackerCoreContainer` | Holds the database and fans it out to the above | + +## Target State + +```text +TrackerCoreContainer + database_stores: DatabaseStores ← replaces Arc<Box<dyn Database>> + ...rest of fields unchanged... +``` + +`DatabaseStores` is a plain struct holding one `Arc<dyn XxxStore>` per context. +The container stores it as one named field; individual services are wired at +construction time by passing the relevant field (e.g. +`database_stores.auth_key_store.clone()`) to each service constructor. Services +themselves never see `DatabaseStores` — they receive only the narrow trait they +need. + +The factory (`databases::driver::build` / `initialize_database`) constructs the +concrete driver once and produces four `Arc<dyn XxxStore>` coercions from it: + +```rust +pub struct DatabaseStores { + pub schema_migrator: Arc<dyn SchemaMigrator>, + pub torrent_metrics_store: Arc<dyn TorrentMetricsStore>, + pub whitelist_store: Arc<dyn WhitelistStore>, + pub auth_key_store: Arc<dyn AuthKeyStore>, +} + +pub fn initialize_database(config: &Core) -> DatabaseStores { + match config.database.driver { + Driver::Sqlite3 => { + let db = Arc::new(Sqlite::new(&config.database.path).expect("...")); + db.create_database_tables().expect("..."); + DatabaseStores { + schema_migrator: db.clone(), + torrent_metrics_store: db.clone(), + whitelist_store: db.clone(), + auth_key_store: db, + } + } + Driver::MySQL => { /* same pattern */ } + } +} +``` + +## Tasks + +### 1) Introduce `DatabaseStores` + +Add a plain struct `databases::setup::DatabaseStores` holding one `Arc<dyn XxxStore>` +per narrow trait. No `Arc<Box<dyn Database>>`. + +### 2) Update `initialize_database` + +Change the return type from `Arc<Box<dyn Database>>` to `DatabaseStores`. +Build the concrete driver, call `create_database_tables`, then produce the four +coercions. + +### 3) Update `TrackerCoreContainer` + +- Replace `pub database: Arc<Box<dyn Database>>` with `pub database_stores: DatabaseStores`. +- Update `initialize_from` to call `initialize_database` (which now returns + `DatabaseStores`) and fan the narrow stores out to each service constructor: + + ```rust + let db = initialize_database(core_config); + let whitelist_manager = initialize_whitelist_manager(db.whitelist_store.clone(), ...); + let db_key_repository = Arc::new(DatabaseKeyRepository::new(db.auth_key_store.clone())); + let db_downloads = Arc::new(DatabaseDownloadsMetricRepository::new(db.torrent_metrics_store.clone())); + // ... store the struct itself so callers can still access it if needed + Self { database_stores: db, ... } + ``` + +### 4) Update individual consumers + +- `DatabaseKeyRepository::new` — accept `Arc<dyn AuthKeyStore>` instead of + `Arc<Box<dyn Database>>`. +- `DatabaseDownloadsMetricRepository::new` — accept `Arc<dyn TorrentMetricsStore>`. +- `whitelist::setup::initialize_whitelist_manager` — accept `Arc<dyn WhitelistStore>`. + +### 5) Update tests in `authentication/handler.rs` + +Replace `Arc<Box<dyn Database>>` wiring with `MockAuthKeyStore` injected +directly as `Arc<dyn AuthKeyStore>`. + +### 6) Update `axum-rest-tracker-api-server` test helper + +`packages/axum-rest-tracker-api-server/tests/server/mod.rs::force_database_error` +currently receives `&Arc<Box<dyn Database>>`. Update to the narrow trait(s) it +actually exercises. + +### 7) Update benchmark binary + +`bin/persistence_benchmark/driver_bench/` passes `&dyn Database` to operations +that each touch only one concern. Update each operation function to accept the +narrow trait it needs: + +- `operations/torrent.rs` → `&dyn TorrentMetricsStore` +- `operations/whitelist.rs` → `&dyn WhitelistStore` +- `operations/keys.rs` → `&dyn AuthKeyStore` +- `database/mod.rs::reset_database` → `&dyn SchemaMigrator` + +### 8) Make `Database` private + +Once no production or test code outside `databases/` uses `Database`, stop +re-exporting it from `databases/mod.rs`. Keep it accessible inside +`databases/traits/database.rs` for driver authors. + +## Out of Scope + +- Async trait methods. That is subissue #1525-05. +- Schema migrations. That is subissue #1525-06. +- PostgreSQL support. That is subissue #1525-08. + +## Acceptance Criteria + +- [ ] `Arc<Box<dyn Database>>` appears only inside `databases/` (driver + traits). +- [ ] Each consumer holds only the narrow trait(s) it uses. +- [ ] `Database` is no longer re-exported from `databases/mod.rs`. +- [ ] Tests in `authentication/handler.rs` use `MockAuthKeyStore` directly. +- [ ] `force_database_error` helper in `axum-rest-tracker-api-server` is updated. +- [ ] Benchmark operations accept narrow traits. +- [ ] `cargo test --workspace --all-targets` passes. +- [ ] `linter all` exits with code `0`. + +## References + +- EPIC: #1525 +- GitHub Issue: #1715 +- Predecessor: [docs/issues/1713-1525-04-split-persistence-traits.md](1713-1525-04-split-persistence-traits.md) +- ADR: [docs/adrs/20260429000000_keep_database_as_aggregate_supertrait.md](../adrs/20260429000000_keep_database_as_aggregate_supertrait.md) +- Successor: [docs/issues/1525-05-migrate-sqlite-and-mysql-to-sqlx.md](1525-05-migrate-sqlite-and-mysql-to-sqlx.md) diff --git a/docs/issues/1717-1525-05-migrate-sqlite-and-mysql-to-sqlx.md b/docs/issues/1717-1525-05-migrate-sqlite-and-mysql-to-sqlx.md new file mode 100644 index 000000000..c4977cd89 --- /dev/null +++ b/docs/issues/1717-1525-05-migrate-sqlite-and-mysql-to-sqlx.md @@ -0,0 +1,411 @@ +# Subissue Draft for #1525-05: Migrate SQLite and MySQL Drivers to sqlx + +## Goal + +Move the existing SQL backends to a shared async `sqlx` substrate before adding PostgreSQL. + +## Why + +PostgreSQL should not be added as a special case. The existing SQL backends need to follow the same +async persistence model first so PostgreSQL can land on a common foundation. + +## Proposed Branch + +- `1525-05-migrate-sqlite-and-mysql-to-sqlx` + +## Background + +### Starting point + +Subissue `1525-04` has already been merged into `develop` (it is included in this branch). +It split the monolithic `Database` trait into four narrow sync traits (`SchemaMigrator`, +`TorrentMetricsStore`, `WhitelistStore`, `AuthKeyStore`) plus a `Database` aggregate supertrait +with a blanket impl. Consumers still hold `Arc<Box<dyn Database>>`. + +The existing drivers (`Sqlite` in `driver/sqlite.rs`, `Mysql` in `driver/mysql.rs`) use +synchronous connection pools (`r2d2_sqlite`/`r2d2` for SQLite, the `mysql` crate for MySQL). +`build()` in `driver/mod.rs` calls `create_database_tables()` eagerly on startup. + +### Migration strategy: green parallel → single switch commit + +Rewriting both drivers at once while simultaneously making all four traits async would keep the +branch in a broken ("red") state for an extended period. Instead, this subissue uses a +**green parallel approach**: + +1. Build the async infrastructure and new driver implementations alongside the existing sync code + (Tasks 1–3). The branch compiles and all tests pass throughout these tasks. +2. Wire everything up and remove the old code in a single focused switch commit (Task 4). The + branch is briefly in a red state only during this commit. + +The technique is to put the async traits and new drivers in a temporary `databases/sqlx/` +submodule during Tasks 1–3. Task 4 moves them into place, updates consumers, and removes the sync +code. + +### Decision update (2026-04-29) + +After implementation review, we decided to keep **eager schema initialization** in this subissue +for operational clarity and parity with the existing sync drivers: + +- Do **not** use per-method lazy schema checks (`ensure_schema()`). +- Keep explicit startup initialization (`create_database_tables()`) in setup/factory wiring. +- Keep using raw `sqlx::query()` DDL in this subissue; migration tooling stays in `1525-06`. + +This decision also applies to Task 4 (switch commit): keep eager initialization there as well. + +### What changes in the drivers + +The current drivers use blocking I/O and create the schema eagerly on construction. The new +`sqlx`-backed drivers: + +- Use `SqlitePool` / `MySqlPool` with lazy `connect_lazy_with()`. +- Manage the schema with raw `sqlx::query()` DDL statements (`CREATE TABLE IF NOT EXISTS ...`), + exactly mirroring what the current sync drivers do. `sqlx::migrate!()` and migration files are + **not** introduced here — that is subissue `1525-06`. +- Keep schema initialization eager via setup/factory initialization (`create_database_tables()`). +- All trait methods become `async fn` (via `async_trait`). + +## Tasks + +### Task 1 — Add sqlx infrastructure (no behavior change, stays green) + +Add the async substrate without touching the existing drivers or traits. + +#### Dependencies + +In `packages/tracker-core/Cargo.toml`, add: + +```toml +async-trait = "*" # latest compatible with MSRV 1.72 +sqlx = { version = "*", features = ["sqlite", "mysql", "runtime-tokio-native-tls"] } # latest compatible +tokio = { version = "*", features = ["full"] } # latest compatible; if not already present with needed features +``` + +Use the latest crate versions compatible with MSRV 1.72. + +Keep `r2d2`, `r2d2_sqlite`, `rusqlite`, and the `mysql` crate — they are still needed by the old +drivers until Task 4. + +#### Error handling + +Update `databases/error.rs` so that `sqlx::Error` can be converted into the existing `Error` +type. The variants `ConnectionError`, `InvalidQuery`, and `QueryReturnedNoRows` **already exist** +in `error.rs`; do not re-introduce them. The only required change is: + +- Broaden `ConnectionError`: its `source` field currently wraps `LocatedError<'static, UrlError>` + (MySQL-specific). Generalize it to `LocatedError<'static, dyn std::error::Error + Send + Sync>` + so it can hold any connection-level error from sqlx as well. +- Add `From<(sqlx::Error, Driver)>` — maps `sqlx::Error` variants to `ConnectionError`, + `QueryReturnedNoRows`, or `InvalidQuery` based on error kind (see reference `error.rs`). Do not + add `Error::migration_error()` — that belongs to `1525-06`. + +Do not change any other existing variants. The `ConnectionPool` variant (wraps `r2d2::Error`) is +removed in Task 4 together with the `r2d2` dependency. + +**Outcome**: `cargo test --workspace --all-targets` still passes. No behavior change. + +### Task 2 — Implement async SQLite driver (stays green) + +Create a new async SQLite driver in a parallel `databases/sqlx/` submodule without touching the +existing `databases/driver/sqlite/` subdirectory. + +> **Note**: post-1525-04 the sync drivers are already split into per-trait files. The actual +> existing layout is: +> +> ```text +> databases/driver/sqlite/mod.rs +> databases/driver/sqlite/schema_migrator.rs +> databases/driver/sqlite/torrent_metrics_store.rs +> databases/driver/sqlite/whitelist_store.rs +> databases/driver/sqlite/auth_key_store.rs +> ``` +> +> The async parallel module must mirror this layout. + +#### New files + +```text +packages/tracker-core/src/databases/sqlx/mod.rs ← async trait definitions + AsyncDatabase aggregate +packages/tracker-core/src/databases/sqlx/sqlite/mod.rs ← SqliteSqlx struct + pool/latch +packages/tracker-core/src/databases/sqlx/sqlite/schema_migrator.rs +packages/tracker-core/src/databases/sqlx/sqlite/torrent_metrics_store.rs +packages/tracker-core/src/databases/sqlx/sqlite/whitelist_store.rs +packages/tracker-core/src/databases/sqlx/sqlite/auth_key_store.rs +``` + +#### Async trait definitions (`databases/sqlx/mod.rs`) + +Define async versions of the four narrow traits. Use `async_trait` for object safety: + +```rust +use async_trait::async_trait; + +#[async_trait] +pub trait AsyncSchemaMigrator: Send + Sync { + async fn create_database_tables(&self) -> Result<(), Error>; + async fn drop_database_tables(&self) -> Result<(), Error>; +} + +// ... AsyncTorrentMetricsStore, AsyncWhitelistStore, AsyncAuthKeyStore (same method +// signatures as their sync counterparts but with async fn) + +pub trait AsyncDatabase: + AsyncSchemaMigrator + AsyncTorrentMetricsStore + AsyncWhitelistStore + AsyncAuthKeyStore +{ +} + +impl<T> AsyncDatabase for T where + T: AsyncSchemaMigrator + AsyncTorrentMetricsStore + AsyncWhitelistStore + AsyncAuthKeyStore +{ +} +``` + +#### `SqliteSqlx` struct (`databases/sqlx/sqlite.rs`) + +Mirrors the reference `Sqlite` in `driver/sqlite.rs` (PR branch): + +```rust +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use sqlx::SqlitePool; +use std::sync::atomic::{AtomicBool, Ordering}; +use tokio::sync::Mutex; + +pub(crate) struct SqliteSqlx { + pool: SqlitePool, + schema_ready: AtomicBool, + schema_lock: Mutex<()>, +} +``` + +Implement `AsyncSchemaMigrator`, `AsyncTorrentMetricsStore`, `AsyncWhitelistStore`, and +`AsyncAuthKeyStore` for `SqliteSqlx`. All SQL queries use `sqlx::query(...)`. Schema +initialization in `create_database_tables()` executes raw `CREATE TABLE IF NOT EXISTS ...` +statements via `sqlx::query()` — no `sqlx::migrate!()` in this step. + +#### Tests + +Add an inline `#[cfg(test)]` module in `databases/sqlx/sqlite.rs`. Use the shared +`databases/driver/tests::run_tests()` helper (or a new async equivalent) to run all behavioral +tests against `SqliteSqlx`. Use `torrust_tracker_test_helpers::configuration::ephemeral_sqlite_database` +for the in-memory/temp-file path. + +**Outcome**: `cargo test --workspace --all-targets` still passes. Old sync `Sqlite` driver +untouched. + +### Task 3 — Implement async MySQL driver (stays green) + +Create a `packages/tracker-core/src/databases/sqlx/mysql/` subdirectory mirroring the same +per-trait file layout as `databases/sqlx/sqlite/` (i.e. `mod.rs`, `schema_migrator.rs`, +`torrent_metrics_store.rs`, `whitelist_store.rs`, `auth_key_store.rs`) but using `MySqlPool`. Schema initialization uses raw +`sqlx::query()` DDL — no `sqlx::migrate!()` in this step. + +Implement the same four async traits. Add an inline `#[cfg(test)]` module that runs the shared +behavioral test suite against a real MySQL instance (via environment variable guard +`TORRUST_TRACKER_CORE_RUN_MYSQL_DRIVER_TEST=true`, consistent with existing MySQL test gating). + +**Outcome**: `cargo test --workspace --all-targets` still passes. Old sync `Mysql` driver +untouched. + +### Task 4 — Switch: replace sync traits with async, update consumers (brief red) + +This task is a single focused commit. Steps within the commit: + +1. **Rename async traits to canonical names**: rename `AsyncSchemaMigrator` → `SchemaMigrator`, + `AsyncTorrentMetricsStore` → `TorrentMetricsStore`, etc. in `databases/sqlx/mod.rs`. Rename + `AsyncDatabase` → `Database`. Move the trait definitions from `databases/sqlx/mod.rs` into + `databases/traits/` (replacing the sync trait definitions in + `databases/traits/schema.rs`, `databases/traits/torrent_metrics.rs`, + `databases/traits/whitelist.rs`, `databases/traits/auth_keys.rs`). + Move the driver subdirectories, overwriting the old sync drivers: + `databases/sqlx/sqlite/` → `databases/driver/sqlite/` and + `databases/sqlx/mysql/` → `databases/driver/mysql/`. + Remove the now-empty `databases/sqlx/` submodule. + +2. **Rename driver structs**: rename `SqliteSqlx` → `Sqlite`, `MysqlSqlx` → `Mysql`. + +3. **Clean up `databases/driver/mod.rs`**: remove the sync test helpers that call trait methods + without `.await`; replace with async equivalents. + +4. **Update `databases/setup.rs` — `initialize_database()`**: this function already returns + `DatabaseStores` (a struct of four `Arc<dyn XxxStore>` fields, one per narrow trait — not + `Arc<Box<dyn Database>>`). Keep eager `create_database_tables()` during initialization. + No return-type change is needed. + +5. **Add `.await` at all consumer call sites**: every location that called a narrow-trait method + synchronously now needs `.await`. The affected files are: + - `statistics/persisted/downloads.rs` (`DatabaseDownloadsMetricRepository`) + - `whitelist/repository/persisted.rs` (`DatabaseWhitelist`) + - `whitelist/setup.rs` + - `authentication/key/repository/persisted.rs` (`DatabaseKeyRepository`) + - `authentication/handler.rs` (test helpers) + - `src/bin/persistence_benchmark/driver_bench/` and + `src/bin/persistence_benchmark/driver_bench/operations/` (benchmark binary) + - Any integration tests in `tests/` + +6. **Remove unused dependencies**: remove `r2d2`, `r2d2_sqlite`, `rusqlite`, and `r2d2_mysql` + from `tracker-core/Cargo.toml`. Also remove the `ConnectionPool` error variant and its + `From<(r2d2::Error, Driver)>` impl from `databases/error.rs`. Run `cargo machete` to verify. + +7. **Update mock usage**: `#[automock]` on the narrow traits generates async mocks via `mockall`. + Note that `MockDatabase` was already removed in `1525-04` (the aggregate supertrait has no + methods). The actual breakage surface in this switch commit is the four narrow-trait mocks: + `MockSchemaMigrator`, `MockTorrentMetricsStore`, `MockWhitelistStore`, and `MockAuthKeyStore`. + Any tests written against the **sync** versions of these mocks (from `1525-04`) will fail to + compile after the switch because async `mockall` mocks use + `.returning(|| Box::pin(async { Ok(()) }))` rather than `.returning(|| Ok(()))`. Find and + update all such tests before declaring this task complete. + +**Outcome**: `cargo test --workspace --all-targets` passes. `linter all` exits `0`. Sync drivers +and all `r2d2`/`rusqlite`/`mysql` dependencies are gone. + +### Task 5 — Remove sync-to-async runtime bridges (cleanup follow-up) + +During Task 4, some sync wrappers were introduced to keep existing sync consumers working +while trait methods became async (helpers named `block_on_current_or_new_runtime`). +These wrappers are a transitional compatibility mechanism and should be removed. + +This task migrates remaining sync call paths to native async end-to-end: + +1. Make repository/service methods async where they call async persistence traits. +2. Propagate `.await` through callers instead of blocking at lower layers. +3. Remove all `block_on_current_or_new_runtime` helpers from tracker-core modules. +4. Keep runtime ownership at application boundaries only (no nested runtime creation). +5. Preserve eager schema initialization behavior while using async initialization paths. + +**Outcome**: no `block_on_current_or_new_runtime` helper remains; persistence interactions +are fully async from call sites to drivers; tests, linters, and benchmarks still pass. + +### Task 6 — Remove legacy persistence surface and temporary sqlx staging tree + +The branch still contains a mixed layout: + +- canonical runtime code under `packages/tracker-core/src/databases/driver/` and + `packages/tracker-core/src/databases/traits/` +- temporary migration staging code under `packages/tracker-core/src/databases/sqlx/` +- legacy compatibility dependencies and error conversions that were expected to disappear in the + switch commit + +This task finishes the structural cleanup so the repository reflects a single persistence model. + +1. Remove the temporary staging subtree under `packages/tracker-core/src/databases/sqlx/`, + including its nested `driver/` and `traits/` directories. +2. Ensure `packages/tracker-core/src/databases/driver/` contains only the canonical sqlx-backed + implementations that remain in use. +3. Ensure `packages/tracker-core/src/databases/traits/` contains only the canonical async trait + definitions that remain in use. +4. Remove leftover legacy compatibility code tied to the pre-sqlx drivers, including obsolete + error conversions and type references. +5. Remove obsolete dependencies from `packages/tracker-core/Cargo.toml`: `r2d2`, `r2d2_sqlite`, + `rusqlite`, and `r2d2_mysql`. +6. Regenerate lockfile state as needed and confirm `cargo machete` still passes. + +**Outcome**: there is one canonical async persistence surface only; the temporary `databases/sqlx/` +tree is gone; legacy sync-driver compatibility code and dependencies are gone. + +### Task 7 — Record final validation and benchmark status + +Once the structural cleanup is complete, record the remaining evidence needed to close the +subissue cleanly. + +Benchmark entrypoints and docs for the implementer: + +- Binary entrypoint: `packages/tracker-core/src/bin/persistence_benchmark_runner.rs` +- Binary-private implementation modules: `packages/tracker-core/src/bin/persistence_benchmark/` +- Benchmark artifact index and workflow notes: `packages/tracker-core/docs/benchmarking/README.md` +- Baseline benchmark spec and command examples: `docs/issues/1710-1525-03-persistence-benchmarking.md` +- Current committed baseline artifacts: `packages/tracker-core/docs/benchmarking/runs/2026-04-28/` + +Typical commands: + +```text +cargo run -p bittorrent-tracker-core --bin persistence_benchmark_runner -- \ + --driver sqlite3 + +cargo run -p bittorrent-tracker-core --bin persistence_benchmark_runner -- \ + --driver mysql \ + --db-version 8.4 +``` + +1. Run and record focused validation for the final cleanup work. +2. Run `cargo test --workspace --all-targets` and `linter all` on the final state. +3. Run the persistence benchmark comparison against the committed baseline from subissue `1525-03`, + or explicitly document why that comparison is still deferred. +4. Update the acceptance criteria in this spec to match the final verified state. + +**Outcome**: the spec contains closure-quality evidence for remaining acceptance criteria instead +of inferred status. + +## Constraints + +- Do not add PostgreSQL in this step. +- Do not introduce `sqlx::migrate!()`, migration files, or the `sqlx` `macros` feature — those + are introduced in subissue `1525-06`. +- Do not change the SQL schema in this step (schema evolution is `1525-06`). +- `DatabaseStores` (four `Arc<dyn XxxStore>` fields, one per narrow trait) is already the + consumer-facing type returned by `initialize_database()`; do not change this. Do not introduce + `Arc<Box<dyn Database>>` or the `Persistence` struct from the reference implementation. +- Keep startup schema initialization eager in this subissue and in Task 4. + +## Acceptance Criteria + +### Progress Review (2026-04-30) + +Status: structural cleanup and benchmark validation complete. + +What is done: + +- SQLite and MySQL driver implementations use `sqlx` pools and async trait methods. +- Schema initialization is still eager in `initialize_database()`. +- Schema creation still uses raw `sqlx::query()` DDL, and `sqlx::migrate!()` is not used. +- Sync-to-async bridge helpers introduced during the migration have been removed, and async initialization has been propagated through current call paths. +- The temporary staging subtree under `packages/tracker-core/src/databases/sqlx/` has been removed; the canonical `databases/driver/` and `databases/traits/` directories are the single persistence surface. +- Legacy `r2d2`, `r2d2_sqlite`, and `r2d2_mysql` dependencies have been removed from `packages/tracker-core/Cargo.toml` (the `rusqlite` symbol was only re-exported through `r2d2_sqlite`; no separate direct dep existed). +- Legacy compatibility/error plumbing has been removed from `packages/tracker-core/src/databases/error.rs` (no more `ConnectionPool` variant or `r2d2`/`rusqlite`/`mysql` `From` impls) and from `packages/tracker-core/src/authentication/key/mod.rs` (the `From<rusqlite::Error>` impl is now `From<sqlx::Error>`). +- Stale `r2d2_*` references in driver doc comments have been replaced with accurate `sqlx`-based wording. +- Current validation passed: `cargo machete`, `linter all`, doc tests, and full workspace tests on the cleaned-up state. +- Persistence benchmark comparison against the `2026-04-28` baseline recorded under `packages/tracker-core/docs/benchmarking/runs/2026-04-30/`. No regression: MySQL totals are 13–16% faster and SQLite per-operation medians stay within run-to-run variance. The bench harness was updated to wait for the MySQL container's TCP listener (sqlx no longer hides this race the way r2d2 did); production code paths are unchanged. + +What is still not done: + +- There is no recorded evidence in this branch that Tasks 1 to 3 were each validated independently at the time they were completed. + +- [x] SQLite and MySQL drivers use `sqlx` with async trait methods. +- [x] Schema initialization remains eager via setup/factory initialization. +- [x] Schema management uses raw `sqlx::query()` DDL; `sqlx::migrate!()` is not used. +- [x] `r2d2`, `r2d2_sqlite`, `rusqlite`, and the `mysql` crate are removed from + `tracker-core/Cargo.toml`. +- [x] Existing behavior is preserved end-to-end. +- [x] All temporary sync-to-async runtime bridge helpers (e.g. `block_on_current_or_new_runtime`) are removed and replaced with native async call paths. +- [ ] The branch compiles and all tests pass after each of Tasks 1–3 individually (verified by CI + or manual `cargo test` run after each task). +- [x] Persistence benchmarking (see subissue `1525-03`) shows no regression against the committed + baseline. — See `packages/tracker-core/docs/benchmarking/runs/2026-04-30/REPORT.md` for the + full comparison; MySQL totals improved by 13–16% and SQLite per-op medians remained within + run-to-run variance. +- [x] `cargo test --workspace --all-targets` passes. +- [x] `linter all` exits with code `0`. +- [x] `cargo machete` reports no unused dependencies. + +## Out of Scope + +- PostgreSQL driver — that is subissue `1525-08`. +- `sqlx::migrate!()` and migration files — that is subissue `1525-06`. +- `async_trait` removal — the `async_trait` crate is required at MSRV 1.72 because + async-fn-in-traits was stabilized in Rust 1.75. When the MSRV is raised to 1.75+, remove + `async_trait` and replace `#[async_trait]` attribute usage with native async trait syntax. + Track this as a follow-up when the MSRV is next bumped. + +## References + +- EPIC: `#1525` +- Subissue `1525-04`: `docs/issues/1713-1525-04-split-persistence-traits.md` — **already merged + into `develop`** +- Subissue `1525-03`: `docs/issues/1525-03-persistence-benchmarking.md` — benchmark baseline +- Reference PR: `#1695` +- Reference implementation branch: `josecelano:pr-1684-review` — local checkout at + `/home/josecelano/Documents/git/committer/me/github/torrust/torrust-tracker-pr-1700`; + consult only if blocked during implementation +- Reference files (async driver implementations — note: the reference uses `sqlx::migrate!()` + which is not adopted in this step; use raw DDL instead): + - `packages/tracker-core/src/databases/driver/sqlite.rs` + - `packages/tracker-core/src/databases/driver/mysql.rs` + - `packages/tracker-core/src/databases/driver/mod.rs` diff --git a/docs/issues/523-internal-linting-tool.md b/docs/issues/523-internal-linting-tool.md new file mode 100644 index 000000000..14593e190 --- /dev/null +++ b/docs/issues/523-internal-linting-tool.md @@ -0,0 +1,141 @@ +# Issue #523 Implementation Plan (Internal Linting Tool) + +## Goal + +Replace the MegaLinter idea with Torrust internal linting tooling and integrate it into CI for this repository. + +## Scope + +- Target issue: https://github.com/torrust/torrust-tracker/issues/523 +- CI workflow to modify: .github/workflows/testing.yaml +- External reference workflow: https://raw.githubusercontent.com/torrust/torrust-tracker-deployer/refs/heads/main/.github/workflows/linting.yml + +## Tasks + +### 0) Create a local branch following GitHub branch naming conventions + +- Approved branch name: `523-internal-linting-tool` +- Commands: + - `git fetch --all --prune` + - `git checkout develop` + - `git pull --ff-only` + - `git checkout -b 523-internal-linting-tool` +- Checkpoint: + - `git branch --show-current` should output `523-internal-linting-tool`. + +### 1) Install and run the linting tool locally; verify it passes in this repo + +- Identify/install internal linting package/tool used by Torrust (likely `torrust-linting` or equivalent wrapper). +- Ensure local runtime dependencies are present (if any). +- Note: linter config files (step 2) must exist in the repo root before a full suite run; it is fine to do a first exploratory run first to discover which linters are active. +- Run the internal linting command against this repository. +- Capture the exact command and output summary for reproducibility. +- Checkpoint: + - Linting command exits with code `0`. + +### 2) Add and adapt linter configuration files + +Some linters require a config file in the repo root. Use the deployer configs as reference and adapt values to this repository. + +| File | Linter | Reference | +| -------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | +| `.markdownlint.json` | markdownlint | https://raw.githubusercontent.com/torrust/torrust-tracker-deployer/refs/heads/main/.markdownlint.json | +| `.taplo.toml` | taplo (TOML fmt) | https://raw.githubusercontent.com/torrust/torrust-tracker-deployer/refs/heads/main/.taplo.toml | +| `.yamllint-ci.yml` | yamllint | https://raw.githubusercontent.com/torrust/torrust-tracker-deployer/refs/heads/main/.yamllint-ci.yml | + +Key adaptations to make per file: + +- `.markdownlint.json`: review line-length rules and Markdown conventions used in this repo's docs. +- `.taplo.toml`: update `exclude` list to match this repo's generated/runtime folders (e.g. `target/**`, `storage/**`) instead of the deployer-specific ones (`build/**`, `data/**`, `envs/**`). +- `.yamllint-ci.yml`: update `ignore` block to reflect this repo's generated/runtime directories instead of cloud-init and deployer folders. + +Commit message: `ci(lint): add linter config files (.markdownlint.json, .taplo.toml, .yamllint-ci.yml)` + +Checkpoint: + +- Config files are present in the repo root. +- Running each individual linter against the repo with the config produces expected/controlled output. + +### 3) If local linting fails, fix all lint errors; commit fixes independently per linter + +- If the linting suite reports failures: + - Group findings by linter (for example: formatting, clippy, docs, spelling, yaml, etc.). + - Fix only one linter category at a time. + - Create one commit per linter category. +- Commit style proposal: + - `fix(lint/<linter-name>): resolve <brief issue summary>` +- Constraints: + - Do not mix workflow/tooling changes with source lint fixes in the same commit. + - Keep each commit minimal and reviewable. +- Checkpoint: + - Re-run linting suite; all checks pass before moving to workflow integration. + +### 4) Review existing workflow example using internal linting + +- Read and analyze: + - https://raw.githubusercontent.com/torrust/torrust-tracker-deployer/refs/heads/main/.github/workflows/linting.yml +- Extract and adapt: + - Trigger strategy. + - Tool setup/install method. + - Cache strategy. + - Invocation command and CI fail behavior. +- Checkpoint: + - Document a short mapping from deployer workflow pattern to this repo’s `testing.yaml` job structure. + +### 5) Modify `.github/workflows/testing.yaml` to use the internal linting tool + +- Update the current `check`/lint-related section to run the internal linting command. +- Replace existing lint/check execution path with the internal linting tool in this migration (no parallel transition mode). +- Ensure matrix/toolchain compatibility is explicit (nightly/stable behavior decided and documented). +- Validate workflow syntax before commit. +- Checkpoint: + - Workflow is valid and executes linting through internal tool. + +### 6) Commit workflow changes + +- Commit only workflow-related changes in a dedicated commit. +- Commit message proposal: + - `ci(lint): switch testing workflow to internal linting tool` +- Checkpoint: + - `git show --name-only --stat HEAD` includes only expected workflow files (and any required supporting CI files if intentionally added). + +### 7) Push to remote `josecelano` and open PR into `develop` + +- Verify remote exists: + - `git remote -v` +- Push branch: + - `git push -u josecelano 523-internal-linting-tool` +- Open PR targeting `torrust/torrust-tracker:develop` with head `josecelano:523-internal-linting-tool`. +- PR content should include: + - Why internal linting over MegaLinter. + - Summary of lint-fix commits by linter. + - Summary of workflow change. + - Evidence (local run + CI status). +- Checkpoint: + - PR is open, linked to issue #523, and ready for review. + +## Execution Notes + +- Keep PR review-friendly by separating commits by concern: + 1. Linter config files (step 2) + 2. Per-linter source fixes (step 3, only if needed) + 3. CI workflow migration (step 6) +- Use Conventional Commits for all commits in this implementation. +- If lint checks differ between local and CI, align tool versions and execution flags before merging. +- Avoid broad refactors unrelated to lint failures. + +## Decisions Confirmed + +1. Branch name: `523-internal-linting-tool`. +2. CI strategy: replace existing lint/check path with internal linting. +3. Commit convention: yes, use Conventional Commits. +4. PR target: base `torrust/torrust-tracker:develop`, head `josecelano:523-internal-linting-tool`. + +## Risks and Mitigations + +- Risk: Internal linting wrapper may not be version-pinned and may produce unstable CI behavior. + - Mitigation: Pin tool version in workflow installation step. +- Risk: Internal linting may overlap with existing checks, increasing CI time. + - Mitigation: Remove redundant jobs only after verifying coverage parity. +- Risk: Tool may require secrets or environment assumptions not available in CI. + - Mitigation: Run dry-run in GitHub Actions on branch before requesting review. diff --git a/docs/packages.md b/docs/packages.md index 118046a87..c07622dc3 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -3,8 +3,8 @@ - [Package Conventions](#package-conventions) - [Package Catalog](#package-catalog) - [Architectural Philosophy](#architectural-philosophy) +- [Design Decisions](#design-decisions) - [Protocol Implementation Details](#protocol-implementation-details) -- [Architectural Philosophy](#architectural-philosophy) ```output packages/ @@ -42,14 +42,14 @@ contrib/ ## Package Conventions -| Prefix | Responsibility | Dependencies | -|-----------------|-----------------------------------------|---------------------------| -| `axum-*` | HTTP server components using Axum | Axum framework | -| `*-server` | Server implementations | Corresponding *-core | -| `*-core` | Domain logic & business rules | Protocol implementations | -| `*-protocol` | BitTorrent protocol implementations | BitTorrent protocol | -| `udp-*` | UDP Protocol-specific implementations | Tracker core | -| `http-*` | HTTP Protocol-specific implementations | Tracker core | +| Prefix | Responsibility | Dependencies | +| ------------ | -------------------------------------- | ------------------------ | +| `axum-*` | HTTP server components using Axum | Axum framework | +| `*-server` | Server implementations | Corresponding \*-core | +| `*-core` | Domain logic & business rules | Protocol implementations | +| `*-protocol` | BitTorrent protocol implementations | BitTorrent protocol | +| `udp-*` | UDP Protocol-specific implementations | Tracker core | +| `http-*` | HTTP Protocol-specific implementations | Tracker core | Key Architectural Principles: @@ -57,33 +57,38 @@ Key Architectural Principles: 2. **Protocol Compliance**: `*-protocol` packages strictly implement BEP specifications. 3. **Extensibility**: Core logic is framework-agnostic for easy protocol additions. +## Design Decisions + +- Persistence trait boundaries and the aggregate supertrait choice: + [docs/adrs/20260429000000_keep_database_as_aggregate_supertrait.md](adrs/20260429000000_keep_database_as_aggregate_supertrait.md) + ## Package Catalog -| Package | Description | Key Responsibilities | -|---------|-------------|----------------------| -| **axum-*** | | | -| `axum-server` | Base Axum HTTP server infrastructure | HTTP server lifecycle management | -| `axum-http-tracker-server` | BitTorrent HTTP tracker (BEP 3/23) | Handle announce/scrape requests | -| `axum-rest-tracker-api-server` | Management REST API | Tracker configuration & monitoring | -| `axum-health-check-api-server` | Health monitoring endpoint | System health reporting | -| **Core Components** | | | -| `http-tracker-core` | HTTP-specific implementation | Request validation, Response formatting | -| `udp-tracker-core` | UDP-specific implementation | Connectionless request handling | -| `tracker-core` | Central tracker logic | Peer management | -| **Protocols** | | | -| `http-protocol` | HTTP tracker protocol (BEP 3/23) | Announce/scrape request parsing | -| `udp-protocol` | UDP tracker protocol (BEP 15) | UDP message framing/parsing | -| **Domain** | | | -| `torrent-repository` | Torrent metadata storage | InfoHash management, Peer coordination | -| `configuration` | Runtime configuration | Config file parsing, Environment variables | -| `primitives` | Domain-specific types | InfoHash, PeerId, Byte handling | -| **Utilities** | | | -| `clock` | Time abstraction | Mockable time source for testing | -| `located-error` | Diagnostic errors | Error tracing with source locations | -| `test-helpers` | Testing utilities | Mock servers, Test data generation | -| **Client Tools** | | | -| `tracker-client` | CLI client | Tracker interaction/testing | -| `rest-tracker-api-client` | API client library | REST API integration | +| Package | Description | Key Responsibilities | +| ------------------------------ | ------------------------------------ | ------------------------------------------ | +| **axum-\*** | | | +| `axum-server` | Base Axum HTTP server infrastructure | HTTP server lifecycle management | +| `axum-http-tracker-server` | BitTorrent HTTP tracker (BEP 3/23) | Handle announce/scrape requests | +| `axum-rest-tracker-api-server` | Management REST API | Tracker configuration & monitoring | +| `axum-health-check-api-server` | Health monitoring endpoint | System health reporting | +| **Core Components** | | | +| `http-tracker-core` | HTTP-specific implementation | Request validation, Response formatting | +| `udp-tracker-core` | UDP-specific implementation | Connectionless request handling | +| `tracker-core` | Central tracker logic | Peer management | +| **Protocols** | | | +| `http-protocol` | HTTP tracker protocol (BEP 3/23) | Announce/scrape request parsing | +| `udp-protocol` | UDP tracker protocol (BEP 15) | UDP message framing/parsing | +| **Domain** | | | +| `torrent-repository` | Torrent metadata storage | InfoHash management, Peer coordination | +| `configuration` | Runtime configuration | Config file parsing, Environment variables | +| `primitives` | Domain-specific types | InfoHash, PeerId, Byte handling | +| **Utilities** | | | +| `clock` | Time abstraction | Mockable time source for testing | +| `located-error` | Diagnostic errors | Error tracing with source locations | +| `test-helpers` | Testing utilities | Mock servers, Test data generation | +| **Client Tools** | | | +| `tracker-client` | CLI client | Tracker interaction/testing | +| `rest-tracker-api-client` | API client library | REST API integration | ## Protocol Implementation Details diff --git a/docs/templates/ADR.md b/docs/templates/ADR.md new file mode 100644 index 000000000..fa8aebe27 --- /dev/null +++ b/docs/templates/ADR.md @@ -0,0 +1,24 @@ +# [Title] + +## Description + +What is the issue motivating this decision? Provide enough context for future +readers who have no prior background. + +## Agreement + +What was decided and why? Be concrete. Include code examples if the decision +involves specific patterns. + +Optional sub-sections: + +- **Alternatives Considered** — other options explored and why they were rejected +- **Consequences** — positive and negative effects of the decision + +## Date + +YYYY-MM-DD + +## References + +Links to related issues, PRs, ADRs, and external documentation. diff --git a/docs/templates/ISSUE.md b/docs/templates/ISSUE.md new file mode 100644 index 000000000..7c899bacd --- /dev/null +++ b/docs/templates/ISSUE.md @@ -0,0 +1,33 @@ +# Issue: {Title} + +## Overview + +Clear description of what needs to be done and why. + +## Goals + +- [ ] Goal 1 +- [ ] Goal 2 + +## Implementation Plan + +### Task 1: {Task Title} + +- [ ] Sub-task a +- [ ] Sub-task b + +### Task 2: {Task Title} + +- [ ] Sub-task a +- [ ] Sub-task b + +## Acceptance Criteria + +- [ ] All tests pass +- [ ] `linter all` exits with code `0` +- [ ] Documentation updated + +## References + +- Related issues: #{number} +- Related ADRs: `docs/adrs/...` diff --git a/packages/AGENTS.md b/packages/AGENTS.md new file mode 100644 index 000000000..231bfe3a9 --- /dev/null +++ b/packages/AGENTS.md @@ -0,0 +1,152 @@ +# Torrust Tracker — Packages + +This directory contains all Cargo workspace packages. All domain logic, protocol +implementations, server infrastructure, and utility libraries live here. + +For full project context see the [root AGENTS.md](../AGENTS.md). + +## Architecture + +Packages are organized in strict layers. Dependencies only flow downward — a package may only +depend on packages in the same layer or a lower one. + +```text +┌────────────────────────────────────────────────────────────────┐ +│ Servers (delivery layer) │ +│ axum-http-tracker-server axum-rest-tracker-api-server │ +│ axum-health-check-api-server udp-tracker-server │ +├────────────────────────────────────────────────────────────────┤ +│ Core (domain layer) │ +│ http-tracker-core udp-tracker-core tracker-core │ +│ rest-tracker-api-core swarm-coordination-registry │ +├────────────────────────────────────────────────────────────────┤ +│ Protocols │ +│ http-protocol udp-protocol │ +├────────────────────────────────────────────────────────────────┤ +│ Domain / Shared │ +│ torrent-repository configuration primitives │ +│ events metrics clock located-error server-lib │ +├────────────────────────────────────────────────────────────────┤ +│ Utilities / Test support │ +│ test-helpers │ +└────────────────────────────────────────────────────────────────┘ +``` + +**Key architectural rule**: Servers contain only network I/O logic. All business rules live in +`*-core` packages. Protocol parsing is isolated in `*-protocol` packages. + +See [docs/packages.md](../docs/packages.md) for a full diagram. + +## Package Catalog + +### Servers (`axum-*`, `udp-tracker-server`) + +Delivery layer — accept network connections, dispatch to core handlers, return responses. +These packages must not contain business logic. + +| Package | Entry point | Protocol | +| ------------------------------ | ------------ | ----------- | +| `axum-http-tracker-server` | `src/lib.rs` | HTTP BEP 3 | +| `axum-rest-tracker-api-server` | `src/lib.rs` | REST (JSON) | +| `axum-health-check-api-server` | `src/lib.rs` | HTTP | +| `axum-server` | `src/lib.rs` | Axum base | +| `udp-tracker-server` | `src/lib.rs` | UDP BEP 15 | + +### Core (`*-core`) + +Domain layer — business rules, request validation, response building. No Axum or networking +imports. Each core package exposes a `container` module that wires up its dependencies via +dependency injection. + +| Package | Purpose | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `tracker-core` | Central peer management: announce/scrape handlers, auth, whitelist, database abstraction (SQLite/MySQL drivers in `src/databases/driver/`) | +| `http-tracker-core` | HTTP-specific validation and response formatting | +| `udp-tracker-core` | UDP connection cookies, crypto, banning logic | +| `rest-tracker-api-core` | REST API statistics and container wiring | +| `swarm-coordination-registry` | Registry of torrents and their peer swarms | + +### Protocols (`*-protocol`) + +Strict BEP implementations — parse and serialize wire formats only. No tracker logic. + +| Package | BEP | Handles | +| --------------- | ------ | -------------------------------------------------------------- | +| `http-protocol` | BEP 3 | URL parameter parsing, bencoded responses, compact peer format | +| `udp-protocol` | BEP 15 | Message framing, connection IDs, transaction IDs | + +### Domain / Shared + +| Package | Purpose | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `torrent-repository` | Torrent metadata storage; InfoHash management; peer coordination | +| `configuration` | Config file parsing (`share/default/config/`) and env var loading (`TORRUST_TRACKER_CONFIG_TOML`, `TORRUST_TRACKER_CONFIG_TOML_PATH`); versioned under `src/v2_0_0/` | +| `primitives` | Core domain types: `InfoHash`, `PeerId`, `Peer`, `SwarmMetadata`, `ServiceBinding` | +| `events` | Async event bus (broadcaster / receiver / shutdown) used across packages | +| `metrics` | Prometheus-compatible metrics: counters, gauges, labels, samples | +| `server-lib` | Shared HTTP server utilities: logging, service registrar, signal handling | +| `clock` | Mockable time source — use `clock::Working` in production, `clock::Stopped` in tests | +| `located-error` | Error decorator that captures the source file/line of the original error | + +### Client Tools + +| Package | Purpose | +| ------------------------- | -------------------------------------------------------- | +| `tracker-client` | Generic HTTP and UDP tracker clients (used by E2E tests) | +| `rest-tracker-api-client` | Typed REST API client library | + +### Utilities / Test support + +| Package | Purpose | +| --------------------------------- | ---------------------------------------------------------- | +| `test-helpers` | Mock servers, test data generators, shared test fixtures | +| `torrent-repository-benchmarking` | Criterion benchmarks for alternative torrent storage impls | + +## Naming Conventions + +| Prefix / Suffix | Responsibility | May depend on | +| --------------- | ----------------------------------------- | ----------------------------- | +| `axum-*` | HTTP server components using Axum | `*-core`, Axum framework | +| `*-server` | Server implementations | Corresponding `*-core` | +| `*-core` | Domain logic and business rules | `*-protocol`, domain packages | +| `*-protocol` | BitTorrent protocol parsing/serialization | `primitives` | +| `udp-*` | UDP-specific implementations | `tracker-core` | +| `http-*` | HTTP-specific implementations | `tracker-core` | + +## Adding or Modifying a Package + +1. Create the directory under `packages/<new-package>/` with a `Cargo.toml` and `src/lib.rs`. +2. Add the package to the workspace `[members]` in the root `Cargo.toml`. +3. Follow the naming conventions above. +4. Each package must have: + - A crate-level doc comment in `src/lib.rs` explaining its purpose and layer. + - At minimum one unit test (doc-test acceptable for simple utility crates). +5. Run `cargo machete` after adding dependencies — unused deps must not be committed. +6. Run `linter all` before committing. + +## Testing Packages + +```sh +# All tests for a specific package +cargo test -p <package-name> + +# Doc tests only +cargo test --doc -p <package-name> + +# MySQL-specific tests in tracker-core (requires a running MySQL instance) +TORRUST_TRACKER_CORE_RUN_MYSQL_DRIVER_TEST=true cargo test -p bittorrent-tracker-core +``` + +Use `clock::Stopped` (from the `clock` package) in unit tests that need deterministic time. +Use `test-helpers` for mock tracker servers in integration tests. + +## Key Dependency Notes + +- `swarm-coordination-registry` is the authoritative store for peer swarms; `tracker-core` + delegates peer lookups to it. +- `configuration` is the only package that reads from the filesystem or environment at startup; + other packages receive config structs as arguments. +- `located-error` wraps any `std::error::Error` — use it at module boundaries to preserve + error origin context without losing the original error type. +- `events` provides the only sanctioned inter-package async communication channel; avoid direct + `tokio::sync` coupling between packages. diff --git a/packages/axum-health-check-api-server/Cargo.toml b/packages/axum-health-check-api-server/Cargo.toml index e0504f7df..cf9d8d9a3 100644 --- a/packages/axum-health-check-api-server/Cargo.toml +++ b/packages/axum-health-check-api-server/Cargo.toml @@ -4,7 +4,7 @@ description = "The Torrust Bittorrent HTTP tracker." documentation.workspace = true edition.workspace = true homepage.workspace = true -keywords = ["axum", "bittorrent", "healthcheck", "http", "server", "torrust", "tracker"] +keywords = [ "axum", "bittorrent", "healthcheck", "http", "server", "torrust", "tracker" ] license.workspace = true name = "torrust-axum-health-check-api-server" publish.workspace = true @@ -14,27 +14,27 @@ rust-version.workspace = true version.workspace = true [dependencies] -axum = { version = "0", features = ["macros"] } -axum-server = { version = "0", features = ["tls-rustls-no-provider"] } +axum = { version = "0", features = [ "macros" ] } +axum-server = { version = "0", features = [ "tls-rustls-no-provider" ] } futures = "0" hyper = "1" -serde = { version = "1", features = ["derive"] } -serde_json = { version = "1", features = ["preserve_order"] } -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +serde = { version = "1", features = [ "derive" ] } +serde_json = { version = "1", features = [ "preserve_order" ] } +tokio = { version = "1", features = [ "macros", "net", "rt-multi-thread", "signal", "sync" ] } torrust-axum-server = { version = "3.0.0-develop", path = "../axum-server" } torrust-server-lib = { version = "3.0.0-develop", path = "../server-lib" } torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } -tower-http = { version = "0", features = ["compression-full", "cors", "propagate-header", "request-id", "trace"] } +tower-http = { version = "0", features = [ "compression-full", "cors", "propagate-header", "request-id", "trace" ] } tracing = "0" url = "2.5.4" [dev-dependencies] -reqwest = { version = "0", features = ["json"] } +reqwest = { version = "0", features = [ "json" ] } torrust-axum-health-check-api-server = { version = "3.0.0-develop", path = "../axum-health-check-api-server" } torrust-axum-http-tracker-server = { version = "3.0.0-develop", path = "../axum-http-tracker-server" } torrust-axum-rest-tracker-api-server = { version = "3.0.0-develop", path = "../axum-rest-tracker-api-server" } torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } torrust-tracker-test-helpers = { version = "3.0.0-develop", path = "../test-helpers" } torrust-udp-tracker-server = { version = "3.0.0-develop", path = "../udp-tracker-server" } -tracing-subscriber = { version = "0", features = ["json"] } +tracing-subscriber = { version = "0", features = [ "json" ] } diff --git a/packages/axum-health-check-api-server/tests/server/contract.rs b/packages/axum-health-check-api-server/tests/server/contract.rs index 1d1ba3539..af1c0cff9 100644 --- a/packages/axum-health-check-api-server/tests/server/contract.rs +++ b/packages/axum-health-check-api-server/tests/server/contract.rs @@ -202,6 +202,9 @@ mod http { service.server.stop().await.expect("it should stop udp server"); + // Give the OS a moment to fully release the TCP port after the server stops. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + { let config = configuration.health_check_api.clone(); let env = Started::new(&config.into(), registar).await; diff --git a/packages/axum-http-tracker-server/Cargo.toml b/packages/axum-http-tracker-server/Cargo.toml index eb2c2cad3..88d073527 100644 --- a/packages/axum-http-tracker-server/Cargo.toml +++ b/packages/axum-http-tracker-server/Cargo.toml @@ -4,7 +4,7 @@ description = "The Torrust Bittorrent HTTP tracker." documentation.workspace = true edition.workspace = true homepage.workspace = true -keywords = ["axum", "bittorrent", "http", "server", "torrust", "tracker"] +keywords = [ "axum", "bittorrent", "http", "server", "torrust", "tracker" ] license.workspace = true name = "torrust-axum-http-tracker-server" publish.workspace = true @@ -15,19 +15,19 @@ version.workspace = true [dependencies] aquatic_udp_protocol = "0" -axum = { version = "0", features = ["macros"] } +axum = { version = "0", features = [ "macros" ] } axum-client-ip = "0" -axum-server = { version = "0", features = ["tls-rustls-no-provider"] } +axum-server = { version = "0", features = [ "tls-rustls-no-provider" ] } bittorrent-http-tracker-core = { version = "3.0.0-develop", path = "../http-tracker-core" } bittorrent-http-tracker-protocol = { version = "3.0.0-develop", path = "../http-protocol" } bittorrent-primitives = "0.1.0" bittorrent-tracker-core = { version = "3.0.0-develop", path = "../tracker-core" } -derive_more = { version = "2", features = ["as_ref", "constructor", "from"] } +derive_more = { version = "2", features = [ "as_ref", "constructor", "from" ] } futures = "0" hyper = "1" -reqwest = { version = "0", features = ["json"] } -serde = { version = "1", features = ["derive"] } -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +reqwest = { version = "0", features = [ "json" ] } +serde = { version = "1", features = [ "derive" ] } +tokio = { version = "1", features = [ "macros", "net", "rt-multi-thread", "signal", "sync" ] } tokio-util = "0.7.15" torrust-axum-server = { version = "3.0.0-develop", path = "../axum-server" } torrust-server-lib = { version = "3.0.0-develop", path = "../server-lib" } @@ -35,8 +35,8 @@ torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } torrust-tracker-swarm-coordination-registry = { version = "3.0.0-develop", path = "../swarm-coordination-registry" } -tower = { version = "0", features = ["timeout"] } -tower-http = { version = "0", features = ["compression-full", "cors", "propagate-header", "request-id", "trace"] } +tower = { version = "0", features = [ "timeout" ] } +tower-http = { version = "0", features = [ "compression-full", "cors", "propagate-header", "request-id", "trace" ] } tracing = "0" [dev-dependencies] @@ -49,5 +49,5 @@ serde_repr = "0" torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } torrust-tracker-events = { version = "3.0.0-develop", path = "../events" } torrust-tracker-test-helpers = { version = "3.0.0-develop", path = "../test-helpers" } -uuid = { version = "1", features = ["v4"] } +uuid = { version = "1", features = [ "v4" ] } zerocopy = "0.7" diff --git a/packages/axum-http-tracker-server/src/environment.rs b/packages/axum-http-tracker-server/src/environment.rs index 616973a0f..57f64bd15 100644 --- a/packages/axum-http-tracker-server/src/environment.rs +++ b/packages/axum-http-tracker-server/src/environment.rs @@ -4,7 +4,6 @@ use bittorrent_http_tracker_core::container::HttpTrackerCoreContainer; use bittorrent_http_tracker_core::statistics::event::listener::run_event_listener; use bittorrent_primitives::info_hash::InfoHash; use bittorrent_tracker_core::container::TrackerCoreContainer; -use futures::executor::block_on; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use torrust_axum_server::tsl::make_rust_tls; @@ -42,17 +41,16 @@ impl Environment<Stopped> { /// Will panic if it fails to make the TSL config from the configuration. #[allow(dead_code)] #[must_use] - pub fn new(configuration: &Arc<Configuration>) -> Self { + pub async fn new(configuration: &Arc<Configuration>) -> Self { initialize_global_services(configuration); - let container = Arc::new(EnvContainer::initialize(configuration)); + let container = Arc::new(EnvContainer::initialize(configuration).await); let bind_to = container.http_tracker_core_container.http_tracker_config.bind_address; - let tls = block_on(make_rust_tls( - &container.http_tracker_core_container.http_tracker_config.tsl_config, - )) - .map(|tls| tls.expect("tls config failed")); + let tls = make_rust_tls(&container.http_tracker_core_container.http_tracker_config.tsl_config) + .await + .map(|tls| tls.expect("tls config failed")); let server = HttpServer::new(Launcher::new(bind_to, tls)); @@ -98,7 +96,7 @@ impl Environment<Stopped> { impl Environment<Running> { pub async fn new(configuration: &Arc<Configuration>) -> Self { - Environment::<Stopped>::new(configuration).start().await + Environment::<Stopped>::new(configuration).await.start().await } /// Stops the test environment and return a stopped environment. @@ -142,7 +140,7 @@ impl EnvContainer { /// /// Will panic if the configuration is missing the HTTP tracker configuration. #[must_use] - pub fn initialize(configuration: &Configuration) -> Self { + pub async fn initialize(configuration: &Configuration) -> Self { let core_config = Arc::new(configuration.core.clone()); let http_tracker_config = configuration .http_trackers @@ -154,10 +152,8 @@ impl EnvContainer { configuration.core.tracker_usage_statistics.into(), )); - let tracker_core_container = Arc::new(TrackerCoreContainer::initialize_from( - &core_config, - &swarm_coordination_registry_container, - )); + let tracker_core_container = + Arc::new(TrackerCoreContainer::initialize_from(&core_config, &swarm_coordination_registry_container).await); let http_tracker_container = HttpTrackerCoreContainer::initialize_from_tracker_core(&tracker_core_container, &http_tracker_config); diff --git a/packages/axum-http-tracker-server/src/server.rs b/packages/axum-http-tracker-server/src/server.rs index 69f9cb72e..f3ec3b8c7 100644 --- a/packages/axum-http-tracker-server/src/server.rs +++ b/packages/axum-http-tracker-server/src/server.rs @@ -270,7 +270,7 @@ mod tests { use crate::server::{HttpServer, Launcher}; - pub fn initialize_container(configuration: &Configuration) -> HttpTrackerCoreContainer { + pub async fn initialize_container(configuration: &Configuration) -> HttpTrackerCoreContainer { let cancellation_token = CancellationToken::new(); let core_config = Arc::new(configuration.core.clone()); @@ -302,10 +302,8 @@ mod tests { configuration.core.tracker_usage_statistics.into(), )); - let tracker_core_container = Arc::new(TrackerCoreContainer::initialize_from( - &core_config, - &swarm_coordination_registry_container, - )); + let tracker_core_container = + Arc::new(TrackerCoreContainer::initialize_from(&core_config, &swarm_coordination_registry_container).await); let announce_service = Arc::new(AnnounceService::new( tracker_core_container.core_config.clone(), @@ -355,7 +353,7 @@ mod tests { initialize_global_services(&configuration); - let http_tracker_container = Arc::new(initialize_container(&configuration)); + let http_tracker_container = Arc::new(initialize_container(&configuration).await); let bind_to = http_tracker_config.bind_address; diff --git a/packages/axum-http-tracker-server/src/v1/handlers/announce.rs b/packages/axum-http-tracker-server/src/v1/handlers/announce.rs index ce718cd30..155f6893e 100644 --- a/packages/axum-http-tracker-server/src/v1/handlers/announce.rs +++ b/packages/axum-http-tracker-server/src/v1/handlers/announce.rs @@ -133,34 +133,34 @@ mod tests { pub announce_service: Arc<AnnounceService>, } - fn initialize_private_tracker() -> CoreHttpTrackerServices { - initialize_core_tracker_services(&configuration::ephemeral_private()) + async fn initialize_private_tracker() -> CoreHttpTrackerServices { + initialize_core_tracker_services(&configuration::ephemeral_private()).await } - fn initialize_listed_tracker() -> CoreHttpTrackerServices { - initialize_core_tracker_services(&configuration::ephemeral_listed()) + async fn initialize_listed_tracker() -> CoreHttpTrackerServices { + initialize_core_tracker_services(&configuration::ephemeral_listed()).await } - fn initialize_tracker_on_reverse_proxy() -> CoreHttpTrackerServices { - initialize_core_tracker_services(&configuration::ephemeral_with_reverse_proxy()) + async fn initialize_tracker_on_reverse_proxy() -> CoreHttpTrackerServices { + initialize_core_tracker_services(&configuration::ephemeral_with_reverse_proxy()).await } - fn initialize_tracker_not_on_reverse_proxy() -> CoreHttpTrackerServices { - initialize_core_tracker_services(&configuration::ephemeral_without_reverse_proxy()) + async fn initialize_tracker_not_on_reverse_proxy() -> CoreHttpTrackerServices { + initialize_core_tracker_services(&configuration::ephemeral_without_reverse_proxy()).await } - fn initialize_core_tracker_services(config: &Configuration) -> CoreHttpTrackerServices { + async fn initialize_core_tracker_services(config: &Configuration) -> CoreHttpTrackerServices { let cancellation_token = CancellationToken::new(); // Initialize the core tracker services with the provided configuration. let core_config = Arc::new(config.core.clone()); - let database = initialize_database(&config.core); + let database = initialize_database(&config.core).await; let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); let whitelist_authorization = Arc::new(WhitelistAuthorization::new(&config.core, &in_memory_whitelist.clone())); let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); let authentication_service = Arc::new(AuthenticationService::new(&config.core, &in_memory_key_repository)); let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); - let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database.torrent_metrics_store)); let announce_handler = Arc::new(AnnounceHandler::new( &config.core, &whitelist_authorization, @@ -236,7 +236,7 @@ mod tests { #[tokio::test] async fn it_should_fail_when_the_authentication_key_is_missing() { - let http_core_tracker_services = initialize_private_tracker(); + let http_core_tracker_services = initialize_private_tracker().await; let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); @@ -265,7 +265,7 @@ mod tests { #[tokio::test] async fn it_should_fail_when_the_authentication_key_is_invalid() { - let http_core_tracker_services = initialize_private_tracker(); + let http_core_tracker_services = initialize_private_tracker().await; let unregistered_key = authentication::Key::from_str("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(); @@ -308,7 +308,7 @@ mod tests { #[tokio::test] async fn it_should_fail_when_the_announced_torrent_is_not_whitelisted() { - let http_core_tracker_services = initialize_listed_tracker(); + let http_core_tracker_services = initialize_listed_tracker().await; let announce_request = sample_announce_request(); @@ -353,7 +353,7 @@ mod tests { #[tokio::test] async fn it_should_fail_when_the_right_most_x_forwarded_for_header_ip_is_not_available() { - let http_core_tracker_services = initialize_tracker_on_reverse_proxy(); + let http_core_tracker_services = initialize_tracker_on_reverse_proxy().await; let client_ip_sources = ClientIpSources { right_most_x_forwarded_for: None, @@ -398,7 +398,7 @@ mod tests { #[tokio::test] async fn it_should_fail_when_the_client_ip_from_the_connection_info_is_not_available() { - let http_core_tracker_services = initialize_tracker_not_on_reverse_proxy(); + let http_core_tracker_services = initialize_tracker_not_on_reverse_proxy().await; let client_ip_sources = ClientIpSources { right_most_x_forwarded_for: None, diff --git a/packages/axum-rest-tracker-api-server/Cargo.toml b/packages/axum-rest-tracker-api-server/Cargo.toml index 9493b8693..7353e66e8 100644 --- a/packages/axum-rest-tracker-api-server/Cargo.toml +++ b/packages/axum-rest-tracker-api-server/Cargo.toml @@ -4,7 +4,7 @@ description = "The Torrust Tracker API." documentation.workspace = true edition.workspace = true homepage.workspace = true -keywords = ["axum", "bittorrent", "http", "server", "torrust", "tracker"] +keywords = [ "axum", "bittorrent", "http", "server", "torrust", "tracker" ] license.workspace = true name = "torrust-axum-rest-tracker-api-server" publish.workspace = true @@ -15,22 +15,22 @@ version.workspace = true [dependencies] aquatic_udp_protocol = "0" -axum = { version = "0", features = ["macros"] } -axum-extra = { version = "0", features = ["query"] } -axum-server = { version = "0", features = ["tls-rustls-no-provider"] } +axum = { version = "0", features = [ "macros" ] } +axum-extra = { version = "0", features = [ "query" ] } +axum-server = { version = "0", features = [ "tls-rustls-no-provider" ] } bittorrent-http-tracker-core = { version = "3.0.0-develop", path = "../http-tracker-core" } bittorrent-primitives = "0.1.0" bittorrent-tracker-core = { version = "3.0.0-develop", path = "../tracker-core" } bittorrent-udp-tracker-core = { version = "3.0.0-develop", path = "../udp-tracker-core" } -derive_more = { version = "2", features = ["as_ref", "constructor", "from"] } +derive_more = { version = "2", features = [ "as_ref", "constructor", "from" ] } futures = "0" hyper = "1" -reqwest = { version = "0", features = ["json"] } -serde = { version = "1", features = ["derive"] } -serde_json = { version = "1", features = ["preserve_order"] } -serde_with = { version = "3", features = ["json"] } +reqwest = { version = "0", features = [ "json" ] } +serde = { version = "1", features = [ "derive" ] } +serde_json = { version = "1", features = [ "preserve_order" ] } +serde_with = { version = "3", features = [ "json" ] } thiserror = "2" -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +tokio = { version = "1", features = [ "macros", "net", "rt-multi-thread", "signal", "sync" ] } torrust-axum-server = { version = "3.0.0-develop", path = "../axum-server" } torrust-rest-tracker-api-client = { version = "3.0.0-develop", path = "../rest-tracker-api-client" } torrust-rest-tracker-api-core = { version = "3.0.0-develop", path = "../rest-tracker-api-core" } @@ -41,8 +41,8 @@ torrust-tracker-metrics = { version = "3.0.0-develop", path = "../metrics" } torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } torrust-tracker-swarm-coordination-registry = { version = "3.0.0-develop", path = "../swarm-coordination-registry" } torrust-udp-tracker-server = { version = "3.0.0-develop", path = "../udp-tracker-server" } -tower = { version = "0", features = ["timeout"] } -tower-http = { version = "0", features = ["compression-full", "cors", "propagate-header", "request-id", "trace"] } +tower = { version = "0", features = [ "timeout" ] } +tower-http = { version = "0", features = [ "compression-full", "cors", "propagate-header", "request-id", "trace" ] } tracing = "0" url = "2" @@ -51,5 +51,5 @@ local-ip-address = "0" mockall = "0" torrust-rest-tracker-api-client = { version = "3.0.0-develop", path = "../rest-tracker-api-client" } torrust-tracker-test-helpers = { version = "3.0.0-develop", path = "../test-helpers" } -url = { version = "2", features = ["serde"] } -uuid = { version = "1", features = ["v4"] } +url = { version = "2", features = [ "serde" ] } +uuid = { version = "1", features = [ "v4" ] } diff --git a/packages/axum-rest-tracker-api-server/src/environment.rs b/packages/axum-rest-tracker-api-server/src/environment.rs index cddb45277..2c138ad50 100644 --- a/packages/axum-rest-tracker-api-server/src/environment.rs +++ b/packages/axum-rest-tracker-api-server/src/environment.rs @@ -5,7 +5,6 @@ use bittorrent_http_tracker_core::container::HttpTrackerCoreContainer; use bittorrent_primitives::info_hash::InfoHash; use bittorrent_tracker_core::container::TrackerCoreContainer; use bittorrent_udp_tracker_core::container::UdpTrackerCoreContainer; -use futures::executor::block_on; use torrust_axum_server::tsl::make_rust_tls; use torrust_rest_tracker_api_client::connection_info::{ConnectionInfo, Origin}; use torrust_rest_tracker_api_core::container::TrackerHttpApiCoreContainer; @@ -48,17 +47,16 @@ impl Environment<Stopped> { /// Will panic if it cannot make the TSL configuration from the provided /// configuration. #[must_use] - pub fn new(configuration: &Arc<Configuration>) -> Self { + pub async fn new(configuration: &Arc<Configuration>) -> Self { initialize_global_services(configuration); - let container = Arc::new(EnvContainer::initialize(configuration)); + let container = Arc::new(EnvContainer::initialize(configuration).await); let bind_to = container.tracker_http_api_core_container.http_api_config.bind_address; - let tls = block_on(make_rust_tls( - &container.tracker_http_api_core_container.http_api_config.tsl_config, - )) - .map(|tls| tls.expect("tls config failed")); + let tls = make_rust_tls(&container.tracker_http_api_core_container.http_api_config.tsl_config) + .await + .map(|tls| tls.expect("tls config failed")); let server = ApiServer::new(Launcher::new(bind_to, tls)); @@ -99,7 +97,7 @@ impl Environment<Stopped> { impl Environment<Running> { pub async fn new(configuration: &Arc<Configuration>) -> Self { - Environment::<Stopped>::new(configuration).start().await + Environment::<Stopped>::new(configuration).await.start().await } /// # Panics @@ -153,7 +151,7 @@ impl EnvContainer { /// - The configuration does not contain a UDP tracker configuration. /// - The configuration does not contain a HTTP API configuration. #[must_use] - pub fn initialize(configuration: &Configuration) -> Self { + pub async fn initialize(configuration: &Configuration) -> Self { let core_config = Arc::new(configuration.core.clone()); let http_tracker_config = configuration @@ -177,10 +175,8 @@ impl EnvContainer { core_config.tracker_usage_statistics.into(), )); - let tracker_core_container = Arc::new(TrackerCoreContainer::initialize_from( - &core_config, - &swarm_coordination_registry_container, - )); + let tracker_core_container = + Arc::new(TrackerCoreContainer::initialize_from(&core_config, &swarm_coordination_registry_container).await); let http_tracker_core_container = HttpTrackerCoreContainer::initialize_from_tracker_core(&tracker_core_container, &http_tracker_config); diff --git a/packages/axum-rest-tracker-api-server/src/server.rs b/packages/axum-rest-tracker-api-server/src/server.rs index 05adeae8a..460bdefc0 100644 --- a/packages/axum-rest-tracker-api-server/src/server.rs +++ b/packages/axum-rest-tracker-api-server/src/server.rs @@ -220,9 +220,9 @@ pub struct Launcher { impl std::fmt::Display for Launcher { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if self.tls.is_some() { - write!(f, "(with socket): {}, using TLS", self.bind_to,) + write!(f, "(with socket): {}, using TLS", self.bind_to) } else { - write!(f, "(with socket): {}, without TLS", self.bind_to,) + write!(f, "(with socket): {}, without TLS", self.bind_to) } } } @@ -350,7 +350,8 @@ mod tests { let register = &Registar::default(); let http_api_container = - TrackerHttpApiCoreContainer::initialize(&core_config, &http_tracker_config, &udp_tracker_config, &http_api_config); + TrackerHttpApiCoreContainer::initialize(&core_config, &http_tracker_config, &udp_tracker_config, &http_api_config) + .await; let started = stopped .start(http_api_container, register.give_form(), access_tokens) diff --git a/packages/axum-rest-tracker-api-server/tests/server/mod.rs b/packages/axum-rest-tracker-api-server/tests/server/mod.rs index 9dea49a4c..2808c27f9 100644 --- a/packages/axum-rest-tracker-api-server/tests/server/mod.rs +++ b/packages/axum-rest-tracker-api-server/tests/server/mod.rs @@ -3,7 +3,7 @@ pub mod v1; use std::sync::Arc; -use bittorrent_tracker_core::databases::Database; +use bittorrent_tracker_core::databases::SchemaMigrator; /// It forces a database error by dropping all tables. That makes all queries /// fail. @@ -14,6 +14,6 @@ use bittorrent_tracker_core::databases::Database; /// /// - Inject a database mock in the future. /// - Inject directly the database reference passed to the Tracker type. -pub fn force_database_error(tracker: &Arc<Box<dyn Database>>) { - tracker.drop_database_tables().unwrap(); +pub async fn force_database_error(schema_migrator: &Arc<dyn SchemaMigrator>) { + schema_migrator.drop_database_tables().await.unwrap(); } diff --git a/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/auth_key.rs b/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/auth_key.rs index 3781f4f60..20865370d 100644 --- a/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/auth_key.rs +++ b/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/auth_key.rs @@ -135,7 +135,7 @@ async fn should_fail_when_the_auth_key_cannot_be_generated() { let env = Started::new(&configuration::ephemeral().into()).await; - force_database_error(&env.container.tracker_core_container.database); + force_database_error(&env.container.tracker_core_container.database_stores.schema_migrator).await; let request_id = Uuid::new_v4(); @@ -315,7 +315,7 @@ async fn should_fail_when_the_auth_key_cannot_be_deleted() { .await .unwrap(); - force_database_error(&env.container.tracker_core_container.database); + force_database_error(&env.container.tracker_core_container.database_stores.schema_migrator).await; let request_id = Uuid::new_v4(); @@ -433,7 +433,7 @@ async fn should_fail_when_keys_cannot_be_reloaded() { .await .unwrap(); - force_database_error(&env.container.tracker_core_container.database); + force_database_error(&env.container.tracker_core_container.database_stores.schema_migrator).await; let response = Client::new(env.get_connection_info()) .unwrap() @@ -598,7 +598,7 @@ mod deprecated_generate_key_endpoint { let env = Started::new(&configuration::ephemeral().into()).await; - force_database_error(&env.container.tracker_core_container.database); + force_database_error(&env.container.tracker_core_container.database_stores.schema_migrator).await; let request_id = Uuid::new_v4(); let seconds_valid = 60; diff --git a/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/whitelist.rs b/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/whitelist.rs index 61fc233d0..019628a97 100644 --- a/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/whitelist.rs +++ b/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/whitelist.rs @@ -115,7 +115,7 @@ async fn should_fail_when_the_torrent_cannot_be_whitelisted() { let info_hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); // DevSkim: ignore DS173237 - force_database_error(&env.container.tracker_core_container.database); + force_database_error(&env.container.tracker_core_container.database_stores.schema_migrator).await; let request_id = Uuid::new_v4(); @@ -266,7 +266,7 @@ async fn should_fail_when_the_torrent_cannot_be_removed_from_the_whitelist() { .await .unwrap(); - force_database_error(&env.container.tracker_core_container.database); + force_database_error(&env.container.tracker_core_container.database_stores.schema_migrator).await; let request_id = Uuid::new_v4(); @@ -392,7 +392,7 @@ async fn should_fail_when_the_whitelist_cannot_be_reloaded_from_the_database() { .await .unwrap(); - force_database_error(&env.container.tracker_core_container.database); + force_database_error(&env.container.tracker_core_container.database_stores.schema_migrator).await; let request_id = Uuid::new_v4(); diff --git a/packages/axum-server/Cargo.toml b/packages/axum-server/Cargo.toml index a60bab885..45eddd3b0 100644 --- a/packages/axum-server/Cargo.toml +++ b/packages/axum-server/Cargo.toml @@ -4,7 +4,7 @@ description = "A wrapper for the Axum server for Torrust HTTP servers to add tim documentation.workspace = true edition.workspace = true homepage.workspace = true -keywords = ["axum", "server", "torrust", "wrapper"] +keywords = [ "axum", "server", "torrust", "wrapper" ] license.workspace = true name = "torrust-axum-server" publish.workspace = true @@ -14,19 +14,19 @@ rust-version.workspace = true version.workspace = true [dependencies] -axum-server = { version = "0", features = ["tls-rustls-no-provider"] } -camino = { version = "1", features = ["serde", "serde1"] } +axum-server = { version = "0", features = [ "tls-rustls-no-provider" ] } +camino = { version = "1", features = [ "serde", "serde1" ] } futures-util = "0" http-body = "1" hyper = "1" -hyper-util = { version = "0", features = ["http1", "http2", "tokio"] } +hyper-util = { version = "0", features = [ "http1", "http2", "tokio" ] } pin-project-lite = "0" thiserror = "2" -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +tokio = { version = "1", features = [ "macros", "net", "rt-multi-thread", "signal", "sync" ] } torrust-server-lib = { version = "3.0.0-develop", path = "../server-lib" } torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } torrust-tracker-located-error = { version = "3.0.0-develop", path = "../located-error" } -tower = { version = "0", features = ["timeout"] } +tower = { version = "0", features = [ "timeout" ] } tracing = "0" [dev-dependencies] diff --git a/packages/clock/Cargo.toml b/packages/clock/Cargo.toml index 3bd00d2b0..c0cafff0a 100644 --- a/packages/clock/Cargo.toml +++ b/packages/clock/Cargo.toml @@ -1,6 +1,6 @@ [package] description = "A library to a clock for the torrust tracker." -keywords = ["clock", "library", "torrents"] +keywords = [ "clock", "library", "torrents" ] name = "torrust-tracker-clock" readme = "README.md" @@ -16,7 +16,7 @@ rust-version.workspace = true version.workspace = true [dependencies] -chrono = { version = "0", default-features = false, features = ["clock"] } +chrono = { version = "0", default-features = false, features = [ "clock" ] } lazy_static = "1" tracing = "0" diff --git a/packages/configuration/Cargo.toml b/packages/configuration/Cargo.toml index e213f7c0c..1155ba417 100644 --- a/packages/configuration/Cargo.toml +++ b/packages/configuration/Cargo.toml @@ -1,6 +1,6 @@ [package] description = "A library to provide configuration to the Torrust Tracker." -keywords = ["config", "library", "settings"] +keywords = [ "config", "library", "settings" ] name = "torrust-tracker-configuration" readme = "README.md" @@ -15,18 +15,18 @@ rust-version.workspace = true version.workspace = true [dependencies] -camino = { version = "1", features = ["serde", "serde1"] } -derive_more = { version = "2", features = ["constructor", "display"] } -figment = { version = "0", features = ["env", "test", "toml"] } -serde = { version = "1", features = ["derive"] } -serde_json = { version = "1", features = ["preserve_order"] } +camino = { version = "1", features = [ "serde", "serde1" ] } +derive_more = { version = "2", features = [ "constructor", "display" ] } +figment = { version = "0", features = [ "env", "test", "toml" ] } +serde = { version = "1", features = [ "derive" ] } +serde_json = { version = "1", features = [ "preserve_order" ] } serde_with = "3" thiserror = "2" toml = "0" torrust-tracker-located-error = { version = "3.0.0-develop", path = "../located-error" } tracing = "0" -tracing-subscriber = { version = "0", features = ["json"] } +tracing-subscriber = { version = "0", features = [ "json" ] } url = "2" [dev-dependencies] -uuid = { version = "1", features = ["v4"] } +uuid = { version = "1", features = [ "v4" ] } diff --git a/packages/configuration/src/v2_0_0/database.rs b/packages/configuration/src/v2_0_0/database.rs index c2b24d809..457b3c925 100644 --- a/packages/configuration/src/v2_0_0/database.rs +++ b/packages/configuration/src/v2_0_0/database.rs @@ -12,8 +12,10 @@ pub struct Database { /// Database connection string. The format depends on the database driver. /// For `sqlite3`, the format is `path/to/database.db`, for example: /// `./storage/tracker/lib/database/sqlite3.db`. - /// For `Mysql`, the format is `mysql://db_user:db_user_password:port/db_name`, for + /// For `mysql`, the format is `mysql://db_user:db_user_password@host:port/db_name`, for /// example: `mysql://root:password@localhost:3306/torrust`. + /// If the password contains reserved URL characters (for example `+` or `/`), + /// percent-encode it in the URL. #[serde(default = "Database::default_path")] pub path: String, } diff --git a/packages/configuration/src/v2_0_0/mod.rs b/packages/configuration/src/v2_0_0/mod.rs index 8391ba0e1..b3fbc881e 100644 --- a/packages/configuration/src/v2_0_0/mod.rs +++ b/packages/configuration/src/v2_0_0/mod.rs @@ -521,6 +521,7 @@ mod tests { } #[test] + #[allow(clippy::result_large_err)] fn configuration_should_use_the_default_values_when_only_the_mandatory_options_are_provided_by_the_user_via_toml_file() { figment::Jail::expect_with(|jail| { jail.create_file( @@ -552,6 +553,7 @@ mod tests { } #[test] + #[allow(clippy::result_large_err)] fn configuration_should_use_the_default_values_when_only_the_mandatory_options_are_provided_by_the_user_via_toml_content() { figment::Jail::expect_with(|_jail| { let config_toml = r#" @@ -581,6 +583,7 @@ mod tests { } #[test] + #[allow(clippy::result_large_err)] fn default_configuration_could_be_overwritten_from_a_single_env_var_with_toml_contents() { figment::Jail::expect_with(|_jail| { let config_toml = r#" @@ -613,6 +616,7 @@ mod tests { } #[test] + #[allow(clippy::result_large_err)] fn default_configuration_could_be_overwritten_from_a_toml_config_file() { figment::Jail::expect_with(|jail| { jail.create_file( @@ -646,6 +650,7 @@ mod tests { }); } + #[allow(clippy::result_large_err)] #[test] fn configuration_should_allow_to_overwrite_the_default_tracker_api_token_for_admin_with_an_env_var() { figment::Jail::expect_with(|jail| { diff --git a/packages/events/Cargo.toml b/packages/events/Cargo.toml index 1d183cddb..165ecca68 100644 --- a/packages/events/Cargo.toml +++ b/packages/events/Cargo.toml @@ -1,6 +1,6 @@ [package] description = "A library with functionality to handle events in Torrust tracker packages." -keywords = ["events", "library", "rust", "torrust", "tracker"] +keywords = [ "events", "library", "rust", "torrust", "tracker" ] name = "torrust-tracker-events" readme = "README.md" @@ -16,7 +16,7 @@ version.workspace = true [dependencies] futures = "0" -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] } +tokio = { version = "1", features = [ "macros", "net", "rt-multi-thread", "signal", "sync", "time" ] } [dev-dependencies] mockall = "0" diff --git a/packages/http-protocol/Cargo.toml b/packages/http-protocol/Cargo.toml index 7803fe78e..78a037b18 100644 --- a/packages/http-protocol/Cargo.toml +++ b/packages/http-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] description = "A library with the primitive types and functions for the BitTorrent HTTP tracker protocol." -keywords = ["api", "library", "primitives"] +keywords = [ "api", "library", "primitives" ] name = "bittorrent-http-tracker-protocol" readme = "README.md" @@ -18,10 +18,10 @@ version.workspace = true aquatic_udp_protocol = "0" bittorrent-primitives = "0.1.0" bittorrent-tracker-core = { version = "3.0.0-develop", path = "../tracker-core" } -derive_more = { version = "2", features = ["as_ref", "constructor", "from"] } +derive_more = { version = "2", features = [ "as_ref", "constructor", "from" ] } multimap = "0" percent-encoding = "2" -serde = { version = "1", features = ["derive"] } +serde = { version = "1", features = [ "derive" ] } serde_bencode = "0" thiserror = "2" torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } diff --git a/packages/http-protocol/src/v1/responses/scrape.rs b/packages/http-protocol/src/v1/responses/scrape.rs index 022735abc..30319bd6b 100644 --- a/packages/http-protocol/src/v1/responses/scrape.rs +++ b/packages/http-protocol/src/v1/responses/scrape.rs @@ -131,5 +131,25 @@ mod tests { String::from_utf8(expected_bytes.to_vec()).unwrap() ); } + + #[test] + fn should_encode_large_download_counts_as_i64() { + let info_hash = InfoHash::from_bytes(&[0x69; 20]); + let mut scrape_data = ScrapeData::empty(); + scrape_data.add_file( + &info_hash, + SwarmMetadata { + complete: 1, + downloaded: u32::MAX, + incomplete: 3, + }, + ); + + let response = Bencoded::from(scrape_data); + let bytes = response.body(); + let body = String::from_utf8(bytes).unwrap(); + + assert!(body.contains(&format!("downloadedi{}e", i64::from(u32::MAX)))); + } } } diff --git a/packages/http-tracker-core/Cargo.toml b/packages/http-tracker-core/Cargo.toml index 04a6c96b6..c419052f9 100644 --- a/packages/http-tracker-core/Cargo.toml +++ b/packages/http-tracker-core/Cargo.toml @@ -4,7 +4,7 @@ description = "A library with the core functionality needed to implement a BitTo documentation.workspace = true edition.workspace = true homepage.workspace = true -keywords = ["api", "bittorrent", "core", "library", "tracker"] +keywords = [ "api", "bittorrent", "core", "library", "tracker" ] license.workspace = true name = "bittorrent-http-tracker-core" publish.workspace = true @@ -18,11 +18,11 @@ aquatic_udp_protocol = "0" bittorrent-http-tracker-protocol = { version = "3.0.0-develop", path = "../http-protocol" } bittorrent-primitives = "0.1.0" bittorrent-tracker-core = { version = "3.0.0-develop", path = "../tracker-core" } -criterion = { version = "0.5.1", features = ["async_tokio"] } +criterion = { version = "0.5.1", features = [ "async_tokio" ] } futures = "0" serde = "1.0.219" thiserror = "2" -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +tokio = { version = "1", features = [ "macros", "net", "rt-multi-thread", "signal", "sync" ] } tokio-util = "0.7.15" torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } diff --git a/packages/http-tracker-core/benches/helpers/sync.rs b/packages/http-tracker-core/benches/helpers/sync.rs index dbf0dac83..f77c9bc5b 100644 --- a/packages/http-tracker-core/benches/helpers/sync.rs +++ b/packages/http-tracker-core/benches/helpers/sync.rs @@ -8,7 +8,7 @@ use crate::helpers::util::{initialize_core_tracker_services, sample_announce_req #[must_use] pub async fn return_announce_data_once(samples: u64) -> Duration { - let (core_tracker_services, core_http_tracker_services) = initialize_core_tracker_services(); + let (core_tracker_services, core_http_tracker_services) = initialize_core_tracker_services().await; let peer = sample_peer(); diff --git a/packages/http-tracker-core/benches/helpers/util.rs b/packages/http-tracker-core/benches/helpers/util.rs index 028d7c535..4f2f96459 100644 --- a/packages/http-tracker-core/benches/helpers/util.rs +++ b/packages/http-tracker-core/benches/helpers/util.rs @@ -38,17 +38,19 @@ pub struct CoreHttpTrackerServices { pub http_stats_event_sender: bittorrent_http_tracker_core::event::sender::Sender, } -pub fn initialize_core_tracker_services() -> (CoreTrackerServices, CoreHttpTrackerServices) { - initialize_core_tracker_services_with_config(&configuration::ephemeral_public()) +pub async fn initialize_core_tracker_services() -> (CoreTrackerServices, CoreHttpTrackerServices) { + initialize_core_tracker_services_with_config(&configuration::ephemeral_public()).await } -pub fn initialize_core_tracker_services_with_config(config: &Configuration) -> (CoreTrackerServices, CoreHttpTrackerServices) { +pub async fn initialize_core_tracker_services_with_config( + config: &Configuration, +) -> (CoreTrackerServices, CoreHttpTrackerServices) { let cancellation_token = CancellationToken::new(); let core_config = Arc::new(config.core.clone()); - let database = initialize_database(&config.core); + let database = initialize_database(&config.core).await; let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); - let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database.torrent_metrics_store)); let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); let whitelist_authorization = Arc::new(WhitelistAuthorization::new(&config.core, &in_memory_whitelist.clone())); let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); diff --git a/packages/http-tracker-core/benches/http_tracker_core_benchmark.rs b/packages/http-tracker-core/benches/http_tracker_core_benchmark.rs index aa50ceeb9..c193c5124 100644 --- a/packages/http-tracker-core/benches/http_tracker_core_benchmark.rs +++ b/packages/http-tracker-core/benches/http_tracker_core_benchmark.rs @@ -12,7 +12,7 @@ fn announce_once(c: &mut Criterion) { let mut group = c.benchmark_group("http_tracker_handle_announce_once"); group.warm_up_time(Duration::from_millis(500)); - group.measurement_time(Duration::from_millis(1000)); + group.measurement_time(Duration::from_secs(1)); group.bench_function("handle_announce_data", |b| { b.iter(|| sync::return_announce_data_once(100)); diff --git a/packages/http-tracker-core/src/container.rs b/packages/http-tracker-core/src/container.rs index ed0aaf8b0..cc4e69a49 100644 --- a/packages/http-tracker-core/src/container.rs +++ b/packages/http-tracker-core/src/container.rs @@ -26,15 +26,13 @@ pub struct HttpTrackerCoreContainer { impl HttpTrackerCoreContainer { #[must_use] - pub fn initialize(core_config: &Arc<Core>, http_tracker_config: &Arc<HttpTracker>) -> Arc<Self> { + pub async fn initialize(core_config: &Arc<Core>, http_tracker_config: &Arc<HttpTracker>) -> Arc<Self> { let swarm_coordination_registry_container = Arc::new(SwarmCoordinationRegistryContainer::initialize( core_config.tracker_usage_statistics.into(), )); - let tracker_core_container = Arc::new(TrackerCoreContainer::initialize_from( - core_config, - &swarm_coordination_registry_container, - )); + let tracker_core_container = + Arc::new(TrackerCoreContainer::initialize_from(core_config, &swarm_coordination_registry_container).await); Self::initialize_from_tracker_core(&tracker_core_container, http_tracker_config) } diff --git a/packages/http-tracker-core/src/services/announce.rs b/packages/http-tracker-core/src/services/announce.rs index 766f08c12..e6ace18b1 100644 --- a/packages/http-tracker-core/src/services/announce.rs +++ b/packages/http-tracker-core/src/services/announce.rs @@ -232,17 +232,19 @@ mod tests { pub http_stats_event_sender: crate::event::sender::Sender, } - fn initialize_core_tracker_services() -> (CoreTrackerServices, CoreHttpTrackerServices) { - initialize_core_tracker_services_with_config(&configuration::ephemeral_public()) + async fn initialize_core_tracker_services() -> (CoreTrackerServices, CoreHttpTrackerServices) { + initialize_core_tracker_services_with_config(&configuration::ephemeral_public()).await } - fn initialize_core_tracker_services_with_config(config: &Configuration) -> (CoreTrackerServices, CoreHttpTrackerServices) { + async fn initialize_core_tracker_services_with_config( + config: &Configuration, + ) -> (CoreTrackerServices, CoreHttpTrackerServices) { let cancellation_token = CancellationToken::new(); let core_config = Arc::new(config.core.clone()); - let database = initialize_database(&config.core); + let database = initialize_database(&config.core).await; let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); - let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database.torrent_metrics_store)); let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); let whitelist_authorization = Arc::new(WhitelistAuthorization::new(&config.core, &in_memory_whitelist.clone())); let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); @@ -346,7 +348,7 @@ mod tests { #[tokio::test] async fn it_should_return_the_announce_data() { - let (core_tracker_services, core_http_tracker_services) = initialize_core_tracker_services(); + let (core_tracker_services, core_http_tracker_services) = initialize_core_tracker_services().await; let peer = sample_peer(); @@ -412,7 +414,7 @@ mod tests { .returning(|_| Box::pin(future::ready(Some(Ok(1))))); let http_stats_event_sender: crate::event::sender::Sender = Some(Arc::new(http_stats_event_sender_mock)); - let (core_tracker_services, mut core_http_tracker_services) = initialize_core_tracker_services(); + let (core_tracker_services, mut core_http_tracker_services) = initialize_core_tracker_services().await; core_http_tracker_services.http_stats_event_sender = http_stats_event_sender; @@ -486,7 +488,7 @@ mod tests { let http_stats_event_sender: crate::event::sender::Sender = Some(Arc::new(http_stats_event_sender_mock)); let (core_tracker_services, mut core_http_tracker_services) = - initialize_core_tracker_services_with_config(&tracker_with_an_ipv6_external_ip()); + initialize_core_tracker_services_with_config(&tracker_with_an_ipv6_external_ip()).await; core_http_tracker_services.http_stats_event_sender = http_stats_event_sender; @@ -532,7 +534,7 @@ mod tests { .returning(|_| Box::pin(future::ready(Some(Ok(1))))); let http_stats_event_sender: crate::event::sender::Sender = Some(Arc::new(http_stats_event_sender_mock)); - let (core_tracker_services, mut core_http_tracker_services) = initialize_core_tracker_services(); + let (core_tracker_services, mut core_http_tracker_services) = initialize_core_tracker_services().await; core_http_tracker_services.http_stats_event_sender = http_stats_event_sender; let (announce_request, client_ip_sources) = sample_announce_request_for_peer(peer); diff --git a/packages/http-tracker-core/src/services/scrape.rs b/packages/http-tracker-core/src/services/scrape.rs index 4587bc90a..29fd424d3 100644 --- a/packages/http-tracker-core/src/services/scrape.rs +++ b/packages/http-tracker-core/src/services/scrape.rs @@ -195,12 +195,12 @@ mod tests { authentication_service: Arc<AuthenticationService>, } - fn initialize_services_with_configuration(config: &Configuration) -> Container { - let database = initialize_database(&config.core); + async fn initialize_services_with_configuration(config: &Configuration) -> Container { + let database = initialize_database(&config.core).await; let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); let whitelist_authorization = Arc::new(WhitelistAuthorization::new(&config.core, &in_memory_whitelist.clone())); let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); - let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database.torrent_metrics_store)); let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); let authentication_service = Arc::new(AuthenticationService::new(&config.core, &in_memory_key_repository)); @@ -281,7 +281,7 @@ mod tests { let http_stats_event_sender = http_stats_event_bus.sender(); - let container = initialize_services_with_configuration(&configuration); + let container = initialize_services_with_configuration(&configuration).await; let info_hash = sample_info_hash(); let info_hashes = vec![info_hash]; @@ -352,7 +352,7 @@ mod tests { .returning(|_| Box::pin(future::ready(Some(Ok(1))))); let http_stats_event_sender: crate::event::sender::Sender = Some(Arc::new(http_stats_event_sender_mock)); - let container = initialize_services_with_configuration(&config); + let container = initialize_services_with_configuration(&config).await; let peer_ip = IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)); @@ -406,7 +406,7 @@ mod tests { .returning(|_| Box::pin(future::ready(Some(Ok(1))))); let http_stats_event_sender: crate::event::sender::Sender = Some(Arc::new(http_stats_event_sender_mock)); - let container = initialize_services_with_configuration(&config); + let container = initialize_services_with_configuration(&config).await; let peer_ip = IpAddr::V6(Ipv6Addr::new(0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969)); @@ -465,7 +465,7 @@ mod tests { ) { let config = configuration::ephemeral_private(); - let container = initialize_services_with_configuration(&config); + let container = initialize_services_with_configuration(&config).await; // HTTP core stats let http_core_broadcaster = Broadcaster::default(); @@ -518,7 +518,7 @@ mod tests { async fn it_should_send_the_tcp_4_scrape_event_when_the_peer_uses_ipv4() { let config = configuration::ephemeral(); - let container = initialize_services_with_configuration(&config); + let container = initialize_services_with_configuration(&config).await; let mut http_stats_event_sender_mock = MockHttpStatsEventSender::new(); http_stats_event_sender_mock @@ -570,7 +570,7 @@ mod tests { let config = configuration::ephemeral(); - let container = initialize_services_with_configuration(&config); + let container = initialize_services_with_configuration(&config).await; let mut http_stats_event_sender_mock = MockHttpStatsEventSender::new(); http_stats_event_sender_mock diff --git a/packages/located-error/Cargo.toml b/packages/located-error/Cargo.toml index 29b0dfb2c..232a6113f 100644 --- a/packages/located-error/Cargo.toml +++ b/packages/located-error/Cargo.toml @@ -1,6 +1,6 @@ [package] description = "A library to provide error decorator with the location and the source of the original error." -keywords = ["errors", "helper", "library"] +keywords = [ "errors", "helper", "library" ] name = "torrust-tracker-located-error" readme = "README.md" diff --git a/packages/metrics/Cargo.toml b/packages/metrics/Cargo.toml index 0597785f4..b6d327d70 100644 --- a/packages/metrics/Cargo.toml +++ b/packages/metrics/Cargo.toml @@ -1,6 +1,6 @@ [package] description = "A library with the primitive types shared by the Torrust tracker packages." -keywords = ["api", "library", "metrics"] +keywords = [ "api", "library", "metrics" ] name = "torrust-tracker-metrics" readme = "README.md" @@ -15,9 +15,9 @@ rust-version.workspace = true version.workspace = true [dependencies] -chrono = { version = "0", default-features = false, features = ["clock"] } -derive_more = { version = "2", features = ["constructor"] } -serde = { version = "1", features = ["derive"] } +chrono = { version = "0", default-features = false, features = [ "clock" ] } +derive_more = { version = "2", features = [ "constructor" ] } +serde = { version = "1", features = [ "derive" ] } serde_json = "1.0.140" thiserror = "2" torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } diff --git a/packages/metrics/cSpell.json b/packages/metrics/cSpell.json index f04cce9e3..8f5002833 100644 --- a/packages/metrics/cSpell.json +++ b/packages/metrics/cSpell.json @@ -1,21 +1,21 @@ { - "words": [ - "cloneable", - "formatjson", - "Gibibytes", - "Kibibytes", - "Mebibytes", - "ñaca", - "println", - "rstest", - "serde", - "subsec", - "Tebibytes", - "thiserror" + "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", + "version": "0.2", + "dictionaryDefinitions": [ + { + "name": "project-words", + "path": "../../project-words.txt", + "addWords": true + } ], + "dictionaries": ["project-words"], "enableFiletypes": [ "dockerfile", "shellscript", "toml" + ], + "ignorePaths": [ + "target", + "/project-words.txt" ] } diff --git a/packages/primitives/Cargo.toml b/packages/primitives/Cargo.toml index 21fab09bf..c9ce64177 100644 --- a/packages/primitives/Cargo.toml +++ b/packages/primitives/Cargo.toml @@ -1,6 +1,6 @@ [package] description = "A library with the primitive types shared by the Torrust tracker packages." -keywords = ["api", "library", "primitives"] +keywords = [ "api", "library", "primitives" ] name = "torrust-tracker-primitives" readme = "README.md" @@ -18,8 +18,8 @@ version.workspace = true aquatic_udp_protocol = "0" binascii = "0" bittorrent-primitives = "0.1.0" -derive_more = { version = "2", features = ["constructor"] } -serde = { version = "1", features = ["derive"] } +derive_more = { version = "2", features = [ "constructor" ] } +serde = { version = "1", features = [ "derive" ] } tdyne-peer-id = "1" tdyne-peer-id-registry = "0" thiserror = "2" diff --git a/packages/rest-tracker-api-client/Cargo.toml b/packages/rest-tracker-api-client/Cargo.toml index cba580e18..47307df9a 100644 --- a/packages/rest-tracker-api-client/Cargo.toml +++ b/packages/rest-tracker-api-client/Cargo.toml @@ -1,6 +1,6 @@ [package] description = "A library to interact with the Torrust Tracker REST API." -keywords = ["bittorrent", "client", "tracker"] +keywords = [ "bittorrent", "client", "tracker" ] license = "LGPL-3.0" name = "torrust-rest-tracker-api-client" readme = "README.md" @@ -16,8 +16,8 @@ version.workspace = true [dependencies] hyper = "1" -reqwest = { version = "0", features = ["json"] } -serde = { version = "1", features = ["derive"] } +reqwest = { version = "0", features = [ "json", "query" ] } +serde = { version = "1", features = [ "derive" ] } thiserror = "2" -url = { version = "2", features = ["serde"] } -uuid = { version = "1", features = ["v4"] } +url = { version = "2", features = [ "serde" ] } +uuid = { version = "1", features = [ "v4" ] } diff --git a/packages/rest-tracker-api-client/src/v1/client.rs b/packages/rest-tracker-api-client/src/v1/client.rs index 3137b8b41..02a5b0d9c 100644 --- a/packages/rest-tracker-api-client/src/v1/client.rs +++ b/packages/rest-tracker-api-client/src/v1/client.rs @@ -204,22 +204,22 @@ impl Client { /// /// Will panic if the request can't be sent pub async fn get(path: Url, query: Option<Query>, headers: Option<HeaderMap>) -> Response { - let builder = reqwest::Client::builder() + let client = reqwest::Client::builder() .timeout(Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_IN_SECS)) .build() .unwrap(); - let builder = match query { - Some(params) => builder.get(path).query(&ReqwestQuery::from(params)), - None => builder.get(path), - }; + let mut request_builder = client.get(path); - let builder = match headers { - Some(headers) => builder.headers(headers), - None => builder, - }; + if let Some(params) = query { + request_builder = request_builder.query(&ReqwestQuery::from(params)); + } + + if let Some(headers) = headers { + request_builder = request_builder.headers(headers); + } - builder.send().await.unwrap() + request_builder.send().await.unwrap() } /// Returns a `HeaderMap` with a request id header. diff --git a/packages/rest-tracker-api-core/Cargo.toml b/packages/rest-tracker-api-core/Cargo.toml index be6d493d7..0808c2dd6 100644 --- a/packages/rest-tracker-api-core/Cargo.toml +++ b/packages/rest-tracker-api-core/Cargo.toml @@ -4,7 +4,7 @@ description = "A library with the core functionality needed to implement a BitTo documentation.workspace = true edition.workspace = true homepage.workspace = true -keywords = ["api", "bittorrent", "core", "library", "tracker"] +keywords = [ "api", "bittorrent", "core", "library", "tracker" ] license.workspace = true name = "torrust-rest-tracker-api-core" publish.workspace = true @@ -17,7 +17,7 @@ version.workspace = true bittorrent-http-tracker-core = { version = "3.0.0-develop", path = "../http-tracker-core" } bittorrent-tracker-core = { version = "3.0.0-develop", path = "../tracker-core" } bittorrent-udp-tracker-core = { version = "3.0.0-develop", path = "../udp-tracker-core" } -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +tokio = { version = "1", features = [ "macros", "net", "rt-multi-thread", "signal", "sync" ] } tokio-util = "0.7.15" torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } torrust-tracker-metrics = { version = "3.0.0-develop", path = "../metrics" } diff --git a/packages/rest-tracker-api-core/src/container.rs b/packages/rest-tracker-api-core/src/container.rs index bcc5a0186..9be6a5d00 100644 --- a/packages/rest-tracker-api-core/src/container.rs +++ b/packages/rest-tracker-api-core/src/container.rs @@ -30,7 +30,7 @@ pub struct TrackerHttpApiCoreContainer { impl TrackerHttpApiCoreContainer { #[must_use] - pub fn initialize( + pub async fn initialize( core_config: &Arc<Core>, http_tracker_config: &Arc<HttpTracker>, udp_tracker_config: &Arc<UdpTracker>, @@ -40,10 +40,8 @@ impl TrackerHttpApiCoreContainer { core_config.tracker_usage_statistics.into(), )); - let tracker_core_container = Arc::new(TrackerCoreContainer::initialize_from( - core_config, - &swarm_coordination_registry_container, - )); + let tracker_core_container = + Arc::new(TrackerCoreContainer::initialize_from(core_config, &swarm_coordination_registry_container).await); let http_tracker_core_container = HttpTrackerCoreContainer::initialize_from_tracker_core(&tracker_core_container, http_tracker_config); diff --git a/packages/rest-tracker-api-core/src/statistics/services.rs b/packages/rest-tracker-api-core/src/statistics/services.rs index f87cb8c76..bb397b74a 100644 --- a/packages/rest-tracker-api-core/src/statistics/services.rs +++ b/packages/rest-tracker-api-core/src/statistics/services.rs @@ -222,7 +222,7 @@ mod tests { Arc::new(SwarmCoordinationRegistryContainer::initialize(SenderStatus::Enabled)); let tracker_core_container = - TrackerCoreContainer::initialize_from(&core_config, &swarm_coordination_registry_container.clone()); + TrackerCoreContainer::initialize_from(&core_config, &swarm_coordination_registry_container.clone()).await; let _ban_service = Arc::new(RwLock::new(BanService::new(MAX_CONNECTION_ID_ERRORS_PER_IP))); diff --git a/packages/server-lib/Cargo.toml b/packages/server-lib/Cargo.toml index 1d30e7fb5..fbd7a7a7f 100644 --- a/packages/server-lib/Cargo.toml +++ b/packages/server-lib/Cargo.toml @@ -4,7 +4,7 @@ description = "Common functionality used in all Torrust HTTP servers." documentation.workspace = true edition.workspace = true homepage.workspace = true -keywords = ["lib", "server", "torrust"] +keywords = [ "lib", "server", "torrust" ] license.workspace = true name = "torrust-server-lib" publish.workspace = true @@ -14,10 +14,10 @@ rust-version.workspace = true version.workspace = true [dependencies] -derive_more = { version = "2", features = ["as_ref", "constructor", "display", "from"] } -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +derive_more = { version = "2", features = [ "as_ref", "constructor", "display", "from" ] } +tokio = { version = "1", features = [ "macros", "net", "rt-multi-thread", "signal", "sync" ] } torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } -tower-http = { version = "0", features = ["compression-full", "cors", "propagate-header", "request-id", "trace"] } +tower-http = { version = "0", features = [ "compression-full", "cors", "propagate-header", "request-id", "trace" ] } tracing = "0" [dev-dependencies] diff --git a/packages/swarm-coordination-registry/Cargo.toml b/packages/swarm-coordination-registry/Cargo.toml index 45359ad81..f9513d3c4 100644 --- a/packages/swarm-coordination-registry/Cargo.toml +++ b/packages/swarm-coordination-registry/Cargo.toml @@ -1,6 +1,6 @@ [package] description = "A library that provides a repository of torrents files and their peers." -keywords = ["library", "repository", "torrents"] +keywords = [ "library", "repository", "torrents" ] name = "torrust-tracker-swarm-coordination-registry" readme = "README.md" @@ -18,12 +18,12 @@ version.workspace = true [dependencies] aquatic_udp_protocol = "0" bittorrent-primitives = "0.1.0" -chrono = { version = "0", default-features = false, features = ["clock"] } +chrono = { version = "0", default-features = false, features = [ "clock" ] } crossbeam-skiplist = "0" futures = "0" -serde = { version = "1.0.219", features = ["derive"] } +serde = { version = "1.0.219", features = [ "derive" ] } thiserror = "2.0.12" -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +tokio = { version = "1", features = [ "macros", "net", "rt-multi-thread", "signal", "sync" ] } tokio-util = "0.7.15" torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } @@ -33,8 +33,8 @@ torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" tracing = "0" [dev-dependencies] -async-std = { version = "1", features = ["attributes", "tokio1"] } -criterion = { version = "0", features = ["async_tokio"] } +async-std = { version = "1", features = [ "attributes", "tokio1" ] } +criterion = { version = "0", features = [ "async_tokio" ] } mockall = "0" rand = "0" rstest = "0" diff --git a/packages/test-helpers/Cargo.toml b/packages/test-helpers/Cargo.toml index 3495c314a..fb240730d 100644 --- a/packages/test-helpers/Cargo.toml +++ b/packages/test-helpers/Cargo.toml @@ -1,6 +1,6 @@ [package] description = "A library providing helpers for testing the Torrust tracker." -keywords = ["helper", "library", "testing"] +keywords = [ "helper", "library", "testing" ] name = "torrust-tracker-test-helpers" readme = "README.md" @@ -18,4 +18,4 @@ version.workspace = true rand = "0" torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } tracing = "0" -tracing-subscriber = { version = "0", features = ["json"] } +tracing-subscriber = { version = "0", features = [ "json" ] } diff --git a/packages/test-helpers/src/random.rs b/packages/test-helpers/src/random.rs index f096d695c..62265dbd7 100644 --- a/packages/test-helpers/src/random.rs +++ b/packages/test-helpers/src/random.rs @@ -1,6 +1,6 @@ //! Random data generators for testing. use rand::distr::Alphanumeric; -use rand::{rng, Rng}; +use rand::{rng, RngExt}; /// Returns a random alphanumeric string of a certain size. /// diff --git a/packages/torrent-repository-benchmarking/Cargo.toml b/packages/torrent-repository-benchmarking/Cargo.toml index 1a93c513c..653ad8102 100644 --- a/packages/torrent-repository-benchmarking/Cargo.toml +++ b/packages/torrent-repository-benchmarking/Cargo.toml @@ -1,6 +1,6 @@ [package] description = "A library to runt benchmarking for different implementations of a repository of torrents files and their peers." -keywords = ["library", "repository", "torrents"] +keywords = [ "library", "repository", "torrents" ] name = "torrust-tracker-torrent-repository-benchmarking" readme = "README.md" @@ -22,15 +22,15 @@ crossbeam-skiplist = "0" dashmap = "6" futures = "0" parking_lot = "0" -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +tokio = { version = "1", features = [ "macros", "net", "rt-multi-thread", "signal", "sync" ] } torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } zerocopy = "0.7" [dev-dependencies] -async-std = { version = "1", features = ["attributes", "tokio1"] } -criterion = { version = "0", features = ["async_tokio"] } +async-std = { version = "1", features = [ "attributes", "tokio1" ] } +criterion = { version = "0", features = [ "async_tokio" ] } rstest = "0" [[bench]] diff --git a/packages/torrent-repository-benchmarking/benches/repository_benchmark.rs b/packages/torrent-repository-benchmarking/benches/repository_benchmark.rs index a58207492..f5f8e4b28 100644 --- a/packages/torrent-repository-benchmarking/benches/repository_benchmark.rs +++ b/packages/torrent-repository-benchmarking/benches/repository_benchmark.rs @@ -17,7 +17,7 @@ fn add_one_torrent(c: &mut Criterion) { let mut group = c.benchmark_group("add_one_torrent"); group.warm_up_time(Duration::from_millis(500)); - group.measurement_time(Duration::from_millis(1000)); + group.measurement_time(Duration::from_secs(1)); group.bench_function("RwLockStd", |b| { b.iter_custom(sync::add_one_torrent::<TorrentsRwLockStd, _>); @@ -74,7 +74,7 @@ fn add_multiple_torrents_in_parallel(c: &mut Criterion) { //group.sample_size(10); group.warm_up_time(Duration::from_millis(500)); - group.measurement_time(Duration::from_millis(1000)); + group.measurement_time(Duration::from_secs(1)); group.bench_function("RwLockStd", |b| { b.to_async(&rt) @@ -138,7 +138,7 @@ fn update_one_torrent_in_parallel(c: &mut Criterion) { //group.sample_size(10); group.warm_up_time(Duration::from_millis(500)); - group.measurement_time(Duration::from_millis(1000)); + group.measurement_time(Duration::from_secs(1)); group.bench_function("RwLockStd", |b| { b.to_async(&rt) @@ -202,7 +202,7 @@ fn update_multiple_torrents_in_parallel(c: &mut Criterion) { //group.sample_size(10); group.warm_up_time(Duration::from_millis(500)); - group.measurement_time(Duration::from_millis(1000)); + group.measurement_time(Duration::from_secs(1)); group.bench_function("RwLockStd", |b| { b.to_async(&rt) diff --git a/packages/torrent-repository-benchmarking/tests/entry/mod.rs b/packages/torrent-repository-benchmarking/tests/entry/mod.rs index 5cbb3b19c..86ca891d4 100644 --- a/packages/torrent-repository-benchmarking/tests/entry/mod.rs +++ b/packages/torrent-repository-benchmarking/tests/entry/mod.rs @@ -1,5 +1,4 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; -use std::ops::Sub; use std::time::Duration; use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes}; @@ -430,7 +429,9 @@ async fn it_should_remove_inactive_peers_beyond_cutoff( let now = clock::Working::now(); clock::Stopped::local_set(&now); - peer.updated = now.sub(EXPIRE); + peer.updated = now + .checked_sub(EXPIRE) + .expect("it_should_remove_inactive_peers_beyond_cutoff: EXPIRE must not exceed now"); torrent.upsert_peer(&peer).await; diff --git a/packages/torrent-repository-benchmarking/tests/repository/mod.rs b/packages/torrent-repository-benchmarking/tests/repository/mod.rs index c3589ce68..fb0b8fcff 100644 --- a/packages/torrent-repository-benchmarking/tests/repository/mod.rs +++ b/packages/torrent-repository-benchmarking/tests/repository/mod.rs @@ -364,12 +364,10 @@ async fn it_should_get_paginated( } // it should return the only the second entry if both the limit and the offset are one. - Pagination { limit: 1, offset: 1 } => { - if info_hashes.len() > 1 { - let page = repo.get_paginated(Some(&paginated)).await; - assert_eq!(page.len(), 1); - assert_eq!(page[0].0, info_hashes[1]); - } + Pagination { limit: 1, offset: 1 } if info_hashes.len() > 1 => { + let page = repo.get_paginated(Some(&paginated)).await; + assert_eq!(page.len(), 1); + assert_eq!(page[0].0, info_hashes[1]); } // the other cases are not yet tested. _ => {} @@ -528,7 +526,6 @@ async fn it_should_remove_inactive_peers( repo: Repo, #[case] entries: Entries, ) { - use std::ops::Sub as _; use std::time::Duration; use torrust_tracker_clock::clock::stopped::Stopped as _; @@ -558,7 +555,9 @@ async fn it_should_remove_inactive_peers( let now = clock::Working::now(); clock::Stopped::local_set(&now); - peer.updated = now.sub(EXPIRE); + peer.updated = now + .checked_sub(EXPIRE) + .expect("it_should_remove_inactive_peers_beyond_cutoff: EXPIRE must not exceed now"); } // Insert the infohash and peer into the repository diff --git a/packages/tracker-client/Cargo.toml b/packages/tracker-client/Cargo.toml index ef5cccaa2..0cd419471 100644 --- a/packages/tracker-client/Cargo.toml +++ b/packages/tracker-client/Cargo.toml @@ -1,6 +1,6 @@ [package] description = "A library with the generic tracker clients." -keywords = ["bittorrent", "client", "tracker"] +keywords = [ "bittorrent", "client", "tracker" ] license = "LGPL-3.0" name = "bittorrent-tracker-client" readme = "README.md" @@ -17,16 +17,16 @@ version.workspace = true [dependencies] aquatic_udp_protocol = "0" bittorrent-primitives = "0.1.0" -derive_more = { version = "2", features = ["as_ref", "constructor", "from"] } +derive_more = { version = "2", features = [ "as_ref", "constructor", "from" ] } hyper = "1" percent-encoding = "2" -reqwest = { version = "0", features = ["json"] } -serde = { version = "1", features = ["derive"] } +reqwest = { version = "0", features = [ "json" ] } +serde = { version = "1", features = [ "derive" ] } serde_bencode = "0" serde_bytes = "0" serde_repr = "0" thiserror = "2" -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +tokio = { version = "1", features = [ "macros", "net", "rt-multi-thread", "signal", "sync" ] } torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } torrust-tracker-located-error = { version = "3.0.0-develop", path = "../located-error" } torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } @@ -34,4 +34,4 @@ tracing = "0" zerocopy = "0.7" [package.metadata.cargo-machete] -ignored = ["serde_bytes"] +ignored = [ "serde_bytes" ] diff --git a/packages/tracker-client/src/udp/client.rs b/packages/tracker-client/src/udp/client.rs index 1c5ffd901..94c882d29 100644 --- a/packages/tracker-client/src/udp/client.rs +++ b/packages/tracker-client/src/udp/client.rs @@ -256,7 +256,7 @@ pub async fn check(service_binding: &ServiceBinding) -> Result<String, String> { } }; - let sleep = time::sleep(Duration::from_millis(2000)); + let sleep = time::sleep(Duration::from_secs(2)); tokio::pin!(sleep); tokio::select! { diff --git a/packages/tracker-core/Cargo.toml b/packages/tracker-core/Cargo.toml index dfc83e58e..03172fbb6 100644 --- a/packages/tracker-core/Cargo.toml +++ b/packages/tracker-core/Cargo.toml @@ -4,7 +4,7 @@ description = "A library with the core functionality needed to implement a BitTo documentation.workspace = true edition.workspace = true homepage.workspace = true -keywords = ["api", "bittorrent", "core", "library", "tracker"] +keywords = [ "api", "bittorrent", "core", "library", "tracker" ] license.workspace = true name = "bittorrent-tracker-core" publish.workspace = true @@ -13,20 +13,25 @@ repository.workspace = true rust-version.workspace = true version.workspace = true +[features] +default = [ ] +db-compatibility-tests = [ ] + [dependencies] +anyhow = "1" +async-trait = "0" aquatic_udp_protocol = "0" bittorrent-primitives = "0.1.0" -chrono = { version = "0", default-features = false, features = ["clock"] } -derive_more = { version = "2", features = ["as_ref", "constructor", "from"] } +chrono = { version = "0", default-features = false, features = [ "clock" ] } +clap = { version = "4", features = [ "derive" ] } +derive_more = { version = "2", features = [ "as_ref", "constructor", "from" ] } mockall = "0" -r2d2 = "0" -r2d2_mysql = "25" -r2d2_sqlite = { version = "0", features = ["bundled"] } rand = "0" -serde = { version = "1", features = ["derive"] } -serde_json = { version = "1", features = ["preserve_order"] } +serde = { version = "1", features = [ "derive" ] } +serde_json = { version = "1", features = [ "preserve_order" ] } +sqlx = { version = "0.8", features = [ "mysql", "runtime-tokio-native-tls", "sqlite" ] } thiserror = "2" -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +tokio = { version = "1", features = [ "macros", "net", "rt-multi-thread", "signal", "sync" ] } tokio-util = "0.7.15" torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } @@ -35,12 +40,15 @@ torrust-tracker-located-error = { version = "3.0.0-develop", path = "../located- torrust-tracker-metrics = { version = "3.0.0-develop", path = "../metrics" } torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } torrust-tracker-swarm-coordination-registry = { version = "3.0.0-develop", path = "../swarm-coordination-registry" } +testcontainers = "0" tracing = "0" [dev-dependencies] local-ip-address = "0" mockall = "0" -testcontainers = "0" torrust-rest-tracker-api-client = { version = "3.0.0-develop", path = "../rest-tracker-api-client" } torrust-tracker-test-helpers = { version = "3.0.0-develop", path = "../test-helpers" } url = "2.5.4" + +[package.metadata.cargo-machete] +ignored = [ "async-trait" ] diff --git a/packages/tracker-core/docs/benchmarking/README.md b/packages/tracker-core/docs/benchmarking/README.md new file mode 100644 index 000000000..b3b5af704 --- /dev/null +++ b/packages/tracker-core/docs/benchmarking/README.md @@ -0,0 +1,81 @@ +# Persistence Benchmarking Reports + +This folder stores benchmark artifacts produced by +`persistence_benchmark_runner` for `bittorrent-tracker-core`. + +Goals: + +- Keep reproducible baseline reports in-repo. +- Track benchmark evolution across major persistence changes. +- Enable before/after comparisons (for example, before and after SQLx migration). + +## Layout + +- `machine/`: machine and toolchain characteristics for each run date. +- `runs/<date>/`: raw JSON benchmark output files and a run summary. + +## Baseline run (pre-SQLx) + +- Date: `2026-04-28` +- Commit: `51c27fda813876afc1cb26ea1d5bbb0fa49dfdd2` +- Issue context: `docs/issues/1710-1525-03-persistence-benchmarking.md` +- Run summary: `runs/2026-04-28/REPORT.md` +- Machine profile: `machine/2026-04-28-josecelano-desktop.txt` + +Raw JSON artifacts: + +- `runs/2026-04-28/sqlite3.json` +- `runs/2026-04-28/mysql-8.4.json` +- `runs/2026-04-28/mysql-8.0.json` + +## Post-SQLx run + +- Date: `2026-04-30` +- Commit (HEAD at run time): `a4dbc63a6c713e115bfc11374b72743aa51ebfb5` +- Issue context: `docs/issues/1717-1525-05-migrate-sqlite-and-mysql-to-sqlx.md` +- Run summary (with comparison vs `2026-04-28`): `runs/2026-04-30/REPORT.md` +- Machine profile: `machine/2026-04-30-josecelano-desktop.txt` + +Raw JSON artifacts: + +- `runs/2026-04-30/sqlite3.json` +- `runs/2026-04-30/mysql-8.4.json` +- `runs/2026-04-30/mysql-8.0.json` + +## How to add a new run + +1. Create a new run folder: + + `mkdir -p packages/tracker-core/docs/benchmarking/runs/YYYY-MM-DD` + +2. Run benchmarks and save JSON artifacts: + + `cargo run -p bittorrent-tracker-core --bin persistence_benchmark_runner -- --driver sqlite3 > packages/tracker-core/docs/benchmarking/runs/YYYY-MM-DD/sqlite3.json` + + `cargo run -p bittorrent-tracker-core --bin persistence_benchmark_runner -- --driver mysql --db-version 8.4 > packages/tracker-core/docs/benchmarking/runs/YYYY-MM-DD/mysql-8.4.json` + +3. Capture machine profile: + + `mkdir -p packages/tracker-core/docs/benchmarking/machine` + + Save at least OS, kernel, CPU, RAM, Rust toolchain and container runtime versions to: + + `packages/tracker-core/docs/benchmarking/machine/YYYY-MM-DD-<host>.txt` + +4. Add `runs/YYYY-MM-DD/REPORT.md` with: + - benchmark context (commit, command, ops) + - high-level summary (total benchmark time) + - important per-operation medians + - comparison versus a prior run when relevant + +5. Update this index file with links to the new run and machine profile. + +## Planned comparison point + +After implementing: + +- `docs/issues/1525-05-migrate-sqlite-and-mysql-to-sqlx.md` + +run the same benchmark commands again, store results in a new dated folder, and compare against `runs/2026-04-28`. + +The first such comparison was captured at `runs/2026-04-30/REPORT.md`. diff --git a/packages/tracker-core/docs/benchmarking/machine/2026-04-28-josecelano-desktop.txt b/packages/tracker-core/docs/benchmarking/machine/2026-04-28-josecelano-desktop.txt new file mode 100644 index 000000000..9a3d20f31 --- /dev/null +++ b/packages/tracker-core/docs/benchmarking/machine/2026-04-28-josecelano-desktop.txt @@ -0,0 +1,94 @@ +hostname: +josecelano-desktop + +date_utc: +2026-04-28T18:40:06Z + +uname -a: +Linux josecelano-desktop 6.17.0-22-generic #22-Ubuntu SMP PREEMPT_DYNAMIC Fri Mar 13 12:04:44 UTC 2026 x86_64 GNU/Linux + +/etc/os-release: +PRETTY_NAME="Ubuntu 25.10" +NAME="Ubuntu" +VERSION_ID="25.10" +VERSION="25.10 (Questing Quokka)" +VERSION_CODENAME=questing +ID=ubuntu +ID_LIKE=debian +HOME_URL="https://www.ubuntu.com/" +SUPPORT_URL="https://help.ubuntu.com/" +BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" +PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" +UBUNTU_CODENAME=questing +LOGO=ubuntu-logo + +lscpu: +Architecture: x86_64 +CPU op-mode(s): 32-bit, 64-bit +Address sizes: 48 bits physical, 48 bits virtual +Byte Order: Little Endian +CPU(s): 32 +On-line CPU(s) list: 0-31 +Vendor ID: AuthenticAMD +Model name: AMD Ryzen 9 7950X 16-Core Processor +CPU family: 25 +Model: 97 +Thread(s) per core: 2 +Core(s) per socket: 16 +Socket(s): 1 +Stepping: 2 +Frequency boost: enabled +CPU(s) scaling MHz: 76% +CPU max MHz: 5883,1968 +CPU min MHz: 425,2920 +BogoMIPS: 8982,52 +Flags: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ht syscall nx mmxext fxsr_opt pdpe1gb rdtscp lm constant_tsc rep_good amd_lbr_v2 nopl xtopology nonstop_tsc cpuid extd_apicid aperfmperf rapl pni pclmulqdq monitor ssse3 fma cx16 sse4_1 sse4_2 movbe popcnt aes xsave avx f16c rdrand lahf_lm cmp_legacy svm extapic cr8_legacy abm sse4a misalignsse 3dnowprefetch osvw ibs skinit wdt tce topoext perfctr_core perfctr_nb bpext perfctr_llc mwaitx cpuid_fault cpb cat_l3 cdp_l3 hw_pstate ssbd mba perfmon_v2 ibrs ibpb stibp ibrs_enhanced vmmcall fsgsbase bmi1 avx2 smep bmi2 erms invpcid cqm rdt_a avx512f avx512dq rdseed adx smap avx512ifma clflushopt clwb avx512cd sha_ni avx512bw avx512vl xsaveopt xsavec xgetbv1 xsaves cqm_llc cqm_occup_llc cqm_mbm_total cqm_mbm_local user_shstk avx512_bf16 clzero irperf xsaveerptr rdpru wbnoinvd cppc arat npt lbrv svm_lock nrip_save tsc_scale vmcb_clean flushbyasid decodeassists pausefilter pfthreshold avic vgif x2avic v_spec_ctrl vnmi avx512vbmi umip pku ospke avx512_vbmi2 gfni vaes vpclmulqdq avx512_vnni avx512_bitalg avx512_vpopcntdq rdpid overflow_recov succor smca fsrm flush_l1d amd_lbr_pmc_freeze +Virtualization: AMD-V +L1d cache: 512 KiB (16 instances) +L1i cache: 512 KiB (16 instances) +L2 cache: 16 MiB (16 instances) +L3 cache: 64 MiB (2 instances) +NUMA node(s): 1 +NUMA node0 CPU(s): 0-31 +Vulnerability Gather data sampling: Not affected +Vulnerability Ghostwrite: Not affected +Vulnerability Indirect target selection: Not affected +Vulnerability Itlb multihit: Not affected +Vulnerability L1tf: Not affected +Vulnerability Mds: Not affected +Vulnerability Meltdown: Not affected +Vulnerability Mmio stale data: Not affected +Vulnerability Old microcode: Not affected +Vulnerability Reg file data sampling: Not affected +Vulnerability Retbleed: Not affected +Vulnerability Spec rstack overflow: Mitigation; Safe RET +Vulnerability Spec store bypass: Mitigation; Speculative Store Bypass disabled via prctl +Vulnerability Spectre v1: Mitigation; usercopy/swapgs barriers and __user pointer sanitization +Vulnerability Spectre v2: Mitigation; Enhanced / Automatic IBRS; IBPB conditional; STIBP always-on; PBRSB-eIBRS Not affected; BHI Not affected +Vulnerability Srbds: Not affected +Vulnerability Tsa: Mitigation; Clear CPU buffers +Vulnerability Tsx async abort: Not affected +Vulnerability Vmscape: Mitigation; IBPB before exit to userspace + +free -h: + total used free shared buff/cache available +Mem: 61Gi 21Gi 24Gi 589Mi 16Gi 39Gi +Swap: 8,0Gi 2,4Gi 5,6Gi + +rustc -Vv: +rustc 1.97.0-nightly (52b6e2c20 2026-04-27) +binary: rustc +commit-hash: 52b6e2c208b73276ccb36ec0b68456913a801c96 +commit-date: 2026-04-27 +host: x86_64-unknown-linux-gnu +release: 1.97.0-nightly +LLVM version: 22.1.2 + +cargo -V: +cargo 1.97.0-nightly (eb9b60f1f 2026-04-24) + +docker version: +28.3.3 + +podman version: +podman-not-available diff --git a/packages/tracker-core/docs/benchmarking/machine/2026-04-30-josecelano-desktop.txt b/packages/tracker-core/docs/benchmarking/machine/2026-04-30-josecelano-desktop.txt new file mode 100644 index 000000000..9c1daecd7 --- /dev/null +++ b/packages/tracker-core/docs/benchmarking/machine/2026-04-30-josecelano-desktop.txt @@ -0,0 +1,96 @@ +hostname: +josecelano-desktop + +date_utc: +2026-04-30T07:34:51Z + +uname -a: +Linux josecelano-desktop 6.17.0-22-generic #22-Ubuntu SMP PREEMPT_DYNAMIC Fri Mar 13 12:04:44 UTC 2026 x86_64 GNU/Linux + +/etc/os-release: +PRETTY_NAME="Ubuntu 25.10" +NAME="Ubuntu" +VERSION_ID="25.10" +VERSION="25.10 (Questing Quokka)" +VERSION_CODENAME=questing +ID=ubuntu +ID_LIKE=debian +HOME_URL="https://www.ubuntu.com/" +SUPPORT_URL="https://help.ubuntu.com/" +BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" +PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" +UBUNTU_CODENAME=questing +LOGO=ubuntu-logo + +lscpu: +Architecture: x86_64 +CPU op-mode(s): 32-bit, 64-bit +Address sizes: 48 bits physical, 48 bits virtual +Byte Order: Little Endian +CPU(s): 32 +On-line CPU(s) list: 0-31 +Vendor ID: AuthenticAMD +Model name: AMD Ryzen 9 7950X 16-Core Processor +CPU family: 25 +Model: 97 +Thread(s) per core: 2 +Core(s) per socket: 16 +Socket(s): 1 +Stepping: 2 +Frequency boost: enabled +CPU(s) scaling MHz: 79% +CPU max MHz: 5883,1968 +CPU min MHz: 425,2920 +BogoMIPS: 8982,52 +Flags: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ht syscall nx mmxext fxsr_opt pdpe1gb rdtscp lm constant_tsc rep_good amd_lbr_v2 nopl xtopology nonstop_tsc cpuid extd_apicid aperfmperf rapl pni pclmulqdq monitor ssse3 fma cx16 sse4_1 sse4_2 movbe popcnt aes xsave avx f16c rdrand lahf_lm cmp_legacy svm extapic cr8_legacy abm sse4a misalignsse 3dnowprefetch osvw ibs skinit wdt tce topoext perfctr_core perfctr_nb bpext perfctr_llc mwaitx cpuid_fault cpb cat_l3 cdp_l3 hw_pstate ssbd mba perfmon_v2 ibrs ibpb stibp ibrs_enhanced vmmcall fsgsbase bmi1 avx2 smep bmi2 erms invpcid cqm rdt_a avx512f avx512dq rdseed adx smap avx512ifma clflushopt clwb avx512cd sha_ni avx512bw avx512vl xsaveopt xsavec xgetbv1 xsaves cqm_llc cqm_occup_llc cqm_mbm_total cqm_mbm_local user_shstk avx512_bf16 clzero irperf xsaveerptr rdpru wbnoinvd cppc arat npt lbrv svm_lock nrip_save tsc_scale vmcb_clean flushbyasid decodeassists pausefilter pfthreshold avic vgif x2avic v_spec_ctrl vnmi avx512vbmi umip pku ospke avx512_vbmi2 gfni vaes vpclmulqdq avx512_vnni avx512_bitalg avx512_vpopcntdq rdpid overflow_recov succor smca fsrm flush_l1d amd_lbr_pmc_freeze +Virtualization: AMD-V +L1d cache: 512 KiB (16 instances) +L1i cache: 512 KiB (16 instances) +L2 cache: 16 MiB (16 instances) +L3 cache: 64 MiB (2 instances) +NUMA node(s): 1 +NUMA node0 CPU(s): 0-31 +Vulnerability Gather data sampling: Not affected +Vulnerability Ghostwrite: Not affected +Vulnerability Indirect target selection: Not affected +Vulnerability Itlb multihit: Not affected +Vulnerability L1tf: Not affected +Vulnerability Mds: Not affected +Vulnerability Meltdown: Not affected +Vulnerability Mmio stale data: Not affected +Vulnerability Old microcode: Not affected +Vulnerability Reg file data sampling: Not affected +Vulnerability Retbleed: Not affected +Vulnerability Spec rstack overflow: Mitigation; Safe RET +Vulnerability Spec store bypass: Mitigation; Speculative Store Bypass disabled via prctl +Vulnerability Spectre v1: Mitigation; usercopy/swapgs barriers and __user pointer sanitization +Vulnerability Spectre v2: Mitigation; Enhanced / Automatic IBRS; IBPB conditional; STIBP always-on; PBRSB-eIBRS Not affected; BHI Not affected +Vulnerability Srbds: Not affected +Vulnerability Tsa: Mitigation; Clear CPU buffers +Vulnerability Tsx async abort: Not affected +Vulnerability Vmscape: Mitigation; IBPB before exit to userspace + +free -h: + total used free shared buff/cache available +Mem: 61Gi 15Gi 28Gi 437Mi 18Gi 45Gi +Swap: 8,0Gi 3,7Gi 4,3Gi + +rustc -Vv: +rustc 1.97.0-nightly (37d85e592 2026-04-28) +binary: rustc +commit-hash: 37d85e592f9ae5f20f7d9a9f99785246fa7298da +commit-date: 2026-04-28 +host: x86_64-unknown-linux-gnu +release: 1.97.0-nightly +LLVM version: 22.1.4 + +cargo -V: +cargo 1.97.0-nightly (eb9b60f1f 2026-04-24) + +docker version: +Docker version 28.3.3, build 980b856 + +podman version: +Command 'podman' not found, but can be installed with: +sudo apt install podman +podman-not-available diff --git a/packages/tracker-core/docs/benchmarking/runs/2026-04-28/REPORT.md b/packages/tracker-core/docs/benchmarking/runs/2026-04-28/REPORT.md new file mode 100644 index 000000000..8df135c0d --- /dev/null +++ b/packages/tracker-core/docs/benchmarking/runs/2026-04-28/REPORT.md @@ -0,0 +1,66 @@ +# Benchmark Report - 2026-04-28 + +This is the baseline benchmark run captured after implementing: + +- `docs/issues/1710-1525-03-persistence-benchmarking.md` + +## Run context + +- Commit: `51c27fda813876afc1cb26ea1d5bbb0fa49dfdd2` +- Ops per operation: `100` +- Benchmark runner: `cargo run -p bittorrent-tracker-core --bin persistence_benchmark_runner` +- Machine profile: `../../machine/2026-04-28-josecelano-desktop.txt` + +## Raw artifacts + +- `sqlite3.json` +- `mysql-8.4.json` +- `mysql-8.0.json` + +## High-level timing summary + +`meta.timings_ms.total`: + +- sqlite3: `75 ms` +- mysql 8.4: `7381 ms` +- mysql 8.0: `7633 ms` + +Interpretation: + +- sqlite3 is much faster on this local setup. +- mysql 8.4 is slightly faster than mysql 8.0 in this run set. + +## Selected operation medians (microseconds) + +| Operation | sqlite3 | mysql 8.4 | mysql 8.0 | +| ------------------------------- | ------: | --------: | --------: | +| save_torrent_downloads | 64 | 750 | 949 | +| load_torrent_downloads | 9 | 114 | 133 | +| increase_downloads_for_torrent | 50 | 759 | 1027 | +| save_global_downloads | 58 | 745 | 1020 | +| increase_global_downloads | 49 | 748 | 1007 | +| add_info_hash_to_whitelist | 61 | 715 | 998 | +| remove_info_hash_from_whitelist | 116 | 1460 | 1902 | +| add_key_to_keys | 61 | 712 | 948 | +| remove_key_from_keys | 116 | 1476 | 1883 | + +## Machine characteristics (summary) + +From `../../machine/2026-04-28-josecelano-desktop.txt`: + +- Host: `josecelano-desktop` +- OS: `Ubuntu 25.10` +- Kernel: `Linux 6.17.0-22-generic` +- CPU: `AMD Ryzen 9 7950X` (16 cores / 32 threads) +- RAM: `61 GiB` +- Rust: `rustc 1.97.0-nightly (LLVM 22.1.2)` +- Cargo: `1.97.0-nightly` +- Container runtime used by benchmark: `Docker 28.3.3` + +## Next comparison milestone + +After implementing: + +- `docs/issues/1525-05-migrate-sqlite-and-mysql-to-sqlx.md` + +run the same commands, store results under a new date folder, and compare medians and totals against this baseline. diff --git a/packages/tracker-core/docs/benchmarking/runs/2026-04-28/mysql-8.0.json b/packages/tracker-core/docs/benchmarking/runs/2026-04-28/mysql-8.0.json new file mode 100644 index 000000000..5955da33c --- /dev/null +++ b/packages/tracker-core/docs/benchmarking/runs/2026-04-28/mysql-8.0.json @@ -0,0 +1,121 @@ +{ + "meta": { + "git_revision": "51c27fda813876afc1cb26ea1d5bbb0fa49dfdd2", + "driver": "mysql", + "db_version": "8.0", + "ops": 100, + "timestamp": "2026-04-28T18:37:46.176977790+00:00", + "timings_ms": { + "benchmark": 7632, + "report_build": 1, + "total": 7633 + } + }, + "operations": [ + { + "name": "save_torrent_downloads", + "count": 100, + "best_us": 725, + "median_us": 949, + "worst_us": 1778 + }, + { + "name": "load_torrent_downloads", + "count": 100, + "best_us": 117, + "median_us": 133, + "worst_us": 474 + }, + { + "name": "load_all_torrents_downloads", + "count": 100, + "best_us": 155, + "median_us": 160, + "worst_us": 254 + }, + { + "name": "increase_downloads_for_torrent", + "count": 100, + "best_us": 928, + "median_us": 1027, + "worst_us": 1463 + }, + { + "name": "save_global_downloads", + "count": 100, + "best_us": 738, + "median_us": 1020, + "worst_us": 1570 + }, + { + "name": "load_global_downloads", + "count": 100, + "best_us": 115, + "median_us": 117, + "worst_us": 267 + }, + { + "name": "increase_global_downloads", + "count": 100, + "best_us": 741, + "median_us": 1007, + "worst_us": 1493 + }, + { + "name": "add_info_hash_to_whitelist", + "count": 100, + "best_us": 702, + "median_us": 998, + "worst_us": 1491 + }, + { + "name": "get_info_hash_from_whitelist", + "count": 100, + "best_us": 115, + "median_us": 118, + "worst_us": 295 + }, + { + "name": "load_whitelist", + "count": 100, + "best_us": 149, + "median_us": 151, + "worst_us": 203 + }, + { + "name": "remove_info_hash_from_whitelist", + "count": 100, + "best_us": 1642, + "median_us": 1902, + "worst_us": 2519 + }, + { + "name": "add_key_to_keys", + "count": 100, + "best_us": 714, + "median_us": 948, + "worst_us": 1317 + }, + { + "name": "get_key_from_keys", + "count": 100, + "best_us": 129, + "median_us": 131, + "worst_us": 317 + }, + { + "name": "load_keys", + "count": 100, + "best_us": 161, + "median_us": 180, + "worst_us": 266 + }, + { + "name": "remove_key_from_keys", + "count": 100, + "best_us": 1631, + "median_us": 1883, + "worst_us": 4593 + } + ] +} diff --git a/packages/tracker-core/docs/benchmarking/runs/2026-04-28/mysql-8.4.json b/packages/tracker-core/docs/benchmarking/runs/2026-04-28/mysql-8.4.json new file mode 100644 index 000000000..f403d036c --- /dev/null +++ b/packages/tracker-core/docs/benchmarking/runs/2026-04-28/mysql-8.4.json @@ -0,0 +1,121 @@ +{ + "meta": { + "git_revision": "51c27fda813876afc1cb26ea1d5bbb0fa49dfdd2", + "driver": "mysql", + "db_version": "8.4", + "ops": 100, + "timestamp": "2026-04-28T18:39:26.804522153+00:00", + "timings_ms": { + "benchmark": 7380, + "report_build": 1, + "total": 7381 + } + }, + "operations": [ + { + "name": "save_torrent_downloads", + "count": 100, + "best_us": 695, + "median_us": 750, + "worst_us": 3000 + }, + { + "name": "load_torrent_downloads", + "count": 100, + "best_us": 109, + "median_us": 114, + "worst_us": 253 + }, + { + "name": "load_all_torrents_downloads", + "count": 100, + "best_us": 142, + "median_us": 146, + "worst_us": 225 + }, + { + "name": "increase_downloads_for_torrent", + "count": 100, + "best_us": 712, + "median_us": 759, + "worst_us": 1248 + }, + { + "name": "save_global_downloads", + "count": 100, + "best_us": 692, + "median_us": 745, + "worst_us": 1453 + }, + { + "name": "load_global_downloads", + "count": 100, + "best_us": 107, + "median_us": 117, + "worst_us": 243 + }, + { + "name": "increase_global_downloads", + "count": 100, + "best_us": 694, + "median_us": 748, + "worst_us": 1178 + }, + { + "name": "add_info_hash_to_whitelist", + "count": 100, + "best_us": 688, + "median_us": 715, + "worst_us": 1556 + }, + { + "name": "get_info_hash_from_whitelist", + "count": 100, + "best_us": 108, + "median_us": 110, + "worst_us": 233 + }, + { + "name": "load_whitelist", + "count": 100, + "best_us": 147, + "median_us": 150, + "worst_us": 228 + }, + { + "name": "remove_info_hash_from_whitelist", + "count": 100, + "best_us": 1400, + "median_us": 1460, + "worst_us": 1935 + }, + { + "name": "add_key_to_keys", + "count": 100, + "best_us": 689, + "median_us": 712, + "worst_us": 1113 + }, + { + "name": "get_key_from_keys", + "count": 100, + "best_us": 108, + "median_us": 110, + "worst_us": 252 + }, + { + "name": "load_keys", + "count": 100, + "best_us": 155, + "median_us": 174, + "worst_us": 246 + }, + { + "name": "remove_key_from_keys", + "count": 100, + "best_us": 1402, + "median_us": 1476, + "worst_us": 2181 + } + ] +} diff --git a/packages/tracker-core/docs/benchmarking/runs/2026-04-28/sqlite3.json b/packages/tracker-core/docs/benchmarking/runs/2026-04-28/sqlite3.json new file mode 100644 index 000000000..ee792a961 --- /dev/null +++ b/packages/tracker-core/docs/benchmarking/runs/2026-04-28/sqlite3.json @@ -0,0 +1,121 @@ +{ + "meta": { + "git_revision": "51c27fda813876afc1cb26ea1d5bbb0fa49dfdd2", + "driver": "sqlite3", + "db_version": "-", + "ops": 100, + "timestamp": "2026-04-28T18:37:30.676323598+00:00", + "timings_ms": { + "benchmark": 73, + "report_build": 1, + "total": 75 + } + }, + "operations": [ + { + "name": "save_torrent_downloads", + "count": 100, + "best_us": 62, + "median_us": 64, + "worst_us": 73 + }, + { + "name": "load_torrent_downloads", + "count": 100, + "best_us": 9, + "median_us": 9, + "worst_us": 17 + }, + { + "name": "load_all_torrents_downloads", + "count": 100, + "best_us": 24, + "median_us": 24, + "worst_us": 36 + }, + { + "name": "increase_downloads_for_torrent", + "count": 100, + "best_us": 48, + "median_us": 50, + "worst_us": 64 + }, + { + "name": "save_global_downloads", + "count": 100, + "best_us": 57, + "median_us": 58, + "worst_us": 194 + }, + { + "name": "load_global_downloads", + "count": 100, + "best_us": 8, + "median_us": 9, + "worst_us": 16 + }, + { + "name": "increase_global_downloads", + "count": 100, + "best_us": 48, + "median_us": 49, + "worst_us": 191 + }, + { + "name": "add_info_hash_to_whitelist", + "count": 100, + "best_us": 60, + "median_us": 61, + "worst_us": 75 + }, + { + "name": "get_info_hash_from_whitelist", + "count": 100, + "best_us": 8, + "median_us": 9, + "worst_us": 220 + }, + { + "name": "load_whitelist", + "count": 100, + "best_us": 18, + "median_us": 18, + "worst_us": 30 + }, + { + "name": "remove_info_hash_from_whitelist", + "count": 100, + "best_us": 114, + "median_us": 116, + "worst_us": 375 + }, + { + "name": "add_key_to_keys", + "count": 100, + "best_us": 59, + "median_us": 61, + "worst_us": 344 + }, + { + "name": "get_key_from_keys", + "count": 100, + "best_us": 9, + "median_us": 9, + "worst_us": 16 + }, + { + "name": "load_keys", + "count": 100, + "best_us": 25, + "median_us": 25, + "worst_us": 46 + }, + { + "name": "remove_key_from_keys", + "count": 100, + "best_us": 113, + "median_us": 116, + "worst_us": 384 + } + ] +} diff --git a/packages/tracker-core/docs/benchmarking/runs/2026-04-30/REPORT.md b/packages/tracker-core/docs/benchmarking/runs/2026-04-30/REPORT.md new file mode 100644 index 000000000..5a3343092 --- /dev/null +++ b/packages/tracker-core/docs/benchmarking/runs/2026-04-30/REPORT.md @@ -0,0 +1,115 @@ +# Benchmark Report - 2026-04-30 + +This run captures benchmark results after migrating the SQLite and MySQL +drivers from `r2d2` + `rusqlite` / `mysql` to `sqlx 0.8`: + +- `docs/issues/1717-1525-05-migrate-sqlite-and-mysql-to-sqlx.md` + +It is the post-SQLx counterpart of the `2026-04-28` baseline. + +## Run context + +- Commit (HEAD at run time): `a4dbc63a6c713e115bfc11374b72743aa51ebfb5` +- Ops per operation: `100` +- Benchmark runner: `cargo run -p bittorrent-tracker-core --bin persistence_benchmark_runner` +- Machine profile: `../../machine/2026-04-30-josecelano-desktop.txt` +- Same machine as the `2026-04-28` baseline (AMD Ryzen 9 7950X, Ubuntu 25.10). + +The `git_revision` recorded in the JSON artifacts is `a4dbc63a…`. A small +benchmark-harness change was applied locally on top of that commit to wait +for the MySQL container to fully accept TCP connections before running +DDL (see "Notes" below). The change does not touch any code path that +contributes to recorded operation timings, so the numbers remain +comparable. + +## Raw artifacts + +- `sqlite3.json` +- `mysql-8.4.json` +- `mysql-8.0.json` + +## High-level timing summary + +`meta.timings_ms.total`: + +| Driver | Baseline (2026-04-28) | New (2026-04-30) | Delta | +| --------- | --------------------: | ---------------: | -------: | +| sqlite3 | 75 ms | 118 ms | +43 ms | +| mysql 8.4 | 7381 ms | 6231 ms | −1150 ms | +| mysql 8.0 | 7633 ms | 6678 ms | −955 ms | + +Interpretation: + +- MySQL totals improve by ~13–16% on both 8.0 and 8.4, mostly driven by + much faster `remove_*` operations (see medians below). +- sqlite3 total rises by 43 ms. On a 75 ms baseline with only 100 ops per + operation and no warmup, this is well inside run-to-run noise; per-op + medians (next section) are within a handful of microseconds of the + baseline and the `remove_*` operations are actually faster. + +## Selected operation medians (microseconds) + +| Operation | sqlite3 (base → new) | mysql 8.4 (base → new) | mysql 8.0 (base → new) | +| ------------------------------- | -------------------: | ---------------------: | ---------------------: | +| save_torrent_downloads | 64 → 80 | 750 → 779 | 949 → 978 | +| load_torrent_downloads | 9 → 24 | 114 → 119 | 133 → 139 | +| increase_downloads_for_torrent | 50 → 73 | 759 → 824 | 1027 → 972 | +| save_global_downloads | 58 → 72 | 745 → 834 | 1020 → 1046 | +| increase_global_downloads | 49 → 65 | 748 → 820 | 1007 → 1053 | +| add_info_hash_to_whitelist | 61 → 82 | 715 → 739 | 998 → 1010 | +| remove_info_hash_from_whitelist | 116 → 73 | 1460 → 743 | 1902 → 982 | +| add_key_to_keys | 61 → 79 | 712 → 730 | 948 → 958 | +| remove_key_from_keys | 116 → 71 | 1476 → 739 | 1883 → 952 | + +Notable changes: + +- `remove_*` operations are roughly **2× faster** on MySQL 8.4 and 8.0, + and ~35% faster on SQLite. Likely sqlx prepared-statement reuse and + the absence of r2d2 connection-checkout overhead on these short + operations. +- `save_*` and simple `load_*` ops show small (~10–20 µs on SQLite, + ~10–80 µs on MySQL) regressions, well inside per-run variance. +- Overall MySQL throughput is meaningfully better; SQLite totals are + unchanged once you discount the dominant per-op variance contribution. + +## Regression assessment + +No regression. The largest single per-operation regression on either +driver is the SQLite `load_torrent_downloads` median going from 9 µs to +24 µs. That difference (15 µs) is the same order of magnitude as the +syscall jitter that sqlx adds for query execution, and is paid for many +times over by the `remove_*` improvements. End-to-end MySQL benchmark +time drops by 13–16%. + +## Machine characteristics (summary) + +From `../../machine/2026-04-30-josecelano-desktop.txt`: + +- Host: `josecelano-desktop` +- OS: `Ubuntu 25.10` +- Kernel: `Linux 6.17.0-22-generic` +- CPU: `AMD Ryzen 9 7950X` (16 cores / 32 threads) +- Container runtime used by benchmark: `Docker 28.3.3` + +Identical hardware to the `2026-04-28` baseline. + +## Notes + +`sqlx` opens connection pools lazily and does not retry the first query +on connect failure. With the `mysql:8.x` testcontainer image the very +first DDL statement issued by the benchmark harness occasionally raced +the TCP listener and failed with `UnexpectedEof`. The +`r2d2`-based driver previously masked this through implicit pool +checkout retries. + +The benchmark harness now waits for the second `ready for connections` +log line on the container's stderr (the official `mysql` image emits it +twice — first transiently on the unix socket during init, then again on +TCP port `3306`) and then performs a short `connect`+`SELECT 1` retry +loop before handing off to `initialize_database`. This is a bench-only +change in +`packages/tracker-core/src/bin/persistence_benchmark/driver_bench/database/mysql.rs` +and does not alter production code paths. + +Whether to introduce a similar startup-retry policy in production +should be considered separately. diff --git a/packages/tracker-core/docs/benchmarking/runs/2026-04-30/mysql-8.0.json b/packages/tracker-core/docs/benchmarking/runs/2026-04-30/mysql-8.0.json new file mode 100644 index 000000000..ecdb6f6d0 --- /dev/null +++ b/packages/tracker-core/docs/benchmarking/runs/2026-04-30/mysql-8.0.json @@ -0,0 +1,121 @@ +{ + "meta": { + "git_revision": "a4dbc63a6c713e115bfc11374b72743aa51ebfb5", + "driver": "mysql", + "db_version": "8.0", + "ops": 100, + "timestamp": "2026-04-30T08:10:56.811832134+00:00", + "timings_ms": { + "benchmark": 6678, + "report_build": 1, + "total": 6679 + } + }, + "operations": [ + { + "name": "save_torrent_downloads", + "count": 100, + "best_us": 720, + "median_us": 978, + "worst_us": 1565 + }, + { + "name": "load_torrent_downloads", + "count": 100, + "best_us": 115, + "median_us": 139, + "worst_us": 543 + }, + { + "name": "load_all_torrents_downloads", + "count": 100, + "best_us": 174, + "median_us": 198, + "worst_us": 291 + }, + { + "name": "increase_downloads_for_torrent", + "count": 100, + "best_us": 778, + "median_us": 972, + "worst_us": 1488 + }, + { + "name": "save_global_downloads", + "count": 100, + "best_us": 762, + "median_us": 1046, + "worst_us": 1482 + }, + { + "name": "load_global_downloads", + "count": 100, + "best_us": 113, + "median_us": 136, + "worst_us": 252 + }, + { + "name": "increase_global_downloads", + "count": 100, + "best_us": 731, + "median_us": 1053, + "worst_us": 1469 + }, + { + "name": "add_info_hash_to_whitelist", + "count": 100, + "best_us": 759, + "median_us": 1010, + "worst_us": 8684 + }, + { + "name": "get_info_hash_from_whitelist", + "count": 100, + "best_us": 104, + "median_us": 117, + "worst_us": 280 + }, + { + "name": "load_whitelist", + "count": 100, + "best_us": 161, + "median_us": 169, + "worst_us": 274 + }, + { + "name": "remove_info_hash_from_whitelist", + "count": 100, + "best_us": 802, + "median_us": 982, + "worst_us": 4835 + }, + { + "name": "add_key_to_keys", + "count": 100, + "best_us": 725, + "median_us": 958, + "worst_us": 1361 + }, + { + "name": "get_key_from_keys", + "count": 100, + "best_us": 103, + "median_us": 124, + "worst_us": 299 + }, + { + "name": "load_keys", + "count": 100, + "best_us": 166, + "median_us": 179, + "worst_us": 327 + }, + { + "name": "remove_key_from_keys", + "count": 100, + "best_us": 754, + "median_us": 952, + "worst_us": 1558 + } + ] +} diff --git a/packages/tracker-core/docs/benchmarking/runs/2026-04-30/mysql-8.4.json b/packages/tracker-core/docs/benchmarking/runs/2026-04-30/mysql-8.4.json new file mode 100644 index 000000000..d5c37ce30 --- /dev/null +++ b/packages/tracker-core/docs/benchmarking/runs/2026-04-30/mysql-8.4.json @@ -0,0 +1,121 @@ +{ + "meta": { + "git_revision": "a4dbc63a6c713e115bfc11374b72743aa51ebfb5", + "driver": "mysql", + "db_version": "8.4", + "ops": 100, + "timestamp": "2026-04-30T08:09:16.593106220+00:00", + "timings_ms": { + "benchmark": 6231, + "report_build": 1, + "total": 6232 + } + }, + "operations": [ + { + "name": "save_torrent_downloads", + "count": 100, + "best_us": 709, + "median_us": 779, + "worst_us": 1594 + }, + { + "name": "load_torrent_downloads", + "count": 100, + "best_us": 94, + "median_us": 119, + "worst_us": 240 + }, + { + "name": "load_all_torrents_downloads", + "count": 100, + "best_us": 153, + "median_us": 168, + "worst_us": 275 + }, + { + "name": "increase_downloads_for_torrent", + "count": 100, + "best_us": 711, + "median_us": 824, + "worst_us": 1266 + }, + { + "name": "save_global_downloads", + "count": 100, + "best_us": 718, + "median_us": 834, + "worst_us": 2425 + }, + { + "name": "load_global_downloads", + "count": 100, + "best_us": 97, + "median_us": 123, + "worst_us": 309 + }, + { + "name": "increase_global_downloads", + "count": 100, + "best_us": 729, + "median_us": 820, + "worst_us": 1431 + }, + { + "name": "add_info_hash_to_whitelist", + "count": 100, + "best_us": 703, + "median_us": 739, + "worst_us": 1591 + }, + { + "name": "get_info_hash_from_whitelist", + "count": 100, + "best_us": 93, + "median_us": 110, + "worst_us": 250 + }, + { + "name": "load_whitelist", + "count": 100, + "best_us": 150, + "median_us": 159, + "worst_us": 241 + }, + { + "name": "remove_info_hash_from_whitelist", + "count": 100, + "best_us": 708, + "median_us": 743, + "worst_us": 2117 + }, + { + "name": "add_key_to_keys", + "count": 100, + "best_us": 691, + "median_us": 730, + "worst_us": 1126 + }, + { + "name": "get_key_from_keys", + "count": 100, + "best_us": 95, + "median_us": 106, + "worst_us": 216 + }, + { + "name": "load_keys", + "count": 100, + "best_us": 161, + "median_us": 180, + "worst_us": 302 + }, + { + "name": "remove_key_from_keys", + "count": 100, + "best_us": 685, + "median_us": 739, + "worst_us": 1147 + } + ] +} diff --git a/packages/tracker-core/docs/benchmarking/runs/2026-04-30/sqlite3.json b/packages/tracker-core/docs/benchmarking/runs/2026-04-30/sqlite3.json new file mode 100644 index 000000000..45d920c81 --- /dev/null +++ b/packages/tracker-core/docs/benchmarking/runs/2026-04-30/sqlite3.json @@ -0,0 +1,121 @@ +{ + "meta": { + "git_revision": "a4dbc63a6c713e115bfc11374b72743aa51ebfb5", + "driver": "sqlite3", + "db_version": "-", + "ops": 100, + "timestamp": "2026-04-30T07:35:03.030593914+00:00", + "timings_ms": { + "benchmark": 116, + "report_build": 1, + "total": 118 + } + }, + "operations": [ + { + "name": "save_torrent_downloads", + "count": 100, + "best_us": 78, + "median_us": 80, + "worst_us": 104 + }, + { + "name": "load_torrent_downloads", + "count": 100, + "best_us": 23, + "median_us": 24, + "worst_us": 51 + }, + { + "name": "load_all_torrents_downloads", + "count": 100, + "best_us": 70, + "median_us": 80, + "worst_us": 198 + }, + { + "name": "increase_downloads_for_torrent", + "count": 100, + "best_us": 66, + "median_us": 73, + "worst_us": 134 + }, + { + "name": "save_global_downloads", + "count": 100, + "best_us": 70, + "median_us": 72, + "worst_us": 234 + }, + { + "name": "load_global_downloads", + "count": 100, + "best_us": 20, + "median_us": 21, + "worst_us": 40 + }, + { + "name": "increase_global_downloads", + "count": 100, + "best_us": 63, + "median_us": 65, + "worst_us": 79 + }, + { + "name": "add_info_hash_to_whitelist", + "count": 100, + "best_us": 76, + "median_us": 82, + "worst_us": 109 + }, + { + "name": "get_info_hash_from_whitelist", + "count": 100, + "best_us": 21, + "median_us": 23, + "worst_us": 53 + }, + { + "name": "load_whitelist", + "count": 100, + "best_us": 51, + "median_us": 60, + "worst_us": 87 + }, + { + "name": "remove_info_hash_from_whitelist", + "count": 100, + "best_us": 70, + "median_us": 73, + "worst_us": 118 + }, + { + "name": "add_key_to_keys", + "count": 100, + "best_us": 76, + "median_us": 79, + "worst_us": 128 + }, + { + "name": "get_key_from_keys", + "count": 100, + "best_us": 21, + "median_us": 21, + "worst_us": 41 + }, + { + "name": "load_keys", + "count": 100, + "best_us": 75, + "median_us": 82, + "worst_us": 121 + }, + { + "name": "remove_key_from_keys", + "count": 100, + "best_us": 69, + "median_us": 71, + "worst_us": 115 + } + ] +} diff --git a/packages/tracker-core/src/announce_handler.rs b/packages/tracker-core/src/announce_handler.rs index 0b6bffd31..150550f49 100644 --- a/packages/tracker-core/src/announce_handler.rs +++ b/packages/tracker-core/src/announce_handler.rs @@ -167,20 +167,20 @@ impl AnnounceHandler { peer.change_ip(&assign_ip_address_to_peer(remote_client_ip, self.config.net.external_ip)); self.in_memory_torrent_repository - .handle_announcement(info_hash, peer, self.load_downloads_metric_if_needed(info_hash)?) + .handle_announcement(info_hash, peer, self.load_downloads_metric_if_needed(info_hash).await?) .await; Ok(self.build_announce_data(info_hash, peer, peers_wanted).await) } /// Loads the number of downloads for a torrent if needed. - fn load_downloads_metric_if_needed( + async fn load_downloads_metric_if_needed( &self, info_hash: &InfoHash, ) -> Result<Option<NumberOfDownloads>, databases::error::Error> { if self.config.tracker_policy.persistent_torrent_completed_stat && !self.in_memory_torrent_repository.contains(info_hash) { - Ok(self.db_downloads_metric_repository.load_torrent_downloads(info_hash)?) + Ok(self.db_downloads_metric_repository.load_torrent_downloads(info_hash).await?) } else { Ok(None) } @@ -292,9 +292,9 @@ mod tests { use crate::scrape_handler::ScrapeHandler; use crate::test_helpers::tests::initialize_handlers; - fn public_tracker() -> (Arc<AnnounceHandler>, Arc<ScrapeHandler>) { + async fn public_tracker() -> (Arc<AnnounceHandler>, Arc<ScrapeHandler>) { let config = configuration::ephemeral_public(); - initialize_handlers(&config) + initialize_handlers(&config).await } // The client peer IP @@ -453,7 +453,7 @@ mod tests { #[tokio::test] async fn it_should_return_the_announce_data_with_an_empty_peer_list_when_it_is_the_first_announced_peer() { - let (announce_handler, _scrape_handler) = public_tracker(); + let (announce_handler, _scrape_handler) = public_tracker().await; let mut peer = sample_peer(); @@ -467,7 +467,7 @@ mod tests { #[tokio::test] async fn it_should_return_the_announce_data_with_the_previously_announced_peers() { - let (announce_handler, _scrape_handler) = public_tracker(); + let (announce_handler, _scrape_handler) = public_tracker().await; let mut previously_announced_peer = sample_peer_1(); announce_handler @@ -491,7 +491,7 @@ mod tests { #[tokio::test] async fn it_should_allow_peers_to_get_only_a_subset_of_the_peers_in_the_swarm() { - let (announce_handler, _scrape_handler) = public_tracker(); + let (announce_handler, _scrape_handler) = public_tracker().await; let mut previously_announced_peer_1 = sample_peer_1(); announce_handler @@ -537,7 +537,7 @@ mod tests { #[tokio::test] async fn when_the_peer_is_a_seeder() { - let (announce_handler, _scrape_handler) = public_tracker(); + let (announce_handler, _scrape_handler) = public_tracker().await; let mut peer = seeder(); @@ -551,7 +551,7 @@ mod tests { #[tokio::test] async fn when_the_peer_is_a_leecher() { - let (announce_handler, _scrape_handler) = public_tracker(); + let (announce_handler, _scrape_handler) = public_tracker().await; let mut peer = leecher(); @@ -565,7 +565,7 @@ mod tests { #[tokio::test] async fn when_a_previously_announced_started_peer_has_completed_downloading() { - let (announce_handler, _scrape_handler) = public_tracker(); + let (announce_handler, _scrape_handler) = public_tracker().await; // We have to announce with "started" event because peer does not count if peer was not previously known let mut started_peer = started_peer(); diff --git a/packages/tracker-core/src/authentication/handler.rs b/packages/tracker-core/src/authentication/handler.rs index 178895b8d..0c42e350c 100644 --- a/packages/tracker-core/src/authentication/handler.rs +++ b/packages/tracker-core/src/authentication/handler.rs @@ -182,7 +182,7 @@ impl KeysHandler { pub async fn generate_expiring_peer_key(&self, lifetime: Option<Duration>) -> Result<PeerKey, databases::error::Error> { let peer_key = key::generate_key(lifetime); - self.db_key_repository.add(&peer_key)?; + self.db_key_repository.add(&peer_key).await?; self.in_memory_key_repository.insert(&peer_key).await; @@ -229,7 +229,7 @@ impl KeysHandler { // code-review: should we return a friendly error instead of the DB // constrain error when the key already exist? For now, it's returning // the specif error for each DB driver when a UNIQUE constrain fails. - self.db_key_repository.add(&peer_key)?; + self.db_key_repository.add(&peer_key).await?; self.in_memory_key_repository.insert(&peer_key).await; @@ -249,7 +249,7 @@ impl KeysHandler { /// Returns a `databases::error::Error` if the key cannot be removed from /// the database. pub async fn remove_peer_key(&self, key: &Key) -> Result<(), databases::error::Error> { - self.db_key_repository.remove(key)?; + self.db_key_repository.remove(key).await?; self.remove_in_memory_auth_key(key).await; @@ -277,7 +277,7 @@ impl KeysHandler { /// /// Returns a `databases::error::Error` if there is an issue loading the keys from the database. pub async fn load_peer_keys_from_database(&self) -> Result<(), databases::error::Error> { - let keys_from_database = self.db_key_repository.load_keys()?; + let keys_from_database = self.db_key_repository.load_keys().await?; self.in_memory_key_repository.reset_with(keys_from_database).await; @@ -299,33 +299,36 @@ mod tests { use crate::authentication::key::repository::in_memory::InMemoryKeyRepository; use crate::authentication::key::repository::persisted::DatabaseKeyRepository; use crate::databases::setup::initialize_database; - use crate::databases::Database; + use crate::databases::{AuthKeyStore, MockAuthKeyStore}; - fn instantiate_keys_handler() -> KeysHandler { + async fn instantiate_keys_handler() -> KeysHandler { let config = configuration::ephemeral_private(); - instantiate_keys_handler_with_configuration(&config) + instantiate_keys_handler_with_configuration(&config).await } - fn instantiate_keys_handler_with_database(database: &Arc<Box<dyn Database>>) -> KeysHandler { - let db_key_repository = Arc::new(DatabaseKeyRepository::new(database)); + fn instantiate_keys_handler_with_database(auth_key_store: &Arc<dyn AuthKeyStore>) -> KeysHandler { + let db_key_repository = Arc::new(DatabaseKeyRepository::new(auth_key_store)); let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); KeysHandler::new(&db_key_repository, &in_memory_key_repository) } - fn instantiate_keys_handler_with_configuration(config: &Configuration) -> KeysHandler { + async fn instantiate_keys_handler_with_configuration(config: &Configuration) -> KeysHandler { // todo: pass only Core configuration - let database = initialize_database(&config.core); - let db_key_repository = Arc::new(DatabaseKeyRepository::new(&database)); + let stores = initialize_database(&config.core).await; + let db_key_repository = Arc::new(DatabaseKeyRepository::new(&stores.auth_key_store)); let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); KeysHandler::new(&db_key_repository, &in_memory_key_repository) } - mod handling_expiring_peer_keys { + fn mock_auth_key_store() -> MockAuthKeyStore { + MockAuthKeyStore::new() + } + mod handling_expiring_peer_keys { use std::time::Duration; use torrust_tracker_clock::clock::Time; @@ -335,7 +338,7 @@ mod tests { #[tokio::test] async fn it_should_generate_the_key() { - let keys_handler = instantiate_keys_handler(); + let keys_handler = instantiate_keys_handler().await; let peer_key = keys_handler .generate_expiring_peer_key(Some(Duration::from_secs(100))) @@ -358,18 +361,18 @@ mod tests { use torrust_tracker_clock::clock::{self, Time}; use crate::authentication::handler::tests::the_keys_handler_when_the_tracker_is_configured_as_private::{ - instantiate_keys_handler, instantiate_keys_handler_with_database, + instantiate_keys_handler, instantiate_keys_handler_with_database, mock_auth_key_store, }; use crate::authentication::handler::AddKeyRequest; use crate::authentication::PeerKey; use crate::databases::driver::Driver; - use crate::databases::{self, Database, MockDatabase}; + use crate::databases::{self, AuthKeyStore}; use crate::error::PeerKeyError; use crate::CurrentClock; #[tokio::test] async fn it_should_add_a_randomly_generated_key() { - let keys_handler = instantiate_keys_handler(); + let keys_handler = instantiate_keys_handler().await; let peer_key = keys_handler .add_peer_key(AddKeyRequest { @@ -392,7 +395,7 @@ mod tests { // The key should be valid the next 60 seconds. let expected_valid_until = clock::Stopped::now_add(&Duration::from_secs(60)).unwrap(); - let mut database_mock = MockDatabase::default(); + let mut database_mock = mock_auth_key_store(); database_mock .expect_add_key_to_keys() .with(function(move |peer_key: &PeerKey| { @@ -400,14 +403,16 @@ mod tests { })) .times(1) .returning(|_peer_key| { - Err(databases::error::Error::InsertFailed { - location: Location::caller(), - driver: Driver::Sqlite3, + Box::pin(async move { + Err(databases::error::Error::InsertFailed { + location: Location::caller(), + driver: Driver::Sqlite3, + }) }) }); - let database_mock: Arc<Box<dyn Database>> = Arc::new(Box::new(database_mock)); + let auth_key_store: Arc<dyn AuthKeyStore> = Arc::new(database_mock); - let keys_handler = instantiate_keys_handler_with_database(&database_mock); + let keys_handler = instantiate_keys_handler_with_database(&auth_key_store); let result = keys_handler .add_peer_key(AddKeyRequest { @@ -430,18 +435,18 @@ mod tests { use torrust_tracker_clock::clock::{self, Time}; use crate::authentication::handler::tests::the_keys_handler_when_the_tracker_is_configured_as_private::{ - instantiate_keys_handler, instantiate_keys_handler_with_database, + instantiate_keys_handler, instantiate_keys_handler_with_database, mock_auth_key_store, }; use crate::authentication::handler::AddKeyRequest; use crate::authentication::{Key, PeerKey}; use crate::databases::driver::Driver; - use crate::databases::{self, Database, MockDatabase}; + use crate::databases::{self, AuthKeyStore}; use crate::error::PeerKeyError; use crate::CurrentClock; #[tokio::test] async fn it_should_add_a_pre_generated_key() { - let keys_handler = instantiate_keys_handler(); + let keys_handler = instantiate_keys_handler().await; let peer_key = keys_handler .add_peer_key(AddKeyRequest { @@ -462,7 +467,7 @@ mod tests { #[tokio::test] async fn it_should_fail_adding_a_pre_generated_key_when_the_key_duration_exceeds_the_maximum_duration() { - let keys_handler = instantiate_keys_handler(); + let keys_handler = instantiate_keys_handler().await; let result = keys_handler .add_peer_key(AddKeyRequest { @@ -476,7 +481,7 @@ mod tests { #[tokio::test] async fn it_should_fail_adding_a_pre_generated_key_when_the_key_is_invalid() { - let keys_handler = instantiate_keys_handler(); + let keys_handler = instantiate_keys_handler().await; let result = keys_handler .add_peer_key(AddKeyRequest { @@ -499,20 +504,22 @@ mod tests { valid_until: Some(expected_valid_until), }; - let mut database_mock = MockDatabase::default(); + let mut database_mock = mock_auth_key_store(); database_mock .expect_add_key_to_keys() .with(predicate::eq(expected_peer_key)) .times(1) .returning(|_peer_key| { - Err(databases::error::Error::InsertFailed { - location: Location::caller(), - driver: Driver::Sqlite3, + Box::pin(async move { + Err(databases::error::Error::InsertFailed { + location: Location::caller(), + driver: Driver::Sqlite3, + }) }) }); - let database_mock: Arc<Box<dyn Database>> = Arc::new(Box::new(database_mock)); + let auth_key_store: Arc<dyn AuthKeyStore> = Arc::new(database_mock); - let keys_handler = instantiate_keys_handler_with_database(&database_mock); + let keys_handler = instantiate_keys_handler_with_database(&auth_key_store); let result = keys_handler .add_peer_key(AddKeyRequest { @@ -536,17 +543,17 @@ mod tests { use mockall::predicate::function; use crate::authentication::handler::tests::the_keys_handler_when_the_tracker_is_configured_as_private::{ - instantiate_keys_handler, instantiate_keys_handler_with_database, + instantiate_keys_handler, instantiate_keys_handler_with_database, mock_auth_key_store, }; use crate::authentication::handler::AddKeyRequest; use crate::authentication::PeerKey; use crate::databases::driver::Driver; - use crate::databases::{self, Database, MockDatabase}; + use crate::databases::{self, AuthKeyStore}; use crate::error::PeerKeyError; #[tokio::test] async fn it_should_generate_the_key() { - let keys_handler = instantiate_keys_handler(); + let keys_handler = instantiate_keys_handler().await; let peer_key = keys_handler.generate_permanent_peer_key().await.unwrap(); @@ -555,7 +562,7 @@ mod tests { #[tokio::test] async fn it_should_add_a_randomly_generated_key() { - let keys_handler = instantiate_keys_handler(); + let keys_handler = instantiate_keys_handler().await; let peer_key = keys_handler .add_peer_key(AddKeyRequest { @@ -570,20 +577,22 @@ mod tests { #[tokio::test] async fn it_should_fail_adding_a_randomly_generated_key_when_there_is_a_database_error() { - let mut database_mock = MockDatabase::default(); + let mut database_mock = mock_auth_key_store(); database_mock .expect_add_key_to_keys() .with(function(move |peer_key: &PeerKey| peer_key.valid_until.is_none())) .times(1) .returning(|_peer_key| { - Err(databases::error::Error::InsertFailed { - location: Location::caller(), - driver: Driver::Sqlite3, + Box::pin(async move { + Err(databases::error::Error::InsertFailed { + location: Location::caller(), + driver: Driver::Sqlite3, + }) }) }); - let database_mock: Arc<Box<dyn Database>> = Arc::new(Box::new(database_mock)); + let auth_key_store: Arc<dyn AuthKeyStore> = Arc::new(database_mock); - let keys_handler = instantiate_keys_handler_with_database(&database_mock); + let keys_handler = instantiate_keys_handler_with_database(&auth_key_store); let result = keys_handler .add_peer_key(AddKeyRequest { @@ -604,17 +613,17 @@ mod tests { use mockall::predicate; use crate::authentication::handler::tests::the_keys_handler_when_the_tracker_is_configured_as_private::{ - instantiate_keys_handler, instantiate_keys_handler_with_database, + instantiate_keys_handler, instantiate_keys_handler_with_database, mock_auth_key_store, }; use crate::authentication::handler::AddKeyRequest; use crate::authentication::{Key, PeerKey}; use crate::databases::driver::Driver; - use crate::databases::{self, Database, MockDatabase}; + use crate::databases::{self, AuthKeyStore}; use crate::error::PeerKeyError; #[tokio::test] async fn it_should_add_a_pre_generated_key() { - let keys_handler = instantiate_keys_handler(); + let keys_handler = instantiate_keys_handler().await; let peer_key = keys_handler .add_peer_key(AddKeyRequest { @@ -635,7 +644,7 @@ mod tests { #[tokio::test] async fn it_should_fail_adding_a_pre_generated_key_when_the_key_is_invalid() { - let keys_handler = instantiate_keys_handler(); + let keys_handler = instantiate_keys_handler().await; let result = keys_handler .add_peer_key(AddKeyRequest { @@ -654,20 +663,22 @@ mod tests { valid_until: None, }; - let mut database_mock = MockDatabase::default(); + let mut database_mock = mock_auth_key_store(); database_mock .expect_add_key_to_keys() .with(predicate::eq(expected_peer_key)) .times(1) .returning(|_peer_key| { - Err(databases::error::Error::InsertFailed { - location: Location::caller(), - driver: Driver::Sqlite3, + Box::pin(async move { + Err(databases::error::Error::InsertFailed { + location: Location::caller(), + driver: Driver::Sqlite3, + }) }) }); - let database_mock: Arc<Box<dyn Database>> = Arc::new(Box::new(database_mock)); + let auth_key_store: Arc<dyn AuthKeyStore> = Arc::new(database_mock); - let keys_handler = instantiate_keys_handler_with_database(&database_mock); + let keys_handler = instantiate_keys_handler_with_database(&auth_key_store); let result = keys_handler .add_peer_key(AddKeyRequest { diff --git a/packages/tracker-core/src/authentication/key/mod.rs b/packages/tracker-core/src/authentication/key/mod.rs index 44bbd0688..ce65385ce 100644 --- a/packages/tracker-core/src/authentication/key/mod.rs +++ b/packages/tracker-core/src/authentication/key/mod.rs @@ -191,8 +191,8 @@ pub enum Error { MissingAuthKey { location: &'static Location<'static> }, } -impl From<r2d2_sqlite::rusqlite::Error> for Error { - fn from(e: r2d2_sqlite::rusqlite::Error) -> Self { +impl From<sqlx::Error> for Error { + fn from(e: sqlx::Error) -> Self { Error::KeyVerificationError { source: (Arc::new(e) as DynError).into(), } @@ -296,7 +296,7 @@ mod tests { #[test] fn could_be_a_database_error() { - let err = r2d2_sqlite::rusqlite::Error::InvalidQuery; + let err = sqlx::Error::RowNotFound; let err: key::Error = err.into(); diff --git a/packages/tracker-core/src/authentication/key/peer_key.rs b/packages/tracker-core/src/authentication/key/peer_key.rs index 41aba950b..ba648ad2f 100644 --- a/packages/tracker-core/src/authentication/key/peer_key.rs +++ b/packages/tracker-core/src/authentication/key/peer_key.rs @@ -13,7 +13,7 @@ use std::time::Duration; use derive_more::Display; use rand::distr::Alphanumeric; -use rand::{rng, Rng}; +use rand::{rng, RngExt}; use serde::{Deserialize, Serialize}; use thiserror::Error; use torrust_tracker_clock::conv::convert_from_timestamp_to_datetime_utc; diff --git a/packages/tracker-core/src/authentication/key/repository/persisted.rs b/packages/tracker-core/src/authentication/key/repository/persisted.rs index e84a23c9b..eed0026f2 100644 --- a/packages/tracker-core/src/authentication/key/repository/persisted.rs +++ b/packages/tracker-core/src/authentication/key/repository/persisted.rs @@ -2,15 +2,15 @@ use std::sync::Arc; use crate::authentication::key::{Key, PeerKey}; -use crate::databases::{self, Database}; +use crate::databases::{self, AuthKeyStore}; /// A repository for storing authentication keys in a persistent database. /// /// This repository provides methods to add, remove, and load authentication /// keys from the underlying database. It wraps an instance of a type -/// implementing the [`Database`] trait. +/// implementing the [`AuthKeyStore`] trait. pub struct DatabaseKeyRepository { - database: Arc<Box<dyn Database>>, + database: Arc<dyn AuthKeyStore>, } impl DatabaseKeyRepository { @@ -18,13 +18,13 @@ impl DatabaseKeyRepository { /// /// # Arguments /// - /// * `database` - A shared reference to a boxed database implementation. + /// * `database` - A shared reference to an auth-key store implementation. /// /// # Returns /// /// A new instance of `DatabaseKeyRepository` #[must_use] - pub fn new(database: &Arc<Box<dyn Database>>) -> Self { + pub fn new(database: &Arc<dyn AuthKeyStore>) -> Self { Self { database: database.clone(), } @@ -39,8 +39,8 @@ impl DatabaseKeyRepository { /// # Errors /// /// Returns a [`databases::error::Error`] if the key cannot be added. - pub(crate) fn add(&self, peer_key: &PeerKey) -> Result<(), databases::error::Error> { - self.database.add_key_to_keys(peer_key)?; + pub(crate) async fn add(&self, peer_key: &PeerKey) -> Result<(), databases::error::Error> { + self.database.add_key_to_keys(peer_key).await?; Ok(()) } @@ -53,8 +53,8 @@ impl DatabaseKeyRepository { /// # Errors /// /// Returns a [`databases::error::Error`] if the key cannot be removed. - pub(crate) fn remove(&self, key: &Key) -> Result<(), databases::error::Error> { - self.database.remove_key_from_keys(key)?; + pub(crate) async fn remove(&self, key: &Key) -> Result<(), databases::error::Error> { + self.database.remove_key_from_keys(key).await?; Ok(()) } @@ -67,8 +67,8 @@ impl DatabaseKeyRepository { /// # Returns /// /// A vector containing all persisted [`PeerKey`] entries. - pub(crate) fn load_keys(&self) -> Result<Vec<PeerKey>, databases::error::Error> { - let keys = self.database.load_keys()?; + pub(crate) async fn load_keys(&self) -> Result<Vec<PeerKey>, databases::error::Error> { + let keys = self.database.load_keys().await?; Ok(keys) } } @@ -94,64 +94,64 @@ mod tests { config } - #[test] - fn persist_a_new_peer_key() { + #[tokio::test] + async fn persist_a_new_peer_key() { let configuration = ephemeral_configuration(); - let database = initialize_database(&configuration); + let stores = initialize_database(&configuration).await; - let repository = DatabaseKeyRepository::new(&database); + let repository = DatabaseKeyRepository::new(&stores.auth_key_store); let peer_key = PeerKey { key: Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(), valid_until: Some(Duration::new(9999, 0)), }; - let result = repository.add(&peer_key); + let result = repository.add(&peer_key).await; assert!(result.is_ok()); - let keys = repository.load_keys().unwrap(); + let keys = repository.load_keys().await.unwrap(); assert_eq!(keys, vec!(peer_key)); } - #[test] - fn remove_a_persisted_peer_key() { + #[tokio::test] + async fn remove_a_persisted_peer_key() { let configuration = ephemeral_configuration(); - let database = initialize_database(&configuration); + let stores = initialize_database(&configuration).await; - let repository = DatabaseKeyRepository::new(&database); + let repository = DatabaseKeyRepository::new(&stores.auth_key_store); let peer_key = PeerKey { key: Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(), valid_until: Some(Duration::new(9999, 0)), }; - let _unused = repository.add(&peer_key); + let _unused = repository.add(&peer_key).await; - let result = repository.remove(&peer_key.key); + let result = repository.remove(&peer_key.key).await; assert!(result.is_ok()); - let keys = repository.load_keys().unwrap(); + let keys = repository.load_keys().await.unwrap(); assert!(keys.is_empty()); } - #[test] - fn load_all_persisted_peer_keys() { + #[tokio::test] + async fn load_all_persisted_peer_keys() { let configuration = ephemeral_configuration(); - let database = initialize_database(&configuration); + let stores = initialize_database(&configuration).await; - let repository = DatabaseKeyRepository::new(&database); + let repository = DatabaseKeyRepository::new(&stores.auth_key_store); let peer_key = PeerKey { key: Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(), valid_until: Some(Duration::new(9999, 0)), }; - let _unused = repository.add(&peer_key); + let _unused = repository.add(&peer_key).await; - let keys = repository.load_keys().unwrap(); + let keys = repository.load_keys().await.unwrap(); assert_eq!(keys, vec!(peer_key)); } diff --git a/packages/tracker-core/src/authentication/mod.rs b/packages/tracker-core/src/authentication/mod.rs index 12b742b8b..ba793ecf0 100644 --- a/packages/tracker-core/src/authentication/mod.rs +++ b/packages/tracker-core/src/authentication/mod.rs @@ -44,13 +44,13 @@ mod tests { use crate::authentication::service::AuthenticationService; use crate::databases::setup::initialize_database; - fn instantiate_keys_manager_and_authentication() -> (Arc<KeysHandler>, Arc<AuthenticationService>) { + async fn instantiate_keys_manager_and_authentication() -> (Arc<KeysHandler>, Arc<AuthenticationService>) { let config = configuration::ephemeral_private(); - instantiate_keys_manager_and_authentication_with_configuration(&config) + instantiate_keys_manager_and_authentication_with_configuration(&config).await } - fn instantiate_keys_manager_and_authentication_with_checking_keys_expiration_disabled( + async fn instantiate_keys_manager_and_authentication_with_checking_keys_expiration_disabled( ) -> (Arc<KeysHandler>, Arc<AuthenticationService>) { let mut config = configuration::ephemeral_private(); @@ -58,14 +58,14 @@ mod tests { check_keys_expiration: false, }); - instantiate_keys_manager_and_authentication_with_configuration(&config) + instantiate_keys_manager_and_authentication_with_configuration(&config).await } - fn instantiate_keys_manager_and_authentication_with_configuration( + async fn instantiate_keys_manager_and_authentication_with_configuration( config: &Configuration, ) -> (Arc<KeysHandler>, Arc<AuthenticationService>) { - let database = initialize_database(&config.core); - let db_key_repository = Arc::new(DatabaseKeyRepository::new(&database)); + let stores = initialize_database(&config.core).await; + let db_key_repository = Arc::new(DatabaseKeyRepository::new(&stores.auth_key_store)); let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); let authentication_service = Arc::new(service::AuthenticationService::new(&config.core, &in_memory_key_repository)); let keys_handler = Arc::new(KeysHandler::new( @@ -78,7 +78,7 @@ mod tests { #[tokio::test] async fn it_should_remove_an_authentication_key() { - let (keys_manager, authentication_service) = instantiate_keys_manager_and_authentication(); + let (keys_manager, authentication_service) = instantiate_keys_manager_and_authentication().await; let expiring_key = keys_manager .generate_expiring_peer_key(Some(Duration::from_secs(100))) @@ -95,7 +95,7 @@ mod tests { #[tokio::test] async fn it_should_load_authentication_keys_from_the_database() { - let (keys_manager, authentication_service) = instantiate_keys_manager_and_authentication(); + let (keys_manager, authentication_service) = instantiate_keys_manager_and_authentication().await; let expiring_key = keys_manager .generate_expiring_peer_key(Some(Duration::from_secs(100))) @@ -126,7 +126,7 @@ mod tests { #[tokio::test] async fn it_should_authenticate_a_peer_with_the_key() { - let (keys_manager, authentication_service) = instantiate_keys_manager_and_authentication(); + let (keys_manager, authentication_service) = instantiate_keys_manager_and_authentication().await; let peer_key = keys_manager .generate_expiring_peer_key(Some(Duration::from_secs(100))) @@ -141,7 +141,7 @@ mod tests { #[tokio::test] async fn it_should_accept_an_expired_key_when_checking_expiration_is_disabled_in_configuration() { let (keys_manager, authentication_service) = - instantiate_keys_manager_and_authentication_with_checking_keys_expiration_disabled(); + instantiate_keys_manager_and_authentication_with_checking_keys_expiration_disabled().await; let past_timestamp = Duration::ZERO; @@ -165,7 +165,7 @@ mod tests { #[tokio::test] async fn it_should_authenticate_a_peer_with_the_key() { - let (keys_manager, authentication_service) = instantiate_keys_manager_and_authentication(); + let (keys_manager, authentication_service) = instantiate_keys_manager_and_authentication().await; let peer_key = keys_manager .add_peer_key(AddKeyRequest { @@ -183,7 +183,7 @@ mod tests { #[tokio::test] async fn it_should_accept_an_expired_key_when_checking_expiration_is_disabled_in_configuration() { let (keys_manager, authentication_service) = - instantiate_keys_manager_and_authentication_with_checking_keys_expiration_disabled(); + instantiate_keys_manager_and_authentication_with_checking_keys_expiration_disabled().await; let peer_key = keys_manager .add_peer_key(AddKeyRequest { @@ -205,7 +205,7 @@ mod tests { #[tokio::test] async fn it_should_authenticate_a_peer_with_the_key() { - let (keys_manager, authentication_service) = instantiate_keys_manager_and_authentication(); + let (keys_manager, authentication_service) = instantiate_keys_manager_and_authentication().await; let peer_key = keys_manager.generate_permanent_peer_key().await.unwrap(); @@ -222,7 +222,7 @@ mod tests { #[tokio::test] async fn it_should_authenticate_a_peer_with_the_key() { - let (keys_manager, authentication_service) = instantiate_keys_manager_and_authentication(); + let (keys_manager, authentication_service) = instantiate_keys_manager_and_authentication().await; let peer_key = keys_manager .add_peer_key(AddKeyRequest { diff --git a/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/database/mod.rs b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/database/mod.rs new file mode 100644 index 000000000..083d735a4 --- /dev/null +++ b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/database/mod.rs @@ -0,0 +1,94 @@ +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::{anyhow, Context, Result}; +use bittorrent_tracker_core::databases::driver::Driver; +use bittorrent_tracker_core::databases::setup::DatabaseStores; +use bittorrent_tracker_core::databases::SchemaMigrator; +use testcontainers::{ContainerAsync, GenericImage}; + +mod mysql; +mod sqlite; + +pub(super) struct ActiveDatabase { + pub(super) database: Option<DatabaseStores>, + resource: Option<BenchmarkResource>, +} + +enum BenchmarkResource { + Sqlite(PathBuf), + Mysql(Box<ContainerAsync<GenericImage>>), +} + +impl ActiveDatabase { + /// Creates an initialized benchmark database for the selected driver. + /// + /// For `sqlite3`, this creates a unique temporary database file. + /// For `mysql`, this starts a temporary container and builds a connection + /// URL from mapped host/port details. + /// + /// # Errors + /// + /// Returns an error if the `MySQL` container cannot be started or queried for + /// connection details. + pub(super) async fn new(driver: Driver, db_version: &str) -> Result<Self> { + match driver { + Driver::Sqlite3 => Ok(sqlite::initialize().await), + Driver::MySQL => mysql::initialize(db_version).await, + } + } +} + +impl Drop for ActiveDatabase { + fn drop(&mut self) { + // Drop the database connection before cleaning up the resource. + // For SQLite this ensures the file handle is released before removal. + drop(self.database.take()); + match self.resource.take() { + Some(BenchmarkResource::Sqlite(path)) => { + let _removed_file_result = std::fs::remove_file(path); + } + Some(BenchmarkResource::Mysql(container)) => { + drop(container); + } + None => {} + } + } +} + +pub(super) async fn reset_database(schema_migrator: &dyn SchemaMigrator) -> Result<()> { + create_database_tables_with_retry(schema_migrator).await?; + schema_migrator + .drop_database_tables() + .await + .context("failed to drop benchmark database tables")?; + create_database_tables_with_retry(schema_migrator).await +} + +/// Retries table creation until the database is ready. +/// +/// This primarily shields `MySQL` startup latency where the process may be up +/// before it is ready to accept migrations/queries. +/// +/// # Errors +/// +/// Returns an error if the database is still not ready after all retries. +async fn create_database_tables_with_retry(schema_migrator: &dyn SchemaMigrator) -> Result<()> { + let mut last_error: Option<anyhow::Error> = None; + + for _ in 0..5 { + match schema_migrator.create_database_tables().await { + Ok(()) => return Ok(()), + Err(error) => { + last_error = Some(error.into()); + } + } + + tokio::time::sleep(Duration::from_secs(2)).await; + } + + match last_error { + Some(error) => Err(anyhow!("database is not ready after retries; last error: {error}")), + None => Err(anyhow!("database is not ready after retries")), + } +} diff --git a/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/database/mysql.rs b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/database/mysql.rs new file mode 100644 index 000000000..a07cce287 --- /dev/null +++ b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/database/mysql.rs @@ -0,0 +1,98 @@ +use std::str::FromStr; +use std::time::Duration; + +use anyhow::{Context, Result}; +use bittorrent_tracker_core::databases::setup::initialize_database; +use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions}; +use testcontainers::core::wait::LogWaitStrategy; +use testcontainers::core::{IntoContainerPort, WaitFor}; +use testcontainers::runners::AsyncRunner; +use testcontainers::{GenericImage, ImageExt}; +use torrust_tracker_configuration as configuration; + +use super::{ActiveDatabase, BenchmarkResource}; + +/// Maximum number of connect-and-ping attempts after the container is reported +/// ready. Belt-and-braces against a brief race between the second +/// `ready for connections` log line and TCP acceptance on port 3306. +const READINESS_PING_RETRIES: usize = 30; +/// Delay between readiness-ping attempts. +const READINESS_PING_INTERVAL: Duration = Duration::from_millis(500); + +pub(super) async fn initialize(db_version: &str) -> Result<ActiveDatabase> { + // The official `mysql` image emits `ready for connections` twice on stderr: + // first transiently during init on the unix socket, then again once mysqld + // is actually accepting TCP clients on port 3306. We wait for the second + // occurrence so the first query (DDL via `initialize_database`) does not + // race the TCP listener and panic with `UnexpectedEof`. This is the same + // idiom the Java testcontainers MySQL module uses internally. + let mysql_container = GenericImage::new("mysql", db_version) + .with_exposed_port(3306.tcp()) + .with_wait_for(WaitFor::Log(LogWaitStrategy::stderr("ready for connections").with_times(2))) + .with_env_var("MYSQL_ROOT_PASSWORD", "test") + .with_env_var("MYSQL_DATABASE", "torrust_tracker_bench") + .with_env_var("MYSQL_ROOT_HOST", "%") + .start() + .await + .context("failed to start mysql test container")?; + + let host = mysql_container + .get_host() + .await + .context("failed to resolve mysql container host")?; + let port = mysql_container + .get_host_port_ipv4(3306) + .await + .context("failed to resolve mysql container host port")?; + + let mysql_database_url = format!("mysql://root:test@{host}:{port}/torrust_tracker_bench"); + + // Belt-and-braces: even after the readiness log message, the very first TCP + // connect can still hit `UnexpectedEof` while mysqld finalises bind/accept. + // Probe with a short connect-and-ping loop so the production + // `initialize_database` call below sees a steady server. This mirrors what + // the previous r2d2-based driver did implicitly through pool checkout + // retries. + wait_until_mysql_accepts_connections(&mysql_database_url) + .await + .context("mysql container did not accept connections in time")?; + + let mut config = configuration::Core::default(); + config.database.driver = configuration::Driver::MySQL; + config.database.path = mysql_database_url; + let database = initialize_database(&config).await; + + Ok(ActiveDatabase { + database: Some(database), + resource: Some(BenchmarkResource::Mysql(Box::new(mysql_container))), + }) +} + +async fn wait_until_mysql_accepts_connections(database_url: &str) -> Result<()> { + let options = MySqlConnectOptions::from_str(database_url).context("invalid mysql benchmark URL")?; + + let mut last_error: Option<sqlx::Error> = None; + + for _ in 0..READINESS_PING_RETRIES { + match MySqlPoolOptions::new().max_connections(1).connect_with(options.clone()).await { + Ok(pool) => { + if let Err(error) = sqlx::query("SELECT 1").execute(&pool).await { + last_error = Some(error); + } else { + pool.close().await; + return Ok(()); + } + } + Err(error) => { + last_error = Some(error); + } + } + + tokio::time::sleep(READINESS_PING_INTERVAL).await; + } + + Err(anyhow::anyhow!( + "mysql still not accepting connections after {READINESS_PING_RETRIES} attempts; last error: {error}", + error = last_error.map_or_else(|| "<none>".to_string(), |e| e.to_string()) + )) +} diff --git a/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/database/sqlite.rs b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/database/sqlite.rs new file mode 100644 index 000000000..c0dba09b6 --- /dev/null +++ b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/database/sqlite.rs @@ -0,0 +1,22 @@ +use bittorrent_tracker_core::databases::setup::initialize_database; +use torrust_tracker_configuration as configuration; + +use super::{ActiveDatabase, BenchmarkResource}; + +pub(super) async fn initialize() -> ActiveDatabase { + let sqlite_db_path = std::env::temp_dir().join(format!( + "torrust-tracker-core-benchmark-{}.sqlite3", + chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() + )); + let sqlite_db_path_as_string = sqlite_db_path.to_string_lossy().to_string(); + let mut config = configuration::Core::default(); + config.database.driver = configuration::Driver::Sqlite3; + config.database.path = sqlite_db_path_as_string; + + let database = initialize_database(&config).await; + + ActiveDatabase { + database: Some(database), + resource: Some(BenchmarkResource::Sqlite(sqlite_db_path)), + } +} diff --git a/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/mod.rs b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/mod.rs new file mode 100644 index 000000000..792a76767 --- /dev/null +++ b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/mod.rs @@ -0,0 +1,37 @@ +use std::time::Duration; + +use anyhow::Result; +use bittorrent_tracker_core::databases::driver::Driver; + +use super::types::OpsCount; + +mod database; +mod operations; +mod sampling; + +#[derive(Debug)] +pub struct RawOperationSamples { + pub name: String, + pub samples: Vec<Duration>, +} + +/// Runs all persistence operation benchmarks for one driver/version pair. +/// +/// # Errors +/// +/// Returns an error if database setup fails or any benchmarked database +/// operation fails. +pub async fn run(driver: Driver, db_version: &str, ops: OpsCount) -> Result<Vec<RawOperationSamples>> { + let active_database = database::ActiveDatabase::new(driver, db_version).await?; + let stores = active_database.database.as_ref().unwrap(); + database::reset_database(&*stores.schema_migrator).await?; + + let ops = ops.get(); + + let mut operations_samples = Vec::new(); + operations::benchmark_torrent_operations(&*stores.torrent_metrics_store, ops, &mut operations_samples).await?; + operations::benchmark_whitelist_operations(&*stores.whitelist_store, ops, &mut operations_samples).await?; + operations::benchmark_key_operations(&*stores.auth_key_store, ops, &mut operations_samples).await?; + + Ok(operations_samples) +} diff --git a/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/keys.rs b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/keys.rs new file mode 100644 index 000000000..6e548aa0a --- /dev/null +++ b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/keys.rs @@ -0,0 +1,95 @@ +use anyhow::{Context, Result}; +use bittorrent_tracker_core::authentication; +use bittorrent_tracker_core::databases::AuthKeyStore; + +use super::super::sampling::measure_operation_async; +use super::super::RawOperationSamples; + +/// Benchmarks authentication-key persistence operations. +/// +/// # Errors +/// +/// Returns an error if any setup or measured database operation fails. +pub(super) async fn benchmark_key_operations( + database: &dyn AuthKeyStore, + ops: usize, + operations: &mut Vec<RawOperationSamples>, +) -> Result<()> { + operations.push( + measure_operation_async( + "add_key_to_keys", + ops, + |_| async move { Ok(authentication::key::generate_key(None)) }, + |peer_key| async move { + let _added_rows = database.add_key_to_keys(&peer_key).await.context("add_key_to_keys failed")?; + Ok(()) + }, + ) + .await?, + ); + + let persisted_peer_key = authentication::key::generate_key(None); + let _added_rows = database + .add_key_to_keys(&persisted_peer_key) + .await + .context("failed to seed get_key_from_keys")?; + let persisted_key = persisted_peer_key.key(); + operations.push( + measure_operation_async( + "get_key_from_keys", + ops, + |_| async move { Ok(()) }, + |()| { + let persisted_key = persisted_key.clone(); + async move { + let persisted_key_result = database + .get_key_from_keys(&persisted_key) + .await + .context("get_key_from_keys failed")?; + drop(persisted_key_result); + Ok(()) + } + }, + ) + .await?, + ); + + operations.push( + measure_operation_async( + "load_keys", + ops, + |_| async move { Ok(()) }, + |()| async move { + let keys = database.load_keys().await.context("load_keys failed")?; + drop(keys); + Ok(()) + }, + ) + .await?, + ); + + operations.push( + measure_operation_async( + "remove_key_from_keys", + ops, + |_| async move { + let peer_key = authentication::key::generate_key(None); + let _added_rows = database + .add_key_to_keys(&peer_key) + .await + .context("failed to seed remove_key_from_keys")?; + Ok(peer_key.key()) + }, + |key| async move { + let _removed_rows = database + .remove_key_from_keys(&key) + .await + .context("remove_key_from_keys failed")?; + Ok(()) + }, + ) + .await?, + ); + + Ok(()) +} diff --git a/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/mod.rs b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/mod.rs new file mode 100644 index 000000000..1b169682b --- /dev/null +++ b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/mod.rs @@ -0,0 +1,32 @@ +mod keys; +mod torrent; +mod whitelist; + +use anyhow::Result; +use bittorrent_tracker_core::databases::{AuthKeyStore, TorrentMetricsStore, WhitelistStore}; + +use super::RawOperationSamples; + +pub(super) async fn benchmark_torrent_operations( + database: &dyn TorrentMetricsStore, + ops: usize, + operations: &mut Vec<RawOperationSamples>, +) -> Result<()> { + torrent::benchmark_torrent_operations(database, ops, operations).await +} + +pub(super) async fn benchmark_whitelist_operations( + database: &dyn WhitelistStore, + ops: usize, + operations: &mut Vec<RawOperationSamples>, +) -> Result<()> { + whitelist::benchmark_whitelist_operations(database, ops, operations).await +} + +pub(super) async fn benchmark_key_operations( + database: &dyn AuthKeyStore, + ops: usize, + operations: &mut Vec<RawOperationSamples>, +) -> Result<()> { + keys::benchmark_key_operations(database, ops, operations).await +} diff --git a/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/torrent.rs b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/torrent.rs new file mode 100644 index 000000000..7c71624a1 --- /dev/null +++ b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/torrent.rs @@ -0,0 +1,216 @@ +use anyhow::{Context, Result}; +use bittorrent_tracker_core::databases::TorrentMetricsStore; + +use super::super::sampling::{downloads_from_index, info_hash_from_index, measure_operation_async}; +use super::super::RawOperationSamples; + +/// Benchmarks torrent statistics persistence operations. +/// +/// This function seeds prerequisite records where needed so each measured +/// operation executes on realistic state. +/// +/// # Errors +/// +/// Returns an error if any setup or measured database operation fails. +pub(super) async fn benchmark_torrent_operations( + database: &dyn TorrentMetricsStore, + ops: usize, + operations: &mut Vec<RawOperationSamples>, +) -> Result<()> { + benchmark_save_torrent_downloads(database, ops, operations).await?; + benchmark_load_torrent_downloads(database, ops, operations).await?; + benchmark_load_all_torrents_downloads(database, ops, operations).await?; + benchmark_increase_downloads_for_torrent(database, ops, operations).await?; + benchmark_save_global_downloads(database, ops, operations).await?; + benchmark_load_global_downloads(database, ops, operations).await?; + benchmark_increase_global_downloads(database, ops, operations).await?; + + Ok(()) +} + +async fn benchmark_save_torrent_downloads( + database: &dyn TorrentMetricsStore, + ops: usize, + operations: &mut Vec<RawOperationSamples>, +) -> Result<()> { + operations.push( + measure_operation_async( + "save_torrent_downloads", + ops, + |index| async move { Ok((info_hash_from_index(index + 1)?, downloads_from_index(index)?)) }, + |(info_hash, downloads)| async move { + database + .save_torrent_downloads(&info_hash, downloads) + .await + .context("save_torrent_downloads failed") + }, + ) + .await?, + ); + + Ok(()) +} + +async fn benchmark_load_torrent_downloads( + database: &dyn TorrentMetricsStore, + ops: usize, + operations: &mut Vec<RawOperationSamples>, +) -> Result<()> { + let load_torrent_info_hash = info_hash_from_index(10_000)?; + database + .save_torrent_downloads(&load_torrent_info_hash, 123) + .await + .context("failed to seed load_torrent_downloads")?; + + operations.push( + measure_operation_async( + "load_torrent_downloads", + ops, + |_| async move { Ok(()) }, + |()| async move { + let _downloads_result = database + .load_torrent_downloads(&load_torrent_info_hash) + .await + .context("load_torrent_downloads failed")?; + Ok(()) + }, + ) + .await?, + ); + + Ok(()) +} + +async fn benchmark_load_all_torrents_downloads( + database: &dyn TorrentMetricsStore, + ops: usize, + operations: &mut Vec<RawOperationSamples>, +) -> Result<()> { + operations.push( + measure_operation_async( + "load_all_torrents_downloads", + ops, + |_| async move { Ok(()) }, + |()| async move { + let all_downloads = database + .load_all_torrents_downloads() + .await + .context("load_all_torrents_downloads failed")?; + drop(all_downloads); + Ok(()) + }, + ) + .await?, + ); + + Ok(()) +} + +async fn benchmark_increase_downloads_for_torrent( + database: &dyn TorrentMetricsStore, + ops: usize, + operations: &mut Vec<RawOperationSamples>, +) -> Result<()> { + let increasing_downloads_info_hash = info_hash_from_index(20_000)?; + database + .save_torrent_downloads(&increasing_downloads_info_hash, 0) + .await + .context("failed to seed increase_downloads_for_torrent")?; + + operations.push( + measure_operation_async( + "increase_downloads_for_torrent", + ops, + |_| async move { Ok(()) }, + |()| async move { + database + .increase_downloads_for_torrent(&increasing_downloads_info_hash) + .await + .context("increase_downloads_for_torrent failed") + }, + ) + .await?, + ); + + Ok(()) +} + +async fn benchmark_save_global_downloads( + database: &dyn TorrentMetricsStore, + ops: usize, + operations: &mut Vec<RawOperationSamples>, +) -> Result<()> { + operations.push( + measure_operation_async( + "save_global_downloads", + ops, + |index| async move { downloads_from_index(index) }, + |downloads| async move { + database + .save_global_downloads(downloads) + .await + .context("save_global_downloads failed") + }, + ) + .await?, + ); + + Ok(()) +} + +async fn benchmark_load_global_downloads( + database: &dyn TorrentMetricsStore, + ops: usize, + operations: &mut Vec<RawOperationSamples>, +) -> Result<()> { + database + .save_global_downloads(0) + .await + .context("failed to seed load_global_downloads")?; + + operations.push( + measure_operation_async( + "load_global_downloads", + ops, + |_| async move { Ok(()) }, + |()| async move { + let _downloads_result = database + .load_global_downloads() + .await + .context("load_global_downloads failed")?; + Ok(()) + }, + ) + .await?, + ); + + Ok(()) +} + +async fn benchmark_increase_global_downloads( + database: &dyn TorrentMetricsStore, + ops: usize, + operations: &mut Vec<RawOperationSamples>, +) -> Result<()> { + database + .save_global_downloads(0) + .await + .context("failed to seed increase_global_downloads")?; + + operations.push( + measure_operation_async( + "increase_global_downloads", + ops, + |_| async move { Ok(()) }, + |()| async move { + database + .increase_global_downloads() + .await + .context("increase_global_downloads failed") + }, + ) + .await?, + ); + + Ok(()) +} diff --git a/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/whitelist.rs b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/whitelist.rs new file mode 100644 index 000000000..bd9b780be --- /dev/null +++ b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/operations/whitelist.rs @@ -0,0 +1,92 @@ +use anyhow::{Context, Result}; +use bittorrent_tracker_core::databases::WhitelistStore; + +use super::super::sampling::{info_hash_from_index, measure_operation_async}; +use super::super::RawOperationSamples; + +/// Benchmarks whitelist-related persistence operations. +/// +/// # Errors +/// +/// Returns an error if any setup or measured database operation fails. +pub(super) async fn benchmark_whitelist_operations( + database: &dyn WhitelistStore, + ops: usize, + operations: &mut Vec<RawOperationSamples>, +) -> Result<()> { + operations.push( + measure_operation_async( + "add_info_hash_to_whitelist", + ops, + |index| async move { info_hash_from_index(30_000 + index) }, + |info_hash| async move { + let _added_rows = database + .add_info_hash_to_whitelist(info_hash) + .await + .context("add_info_hash_to_whitelist failed")?; + Ok(()) + }, + ) + .await?, + ); + + let whitelisted_info_hash = info_hash_from_index(40_000)?; + let _added_rows = database + .add_info_hash_to_whitelist(whitelisted_info_hash) + .await + .context("failed to seed get_info_hash_from_whitelist")?; + operations.push( + measure_operation_async( + "get_info_hash_from_whitelist", + ops, + |_| async move { Ok(()) }, + |()| async move { + let _info_hash_result = database + .get_info_hash_from_whitelist(whitelisted_info_hash) + .await + .context("get_info_hash_from_whitelist failed")?; + Ok(()) + }, + ) + .await?, + ); + + operations.push( + measure_operation_async( + "load_whitelist", + ops, + |_| async move { Ok(()) }, + |()| async move { + let whitelist = database.load_whitelist().await.context("load_whitelist failed")?; + drop(whitelist); + Ok(()) + }, + ) + .await?, + ); + + operations.push( + measure_operation_async( + "remove_info_hash_from_whitelist", + ops, + |index| async move { + let info_hash = info_hash_from_index(50_000 + index)?; + let _added_rows = database + .add_info_hash_to_whitelist(info_hash) + .await + .context("failed to seed remove_info_hash_from_whitelist")?; + Ok(info_hash) + }, + |info_hash| async move { + let _removed_rows = database + .remove_info_hash_from_whitelist(info_hash) + .await + .context("remove_info_hash_from_whitelist failed")?; + Ok(()) + }, + ) + .await?, + ); + + Ok(()) +} diff --git a/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/sampling.rs b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/sampling.rs new file mode 100644 index 000000000..a0daf9b00 --- /dev/null +++ b/packages/tracker-core/src/bin/persistence_benchmark/driver_bench/sampling.rs @@ -0,0 +1,57 @@ +use std::str::FromStr; +use std::time::Instant; + +use anyhow::{anyhow, Context, Result}; +use bittorrent_primitives::info_hash::InfoHash; + +use super::RawOperationSamples; + +/// Async variant of operation measurement, for database operations requiring +/// `.await`. +/// +/// # Errors +/// +/// Returns an error if setup or any async operation invocation fails. +pub(super) async fn measure_operation_async<S, SetupFut, F, T, OpFut>( + name: impl Into<String>, + ops: usize, + mut setup: S, + mut operation: F, +) -> Result<RawOperationSamples> +where + S: FnMut(usize) -> SetupFut, + SetupFut: std::future::Future<Output = Result<T>>, + F: FnMut(T) -> OpFut, + OpFut: std::future::Future<Output = Result<()>>, +{ + let name = name.into(); + let mut samples = Vec::with_capacity(ops); + + for index in 0..ops { + let prepared = setup(index).await?; + let start = Instant::now(); + operation(prepared).await?; + samples.push(start.elapsed()); + } + + Ok(RawOperationSamples { name, samples }) +} + +/// Converts a loop index into a valid download-count value. +/// +/// # Errors +/// +/// Returns an error if the index does not fit in `u32`. +pub(super) fn downloads_from_index(index: usize) -> Result<u32> { + u32::try_from(index).context("failed to convert operation index to download count") +} + +/// Builds a deterministic 40-hex-char `InfoHash` from an index. +/// +/// # Errors +/// +/// Returns an error if the generated value cannot be parsed as an `InfoHash`. +pub(super) fn info_hash_from_index(index: usize) -> Result<InfoHash> { + let hex = format!("{index:040x}"); + InfoHash::from_str(&hex).map_err(|error| anyhow!("failed to generate benchmark info hash: {error:?}")) +} diff --git a/packages/tracker-core/src/bin/persistence_benchmark/helpers.rs b/packages/tracker-core/src/bin/persistence_benchmark/helpers.rs new file mode 100644 index 000000000..d6474e118 --- /dev/null +++ b/packages/tracker-core/src/bin/persistence_benchmark/helpers.rs @@ -0,0 +1,12 @@ +use std::process::Command; + +#[must_use] +pub fn git_revision() -> String { + match Command::new("git").args(["rev-parse", "HEAD"]).output() { + Ok(output) if output.status.success() => { + let revision = String::from_utf8_lossy(&output.stdout); + revision.trim().to_string() + } + _ => "unknown".to_string(), + } +} diff --git a/packages/tracker-core/src/bin/persistence_benchmark/metrics.rs b/packages/tracker-core/src/bin/persistence_benchmark/metrics.rs new file mode 100644 index 000000000..89e2d1049 --- /dev/null +++ b/packages/tracker-core/src/bin/persistence_benchmark/metrics.rs @@ -0,0 +1,101 @@ +use std::time::Duration; + +use anyhow::{anyhow, Result}; + +use super::driver_bench::RawOperationSamples; + +#[derive(Debug, Clone)] +pub struct OperationStats { + pub name: String, + pub count: usize, + pub best: Duration, + pub median: Duration, + pub worst: Duration, +} + +/// Computes benchmark statistics for each operation. +/// +/// # Errors +/// +/// Returns an error if an operation has no samples. +pub fn compute(raw_operations: Vec<RawOperationSamples>) -> Result<Vec<OperationStats>> { + let mut operation_stats = Vec::with_capacity(raw_operations.len()); + + for raw_operation in raw_operations { + operation_stats.push(compute_operation(raw_operation)?); + } + + Ok(operation_stats) +} + +/// Computes summary statistics for one benchmark operation. +/// +/// Samples are sorted so `best`/`median`/`worst` are deterministic and +/// independent from insertion order. +/// +/// # Errors +/// +/// Returns an error when no samples were collected for the operation. +fn compute_operation(raw_operation: RawOperationSamples) -> Result<OperationStats> { + if raw_operation.samples.is_empty() { + return Err(anyhow!("operation '{}' has no samples", raw_operation.name)); + } + + let mut sorted_samples = raw_operation.samples; + sorted_samples.sort_unstable(); + + let count = sorted_samples.len(); + let best = sorted_samples[0]; + let median = sorted_samples[count / 2]; + let worst = sorted_samples[count - 1]; + + Ok(OperationStats { + name: raw_operation.name, + count, + best, + median, + worst, + }) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::compute; + use crate::persistence_benchmark::driver_bench::RawOperationSamples; + + #[test] + fn it_should_compute_sorted_best_median_and_worst_for_each_operation() { + let raw_operations = vec![RawOperationSamples { + name: "save_torrent_downloads".to_string(), + samples: vec![ + Duration::from_micros(50), + Duration::from_micros(20), + Duration::from_micros(30), + Duration::from_micros(10), + ], + }]; + + let stats = compute(raw_operations).expect("metrics should compute"); + + assert_eq!(stats.len(), 1); + assert_eq!(stats[0].name, "save_torrent_downloads"); + assert_eq!(stats[0].count, 4); + assert_eq!(stats[0].best, Duration::from_micros(10)); + assert_eq!(stats[0].median, Duration::from_micros(30)); + assert_eq!(stats[0].worst, Duration::from_micros(50)); + } + + #[test] + fn it_should_fail_when_operation_has_no_samples() { + let raw_operations = vec![RawOperationSamples { + name: "load_keys".to_string(), + samples: Vec::new(), + }]; + + let error = compute(raw_operations).expect_err("empty samples should fail"); + + assert_eq!(error.to_string(), "operation 'load_keys' has no samples"); + } +} diff --git a/packages/tracker-core/src/bin/persistence_benchmark/mod.rs b/packages/tracker-core/src/bin/persistence_benchmark/mod.rs new file mode 100644 index 000000000..57f565021 --- /dev/null +++ b/packages/tracker-core/src/bin/persistence_benchmark/mod.rs @@ -0,0 +1,10 @@ +//! Binary-private support code for the persistence benchmark runner. + +pub mod driver_bench; +pub mod helpers; +pub mod metrics; +pub mod operations; +pub mod report; +pub mod reporting; +pub mod runner; +pub mod types; diff --git a/packages/tracker-core/src/bin/persistence_benchmark/operations.rs b/packages/tracker-core/src/bin/persistence_benchmark/operations.rs new file mode 100644 index 000000000..c75861ad4 --- /dev/null +++ b/packages/tracker-core/src/bin/persistence_benchmark/operations.rs @@ -0,0 +1,20 @@ +use anyhow::Result; +use bittorrent_tracker_core::databases::driver::Driver; + +use super::types::{DbVersion, OpsCount}; +use super::{driver_bench, metrics}; + +/// Collects benchmark operation samples and computes aggregate statistics. +/// +/// # Errors +/// +/// Returns an error if operation sampling or metrics computation fails. +pub async fn collect_operation_stats( + driver: &Driver, + db_version: &DbVersion, + ops: OpsCount, +) -> Result<Vec<metrics::OperationStats>> { + let raw_operations = driver_bench::run(driver.clone(), db_version.as_str(), ops).await?; + + metrics::compute(raw_operations) +} diff --git a/packages/tracker-core/src/bin/persistence_benchmark/report.rs b/packages/tracker-core/src/bin/persistence_benchmark/report.rs new file mode 100644 index 000000000..9ea74d431 --- /dev/null +++ b/packages/tracker-core/src/bin/persistence_benchmark/report.rs @@ -0,0 +1,166 @@ +use anyhow::{Context, Result}; +use chrono::Utc; +use serde::Serialize; + +use super::helpers; +use super::metrics::OperationStats; + +#[derive(Debug, Serialize)] +pub struct BenchReport { + pub meta: ReportMeta, + pub operations: Vec<OperationReport>, +} + +#[derive(Debug, Serialize)] +pub struct ReportMeta { + pub git_revision: String, + pub driver: String, + pub db_version: String, + pub ops: usize, + pub timestamp: String, + pub timings_ms: ReportTimings, +} + +#[derive(Debug, Serialize)] +pub struct ReportTimings { + pub benchmark: u64, + pub report_build: u64, + pub total: u64, +} + +#[derive(Debug, Serialize)] +pub struct OperationReport { + pub name: String, + pub count: usize, + pub best_us: u64, + pub median_us: u64, + pub worst_us: u64, +} + +impl BenchReport { + /// Builds a serializable benchmark report from aggregated operation stats. + /// + /// Durations are converted to microseconds to keep report values compact, + /// language-agnostic, and easy to compare across runs. + #[must_use] + pub fn new(meta: ReportMeta, operation_stats: Vec<OperationStats>) -> Self { + let operations = operation_stats + .into_iter() + .map(|operation_stat| OperationReport { + name: operation_stat.name.clone(), + count: operation_stat.count, + best_us: duration_to_micros(operation_stat.best), + median_us: duration_to_micros(operation_stat.median), + worst_us: duration_to_micros(operation_stat.worst), + }) + .collect(); + + Self { meta, operations } + } +} + +impl ReportMeta { + /// Captures report metadata for one benchmark execution. + /// + /// The timestamp is recorded in RFC 3339 format and the git revision is + /// resolved from the current repository state. + #[must_use] + pub fn from_run_context(driver: &str, db_version: &str, ops: usize, timings_ms: ReportTimings) -> Self { + let git_revision = helpers::git_revision(); + + Self { + git_revision, + driver: driver.to_string(), + db_version: db_version.to_string(), + ops, + timestamp: Utc::now().to_rfc3339(), + timings_ms, + } + } +} + +/// Serializes the benchmark report as pretty-printed JSON. +/// +/// # Errors +/// +/// Returns an error if serialization fails. +pub fn to_json_pretty(report: &BenchReport) -> Result<String> { + serde_json::to_string_pretty(report).context("failed to serialize benchmark report") +} + +/// Converts a duration into microseconds for JSON serialization. +/// +/// Saturates to `u64::MAX` if conversion overflows. +fn duration_to_micros(duration: std::time::Duration) -> u64 { + u64::try_from(duration.as_micros()).unwrap_or(u64::MAX) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::{to_json_pretty, BenchReport, ReportMeta, ReportTimings}; + use crate::persistence_benchmark::metrics::OperationStats; + + #[test] + fn it_should_convert_operation_durations_to_microseconds_in_report() { + let meta = ReportMeta { + git_revision: "test-revision".to_string(), + driver: "sqlite3".to_string(), + db_version: "-".to_string(), + ops: 2, + timestamp: "2026-01-01T00:00:00+00:00".to_string(), + timings_ms: ReportTimings { + benchmark: 10, + report_build: 1, + total: 11, + }, + }; + let operation_stats = vec![OperationStats { + name: "save_global_downloads".to_string(), + count: 2, + best: Duration::from_micros(7), + median: Duration::from_micros(11), + worst: Duration::from_micros(19), + }]; + + let report = BenchReport::new(meta, operation_stats); + + assert_eq!(report.operations.len(), 1); + assert_eq!(report.operations[0].name, "save_global_downloads"); + assert_eq!(report.operations[0].best_us, 7); + assert_eq!(report.operations[0].median_us, 11); + assert_eq!(report.operations[0].worst_us, 19); + } + + #[test] + fn it_should_serialize_report_as_valid_pretty_json() { + let meta = ReportMeta { + git_revision: "test-revision".to_string(), + driver: "sqlite3".to_string(), + db_version: "-".to_string(), + ops: 1, + timestamp: "2026-01-01T00:00:00+00:00".to_string(), + timings_ms: ReportTimings { + benchmark: 5, + report_build: 1, + total: 6, + }, + }; + let operation_stats = vec![OperationStats { + name: "load_whitelist".to_string(), + count: 1, + best: Duration::from_micros(3), + median: Duration::from_micros(3), + worst: Duration::from_micros(3), + }]; + let report = BenchReport::new(meta, operation_stats); + + let json = to_json_pretty(&report).expect("report should serialize"); + let parsed: serde_json::Value = serde_json::from_str(&json).expect("json should parse"); + + assert_eq!(parsed["meta"]["driver"], "sqlite3"); + assert_eq!(parsed["meta"]["timings_ms"]["total"], 6); + assert_eq!(parsed["operations"][0]["name"], "load_whitelist"); + } +} diff --git a/packages/tracker-core/src/bin/persistence_benchmark/reporting.rs b/packages/tracker-core/src/bin/persistence_benchmark/reporting.rs new file mode 100644 index 000000000..10ea7ddb1 --- /dev/null +++ b/packages/tracker-core/src/bin/persistence_benchmark/reporting.rs @@ -0,0 +1,84 @@ +use bittorrent_tracker_core::databases::driver::Driver; + +use super::types::DbVersion; +use super::{metrics, report}; + +/// Builds the final JSON-serializable report from run context and metrics. +/// +/// For `sqlite3` runs, `db_version` is normalized to `-` because there is no +/// image tag associated with the local file-backed database. +#[must_use] +pub fn build_report( + driver: &Driver, + db_version: &DbVersion, + ops: usize, + timings_ms: report::ReportTimings, + operation_stats: Vec<metrics::OperationStats>, +) -> report::BenchReport { + let normalized_db_version = match driver { + Driver::Sqlite3 => "-".to_string(), + Driver::MySQL => db_version.to_string(), + }; + + let meta = report::ReportMeta::from_run_context(driver.as_str(), &normalized_db_version, ops, timings_ms); + + report::BenchReport::new(meta, operation_stats) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + use std::time::Duration; + + use bittorrent_tracker_core::databases::driver::Driver; + + use super::build_report; + use crate::persistence_benchmark::metrics::OperationStats; + use crate::persistence_benchmark::report::ReportTimings; + use crate::persistence_benchmark::types::DbVersion; + + #[test] + fn it_should_normalize_db_version_to_dash_for_sqlite_reports() { + let db_version = DbVersion::from_str("8.4").expect("db version should parse"); + let timings_ms = ReportTimings { + benchmark: 7, + report_build: 1, + total: 8, + }; + let operation_stats = vec![OperationStats { + name: "save_torrent_downloads".to_string(), + count: 1, + best: Duration::from_micros(1), + median: Duration::from_micros(1), + worst: Duration::from_micros(1), + }]; + + let report = build_report(&Driver::Sqlite3, &db_version, 1, timings_ms, operation_stats); + + assert_eq!(report.meta.driver, "sqlite3"); + assert_eq!(report.meta.db_version, "-"); + } + + #[test] + fn it_should_keep_mysql_db_version_in_report_metadata() { + let db_version = DbVersion::from_str("8.4").expect("db version should parse"); + let timings_ms = ReportTimings { + benchmark: 9, + report_build: 1, + total: 10, + }; + let operation_stats = vec![OperationStats { + name: "load_keys".to_string(), + count: 2, + best: Duration::from_micros(2), + median: Duration::from_micros(3), + worst: Duration::from_micros(4), + }]; + + let report = build_report(&Driver::MySQL, &db_version, 2, timings_ms, operation_stats); + + assert_eq!(report.meta.driver, "mysql"); + assert_eq!(report.meta.db_version, "8.4"); + assert_eq!(report.meta.ops, 2); + } +} diff --git a/packages/tracker-core/src/bin/persistence_benchmark/runner.rs b/packages/tracker-core/src/bin/persistence_benchmark/runner.rs new file mode 100644 index 000000000..81d871a6c --- /dev/null +++ b/packages/tracker-core/src/bin/persistence_benchmark/runner.rs @@ -0,0 +1,71 @@ +use std::time::Instant; + +use anyhow::Result; +use bittorrent_tracker_core::databases::driver::Driver; +use clap::Parser; + +use super::types::{DbVersion, OpsCount}; +use super::{operations, report, reporting}; + +#[derive(Parser, Debug)] +#[clap(author, version, about, long_about = None)] +struct Args { + /// Database driver benchmarked in this invocation. + #[arg(long)] + driver: Driver, + + /// Database image tag. Used only for `MySQL`. + #[arg(long, default_value = "8.4")] + db_version: DbVersion, + + /// Number of samples per operation. + #[arg(long, default_value = "100")] + ops: OpsCount, +} + +/// Executes the persistence benchmark runner CLI. +/// +/// # Errors +/// +/// Returns an error if argument validation fails, the benchmark execution +/// fails, or report serialization fails. +pub async fn run() -> Result<()> { + let Args { driver, db_version, ops } = Args::parse(); + + let total_started_at = Instant::now(); + + let benchmark_started_at = Instant::now(); + let operation_stats = operations::collect_operation_stats(&driver, &db_version, ops).await?; + let benchmark_duration = benchmark_started_at.elapsed(); + + let report_build_started_at = Instant::now(); + let mut benchmark_report = reporting::build_report( + &driver, + &db_version, + ops.get(), + report::ReportTimings { + benchmark: 0, + report_build: 0, + total: 0, + }, + operation_stats, + ); + let report_build_duration = report_build_started_at.elapsed(); + + let total_duration = total_started_at.elapsed(); + benchmark_report.meta.timings_ms = report::ReportTimings { + benchmark: duration_to_millis_u64(benchmark_duration), + report_build: duration_to_millis_u64(report_build_duration), + total: duration_to_millis_u64(total_duration), + }; + + let json = report::to_json_pretty(&benchmark_report)?; + + println!("{json}"); + + Ok(()) +} + +fn duration_to_millis_u64(duration: std::time::Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} diff --git a/packages/tracker-core/src/bin/persistence_benchmark/types.rs b/packages/tracker-core/src/bin/persistence_benchmark/types.rs new file mode 100644 index 000000000..15a3b36cf --- /dev/null +++ b/packages/tracker-core/src/bin/persistence_benchmark/types.rs @@ -0,0 +1,114 @@ +use std::num::NonZeroUsize; +use std::str::FromStr; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OpsCount(NonZeroUsize); + +impl OpsCount { + #[must_use] + pub fn get(self) -> usize { + self.0.get() + } +} + +impl FromStr for OpsCount { + type Err = String; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + let parsed = value + .parse::<usize>() + .map_err(|_| "ops must be a positive integer".to_string())?; + + let count = NonZeroUsize::new(parsed).ok_or_else(|| "ops must be greater than zero".to_string())?; + + Ok(Self(count)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DbVersion(String); + +impl DbVersion { + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl FromStr for DbVersion { + type Err = String; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + if value.is_empty() { + return Err("db-version must not be empty".to_string()); + } + + let is_valid = value + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_')); + + if !is_valid { + return Err("db-version contains invalid characters; allowed: letters, digits, '.', '-', '_'".to_string()); + } + + Ok(Self(value.to_string())) + } +} + +impl std::fmt::Display for DbVersion { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::{DbVersion, OpsCount}; + + #[test] + fn it_should_parse_ops_count_when_value_is_positive() { + let ops = OpsCount::from_str("100").expect("ops count should parse"); + + assert_eq!(ops.get(), 100); + } + + #[test] + fn it_should_reject_ops_count_when_value_is_zero() { + let error = OpsCount::from_str("0").expect_err("zero ops count should fail"); + + assert_eq!(error, "ops must be greater than zero"); + } + + #[test] + fn it_should_reject_ops_count_when_value_is_not_numeric() { + let error = OpsCount::from_str("abc").expect_err("non-numeric ops count should fail"); + + assert_eq!(error, "ops must be a positive integer"); + } + + #[test] + fn it_should_parse_db_version_when_value_has_allowed_characters() { + let db_version = DbVersion::from_str("8.4-rc1").expect("db version should parse"); + + assert_eq!(db_version.as_str(), "8.4-rc1"); + } + + #[test] + fn it_should_reject_db_version_when_value_is_empty() { + let error = DbVersion::from_str("").expect_err("empty db version should fail"); + + assert_eq!(error, "db-version must not be empty"); + } + + #[test] + fn it_should_reject_db_version_when_value_has_invalid_characters() { + let error = DbVersion::from_str("8.4/rc1").expect_err("db version with slash should fail"); + + assert_eq!( + error, + "db-version contains invalid characters; allowed: letters, digits, '.', '-', '_'" + ); + } +} diff --git a/packages/tracker-core/src/bin/persistence_benchmark_runner.rs b/packages/tracker-core/src/bin/persistence_benchmark_runner.rs new file mode 100644 index 000000000..357443a23 --- /dev/null +++ b/packages/tracker-core/src/bin/persistence_benchmark_runner.rs @@ -0,0 +1,76 @@ +//! Program to run persistence benchmarks directly against database drivers. +//! +//! This binary is a developer tool for measuring the persistence-layer methods +//! implemented by the [`Database`](bittorrent_tracker_core::databases::Database) +//! trait. It benchmarks one driver per invocation and prints a JSON report to +//! standard output with per-operation timing statistics. +//! +//! How it works: +//! +//! - Parses CLI arguments for the target driver, database version, and sample +//! count (`--ops`, default: `100`). +//! - Instantiates a real persistence backend: +//! - `sqlite3` uses a temporary `SQLite` database file. +//! - `mysql` starts a testcontainers `mysql` container with the requested +//! image tag. +//! - Creates a clean schema and seeds the minimum data needed for each measured +//! operation. +//! - Repeats every persistence operation `--ops` times, measuring each call +//! with `std::time::Instant`. +//! - Sorts the collected durations and prints `count`, `best`, `median`, and +//! `worst` values as JSON. +//! - Emits only JSON on standard output (no status line and no file output +//! argument). +//! +//! Typical usage: +//! +//! ```text +//! cargo run -p bittorrent-tracker-core --bin persistence_benchmark_runner -- \ +//! --driver sqlite3 +//! +//! cargo run -p bittorrent-tracker-core --bin persistence_benchmark_runner -- \ +//! --driver mysql \ +//! --db-version 8.4 +//! ``` +//! +//! Store output in a file with shell redirection: +//! +//! ```text +//! cargo run -p bittorrent-tracker-core --bin persistence_benchmark_runner -- \ +//! --driver sqlite3 \ +//! > .benchmarks/bench-results-sqlite3.json +//! ``` +//! +//! Sample report: +//! +//! ```json +//! { +//! "meta": { +//! "git_revision": "16c9c8a4695d336a4531204913390a47b20d9468", +//! "driver": "sqlite3", +//! "db_version": "-", +//! "ops": 100, +//! "timestamp": "2026-04-28T16:23:24.084307218+00:00", +//! "timings_ms": { +//! "benchmark": 18, +//! "report_build": 0, +//! "total": 19 +//! } +//! }, +//! "operations": [ +//! { +//! "name": "save_torrent_downloads", +//! "count": 100, +//! "best_us": 66, +//! "median_us": 70, +//! "worst_us": 79 +//! } +//! ] +//! } +//! ``` +mod persistence_benchmark; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + persistence_benchmark::runner::run().await +} diff --git a/packages/tracker-core/src/container.rs b/packages/tracker-core/src/container.rs index 93b8efd7e..e52547c28 100644 --- a/packages/tracker-core/src/container.rs +++ b/packages/tracker-core/src/container.rs @@ -8,8 +8,7 @@ use crate::authentication::handler::KeysHandler; use crate::authentication::key::repository::in_memory::InMemoryKeyRepository; use crate::authentication::key::repository::persisted::DatabaseKeyRepository; use crate::authentication::service::AuthenticationService; -use crate::databases::setup::initialize_database; -use crate::databases::Database; +use crate::databases::setup::{initialize_database, DatabaseStores}; use crate::scrape_handler::ScrapeHandler; use crate::statistics::persisted::downloads::DatabaseDownloadsMetricRepository; use crate::torrent::manager::TorrentsManager; @@ -22,7 +21,7 @@ use crate::{statistics, whitelist}; pub struct TrackerCoreContainer { pub core_config: Arc<Core>, - pub database: Arc<Box<dyn Database>>, + pub database_stores: DatabaseStores, pub announce_handler: Arc<AnnounceHandler>, pub scrape_handler: Arc<ScrapeHandler>, pub keys_handler: Arc<KeysHandler>, @@ -38,15 +37,15 @@ pub struct TrackerCoreContainer { impl TrackerCoreContainer { #[must_use] - pub fn initialize_from( + pub async fn initialize_from( core_config: &Arc<Core>, swarm_coordination_registry_container: &Arc<SwarmCoordinationRegistryContainer>, ) -> Self { - let database = initialize_database(core_config); + let db = initialize_database(core_config).await; let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); let whitelist_authorization = Arc::new(WhitelistAuthorization::new(core_config, &in_memory_whitelist.clone())); - let whitelist_manager = initialize_whitelist_manager(database.clone(), in_memory_whitelist.clone()); - let db_key_repository = Arc::new(DatabaseKeyRepository::new(&database)); + let whitelist_manager = initialize_whitelist_manager(db.whitelist_store.clone(), in_memory_whitelist.clone()); + let db_key_repository = Arc::new(DatabaseKeyRepository::new(&db.auth_key_store)); let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); let authentication_service = Arc::new(AuthenticationService::new(core_config, &in_memory_key_repository)); let keys_handler = Arc::new(KeysHandler::new( @@ -56,7 +55,7 @@ impl TrackerCoreContainer { let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::new( swarm_coordination_registry_container.swarms.clone(), )); - let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&db.torrent_metrics_store)); let torrents_manager = Arc::new(TorrentsManager::new( core_config, @@ -77,7 +76,7 @@ impl TrackerCoreContainer { Self { core_config: core_config.clone(), - database, + database_stores: db, announce_handler, scrape_handler, keys_handler, diff --git a/packages/tracker-core/src/databases/driver/mod.rs b/packages/tracker-core/src/databases/driver/mod.rs index 6c849bb70..147275f30 100644 --- a/packages/tracker-core/src/databases/driver/mod.rs +++ b/packages/tracker-core/src/databases/driver/mod.rs @@ -1,13 +1,12 @@ //! Database driver factory. -use mysql::Mysql; +use std::str::FromStr; + use serde::{Deserialize, Serialize}; -use sqlite::Sqlite; use super::error::Error; -use super::Database; /// Metric name in DB for the total number of downloads across all torrents. -const TORRENTS_DOWNLOADS_TOTAL: &str = "torrents_downloads_total"; +pub(super) const TORRENTS_DOWNLOADS_TOTAL: &str = "torrents_downloads_total"; /// The database management system used by the tracker. /// @@ -25,121 +24,67 @@ pub enum Driver { MySQL, } -/// It builds a new database driver. -/// -/// Example for `SQLite3`: -/// -/// ```text -/// use bittorrent_tracker_core::databases; -/// use bittorrent_tracker_core::databases::driver::Driver; -/// -/// let db_driver = Driver::Sqlite3; -/// let db_path = "./storage/tracker/lib/database/sqlite3.db".to_string(); -/// let database = databases::driver::build(&db_driver, &db_path); -/// ``` -/// -/// Example for `MySQL`: -/// -/// ```text -/// use bittorrent_tracker_core::databases; -/// use bittorrent_tracker_core::databases::driver::Driver; -/// -/// let db_driver = Driver::MySQL; -/// let db_path = "mysql://db_user:db_user_secret_password@mysql:3306/torrust_tracker".to_string(); -/// let database = databases::driver::build(&db_driver, &db_path); -/// ``` -/// -/// Refer to the [configuration documentation](https://docs.rs/torrust-tracker-configuration) -/// for more information about the database configuration. -/// -/// > **WARNING**: The driver instantiation runs database migrations. -/// -/// # Errors -/// -/// This function will return an error if unable to connect to the database. -/// -/// # Panics -/// -/// This function will panic if unable to create database tables. -pub mod mysql; -pub mod sqlite; - -/// It builds a new database driver. -/// -/// # Panics -/// -/// Will panic if unable to create database tables. -/// -/// # Errors -/// -/// Will return `Error` if unable to build the driver. -pub(crate) fn build(driver: &Driver, db_path: &str) -> Result<Box<dyn Database>, Error> { - let database: Box<dyn Database> = match driver { - Driver::Sqlite3 => Box::new(Sqlite::new(db_path)?), - Driver::MySQL => Box::new(Mysql::new(db_path)?), - }; +impl Driver { + /// Returns the stable lowercase identifier used by CLI and reports. + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::Sqlite3 => "sqlite3", + Self::MySQL => "mysql", + } + } +} - database.create_database_tables().expect("Could not create database tables."); +impl FromStr for Driver { + type Err = String; - Ok(database) + fn from_str(value: &str) -> Result<Self, Self::Err> { + match value { + "sqlite3" => Ok(Self::Sqlite3), + "mysql" => Ok(Self::MySQL), + _ => Err("driver must be one of: sqlite3, mysql".to_string()), + } + } } +pub mod mysql; +pub mod sqlite; + #[cfg(test)] pub(crate) mod tests { use std::sync::Arc; use std::time::Duration; - use crate::databases::Database; + use crate::databases::traits::Database; pub async fn run_tests(driver: &Arc<Box<dyn Database>>) { - // Since the interface is very simple and there are no conflicts between - // tests, we share the same database. If we want to isolate the tests in - // the future, we can create a new database for each test. - database_setup(driver).await; - // Persistent torrents (stats) - - // Torrent metrics - handling_torrent_persistence::it_should_save_and_load_persistent_torrents(driver); - handling_torrent_persistence::it_should_load_all_persistent_torrents(driver); - handling_torrent_persistence::it_should_increase_the_number_of_downloads_for_a_given_torrent(driver); - // Aggregate metrics for all torrents - handling_torrent_persistence::it_should_save_and_load_the_global_number_of_downloads(driver); - handling_torrent_persistence::it_should_load_the_global_number_of_downloads(driver); - handling_torrent_persistence::it_should_increase_the_global_number_of_downloads(driver); - - // Authentication keys (for private trackers) - - handling_authentication_keys::it_should_load_the_keys(driver); - - // Permanent keys - handling_authentication_keys::it_should_save_and_load_permanent_authentication_keys(driver); - handling_authentication_keys::it_should_remove_a_permanent_authentication_key(driver); - - // Expiring keys - handling_authentication_keys::it_should_save_and_load_expiring_authentication_keys(driver); - handling_authentication_keys::it_should_remove_an_expiring_authentication_key(driver); - - // Whitelist (for listed trackers) - - handling_the_whitelist::it_should_load_the_whitelist(driver); - handling_the_whitelist::it_should_add_and_get_infohashes(driver); - handling_the_whitelist::it_should_remove_an_infohash_from_the_whitelist(driver); - handling_the_whitelist::it_should_fail_trying_to_add_the_same_infohash_twice(driver); + handling_torrent_persistence::it_should_save_and_load_persistent_torrents(driver).await; + handling_torrent_persistence::it_should_load_all_persistent_torrents(driver).await; + handling_torrent_persistence::it_should_increase_the_number_of_downloads_for_a_given_torrent(driver).await; + handling_torrent_persistence::it_should_save_and_load_the_global_number_of_downloads(driver).await; + handling_torrent_persistence::it_should_load_the_global_number_of_downloads(driver).await; + handling_torrent_persistence::it_should_increase_the_global_number_of_downloads(driver).await; + + handling_authentication_keys::it_should_load_the_keys(driver).await; + handling_authentication_keys::it_should_save_and_load_permanent_authentication_keys(driver).await; + handling_authentication_keys::it_should_remove_a_permanent_authentication_key(driver).await; + handling_authentication_keys::it_should_save_and_load_expiring_authentication_keys(driver).await; + handling_authentication_keys::it_should_remove_an_expiring_authentication_key(driver).await; + + handling_the_whitelist::it_should_load_the_whitelist(driver).await; + handling_the_whitelist::it_should_add_and_get_infohashes(driver).await; + handling_the_whitelist::it_should_remove_an_infohash_from_the_whitelist(driver).await; + handling_the_whitelist::it_should_fail_trying_to_add_the_same_infohash_twice(driver).await; } - /// It initializes the database schema. - /// - /// Since the drop SQL queries don't check if the tables already exist, - /// we have to create them first, and then drop them. - /// - /// The method to drop tables does not use "DROP TABLE IF EXISTS". We can - /// change this function when we update the `Database::drop_database_tables` - /// method to use "DROP TABLE IF EXISTS". async fn database_setup(driver: &Arc<Box<dyn Database>>) { create_database_tables(driver).await.expect("database tables creation failed"); - driver.drop_database_tables().expect("old database tables deletion failed"); + driver + .drop_database_tables() + .await + .expect("old database tables deletion failed"); create_database_tables(driver) .await .expect("database tables creation from empty schema failed"); @@ -147,7 +92,7 @@ pub(crate) mod tests { async fn create_database_tables(driver: &Arc<Box<dyn Database>>) -> Result<(), Box<dyn std::error::Error>> { for _ in 0..5 { - if driver.create_database_tables().is_ok() { + if driver.create_database_tables().await.is_ok() { return Ok(()); } tokio::time::sleep(Duration::from_secs(2)).await; @@ -159,80 +104,80 @@ pub(crate) mod tests { use std::sync::Arc; - use crate::databases::Database; + use crate::databases::traits::Database; use crate::test_helpers::tests::sample_info_hash; // Metrics per torrent - pub fn it_should_save_and_load_persistent_torrents(driver: &Arc<Box<dyn Database>>) { + pub async fn it_should_save_and_load_persistent_torrents(driver: &Arc<Box<dyn Database>>) { let infohash = sample_info_hash(); let number_of_downloads = 1; - driver.save_torrent_downloads(&infohash, number_of_downloads).unwrap(); + driver.save_torrent_downloads(&infohash, number_of_downloads).await.unwrap(); - let number_of_downloads = driver.load_torrent_downloads(&infohash).unwrap().unwrap(); + let number_of_downloads = driver.load_torrent_downloads(&infohash).await.unwrap().unwrap(); assert_eq!(number_of_downloads, 1); } - pub fn it_should_load_all_persistent_torrents(driver: &Arc<Box<dyn Database>>) { + pub async fn it_should_load_all_persistent_torrents(driver: &Arc<Box<dyn Database>>) { let infohash = sample_info_hash(); let number_of_downloads = 1; - driver.save_torrent_downloads(&infohash, number_of_downloads).unwrap(); + driver.save_torrent_downloads(&infohash, number_of_downloads).await.unwrap(); - let torrents = driver.load_all_torrents_downloads().unwrap(); + let torrents = driver.load_all_torrents_downloads().await.unwrap(); assert_eq!(torrents.len(), 1); assert_eq!(torrents.get(&infohash), Some(number_of_downloads).as_ref()); } - pub fn it_should_increase_the_number_of_downloads_for_a_given_torrent(driver: &Arc<Box<dyn Database>>) { + pub async fn it_should_increase_the_number_of_downloads_for_a_given_torrent(driver: &Arc<Box<dyn Database>>) { let infohash = sample_info_hash(); let number_of_downloads = 1; - driver.save_torrent_downloads(&infohash, number_of_downloads).unwrap(); + driver.save_torrent_downloads(&infohash, number_of_downloads).await.unwrap(); - driver.increase_downloads_for_torrent(&infohash).unwrap(); + driver.increase_downloads_for_torrent(&infohash).await.unwrap(); - let number_of_downloads = driver.load_torrent_downloads(&infohash).unwrap().unwrap(); + let number_of_downloads = driver.load_torrent_downloads(&infohash).await.unwrap().unwrap(); assert_eq!(number_of_downloads, 2); } // Aggregate metrics for all torrents - pub fn it_should_save_and_load_the_global_number_of_downloads(driver: &Arc<Box<dyn Database>>) { + pub async fn it_should_save_and_load_the_global_number_of_downloads(driver: &Arc<Box<dyn Database>>) { let number_of_downloads = 1; - driver.save_global_downloads(number_of_downloads).unwrap(); + driver.save_global_downloads(number_of_downloads).await.unwrap(); - let number_of_downloads = driver.load_global_downloads().unwrap().unwrap(); + let number_of_downloads = driver.load_global_downloads().await.unwrap().unwrap(); assert_eq!(number_of_downloads, 1); } - pub fn it_should_load_the_global_number_of_downloads(driver: &Arc<Box<dyn Database>>) { + pub async fn it_should_load_the_global_number_of_downloads(driver: &Arc<Box<dyn Database>>) { let number_of_downloads = 1; - driver.save_global_downloads(number_of_downloads).unwrap(); + driver.save_global_downloads(number_of_downloads).await.unwrap(); - let number_of_downloads = driver.load_global_downloads().unwrap().unwrap(); + let number_of_downloads = driver.load_global_downloads().await.unwrap().unwrap(); assert_eq!(number_of_downloads, 1); } - pub fn it_should_increase_the_global_number_of_downloads(driver: &Arc<Box<dyn Database>>) { + pub async fn it_should_increase_the_global_number_of_downloads(driver: &Arc<Box<dyn Database>>) { let number_of_downloads = 1; - driver.save_global_downloads(number_of_downloads).unwrap(); + driver.save_global_downloads(number_of_downloads).await.unwrap(); - driver.increase_global_downloads().unwrap(); + driver.increase_global_downloads().await.unwrap(); - let number_of_downloads = driver.load_global_downloads().unwrap().unwrap(); + let number_of_downloads = driver.load_global_downloads().await.unwrap().unwrap(); assert_eq!(number_of_downloads, 2); } @@ -244,56 +189,56 @@ pub(crate) mod tests { use std::time::Duration; use crate::authentication::key::{generate_expiring_key, generate_permanent_key}; - use crate::databases::Database; + use crate::databases::traits::Database; - pub fn it_should_load_the_keys(driver: &Arc<Box<dyn Database>>) { + pub async fn it_should_load_the_keys(driver: &Arc<Box<dyn Database>>) { let permanent_peer_key = generate_permanent_key(); - driver.add_key_to_keys(&permanent_peer_key).unwrap(); + driver.add_key_to_keys(&permanent_peer_key).await.unwrap(); let expiring_peer_key = generate_expiring_key(Duration::from_secs(120)); - driver.add_key_to_keys(&expiring_peer_key).unwrap(); + driver.add_key_to_keys(&expiring_peer_key).await.unwrap(); - let keys = driver.load_keys().unwrap(); + let keys = driver.load_keys().await.unwrap(); assert!(keys.contains(&permanent_peer_key)); assert!(keys.contains(&expiring_peer_key)); } - pub fn it_should_save_and_load_permanent_authentication_keys(driver: &Arc<Box<dyn Database>>) { + pub async fn it_should_save_and_load_permanent_authentication_keys(driver: &Arc<Box<dyn Database>>) { let peer_key = generate_permanent_key(); - driver.add_key_to_keys(&peer_key).unwrap(); + driver.add_key_to_keys(&peer_key).await.unwrap(); - let stored_peer_key = driver.get_key_from_keys(&peer_key.key()).unwrap().unwrap(); + let stored_peer_key = driver.get_key_from_keys(&peer_key.key()).await.unwrap().unwrap(); assert_eq!(stored_peer_key, peer_key); } - pub fn it_should_save_and_load_expiring_authentication_keys(driver: &Arc<Box<dyn Database>>) { + pub async fn it_should_save_and_load_expiring_authentication_keys(driver: &Arc<Box<dyn Database>>) { let peer_key = generate_expiring_key(Duration::from_secs(120)); - driver.add_key_to_keys(&peer_key).unwrap(); + driver.add_key_to_keys(&peer_key).await.unwrap(); - let stored_peer_key = driver.get_key_from_keys(&peer_key.key()).unwrap().unwrap(); + let stored_peer_key = driver.get_key_from_keys(&peer_key.key()).await.unwrap().unwrap(); assert_eq!(stored_peer_key, peer_key); assert_eq!(stored_peer_key.expiry_time(), peer_key.expiry_time()); } - pub fn it_should_remove_a_permanent_authentication_key(driver: &Arc<Box<dyn Database>>) { + pub async fn it_should_remove_a_permanent_authentication_key(driver: &Arc<Box<dyn Database>>) { let peer_key = generate_permanent_key(); - driver.add_key_to_keys(&peer_key).unwrap(); + driver.add_key_to_keys(&peer_key).await.unwrap(); - driver.remove_key_from_keys(&peer_key.key()).unwrap(); + driver.remove_key_from_keys(&peer_key.key()).await.unwrap(); - assert!(driver.get_key_from_keys(&peer_key.key()).unwrap().is_none()); + assert!(driver.get_key_from_keys(&peer_key.key()).await.unwrap().is_none()); } - pub fn it_should_remove_an_expiring_authentication_key(driver: &Arc<Box<dyn Database>>) { + pub async fn it_should_remove_an_expiring_authentication_key(driver: &Arc<Box<dyn Database>>) { let peer_key = generate_expiring_key(Duration::from_secs(120)); - driver.add_key_to_keys(&peer_key).unwrap(); + driver.add_key_to_keys(&peer_key).await.unwrap(); - driver.remove_key_from_keys(&peer_key.key()).unwrap(); + driver.remove_key_from_keys(&peer_key.key()).await.unwrap(); - assert!(driver.get_key_from_keys(&peer_key.key()).unwrap().is_none()); + assert!(driver.get_key_from_keys(&peer_key.key()).await.unwrap().is_none()); } } @@ -301,42 +246,42 @@ pub(crate) mod tests { use std::sync::Arc; - use crate::databases::Database; + use crate::databases::traits::Database; use crate::test_helpers::tests::random_info_hash; - pub fn it_should_load_the_whitelist(driver: &Arc<Box<dyn Database>>) { + pub async fn it_should_load_the_whitelist(driver: &Arc<Box<dyn Database>>) { let infohash = random_info_hash(); - driver.add_info_hash_to_whitelist(infohash).unwrap(); + driver.add_info_hash_to_whitelist(infohash).await.unwrap(); - let whitelist = driver.load_whitelist().unwrap(); + let whitelist = driver.load_whitelist().await.unwrap(); assert!(whitelist.contains(&infohash)); } - pub fn it_should_add_and_get_infohashes(driver: &Arc<Box<dyn Database>>) { + pub async fn it_should_add_and_get_infohashes(driver: &Arc<Box<dyn Database>>) { let infohash = random_info_hash(); - driver.add_info_hash_to_whitelist(infohash).unwrap(); + driver.add_info_hash_to_whitelist(infohash).await.unwrap(); - let stored_infohash = driver.get_info_hash_from_whitelist(infohash).unwrap().unwrap(); + let stored_infohash = driver.get_info_hash_from_whitelist(infohash).await.unwrap().unwrap(); assert_eq!(stored_infohash, infohash); } - pub fn it_should_remove_an_infohash_from_the_whitelist(driver: &Arc<Box<dyn Database>>) { + pub async fn it_should_remove_an_infohash_from_the_whitelist(driver: &Arc<Box<dyn Database>>) { let infohash = random_info_hash(); - driver.add_info_hash_to_whitelist(infohash).unwrap(); + driver.add_info_hash_to_whitelist(infohash).await.unwrap(); - driver.remove_info_hash_from_whitelist(infohash).unwrap(); + driver.remove_info_hash_from_whitelist(infohash).await.unwrap(); - assert!(driver.get_info_hash_from_whitelist(infohash).unwrap().is_none()); + assert!(driver.get_info_hash_from_whitelist(infohash).await.unwrap().is_none()); } - pub fn it_should_fail_trying_to_add_the_same_infohash_twice(driver: &Arc<Box<dyn Database>>) { + pub async fn it_should_fail_trying_to_add_the_same_infohash_twice(driver: &Arc<Box<dyn Database>>) { let infohash = random_info_hash(); - driver.add_info_hash_to_whitelist(infohash).unwrap(); - let result = driver.add_info_hash_to_whitelist(infohash); + driver.add_info_hash_to_whitelist(infohash).await.unwrap(); + let result = driver.add_info_hash_to_whitelist(infohash).await; assert!(result.is_err()); } diff --git a/packages/tracker-core/src/databases/driver/mysql.rs b/packages/tracker-core/src/databases/driver/mysql.rs deleted file mode 100644 index da2f86ce8..000000000 --- a/packages/tracker-core/src/databases/driver/mysql.rs +++ /dev/null @@ -1,483 +0,0 @@ -//! The `MySQL` database driver. -//! -//! This module provides an implementation of the [`Database`] trait for `MySQL` -//! using the `r2d2_mysql` connection pool. It configures the MySQL connection -//! based on a URL, creates the necessary tables (for torrent metrics, torrent -//! whitelist, and authentication keys), and implements all CRUD operations -//! required by the persistence layer. -use std::str::FromStr; -use std::time::Duration; - -use bittorrent_primitives::info_hash::InfoHash; -use r2d2::Pool; -use r2d2_mysql::mysql::prelude::Queryable; -use r2d2_mysql::mysql::{params, Opts, OptsBuilder}; -use r2d2_mysql::MySqlConnectionManager; -use torrust_tracker_primitives::{NumberOfDownloads, NumberOfDownloadsBTreeMap}; - -use super::{Database, Driver, Error, TORRENTS_DOWNLOADS_TOTAL}; -use crate::authentication::key::AUTH_KEY_LENGTH; -use crate::authentication::{self, Key}; - -const DRIVER: Driver = Driver::MySQL; - -/// `MySQL` driver implementation. -/// -/// This struct encapsulates a connection pool for `MySQL`, built using the -/// `r2d2_mysql` connection manager. It implements the [`Database`] trait to -/// provide persistence operations. -pub(crate) struct Mysql { - pool: Pool<MySqlConnectionManager>, -} - -impl Mysql { - /// It instantiates a new `MySQL` database driver. - /// - /// Refer to [`databases::Database::new`](crate::core::databases::Database::new). - /// - /// # Errors - /// - /// Will return `r2d2::Error` if `db_path` is not able to create `MySQL` database. - pub fn new(db_path: &str) -> Result<Self, Error> { - let opts = Opts::from_url(db_path)?; - let builder = OptsBuilder::from_opts(opts); - let manager = MySqlConnectionManager::new(builder); - let pool = r2d2::Pool::builder().build(manager).map_err(|e| (e, DRIVER))?; - - Ok(Self { pool }) - } - - fn load_torrent_aggregate_metric(&self, metric_name: &str) -> Result<Option<NumberOfDownloads>, Error> { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let query = conn.exec_first::<u32, _, _>( - "SELECT value FROM torrent_aggregate_metrics WHERE metric_name = :metric_name", - params! { "metric_name" => metric_name }, - ); - - let persistent_torrent = query?; - - Ok(persistent_torrent) - } - - fn save_torrent_aggregate_metric(&self, metric_name: &str, completed: NumberOfDownloads) -> Result<(), Error> { - const COMMAND : &str = "INSERT INTO torrent_aggregate_metrics (metric_name, value) VALUES (:metric_name, :completed) ON DUPLICATE KEY UPDATE value = VALUES(value)"; - - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - Ok(conn.exec_drop(COMMAND, params! { metric_name, completed })?) - } -} - -impl Database for Mysql { - /// Refer to [`databases::Database::create_database_tables`](crate::core::databases::Database::create_database_tables). - fn create_database_tables(&self) -> Result<(), Error> { - let create_whitelist_table = " - CREATE TABLE IF NOT EXISTS whitelist ( - id integer PRIMARY KEY AUTO_INCREMENT, - info_hash VARCHAR(40) NOT NULL UNIQUE - );" - .to_string(); - - let create_torrents_table = " - CREATE TABLE IF NOT EXISTS torrents ( - id integer PRIMARY KEY AUTO_INCREMENT, - info_hash VARCHAR(40) NOT NULL UNIQUE, - completed INTEGER DEFAULT 0 NOT NULL - );" - .to_string(); - - let create_torrent_aggregate_metrics_table = " - CREATE TABLE IF NOT EXISTS torrent_aggregate_metrics ( - id integer PRIMARY KEY AUTO_INCREMENT, - metric_name VARCHAR(50) NOT NULL UNIQUE, - value INTEGER DEFAULT 0 NOT NULL - );" - .to_string(); - - let create_keys_table = format!( - " - CREATE TABLE IF NOT EXISTS `keys` ( - `id` INT NOT NULL AUTO_INCREMENT, - `key` VARCHAR({}) NOT NULL, - `valid_until` INT(10), - PRIMARY KEY (`id`), - UNIQUE (`key`) - );", - i8::try_from(AUTH_KEY_LENGTH).expect("authentication key length should fit within a i8!") - ); - - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - conn.query_drop(&create_torrents_table) - .expect("Could not create torrents table."); - conn.query_drop(&create_torrent_aggregate_metrics_table) - .expect("Could not create create_torrent_aggregate_metrics_table table."); - conn.query_drop(&create_keys_table).expect("Could not create keys table."); - conn.query_drop(&create_whitelist_table) - .expect("Could not create whitelist table."); - - Ok(()) - } - - /// Refer to [`databases::Database::drop_database_tables`](crate::core::databases::Database::drop_database_tables). - fn drop_database_tables(&self) -> Result<(), Error> { - let drop_whitelist_table = " - DROP TABLE `whitelist`;" - .to_string(); - - let drop_torrents_table = " - DROP TABLE `torrents`;" - .to_string(); - - let drop_keys_table = " - DROP TABLE `keys`;" - .to_string(); - - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - conn.query_drop(&drop_whitelist_table) - .expect("Could not drop `whitelist` table."); - conn.query_drop(&drop_torrents_table) - .expect("Could not drop `torrents` table."); - conn.query_drop(&drop_keys_table).expect("Could not drop `keys` table."); - - Ok(()) - } - - /// Refer to [`databases::Database::load_persistent_torrents`](crate::core::databases::Database::load_persistent_torrents). - fn load_all_torrents_downloads(&self) -> Result<NumberOfDownloadsBTreeMap, Error> { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let torrents = conn.query_map( - "SELECT info_hash, completed FROM torrents", - |(info_hash_string, completed): (String, u32)| { - let info_hash = InfoHash::from_str(&info_hash_string).unwrap(); - (info_hash, completed) - }, - )?; - - Ok(torrents.iter().copied().collect()) - } - - /// Refer to [`databases::Database::load_persistent_torrent`](crate::core::databases::Database::load_persistent_torrent). - fn load_torrent_downloads(&self, info_hash: &InfoHash) -> Result<Option<NumberOfDownloads>, Error> { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let query = conn.exec_first::<u32, _, _>( - "SELECT completed FROM torrents WHERE info_hash = :info_hash", - params! { "info_hash" => info_hash.to_hex_string() }, - ); - - let persistent_torrent = query?; - - Ok(persistent_torrent) - } - - /// Refer to [`databases::Database::save_persistent_torrent`](crate::core::databases::Database::save_persistent_torrent). - fn save_torrent_downloads(&self, info_hash: &InfoHash, completed: u32) -> Result<(), Error> { - const COMMAND : &str = "INSERT INTO torrents (info_hash, completed) VALUES (:info_hash_str, :completed) ON DUPLICATE KEY UPDATE completed = VALUES(completed)"; - - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let info_hash_str = info_hash.to_string(); - - Ok(conn.exec_drop(COMMAND, params! { info_hash_str, completed })?) - } - - /// Refer to [`databases::Database::increase_number_of_downloads`](crate::core::databases::Database::increase_number_of_downloads). - fn increase_downloads_for_torrent(&self, info_hash: &InfoHash) -> Result<(), Error> { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let info_hash_str = info_hash.to_string(); - - conn.exec_drop( - "UPDATE torrents SET completed = completed + 1 WHERE info_hash = :info_hash_str", - params! { info_hash_str }, - )?; - - Ok(()) - } - - /// Refer to [`databases::Database::load_global_number_of_downloads`](crate::core::databases::Database::load_global_number_of_downloads). - fn load_global_downloads(&self) -> Result<Option<NumberOfDownloads>, Error> { - self.load_torrent_aggregate_metric(TORRENTS_DOWNLOADS_TOTAL) - } - - /// Refer to [`databases::Database::save_global_number_of_downloads`](crate::core::databases::Database::save_global_number_of_downloads). - fn save_global_downloads(&self, downloaded: NumberOfDownloads) -> Result<(), Error> { - self.save_torrent_aggregate_metric(TORRENTS_DOWNLOADS_TOTAL, downloaded) - } - - /// Refer to [`databases::Database::increase_global_number_of_downloads`](crate::core::databases::Database::increase_global_number_of_downloads). - fn increase_global_downloads(&self) -> Result<(), Error> { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let metric_name = TORRENTS_DOWNLOADS_TOTAL; - - conn.exec_drop( - "UPDATE torrent_aggregate_metrics SET value = value + 1 WHERE metric_name = :metric_name", - params! { metric_name }, - )?; - - Ok(()) - } - - /// Refer to [`databases::Database::load_keys`](crate::core::databases::Database::load_keys). - fn load_keys(&self) -> Result<Vec<authentication::PeerKey>, Error> { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let keys = conn.query_map( - "SELECT `key`, valid_until FROM `keys`", - |(key, valid_until): (String, Option<i64>)| match valid_until { - Some(valid_until) => authentication::PeerKey { - key: key.parse::<Key>().unwrap(), - valid_until: Some(Duration::from_secs(valid_until.unsigned_abs())), - }, - None => authentication::PeerKey { - key: key.parse::<Key>().unwrap(), - valid_until: None, - }, - }, - )?; - - Ok(keys) - } - - /// Refer to [`databases::Database::load_whitelist`](crate::core::databases::Database::load_whitelist). - fn load_whitelist(&self) -> Result<Vec<InfoHash>, Error> { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let info_hashes = conn.query_map("SELECT info_hash FROM whitelist", |info_hash: String| { - InfoHash::from_str(&info_hash).unwrap() - })?; - - Ok(info_hashes) - } - - /// Refer to [`databases::Database::get_info_hash_from_whitelist`](crate::core::databases::Database::get_info_hash_from_whitelist). - fn get_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result<Option<InfoHash>, Error> { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let select = conn.exec_first::<String, _, _>( - "SELECT info_hash FROM whitelist WHERE info_hash = :info_hash", - params! { "info_hash" => info_hash.to_hex_string() }, - )?; - - let info_hash = select.map(|f| InfoHash::from_str(&f).expect("Failed to decode InfoHash String from DB!")); - - Ok(info_hash) - } - - /// Refer to [`databases::Database::add_info_hash_to_whitelist`](crate::core::databases::Database::add_info_hash_to_whitelist). - fn add_info_hash_to_whitelist(&self, info_hash: InfoHash) -> Result<usize, Error> { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let info_hash_str = info_hash.to_string(); - - conn.exec_drop( - "INSERT INTO whitelist (info_hash) VALUES (:info_hash_str)", - params! { info_hash_str }, - )?; - - Ok(1) - } - - /// Refer to [`databases::Database::remove_info_hash_from_whitelist`](crate::core::databases::Database::remove_info_hash_from_whitelist). - fn remove_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result<usize, Error> { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let info_hash = info_hash.to_string(); - - conn.exec_drop("DELETE FROM whitelist WHERE info_hash = :info_hash", params! { info_hash })?; - - Ok(1) - } - - /// Refer to [`databases::Database::get_key_from_keys`](crate::core::databases::Database::get_key_from_keys). - fn get_key_from_keys(&self, key: &Key) -> Result<Option<authentication::PeerKey>, Error> { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let query = conn.exec_first::<(String, Option<i64>), _, _>( - "SELECT `key`, valid_until FROM `keys` WHERE `key` = :key", - params! { "key" => key.to_string() }, - ); - - let key = query?; - - Ok(key.map(|(key, opt_valid_until)| match opt_valid_until { - Some(valid_until) => authentication::PeerKey { - key: key.parse::<Key>().unwrap(), - valid_until: Some(Duration::from_secs(valid_until.unsigned_abs())), - }, - None => authentication::PeerKey { - key: key.parse::<Key>().unwrap(), - valid_until: None, - }, - })) - } - - /// Refer to [`databases::Database::add_key_to_keys`](crate::core::databases::Database::add_key_to_keys). - fn add_key_to_keys(&self, auth_key: &authentication::PeerKey) -> Result<usize, Error> { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - match auth_key.valid_until { - Some(valid_until) => conn.exec_drop( - "INSERT INTO `keys` (`key`, valid_until) VALUES (:key, :valid_until)", - params! { "key" => auth_key.key.to_string(), "valid_until" => valid_until.as_secs().to_string() }, - )?, - None => conn.exec_drop( - "INSERT INTO `keys` (`key`) VALUES (:key)", - params! { "key" => auth_key.key.to_string() }, - )?, - } - - Ok(1) - } - - /// Refer to [`databases::Database::remove_key_from_keys`](crate::core::databases::Database::remove_key_from_keys). - fn remove_key_from_keys(&self, key: &Key) -> Result<usize, Error> { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - conn.exec_drop("DELETE FROM `keys` WHERE `key` = :key", params! { "key" => key.to_string() })?; - - Ok(1) - } -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use testcontainers::core::IntoContainerPort; - /* - We run a MySQL container and run all the tests against the same container and database. - - Test for this driver are executed with: - - `TORRUST_TRACKER_CORE_RUN_MYSQL_DRIVER_TEST=true cargo test` - - The `Database` trait is very simple and we only have one driver that needs - a container. In the future we might want to use different approaches like: - - - https://github.com/testcontainers/testcontainers-rs/issues/707 - - https://www.infinyon.com/blog/2021/04/rust-custom-test-harness/ - - https://github.com/torrust/torrust-tracker/blob/develop/src/bin/e2e_tests_runner.rs - - If we increase the number of methods or the number or drivers. - */ - use testcontainers::runners::AsyncRunner; - use testcontainers::{ContainerAsync, GenericImage, ImageExt}; - use torrust_tracker_configuration::Core; - - use super::Mysql; - use crate::databases::driver::tests::run_tests; - use crate::databases::Database; - - #[derive(Debug, Default)] - struct StoppedMysqlContainer {} - - impl StoppedMysqlContainer { - async fn run(self, config: &MysqlConfiguration) -> Result<RunningMysqlContainer, Box<dyn std::error::Error + 'static>> { - let container = GenericImage::new("mysql", "8.0") - .with_exposed_port(config.internal_port.tcp()) - // todo: this does not work - //.with_wait_for(WaitFor::message_on_stdout("ready for connections")) - .with_env_var("MYSQL_ROOT_PASSWORD", config.db_root_password.clone()) - .with_env_var("MYSQL_DATABASE", config.database.clone()) - .with_env_var("MYSQL_ROOT_HOST", "%") - .start() - .await?; - - Ok(RunningMysqlContainer::new(container, config.internal_port)) - } - } - - struct RunningMysqlContainer { - container: ContainerAsync<GenericImage>, - internal_port: u16, - } - - impl RunningMysqlContainer { - fn new(container: ContainerAsync<GenericImage>, internal_port: u16) -> Self { - Self { - container, - internal_port, - } - } - - async fn stop(self) { - self.container.stop().await.unwrap(); - } - - async fn get_host(&self) -> url::Host { - self.container.get_host().await.unwrap() - } - - async fn get_host_port_ipv4(&self) -> u16 { - self.container.get_host_port_ipv4(self.internal_port).await.unwrap() - } - } - - impl Default for MysqlConfiguration { - fn default() -> Self { - Self { - internal_port: 3306, - database: "torrust_tracker_test".to_string(), - db_user: "root".to_string(), - db_root_password: "test".to_string(), - } - } - } - - struct MysqlConfiguration { - pub internal_port: u16, - pub database: String, - pub db_user: String, - pub db_root_password: String, - } - - fn core_configuration(host: &url::Host, port: u16, mysql_configuration: &MysqlConfiguration) -> Core { - let mut config = Core::default(); - - let database = mysql_configuration.database.clone(); - let db_user = mysql_configuration.db_user.clone(); - let db_password = mysql_configuration.db_root_password.clone(); - - config.database.path = format!("mysql://{db_user}:{db_password}@{host}:{port}/{database}"); - - config - } - - fn initialize_driver(config: &Core) -> Arc<Box<dyn Database>> { - let driver: Arc<Box<dyn Database>> = Arc::new(Box::new(Mysql::new(&config.database.path).unwrap())); - driver - } - - #[tokio::test] - async fn run_mysql_driver_tests() -> Result<(), Box<dyn std::error::Error + 'static>> { - if std::env::var("TORRUST_TRACKER_CORE_RUN_MYSQL_DRIVER_TEST").is_err() { - println!("Skipping the MySQL driver tests."); - return Ok(()); - } - - let mysql_configuration = MysqlConfiguration::default(); - - let stopped_mysql_container = StoppedMysqlContainer::default(); - - let mysql_container = stopped_mysql_container.run(&mysql_configuration).await.unwrap(); - - let host = mysql_container.get_host().await; - let port = mysql_container.get_host_port_ipv4().await; - - let config = core_configuration(&host, port, &mysql_configuration); - - let driver = initialize_driver(&config); - - run_tests(&driver).await; - - mysql_container.stop().await; - - Ok(()) - } -} diff --git a/packages/tracker-core/src/databases/driver/mysql/auth_key_store.rs b/packages/tracker-core/src/databases/driver/mysql/auth_key_store.rs new file mode 100644 index 000000000..6029855c2 --- /dev/null +++ b/packages/tracker-core/src/databases/driver/mysql/auth_key_store.rs @@ -0,0 +1,125 @@ +use ::sqlx::Row; +use async_trait::async_trait; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use super::{Mysql, DRIVER}; +use crate::authentication::{self, Key}; +use crate::databases::error::Error; +use crate::databases::AuthKeyStore; + +#[async_trait] +impl AuthKeyStore for Mysql { + async fn load_keys(&self) -> Result<Vec<authentication::PeerKey>, Error> { + let rows = ::sqlx::query("SELECT `key`, valid_until FROM `keys`") + .fetch_all(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + rows.into_iter() + .map(|row| { + let key_value: String = row.try_get("key").map_err(|e| (e, DRIVER))?; + let valid_until: Option<i64> = row.try_get("valid_until").map_err(|e| (e, DRIVER))?; + + let parsed_key = key_value.parse::<Key>().map_err(|e| Error::MalformedDatabaseRecord { + message: e.to_string(), + driver: DRIVER, + })?; + + Ok(authentication::PeerKey { + key: parsed_key, + valid_until: valid_until.map(parse_valid_until).transpose()?, + }) + }) + .collect() + } + + async fn get_key_from_keys(&self, key: &Key) -> Result<Option<authentication::PeerKey>, Error> { + let maybe_row = ::sqlx::query("SELECT `key`, valid_until FROM `keys` WHERE `key` = ?") + .bind(key.to_string()) + .fetch_optional(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + maybe_row + .map(|row| { + let key_value: String = row.try_get("key").map_err(|e| (e, DRIVER))?; + let valid_until: Option<i64> = row.try_get("valid_until").map_err(|e| (e, DRIVER))?; + + let parsed_key = key_value.parse::<Key>().map_err(|e| Error::MalformedDatabaseRecord { + message: e.to_string(), + driver: DRIVER, + })?; + + Ok(authentication::PeerKey { + key: parsed_key, + valid_until: valid_until.map(parse_valid_until).transpose()?, + }) + }) + .transpose() + } + + async fn add_key_to_keys(&self, auth_key: &authentication::PeerKey) -> Result<usize, Error> { + let valid_until = auth_key + .valid_until + .map(|value| { + i64::try_from(value.as_secs()).map_err(|e| Error::MalformedDatabaseRecord { + message: e.to_string(), + driver: DRIVER, + }) + }) + .transpose()?; + + let insert = ::sqlx::query("INSERT INTO `keys` (`key`, valid_until) VALUES (?, ?)") + .bind(auth_key.key.to_string()) + .bind(valid_until) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))? + .rows_affected(); + + if insert == 0 { + Err(Error::InsertFailed { + location: std::panic::Location::caller(), + driver: DRIVER, + }) + } else { + usize::try_from(insert).map_err(|e| Error::MalformedDatabaseRecord { + message: format!("rows_affected does not fit in usize: {e}"), + driver: DRIVER, + }) + } + } + + async fn remove_key_from_keys(&self, key: &Key) -> Result<usize, Error> { + let deleted = ::sqlx::query("DELETE FROM `keys` WHERE `key` = ?") + .bind(key.to_string()) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))? + .rows_affected(); + + if deleted == 1 { + Ok(1) + } else { + Err(Error::DeleteFailed { + location: std::panic::Location::caller(), + error_code: usize::try_from(deleted).unwrap_or(0), + driver: DRIVER, + }) + } + } +} + +/// Convert a signed seconds value loaded from the database into a +/// [`DurationSinceUnixEpoch`]. +/// +/// Negative values indicate a corrupted record (timestamps before the Unix +/// epoch are not representable) and are rejected as +/// [`Error::MalformedDatabaseRecord`]. +fn parse_valid_until(value: i64) -> Result<DurationSinceUnixEpoch, Error> { + let secs = u64::try_from(value).map_err(|_| Error::MalformedDatabaseRecord { + message: format!("negative valid_until timestamp: {value}"), + driver: DRIVER, + })?; + Ok(DurationSinceUnixEpoch::from_secs(secs)) +} diff --git a/packages/tracker-core/src/databases/driver/mysql/mod.rs b/packages/tracker-core/src/databases/driver/mysql/mod.rs new file mode 100644 index 000000000..545754e5f --- /dev/null +++ b/packages/tracker-core/src/databases/driver/mysql/mod.rs @@ -0,0 +1,209 @@ +//! The `MySQL` database driver. +use std::str::FromStr; + +use ::sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions}; +use ::sqlx::{MySqlPool, Row}; +use torrust_tracker_primitives::NumberOfDownloads; + +use super::{Driver, Error}; + +mod auth_key_store; +mod schema_migrator; +mod torrent_metrics_store; +mod whitelist_store; + +const DRIVER: Driver = Driver::MySQL; + +/// `MySQL` driver implementation. +/// +/// This struct encapsulates an async `sqlx` connection pool for `MySQL`. +/// It implements the [`Database`] trait to provide persistence operations. +pub(crate) struct Mysql { + pool: MySqlPool, +} + +impl Mysql { + pub fn new(db_path: &str) -> Result<Self, Error> { + let options = MySqlConnectOptions::from_str(db_path).map_err(|e| (e, DRIVER))?; + + let pool = MySqlPoolOptions::new().connect_lazy_with(options); + + Ok(Self { pool }) + } + + async fn load_torrent_aggregate_metric(&self, metric_name: &str) -> Result<Option<NumberOfDownloads>, Error> { + let maybe_row = ::sqlx::query("SELECT value FROM torrent_aggregate_metrics WHERE metric_name = ?") + .bind(metric_name) + .fetch_optional(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + maybe_row + .map(|row| { + let value: i64 = row.try_get("value").map_err(|e| (e, DRIVER))?; + u32::try_from(value).map_err(|e| Error::MalformedDatabaseRecord { + message: e.to_string(), + driver: DRIVER, + }) + }) + .transpose() + } + + async fn save_torrent_aggregate_metric(&self, metric_name: &str, completed: NumberOfDownloads) -> Result<(), Error> { + // `ON DUPLICATE KEY UPDATE` may legitimately report `rows_affected() == 0` + // when the row already exists with the same value (no-op update), so we + // do not treat 0 as a failure here. A real failure surfaces as `Err` + // from `execute()`. + ::sqlx::query( + "INSERT INTO torrent_aggregate_metrics (metric_name, value) VALUES (?, ?) ON DUPLICATE KEY UPDATE value = VALUES(value)", + ) + .bind(metric_name) + .bind(i64::from(completed)) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + Ok(()) + } +} + +#[cfg(all(test, feature = "db-compatibility-tests"))] +mod tests { + use std::sync::Arc; + + use testcontainers::core::IntoContainerPort; + /* + We run a MySQL container and run all the tests against the same container and database. + + Test for this driver are executed with: + + `TORRUST_TRACKER_CORE_RUN_MYSQL_DRIVER_TEST=true \ + cargo test -p bittorrent-tracker-core --features db-compatibility-tests run_mysql_driver_tests` + + The `Database` trait is very simple and we only have one driver that needs + a container. In the future we might want to use different approaches like: + + - https://github.com/testcontainers/testcontainers-rs/issues/707 + - https://www.infinyon.com/blog/2021/04/rust-custom-test-harness/ + - https://github.com/torrust/torrust-tracker/blob/develop/src/bin/e2e_tests_runner.rs + + If we increase the number of methods or the number or drivers. + */ + use testcontainers::runners::AsyncRunner; + use testcontainers::{ContainerAsync, GenericImage, ImageExt}; + use torrust_tracker_configuration::Core; + + use super::Mysql; + use crate::databases::driver::tests::run_tests; + use crate::databases::traits::Database; + + #[derive(Debug, Default)] + struct StoppedMysqlContainer {} + + impl StoppedMysqlContainer { + async fn run(self, config: &MysqlConfiguration) -> Result<RunningMysqlContainer, Box<dyn std::error::Error + 'static>> { + let image_tag = std::env::var("TORRUST_TRACKER_CORE_MYSQL_DRIVER_IMAGE_TAG").unwrap_or_else(|_| "8.0".to_string()); + + let container = GenericImage::new("mysql", image_tag.as_str()) + .with_exposed_port(config.internal_port.tcp()) + // todo: this does not work + //.with_wait_for(WaitFor::message_on_stdout("ready for connections")) + .with_env_var("MYSQL_ROOT_PASSWORD", config.db_root_password.clone()) + .with_env_var("MYSQL_DATABASE", config.database.clone()) + .with_env_var("MYSQL_ROOT_HOST", "%") + .start() + .await?; + + Ok(RunningMysqlContainer::new(container, config.internal_port)) + } + } + + struct RunningMysqlContainer { + container: ContainerAsync<GenericImage>, + internal_port: u16, + } + + impl RunningMysqlContainer { + fn new(container: ContainerAsync<GenericImage>, internal_port: u16) -> Self { + Self { + container, + internal_port, + } + } + + async fn stop(self) { + self.container.stop().await.unwrap(); + } + + async fn get_host(&self) -> url::Host { + self.container.get_host().await.unwrap() + } + + async fn get_host_port_ipv4(&self) -> u16 { + self.container.get_host_port_ipv4(self.internal_port).await.unwrap() + } + } + + impl Default for MysqlConfiguration { + fn default() -> Self { + Self { + internal_port: 3306, + database: "torrust_tracker_test".to_string(), + db_user: "root".to_string(), + db_root_password: "test".to_string(), + } + } + } + + struct MysqlConfiguration { + pub internal_port: u16, + pub database: String, + pub db_user: String, + pub db_root_password: String, + } + + fn core_configuration(host: &url::Host, port: u16, mysql_configuration: &MysqlConfiguration) -> Core { + let mut config = Core::default(); + + let database = mysql_configuration.database.clone(); + let db_user = mysql_configuration.db_user.clone(); + let db_password = mysql_configuration.db_root_password.clone(); + + config.database.path = format!("mysql://{db_user}:{db_password}@{host}:{port}/{database}"); + + config + } + + fn initialize_driver(config: &Core) -> Arc<Box<dyn Database>> { + Arc::new(Box::new(Mysql::new(&config.database.path).unwrap())) + } + + // This test is invoked by `.github/workflows/testing.yaml` in the + // `database-compatibility` job to validate supported MySQL versions. + #[tokio::test] + async fn run_mysql_driver_tests() -> Result<(), Box<dyn std::error::Error + 'static>> { + if std::env::var("TORRUST_TRACKER_CORE_RUN_MYSQL_DRIVER_TEST").is_err() { + println!("Skipping the MySQL driver tests."); + return Ok(()); + } + + let mysql_configuration = MysqlConfiguration::default(); + + let stopped_mysql_container = StoppedMysqlContainer::default(); + + let mysql_container = stopped_mysql_container.run(&mysql_configuration).await.unwrap(); + + let host = mysql_container.get_host().await; + let port = mysql_container.get_host_port_ipv4().await; + + let config = core_configuration(&host, port, &mysql_configuration); + + let driver = initialize_driver(&config); + + run_tests(&driver).await; + + mysql_container.stop().await; + + Ok(()) + } +} diff --git a/packages/tracker-core/src/databases/driver/mysql/schema_migrator.rs b/packages/tracker-core/src/databases/driver/mysql/schema_migrator.rs new file mode 100644 index 000000000..a72b3feb6 --- /dev/null +++ b/packages/tracker-core/src/databases/driver/mysql/schema_migrator.rs @@ -0,0 +1,95 @@ +use async_trait::async_trait; + +use super::{Mysql, DRIVER}; +use crate::authentication::key::AUTH_KEY_LENGTH; +use crate::databases::error::Error; +use crate::databases::SchemaMigrator; + +#[async_trait] +impl SchemaMigrator for Mysql { + async fn create_database_tables(&self) -> Result<(), Error> { + let create_whitelist_table = " + CREATE TABLE IF NOT EXISTS whitelist ( + id integer PRIMARY KEY AUTO_INCREMENT, + info_hash VARCHAR(40) NOT NULL UNIQUE + );"; + + let create_torrents_table = " + CREATE TABLE IF NOT EXISTS torrents ( + id integer PRIMARY KEY AUTO_INCREMENT, + info_hash VARCHAR(40) NOT NULL UNIQUE, + completed INTEGER DEFAULT 0 NOT NULL + );"; + + let create_torrent_aggregate_metrics_table = " + CREATE TABLE IF NOT EXISTS torrent_aggregate_metrics ( + id integer PRIMARY KEY AUTO_INCREMENT, + metric_name VARCHAR(50) NOT NULL UNIQUE, + value INTEGER DEFAULT 0 NOT NULL + );"; + + let create_keys_table = format!( + " + CREATE TABLE IF NOT EXISTS `keys` ( + `id` INT NOT NULL AUTO_INCREMENT, + `key` VARCHAR({}) NOT NULL, + `valid_until` INT(10), + PRIMARY KEY (`id`), + UNIQUE (`key`) + );", + i8::try_from(AUTH_KEY_LENGTH).expect("authentication key length should fit within a i8!") + ); + + ::sqlx::query(create_torrents_table) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + ::sqlx::query(create_torrent_aggregate_metrics_table) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + ::sqlx::query(&create_keys_table) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + ::sqlx::query(create_whitelist_table) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + Ok(()) + } + + async fn drop_database_tables(&self) -> Result<(), Error> { + let drop_whitelist_table = " + DROP TABLE `whitelist`;"; + + let drop_torrents_table = " + DROP TABLE `torrents`;"; + + let drop_torrent_aggregate_metrics_table = " + DROP TABLE `torrent_aggregate_metrics`;"; + + let drop_keys_table = " + DROP TABLE `keys`;"; + + ::sqlx::query(drop_whitelist_table) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + ::sqlx::query(drop_torrents_table) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + ::sqlx::query(drop_torrent_aggregate_metrics_table) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + ::sqlx::query(drop_keys_table) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + Ok(()) + } +} diff --git a/packages/tracker-core/src/databases/driver/mysql/torrent_metrics_store.rs b/packages/tracker-core/src/databases/driver/mysql/torrent_metrics_store.rs new file mode 100644 index 000000000..1f8d7f436 --- /dev/null +++ b/packages/tracker-core/src/databases/driver/mysql/torrent_metrics_store.rs @@ -0,0 +1,105 @@ +use std::str::FromStr; + +use ::sqlx::Row; +use async_trait::async_trait; +use bittorrent_primitives::info_hash::InfoHash; +use torrust_tracker_primitives::{NumberOfDownloads, NumberOfDownloadsBTreeMap}; + +use super::{Mysql, DRIVER}; +use crate::databases::driver::TORRENTS_DOWNLOADS_TOTAL; +use crate::databases::error::Error; +use crate::databases::TorrentMetricsStore; + +#[async_trait] +impl TorrentMetricsStore for Mysql { + async fn load_all_torrents_downloads(&self) -> Result<NumberOfDownloadsBTreeMap, Error> { + let rows = ::sqlx::query("SELECT info_hash, completed FROM torrents") + .fetch_all(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + rows.into_iter() + .map(|row| { + let info_hash_value: String = row.try_get("info_hash").map_err(|e| (e, DRIVER))?; + let completed: i64 = row.try_get("completed").map_err(|e| (e, DRIVER))?; + let completed = u32::try_from(completed).map_err(|e| Error::MalformedDatabaseRecord { + message: e.to_string(), + driver: DRIVER, + })?; + + InfoHash::from_str(&info_hash_value) + .map(|info_hash| (info_hash, completed)) + .map_err(|e| Error::MalformedDatabaseRecord { + message: format!("{e:?}"), + driver: DRIVER, + }) + }) + .collect::<Result<Vec<_>, Error>>() + .map(|v| v.iter().copied().collect()) + } + + async fn load_torrent_downloads(&self, info_hash: &InfoHash) -> Result<Option<NumberOfDownloads>, Error> { + let maybe_row = ::sqlx::query("SELECT completed FROM torrents WHERE info_hash = ?") + .bind(info_hash.to_hex_string()) + .fetch_optional(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + maybe_row + .map(|row| { + let completed: i64 = row.try_get("completed").map_err(|e| (e, DRIVER))?; + u32::try_from(completed).map_err(|e| Error::MalformedDatabaseRecord { + message: e.to_string(), + driver: DRIVER, + }) + }) + .transpose() + } + + async fn save_torrent_downloads(&self, info_hash: &InfoHash, completed: u32) -> Result<(), Error> { + // `ON DUPLICATE KEY UPDATE` may legitimately report `rows_affected() == 0` + // when the row already exists with the same value (no-op update), so we + // do not treat 0 as a failure here. A real failure surfaces as `Err` + // from `execute()`. + ::sqlx::query( + "INSERT INTO torrents (info_hash, completed) VALUES (?, ?) ON DUPLICATE KEY UPDATE completed = VALUES(completed)", + ) + .bind(info_hash.to_string()) + .bind(i64::from(completed)) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + Ok(()) + } + + async fn increase_downloads_for_torrent(&self, info_hash: &InfoHash) -> Result<(), Error> { + ::sqlx::query("UPDATE torrents SET completed = completed + 1 WHERE info_hash = ?") + .bind(info_hash.to_string()) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + Ok(()) + } + + async fn load_global_downloads(&self) -> Result<Option<NumberOfDownloads>, Error> { + self.load_torrent_aggregate_metric(TORRENTS_DOWNLOADS_TOTAL).await + } + + async fn save_global_downloads(&self, downloaded: NumberOfDownloads) -> Result<(), Error> { + self.save_torrent_aggregate_metric(TORRENTS_DOWNLOADS_TOTAL, downloaded).await + } + + async fn increase_global_downloads(&self) -> Result<(), Error> { + let metric_name = TORRENTS_DOWNLOADS_TOTAL; + + ::sqlx::query("UPDATE torrent_aggregate_metrics SET value = value + 1 WHERE metric_name = ?") + .bind(metric_name) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + Ok(()) + } +} diff --git a/packages/tracker-core/src/databases/driver/mysql/whitelist_store.rs b/packages/tracker-core/src/databases/driver/mysql/whitelist_store.rs new file mode 100644 index 000000000..71c1ac7bd --- /dev/null +++ b/packages/tracker-core/src/databases/driver/mysql/whitelist_store.rs @@ -0,0 +1,88 @@ +use std::panic::Location; +use std::str::FromStr; + +use ::sqlx::Row; +use async_trait::async_trait; +use bittorrent_primitives::info_hash::InfoHash; + +use super::{Mysql, DRIVER}; +use crate::databases::error::Error; +use crate::databases::WhitelistStore; + +#[async_trait] +impl WhitelistStore for Mysql { + async fn load_whitelist(&self) -> Result<Vec<InfoHash>, Error> { + let rows = ::sqlx::query("SELECT info_hash FROM whitelist") + .fetch_all(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + rows.into_iter() + .map(|row| { + let value: String = row.try_get("info_hash").map_err(|e| (e, DRIVER))?; + InfoHash::from_str(&value).map_err(|e| Error::MalformedDatabaseRecord { + message: format!("{e:?}"), + driver: DRIVER, + }) + }) + .collect() + } + + async fn get_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result<Option<InfoHash>, Error> { + let maybe_row = ::sqlx::query("SELECT info_hash FROM whitelist WHERE info_hash = ?") + .bind(info_hash.to_hex_string()) + .fetch_optional(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + maybe_row + .map(|row| { + let value: String = row.try_get("info_hash").map_err(|e| (e, DRIVER))?; + InfoHash::from_str(&value).map_err(|e| Error::MalformedDatabaseRecord { + message: format!("{e:?}"), + driver: DRIVER, + }) + }) + .transpose() + } + + async fn add_info_hash_to_whitelist(&self, info_hash: InfoHash) -> Result<usize, Error> { + let insert = ::sqlx::query("INSERT INTO whitelist (info_hash) VALUES (?)") + .bind(info_hash.to_string()) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))? + .rows_affected(); + + if insert == 0 { + Err(Error::InsertFailed { + location: Location::caller(), + driver: DRIVER, + }) + } else { + usize::try_from(insert).map_err(|e| Error::MalformedDatabaseRecord { + message: format!("rows_affected does not fit in usize: {e}"), + driver: DRIVER, + }) + } + } + + async fn remove_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result<usize, Error> { + let deleted = ::sqlx::query("DELETE FROM whitelist WHERE info_hash = ?") + .bind(info_hash.to_string()) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))? + .rows_affected(); + + if deleted == 1 { + Ok(1) + } else { + Err(Error::DeleteFailed { + location: Location::caller(), + error_code: usize::try_from(deleted).unwrap_or(0), + driver: DRIVER, + }) + } + } +} diff --git a/packages/tracker-core/src/databases/driver/sqlite.rs b/packages/tracker-core/src/databases/driver/sqlite.rs deleted file mode 100644 index d08351aa8..000000000 --- a/packages/tracker-core/src/databases/driver/sqlite.rs +++ /dev/null @@ -1,437 +0,0 @@ -//! The `SQLite3` database driver. -//! -//! This module provides an implementation of the [`Database`] trait for -//! `SQLite3` using the `r2d2_sqlite` connection pool. It defines the schema for -//! whitelist, torrent metrics, and authentication keys, and provides methods -//! to create and drop tables as well as perform CRUD operations on these -//! persistent objects. -use std::panic::Location; -use std::str::FromStr; - -use bittorrent_primitives::info_hash::InfoHash; -use r2d2::Pool; -use r2d2_sqlite::rusqlite::params; -use r2d2_sqlite::rusqlite::types::Null; -use r2d2_sqlite::SqliteConnectionManager; -use torrust_tracker_primitives::{DurationSinceUnixEpoch, NumberOfDownloads, NumberOfDownloadsBTreeMap}; - -use super::{Database, Driver, Error, TORRENTS_DOWNLOADS_TOTAL}; -use crate::authentication::{self, Key}; - -const DRIVER: Driver = Driver::Sqlite3; - -/// `SQLite` driver implementation. -/// -/// This struct encapsulates a connection pool for `SQLite` using the `r2d2_sqlite` -/// connection manager. -pub(crate) struct Sqlite { - pool: Pool<SqliteConnectionManager>, -} - -impl Sqlite { - /// Instantiates a new `SQLite3` database driver. - /// - /// This function creates a connection manager for the `SQLite` database - /// located at `db_path` and then builds a connection pool using `r2d2`. If - /// the pool cannot be created, an error is returned (wrapped with the - /// appropriate driver information). - /// - /// # Arguments - /// - /// * `db_path` - A string slice representing the file path to the `SQLite` database. - /// - /// # Errors - /// - /// Returns an [`Error`] if the connection pool cannot be built. - pub fn new(db_path: &str) -> Result<Self, Error> { - let manager = SqliteConnectionManager::file(db_path); - let pool = r2d2::Pool::builder().build(manager).map_err(|e| (e, DRIVER))?; - - Ok(Self { pool }) - } - - fn load_torrent_aggregate_metric(&self, metric_name: &str) -> Result<Option<NumberOfDownloads>, Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let mut stmt = conn.prepare("SELECT value FROM torrent_aggregate_metrics WHERE metric_name = ?")?; - - let mut rows = stmt.query([metric_name])?; - - let persistent_torrent = rows.next()?; - - Ok(persistent_torrent.map(|f| { - let value: i64 = f.get(0).unwrap(); - u32::try_from(value).unwrap() - })) - } - - fn save_torrent_aggregate_metric(&self, metric_name: &str, completed: NumberOfDownloads) -> Result<(), Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let insert = conn.execute( - "INSERT INTO torrent_aggregate_metrics (metric_name, value) VALUES (?1, ?2) ON CONFLICT(metric_name) DO UPDATE SET value = ?2", - [metric_name.to_string(), completed.to_string()], - )?; - - if insert == 0 { - Err(Error::InsertFailed { - location: Location::caller(), - driver: DRIVER, - }) - } else { - Ok(()) - } - } -} - -impl Database for Sqlite { - /// Refer to [`databases::Database::create_database_tables`](crate::core::databases::Database::create_database_tables). - fn create_database_tables(&self) -> Result<(), Error> { - let create_whitelist_table = " - CREATE TABLE IF NOT EXISTS whitelist ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - info_hash TEXT NOT NULL UNIQUE - );" - .to_string(); - - let create_torrents_table = " - CREATE TABLE IF NOT EXISTS torrents ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - info_hash TEXT NOT NULL UNIQUE, - completed INTEGER DEFAULT 0 NOT NULL - );" - .to_string(); - - let create_torrent_aggregate_metrics_table = " - CREATE TABLE IF NOT EXISTS torrent_aggregate_metrics ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - metric_name TEXT NOT NULL UNIQUE, - value INTEGER DEFAULT 0 NOT NULL - );" - .to_string(); - - let create_keys_table = " - CREATE TABLE IF NOT EXISTS keys ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - key TEXT NOT NULL UNIQUE, - valid_until INTEGER - );" - .to_string(); - - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - conn.execute(&create_whitelist_table, [])?; - conn.execute(&create_keys_table, [])?; - conn.execute(&create_torrents_table, [])?; - conn.execute(&create_torrent_aggregate_metrics_table, [])?; - - Ok(()) - } - - /// Refer to [`databases::Database::drop_database_tables`](crate::core::databases::Database::drop_database_tables). - fn drop_database_tables(&self) -> Result<(), Error> { - let drop_whitelist_table = " - DROP TABLE whitelist;" - .to_string(); - - let drop_torrents_table = " - DROP TABLE torrents;" - .to_string(); - - let drop_keys_table = " - DROP TABLE keys;" - .to_string(); - - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - conn.execute(&drop_whitelist_table, []) - .and_then(|_| conn.execute(&drop_torrents_table, [])) - .and_then(|_| conn.execute(&drop_keys_table, []))?; - - Ok(()) - } - - /// Refer to [`databases::Database::load_persistent_torrents`](crate::core::databases::Database::load_persistent_torrents). - fn load_all_torrents_downloads(&self) -> Result<NumberOfDownloadsBTreeMap, Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let mut stmt = conn.prepare("SELECT info_hash, completed FROM torrents")?; - - let torrent_iter = stmt.query_map([], |row| { - let info_hash_string: String = row.get(0)?; - let info_hash = InfoHash::from_str(&info_hash_string).unwrap(); - let completed: u32 = row.get(1)?; - Ok((info_hash, completed)) - })?; - - Ok(torrent_iter.filter_map(std::result::Result::ok).collect()) - } - - /// Refer to [`databases::Database::load_persistent_torrent`](crate::core::databases::Database::load_persistent_torrent). - fn load_torrent_downloads(&self, info_hash: &InfoHash) -> Result<Option<NumberOfDownloads>, Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let mut stmt = conn.prepare("SELECT completed FROM torrents WHERE info_hash = ?")?; - - let mut rows = stmt.query([info_hash.to_hex_string()])?; - - let persistent_torrent = rows.next()?; - - Ok(persistent_torrent.map(|f| { - let completed: i64 = f.get(0).unwrap(); - u32::try_from(completed).unwrap() - })) - } - - /// Refer to [`databases::Database::save_persistent_torrent`](crate::core::databases::Database::save_persistent_torrent). - fn save_torrent_downloads(&self, info_hash: &InfoHash, completed: u32) -> Result<(), Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let insert = conn.execute( - "INSERT INTO torrents (info_hash, completed) VALUES (?1, ?2) ON CONFLICT(info_hash) DO UPDATE SET completed = ?2", - [info_hash.to_string(), completed.to_string()], - )?; - - if insert == 0 { - Err(Error::InsertFailed { - location: Location::caller(), - driver: DRIVER, - }) - } else { - Ok(()) - } - } - - /// Refer to [`databases::Database::increase_number_of_downloads`](crate::core::databases::Database::increase_number_of_downloads). - fn increase_downloads_for_torrent(&self, info_hash: &InfoHash) -> Result<(), Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let _ = conn.execute( - "UPDATE torrents SET completed = completed + 1 WHERE info_hash = ?", - [info_hash.to_string()], - )?; - - Ok(()) - } - - /// Refer to [`databases::Database::load_global_number_of_downloads`](crate::core::databases::Database::load_global_number_of_downloads). - fn load_global_downloads(&self) -> Result<Option<NumberOfDownloads>, Error> { - self.load_torrent_aggregate_metric(TORRENTS_DOWNLOADS_TOTAL) - } - - /// Refer to [`databases::Database::save_global_number_of_downloads`](crate::core::databases::Database::save_global_number_of_downloads). - fn save_global_downloads(&self, downloaded: NumberOfDownloads) -> Result<(), Error> { - self.save_torrent_aggregate_metric(TORRENTS_DOWNLOADS_TOTAL, downloaded) - } - - /// Refer to [`databases::Database::increase_global_number_of_downloads`](crate::core::databases::Database::increase_global_number_of_downloads). - fn increase_global_downloads(&self) -> Result<(), Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let metric_name = TORRENTS_DOWNLOADS_TOTAL; - - let _ = conn.execute( - "UPDATE torrent_aggregate_metrics SET value = value + 1 WHERE metric_name = ?", - [metric_name], - )?; - - Ok(()) - } - - /// Refer to [`databases::Database::load_keys`](crate::core::databases::Database::load_keys). - fn load_keys(&self) -> Result<Vec<authentication::PeerKey>, Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let mut stmt = conn.prepare("SELECT key, valid_until FROM keys")?; - - let keys_iter = stmt.query_map([], |row| { - let key: String = row.get(0)?; - let opt_valid_until: Option<i64> = row.get(1)?; - - match opt_valid_until { - Some(valid_until) => Ok(authentication::PeerKey { - key: key.parse::<Key>().unwrap(), - valid_until: Some(DurationSinceUnixEpoch::from_secs(valid_until.unsigned_abs())), - }), - None => Ok(authentication::PeerKey { - key: key.parse::<Key>().unwrap(), - valid_until: None, - }), - } - })?; - - let keys: Vec<authentication::PeerKey> = keys_iter.filter_map(std::result::Result::ok).collect(); - - Ok(keys) - } - - /// Refer to [`databases::Database::load_whitelist`](crate::core::databases::Database::load_whitelist). - fn load_whitelist(&self) -> Result<Vec<InfoHash>, Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let mut stmt = conn.prepare("SELECT info_hash FROM whitelist")?; - - let info_hash_iter = stmt.query_map([], |row| { - let info_hash: String = row.get(0)?; - - Ok(InfoHash::from_str(&info_hash).unwrap()) - })?; - - let info_hashes: Vec<InfoHash> = info_hash_iter.filter_map(std::result::Result::ok).collect(); - - Ok(info_hashes) - } - - /// Refer to [`databases::Database::get_info_hash_from_whitelist`](crate::core::databases::Database::get_info_hash_from_whitelist). - fn get_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result<Option<InfoHash>, Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let mut stmt = conn.prepare("SELECT info_hash FROM whitelist WHERE info_hash = ?")?; - - let mut rows = stmt.query([info_hash.to_hex_string()])?; - - let query = rows.next()?; - - Ok(query.map(|f| InfoHash::from_str(&f.get_unwrap::<_, String>(0)).unwrap())) - } - - /// Refer to [`databases::Database::add_info_hash_to_whitelist`](crate::core::databases::Database::add_info_hash_to_whitelist). - fn add_info_hash_to_whitelist(&self, info_hash: InfoHash) -> Result<usize, Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let insert = conn.execute("INSERT INTO whitelist (info_hash) VALUES (?)", [info_hash.to_string()])?; - - if insert == 0 { - Err(Error::InsertFailed { - location: Location::caller(), - driver: DRIVER, - }) - } else { - Ok(insert) - } - } - - /// Refer to [`databases::Database::remove_info_hash_from_whitelist`](crate::core::databases::Database::remove_info_hash_from_whitelist). - fn remove_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result<usize, Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let deleted = conn.execute("DELETE FROM whitelist WHERE info_hash = ?", [info_hash.to_string()])?; - - if deleted == 1 { - // should only remove a single record. - Ok(deleted) - } else { - Err(Error::DeleteFailed { - location: Location::caller(), - error_code: deleted, - driver: DRIVER, - }) - } - } - - /// Refer to [`databases::Database::get_key_from_keys`](crate::core::databases::Database::get_key_from_keys). - fn get_key_from_keys(&self, key: &Key) -> Result<Option<authentication::PeerKey>, Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let mut stmt = conn.prepare("SELECT key, valid_until FROM keys WHERE key = ?")?; - - let mut rows = stmt.query([key.to_string()])?; - - let key = rows.next()?; - - Ok(key.map(|f| { - let valid_until: Option<i64> = f.get(1).unwrap(); - let key: String = f.get(0).unwrap(); - - match valid_until { - Some(valid_until) => authentication::PeerKey { - key: key.parse::<Key>().unwrap(), - valid_until: Some(DurationSinceUnixEpoch::from_secs(valid_until.unsigned_abs())), - }, - None => authentication::PeerKey { - key: key.parse::<Key>().unwrap(), - valid_until: None, - }, - } - })) - } - - /// Refer to [`databases::Database::add_key_to_keys`](crate::core::databases::Database::add_key_to_keys). - fn add_key_to_keys(&self, auth_key: &authentication::PeerKey) -> Result<usize, Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let insert = match auth_key.valid_until { - Some(valid_until) => conn.execute( - "INSERT INTO keys (key, valid_until) VALUES (?1, ?2)", - [auth_key.key.to_string(), valid_until.as_secs().to_string()], - )?, - None => conn.execute( - "INSERT INTO keys (key, valid_until) VALUES (?1, ?2)", - params![auth_key.key.to_string(), Null], - )?, - }; - - if insert == 0 { - Err(Error::InsertFailed { - location: Location::caller(), - driver: DRIVER, - }) - } else { - Ok(insert) - } - } - - /// Refer to [`databases::Database::remove_key_from_keys`](crate::core::databases::Database::remove_key_from_keys). - fn remove_key_from_keys(&self, key: &Key) -> Result<usize, Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let deleted = conn.execute("DELETE FROM keys WHERE key = ?", [key.to_string()])?; - - if deleted == 1 { - // should only remove a single record. - Ok(deleted) - } else { - Err(Error::DeleteFailed { - location: Location::caller(), - error_code: deleted, - driver: DRIVER, - }) - } - } -} - -#[cfg(test)] -mod tests { - - use std::sync::Arc; - - use torrust_tracker_configuration::Core; - use torrust_tracker_test_helpers::configuration::ephemeral_sqlite_database; - - use crate::databases::driver::sqlite::Sqlite; - use crate::databases::driver::tests::run_tests; - use crate::databases::Database; - - fn ephemeral_configuration() -> Core { - let mut config = Core::default(); - let temp_file = ephemeral_sqlite_database(); - temp_file.to_str().unwrap().clone_into(&mut config.database.path); - config - } - - fn initialize_driver(config: &Core) -> Arc<Box<dyn Database>> { - let driver: Arc<Box<dyn Database>> = Arc::new(Box::new(Sqlite::new(&config.database.path).unwrap())); - driver - } - - #[tokio::test] - async fn run_sqlite_driver_tests() -> Result<(), Box<dyn std::error::Error + 'static>> { - let config = ephemeral_configuration(); - - let driver = initialize_driver(&config); - - run_tests(&driver).await; - - Ok(()) - } -} diff --git a/packages/tracker-core/src/databases/driver/sqlite/auth_key_store.rs b/packages/tracker-core/src/databases/driver/sqlite/auth_key_store.rs new file mode 100644 index 000000000..f94770842 --- /dev/null +++ b/packages/tracker-core/src/databases/driver/sqlite/auth_key_store.rs @@ -0,0 +1,128 @@ +use std::panic::Location; + +use ::sqlx::Row; +use async_trait::async_trait; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use super::{Sqlite, DRIVER}; +use crate::authentication::{self, Key}; +use crate::databases::error::Error; +use crate::databases::AuthKeyStore; + +#[async_trait] +impl AuthKeyStore for Sqlite { + async fn load_keys(&self) -> Result<Vec<authentication::PeerKey>, Error> { + let rows = ::sqlx::query("SELECT key, valid_until FROM keys") + .fetch_all(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + rows.into_iter() + .map(|row| { + let key_value: String = row.try_get("key").map_err(|e| (e, DRIVER))?; + let valid_until: Option<i64> = row.try_get("valid_until").map_err(|e| (e, DRIVER))?; + + let parsed_key = key_value.parse::<Key>().map_err(|e| Error::MalformedDatabaseRecord { + message: e.to_string(), + driver: DRIVER, + })?; + + Ok(authentication::PeerKey { + key: parsed_key, + valid_until: valid_until.map(parse_valid_until).transpose()?, + }) + }) + .collect() + } + + async fn get_key_from_keys(&self, key: &Key) -> Result<Option<authentication::PeerKey>, Error> { + let maybe_row = ::sqlx::query("SELECT key, valid_until FROM keys WHERE key = ?1") + .bind(key.to_string()) + .fetch_optional(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + maybe_row + .map(|row| { + let key_value: String = row.try_get("key").map_err(|e| (e, DRIVER))?; + let valid_until: Option<i64> = row.try_get("valid_until").map_err(|e| (e, DRIVER))?; + + let parsed_key = key_value.parse::<Key>().map_err(|e| Error::MalformedDatabaseRecord { + message: e.to_string(), + driver: DRIVER, + })?; + + Ok(authentication::PeerKey { + key: parsed_key, + valid_until: valid_until.map(parse_valid_until).transpose()?, + }) + }) + .transpose() + } + + async fn add_key_to_keys(&self, auth_key: &authentication::PeerKey) -> Result<usize, Error> { + let valid_until = auth_key + .valid_until + .map(|value| { + i64::try_from(value.as_secs()).map_err(|e| Error::MalformedDatabaseRecord { + message: e.to_string(), + driver: DRIVER, + }) + }) + .transpose()?; + + let insert = ::sqlx::query("INSERT INTO keys (key, valid_until) VALUES (?1, ?2)") + .bind(auth_key.key.to_string()) + .bind(valid_until) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))? + .rows_affected(); + + if insert == 0 { + Err(Error::InsertFailed { + location: Location::caller(), + driver: DRIVER, + }) + } else { + usize::try_from(insert).map_err(|e| Error::MalformedDatabaseRecord { + message: format!("rows_affected does not fit in usize: {e}"), + driver: DRIVER, + }) + } + } + + async fn remove_key_from_keys(&self, key: &Key) -> Result<usize, Error> { + let deleted = ::sqlx::query("DELETE FROM keys WHERE key = ?1") + .bind(key.to_string()) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))? + .rows_affected(); + + if deleted == 1 { + // should only remove a single record. + Ok(1) + } else { + Err(Error::DeleteFailed { + location: Location::caller(), + error_code: usize::try_from(deleted).unwrap_or(0), + driver: DRIVER, + }) + } + } +} + +/// Convert a signed seconds value loaded from the database into a +/// [`DurationSinceUnixEpoch`]. +/// +/// Negative values indicate a corrupted record (timestamps before the Unix +/// epoch are not representable) and are rejected as +/// [`Error::MalformedDatabaseRecord`]. +fn parse_valid_until(value: i64) -> Result<DurationSinceUnixEpoch, Error> { + let secs = u64::try_from(value).map_err(|_| Error::MalformedDatabaseRecord { + message: format!("negative valid_until timestamp: {value}"), + driver: DRIVER, + })?; + Ok(DurationSinceUnixEpoch::from_secs(secs)) +} diff --git a/packages/tracker-core/src/databases/driver/sqlite/mod.rs b/packages/tracker-core/src/databases/driver/sqlite/mod.rs new file mode 100644 index 000000000..d6a10d818 --- /dev/null +++ b/packages/tracker-core/src/databases/driver/sqlite/mod.rs @@ -0,0 +1,108 @@ +//! The `SQLite3` database driver. +use ::sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use ::sqlx::{Row, SqlitePool}; +use torrust_tracker_primitives::NumberOfDownloads; + +use super::{Driver, Error}; + +mod auth_key_store; +mod schema_migrator; +mod torrent_metrics_store; +mod whitelist_store; + +const DRIVER: Driver = Driver::Sqlite3; + +/// `SQLite` driver implementation. +/// +/// This struct encapsulates an async `sqlx` connection pool for `SQLite`. +pub(crate) struct Sqlite { + pool: SqlitePool, +} + +impl Sqlite { + /// Instantiates a new `SQLite3` database driver. + /// + // Keep the `Result` return for API symmetry with the MySQL driver and + // forward-compatibility (future option parsing may surface fallible cases). + #[allow(clippy::unnecessary_wraps)] + pub fn new(db_path: &str) -> Result<Self, Error> { + // Build the connection options directly from the filesystem path so + // relative paths (e.g. `./storage/...`) are preserved verbatim instead + // of being parsed as the authority component of a `sqlite://` URL. + let options = SqliteConnectOptions::new().filename(db_path).create_if_missing(true); + + let pool = SqlitePoolOptions::new().connect_lazy_with(options); + + Ok(Self { pool }) + } + + async fn load_torrent_aggregate_metric(&self, metric_name: &str) -> Result<Option<NumberOfDownloads>, Error> { + let maybe_row = ::sqlx::query("SELECT value FROM torrent_aggregate_metrics WHERE metric_name = ?1") + .bind(metric_name) + .fetch_optional(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + maybe_row + .map(|row| { + let value: i64 = row.try_get("value").map_err(|e| (e, DRIVER))?; + u32::try_from(value).map_err(|e| Error::MalformedDatabaseRecord { + message: e.to_string(), + driver: DRIVER, + }) + }) + .transpose() + } + + async fn save_torrent_aggregate_metric(&self, metric_name: &str, completed: NumberOfDownloads) -> Result<(), Error> { + // `ON CONFLICT ... DO UPDATE` may legitimately report `rows_affected() == 0` + // when the row already exists with the same value (no-op update), so we + // do not treat 0 as a failure here. A real failure surfaces as `Err` + // from `execute()`. + ::sqlx::query( + "INSERT INTO torrent_aggregate_metrics (metric_name, value) VALUES (?1, ?2) ON CONFLICT(metric_name) DO UPDATE SET value = ?2", + ) + .bind(metric_name) + .bind(i64::from(completed)) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + + use std::sync::Arc; + + use torrust_tracker_configuration::Core; + use torrust_tracker_test_helpers::configuration::ephemeral_sqlite_database; + + use crate::databases::driver::sqlite::Sqlite; + use crate::databases::driver::tests::run_tests; + use crate::databases::traits::Database; + + fn ephemeral_configuration() -> Core { + let mut config = Core::default(); + let temp_file = ephemeral_sqlite_database(); + temp_file.to_str().unwrap().clone_into(&mut config.database.path); + config + } + + fn initialize_driver(config: &Core) -> Arc<Box<dyn Database>> { + Arc::new(Box::new(Sqlite::new(&config.database.path).unwrap())) + } + + #[tokio::test] + async fn run_sqlite_driver_tests() -> Result<(), Box<dyn std::error::Error + 'static>> { + let config = ephemeral_configuration(); + + let driver = initialize_driver(&config); + + run_tests(&driver).await; + + Ok(()) + } +} diff --git a/packages/tracker-core/src/databases/driver/sqlite/schema_migrator.rs b/packages/tracker-core/src/databases/driver/sqlite/schema_migrator.rs new file mode 100644 index 000000000..33bed3d4f --- /dev/null +++ b/packages/tracker-core/src/databases/driver/sqlite/schema_migrator.rs @@ -0,0 +1,89 @@ +use async_trait::async_trait; + +use super::{Sqlite, DRIVER}; +use crate::databases::error::Error; +use crate::databases::SchemaMigrator; + +#[async_trait] +impl SchemaMigrator for Sqlite { + async fn create_database_tables(&self) -> Result<(), Error> { + let create_whitelist_table = " + CREATE TABLE IF NOT EXISTS whitelist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + info_hash TEXT NOT NULL UNIQUE + );"; + + let create_torrents_table = " + CREATE TABLE IF NOT EXISTS torrents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + info_hash TEXT NOT NULL UNIQUE, + completed INTEGER DEFAULT 0 NOT NULL + );"; + + let create_torrent_aggregate_metrics_table = " + CREATE TABLE IF NOT EXISTS torrent_aggregate_metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + metric_name TEXT NOT NULL UNIQUE, + value INTEGER DEFAULT 0 NOT NULL + );"; + + let create_keys_table = " + CREATE TABLE IF NOT EXISTS keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + valid_until INTEGER + );"; + + ::sqlx::query(create_whitelist_table) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + ::sqlx::query(create_keys_table) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + ::sqlx::query(create_torrents_table) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + ::sqlx::query(create_torrent_aggregate_metrics_table) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + Ok(()) + } + + async fn drop_database_tables(&self) -> Result<(), Error> { + let drop_whitelist_table = " + DROP TABLE whitelist;"; + + let drop_torrents_table = " + DROP TABLE torrents;"; + + let drop_torrent_aggregate_metrics_table = " + DROP TABLE torrent_aggregate_metrics;"; + + let drop_keys_table = " + DROP TABLE keys;"; + + ::sqlx::query(drop_whitelist_table) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + ::sqlx::query(drop_torrents_table) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + ::sqlx::query(drop_torrent_aggregate_metrics_table) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + ::sqlx::query(drop_keys_table) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + Ok(()) + } +} diff --git a/packages/tracker-core/src/databases/driver/sqlite/torrent_metrics_store.rs b/packages/tracker-core/src/databases/driver/sqlite/torrent_metrics_store.rs new file mode 100644 index 000000000..b8df34fb1 --- /dev/null +++ b/packages/tracker-core/src/databases/driver/sqlite/torrent_metrics_store.rs @@ -0,0 +1,105 @@ +use std::str::FromStr; + +use ::sqlx::Row; +use async_trait::async_trait; +use bittorrent_primitives::info_hash::InfoHash; +use torrust_tracker_primitives::{NumberOfDownloads, NumberOfDownloadsBTreeMap}; + +use super::{Sqlite, DRIVER}; +use crate::databases::driver::TORRENTS_DOWNLOADS_TOTAL; +use crate::databases::error::Error; +use crate::databases::TorrentMetricsStore; + +#[async_trait] +impl TorrentMetricsStore for Sqlite { + async fn load_all_torrents_downloads(&self) -> Result<NumberOfDownloadsBTreeMap, Error> { + let rows = ::sqlx::query("SELECT info_hash, completed FROM torrents") + .fetch_all(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + rows.into_iter() + .map(|row| { + let info_hash_value: String = row.try_get("info_hash").map_err(|e| (e, DRIVER))?; + let completed: i64 = row.try_get("completed").map_err(|e| (e, DRIVER))?; + let completed = u32::try_from(completed).map_err(|e| Error::MalformedDatabaseRecord { + message: e.to_string(), + driver: DRIVER, + })?; + + InfoHash::from_str(&info_hash_value) + .map(|info_hash| (info_hash, completed)) + .map_err(|e| Error::MalformedDatabaseRecord { + message: format!("{e:?}"), + driver: DRIVER, + }) + }) + .collect::<Result<Vec<_>, Error>>() + .map(|v| v.iter().copied().collect()) + } + + async fn load_torrent_downloads(&self, info_hash: &InfoHash) -> Result<Option<NumberOfDownloads>, Error> { + let maybe_row = ::sqlx::query("SELECT completed FROM torrents WHERE info_hash = ?1") + .bind(info_hash.to_hex_string()) + .fetch_optional(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + maybe_row + .map(|row| { + let completed: i64 = row.try_get("completed").map_err(|e| (e, DRIVER))?; + u32::try_from(completed).map_err(|e| Error::MalformedDatabaseRecord { + message: e.to_string(), + driver: DRIVER, + }) + }) + .transpose() + } + + async fn save_torrent_downloads(&self, info_hash: &InfoHash, completed: u32) -> Result<(), Error> { + // `ON CONFLICT ... DO UPDATE` may legitimately report `rows_affected() == 0` + // when the row already exists with the same value (no-op update), so we + // do not treat 0 as a failure here. A real failure surfaces as `Err` + // from `execute()`. + ::sqlx::query( + "INSERT INTO torrents (info_hash, completed) VALUES (?1, ?2) ON CONFLICT(info_hash) DO UPDATE SET completed = ?2", + ) + .bind(info_hash.to_string()) + .bind(i64::from(completed)) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + Ok(()) + } + + async fn increase_downloads_for_torrent(&self, info_hash: &InfoHash) -> Result<(), Error> { + ::sqlx::query("UPDATE torrents SET completed = completed + 1 WHERE info_hash = ?1") + .bind(info_hash.to_string()) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + Ok(()) + } + + async fn load_global_downloads(&self) -> Result<Option<NumberOfDownloads>, Error> { + self.load_torrent_aggregate_metric(TORRENTS_DOWNLOADS_TOTAL).await + } + + async fn save_global_downloads(&self, downloaded: NumberOfDownloads) -> Result<(), Error> { + self.save_torrent_aggregate_metric(TORRENTS_DOWNLOADS_TOTAL, downloaded).await + } + + async fn increase_global_downloads(&self) -> Result<(), Error> { + let metric_name = TORRENTS_DOWNLOADS_TOTAL; + + ::sqlx::query("UPDATE torrent_aggregate_metrics SET value = value + 1 WHERE metric_name = ?1") + .bind(metric_name) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + Ok(()) + } +} diff --git a/packages/tracker-core/src/databases/driver/sqlite/whitelist_store.rs b/packages/tracker-core/src/databases/driver/sqlite/whitelist_store.rs new file mode 100644 index 000000000..263eae2fb --- /dev/null +++ b/packages/tracker-core/src/databases/driver/sqlite/whitelist_store.rs @@ -0,0 +1,89 @@ +use std::panic::Location; +use std::str::FromStr; + +use ::sqlx::Row; +use async_trait::async_trait; +use bittorrent_primitives::info_hash::InfoHash; + +use super::{Sqlite, DRIVER}; +use crate::databases::error::Error; +use crate::databases::WhitelistStore; + +#[async_trait] +impl WhitelistStore for Sqlite { + async fn load_whitelist(&self) -> Result<Vec<InfoHash>, Error> { + let rows = ::sqlx::query("SELECT info_hash FROM whitelist") + .fetch_all(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + rows.into_iter() + .map(|row| { + let value: String = row.try_get("info_hash").map_err(|e| (e, DRIVER))?; + InfoHash::from_str(&value).map_err(|e| Error::MalformedDatabaseRecord { + message: format!("{e:?}"), + driver: DRIVER, + }) + }) + .collect() + } + + async fn get_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result<Option<InfoHash>, Error> { + let maybe_row = ::sqlx::query("SELECT info_hash FROM whitelist WHERE info_hash = ?1") + .bind(info_hash.to_hex_string()) + .fetch_optional(&self.pool) + .await + .map_err(|e| (e, DRIVER))?; + + maybe_row + .map(|row| { + let value: String = row.try_get("info_hash").map_err(|e| (e, DRIVER))?; + InfoHash::from_str(&value).map_err(|e| Error::MalformedDatabaseRecord { + message: format!("{e:?}"), + driver: DRIVER, + }) + }) + .transpose() + } + + async fn add_info_hash_to_whitelist(&self, info_hash: InfoHash) -> Result<usize, Error> { + let insert = ::sqlx::query("INSERT INTO whitelist (info_hash) VALUES (?1)") + .bind(info_hash.to_string()) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))? + .rows_affected(); + + if insert == 0 { + Err(Error::InsertFailed { + location: Location::caller(), + driver: DRIVER, + }) + } else { + usize::try_from(insert).map_err(|e| Error::MalformedDatabaseRecord { + message: format!("rows_affected does not fit in usize: {e}"), + driver: DRIVER, + }) + } + } + + async fn remove_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result<usize, Error> { + let deleted = ::sqlx::query("DELETE FROM whitelist WHERE info_hash = ?1") + .bind(info_hash.to_string()) + .execute(&self.pool) + .await + .map_err(|e| (e, DRIVER))? + .rows_affected(); + + if deleted == 1 { + // should only remove a single record. + Ok(1) + } else { + Err(Error::DeleteFailed { + location: Location::caller(), + error_code: usize::try_from(deleted).unwrap_or(0), + driver: DRIVER, + }) + } + } +} diff --git a/packages/tracker-core/src/databases/error.rs b/packages/tracker-core/src/databases/error.rs index 2df2cb277..427270c65 100644 --- a/packages/tracker-core/src/databases/error.rs +++ b/packages/tracker-core/src/databases/error.rs @@ -6,13 +6,13 @@ //! creation errors. Each error variant includes contextual information such as //! the associated database driver and, when applicable, the source error. //! -//! External errors from database libraries (e.g., `rusqlite`, `mysql`) are -//! converted into this error type using the provided `From` implementations. +//! External errors from the `sqlx` database library are converted into this +//! error type using the provided `From` implementations. use std::panic::Location; use std::sync::Arc; -use r2d2_mysql::mysql::UrlError; -use torrust_tracker_located_error::{DynError, Located, LocatedError}; +use sqlx::Error as SqlxError; +use torrust_tracker_located_error::{DynError, LocatedError}; use super::driver::Driver; @@ -69,104 +69,71 @@ pub enum Error { driver: Driver, }, + /// Indicates that a row read from the database contains a malformed value + /// (e.g., a corrupt or manually-edited `info_hash` or key string that + /// cannot be parsed into the expected domain type). + #[error("Malformed {driver} database record: {message}")] + MalformedDatabaseRecord { message: String, driver: Driver }, + /// Indicates a failure to connect to the database. /// - /// This error variant wraps connection-related errors, such as those caused by an invalid URL. + /// This error variant wraps connection-related errors, such as pool + /// timeouts, TLS failures, or invalid URL errors. #[error("Failed to connect to {driver} database: {source}")] ConnectionError { - source: LocatedError<'static, UrlError>, - driver: Driver, - }, - - /// Indicates a failure to create a connection pool. - /// - /// This error variant is used when the connection pool creation (using r2d2) fails. - #[error("Failed to create r2d2 {driver} connection pool: {source}")] - ConnectionPool { - source: LocatedError<'static, r2d2::Error>, + source: LocatedError<'static, dyn std::error::Error + Send + Sync>, driver: Driver, }, } -impl From<r2d2_sqlite::rusqlite::Error> for Error { +impl From<(SqlxError, Driver)> for Error { #[track_caller] - fn from(err: r2d2_sqlite::rusqlite::Error) -> Self { + fn from(value: (SqlxError, Driver)) -> Self { + let (err, driver) = value; + match err { - r2d2_sqlite::rusqlite::Error::QueryReturnedNoRows => Error::QueryReturnedNoRows { + SqlxError::RowNotFound => Self::QueryReturnedNoRows { + source: (Arc::new(SqlxError::RowNotFound) as DynError).into(), + driver, + }, + SqlxError::Io(_) + | SqlxError::Tls(_) + | SqlxError::PoolTimedOut + | SqlxError::PoolClosed + | SqlxError::WorkerCrashed + | SqlxError::Configuration(_) => Self::ConnectionError { source: (Arc::new(err) as DynError).into(), - driver: Driver::Sqlite3, + driver, }, - _ => Error::InvalidQuery { + _ => Self::InvalidQuery { source: (Arc::new(err) as DynError).into(), - driver: Driver::Sqlite3, + driver, }, } } } -impl From<r2d2_mysql::mysql::Error> for Error { - #[track_caller] - fn from(err: r2d2_mysql::mysql::Error) -> Self { - let e: DynError = Arc::new(err); - Error::InvalidQuery { - source: e.into(), - driver: Driver::MySQL, - } - } -} - -impl From<UrlError> for Error { - #[track_caller] - fn from(err: UrlError) -> Self { - Self::ConnectionError { - source: Located(err).into(), - driver: Driver::MySQL, - } - } -} - -impl From<(r2d2::Error, Driver)> for Error { - #[track_caller] - fn from(e: (r2d2::Error, Driver)) -> Self { - let (err, driver) = e; - Self::ConnectionPool { - source: Located(err).into(), - driver, - } - } -} - #[cfg(test)] mod tests { - use r2d2_mysql::mysql; - + use crate::databases::driver::Driver; use crate::databases::error::Error; #[test] - fn it_should_build_a_database_error_from_a_rusqlite_error() { - let err: Error = r2d2_sqlite::rusqlite::Error::InvalidQuery.into(); - - assert!(matches!(err, Error::InvalidQuery { .. })); - } - - #[test] - fn it_should_build_an_specific_database_error_from_a_no_rows_returned_rusqlite_error() { - let err: Error = r2d2_sqlite::rusqlite::Error::QueryReturnedNoRows.into(); + fn it_should_build_a_database_error_from_a_sqlx_row_not_found_error() { + let err: Error = (sqlx::Error::RowNotFound, Driver::Sqlite3).into(); assert!(matches!(err, Error::QueryReturnedNoRows { .. })); } #[test] - fn it_should_build_a_database_error_from_a_mysql_error() { - let url_err = mysql::error::UrlError::BadUrl; - let err: Error = r2d2_mysql::mysql::Error::UrlError(url_err).into(); - - assert!(matches!(err, Error::InvalidQuery { .. })); - } - - #[test] - fn it_should_build_a_database_error_from_a_mysql_url_error() { - let err: Error = mysql::error::UrlError::BadUrl.into(); + fn it_should_build_a_database_error_from_a_sqlx_io_error() { + use std::io; + + let err: Error = ( + sqlx::Error::Io(io::Error::from(io::ErrorKind::ConnectionRefused)), + Driver::MySQL, + ) + .into(); assert!(matches!(err, Error::ConnectionError { .. })); } diff --git a/packages/tracker-core/src/databases/mod.rs b/packages/tracker-core/src/databases/mod.rs index c9d89769a..0742c5481 100644 --- a/packages/tracker-core/src/databases/mod.rs +++ b/packages/tracker-core/src/databases/mod.rs @@ -1,8 +1,19 @@ //! The persistence module. //! -//! Persistence is currently implemented using a single [`Database`] trait. +//! Persistence is implemented through four narrow context traits and an +//! aggregate supertrait: //! -//! There are two implementations of the trait (two drivers): +//! - [`SchemaMigrator`] — schema lifecycle (create / drop tables) +//! - [`TorrentMetricsStore`] — per-torrent and global download counters +//! - [`WhitelistStore`] — torrent infohash whitelist +//! - [`AuthKeyStore`] — authentication key persistence +//! - [`Database`] — aggregate supertrait; any type that implements all four +//! narrow traits automatically satisfies `Database` via a blanket impl +//! +//! Design rationale: see ADR +//! [`20260429000000_keep_database_as_aggregate_supertrait`](../../../docs/adrs/20260429000000_keep_database_as_aggregate_supertrait.md). +//! +//! There are two implementations (two drivers): //! //! - **`MySQL`** //! - **`Sqlite`** @@ -49,224 +60,9 @@ pub mod driver; pub mod error; pub mod setup; +pub mod traits; -use bittorrent_primitives::info_hash::InfoHash; -use mockall::automock; -use torrust_tracker_primitives::{NumberOfDownloads, NumberOfDownloadsBTreeMap}; - -use self::error::Error; -use crate::authentication::{self, Key}; - -/// The persistence trait. -/// -/// This trait defines all the methods required to interact with the database, -/// including creating and dropping schema tables, and CRUD operations for -/// torrent metrics, whitelists, and authentication keys. Implementations of -/// this trait must ensure that operations are safe, consistent, and report -/// errors using the [`Error`] type. -#[automock] -pub trait Database: Sync + Send { - /// Creates the necessary database tables. - /// - /// The SQL queries for table creation are hardcoded in the trait implementation. - /// - /// # Context: Schema - /// - /// # Errors - /// - /// Returns an [`Error`] if the tables cannot be created. - fn create_database_tables(&self) -> Result<(), Error>; - - /// Drops the database tables. - /// - /// This operation removes the persistent schema. - /// - /// # Context: Schema - /// - /// # Errors - /// - /// Returns an [`Error`] if the tables cannot be dropped. - fn drop_database_tables(&self) -> Result<(), Error>; - - // Torrent Metrics - - /// Loads torrent metrics data from the database for all torrents. - /// - /// This function returns the persistent torrent metrics as a collection of - /// tuples, where each tuple contains an [`InfoHash`] and the `downloaded` - /// counter (i.e. the number of times the torrent has been downloaded). - /// - /// # Context: Torrent Metrics - /// - /// # Errors - /// - /// Returns an [`Error`] if the metrics cannot be loaded. - fn load_all_torrents_downloads(&self) -> Result<NumberOfDownloadsBTreeMap, Error>; - - /// Loads torrent metrics data from the database for one torrent. - /// - /// # Context: Torrent Metrics - /// - /// # Errors - /// - /// Returns an [`Error`] if the metrics cannot be loaded. - fn load_torrent_downloads(&self, info_hash: &InfoHash) -> Result<Option<NumberOfDownloads>, Error>; - - /// Saves torrent metrics data into the database. - /// - /// # Arguments - /// - /// * `info_hash` - A reference to the torrent's info hash. - /// * `downloaded` - The number of times the torrent has been downloaded. - /// - /// # Context: Torrent Metrics - /// - /// # Errors - /// - /// Returns an [`Error`] if the metrics cannot be saved. - fn save_torrent_downloads(&self, info_hash: &InfoHash, downloaded: u32) -> Result<(), Error>; - - /// Increases the number of downloads for a given torrent. - /// - /// It does not create a new entry if the torrent is not found and it does - /// not return an error. - /// - /// # Context: Torrent Metrics - /// - /// # Arguments - /// - /// * `info_hash` - A reference to the torrent's info hash. - /// - /// # Errors - /// - /// Returns an [`Error`] if the query failed. - fn increase_downloads_for_torrent(&self, info_hash: &InfoHash) -> Result<(), Error>; - - /// Loads the total number of downloads for all torrents from the database. - /// - /// # Context: Torrent Metrics - /// - /// # Errors - /// - /// Returns an [`Error`] if the total downloads cannot be loaded. - fn load_global_downloads(&self) -> Result<Option<NumberOfDownloads>, Error>; - - /// Saves the total number of downloads for all torrents into the database. - /// - /// # Context: Torrent Metrics - /// - /// # Arguments - /// - /// * `info_hash` - A reference to the torrent's info hash. - /// * `downloaded` - The number of times the torrent has been downloaded. - /// - /// # Errors - /// - /// Returns an [`Error`] if the total downloads cannot be saved. - fn save_global_downloads(&self, downloaded: NumberOfDownloads) -> Result<(), Error>; - - /// Increases the total number of downloads for all torrents. - /// - /// # Context: Torrent Metrics - /// - /// # Errors - /// - /// Returns an [`Error`] if the query failed. - fn increase_global_downloads(&self) -> Result<(), Error>; - - // Whitelist - - /// Loads the whitelisted torrents from the database. - /// - /// # Context: Whitelist - /// - /// # Errors - /// - /// Returns an [`Error`] if the whitelist cannot be loaded. - fn load_whitelist(&self) -> Result<Vec<InfoHash>, Error>; - - /// Retrieves a whitelisted torrent from the database. - /// - /// Returns `Some(InfoHash)` if the torrent is in the whitelist, or `None` - /// otherwise. - /// - /// # Context: Whitelist - /// - /// # Errors - /// - /// Returns an [`Error`] if the whitelist cannot be queried. - fn get_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result<Option<InfoHash>, Error>; - - /// Adds a torrent to the whitelist. - /// - /// # Context: Whitelist - /// - /// # Errors - /// - /// Returns an [`Error`] if the torrent cannot be added to the whitelist. - fn add_info_hash_to_whitelist(&self, info_hash: InfoHash) -> Result<usize, Error>; - - /// Checks whether a torrent is whitelisted. - /// - /// This default implementation returns `true` if the infohash is included - /// in the whitelist, or `false` otherwise. - /// - /// # Context: Whitelist - /// - /// # Errors - /// - /// Returns an [`Error`] if the whitelist cannot be queried. - fn is_info_hash_whitelisted(&self, info_hash: InfoHash) -> Result<bool, Error> { - Ok(self.get_info_hash_from_whitelist(info_hash)?.is_some()) - } - - /// Removes a torrent from the whitelist. - /// - /// # Context: Whitelist - /// - /// # Errors - /// - /// Returns an [`Error`] if the torrent cannot be removed from the whitelist. - fn remove_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result<usize, Error>; - - // Authentication keys - - /// Loads all authentication keys from the database. - /// - /// # Context: Authentication Keys - /// - /// # Errors - /// - /// Returns an [`Error`] if the keys cannot be loaded. - fn load_keys(&self) -> Result<Vec<authentication::PeerKey>, Error>; - - /// Retrieves a specific authentication key from the database. - /// - /// Returns `Some(PeerKey)` if a key corresponding to the provided [`Key`] - /// exists, or `None` otherwise. - /// - /// # Context: Authentication Keys - /// - /// # Errors - /// - /// Returns an [`Error`] if the key cannot be queried. - fn get_key_from_keys(&self, key: &Key) -> Result<Option<authentication::PeerKey>, Error>; - - /// Adds an authentication key to the database. - /// - /// # Context: Authentication Keys - /// - /// # Errors - /// - /// Returns an [`Error`] if the key cannot be saved. - fn add_key_to_keys(&self, auth_key: &authentication::PeerKey) -> Result<usize, Error>; - - /// Removes an authentication key from the database. - /// - /// # Context: Authentication Keys - /// - /// # Errors - /// - /// Returns an [`Error`] if the key cannot be removed. - fn remove_key_from_keys(&self, key: &Key) -> Result<usize, Error>; -} +pub use traits::{ + AuthKeyStore, MockAuthKeyStore, MockSchemaMigrator, MockTorrentMetricsStore, MockWhitelistStore, SchemaMigrator, + TorrentMetricsStore, WhitelistStore, +}; diff --git a/packages/tracker-core/src/databases/setup.rs b/packages/tracker-core/src/databases/setup.rs index 6ba9f2a64..c09a754e3 100644 --- a/packages/tracker-core/src/databases/setup.rs +++ b/packages/tracker-core/src/databases/setup.rs @@ -1,29 +1,77 @@ //! This module provides functionality for setting up databases. +//! +//! For the persistence trait boundary and wiring rationale, see ADR +//! [`20260429000000_keep_database_as_aggregate_supertrait`](../../../docs/adrs/20260429000000_keep_database_as_aggregate_supertrait.md). use std::sync::Arc; use torrust_tracker_configuration::Core; -use super::driver::{self, Driver}; -use super::Database; +use super::driver::mysql::Mysql; +use super::driver::sqlite::Sqlite; +use super::driver::Driver; +use super::traits::{AuthKeyStore, SchemaMigrator, TorrentMetricsStore, WhitelistStore}; -/// Initializes and returns a database instance based on the provided configuration. +/// A bundle of narrow-trait store references, one per persistence context. /// -/// This function creates a new database instance according to the settings +/// The factory (`initialize_database`) constructs the concrete driver once and +/// coerces it into each narrow `Arc<dyn XxxStore>`. Individual services are +/// wired at construction time by passing the relevant field +/// (e.g. `database_stores.auth_key_store.clone()`) to each constructor. +/// Services themselves never hold a `DatabaseStores`; they only see the narrow +/// trait they need. +pub struct DatabaseStores { + /// Schema lifecycle: create / drop tables. + pub schema_migrator: Arc<dyn SchemaMigrator>, + /// Per-torrent and global download counters. + pub torrent_metrics_store: Arc<dyn TorrentMetricsStore>, + /// Torrent infohash whitelist. + pub whitelist_store: Arc<dyn WhitelistStore>, + /// Authentication key persistence. + pub auth_key_store: Arc<dyn AuthKeyStore>, +} + +fn build_database_stores<T>(db: Arc<T>) -> DatabaseStores +where + T: SchemaMigrator + TorrentMetricsStore + WhitelistStore + AuthKeyStore + Send + Sync + 'static, +{ + DatabaseStores { + schema_migrator: db.clone(), + torrent_metrics_store: db.clone(), + whitelist_store: db.clone(), + auth_key_store: db, + } +} + +/// Initializes and returns a [`DatabaseStores`] bundle based on the provided +/// configuration. +/// +/// This function creates a new database driver according to the settings /// defined in the [`Core`] configuration. It selects the appropriate driver /// (either `Sqlite3` or `MySQL`) as specified in `config.database.driver` and /// attempts to build the database connection using the path defined in /// `config.database.path`. /// -/// The resulting database instance is wrapped in a shared pointer (`Arc`) to a -/// boxed trait object, allowing safe sharing of the database connection across -/// multiple threads. +/// The concrete driver is constructed once and coerced into four narrow +/// `Arc<dyn XxxStore>` references, one for each persistence context. /// /// # Panics /// /// This function will panic if the database cannot be initialized (i.e., if the -/// driver fails to build the connection). This is enforced by the use of +/// driver fails to build the connection). This is enforced by the use of /// [`expect`](std::result::Result::expect) in the implementation. /// +/// In particular, schema initialization issues a query against the configured +/// database immediately after the driver is built. If the database service is +/// not yet ready to accept connections (for example, a freshly started `MySQL` +/// container that has not finished binding its TCP listener), the first query +/// can fail and this function will panic. The `sqlx` driver does not retry the +/// initial connection on its own, so callers are responsible for ensuring the +/// database is reachable before calling `initialize_database`. +/// +/// Other panic causes include malformed connection URLs, authentication +/// failures, insufficient permissions to issue DDL, network errors, or any +/// other underlying `sqlx::Error` returned while creating the schema. +/// /// # Example /// /// ```rust,no_run @@ -34,18 +82,29 @@ use super::Database; /// let config = Core::default(); /// /// // Initialize the database; this will panic if initialization fails. -/// let database = initialize_database(&config); -/// -/// // The returned database instance can now be used for persistence operations. +/// # async { +/// let stores = initialize_database(&config).await; +/// # }; /// ``` #[must_use] -pub fn initialize_database(config: &Core) -> Arc<Box<dyn Database>> { +pub async fn initialize_database(config: &Core) -> DatabaseStores { let driver = match config.database.driver { torrust_tracker_configuration::Driver::Sqlite3 => Driver::Sqlite3, torrust_tracker_configuration::Driver::MySQL => Driver::MySQL, }; - Arc::new(driver::build(&driver, &config.database.path).expect("Database driver build failed.")) + match driver { + Driver::Sqlite3 => { + let db = Arc::new(Sqlite::new(&config.database.path).expect("Database driver build failed.")); + db.create_database_tables().await.expect("Could not create database tables."); + build_database_stores(db) + } + Driver::MySQL => { + let db = Arc::new(Mysql::new(&config.database.path).expect("Database driver build failed.")); + db.create_database_tables().await.expect("Could not create database tables."); + build_database_stores(db) + } + } } #[cfg(test)] @@ -53,9 +112,9 @@ mod tests { use super::initialize_database; use crate::test_helpers::tests::ephemeral_configuration; - #[test] - fn it_should_initialize_the_sqlite_database() { + #[tokio::test] + async fn it_should_initialize_the_sqlite_database() { let config = ephemeral_configuration(); - let _database = initialize_database(&config); + let _database = initialize_database(&config).await; } } diff --git a/packages/tracker-core/src/databases/traits/auth_keys.rs b/packages/tracker-core/src/databases/traits/auth_keys.rs new file mode 100644 index 000000000..d99759ef0 --- /dev/null +++ b/packages/tracker-core/src/databases/traits/auth_keys.rs @@ -0,0 +1,46 @@ +//! The [`AuthKeyStore`] trait — authentication keys context. +use async_trait::async_trait; +use mockall::automock; + +use super::super::error::Error; +use crate::authentication::{self, Key}; + +/// Trait covering persistence operations for authentication keys. +// The `automock` macro generates a struct whose fields all end with `keys`, +// which triggers `clippy::struct_field_names` (pedantic). Suppressed here +// because the generated mock struct is outside our control. +#[async_trait] +#[allow(clippy::struct_field_names)] +#[automock] +pub trait AuthKeyStore: Sync + Send { + /// Loads all authentication keys from the database. + /// + /// # Errors + /// + /// Returns an [`Error`] if the keys cannot be loaded. + async fn load_keys(&self) -> Result<Vec<authentication::PeerKey>, Error>; + + /// Retrieves a specific authentication key from the database. + /// + /// Returns `Some(PeerKey)` if a key corresponding to the provided [`Key`] + /// exists, or `None` otherwise. + /// + /// # Errors + /// + /// Returns an [`Error`] if the key cannot be queried. + async fn get_key_from_keys(&self, key: &Key) -> Result<Option<authentication::PeerKey>, Error>; + + /// Adds an authentication key to the database. + /// + /// # Errors + /// + /// Returns an [`Error`] if the key cannot be saved. + async fn add_key_to_keys(&self, auth_key: &authentication::PeerKey) -> Result<usize, Error>; + + /// Removes an authentication key from the database. + /// + /// # Errors + /// + /// Returns an [`Error`] if the key cannot be removed. + async fn remove_key_from_keys(&self, key: &Key) -> Result<usize, Error>; +} diff --git a/packages/tracker-core/src/databases/traits/database.rs b/packages/tracker-core/src/databases/traits/database.rs new file mode 100644 index 000000000..72086f270 --- /dev/null +++ b/packages/tracker-core/src/databases/traits/database.rs @@ -0,0 +1,24 @@ +//! The [`Database`] aggregate supertrait — the full driver contract. +use super::auth_keys::AuthKeyStore; +use super::schema::SchemaMigrator; +use super::torrent_metrics::TorrentMetricsStore; +use super::whitelist::WhitelistStore; + +/// The full database driver contract — **internal use only**. +/// +/// A new database driver must implement all four supertrait bounds: +/// [`SchemaMigrator`], [`TorrentMetricsStore`], [`WhitelistStore`], and +/// [`AuthKeyStore`]. The blanket impl below means that any type satisfying all +/// four automatically satisfies `Database` — no separate +/// `impl Database for MyDriver {}` block is needed. +/// +/// This trait is a compile-time completeness guard for driver authors. External +/// consumers (services, repositories, tests) should depend only on the narrow +/// trait they actually need (`AuthKeyStore`, `WhitelistStore`, etc.). Migration +/// of consumer wiring away from `Arc<Box<dyn Database>>` toward narrow trait +/// injection happens in subsequent subissues; it does not require trait-object +/// upcasting because the factory will coerce the concrete driver type directly +/// into each narrow trait object. +pub trait Database: Sync + Send + SchemaMigrator + TorrentMetricsStore + WhitelistStore + AuthKeyStore {} + +impl<T> Database for T where T: Sync + Send + SchemaMigrator + TorrentMetricsStore + WhitelistStore + AuthKeyStore {} diff --git a/packages/tracker-core/src/databases/traits/mod.rs b/packages/tracker-core/src/databases/traits/mod.rs new file mode 100644 index 000000000..d1308566e --- /dev/null +++ b/packages/tracker-core/src/databases/traits/mod.rs @@ -0,0 +1,15 @@ +//! Narrow context traits and the aggregate [`Database`] supertrait. +//! +//! Design rationale and revisit criteria: +//! [`20260429000000_keep_database_as_aggregate_supertrait`](../../../../docs/adrs/20260429000000_keep_database_as_aggregate_supertrait.md). +pub mod auth_keys; +pub mod database; +pub mod schema; +pub mod torrent_metrics; +pub mod whitelist; + +pub use auth_keys::{AuthKeyStore, MockAuthKeyStore}; +pub use database::Database; +pub use schema::{MockSchemaMigrator, SchemaMigrator}; +pub use torrent_metrics::{MockTorrentMetricsStore, TorrentMetricsStore}; +pub use whitelist::{MockWhitelistStore, WhitelistStore}; diff --git a/packages/tracker-core/src/databases/traits/schema.rs b/packages/tracker-core/src/databases/traits/schema.rs new file mode 100644 index 000000000..86ce385f3 --- /dev/null +++ b/packages/tracker-core/src/databases/traits/schema.rs @@ -0,0 +1,31 @@ +//! The [`SchemaMigrator`] trait — schema management context. +use async_trait::async_trait; +use mockall::automock; + +use super::super::error::Error; + +/// Trait covering schema lifecycle operations for a database driver. +/// +/// Implementors are responsible for creating and dropping the full set of +/// database tables used by the tracker. +#[async_trait] +#[automock] +pub trait SchemaMigrator: Sync + Send { + /// Creates the necessary database tables. + /// + /// The SQL queries for table creation are hardcoded in the trait implementation. + /// + /// # Errors + /// + /// Returns an [`Error`] if the tables cannot be created. + async fn create_database_tables(&self) -> Result<(), Error>; + + /// Drops the database tables. + /// + /// This operation removes the persistent schema. + /// + /// # Errors + /// + /// Returns an [`Error`] if the tables cannot be dropped. + async fn drop_database_tables(&self) -> Result<(), Error>; +} diff --git a/packages/tracker-core/src/databases/traits/torrent_metrics.rs b/packages/tracker-core/src/databases/traits/torrent_metrics.rs new file mode 100644 index 000000000..0a618a20d --- /dev/null +++ b/packages/tracker-core/src/databases/traits/torrent_metrics.rs @@ -0,0 +1,89 @@ +//! The [`TorrentMetricsStore`] trait — torrent metrics context. +//! +//! Note: this trait currently includes both per-torrent metrics and the global +//! aggregate downloads metric. The decision and revisit criteria are documented +//! in ADR +//! [`20260429000000_keep_database_as_aggregate_supertrait`](../../../../docs/adrs/20260429000000_keep_database_as_aggregate_supertrait.md). +use async_trait::async_trait; +use bittorrent_primitives::info_hash::InfoHash; +use mockall::automock; +use torrust_tracker_primitives::{NumberOfDownloads, NumberOfDownloadsBTreeMap}; + +use super::super::error::Error; + +/// Trait covering persistence operations for per-torrent and global download +/// counters. +#[async_trait] +#[automock] +pub trait TorrentMetricsStore: Sync + Send { + /// Loads torrent metrics data from the database for all torrents. + /// + /// This function returns the persistent torrent metrics as a collection of + /// tuples, where each tuple contains an [`InfoHash`] and the `downloaded` + /// counter (i.e. the number of times the torrent has been downloaded). + /// + /// # Errors + /// + /// Returns an [`Error`] if the metrics cannot be loaded. + async fn load_all_torrents_downloads(&self) -> Result<NumberOfDownloadsBTreeMap, Error>; + + /// Loads torrent metrics data from the database for one torrent. + /// + /// # Errors + /// + /// Returns an [`Error`] if the metrics cannot be loaded. + async fn load_torrent_downloads(&self, info_hash: &InfoHash) -> Result<Option<NumberOfDownloads>, Error>; + + /// Saves torrent metrics data into the database. + /// + /// # Arguments + /// + /// * `info_hash` - A reference to the torrent's info hash. + /// * `downloaded` - The number of times the torrent has been downloaded. + /// + /// # Errors + /// + /// Returns an [`Error`] if the metrics cannot be saved. + async fn save_torrent_downloads(&self, info_hash: &InfoHash, downloaded: u32) -> Result<(), Error>; + + /// Increases the number of downloads for a given torrent. + /// + /// It does not create a new entry if the torrent is not found and it does + /// not return an error. + /// + /// # Context: Torrent Metrics + /// + /// # Arguments + /// + /// * `info_hash` - A reference to the torrent's info hash. + /// + /// # Errors + /// + /// Returns an [`Error`] if the query failed. + async fn increase_downloads_for_torrent(&self, info_hash: &InfoHash) -> Result<(), Error>; + + /// Loads the total number of downloads for all torrents from the database. + /// + /// # Errors + /// + /// Returns an [`Error`] if the total downloads cannot be loaded. + async fn load_global_downloads(&self) -> Result<Option<NumberOfDownloads>, Error>; + + /// Saves the total number of downloads for all torrents into the database. + /// + /// # Arguments + /// + /// * `downloaded` - The total number of times all torrents have been downloaded. + /// + /// # Errors + /// + /// Returns an [`Error`] if the total downloads cannot be saved. + async fn save_global_downloads(&self, downloaded: NumberOfDownloads) -> Result<(), Error>; + + /// Increases the total number of downloads for all torrents. + /// + /// # Errors + /// + /// Returns an [`Error`] if the query failed. + async fn increase_global_downloads(&self) -> Result<(), Error>; +} diff --git a/packages/tracker-core/src/databases/traits/whitelist.rs b/packages/tracker-core/src/databases/traits/whitelist.rs new file mode 100644 index 000000000..b463708f2 --- /dev/null +++ b/packages/tracker-core/src/databases/traits/whitelist.rs @@ -0,0 +1,54 @@ +//! The [`WhitelistStore`] trait — torrent whitelist context. +use async_trait::async_trait; +use bittorrent_primitives::info_hash::InfoHash; +use mockall::automock; + +use super::super::error::Error; + +/// Trait covering persistence operations for the torrent whitelist. +#[async_trait] +#[automock] +pub trait WhitelistStore: Sync + Send { + /// Loads the whitelisted torrents from the database. + /// + /// # Errors + /// + /// Returns an [`Error`] if the whitelist cannot be loaded. + async fn load_whitelist(&self) -> Result<Vec<InfoHash>, Error>; + + /// Retrieves a whitelisted torrent from the database. + /// + /// Returns `Some(InfoHash)` if the torrent is in the whitelist, or `None` + /// otherwise. + /// + /// # Errors + /// + /// Returns an [`Error`] if the whitelist cannot be queried. + async fn get_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result<Option<InfoHash>, Error>; + + /// Adds a torrent to the whitelist. + /// + /// # Errors + /// + /// Returns an [`Error`] if the torrent cannot be added to the whitelist. + async fn add_info_hash_to_whitelist(&self, info_hash: InfoHash) -> Result<usize, Error>; + + /// Removes a torrent from the whitelist. + /// + /// # Errors + /// + /// Returns an [`Error`] if the torrent cannot be removed from the whitelist. + async fn remove_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result<usize, Error>; + + /// Checks whether a torrent is whitelisted. + /// + /// This default implementation returns `true` if the infohash is included + /// in the whitelist, or `false` otherwise. + /// + /// # Errors + /// + /// Returns an [`Error`] if the whitelist cannot be queried. + async fn is_info_hash_whitelisted(&self, info_hash: InfoHash) -> Result<bool, Error> { + Ok(self.get_info_hash_from_whitelist(info_hash).await?.is_some()) + } +} diff --git a/packages/tracker-core/src/lib.rs b/packages/tracker-core/src/lib.rs index 5167abf51..b711cda13 100644 --- a/packages/tracker-core/src/lib.rs +++ b/packages/tracker-core/src/lib.rs @@ -170,14 +170,14 @@ mod tests { use crate::scrape_handler::ScrapeHandler; use crate::test_helpers::tests::initialize_handlers; - fn initialize_handlers_for_public_tracker() -> (Arc<AnnounceHandler>, Arc<ScrapeHandler>) { + async fn initialize_handlers_for_public_tracker() -> (Arc<AnnounceHandler>, Arc<ScrapeHandler>) { let config = configuration::ephemeral_public(); - initialize_handlers(&config) + initialize_handlers(&config).await } - fn initialize_handlers_for_listed_tracker() -> (Arc<AnnounceHandler>, Arc<ScrapeHandler>) { + async fn initialize_handlers_for_listed_tracker() -> (Arc<AnnounceHandler>, Arc<ScrapeHandler>) { let config = configuration::ephemeral_listed(); - initialize_handlers(&config) + initialize_handlers(&config).await } mod for_all_config_modes { @@ -196,7 +196,7 @@ mod tests { #[tokio::test] async fn it_should_return_the_swarm_metadata_for_the_requested_file_if_the_tracker_has_that_torrent() { - let (announce_handler, scrape_handler) = initialize_handlers_for_public_tracker(); + let (announce_handler, scrape_handler) = initialize_handlers_for_public_tracker().await; let info_hash = "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0".parse::<InfoHash>().unwrap(); // DevSkim: ignore DS173237 @@ -255,7 +255,7 @@ mod tests { #[tokio::test] async fn it_should_return_the_zeroed_swarm_metadata_for_the_requested_file_if_it_is_not_whitelisted() { - let (_announce_handler, scrape_handler) = initialize_handlers_for_listed_tracker(); + let (_announce_handler, scrape_handler) = initialize_handlers_for_listed_tracker().await; let non_whitelisted_info_hash = "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0".parse::<InfoHash>().unwrap(); // DevSkim: ignore DS173237 diff --git a/packages/tracker-core/src/statistics/event/handler.rs b/packages/tracker-core/src/statistics/event/handler.rs index 9a5182f25..afcff4e82 100644 --- a/packages/tracker-core/src/statistics/event/handler.rs +++ b/packages/tracker-core/src/statistics/event/handler.rs @@ -53,7 +53,10 @@ pub async fn handle_event( if persistent_torrent_completed_stat { // Increment the number of downloads for the torrent in the database - match db_downloads_metric_repository.increase_downloads_for_torrent(&info_hash) { + match db_downloads_metric_repository + .increase_downloads_for_torrent(&info_hash) + .await + { Ok(()) => { tracing::debug!(info_hash = ?info_hash, "Number of torrent downloads increased"); } @@ -63,7 +66,7 @@ pub async fn handle_event( } // Increment the global number of downloads (for all torrents) in the database - match db_downloads_metric_repository.increase_global_downloads() { + match db_downloads_metric_repository.increase_global_downloads().await { Ok(()) => { tracing::debug!("Global number of downloads increased"); } diff --git a/packages/tracker-core/src/statistics/persisted/downloads.rs b/packages/tracker-core/src/statistics/persisted/downloads.rs index 6248bdc73..e308c0063 100644 --- a/packages/tracker-core/src/statistics/persisted/downloads.rs +++ b/packages/tracker-core/src/statistics/persisted/downloads.rs @@ -5,14 +5,14 @@ use bittorrent_primitives::info_hash::InfoHash; use torrust_tracker_primitives::{NumberOfDownloads, NumberOfDownloadsBTreeMap}; use crate::databases::error::Error; -use crate::databases::Database; +use crate::databases::TorrentMetricsStore; /// It persists torrent metrics in a database. /// /// This repository persists only a subset of the torrent data: the torrent /// metrics, specifically the number of downloads (or completed counts) for each /// torrent. It relies on a database driver (either `SQLite3` or `MySQL`) that -/// implements the [`Database`] trait to perform the actual persistence +/// implements the [`TorrentMetricsStore`] trait to perform the actual persistence /// operations. /// /// # Note @@ -20,28 +20,27 @@ use crate::databases::Database; /// Not all in-memory torrent data is persisted; only the aggregate metrics are /// stored. pub struct DatabaseDownloadsMetricRepository { - /// A shared reference to the database driver implementation. + /// A shared reference to the torrent metrics store implementation. /// - /// The driver must implement the [`Database`] trait. This allows for - /// different underlying implementations (e.g., `SQLite3` or `MySQL`) to be - /// used interchangeably. - database: Arc<Box<dyn Database>>, + /// This allows for different underlying implementations (e.g., `SQLite3` + /// or `MySQL`) to be used interchangeably. + database: Arc<dyn TorrentMetricsStore>, } impl DatabaseDownloadsMetricRepository { - /// Creates a new instance of `DatabasePersistentTorrentRepository`. + /// Creates a new instance of `DatabaseDownloadsMetricRepository`. /// /// # Arguments /// - /// * `database` - A shared reference to a boxed database driver - /// implementing the [`Database`] trait. + /// * `database` - A shared reference to a torrent metrics store + /// implementing the [`TorrentMetricsStore`] trait. /// /// # Returns /// - /// A new `DatabasePersistentTorrentRepository` instance with a cloned - /// reference to the provided database. + /// A new `DatabaseDownloadsMetricRepository` instance with a cloned + /// reference to the provided store. #[must_use] - pub fn new(database: &Arc<Box<dyn Database>>) -> DatabaseDownloadsMetricRepository { + pub fn new(database: &Arc<dyn TorrentMetricsStore>) -> DatabaseDownloadsMetricRepository { Self { database: database.clone(), } @@ -60,12 +59,12 @@ impl DatabaseDownloadsMetricRepository { /// # Errors /// /// Returns an [`Error`] if the database operation fails. - pub(crate) fn increase_downloads_for_torrent(&self, info_hash: &InfoHash) -> Result<(), Error> { - let torrent = self.load_torrent_downloads(info_hash)?; + pub(crate) async fn increase_downloads_for_torrent(&self, info_hash: &InfoHash) -> Result<(), Error> { + let torrent = self.load_torrent_downloads(info_hash).await?; match torrent { - Some(_number_of_downloads) => self.database.increase_downloads_for_torrent(info_hash), - None => self.save_torrent_downloads(info_hash, 1), + Some(_number_of_downloads) => self.database.increase_downloads_for_torrent(info_hash).await, + None => self.save_torrent_downloads(info_hash, 1).await, } } @@ -77,8 +76,8 @@ impl DatabaseDownloadsMetricRepository { /// # Errors /// /// Returns an [`Error`] if the underlying database query fails. - pub(crate) fn load_all_torrents_downloads(&self) -> Result<NumberOfDownloadsBTreeMap, Error> { - self.database.load_all_torrents_downloads() + pub(crate) async fn load_all_torrents_downloads(&self) -> Result<NumberOfDownloadsBTreeMap, Error> { + self.database.load_all_torrents_downloads().await } /// Loads one persistent torrent metrics from the database. @@ -89,8 +88,8 @@ impl DatabaseDownloadsMetricRepository { /// # Errors /// /// Returns an [`Error`] if the underlying database query fails. - pub(crate) fn load_torrent_downloads(&self, info_hash: &InfoHash) -> Result<Option<NumberOfDownloads>, Error> { - self.database.load_torrent_downloads(info_hash) + pub(crate) async fn load_torrent_downloads(&self, info_hash: &InfoHash) -> Result<Option<NumberOfDownloads>, Error> { + self.database.load_torrent_downloads(info_hash).await } /// Saves the persistent torrent metric into the database. @@ -106,8 +105,8 @@ impl DatabaseDownloadsMetricRepository { /// # Errors /// /// Returns an [`Error`] if the database operation fails. - pub(crate) fn save_torrent_downloads(&self, info_hash: &InfoHash, downloaded: u32) -> Result<(), Error> { - self.database.save_torrent_downloads(info_hash, downloaded) + pub(crate) async fn save_torrent_downloads(&self, info_hash: &InfoHash, downloaded: u32) -> Result<(), Error> { + self.database.save_torrent_downloads(info_hash, downloaded).await } // Aggregate Metrics @@ -119,12 +118,12 @@ impl DatabaseDownloadsMetricRepository { /// # Errors /// /// Returns an [`Error`] if the database operation fails. - pub(crate) fn increase_global_downloads(&self) -> Result<(), Error> { - let torrent = self.database.load_global_downloads()?; + pub(crate) async fn increase_global_downloads(&self) -> Result<(), Error> { + let torrent = self.database.load_global_downloads().await?; match torrent { - Some(_number_of_downloads) => self.database.increase_global_downloads(), - None => self.database.save_global_downloads(1), + Some(_number_of_downloads) => self.database.increase_global_downloads().await, + None => self.database.save_global_downloads(1).await, } } @@ -133,8 +132,8 @@ impl DatabaseDownloadsMetricRepository { /// # Errors /// /// Returns an [`Error`] if the underlying database query fails. - pub(crate) fn load_global_downloads(&self) -> Result<Option<NumberOfDownloads>, Error> { - self.database.load_global_downloads() + pub(crate) async fn load_global_downloads(&self) -> Result<Option<NumberOfDownloads>, Error> { + self.database.load_global_downloads().await } } @@ -147,49 +146,49 @@ mod tests { use crate::databases::setup::initialize_database; use crate::test_helpers::tests::{ephemeral_configuration, sample_info_hash, sample_info_hash_one, sample_info_hash_two}; - fn initialize_db_persistent_torrent_repository() -> DatabaseDownloadsMetricRepository { + async fn initialize_db_persistent_torrent_repository() -> DatabaseDownloadsMetricRepository { let config = ephemeral_configuration(); - let database = initialize_database(&config); - DatabaseDownloadsMetricRepository::new(&database) + let stores = initialize_database(&config).await; + DatabaseDownloadsMetricRepository::new(&stores.torrent_metrics_store) } - #[test] - fn it_saves_the_numbers_of_downloads_for_a_torrent_into_the_database() { - let repository = initialize_db_persistent_torrent_repository(); + #[tokio::test] + async fn it_saves_the_numbers_of_downloads_for_a_torrent_into_the_database() { + let repository = initialize_db_persistent_torrent_repository().await; let infohash = sample_info_hash(); - repository.save_torrent_downloads(&infohash, 1).unwrap(); + repository.save_torrent_downloads(&infohash, 1).await.unwrap(); - let torrents = repository.load_all_torrents_downloads().unwrap(); + let torrents = repository.load_all_torrents_downloads().await.unwrap(); assert_eq!(torrents.get(&infohash), Some(1).as_ref()); } - #[test] - fn it_increases_the_numbers_of_downloads_for_a_torrent_into_the_database() { - let repository = initialize_db_persistent_torrent_repository(); + #[tokio::test] + async fn it_increases_the_numbers_of_downloads_for_a_torrent_into_the_database() { + let repository = initialize_db_persistent_torrent_repository().await; let infohash = sample_info_hash(); - repository.increase_downloads_for_torrent(&infohash).unwrap(); + repository.increase_downloads_for_torrent(&infohash).await.unwrap(); - let torrents = repository.load_all_torrents_downloads().unwrap(); + let torrents = repository.load_all_torrents_downloads().await.unwrap(); assert_eq!(torrents.get(&infohash), Some(1).as_ref()); } - #[test] - fn it_loads_the_numbers_of_downloads_for_all_torrents_from_the_database() { - let repository = initialize_db_persistent_torrent_repository(); + #[tokio::test] + async fn it_loads_the_numbers_of_downloads_for_all_torrents_from_the_database() { + let repository = initialize_db_persistent_torrent_repository().await; let infohash_one = sample_info_hash_one(); let infohash_two = sample_info_hash_two(); - repository.save_torrent_downloads(&infohash_one, 1).unwrap(); - repository.save_torrent_downloads(&infohash_two, 2).unwrap(); + repository.save_torrent_downloads(&infohash_one, 1).await.unwrap(); + repository.save_torrent_downloads(&infohash_two, 2).await.unwrap(); - let torrents = repository.load_all_torrents_downloads().unwrap(); + let torrents = repository.load_all_torrents_downloads().await.unwrap(); let mut expected_torrents = NumberOfDownloadsBTreeMap::new(); expected_torrents.insert(infohash_one, 1); diff --git a/packages/tracker-core/src/statistics/persisted/mod.rs b/packages/tracker-core/src/statistics/persisted/mod.rs index 86c28370d..b808d9cf2 100644 --- a/packages/tracker-core/src/statistics/persisted/mod.rs +++ b/packages/tracker-core/src/statistics/persisted/mod.rs @@ -23,7 +23,7 @@ pub async fn load_persisted_metrics( db_downloads_metric_repository: &Arc<DatabaseDownloadsMetricRepository>, now: DurationSinceUnixEpoch, ) -> Result<(), Error> { - if let Some(downloads) = db_downloads_metric_repository.load_global_downloads()? { + if let Some(downloads) = db_downloads_metric_repository.load_global_downloads().await? { stats_repository .set_counter( &metric_name!(TRACKER_CORE_PERSISTENT_TORRENTS_DOWNLOADS_TOTAL), diff --git a/packages/tracker-core/src/test_helpers.rs b/packages/tracker-core/src/test_helpers.rs index 62649cd22..08677363e 100644 --- a/packages/tracker-core/src/test_helpers.rs +++ b/packages/tracker-core/src/test_helpers.rs @@ -7,7 +7,7 @@ pub(crate) mod tests { use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes, PeerId}; use bittorrent_primitives::info_hash::InfoHash; - use rand::Rng; + use rand::RngExt; use torrust_tracker_configuration::Configuration; #[cfg(test)] use torrust_tracker_configuration::Core; @@ -129,15 +129,15 @@ pub(crate) mod tests { } #[must_use] - pub fn initialize_handlers(config: &Configuration) -> (Arc<AnnounceHandler>, Arc<ScrapeHandler>) { - let database = initialize_database(&config.core); + pub async fn initialize_handlers(config: &Configuration) -> (Arc<AnnounceHandler>, Arc<ScrapeHandler>) { + let stores = initialize_database(&config.core).await; let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); let whitelist_authorization = Arc::new(whitelist::authorization::WhitelistAuthorization::new( &config.core, &in_memory_whitelist.clone(), )); let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); - let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&stores.torrent_metrics_store)); let announce_handler = Arc::new(AnnounceHandler::new( &config.core, diff --git a/packages/tracker-core/src/torrent/manager.rs b/packages/tracker-core/src/torrent/manager.rs index 5acc27980..60b626328 100644 --- a/packages/tracker-core/src/torrent/manager.rs +++ b/packages/tracker-core/src/torrent/manager.rs @@ -70,8 +70,8 @@ impl TorrentsManager { /// /// Returns a `databases::error::Error` if unable to load the persistent /// torrent data. - pub fn load_torrents_from_database(&self) -> Result<(), databases::error::Error> { - let persistent_torrents = self.db_downloads_metric_repository.load_all_torrents_downloads()?; + pub async fn load_torrents_from_database(&self) -> Result<(), databases::error::Error> { + let persistent_torrents = self.db_downloads_metric_repository.load_all_torrents_downloads().await?; self.in_memory_torrent_repository.import_persistent(&persistent_torrents); @@ -161,16 +161,17 @@ mod tests { database_persistent_torrent_repository: Arc<DatabaseDownloadsMetricRepository>, } - fn initialize_torrents_manager() -> (Arc<TorrentsManager>, Arc<TorrentsManagerDeps>) { + async fn initialize_torrents_manager() -> (Arc<TorrentsManager>, Arc<TorrentsManagerDeps>) { let config = ephemeral_configuration(); - initialize_torrents_manager_with(config.clone()) + initialize_torrents_manager_with(config.clone()).await } - fn initialize_torrents_manager_with(config: Core) -> (Arc<TorrentsManager>, Arc<TorrentsManagerDeps>) { + async fn initialize_torrents_manager_with(config: Core) -> (Arc<TorrentsManager>, Arc<TorrentsManagerDeps>) { let swarms = Arc::new(Registry::default()); let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::new(swarms)); - let database = initialize_database(&config); - let database_persistent_torrent_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + let database = initialize_database(&config).await; + let database_persistent_torrent_repository = + Arc::new(DatabaseDownloadsMetricRepository::new(&database.torrent_metrics_store)); let torrents_manager = Arc::new(TorrentsManager::new( &config, @@ -190,16 +191,17 @@ mod tests { #[tokio::test] async fn it_should_load_the_numbers_of_downloads_for_all_torrents_from_the_database() { - let (torrents_manager, services) = initialize_torrents_manager(); + let (torrents_manager, services) = initialize_torrents_manager().await; let infohash = sample_info_hash(); services .database_persistent_torrent_repository .save_torrent_downloads(&infohash, 1) + .await .unwrap(); - torrents_manager.load_torrents_from_database().unwrap(); + torrents_manager.load_torrents_from_database().await.unwrap(); assert_eq!( services @@ -230,7 +232,7 @@ mod tests { #[tokio::test] async fn it_should_remove_peers_that_have_not_been_updated_after_a_cutoff_time() { - let (torrents_manager, services) = initialize_torrents_manager(); + let (torrents_manager, services) = initialize_torrents_manager().await; let infohash = sample_info_hash(); @@ -272,7 +274,7 @@ mod tests { let mut config = ephemeral_configuration(); config.tracker_policy.remove_peerless_torrents = true; - let (torrents_manager, services) = initialize_torrents_manager_with(config); + let (torrents_manager, services) = initialize_torrents_manager_with(config).await; let infohash = sample_info_hash(); @@ -288,7 +290,7 @@ mod tests { let mut config = ephemeral_configuration(); config.tracker_policy.remove_peerless_torrents = false; - let (torrents_manager, services) = initialize_torrents_manager_with(config); + let (torrents_manager, services) = initialize_torrents_manager_with(config).await; let infohash = sample_info_hash(); diff --git a/packages/tracker-core/src/whitelist/manager.rs b/packages/tracker-core/src/whitelist/manager.rs index 452fcb6c5..bdef1eb81 100644 --- a/packages/tracker-core/src/whitelist/manager.rs +++ b/packages/tracker-core/src/whitelist/manager.rs @@ -50,7 +50,7 @@ impl WhitelistManager { /// # Errors /// Returns a `database::Error` if the operation fails in the database. pub async fn add_torrent_to_whitelist(&self, info_hash: &InfoHash) -> Result<(), databases::error::Error> { - self.database_whitelist.add(info_hash)?; + self.database_whitelist.add(info_hash).await?; self.in_memory_whitelist.add(info_hash).await; Ok(()) } @@ -63,7 +63,7 @@ impl WhitelistManager { /// # Errors /// Returns a `database::Error` if the operation fails in the database. pub async fn remove_torrent_from_whitelist(&self, info_hash: &InfoHash) -> Result<(), databases::error::Error> { - self.database_whitelist.remove(info_hash)?; + self.database_whitelist.remove(info_hash).await?; self.in_memory_whitelist.remove(info_hash).await; Ok(()) } @@ -76,7 +76,7 @@ impl WhitelistManager { /// # Errors /// Returns a `database::Error` if the operation fails to load from the database. pub async fn load_whitelist_from_database(&self) -> Result<(), databases::error::Error> { - let whitelisted_torrents_from_database = self.database_whitelist.load_from_database()?; + let whitelisted_torrents_from_database = self.database_whitelist.load_from_database().await?; self.in_memory_whitelist.clear().await; @@ -96,26 +96,24 @@ mod tests { use torrust_tracker_configuration::Core; use crate::databases::setup::initialize_database; - use crate::databases::Database; use crate::test_helpers::tests::ephemeral_configuration_for_listed_tracker; use crate::whitelist::manager::WhitelistManager; use crate::whitelist::repository::in_memory::InMemoryWhitelist; use crate::whitelist::repository::persisted::DatabaseWhitelist; struct WhitelistManagerDeps { - pub _database: Arc<Box<dyn Database>>, pub database_whitelist: Arc<DatabaseWhitelist>, pub in_memory_whitelist: Arc<InMemoryWhitelist>, } - fn initialize_whitelist_manager_for_whitelisted_tracker() -> (Arc<WhitelistManager>, Arc<WhitelistManagerDeps>) { + async fn initialize_whitelist_manager_for_whitelisted_tracker() -> (Arc<WhitelistManager>, Arc<WhitelistManagerDeps>) { let config = ephemeral_configuration_for_listed_tracker(); - initialize_whitelist_manager_and_deps(&config) + initialize_whitelist_manager_and_deps(&config).await } - fn initialize_whitelist_manager_and_deps(config: &Core) -> (Arc<WhitelistManager>, Arc<WhitelistManagerDeps>) { - let database = initialize_database(config); - let database_whitelist = Arc::new(DatabaseWhitelist::new(database.clone())); + async fn initialize_whitelist_manager_and_deps(config: &Core) -> (Arc<WhitelistManager>, Arc<WhitelistManagerDeps>) { + let stores = initialize_database(config).await; + let database_whitelist = Arc::new(DatabaseWhitelist::new(stores.whitelist_store.clone())); let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); let whitelist_manager = Arc::new(WhitelistManager::new(database_whitelist.clone(), in_memory_whitelist.clone())); @@ -123,7 +121,6 @@ mod tests { ( whitelist_manager, Arc::new(WhitelistManagerDeps { - _database: database, database_whitelist, in_memory_whitelist, }), @@ -138,19 +135,24 @@ mod tests { #[tokio::test] async fn it_should_add_a_torrent_to_the_whitelist() { - let (whitelist_manager, services) = initialize_whitelist_manager_for_whitelisted_tracker(); + let (whitelist_manager, services) = initialize_whitelist_manager_for_whitelisted_tracker().await; let info_hash = sample_info_hash(); whitelist_manager.add_torrent_to_whitelist(&info_hash).await.unwrap(); assert!(services.in_memory_whitelist.contains(&info_hash).await); - assert!(services.database_whitelist.load_from_database().unwrap().contains(&info_hash)); + assert!(services + .database_whitelist + .load_from_database() + .await + .unwrap() + .contains(&info_hash)); } #[tokio::test] async fn it_should_remove_a_torrent_from_the_whitelist() { - let (whitelist_manager, services) = initialize_whitelist_manager_for_whitelisted_tracker(); + let (whitelist_manager, services) = initialize_whitelist_manager_for_whitelisted_tracker().await; let info_hash = sample_info_hash(); @@ -159,7 +161,12 @@ mod tests { whitelist_manager.remove_torrent_from_whitelist(&info_hash).await.unwrap(); assert!(!services.in_memory_whitelist.contains(&info_hash).await); - assert!(!services.database_whitelist.load_from_database().unwrap().contains(&info_hash)); + assert!(!services + .database_whitelist + .load_from_database() + .await + .unwrap() + .contains(&info_hash)); } mod persistence { @@ -168,11 +175,11 @@ mod tests { #[tokio::test] async fn it_should_load_the_whitelist_from_the_database() { - let (whitelist_manager, services) = initialize_whitelist_manager_for_whitelisted_tracker(); + let (whitelist_manager, services) = initialize_whitelist_manager_for_whitelisted_tracker().await; let info_hash = sample_info_hash(); - services.database_whitelist.add(&info_hash).unwrap(); + services.database_whitelist.add(&info_hash).await.unwrap(); whitelist_manager.load_whitelist_from_database().await.unwrap(); diff --git a/packages/tracker-core/src/whitelist/mod.rs b/packages/tracker-core/src/whitelist/mod.rs index d9ad18311..a0dd7c23e 100644 --- a/packages/tracker-core/src/whitelist/mod.rs +++ b/packages/tracker-core/src/whitelist/mod.rs @@ -33,7 +33,7 @@ mod tests { #[tokio::test] async fn it_should_authorize_the_announce_and_scrape_actions_on_whitelisted_torrents() { - let (whitelist_authorization, whitelist_manager) = initialize_whitelist_services_for_listed_tracker(); + let (whitelist_authorization, whitelist_manager) = initialize_whitelist_services_for_listed_tracker().await; let info_hash = sample_info_hash(); @@ -46,7 +46,7 @@ mod tests { #[tokio::test] async fn it_should_not_authorize_the_announce_and_scrape_actions_on_not_whitelisted_torrents() { - let (whitelist_authorization, _whitelist_manager) = initialize_whitelist_services_for_listed_tracker(); + let (whitelist_authorization, _whitelist_manager) = initialize_whitelist_services_for_listed_tracker().await; let info_hash = sample_info_hash(); diff --git a/packages/tracker-core/src/whitelist/repository/persisted.rs b/packages/tracker-core/src/whitelist/repository/persisted.rs index eec6704d6..aa78eb7c7 100644 --- a/packages/tracker-core/src/whitelist/repository/persisted.rs +++ b/packages/tracker-core/src/whitelist/repository/persisted.rs @@ -3,22 +3,21 @@ use std::sync::Arc; use bittorrent_primitives::info_hash::InfoHash; -use crate::databases::{self, Database}; +use crate::databases::{self, WhitelistStore}; /// The persisted list of allowed torrents. /// /// This repository handles adding, removing, and loading torrents -/// from a persistent database like `SQLite` or `MySQL`ç. +/// from a persistent database like `SQLite` or `MySQL`. pub struct DatabaseWhitelist { - /// A database driver implementation: [`Sqlite3`](crate::core::databases::sqlite) - /// or [`MySQL`](crate::core::databases::mysql) - database: Arc<Box<dyn Database>>, + /// A whitelist store implementation (e.g., `SQLite3` or `MySQL`). + database: Arc<dyn WhitelistStore>, } impl DatabaseWhitelist { /// Creates a new `DatabaseWhitelist`. #[must_use] - pub fn new(database: Arc<Box<dyn Database>>) -> Self { + pub fn new(database: Arc<dyn WhitelistStore>) -> Self { Self { database } } @@ -27,14 +26,14 @@ impl DatabaseWhitelist { /// # Errors /// Returns a `database::Error` if unable to add the `info_hash` to the /// whitelist. - pub(crate) fn add(&self, info_hash: &InfoHash) -> Result<(), databases::error::Error> { - let is_whitelisted = self.database.is_info_hash_whitelisted(*info_hash)?; + pub(crate) async fn add(&self, info_hash: &InfoHash) -> Result<(), databases::error::Error> { + let is_whitelisted = self.database.is_info_hash_whitelisted(*info_hash).await?; if is_whitelisted { return Ok(()); } - self.database.add_info_hash_to_whitelist(*info_hash)?; + self.database.add_info_hash_to_whitelist(*info_hash).await?; Ok(()) } @@ -43,14 +42,14 @@ impl DatabaseWhitelist { /// /// # Errors /// Returns a `database::Error` if unable to remove the `info_hash`. - pub(crate) fn remove(&self, info_hash: &InfoHash) -> Result<(), databases::error::Error> { - let is_whitelisted = self.database.is_info_hash_whitelisted(*info_hash)?; + pub(crate) async fn remove(&self, info_hash: &InfoHash) -> Result<(), databases::error::Error> { + let is_whitelisted = self.database.is_info_hash_whitelisted(*info_hash).await?; if !is_whitelisted { return Ok(()); } - self.database.remove_info_hash_from_whitelist(*info_hash)?; + self.database.remove_info_hash_from_whitelist(*info_hash).await?; Ok(()) } @@ -60,8 +59,8 @@ impl DatabaseWhitelist { /// # Errors /// Returns a `database::Error` if unable to load whitelisted `info_hash` /// values. - pub(crate) fn load_from_database(&self) -> Result<Vec<InfoHash>, databases::error::Error> { - self.database.load_whitelist() + pub(crate) async fn load_from_database(&self) -> Result<Vec<InfoHash>, databases::error::Error> { + self.database.load_whitelist().await } } @@ -73,68 +72,68 @@ mod tests { use crate::test_helpers::tests::{ephemeral_configuration_for_listed_tracker, sample_info_hash}; use crate::whitelist::repository::persisted::DatabaseWhitelist; - fn initialize_database_whitelist() -> DatabaseWhitelist { + async fn initialize_database_whitelist() -> DatabaseWhitelist { let configuration = ephemeral_configuration_for_listed_tracker(); - let database = initialize_database(&configuration); - DatabaseWhitelist::new(database) + let stores = initialize_database(&configuration).await; + DatabaseWhitelist::new(stores.whitelist_store) } - #[test] - fn should_add_a_new_infohash_to_the_list() { - let whitelist = initialize_database_whitelist(); + #[tokio::test] + async fn should_add_a_new_infohash_to_the_list() { + let whitelist = initialize_database_whitelist().await; let infohash = sample_info_hash(); - let _result = whitelist.add(&infohash); + let _result = whitelist.add(&infohash).await; - assert_eq!(whitelist.load_from_database().unwrap(), vec!(infohash)); + assert_eq!(whitelist.load_from_database().await.unwrap(), vec!(infohash)); } - #[test] - fn should_remove_a_infohash_from_the_list() { - let whitelist = initialize_database_whitelist(); + #[tokio::test] + async fn should_remove_a_infohash_from_the_list() { + let whitelist = initialize_database_whitelist().await; let infohash = sample_info_hash(); - let _result = whitelist.add(&infohash); + let _result = whitelist.add(&infohash).await; - let _result = whitelist.remove(&infohash); + let _result = whitelist.remove(&infohash).await; - assert_eq!(whitelist.load_from_database().unwrap(), vec!()); + assert_eq!(whitelist.load_from_database().await.unwrap(), vec!()); } - #[test] - fn should_load_all_infohashes_from_the_database() { - let whitelist = initialize_database_whitelist(); + #[tokio::test] + async fn should_load_all_infohashes_from_the_database() { + let whitelist = initialize_database_whitelist().await; let infohash = sample_info_hash(); - let _result = whitelist.add(&infohash); + let _result = whitelist.add(&infohash).await; - let result = whitelist.load_from_database().unwrap(); + let result = whitelist.load_from_database().await.unwrap(); assert_eq!(result, vec!(infohash)); } - #[test] - fn should_not_add_the_same_infohash_to_the_list_twice() { - let whitelist = initialize_database_whitelist(); + #[tokio::test] + async fn should_not_add_the_same_infohash_to_the_list_twice() { + let whitelist = initialize_database_whitelist().await; let infohash = sample_info_hash(); - let _result = whitelist.add(&infohash); - let _result = whitelist.add(&infohash); + let _result = whitelist.add(&infohash).await; + let _result = whitelist.add(&infohash).await; - assert_eq!(whitelist.load_from_database().unwrap(), vec!(infohash)); + assert_eq!(whitelist.load_from_database().await.unwrap(), vec!(infohash)); } - #[test] - fn should_not_fail_removing_an_infohash_that_is_not_in_the_list() { - let whitelist = initialize_database_whitelist(); + #[tokio::test] + async fn should_not_fail_removing_an_infohash_that_is_not_in_the_list() { + let whitelist = initialize_database_whitelist().await; let infohash = sample_info_hash(); - let result = whitelist.remove(&infohash); + let result = whitelist.remove(&infohash).await; assert!(result.is_ok()); } diff --git a/packages/tracker-core/src/whitelist/setup.rs b/packages/tracker-core/src/whitelist/setup.rs index cb18c1478..b1c163f97 100644 --- a/packages/tracker-core/src/whitelist/setup.rs +++ b/packages/tracker-core/src/whitelist/setup.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use super::manager::WhitelistManager; use super::repository::in_memory::InMemoryWhitelist; use super::repository::persisted::DatabaseWhitelist; -use crate::databases::Database; +use crate::databases::WhitelistStore; /// Initializes the `WhitelistManager` by combining in-memory and database /// repositories. @@ -22,20 +22,20 @@ use crate::databases::Database; /// /// # Arguments /// -/// * `database` - An `Arc<Box<dyn Database>>` representing the database connection, -/// sed for persistent whitelist storage. -/// * `in_memory_whitelist` - An `Arc<InMemoryWhitelist>` representing the in-memory -/// whitelist repository for fast access. +/// * `whitelist_store` - An `Arc<dyn WhitelistStore>` representing the +/// whitelist persistence store. +/// * `in_memory_whitelist` - An `Arc<InMemoryWhitelist>` representing the +/// in-memory whitelist repository for fast access. /// /// # Returns /// -/// An `Arc<WhitelistManager>` instance that manages both the in-memory and database -/// whitelist repositories. +/// An `Arc<WhitelistManager>` instance that manages both the in-memory and +/// database whitelist repositories. #[must_use] pub fn initialize_whitelist_manager( - database: Arc<Box<dyn Database>>, + whitelist_store: Arc<dyn WhitelistStore>, in_memory_whitelist: Arc<InMemoryWhitelist>, ) -> Arc<WhitelistManager> { - let database_whitelist = Arc::new(DatabaseWhitelist::new(database)); + let database_whitelist = Arc::new(DatabaseWhitelist::new(whitelist_store)); Arc::new(WhitelistManager::new(database_whitelist, in_memory_whitelist)) } diff --git a/packages/tracker-core/src/whitelist/test_helpers.rs b/packages/tracker-core/src/whitelist/test_helpers.rs index cf1699be4..4c30c35a7 100644 --- a/packages/tracker-core/src/whitelist/test_helpers.rs +++ b/packages/tracker-core/src/whitelist/test_helpers.rs @@ -17,19 +17,19 @@ pub(crate) mod tests { use crate::whitelist::setup::initialize_whitelist_manager; #[must_use] - pub fn initialize_whitelist_services(config: &Configuration) -> (Arc<WhitelistAuthorization>, Arc<WhitelistManager>) { - let database = initialize_database(&config.core); + pub async fn initialize_whitelist_services(config: &Configuration) -> (Arc<WhitelistAuthorization>, Arc<WhitelistManager>) { + let stores = initialize_database(&config.core).await; let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); let whitelist_authorization = Arc::new(WhitelistAuthorization::new(&config.core, &in_memory_whitelist.clone())); - let whitelist_manager = initialize_whitelist_manager(database.clone(), in_memory_whitelist.clone()); + let whitelist_manager = initialize_whitelist_manager(stores.whitelist_store.clone(), in_memory_whitelist.clone()); (whitelist_authorization, whitelist_manager) } #[must_use] - pub fn initialize_whitelist_services_for_listed_tracker() -> (Arc<WhitelistAuthorization>, Arc<WhitelistManager>) { + pub async fn initialize_whitelist_services_for_listed_tracker() -> (Arc<WhitelistAuthorization>, Arc<WhitelistManager>) { use torrust_tracker_test_helpers::configuration; - initialize_whitelist_services(&configuration::ephemeral_listed()) + initialize_whitelist_services(&configuration::ephemeral_listed()).await } } diff --git a/packages/tracker-core/tests/common/test_env.rs b/packages/tracker-core/tests/common/test_env.rs index 3fe0464fe..c5f61366a 100644 --- a/packages/tracker-core/tests/common/test_env.rs +++ b/packages/tracker-core/tests/common/test_env.rs @@ -25,23 +25,21 @@ pub struct TestEnv { impl TestEnv { #[must_use] pub async fn started(core_config: Core) -> Self { - let test_env = TestEnv::new(core_config); + let test_env = TestEnv::new(core_config).await; test_env.start().await; test_env } #[must_use] - pub fn new(core_config: Core) -> Self { + pub async fn new(core_config: Core) -> Self { let core_config = Arc::new(core_config); let swarm_coordination_registry_container = Arc::new(SwarmCoordinationRegistryContainer::initialize( core_config.tracker_usage_statistics.into(), )); - let tracker_core_container = Arc::new(TrackerCoreContainer::initialize_from( - &core_config, - &swarm_coordination_registry_container, - )); + let tracker_core_container = + Arc::new(TrackerCoreContainer::initialize_from(&core_config, &swarm_coordination_registry_container).await); Self { swarm_coordination_registry_container, diff --git a/packages/tracker-core/tests/integration.rs b/packages/tracker-core/tests/integration.rs index b170aaebd..1c683923b 100644 --- a/packages/tracker-core/tests/integration.rs +++ b/packages/tracker-core/tests/integration.rs @@ -77,14 +77,35 @@ async fn it_should_persist_the_number_of_completed_peers_for_each_torrent_into_t // Ensure the swarm metadata is removed assert!(test_env.get_swarm_metadata(&info_hash).await.is_none()); - // Load torrents from the database to ensure the completed stats are persisted - test_env - .tracker_core_container - .torrents_manager - .load_torrents_from_database() - .unwrap(); + // Load torrents from the database to ensure the completed stats are persisted. + // Bound the wait with a timeout instead of a fixed iteration count so the + // test fails loudly on a stalled system rather than after an arbitrary + // number of immediate retries. Re-check the desired state (`downloads == 1`) + // inside the retry condition so an intermediate observation does not + // panic the test before the background listener has finished applying + // the persisted value. + let restored = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + test_env + .tracker_core_container + .torrents_manager + .load_torrents_from_database() + .await + .unwrap(); + + if let Some(swarm_metadata) = test_env.get_swarm_metadata(&info_hash).await { + if swarm_metadata.downloads() == 1 { + break true; + } + } - assert!(test_env.get_swarm_metadata(&info_hash).await.unwrap().downloads() == 1); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + }) + .await + .unwrap_or(false); + + assert!(restored); } #[tokio::test] diff --git a/packages/udp-protocol/Cargo.toml b/packages/udp-protocol/Cargo.toml index 31fd52af8..3bcde9a95 100644 --- a/packages/udp-protocol/Cargo.toml +++ b/packages/udp-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] description = "A library with the primitive types and functions for the BitTorrent UDP tracker protocol." -keywords = ["bittorrent", "library", "primitives", "udp"] +keywords = [ "bittorrent", "library", "primitives", "udp" ] name = "bittorrent-udp-tracker-protocol" readme = "README.md" diff --git a/packages/udp-tracker-core/Cargo.toml b/packages/udp-tracker-core/Cargo.toml index b3007eb80..45a74f93c 100644 --- a/packages/udp-tracker-core/Cargo.toml +++ b/packages/udp-tracker-core/Cargo.toml @@ -4,7 +4,7 @@ description = "A library with the core functionality needed to implement a BitTo documentation.workspace = true edition.workspace = true homepage.workspace = true -keywords = ["api", "bittorrent", "core", "library", "tracker"] +keywords = [ "api", "bittorrent", "core", "library", "tracker" ] license.workspace = true name = "bittorrent-udp-tracker-core" publish.workspace = true @@ -20,14 +20,14 @@ bittorrent-tracker-core = { version = "3.0.0-develop", path = "../tracker-core" bittorrent-udp-tracker-protocol = { version = "3.0.0-develop", path = "../udp-protocol" } bloom = "0.3.2" blowfish = "0" -cipher = "0" -criterion = { version = "0.5.1", features = ["async_tokio"] } +cipher = "0.5" +criterion = { version = "0.5.1", features = [ "async_tokio" ] } futures = "0" lazy_static = "1" rand = "0" serde = "1.0.219" thiserror = "2" -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] } +tokio = { version = "1", features = [ "macros", "net", "rt-multi-thread", "signal", "sync", "time" ] } tokio-util = "0.7.15" torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } diff --git a/packages/udp-tracker-core/benches/udp_tracker_core_benchmark.rs b/packages/udp-tracker-core/benches/udp_tracker_core_benchmark.rs index 5bd0e27c8..90fc721d0 100644 --- a/packages/udp-tracker-core/benches/udp_tracker_core_benchmark.rs +++ b/packages/udp-tracker-core/benches/udp_tracker_core_benchmark.rs @@ -9,7 +9,7 @@ use crate::helpers::sync; fn bench_connect_once(c: &mut Criterion) { let mut group = c.benchmark_group("udp_tracker/connect_once"); group.warm_up_time(Duration::from_millis(500)); - group.measurement_time(Duration::from_millis(1000)); + group.measurement_time(Duration::from_secs(1)); group.bench_function("connect_once", |b| { b.iter(|| sync::connect_once(100)); diff --git a/packages/udp-tracker-core/src/connection_cookie.rs b/packages/udp-tracker-core/src/connection_cookie.rs index ce255705f..2d8e941cd 100644 --- a/packages/udp-tracker-core/src/connection_cookie.rs +++ b/packages/udp-tracker-core/src/connection_cookie.rs @@ -84,7 +84,6 @@ use tracing::instrument; use zerocopy::AsBytes; use crate::crypto::keys::CipherArrayBlowfish; - /// Error returned when there was an error with the connection cookie. #[derive(Error, Debug, Clone, PartialEq)] pub enum ConnectionCookieError { @@ -140,8 +139,8 @@ use std::ops::Range; pub fn check(cookie: &Cookie, fingerprint: u64, valid_range: Range<f64>) -> Result<f64, ConnectionCookieError> { assert!(valid_range.start <= valid_range.end, "range start is larger than range end"); - let cookie_bytes = CipherArrayBlowfish::from_slice(cookie.0.as_bytes()); - let cookie_bytes = decode(*cookie_bytes); + let cookie_bytes = CipherArrayBlowfish::try_from(cookie.0.as_bytes()).expect("it should be the same size"); + let cookie_bytes = decode(cookie_bytes); let issue_time = disassemble(fingerprint, cookie_bytes); @@ -176,7 +175,7 @@ pub fn gen_remote_fingerprint(remote_addr: &SocketAddr) -> u64 { } mod cookie_builder { - use cipher::{BlockDecrypt, BlockEncrypt}; + use cipher::{BlockCipherDecrypt, BlockCipherEncrypt}; use tracing::instrument; use zerocopy::{byteorder, AsBytes as _, NativeEndian}; @@ -196,7 +195,7 @@ mod cookie_builder { let cookie: byteorder::I64<NativeEndian> = *zerocopy::FromBytes::ref_from(&cookie.to_ne_bytes()).expect("it should be aligned"); - *CipherArrayBlowfish::from_slice(cookie.as_bytes()) + CipherArrayBlowfish::try_from(cookie.as_bytes()).expect("it should be the same size") } #[instrument()] diff --git a/packages/udp-tracker-core/src/container.rs b/packages/udp-tracker-core/src/container.rs index 1d8b1d71c..e6db5aec6 100644 --- a/packages/udp-tracker-core/src/container.rs +++ b/packages/udp-tracker-core/src/container.rs @@ -31,15 +31,13 @@ pub struct UdpTrackerCoreContainer { impl UdpTrackerCoreContainer { #[must_use] - pub fn initialize(core_config: &Arc<Core>, udp_tracker_config: &Arc<UdpTracker>) -> Arc<UdpTrackerCoreContainer> { + pub async fn initialize(core_config: &Arc<Core>, udp_tracker_config: &Arc<UdpTracker>) -> Arc<UdpTrackerCoreContainer> { let swarm_coordination_registry_container = Arc::new(SwarmCoordinationRegistryContainer::initialize( core_config.tracker_usage_statistics.into(), )); - let tracker_core_container = Arc::new(TrackerCoreContainer::initialize_from( - core_config, - &swarm_coordination_registry_container, - )); + let tracker_core_container = + Arc::new(TrackerCoreContainer::initialize_from(core_config, &swarm_coordination_registry_container).await); Self::initialize_from_tracker_core(&tracker_core_container, udp_tracker_config) } diff --git a/packages/udp-tracker-core/src/crypto/ephemeral_instance_keys.rs b/packages/udp-tracker-core/src/crypto/ephemeral_instance_keys.rs index 58ba70562..357bdeca5 100644 --- a/packages/udp-tracker-core/src/crypto/ephemeral_instance_keys.rs +++ b/packages/udp-tracker-core/src/crypto/ephemeral_instance_keys.rs @@ -4,14 +4,13 @@ //! application starts and are not persisted anywhere. use blowfish::BlowfishLE; -use cipher::generic_array::GenericArray; -use cipher::{BlockSizeUser, KeyInit}; +use cipher::{Block, KeyInit}; use rand::rngs::ThreadRng; -use rand::Rng; +use rand::RngExt; pub type Seed = [u8; 32]; pub type CipherBlowfish = BlowfishLE; -pub type CipherArrayBlowfish = GenericArray<u8, <CipherBlowfish as BlockSizeUser>::BlockSize>; +pub type CipherArrayBlowfish = Block<CipherBlowfish>; lazy_static! { /// The random static seed. diff --git a/packages/udp-tracker-core/src/crypto/keys.rs b/packages/udp-tracker-core/src/crypto/keys.rs index f9a3e361d..2faa745c3 100644 --- a/packages/udp-tracker-core/src/crypto/keys.rs +++ b/packages/udp-tracker-core/src/crypto/keys.rs @@ -5,6 +5,8 @@ //! //! It also provides the logic for the cipher for encryption and decryption. +use cipher::{BlockCipherDecrypt, BlockCipherEncrypt}; + use self::detail_cipher::CURRENT_CIPHER; use self::detail_seed::CURRENT_SEED; pub use crate::crypto::ephemeral_instance_keys::CipherArrayBlowfish; @@ -13,7 +15,7 @@ use crate::crypto::ephemeral_instance_keys::{CipherBlowfish, Seed, RANDOM_CIPHER /// This trait is for structures that can keep and provide a seed. pub trait Keeper { type Seed: Sized + Default + AsMut<[u8]>; - type Cipher: cipher::BlockCipher; + type Cipher: BlockCipherEncrypt + BlockCipherDecrypt; /// It returns a reference to the seed that is keeping. fn get_seed() -> &'static Self::Seed; @@ -135,14 +137,14 @@ mod detail_cipher { #[cfg(test)] mod tests { - use cipher::BlockEncrypt; + use cipher::BlockCipherEncrypt; use crate::crypto::ephemeral_instance_keys::{CipherArrayBlowfish, ZEROED_TEST_CIPHER_BLOWFISH}; use crate::crypto::keys::detail_cipher::CURRENT_CIPHER; #[test] fn it_should_default_to_zeroed_seed_when_testing() { - let mut data: cipher::generic_array::GenericArray<u8, _> = CipherArrayBlowfish::from([0u8; 8]); + let mut data = CipherArrayBlowfish::from([0u8; 8]); let mut data_2 = CipherArrayBlowfish::from([0u8; 8]); CURRENT_CIPHER.encrypt_block(&mut data); diff --git a/packages/udp-tracker-server/Cargo.toml b/packages/udp-tracker-server/Cargo.toml index 160fe58f9..dc66572d8 100644 --- a/packages/udp-tracker-server/Cargo.toml +++ b/packages/udp-tracker-server/Cargo.toml @@ -4,7 +4,7 @@ description = "The Torrust Bittorrent UDP tracker." documentation.workspace = true edition.workspace = true homepage.workspace = true -keywords = ["axum", "bittorrent", "server", "torrust", "tracker", "udp"] +keywords = [ "axum", "bittorrent", "server", "torrust", "tracker", "udp" ] license.workspace = true name = "torrust-udp-tracker-server" publish.workspace = true @@ -19,13 +19,13 @@ bittorrent-primitives = "0.1.0" bittorrent-tracker-client = { version = "3.0.0-develop", path = "../tracker-client" } bittorrent-tracker-core = { version = "3.0.0-develop", path = "../tracker-core" } bittorrent-udp-tracker-core = { version = "3.0.0-develop", path = "../udp-tracker-core" } -derive_more = { version = "2", features = ["as_ref", "constructor", "from"] } +derive_more = { version = "2", features = [ "as_ref", "constructor", "from" ] } futures = "0" futures-util = "0" ringbuf = "0" serde = "1.0.219" thiserror = "2" -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +tokio = { version = "1", features = [ "macros", "net", "rt-multi-thread", "signal", "sync" ] } tokio-util = "0.7.15" torrust-server-lib = { version = "3.0.0-develop", path = "../server-lib" } torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } @@ -35,8 +35,8 @@ torrust-tracker-metrics = { version = "3.0.0-develop", path = "../metrics" } torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } torrust-tracker-swarm-coordination-registry = { version = "3.0.0-develop", path = "../swarm-coordination-registry" } tracing = "0" -url = { version = "2", features = ["serde"] } -uuid = { version = "1", features = ["v4"] } +url = { version = "2", features = [ "serde" ] } +uuid = { version = "1", features = [ "v4" ] } zerocopy = "0.7" [dev-dependencies] diff --git a/packages/udp-tracker-server/src/environment.rs b/packages/udp-tracker-server/src/environment.rs index 13e18ba9b..36c5dcd1d 100644 --- a/packages/udp-tracker-server/src/environment.rs +++ b/packages/udp-tracker-server/src/environment.rs @@ -32,10 +32,10 @@ where impl Environment<Stopped> { #[allow(dead_code)] #[must_use] - pub fn new(configuration: &Arc<Configuration>) -> Self { + pub async fn new(configuration: &Arc<Configuration>) -> Self { initialize_global_services(configuration); - let container = Arc::new(EnvContainer::initialize(configuration)); + let container = Arc::new(EnvContainer::initialize(configuration).await); let bind_to = container.udp_tracker_core_container.udp_tracker_config.bind_address; @@ -112,7 +112,7 @@ impl Environment<Running> { /// /// Will panic if it cannot start the server within the timeout. pub async fn new(configuration: &Arc<Configuration>) -> Self { - tokio::time::timeout(DEFAULT_TIMEOUT, Environment::<Stopped>::new(configuration).start()) + tokio::time::timeout(DEFAULT_TIMEOUT, Environment::<Stopped>::new(configuration).await.start()) .await .expect("Failed to create a UDP tracker server running environment within the timeout") } @@ -179,7 +179,7 @@ impl EnvContainer { /// /// Will panic if the configuration is missing the UDP tracker configuration. #[must_use] - pub fn initialize(configuration: &Configuration) -> Self { + pub async fn initialize(configuration: &Configuration) -> Self { let core_config = Arc::new(configuration.core.clone()); let udp_tracker_configurations = configuration.udp_trackers.clone().expect("missing UDP tracker configuration"); let udp_tracker_config = Arc::new(udp_tracker_configurations[0].clone()); @@ -188,10 +188,8 @@ impl EnvContainer { core_config.tracker_usage_statistics.into(), )); - let tracker_core_container = Arc::new(TrackerCoreContainer::initialize_from( - &core_config, - &swarm_coordination_registry_container, - )); + let tracker_core_container = + Arc::new(TrackerCoreContainer::initialize_from(&core_config, &swarm_coordination_registry_container).await); let udp_tracker_core_container = UdpTrackerCoreContainer::initialize_from_tracker_core(&tracker_core_container, &udp_tracker_config); diff --git a/packages/udp-tracker-server/src/handlers/announce.rs b/packages/udp-tracker-server/src/handlers/announce.rs index ea19611ce..b74de43a0 100644 --- a/packages/udp-tracker-server/src/handlers/announce.rs +++ b/packages/udp-tracker-server/src/handlers/announce.rs @@ -232,7 +232,7 @@ pub(crate) mod tests { #[tokio::test] async fn an_announced_peer_should_be_added_to_the_tracker() { let (core_tracker_services, core_udp_tracker_services, server_udp_tracker_services) = - initialize_core_tracker_services_for_public_tracker(); + initialize_core_tracker_services_for_public_tracker().await; let client_ip = Ipv4Addr::new(126, 0, 0, 1); let client_port = 8080; @@ -280,7 +280,7 @@ pub(crate) mod tests { #[tokio::test] async fn the_announced_peer_should_not_be_included_in_the_response() { let (core_tracker_services, core_udp_tracker_services, server_udp_tracker_services) = - initialize_core_tracker_services_for_public_tracker(); + initialize_core_tracker_services_for_public_tracker().await; let client_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080); let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969); @@ -324,7 +324,7 @@ pub(crate) mod tests { // "Do note that most trackers will only honor the IP address field under limited circumstances." let (core_tracker_services, core_udp_tracker_services, server_udp_tracker_services) = - initialize_core_tracker_services_for_public_tracker(); + initialize_core_tracker_services_for_public_tracker().await; let info_hash = AquaticInfoHash([0u8; 20]); let peer_id = AquaticPeerId([255u8; 20]); @@ -420,7 +420,7 @@ pub(crate) mod tests { #[tokio::test] async fn when_the_announce_request_comes_from_a_client_using_ipv4_the_response_should_not_include_peers_using_ipv6() { let (core_tracker_services, core_udp_tracker_services, _server_udp_tracker_services) = - initialize_core_tracker_services_for_public_tracker(); + initialize_core_tracker_services_for_public_tracker().await; add_a_torrent_peer_using_ipv6(&core_tracker_services.in_memory_torrent_repository).await; @@ -456,7 +456,7 @@ pub(crate) mod tests { Some(Arc::new(udp_server_stats_event_sender_mock)); let (core_tracker_services, core_udp_tracker_services, _server_udp_tracker_services) = - initialize_core_tracker_services_for_default_tracker_configuration(); + initialize_core_tracker_services_for_default_tracker_configuration().await; handle_announce( &core_udp_tracker_services.announce_service, @@ -489,7 +489,7 @@ pub(crate) mod tests { #[tokio::test] async fn the_peer_ip_should_be_changed_to_the_external_ip_in_the_tracker_configuration_if_defined() { let (core_tracker_services, core_udp_tracker_services, server_udp_tracker_services) = - initialize_core_tracker_services_for_public_tracker(); + initialize_core_tracker_services_for_public_tracker().await; let client_ip = Ipv4Addr::LOCALHOST; let client_port = 8080; @@ -573,7 +573,7 @@ pub(crate) mod tests { #[tokio::test] async fn an_announced_peer_should_be_added_to_the_tracker() { let (core_tracker_services, core_udp_tracker_services, server_udp_tracker_services) = - initialize_core_tracker_services_for_public_tracker(); + initialize_core_tracker_services_for_public_tracker().await; let client_ip_v4 = Ipv4Addr::new(126, 0, 0, 1); let client_ip_v6 = client_ip_v4.to_ipv6_compatible(); @@ -622,7 +622,7 @@ pub(crate) mod tests { #[tokio::test] async fn the_announced_peer_should_not_be_included_in_the_response() { let (core_tracker_services, core_udp_tracker_services, server_udp_tracker_services) = - initialize_core_tracker_services_for_public_tracker(); + initialize_core_tracker_services_for_public_tracker().await; let client_ip_v4 = Ipv4Addr::new(126, 0, 0, 1); let client_ip_v6 = client_ip_v4.to_ipv6_compatible(); @@ -669,7 +669,7 @@ pub(crate) mod tests { // "Do note that most trackers will only honor the IP address field under limited circumstances." let (core_tracker_services, core_udp_tracker_services, server_udp_tracker_service) = - initialize_core_tracker_services_for_public_tracker(); + initialize_core_tracker_services_for_public_tracker().await; let info_hash = AquaticInfoHash([0u8; 20]); let peer_id = AquaticPeerId([255u8; 20]); @@ -780,7 +780,7 @@ pub(crate) mod tests { #[tokio::test] async fn when_the_announce_request_comes_from_a_client_using_ipv6_the_response_should_not_include_peers_using_ipv4() { let (core_tracker_services, _core_udp_tracker_services, _server_udp_tracker_services) = - initialize_core_tracker_services_for_public_tracker(); + initialize_core_tracker_services_for_public_tracker().await; add_a_torrent_peer_using_ipv4(&core_tracker_services.in_memory_torrent_repository).await; @@ -823,7 +823,7 @@ pub(crate) mod tests { Some(Arc::new(udp_server_stats_event_sender_mock)); let (core_tracker_services, core_udp_tracker_services, _server_udp_tracker_services) = - initialize_core_tracker_services_for_default_tracker_configuration(); + initialize_core_tracker_services_for_default_tracker_configuration().await; handle_announce( &core_udp_tracker_services.announce_service, @@ -891,12 +891,13 @@ pub(crate) mod tests { let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); let server_service_binding_clone = server_service_binding.clone(); - let database = initialize_database(&config.core); + let database = initialize_database(&config.core).await; let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); let whitelist_authorization = Arc::new(WhitelistAuthorization::new(&config.core, &in_memory_whitelist.clone())); let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); - let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + let db_downloads_metric_repository = + Arc::new(DatabaseDownloadsMetricRepository::new(&database.torrent_metrics_store)); let request = AnnounceRequestBuilder::default() .with_connection_id(make(gen_remote_fingerprint(&client_socket_addr), sample_issue_time()).unwrap()) diff --git a/packages/udp-tracker-server/src/handlers/mod.rs b/packages/udp-tracker-server/src/handlers/mod.rs index add576a89..acbaed905 100644 --- a/packages/udp-tracker-server/src/handlers/mod.rs +++ b/packages/udp-tracker-server/src/handlers/mod.rs @@ -250,30 +250,30 @@ pub(crate) mod tests { configuration::ephemeral() } - pub(crate) fn initialize_core_tracker_services_for_default_tracker_configuration( + pub(crate) async fn initialize_core_tracker_services_for_default_tracker_configuration( ) -> (CoreTrackerServices, CoreUdpTrackerServices, ServerUdpTrackerServices) { - initialize_core_tracker_services(&default_testing_tracker_configuration()) + initialize_core_tracker_services(&default_testing_tracker_configuration()).await } - pub(crate) fn initialize_core_tracker_services_for_public_tracker( + pub(crate) async fn initialize_core_tracker_services_for_public_tracker( ) -> (CoreTrackerServices, CoreUdpTrackerServices, ServerUdpTrackerServices) { - initialize_core_tracker_services(&configuration::ephemeral_public()) + initialize_core_tracker_services(&configuration::ephemeral_public()).await } - pub(crate) fn initialize_core_tracker_services_for_listed_tracker( + pub(crate) async fn initialize_core_tracker_services_for_listed_tracker( ) -> (CoreTrackerServices, CoreUdpTrackerServices, ServerUdpTrackerServices) { - initialize_core_tracker_services(&configuration::ephemeral_listed()) + initialize_core_tracker_services(&configuration::ephemeral_listed()).await } - fn initialize_core_tracker_services( + async fn initialize_core_tracker_services( config: &Configuration, ) -> (CoreTrackerServices, CoreUdpTrackerServices, ServerUdpTrackerServices) { let core_config = Arc::new(config.core.clone()); - let database = initialize_database(&config.core); + let database = initialize_database(&config.core).await; let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); let whitelist_authorization = Arc::new(WhitelistAuthorization::new(&config.core, &in_memory_whitelist.clone())); let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); - let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database.torrent_metrics_store)); let announce_handler = Arc::new(AnnounceHandler::new( &config.core, &whitelist_authorization, diff --git a/packages/udp-tracker-server/src/handlers/scrape.rs b/packages/udp-tracker-server/src/handlers/scrape.rs index 8bac05c1e..8bd86f509 100644 --- a/packages/udp-tracker-server/src/handlers/scrape.rs +++ b/packages/udp-tracker-server/src/handlers/scrape.rs @@ -53,19 +53,22 @@ pub async fn handle_scrape( Ok(build_response(request, &scrape_data)) } +fn udp_counter_from_u32(value: u32) -> i32 { + // Temporary saturation guard for UDP i32 counters. Proper type alignment across Rust and DB layers + // will be addressed in docs/issues/1525-07-align-rust-and-db-types.md. + i32::try_from(value).unwrap_or(i32::MAX) +} + fn build_response(request: &ScrapeRequest, scrape_data: &ScrapeData) -> Response { let mut torrent_stats: Vec<TorrentScrapeStatistics> = Vec::new(); for file in &scrape_data.files { let swarm_metadata = file.1; - #[allow(clippy::cast_possible_truncation)] - let scrape_entry = { - TorrentScrapeStatistics { - seeders: NumberOfPeers(I32::new(i64::from(swarm_metadata.complete) as i32)), - completed: NumberOfDownloads(I32::new(i64::from(swarm_metadata.downloaded) as i32)), - leechers: NumberOfPeers(I32::new(i64::from(swarm_metadata.incomplete) as i32)), - } + let scrape_entry = TorrentScrapeStatistics { + seeders: NumberOfPeers(I32::new(udp_counter_from_u32(swarm_metadata.complete))), + completed: NumberOfDownloads(I32::new(udp_counter_from_u32(swarm_metadata.downloaded))), + leechers: NumberOfPeers(I32::new(udp_counter_from_u32(swarm_metadata.incomplete))), }; torrent_stats.push(scrape_entry); @@ -115,7 +118,7 @@ mod tests { #[tokio::test] async fn should_return_no_stats_when_the_tracker_does_not_have_any_torrent() { let (_core_tracker_services, core_udp_tracker_services, server_udp_tracker_services) = - initialize_core_tracker_services_for_public_tracker(); + initialize_core_tracker_services_for_public_tracker().await; let client_socket_addr = sample_ipv4_remote_addr(); let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969); @@ -232,7 +235,7 @@ mod tests { #[tokio::test] async fn should_return_torrent_statistics_when_the_tracker_has_the_requested_torrent() { let (core_tracker_services, core_udp_tracker_services, _server_udp_tracker_services) = - initialize_core_tracker_services_for_public_tracker(); + initialize_core_tracker_services_for_public_tracker().await; let torrent_stats = match_scrape_response( add_a_sample_seeder_and_scrape(core_tracker_services.into(), core_udp_tracker_services.into()).await, @@ -265,7 +268,7 @@ mod tests { #[tokio::test] async fn should_return_the_torrent_statistics_when_the_requested_torrent_is_whitelisted() { let (core_tracker_services, core_udp_tracker_services, server_udp_tracker_services) = - initialize_core_tracker_services_for_listed_tracker(); + initialize_core_tracker_services_for_listed_tracker().await; let client_socket_addr = sample_ipv4_remote_addr(); let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969); @@ -310,7 +313,7 @@ mod tests { #[tokio::test] async fn should_return_zeroed_statistics_when_the_requested_torrent_is_not_whitelisted() { let (core_tracker_services, core_udp_tracker_services, server_udp_tracker_services) = - initialize_core_tracker_services_for_listed_tracker(); + initialize_core_tracker_services_for_listed_tracker().await; let client_socket_addr = sample_ipv4_remote_addr(); let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969); @@ -393,7 +396,7 @@ mod tests { Some(Arc::new(udp_server_stats_event_sender_mock)); let (_core_tracker_services, core_udp_tracker_services, _server_udp_tracker_services) = - initialize_core_tracker_services_for_default_tracker_configuration(); + initialize_core_tracker_services_for_default_tracker_configuration().await; handle_scrape( &core_udp_tracker_services.scrape_service, @@ -443,7 +446,7 @@ mod tests { Some(Arc::new(udp_server_stats_event_sender_mock)); let (_core_tracker_services, core_udp_tracker_services, _server_udp_tracker_services) = - initialize_core_tracker_services_for_default_tracker_configuration(); + initialize_core_tracker_services_for_default_tracker_configuration().await; handle_scrape( &core_udp_tracker_services.scrape_service, @@ -458,4 +461,11 @@ mod tests { } } } + + #[test] + fn should_saturate_large_download_counts_for_udp_protocol() { + assert_eq!(super::udp_counter_from_u32(u32::MAX), i32::MAX); + assert_eq!(super::udp_counter_from_u32((i32::MAX as u32) + 1), i32::MAX); + assert_eq!(super::udp_counter_from_u32(42), 42); + } } diff --git a/packages/udp-tracker-server/src/server/launcher.rs b/packages/udp-tracker-server/src/server/launcher.rs index a514921cc..4fd3a95d9 100644 --- a/packages/udp-tracker-server/src/server/launcher.rs +++ b/packages/udp-tracker-server/src/server/launcher.rs @@ -54,7 +54,7 @@ impl Launcher { panic!("it should not use udp if using authentication"); } - let socket = tokio::time::timeout(Duration::from_millis(5000), BoundSocket::new(bind_to)) + let socket = tokio::time::timeout(Duration::from_secs(5), BoundSocket::new(bind_to)) .await .expect("it should bind to the socket within five seconds"); diff --git a/packages/udp-tracker-server/src/server/mod.rs b/packages/udp-tracker-server/src/server/mod.rs index f70e28b27..c46277e50 100644 --- a/packages/udp-tracker-server/src/server/mod.rs +++ b/packages/udp-tracker-server/src/server/mod.rs @@ -98,7 +98,7 @@ mod tests { let stopped = Server::new(Spawner::new(bind_to)); - let udp_tracker_core_container = UdpTrackerCoreContainer::initialize(&core_config, &udp_tracker_config); + let udp_tracker_core_container = UdpTrackerCoreContainer::initialize(&core_config, &udp_tracker_config).await; let udp_tracker_server_container = UdpTrackerServerContainer::initialize(&core_config); let started = stopped @@ -138,7 +138,7 @@ mod tests { let stopped = Server::new(Spawner::new(bind_to)); - let udp_tracker_core_container = UdpTrackerCoreContainer::initialize(&core_config, &udp_tracker_config); + let udp_tracker_core_container = UdpTrackerCoreContainer::initialize(&core_config, &udp_tracker_config).await; let udp_tracker_server_container = UdpTrackerServerContainer::initialize(&core_config); let started = stopped diff --git a/packages/udp-tracker-server/src/statistics/repository.rs b/packages/udp-tracker-server/src/statistics/repository.rs index 94a86e3ab..c4c995b8a 100644 --- a/packages/udp-tracker-server/src/statistics/repository.rs +++ b/packages/udp-tracker-server/src/statistics/repository.rs @@ -330,7 +330,7 @@ mod tests { // Calculate new average with processing time of 2000ns // This will increment the processed requests counter from 0 to 1 - let processing_time = Duration::from_nanos(2000); + let processing_time = Duration::from_micros(2); let new_avg = repo .recalculate_udp_avg_processing_time_ns(processing_time, &connect_labels, now) .await; @@ -417,7 +417,7 @@ mod tests { let now = CurrentClock::now(); // Test with zero connections (should not panic, should handle division by zero) - let processing_time = Duration::from_nanos(1000); + let processing_time = Duration::from_micros(1); let connect_labels = LabelSet::from([("request_kind", "connect")]); let connect_avg = repo diff --git a/packages/udp-tracker-server/tests/server/contract.rs b/packages/udp-tracker-server/tests/server/contract.rs index e9691c879..350f3b8eb 100644 --- a/packages/udp-tracker-server/tests/server/contract.rs +++ b/packages/udp-tracker-server/tests/server/contract.rs @@ -32,7 +32,7 @@ async fn send_connection_request(transaction_id: TransactionId, client: &UdpTrac match response { Response::Connect(connect_response) => connect_response.connection_id, - _ => panic!("error connecting to udp server {:?}", response), + _ => panic!("error connecting to udp server {response:?}"), } } diff --git a/project-words.txt b/project-words.txt new file mode 100644 index 000000000..98ea65f62 --- /dev/null +++ b/project-words.txt @@ -0,0 +1,306 @@ +actix +Addrs +adduser +adminadmin +adrs +Agentic +agentskills +Aideq +alekitto +analyse +appuser +Arvid +ASMS +asyn +autoclean +AUTOINCREMENT +autolinks +automock +autoremove +Avicora +Azureus +backlinks +bdecode +behaviour +bencode +bencoded +bencoding +beps +binascii +binstall +Bitflu +bools +Bragilevsky +bufs +buildid +Buildx +byteorder +callgrind +CALLSITE +camino +canonicalize +canonicalized +certbot +chrono +Cinstrument +ciphertext +clippy +cloneable +codecov +codegen +commiter +completei +composecheck +Condvar +connectionless +Containerfile +conv +curr +cvar +Cyberneering +cyclomatic +dashmap +datagram +datetime +dbip +dbname +debuginfo +Deque +Dihc +Dijke +distroless +Dmqcd +dockerhub +downloadedi +dtolnay +elif +endianness +Eray +eventfd +fastrand +fdbased +fdget +filesd +finalises +flamegraph +formatjson +fput +Freebox +frontmatter +Frostegård +gecos +Gibibytes +Glrg +Grcov +hasher +healthcheck +heaptrack +hexdigit +hexlify +hlocalhost +hmac +Hydranode +hyperthread +Icelake +iiiiiiiiiiiiiiiiiiiid +iiiiiiiiiiiiiiiipp +iiiiiiiiiiiiiiiippe +iiiiiiiiiiiiiiip +iiiipp +iipp +imdl +impls +incompletei +infohash +infohashes +infoschema +Intermodal +intervali +Irwe +isready +iterationsadd +jdbe +Joakim +josecelano +kallsyms +Karatay +kcachegrind +kexec +keyout +Kibibytes +kptr +ksys +lcov +leecher +leechers +libsqlite +libtorrent +libz +llist +LOGNAME +Lphant +lscr +matchmakes +Mebibytes +metainfo +middlewares +misresolved +mmap +mmdb +mockall +mprotect +MSRV +multimap +myacicontext +mysqladmin +mysqld +ñaca +Naim +nanos +newkey +newtype +newtypes +nextest +nocapture +nologin +nonblocking +nonroot +Norberg +numwant +nvCFlJCq7fz7Qx6KoKTDiMZvns8l5Kw7 +obra +oneshot +ostr +Pando +peekable +peerlist +peersld +penalise +PGID +pipefail +pkey +porti +prealloc +println +programatik +proot +proto +PUID +qbittorrent +QJSF +Quickstart +Radeon +RAII +Rakshasa +randomised +Rasterbar +realpath +reannounce +referer +Registar +repomix +repr +reqs +reqwest +reuseaddr +rerequests +ringbuf +ringsize +rngs +rosegment +routable +rsplit +rstest +rusqlite +rustc +RUSTDOCFLAGS +RUSTFLAGS +rustfmt +Rustls +rustup +Ryzen +savepath +Seedable +serde +setgroups +Shareaza +sharktorrent +shellcheck +SHLVL +skiplist +slowloris +socat +socketaddr +sockfd +specialised +sqllite +sqlx +stabilised +subissue +Subissue +Subissues +subkey +subsec +supertrait +Swatinem +Swiftbit +syscall +sysmalloc +sysret +taiki +taplo +tdyne +Tebibytes +tempfile +Tera +testcontainer +testcontainers +thiserror +timespec +tlsv +toki +toplevel +Torrentstorm +torru +torrust +torrustracker +trackerid +Trackon +trixie +ttwu +typenum +udpv +Unamed +underflows +uninit +Uninit +unparked +Unparker +Unsendable +unsync +untuple +upcasting +uroot +usize +Vagaa +valgrind +VARCHAR +Vitaly +vmlinux +vtable +Vuze +wakelist +wakeup +WEBUI +Weidendorfer +Werror +whitespaces +Xacrimon +XBTT +Xdebug +Xeon +Xtorrent +Xunlei +xxxxxxxxxxxxxxxxxxxxd +yyyyyyyyyyyyyyyyyyyyd +zerocopy diff --git a/share/default/config/tracker.container.mysql.toml b/share/default/config/tracker.container.mysql.toml index 865ea224e..33fcf713a 100644 --- a/share/default/config/tracker.container.mysql.toml +++ b/share/default/config/tracker.container.mysql.toml @@ -12,6 +12,9 @@ private = false [core.database] driver = "mysql" +# If the MySQL password includes reserved URL characters (for example + or /), +# percent-encode it in the DSN password component. +# Example: password a+b/c -> a%2Bb%2Fc path = "mysql://db_user:db_user_secret_password@mysql:3306/torrust_tracker" # Uncomment to enable services diff --git a/src/AGENTS.md b/src/AGENTS.md new file mode 100644 index 000000000..88296f152 --- /dev/null +++ b/src/AGENTS.md @@ -0,0 +1,109 @@ +# `src/` — Binary and Library Entry Points + +This directory contains only the top-level wiring of the application: the binary entry points, +the bootstrap sequence, and the dependency-injection container. All domain logic lives in +`packages/`; this directory merely assembles and launches it. + +## File Map + +| Path | Purpose | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `main.rs` | Binary entry point. Calls `app::run()`, waits for Ctrl-C, then cancels jobs and waits for graceful shutdown. | +| `lib.rs` | Library crate root and crate-level documentation. Re-exports the public API used by integration tests and other binaries. | +| `app.rs` | `run()` and `start()` — orchestrates the full startup sequence (setup → load data from DB → start jobs). | +| `container.rs` | `AppContainer` — dependency-injection struct that holds `Arc`-wrapped instances of every per-layer container. | +| `bootstrap/app.rs` | `setup()` — loads config, validates it, initializes logging and global services, builds `AppContainer`. | +| `bootstrap/config.rs` | `initialize_configuration()` — reads config from the environment / file. | +| `bootstrap/jobs/` | One module per service: each module exposes a starter function called from `app::start_jobs`. | +| `bootstrap/jobs/manager.rs` | `JobManager` — collects `JoinHandle`s, owns the `CancellationToken`, and drives graceful shutdown. | +| `bin/e2e_tests_runner.rs` | Binary that runs E2E tests by delegating to `src/console/ci/`. | +| `bin/http_health_check.rs` | Minimal HTTP health-check binary used inside containers (avoids curl/wget dependency). | +| `bin/profiling.rs` | Binary for Valgrind / kcachegrind profiling sessions. | +| `console/` | Internal console apps (`ci/e2e`, `profiling`) used by the extra binaries above. | + +## Bootstrap Flow + +```text +main() + └─ app::run() + ├─ bootstrap::app::setup() + │ ├─ bootstrap::config::initialize_configuration() ← reads TOML / env vars + │ ├─ configuration.validate() ← panics on invalid config + │ ├─ initialize_global_services() ← logging, crypto seed + │ └─ AppContainer::initialize(&configuration) ← builds all containers + │ + └─ app::start(&config, &app_container) + ├─ load_data_from_database() ← peer keys, whitelist, metrics + └─ start_jobs() + ├─ start_swarm_coordination_registry_event_listener + ├─ start_tracker_core_event_listener + ├─ start_http_core_event_listener + ├─ start_udp_core_event_listener + ├─ start_udp_server_stats_event_listener + ├─ start_udp_server_banning_event_listener + ├─ start_the_udp_instances ← one job per configured UDP bind address + ├─ start_the_http_instances ← one job per configured HTTP bind address + ├─ start_torrent_cleanup + ├─ start_peers_inactivity_update + ├─ start_the_http_api + └─ start_health_check_api ← always started +``` + +Shutdown (`main`): receives `Ctrl-C` → calls `jobs.cancel()` (fires the `CancellationToken`) → +waits up to 10 seconds for all `JoinHandle`s to complete. + +## `AppContainer` + +`AppContainer` (`container.rs`) is a plain struct — not a framework, not a trait object tree. +It holds one `Arc<…Container>` per architectural layer: + +| Field | Layer / Package | +| ------------------------------------------------------------------------------------------------ | -------------------------------------------------------- | +| `registar` | `server-lib` — tracks active server socket registrations | +| `swarm_coordination_registry_container` | `swarm-coordination-registry` | +| `tracker_core_container` | `tracker-core` | +| `http_tracker_core_services` / `http_tracker_instance_containers` | `http-tracker-core` | +| `udp_tracker_core_services` / `udp_tracker_server_container` / `udp_tracker_instance_containers` | `udp-tracker-core` / `udp-tracker-server` | + +`AppContainer::initialize` is the only place where domain containers are constructed. +Every `bootstrap/jobs/` starter receives an `&Arc<AppContainer>` and pulls out exactly what it +needs — no globals, no lazy statics for domain objects. + +## `JobManager` + +`JobManager` (`bootstrap/jobs/manager.rs`) is a thin wrapper around a `Vec<Job>` (each `Job` +holds a name + `JoinHandle<()>`) and a shared `CancellationToken`: + +- `push(name, handle)` — registers a job. +- `push_opt(name, handle)` — convenience for jobs that may be disabled. +- `cancel()` — fires the token; all jobs that own a clone of it will observe cancellation. +- `wait_for_all(timeout)` — joins all handles with a timeout, logging warnings for any that + exceed it. + +## Adding a New Service + +When wiring a new server or background task, follow this checklist in order: + +1. **Package** — add the new crate under `packages/` with the appropriate layer prefix. +2. **Container field** — add an `Arc<NewServiceContainer>` field to `AppContainer` and + initialize it inside `AppContainer::initialize`. +3. **Job launcher** — create `src/bootstrap/jobs/new_service.rs` and register it in + `src/bootstrap/jobs/mod.rs`. +4. **Wire into `app::start_jobs`** — call the new starter function and push its handle to + `job_manager`. +5. **Graceful shutdown** — ensure the new service listens for the `CancellationToken` passed + from `JobManager`. +6. **Config guard** — if the service is optional, gate the starter behind the appropriate + config field and use `push_opt`. + +## Key Rules for This Directory + +- **No domain logic here.** This directory is pure wiring. Business rules belong in `packages/`. +- **No globals for domain objects.** All state flows through `AppContainer`. +- **Startup errors panic.** `bootstrap::app::setup()` panics on invalid config or a bad crypto + seed — this is intentional (fail fast before binding ports). +- **Health check always starts.** The health-check API job is unconditional — do not gate it + behind a config flag. +- **`lib.rs` is the integration-test surface.** Integration tests import + `torrust_tracker_lib::…`. Keep the public API in `lib.rs` stable; avoid leaking internal + bootstrap details. diff --git a/src/app.rs b/src/app.rs index 2149a6d4c..dc93710de 100644 --- a/src/app.rs +++ b/src/app.rs @@ -36,7 +36,7 @@ use crate::container::AppContainer; use crate::CurrentClock; pub async fn run() -> (Arc<AppContainer>, JobManager) { - let (config, app_container) = bootstrap::app::setup(); + let (config, app_container) = bootstrap::app::setup().await; let app_container = Arc::new(app_container); diff --git a/src/bin/qbittorrent_e2e_runner.rs b/src/bin/qbittorrent_e2e_runner.rs new file mode 100644 index 000000000..e8017a041 --- /dev/null +++ b/src/bin/qbittorrent_e2e_runner.rs @@ -0,0 +1,52 @@ +//! Binary entry point for the qBittorrent end-to-end smoke test. +//! +//! This runner validates the full `BitTorrent` seeder→tracker→leecher flow using +//! real qBittorrent 5.1.4 containers: +//! +//! 1. Builds a local Torrust Tracker Docker image. +//! 2. Creates an ephemeral workspace (temporary directory) with all required +//! configuration files and pre-generated torrent + payload. +//! 3. Starts a Docker Compose stack (`compose.qbittorrent-e2e.yaml`) containing +//! a tracker, a seeder, and a leecher — all using randomly assigned host ports +//! so multiple runs can coexist. +//! 4. Authenticates with both `qBittorrent` `WebUI` instances. +//! 5. Uploads the torrent to the seeder and the leecher. +//! 6. Logs the torrent count reported by each client. +//! 7. Tears down the compose stack (RAII — even on failure). +//! +//! # Prerequisites +//! +//! - Docker (or compatible OCI runtime) must be installed and running. +//! - The `docker compose` plugin (v2) must be available on `PATH`. +//! - The workspace must be the repository root (default compose file and tracker +//! config template are resolved relative to the current working directory). +//! +//! # Usage +//! +//! ```text +//! cargo run --bin qbittorrent_e2e_runner -- \ +//! --compose-file ./compose.qbittorrent-e2e.yaml \ +//! --timeout-seconds 180 +//! ``` +//! +//! ## Key CLI flags +//! +//! | Flag | Default | Description | +//! |------|---------|-------------| +//! | `--compose-file` | `compose.qbittorrent-e2e.yaml` | Compose file for the scenario | +//! | `--timeout-seconds` | `180` | Per-operation HTTP timeout for `WebUI` calls | +//! | `--tracker-image` | `torrust-tracker:qbt-e2e-local` | Local Docker image tag built for the tracker | +//! | `--qbittorrent-image` | `lscr.io/linuxserver/qbittorrent:5.1.4` | qBittorrent image for seeder and leecher | +//! | `--project-prefix` | `qbt-e2e` | Prefix for the randomised compose project name | +//! +//! # Debugging +//! +//! See `contrib/dev-tools/debugging/qbt/` for standalone shell scripts that +//! probe a single qBittorrent container in isolation and validate the compose +//! stack without running the full Rust runner. +use torrust_tracker_lib::console::ci::qbittorrent_e2e; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + qbittorrent_e2e::runner::run().await +} diff --git a/src/bootstrap/app.rs b/src/bootstrap/app.rs index bcf000dfd..4671ccbfd 100644 --- a/src/bootstrap/app.rs +++ b/src/bootstrap/app.rs @@ -23,10 +23,10 @@ use crate::container::AppContainer; /// /// # Panics /// -/// Setup can file if the configuration is invalid. +/// Setup can fail if the configuration is invalid. #[must_use] #[instrument(skip())] -pub fn setup() -> (Configuration, AppContainer) { +pub async fn setup() -> (Configuration, AppContainer) { #[cfg(not(test))] check_seed(); @@ -40,7 +40,7 @@ pub fn setup() -> (Configuration, AppContainer) { tracing::info!("Configuration:\n{}", configuration.clone().mask_secrets().to_json()); - let app_container = AppContainer::initialize(&configuration); + let app_container = AppContainer::initialize(&configuration).await; (configuration, app_container) } diff --git a/src/bootstrap/jobs/http_tracker.rs b/src/bootstrap/jobs/http_tracker.rs index 013031395..e10b3b6d3 100644 --- a/src/bootstrap/jobs/http_tracker.rs +++ b/src/bootstrap/jobs/http_tracker.rs @@ -94,7 +94,7 @@ mod tests { initialize_global_services(&cfg); - let http_tracker_container = HttpTrackerCoreContainer::initialize(&core_config, &http_tracker_config); + let http_tracker_container = HttpTrackerCoreContainer::initialize(&core_config, &http_tracker_config).await; let version = Version::V1; diff --git a/src/bootstrap/jobs/tracker_apis.rs b/src/bootstrap/jobs/tracker_apis.rs index 9f3964c20..2d5eb14af 100644 --- a/src/bootstrap/jobs/tracker_apis.rs +++ b/src/bootstrap/jobs/tracker_apis.rs @@ -121,7 +121,8 @@ mod tests { initialize_global_services(&cfg); let http_api_container = - TrackerHttpApiCoreContainer::initialize(&core_config, &http_tracker_config, &udp_tracker_config, &http_api_config); + TrackerHttpApiCoreContainer::initialize(&core_config, &http_tracker_config, &udp_tracker_config, &http_api_config) + .await; let version = Version::V1; diff --git a/src/console/ci/compose.rs b/src/console/ci/compose.rs new file mode 100644 index 000000000..d1d215e75 --- /dev/null +++ b/src/console/ci/compose.rs @@ -0,0 +1,344 @@ +//! Docker compose command wrapper. +use std::io; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::time::{Duration, Instant}; + +use tokio::time::sleep; + +#[derive(Clone, Debug)] +pub struct DockerCompose { + file: PathBuf, + project: String, + env_vars: Vec<(String, String)>, +} + +#[derive(Debug)] +pub struct RunningCompose { + compose: DockerCompose, + is_active: bool, +} + +impl Drop for RunningCompose { + fn drop(&mut self) { + if !self.is_active { + return; + } + + if let Err(error) = self.compose.down() { + tracing::error!( + "Failed to stop compose project '{}' from '{}': {error}", + self.compose.project, + self.compose.file.display() + ); + } + } +} + +impl RunningCompose { + /// Returns the compose project name for this running stack. + #[must_use] + pub fn project(&self) -> &str { + &self.compose.project + } + + /// Disables the automatic teardown so containers are left running after this + /// guard is dropped. Useful for post-run debugging. + pub fn keep(&mut self) { + self.is_active = false; + } +} + +impl DockerCompose { + #[must_use] + pub fn new(file: &Path, project: &str) -> Self { + Self { + file: file.to_path_buf(), + project: project.to_string(), + env_vars: vec![], + } + } + + #[must_use] + pub fn with_env(mut self, key: &str, value: &str) -> Self { + self.env_vars.push((key.to_string(), value.to_string())); + self + } + + /// Runs docker compose up and returns a guard that will always run `down --volumes` on drop. + /// + /// # Errors + /// + /// Returns an error when docker compose fails to start all services. + pub fn up(&self) -> io::Result<RunningCompose> { + let output = self.run_compose(&["up", "--wait", "--detach"])?; + + if output.status.success() { + Ok(RunningCompose { + compose: self.clone(), + is_active: true, + }) + } else { + Err(io::Error::new( + io::ErrorKind::Other, + format!( + "docker compose up failed for file '{}' and project '{}': {}", + self.file.display(), + self.project, + String::from_utf8_lossy(&output.stderr) + ), + )) + } + } + + /// Builds images defined in the compose file. + /// + /// Build output is streamed live to stdout/stderr so progress is visible. + /// + /// # Errors + /// + /// Returns an error when docker compose build fails. + pub fn build(&self) -> io::Result<()> { + let mut command = Command::new("docker"); + command.envs(self.env_vars.iter().map(|(key, value)| (key, value))); + command.arg("compose"); + command.arg("-f").arg(&self.file); + command.arg("-p").arg(&self.project); + command.arg("build"); + + tracing::info!("Running docker compose command: {:?}", command); + + let status = command.status()?; + if status.success() { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::Other, + format!( + "docker compose build failed for file '{}' and project '{}'", + self.file.display(), + self.project, + ), + )) + } + } + + /// Runs docker compose down --volumes. + /// + /// # Errors + /// + /// Returns an error when docker compose cannot stop and remove resources. + pub fn down(&self) -> io::Result<()> { + let output = self.run_compose(&["down", "--volumes"])?; + + if output.status.success() { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::Other, + format!( + "docker compose down failed for file '{}' and project '{}': {}", + self.file.display(), + self.project, + String::from_utf8_lossy(&output.stderr) + ), + )) + } + } + + /// Resolves an ephemeral host port from a service published container port. + /// + /// # Errors + /// + /// Returns an error when the compose command fails or port parsing fails. + pub fn port(&self, service: &str, container_port: u16) -> io::Result<u16> { + let output = self.run_compose(&["port", service, &container_port.to_string()])?; + + if !output.status.success() { + return Err(io::Error::new( + io::ErrorKind::Other, + format!( + "docker compose port failed for file '{}' and project '{}', service '{}' and port '{}': stderr: {} stdout: {}", + self.file.display(), + self.project, + service, + container_port, + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ), + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let first_line = stdout + .lines() + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "docker compose port returned no output"))?; + + let host_port = first_line + .rsplit(':') + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "docker compose port output has no ':' separator"))? + .parse::<u16>() + .map_err(|_| io::Error::new(io::ErrorKind::Other, format!("invalid host port in output: '{first_line}'")))?; + + Ok(host_port) + } + + /// Waits until a service has a resolved host port mapping. + /// + /// This helper retries `docker compose port` until it succeeds, the timeout + /// expires, or the target service exits. + /// + /// # Errors + /// + /// Returns an error when the service exits, port mapping cannot be resolved + /// before timeout, or compose commands fail while gathering diagnostics. + pub async fn wait_for_port_mapping( + &self, + service: &str, + container_port: u16, + timeout: Duration, + poll_interval: Duration, + extra_log_services: &[&str], + ) -> io::Result<u16> { + let deadline = Instant::now() + timeout; + + loop { + if let Ok(ps_output) = self.ps() { + if compose_service_has_exited(&ps_output, service) { + let logs_output = self + .logs(&[service]) + .unwrap_or_else(|error| format!("failed to collect compose logs output: {error}")); + + return Err(io::Error::new( + io::ErrorKind::Other, + format!( + "compose service '{service}' exited while waiting for port mapping '{container_port}'.\nCompose ps:\n{ps_output}\nCompose logs:\n{logs_output}" + ), + )); + } + } + + match self.port(service, container_port) { + Ok(host_port) => return Ok(host_port), + Err(_) => { + tracing::info!("Waiting for compose port mapping for service '{service}'"); + } + } + + if Instant::now() >= deadline { + let ps_output = self + .ps() + .unwrap_or_else(|error| format!("failed to collect compose ps output: {error}")); + + let mut log_services = Vec::with_capacity(1 + extra_log_services.len()); + log_services.push(service); + for extra_service in extra_log_services { + if *extra_service != service { + log_services.push(*extra_service); + } + } + + let logs_output = self + .logs(&log_services) + .unwrap_or_else(|error| format!("failed to collect compose logs output: {error}")); + + return Err(io::Error::new( + io::ErrorKind::TimedOut, + format!( + "timed out waiting for compose port mapping for service '{service}' and port '{container_port}'.\nCompose ps:\n{ps_output}\nCompose logs:\n{logs_output}" + ), + )); + } + + sleep(poll_interval).await; + } + } + + /// Runs `docker compose exec` in non-interactive mode for scripted commands. + /// + /// # Errors + /// + /// Returns an error when command execution fails. + pub fn exec(&self, service: &str, cmd: &[&str]) -> io::Result<Output> { + let mut args = vec!["exec".to_string(), "-T".to_string(), service.to_string()]; + args.extend(cmd.iter().map(|value| (*value).to_string())); + + self.run_compose_strings(&args) + } + + /// Runs `docker compose ps -a` and returns stdout. + /// + /// # Errors + /// + /// Returns an error when the compose command fails. + pub fn ps(&self) -> io::Result<String> { + let output = self.run_compose(&["ps", "-a"])?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + Err(io::Error::new( + io::ErrorKind::Other, + format!( + "docker compose ps failed for file '{}' and project '{}': {}", + self.file.display(), + self.project, + String::from_utf8_lossy(&output.stderr) + ), + )) + } + } + + /// Runs `docker compose logs --no-color <services...>` and returns stdout. + /// + /// # Errors + /// + /// Returns an error when the compose command fails. + pub fn logs(&self, services: &[&str]) -> io::Result<String> { + let mut args = vec!["logs".to_string(), "--no-color".to_string()]; + args.extend(services.iter().map(|service| (*service).to_string())); + + let output = self.run_compose_strings(&args)?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + Err(io::Error::new( + io::ErrorKind::Other, + format!( + "docker compose logs failed for file '{}' and project '{}': {}", + self.file.display(), + self.project, + String::from_utf8_lossy(&output.stderr) + ), + )) + } + } + + fn run_compose(&self, args: &[&str]) -> io::Result<Output> { + let args_as_strings: Vec<String> = args.iter().map(|value| (*value).to_string()).collect(); + self.run_compose_strings(&args_as_strings) + } + + fn run_compose_strings(&self, args: &[String]) -> io::Result<Output> { + let mut command = Command::new("docker"); + command.envs(self.env_vars.iter().map(|(key, value)| (key, value))); + command.arg("compose"); + command.arg("-f").arg(&self.file); + command.arg("-p").arg(&self.project); + command.args(args); + + tracing::info!("Running docker compose command: {:?}", command); + + command.output() + } +} + +fn compose_service_has_exited(ps_output: &str, service_name: &str) -> bool { + ps_output.lines().any(|line| { + line.contains(service_name) + && (line.contains("exited") || line.contains("dead") || line.contains("created") || line.contains("removing")) + }) +} diff --git a/src/console/ci/e2e/tracker_container.rs b/src/console/ci/e2e/tracker_container.rs index a3845c103..1a7717a41 100644 --- a/src/console/ci/e2e/tracker_container.rs +++ b/src/console/ci/e2e/tracker_container.rs @@ -1,7 +1,7 @@ use std::time::Duration; use rand::distr::Alphanumeric; -use rand::Rng; +use rand::RngExt; use super::docker::{RunOptions, RunningContainer}; use super::logs_parser::RunningServices; diff --git a/src/console/ci/mod.rs b/src/console/ci/mod.rs index 6eac3e120..18302be7d 100644 --- a/src/console/ci/mod.rs +++ b/src/console/ci/mod.rs @@ -1,2 +1,4 @@ -//! Continuos integration scripts. +//! Continuous integration scripts. +pub mod compose; pub mod e2e; +pub mod qbittorrent_e2e; diff --git a/src/console/ci/qbittorrent_e2e/bencode.rs b/src/console/ci/qbittorrent_e2e/bencode.rs new file mode 100644 index 000000000..9a9f1a2df --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/bencode.rs @@ -0,0 +1,116 @@ +//! Minimal bencode encoder for generating `.torrent` files in E2E tests. +//! +//! This module intentionally avoids pulling in `serde_bencode` or +//! `torrust-tracker-contrib-bencode`. The key reason is the [`BencodeValue::Raw`] +//! variant: it embeds pre-encoded bytes verbatim inside an outer dictionary, +//! which is required for the two-pass `InfoHash` pattern (encode the `info` dict, +//! SHA-1 hash it, then embed the raw bytes into the outer torrent dict). Neither +//! `serde_bencode` nor the contrib crate can express that semantics without an +//! equivalent workaround. +//! +//! If encoding needs grow in complexity, consider migrating to one of those +//! crates rather than expanding this module. + +pub(crate) enum BencodeValue { + Integer(i64), + Bytes(Vec<u8>), + Dictionary(Vec<(Vec<u8>, BencodeValue)>), + Raw(Vec<u8>), +} + +impl BencodeValue { + #[must_use] + pub(crate) fn encode(&self) -> Vec<u8> { + match self { + Self::Integer(value) => format!("i{value}e").into_bytes(), + Self::Bytes(value) => encode_bytes(value), + Self::Dictionary(entries) => encode_dictionary(entries), + Self::Raw(value) => value.clone(), + } + } +} + +fn encode_dictionary(entries: &[(Vec<u8>, BencodeValue)]) -> Vec<u8> { + let mut sorted_entries = entries.iter().collect::<Vec<_>>(); + sorted_entries.sort_by(|left, right| left.0.cmp(&right.0)); + + let mut encoded = Vec::from(*b"d"); + for (key, value) in sorted_entries { + encoded.extend(encode_bytes(key)); + encoded.extend(value.encode()); + } + encoded.push(b'e'); + encoded +} + +fn encode_bytes(value: &[u8]) -> Vec<u8> { + let mut encoded = value.len().to_string().into_bytes(); + encoded.push(b':'); + encoded.extend(value); + encoded +} + +#[cfg(test)] +mod tests { + use super::BencodeValue; + + #[test] + fn it_should_encode_a_positive_integer() { + assert_eq!(BencodeValue::Integer(42).encode(), b"i42e"); + } + + #[test] + fn it_should_encode_a_negative_integer() { + assert_eq!(BencodeValue::Integer(-3).encode(), b"i-3e"); + } + + #[test] + fn it_should_encode_zero() { + assert_eq!(BencodeValue::Integer(0).encode(), b"i0e"); + } + + #[test] + fn it_should_encode_a_byte_string() { + assert_eq!(BencodeValue::Bytes(b"spam".to_vec()).encode(), b"4:spam"); + } + + #[test] + fn it_should_encode_an_empty_byte_string() { + assert_eq!(BencodeValue::Bytes(vec![]).encode(), b"0:"); + } + + #[test] + fn it_should_encode_a_dictionary_with_keys_sorted_lexicographically() { + // Keys "bar" < "foo" — even though "foo" is listed first. + let dict = BencodeValue::Dictionary(vec![ + (b"foo".to_vec(), BencodeValue::Integer(1)), + (b"bar".to_vec(), BencodeValue::Integer(2)), + ]); + assert_eq!(dict.encode(), b"d3:bari2e3:fooi1ee"); // cspell:disable-line + } + + #[test] + fn it_should_encode_an_empty_dictionary() { + assert_eq!(BencodeValue::Dictionary(vec![]).encode(), b"de"); + } + + #[test] + fn it_should_embed_raw_bytes_verbatim() { + // Raw is used to embed a pre-encoded inner dict (e.g. the info dict) + // without re-encoding it. The bytes must appear unchanged in the output. + let inner = BencodeValue::Integer(7).encode(); // b"i7e" + assert_eq!(BencodeValue::Raw(inner).encode(), b"i7e"); + } + + #[test] + fn it_should_embed_raw_inner_dict_inside_outer_dict() { + // Simulates the two-pass InfoHash pattern: encode the info dict first, + // then wrap it in the outer torrent dict via Raw. + let info = BencodeValue::Dictionary(vec![(b"length".to_vec(), BencodeValue::Integer(100))]); + let info_bytes = info.encode(); // b"d6:lengthi100ee" // cspell:disable-line + + let torrent = BencodeValue::Dictionary(vec![(b"info".to_vec(), BencodeValue::Raw(info_bytes))]); + + assert_eq!(torrent.encode(), b"d4:infod6:lengthi100eee"); // cspell:disable-line + } +} diff --git a/src/console/ci/qbittorrent_e2e/client_role.rs b/src/console/ci/qbittorrent_e2e/client_role.rs new file mode 100644 index 000000000..448f4e9e4 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/client_role.rs @@ -0,0 +1,21 @@ +#[derive(Clone, Copy, Debug)] +pub(super) enum ClientRole { + Seeder, + Leecher, +} + +impl ClientRole { + pub(super) const fn service_name(self) -> &'static str { + match self { + Self::Seeder => "qbittorrent-seeder", + Self::Leecher => "qbittorrent-leecher", + } + } + + pub(super) const fn client_label(self) -> &'static str { + match self { + Self::Seeder => "seeder", + Self::Leecher => "leecher", + } + } +} diff --git a/src/console/ci/qbittorrent_e2e/filesystem_setup.rs b/src/console/ci/qbittorrent_e2e/filesystem_setup.rs new file mode 100644 index 000000000..f5a736284 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/filesystem_setup.rs @@ -0,0 +1,156 @@ +//! Filesystem setup for the `qBittorrent` E2E tests. +//! +//! This module creates the directory tree, service configuration files, and +//! shared test fixtures that the `Docker` Compose stack needs before it starts. +//! +//! # Workspace Layout +//! +//! After [`prepare`] returns, the workspace root contains: +//! +//! ```text +//! <workspace-root>/ +//! ├── leecher-config/ +//! │ └── qBittorrent/ +//! │ └── qBittorrent.conf +//! ├── leecher-downloads/ +//! ├── seeder-config/ +//! │ └── qBittorrent/ +//! │ └── qBittorrent.conf +//! ├── seeder-downloads/ +//! │ └── payload.bin ← pre-seeded payload copy +//! ├── shared/ +//! │ ├── payload.bin ← source payload file +//! │ └── payload.torrent +//! ├── tracker-config.toml +//! └── tracker-storage/ +//! └── database/ +//! └── sqlite3.db ← created at runtime by the tracker +//! ``` +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::Context; +use reqwest::Url; + +use super::qbittorrent::{QbittorrentConfigBuilder, QbittorrentCredentials}; +use super::tracker::{TrackerConfig, TrackerConfigBuilder}; +use super::types::{ComposeProjectName, ContainerPath, Deadline, PollInterval}; +use super::workspace::{ + EphemeralWorkspace, PeerConfig, PermanentWorkspace, PreparedWorkspace, SharedFixtures, TimingConfig, TrackerEndpoints, + TrackerFilesystem, WorkspaceResources, +}; + +const QBITTORRENT_USERNAME: &str = "admin"; +const SEEDER_PASSWORD: &str = "seeder-pass"; +const LEECHER_PASSWORD: &str = "leecher-pass"; +const QBITTORRENT_DOWNLOADS_PATH: &str = "/downloads"; +const TORRENT_POLL_INTERVAL: Duration = Duration::from_millis(500); +const LOGIN_POLL_INTERVAL: Duration = Duration::from_secs(1); + +/// Creates and populates the workspace for a single E2E test run. +/// +/// Returns an ephemeral workspace (temporary directory, auto-cleaned on drop) +/// when `keep_containers` is `false`, or a permanent workspace under +/// `storage/qbt-e2e/<project_name>` when it is `true`. +/// +/// # Errors +/// +/// Returns an error when any directory or file operation fails. +pub(crate) fn prepare( + project_name: &ComposeProjectName, + keep_containers: bool, + timeout: Duration, + tracker_config: &TrackerConfig, +) -> anyhow::Result<PreparedWorkspace> { + if keep_containers { + let persistent_root = std::env::current_dir() + .context("failed to resolve current working directory")? + .join("storage") + .join("qbt-e2e") + .join(project_name.as_str()); + fs::create_dir_all(&persistent_root).with_context(|| { + format!( + "failed to create persistent qBittorrent workspace '{}'", + persistent_root.display() + ) + })?; + let resources = prepare_resources(persistent_root, timeout, tracker_config)?; + + Ok(PreparedWorkspace::Permanent(PermanentWorkspace { resources })) + } else { + let temp_dir = tempfile::tempdir().context("failed to create temporary workspace")?; + let root_path = temp_dir.path().to_path_buf(); + let resources = prepare_resources(root_path, timeout, tracker_config)?; + + Ok(PreparedWorkspace::Ephemeral(EphemeralWorkspace { + _temp_dir: temp_dir, + resources, + })) + } +} + +fn prepare_resources( + root_path: PathBuf, + timeout: Duration, + tracker_config: &TrackerConfig, +) -> anyhow::Result<WorkspaceResources> { + let tracker = setup_tracker_workspace(&root_path, tracker_config)?; + let seeder = setup_qbittorrent_workspace(&root_path, "seeder", SEEDER_PASSWORD)?; + let leecher = setup_qbittorrent_workspace(&root_path, "leecher", LEECHER_PASSWORD)?; + let shared = setup_shared_fixtures(&root_path)?; + let tracker_endpoints = TrackerEndpoints { + http_announce_url: Url::parse(&tracker_config.announce_url_for_compose_service()) + .context("failed to parse HTTP tracker announce URL for compose service")?, + udp_announce_url: Url::parse(&tracker_config.udp_announce_url_for_compose_service()) + .context("failed to parse UDP tracker announce URL for compose service")?, + }; + + Ok(WorkspaceResources { + root_path, + tracker, + tracker_endpoints, + seeder, + leecher, + shared, + timing: TimingConfig { + polling_deadline: Deadline::new(timeout), + login_poll_interval: PollInterval::new(LOGIN_POLL_INTERVAL), + torrent_poll_interval: PollInterval::new(TORRENT_POLL_INTERVAL), + }, + }) +} + +fn setup_tracker_workspace(root: &Path, tracker_config: &TrackerConfig) -> anyhow::Result<TrackerFilesystem> { + let storage_path = root.join("tracker-storage"); + fs::create_dir_all(&storage_path).context("failed to create tracker storage directory")?; + let config_path = TrackerConfigBuilder::new(tracker_config.clone()).write_to(root)?; + Ok(TrackerFilesystem { + config_path, + storage_path, + }) +} + +fn setup_qbittorrent_workspace(root: &Path, role: &str, password: &str) -> anyhow::Result<PeerConfig> { + let config_path = root.join(format!("{role}-config")); + let downloads_path = root.join(format!("{role}-downloads")); + fs::create_dir_all(&downloads_path).with_context(|| format!("failed to create {role} downloads directory"))?; + QbittorrentConfigBuilder::new(QBITTORRENT_USERNAME, password) + .write_to(&config_path) + .with_context(|| format!("failed to generate {role} qBittorrent config"))?; + Ok(PeerConfig { + config_path, + downloads_path, + credentials: QbittorrentCredentials { + username: QBITTORRENT_USERNAME.to_string(), + password: password.to_string(), + }, + container_downloads_path: ContainerPath::new(QBITTORRENT_DOWNLOADS_PATH), + }) +} + +fn setup_shared_fixtures(root: &Path) -> anyhow::Result<SharedFixtures> { + let path = root.join("shared"); + fs::create_dir_all(&path).context("failed to create shared artifacts directory")?; + Ok(SharedFixtures { path }) +} diff --git a/src/console/ci/qbittorrent_e2e/mod.rs b/src/console/ci/qbittorrent_e2e/mod.rs new file mode 100644 index 000000000..2a006d38e --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/mod.rs @@ -0,0 +1,70 @@ +//! qBittorrent end-to-end test module. +//! +//! This module drives E2E smoke tests for the Torrust tracker by orchestrating real +//! qBittorrent clients against a live tracker instance, all running inside Docker +//! Compose containers. +//! +//! # Architecture +//! +//! The entry point is the `qbittorrent_e2e_runner` binary +//! (`src/bin/qbittorrent_e2e_runner.rs`), which is a thin wrapper that delegates +//! everything to [`runner`]. All domain logic lives in this module tree. +//! +//! qBittorrent-specific concerns are grouped under [`qbittorrent`], with focused +//! submodules for HTTP client behavior, API models, credentials, and config +//! building. Scenario orchestration modules depend on this feature module instead +//! of importing those concerns from ad-hoc top-level files. +//! +//! ## BDD-style scenarios and steps +//! +//! Tests are structured around *scenarios* — each scenario describes a complete +//! user story from the `BitTorrent` perspective. Scenarios are composed of reusable +//! *steps* (see [`scenario_steps`]) that can be shared across scenarios. +//! +//! Currently one scenario is implemented, covering the most common tracker usage: +//! +//! 1. A **seeder** qBittorrent client creates a torrent from a known payload file +//! and starts seeding it through the tracker. +//! 2. A **leecher** qBittorrent client discovers the torrent via the tracker and +//! downloads it from the seeder. +//! 3. After the download completes, the downloaded file is compared byte-for-byte +//! against the original payload to assert data integrity. +//! +//! ## Infrastructure vs. scenario +//! +//! A deliberate design decision separates *infrastructure setup* from *scenario +//! execution*: +//! +//! **Infrastructure setup** (done once before any scenario runs): +//! - Prepare the tracker workspace (config file, storage directory) and start the +//! tracker container. +//! - Prepare each qBittorrent client workspace (per-client config, downloads +//! directory) and start the client containers. +//! - Wait until all services are reachable. +//! +//! **Scenario execution** (runs against the already-running infrastructure): +//! - Perform the actual `BitTorrent` workflow steps. +//! - Assert the expected outcome. +//! +//! The reason for this split is cost: starting containers is slow. By keeping the +//! infrastructure alive across scenarios, multiple scenarios can run against the +//! same stack without paying the startup penalty each time. +//! +//! This also opens a clear extension path: in the future we could have multiple +//! infrastructure configurations (e.g. public vs. private tracker, `SQLite` vs. +//! `MySQL`, different numbers of peers) each hosting their own suite of scenarios, +//! without changing the scenario or step code. + +pub mod bencode; +pub mod client_role; +pub mod filesystem_setup; +pub mod poller; +pub mod qbittorrent; +pub mod runner; +pub mod scenario_steps; +pub mod scenarios; +pub mod services_setup; +pub mod torrent_artifacts; +pub mod tracker; +pub mod types; +pub mod workspace; diff --git a/src/console/ci/qbittorrent_e2e/poller.rs b/src/console/ci/qbittorrent_e2e/poller.rs new file mode 100644 index 000000000..c34cc7965 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/poller.rs @@ -0,0 +1,32 @@ +use std::time::{Duration, Instant}; + +use tokio::time::sleep; + +use super::types::{Deadline, PollInterval}; + +pub(super) struct Poller { + deadline: Instant, + interval: Duration, +} + +impl Poller { + pub(super) fn new(timeout: Deadline, interval: PollInterval) -> Self { + Self { + deadline: Instant::now() + timeout.as_duration(), + interval: interval.as_duration(), + } + } + + pub(super) async fn retry_or_timeout<M>(&self, timeout_message: M) -> anyhow::Result<()> + where + M: FnOnce() -> String, + { + if Instant::now() >= self.deadline { + anyhow::bail!(timeout_message()); + } + + sleep(self.interval).await; + + Ok(()) + } +} diff --git a/src/console/ci/qbittorrent_e2e/qbittorrent/client.rs b/src/console/ci/qbittorrent_e2e/qbittorrent/client.rs new file mode 100644 index 000000000..1351b7795 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/qbittorrent/client.rs @@ -0,0 +1,367 @@ +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Context; +use reqwest::header::{CONTENT_TYPE, HOST, SET_COOKIE}; +use reqwest::multipart::{Form, Part}; +use tokio::sync::Mutex; + +use super::super::types::InfoHash; +use super::credentials::QbittorrentCredentials; +use super::torrent::{TorrentInfo, TorrentProgress}; +use super::QBITTORRENT_WEBUI_PORT; + +const WEBUI_HEADER_HOST: &str = "localhost"; +const WEBUI_HEADER_SCHEME: &str = "http"; + +/// A validated qBittorrent `WebUI` base URL. +/// +/// Parses the raw URL string once at construction time. All subsequent +/// accessors are infallible, removing the repeated parse-and-error pattern +/// that would otherwise occur in every API method. +#[derive(Debug, Clone)] +struct WebUiBaseUrl { + raw: String, +} + +impl WebUiBaseUrl { + fn new(url: &str) -> anyhow::Result<Self> { + let parsed = reqwest::Url::parse(url).with_context(|| format!("failed to parse qBittorrent WebUI base URL '{url}'"))?; + parsed + .host_str() + .ok_or_else(|| anyhow::anyhow!("qBittorrent WebUI URL has no host: '{url}'"))?; + + Ok(Self { raw: url.to_string() }) + } + + /// Returns the base URL string for composing API paths. + fn as_str(&self) -> &str { + &self.raw + } +} + +#[derive(Debug, Clone)] +pub struct QbittorrentClient { + client_label: String, + base_url: WebUiBaseUrl, + client: reqwest::Client, + sid_cookie: Arc<Mutex<Option<String>>>, +} + +impl QbittorrentClient { + /// # Errors + /// + /// Returns an error when the HTTP client cannot be built. + pub fn new(client_label: &str, base_url: &str, timeout: Duration) -> anyhow::Result<Self> { + let base_url = WebUiBaseUrl::new(base_url)?; + let client = reqwest::Client::builder() + .timeout(timeout) + .build() + .context("failed to build qBittorrent HTTP client")?; + + Ok(Self { + client_label: client_label.to_string(), + base_url, + client, + sid_cookie: Arc::new(Mutex::new(None)), + }) + } + + /// Returns the human-readable label identifying this client (e.g. `"seeder"` or `"leecher"`). + pub fn label(&self) -> &str { + &self.client_label + } + + /// # Errors + /// + /// Returns an error when login fails. + pub async fn login(&self, credentials: &QbittorrentCredentials) -> anyhow::Result<()> { + let body = reqwest::Url::parse_with_params( + "http://localhost", + &[ + ("username", credentials.username.as_str()), + ("password", credentials.password.as_str()), + ], + ) + .context("failed to URL-encode qBittorrent login body")? + .query() + .ok_or_else(|| anyhow::anyhow!("encoded qBittorrent login body is unexpectedly empty"))? + .to_string(); + let (webui_host, webui_origin) = Self::webui_headers(); + + let response = self + .client + .post(format!("{}/api/v2/auth/login", self.base_url.as_str())) + .header(CONTENT_TYPE, "application/x-www-form-urlencoded") + .header(HOST, webui_host) + .header("Referer", &webui_origin) + .header("Origin", &webui_origin) + .body(body) + .send() + .await + .context("failed to call qBittorrent login API")?; + + if let Some(sid_cookie) = extract_sid_cookie(response.headers()) { + *self.sid_cookie.lock().await = Some(sid_cookie); + } + + let status = response.status(); + let body_text = response + .text() + .await + .context("failed to read qBittorrent login response body")?; + + if status.is_success() && body_text.trim() == "Ok." { + Ok(()) + } else { + Err(anyhow::anyhow!("qBittorrent login failed: HTTP {status}, body: {body_text}")) + } + } + + /// # Errors + /// + /// Returns an error when reading the qBittorrent application version fails. + // Staged: used by planned scenario steps in <https://github.com/torrust/torrust-tracker/issues/1706>. + #[expect(dead_code, reason = "reserved for staged scenario coverage; see #1706")] + pub async fn app_version(&self) -> anyhow::Result<String> { + let (webui_host, webui_origin) = Self::webui_headers(); + let sid_cookie = self.sid_cookie.lock().await.clone(); + + let request = self + .client + .get(format!("{}/api/v2/app/version", self.base_url.as_str())) + .header(HOST, webui_host) + .header("Referer", webui_origin); + let request = if let Some(cookie) = sid_cookie { + request.header("Cookie", cookie) + } else { + request + }; + + let response = request.send().await.context("failed to call qBittorrent app/version API")?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "qBittorrent app/version failed with status {}", + response.status() + )); + } + + response.text().await.context("failed to read qBittorrent app version body") + } + + /// # Errors + /// + /// Returns an error when adding a torrent file fails. + pub async fn add_torrent_file(&self, torrent_name: &str, torrent_bytes: &[u8], save_path: &str) -> anyhow::Result<()> { + let (webui_host, webui_origin) = Self::webui_headers(); + let sid_cookie = self.sid_cookie.lock().await.clone(); + + let part = Part::bytes(torrent_bytes.to_vec()).file_name(torrent_name.to_string()); + let form = Form::new() + .part("torrents", part) + .text("savepath", save_path.to_string()) + .text("paused", "false") + .text("skip_checking", "false"); + + let request = self + .client + .post(format!("{}/api/v2/torrents/add", self.base_url.as_str())) + .header(HOST, webui_host) + .header("Referer", &webui_origin) + .header("Origin", &webui_origin) + .multipart(form); + let request = if let Some(cookie) = sid_cookie { + request.header("Cookie", cookie) + } else { + request + }; + + let response = request + .send() + .await + .with_context(|| format!("failed to call torrents/add on {} qBittorrent instance", self.client_label))?; + + if response.status().is_success() { + Ok(()) + } else { + Err(anyhow::anyhow!( + "qBittorrent torrents/add failed with status {} on {} instance", + response.status(), + self.client_label + )) + } + } + + /// # Errors + /// + /// Returns an error when querying torrents fails. + pub async fn list_torrents(&self) -> anyhow::Result<Vec<TorrentInfo>> { + let (webui_host, webui_origin) = Self::webui_headers(); + let sid_cookie = self.sid_cookie.lock().await.clone(); + + let request = self + .client + .get(format!("{}/api/v2/torrents/info", self.base_url.as_str())) + .header(HOST, webui_host) + .header("Referer", webui_origin); + let request = if let Some(cookie) = sid_cookie { + request.header("Cookie", cookie) + } else { + request + }; + + let response = request.send().await.context("failed to call qBittorrent torrents/info API")?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "qBittorrent torrents/info failed with status {}", + response.status() + )); + } + + response + .json::<Vec<TorrentInfo>>() + .await + .context("failed to deserialize qBittorrent torrents list") + } + + /// # Errors + /// + /// Returns an error when querying torrents fails. + pub async fn first_torrent(&self) -> anyhow::Result<Option<TorrentInfo>> { + let torrents = self + .list_torrents() + .await + .with_context(|| format!("failed to list {} torrents", self.client_label))?; + + Ok(torrents.into_iter().next()) + } + + /// # Errors + /// + /// Returns an error when querying torrents fails. + // Staged: used by planned scenario steps in <https://github.com/torrust/torrust-tracker/issues/1706>. + #[expect(dead_code, reason = "reserved for staged scenario coverage; see #1706")] + pub async fn first_torrent_progress(&self) -> anyhow::Result<Option<TorrentProgress>> { + Ok(self.first_torrent().await?.map(|torrent| torrent.progress)) + } + + /// Returns the [`TorrentInfo`] for the torrent identified by `hash`, or `None` if it is not + /// in the client's list. + /// + /// # Errors + /// + /// Returns an error when querying torrents fails. + pub async fn torrent_by_hash(&self, hash: &InfoHash) -> anyhow::Result<Option<TorrentInfo>> { + let torrents = self + .list_torrents() + .await + .with_context(|| format!("failed to list {} torrents", self.client_label))?; + Ok(torrents.into_iter().find(|t| t.hash.as_str() == hash.as_str())) + } + + /// # Errors + /// + /// Returns an error when querying torrents fails. + pub async fn has_torrent_with_hash(&self, hash: &InfoHash) -> anyhow::Result<bool> { + Ok(self.torrent_by_hash(hash).await?.is_some()) + } + + /// Deletes the torrent identified by `hash` without removing its downloaded files. + /// + /// # Errors + /// + /// Returns an error when the qBittorrent API call fails. + pub async fn delete_torrent(&self, hash: &InfoHash) -> anyhow::Result<()> { + let (webui_host, webui_origin) = Self::webui_headers(); + let sid_cookie = self.sid_cookie.lock().await.clone(); + + let body = format!("hashes={}&deleteFiles=false", hash.as_str()); + let request = self + .client + .post(format!("{}/api/v2/torrents/delete", self.base_url.as_str())) + .header(CONTENT_TYPE, "application/x-www-form-urlencoded") + .header(HOST, webui_host) + .header("Referer", &webui_origin) + .header("Origin", &webui_origin) + .body(body); + let request = if let Some(cookie) = sid_cookie { + request.header("Cookie", cookie) + } else { + request + }; + + let response = request + .send() + .await + .with_context(|| format!("failed to call torrents/delete on {} qBittorrent instance", self.client_label))?; + + if response.status().is_success() { + Ok(()) + } else { + Err(anyhow::anyhow!( + "qBittorrent torrents/delete failed with status {} on {} instance", + response.status(), + self.client_label + )) + } + } + + /// # Errors + /// + /// Returns an error when querying torrents fails. + pub async fn torrent_count(&self) -> anyhow::Result<usize> { + Ok(self + .list_torrents() + .await + .with_context(|| format!("failed to list {} torrents", self.client_label))? + .len()) + } + + fn webui_headers() -> (String, String) { + ( + format!("{WEBUI_HEADER_HOST}:{QBITTORRENT_WEBUI_PORT}"), + format!("{WEBUI_HEADER_SCHEME}://{WEBUI_HEADER_HOST}:{QBITTORRENT_WEBUI_PORT}"), + ) + } +} + +fn extract_sid_cookie(headers: &reqwest::header::HeaderMap) -> Option<String> { + headers + .get_all(SET_COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .find_map(|value| { + value + .split(';') + .next() + .map(str::trim) + .filter(|cookie| cookie.starts_with("SID=")) + .map(ToOwned::to_owned) + }) +} + +#[cfg(test)] +mod tests { + use reqwest::header::{HeaderMap, HeaderValue, SET_COOKIE}; + + use super::extract_sid_cookie; + + #[test] + fn it_should_extract_sid_cookie_when_present() { + let mut headers = HeaderMap::new(); + headers.append(SET_COOKIE, HeaderValue::from_static("foo=bar; Path=/")); + headers.append(SET_COOKIE, HeaderValue::from_static("SID=abc123; HttpOnly; Path=/")); + + assert_eq!(extract_sid_cookie(&headers), Some(String::from("SID=abc123"))); + } + + #[test] + fn it_should_return_none_when_sid_cookie_is_missing() { + let mut headers = HeaderMap::new(); + headers.append(SET_COOKIE, HeaderValue::from_static("foo=bar; Path=/")); + + assert_eq!(extract_sid_cookie(&headers), None); + } +} diff --git a/src/console/ci/qbittorrent_e2e/qbittorrent/config_builder.rs b/src/console/ci/qbittorrent_e2e/qbittorrent/config_builder.rs new file mode 100644 index 000000000..8cac264cc --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/qbittorrent/config_builder.rs @@ -0,0 +1,129 @@ +//! Builder for the qBittorrent configuration file written into the E2E workspace. +use std::fs; +use std::path::Path; + +use anyhow::Context; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::Engine; +use pbkdf2::pbkdf2_hmac; +use sha2::Sha512; + +use super::QBITTORRENT_WEBUI_PORT; + +const CONFIG_RELATIVE_PATH: &str = "qBittorrent/qBittorrent.conf"; +const DEFAULT_DOWNLOADS_PATH: &str = "/downloads"; +const DEFAULT_DOWNLOADS_TEMP_PATH: &str = "/downloads/temp"; + +/// Builds and writes the qBittorrent configuration file for the E2E workspace. +/// +/// Provides a fluent interface to configure credentials and paths. Call +/// [`write_to`](QbittorrentConfigBuilder::write_to) to create the required +/// directory layout and write `qBittorrent/qBittorrent.conf`. +pub(crate) struct QbittorrentConfigBuilder<'a> { + username: &'a str, + password: &'a str, + webui_port: u16, + downloads_path: &'a str, + downloads_temp_path: &'a str, +} + +impl<'a> QbittorrentConfigBuilder<'a> { + /// Creates a builder with default port (`8080`) and download paths (`/downloads`). + pub(crate) fn new(username: &'a str, password: &'a str) -> Self { + Self { + username, + password, + webui_port: QBITTORRENT_WEBUI_PORT, + downloads_path: DEFAULT_DOWNLOADS_PATH, + downloads_temp_path: DEFAULT_DOWNLOADS_TEMP_PATH, + } + } + + // These builder methods override the defaults written into the qBittorrent + // config file. They are needed when future scenarios require non-standard + // paths or a different WebUI port. Tracked: <https://github.com/torrust/torrust-tracker/issues/1706>. + #[expect(dead_code, reason = "reserved for future scenario configuration; see #1706")] + pub(crate) fn webui_port(mut self, port: u16) -> Self { + self.webui_port = port; + self + } + + #[expect(dead_code, reason = "reserved for future scenario configuration; see #1706")] + pub(crate) fn downloads_path(mut self, path: &'a str) -> Self { + self.downloads_path = path; + self + } + + #[expect(dead_code, reason = "reserved for future scenario configuration; see #1706")] + pub(crate) fn downloads_temp_path(mut self, path: &'a str) -> Self { + self.downloads_temp_path = path; + self + } + + /// Writes the qBittorrent configuration to `config_root`. + /// + /// Creates the required directory layout under `config_root` and writes + /// `qBittorrent/qBittorrent.conf` with the supplied credentials and paths. + /// + /// # Errors + /// + /// Returns an error when creating directories or writing the config file fails. + pub(crate) fn write_to(&self, config_root: &Path) -> anyhow::Result<()> { + let config_path = config_root.join(CONFIG_RELATIVE_PATH); + let config_dir = config_path + .parent() + .ok_or_else(|| anyhow::anyhow!("qBittorrent config path has no parent directory"))?; + let resume_dir = config_root.join("qBittorrent/BT_backup"); + let cache_dir = config_root.join(".cache/qBittorrent"); + + fs::create_dir_all(config_dir) + .with_context(|| format!("failed to create qBittorrent config directory '{}'", config_dir.display()))?; + fs::create_dir_all(&resume_dir) + .with_context(|| format!("failed to create qBittorrent resume directory '{}'", resume_dir.display()))?; + fs::create_dir_all(&cache_dir) + .with_context(|| format!("failed to create qBittorrent cache directory '{}'", cache_dir.display()))?; + + let password_hash = build_password_hash(self.password); + let config = self.format_config(&password_hash); + + fs::write(&config_path, config) + .with_context(|| format!("failed to write qBittorrent config '{}'", config_path.display()))?; + + Ok(()) + } + + fn format_config(&self, password_hash: &str) -> String { + let username = self.username; + let webui_port = self.webui_port; + let downloads_path = self.downloads_path; + let downloads_temp_path = self.downloads_temp_path; + + format!( + "[BitTorrent]\n\ + Session\\AddTorrentStopped=false\n\ + Session\\DefaultSavePath={downloads_path}\n\ + Session\\DHTEnabled=false\n\ + Session\\LSDEnabled=false\n\ + Session\\PeXEnabled=false\n\ + Session\\TempPath={downloads_temp_path}\n\ + \n\ + [Preferences]\n\ + WebUI\\LocalHostAuth=false\n\ + WebUI\\Port={webui_port}\n\ + WebUI\\Password_PBKDF2=\"{password_hash}\"\n\ + WebUI\\Username={username}\n" + ) + } +} + +fn build_password_hash(password: &str) -> String { + let salt: [u8; 16] = rand::random(); + let mut digest = [0_u8; 64]; + pbkdf2_hmac::<Sha512>(password.as_bytes(), &salt, 100_000, &mut digest); + + format!( + "@ByteArray({}:{})", + BASE64_STANDARD.encode(salt), + BASE64_STANDARD.encode(digest) + ) +} diff --git a/src/console/ci/qbittorrent_e2e/qbittorrent/credentials.rs b/src/console/ci/qbittorrent_e2e/qbittorrent/credentials.rs new file mode 100644 index 000000000..141c037bc --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/qbittorrent/credentials.rs @@ -0,0 +1,8 @@ +/// Credentials for authenticating with the `qBittorrent` web UI. +#[derive(Debug, Clone)] +pub(crate) struct QbittorrentCredentials { + /// Web-UI username. + pub(crate) username: String, + /// Web-UI password. + pub(crate) password: String, +} diff --git a/src/console/ci/qbittorrent_e2e/qbittorrent/mod.rs b/src/console/ci/qbittorrent_e2e/qbittorrent/mod.rs new file mode 100644 index 000000000..9f30b30b2 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/qbittorrent/mod.rs @@ -0,0 +1,25 @@ +//! Staged feature module for qBittorrent-specific internals. +//! +//! During the migration this module re-exports symbols from legacy files so +//! call sites can switch imports incrementally. + +mod client; +mod config_builder; +mod credentials; +mod torrent; + +/// Default port on which the qBittorrent `WebUI` listens. +/// +/// Used both when writing the per-client config file ([`QbittorrentConfigBuilder`]) +/// and when connecting to the container's `WebUI` ([`QbittorrentClient`]). +/// Keeping it here ensures both sides always agree on the same value. +pub(super) const QBITTORRENT_WEBUI_PORT: u16 = 8080; + +pub(super) use client::QbittorrentClient; +pub(super) use config_builder::QbittorrentConfigBuilder; +pub(super) use credentials::QbittorrentCredentials; +// These re-exports are staged ahead of use: they will be consumed once +// additional scenario steps reference `TorrentState` / `TorrentProgress` +// directly. Tracked: <https://github.com/torrust/torrust-tracker/issues/1706>. +#[expect(unused_imports, reason = "staged migration re-export; see #1706")] +pub(super) use torrent::{TorrentInfo, TorrentProgress, TorrentState}; diff --git a/src/console/ci/qbittorrent_e2e/qbittorrent/torrent.rs b/src/console/ci/qbittorrent_e2e/qbittorrent/torrent.rs new file mode 100644 index 000000000..4e16e262f --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/qbittorrent/torrent.rs @@ -0,0 +1,199 @@ +use std::fmt; + +use serde::Deserialize; + +use super::super::types::InfoHash; + +#[derive(Debug, Deserialize)] +pub struct TorrentInfo { + pub hash: InfoHash, + pub progress: TorrentProgress, + pub state: TorrentState, +} + +/// A torrent download progress value in the range `0.0` (not started) to +/// `1.0` (fully complete), as reported by the qBittorrent Web API. +/// +/// Wraps an `f64` to disambiguate progress from other floating-point fields +/// such as download speed. Use [`is_complete`](Self::is_complete) to test for +/// full completion and [`as_fraction`](Self::as_fraction) to obtain the raw +/// `0.0`-`1.0` value for arithmetic or formatted output. +#[derive(Debug, Clone, Copy)] +pub struct TorrentProgress(f64); + +impl TorrentProgress { + /// Returns `true` when the torrent has reached 100 % (`progress >= 1.0`). + #[must_use] + pub fn is_complete(self) -> bool { + self.0 >= 1.0 + } + + /// Returns the raw fraction in the range `0.0`-`1.0`. + #[must_use] + pub fn as_fraction(self) -> f64 { + self.0 + } +} + +impl<'de> serde::Deserialize<'de> for TorrentProgress { + fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { + let value = <f64 as serde::Deserialize>::deserialize(deserializer)?; + Ok(Self(value)) + } +} + +/// The state of a torrent as reported by the qBittorrent Web API. +/// +/// Variants map one-to-one to the string values returned by the +/// `/api/v2/torrents/info` endpoint. Any string not listed here is captured +/// by [`TorrentState::Unknown`] and its raw value is preserved for diagnostics. +/// +/// Note: qBittorrent 5.0 renamed `pausedUP`/`pausedDL` to +/// `stoppedUP`/`stoppedDL`. Both spellings are represented. +#[derive(Debug, Clone)] +pub enum TorrentState { + /// Some error occurred. + Error, + /// Torrent data files are missing. + MissingFiles, + /// Torrent is being seeded and data is being transferred. + Uploading, + /// Seeder has finished and the torrent is stopped (qBittorrent >= 5.0). + StoppedUp, + /// Seeder has finished and the torrent is paused (qBittorrent < 5.0). + PausedUp, + /// Torrent is queued for upload. + QueuedUp, + /// Seeding is stalled (no peers downloading). + StalledUp, + /// Checking data after completing upload. + CheckingUp, + /// Torrent is force-seeding. + ForcedUp, + /// Allocating disk space for the download. + Allocating, + /// Torrent is downloading. + Downloading, + /// Fetching torrent metadata. + MetaDl, + /// Download is stopped (qBittorrent >= 5.0). + StoppedDl, + /// Download is paused (qBittorrent < 5.0). + PausedDl, + /// Torrent is queued for download. + QueuedDl, + /// Download is stalled (no seeds available). + StalledDl, + /// Checking data while downloading. + CheckingDl, + /// Torrent is force-downloading. + ForcedDl, + /// Checking resume data on startup. + CheckingResumeData, + /// Moving files to a new location. + Moving, + /// The API returned `"unknown"`. + UnknownToApi, + /// An unrecognized state string; the raw value is preserved for diagnostics. + Unknown(String), +} + +impl<'de> serde::Deserialize<'de> for TorrentState { + fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { + let s = <String as serde::Deserialize>::deserialize(deserializer)?; + Ok(match s.as_str() { + "error" => Self::Error, + "missingFiles" => Self::MissingFiles, + "uploading" => Self::Uploading, + "stoppedUP" => Self::StoppedUp, + "pausedUP" => Self::PausedUp, + "queuedUP" => Self::QueuedUp, + "stalledUP" => Self::StalledUp, + "checkingUP" => Self::CheckingUp, + "forcedUP" => Self::ForcedUp, + "allocating" => Self::Allocating, + "downloading" => Self::Downloading, + "metaDL" => Self::MetaDl, + "stoppedDL" => Self::StoppedDl, + "pausedDL" => Self::PausedDl, + "queuedDL" => Self::QueuedDl, + "stalledDL" => Self::StalledDl, + "checkingDL" => Self::CheckingDl, + "forcedDL" => Self::ForcedDl, + "checkingResumeData" => Self::CheckingResumeData, + "moving" => Self::Moving, + "unknown" => Self::UnknownToApi, + other => Self::Unknown(other.to_string()), + }) + } +} + +impl fmt::Display for TorrentState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Error => "error", + Self::MissingFiles => "missingFiles", + Self::Uploading => "uploading", + Self::StoppedUp => "stoppedUP", + Self::PausedUp => "pausedUP", + Self::QueuedUp => "queuedUP", + Self::StalledUp => "stalledUP", + Self::CheckingUp => "checkingUP", + Self::ForcedUp => "forcedUP", + Self::Allocating => "allocating", + Self::Downloading => "downloading", + Self::MetaDl => "metaDL", + Self::StoppedDl => "stoppedDL", + Self::PausedDl => "pausedDL", + Self::QueuedDl => "queuedDL", + Self::StalledDl => "stalledDL", + Self::CheckingDl => "checkingDL", + Self::ForcedDl => "forcedDL", + Self::CheckingResumeData => "checkingResumeData", + Self::Moving => "moving", + Self::UnknownToApi => "unknown", + Self::Unknown(raw) => return f.write_str(raw), + }; + f.write_str(s) + } +} + +#[cfg(test)] +mod tests { + use super::{TorrentProgress, TorrentState}; + + #[test] + fn it_should_report_torrent_progress_completion_threshold() { + let complete = serde_json::from_str::<TorrentProgress>("1.0").expect("1.0 is valid progress JSON"); + let in_progress = serde_json::from_str::<TorrentProgress>("0.42").expect("0.42 is valid progress JSON"); + + assert!(complete.is_complete()); + assert!((complete.as_fraction() - 1.0).abs() < f64::EPSILON); + + assert!(!in_progress.is_complete()); + assert!((in_progress.as_fraction() - 0.42).abs() < f64::EPSILON); + } + + #[test] + fn it_should_deserialize_torrent_state_known_variant() { + let parsed = serde_json::from_str::<TorrentState>("\"stoppedDL\"").expect("stoppedDL is a valid state JSON"); + + assert!(matches!(parsed, TorrentState::StoppedDl), "expected StoppedDl, got {parsed}"); + } + + #[test] + fn it_should_deserialize_unknown_torrent_state_preserving_raw_value() { + let parsed = serde_json::from_str::<TorrentState>("\"futureState\"").expect("futureState is valid state JSON"); + + let TorrentState::Unknown(raw) = parsed else { + panic!("expected Unknown variant, got {parsed}"); + }; + assert_eq!(raw, "futureState"); + } + + #[test] + fn it_should_display_known_and_unknown_torrent_state_values() { + assert_eq!(TorrentState::PausedDl.to_string(), "pausedDL"); + assert_eq!(TorrentState::Unknown(String::from("custom")).to_string(), "custom"); + } +} diff --git a/src/console/ci/qbittorrent_e2e/runner.rs b/src/console/ci/qbittorrent_e2e/runner.rs new file mode 100644 index 000000000..441ad0992 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/runner.rs @@ -0,0 +1,105 @@ +//! Program to run qBittorrent E2E checks. +//! +//! Example: +//! +//! ```text +//! cargo run --bin qbittorrent_e2e_runner -- --compose-file ./compose.qbittorrent-e2e.yaml --timeout-seconds 300 +//! ``` +use std::path::PathBuf; +use std::time::Duration; + +use clap::Parser; +use tracing::level_filters::LevelFilter; + +use super::tracker::TrackerConfig; +use super::types::{ComposeProjectName, QbittorrentImage, TrackerImage}; +use super::{filesystem_setup, scenarios, services_setup}; + +const TRACKER_IMAGE: &str = "torrust-tracker:qbt-e2e-local"; +const QBITTORRENT_IMAGE: &str = "lscr.io/linuxserver/qbittorrent:5.1.4"; + +#[derive(Parser, Debug)] +#[clap(author, version, about, long_about = None)] +struct Args { + /// Compose file used for the qBittorrent scenario. + #[clap(long, default_value = "compose.qbittorrent-e2e.yaml")] + compose_file: PathBuf, + + /// Timeout in seconds for API operations. + #[clap(long, default_value_t = 180)] + timeout_seconds: u64, + + /// Local docker image tag used for the tracker service. + #[clap(long, default_value = TRACKER_IMAGE)] + tracker_image: String, + + /// qBittorrent image used for both seeder and leecher containers. + #[clap(long, default_value = QBITTORRENT_IMAGE)] + qbittorrent_image: String, + + /// Prefix for the random docker compose project name. + #[clap(long, default_value = "qbt-e2e")] + project_prefix: String, + + /// Leave containers running after the test finishes instead of tearing them + /// down. Useful for post-run debugging (e.g. `docker logs <container>`). + #[clap(long, default_value_t = false)] + keep_containers: bool, +} + +/// Runs the qBittorrent E2E smoke orchestration. +/// +/// # Errors +/// +/// Returns an error when compose orchestration fails. +pub async fn run() -> anyhow::Result<()> { + tracing_stdout_init(LevelFilter::INFO); + + let args = Args::parse(); + let project_name = ComposeProjectName::generate(&args.project_prefix); + tracing::info!("Using compose project name: {project_name}"); + + let timeout = Duration::from_secs(args.timeout_seconds); + let tracker_config = TrackerConfig::default(); + + let workspace = filesystem_setup::prepare(&project_name, args.keep_containers, timeout, &tracker_config)?; + let resources = workspace.resources(); + let prepared_cases = scenarios::seeder_to_leecher_transfer::prepare(resources)?; + + let tracker_image = TrackerImage::new(&args.tracker_image); + let qbittorrent_image = QbittorrentImage::new(&args.qbittorrent_image); + + let (mut running_compose, seeder, leecher, tracker) = services_setup::start( + &args.compose_file, + &project_name, + &tracker_image, + &qbittorrent_image, + resources, + &tracker_config, + ) + .await?; + + scenarios::seeder_to_leecher_transfer::run(&seeder, &leecher, &tracker, resources, &prepared_cases).await?; + + // POST-SCENARIO: optionally keep containers for debugging. + if args.keep_containers { + tracing::info!( + "Keeping containers alive for debugging. Project name: '{}'. \ + Workspace: '{}'. \ + Use `docker compose -p {} logs` to inspect them, \ + then `docker compose -p {} down --volumes` to clean up.", + running_compose.project(), + workspace.root_path().display(), + running_compose.project(), + running_compose.project(), + ); + running_compose.keep(); + } + + Ok(()) +} + +fn tracing_stdout_init(filter: LevelFilter) { + tracing_subscriber::fmt().with_max_level(filter).init(); + tracing::info!("Logging initialized"); +} diff --git a/src/console/ci/qbittorrent_e2e/scenario_steps/fixtures/build_payload_fixture.rs b/src/console/ci/qbittorrent_e2e/scenario_steps/fixtures/build_payload_fixture.rs new file mode 100644 index 000000000..77ada349d --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/scenario_steps/fixtures/build_payload_fixture.rs @@ -0,0 +1,16 @@ +use super::super::super::torrent_artifacts::build_payload_bytes; +use super::super::super::types::PayloadSize; + +/// In-memory payload fixture used to generate torrent metadata and integrity checks. +pub struct GeneratedPayload { + pub bytes: Vec<u8>, +} + +/// Builds deterministic payload bytes for the E2E scenario. +/// +/// The generated payload is stable for a given size, which keeps test behavior reproducible. +pub fn build_payload_fixture(payload_size_bytes: PayloadSize) -> GeneratedPayload { + GeneratedPayload { + bytes: build_payload_bytes(payload_size_bytes.as_usize()), + } +} diff --git a/src/console/ci/qbittorrent_e2e/scenario_steps/fixtures/build_torrent_fixture.rs b/src/console/ci/qbittorrent_e2e/scenario_steps/fixtures/build_torrent_fixture.rs new file mode 100644 index 000000000..b4820ab0e --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/scenario_steps/fixtures/build_torrent_fixture.rs @@ -0,0 +1,34 @@ +use anyhow::Context; + +use super::super::super::torrent_artifacts::build_torrent_bytes; +use super::super::super::types::{InfoHash, PieceLength}; +use super::build_payload_fixture::GeneratedPayload; + +/// In-memory `.torrent` fixture generated from a payload fixture. +pub struct GeneratedTorrent { + /// Raw bytes of the `.torrent` metainfo file. + pub bytes: Vec<u8>, + /// v1 `InfoHash`: SHA-1 of the bencoded `info` dict, lowercase hex (40 chars). + /// Matches the hash format returned by the qBittorrent Web API. + pub info_hash: InfoHash, +} + +/// Builds torrent metadata bytes from a payload fixture. +/// +/// # Errors +/// +/// Returns an error when torrent metadata encoding fails. +pub fn build_torrent_fixture( + payload: &GeneratedPayload, + payload_name: &str, + announce_url: &str, + piece_length: PieceLength, +) -> anyhow::Result<GeneratedTorrent> { + let artifacts = build_torrent_bytes(&payload.bytes, payload_name, announce_url, piece_length.as_usize()) + .context("failed to build torrent fixture bytes from payload fixture")?; + + Ok(GeneratedTorrent { + bytes: artifacts.torrent_bytes, + info_hash: artifacts.info_hash, + }) +} diff --git a/src/console/ci/qbittorrent_e2e/scenario_steps/fixtures/mod.rs b/src/console/ci/qbittorrent_e2e/scenario_steps/fixtures/mod.rs new file mode 100644 index 000000000..652bb4185 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/scenario_steps/fixtures/mod.rs @@ -0,0 +1,9 @@ +//! Fixture builders for qBittorrent E2E scenarios. +//! +//! Each file contains one builder so available fixtures are discoverable in the IDE tree. + +mod build_payload_fixture; +mod build_torrent_fixture; + +pub(in super::super) use build_payload_fixture::build_payload_fixture; +pub(in super::super) use build_torrent_fixture::build_torrent_fixture; diff --git a/src/console/ci/qbittorrent_e2e/scenario_steps/mod.rs b/src/console/ci/qbittorrent_e2e/scenario_steps/mod.rs new file mode 100644 index 000000000..c43dd06e3 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/scenario_steps/mod.rs @@ -0,0 +1,21 @@ +//! Reusable scenario steps for qBittorrent E2E flows. +//! +//! Steps are grouped by subject: +//! - `fixtures` — test data builders (payload, torrent metadata) +//! - `qbittorrent` — qBittorrent client interaction steps +//! - `verify_payload_integrity` — assert that a downloaded file matches the original payload +//! +//! Each leaf file contains one explicit step so available actions are discoverable in the IDE tree. + +mod fixtures; +mod qbittorrent; +mod tracker; +mod verify_payload_integrity; + +pub(super) use fixtures::{build_payload_fixture, build_torrent_fixture}; +pub(super) use qbittorrent::{ + add_torrent_file_to_client, ensure_torrent_is_absent, login_client, wait_until_download_completes, + wait_until_torrent_appears_in_client, +}; +pub(super) use tracker::verify_tracker_swarm; +pub(super) use verify_payload_integrity::verify_payload_integrity; diff --git a/src/console/ci/qbittorrent_e2e/scenario_steps/qbittorrent/add_torrent_file_to_client.rs b/src/console/ci/qbittorrent_e2e/scenario_steps/qbittorrent/add_torrent_file_to_client.rs new file mode 100644 index 000000000..8e126e658 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/scenario_steps/qbittorrent/add_torrent_file_to_client.rs @@ -0,0 +1,31 @@ +use anyhow::Context; + +use super::super::super::qbittorrent::QbittorrentClient; + +/// Submits a `.torrent` file to a qBittorrent client. +/// +/// This step only submits the torrent definition and save path. It does not guarantee that the +/// torrent has already appeared in the client list or reached a seeding/downloading state. +/// +/// # Errors +/// +/// Returns an error when the qBittorrent API call fails. +pub async fn add_torrent_file_to_client( + client: &QbittorrentClient, + torrent_file_name: &str, + torrent_bytes: &[u8], + save_path: &str, +) -> anyhow::Result<()> { + client + .add_torrent_file(torrent_file_name, torrent_bytes, save_path) + .await + .context("failed to add torrent file to qBittorrent client")?; + + tracing::info!( + client = client.label(), + torrent_file = torrent_file_name, + "torrent file submitted to client" + ); + + Ok(()) +} diff --git a/src/console/ci/qbittorrent_e2e/scenario_steps/qbittorrent/ensure_torrent_is_absent.rs b/src/console/ci/qbittorrent_e2e/scenario_steps/qbittorrent/ensure_torrent_is_absent.rs new file mode 100644 index 000000000..f935859e4 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/scenario_steps/qbittorrent/ensure_torrent_is_absent.rs @@ -0,0 +1,43 @@ +use super::super::super::poller::Poller; +use super::super::super::qbittorrent::QbittorrentClient; +use super::super::super::types::{Deadline, InfoHash, PollInterval}; + +/// Ensures the torrent identified by `hash` is absent from the client's list. +/// +/// If the torrent is already present it is deleted (files are kept on disk). +/// The function then polls until the client confirms it is gone, giving the +/// scenario a clean, deterministic starting state regardless of whether a +/// previous run left the torrent behind. +/// +/// # Errors +/// +/// Returns an error when the deletion request or the absence-polling times out +/// or fails. +pub async fn ensure_torrent_is_absent( + client: &QbittorrentClient, + hash: &InfoHash, + timeout: Deadline, + poll_interval: PollInterval, +) -> anyhow::Result<()> { + let client_label = client.label(); + + if client.has_torrent_with_hash(hash).await? { + tracing::info!(client = client_label, torrent = %hash, "torrent already present, deleting for clean start"); + client.delete_torrent(hash).await?; + } + + let poller = Poller::new(timeout, poll_interval); + + loop { + if !client.has_torrent_with_hash(hash).await? { + tracing::info!(client = client_label, torrent = %hash, "torrent is absent"); + return Ok(()); + } + + tracing::info!(client = client_label, torrent = %hash, "waiting for torrent to be removed"); + + poller + .retry_or_timeout(|| format!("timed out waiting for {client_label} to remove torrent {hash}")) + .await?; + } +} diff --git a/src/console/ci/qbittorrent_e2e/scenario_steps/qbittorrent/login_client.rs b/src/console/ci/qbittorrent_e2e/scenario_steps/qbittorrent/login_client.rs new file mode 100644 index 000000000..73938dfdb --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/scenario_steps/qbittorrent/login_client.rs @@ -0,0 +1,40 @@ +use super::super::super::poller::Poller; +use super::super::super::qbittorrent::{QbittorrentClient, QbittorrentCredentials}; +use super::super::super::types::{Deadline, PollInterval}; + +/// Attempts login using provided credentials and retries until accepted. +/// +/// # Errors +/// +/// Returns an error when login does not succeed before timeout. +pub async fn login_client( + client: &QbittorrentClient, + credentials: &QbittorrentCredentials, + timeout: Deadline, + poll_interval: PollInterval, +) -> anyhow::Result<()> { + let poller = Poller::new(timeout, poll_interval); + let client_label = client.label(); + + loop { + let last_error = match client.login(credentials).await { + Ok(()) => { + tracing::info!(client = client_label, "qBittorrent WebUI login succeeded"); + return Ok(()); + } + Err(error) => error.to_string(), + }; + + tracing::info!( + client = client_label, + error = last_error, + "waiting for qBittorrent WebUI authentication" + ); + + poller + .retry_or_timeout(|| { + format!("timed out waiting for qBittorrent WebUI authentication readiness. Last error: {last_error}") + }) + .await?; + } +} diff --git a/src/console/ci/qbittorrent_e2e/scenario_steps/qbittorrent/mod.rs b/src/console/ci/qbittorrent_e2e/scenario_steps/qbittorrent/mod.rs new file mode 100644 index 000000000..957c87913 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/scenario_steps/qbittorrent/mod.rs @@ -0,0 +1,15 @@ +//! qBittorrent client interaction steps for E2E scenarios. +//! +//! Each file contains one explicit step so available actions are discoverable in the IDE tree. + +mod add_torrent_file_to_client; +mod ensure_torrent_is_absent; +mod login_client; +mod wait_until_download_completes; +mod wait_until_torrent_appears_in_client; + +pub(in super::super) use add_torrent_file_to_client::add_torrent_file_to_client; +pub(in super::super) use ensure_torrent_is_absent::ensure_torrent_is_absent; +pub(in super::super) use login_client::login_client; +pub(in super::super) use wait_until_download_completes::wait_until_download_completes; +pub(in super::super) use wait_until_torrent_appears_in_client::wait_until_torrent_appears_in_client; diff --git a/src/console/ci/qbittorrent_e2e/scenario_steps/qbittorrent/wait_until_download_completes.rs b/src/console/ci/qbittorrent_e2e/scenario_steps/qbittorrent/wait_until_download_completes.rs new file mode 100644 index 000000000..d22f9a298 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/scenario_steps/qbittorrent/wait_until_download_completes.rs @@ -0,0 +1,44 @@ +use super::super::super::poller::Poller; +use super::super::super::qbittorrent::QbittorrentClient; +use super::super::super::types::{Deadline, InfoHash, PollInterval}; + +/// Waits until the torrent identified by `hash` reaches full completion. +/// +/// Uses the `InfoHash` to look up the specific torrent rather than picking the +/// first entry in the list, making this step robust when the client holds +/// multiple torrents concurrently. +/// +/// # Errors +/// +/// Returns an error when polling times out or the torrent list query fails. +pub async fn wait_until_download_completes( + client: &QbittorrentClient, + hash: &InfoHash, + timeout: Deadline, + poll_interval: PollInterval, +) -> anyhow::Result<()> { + let poller = Poller::new(timeout, poll_interval); + let client_label = client.label(); + + loop { + if let Some(torrent) = client.torrent_by_hash(hash).await? { + let progress_pct = torrent.progress.as_fraction() * 100.0; + tracing::info!( + client = client_label, + torrent = %hash, + progress = progress_pct, + state = %torrent.state, + "download progress" + ); + + if torrent.progress.is_complete() { + tracing::info!(client = client_label, torrent = %hash, "download complete"); + return Ok(()); + } + } + + poller + .retry_or_timeout(|| format!("timed out waiting for torrent {hash} to complete")) + .await?; + } +} diff --git a/src/console/ci/qbittorrent_e2e/scenario_steps/qbittorrent/wait_until_torrent_appears_in_client.rs b/src/console/ci/qbittorrent_e2e/scenario_steps/qbittorrent/wait_until_torrent_appears_in_client.rs new file mode 100644 index 000000000..dd74f54e7 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/scenario_steps/qbittorrent/wait_until_torrent_appears_in_client.rs @@ -0,0 +1,39 @@ +use super::super::super::poller::Poller; +use super::super::super::qbittorrent::QbittorrentClient; +use super::super::super::types::{Deadline, InfoHash, PollInterval}; + +/// Waits until the client reports the torrent identified by `hash` in its list. +/// +/// This is the presence/registration barrier for the asynchronous add-torrent +/// flow. It does not guarantee seeding, downloading, or completion state. +/// +/// Unlike a generic "has any torrent" check, this is robust when the client +/// already holds other torrents: it returns only once the specific torrent +/// uploaded by this scenario is confirmed present. +/// +/// # Errors +/// +/// Returns an error when polling times out or the torrent list query fails. +pub async fn wait_until_torrent_appears_in_client( + client: &QbittorrentClient, + hash: &InfoHash, + timeout: Deadline, + poll_interval: PollInterval, +) -> anyhow::Result<()> { + let client_label = client.label(); + let poller = Poller::new(timeout, poll_interval); + + loop { + if client.has_torrent_with_hash(hash).await? { + tracing::info!(client = client_label, torrent = %hash, "torrent has appeared in client list"); + return Ok(()); + } + + let torrent_count = client.torrent_count().await?; + tracing::info!(client = client_label, torrent = %hash, torrent_count = torrent_count, "waiting for torrent to appear"); + + poller + .retry_or_timeout(|| format!("timed out waiting for {client_label} to register torrent {hash}")) + .await?; + } +} diff --git a/src/console/ci/qbittorrent_e2e/scenario_steps/tracker/mod.rs b/src/console/ci/qbittorrent_e2e/scenario_steps/tracker/mod.rs new file mode 100644 index 000000000..bc70653d1 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/scenario_steps/tracker/mod.rs @@ -0,0 +1,7 @@ +//! Tracker API verification steps for E2E scenarios. +//! +//! Each file contains one explicit step so available actions are discoverable in the IDE tree. + +mod verify_tracker_swarm; + +pub(in super::super) use verify_tracker_swarm::verify_tracker_swarm; diff --git a/src/console/ci/qbittorrent_e2e/scenario_steps/tracker/verify_tracker_swarm.rs b/src/console/ci/qbittorrent_e2e/scenario_steps/tracker/verify_tracker_swarm.rs new file mode 100644 index 000000000..f3b6f3eba --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/scenario_steps/tracker/verify_tracker_swarm.rs @@ -0,0 +1,48 @@ +use anyhow::Context; +use torrust_axum_rest_tracker_api_server::v1::context::torrent::resources::torrent::Torrent; + +use super::super::super::tracker::TrackerApiClient; +use super::super::super::types::InfoHash; + +/// Queries the tracker REST API and asserts that the torrent shows at least one +/// seeder and at least one completed transfer. +/// +/// This confirms that: +/// - the seeder announced itself to the tracker (`seeders >= 1`) +/// - the leecher sent a `completed` event after finishing the download (`completed >= 1`) +/// +/// # Errors +/// +/// Returns an error if the API request fails or either assertion does not hold. +pub async fn verify_tracker_swarm(client: &TrackerApiClient, hash: &InfoHash) -> anyhow::Result<()> { + let torrent: Torrent = client + .get_torrent(hash) + .await + .with_context(|| format!("failed to query tracker swarm for torrent {hash}"))?; + + tracing::info!( + torrent = %hash, + seeders = torrent.seeders, + completed = torrent.completed, + leechers = torrent.leechers, + "tracker swarm stats" + ); + + anyhow::ensure!( + torrent.seeders >= 1, + "expected at least 1 seeder in tracker for torrent {hash}, got {} \ + — seeder did not announce to the tracker", + torrent.seeders + ); + + anyhow::ensure!( + torrent.completed >= 1, + "expected at least 1 completed transfer in tracker for torrent {hash}, got {} \ + — leecher did not send a completed event", + torrent.completed + ); + + tracing::info!(torrent = %hash, "tracker swarm verification passed"); + + Ok(()) +} diff --git a/src/console/ci/qbittorrent_e2e/scenario_steps/verify_payload_integrity.rs b/src/console/ci/qbittorrent_e2e/scenario_steps/verify_payload_integrity.rs new file mode 100644 index 000000000..ebaad33d1 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/scenario_steps/verify_payload_integrity.rs @@ -0,0 +1,30 @@ +use std::fs; +use std::path::Path; + +use anyhow::Context; + +/// Verifies that a downloaded file matches the original payload file byte-for-byte. +/// +/// Reads both files from disk and compares their contents byte-for-byte. +pub(in super::super) fn verify_payload_integrity(downloaded_path: &Path, original_path: &Path) -> anyhow::Result<()> { + let downloaded_bytes = fs::read(downloaded_path) + .with_context(|| format!("failed to read downloaded payload from '{}'", downloaded_path.display()))?; + let original_bytes = + fs::read(original_path).with_context(|| format!("failed to read original payload from '{}'", original_path.display()))?; + + if downloaded_bytes.len() != original_bytes.len() { + anyhow::bail!( + "payload size mismatch: original {} bytes, downloaded {} bytes", + original_bytes.len(), + downloaded_bytes.len() + ); + } + + if downloaded_bytes != original_bytes { + anyhow::bail!("payload content mismatch: files have the same size but different contents"); + } + + tracing::info!(bytes = original_bytes.len(), "payload integrity verified"); + + Ok(()) +} diff --git a/src/console/ci/qbittorrent_e2e/scenarios/mod.rs b/src/console/ci/qbittorrent_e2e/scenarios/mod.rs new file mode 100644 index 000000000..70a693472 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/scenarios/mod.rs @@ -0,0 +1,6 @@ +//! E2E test scenarios. +//! +//! Each module in this directory implements one BDD scenario that can be run +//! against a live infrastructure stack. + +pub mod seeder_to_leecher_transfer; diff --git a/src/console/ci/qbittorrent_e2e/scenarios/seeder_to_leecher_transfer.rs b/src/console/ci/qbittorrent_e2e/scenarios/seeder_to_leecher_transfer.rs new file mode 100644 index 000000000..ff2477c12 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/scenarios/seeder_to_leecher_transfer.rs @@ -0,0 +1,268 @@ +//! Scenario: a seeder and a leecher transfer a file via the tracker. +//! +//! This scenario verifies the most common `BitTorrent` tracker use-case: +//! a seeder publishes a torrent and a leecher downloads the complete file +//! through the tracker, which matches them as peers. +//! +//! The scenario is run twice — once with an HTTP announce URL and once with a +//! UDP announce URL — to exercise both tracker protocol implementations. + +use std::fs; + +use anyhow::Context; +use reqwest::Url; + +use super::super::qbittorrent::QbittorrentClient; +use super::super::scenario_steps::{ + add_torrent_file_to_client, build_payload_fixture, build_torrent_fixture, ensure_torrent_is_absent, login_client, + verify_payload_integrity, verify_tracker_swarm, wait_until_download_completes, wait_until_torrent_appears_in_client, +}; +use super::super::tracker::TrackerApiClient; +use super::super::types::{FileName, InfoHash, PayloadSize, PieceLength}; +use super::super::workspace::WorkspaceResources; + +const PAYLOAD_SIZE_BYTES: PayloadSize = PayloadSize::new(1024 * 1024); +const TORRENT_PIECE_LENGTH: PieceLength = PieceLength::new(16 * 1024); + +#[derive(Clone, Copy)] +enum Protocol { + Http, + Udp, +} + +impl Protocol { + fn label(self) -> &'static str { + match self { + Self::Http => "http", + Self::Udp => "udp", + } + } +} + +/// Per-case data built fresh for each protocol run. +struct ScenarioCase { + /// Protocol label used to disambiguate tracing events for repeated runs. + protocol: Protocol, + /// File name of the payload binary (e.g. `"payload-http.bin"`). + payload_file_name: FileName, + /// File name of the `.torrent` metainfo (e.g. `"payload-http.torrent"`). + torrent_file_name: FileName, + /// Raw bytes of the `.torrent` metainfo file passed to the qBittorrent API. + torrent_bytes: Vec<u8>, + /// v1 info hash of the torrent (lowercase hex, 40 chars). + info_hash: InfoHash, +} + +/// Scenario fixtures prepared on the host filesystem before containers start. +pub(crate) struct PreparedCases { + cases: Vec<ScenarioCase>, +} + +impl PreparedCases { + fn iter(&self) -> impl Iterator<Item = &ScenarioCase> { + self.cases.iter() + } +} + +/// Builds all scenario fixtures on disk. +/// +/// This must run before `docker compose up` so host-side writes to bind-mounted +/// paths are done before container init scripts can alter ownership/permissions. +pub(crate) fn prepare(workspace: &WorkspaceResources) -> anyhow::Result<PreparedCases> { + let http_case = prepare_case(workspace, Protocol::Http, &workspace.tracker_endpoints.http_announce_url) + .context("failed to prepare HTTP scenario case")?; + let udp_case = prepare_case(workspace, Protocol::Udp, &workspace.tracker_endpoints.udp_announce_url) + .context("failed to prepare UDP scenario case")?; + + Ok(PreparedCases { + cases: vec![http_case, udp_case], + }) +} + +/// Runs the seeder-to-leecher transfer scenario for both the HTTP and UDP trackers. +/// +/// # Errors +/// +/// Returns an error if any step of either scenario case fails. +pub(crate) async fn run( + seeder: &QbittorrentClient, + leecher: &QbittorrentClient, + tracker: &TrackerApiClient, + workspace: &WorkspaceResources, + prepared_cases: &PreparedCases, +) -> anyhow::Result<()> { + for case in prepared_cases.iter() { + let case_label = case.protocol.label(); + run_case(seeder, leecher, tracker, workspace, case) + .await + .with_context(|| format!("{case_label} tracker scenario failed"))?; + } + + Ok(()) +} + +/// Prepares the shared and seeder-downloads files for one protocol run. +/// +/// Writes `payload-{protocol}.bin` to both the shared directory and the seeder +/// downloads directory, then writes `payload-{protocol}.torrent` (pointing at +/// `announce_url`) to the shared directory. +/// +/// # Errors +/// +/// Returns an error when any file operation or torrent encoding fails. +fn prepare_case(workspace: &WorkspaceResources, protocol: Protocol, announce_url: &Url) -> anyhow::Result<ScenarioCase> { + let payload_file_name = format!("payload-{}.bin", protocol.label()); + let torrent_file_name = format!("payload-{}.torrent", protocol.label()); + + let payload_fixture = build_payload_fixture(PAYLOAD_SIZE_BYTES); + + let payload_path = workspace.shared.path.join(&payload_file_name); + fs::write(&payload_path, &payload_fixture.bytes) + .with_context(|| format!("failed to write payload file '{}'", payload_path.display()))?; + + let seeder_payload_path = workspace.seeder.downloads_path.join(&payload_file_name); + fs::copy(&payload_path, &seeder_payload_path).with_context(|| { + format!( + "failed to prime seeder downloads with payload '{}'", + seeder_payload_path.display() + ) + })?; + + let torrent_fixture = build_torrent_fixture( + &payload_fixture, + &payload_file_name, + announce_url.as_ref(), + TORRENT_PIECE_LENGTH, + ) + .context("failed to build torrent fixture")?; + + let torrent_path = workspace.shared.path.join(&torrent_file_name); + fs::write(&torrent_path, &torrent_fixture.bytes) + .with_context(|| format!("failed to write torrent file '{}'", torrent_path.display()))?; + + Ok(ScenarioCase { + protocol, + payload_file_name: FileName::new(&payload_file_name), + torrent_file_name: FileName::new(&torrent_file_name), + torrent_bytes: torrent_fixture.bytes, + info_hash: torrent_fixture.info_hash, + }) +} + +async fn run_case( + seeder: &QbittorrentClient, + leecher: &QbittorrentClient, + tracker: &TrackerApiClient, + workspace: &WorkspaceResources, + case: &ScenarioCase, +) -> anyhow::Result<()> { + let info_hash = &case.info_hash; + let scenario_case = case.protocol.label(); + + tracing::info!(case = scenario_case, torrent = %info_hash, "scenario start: seeder-to-leecher transfer"); + + // ARRANGE: seeder seeds a new torrent + + login_client( + seeder, + &workspace.seeder.credentials, + workspace.timing.polling_deadline, + workspace.timing.login_poll_interval, + ) + .await + .context("seeder qBittorrent API did not become ready for authentication")?; + + // Guarantee a clean starting state — delete the torrent if a previous run left it behind. + ensure_torrent_is_absent( + seeder, + info_hash, + workspace.timing.polling_deadline, + workspace.timing.torrent_poll_interval, + ) + .await?; + + add_torrent_file_to_client( + seeder, + &case.torrent_file_name, + &case.torrent_bytes, + &workspace.seeder.container_downloads_path, + ) + .await?; + + // qBittorrent processes `add_torrent` asynchronously, so an immediate `list_torrents` + // after upload can race and return 0. + wait_until_torrent_appears_in_client( + seeder, + info_hash, + workspace.timing.polling_deadline, + workspace.timing.torrent_poll_interval, + ) + .await?; + + tracing::info!(case = scenario_case, torrent = %info_hash, "seeder is ready"); + + // ACT: leecher downloads the torrent from the seeder via the tracker + + login_client( + leecher, + &workspace.leecher.credentials, + workspace.timing.polling_deadline, + workspace.timing.login_poll_interval, + ) + .await + .context("leecher qBittorrent API did not become ready for authentication")?; + + // Guarantee a clean starting state for the leecher. + ensure_torrent_is_absent( + leecher, + info_hash, + workspace.timing.polling_deadline, + workspace.timing.torrent_poll_interval, + ) + .await?; + + add_torrent_file_to_client( + leecher, + &case.torrent_file_name, + &case.torrent_bytes, + &workspace.leecher.container_downloads_path, + ) + .await?; + + tracing::info!(case = scenario_case, torrent = %info_hash, "download started: leecher is fetching from seeder"); + + wait_until_torrent_appears_in_client( + leecher, + info_hash, + workspace.timing.polling_deadline, + workspace.timing.torrent_poll_interval, + ) + .await?; + wait_until_download_completes( + leecher, + info_hash, + workspace.timing.polling_deadline, + workspace.timing.torrent_poll_interval, + ) + .await?; + + tracing::info!(case = scenario_case, torrent = %info_hash, "download finished"); + + // ASSERT: downloaded file matches the original payload. + + verify_payload_integrity( + &workspace.leecher.downloads_path.join(&case.payload_file_name), + &workspace.shared.path.join(&case.payload_file_name), + ) + .context("downloaded payload does not match the original")?; + + // ASSERT: tracker registered both peers (seeder announced; leecher completed). + + verify_tracker_swarm(tracker, info_hash) + .await + .context("tracker swarm verification failed")?; + + tracing::info!(case = scenario_case, torrent = %info_hash, "scenario passed: seeder-to-leecher transfer"); + + Ok(()) +} diff --git a/src/console/ci/qbittorrent_e2e/services_setup.rs b/src/console/ci/qbittorrent_e2e/services_setup.rs new file mode 100644 index 000000000..d388feb78 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/services_setup.rs @@ -0,0 +1,164 @@ +//! Container services setup for the `qBittorrent` E2E tests. +//! +//! This module starts the full infrastructure stack: builds the tracker image, +//! brings up the `Docker` Compose services, and constructs the `qBittorrent` API +//! clients for the seeder and leecher containers. +use std::fs; +use std::path::Path; +use std::time::Duration; + +use anyhow::Context; + +use super::client_role::ClientRole; +use super::qbittorrent::{QbittorrentClient, QBITTORRENT_WEBUI_PORT}; +use super::tracker::{TrackerApiClient, TrackerConfig}; +use super::types::{ComposeProjectName, QbittorrentImage, TrackerImage}; +use super::workspace::WorkspaceResources; +use crate::console::ci::compose::{DockerCompose, RunningCompose}; +const COMPOSE_PORT_POLL_INTERVAL: Duration = Duration::from_secs(1); + +/// Builds the tracker image, starts all Docker Compose services, and returns +/// the running stack guard together with the seeder and leecher API clients. +/// +/// # Errors +/// +/// Returns an error when image building, service start-up, or client +/// construction fails. +pub(crate) async fn start( + compose_file: &Path, + project_name: &ComposeProjectName, + tracker_image: &TrackerImage, + qbittorrent_image: &QbittorrentImage, + resources: &WorkspaceResources, + tracker_config: &TrackerConfig, +) -> anyhow::Result<(RunningCompose, QbittorrentClient, QbittorrentClient, TrackerApiClient)> { + let compose = configure_compose( + compose_file, + project_name, + tracker_image, + qbittorrent_image, + resources, + tracker_config, + )?; + compose.build().context("failed to build local tracker image")?; + let running_compose = compose.up().context("failed to start qBittorrent compose stack")?; + let timeout = resources.timing.polling_deadline.as_duration(); + let (seeder, leecher) = build_clients(&compose, timeout).await?; + let tracker = build_tracker_api_client(&compose, tracker_config, timeout).await?; + Ok((running_compose, seeder, leecher, tracker)) +} + +async fn build_clients(compose: &DockerCompose, timeout: Duration) -> anyhow::Result<(QbittorrentClient, QbittorrentClient)> { + let seeder = build_seeder_client(compose, timeout).await?; + let leecher = build_leecher_client(compose, timeout).await?; + Ok((seeder, leecher)) +} + +async fn build_tracker_api_client( + compose: &DockerCompose, + tracker_config: &TrackerConfig, + timeout: Duration, +) -> anyhow::Result<TrackerApiClient> { + let container_port = tracker_config.http_api_bind_address().port(); + let host_port = compose + .wait_for_port_mapping("tracker", container_port, timeout, COMPOSE_PORT_POLL_INTERVAL, &[]) + .await + .context("failed to resolve tracker REST API host port")?; + + tracing::info!("Tracker REST API host port: {host_port}"); + + TrackerApiClient::new(host_port, tracker_config).context("failed to build tracker REST API client") +} + +async fn build_seeder_client(compose: &DockerCompose, timeout: Duration) -> anyhow::Result<QbittorrentClient> { + let port = wait_for_client_port(compose, ClientRole::Seeder, timeout).await?; + build_client(ClientRole::Seeder, port, timeout) +} + +async fn build_leecher_client(compose: &DockerCompose, timeout: Duration) -> anyhow::Result<QbittorrentClient> { + let port = wait_for_client_port(compose, ClientRole::Leecher, timeout).await?; + build_client(ClientRole::Leecher, port, timeout) +} + +async fn wait_for_client_port(compose: &DockerCompose, role: ClientRole, timeout: Duration) -> anyhow::Result<u16> { + let service_name = role.service_name(); + let host_port = compose + .wait_for_port_mapping( + service_name, + QBITTORRENT_WEBUI_PORT, + timeout, + COMPOSE_PORT_POLL_INTERVAL, + &["tracker"], + ) + .await + .with_context(|| format!("failed to resolve {service_name} WebUI host port"))?; + + tracing::info!("{} WebUI host port: {host_port}", role.client_label()); + + Ok(host_port) +} + +fn build_client(role: ClientRole, host_port: u16, timeout: Duration) -> anyhow::Result<QbittorrentClient> { + let service_name = role.service_name(); + QbittorrentClient::new(role.client_label(), &format!("http://localhost:{host_port}"), timeout) + .with_context(|| format!("failed to create qBittorrent client for service '{service_name}'")) +} + +fn configure_compose( + compose_file: &Path, + project_name: &ComposeProjectName, + tracker_image: &TrackerImage, + qbittorrent_image: &QbittorrentImage, + workspace: &WorkspaceResources, + tracker_config: &TrackerConfig, +) -> anyhow::Result<DockerCompose> { + let tracker_http_tracker_port = tracker_config.http_tracker_bind_address().port().to_string(); + let tracker_udp_port = tracker_config.udp_bind_address().port().to_string(); + let tracker_http_api_port = tracker_config.http_api_bind_address().port().to_string(); + let tracker_health_check_api_port = tracker_config.health_check_api_bind_address().port().to_string(); + + Ok(DockerCompose::new(compose_file, project_name.as_str()) + .with_env("QBT_E2E_TRACKER_IMAGE", tracker_image.as_str()) + .with_env("QBT_E2E_QBITTORRENT_IMAGE", qbittorrent_image.as_str()) + .with_env("QBT_E2E_TRACKER_HTTP_TRACKER_PORT", tracker_http_tracker_port.as_str()) + .with_env("QBT_E2E_TRACKER_UDP_PORT", tracker_udp_port.as_str()) + .with_env("QBT_E2E_TRACKER_HTTP_API_PORT", tracker_http_api_port.as_str()) + .with_env( + "QBT_E2E_TRACKER_HEALTH_CHECK_API_PORT", + tracker_health_check_api_port.as_str(), + ) + .with_env( + "QBT_E2E_TRACKER_CONFIG_PATH", + normalize_path_for_compose(&workspace.tracker.config_path)?.as_str(), + ) + .with_env( + "QBT_E2E_TRACKER_STORAGE_PATH", + normalize_path_for_compose(&workspace.tracker.storage_path)?.as_str(), + ) + .with_env( + "QBT_E2E_SHARED_PATH", + normalize_path_for_compose(&workspace.shared.path)?.as_str(), + ) + .with_env( + "QBT_E2E_SEEDER_CONFIG_PATH", + normalize_path_for_compose(&workspace.seeder.config_path)?.as_str(), + ) + .with_env( + "QBT_E2E_LEECHER_CONFIG_PATH", + normalize_path_for_compose(&workspace.leecher.config_path)?.as_str(), + ) + .with_env( + "QBT_E2E_SEEDER_DOWNLOADS_PATH", + normalize_path_for_compose(&workspace.seeder.downloads_path)?.as_str(), + ) + .with_env( + "QBT_E2E_LEECHER_DOWNLOADS_PATH", + normalize_path_for_compose(&workspace.leecher.downloads_path)?.as_str(), + )) +} + +fn normalize_path_for_compose(path: &Path) -> anyhow::Result<String> { + let absolute_path = fs::canonicalize(path).with_context(|| format!("failed to canonicalize path '{}'", path.display()))?; + + Ok(absolute_path.to_string_lossy().into_owned()) +} diff --git a/src/console/ci/qbittorrent_e2e/torrent_artifacts.rs b/src/console/ci/qbittorrent_e2e/torrent_artifacts.rs new file mode 100644 index 000000000..eab4bff32 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/torrent_artifacts.rs @@ -0,0 +1,196 @@ +use std::fmt::Write as _; + +use anyhow::Context; +use sha1::{Digest as Sha1Digest, Sha1}; + +use super::bencode::BencodeValue; +use super::types::InfoHash; + +/// Artifacts produced by [`build_torrent_bytes`]. +pub(super) struct TorrentArtifacts { + /// Raw bytes of the `.torrent` metainfo file. + pub(super) torrent_bytes: Vec<u8>, + /// v1 `InfoHash`: SHA-1 of the bencoded `info` dict, lowercase hex (40 chars). + /// Matches the hash format returned by the qBittorrent Web API. + pub(super) info_hash: InfoHash, +} + +pub(super) fn build_payload_bytes(length: usize) -> Vec<u8> { + let pattern = (0_u8..=250_u8).collect::<Vec<_>>(); + + (0..length).map(|index| pattern[index % pattern.len()]).collect() +} + +pub(super) fn build_torrent_bytes( + payload_bytes: &[u8], + payload_name: &str, + announce_url: &str, + piece_length: usize, +) -> anyhow::Result<TorrentArtifacts> { + let pieces = payload_bytes + .chunks(piece_length) + .map(|piece| Sha1::digest(piece).to_vec()) + .collect::<Vec<_>>() + .concat(); + + let payload_length = i64::try_from(payload_bytes.len()).context("payload length does not fit in i64")?; + let piece_length = i64::try_from(piece_length).context("piece length does not fit in i64")?; + + let info = BencodeValue::Dictionary(vec![ + (b"length".to_vec(), BencodeValue::Integer(payload_length)), + (b"name".to_vec(), BencodeValue::Bytes(payload_name.as_bytes().to_vec())), + (b"piece length".to_vec(), BencodeValue::Integer(piece_length)), + (b"pieces".to_vec(), BencodeValue::Bytes(pieces)), + ]); + + let info_bytes = info.encode(); + let info_hash_bytes: [u8; 20] = Sha1::digest(&info_bytes).into(); + let mut info_hash_hex = String::with_capacity(40); + for b in info_hash_bytes { + write!(info_hash_hex, "{b:02x}").expect("writing to String is infallible"); + } + + let torrent = BencodeValue::Dictionary(vec![ + (b"announce".to_vec(), BencodeValue::Bytes(announce_url.as_bytes().to_vec())), + (b"created by".to_vec(), BencodeValue::Bytes(b"torrust-qb-e2e".to_vec())), + (b"creation date".to_vec(), BencodeValue::Integer(0)), + (b"info".to_vec(), BencodeValue::Raw(info_bytes)), + ]); + + Ok(TorrentArtifacts { + torrent_bytes: torrent.encode(), + info_hash: InfoHash::new(info_hash_hex), + }) +} + +#[cfg(test)] +mod tests { + use super::{build_payload_bytes, build_torrent_bytes}; + + #[test] + fn it_should_build_payload_bytes_with_the_right_length() { + assert_eq!(build_payload_bytes(5).len(), 5); + } + + #[test] + fn it_should_build_payload_bytes_with_a_repeating_pattern() { + // Pattern starts at 0. + assert_eq!(build_payload_bytes(3), vec![0, 1, 2]); + } + + #[test] + fn it_should_build_payload_bytes_wrapping_around_the_pattern() { + // Pattern is 0..=250 (251 bytes). Index 251 wraps back to 0. + let bytes = build_payload_bytes(252); + assert_eq!(bytes[250], 250); + assert_eq!(bytes[251], 0); + } + + #[test] + fn it_should_build_torrent_bytes_as_a_valid_bencode_dictionary() { + // A valid bencode dict starts with b'd' and ends with b'e'. + let payload = build_payload_bytes(1); + let artifacts = build_torrent_bytes(&payload, "test", "http://tracker:7070/announce", 1).unwrap(); + assert_eq!(artifacts.torrent_bytes.first(), Some(&b'd')); + assert_eq!(artifacts.torrent_bytes.last(), Some(&b'e')); + } + + #[test] + fn it_should_embed_the_announce_url_verbatim_in_the_torrent_bytes() { + let payload = build_payload_bytes(1); + let url = "http://tracker:7070/announce"; + let artifacts = build_torrent_bytes(&payload, "test", url, 1).unwrap(); + let url_bytes = url.as_bytes(); + assert!( + artifacts.torrent_bytes.windows(url_bytes.len()).any(|w| w == url_bytes), + "announce URL not found in torrent bytes" + ); + } + + #[test] + fn it_should_embed_the_info_dict_raw_so_it_appears_as_a_nested_bencode_dict() { + // The outer dict must contain the inner info dict as a raw bencode dict + // (starting with b'd'), not as a length-prefixed byte string. + // This verifies the two-pass InfoHash pattern: encode info, embed via Raw. + let payload = build_payload_bytes(1); + let artifacts = build_torrent_bytes(&payload, "test", "http://tracker:7070/announce", 1).unwrap(); + // b"4:info" is the bencode key; the very next byte must be b'd' (dict), not a digit (byte string). + let key = b"4:info"; + let pos = artifacts + .torrent_bytes + .windows(key.len()) + .position(|w| w == key) + .expect("key '4:info' not found in torrent bytes"); + assert_eq!( + artifacts.torrent_bytes[pos + key.len()], + b'd', + "info value should be a nested bencode dict (b'd'), not a byte string" + ); + } + + #[test] + fn it_should_produce_deterministic_torrent_bytes_for_identical_inputs() { + let payload = build_payload_bytes(100); + let first = build_torrent_bytes(&payload, "test.bin", "http://tracker:7070/announce", 16).unwrap(); + let second = build_torrent_bytes(&payload, "test.bin", "http://tracker:7070/announce", 16).unwrap(); + assert_eq!(first.torrent_bytes, second.torrent_bytes); + assert_eq!(first.info_hash, second.info_hash); + } + + #[test] + fn it_should_produce_different_torrent_bytes_for_different_payloads() { + let payload_a = build_payload_bytes(10); + let payload_b = build_payload_bytes(20); + let torrent_a = build_torrent_bytes(&payload_a, "test", "http://tracker:7070/announce", 8).unwrap(); + let torrent_b = build_torrent_bytes(&payload_b, "test", "http://tracker:7070/announce", 8).unwrap(); + assert_ne!(torrent_a.torrent_bytes, torrent_b.torrent_bytes); + assert_ne!(torrent_a.info_hash, torrent_b.info_hash); + } + + #[test] + fn it_should_produce_a_40_character_lowercase_hex_info_hash() { + let payload = build_payload_bytes(100); + let artifacts = build_torrent_bytes(&payload, "test.bin", "http://tracker:7070/announce", 16).unwrap(); + assert_eq!( + artifacts.info_hash.as_str().len(), + 40, + "InfoHash hex must be 40 characters (20 bytes × 2)" + ); + assert!( + artifacts + .info_hash + .as_str() + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()), + "InfoHash hex must contain only lowercase hex digits" + ); + } + + #[test] + fn it_should_produce_a_different_info_hash_when_only_the_payload_changes() { + // The InfoHash covers the info dict (payload content, name, piece length). + // Two torrents with different payloads must have different hashes. + let payload_a = build_payload_bytes(10); + let payload_b = build_payload_bytes(20); + let hash_a = build_torrent_bytes(&payload_a, "test", "http://tracker:7070/announce", 8) + .unwrap() + .info_hash; + let hash_b = build_torrent_bytes(&payload_b, "test", "http://tracker:7070/announce", 8) + .unwrap() + .info_hash; + assert_ne!(hash_a, hash_b); + } + + #[test] + fn it_should_produce_the_same_info_hash_regardless_of_the_announce_url() { + // The announce URL is outside the info dict and must not affect the InfoHash. + let payload = build_payload_bytes(10); + let hash_a = build_torrent_bytes(&payload, "test", "http://tracker-a:7070/announce", 8) + .unwrap() + .info_hash; + let hash_b = build_torrent_bytes(&payload, "test", "http://tracker-b:7070/announce", 8) + .unwrap() + .info_hash; + assert_eq!(hash_a, hash_b, "announce URL must not affect the InfoHash"); + } +} diff --git a/src/console/ci/qbittorrent_e2e/tracker/client.rs b/src/console/ci/qbittorrent_e2e/tracker/client.rs new file mode 100644 index 000000000..0300a9492 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/tracker/client.rs @@ -0,0 +1,61 @@ +//! Tracker REST API client, scoped to E2E test needs. +//! +//! Wraps the official [`torrust_rest_tracker_api_client::v1::Client`] so that +//! future scenario steps can call any REST API endpoint through the same client +//! without having to reconstruct connection details each time. +use anyhow::Context; +use torrust_axum_rest_tracker_api_server::v1::context::torrent::resources::torrent::Torrent; +use torrust_rest_tracker_api_client::connection_info::{ConnectionInfo, Origin}; +use torrust_rest_tracker_api_client::v1::client::Client; + +use super::super::types::InfoHash; +use super::config_builder::TrackerConfig; + +/// Wrapper around the official Torrust Tracker REST API client. +/// +/// Provides typed, high-level helpers for the endpoints used in E2E test scenarios. +/// All other endpoints are still reachable through the inner [`Client`]. +pub(crate) struct TrackerApiClient { + inner: Client, +} + +impl TrackerApiClient { + /// Creates a new client connected to the tracker REST API on the given host port. + /// + /// # Errors + /// + /// Returns an error if the origin URL cannot be parsed or the HTTP client + /// cannot be built. + pub(crate) fn new(host_port: u16, tracker_config: &TrackerConfig) -> anyhow::Result<Self> { + let origin = Origin::new(&format!("http://127.0.0.1:{host_port}")) // DevSkim: ignore DS137138 + .context("failed to parse tracker REST API origin")?; + + let connection_info = ConnectionInfo::authenticated(origin, tracker_config.access_token()); + + let inner = Client::new(connection_info).context("failed to build tracker REST API client")?; + + Ok(Self { inner }) + } + + /// Returns the full [`Torrent`] resource for the torrent identified by `hash`. + /// + /// # Errors + /// + /// Returns an error if the HTTP request fails, the server returns a non-2xx + /// status, or the response body cannot be deserialized. + pub(crate) async fn get_torrent(&self, hash: &InfoHash) -> anyhow::Result<Torrent> { + let response = self.inner.get_torrent(hash.as_str(), None).await; + + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "tracker REST API returned status {} for torrent {hash}", + response.status() + )); + } + + response + .json::<Torrent>() + .await + .with_context(|| format!("failed to deserialize tracker torrent response for {hash}")) + } +} diff --git a/src/console/ci/qbittorrent_e2e/tracker/config_builder.rs b/src/console/ci/qbittorrent_e2e/tracker/config_builder.rs new file mode 100644 index 000000000..157a8e0c0 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/tracker/config_builder.rs @@ -0,0 +1,178 @@ +//! Builder for the Torrust Tracker configuration file written into the E2E workspace. +use std::fs; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::path::{Path, PathBuf}; + +use anyhow::Context; +use torrust_tracker_configuration::{Configuration, HealthCheckApi, HttpApi, HttpTracker, UdpTracker}; + +const CONFIG_FILE_NAME: &str = "tracker-config.toml"; +const DEFAULT_DATABASE_PATH: &str = "/var/lib/torrust/tracker/database/sqlite3.db"; +const TRACKER_BIND_HOST: IpAddr = IpAddr::V4(Ipv4Addr::UNSPECIFIED); +const TRACKER_UDP_PORT: u16 = 6969; +const TRACKER_HTTP_TRACKER_PORT: u16 = 7070; +const TRACKER_HTTP_API_PORT: u16 = 1212; +const TRACKER_HEALTH_CHECK_API_PORT: u16 = 1313; +const DEFAULT_ACCESS_TOKEN: &str = "MyAccessToken"; + +/// Typed tracker configuration shared across the E2E workflow. +#[derive(Clone, Debug)] +pub(crate) struct TrackerConfig { + database_path: String, + udp_bind_address: SocketAddr, + http_tracker_bind_address: SocketAddr, + http_api_bind_address: SocketAddr, + health_check_api_bind_address: SocketAddr, + access_token: String, +} + +impl Default for TrackerConfig { + fn default() -> Self { + Self { + database_path: DEFAULT_DATABASE_PATH.to_string(), + udp_bind_address: bind_address(TRACKER_UDP_PORT), + http_tracker_bind_address: bind_address(TRACKER_HTTP_TRACKER_PORT), + http_api_bind_address: bind_address(TRACKER_HTTP_API_PORT), + health_check_api_bind_address: bind_address(TRACKER_HEALTH_CHECK_API_PORT), + access_token: DEFAULT_ACCESS_TOKEN.to_string(), + } + } +} + +impl TrackerConfig { + pub(crate) fn udp_bind_address(&self) -> SocketAddr { + self.udp_bind_address + } + + pub(crate) fn http_tracker_bind_address(&self) -> SocketAddr { + self.http_tracker_bind_address + } + + pub(crate) fn health_check_api_bind_address(&self) -> SocketAddr { + self.health_check_api_bind_address + } + + pub(crate) fn http_api_bind_address(&self) -> SocketAddr { + self.http_api_bind_address + } + + pub(crate) fn access_token(&self) -> &str { + &self.access_token + } + + pub(crate) fn announce_url_for_compose_service(&self) -> String { + let announce_url = format!("http://tracker:{}/announce", self.http_tracker_bind_address.port()); // DevSkim: ignore DS137138 + + announce_url + } + + pub(crate) fn udp_announce_url_for_compose_service(&self) -> String { + format!("udp://tracker:{}", self.udp_bind_address.port()) + } + + fn to_torrust_configuration(&self) -> Configuration { + let mut configuration = Configuration::default(); + + configuration.core.database.path.clone_from(&self.database_path); + + configuration.udp_trackers = Some(vec![UdpTracker { + bind_address: self.udp_bind_address, + ..UdpTracker::default() + }]); + + configuration.http_trackers = Some(vec![HttpTracker { + bind_address: self.http_tracker_bind_address, + ..HttpTracker::default() + }]); + + let mut http_api = HttpApi { + bind_address: self.http_api_bind_address, + ..HttpApi::default() + }; + http_api.add_token("admin", &self.access_token); + configuration.http_api = Some(http_api); + + configuration.health_check_api = HealthCheckApi { + bind_address: self.health_check_api_bind_address, + }; + + configuration + } +} + +/// Builds and writes the Torrust Tracker configuration file for the E2E workspace. +/// +/// All fields default to values suited for the E2E Docker Compose stack. Call +/// [`write_to`](TrackerConfigBuilder::write_to) to write `tracker-config.toml` +/// into the supplied workspace root directory. +pub(crate) struct TrackerConfigBuilder { + tracker_config: TrackerConfig, +} + +impl TrackerConfigBuilder { + /// Creates a builder from a typed E2E tracker configuration object. + pub(crate) fn new(tracker_config: TrackerConfig) -> Self { + Self { tracker_config } + } + + // These builder methods allow future scenarios to override the default + // tracker bind addresses, database path, and access token (e.g. for + // private-tracker or multi-database scenarios). Tracked: <https://github.com/torrust/torrust-tracker/issues/1706>. + #[expect(dead_code, reason = "reserved for future scenario configuration; see #1706")] + pub(crate) fn database_path(mut self, path: &str) -> Self { + self.tracker_config.database_path = path.to_string(); + self + } + + #[expect(dead_code, reason = "reserved for future scenario configuration; see #1706")] + pub(crate) fn udp_bind_address(mut self, addr: SocketAddr) -> Self { + self.tracker_config.udp_bind_address = addr; + self + } + + #[expect(dead_code, reason = "reserved for future scenario configuration; see #1706")] + pub(crate) fn http_tracker_bind_address(mut self, addr: SocketAddr) -> Self { + self.tracker_config.http_tracker_bind_address = addr; + self + } + + #[expect(dead_code, reason = "reserved for future scenario configuration; see #1706")] + pub(crate) fn http_api_bind_address(mut self, addr: SocketAddr) -> Self { + self.tracker_config.http_api_bind_address = addr; + self + } + + #[expect(dead_code, reason = "reserved for future scenario configuration; see #1706")] + pub(crate) fn health_check_api_bind_address(mut self, addr: SocketAddr) -> Self { + self.tracker_config.health_check_api_bind_address = addr; + self + } + + #[expect(dead_code, reason = "reserved for future scenario configuration; see #1706")] + pub(crate) fn access_token(mut self, token: &str) -> Self { + self.tracker_config.access_token = token.to_string(); + self + } + + /// Writes `tracker-config.toml` to `workspace_root`. + /// + /// Returns the path of the written file. + /// + /// # Errors + /// + /// Returns an error when writing the config file fails. + pub(crate) fn write_to(&self, workspace_root: &Path) -> anyhow::Result<PathBuf> { + let config_path = workspace_root.join(CONFIG_FILE_NAME); + let config = self.tracker_config.to_torrust_configuration(); + let config_toml = toml::to_string(&config).context("failed to serialize tracker config to TOML")?; + + fs::write(&config_path, config_toml) + .with_context(|| format!("failed to write tracker config '{}'", config_path.display()))?; + + Ok(config_path) + } +} + +fn bind_address(port: u16) -> SocketAddr { + SocketAddr::new(TRACKER_BIND_HOST, port) +} diff --git a/src/console/ci/qbittorrent_e2e/tracker/mod.rs b/src/console/ci/qbittorrent_e2e/tracker/mod.rs new file mode 100644 index 000000000..10b6e2a1d --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/tracker/mod.rs @@ -0,0 +1,6 @@ +//! Torrust Tracker feature module for the qBittorrent E2E tests. +mod client; +mod config_builder; + +pub(crate) use client::TrackerApiClient; +pub(super) use config_builder::{TrackerConfig, TrackerConfigBuilder}; diff --git a/src/console/ci/qbittorrent_e2e/types/compose_project_name.rs b/src/console/ci/qbittorrent_e2e/types/compose_project_name.rs new file mode 100644 index 000000000..d556b658b --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/types/compose_project_name.rs @@ -0,0 +1,71 @@ +use std::fmt; +use std::ops::Deref; + +use rand::distr::Alphanumeric; +use rand::RngExt; + +/// A Docker Compose project name generated for one E2E test run. +/// +/// Project names follow the pattern `<prefix>-<random-suffix>` where the +/// suffix is ten lowercase alphanumeric characters, keeping each run's +/// containers, volumes, and networks isolated from one another. +/// +/// Wraps a [`String`] and provides [`Deref`] to `str` so values can be +/// passed wherever `&str` is expected. +#[derive(Debug, Clone)] +pub(crate) struct ComposeProjectName(String); + +impl ComposeProjectName { + /// Generates a unique project name with the given prefix. + /// + /// Appends ten random lowercase alphanumeric characters to `prefix`, + /// separated by a hyphen. + pub(crate) fn generate(prefix: &str) -> Self { + let suffix: String = rand::rng() + .sample_iter(&Alphanumeric) + .take(10) + .map(char::from) + .map(|c| c.to_ascii_lowercase()) + .collect(); + Self(format!("{prefix}-{suffix}")) + } + + /// Returns the project name as a `&str`. + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +impl Deref for ComposeProjectName { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl fmt::Display for ComposeProjectName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[cfg(test)] +mod tests { + use super::ComposeProjectName; + + #[test] + fn it_should_generate_expected_shape() { + let name = ComposeProjectName::generate("qbt-e2e"); + let as_str = name.as_str(); + + assert!(as_str.starts_with("qbt-e2e-")); + assert_eq!(as_str.len(), "qbt-e2e-".len() + 10); + + let suffix = &as_str["qbt-e2e-".len()..]; + assert!(suffix.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())); + + assert_eq!(&*name, as_str); + assert_eq!(name.to_string(), as_str); + } +} diff --git a/src/console/ci/qbittorrent_e2e/types/container_path.rs b/src/console/ci/qbittorrent_e2e/types/container_path.rs new file mode 100644 index 000000000..9141c1fcd --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/types/container_path.rs @@ -0,0 +1,67 @@ +use std::fmt; +use std::ops::Deref; + +/// An absolute path inside a Docker container (e.g. `"/downloads"`). +/// +/// Distinct from host [`PathBuf`]s: a `ContainerPath` is always a +/// Linux-style absolute path that exists only within the container +/// file-system, never on the host. +/// +/// [`PathBuf`]: std::path::PathBuf +#[derive(Debug, Clone)] +pub(crate) struct ContainerPath(String); + +impl ContainerPath { + /// Creates a new [`ContainerPath`] from any value that converts into a [`String`]. + pub(crate) fn new(path: impl Into<String>) -> Self { + Self(path.into()) + } +} + +impl Deref for ContainerPath { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl fmt::Display for ContainerPath { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl From<String> for ContainerPath { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From<&str> for ContainerPath { + fn from(s: &str) -> Self { + Self(s.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::ContainerPath; + + #[test] + fn it_should_build_from_new_and_format_as_string() { + let path = ContainerPath::new("/downloads"); + + assert_eq!(&*path, "/downloads"); + assert_eq!(path.to_string(), "/downloads"); + } + + #[test] + fn it_should_convert_from_string_and_str() { + let from_string = ContainerPath::from(String::from("/a")); + let from_str = ContainerPath::from("/b"); + + assert_eq!(&*from_string, "/a"); + assert_eq!(&*from_str, "/b"); + } +} diff --git a/src/console/ci/qbittorrent_e2e/types/deadline.rs b/src/console/ci/qbittorrent_e2e/types/deadline.rs new file mode 100644 index 000000000..4752ac46d --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/types/deadline.rs @@ -0,0 +1,37 @@ +use std::time::Duration; + +/// A polling-loop deadline expressed as a [`Duration`] measured from the moment +/// the loop starts. +/// +/// Wraps a [`Duration`] representing the *maximum time* a polling loop may wait +/// before giving up. Keeping it distinct from [`PollInterval`] turns an +/// accidental swap into a compile error instead of a silent logic bug. +#[derive(Debug, Clone, Copy)] +pub(crate) struct Deadline(Duration); + +impl Deadline { + /// Creates a new [`Deadline`] from a [`Duration`]. + pub(crate) fn new(duration: Duration) -> Self { + Self(duration) + } + + /// Returns the underlying [`Duration`]. + pub(crate) fn as_duration(&self) -> Duration { + self.0 + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::Deadline; + + #[test] + fn it_should_round_trip_duration() { + let duration = Duration::from_secs(42); + let deadline = Deadline::new(duration); + + assert_eq!(deadline.as_duration(), duration); + } +} diff --git a/src/console/ci/qbittorrent_e2e/types/file_name.rs b/src/console/ci/qbittorrent_e2e/types/file_name.rs new file mode 100644 index 000000000..97bf32a5c --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/types/file_name.rs @@ -0,0 +1,140 @@ +use std::fmt; +use std::ops::Deref; +use std::path::Path; + +/// A file name (base name only, no path separators). +/// +/// Wraps a [`String`] and provides [`Deref`] to `str` so values can be used +/// directly wherever `&str` is expected, and [`AsRef<Path>`] so they can be +/// passed to [`Path::join`]. +/// +/// # Invariant +/// +/// The wrapped string must not contain `/`, `\`, or the component `..`. +/// Construction fails with a panic in debug builds and returns an error via +/// the `TryFrom` impl when the invariant is violated. +#[derive(Debug, Clone)] +pub(crate) struct FileName(String); + +/// Error returned when a string is not a valid base file name. +#[derive(Debug)] +pub(crate) struct InvalidFileName(String); + +impl fmt::Display for InvalidFileName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "invalid file name (must not contain path separators or '..'): {:?}", + self.0 + ) + } +} + +impl std::error::Error for InvalidFileName {} + +fn validate(name: &str) -> Result<(), InvalidFileName> { + if name.contains('/') || name.contains('\\') || name == ".." || name.contains("/..") || name.contains("../") { + return Err(InvalidFileName(name.to_string())); + } + Ok(()) +} + +impl FileName { + /// Creates a new [`FileName`]. + /// + /// # Panics + /// + /// Panics if `name` contains `/`, `\`, or the path component `..`. + pub(crate) fn new(name: impl Into<String>) -> Self { + let s = name.into(); + validate(&s).expect("FileName invariant violated"); + Self(s) + } +} + +impl TryFrom<String> for FileName { + type Error = InvalidFileName; + + fn try_from(s: String) -> Result<Self, Self::Error> { + validate(&s)?; + Ok(Self(s)) + } +} + +impl TryFrom<&str> for FileName { + type Error = InvalidFileName; + + fn try_from(s: &str) -> Result<Self, Self::Error> { + validate(s)?; + Ok(Self(s.to_string())) + } +} + +impl Deref for FileName { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AsRef<Path> for FileName { + fn as_ref(&self) -> &Path { + Path::new(&self.0) + } +} + +impl fmt::Display for FileName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::FileName; + + #[test] + fn it_should_build_from_new_and_format_as_string() { + let file_name = FileName::new("payload.bin"); + + assert_eq!(&*file_name, "payload.bin"); + assert_eq!(file_name.to_string(), "payload.bin"); + } + + #[test] + fn it_should_convert_from_string_and_str() { + let from_string = FileName::try_from(String::from("a.torrent")).unwrap(); + let from_str = FileName::try_from("b.torrent").unwrap(); + + assert_eq!(&*from_string, "a.torrent"); + assert_eq!(&*from_str, "b.torrent"); + } + + #[test] + fn it_should_implement_as_ref_path() { + let file_name = FileName::new("file.txt"); + + assert_eq!(file_name.as_ref(), Path::new("file.txt")); + } + + #[test] + fn it_should_reject_forward_slash() { + let result = FileName::try_from("nested/file.txt"); + assert!(result.is_err()); + } + + #[test] + fn it_should_reject_backslash() { + let result = FileName::try_from("nested\\file.txt"); + assert!(result.is_err()); + } + + #[test] + fn it_should_reject_double_dot() { + let result = FileName::try_from(".."); + assert!(result.is_err()); + } +} diff --git a/src/console/ci/qbittorrent_e2e/types/info_hash.rs b/src/console/ci/qbittorrent_e2e/types/info_hash.rs new file mode 100644 index 000000000..06e157efc --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/types/info_hash.rs @@ -0,0 +1,69 @@ +use std::fmt; +use std::ops::Deref; + +/// A v1 `BitTorrent` `InfoHash` — a 40-character lowercase hex-encoded SHA-1 digest. +/// +/// Wraps a [`String`] to give the value a precise type at every call site, +/// eliminating confusion with other hex strings (e.g. peer IDs, piece hashes). +/// +/// The format matches what the qBittorrent Web API returns in the `hash` field +/// of `/api/v2/torrents/info`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct InfoHash(String); + +impl InfoHash { + /// Creates a new [`InfoHash`] from any value that converts into a [`String`]. + pub(crate) fn new(hash: impl Into<String>) -> Self { + Self(hash.into()) + } + + /// Returns the hash as a `&str`. + #[must_use] + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +impl Deref for InfoHash { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl fmt::Display for InfoHash { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl<'de> serde::Deserialize<'de> for InfoHash { + fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { + let value = <String as serde::Deserialize>::deserialize(deserializer)?; + Ok(Self(value)) + } +} + +#[cfg(test)] +mod tests { + use super::InfoHash; + + #[test] + fn it_should_construct_info_hash_and_expose_accessors() { + let hash = InfoHash::new("0123456789abcdef0123456789abcdef01234567"); // DevSkim: ignore DS173237 + + assert_eq!(hash.as_str(), "0123456789abcdef0123456789abcdef01234567"); // DevSkim: ignore DS173237 + assert_eq!(&*hash, "0123456789abcdef0123456789abcdef01234567"); // DevSkim: ignore DS173237 + assert_eq!(hash.to_string(), "0123456789abcdef0123456789abcdef01234567"); + // DevSkim: ignore DS173237 + } + + #[test] + fn it_should_deserialize_info_hash_from_json_string() { + let parsed = serde_json::from_str::<InfoHash>("\"abcdef0123456789abcdef0123456789abcdef01\""); // DevSkim: ignore DS173237 + + let hash = parsed.expect("valid hash JSON"); + assert_eq!(hash.as_str(), "abcdef0123456789abcdef0123456789abcdef01"); // DevSkim: ignore DS173237 + } +} diff --git a/src/console/ci/qbittorrent_e2e/types/mod.rs b/src/console/ci/qbittorrent_e2e/types/mod.rs new file mode 100644 index 000000000..9b5cfd79c --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/types/mod.rs @@ -0,0 +1,26 @@ +//! Small domain types shared across the `qBittorrent` E2E module. +//! +//! Most types here follow the newtype pattern: a thin wrapper around a primitive +//! that gives the value a precise, self-documenting type at every call site. + +mod compose_project_name; +mod container_path; +mod deadline; +mod file_name; +mod info_hash; +mod payload_size; +mod piece_length; +mod poll_interval; +mod qbittorrent_image; +mod tracker_image; + +pub(crate) use compose_project_name::ComposeProjectName; +pub(crate) use container_path::ContainerPath; +pub(crate) use deadline::Deadline; +pub(crate) use file_name::FileName; +pub(crate) use info_hash::InfoHash; +pub(crate) use payload_size::PayloadSize; +pub(crate) use piece_length::PieceLength; +pub(crate) use poll_interval::PollInterval; +pub(crate) use qbittorrent_image::QbittorrentImage; +pub(crate) use tracker_image::TrackerImage; diff --git a/src/console/ci/qbittorrent_e2e/types/payload_size.rs b/src/console/ci/qbittorrent_e2e/types/payload_size.rs new file mode 100644 index 000000000..3a1709521 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/types/payload_size.rs @@ -0,0 +1,31 @@ +/// The total byte size of a test payload used in the E2E torrent scenario. +/// +/// Distinct from [`PieceLength`] to prevent an accidental swap of the two +/// `usize` torrent-construction arguments. +#[derive(Debug, Clone, Copy)] +pub(crate) struct PayloadSize(usize); + +impl PayloadSize { + /// Creates a new [`PayloadSize`] from a byte count. + pub(crate) const fn new(bytes: usize) -> Self { + Self(bytes) + } + + /// Returns the byte count as a `usize`. + #[must_use] + pub(crate) fn as_usize(self) -> usize { + self.0 + } +} + +#[cfg(test)] +mod tests { + use super::PayloadSize; + + #[test] + fn it_should_round_trip_payload_size() { + let size = PayloadSize::new(16_384); + + assert_eq!(size.as_usize(), 16_384); + } +} diff --git a/src/console/ci/qbittorrent_e2e/types/piece_length.rs b/src/console/ci/qbittorrent_e2e/types/piece_length.rs new file mode 100644 index 000000000..81bf7439c --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/types/piece_length.rs @@ -0,0 +1,31 @@ +/// The piece length for a torrent, in bytes. +/// +/// Distinct from [`PayloadSize`] to prevent an accidental swap of the two +/// `usize` torrent-construction arguments. +#[derive(Debug, Clone, Copy)] +pub(crate) struct PieceLength(usize); + +impl PieceLength { + /// Creates a new [`PieceLength`] from a byte count. + pub(crate) const fn new(bytes: usize) -> Self { + Self(bytes) + } + + /// Returns the piece length as a `usize`. + #[must_use] + pub(crate) fn as_usize(self) -> usize { + self.0 + } +} + +#[cfg(test)] +mod tests { + use super::PieceLength; + + #[test] + fn it_should_round_trip_piece_length() { + let piece_length = PieceLength::new(262_144); + + assert_eq!(piece_length.as_usize(), 262_144); + } +} diff --git a/src/console/ci/qbittorrent_e2e/types/poll_interval.rs b/src/console/ci/qbittorrent_e2e/types/poll_interval.rs new file mode 100644 index 000000000..252db86c3 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/types/poll_interval.rs @@ -0,0 +1,35 @@ +use std::time::Duration; + +/// The sleep duration between successive retries in a polling loop. +/// +/// Wraps a [`Duration`]. Distinct from [`Deadline`] so that the two cannot +/// be accidentally swapped at a call site. +#[derive(Debug, Clone, Copy)] +pub(crate) struct PollInterval(Duration); + +impl PollInterval { + /// Creates a new [`PollInterval`] from a [`Duration`]. + pub(crate) fn new(duration: Duration) -> Self { + Self(duration) + } + + /// Returns the underlying [`Duration`]. + pub(crate) fn as_duration(&self) -> Duration { + self.0 + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::PollInterval; + + #[test] + fn it_should_round_trip_duration() { + let duration = Duration::from_millis(750); + let interval = PollInterval::new(duration); + + assert_eq!(interval.as_duration(), duration); + } +} diff --git a/src/console/ci/qbittorrent_e2e/types/qbittorrent_image.rs b/src/console/ci/qbittorrent_e2e/types/qbittorrent_image.rs new file mode 100644 index 000000000..7a34eac75 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/types/qbittorrent_image.rs @@ -0,0 +1,49 @@ +use std::fmt; +use std::ops::Deref; + +/// A Docker image reference for a qBittorrent service container. +/// +/// Keeping this distinct from [`TrackerImage`] turns an accidental swap of the +/// two image arguments into a compile error. +#[derive(Debug, Clone)] +pub(crate) struct QbittorrentImage(String); + +impl QbittorrentImage { + /// Creates a new [`QbittorrentImage`] from any value that converts into a [`String`]. + pub(crate) fn new(image: impl Into<String>) -> Self { + Self(image.into()) + } + + /// Returns the image reference as a `&str`. + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +impl Deref for QbittorrentImage { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl fmt::Display for QbittorrentImage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[cfg(test)] +mod tests { + use super::QbittorrentImage; + + #[test] + fn it_should_round_trip_image_string() { + let image = QbittorrentImage::new("lscr.io/linuxserver/qbittorrent:5.1.4"); + + assert_eq!(image.as_str(), "lscr.io/linuxserver/qbittorrent:5.1.4"); + assert_eq!(&*image, "lscr.io/linuxserver/qbittorrent:5.1.4"); + assert_eq!(image.to_string(), "lscr.io/linuxserver/qbittorrent:5.1.4"); + } +} diff --git a/src/console/ci/qbittorrent_e2e/types/tracker_image.rs b/src/console/ci/qbittorrent_e2e/types/tracker_image.rs new file mode 100644 index 000000000..6a5a572e6 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/types/tracker_image.rs @@ -0,0 +1,49 @@ +use std::fmt; +use std::ops::Deref; + +/// A Docker image reference for the Torrust tracker service. +/// +/// Keeping this distinct from [`QbittorrentImage`] turns an accidental swap of +/// the two image arguments into a compile error. +#[derive(Debug, Clone)] +pub(crate) struct TrackerImage(String); + +impl TrackerImage { + /// Creates a new [`TrackerImage`] from any value that converts into a [`String`]. + pub(crate) fn new(image: impl Into<String>) -> Self { + Self(image.into()) + } + + /// Returns the image reference as a `&str`. + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +impl Deref for TrackerImage { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl fmt::Display for TrackerImage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[cfg(test)] +mod tests { + use super::TrackerImage; + + #[test] + fn it_should_round_trip_image_string() { + let image = TrackerImage::new("torrust/tracker:latest"); + + assert_eq!(image.as_str(), "torrust/tracker:latest"); + assert_eq!(&*image, "torrust/tracker:latest"); + assert_eq!(image.to_string(), "torrust/tracker:latest"); + } +} diff --git a/src/console/ci/qbittorrent_e2e/workspace.rs b/src/console/ci/qbittorrent_e2e/workspace.rs new file mode 100644 index 000000000..932d365a3 --- /dev/null +++ b/src/console/ci/qbittorrent_e2e/workspace.rs @@ -0,0 +1,84 @@ +use std::path::{Path, PathBuf}; + +use reqwest::Url; + +use super::qbittorrent::QbittorrentCredentials; +use super::types::{ContainerPath, Deadline, PollInterval}; + +pub(crate) struct PeerConfig { + /// Path to `{role}-config/` on the host. + pub(crate) config_path: PathBuf, + /// Path to `{role}-downloads/` on the host. + pub(crate) downloads_path: PathBuf, + /// Credentials for the `qBittorrent` web UI. + pub(crate) credentials: QbittorrentCredentials, + /// Download path inside the container (e.g. `"/downloads"`). + pub(crate) container_downloads_path: ContainerPath, +} + +pub(crate) struct TrackerFilesystem { + /// Path to `tracker-config.toml` on the host. + pub(crate) config_path: PathBuf, + /// Path to the `tracker-storage/` directory on the host. + pub(crate) storage_path: PathBuf, +} + +/// Tracker announce URLs formatted for use from within the Docker Compose network. +pub(crate) struct TrackerEndpoints { + /// HTTP announce URL reachable by containers (e.g. `"http://tracker:7070/announce"`). + pub(crate) http_announce_url: Url, + /// UDP announce URL reachable by containers (e.g. `"udp://tracker:6969/announce"`). + pub(crate) udp_announce_url: Url, +} + +pub(crate) struct SharedFixtures { + /// Path to the `shared/` directory on the host. + pub(crate) path: PathBuf, +} + +pub(crate) struct TimingConfig { + /// Maximum time any single polling loop will wait before giving up. + /// Passed directly to `Poller::new` as the loop deadline. + pub(crate) polling_deadline: Deadline, + /// Sleep duration between login-readiness retries. + pub(crate) login_poll_interval: PollInterval, + /// Sleep duration between torrent-state retries. + pub(crate) torrent_poll_interval: PollInterval, +} + +pub(crate) struct WorkspaceResources { + pub(crate) root_path: PathBuf, + pub(crate) tracker: TrackerFilesystem, + pub(crate) tracker_endpoints: TrackerEndpoints, + pub(crate) seeder: PeerConfig, + pub(crate) leecher: PeerConfig, + pub(crate) shared: SharedFixtures, + pub(crate) timing: TimingConfig, +} + +pub(crate) struct EphemeralWorkspace { + pub(crate) _temp_dir: tempfile::TempDir, + pub(crate) resources: WorkspaceResources, +} + +pub(crate) struct PermanentWorkspace { + pub(crate) resources: WorkspaceResources, +} + +pub(crate) enum PreparedWorkspace { + Ephemeral(EphemeralWorkspace), + Permanent(PermanentWorkspace), +} + +impl PreparedWorkspace { + pub(crate) fn resources(&self) -> &WorkspaceResources { + match self { + Self::Ephemeral(workspace) => &workspace.resources, + Self::Permanent(workspace) => &workspace.resources, + } + } + + pub(crate) fn root_path(&self) -> &Path { + &self.resources().root_path + } +} diff --git a/src/container.rs b/src/container.rs index 7112a54e8..3fb88fafa 100644 --- a/src/container.rs +++ b/src/container.rs @@ -47,7 +47,7 @@ pub struct AppContainer { impl AppContainer { #[instrument(skip(configuration))] - pub fn initialize(configuration: &Configuration) -> AppContainer { + pub async fn initialize(configuration: &Configuration) -> AppContainer { // Configuration let core_config = Arc::new(configuration.core.clone()); @@ -66,10 +66,8 @@ impl AppContainer { // Core - let tracker_core_container = Arc::new(TrackerCoreContainer::initialize_from( - &core_config, - &swarm_coordination_registry_container, - )); + let tracker_core_container = + Arc::new(TrackerCoreContainer::initialize_from(&core_config, &swarm_coordination_registry_container).await); // HTTP diff --git a/src/lib.rs b/src/lib.rs index b26960899..791c0d928 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -88,6 +88,12 @@ //! //! The tracker has some system dependencies: //! +//! First, you need to install the build tools: +//! +//! ```text +//! sudo apt-get install build-essential +//! ``` +//! //! Since we are using the `openssl` crate with the [vendored feature](https://docs.rs/openssl/latest/openssl/#vendored), //! enabled, you will need to install the following dependencies: //!