#![cfg(all(feature = "bin", feature = "shadow-bcrypt"))]
use std::process::{Command, Stdio};
const BURN: &str = env!("CARGO_BIN_EXE_burn");
fn run_burn_eval(src: &str) -> std::process::Output {
Command::new(BURN)
.env("BURN_QUIET", "1")
.arg("-e")
.arg(src)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("spawn burn")
}
fn assert_ok(out: &std::process::Output, what: &str) {
assert!(
out.status.success(),
"{what} FAILED\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn require_bcrypt_returns_a_module() {
let out = run_burn_eval(
"const bcrypt = require('bcrypt');\n\
console.log(typeof bcrypt.hashSync);\n\
console.log(typeof bcrypt.compareSync);\n\
console.log(typeof bcrypt.genSaltSync);\n\
console.log(typeof bcrypt.hash);\n\
console.log(typeof bcrypt.compare);\n\
console.log(typeof bcrypt.genSalt);",
);
assert_ok(&out, "require('bcrypt')");
let stdout = String::from_utf8_lossy(&out.stdout);
let count = stdout.matches("function").count();
assert!(count >= 6, "expected 6+ function exports, got: {stdout}");
}
#[test]
fn hashsync_produces_bcrypt_shaped_hash() {
let out = run_burn_eval(
"const bcrypt = require('bcrypt');\n\
// Cost 4 is the minimum bcrypt accepts — keeps the test fast.\n\
const h = bcrypt.hashSync('hunter2', 4);\n\
// bcrypt hashes start with $2 variants and carry the cost.\n\
console.log(h.slice(0, 7));\n\
console.log(h.length);",
);
assert_ok(&out, "hashSync");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("$2b$04$") || stdout.contains("$2a$04$") || stdout.contains("$2y$04$"),
"hash prefix wrong: {stdout}"
);
assert!(stdout.contains("60"), "hash length wrong: {stdout}");
}
#[test]
fn compare_sync_roundtrips() {
let out = run_burn_eval(
"const bcrypt = require('bcrypt');\n\
const h = bcrypt.hashSync('correct-password', 4);\n\
console.log(bcrypt.compareSync('correct-password', h));\n\
console.log(bcrypt.compareSync('wrong-password', h));",
);
assert_ok(&out, "compareSync round-trip");
let stdout = String::from_utf8_lossy(&out.stdout);
let lines: Vec<&str> = stdout.trim().lines().collect();
assert_eq!(lines, vec!["true", "false"], "round-trip shape: {stdout}");
}
#[test]
fn async_hash_returns_promise_and_resolves() {
let out = run_burn_eval(
"(async () => {\n\
const bcrypt = require('bcrypt');\n\
const h = await bcrypt.hash('pw', 4);\n\
console.log(h.slice(0, 7));\n\
const ok = await bcrypt.compare('pw', h);\n\
console.log(ok);\n\
})();",
);
assert_ok(&out, "async hash+compare");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("$2"), "hash prefix: {stdout}");
assert!(stdout.contains("true"), "compare result: {stdout}");
}
#[test]
fn callback_api_for_async_hash() {
let out = run_burn_eval(
"const bcrypt = require('bcrypt');\n\
bcrypt.hash('pw', 4, function(err, h) {\n\
if (err) { console.error('err:', err); process.exit(1); }\n\
console.log('cb got 60-char hash:', h && h.length === 60);\n\
});",
);
assert_ok(&out, "callback hash");
assert!(
String::from_utf8_lossy(&out.stdout).contains("cb got 60-char hash: true"),
"callback: {:?}",
String::from_utf8_lossy(&out.stdout)
);
}
#[test]
fn gen_salt_sync_produces_bcrypt_salt() {
let out = run_burn_eval(
"const bcrypt = require('bcrypt');\n\
const salt = bcrypt.genSaltSync(4);\n\
console.log(salt.length);\n\
console.log(salt.slice(0, 7));",
);
assert_ok(&out, "genSaltSync");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("29"), "salt length wrong: {stdout}");
assert!(
stdout.contains("$2b$04$") || stdout.contains("$2a$04$") || stdout.contains("$2y$04$"),
"salt prefix wrong: {stdout}"
);
}
#[test]
fn get_rounds_parses_cost_from_hash() {
let out = run_burn_eval(
"const bcrypt = require('bcrypt');\n\
const h = bcrypt.hashSync('pw', 6);\n\
console.log(bcrypt.getRounds(h));",
);
assert_ok(&out, "getRounds");
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "6");
}
#[test]
fn truncates_detects_long_passwords() {
let out = run_burn_eval(
"const bcrypt = require('bcrypt');\n\
console.log(bcrypt.truncates('short'));\n\
console.log(bcrypt.truncates('x'.repeat(72)));\n\
console.log(bcrypt.truncates('x'.repeat(73)));",
);
assert_ok(&out, "truncates");
let stdout = String::from_utf8_lossy(&out.stdout);
let lines: Vec<&str> = stdout.trim().lines().collect();
assert_eq!(lines, vec!["false", "false", "true"], "truncates: {stdout}");
}
#[test]
fn unknown_hash_raises_error() {
let out = run_burn_eval(
"const bcrypt = require('bcrypt');\n\
try {\n\
bcrypt.compareSync('pw', 'not-a-real-bcrypt-hash');\n\
console.log('BAD: did not throw');\n\
} catch (e) {\n\
console.log('code=' + e.code);\n\
}",
);
assert_ok(&out, "malformed hash");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("code=ERR_SHADOW_BCRYPT"),
"expected ERR_SHADOW_BCRYPT code: {stdout}"
);
}
#[test]
fn shadow_wins_over_node_modules_bcrypt() {
let dir = std::env::temp_dir().join(format!("burn_bcrypt_precedence_{}", std::process::id()));
std::fs::create_dir_all(dir.join("node_modules/bcrypt")).unwrap();
std::fs::write(
dir.join("node_modules/bcrypt/package.json"),
r#"{"name":"bcrypt","main":"index.js"}"#,
)
.unwrap();
std::fs::write(
dir.join("node_modules/bcrypt/index.js"),
"module.exports = { shadowed: 'FAKE-USER-BCRYPT' };",
)
.unwrap();
let out = Command::new(BURN)
.env("BURN_QUIET", "1")
.current_dir(&dir)
.arg("-e")
.arg(
"const bcrypt = require('bcrypt');\n\
// Shadow exposes hashSync; the fake package above doesn't.\n\
console.log(typeof bcrypt.hashSync);\n\
console.log(!!bcrypt.shadowed);",
)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("spawn burn");
let _ = std::fs::remove_dir_all(&dir);
assert_ok(&out, "shadow precedence");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("function"), "shadow not resolved: {stdout}");
assert!(
stdout.contains("false"),
"fake user bcrypt leaked through: {stdout}"
);
}
#[test]
fn hash_of_same_password_produces_different_bcrypt_hashes() {
let out = run_burn_eval(
"const bcrypt = require('bcrypt');\n\
const a = bcrypt.hashSync('same-pw', 4);\n\
const b = bcrypt.hashSync('same-pw', 4);\n\
console.log(a === b);\n\
// But both should still verify.\n\
console.log(bcrypt.compareSync('same-pw', a));\n\
console.log(bcrypt.compareSync('same-pw', b));",
);
assert_ok(&out, "random salt per call");
let stdout = String::from_utf8_lossy(&out.stdout);
let lines: Vec<&str> = stdout.trim().lines().collect();
assert_eq!(lines, vec!["false", "true", "true"], "lines: {stdout}");
}