diff --git a/.gitignore b/.gitignore index c521e65c..7b8518a2 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ tests/gen_commands_bin # worktrees .claude/worktrees/ +dist-bundled/ diff --git a/.goreleaser-linux.yaml b/.goreleaser-linux.yaml index 6f408a3f..e6bc584d 100644 --- a/.goreleaser-linux.yaml +++ b/.goreleaser-linux.yaml @@ -13,6 +13,7 @@ before: # Verify Cargo.toml version matches git tag - >- sh -c 'CARGO_VER=$(grep "^version" Cargo.toml | head -1 | sed "s/.*\"\(.*\)\"/\1/"); TAG_VER=$(git describe --tags --exact-match 2>/dev/null | sed "s/^v//"); if [ "$CARGO_VER" != "$TAG_VER" ]; then echo "ERROR: Cargo.toml version ($CARGO_VER) does not match git tag ($TAG_VER)"; exit 1; fi' + - bash scripts/fetch-bundled-extensions.sh dist-bundled builds: - id: linux @@ -39,6 +40,11 @@ archives: - LICENSE - LICENSE-3rdparty.csv - README.md + - src: "dist-bundled/{{ .Os }}-{{ .Arch }}/pup-setup" + dst: libexec/pup-extensions + strip_parent: true + info: + mode: 0755 sboms: - artifacts: archive diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 865ad827..b686e268 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -390,6 +390,10 @@ The config directory location depends on your platform: Override with `PUP_CONFIG_DIR` environment variable. +### Bundled extensions + +Release archives can ship first-party extensions in `libexec/pup-extensions/`, either next to the `pup` binary or one level up (Homebrew's `bin/` + `libexec/` layout). Pup dispatches to these without `pup extension install`. A user-installed extension with the same name takes precedence. + ## Exit Codes Pup propagates the extension's exit code. If the extension exits with code 1, pup exits with code 1. On Unix, if the extension is killed by a signal, pup exits with 128 + signal number (standard convention). diff --git a/scripts/fetch-bundled-extensions.sh b/scripts/fetch-bundled-extensions.sh new file mode 100644 index 00000000..647a52e9 --- /dev/null +++ b/scripts/fetch-bundled-extensions.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Downloads the pinned first-party extensions that ship inside pup release +# archives and verifies them against pinned SHA-256 sums. Output layout matches +# the archive `files:` entries in .goreleaser-*.yaml: +# dist-bundled/-/pup-setup +# Kept to bash 3.2 features because the macOS release runner uses /bin/bash. +set -euo pipefail + +SETUP_REPO="DataDog/pup-setup" +# Placeholder pin: no pup-setup release exists yet. +SETUP_TAG="v0.0.0" + +setup_sha256() { + case "$1" in + linux-amd64) echo "0000000000000000000000000000000000000000000000000000000000000000" ;; + linux-arm64) echo "0000000000000000000000000000000000000000000000000000000000000000" ;; + darwin-amd64) echo "0000000000000000000000000000000000000000000000000000000000000000" ;; + darwin-arm64) echo "0000000000000000000000000000000000000000000000000000000000000000" ;; + *) return 1 ;; + esac +} + +asset_arch() { + case "$1" in + amd64) echo "x86_64" ;; + arm64) echo "aarch64" ;; + *) return 1 ;; + esac +} + +out_root="${1:-dist-bundled}" + +for platform in linux-amd64 linux-arm64 darwin-amd64 darwin-arm64; do + goos="${platform%-*}" + goarch="${platform#*-}" + asset="pup-setup-${goos}-$(asset_arch "$goarch")" + expected="$(setup_sha256 "$platform")" + dest_dir="${out_root}/${platform}" + dest="${dest_dir}/pup-setup" + + mkdir -p "$dest_dir" + curl --fail --silent --show-error --location \ + --output "$dest" \ + "https://github.com/${SETUP_REPO}/releases/download/${SETUP_TAG}/${asset}" + + actual="$(shasum -a 256 "$dest" | cut -d' ' -f1)" + if [[ "$actual" != "$expected" ]]; then + echo "checksum mismatch for ${asset}: expected ${expected}, got ${actual}" >&2 + exit 1 + fi + chmod 0755 "$dest" +done diff --git a/src/extensions/discovery.rs b/src/extensions/discovery.rs index 8b13ea7f..c0c37b9e 100644 --- a/src/extensions/discovery.rs +++ b/src/extensions/discovery.rs @@ -1,6 +1,7 @@ use anyhow::Result; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use super::install::validate_extension_name; use super::manifest::Manifest; use crate::config; @@ -30,7 +31,15 @@ fn extension_executable_name(name: &str) -> String { } /// Look up an installed extension by name. Returns the path to the executable if found. +/// A user-installed extension wins over one bundled with the pup release. pub fn extension_path(name: &str) -> Option { + user_extension_path(name).or_else(|| { + let exe = std::env::current_exe().ok()?; + bundled_extension_path(&exe, name) + }) +} + +fn user_extension_path(name: &str) -> Option { let dir = extension_dir()?; let exe_name = extension_executable_name(name); let path = dir.join(format!("pup-{name}")).join(&exe_name); @@ -41,6 +50,20 @@ pub fn extension_path(name: &str) -> Option { } } +/// Extensions shipped inside the release archive live in +/// `libexec/pup-extensions/` next to the pup binary (tarball layout) or one +/// level up (Homebrew's `bin/` + `libexec/` layout). +fn bundled_extension_path(pup_exe: &Path, name: &str) -> Option { + validate_extension_name(name).ok()?; + let bin_dir = pup_exe.parent()?; + let exe_name = extension_executable_name(name); + [Some(bin_dir), bin_dir.parent()] + .into_iter() + .flatten() + .map(|dir| dir.join("libexec").join("pup-extensions").join(&exe_name)) + .find(|path| path.is_file()) +} + /// List all installed extensions by scanning the extensions directory. pub fn list_extensions() -> Result> { let dir = match extension_dir() { @@ -147,6 +170,73 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + fn write_bundled(root: &std::path::Path, name: &str) -> PathBuf { + let dir = root.join("libexec").join("pup-extensions"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(extension_executable_name(name)); + std::fs::write(&path, "#!/bin/sh\n").unwrap(); + path + } + + #[test] + fn test_bundled_extension_next_to_binary() { + let dir = make_test_dir("bundled-sibling"); + let expected = write_bundled(&dir, "setup"); + + let found = bundled_extension_path(&dir.join("pup"), "setup"); + assert_eq!(found, Some(expected)); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_bundled_extension_homebrew_layout() { + let dir = make_test_dir("bundled-brew"); + let expected = write_bundled(&dir, "setup"); + + let found = bundled_extension_path(&dir.join("bin").join("pup"), "setup"); + assert_eq!(found, Some(expected)); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_bundled_extension_missing() { + let dir = make_test_dir("bundled-missing"); + write_bundled(&dir, "setup"); + + assert_eq!(bundled_extension_path(&dir.join("pup"), "other"), None); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_bundled_extension_rejects_invalid_names() { + let dir = make_test_dir("bundled-invalid"); + write_bundled(&dir, "setup"); + + for name in ["../setup", "Setup", "", "set/up"] { + assert_eq!(bundled_extension_path(&dir.join("pup"), name), None); + } + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_bundled_extension_ignores_directories() { + let dir = make_test_dir("bundled-dir"); + std::fs::create_dir_all( + dir.join("libexec") + .join("pup-extensions") + .join(extension_executable_name("setup")), + ) + .unwrap(); + + assert_eq!(bundled_extension_path(&dir.join("pup"), "setup"), None); + + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn test_extension_path_not_found() { let dir = make_test_dir("path-not-found");