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" .