diff --git a/CHANGELOG.md b/CHANGELOG.md index 92145dce77..380a417315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,69 @@ # Changelog +## [2026.6.12](https://github.com/jdx/mise/compare/v2026.6.11..v2026.6.12) - 2026-06-21 + +### 🚀 Features + +- **(bootstrap)** add skip controls by @jdx in [#10497](https://github.com/jdx/mise/pull/10497) +- **(http)** resolve cross-platform lock checksums from published metadata by @itochan in [#10509](https://github.com/jdx/mise/pull/10509) +- **(upgrade)** fix tool removal when minimum_release_age is set by @roele in [#10466](https://github.com/jdx/mise/pull/10466) + +### 🐛 Bug Fixes + +- **(aqua)** support checksum verification fields by @risu729 in [#10473](https://github.com/jdx/mise/pull/10473) +- **(backend)** resolve dependency executables on Windows by @risu729 in [#10514](https://github.com/jdx/mise/pull/10514) +- **(env)** expand escaped dollar in env shell expansion by @jdx in [#10511](https://github.com/jdx/mise/pull/10511) +- **(install)** rebuild symlinks after partial installs by @risu729 in [#10470](https://github.com/jdx/mise/pull/10470) +- **(npm)** warn when system pnpm/bun may not support minimum_release_age by @risu729 in [#10491](https://github.com/jdx/mise/pull/10491) +- **(pipx)** upgrade shared pip for release age installs by @risu729 in [#10472](https://github.com/jdx/mise/pull/10472) +- **(pipx)** warn for unsupported uv exclude-newer by @risu729 in [#10510](https://github.com/jdx/mise/pull/10510) +- **(pipx)** force pip backend for mise pipx subprocess calls by @risu729 in [#10513](https://github.com/jdx/mise/pull/10513) +- **(system)** drop bare -- from dnf argv (breaks DNF5 install/upgrade) by @spencergilbert in [#10538](https://github.com/jdx/mise/pull/10538) +- **(task)** align duplicate config task precedence by @risu729 in [#10471](https://github.com/jdx/mise/pull/10471) +- **(task)** skip mise configs in task include dirs by @jdx in [#10500](https://github.com/jdx/mise/pull/10500) +- **(task)** error on empty task shell by @jdx in [#10517](https://github.com/jdx/mise/pull/10517) +- **(task)** honor show_full_cmd in the task command header by @JamBalaya56562 in [#10518](https://github.com/jdx/mise/pull/10518) +- **(vfox)** resolve tools=true env path templates against the dependency toolset by @JamBalaya56562 in [#10481](https://github.com/jdx/mise/pull/10481) + +### 📚 Documentation + +- **(dotfiles)** make self-managing config first-run safe by @jdx in [#10494](https://github.com/jdx/mise/pull/10494) +- **(env)** remove emoji checkboxes by @jdx in [#10504](https://github.com/jdx/mise/pull/10504) +- recommend keeping mise current by @jdx in [#10505](https://github.com/jdx/mise/pull/10505) + +### 📦️ Dependency Updates + +- bump usage to 3.5.2 by @jdx in [#10498](https://github.com/jdx/mise/pull/10498) +- update ghcr.io/jdx/mise:rpm docker digest to b5e0574 by @renovate[bot] in [#10531](https://github.com/jdx/mise/pull/10531) +- update ghcr.io/jdx/mise:alpine docker digest to 892c324 by @renovate[bot] in [#10529](https://github.com/jdx/mise/pull/10529) +- update jdx/mise-action digest to e6a8b39 by @renovate[bot] in [#10532](https://github.com/jdx/mise/pull/10532) +- update rust docker digest to c681116 by @renovate[bot] in [#10533](https://github.com/jdx/mise/pull/10533) +- update ubuntu:26.04 docker digest to e153663 by @renovate[bot] in [#10534](https://github.com/jdx/mise/pull/10534) +- update ghcr.io/jdx/mise:deb docker digest to 3d636fa by @renovate[bot] in [#10530](https://github.com/jdx/mise/pull/10530) +- update dependency esbuild to v0.28.1 by @renovate[bot] in [#10535](https://github.com/jdx/mise/pull/10535) +- update ubuntu:26.04 docker digest to 53958ec by @renovate[bot] in [#10539](https://github.com/jdx/mise/pull/10539) + +### 📦 Registry + +- add aspire ([github:microsoft/aspire](https://github.com/microsoft/aspire)) by @davidfowl in [#10520](https://github.com/jdx/mise/pull/10520) +- add nub by @colinhacks in [#10544](https://github.com/jdx/mise/pull/10544) +- add ldc ([github:ldc-developers/ldc](https://github.com/ldc-developers/ldc)) by @slbls in [#10527](https://github.com/jdx/mise/pull/10527) +- relax checkov test by @jdx in [#10548](https://github.com/jdx/mise/pull/10548) + +### New Contributors + +- @spencergilbert made their first contribution in [#10538](https://github.com/jdx/mise/pull/10538) +- @slbls made their first contribution in [#10527](https://github.com/jdx/mise/pull/10527) +- @colinhacks made their first contribution in [#10544](https://github.com/jdx/mise/pull/10544) +- @davidfowl made their first contribution in [#10520](https://github.com/jdx/mise/pull/10520) + +### 📦 Aqua Registry Updates + +#### New Packages (2) + +- [`EpicGames/lore`](https://github.com/EpicGames/lore) +- [`cdxgen/cdxgen`](https://github.com/cdxgen/cdxgen) + ## [2026.6.11](https://github.com/jdx/mise/compare/v2026.6.10..v2026.6.11) - 2026-06-16 ### 🚀 Features diff --git a/Cargo.lock b/Cargo.lock index ce50f0dd20..e5527b1cf5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5271,7 +5271,7 @@ dependencies = [ [[package]] name = "mise" -version = "2026.6.11" +version = "2026.6.12" dependencies = [ "age", "aho-corasick", diff --git a/Cargo.toml b/Cargo.toml index 79f9734fed..86990ad922 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ [package] name = "mise" -version = "2026.6.11" +version = "2026.6.12" edition = "2024" description = "Dev tools, env vars, and tasks in one CLI" authors = ["Jeff Dickey (@jdx)"] diff --git a/README.md b/README.md index e4b58cafdc..f3ac01ee9c 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ $ ~/.local/bin/mise --version / / / / / / (__ ) __/_____/ __/ / / /_____/ /_/ / / /_/ / /__/ __/ /_/ /_/ /_/_/____/\___/ \___/_/ /_/ / .___/_/\__,_/\___/\___/ /_/ by @jdx -2026.6.11 macos-arm64 (2026-06-16) +2026.6.12 macos-arm64 (2026-06-21) ``` Hook mise into your shell (pick the right one for your shell): diff --git a/completions/_mise b/completions/_mise index 22a2457312..6285218d28 100644 --- a/completions/_mise +++ b/completions/_mise @@ -24,7 +24,7 @@ _mise() { return 1 fi - local spec_file="${TMPDIR:-/tmp}/usage__usage_spec_mise_2026_6_11.spec" + local spec_file="${TMPDIR:-/tmp}/usage__usage_spec_mise_2026_6_12.spec" if [[ ! -f "$spec_file" ]]; then mise usage >| "$spec_file" fi diff --git a/completions/mise.bash b/completions/mise.bash index 5d661cd56e..5a3fd43ebc 100644 --- a/completions/mise.bash +++ b/completions/mise.bash @@ -9,7 +9,7 @@ _mise() { local cur prev words cword was_split comp_args _comp_initialize -n : -- "$@" || return - local spec_file="${TMPDIR:-/tmp}/usage__usage_spec_mise_2026_6_11.spec" + local spec_file="${TMPDIR:-/tmp}/usage__usage_spec_mise_2026_6_12.spec" if [[ ! -f "$spec_file" ]]; then mise usage >| "$spec_file" fi diff --git a/completions/mise.fish b/completions/mise.fish index c877f1a592..5cfd7c055f 100644 --- a/completions/mise.fish +++ b/completions/mise.fish @@ -8,7 +8,7 @@ if ! type -p usage &> /dev/null return 1 end set -l tmpdir (if set -q TMPDIR; echo $TMPDIR; else; echo /tmp; end) -set -l spec_file "$tmpdir/usage__usage_spec_mise_2026_6_11.spec" +set -l spec_file "$tmpdir/usage__usage_spec_mise_2026_6_12.spec" if not test -f "$spec_file" mise usage | string collect > "$spec_file" end diff --git a/completions/mise.ps1 b/completions/mise.ps1 index ad5cdfeab3..1d138b2c59 100644 --- a/completions/mise.ps1 +++ b/completions/mise.ps1 @@ -10,7 +10,7 @@ Register-ArgumentCompleter -Native -CommandName 'mise' -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) $tmpDir = if ($env:TEMP) { $env:TEMP } else { [System.IO.Path]::GetTempPath() } - $specFile = Join-Path $tmpDir "usage__usage_spec_mise_2026_6_11.kdl" + $specFile = Join-Path $tmpDir "usage__usage_spec_mise_2026_6_12.kdl" if (-not (Test-Path $specFile)) { mise usage | Out-File -FilePath $specFile -Encoding utf8 diff --git a/default.nix b/default.nix index 9c651ec47d..9527b49444 100644 --- a/default.nix +++ b/default.nix @@ -2,7 +2,7 @@ rustPlatform.buildRustPackage { pname = "mise"; - version = "2026.6.11"; + version = "2026.6.12"; src = lib.cleanSource ./.; diff --git a/docs/.vitepress/stars.data.ts b/docs/.vitepress/stars.data.ts index 7ae72a424a..dc681c0fd7 100644 --- a/docs/.vitepress/stars.data.ts +++ b/docs/.vitepress/stars.data.ts @@ -3,7 +3,7 @@ export default { load() { return { - stars: "29.5k", + stars: "29.8k", }; }, }; diff --git a/docs/bootstrap/systemd.md b/docs/bootstrap/systemd.md index c29f188679..5c8f4ca676 100644 --- a/docs/bootstrap/systemd.md +++ b/docs/bootstrap/systemd.md @@ -57,8 +57,8 @@ and enable without keeping the unit running. - **User services only** — mise writes to `~/.config/systemd/user` and uses `systemctl --user`. System services in `/etc/systemd/system` are not supported. -- **Target user only** — run mise as the user that owns the services, with an - active systemd user bus. `sudo mise` is skipped because `systemctl --user` +- **Target user only** — run mise as the user who owns the services, with a + reachable systemd user manager. `sudo mise` is skipped because `systemctl --user` would target the wrong user manager. - **Manual application only** — mise never writes or starts systemd services implicitly; only `mise bootstrap systemd apply` and `mise bootstrap` do. diff --git a/docs/dev-tools/backends/http.md b/docs/dev-tools/backends/http.md index 097afaea26..5ea8114437 100644 --- a/docs/dev-tools/backends/http.md +++ b/docs/dev-tools/backends/http.md @@ -127,6 +127,72 @@ linux-x64 = { } ``` +### `checksum_url` + +URL of a published checksum source. When set, [`mise lock`](/dev-tools/mise-lock) +resolves checksums for every target platform — including platforms other than +the one you are running on — **without downloading the artifacts**. This lets a +single machine produce a complete, cross-platform lockfile. + +`checksum_url` is a template (supports {{ version }}, {{ os() }}, {{ arch() }} +and is platform-specific via `platforms..checksum_url`). It may point at any +of: + +- an **individual checksum file** (e.g. `.sha256`), which may contain + just the hash or ` `; +- a **SHASUMS**-style file listing ` ` for many platforms (the row + is matched by the artifact's filename); +- a **manifest** (e.g. JSON), combined with `checksum_expr` below. + +For individual and SHASUMS checksum files, the algorithm is detected from the +file's name (`*.sha512`, `SHA512SUMS`, `*.md5`, `*.b3`, defaulting to sha256). + +```toml +# Individual checksum file (one per artifact) +[tools."http:my-tool"] +version = "1.0.0" +url = "https://example.com/releases/my-tool-{{ version }}-{{ os() }}-{{ arch() }}.tar.gz" +checksum_url = "https://example.com/releases/my-tool-{{ version }}-{{ os() }}-{{ arch() }}.tar.gz.sha256" + +# SHASUMS (one file lists every platform) +[tools."http:other-tool"] +version = "1.0.0" +url = 'https://example.com/{{ version }}/other_{{ version }}_{{ os(macos="darwin") }}_{{ arch(x64="amd64") }}.zip' +checksum_url = 'https://example.com/{{ version }}/other_{{ version }}_SHASUMS' +``` + +### `checksum_expr` + +When the checksum lives in a manifest (rather than a plain checksum file), use +`checksum_expr` to extract it. The manifest body fetched from `checksum_url` is +evaluated with [expr-lang](https://expr-lang.org). The following variables are +available: `body` (the raw manifest), `version`, `os`, `arch`, `url` (the +resolved artifact URL for the target), and `filename`. + +The expression must evaluate to a qualified `algo:hash` **string** (e.g. +`sha256:`, `sha512:`). Build the prefix in the expression: prepend a +literal when the algorithm is fixed (`"sha256:" + entry.hash`), or read it from +the manifest when it varies (`entry.algo + ":" + entry.hash`). + +```toml +[tools."http:my-tool"] +version = "1.10.0" +checksum_url = "https://example.com/versions.json" +# Match the file whose url equals the resolved artifact url, return sha256: +checksum_expr = '"sha256:" + filter(fromJSON(body)[version + ""].files, { #.url == url })[0].sha256' + +[tools."http:my-tool".platforms] +linux-x64 = { url = "https://example.com/my-tool-{{ version }}-linux-x86_64.tar.gz" } +macos-arm64 = { url = "https://example.com/my-tool-{{ version }}-macos-arm64.tar.gz" } +``` + +::: tip expr-lang gotchas +The predicate placeholder must be written as `{ #... }` **with a space** after +`{`, because `{#` is the Tera comment delimiter. To index a map by a runtime +value, force evaluation with `[version + ""]` — a bare `[version]` is treated as +the literal key `"version"`. +::: + ### `size` Verify the downloaded file size: diff --git a/e2e/backend/test_http_lock_checksum b/e2e/backend/test_http_lock_checksum new file mode 100644 index 0000000000..5bb29614ec --- /dev/null +++ b/e2e/backend/test_http_lock_checksum @@ -0,0 +1,110 @@ +#!/usr/bin/env bash + +# Cross-platform `mise lock` for the http backend resolves published checksums +# for platforms other than the host, without downloading artifacts. Covers the +# SHASUMS form (matched by filename) and the individual checksum file form, with +# os()/arch() in the url rendered per target platform. + +SRV="$PWD/srv" +mkdir -p "$SRV" + +# sha256 of each artifact's bytes (the string written below). +LINUX_SHA="ae26b73f6db848ed1ccb7c35ec59234cd74ecf9d344f422732058df5be2e6a01" # "tool linux payload" +MACOS_SHA="40a3a5cdad86d4833b06b463da7f294fae29b61298aeb729e4e9dbf5e45ba4af" # "tool macos payload" +FILE_LINUX_SHA="508f35a45c1c4869a5949ec3d96779237619c36b7a6b51f6a83400de9fc5b01e" # "individual linux payload" +FILE_MACOS_SHA="f4fbe0d002f0331efc717160d7ea559c0f1172685d454aac59c12c1379d2cc49" # "individual macos payload" +# sha512 (128 hex chars) of "tool512 linux payload" — algorithm detected from the file name +SHA512_LINUX="78b83c1f3aa14cfa6c5a551a92d90c147b3c029304429d96bc86560e0f08fc9cf69c343c2dc52fec0d7c7bceee894bb5647d4f3e3359816b231dafaee8799363" + +printf '%s' "tool linux payload" >"$SRV/tool_1.0.0_linux.tar.gz" +printf '%s' "tool macos payload" >"$SRV/tool_1.0.0_macos.tar.gz" +printf '%s' "individual linux payload" >"$SRV/file_1.0.0_linux.tar.gz" +printf '%s' "individual macos payload" >"$SRV/file_1.0.0_macos.tar.gz" +printf '%s' "tool512 linux payload" >"$SRV/tool512_1.0.0_linux.tar.gz" + +# SHASUMS file listing both platform artifacts (HashiCorp/OpenShift style) +cat >"$SRV/tool_1.0.0_SHASUMS" <"$SRV/tool512_1.0.0_SHA512SUMS" <"$SRV/file_1.0.0_linux.tar.gz.sha256" +echo "$FILE_MACOS_SHA" >"$SRV/file_1.0.0_macos.tar.gz.sha256" + +# A SHASUMS list whose entries don't match this tool's artifact name (a naming +# mismatch). Locking must NOT fall back to the first hash in the list and +# silently write another artifact's checksum. +BOGUS_SHA="1111111111111111111111111111111111111111111111111111111111111111" +cat >"$SRV/mismatch_1.0.0_SHASUMS" </dev/null || true; } +trap cleanup EXIT + +wait_for_file "$PORT_FILE" "lock http port file" 30 "$SERVER_PID" +PORT=$(cat "$PORT_FILE") + +cat >mise.toml <"$SRV/mytool" +printf '%s mytool\n' "$REAL_SHA" >"$SRV/mytool_SHASUMS" + +# Serve the directory on an ephemeral port +PORT_FILE="$TMPDIR/mise_lock_verify_port" +python3 - "$SRV" "$PORT_FILE" <<'PY' & +import http.server, socketserver, sys, os +srv, port_file = sys.argv[1], sys.argv[2] +os.chdir(srv) +socketserver.TCPServer.allow_reuse_address = True +with socketserver.TCPServer(("127.0.0.1", 0), http.server.SimpleHTTPRequestHandler) as httpd: + with open(port_file, "w") as f: + f.write(str(httpd.server_address[1])) + httpd.serve_forever() +PY +SERVER_PID=$! +cleanup() { kill "$SERVER_PID" 2>/dev/null || true; } +trap cleanup EXIT + +wait_for_file "$PORT_FILE" "lock verify port file" 30 "$SERVER_PID" +PORT=$(cat "$PORT_FILE") + +cat >mise.toml <mise.lock.tmp && mv mise.lock.tmp mise.lock +assert_fail "mise install --locked -f" diff --git a/e2e/backend/test_http_lock_skips_unresolved b/e2e/backend/test_http_lock_skips_unresolved new file mode 100644 index 0000000000..0ff46c0aeb --- /dev/null +++ b/e2e/backend/test_http_lock_skips_unresolved @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +# `mise lock` fails closed for an http platform it can't resolve: a platform with +# no URL is reported as skipped (and not written), rather than being miscounted +# as a successful entry while writing nothing. Mirrors test_github_matching_lock +# for the http backend. + +export MISE_LOCKFILE=1 + +cat <mise.toml +[tools."http:lockskip-test"] +version = "1.0.0" + +[tools."http:lockskip-test".platforms.linux-x64] +url = "https://example.com/lockskip-{{ version }}-linux.tar.gz" +EOF + +touch mise.lock + +# linux-x64 has a URL (a url-only entry is written); macos-arm64 has none. +output="$(mise lock --platform linux-x64,macos-arm64)" + +# The unresolvable platform is reported as skipped, not as a success. +assert_contains "echo '$output'" "1 skipped" + +# The resolvable platform is written; the skipped one is not. +assert_contains "cat mise.lock" "platforms.linux-x64" +assert_not_contains "cat mise.lock" "macos-arm64" diff --git a/e2e/cli/test_bootstrap b/e2e/cli/test_bootstrap index a5b3109be3..b9bba6d994 100644 --- a/e2e/cli/test_bootstrap +++ b/e2e/cli/test_bootstrap @@ -138,8 +138,9 @@ description = "sync files" exec_start = "~/.local/bin/my-sync --watch" restart = "on-failure" EOF -DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/mise-test-bus assert_succeed "mise bootstrap --dry-run" -DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/mise-test-bus assert_contains "mise bootstrap systemd status" "my-sync" +mkdir -p runtime/systemd/private +XDG_RUNTIME_DIR="$PWD/runtime" assert_succeed "mise bootstrap --dry-run" +XDG_RUNTIME_DIR="$PWD/runtime" assert_contains "mise bootstrap systemd status" "my-sync" # unavailable system package managers are skipped, not errors cat <mise.toml diff --git a/e2e/tasks/test_task_show_full_cmd b/e2e/tasks/test_task_show_full_cmd new file mode 100644 index 0000000000..7f5adc7aaa --- /dev/null +++ b/e2e/tasks/test_task_show_full_cmd @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +# #10469: the `task.show_full_cmd` setting (env MISE_TASK_SHOW_CMD_NO_TRUNC) must +# echo the FULL multi-line command in the task header, not just the first real +# command. PR #9844 added display_first_command(), which reduced the echoed header +# to the first real command (skipping leading shebang/blank/`set ...` lines) +# UNCONDITIONALLY — making show_full_cmd a no-op. The reduction is now gated on the +# setting: the whole script when on, the first command when off. + +cat <<'EOF' >mise.toml +[tasks.multi] +run = """ +#!/usr/bin/env bash +set -e +echo first-line +echo second-line +""" +EOF + +# show_full_cmd ON: the echoed header includes the LATER command line. The literal +# "echo second-line" only appears in the echoed command (execution prints +# "second-line", not "echo second-line"). +assert_contains "MISE_TASK_SHOW_CMD_NO_TRUNC=1 mise run multi 2>&1" "echo second-line" + +# show_full_cmd OFF (#9844 behavior preserved): the header shows the first REAL +# command, skipping the shebang/`set` boilerplate, and later lines are truncated. +assert_contains "mise run multi 2>&1" "echo first-line" +assert_not_contains "mise run multi 2>&1" "echo second-line" diff --git a/packaging/rpm/mise.spec b/packaging/rpm/mise.spec index ed02ee4fd4..05cf142e82 100644 --- a/packaging/rpm/mise.spec +++ b/packaging/rpm/mise.spec @@ -1,6 +1,6 @@ Summary: Dev tools, env vars, and tasks in one CLI Name: mise -Version: 2026.6.11 +Version: 2026.6.12 Release: 1 URL: https://github.com/jdx/mise/ Group: System diff --git a/registry/checkov.toml b/registry/checkov.toml index c65f545f3a..f2039e28c1 100644 --- a/registry/checkov.toml +++ b/registry/checkov.toml @@ -1,3 +1,3 @@ backends = ["aqua:bridgecrewio/checkov", "asdf:bosmak/asdf-checkov"] description = "Prevent cloud misconfigurations and find vulnerabilities during build-time in infrastructure as code, container images and open source packages with Checkov by Bridgecrew" -test = { cmd = "checkov -v", expected = "{{version}}" } +test = { cmd = "checkov --help", expected = "usage: checkov" } diff --git a/registry/ldc.toml b/registry/ldc.toml new file mode 100644 index 0000000000..bb59f6b21d --- /dev/null +++ b/registry/ldc.toml @@ -0,0 +1,3 @@ +backends = ["github:ldc-developers/ldc"] +description = "LLVM-based compiler for the D programming language" +test = { cmd = "ldc2 --version", expected = "{{version}}" } diff --git a/snapcraft.yaml b/snapcraft.yaml index 1d56abb5f8..0389c73935 100644 --- a/snapcraft.yaml +++ b/snapcraft.yaml @@ -9,7 +9,7 @@ name: mise title: mise-en-place -version: "2026.6.11" +version: "2026.6.12" summary: Dev tools, env vars, and tasks in one CLI description: | mise-en-place prepares your development environment before each command runs. diff --git a/src/backend/asset_matcher.rs b/src/backend/asset_matcher.rs index e1f21b9fc3..9ba217de59 100644 --- a/src/backend/asset_matcher.rs +++ b/src/backend/asset_matcher.rs @@ -1069,7 +1069,7 @@ impl<'a> ChecksumFetcher<'a> { } /// Detect the checksum algorithm from the filename -fn detect_checksum_algorithm(filename: &str) -> String { +pub(crate) fn detect_checksum_algorithm(filename: &str) -> String { let lower = filename.to_lowercase(); if lower.contains("sha512") || lower.ends_with(".sha512") || lower.ends_with(".sha512sum") { "sha512".to_string() diff --git a/src/backend/cargo.rs b/src/backend/cargo.rs index c177d3119f..a70a7303e3 100644 --- a/src/backend/cargo.rs +++ b/src/backend/cargo.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::path::PathBuf; use std::{fmt::Debug, sync::Arc}; use async_trait::async_trait; @@ -16,10 +17,9 @@ use crate::cli::args::BackendArg; use crate::cmd::CmdLineRunner; use crate::config::{Config, Settings}; use crate::env::GITHUB_TOKEN; -use crate::file; use crate::http::HTTP_FETCH; use crate::install_context::InstallContext; -use crate::toolset::{ToolRequest, ToolVersion, ToolVersionOptions}; +use crate::toolset::{ToolRequest, ToolVersion, ToolVersionOptions, Toolset}; #[derive(Debug)] pub struct CargoBackend { @@ -84,7 +84,7 @@ impl<'a> CargoOptions<'a> { #[derive(Debug)] enum BinstallStatus { - Enabled, + Enabled(PathBuf), Disabled, Unavailable, UnsupportedOptions(Vec<&'static str>), @@ -183,9 +183,9 @@ impl Backend for CargoBackend { } cmd } else { - match self.binstall_status(&config, &tv).await { - BinstallStatus::Enabled => { - let mut cmd = CmdLineRunner::new("cargo-binstall").arg("-y"); + match self.binstall_status(&config, Some(&ctx.ts), &tv).await { + BinstallStatus::Enabled(cargo_binstall) => { + let mut cmd = CmdLineRunner::new(cargo_binstall).arg("-y"); if let Some(token) = &*GITHUB_TOKEN { cmd = cmd.env("GITHUB_TOKEN", token) } @@ -298,7 +298,12 @@ impl CargoBackend { options } - async fn binstall_status(&self, config: &Arc, tv: &ToolVersion) -> BinstallStatus { + async fn binstall_status( + &self, + config: &Arc, + ts: Option<&Toolset>, + tv: &ToolVersion, + ) -> BinstallStatus { if !Settings::get().cargo.binstall { return BinstallStatus::Disabled; } @@ -307,19 +312,13 @@ impl CargoBackend { if !cargo_install_required_options.is_empty() { return BinstallStatus::UnsupportedOptions(cargo_install_required_options); } - if file::which_non_pristine("cargo-binstall").is_none() { - match self.dependency_toolset(config).await { - Ok(ts) => { - if ts.which(config, "cargo-binstall").await.is_none() { - return BinstallStatus::Unavailable; - } - } - Err(_e) => { - return BinstallStatus::Unavailable; - } - } + if let Some(cargo_binstall) = self + .dependency_path_for_install(config, ts, "cargo-binstall") + .await + { + return BinstallStatus::Enabled(cargo_binstall); } - BinstallStatus::Enabled + BinstallStatus::Unavailable } /// if the name is a git repo, return the git url diff --git a/src/backend/http.rs b/src/backend/http.rs index 6332011478..146303b052 100644 --- a/src/backend/http.rs +++ b/src/backend/http.rs @@ -2,10 +2,12 @@ use crate::backend::Backend; use crate::backend::VersionInfo; use crate::backend::backend_type::BackendType; use crate::backend::options::BackendOptions; +use crate::backend::platform_target::PlatformTarget; use crate::backend::runtime_path_for_install_path; use crate::backend::static_helpers::{ - clean_binary_name, get_filename_from_url, rename_executable_in_dir, template_string, - verify_artifact, + clean_binary_name, eval_checksum_expr, fetch_checksum_from_file, fetch_checksum_from_shasums, + get_filename_from_url, rename_executable_in_dir, shasums_has_entries, template_string, + template_string_for_target, verify_artifact, }; use crate::backend::version_list; use crate::cli::args::BackendArg; @@ -13,6 +15,7 @@ use crate::config::Config; use crate::config::Settings; use crate::http::HTTP; use crate::install_context::InstallContext; +use crate::lockfile::PlatformInfo; use crate::runtime_symlinks::is_runtime_symlink; use crate::toolset::ToolRequest; use crate::toolset::ToolVersion; @@ -22,6 +25,7 @@ use crate::{dirs, file, hash}; use async_trait::async_trait; use eyre::Result; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::fmt::Debug; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -173,6 +177,38 @@ impl<'a> HttpOptions<'a> { self.values.platform_string("bin_path") } + fn checksum_expr(&self) -> Option<&'a str> { + self.values.str("checksum_expr") + } + + // Target-aware accessors for cross-platform `mise lock`. These resolve + // `platforms..` for an arbitrary target rather than the host. + fn url_for_target(&self, target: &PlatformTarget) -> Option { + self.values.platform_string_for_target("url", target) + } + + fn checksum_for_target(&self, target: &PlatformTarget) -> Option { + self.values.platform_string_for_target("checksum", target) + } + + fn checksum_url_for_target(&self, target: &PlatformTarget) -> Option { + self.values + .platform_string_for_target("checksum_url", target) + } + + fn format_for_target(&self, target: &PlatformTarget) -> Option { + self.values.platform_string_for_target("format", target) + } + + fn strip_components_for_target(&self, target: &PlatformTarget) -> Option { + self.values + .platform_string_for_target("strip_components", target) + } + + fn rename_exe_for_target(&self, target: &PlatformTarget) -> Option { + self.values.platform_string_for_target("rename_exe", target) + } + fn version_list_url(&self) -> Option<&'a str> { self.values.str("version_list_url") } @@ -723,6 +759,88 @@ impl HttpBackend { version_list::fetch_versions(&url, regex, json_path, version_expr).await } + + // ------------------------------------------------------------------------- + // Cross-platform lock resolution + // ------------------------------------------------------------------------- + + /// Resolve the artifact URL for a target platform during `mise lock`. + /// Renders `os()`/`arch()` for the target rather than the host. + fn lock_url_for_target( + &self, + opts: &HttpOptions<'_>, + tv: &ToolVersion, + target: &PlatformTarget, + ) -> Option { + opts.url_for_target(target) + .map(|template| template_string_for_target(&template, tv, target)) + } + + /// Resolve a published checksum for a target platform without downloading + /// the artifact. Tries, in order: a checksum configured directly for the + /// platform, a manifest evaluated via `checksum_expr`, a SHASUMS file keyed + /// by filename, then an individual checksum file. Returns `None` + /// (best-effort) when no published checksum is available. + async fn resolve_lock_checksum( + &self, + opts: &HttpOptions<'_>, + tv: &ToolVersion, + target: &PlatformTarget, + url: &str, + ) -> Option { + // 1. Checksum declared directly for this platform. + if let Some(checksum) = opts.checksum_for_target(target) { + return Some(checksum); + } + + // 2. Fetch from a declared checksum source. + let checksum_url_template = opts.checksum_url_for_target(target)?; + let checksum_url = template_string_for_target(&checksum_url_template, tv, target); + let filename = get_filename_from_url(url); + + // 2a. Manifest with an extraction expression. The expression returns an + // `algo:hash` string. The manifest is the same across platforms, so use + // the cached fetch. + if let Some(expr) = opts.checksum_expr() { + let body = match HTTP.get_text_cached(&checksum_url).await { + Ok(body) => body, + Err(e) => { + debug!("failed to fetch checksum manifest {checksum_url}: {e}"); + return None; + } + }; + let vars = [ + ("version", tv.version.as_str()), + ("os", target.os_name()), + ("arch", target.arch_name()), + ("url", url), + ("filename", filename.as_str()), + ]; + return eval_checksum_expr(expr, &body, &vars); + } + + // 2b. Checksum file: a SHASUMS list (filename match) first, then an + // individual checksum file. The algorithm is detected from its name. + if let Some(checksum) = fetch_checksum_from_shasums(&checksum_url, &filename).await { + return Some(checksum); + } + // A SHASUMS list that has entries but none matching our artifact is a + // naming mismatch, not an individual checksum file. Falling back to the + // individual-file scan would return the first hash in the list — another + // platform's checksum — and silently lock it. Bail so the platform is + // reported unresolved instead. + if shasums_has_entries(&checksum_url).await { + debug!( + "checksum_url {checksum_url} is a SHASUMS list with no entry for {filename}; \ + not falling back to a first-hash scan" + ); + return None; + } + let file_algo = crate::backend::asset_matcher::detect_checksum_algorithm( + &get_filename_from_url(&checksum_url), + ); + fetch_checksum_from_file(&checksum_url, &file_algo).await + } } /// Returns install-time-only option keys for HTTP backend. @@ -736,6 +854,8 @@ pub fn install_time_option_keys() -> Vec { "version_expr".into(), "format".into(), "rename_exe".into(), + "checksum_url".into(), + "checksum_expr".into(), ] } @@ -768,6 +888,73 @@ impl Backend for HttpBackend { super::http_install_operation_count(opts.checksum().is_some(), &self.get_platform_key(), tv) } + /// Options that affect which artifact is downloaded, resolved for the target + /// platform so cross-platform lockfile entries match install-time lookups. + fn resolve_lockfile_options( + &self, + request: &ToolRequest, + target: &PlatformTarget, + ) -> Result> { + let raw_opts = request.options(); + let opts = HttpOptions::new(&raw_opts); + let mut result = BTreeMap::new(); + if let Some(format) = opts.format_for_target(target) { + result.insert("format".to_string(), format); + } + if let Some(strip) = opts.strip_components_for_target(target) { + result.insert("strip_components".to_string(), strip); + } + if let Some(rename) = opts.rename_exe_for_target(target) { + result.insert("rename_exe".to_string(), rename); + } + Ok(result) + } + + /// Resolve URL + published checksum for a target platform during `mise lock`, + /// without downloading the artifact. Best-effort: a platform with no + /// resolvable URL fails closed (`Err`) so the lock run reports it as skipped + /// rather than writing nothing under a success count; a missing checksum + /// yields a url-only entry. + async fn resolve_lock_info( + &self, + tv: &ToolVersion, + target: &PlatformTarget, + ) -> Result { + let raw_opts = tv.request.options(); + let opts = HttpOptions::new(&raw_opts); + + // Fail closed when the platform can't be resolved so the lock + // orchestration reports it as skipped, rather than returning an empty + // entry that is miscounted as a successful platform (see #7113). + let Some(url) = self.lock_url_for_target(&opts, tv, target) else { + return Err(eyre::eyre!( + "no URL configured for {} on {}; skipping", + self.ba.full(), + target.to_key() + )); + }; + + let checksum = self.resolve_lock_checksum(&opts, tv, target, &url).await; + + // A checksum source was configured but produced nothing for this target + // (manifest miss, SHASUMS naming mismatch, unreachable file, ...). The + // url-only entry is still written, but surface it so it isn't a silent + // drop of checksum verification. + if checksum.is_none() && opts.checksum_url_for_target(target).is_some() { + warn!( + "could not resolve a checksum for {} on {}; locking the URL without checksum verification", + self.ba.full(), + target.to_key() + ); + } + + Ok(PlatformInfo { + url: Some(url), + checksum, + ..Default::default() + }) + } + async fn _list_remote_versions(&self, config: &Arc) -> Result> { let versions = self.fetch_versions(config).await?; Ok(versions @@ -975,6 +1162,23 @@ mod tests { crate::hash::hash_sha256_to_str(version)[..7].to_string() } + #[test] + fn template_string_for_target_renders_target_os_arch() { + let tv = http_test_tv("0.40.0"); + let template = + r#"sentinel_{{ version }}_{{ os(macos="darwin") }}_{{ arch(x64="amd64") }}.zip"#; + let linux = PlatformTarget::new(crate::platform::Platform::parse("linux-x64").unwrap()); + assert_eq!( + template_string_for_target(template, &tv, &linux), + "sentinel_0.40.0_linux_amd64.zip" + ); + let win = PlatformTarget::new(crate::platform::Platform::parse("windows-x64").unwrap()); + assert_eq!( + template_string_for_target(template, &tv, &win), + "sentinel_0.40.0_windows_amd64.zip" + ); + } + #[test] fn install_symlink_path_uses_sanitized_version_pathname() { let version = "/outside-root/mise-http-version-out/selected-prefix"; diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 22c7711f76..373576388f 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -520,6 +520,48 @@ pub(crate) fn normalize_idiomatic_contents(contents: &str) -> String { .join("\n") } +fn executable_names(bin: &str) -> Vec { + let mut names = vec![bin.to_string()]; + if cfg!(target_os = "windows") && Path::new(bin).extension().is_none() { + for ext in &Settings::get().windows_executable_extensions { + let name = if ext.is_empty() { + bin.to_string() + } else { + format!("{bin}.{ext}") + }; + if !names.contains(&name) { + names.push(name); + } + } + } + names +} + +fn which_non_pristine_executable(bin: &str) -> Option { + executable_names(bin) + .into_iter() + .find_map(file::which_non_pristine) +} + +pub(crate) async fn configured_toolset_or_path_which( + config: &Arc, + tools: impl IntoIterator, + bin: &str, +) -> Result> { + let filtered = config + .get_tool_request_set() + .await? + .filter_by_tool(tools.into_iter().collect::>()); + if !filtered.tools.is_empty() { + let mut ts = filtered.into_toolset(); + Box::pin(ts.resolve(config)).await?; + if let Some(bin) = ts.which_bin(config, bin).await { + return Ok(Some(bin)); + } + } + Ok(which_non_pristine_executable(bin)) +} + #[cfg(test)] mod tests { use super::*; @@ -2371,18 +2413,10 @@ pub trait Backend: Debug + Send + Sync { .into_iter() .filter(|p| p.parent().is_some()); for bin_path in bin_paths { - let paths_with_ext = if cfg!(windows) { - vec![ - bin_path.clone(), - bin_path.join(bin_name).with_extension("exe"), - bin_path.join(bin_name).with_extension("cmd"), - bin_path.join(bin_name).with_extension("bat"), - bin_path.join(bin_name).with_extension("ps1"), - ] - } else { - vec![bin_path.join(bin_name)] - }; - for bin_path in paths_with_ext { + for bin_path in executable_names(bin_name) + .into_iter() + .map(|bin| bin_path.join(bin)) + { if bin_path.exists() && file::is_executable(&bin_path) { return Ok(Some(bin_path)); } @@ -2519,7 +2553,7 @@ pub trait Backend: Debug + Send + Sync { } async fn dependency_which(&self, config: &Arc, bin: &str) -> Option { - if let Some(bin) = file::which_non_pristine(bin) { + if let Some(bin) = which_non_pristine_executable(bin) { return Some(bin); } let Ok(ts) = self.dependency_toolset(config).await else { @@ -2529,6 +2563,20 @@ pub trait Backend: Debug + Send + Sync { b.which(config, &tv, bin).await.ok().flatten() } + async fn dependency_path_for_install( + &self, + config: &Arc, + ts: Option<&Toolset>, + bin: &str, + ) -> Option { + if let Some(ts) = ts + && let Some(bin) = ts.which_bin(config, bin).await + { + return Some(bin); + } + self.dependency_which(config, bin).await + } + /// Check if a required dependency is available and show a warning if not. /// `provided_by` lists tool names that are known to provide the `program` binary /// (e.g., "npm" is provided by &["node"]). If any of these tools are configured @@ -2541,26 +2589,7 @@ pub trait Backend: Debug + Send + Sync { provided_by: &[&str], install_instructions: &str, ) { - let found = if self.dependency_which(config, program).await.is_some() { - true - } else if cfg!(windows) { - // On Windows, also check for program with Windows executable extensions - let settings = Settings::get(); - let mut found = false; - for ext in &settings.windows_executable_extensions { - if self - .dependency_which(config, &format!("{}.{}", program, ext)) - .await - .is_some() - { - found = true; - break; - } - } - found - } else { - false - }; + let found = self.dependency_which(config, program).await.is_some(); if !found { // Check if a tool that provides this program is configured in the toolset diff --git a/src/backend/npm.rs b/src/backend/npm.rs index 79e1d2eb08..dfdffa170e 100644 --- a/src/backend/npm.rs +++ b/src/backend/npm.rs @@ -331,7 +331,7 @@ impl Backend for NPMBackend { NpmPackageManager::Auto => unreachable!("auto package manager should be resolved"), NpmPackageManager::Aube => { let aube_program = self - .aube_path_for_install(&ctx.config, Some(&ctx.ts)) + .dependency_path_for_install(&ctx.config, Some(&ctx.ts), AUBE_PROGRAM) .await .unwrap_or_else(|| AUBE_PROGRAM.into()); self.write_aube_npmrc(&tv.install_path(), ctx.before_date)?; @@ -729,20 +729,9 @@ impl NPMBackend { } async fn aube_is_installed(&self, config: &Arc, ts: Option<&Toolset>) -> bool { - self.aube_path_for_install(config, ts).await.is_some() - } - - async fn aube_path_for_install( - &self, - config: &Arc, - ts: Option<&Toolset>, - ) -> Option { - if let Some(ts) = ts - && let Some(bin) = ts.which_bin(config, AUBE_PROGRAM).await - { - return Some(bin); - } - self.dependency_which(config, AUBE_PROGRAM).await + self.dependency_path_for_install(config, ts, AUBE_PROGRAM) + .await + .is_some() } fn write_aube_npmrc(&self, install_path: &Path, before_date: Option) -> Result<()> { diff --git a/src/backend/pipx.rs b/src/backend/pipx.rs index 9d76f6645e..c120123f0c 100644 --- a/src/backend/pipx.rs +++ b/src/backend/pipx.rs @@ -29,9 +29,7 @@ use serde::Deserialize; use serde_json::Value; use std::collections::BTreeMap; use std::ffi::OsString; -use std::path::Path; -#[cfg(unix)] -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::str::FromStr; use std::{fmt::Debug, sync::Arc}; use versions::Versioning; @@ -256,11 +254,14 @@ impl Backend for PIPXBackend { let options = PipxOptions::new(&request_options); // Check if pipx is available (unless uvx is being used) - let use_uvx = self.uv_is_installed(&ctx.config).await - && Settings::get().pipx.uvx != Some(false) - && !options.uvx_disabled(); + let uv_program = if Settings::get().pipx.uvx != Some(false) && !options.uvx_disabled() { + self.dependency_path_for_install(&ctx.config, Some(&ctx.ts), "uv") + .await + } else { + None + }; - if !use_uvx { + if uv_program.is_none() { self.warn_if_dependency_missing( &ctx.config, "pipx", @@ -278,11 +279,12 @@ impl Backend for PIPXBackend { .parse::()? .pipx_request(&tv.version, &options); - if use_uvx { + if let Some(uv_program) = uv_program { self.warn_if_uv_may_not_support_exclude_newer(ctx).await; ctx.pr .set_message(format!("uv tool install {pipx_request}")); let mut cmd = Self::uvx_cmd( + &uv_program, &ctx.config, &["tool", "install", &pipx_request], self, @@ -515,6 +517,7 @@ impl PIPXBackend { } async fn uvx_cmd<'a>( + uv_program: &Path, config: &Arc, args: &[&str], b: &dyn Backend, @@ -522,7 +525,7 @@ impl PIPXBackend { ts: &Toolset, pr: &'a dyn SingleReport, ) -> Result> { - let mut cmd = CmdLineRunner::new("uv"); + let mut cmd = CmdLineRunner::new(uv_program); for arg in args { cmd = cmd.arg(arg); } @@ -563,10 +566,6 @@ impl PIPXBackend { .prepend_path(b.dependency_toolset(config).await?.list_paths(config).await) } - async fn uv_is_installed(&self, config: &Arc) -> bool { - self.dependency_which(config, "uv").await.is_some() - } - async fn warn_if_uv_may_not_support_exclude_newer(&self, ctx: &InstallContext) { if ctx.before_date.is_none() { return; diff --git a/src/backend/static_helpers.rs b/src/backend/static_helpers.rs index 758c9cc723..20110abe48 100644 --- a/src/backend/static_helpers.rs +++ b/src/backend/static_helpers.rs @@ -23,18 +23,25 @@ static VERSION_PATTERN: LazyLock = /// Fetches a checksum for a specific file from a SHASUMS256.txt-style file. /// Uses cached HTTP requests since the same SHASUMS file is fetched for all platforms. /// +/// The algorithm is detected from the SHASUMS file name (e.g. `*.sha512`, +/// `SHA512SUMS`, defaulting to sha256), since the file lists bare hashes without +/// declaring it. +/// /// # Arguments /// * `shasums_url` - URL to the SHASUMS256.txt file /// * `filename` - The filename to look up in the SHASUMS file /// /// # Returns -/// * `Some("sha256:")` if found +/// * `Some(":")` if found /// * `None` if the SHASUMS file couldn't be fetched or filename not found pub async fn fetch_checksum_from_shasums(shasums_url: &str, filename: &str) -> Option { match HTTP.get_text_cached(shasums_url).await { Ok(shasums_content) => { let shasums = hash::parse_shasums(&shasums_content); - shasums.get(filename).map(|h| format!("sha256:{h}")) + let algo = crate::backend::asset_matcher::detect_checksum_algorithm( + &get_filename_from_url(shasums_url), + ); + shasums.get(filename).map(|h| format!("{algo}:{h}")) } Err(e) => { debug!("Failed to fetch SHASUMS from {}: {e}", shasums_url); @@ -43,6 +50,21 @@ pub async fn fetch_checksum_from_shasums(shasums_url: &str, filename: &str) -> O } } +/// Returns `true` if the file at `shasums_url` parses as a SHASUMS-style list +/// with at least one ` ` entry (as opposed to a bare individual +/// checksum file that has only a hash). +/// +/// Used to decide whether a [`fetch_checksum_from_shasums`] miss means "this is +/// an individual checksum file, scan it for the hash" or "this is a SHASUMS list +/// that simply has no row for our artifact" — in which case falling back to a +/// first-hash scan would silently pick another platform's checksum. +pub async fn shasums_has_entries(shasums_url: &str) -> bool { + match HTTP.get_text_cached(shasums_url).await { + Ok(content) => !hash::parse_shasums(&content).is_empty(), + Err(_) => false, + } +} + /// Fetches a checksum from an individual checksum file (e.g., file.tar.gz.sha256). /// The checksum file should contain just the hash, optionally followed by filename. /// @@ -53,8 +75,12 @@ pub async fn fetch_checksum_from_shasums(shasums_url: &str, filename: &str) -> O /// # Returns /// * `Some(":")` if found /// * `None` if the checksum file couldn't be fetched +/// +/// Uses the in-process cache so that resolving an individual checksum file +/// doesn't re-fetch the same URL already probed by [`fetch_checksum_from_shasums`] +/// / [`shasums_has_entries`] for that platform. pub async fn fetch_checksum_from_file(checksum_url: &str, algo: &str) -> Option { - match HTTP.get_text(checksum_url).await { + match HTTP.get_text_cached(checksum_url).await { Ok(content) => parse_checksum_file_content(&content, algo), Err(e) => { debug!("Failed to fetch checksum from {}: {e}", checksum_url); @@ -84,6 +110,64 @@ fn parse_checksum_file_content(content: &str, algo: &str) -> Option { .map(|hash| format!("{algo}:{}", hash.to_lowercase())) } +/// Evaluate a checksum expression (expr-lang) against a manifest body to extract +/// a single checksum string for a target platform. +/// +/// The raw manifest is injected as a `body` string; `vars` supplies additional +/// context such as `version`, `os`, `arch`, `url`, and `filename` so the +/// expression can select the right entry. The expression must evaluate to an +/// `algo:hash` string. Manifests that store the hash and algorithm separately +/// build the prefix in the expression itself, e.g. `entry.algo + ":" + entry.hash` +/// (or a literal `"sha256:" + entry.hash` when the algorithm is fixed). +/// +/// The result is normalized to `algo:hash`. Returns `None` when evaluation fails +/// or the result is not a usable `algo:hash`. +pub fn eval_checksum_expr(expr_str: &str, body: &str, vars: &[(&str, &str)]) -> Option { + use expr::{Context, Environment, Value}; + + let mut ctx = Context::default(); + ctx.insert("body".to_string(), Value::String(body.to_string())); + for (key, value) in vars { + ctx.insert((*key).to_string(), Value::String((*value).to_string())); + } + + let env = Environment::new(); + match env.eval(expr_str, &ctx) { + Ok(Value::String(s)) => normalize_checksum(&s), + Ok(other) => { + debug!("checksum_expr did not evaluate to a string: {other:?}"); + None + } + Err(e) => { + debug!("failed to evaluate checksum_expr '{expr_str}': {e}"); + None + } + } +} + +/// Normalize an `algo:hash` checksum string. The algorithm name is +/// case-insensitive (e.g. `SHA256` from a manifest). Returns `None` when the +/// value has no `algo:` prefix or the hash isn't valid hex for that algorithm. +/// +/// mise stores and verifies checksums as `algo:hash` everywhere, so a bare hash +/// is rejected rather than guessed — the expression must qualify it (e.g. +/// `"sha256:" + entry.hash`). +fn normalize_checksum(raw: &str) -> Option { + let raw = raw.trim(); + let Some((algo, hash)) = raw.split_once(':') else { + debug!("checksum_expr result is not in algo:hash form: {raw}"); + return None; + }; + let algo = algo.trim().to_ascii_lowercase(); + let hash = hash.trim(); + if is_checksum_hex(hash, &algo) { + Some(format!("{algo}:{}", hash.to_lowercase())) + } else { + debug!("checksum value is not valid {algo} hex: {hash}"); + None + } +} + fn is_checksum_hex(s: &str, algo: &str) -> bool { let expected_len = match algo { "sha1" => 40, @@ -361,6 +445,26 @@ pub fn list_available_platforms_with_key(opts: &ToolVersionOptions, key_type: &s } pub fn template_string(template: &str, tv: &ToolVersion) -> String { + // `os()`/`arch()` resolve to the current host platform. + render_template(template, &tv.version, crate::tera::get_tera(None)) +} + +/// Like [`template_string`] but renders `os()`/`arch()` for an explicit target +/// platform instead of the current host. Used by cross-platform `mise lock` to +/// build URLs and checksum-file URLs for platforms other than the one mise runs on. +pub fn template_string_for_target( + template: &str, + tv: &ToolVersion, + target: &PlatformTarget, +) -> String { + render_template( + template, + &tv.version, + crate::tera::get_tera_for_target(None, target.os_name(), target.arch_name()), + ) +} + +fn render_template(template: &str, version: &str, mut tera: tera::Tera) -> String { // Check for legacy {version} syntax and emit deprecation warning if template.contains("{version}") && !template.contains("{{version}}") { deprecated_at!( @@ -370,7 +474,7 @@ pub fn template_string(template: &str, tv: &ToolVersion) -> String { "Use {{{{ version }}}} instead of {{version}} in URL templates" ); // Legacy support: replace {version} placeholder - return template.replace("{version}", &tv.version); + return template.replace("{version}", version); } if !crate::tera::contains_template_syntax(template) { @@ -380,9 +484,8 @@ pub fn template_string(template: &str, tv: &ToolVersion) -> String { // Use Tera rendering for templates // Supports {{ version }}, {{ os() }}, {{ arch() }}, etc. let mut ctx = crate::tera::BASE_CONTEXT.clone(); - ctx.insert("version", &tv.version); + ctx.insert("version", version); - let mut tera = crate::tera::get_tera(None); match crate::tera::render_str(&mut tera, template, &ctx) { Ok(rendered) => rendered, Err(e) => { @@ -1023,6 +1126,7 @@ mod tests { const SHA256_LOWER: &str = "7fdd1f42e6b0855421ecf27bb406e2492ade1087c85e30ebf0deab6280ea743c"; const SHA256_UPPER: &str = "7FDD1F42E6B0855421ECF27BB406E2492ADE1087C85E30EBF0DEAB6280EA743C"; + const SHA512_LOWER: &str = "78b83c1f3aa14cfa6c5a551a92d90c147b3c029304429d96bc86560e0f08fc9cf69c343c2dc52fec0d7c7bceee894bb5647d4f3e3359816b231dafaee8799363"; #[test] fn test_parse_checksum_file_content_standard_format() { @@ -1062,6 +1166,108 @@ Path : C:\\a\\deno\\deno\\target\\release\\deno-x86_64-pc-windows-msvc.zip assert_eq!(parse_checksum_file_content(SHA256_LOWER, "sha3-256"), None); } + #[test] + fn test_normalize_checksum_rejects_bare_hash() { + // A bare hash with no `algo:` prefix is rejected; the expression must + // qualify it (e.g. `"sha256:" + hash`). + assert_eq!(normalize_checksum(SHA256_LOWER), None); + } + + #[test] + fn test_normalize_checksum_prefixed_hash_keeps_algo() { + assert_eq!( + normalize_checksum(&format!("sha256:{SHA256_UPPER}")), + Some(format!("sha256:{SHA256_LOWER}")) + ); + } + + #[test] + fn test_normalize_checksum_rejects_non_hex() { + assert_eq!(normalize_checksum("sha256:not-a-hash"), None); + } + + #[test] + fn test_normalize_checksum_accepts_uppercase_algo() { + // Uppercase algorithm name in the prefix is normalized, not rejected. + assert_eq!( + normalize_checksum(&format!("SHA256:{SHA256_LOWER}")), + Some(format!("sha256:{SHA256_LOWER}")) + ); + } + + #[test] + fn test_eval_checksum_expr_selects_entry_by_target() { + // A julia-style manifest: pick the file matching os/arch and read sha256. + let body = format!( + r#"{{"files":[ + {{"os":"linux","arch":"x64","sha256":"{SHA256_LOWER}"}}, + {{"os":"macos","arch":"arm64","sha256":"{SHA256_UPPER}"}} + ]}}"# + ); + let expr = r#""sha256:" + filter(fromJSON(body).files, {#.os == os and #.arch == arch})[0].sha256"#; + let vars = [("os", "macos"), ("arch", "arm64")]; + assert_eq!( + eval_checksum_expr(expr, &body, &vars), + Some(format!("sha256:{SHA256_LOWER}")) + ); + } + + #[test] + fn test_eval_checksum_expr_julia_shaped_version_keyed_manifest() { + // julia versions.json shape: top-level keyed by version, files[] with url+sha256. + let body = format!( + r#"{{"1.10.0":{{"files":[ + {{"url":"https://x/julia-1.10.0-linux-x86_64.tar.gz","sha256":"{SHA256_LOWER}"}}, + {{"url":"https://x/julia-1.10.0-macaarch64.tar.gz","sha256":"{SHA256_UPPER}"}} + ]}}}}"# + ); + // expr-lang treats a bare identifier in `[]` as a literal key, so a + // runtime version must be forced to evaluate via `version + ""`. + let expr = + r#""sha256:" + filter(fromJSON(body)[version + ""].files, { #.url == url })[0].sha256"#; + let vars = [ + ("version", "1.10.0"), + ("url", "https://x/julia-1.10.0-linux-x86_64.tar.gz"), + ]; + assert_eq!( + eval_checksum_expr(expr, &body, &vars), + Some(format!("sha256:{SHA256_LOWER}")) + ); + } + + #[test] + fn test_eval_checksum_expr_returns_none_on_no_match() { + let body = r#"{"files":[]}"#; + let expr = r#"len(fromJSON(body).files) > 0 ? fromJSON(body).files[0].sha256 : """#; + let vars: [(&str, &str); 0] = []; + assert_eq!(eval_checksum_expr(expr, body, &vars), None); + } + + #[test] + fn test_eval_checksum_expr_rejects_bare_hash_result() { + // A bare hash (no algo: prefix) is rejected rather than assumed sha256. + let body = format!(r#"{{"files":[{{"os":"linux","sha256":"{SHA256_LOWER}"}}]}}"#); + let expr = r#"filter(fromJSON(body).files, { #.os == os })[0].sha256"#; + let vars = [("os", "linux")]; + assert_eq!(eval_checksum_expr(expr, &body, &vars), None); + } + + #[test] + fn test_eval_checksum_expr_honors_explicit_algo_prefix() { + // When the algorithm varies, the expression builds the `algo:hash` + // string itself; the prefix is used as-is rather than the default. + let body = format!( + r#"{{"files":[{{"os":"linux","algo":"sha512","checksum":"{SHA512_LOWER}"}}]}}"# + ); + let expr = + r#"let f = filter(fromJSON(body).files, { #.os == os })[0]; f.algo + ":" + f.checksum"#; + let vars = [("os", "linux")]; + assert_eq!( + eval_checksum_expr(expr, &body, &vars), + Some(format!("sha512:{SHA512_LOWER}")) + ); + } + #[test] fn test_clean_binary_name() { // Test basic OS/arch removal diff --git a/src/config/config_file/mise_toml.rs b/src/config/config_file/mise_toml.rs index 84df8fc241..5f23e1437a 100644 --- a/src/config/config_file/mise_toml.rs +++ b/src/config/config_file/mise_toml.rs @@ -706,6 +706,27 @@ impl MiseToml { })?; Ok(output) } + + /// Render a tool-option template at config-load time, resolving env/vars but + /// deferring `os()`/`arch()` (re-emitted as `{{ os() }}`/`{{ arch() }}`) so + /// backends can render them for the host at install time or for an arbitrary + /// target during cross-platform `mise lock`. + fn parse_tool_option_template( + &self, + context: &TeraContext, + input: &str, + ) -> eyre::Result { + if !contains_template_syntax(input) { + return Ok(input.to_string()); + } + let dir = self.path.parent(); + let mut tera = crate::tera::get_tera_preserving_os_arch(dir); + let output = render_str(&mut tera, input, context).wrap_err_with(|| { + let p = display_path(&self.path); + eyre!("failed to parse template {input} in {p}") + })?; + Ok(output) + } } impl ConfigFile for MiseToml { @@ -929,9 +950,26 @@ impl ConfigFile for MiseToml { // This preserves {{ version }} in the output for install-time rendering let mut opts_context = context.clone(); opts_context.insert("version", "{{ version }}"); - for v in options.opts.values_mut() { + // The http backend re-renders its url/checksum_url per target + // platform (host at install, any target during `mise lock`), so + // only those two options defer os()/arch() instead of resolving + // them now. Every other option (here and for other backends) is + // consumed verbatim, so it keeps host resolution at config load — + // deferring it would leak raw `{{ os() }}` fragments into + // consumers that never render again (e.g. checksum_expr). + let defer_os_arch = matches!( + ba.backend_type(), + crate::backend::backend_type::BackendType::Http + ); + for (k, v) in options.opts.iter_mut() { if let toml::Value::String(s) = v { - *s = self.parse_template_with_context(&opts_context, s)?; + let defer = + defer_os_arch && matches!(k.as_str(), "url" | "checksum_url"); + *s = if defer { + self.parse_tool_option_template(&opts_context, s)? + } else { + self.parse_template_with_context(&opts_context, s)? + }; } } let mut ba = ba.clone(); diff --git a/src/hash.rs b/src/hash.rs index 4c288a104f..f81bce4b8e 100644 --- a/src/hash.rs +++ b/src/hash.rs @@ -139,11 +139,13 @@ pub fn ensure_checksum( pub fn parse_shasums(text: &str) -> HashMap { text.lines() - .map(|l| { + .filter_map(|l| { let mut parts = l.split_whitespace(); - let hash = parts.next().unwrap(); - let name = parts.next().unwrap(); - (name.into(), hash.into()) + let hash = parts.next()?; + let name = parts.next()?; + // Strip coreutils binary-mode marker (e.g. " *file.tar.gz"). + let name = name.strip_prefix('*').unwrap_or(name); + Some((name.into(), hash.into())) }) .collect() } diff --git a/src/sops.rs b/src/sops.rs index 12fe35448b..c6bb0bbd15 100644 --- a/src/sops.rs +++ b/src/sops.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use crate::backend::configured_toolset_or_path_which; use crate::config::{Config, Settings}; use crate::env; use crate::file::replace_path; @@ -178,15 +179,8 @@ where } } } else { - let mut ts = config - .get_tool_request_set() - .await - .cloned() - .unwrap_or_default() - .filter_by_tool(["sops".into()].into()) - .into_toolset(); - Box::pin(ts.resolve(config)).await?; - let sops_path = ts.which_bin(config, "sops").await; + let sops_path = + configured_toolset_or_path_which(config, ["sops".to_string()], "sops").await?; match sops_path { None => { diff --git a/src/system/packages/dnf.rs b/src/system/packages/dnf.rs index 3676e3223b..a902054c3c 100644 --- a/src/system/packages/dnf.rs +++ b/src/system/packages/dnf.rs @@ -17,6 +17,38 @@ impl DnfManager { } } +// Never add `--` here: DNF5 rejects it on subcommands like install/upgrade. +// Pins use rpm NEVRA syntax (name-version) which dnf accepts positionally. +fn pkg_operand(p: &PackageRequest) -> String { + match &p.version { + Some(v) => format!("{}-{v}", p.name), + None => p.name.clone(), + } +} + +fn install_args(pkgs: &[PackageRequest], opts: &InstallOpts) -> Vec { + let mut args = vec!["install".to_string(), "-y".to_string()]; + if opts.update { + args.push("--refresh".to_string()); + } + args.extend(pkgs.iter().map(pkg_operand)); + args +} + +fn upgrade_args(pkgs: &[PackageRequest]) -> Vec { + // --refresh: expire cached metadata so "upgrade" actually sees new + // versions; `dnf upgrade ` only touches already-installed + // packages (a pin downgrade would need `dnf install name-version`, + // which the install path already provides) + let mut args = vec![ + "upgrade".to_string(), + "-y".to_string(), + "--refresh".to_string(), + ]; + args.extend(pkgs.iter().map(pkg_operand)); + args +} + fn parse_rpm_query(output: &str, requests: &[PackageRequest]) -> Vec { let mut installed: HashMap<&str, &str> = HashMap::new(); for line in output.lines() { @@ -107,17 +139,7 @@ impl SystemPackageManager for DnfManager { } async fn install(&self, pkgs: &[PackageRequest], opts: &InstallOpts) -> Result<()> { - let mut args = vec!["install".to_string(), "-y".to_string()]; - if opts.update { - args.push("--refresh".to_string()); - } - // `--` keeps package operands from being parsed as dnf options; - // pins render to rpm's native name-version(-release) syntax - args.push("--".to_string()); - args.extend(pkgs.iter().map(|p| match &p.version { - Some(v) => format!("{}-{v}", p.name), - None => p.name.clone(), - })); + let args = install_args(pkgs, opts); if opts.dry_run { miseprintln!("{}", sudo::argv("dnf", &args).join(" ")); return Ok(()); @@ -126,20 +148,7 @@ impl SystemPackageManager for DnfManager { } async fn upgrade(&self, pkgs: &[PackageRequest], opts: &InstallOpts) -> Result<()> { - // --refresh: expire cached metadata so "upgrade" actually sees new - // versions; `dnf upgrade ` only touches already-installed - // packages (a pin downgrade would need `dnf install name-version`, - // which the install path already provides) - let mut args = vec![ - "upgrade".to_string(), - "-y".to_string(), - "--refresh".to_string(), - "--".to_string(), - ]; - args.extend(pkgs.iter().map(|p| match &p.version { - Some(v) => format!("{}-{v}", p.name), - None => p.name.clone(), - })); + let args = upgrade_args(pkgs); if opts.dry_run { miseprintln!("{}", sudo::argv("dnf", &args).join(" ")); return Ok(()); @@ -160,6 +169,43 @@ mod tests { } } + #[test] + fn test_install_args_no_separator() { + let pkgs = vec![req("ripgrep", None), req("bat", Some("0.24.0"))]; + let opts = InstallOpts { + dry_run: false, + update: false, + }; + let args = install_args(&pkgs, &opts); + // DNF5 rejects a bare `--` on subcommands; it must never appear + assert!(args.iter().all(|a| a != "--")); + assert_eq!(args, vec!["install", "-y", "ripgrep", "bat-0.24.0"]); + } + + #[test] + fn test_install_args_update_adds_refresh() { + let pkgs = vec![req("ripgrep", None)]; + let opts = InstallOpts { + dry_run: false, + update: true, + }; + let args = install_args(&pkgs, &opts); + assert!(args.iter().all(|a| a != "--")); + // --refresh precedes the operands, after the subcommand flags + assert_eq!(args, vec!["install", "-y", "--refresh", "ripgrep"]); + } + + #[test] + fn test_upgrade_args_no_separator() { + let pkgs = vec![req("ripgrep", None), req("bat", Some("0.24.0"))]; + let args = upgrade_args(&pkgs); + assert!(args.iter().all(|a| a != "--")); + assert_eq!( + args, + vec!["upgrade", "-y", "--refresh", "ripgrep", "bat-0.24.0"] + ); + } + #[test] fn test_parse_rpm_query() { let requests = vec![ diff --git a/src/system/systemd.rs b/src/system/systemd.rs index b7500d93c1..a9d1b9b9c7 100644 --- a/src/system/systemd.rs +++ b/src/system/systemd.rs @@ -128,7 +128,7 @@ pub fn is_available() -> bool { cfg!(target_os = "linux") && crate::file::which("systemctl").is_some() && sudo_invoking_user().is_none() - && user_bus_available() + && user_manager_available() } pub fn unavailable_reason() -> String { @@ -138,8 +138,8 @@ pub fn unavailable_reason() -> String { "`systemctl` not found".to_string() } else if sudo_invoking_user().is_some() { "`systemctl --user` cannot target SUDO_USER; run mise as the target user".to_string() - } else if !user_bus_available() { - "systemd user bus not available".to_string() + } else if !user_manager_available() { + "systemd user manager not available".to_string() } else { "systemd unavailable".to_string() } @@ -360,15 +360,19 @@ fn normalize(value: &str) -> String { value.replace("\r\n", "\n").trim_end().to_string() } -fn user_bus_available() -> bool { +fn user_manager_available() -> bool { if crate::env::var("DBUS_SESSION_BUS_ADDRESS").is_ok_and(|v| !v.is_empty()) { return true; } crate::env::var("XDG_RUNTIME_DIR") - .map(|dir| Path::new(&dir).join("bus").exists()) + .map(|dir| user_manager_socket_available(Path::new(&dir))) .unwrap_or(false) } +fn user_manager_socket_available(runtime_dir: &Path) -> bool { + runtime_dir.join("systemd/private").exists() || runtime_dir.join("bus").exists() +} + fn sudo_invoking_user() -> Option { if crate::system::sudo::is_root() && let Ok(sudo_user) = crate::env::var("SUDO_USER") @@ -565,4 +569,17 @@ mod tests { }; assert!(inactive_stopped.is_desired()); } + + #[test] + fn test_user_manager_socket_available() { + let runtime_dir = tempfile::tempdir().unwrap(); + assert!(!user_manager_socket_available(runtime_dir.path())); + + crate::file::create_dir_all(runtime_dir.path().join("systemd/private")).unwrap(); + assert!(user_manager_socket_available(runtime_dir.path())); + + crate::file::remove_file_or_dir(runtime_dir.path().join("systemd/private")).unwrap(); + std::fs::write(runtime_dir.path().join("bus"), "").unwrap(); + assert!(user_manager_socket_available(runtime_dir.path())); + } } diff --git a/src/task/task_executor.rs b/src/task/task_executor.rs index 02feedcdb7..2d3bc3684a 100644 --- a/src/task/task_executor.rs +++ b/src/task/task_executor.rs @@ -855,8 +855,14 @@ impl TaskExecutor { let script = script.trim_start(); // For display, skip leading shebang/blank/`set ...` boilerplate and join // backslash-continued lines so the header shows the first real command as a - // single logical line (see display_first_command). - let display_script = display_first_command(script); + // single logical line (see display_first_command). When show_full_cmd is set, + // keep the whole script instead — the reduction would otherwise discard every + // line past the first command, making the setting a no-op (#10469, #9844). + let display_script = if Settings::get().task.show_full_cmd { + script.to_string() + } else { + display_first_command(script) + }; let args_str = args.join(" "); let cmd = match (display_script.is_empty(), args_str.is_empty()) { (true, true) => "$".to_string(), diff --git a/src/tera.rs b/src/tera.rs index 625c31cefc..de0394b54a 100644 --- a/src/tera.rs +++ b/src/tera.rs @@ -413,6 +413,102 @@ pub fn get_tera(dir: Option<&Path>) -> Tera { tera } +/// Like [`get_tera`] but with `os()` and `arch()` bound to an explicit target +/// platform instead of the current host. Used by cross-platform `mise lock` to +/// render URL/checksum templates for platforms other than the one mise runs on. +/// +/// `os` should be a platform os name (e.g. "macos", "linux", "windows") and +/// `arch` a platform arch name (e.g. "x64", "arm64"), matching the values +/// returned by the host-bound functions. Remap arguments such as +/// `os(macos="darwin")` and `arch(x64="amd64")` keep the same semantics. +pub fn get_tera_for_target(dir: Option<&Path>, os: &str, arch: &str) -> Tera { + let mut tera = get_tera(dir); + + // os_family() must follow the target too, not the host. + let family = if os == "windows" { "windows" } else { "unix" }; + + let os = os.to_string(); + tera.register_function( + "os", + move |args: &HashMap| -> tera::Result { + if let Some(remapped) = args.get(&os).and_then(|v| v.as_str()) { + return Ok(Value::String(remapped.to_string())); + } + Ok(Value::String(os.clone())) + }, + ); + + let arch = arch.to_string(); + tera.register_function( + "arch", + move |args: &HashMap| -> tera::Result { + if let Some(remapped) = args.get(&arch).and_then(|v| v.as_str()) { + return Ok(Value::String(remapped.to_string())); + } + Ok(Value::String(arch.clone())) + }, + ); + + tera.register_function( + "os_family", + move |_args: &HashMap| -> tera::Result { + Ok(Value::String(family.to_string())) + }, + ); + + tera +} + +/// Like [`get_tera`] but with `os()` and `arch()` rewritten to re-emit +/// themselves as template fragments (e.g. `os(macos="darwin")` renders back to +/// the literal `{{ os(macos="darwin") }}`). +/// +/// Used when rendering tool option templates at config-load time: env/vars are +/// resolved, but `os()`/`arch()` are deferred so the backend can re-render them +/// for the host at install time or for an arbitrary target during cross-platform +/// `mise lock`. Mirrors how `{{ version }}` is preserved via a placeholder. +pub fn get_tera_preserving_os_arch(dir: Option<&Path>) -> Tera { + let mut tera = get_tera(dir); + tera.register_function("os", reemit_template_fn("os")); + tera.register_function("arch", reemit_template_fn("arch")); + // os_family() must be deferred too: it derives from the target OS, so + // resolving it against the host here would bake e.g. "unix" into a template + // that is later rendered for a windows target. + tera.register_function("os_family", reemit_template_fn("os_family")); + tera +} + +fn reemit_template_fn( + name: &'static str, +) -> impl Fn(&HashMap) -> tera::Result { + move |args: &HashMap| { + let mut parts: Vec = args + .iter() + .filter_map(|(k, v)| reemit_arg_literal(v).map(|lit| format!("{k}={lit}"))) + .collect(); + let rendered = if parts.is_empty() { + format!("{{{{ {name}() }}}}") + } else { + parts.sort(); + format!("{{{{ {name}({}) }}}}", parts.join(", ")) + }; + Ok(Value::String(rendered)) + } +} + +/// Render a Tera function argument value back into its template literal form so +/// it round-trips through re-emission. Tera string literals are literal (no +/// escape sequences), so strings are simply re-quoted; numbers/bools render +/// natively; other types are dropped (os()/arch() ignore non-string remaps). +fn reemit_arg_literal(v: &Value) -> Option { + match v { + Value::String(s) => Some(format!("\"{s}\"")), + Value::Number(n) => Some(n.to_string()), + Value::Bool(b) => Some(b.to_string()), + _ => None, + } +} + pub fn tera_exec( dir: Option, env: EnvMap, @@ -920,4 +1016,85 @@ mod tests { let mut tera = get_tera(Option::from(config_root)); render_str(&mut tera, s, &tera_ctx).unwrap() } + + fn render_for_target(s: &str, os: &str, arch: &str) -> String { + let mut tera_ctx = BASE_CONTEXT.clone(); + tera_ctx.insert("cwd", "/"); + let mut tera = get_tera_for_target(None, os, arch); + render_str(&mut tera, s, &tera_ctx).unwrap() + } + + #[tokio::test] + async fn test_os_arch_for_target() { + let _config = Config::get().await.unwrap(); + // os()/arch() resolve to the requested target, not the host. + assert_eq!( + render_for_target("{{os()}}-{{arch()}}", "windows", "arm64"), + "windows-arm64" + ); + assert_eq!(render_for_target("{{os()}}", "macos", "x64"), "macos"); + } + + #[tokio::test] + async fn test_os_arch_remap_for_target() { + let _config = Config::get().await.unwrap(); + // Remap arguments keep host semantics but apply to the target value. + assert_eq!( + render_for_target( + r#"{{os(macos="darwin")}}_{{arch(x64="amd64")}}"#, + "macos", + "x64" + ), + "darwin_amd64" + ); + // A remap that does not match the target value is ignored. + assert_eq!( + render_for_target(r#"{{arch(x64="amd64")}}"#, "linux", "arm64"), + "arm64" + ); + } + + #[tokio::test] + async fn test_os_family_for_target() { + let _config = Config::get().await.unwrap(); + // os_family() follows the target, not the host. + assert_eq!( + render_for_target("{{os_family()}}", "windows", "x64"), + "windows" + ); + assert_eq!(render_for_target("{{os_family()}}", "linux", "x64"), "unix"); + assert_eq!( + render_for_target("{{os_family()}}", "macos", "arm64"), + "unix" + ); + } + + #[tokio::test] + async fn test_preserving_os_arch_round_trips_through_target() { + let _config = Config::get().await.unwrap(); + // A deferred os(...) remap survives config-load preservation and then + // re-renders correctly for a target platform. + let mut ctx = BASE_CONTEXT.clone(); + ctx.insert("cwd", "/"); + let mut deferred = get_tera_preserving_os_arch(None); + let preserved = render_str(&mut deferred, r#"{{ os(macos="darwin") }}"#, &ctx).unwrap(); + assert_eq!(preserved, r#"{{ os(macos="darwin") }}"#); + let mut tera = get_tera_for_target(None, "macos", "arm64"); + assert_eq!(render_str(&mut tera, &preserved, &ctx).unwrap(), "darwin"); + } + + #[tokio::test] + async fn test_preserving_os_family_round_trips_through_target() { + let _config = Config::get().await.unwrap(); + // os_family() must be deferred at config-load time and resolve against + // the lock target, not the host — otherwise a windows target locked from + // a unix host would get "unix" baked in. + let mut ctx = BASE_CONTEXT.clone(); + ctx.insert("cwd", "/"); + let mut deferred = get_tera_preserving_os_arch(None); + let preserved = render_str(&mut deferred, r#"{{ os_family() }}"#, &ctx).unwrap(); + assert_eq!(preserved, r#"{{ os_family() }}"#); + let mut tera = get_tera_for_target(None, "windows", "x64"); + assert_eq!(render_str(&mut tera, &preserved, &ctx).unwrap(), "windows"); + } } diff --git a/vendor/aqua-registry/metadata.json b/vendor/aqua-registry/metadata.json index 4125d4b5bb..485790d524 100644 --- a/vendor/aqua-registry/metadata.json +++ b/vendor/aqua-registry/metadata.json @@ -1,4 +1,4 @@ { "repository": "aquaproj/aqua-registry", - "tag": "d974f158358577be8c7a98ed03341307846de58e" + "tag": "e1fd540ed7e4e1266c408e70adb7db78be2ca360" } diff --git a/vendor/aqua-registry/registry.yml b/vendor/aqua-registry/registry.yml index 535233ce92..04aa8a7ec0 100644 --- a/vendor/aqua-registry/registry.yml +++ b/vendor/aqua-registry/registry.yml @@ -2408,48 +2408,6 @@ packages: - darwin - windows - amd64 - - type: github_release - repo_owner: CycloneDX - repo_name: cdxgen - description: Creates CycloneDX Bill of Materials (BOM) from source code and container images - version_constraint: "false" - version_overrides: - - version_constraint: semver("< 11.5.0") - error_message: The version is too old. Please use a version 11.5.0 or higher. - - version_constraint: semver("<= 11.9.0") - asset: cdxgen-{{.OS}}-{{.Arch}} - format: raw - overrides: - - goos: linux - asset: cdxgen-{{.OS}}-{{.Arch}}-musl - checksum: - type: github_release - asset: "{{.Asset}}.sha256" - algorithm: sha256 - - version_constraint: semver("<= 12.0.0") - asset: cdxgen-{{.OS}}-{{.Arch}} - format: raw - overrides: - - goos: linux - asset: cdxgen-{{.OS}}-{{.Arch}}-musl - checksum: - type: github_release - asset: "{{.Asset}}.sha256" - algorithm: sha256 - supported_envs: - - linux - - darwin/arm64 - - windows - - version_constraint: "true" - asset: cdxgen-{{.OS}}-{{.Arch}} - format: raw - overrides: - - goos: linux - asset: cdxgen-{{.OS}}-{{.Arch}}-musl - checksum: - type: github_release - asset: "{{.Asset}}.sha256" - algorithm: sha256 - type: github_release repo_owner: CycloneDX repo_name: cyclonedx-cli @@ -3063,6 +3021,35 @@ packages: - darwin - windows - amd64 + - type: github_release + repo_owner: EpicGames + repo_name: lore + description: Lore is a next-generation, open source version control system + version_constraint: "false" + version_overrides: + - version_constraint: "true" + asset: lore-{{.Version}}-{{.Arch}}-{{.OS}}.{{.Format}} + format: tar.gz + windows_arm_emulation: true + replacements: + amd64: x86_64 + arm64: aarch64 + darwin: apple-darwin + linux: unknown-linux-gnu + windows: pc-windows-msvc + overrides: + - goos: linux + goarch: arm64 + asset: lore-{{.Version}}-{{.Arch}}-{{.OS}}-neoverse-512tvb.{{.Format}} + - goos: darwin + replacements: + amd64: amd64 + - goos: windows + format: zip + supported_envs: + - linux + - darwin/arm64 + - windows/amd64 - type: github_release repo_owner: Epistates repo_name: treemd @@ -23501,6 +23488,50 @@ packages: type: github_release asset: mustache_{{trimV .Version}}_checksums.txt algorithm: sha256 + - type: github_release + repo_owner: cdxgen + repo_name: cdxgen + aliases: + - name: CycloneDX/cdxgen + description: Creates CycloneDX Bill of Materials (BOM) from source code and container images + version_constraint: "false" + version_overrides: + - version_constraint: semver("< 11.5.0") + error_message: The version is too old. Please use a version 11.5.0 or higher. + - version_constraint: semver("<= 11.9.0") + asset: cdxgen-{{.OS}}-{{.Arch}} + format: raw + overrides: + - goos: linux + asset: cdxgen-{{.OS}}-{{.Arch}}-musl + checksum: + type: github_release + asset: "{{.Asset}}.sha256" + algorithm: sha256 + - version_constraint: semver("<= 12.0.0") + asset: cdxgen-{{.OS}}-{{.Arch}} + format: raw + overrides: + - goos: linux + asset: cdxgen-{{.OS}}-{{.Arch}}-musl + checksum: + type: github_release + asset: "{{.Asset}}.sha256" + algorithm: sha256 + supported_envs: + - linux + - darwin/arm64 + - windows + - version_constraint: "true" + asset: cdxgen-{{.OS}}-{{.Arch}} + format: raw + overrides: + - goos: linux + asset: cdxgen-{{.OS}}-{{.Arch}}-musl + checksum: + type: github_release + asset: "{{.Asset}}.sha256" + algorithm: sha256 - type: github_release repo_owner: cea-hpc repo_name: sshproxy