diff --git a/.github/scripts/release_helpers.sh b/.github/scripts/release_helpers.sh index d4e2d2e8..ed01f089 100644 --- a/.github/scripts/release_helpers.sh +++ b/.github/scripts/release_helpers.sh @@ -208,6 +208,8 @@ lla-linux-i686 lla-macos-amd64 lla-macos-arm64 lla-netbsd-amd64 +lla-windows-amd64.exe +lla-windows-arm64.exe plugins-linux-amd64.tar.gz plugins-linux-amd64.zip plugins-linux-arm64.tar.gz @@ -218,6 +220,8 @@ plugins-macos-amd64.tar.gz plugins-macos-amd64.zip plugins-macos-arm64.tar.gz plugins-macos-arm64.zip +plugins-windows-amd64.zip +plugins-windows-arm64.zip lla_${version}_amd64.deb lla-${version}-1.x86_64.rpm lla-${version}-r0.x86_64.apk diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 4f8251c2..989a1562 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -11,7 +11,8 @@ The canonical maintainer checklist is in ## CI (`ci.yml`) - Triggered on pushes and pull requests to `main` when Rust sources, manifests, proto files, scripts, workflow files, package metadata, or toolchain files change. -- Runs formatting, Clippy, tests, and release-mode build checks across Linux and macOS. +- Runs formatting, Clippy, tests, and release-mode build checks across Linux, + macOS, Windows AMD64, and Windows ARM64. - Builds the default-feature CLI natively on NetBSD, verifies that Wasmtime is absent from its dependency graph, smoke-tests it, and uploads `lla-netbsd-amd64`. @@ -50,10 +51,14 @@ The canonical maintainer checklist is in - `cargo test --workspace` - `cargo test -p lla --features wasm-plugins` - Builds and verifies all release assets before publishing: - - full-featured CLI binaries built with `wasm-plugins`: `lla-linux-{amd64,arm64,i686}`, `lla-macos-*` + - full-featured CLI binaries built with `wasm-plugins`: + `lla-linux-{amd64,arm64,i686}`, `lla-macos-*`, and + `lla-windows-{amd64,arm64}.exe` - full-featured static musl binaries with Wasmtime: `lla-linux-{amd64,arm64}-musl` - native `lla-netbsd-amd64`, built and smoke-tested inside NetBSD 10.1 with default features and no Wasmtime dependency + - Windows AMD64/ARM64 plugin `.zip` archives, built and verified on native + GitHub-hosted runners - plugin archives: `plugins-*.tar.gz` and `plugins-*.zip` - Linux packages: `.deb`, `.rpm`, `.apk`, `.pkg.tar.zst` - `themes.zip` @@ -88,6 +93,7 @@ The canonical maintainer checklist is in - `.github/scripts/prepare_release.sh` updates release versions and changelog content for the generated PR. - `.github/scripts/release_helpers.sh` contains shared validation, expected asset, checksum, crates.io, and GitHub release helpers. -- `scripts/build_plugins.sh` builds plugin dynamic libraries and produces both `.tar.gz` and `.zip` archives for each release target. +- `scripts/build_plugins.sh` builds plugin dynamic libraries and produces + `.tar.gz` plus `.zip` archives on Unix and `.zip` archives on Windows. - `.github/scripts/check_glibc_baseline.sh` enforces the documented GNU/Linux compatibility baseline. - `.github/scripts/smoke_test_musl.sh` exercises core features and the explicit plugin error inside Alpine. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e505a35a..00c39baa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,7 @@ on: - "**/Cargo.lock" - "**/*.proto" - "**/*.sh" + - "**/*.ps1" - "plugins/*/plugin.toml" - "scripts/**" - ".github/workflows/ci.yml" @@ -22,6 +23,7 @@ on: - "**/Cargo.lock" - "**/*.proto" - "**/*.sh" + - "**/*.ps1" - "plugins/*/plugin.toml" - "scripts/**" - ".github/workflows/ci.yml" @@ -40,7 +42,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest, windows-11-arm] rust: [stable] steps: @@ -156,6 +158,14 @@ jobs: target: aarch64-apple-darwin artifact_name: lla-macos-arm64 + # Windows builds + - os: windows-latest + target: x86_64-pc-windows-msvc + artifact_name: lla-windows-amd64.exe + - os: windows-11-arm + target: aarch64-pc-windows-msvc + artifact_name: lla-windows-arm64.exe + steps: - uses: actions/checkout@v4 @@ -186,7 +196,8 @@ jobs: with: key: ${{ matrix.target }} - - name: Build release + - name: Build release (Unix) + if: runner.os != 'Windows' env: RUSTUP_TOOLCHAIN: stable run: | @@ -198,6 +209,14 @@ jobs: cargo build --release --target "${{ matrix.target }}" -p lla --features wasm-plugins fi + - name: Build release (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + RUSTUP_TOOLCHAIN: stable + RUSTFLAGS: -C target-feature=+crt-static + run: cargo build --release --target "${{ matrix.target }}" -p lla --features wasm-plugins + - name: Verify glibc baseline if: matrix.libc == 'gnu' run: | @@ -229,6 +248,112 @@ jobs: run: | cp target/${{ matrix.target }}/release/lla ${{ matrix.artifact_name }} + - name: Prepare and smoke-test binary (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $binary = "target/${{ matrix.target }}/release/lla.exe" + Copy-Item $binary "${{ matrix.artifact_name }}" + & $binary --help | Out-Null + & $binary --version | Out-Null + $fixture = Join-Path $env:RUNNER_TEMP "lla-windows-smoke" + New-Item -ItemType Directory -Force $fixture | Out-Null + $fixtureFile = Join-Path $fixture "fixture.txt" + [System.IO.File]::WriteAllBytes($fixtureFile, [byte[]](97, 98, 99)) + if (-not (Test-Path -LiteralPath $fixtureFile -PathType Leaf)) { + throw "Windows smoke fixture was not created: $fixtureFile" + } + if ((Get-Item -LiteralPath $fixtureFile).Length -ne 3) { + throw "Windows smoke fixture has an unexpected size" + } + $rawJson = (& $binary --json $fixture) -join "`n" + if ($LASTEXITCODE -ne 0) { throw "Windows JSON smoke test failed: $rawJson" } + $json = $rawJson | ConvertFrom-Json + if (@($json).Count -ne 1) { throw "Windows JSON smoke test returned an unexpected entry count: $rawJson" } + $entry = @($json)[0] + foreach ($field in @('owner_user', 'owner_group', 'inode', 'hard_links', 'allocated_size_bytes', 'security_context', 'mount_point', 'mount_source', 'filesystem')) { + if ($null -ne $entry.$field) { throw "Windows JSON field '$field' must be null" } + } + $imports = (& llvm-readobj --coff-imports $binary) -join "`n" + if ($LASTEXITCODE -ne 0) { throw 'Failed to inspect Windows PE imports' } + if ($imports -match '(?i)(VCRUNTIME|MSVCP)[0-9_]*\.dll') { + throw "Windows binary imports the redistributable MSVC runtime" + } + + - name: Build and verify Windows plugin archive + if: runner.os == 'Windows' + shell: bash + run: | + ./scripts/build_plugins.sh --target "${{ matrix.target }}" + ./scripts/verify_plugins_v3.sh "dist/plugins-windows-${{ matrix.target == 'x86_64-pc-windows-msvc' && 'amd64' || 'arm64' }}" + + - name: Verify Windows plugin PE imports + if: runner.os == 'Windows' + shell: pwsh + run: | + $pluginDirectory = "dist/plugins-windows-${{ matrix.target == 'x86_64-pc-windows-msvc' && 'amd64' || 'arm64' }}" + Get-ChildItem $pluginDirectory -Recurse -Filter *.dll | ForEach-Object { + $imports = (& llvm-readobj --coff-imports $_.FullName) -join "`n" + if ($LASTEXITCODE -ne 0) { throw "Failed to inspect PE imports for '$($_.FullName)'" } + if ($imports -match '(?i)(VCRUNTIME|MSVCP)[0-9_]*\.dll') { + throw "Plugin '$($_.Name)' imports the redistributable MSVC runtime" + } + } + + - name: Test PowerShell installer helpers + if: runner.os == 'Windows' + shell: pwsh + run: | + . ./install.ps1 + if ((Resolve-LlaArchitecture 'AMD64' '') -ne 'amd64') { throw 'AMD64 mapping failed' } + if ((Resolve-LlaArchitecture 'AMD64' 'ARM64') -ne 'arm64') { throw 'ARM64 native mapping failed' } + $asset = "lla-windows-amd64.exe" + $hash = 'a' * 64 + if ((Get-LlaChecksum "$hash $asset`n" $asset) -ne $hash) { throw 'Checksum parsing failed' } + try { Get-LlaChecksum "$hash $asset.old`n" $asset | Out-Null; throw 'Inexact checksum entry was accepted' } catch { + if ($_.Exception.Message -eq 'Inexact checksum entry was accepted') { throw } + } + try { Resolve-LlaArchitecture 'x86' '' | Out-Null; throw 'x86 was accepted' } catch { + if ($_.Exception.Message -eq 'x86 was accepted') { throw } + } + + $binary = (Resolve-Path "target/${{ matrix.target }}/release/lla.exe").Path + $expectedAsset = "lla-windows-$(Resolve-LlaArchitecture).exe" + $expectedHash = (Get-FileHash -Algorithm SHA256 $binary).Hash.ToLowerInvariant() + function Invoke-WebRequest { + param( + [Parameter(Position = 0)][string]$Uri, + [string]$OutFile + ) + if ($Uri.EndsWith('.exe')) { + Copy-Item $binary $OutFile + } else { + [PSCustomObject]@{ Content = "$expectedHash $expectedAsset`n" } + } + } + + $originalUserPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $originalProcessPath = $env:Path + $customInstall = Join-Path $env:RUNNER_TEMP 'lla-custom-install' + try { + $script:Version = 'v0.0.0-test' + $script:InstallDir = $customInstall + $script:NoPathUpdate = $true + Invoke-LlaInstall + if (-not (Test-Path (Join-Path $customInstall 'lla.exe'))) { throw 'Custom install directory was ignored' } + if ([Environment]::GetEnvironmentVariable('Path', 'User') -ne $originalUserPath) { throw '-NoPathUpdate changed the user PATH' } + + Add-LlaToUserPath $customInstall + $userEntries = @([Environment]::GetEnvironmentVariable('Path', 'User') -split ';') + if (-not ($userEntries -contains $customInstall)) { throw 'User PATH was not updated' } + Add-LlaToUserPath $customInstall + $matches = @([Environment]::GetEnvironmentVariable('Path', 'User') -split ';' | Where-Object { $_ -eq $customInstall }) + if ($matches.Count -ne 1) { throw 'User PATH update was not idempotent' } + } finally { + [Environment]::SetEnvironmentVariable('Path', $originalUserPath, 'User') + $env:Path = $originalProcessPath + } + - name: Upload artifact uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/rebuild-release-plugins.yml b/.github/workflows/rebuild-release-plugins.yml index c5e2b120..ca83130e 100644 --- a/.github/workflows/rebuild-release-plugins.yml +++ b/.github/workflows/rebuild-release-plugins.yml @@ -76,6 +76,14 @@ jobs: target: aarch64-apple-darwin os_label: macos arch_label: arm64 + - runner: windows-latest + target: x86_64-pc-windows-msvc + os_label: windows + arch_label: amd64 + - runner: windows-11-arm + target: aarch64-pc-windows-msvc + os_label: windows + arch_label: arm64 steps: - uses: actions/checkout@v4 @@ -96,6 +104,7 @@ jobs: cargo install cargo-zigbuild --version "$CARGO_ZIGBUILD_VERSION" --locked - name: Build plugin archives + shell: bash run: | if [[ "${{ runner.os }}" == "Linux" ]]; then ./scripts/build_plugins.sh --target "${{ matrix.target }}" --glibc-version "$GLIBC_BASELINE" @@ -110,10 +119,24 @@ jobs: .github/scripts/check_glibc_baseline.sh "$GLIBC_BASELINE" "${plugins[@]}" - name: Verify Plugin Platform v3 - if: matrix.target == 'x86_64-unknown-linux-gnu' || matrix.target == 'x86_64-apple-darwin' || matrix.target == 'aarch64-apple-darwin' + if: matrix.target == 'x86_64-unknown-linux-gnu' || matrix.target == 'x86_64-apple-darwin' || matrix.target == 'aarch64-apple-darwin' || matrix.target == 'x86_64-pc-windows-msvc' || matrix.target == 'aarch64-pc-windows-msvc' + shell: bash run: ./scripts/verify_plugins_v3.sh "dist/plugins-${{ matrix.os_label }}-${{ matrix.arch_label }}" + - name: Verify Windows plugin PE imports + if: matrix.os_label == 'windows' + shell: pwsh + run: | + Get-ChildItem "dist/plugins-windows-${{ matrix.arch_label }}" -Recurse -Filter *.dll | ForEach-Object { + $imports = (& llvm-readobj --coff-imports $_.FullName) -join "`n" + if ($LASTEXITCODE -ne 0) { throw "Failed to inspect PE imports for '$($_.FullName)'" } + if ($imports -match '(?i)(VCRUNTIME|MSVCP)[0-9_]*\.dll') { + throw "Plugin '$($_.Name)' imports the redistributable MSVC runtime" + } + } + - name: Confirm file_hash is native + shell: bash run: | manifest="dist/plugins-${{ matrix.os_label }}-${{ matrix.arch_label }}/file_hash/plugin.toml" grep -Eq '^runtime = "native"$' "$manifest" @@ -122,7 +145,8 @@ jobs: exit 1 fi - - name: Upload plugin artifacts + - name: Upload plugin artifacts (Unix) + if: matrix.os_label != 'windows' uses: actions/upload-artifact@v4 with: name: plugins-${{ matrix.os_label }}-${{ matrix.arch_label }} @@ -130,6 +154,13 @@ jobs: dist/plugins-${{ matrix.os_label }}-${{ matrix.arch_label }}.tar.gz dist/plugins-${{ matrix.os_label }}-${{ matrix.arch_label }}.zip + - name: Upload plugin artifact (Windows) + if: matrix.os_label == 'windows' + uses: actions/upload-artifact@v4 + with: + name: plugins-${{ matrix.os_label }}-${{ matrix.arch_label }} + path: dist/plugins-${{ matrix.os_label }}-${{ matrix.arch_label }}.zip + replace_assets: name: Replace Release Plugin Assets needs: [validate, build_plugins] @@ -151,7 +182,7 @@ jobs: - name: Replace plugin archives run: | set -euo pipefail - expected=10 + expected=12 actual="$(find rebuilt_plugins -maxdepth 1 -type f \( -name 'plugins-*.tar.gz' -o -name 'plugins-*.zip' \) | wc -l | tr -d ' ')" if [[ "$actual" != "$expected" ]]; then echo "Expected $expected rebuilt plugin archives, found $actual" 1>&2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2fd3ae0b..8d05947c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -170,12 +170,19 @@ jobs: - os: macos-latest target: aarch64-apple-darwin artifact_name: lla-macos-arm64 + - os: windows-latest + target: x86_64-pc-windows-msvc + artifact_name: lla-windows-amd64.exe + - os: windows-11-arm + target: aarch64-pc-windows-msvc + artifact_name: lla-windows-arm64.exe steps: - uses: actions/checkout@v4 with: ref: ${{ needs.validate.outputs.release_tag }} - name: Setup Protoc + if: runner.os != 'Windows' uses: arduino/setup-protoc@v3 with: version: "35.1" @@ -200,7 +207,8 @@ jobs: with: key: ${{ matrix.target }} - - name: Build release binary + - name: Build release binary (Unix) + if: runner.os != 'Windows' run: | if [[ "${{ matrix.libc }}" == "musl" ]]; then cargo zigbuild --release --target "${{ matrix.target }}" -p lla --features wasm-plugins @@ -210,6 +218,13 @@ jobs: cargo build --release --target "${{ matrix.target }}" -p lla --features wasm-plugins fi + - name: Build release binary (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + RUSTFLAGS: -C target-feature=+crt-static + run: cargo build --release --target "${{ matrix.target }}" -p lla --features wasm-plugins + - name: Verify glibc baseline if: matrix.libc == 'gnu' run: .github/scripts/check_glibc_baseline.sh "$GLIBC_BASELINE" "target/${{ matrix.target }}/release/lla" @@ -229,8 +244,41 @@ jobs: fi - name: Prepare binary artifact + if: runner.os != 'Windows' run: cp "target/${{ matrix.target }}/release/lla" "${{ matrix.artifact_name }}" + - name: Prepare and verify Windows binary artifact + if: runner.os == 'Windows' + shell: pwsh + run: | + $binary = "target/${{ matrix.target }}/release/lla.exe" + Copy-Item $binary "${{ matrix.artifact_name }}" + & $binary --help | Out-Null + & $binary --version | Out-Null + $fixture = Join-Path $env:RUNNER_TEMP "lla-release-smoke" + New-Item -ItemType Directory -Force $fixture | Out-Null + $fixtureFile = Join-Path $fixture "fixture.txt" + [System.IO.File]::WriteAllBytes($fixtureFile, [byte[]](97, 98, 99)) + if (-not (Test-Path -LiteralPath $fixtureFile -PathType Leaf)) { + throw "Windows smoke fixture was not created: $fixtureFile" + } + if ((Get-Item -LiteralPath $fixtureFile).Length -ne 3) { + throw "Windows smoke fixture has an unexpected size" + } + $rawJson = (& $binary --json $fixture) -join "`n" + if ($LASTEXITCODE -ne 0) { throw "Windows JSON smoke test failed: $rawJson" } + $json = $rawJson | ConvertFrom-Json + if (@($json).Count -ne 1) { throw "Windows JSON smoke test returned an unexpected entry count: $rawJson" } + $entry = @($json)[0] + foreach ($field in @('owner_user', 'owner_group', 'inode', 'hard_links', 'allocated_size_bytes', 'security_context', 'mount_point', 'mount_source', 'filesystem')) { + if ($null -ne $entry.$field) { throw "Windows JSON field '$field' must be null" } + } + $imports = (& llvm-readobj --coff-imports $binary) -join "`n" + if ($LASTEXITCODE -ne 0) { throw 'Failed to inspect Windows PE imports' } + if ($imports -match '(?i)(VCRUNTIME|MSVCP)[0-9_]*\.dll') { + throw "Windows binary imports the redistributable MSVC runtime" + } + - name: Upload binary artifact uses: actions/upload-artifact@v4 with: @@ -306,6 +354,14 @@ jobs: target: aarch64-apple-darwin os_label: macos arch_label: arm64 + - runner: windows-latest + target: x86_64-pc-windows-msvc + os_label: windows + arch_label: amd64 + - runner: windows-11-arm + target: aarch64-pc-windows-msvc + os_label: windows + arch_label: arm64 steps: - uses: actions/checkout@v4 with: @@ -328,6 +384,7 @@ jobs: cargo install cargo-zigbuild --version "$CARGO_ZIGBUILD_VERSION" --locked - name: Build plugin archives + shell: bash run: | if [[ "${{ runner.os }}" == "Linux" ]]; then ./scripts/build_plugins.sh --target "${{ matrix.target }}" --glibc-version "$GLIBC_BASELINE" @@ -342,10 +399,24 @@ jobs: .github/scripts/check_glibc_baseline.sh "$GLIBC_BASELINE" "${plugins[@]}" - name: Verify Plugin Platform v3 - if: matrix.target == 'x86_64-unknown-linux-gnu' || matrix.target == 'x86_64-apple-darwin' || matrix.target == 'aarch64-apple-darwin' + if: matrix.target == 'x86_64-unknown-linux-gnu' || matrix.target == 'x86_64-apple-darwin' || matrix.target == 'aarch64-apple-darwin' || matrix.target == 'x86_64-pc-windows-msvc' || matrix.target == 'aarch64-pc-windows-msvc' + shell: bash run: ./scripts/verify_plugins_v3.sh "dist/plugins-${{ matrix.os_label }}-${{ matrix.arch_label }}" - - name: Upload plugin artifacts + - name: Verify Windows plugin PE imports + if: matrix.os_label == 'windows' + shell: pwsh + run: | + Get-ChildItem "dist/plugins-windows-${{ matrix.arch_label }}" -Recurse -Filter *.dll | ForEach-Object { + $imports = (& llvm-readobj --coff-imports $_.FullName) -join "`n" + if ($LASTEXITCODE -ne 0) { throw "Failed to inspect PE imports for '$($_.FullName)'" } + if ($imports -match '(?i)(VCRUNTIME|MSVCP)[0-9_]*\.dll') { + throw "Plugin '$($_.Name)' imports the redistributable MSVC runtime" + } + } + + - name: Upload plugin artifacts (Unix) + if: matrix.os_label != 'windows' uses: actions/upload-artifact@v4 with: name: plugins-${{ matrix.os_label }}-${{ matrix.arch_label }} @@ -353,6 +424,13 @@ jobs: dist/plugins-${{ matrix.os_label }}-${{ matrix.arch_label }}.tar.gz dist/plugins-${{ matrix.os_label }}-${{ matrix.arch_label }}.zip + - name: Upload plugin artifact (Windows) + if: matrix.os_label == 'windows' + uses: actions/upload-artifact@v4 + with: + name: plugins-${{ matrix.os_label }}-${{ matrix.arch_label }} + path: dist/plugins-${{ matrix.os_label }}-${{ matrix.arch_label }}.zip + test_musl: name: Test Musl (${{ matrix.arch }}) needs: [validate, build_core] diff --git a/CHANGELOG.md b/CHANGELOG.md index d0e07e0a..f800c363 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Official Windows AMD64 and ARM64 executables now ship with native and WASM + plugin support, matching prebuilt plugin `.zip` archives, native CI coverage, + and statically linked MSVC runtimes. +- A checksum-verifying PowerShell installer supports latest or pinned releases, + custom installation directories, and optional user `PATH` updates. + +### Changed + +- Filesystem metadata and permission formatting now compile portably on Windows; + Windows uses deterministic synthetic permission bits and reports unavailable + Unix-only metadata as empty machine fields or `-` in human output. +- `lla upgrade` recognizes Windows AMD64/ARM64 `.exe` assets and safely replaces + the running executable. + ## [0.6.3] - 2026-08-20 ### Added diff --git a/Cargo.lock b/Cargo.lock index 6dade4b6..9f5a72a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2168,6 +2168,7 @@ dependencies = [ "prost", "rayon", "regex", + "self-replace", "serde", "serde_json", "sha2", @@ -3329,6 +3330,17 @@ dependencies = [ "walkdir", ] +[[package]] +name = "self-replace" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" +dependencies = [ + "fastrand", + "tempfile", + "windows-sys 0.52.0", +] + [[package]] name = "semver" version = "1.0.28" diff --git a/README.md b/README.md index 4f28464f..3bdbd54c 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,12 @@ formats, themes, shortcuts, and an extensible plugin platform. curl -sSL https://raw.githubusercontent.com/chaqchase/lla/main/install.sh | bash ``` +On Windows PowerShell: + +```powershell +irm https://raw.githubusercontent.com/chaqchase/lla/main/install.ps1 | iex +``` + Or use a package manager: | Platform | Command | @@ -47,6 +53,12 @@ Official releases include `lla-netbsd-amd64`; the install script selects it on NetBSD amd64. This binary supports native plugins but omits the unsupported embedded WASM runtime. +Windows releases include native AMD64 and ARM64 executables plus matching +prebuilt plugin archives. Both Windows executables include native and WASM +plugin support; the PowerShell installer verifies the release checksum and +installs `lla.exe` under `%LOCALAPPDATA%\Programs\lla`. Windows 10 and Windows +Server 2016 or newer are supported; 32-bit x86 Windows is not. + ## Try it ```bash @@ -133,7 +145,8 @@ differences, or the [command reference](docs/command-reference.md) for flags. - Native plugins provide trusted in-process extensions. On builds with the optional `wasm-plugins` feature, WebAssembly Component Model plugins run with declared, persisted grants and scoped host capabilities. Official Linux and - macOS release binaries enable this feature; the NetBSD binary does not. + macOS and Windows release binaries enable this feature; the NetBSD binary + does not. - Plugins can add typed listing fields, formatting, and actions with human, JSON, NDJSON, or CSV output. The bundled catalog covers metadata, Git context, code analysis, storage insights, file operations, and other focused workflows. diff --git a/docs/getting-started.md b/docs/getting-started.md index e2ed7995..566cd9e8 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -14,6 +14,17 @@ The script detects the operating system and architecture, downloads the matching release binary, verifies it against the release checksum, and installs it in `/usr/local/bin`. +On Windows PowerShell, use the native installer: + +```powershell +irm https://raw.githubusercontent.com/chaqchase/lla/main/install.ps1 | iex +``` + +It detects AMD64 or ARM64, verifies `SHA256SUMS`, installs to +`%LOCALAPPDATA%\Programs\lla`, and adds that directory to the user `PATH`. +Download `install.ps1` first to use its `-Version`, `-InstallDir`, or +`-NoPathUpdate` options. + ## Install with a package manager | Platform | Command | @@ -50,6 +61,20 @@ plugins are enabled, but the embedded WASM runtime is omitted because Wasmtime does not support NetBSD. Official prebuilt plugin archives are not currently published for NetBSD; native plugins can still be built and installed locally. +Windows releases include `lla-windows-amd64.exe` and +`lla-windows-arm64.exe`, built natively with `wasm-plugins`. Matching +`plugins-windows-amd64.zip` and `plugins-windows-arm64.zip` archives contain +native DLLs and WASM components. Windows reports file size and timestamps +normally, synthesizes Unix-style permission columns from file type and the +read-only attribute, and returns null or `-` for Unix-only ownership, inode, +hard-link, xattr, security-context, and mount metadata. Supported systems are +Windows 10 or Windows Server 2016 and newer on AMD64 or ARM64; 32-bit x86 is +rejected by the installer. + +Creating symlinks may require Windows Developer Mode or an elevated terminal. +Existing directory and file symlinks can still be listed normally when the +current account can access them. + ## Initialize lla Run the guided setup after installation: @@ -84,7 +109,8 @@ lla upgrade --path /usr/local/bin/lla `lla upgrade` uses the official installation logic to detect the platform, verify `SHA256SUMS`, and atomically replace the executable. It targets the currently running executable unless `--path` is provided. NetBSD amd64 upgrades -select the `lla-netbsd-amd64` release asset. +select the `lla-netbsd-amd64` release asset. Windows upgrades select the matching +`.exe` asset and use a Windows-safe self-replacement path. ## Next steps diff --git a/docs/handbook.md b/docs/handbook.md index 663731e5..3856b40c 100644 --- a/docs/handbook.md +++ b/docs/handbook.md @@ -504,9 +504,9 @@ the manifest. Raw sockets and subprocess execution are not exposed. The embedded WASM runtime is compiled only when the non-default `wasm-plugins` feature is enabled. It is available on supported x86_64 and ARM64 builds; i686 builds reject WASM packages as unsupported. Native packages remain available -through the default `dynamic-plugins` feature. Official Linux and macOS release -binaries explicitly enable `wasm-plugins`; the official NetBSD amd64 binary -uses the default feature set and omits Wasmtime. +through the default `dynamic-plugins` feature. Official Linux, macOS, and +Windows release binaries explicitly enable `wasm-plugins`; the official +NetBSD amd64 binary uses the default feature set and omits Wasmtime. ### Package is the trust and compatibility unit @@ -1664,6 +1664,9 @@ cargo clippy -p lla --no-default-features --all-targets -- -D warnings CI additionally checks the default build natively on NetBSD and asserts that its dependency graph does not include Wasmtime. The release workflow also builds and smoke-tests that native configuration as `lla-netbsd-amd64`. +Windows CI runs the workspace and WASM suites natively on AMD64 and ARM64, +builds statically linked MSVC executables, and verifies the matching plugin +archives on each architecture. Build and verify every bundled package: @@ -1795,7 +1798,14 @@ WASM components require an x86_64 or ARM64 build compiled with it; an unsupported architecture reports the platform separately. NetBSD default builds intentionally omit Wasmtime because its runtime does not support NetBSD. Use a native package there or install an official full-featured CLI on a -supported Linux or macOS target. +supported Linux, macOS, or Windows target. + +### Windows installer or symlink operation fails + +Official binaries support Windows 10 and Windows Server 2016 or newer on AMD64 +and ARM64. The PowerShell installer intentionally rejects 32-bit x86. If a +symlink operation returns access denied, enable Windows Developer Mode or use an +elevated terminal; ordinary listing and plugin use do not require elevation. ### WASM permission denied @@ -1903,9 +1913,11 @@ changelog, then opens a conventional release PR in the automated workflow. The release workflow validates version/tag/changelog/crates.io state, runs quality gates, and builds: -- dynamic/native-plugin-enabled Linux and macOS CLI binaries; release builds - explicitly enable `wasm-plugins`, so supported x86_64 and ARM64 artifacts - include WASM while Linux i686 does not; +- dynamic/native-plugin-enabled Linux, macOS, and Windows CLI binaries; release + builds explicitly enable `wasm-plugins`, so supported x86_64 and ARM64 + artifacts include WASM while Linux i686 does not; +- standalone Windows AMD64/ARM64 executables with the static MSVC runtime and + matching `.zip` archives containing native DLLs and WASM components; - a native NetBSD amd64 CLI binary built with default features, with native plugin loading enabled and Wasmtime omitted; - static musl amd64/arm64 binaries with the plugin runtime; diff --git a/docs/maintainers/releasing.md b/docs/maintainers/releasing.md index 8fb10fed..800c77a8 100644 --- a/docs/maintainers/releasing.md +++ b/docs/maintainers/releasing.md @@ -48,8 +48,10 @@ default dependency graph does not contain Wasmtime. The release workflow separately builds `lla-netbsd-amd64` inside NetBSD 10.1, checks that Wasmtime remains absent, exercises the binary's help path, and adds -it to the verified release assets and checksum manifest. Linux and macOS -binaries continue to build with `--features wasm-plugins`. +it to the verified release assets and checksum manifest. Linux, macOS, and +Windows binaries build with `--features wasm-plugins`. Windows AMD64 and ARM64 +builds run natively, use the static MSVC runtime, and verify their corresponding +plugin `.zip` archives. Build the SDK fixtures, including a real Component Model target: @@ -89,4 +91,5 @@ that exact version before continuing. A resumed workflow skips versions already published and rejects an inconsistent partial publication state. The GitHub release is published only after validation, binary/plugin artifact -collection (including `lla-netbsd-amd64`), and crates.io publication succeed. +collection (including NetBSD and both Windows architectures), and crates.io +publication succeed. diff --git a/docs/plugins/README.md b/docs/plugins/README.md index edf97479..74a27743 100644 --- a/docs/plugins/README.md +++ b/docs/plugins/README.md @@ -18,8 +18,9 @@ my-plugin/ For a WebAssembly package, the native library is replaced by the `.wasm` component named by `plugin.entrypoint`. Loading it requires an lla host built -with `--features wasm-plugins`; official Linux and macOS release binaries -include that feature, while the NetBSD release binary intentionally does not. +with `--features wasm-plugins`; official Linux, macOS, and Windows release +binaries include that feature, while the NetBSD release binary intentionally +does not. ## Install plugins @@ -29,6 +30,9 @@ Install the official bundle from the latest release: lla install --prebuilt ``` +Windows AMD64 and ARM64 releases provide matching `.zip` bundles containing +native DLLs and the portable WASM components. + Build and install plugins from a Git repository or a local source directory: ```bash diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index 67679f26..9bf6d36c 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -54,10 +54,11 @@ libraries are reported as disabled and never executed by lla 0.6.0. When the host is compiled with the non-default `wasm-plugins` feature, WebAssembly packages run through embedded Wasmtime with WASI Preview 2 and the -Component Model. Official Linux and macOS release binaries enable this feature; -the NetBSD release binary uses the default native-only runtime. The host exposes -scoped filesystem preopens, exact-domain WASI HTTP, and permission-gated -clipboard and URL calls. It does not expose raw sockets or subprocess execution. +Component Model. Official Linux, macOS, and Windows release binaries enable +this feature; the NetBSD release binary uses the default native-only runtime. +The host exposes scoped filesystem preopens, exact-domain WASI HTTP, and +permission-gated clipboard and URL calls. It does not expose raw sockets or +subprocess execution. The host limits each instance to 128 MiB of memory, responses to 16 MiB, batches to 512 entries, decoration calls to 5 seconds, and actions to 60 seconds. Traps, diff --git a/docs/plugins/developing.md b/docs/plugins/developing.md index 05fedfc7..aab6579c 100644 --- a/docs/plugins/developing.md +++ b/docs/plugins/developing.md @@ -17,11 +17,11 @@ are trusted code and must be built for every supported operating system and architecture. Use a WebAssembly component when portability and enforced permissions matter. -The host must be compiled with `--features wasm-plugins`; official Linux and -macOS release binaries enable it. The embedded runtime is available on -supported x86_64 and ARM64 Linux/macOS builds. i686 builds report WebAssembly -packages as unsupported, while the official NetBSD amd64 build omits Wasmtime -entirely. +The host must be compiled with `--features wasm-plugins`; official Linux, +macOS, and Windows release binaries enable it. The embedded runtime is +available on supported x86_64 and ARM64 Linux/macOS/Windows builds. i686 builds +report WebAssembly packages as unsupported, while the official NetBSD amd64 +build omits Wasmtime entirely. ## Create a native plugin diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 00000000..b43b568e --- /dev/null +++ b/install.ps1 @@ -0,0 +1,141 @@ +[CmdletBinding()] +param( + [string]$Version, + [string]$InstallDir = (Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'Programs\lla'), + [switch]$NoPathUpdate +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$LlaRepository = 'chaqchase/lla' + +function Resolve-LlaArchitecture { + param( + [string]$Architecture = $env:PROCESSOR_ARCHITECTURE, + [string]$NativeArchitecture = $env:PROCESSOR_ARCHITEW6432 + ) + + $effective = if ([string]::IsNullOrWhiteSpace($NativeArchitecture)) { + $Architecture + } else { + $NativeArchitecture + } + + switch ($effective.ToUpperInvariant()) { + 'AMD64' { return 'amd64' } + 'ARM64' { return 'arm64' } + default { + throw "Unsupported Windows architecture '$effective'. Official lla binaries are available for AMD64 and ARM64." + } + } +} + +function Normalize-LlaVersion { + param([Parameter(Mandatory = $true)][string]$RequestedVersion) + + $trimmed = $RequestedVersion.Trim() + if ([string]::IsNullOrWhiteSpace($trimmed)) { + throw 'Version must not be empty.' + } + if ($trimmed.StartsWith('v')) { + return $trimmed + } + return "v$trimmed" +} + +function Get-LlaChecksum { + param( + [Parameter(Mandatory = $true)][string]$Manifest, + [Parameter(Mandatory = $true)][string]$AssetName + ) + + $escaped = [Regex]::Escape($AssetName) + $matches = [Regex]::Matches( + $Manifest, + "(?m)^(?[0-9a-fA-F]{64})[ \t]+\*?$escaped[ \t]*\r?$" + ) + if ($matches.Count -ne 1) { + throw "SHA256SUMS must contain exactly one checksum entry for '$AssetName'." + } + return $matches[0].Groups['hash'].Value.ToLowerInvariant() +} + +function Add-LlaToUserPath { + param([Parameter(Mandatory = $true)][string]$Directory) + + $normalized = [IO.Path]::GetFullPath($Directory).TrimEnd('\') + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $entries = @( + $userPath -split ';' | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + ) + if (-not ($entries | Where-Object { + [IO.Path]::GetFullPath($_).TrimEnd('\').Equals( + $normalized, + [StringComparison]::OrdinalIgnoreCase + ) + })) { + $updated = (@($entries) + $normalized) -join ';' + [Environment]::SetEnvironmentVariable('Path', $updated, 'User') + } + + $processEntries = @($env:Path -split ';') + if (-not ($processEntries | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) -and + [IO.Path]::GetFullPath($_).TrimEnd('\').Equals( + $normalized, + [StringComparison]::OrdinalIgnoreCase + ) + })) { + $env:Path = "$normalized;$env:Path" + } +} + +function Invoke-LlaInstall { + $architecture = Resolve-LlaArchitecture + $releaseTag = if ([string]::IsNullOrWhiteSpace($Version)) { + Write-Host 'Fetching the latest lla release...' + (Invoke-RestMethod "https://api.github.com/repos/$LlaRepository/releases/latest").tag_name + } else { + Normalize-LlaVersion $Version + } + + $assetName = "lla-windows-$architecture.exe" + $releaseBase = "https://github.com/$LlaRepository/releases/download/$releaseTag" + $temporaryDirectory = Join-Path ([IO.Path]::GetTempPath()) ("lla-install-" + [Guid]::NewGuid()) + $downloadPath = Join-Path $temporaryDirectory $assetName + + New-Item -ItemType Directory -Force -Path $temporaryDirectory | Out-Null + try { + Write-Host "Downloading lla $releaseTag for Windows $architecture..." + Invoke-WebRequest "$releaseBase/$assetName" -OutFile $downloadPath + $checksumManifest = (Invoke-WebRequest "$releaseBase/SHA256SUMS").Content + $expected = Get-LlaChecksum $checksumManifest $assetName + $actual = (Get-FileHash -Algorithm SHA256 $downloadPath).Hash.ToLowerInvariant() + if ($actual -ne $expected) { + throw "Checksum verification failed for '$assetName'. Expected $expected, got $actual." + } + + New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null + $destination = Join-Path $InstallDir 'lla.exe' + Copy-Item -Force $downloadPath $destination + + if (-not $NoPathUpdate) { + Add-LlaToUserPath $InstallDir + } + + Write-Host "lla $releaseTag installed to $destination" -ForegroundColor Green + if ($NoPathUpdate) { + Write-Host "Add '$InstallDir' to PATH to invoke lla by name." + } else { + Write-Host "The user PATH includes '$InstallDir'. Open a new terminal if this session does not see it." + } + Write-Host "Run 'lla init' to create your configuration." + } finally { + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $temporaryDirectory + } +} + +if ($MyInvocation.InvocationName -ne '.') { + Invoke-LlaInstall +} diff --git a/lla/Cargo.toml b/lla/Cargo.toml index 27e61672..0896c68b 100644 --- a/lla/Cargo.toml +++ b/lla/Cargo.toml @@ -40,7 +40,6 @@ libloading = { workspace = true, optional = true } serde_json.workspace = true walkdir.workspace = true tempfile.workspace = true -users.workspace = true parking_lot.workspace = true lla_plugin_interface = { version = "0.6.3", path = "../interface" } lla_plugin_utils = { version = "0.6.3", path = "../utils" } @@ -68,12 +67,18 @@ zip = { version = "1", default-features = false, features = ["deflate"] } tar = "0.4" flate2 = "1" libc = "0.2" -xattr = "1.4" ureq.workspace = true sha2.workspace = true similar = "2" object = { version = "0.39", features = ["read"] } +[target.'cfg(unix)'.dependencies] +users.workspace = true +xattr = "1.4" + +[target.'cfg(windows)'.dependencies] +self-replace = "1.5.0" + [target.'cfg(any(target_arch = "x86_64", target_arch = "aarch64"))'.dependencies] wasmtime = { version = "47.0.3", default-features = false, features = ["anyhow", "cache", "component-model", "cranelift", "runtime", "std"], optional = true } wasmtime-wasi = { version = "47.0.3", default-features = false, features = ["p2"], optional = true } diff --git a/lla/src/commands/file_utils.rs b/lla/src/commands/file_utils.rs index 68a96c42..16d258f3 100644 --- a/lla/src/commands/file_utils.rs +++ b/lla/src/commands/file_utils.rs @@ -1120,7 +1120,7 @@ fn canonicalize_path_for_cache(path: &str) -> Option { .map(|p| p.to_string_lossy().into_owned()) } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; use std::os::unix::fs::{symlink, MetadataExt}; diff --git a/lla/src/config/mod.rs b/lla/src/config/mod.rs index d7211c5e..3dca30b2 100644 --- a/lla/src/config/mod.rs +++ b/lla/src/config/mod.rs @@ -496,7 +496,7 @@ enabled_plugins = {} # Directory where plugins are stored # Default: ~/.config/lla/plugins -plugins_dir = "{}" +plugins_dir = {} # Additional plugin locations searched after plugins_dir. # Package managers can install plugins into system directories without touching $HOME. @@ -624,7 +624,7 @@ editor = {}"#, self.permission_format, self.theme, serde_json::to_string(&self.enabled_plugins).unwrap_or_else(|_| "[]".to_string()), - plugins_dir_display, + format_string(&plugins_dir_display), { let home = dirs::home_dir(); let display_paths: Vec = self @@ -1776,6 +1776,22 @@ mod tests { assert!(content.contains("%Y-%m-%d %H:%M")); } + #[test] + fn generated_config_escapes_windows_paths() { + let config = Config { + plugins_dir: PathBuf::from(r"C:\Users\runner\.config\lla\plugins"), + ..Default::default() + }; + + let content = config.generate_config_content(); + let document = content.parse::().unwrap(); + + assert_eq!( + document.get("plugins_dir").and_then(TomlValue::as_str), + Some(r"C:\Users\runner\.config\lla\plugins") + ); + } + #[test] fn config_set_updates_long_date_format() { let config_dir = tempfile::tempdir().unwrap(); diff --git a/lla/src/formatter/fuzzy.rs b/lla/src/formatter/fuzzy.rs index cf37ef1b..7e8d2111 100644 --- a/lla/src/formatter/fuzzy.rs +++ b/lla/src/formatter/fuzzy.rs @@ -6,8 +6,6 @@ use crate::utils::hyperlink; use crate::utils::icons::format_with_icon; use colored::*; use lla_plugin_interface::proto::DecoratedEntry; -use std::fs::Permissions; -use std::os::unix::fs::PermissionsExt; use std::path::Path; use std::time::{Duration, SystemTime}; @@ -46,8 +44,8 @@ impl FuzzyFormatter { }; let name_display = hyperlink::link_path(path, name_display); - let perms = Permissions::from_mode(metadata.permissions); - let perms_display = colorize_permissions(&perms, Some(&self.permission_format)); + let perms_display = + colorize_permissions(metadata.permissions, Some(&self.permission_format)); let size = colorize_size(metadata.size); let modified = SystemTime::UNIX_EPOCH + Duration::from_secs(metadata.modified); let date = colorize_date(&modified); diff --git a/lla/src/formatter/long.rs b/lla/src/formatter/long.rs index 5518f460..3b1a4991 100644 --- a/lla/src/formatter/long.rs +++ b/lla/src/formatter/long.rs @@ -9,18 +9,22 @@ use crate::utils::{fs_metadata, hyperlink}; use chrono::format::{Item, StrftimeItems}; use chrono::{DateTime, Local}; use lla_plugin_interface::proto::{DecoratedEntry, EntryMetadata}; +#[cfg(unix)] use once_cell::sync::Lazy; use unicode_width::UnicodeWidthStr; +#[cfg(unix)] use std::collections::HashMap; -use std::fs::Permissions; -use std::os::unix::fs::PermissionsExt; use std::path::Path; +#[cfg(unix)] use std::sync::Mutex; use std::time::{Duration, SystemTime}; +#[cfg(unix)] use users::{get_group_by_gid, get_user_by_uid}; +#[cfg(unix)] static USER_CACHE: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); +#[cfg(unix)] static GROUP_CACHE: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); pub struct LongFormatter { @@ -131,17 +135,19 @@ impl LongFormatter { ) -> String { match column { ColumnKey::Permissions => { - let perms = Permissions::from_mode(metadata.permissions); - let mut rendered = colorize_permissions(&perms, Some(&self.permission_format)); + let mut rendered = + colorize_permissions(metadata.permissions, Some(&self.permission_format)); if metadata.has_acl { rendered.push('+'); } rendered } - ColumnKey::Inode => metadata.inode.to_string(), - ColumnKey::HardLinks => metadata.hard_links.to_string(), + ColumnKey::Inode => fs_metadata::format_inode(metadata), + ColumnKey::HardLinks => fs_metadata::format_hard_links(metadata), ColumnKey::Size => colorize_size(metadata.size).to_string(), - ColumnKey::AllocatedSize => colorize_size(metadata.allocated_size).to_string(), + ColumnKey::AllocatedSize => fs_metadata::allocated_size(metadata) + .map(|size| colorize_size(size).to_string()) + .unwrap_or_else(|| "-".to_string()), ColumnKey::Modified => self.format_timestamp(metadata.modified), ColumnKey::Created => self.format_timestamp(metadata.created), ColumnKey::Accessed => self.format_timestamp(metadata.accessed), @@ -263,6 +269,7 @@ impl Default for LongFormatter { } } +#[cfg(unix)] fn lookup_user(uid: u32) -> String { let resolved = || { get_user_by_uid(uid) @@ -281,6 +288,12 @@ fn lookup_user(uid: u32) -> String { resolved } +#[cfg(windows)] +fn lookup_user(_uid: u32) -> String { + "-".to_string() +} + +#[cfg(unix)] fn lookup_group(gid: u32) -> String { let resolved = || { get_group_by_gid(gid) @@ -299,6 +312,11 @@ fn lookup_group(gid: u32) -> String { resolved } +#[cfg(windows)] +fn lookup_group(_gid: u32) -> String { + "-".to_string() +} + fn visible_width(value: &str) -> usize { let stripped = strip_ansi_escapes::strip(value).unwrap_or_default(); let plain = String::from_utf8_lossy(&stripped); diff --git a/lla/src/formatter/serializable.rs b/lla/src/formatter/serializable.rs index f0a06402..d5110637 100644 --- a/lla/src/formatter/serializable.rs +++ b/lla/src/formatter/serializable.rs @@ -5,11 +5,16 @@ use std::fs; use std::path::{Path, PathBuf}; use chrono::{SecondsFormat, TimeZone, Utc}; +#[cfg(unix)] use once_cell::sync::Lazy; +#[cfg(unix)] use std::sync::Mutex; +#[cfg(unix)] use users::{get_group_by_gid, get_user_by_uid}; +#[cfg(unix)] static USER_CACHE: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); +#[cfg(unix)] static GROUP_CACHE: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); #[derive(Debug, Serialize)] @@ -58,6 +63,7 @@ fn mode_to_octal(permissions: u32) -> String { format!("{:04o}", permissions & 0o7777) } +#[cfg(unix)] fn uid_to_name(uid: u32) -> Option { if uid == 0 && get_user_by_uid(uid).is_none() { return None; @@ -75,6 +81,12 @@ fn uid_to_name(uid: u32) -> Option { name } +#[cfg(windows)] +fn uid_to_name(_uid: u32) -> Option { + None +} + +#[cfg(unix)] fn gid_to_name(gid: u32) -> Option { if gid == 0 && get_group_by_gid(gid).is_none() { return None; @@ -92,6 +104,11 @@ fn gid_to_name(gid: u32) -> Option { name } +#[cfg(windows)] +fn gid_to_name(_gid: u32) -> Option { + None +} + pub fn to_serializable(entry: &DecoratedEntry, git_status: Option) -> SerializableEntry { let path = Path::new(&entry.path); let name = path diff --git a/lla/src/formatter/table.rs b/lla/src/formatter/table.rs index ff6b7cc0..680446d8 100644 --- a/lla/src/formatter/table.rs +++ b/lla/src/formatter/table.rs @@ -8,17 +8,21 @@ use crate::utils::icons::format_with_icon; use crate::utils::{fs_metadata, hyperlink}; use colored::*; use lla_plugin_interface::proto::DecoratedEntry; +#[cfg(unix)] use once_cell::sync::Lazy; +#[cfg(unix)] use std::collections::HashMap; -use std::fs::Permissions; -use std::os::unix::fs::PermissionsExt; use std::path::Path; +#[cfg(unix)] use std::sync::Mutex; use std::time::{Duration, SystemTime}; use unicode_width::UnicodeWidthStr; +#[cfg(unix)] use users::{get_group_by_gid, get_user_by_uid}; +#[cfg(unix)] static USER_CACHE: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); +#[cfg(unix)] static GROUP_CACHE: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); pub struct TableFormatter { @@ -173,17 +177,19 @@ impl TableFormatter { ) -> String { match column { ColumnKey::Permissions => { - let perms = Permissions::from_mode(metadata.permissions); - let mut rendered = colorize_permissions(&perms, Some(&self.permission_format)); + let mut rendered = + colorize_permissions(metadata.permissions, Some(&self.permission_format)); if metadata.has_acl { rendered.push('+'); } rendered } - ColumnKey::Inode => metadata.inode.to_string(), - ColumnKey::HardLinks => metadata.hard_links.to_string(), + ColumnKey::Inode => fs_metadata::format_inode(metadata), + ColumnKey::HardLinks => fs_metadata::format_hard_links(metadata), ColumnKey::Size => colorize_size(metadata.size).to_string(), - ColumnKey::AllocatedSize => colorize_size(metadata.allocated_size).to_string(), + ColumnKey::AllocatedSize => fs_metadata::allocated_size(metadata) + .map(|size| colorize_size(size).to_string()) + .unwrap_or_else(|| "-".to_string()), ColumnKey::Modified => self.format_timestamp(metadata.modified), ColumnKey::Created => self.format_timestamp(metadata.created), ColumnKey::Accessed => self.format_timestamp(metadata.accessed), @@ -223,6 +229,7 @@ impl TableFormatter { } } +#[cfg(unix)] fn lookup_user(uid: u32) -> String { let resolved = || { get_user_by_uid(uid) @@ -241,6 +248,12 @@ fn lookup_user(uid: u32) -> String { resolved } +#[cfg(windows)] +fn lookup_user(_uid: u32) -> String { + "-".to_string() +} + +#[cfg(unix)] fn lookup_group(gid: u32) -> String { let resolved = || { get_group_by_gid(gid) @@ -259,6 +272,11 @@ fn lookup_group(gid: u32) -> String { resolved } +#[cfg(windows)] +fn lookup_group(_gid: u32) -> String { + "-".to_string() +} + impl FileFormatter for TableFormatter { fn format_files( &self, diff --git a/lla/src/installer.rs b/lla/src/installer.rs index 14222805..f519b7af 100644 --- a/lla/src/installer.rs +++ b/lla/src/installer.rs @@ -91,6 +91,16 @@ impl HostTarget { arch_label: "i686", library_extension: "so", }), + ("windows", "x86_64") => Ok(Self { + os_label: "windows", + arch_label: "amd64", + library_extension: "dll", + }), + ("windows", "aarch64") => Ok(Self { + os_label: "windows", + arch_label: "arm64", + library_extension: "dll", + }), _ => Err(LlaError::Plugin(format!( "Unsupported platform for prebuilt plugins: {}-{}", OS, ARCH @@ -99,6 +109,9 @@ impl HostTarget { } fn asset_candidates(&self) -> Vec { + if self.os_label == "windows" { + return vec![format!("plugins-{}-{}.zip", self.os_label, self.arch_label)]; + } vec![ format!("plugins-{}-{}.tar.gz", self.os_label, self.arch_label), format!("plugins-{}-{}.zip", self.os_label, self.arch_label), @@ -166,8 +179,20 @@ impl CliBinaryTarget { display_os: "NetBSD", musl: false, }), + ("windows", "x86_64") => Ok(Self { + os_label: "windows", + arch_label: "amd64", + display_os: "Windows", + musl: false, + }), + ("windows", "aarch64") => Ok(Self { + os_label: "windows", + arch_label: "arm64", + display_os: "Windows", + musl: false, + }), _ => Err(LlaError::Other(format!( - "Unsupported platform for CLI upgrades: {}-{} (supported: macOS/Linux on amd64, arm64, i686; NetBSD on amd64)", + "Unsupported platform for CLI upgrades: {}-{} (supported: Windows/macOS/Linux on amd64 and arm64; Linux on i686; NetBSD on amd64)", os, arch ))), } @@ -175,7 +200,15 @@ impl CliBinaryTarget { fn asset_name(&self) -> String { let musl_suffix = if self.musl { "-musl" } else { "" }; - format!("lla-{}-{}{}", self.os_label, self.arch_label, musl_suffix) + let executable_suffix = if self.os_label == "windows" { + ".exe" + } else { + "" + }; + format!( + "lla-{}-{}{}{}", + self.os_label, self.arch_label, musl_suffix, executable_suffix + ) } fn human_label(&self) -> String { @@ -822,6 +855,32 @@ fn install_cli_binary( let install_message = ui.progress_message("Installing", &destination.display().to_string()); let spinner = installer.create_status_spinner(&install_message); + #[cfg(windows)] + if destination_is_current_executable(destination) { + self_replace::self_replace(source).map_err(|err| { + ui.complete_progress_standalone( + &spinner, + StatusKind::Error, + format!("Failed to replace running executable: {}", err), + ); + LlaError::Other(format!( + "Failed to replace the running executable at {}: {}", + destination.display(), + err + )) + })?; + ui.complete_progress_standalone( + &spinner, + StatusKind::Success, + format!( + "Installed {} to {}", + ui.highlight_text(release_tag), + ui.highlight_text(&destination.display().to_string()) + ), + ); + return Ok(()); + } + let parent = destination.parent().ok_or_else(|| { LlaError::Other(format!( "Invalid install path '{}': missing parent directory", @@ -865,7 +924,7 @@ fn install_cli_binary( })?; mark_file_executable(&temp_target)?; - let rename_result = fs::rename(&temp_target, destination); + let rename_result = replace_cli_file(&temp_target, destination); if let Err(err) = rename_result { let _ = fs::remove_file(&temp_target); ui.complete_progress_standalone( @@ -892,6 +951,50 @@ fn install_cli_binary( Ok(()) } +#[cfg(windows)] +fn destination_is_current_executable(destination: &Path) -> bool { + let Ok(current) = std::env::current_exe() else { + return false; + }; + let current = current.canonicalize().unwrap_or(current); + let destination = destination + .canonicalize() + .unwrap_or_else(|_| destination.to_path_buf()); + current == destination +} + +#[cfg(not(windows))] +fn replace_cli_file(source: &Path, destination: &Path) -> std::io::Result<()> { + fs::rename(source, destination) +} + +#[cfg(windows)] +fn replace_cli_file(source: &Path, destination: &Path) -> std::io::Result<()> { + if !destination.exists() { + return fs::rename(source, destination); + } + + let backup = destination.with_extension(format!( + "lla-backup-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + fs::rename(destination, &backup)?; + match fs::rename(source, destination) { + Ok(()) => { + let _ = fs::remove_file(backup); + Ok(()) + } + Err(error) => { + let _ = fs::rename(backup, destination); + Err(error) + } + } +} + fn mark_file_executable(path: &Path) -> Result<()> { #[cfg(unix)] { @@ -901,11 +1004,7 @@ fn mark_file_executable(path: &Path) -> Result<()> { } #[cfg(not(unix))] - { - let mut perms = fs::metadata(path)?.permissions(); - perms.set_readonly(false); - fs::set_permissions(path, perms)?; - } + let _ = path; Ok(()) } @@ -3140,7 +3239,7 @@ impl PluginInstaller { #[cfg(test)] mod tests { use super::{CliBinaryTarget, PluginInstaller}; - #[cfg(feature = "dynamic-plugins")] + #[cfg(any(feature = "dynamic-plugins", windows))] use std::fs; use std::io::Write; @@ -3157,15 +3256,30 @@ mod tests { } #[test] - fn cli_asset_names_preserve_macos_i686_gnu_and_netbsd() { + fn cli_asset_names_preserve_macos_i686_gnu_netbsd_and_windows() { let macos = CliBinaryTarget::for_platform("macos", "aarch64", false).unwrap(); let i686 = CliBinaryTarget::for_platform("linux", "i686", false).unwrap(); let netbsd = CliBinaryTarget::for_platform("netbsd", "x86_64", false).unwrap(); + let windows_amd64 = CliBinaryTarget::for_platform("windows", "x86_64", false).unwrap(); + let windows_arm64 = CliBinaryTarget::for_platform("windows", "aarch64", false).unwrap(); assert_eq!(macos.asset_name(), "lla-macos-arm64"); assert_eq!(i686.asset_name(), "lla-linux-i686"); assert_eq!(netbsd.asset_name(), "lla-netbsd-amd64"); + assert_eq!(windows_amd64.asset_name(), "lla-windows-amd64.exe"); + assert_eq!(windows_arm64.asset_name(), "lla-windows-arm64.exe"); assert_eq!(netbsd.human_label(), "NetBSD (amd64)"); + assert_eq!(windows_arm64.human_label(), "Windows (arm64)"); + } + + #[test] + fn prebuilt_windows_plugins_use_zip_archives() { + let amd64 = super::HostTarget { + os_label: "windows", + arch_label: "amd64", + library_extension: "dll", + }; + assert_eq!(amd64.asset_candidates(), vec!["plugins-windows-amd64.zip"]); } #[test] @@ -3174,6 +3288,50 @@ mod tests { assert!(error.to_string().contains("static musl CLI upgrades")); } + #[cfg(windows)] + #[test] + fn windows_self_replace_helper() { + let Ok(source) = std::env::var("LLA_TEST_SELF_REPLACE_SOURCE") else { + return; + }; + self_replace::self_replace(source).unwrap(); + } + + #[cfg(windows)] + #[test] + fn windows_self_replace_updates_a_running_temporary_copy() { + use std::fs::OpenOptions; + use std::process::Command; + + const MARKER: &[u8] = b"lla-self-replace-test-marker"; + let directory = tempfile::tempdir().unwrap(); + let current = std::env::current_exe().unwrap(); + let installed = directory.path().join("lla-upgrade-test.exe"); + let replacement = directory.path().join("replacement.exe"); + fs::copy(¤t, &installed).unwrap(); + fs::copy(¤t, &replacement).unwrap(); + OpenOptions::new() + .append(true) + .open(&replacement) + .unwrap() + .write_all(MARKER) + .unwrap(); + + let status = Command::new(&installed) + .args([ + "--exact", + "installer::tests::windows_self_replace_helper", + "--nocapture", + ]) + .env("LLA_TEST_SELF_REPLACE_SOURCE", &replacement) + .status() + .unwrap(); + assert!(status.success()); + + let installed_bytes = fs::read(&installed).unwrap(); + assert!(installed_bytes.ends_with(MARKER)); + } + #[test] fn cli_checksum_calculation_remains_available_without_plugins() { let mut file = tempfile::NamedTempFile::new().unwrap(); diff --git a/lla/src/lister/fuzzy.rs b/lla/src/lister/fuzzy.rs index afef689d..10d5cbd6 100644 --- a/lla/src/lister/fuzzy.rs +++ b/lla/src/lister/fuzzy.rs @@ -2,6 +2,7 @@ use super::FileLister; use crate::utils::color::*; +use crate::utils::fs_metadata; use crate::utils::icons::format_with_icon; use crate::{error::Result, theme::color_value_to_color}; use colored::*; @@ -19,9 +20,7 @@ use ignore::WalkBuilder; use parking_lot::RwLock; use rayon::prelude::*; use std::collections::HashSet; -use std::fs::Permissions; use std::io::{self, stdout, Write}; -use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::Command as ProcessCommand; use std::sync::{ @@ -179,7 +178,7 @@ fn copy_to_clipboard(text: &str) -> std::io::Result<()> { } } -fn open_path(path: &PathBuf) -> std::io::Result<()> { +fn open_path(path: &Path) -> std::io::Result<()> { #[cfg(target_os = "macos")] { let _ = ProcessCommand::new("open").arg(path).spawn()?.wait(); @@ -1413,11 +1412,11 @@ impl ResultList { " ".normal() }; - let perms = metadata + let permissions = metadata .as_ref() - .map(|m| m.permissions()) - .unwrap_or_else(|| Permissions::from_mode(0o644)); - let perms_display = colorize_permissions(&perms, Some("symbolic")); + .map(fs_metadata::permission_mode) + .unwrap_or(0o100644); + let perms_display = colorize_permissions(permissions, Some("symbolic")); let size_display = colorize_size(size); let date_display = colorize_date(&modified); diff --git a/lla/src/plugin/mod.rs b/lla/src/plugin/mod.rs index 37999e60..17a57db3 100644 --- a/lla/src/plugin/mod.rs +++ b/lla/src/plugin/mod.rs @@ -1236,24 +1236,35 @@ impl PluginManager { fn manifest_matches_runtime(&self, manifest: &PluginManifest, entrypoint: &Path) -> bool { let mut probe = PluginManager::new(self.config.clone()); - if let Err(error) = probe.load_manifest_plugin(entrypoint, manifest) { - eprintln!( - "⚠️ Plugin '{}' could not be loaded for verification: {}", - manifest.plugin.name, error - ); - return false; - } - if !probe.loaded_plugin_matches_manifest(manifest, entrypoint) { - return false; - } - if let Err(error) = probe.verify_functional_contract(manifest) { - eprintln!( - "⚠️ Plugin '{}' failed v3 functional verification: {}", - manifest.plugin.name, error - ); - return false; - } - true + let matches = match probe.load_manifest_plugin(entrypoint, manifest) { + Err(error) => { + eprintln!( + "⚠️ Plugin '{}' could not be loaded for verification: {}", + manifest.plugin.name, error + ); + false + } + Ok(()) if !probe.loaded_plugin_matches_manifest(manifest, entrypoint) => false, + Ok(()) => match probe.verify_functional_contract(manifest) { + Ok(()) => true, + Err(error) => { + eprintln!( + "⚠️ Plugin '{}' failed v3 functional verification: {}", + manifest.plugin.name, error + ); + false + } + }, + }; + + // Unloading arbitrary DLLs after exercising them can crash on Windows when + // a dependency retains process-global or thread-local state. Verification + // commands are short-lived, so keep successful probes mapped and let the OS + // reclaim them at process exit. Native Unix plugins retain normal teardown. + #[cfg(windows)] + std::mem::forget(probe); + + matches } fn loaded_plugin_matches_manifest( diff --git a/lla/src/plugin/wasm_runtime.rs b/lla/src/plugin/wasm_runtime.rs index efbe12e7..7aa43481 100644 --- a/lla/src/plugin/wasm_runtime.rs +++ b/lla/src/plugin/wasm_runtime.rs @@ -485,11 +485,11 @@ mod tests { #[test] fn preopen_roots_reject_parent_traversal_and_symlink_escape() { - let root = tempfile::tempdir().unwrap(); assert!(canonical_preopen_root(Path::new("../escape"), true).is_err()); #[cfg(unix)] { use std::os::unix::fs::symlink; + let root = tempfile::tempdir().unwrap(); let outside = tempfile::tempdir().unwrap(); let link = root.path().join("escape"); symlink(outside.path(), &link).unwrap(); diff --git a/lla/src/utils/color.rs b/lla/src/utils/color.rs index 3b0d1102..9fa50d7b 100644 --- a/lla/src/utils/color.rs +++ b/lla/src/utils/color.rs @@ -133,12 +133,9 @@ pub fn colorize_user(user: &str) -> ColoredString { } } -use std::fs::Permissions; +#[cfg(unix)] use std::os::unix::fs::PermissionsExt; - -pub fn colorize_permissions(permissions: &Permissions, format: Option<&str>) -> String { - let mode = permissions.mode(); - +pub fn colorize_permissions(mode: u32, format: Option<&str>) -> String { if is_no_color() { return format_permissions_no_color(mode, format); } @@ -504,13 +501,27 @@ pub fn colorize_date_relative(date: &std::time::SystemTime) -> ColoredString { } } +#[cfg(unix)] fn is_executable(path: &Path) -> bool { - #[cfg(unix)] - { - if let Ok(metadata) = path.metadata() { - return metadata.permissions().mode() & 0o111 != 0; - } - } + path.metadata() + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +#[cfg(windows)] +fn is_executable(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + matches!( + extension.to_ascii_lowercase().as_str(), + "exe" | "com" | "bat" | "cmd" | "ps1" + ) + }) +} + +#[cfg(not(any(unix, windows)))] +fn is_executable(_path: &Path) -> bool { false } @@ -581,4 +592,12 @@ mod tests { "644" ); } + + #[cfg(windows)] + #[test] + fn windows_executable_extensions_are_case_insensitive() { + assert!(is_executable(Path::new("lla.EXE"))); + assert!(is_executable(Path::new("setup.ps1"))); + assert!(!is_executable(Path::new("notes.txt"))); + } } diff --git a/lla/src/utils/fs_metadata.rs b/lla/src/utils/fs_metadata.rs index 0d68416c..42d76d1b 100644 --- a/lla/src/utils/fs_metadata.rs +++ b/lla/src/utils/fs_metadata.rs @@ -1,8 +1,12 @@ use lla_plugin_interface::proto::EntryMetadata; use once_cell::sync::Lazy; use std::fs::Metadata; +#[cfg(target_os = "macos")] use std::os::unix::ffi::OsStrExt; +#[cfg(unix)] use std::os::unix::fs::MetadataExt; +#[cfg(windows)] +use std::os::windows::fs::MetadataExt; use std::path::{Path, PathBuf}; use std::process::Command; @@ -24,16 +28,91 @@ pub fn from_metadata(metadata: &Metadata) -> EntryMetadata { is_dir: metadata.is_dir(), is_file: metadata.is_file(), is_symlink: metadata.is_symlink(), - permissions: metadata.mode(), - uid: metadata.uid(), - gid: metadata.gid(), - inode: metadata.ino(), - hard_links: metadata.nlink(), - allocated_size: metadata.blocks().saturating_mul(512), + permissions: permission_mode(metadata), + uid: metadata_uid(metadata), + gid: metadata_gid(metadata), + inode: metadata_inode(metadata), + hard_links: metadata_hard_links(metadata), + allocated_size: metadata_allocated_size(metadata), ..EntryMetadata::default() } } +#[cfg(unix)] +pub fn permission_mode(metadata: &Metadata) -> u32 { + metadata.mode() +} + +#[cfg(windows)] +pub fn permission_mode(metadata: &Metadata) -> u32 { + const FILE_ATTRIBUTE_READONLY: u32 = 0x0000_0001; + let file_type = if metadata.is_symlink() { + 0o120000 + } else if metadata.is_dir() { + 0o040000 + } else { + 0o100000 + }; + let mut permissions = 0o444; + if metadata.file_attributes() & FILE_ATTRIBUTE_READONLY == 0 { + permissions |= 0o222; + } + if metadata.is_dir() { + permissions |= 0o111; + } + file_type | permissions +} + +#[cfg(unix)] +fn metadata_uid(metadata: &Metadata) -> u32 { + metadata.uid() +} + +#[cfg(windows)] +fn metadata_uid(_metadata: &Metadata) -> u32 { + 0 +} + +#[cfg(unix)] +fn metadata_gid(metadata: &Metadata) -> u32 { + metadata.gid() +} + +#[cfg(windows)] +fn metadata_gid(_metadata: &Metadata) -> u32 { + 0 +} + +#[cfg(unix)] +fn metadata_inode(metadata: &Metadata) -> u64 { + metadata.ino() +} + +#[cfg(windows)] +fn metadata_inode(_metadata: &Metadata) -> u64 { + 0 +} + +#[cfg(unix)] +fn metadata_hard_links(metadata: &Metadata) -> u64 { + metadata.nlink() +} + +#[cfg(windows)] +fn metadata_hard_links(_metadata: &Metadata) -> u64 { + 0 +} + +#[cfg(unix)] +fn metadata_allocated_size(metadata: &Metadata) -> u64 { + metadata.blocks().saturating_mul(512) +} + +#[cfg(windows)] +fn metadata_allocated_size(_metadata: &Metadata) -> u64 { + 0 +} + fn timestamp(value: std::io::Result) -> u64 { value .ok() @@ -60,6 +139,12 @@ pub fn enrich(path: &Path, metadata: &mut EntryMetadata, extended: bool, mounts: fn read_extended_metadata(path: &Path, metadata: &mut EntryMetadata) { metadata.has_acl = platform_has_acl(path); + #[cfg(unix)] + read_xattrs(path, metadata); +} + +#[cfg(unix)] +fn read_xattrs(path: &Path, metadata: &mut EntryMetadata) { let Ok(names) = xattr::list(path) else { return; }; @@ -125,6 +210,7 @@ fn platform_has_acl(_path: &Path) -> bool { false } +#[cfg(unix)] fn is_acl_attribute(name: &str) -> bool { matches!( name, @@ -149,6 +235,42 @@ pub fn format_xattrs(metadata: &EntryMetadata) -> String { .join(",") } +pub fn format_inode(metadata: &EntryMetadata) -> String { + #[cfg(unix)] + { + metadata.inode.to_string() + } + #[cfg(windows)] + { + let _ = metadata; + "-".to_string() + } +} + +pub fn format_hard_links(metadata: &EntryMetadata) -> String { + #[cfg(unix)] + { + metadata.hard_links.to_string() + } + #[cfg(windows)] + { + let _ = metadata; + "-".to_string() + } +} + +pub fn allocated_size(metadata: &EntryMetadata) -> Option { + #[cfg(unix)] + { + Some(metadata.allocated_size) + } + #[cfg(windows)] + { + let _ = metadata; + None + } +} + pub fn format_context(metadata: &EntryMetadata) -> String { if !metadata.security_context.is_empty() { metadata.security_context.clone() @@ -188,7 +310,7 @@ fn mount_for(path: &Path) -> Option<&'static MountInfo> { MOUNTS .iter() .filter(|mount| absolute.starts_with(&mount.point)) - .max_by_key(|mount| mount.point.as_os_str().as_bytes().len()) + .max_by_key(|mount| mount.point.as_os_str().len()) } fn load_mounts() -> Vec { @@ -283,6 +405,7 @@ mod tests { } #[test] + #[cfg(unix)] fn captures_inode_links_allocated_size_and_xattrs() { let directory = tempfile::tempdir().unwrap(); let file = directory.path().join("file"); @@ -304,6 +427,57 @@ mod tests { assert_eq!(metadata.xattrs.get(attribute), Some(&5)); } + #[cfg(windows)] + #[test] + fn synthesizes_windows_permissions_and_unavailable_metadata() { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("file.txt"); + fs::write(&file, b"content").unwrap(); + + let directory_metadata = from_metadata(&fs::metadata(directory.path()).unwrap()); + assert_eq!(directory_metadata.permissions & 0o170000, 0o040000); + assert_eq!(directory_metadata.permissions & 0o777, 0o777); + + let metadata = from_metadata(&fs::metadata(&file).unwrap()); + assert_eq!(metadata.permissions & 0o170000, 0o100000); + assert_eq!(metadata.permissions & 0o666, 0o666); + assert_eq!(metadata.uid, 0); + assert_eq!(metadata.gid, 0); + assert_eq!(metadata.inode, 0); + assert_eq!(metadata.hard_links, 0); + assert_eq!(metadata.allocated_size, 0); + + let mut permissions = fs::metadata(&file).unwrap().permissions(); + permissions.set_readonly(true); + fs::set_permissions(&file, permissions).unwrap(); + let readonly = from_metadata(&fs::metadata(&file).unwrap()); + assert_eq!(readonly.permissions & 0o222, 0); + assert_eq!(format_inode(&readonly), "-"); + assert_eq!(format_hard_links(&readonly), "-"); + assert_eq!(allocated_size(&readonly), None); + } + + #[cfg(windows)] + #[test] + fn preserves_windows_symlink_file_type_when_creation_is_permitted() { + use std::os::windows::fs::symlink_file; + + let directory = tempfile::tempdir().unwrap(); + let target = directory.path().join("target.txt"); + let link = directory.path().join("link.txt"); + fs::write(&target, b"content").unwrap(); + if let Err(error) = symlink_file(&target, &link) { + if error.kind() == std::io::ErrorKind::PermissionDenied { + return; + } + panic!("failed to create Windows symlink: {error}"); + } + + let metadata = from_metadata(&fs::symlink_metadata(&link).unwrap()); + assert!(metadata.is_symlink); + assert_eq!(metadata.permissions & 0o170000, 0o120000); + } + #[cfg(target_os = "macos")] #[test] fn detects_macos_extended_acl() { diff --git a/lla/src/utils/hyperlink.rs b/lla/src/utils/hyperlink.rs index 671fb627..3ebecd76 100644 --- a/lla/src/utils/hyperlink.rs +++ b/lla/src/utils/hyperlink.rs @@ -25,15 +25,36 @@ fn hyperlink(path: &Path, label: String) -> String { .map(|directory| directory.join(path)) .unwrap_or_else(|_| path.to_path_buf()) }; - let uri_path = percent_encode(absolute.to_string_lossy().as_bytes()); - format!("\x1b]8;;file://{}\x1b\\{}\x1b]8;;\x1b\\", uri_path, label) + let uri = file_uri(&absolute); + format!("\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\", uri, label) +} + +#[cfg(windows)] +fn file_uri(path: &Path) -> String { + let normalized = path.to_string_lossy().replace('\\', "/"); + if let Some(unc_path) = normalized.strip_prefix("//") { + format!("file://{}", percent_encode(unc_path.as_bytes())) + } else { + format!( + "file:///{}", + percent_encode(normalized.trim_start_matches('/').as_bytes()) + ) + } +} + +#[cfg(not(windows))] +fn file_uri(path: &Path) -> String { + format!( + "file://{}", + percent_encode(path.as_os_str().as_encoded_bytes()) + ) } fn percent_encode(value: &[u8]) -> String { let mut encoded = String::with_capacity(value.len()); for byte in value { match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => { encoded.push(*byte as char) } _ => encoded.push_str(&format!("%{:02X}", byte)), @@ -54,4 +75,16 @@ mod tests { assert!(linked.ends_with("label\x1b]8;;\x1b\\")); assert_eq!(strip_ansi_escapes::strip(&linked).unwrap(), b"label"); } + + #[cfg(windows)] + #[test] + fn emits_windows_drive_paths_as_file_uris() { + let linked = hyperlink( + Path::new(r"C:\Program Files\lla\lla.exe"), + "lla".to_string(), + ); + + assert!(linked.starts_with("\x1b]8;;file:///C:/Program%20Files/lla/lla.exe")); + assert!(!linked.contains("%5C")); + } } diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 93f80c46..031df3e3 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -10,4 +10,5 @@ targets = [ "aarch64-apple-darwin", "wasm32-wasip2", "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc", ] diff --git a/scripts/build_plugins.sh b/scripts/build_plugins.sh index f3846d6a..a4b13e66 100755 --- a/scripts/build_plugins.sh +++ b/scripts/build_plugins.sh @@ -148,6 +148,10 @@ elif [[ "$OS_LABEL" == "macos" ]]; then # some cdylibs. Keep plugin symbols intact; the CLI binary remains stripped. echo "Running: CARGO_PROFILE_RELEASE_STRIP=none cargo build --release --target $TARGET_TRIPLE ${BUILD_PKGS[*]}" CARGO_PROFILE_RELEASE_STRIP=none cargo build --release --target "$TARGET_TRIPLE" "${BUILD_PKGS[@]}" +elif [[ "$OS_LABEL" == "windows" ]]; then + echo "Running: RUSTFLAGS=-C target-feature=+crt-static cargo build --release --target $TARGET_TRIPLE ${BUILD_PKGS[*]}" + RUSTFLAGS="${RUSTFLAGS:-} -C target-feature=+crt-static" \ + cargo build --release --target "$TARGET_TRIPLE" "${BUILD_PKGS[@]}" else echo "Running: cargo build --release --target $TARGET_TRIPLE ${BUILD_PKGS[*]}" cargo build --release --target "$TARGET_TRIPLE" "${BUILD_PKGS[@]}" diff --git a/scripts/verify_plugins_v3.sh b/scripts/verify_plugins_v3.sh index ae7ff52f..dc072413 100755 --- a/scripts/verify_plugins_v3.sh +++ b/scripts/verify_plugins_v3.sh @@ -15,6 +15,13 @@ if [[ ! -d "$PACKAGE_DIR" ]]; then exit 1 fi +HOST_TRIPLE=$(rustc -vV | sed -n 's/^host: \(.*\)$/\1/p') +if [[ "$HOST_TRIPLE" == *windows* ]]; then + LLA_BIN="target/debug/lla.exe" +else + LLA_BIN="target/debug/lla" +fi + expected_count=$(find plugins -mindepth 2 -maxdepth 2 -name Cargo.toml -type f | wc -l | tr -d ' ') packaged_count=$(find "$PACKAGE_DIR" -mindepth 2 -maxdepth 2 -name plugin.toml -type f | wc -l | tr -d ' ') if [[ "$packaged_count" != "$expected_count" ]]; then @@ -37,6 +44,33 @@ done # Package verification runs WASM components with exactly their declared # permissions without persisting release-test grants in the archive. +WASM_FIXTURE_DIR="$PACKAGE_DIR/fixture_wasm" +WASM_FIXTURE_MANIFEST="sdk/tests/fixtures/wasm_component/plugin.toml" +WASM_FIXTURE_ARTIFACT="sdk/tests/fixtures/wasm_component/target/wasm32-wasip2/release/fixture_wasm.wasm" +rustup target add wasm32-wasip2 --toolchain stable >/dev/null +WASM_RUSTC=$(rustup which rustc --toolchain stable) +RUSTC="$WASM_RUSTC" rustup run stable cargo build --release \ + --manifest-path sdk/tests/fixtures/wasm_component/Cargo.toml \ + --target wasm32-wasip2 +mkdir -p "$WASM_FIXTURE_DIR" +cp "$WASM_FIXTURE_MANIFEST" "$WASM_FIXTURE_DIR/plugin.toml" +cp "$WASM_FIXTURE_ARTIFACT" "$WASM_FIXTURE_DIR/fixture_wasm.wasm" +python3 - "$WASM_FIXTURE_DIR" <<'PY' +import hashlib +import pathlib +import sys + +package_dir = pathlib.Path(sys.argv[1]) +artifact = package_dir / "fixture_wasm.wasm" +manifest = package_dir / "plugin.toml" +checksums = ( + '[files]\n' + f'"fixture_wasm.wasm" = "{hashlib.sha256(artifact.read_bytes()).hexdigest()}"\n' + f'"plugin.toml" = "{hashlib.sha256(manifest.read_bytes()).hexdigest()}"\n' +) +(package_dir / "checksums.toml").write_text(checksums) +PY + GRANTS_PATH="$PACKAGE_DIR/plugin-grants.toml" REMOVE_GRANTS=false if [[ ! -f "$GRANTS_PATH" ]]; then @@ -74,6 +108,7 @@ PY fi cleanup_grants() { + rm -rf "$WASM_FIXTURE_DIR" if [[ "$REMOVE_GRANTS" == true ]]; then rm -f "$GRANTS_PATH" fi @@ -82,21 +117,34 @@ trap cleanup_grants EXIT cargo build -p lla --features wasm-plugins TEST_HOME=$(mktemp -d) +if [[ "$HOST_TRIPLE" == *windows* ]]; then + if command -v cygpath >/dev/null 2>&1; then + TEST_HOME_NATIVE=$(cygpath -w "$TEST_HOME") + else + TEST_HOME_NATIVE="$TEST_HOME" + fi + export HOME="$TEST_HOME_NATIVE" + export USERPROFILE="$TEST_HOME_NATIVE" + export APPDATA="$TEST_HOME_NATIVE\\AppData\\Roaming" + export LOCALAPPDATA="$TEST_HOME_NATIVE\\AppData\\Local" +else + export HOME="$TEST_HOME" +fi cleanup_all() { cleanup_grants rm -rf "$TEST_HOME" } trap cleanup_all EXIT -HOME="$TEST_HOME" target/debug/lla --plugins-dir "$PACKAGE_DIR" plugin doctor +"$LLA_BIN" --plugins-dir "$PACKAGE_DIR" plugin doctor if [[ -d "$PACKAGE_DIR/file_hash" ]]; then - HOME="$TEST_HOME" target/debug/lla --plugins-dir "$PACKAGE_DIR" \ + "$LLA_BIN" --plugins-dir "$PACKAGE_DIR" \ plugin run file_hash help --output json >/dev/null HASH_FIXTURE="$TEST_HOME/hash-fixture" mkdir -p "$HASH_FIXTURE" printf 'abc' > "$HASH_FIXTURE/abc.txt" - HOME="$TEST_HOME" target/debug/lla --plugins-dir "$PACKAGE_DIR" \ + "$LLA_BIN" --plugins-dir "$PACKAGE_DIR" \ --enable-plugin file_hash --json "$HASH_FIXTURE" | python3 -c ' import json import sys @@ -107,3 +155,17 @@ assert fields["sha1"] == "a9993e364706816aba3e25717850c26c9cd0d89d" assert fields["sha256"] == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" ' fi + + +WASM_LIST_FIXTURE="$TEST_HOME/wasm-list-fixture" +mkdir -p "$WASM_LIST_FIXTURE" +printf 'wasm' > "$WASM_LIST_FIXTURE/fixture.txt" +"$LLA_BIN" --plugins-dir "$PACKAGE_DIR" \ + --enable-plugin fixture_wasm --json "$WASM_LIST_FIXTURE" | python3 -c ' +import json +import sys + +entries = json.load(sys.stdin) +assert len(entries) == 1 +assert entries[0]["name"] == "fixture.txt" +' diff --git a/utils/Cargo.toml b/utils/Cargo.toml index 2787e9a3..97e55ec7 100644 --- a/utils/Cargo.toml +++ b/utils/Cargo.toml @@ -18,13 +18,15 @@ colored = { workspace = true } toml = { workspace = true } dirs = { workspace = true } chrono = { workspace = true } -users = { workspace = true } indicatif = { workspace = true } console = "0.15.8" dialoguer = "0.11.0" syntect = { version = "5.1.0", optional = true } lazy_static = { version = "1.4", optional = true } +[target.'cfg(unix)'.dependencies] +users = { workspace = true } + [features] default = ["config", "ui", "format", "syntax", "interactive"] config = [] diff --git a/utils/src/format.rs b/utils/src/format.rs index 016bf67b..89a9be62 100644 --- a/utils/src/format.rs +++ b/utils/src/format.rs @@ -90,15 +90,24 @@ pub fn format_file_type(entry: &DecoratedEntry) -> String { } pub fn format_ownership(uid: u32, gid: u32) -> String { - use users::{get_group_by_gid, get_user_by_uid}; + #[cfg(unix)] + { + use users::{get_group_by_gid, get_user_by_uid}; + + let user = get_user_by_uid(uid) + .map(|u| u.name().to_string_lossy().into_owned()) + .unwrap_or_else(|| uid.to_string()); - let user = get_user_by_uid(uid) - .map(|u| u.name().to_string_lossy().into_owned()) - .unwrap_or_else(|| uid.to_string()); + let group = get_group_by_gid(gid) + .map(|g| g.name().to_string_lossy().into_owned()) + .unwrap_or_else(|| gid.to_string()); - let group = get_group_by_gid(gid) - .map(|g| g.name().to_string_lossy().into_owned()) - .unwrap_or_else(|| gid.to_string()); + format!("{}:{}", user, group) + } - format!("{}:{}", user, group) + #[cfg(windows)] + { + let _ = (uid, gid); + "-:-".to_string() + } }