Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,4 @@ tests/gen_commands_bin

# worktrees
.claude/worktrees/
dist-bundled/
6 changes: 6 additions & 0 deletions .goreleaser-linux.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/EXTENSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
52 changes: 52 additions & 0 deletions scripts/fetch-bundled-extensions.sh
Original file line number Diff line number Diff line change
@@ -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/<goos>-<goarch>/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
92 changes: 91 additions & 1 deletion src/extensions/discovery.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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<PathBuf> {
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<PathBuf> {
let dir = extension_dir()?;
let exe_name = extension_executable_name(name);
let path = dir.join(format!("pup-{name}")).join(&exe_name);
Expand All @@ -41,6 +50,20 @@ pub fn extension_path(name: &str) -> Option<PathBuf> {
}
}

/// 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<PathBuf> {
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<Vec<ExtensionInfo>> {
let dir = match extension_dir() {
Expand Down Expand Up @@ -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");
Expand Down
Loading