From faf5b273289a42284edbdeed3f1a818becbd9e0a Mon Sep 17 00:00:00 2001 From: Zane Walker Date: Fri, 25 Sep 2026 21:33:46 +0530 Subject: [PATCH] feat: ship Rust login manager and GitLab release pipeline Replace the legacy Go implementation as the production path, quarantine the reference tree, add deterministic release packaging and version metadata, and run verification through GitLab CI. --- .dockerignore | 6 + .github/workflows/check.yaml | 16 - .github/workflows/release.yml | 296 ---- .github/workflows/rust.yml | 54 - .gitignore | 9 +- .gitlab-ci.yml | 127 ++ CHANGELOG.md | 16 + Cargo.lock | 2296 +++++++++++++++++++++++++ Cargo.toml | 40 + Dockerfile.test | 20 + LICENSE | 201 +++ Makefile | 272 +-- README.md | 286 ++- build.rs | 41 + crates/latchd-auth/Cargo.toml | 11 + crates/latchd-auth/src/lib.rs | 548 ++++++ crates/latchd-core/Cargo.toml | 17 + crates/latchd-core/src/accounting.rs | 97 ++ crates/latchd-core/src/accounts.rs | 237 +++ crates/latchd-core/src/config.rs | 380 ++++ crates/latchd-core/src/error.rs | 68 + crates/latchd-core/src/lib.rs | 15 + crates/latchd-core/src/session.rs | 861 ++++++++++ crates/latchd-tui/Cargo.toml | 16 + crates/latchd-tui/src/background.rs | 267 +++ crates/latchd-tui/src/lib.rs | 901 ++++++++++ extra/config.toml | 53 +- extra/latchd.pam | 1 + extra/latchd.service | 4 + extra/xsetup.sh | 41 - legacy/README.md | 9 + {src => legacy/go}/auth/faillock.go | 0 {src => legacy/go}/auth/pam.go | 2 +- {src => legacy/go}/auth/utmpx.go | 0 {src => legacy/go}/auth/utmpx_time.h | 1 + {src => legacy/go}/config/config.go | 34 +- {src => legacy/go}/go.mod | 6 +- {src => legacy/go}/go.sum | 9 +- {src => legacy/go}/login/login.go | 18 +- {src => legacy/go}/main.go | 0 {src => legacy/go}/session/env.go | 0 {src => legacy/go}/session/exec.go | 12 +- {src => legacy/go}/session/scan.go | 0 {src => legacy/go}/session/wayland.go | 0 {src => legacy/go}/session/x11.go | 0 {src => legacy/go}/tui/bg/automata.go | 8 +- rust-toolchain.toml | 4 + rust/main.rs | 237 +++ scripts/test-docker.sh | 5 + 49 files changed, 6795 insertions(+), 747 deletions(-) create mode 100644 .dockerignore delete mode 100644 .github/workflows/check.yaml delete mode 100644 .github/workflows/release.yml delete mode 100644 .github/workflows/rust.yml create mode 100644 .gitlab-ci.yml create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Dockerfile.test create mode 100644 LICENSE create mode 100644 build.rs create mode 100644 crates/latchd-auth/Cargo.toml create mode 100644 crates/latchd-auth/src/lib.rs create mode 100644 crates/latchd-core/Cargo.toml create mode 100644 crates/latchd-core/src/accounting.rs create mode 100644 crates/latchd-core/src/accounts.rs create mode 100644 crates/latchd-core/src/config.rs create mode 100644 crates/latchd-core/src/error.rs create mode 100644 crates/latchd-core/src/lib.rs create mode 100644 crates/latchd-core/src/session.rs create mode 100644 crates/latchd-tui/Cargo.toml create mode 100644 crates/latchd-tui/src/background.rs create mode 100644 crates/latchd-tui/src/lib.rs delete mode 100755 extra/xsetup.sh create mode 100644 legacy/README.md rename {src => legacy/go}/auth/faillock.go (100%) rename {src => legacy/go}/auth/pam.go (99%) rename {src => legacy/go}/auth/utmpx.go (100%) rename {src => legacy/go}/auth/utmpx_time.h (95%) rename {src => legacy/go}/config/config.go (89%) rename {src => legacy/go}/go.mod (82%) rename {src => legacy/go}/go.sum (83%) rename {src => legacy/go}/login/login.go (97%) rename {src => legacy/go}/main.go (100%) rename {src => legacy/go}/session/env.go (100%) rename {src => legacy/go}/session/exec.go (94%) rename {src => legacy/go}/session/scan.go (100%) rename {src => legacy/go}/session/wayland.go (100%) rename {src => legacy/go}/session/x11.go (100%) rename {src => legacy/go}/tui/bg/automata.go (97%) create mode 100644 rust-toolchain.toml create mode 100644 rust/main.rs create mode 100755 scripts/test-docker.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e6cb991 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +target +build +dist +legacy +.git +tui-design-skill/.git diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml deleted file mode 100644 index 4765913..0000000 --- a/.github/workflows/check.yaml +++ /dev/null @@ -1,16 +0,0 @@ -name: Integration Check CI - -on: - push: - branches: - - main - pull_request: - -jobs: - check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: DeterminateSystems/nix-installer-action@main - - uses: DeterminateSystems/magic-nix-cache-action@main - - run: nix -L flake check \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 3c59af5..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,296 +0,0 @@ -# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist -# -# Copyright 2022-2024, axodotdev -# SPDX-License-Identifier: MIT or Apache-2.0 -# -# CI that: -# -# * checks for a Git Tag that looks like a release -# * builds artifacts with dist (archives, installers, hashes) -# * uploads those artifacts to temporary workflow zip -# * on success, uploads the artifacts to a GitHub Release -# -# Note that the GitHub Release will be created with a generated -# title/body based on your changelogs. - -name: Release -permissions: - "contents": "write" - -# This task will run whenever you push a git tag that looks like a version -# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. -# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where -# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION -# must be a Cargo-style SemVer Version (must have at least major.minor.patch). -# -# If PACKAGE_NAME is specified, then the announcement will be for that -# package (erroring out if it doesn't have the given version or isn't dist-able). -# -# If PACKAGE_NAME isn't specified, then the announcement will be for all -# (dist-able) packages in the workspace with that version (this mode is -# intended for workspaces with only one dist-able package, or with all dist-able -# packages versioned/released in lockstep). -# -# If you push multiple tags at once, separate instances of this workflow will -# spin up, creating an independent announcement for each one. However, GitHub -# will hard limit this to 3 tags per commit, as it will assume more tags is a -# mistake. -# -# If there's a prerelease-style suffix to the version, then the release(s) -# will be marked as a prerelease. -on: - pull_request: - push: - tags: - - '**[0-9]+.[0-9]+.[0-9]+*' - -jobs: - # Run 'dist plan' (or host) to determine what tasks we need to do - plan: - runs-on: "ubuntu-22.04" - outputs: - val: ${{ steps.plan.outputs.manifest }} - tag: ${{ !github.event.pull_request && github.ref_name || '' }} - tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} - publishing: ${{ !github.event.pull_request }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - submodules: recursive - - name: Install dist - # we specify bash to get pipefail; it guards against the `curl` command - # failing. otherwise `sh` won't catch that `curl` returned non-0 - shell: bash - run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.3/cargo-dist-installer.sh | sh" - - name: Cache dist - uses: actions/upload-artifact@v4 - with: - name: cargo-dist-cache - path: ~/.cargo/bin/dist - # sure would be cool if github gave us proper conditionals... - # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible - # functionality based on whether this is a pull_request, and whether it's from a fork. - # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* - # but also really annoying to build CI around when it needs secrets to work right.) - - id: plan - run: | - dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json - echo "dist ran successfully" - cat plan-dist-manifest.json - echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" - - name: "Upload dist-manifest.json" - uses: actions/upload-artifact@v4 - with: - name: artifacts-plan-dist-manifest - path: plan-dist-manifest.json - - # Build and packages all the platform-specific things - build-local-artifacts: - name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) - # Let the initial task tell us to not run (currently very blunt) - needs: - - plan - if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} - strategy: - fail-fast: false - # Target platforms/runners are computed by dist in create-release. - # Each member of the matrix has the following arguments: - # - # - runner: the github runner - # - dist-args: cli flags to pass to dist - # - install-dist: expression to run to install dist on the runner - # - # Typically there will be: - # - 1 "global" task that builds universal installers - # - N "local" tasks that build each platform's binaries and platform-specific installers - matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} - runs-on: ${{ matrix.runner }} - container: ${{ matrix.container && matrix.container.image || null }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json - steps: - - name: enable windows longpaths - run: | - git config --global core.longpaths true - - uses: actions/checkout@v4 - with: - persist-credentials: false - submodules: recursive - - name: Install Rust non-interactively if not already installed - if: ${{ matrix.container }} - run: | - if ! command -v cargo > /dev/null 2>&1; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y - echo "$HOME/.cargo/bin" >> $GITHUB_PATH - fi - - name: Install dist - run: ${{ matrix.install_dist.run }} - # Get the dist-manifest - - name: Fetch local artifacts - uses: actions/download-artifact@v4 - with: - pattern: artifacts-* - path: target/distrib/ - merge-multiple: true - - name: Install dependencies - run: | - ${{ matrix.packages_install }} - - name: Build artifacts - run: | - # Actually do builds and make zips and whatnot - dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json - echo "dist ran successfully" - - id: cargo-dist - name: Post-build - # We force bash here just because github makes it really hard to get values up - # to "real" actions without writing to env-vars, and writing to env-vars has - # inconsistent syntax between shell and powershell. - shell: bash - run: | - # Parse out what we just built and upload it to scratch storage - echo "paths<> "$GITHUB_OUTPUT" - dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" - echo "EOF" >> "$GITHUB_OUTPUT" - - cp dist-manifest.json "$BUILD_MANIFEST_NAME" - - name: "Upload artifacts" - uses: actions/upload-artifact@v4 - with: - name: artifacts-build-local-${{ join(matrix.targets, '_') }} - path: | - ${{ steps.cargo-dist.outputs.paths }} - ${{ env.BUILD_MANIFEST_NAME }} - - # Build and package all the platform-agnostic(ish) things - build-global-artifacts: - needs: - - plan - - build-local-artifacts - runs-on: "ubuntu-22.04" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - submodules: recursive - - name: Install cached dist - uses: actions/download-artifact@v4 - with: - name: cargo-dist-cache - path: ~/.cargo/bin/ - - run: chmod +x ~/.cargo/bin/dist - # Get all the local artifacts for the global tasks to use (for e.g. checksums) - - name: Fetch local artifacts - uses: actions/download-artifact@v4 - with: - pattern: artifacts-* - path: target/distrib/ - merge-multiple: true - - id: cargo-dist - shell: bash - run: | - dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json - echo "dist ran successfully" - - # Parse out what we just built and upload it to scratch storage - echo "paths<> "$GITHUB_OUTPUT" - jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" - echo "EOF" >> "$GITHUB_OUTPUT" - - cp dist-manifest.json "$BUILD_MANIFEST_NAME" - - name: "Upload artifacts" - uses: actions/upload-artifact@v4 - with: - name: artifacts-build-global - path: | - ${{ steps.cargo-dist.outputs.paths }} - ${{ env.BUILD_MANIFEST_NAME }} - # Determines if we should publish/announce - host: - needs: - - plan - - build-local-artifacts - - build-global-artifacts - # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) - if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - runs-on: "ubuntu-22.04" - outputs: - val: ${{ steps.host.outputs.manifest }} - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - submodules: recursive - - name: Install cached dist - uses: actions/download-artifact@v4 - with: - name: cargo-dist-cache - path: ~/.cargo/bin/ - - run: chmod +x ~/.cargo/bin/dist - # Fetch artifacts from scratch-storage - - name: Fetch artifacts - uses: actions/download-artifact@v4 - with: - pattern: artifacts-* - path: target/distrib/ - merge-multiple: true - - id: host - shell: bash - run: | - dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json - echo "artifacts uploaded and released successfully" - cat dist-manifest.json - echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" - - name: "Upload dist-manifest.json" - uses: actions/upload-artifact@v4 - with: - # Overwrite the previous copy - name: artifacts-dist-manifest - path: dist-manifest.json - # Create a GitHub Release while uploading all files to it - - name: "Download GitHub Artifacts" - uses: actions/download-artifact@v4 - with: - pattern: artifacts-* - path: artifacts - merge-multiple: true - - name: Cleanup - run: | - # Remove the granular manifests - rm -f artifacts/*-dist-manifest.json - - name: Create GitHub Release - env: - PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" - ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" - ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" - RELEASE_COMMIT: "${{ github.sha }}" - run: | - # Write and read notes from a file to avoid quoting breaking things - echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt - - gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* - - announce: - needs: - - plan - - host - # use "always() && ..." to allow us to wait for all publish jobs while - # still allowing individual publish jobs to skip themselves (for prereleases). - # "host" however must run to completion, no skipping allowed! - if: ${{ always() && needs.host.result == 'success' }} - runs-on: "ubuntu-22.04" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - submodules: recursive diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml deleted file mode 100644 index 167bfb0..0000000 --- a/.github/workflows/rust.yml +++ /dev/null @@ -1,54 +0,0 @@ -on: - pull_request: - push: - branches: - - main - -name: Continuous integration - -jobs: - check: - name: Check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions-rs/toolchain@v1 - with: - profile: minimal - toolchain: stable - override: true - - uses: actions-rs/cargo@v1 - with: - command: check - - fmt: - name: Rustfmt - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions-rs/toolchain@v1 - with: - profile: minimal - toolchain: stable - override: true - - run: rustup component add rustfmt - - uses: actions-rs/cargo@v1 - with: - command: fmt - args: --all -- --check - - clippy: - name: Clippy - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions-rs/toolchain@v1 - with: - profile: minimal - toolchain: stable - override: true - - run: rustup component add clippy - - uses: actions-rs/cargo@v1 - with: - command: clippy - args: -- -D warnings \ No newline at end of file diff --git a/.gitignore b/.gitignore index 258b784..ad0f3de 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target +/dist/ go_project_build_documentation.md documentation.md @@ -6,4 +7,10 @@ session.md build/* examples/* things_todo.md -ly/* \ No newline at end of file +ly/* + +tui-design-skill/ +FEATURE_CHECKLIST.md +IMPLEMENTATION_CHECKLIST.md +audit_code.md +audit_rust_code.md diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..f677566 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,127 @@ +stages: + - verify + - container + - package + - release + +workflow: + rules: + - if: '$CI_COMMIT_TAG' + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + - if: '$CI_COMMIT_BRANCH' + +variables: + CARGO_HOME: "$CI_PROJECT_DIR/.cargo" + CARGO_TERM_COLOR: never + RUST_BACKTRACE: "1" + +.rust-job: + image: rust:1.88.0-bookworm + before_script: + - apt-get update + - >- + apt-get install --no-install-recommends -y + libpam0g-dev libpam-modules pkg-config xauth xvfb util-linux + - rustup component add rustfmt clippy + cache: + key: + files: + - Cargo.lock + - rust-toolchain.toml + paths: + - .cargo/registry/index/ + - .cargo/registry/cache/ + - .cargo/git/db/ + - .cargo/bin/ + - target/ + +rust-verify: + extends: .rust-job + stage: verify + script: + - make check COLOR=0 + - make version COLOR=0 + artifacts: + when: always + expire_in: 7 days + paths: + - target/debug/deps/ + +security-audit: + image: rust:1.88.0-bookworm + stage: verify + cache: + key: cargo-audit-0.22.2 + paths: + - .cargo/bin/ + - .cargo/registry/index/ + - .cargo/registry/cache/ + before_script: + - command -v cargo-audit >/dev/null || cargo install cargo-audit --locked --version 0.22.2 + script: + - cargo audit + +docker-verify: + image: docker:27.5.1-cli + stage: container + services: + - name: docker:27.5.1-dind + alias: docker + variables: + DOCKER_HOST: tcp://docker:2376 + DOCKER_TLS_CERTDIR: "/certs" + DOCKER_TLS_VERIFY: "1" + DOCKER_CERT_PATH: "$DOCKER_TLS_CERTDIR/client" + before_script: + - until docker info >/dev/null 2>&1; do sleep 1; done + script: + - ./scripts/test-docker.sh + rules: + - if: '$CI_COMMIT_BRANCH' + - if: '$CI_COMMIT_TAG' + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + +package: + extends: .rust-job + stage: package + needs: + - job: rust-verify + artifacts: false + - job: docker-verify + artifacts: false + - job: security-audit + artifacts: false + rules: + - if: '$CI_COMMIT_TAG =~ /^v?[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$/' + script: + - VERSION="${CI_COMMIT_TAG#v}" make package COLOR=0 TAG="$CI_COMMIT_TAG" + - (cd dist && sha256sum --check "$(basename "$(ls *.tar.gz)").sha256") + - printf 'PACKAGE_JOB_ID=%s\n' "$CI_JOB_ID" >> dist/release.env + artifacts: + name: "latchd-${CI_COMMIT_TAG}" + expire_in: 30 days + paths: + - dist/ + reports: + dotenv: dist/release.env + +release: + image: registry.gitlab.com/gitlab-org/cli:v1.103.0 + stage: release + needs: + - job: package + artifacts: true + rules: + - if: '$CI_COMMIT_TAG =~ /^v?[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$/' + script: + - test -n "$PACKAGE_JOB_ID" + - test -f dist/release.env + release: + tag_name: "$CI_COMMIT_TAG" + name: "latchd ${CI_COMMIT_TAG}" + description: "Production artifacts and checksums are attached from the reproducible package job." + assets: + links: + - name: "latchd package and SHA-256" + url: "${CI_PROJECT_URL}/-/jobs/${PACKAGE_JOB_ID}/artifacts/download?file_type=archive" + link_type: package diff --git a/CHANGELOG.md b/CHANGELOG.md index c20c087..9774d9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.3.0 — Rust workspace release preparation + +- Rust is the supported login manager; the Go/cgo prototype is quarantined at + `legacy/go/` and excluded from normal builds and release archives. +- Added workspace crates for PAM authentication, session/config/account + management, and the TUI, including account switching and advisory faillock. +- Added optional terminal-cell wallpapers, cellular automata effects, and + accessible motion/background controls. +- Added GitLab CI verification, version-checked tag releases, optimized + single-version Cargo builds, deterministic installation archives, SHA-256 + checksums, and embedded build metadata. +- Live VT, logind, PAM-policy, X11, and Wayland validation remains a deployment + requirement on an isolated VM or target host. + +## Legacy Go history (not the Rust release) + ## 0.1.0 — Unreleased first public release diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..784beac --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2296 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[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", +] + +[[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", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide 0.8.9", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.2", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.6", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[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", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "eyre" +version = "0.6.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08309dbcc659c5549a24ddb9b27027640641b282ef5768267c7e675558986a3" +dependencies = [ + "autocfg", + "indenter", + "once_cell", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "finl_unicode" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.20", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "latchd" +version = "0.3.0" +dependencies = [ + "clap", + "color-eyre", + "latchd-auth", + "latchd-core", + "latchd-tui", + "libc", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "latchd-auth" +version = "0.3.0" +dependencies = [ + "latchd-core", + "libc", + "zeroize", +] + +[[package]] +name = "latchd-core" +version = "0.3.0" +dependencies = [ + "libc", + "rand 0.9.5", + "serde", + "signal-hook", + "thiserror 2.0.20", + "toml", + "unicode-width", +] + +[[package]] +name = "latchd-tui" +version = "0.3.0" +dependencies = [ + "crossterm", + "image", + "latchd-auth", + "latchd-core", + "ratatui", + "tracing", + "unicode-width", + "zeroize", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[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 = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "owo-colors" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" + +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pest" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.8", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +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.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[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 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[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 = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.2", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.20", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.2", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.2", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.13.2", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[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.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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 = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..efd13ff --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,40 @@ +[workspace] +members = [".", "crates/latchd-core", "crates/latchd-auth", "crates/latchd-tui"] +resolver = "3" + +[workspace.package] +version = "0.3.0" +edition = "2024" +rust-version = "1.88" +license = "Apache-2.0" + +[package] +name = "latchd" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description = "A secure terminal login manager for Linux" +license.workspace = true + +[[bin]] +name = "latchd" +path = "rust/main.rs" + +[dependencies] +clap = { version = "4.5", features = ["derive", "env"] } +color-eyre = "0.6" +latchd-auth = { path = "crates/latchd-auth" } +latchd-core = { path = "crates/latchd-core" } +latchd-tui = { path = "crates/latchd-tui" } +libc = "0.2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +debug = false +incremental = false +panic = "abort" +strip = "symbols" diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..7bb4a08 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,20 @@ +FROM rust:1.88.0-bookworm + +ENV CARGO_TERM_COLOR=never +ENV RUST_BACKTRACE=1 + +RUN apt-get update \ + && apt-get install --no-install-recommends -y \ + libpam0g-dev libpam-modules xauth xvfb pkg-config \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace +COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ +COPY build.rs ./ +COPY rust ./rust +COPY crates ./crates + +RUN cargo fmt --all -- --check \ + && cargo test --locked --all-targets \ + && cargo clippy --locked --all-targets --all-features -- -D warnings \ + && RUSTDOCFLAGS="-D warnings" cargo doc --locked --no-deps diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9ade8c7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + 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 2026 Zane Walker + + 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/Makefile b/Makefile index 02ccd05..e2248a3 100644 --- a/Makefile +++ b/Makefile @@ -1,146 +1,168 @@ -BINARY := latchd -SRC_DIR := src -CMD_DIR := $(SRC_DIR) -BUILD_DIR := build +SHELL := /usr/bin/env bash +.SHELLFLAGS := -eu -o pipefail -c +.DEFAULT_GOAL := help +.DELETE_ON_ERROR: -PREFIX ?= /usr -BINDIR := $(DESTDIR)$(PREFIX)/bin -CONFDIR := $(DESTDIR)/etc/$(BINARY) -SYSTEMD := $(DESTDIR)/usr/lib/systemd/system +PROJECT := latchd +BINARY := latchd +PREFIX ?= /usr +DESTDIR ?= +BINDIR := $(DESTDIR)$(PREFIX)/bin +CONFDIR := $(DESTDIR)/etc/$(PROJECT) +SYSTEMD_DIR := $(DESTDIR)/usr/lib/systemd/system +BUILD_DIR ?= build +DIST_DIR ?= dist +CARGO_TARGET_DIR ?= target +CARGO ?= cargo +GO ?= go +TARGET ?= +TAG ?= -VERSION ?= 0.1.0 -COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") -BUILDDATE := $(shell date -u '+%Y-%m-%d_%H:%M:%S') +VERSION ?= $(shell sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1) +GIT_COMMIT ?= $(shell git rev-parse --short=12 HEAD 2>/dev/null || printf unknown) +SOURCE_DATE_EPOCH ?= $(shell git log -1 --format=%ct 2>/dev/null || date +%s) +TARGET_TRIPLE ?= $(shell rustc -vV 2>/dev/null | sed -n 's/^host: //p' || printf unknown) -LDFLAGS := -s -w \ - -X main.Version=$(VERSION) \ - -X main.Commit=$(COMMIT) \ - -X main.BuildTime=$(BUILDDATE) +TARGET_ARGS := $(if $(TARGET),--target $(TARGET),) +RELEASE_DIR := $(CARGO_TARGET_DIR)/$(if $(TARGET),$(TARGET)/)release +PACKAGE_NAME := $(PROJECT)-$(VERSION)-$(if $(TARGET),$(TARGET),$(TARGET_TRIPLE)) +PACKAGE_FILE := $(DIST_DIR)/$(PACKAGE_NAME).tar.gz +PACKAGE_STAGE := $(DIST_DIR)/stage/$(PACKAGE_NAME) +CARGO_ENV := LATCHD_VERSION=$(VERSION) LATCHD_COMMIT=$(GIT_COMMIT) LATCHD_BUILD_TIME=$(SOURCE_DATE_EPOCH) SOURCE_DATE_EPOCH=$(SOURCE_DATE_EPOCH) -GO := go -GOFLAGS := -trimpath -ldflags="$(LDFLAGS)" +COLOR ?= 1 +ifeq ($(COLOR),1) +RESET := \033[0m +DIM := \033[2m +CYAN := \033[36m +GREEN := \033[32m +YELLOW := \033[33m +RED := \033[31m +else +RESET := +DIM := +CYAN := +GREEN := +YELLOW := +RED := +endif -## ansi helpers -BOLD := \033[1m -DIM := \033[2m -RED := \033[31m -GREEN := \033[32m -YELLOW := \033[33m -BLUE := \033[34m -CYAN := \033[36m -WHITE := \033[37m -RST := \033[0m +.PHONY: all deps build release-build test check fmt lint doc audit docker-test \ + package release release-check version install uninstall clean run-preview \ + legacy-build legacy-check enable update help -## ─── targets ──────────────────────────────────────────────── +all: release-build ## Build the optimized release binary -.PHONY: all deps build install uninstall clean test lint fmt vet \ - run-preview release enable update help +version: ## Print the package, commit, and reproducibility metadata + @printf 'version=%s\ncommit=%s\nepoch=%s\ntarget=%s\n' \ + '$(VERSION)' '$(GIT_COMMIT)' '$(SOURCE_DATE_EPOCH)' '$(if $(TARGET),$(TARGET),$(TARGET_TRIPLE))' -deps: ## install system dependencies - @printf " $(BLUE)%-10s$(RST) checking dependencies...\n" "DEPS" - @if ! dpkg -s libpam0g-dev >/dev/null 2>&1; then \ - printf " $(YELLOW)%-10s$(RST) sudo apt install -y libpam0g-dev\n" ""; \ - sudo apt install -y libpam0g-dev; \ +deps: ## Check native dependencies needed for builds and tests + @command -v $(CARGO) >/dev/null || { echo 'cargo (Rust 1.88+) is required'; exit 1; } + @command -v rustfmt >/dev/null || { echo 'rustfmt is required'; exit 1; } + @command -v pkg-config >/dev/null || { echo 'pkg-config is required'; exit 1; } + @pkg-config --exists pam || { echo 'libpam development files are required'; exit 1; } + @printf '$(GREEN)dependencies ok$(RESET)\n' + +build: release-check ## Build and copy the optimized binary to build/ + @mkdir -p '$(BUILD_DIR)' + @printf '$(CYAN)building$(RESET) %s %s\n' '$(VERSION)' '$(if $(TARGET),$(TARGET),$(TARGET_TRIPLE))' + @env $(CARGO_ENV) $(CARGO) build --release --locked $(TARGET_ARGS) + @install -m755 '$(RELEASE_DIR)/$(BINARY)' '$(BUILD_DIR)/$(BINARY)' + @'$(BUILD_DIR)/$(BINARY)' --version + +release-build: build ## Alias for the optimized release build + +fmt: ## Check Rust formatting + @$(CARGO) fmt --all -- --check + +test: ## Run all Rust tests + @$(CARGO) test --locked --workspace --all-targets + +lint: ## Run Clippy with warnings denied + @$(CARGO) clippy --locked --workspace --all-targets --all-features -- -D warnings + +doc: ## Build rustdoc with warnings denied + @RUSTDOCFLAGS='-D warnings' $(CARGO) doc --locked --workspace --no-deps + +audit: ## Run cargo-audit when installed + @command -v cargo-audit >/dev/null || { echo 'cargo-audit is required; install with cargo install cargo-audit --locked'; exit 1; } + @cargo audit + +check: fmt test lint doc ## Run the complete local verification gate + +docker-test: ## Run the pinned Docker verification suite + @./scripts/test-docker.sh + +release-check: ## Validate a release version and optional tag + @[[ '$(VERSION)' =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$$ ]] || { echo 'invalid VERSION: $(VERSION)'; exit 1; } + @cargo_version="$$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)"; \ + [[ "$$cargo_version" == '$(VERSION)' ]] || { echo "Cargo version $$cargo_version does not match $(VERSION)"; exit 1; } + @if [[ -n '$(TAG)' ]]; then \ + tag_version="$$(printf %s '$(TAG)' | sed 's/^v//')"; \ + [[ "$$tag_version" == '$(VERSION)' ]] || { echo "tag $(TAG) does not match $(VERSION)"; exit 1; }; \ fi - @if ! dpkg -s build-essential >/dev/null 2>&1; then \ - printf " $(YELLOW)%-10s$(RST) sudo apt install -y build-essential\n" ""; \ - sudo apt install -y build-essential; \ - fi - @if ! command -v go >/dev/null 2>&1; then \ - printf " $(RED)%-10s$(RST) go not found — install go 1.22+\n" ""; \ - exit 1; \ - fi - @printf " $(GREEN)%-10s$(RST) dependencies ok\n" "DEPS" -all: build ## build the release binary +package: release-check build ## Create a deterministic install archive and checksum + @rm -rf '$(PACKAGE_STAGE)' + @mkdir -p '$(PACKAGE_STAGE)/usr/bin' '$(PACKAGE_STAGE)/usr/lib/systemd/system' \ + '$(PACKAGE_STAGE)/etc/pam.d' '$(PACKAGE_STAGE)/etc/$(PROJECT)/wms' '$(PACKAGE_STAGE)/etc/$(PROJECT)/wayland' + @install -m755 '$(BUILD_DIR)/$(BINARY)' '$(PACKAGE_STAGE)/usr/bin/$(BINARY)' + @install -m644 extra/config.toml '$(PACKAGE_STAGE)/etc/$(PROJECT)/config.toml' + @install -m644 extra/latchd.pam '$(PACKAGE_STAGE)/etc/pam.d/$(PROJECT)' + @install -m644 extra/latchd.service '$(PACKAGE_STAGE)/usr/lib/systemd/system/$(PROJECT).service' + @install -m644 README.md LICENSE CHANGELOG.md '$(PACKAGE_STAGE)/' + @printf 'version=%s\ncommit=%s\nepoch=%s\ntarget=%s\n' \ + '$(VERSION)' '$(GIT_COMMIT)' '$(SOURCE_DATE_EPOCH)' '$(if $(TARGET),$(TARGET),$(TARGET_TRIPLE))' \ + > '$(PACKAGE_STAGE)/BUILD-METADATA' + @mkdir -p '$(DIST_DIR)' + @tar --sort=name --numeric-owner --owner=0 --group=0 --mtime='@$(SOURCE_DATE_EPOCH)' \ + -C '$(DIST_DIR)/stage' -czf '$(PACKAGE_FILE)' '$(PACKAGE_NAME)' + @(cd '$(DIST_DIR)' && sha256sum '$(notdir $(PACKAGE_FILE))' > '$(notdir $(PACKAGE_FILE)).sha256') + @(cd '$(DIST_DIR)' && sha256sum --check '$(notdir $(PACKAGE_FILE)).sha256') + @checksum="$$(sha256sum '$(PACKAGE_FILE)' | cut -d' ' -f1)"; \ + printf 'package=%s\nsha256=%s\nversion=%s\n' \ + '$(PACKAGE_FILE)' "$$checksum" '$(VERSION)' > '$(DIST_DIR)/release.env' + @printf '$(GREEN)created$(RESET) %s\n' '$(PACKAGE_FILE)' -build: $(BUILD_DIR)/$(BINARY) ## compile latchd +release: check audit docker-test package ## Run all gates and produce release artifacts -$(BUILD_DIR)/$(BINARY): $(shell find $(SRC_DIR) -name '*.go') - @printf " $(CYAN)%-10s$(RST) %s\n" "GO" "$@" - @mkdir -p $(BUILD_DIR) - @cd $(SRC_DIR) && $(GO) build $(GOFLAGS) -o ../$@ . +install: build ## Install the binary, config, PAM policy, and systemd unit + @install -Dm755 '$(BUILD_DIR)/$(BINARY)' '$(BINDIR)/$(BINARY)' + @install -Dm644 extra/config.toml '$(CONFDIR)/config.toml' + @install -Dm644 extra/latchd.pam '$(DESTDIR)/etc/pam.d/$(PROJECT)' + @install -Dm644 extra/latchd.service '$(SYSTEMD_DIR)/$(PROJECT).service' + @install -d '$(CONFDIR)/wms' '$(CONFDIR)/wayland' -install: build ## install to $(PREFIX) - @printf " $(GREEN)%-10s$(RST) %s\n" "INSTALL" "$(BINDIR)/$(BINARY)" - @install -Dm755 $(BUILD_DIR)/$(BINARY) $(BINDIR)/$(BINARY) - @install -Dm644 extra/config.toml $(CONFDIR)/config.toml - @install -Dm755 extra/xsetup.sh $(CONFDIR)/xsetup.sh - @install -Dm644 extra/latchd.pam $(DESTDIR)/etc/pam.d/$(BINARY) - @install -Dm644 extra/latchd.service $(SYSTEMD)/$(BINARY).service - @mkdir -p $(CONFDIR)/wms $(CONFDIR)/wayland - @printf " $(GREEN)%-10s$(RST) done — run 'systemctl enable $(BINARY)' to activate\n" "INSTALL" +uninstall: ## Remove files installed by make install + @rm -f '$(BINDIR)/$(BINARY)' '$(DESTDIR)/etc/pam.d/$(PROJECT)' '$(SYSTEMD_DIR)/$(PROJECT).service' + @printf '$(YELLOW)configuration at %s was preserved; remove it manually if no longer needed$(RESET)\n' '$(CONFDIR)' -uninstall: ## remove installed files - @printf " $(RED)%-10s$(RST) %s\n" "RM" "$(BINDIR)/$(BINARY)" - @rm -f $(BINDIR)/$(BINARY) - @rm -rf $(CONFDIR) - @rm -f $(DESTDIR)/etc/pam.d/$(BINARY) - @rm -f $(SYSTEMD)/$(BINARY).service +clean: ## Remove generated build, release, and Cargo artifacts + @rm -rf '$(BUILD_DIR)' '$(DIST_DIR)' + @$(CARGO) clean -clean: ## remove build artifacts - @printf " $(RED)%-10s$(RST) %s\n" "RM" "$(BUILD_DIR)" - @rm -rf $(BUILD_DIR) +run-preview: build ## Run the safe non-authenticating preview UI + @'$(BUILD_DIR)/$(BINARY)' --preview --no-log -test: ## run tests - @printf " $(YELLOW)%-10s$(RST) running tests...\n" "TEST" - @cd $(SRC_DIR) && $(GO) test ./... +legacy-build: ## Build the quarantined Go reference only when explicitly requested + @printf '$(YELLOW)legacy Go is reference-only and excluded from release builds$(RESET)\n' + @command -v $(GO) >/dev/null || { echo 'go is required for legacy-build'; exit 1; } + @mkdir -p '$(BUILD_DIR)' + @cd legacy/go && $(GO) build -trimpath -o '../../$(BUILD_DIR)/$(BINARY)-go' . -lint: ## run linter - @printf " $(YELLOW)%-10s$(RST) linting...\n" "LINT" - @golangci-lint run $(SRC_DIR)/... 2>/dev/null || printf " $(DIM)%-10s$(RST) golangci-lint not found; skipping\n" "" +legacy-check: ## Format-check and test the quarantined Go reference + @command -v $(GO) >/dev/null || { echo 'go is required for legacy-check'; exit 1; } + @command -v gofmt >/dev/null || { echo 'gofmt is required for legacy-check'; exit 1; } + @test -z "$$(find legacy/go -type f -name '*.go' -print0 | xargs -0 gofmt -l)" || { echo 'legacy Go files need gofmt'; exit 1; } + @cd legacy/go && $(GO) test ./... -fmt: ## format source - @printf " $(BLUE)%-10s$(RST) formatting...\n" "FMT" - @cd $(SRC_DIR) && $(GO) fmt ./... +enable: ## Enable the systemd service after host validation + @sudo systemctl enable $(PROJECT).service -vet: ## run go vet - @printf " $(YELLOW)%-10s$(RST) vetting...\n" "VET" - @cd $(SRC_DIR) && $(GO) vet ./... - -run-preview: build ## launch preview mode - @printf " $(GREEN)%-10s$(RST) starting preview...\n" "RUN" - @./$(BUILD_DIR)/$(BINARY) --preview --no-log - -release: clean ## build with version info - @printf " $(BOLD)$(CYAN)╔══════════════════════════════════════╗$(RST)\n" - @printf " $(BOLD)$(CYAN)║ latchd $(VERSION) — release build ║$(RST)\n" - @printf " $(BOLD)$(CYAN)╚══════════════════════════════════════╝$(RST)\n" - @printf "\n" - @printf " $(DIM)commit$(RST) $(COMMIT)\n" - @printf " $(DIM)built$(RST) $(BUILDDATE)\n" - @printf "\n" - @$(MAKE) build - -enable: ## disable other DM + enable latchd - @printf " $(YELLOW)%-10s$(RST) switching to latchd...\n" "ENABLE" - @if systemctl is-active display-manager.service >/dev/null 2>&1; then \ - printf " $(DIM)%-10s$(RST) stopping current display manager\n" ""; \ - sudo systemctl stop display-manager.service; \ - fi - @if [ -L /etc/systemd/system/display-manager.service ] \ - && ! readlink /etc/systemd/system/display-manager.service | grep -q $(BINARY); then \ - printf " $(DIM)%-10s$(RST) disabling aliased display manager\n" ""; \ - sudo systemctl disable display-manager.service; \ - fi - @sudo systemctl enable $(BINARY).service - @printf " $(GREEN)%-10s$(RST) latchd enabled — reboot or run 'sudo systemctl start latchd'\n" "ENABLE" - -update: ## uninstall → git pull → rebuild → reinstall - @printf " $(YELLOW)%-10s$(RST) removing previous install...\n" "UPDATE" - @sudo $(MAKE) uninstall - @printf " $(CYAN)%-10s$(RST) pulling latest...\n" "UPDATE" - @git pull - @printf " $(BLUE)%-10s$(RST) rebuilding...\n" "UPDATE" +update: ## Rebuild and reinstall the current checkout without changing git state @sudo $(MAKE) install - @printf " $(GREEN)%-10s$(RST) updated — run 'sudo make enable' to switch DMs\n" "UPDATE" -help: ## print this help - @printf " $(BOLD)latchd$(RST) — tui display manager\n" - @printf " $(DIM)version $(VERSION) commit $(COMMIT)$(RST)\n" - @printf "\n" - @printf " $(WHITE)targets:$(RST)\n" - @awk -F ':|##' '/^[a-zA-Z].*:.*##/ { \ - printf " $(CYAN)%-16s$(RST) %s\n", $$1, $$NF }' $(MAKEFILE_LIST) \ - | sort - @printf "\n" +help: ## Show available targets + @printf '$(CYAN)$(PROJECT)$(RESET) $(VERSION) — production build targets\n\n' + @awk -F ':|##' '/^[a-zA-Z0-9_-]+:.*##/ { printf " %-18s %s\n", $$1, $$NF }' '$(MAKEFILE_LIST)' | sort diff --git a/README.md b/README.md index c9c1523..9e862cf 100644 --- a/README.md +++ b/README.md @@ -1,190 +1,148 @@ # latchd -a display manager that runs in your terminal. lets you pick a desktop environment and log in — supports tty, x11, and wayland. +`latchd` is a Linux/PAM terminal login manager written in Rust. It presents a keyboard-first Ratatui login screen on a virtual terminal, authenticates through PAM, and starts an isolated TTY, X11, or Wayland session with an explicit per-user environment. -## what problem does this solve +The Rust binary is the supported implementation. The original Go/cgo tree under `legacy/go/` is retained as a quarantined comparison artifact and is not built by the default targets. -Most display managers are graphical. that means you need x11 or wayland running before you can even log in. latchd runs in a plain linux terminal (like tty2), so you can log in first, then start your desktop. it's lightweight, looks decent, and doesn't drag in a full graphics stack just to show a login screen. +## Security model -## how it works +- The service requires root for real sessions and refuses to start from an existing desktop session. +- PAM work is serialized on one dedicated worker thread. PAM handles never cross that thread boundary; authentication tickets are opaque RAII-owned transactions. +- Password buffers are zeroized and are never placed in argv, logs, or the rendered UI. +- Session environments are rebuilt from an allowlist for every login. Shell commands are argv vectors; no configured command is passed through a shell. +- Desktop files and session scripts must be root-owned, regular, non-group-writable files. Account shells such as `false` and `nologin` are rejected. +- Xauthority cookies are generated in a `0600` file owned by the account, supplied to `xauth` through stdin, and removed by an RAII cleanup guard. +- Main, graphical-session, and Xorg logs are private (`0600`) and opened without following symlinks. +- Power actions are an explicit absolute-path allowlist and require confirmation. They are disabled in `--preview` mode. -latchd takes over a virtual terminal (tty2 by default) and draws a login screen using ansi escape codes, powered by [bubbletea](https://github.com/charmbracelet/bubbletea). you type your username and password, pick a session from the switcher, and hit enter. +The implementation intentionally does not provide an administrator unlock shortcut: account lockout and authorization remain the responsibility of the configured PAM policy. -The actual authentication happens through pam (pluggable authentication modules). Once you're authenticated, latchd forks a child process that opens the pam session, sets up your environment, spawns your desktop or window manager, and does utmp accounting so tools like `who` and `loginctl` can see you're logged in. +## Build and test -For x11 sessions it handles the whole setup — xauthority cookies, sigusr1 handshake with the x server, timing out if xorg fails to start. for wayland it just runs your compositor script. for tty it drops you into your shell. - -While the login screen is up, it renders a conway's game of life simulation as the background at 30fps, using unicode half-block characters for a pixelated look. - -## what you get - -- Session switcher with left/right arrows and dot indicators -- username and password fields -- Status line for errors and info -- power controls (f1 shutdown, f2 reboot by default — configurable) -- caps lock indicator -- shake animation on wrong password -- spring animation on switcher transitions -- faillock integration — shows remaining attempts, detects lockouts -- Admin override (ctrl+u) to unlock a locked-out account -- configurable color theming via toml -- variable substitution in config (`$varname`) -- Preview mode for testing inside an existing session - -## Known Issues - -This project is a work in progress. here's what you should know: - -- **no tests**. at all. no unit tests, no integration tests. if you hit a bug, you'll find it the hard way. -- **Session spawning is a bit rough**. it uses `ForkExec` to re-launch itself as a child process, which means the pam session lifecycle isn't as clean as the original fork-then-exec model. this can cause pam modules that track session leaders to get confused. -- **a handful of open bugs** — edge cases in the switcher, occasional terminal state corruption on abrupt exits, and the automata background can flicker when the terminal resizes rapidly. -- **not all config options are fully wired**. things like `SwitcherConfig.ShowNeighbours`, various color options, and input field styling fields exist in the config struct but may not all be hooked up to the rendering yet. - -## installing - -### from source - -you need go 1.22 or later, `libpam0g-dev` (debian/ubuntu) or `pam-devel` (rhel/fedora) or `pam` (arch), and a c compiler (`build-essential`). +Requirements: Rust 1.88 or newer, a Linux PAM development package, and a C toolchain. Debian/Ubuntu packages are `libpam0g-dev`, `libpam-modules`, and `build-essential`. ```bash -make deps # install system dependencies -sudo make install # build, install binary + configs + systemd unit -sudo systemctl enable latchd +make deps +make build +make test # unit tests and rustdoc warnings +make lint # Clippy with warnings denied +make audit # RustSec dependency advisory scan +make docker-test # the same checks in a pinned Debian container +make package # deterministic install archive plus SHA-256 +make release # all local gates, Docker, and package output ``` -to test without rebooting: +`make build` installs the release binary at `build/latchd`. The reproducible test image is defined by [Dockerfile.test](Dockerfile.test) and does not require `--privileged`. + +## Releases + +The supported release pipeline is GitLab CI in [`.gitlab-ci.yml`](.gitlab-ci.yml). +Every branch and merge request runs formatting, tests, Clippy, rustdoc, a +RustSec dependency audit, and the Docker suite. Docker-in-Docker requires a trusted privileged GitLab Runner with +the runner client-certificate volume configured; untrusted fork pipelines +should not be assigned to that runner. A SemVer tag +such as `v0.3.0` additionally builds a deterministic +Linux archive containing the binary, systemd unit, PAM policy, configuration, +license, changelog, build metadata, and a SHA-256 checksum, then publishes it +as a GitLab Release. The tag version must match the workspace version. CI +creates the GitLab Release only after both Rust and Docker verification pass. + +Release builds use `SOURCE_DATE_EPOCH`, commit metadata, stripped symbols, +`opt-level = 3`, thin LTO, one codegen unit, no incremental state, and +`panic = "abort"`. Set `TARGET` or `DESTDIR` when invoking `make package` for a +controlled package build; `VERSION` must match the workspace version. The +legacy Go tree is physically isolated under `legacy/go/` and is never included +in release artifacts. + +## Installation ```bash -make run-preview +sudo make install +sudo systemctl enable latchd.service ``` -### arch +Installation places the binary at `/usr/bin/latchd`, the configuration at `/etc/latchd/config.toml`, the PAM policy at `/etc/pam.d/latchd`, and the systemd unit at `/usr/lib/systemd/system/latchd.service`. The unit owns `/dev/tty2`; adjust `TTYPath`, `After`, and the `tty` configuration together if another VT is required. -```bash -pacman -S latchd -systemctl enable latchd +Keep the previous package/binary available while validating a deployment. `--preview` is a safe UI exercise that does not authenticate, create a user session, or execute power actions. `--no-tui` (also `--preflight`) validates configuration and exits without entering the login screen. + +## Command line + +```text +latchd [OPTIONS] + + -c, --config Configuration TOML (default /etc/latchd/config.toml) + -v, --variables Optional TOML variable file + --tty Override the virtual terminal number + --xsessions Override the X11 desktop-entry directory + --wlsessions Override the Wayland desktop-entry directory + --initial-path Override the session PATH + --preview Draw the UI without authentication or session startup + --no-tui, --preflight Validate configuration and exit + --no-log Disable the main log + --show-config Print the key validated settings and exit + --build-info Print version, commit, and build epoch and exit + -V, --version Print the version ``` -## Command Line +An explicit configuration or variables file used by a real (non-preview) service must be root-owned, regular, and not group/other writable. The default configuration is optional: when `/etc/latchd/config.toml` is absent, built-in defaults are used. -```bash -latchd [options] +## Configuration - -c, --config config path (default: /etc/latchd/config.toml) - -v, --variables variables path - --tty override the tty number to run on - --preview run inside an existing session for testing - --no-log disable all logging - --show-config print the parsed config and exit - -V, --version print version and exit - --xsessions override xsessions path - --wlsessions override wayland-sessions path - --initial-path override initial PATH value +See [extra/config.toml](extra/config.toml) for the complete schema. Values may be substituted from a TOML variables file with `$NAME` syntax. Power entries contain an executable path and an argv array, never a shell command string. + +Session discovery reads root-owned `.desktop` files from the configured X11 and Wayland directories and executable root-owned scripts from `/etc/latchd/wms` and `/etc/latchd/wayland`. Unsupported desktop-entry field codes are rejected rather than interpreted by a shell. A TTY shell is offered when no graphical sessions are available, or when `include_tty_shell = true`. + +When enabled, the account chooser discovers eligible NSS users (by default, +UID 1000 and above with a real home directory and login shell). `Ctrl-N` and +`Ctrl-P` cycle accounts, clear the password, and start a bounded advisory +`faillock` query. The displayed lockout state never overrides PAM: a PAM +conversation remains the only authorization decision. Set +`accounts.enumerate = false` when listing local accounts is not appropriate. + +The `[visual]` section is TUI-only. It can load a root-owned PNG/JPEG as a +downsampled terminal-cell wallpaper, run a low-rate cellular-automata ambient +layer, and show short-lived notifications. `reduced_motion = true` disables +motion while preserving the static layout; `NO_COLOR=1` disables the image +layer and all color effects. + +## TUI controls + +The screen is designed for 80×24 and remains usable down to 60 columns. Below the supported minimum it reports the required size without drawing a partial frame. + +| Key | Action | +| --- | --- | +| Tab / Shift-Tab | Move focus | +| Up / Down | Move focus | +| Left / Right | Change session when the chooser is focused | +| Ctrl-N / Ctrl-P | Select the next / previous discovered account | +| Ctrl-B | Toggle the TUI-only wallpaper/automata background | +| Ctrl-M | Toggle reduced motion | +| Enter | Authenticate from the password field | +| Esc | Clear the password / cancel power confirmation | +| `?` | Show keyboard help | +| Ctrl-C | Restore the terminal and quit | +| F1–F12 | Confirm and run a configured power action | + +`NO_COLOR=1` requests a monochrome semantic style, and `LATCHD_ASCII=1` selects ASCII session markers for terminals without reliable Unicode support. + +## Project layout + +```text +crates/ +├── latchd-core/ validated config, accounts, sessions, and UTMPX accounting +├── latchd-auth/ PAM FFI, conversation callback, and transaction worker +└── latchd-tui/ Ratatui state machine, wallpaper, automata, and effects +rust/main.rs CLI, privilege checks, logging, and orchestration +extra/ example config, PAM policy, systemd unit, session directories +scripts/ Docker test entry point +legacy/ +└── go/ non-production Go reference (explicit `make legacy-build` only) ``` -## Adding Sessions +## Platform support -Drop executable scripts into the right folder and they show up in the switcher. +The first release targets Linux with PAM and systemd. BSD and non-PAM platforms are not claimed or tested. Real PAM/VT/X11 integration should be exercised on an isolated test host; the ordinary Docker suite covers compilation, unit tests, rendering tests, formatting, Clippy, and rustdoc without privileged container access. -**x11** — put your xinitrc scripts in `/etc/latchd/wms/`: +## License -```bash -# /etc/latchd/wms/bspwm -#!/bin/sh -sxhkd & -exec bspwm -``` - -```bash -chmod +x /etc/latchd/wms/bspwm -``` - -**wayland** — same thing in `/etc/latchd/wayland/`: - -```bash -# /etc/latchd/wayland/sway -#!/bin/sh -exec sway -``` - -latchd also reads freedesktop `.desktop` files from `/usr/share/xsessions` and `/usr/share/wayland-sessions` automatically. - -tty sessions are added automatically if no other environments are found, or you can force one with `include_tty_shell = true` in the config. - -## configuring - -The config lives at `/etc/latchd/config.toml`. all options are documented inline in the file. you can also use a `variables.toml` file with `$VAR` syntax: - -```toml -# /etc/latchd/variables.toml -accent = "orange" -``` - -```toml -# /etc/latchd/config.toml -[password_field.style] -title_color_focused = "$accent" -border_color_focused = "$accent" -``` - -## Debugging - -three log files: - -- `/var/log/latchd.log` — main latchd events -- `/var/log/latchd.client.log` — stdout/stderr of your session -- `/var/log/latchd.xorg.log` — x server output (64mb cap) - -run `latchd --show-config` to verify your configuration. run `latchd --preview` to test the login screen without leaving your current session. - -## Keybindings - -standard shell-style bindings in text fields: - -| key | what it does | -|---|---| -| ctrl+a | start of line | -| ctrl+e | end of line | -| ctrl+l or ctrl+u | clear field | -| ctrl+d | delete forward | -| ctrl+h | backspace | -| ctrl+b/f | left/right | -| ctrl+p/n | up/down (previous/next field) | -| tab, shift+tab | next/prev field | -| down / up | next/prev field | -| left/right | previous/next session (switcher mode) | -| , / . | previous/next session (any mode) | -| f1, f2 | power controls | -| ctrl+u | admin unlock (when account is locked) | - -## source layout - -```bash -src/ -├── main.go entry point, cli, vt switching, session forking -├── auth/ -│ ├── pam.go cgo pam bindings (locks goroutine to os thread) -│ ├── faillock.go faillock tally detection and reset -│ └── utmpx.go user accounting records -├── config/ -│ └── config.go toml parsing, variable substitution, defaults -├── session/ -│ ├── env.go xdg and environment variable setup -│ ├── exec.go process spawning, privilege dropping, log limits -│ ├── scan.go session discovery and .desktop parsing -│ ├── x11.go xorg setup, xauth, sigusr1 handshake -│ └── wayland.go wayland placeholder -├── login/ -│ └── login.go login form ui, state machine, animations, rendering -└── tui/ - └── bg/ - └── automata.go conway's game of life background -``` - -## license - -MIT or Apache-2.0 - -## credits - -the `extra/` directory structure and the session scanning / pam delegation approach are adapted from [lemurs](https://github.com/coastalwhite/lemurs), a rust display manager. +Apache-2.0; see [LICENSE](LICENSE). diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..c11eee0 --- /dev/null +++ b/build.rs @@ -0,0 +1,41 @@ +use std::{env, process::Command}; + +fn main() { + for variable in [ + "LATCHD_VERSION", + "LATCHD_COMMIT", + "LATCHD_BUILD_TIME", + "SOURCE_DATE_EPOCH", + ] { + println!("cargo:rerun-if-env-changed={variable}"); + } + + let version = env::var("LATCHD_VERSION") + .ok() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_owned()); + let commit = env::var("LATCHD_COMMIT") + .ok() + .filter(|value| !value.is_empty()) + .or_else(|| git_value(["rev-parse", "--short=12", "HEAD"])) + .unwrap_or_else(|| "unknown".to_owned()); + let build_time = env::var("LATCHD_BUILD_TIME") + .ok() + .filter(|value| !value.is_empty()) + .or_else(|| env::var("SOURCE_DATE_EPOCH").ok()) + .unwrap_or_else(|| "unknown".to_owned()); + + println!("cargo:rustc-env=LATCHD_VERSION={version}"); + println!("cargo:rustc-env=LATCHD_COMMIT={commit}"); + println!("cargo:rustc-env=LATCHD_BUILD_TIME={build_time}"); +} + +fn git_value(arguments: [&str; N]) -> Option { + let output = Command::new("git").args(arguments).output().ok()?; + if !output.status.success() { + return None; + } + let value = String::from_utf8(output.stdout).ok()?; + let value = value.trim(); + (!value.is_empty()).then(|| value.to_owned()) +} diff --git a/crates/latchd-auth/Cargo.toml b/crates/latchd-auth/Cargo.toml new file mode 100644 index 0000000..fa81d7b --- /dev/null +++ b/crates/latchd-auth/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "latchd-auth" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +latchd-core = { path = "../latchd-core" } +libc = "0.2" +zeroize = "1.8" diff --git a/crates/latchd-auth/src/lib.rs b/crates/latchd-auth/src/lib.rs new file mode 100644 index 0000000..ace9c98 --- /dev/null +++ b/crates/latchd-auth/src/lib.rs @@ -0,0 +1,548 @@ +//! PAM authentication with one dedicated worker thread. +//! +//! PAM handles are intentionally never moved out of the worker. The public +//! ticket contains only an opaque ID, so the UI cannot accidentally use a PAM +//! transaction from the wrong thread or forget to close it. + +use std::{ + collections::HashMap, + ffi::{CStr, CString}, + mem, + os::raw::{c_char, c_int, c_void}, + ptr, + sync::mpsc::{Receiver, SyncSender, sync_channel}, + thread, +}; + +use zeroize::{Zeroize, Zeroizing}; + +use latchd_core::{ + accounts::Identity, + error::Error, + session::{self, SessionSpec}, +}; + +const PAM_SUCCESS: c_int = 0; +const PAM_SYSTEM_ERR: c_int = 4; +const PAM_BUF_ERR: c_int = 5; +const PAM_CONV_ERR: c_int = 19; +const PAM_AUTHINFO_UNAVAIL: c_int = 9; + +const PAM_PROMPT_ECHO_OFF: c_int = 1; +const PAM_PROMPT_ECHO_ON: c_int = 2; +const PAM_ERROR_MSG: c_int = 3; +const PAM_TEXT_INFO: c_int = 4; +const PAM_ESTABLISH_CRED: c_int = 0x0002; +const PAM_DELETE_CRED: c_int = 0x0004; + +#[repr(C)] +struct PamHandle { + _private: [u8; 0], +} + +#[repr(C)] +struct PamMessage { + msg_style: c_int, + msg: *const c_char, +} + +#[repr(C)] +struct PamResponse { + resp: *mut c_char, + resp_retcode: c_int, +} + +#[repr(C)] +struct PamConv { + conv: Option< + unsafe extern "C" fn( + c_int, + *mut *const PamMessage, + *mut *mut PamResponse, + *mut c_void, + ) -> c_int, + >, + appdata_ptr: *mut c_void, +} + +#[link(name = "pam")] +unsafe extern "C" { + #[link_name = "pam_start"] + fn pam_start( + service: *const c_char, + user: *const c_char, + conv: *const PamConv, + pamh: *mut *mut PamHandle, + ) -> c_int; + #[link_name = "pam_authenticate"] + fn pam_authenticate(pamh: *mut PamHandle, flags: c_int) -> c_int; + #[link_name = "pam_acct_mgmt"] + fn pam_acct_mgmt(pamh: *mut PamHandle, flags: c_int) -> c_int; + #[link_name = "pam_setcred"] + fn pam_setcred(pamh: *mut PamHandle, flags: c_int) -> c_int; + #[link_name = "pam_open_session"] + fn pam_open_session(pamh: *mut PamHandle, flags: c_int) -> c_int; + #[link_name = "pam_close_session"] + fn pam_close_session(pamh: *mut PamHandle, flags: c_int) -> c_int; + #[link_name = "pam_end"] + fn pam_end(pamh: *mut PamHandle, pam_status: c_int) -> c_int; + #[link_name = "pam_strerror"] + fn pam_strerror(pamh: *const PamHandle, pam_status: c_int) -> *const c_char; +} + +/// An opaque authenticated PAM transaction. +pub struct AuthTicket { + id: u64, + identity: Identity, + owner: Option, +} + +impl AuthTicket { + /// Returns the account identity associated with this ticket. + pub fn identity(&self) -> &Identity { + &self.identity + } +} + +impl Drop for AuthTicket { + fn drop(&mut self) { + if let Some(owner) = &self.owner { + let _ = owner.commands.try_send(Command::Cancel { id: self.id }); + } + } +} + +enum Command { + Authenticate { + username: String, + password: Zeroizing>, + service: String, + response: std::sync::mpsc::SyncSender>, + }, + Run { + id: u64, + spec: SessionSpec, + response: std::sync::mpsc::SyncSender>, + }, + Cancel { + id: u64, + }, +} + +/// Serializes PAM operations on a dedicated worker thread. +#[derive(Clone)] +pub struct AuthService { + commands: SyncSender, +} + +impl AuthService { + /// Starts the PAM worker. + pub fn new() -> Result { + let (commands, requests) = sync_channel::(1); + thread::Builder::new() + .name("latchd-pam".into()) + .spawn(move || worker(requests)) + .map_err(|error| Error::Worker(format!("spawn PAM worker: {error}")))?; + Ok(Self { commands }) + } + + /// Starts one authentication transaction without blocking the UI thread. + pub fn authenticate( + &self, + username: String, + password: Vec, + service: String, + ) -> Result { + let (response, result) = sync_channel(0); + self.commands + .send(Command::Authenticate { + username, + password: Zeroizing::new(password), + service, + response, + }) + .map_err(|_| Error::Worker("PAM worker stopped".into()))?; + let ticket = result + .recv() + .map_err(|_| Error::Worker("PAM worker disconnected".into()))??; + let mut ticket = ticket; + ticket.owner = Some(self.clone()); + Ok(ticket) + } + + /// Runs the authenticated session and closes its PAM transaction. + pub fn run_session(&self, mut ticket: AuthTicket, spec: SessionSpec) -> Result<(), Error> { + let (response, result) = sync_channel(0); + let id = ticket.id; + ticket.owner = None; + self.commands + .send(Command::Run { id, spec, response }) + .map_err(|_| Error::Worker("PAM worker stopped".into()))?; + result + .recv() + .map_err(|_| Error::Worker("PAM worker disconnected".into()))? + } + + /// Cancels a ticket that will not be used for a session. + pub fn cancel(&self, mut ticket: AuthTicket) -> Result<(), Error> { + let id = ticket.id; + ticket.owner = None; + self.commands + .send(Command::Cancel { id }) + .map_err(|_| Error::Worker("PAM worker stopped".into())) + } +} + +struct ConversationData { + username: CString, + password: Zeroizing>, +} + +impl Drop for ConversationData { + fn drop(&mut self) { + self.password.zeroize(); + } +} + +struct PamSession { + handle: *mut PamHandle, + conversation: Box, + opened: bool, + credentials_established: bool, + identity: Option, +} + +impl PamSession { + fn close(&mut self, status: c_int) -> Result<(), Error> { + let mut first_error = None; + if self.opened { + let result = unsafe { pam_close_session(self.handle, 0) }; + if result != PAM_SUCCESS { + first_error = Some(pam_error(self.handle, "pam_close_session", result)); + } + self.opened = false; + } + if self.credentials_established { + let credentials = unsafe { pam_setcred(self.handle, PAM_DELETE_CRED) }; + if credentials != PAM_SUCCESS && first_error.is_none() { + first_error = Some(pam_error(self.handle, "pam_setcred(delete)", credentials)); + } + self.credentials_established = false; + } + let ended = unsafe { pam_end(self.handle, status) }; + if ended != PAM_SUCCESS && first_error.is_none() { + first_error = Some(pam_error(self.handle, "pam_end", ended)); + } + self.handle = ptr::null_mut(); + first_error.map_or(Ok(()), Err) + } +} + +impl Drop for PamSession { + fn drop(&mut self) { + if !self.handle.is_null() { + let _ = self.close(PAM_SYSTEM_ERR); + } + } +} + +fn worker(requests: Receiver) { + let mut sessions = HashMap::::new(); + let mut next_id = 1u64; + while let Ok(command) = requests.recv() { + match command { + Command::Authenticate { + username, + password, + service, + response, + } => { + let result = + authenticate(&username, password, &service, &mut next_id, &mut sessions); + let _ = response.send(result); + } + Command::Run { id, spec, response } => { + let result = match sessions.remove(&id) { + Some(mut session) => { + let opened = unsafe { pam_open_session(session.handle, 0) }; + if opened != PAM_SUCCESS { + let error = pam_error(session.handle, "pam_open_session", opened); + let _ = session.close(opened); + Err(error) + } else { + session.opened = true; + let run = match session.identity.as_ref() { + Some(identity) => session::run_user_session(identity, &spec), + None => Err(Error::Worker("ticket has no account identity".into())), + }; + let close = session.close(if run.is_ok() { + PAM_SUCCESS + } else { + PAM_SYSTEM_ERR + }); + match (run, close) { + (Err(error), _) => Err(error), + (Ok(()), Err(error)) => Err(error), + (Ok(()), Ok(())) => Ok(()), + } + } + } + None => Err(Error::Worker("unknown authentication ticket".into())), + }; + let _ = response.send(result); + } + Command::Cancel { id } => { + sessions.remove(&id); + } + } + } +} + +fn authenticate( + username: &str, + password: Zeroizing>, + service: &str, + next_id: &mut u64, + sessions: &mut HashMap, +) -> Result { + let service = + CString::new(service).map_err(|_| Error::Config("PAM service contains NUL".into()))?; + let username_c = + CString::new(username).map_err(|_| Error::Config("username contains NUL".into()))?; + let conversation = Box::new(ConversationData { + username: username_c.clone(), + password, + }); + let data = Box::into_raw(conversation); + let conversation_ptr = PamConv { + conv: Some(converse), + appdata_ptr: data.cast(), + }; + let mut handle = ptr::null_mut(); + let started = unsafe { + pam_start( + service.as_ptr(), + username_c.as_ptr(), + &conversation_ptr, + &mut handle, + ) + }; + // Reconstitute the allocation on every path after pam_start. PAM copies the + // conversation structure, but does not own appdata_ptr. + let conversation = unsafe { Box::from_raw(data) }; + if started != PAM_SUCCESS { + let error = pam_error(handle, "pam_start", started); + if !handle.is_null() { + let _ = unsafe { pam_end(handle, started) }; + } + return Err(error); + } + if handle.is_null() { + return Err(Error::Pam { + operation: "pam_start", + status: PAM_SYSTEM_ERR, + message: "returned a null handle".into(), + }); + } + + let mut session = PamSession { + handle, + conversation, + opened: false, + credentials_established: false, + identity: None, + }; + let authenticated = unsafe { pam_authenticate(session.handle, 0) }; + if authenticated != PAM_SUCCESS { + let error = pam_error(session.handle, "pam_authenticate", authenticated); + let _ = session.close(authenticated); + return Err(error); + } + let account = unsafe { pam_acct_mgmt(session.handle, 0) }; + if account != PAM_SUCCESS { + let error = pam_error(session.handle, "pam_acct_mgmt", account); + let _ = session.close(account); + return Err(error); + } + let identity = match lookup_identity(username) { + Ok(identity) => identity, + Err(error) => { + let _ = session.close(PAM_SYSTEM_ERR); + return Err(error); + } + }; + session.identity = Some(identity); + let credentials = unsafe { pam_setcred(session.handle, PAM_ESTABLISH_CRED) }; + if credentials != PAM_SUCCESS { + let error = pam_error(session.handle, "pam_setcred(establish)", credentials); + let _ = session.close(credentials); + return Err(error); + } + session.credentials_established = true; + + // Authentication is complete. Do not retain the password for the session. + session.conversation.password.zeroize(); + let identity = session + .identity + .clone() + .ok_or_else(|| Error::Worker("authenticated session has no identity".into()))?; + let id = *next_id; + *next_id = next_id + .checked_add(1) + .ok_or_else(|| Error::Worker("authentication ticket counter exhausted".into()))?; + sessions.insert(id, session); + Ok(AuthTicket { + id, + identity, + owner: None, + }) +} + +fn lookup_identity(username: &str) -> Result { + let name = CString::new(username).map_err(|_| Error::Config("username contains NUL".into()))?; + let passwd = unsafe { libc::getpwnam(name.as_ptr()) }; + if passwd.is_null() { + return Err(Error::Pam { + operation: "getpwnam", + status: PAM_AUTHINFO_UNAVAIL, + message: format!("unknown account {username}"), + }); + } + let passwd = unsafe { &*passwd }; + let home = c_path(passwd.pw_dir, "home")?; + let shell = c_path(passwd.pw_shell, "shell")?; + if shell == std::path::Path::new("/bin/false") + || shell == std::path::Path::new("/usr/sbin/nologin") + || shell == std::path::Path::new("/sbin/nologin") + { + return Err(Error::Config( + "account does not have an interactive shell".into(), + )); + } + Ok(Identity { + username: username.into(), + uid: passwd.pw_uid, + gid: passwd.pw_gid, + home, + shell, + }) +} + +fn c_path(pointer: *const c_char, field: &str) -> Result { + if pointer.is_null() { + return Err(Error::Config(format!("account has no {field}"))); + } + let value = unsafe { CStr::from_ptr(pointer) } + .to_str() + .map_err(|_| Error::Config(format!("account {field} is not UTF-8")))?; + if value.is_empty() { + return Err(Error::Config(format!("account has empty {field}"))); + } + Ok(value.into()) +} + +#[allow(unsafe_op_in_unsafe_fn)] +unsafe extern "C" fn converse( + count: c_int, + messages: *mut *const PamMessage, + responses: *mut *mut PamResponse, + data: *mut c_void, +) -> c_int { + if count <= 0 || messages.is_null() || responses.is_null() || data.is_null() { + return PAM_CONV_ERR; + } + let count = count as usize; + let output = libc::calloc(count, mem::size_of::()) as *mut PamResponse; + if output.is_null() { + return PAM_BUF_ERR; + } + let context = &*(data.cast::()); + for index in 0..count { + let message = *messages.add(index); + if message.is_null() { + free_responses(output, index); + return PAM_CONV_ERR; + } + let value: Option<&[u8]> = match (*message).msg_style { + PAM_PROMPT_ECHO_OFF => Some(context.password.as_slice()), + PAM_PROMPT_ECHO_ON => Some(context.username.as_bytes()), + PAM_ERROR_MSG | PAM_TEXT_INFO => None, + _ => { + free_responses(output, index); + return PAM_CONV_ERR; + } + }; + if let Some(value) = value { + let destination = libc::malloc(value.len().saturating_add(1)) as *mut u8; + if destination.is_null() { + free_responses(output, index); + return PAM_BUF_ERR; + } + ptr::copy_nonoverlapping(value.as_ptr(), destination, value.len()); + *destination.add(value.len()) = 0; + (*output.add(index)).resp = destination.cast(); + } + } + *responses = output; + PAM_SUCCESS +} + +#[allow(unsafe_op_in_unsafe_fn)] +unsafe fn free_responses(responses: *mut PamResponse, count: usize) { + for index in 0..count { + libc::free((*responses.add(index)).resp.cast()); + } + libc::free(responses.cast()); +} + +fn pam_error(handle: *mut PamHandle, operation: &'static str, status: c_int) -> Error { + let message = if handle.is_null() { + format!("status {status}") + } else { + let pointer = unsafe { pam_strerror(handle, status) }; + if pointer.is_null() { + format!("status {status}") + } else { + unsafe { CStr::from_ptr(pointer) } + .to_string_lossy() + .into_owned() + } + }; + Error::Pam { + operation, + status, + message, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn conversation_never_returns_password_for_echo_on() { + let context = Box::new(ConversationData { + username: CString::new("alice").unwrap(), + password: Zeroizing::new(b"secret".to_vec()), + }); + let pointer = Box::into_raw(context); + let message = PamMessage { + msg_style: PAM_PROMPT_ECHO_ON, + msg: ptr::null(), + }; + let mut message_pointer = &message as *const PamMessage; + let mut responses = ptr::null_mut(); + let status = unsafe { converse(1, &mut message_pointer, &mut responses, pointer.cast()) }; + assert_eq!(status, PAM_SUCCESS); + let response = unsafe { &*responses }; + assert_eq!( + unsafe { CStr::from_ptr(response.resp) }.to_bytes(), + b"alice" + ); + unsafe { + libc::free(response.resp.cast()); + libc::free(responses.cast()); + drop(Box::from_raw(pointer)); + } + } +} diff --git a/crates/latchd-core/Cargo.toml b/crates/latchd-core/Cargo.toml new file mode 100644 index 0000000..ee0bfce --- /dev/null +++ b/crates/latchd-core/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "latchd-core" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +libc = "0.2" +rand = "0.9" +serde = { version = "1.0", features = ["derive"] } +signal-hook = "0.3" +thiserror = "2.0" +toml = "0.9" + +[dev-dependencies] +unicode-width = "0.2" diff --git a/crates/latchd-core/src/accounting.rs b/crates/latchd-core/src/accounting.rs new file mode 100644 index 0000000..d1392b4 --- /dev/null +++ b/crates/latchd-core/src/accounting.rs @@ -0,0 +1,97 @@ +//! Linux UTMPX accounting with idempotent RAII cleanup. + +use std::{io, mem, os::raw::c_char, ptr}; + +use crate::error::Error; + +/// A live user-process record in UTMPX. +pub struct UtmpxGuard { + record: libc::utmpx, + closed: bool, +} + +impl UtmpxGuard { + /// Adds a user-process record for a child and returns its cleanup guard. + pub fn start(username: &str, tty: u8, pid: libc::pid_t) -> Result { + let mut record: libc::utmpx = unsafe { mem::zeroed() }; + record.ut_type = libc::USER_PROCESS; + record.ut_pid = pid; + write_field(&mut record.ut_line, &format!("tty{tty}")); + write_field(&mut record.ut_id, &format!("l{tty}")); + write_field(&mut record.ut_user, username); + write_timestamp(&mut record); + put_record(&record, "write UTMPX login record")?; + Ok(Self { + record, + closed: false, + }) + } + + /// Writes the corresponding dead-process record. Calling it twice is safe. + pub fn close(&mut self) -> Result<(), Error> { + if self.closed { + return Ok(()); + } + self.record.ut_type = libc::DEAD_PROCESS; + self.record.ut_user.fill(0); + self.record.ut_host.fill(0); + write_timestamp(&mut self.record); + put_record(&self.record, "write UTMPX logout record")?; + self.closed = true; + Ok(()) + } +} + +impl Drop for UtmpxGuard { + fn drop(&mut self) { + let _ = self.close(); + } +} + +fn write_field(field: &mut [c_char; N], value: &str) { + field.fill(0); + for (destination, byte) in field.iter_mut().zip(value.as_bytes().iter().copied()) { + *destination = byte as c_char; + } +} + +fn write_timestamp(record: &mut libc::utmpx) { + let seconds = unsafe { libc::time(ptr::null_mut()) }; + record.ut_tv.tv_sec = seconds as _; + record.ut_tv.tv_usec = 0; +} + +fn put_record(record: &libc::utmpx, operation: &'static str) -> Result<(), Error> { + unsafe { + libc::setutxent(); + let result = libc::pututxline(record); + libc::endutxent(); + if result.is_null() { + return Err(Error::Session(format!( + "{operation}: {}", + io::Error::last_os_error() + ))); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn field_writer_truncates_without_panicking() { + let mut field = [0 as c_char; 4]; + write_field(&mut field, "abcdef"); + assert_eq!( + field, + [ + b'a' as c_char, + b'b' as c_char, + b'c' as c_char, + b'd' as c_char + ] + ); + } +} diff --git a/crates/latchd-core/src/accounts.rs b/crates/latchd-core/src/accounts.rs new file mode 100644 index 0000000..3e6507d --- /dev/null +++ b/crates/latchd-core/src/accounts.rs @@ -0,0 +1,237 @@ +//! NSS account discovery and advisory faillock status. + +use std::{ + ffi::CStr, + os::unix::fs::MetadataExt, + path::{Path, PathBuf}, + process::{Command, Stdio}, + thread, + time::{Duration, Instant}, +}; + +use crate::{ + config::AccountsConfig, + error::{Error, ResultExt}, +}; + +/// An account that can be offered by the login screen. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Identity { + /// Canonical username. + pub username: String, + /// Numeric user ID. + pub uid: u32, + /// Primary group ID. + pub gid: u32, + /// Home directory. + pub home: PathBuf, + /// Login shell. + pub shell: PathBuf, +} + +/// A non-authoritative account lockout summary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct FaillockStatus { + /// Whether the faillock helper returned a usable result. + pub available: bool, + /// Number of observed failed attempts. + pub failures: u32, + /// Configured failure threshold, when discoverable. + pub max_tries: Option, + /// Remaining attempts before the advisory threshold. + pub remaining: Option, + /// Whether the advisory threshold has been reached. + pub locked_out: bool, +} + +/// Enumerates interactive accounts using NSS and deterministic ordering. +pub fn discover(config: &AccountsConfig) -> Vec { + let mut accounts = Vec::new(); + unsafe { + libc::setpwent(); + loop { + let entry = libc::getpwent(); + if entry.is_null() { + break; + } + let passwd = &*entry; + let Some(username) = c_string(passwd.pw_name) else { + continue; + }; + let Some(home) = c_path(passwd.pw_dir) else { + continue; + }; + let Some(shell) = c_path(passwd.pw_shell) else { + continue; + }; + if !eligible(&username, passwd.pw_uid, &home, &shell, config) { + continue; + } + accounts.push(Identity { + username, + uid: passwd.pw_uid, + gid: passwd.pw_gid, + home, + shell, + }); + } + libc::endpwent(); + } + accounts.sort_by(|left, right| left.username.cmp(&right.username)); + accounts +} + +/// Runs the configured helper and parses its stable numeric output. +pub fn faillock_status(username: &str, config: &AccountsConfig) -> Result { + let helper = &config.faillock_path; + if !helper.is_absolute() { + return Err(Error::Config("faillock_path must be absolute".into())); + } + if !trusted_helper(helper) { + return Ok(FaillockStatus::default()); + } + let mut child = Command::new(helper) + .args(["--user", username, "--legacy-output"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .io_context("start faillock query")?; + let deadline = Instant::now() + Duration::from_millis(750); + loop { + if child + .try_wait() + .io_context("poll faillock query")? + .is_some() + { + break; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Ok(FaillockStatus::default()); + } + thread::sleep(Duration::from_millis(10)); + } + let output = child.wait_with_output().io_context("read faillock query")?; + if !output.status.success() { + return Ok(FaillockStatus::default()); + } + let failures = parse_failure_count(&output.stdout); + let max_tries = read_deny(&config.faillock_config); + let remaining = max_tries.map(|limit| limit.saturating_sub(failures)); + Ok(FaillockStatus { + available: true, + failures, + max_tries, + remaining, + locked_out: remaining.is_some_and(|value| value == 0), + }) +} + +fn eligible(username: &str, uid: u32, home: &Path, shell: &Path, config: &AccountsConfig) -> bool { + if uid < config.minimum_uid || config.excluded_users.iter().any(|value| value == username) { + return false; + } + if shell == Path::new("/bin/false") + || shell == Path::new("/usr/sbin/nologin") + || shell == Path::new("/sbin/nologin") + || !shell.is_absolute() + { + return false; + } + let Ok(metadata) = std::fs::symlink_metadata(home) else { + return false; + }; + metadata.is_dir() + && (metadata.uid() == uid || metadata.uid() == 0) + && metadata.mode() & 0o022 == 0 +} + +fn c_string(pointer: *const libc::c_char) -> Option { + (!pointer.is_null()) + .then(|| { + unsafe { CStr::from_ptr(pointer) } + .to_str() + .ok() + .map(str::to_owned) + }) + .flatten() +} + +fn c_path(pointer: *const libc::c_char) -> Option { + c_string(pointer) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +fn parse_failure_count(bytes: &[u8]) -> u32 { + String::from_utf8_lossy(bytes) + .lines() + .filter_map(|line| line.split_whitespace().next()) + .find_map(|value| value.parse::().ok()) + .unwrap_or_default() +} + +fn read_deny(path: &Path) -> Option { + if !trusted_helper(path) { + return None; + } + let contents = std::fs::read_to_string(path).ok()?; + contents.lines().find_map(|line| { + let (key, value) = line.split_once('=')?; + (key.trim() == "deny") + .then(|| value.trim().parse().ok()) + .flatten() + }) +} + +fn trusted_helper(path: &Path) -> bool { + let Ok(metadata) = std::fs::symlink_metadata(path) else { + return false; + }; + metadata.is_file() + && metadata.uid() == 0 + && metadata.mode() & 0o022 == 0 + && (metadata.mode() & 0o111 != 0 || path.ends_with("faillock.conf")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_legacy_failure_count() { + assert_eq!(parse_failure_count(b" 4 2026-01-01 source\n"), 4); + assert_eq!(parse_failure_count(b""), 0); + } + + #[test] + fn filters_non_login_shells_and_untrusted_homes() { + let config = AccountsConfig::default(); + assert!(!eligible( + "service", + 1000, + Path::new("/tmp"), + Path::new("/bin/false"), + &config, + )); + assert!(!eligible( + "service", + 1000, + Path::new("/definitely/missing"), + Path::new("/bin/sh"), + &config, + )); + } + + #[cfg(unix)] + #[test] + fn unavailable_faillock_helper_is_advisory() { + let config = AccountsConfig { + faillock_path: PathBuf::from("/bin/false"), + ..AccountsConfig::default() + }; + let status = faillock_status("alice", &config).expect("false helper should be handled"); + assert_eq!(status, FaillockStatus::default()); + } +} diff --git a/crates/latchd-core/src/config.rs b/crates/latchd-core/src/config.rs new file mode 100644 index 0000000..f3527d9 --- /dev/null +++ b/crates/latchd-core/src/config.rs @@ -0,0 +1,380 @@ +use std::{ + collections::HashMap, + fs, + os::unix::fs::MetadataExt, + path::{Path, PathBuf}, +}; + +use serde::Deserialize; + +use crate::error::{Error, ResultExt}; + +/// Validated application configuration. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Config { + /// Virtual terminal number used by the service. + pub tty: u8, + /// Main log file. + pub main_log_path: PathBuf, + /// Per-session log file. + pub client_log_path: PathBuf, + /// PAM service name. + pub pam_service: String, + /// Default shell used only when the account has no shell value. + pub system_shell: PathBuf, + /// Session PATH. + pub initial_path: String, + /// X11 settings. + pub x11: X11Config, + /// Wayland settings. + pub wayland: WaylandConfig, + /// Environment switcher settings. + pub environment_switcher: SwitcherConfig, + /// Power actions shown by the login screen. + pub power_controls: PowerControls, + /// Account enumeration and advisory lockout settings. + pub accounts: AccountsConfig, + /// TUI-only background, animation, and notification settings. + pub visual: VisualConfig, +} + +impl Default for Config { + fn default() -> Self { + Self { + tty: 2, + main_log_path: PathBuf::from("/var/log/latchd.log"), + client_log_path: PathBuf::from("/var/log/latchd.client.log"), + pam_service: "latchd".into(), + system_shell: PathBuf::from("/bin/sh"), + initial_path: "/usr/local/sbin:/usr/local/bin:/usr/bin".into(), + x11: X11Config::default(), + wayland: WaylandConfig::default(), + environment_switcher: SwitcherConfig::default(), + power_controls: PowerControls::default(), + accounts: AccountsConfig::default(), + visual: VisualConfig::default(), + } + } +} + +/// Account discovery and faillock settings. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct AccountsConfig { + /// Minimum UID shown by automatic account discovery. + pub minimum_uid: u32, + /// Usernames excluded from the chooser. + pub excluded_users: Vec, + /// Whether the chooser lists NSS accounts. + pub enumerate: bool, + /// Absolute faillock helper path. + pub faillock_path: PathBuf, + /// faillock configuration used to read the deny threshold. + pub faillock_config: PathBuf, +} + +impl Default for AccountsConfig { + fn default() -> Self { + Self { + minimum_uid: 1000, + excluded_users: Vec::new(), + enumerate: true, + faillock_path: PathBuf::from("/usr/sbin/faillock"), + faillock_config: PathBuf::from("/etc/security/faillock.conf"), + } + } +} + +/// Visual effects used only by the terminal login screen. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct VisualConfig { + /// Optional root-owned image used as a low-contrast terminal wallpaper. + pub wallpaper_path: Option, + /// Enable the wallpaper and procedural background layer. + pub background_enabled: bool, + /// Advance animated background and spinner frames. + pub animations: bool, + /// Show transient in-TUI notifications for user-visible events. + pub notifications: bool, + /// Disable motion while retaining static visual styling. + pub reduced_motion: bool, + /// Maximum wallpaper file size accepted before decoding. + pub max_wallpaper_bytes: u64, +} + +impl Default for VisualConfig { + fn default() -> Self { + Self { + wallpaper_path: None, + background_enabled: true, + animations: true, + notifications: true, + reduced_motion: false, + max_wallpaper_bytes: 8 * 1024 * 1024, + } + } +} + +/// X11 session configuration. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct X11Config { + /// X display name. + pub display: String, + /// X server executable. + pub server_path: PathBuf, + /// Xauthority executable. + pub xauth_path: PathBuf, + /// X server startup timeout. + pub server_timeout_secs: u16, + /// X server log. + pub server_log_path: PathBuf, + /// Desktop-entry directory. + pub sessions_path: PathBuf, + /// Custom script directory. + pub scripts_path: PathBuf, +} + +impl Default for X11Config { + fn default() -> Self { + Self { + display: ":1".into(), + server_path: "/usr/bin/X".into(), + xauth_path: "/usr/bin/xauth".into(), + server_timeout_secs: 60, + server_log_path: "/var/log/latchd.xorg.log".into(), + sessions_path: "/usr/share/xsessions".into(), + scripts_path: "/etc/latchd/wms".into(), + } + } +} + +/// Wayland session configuration. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct WaylandConfig { + /// Desktop-entry directory. + pub sessions_path: PathBuf, + /// Custom script directory. + pub scripts_path: PathBuf, +} + +impl Default for WaylandConfig { + fn default() -> Self { + Self { + sessions_path: "/usr/share/wayland-sessions".into(), + scripts_path: "/etc/latchd/wayland".into(), + } + } +} + +/// Session switcher settings. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct SwitcherConfig { + /// Include the fallback TTY shell in the chooser. + pub include_tty_shell: bool, + /// Maximum visible title width. + pub max_display_length: usize, +} + +impl Default for SwitcherConfig { + fn default() -> Self { + Self { + include_tty_shell: false, + max_display_length: 18, + } + } +} + +/// Power actions available to the login screen. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct PowerControls { + /// Built-in or administrator-defined actions. + pub entries: Vec, +} + +/// A power action represented as an executable argv vector. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PowerAction { + /// Displayed key, such as `F1`. + pub key: String, + /// Displayed label. + pub hint: String, + /// Executable path. + pub program: PathBuf, + /// Arguments passed without shell interpretation. + #[serde(default)] + pub args: Vec, +} + +/// Loads a configuration file over the built-in defaults. +pub fn load(path: Option<&Path>, variables: Option<&Path>) -> Result { + let mut config = Config::default(); + if let Some(variables_path) = variables { + let values = load_variables(variables_path)?; + if let Some(config_path) = path { + let raw = fs::read_to_string(config_path) + .io_context(format!("read {}", config_path.display()))?; + let expanded = resolve_variables(&raw, &values); + config = toml::from_str(&expanded).map_err(|source| Error::Toml { + path: config_path.display().to_string(), + source, + })?; + } + } else if let Some(config_path) = path { + let raw = fs::read_to_string(config_path) + .io_context(format!("read {}", config_path.display()))?; + config = toml::from_str(&raw).map_err(|source| Error::Toml { + path: config_path.display().to_string(), + source, + })?; + } + validate(&config)?; + Ok(config) +} + +fn load_variables(path: &Path) -> Result, Error> { + let raw = fs::read_to_string(path).io_context(format!("read {}", path.display()))?; + let values: HashMap = + toml::from_str(&raw).map_err(|source| Error::Toml { + path: path.display().to_string(), + source, + })?; + Ok(values + .into_iter() + .map(|(key, value)| (key, value.to_string().trim_matches('"').to_owned())) + .collect()) +} + +fn resolve_variables(input: &str, variables: &HashMap) -> String { + let mut keys: Vec<_> = variables.keys().collect(); + keys.sort_by_key(|key| std::cmp::Reverse(key.len())); + keys.into_iter().fold(input.to_owned(), |result, key| { + result.replace(&format!("${key}"), &variables[key]) + }) +} + +/// Validates values that must be safe before starting the login screen. +pub fn validate(config: &Config) -> Result<(), Error> { + if config.tty == 0 || config.tty > 63 { + return Err(Error::Config("tty must be between 1 and 63".into())); + } + if config.pam_service.is_empty() || config.pam_service.contains('\0') { + return Err(Error::Config("pam_service must be a non-empty name".into())); + } + if config.initial_path.is_empty() { + return Err(Error::Config("initial_path must not be empty".into())); + } + if config + .initial_path + .split(':') + .any(|entry| entry.is_empty() || !Path::new(entry).is_absolute()) + { + return Err(Error::Config( + "initial_path must contain only non-empty absolute directories".into(), + )); + } + if config.environment_switcher.max_display_length == 0 { + return Err(Error::Config( + "environment_switcher.max_display_length must be greater than zero".into(), + )); + } + for (name, path) in [ + ("main_log_path", &config.main_log_path), + ("client_log_path", &config.client_log_path), + ("system_shell", &config.system_shell), + ("x11.server_path", &config.x11.server_path), + ("x11.xauth_path", &config.x11.xauth_path), + ("x11.server_log_path", &config.x11.server_log_path), + ("accounts.faillock_path", &config.accounts.faillock_path), + ("accounts.faillock_config", &config.accounts.faillock_config), + ] { + if !path.is_absolute() { + return Err(Error::Config(format!("{name} must be an absolute path"))); + } + } + if config.visual.max_wallpaper_bytes == 0 + || config.visual.max_wallpaper_bytes > 64 * 1024 * 1024 + { + return Err(Error::Config( + "visual.max_wallpaper_bytes must be between 1 and 67108864".into(), + )); + } + if let Some(path) = &config.visual.wallpaper_path { + if !path.is_absolute() { + return Err(Error::Config( + "visual.wallpaper_path must be an absolute path".into(), + )); + } + } + if config.x11.display.trim().is_empty() || config.x11.server_timeout_secs == 0 { + return Err(Error::Config( + "x11 display must be non-empty and server_timeout_secs must be greater than zero" + .into(), + )); + } + for action in &config.power_controls.entries { + let valid_key = action + .key + .strip_prefix('F') + .and_then(|number| number.parse::().ok()) + .is_some_and(|number| (1..=12).contains(&number)); + if !valid_key || action.hint.is_empty() || !action.program.is_absolute() { + return Err(Error::Config(format!( + "power action {} must use F1-F12, an absolute program path, and a non-empty label", + action.key + ))); + } + } + Ok(()) +} + +/// Rejects a privileged configuration source that can be replaced by another user. +pub fn validate_source(path: &Path, label: &str) -> Result<(), Error> { + let metadata = + fs::symlink_metadata(path).io_context(format!("inspect {label} {}", path.display()))?; + if !metadata.is_file() || metadata.uid() != 0 || metadata.mode() & 0o022 != 0 { + return Err(Error::Config(format!( + "{label} must be a root-owned, non-group-writable regular file: {}", + path.display() + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn variables_are_replaced_longest_first() { + let mut vars = HashMap::new(); + vars.insert("A".into(), "short".into()); + vars.insert("AB".into(), "long".into()); + assert_eq!(resolve_variables("$AB/$A", &vars), "long/short"); + } + + #[test] + fn defaults_are_valid() { + validate(&Config::default()).expect("defaults are static and valid"); + } + + #[test] + fn unknown_fields_are_rejected() { + assert!(toml::from_str::("unexpected = true").is_err()); + } + + #[test] + fn relative_path_entries_are_rejected() { + let config = Config { + initial_path: "relative:/usr/bin".into(), + ..Config::default() + }; + assert!(validate(&config).is_err()); + } +} diff --git a/crates/latchd-core/src/error.rs b/crates/latchd-core/src/error.rs new file mode 100644 index 0000000..55589de --- /dev/null +++ b/crates/latchd-core/src/error.rs @@ -0,0 +1,68 @@ +use std::io; + +use thiserror::Error; + +/// The application-wide error type. +#[derive(Debug, Error)] +pub enum Error { + /// A command-line or configuration value was invalid. + #[error("configuration: {0}")] + Config(String), + /// A filesystem operation failed. + #[error("{context}: {source}")] + Io { + /// The operation being performed. + context: String, + /// The underlying error. + #[source] + source: io::Error, + }, + /// TOML could not be decoded. + #[error("parse {path}: {source}")] + Toml { + /// The file being parsed. + path: String, + /// The parser error. + #[source] + source: toml::de::Error, + }, + /// PAM rejected or could not complete an operation. + #[error("PAM {operation} failed with status {status}: {message}")] + Pam { + /// The PAM operation. + operation: &'static str, + /// The numeric PAM status. + status: i32, + /// The human-readable PAM message. + message: String, + }, + /// A session process failed. + #[error("session: {0}")] + Session(String), + /// The terminal could not be initialized or restored. + #[error("terminal: {0}")] + Terminal(String), + /// An authentication worker request was cancelled or disconnected. + #[error("authentication worker: {0}")] + Worker(String), +} + +/// Adds a stable operation label to an I/O error. +pub fn io_error(context: impl Into, source: io::Error) -> Error { + Error::Io { + context: context.into(), + source, + } +} + +/// Converts a failed result into an application error with context. +pub trait ResultExt { + /// Adds context to an I/O operation. + fn io_context(self, context: impl Into) -> Result; +} + +impl ResultExt for io::Result { + fn io_context(self, context: impl Into) -> Result { + self.map_err(|source| io_error(context, source)) + } +} diff --git a/crates/latchd-core/src/lib.rs b/crates/latchd-core/src/lib.rs new file mode 100644 index 0000000..1092145 --- /dev/null +++ b/crates/latchd-core/src/lib.rs @@ -0,0 +1,15 @@ +//! Core configuration, account, session, and accounting contracts for latchd. + +#![deny(unsafe_op_in_unsafe_fn)] +#![warn(missing_docs)] + +/// Linux UTMPX accounting primitives. +pub mod accounting; +/// NSS account discovery and faillock status. +pub mod accounts; +/// Validated application configuration. +pub mod config; +/// Structured application errors. +pub mod error; +/// Session discovery and process lifecycle. +pub mod session; diff --git a/crates/latchd-core/src/session.rs b/crates/latchd-core/src/session.rs new file mode 100644 index 0000000..eee9f6a --- /dev/null +++ b/crates/latchd-core/src/session.rs @@ -0,0 +1,861 @@ +//! Session discovery, environment construction, and child lifecycle. + +use std::{ + ffi::{CString, OsStr}, + fs::{self, File, OpenOptions}, + io::{self, Write}, + os::unix::{ + fs::{MetadataExt, OpenOptionsExt}, + io::AsRawFd, + process::CommandExt, + }, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::{Duration, Instant}, +}; + +use crate::{ + accounting::UtmpxGuard, + accounts::Identity, + config::Config, + error::{Error, ResultExt, io_error}, +}; +use rand::RngCore; +use signal_hook::{consts::SIGUSR1, flag}; + +/// A session family supported by latchd. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionKind { + /// A login shell on the current virtual terminal. + Tty, + /// An X11 desktop session. + X11, + /// A Wayland desktop session. + Wayland, +} + +/// A discoverable desktop or script session. +#[derive(Debug, Clone)] +pub struct SessionDescriptor { + /// Session family. + pub kind: SessionKind, + /// User-facing title. + pub title: String, + /// Program and arguments, without shell interpretation. + pub argv: Vec, + /// XDG current-desktop value. + pub current_desktop: Option, + /// XDG session-desktop value. + pub session_desktop: Option, +} + +/// A fully materialized session request. +#[derive(Debug, Clone)] +pub struct SessionSpec { + /// Session family. + pub kind: SessionKind, + /// Session title for logs. + pub title: String, + /// Program and arguments. + pub argv: Vec, + /// Explicit child environment. + pub environment: Vec<(String, String)>, + /// Working directory. + pub working_directory: PathBuf, + /// Private stdout/stderr log for graphical sessions. + pub client_log_path: PathBuf, + /// Login shell for TTY fallback. + pub shell: PathBuf, + /// Virtual terminal number. + pub tty: u8, + /// X11 configuration when this is an X11 session. + pub x11: Option, +} + +/// Runtime X11 parameters passed to a session. +#[derive(Debug, Clone)] +pub struct X11Runtime { + /// X display name. + pub display: String, + /// X server command. + pub server_path: PathBuf, + /// Xauthority command. + pub xauth_path: PathBuf, + /// X server timeout. + pub timeout: Duration, + /// X server log path. + pub log_path: PathBuf, +} + +/// Discovers configured sessions in stable directory order. +pub fn discover(config: &Config) -> Vec { + let mut sessions = Vec::new(); + scan_desktop_dir( + &config.x11.sessions_path, + SessionKind::X11, + &config.initial_path, + &mut sessions, + ); + scan_desktop_dir( + &config.wayland.sessions_path, + SessionKind::Wayland, + &config.initial_path, + &mut sessions, + ); + scan_script_dir(&config.x11.scripts_path, SessionKind::X11, &mut sessions); + scan_script_dir( + &config.wayland.scripts_path, + SessionKind::Wayland, + &mut sessions, + ); + if sessions.is_empty() || config.environment_switcher.include_tty_shell { + sessions.push(SessionDescriptor { + kind: SessionKind::Tty, + title: "TTY shell".into(), + argv: Vec::new(), + current_desktop: None, + session_desktop: None, + }); + } + sessions +} + +/// Checks configured binaries and discovered session entries without starting them. +pub fn preflight(config: &Config) -> Result<(), Error> { + let shell = secure_executable(&config.system_shell, "configured system shell")?; + if !shell.is_file() { + return Err(Error::Config(format!( + "configured system shell is not a regular file: {}", + shell.display() + ))); + } + let sessions = discover(config); + for session in &sessions { + if session.kind == SessionKind::Tty { + continue; + } + let Some(program) = session.argv.first() else { + return Err(Error::Config(format!( + "session {} has no executable", + session.title + ))); + }; + let executable = find_executable(program, &config.initial_path).ok_or_else(|| { + Error::Config(format!( + "session {} executable is not available: {program}", + session.title + )) + })?; + secure_executable(&executable, "session executable")?; + } + if sessions + .iter() + .any(|session| session.kind == SessionKind::X11) + { + secure_executable(&config.x11.server_path, "X11 server")?; + secure_executable(&config.x11.xauth_path, "xauth")?; + } + Ok(()) +} + +fn find_executable(program: &str, path: &str) -> Option { + let candidate = Path::new(program); + if candidate.is_absolute() { + return candidate.exists().then(|| candidate.to_owned()); + } + path.split(':') + .map(|directory| Path::new(directory).join(program)) + .find(|candidate| candidate.is_file()) +} + +fn secure_executable(path: &Path, label: &str) -> Result { + let resolved = + fs::canonicalize(path).io_context(format!("resolve {label} {}", path.display()))?; + let metadata = fs::symlink_metadata(&resolved) + .io_context(format!("inspect {label} {}", resolved.display()))?; + if !metadata.is_file() || metadata.uid() != 0 || metadata.mode() & 0o022 != 0 { + return Err(Error::Config(format!( + "{label} must resolve to a root-owned, non-writable regular file: {}", + path.display() + ))); + } + if metadata.mode() & 0o111 == 0 { + return Err(Error::Config(format!( + "{label} is not executable: {}", + path.display() + ))); + } + Ok(resolved) +} + +/// Builds an explicit environment and process specification for one identity. +pub fn build_spec( + identity: &Identity, + config: &Config, + descriptor: &SessionDescriptor, +) -> Result { + let mut environment = inherited_safe_environment(); + insert_env( + &mut environment, + "HOME", + identity.home.to_string_lossy().into_owned(), + ); + insert_env( + &mut environment, + "SHELL", + identity.shell.to_string_lossy().into_owned(), + ); + insert_env(&mut environment, "USER", identity.username.clone()); + insert_env(&mut environment, "LOGNAME", identity.username.clone()); + insert_env(&mut environment, "PATH", config.initial_path.clone()); + insert_env( + &mut environment, + "PWD", + identity.home.to_string_lossy().into_owned(), + ); + insert_env(&mut environment, "XDG_SESSION_CLASS", "user".into()); + insert_env( + &mut environment, + "XDG_SESSION_TYPE", + match descriptor.kind { + SessionKind::Tty => "tty", + SessionKind::X11 => "x11", + SessionKind::Wayland => "wayland", + } + .into(), + ); + insert_env(&mut environment, "XDG_SEAT", "seat0".into()); + insert_env(&mut environment, "XDG_VTNR", config.tty.to_string()); + insert_env( + &mut environment, + "XDG_RUNTIME_DIR", + format!("/run/user/{}", identity.uid), + ); + insert_env(&mut environment, "XDG_SESSION_ID", "latchd".into()); + insert_env( + &mut environment, + "XDG_CONFIG_HOME", + identity.home.join(".config").to_string_lossy().into_owned(), + ); + insert_env( + &mut environment, + "XDG_CACHE_HOME", + identity.home.join(".cache").to_string_lossy().into_owned(), + ); + insert_env( + &mut environment, + "XDG_DATA_HOME", + identity + .home + .join(".local/share") + .to_string_lossy() + .into_owned(), + ); + insert_env( + &mut environment, + "XDG_STATE_HOME", + identity + .home + .join(".local/state") + .to_string_lossy() + .into_owned(), + ); + insert_env( + &mut environment, + "XDG_DATA_DIRS", + "/usr/local/share:/usr/share".into(), + ); + insert_env(&mut environment, "XDG_CONFIG_DIRS", "/etc/xdg".into()); + + let x11 = if descriptor.kind == SessionKind::X11 { + insert_env(&mut environment, "DISPLAY", config.x11.display.clone()); + Some(X11Runtime { + display: config.x11.display.clone(), + server_path: config.x11.server_path.clone(), + xauth_path: config.x11.xauth_path.clone(), + timeout: Duration::from_secs(config.x11.server_timeout_secs as u64), + log_path: config.x11.server_log_path.clone(), + }) + } else { + None + }; + if let Some(value) = descriptor.current_desktop.as_deref() { + insert_env(&mut environment, "XDG_CURRENT_DESKTOP", value.to_owned()); + } + if let Some(value) = descriptor.session_desktop.as_deref() { + insert_env(&mut environment, "XDG_SESSION_DESKTOP", value.to_owned()); + } + + let mut argv = if descriptor.kind == SessionKind::Tty { + Vec::new() + } else if descriptor.argv.is_empty() { + return Err(Error::Session(format!( + "session {} has no executable", + descriptor.title + ))); + } else { + descriptor.argv.clone() + }; + if let Some(program) = argv.first_mut() { + if !Path::new(program).is_absolute() { + let resolved = find_executable(program, &config.initial_path).ok_or_else(|| { + Error::Session(format!( + "session {} executable is not available: {program}", + descriptor.title + )) + })?; + *program = resolved.to_string_lossy().into_owned(); + } + } + if !identity.shell.is_absolute() { + return Err(Error::Session( + "account shell must be an absolute path".into(), + )); + } + Ok(SessionSpec { + kind: descriptor.kind, + title: descriptor.title.clone(), + argv, + environment, + working_directory: identity.home.clone(), + client_log_path: config.client_log_path.clone(), + shell: identity.shell.clone(), + tty: config.tty, + x11, + }) +} + +/// Runs a user session and owns all child process cleanup. +pub fn run_user_session(identity: &Identity, spec: &SessionSpec) -> Result<(), Error> { + let home_metadata = fs::symlink_metadata(&identity.home) + .map_err(|error| io_error(format!("inspect home {}", identity.home.display()), error))?; + if !home_metadata.is_dir() + || (home_metadata.uid() != identity.uid && home_metadata.uid() != 0) + || home_metadata.mode() & 0o022 != 0 + { + return Err(Error::Session(format!( + "home directory is missing or insecure: {}", + identity.home.display() + ))); + } + if spec.kind == SessionKind::X11 { + return run_x11(identity, spec); + } + let mut command = user_command(identity, spec)?; + let mut child = command.spawn().io_context("spawn user session")?; + let mut accounting = match UtmpxGuard::start(&identity.username, spec.tty, child.id() as _) { + Ok(guard) => guard, + Err(error) => { + let _ = terminate_child(&mut child); + return Err(error); + } + }; + let status = match child.wait().io_context("wait for user session") { + Ok(status) => status, + Err(error) => { + let _ = terminate_child(&mut child); + return Err(error); + } + }; + if !status.success() { + let _ = accounting.close(); + return Err(Error::Session(format!( + "{} exited with {status}", + spec.title + ))); + } + accounting.close()?; + Ok(()) +} + +fn user_command(identity: &Identity, spec: &SessionSpec) -> Result { + let (program, args): (&OsStr, &[String]) = if spec.kind == SessionKind::Tty { + (spec.shell.as_os_str(), &[] as &[String]) + } else { + let program = spec + .argv + .first() + .ok_or_else(|| Error::Session("empty session argv".into()))?; + (OsStr::new(program), &spec.argv[1..]) + }; + let mut command = Command::new(program); + command + .args(args) + .current_dir(&spec.working_directory) + .env_clear() + .envs(spec.environment.iter().map(|(key, value)| (key, value))) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + if spec.kind != SessionKind::Tty { + let output = private_log(&spec.client_log_path)?; + let error = output.try_clone().io_context("clone client log")?; + command + .stdout(Stdio::from(output)) + .stderr(Stdio::from(error)); + } + install_user_credentials(&mut command, identity, spec.kind == SessionKind::Tty)?; + Ok(command) +} + +fn install_user_credentials( + command: &mut Command, + identity: &Identity, + controlling_tty: bool, +) -> Result<(), Error> { + let username = CString::new(identity.username.as_str()) + .map_err(|_| Error::Session("username contains NUL".into()))?; + let uid = identity.uid; + let gid = identity.gid; + unsafe { + command.pre_exec(move || { + if libc::setsid() == -1 { + return Err(io::Error::last_os_error()); + } + if controlling_tty && libc::ioctl(libc::STDIN_FILENO, libc::TIOCSCTTY as _, 0) == -1 { + return Err(io::Error::last_os_error()); + } + if libc::initgroups(username.as_ptr(), gid) == -1 { + return Err(io::Error::last_os_error()); + } + if libc::setgid(gid) == -1 || libc::setuid(uid) == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + Ok(()) +} + +fn run_x11(identity: &Identity, spec: &SessionSpec) -> Result<(), Error> { + let runtime = spec + .x11 + .as_ref() + .ok_or_else(|| Error::Session("missing X11 runtime configuration".into()))?; + let authority = create_xauthority(identity, runtime)?; + let ready = Arc::new(AtomicBool::new(false)); + flag::register(SIGUSR1, Arc::clone(&ready)) + .map_err(|error| Error::Session(format!("register X11 readiness signal: {error}")))?; + let x_log = private_log(&runtime.log_path)?; + let x_log_err = x_log.try_clone().io_context("clone Xorg log")?; + let mut server = Command::new(&runtime.server_path); + let vt = format!("vt{}", spec.tty); + server + .args([runtime.display.as_str(), vt.as_str()]) + .arg("-auth") + .arg(authority.path()) + .stdin(Stdio::null()) + .stdout(Stdio::from(x_log)) + .stderr(Stdio::from(x_log_err)); + unsafe { + server.pre_exec(|| { + if libc::setsid() == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut xserver = server.spawn().io_context("spawn X server")?; + let deadline = Instant::now() + runtime.timeout; + while !ready.load(Ordering::Acquire) { + if let Some(status) = xserver.try_wait().io_context("poll X server")? { + return Err(Error::Session(format!("X server exited with {status}"))); + } + if runtime.timeout != Duration::ZERO && Instant::now() >= deadline { + terminate_child(&mut xserver)?; + return Err(Error::Session("X server readiness timeout".into())); + } + thread::sleep(Duration::from_millis(20)); + } + + let mut environment = spec.environment.clone(); + insert_env( + &mut environment, + "XAUTHORITY", + authority.path().to_string_lossy().into_owned(), + ); + let mut client_spec = spec.clone(); + client_spec.environment = environment; + let result = user_command(identity, &client_spec).and_then(|mut client| { + let mut child = client.spawn().io_context("spawn X11 client")?; + match child.wait().io_context("wait for X11 client") { + Ok(status) => Ok(status), + Err(error) => { + let _ = terminate_child(&mut child); + Err(error) + } + } + }); + let cleanup = terminate_child(&mut xserver); + cleanup?; + let result = result?; + if !result.success() { + return Err(Error::Session(format!("X11 client exited with {result}"))); + } + Ok(()) +} + +struct AuthorityFile(PathBuf); + +impl AuthorityFile { + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for AuthorityFile { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} + +fn create_xauthority(identity: &Identity, runtime: &X11Runtime) -> Result { + let mut cookie = [0u8; 16]; + rand::rng().fill_bytes(&mut cookie); + let path = identity + .home + .join(format!(".Xauthority.latchd-{}", std::process::id())); + let file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + .io_context(format!("create {}", path.display()))?; + let owner = unsafe { libc::fchown(file.as_raw_fd(), identity.uid, identity.gid) }; + if owner == -1 { + let error = io::Error::last_os_error(); + let _ = fs::remove_file(&path); + return Err(io_error(format!("chown {}", path.display()), error)); + } + drop(file); + let mut command = Command::new(&runtime.xauth_path); + command + .arg("-f") + .arg(&path) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + if let Err(error) = install_user_credentials(&mut command, identity, false) { + let _ = fs::remove_file(&path); + return Err(error); + } + let mut child = match command.spawn().io_context("spawn xauth") { + Ok(child) => child, + Err(error) => { + let _ = fs::remove_file(&path); + return Err(error); + } + }; + if let Some(mut stdin) = child.stdin.take() { + if let Err(error) = writeln!(stdin, "add {} . {}", runtime.display, hex_cookie(&cookie)) + .io_context("write xauth command") + { + let _ = terminate_child(&mut child); + let _ = fs::remove_file(&path); + return Err(error); + } + } + let status = match child.wait().io_context("wait for xauth") { + Ok(status) => status, + Err(error) => { + let _ = fs::remove_file(&path); + return Err(error); + } + }; + if !status.success() { + let _ = fs::remove_file(&path); + return Err(Error::Session(format!("xauth exited with {status}"))); + } + Ok(AuthorityFile(path)) +} + +fn hex_cookie(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn terminate_child(child: &mut Child) -> Result<(), Error> { + if child.try_wait().io_context("poll child")?.is_some() { + return Ok(()); + } + unsafe { + libc::kill(-(child.id() as i32), libc::SIGTERM); + } + let deadline = Instant::now() + Duration::from_secs(3); + while Instant::now() < deadline { + if child + .try_wait() + .io_context("wait for child termination")? + .is_some() + { + return Ok(()); + } + thread::sleep(Duration::from_millis(25)); + } + unsafe { + libc::kill(-(child.id() as i32), libc::SIGKILL); + } + child.wait().io_context("reap killed child")?; + Ok(()) +} + +fn private_log(path: &Path) -> Result { + const MAX_LOG_BYTES: u64 = 64 * 1024 * 1024; + let parent = path + .parent() + .ok_or_else(|| Error::Config(format!("log path has no parent: {}", path.display())))?; + let metadata = fs::symlink_metadata(parent) + .io_context(format!("inspect log directory {}", parent.display()))?; + if !metadata.is_dir() || metadata.uid() != 0 || metadata.mode() & 0o022 != 0 { + return Err(Error::Config(format!( + "log directory must be root-owned and not group-writable: {}", + parent.display() + ))); + } + let file = OpenOptions::new() + .create(true) + .append(true) + .custom_flags(libc::O_NOFOLLOW) + .mode(0o600) + .open(path) + .io_context(format!("open log {}", path.display()))?; + if file + .metadata() + .io_context(format!("inspect log {}", path.display()))? + .len() + > MAX_LOG_BYTES + { + return Err(Error::Config(format!( + "log exceeds {MAX_LOG_BYTES} bytes: {}", + path.display() + ))); + } + file.set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600)) + .io_context(format!("secure log {}", path.display()))?; + Ok(file) +} + +fn inherited_safe_environment() -> Vec<(String, String)> { + std::env::vars() + .filter(|(key, _)| key == "TERM" || key == "LANG" || key.starts_with("LC_")) + .collect() +} + +fn insert_env(environment: &mut Vec<(String, String)>, key: &str, value: String) { + if let Some((_, existing)) = environment.iter_mut().find(|(name, _)| name == key) { + *existing = value; + } else { + environment.push((key.into(), value)); + } +} + +fn scan_desktop_dir( + path: &Path, + kind: SessionKind, + session_path: &str, + output: &mut Vec, +) { + let Ok(entries) = fs::read_dir(path) else { + return; + }; + let mut entries: Vec<_> = entries.filter_map(Result::ok).collect(); + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + if entry.path().extension().and_then(|value| value.to_str()) != Some("desktop") { + continue; + } + let Ok(metadata) = fs::symlink_metadata(entry.path()) else { + continue; + }; + if !metadata.is_file() || metadata.mode() & 0o022 != 0 || metadata.uid() != 0 { + continue; + } + let Ok(raw) = fs::read_to_string(entry.path()) else { + continue; + }; + let fields = desktop_fields(&raw); + if fields + .get("Hidden") + .is_some_and(|value| value.eq_ignore_ascii_case("true")) + || fields + .get("NoDisplay") + .is_some_and(|value| value.eq_ignore_ascii_case("true")) + { + continue; + } + if fields + .get("Type") + .is_some_and(|value| !value.eq_ignore_ascii_case("Application")) + { + continue; + } + if let Some(try_exec) = fields.get("TryExec") + && find_executable(try_exec, session_path).is_none() + { + continue; + } + let Some(exec) = fields.get("Exec").and_then(|value| parse_argv(value).ok()) else { + continue; + }; + if exec.is_empty() { + continue; + } + let title = fields + .get("Name") + .cloned() + .unwrap_or_else(|| entry.file_name().to_string_lossy().into_owned()); + let (current_desktop, session_desktop) = fields + .get("DesktopNames") + .map(|value| { + ( + Some(value.replace(';', ":")), + value.split(';').next().map(str::to_owned), + ) + }) + .unwrap_or((None, None)); + output.push(SessionDescriptor { + kind, + title, + argv: exec, + current_desktop, + session_desktop, + }); + } +} + +fn scan_script_dir(path: &Path, kind: SessionKind, output: &mut Vec) { + let Ok(entries) = fs::read_dir(path) else { + return; + }; + let mut entries: Vec<_> = entries.filter_map(Result::ok).collect(); + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let path = entry.path(); + let Ok(metadata) = fs::symlink_metadata(&path) else { + continue; + }; + if !metadata.is_file() + || metadata.mode() & 0o111 == 0 + || metadata.mode() & 0o022 != 0 + || metadata.uid() != 0 + { + continue; + } + let file_name = entry.file_name(); + let Some(title) = file_name.to_str() else { + continue; + }; + output.push(SessionDescriptor { + kind, + title: title.into(), + argv: vec![path.to_string_lossy().into_owned()], + current_desktop: None, + session_desktop: None, + }); + } +} + +fn desktop_fields(raw: &str) -> std::collections::HashMap { + let mut fields = std::collections::HashMap::new(); + let mut in_desktop = false; + for line in raw.lines().map(str::trim) { + if line.starts_with('[') && line.ends_with(']') { + in_desktop = line == "[Desktop Entry]"; + continue; + } + if !in_desktop || line.is_empty() || line.starts_with('#') { + continue; + } + if let Some((key, value)) = line.split_once('=') { + fields.insert(key.trim().into(), value.trim().into()); + } + } + fields +} + +fn parse_argv(value: &str) -> Result, Error> { + let mut output = Vec::new(); + let mut current = String::new(); + let mut quote = None; + let mut escaped = false; + for character in value.chars() { + if escaped { + current.push(character); + escaped = false; + continue; + } + match (quote, character) { + (_, '\\') => escaped = true, + (Some(expected), character) if expected == character => quote = None, + (None, '\'' | '"') => quote = Some(character), + (None, character) if character.is_whitespace() => { + if !current.is_empty() { + output.push(std::mem::take(&mut current)); + } + } + (_, character) => current.push(character), + } + } + if escaped || quote.is_some() { + return Err(Error::Session("unterminated session command quote".into())); + } + if !current.is_empty() { + output.push(current); + } + if output.iter().any(|argument| argument.contains('%')) { + return Err(Error::Session( + "desktop Exec field codes are not supported".into(), + )); + } + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + use unicode_width::UnicodeWidthStr; + + #[test] + fn parses_argv_without_shell_expansion() { + let argv = parse_argv("/bin/example --title 'hello world'").expect("valid argv"); + assert_eq!(argv, ["/bin/example", "--title", "hello world"]); + } + + #[test] + fn rejects_field_codes() { + assert!(parse_argv("foo %f").is_err()); + } + + #[test] + fn display_width_is_not_byte_length() { + assert_eq!(UnicodeWidthStr::width("界"), 2); + } + + #[test] + fn environment_does_not_copy_display_or_xauthority() { + let identity = Identity { + username: "alice".into(), + uid: 1000, + gid: 1000, + home: "/home/alice".into(), + shell: "/bin/sh".into(), + }; + let descriptor = SessionDescriptor { + kind: SessionKind::Wayland, + title: "test".into(), + argv: vec!["/bin/true".into()], + current_desktop: None, + session_desktop: None, + }; + let spec = build_spec(&identity, &Config::default(), &descriptor).expect("valid spec"); + assert!( + spec.environment + .iter() + .all(|(key, _)| key != "DISPLAY" && key != "XAUTHORITY") + ); + } +} diff --git a/crates/latchd-tui/Cargo.toml b/crates/latchd-tui/Cargo.toml new file mode 100644 index 0000000..59dcd2c --- /dev/null +++ b/crates/latchd-tui/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "latchd-tui" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +crossterm = "0.29" +image = { version = "0.25.10", default-features = false, features = ["jpeg", "png"] } +latchd-auth = { path = "../latchd-auth" } +latchd-core = { path = "../latchd-core" } +ratatui = "0.30.2" +tracing = "0.1" +unicode-width = "0.2" +zeroize = "1.8" diff --git a/crates/latchd-tui/src/background.rs b/crates/latchd-tui/src/background.rs new file mode 100644 index 0000000..a0746bb --- /dev/null +++ b/crates/latchd-tui/src/background.rs @@ -0,0 +1,267 @@ +//! Low-cost terminal wallpaper and procedural ambient animation. + +use std::{ + fs, + path::Path, + time::{Duration, Instant}, +}; + +use image::{ImageReader, RgbImage, imageops::FilterType}; +use latchd_core::{ + config::VisualConfig, + error::{Error, ResultExt}, +}; +use ratatui::{ + Frame, + prelude::{Buffer, Color, Rect}, +}; + +const MAX_IMAGE_SIDE: u32 = 480; +const MAX_SOURCE_SIDE: u32 = 8_192; +const MAX_SOURCE_PIXELS: u64 = 16_777_216; +const FRAME_INTERVAL: Duration = Duration::from_millis(120); + +/// Background state owned by the TUI thread. +pub(crate) struct Background { + wallpaper: Option, + automata: Automata, + enabled: bool, + animations: bool, + reduced_motion: bool, + last_tick: Instant, +} + +impl Background { + /// Loads a bounded wallpaper image and prepares the procedural layer. + pub(crate) fn load(config: &VisualConfig) -> Result { + let wallpaper = config + .wallpaper_path + .as_deref() + .map(|path| load_wallpaper(path, config.max_wallpaper_bytes)) + .transpose()?; + Ok(Self { + wallpaper, + automata: Automata::default(), + enabled: config.background_enabled, + animations: config.animations, + reduced_motion: config.reduced_motion, + last_tick: Instant::now(), + }) + } + + pub(crate) fn tick_if_due(&mut self, now: Instant) -> bool { + if !self.enabled + || !self.animations + || self.reduced_motion + || now.duration_since(self.last_tick) < FRAME_INTERVAL + { + return false; + } + self.automata.tick(); + self.last_tick = now; + true + } + + pub(crate) fn toggle_enabled(&mut self) -> bool { + self.enabled = !self.enabled; + self.enabled + } + + pub(crate) fn toggle_motion(&mut self) -> bool { + self.reduced_motion = !self.reduced_motion; + self.reduced_motion + } + + pub(crate) fn draw(&mut self, frame: &mut Frame<'_>, area: Rect, ascii: bool, no_color: bool) { + if !self.enabled || area.width < 80 || area.width == 0 || area.height == 0 { + return; + } + self.automata + .resize(area.width as usize, area.height as usize); + let buffer = frame.buffer_mut(); + draw_wallpaper(buffer, area, self.wallpaper.as_ref(), no_color); + draw_automata(buffer, area, &self.automata, ascii, no_color); + } +} + +fn load_wallpaper(path: &Path, max_bytes: u64) -> Result { + let metadata = + fs::symlink_metadata(path).io_context(format!("inspect wallpaper {}", path.display()))?; + if !metadata.is_file() || metadata.uid() != 0 || metadata.mode() & 0o022 != 0 { + return Err(Error::Config(format!( + "wallpaper must be a root-owned, non-group-writable regular file: {}", + path.display() + ))); + } + if metadata.len() > max_bytes { + return Err(Error::Config(format!( + "wallpaper exceeds visual.max_wallpaper_bytes: {}", + path.display() + ))); + } + let reader = ImageReader::open(path) + .io_context(format!("open wallpaper {}", path.display()))? + .with_guessed_format() + .io_context(format!("inspect wallpaper format {}", path.display()))?; + let (width, height) = reader.into_dimensions().map_err(|error| { + Error::Config(format!( + "inspect wallpaper dimensions {}: {error}", + path.display() + )) + })?; + if width == 0 + || height == 0 + || width > MAX_SOURCE_SIDE + || height > MAX_SOURCE_SIDE + || u64::from(width).saturating_mul(u64::from(height)) > MAX_SOURCE_PIXELS + { + return Err(Error::Config(format!( + "wallpaper dimensions exceed {MAX_SOURCE_SIDE}x{MAX_SOURCE_SIDE} or {MAX_SOURCE_PIXELS} pixels" + ))); + } + let image = ImageReader::open(path) + .io_context(format!("open wallpaper {}", path.display()))? + .with_guessed_format() + .io_context(format!("inspect wallpaper format {}", path.display()))? + .decode() + .map_err(|error| Error::Config(format!("decode wallpaper {}: {error}", path.display())))?; + if image.width() == 0 || image.height() == 0 { + return Err(Error::Config( + "wallpaper dimensions must be non-zero".into(), + )); + } + let image = image.resize(MAX_IMAGE_SIDE, MAX_IMAGE_SIDE, FilterType::Triangle); + Ok(image.to_rgb8()) +} + +fn draw_wallpaper(buffer: &mut Buffer, area: Rect, wallpaper: Option<&RgbImage>, no_color: bool) { + let Some(image) = wallpaper else { return }; + if no_color { + return; + } + let width = image.width().max(1); + let height = image.height().max(1); + for y in 0..area.height { + for x in 0..area.width { + let source_x = (u32::from(x) * width / u32::from(area.width)).min(width - 1); + let source_y = (u32::from(y) * height / u32::from(area.height)).min(height - 1); + let pixel = image.get_pixel(source_x, source_y).0; + if let Some(cell) = buffer.cell_mut((area.x + x, area.y + y)) { + cell.set_symbol(" "); + cell.set_bg(Color::Rgb(pixel[0] / 3, pixel[1] / 3, pixel[2] / 3)); + } + } + } +} + +fn draw_automata( + buffer: &mut Buffer, + area: Rect, + automata: &Automata, + ascii: bool, + no_color: bool, +) { + for y in 0..area.height as usize { + for x in 0..area.width as usize { + if !automata.alive(x, y) { + continue; + } + let x = x as u16; + let y = y as u16; + if let Some(cell) = buffer.cell_mut((area.x + x, area.y + y)) { + cell.set_symbol(if ascii { "." } else { "·" }); + if !no_color { + cell.set_fg(Color::Rgb(60, 100, 120)); + } + } + } + } +} + +#[derive(Default)] +struct Automata { + width: usize, + height: usize, + current: Vec, + next: Vec, + seed: u64, +} + +impl Automata { + fn resize(&mut self, width: usize, height: usize) { + if self.width == width && self.height == height { + return; + } + self.width = width; + self.height = height; + self.current = vec![0; width.saturating_mul(height)]; + self.next = vec![0; width.saturating_mul(height)]; + self.seed = self.seed.wrapping_add(0x9E37_79B9_7F4A_7C15); + for index in 0..self.current.len() { + self.current[index] = u8::from(self.random() % 11 == 0); + } + } + + fn tick(&mut self) { + if self.width == 0 || self.height == 0 { + return; + } + for y in 0..self.height { + for x in 0..self.width { + let neighbors = self.neighbors(x, y); + let index = y * self.width + x; + self.next[index] = + u8::from(neighbors == 3 || (neighbors == 2 && self.current[index] == 1)); + } + } + std::mem::swap(&mut self.current, &mut self.next); + } + + fn neighbors(&self, x: usize, y: usize) -> u8 { + let mut count = 0; + for dy in [self.height.wrapping_sub(1), 0, 1] { + for dx in [self.width.wrapping_sub(1), 0, 1] { + if dx == 0 && dy == 0 { + continue; + } + let nx = (x + dx) % self.width; + let ny = (y + dy) % self.height; + count += self.current[ny * self.width + nx]; + } + } + count + } + + fn alive(&self, x: usize, y: usize) -> bool { + self.current + .get(y.saturating_mul(self.width).saturating_add(x)) + .copied() + == Some(1) + } + + fn random(&mut self) -> u64 { + let mut value = self.seed; + value ^= value << 13; + value ^= value >> 7; + value ^= value << 17; + self.seed = value; + value + } +} + +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn automata_is_bounded_and_progresses() { + let mut automata = Automata::default(); + automata.resize(8, 4); + assert_eq!(automata.current.len(), 32); + automata.tick(); + assert_eq!(automata.current.len(), 32); + } +} diff --git a/crates/latchd-tui/src/lib.rs b/crates/latchd-tui/src/lib.rs new file mode 100644 index 0000000..d0cc6e2 --- /dev/null +++ b/crates/latchd-tui/src/lib.rs @@ -0,0 +1,901 @@ +//! Ratatui login screen and deterministic state machine. + +use std::{ + sync::mpsc::{self, Receiver}, + thread, + time::{Duration, Instant}, +}; + +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::{ + DefaultTerminal, + prelude::{Constraint, Direction, Layout, Line, Rect, Span, Style}, + widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}, +}; +use unicode_width::UnicodeWidthStr; +use zeroize::Zeroizing; + +use latchd_auth::{AuthService, AuthTicket}; +use latchd_core::{ + accounts::{self, FaillockStatus, Identity}, + config::Config, + error::{Error, ResultExt}, + session::{self, SessionDescriptor}, +}; + +mod background; +use background::Background; + +const MIN_WIDTH: u16 = 60; +const MIN_HEIGHT: u16 = 16; +const MAX_INPUT: usize = 256; + +/// The result of one completed login-screen interaction. +pub struct LoginSelection { + /// Authenticated PAM ticket. + pub ticket: AuthTicket, + /// Selected session descriptor. + pub session: SessionDescriptor, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Focus { + Sessions, + Username, + Password, +} + +#[derive(Debug)] +enum AuthState { + Idle, + Pending(Receiver>), + Failed, +} + +#[derive(Debug, Clone, Copy)] +enum NoticeLevel { + Info, + Warning, +} + +#[derive(Debug)] +struct Notice { + message: String, + level: NoticeLevel, + expires: Instant, +} + +struct App { + config: Config, + auth: AuthService, + preview: bool, + sessions: Vec, + accounts: Vec, + account_index: Option, + faillock_receiver: Option>>, + faillock_status: Option, + selected: usize, + focus: Focus, + username: String, + password: Zeroizing, + status: String, + auth_state: AuthState, + show_help: bool, + power_confirmation: Option, + should_quit: bool, + ascii: bool, + no_color: bool, + background: Background, + notice: Option, + animation_frame: usize, + reduced_motion: bool, +} + +impl App { + fn new( + config: Config, + auth: AuthService, + preview: bool, + sessions: Vec, + accounts: Vec, + background: Background, + ) -> Self { + let username = accounts + .first() + .map(|account| account.username.clone()) + .unwrap_or_default(); + let account_index = (!accounts.is_empty()).then_some(0); + let reduced_motion = config.visual.reduced_motion; + Self { + config, + auth, + preview, + sessions, + accounts, + account_index, + faillock_receiver: None, + faillock_status: None, + selected: 0, + focus: Focus::Username, + username, + password: Zeroizing::new(String::new()), + status: "Enter your username".into(), + auth_state: AuthState::Idle, + show_help: false, + power_confirmation: None, + should_quit: false, + ascii: std::env::var_os("LATCHD_ASCII").is_some(), + no_color: std::env::var_os("NO_COLOR").is_some(), + background, + notice: None, + animation_frame: 0, + reduced_motion, + } + } + + fn run(&mut self, terminal: &mut DefaultTerminal) -> Result, Error> { + loop { + self.poll_faillock(); + let now = Instant::now(); + if self.background.tick_if_due(now) { + self.animation_frame = self.animation_frame.wrapping_add(1); + } + if self + .notice + .as_ref() + .is_some_and(|notice| notice.expires <= now) + { + self.notice = None; + } + terminal + .draw(|frame| self.draw(frame)) + .map_err(|error| Error::Terminal(error.to_string()))?; + if let Some(result) = self.poll_auth() { + match result { + Ok(ticket) => { + let selected = self + .sessions + .get(self.selected) + .cloned() + .ok_or_else(|| Error::Session("no session selected".into()))?; + return Ok(Some(LoginSelection { + ticket, + session: selected, + })); + } + Err(error) => { + tracing::warn!(%error, "authentication attempt failed"); + self.auth_state = AuthState::Failed; + self.password.clear(); + self.status = "Authentication failed".into(); + self.notify("Authentication failed", NoticeLevel::Warning); + self.start_faillock_query(); + } + } + } + if self.should_quit { + return Ok(None); + } + if event::poll(Duration::from_millis(100)).io_context("poll terminal event")? { + if let Event::Key(key) = event::read().io_context("read terminal event")? { + if key.kind == KeyEventKind::Press { + self.handle_key(key); + } + } + } + } + } + + fn poll_auth(&mut self) -> Option> { + let AuthState::Pending(receiver) = &self.auth_state else { + return None; + }; + match receiver.try_recv() { + Ok(result) => Some(result), + Err(mpsc::TryRecvError::Empty) => None, + Err(mpsc::TryRecvError::Disconnected) => Some(Err(Error::Worker( + "authentication task disconnected".into(), + ))), + } + } + + fn poll_faillock(&mut self) { + let Some(receiver) = &self.faillock_receiver else { + return; + }; + match receiver.try_recv() { + Ok(Ok(status)) => { + self.faillock_status = Some(status); + self.faillock_receiver = None; + if status.available { + if status.locked_out { + self.notify( + "Account has reached the advisory faillock threshold", + NoticeLevel::Warning, + ); + } else if status.failures > 0 { + self.notify( + &format!("{} failed attempt(s) recorded", status.failures), + NoticeLevel::Info, + ); + } + } + } + Ok(Err(error)) => { + tracing::debug!(%error, "faillock query failed"); + self.faillock_receiver = None; + } + Err(mpsc::TryRecvError::Empty) => {} + Err(mpsc::TryRecvError::Disconnected) => self.faillock_receiver = None, + } + } + + fn start_faillock_query(&mut self) { + let username = self.username.clone(); + if username.is_empty() { + self.faillock_receiver = None; + self.faillock_status = None; + return; + } + let config = self.config.accounts.clone(); + let (sender, receiver) = mpsc::sync_channel(1); + self.faillock_receiver = Some(receiver); + let task = thread::Builder::new() + .name("latchd-faillock".into()) + .spawn(move || { + let result = accounts::faillock_status(&username, &config); + let _ = sender.send(result); + }); + if let Err(error) = task { + tracing::debug!(%error, "could not start faillock query"); + self.faillock_receiver = None; + } + } + + fn notify(&mut self, message: &str, level: NoticeLevel) { + if self.config.visual.notifications { + self.notice = Some(Notice { + message: message.to_owned(), + level, + expires: Instant::now() + Duration::from_secs(4), + }); + } + } + + fn handle_key(&mut self, key: KeyEvent) { + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + self.should_quit = true; + return; + } + if matches!(self.auth_state, AuthState::Pending(_)) { + if key.code == KeyCode::Esc { + self.status = "Authentication is still in progress".into(); + } + return; + } + if self.show_help { + if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) { + self.show_help = false; + } + return; + } + if self.power_confirmation.is_none() && key.modifiers.contains(KeyModifiers::CONTROL) { + match key.code { + KeyCode::Char('n') => { + self.next_account(); + return; + } + KeyCode::Char('p') => { + self.previous_account(); + return; + } + KeyCode::Char('b') => { + let enabled = self.background.toggle_enabled(); + self.notify( + if enabled { + "Ambient background enabled" + } else { + "Ambient background disabled" + }, + NoticeLevel::Info, + ); + return; + } + KeyCode::Char('m') => { + let reduced = self.background.toggle_motion(); + self.reduced_motion = reduced; + self.notify( + if reduced { + "Reduced motion enabled" + } else { + "Reduced motion disabled" + }, + NoticeLevel::Info, + ); + return; + } + _ => {} + } + } + if let Some(index) = self.power_confirmation { + if key.code == KeyCode::Enter { + self.run_power_action(index); + } else if key.code == KeyCode::Esc { + self.power_confirmation = None; + self.status = "Power action cancelled".into(); + } + return; + } + match key.code { + KeyCode::Char('?') => self.show_help = true, + KeyCode::Esc => { + self.password.clear(); + self.status = "Login cancelled".into(); + } + KeyCode::Tab | KeyCode::Down => self.next_focus(), + KeyCode::BackTab | KeyCode::Up => self.previous_focus(), + KeyCode::Left if self.focus == Focus::Sessions => self.previous_session(), + KeyCode::Right if self.focus == Focus::Sessions => self.next_session(), + KeyCode::Enter if self.focus == Focus::Password => self.begin_authentication(), + KeyCode::F(number) => self.confirm_power_action(number), + KeyCode::Backspace => self.backspace(), + KeyCode::Char(character) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + self.insert(character) + } + _ => {} + } + } + + fn confirm_power_action(&mut self, number: u8) { + if self.preview { + self.status = "Preview mode — power actions are disabled".into(); + return; + } + let key = format!("F{number}"); + if let Some((index, action)) = self + .config + .power_controls + .entries + .iter() + .enumerate() + .find(|(_, action)| action.key.eq_ignore_ascii_case(&key)) + { + self.power_confirmation = Some(index); + self.status = format!("Confirm {} — press Enter, Esc cancels", action.hint); + } + } + + fn run_power_action(&mut self, index: usize) { + if self.preview { + self.power_confirmation = None; + self.status = "Preview mode — power actions are disabled".into(); + return; + } + let Some(action) = self.config.power_controls.entries.get(index).cloned() else { + self.power_confirmation = None; + self.status = "Power action is no longer available".into(); + return; + }; + if !action.program.is_absolute() { + self.power_confirmation = None; + self.status = "Power action must use an absolute program path".into(); + return; + } + match std::process::Command::new(&action.program) + .args(&action.args) + .spawn() + { + Ok(_) => { + self.power_confirmation = None; + self.status = format!("Started {}", action.hint); + } + Err(error) => { + self.power_confirmation = None; + self.status = format!("Could not start {}: {error}", action.hint); + } + } + } + + fn begin_authentication(&mut self) { + if self.preview { + self.status = "Preview mode — authentication and session startup are disabled".into(); + return; + } + if self.username.is_empty() || self.password.is_empty() { + self.status = "Username and password are required".into(); + return; + } + let username = self.username.clone(); + let password = self.password.as_bytes().to_vec(); + let service = self.config.pam_service.clone(); + let auth = self.auth.clone(); + let (sender, receiver) = mpsc::sync_channel(0); + let task = thread::Builder::new() + .name("latchd-auth-attempt".into()) + .spawn(move || { + let result = auth.authenticate(username, password, service); + let _ = sender.send(result); + }); + match task { + Ok(_) => { + self.auth_state = AuthState::Pending(receiver); + self.status = "Authenticating…".into(); + self.password.clear(); + } + Err(error) => self.status = format!("Could not start authentication: {error}"), + } + } + + fn insert(&mut self, character: char) { + if self.focus == Focus::Username { + self.account_index = None; + } + let target = match self.focus { + Focus::Username => &mut self.username, + Focus::Password => &mut self.password, + Focus::Sessions => return, + }; + if target.chars().count() < MAX_INPUT { + target.push(character); + } + } + + fn backspace(&mut self) { + if self.focus == Focus::Username { + self.account_index = None; + } + let target = match self.focus { + Focus::Username => &mut self.username, + Focus::Password => &mut self.password, + Focus::Sessions => return, + }; + target.pop(); + } + + fn next_focus(&mut self) { + self.focus = match self.focus { + Focus::Sessions => Focus::Username, + Focus::Username => Focus::Password, + Focus::Password => Focus::Sessions, + }; + self.update_hint(); + } + + fn previous_focus(&mut self) { + self.focus = match self.focus { + Focus::Sessions => Focus::Password, + Focus::Username => Focus::Sessions, + Focus::Password => Focus::Username, + }; + self.update_hint(); + } + + fn update_hint(&mut self) { + self.status = match self.focus { + Focus::Sessions => "Choose a session with ←/→".into(), + Focus::Username => "Enter your username".into(), + Focus::Password => "Enter your password and press Enter".into(), + }; + } + + fn next_session(&mut self) { + if !self.sessions.is_empty() { + self.selected = (self.selected + 1) % self.sessions.len(); + } + } + + fn previous_session(&mut self) { + if !self.sessions.is_empty() { + self.selected = self + .selected + .checked_sub(1) + .unwrap_or(self.sessions.len() - 1); + } + } + + fn next_account(&mut self) { + self.select_account(1); + } + + fn previous_account(&mut self) { + self.select_account(-1); + } + + fn select_account(&mut self, delta: isize) { + if self.accounts.is_empty() { + self.notify( + "No eligible accounts discovered; enter a username manually", + NoticeLevel::Info, + ); + return; + } + let current = self.account_index.unwrap_or(0) as isize; + let length = self.accounts.len() as isize; + let index = (current + delta).rem_euclid(length) as usize; + self.account_index = Some(index); + self.username = self.accounts[index].username.clone(); + self.password.clear(); + self.auth_state = AuthState::Idle; + self.faillock_status = None; + self.status = format!("Selected account {}", self.username); + self.start_faillock_query(); + self.notify( + &format!("Selected account {}", self.username), + NoticeLevel::Info, + ); + } + + fn draw(&mut self, frame: &mut ratatui::Frame) { + let area = frame.area(); + self.background.draw(frame, area, self.ascii, self.no_color); + if area.width < MIN_WIDTH || area.height < MIN_HEIGHT { + frame.render_widget(Clear, area); + frame.render_widget( + Paragraph::new(format!( + "terminal too small — need {MIN_WIDTH}×{MIN_HEIGHT}" + )) + .alignment(ratatui::prelude::Alignment::Center), + area, + ); + return; + } + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), + Constraint::Min(8), + Constraint::Length(2), + Constraint::Length(1), + ]) + .split(area); + let title = Paragraph::new(Line::from(vec![ + Span::styled("latchd", self.style_accent()), + Span::raw(" secure terminal login"), + ])) + .alignment(ratatui::prelude::Alignment::Center); + frame.render_widget(title, vertical[0]); + + let body = if area.width < 80 { + Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(5), Constraint::Length(8)]) + .split(vertical[1]) + } else { + Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(42), Constraint::Percentage(58)]) + .split(vertical[1]) + }; + let session_area = body[0]; + let form_area = if body.len() == 2 { + body[1] + } else { + vertical[1] + }; + let session_items: Vec = self + .sessions + .iter() + .enumerate() + .map(|(index, session)| { + let marker = if index == self.selected { + if self.ascii { ">" } else { "▶" } + } else { + " " + }; + ListItem::new(format!( + "{marker} {}", + truncate_cells( + &session.title, + self.config.environment_switcher.max_display_length + ) + )) + .style(if index == self.selected { + self.style_selected() + } else { + self.style_muted() + }) + }) + .collect(); + let sessions = + List::new(session_items).block(self.block("Session", self.focus == Focus::Sessions)); + frame.render_widget(sessions, session_area); + + let fields = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(1), + ]) + .split(form_area); + let account_title = self.account_title(); + let username = Paragraph::new(self.username.as_str()) + .block(self.block(&account_title, self.focus == Focus::Username)); + let password_marker = if self.ascii { "*" } else { "•" }; + let password = Paragraph::new(password_marker.repeat(self.password.chars().count())) + .block(self.block("Password", self.focus == Focus::Password)); + frame.render_widget(username, fields[0]); + frame.render_widget(password, fields[1]); + let status = self.status_line(); + frame.render_widget(Paragraph::new(status).wrap(Wrap { trim: true }), fields[2]); + frame.render_widget( + Paragraph::new( + "Tab focus ←/→ session Ctrl-N/P account Ctrl-B effects Ctrl-M motion ? help", + ), + vertical[2], + ); + frame.render_widget( + Paragraph::new("PAM authentication • session output stays off the login screen"), + vertical[3], + ); + if self.show_help { + self.draw_help(frame, area); + } + } + + fn draw_help(&self, frame: &mut ratatui::Frame, area: Rect) { + let popup = centered_rect(70, 50, area); + frame.render_widget(Clear, popup); + frame.render_widget(Paragraph::new("Keyboard help\n\nTab / Shift-Tab move focus\nArrow keys choose session\nCtrl-N / Ctrl-P switch account\nCtrl-B toggle background\nCtrl-M toggle reduced motion\nEnter authenticate\nEsc clear password\nCtrl-C quit cleanly\n? close help") + .block(self.block("Help", false)).wrap(Wrap { trim: true }), popup); + } + + fn block(&self, title: &str, focused: bool) -> Block<'static> { + let mut block = Block::default() + .borders(Borders::ALL) + .title(title.to_owned()) + .border_style(if focused { + self.style_accent() + } else { + self.style_muted() + }); + if !self.no_color { + block = block.style(Style::default().bg(ratatui::prelude::Color::Black)); + } + block + } + + fn account_title(&self) -> String { + self.account_index + .map(|index| format!("Username {}/{}", index + 1, self.accounts.len())) + .unwrap_or_else(|| "Username".into()) + } + + fn status_line(&self) -> String { + let mut status = self.status.clone(); + if matches!(self.auth_state, AuthState::Pending(_)) { + let spinner = if self.config.visual.animations && !self.reduced_motion { + ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + .get(self.animation_frame % 10) + .copied() + .unwrap_or("⠋") + } else if self.ascii { + "|" + } else { + "…" + }; + status = format!("{spinner} Authenticating…"); + } + if let Some(lockout) = self.faillock_status { + if lockout.locked_out { + status + .push_str(" • advisory faillock threshold reached; PAM remains authoritative"); + } else if lockout.failures > 0 { + let remaining = lockout + .remaining + .map_or_else(|| "unknown".into(), |value| value.to_string()); + status.push_str(&format!( + " • {} failed attempt(s), {remaining} remaining", + lockout.failures + )); + } + } + if let Some(notice) = &self.notice { + let marker = match notice.level { + NoticeLevel::Info => { + if self.ascii { + "[i]" + } else { + "ℹ" + } + } + NoticeLevel::Warning => { + if self.ascii { + "[!]" + } else { + "⚠" + } + } + }; + status.push_str(&format!(" {marker} {}", notice.message)); + } + status + } + + fn style_accent(&self) -> Style { + if self.no_color { + Style::default().add_modifier(ratatui::prelude::Modifier::BOLD) + } else { + Style::default() + .fg(ratatui::prelude::Color::Cyan) + .add_modifier(ratatui::prelude::Modifier::BOLD) + } + } + fn style_selected(&self) -> Style { + if self.no_color { + Style::default().add_modifier(ratatui::prelude::Modifier::REVERSED) + } else { + Style::default() + .fg(ratatui::prelude::Color::Yellow) + .add_modifier(ratatui::prelude::Modifier::BOLD) + } + } + fn style_muted(&self) -> Style { + Style::default().add_modifier(ratatui::prelude::Modifier::DIM) + } +} + +/// Runs the login UI and restores the terminal on every return path. +pub fn run( + config: Config, + auth: AuthService, + preview: bool, +) -> Result, Error> { + let sessions = session::discover(&config); + let accounts = if config.accounts.enumerate { + accounts::discover(&config.accounts) + } else { + Vec::new() + }; + let background = Background::load(&config.visual)?; + let mut app = App::new(config, auth, preview, sessions, accounts, background); + app.account_index = (!app.accounts.is_empty()).then_some(0); + app.start_faillock_query(); + ratatui::run(|terminal| app.run(terminal)).map_err(|error| Error::Terminal(error.to_string())) +} + +fn centered_rect(width_percent: u16, height_percent: u16, area: Rect) -> Rect { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - height_percent) / 2), + Constraint::Percentage(height_percent), + Constraint::Percentage((100 - height_percent) / 2), + ]) + .split(area); + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - width_percent) / 2), + Constraint::Percentage(width_percent), + Constraint::Percentage((100 - width_percent) / 2), + ]) + .split(vertical[1])[1] +} + +fn truncate_cells(value: &str, width: usize) -> String { + if UnicodeWidthStr::width(value) <= width { + return value.into(); + } + let mut output = String::new(); + let mut used = 0; + for character in value.chars() { + let character_width = unicode_width::UnicodeWidthChar::width(character).unwrap_or(1); + if used + character_width + 1 > width { + break; + } + output.push(character); + used += character_width; + } + output.push('…'); + output +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::{Terminal, backend::TestBackend}; + + #[test] + fn truncation_respects_terminal_cells() { + assert_eq!(truncate_cells("界界界", 5), "界界…"); + } + + #[test] + fn renders_the_login_screen_at_the_floor() { + let auth = AuthService::new().expect("PAM worker should start"); + let mut app = App::new( + Config::default(), + auth, + true, + vec![SessionDescriptor { + kind: session::SessionKind::Tty, + title: "TTY shell".into(), + argv: Vec::new(), + current_desktop: None, + session_desktop: None, + }], + Vec::new(), + Background::load(&Config::default().visual).expect("default background should load"), + ); + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).expect("test terminal should initialize"); + terminal + .draw(|frame| app.draw(frame)) + .expect("frame should render"); + let buffer = terminal.backend().buffer(); + let rendered = buffer + .content() + .chunks(buffer.area.width as usize) + .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) + .collect::>() + .join("\n"); + assert!(rendered.contains("latchd")); + assert!(rendered.contains("Username")); + assert!(rendered.contains("Password")); + } + + #[test] + fn renders_a_truthful_too_small_state() { + let auth = AuthService::new().expect("PAM worker should start"); + let mut app = App::new( + Config::default(), + auth, + true, + Vec::new(), + Vec::new(), + Background::load(&Config::default().visual).expect("default background should load"), + ); + let backend = TestBackend::new(59, 15); + let mut terminal = Terminal::new(backend).expect("test terminal should initialize"); + terminal + .draw(|frame| app.draw(frame)) + .expect("frame should render"); + let buffer = terminal.backend().buffer(); + let rendered = buffer + .content() + .chunks(buffer.area.width as usize) + .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) + .collect::>() + .join("\n"); + assert!(rendered.contains("terminal too small")); + } + + #[test] + fn switching_accounts_clears_password_and_auth_state() { + let auth = AuthService::new().expect("PAM worker should start"); + let accounts = vec![ + Identity { + username: "alice".into(), + uid: 1000, + gid: 1000, + home: "/home/alice".into(), + shell: "/bin/sh".into(), + }, + Identity { + username: "bob".into(), + uid: 1001, + gid: 1001, + home: "/home/bob".into(), + shell: "/bin/sh".into(), + }, + ]; + let mut app = App::new( + Config::default(), + auth, + true, + Vec::new(), + accounts, + Background::load(&Config::default().visual).expect("default background should load"), + ); + app.password = Zeroizing::new("secret".into()); + app.next_account(); + assert_eq!(app.username, "bob"); + assert!(app.password.is_empty()); + assert!(matches!(app.auth_state, AuthState::Idle)); + } +} diff --git a/extra/config.toml b/extra/config.toml index 3d4e023..fd7aa3c 100644 --- a/extra/config.toml +++ b/extra/config.toml @@ -1,13 +1,46 @@ -# latchd config — see src/config/config.go for all defaults. -# only include values you want to override. +# latchd configuration. All paths are validated before the login screen starts. -# tty = 2 -# pam_service = "latchd" -# system_shell = "/bin/sh" -# focus_behaviour = "default" +tty = 2 +main_log_path = "/var/log/latchd.log" +client_log_path = "/var/log/latchd.client.log" +pam_service = "latchd" +system_shell = "/bin/sh" +initial_path = "/usr/local/sbin:/usr/local/bin:/usr/bin" +[x11] +display = ":1" +server_path = "/usr/bin/X" +xauth_path = "/usr/bin/xauth" +server_timeout_secs = 60 +server_log_path = "/var/log/latchd.xorg.log" +sessions_path = "/usr/share/xsessions" +scripts_path = "/etc/latchd/wms" + +[wayland] +sessions_path = "/usr/share/wayland-sessions" +scripts_path = "/etc/latchd/wayland" + +[environment_switcher] +include_tty_shell = false +max_display_length = 18 + +# Power actions use an executable path and argv, never a shell command string. +# Keep this section empty unless the installation explicitly wants console +# power controls. [power_controls] -# [[power_controls.entries]] -# hint = "Hibernate" -# key = "F3" -# cmd = "systemctl hibernate" +entries = [] + +[accounts] +minimum_uid = 1000 +excluded_users = [] +enumerate = true +faillock_path = "/usr/sbin/faillock" +faillock_config = "/etc/security/faillock.conf" + +[visual] +background_enabled = true +animations = true +notifications = true +reduced_motion = false +max_wallpaper_bytes = 8388608 +# wallpaper_path = "/etc/latchd/wallpaper.png" diff --git a/extra/latchd.pam b/extra/latchd.pam index 7ac698b..5db7bba 100644 --- a/extra/latchd.pam +++ b/extra/latchd.pam @@ -2,3 +2,4 @@ auth include login account include login password include login +session include login diff --git a/extra/latchd.service b/extra/latchd.service index 27e4bb7..02497fc 100644 --- a/extra/latchd.service +++ b/extra/latchd.service @@ -9,6 +9,10 @@ StandardInput=tty TTYPath=/dev/tty2 TTYReset=yes TTYVHangup=yes +KillMode=control-group +Restart=on-failure +RestartSec=2s +UMask=0077 Type=idle [Install] diff --git a/extra/xsetup.sh b/extra/xsetup.sh deleted file mode 100755 index 8a4da26..0000000 --- a/extra/xsetup.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/sh -# sourced by latchd before starting an X11 session. -# runs the user's xprofile, xinitrc fragments, and xsession. - -_xexec="$1" -shift - -# source shell profiles -if [ -f /etc/profile ]; then - . /etc/profile -fi -shell="${SHELL##*/}" -case "$shell" in - zsh) [ -f "$HOME/.zprofile" ] && . "$HOME/.zprofile" ;; - bash) [ -f "$HOME/.bash_profile" ] && . "$HOME/.bash_profile" ;; - *) [ -f "$HOME/.profile" ] && . "$HOME/.profile" ;; -esac - -# source x profiles -[ -f /etc/xprofile ] && . /etc/xprofile -[ -f "$HOME/.xprofile" ] && . "$HOME/.xprofile" - -# run xinitrc.d scripts -for _f in /etc/X11/xinit/xinitrc.d/*; do - [ -x "$_f" ] && . "$_f" -done - -# run Xsession.d scripts -for _f in /etc/X11/Xsession.d/*; do - [ -x "$_f" ] && . "$_f" -done - -# load xresources -if [ -f "$HOME/.Xresources" ]; then - xrdb -merge "$HOME/.Xresources" -fi - -# run xsession -[ -f "$HOME/.xsession" ] && . "$HOME/.xsession" - -exec $_xexec "$@" diff --git a/legacy/README.md b/legacy/README.md new file mode 100644 index 0000000..16e2205 --- /dev/null +++ b/legacy/README.md @@ -0,0 +1,9 @@ +# Legacy Go implementation + +The original Go implementation remains in `legacy/go/` as a reference and +compatibility target while the production binary is built from the Rust +workspace. It is not part of the default build, install, service, release, or +Docker test paths. Run `make legacy-build` only when comparing behavior during +the migration; the target is intentionally labelled as quarantined. + +New security fixes and features belong in the Rust crates under `crates/`. diff --git a/src/auth/faillock.go b/legacy/go/auth/faillock.go similarity index 100% rename from src/auth/faillock.go rename to legacy/go/auth/faillock.go diff --git a/src/auth/pam.go b/legacy/go/auth/pam.go similarity index 99% rename from src/auth/pam.go rename to legacy/go/auth/pam.go index d759d3e..789388a 100644 --- a/src/auth/pam.go +++ b/legacy/go/auth/pam.go @@ -51,7 +51,7 @@ type AuthError struct { } const ( - ErrPAMService = iota + ErrPAMService = iota ErrAccountValidation ErrHomeDirInvalid ErrShellInvalid diff --git a/src/auth/utmpx.go b/legacy/go/auth/utmpx.go similarity index 100% rename from src/auth/utmpx.go rename to legacy/go/auth/utmpx.go diff --git a/src/auth/utmpx_time.h b/legacy/go/auth/utmpx_time.h similarity index 95% rename from src/auth/utmpx_time.h rename to legacy/go/auth/utmpx_time.h index ccee120..fe05439 100644 --- a/src/auth/utmpx_time.h +++ b/legacy/go/auth/utmpx_time.h @@ -3,6 +3,7 @@ // ut_tv is struct timeval (long tv_sec/tv_usec) or the compat struct // (__uint32_t tv_sec / __int32_t tv_usec). +#include static inline void latchd_set_utmpx_time(struct utmpx *ut, int64_t sec, int32_t usec) { ut->ut_tv.tv_sec = sec; ut->ut_tv.tv_usec = usec; diff --git a/src/config/config.go b/legacy/go/config/config.go similarity index 89% rename from src/config/config.go rename to legacy/go/config/config.go index 7e4216a..4167add 100644 --- a/src/config/config.go +++ b/legacy/go/config/config.go @@ -125,23 +125,23 @@ type WaylandConfig struct { } type Config struct { - TTY uint8 `toml:"tty"` - MainLogPath string `toml:"main_log_path"` - ClientLogPath string `toml:"client_log_path"` - CachePath string `toml:"cache_path"` - DoLog bool `toml:"do_log"` - PAMService string `toml:"pam_service"` - SystemShell string `toml:"system_shell"` - InitialPath string `toml:"initial_path"` - ShellLoginFlag ShellLoginFlag `toml:"shell_login_flag"` - FocusBehaviour FocusBehaviour `toml:"focus_behaviour"` - Background BackgroundConfig `toml:"background"` - PowerControls PowerControlConfig `toml:"power_controls"` - Switcher SwitcherConfig `toml:"environment_switcher"` - Username UsernameFieldConfig `toml:"username_field"` - Password PasswordFieldConfig `toml:"password_field"` - X11 X11Config `toml:"x11"` - Wayland WaylandConfig `toml:"wayland"` + TTY uint8 `toml:"tty"` + MainLogPath string `toml:"main_log_path"` + ClientLogPath string `toml:"client_log_path"` + CachePath string `toml:"cache_path"` + DoLog bool `toml:"do_log"` + PAMService string `toml:"pam_service"` + SystemShell string `toml:"system_shell"` + InitialPath string `toml:"initial_path"` + ShellLoginFlag ShellLoginFlag `toml:"shell_login_flag"` + FocusBehaviour FocusBehaviour `toml:"focus_behaviour"` + Background BackgroundConfig `toml:"background"` + PowerControls PowerControlConfig `toml:"power_controls"` + Switcher SwitcherConfig `toml:"environment_switcher"` + Username UsernameFieldConfig `toml:"username_field"` + Password PasswordFieldConfig `toml:"password_field"` + X11 X11Config `toml:"x11"` + Wayland WaylandConfig `toml:"wayland"` } func Default() Config { diff --git a/src/go.mod b/legacy/go/go.mod similarity index 82% rename from src/go.mod rename to legacy/go/go.mod index cad9f2e..7a7fc1e 100644 --- a/src/go.mod +++ b/legacy/go/go.mod @@ -4,10 +4,8 @@ go 1.22.0 require ( github.com/BurntSushi/toml v1.4.0 - github.com/charmbracelet/bubbles v0.18.0 github.com/charmbracelet/bubbletea v0.26.6 github.com/charmbracelet/lipgloss v0.11.0 - github.com/mattn/go-runewidth v0.0.15 ) require ( @@ -15,9 +13,12 @@ require ( github.com/charmbracelet/x/ansi v0.1.2 // indirect github.com/charmbracelet/x/input v0.1.0 // indirect github.com/charmbracelet/x/term v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.1.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.15.2 // indirect @@ -25,4 +26,5 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.21.0 // indirect + golang.org/x/text v0.3.8 // indirect ) diff --git a/src/go.sum b/legacy/go/go.sum similarity index 83% rename from src/go.sum rename to legacy/go/go.sum index a771b94..afc43f5 100644 --- a/src/go.sum +++ b/legacy/go/go.sum @@ -2,7 +2,6 @@ github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0 github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= github.com/charmbracelet/bubbletea v0.26.6 h1:zTCWSuST+3yZYZnVSvbXwKOPRSNZceVeqpzOLN2zq1s= github.com/charmbracelet/bubbletea v0.26.6/go.mod h1:dz8CWPlfCCGLFbBlTY4N7bjLiyOGDJEnd2Muu7pOWhk= github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g= @@ -13,12 +12,16 @@ github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8 github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28= github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI= github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw= +github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wph7Ika2k4= +github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -32,9 +35,13 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/src/login/login.go b/legacy/go/login/login.go similarity index 97% rename from src/login/login.go rename to legacy/go/login/login.go index 35f1cfd..716169d 100644 --- a/src/login/login.go +++ b/legacy/go/login/login.go @@ -424,8 +424,8 @@ func (m *Model) adminBypassCmd() tea.Cmd { // ── queries ──────────────────────────────────────────────────────────── -func (m *Model) Result() *Result { return m.result } -func (m *Model) Quitting() bool { return m.quitting } +func (m *Model) Result() *Result { return m.result } +func (m *Model) Quitting() bool { return m.quitting } func (m *Model) SetAutomata(a *bg.Automata) { m.automata = a } // ── view ─────────────────────────────────────────────────────────────── @@ -438,8 +438,8 @@ var ( infoCol = lipgloss.Color("3") // yellow greenCol = lipgloss.Color("2") // green - cardBg = lipgloss.Color("0") // black card background - cardPad = 2 + cardBg = lipgloss.Color("0") // black card background + cardPad = 2 avatarStyle = lipgloss.NewStyle().Foreground(accent) @@ -459,11 +459,11 @@ var ( fieldText = lipgloss.NewStyle().Foreground(bright) fieldFocus = lipgloss.NewStyle().Foreground(accent) - swSelected = lipgloss.NewStyle().Foreground(bright).Bold(true) - swNeighbour = lipgloss.NewStyle().Foreground(subtle) - swMover = lipgloss.NewStyle().Foreground(accent).Bold(true) - swDot = lipgloss.NewStyle().Foreground(subtle) - swDotActive = lipgloss.NewStyle().Foreground(accent) + swSelected = lipgloss.NewStyle().Foreground(bright).Bold(true) + swNeighbour = lipgloss.NewStyle().Foreground(subtle) + swMover = lipgloss.NewStyle().Foreground(accent).Bold(true) + swDot = lipgloss.NewStyle().Foreground(subtle) + swDotActive = lipgloss.NewStyle().Foreground(accent) powerHint = lipgloss.NewStyle().Foreground(subtle).Faint(true) diff --git a/src/main.go b/legacy/go/main.go similarity index 100% rename from src/main.go rename to legacy/go/main.go diff --git a/src/session/env.go b/legacy/go/session/env.go similarity index 100% rename from src/session/env.go rename to legacy/go/session/env.go diff --git a/src/session/exec.go b/legacy/go/session/exec.go similarity index 94% rename from src/session/exec.go rename to legacy/go/session/exec.go index 830ffb7..15c31e7 100644 --- a/src/session/exec.go +++ b/legacy/go/session/exec.go @@ -16,12 +16,12 @@ import ( ) type PostLoginEnv struct { - Kind string // "x11", "wayland", "tty" - Title string - XinitrcPath string - ScriptPath string - DesktopNames string // for XDG_CURRENT_DESKTOP (colon-separated) - SessionDesktop string // for XDG_SESSION_DESKTOP + Kind string // "x11", "wayland", "tty" + Title string + XinitrcPath string + ScriptPath string + DesktopNames string // for XDG_CURRENT_DESKTOP (colon-separated) + SessionDesktop string // for XDG_SESSION_DESKTOP } type SpawnedEnv struct { diff --git a/src/session/scan.go b/legacy/go/session/scan.go similarity index 100% rename from src/session/scan.go rename to legacy/go/session/scan.go diff --git a/src/session/wayland.go b/legacy/go/session/wayland.go similarity index 100% rename from src/session/wayland.go rename to legacy/go/session/wayland.go diff --git a/src/session/x11.go b/legacy/go/session/x11.go similarity index 100% rename from src/session/x11.go rename to legacy/go/session/x11.go diff --git a/src/tui/bg/automata.go b/legacy/go/tui/bg/automata.go similarity index 97% rename from src/tui/bg/automata.go rename to legacy/go/tui/bg/automata.go index 9cf7a05..4268046 100644 --- a/src/tui/bg/automata.go +++ b/legacy/go/tui/bg/automata.go @@ -23,10 +23,10 @@ const ( ) type Automata struct { - w, h int - a, b []uint8 - useA bool - buf strings.Builder + w, h int + a, b []uint8 + useA bool + buf strings.Builder } func NewAutomata(w, h int) *Automata { diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..d6eb8fb --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.88.0" +components = ["rustfmt", "clippy"] +profile = "minimal" diff --git a/rust/main.rs b/rust/main.rs new file mode 100644 index 0000000..6ae5f21 --- /dev/null +++ b/rust/main.rs @@ -0,0 +1,237 @@ +//! latchd is a root-owned terminal login manager. + +#![deny(unsafe_op_in_unsafe_fn)] +#![warn(missing_docs)] + +use std::{ + fs::{self, OpenOptions}, + io, + os::unix::fs::{MetadataExt, OpenOptionsExt}, + path::PathBuf, +}; + +use clap::Parser; +use latchd_auth::AuthService; +use latchd_core::{ + config::{self, Config}, + error::{Error, ResultExt}, + session, +}; +use latchd_tui as tui; +use tracing::{error, info}; + +const BUILD_VERSION: &str = env!("LATCHD_VERSION"); +const BUILD_COMMIT: &str = env!("LATCHD_COMMIT"); +const BUILD_TIME: &str = env!("LATCHD_BUILD_TIME"); + +/// Command-line options for latchd. +#[derive(Debug, Parser)] +#[command( + name = "latchd", + version = BUILD_VERSION, + about = "A secure terminal login manager" +)] +struct Cli { + /// Configuration TOML path. + #[arg(short, long, default_value = "/etc/latchd/config.toml")] + config: PathBuf, + /// Optional variable TOML path. + #[arg(short = 'v', long)] + variables: Option, + /// Override the configured virtual terminal. + #[arg(long)] + tty: Option, + /// Override the X11 desktop-entry directory. + #[arg(long)] + xsessions: Option, + /// Override the Wayland desktop-entry directory. + #[arg(long)] + wlsessions: Option, + /// Override the session PATH. + #[arg(long)] + initial_path: Option, + /// Run without authenticating or starting a real session. + #[arg(long)] + preview: bool, + /// Validate configuration and exit without starting the TUI. + #[arg(long, alias = "preflight")] + no_tui: bool, + /// Do not create a main log. + #[arg(long)] + no_log: bool, + /// Print the validated configuration and exit. + #[arg(long)] + show_config: bool, + /// Print embedded release metadata without accessing configuration or PAM. + #[arg(long)] + build_info: bool, +} + +fn main() -> color_eyre::Result<()> { + color_eyre::install()?; + run().map_err(|error| color_eyre::eyre::eyre!(error.to_string())) +} + +fn run() -> Result<(), Error> { + let cli = Cli::parse(); + if cli.build_info { + println!("version={BUILD_VERSION}\ncommit={BUILD_COMMIT}\nepoch={BUILD_TIME}"); + return Ok(()); + } + let config_path = if cli.config.exists() { + Some(cli.config.as_path()) + } else { + if cli.config.as_os_str() != "/etc/latchd/config.toml" { + return Err(Error::Config(format!( + "configuration does not exist: {}", + cli.config.display() + ))); + } + None + }; + if let Some(path) = cli.variables.as_deref() { + if !path.exists() { + return Err(Error::Config(format!( + "variables file does not exist: {}", + path.display() + ))); + } + } + let variables_path = cli.variables.as_deref(); + if !cli.preview { + if let Some(path) = config_path { + config::validate_source(path, "configuration")?; + } + if let Some(path) = variables_path { + config::validate_source(path, "variables")?; + } + } + let mut config = Config::default(); + if config_path.is_some() || variables_path.is_some() { + config = config::load(config_path, variables_path)?; + } + if let Some(tty) = cli.tty { + if tty == 0 { + return Err(Error::Config("--tty must be greater than zero".into())); + } + config.tty = tty; + } + if let Some(path) = cli.xsessions { + config.x11.sessions_path = path; + } + if let Some(path) = cli.wlsessions { + config.wayland.sessions_path = path; + } + if let Some(path) = cli.initial_path { + if path.is_empty() { + return Err(Error::Config("--initial-path must not be empty".into())); + } + config.initial_path = path; + } + config::validate(&config)?; + if cli.show_config { + println!( + "version = {BUILD_VERSION}\ncommit = {BUILD_COMMIT}\nbuilt = {BUILD_TIME}\ntty = {}\npam_service = {:?}\nsystem_shell = {:?}", + config.tty, config.pam_service, config.system_shell + ); + return Ok(()); + } + if cli.no_tui { + session::preflight(&config)?; + println!("configuration valid"); + return Ok(()); + } + if !cli.preview { + if std::env::var_os("XDG_SESSION_TYPE").is_some() { + return Err(Error::Config( + "already inside a session; use --preview".into(), + )); + } + if unsafe { libc::geteuid() } != 0 { + return Err(Error::Config("latchd must run as root".into())); + } + validate_terminal()?; + } + if !cli.no_log && !cli.preview { + setup_logging(&config.main_log_path)?; + } else { + tracing_subscriber::fmt() + .with_writer(io::sink) + .with_ansi(false) + .init(); + } + + let auth = AuthService::new()?; + while let Some(selection) = tui::run(config.clone(), auth.clone(), cli.preview)? { + let identity = selection.ticket.identity().clone(); + let spec = match session::build_spec(&identity, &config, &selection.session) { + Ok(spec) => spec, + Err(error) => { + let _ = auth.cancel(selection.ticket); + error!(%error, "cannot build session specification"); + continue; + } + }; + info!(user = %identity.username, session = %selection.session.title, "starting session"); + if let Err(error) = auth.run_session(selection.ticket, spec) { + error!(%error, "session ended with an error"); + } + } + Ok(()) +} + +fn setup_logging(path: &std::path::Path) -> Result<(), Error> { + const MAX_LOG_BYTES: u64 = 64 * 1024 * 1024; + let parent = path + .parent() + .ok_or_else(|| Error::Config(format!("log path has no parent: {}", path.display())))?; + fs::create_dir_all(parent).io_context(format!("create log directory {}", parent.display()))?; + let parent_metadata = fs::symlink_metadata(parent) + .io_context(format!("inspect log directory {}", parent.display()))?; + if !parent_metadata.is_dir() + || parent_metadata.uid() != 0 + || parent_metadata.mode() & 0o022 != 0 + { + return Err(Error::Config(format!( + "log directory must be root-owned and not group-writable: {}", + parent.display() + ))); + } + let file = OpenOptions::new() + .create(true) + .append(true) + .custom_flags(libc::O_NOFOLLOW) + .mode(0o600) + .open(path) + .io_context(format!("open log {}", path.display()))?; + if file + .metadata() + .io_context(format!("inspect log {}", path.display()))? + .len() + > MAX_LOG_BYTES + { + return Err(Error::Config(format!( + "log exceeds {MAX_LOG_BYTES} bytes: {}", + path.display() + ))); + } + file.set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600)) + .io_context(format!("secure log {}", path.display()))?; + tracing_subscriber::fmt() + .with_writer(file) + .with_ansi(false) + .init(); + Ok(()) +} + +fn validate_terminal() -> Result<(), Error> { + let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) } == 1; + let stdout_is_tty = unsafe { libc::isatty(libc::STDOUT_FILENO) } == 1; + if !stdin_is_tty || !stdout_is_tty { + return Err(Error::Terminal( + "latchd requires a controlling terminal (use --preview for non-interactive checks)" + .into(), + )); + } + Ok(()) +} diff --git a/scripts/test-docker.sh b/scripts/test-docker.sh new file mode 100755 index 0000000..dfcd4a6 --- /dev/null +++ b/scripts/test-docker.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +image="latchd-test:local" +docker build --pull -f Dockerfile.test -t "$image" .