diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..e3efbf5 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,10 @@ +# Cross-compilation linker configuration. +# Linux linkers are provided by the following Debian/Ubuntu packages: +# aarch64-linux-gnu-gcc → gcc-aarch64-linux-gnu +# i686-linux-gnu-gcc → gcc-i686-linux-gnu + +[target.aarch64-unknown-linux-gnu] +linker = "aarch64-linux-gnu-gcc" + +[target.i686-unknown-linux-gnu] +linker = "i686-linux-gnu-gcc" diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..940f6d8 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,59 @@ +{ + "name": "analyzer-cli", + "build": { + "context": "..", + "dockerfile": "../containers/devcontainer/Dockerfile" + }, + "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached", + "workspaceFolder": "/workspace", + "remoteUser": "vscode", + // Mount the host container-engine socket into the container so that docker/podman + // CLI commands inside the devcontainer talk to the host daemon. + // + // Docker users : no extra setup required (the default path is used). + // Podman users : export CONTAINER_SOCKET_PATH to your Podman socket path + // BEFORE opening the devcontainer, e.g.: + // export CONTAINER_SOCKET_PATH=/run/user/1000/podman/podman.sock + // With rootless Podman the path is usually: + // $XDG_RUNTIME_DIR/podman/podman.sock + // Enable the socket if needed: + // systemctl --user enable --now podman.socket + "mounts": [ + "source=${localEnv:CONTAINER_SOCKET_PATH:/var/run/docker.sock},target=/var/run/docker.sock,type=bind" + ], + // Always expose a Docker-API-compatible DOCKER_HOST inside the container. + // Both the Docker CLI and the Podman CLI support this variable. + "containerEnv": { + "DOCKER_HOST": "unix:///var/run/docker.sock" + }, + "postCreateCommand": "bash /usr/local/share/analyzer-devcontainer/post-create.sh", + "customizations": { + "vscode": { + "extensions": [ + // Rust + "rust-lang.rust-analyzer", + "tamasfe.even-better-toml", + "vadimcn.vscode-lldb", + "fill-labs.dependi", + // Editor UX + "usernamehw.errorlens", + "eamodio.gitlens", + // Config / markup files + "redhat.vscode-yaml", + // CI + containers + "github.vscode-github-actions", + "ms-azuretools.vscode-docker", + // AI assistance + "github.copilot", + "github.copilot-chat" + ], + "settings": { + "terminal.integrated.defaultProfile.linux": "bash", + "rust-analyzer.check.command": "clippy", + // Podman users: set this to "podman" so the Docker VS Code extension + // uses the Podman CLI instead of the docker binary. + "docker.dockerPath": "docker" + } + } + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4077d1c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +target/ +.git/ +.github/ +.copilot/ +dist/ +coverage/ +**/*.log +**/*.tmp +**/*.temp +**/.DS_Store diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a11e382 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.rs] +indent_size = 4 + +[*.{md,toml,json,yml,yaml}] +indent_size = 2 + +[*.ps1] +end_of_line = crlf + +[*.{bat,cmd}] +end_of_line = crlf diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e24818f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +* text=auto eol=lf + +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.pdf binary +*.zip binary +*.gz binary +*.tar binary diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..20784fd --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @exein-io diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..29451d7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,42 @@ +name: Bug report +description: Report a reproducible problem in the CLI. +title: "[Bug]: " +labels: + - bug +body: + - type: markdown + attributes: + value: | + Thanks for filing a bug report. Please provide enough detail for us to reproduce the issue. + + - type: textarea + id: description + attributes: + label: What happened? + description: Describe the current behavior and the expected behavior. + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Reproduction steps + description: Include commands, flags, and sanitized sample inputs. + validations: + required: true + + - type: input + id: version + attributes: + label: Analyzer CLI version + placeholder: e.g. v0.2.0 + validations: + required: true + + - type: textarea + id: environment + attributes: + label: Environment + description: OS, shell, Rust version if building from source, and any other relevant details. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..19e0bea --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security report + url: https://github.com/exein-io/analyzer-cli/security/advisories/new + about: Please use private reporting for security issues. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..7c7e328 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,27 @@ +name: Feature request +description: Suggest an improvement for the CLI or release workflow. +title: "[Feature]: " +labels: + - enhancement +body: + - type: textarea + id: problem + attributes: + label: Problem statement + description: What user or business problem does this solve? + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Describe the desired behavior, commands, or UX. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: List workarounds or other options you evaluated. diff --git a/.github/agents/release-manager.md b/.github/agents/release-manager.md new file mode 100644 index 0000000..4e6a210 --- /dev/null +++ b/.github/agents/release-manager.md @@ -0,0 +1,13 @@ +--- +name: Release Manager +description: Prepares GitFlow-friendly releases, checks versioning, and keeps GitHub workflows aligned. +--- + +You are responsible for release readiness. + +## Focus areas + +- Review `GitVersion.yml`, `Cargo.toml`, and `.github/workflows/`. +- Ensure release assets cover supported targets. +- Verify branch strategy, tags, and release notes remain coherent. +- Summarize release risks before shipping. diff --git a/.github/agents/rust-cli-maintainer.md b/.github/agents/rust-cli-maintainer.md new file mode 100644 index 0000000..b753ba2 --- /dev/null +++ b/.github/agents/rust-cli-maintainer.md @@ -0,0 +1,18 @@ +--- +name: Rust CLI Maintainer +description: Maintains the Rust command-line application, preserving CLI stability and quality gates. +--- + +You are the maintainer for a Rust CLI. + +## Responsibilities + +- Implement or refactor features in `src/`. +- Keep `clap` command definitions coherent and backwards compatible where possible. +- Update `README.md` for any visible behavior change. +- Validate with `cargo fmt`, `cargo clippy`, and `cargo test --locked`. + +## Constraints + +- Do not introduce broad fallback logic that hides failures. +- Prefer targeted edits and existing patterns over new abstractions. diff --git a/.github/agents/security-reviewer.md b/.github/agents/security-reviewer.md new file mode 100644 index 0000000..abce0c7 --- /dev/null +++ b/.github/agents/security-reviewer.md @@ -0,0 +1,13 @@ +--- +name: Security Reviewer +description: Reviews changes for CLI, workflow, and dependency security regressions. +--- + +You perform targeted security reviews. + +## Focus areas + +- secrets exposure in workflows, docs, and examples +- unsafe shell or release automation patterns +- error handling paths that leak credentials or hide failures +- dependency and supply-chain concerns in GitHub Actions changes diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..4339527 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,25 @@ +# Analyzer CLI Copilot instructions + +This repository contains a Rust command-line application for Exein Analyzer. + +## What matters most + +- Preserve CLI compatibility and existing command names unless the task explicitly requires change. +- Keep `README.md`, release automation, and user-facing docs aligned with code changes. +- Prefer small, explicit Rust functions with `anyhow` or typed errors already used by the codebase. +- Do not swallow errors or silently ignore failed API interactions. +- Favor maintainable terminal UX: readable defaults, JSON support for automation, and clear progress/output messages. + +## Validation expectations + +Before considering work complete, prefer running: + +- `cargo fmt --all -- --check` +- `cargo clippy --all-targets -- -D warnings` +- `cargo test --locked` + +## Repository conventions + +- `Cargo.lock` is committed. +- The repository follows GitFlow branch naming and GitVersion semantic versioning. +- Workflow changes should stay compatible with GitHub Actions on Linux, macOS, and Windows where applicable. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d478e1b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 + groups: + rust-dependencies: + patterns: + - "*" + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 diff --git a/.github/hooks/README.md b/.github/hooks/README.md new file mode 100644 index 0000000..0c7688a --- /dev/null +++ b/.github/hooks/README.md @@ -0,0 +1,5 @@ +# Copilot hooks + +This folder contains optional hook bundles inspired by the patterns used in Awesome Copilot. + +Hooks are intentionally kept opt-in. Review them before enabling them in your local Copilot client. diff --git a/.github/hooks/rust-quality-gate/README.md b/.github/hooks/rust-quality-gate/README.md new file mode 100644 index 0000000..c2a81c5 --- /dev/null +++ b/.github/hooks/rust-quality-gate/README.md @@ -0,0 +1,15 @@ +--- +name: Rust quality gate +description: Run the repository quality checks after AI-driven edits. +tags: [rust, quality, copilot, hooks] +--- + +# Rust quality gate + +This optional hook runs the Rust validation trio after Copilot or agent edits: + +- `cargo fmt --all -- --check` +- `cargo clippy --all-targets -- -D warnings` +- `cargo test --locked` + +Use it locally when you want fast feedback after AI-assisted changes. diff --git a/.github/hooks/rust-quality-gate/hooks.json b/.github/hooks/rust-quality-gate/hooks.json new file mode 100644 index 0000000..0fa1e55 --- /dev/null +++ b/.github/hooks/rust-quality-gate/hooks.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "hooks": { + "postToolUse": [ + { + "type": "command", + "bash": "bash .github/hooks/rust-quality-gate/post-tool-use.sh", + "timeoutSec": 300 + } + ] + } +} diff --git a/.github/hooks/rust-quality-gate/post-tool-use.sh b/.github/hooks/rust-quality-gate/post-tool-use.sh new file mode 100644 index 0000000..cc1f38a --- /dev/null +++ b/.github/hooks/rust-quality-gate/post-tool-use.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ ! -f Cargo.toml ]]; then + exit 0 +fi + +cargo fmt --all -- --check +cargo clippy --all-targets -- -D warnings +cargo test --locked diff --git a/.github/instructions/docs.instructions.md b/.github/instructions/docs.instructions.md new file mode 100644 index 0000000..2e8e9e6 --- /dev/null +++ b/.github/instructions/docs.instructions.md @@ -0,0 +1,10 @@ +--- +applyTo: "**/*.md" +--- + +# Documentation instructions + +- Keep documentation concise, practical, and copy-paste friendly. +- Use fenced code blocks with the right language hint. +- Prefer imperative instructions and concrete examples over generic prose. +- Keep repository docs aligned with actual commands, branch names, and workflow files. diff --git a/.github/instructions/rust.instructions.md b/.github/instructions/rust.instructions.md new file mode 100644 index 0000000..6ad2120 --- /dev/null +++ b/.github/instructions/rust.instructions.md @@ -0,0 +1,11 @@ +--- +applyTo: "src/**/*.rs" +--- + +# Rust source instructions + +- Follow existing `clap` patterns for commands, flags, and subcommands. +- Prefer explicit types and builder-free clarity over clever abstractions. +- Keep I/O and API errors visible to users with actionable context. +- Reuse existing client, config, and output modules before introducing new helpers. +- When adding new commands or flags, update `README.md`. diff --git a/.github/instructions/workflows.instructions.md b/.github/instructions/workflows.instructions.md new file mode 100644 index 0000000..80799dd --- /dev/null +++ b/.github/instructions/workflows.instructions.md @@ -0,0 +1,11 @@ +--- +applyTo: ".github/workflows/**/*.yml" +--- + +# GitHub Actions instructions + +- Use least-privilege permissions. +- Prefer `actions/checkout@v4` with `fetch-depth: 0` only when history is required. +- Keep workflows deterministic and compatible with GitFlow and tag-based releases. +- Use `cargo ... --locked` in CI when dependencies are expected to be reproducible. +- Add concise step names and preserve readable summaries. diff --git a/.github/mcp.json b/.github/mcp.json new file mode 100644 index 0000000..817c916 --- /dev/null +++ b/.github/mcp.json @@ -0,0 +1,14 @@ +{ + "servers": { + "awesome-copilot": { + "type": "stdio", + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "ghcr.io/microsoft/mcp-dotnet-samples/awesome-copilot:latest" + ] + } + } +} diff --git a/.github/mcp/README.md b/.github/mcp/README.md new file mode 100644 index 0000000..3f5bda4 --- /dev/null +++ b/.github/mcp/README.md @@ -0,0 +1,24 @@ +# MCP setup + +This repository includes `.github/mcp.json` with the `awesome-copilot` MCP server enabled as the default shared repository configuration. + +## Why this server + +`awesome-copilot` gives the team a curated catalog of community-maintained agents, instructions, skills, hooks, and prompts that can be copied into the repository safely after review. + +## Recommended team stack + +- `awesome-copilot` for discovery and installation of Copilot customizations +- a GitHub-aware MCP server for repository search, issues, PRs, and releases +- a local filesystem MCP server for richer local file exploration when allowed by your environment + +## Usage + +In GitHub Copilot Chat or compatible clients, use the Awesome Copilot MCP server to discover reusable customizations for: + +- Rust maintenance +- release management +- security review +- documentation review + +Review every imported customization before committing it. diff --git a/.github/prompts/release-readiness.prompt.md b/.github/prompts/release-readiness.prompt.md new file mode 100644 index 0000000..a394615 --- /dev/null +++ b/.github/prompts/release-readiness.prompt.md @@ -0,0 +1,19 @@ +--- +mode: ask +description: Review the repository for release readiness before tagging a version. +--- + +Review this repository for release readiness. + +Check: + +1. `Cargo.toml`, `Cargo.lock`, and `rust-toolchain.toml` +2. `GitVersion.yml` and GitFlow branch assumptions +3. `.github/workflows/*.yml` +4. `README.md`, `CONTRIBUTING.md`, and `SECURITY.md` + +Return: + +- release blockers +- recommended follow-ups +- confidence level for tagging `v` diff --git a/.github/prompts/rust-hardening.prompt.md b/.github/prompts/rust-hardening.prompt.md new file mode 100644 index 0000000..a8f3655 --- /dev/null +++ b/.github/prompts/rust-hardening.prompt.md @@ -0,0 +1,14 @@ +--- +mode: edit +description: Harden Rust CLI code while preserving command-line behavior. +--- + +Improve reliability and maintainability for this Rust CLI without changing intended user-facing behavior. + +Priorities: + +1. preserve command names and flags +2. keep error handling explicit +3. avoid duplicated logic +4. update docs if behavior changes +5. keep `cargo fmt`, `cargo clippy`, and `cargo test --locked` passing diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..b5b937d --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,15 @@ +## Summary + +Describe the change and why it is needed. + +## Validation + +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --all-targets -- -D warnings` +- [ ] `cargo test --locked` + +## Checklist + +- [ ] Documentation updated if needed +- [ ] No secrets introduced +- [ ] Branch naming follows GitFlow conventions diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de35d32..404e7b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,17 @@ name: CI on: push: - branches: [main] + branches: [main, stable, develop] pull_request: - branches: [main] + branches: [main, stable, develop] + workflow_dispatch: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read env: CARGO_TERM_COLOR: always @@ -18,16 +26,7 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - run: cargo check --all-targets - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - run: cargo test + - run: cargo check --locked --all-targets fmt: name: Format @@ -49,3 +48,39 @@ jobs: components: clippy - uses: Swatinem/rust-cache@v2 - run: cargo clippy --all-targets -- -D warnings + + test: + name: Test + runs-on: ubuntu-latest + needs: [check, fmt, clippy] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test --locked + + build: + name: Build + runs-on: ubuntu-latest + needs: [check, fmt, clippy] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo build --locked --release + + # Required status check — branch protection rules reference this job. + # It succeeds only when all quality gates above pass. + ci: + name: CI + runs-on: ubuntu-latest + needs: [test, build] + if: always() + steps: + - name: All checks passed + run: | + if [[ "${{ needs.test.result }}" != "success" || "${{ needs.build.result }}" != "success" ]]; then + echo "One or more required checks failed." + exit 1 + fi + echo "All checks passed." diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..755982d --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,38 @@ +name: CodeQL + +on: + push: + branches: [main, stable, develop] + pull_request: + branches: [main, stable, develop] + schedule: + - cron: "17 3 * * 1" + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: [rust] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + + - name: Build + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..2fa3799 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,18 @@ +name: Dependency Review + +on: + pull_request: + branches: [main, stable, develop] + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Dependency review + uses: actions/dependency-review-action@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 116808f..98fae62 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,8 +2,17 @@ name: Release on: push: + branches: + - main + - stable + - develop tags: - "v*" + workflow_dispatch: + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: true permissions: contents: write @@ -12,67 +21,138 @@ env: CARGO_TERM_COLOR: always jobs: - create-release: - name: Create GitHub Release + # ── Compute semantic version ────────────────────────────────────────────── + version: + name: Compute version runs-on: ubuntu-latest + outputs: + semVer: ${{ steps.compute.outputs.semVer }} + majorMinorPatch: ${{ steps.compute.outputs.majorMinorPatch }} + buildMetaData: ${{ steps.compute.outputs.buildMetaData }} + stamp: ${{ steps.stamp.outputs.stamp }} steps: - uses: actions/checkout@v4 - - name: Create release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release create "${{ github.ref_name }}" --draft --generate-notes + with: + fetch-depth: 0 - build: - name: Build ${{ matrix.target }} - needs: create-release - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - - target: x86_64-unknown-linux-gnu - os: ubuntu-latest - - target: aarch64-unknown-linux-gnu - os: ubuntu-latest - - target: x86_64-apple-darwin - os: macos-latest - - target: aarch64-apple-darwin - os: macos-14 + - name: Compute version + id: compute + run: | + BASE=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') + BUILD=$(git rev-list --count HEAD) + BRANCH="${GITHUB_REF_NAME}" + case "$BRANCH" in + main) SEMVER="${BASE}" ;; + stable) SEMVER="${BASE}-stable.${BUILD}" ;; + *) SEMVER="${BASE}-${BRANCH//\//-}.${BUILD}" ;; + esac + echo "majorMinorPatch=${BASE}" >> "$GITHUB_OUTPUT" + echo "buildMetaData=${BUILD}" >> "$GITHUB_OUTPUT" + echo "semVer=${SEMVER}" >> "$GITHUB_OUTPUT" + - name: Compute date stamp + id: stamp + run: echo "stamp=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT" + + # ── Cross-compile all targets inside the release container ─────────────── + build: + name: Cross-compile (all targets) + runs-on: ubuntu-latest + needs: version steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: taiki-e/setup-cross-toolchain-action@v1 - if: startsWith(matrix.os, 'ubuntu') && !contains(matrix.target, 'x86_64-unknown-linux-gnu') + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build release container image + uses: docker/build-push-action@v6 with: - target: ${{ matrix.target }} + context: . + file: containers/release/Dockerfile + load: true + tags: analyzer-cli-release:local + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Run cross-compile pipeline + run: | + docker run --rm \ + -e ANALYZER_RELEASE_VERSION="${{ needs.version.outputs.majorMinorPatch }}" \ + -e ANALYZER_RELEASE_BUILD="${{ needs.version.outputs.buildMetaData || '0' }}" \ + -e ANALYZER_RELEASE_STAMP="${{ needs.version.outputs.stamp }}" \ + -v "${{ github.workspace }}:/workspace" \ + analyzer-cli-release:local - - uses: taiki-e/upload-rust-binary-action@v1 + - name: Upload archives as workflow artifacts + uses: actions/upload-artifact@v4 with: - bin: analyzer - target: ${{ matrix.target }} - tar: all - zip: windows - checksum: sha256 - token: ${{ secrets.GITHUB_TOKEN }} - - publish-release: - name: Publish Release - needs: build + name: release-archives + path: dist/releases/**/*.zip + if-no-files-found: error + retention-days: 30 + + # ── Create or update GitHub Release with all archives ──────────────────── + publish: + name: Publish GitHub Release runs-on: ubuntu-latest + needs: [version, build] + env: + VERSION: ${{ needs.version.outputs.semVer }} + STAMP: ${{ needs.version.outputs.stamp }} steps: - uses: actions/checkout@v4 - - name: Publish release (remove draft) + with: + fetch-depth: 0 + + - name: Download archives + uses: actions/download-artifact@v4 + with: + name: release-archives + path: dist/releases + + - name: Derive tag name + id: tag + run: | + # For tag-triggered runs use the tag as-is. + # For branch-triggered runs use the full semVer from GitVersion, + # which already carries pre-release suffixes for non-main branches. + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + else + echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" + fi + + - name: Create or update release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release edit "${{ github.ref_name }}" --draft=false + TAG: ${{ steps.tag.outputs.tag }} + run: | + mapfile -t ARCHIVES < <(find dist/releases -name "*.zip" | sort) + if [[ ${#ARCHIVES[@]} -eq 0 ]]; then + echo "No archives found — aborting release." >&2 + exit 1 + fi + + PRERELEASE="" + [[ "${GITHUB_REF_NAME}" != "main" ]] && PRERELEASE="--prerelease" + + # Create release if it doesn't exist yet; ignore "already exists" error + # (handles concurrent main/stable runs targeting the same semver tag). + gh release create "$TAG" \ + --title "Release $TAG ($STAMP)" \ + --generate-notes \ + $PRERELEASE 2>/dev/null || true + + # Upload/overwrite archives — idempotent + gh release upload "$TAG" "${ARCHIVES[@]}" --clobber - # Update Homebrew tap after release is published + # ── Update Homebrew tap (main / tag only) ───────────────────────────────── homebrew: name: Update Homebrew tap - needs: publish-release runs-on: ubuntu-latest + needs: [version, publish] + if: github.ref_name == 'main' || github.ref_type == 'tag' steps: - uses: actions/checkout@v4 @@ -83,4 +163,4 @@ jobs: token: ${{ secrets.EXEIN_TOOLS_SSH_PRIVATE_KEY }} tap: exein-io/tools formula: analyzer - tag: ${{ github.ref_name }} + tag: ${{ needs.version.outputs.semVer }} diff --git a/.github/workflows/versioning.yml b/.github/workflows/versioning.yml new file mode 100644 index 0000000..5f65ef7 --- /dev/null +++ b/.github/workflows/versioning.yml @@ -0,0 +1,53 @@ +name: Versioning + +on: + push: + branches: + - main + - stable + - develop + - feature/** + - release/** + - hotfix/** + pull_request: + branches: + - main + - stable + - develop + workflow_dispatch: + +permissions: + contents: read + +jobs: + version: + name: Calculate semantic version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Compute version + id: compute + run: | + BASE=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') + BUILD=$(git rev-list --count HEAD) + BRANCH="${GITHUB_REF_NAME}" + case "$BRANCH" in + main) SEMVER="${BASE}" ;; + stable) SEMVER="${BASE}-stable.${BUILD}" ;; + *) SEMVER="${BASE}-${BRANCH//\//-}.${BUILD}" ;; + esac + echo "majorMinorPatch=${BASE}" >> "$GITHUB_OUTPUT" + echo "buildMetaData=${BUILD}" >> "$GITHUB_OUTPUT" + echo "semVer=${SEMVER}" >> "$GITHUB_OUTPUT" + + - name: Publish summary + run: | + echo "## Version" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "- Branch: ${GITHUB_REF_NAME}" >> "$GITHUB_STEP_SUMMARY" + echo "- Base: ${{ steps.compute.outputs.majorMinorPatch }}" >> "$GITHUB_STEP_SUMMARY" + echo "- SemVer: ${{ steps.compute.outputs.semVer }}" >> "$GITHUB_STEP_SUMMARY" + echo "- Build meta: ${{ steps.compute.outputs.buildMetaData }}" >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 96ef6c0..15c51bb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,25 @@ -/target -Cargo.lock +/target/ +*.rs.bk + +# Environment and editor metadata +/.idea/ +/.vscode/ +/.copilot/ +.DS_Store +Thumbs.db + +# Coverage and profiling +/coverage/ +*.profraw +*.profdata + +# Logs and temporary files +*.log +*.tmp +*.temp + +# Release artifacts +/dist/releases/ +/dist/*.zip +/dist/*.tar.gz +!/dist/install.sh diff --git a/.intentionally-empty-file.o b/.intentionally-empty-file.o new file mode 100644 index 0000000..e69de29 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6a3a159 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,17 @@ +# Repository agents guide + +This repository hosts a Rust CLI for Exein Analyzer. + +## Default expectations + +- Prefer minimal, well-scoped changes. +- Keep `cargo fmt`, `cargo clippy --all-targets -- -D warnings`, and `cargo test --locked` green. +- Reuse existing command and output patterns instead of introducing parallel abstractions. +- Preserve API, command-line flags, and human-readable output unless the task explicitly changes them. + +## Repository-specific guidance + +- The crate is an application, so `Cargo.lock` must remain committed. +- Keep cross-platform behavior in mind: Linux, macOS, and Windows release assets are supported. +- Document any user-visible CLI change in `README.md`. +- When changing workflows, keep the GitFlow + GitVersion model aligned with `GitVersion.yml`. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..35ee4fa --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +--- + +## [Unreleased] + +### Added +- Cross-platform release container supporting Linux, macOS, and Windows for all architectures (x64, x86, arm64). + +--- + +## [0.2.0] - 2026-03-19 + +### Added +- Initial public release of the Analyzer CLI. +- Authentication with profile-aware configuration. +- Object management (create, list, delete). +- Scan management (new, status, score, overview, results, report, SBOM, compliance). +- Shell completions for bash, zsh, and fish. +- JSON output mode for automation and scripting. +- Multi-language CLI theme support. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..e8dc2aa --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,51 @@ +# 🌍 Code of Conduct + +## 🤝 Our pledge + +We are committed to making participation in this project a **harassment-free experience for everyone**, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +--- + +## ✅ Our standards + +**Examples of behavior that contributes to a positive environment:** + +- 💚 Demonstrating empathy and kindness toward other people +- 🗣️ Being respectful of differing opinions, viewpoints, and experiences +- 🔄 Giving and gracefully accepting constructive feedback +- 🙏 Taking responsibility and apologizing to those affected by our mistakes +- 🎯 Focusing on what is best for the overall community + +**Examples of unacceptable behavior:** + +- ❌ The use of sexualized language or imagery, and sexual attention or advances of any kind +- ❌ Trolling, insulting or derogatory comments, and personal or political attacks +- ❌ Public or private harassment +- ❌ Publishing others' private information without their explicit permission +- ❌ Other conduct which could reasonably be considered inappropriate in a professional setting + +--- + +## ⚖️ Enforcement responsibilities + +Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior they deem inappropriate, threatening, offensive, or harmful. + +--- + +## 🌐 Scope + +This Code of Conduct applies within all project spaces and also applies when an individual is officially representing the project in public spaces. + +--- + +## 📣 Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the maintainers through the private reporting channel documented in [SECURITY.md](SECURITY.md). All complaints will be reviewed and investigated promptly and fairly. + +--- + +## 📖 Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9c2e073 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,74 @@ +# 🤝 Contributing + +Thanks for contributing to `analyzer-cli`! Every bug report, feature suggestion, and pull request is appreciated. + +--- + +## 🛠️ Development environment + +1. Install Rust `1.85.0` or newer. +2. Clone the repository. +3. Run the validation suite before opening a pull request: + +```bash +cargo fmt --all -- --check +cargo clippy --all-targets -- -D warnings +cargo test --locked +``` + +--- + +## 🌿 Branching model + +This repository follows a **GitFlow-style** model: + +| Branch | Purpose | +|--------|---------| +| `main` | Production-ready history | +| `develop` | Integration branch | +| `feature/` | New functionality | +| `release/` | Release hardening | +| `hotfix/` | Urgent fixes from `main` | + +--- + +## 🏷️ Versioning + +Version calculation is handled by `GitVersion.yml`. + +- Use semantic versioning. +- Tag releases as `v..`. +- Prefer meaningful commits; `+semver:` hints are supported by GitVersion when needed. + +**`+semver:` commit hints:** + +| Hint | Effect | +|------|--------| +| `+semver: major` | Bumps major version | +| `+semver: feature` | Bumps minor version | +| `+semver: fix` | Bumps patch version | +| `+semver: none` | No version bump | + +--- + +## 📬 Pull requests + +- ✅ Keep changes focused and well-described. +- ✅ Update documentation for user-visible behavior changes. +- ✅ Add or adjust tests when behavior changes. +- ✅ Ensure CI passes before requesting review. + +--- + +## 🦀 Coding standards + +- Follow existing Rust patterns in `src/`. +- Avoid broad error swallowing; prefer explicit propagation. +- Preserve CLI compatibility unless the change explicitly requires a breaking change. +- Keep output human-friendly by default and scriptable with `--format json`. + +--- + +## 🔒 Security + +If you believe you have found a security issue, please follow [SECURITY.md](SECURITY.md) instead of opening a public issue. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..f4eb639 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2498 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "analyzer-cli" +version = "0.2.0" +dependencies = [ + "anyhow", + "assert_cmd", + "bytes", + "chrono", + "clap", + "clap_complete", + "console", + "dirs", + "futures", + "humantime", + "indicatif", + "owo-colors", + "predicates", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tokio", + "tokio-stream", + "tokio-util", + "toml", + "url", + "uuid", + "wiremock", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "assert_cmd" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a686bbee5efb88a82df0621b236e74d925f470e5445d3220a5648b892ec99c9" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_complete" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "deadpool" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb84100978c1c7b37f09ed3ce3e5f843af02c2a2c431bae5b19230dad2c1b490" +dependencies = [ + "async-trait", + "deadpool-runtime", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "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", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +dependencies = [ + "rustix", + "windows-sys 0.60.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen 0.46.0", +] + +[[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 = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +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", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[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", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wiremock" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "101681b74cd87b5899e87bcf5a64e83334dd313fcd3053ea72e6dba18928e301" +dependencies = [ + "assert-json-diff", + "async-trait", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +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 = "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", + "prettyplease", + "syn", + "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", + "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", + "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 = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index bee6d43..cb565f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,7 @@ clap_complete = "4" assert_cmd = "2" predicates = "3" tempfile = "3" -wiremock = "0.6" +wiremock = "=0.6.3" [profile.release] opt-level = "z" diff --git a/GitVersion.yml b/GitVersion.yml new file mode 100644 index 0000000..14bfd15 --- /dev/null +++ b/GitVersion.yml @@ -0,0 +1,19 @@ +next-version: 0.2.0 +workflow: GitFlow/v1 +tag-prefix: '[vV]' + +major-version-bump-message: '\+semver:\s?(breaking|major)' +minor-version-bump-message: '\+semver:\s?(feature|minor)' +patch-version-bump-message: '\+semver:\s?(fix|patch)' +no-bump-message: '\+semver:\s?(none|skip)' + +branches: + stable: + regex: ^stable$ + increment: Patch + source-branches: [main] + is-release-branch: true + + +ignore: + sha: [] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..51aabf6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,186 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the +copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other +entities that control, are controlled by, or are under common control with +that entity. For the purposes of this definition, "control" means (i) the +power, direct or indirect, to cause the direction or management of such +entity, whether by contract or otherwise, or (ii) ownership of fifty percent +(50%) or more of the outstanding shares, or (iii) beneficial ownership of +such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising +permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation source, and +configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object +code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, +made available under the License, as indicated by a copyright notice that is +included in or attached to the work (an example is provided in the Appendix +below). + +"Derivative Works" shall mean any work, whether in Source or Object form, +that is based on (or derived from) the Work and for which the editorial +revisions, annotations, elaborations, or other modifications represent, as a +whole, an original work of authorship. For the purposes of this License, +Derivative Works shall not include works that remain separable from, or +merely link (or bind by name) to the interfaces of, the Work and Derivative +Works thereof. + +"Contribution" shall mean any work of authorship, including the original +version of the Work and any modifications or additions to that Work or +Derivative Works thereof, that is intentionally submitted to Licensor for +inclusion in the Work by the copyright owner or by an individual or Legal +Entity authorized to submit on behalf of the copyright owner. For the +purposes of this definition, "submitted" means any form of electronic, verbal, +or written communication sent to the Licensor or its representatives, +including but not limited to communication on electronic mailing lists, source +code control systems, and issue tracking systems that are managed by, or on +behalf of, the Licensor for the purpose of discussing and improving the Work, +but excluding communication that is conspicuously marked or otherwise +designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this +License, each Contributor hereby grants to You a perpetual, worldwide, +non-exclusive, no-charge, royalty-free, irrevocable copyright license to +reproduce, prepare Derivative Works of, publicly display, publicly perform, +sublicense, and distribute the Work and such Derivative Works in Source or +Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this +License, each Contributor hereby grants to You a perpetual, worldwide, +non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this +section) patent license to make, have made, use, offer to sell, sell, import, +and otherwise transfer the Work, where such license applies only to those +patent claims licensable by such Contributor that are necessarily infringed by +their Contribution(s) alone or by combination of their Contribution(s) with +the Work to which such Contribution(s) was submitted. If You institute patent +litigation against any entity (including a cross-claim or counterclaim in a +lawsuit) alleging that the Work or a Contribution incorporated within the Work +constitutes direct or contributory patent infringement, then any patent +licenses granted to You under this License for that Work shall terminate as of +the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or +Derivative Works thereof in any medium, with or without modifications, and in +Source or Object form, provided that You meet the following conditions: + +(a) You must give any other recipients of the Work or Derivative Works a copy +of this License; and + +(b) You must cause any modified files to carry prominent notices stating that +You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works that You +distribute, all copyright, patent, trademark, and attribution notices from +the Source form of the Work, excluding those notices that do not pertain to +any part of the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its distribution, +then any Derivative Works that You distribute must include a readable copy of +the attribution notices contained within such NOTICE file, excluding those +notices that do not pertain to any part of the Derivative Works, in at least +one of the following places: within a NOTICE text file distributed as part of +the Derivative Works; within the Source form or documentation, if provided +along with the Derivative Works; or, within a display generated by the +Derivative Works, if and wherever such third-party notices normally appear. +The contents of the NOTICE file are for informational purposes only and do not +modify the License. You may add Your own attribution notices within Derivative +Works that You distribute, alongside or as an addendum to the NOTICE text from +the Work, provided that such additional attribution notices cannot be +construed as modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a +whole, provided Your use, reproduction, and distribution of the Work otherwise +complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any +Contribution intentionally submitted for inclusion in the Work by You to the +Licensor shall be under the terms and conditions of this License, without any +additional terms or conditions. Notwithstanding the above, nothing herein +shall supersede or modify the terms of any separate license agreement you may +have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, +trademarks, service marks, or product names of the Licensor, except as +required for reasonable and customary use in describing the origin of the Work +and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in +writing, Licensor provides the Work (and each Contributor provides its +Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied, including, without limitation, any warranties +or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +PARTICULAR PURPOSE. You are solely responsible for determining the +appropriateness of using or redistributing the Work and assume any risks +associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in +tort (including negligence), contract, or otherwise, unless required by +applicable law (such as deliberate and grossly negligent acts) or agreed to in +writing, shall any Contributor be liable to You for damages, including any +direct, indirect, special, incidental, or consequential damages of any +character arising as a result of this License or out of the use or inability +to use the Work (including but not limited to damages for loss of goodwill, +work stoppage, computer failure or malfunction, or any and all other +commercial damages or losses), even if such Contributor has been advised of +the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work +or Derivative Works thereof, You may choose to offer, and charge a fee for, +acceptance of support, warranty, indemnity, or other liability obligations +and/or rights consistent with this License. However, in accepting such +obligations, You may act only on Your own behalf and on Your sole +responsibility, not on behalf of any other Contributor, and only if You agree +to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets "[]" replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same "printed page" as the copyright notice for easier identification +within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md index 963e8ed..8a64757 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,51 @@ -# Analyzer CLI +# 🔍 Analyzer CLI -A command-line interface for [Exein Analyzer](https://analyzer.exein.io) -- firmware & container security scanning. +
-Scan firmware images for CVEs, generate SBOMs, check compliance, browse analysis results, and more. All from your terminal. +[![CI](https://github.com/exein-io/analyzer-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/exein-io/analyzer-cli/actions/workflows/ci.yml) [![Release](https://github.com/exein-io/analyzer-cli/actions/workflows/release.yml/badge.svg)](https://github.com/exein-io/analyzer-cli/actions/workflows/release.yml) [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) [![Crates.io](https://img.shields.io/crates/v/analyzer-cli.svg)](https://crates.io/crates/analyzer-cli) [![MSRV](https://img.shields.io/badge/MSRV-1.85.0-orange?logo=rust)](rust-toolchain.toml) [![Made with Rust](https://img.shields.io/badge/Made%20with-Rust-CE422B?logo=rust)](https://www.rust-lang.org) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) [![GitHub last commit](https://img.shields.io/github/last-commit/exein-io/analyzer-cli)](https://github.com/exein-io/analyzer-cli/commits/main) [![GitHub issues](https://img.shields.io/github/issues/exein-io/analyzer-cli)](https://github.com/exein-io/analyzer-cli/issues) [![Platform: Linux](https://img.shields.io/badge/platform-Linux-lightgrey?logo=linux)](https://github.com/exein-io/analyzer-cli/releases) [![Platform: macOS](https://img.shields.io/badge/platform-macOS-lightgrey?logo=apple)](https://github.com/exein-io/analyzer-cli/releases) [![Platform: Windows](https://img.shields.io/badge/platform-Windows-lightgrey?logo=windows)](https://github.com/exein-io/analyzer-cli/releases) [![Security Audit](https://img.shields.io/badge/security-audit-green?logo=dependabot)](SECURITY.md) -## Install +
-### Homebrew (macOS & Linux) +> **`analyzer`** is the official command-line interface for [Exein Analyzer](https://analyzer.exein.io) — a platform for firmware and container security analysis. + +Authenticate, upload firmware or container images, run security analyses, inspect findings, generate compliance reports, and download SBOMs — all directly from the terminal. + +--- + +## ✨ Features + +| Feature | Description | +|---------|-------------| +| 🔐 **Authentication** | Profile-aware login against any Exein Analyzer instance | +| 📦 **Object management** | Create and manage logical device/product groupings | +| 🚀 **Scan launch** | Upload and trigger firmware and container image scans | +| 🔎 **Results inspection** | Browse CVEs, malware, hardening, compliance, SBOMs | +| 📄 **Reports & artifacts** | Export PDF reports, CycloneDX SBOMs, compliance reports | +| 🤖 **Automation-ready** | JSON output mode for scripting and CI/CD pipelines | + +--- + +## 📥 Installation + +### 🍺 Homebrew ```bash brew install exein-io/tools/analyzer ``` -### Shell installer +### ⚡ Shell installer ```bash curl -fsSL https://raw.githubusercontent.com/exein-io/analyzer-cli/main/dist/install.sh | bash ``` -### Cargo +### 📦 Cargo ```bash cargo install analyzer-cli ``` -### From source +### 🛠️ From source ```bash git clone https://github.com/exein-io/analyzer-cli.git @@ -32,247 +53,243 @@ cd analyzer-cli cargo install --path . ``` -## Quick start +--- + +## 🚀 Quick start ```bash # 1. Authenticate analyzer login -# Enter your API key: ******** -# OK Saved to ~/.config/analyzer/config.toml -# 2. Create an object (device / product) +# 2. Create an object analyzer object new "my-router" -# OK Created object 'my-router' (a1b2c3d4-...) # 3. Upload and scan a firmware image analyzer scan new \ - --object a1b2c3d4-... \ + --object a1b2c3d4-0000-0000-0000-000000000000 \ --file firmware.bin \ --type linux \ --analysis info cve software-bom malware \ --wait -# Uploading firmware.bin [=====================>] 100% (42 MB) -# OK Scan completed successfully! - -# 4. View the scan overview -analyzer scan overview --object a1b2c3d4-... -# Shows a summary of all analyses with finding counts by severity - -# 5. Browse CVE results -analyzer scan results --object a1b2c3d4-... --analysis cve -# Paginated table of CVEs with severity, score, and affected package - -# 6. Check CRA compliance -analyzer scan compliance --type cra --object a1b2c3d4-... -# Shows pass/fail status for each CRA requirement -# 7. Download the report -analyzer scan report --object a1b2c3d4-... -O report.pdf -# OK Report saved to report.pdf +# 4. Inspect the overview +analyzer scan overview --object a1b2c3d4-0000-0000-0000-000000000000 -# 8. Download the SBOM -analyzer scan sbom --object a1b2c3d4-... -O sbom.json -# OK SBOM saved to sbom.json +# 5. Download the SBOM +analyzer scan sbom --object a1b2c3d4-0000-0000-0000-000000000000 -O sbom.json ``` -## Usage +--- -### Authentication +## 📖 Common usage + +### 🔐 Authentication ```bash -# Interactive login (prompts for API key, validates, saves) analyzer login - -# Use a specific server URL analyzer login --url https://my-analyzer.example.com/api/ - -# Login to a named profile analyzer login --profile staging - -# Check your current identity analyzer whoami ``` -### Objects +### 📦 Objects ```bash -# List all objects analyzer object list - -# Create a new object analyzer object new "my-device" --description "Router firmware" --tags iot,router - -# Delete an object analyzer object delete ``` -### Scans - -#### Creating and managing scans +### 🔬 Scans ```bash -# Create a scan (returns immediately) analyzer scan new -o -f firmware.bin -t linux -a info cve software-bom - -# Create a scan and wait for completion analyzer scan new -o -f image.tar -t docker -a info cve malware --wait - -# Check scan status analyzer scan status --scan - -# View the security score analyzer scan score --scan +analyzer scan results --scan --analysis cve +analyzer scan compliance --type cra --scan +analyzer scan report --scan -O report.pdf --wait +analyzer scan sbom --scan -O sbom.json +``` -# List available scan types and analyses -analyzer scan types +> 💡 All commands that accept `--scan ` also accept `--object `, resolving the latest scan for the object automatically. -# Cancel a running scan -analyzer scan cancel +### 📊 Output formats -# Delete a scan -analyzer scan delete +```bash +analyzer object list +analyzer object list --format json +analyzer scan overview --object --format json | jq '.analyses' ``` -#### Browsing analysis results +### 🐚 Shell completions ```bash -# View scan overview (summary of all analyses with finding counts) -analyzer scan overview --scan +analyzer completions bash > /etc/bash_completion.d/analyzer +analyzer completions zsh > ~/.zfunc/_analyzer +analyzer completions fish > ~/.config/fish/completions/analyzer.fish +``` -# Browse results for a specific analysis type -analyzer scan results --scan --analysis cve -analyzer scan results --scan --analysis malware -analyzer scan results --scan --analysis hardening +--- -# Paginate through results -analyzer scan results --scan --analysis cve --page 2 --per-page 50 +## ⚙️ Configuration -# Search / filter results -analyzer scan results --scan --analysis cve --search "openssl" +Configuration is stored at `~/.config/analyzer/config.toml`. -# View compliance check results -analyzer scan compliance --type cra --scan +```toml +default_profile = "default" + +[profiles.default] +api_key = "your-api-key" +url = "https://analyzer.exein.io/api/" ``` -Supported `--analysis` types: `cve`, `password-hash`, `malware`, `hardening`, `capabilities`, `crypto`, `software-bom`, `kernel`, `info`, `symbols`, `tasks`, `stack-overflow`. +Settings are resolved in this order: -Supported `--type` compliance standards: `cra` (Cyber Resilience Act). +1. 🚩 CLI flags +2. 🌍 Environment variables +3. 📄 Config file +4. 🔧 Built-in defaults -#### Downloading reports and artifacts +**Environment variables:** -```bash -# Download PDF report (waits for completion) -analyzer scan report --scan -O report.pdf --wait +| Variable | Description | +|----------|-------------| +| `ANALYZER_API_KEY` | API key for authentication | +| `ANALYZER_URL` | Base API URL | +| `ANALYZER_PROFILE` | Active profile name | +| `ANALYZER_LANG` | Language code (`en`, `fr`, `de`, `nl`, `es`, `pt`, `zh`, `ko`, `ar`, `ja`) | +| `ANALYZER_CONFIG_DIR` | Override the configuration directory | -# Download SBOM -analyzer scan sbom --scan -O sbom.json +Use `--lang ` to switch the human-oriented CLI theme and messages at runtime. English is the default; JSON output stays stable for automation regardless of language. -# Download compliance report -analyzer scan compliance-report --type cra --scan -O cra.pdf --wait -``` +--- + +## 🔬 Supported scan types + +| Type | Analyses | +|------|----------| +| `linux` | `info`, `kernel`, `cve`, `password-hash`, `crypto`, `software-bom`, `malware`, `hardening`, `capabilities` | +| `docker` | `info`, `cve`, `password-hash`, `crypto`, `software-bom`, `malware`, `hardening`, `capabilities` | +| `idf` | `info`, `cve`, `software-bom`, `symbols`, `tasks`, `stack-overflow` | + +**Supported compliance standards:** + +| Standard | CLI value | +|----------|-----------| +| 🇪🇺 Cyber Resilience Act | `cra` | -### Using `--object` instead of `--scan` +--- -All scan commands that accept `--scan ` also accept `--object `. When `--object` is used, the CLI automatically resolves the object's most recent scan and uses that. +## 🛠️ Development -This simplifies the common workflow: instead of finding a scan ID from the object, you can go straight from object to results. +### Prerequisites + +- 🦀 Rust `1.85.0` or newer +- `rustfmt` +- `clippy` + +### Bootstrap scripts + +Repository bootstrap scripts are available under `scripts/`: + +| Platform | Script | +|----------|--------| +| 🪟 Windows | `scripts/windows/environment/setup-dev.ps1` | +| 🍎 macOS | `scripts/macos/environment/setup-dev` | +| 🐧 Linux | `scripts/linux/environment/setup-dev` | + +Command scripts (`build`, `release`, `test`) are under `scripts//commands/`. + +**Examples:** ```bash -# These are equivalent (assuming the object's last scan is e5f6g7h8-...) -analyzer scan overview --scan e5f6g7h8-... -analyzer scan overview --object a1b2c3d4-... - -# Works with all scan commands -analyzer scan status --object -analyzer scan score --object -analyzer scan overview --object -analyzer scan results --object --analysis cve -analyzer scan compliance --type cra --object -analyzer scan report --object -O report.pdf -analyzer scan sbom --object -O sbom.json -analyzer scan compliance-report --type cra --object -O cra.pdf +pwsh -File .\scripts\windows\environment\setup-dev.ps1 --help +pwsh -File .\scripts\windows\commands\test.ps1 +zsh ./scripts/macos/environment/setup-dev --help +bash ./scripts/linux/commands/build ``` -Short flags are also available: `-s` for `--scan`, `-o` for `--object`. +**Release packaging conventions:** -### Configuration +- Archive names follow `analyzer-cli___-.zip` +- Artifacts are written under `release/` +- Use `ANALYZER_RELEASE_DATE` or `--release-date` / `-ReleaseDate` to override the date stamp +- `ANALYZER_RELEASE_BUILD` / `--build-number` remain accepted for compatibility but are ignored by the current naming convention -```bash -# Show all config -analyzer config show +### 🐳 Containers and devcontainer -# Set a value -analyzer config set url https://my-instance.example.com/api/ -analyzer config set api-key -analyzer config set default-profile staging +Repository-owned container definitions live under `containers/`: -# Get a value -analyzer config get url -``` +| Directory | Purpose | +|-----------|---------| +| `containers/build` | Build environment | +| `containers/release` | Release packaging | +| `containers/test` | Test runner | +| `containers/devcontainer` | VS Code Dev Container | -### Output formats +VS Code Dev Containers use `.devcontainer/devcontainer.json`, wired to `containers/devcontainer/Dockerfile`. -Every command supports `--format`: +### 🧪 Local workflow ```bash -# Human-readable (default) -- colored, tables -analyzer object list +cargo fmt --all +cargo clippy --all-targets -- -D warnings +cargo test --locked +``` -# JSON -- for scripting and piping -analyzer object list --format json +> `Cargo.lock` is committed because this repository ships an application, not a reusable library. -# Pipe into jq -analyzer scan status --scan --format json | jq '.status' -analyzer scan overview --object --format json | jq '.analyses' -analyzer scan results --object --analysis cve --format json | jq '.findings' -``` +--- -### Shell completions +## 🏷️ Release and versioning -```bash -# Bash -analyzer completions bash > /etc/bash_completion.d/analyzer +This repository follows a **GitFlow-style** branching strategy: -# Zsh -analyzer completions zsh > ~/.zfunc/_analyzer +| Branch | Purpose | +|--------|---------| +| `main` | Production-ready history | +| `develop` | Integration branch | +| `feature/*` | New functionality | +| `release/*` | Release hardening | +| `hotfix/*` | Urgent production fixes | -# Fish -analyzer completions fish > ~/.config/fish/completions/analyzer.fish -``` +Semantic versions are calculated with `GitVersion.yml`. Tagged releases use the `v*` convention and publish platform binaries through GitHub Actions. -## Configuration +--- -The CLI stores configuration at `~/.config/analyzer/config.toml`: +## 🤖 GitHub Copilot and MCP -```toml -default_profile = "default" +The repository includes a curated `.github` setup with: -[profiles.default] -api_key = "your-api-key" -url = "https://analyzer.exein.io/api/" +- 📋 Repository and path-specific Copilot instructions +- 🔄 Reusable prompts +- 🤖 Custom agents for Rust maintenance and releases +- 🪝 Example hooks +- 🔌 An `awesome-copilot` MCP server configuration -[profiles.staging] -api_key = "staging-key" -url = "https://staging.analyzer.exein.io/api/" -``` +--- -### Precedence +## 🤝 Contributing -Settings are resolved in this order (highest priority first): +Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) to get started. -1. CLI flags (`--api-key`, `--url`, `--profile`) -2. Environment variables (`ANALYZER_API_KEY`, `ANALYZER_URL`, `ANALYZER_PROFILE`) -3. Config file (`~/.config/analyzer/config.toml`) -4. Defaults (URL: `https://analyzer.exein.io/api/`) +## 🔒 Security -## Supported scan types +Found a vulnerability? Please follow the responsible disclosure process in [SECURITY.md](SECURITY.md). -| Type | Analyses | -|------|----------| -| `linux` | info, kernel, cve, password-hash, crypto, software-bom, malware, hardening, capabilities | -| `docker` | info, cve, password-hash, crypto, software-bom, malware, hardening, capabilities | -| `idf` | info, cve, software-bom, symbols, tasks, stack-overflow | +## 📜 License + +This project is licensed under the [Apache 2.0 License](LICENSE). + +See `.github/mcp/README.md` and `.github/copilot-instructions.md` for details. + +## Contributing + +Please read [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md), and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) before opening pull requests. ## License -Apache-2.0 +Licensed under the [Apache License 2.0](LICENSE). + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e570097 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,33 @@ +# 🔒 Security Policy + +## 🚨 Reporting a vulnerability + +Please **do not** report security vulnerabilities through public GitHub issues. + +Instead, report them privately to the project maintainers by opening a **private security advisory** in GitHub (Security → Advisories → New draft security advisory). + +When reporting an issue, include: + +| Field | Description | +|-------|-------------| +| 📝 Description | A clear description of the vulnerability | +| 🌿 Affected versions | Affected versions or branches | +| 🔁 Reproduction | Steps to reproduce or proof of concept | +| 💥 Impact | Potential impact of the issue | +| 🩹 Remediation | Any suggested fix or mitigation | + +--- + +## 🛡️ Supported versions + +Security fixes are applied to the latest maintained release branch and, when appropriate, backported to supported hotfix branches. + +--- + +## ⏱️ Response expectations + +We aim to: + +- ✅ Acknowledge reports promptly +- ✅ Validate impact and severity +- ✅ Communicate remediation status as soon as practical diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..d48e6e0 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,18 @@ +# 💬 Support + +## 🆘 Getting help + +| Channel | Use for | +|---------|---------| +| [GitHub Issues](https://github.com/exein-io/analyzer-cli/issues) | 🐛 Bugs and 💡 feature requests | +| [Pull Requests](https://github.com/exein-io/analyzer-cli/pulls) | Concrete changes ready for review | +| [SECURITY.md](SECURITY.md) | 🔒 Security-sensitive topics | + +--- + +## ✅ Before opening an issue + +- Confirm you are using a **current release**. +- Include the exact command you ran and the relevant CLI output. +- If possible, rerun with `--format json` and attach sanitized output. +- Check existing issues to avoid duplicates. diff --git a/containers/build/Dockerfile b/containers/build/Dockerfile new file mode 100644 index 0000000..f753aeb --- /dev/null +++ b/containers/build/Dockerfile @@ -0,0 +1,16 @@ +FROM rust:1.85-bookworm + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + git \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +COPY containers/build/run-build.sh /usr/local/bin/run-build.sh +RUN chmod +x /usr/local/bin/run-build.sh + +WORKDIR /workspace +ENTRYPOINT ["/usr/local/bin/run-build.sh"] diff --git a/containers/build/run-build.sh b/containers/build/run-build.sh new file mode 100644 index 0000000..fe32f2f --- /dev/null +++ b/containers/build/run-build.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "🚀 Running containerized debug build..." +cargo build --locked +echo "✅ Debug build completed." diff --git a/containers/devcontainer/Dockerfile b/containers/devcontainer/Dockerfile new file mode 100644 index 0000000..724e23f --- /dev/null +++ b/containers/devcontainer/Dockerfile @@ -0,0 +1,50 @@ +FROM mcr.microsoft.com/devcontainers/rust:1-1-bookworm + +USER root + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + apt-transport-https \ + bash \ + ca-certificates \ + curl \ + docker.io \ + git-flow \ + gnupg \ + jq \ + lsb-release \ + podman \ + software-properties-common \ + unzip \ + wget \ + zsh \ + && wget -q https://packages.microsoft.com/config/debian/12/packages-microsoft-prod.deb \ + && dpkg -i packages-microsoft-prod.deb \ + && rm packages-microsoft-prod.deb \ + && apt-get update \ + && apt-get install -y --no-install-recommends powershell \ + && rm -rf /var/lib/apt/lists/* + +# Install Node.js LTS (required for MCP servers via npx). +RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +# Install oh-my-posh and make themes available to all users. +RUN curl -fsSL https://ohmyposh.dev/install.sh | bash -s -- -d /usr/local/bin \ + && OMP_THEMES_SRC="$(oh-my-posh --print-install-dir 2>/dev/null || true)" \ + && mkdir -p /usr/local/share/oh-my-posh \ + && if [ -d "/root/.cache/oh-my-posh/themes" ]; then \ + cp -r /root/.cache/oh-my-posh/themes /usr/local/share/oh-my-posh/; \ + else \ + mkdir -p /usr/local/share/oh-my-posh/themes; \ + fi \ + && mkdir -p /home/vscode/.cache/oh-my-posh \ + && ln -sfn /usr/local/share/oh-my-posh/themes /home/vscode/.cache/oh-my-posh/themes \ + && chown -R vscode:vscode /home/vscode/.cache + +COPY containers/devcontainer/post-create.sh /usr/local/share/analyzer-devcontainer/post-create.sh +RUN chmod +x /usr/local/share/analyzer-devcontainer/post-create.sh + +USER vscode +WORKDIR /workspace diff --git a/containers/devcontainer/post-create.sh b/containers/devcontainer/post-create.sh new file mode 100644 index 0000000..5c1682a --- /dev/null +++ b/containers/devcontainer/post-create.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "🚀 Finalizing devcontainer setup..." +rustup component add rustfmt clippy +git config --global --add safe.directory /workspace +cargo fetch --locked + +# --------------------------------------------------------------------------- +# Container-engine detection +# --------------------------------------------------------------------------- +# Determine which container CLI (docker or podman) can communicate with the +# socket that was mounted at /var/run/docker.sock. Both Docker and Podman +# expose a Docker-compatible REST API, so the docker CLI works with either +# daemon. We prefer docker if it is functional, then fall back to podman. +# +# The result is a thin wrapper /usr/local/bin/container-engine that forwards +# all arguments to the detected engine, plus a /usr/local/bin/docker symlink +# when docker itself is not the active engine (so tooling that hard-codes +# "docker" still works through the same backend). +# --------------------------------------------------------------------------- + +WRAPPER=/usr/local/bin/container-engine +ACTIVE_ENGINE="" + +_engine_ok() { + "$1" info --format '{{.ServerVersion}}' >/dev/null 2>&1 +} + +if command -v docker >/dev/null 2>&1 && _engine_ok docker; then + ACTIVE_ENGINE=docker +elif command -v podman >/dev/null 2>&1 && _engine_ok podman; then + ACTIVE_ENGINE=podman +fi + +if [ -n "$ACTIVE_ENGINE" ]; then + echo "🐳 Container engine detected: $ACTIVE_ENGINE" + + # Create the container-engine wrapper. The postCreate step runs as the + # remoteUser (vscode), which cannot write to /usr/local/bin directly. + # The vscode user in this base image has passwordless sudo. + sudo tee "$WRAPPER" >/dev/null </dev/null 2>&1; then + # docker binary exists but couldn't reach a daemon; replace it with a + # wrapper that forwards to podman. + sudo tee /usr/local/bin/docker >/dev/null <<'EOF' +#!/usr/bin/env bash +exec podman "$@" +EOF + sudo chmod +x /usr/local/bin/docker + echo " ↳ /usr/local/bin/docker shimmed → podman" + fi +else + echo "⚠️ No reachable container engine found." + echo " Make sure the host Docker or Podman socket is mounted." + echo " Podman users: export CONTAINER_SOCKET_PATH= before" + echo " opening the devcontainer (see .devcontainer/devcontainer.json)." +fi + +echo "✅ Devcontainer is ready." diff --git a/containers/release/Dockerfile b/containers/release/Dockerfile new file mode 100644 index 0000000..8a303a4 --- /dev/null +++ b/containers/release/Dockerfile @@ -0,0 +1,65 @@ +FROM rust:1.85-bookworm + +# ── System tools & cross-compilation prerequisites ──────────────────────────── +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + build-essential \ + ca-certificates \ + cmake \ + curl \ + git \ + libc6-dev-i386 \ + mingw-w64 \ + pkg-config \ + python3 \ + xz-utils \ + zip \ + && rm -rf /var/lib/apt/lists/* + +# ── Zig (universal cross-linker used by cargo-zigbuild) ─────────────────────── +ARG ZIG_VERSION=0.13.0 +RUN ARCH="$(uname -m)" \ + && URL="https://ziglang.org/download/${ZIG_VERSION}/zig-linux-${ARCH}-${ZIG_VERSION}.tar.xz" \ + && curl -fsSL "$URL" -o /tmp/zig.tar.xz \ + && tar -xf /tmp/zig.tar.xz -C /usr/local \ + && ln -s "/usr/local/zig-linux-${ARCH}-${ZIG_VERSION}/zig" /usr/local/bin/zig \ + && rm /tmp/zig.tar.xz \ + && zig version + +# ── cargo-zigbuild ──────────────────────────────────────────────────────────── +RUN cargo install cargo-zigbuild --locked --version "^0.19" + +# ── macOS SDK (CoreFoundation and other Apple frameworks) ───────────────────── +# Provides the framework stubs needed when cross-compiling for Darwin targets. +# SDK redistribution sourced from https://github.com/phracker/MacOSX-SDKs +RUN curl -fsSL \ + "https://github.com/phracker/MacOSX-SDKs/releases/download/11.3/MacOSX11.3.sdk.tar.xz" \ + -o /tmp/macos-sdk.tar.xz \ + && mkdir -p /opt/sdks \ + && tar -xf /tmp/macos-sdk.tar.xz -C /opt/sdks \ + && rm /tmp/macos-sdk.tar.xz +ENV SDKROOT=/opt/sdks/MacOSX11.3.sdk + +# ── Rust cross-compilation targets ─────────────────────────────────────────── +# Linux +RUN rustup target add \ + x86_64-unknown-linux-gnu \ + i686-unknown-linux-gnu \ + aarch64-unknown-linux-gnu +# Windows (GNU ABI — supported by Zig linker without MinGW install) +RUN rustup target add \ + x86_64-pc-windows-gnu \ + i686-pc-windows-gnu \ + aarch64-pc-windows-gnullvm +# macOS (Zig provides Darwin libc/runtime stubs) +RUN rustup target add \ + x86_64-apple-darwin \ + aarch64-apple-darwin + +# ── Entrypoint ──────────────────────────────────────────────────────────────── +COPY containers/release/run-release.sh /usr/local/bin/run-release.sh +RUN chmod +x /usr/local/bin/run-release.sh + +WORKDIR /workspace +ENTRYPOINT ["/bin/bash", "/usr/local/bin/run-release.sh"] diff --git a/containers/release/run-release.sh b/containers/release/run-release.sh new file mode 100644 index 0000000..99e1614 --- /dev/null +++ b/containers/release/run-release.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# run-release.sh — cross-compile all targets and package release archives. +# Runs inside the release container. Invoked by host-side release scripts. +# +# Required environment variables: +# ANALYZER_RELEASE_VERSION — semver version string (e.g. "0.2.0") +# ANALYZER_RELEASE_STAMP — date stamp (e.g. "20260319") +# +# Optional environment variables: +# ANALYZER_RELEASE_BUILD — build number, appended to version (default: 0) +# ANALYZER_RELEASE_WORKSPACE — repo root inside container (default: $(pwd)) +set -euo pipefail + +# ── Configuration ───────────────────────────────────────────────────────────── +VERSION="${ANALYZER_RELEASE_VERSION:?ANALYZER_RELEASE_VERSION is required}" +BUILD="${ANALYZER_RELEASE_BUILD:-0}" +STAMP="${ANALYZER_RELEASE_STAMP:?ANALYZER_RELEASE_STAMP is required}" +REPO_ROOT="${ANALYZER_RELEASE_WORKSPACE:-$(pwd)}" + +FULL_VERSION="${VERSION}.${BUILD}" +RELEASE_SUBDIR="release_${FULL_VERSION}_${STAMP}" +ARTIFACT_DIR="${REPO_ROOT}/dist/releases/${RELEASE_SUBDIR}" + +# ── Helpers ─────────────────────────────────────────────────────────────────── +log() { local c="$1"; shift; printf "\033[${c}m%s\033[0m\n" "$*"; } +info() { log 36 "ℹ️ $*"; } +ok() { log 32 "✅ $*"; } +warn() { log 33 "⚠️ $*"; } +err() { log 31 "❌ $*" >&2; } + +# ── Cross-compilation target matrix ─────────────────────────────────────────── +# Format: "os|arch|rust_target|binary_name" +TARGETS=( + "linux|x64|x86_64-unknown-linux-gnu|analyzer" + "linux|x86|i686-unknown-linux-gnu|analyzer" + "linux|arm64|aarch64-unknown-linux-gnu|analyzer" + "windows|x64|x86_64-pc-windows-gnu|analyzer.exe" + "windows|x86|i686-pc-windows-gnu|analyzer.exe" + "windows|arm64|aarch64-pc-windows-gnullvm|analyzer.exe" + "macos|x64|x86_64-apple-darwin|analyzer" + "macos|arm64|aarch64-apple-darwin|analyzer" +) + +# ── Setup ───────────────────────────────────────────────────────────────────── +info "Release: ${FULL_VERSION}" +info "Date: ${STAMP}" +info "Output: ${ARTIFACT_DIR}" +info "Targets: ${#TARGETS[@]}" +echo "" + +mkdir -p "$ARTIFACT_DIR" + +built=0 +failed=0 + +# ── Build and package each target ───────────────────────────────────────────── +for entry in "${TARGETS[@]}"; do + IFS='|' read -r os arch target binary <<< "$entry" + archive_name="exein_analyzer_cli_${FULL_VERSION}_${STAMP}_${os}_${arch}.zip" + archive_path="${ARTIFACT_DIR}/${archive_name}" + + info "Building ${os}/${arch} (${target})" + + rustup target add "$target" >/dev/null 2>&1 || true + + # i686-pc-windows-gnu: cargo-zigbuild's lld-link wrapper does not provide + # the libgcc_eh symbols (___register_frame_info) referenced by rsbegin.o. + # Use plain cargo build with the real MinGW GCC linker instead. + if [[ "$target" == "i686-pc-windows-gnu" ]]; then + if ! CARGO_TARGET_I686_PC_WINDOWS_GNU_LINKER=i686-w64-mingw32-gcc \ + cargo build --locked --release --target "$target" 2>&1; then + warn "Build failed for ${target} — skipping." + (( failed++ )) || true + continue + fi + elif ! cargo zigbuild --locked --release --target "$target" 2>&1; then + warn "Build failed for ${target} — skipping." + (( failed++ )) || true + continue + fi + + # ── Stage archive contents ────────────────────────────────────────────────── + staging="$(mktemp -d)" + + cp "${REPO_ROOT}/target/${target}/release/${binary}" "${staging}/${binary}" + cp "${REPO_ROOT}/LICENSE" "${staging}/LICENSE" + [[ -f "${REPO_ROOT}/CHANGELOG.md" ]] \ + && cp "${REPO_ROOT}/CHANGELOG.md" "${staging}/CHANGELOG.md" + + rm -f "$archive_path" + (cd "$staging" && zip -q -r "$archive_path" .) + rm -rf "$staging" + + ok "${archive_name}" + (( built++ )) || true +done + +# ── Summary ─────────────────────────────────────────────────────────────────── +echo "" +if [[ $built -eq 0 ]]; then + err "No targets built successfully." + exit 1 +fi + +ok "Release complete — ${built} archive(s) in dist/releases/${RELEASE_SUBDIR}" +[[ $failed -gt 0 ]] && warn "${failed} target(s) failed." +exit 0 diff --git a/containers/test/Dockerfile b/containers/test/Dockerfile new file mode 100644 index 0000000..389e9ee --- /dev/null +++ b/containers/test/Dockerfile @@ -0,0 +1,17 @@ +FROM rust:1.85-bookworm + +RUN rustup component add clippy rustfmt + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + git \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +COPY containers/test/run-tests.sh /usr/local/bin/run-tests.sh +RUN chmod +x /usr/local/bin/run-tests.sh + +WORKDIR /workspace +ENTRYPOINT ["/usr/local/bin/run-tests.sh"] diff --git a/containers/test/run-tests.sh b/containers/test/run-tests.sh new file mode 100644 index 0000000..e7509ae --- /dev/null +++ b/containers/test/run-tests.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "🧪 Running containerized quality gates..." +cargo fmt --all -- --check +cargo clippy --all-targets -- -D warnings +cargo test --locked +echo "✅ Containerized test suite completed." diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..7fe1133 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,605 @@ +# 📡 Analyzer CLI — API Reference + +This document describes every HTTP endpoint invoked by the **Analyzer CLI** (`analyzer`), +together with the HTTP method, path, request/response shapes, authentication, and the CLI +command that triggers each call. + +--- + +## 🌐 Base URL + +The default base URL is: + +``` +https://analyzer.exein.io/api/ +``` + +It can be overridden through (highest → lowest precedence): + +| Priority | Source | Example | +|----------|--------|---------| +| 1st | 🚩 CLI flag | `--url https://my-instance/api/` | +| 2nd | 🌍 Environment variable | `ANALYZER_URL=https://my-instance/api/` | +| 3rd | 📄 Config file profile | `url = "https://my-instance/api/"` in `~/.config/analyzer/config.toml` | +| 4th | 🔧 Compiled default | `https://analyzer.exein.io/api/` | + +--- + +## 🔐 Authentication + +All endpoints (except the health check) require a **Bearer token** sent in the +`Authorization` header: + +``` +Authorization: Bearer +``` + +The API key is resolved in the following order: + +| Priority | Source | How to set | +|----------|--------|------------| +| 1st | 🚩 CLI flag | `--api-key ` | +| 2nd | 🌍 Environment variable | `export ANALYZER_API_KEY=` | +| 3rd | 📄 Config file profile | `api_key = ""` in `~/.config/analyzer/config.toml` | + +The user-agent header is set to `analyzer-cli/` on every request. + +--- + +## 📋 Endpoints + +### 🏥 Health + +#### `GET /health` + +Verify that the server is reachable and healthy. Used by `analyzer login` to validate a +newly entered API key. + +| | | +|---|---| +| **Method** | `GET` | +| **Auth required** | No (but the bearer token is still sent if present) | +| **CLI command** | `analyzer login` | + +**Response — 200 OK** + +```json +{ "healthy": true } +``` + +--- + +### 📦 Objects + +Objects represent logical entities (devices, products, firmware families) that group one or +more scans together. + +--- + +#### `GET /objects/` + +Retrieve a paginated list of all objects. + +| | | +|---|---| +| **Method** | `GET` | +| **Auth required** | Yes | +| **CLI command** | `analyzer object list` | + +**Response — 200 OK** + +```json +{ + "data": [ + { + "id": "uuid", + "name": "My Router", + "description": "Optional description", + "favorite": false, + "tags": ["production"], + "created_on": "2024-01-01T00:00:00Z", + "updated_on": "2024-06-01T00:00:00Z", + "score": { + "current": { "scan_id": "uuid", "created_on": "...", "value": 82 }, + "previous": null + }, + "last_scan": { + "status": { "id": "uuid", "status": "success" }, + "score": { "score": 82, "scores": [] } + } + } + ], + "_links": { "next": null } +} +``` + +--- + +#### `GET /objects/{id}` + +Retrieve a single object by its UUID. + +| | | +|---|---| +| **Method** | `GET` | +| **Auth required** | Yes | +| **Path parameter** | `id` — UUID of the object | +| **CLI command** | Internally used by `--object` flag resolution on scan commands | + +**Response — 200 OK** — single `Object` (same shape as items in list above). + +--- + +#### `POST /objects/` + +Create a new object. + +| | | +|---|---| +| **Method** | `POST` | +| **Auth required** | Yes | +| **Content-Type** | `application/json` | +| **CLI command** | `analyzer object new [--description ] [--tags ...]` | + +**Request body** + +```json +{ + "name": "My Router", + "description": "Optional description", + "tags": ["production", "iot"] +} +``` + +| Field | Type | Required | Notes | +|---|---|---|---| +| `name` | string | Yes | Human-readable label | +| `description` | string | No | Omitted from body when not set | +| `tags` | string[] | No | Defaults to empty array | + +**Response — 201 Created** — the newly created `Object`. + +--- + +#### `DELETE /objects/{id}` + +Delete an object and all its associated data. + +| | | +|---|---| +| **Method** | `DELETE` | +| **Auth required** | Yes | +| **Path parameter** | `id` — UUID of the object | +| **CLI command** | `analyzer object delete ` | + +**Response — 204 No Content** + +--- + +### 🔬 Scans + +A scan represents the analysis of a firmware or container image file. + +--- + +#### `GET /scans/types` + +List all supported image types and their corresponding analysis options. + +| | | +|---|---| +| **Method** | `GET` | +| **Auth required** | Yes | +| **CLI command** | `analyzer scan types` | + +**Response — 200 OK** + +```json +[ + { + "type": "linux", + "analyses": [ + { "type": "info", "default": true }, + { "type": "cve", "default": true }, + { "type": "software-bom", "default": true }, + { "type": "malware", "default": false } + ] + }, + { "type": "docker", "analyses": [ ... ] }, + { "type": "idf", "analyses": [ ... ] } +] +``` + +Also called internally by `analyzer scan new` when `--analysis` is omitted, to discover the +default set of analyses for the requested scan type. + +--- + +#### `POST /scans/` + +Upload a firmware/container image and start a new scan. The file is streamed as a multipart +form upload with progress reporting. + +| | | +|---|---| +| **Method** | `POST` | +| **Auth required** | Yes | +| **Content-Type** | `multipart/form-data` | +| **CLI command** | `analyzer scan new --object --file --type [--analysis ...] [--wait] [--interval ] [--timeout ]` | + +**Form fields** + +| Field | Type | Description | +|---|---|---| +| `object_id` | text | UUID of the parent object | +| `analysis` | text (JSON) | `{"type":"linux","analyses":["cve","software-bom"]}` | +| `image` | file | Binary firmware / container image; filename is preserved | + +**`analysis` JSON schema** + +```json +{ + "type": "linux", + "analyses": ["info", "cve", "software-bom", "malware"] +} +``` + +> When `--analysis` is omitted from the CLI, all analyses marked `default: true` for the +> requested scan type are used automatically (fetched via `GET /scans/types`). + +**Response — 201 Created** + +```json +{ "id": "uuid" } +``` + +--- + +#### `GET /scans/{id}` + +Retrieve full details of a single scan. + +| | | +|---|---| +| **Method** | `GET` | +| **Auth required** | Yes | +| **Path parameter** | `id` — UUID of the scan | + +**Response — 200 OK** + +```json +{ + "id": "uuid", + "image": { "id": "uuid", "file_name": "firmware.bin" }, + "created": "2024-01-01T00:00:00Z", + "analysis": [ + { + "id": "uuid", + "type": { "type": "linux", "analyses": ["cve"] }, + "status": "success" + } + ], + "image_type": "linux", + "info": null, + "score": { "score": 82, "scores": [] } +} +``` + +--- + +#### `DELETE /scans/{id}` + +Delete a scan and all its results. + +| | | +|---|---| +| **Method** | `DELETE` | +| **Auth required** | Yes | +| **Path parameter** | `id` — UUID of the scan | +| **CLI command** | `analyzer scan delete ` | + +**Response — 204 No Content** + +--- + +#### `POST /scans/{id}/cancel` + +Cancel an in-progress scan. + +| | | +|---|---| +| **Method** | `POST` | +| **Auth required** | Yes | +| **Path parameter** | `id` — UUID of the scan | +| **CLI command** | `analyzer scan cancel ` | + +**Response — 204 No Content** + +--- + +#### `GET /scans/{id}/status` + +Retrieve the current execution status of a scan and the status of each individual analysis. + +| | | +|---|---| +| **Method** | `GET` | +| **Auth required** | Yes | +| **Path parameter** | `id` — UUID of the scan | +| **CLI command** | `analyzer scan status --scan ` / `analyzer scan status --object ` | + +Also polled in a loop (configurable `--interval` / `--timeout`) when `--wait` is passed to +`scan new`, `scan report`, `scan sbom`, and `scan compliance-report`. + +**Response — 200 OK** + +```json +{ + "id": "uuid", + "status": "in-progress", + "cve": { "id": "uuid", "status": "in-progress" }, + "software-bom": { "id": "uuid", "status": "success" }, + "malware": { "id": "uuid", "status": "pending" } +} +``` + +Possible `status` values: `pending` · `in-progress` · `success` · `error` · `canceled` + +--- + +#### `GET /scans/{id}/score` + +Retrieve the aggregated security score and per-analysis breakdown. + +| | | +|---|---| +| **Method** | `GET` | +| **Auth required** | Yes | +| **Path parameter** | `id` — UUID of the scan | +| **CLI command** | `analyzer scan score --scan ` / `--object ` | + +**Response — 200 OK** + +```json +{ + "score": 82, + "scores": [ + { "id": "uuid", "type": "cve", "score": 90 }, + { "id": "uuid", "type": "software-bom", "score": 74 } + ] +} +``` + +`score` is `null` if scoring has not yet completed. + +--- + +#### `GET /scans/{id}/overview` + +Retrieve a high-level summary of all analysis results (counts and severities). + +| | | +|---|---| +| **Method** | `GET` | +| **Auth required** | Yes | +| **Path parameter** | `id` — UUID of the scan | +| **CLI command** | `analyzer scan overview --scan ` / `--object ` | + +**Response — 200 OK** + +```json +{ + "cve": { + "total": 42, + "counts": { "critical": 2, "high": 10, "medium": 20, "low": 8, "unknown": 2 }, + "products": { "openssl": 5 } + }, + "hardening": { + "total": 12, + "counts": { "high": 2, "medium": 6, "low": 4 } + }, + "malware": { "count": 0 }, + "password-hash": { "count": 3 }, + "software-bom": { "count": 150, "licenses": { "MIT": 60, "GPL-2.0": 10 } }, + "capabilities": { + "executable_count": 25, + "counts": { "critical": 1, "high": 3, "medium": 5, "low": 10, "none": 6, "unknown": 0 }, + "capabilities": { "CAP_NET_RAW": 2 } + }, + "crypto": { "certificates": 4, "public_keys": 2, "private_keys": 0 }, + "kernel": { "count": 1 }, + "tasks": { "count": 8 }, + "symbols": { "count": 320 }, + "stack-overflow": { "method": "canary" } +} +``` + +Fields that are absent from the response are omitted when the corresponding analysis was +not run. + +--- + +#### `GET /scans/{id}/results/{analysis_id}` + +Retrieve paginated findings for a specific analysis. + +| | | +|---|---| +| **Method** | `GET` | +| **Auth required** | Yes | +| **Path parameters** | `id` — scan UUID; `analysis_id` — analysis UUID | +| **CLI command** | `analyzer scan results --scan --analysis [--page N] [--per-page N] [--search ]` | + +**Query parameters** + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `page` | integer | `1` | 1-based page number | +| `per-page` | integer | `25` | Items per page | +| `sort-by` | string | determined by analysis type | Field to sort by | +| `sort-ord` | string | `desc` | `asc` or `desc` | +| `search` | string | _(none)_ | Filter/search string | + +**Response — 200 OK** + +```json +{ + "findings": [ { /* analysis-specific object */ } ], + "total-findings": 42, + "filters": {} +} +``` + +The shape of each item in `findings` depends on the analysis type: + +| Analysis type | Notable finding fields | +|---|---| +| `cve` | `cveid`, `severity`, `vendor`, `summary`, `cvss.v3.base_score`, `products`, `patch`, `references` | +| `malware` | `filename`, `description`, `detection_engine` | +| `hardening` | `filename`, `severity`, `canary`, `nx`, `pie`, `relro`, `fortify`, `stripped`, `suid`, `execstack` | +| `capabilities` | `filename`, `level`, `behaviors[].risk_level`, `syscalls` | +| `crypto` | `filename`, `type`, `subtype`, `pubsz`, `aux` | +| `software-bom` | `name`, `version`, `type`, `bom-ref`, `licenses` | +| `password-hash` | `username`, `password`, `severity` | +| `kernel` | `file`, `score`, `features[].name`, `features[].enabled` | +| `tasks` | `task-name`, `task_fn` | +| `symbols` | `symbol-name`, `symbol-type`, `symbol-bind` | + +--- + +#### `GET /scans/{id}/report` + +Download the full PDF security report for a scan. + +| | | +|---|---| +| **Method** | `GET` | +| **Auth required** | Yes | +| **Path parameter** | `id` — UUID of the scan | +| **CLI command** | `analyzer scan report --scan --output [--wait]` | + +**Response — 200 OK** — binary PDF (`application/pdf`). + +--- + +#### `GET /scans/{id}/sbom` + +Download the Software Bill of Materials in CycloneDX JSON format. + +| | | +|---|---| +| **Method** | `GET` | +| **Auth required** | Yes | +| **Path parameter** | `id` — UUID of the scan | +| **CLI command** | `analyzer scan sbom --scan --output [--wait]` | + +**Response — 200 OK** — binary CycloneDX JSON file. + +--- + +#### `GET /scans/{id}/compliance-check/{standard}` + +Retrieve the structured compliance check results for a regulatory standard. + +| | | +|---|---| +| **Method** | `GET` | +| **Auth required** | Yes | +| **Path parameters** | `id` — scan UUID; `standard` — compliance slug (see table below) | +| **CLI command** | `analyzer scan compliance --scan --type ` | + +**Supported standards** + +| CLI value | API slug | +|---|---| +| `cra` | `cyber-resilience-act` | + +**Response — 200 OK** + +```json +{ + "name": "Cyber Resilience Act", + "created-at": "2024-01-01T00:00:00Z", + "updated-at": null, + "sections": [ + { + "label": "Section 1", + "policy-ref": "CRA-1", + "sub-sections": [ + { + "label": "Requirement X", + "requirements": [ + { + "id": "CRA-1.1", + "description": "...", + "policy-ref": "...", + "explanation": null, + "advice": null, + "analyzer-status": "passed", + "overwritten-status": null + } + ] + } + ] + } + ], + "checks": { + "total": 50, + "passed": 40, + "unknown": 5, + "failed": 3, + "not-applicable": 2 + } +} +``` + +--- + +#### `GET /scans/{id}/compliance-check/{standard}/report` + +Download a PDF compliance report for a regulatory standard. + +| | | +|---|---| +| **Method** | `GET` | +| **Auth required** | Yes | +| **Path parameters** | `id` — scan UUID; `standard` — compliance slug (e.g. `cyber-resilience-act`) | +| **CLI command** | `analyzer scan compliance-report --scan --type --output [--wait]` | + +**Response — 200 OK** — binary PDF. + +--- + +## ⚠️ Error Handling + +All 4xx and 5xx responses are surfaced to the user as: + +``` +error: API error (HTTP ): +``` + +The CLI exits with code `1` on any API error. + +--- + +## 📊 Summary Table + +| # | Method | Path | CLI command | Auth | +|---|---|---|---|---| +| 1 | `GET` | `/health` | `login` (validation) | Optional | +| 2 | `GET` | `/objects/` | `object list` | Required | +| 3 | `GET` | `/objects/{id}` | `--object` flag resolution | Required | +| 4 | `POST` | `/objects/` | `object new` | Required | +| 5 | `DELETE` | `/objects/{id}` | `object delete` | Required | +| 6 | `GET` | `/scans/types` | `scan types` | Required | +| 7 | `POST` | `/scans/` | `scan new` | Required | +| 8 | `DELETE` | `/scans/{id}` | `scan delete` | Required | +| 9 | `POST` | `/scans/{id}/cancel` | `scan cancel` | Required | +| 10 | `GET` | `/scans/{id}/status` | `scan status` / `--wait` polling | Required | +| 11 | `GET` | `/scans/{id}/score` | `scan score` | Required | +| 12 | `GET` | `/scans/{id}/overview` | `scan overview` | Required | +| 13 | `GET` | `/scans/{id}/results/{analysis_id}` | `scan results` | Required | +| 14 | `GET` | `/scans/{id}/report` | `scan report` | Required | +| 15 | `GET` | `/scans/{id}/sbom` | `scan sbom` | Required | +| 16 | `GET` | `/scans/{id}/compliance-check/{standard}` | `scan compliance` | Required | +| 17 | `GET` | `/scans/{id}/compliance-check/{standard}/report` | `scan compliance-report` | Required | diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..dd94bb9 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.85.0" +profile = "minimal" +components = ["rustfmt", "clippy"] diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..ad47764 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,153 @@ +# 🛠️ Development bootstrap scripts + +This repository includes cross-platform development bootstrap scripts under `scripts/`. + +## 📁 Layout + +Scripts are organised per operating system and then by topic: + +``` +scripts/ + linux/ + commands/ build release test # container-backed operations + common/ runtime.sh # shared bash utilities + environment/ setup-dev # workstation bootstrap + macos/ + commands/ build release test # container-backed operations + common/ runtime.sh # shared zsh utilities + environment/ setup-dev # workstation bootstrap + windows/ + commands/ build.ps1 release.ps1 test.ps1 # container-backed operations + common/ runtime.ps1 # shared PowerShell utilities + environment/ setup-dev.ps1 # workstation bootstrap +``` + +Windows scripts use the `.ps1` extension so they can be executed directly with `pwsh -File`. + +--- + +## 🎯 Goals + +Each `setup-dev` script is: + +- ♻️ **Idempotent** — safe to run multiple times +- 🌍 **English-only** — all output in English +- 🎨 **Colorized and emoji-friendly** — readable terminal UX +- 📖 **Self-documented** — includes `--help` + +The container runner scripts follow the same conventions and build their images on demand when the local image does not exist yet. + +--- + +## 📦 What `release` does + +Any platform-specific `release` script (Linux, macOS, or Windows) builds **all** supported +OS/architecture combinations in a single container run using +[cargo-zigbuild](https://github.com/rust-cross/cargo-zigbuild) + Zig as the universal linker. +No host Rust toolchain is required — the container handles everything. + +**Output folder structure:** + +``` +dist/releases/ + release_._/ + exein_analyzer_cli_.___.zip + analyzer (or analyzer.exe on Windows targets) + LICENSE + CHANGELOG.md +``` + +**Supported targets (all produced in one run):** + +| OS | Architecture | Rust target | +|----|-------------|-------------| +| 🐧 linux | x64 | `x86_64-unknown-linux-gnu` | +| 🐧 linux | x86 | `i686-unknown-linux-gnu` | +| 🐧 linux | arm64 | `aarch64-unknown-linux-gnu` | +| 🪟 windows | x64 | `x86_64-pc-windows-gnu` | +| 🪟 windows | x86 | `i686-pc-windows-gnu` | +| 🪟 windows | arm64 | `aarch64-pc-windows-gnullvm` | +| 🍎 macos | x64 | `x86_64-apple-darwin` | +| 🍎 macos | arm64 | `aarch64-apple-darwin` | + +**Environment variables / flags:** + +| Variable / flag | Default | Description | +|----------------|---------|-------------| +| `ANALYZER_RELEASE_BUILD` / `--build-number` | `0` | Build number appended to the version | +| `ANALYZER_RELEASE_DATE` / `--release-date` | today | Override the `YYYYMMDD` date stamp | +| `--dry-run` / `-DryRun` | off | Print commands without executing them | + +On Windows, invoke the script directly with `pwsh -File`: + +```powershell +pwsh -File .\scripts\windows\commands\release.ps1 -Help +pwsh -File .\scripts\windows\commands\release.ps1 -DryRun -BuildNumber 42 -ReleaseDate 20260317 +``` + +--- + +## 🚀 What `setup-dev` does + +Depending on the operating system, the script installs or configures: + +- 💻 PowerShell +- 🖊️ Visual Studio Code +- 🔀 Git +- 📦 Git LFS +- 🐙 GitHub CLI +- 🌿 GitFlow +- 🦀 Rust toolchain +- 🎨 Oh My Posh +- 📋 Package managers relevant to the platform +- Docker Desktop or Podman Desktop checks, with Podman fallback + +The scripts also make sure Oh My Posh starts from the current user's PowerShell profile. + +## Usage + +### Windows + +```powershell +# Environment setup +pwsh -File .\scripts\windows\environment\setup-dev.ps1 --help +pwsh -File .\scripts\windows\environment\setup-dev.ps1 + +# Build / test / release +pwsh -File .\scripts\windows\commands\build.ps1 +pwsh -File .\scripts\windows\commands\test.ps1 +pwsh -File .\scripts\windows\commands\release.ps1 +``` + +### macOS + +```bash +# Environment setup +zsh ./scripts/macos/environment/setup-dev --help +zsh ./scripts/macos/environment/setup-dev + +# Build / test / release +zsh ./scripts/macos/commands/build +zsh ./scripts/macos/commands/test +zsh ./scripts/macos/commands/release +``` + +### Linux + +```bash +# Environment setup +bash ./scripts/linux/environment/setup-dev --help +bash ./scripts/linux/environment/setup-dev + +# Build / test / release +bash ./scripts/linux/commands/build +bash ./scripts/linux/commands/test +bash ./scripts/linux/commands/release +``` + +## Notes + +- Some package managers are platform-specific. Unsupported ones are reported and skipped explicitly. +- Linux support targets the most common package families: Debian/Ubuntu, Fedora/RHEL, Arch, and openSUSE. +- Container desktop installation depends on what the platform supports natively. +- The `containers/` directory defines repository-owned images for build, release, tests, and the devcontainer workflow. diff --git a/scripts/linux/commands/build b/scripts/linux/commands/build new file mode 100644 index 0000000..72c702b --- /dev/null +++ b/scripts/linux/commands/build @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +DRY_RUN=0 +RUNTIME="" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../common/runtime.sh +. "$SCRIPT_DIR/../common/runtime.sh" + +show_help() { + cat <<'EOF' +🚀 build for Linux + +Synopsis: + Build the project inside the repository build container. + +Usage: + bash ./scripts/linux/commands/build [--runtime docker|podman] [--dry-run] [--help] +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) show_help; exit 0 ;; + --dry-run) DRY_RUN=1 ;; + --runtime) RUNTIME="$2"; shift ;; + *) log ERR "Unknown argument: $1"; show_help; exit 1 ;; + esac + shift +done + +RUNTIME="$(resolve_runtime)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +HOST_REPO_ROOT="$(normalize_host_path "$REPO_ROOT")" +DOCKERFILE="$(host_path_join "$HOST_REPO_ROOT" "containers/build/Dockerfile")" +TAG="analyzer-cli-build:local" + +log INFO "Using container runtime: $RUNTIME" +ensure_image "$TAG" "$DOCKERFILE" "$HOST_REPO_ROOT" +run_cmd "$RUNTIME run --rm -v \"$HOST_REPO_ROOT:/workspace\" -w /workspace $TAG" +log OK "Containerized build completed." diff --git a/scripts/linux/commands/release b/scripts/linux/commands/release new file mode 100644 index 0000000..971efab --- /dev/null +++ b/scripts/linux/commands/release @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +DRY_RUN=0 +RUNTIME="" +BUILD_NUMBER="${ANALYZER_RELEASE_BUILD:-0}" +RELEASE_DATE="${ANALYZER_RELEASE_DATE:-}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../common/runtime.sh +. "$SCRIPT_DIR/../common/runtime.sh" + +show_help() { + cat <<'EOF' +🚀 release — cross-compile all platforms/architectures from a single container + +Synopsis: + Builds and packages release binaries for all supported OS/architecture + combinations (Linux, macOS, Windows × x64/x86/arm64) using the repository + release container (cargo-zigbuild). All archives are created in one container + run; no host Rust toolchain is required. + +Usage: + bash ./scripts/linux/commands/release [--runtime docker|podman] [--build-number N] [--release-date YYYYMMDD] [--dry-run] [--help] + +Artifacts: + dist/releases/release_._/ + exein_analyzer_cli_.___.zip +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) show_help; exit 0 ;; + --dry-run) DRY_RUN=1 ;; + --runtime) RUNTIME="$2"; shift ;; + --build-number) BUILD_NUMBER="$2"; shift ;; + --release-date) RELEASE_DATE="$2"; shift ;; + *) log ERR "Unknown argument: $1"; show_help; exit 1 ;; + esac + shift +done + +parse_version() { + local version + version="$(sed -n 's/^version = "\(.*\)"$/\1/p' "$REPO_ROOT/Cargo.toml" | head -n1)" + if [[ -z "$version" ]]; then + log ERR "Could not determine version from Cargo.toml." + exit 1 + fi + printf '%s\n' "$version" +} + +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +RUNTIME="$(resolve_runtime)" +HOST_REPO_ROOT="$(normalize_host_path "$REPO_ROOT")" +DOCKERFILE="$(host_path_join "$HOST_REPO_ROOT" "containers/release/Dockerfile")" +TAG="analyzer-cli-release:local" +VERSION="$(parse_version)" +STAMP="${RELEASE_DATE:-$(date '+%Y%m%d')}" + +log INFO "Version: ${VERSION}.${BUILD_NUMBER}" +log INFO "Date: ${STAMP}" +log INFO "Runtime: ${RUNTIME}" + +run_cmd "mkdir -p \"$REPO_ROOT/dist/releases\"" +ensure_image "$TAG" "$DOCKERFILE" "$HOST_REPO_ROOT" + +log INFO "Launching release container for all targets..." +run_cmd "$RUNTIME run --rm \ + -e ANALYZER_RELEASE_VERSION=\"$VERSION\" \ + -e ANALYZER_RELEASE_BUILD=\"$BUILD_NUMBER\" \ + -e ANALYZER_RELEASE_STAMP=\"$STAMP\" \ + -e ANALYZER_RELEASE_WORKSPACE=/workspace \ + -v \"$HOST_REPO_ROOT:/workspace\" \ + -w /workspace \ + $TAG" + +log OK "Release complete — archives written to $REPO_ROOT/dist/releases" diff --git a/scripts/linux/commands/test b/scripts/linux/commands/test new file mode 100644 index 0000000..c468951 --- /dev/null +++ b/scripts/linux/commands/test @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +DRY_RUN=0 +RUNTIME="" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../common/runtime.sh +. "$SCRIPT_DIR/../common/runtime.sh" + +show_help() { + cat <<'EOF' +🚀 test for Linux + +Synopsis: + Run formatting, linting, and tests inside the repository test container. + +Usage: + bash ./scripts/linux/commands/test [--runtime docker|podman] [--dry-run] [--help] +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) show_help; exit 0 ;; + --dry-run) DRY_RUN=1 ;; + --runtime) RUNTIME="$2"; shift ;; + *) log ERR "Unknown argument: $1"; show_help; exit 1 ;; + esac + shift +done + +RUNTIME="$(resolve_runtime)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +HOST_REPO_ROOT="$(normalize_host_path "$REPO_ROOT")" +DOCKERFILE="$(host_path_join "$HOST_REPO_ROOT" "containers/test/Dockerfile")" +TAG="analyzer-cli-test:local" + +log INFO "Using container runtime: $RUNTIME" +ensure_image "$TAG" "$DOCKERFILE" "$HOST_REPO_ROOT" +run_cmd "$RUNTIME run --rm -v \"$HOST_REPO_ROOT:/workspace\" -w /workspace $TAG" +log OK "Containerized test run completed." diff --git a/scripts/linux/common/runtime.sh b/scripts/linux/common/runtime.sh new file mode 100644 index 0000000..458f5a8 --- /dev/null +++ b/scripts/linux/common/runtime.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Shared container runtime utilities sourced by Linux command scripts. +# Callers must declare: DRY_RUN=0 RUNTIME="" before sourcing this file. + +log() { + local level="$1" + local message="$2" + case "$level" in + INFO) printf '\033[36mℹ️ %s\033[0m\n' "$message" ;; + OK) printf '\033[32m✅ %s\033[0m\n' "$message" ;; + WARN) printf '\033[33m⚠️ %s\033[0m\n' "$message" ;; + ERR) printf '\033[31m❌ %s\033[0m\n' "$message" ;; + esac +} + +run_cmd() { + if [[ "${DRY_RUN:-0}" -eq 1 ]]; then + log INFO "[dry-run] $*" + else + eval "$*" + fi +} + +# Convert an absolute host path to a form the container runtime can mount. +# On WSL/Cygwin with a Windows-native runtime (docker.exe / podman.exe) the +# path must be in Windows format; everywhere else it is returned unchanged. +normalize_host_path() { + local path="$1" + if [[ "${RUNTIME:-}" == *.exe ]]; then + if command -v wslpath >/dev/null 2>&1; then wslpath -w "$path"; return; fi + if command -v cygpath >/dev/null 2>&1; then cygpath -aw "$path"; return; fi + fi + printf '%s\n' "$path" +} + +# Join a host-format base path with a forward-slash relative suffix. +host_path_join() { + local base="$1" suffix="$2" + if [[ "${RUNTIME:-}" == *.exe ]]; then + printf '%s\\%s\n' "$base" "${suffix//\//\\}" + return + fi + printf '%s/%s\n' "$base" "$suffix" +} + +resolve_runtime() { + if [[ -n "${RUNTIME:-}" ]]; then + case "$RUNTIME" in + podman) + command -v podman >/dev/null 2>&1 && { printf 'podman\n'; return; } + command -v podman.exe >/dev/null 2>&1 && { printf 'podman.exe\n'; return; } + ;; + docker) + command -v docker >/dev/null 2>&1 && { printf 'docker\n'; return; } + command -v docker.exe >/dev/null 2>&1 && { printf 'docker.exe\n'; return; } + ;; + *) + printf '%s\n' "$RUNTIME"; return ;; + esac + fi + command -v docker >/dev/null 2>&1 && { printf 'docker\n'; return; } + command -v docker.exe >/dev/null 2>&1 && { printf 'docker.exe\n'; return; } + command -v podman >/dev/null 2>&1 && { printf 'podman\n'; return; } + command -v podman.exe >/dev/null 2>&1 && { printf 'podman.exe\n'; return; } + log ERR "Neither docker nor podman is available." + exit 1 +} + +# ensure_image TAG DOCKERFILE CONTEXT +# Builds the image only when it does not already exist locally. +ensure_image() { + local tag="$1" dockerfile="$2" context="$3" + if [[ "${DRY_RUN:-0}" -eq 0 ]] && "$RUNTIME" image inspect "$tag" >/dev/null 2>&1; then + log INFO "Reusing container image: $tag" + return + fi + log INFO "Building container image: $tag" + run_cmd "$RUNTIME build -t $tag -f \"$dockerfile\" \"$context\"" +} diff --git a/scripts/linux/environment/setup-dev b/scripts/linux/environment/setup-dev new file mode 100644 index 0000000..68f2325 --- /dev/null +++ b/scripts/linux/environment/setup-dev @@ -0,0 +1,326 @@ +#!/usr/bin/env bash +set -euo pipefail + +DRY_RUN=0 + +show_help() { + cat <<'EOF' +🚀 setup-dev for Linux + +Synopsis: + Bootstrap a professional Linux developer workstation for this repository. + +Usage: + bash ./scripts/linux/setup-dev [--dry-run] [--help] + +What it does: + • Installs Homebrew + • Installs PowerShell, Visual Studio Code, Git, Git LFS, GitHub CLI, GitFlow, Rust, and Oh My Posh + • Configures Oh My Posh in the current user's PowerShell profile + • Checks Docker Desktop or Podman Desktop and installs Podman when neither is present + • Supports common Linux package families: Debian/Ubuntu, Fedora/RHEL, Arch, and openSUSE + • Reports unsupported package managers such as Scoop and Chocolatey explicitly + +Behavior: + • Idempotent: safe to run multiple times + • English output + • Colorful logs with emoji +EOF +} + +color() { + local name="$1" + case "$name" in + cyan) printf '\033[36m' ;; + green) printf '\033[32m' ;; + yellow) printf '\033[33m' ;; + red) printf '\033[31m' ;; + magenta) printf '\033[35m' ;; + reset) printf '\033[0m' ;; + esac +} + +log() { + local level="$1" + local message="$2" + local icon color_name + case "$level" in + INFO) icon="ℹ️"; color_name="cyan" ;; + OK) icon="✅"; color_name="green" ;; + WARN) icon="⚠️"; color_name="yellow" ;; + ERR) icon="❌"; color_name="red" ;; + STEP) icon="🚀"; color_name="magenta" ;; + esac + printf "%b%s %s%b\n" "$(color "$color_name")" "$icon" "$message" "$(color reset)" +} + +run_cmd() { + if [[ "$DRY_RUN" -eq 1 ]]; then + log INFO "[dry-run] $*" + else + eval "$*" + fi +} + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +detect_linux_family() { + if [[ -f /etc/debian_version ]]; then + echo debian + elif [[ -f /etc/fedora-release ]] || [[ -f /etc/redhat-release ]]; then + echo fedora + elif [[ -f /etc/arch-release ]]; then + echo arch + elif [[ -f /etc/os-release ]] && grep -qi "suse" /etc/os-release; then + echo suse + else + echo unknown + fi +} + +install_base_dependencies() { + local family + family="$(detect_linux_family)" + + case "$family" in + debian) + run_cmd "sudo apt-get update" + run_cmd "sudo apt-get install -y build-essential curl file git procps wget gpg" + ;; + fedora) + run_cmd "sudo dnf install -y @development-tools curl file git procps-ng wget gnupg2" + ;; + arch) + run_cmd "sudo pacman -Syu --noconfirm" + run_cmd "sudo pacman -S --noconfirm base-devel curl file git procps-ng wget gnupg" + ;; + suse) + run_cmd "sudo zypper --non-interactive refresh" + run_cmd "sudo zypper --non-interactive install -t pattern devel_basis" + run_cmd "sudo zypper --non-interactive install curl file git procps wget gpg2" + ;; + *) + log ERR "Unsupported Linux distribution family." + exit 1 + ;; + esac +} + +ensure_brew() { + if command_exists brew; then + log OK "Homebrew is already installed." + return + fi + + install_base_dependencies + log INFO "Installing Homebrew..." + run_cmd 'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' + if [[ "$DRY_RUN" -eq 0 ]]; then + if [[ -x /home/linuxbrew/.linuxbrew/bin/brew ]]; then + eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" + fi + fi +} + +ensure_brew_shellenv() { + if ! command_exists brew; then + return + fi + + local line rcfile + line='eval "$('"$(command -v brew)"' shellenv)"' + for rcfile in "$HOME/.profile" "$HOME/.bashrc" "$HOME/.zshrc"; do + if [[ ! -f "$rcfile" ]]; then + if [[ "$DRY_RUN" -eq 1 ]]; then + log INFO "[dry-run] touch \"$rcfile\"" + else + touch "$rcfile" + fi + fi + if grep -Fqx "$line" "$rcfile"; then + continue + fi + + if [[ "$DRY_RUN" -eq 1 ]]; then + log INFO "[dry-run] printf '\n%s\n' '$line' >> \"$rcfile\"" + else + printf '\n%s\n' "$line" >>"$rcfile" + fi + done +} + +ensure_brew_package() { + local formula="$1" + local label="$2" + + if brew list "$formula" >/dev/null 2>&1; then + log OK "$label is already installed." + return + fi + + log INFO "Installing $label..." + run_cmd "brew install $formula" +} + +ensure_rust() { + if command_exists rustup; then + log OK "Rustup is already installed." + return + fi + + log INFO "Installing Rust with rustup..." + run_cmd 'curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y' +} + +ensure_vscode() { + if command_exists code; then + log OK "Visual Studio Code is already installed." + return + fi + + local family + family="$(detect_linux_family)" + + case "$family" in + debian) + run_cmd "wget -qO- https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /tmp/packages.microsoft.gpg" + run_cmd "sudo install -D -o root -g root -m 644 /tmp/packages.microsoft.gpg /etc/apt/keyrings/packages.microsoft.gpg" + run_cmd "sudo sh -c 'echo \"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/packages.microsoft.gpg] https://packages.microsoft.com/repos/code stable main\" > /etc/apt/sources.list.d/vscode.list'" + run_cmd "sudo apt-get update" + run_cmd "sudo apt-get install -y code" + ;; + fedora) + run_cmd "sudo rpm --import https://packages.microsoft.com/keys/microsoft.asc" + run_cmd "sudo sh -c 'cat > /etc/yum.repos.d/vscode.repo <<\"REPO\"\n[code]\nname=Visual Studio Code\nbaseurl=https://packages.microsoft.com/yumrepos/vscode\nenabled=1\ngpgcheck=1\nrepo_gpgcheck=1\ngpgkey=https://packages.microsoft.com/keys/microsoft.asc\nREPO'" + run_cmd "sudo dnf install -y code" + ;; + arch) + log WARN "Visual Studio Code is not installed automatically on Arch without an AUR helper. Please install it manually or adapt the script to your preferred helper." + ;; + suse) + run_cmd "sudo rpm --import https://packages.microsoft.com/keys/microsoft.asc" + run_cmd "sudo zypper addrepo https://packages.microsoft.com/yumrepos/vscode vscode" + run_cmd "sudo zypper --gpg-auto-import-keys refresh" + run_cmd "sudo zypper --non-interactive install code" + ;; + esac +} + +ensure_pwsh_profile() { + if ! command_exists pwsh; then + log WARN "PowerShell is not installed yet. Skipping profile setup." + return + fi + + local profile_path profile_dir existing_line init_line + profile_path="$(pwsh -NoProfile -Command '$PROFILE.CurrentUserAllHosts')" + profile_dir="$(dirname "$profile_path")" + + [[ -d "$profile_dir" ]] || run_cmd "mkdir -p \"$profile_dir\"" + [[ -f "$profile_path" ]] || run_cmd "touch \"$profile_path\"" + + existing_line="$(grep -E 'oh-my-posh init pwsh --config' "$profile_path" | head -n 1 || true)" + if [[ -z "$existing_line" ]]; then + init_line='oh-my-posh init pwsh --config "$env:POSH_THEMES_PATH/jandedobbeleer.omp.json" | Invoke-Expression' + else + init_line="$existing_line" + fi + + if grep -Fqx "$init_line" "$profile_path"; then + log OK "Oh My Posh profile initialization already exists." + else + log INFO "Adding Oh My Posh initialization to the current user's PowerShell profile..." + if [[ "$DRY_RUN" -eq 1 ]]; then + log INFO "[dry-run] printf '\n%s\n' '$init_line' >> \"$profile_path\"" + else + printf '\n%s\n' "$init_line" >>"$profile_path" + fi + fi + + if [[ "$DRY_RUN" -eq 0 ]]; then + pwsh -NoProfile -Command ". \"$profile_path\"" + log OK "PowerShell profile reloaded." + fi +} + +ensure_container_stack() { + local docker_cli=0 + local podman_cli=0 + local docker_desktop=0 + local podman_desktop=0 + + command_exists docker && docker_cli=1 + command_exists podman && podman_cli=1 + command_exists docker-desktop && docker_desktop=1 + command_exists podman-desktop && podman_desktop=1 + + if { [[ "$docker_cli" -eq 1 ]] && [[ "$docker_desktop" -eq 1 ]]; } || { [[ "$podman_cli" -eq 1 ]] && [[ "$podman_desktop" -eq 1 ]]; }; then + log OK "A supported container desktop stack is already installed." + return + fi + + log INFO "Installing Podman because no supported container desktop stack was detected." + ensure_brew_package podman "Podman CLI" + + if command_exists flatpak; then + run_cmd "flatpak install --user -y flathub io.podman_desktop.PodmanDesktop" + return + fi + + local family + family="$(detect_linux_family)" + case "$family" in + debian) run_cmd "sudo apt-get install -y flatpak" ;; + fedora) run_cmd "sudo dnf install -y flatpak" ;; + arch) run_cmd "sudo pacman -S --noconfirm flatpak" ;; + suse) run_cmd "sudo zypper --non-interactive install flatpak" ;; + esac + run_cmd "flatpak install --user -y flathub io.podman_desktop.PodmanDesktop" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help + exit 0 + ;; + --dry-run) + DRY_RUN=1 + ;; + *) + log ERR "Unknown argument: $1" + show_help + exit 1 + ;; + esac + shift +done + +log STEP "Step 1 - Installing package managers" +ensure_brew +if [[ "$DRY_RUN" -eq 0 ]] && command -v /home/linuxbrew/.linuxbrew/bin/brew >/dev/null 2>&1; then + eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" +fi +ensure_brew_shellenv +log WARN "Scoop is Windows-only and has been skipped on Linux." +log WARN "Chocolatey is Windows-only and has been skipped on Linux." + +log STEP "Step 2 - Installing developer tools" +ensure_brew_package powershell "PowerShell" +ensure_vscode +ensure_brew_package git "Git" +ensure_brew_package git-lfs "Git LFS" +ensure_brew_package gh "GitHub CLI" +ensure_brew_package git-flow-avh "GitFlow" +ensure_brew_package oh-my-posh "Oh My Posh" +ensure_rust + +log STEP "Step 3 - Checking container tooling" +ensure_container_stack + +log STEP "Step 4 - Configuring the PowerShell profile" +ensure_pwsh_profile + +log OK "Linux development environment setup completed." diff --git a/scripts/macos/commands/build b/scripts/macos/commands/build new file mode 100644 index 0000000..53303ae --- /dev/null +++ b/scripts/macos/commands/build @@ -0,0 +1,40 @@ +#!/usr/bin/env zsh +set -euo pipefail + +DRY_RUN=0 +RUNTIME="" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=../common/runtime.sh +. "$SCRIPT_DIR/../common/runtime.sh" + +show_help() { + cat <<'EOF' +🚀 build for macOS + +Synopsis: + Build the project inside the repository build container. + +Usage: + zsh ./scripts/macos/commands/build [--runtime docker|podman] [--dry-run] [--help] +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) show_help; exit 0 ;; + --dry-run) DRY_RUN=1 ;; + --runtime) RUNTIME="$2"; shift ;; + *) log ERR "Unknown argument: $1"; show_help; exit 1 ;; + esac + shift +done + +RUNTIME="$(resolve_runtime)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +TAG="analyzer-cli-build:local" + +log INFO "Using container runtime: $RUNTIME" +ensure_image "$TAG" "$REPO_ROOT/containers/build/Dockerfile" +run_cmd "$RUNTIME run --rm -v \"$REPO_ROOT:/workspace\" -w /workspace $TAG" +log OK "Containerized build completed." diff --git a/scripts/macos/commands/release b/scripts/macos/commands/release new file mode 100644 index 0000000..7c7dbdc --- /dev/null +++ b/scripts/macos/commands/release @@ -0,0 +1,78 @@ +#!/usr/bin/env zsh +set -euo pipefail + +DRY_RUN=0 +RUNTIME="" +BUILD_NUMBER="${ANALYZER_RELEASE_BUILD:-0}" +RELEASE_DATE="${ANALYZER_RELEASE_DATE:-}" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=../common/runtime.sh +. "$SCRIPT_DIR/../common/runtime.sh" + +show_help() { + cat <<'EOF' +🚀 release — cross-compile all platforms/architectures from a single container + +Synopsis: + Builds and packages release binaries for all supported OS/architecture + combinations (Linux, macOS, Windows × x64/x86/arm64) using the repository + release container (cargo-zigbuild). All archives are created in one container + run; no host Rust toolchain is required. + +Usage: + zsh ./scripts/macos/commands/release [--runtime docker|podman] [--build-number N] [--release-date YYYYMMDD] [--dry-run] [--help] + +Artifacts: + dist/releases/release_._/ + exein_analyzer_cli_.___.zip +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) show_help; exit 0 ;; + --dry-run) DRY_RUN=1 ;; + --runtime) RUNTIME="$2"; shift ;; + --build-number) BUILD_NUMBER="$2"; shift ;; + --release-date) RELEASE_DATE="$2"; shift ;; + *) log ERR "Unknown argument: $1"; show_help; exit 1 ;; + esac + shift +done + +parse_version() { + local version + version="$(sed -n 's/^version = "\(.*\)"$/\1/p' "$REPO_ROOT/Cargo.toml" | head -n1)" + if [[ -z "$version" ]]; then + log ERR "Could not determine version from Cargo.toml." + exit 1 + fi + printf '%s\n' "$version" +} + +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +RUNTIME="$(resolve_runtime)" +DOCKERFILE="$REPO_ROOT/containers/release/Dockerfile" +TAG="analyzer-cli-release:local" +VERSION="$(parse_version)" +STAMP="${RELEASE_DATE:-$(date '+%Y%m%d')}" + +log INFO "Version: ${VERSION}.${BUILD_NUMBER}" +log INFO "Date: ${STAMP}" +log INFO "Runtime: ${RUNTIME}" + +run_cmd "mkdir -p \"$REPO_ROOT/dist/releases\"" +ensure_image "$TAG" "$DOCKERFILE" "$REPO_ROOT" + +log INFO "Launching release container for all targets..." +run_cmd "$RUNTIME run --rm \ + -e ANALYZER_RELEASE_VERSION=\"$VERSION\" \ + -e ANALYZER_RELEASE_BUILD=\"$BUILD_NUMBER\" \ + -e ANALYZER_RELEASE_STAMP=\"$STAMP\" \ + -e ANALYZER_RELEASE_WORKSPACE=/workspace \ + -v \"$REPO_ROOT:/workspace\" \ + -w /workspace \ + $TAG" + +log OK "Release complete — archives written to $REPO_ROOT/dist/releases" diff --git a/scripts/macos/commands/test b/scripts/macos/commands/test new file mode 100644 index 0000000..0bd9155 --- /dev/null +++ b/scripts/macos/commands/test @@ -0,0 +1,40 @@ +#!/usr/bin/env zsh +set -euo pipefail + +DRY_RUN=0 +RUNTIME="" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=../common/runtime.sh +. "$SCRIPT_DIR/../common/runtime.sh" + +show_help() { + cat <<'EOF' +🚀 test for macOS + +Synopsis: + Run formatting, linting, and tests inside the repository test container. + +Usage: + zsh ./scripts/macos/commands/test [--runtime docker|podman] [--dry-run] [--help] +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) show_help; exit 0 ;; + --dry-run) DRY_RUN=1 ;; + --runtime) RUNTIME="$2"; shift ;; + *) log ERR "Unknown argument: $1"; show_help; exit 1 ;; + esac + shift +done + +RUNTIME="$(resolve_runtime)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +TAG="analyzer-cli-test:local" + +log INFO "Using container runtime: $RUNTIME" +ensure_image "$TAG" "$REPO_ROOT/containers/test/Dockerfile" +run_cmd "$RUNTIME run --rm -v \"$REPO_ROOT:/workspace\" -w /workspace $TAG" +log OK "Containerized test run completed." diff --git a/scripts/macos/common/runtime.sh b/scripts/macos/common/runtime.sh new file mode 100644 index 0000000..a787a5d --- /dev/null +++ b/scripts/macos/common/runtime.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env zsh +# Shared container runtime utilities sourced by macOS command scripts. +# Callers must declare: DRY_RUN=0 RUNTIME="" before sourcing this file. + +log() { + local level="$1" + local message="$2" + case "$level" in + INFO) printf '\033[36mℹ️ %s\033[0m\n' "$message" ;; + OK) printf '\033[32m✅ %s\033[0m\n' "$message" ;; + WARN) printf '\033[33m⚠️ %s\033[0m\n' "$message" ;; + ERR) printf '\033[31m❌ %s\033[0m\n' "$message" ;; + esac +} + +run_cmd() { + if [[ "${DRY_RUN:-0}" -eq 1 ]]; then + log INFO "[dry-run] $*" + else + eval "$*" + fi +} + +resolve_runtime() { + if [[ -n "${RUNTIME:-}" ]]; then + printf '%s\n' "$RUNTIME" + return + fi + if command -v docker >/dev/null 2>&1; then printf 'docker\n'; return; fi + if command -v podman >/dev/null 2>&1; then printf 'podman\n'; return; fi + log ERR "Neither docker nor podman is available." + exit 1 +} + +# ensure_image TAG DOCKERFILE CONTEXT +# Builds the image only when it does not already exist locally. +ensure_image() { + local tag="$1" dockerfile="$2" context="$3" + if [[ "${DRY_RUN:-0}" -eq 0 ]] && "$RUNTIME" image inspect "$tag" >/dev/null 2>&1; then + log INFO "Reusing container image: $tag" + return + fi + log INFO "Building container image: $tag" + run_cmd "$RUNTIME build -t $tag -f \"$dockerfile\" \"$context\"" +} diff --git a/scripts/macos/environment/setup-dev b/scripts/macos/environment/setup-dev new file mode 100644 index 0000000..3099a6e --- /dev/null +++ b/scripts/macos/environment/setup-dev @@ -0,0 +1,220 @@ +#!/usr/bin/env zsh +set -euo pipefail + +DRY_RUN=0 + +show_help() { + cat <<'EOF' +🚀 setup-dev for macOS + +Synopsis: + Bootstrap a professional macOS developer workstation for this repository. + +Usage: + zsh ./scripts/macos/setup-dev [--dry-run] [--help] + +What it does: + • Installs Homebrew + • Installs PowerShell, Visual Studio Code, Git, Git LFS, GitHub CLI, GitFlow, Rust, and Oh My Posh + • Configures Oh My Posh in the current user's PowerShell profile + • Checks Docker Desktop or Podman Desktop and installs Podman when neither is present + • Reports unsupported package managers such as Scoop and Chocolatey explicitly + +Behavior: + • Idempotent: safe to run multiple times + • English output + • Colorful logs with emoji +EOF +} + +color() { + local name="$1" + case "$name" in + cyan) printf '\033[36m' ;; + green) printf '\033[32m' ;; + yellow) printf '\033[33m' ;; + red) printf '\033[31m' ;; + magenta) printf '\033[35m' ;; + reset) printf '\033[0m' ;; + esac +} + +log() { + local level="$1" + local message="$2" + local icon color_name + case "$level" in + INFO) icon="ℹ️"; color_name="cyan" ;; + OK) icon="✅"; color_name="green" ;; + WARN) icon="⚠️"; color_name="yellow" ;; + ERR) icon="❌"; color_name="red" ;; + STEP) icon="🚀"; color_name="magenta" ;; + esac + printf "%b%s %s%b\n" "$(color "$color_name")" "$icon" "$message" "$(color reset)" +} + +run_cmd() { + if [[ "$DRY_RUN" -eq 1 ]]; then + log INFO "[dry-run] $*" + else + eval "$*" + fi +} + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +ensure_brew() { + if command_exists brew; then + log OK "Homebrew is already installed." + return + fi + + log INFO "Installing Homebrew..." + run_cmd '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' + if [[ "$DRY_RUN" -eq 0 ]]; then + eval "$(/opt/homebrew/bin/brew shellenv 2>/dev/null || /usr/local/bin/brew shellenv)" + fi +} + +ensure_brew_package() { + local formula="$1" + local label="$2" + + if brew list "$formula" >/dev/null 2>&1; then + log OK "$label is already installed." + return + fi + + log INFO "Installing $label..." + run_cmd "brew install $formula" +} + +ensure_brew_cask() { + local cask="$1" + local label="$2" + + if brew list --cask "$cask" >/dev/null 2>&1; then + log OK "$label is already installed." + return + fi + + log INFO "Installing $label..." + run_cmd "brew install --cask $cask" +} + +ensure_rust() { + if command_exists rustup; then + log OK "Rustup is already installed." + return + fi + + log INFO "Installing Rust with rustup..." + run_cmd 'curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y' +} + +ensure_pwsh_profile() { + if ! command_exists pwsh; then + log WARN "PowerShell is not installed yet. Skipping profile setup." + return + fi + + local profile_path profile_dir existing_line init_line + profile_path="$(pwsh -NoProfile -Command '$PROFILE.CurrentUserAllHosts')" + profile_dir="$(dirname "$profile_path")" + + if [[ ! -d "$profile_dir" ]]; then + run_cmd "mkdir -p \"$profile_dir\"" + fi + + if [[ ! -f "$profile_path" ]]; then + run_cmd "touch \"$profile_path\"" + fi + + existing_line="$(grep -E 'oh-my-posh init pwsh --config' "$profile_path" | head -n 1 || true)" + if [[ -z "$existing_line" ]]; then + init_line='oh-my-posh init pwsh --config "$env:POSH_THEMES_PATH/jandedobbeleer.omp.json" | Invoke-Expression' + else + init_line="$existing_line" + fi + + if grep -Fqx "$init_line" "$profile_path"; then + log OK "Oh My Posh profile initialization already exists." + else + log INFO "Adding Oh My Posh initialization to the current user's PowerShell profile..." + if [[ "$DRY_RUN" -eq 1 ]]; then + log INFO "[dry-run] printf '\n%s\n' '$init_line' >> \"$profile_path\"" + else + printf '\n%s\n' "$init_line" >>"$profile_path" + fi + fi + + if [[ "$DRY_RUN" -eq 0 ]]; then + pwsh -NoProfile -Command ". \"$profile_path\"" + log OK "PowerShell profile reloaded." + fi +} + +ensure_container_stack() { + local docker_desktop=0 + local podman_desktop=0 + local docker_cli=0 + local podman_cli=0 + + [[ -d "/Applications/Docker.app" ]] && docker_desktop=1 + [[ -d "/Applications/Podman Desktop.app" ]] && podman_desktop=1 + command_exists docker && docker_cli=1 + command_exists podman && podman_cli=1 + + if { [[ "$docker_desktop" -eq 1 ]] && [[ "$docker_cli" -eq 1 ]]; } || { [[ "$podman_desktop" -eq 1 ]] && [[ "$podman_cli" -eq 1 ]]; }; then + log OK "A supported container desktop stack is already installed." + return + fi + + log INFO "Installing Podman CLI and Podman Desktop because no supported container desktop was detected." + ensure_brew_package podman "Podman CLI" + ensure_brew_cask podman-desktop "Podman Desktop" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help + exit 0 + ;; + --dry-run) + DRY_RUN=1 + ;; + *) + log ERR "Unknown argument: $1" + show_help + exit 1 + ;; + esac + shift +done + +log STEP "Step 1 - Installing package managers" +ensure_brew +eval "$(/opt/homebrew/bin/brew shellenv 2>/dev/null || /usr/local/bin/brew shellenv)" +log WARN "Scoop is Windows-only and has been skipped on macOS." +log WARN "Chocolatey is Windows-only and has been skipped on macOS." + +log STEP "Step 2 - Installing developer tools" +ensure_brew_package powershell "PowerShell" +ensure_brew_cask visual-studio-code "Visual Studio Code" +ensure_brew_package git "Git" +ensure_brew_package git-lfs "Git LFS" +ensure_brew_package gh "GitHub CLI" +ensure_brew_package git-flow-avh "GitFlow" +ensure_brew_package oh-my-posh "Oh My Posh" +ensure_rust + +log STEP "Step 3 - Checking container tooling" +ensure_container_stack + +log STEP "Step 4 - Configuring the PowerShell profile" +ensure_pwsh_profile + +log OK "macOS development environment setup completed." diff --git a/scripts/windows/commands/build.ps1 b/scripts/windows/commands/build.ps1 new file mode 100644 index 0000000..82bd7be --- /dev/null +++ b/scripts/windows/commands/build.ps1 @@ -0,0 +1,40 @@ +#!/usr/bin/env pwsh +[CmdletBinding()] +param( + [ValidateSet("docker", "podman")] + [string]$Runtime, + [switch]$Help, + [switch]$DryRun +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +. "$PSScriptRoot\..\common\runtime.ps1" + +function Show-Help { + @" +🚀 build for Windows + +Synopsis: + Build the project inside the repository build container. + +Usage: + pwsh -File .\scripts\windows\commands\build.ps1 [-Runtime docker|podman] [-DryRun] [-Help] +"@ | Write-Host +} + +if ($Help) { + Show-Help + exit 0 +} + +$containerRuntime = Resolve-Runtime +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path +$tag = "analyzer-cli-build:local" +$dockerfile = Join-Path $repoRoot "containers\build\Dockerfile" + +Write-Log INFO "Using container runtime: $containerRuntime" +Ensure-Image -Tag $tag -Dockerfile $dockerfile +Invoke-Step "$containerRuntime run --rm -v `"${repoRoot}:/workspace`" -w /workspace $tag" +Write-Log OK "Containerized build completed." diff --git a/scripts/windows/commands/release.ps1 b/scripts/windows/commands/release.ps1 new file mode 100644 index 0000000..563f28a --- /dev/null +++ b/scripts/windows/commands/release.ps1 @@ -0,0 +1,81 @@ +#!/usr/bin/env pwsh +[CmdletBinding()] +param( + [string]$Runtime = $env:ANALYZER_RELEASE_RUNTIME, + [string]$BuildNumber = $(if ($env:ANALYZER_RELEASE_BUILD) { $env:ANALYZER_RELEASE_BUILD } else { "0" }), + [string]$ReleaseDate = $env:ANALYZER_RELEASE_DATE, + [switch]$DryRun, + [switch]$Help +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +. "$PSScriptRoot\..\common\runtime.ps1" + +function Show-Help { + @" +🚀 release — cross-compile all platforms/architectures from a single container + +Synopsis: + Builds and packages release binaries for all supported OS/architecture + combinations (Linux, macOS, Windows x64/x86/arm64) using the repository + release container (cargo-zigbuild). All archives are created in one container + run; no host Rust toolchain is required. + +Usage: + pwsh -File .\scripts\windows\commands\release.ps1 [-Runtime docker|podman] [-BuildNumber N] [-ReleaseDate YYYYMMDD] [-DryRun] [-Help] + +Artifacts: + dist\releases\release_._\ + exein_analyzer_cli_.___.zip +"@ | Write-Host +} + +function Get-Version { + $line = Get-Content -Path (Join-Path $repoRoot "Cargo.toml") | + Where-Object { $_ -match '^version = "(.+)"$' } | + Select-Object -First 1 + if (-not $line) { throw "Could not determine version from Cargo.toml." } + return ($line -replace '^version = "(.+)"$', '$1') +} + +function Get-ReleaseDate { + if ($ReleaseDate) { return $ReleaseDate } + return (Get-Date).ToString("yyyyMMdd") +} + +if ($Help) { + Show-Help + exit 0 +} + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path +$artifactDir = Join-Path $repoRoot "dist\releases" +$dockerfile = Join-Path $repoRoot "containers\release\Dockerfile" +$tag = "analyzer-cli-release:local" +$containerRuntime = Resolve-Runtime +$version = Get-Version +$stamp = Get-ReleaseDate + +if ($ReleaseDate) { + Write-Log INFO "Using release date override: $ReleaseDate" +} +Write-Log INFO "Version: $version.$BuildNumber" +Write-Log INFO "Date: $stamp" +Write-Log INFO "Runtime: $containerRuntime" + +Invoke-Step "New-Item -ItemType Directory -Force -Path '$artifactDir' | Out-Null" +Ensure-Image -Tag $tag -Dockerfile $dockerfile + +Write-Log INFO "Launching release container for all targets..." +Invoke-Step "$containerRuntime run --rm `` + -e ANALYZER_RELEASE_VERSION=`"$version`" `` + -e ANALYZER_RELEASE_BUILD=`"$BuildNumber`" `` + -e ANALYZER_RELEASE_STAMP=`"$stamp`" `` + -e ANALYZER_RELEASE_WORKSPACE=/workspace `` + -v `"${repoRoot}:/workspace`" `` + -w /workspace `` + $tag" + +Write-Log OK "Release complete — archives written to $artifactDir" diff --git a/scripts/windows/commands/test.ps1 b/scripts/windows/commands/test.ps1 new file mode 100644 index 0000000..df2f5be --- /dev/null +++ b/scripts/windows/commands/test.ps1 @@ -0,0 +1,40 @@ +#!/usr/bin/env pwsh +[CmdletBinding()] +param( + [ValidateSet("docker", "podman")] + [string]$Runtime, + [switch]$Help, + [switch]$DryRun +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +. "$PSScriptRoot\..\common\runtime.ps1" + +function Show-Help { + @" +🚀 test for Windows + +Synopsis: + Run formatting, linting, and tests inside the repository test container. + +Usage: + pwsh -File .\scripts\windows\commands\test.ps1 [-Runtime docker|podman] [-DryRun] [-Help] +"@ | Write-Host +} + +if ($Help) { + Show-Help + exit 0 +} + +$containerRuntime = Resolve-Runtime +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path +$tag = "analyzer-cli-test:local" +$dockerfile = Join-Path $repoRoot "containers\test\Dockerfile" + +Write-Log INFO "Using container runtime: $containerRuntime" +Ensure-Image -Tag $tag -Dockerfile $dockerfile +Invoke-Step "$containerRuntime run --rm -v `"${repoRoot}:/workspace`" -w /workspace $tag" +Write-Log OK "Containerized test run completed." diff --git a/scripts/windows/common/runtime.ps1 b/scripts/windows/common/runtime.ps1 new file mode 100644 index 0000000..51ad8be --- /dev/null +++ b/scripts/windows/common/runtime.ps1 @@ -0,0 +1,60 @@ +# Shared container runtime utilities dot-sourced by Windows command scripts. +# Callers must declare the $Runtime parameter and $DryRun switch before +# dot-sourcing this file. Functions reference $containerRuntime and +# $repoRoot which are set by the caller after dot-sourcing. + +function Write-Log([string]$Level, [string]$Message) { + $colors = @{ INFO = "Cyan"; OK = "Green"; WARN = "Yellow"; ERR = "Red" } + $icons = @{ INFO = "ℹ️"; OK = "✅"; WARN = "⚠️"; ERR = "❌" } + Write-Host "$($icons[$Level]) $Message" -ForegroundColor $colors[$Level] +} + +# Invoke-Step COMMAND +# Prints (dry-run) or executes a shell command string, throwing on failure. +function Invoke-Step([string]$Command) { + if ($DryRun) { + Write-Log INFO "[dry-run] $Command" + } else { + Invoke-Expression $Command + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code $LASTEXITCODE." + } + } +} + +# Resolve-Runtime +# Returns "docker" or "podman" depending on what is installed. +# Handles the case where 'docker' is a shim/alias for podman. +function Resolve-Runtime { + if ($Runtime) { return $Runtime } + + $dockerCmd = Get-Command docker -ErrorAction SilentlyContinue + $podmanCmd = Get-Command podman -ErrorAction SilentlyContinue + + if ($dockerCmd -and $podmanCmd) { + # If docker's resolved definition points at podman, prefer the real one. + $definition = try { $dockerCmd.Definition } catch { '' } + if ($definition -match 'podman') { return 'podman' } + return 'docker' + } + if ($dockerCmd) { return 'docker' } + if ($podmanCmd) { return 'podman' } + throw "Neither docker nor podman is available." +} + +function Test-ImageExists([string]$Tag) { + if ($DryRun) { return $false } + & $containerRuntime image inspect $Tag *> $null + return $LASTEXITCODE -eq 0 +} + +# Ensure-Image TAG DOCKERFILE +# Builds the image only when it does not already exist locally. +function Ensure-Image([string]$Tag, [string]$Dockerfile) { + if (Test-ImageExists $Tag) { + Write-Log INFO "Reusing container image: $Tag" + return + } + Write-Log INFO "Building container image: $Tag" + Invoke-Step "$containerRuntime build -t $Tag -f `"$Dockerfile`" `"$repoRoot`"" +} diff --git a/scripts/windows/environment/setup-dev.ps1 b/scripts/windows/environment/setup-dev.ps1 new file mode 100644 index 0000000..2d8ac34 --- /dev/null +++ b/scripts/windows/environment/setup-dev.ps1 @@ -0,0 +1,362 @@ +#!/usr/bin/env pwsh +[CmdletBinding()] +param( + [switch]$Help, + [switch]$DryRun +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$Script:StepCounter = 0 + +function Show-Help { + @" +🚀 setup-dev for Windows + +Synopsis: + Bootstrap a professional Windows developer workstation for this repository. + +Usage: + pwsh -File .\scripts\windows\setup-dev.ps1 [--dry-run] [--help] + +What it does: + • Enables Hyper-V, Virtual Machine Platform, and WSL when possible + • Updates WSL2 when installed and ensures an Ubuntu distro is available + • Installs package managers when possible: Chocolatey, Scoop + • Validates Homebrew availability and reports Windows limitations clearly + • Installs PowerShell, Visual Studio Code, Git, Git LFS, GitHub CLI, GitFlow, Rust, and Oh My Posh + • Configures Oh My Posh in the current user's PowerShell profile + • Checks Docker Desktop or Podman Desktop and installs Podman when neither is present + +Behavior: + • Idempotent: safe to run multiple times + • English output + • Colorful logs with emoji +"@ | Write-Host +} + +function Write-Log([string]$Level, [string]$Message) { + $colors = @{ + INFO = "Cyan" + OK = "Green" + WARN = "Yellow" + ERR = "Red" + STEP = "Magenta" + } + + $icons = @{ + INFO = "ℹ️" + OK = "✅" + WARN = "⚠️" + ERR = "❌" + STEP = "🚀" + } + + Write-Host "$($icons[$Level]) $Message" -ForegroundColor $colors[$Level] +} + +function Invoke-CommandSafe([string]$Command) { + if ($DryRun) { + Write-Log INFO "[dry-run] $Command" + return + } + + Invoke-Expression $Command +} + +function Test-CommandExists([string]$Name) { + return $null -ne (Get-Command $Name -ErrorAction SilentlyContinue) +} + +function Start-Step([string]$Title) { + $Script:StepCounter++ + Write-Log STEP "Step $Script:StepCounter - $Title" +} + +function Test-IsAdministrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Ensure-Winget { + if (-not (Test-CommandExists "winget")) { + throw "winget is required on Windows to install core packages." + } + + Write-Log OK "winget is available." +} + +function Ensure-WingetPackage([string]$Id, [string]$Label) { + $alreadyInstalled = winget list --id $Id --accept-source-agreements 2>$null | Select-String -SimpleMatch $Id + if ($alreadyInstalled) { + Write-Log OK "$Label is already installed." + return + } + + Write-Log INFO "Installing $Label with winget..." + Invoke-CommandSafe "winget install --id $Id --exact --accept-package-agreements --accept-source-agreements" +} + +function Ensure-Chocolatey { + if (Test-CommandExists "choco") { + Write-Log OK "Chocolatey is already installed." + return + } + + Write-Log INFO "Installing Chocolatey..." + Invoke-CommandSafe "Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))" +} + +function Ensure-Scoop { + if (Test-CommandExists "scoop") { + Write-Log OK "Scoop is already installed." + return + } + + Write-Log INFO "Installing Scoop..." + Invoke-CommandSafe "Set-ExecutionPolicy RemoteSigned -Scope CurrentUser -Force; iwr -useb get.scoop.sh | iex" +} + +function Ensure-Brew { + if (Test-CommandExists "brew") { + Write-Log OK "Homebrew is already available." + return + } + + if (Test-CommandExists "wsl") { + Write-Log WARN "Homebrew is not native on Windows. Skipping automatic install and recommending WSL-based Homebrew if needed." + return + } + + Write-Log WARN "Homebrew is not supported natively on this Windows environment. Skipping." +} + +function Ensure-WindowsOptionalFeatureEnabled([string]$FeatureName, [string]$Label) { + try { + $feature = Get-WindowsOptionalFeature -Online -FeatureName $FeatureName -ErrorAction Stop + } catch { + Write-Log WARN "$Label could not be queried on this system." + return + } + + if ($feature.State -eq "Enabled") { + Write-Log OK "$Label is already enabled." + return + } + + if (-not (Test-IsAdministrator)) { + Write-Log WARN "$Label requires an elevated PowerShell session to be enabled automatically." + return + } + + Write-Log INFO "Enabling $Label..." + if ($DryRun) { + Write-Log INFO "[dry-run] Enable-WindowsOptionalFeature -Online -FeatureName $FeatureName -All -NoRestart" + return + } + + $result = Enable-WindowsOptionalFeature -Online -FeatureName $FeatureName -All -NoRestart + if ($result.RestartNeeded) { + Write-Log WARN "$Label was enabled and Windows reported that a restart is required." + } else { + Write-Log OK "$Label was enabled." + } +} + +function Ensure-VirtualizationStack { + Ensure-WindowsOptionalFeatureEnabled "Microsoft-Hyper-V-All" "Hyper-V" + Ensure-WindowsOptionalFeatureEnabled "VirtualMachinePlatform" "Virtual Machine Platform" + Ensure-WindowsOptionalFeatureEnabled "Microsoft-Windows-Subsystem-Linux" "Windows Subsystem for Linux" + Ensure-WingetPackage "Microsoft.WSL" "WSL" +} + +function Ensure-WSLAndUbuntu { + if (-not (Test-CommandExists "wsl")) { + Write-Log WARN "WSL is not available yet. Run the script again after the WSL package and Windows features are installed." + return + } + + Write-Log INFO "Setting WSL 2 as the default version..." + Invoke-CommandSafe "wsl --set-default-version 2" + + Write-Log INFO "Updating WSL..." + try { + Invoke-CommandSafe "wsl --update" + Write-Log OK "WSL update completed." + } catch { + Write-Log WARN "WSL update could not be completed automatically: $($_.Exception.Message)" + } + + $distros = @() + try { + $distros = @(& wsl --list --quiet 2>$null) | ForEach-Object { $_.Trim() } | Where-Object { $_ } + } catch { + Write-Log WARN "Could not enumerate WSL distros yet." + } + + if ($distros -match "^Ubuntu(?:-\d+\.\d+)?$") { + Write-Log OK "An Ubuntu distro is already installed in WSL." + return + } + + Write-Log INFO "Installing Ubuntu for WSL..." + if (Test-IsAdministrator) { + try { + Invoke-CommandSafe "wsl --install -d Ubuntu" + return + } catch { + Write-Log WARN "WSL Ubuntu installation via wsl.exe failed: $($_.Exception.Message)" + } + } + + Ensure-WingetPackage "Canonical.Ubuntu.2204" "Ubuntu LTS" +} + +function Ensure-Rust { + if (Test-CommandExists "rustup") { + Write-Log OK "Rustup is already installed." + return + } + + Write-Log INFO "Installing Rust with rustup..." + $tempExe = Join-Path $env:TEMP "rustup-init.exe" + Invoke-CommandSafe "Invoke-WebRequest -Uri https://win.rustup.rs/x86_64 -OutFile '$tempExe'" + Invoke-CommandSafe "& '$tempExe' -y" +} + +function Ensure-GitFlow { + $gitFlowInstalled = $false + if (Test-CommandExists "git-flow") { + $gitFlowInstalled = $true + } else { + try { + git flow version *> $null + $gitFlowInstalled = $true + } catch { + $gitFlowInstalled = $false + } + } + + if ($gitFlowInstalled) { + Write-Log OK "GitFlow is already installed." + return + } + + if (Test-CommandExists "choco") { + Write-Log INFO "Installing GitFlow with Chocolatey..." + Invoke-CommandSafe "choco install gitflow-avh -y" + return + } + + if (Test-CommandExists "scoop") { + Write-Log INFO "Installing GitFlow with Scoop..." + Invoke-CommandSafe "scoop bucket add main" + Invoke-CommandSafe "scoop install git-flow" + return + } + + Write-Log WARN "Could not install GitFlow automatically because neither Chocolatey nor Scoop is available." +} + +function Ensure-OhMyPoshProfile { + if (-not (Test-CommandExists "oh-my-posh")) { + Write-Log WARN "Oh My Posh is not installed yet. Skipping profile setup." + return + } + + $profilePath = & pwsh -NoProfile -Command '$PROFILE.CurrentUserAllHosts' + $profileDir = Split-Path -Parent $profilePath + if (-not (Test-Path $profileDir)) { + if ($DryRun) { + Write-Log INFO "[dry-run] New-Item -ItemType Directory -Path `"$profileDir`" -Force" + } else { + New-Item -ItemType Directory -Path $profileDir -Force | Out-Null + } + } + + if (-not (Test-Path $profilePath)) { + if ($DryRun) { + Write-Log INFO "[dry-run] New-Item -ItemType File -Path `"$profilePath`" -Force" + } else { + New-Item -ItemType File -Path $profilePath -Force | Out-Null + } + } + + $profileContent = if (Test-Path $profilePath) { Get-Content $profilePath -Raw } else { "" } + $existingLine = ($profileContent -split "`r?`n" | Where-Object { $_ -match "oh-my-posh init pwsh --config" } | Select-Object -First 1) + + if ([string]::IsNullOrWhiteSpace($existingLine)) { + $themePath = '$env:POSH_THEMES_PATH\jandedobbeleer.omp.json' + $initLine = "oh-my-posh init pwsh --config `"$themePath`" | Invoke-Expression" + } else { + $initLine = $existingLine + } + + if ($profileContent -notmatch [regex]::Escape($initLine)) { + Write-Log INFO "Adding Oh My Posh initialization to the current user's PowerShell profile..." + if ($DryRun) { + Write-Log INFO "[dry-run] Add-Content -Path `"$profilePath`" -Value `"$initLine`"" + } else { + Add-Content -Path $profilePath -Value "`r`n$initLine`r`n" + } + } else { + Write-Log OK "Oh My Posh profile initialization already exists." + } + + if (-not $DryRun) { + . $profilePath + Write-Log OK "PowerShell profile reloaded." + } +} + +function Ensure-ContainerStack { + $dockerDesktop = Test-Path "$env:ProgramFiles\Docker\Docker\Docker Desktop.exe" + $podmanDesktop = Test-Path "$env:ProgramFiles\Podman Desktop\Podman Desktop.exe" + $dockerCli = Test-CommandExists "docker" + $podmanCli = Test-CommandExists "podman" + + if (($dockerDesktop -and $dockerCli) -or ($podmanDesktop -and $podmanCli)) { + Write-Log OK "A supported container desktop stack is already installed." + return + } + + Write-Log INFO "Installing Podman CLI and Podman Desktop because no supported container desktop was detected." + Ensure-WingetPackage "RedHat.Podman" "Podman CLI" + Ensure-WingetPackage "RedHat.Podman-Desktop" "Podman Desktop" +} + +if ($Help) { + Show-Help + exit 0 +} + +Start-Step "Hardening virtualization prerequisites" +Ensure-Winget +Ensure-VirtualizationStack +Ensure-WSLAndUbuntu + +Start-Step "Checking package manager prerequisites" +Ensure-Winget +Ensure-Chocolatey +Ensure-Scoop +Ensure-Brew + +Start-Step "Installing developer tools" +Ensure-WingetPackage "Microsoft.PowerShell" "PowerShell" +Ensure-WingetPackage "Microsoft.VisualStudioCode" "Visual Studio Code" +Ensure-WingetPackage "Git.Git" "Git" +Ensure-WingetPackage "GitHub.GitLFS" "Git LFS" +Ensure-WingetPackage "GitHub.cli" "GitHub CLI" +Ensure-WingetPackage "JanDeDobbeleer.OhMyPosh" "Oh My Posh" +Ensure-Rust +Ensure-GitFlow + +Start-Step "Checking container tooling" +Ensure-ContainerStack + +Start-Step "Configuring the PowerShell profile" +Ensure-OhMyPoshProfile + +Write-Log OK "Windows development environment setup completed." diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 32a567e..df50ba4 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -5,6 +5,7 @@ use console::style; use crate::client::AnalyzerClient; use crate::config::ConfigFile; +use crate::i18n::{self, Text}; use crate::output; /// Run the `login` command — prompt for API key, validate, save. @@ -12,27 +13,10 @@ pub async fn run_login(url: Option<&str>, profile_name: Option<&str>) -> Result< let profile_name = profile_name.unwrap_or("default"); eprintln!(); - let banner = [ - r" _ ", - r" _____ _____(_)_ __ ", - r" / _ \ \/ / _ \ | '_ \ ", - r" | __/> < __/ | | | | ", - r" \___/_/\_\___|_|_| |_| ", - ]; - for line in &banner { - eprintln!(" {}", style(line).cyan().bold()); - } - eprintln!( - " {} {}", - style(" analyzer").dim(), - style(format!("v{}", env!("CARGO_PKG_VERSION"))).dim() - ); - eprintln!(); - eprintln!( - " {}", - style(format!("Configuring profile '{profile_name}'")).dim() + output::hero( + i18n::analyzer_cli(), + &i18n::configuring_profile(profile_name), ); - eprintln!(); let api_key = prompt_api_key()?; @@ -46,15 +30,13 @@ pub async fn run_login(url: Option<&str>, profile_name: Option<&str>) -> Result< // Validate eprintln!(); - output::status("", "Validating API key..."); + output::status("", i18n::validating_api_key()); let parsed_url: url::Url = url.parse()?; let client = AnalyzerClient::new(parsed_url, &api_key)?; match client.health().await { - Ok(_) => output::success("Key accepted. You're in."), - Err(e) => output::warning(&format!( - "Could not validate key ({e}). Saving anyway — the server may be unreachable." - )), + Ok(_) => output::success(i18n::key_accepted()), + Err(e) => output::warning(&i18n::could_not_validate(e)), } // Save @@ -64,27 +46,12 @@ pub async fn run_login(url: Option<&str>, profile_name: Option<&str>) -> Result< config.save()?; let path = ConfigFile::path()?; - output::success(&format!("Config saved to {}", path.display())); + output::success(&i18n::config_saved(path.display())); eprintln!(); - eprintln!( - " {} Ready to hunt some vulns. Try:", - style(">").green().bold() - ); - eprintln!( - " {} {} # list your objects", - style("analyzer").bold(), - style("object list").cyan() - ); - eprintln!( - " {} {} # available scan types", - style("analyzer").bold(), - style("scan types").cyan() - ); - eprintln!( - " {} {} # start a scan", - style("analyzer").bold(), - style("scan new -h").cyan() - ); + eprintln!(" {} {}", style("🎯").green().bold(), i18n::ready_to_hunt()); + output::command_hint("object list", i18n::list_your_objects()); + output::command_hint("scan types", i18n::available_scan_types()); + output::command_hint("scan new -h", i18n::start_a_scan()); eprintln!(); Ok(()) @@ -114,29 +81,28 @@ pub fn run_whoami(api_key: Option<&str>, url: Option<&str>, profile: Option<&str let masked_key = match &resolved_key { Some(key) if key.len() > 8 => format!("{}...{}", &key[..4], &key[key.len() - 4..]), Some(key) => format!("{}...", &key[..key.len().min(4)]), - None => "(not set)".to_string(), + None => i18n::not_set_value().to_string(), }; - eprintln!("{}", style("Analyzer CLI").bold().underlined()); - eprintln!(); - eprintln!(" {:>12} {}", style("Profile:").bold(), profile_name); - eprintln!(" {:>12} {}", style("URL:").bold(), resolved_url); - eprintln!(" {:>12} {}", style("API Key:").bold(), masked_key); + output::hero(i18n::analyzer_cli(), i18n::tagline()); + output::key_value(i18n::text(Text::Profile), profile_name); + output::key_value(i18n::text(Text::Url), resolved_url); + output::key_value(i18n::text(Text::ApiKey), masked_key); if let Ok(path) = ConfigFile::path() { - eprintln!(" {:>12} {}", style("Config:").bold(), path.display()); + output::key_value(i18n::text(Text::Config), path.display()); } Ok(()) } fn prompt_api_key() -> Result { - eprint!(" Enter your API key: "); + eprint!(" 🔑 {} ", i18n::enter_api_key()); let mut key = String::new(); std::io::stdin().read_line(&mut key)?; let key = key.trim().to_string(); if key.is_empty() { - anyhow::bail!("API key cannot be empty"); + anyhow::bail!(i18n::api_key_cannot_be_empty()); } Ok(key) } diff --git a/src/commands/config.rs b/src/commands/config.rs index d0f17f3..f2752ce 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -1,44 +1,36 @@ //! Configuration management commands. use anyhow::Result; -use console::style; use crate::config::ConfigFile; +use crate::i18n::{self, Text}; use crate::output; /// Show the current configuration. pub fn run_show() -> Result<()> { let config = ConfigFile::load().unwrap_or_default(); - eprintln!( - "{}", - style("Analyzer CLI Configuration").bold().underlined() - ); - eprintln!(); - eprintln!( - " {:>16} {}", - style("Default profile:").bold(), - config.default_profile - ); + output::hero(i18n::analyzer_cli_configuration(), i18n::tagline()); + output::key_value(i18n::text(Text::DefaultProfile), &config.default_profile); if let Ok(path) = ConfigFile::path() { - eprintln!(" {:>16} {}", style("Config file:").bold(), path.display()); + output::key_value(i18n::text(Text::ConfigFile), path.display()); } if config.profiles.is_empty() { - eprintln!("\n No profiles configured. Run: analyzer login"); + eprintln!("\n {}", i18n::no_profiles_configured()); } else { - eprintln!("\n {}", style("Profiles:").bold()); + eprintln!("\n 📚 {}", i18n::text(Text::Profiles)); for (name, profile) in &config.profiles { - let url = profile.url.as_deref().unwrap_or("(default)"); + let url = profile.url.as_deref().unwrap_or(i18n::default_value()); let key_status = if profile.api_key.is_some() { - "set" + i18n::value_set() } else { - "not set" + i18n::value_not_set() }; eprintln!( " {} -- URL: {}, API key: {}", - style(name).cyan(), + console::style(name).cyan(), url, key_status ); @@ -67,14 +59,12 @@ pub fn run_set(key: &str, value: &str, profile: Option<&str>) -> Result<()> { config.default_profile = value.to_string(); } other => { - anyhow::bail!( - "Unknown config key: {other}\n\nValid keys: url, api-key, default-profile" - ); + anyhow::bail!(i18n::unknown_config_key(other)); } } config.save()?; - output::success(&format!("Set {key} = {value} (profile: {profile_name})")); + output::success(&i18n::set_config_value(key, value, profile_name)); Ok(()) } @@ -85,19 +75,17 @@ pub fn run_get(key: &str, profile: Option<&str>) -> Result<()> { let p = config.profile(Some(profile_name)); let value = match key { - "url" => p.url.as_deref().unwrap_or("(not set)"), + "url" => p.url.as_deref().unwrap_or(i18n::not_set_value()), "api-key" | "api_key" => { if p.api_key.is_some() { - "(set)" + i18n::value_set() } else { - "(not set)" + i18n::not_set_value() } } "default-profile" | "default_profile" => &config.default_profile, other => { - anyhow::bail!( - "Unknown config key: {other}\n\nValid keys: url, api-key, default-profile" - ); + anyhow::bail!(i18n::unknown_config_key(other)); } }; diff --git a/src/commands/object.rs b/src/commands/object.rs index d9d4a64..0c05d16 100644 --- a/src/commands/object.rs +++ b/src/commands/object.rs @@ -6,6 +6,7 @@ use uuid::Uuid; use crate::client::AnalyzerClient; use crate::client::models::CreateObject; +use crate::i18n::{self, Text}; use crate::output::{self, Format}; /// List all objects. @@ -22,20 +23,17 @@ pub async fn run_list(client: &AnalyzerClient, format: Format) -> Result<()> { } Format::Human | Format::Table => { if objects.is_empty() { - output::status( - "Objects", - "None found. Create one with: analyzer object new ", - ); + output::status(i18n::text(Text::Objects), i18n::objects_empty()); return Ok(()); } eprintln!(); eprintln!( " {:<36} {:<30} {:<5} {}", - style("ID").underlined(), - style("Name").underlined(), - style("Score").underlined(), - style("Description").underlined(), + style(i18n::text(Text::Id)).underlined(), + style(i18n::text(Text::Name)).underlined(), + style(i18n::text(Text::Score)).underlined(), + style(i18n::text(Text::Description)).underlined(), ); for obj in &objects { let score = obj @@ -115,7 +113,7 @@ pub async fn run_new( ); } Format::Human | Format::Table => { - output::success(&format!("Created object '{}' ({})", object.name, object.id)); + output::success(&i18n::created_object(&object.name, object.id)); } } Ok(()) @@ -124,6 +122,6 @@ pub async fn run_new( /// Delete an object. pub async fn run_delete(client: &AnalyzerClient, id: Uuid) -> Result<()> { client.delete_object(id).await?; - output::success(&format!("Deleted object {id}")); + output::success(&i18n::deleted_object(id)); Ok(()) } diff --git a/src/commands/scan.rs b/src/commands/scan.rs index d93dd7b..152bb0a 100644 --- a/src/commands/scan.rs +++ b/src/commands/scan.rs @@ -14,6 +14,7 @@ use crate::client::models::{ ComplianceType, CryptoFinding, CveFinding, HardeningFinding, IdfSymbolFinding, IdfTaskFinding, KernelFinding, MalwareFinding, PasswordFinding, ResultsQuery, SbomComponent, ScanTypeRequest, }; +use crate::i18n::{self, Text}; use crate::output::{self, Format, format_score, format_status}; /// Resolve a scan ID from either an explicit --scan or an --object flag. @@ -75,12 +76,11 @@ pub async fn run_new( println!("{}", serde_json::json!({ "id": resp.id })); } _ if !wait => { - output::success(&format!("Scan {} created", style(resp.id).bold())); - eprintln!( - "\n Check status:\n {} {} --object {}", - style("analyzer").bold(), - style("scan status").cyan(), - object_id, + output::success(&i18n::scan_created(resp.id)); + eprintln!(); + output::command_hint( + &format!("scan status --object {object_id}"), + &i18n::check_status_command(object_id), ); } _ => {} @@ -97,14 +97,14 @@ pub async fn run_new( /// Delete a scan. pub async fn run_delete(client: &AnalyzerClient, id: Uuid) -> Result<()> { client.delete_scan(id).await?; - output::success(&format!("Deleted scan {id}")); + output::success(&i18n::deleted_scan(id)); Ok(()) } /// Cancel a running scan. pub async fn run_cancel(client: &AnalyzerClient, id: Uuid) -> Result<()> { client.cancel_scan(id).await?; - output::success(&format!("Cancelled scan {id}")); + output::success(&i18n::cancelled_scan(id)); Ok(()) } @@ -126,10 +126,10 @@ pub async fn run_report( if wait { wait_for_completion(client, scan_id, interval, timeout).await?; } - output::status("Downloading", "PDF report..."); + output::status("", i18n::downloading_pdf_report()); let bytes = client.download_report(scan_id).await?; tokio::fs::write(&output_path, &bytes).await?; - output::success(&format!("Report saved to {}", output_path.display())); + output::success(&i18n::report_saved(output_path.display())); Ok(()) } @@ -145,10 +145,10 @@ pub async fn run_sbom( if wait { wait_for_completion(client, scan_id, interval, timeout).await?; } - output::status("Downloading", "SBOM..."); + output::status("", i18n::downloading_sbom()); let bytes = client.download_sbom(scan_id).await?; tokio::fs::write(&output_path, &bytes).await?; - output::success(&format!("SBOM saved to {}", output_path.display())); + output::success(&i18n::sbom_saved(output_path.display())); Ok(()) } @@ -165,16 +165,12 @@ pub async fn run_compliance_report( if wait { wait_for_completion(client, scan_id, interval, timeout).await?; } - output::status( - "Downloading", - &format!("{} compliance report...", ct.display_name()), - ); + output::status("", &i18n::downloading_compliance_report(ct.display_name())); let bytes = client.download_compliance_report(scan_id, ct).await?; tokio::fs::write(&output_path, &bytes).await?; - output::success(&format!( - "{} report saved to {}", + output::success(&i18n::compliance_report_saved( ct.display_name(), - output_path.display() + output_path.display(), )); Ok(()) } @@ -193,26 +189,18 @@ pub async fn run_score(client: &AnalyzerClient, scan_id: Uuid, format: Format) - Format::Human | Format::Table => { eprintln!( "\n {} {}", - style("Overall Score:").bold(), + style(format!("{}:", i18n::text(Text::OverallScore))).bold(), format_score(score.score) ); if !score.scores.is_empty() { eprintln!(); eprintln!( " {:<20} {}", - style("Analysis").underlined(), - style("Score").underlined(), + style(i18n::text(Text::Analysis)).underlined(), + style(i18n::text(Text::Score)).underlined(), ); for s in &score.scores { - let score_str = format!("{:<5}", s.score); - let score_styled = if s.score >= 80 { - style(score_str).green().to_string() - } else if s.score >= 50 { - style(score_str).yellow().to_string() - } else { - style(score_str).red().to_string() - }; - eprintln!(" {:<20} {}", s.analysis_type, score_styled); + eprintln!(" {:<20} {}", s.analysis_type, format_score(Some(s.score))); } } eprintln!(); @@ -237,7 +225,9 @@ pub async fn run_types(client: &AnalyzerClient, format: Format) -> Result<()> { eprintln!("\n {}", style(&st.image_type).bold().underlined()); for a in &st.analyses { let marker = if a.default { - style(" (default)").dim().to_string() + style(format!(" ({})", i18n::text(Text::Default))) + .dim() + .to_string() } else { String::new() }; @@ -285,7 +275,7 @@ fn print_status( Format::Human | Format::Table => { eprintln!( "\n {} {} ({})", - style("Scan").bold(), + style(i18n::text(Text::Scan)).bold(), scan_id, format_status(&status.status.to_string()), ); @@ -304,8 +294,8 @@ fn print_status( eprintln!(); eprintln!( " {:<20} {}", - style("Analysis").underlined(), - style("Status").underlined(), + style(i18n::text(Text::Analysis)).underlined(), + style(i18n::text(Text::Status)).underlined(), ); for (key, entry) in &entries { eprintln!( @@ -337,7 +327,7 @@ async fn wait_for_completion( .tick_strings(&[" ", ". ", ".. ", "...", " ..", " .", " "]), ); spinner.enable_steady_tick(Duration::from_millis(120)); - spinner.set_message("Waiting for scan to complete..."); + spinner.set_message(i18n::waiting_for_scan()); loop { let status = client.get_scan_status(scan_id).await?; @@ -345,40 +335,32 @@ async fn wait_for_completion( match status.status { AnalysisStatus::Success => { spinner.finish_and_clear(); - output::success("Scan completed successfully!"); + output::success(i18n::scan_completed_successfully()); return Ok(status); } AnalysisStatus::Error => { spinner.finish_and_clear(); - bail!("Scan failed with error status"); + bail!(i18n::scan_failed_with_error_status()); } AnalysisStatus::Canceled => { spinner.finish_and_clear(); - bail!("Scan was cancelled"); + bail!(i18n::scan_was_cancelled()); } _ => { let mut parts = Vec::new(); for (key, val) in &status.analyses { if let Ok(entry) = serde_json::from_value::(val.clone()) { - let icon = match entry.status { - AnalysisStatus::Success => "done", - AnalysisStatus::InProgress => "running", - AnalysisStatus::Pending => "queued", - _ => "?", - }; + let icon = i18n::progress_word(&entry.status.to_string()); parts.push(format!("{key}: {icon}")); } } - spinner.set_message(format!("Analyzing... [{}]", parts.join(", "))); + spinner.set_message(i18n::analyzing(&parts.join(", "))); } } if tokio::time::Instant::now() >= deadline { spinner.finish_and_clear(); - bail!( - "Timed out waiting for scan to complete ({}s)", - timeout.as_secs() - ); + bail!(i18n::timed_out_waiting_for_scan(timeout.as_secs())); } tokio::time::sleep(interval).await; @@ -398,11 +380,15 @@ pub async fn run_overview(client: &AnalyzerClient, scan_id: Uuid, format: Format println!("{}", serde_json::to_string_pretty(&overview)?); } Format::Human | Format::Table => { - eprintln!("\n {} {}\n", style("Scan Overview").bold(), scan_id); + eprintln!("\n {} {}\n", style(i18n::text(Text::Scan)).bold(), scan_id); if let Some(cve) = &overview.cve { let c = &cve.counts; - eprintln!(" {} ({})", style("CVE Vulnerabilities").bold(), cve.total); + eprintln!( + " {} ({})", + style(i18n::text(Text::CveVulnerabilities)).bold(), + cve.total + ); eprintln!( " Critical: {} High: {} Medium: {} Low: {} Unknown: {}", style(c.critical).red(), @@ -413,14 +399,26 @@ pub async fn run_overview(client: &AnalyzerClient, scan_id: Uuid, format: Format ); } if let Some(m) = &overview.malware { - eprintln!(" {}: {}", style("Malware Detections").bold(), m.count); + eprintln!( + " {}: {}", + style(i18n::text(Text::MalwareDetections)).bold(), + m.count + ); } if let Some(p) = &overview.password_hash { - eprintln!(" {}: {}", style("Password Issues").bold(), p.count); + eprintln!( + " {}: {}", + style(i18n::text(Text::PasswordIssues)).bold(), + p.count + ); } if let Some(h) = &overview.hardening { let c = &h.counts; - eprintln!(" {} ({})", style("Hardening Issues").bold(), h.total); + eprintln!( + " {} ({})", + style(i18n::text(Text::HardeningIssues)).bold(), + h.total + ); eprintln!( " High: {} Medium: {} Low: {}", style(c.high).red(), @@ -431,7 +429,7 @@ pub async fn run_overview(client: &AnalyzerClient, scan_id: Uuid, format: Format if let Some(cap) = &overview.capabilities { eprintln!( " {} ({} executables)", - style("Capabilities").bold(), + style(i18n::text(Text::Capabilities)).bold(), cap.executable_count ); let c = &cap.counts; @@ -446,7 +444,7 @@ pub async fn run_overview(client: &AnalyzerClient, scan_id: Uuid, format: Format if let Some(cr) = &overview.crypto { eprintln!( " {}: {} certs, {} public keys, {} private keys", - style("Crypto").bold(), + style(i18n::text(Text::Crypto)).bold(), cr.certificates, cr.public_keys, cr.private_keys, @@ -455,22 +453,30 @@ pub async fn run_overview(client: &AnalyzerClient, scan_id: Uuid, format: Format if let Some(sbom) = &overview.software_bom { eprintln!( " {}: {} components", - style("Software BOM").bold(), + style(i18n::text(Text::SoftwareBom)).bold(), sbom.count ); } if let Some(k) = &overview.kernel { - eprintln!(" {}: {} configs", style("Kernel").bold(), k.count); + eprintln!( + " {}: {} configs", + style(i18n::text(Text::Kernel)).bold(), + k.count + ); } if let Some(s) = &overview.symbols { - eprintln!(" {}: {}", style("Symbols").bold(), s.count); + eprintln!(" {}: {}", style(i18n::text(Text::Symbols)).bold(), s.count); } if let Some(t) = &overview.tasks { - eprintln!(" {}: {}", style("Tasks").bold(), t.count); + eprintln!(" {}: {}", style(i18n::text(Text::Tasks)).bold(), t.count); } if let Some(so) = &overview.stack_overflow { if let Some(method) = &so.method { - eprintln!(" {}: {}", style("Stack Overflow").bold(), method); + eprintln!( + " {}: {}", + style(i18n::text(Text::StackOverflow)).bold(), + method + ); } } eprintln!(); @@ -545,51 +551,49 @@ pub async fn run_results( println!("{}", serde_json::to_string_pretty(&results)?); } Format::Human | Format::Table => { - let all_values: Vec<&serde_json::Value> = results.findings.iter().collect(); - - if all_values.is_empty() { - eprintln!("\n No findings.\n"); + if results.findings.is_empty() { + eprintln!("\n {}\n", i18n::no_findings()); return Ok(()); } match analysis_type { - AnalysisType::Cve => render_cve_table(&all_values)?, - AnalysisType::PasswordHash => render_password_table(&all_values)?, - AnalysisType::Malware => render_malware_table(&all_values)?, - AnalysisType::Hardening => render_hardening_table(&all_values)?, - AnalysisType::Capabilities => render_capabilities_table(&all_values)?, - AnalysisType::Crypto => render_crypto_table(&all_values)?, - AnalysisType::SoftwareBom => render_sbom_table(&all_values)?, - AnalysisType::Kernel => render_kernel_table(&all_values)?, - AnalysisType::Symbols => render_symbols_table(&all_values)?, - AnalysisType::Tasks => render_tasks_table(&all_values)?, - AnalysisType::Info => render_info(&all_values)?, - AnalysisType::StackOverflow => render_info(&all_values)?, + AnalysisType::Cve => render_cve_table(&results.findings)?, + AnalysisType::PasswordHash => render_password_table(&results.findings)?, + AnalysisType::Malware => render_malware_table(&results.findings)?, + AnalysisType::Hardening => render_hardening_table(&results.findings)?, + AnalysisType::Capabilities => render_capabilities_table(&results.findings)?, + AnalysisType::Crypto => render_crypto_table(&results.findings)?, + AnalysisType::SoftwareBom => render_sbom_table(&results.findings)?, + AnalysisType::Kernel => render_kernel_table(&results.findings)?, + AnalysisType::Symbols => render_symbols_table(&results.findings)?, + AnalysisType::Tasks => render_tasks_table(&results.findings)?, + AnalysisType::Info => render_info(&results.findings)?, + AnalysisType::StackOverflow => render_info(&results.findings)?, } let total_pages = results.total_findings.div_ceil(per_page as u64); eprintln!( - "\n Page {}/{} ({} total) — use --page N to navigate\n", - page, total_pages, results.total_findings, + "\n {}\n", + i18n::page_navigation(page, total_pages, results.total_findings) ); } } Ok(()) } -fn render_cve_table(values: &[&serde_json::Value]) -> Result<()> { +fn render_cve_table(values: &[serde_json::Value]) -> Result<()> { eprintln!(); eprintln!( " {:<8} {:<15} {:<5} {:<14} {:<20} {}", - style("Severity").underlined(), + style(i18n::text(Text::Severity)).underlined(), style("CVE ID").underlined(), - style("Score").underlined(), - style("Vendor").underlined(), - style("Product").underlined(), - style("Summary").underlined(), + style(i18n::text(Text::Score)).underlined(), + style(i18n::text(Text::Vendor)).underlined(), + style(i18n::text(Text::Product)).underlined(), + style(i18n::text(Text::Summary)).underlined(), ); for val in values { - if let Ok(f) = serde_json::from_value::((*val).clone()) { + if let Ok(f) = serde_json::from_value::(val.clone()) { let score_str = f .cvss .as_ref() @@ -603,12 +607,6 @@ fn render_cve_table(values: &[&serde_json::Value]) -> Result<()> { .first() .and_then(|p| p.product.as_deref()) .unwrap_or("-"); - let summary = f.summary.as_deref().unwrap_or(""); - let summary_trunc = if summary.len() > 40 { - format!("{}...", &summary[..37]) - } else { - summary.to_string() - }; eprintln!( " {} {:<15} {:<5} {:<14} {:<20} {}", sev, @@ -616,23 +614,23 @@ fn render_cve_table(values: &[&serde_json::Value]) -> Result<()> { score_str, truncate_str(f.vendor.as_deref().unwrap_or("-"), 14), truncate_str(product, 20), - summary_trunc, + truncate_str(f.summary.as_deref().unwrap_or(""), 40), ); } } Ok(()) } -fn render_password_table(values: &[&serde_json::Value]) -> Result<()> { +fn render_password_table(values: &[serde_json::Value]) -> Result<()> { eprintln!(); eprintln!( " {:<8} {:<20} {}", - style("Severity").underlined(), - style("Username").underlined(), - style("Password").underlined(), + style(i18n::text(Text::Severity)).underlined(), + style(i18n::text(Text::Username)).underlined(), + style(i18n::text(Text::Password)).underlined(), ); for val in values { - if let Ok(f) = serde_json::from_value::((*val).clone()) { + if let Ok(f) = serde_json::from_value::(val.clone()) { let sev = format_severity(f.severity.as_deref().unwrap_or("unknown"), 8); eprintln!( " {} {:<20} {}", @@ -645,16 +643,16 @@ fn render_password_table(values: &[&serde_json::Value]) -> Result<()> { Ok(()) } -fn render_malware_table(values: &[&serde_json::Value]) -> Result<()> { +fn render_malware_table(values: &[serde_json::Value]) -> Result<()> { eprintln!(); eprintln!( " {:<30} {:<40} {}", - style("Filename").underlined(), - style("Description").underlined(), - style("Engine").underlined(), + style(i18n::text(Text::Filename)).underlined(), + style(i18n::text(Text::Description)).underlined(), + style(i18n::text(Text::Engine)).underlined(), ); for val in values { - if let Ok(f) = serde_json::from_value::((*val).clone()) { + if let Ok(f) = serde_json::from_value::(val.clone()) { eprintln!( " {:<30} {:<40} {}", truncate_str(f.filename.as_deref().unwrap_or("-"), 30), @@ -666,20 +664,20 @@ fn render_malware_table(values: &[&serde_json::Value]) -> Result<()> { Ok(()) } -fn render_hardening_table(values: &[&serde_json::Value]) -> Result<()> { +fn render_hardening_table(values: &[serde_json::Value]) -> Result<()> { eprintln!(); eprintln!( " {:<8} {:<30} {:<6} {:<3} {:<7} {:<7} {}", - style("Severity").underlined(), - style("Filename").underlined(), - style("Canary").underlined(), - style("NX").underlined(), - style("PIE").underlined(), - style("RELRO").underlined(), - style("Fortify").underlined(), + style(i18n::text(Text::Severity)).underlined(), + style(i18n::text(Text::Filename)).underlined(), + style(i18n::text(Text::Canary)).underlined(), + style(i18n::text(Text::Nx)).underlined(), + style(i18n::text(Text::Pie)).underlined(), + style(i18n::text(Text::Relro)).underlined(), + style(i18n::text(Text::Fortify)).underlined(), ); for val in values { - if let Ok(f) = serde_json::from_value::((*val).clone()) { + if let Ok(f) = serde_json::from_value::(val.clone()) { let sev = format_severity(f.severity.as_deref().unwrap_or("unknown"), 8); eprintln!( " {} {:<30} {} {} {:<7} {:<7} {}", @@ -696,17 +694,17 @@ fn render_hardening_table(values: &[&serde_json::Value]) -> Result<()> { Ok(()) } -fn render_capabilities_table(values: &[&serde_json::Value]) -> Result<()> { +fn render_capabilities_table(values: &[serde_json::Value]) -> Result<()> { eprintln!(); eprintln!( " {:<30} {:<8} {:<9} {}", - style("Filename").underlined(), - style("Severity").underlined(), - style("Behaviors").underlined(), - style("Syscalls").underlined(), + style(i18n::text(Text::Filename)).underlined(), + style(i18n::text(Text::Severity)).underlined(), + style(i18n::text(Text::Behaviors)).underlined(), + style(i18n::text(Text::Syscalls)).underlined(), ); for val in values { - if let Ok(f) = serde_json::from_value::((*val).clone()) { + if let Ok(f) = serde_json::from_value::(val.clone()) { let sev = format_severity(f.level.as_deref().unwrap_or("unknown"), 8); eprintln!( " {:<30} {} {:<9} {}", @@ -754,18 +752,18 @@ fn format_bool(val: bool, width: usize) -> String { } } -fn render_crypto_table(values: &[&serde_json::Value]) -> Result<()> { +fn render_crypto_table(values: &[serde_json::Value]) -> Result<()> { eprintln!(); eprintln!( " {:<14} {:<20} {:<20} {:<8} {}", - style("Type").underlined(), - style("Filename").underlined(), + style(i18n::text(Text::Type)).underlined(), + style(i18n::text(Text::Filename)).underlined(), style("Path").underlined(), - style("Key Size").underlined(), - style("Aux").underlined(), + style(i18n::text(Text::KeySize)).underlined(), + style(i18n::text(Text::Aux)).underlined(), ); for val in values { - if let Ok(f) = serde_json::from_value::((*val).clone()) { + if let Ok(f) = serde_json::from_value::(val.clone()) { let aux = if f.aux.is_empty() { "-".to_string() } else { @@ -784,17 +782,17 @@ fn render_crypto_table(values: &[&serde_json::Value]) -> Result<()> { Ok(()) } -fn render_sbom_table(values: &[&serde_json::Value]) -> Result<()> { +fn render_sbom_table(values: &[serde_json::Value]) -> Result<()> { eprintln!(); eprintln!( " {:<30} {:<14} {:<12} {}", - style("Name").underlined(), - style("Version").underlined(), - style("Type").underlined(), - style("Licenses").underlined(), + style(i18n::text(Text::Name)).underlined(), + style(i18n::text(Text::Version)).underlined(), + style(i18n::text(Text::Type)).underlined(), + style(i18n::text(Text::Licenses)).underlined(), ); for val in values { - if let Ok(f) = serde_json::from_value::((*val).clone()) { + if let Ok(f) = serde_json::from_value::(val.clone()) { let licenses = f .licenses .iter() @@ -818,20 +816,24 @@ fn render_sbom_table(values: &[&serde_json::Value]) -> Result<()> { Ok(()) } -fn render_kernel_table(values: &[&serde_json::Value]) -> Result<()> { +fn render_kernel_table(values: &[serde_json::Value]) -> Result<()> { for val in values { - if let Ok(f) = serde_json::from_value::((*val).clone()) { + if let Ok(f) = serde_json::from_value::(val.clone()) { if let Some(file) = &f.file { - eprintln!("\n {} {}", style("Kernel Config:").bold(), file); + eprintln!( + "\n {} {}", + style(format!("{}:", i18n::text(Text::KernelConfig))).bold(), + file + ); } - if let Some(score) = f.score { - eprintln!(" Score: {}", score); + if let Some(score_value) = f.score { + eprintln!(" {}: {}", i18n::text(Text::Score), score_value); } eprintln!(); eprintln!( " {:<40} {}", - style("Feature").underlined(), - style("Status").underlined(), + style(i18n::text(Text::Feature)).underlined(), + style(i18n::text(Text::Status)).underlined(), ); for feat in &f.features { eprintln!(" {:<40} {}", feat.name, format_bool(feat.enabled, 8),); @@ -841,16 +843,16 @@ fn render_kernel_table(values: &[&serde_json::Value]) -> Result<()> { Ok(()) } -fn render_symbols_table(values: &[&serde_json::Value]) -> Result<()> { +fn render_symbols_table(values: &[serde_json::Value]) -> Result<()> { eprintln!(); eprintln!( " {:<40} {:<12} {}", - style("Name").underlined(), - style("Type").underlined(), - style("Bind").underlined(), + style(i18n::text(Text::Name)).underlined(), + style(i18n::text(Text::Type)).underlined(), + style(i18n::text(Text::Bind)).underlined(), ); for val in values { - if let Ok(f) = serde_json::from_value::((*val).clone()) { + if let Ok(f) = serde_json::from_value::(val.clone()) { eprintln!( " {:<40} {:<12} {}", truncate_str(f.symbol_name.as_deref().unwrap_or("-"), 40), @@ -862,15 +864,15 @@ fn render_symbols_table(values: &[&serde_json::Value]) -> Result<()> { Ok(()) } -fn render_tasks_table(values: &[&serde_json::Value]) -> Result<()> { +fn render_tasks_table(values: &[serde_json::Value]) -> Result<()> { eprintln!(); eprintln!( " {:<30} {}", - style("Name").underlined(), - style("Function").underlined(), + style(i18n::text(Text::Name)).underlined(), + style(i18n::text(Text::Function)).underlined(), ); for val in values { - if let Ok(f) = serde_json::from_value::((*val).clone()) { + if let Ok(f) = serde_json::from_value::(val.clone()) { eprintln!( " {:<30} {}", truncate_str(f.task_name.as_deref().unwrap_or("-"), 30), @@ -881,7 +883,7 @@ fn render_tasks_table(values: &[&serde_json::Value]) -> Result<()> { Ok(()) } -fn render_info(values: &[&serde_json::Value]) -> Result<()> { +fn render_info(values: &[serde_json::Value]) -> Result<()> { for val in values { eprintln!("\n{}", serde_json::to_string_pretty(val)?); } diff --git a/src/config.rs b/src/config.rs index 239af9d..7a3435a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -50,6 +50,10 @@ fn default_profile_name() -> String { impl ConfigFile { /// Path to the config file. pub fn path() -> Result { + if let Ok(dir) = std::env::var("ANALYZER_CONFIG_DIR") { + return Ok(PathBuf::from(dir).join(CONFIG_FILE_NAME)); + } + let dir = dirs::config_dir() .context("could not determine config directory")? .join(CONFIG_DIR_NAME); diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..b31d53a --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,1574 @@ +use std::borrow::Cow; +use std::fmt::Display; +use std::sync::{OnceLock, RwLock}; + +use clap::ValueEnum; + +#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, ValueEnum)] +pub enum Language { + #[default] + #[value(name = "en", alias = "english")] + English, + #[value(name = "fr", alias = "french")] + French, + #[value(name = "de", alias = "german")] + German, + #[value(name = "nl", alias = "dutch")] + Dutch, + #[value(name = "es", alias = "spanish")] + Spanish, + #[value(name = "pt", alias = "portuguese")] + Portuguese, + #[value(name = "zh", alias = "chinese")] + Chinese, + #[value(name = "ko", alias = "korean")] + Korean, + #[value(name = "ar", alias = "arabic")] + Arabic, + #[value(name = "ja", alias = "japanese")] + Japanese, +} + +#[derive(Debug, Clone, Copy)] +pub enum Text { + Ok, + Warning, + Error, + Profile, + Url, + ApiKey, + Config, + ConfigFile, + DefaultProfile, + Profiles, + Id, + Name, + Description, + Score, + Analysis, + Status, + Type, + Version, + Licenses, + Feature, + Function, + Username, + Password, + Filename, + Engine, + Product, + Summary, + Vendor, + KeySize, + Aux, + Behaviors, + Syscalls, + Canary, + Nx, + Pie, + Relro, + Fortify, + Severity, + Objects, + Scan, + OverallScore, + Default, + CveVulnerabilities, + MalwareDetections, + PasswordIssues, + HardeningIssues, + Capabilities, + Crypto, + SoftwareBom, + Kernel, + Symbols, + Tasks, + StackOverflow, + KernelConfig, + Bind, + SuccessStatus, + PendingStatus, + InProgressStatus, + CanceledStatus, + ErrorStatus, + Running, + Queued, + Done, +} + +pub fn set_language(language: Language) { + *language_lock().write().expect("language lock poisoned") = language; +} + +pub fn language() -> Language { + *language_lock().read().expect("language lock poisoned") +} + +pub fn language_name() -> &'static str { + match language() { + Language::English => "English", + Language::French => "Francais", + Language::German => "Deutsch", + Language::Dutch => "Nederlands", + Language::Spanish => "Espanol", + Language::Portuguese => "Portugues", + Language::Chinese => "中文", + Language::Korean => "한국어", + Language::Arabic => "العربية", + Language::Japanese => "日本語", + } +} + +pub fn text(key: Text) -> &'static str { + match language() { + Language::English => text_en(key), + Language::French => text_fr(key), + Language::German => text_de(key), + Language::Dutch => text_nl(key), + Language::Spanish => text_es(key), + Language::Portuguese => text_pt(key), + Language::Chinese => text_zh(key), + Language::Korean => text_ko(key), + Language::Arabic => text_ar(key), + Language::Japanese => text_ja(key), + } +} + +pub fn tagline() -> &'static str { + match language() { + Language::English => "Secure every artifact", + Language::French => "Securisez chaque artefact", + Language::German => "Sichern Sie jedes Artefakt", + Language::Dutch => "Beveilig elk artefact", + Language::Spanish => "Protege cada artefacto", + Language::Portuguese => "Proteja cada artefato", + Language::Chinese => "为每个制品提供安全保障", + Language::Korean => "모든 아티팩트를 안전하게 보호하세요", + Language::Arabic => "أمّن كل أصل برمجي", + Language::Japanese => "すべての成果物を安全に", + } +} + +pub fn subtitle() -> String { + match language() { + Language::English => format!( + "Copilot-style terminal theme • active language: {}", + language_name() + ), + Language::French => format!( + "Theme terminal moderne • langue active : {}", + language_name() + ), + Language::German => format!( + "Modernes Terminal-Theme • aktive Sprache: {}", + language_name() + ), + Language::Dutch => format!("Modern terminalthema • actieve taal: {}", language_name()), + Language::Spanish => format!( + "Tema de terminal moderno • idioma activo: {}", + language_name() + ), + Language::Portuguese => format!( + "Tema moderno de terminal • idioma ativo: {}", + language_name() + ), + Language::Chinese => format!("现代终端主题 • 当前语言:{}", language_name()), + Language::Korean => format!("모던 터미널 테마 • 현재 언어: {}", language_name()), + Language::Arabic => format!("سمة طرفية حديثة • اللغة النشطة: {}", language_name()), + Language::Japanese => format!("モダンなターミナルテーマ • 現在の言語: {}", language_name()), + } +} + +pub fn analyzer_cli() -> &'static str { + "Analyzer CLI" +} + +pub fn analyzer_cli_configuration() -> &'static str { + match language() { + Language::English => "Analyzer CLI Configuration", + Language::French => "Configuration d'Analyzer CLI", + Language::German => "Analyzer CLI Konfiguration", + Language::Dutch => "Analyzer CLI-configuratie", + Language::Spanish => "Configuracion de Analyzer CLI", + Language::Portuguese => "Configuracao do Analyzer CLI", + Language::Chinese => "Analyzer CLI 配置", + Language::Korean => "Analyzer CLI 구성", + Language::Arabic => "إعدادات Analyzer CLI", + Language::Japanese => "Analyzer CLI の設定", + } +} + +pub fn configuring_profile(profile: &str) -> String { + match language() { + Language::English => format!("Configuring profile '{profile}'"), + Language::French => format!("Configuration du profil '{profile}'"), + Language::German => format!("Profil '{profile}' wird konfiguriert"), + Language::Dutch => format!("Profiel '{profile}' wordt geconfigureerd"), + Language::Spanish => format!("Configurando el perfil '{profile}'"), + Language::Portuguese => format!("Configurando o perfil '{profile}'"), + Language::Chinese => format!("正在配置配置文件“{profile}”"), + Language::Korean => format!("'{profile}' 프로필을 구성하는 중입니다"), + Language::Arabic => format!("جارٍ إعداد الملف الشخصي '{profile}'"), + Language::Japanese => format!("プロファイル '{profile}' を設定中"), + } +} + +pub fn enter_api_key() -> &'static str { + match language() { + Language::English => "Enter your API key:", + Language::French => "Saisissez votre cle API :", + Language::German => "API-Schlussel eingeben:", + Language::Dutch => "Voer uw API-sleutel in:", + Language::Spanish => "Introduce tu clave API:", + Language::Portuguese => "Informe sua chave de API:", + Language::Chinese => "请输入您的 API 密钥:", + Language::Korean => "API 키를 입력하세요:", + Language::Arabic => "أدخل مفتاح API:", + Language::Japanese => "API キーを入力してください:", + } +} + +pub fn api_key_cannot_be_empty() -> &'static str { + match language() { + Language::English => "API key cannot be empty", + Language::French => "La cle API ne peut pas etre vide", + Language::German => "Der API-Schlussel darf nicht leer sein", + Language::Dutch => "De API-sleutel mag niet leeg zijn", + Language::Spanish => "La clave API no puede estar vacia", + Language::Portuguese => "A chave de API nao pode estar vazia", + Language::Chinese => "API 密钥不能为空", + Language::Korean => "API 키는 비워 둘 수 없습니다", + Language::Arabic => "لا يمكن أن يكون مفتاح API فارغًا", + Language::Japanese => "API キーは空にできません", + } +} + +pub fn validating_api_key() -> &'static str { + match language() { + Language::English => "Validating API key...", + Language::French => "Validation de la cle API...", + Language::German => "API-Schlussel wird gepruft...", + Language::Dutch => "API-sleutel wordt gevalideerd...", + Language::Spanish => "Validando la clave API...", + Language::Portuguese => "Validando a chave de API...", + Language::Chinese => "正在验证 API 密钥...", + Language::Korean => "API 키를 확인하는 중입니다...", + Language::Arabic => "جارٍ التحقق من مفتاح API...", + Language::Japanese => "API キーを検証しています...", + } +} + +pub fn key_accepted() -> &'static str { + match language() { + Language::English => "Key accepted. You're in.", + Language::French => "Cle acceptee. Vous etes connecte.", + Language::German => "Schlussel akzeptiert. Zugriff gewahrt.", + Language::Dutch => "Sleutel geaccepteerd. U bent binnen.", + Language::Spanish => "Clave aceptada. Ya estas dentro.", + Language::Portuguese => "Chave aceita. Acesso liberado.", + Language::Chinese => "密钥已接受,登录成功。", + Language::Korean => "키가 승인되었습니다. 접속 완료.", + Language::Arabic => "تم قبول المفتاح. تم تسجيل الدخول.", + Language::Japanese => "キーが受け入れられました。準備完了です。", + } +} + +pub fn could_not_validate(error: E) -> String { + match language() { + Language::English => format!( + "Could not validate key ({error}). Saving anyway - the server may be unreachable." + ), + Language::French => format!( + "Impossible de valider la cle ({error}). Enregistrement quand meme - le serveur est peut-etre indisponible." + ), + Language::German => format!( + "Schlussel konnte nicht gepruft werden ({error}). Er wird trotzdem gespeichert - der Server ist moglicherweise nicht erreichbar." + ), + Language::Dutch => format!( + "De sleutel kon niet worden gevalideerd ({error}). Toch opgeslagen - de server is mogelijk onbereikbaar." + ), + Language::Spanish => format!( + "No se pudo validar la clave ({error}). Se guardara igualmente - puede que el servidor no este disponible." + ), + Language::Portuguese => format!( + "Nao foi possivel validar a chave ({error}). Ela sera salva mesmo assim - talvez o servidor esteja indisponivel." + ), + Language::Chinese => format!("无法验证密钥({error})。仍将保存,服务器可能暂时不可达。"), + Language::Korean => format!( + "키를 검증할 수 없습니다 ({error}). 서버에 연결할 수 없을 수 있어도 그대로 저장합니다." + ), + Language::Arabic => format!( + "تعذر التحقق من المفتاح ({error}). سيتم حفظه على أي حال لأن الخادم قد يكون غير متاح." + ), + Language::Japanese => format!( + "キーを検証できませんでした ({error})。サーバーに到達できない可能性があるため、そのまま保存します。" + ), + } +} + +pub fn config_saved(path: impl Display) -> String { + match language() { + Language::English => format!("Config saved to {path}"), + Language::French => format!("Configuration enregistree dans {path}"), + Language::German => format!("Konfiguration gespeichert unter {path}"), + Language::Dutch => format!("Configuratie opgeslagen in {path}"), + Language::Spanish => format!("Configuracion guardada en {path}"), + Language::Portuguese => format!("Configuracao salva em {path}"), + Language::Chinese => format!("配置已保存到 {path}"), + Language::Korean => format!("구성이 {path} 에 저장되었습니다"), + Language::Arabic => format!("تم حفظ الإعدادات في {path}"), + Language::Japanese => format!("設定を {path} に保存しました"), + } +} + +pub fn ready_to_hunt() -> &'static str { + match language() { + Language::English => "Ready to hunt vulnerabilities. Try:", + Language::French => "Pret a traquer les vulnerabilites. Essayez :", + Language::German => "Bereit, Schwachstellen zu finden. Probieren Sie:", + Language::Dutch => "Klaar om kwetsbaarheden op te sporen. Probeer:", + Language::Spanish => "Listo para buscar vulnerabilidades. Prueba:", + Language::Portuguese => "Pronto para encontrar vulnerabilidades. Tente:", + Language::Chinese => "已准备好开始排查漏洞。试试:", + Language::Korean => "취약점 탐색 준비가 끝났습니다. 예시:", + Language::Arabic => "أنت جاهز للبحث عن الثغرات. جرّب:", + Language::Japanese => "脆弱性の調査を始めましょう。次を試してください:", + } +} + +pub fn list_your_objects() -> &'static str { + match language() { + Language::English => "list your objects", + Language::French => "liste vos objets", + Language::German => "Objekte auflisten", + Language::Dutch => "toon uw objecten", + Language::Spanish => "listar tus objetos", + Language::Portuguese => "listar seus objetos", + Language::Chinese => "列出对象", + Language::Korean => "오브젝트 목록 보기", + Language::Arabic => "اعرض العناصر", + Language::Japanese => "オブジェクト一覧を表示", + } +} + +pub fn available_scan_types() -> &'static str { + match language() { + Language::English => "available scan types", + Language::French => "types d'analyse disponibles", + Language::German => "verfugbare Scan-Typen", + Language::Dutch => "beschikbare scantypen", + Language::Spanish => "tipos de analisis disponibles", + Language::Portuguese => "tipos de analise disponiveis", + Language::Chinese => "可用扫描类型", + Language::Korean => "사용 가능한 스캔 유형", + Language::Arabic => "أنواع الفحص المتاحة", + Language::Japanese => "利用可能なスキャン種別", + } +} + +pub fn start_a_scan() -> &'static str { + match language() { + Language::English => "start a scan", + Language::French => "demarrer une analyse", + Language::German => "einen Scan starten", + Language::Dutch => "een scan starten", + Language::Spanish => "iniciar un analisis", + Language::Portuguese => "iniciar uma analise", + Language::Chinese => "开始扫描", + Language::Korean => "스캔 시작", + Language::Arabic => "ابدأ فحصًا", + Language::Japanese => "スキャンを開始", + } +} + +pub fn no_profiles_configured() -> &'static str { + match language() { + Language::English => "No profiles configured. Run: analyzer login", + Language::French => "Aucun profil configure. Lancez : analyzer login", + Language::German => "Keine Profile konfiguriert. Ausfuhren: analyzer login", + Language::Dutch => "Geen profielen geconfigureerd. Voer uit: analyzer login", + Language::Spanish => "No hay perfiles configurados. Ejecuta: analyzer login", + Language::Portuguese => "Nenhum perfil configurado. Execute: analyzer login", + Language::Chinese => "尚未配置任何 profile。请运行:analyzer login", + Language::Korean => "구성된 프로필이 없습니다. 다음을 실행하세요: analyzer login", + Language::Arabic => "لا توجد ملفات شخصية مهيأة. شغّل: analyzer login", + Language::Japanese => { + "設定されたプロファイルがありません。`analyzer login` を実行してください" + } + } +} + +pub fn value_set() -> &'static str { + match language() { + Language::English => "set", + Language::French => "definie", + Language::German => "gesetzt", + Language::Dutch => "ingesteld", + Language::Spanish => "configurada", + Language::Portuguese => "definida", + Language::Chinese => "已设置", + Language::Korean => "설정됨", + Language::Arabic => "تم التعيين", + Language::Japanese => "設定済み", + } +} + +pub fn value_not_set() -> &'static str { + match language() { + Language::English => "not set", + Language::French => "non definie", + Language::German => "nicht gesetzt", + Language::Dutch => "niet ingesteld", + Language::Spanish => "sin configurar", + Language::Portuguese => "nao definida", + Language::Chinese => "未设置", + Language::Korean => "설정되지 않음", + Language::Arabic => "غير معيّن", + Language::Japanese => "未設定", + } +} + +pub fn default_value() -> &'static str { + match language() { + Language::English => "(default)", + Language::French => "(par defaut)", + Language::German => "(Standard)", + Language::Dutch => "(standaard)", + Language::Spanish => "(predeterminado)", + Language::Portuguese => "(padrao)", + Language::Chinese => "(默认)", + Language::Korean => "(기본값)", + Language::Arabic => "(افتراضي)", + Language::Japanese => "(既定)", + } +} + +pub fn not_set_value() -> &'static str { + match language() { + Language::English => "(not set)", + Language::French => "(non defini)", + Language::German => "(nicht gesetzt)", + Language::Dutch => "(niet ingesteld)", + Language::Spanish => "(sin configurar)", + Language::Portuguese => "(nao definido)", + Language::Chinese => "(未设置)", + Language::Korean => "(설정되지 않음)", + Language::Arabic => "(غير معيّن)", + Language::Japanese => "(未設定)", + } +} + +pub fn valid_config_keys() -> &'static str { + "Valid keys: url, api-key, default-profile" +} + +pub fn unknown_config_key(other: &str) -> String { + match language() { + Language::English => format!("Unknown config key: {other}\n\n{}", valid_config_keys()), + Language::French => format!( + "Cle de configuration inconnue : {other}\n\n{}", + valid_config_keys() + ), + Language::German => format!( + "Unbekannter Konfigurationsschlussel: {other}\n\n{}", + valid_config_keys() + ), + Language::Dutch => format!( + "Onbekende configuratiesleutel: {other}\n\n{}", + valid_config_keys() + ), + Language::Spanish => format!( + "Clave de configuracion desconocida: {other}\n\n{}", + valid_config_keys() + ), + Language::Portuguese => format!( + "Chave de configuracao desconhecida: {other}\n\n{}", + valid_config_keys() + ), + Language::Chinese => format!("未知配置键:{other}\n\n{}", valid_config_keys()), + Language::Korean => format!("알 수 없는 구성 키: {other}\n\n{}", valid_config_keys()), + Language::Arabic => format!("مفتاح إعداد غير معروف: {other}\n\n{}", valid_config_keys()), + Language::Japanese => format!("不明な設定キーです: {other}\n\n{}", valid_config_keys()), + } +} + +pub fn set_config_value(key: &str, value: &str, profile: &str) -> String { + match language() { + Language::English => format!("Set {key} = {value} (profile: {profile})"), + Language::French => format!("{key} = {value} defini (profil : {profile})"), + Language::German => format!("{key} = {value} gesetzt (Profil: {profile})"), + Language::Dutch => format!("{key} = {value} ingesteld (profiel: {profile})"), + Language::Spanish => format!("{key} = {value} configurado (perfil: {profile})"), + Language::Portuguese => format!("{key} = {value} definido (perfil: {profile})"), + Language::Chinese => format!("已设置 {key} = {value}(profile: {profile})"), + Language::Korean => format!("{key} = {value} 로 설정했습니다 (프로필: {profile})"), + Language::Arabic => format!("تم تعيين {key} = {value} (الملف الشخصي: {profile})"), + Language::Japanese => format!("{key} = {value} を設定しました (プロファイル: {profile})"), + } +} + +pub fn objects_empty() -> &'static str { + match language() { + Language::English => "None found. Create one with: analyzer object new ", + Language::French => "Aucun objet trouve. Creez-en un avec : analyzer object new ", + Language::German => { + "Keine Objekte gefunden. Erstellen Sie eines mit: analyzer object new " + } + Language::Dutch => "Geen objecten gevonden. Maak er een met: analyzer object new ", + Language::Spanish => "No se encontraron objetos. Crea uno con: analyzer object new ", + Language::Portuguese => "Nenhum objeto encontrado. Crie um com: analyzer object new ", + Language::Chinese => "未找到对象。使用以下命令创建:analyzer object new ", + Language::Korean => "오브젝트가 없습니다. 다음으로 생성하세요: analyzer object new ", + Language::Arabic => "لم يتم العثور على عناصر. أنشئ واحدًا عبر: analyzer object new ", + Language::Japanese => { + "オブジェクトが見つかりません。`analyzer object new ` で作成してください" + } + } +} + +pub fn created_object(name: &str, id: impl Display) -> String { + match language() { + Language::English => format!("Created object '{name}' ({id})"), + Language::French => format!("Objet '{name}' cree ({id})"), + Language::German => format!("Objekt '{name}' erstellt ({id})"), + Language::Dutch => format!("Object '{name}' aangemaakt ({id})"), + Language::Spanish => format!("Objeto '{name}' creado ({id})"), + Language::Portuguese => format!("Objeto '{name}' criado ({id})"), + Language::Chinese => format!("已创建对象“{name}”({id})"), + Language::Korean => format!("오브젝트 '{name}' 생성됨 ({id})"), + Language::Arabic => format!("تم إنشاء العنصر '{name}' ({id})"), + Language::Japanese => format!("オブジェクト '{name}' を作成しました ({id})"), + } +} + +pub fn deleted_object(id: impl Display) -> String { + match language() { + Language::English => format!("Deleted object {id}"), + Language::French => format!("Objet supprime {id}"), + Language::German => format!("Objekt {id} geloscht"), + Language::Dutch => format!("Object {id} verwijderd"), + Language::Spanish => format!("Objeto {id} eliminado"), + Language::Portuguese => format!("Objeto {id} removido"), + Language::Chinese => format!("已删除对象 {id}"), + Language::Korean => format!("오브젝트 {id} 삭제됨"), + Language::Arabic => format!("تم حذف العنصر {id}"), + Language::Japanese => format!("オブジェクト {id} を削除しました"), + } +} + +pub fn scan_created(id: impl Display) -> String { + match language() { + Language::English => format!("Scan {id} created"), + Language::French => format!("Analyse {id} creee"), + Language::German => format!("Scan {id} erstellt"), + Language::Dutch => format!("Scan {id} aangemaakt"), + Language::Spanish => format!("Analisis {id} creado"), + Language::Portuguese => format!("Analise {id} criada"), + Language::Chinese => format!("扫描 {id} 已创建"), + Language::Korean => format!("스캔 {id} 생성됨"), + Language::Arabic => format!("تم إنشاء الفحص {id}"), + Language::Japanese => format!("スキャン {id} を作成しました"), + } +} + +pub fn check_status_command(object_id: impl Display) -> String { + match language() { + Language::English => { + format!("Check status with: analyzer scan status --object {object_id}") + } + Language::French => { + format!("Verifier l'etat avec : analyzer scan status --object {object_id}") + } + Language::German => format!("Status prufen mit: analyzer scan status --object {object_id}"), + Language::Dutch => { + format!("Controleer de status met: analyzer scan status --object {object_id}") + } + Language::Spanish => { + format!("Consulta el estado con: analyzer scan status --object {object_id}") + } + Language::Portuguese => { + format!("Verifique o status com: analyzer scan status --object {object_id}") + } + Language::Chinese => { + format!("使用以下命令检查状态:analyzer scan status --object {object_id}") + } + Language::Korean => format!("상태 확인: analyzer scan status --object {object_id}"), + Language::Arabic => { + format!("تحقق من الحالة عبر: analyzer scan status --object {object_id}") + } + Language::Japanese => format!("状態確認: analyzer scan status --object {object_id}"), + } +} + +pub fn deleted_scan(id: impl Display) -> String { + match language() { + Language::English => format!("Deleted scan {id}"), + Language::French => format!("Analyse supprimee {id}"), + Language::German => format!("Scan {id} geloscht"), + Language::Dutch => format!("Scan {id} verwijderd"), + Language::Spanish => format!("Analisis {id} eliminado"), + Language::Portuguese => format!("Analise {id} removida"), + Language::Chinese => format!("已删除扫描 {id}"), + Language::Korean => format!("스캔 {id} 삭제됨"), + Language::Arabic => format!("تم حذف الفحص {id}"), + Language::Japanese => format!("スキャン {id} を削除しました"), + } +} + +pub fn cancelled_scan(id: impl Display) -> String { + match language() { + Language::English => format!("Cancelled scan {id}"), + Language::French => format!("Analyse annulee {id}"), + Language::German => format!("Scan {id} abgebrochen"), + Language::Dutch => format!("Scan {id} geannuleerd"), + Language::Spanish => format!("Analisis {id} cancelado"), + Language::Portuguese => format!("Analise {id} cancelada"), + Language::Chinese => format!("已取消扫描 {id}"), + Language::Korean => format!("스캔 {id} 취소됨"), + Language::Arabic => format!("تم إلغاء الفحص {id}"), + Language::Japanese => format!("スキャン {id} をキャンセルしました"), + } +} + +pub fn downloading_pdf_report() -> &'static str { + match language() { + Language::English => "Downloading PDF report...", + Language::French => "Telechargement du rapport PDF...", + Language::German => "PDF-Bericht wird heruntergeladen...", + Language::Dutch => "PDF-rapport wordt gedownload...", + Language::Spanish => "Descargando informe PDF...", + Language::Portuguese => "Baixando relatorio PDF...", + Language::Chinese => "正在下载 PDF 报告...", + Language::Korean => "PDF 보고서를 다운로드하는 중입니다...", + Language::Arabic => "جارٍ تنزيل تقرير PDF...", + Language::Japanese => "PDF レポートをダウンロードしています...", + } +} + +pub fn report_saved(path: impl Display) -> String { + match language() { + Language::English => format!("Report saved to {path}"), + Language::French => format!("Rapport enregistre dans {path}"), + Language::German => format!("Bericht gespeichert unter {path}"), + Language::Dutch => format!("Rapport opgeslagen in {path}"), + Language::Spanish => format!("Informe guardado en {path}"), + Language::Portuguese => format!("Relatorio salvo em {path}"), + Language::Chinese => format!("报告已保存到 {path}"), + Language::Korean => format!("보고서가 {path} 에 저장되었습니다"), + Language::Arabic => format!("تم حفظ التقرير في {path}"), + Language::Japanese => format!("レポートを {path} に保存しました"), + } +} + +pub fn downloading_sbom() -> &'static str { + match language() { + Language::English => "Downloading SBOM...", + Language::French => "Telechargement du SBOM...", + Language::German => "SBOM wird heruntergeladen...", + Language::Dutch => "SBOM wordt gedownload...", + Language::Spanish => "Descargando SBOM...", + Language::Portuguese => "Baixando SBOM...", + Language::Chinese => "正在下载 SBOM...", + Language::Korean => "SBOM을 다운로드하는 중입니다...", + Language::Arabic => "جارٍ تنزيل SBOM...", + Language::Japanese => "SBOM をダウンロードしています...", + } +} + +pub fn sbom_saved(path: impl Display) -> String { + match language() { + Language::English => format!("SBOM saved to {path}"), + Language::French => format!("SBOM enregistre dans {path}"), + Language::German => format!("SBOM gespeichert unter {path}"), + Language::Dutch => format!("SBOM opgeslagen in {path}"), + Language::Spanish => format!("SBOM guardado en {path}"), + Language::Portuguese => format!("SBOM salvo em {path}"), + Language::Chinese => format!("SBOM 已保存到 {path}"), + Language::Korean => format!("SBOM이 {path} 에 저장되었습니다"), + Language::Arabic => format!("تم حفظ SBOM في {path}"), + Language::Japanese => format!("SBOM を {path} に保存しました"), + } +} + +pub fn downloading_compliance_report(name: &str) -> String { + match language() { + Language::English => format!("Downloading {name} compliance report..."), + Language::French => format!("Telechargement du rapport de conformite {name}..."), + Language::German => format!("{name}-Compliance-Bericht wird heruntergeladen..."), + Language::Dutch => format!("{name}-compliancerapport wordt gedownload..."), + Language::Spanish => format!("Descargando el informe de cumplimiento {name}..."), + Language::Portuguese => format!("Baixando relatorio de conformidade {name}..."), + Language::Chinese => format!("正在下载 {name} 合规报告..."), + Language::Korean => format!("{name} 규정 준수 보고서를 다운로드하는 중입니다..."), + Language::Arabic => format!("جارٍ تنزيل تقرير الامتثال {name}..."), + Language::Japanese => format!("{name} コンプライアンスレポートをダウンロードしています..."), + } +} + +pub fn compliance_report_saved(name: &str, path: impl Display) -> String { + match language() { + Language::English => format!("{name} report saved to {path}"), + Language::French => format!("Rapport {name} enregistre dans {path}"), + Language::German => format!("{name}-Bericht gespeichert unter {path}"), + Language::Dutch => format!("{name}-rapport opgeslagen in {path}"), + Language::Spanish => format!("Informe {name} guardado en {path}"), + Language::Portuguese => format!("Relatorio {name} salvo em {path}"), + Language::Chinese => format!("{name} 报告已保存到 {path}"), + Language::Korean => format!("{name} 보고서가 {path} 에 저장되었습니다"), + Language::Arabic => format!("تم حفظ تقرير {name} في {path}"), + Language::Japanese => format!("{name} レポートを {path} に保存しました"), + } +} + +pub fn waiting_for_scan() -> &'static str { + match language() { + Language::English => "Waiting for scan to complete...", + Language::French => "En attente de la fin de l'analyse...", + Language::German => "Warten auf den Abschluss des Scans...", + Language::Dutch => "Wachten tot de scan is voltooid...", + Language::Spanish => "Esperando a que finalice el analisis...", + Language::Portuguese => "Aguardando a conclusao da analise...", + Language::Chinese => "正在等待扫描完成...", + Language::Korean => "스캔 완료를 기다리는 중입니다...", + Language::Arabic => "جارٍ انتظار اكتمال الفحص...", + Language::Japanese => "スキャンの完了を待機しています...", + } +} + +pub fn scan_completed_successfully() -> &'static str { + match language() { + Language::English => "Scan completed successfully!", + Language::French => "Analyse terminee avec succes !", + Language::German => "Scan erfolgreich abgeschlossen!", + Language::Dutch => "Scan succesvol voltooid!", + Language::Spanish => "Analisis completado correctamente.", + Language::Portuguese => "Analise concluida com sucesso!", + Language::Chinese => "扫描已成功完成!", + Language::Korean => "스캔이 성공적으로 완료되었습니다!", + Language::Arabic => "اكتمل الفحص بنجاح!", + Language::Japanese => "スキャンが正常に完了しました!", + } +} + +pub fn scan_failed_with_error_status() -> &'static str { + match language() { + Language::English => "Scan failed with error status", + Language::French => "L'analyse a echoue avec un statut d'erreur", + Language::German => "Scan mit Fehlerstatus fehlgeschlagen", + Language::Dutch => "Scan is mislukt met foutstatus", + Language::Spanish => "El analisis fallo con estado de error", + Language::Portuguese => "A analise falhou com status de erro", + Language::Chinese => "扫描以错误状态失败", + Language::Korean => "스캔이 오류 상태로 실패했습니다", + Language::Arabic => "فشل الفحص بحالة خطأ", + Language::Japanese => "スキャンがエラーステータスで失敗しました", + } +} + +pub fn scan_was_cancelled() -> &'static str { + match language() { + Language::English => "Scan was cancelled", + Language::French => "L'analyse a ete annulee", + Language::German => "Scan wurde abgebrochen", + Language::Dutch => "Scan is geannuleerd", + Language::Spanish => "El analisis fue cancelado", + Language::Portuguese => "A analise foi cancelada", + Language::Chinese => "扫描已取消", + Language::Korean => "스캔이 취소되었습니다", + Language::Arabic => "تم إلغاء الفحص", + Language::Japanese => "スキャンはキャンセルされました", + } +} + +pub fn analyzing(parts: &str) -> String { + match language() { + Language::English => format!("Analyzing... [{parts}]"), + Language::French => format!("Analyse en cours... [{parts}]"), + Language::German => format!("Analyse lauft... [{parts}]"), + Language::Dutch => format!("Bezig met analyseren... [{parts}]"), + Language::Spanish => format!("Analizando... [{parts}]"), + Language::Portuguese => format!("Analisando... [{parts}]"), + Language::Chinese => format!("正在分析... [{parts}]"), + Language::Korean => format!("분석 중... [{parts}]"), + Language::Arabic => format!("جارٍ التحليل... [{parts}]"), + Language::Japanese => format!("解析中... [{parts}]"), + } +} + +pub fn timed_out_waiting_for_scan(seconds: u64) -> String { + match language() { + Language::English => format!("Timed out waiting for scan to complete ({seconds}s)"), + Language::French => format!("Delai depasse en attendant la fin de l'analyse ({seconds}s)"), + Language::German => format!("Zeituberschreitung beim Warten auf den Scan ({seconds}s)"), + Language::Dutch => format!("Time-out tijdens wachten op scanvoltooiing ({seconds}s)"), + Language::Spanish => { + format!("Tiempo de espera agotado al esperar el analisis ({seconds}s)") + } + Language::Portuguese => format!("Tempo esgotado aguardando a analise ({seconds}s)"), + Language::Chinese => format!("等待扫描完成超时({seconds}s)"), + Language::Korean => format!("스캔 완료 대기 시간이 초과되었습니다 ({seconds}s)"), + Language::Arabic => format!("انتهت مهلة انتظار اكتمال الفحص ({seconds}s)"), + Language::Japanese => format!("スキャン完了待機がタイムアウトしました ({seconds}s)"), + } +} + +pub fn no_findings() -> &'static str { + match language() { + Language::English => "No findings.", + Language::French => "Aucun resultat.", + Language::German => "Keine Befunde.", + Language::Dutch => "Geen bevindingen.", + Language::Spanish => "Sin hallazgos.", + Language::Portuguese => "Nenhum achado.", + Language::Chinese => "没有发现项。", + Language::Korean => "발견된 항목이 없습니다.", + Language::Arabic => "لا توجد نتائج.", + Language::Japanese => "検出結果はありません。", + } +} + +pub fn page_navigation(page: u32, total_pages: u64, total_findings: u64) -> String { + match language() { + Language::English => { + format!("Page {page}/{total_pages} ({total_findings} total) - use --page N to navigate") + } + Language::French => format!( + "Page {page}/{total_pages} ({total_findings} au total) - utilisez --page N pour naviguer" + ), + Language::German => format!( + "Seite {page}/{total_pages} ({total_findings} gesamt) - mit --page N navigieren" + ), + Language::Dutch => format!( + "Pagina {page}/{total_pages} ({total_findings} totaal) - gebruik --page N om te navigeren" + ), + Language::Spanish => format!( + "Pagina {page}/{total_pages} ({total_findings} en total) - usa --page N para navegar" + ), + Language::Portuguese => format!( + "Pagina {page}/{total_pages} ({total_findings} no total) - use --page N para navegar" + ), + Language::Chinese => { + format!("第 {page}/{total_pages} 页(共 {total_findings} 条)- 使用 --page N 翻页") + } + Language::Korean => { + format!( + "{page}/{total_pages} 페이지 (총 {total_findings}개) - 이동하려면 --page N 사용" + ) + } + Language::Arabic => { + format!( + "الصفحة {page}/{total_pages} (الإجمالي {total_findings}) - استخدم --page N للتنقل" + ) + } + Language::Japanese => { + format!( + "{page}/{total_pages} ページ / 全 {total_findings} 件 - 移動には --page N を使用" + ) + } + } +} + +pub fn status_display(status: &str) -> Cow<'static, str> { + match status { + "success" => Cow::Borrowed(text(Text::SuccessStatus)), + "pending" => Cow::Borrowed(text(Text::PendingStatus)), + "in-progress" => Cow::Borrowed(text(Text::InProgressStatus)), + "canceled" => Cow::Borrowed(text(Text::CanceledStatus)), + "error" => Cow::Borrowed(text(Text::ErrorStatus)), + _ => Cow::Owned(status.to_string()), + } +} + +pub fn progress_word(status: &str) -> &'static str { + match status { + "success" => text(Text::Done), + "in-progress" => text(Text::Running), + "pending" => text(Text::Queued), + _ => "?", + } +} + +fn language_lock() -> &'static RwLock { + static LANGUAGE: OnceLock> = OnceLock::new(); + LANGUAGE.get_or_init(|| RwLock::new(Language::English)) +} + +fn text_en(key: Text) -> &'static str { + match key { + Text::Ok => "OK", + Text::Warning => "WARN", + Text::Error => "ERR", + Text::Profile => "Profile", + Text::Url => "URL", + Text::ApiKey => "API Key", + Text::Config => "Config", + Text::ConfigFile => "Config file", + Text::DefaultProfile => "Default profile", + Text::Profiles => "Profiles", + Text::Id => "ID", + Text::Name => "Name", + Text::Description => "Description", + Text::Score => "Score", + Text::Analysis => "Analysis", + Text::Status => "Status", + Text::Type => "Type", + Text::Version => "Version", + Text::Licenses => "Licenses", + Text::Feature => "Feature", + Text::Function => "Function", + Text::Username => "Username", + Text::Password => "Password", + Text::Filename => "Filename", + Text::Engine => "Engine", + Text::Product => "Product", + Text::Summary => "Summary", + Text::Vendor => "Vendor", + Text::KeySize => "Key Size", + Text::Aux => "Aux", + Text::Behaviors => "Behaviors", + Text::Syscalls => "Syscalls", + Text::Canary => "Canary", + Text::Nx => "NX", + Text::Pie => "PIE", + Text::Relro => "RELRO", + Text::Fortify => "Fortify", + Text::Severity => "Severity", + Text::Objects => "Objects", + Text::Scan => "Scan", + Text::OverallScore => "Overall Score", + Text::Default => "default", + Text::CveVulnerabilities => "CVE Vulnerabilities", + Text::MalwareDetections => "Malware Detections", + Text::PasswordIssues => "Password Issues", + Text::HardeningIssues => "Hardening Issues", + Text::Capabilities => "Capabilities", + Text::Crypto => "Crypto", + Text::SoftwareBom => "Software BOM", + Text::Kernel => "Kernel", + Text::Symbols => "Symbols", + Text::Tasks => "Tasks", + Text::StackOverflow => "Stack Overflow", + Text::KernelConfig => "Kernel Config", + Text::Bind => "Bind", + Text::SuccessStatus => "success", + Text::PendingStatus => "pending", + Text::InProgressStatus => "in progress", + Text::CanceledStatus => "canceled", + Text::ErrorStatus => "error", + Text::Running => "running", + Text::Queued => "queued", + Text::Done => "done", + } +} + +fn text_fr(key: Text) -> &'static str { + match key { + Text::Ok => "OK", + Text::Warning => "ATTN", + Text::Error => "ERR", + Text::Profile => "Profil", + Text::Url => "URL", + Text::ApiKey => "Cle API", + Text::Config => "Config", + Text::ConfigFile => "Fichier config", + Text::DefaultProfile => "Profil par defaut", + Text::Profiles => "Profils", + Text::Id => "ID", + Text::Name => "Nom", + Text::Description => "Description", + Text::Score => "Score", + Text::Analysis => "Analyse", + Text::Status => "Statut", + Text::Type => "Type", + Text::Version => "Version", + Text::Licenses => "Licences", + Text::Feature => "Fonction", + Text::Function => "Fonction", + Text::Username => "Utilisateur", + Text::Password => "Mot de passe", + Text::Filename => "Fichier", + Text::Engine => "Moteur", + Text::Product => "Produit", + Text::Summary => "Resume", + Text::Vendor => "Editeur", + Text::KeySize => "Taille cle", + Text::Aux => "Aux", + Text::Behaviors => "Comportements", + Text::Syscalls => "Syscalls", + Text::Canary => "Canary", + Text::Nx => "NX", + Text::Pie => "PIE", + Text::Relro => "RELRO", + Text::Fortify => "Fortify", + Text::Severity => "Gravite", + Text::Objects => "Objets", + Text::Scan => "Analyse", + Text::OverallScore => "Score global", + Text::Default => "defaut", + Text::CveVulnerabilities => "Vulnerabilites CVE", + Text::MalwareDetections => "Detections malware", + Text::PasswordIssues => "Problemes de mot de passe", + Text::HardeningIssues => "Problemes de durcissement", + Text::Capabilities => "Capacites", + Text::Crypto => "Crypto", + Text::SoftwareBom => "BOM logicielle", + Text::Kernel => "Noyau", + Text::Symbols => "Symboles", + Text::Tasks => "Taches", + Text::StackOverflow => "Depassement de pile", + Text::KernelConfig => "Config noyau", + Text::Bind => "Lien", + Text::SuccessStatus => "reussi", + Text::PendingStatus => "en attente", + Text::InProgressStatus => "en cours", + Text::CanceledStatus => "annule", + Text::ErrorStatus => "erreur", + Text::Running => "en cours", + Text::Queued => "file", + Text::Done => "termine", + } +} + +fn text_de(key: Text) -> &'static str { + match key { + Text::Ok => "OK", + Text::Warning => "WARN", + Text::Error => "FEHL", + Text::Profile => "Profil", + Text::Url => "URL", + Text::ApiKey => "API-Schlussel", + Text::Config => "Konfig", + Text::ConfigFile => "Konfigdatei", + Text::DefaultProfile => "Standardprofil", + Text::Profiles => "Profile", + Text::Id => "ID", + Text::Name => "Name", + Text::Description => "Beschreibung", + Text::Score => "Score", + Text::Analysis => "Analyse", + Text::Status => "Status", + Text::Type => "Typ", + Text::Version => "Version", + Text::Licenses => "Lizenzen", + Text::Feature => "Feature", + Text::Function => "Funktion", + Text::Username => "Benutzer", + Text::Password => "Passwort", + Text::Filename => "Datei", + Text::Engine => "Engine", + Text::Product => "Produkt", + Text::Summary => "Zusammenfassung", + Text::Vendor => "Hersteller", + Text::KeySize => "Schlusselgrosse", + Text::Aux => "Aux", + Text::Behaviors => "Verhalten", + Text::Syscalls => "Syscalls", + Text::Canary => "Canary", + Text::Nx => "NX", + Text::Pie => "PIE", + Text::Relro => "RELRO", + Text::Fortify => "Fortify", + Text::Severity => "Schweregrad", + Text::Objects => "Objekte", + Text::Scan => "Scan", + Text::OverallScore => "Gesamtwert", + Text::Default => "standard", + Text::CveVulnerabilities => "CVE-Schwachstellen", + Text::MalwareDetections => "Malware-Funde", + Text::PasswordIssues => "Passwortprobleme", + Text::HardeningIssues => "Hardening-Probleme", + Text::Capabilities => "Fahigkeiten", + Text::Crypto => "Krypto", + Text::SoftwareBom => "Software-BOM", + Text::Kernel => "Kernel", + Text::Symbols => "Symbole", + Text::Tasks => "Tasks", + Text::StackOverflow => "Stack Overflow", + Text::KernelConfig => "Kernel-Konfig", + Text::Bind => "Bind", + Text::SuccessStatus => "erfolgreich", + Text::PendingStatus => "ausstehend", + Text::InProgressStatus => "laufend", + Text::CanceledStatus => "abgebrochen", + Text::ErrorStatus => "fehler", + Text::Running => "laufend", + Text::Queued => "wartend", + Text::Done => "fertig", + } +} + +fn text_nl(key: Text) -> &'static str { + match key { + Text::Ok => "OK", + Text::Warning => "WAARS", + Text::Error => "FOUT", + Text::Profile => "Profiel", + Text::Url => "URL", + Text::ApiKey => "API-sleutel", + Text::Config => "Config", + Text::ConfigFile => "Configbestand", + Text::DefaultProfile => "Standaardprofiel", + Text::Profiles => "Profielen", + Text::Id => "ID", + Text::Name => "Naam", + Text::Description => "Beschrijving", + Text::Score => "Score", + Text::Analysis => "Analyse", + Text::Status => "Status", + Text::Type => "Type", + Text::Version => "Versie", + Text::Licenses => "Licenties", + Text::Feature => "Feature", + Text::Function => "Functie", + Text::Username => "Gebruiker", + Text::Password => "Wachtwoord", + Text::Filename => "Bestand", + Text::Engine => "Engine", + Text::Product => "Product", + Text::Summary => "Samenvatting", + Text::Vendor => "Leverancier", + Text::KeySize => "Sleutelgrootte", + Text::Aux => "Aux", + Text::Behaviors => "Gedragingen", + Text::Syscalls => "Syscalls", + Text::Canary => "Canary", + Text::Nx => "NX", + Text::Pie => "PIE", + Text::Relro => "RELRO", + Text::Fortify => "Fortify", + Text::Severity => "Ernst", + Text::Objects => "Objecten", + Text::Scan => "Scan", + Text::OverallScore => "Totale score", + Text::Default => "standaard", + Text::CveVulnerabilities => "CVE-kwetsbaarheden", + Text::MalwareDetections => "Malware-detecties", + Text::PasswordIssues => "Wachtwoordproblemen", + Text::HardeningIssues => "Hardening-problemen", + Text::Capabilities => "Capaciteiten", + Text::Crypto => "Crypto", + Text::SoftwareBom => "Software BOM", + Text::Kernel => "Kernel", + Text::Symbols => "Symbolen", + Text::Tasks => "Taken", + Text::StackOverflow => "Stack Overflow", + Text::KernelConfig => "Kernelconfig", + Text::Bind => "Binding", + Text::SuccessStatus => "geslaagd", + Text::PendingStatus => "wachtend", + Text::InProgressStatus => "bezig", + Text::CanceledStatus => "geannuleerd", + Text::ErrorStatus => "fout", + Text::Running => "actief", + Text::Queued => "in wachtrij", + Text::Done => "klaar", + } +} + +fn text_es(key: Text) -> &'static str { + match key { + Text::Ok => "OK", + Text::Warning => "AVISO", + Text::Error => "ERR", + Text::Profile => "Perfil", + Text::Url => "URL", + Text::ApiKey => "Clave API", + Text::Config => "Config", + Text::ConfigFile => "Archivo config", + Text::DefaultProfile => "Perfil predeterminado", + Text::Profiles => "Perfiles", + Text::Id => "ID", + Text::Name => "Nombre", + Text::Description => "Descripcion", + Text::Score => "Puntuacion", + Text::Analysis => "Analisis", + Text::Status => "Estado", + Text::Type => "Tipo", + Text::Version => "Version", + Text::Licenses => "Licencias", + Text::Feature => "Caracteristica", + Text::Function => "Funcion", + Text::Username => "Usuario", + Text::Password => "Contrasena", + Text::Filename => "Archivo", + Text::Engine => "Motor", + Text::Product => "Producto", + Text::Summary => "Resumen", + Text::Vendor => "Proveedor", + Text::KeySize => "Tamano clave", + Text::Aux => "Aux", + Text::Behaviors => "Comportamientos", + Text::Syscalls => "Syscalls", + Text::Canary => "Canary", + Text::Nx => "NX", + Text::Pie => "PIE", + Text::Relro => "RELRO", + Text::Fortify => "Fortify", + Text::Severity => "Severidad", + Text::Objects => "Objetos", + Text::Scan => "Analisis", + Text::OverallScore => "Puntuacion global", + Text::Default => "predeterminado", + Text::CveVulnerabilities => "Vulnerabilidades CVE", + Text::MalwareDetections => "Detecciones de malware", + Text::PasswordIssues => "Problemas de contrasena", + Text::HardeningIssues => "Problemas de hardening", + Text::Capabilities => "Capacidades", + Text::Crypto => "Cripto", + Text::SoftwareBom => "SBOM", + Text::Kernel => "Kernel", + Text::Symbols => "Simbolos", + Text::Tasks => "Tareas", + Text::StackOverflow => "Desbordamiento de pila", + Text::KernelConfig => "Config kernel", + Text::Bind => "Enlace", + Text::SuccessStatus => "correcto", + Text::PendingStatus => "pendiente", + Text::InProgressStatus => "en curso", + Text::CanceledStatus => "cancelado", + Text::ErrorStatus => "error", + Text::Running => "ejecutando", + Text::Queued => "en cola", + Text::Done => "hecho", + } +} + +fn text_pt(key: Text) -> &'static str { + match key { + Text::Ok => "OK", + Text::Warning => "AVISO", + Text::Error => "ERR", + Text::Profile => "Perfil", + Text::Url => "URL", + Text::ApiKey => "Chave API", + Text::Config => "Config", + Text::ConfigFile => "Arquivo config", + Text::DefaultProfile => "Perfil padrao", + Text::Profiles => "Perfis", + Text::Id => "ID", + Text::Name => "Nome", + Text::Description => "Descricao", + Text::Score => "Pontuacao", + Text::Analysis => "Analise", + Text::Status => "Status", + Text::Type => "Tipo", + Text::Version => "Versao", + Text::Licenses => "Licencas", + Text::Feature => "Recurso", + Text::Function => "Funcao", + Text::Username => "Usuario", + Text::Password => "Senha", + Text::Filename => "Arquivo", + Text::Engine => "Motor", + Text::Product => "Produto", + Text::Summary => "Resumo", + Text::Vendor => "Fornecedor", + Text::KeySize => "Tam chave", + Text::Aux => "Aux", + Text::Behaviors => "Comportamentos", + Text::Syscalls => "Syscalls", + Text::Canary => "Canary", + Text::Nx => "NX", + Text::Pie => "PIE", + Text::Relro => "RELRO", + Text::Fortify => "Fortify", + Text::Severity => "Severidade", + Text::Objects => "Objetos", + Text::Scan => "Analise", + Text::OverallScore => "Pontuacao geral", + Text::Default => "padrao", + Text::CveVulnerabilities => "Vulnerabilidades CVE", + Text::MalwareDetections => "Deteccoes de malware", + Text::PasswordIssues => "Problemas de senha", + Text::HardeningIssues => "Problemas de hardening", + Text::Capabilities => "Capacidades", + Text::Crypto => "Cripto", + Text::SoftwareBom => "SBOM", + Text::Kernel => "Kernel", + Text::Symbols => "Simbolos", + Text::Tasks => "Tarefas", + Text::StackOverflow => "Estouro de pilha", + Text::KernelConfig => "Config kernel", + Text::Bind => "Bind", + Text::SuccessStatus => "sucesso", + Text::PendingStatus => "pendente", + Text::InProgressStatus => "em andamento", + Text::CanceledStatus => "cancelado", + Text::ErrorStatus => "erro", + Text::Running => "executando", + Text::Queued => "na fila", + Text::Done => "feito", + } +} + +fn text_zh(key: Text) -> &'static str { + match key { + Text::Ok => "成功", + Text::Warning => "警告", + Text::Error => "错误", + Text::Profile => "配置文件", + Text::Url => "URL", + Text::ApiKey => "API 密钥", + Text::Config => "配置", + Text::ConfigFile => "配置文件", + Text::DefaultProfile => "默认 profile", + Text::Profiles => "Profiles", + Text::Id => "ID", + Text::Name => "名称", + Text::Description => "描述", + Text::Score => "评分", + Text::Analysis => "分析", + Text::Status => "状态", + Text::Type => "类型", + Text::Version => "版本", + Text::Licenses => "许可证", + Text::Feature => "特性", + Text::Function => "函数", + Text::Username => "用户名", + Text::Password => "密码", + Text::Filename => "文件名", + Text::Engine => "引擎", + Text::Product => "产品", + Text::Summary => "摘要", + Text::Vendor => "厂商", + Text::KeySize => "密钥长度", + Text::Aux => "辅助", + Text::Behaviors => "行为", + Text::Syscalls => "系统调用", + Text::Canary => "Canary", + Text::Nx => "NX", + Text::Pie => "PIE", + Text::Relro => "RELRO", + Text::Fortify => "Fortify", + Text::Severity => "严重级别", + Text::Objects => "对象", + Text::Scan => "扫描", + Text::OverallScore => "总体评分", + Text::Default => "默认", + Text::CveVulnerabilities => "CVE 漏洞", + Text::MalwareDetections => "恶意软件检出", + Text::PasswordIssues => "密码问题", + Text::HardeningIssues => "加固问题", + Text::Capabilities => "能力", + Text::Crypto => "密码学", + Text::SoftwareBom => "软件 BOM", + Text::Kernel => "内核", + Text::Symbols => "符号", + Text::Tasks => "任务", + Text::StackOverflow => "栈溢出", + Text::KernelConfig => "内核配置", + Text::Bind => "绑定", + Text::SuccessStatus => "成功", + Text::PendingStatus => "等待中", + Text::InProgressStatus => "进行中", + Text::CanceledStatus => "已取消", + Text::ErrorStatus => "错误", + Text::Running => "运行中", + Text::Queued => "排队中", + Text::Done => "完成", + } +} + +fn text_ko(key: Text) -> &'static str { + match key { + Text::Ok => "확인", + Text::Warning => "경고", + Text::Error => "오류", + Text::Profile => "프로필", + Text::Url => "URL", + Text::ApiKey => "API 키", + Text::Config => "구성", + Text::ConfigFile => "구성 파일", + Text::DefaultProfile => "기본 프로필", + Text::Profiles => "프로필", + Text::Id => "ID", + Text::Name => "이름", + Text::Description => "설명", + Text::Score => "점수", + Text::Analysis => "분석", + Text::Status => "상태", + Text::Type => "유형", + Text::Version => "버전", + Text::Licenses => "라이선스", + Text::Feature => "기능", + Text::Function => "함수", + Text::Username => "사용자 이름", + Text::Password => "비밀번호", + Text::Filename => "파일명", + Text::Engine => "엔진", + Text::Product => "제품", + Text::Summary => "요약", + Text::Vendor => "벤더", + Text::KeySize => "키 크기", + Text::Aux => "보조", + Text::Behaviors => "동작", + Text::Syscalls => "시스템 호출", + Text::Canary => "Canary", + Text::Nx => "NX", + Text::Pie => "PIE", + Text::Relro => "RELRO", + Text::Fortify => "Fortify", + Text::Severity => "심각도", + Text::Objects => "오브젝트", + Text::Scan => "스캔", + Text::OverallScore => "전체 점수", + Text::Default => "기본", + Text::CveVulnerabilities => "CVE 취약점", + Text::MalwareDetections => "멀웨어 탐지", + Text::PasswordIssues => "비밀번호 문제", + Text::HardeningIssues => "하드닝 문제", + Text::Capabilities => "기능", + Text::Crypto => "암호화", + Text::SoftwareBom => "소프트웨어 BOM", + Text::Kernel => "커널", + Text::Symbols => "심볼", + Text::Tasks => "작업", + Text::StackOverflow => "스택 오버플로", + Text::KernelConfig => "커널 설정", + Text::Bind => "바인드", + Text::SuccessStatus => "성공", + Text::PendingStatus => "대기 중", + Text::InProgressStatus => "진행 중", + Text::CanceledStatus => "취소됨", + Text::ErrorStatus => "오류", + Text::Running => "실행 중", + Text::Queued => "대기열", + Text::Done => "완료", + } +} + +fn text_ar(key: Text) -> &'static str { + match key { + Text::Ok => "تم", + Text::Warning => "تحذير", + Text::Error => "خطأ", + Text::Profile => "الملف الشخصي", + Text::Url => "الرابط", + Text::ApiKey => "مفتاح API", + Text::Config => "الإعدادات", + Text::ConfigFile => "ملف الإعدادات", + Text::DefaultProfile => "الملف الافتراضي", + Text::Profiles => "الملفات الشخصية", + Text::Id => "المعرف", + Text::Name => "الاسم", + Text::Description => "الوصف", + Text::Score => "النتيجة", + Text::Analysis => "التحليل", + Text::Status => "الحالة", + Text::Type => "النوع", + Text::Version => "الإصدار", + Text::Licenses => "التراخيص", + Text::Feature => "الميزة", + Text::Function => "الدالة", + Text::Username => "اسم المستخدم", + Text::Password => "كلمة المرور", + Text::Filename => "اسم الملف", + Text::Engine => "المحرّك", + Text::Product => "المنتج", + Text::Summary => "الملخص", + Text::Vendor => "المورّد", + Text::KeySize => "حجم المفتاح", + Text::Aux => "إضافي", + Text::Behaviors => "السلوكيات", + Text::Syscalls => "استدعاءات النظام", + Text::Canary => "Canary", + Text::Nx => "NX", + Text::Pie => "PIE", + Text::Relro => "RELRO", + Text::Fortify => "Fortify", + Text::Severity => "الخطورة", + Text::Objects => "العناصر", + Text::Scan => "الفحص", + Text::OverallScore => "النتيجة العامة", + Text::Default => "افتراضي", + Text::CveVulnerabilities => "ثغرات CVE", + Text::MalwareDetections => "اكتشافات البرمجيات الخبيثة", + Text::PasswordIssues => "مشكلات كلمات المرور", + Text::HardeningIssues => "مشكلات التقوية", + Text::Capabilities => "القدرات", + Text::Crypto => "التشفير", + Text::SoftwareBom => "فاتورة البرمجيات", + Text::Kernel => "النواة", + Text::Symbols => "الرموز", + Text::Tasks => "المهام", + Text::StackOverflow => "تجاوز المكدس", + Text::KernelConfig => "إعدادات النواة", + Text::Bind => "الربط", + Text::SuccessStatus => "ناجح", + Text::PendingStatus => "قيد الانتظار", + Text::InProgressStatus => "قيد التنفيذ", + Text::CanceledStatus => "ملغي", + Text::ErrorStatus => "خطأ", + Text::Running => "يعمل", + Text::Queued => "في الطابور", + Text::Done => "مكتمل", + } +} + +fn text_ja(key: Text) -> &'static str { + match key { + Text::Ok => "OK", + Text::Warning => "警告", + Text::Error => "エラー", + Text::Profile => "プロファイル", + Text::Url => "URL", + Text::ApiKey => "API キー", + Text::Config => "設定", + Text::ConfigFile => "設定ファイル", + Text::DefaultProfile => "既定のプロファイル", + Text::Profiles => "プロファイル", + Text::Id => "ID", + Text::Name => "名前", + Text::Description => "説明", + Text::Score => "スコア", + Text::Analysis => "解析", + Text::Status => "状態", + Text::Type => "種類", + Text::Version => "バージョン", + Text::Licenses => "ライセンス", + Text::Feature => "機能", + Text::Function => "関数", + Text::Username => "ユーザー名", + Text::Password => "パスワード", + Text::Filename => "ファイル名", + Text::Engine => "エンジン", + Text::Product => "製品", + Text::Summary => "概要", + Text::Vendor => "ベンダー", + Text::KeySize => "鍵長", + Text::Aux => "補助", + Text::Behaviors => "挙動", + Text::Syscalls => "システムコール", + Text::Canary => "Canary", + Text::Nx => "NX", + Text::Pie => "PIE", + Text::Relro => "RELRO", + Text::Fortify => "Fortify", + Text::Severity => "深刻度", + Text::Objects => "オブジェクト", + Text::Scan => "スキャン", + Text::OverallScore => "総合スコア", + Text::Default => "既定", + Text::CveVulnerabilities => "CVE 脆弱性", + Text::MalwareDetections => "マルウェア検出", + Text::PasswordIssues => "パスワード問題", + Text::HardeningIssues => "ハードニング問題", + Text::Capabilities => "機能", + Text::Crypto => "暗号", + Text::SoftwareBom => "ソフトウェア BOM", + Text::Kernel => "カーネル", + Text::Symbols => "シンボル", + Text::Tasks => "タスク", + Text::StackOverflow => "スタックオーバーフロー", + Text::KernelConfig => "カーネル設定", + Text::Bind => "バインド", + Text::SuccessStatus => "成功", + Text::PendingStatus => "保留", + Text::InProgressStatus => "進行中", + Text::CanceledStatus => "キャンセル済み", + Text::ErrorStatus => "エラー", + Text::Running => "実行中", + Text::Queued => "待機中", + Text::Done => "完了", + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..401cd4b --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,7 @@ +//! Shared library surface for the Analyzer CLI binary and test suite. + +pub mod client; +pub mod commands; +pub mod config; +pub mod i18n; +pub mod output; diff --git a/src/main.rs b/src/main.rs index a3ea2d3..6eabddc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,23 +3,19 @@ //! Scan firmware and container images for vulnerabilities, generate SBOMs, //! check CRA compliance, and more. -mod client; -mod commands; -mod config; -mod output; - use std::path::PathBuf; use std::process::ExitCode; use std::time::Duration; +use analyzer_cli::client::AnalyzerClient; +use analyzer_cli::client::models::{AnalysisType, ComplianceType}; +use analyzer_cli::i18n::{self, Language}; +use analyzer_cli::output; +use analyzer_cli::output::Format; use anyhow::Result; use clap::{Parser, Subcommand}; use uuid::Uuid; -use crate::client::AnalyzerClient; -use crate::client::models::{AnalysisType, ComplianceType}; -use crate::output::Format; - /// Exein Analyzer CLI — firmware & container security scanning. /// /// Scan images for CVEs, generate SBOMs, check CRA compliance, and more. @@ -50,6 +46,17 @@ struct Cli { #[arg(long, global = true, value_enum, default_value_t = Format::Human)] format: Format, + /// Human language for themed CLI output. + #[arg( + long = "lang", + global = true, + alias = "language", + env = "ANALYZER_LANG", + value_enum, + default_value_t = Language::English + )] + language: Language, + #[command(subcommand)] command: Command, } @@ -360,27 +367,43 @@ async fn run(cli: Cli) -> Result<()> { let url = cli.url; let profile = cli.profile; let format = cli.format; + let language = cli.language; + let show_welcome = + matches!(format, Format::Human) && !matches!(&cli.command, Command::Completions { .. }); + + i18n::set_language(language); + + if show_welcome { + output::print_welcome(); + } match cli.command { // -- Auth (no API key required) ----------------------------------- Command::Login { url: login_url, profile: login_profile, - } => commands::auth::run_login(login_url.as_deref(), login_profile.as_deref()).await, - - Command::Whoami => { - commands::auth::run_whoami(api_key.as_deref(), url.as_deref(), profile.as_deref()) + } => { + analyzer_cli::commands::auth::run_login(login_url.as_deref(), login_profile.as_deref()) + .await } + Command::Whoami => analyzer_cli::commands::auth::run_whoami( + api_key.as_deref(), + url.as_deref(), + profile.as_deref(), + ), + // -- Config (no API key required) --------------------------------- Command::Config(cmd) => match cmd { - ConfigCommand::Show => commands::config::run_show(), + ConfigCommand::Show => analyzer_cli::commands::config::run_show(), ConfigCommand::Set { key, value, profile: p, - } => commands::config::run_set(&key, &value, p.as_deref()), - ConfigCommand::Get { key, profile: p } => commands::config::run_get(&key, p.as_deref()), + } => analyzer_cli::commands::config::run_set(&key, &value, p.as_deref()), + ConfigCommand::Get { key, profile: p } => { + analyzer_cli::commands::config::run_get(&key, p.as_deref()) + } }, // -- Completions (no API key required) ---------------------------- @@ -394,13 +417,26 @@ async fn run(cli: Cli) -> Result<()> { Command::Object(cmd) => { let client = make_client(api_key.as_deref(), url.as_deref(), profile.as_deref())?; match cmd { - ObjectCommand::List => commands::object::run_list(&client, format).await, + ObjectCommand::List => { + analyzer_cli::commands::object::run_list(&client, format).await + } ObjectCommand::New { name, description, tags, - } => commands::object::run_new(&client, name, description, tags, format).await, - ObjectCommand::Delete { id } => commands::object::run_delete(&client, id).await, + } => { + analyzer_cli::commands::object::run_new( + &client, + name, + description, + tags, + format, + ) + .await + } + ObjectCommand::Delete { id } => { + analyzer_cli::commands::object::run_delete(&client, id).await + } } } @@ -416,21 +452,29 @@ async fn run(cli: Cli) -> Result<()> { interval, timeout, } => { - commands::scan::run_new( + analyzer_cli::commands::scan::run_new( &client, object_id, file, scan_type, analyses, format, wait, interval, timeout, ) .await } - ScanCommand::Delete { id } => commands::scan::run_delete(&client, id).await, - ScanCommand::Cancel { id } => commands::scan::run_cancel(&client, id).await, + ScanCommand::Delete { id } => { + analyzer_cli::commands::scan::run_delete(&client, id).await + } + ScanCommand::Cancel { id } => { + analyzer_cli::commands::scan::run_cancel(&client, id).await + } ScanCommand::Status { scan_id, object_id } => { - let sid = commands::scan::resolve_scan_id(&client, scan_id, object_id).await?; - commands::scan::run_status(&client, sid, format).await + let sid = + analyzer_cli::commands::scan::resolve_scan_id(&client, scan_id, object_id) + .await?; + analyzer_cli::commands::scan::run_status(&client, sid, format).await } ScanCommand::Score { scan_id, object_id } => { - let sid = commands::scan::resolve_scan_id(&client, scan_id, object_id).await?; - commands::scan::run_score(&client, sid, format).await + let sid = + analyzer_cli::commands::scan::resolve_scan_id(&client, scan_id, object_id) + .await?; + analyzer_cli::commands::scan::run_score(&client, sid, format).await } ScanCommand::Report { scan_id, @@ -440,8 +484,13 @@ async fn run(cli: Cli) -> Result<()> { interval, timeout, } => { - let sid = commands::scan::resolve_scan_id(&client, scan_id, object_id).await?; - commands::scan::run_report(&client, sid, output, wait, interval, timeout).await + let sid = + analyzer_cli::commands::scan::resolve_scan_id(&client, scan_id, object_id) + .await?; + analyzer_cli::commands::scan::run_report( + &client, sid, output, wait, interval, timeout, + ) + .await } ScanCommand::Sbom { scan_id, @@ -451,8 +500,13 @@ async fn run(cli: Cli) -> Result<()> { interval, timeout, } => { - let sid = commands::scan::resolve_scan_id(&client, scan_id, object_id).await?; - commands::scan::run_sbom(&client, sid, output, wait, interval, timeout).await + let sid = + analyzer_cli::commands::scan::resolve_scan_id(&client, scan_id, object_id) + .await?; + analyzer_cli::commands::scan::run_sbom( + &client, sid, output, wait, interval, timeout, + ) + .await } ScanCommand::ComplianceReport { scan_id, @@ -463,8 +517,10 @@ async fn run(cli: Cli) -> Result<()> { interval, timeout, } => { - let sid = commands::scan::resolve_scan_id(&client, scan_id, object_id).await?; - commands::scan::run_compliance_report( + let sid = + analyzer_cli::commands::scan::resolve_scan_id(&client, scan_id, object_id) + .await?; + analyzer_cli::commands::scan::run_compliance_report( &client, sid, compliance_type, @@ -475,10 +531,14 @@ async fn run(cli: Cli) -> Result<()> { ) .await } - ScanCommand::Types => commands::scan::run_types(&client, format).await, + ScanCommand::Types => { + analyzer_cli::commands::scan::run_types(&client, format).await + } ScanCommand::Overview { scan_id, object_id } => { - let sid = commands::scan::resolve_scan_id(&client, scan_id, object_id).await?; - commands::scan::run_overview(&client, sid, format).await + let sid = + analyzer_cli::commands::scan::resolve_scan_id(&client, scan_id, object_id) + .await?; + analyzer_cli::commands::scan::run_overview(&client, sid, format).await } ScanCommand::Results { scan_id, @@ -488,8 +548,10 @@ async fn run(cli: Cli) -> Result<()> { per_page, search, } => { - let sid = commands::scan::resolve_scan_id(&client, scan_id, object_id).await?; - commands::scan::run_results( + let sid = + analyzer_cli::commands::scan::resolve_scan_id(&client, scan_id, object_id) + .await?; + analyzer_cli::commands::scan::run_results( &client, sid, analysis, page, per_page, search, format, ) .await @@ -499,8 +561,16 @@ async fn run(cli: Cli) -> Result<()> { object_id, compliance_type, } => { - let sid = commands::scan::resolve_scan_id(&client, scan_id, object_id).await?; - commands::scan::run_compliance(&client, sid, compliance_type, format).await + let sid = + analyzer_cli::commands::scan::resolve_scan_id(&client, scan_id, object_id) + .await?; + analyzer_cli::commands::scan::run_compliance( + &client, + sid, + compliance_type, + format, + ) + .await } } } @@ -512,6 +582,6 @@ fn make_client( url: Option<&str>, profile: Option<&str>, ) -> Result { - let cfg = config::resolve(api_key, url, profile)?; + let cfg = analyzer_cli::config::resolve(api_key, url, profile)?; AnalyzerClient::new(cfg.url, &cfg.api_key) } diff --git a/src/output.rs b/src/output.rs index 83316bc..ccb187b 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,8 +1,13 @@ //! Output formatting: human (colored), JSON, and table modes. +use std::fmt::Display; +use std::sync::atomic::{AtomicBool, Ordering}; + use console::style; use owo_colors::OwoColorize; +use crate::i18n::{self, Text}; + /// Output format selected by the user. #[derive(Debug, Clone, Copy, Default, clap::ValueEnum)] pub enum Format { @@ -15,24 +20,107 @@ pub enum Format { Table, } +const EXEIN_ASCII: [&str; 6] = [ + r" ███████╗██╗ ██╗███████╗██╗███╗ ██╗", + r" ██╔════╝╚██╗██╔╝██╔════╝██║████╗ ██║", + r" █████╗ ╚███╔╝ █████╗ ██║██╔██╗ ██║", + r" ██╔══╝ ██╔██╗ ██╔══╝ ██║██║╚██╗██║", + r" ███████╗██╔╝ ██╗███████╗██║██║ ╚████║", + r" ╚══════╝╚═╝ ╚═╝╚══════╝╚═╝╚═╝ ╚═══╝", +]; + +pub fn print_welcome() { + static PRINTED: AtomicBool = AtomicBool::new(false); + + if PRINTED.swap(true, Ordering::AcqRel) { + return; + } + + eprintln!(); + for (idx, line) in EXEIN_ASCII.iter().enumerate() { + let colored = match idx % 3 { + 0 => line.bright_cyan().bold().to_string(), + 1 => line.bright_blue().bold().to_string(), + _ => line.bright_magenta().bold().to_string(), + }; + eprintln!("{colored}"); + } + eprintln!( + " {} {}", + "🛡️".bright_cyan(), + style(i18n::tagline()).bold().white() + ); + eprintln!( + " {} {} {} v{} {} {}", + "✨".bright_magenta(), + style(i18n::subtitle()).dim(), + "🚀".bright_green(), + env!("CARGO_PKG_VERSION"), + "🌍".bright_yellow(), + style(i18n::language_name()).bold() + ); + eprintln!(); +} + +pub fn hero(title: &str, subtitle: &str) { + eprintln!(" {} {}", "◆".bright_cyan(), style(title).bold().white()); + eprintln!(" {} {}", "•".bright_magenta(), style(subtitle).dim()); + eprintln!(); +} + +pub fn key_value(label: &str, value: impl Display) { + eprintln!(" {:>16} {}", style(format!("{label}:")).bold(), value); +} + +pub fn command_hint(command: &str, description: &str) { + eprintln!( + " {} {} {}", + "👉".bright_cyan(), + style("analyzer").bold(), + style(command).cyan() + ); + eprintln!(" {}", style(description).dim()); +} + /// Print a success message to stderr. pub fn success(msg: &str) { - eprintln!(" {} {msg}", style("OK").green().bold()); + eprintln!( + " {} {msg}", + style(format!("✅ {}", i18n::text(Text::Ok))).green().bold() + ); } /// Print a warning message to stderr. pub fn warning(msg: &str) { - eprintln!(" {} {msg}", style("WARN").yellow().bold()); + eprintln!( + " {} {msg}", + style(format!("⚠️ {}", i18n::text(Text::Warning))) + .yellow() + .bold() + ); } /// Print an error message to stderr. pub fn error(msg: &str) { - eprintln!(" {} {msg}", style("ERR").red().bold()); + eprintln!( + " {} {msg}", + style(format!("❌ {}", i18n::text(Text::Error))) + .red() + .bold() + ); } /// Print a labelled status line to stderr. pub fn status(label: &str, msg: &str) { - eprintln!("{} {msg}", style(format!("{label:>12}")).cyan().bold()); + if label.is_empty() { + eprintln!(" {} {msg}", style("⏳").cyan().bold()); + } else { + eprintln!( + " {} {}", + style(format!("{label:>12}")).cyan().bold(), + style(msg).white() + ); + } } /// Format a score with colour coding. @@ -47,12 +135,13 @@ pub fn format_score(score: Option) -> String { /// Format an analysis status string with colour. pub fn format_status(status: &str) -> String { + let display = i18n::status_display(status); match status { - "success" => style(status).green().to_string(), - "pending" => style(status).dim().to_string(), - "in-progress" => style(status).cyan().to_string(), - "canceled" => style(status).yellow().to_string(), - "error" => style(status).red().to_string(), - other => other.to_string(), + "success" => style(display.as_ref()).green().to_string(), + "pending" => style(display.as_ref()).dim().to_string(), + "in-progress" => style(display.as_ref()).cyan().to_string(), + "canceled" => style(display.as_ref()).yellow().to_string(), + "error" => style(display.as_ref()).red().to_string(), + _ => display.into_owned(), } } diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..f930a51 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,100 @@ +#![allow(dead_code)] + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard, OnceLock}; + +pub struct EnvGuard { + _lock: MutexGuard<'static, ()>, + previous: HashMap>, +} + +impl EnvGuard { + pub fn new(vars: &[(&str, Option)]) -> Self { + let lock = env_lock().lock().expect("environment mutex poisoned"); + let mut previous = HashMap::new(); + + for (key, value) in vars { + previous.insert((*key).to_string(), std::env::var(key).ok()); + match value { + Some(v) => unsafe { std::env::set_var(key, v) }, + None => unsafe { std::env::remove_var(key) }, + } + } + + Self { + _lock: lock, + previous, + } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + for (key, value) in &self.previous { + match value { + Some(v) => unsafe { std::env::set_var(key, v) }, + None => unsafe { std::env::remove_var(key) }, + } + } + } +} + +pub fn config_env(temp_root: &Path) -> Vec<(&'static str, String)> { + let appdata = temp_root.join("appdata"); + let xdg = temp_root.join("xdg"); + let home = temp_root.join("home"); + let config_root = temp_root.join("config-root"); + + std::fs::create_dir_all(&appdata).expect("failed to create appdata temp dir"); + std::fs::create_dir_all(&xdg).expect("failed to create xdg temp dir"); + std::fs::create_dir_all(&home).expect("failed to create home temp dir"); + std::fs::create_dir_all(&config_root).expect("failed to create config root temp dir"); + + vec![ + ("APPDATA", appdata.display().to_string()), + ("XDG_CONFIG_HOME", xdg.display().to_string()), + ("HOME", home.display().to_string()), + ("USERPROFILE", home.display().to_string()), + ("ANALYZER_CONFIG_DIR", config_root.display().to_string()), + ] +} + +pub fn apply_config_env(temp_root: &Path) -> EnvGuard { + apply_full_env(temp_root, &[]) +} + +pub fn set_runtime_env(pairs: &[(&str, &str)]) -> EnvGuard { + let vars: Vec<(&str, Option)> = pairs + .iter() + .map(|(k, v)| (*k, Some((*v).to_string()))) + .collect(); + EnvGuard::new(&vars) +} + +pub fn apply_full_env(temp_root: &Path, pairs: &[(&str, &str)]) -> EnvGuard { + let mut vars: Vec<(&str, Option)> = config_env(temp_root) + .into_iter() + .map(|(k, v)| (k, Some(v))) + .collect(); + + vars.extend([ + ("ANALYZER_API_KEY", None), + ("ANALYZER_URL", None), + ("ANALYZER_PROFILE", None), + ("ANALYZER_LANG", None), + ]); + + vars.extend(pairs.iter().map(|(k, v)| (*k, Some((*v).to_string())))); + + EnvGuard::new(&vars) +} + +pub fn config_file_path(temp_root: &Path) -> PathBuf { + temp_root.join("config-root").join("config.toml") +} + +fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} diff --git a/tests/e2e.rs b/tests/e2e.rs new file mode 100644 index 0000000..d4a6c7d --- /dev/null +++ b/tests/e2e.rs @@ -0,0 +1,4 @@ +mod common; + +#[path = "e2e/cli_smoke.rs"] +mod cli_smoke; diff --git a/tests/e2e/cli_smoke.rs b/tests/e2e/cli_smoke.rs new file mode 100644 index 0000000..2c1bb8f --- /dev/null +++ b/tests/e2e/cli_smoke.rs @@ -0,0 +1,63 @@ +use assert_cmd::Command; +use predicates::prelude::*; + +use crate::common::config_env; + +#[test] +fn top_level_help_lists_key_commands() { + Command::cargo_bin("analyzer") + .expect("binary") + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("login")) + .stdout(predicate::str::contains("scan")) + .stdout(predicate::str::contains("object")) + .stdout(predicate::str::contains("config")); +} + +#[test] +fn completions_bash_mentions_binary_name() { + Command::cargo_bin("analyzer") + .expect("binary") + .args(["completions", "bash"]) + .assert() + .success() + .stdout(predicate::str::contains("_analyzer")); +} + +#[test] +fn whoami_masks_api_key_from_environment() { + let temp = tempfile::tempdir().expect("tempdir"); + let envs = config_env(temp.path()); + + let mut cmd = Command::cargo_bin("analyzer").expect("binary"); + for (key, value) in &envs { + cmd.env(key, value); + } + cmd.env("ANALYZER_API_KEY", "super-secret-key") + .env("ANALYZER_URL", "https://whoami.example/api/") + .args(["whoami"]) + .assert() + .success() + .stderr(predicate::str::contains("supe...-key")) + .stderr(predicate::str::contains("https://whoami.example/api/")); +} + +#[test] +fn whoami_supports_localized_human_output() { + let temp = tempfile::tempdir().expect("tempdir"); + let envs = config_env(temp.path()); + + let mut cmd = Command::cargo_bin("analyzer").expect("binary"); + for (key, value) in &envs { + cmd.env(key, value); + } + + cmd.env("ANALYZER_API_KEY", "localized-key") + .args(["--lang", "fr", "whoami"]) + .assert() + .success() + .stderr(predicate::str::contains("Profil:")) + .stderr(predicate::str::contains("Cle API:")); +} diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..1f3efb4 --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,4 @@ +mod common; + +#[path = "integration/config_commands.rs"] +mod config_commands; diff --git a/tests/integration/config_commands.rs b/tests/integration/config_commands.rs new file mode 100644 index 0000000..76ebc9a --- /dev/null +++ b/tests/integration/config_commands.rs @@ -0,0 +1,36 @@ +use analyzer_cli::commands::config::{run_get, run_set}; +use analyzer_cli::config::ConfigFile; + +use crate::common::apply_config_env; + +#[test] +fn config_set_and_get_round_trip() { + let temp = tempfile::tempdir().expect("tempdir"); + let _env = apply_config_env(temp.path()); + + run_set( + "url", + "https://integration.example/api/", + Some("integration"), + ) + .expect("set config url"); + + let config = ConfigFile::load().expect("load config"); + let profile = config.profile(Some("integration")); + assert_eq!( + profile.url.as_deref(), + Some("https://integration.example/api/") + ); + + run_get("url", Some("integration")).expect("get config url"); +} + +#[test] +fn config_set_rejects_invalid_url() { + let temp = tempfile::tempdir().expect("tempdir"); + let _env = apply_config_env(temp.path()); + + let error = + run_set("url", "not-a-url", Some("integration")).expect_err("invalid url should fail"); + assert!(format!("{error:#}").contains("invalid URL: not-a-url")); +} diff --git a/tests/unit.rs b/tests/unit.rs new file mode 100644 index 0000000..2a3d26b --- /dev/null +++ b/tests/unit.rs @@ -0,0 +1,7 @@ +mod common; + +#[path = "unit/config_resolution.rs"] +mod config_resolution; + +#[path = "unit/output_formatting.rs"] +mod output_formatting; diff --git a/tests/unit/config_resolution.rs b/tests/unit/config_resolution.rs new file mode 100644 index 0000000..52587f8 --- /dev/null +++ b/tests/unit/config_resolution.rs @@ -0,0 +1,64 @@ +use analyzer_cli::config::{ConfigFile, Profile, resolve}; + +use crate::common::{apply_config_env, apply_full_env}; + +#[test] +fn resolve_prefers_cli_values_over_env_and_profile() { + let temp = tempfile::tempdir().expect("tempdir"); + let _env = apply_full_env( + temp.path(), + &[ + ("ANALYZER_API_KEY", "env-key"), + ("ANALYZER_URL", "https://env.example/api/"), + ("ANALYZER_PROFILE", "team"), + ], + ); + + let mut config = ConfigFile { + default_profile: "team".to_string(), + ..ConfigFile::default() + }; + config.profiles.insert( + "team".to_string(), + Profile { + api_key: Some("profile-key".to_string()), + url: Some("https://profile.example/api/".to_string()), + }, + ); + config.save().expect("save config"); + + let resolved = resolve( + Some("cli-key"), + Some("https://cli.example/api/"), + Some("team"), + ) + .expect("resolve config"); + + assert_eq!(resolved.api_key, "cli-key"); + assert_eq!(resolved.url.as_str(), "https://cli.example/api/"); + assert_eq!(resolved.profile, "team"); +} + +#[test] +fn resolve_uses_default_url_when_missing() { + let temp = tempfile::tempdir().expect("tempdir"); + let _env = apply_full_env(temp.path(), &[("ANALYZER_API_KEY", "env-key")]); + + let resolved = resolve(None, None, None).expect("resolve config"); + + assert_eq!(resolved.api_key, "env-key"); + assert_eq!(resolved.url.as_str(), "https://analyzer.exein.io/api/"); + assert_eq!(resolved.profile, "default"); +} + +#[test] +fn resolve_errors_when_api_key_is_missing() { + let temp = tempfile::tempdir().expect("tempdir"); + let _env = apply_config_env(temp.path()); + + let error = resolve(None, None, None).expect_err("missing api key should fail"); + let message = format!("{error:#}"); + + assert!(message.contains("no API key provided")); + assert!(message.contains("analyzer login")); +} diff --git a/tests/unit/output_formatting.rs b/tests/unit/output_formatting.rs new file mode 100644 index 0000000..455f248 --- /dev/null +++ b/tests/unit/output_formatting.rs @@ -0,0 +1,31 @@ +use analyzer_cli::client::models::AnalysisStatus; +use analyzer_cli::output::{format_score, format_status}; + +#[test] +fn format_score_handles_none_and_thresholds() { + let empty = format_score(None); + let high = format_score(Some(95)); + let medium = format_score(Some(60)); + let low = format_score(Some(15)); + + assert!(empty.contains("--")); + assert!(high.contains("95")); + assert!(medium.contains("60")); + assert!(low.contains("15")); +} + +#[test] +fn format_status_handles_known_and_unknown_values() { + assert!(format_status("success").contains("success")); + assert!(format_status("pending").contains("pending")); + assert_eq!(format_status("mystery"), "mystery"); +} + +#[test] +fn analysis_status_display_uses_expected_api_strings() { + assert_eq!(AnalysisStatus::Success.to_string(), "success"); + assert_eq!(AnalysisStatus::Pending.to_string(), "pending"); + assert_eq!(AnalysisStatus::InProgress.to_string(), "in-progress"); + assert_eq!(AnalysisStatus::Canceled.to_string(), "canceled"); + assert_eq!(AnalysisStatus::Error.to_string(), "error"); +}