mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(node): add Windows managed Node.js fallback (win-x64 + win-arm64) (#2661)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
269ef357f7
commit
596386ee55
@@ -43,11 +43,27 @@ const MANAGED_NODE_ARTIFACT: Option<ManagedNodeArtifact> = Some(ManagedNodeArtif
|
||||
sha256: "4786d00c4d259d3ff0b2328307f764ef3ced65f2d6e9502d433e68d66238509d",
|
||||
});
|
||||
|
||||
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
|
||||
const MANAGED_NODE_ARTIFACT: Option<ManagedNodeArtifact> = Some(ManagedNodeArtifact {
|
||||
platform: "win-x64",
|
||||
filename: "node-v24.11.0-win-x64.zip",
|
||||
sha256: "1054540bce22b54ec7e50ebc078ec5d090700a77657607a58f6a64df21f49fdd",
|
||||
});
|
||||
|
||||
#[cfg(all(target_os = "windows", target_arch = "aarch64"))]
|
||||
const MANAGED_NODE_ARTIFACT: Option<ManagedNodeArtifact> = Some(ManagedNodeArtifact {
|
||||
platform: "win-arm64",
|
||||
filename: "node-v24.11.0-win-arm64.zip",
|
||||
sha256: "12d3b1aa9696b7411e115a4fa2aef57f95560b5ee16bb62cd69843e535ec72be",
|
||||
});
|
||||
|
||||
#[cfg(not(any(
|
||||
all(target_os = "macos", target_arch = "aarch64"),
|
||||
all(target_os = "macos", target_arch = "x86_64"),
|
||||
all(target_os = "linux", target_arch = "x86_64"),
|
||||
all(target_os = "linux", target_arch = "aarch64")
|
||||
all(target_os = "linux", target_arch = "aarch64"),
|
||||
all(target_os = "windows", target_arch = "x86_64"),
|
||||
all(target_os = "windows", target_arch = "aarch64")
|
||||
)))]
|
||||
const MANAGED_NODE_ARTIFACT: Option<ManagedNodeArtifact> = None;
|
||||
|
||||
@@ -180,7 +196,12 @@ fn install_managed_node_runtime(
|
||||
extract_managed_node_archive(&archive_path, &temp_dir, artifact.filename)?;
|
||||
let _ = std::fs::remove_file(&archive_path);
|
||||
|
||||
let extracted_dir = temp_dir.join(artifact.filename.trim_end_matches(".tar.gz"));
|
||||
let extracted_dir = temp_dir.join(
|
||||
artifact
|
||||
.filename
|
||||
.trim_end_matches(".tar.gz")
|
||||
.trim_end_matches(".zip"),
|
||||
);
|
||||
let source_dir = if extracted_dir.is_dir() {
|
||||
extracted_dir
|
||||
} else {
|
||||
@@ -277,22 +298,106 @@ fn extract_managed_node_archive(
|
||||
dest_dir: &std::path::Path,
|
||||
filename: &str,
|
||||
) -> Result<(), String> {
|
||||
if !filename.ends_with(".tar.gz") {
|
||||
return Err(format!("unsupported managed Node.js archive: {filename}"));
|
||||
}
|
||||
let file =
|
||||
std::fs::File::open(archive_path).map_err(|e| format!("open Node.js archive: {e}"))?;
|
||||
let decoder = flate2::read::GzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
validate_managed_node_archive_entries(&mut archive)?;
|
||||
if filename.ends_with(".tar.gz") {
|
||||
let file =
|
||||
std::fs::File::open(archive_path).map_err(|e| format!("open Node.js archive: {e}"))?;
|
||||
let decoder = flate2::read::GzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
validate_managed_node_archive_entries(&mut archive)?;
|
||||
|
||||
let file = std::fs::File::open(archive_path)
|
||||
.map_err(|e| format!("open Node.js archive for extraction: {e}"))?;
|
||||
let decoder = flate2::read::GzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive
|
||||
.unpack(dest_dir)
|
||||
.map_err(|e| format!("extract Node.js archive: {e}"))
|
||||
let file = std::fs::File::open(archive_path)
|
||||
.map_err(|e| format!("open Node.js archive for extraction: {e}"))?;
|
||||
let decoder = flate2::read::GzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive
|
||||
.unpack(dest_dir)
|
||||
.map_err(|e| format!("extract Node.js archive: {e}"))
|
||||
} else if filename.ends_with(".zip") {
|
||||
let file =
|
||||
std::fs::File::open(archive_path).map_err(|e| format!("open Node.js archive: {e}"))?;
|
||||
let mut archive =
|
||||
zip::ZipArchive::new(file).map_err(|e| format!("read Node.js zip archive: {e}"))?;
|
||||
validate_managed_node_zip_entries(&archive)?;
|
||||
extract_managed_node_zip(&mut archive, dest_dir)
|
||||
} else {
|
||||
Err(format!("unsupported managed Node.js archive: {filename}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate ZIP entry names using platform-neutral string logic.
|
||||
///
|
||||
/// `std::path::Path` is intentionally avoided: its `is_absolute()` and
|
||||
/// `Component` parsing use BUILD-HOST grammar, so `/etc/passwd` is not
|
||||
/// `is_absolute()` on Windows (no drive prefix), causing the check to lie on
|
||||
/// the platform this guard exists to protect. Instead we apply pure string
|
||||
/// rules that produce identical results on every host:
|
||||
///
|
||||
/// - Unix-rooted: starts with `/`
|
||||
/// - Windows-rooted: starts with `\`, has a drive prefix (`X:`), or is UNC
|
||||
/// (`\\` / `//`)
|
||||
/// - Traversal: any component that is `..` when split on EITHER `/` or `\`
|
||||
fn validate_managed_node_zip_entries(
|
||||
archive: &zip::ZipArchive<std::fs::File>,
|
||||
) -> Result<(), String> {
|
||||
for i in 0..archive.len() {
|
||||
let name = archive
|
||||
.name_for_index(i)
|
||||
.ok_or_else(|| format!("Node.js zip entry {i}: missing name"))?;
|
||||
|
||||
// Absolute-path checks (platform-neutral).
|
||||
if name.starts_with('/') || name.starts_with('\\') {
|
||||
return Err(format!("Node.js zip contains absolute path: {name}"));
|
||||
}
|
||||
// Drive prefix: one ASCII letter followed by ':'
|
||||
if name.len() >= 2 && name.as_bytes()[1] == b':' && name.as_bytes()[0].is_ascii_alphabetic()
|
||||
{
|
||||
return Err(format!("Node.js zip contains absolute path: {name}"));
|
||||
}
|
||||
// UNC prefix: // or \\ (covered by starts_with checks above for \\,
|
||||
// and // is caught by starts_with('/') then a second '/' — belt + suspenders).
|
||||
// (Already caught by the starts_with checks above; explicit for clarity.)
|
||||
|
||||
// Traversal: split on both separators and check each component.
|
||||
let has_traversal = name.split(['/', '\\']).any(|component| component == "..");
|
||||
if has_traversal {
|
||||
return Err(format!("Node.js zip contains path traversal: {name}"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_managed_node_zip(
|
||||
archive: &mut zip::ZipArchive<std::fs::File>,
|
||||
dest_dir: &std::path::Path,
|
||||
) -> Result<(), String> {
|
||||
for i in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| format!("Node.js zip entry {i}: {e}"))?;
|
||||
let outpath = match entry.enclosed_name() {
|
||||
Some(p) => dest_dir.join(p),
|
||||
None => {
|
||||
return Err(format!(
|
||||
"Node.js zip contains unsafe path: {}",
|
||||
entry.name()
|
||||
))
|
||||
}
|
||||
};
|
||||
if entry.is_dir() {
|
||||
std::fs::create_dir_all(&outpath)
|
||||
.map_err(|e| format!("create dir in Node.js zip: {e}"))?;
|
||||
} else {
|
||||
if let Some(parent) = outpath.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("create parent dir in Node.js zip: {e}"))?;
|
||||
}
|
||||
let mut out = std::fs::File::create(&outpath)
|
||||
.map_err(|e| format!("create file in Node.js zip: {e}"))?;
|
||||
std::io::copy(&mut entry, &mut out)
|
||||
.map_err(|e| format!("extract file in Node.js zip: {e}"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_managed_node_archive_entries<R: std::io::Read>(
|
||||
@@ -325,15 +430,36 @@ fn validate_managed_node_archive_entries<R: std::io::Read>(
|
||||
}
|
||||
|
||||
fn verify_node_tree(dir: &std::path::Path) -> Result<(), String> {
|
||||
let node = dir.join("bin").join("node");
|
||||
let npm = dir.join("bin").join("npm");
|
||||
if !node.is_file() {
|
||||
return Err("Node.js archive missing bin/node".to_string());
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Windows zip layout: node.exe + npm.cmd + npm (POSIX sh shim) at archive root
|
||||
let node = dir.join("node.exe");
|
||||
let npm_cmd = dir.join("npm.cmd");
|
||||
let npm = dir.join("npm");
|
||||
if !node.is_file() {
|
||||
return Err("Node.js archive missing node.exe".to_string());
|
||||
}
|
||||
if !npm_cmd.is_file() {
|
||||
return Err("Node.js archive missing npm.cmd".to_string());
|
||||
}
|
||||
if !npm.is_file() {
|
||||
return Err("Node.js archive missing npm".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
if !npm.is_file() {
|
||||
return Err("Node.js archive missing bin/npm".to_string());
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// Unix tarball layout: bin/node + bin/npm
|
||||
let node = dir.join("bin").join("node");
|
||||
let npm = dir.join("bin").join("npm");
|
||||
if !node.is_file() {
|
||||
return Err("Node.js archive missing bin/node".to_string());
|
||||
}
|
||||
if !npm.is_file() {
|
||||
return Err("Node.js archive missing bin/npm".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── managed npm adapter installs ──────────────────────────────────────────────
|
||||
@@ -466,4 +592,157 @@ mod tests {
|
||||
"'/tmp/Buzz'\\''s Node'"
|
||||
);
|
||||
}
|
||||
|
||||
// ── zip validation tests ──────────────────────────────────────────────────
|
||||
|
||||
/// Build an in-memory zip archive with the supplied entry names and return
|
||||
/// a temporary file containing it (zip::ZipArchive requires Seek).
|
||||
fn make_zip_with_entries(entry_names: &[&str]) -> tempfile::NamedTempFile {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
{
|
||||
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
|
||||
let opts = zip::write::SimpleFileOptions::default();
|
||||
for name in entry_names {
|
||||
writer.start_file(*name, opts).unwrap();
|
||||
}
|
||||
writer.finish().unwrap();
|
||||
}
|
||||
let mut tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
std::io::Write::write_all(&mut tmp, &buf).unwrap();
|
||||
tmp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_zip_accepts_normal_entries() {
|
||||
let tmp = make_zip_with_entries(&[
|
||||
"node-v24.11.0-win-x64/node.exe",
|
||||
"node-v24.11.0-win-x64/npm.cmd",
|
||||
"node-v24.11.0-win-x64/npm",
|
||||
]);
|
||||
let file = std::fs::File::open(tmp.path()).unwrap();
|
||||
let archive = zip::ZipArchive::new(file).unwrap();
|
||||
assert!(validate_managed_node_zip_entries(&archive).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_zip_rejects_absolute_path() {
|
||||
let tmp = make_zip_with_entries(&["/etc/passwd"]);
|
||||
let file = std::fs::File::open(tmp.path()).unwrap();
|
||||
let archive = zip::ZipArchive::new(file).unwrap();
|
||||
let err = validate_managed_node_zip_entries(&archive).unwrap_err();
|
||||
assert!(
|
||||
err.contains("absolute path"),
|
||||
"expected 'absolute path' in: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_zip_rejects_path_traversal() {
|
||||
let tmp = make_zip_with_entries(&["../../../etc/passwd"]);
|
||||
let file = std::fs::File::open(tmp.path()).unwrap();
|
||||
let archive = zip::ZipArchive::new(file).unwrap();
|
||||
let err = validate_managed_node_zip_entries(&archive).unwrap_err();
|
||||
assert!(
|
||||
err.contains("path traversal"),
|
||||
"expected 'path traversal' in: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_zip_rejects_backslash_rooted() {
|
||||
// Windows-style absolute path using backslash — must reject on every host.
|
||||
let tmp = make_zip_with_entries(&["\\Windows\\system32\\evil.dll"]);
|
||||
let file = std::fs::File::open(tmp.path()).unwrap();
|
||||
let archive = zip::ZipArchive::new(file).unwrap();
|
||||
let err = validate_managed_node_zip_entries(&archive).unwrap_err();
|
||||
assert!(
|
||||
err.contains("absolute path"),
|
||||
"expected 'absolute path' in: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_zip_rejects_drive_prefix() {
|
||||
// Windows drive-letter absolute path — must reject on every host.
|
||||
let tmp = make_zip_with_entries(&["C:\\evil\\payload.exe"]);
|
||||
let file = std::fs::File::open(tmp.path()).unwrap();
|
||||
let archive = zip::ZipArchive::new(file).unwrap();
|
||||
let err = validate_managed_node_zip_entries(&archive).unwrap_err();
|
||||
assert!(
|
||||
err.contains("absolute path"),
|
||||
"expected 'absolute path' in: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_zip_rejects_backslash_traversal() {
|
||||
// Path traversal using Windows separator — must reject on every host.
|
||||
let tmp = make_zip_with_entries(&["node-v24.11.0-win-x64\\..\\..\\evil"]);
|
||||
let file = std::fs::File::open(tmp.path()).unwrap();
|
||||
let archive = zip::ZipArchive::new(file).unwrap();
|
||||
let err = validate_managed_node_zip_entries(&archive).unwrap_err();
|
||||
assert!(
|
||||
err.contains("path traversal"),
|
||||
"expected 'path traversal' in: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── verify_node_tree layout tests ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_verify_node_tree_unix_layout_passes() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let bin = tmp.path().join("bin");
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
std::fs::write(bin.join("node"), b"").unwrap();
|
||||
std::fs::write(bin.join("npm"), b"").unwrap();
|
||||
// On non-Windows the unix branch is active — this must pass.
|
||||
#[cfg(not(windows))]
|
||||
assert!(verify_node_tree(tmp.path()).is_ok());
|
||||
// On Windows the windows branch is active — unix layout must fail.
|
||||
#[cfg(windows)]
|
||||
assert!(verify_node_tree(tmp.path()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_node_tree_unix_layout_missing_npm_fails() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let bin = tmp.path().join("bin");
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
std::fs::write(bin.join("node"), b"").unwrap();
|
||||
// npm intentionally absent
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let err = verify_node_tree(tmp.path()).unwrap_err();
|
||||
assert!(err.contains("bin/npm"), "err: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_node_tree_windows_layout_passes() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("node.exe"), b"").unwrap();
|
||||
std::fs::write(tmp.path().join("npm.cmd"), b"").unwrap();
|
||||
std::fs::write(tmp.path().join("npm"), b"").unwrap();
|
||||
// On Windows the windows branch is active — this must pass.
|
||||
#[cfg(windows)]
|
||||
assert!(verify_node_tree(tmp.path()).is_ok());
|
||||
// On non-Windows the unix branch is active — windows-layout root files
|
||||
// don't satisfy bin/node + bin/npm, so this must fail.
|
||||
#[cfg(not(windows))]
|
||||
assert!(verify_node_tree(tmp.path()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_node_tree_windows_layout_missing_npm_shim_fails() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("node.exe"), b"").unwrap();
|
||||
std::fs::write(tmp.path().join("npm.cmd"), b"").unwrap();
|
||||
// npm POSIX shim intentionally absent
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let err = verify_node_tree(tmp.path()).unwrap_err();
|
||||
assert!(err.contains("npm"), "err: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,22 +11,37 @@ pub(crate) fn buzz_managed_node_root() -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
pub(crate) fn buzz_managed_node_bin_dir() -> Option<PathBuf> {
|
||||
let platform = match (std::env::consts::OS, std::env::consts::ARCH) {
|
||||
("macos", "aarch64") => "darwin-arm64",
|
||||
("macos", "x86_64") => "darwin-x64",
|
||||
("linux", "x86_64") => "linux-x64",
|
||||
("linux", "aarch64") => "linux-arm64",
|
||||
_ => return None,
|
||||
};
|
||||
let (platform, bin_subdir): (&str, Option<&str>) =
|
||||
match (std::env::consts::OS, std::env::consts::ARCH) {
|
||||
("macos", "aarch64") => ("darwin-arm64", Some("bin")),
|
||||
("macos", "x86_64") => ("darwin-x64", Some("bin")),
|
||||
("linux", "x86_64") => ("linux-x64", Some("bin")),
|
||||
("linux", "aarch64") => ("linux-arm64", Some("bin")),
|
||||
// Windows zips have node.exe + npm.cmd at the archive root — no bin/ subdir
|
||||
("windows", "x86_64") => ("win-x64", None),
|
||||
("windows", "aarch64") => ("win-arm64", None),
|
||||
_ => return None,
|
||||
};
|
||||
buzz_managed_node_root().map(|root| {
|
||||
root.join(BUZZ_MANAGED_NODE_VERSION)
|
||||
.join(platform)
|
||||
.join("bin")
|
||||
let dir = root.join(BUZZ_MANAGED_NODE_VERSION).join(platform);
|
||||
match bin_subdir {
|
||||
Some(sub) => dir.join(sub),
|
||||
None => dir,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn buzz_managed_node_bin_path() -> Option<PathBuf> {
|
||||
buzz_managed_node_bin_dir().map(|bin| bin.join("node"))
|
||||
buzz_managed_node_bin_dir().map(|bin| {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
bin.join("node.exe")
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
bin.join("node")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn buzz_managed_npm_bin_dir() -> Option<PathBuf> {
|
||||
|
||||
Reference in New Issue
Block a user