Skip to content
Merged
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
116 changes: 116 additions & 0 deletions crates/perry/src/commands/install/allowlist.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//! Bundled trust allowlist for lifecycle-script execution.
//!
//! v1 ships allowlist-only script execution (no sandbox). The list
//! covers the well-known native-binding / build-step packages whose
//! `postinstall` is essential for the package to work (e.g. esbuild
//! downloads its platform-specific binary; prisma generates client
//! code; sharp builds its native module).
//!
//! v2 will sandbox these (macOS sandbox-exec / Linux bubblewrap +
//! seccomp / Windows AppContainer) so even a compromised allowlisted
//! package's script can't exfil credentials. Until v2 lands, the
//! allowlist is the trust boundary: a malicious version of an
//! allowlisted package WOULD run, so users on high-stakes hosts can
//! `--installer=npm` + omit the allowlist via package.json's
//! `perry.disallowScripts` (TODO Phase 9.1) or simply
//! `--run-scripts-all=false` (the default).

const EXACT: &[&str] = &[
// Build / bundler tooling
"esbuild",
"swc",
"@swc/core",
"@swc/wasm",
"@biomejs/biome",
"lightningcss",
// Database / ORM
"prisma",
"@prisma/client",
"@prisma/engines",
"better-sqlite3",
"sqlite3",
"leveldown",
// Crypto / native compute
"bcrypt",
"argon2",
"node-sass", // legacy but still in long-tail use
// Image / media
"sharp",
"canvas",
// Browser automation
"electron",
"puppeteer",
"puppeteer-core",
"playwright",
"@playwright/test",
"@playwright/browser-chromium",
// WebSocket native
"bufferutil",
"utf-8-validate",
// macOS-only fs watcher
"fsevents",
// Native binding glue
"node-gyp",
"node-gyp-build",
"node-pre-gyp",
"prebuild-install",
"@mapbox/node-pre-gyp",
"@parcel/watcher",
"protobufjs",
"grpc",
"@grpc/grpc-js",
];

/// Scoped prefixes — match any package whose name starts with one of
/// these. Used for platform-specific subpackages that npm ships per
/// (os, arch) and that need their install script (a platform check, a
/// binary chmod, etc.). Listing each variant explicitly would be
/// tedious and version-fragile.
const PREFIXES: &[&str] = &[
"@next/swc-",
"@tailwindcss/oxide",
"@biomejs/cli-",
"@esbuild/",
"@swc/core-",
"lightningcss-",
"@rollup/rollup-",
"@parcel/",
"@playwright/",
"@puppeteer/",
"@prisma/",
"@napi-rs/",
];

/// Is this package on the bundled trust allowlist?
pub fn is_bundled(name: &str) -> bool {
EXACT.iter().any(|n| *n == name) || PREFIXES.iter().any(|p| name.starts_with(p))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn exact_matches() {
assert!(is_bundled("esbuild"));
assert!(is_bundled("sharp"));
assert!(is_bundled("prisma"));
assert!(is_bundled("@prisma/client"));
}

#[test]
fn prefix_matches() {
assert!(is_bundled("@next/swc-darwin-arm64"));
assert!(is_bundled("@esbuild/darwin-arm64"));
assert!(is_bundled("@tailwindcss/oxide-linux-x64-gnu"));
assert!(is_bundled("@napi-rs/canvas-darwin-arm64"));
}

#[test]
fn unrelated_not_matched() {
assert!(!is_bundled("lodash"));
assert!(!is_bundled("evilpkg"));
assert!(!is_bundled("esbuild-evil")); // typosquat shape, not allowed
assert!(!is_bundled("@scope/random"));
}
}
99 changes: 99 additions & 0 deletions crates/perry/src/commands/install/detect.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//! Installer detection: pick `bun` if available, else `npm`, else error.

use anyhow::{anyhow, bail, Result};
use std::process::Command;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Installer {
Bun,
Npm,
}

impl Installer {
pub fn binary(&self) -> &'static str {
match self {
Installer::Bun => "bun",
Installer::Npm => "npm",
}
}

pub fn print_banner(&self, use_color: bool) {
let label = match self {
Installer::Bun => "bun",
Installer::Npm => "npm",
};
if use_color {
eprintln!(
"\x1b[1;36mperry install\x1b[0m \x1b[2m(via {} install --ignore-scripts)\x1b[0m",
label
);
} else {
eprintln!("perry install (via {} install --ignore-scripts)", label);
}
}
}

/// Probe whether a binary exists and responds to `--version`.
pub fn probe(bin: &str) -> bool {
Command::new(bin)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}

/// Pick an installer, honoring an optional explicit override.
///
/// Override values are case-insensitive; "bun" or "npm" are accepted.
/// If an override is given and that binary isn't on PATH, we error
/// loudly rather than silently fall back — the user asked for a
/// specific tool.
pub fn pick(override_choice: Option<&str>) -> Result<Installer> {
if let Some(name) = override_choice {
let choice = match name.to_ascii_lowercase().as_str() {
"bun" => Installer::Bun,
"npm" => Installer::Npm,
other => bail!(
"unknown --installer value '{}'; expected 'bun' or 'npm'",
other
),
};
if !probe(choice.binary()) {
return Err(anyhow!(
"--installer={} requested but `{}` not found on PATH",
name,
choice.binary()
));
}
return Ok(choice);
}

if probe("bun") {
return Ok(Installer::Bun);
}
if probe("npm") {
return Ok(Installer::Npm);
}
bail!(
"neither `bun` nor `npm` is on PATH. Install one of them and re-run, \
or pass --installer=<name> after putting it on PATH."
)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn override_unknown_errors() {
let err = pick(Some("yarn")).unwrap_err().to_string();
assert!(err.contains("unknown --installer"));
}

#[test]
fn probe_nonexistent_returns_false() {
assert!(!probe("definitely-not-a-real-binary-zzz-1234"));
}
}
Loading
Loading