feat: publish Dever core with project scaffolding and versioned AI skills
This commit is contained in:
1 parent
70fa73cdc2
commit
ac458ffa7f
960 files changed
+221319
-1163
No files matched your search
@@ -0,0 +1,98 @@
|
||||
use super::{Action, BuildTarget, OsString, arguments_for};
|
||||
|
||||
#[test]
|
||||
fn build_target_is_explicit_or_defaults_to_host() {
|
||||
for options in [
|
||||
vec!["--output", "program", "--target", "linux-aarch64"],
|
||||
vec!["--target", "linux-aarch64", "--output", "program"],
|
||||
vec!["--output", "program"],
|
||||
] {
|
||||
let explicit = options.contains(&"--target");
|
||||
let arguments: Vec<_> = [vec!["build", "project"], options]
|
||||
.concat()
|
||||
.into_iter()
|
||||
.map(OsString::from)
|
||||
.collect();
|
||||
let (root, action) = arguments_for(&arguments).unwrap();
|
||||
assert_eq!(root.to_str(), Some("project"));
|
||||
let Action::Build { output, target } = action else {
|
||||
panic!("expected build");
|
||||
};
|
||||
assert_eq!(output.to_str(), Some("program"));
|
||||
assert_eq!(
|
||||
target,
|
||||
if explicit {
|
||||
BuildTarget::LinuxAarch64
|
||||
} else {
|
||||
BuildTarget::host().unwrap()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_rejects_missing_unknown_duplicate_and_misplaced_options() {
|
||||
for options in [
|
||||
vec![],
|
||||
vec!["--output"],
|
||||
vec!["--target"],
|
||||
vec!["--target", "linux-aarch64"],
|
||||
vec!["--output", "program", "--target", "arm64"],
|
||||
vec![
|
||||
"--output",
|
||||
"program",
|
||||
"--target",
|
||||
"linux-aarch64",
|
||||
"--target",
|
||||
"linux-aarch64",
|
||||
],
|
||||
vec!["--output", "program", "--output", "second"],
|
||||
vec!["--output", "--target", "linux-aarch64"],
|
||||
vec!["--output", "program", "--unknown"],
|
||||
vec!["--output", "program", "extra"],
|
||||
vec!["--output", ""],
|
||||
] {
|
||||
let arguments: Vec<_> = [vec!["build", "project"], options]
|
||||
.concat()
|
||||
.into_iter()
|
||||
.map(OsString::from)
|
||||
.collect();
|
||||
assert!(arguments_for(&arguments).is_err(), "accepted {arguments:?}");
|
||||
}
|
||||
for command in ["run", "test", "check"] {
|
||||
let arguments = [command, "project", "--target", "linux-aarch64"].map(OsString::from);
|
||||
assert!(arguments_for(&arguments).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lib_target_keeps_specs_and_rejects_option_drift() {
|
||||
let arguments = [
|
||||
"lib",
|
||||
"add",
|
||||
"project",
|
||||
"pip:demo",
|
||||
"--target",
|
||||
"linux-aarch64",
|
||||
]
|
||||
.map(OsString::from);
|
||||
let (_, action) = arguments_for(&arguments).unwrap();
|
||||
let Action::Lib { specs, target, .. } = action else {
|
||||
panic!("expected lib");
|
||||
};
|
||||
assert_eq!(specs, ["pip:demo"]);
|
||||
assert_eq!(target, BuildTarget::LinuxAarch64);
|
||||
for options in [
|
||||
vec!["--target"],
|
||||
vec!["--target", "arm64"],
|
||||
vec!["--unknown"],
|
||||
vec!["--target", "linux-aarch64", "--target", "linux-aarch64"],
|
||||
] {
|
||||
let arguments: Vec<_> = [vec!["lib", "update", "project"], options]
|
||||
.concat()
|
||||
.into_iter()
|
||||
.map(OsString::from)
|
||||
.collect();
|
||||
assert!(arguments_for(&arguments).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
#[path = "../dever-tests/tests/support/process.rs"]
|
||||
pub mod process;
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
#[path = "support/llvm_pack.rs"]
|
||||
pub mod llvm_pack;
|
||||
|
||||
static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct TemporaryProject(PathBuf);
|
||||
|
||||
impl TemporaryProject {
|
||||
fn new() -> Self {
|
||||
let suffix = NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed);
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("dever-cms-project-{}-{suffix}", std::process::id()));
|
||||
fs::create_dir(&root).unwrap();
|
||||
Self(root)
|
||||
}
|
||||
|
||||
fn copy_from(source: &Path) -> Self {
|
||||
let project = Self::new();
|
||||
copy_directory(&source.join("config"), &project.path().join("config"));
|
||||
copy_directory(&source.join("module"), &project.path().join("module"));
|
||||
let tests = source.join("test");
|
||||
if tests.exists() {
|
||||
copy_directory(&tests, &project.path().join("test"));
|
||||
}
|
||||
fs::create_dir(project.path().join("data")).unwrap();
|
||||
project
|
||||
}
|
||||
|
||||
fn package(source: &Path, executable: &Path) -> Self {
|
||||
let project = Self::new();
|
||||
copy_directory(&source.join("config"), &project.path().join("config"));
|
||||
fs::copy(executable, project.path().join("cms-app")).unwrap();
|
||||
project
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
|
||||
fn block_deployment_database(&self) {
|
||||
// Package metadata needs valid JSON. A directory as the configured
|
||||
// database proves tests use their own SQLite configuration instead.
|
||||
fs::create_dir(self.path().join("data/deployment.db")).unwrap();
|
||||
fs::write(
|
||||
self.path().join("config/setting.json"),
|
||||
r#"{"database":{"default":{"type":"sqlite","path":"data/deployment.db"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TemporaryProject {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_directory(source: &Path, target: &Path) {
|
||||
fs::create_dir(target).unwrap();
|
||||
for entry in fs::read_dir(source).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let source = entry.path();
|
||||
let target = target.join(entry.file_name());
|
||||
if entry.file_type().unwrap().is_dir() {
|
||||
copy_directory(&source, &target);
|
||||
} else {
|
||||
fs::copy(source, target).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dever(project: &Path, action: &str, arguments: &[&str]) -> Output {
|
||||
dever_with_pid(project, action, arguments).1
|
||||
}
|
||||
|
||||
fn dever_with_pid(project: &Path, action: &str, arguments: &[&str]) -> (u32, Output) {
|
||||
output_with_pid(
|
||||
Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.arg(action)
|
||||
.arg(project)
|
||||
.args(arguments),
|
||||
)
|
||||
}
|
||||
|
||||
fn output_with_pid(command: &mut Command) -> (u32, Output) {
|
||||
let capture = TemporaryProject::new();
|
||||
let stdout = capture.path().join("stdout");
|
||||
let stderr = capture.path().join("stderr");
|
||||
let mut child = command
|
||||
.env_clear()
|
||||
.stdout(Stdio::from(fs::File::create(&stdout).unwrap()))
|
||||
.stderr(Stdio::from(fs::File::create(&stderr).unwrap()))
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let pid = child.id();
|
||||
let status = process::wait(&mut child, Duration::from_secs(180)).unwrap();
|
||||
(
|
||||
pid,
|
||||
Output {
|
||||
status,
|
||||
stdout: fs::read(stdout).unwrap(),
|
||||
stderr: fs::read(stderr).unwrap(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_test_artifacts_removed(pid: u32) {
|
||||
let test_prefix = format!("dever-application-test-{pid}-");
|
||||
let build_prefix = format!("dever-build-{pid}-");
|
||||
let leftovers = fs::read_dir(std::env::temp_dir())
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.file_name().to_string_lossy().into_owned())
|
||||
.filter(|name| name.starts_with(&test_prefix) || name.starts_with(&build_prefix))
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
leftovers.is_empty(),
|
||||
"temporary test artifacts remain: {leftovers:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_success(output: &Output) {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn clean_removes_only_stale_owned_dever_artifacts() {
|
||||
use std::os::unix::fs::{MetadataExt, symlink};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let project = TemporaryProject::new();
|
||||
let pid = u64::from(std::process::id()) + 1_000_000;
|
||||
let suffix = NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed);
|
||||
let stale = std::env::temp_dir().join(format!("dever-build-{pid}-{suffix}"));
|
||||
fs::create_dir(&stale).unwrap();
|
||||
let owner = fs::metadata(&stale).unwrap().uid();
|
||||
fs::write(
|
||||
stale.join(".dever-owner"),
|
||||
format!(
|
||||
"dever-build-owner-v1\ncreated=1\npid={pid}\nowner={owner}\ncompiler={}\n",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(stale.join("program"), b"stale").unwrap();
|
||||
|
||||
let fresh_pid = pid + 1;
|
||||
let fresh = std::env::temp_dir().join(format!("dever-build-{fresh_pid}-{suffix}"));
|
||||
fs::create_dir(&fresh).unwrap();
|
||||
let created = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
fs::write(
|
||||
fresh.join(".dever-owner"),
|
||||
format!(
|
||||
"dever-build-owner-v1\ncreated={created}\npid={fresh_pid}\nowner={owner}\ncompiler={}\n",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
),
|
||||
).unwrap();
|
||||
let unknown = std::env::temp_dir().join(format!("dever-build-{}-{suffix}", pid + 2));
|
||||
fs::create_dir(&unknown).unwrap();
|
||||
let link = std::env::temp_dir().join(format!("dever-build-{}-{suffix}", pid + 3));
|
||||
symlink(&unknown, &link).unwrap();
|
||||
|
||||
let mut active_process = Command::new("/bin/sleep").arg("60").spawn().unwrap();
|
||||
let active_pid = u64::from(active_process.id());
|
||||
let active = std::env::temp_dir().join(format!("dever-build-{active_pid}-{suffix}"));
|
||||
fs::create_dir(&active).unwrap();
|
||||
fs::write(
|
||||
active.join(".dever-owner"),
|
||||
format!(
|
||||
"dever-build-owner-v1\ncreated=1\npid={active_pid}\nowner={owner}\ncompiler={}\n",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(active.join("program"), b"active").unwrap();
|
||||
|
||||
let legacy = project.path().join(format!(".dever-run-{pid}"));
|
||||
fs::write(&legacy, b"stale legacy").unwrap();
|
||||
let legacy_file = fs::OpenOptions::new().write(true).open(&legacy).unwrap();
|
||||
legacy_file
|
||||
.set_times(
|
||||
fs::FileTimes::new().set_modified(UNIX_EPOCH + std::time::Duration::from_secs(1)),
|
||||
)
|
||||
.unwrap();
|
||||
let fresh_legacy = project.path().join(format!(".dever-run-{}", pid + 1));
|
||||
fs::write(&fresh_legacy, b"fresh legacy").unwrap();
|
||||
let active_legacy = project.path().join(format!(".dever-run-{active_pid}"));
|
||||
fs::write(&active_legacy, b"active legacy").unwrap();
|
||||
let active_legacy_file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&active_legacy)
|
||||
.unwrap();
|
||||
active_legacy_file
|
||||
.set_times(
|
||||
fs::FileTimes::new().set_modified(UNIX_EPOCH + std::time::Duration::from_secs(1)),
|
||||
)
|
||||
.unwrap();
|
||||
let explicit = project.path().join("application");
|
||||
fs::write(&explicit, b"explicit output").unwrap();
|
||||
|
||||
let output = dever(project.path(), "clean", &[]);
|
||||
active_process.kill().unwrap();
|
||||
active_process.wait().unwrap();
|
||||
assert_success(&output);
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stdout).starts_with("cleaned 1 directories, 3 files, ")
|
||||
);
|
||||
assert!(!stale.exists());
|
||||
assert!(fresh.exists());
|
||||
assert!(unknown.exists());
|
||||
assert!(link.symlink_metadata().is_ok());
|
||||
assert!(active.exists());
|
||||
assert!(!legacy.exists());
|
||||
assert!(fresh_legacy.exists());
|
||||
assert!(active_legacy.exists());
|
||||
assert!(explicit.exists());
|
||||
|
||||
fs::remove_dir_all(fresh).unwrap();
|
||||
fs::remove_file(link).unwrap();
|
||||
fs::remove_dir_all(unknown).unwrap();
|
||||
fs::remove_dir_all(active).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cms_project_checks_runs_and_builds_from_an_isolated_copy() {
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
for format in ["dever", "md"] {
|
||||
let project = TemporaryProject::copy_from(&workspace.join("examples/cms").join(format));
|
||||
let settings_path = project.path().join("config/setting.json");
|
||||
let production_settings = fs::read(&settings_path).unwrap();
|
||||
project.block_deployment_database();
|
||||
for _ in 0..2 {
|
||||
let (pid, output) = dever_with_pid(project.path(), "test", &[]);
|
||||
assert_cms_tests(&output);
|
||||
assert_test_artifacts_removed(pid);
|
||||
}
|
||||
fs::write(&settings_path, production_settings).unwrap();
|
||||
fs::write(project.path().join("test/ignored.dever"), "not valid Dever").unwrap();
|
||||
|
||||
assert_success(&dever(project.path(), "check", &[]));
|
||||
for _ in 0..2 {
|
||||
assert_health_stdout(&dever(
|
||||
project.path(),
|
||||
"run",
|
||||
&["--", "system.health.ping", "{}"],
|
||||
));
|
||||
}
|
||||
|
||||
let executable = build(project.path(), "cms-app");
|
||||
let package = TemporaryProject::package(project.path(), &executable);
|
||||
assert!(!package.path().join("module").exists());
|
||||
for _ in 0..2 {
|
||||
let output = output_with_pid(
|
||||
Command::new(package.path().join("cms-app"))
|
||||
.args(["system.health.ping", "{}"])
|
||||
.current_dir(package.path()),
|
||||
)
|
||||
.1;
|
||||
assert_health_stdout(&output);
|
||||
}
|
||||
assert!(fs::read_dir(project.path()).unwrap().all(|entry| {
|
||||
!entry
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with(".dever-run-")
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_without_tests_reports_zero_and_succeeds() {
|
||||
let project = TemporaryProject::new();
|
||||
fs::create_dir(project.path().join("module")).unwrap();
|
||||
fs::write(project.path().join("module/main.dever"), "main() () {}").unwrap();
|
||||
|
||||
let (pid, output) = dever_with_pid(project.path(), "test", &[]);
|
||||
assert_success(&output);
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
"running 0 tests\n\ntest result: ok. 0 passed; 0 failed; 0 not run\n"
|
||||
);
|
||||
assert_test_artifacts_removed(pid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_suite_initializes_database_only_for_database_cases() {
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let project = TemporaryProject::copy_from(&workspace.join("examples/cms/dever"));
|
||||
project.block_deployment_database();
|
||||
let health = project.path().join("test/system/health");
|
||||
fs::create_dir_all(&health).unwrap();
|
||||
fs::write(
|
||||
health.join("ping.dever"),
|
||||
"ping() () { assert_eq(\"ready\", \"ready\") }\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (pid, output) = dever_with_pid(project.path(), "test", &[]);
|
||||
assert_success(&output);
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
"running 6 tests\ntest content/category/lifecycle ... ok\ntest news/article/publish ... ok\ntest system/health/ping ... ok\ntest user/account/bootstrap ... ok\ntest user/account/validation ... ok\ntest user/session/lifecycle ... ok\n\ntest result: ok. 6 passed; 0 failed; 0 not run\n"
|
||||
);
|
||||
assert_test_artifacts_removed(pid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_test_reports_captured_output_and_does_not_stop_later_tests() {
|
||||
let project = TemporaryProject::new();
|
||||
fs::create_dir(project.path().join("module")).unwrap();
|
||||
fs::create_dir_all(project.path().join("test/sample/case")).unwrap();
|
||||
fs::write(project.path().join("module/main.dever"), "main() () {}").unwrap();
|
||||
fs::write(
|
||||
project.path().join("test/sample/case/fails.dever"),
|
||||
r#"fails() () {
|
||||
blocking(dever.io.println("failed output"))
|
||||
assert_eq(1, 2)
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
project.path().join("test/sample/case/passes.dever"),
|
||||
r#"passes() () {
|
||||
blocking(dever.io.println("hidden successful output"))
|
||||
assert(true)
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (pid, output) = dever_with_pid(project.path(), "test", &[]);
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stderr.is_empty());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let failed = stdout.find("test sample/case/fails ... FAILED").unwrap();
|
||||
let passed = stdout.find("test sample/case/passes ... ok").unwrap();
|
||||
assert!(failed < passed, "{stdout}");
|
||||
assert!(
|
||||
stdout.contains("---- stdout ----\nfailed output\n"),
|
||||
"{stdout}"
|
||||
);
|
||||
assert!(stdout.contains("---- stderr ----"), "{stdout}");
|
||||
assert!(
|
||||
stdout.contains("assert_eq failed: actual = 1, expected = 2"),
|
||||
"{stdout}"
|
||||
);
|
||||
assert!(!stdout.contains("hidden successful output"), "{stdout}");
|
||||
assert!(
|
||||
stdout.ends_with("test result: FAILED. 1 passed; 1 failed; 0 not run\n"),
|
||||
"{stdout}"
|
||||
);
|
||||
assert_test_artifacts_removed(pid);
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
#[test]
|
||||
fn suite_compilation_failure_marks_every_discovered_case_failed() {
|
||||
let project = TemporaryProject::new();
|
||||
fs::create_dir(project.path().join("module")).unwrap();
|
||||
fs::create_dir_all(project.path().join("test/sample/case")).unwrap();
|
||||
fs::write(project.path().join("module/main.dever"), "main() () {}").unwrap();
|
||||
fs::write(
|
||||
project.path().join("test/sample/case/first.dever"),
|
||||
"first() () { assert(true) }\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
project.path().join("test/sample/case/second.dever"),
|
||||
"second() () { assert(true) }\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let toolchain = TemporaryProject::new();
|
||||
let compiler = llvm_pack::compiler(
|
||||
Path::new(env!("CARGO_BIN_EXE_dever")),
|
||||
toolchain.path(),
|
||||
"dever",
|
||||
);
|
||||
let (pid, output) = output_with_pid(Command::new(compiler).arg("test").arg(project.path()));
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stderr.is_empty());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
stdout.contains("test sample/case/first ... FAILED"),
|
||||
"{stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("test sample/case/second ... FAILED"),
|
||||
"{stdout}"
|
||||
);
|
||||
assert_eq!(
|
||||
stdout.matches("cannot inspect runtime pack").count(),
|
||||
2,
|
||||
"{stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.ends_with("test result: FAILED. 0 passed; 2 failed; 0 not run\n"),
|
||||
"{stdout}"
|
||||
);
|
||||
assert_test_artifacts_removed(pid);
|
||||
assert!(!toolchain.path().join("cache").exists());
|
||||
}
|
||||
|
||||
fn build(project: &Path, name: &str) -> PathBuf {
|
||||
let executable = project.join(name);
|
||||
let build = dever(
|
||||
project,
|
||||
"build",
|
||||
&["--output", executable.to_str().unwrap()],
|
||||
);
|
||||
assert_success(&build);
|
||||
assert!(executable.is_file());
|
||||
executable
|
||||
}
|
||||
|
||||
fn assert_health_stdout(output: &Output) {
|
||||
assert_success(output);
|
||||
let body: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(body["code"], 0);
|
||||
assert_eq!(body["message"], "ok");
|
||||
assert_eq!(body["data"]["status"], "ready");
|
||||
}
|
||||
|
||||
fn assert_cms_tests(output: &Output) {
|
||||
assert_success(output);
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
"running 5 tests\ntest content/category/lifecycle ... ok\ntest news/article/publish ... ok\ntest user/account/bootstrap ... ok\ntest user/account/validation ... ok\ntest user/session/lifecycle ... ok\n\ntest result: ok. 5 passed; 0 failed; 0 not run\n"
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,468 @@
|
||||
use super::*;
|
||||
use dever_cli::libs::build::{Inputs, Session, pack};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[path = "../../dever-tests/tests/support/sandbox.rs"]
|
||||
pub(super) mod sandbox;
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Settings {
|
||||
runtime: RegistryRuntime,
|
||||
runtime_file: String,
|
||||
build: pack::Descriptor,
|
||||
build_file: String,
|
||||
}
|
||||
|
||||
struct AuthorInputs {
|
||||
root: PathBuf,
|
||||
settings: Settings,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_build_pack_binds_runtime_layout_tools_and_archive_digest() {
|
||||
let runtime = registry_runtime(Ecosystem::Pip);
|
||||
let mut descriptor = pack::Descriptor {
|
||||
format: "dever-registry-build-v1".into(),
|
||||
ecosystem: Ecosystem::Pip,
|
||||
target: runtime.target.clone(),
|
||||
runtime_sha256: runtime.pack.sha256.clone(),
|
||||
python_runtime_sha256: None,
|
||||
sha256: "0".repeat(64),
|
||||
};
|
||||
descriptor.validate(&runtime, None).unwrap();
|
||||
let manifest = pack::Manifest {
|
||||
format: "dever-lib-build-v1".into(),
|
||||
ecosystem: Ecosystem::Pip,
|
||||
target: runtime.target.clone(),
|
||||
runtime_sha256: runtime.pack.sha256.clone(),
|
||||
python_runtime_sha256: None,
|
||||
executables: ["sh", "cc", "c++", "as", "ld", "ar", "ranlib", "make"]
|
||||
.into_iter()
|
||||
.map(|name| format!("rootfs/usr/bin/{name}"))
|
||||
.collect(),
|
||||
};
|
||||
let mut files = manifest
|
||||
.executables
|
||||
.iter()
|
||||
.map(|path| (path.clone(), b"#!/bin/sh\nexit 0\n".to_vec()))
|
||||
.collect::<Vec<_>>();
|
||||
files.push((
|
||||
"dever-build.json".into(),
|
||||
serde_json::to_vec(&manifest).unwrap(),
|
||||
));
|
||||
manifest.validate(&files, &descriptor).unwrap();
|
||||
let mut changed = descriptor.clone();
|
||||
changed.runtime_sha256 = "f".repeat(64);
|
||||
assert!(changed.validate(&runtime, None).is_err());
|
||||
assert!(manifest.validate(&files, &changed).is_err());
|
||||
let mut changed = files.clone();
|
||||
changed.push(("rootfs/etc/ld.so.preload".into(), b"injected".to_vec()));
|
||||
assert!(manifest.validate(&changed, &descriptor).is_err());
|
||||
let mut changed = manifest.clone();
|
||||
changed.executables.push("rootfs/usr/bin/absent".into());
|
||||
assert!(changed.validate(&files, &descriptor).is_err());
|
||||
let mut archive = tar::Builder::new(flate2::write::GzEncoder::new(
|
||||
Vec::new(),
|
||||
flate2::Compression::default(),
|
||||
));
|
||||
for (path, bytes) in files {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
archive
|
||||
.append_data(&mut header, path, bytes.as_slice())
|
||||
.unwrap();
|
||||
}
|
||||
let bytes = archive.into_inner().unwrap().finish().unwrap();
|
||||
descriptor.sha256 = digest(&bytes);
|
||||
pack::unpack(&bytes, &descriptor).unwrap();
|
||||
let mut changed = bytes;
|
||||
changed[0] ^= 1;
|
||||
assert!(pack::unpack(&changed, &descriptor).is_err());
|
||||
}
|
||||
|
||||
impl Inputs for AuthorInputs {
|
||||
fn diagnostic(&self, output: &[u8]) {
|
||||
eprintln!("{}", String::from_utf8_lossy(output));
|
||||
}
|
||||
|
||||
fn runtime(&self, ecosystem: Ecosystem) -> Result<(RegistryRuntime, Vec<u8>), String> {
|
||||
if ecosystem != Ecosystem::Pip {
|
||||
return Err("fixture only supplies Python".into());
|
||||
}
|
||||
let bytes = fs::read(self.root.join(&self.settings.runtime_file))
|
||||
.map_err(|error| error.to_string())?;
|
||||
if digest(&bytes) != self.settings.runtime.pack.sha256 {
|
||||
return Err("author runtime drift".into());
|
||||
}
|
||||
Ok((self.settings.runtime.clone(), bytes))
|
||||
}
|
||||
fn tools(&self, ecosystem: Ecosystem) -> Result<(pack::Descriptor, Vec<u8>), String> {
|
||||
if ecosystem != Ecosystem::Pip {
|
||||
return Err("fixture only supplies Python".into());
|
||||
}
|
||||
Ok((
|
||||
self.settings.build.clone(),
|
||||
fs::read(self.root.join(&self.settings.build_file))
|
||||
.map_err(|error| error.to_string())?,
|
||||
))
|
||||
}
|
||||
fn assets(&self) -> Result<Vec<(String, Vec<u8>)>, String> {
|
||||
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
Ok(sandbox::assets(&workspace)
|
||||
.into_iter()
|
||||
.map(|asset| {
|
||||
let _mode = asset.executable;
|
||||
(
|
||||
asset.path.trim_start_matches("sandbox/").into(),
|
||||
asset.bytes,
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit author operation: assembles previously hashed native-build-inputs; no network or host execution"]
|
||||
fn prepare_source_build_tool_fixture() {
|
||||
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let runtime: RegistryRuntime = serde_json::from_value(
|
||||
serde_json::from_slice::<serde_json::Value>(
|
||||
&fs::read(workspace.join("target/python-managed/config/setting.json")).unwrap(),
|
||||
)
|
||||
.unwrap()["runtime"]
|
||||
.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let bytes = fs::read(workspace.join("target/python-managed/runtime.pack")).unwrap();
|
||||
assert_eq!(digest(&bytes), runtime.pack.sha256);
|
||||
drop(bytes);
|
||||
let root = workspace.join("target/source-build");
|
||||
let descriptor = prepare_tool_pack(Ecosystem::Pip, &runtime, None, &root);
|
||||
fs::write(
|
||||
root.join("config/setting.json"),
|
||||
serde_json::to_vec_pretty(&Settings {
|
||||
runtime,
|
||||
runtime_file: "../python-managed/runtime.pack".into(),
|
||||
build: descriptor,
|
||||
build_file: "build.pack".into(),
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub(super) fn prepare_tool_pack(
|
||||
ecosystem: Ecosystem,
|
||||
runtime: &RegistryRuntime,
|
||||
python: Option<&RegistryRuntime>,
|
||||
root: &std::path::Path,
|
||||
) -> pack::Descriptor {
|
||||
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let inputs = workspace.join("target/native-build-inputs");
|
||||
let document: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(inputs.join("inputs.json")).unwrap()).unwrap();
|
||||
let mut descriptor = pack::Descriptor {
|
||||
format: "dever-registry-build-v1".into(),
|
||||
ecosystem: ecosystem.clone(),
|
||||
target: runtime.target.clone(),
|
||||
runtime_sha256: runtime.pack.sha256.clone(),
|
||||
python_runtime_sha256: python.map(|runtime| runtime.pack.sha256.clone()),
|
||||
sha256: "0".repeat(64),
|
||||
};
|
||||
descriptor.validate(runtime, python).unwrap();
|
||||
let manifest = pack::Manifest {
|
||||
format: "dever-lib-build-v1".into(),
|
||||
ecosystem: ecosystem.clone(),
|
||||
target: runtime.target.clone(),
|
||||
runtime_sha256: runtime.pack.sha256.clone(),
|
||||
python_runtime_sha256: descriptor.python_runtime_sha256.clone(),
|
||||
executables: serde_json::from_value(document[ecosystem.as_str()]["executables"].clone())
|
||||
.unwrap(),
|
||||
};
|
||||
let mut files: Vec<(String, Vec<u8>)> = vec![(
|
||||
"dever-build.json".into(),
|
||||
serde_json::to_vec(&manifest).unwrap(),
|
||||
)];
|
||||
for input in document[ecosystem.as_str()]["files"].as_array().unwrap() {
|
||||
let source = inputs.join(input["source"].as_str().unwrap());
|
||||
assert!(fs::symlink_metadata(&source).unwrap().is_file());
|
||||
let bytes = fs::read(&source).unwrap();
|
||||
assert_eq!(digest(&bytes), input["sha256"].as_str().unwrap());
|
||||
files.push((input["path"].as_str().unwrap().into(), bytes));
|
||||
}
|
||||
files.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
manifest.validate(&files, &descriptor).unwrap();
|
||||
let assets = sandbox::assets(&workspace)
|
||||
.into_iter()
|
||||
.map(|asset| {
|
||||
(
|
||||
asset.path.trim_start_matches("sandbox/").into(),
|
||||
asset.bytes,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
pack::validate_tools(&manifest, &files, &assets).unwrap();
|
||||
fs::create_dir_all(root.join("config")).unwrap();
|
||||
let encoder = flate2::GzBuilder::new()
|
||||
.mtime(0)
|
||||
.operating_system(255)
|
||||
.write(
|
||||
fs::File::create(root.join("build.pack")).unwrap(),
|
||||
flate2::Compression::default(),
|
||||
);
|
||||
let mut archive = tar::Builder::new(encoder);
|
||||
for (path, bytes) in files {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_mode(if manifest.executables.contains(&path) {
|
||||
0o755
|
||||
} else {
|
||||
0o644
|
||||
});
|
||||
header.set_uid(0);
|
||||
header.set_gid(0);
|
||||
header.set_mtime(0);
|
||||
header.set_cksum();
|
||||
archive
|
||||
.append_data(&mut header, path, bytes.as_slice())
|
||||
.unwrap();
|
||||
}
|
||||
archive.into_inner().unwrap().finish().unwrap();
|
||||
descriptor.sha256 = digest(&fs::read(root.join("build.pack")).unwrap());
|
||||
descriptor
|
||||
}
|
||||
|
||||
struct SourceOnly(dever_cli::libs::HttpRegistry);
|
||||
|
||||
#[test]
|
||||
fn incompatible_sdist_candidate_backtracks_before_loading_build_tools() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
python_release(&mut registry, "app", "1.0.0", &[], &["backend>=1"]);
|
||||
python_release(&mut registry, "backend", "1.0.0", &[], &[]);
|
||||
python_release(&mut registry, "backend", "2.0.0", &[], &[]);
|
||||
let path = (Ecosystem::Pip, "/pypi/backend/2.0.0/json".into());
|
||||
let mut release: serde_json::Value = serde_json::from_slice(®istry.0[&path]).unwrap();
|
||||
release["urls"] = serde_json::json!([{"packagetype":"sdist","requires_python":">=3.13","filename":"backend-2.0.0.tar.gz","url":"must-not-fetch","digests":{"sha256":"0".repeat(64)}}]);
|
||||
registry
|
||||
.0
|
||||
.insert(path, serde_json::to_vec(&release).unwrap());
|
||||
let lock = python_lock(®istry, &["pip:app@1.0.0"]).unwrap();
|
||||
assert!(
|
||||
lock.libs
|
||||
.iter()
|
||||
.any(|lib| lib.spec.key() == "pip:backend@1.0.0")
|
||||
);
|
||||
}
|
||||
|
||||
fn backend_fixture(mutate: bool) -> LocalRegistry {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
python_release(&mut registry, "buildhelper", "1.0.0", &[], &[]);
|
||||
registry.0.insert(
|
||||
(Ecosystem::Pip, "/pypi/sample/json".into()),
|
||||
serde_json::to_vec(&serde_json::json!({"releases":{"1.0.0":[]}})).unwrap(),
|
||||
);
|
||||
let helper = wheel_fixture::pack(
|
||||
"buildhelper",
|
||||
"1.0.0",
|
||||
"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
|
||||
&wheel_fixture::metadata("buildhelper", "1.0.0", &[], &[]),
|
||||
&[
|
||||
(
|
||||
"buildhelper.py",
|
||||
b"def cli():\n print('isolated-cli')\n".to_vec(),
|
||||
),
|
||||
(
|
||||
"buildhelper-1.0.0.dist-info/entry_points.txt",
|
||||
b"[console_scripts]\nbuild-probe = buildhelper:cli\n".to_vec(),
|
||||
),
|
||||
],
|
||||
);
|
||||
let key = (Ecosystem::Pip, "/pypi/buildhelper/1.0.0/json".into());
|
||||
let mut release: serde_json::Value = serde_json::from_slice(®istry.0[&key]).unwrap();
|
||||
let url = release["urls"][0]["url"].as_str().unwrap().to_owned();
|
||||
release["urls"][0]["digests"]["sha256"] = serde_json::json!(digest(&helper));
|
||||
registry
|
||||
.0
|
||||
.insert(key, serde_json::to_vec(&release).unwrap());
|
||||
registry.0.insert((Ecosystem::Pip, url), helper);
|
||||
let original = wheel_fixture::metadata("sample", "1.0.0", &[], &[]);
|
||||
let changed = format!("{original}Summary: changed by wheel hook\n");
|
||||
let wheel = wheel_fixture::pack(
|
||||
"sample",
|
||||
"1.0.0",
|
||||
"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
|
||||
if mutate { &changed } else { &original },
|
||||
&[],
|
||||
);
|
||||
let backend = format!(
|
||||
r#"import os, shutil, subprocess
|
||||
from pathlib import Path
|
||||
def get_requires_for_build_wheel(config_settings=None):
|
||||
try:
|
||||
import buildhelper
|
||||
return []
|
||||
except ImportError:
|
||||
return ['buildhelper==1.0.0']
|
||||
def prepare_metadata_for_build_wheel(directory, config_settings=None):
|
||||
assert subprocess.check_output(['build-probe'], text=True).strip() == 'isolated-cli'
|
||||
metadata = Path(directory) / 'sample-1.0.0.dist-info'
|
||||
metadata.mkdir()
|
||||
shutil.copy('original-metadata', metadata / 'METADATA')
|
||||
os.chdir('/')
|
||||
return metadata.name
|
||||
def build_wheel(directory, config_settings=None, metadata_directory=None):
|
||||
assert Path.cwd().name == 'source', 'previous hook leaked its cwd'
|
||||
if {mutate}:
|
||||
shutil.copy('changed-metadata', Path(metadata_directory) / 'METADATA')
|
||||
name = 'sample-1.0.0-py3-none-any.whl'
|
||||
shutil.copy('result.whl', Path(directory) / name)
|
||||
return name
|
||||
"#,
|
||||
mutate = if mutate { "True" } else { "False" }
|
||||
);
|
||||
let sourcefiles = vec![
|
||||
(
|
||||
"sample-1.0.0/pyproject.toml",
|
||||
b"[build-system]\nrequires = []\nbuild-backend = 'backend'\nbackend-path = ['.']\n"
|
||||
.to_vec(),
|
||||
),
|
||||
("sample-1.0.0/backend.py", backend.into_bytes()),
|
||||
("sample-1.0.0/original-metadata", original.into_bytes()),
|
||||
("sample-1.0.0/changed-metadata", changed.into_bytes()),
|
||||
("sample-1.0.0/result.whl", wheel),
|
||||
];
|
||||
let mut tar = tar::Builder::new(flate2::write::GzEncoder::new(
|
||||
Vec::new(),
|
||||
flate2::Compression::default(),
|
||||
));
|
||||
for (name, bytes) in sourcefiles {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
tar.append_data(&mut header, name, bytes.as_slice())
|
||||
.unwrap();
|
||||
}
|
||||
let source = tar.into_inner().unwrap().finish().unwrap();
|
||||
registry.0.insert((Ecosystem::Pip,"/pypi/sample/1.0.0/json".into()),serde_json::to_vec(&serde_json::json!({"info":{},"urls":[{"packagetype":"sdist","filename":"sample-1.0.0.tar.gz","url":"sample-source","digests":{"sha256":digest(&source)}}]})).unwrap());
|
||||
registry
|
||||
.0
|
||||
.insert((Ecosystem::Pip, "sample-source".into()), source);
|
||||
registry
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "isolated offline PEP517 contracts; requires explicit target/source-build tool/runtime fixture"]
|
||||
fn pep517_hooks_keep_requirements_cli_and_metadata_boundaries() {
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/source-build");
|
||||
let settings: Settings =
|
||||
serde_json::from_slice(&fs::read(root.join("config/setting.json")).unwrap()).unwrap();
|
||||
let inputs = AuthorInputs { root, settings };
|
||||
for mutate in [false, true] {
|
||||
let session = Session::new(&inputs);
|
||||
let transport = backend_fixture(mutate);
|
||||
let store = FixtureArtifactStore::default();
|
||||
let resolver = RegistryResolver {
|
||||
transport: &transport,
|
||||
store: &store,
|
||||
runtimes: BTreeMap::from([(Ecosystem::Pip, inputs.settings.runtime.clone())]),
|
||||
go_sumdb: dever_cli::libs::sumdb::ChecksumDatabase::official(None).unwrap(),
|
||||
build: Some(&session),
|
||||
};
|
||||
let result = dever_cli::libs::LibResolver::resolve(
|
||||
&resolver,
|
||||
&["pip:sample@1.0.0".parse().unwrap()],
|
||||
);
|
||||
if mutate {
|
||||
assert!(result.unwrap_err().contains("metadata differs"));
|
||||
continue;
|
||||
}
|
||||
let lock = result.unwrap();
|
||||
dever_cli::libs::doctor(&lock).unwrap();
|
||||
assert_eq!(lock.builds[0].dynamic_requires, vec!["buildhelper==1.0.0"]);
|
||||
for mutation in 0..3 {
|
||||
let mut altered = lock.clone();
|
||||
match mutation {
|
||||
0 => altered.builds[0].tools.sha256 = "not-a-hash".into(),
|
||||
1 => altered.builds[0].dynamic_requires = vec!["buildhelper>=2".into()],
|
||||
_ => *altered.builds[0].dependencies = LockFile::new(vec![]).unwrap(),
|
||||
}
|
||||
altered.libs[0].build = Some(altered.builds[0].identity().unwrap());
|
||||
assert!(
|
||||
dever_cli::libs::doctor(&altered).is_err(),
|
||||
"accepted contradictory receipt {mutation}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl RegistryTransport for SourceOnly {
|
||||
fn get(&self, ecosystem: Ecosystem, path: &str, limit: usize) -> Result<Vec<u8>, String> {
|
||||
let bytes = self.0.get(ecosystem, path, limit)?;
|
||||
// Explicit author acceptance forces this one package's official sdist.
|
||||
// Backend requirements still use their ordinary authenticated wheels.
|
||||
if path == "/pypi/simplejson/3.20.1/json" {
|
||||
let mut document: serde_json::Value =
|
||||
serde_json::from_slice(&bytes).map_err(|error| error.to_string())?;
|
||||
document["urls"]
|
||||
.as_array_mut()
|
||||
.ok_or("official release lacks files")?
|
||||
.retain(|file| file["packagetype"] == "sdist");
|
||||
serde_json::to_vec(&document).map_err(|error| error.to_string())
|
||||
} else {
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit official PyPI source build: downloads fixed source/build requirements and executes signed managed build tools"]
|
||||
fn builds_official_python_source_with_isolated_pep517() {
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/source-build");
|
||||
let settings: Settings =
|
||||
serde_json::from_slice(&fs::read(root.join("config/setting.json")).unwrap()).unwrap();
|
||||
let inputs = AuthorInputs {
|
||||
root: root.clone(),
|
||||
settings,
|
||||
};
|
||||
let session = Session::new(&inputs);
|
||||
let transport = SourceOnly(dever_cli::libs::HttpRegistry::official());
|
||||
let store = FixtureArtifactStore::default();
|
||||
let resolver = RegistryResolver {
|
||||
transport: &transport,
|
||||
store: &store,
|
||||
runtimes: BTreeMap::from([(Ecosystem::Pip, inputs.settings.runtime.clone())]),
|
||||
go_sumdb: dever_cli::libs::sumdb::ChecksumDatabase::official(None).unwrap(),
|
||||
build: Some(&session),
|
||||
};
|
||||
let requested = vec!["pip:simplejson@3.20.1".parse().unwrap()];
|
||||
let lock = dever_cli::libs::LibResolver::resolve(&resolver, &requested).unwrap();
|
||||
dever_cli::libs::doctor(&lock).unwrap();
|
||||
assert_eq!(lock.builds.len(), 1);
|
||||
assert!(!lock.builds[0].dependencies.libs.is_empty());
|
||||
let artifact = &lock
|
||||
.libs
|
||||
.iter()
|
||||
.find(|lib| lib.spec.name == "simplejson")
|
||||
.unwrap()
|
||||
.artifacts[0];
|
||||
let wheel = store
|
||||
.verify_exact(&artifact.sha256, artifact.bytes, &artifact.target)
|
||||
.unwrap();
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(wheel)).unwrap();
|
||||
assert!((0..archive.len()).any(|index| {
|
||||
let file = archive.by_index(index).unwrap();
|
||||
file.name().contains("_speedups.") && file.name().ends_with(".so")
|
||||
}));
|
||||
assert_eq!(
|
||||
lock,
|
||||
dever_cli::libs::LibResolver::resolve(&resolver, &requested).unwrap(),
|
||||
"same session must replay one exact built artifact"
|
||||
);
|
||||
publish_fixture_artifacts(&root.join("dependency"), &lock, &store);
|
||||
let mut altered = lock.clone();
|
||||
altered.builds[0].frontend = "0".repeat(64);
|
||||
assert!(dever_cli::libs::doctor(&altered).is_err());
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
use super::*;
|
||||
use dever_cli::libs::{LibResolver, LockedWorker, npm, resolve_workers};
|
||||
|
||||
pub(super) fn archive(
|
||||
name: &str,
|
||||
version: &str,
|
||||
mut manifest: serde_json::Value,
|
||||
extra: &[(&str, Vec<u8>)],
|
||||
) -> Vec<u8> {
|
||||
manifest["name"] = name.into();
|
||||
manifest["version"] = version.into();
|
||||
let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
let mut writer = tar::Builder::new(encoder);
|
||||
for (name, bytes) in [
|
||||
(
|
||||
"package/package.json",
|
||||
serde_json::to_vec(&manifest).unwrap(),
|
||||
),
|
||||
(
|
||||
"package/index.js",
|
||||
format!("module.exports = '{name}/{version}';\n").into_bytes(),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(extra.iter().map(|(name, bytes)| (*name, bytes.clone())))
|
||||
{
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
writer
|
||||
.append_data(&mut header, name, bytes.as_slice())
|
||||
.unwrap();
|
||||
}
|
||||
writer.into_inner().unwrap().finish().unwrap()
|
||||
}
|
||||
|
||||
pub(super) fn release(
|
||||
registry: &mut LocalRegistry,
|
||||
name: &str,
|
||||
version: &str,
|
||||
manifest: serde_json::Value,
|
||||
extra: &[(&str, Vec<u8>)],
|
||||
) {
|
||||
let archive = archive(name, version, manifest.clone(), extra);
|
||||
let index_path = (Ecosystem::Npm, format!("/{name}"));
|
||||
let mut index = registry
|
||||
.0
|
||||
.get(&index_path)
|
||||
.map(|bytes| serde_json::from_slice::<serde_json::Value>(bytes).unwrap())
|
||||
.unwrap_or(serde_json::json!({"versions":{}}));
|
||||
index["versions"][version] = serde_json::json!({});
|
||||
registry
|
||||
.0
|
||||
.insert(index_path, serde_json::to_vec(&index).unwrap());
|
||||
let mut document = manifest;
|
||||
document["name"] = name.into();
|
||||
document["version"] = version.into();
|
||||
let url = format!("https://mock/{name}/{version}.tgz");
|
||||
document["dist"] = serde_json::json!({"tarball":url,"integrity":format!("sha512-{}", base64::engine::general_purpose::STANDARD.encode(Sha512::digest(&archive)))});
|
||||
registry.0.insert(
|
||||
(Ecosystem::Npm, format!("/{name}/{version}")),
|
||||
serde_json::to_vec(&document).unwrap(),
|
||||
);
|
||||
registry.0.insert((Ecosystem::Npm, url), archive);
|
||||
}
|
||||
|
||||
fn resolved(registry: &LocalRegistry, requested: &[&str]) -> (LockFile, FixtureArtifactStore) {
|
||||
let store = FixtureArtifactStore::default();
|
||||
let lock = local_resolver(registry, &store)
|
||||
.resolve(
|
||||
&requested
|
||||
.iter()
|
||||
.map(|spec| spec.parse().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.unwrap();
|
||||
doctor(&lock).unwrap();
|
||||
assert_eq!(LockFile::decode(&lock.encode().unwrap()).unwrap(), lock);
|
||||
(lock, store)
|
||||
}
|
||||
|
||||
fn files(lock: &LockFile, store: &FixtureArtifactStore, index: usize) -> BTreeMap<String, Vec<u8>> {
|
||||
let archives = lock
|
||||
.libs
|
||||
.iter()
|
||||
.map(|lib| {
|
||||
(
|
||||
lib.spec.clone(),
|
||||
store
|
||||
.verify_exact(
|
||||
&lib.artifacts[0].sha256,
|
||||
lib.artifacts[0].bytes,
|
||||
&lib.artifacts[0].target,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
npm::files(&lock.npm[index], lock, |lib| {
|
||||
Ok(archives[&lib.spec].as_slice())
|
||||
})
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_versions_and_shared_packages_keep_distinct_peer_contexts() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
for (name, dependencies) in [
|
||||
("left", serde_json::json!({"plugin":"1.0.0","host":"1.0.0"})),
|
||||
(
|
||||
"right",
|
||||
serde_json::json!({"plugin":"1.0.0","host":"2.0.0"}),
|
||||
),
|
||||
] {
|
||||
release(
|
||||
&mut registry,
|
||||
name,
|
||||
"1.0.0",
|
||||
serde_json::json!({"dependencies":dependencies}),
|
||||
&[],
|
||||
);
|
||||
}
|
||||
release(
|
||||
&mut registry,
|
||||
"plugin",
|
||||
"1.0.0",
|
||||
serde_json::json!({"peerDependencies":{"host":">=1.0.0 <3.0.0"}}),
|
||||
&[],
|
||||
);
|
||||
for version in ["1.0.0", "2.0.0"] {
|
||||
release(&mut registry, "host", version, serde_json::json!({}), &[]);
|
||||
}
|
||||
let (lock, store) = resolved(®istry, &["npm:left@1.0.0", "npm:right@1.0.0"]);
|
||||
assert_eq!(lock.libs.len(), 5);
|
||||
let files = files(&lock, &store, 0);
|
||||
for (side, version) in [("left", "1.0.0"), ("right", "2.0.0")] {
|
||||
let node = lock.npm[0]
|
||||
.instances
|
||||
.iter()
|
||||
.find(|node| node.path == format!("node_modules/{side}/node_modules/plugin"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
node.edges[0].target.as_deref(),
|
||||
Some(format!("node_modules/{side}/node_modules/host").as_str())
|
||||
);
|
||||
assert!(
|
||||
String::from_utf8(
|
||||
files[&format!("node_modules/{side}/node_modules/host/index.js")].clone()
|
||||
)
|
||||
.unwrap()
|
||||
.contains(version)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_dependencies_override_regular_edges_and_record_target_or_absence() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
release(
|
||||
&mut registry,
|
||||
"root",
|
||||
"1.0.0",
|
||||
serde_json::json!({"dependencies":{"child":"1.0.0"},"optionalDependencies":{"child":"2.0.0","missing":"*","darwin":"*","musl":"*"}}),
|
||||
&[],
|
||||
);
|
||||
release(&mut registry, "child", "2.0.0", serde_json::json!({}), &[]);
|
||||
release(
|
||||
&mut registry,
|
||||
"darwin",
|
||||
"1.0.0",
|
||||
serde_json::json!({"os":["darwin"]}),
|
||||
&[],
|
||||
);
|
||||
release(
|
||||
&mut registry,
|
||||
"musl",
|
||||
"1.0.0",
|
||||
serde_json::json!({"os":["linux"],"libc":["musl"]}),
|
||||
&[],
|
||||
);
|
||||
let (lock, store) = resolved(®istry, &["npm:root@1.0.0"]);
|
||||
assert_eq!(lock.libs.len(), 2);
|
||||
let root = lock.npm[0]
|
||||
.instances
|
||||
.iter()
|
||||
.find(|node| node.spec.name == "root")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
root.edges
|
||||
.iter()
|
||||
.find(|edge| edge.name == "child")
|
||||
.unwrap()
|
||||
.kind,
|
||||
npm::Kind::Optional
|
||||
);
|
||||
for name in ["darwin", "musl"] {
|
||||
assert_eq!(
|
||||
root.edges
|
||||
.iter()
|
||||
.find(|edge| edge.name == name)
|
||||
.unwrap()
|
||||
.omission,
|
||||
Some(npm::Omission::Target)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
root.edges
|
||||
.iter()
|
||||
.find(|edge| edge.name == "missing")
|
||||
.unwrap()
|
||||
.omission,
|
||||
Some(npm::Omission::Unavailable)
|
||||
);
|
||||
assert!(
|
||||
!files(&lock, &store, 0)
|
||||
.keys()
|
||||
.any(|path| path.contains("darwin") || path.contains("musl"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_integrity_and_protocol_failures_are_never_omissions() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
release(
|
||||
&mut registry,
|
||||
"root",
|
||||
"1.0.0",
|
||||
serde_json::json!({"optionalDependencies":{"child":"*"}}),
|
||||
&[],
|
||||
);
|
||||
release(&mut registry, "child", "1.0.0", serde_json::json!({}), &[]);
|
||||
registry.0.insert(
|
||||
(Ecosystem::Npm, "https://mock/child/1.0.0.tgz".into()),
|
||||
b"tampered".to_vec(),
|
||||
);
|
||||
let store = FixtureArtifactStore::default();
|
||||
assert!(
|
||||
local_resolver(®istry, &store)
|
||||
.resolve(&["npm:root@1.0.0".parse().unwrap()])
|
||||
.unwrap_err()
|
||||
.contains("integrity mismatch")
|
||||
);
|
||||
release(
|
||||
&mut registry,
|
||||
"child",
|
||||
"1.0.0",
|
||||
serde_json::json!({"dependencies":{"other":"not-a-semver"}}),
|
||||
&[],
|
||||
);
|
||||
assert!(
|
||||
local_resolver(®istry, &store)
|
||||
.resolve(&["npm:root@1.0.0".parse().unwrap()])
|
||||
.unwrap_err()
|
||||
.contains("invalid npm range")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_peers_auto_install_and_backtrack_but_optional_peers_do_not() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
release(
|
||||
&mut registry,
|
||||
"root",
|
||||
"1.0.0",
|
||||
serde_json::json!({"dependencies":{"plugin":"*","host":"1.0.0"}}),
|
||||
&[],
|
||||
);
|
||||
release(
|
||||
&mut registry,
|
||||
"plugin",
|
||||
"2.0.0",
|
||||
serde_json::json!({"peerDependencies":{"host":"2.0.0"}}),
|
||||
&[],
|
||||
);
|
||||
release(
|
||||
&mut registry,
|
||||
"plugin",
|
||||
"1.0.0",
|
||||
serde_json::json!({"peerDependencies":{"host":"1.0.0","absent":"*"},"peerDependenciesMeta":{"absent":{"optional":true}}}),
|
||||
&[],
|
||||
);
|
||||
for version in ["1.0.0", "2.0.0"] {
|
||||
release(&mut registry, "host", version, serde_json::json!({}), &[]);
|
||||
}
|
||||
let (lock, _) = resolved(®istry, &["npm:root@1.0.0"]);
|
||||
let plugin = lock.npm[0]
|
||||
.instances
|
||||
.iter()
|
||||
.find(|node| node.spec.name == "plugin")
|
||||
.unwrap();
|
||||
assert_eq!(plugin.spec.version, "1.0.0");
|
||||
assert_eq!(
|
||||
plugin
|
||||
.edges
|
||||
.iter()
|
||||
.find(|edge| edge.name == "absent")
|
||||
.unwrap()
|
||||
.omission,
|
||||
Some(npm::Omission::OptionalPeerAbsent)
|
||||
);
|
||||
let (lock, _) = resolved(®istry, &["npm:plugin@1.0.0"]);
|
||||
assert!(
|
||||
lock.npm[0]
|
||||
.instances
|
||||
.iter()
|
||||
.any(|node| node.path == "node_modules/host")
|
||||
);
|
||||
let store = FixtureArtifactStore::default();
|
||||
assert!(
|
||||
local_resolver(®istry, &store)
|
||||
.resolve(&[
|
||||
"npm:plugin@2.0.0".parse().unwrap(),
|
||||
"npm:host@1.0.0".parse().unwrap()
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_transitive_packages_are_bound_to_parent_and_never_downloaded() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
let extra = vec![
|
||||
(
|
||||
"package/node_modules/b/package.json",
|
||||
br#"{"name":"b","version":"1.0.0","dependencies":{"c":"2.0.0"}}"#.to_vec(),
|
||||
),
|
||||
(
|
||||
"package/node_modules/b/index.js",
|
||||
b"module.exports = require('c');".to_vec(),
|
||||
),
|
||||
(
|
||||
"package/node_modules/c/package.json",
|
||||
br#"{"name":"c","version":"2.0.0"}"#.to_vec(),
|
||||
),
|
||||
(
|
||||
"package/node_modules/c/index.js",
|
||||
b"module.exports = 42;".to_vec(),
|
||||
),
|
||||
];
|
||||
release(
|
||||
&mut registry,
|
||||
"root",
|
||||
"1.0.0",
|
||||
serde_json::json!({"dependencies":{"b":"1.0.0"},"bundleDependencies":["b"]}),
|
||||
&extra,
|
||||
);
|
||||
let (lock, store) = resolved(®istry, &["npm:root@1.0.0"]);
|
||||
assert_eq!(lock.libs.len(), 1);
|
||||
assert_eq!(lock.npm[0].instances.len(), 3);
|
||||
assert_eq!(
|
||||
files(&lock, &store, 0)["node_modules/root/node_modules/c/index.js"],
|
||||
b"module.exports = 42;"
|
||||
);
|
||||
release(
|
||||
&mut registry,
|
||||
"root",
|
||||
"1.0.0",
|
||||
serde_json::json!({}),
|
||||
&extra,
|
||||
);
|
||||
assert!(
|
||||
local_resolver(®istry, &store)
|
||||
.resolve(&["npm:root@1.0.0".parse().unwrap()])
|
||||
.unwrap_err()
|
||||
.contains("undeclared")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_root_sets_keep_separate_npm_environments_and_detect_lock_tampering() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
release(
|
||||
&mut registry,
|
||||
"plugin",
|
||||
"1.0.0",
|
||||
serde_json::json!({"peerDependencies":{"host":"*"}}),
|
||||
&[],
|
||||
);
|
||||
for version in ["1.0.0", "2.0.0"] {
|
||||
release(&mut registry, "host", version, serde_json::json!({}), &[]);
|
||||
}
|
||||
let store = FixtureArtifactStore::default();
|
||||
let worker = |adapter: &str, version: &str| LockedWorker {
|
||||
port: "sample.worker".into(),
|
||||
adapter: adapter.into(),
|
||||
ecosystem: "npm".into(),
|
||||
runtime: Some(registry_runtime(Ecosystem::Npm).pack),
|
||||
entry: format!("worker/{adapter}.mjs"),
|
||||
schema: "checked-port".into(),
|
||||
capabilities: vec![],
|
||||
operations: vec!["read".into()],
|
||||
libs: vec![
|
||||
"npm:plugin@1.0.0".parse().unwrap(),
|
||||
format!("npm:host@{version}").parse().unwrap(),
|
||||
],
|
||||
};
|
||||
let lock = resolve_workers(
|
||||
&local_resolver(®istry, &store),
|
||||
&[],
|
||||
vec![worker("first", "1.0.0"), worker("second", "2.0.0")],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(lock.npm.len(), 2);
|
||||
for index in 0..2 {
|
||||
assert!(files(&lock, &store, index).contains_key("node_modules/plugin/index.js"));
|
||||
}
|
||||
let mut changed = lock.clone();
|
||||
changed.npm[0].instances[0].path = "../escape".into();
|
||||
assert!(doctor(&changed).is_err());
|
||||
let mut changed = lock.clone();
|
||||
changed.npm[0]
|
||||
.instances
|
||||
.iter_mut()
|
||||
.find(|node| node.spec.name == "plugin")
|
||||
.unwrap()
|
||||
.edges[0]
|
||||
.target = Some("node_modules/missing".into());
|
||||
assert!(doctor(&changed).is_err());
|
||||
let mut changed = lock;
|
||||
changed.npm[0]
|
||||
.instances
|
||||
.iter_mut()
|
||||
.find(|node| node.spec.name == "plugin")
|
||||
.unwrap()
|
||||
.edges
|
||||
.clear();
|
||||
let archives = changed
|
||||
.libs
|
||||
.iter()
|
||||
.map(|lib| {
|
||||
(
|
||||
lib.spec.clone(),
|
||||
store
|
||||
.verify_exact(
|
||||
&lib.artifacts[0].sha256,
|
||||
lib.artifacts[0].bytes,
|
||||
&lib.artifacts[0].target,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
assert!(
|
||||
npm::files(&changed.npm[0], &changed, |lib| Ok(
|
||||
archives[&lib.spec].as_slice()
|
||||
))
|
||||
.unwrap_err()
|
||||
.contains("differs from package.json")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_instances_cannot_switch_to_another_parents_same_version_package() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
for name in ["a", "c"] {
|
||||
release(
|
||||
&mut registry,
|
||||
name,
|
||||
"1.0.0",
|
||||
serde_json::json!({"dependencies":{"b":"1.0.0"},"bundleDependencies":["b"]}),
|
||||
&[
|
||||
(
|
||||
"package/node_modules/b/package.json",
|
||||
br#"{"name":"b","version":"1.0.0"}"#.to_vec(),
|
||||
),
|
||||
(
|
||||
"package/node_modules/b/index.js",
|
||||
format!("module.exports = '{name}';").into_bytes(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
release(&mut registry, "b", "1.0.0", serde_json::json!({}), &[]);
|
||||
let (original, store) = resolved(®istry, &["npm:a@1.0.0", "npm:c@1.0.0", "npm:b@1.0.0"]);
|
||||
let archives = original
|
||||
.libs
|
||||
.iter()
|
||||
.map(|lib| {
|
||||
(
|
||||
lib.spec.clone(),
|
||||
store
|
||||
.verify_exact(
|
||||
&lib.artifacts[0].sha256,
|
||||
lib.artifacts[0].bytes,
|
||||
&lib.artifacts[0].target,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
for (archive, prefix) in [
|
||||
("npm:c@1.0.0", "package/node_modules/b/"),
|
||||
("npm:b@1.0.0", "package/"),
|
||||
] {
|
||||
let mut changed = original.clone();
|
||||
changed.npm[0]
|
||||
.instances
|
||||
.iter_mut()
|
||||
.find(|node| node.path == "node_modules/a/node_modules/b")
|
||||
.unwrap()
|
||||
.source = npm::Source {
|
||||
archive: archive.parse().unwrap(),
|
||||
prefix: prefix.into(),
|
||||
};
|
||||
assert!(
|
||||
doctor(&changed)
|
||||
.unwrap_err()
|
||||
.contains("parent archive installation")
|
||||
);
|
||||
assert!(
|
||||
npm::files(&changed.npm[0], &changed, |lib| Ok(
|
||||
archives[&lib.spec].as_slice()
|
||||
))
|
||||
.unwrap_err()
|
||||
.contains("parent archive installation")
|
||||
);
|
||||
// Removing the locked layout cannot hide the original archive binding.
|
||||
for node in &mut changed.npm[0].instances {
|
||||
node.bundled.clear();
|
||||
}
|
||||
assert!(
|
||||
npm::files(&changed.npm[0], &changed, |lib| Ok(
|
||||
archives[&lib.spec].as_slice()
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_and_optional_dependencies_override_same_named_peers() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
release(&mut registry, "host", "2.0.0", serde_json::json!({}), &[]);
|
||||
release(
|
||||
&mut registry,
|
||||
"plugin",
|
||||
"1.0.0",
|
||||
serde_json::json!({"dependencies":{"host":"2.0.0"},"peerDependencies":{"host":"1.0.0"}}),
|
||||
&[],
|
||||
);
|
||||
let (lock, _) = resolved(®istry, &["npm:plugin@1.0.0", "npm:host@2.0.0"]);
|
||||
let plugin = lock.npm[0]
|
||||
.instances
|
||||
.iter()
|
||||
.find(|node| node.spec.name == "plugin")
|
||||
.unwrap();
|
||||
assert_eq!(plugin.edges.len(), 1);
|
||||
assert_eq!(plugin.edges[0].kind, npm::Kind::Dependency);
|
||||
assert_eq!(
|
||||
plugin.edges[0].target.as_deref(),
|
||||
Some("node_modules/plugin/node_modules/host")
|
||||
);
|
||||
release(
|
||||
&mut registry,
|
||||
"plugin",
|
||||
"1.0.0",
|
||||
serde_json::json!({"optionalDependencies":{"absent":"*"},"peerDependencies":{"absent":"1.0.0"}}),
|
||||
&[],
|
||||
);
|
||||
let (lock, _) = resolved(®istry, &["npm:plugin@1.0.0"]);
|
||||
assert_eq!(lock.npm[0].instances[0].edges.len(), 1);
|
||||
assert_eq!(lock.npm[0].instances[0].edges[0].kind, npm::Kind::Optional);
|
||||
assert_eq!(
|
||||
lock.npm[0].instances[0].edges[0].omission,
|
||||
Some(npm::Omission::Unavailable)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_root_prunes_its_entire_dependency_cycle() {
|
||||
for bundled in [false, true] {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
release(&mut registry, "z", "1.0.0", serde_json::json!({}), &[]);
|
||||
let b = serde_json::json!({"name":"b","version":"1.0.0","dependencies":{"a":"1.0.0"}});
|
||||
let mut a = serde_json::json!({"dependencies":{"b":"1.0.0"}});
|
||||
let extra = if bundled {
|
||||
a["bundleDependencies"] = serde_json::json!(["b"]);
|
||||
vec![(
|
||||
"package/node_modules/b/package.json",
|
||||
serde_json::to_vec(&b).unwrap(),
|
||||
)]
|
||||
} else {
|
||||
release(&mut registry, "b", "1.0.0", b, &[]);
|
||||
vec![]
|
||||
};
|
||||
release(&mut registry, "a", "1.0.0", a, &extra);
|
||||
let (lock, _) = resolved(®istry, &["npm:a@1.0.0", "npm:z@1.0.0"]);
|
||||
let root = TemporaryDirectory::new();
|
||||
fs::create_dir(root.path().join("config")).unwrap();
|
||||
fs::write(
|
||||
root.path().join("config/setting.json"),
|
||||
br#"{"lib":["npm:a@1.0.0","npm:z@1.0.0"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
lock.write_atomic(root.path()).unwrap();
|
||||
dever_cli::libs::execute("remove", root.path(), &["npm:a@1.0.0".into()]).unwrap();
|
||||
let retained =
|
||||
LockFile::decode(&fs::read(root.path().join("dever.lock")).unwrap()).unwrap();
|
||||
assert_eq!(retained.libs.len(), 1);
|
||||
assert_eq!(retained.libs[0].spec.name, "z");
|
||||
assert_eq!(retained.npm[0].instances.len(), 1);
|
||||
doctor(&retained).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_dependency_still_required_by_a_remaining_root_is_rejected() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
release(
|
||||
&mut registry,
|
||||
"a",
|
||||
"1.0.0",
|
||||
serde_json::json!({"dependencies":{"b":"1.0.0"}}),
|
||||
&[],
|
||||
);
|
||||
release(&mut registry, "b", "1.0.0", serde_json::json!({}), &[]);
|
||||
let (lock, _) = resolved(®istry, &["npm:a@1.0.0", "npm:b@1.0.0"]);
|
||||
let root = TemporaryDirectory::new();
|
||||
fs::create_dir(root.path().join("config")).unwrap();
|
||||
let setting = br#"{"lib":["npm:a@1.0.0","npm:b@1.0.0"]}"#;
|
||||
fs::write(root.path().join("config/setting.json"), setting).unwrap();
|
||||
lock.write_atomic(root.path()).unwrap();
|
||||
let before = fs::read(root.path().join("dever.lock")).unwrap();
|
||||
assert!(
|
||||
dever_cli::libs::execute("remove", root.path(), &["npm:b@1.0.0".into()])
|
||||
.unwrap_err()
|
||||
.contains("required by")
|
||||
);
|
||||
assert_eq!(fs::read(root.path().join("dever.lock")).unwrap(), before);
|
||||
assert_eq!(
|
||||
fs::read(root.path().join("config/setting.json")).unwrap(),
|
||||
setting
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_archive_instances_are_bounded_before_materialization_clones() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
release(
|
||||
&mut registry,
|
||||
"leaf",
|
||||
"1.0.0",
|
||||
serde_json::json!({}),
|
||||
&[("package/payload.js", vec![b'x'; 2 * 1024 * 1024])],
|
||||
);
|
||||
let mut roots = Vec::new();
|
||||
for number in 0..129 {
|
||||
let name = format!("root-{number}");
|
||||
release(
|
||||
&mut registry,
|
||||
&name,
|
||||
"1.0.0",
|
||||
serde_json::json!({"dependencies":{"leaf":"1.0.0"}}),
|
||||
&[],
|
||||
);
|
||||
roots.push(format!("npm:{name}@1.0.0"));
|
||||
}
|
||||
let (lock, store) = resolved(
|
||||
®istry,
|
||||
&roots.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
assert_eq!(lock.npm[0].instances.len(), 258);
|
||||
let archives = lock
|
||||
.libs
|
||||
.iter()
|
||||
.map(|lib| {
|
||||
(
|
||||
lib.spec.clone(),
|
||||
store
|
||||
.verify_exact(
|
||||
&lib.artifacts[0].sha256,
|
||||
lib.artifacts[0].bytes,
|
||||
&lib.artifacts[0].target,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
assert!(
|
||||
npm::files(
|
||||
&lock.npm[0],
|
||||
&lock,
|
||||
|lib| Ok(archives[&lib.spec].as_slice())
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("tree file/byte budget")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
use super::*;
|
||||
use dever_cli::libs::build::{Inputs, NativeRejection, Receipt, Session, pack};
|
||||
use dever_cli::libs::{LibResolver, npm};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Settings {
|
||||
runtime: RegistryRuntime,
|
||||
runtime_file: String,
|
||||
python: RegistryRuntime,
|
||||
python_file: String,
|
||||
build: pack::Descriptor,
|
||||
build_file: String,
|
||||
}
|
||||
|
||||
struct AuthorInputs {
|
||||
root: PathBuf,
|
||||
settings: Settings,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_lifecycle_output_preserves_locked_identity_and_instance_ownership() {
|
||||
let environment = npm::Environment::single("npm:app@1.0.0".parse().unwrap());
|
||||
let files = vec![
|
||||
(
|
||||
"node_modules/app/package.json".into(),
|
||||
br#"{"name":"app","version":"1.0.0"}"#.to_vec(),
|
||||
),
|
||||
(
|
||||
"node_modules/app/generated.js".into(),
|
||||
b"module.exports=42;".to_vec(),
|
||||
),
|
||||
];
|
||||
npm::validate_built_files(&environment, &files).unwrap();
|
||||
let mut changed = files.clone();
|
||||
changed[0].1 =
|
||||
br#"{"name":"app","version":"1.0.0","dependencies":{"injected":"1.0.0"}}"#.to_vec();
|
||||
assert!(npm::validate_built_files(&environment, &changed).is_err());
|
||||
let mut changed = files.clone();
|
||||
changed.push(("node_modules/injected/index.js".into(), vec![]));
|
||||
assert!(npm::validate_built_files(&environment, &changed).is_err());
|
||||
let mut changed = files.clone();
|
||||
changed.push(("node_modules/app/../escape".into(), vec![]));
|
||||
assert!(npm::validate_built_files(&environment, &changed).is_err());
|
||||
assert!(npm::validate_built_files(&environment, &files[1..]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_registry_install_hooks_do_not_run_publishing_lifecycle() {
|
||||
let metadata = |script: &str| {
|
||||
vec![(
|
||||
"node_modules/app/package.json".into(),
|
||||
serde_json::to_vec(&serde_json::json!({"scripts":{script:"node command.js"}})).unwrap(),
|
||||
)]
|
||||
};
|
||||
for script in ["preinstall", "install", "postinstall"] {
|
||||
assert!(npm::requires_build(&metadata(script)).unwrap());
|
||||
}
|
||||
for script in ["prepare", "prepack", "prepublish"] {
|
||||
assert!(!npm::requires_build(&metadata(script)).unwrap());
|
||||
}
|
||||
assert!(
|
||||
npm::requires_build(&[("node_modules/app/binding.gyp".into(), b"{}".to_vec())]).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
fn locked_installation_fixture() -> (LockFile, Vec<u8>) {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
npm_tests::release(
|
||||
&mut registry,
|
||||
"app",
|
||||
"1.0.0",
|
||||
serde_json::json!({}),
|
||||
&[(
|
||||
"package/prebuilds/foreign/addon.node",
|
||||
b"foreign-object".to_vec(),
|
||||
)],
|
||||
);
|
||||
npm_tests::release(&mut registry, "other", "1.0.0", serde_json::json!({}), &[]);
|
||||
let store = FixtureArtifactStore::default();
|
||||
let mut lock = local_resolver(®istry, &store)
|
||||
.resolve(&[
|
||||
"npm:app@1.0.0".parse().unwrap(),
|
||||
"npm:other@1.0.0".parse().unwrap(),
|
||||
])
|
||||
.unwrap();
|
||||
let sources = lock
|
||||
.libs
|
||||
.iter()
|
||||
.flat_map(|lib| lib.artifacts.clone())
|
||||
.collect();
|
||||
let archives = lock
|
||||
.libs
|
||||
.iter()
|
||||
.map(|lib| {
|
||||
(
|
||||
lib.spec.clone(),
|
||||
store
|
||||
.verify_exact(
|
||||
&lib.artifacts[0].sha256,
|
||||
lib.artifacts[0].bytes,
|
||||
&lib.artifacts[0].target,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let files = npm::build_files(
|
||||
&lock.npm[0],
|
||||
&lock,
|
||||
|lib| Ok(archives[&lib.spec].as_slice()),
|
||||
)
|
||||
.unwrap();
|
||||
let mut writer = tar::Builder::new(flate2::write::GzEncoder::new(
|
||||
Vec::new(),
|
||||
flate2::Compression::default(),
|
||||
));
|
||||
for (path, bytes) in files {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
writer
|
||||
.append_data(&mut header, path, bytes.as_slice())
|
||||
.unwrap();
|
||||
}
|
||||
let bytes = writer.into_inner().unwrap().finish().unwrap();
|
||||
let runtime = registry_runtime(Ecosystem::Npm);
|
||||
let python = registry_runtime(Ecosystem::Pip);
|
||||
let hash = digest(&bytes);
|
||||
let receipt = Receipt {
|
||||
ecosystem: Ecosystem::Npm,
|
||||
roots: lock.npm[0].roots.clone(),
|
||||
sources,
|
||||
graph: Some(lock.npm[0].clone()),
|
||||
runtime: runtime.pack.clone(),
|
||||
auxiliary_runtime: Some(python.pack.clone()),
|
||||
native_entries: Default::default(),
|
||||
native_rejections: BTreeMap::from([(
|
||||
"node_modules/app/prebuilds/foreign/addon.node".into(),
|
||||
NativeRejection::Format,
|
||||
)]),
|
||||
python_markers: None,
|
||||
tools: pack::Descriptor {
|
||||
format: "dever-registry-build-v1".into(),
|
||||
ecosystem: Ecosystem::Npm,
|
||||
target: runtime.target.clone(),
|
||||
runtime_sha256: runtime.pack.sha256.clone(),
|
||||
python_runtime_sha256: Some(python.pack.sha256),
|
||||
sha256: "a".repeat(64),
|
||||
},
|
||||
frontend: digest(
|
||||
&[
|
||||
include_bytes!("../../../crates/dever-cli/src/libs/build/npm.js").as_slice(),
|
||||
include_bytes!("../../../crates/dever-cli/src/libs/build/npm/probe.cjs").as_slice(),
|
||||
]
|
||||
.concat(),
|
||||
),
|
||||
python: None,
|
||||
dynamic_requires: vec![],
|
||||
dependencies: Box::new(LockFile::new(vec![]).unwrap()),
|
||||
config_settings: BTreeMap::new(),
|
||||
output: dever_cli::libs::LockedArtifact {
|
||||
target: runtime.target,
|
||||
path: format!("build/npm/{hash}/installation.tgz"),
|
||||
bytes: bytes.len() as u64,
|
||||
sha256: hash,
|
||||
},
|
||||
};
|
||||
lock.npm[0].build = Some(receipt.identity().unwrap());
|
||||
lock.builds.push(receipt);
|
||||
doctor(&lock).unwrap();
|
||||
(lock, bytes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_expanded_outputs_are_pruned_only_when_no_raw_consumer_remains() {
|
||||
use dever_cli::libs::{LockedWorker, prune_expanded_resources};
|
||||
use dever_core::native::EmbeddedResource;
|
||||
|
||||
let (mut lock, bytes) = locked_installation_fixture();
|
||||
let roots = lock.npm[0].roots.clone();
|
||||
let other = vec!["npm:other@1.0.0".parse().unwrap()];
|
||||
lock.npm = npm::retain(&lock, &[roots.clone(), other.clone()]).unwrap();
|
||||
let output = &lock.builds[0].output;
|
||||
let managed = LockedWorker {
|
||||
port: "sample.worker".into(),
|
||||
adapter: "default".into(),
|
||||
ecosystem: "npm".into(),
|
||||
runtime: Some(registry_runtime(Ecosystem::Npm).pack),
|
||||
entry: "worker/main.cjs".into(),
|
||||
schema: "checked".into(),
|
||||
capabilities: vec![],
|
||||
operations: vec!["probe".into()],
|
||||
libs: roots.clone(),
|
||||
};
|
||||
let resource = EmbeddedResource {
|
||||
path: output.path.clone(),
|
||||
bytes,
|
||||
sha256: output.sha256.clone(),
|
||||
executable: false,
|
||||
};
|
||||
let mut resources = vec![resource.clone()];
|
||||
prune_expanded_resources(&lock, std::slice::from_ref(&managed), &[], &mut resources).unwrap();
|
||||
assert!(
|
||||
resources.is_empty(),
|
||||
"expanded installation archive leaked into program"
|
||||
);
|
||||
|
||||
// A projected environment shares its original build output with the full
|
||||
// Worker. Retention follows current consumers, not the receipt's old roots.
|
||||
for declared in [roots, other] {
|
||||
let mut resources = vec![resource.clone()];
|
||||
prune_expanded_resources(
|
||||
&lock,
|
||||
std::slice::from_ref(&managed),
|
||||
&declared,
|
||||
&mut resources,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(resources.len(), 1);
|
||||
}
|
||||
let mut exec = managed.clone();
|
||||
exec.ecosystem = "exec".into();
|
||||
exec.runtime = None;
|
||||
let mut resources = vec![resource];
|
||||
prune_expanded_resources(&lock, &[managed, exec], &[], &mut resources).unwrap();
|
||||
assert_eq!(resources.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_native_admission_accounts_every_addon_and_prunes_unsupported_objects() {
|
||||
let (lock, bytes) = locked_installation_fixture();
|
||||
let files = dever_cli::libs::build::npm_installation(&lock, &lock.npm[0], &bytes).unwrap();
|
||||
assert!(files.iter().all(|(path, _, _)| !path.ends_with(".node")));
|
||||
let mut missing = lock.clone();
|
||||
missing.builds[0].native_rejections.clear();
|
||||
missing.npm[0].build = Some(missing.builds[0].identity().unwrap());
|
||||
assert!(
|
||||
dever_cli::libs::build::npm_installation(&missing, &missing.npm[0], &bytes)
|
||||
.unwrap_err()
|
||||
.contains("exact addon files")
|
||||
);
|
||||
let mut corrupted = bytes.clone();
|
||||
corrupted[10] ^= 1;
|
||||
assert!(dever_cli::libs::build::npm_installation(&lock, &lock.npm[0], &corrupted).is_err());
|
||||
for change in ["tools", "graph", "runtime"] {
|
||||
let mut changed = lock.clone();
|
||||
match change {
|
||||
"tools" => changed.builds[0].tools.sha256 = "not-a-hash".into(),
|
||||
"graph" => changed.builds[0].graph.as_mut().unwrap().instances.clear(),
|
||||
_ => changed.builds[0].runtime.sha256 = "b".repeat(64),
|
||||
}
|
||||
changed.npm[0].build = Some(changed.builds[0].identity().unwrap());
|
||||
assert!(
|
||||
doctor(&changed).is_err(),
|
||||
"accepted internally inconsistent {change}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_locked_output_projects_retained_roots_without_rerunning_hooks() {
|
||||
let (lock, bytes) = locked_installation_fixture();
|
||||
let project = TemporaryDirectory::new();
|
||||
fs::create_dir(project.path().join("config")).unwrap();
|
||||
fs::write(
|
||||
project.path().join("config/setting.json"),
|
||||
br#"{"lib":["npm:app@1.0.0","npm:other@1.0.0"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
lock.write_atomic(project.path()).unwrap();
|
||||
dever_cli::libs::execute("remove", project.path(), &["npm:app@1.0.0".into()]).unwrap();
|
||||
let retained = LockFile::decode(&fs::read(project.path().join("dever.lock")).unwrap()).unwrap();
|
||||
doctor(&retained).unwrap();
|
||||
let files =
|
||||
dever_cli::libs::build::npm_installation(&retained, &retained.npm[0], &bytes).unwrap();
|
||||
assert_eq!(retained.builds, lock.builds);
|
||||
assert!(
|
||||
files
|
||||
.iter()
|
||||
.all(|(path, _, _)| path.starts_with("node_modules/other/"))
|
||||
);
|
||||
}
|
||||
|
||||
impl AuthorInputs {
|
||||
fn load() -> Self {
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/npm-build");
|
||||
let settings =
|
||||
serde_json::from_slice(&fs::read(root.join("config/setting.json")).unwrap()).unwrap();
|
||||
Self { root, settings }
|
||||
}
|
||||
}
|
||||
|
||||
impl Inputs for AuthorInputs {
|
||||
fn runtime(&self, ecosystem: Ecosystem) -> Result<(RegistryRuntime, Vec<u8>), String> {
|
||||
let (runtime, path) = match ecosystem {
|
||||
Ecosystem::Pip => (&self.settings.python, &self.settings.python_file),
|
||||
Ecosystem::Npm => (&self.settings.runtime, &self.settings.runtime_file),
|
||||
_ => return Err("fixture only supplies Node and auxiliary Python".into()),
|
||||
};
|
||||
let bytes = fs::read(self.root.join(path)).map_err(|error| error.to_string())?;
|
||||
if digest(&bytes) != runtime.pack.sha256 {
|
||||
return Err("author runtime drift".into());
|
||||
}
|
||||
Ok((runtime.clone(), bytes))
|
||||
}
|
||||
fn tools(&self, ecosystem: Ecosystem) -> Result<(pack::Descriptor, Vec<u8>), String> {
|
||||
if ecosystem != Ecosystem::Npm {
|
||||
return Err("fixture only supplies npm tools".into());
|
||||
}
|
||||
Ok((
|
||||
self.settings.build.clone(),
|
||||
fs::read(self.root.join(&self.settings.build_file))
|
||||
.map_err(|error| error.to_string())?,
|
||||
))
|
||||
}
|
||||
fn assets(&self) -> Result<Vec<(String, Vec<u8>)>, String> {
|
||||
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
Ok(build_tests::sandbox::assets(&workspace)
|
||||
.into_iter()
|
||||
.map(|asset| {
|
||||
(
|
||||
asset.path.trim_start_matches("sandbox/").into(),
|
||||
asset.bytes,
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
fn diagnostic(&self, output: &[u8]) {
|
||||
eprintln!("{}", String::from_utf8_lossy(output));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit author operation: assembles hashed npm/compiler/header inputs; no network or host execution"]
|
||||
fn prepare_npm_build_tool_fixture() {
|
||||
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let runtime = |name: &str| {
|
||||
let root = workspace.join(format!("target/{name}-managed"));
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(root.join("config/setting.json")).unwrap()).unwrap();
|
||||
let runtime: RegistryRuntime = serde_json::from_value(config["runtime"].clone()).unwrap();
|
||||
assert_eq!(
|
||||
digest(&fs::read(root.join("runtime.pack")).unwrap()),
|
||||
runtime.pack.sha256
|
||||
);
|
||||
runtime
|
||||
};
|
||||
let node = runtime("npm");
|
||||
let python = runtime("python");
|
||||
let root = workspace.join("target/npm-build");
|
||||
let build = build_tests::prepare_tool_pack(Ecosystem::Npm, &node, Some(&python), &root);
|
||||
let settings = Settings {
|
||||
runtime: node,
|
||||
runtime_file: "../npm-managed/runtime.pack".into(),
|
||||
python,
|
||||
python_file: "../python-managed/runtime.pack".into(),
|
||||
build,
|
||||
build_file: "build.pack".into(),
|
||||
};
|
||||
fs::write(
|
||||
root.join("config/setting.json"),
|
||||
serde_json::to_vec_pretty(&settings).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn resolver<'a>(
|
||||
inputs: &AuthorInputs,
|
||||
session: &'a Session<'a>,
|
||||
transport: &'a dyn RegistryTransport,
|
||||
store: &'a FixtureArtifactStore,
|
||||
) -> RegistryResolver<'a> {
|
||||
RegistryResolver {
|
||||
transport,
|
||||
store,
|
||||
runtimes: BTreeMap::from([(Ecosystem::Npm, inputs.settings.runtime.clone())]),
|
||||
go_sumdb: dever_cli::libs::sumdb::ChecksumDatabase::official(None).unwrap(),
|
||||
build: Some(session),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit official npm native source build; network only in registry resolver, never in lifecycle sandbox"]
|
||||
fn builds_official_npm_addon_with_locked_lifecycle() {
|
||||
let inputs = AuthorInputs::load();
|
||||
let session = Session::new(&inputs);
|
||||
let transport = dever_cli::libs::HttpRegistry::official();
|
||||
let store = FixtureArtifactStore::default();
|
||||
let resolver = resolver(&inputs, &session, &transport, &store);
|
||||
let roots = vec!["npm:bufferutil@4.0.9".parse().unwrap()];
|
||||
let lock = resolver.resolve(&roots).unwrap();
|
||||
doctor(&lock).unwrap();
|
||||
assert_eq!(lock.builds.len(), 1);
|
||||
let receipt = &lock.builds[0];
|
||||
assert!(
|
||||
receipt
|
||||
.native_entries
|
||||
.contains("node_modules/bufferutil/build/Release/bufferutil.node")
|
||||
);
|
||||
let bytes = store
|
||||
.verify_exact(
|
||||
&receipt.output.sha256,
|
||||
receipt.output.bytes,
|
||||
&receipt.output.target,
|
||||
)
|
||||
.unwrap();
|
||||
let files = dever_cli::libs::build::npm_installation(&lock, &lock.npm[0], &bytes).unwrap();
|
||||
assert!(files.iter().any(
|
||||
|(path, bytes, _)| path.ends_with("/build/Release/bufferutil.node")
|
||||
&& bytes.starts_with(b"\x7fELF")
|
||||
));
|
||||
assert_eq!(lock, resolver.resolve(&roots).unwrap());
|
||||
publish_fixture_artifacts(&inputs.root.join("dependency"), &lock, &store);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit managed npm hooks; uses local registry and real signed author runtime/tools"]
|
||||
fn npm_lifecycle_preserves_optional_failures_bins_and_offline_remove() {
|
||||
let inputs = AuthorInputs::load();
|
||||
let session = Session::new(&inputs);
|
||||
let mut transport = LocalRegistry(BTreeMap::new());
|
||||
npm_tests::release(&mut transport, "helper", "1.0.0", serde_json::json!({"bin":{"build-probe":"cli.js"}}), &[("package/cli.js", b"#!/usr/bin/env node\nrequire('fs').writeFileSync('generated.js','module.exports = 42;');\n".to_vec())]);
|
||||
npm_tests::release(
|
||||
&mut transport,
|
||||
"broken",
|
||||
"1.0.0",
|
||||
serde_json::json!({"dependencies":{"cycle":"1.0.0"},"scripts":{"install":"node -e 'process.exit(7)'"}}),
|
||||
&[],
|
||||
);
|
||||
npm_tests::release(
|
||||
&mut transport,
|
||||
"cycle",
|
||||
"1.0.0",
|
||||
serde_json::json!({"dependencies":{"broken":"1.0.0"}}),
|
||||
&[],
|
||||
);
|
||||
npm_tests::release(
|
||||
&mut transport,
|
||||
"app",
|
||||
"1.0.0",
|
||||
serde_json::json!({"dependencies":{"helper":"1.0.0"},"optionalDependencies":{"broken":"1.0.0"},"scripts":{"postinstall":"build-probe"}}),
|
||||
&[],
|
||||
);
|
||||
npm_tests::release(&mut transport, "other", "1.0.0", serde_json::json!({}), &[]);
|
||||
let store = FixtureArtifactStore::default();
|
||||
let resolver = resolver(&inputs, &session, &transport, &store);
|
||||
let lock = resolver
|
||||
.resolve(&[
|
||||
"npm:app@1.0.0".parse().unwrap(),
|
||||
"npm:other@1.0.0".parse().unwrap(),
|
||||
])
|
||||
.unwrap();
|
||||
doctor(&lock).unwrap();
|
||||
assert!(
|
||||
!lock
|
||||
.libs
|
||||
.iter()
|
||||
.any(|lib| matches!(lib.spec.name.as_str(), "broken" | "cycle"))
|
||||
);
|
||||
assert!(
|
||||
lock.npm[0]
|
||||
.instances
|
||||
.iter()
|
||||
.flat_map(|node| &node.edges)
|
||||
.any(|edge| edge.omission == Some(npm::Omission::BuildFailure))
|
||||
);
|
||||
let receipt = &lock.builds[0];
|
||||
let bytes = store
|
||||
.verify_exact(
|
||||
&receipt.output.sha256,
|
||||
receipt.output.bytes,
|
||||
&receipt.output.target,
|
||||
)
|
||||
.unwrap();
|
||||
let files = dever_cli::libs::build::npm_installation(&lock, &lock.npm[0], &bytes).unwrap();
|
||||
assert!(
|
||||
files
|
||||
.iter()
|
||||
.any(|(path, bytes, _)| path == "node_modules/app/generated.js"
|
||||
&& bytes == b"module.exports = 42;")
|
||||
);
|
||||
assert!(
|
||||
resolver
|
||||
.resolve(&["npm:broken@1.0.0".parse().unwrap()])
|
||||
.unwrap_err()
|
||||
.contains("failed")
|
||||
);
|
||||
let project = TemporaryDirectory::new();
|
||||
fs::create_dir(project.path().join("config")).unwrap();
|
||||
fs::write(
|
||||
project.path().join("config/setting.json"),
|
||||
br#"{"lib":["npm:app@1.0.0","npm:other@1.0.0"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
lock.write_atomic(project.path()).unwrap();
|
||||
dever_cli::libs::execute("remove", project.path(), &["npm:app@1.0.0".into()]).unwrap();
|
||||
let retained = LockFile::decode(&fs::read(project.path().join("dever.lock")).unwrap()).unwrap();
|
||||
doctor(&retained).unwrap();
|
||||
let files =
|
||||
dever_cli::libs::build::npm_installation(&retained, &retained.npm[0], &bytes).unwrap();
|
||||
assert!(
|
||||
files
|
||||
.iter()
|
||||
.all(|(path, _, _)| path.starts_with("node_modules/other/"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit managed npm hook: sparse scratch overflow and child cleanup; no network"]
|
||||
fn npm_build_scratch_budget_kills_owned_hook_and_cleans_stage() {
|
||||
let inputs = AuthorInputs::load();
|
||||
let session = Session::new(&inputs);
|
||||
let mut transport = LocalRegistry(BTreeMap::new());
|
||||
npm_tests::release(&mut transport,"oversized","1.0.0",serde_json::json!({"scripts":{"install":"node fill.js"}}),&[("package/fill.js",b"const fs=require('fs');fs.writeFileSync('large',Buffer.alloc(1));fs.truncateSync('large',600*1024*1024);setInterval(()=>{},1000);".to_vec())]);
|
||||
let store = FixtureArtifactStore::default();
|
||||
let resolver = resolver(&inputs, &session, &transport, &store);
|
||||
let owned_stages = || {
|
||||
fs::read_dir(std::env::temp_dir())
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
|
||||
.filter(|name| name.starts_with(&format!("dever-lib-build-{}-", std::process::id())))
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
};
|
||||
let before = owned_stages();
|
||||
let error = resolver
|
||||
.resolve(&["npm:oversized@1.0.0".parse().unwrap()])
|
||||
.unwrap_err();
|
||||
assert!(error.contains("scratch budget"), "{error}");
|
||||
assert_eq!(
|
||||
owned_stages(),
|
||||
before,
|
||||
"failed hook leaked its owned staging tree"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit managed npm hook: internal output aliases are flattened; aliases outside output are rejected"]
|
||||
fn npm_build_output_hard_links_stay_inside_the_exported_tree() {
|
||||
let inputs = AuthorInputs::load();
|
||||
let session = Session::new(&inputs);
|
||||
for (name, source, accepted) in [
|
||||
(
|
||||
"insidealias",
|
||||
"const fs=require('fs');fs.writeFileSync('original.txt','same-bytes');fs.linkSync('original.txt','alias.txt');",
|
||||
true,
|
||||
),
|
||||
(
|
||||
"outsidealias",
|
||||
"const fs=require('fs');fs.writeFileSync('/data/build/outside.txt','outside');fs.linkSync('/data/build/outside.txt','alias.txt');",
|
||||
false,
|
||||
),
|
||||
] {
|
||||
let mut transport = LocalRegistry(BTreeMap::new());
|
||||
npm_tests::release(
|
||||
&mut transport,
|
||||
name,
|
||||
"1.0.0",
|
||||
serde_json::json!({"scripts":{"install":"node aliases.js"}}),
|
||||
&[("package/aliases.js", source.as_bytes().to_vec())],
|
||||
);
|
||||
let store = FixtureArtifactStore::default();
|
||||
let result =
|
||||
resolver(&inputs, &session, &transport, &store)
|
||||
.resolve(&[format!("npm:{name}@1.0.0").parse().unwrap()]);
|
||||
if accepted {
|
||||
let lock = result.unwrap();
|
||||
doctor(&lock).unwrap();
|
||||
let receipt = &lock.builds[0];
|
||||
let bytes = store
|
||||
.verify_exact(
|
||||
&receipt.output.sha256,
|
||||
receipt.output.bytes,
|
||||
&receipt.output.target,
|
||||
)
|
||||
.unwrap();
|
||||
let files =
|
||||
dever_cli::libs::build::npm_installation(&lock, &lock.npm[0], &bytes).unwrap();
|
||||
for leaf in ["original.txt", "alias.txt"] {
|
||||
assert!(files.iter().any(|(path, bytes, _)| path
|
||||
== &format!("node_modules/{name}/{leaf}")
|
||||
&& bytes == b"same-bytes"));
|
||||
}
|
||||
} else {
|
||||
assert!(result.unwrap_err().contains("hard-link alias outside"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
use super::*;
|
||||
use dever_cli::libs::sumdb::{self, ChecksumDatabase, Evidence, Verifier};
|
||||
use ring::signature::{Ed25519KeyPair, KeyPair};
|
||||
|
||||
fn key() -> Ed25519KeyPair {
|
||||
Ed25519KeyPair::from_seed_unchecked(&[47; 32]).unwrap()
|
||||
}
|
||||
|
||||
pub(super) fn verifier() -> Verifier {
|
||||
let mut encoded = vec![1];
|
||||
encoded.extend(key().public_key().as_ref());
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(b"dever-sumdb-test\n");
|
||||
hash.update(&encoded);
|
||||
let id = hash.finalize();
|
||||
Verifier::new(&format!(
|
||||
"dever-sumdb-test+{}+{}",
|
||||
id[..4]
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>(),
|
||||
base64::engine::general_purpose::STANDARD.encode(encoded)
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn signed(size: usize, root: [u8; 32]) -> String {
|
||||
let text = format!(
|
||||
"go.sum database tree\n{size}\n{}\n",
|
||||
base64::engine::general_purpose::STANDARD.encode(root)
|
||||
);
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(b"dever-sumdb-test\n");
|
||||
hash.update([1]);
|
||||
hash.update(key().public_key().as_ref());
|
||||
let mut signature = hash.finalize()[..4].to_vec();
|
||||
signature.extend(key().sign(text.as_bytes()).as_ref());
|
||||
format!(
|
||||
"{text}\n— dever-sumdb-test {}\n",
|
||||
base64::engine::general_purpose::STANDARD.encode(signature)
|
||||
)
|
||||
}
|
||||
|
||||
fn leaf(record: &[u8]) -> [u8; 32] {
|
||||
let mut hash = Sha256::new();
|
||||
hash.update([0]);
|
||||
hash.update(record);
|
||||
hash.finalize().into()
|
||||
}
|
||||
|
||||
fn root(hashes: &[[u8; 32]]) -> [u8; 32] {
|
||||
if hashes.is_empty() {
|
||||
return Sha256::digest([]).into();
|
||||
}
|
||||
if hashes.len() == 1 {
|
||||
return hashes[0];
|
||||
}
|
||||
let split = hashes.len().next_power_of_two() / 2;
|
||||
let mut hash = Sha256::new();
|
||||
hash.update([1]);
|
||||
hash.update(root(&hashes[..split]));
|
||||
hash.update(root(&hashes[split..]));
|
||||
hash.finalize().into()
|
||||
}
|
||||
|
||||
fn unescape(path: &str) -> String {
|
||||
let mut chars = path.chars();
|
||||
let mut output = String::new();
|
||||
while let Some(ch) = chars.next() {
|
||||
output.push(if ch == '!' {
|
||||
chars.next().unwrap().to_ascii_uppercase()
|
||||
} else {
|
||||
ch
|
||||
});
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
pub(super) fn seal(registry: &mut LocalRegistry) {
|
||||
registry
|
||||
.0
|
||||
.retain(|(_, path), _| !path.starts_with("https://sum.golang.org/"));
|
||||
let records = registry
|
||||
.0
|
||||
.iter()
|
||||
.filter_map(|((ecosystem, path), bytes)| {
|
||||
if *ecosystem != Ecosystem::Go || !path.ends_with(".mod") {
|
||||
return None;
|
||||
}
|
||||
let (module, version) = path.strip_prefix('/')?.split_once("/@v/v")?;
|
||||
let version = version.strip_suffix(".mod")?;
|
||||
let module = unescape(module);
|
||||
let archive = registry.0.get(&(
|
||||
Ecosystem::Go,
|
||||
format!("{}zip", path.strip_suffix("mod").unwrap()),
|
||||
))?;
|
||||
let record = format!(
|
||||
"{module} v{version} {}\n{module} v{version}/go.mod {}\n",
|
||||
sumdb::zip_hash(&module, version, archive).unwrap(),
|
||||
sumdb::mod_hash(bytes)
|
||||
);
|
||||
Some((
|
||||
format!(
|
||||
"/lookup/{}@v{version}",
|
||||
path.strip_prefix('/')
|
||||
.unwrap()
|
||||
.split("/@v/")
|
||||
.next()
|
||||
.unwrap()
|
||||
),
|
||||
record,
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let hashes = records
|
||||
.iter()
|
||||
.map(|(_, record)| leaf(record.as_bytes()))
|
||||
.collect::<Vec<_>>();
|
||||
let checkpoint = signed(records.len(), root(&hashes));
|
||||
registry.0.insert(
|
||||
(Ecosystem::Go, "https://sum.golang.org/latest".into()),
|
||||
checkpoint.as_bytes().to_vec(),
|
||||
);
|
||||
for (id, (path, record)) in records.iter().enumerate() {
|
||||
registry.0.insert(
|
||||
(Ecosystem::Go, format!("https://sum.golang.org{path}")),
|
||||
format!("{id}\n{record}\n{checkpoint}").into_bytes(),
|
||||
);
|
||||
}
|
||||
// Fixtures deliberately remain below one tile. Larger trees use official
|
||||
// captured vectors, not a second production tile-address implementation.
|
||||
assert!(hashes.len() < 256);
|
||||
if !hashes.is_empty() {
|
||||
registry.0.insert(
|
||||
(
|
||||
Ecosystem::Go,
|
||||
format!("https://sum.golang.org/tile/8/0/000.p/{}", hashes.len()),
|
||||
),
|
||||
hashes.iter().flatten().copied().collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Transport<'a> {
|
||||
pub registry: &'a dyn RegistryTransport,
|
||||
pub checksums: &'a LocalRegistry,
|
||||
}
|
||||
impl RegistryTransport for Transport<'_> {
|
||||
fn get(&self, ecosystem: Ecosystem, path: &str, limit: usize) -> Result<Vec<u8>, String> {
|
||||
if path.starts_with("https://sum.golang.org/") {
|
||||
self.checksums.get(ecosystem, path, limit)
|
||||
} else {
|
||||
self.registry.get(ecosystem, path, limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolved(
|
||||
registry: &LocalRegistry,
|
||||
previous: Option<String>,
|
||||
roots: &[&str],
|
||||
) -> Result<LockFile, String> {
|
||||
let cache = FixtureArtifactStore::default();
|
||||
let mut resolver = local_resolver(registry, &cache);
|
||||
resolver.go_sumdb = ChecksumDatabase::new(verifier(), previous)?;
|
||||
dever_cli::libs::LibResolver::resolve(
|
||||
&resolver,
|
||||
&roots
|
||||
.iter()
|
||||
.map(|root| root.parse().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_modules_replay_offline_and_reject_content_or_proof_drift() {
|
||||
let registry = local_registry();
|
||||
let lock = resolved(®istry, None, &["go:example.com/a@1.0.0"]).unwrap();
|
||||
let evidence = &lock.go_sumdb[0];
|
||||
let checked = evidence.verify(&verifier()).unwrap();
|
||||
let archive = ®istry.0[&(Ecosystem::Go, "/example.com/a/@v/v1.0.0.zip".into())];
|
||||
checked
|
||||
.verify_zip("example.com/a", "1.0.0", archive)
|
||||
.unwrap();
|
||||
assert!(
|
||||
evidence
|
||||
.verify(&Verifier::official())
|
||||
.unwrap_err()
|
||||
.contains("trusted signature")
|
||||
);
|
||||
let mut changed = evidence.clone();
|
||||
changed.records[0].go_mod.push_str("// changed\n");
|
||||
assert!(changed.verify(&verifier()).unwrap_err().contains("go.mod"));
|
||||
let mut changed = evidence.clone();
|
||||
changed.records[0].module = "wrong.example/a".into();
|
||||
assert!(
|
||||
changed
|
||||
.verify(&verifier())
|
||||
.unwrap_err()
|
||||
.contains("different module/version")
|
||||
);
|
||||
let mut changed = evidence.clone();
|
||||
changed.records[0].inclusion[0] = base64::engine::general_purpose::STANDARD.encode([0; 32]);
|
||||
assert!(
|
||||
changed
|
||||
.verify(&verifier())
|
||||
.unwrap_err()
|
||||
.contains("inclusion proof")
|
||||
);
|
||||
let mut changed = evidence.clone();
|
||||
changed.checkpoint = changed
|
||||
.checkpoint
|
||||
.replace("go.sum database tree", "go.sum database free");
|
||||
assert!(
|
||||
changed
|
||||
.verify(&verifier())
|
||||
.unwrap_err()
|
||||
.contains("signature verification")
|
||||
);
|
||||
assert!(
|
||||
checked
|
||||
.verify_zip(
|
||||
"example.com/a",
|
||||
"1.0.0",
|
||||
&zip_file("example.com/a@v1.0.0/changed.go")
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("sumdb h1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkpoint_updates_require_consistency_and_never_move_backwards() {
|
||||
let mut first = local_registry();
|
||||
first
|
||||
.0
|
||||
.remove(&(Ecosystem::Go, "/example.com/b/@v/v1.2.0.mod".into()));
|
||||
first.0.insert(
|
||||
(Ecosystem::Go, "/example.com/a/@v/v1.0.0.mod".into()),
|
||||
b"module example.com/a\n".to_vec(),
|
||||
);
|
||||
seal(&mut first);
|
||||
let old = resolved(&first, None, &["go:example.com/a@1.0.0"]).unwrap();
|
||||
let checkpoint = old.go_sumdb[0].verify(&verifier()).unwrap().checkpoint;
|
||||
let mut next = first;
|
||||
next.0.insert(
|
||||
(Ecosystem::Go, "/example.com/z/@v/v1.0.0.mod".into()),
|
||||
b"module example.com/z\n".to_vec(),
|
||||
);
|
||||
next.0.insert(
|
||||
(Ecosystem::Go, "/example.com/z/@v/v1.0.0.zip".into()),
|
||||
zip_file("example.com/z@v1.0.0/z.go"),
|
||||
);
|
||||
seal(&mut next);
|
||||
let new = resolved(&next, Some(checkpoint.clone()), &["go:example.com/z@1.0.0"]).unwrap();
|
||||
let current = new.go_sumdb[0].verify(&verifier()).unwrap().checkpoint;
|
||||
assert!(resolved(&next, Some(current.clone()), &["go:example.com/z@1.0.0"]).is_ok());
|
||||
next.0.insert(
|
||||
(Ecosystem::Go, "https://sum.golang.org/latest".into()),
|
||||
checkpoint.into_bytes(),
|
||||
);
|
||||
assert!(
|
||||
resolved(&next, Some(current), &["go:example.com/a@1.0.0"])
|
||||
.unwrap_err()
|
||||
.contains("rollback")
|
||||
);
|
||||
let mut fork = new.go_sumdb[0].clone();
|
||||
fork.consistency[0] = base64::engine::general_purpose::STANDARD.encode([0; 32]);
|
||||
assert!(
|
||||
fork.verify(&verifier())
|
||||
.unwrap_err()
|
||||
.contains("conflicting history")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sumdb_verifier_key_and_upstream_signed_note_vectors() {
|
||||
let note = "go.sum database tree\n63512409\nF7eHRPqN/cqPtqti14NKdGGaI1Giw8coWP3KxihFZbg=\n\n— sum.golang.org Az3gri1OBHKT87Xj8UXU8zjoHGy/X+7Nl94f8TZkELVaasolmGApUsWqwVG9vE6hgqYb/ph/w2vdkLIVRH7bMB/FPQ4=\n";
|
||||
let evidence = Evidence {
|
||||
previous: None,
|
||||
checkpoint: note.into(),
|
||||
consistency: vec![],
|
||||
records: vec![],
|
||||
};
|
||||
evidence.verify(&Verifier::official()).unwrap();
|
||||
assert!(evidence.verify(&verifier()).is_err());
|
||||
assert!(Verifier::new(&sumdb::OFFICIAL_VERIFIER.replace("033de0ae", "033de0af")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_large_tree_inclusion_and_consistency_vectors_verify_offline() {
|
||||
// Captured from sum.golang.org/lookup/github.com/google/uuid@v1.6.0
|
||||
// and its immutable height-8 tiles on 2026-10-02. Hashes were assembled
|
||||
// independently of this Rust implementation. Both notes retain Google's
|
||||
// original Ed25519 signatures; no fixture signer can mint these vectors.
|
||||
let evidence: Evidence = serde_json::from_str(include_str!("sumdb_uuid.json")).unwrap();
|
||||
let trusted = Verifier::official();
|
||||
let verified = evidence.verify(&trusted).unwrap();
|
||||
assert!(verified.contains("github.com/google/uuid", "1.6.0"));
|
||||
assert_eq!(
|
||||
sumdb::mod_hash(b"module github.com/google/uuid\n"),
|
||||
"h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo="
|
||||
);
|
||||
for index in 0..evidence.records[0].inclusion.len() {
|
||||
let mut changed = evidence.clone();
|
||||
changed.records[0].inclusion[index] =
|
||||
base64::engine::general_purpose::STANDARD.encode([0; 32]);
|
||||
assert!(
|
||||
changed.verify(&trusted).is_err(),
|
||||
"accepted corrupt inclusion hash {index}"
|
||||
);
|
||||
}
|
||||
for index in 0..evidence.consistency.len() {
|
||||
let mut changed = evidence.clone();
|
||||
changed.consistency[index] = base64::engine::general_purpose::STANDARD.encode([0; 32]);
|
||||
assert!(
|
||||
changed.verify(&trusted).is_err(),
|
||||
"accepted corrupt consistency hash {index}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_h1_ignores_zip_order_and_compression_and_preserves_module_case() {
|
||||
let archive = |reverse, compression| {
|
||||
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut files = vec![
|
||||
("a.go", b"package a\n".as_slice()),
|
||||
("LICENSE", b"fixture license\n".as_slice()),
|
||||
];
|
||||
if reverse {
|
||||
files.reverse();
|
||||
}
|
||||
for (name, content) in files {
|
||||
writer
|
||||
.start_file(
|
||||
format!("example.com/Mixed@v1.0.0/{name}"),
|
||||
zip::write::SimpleFileOptions::default().compression_method(compression),
|
||||
)
|
||||
.unwrap();
|
||||
writer.write_all(content).unwrap();
|
||||
}
|
||||
writer.finish().unwrap().into_inner()
|
||||
};
|
||||
let first = archive(false, zip::CompressionMethod::Stored);
|
||||
let second = archive(true, zip::CompressionMethod::Deflated);
|
||||
assert_ne!(digest(&first), digest(&second));
|
||||
assert_eq!(
|
||||
sumdb::zip_hash("example.com/Mixed", "1.0.0", &first).unwrap(),
|
||||
sumdb::zip_hash("example.com/Mixed", "1.0.0", &second).unwrap()
|
||||
);
|
||||
let mut registry = LocalRegistry(BTreeMap::from([
|
||||
(
|
||||
(Ecosystem::Go, "/example.com/!mixed/@v/v1.0.0.mod".into()),
|
||||
b"module example.com/Mixed\n".to_vec(),
|
||||
),
|
||||
(
|
||||
(Ecosystem::Go, "/example.com/!mixed/@v/v1.0.0.zip".into()),
|
||||
first,
|
||||
),
|
||||
]));
|
||||
seal(&mut registry);
|
||||
let lock = resolved(®istry, None, &["go:example.com/Mixed@1.0.0"]).unwrap();
|
||||
assert_eq!(lock.libs[0].spec.name, "example.com/Mixed");
|
||||
assert!(
|
||||
lock.go_sumdb[0]
|
||||
.verify(&verifier())
|
||||
.unwrap()
|
||||
.contains("example.com/Mixed", "1.0.0")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_go_mod_consulted_by_mvs_is_authenticated_even_when_not_selected() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
for (name, version, source) in [
|
||||
(
|
||||
"a",
|
||||
"1.0.0",
|
||||
"module example.com/a\nrequire (\nexample.com/b v1.0.0\nexample.com/c v1.0.0\n)\n",
|
||||
),
|
||||
("b", "1.0.0", "module example.com/b\n"),
|
||||
("b", "2.0.0", "module example.com/b\n"),
|
||||
(
|
||||
"c",
|
||||
"1.0.0",
|
||||
"module example.com/c\nrequire example.com/b v2.0.0\n",
|
||||
),
|
||||
] {
|
||||
registry.0.insert(
|
||||
(
|
||||
Ecosystem::Go,
|
||||
format!("/example.com/{name}/@v/v{version}.mod"),
|
||||
),
|
||||
source.as_bytes().to_vec(),
|
||||
);
|
||||
registry.0.insert(
|
||||
(
|
||||
Ecosystem::Go,
|
||||
format!("/example.com/{name}/@v/v{version}.zip"),
|
||||
),
|
||||
zip_file(&format!("example.com/{name}@v{version}/{name}.go")),
|
||||
);
|
||||
}
|
||||
seal(&mut registry);
|
||||
let lock = resolved(®istry, None, &["go:example.com/a@1.0.0"]).unwrap();
|
||||
assert!(
|
||||
!lock
|
||||
.libs
|
||||
.iter()
|
||||
.any(|lib| lib.spec.key() == "go:example.com/b@1.0.0")
|
||||
);
|
||||
assert!(
|
||||
lock.go_sumdb[0]
|
||||
.records
|
||||
.iter()
|
||||
.any(|record| record.module == "example.com/b" && record.version == "1.0.0")
|
||||
);
|
||||
registry
|
||||
.0
|
||||
.get_mut(&(Ecosystem::Go, "/example.com/b/@v/v1.0.0.mod".into()))
|
||||
.unwrap()
|
||||
.extend(b"// modified discarded metadata\n");
|
||||
let cache = FixtureArtifactStore::default();
|
||||
let resolver = local_resolver(®istry, &cache);
|
||||
assert!(
|
||||
dever_cli::libs::LibResolver::resolve(
|
||||
&resolver,
|
||||
&["go:example.com/a@1.0.0".parse().unwrap()]
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("go.mod does not match")
|
||||
);
|
||||
for ((_, path), bytes) in ®istry.0 {
|
||||
if path.ends_with(".zip") {
|
||||
assert!(!cache.has(&digest(bytes), "linux-x86_64").unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_hashzip_includes_empty_directory_entries() {
|
||||
let archive = |directory| {
|
||||
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
writer
|
||||
.start_file(
|
||||
"example.com/a@v1.0.0/a.go",
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
writer.write_all(b"package a\n").unwrap();
|
||||
if directory {
|
||||
writer
|
||||
.add_directory(
|
||||
"example.com/a@v1.0.0/empty/",
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
writer.finish().unwrap().into_inner()
|
||||
};
|
||||
// Independent Hash1 vectors: sorted "sha256(content) filename\n" records,
|
||||
// including the directory's trailing slash and SHA-256 of empty content.
|
||||
assert_eq!(
|
||||
sumdb::zip_hash("example.com/a", "1.0.0", &archive(false)).unwrap(),
|
||||
"h1:VEUcYKYLbKBYq0eA/VEJ3mMQBzi4jjWpusjuduGJcBQ="
|
||||
);
|
||||
assert_eq!(
|
||||
sumdb::zip_hash("example.com/a", "1.0.0", &archive(true)).unwrap(),
|
||||
"h1:y9ZWKWCmVsKnLxdFkIVoxr4MyXyHgBUvp1hgBzZ+IfE="
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_updates_and_removing_last_lib_preserve_official_checkpoint_anchor() {
|
||||
let root = TemporaryDirectory::new();
|
||||
let evidence: Evidence = serde_json::from_str(include_str!("sumdb_uuid.json")).unwrap();
|
||||
let mut lock = LockFile::new(Vec::new()).unwrap();
|
||||
lock.go_sumdb.push(evidence.clone());
|
||||
lock.write_atomic(root.path()).unwrap();
|
||||
let spec = vec!["pip:fixture@1.0.0".into()];
|
||||
for command in ["add", "update", "remove"] {
|
||||
dever_cli::libs::execute(command, root.path(), &spec).unwrap();
|
||||
let lock = LockFile::decode(&fs::read(root.path().join("dever.lock")).unwrap()).unwrap();
|
||||
assert_eq!(lock.go_sumdb, vec![evidence.clone()]);
|
||||
let current = sumdb::verify_chain(&lock.go_sumdb, &Verifier::official()).unwrap();
|
||||
let previous = evidence.previous.clone().unwrap();
|
||||
// A subsequent online resolution cannot accept the older official tree.
|
||||
let mut registry = local_registry();
|
||||
registry.0.insert(
|
||||
(Ecosystem::Go, "https://sum.golang.org/latest".into()),
|
||||
previous.into_bytes(),
|
||||
);
|
||||
let cache = FixtureArtifactStore::default();
|
||||
let mut resolver = local_resolver(®istry, &cache);
|
||||
resolver.go_sumdb = ChecksumDatabase::official(Some(current.checkpoint)).unwrap();
|
||||
assert!(
|
||||
dever_cli::libs::LibResolver::resolve(
|
||||
&resolver,
|
||||
&["go:example.com/a@1.0.0".parse().unwrap()]
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("rollback")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_publication_rejects_offline_byte_and_element_overflow_before_replacement() {
|
||||
let root = TemporaryDirectory::new();
|
||||
let original = LockFile::new(Vec::new()).unwrap();
|
||||
original.write_atomic(root.path()).unwrap();
|
||||
let path = root.path().join("dever.lock");
|
||||
let before = fs::read(&path).unwrap();
|
||||
let receipt = Evidence {
|
||||
previous: None,
|
||||
checkpoint: "x".into(),
|
||||
consistency: Vec::new(),
|
||||
records: Vec::new(),
|
||||
};
|
||||
for overflow in [true, false] {
|
||||
let mut lock = original.clone();
|
||||
let mut receipt = receipt.clone();
|
||||
if overflow {
|
||||
receipt.checkpoint = "x".repeat(dever_runtime::wire::MAX_BYTES);
|
||||
} else {
|
||||
receipt.consistency = vec!["x".into(); dever_runtime::wire::MAX_ELEMENTS];
|
||||
}
|
||||
lock.go_sumdb.push(receipt);
|
||||
assert!(lock.encode().unwrap_err().contains("invalid dever.lock"));
|
||||
assert!(lock.write_atomic(root.path()).is_err());
|
||||
assert_eq!(fs::read(&path).unwrap(), before);
|
||||
assert_eq!(fs::read_dir(root.path()).unwrap().count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_surplus_and_out_of_bounds_sumdb_proofs_are_rejected() {
|
||||
let original: Evidence = serde_json::from_str(include_str!("sumdb_uuid.json")).unwrap();
|
||||
let trusted = Verifier::official();
|
||||
let mut changed = original.clone();
|
||||
changed.records[0].inclusion.pop();
|
||||
assert!(changed.verify(&trusted).is_err());
|
||||
let mut changed = original.clone();
|
||||
changed.records[0]
|
||||
.inclusion
|
||||
.push(base64::engine::general_purpose::STANDARD.encode([0; 32]));
|
||||
assert!(changed.verify(&trusted).is_err());
|
||||
for id in ["66321505", "09223372036854775807", "9223372036854775808"] {
|
||||
let mut changed = original.clone();
|
||||
let (_, suffix) = changed.records[0].lookup.split_once('\n').unwrap();
|
||||
changed.records[0].lookup = format!("{id}\n{suffix}");
|
||||
assert!(changed.verify(&trusted).is_err());
|
||||
}
|
||||
let mut changed = original.clone();
|
||||
changed.checkpoint.pop();
|
||||
assert!(changed.verify(&trusted).is_err());
|
||||
let mut disconnected = original.clone();
|
||||
disconnected.previous = None;
|
||||
disconnected.consistency.clear();
|
||||
assert!(
|
||||
sumdb::verify_chain(&[original, disconnected], &trusted)
|
||||
.unwrap_err()
|
||||
.contains("not connected")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn lib_and_package_mutations_share_a_stable_nonblocking_project_lock() {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let root = TemporaryDirectory::new();
|
||||
let requested = vec!["pip:fixture@1.0.0".into()];
|
||||
dever_cli::libs::execute("add", root.path(), &requested).unwrap();
|
||||
let lock_path = root.path().join("dever.lock");
|
||||
let config_path = root.path().join("config/setting.json");
|
||||
let before_lock = fs::read(&lock_path).unwrap();
|
||||
let before_config = fs::read(&config_path).unwrap();
|
||||
let guard = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.mode(0o600)
|
||||
.open(root.path().join(".dever-project.lock"))
|
||||
.unwrap();
|
||||
fs2::FileExt::try_lock_exclusive(&guard).unwrap();
|
||||
for command in ["add", "update", "remove"] {
|
||||
assert!(
|
||||
dever_cli::libs::execute(command, root.path(), &requested)
|
||||
.unwrap_err()
|
||||
.contains("mutation is busy")
|
||||
);
|
||||
assert!(
|
||||
dever_cli::packages::execute(command, root.path(), &[])
|
||||
.unwrap_err()
|
||||
.contains("mutation is busy")
|
||||
);
|
||||
struct UnusedTransport;
|
||||
impl dever_cli::packages::PackageTransport for UnusedTransport {
|
||||
fn get(&self, _: &str, _: usize) -> Result<Vec<u8>, String> {
|
||||
panic!("busy mutation must not contact a registry")
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
dever_cli::packages::execute_with(
|
||||
command,
|
||||
root.path(),
|
||||
&[],
|
||||
&UnusedTransport,
|
||||
&FixtureArtifactStore::default()
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("mutation is busy")
|
||||
);
|
||||
}
|
||||
assert_eq!(fs::read(&lock_path).unwrap(), before_lock);
|
||||
assert_eq!(fs::read(&config_path).unwrap(), before_config);
|
||||
drop(guard);
|
||||
dever_cli::libs::execute("update", root.path(), &requested).unwrap();
|
||||
assert_eq!(fs::read(&lock_path).unwrap(), before_lock);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn project_mutation_rejects_linked_or_unprotected_lock_paths() {
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
let root = TemporaryDirectory::new();
|
||||
let outside = TemporaryDirectory::new();
|
||||
let target = outside.path().join("untouched");
|
||||
fs::write(&target, "keep").unwrap();
|
||||
let path = root.path().join(".dever-project.lock");
|
||||
symlink(&target, &path).unwrap();
|
||||
let requested = vec!["pip:fixture@1.0.0".into()];
|
||||
assert!(
|
||||
dever_cli::libs::execute("add", root.path(), &requested)
|
||||
.unwrap_err()
|
||||
.contains("without symlinks")
|
||||
);
|
||||
assert_eq!(fs::read(&target).unwrap(), b"keep");
|
||||
fs::remove_file(&path).unwrap();
|
||||
fs::hard_link(&target, &path).unwrap();
|
||||
assert!(
|
||||
dever_cli::libs::execute("add", root.path(), &requested)
|
||||
.unwrap_err()
|
||||
.contains("private and unlinked")
|
||||
);
|
||||
fs::remove_file(&path).unwrap();
|
||||
fs::write(&path, "").unwrap();
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o666)).unwrap();
|
||||
assert!(
|
||||
dever_cli::libs::execute("add", root.path(), &requested)
|
||||
.unwrap_err()
|
||||
.contains("private and unlinked")
|
||||
);
|
||||
fs::set_permissions(root.path(), fs::Permissions::from_mode(0o777)).unwrap();
|
||||
assert!(
|
||||
dever_cli::libs::execute("add", root.path(), &requested)
|
||||
.unwrap_err()
|
||||
.contains("not group/other writable")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_go_workers_merge_connected_offline_evidence() {
|
||||
use dever_cli::libs::{LockedWorker, resolve_workers};
|
||||
let registry = local_registry();
|
||||
let cache = FixtureArtifactStore::default();
|
||||
let resolver = local_resolver(®istry, &cache);
|
||||
let worker = |adapter: &str, spec: &str| LockedWorker {
|
||||
port: "sample.worker".into(),
|
||||
adapter: adapter.into(),
|
||||
ecosystem: "go".into(),
|
||||
runtime: Some(registry_runtime(Ecosystem::Go).pack),
|
||||
entry: format!("worker/{adapter}.go"),
|
||||
schema: "checked-port".into(),
|
||||
capabilities: vec![],
|
||||
operations: vec!["read".into()],
|
||||
libs: vec![spec.parse().unwrap()],
|
||||
};
|
||||
let lock = resolve_workers(
|
||||
&resolver,
|
||||
&[],
|
||||
vec![
|
||||
worker("first", "go:example.com/a@1.0.0"),
|
||||
worker("second", "go:example.com/b@1.2.0"),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(lock.go_sumdb.len(), 2);
|
||||
assert_eq!(
|
||||
lock.go_sumdb[1].previous.as_ref(),
|
||||
Some(&lock.go_sumdb[0].verify(&verifier()).unwrap().checkpoint)
|
||||
);
|
||||
let decoded = LockFile::decode(&lock.encode().unwrap()).unwrap();
|
||||
let verified = sumdb::verify_chain(&decoded.go_sumdb, &verifier()).unwrap();
|
||||
assert!(verified.contains("example.com/a", "1.0.0"));
|
||||
assert!(verified.contains("example.com/b", "1.2.0"));
|
||||
verify_locked_artifacts(&decoded, &cache, "linux-x86_64").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "author operation: explicitly fetches official Go proxy/sumdb; requires target/go-managed/config/setting.json and runtime.pack"]
|
||||
fn prepare_official_go_dependency_fixture() {
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Settings {
|
||||
runtime: RegistryRuntime,
|
||||
}
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/go-managed");
|
||||
let settings: Settings = serde_json::from_slice(
|
||||
&fs::read(root.join("config/setting.json"))
|
||||
.expect("prepare the explicit fixture settings first"),
|
||||
)
|
||||
.unwrap();
|
||||
let pack =
|
||||
fs::read(root.join("runtime.pack")).expect("prepare the explicit Go runtime pack first");
|
||||
assert_eq!(
|
||||
settings.runtime.pack.sha256,
|
||||
digest(&pack),
|
||||
"configured runtime does not match runtime.pack"
|
||||
);
|
||||
assert_eq!(
|
||||
settings.runtime.target,
|
||||
dever_cli::toolchain::platform_identity()
|
||||
);
|
||||
let output = root.join("dependency");
|
||||
let lock_path = output.join("dever.lock");
|
||||
let previous = if lock_path.exists() {
|
||||
let lock = LockFile::decode(&fs::read(&lock_path).unwrap()).unwrap();
|
||||
dever_cli::libs::doctor(&lock).unwrap();
|
||||
let verified = sumdb::verify_chain(&lock.go_sumdb, &Verifier::official()).unwrap();
|
||||
(!verified.checkpoint.is_empty()).then_some(verified.checkpoint)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let target = settings.runtime.target.clone();
|
||||
let store = FixtureArtifactStore::default();
|
||||
let transport = dever_cli::libs::HttpRegistry::official();
|
||||
let resolver = RegistryResolver {
|
||||
build: None,
|
||||
transport: &transport,
|
||||
store: &store,
|
||||
runtimes: BTreeMap::from([(Ecosystem::Go, settings.runtime)]),
|
||||
go_sumdb: ChecksumDatabase::official(previous).unwrap(),
|
||||
};
|
||||
let lock = dever_cli::libs::LibResolver::resolve(
|
||||
&resolver,
|
||||
&["go:github.com/google/uuid@1.6.0".parse().unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
// The same official verifier and offline artifact checks protect normal builds.
|
||||
dever_cli::libs::doctor(&lock).unwrap();
|
||||
dever_cli::libs::verify_locked_artifacts(&lock, &store, &target).unwrap();
|
||||
publish_fixture_artifacts(&output, &lock, &store);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"previous": "go.sum database tree\n63512409\nF7eHRPqN/cqPtqti14NKdGGaI1Giw8coWP3KxihFZbg=\n\n— sum.golang.org Az3gri1OBHKT87Xj8UXU8zjoHGy/X+7Nl94f8TZkELVaasolmGApUsWqwVG9vE6hgqYb/ph/w2vdkLIVRH7bMB/FPQ4=\n",
|
||||
"checkpoint": "go.sum database tree\n66321505\nnL/ZK4zIq2TWUhMi/cDJQRq6hXj67qFBbyEqxks3epY=\n\n— sum.golang.org Az3gruvaP9rIy6foXnttmnViaXM55mqUcP66AeP7N5r+tpFo2ll40wDr7sN0guXk6rXp0fL63fe30pchMCTqmbRo+Ak=\n",
|
||||
"consistency": [
|
||||
"3OyjS9XS6r41pxaFWGnW/WSmX7b1ik3nupquDNipdZ8=",
|
||||
"zX5IsB02+IutT35ka/MwY7gFiPSAlihfnt2cu6smUjw=",
|
||||
"Qc0CqO89A+/onGz1u4ZsDoMOfRIgPZpayRGTETOv8kk=",
|
||||
"JhdrRO/y8Iu+MDeUupFG0uM4+odAADMjVH0mOgLOA/c=",
|
||||
"lOgywJvTdrBWol0uphM6ZulvAebwJGNlekm8MIwiwKM=",
|
||||
"FRf0N51Fw+8eSLHEa/e/AsnDway0EkKo4jI0E1Ne7dk=",
|
||||
"Ak0gyC+RS6/ahVBCBuSBDA1wFN9mgq3acIbYm9uJHQ4=",
|
||||
"FFJ8WrKTTQgCjTuAl5CukBdjDaQ7opSdHBpNSolRYWw=",
|
||||
"rdNcd+MUjUxzlX/nlVGeQP5BkkZyTT89sn/yfg0AXQ0=",
|
||||
"gJzOh5y5rP1BILUrFJw+aGjod840PErDnjy4T0BoUE4=",
|
||||
"7I8BPI+ADxeJmu19mQxO+xt6bNhg3Ueq1j2dIfOppwA=",
|
||||
"urhcUF9QOyTRAYRjJjsRHjHdwIc+BxfiQ6a+NuYeC6E=",
|
||||
"p9eGEUHXeKLFnrIPB7VXHHMuwtaBnvHG4CYwgC0wq+w=",
|
||||
"Gcry0ScaNfFmQujPMT95RQ3rbzKDSynQsL546C8CkfU=",
|
||||
"dIpym+JRT/+z9/tlhpuIRyv+uoWGqgND/MHw0f1MtqA=",
|
||||
"cQrKLJIBbPoWDnRnbGfAEMfpsau0h+Aw3aqDOJ5LAVU=",
|
||||
"C46761Ui7jL+lf4vRXBlveoxuFS6tykAd6mMEfNttPE=",
|
||||
"45Kz4HcZ9iZ84ySMHCy21d4vd2MvibvfNMzYm0hKYRs=",
|
||||
"4JZTJ+4VUtLMTEs11jn4GmCxoz56Lm5HDwkYmkmAWlw=",
|
||||
"2hJV4h1jZ2YujjyXUw+7YYCwLXGYZy+ondhRUgxKmXI=",
|
||||
"lrn/kRfgYdYSDxgPdSQ8/GnZw6u/joKu9++AR+Saa5M=",
|
||||
"hnm9+kaHQdhHWrAcyrvY5fbv8QB+GwVjRMFeobtTg8A=",
|
||||
"rPFmKL/vD6Edsk/0DmpvDa/nMOqWgjfpPGRjBUL9yKs=",
|
||||
"2IzbyezmwwGZZPJ276ejAQZiAnar+RcjAUU3MEMtbIA=",
|
||||
"k3kfr9AECmkV+UA3/D6EMqaBDr67aubL5hXGsvJ+Nng=",
|
||||
"/WnQtddEqG03mbcU1XID1iws1nlKHVkJcaREWknCOiE=",
|
||||
"xJ20xXM4QItt5vxFOtolZ5l18p7ej1+RimNkgWmoRFM="
|
||||
],
|
||||
"records": [{
|
||||
"module": "github.com/google/uuid",
|
||||
"version": "1.6.0",
|
||||
"go_mod": "module github.com/google/uuid\n",
|
||||
"lookup": "22152757\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\n\ngo.sum database tree\n66321505\nnL/ZK4zIq2TWUhMi/cDJQRq6hXj67qFBbyEqxks3epY=\n\n— sum.golang.org Az3gruvaP9rIy6foXnttmnViaXM55mqUcP66AeP7N5r+tpFo2ll40wDr7sN0guXk6rXp0fL63fe30pchMCTqmbRo+Ak=\n",
|
||||
"inclusion": [
|
||||
"vYU6vRfhcMyd5/U04n+AYg52mm3BQrjwyN+27saiSnU=",
|
||||
"syWiyNbj6gybIdLKoAFd7xF3qNltS7OhQjtp4y8rCFg=",
|
||||
"Zqpa9zJsOg/sI/mv0UkXO9d7gvPHLLjMAKnZCPzeiNM=",
|
||||
"tGbcXa1AQlYBIm5gM2KDy8JvdAFDizj4lUop18Eehxs=",
|
||||
"/w9FG1Sae/omKxZdOvj719sT1rCow3tlP8VNo0fDQtE=",
|
||||
"dR6Bk+gh/hyJx7bu65HfhQdfuvT/OdVv/G7RUsOc26Y=",
|
||||
"fRC86/2mWWtFWNPz/Vou6f7hdyvRLHFPo9KaOlVOVmc=",
|
||||
"jJS2b3XVP2sst4CS5srpnF34agHpiluU63h6UQyFPDc=",
|
||||
"cOL2+zAXTEOaAiyvmCrL3Ned6soAg08CJJJ2VW3+a18=",
|
||||
"In5NzPUzIAZfV6bnUL11Bkm4Aqoit/ZG7gJ8nxOdB2w=",
|
||||
"r+8IH0m7Ig3BS+uwCVVG1CZT0I/Rro3bbSUVYcSrN2Q=",
|
||||
"wOgTamt00bdokE1Nbv3aHYu2um06V1xdLFEmOBjt0GI=",
|
||||
"21woobbbCvogV/ZXACdAozfVAFjRv5xMID/iLcryHzw=",
|
||||
"5+o2gn9/zVod+MYr4UuoeMzuz68vt/hA5+9H4YZaVME=",
|
||||
"040B+RrLQeFCJhPU69kzoKr4BTqhSwdrnYEVhknZ+I0=",
|
||||
"ioI6UByPBEr2lEZB0dI6pCXgcNss6UkKxm+TYAADujg=",
|
||||
"HOJ0dLK4hnl0tFeu1tmT7TRrNKRIbi/T8KI/2s4DXDs=",
|
||||
"BBcHtB4mS19jHff/LQTNI/+WCVBZ1Up52gNqyoln71U=",
|
||||
"Oz/OlF1Lj7wkqqE15p324ZSX1XHcnLzDSMKNtUx6rwY=",
|
||||
"ofpmTAcvFvNzlwjZnsrMqeG4iIBCP2lt5wIusvZ2gG4=",
|
||||
"IRpVS0aap6n0qb02jyOy6ikkOPxdFIQtWwckaGF2MAQ=",
|
||||
"liRusDUG3TanNZLGkfjb58ppX/6d40M7X2s3wptjxMw=",
|
||||
"ROjUzjXHDN0Yo/I6EeqHhpszFgIs7FCABVeQniliPTk=",
|
||||
"NTjIv8CJqIIGrOHhNQWSP9FV6XEPL6uGA4lmMfNKGJg=",
|
||||
"bwqUQG2wJXGxo1QLA2CFb3chH17xvlqXe6eQy43p0HY=",
|
||||
"bqa+4xGvzGcI/PYne2fXfUvdULgy8o3zQmD13q/gu1Y="
|
||||
],
|
||||
"consistency": []
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
use super::*;
|
||||
use dever_cli::workers::python_wheel::Wheel;
|
||||
|
||||
fn validate_native(
|
||||
target: &str,
|
||||
suffixes: &[String],
|
||||
interpreter: &str,
|
||||
files: &BTreeMap<String, &[u8]>,
|
||||
) -> Result<(), String> {
|
||||
let wheel_files = files
|
||||
.keys()
|
||||
.filter(|path| !path.starts_with("runtime/") && !path.starts_with("sandbox/"))
|
||||
.cloned()
|
||||
.collect();
|
||||
dever_cli::workers::python_wheel::validate_native(
|
||||
target,
|
||||
suffixes,
|
||||
interpreter,
|
||||
files,
|
||||
&wheel_files,
|
||||
)
|
||||
}
|
||||
|
||||
fn spec() -> LibSpec {
|
||||
"pip:sample@1.0.0".parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_console_scripts_use_the_same_private_prefix_and_record_generated_files() {
|
||||
let bytes=wheel_fixture::pack("sample","1.0.0","Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
|
||||
&wheel_fixture::metadata("sample","1.0.0",&[],&[]),&[("sample-1.0.0.dist-info/entry_points.txt",b"[console_scripts]\nsample-tool = sample.cli:main\n[gui_scripts]\nsample-gui = sample.gui:App.run [desktop]\n".to_vec())]);
|
||||
let installed = Wheel::parse(&spec(), None, &tags(), &bytes)
|
||||
.unwrap()
|
||||
.install("runtime/bin/python3", "3.12")
|
||||
.unwrap();
|
||||
let script = installed
|
||||
.iter()
|
||||
.find(|file| file.path == "runtime/bin/sample-tool")
|
||||
.unwrap();
|
||||
assert!(script.executable);
|
||||
assert!(
|
||||
script
|
||||
.bytes
|
||||
.starts_with(b"#!/worker/runtime/bin/python3 -I\n")
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&script.bytes).contains("sample.cli"));
|
||||
let record = installed
|
||||
.iter()
|
||||
.find(|file| file.path.ends_with(".dist-info/RECORD"))
|
||||
.unwrap();
|
||||
assert!(String::from_utf8_lossy(&record.bytes).contains("../../../bin/sample-tool,sha256="));
|
||||
let collision = wheel_fixture::pack(
|
||||
"sample",
|
||||
"1.0.0",
|
||||
"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
|
||||
&wheel_fixture::metadata("sample", "1.0.0", &[], &[]),
|
||||
&[
|
||||
(
|
||||
"sample-1.0.0.dist-info/entry_points.txt",
|
||||
b"[console_scripts]\ntool = sample:main\n".to_vec(),
|
||||
),
|
||||
("sample-1.0.0.data/scripts/tool", b"#!python\n".to_vec()),
|
||||
],
|
||||
);
|
||||
assert!(
|
||||
Wheel::parse(&spec(), None, &tags(), &collision)
|
||||
.unwrap()
|
||||
.install("runtime/bin/python3", "3.12")
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
fn tags() -> Vec<String> {
|
||||
vec!["py3-none-any".into()]
|
||||
}
|
||||
fn valid() -> Vec<u8> {
|
||||
pure_wheel("sample", "1.0.0", &[], &[])
|
||||
}
|
||||
fn unpack(bytes: &[u8]) -> Vec<(String, Vec<u8>)> {
|
||||
use std::io::Read;
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
(0..archive.len())
|
||||
.map(|index| {
|
||||
let mut file = archive.by_index(index).unwrap();
|
||||
let name = file.name().to_owned();
|
||||
let mut bytes = vec![];
|
||||
file.read_to_end(&mut bytes).unwrap();
|
||||
(name, bytes)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_identity_and_all_compressed_filename_tags_are_bound_to_metadata() {
|
||||
Wheel::parse(
|
||||
&spec(),
|
||||
Some("sample-1.0.0-py3-none-any.whl"),
|
||||
&tags(),
|
||||
&valid(),
|
||||
)
|
||||
.unwrap();
|
||||
for filename in [
|
||||
"other-1.0.0-py3-none-any.whl",
|
||||
"sample-2.0.0-py3-none-any.whl",
|
||||
"sample-1.0.0-py2.py3-none-any.whl",
|
||||
"sample-1.0.0-1-py3-none-any.whl",
|
||||
] {
|
||||
assert!(
|
||||
Wheel::parse(&spec(), Some(filename), &tags(), &valid()).is_err(),
|
||||
"{filename}"
|
||||
);
|
||||
}
|
||||
let compressed = wheel_fixture::pack(
|
||||
"sample",
|
||||
"1.0.0",
|
||||
"Wheel-Version: 1.0\nRoot-Is-Purelib: false\nTag: py2-none-any\nTag: py3-none-any\n",
|
||||
&wheel_fixture::metadata("sample", "1.0.0", &[], &[]),
|
||||
&[],
|
||||
);
|
||||
Wheel::parse(
|
||||
&spec(),
|
||||
Some("sample-1.0.0-py2.py3-none-any.whl"),
|
||||
&tags(),
|
||||
&compressed,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
Wheel::parse(
|
||||
&spec(),
|
||||
None,
|
||||
&["cp312-cp312-linux_x86_64".into()],
|
||||
&valid()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let forged_metadata = wheel_fixture::pack(
|
||||
"sample",
|
||||
"1.0.0",
|
||||
"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
|
||||
&wheel_fixture::metadata("other", "1.0.0", &[], &[]),
|
||||
&[],
|
||||
);
|
||||
assert!(Wheel::parse(&spec(), None, &tags(), &forged_metadata).is_err());
|
||||
let hidden_binary = wheel_fixture::pack(
|
||||
"sample",
|
||||
"1.0.0",
|
||||
"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
|
||||
&wheel_fixture::metadata("sample", "1.0.0", &[], &[]),
|
||||
&[("sample/native.so", elf(62, None, None))],
|
||||
);
|
||||
assert!(Wheel::parse(&spec(), None, &tags(), &hidden_binary).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "author operation: explicitly fetches PyPI simplejson; requires target/python-managed/config/setting.json and runtime.pack"]
|
||||
fn prepare_official_native_python_fixture() {
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Settings {
|
||||
runtime: RegistryRuntime,
|
||||
}
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/python-managed");
|
||||
let settings: Settings =
|
||||
serde_json::from_slice(&fs::read(root.join("config/setting.json")).unwrap()).unwrap();
|
||||
let pack = fs::read(root.join("runtime.pack")).unwrap();
|
||||
assert_eq!(digest(&pack), settings.runtime.pack.sha256);
|
||||
assert_eq!(
|
||||
settings.runtime.target,
|
||||
dever_cli::toolchain::platform_identity()
|
||||
);
|
||||
let target = settings.runtime.target.clone();
|
||||
let store = FixtureArtifactStore::default();
|
||||
let transport = dever_cli::libs::HttpRegistry::official();
|
||||
let resolver = RegistryResolver {
|
||||
build: None,
|
||||
transport: &transport,
|
||||
store: &store,
|
||||
runtimes: BTreeMap::from([(Ecosystem::Pip, settings.runtime)]),
|
||||
go_sumdb: dever_cli::libs::sumdb::ChecksumDatabase::official(None).unwrap(),
|
||||
};
|
||||
let lock = dever_cli::libs::LibResolver::resolve(
|
||||
&resolver,
|
||||
&["pip:simplejson@3.20.1".parse().unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
dever_cli::libs::doctor(&lock).unwrap();
|
||||
dever_cli::libs::verify_locked_artifacts(&lock, &store, &target).unwrap();
|
||||
let artifact = &lock.libs[0].artifacts[0];
|
||||
let wheel = store
|
||||
.verify_exact(&artifact.sha256, artifact.bytes, &target)
|
||||
.unwrap();
|
||||
assert!(
|
||||
unpack(&wheel)
|
||||
.iter()
|
||||
.any(|(path, bytes)| path.contains("_speedups.")
|
||||
&& path.ends_with(".so")
|
||||
&& bytes.starts_with(b"\x7fELF")),
|
||||
"fixture must use native speedups, not the optional pure-Python fallback"
|
||||
);
|
||||
publish_fixture_artifacts(&root.join("dependency"), &lock, &store);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_record_requires_exact_files_hashes_sizes_and_unique_csv_paths() {
|
||||
for mutation in 0..6 {
|
||||
let mut files = unpack(&valid());
|
||||
match mutation {
|
||||
0 => files
|
||||
.iter_mut()
|
||||
.find(|(path, _)| path.ends_with("__init__.py"))
|
||||
.unwrap()
|
||||
.1
|
||||
.push(b'!'),
|
||||
1 => files.push(("sample/unlisted.py".into(), vec![b'x'])),
|
||||
2 => {
|
||||
files.retain(|(path, _)| !path.ends_with("__init__.py"));
|
||||
}
|
||||
_ => {
|
||||
let record = &mut files
|
||||
.iter_mut()
|
||||
.find(|(path, _)| path.ends_with("/RECORD"))
|
||||
.unwrap()
|
||||
.1;
|
||||
let mut content = String::from_utf8(record.clone()).unwrap();
|
||||
match mutation {
|
||||
3 => content = content.replace("sha256=", "sha1="),
|
||||
4 => content.push_str("sample-1.0.0.dist-info/RECORD,,\n"),
|
||||
5 => content.push_str("../outside.py,sha256=abc,1\n"),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
*record = content.into_bytes();
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
Wheel::parse(&spec(), None, &tags(), &wheel_fixture::zip(&files)).is_err(),
|
||||
"mutation {mutation}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_installs_all_standard_schemes_and_rewrites_installed_record() {
|
||||
let bytes = wheel_fixture::pack(
|
||||
"sample",
|
||||
"1.0.0",
|
||||
"Wheel-Version: 1.0\nRoot-Is-Purelib: false\nTag: py3-none-any\n",
|
||||
&wheel_fixture::metadata("sample", "1.0.0", &[], &[]),
|
||||
&[
|
||||
("sample-1.0.0.data/purelib/plain.py", b"plain".to_vec()),
|
||||
(
|
||||
"sample-1.0.0.data/platlib/platform.py",
|
||||
b"platform".to_vec(),
|
||||
),
|
||||
(
|
||||
"sample-1.0.0.data/scripts/tool",
|
||||
b"#!python\nprint('ok')\n".to_vec(),
|
||||
),
|
||||
("sample-1.0.0.data/headers/public.h", b"header".to_vec()),
|
||||
(
|
||||
"sample-1.0.0.data/data/share/sample/schema.json",
|
||||
b"{}".to_vec(),
|
||||
),
|
||||
("sample/name,with\"quotes.py", b"quoted".to_vec()),
|
||||
],
|
||||
);
|
||||
let files = Wheel::parse(&spec(), None, &tags(), &bytes)
|
||||
.unwrap()
|
||||
.install("runtime/bin/python3", "3.12")
|
||||
.unwrap();
|
||||
for path in [
|
||||
"runtime/lib/python3.12/site-packages/plain.py",
|
||||
"runtime/lib/python3.12/site-packages/platform.py",
|
||||
"runtime/include/python3.12/sample/public.h",
|
||||
"runtime/share/sample/schema.json",
|
||||
"runtime/lib/python3.12/site-packages/sample/name,with\"quotes.py",
|
||||
] {
|
||||
assert!(files.iter().any(|file| file.path == path), "{path}");
|
||||
}
|
||||
let script = files
|
||||
.iter()
|
||||
.find(|file| file.path == "runtime/bin/tool")
|
||||
.unwrap();
|
||||
assert!(script.executable);
|
||||
assert!(
|
||||
script
|
||||
.bytes
|
||||
.starts_with(b"#!/worker/runtime/bin/python3 -I\n")
|
||||
);
|
||||
let record = files
|
||||
.iter()
|
||||
.find(|file| file.path.ends_with("/RECORD"))
|
||||
.unwrap();
|
||||
let rows = csv::ReaderBuilder::new()
|
||||
.has_headers(false)
|
||||
.from_reader(record.bytes.as_slice())
|
||||
.records()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
let installed_script = rows
|
||||
.iter()
|
||||
.find(|row| &row[0] == "../../../bin/tool")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
installed_script[1].to_owned(),
|
||||
format!(
|
||||
"sha256={}",
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(Sha256::digest(&script.bytes))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_rejects_scheme_collisions_and_uses_artifact_dependencies() {
|
||||
let bytes = wheel_fixture::pack(
|
||||
"sample",
|
||||
"1.0.0",
|
||||
"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
|
||||
&wheel_fixture::metadata("sample", "1.0.0", &[], &[]),
|
||||
&[
|
||||
("same.py", vec![b'a']),
|
||||
("sample-1.0.0.data/platlib/same.py", vec![b'b']),
|
||||
],
|
||||
);
|
||||
assert!(
|
||||
Wheel::parse(&spec(), None, &tags(), &bytes)
|
||||
.unwrap()
|
||||
.install("runtime/bin/python3", "3.12")
|
||||
.is_err()
|
||||
);
|
||||
let mut registry = local_registry();
|
||||
let release = registry
|
||||
.0
|
||||
.get_mut(&(Ecosystem::Pip, "/pypi/a/1.0.0/json".into()))
|
||||
.unwrap();
|
||||
let mut document: serde_json::Value = serde_json::from_slice(release).unwrap();
|
||||
document["info"]["requires_dist"] = serde_json::json!([]);
|
||||
*release = serde_json::to_vec(&document).unwrap();
|
||||
let lock = python_lock(®istry, &["pip:a@1.0.0"]).unwrap();
|
||||
assert_eq!(dependency_keys(&lock, "pip:a@1.0.0"), ["pip:b@1.1.0"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_backtracks_on_selected_wheel_metadata_and_its_actual_extras() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
python_release(
|
||||
&mut registry,
|
||||
"root",
|
||||
"1.0.0",
|
||||
&[],
|
||||
&["a[x] >=1", "b ==1.0.0"],
|
||||
);
|
||||
python_release(
|
||||
&mut registry,
|
||||
"a",
|
||||
"2.0.0",
|
||||
&["x"],
|
||||
&["b ==2.0.0; extra == 'x'"],
|
||||
);
|
||||
python_release(
|
||||
&mut registry,
|
||||
"a",
|
||||
"1.0.0",
|
||||
&["x"],
|
||||
&["b ==1.0.0; extra == 'x'"],
|
||||
);
|
||||
python_release(&mut registry, "b", "2.0.0", &[], &[]);
|
||||
python_release(&mut registry, "b", "1.0.0", &[], &[]);
|
||||
for version in ["1.0.0", "2.0.0"] {
|
||||
let json = registry
|
||||
.0
|
||||
.get_mut(&(Ecosystem::Pip, format!("/pypi/a/{version}/json")))
|
||||
.unwrap();
|
||||
let mut release: serde_json::Value = serde_json::from_slice(json).unwrap();
|
||||
release["info"] = serde_json::json!({"requires_dist":null,"provides_extra":null});
|
||||
*json = serde_json::to_vec(&release).unwrap();
|
||||
}
|
||||
let lock = python_lock(®istry, &["pip:root@1.0.0"]).unwrap();
|
||||
assert!(
|
||||
lock.libs
|
||||
.iter()
|
||||
.any(|lib| lib.spec.key() == "pip:a[x]@1.0.0")
|
||||
);
|
||||
assert!(!lock.libs.iter().any(|lib| lib.spec.version == "2.0.0"));
|
||||
assert_eq!(dependency_keys(&lock, "pip:a[x]@1.0.0"), ["pip:b@1.0.0"]);
|
||||
}
|
||||
|
||||
/// ELF64 protocol fixture with PT_LOAD and optional PT_DYNAMIC/DT_STRTAB.
|
||||
/// It is parsed only; no executable bytes are launched by this test.
|
||||
fn elf(machine: u16, needed: Option<&str>, rpath: Option<&str>) -> Vec<u8> {
|
||||
let mut bytes = vec![0u8; 1024];
|
||||
bytes[..7].copy_from_slice(b"\x7fELF\x02\x01\x01");
|
||||
bytes[16..18].copy_from_slice(&3u16.to_le_bytes());
|
||||
bytes[18..20].copy_from_slice(&machine.to_le_bytes());
|
||||
bytes[20..24].copy_from_slice(&1u32.to_le_bytes());
|
||||
bytes[32..40].copy_from_slice(&64u64.to_le_bytes());
|
||||
bytes[52..54].copy_from_slice(&64u16.to_le_bytes());
|
||||
bytes[54..56].copy_from_slice(&56u16.to_le_bytes());
|
||||
bytes[56..58].copy_from_slice(&2u16.to_le_bytes());
|
||||
bytes[64..68].copy_from_slice(&1u32.to_le_bytes());
|
||||
bytes[96..104].copy_from_slice(&1024u64.to_le_bytes());
|
||||
bytes[104..112].copy_from_slice(&1024u64.to_le_bytes());
|
||||
bytes[120..124].copy_from_slice(&2u32.to_le_bytes());
|
||||
bytes[128..136].copy_from_slice(&176u64.to_le_bytes());
|
||||
bytes[136..144].copy_from_slice(&176u64.to_le_bytes());
|
||||
let mut strings = vec![0];
|
||||
let mut dynamic = vec![];
|
||||
for (tag, value) in [(1u64, needed), (15u64, rpath)] {
|
||||
if let Some(value) = value {
|
||||
dynamic.push((tag, strings.len() as u64));
|
||||
strings.extend(value.bytes());
|
||||
strings.push(0);
|
||||
}
|
||||
}
|
||||
dynamic.extend([(5, 512), (10, strings.len() as u64), (0, 0)]);
|
||||
bytes[152..160].copy_from_slice(&((dynamic.len() * 16) as u64).to_le_bytes());
|
||||
bytes[160..168].copy_from_slice(&((dynamic.len() * 16) as u64).to_le_bytes());
|
||||
for (index, (tag, value)) in dynamic.into_iter().enumerate() {
|
||||
bytes[176 + index * 16..184 + index * 16].copy_from_slice(&tag.to_le_bytes());
|
||||
bytes[184 + index * 16..192 + index * 16].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
bytes[512..512 + strings.len()].copy_from_slice(&strings);
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_wheel_checks_target_suffix_and_closed_library_search() {
|
||||
let runtime = elf(62, None, None);
|
||||
let library = elf(62, None, None);
|
||||
let native = elf(62, Some("libsample.so.1"), Some("$ORIGIN/../sample.libs"));
|
||||
let suffixes = vec![
|
||||
".cpython-312-x86_64-linux-gnu.so".into(),
|
||||
".abi3.so".into(),
|
||||
".so".into(),
|
||||
];
|
||||
let mut files = BTreeMap::from([
|
||||
("runtime/bin/python3".into(), runtime.as_slice()),
|
||||
(
|
||||
"site-packages/sample/_native.cpython-312-x86_64-linux-gnu.so".into(),
|
||||
native.as_slice(),
|
||||
),
|
||||
(
|
||||
"site-packages/sample.libs/libsample.so.1".into(),
|
||||
library.as_slice(),
|
||||
),
|
||||
]);
|
||||
validate_native("linux-x86_64", &suffixes, "runtime/bin/python3", &files).unwrap();
|
||||
files.remove("site-packages/sample.libs/libsample.so.1");
|
||||
assert!(
|
||||
validate_native("linux-x86_64", &suffixes, "runtime/bin/python3", &files)
|
||||
.unwrap_err()
|
||||
.contains("packaged dependency closure")
|
||||
);
|
||||
files.insert("sandbox/lib/libsample.so.1".into(), library.as_slice());
|
||||
validate_native("linux-x86_64", &suffixes, "runtime/bin/python3", &files).unwrap();
|
||||
let old = files
|
||||
.remove("site-packages/sample/_native.cpython-312-x86_64-linux-gnu.so")
|
||||
.unwrap();
|
||||
files.insert(
|
||||
"site-packages/sample/_native.cpython-311-x86_64-linux-gnu.so".into(),
|
||||
old,
|
||||
);
|
||||
assert!(
|
||||
validate_native("linux-x86_64", &suffixes, "runtime/bin/python3", &files)
|
||||
.unwrap_err()
|
||||
.contains("suffix")
|
||||
);
|
||||
for (machine, rpath, message) in [
|
||||
(183, "$ORIGIN", "target"),
|
||||
(62, "/usr/lib", "$ORIGIN"),
|
||||
(62, "$ORIGIN/../../../..", "escapes"),
|
||||
] {
|
||||
let rejected = elf(machine, None, Some(rpath));
|
||||
let files = BTreeMap::from([
|
||||
("runtime/bin/python3".into(), runtime.as_slice()),
|
||||
("site-packages/sample/native.so".into(), rejected.as_slice()),
|
||||
]);
|
||||
assert!(
|
||||
validate_native("linux-x86_64", &suffixes, "runtime/bin/python3", &files)
|
||||
.unwrap_err()
|
||||
.contains(message)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_runpath_outranks_ancestor_rpath_and_helpers_keep_import_context() {
|
||||
let runtime = elf(62, None, Some("$ORIGIN/../lib"));
|
||||
let shared = elf(62, None, None);
|
||||
let wrong_target = elf(183, None, None);
|
||||
let mut extension = elf(
|
||||
62,
|
||||
Some("libsample.so.1"),
|
||||
Some("$ORIGIN/../../runtime/alternate"),
|
||||
);
|
||||
// Replace DT_RPATH with DT_RUNPATH in the independently encoded ELF vector.
|
||||
for offset in (176..256).step_by(16) {
|
||||
if u64::from_le_bytes(extension[offset..offset + 8].try_into().unwrap()) == 15 {
|
||||
extension[offset..offset + 8].copy_from_slice(&29u64.to_le_bytes());
|
||||
}
|
||||
}
|
||||
let suffixes = vec![".cpython-312-x86_64-linux-gnu.so".into()];
|
||||
let files = BTreeMap::from([
|
||||
("runtime/bin/python3".into(), runtime.as_slice()),
|
||||
("runtime/lib/libsample.so.1".into(), shared.as_slice()),
|
||||
(
|
||||
"runtime/alternate/libsample.so.1".into(),
|
||||
wrong_target.as_slice(),
|
||||
),
|
||||
(
|
||||
"site-packages/pkg/mod.cpython-312-x86_64-linux-gnu.so".into(),
|
||||
extension.as_slice(),
|
||||
),
|
||||
]);
|
||||
assert!(
|
||||
validate_native("linux-x86_64", &suffixes, "runtime/bin/python3", &files)
|
||||
.unwrap_err()
|
||||
.contains("target")
|
||||
);
|
||||
|
||||
let extension = elf(62, Some("liba.so.1"), Some("$ORIGIN/../vendor"));
|
||||
let helper = elf(62, Some("libb.so.1"), None);
|
||||
let files = BTreeMap::from([
|
||||
("runtime/bin/python3".into(), runtime.as_slice()),
|
||||
(
|
||||
"site-packages/pkg/mod.cpython-312-x86_64-linux-gnu.so".into(),
|
||||
extension.as_slice(),
|
||||
),
|
||||
("site-packages/vendor/liba.so.1".into(), helper.as_slice()),
|
||||
("site-packages/vendor/libb.so.1".into(), shared.as_slice()),
|
||||
]);
|
||||
validate_native("linux-x86_64", &suffixes, "runtime/bin/python3", &files).unwrap();
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,204 @@
|
||||
use super::*;
|
||||
use dever_cli::toolchain::BuildTarget;
|
||||
|
||||
fn command_fixture(root: &Path, binary: &[u8]) -> dever_core::hir::Program {
|
||||
let mut sources = SourceMap::default();
|
||||
sources.add(
|
||||
"media/tool/port.dever",
|
||||
"execute(args: List<Text>) (output: dever.process.Output) fails dever.process.Error",
|
||||
);
|
||||
sources.add(
|
||||
"media/tool/adapter.dever",
|
||||
"external command \"bin/tool\" {}\n",
|
||||
);
|
||||
sources.add("media/tool/app.dever", "execute() (code: Int) {\n output = port.execute([\"a b\", \"$(literal)\"])\n code = output.code\n}\n");
|
||||
sources.add("media/tool/api.dever", "cmd execute = app.execute\n");
|
||||
let program = dever_core::check(&sources).unwrap_or_else(|errors| panic!("{errors:?}"));
|
||||
let entry = &program.external_command_contracts()[0].entry;
|
||||
fs::create_dir_all(root.join(entry).parent().unwrap()).unwrap();
|
||||
fs::write(root.join(entry), binary).unwrap();
|
||||
program
|
||||
}
|
||||
|
||||
fn command_manifest(resources: &[EmbeddedResource]) -> serde_json::Value {
|
||||
let manifest = resources
|
||||
.iter()
|
||||
.find(|resource| resource.path.ends_with(".dever-command.json"))
|
||||
.unwrap();
|
||||
serde_json::from_slice(&manifest.bytes).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_preparation_needs_no_ecosystem_lock_and_keeps_target_identity() {
|
||||
for (target, machine) in [
|
||||
(BuildTarget::LinuxX86_64, 62u16),
|
||||
(BuildTarget::LinuxAarch64, 183u16),
|
||||
] {
|
||||
let temp = TemporaryDirectory::new();
|
||||
let mut binary = native_elf::static_executable();
|
||||
binary[18..20].copy_from_slice(&machine.to_le_bytes());
|
||||
let program = command_fixture(temp.path(), &binary);
|
||||
assert!(program.external_worker_contracts().is_empty());
|
||||
assert!(
|
||||
dever_cli::libs::prepare_program(temp.path(), &program, target)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let resources =
|
||||
workers::prepare_for_target(temp.path(), &program, &[], target, &[]).unwrap();
|
||||
let manifest = command_manifest(&resources);
|
||||
assert_eq!(manifest["format"], "dever-command-launch-v1");
|
||||
assert_eq!(manifest["arguments"], json!([]));
|
||||
assert_eq!(manifest["ecosystem"], "command");
|
||||
let executable = resources
|
||||
.iter()
|
||||
.find(|resource| Some(resource.path.as_str()) == manifest["executable"].as_str())
|
||||
.unwrap();
|
||||
assert!(executable.executable);
|
||||
assert_eq!(executable.bytes, binary);
|
||||
let other = if target == BuildTarget::LinuxX86_64 {
|
||||
BuildTarget::LinuxAarch64
|
||||
} else {
|
||||
BuildTarget::LinuxX86_64
|
||||
};
|
||||
assert!(
|
||||
workers::prepare_for_target(temp.path(), &program, &[], other, &[])
|
||||
.unwrap_err()
|
||||
.contains("differs from target")
|
||||
);
|
||||
assert!(!temp.path().join("dever.lock").exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_preparation_checks_recursive_library_closure_and_loader() {
|
||||
let temp = TemporaryDirectory::new();
|
||||
let program = command_fixture(temp.path(), &native_elf::compiler(&["libfirst.so"]));
|
||||
assert!(
|
||||
workers::prepare(temp.path(), &program, &[])
|
||||
.unwrap_err()
|
||||
.contains("libfirst.so")
|
||||
);
|
||||
let lib = temp.path().join("module/media/tool/bin/lib");
|
||||
fs::create_dir_all(&lib).unwrap();
|
||||
fs::write(
|
||||
lib.join("libfirst.so"),
|
||||
native_elf::library("libfirst.so", &["libsecond.so"]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
workers::prepare(temp.path(), &program, &[])
|
||||
.unwrap_err()
|
||||
.contains("libsecond.so")
|
||||
);
|
||||
fs::write(
|
||||
lib.join("libsecond.so"),
|
||||
native_elf::library("libwrong.so", &[]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
workers::prepare(temp.path(), &program, &[])
|
||||
.unwrap_err()
|
||||
.contains("SONAME")
|
||||
);
|
||||
fs::write(
|
||||
lib.join("libsecond.so"),
|
||||
native_elf::library("libsecond.so", &[]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
workers::prepare(temp.path(), &program, &[])
|
||||
.unwrap_err()
|
||||
.contains("signed target GNU loader")
|
||||
);
|
||||
let loader_name = dever_sandbox::loader_name();
|
||||
let mut loader = resource(
|
||||
format!("sandbox/lib/{loader_name}"),
|
||||
native_elf::static_executable(),
|
||||
);
|
||||
loader.executable = true;
|
||||
let resources = workers::prepare(temp.path(), &program, &[loader]).unwrap();
|
||||
let manifest = command_manifest(&resources);
|
||||
assert!(
|
||||
manifest["executable"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.ends_with("/loader")
|
||||
);
|
||||
assert_eq!(manifest["arguments"][0]["literal"], "--inhibit-cache");
|
||||
for name in ["libfirst.so", "libsecond.so"] {
|
||||
assert!(resources.iter().any(
|
||||
|resource| resource.path.ends_with(&format!("/lib/{name}")) && !resource.executable
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_rejects_host_library_paths_and_signed_library_shadowing() {
|
||||
let temp = TemporaryDirectory::new();
|
||||
let program = command_fixture(
|
||||
temp.path(),
|
||||
&native_elf::elf(None, &["libfirst.so"], Some("/usr/lib")),
|
||||
);
|
||||
assert!(
|
||||
workers::prepare(temp.path(), &program, &[])
|
||||
.unwrap_err()
|
||||
.contains("$ORIGIN")
|
||||
);
|
||||
let entry = &program.external_command_contracts()[0].entry;
|
||||
for path in ["$ORIGIN/../data/grant", "${ORIGIN}/../lib"] {
|
||||
fs::write(
|
||||
temp.path().join(entry),
|
||||
native_elf::elf(None, &[], Some(path)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
workers::prepare(temp.path(), &program, &[])
|
||||
.unwrap_err()
|
||||
.contains("escapes its packaged tree")
|
||||
);
|
||||
}
|
||||
fs::write(
|
||||
temp.path().join(entry),
|
||||
native_elf::compiler(&["libfirst.so"]),
|
||||
)
|
||||
.unwrap();
|
||||
let lib = temp.path().join("module/media/tool/bin/lib");
|
||||
fs::create_dir_all(&lib).unwrap();
|
||||
fs::write(
|
||||
lib.join("libfirst.so"),
|
||||
native_elf::library("libfirst.so", &[]),
|
||||
)
|
||||
.unwrap();
|
||||
let signed = resource(
|
||||
"sandbox/lib/libfirst.so".into(),
|
||||
native_elf::library("libfirst.so", &[]),
|
||||
);
|
||||
assert!(
|
||||
workers::prepare(temp.path(), &program, &[signed])
|
||||
.unwrap_err()
|
||||
.contains("overrides a signed sandbox library")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn command_rejects_symlinks_and_library_subdirectories() {
|
||||
let temp = TemporaryDirectory::new();
|
||||
let program = command_fixture(temp.path(), &native_elf::static_executable());
|
||||
let lib = temp.path().join("module/media/tool/bin/lib");
|
||||
fs::create_dir_all(&lib).unwrap();
|
||||
std::os::unix::fs::symlink("../tool", lib.join("libalias.so")).unwrap();
|
||||
assert!(
|
||||
workers::prepare(temp.path(), &program, &[])
|
||||
.unwrap_err()
|
||||
.contains("regular library files")
|
||||
);
|
||||
fs::remove_file(lib.join("libalias.so")).unwrap();
|
||||
fs::create_dir(lib.join("hidden")).unwrap();
|
||||
assert!(
|
||||
workers::prepare(temp.path(), &program, &[])
|
||||
.unwrap_err()
|
||||
.contains("regular library files")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires the explicit official native npm build fixture at target/npm-build"]
|
||||
fn runs_official_npm_addon_from_offline_resources_and_threads() {
|
||||
let temp = TemporaryDirectory::new();
|
||||
let program = dever_core::check(&worker_sources(
|
||||
"npm",
|
||||
"worker/main.cjs",
|
||||
"lib \"bufferutil@4.0.9\"",
|
||||
))
|
||||
.unwrap();
|
||||
let contract = program.external_worker_contracts().remove(0);
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let prepared = workspace.join("target/npm-build");
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(prepared.join("config/setting.json")).unwrap()).unwrap();
|
||||
let pack = fs::read(prepared.join(config["runtime_file"].as_str().unwrap())).unwrap();
|
||||
let mut lock =
|
||||
LockFile::decode(&fs::read(prepared.join("dependency/dever.lock")).unwrap()).unwrap();
|
||||
let runtime = lock.libs[0].runtime.clone();
|
||||
assert_eq!(sha(&pack), runtime.sha256);
|
||||
let rejected = lock.builds[0]
|
||||
.native_rejections
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
!rejected.is_empty(),
|
||||
"official multi-platform prebuilds must be classified"
|
||||
);
|
||||
let entry = temp.path().join(&contract.entry);
|
||||
fs::create_dir_all(entry.parent().unwrap()).unwrap();
|
||||
let adapter = r#"const addon = require('bufferutil/build/Release/bufferutil.node');
|
||||
const {Worker} = require('node:worker_threads');
|
||||
const path = require('node:path');
|
||||
const rejected = __REJECTED__.map(name => path.join(__dirname, '..', name));
|
||||
exports.send = async payload => {
|
||||
await new Promise((resolve, reject) => {
|
||||
const worker = new Worker(`const {parentPort,workerData}=require('node:worker_threads'); const fs=require('node:fs'); parentPort.postMessage(workerData.every(path=>!fs.existsSync(path)));`, {eval:true,workerData:rejected});
|
||||
worker.once('message', ok => ok ? resolve() : reject(new Error('unverified addon was installed')));
|
||||
worker.once('error', reject);
|
||||
});
|
||||
const input=Buffer.from([payload.value]), output=Buffer.alloc(1), mask=Buffer.from([1,2,3,4]);
|
||||
addon.mask(input,mask,output,0,1); addon.unmask(output,mask);
|
||||
return {receipt:output[0]+1};
|
||||
};
|
||||
"#.replace("__REJECTED__",&serde_json::to_string(&rejected).unwrap());
|
||||
fs::write(&entry, adapter).unwrap();
|
||||
let mut resources = vec![resource(
|
||||
format!(
|
||||
"lib/runtime/npm/{}/runtime.pack",
|
||||
dever_cli::toolchain::platform_identity()
|
||||
),
|
||||
pack,
|
||||
)];
|
||||
for library in &mut lock.libs {
|
||||
library.schema = contract.schema.clone();
|
||||
}
|
||||
for artifact in lock
|
||||
.libs
|
||||
.iter()
|
||||
.flat_map(|lib| &lib.artifacts)
|
||||
.chain(lock.builds.iter().map(|receipt| &receipt.output))
|
||||
{
|
||||
let bytes = fs::read(prepared.join("dependency/artifacts").join(&artifact.sha256)).unwrap();
|
||||
assert_eq!(sha(&bytes), artifact.sha256);
|
||||
resources.push(resource(artifact.path.clone(), bytes));
|
||||
}
|
||||
for asset in sandbox::assets(&workspace) {
|
||||
let mut file = resource(asset.path, asset.bytes);
|
||||
file.executable = asset.executable;
|
||||
resources.push(file);
|
||||
}
|
||||
lock.workers.push(LockedWorker {
|
||||
port: contract.port.clone(),
|
||||
adapter: contract.adapter.clone(),
|
||||
ecosystem: "npm".into(),
|
||||
runtime: Some(runtime),
|
||||
entry: contract.entry.clone(),
|
||||
schema: contract.schema.clone(),
|
||||
capabilities: contract.capabilities.clone(),
|
||||
operations: contract.operations.clone(),
|
||||
libs: contract
|
||||
.libs
|
||||
.iter()
|
||||
.map(|spec| spec.parse().unwrap())
|
||||
.collect(),
|
||||
});
|
||||
lock.write_atomic(temp.path()).unwrap();
|
||||
let mut generated = workers::prepare(temp.path(), &program, &resources).unwrap();
|
||||
let again = workers::prepare(temp.path(), &program, &resources).unwrap();
|
||||
assert_eq!(
|
||||
generated
|
||||
.iter()
|
||||
.map(|file| (&file.path, &file.sha256))
|
||||
.collect::<Vec<_>>(),
|
||||
again
|
||||
.iter()
|
||||
.map(|file| (&file.path, &file.sha256))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert!(
|
||||
generated
|
||||
.iter()
|
||||
.any(|file| file.path.ends_with("/build/Release/bufferutil.node")
|
||||
&& file.bytes.starts_with(b"\x7fELF"))
|
||||
);
|
||||
for rejected in &rejected {
|
||||
assert!(!generated.iter().any(|file| file.path.ends_with(rejected)));
|
||||
}
|
||||
generated.extend(
|
||||
resources
|
||||
.into_iter()
|
||||
.filter(|file| file.path.starts_with("sandbox/")),
|
||||
);
|
||||
fs::remove_file(entry).unwrap();
|
||||
run_packaged_protocol(&temp, &contract, "npm", &generated);
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
//! Actual default CLI boundary. Native cases consume an explicitly prepared
|
||||
//! runtime archive; ordinary negative checks never start a compiler subprocess.
|
||||
#![cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[path = "support/llvm_pack.rs"]
|
||||
mod pack;
|
||||
#[path = "../dever-tests/tests/support/process.rs"]
|
||||
mod process;
|
||||
#[path = "../dever-tests/tests/support/temp.rs"]
|
||||
mod temp;
|
||||
use temp::TemporaryDirectory;
|
||||
|
||||
const APP: &str = r#"type Outcome { error Denied(message: Text, labels: List<Text?>) }
|
||||
echo(name: Text) (value: Text) { dever.task.sleep(1)
|
||||
value = name }
|
||||
divide(divisor: Int) (value: Int) { value = 42 // divisor }
|
||||
deny() (value: Text) { fail(Outcome.Denied("owned denial", ["nested label", null])) }
|
||||
"#;
|
||||
|
||||
fn write(root: &Path, relative: &str, source: &str) {
|
||||
let path = root.join(relative);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, source).unwrap();
|
||||
}
|
||||
|
||||
fn project(root: &Path) {
|
||||
write(root, "module/sample/echo/app.dever", APP);
|
||||
write(
|
||||
root,
|
||||
"module/sample/echo/api.dever",
|
||||
"cmd echo = app.echo\ncmd divide = app.divide\ncmd deny = app.deny",
|
||||
);
|
||||
write(root, "config/setting.json", "{}");
|
||||
}
|
||||
|
||||
fn compiler(root: &Path, name: &str) -> PathBuf {
|
||||
pack::compiler(Path::new(env!("CARGO_BIN_EXE_dever")), root, name)
|
||||
}
|
||||
|
||||
fn output(command: &mut Command) -> Output {
|
||||
let capture = TemporaryDirectory::new();
|
||||
let stdout = capture.path().join("stdout");
|
||||
let stderr = capture.path().join("stderr");
|
||||
command
|
||||
.stdout(Stdio::from(fs::File::create(&stdout).unwrap()))
|
||||
.stderr(Stdio::from(fs::File::create(&stderr).unwrap()));
|
||||
let status = process::status(command, Duration::from_secs(180)).unwrap();
|
||||
Output {
|
||||
status,
|
||||
stdout: fs::read(stdout).unwrap(),
|
||||
stderr: fs::read(stderr).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cli(compiler: &Path, project: &Path, action: &str, args: &[&str]) -> Output {
|
||||
output(
|
||||
Command::new(compiler)
|
||||
.env_clear()
|
||||
.arg(action)
|
||||
.arg(project)
|
||||
.args(args),
|
||||
)
|
||||
}
|
||||
|
||||
fn success(output: &Output) {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn failure(output: &Output, expected: &str) {
|
||||
assert!(!output.status.success(), "unexpected success");
|
||||
let message = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
message.contains(expected),
|
||||
"expected {expected:?}: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_pack_fails_without_host_tools_and_check_and_empty_test_need_no_pack() {
|
||||
let toolchain = TemporaryDirectory::new();
|
||||
let compiler = compiler(toolchain.path(), "dever");
|
||||
let help = output(Command::new(&compiler).env_clear().arg("--help"));
|
||||
success(&help);
|
||||
assert!(String::from_utf8_lossy(&help.stdout).contains("dever run <project-root>"));
|
||||
let project_dir = TemporaryDirectory::new();
|
||||
project(project_dir.path());
|
||||
success(&cli(&compiler, project_dir.path(), "check", &[]));
|
||||
let tests = cli(&compiler, project_dir.path(), "test", &[]);
|
||||
success(&tests);
|
||||
assert!(String::from_utf8_lossy(&tests.stdout).contains("0 passed; 0 failed"));
|
||||
failure(
|
||||
&cli(
|
||||
&compiler,
|
||||
project_dir.path(),
|
||||
"run",
|
||||
&["--", "sample.echo.echo", "{\"name\":\"Ada\"}"],
|
||||
),
|
||||
"runtime pack",
|
||||
);
|
||||
assert!(!toolchain.path().join("cache").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_build_requires_the_selected_pack_even_when_host_pack_exists() {
|
||||
let root = TemporaryDirectory::new();
|
||||
let compiler = compiler(root.path(), "dever");
|
||||
pack::manifest(root.path(), false);
|
||||
let project_dir = TemporaryDirectory::new();
|
||||
project(project_dir.path());
|
||||
let executable = root.path().join("arm-application");
|
||||
failure(
|
||||
&cli(
|
||||
&compiler,
|
||||
project_dir.path(),
|
||||
"build",
|
||||
&[
|
||||
"--target",
|
||||
"linux-aarch64",
|
||||
"--output",
|
||||
executable.to_str().unwrap(),
|
||||
],
|
||||
),
|
||||
"runtime/linux-aarch64",
|
||||
);
|
||||
assert!(!executable.exists());
|
||||
assert!(!root.path().join("cache").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_cores_never_fall_back_to_private_packs_or_cache() {
|
||||
let root = TemporaryDirectory::new();
|
||||
let project_dir = TemporaryDirectory::new();
|
||||
project(project_dir.path());
|
||||
for (directory, diagnostic) in [
|
||||
(
|
||||
root.path().join("standalone"),
|
||||
"managed compiler must be installed",
|
||||
),
|
||||
(
|
||||
root.path().join("versions/0.1.0"),
|
||||
"cannot inspect deverd state directory",
|
||||
),
|
||||
] {
|
||||
let compiler = compiler(&directory, "dever-core");
|
||||
failure(&cli(&compiler, project_dir.path(), "run", &[]), diagnostic);
|
||||
assert!(!directory.join("cache").exists());
|
||||
}
|
||||
let installed = root.path().join("versions/0.1.0/dever-core");
|
||||
let worker = output(
|
||||
Command::new(installed)
|
||||
.env_clear()
|
||||
.current_dir(project_dir.path())
|
||||
.arg("--dever-compile-worker"),
|
||||
);
|
||||
failure(&worker, "service-owned native cache staging directory");
|
||||
assert!(!project_dir.path().join("program").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_contract_rejects_drift_duplicate_paths_and_undeclared_link_inputs() {
|
||||
let root = TemporaryDirectory::new();
|
||||
let compiler = compiler(root.path(), "dever");
|
||||
let original = pack::manifest(root.path(), false);
|
||||
let project_dir = TemporaryDirectory::new();
|
||||
project(project_dir.path());
|
||||
let mutations: &[(&str, Value, &str)] = &[
|
||||
("/format", json!("future"), "does not match"),
|
||||
("/compiler_version", json!("99.0.0"), "does not match"),
|
||||
("/abi", json!(99), "does not match"),
|
||||
(
|
||||
"/target",
|
||||
json!("aarch64-unknown-linux-gnu"),
|
||||
"does not match",
|
||||
),
|
||||
("/profiles/base", Value::Null, "missing the required 'base'"),
|
||||
(
|
||||
"/profiles/base/runtime",
|
||||
json!("missing.a"),
|
||||
"undeclared or duplicate",
|
||||
),
|
||||
(
|
||||
"/profiles/base/libraries/0",
|
||||
json!("runtime.a"),
|
||||
"undeclared or duplicate",
|
||||
),
|
||||
(
|
||||
"/files/0/path",
|
||||
json!("../runtime.a"),
|
||||
"normalized relative",
|
||||
),
|
||||
(
|
||||
"/files/0/path",
|
||||
json!("nested//runtime.a"),
|
||||
"normalized relative",
|
||||
),
|
||||
(
|
||||
"/files/0/path",
|
||||
json!("C:\\runtime.a"),
|
||||
"normalized relative",
|
||||
),
|
||||
(
|
||||
"/files/1/path",
|
||||
json!("runtime.a"),
|
||||
"duplicate runtime file",
|
||||
),
|
||||
("/files/0/bytes", json!(9), "SHA-256 verification"),
|
||||
(
|
||||
"/files/0/sha256",
|
||||
json!("0".repeat(64)),
|
||||
"SHA-256 verification",
|
||||
),
|
||||
];
|
||||
for (pointer, value, expected) in mutations {
|
||||
let mut manifest = original.clone();
|
||||
*manifest.pointer_mut(pointer).unwrap() = value.clone();
|
||||
pack::write_manifest(root.path(), &manifest);
|
||||
failure(&cli(&compiler, project_dir.path(), "run", &[]), expected);
|
||||
assert!(!root.path().join("cache").exists());
|
||||
}
|
||||
let text = serde_json::to_string(&original).unwrap();
|
||||
fs::write(
|
||||
pack::pack_root(root.path()).join("manifest.json"),
|
||||
text.replacen("\"abi\":1", "\"abi\":1,\"abi\":1", 1),
|
||||
)
|
||||
.unwrap();
|
||||
failure(
|
||||
&cli(&compiler, project_dir.path(), "run", &[]),
|
||||
"duplicate field",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_rejects_symlinks_and_disguised_linker_scripts() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let root = TemporaryDirectory::new();
|
||||
let compiler = compiler(root.path(), "dever");
|
||||
let mut manifest = pack::manifest(root.path(), false);
|
||||
let project_dir = TemporaryDirectory::new();
|
||||
project(project_dir.path());
|
||||
let runtime = pack::pack_root(root.path()).join("runtime.a");
|
||||
let outside = root.path().join("outside.a");
|
||||
fs::rename(&runtime, &outside).unwrap();
|
||||
symlink(&outside, &runtime).unwrap();
|
||||
failure(
|
||||
&cli(&compiler, project_dir.path(), "run", &[]),
|
||||
"must be a real file",
|
||||
);
|
||||
fs::remove_file(&runtime).unwrap();
|
||||
for content in [b"INPUT(/usr/lib/forbidden.a)".as_slice(), b"!<thin>\n"] {
|
||||
fs::write(&runtime, content).unwrap();
|
||||
manifest["files"][0]["bytes"] = json!(content.len());
|
||||
manifest["files"][0]["sha256"] = json!(pack::digest(&runtime));
|
||||
pack::write_manifest(root.path(), &manifest);
|
||||
failure(
|
||||
&cli(&compiler, project_dir.path(), "run", &[]),
|
||||
"linker scripts and thin archives are forbidden",
|
||||
);
|
||||
}
|
||||
assert!(!root.path().join("cache").exists());
|
||||
}
|
||||
|
||||
fn cached_programs(root: &Path) -> Vec<PathBuf> {
|
||||
let mut programs = fs::read_dir(root.join("cache/native"))
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().path().join("program"))
|
||||
.filter(|path| path.is_file())
|
||||
.collect::<Vec<_>>();
|
||||
programs.sort();
|
||||
programs
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicitly prepared full runtime archive and Linux CRT files; serial offline CLI execution"]
|
||||
fn default_llvm_runs_builds_moves_and_reuses_cache_without_host_toolchain() {
|
||||
let root = TemporaryDirectory::new();
|
||||
let compiler = compiler(root.path(), "dever");
|
||||
pack::manifest(root.path(), true);
|
||||
let project_dir = TemporaryDirectory::new();
|
||||
project(project_dir.path());
|
||||
let args = ["--", "sample.echo.echo", "{\"name\":\"你好 LLVM\"}"];
|
||||
let marker = root.path().join("forbidden-tool-was-started");
|
||||
for name in ["rustc", "cargo", "cc"] {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let path = root.path().join(name);
|
||||
fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"#!/bin/sh\nprintf forbidden > '{}'\nexit 99\n",
|
||||
marker.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
}
|
||||
let run = output(
|
||||
Command::new(&compiler)
|
||||
.env_clear()
|
||||
.env("PATH", root.path())
|
||||
.env("RUSTC", root.path().join("rustc"))
|
||||
.env("CARGO", root.path().join("cargo"))
|
||||
.env("CC", root.path().join("cc"))
|
||||
.arg("run")
|
||||
.arg(project_dir.path())
|
||||
.args(args),
|
||||
);
|
||||
success(&run);
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"LLVM compilation must not start a host compiler"
|
||||
);
|
||||
let response: Value = serde_json::from_slice(&run.stdout).unwrap();
|
||||
assert_eq!(response["data"], "你好 LLVM");
|
||||
let cached = cached_programs(root.path());
|
||||
assert_eq!(cached.len(), 1);
|
||||
let modified = fs::metadata(&cached[0]).unwrap().modified().unwrap();
|
||||
let binary = project_dir.path().join("standalone");
|
||||
success(&cli(
|
||||
&compiler,
|
||||
project_dir.path(),
|
||||
"build",
|
||||
&["--output", binary.to_str().unwrap()],
|
||||
));
|
||||
assert_eq!(
|
||||
fs::metadata(&cached[0]).unwrap().modified().unwrap(),
|
||||
modified
|
||||
);
|
||||
let first_digest = pack::digest(&binary);
|
||||
failure(
|
||||
&cli(
|
||||
&compiler,
|
||||
project_dir.path(),
|
||||
"build",
|
||||
&["--output", binary.to_str().unwrap()],
|
||||
),
|
||||
"cannot save executable",
|
||||
);
|
||||
assert_eq!(pack::digest(&binary), first_digest);
|
||||
let moved = TemporaryDirectory::new();
|
||||
let relocated = moved.path().join("application");
|
||||
fs::rename(&binary, &relocated).unwrap();
|
||||
write(moved.path(), "config/setting.json", "{}");
|
||||
success(&output(
|
||||
Command::new(&relocated).env_clear().args(&args[1..]),
|
||||
));
|
||||
{
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
let (reader, writer) = UnixStream::pair().unwrap();
|
||||
drop(reader);
|
||||
let stderr = moved.path().join("closed-stdout-error");
|
||||
let status = process::status(
|
||||
Command::new(&relocated)
|
||||
.env_clear()
|
||||
.args(&args[1..])
|
||||
.stdout(Stdio::from(OwnedFd::from(writer)))
|
||||
.stderr(Stdio::from(fs::File::create(&stderr).unwrap())),
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
status.code(),
|
||||
Some(1),
|
||||
"closed stdout must return a checked failure, not SIGPIPE"
|
||||
);
|
||||
let error = fs::read_to_string(stderr).unwrap();
|
||||
assert!(
|
||||
error.contains("Broken pipe") && error.contains(".dever"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
let denied = cli(
|
||||
&compiler,
|
||||
project_dir.path(),
|
||||
"run",
|
||||
&["--", "sample.echo.deny", "{}"],
|
||||
);
|
||||
failure(&denied, "owned denial");
|
||||
assert!(String::from_utf8_lossy(&denied.stderr).contains("nested label"));
|
||||
assert!(String::from_utf8_lossy(&denied.stderr).contains("app.dever"));
|
||||
failure(
|
||||
&cli(
|
||||
&compiler,
|
||||
project_dir.path(),
|
||||
"run",
|
||||
&["--", "sample.echo.echo", "{}"],
|
||||
),
|
||||
"name",
|
||||
);
|
||||
let failure_output = cli(
|
||||
&compiler,
|
||||
project_dir.path(),
|
||||
"run",
|
||||
&["--", "sample.echo.divide", "{\"divisor\":0}"],
|
||||
);
|
||||
failure(&failure_output, "division");
|
||||
assert!(String::from_utf8_lossy(&failure_output.stderr).contains("app.dever"));
|
||||
// Corrupt only the fixture-owned cache copy, never a hard-linked runtime.
|
||||
fs::write(&cached[0], b"corrupt cached executable").unwrap();
|
||||
failure(
|
||||
&cli(&compiler, project_dir.path(), "run", &args),
|
||||
"incomplete or modified",
|
||||
);
|
||||
assert_eq!(fs::read(&cached[0]).unwrap(), b"corrupt cached executable");
|
||||
write(
|
||||
project_dir.path(),
|
||||
"module/sample/echo/app.dever",
|
||||
&APP.replace("value = name", "value = name + \"!\""),
|
||||
);
|
||||
let changed = cli(&compiler, project_dir.path(), "run", &args);
|
||||
success(&changed);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&changed.stdout).unwrap()["data"],
|
||||
"你好 LLVM!"
|
||||
);
|
||||
assert_eq!(cached_programs(root.path()).len(), 2);
|
||||
|
||||
// Even a byte-level manifest change has its own exact pack identity.
|
||||
let manifest = pack::pack_root(root.path()).join("manifest.json");
|
||||
let mut bytes = fs::read(&manifest).unwrap();
|
||||
bytes.push(b'\n');
|
||||
fs::write(&manifest, bytes).unwrap();
|
||||
success(&cli(&compiler, project_dir.path(), "run", &args));
|
||||
assert_eq!(cached_programs(root.path()).len(), 3);
|
||||
|
||||
let markdown = TemporaryDirectory::new();
|
||||
write(markdown.path(), "config/setting.json", "{}");
|
||||
write(
|
||||
markdown.path(),
|
||||
"module/system/health/app.dever.md",
|
||||
include_str!("../../examples/cms/md/module/system/health/app.dever.md"),
|
||||
);
|
||||
write(
|
||||
markdown.path(),
|
||||
"module/system/health/api.dever.md",
|
||||
include_str!("../../examples/cms/md/module/system/health/api.dever.md"),
|
||||
);
|
||||
let response = cli(
|
||||
&compiler,
|
||||
markdown.path(),
|
||||
"run",
|
||||
&["--", "system.health.ping", "{}"],
|
||||
);
|
||||
success(&response);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&response.stdout).unwrap()["data"]["status"],
|
||||
"ready"
|
||||
);
|
||||
assert_eq!(cached_programs(root.path()).len(), 4);
|
||||
let object = pack::pack_root(root.path()).join("crt1.o");
|
||||
let mut corrupt = fs::read(&object).unwrap();
|
||||
corrupt[16] ^= 1;
|
||||
// Break this fixture's hard link before mutation; the host CRT is immutable.
|
||||
fs::remove_file(&object).unwrap();
|
||||
fs::write(&object, corrupt).unwrap();
|
||||
failure(
|
||||
&cli(&compiler, project_dir.path(), "run", &args),
|
||||
"SHA-256 verification",
|
||||
);
|
||||
assert_eq!(cached_programs(root.path()).len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicitly prepared runtime archive and component-fixture; no downloads or language SDK"]
|
||||
fn unsigned_llvm_run_and_build_reject_exec_workers() {
|
||||
let root = TemporaryDirectory::new();
|
||||
let compiler = compiler(root.path(), "dever");
|
||||
pack::manifest(root.path(), true);
|
||||
let project_dir = TemporaryDirectory::new();
|
||||
let directory = project_dir.path();
|
||||
write(
|
||||
directory,
|
||||
"module/notification/delivery/app.dever",
|
||||
"read() (value: Int) { value = port.read() }",
|
||||
);
|
||||
write(
|
||||
directory,
|
||||
"module/notification/delivery/port.dever",
|
||||
"read() (value: Int) fails dever.time.SleepResult",
|
||||
);
|
||||
write(
|
||||
directory,
|
||||
"module/notification/delivery/adapter.dever",
|
||||
"setting { mode: Text }\nexternal exec \"worker\" {}",
|
||||
);
|
||||
write(
|
||||
directory,
|
||||
"module/notification/delivery/api.dever",
|
||||
"cmd read = app.read",
|
||||
);
|
||||
let settings = r#"{"adapter":{"notification.delivery":{"setting":{"mode":"embedded"}}}}"#;
|
||||
write(directory, "config/setting.json", settings);
|
||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../target/native-release-inputs/component-fixture");
|
||||
assert!(
|
||||
fixture.is_file(),
|
||||
"prepare the immutable component-fixture in target/native-release-inputs"
|
||||
);
|
||||
fs::copy(
|
||||
fixture,
|
||||
directory.join("module/notification/delivery/worker"),
|
||||
)
|
||||
.unwrap();
|
||||
let args = ["--", "notification.delivery.read", "{}"];
|
||||
let run = cli(&compiler, directory, "run", &args);
|
||||
let binary = directory.join("packaged");
|
||||
let build = cli(
|
||||
&compiler,
|
||||
directory,
|
||||
"build",
|
||||
&["--output", binary.to_str().unwrap()],
|
||||
);
|
||||
for rejected in [run, build] {
|
||||
assert!(!rejected.status.success());
|
||||
assert!(rejected.stdout.is_empty());
|
||||
assert_eq!(
|
||||
String::from_utf8(rejected.stderr).unwrap().trim(),
|
||||
"real Lib providers require a signed managed Dever release"
|
||||
);
|
||||
}
|
||||
assert!(!binary.exists());
|
||||
assert!(!root.path().join("cache").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicitly prepared full runtime archive; isolated SQLite and case-local fake processes"]
|
||||
fn llvm_test_preserves_case_isolation_fakes_and_failure_continuation() {
|
||||
let root = TemporaryDirectory::new();
|
||||
let compiler = compiler(root.path(), "dever");
|
||||
pack::manifest(root.path(), true);
|
||||
let project_dir = TemporaryDirectory::new();
|
||||
let directory = project_dir.path();
|
||||
write(
|
||||
directory,
|
||||
"module/sample/account/model.dever",
|
||||
"type Account { name: Text }",
|
||||
);
|
||||
write(
|
||||
directory,
|
||||
"module/sample/account/app.dever",
|
||||
"create(name: Text) (count: Int) { row = model.create({ name = name })\n count = model.count() }",
|
||||
);
|
||||
write(
|
||||
directory,
|
||||
"module/notification/mail/app.dever",
|
||||
"read() (value: Int) { value = port.read() }",
|
||||
);
|
||||
write(
|
||||
directory,
|
||||
"module/notification/mail/port.dever",
|
||||
"read() (value: Int) fails dever.time.SleepResult",
|
||||
);
|
||||
write(
|
||||
directory,
|
||||
"module/notification/mail/adapter.dever",
|
||||
"external exec \"worker/must-not-start\" {}",
|
||||
);
|
||||
write(
|
||||
directory,
|
||||
"config/setting.json",
|
||||
r#"{"database":{"default":{"type":"sqlite","path":"deployment.db"}}}"#,
|
||||
);
|
||||
// The CLI reads project dependency metadata from valid JSON, but test
|
||||
// execution must use its generated database, never this unusable directory.
|
||||
fs::create_dir(directory.join("deployment.db")).unwrap();
|
||||
for name in ["first", "second"] {
|
||||
write(
|
||||
directory,
|
||||
&format!("test/sample/account/{name}.dever"),
|
||||
&format!("{name}() () {{ assert_eq(app.create(\"Ada\"), 1) }}"),
|
||||
);
|
||||
}
|
||||
write(
|
||||
directory,
|
||||
"test/notification/mail/blocking.dever",
|
||||
"blocking() () { dever.time.sleep(1)\n assert_eq(app.read(), 11) }\nport.read() (value: Int) { value = 11 }",
|
||||
);
|
||||
write(
|
||||
directory,
|
||||
"test/notification/mail/suspending.dever",
|
||||
"suspending() () { assert_eq(app.read(), 23) }\nport.read() (value: Int) { dever.task.sleep(1)\n value = 23 }",
|
||||
);
|
||||
write(
|
||||
directory,
|
||||
"test/sample/account/failure.dever",
|
||||
"failure() () { assert_eq(1, 2) }",
|
||||
);
|
||||
write(
|
||||
directory,
|
||||
"test/sample/account/business.dever",
|
||||
"type Outcome { error Denied(message: Text) }\nbusiness() () { fail(Outcome.Denied(\"test business denial\")) }",
|
||||
);
|
||||
let result = cli(&compiler, directory, "test", &[]);
|
||||
failure(&result, "4 passed; 2 failed; 0 not run");
|
||||
let report = String::from_utf8_lossy(&result.stdout);
|
||||
assert!(
|
||||
report.contains("test sample/account/second ... ok"),
|
||||
"{report}"
|
||||
);
|
||||
assert!(report.contains("failure.dever"), "{report}");
|
||||
assert!(
|
||||
report.contains("assert_eq failed: actual = 1, expected = 2"),
|
||||
"{report}"
|
||||
);
|
||||
assert!(report.contains("test business denial"), "{report}");
|
||||
assert!(!directory.join("data").exists());
|
||||
assert_eq!(cached_programs(root.path()).len(), 1);
|
||||
let suite = &cached_programs(root.path())[0];
|
||||
for args in [
|
||||
vec![],
|
||||
vec![""],
|
||||
vec!["-1"],
|
||||
vec!["+0"],
|
||||
vec![" 0"],
|
||||
vec!["0 "],
|
||||
vec!["18446744073709551616"],
|
||||
vec!["6"],
|
||||
vec!["0", "extra"],
|
||||
] {
|
||||
let rejected = output(Command::new(suite).env_clear().args(args));
|
||||
failure(&rejected, "test program requires a valid case index");
|
||||
assert_eq!(
|
||||
rejected.stderr,
|
||||
b"test program requires a valid case index\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
//! Authoring contracts use tiny synthetic payloads; real link/install checks are opt-in.
|
||||
#![cfg(all(
|
||||
target_os = "linux",
|
||||
any(target_arch = "x86_64", target_arch = "aarch64")
|
||||
))]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt, symlink};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use dever_cli::toolchain::{BuildTarget, packaging, platform_identity, runtime_pack, sha256_file};
|
||||
use ring::rand::SystemRandom;
|
||||
use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[path = "../dever-tests/tests/support/temp.rs"]
|
||||
mod temp;
|
||||
use temp::TemporaryDirectory;
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
#[path = "native_release/acceptance.rs"]
|
||||
mod acceptance;
|
||||
|
||||
#[path = "native_release/ecosystems.rs"]
|
||||
mod ecosystems;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "native_release/bootstrap.rs"]
|
||||
mod bootstrap;
|
||||
|
||||
#[path = "support/native_elf.rs"]
|
||||
mod native_elf;
|
||||
#[path = "support/native_objects.rs"]
|
||||
mod native_objects;
|
||||
|
||||
#[path = "native_release/targets.rs"]
|
||||
mod targets;
|
||||
|
||||
#[test]
|
||||
fn sandbox_bootstrap_requires_static_elf_and_complete_guard_libraries() {
|
||||
let loader = format!("lib/{}", dever_sandbox::loader_name());
|
||||
let mut files = vec![
|
||||
("bin/bwrap".into(), native_elf::static_executable()),
|
||||
(
|
||||
"bin/guard".into(),
|
||||
native_elf::elf(None, &["libc.so.6"], None),
|
||||
),
|
||||
(
|
||||
loader,
|
||||
native_elf::library(dever_sandbox::loader_name(), &[]),
|
||||
),
|
||||
(
|
||||
"lib/libc.so.6".into(),
|
||||
native_elf::library("libc.so.6", &[]),
|
||||
),
|
||||
];
|
||||
dever_sandbox::validate_assets(&files).unwrap();
|
||||
files[0].1 = native_elf::elf(None, &[], None);
|
||||
assert!(
|
||||
dever_sandbox::validate_assets(&files)
|
||||
.unwrap_err()
|
||||
.contains("static target ELF")
|
||||
);
|
||||
files[0].1 = native_elf::library("bwrap", &["libc.so.6"]);
|
||||
assert!(
|
||||
dever_sandbox::validate_assets(&files)
|
||||
.unwrap_err()
|
||||
.contains("static target ELF")
|
||||
);
|
||||
files[0].1 = native_elf::static_executable();
|
||||
files.pop();
|
||||
assert!(
|
||||
dever_sandbox::validate_assets(&files)
|
||||
.unwrap_err()
|
||||
.contains("sandbox dependency 'lib/libc.so.6' is missing")
|
||||
);
|
||||
}
|
||||
|
||||
struct Author {
|
||||
directory: TemporaryDirectory,
|
||||
root: PathBuf,
|
||||
settings: Value,
|
||||
public_key: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Author {
|
||||
fn new() -> Self {
|
||||
let directory = TemporaryDirectory::new();
|
||||
let root = directory.path().join("author");
|
||||
fs::create_dir_all(root.join("config")).unwrap();
|
||||
fs::create_dir(root.join("inputs")).unwrap();
|
||||
let key = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
|
||||
fs::write(root.join("signing.pk8"), key.as_ref()).unwrap();
|
||||
fs::set_permissions(root.join("signing.pk8"), fs::Permissions::from_mode(0o600)).unwrap();
|
||||
let public_key = Ed25519KeyPair::from_pkcs8(key.as_ref())
|
||||
.unwrap()
|
||||
.public_key()
|
||||
.as_ref()
|
||||
.to_vec();
|
||||
let input = |name: &str, bytes: &[u8]| {
|
||||
let source = format!("inputs/{name}");
|
||||
fs::write(root.join(&source), bytes).unwrap();
|
||||
json!({ "source": source, "sha256": sha256_file(&root.join(source)).unwrap() })
|
||||
};
|
||||
let core = input(
|
||||
"core",
|
||||
&native_elf::compiler(&["libLLVM.so.18.1", "libc.so.6"]),
|
||||
);
|
||||
let mut llvm = input(
|
||||
"llvm",
|
||||
&native_elf::library("libLLVM.so.18.1", &["libc.so.6"]),
|
||||
);
|
||||
llvm["path"] = json!("libLLVM.so.18.1");
|
||||
let mut profiles = json!({});
|
||||
for name in ["base", "sqlite", "postgres", "both"] {
|
||||
profiles[name] = input(
|
||||
name,
|
||||
&native_objects::archive(BuildTarget::host().unwrap(), name),
|
||||
);
|
||||
}
|
||||
let link_input = |name: &str, bytes: &[u8]| {
|
||||
let mut value = input(name, bytes);
|
||||
value["path"] = json!(name);
|
||||
value
|
||||
};
|
||||
let start = link_input(
|
||||
"start.o",
|
||||
&native_objects::object(BuildTarget::host().unwrap()),
|
||||
);
|
||||
let end = link_input(
|
||||
"end.o",
|
||||
&native_objects::object(BuildTarget::host().unwrap()),
|
||||
);
|
||||
let library = link_input("system.a", b"!<arch>\n");
|
||||
let skill = link_input(
|
||||
"SKILL.md",
|
||||
b"---\nname: dever-language\ndescription: Develop Dever applications.\n---\n",
|
||||
);
|
||||
let settings = json!({
|
||||
"format": "dever-native-release-input-v1",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"target": format!("{}-unknown-linux-gnu", std::env::consts::ARCH),
|
||||
"signing_key": "signing.pk8", "core": core, "core_libraries": [llvm],
|
||||
"skill": [skill],
|
||||
"profiles": profiles, "start": [start], "libraries": [library], "end": [end],
|
||||
});
|
||||
Self {
|
||||
directory,
|
||||
root,
|
||||
settings,
|
||||
public_key,
|
||||
}
|
||||
}
|
||||
|
||||
fn save(&self) {
|
||||
fs::write(
|
||||
self.root.join("config/setting.json"),
|
||||
serde_json::to_vec(&self.settings).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn output(&self, name: &str) -> PathBuf {
|
||||
self.directory.path().join(name)
|
||||
}
|
||||
|
||||
fn machine(&self) -> dever_cli::toolchain::Layout {
|
||||
let layout = dever_cli::toolchain::Layout::new(self.output("machine"));
|
||||
layout.initialize().unwrap();
|
||||
let key_path = layout.state().join("trusted-release-key");
|
||||
fs::write(
|
||||
&key_path,
|
||||
self.public_key
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::set_permissions(key_path, fs::Permissions::from_mode(0o644)).unwrap();
|
||||
layout
|
||||
}
|
||||
|
||||
fn reject(&self) -> String {
|
||||
self.save();
|
||||
let output = self.output("rejected");
|
||||
let error = packaging::create(&self.root, &output).unwrap_err();
|
||||
assert!(!output.exists());
|
||||
assert_eq!(
|
||||
fs::read_dir(self.directory.path()).unwrap().count(),
|
||||
1,
|
||||
"staging leaked: {error}"
|
||||
);
|
||||
assert!(
|
||||
!error.contains("signing.pk8"),
|
||||
"private key location leaked: {error}"
|
||||
);
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
fn contents(directory: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
|
||||
fn visit(root: &Path, directory: &Path, files: &mut BTreeMap<PathBuf, Vec<u8>>) {
|
||||
for entry in fs::read_dir(directory).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let path = entry.path();
|
||||
if entry.file_type().unwrap().is_dir() {
|
||||
visit(root, &path, files);
|
||||
} else {
|
||||
files.insert(
|
||||
path.strip_prefix(root).unwrap().to_owned(),
|
||||
fs::read(path).unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut files = BTreeMap::new();
|
||||
visit(directory, directory, &mut files);
|
||||
files
|
||||
}
|
||||
|
||||
fn assert_signed(author: &Author, release: &Path) {
|
||||
let encoded = fs::read_to_string(release.join("manifest.sig")).unwrap();
|
||||
let (pairs, remainder) = encoded.trim().as_bytes().as_chunks::<2>();
|
||||
assert!(remainder.is_empty());
|
||||
let signature = pairs
|
||||
.iter()
|
||||
.map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
UnparsedPublicKey::new(&ED25519, &author.public_key)
|
||||
.verify(
|
||||
&fs::read(release.join("manifest.json")).unwrap(),
|
||||
&signature,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_skill_rejects_missing_entry_and_oversized_files() {
|
||||
let mut author = Author::new();
|
||||
author.settings["skill"] = json!([]);
|
||||
author.save();
|
||||
assert!(packaging::create(&author.root, &author.output("missing")).is_err());
|
||||
assert!(!author.output("missing").exists());
|
||||
let mut author = Author::new();
|
||||
fs::write(
|
||||
author.root.join("inputs/SKILL.md"),
|
||||
vec![b'x'; 1024 * 1024 + 1],
|
||||
)
|
||||
.unwrap();
|
||||
author.settings["skill"][0]["sha256"] =
|
||||
json!(sha256_file(&author.root.join("inputs/SKILL.md")).unwrap());
|
||||
author.save();
|
||||
assert!(
|
||||
packaging::create(&author.root, &author.output("large"))
|
||||
.unwrap_err()
|
||||
.contains("limit")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_inputs_produce_identical_signed_independent_releases() {
|
||||
let author = Author::new();
|
||||
author.save();
|
||||
let first = author.output("first");
|
||||
let second = author.output("second");
|
||||
let manifest = packaging::create(&author.root, &first).unwrap();
|
||||
assert_eq!(packaging::create(&author.root, &second).unwrap(), manifest);
|
||||
let before = contents(&first);
|
||||
assert_eq!(contents(&second), before);
|
||||
assert_eq!(before.len(), manifest.artifacts.len() + 2);
|
||||
assert!(
|
||||
manifest
|
||||
.artifacts
|
||||
.windows(2)
|
||||
.all(|pair| pair[0].path < pair[1].path)
|
||||
);
|
||||
for artifact in &manifest.artifacts {
|
||||
assert_eq!(
|
||||
sha256_file(&first.join(&artifact.path)).unwrap(),
|
||||
artifact.sha256
|
||||
);
|
||||
assert_eq!(fs::metadata(first.join(&artifact.path)).unwrap().nlink(), 1);
|
||||
}
|
||||
assert_signed(&author, &first);
|
||||
let runtime = runtime_pack::validate(
|
||||
&first,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
BuildTarget::host().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(runtime.len() + 3, manifest.artifacts.len());
|
||||
let metadata: Value = serde_json::from_slice(
|
||||
&before[&PathBuf::from(format!("runtime/{}/manifest.json", platform_identity()))],
|
||||
)
|
||||
.unwrap();
|
||||
for profile in ["base", "sqlite", "postgres", "both"] {
|
||||
assert_eq!(
|
||||
metadata["profiles"][profile]["runtime"],
|
||||
format!("archives/{profile}.a")
|
||||
);
|
||||
}
|
||||
fs::write(author.root.join("inputs/base"), b"changed source").unwrap();
|
||||
assert_eq!(contents(&first), before);
|
||||
assert_eq!(contents(&second), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiler_libraries_must_form_a_closed_target_and_search_path() {
|
||||
let invalid_libraries = [
|
||||
(
|
||||
native_elf::library("libLLVM.so.18.1", &["libz.so.1"]),
|
||||
"not supplied",
|
||||
),
|
||||
(
|
||||
native_elf::library("different.so", &["libc.so.6"]),
|
||||
"SONAME",
|
||||
),
|
||||
(
|
||||
native_elf::elf(Some("libLLVM.so.18.1"), &[], Some("/usr/lib")),
|
||||
"search path",
|
||||
),
|
||||
(
|
||||
native_elf::elf(Some("libLLVM.so.18.1"), &[], Some("$ORIGIN/../../lib")),
|
||||
"search path",
|
||||
),
|
||||
(
|
||||
native_elf::library("libLLVM.so.18.1", &["/usr/lib/libz.so.1"]),
|
||||
"plain library",
|
||||
),
|
||||
];
|
||||
for (bytes, diagnostic) in invalid_libraries {
|
||||
let mut author = Author::new();
|
||||
fs::write(author.root.join("inputs/llvm"), bytes).unwrap();
|
||||
author.settings["core_libraries"][0]["sha256"] =
|
||||
json!(sha256_file(&author.root.join("inputs/llvm")).unwrap());
|
||||
assert!(author.reject().contains(diagnostic));
|
||||
}
|
||||
let mut author = Author::new();
|
||||
let mut bytes = native_elf::compiler(&["libLLVM.so.18.1"]);
|
||||
bytes[18..20].copy_from_slice(&0u16.to_le_bytes());
|
||||
fs::write(author.root.join("inputs/core"), bytes).unwrap();
|
||||
author.settings["core"]["sha256"] =
|
||||
json!(sha256_file(&author.root.join("inputs/core")).unwrap());
|
||||
assert!(author.reject().contains("incompatible target"));
|
||||
let mut author = Author::new();
|
||||
author.settings["core_libraries"] = json!([]);
|
||||
assert!(author.reject().contains("not supplied"));
|
||||
let mut author = Author::new();
|
||||
let bytes = native_elf::elf(None, &["libLLVM.so.18.1"], Some("/usr/lib"));
|
||||
fs::write(author.root.join("inputs/core"), bytes).unwrap();
|
||||
author.settings["core"]["sha256"] =
|
||||
json!(sha256_file(&author.root.join("inputs/core")).unwrap());
|
||||
assert!(author.reject().contains("inherited RPATH"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installation_rechecks_the_signed_native_compiler_closure_before_health_execution() {
|
||||
let author = Author::new();
|
||||
author.save();
|
||||
let layout = author.machine();
|
||||
let release = layout.downloads().join(env!("CARGO_PKG_VERSION"));
|
||||
let mut manifest = packaging::create(&author.root, &release).unwrap();
|
||||
manifest
|
||||
.artifacts
|
||||
.retain(|artifact| artifact.path != "lib/libLLVM.so.18.1");
|
||||
fs::remove_file(release.join("lib/libLLVM.so.18.1")).unwrap();
|
||||
let encoded = serde_json::to_vec(&manifest).unwrap();
|
||||
let key =
|
||||
Ed25519KeyPair::from_pkcs8(&fs::read(author.root.join("signing.pk8")).unwrap()).unwrap();
|
||||
fs::write(release.join("manifest.json"), &encoded).unwrap();
|
||||
fs::write(
|
||||
release.join("manifest.sig"),
|
||||
key.sign(&encoded)
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>(),
|
||||
)
|
||||
.unwrap();
|
||||
let error = dever_cli::toolchain::MachineManager::new(layout.clone())
|
||||
.install_requested(env!("CARGO_PKG_VERSION"))
|
||||
.unwrap_err();
|
||||
assert!(error.contains("not supplied"), "{error}");
|
||||
assert!(!layout.versions().join(env!("CARGO_PKG_VERSION")).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_core_resolution_rejects_unsigned_loader_search_entries() {
|
||||
for entry in [
|
||||
"lib/libc.so.6",
|
||||
"lib/glibc-hwcaps/x86-64-v3/libLLVM.so.18.1",
|
||||
] {
|
||||
let author = Author::new();
|
||||
author.save();
|
||||
let layout = author.machine();
|
||||
let version = dever_cli::toolchain::Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
||||
let release = layout.versions().join(version.as_str());
|
||||
packaging::create(&author.root, &release).unwrap();
|
||||
let extra = release.join(entry);
|
||||
fs::create_dir_all(extra.parent().unwrap()).unwrap();
|
||||
fs::write(extra, b"unsigned loader input").unwrap();
|
||||
let error = dever_cli::toolchain::MachineManager::new(layout)
|
||||
.resolve_core(&version)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.contains("unsigned file, directory or link"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_configuration_and_rolls_back_partial_payloads() {
|
||||
let changes = [
|
||||
("/format", json!("unknown")),
|
||||
("/target", json!("x86_64-pc-windows-msvc")),
|
||||
("/version", json!("1.0")),
|
||||
("/unexpected", json!(true)),
|
||||
("/core/unexpected", json!(true)),
|
||||
("/core/source", json!("../outside")),
|
||||
("/core/source", json!("/etc/passwd")),
|
||||
("/core/source", json!("inputs/./core")),
|
||||
("/core/source", json!("inputs/missing")),
|
||||
("/profiles/base/sha256", json!("0".repeat(64))),
|
||||
("/profiles/base/sha256", json!("A".repeat(64))),
|
||||
("/profiles/postgres", Value::Null),
|
||||
("/start", json!([])),
|
||||
("/libraries", json!([])),
|
||||
("/end", json!([])),
|
||||
("/end/0/path", json!("start.o")),
|
||||
("/end/0/path", json!("archives/base.a")),
|
||||
("/end/0/path", json!("manifest.json")),
|
||||
("/end/0/path", json!("../../escape.o")),
|
||||
("/end/0/path", json!("end.txt")),
|
||||
];
|
||||
for (pointer, value) in changes {
|
||||
let mut author = Author::new();
|
||||
if let Some(slot) = author.settings.pointer_mut(pointer) {
|
||||
*slot = value;
|
||||
} else if pointer == "/unexpected" {
|
||||
author.settings["unexpected"] = value;
|
||||
} else {
|
||||
author.settings["core"]["unexpected"] = value;
|
||||
}
|
||||
author.reject();
|
||||
}
|
||||
let mut author = Author::new();
|
||||
author.settings["profiles"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("sqlite");
|
||||
author.reject();
|
||||
for bytes in [b"!<thin>\n".as_slice(), b"linker script".as_slice()] {
|
||||
let mut author = Author::new();
|
||||
fs::write(author.root.join("inputs/base"), bytes).unwrap();
|
||||
author.settings["profiles"]["base"]["sha256"] =
|
||||
json!(sha256_file(&author.root.join("inputs/base")).unwrap());
|
||||
assert!(author.reject().contains("self-contained archive"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_key_and_symlink_inputs_never_enter_the_release() {
|
||||
let author = Author::new();
|
||||
fs::set_permissions(
|
||||
author.root.join("signing.pk8"),
|
||||
fs::Permissions::from_mode(0o644),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(author.reject().contains("permissions"));
|
||||
let mut author = Author::new();
|
||||
symlink("signing.pk8", author.root.join("key-link")).unwrap();
|
||||
author.settings["signing_key"] = json!("key-link");
|
||||
author.reject();
|
||||
let mut author = Author::new();
|
||||
symlink("inputs/core", author.root.join("payload-link")).unwrap();
|
||||
author.settings["core"]["source"] = json!("payload-link");
|
||||
author.reject();
|
||||
let mut author = Author::new();
|
||||
symlink("inputs", author.root.join("directory-link")).unwrap();
|
||||
author.settings["core"]["source"] = json!("directory-link/core");
|
||||
author.reject();
|
||||
let mut author = Author::new();
|
||||
fs::copy(
|
||||
author.root.join("signing.pk8"),
|
||||
author.root.join("inputs/key-copy"),
|
||||
)
|
||||
.unwrap();
|
||||
author.settings["core"] = json!({ "source":"inputs/key-copy", "sha256":sha256_file(&author.root.join("inputs/key-copy")).unwrap() });
|
||||
assert!(author.reject().contains("cannot be included"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_outputs_are_preserved_and_concurrent_creation_has_one_winner() {
|
||||
let author = Author::new();
|
||||
author.save();
|
||||
let existing = author.output("existing");
|
||||
fs::create_dir(&existing).unwrap();
|
||||
fs::write(existing.join("keep"), b"original").unwrap();
|
||||
let before = contents(&existing);
|
||||
assert!(packaging::create(&author.root, &existing).is_err());
|
||||
assert_eq!(contents(&existing), before);
|
||||
let empty = author.output("empty");
|
||||
fs::create_dir(&empty).unwrap();
|
||||
assert!(packaging::create(&author.root, &empty).is_err());
|
||||
assert!(empty.is_dir());
|
||||
let link = author.output("link");
|
||||
symlink(&existing, &link).unwrap();
|
||||
assert!(packaging::create(&author.root, &link).is_err());
|
||||
assert_eq!(contents(&existing), before);
|
||||
let output = author.output("concurrent");
|
||||
let outcomes = std::thread::scope(|scope| {
|
||||
let first = scope.spawn(|| packaging::create(&author.root, &output));
|
||||
let second = scope.spawn(|| packaging::create(&author.root, &output));
|
||||
[first.join().unwrap(), second.join().unwrap()]
|
||||
});
|
||||
assert_eq!(outcomes.iter().filter(|result| result.is_ok()).count(), 1);
|
||||
runtime_pack::validate(
|
||||
&output,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
BuildTarget::host().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fs::read_dir(author.directory.path()).unwrap().count(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_fields_and_invalid_private_keys_fail_before_publication() {
|
||||
let author = Author::new();
|
||||
author.save();
|
||||
let config = author.root.join("config/setting.json");
|
||||
let original = fs::read_to_string(&config).unwrap();
|
||||
let duplicate = original.replacen('{', "{\"signing_key\":\"signing.pk8\",", 1);
|
||||
fs::write(&config, duplicate).unwrap();
|
||||
let error = packaging::create(&author.root, &author.output("duplicate")).unwrap_err();
|
||||
assert!(error.contains("invalid author configuration"));
|
||||
assert!(!error.contains("signing.pk8"));
|
||||
assert_eq!(fs::read_dir(author.directory.path()).unwrap().count(), 1);
|
||||
fs::write(author.root.join("signing.pk8"), b"invalid private key").unwrap();
|
||||
assert!(author.reject().contains("PKCS#8"));
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
//! Explicit Linux acceptance using four separately built optimized archives.
|
||||
use std::fs;
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
use dever_cli::toolchain::{Layout, MachineManager, Version, cache_status, packaging, sha256_file};
|
||||
use ring::rand::SystemRandom;
|
||||
use ring::signature::{Ed25519KeyPair, KeyPair};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[path = "../support/daemon.rs"]
|
||||
mod daemon;
|
||||
#[path = "../support/llvm_inputs.rs"]
|
||||
mod inputs;
|
||||
#[path = "../../dever-tests/tests/support/process.rs"]
|
||||
mod process;
|
||||
#[path = "../../dever-tests/tests/support/sandbox.rs"]
|
||||
mod sandbox;
|
||||
use super::temp::TemporaryDirectory;
|
||||
|
||||
#[path = "ecosystem_acceptance.rs"]
|
||||
mod ecosystems;
|
||||
|
||||
#[path = "bootstrap_acceptance.rs"]
|
||||
mod bootstrap;
|
||||
|
||||
#[path = "cross_acceptance.rs"]
|
||||
mod cross;
|
||||
|
||||
#[path = "command_acceptance.rs"]
|
||||
mod command_acceptance;
|
||||
|
||||
const PROFILES: [&str; 4] = ["base", "sqlite", "postgres", "both"];
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AcceptanceSettings {
|
||||
machine_temporary_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn machine_temporary_root(workspace: &Path) -> Result<Option<PathBuf>, String> {
|
||||
let path = workspace.join("target/native-release-inputs/config/setting.json");
|
||||
let bytes = match fs::read(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(format!("cannot read acceptance settings: {error}")),
|
||||
};
|
||||
let settings: AcceptanceSettings = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| format!("invalid acceptance settings: {error}"))?;
|
||||
if let Some(root) = &settings.machine_temporary_root
|
||||
&& (!root.is_absolute()
|
||||
|| root
|
||||
.components()
|
||||
.any(|part| matches!(part, std::path::Component::ParentDir)))
|
||||
{
|
||||
return Err("machine_temporary_root must be an absolute path without '..'".into());
|
||||
}
|
||||
Ok(settings.machine_temporary_root)
|
||||
}
|
||||
|
||||
fn machine_temporary_directory(workspace: &Path) -> TemporaryDirectory {
|
||||
match machine_temporary_root(workspace).expect("valid native release acceptance settings") {
|
||||
Some(root) => TemporaryDirectory::new_in(&root),
|
||||
None => TemporaryDirectory::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acceptance_machine_directory_uses_only_explicit_author_settings() {
|
||||
let workspace = TemporaryDirectory::new();
|
||||
assert_eq!(machine_temporary_root(workspace.path()).unwrap(), None);
|
||||
let config = "target/native-release-inputs/config/setting.json";
|
||||
write(
|
||||
workspace.path(),
|
||||
config,
|
||||
br#"{"machine_temporary_root":"relative"}"#,
|
||||
);
|
||||
assert!(
|
||||
machine_temporary_root(workspace.path())
|
||||
.unwrap_err()
|
||||
.contains("absolute path")
|
||||
);
|
||||
write(
|
||||
workspace.path(),
|
||||
config,
|
||||
br#"{"machine_temporary_root":"/tmp","unknown":true}"#,
|
||||
);
|
||||
assert!(
|
||||
machine_temporary_root(workspace.path())
|
||||
.unwrap_err()
|
||||
.contains("unknown field")
|
||||
);
|
||||
let selected = TemporaryDirectory::new();
|
||||
write(
|
||||
workspace.path(),
|
||||
config,
|
||||
serde_json::to_vec(&json!({"machine_temporary_root":selected.path()})).unwrap(),
|
||||
);
|
||||
let directory = machine_temporary_directory(workspace.path());
|
||||
assert_eq!(directory.path().parent(), Some(selected.path()));
|
||||
assert!(directory.path().is_dir());
|
||||
let path = directory.path().to_owned();
|
||||
drop(directory);
|
||||
assert!(!path.exists());
|
||||
assert!(selected.path().is_dir());
|
||||
}
|
||||
|
||||
fn write(root: &Path, relative: &str, bytes: impl AsRef<[u8]>) {
|
||||
let path = root.join(relative);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
fn input(author: &Path, source: &Path, name: &str) -> Value {
|
||||
let relative = format!("inputs/{name}");
|
||||
let path = author.join(&relative);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
// This is immutable test input staging, never a published release artifact.
|
||||
fs::hard_link(source, &path).unwrap();
|
||||
json!({"source":relative,"sha256":sha256_file(&path).unwrap()})
|
||||
}
|
||||
|
||||
fn author_inputs(author: &Path, workspace: &Path) -> Ed25519KeyPair {
|
||||
let document = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
|
||||
let key = Ed25519KeyPair::from_pkcs8(document.as_ref()).unwrap();
|
||||
use std::io::Write;
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(author.join("signing.pk8"))
|
||||
.unwrap()
|
||||
.write_all(document.as_ref())
|
||||
.unwrap();
|
||||
let mut profiles = serde_json::Map::new();
|
||||
for profile in PROFILES {
|
||||
let name = format!("{profile}.a");
|
||||
profiles.insert(
|
||||
profile.into(),
|
||||
input(
|
||||
author,
|
||||
&workspace.join("target/native-release-inputs").join(&name),
|
||||
&name,
|
||||
),
|
||||
);
|
||||
}
|
||||
let compiler = input(author, Path::new(env!("CARGO_BIN_EXE_dever")), "dever");
|
||||
let core_libraries = inputs::core_libraries()
|
||||
.into_iter()
|
||||
.map(|(name, source)| {
|
||||
let mut value = input(author, &source, name);
|
||||
value["path"] = json!(name);
|
||||
value
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut links = serde_json::Map::new();
|
||||
for (name, source) in inputs::native_inputs()
|
||||
.into_iter()
|
||||
.filter(|(name, _)| *name != "runtime.a")
|
||||
{
|
||||
let mut entry = input(author, &source, name);
|
||||
entry["path"] = json!(name);
|
||||
links.insert(name.into(), entry);
|
||||
}
|
||||
let ordered = |names: &[&str]| {
|
||||
names
|
||||
.iter()
|
||||
.map(|name| links[*name].clone())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let sandbox = sandbox::assets(workspace).into_iter().map(|asset| {
|
||||
let relative = format!("inputs/{}", asset.path);
|
||||
write(author, &relative, &asset.bytes);
|
||||
fs::set_permissions(author.join(&relative), fs::Permissions::from_mode(if asset.executable { 0o755 } else { 0o644 })).unwrap();
|
||||
json!({"source":relative, "path":asset.path.strip_prefix("sandbox/").unwrap(), "sha256":sha256_file(&author.join(&relative)).unwrap()})
|
||||
}).collect::<Vec<_>>();
|
||||
write(
|
||||
author,
|
||||
"inputs/SKILL.md",
|
||||
b"---\nname: dever-language\ndescription: Dever acceptance skill.\n---\n",
|
||||
);
|
||||
let skill = json!({"source":"inputs/SKILL.md", "path":"SKILL.md", "sha256":sha256_file(&author.join("inputs/SKILL.md")).unwrap()});
|
||||
write(
|
||||
author,
|
||||
"config/setting.json",
|
||||
serde_json::to_vec(&json!({
|
||||
"format":"dever-native-release-input-v1", "version":env!("CARGO_PKG_VERSION"),
|
||||
"target":"x86_64-unknown-linux-gnu", "signing_key":"signing.pk8",
|
||||
"core":compiler, "core_libraries":core_libraries, "profiles":profiles,
|
||||
"skill":[skill],
|
||||
"start":ordered(&["crt1.o","crti.o","crtbeginT.o"]),
|
||||
"libraries":ordered(&["libc.a","libm.a","libmvec.a","libgcc.a","libgcc_eh.a"]),
|
||||
"end":ordered(&["crtend.o","crtn.o"]), "sandbox":sandbox
|
||||
}))
|
||||
.unwrap(),
|
||||
);
|
||||
key
|
||||
}
|
||||
|
||||
fn command(command: &mut Command) -> Output {
|
||||
command_with_timeout(command, Duration::from_secs(190))
|
||||
}
|
||||
|
||||
fn isolated_os_command(root: &Path) -> Command {
|
||||
let mut command = isolated_os_namespace(root);
|
||||
command.args(["--", "/fixture-proc-init"]);
|
||||
command
|
||||
}
|
||||
|
||||
fn isolated_os_namespace(root: &Path) -> Command {
|
||||
let assets = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../target/sandbox-inputs/assets")
|
||||
.canonicalize()
|
||||
.expect("prepare the explicit Linux isolation assets");
|
||||
let init = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../target/debug/examples/native-acceptance-init")
|
||||
.canonicalize()
|
||||
.expect("build the explicit static native-acceptance-init example");
|
||||
let bytes = fs::read(&init).unwrap();
|
||||
let executable = goblin::elf::Elf::parse(&bytes).unwrap();
|
||||
assert!(executable.interpreter.is_none() && executable.libraries.is_empty());
|
||||
// A plain chroot cannot create a nested user namespace. Use a real mount
|
||||
// namespace root so the program can retain its normal Worker sandbox.
|
||||
dever_sandbox::validate_bwrap(&fs::read(assets.join("bin/bwrap")).unwrap()).unwrap();
|
||||
let mut command = Command::new(assets.join("bin/bwrap"));
|
||||
command
|
||||
.env_clear()
|
||||
.args([
|
||||
"--unshare-all",
|
||||
"--die-with-parent",
|
||||
"--new-session",
|
||||
"--bind",
|
||||
])
|
||||
.arg(root)
|
||||
.args(["/", "--proc", "/proc", "--dev", "/dev", "--chdir", "/"])
|
||||
.arg("--ro-bind")
|
||||
.arg(init)
|
||||
.arg("/fixture-proc-init");
|
||||
command
|
||||
}
|
||||
|
||||
fn command_with_timeout(command: &mut Command, timeout: Duration) -> Output {
|
||||
let capture = TemporaryDirectory::new();
|
||||
let stdout = capture.path().join("stdout");
|
||||
let stderr = capture.path().join("stderr");
|
||||
let status = process::status(
|
||||
command
|
||||
.env_clear()
|
||||
.stdout(Stdio::from(fs::File::create(&stdout).unwrap()))
|
||||
.stderr(Stdio::from(fs::File::create(&stderr).unwrap())),
|
||||
timeout,
|
||||
)
|
||||
.unwrap();
|
||||
let output = Output {
|
||||
status,
|
||||
stdout: fs::read(stdout).unwrap(),
|
||||
stderr: fs::read(stderr).unwrap(),
|
||||
};
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{:?}: {}\n{}",
|
||||
command,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
output
|
||||
}
|
||||
|
||||
fn application(root: &Path, profile: &str) {
|
||||
let sqlite = json!({"type":"sqlite","path":"data/owned.sqlite","max_connections":1});
|
||||
let postgres =
|
||||
json!({"type":"postgres","url":"postgres://fixture@127.0.0.1:1/unused", "tls":"disabled"});
|
||||
let settings = match profile {
|
||||
"base" => json!({}),
|
||||
"sqlite" => json!({"database":{"default":sqlite}}),
|
||||
"postgres" => json!({"database":{"default":postgres}}),
|
||||
"both" => json!({"database":{"default":sqlite,"unused":postgres}}),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
write(
|
||||
root,
|
||||
"config/setting.json",
|
||||
serde_json::to_vec(&settings).unwrap(),
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/sample/value/api.dever",
|
||||
"cmd exercise = app.exercise\n",
|
||||
);
|
||||
let app = if matches!(profile, "sqlite" | "both") {
|
||||
write(
|
||||
root,
|
||||
"module/sample/value/model.dever",
|
||||
"type Value { name: Text(1, 32) }\n",
|
||||
);
|
||||
"exercise() (okay: Bool) { saved = model.create({ name = \"pack\" })\n count = model.count()\n removed = model.delete(saved.id)\n okay = count == 1 and removed == 1 }\n"
|
||||
} else {
|
||||
"exercise() (okay: Bool) { dever.task.sleep(1)\n okay = true }\n"
|
||||
};
|
||||
write(root, "module/sample/value/app.dever", app);
|
||||
}
|
||||
|
||||
fn assert_response(output: Output) {
|
||||
assert_response_data(output, json!(true));
|
||||
}
|
||||
|
||||
fn assert_response_data(output: Output, expected: Value) {
|
||||
let value: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(value["code"], 0);
|
||||
assert_eq!(value["data"], expected);
|
||||
}
|
||||
|
||||
fn exec_application(root: &Path, workspace: &Path) {
|
||||
for (name, source) in [
|
||||
("app", "read() (value: Int) { value = port.read() }"),
|
||||
("port", "read() (value: Int) fails dever.time.SleepResult"),
|
||||
(
|
||||
"adapter",
|
||||
"setting { mode: Text }\nexternal exec \"worker\" {}",
|
||||
),
|
||||
("api", "cmd read = app.read"),
|
||||
] {
|
||||
write(
|
||||
root,
|
||||
&format!("module/notification/delivery/{name}.dever"),
|
||||
source,
|
||||
);
|
||||
}
|
||||
write(
|
||||
root,
|
||||
"config/setting.json",
|
||||
br#"{"adapter":{"notification.delivery":{"setting":{"mode":"embedded"}}}}"#,
|
||||
);
|
||||
let fixture = workspace.join("target/native-release-inputs/component-fixture");
|
||||
assert!(
|
||||
fixture.is_file(),
|
||||
"prepare the immutable component-fixture in target/native-release-inputs"
|
||||
);
|
||||
fs::copy(fixture, root.join("module/notification/delivery/worker")).unwrap();
|
||||
}
|
||||
|
||||
fn install_release(layout: &Layout, version: &Version, key: &Ed25519KeyPair) {
|
||||
let public_key = key
|
||||
.public_key()
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>();
|
||||
fs::write(layout.state().join("trusted-release-key"), public_key).unwrap();
|
||||
fs::set_permissions(
|
||||
layout.state().join("trusted-release-key"),
|
||||
fs::Permissions::from_mode(0o644),
|
||||
)
|
||||
.unwrap();
|
||||
MachineManager::new(layout.clone())
|
||||
.install_requested(version.as_str())
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn archive_report(workspace: &Path) -> Value {
|
||||
let mut profiles = serde_json::Map::new();
|
||||
for profile in PROFILES {
|
||||
let path = workspace
|
||||
.join("target/native-release-inputs")
|
||||
.join(format!("{profile}.a"));
|
||||
let output = command(
|
||||
Command::new("/usr/bin/nm")
|
||||
.args(["--defined-only", "--demangle"])
|
||||
.arg(&path),
|
||||
);
|
||||
let symbols = String::from_utf8(output.stdout).unwrap();
|
||||
assert_eq!(
|
||||
symbols.contains(" sqlite3_open"),
|
||||
matches!(profile, "sqlite" | "both"),
|
||||
"{profile} SQLite driver"
|
||||
);
|
||||
assert_eq!(
|
||||
symbols.contains("tokio_postgres"),
|
||||
matches!(profile, "postgres" | "both"),
|
||||
"{profile} PostgreSQL driver"
|
||||
);
|
||||
assert!(!symbols.contains("LLVMContextCreate"));
|
||||
profiles.insert(profile.into(), json!({"bytes":fs::metadata(&path).unwrap().len(),"sha256":sha256_file(&path).unwrap()}));
|
||||
}
|
||||
json!(profiles)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared optimized archives and root for an owned GNU ABI root"]
|
||||
fn signed_core_builds_without_host_compiler_libraries_or_tools() {
|
||||
assert_eq!(
|
||||
rustix::process::geteuid().as_raw(),
|
||||
0,
|
||||
"explicit owned namespace acceptance requires root"
|
||||
);
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let author = TemporaryDirectory::new();
|
||||
author_inputs(author.path(), &workspace);
|
||||
let release = machine_temporary_directory(&workspace);
|
||||
let payload = release.path().join("release");
|
||||
let manifest = packaging::create(author.path(), &payload).unwrap();
|
||||
let isolated = machine_temporary_directory(&workspace);
|
||||
ecosystems::minimal_os(isolated.path());
|
||||
let compiler_root = isolated.path().join("compiler");
|
||||
for artifact in &manifest.artifacts {
|
||||
let relative = if artifact.path == "dever-core" {
|
||||
"dever"
|
||||
} else {
|
||||
&artifact.path
|
||||
};
|
||||
let destination = compiler_root.join(relative);
|
||||
fs::create_dir_all(destination.parent().unwrap()).unwrap();
|
||||
// Immutable signed inputs; no host directory is mounted into the root.
|
||||
fs::hard_link(payload.join(&artifact.path), destination).unwrap();
|
||||
}
|
||||
application(&isolated.path().join("project"), "sqlite");
|
||||
let invoke = |arguments: &[&str]| command(isolated_os_command(isolated.path()).args(arguments));
|
||||
invoke(&["/compiler/dever", "check", "/project"]);
|
||||
invoke(&[
|
||||
"/compiler/dever",
|
||||
"build",
|
||||
"/project",
|
||||
"--output",
|
||||
"/project/program",
|
||||
]);
|
||||
fs::remove_dir_all(isolated.path().join("project/module")).unwrap();
|
||||
assert_response(invoke(&["/project/program", "sample.value.exercise", "{}"]));
|
||||
write(&workspace, "target/closure-core-loader-acceptance.json", serde_json::to_vec_pretty(&json!({
|
||||
"platform":"linux-x86_64", "os_baseline":"GNU libc 2.39 and its named companion libraries",
|
||||
"core_libraries":manifest.artifacts.iter().filter(|artifact| artifact.path.starts_with("lib/")).collect::<Vec<_>>(),
|
||||
"checks":{"signed_payload":true,"check_in_minimal_root":true,"sqlite_build_in_minimal_root":true,"source_removed_execution":true},
|
||||
"host_compiler_libraries":false,"host_language_tools":false
|
||||
})).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires four optimized archives and component-fixture; starts only an owned daemon, SQLite and exec Worker fixtures"]
|
||||
fn optimized_profiles_make_reproducible_signed_release_and_run_through_daemon() {
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let profiles = archive_report(&workspace);
|
||||
let author = TemporaryDirectory::new();
|
||||
let key = author_inputs(author.path(), &workspace);
|
||||
let machine = machine_temporary_directory(&workspace);
|
||||
let layout = Layout::new(machine.path());
|
||||
layout.initialize().unwrap();
|
||||
let first = machine.path().join("first");
|
||||
let expected = packaging::create(author.path(), &first).unwrap();
|
||||
let manifest = fs::read(first.join("manifest.json")).unwrap();
|
||||
let signature = fs::read(first.join("manifest.sig")).unwrap();
|
||||
// Remove only this test's first completed copy before making the second.
|
||||
fs::remove_dir_all(&first).unwrap();
|
||||
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
||||
let release = layout.downloads().join(version.as_str());
|
||||
command(
|
||||
Command::new(workspace.join("target/debug/examples/native-release"))
|
||||
.arg(author.path())
|
||||
.arg("--output")
|
||||
.arg(&release),
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<dever_cli::toolchain::ReleaseManifest>(
|
||||
&fs::read(release.join("manifest.json")).unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(fs::read(release.join("manifest.json")).unwrap(), manifest);
|
||||
assert_eq!(fs::read(release.join("manifest.sig")).unwrap(), signature);
|
||||
install_release(&layout, &version, &key);
|
||||
fs::remove_dir_all(&release).unwrap();
|
||||
drop(author);
|
||||
let compiler = layout.versions().join(version.as_str()).join("dever-core");
|
||||
let mut daemon = daemon::TestDaemon::start(&layout);
|
||||
let projects = TemporaryDirectory::new();
|
||||
let mut binaries = serde_json::Map::new();
|
||||
let mut programs: Vec<(PathBuf, PathBuf)> = Vec::new();
|
||||
for profile in PROFILES {
|
||||
let project = projects.path().join(profile);
|
||||
application(&project, profile);
|
||||
command(Command::new(&compiler).arg("check").arg(&project));
|
||||
assert_response(command(
|
||||
Command::new(&compiler).arg("run").arg(&project).args([
|
||||
"--",
|
||||
"sample.value.exercise",
|
||||
"{}",
|
||||
]),
|
||||
));
|
||||
let executable = project.join("program");
|
||||
command(
|
||||
Command::new(&compiler)
|
||||
.arg("build")
|
||||
.arg(&project)
|
||||
.arg("--output")
|
||||
.arg(&executable),
|
||||
);
|
||||
binaries.insert(profile.into(), json!({"bytes":fs::metadata(&executable).unwrap().len(),"sha256":sha256_file(&executable).unwrap()}));
|
||||
programs.push((project, executable));
|
||||
}
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 4);
|
||||
let exec_project = TemporaryDirectory::new();
|
||||
let exec_source = exec_project.path().to_owned();
|
||||
exec_application(&exec_source, &workspace);
|
||||
assert_response_data(
|
||||
command(Command::new(&compiler).arg("run").arg(&exec_source).args([
|
||||
"--",
|
||||
"notification.delivery.read",
|
||||
"{}",
|
||||
])),
|
||||
json!(7),
|
||||
);
|
||||
let standalone = TemporaryDirectory::new();
|
||||
let exec_program = standalone.path().join("application");
|
||||
fs::create_dir(standalone.path().join("config")).unwrap();
|
||||
fs::copy(
|
||||
exec_source.join("config/setting.json"),
|
||||
standalone.path().join("config/setting.json"),
|
||||
)
|
||||
.unwrap();
|
||||
command(
|
||||
Command::new(&compiler)
|
||||
.arg("build")
|
||||
.arg(&exec_source)
|
||||
.arg("--output")
|
||||
.arg(&exec_program),
|
||||
);
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 5);
|
||||
drop(exec_project);
|
||||
assert!(!exec_source.exists());
|
||||
daemon.stop();
|
||||
assert_response_data(
|
||||
command(
|
||||
Command::new(&exec_program)
|
||||
.current_dir(standalone.path())
|
||||
.args(["notification.delivery.read", "{}"]),
|
||||
),
|
||||
json!(7),
|
||||
);
|
||||
assert!(!standalone.path().join("module").exists());
|
||||
let exec = json!({
|
||||
"bytes": fs::metadata(&exec_program).unwrap().len(),
|
||||
"sha256": sha256_file(&exec_program).unwrap(),
|
||||
"managed_run": true, "standalone_run": true,
|
||||
"source_project_removed": true, "response": 7
|
||||
});
|
||||
for (project, executable) in programs {
|
||||
fs::remove_dir_all(project.join("module")).unwrap();
|
||||
assert_response(command(
|
||||
Command::new(executable).args(["sample.value.exercise", "{}"]),
|
||||
));
|
||||
}
|
||||
let report = json!({
|
||||
"format":"dever-native-release-acceptance-v1", "platform":"linux-x86_64",
|
||||
"version":version.as_str(), "profiles":profiles,"programs":binaries,"exec":exec,
|
||||
"manifest_sha256":format!("{:x}", sha2::Sha256::digest(&manifest)),
|
||||
"checks":{"pack_determinism":true,"signed_install":true,"managed_profiles":4,"standalone_profiles":4,"sqlite_crud":true,"signed_exec_worker":true},
|
||||
"postgres_server":"not_run", "cross_host_source_reproducibility":"not_run"
|
||||
});
|
||||
use sha2::Digest;
|
||||
write(
|
||||
&workspace,
|
||||
"target/closure-native-release-acceptance.json",
|
||||
serde_json::to_vec_pretty(&report).unwrap(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
//! Bootstrap package validation and recovery use only owned fixture directories.
|
||||
use super::*;
|
||||
use dever_cli::toolchain::bootstrap;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[test]
|
||||
fn bootstrap_service_recovery_orders_actions_around_file_restoration() {
|
||||
use bootstrap::RecoveryAction::{Reload, Restart, Stop};
|
||||
for active in [false, true] {
|
||||
for loaded in [false, true] {
|
||||
assert_eq!(
|
||||
bootstrap::recovery_actions(false, active, loaded),
|
||||
(vec![], vec![])
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
bootstrap::recovery_actions(true, false, false),
|
||||
(vec![], vec![Reload])
|
||||
);
|
||||
assert_eq!(
|
||||
bootstrap::recovery_actions(true, false, true),
|
||||
(vec![Stop], vec![Reload])
|
||||
);
|
||||
assert_eq!(
|
||||
bootstrap::recovery_actions(true, true, true),
|
||||
(vec![Stop], vec![Reload, Restart])
|
||||
);
|
||||
assert_eq!(
|
||||
bootstrap::recovery_actions(true, true, false),
|
||||
(vec![], vec![Reload, Restart])
|
||||
);
|
||||
}
|
||||
|
||||
fn add_bootstrap(author: &mut Author) {
|
||||
let input = |name: &str, bytes: &[u8]| {
|
||||
let relative = format!("inputs/{name}");
|
||||
fs::write(author.root.join(&relative), bytes).unwrap();
|
||||
json!({"source":relative,"sha256":sha256_file(&author.root.join(&relative)).unwrap()})
|
||||
};
|
||||
let launcher = input(
|
||||
"dever",
|
||||
&native_elf::compiler(&["libgcc_s.so.1", "libc.so.6"]),
|
||||
);
|
||||
let daemon = input(
|
||||
"deverd",
|
||||
&native_elf::compiler(&["libgcc_s.so.1", "libc.so.6"]),
|
||||
);
|
||||
let mut gcc = input("gcc", &native_elf::library("libgcc_s.so.1", &["libc.so.6"]));
|
||||
gcc["path"] = json!("libgcc_s.so.1");
|
||||
author.settings["bootstrap"] = json!({"launcher":launcher,"daemon":daemon,"libraries":[gcc]});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_payload_has_a_separate_signed_complete_loader_closure() {
|
||||
let mut author = Author::new();
|
||||
add_bootstrap(&mut author);
|
||||
author.save();
|
||||
let release = author.output("complete");
|
||||
let manifest = packaging::create(&author.root, &release).unwrap();
|
||||
assert_signed(&author, &release);
|
||||
for path in [
|
||||
"bootstrap/dever",
|
||||
"bootstrap/deverd",
|
||||
"bootstrap/lib/libgcc_s.so.1",
|
||||
"bootstrap/deverd.service",
|
||||
"bootstrap/dever-sandbox",
|
||||
] {
|
||||
let artifact = manifest
|
||||
.artifacts
|
||||
.iter()
|
||||
.find(|artifact| artifact.path == path)
|
||||
.unwrap();
|
||||
assert_eq!(artifact.sha256, sha256_file(&release.join(path)).unwrap());
|
||||
}
|
||||
let unit = fs::read_to_string(release.join("bootstrap/deverd.service")).unwrap();
|
||||
assert!(unit.contains("ExecStart=/opt/dever/bin/deverd --root /opt/dever"));
|
||||
assert!(unit.contains("RuntimeDirectory=dever"));
|
||||
assert!(!unit.contains("Environment"));
|
||||
assert!(!unit.contains("PATH"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(release.join("bootstrap/dever-sandbox")).unwrap(),
|
||||
dever_sandbox::APPARMOR_PROFILE
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_authoring_rejects_missing_libraries_wrong_sonames_and_search_paths() {
|
||||
let mut author = Author::new();
|
||||
add_bootstrap(&mut author);
|
||||
author.settings["bootstrap"]["libraries"] = json!([]);
|
||||
assert!(author.reject().contains("libgcc_s.so.1"));
|
||||
|
||||
let mut author = Author::new();
|
||||
add_bootstrap(&mut author);
|
||||
fs::write(
|
||||
author.root.join("inputs/gcc"),
|
||||
native_elf::library("libwrong.so", &["libc.so.6"]),
|
||||
)
|
||||
.unwrap();
|
||||
author.settings["bootstrap"]["libraries"][0]["sha256"] =
|
||||
json!(sha256_file(&author.root.join("inputs/gcc")).unwrap());
|
||||
assert!(author.reject().contains("SONAME"));
|
||||
|
||||
let mut author = Author::new();
|
||||
add_bootstrap(&mut author);
|
||||
fs::write(
|
||||
author.root.join("inputs/dever"),
|
||||
native_elf::elf(None, &["libgcc_s.so.1"], Some("/usr/lib")),
|
||||
)
|
||||
.unwrap();
|
||||
author.settings["bootstrap"]["launcher"]["sha256"] =
|
||||
json!(sha256_file(&author.root.join("inputs/dever")).unwrap());
|
||||
assert!(author.reject().contains("RPATH"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_installed_versions_keep_their_original_bootstrap_service_template() {
|
||||
use dever_cli::toolchain::{Layout, MachineManager, Version};
|
||||
let mut author = Author::new();
|
||||
add_bootstrap(&mut author);
|
||||
author.save();
|
||||
let machine = TemporaryDirectory::new();
|
||||
let layout = Layout::new(machine.path().join("machine"));
|
||||
layout.initialize().unwrap();
|
||||
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
||||
let installed = layout.versions().join(version.as_str());
|
||||
let mut manifest = packaging::create(&author.root, &installed).unwrap();
|
||||
let service = installed.join("bootstrap/deverd.service");
|
||||
let current = fs::read_to_string(&service).unwrap();
|
||||
let old = current.replace("RestartSec=1", "RestartSec=2");
|
||||
assert_ne!(current, old);
|
||||
fs::write(&service, old).unwrap();
|
||||
let artifact = manifest
|
||||
.artifacts
|
||||
.iter_mut()
|
||||
.find(|artifact| artifact.path == "bootstrap/deverd.service")
|
||||
.unwrap();
|
||||
artifact.bytes = fs::metadata(&service).unwrap().len();
|
||||
artifact.sha256 = sha256_file(&service).unwrap();
|
||||
let encoded = serde_json::to_vec(&manifest).unwrap();
|
||||
let key =
|
||||
Ed25519KeyPair::from_pkcs8(&fs::read(author.root.join("signing.pk8")).unwrap()).unwrap();
|
||||
fs::write(installed.join("manifest.json"), &encoded).unwrap();
|
||||
fs::write(
|
||||
installed.join("manifest.sig"),
|
||||
key.sign(&encoded)
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
layout.state().join("trusted-release-key"),
|
||||
author
|
||||
.public_key
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>(),
|
||||
)
|
||||
.unwrap();
|
||||
let manager = MachineManager::new(layout);
|
||||
assert_eq!(
|
||||
manager.resolve_core(&version).unwrap(),
|
||||
installed.join("dever-core")
|
||||
);
|
||||
fs::write(service, b"unsigned modification").unwrap();
|
||||
assert!(
|
||||
manager
|
||||
.resolve_core(&version)
|
||||
.unwrap_err()
|
||||
.contains("integrity verification")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "root-only installer trust rejection; never touches the actual machine installation"]
|
||||
fn bootstrap_installer_rejects_untrusted_inputs_before_publication() {
|
||||
assert_eq!(rustix::process::geteuid().as_raw(), 0);
|
||||
assert!(
|
||||
bootstrap::install(Path::new("."))
|
||||
.unwrap_err()
|
||||
.contains("absolute paths")
|
||||
);
|
||||
assert!(
|
||||
bootstrap::recover(Path::new("."))
|
||||
.unwrap_err()
|
||||
.contains("absolute paths")
|
||||
);
|
||||
let mut author = Author::new();
|
||||
add_bootstrap(&mut author);
|
||||
author.save();
|
||||
let package = author.output("release");
|
||||
packaging::create(&author.root, &package).unwrap();
|
||||
let installer = TemporaryDirectory::new();
|
||||
let system = TemporaryDirectory::new();
|
||||
let key = installer.path().join("trusted.pub");
|
||||
fs::write(
|
||||
&key,
|
||||
author
|
||||
.public_key
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir(installer.path().join("config")).unwrap();
|
||||
let save = |key: &Path, activate: bool| {
|
||||
fs::write(installer.path().join("config/setting.json"), serde_json::to_vec(&json!({"release":package,"system_root":system.path(),"trusted_key":key,"activate_service":activate})).unwrap()).unwrap()
|
||||
};
|
||||
save(&key, true);
|
||||
assert!(
|
||||
bootstrap::install(installer.path())
|
||||
.unwrap_err()
|
||||
.contains("actual system root")
|
||||
);
|
||||
let inside = package.join("trusted.pub");
|
||||
fs::copy(&key, &inside).unwrap();
|
||||
save(&inside, false);
|
||||
assert!(
|
||||
bootstrap::install(installer.path())
|
||||
.unwrap_err()
|
||||
.contains("independently")
|
||||
);
|
||||
save(&key, false);
|
||||
fs::set_permissions(&key, fs::Permissions::from_mode(0o666)).unwrap();
|
||||
assert!(
|
||||
bootstrap::install(installer.path())
|
||||
.unwrap_err()
|
||||
.contains("root-owned")
|
||||
);
|
||||
fs::set_permissions(&key, fs::Permissions::from_mode(0o644)).unwrap();
|
||||
let signature = fs::read(package.join("manifest.sig")).unwrap();
|
||||
fs::write(package.join("manifest.sig"), "00".repeat(64)).unwrap();
|
||||
assert!(
|
||||
bootstrap::install(installer.path())
|
||||
.unwrap_err()
|
||||
.contains("signature verification")
|
||||
);
|
||||
fs::write(package.join("manifest.sig"), signature).unwrap();
|
||||
// The key is pinned before the intentionally non-executable synthetic core
|
||||
// fails its health check. Empty/partial temporary files model interrupted
|
||||
// writes without injecting product failpoints or touching real services.
|
||||
let state = system.path().join("opt/dever/state");
|
||||
let temporary_key = state.join(".trusted-release-key.next");
|
||||
let pinned_key = state.join("trusted-release-key");
|
||||
for partial in ["", "abc"] {
|
||||
fs::write(&temporary_key, partial).unwrap();
|
||||
let error = bootstrap::install(installer.path()).unwrap_err();
|
||||
assert!(!error.contains("temporary release key"), "{error}");
|
||||
assert_eq!(
|
||||
fs::read_to_string(&pinned_key).unwrap().trim(),
|
||||
fs::read_to_string(&key).unwrap()
|
||||
);
|
||||
assert!(!temporary_key.exists());
|
||||
fs::remove_file(&pinned_key).unwrap();
|
||||
}
|
||||
fs::write(&pinned_key, "11".repeat(32)).unwrap();
|
||||
assert!(
|
||||
bootstrap::install(installer.path())
|
||||
.unwrap_err()
|
||||
.contains("pinned release key")
|
||||
);
|
||||
assert_eq!(fs::read_to_string(&pinned_key).unwrap(), "11".repeat(32));
|
||||
fs::remove_file(&pinned_key).unwrap();
|
||||
fs::write(package.join("bootstrap/lib/libgcc_s.so.1"), b"tampered").unwrap();
|
||||
assert!(
|
||||
bootstrap::install(installer.path())
|
||||
.unwrap_err()
|
||||
.contains("integrity verification")
|
||||
);
|
||||
fs::remove_file(package.join("bootstrap/lib/libgcc_s.so.1")).unwrap();
|
||||
assert!(bootstrap::install(installer.path()).is_err());
|
||||
assert!(!system.path().join("usr/local/bin/dever").exists());
|
||||
assert!(
|
||||
!system
|
||||
.path()
|
||||
.join("opt/dever/state/active-version")
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
|
||||
// Independent construction of a one-node journal identity lets recovery tests
|
||||
// simulate interruption at publication boundaries without product failpoints.
|
||||
fn node_hash(kind: &str, bytes: &[u8]) -> String {
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(0u64.to_le_bytes());
|
||||
hash.update(
|
||||
if kind == "directory" {
|
||||
0o755u32
|
||||
} else {
|
||||
0o644u32
|
||||
}
|
||||
.to_le_bytes(),
|
||||
);
|
||||
hash.update(kind.as_bytes());
|
||||
if kind == "file" {
|
||||
hash.update((bytes.len() as u64).to_le_bytes());
|
||||
hash.update(bytes);
|
||||
}
|
||||
format!("{:x}", hash.finalize())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "root-only crash-recovery fixture; all journal targets are below an owned image root"]
|
||||
fn bootstrap_recovery_is_idempotent_and_rejects_unrecorded_targets() {
|
||||
assert_eq!(rustix::process::geteuid().as_raw(), 0);
|
||||
let system = TemporaryDirectory::new();
|
||||
let root = system.path();
|
||||
let layout = dever_cli::toolchain::Layout::new(root.join("opt/dever"));
|
||||
layout.initialize().unwrap();
|
||||
for directory in [
|
||||
"usr/local/bin",
|
||||
"etc/systemd/system/multi-user.target.wants",
|
||||
"etc/apparmor.d",
|
||||
] {
|
||||
fs::create_dir_all(root.join(directory)).unwrap();
|
||||
}
|
||||
let service = root.join("etc/systemd/system/deverd.service");
|
||||
fs::write(&service, b"new service").unwrap();
|
||||
let entry = root.join("usr/local/bin/dever");
|
||||
symlink("/opt/dever/bin/dever", &entry).unwrap();
|
||||
let enable_stage = root
|
||||
.join("etc/systemd/system/multi-user.target.wants/.dever-bootstrap-deverd.service.next");
|
||||
symlink("../deverd.service", &enable_stage).unwrap();
|
||||
let active_stage = layout.state().join(".dever-bootstrap-active-version.next");
|
||||
fs::write(&active_stage, b"new active").unwrap();
|
||||
let sentinel = root.join("etc/systemd/system/unrelated.service");
|
||||
fs::write(&sentinel, b"keep").unwrap();
|
||||
let link_hash =
|
||||
|target: &str| format!("{:x}", Sha256::digest(format!("link\0{target}").as_bytes()));
|
||||
let journal = json!({"format":"dever-bootstrap-transaction-v1","phase":"prepared","service_was_active":null,"service_start_requested":false,"changes":[
|
||||
{"target":"sandbox","before":null,"after":node_hash("directory",b"")},
|
||||
{"target":"apparmor","before":null,"after":node_hash("file",b"new profile")},
|
||||
{"target":"bin","before":node_hash("directory",b""),"after":node_hash("directory",b"")},
|
||||
{"target":"service","before":null,"after":node_hash("file",b"new service")},
|
||||
{"target":"entry","before":null,"after":link_hash("/opt/dever/bin/dever")},
|
||||
{"target":"enable","before":null,"after":link_hash("../deverd.service")},
|
||||
{"target":"active","before":null,"after":node_hash("file",b"new active")}
|
||||
]});
|
||||
fs::create_dir(root.join("opt/dever/.dever-bootstrap-sandbox.next")).unwrap();
|
||||
fs::write(
|
||||
root.join("etc/apparmor.d/.dever-bootstrap-dever-sandbox.next"),
|
||||
b"new profile",
|
||||
)
|
||||
.unwrap();
|
||||
let path = layout.state().join("bootstrap-install.json");
|
||||
fs::write(&path, serde_json::to_vec(&journal).unwrap()).unwrap();
|
||||
let mut invalid = journal.clone();
|
||||
invalid["changes"][0]["target"] = json!("../../unrelated");
|
||||
fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
|
||||
assert!(bootstrap::recover(root).is_err());
|
||||
assert_eq!(fs::read(&sentinel).unwrap(), b"keep");
|
||||
fs::write(&path, serde_json::to_vec(&journal).unwrap()).unwrap();
|
||||
fs::write(&service, b"not this transaction").unwrap();
|
||||
assert!(
|
||||
bootstrap::recover(root)
|
||||
.unwrap_err()
|
||||
.contains("outside this transaction")
|
||||
);
|
||||
assert_eq!(fs::read(&service).unwrap(), b"not this transaction");
|
||||
fs::write(&service, b"new service").unwrap();
|
||||
bootstrap::recover(root).unwrap();
|
||||
bootstrap::recover(root).unwrap();
|
||||
assert!(!service.exists());
|
||||
assert!(fs::symlink_metadata(&entry).is_err());
|
||||
assert!(!active_stage.exists());
|
||||
assert!(fs::symlink_metadata(&enable_stage).is_err());
|
||||
assert_eq!(fs::read(&sentinel).unwrap(), b"keep");
|
||||
assert!(!path.exists());
|
||||
|
||||
// A committed publication cleans only its old staging copies; it must not
|
||||
// undo the new active version, including after a failed cleanup retry.
|
||||
fs::write(&service, b"new service").unwrap();
|
||||
symlink("/opt/dever/bin/dever", &entry).unwrap();
|
||||
fs::create_dir(root.join("opt/dever/sandbox")).unwrap();
|
||||
fs::write(root.join("etc/apparmor.d/dever-sandbox"), b"new profile").unwrap();
|
||||
symlink(
|
||||
"../deverd.service",
|
||||
root.join("etc/systemd/system/multi-user.target.wants/deverd.service"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(layout.state().join("active-version"), b"new active").unwrap();
|
||||
fs::write(&active_stage, b"old active").unwrap();
|
||||
let mut committed = journal;
|
||||
committed["phase"] = json!("committed");
|
||||
fs::write(&path, serde_json::to_vec(&committed).unwrap()).unwrap();
|
||||
fs::write(&service, b"unexpected modification").unwrap();
|
||||
assert!(
|
||||
bootstrap::recover(root)
|
||||
.unwrap_err()
|
||||
.contains("committed bootstrap destination changed")
|
||||
);
|
||||
assert!(path.exists());
|
||||
assert_eq!(
|
||||
fs::read(layout.state().join("active-version")).unwrap(),
|
||||
b"new active"
|
||||
);
|
||||
fs::write(&service, b"new service").unwrap();
|
||||
bootstrap::recover(root).unwrap();
|
||||
bootstrap::recover(root).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(layout.state().join("active-version")).unwrap(),
|
||||
b"new active"
|
||||
);
|
||||
assert!(!active_stage.exists());
|
||||
assert!(!path.exists());
|
||||
assert_eq!(fs::read(sentinel).unwrap(), b"keep");
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
//! Real bootstrap installation and execution; no global service is changed.
|
||||
use super::*;
|
||||
use std::os::unix::fs::{MetadataExt, chown};
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::process::Child;
|
||||
|
||||
struct Daemon(Child);
|
||||
impl Drop for Daemon {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_socket(daemon: &mut Daemon, socket: &Path) {
|
||||
for _ in 0..200 {
|
||||
assert!(
|
||||
daemon.0.try_wait().unwrap().is_none(),
|
||||
"installed daemon exited before becoming ready"
|
||||
);
|
||||
if socket.exists() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
panic!("installed daemon did not create its socket");
|
||||
}
|
||||
|
||||
/// Clients share the daemon's PID namespace so SO_PEERCRED can identify both
|
||||
/// peers. Separate bwrap invocations would hide the daemon PID from clients.
|
||||
struct IsolatedDaemon {
|
||||
process: Daemon,
|
||||
namespace_pid: u32,
|
||||
namespace: std::os::fd::OwnedFd,
|
||||
root: PathBuf,
|
||||
stopped: bool,
|
||||
}
|
||||
|
||||
impl IsolatedDaemon {
|
||||
fn start(root: &Path) -> Self {
|
||||
let information = TemporaryDirectory::new();
|
||||
let path = information.path().join("namespace.json");
|
||||
let output = fs::File::create(&path).unwrap();
|
||||
let mut process = Daemon(
|
||||
isolated_os_namespace(root)
|
||||
.args([
|
||||
"--info-fd",
|
||||
"1",
|
||||
"--",
|
||||
"/fixture-proc-init",
|
||||
"/opt/dever/bin/deverd",
|
||||
"--root",
|
||||
"/opt/dever",
|
||||
])
|
||||
.stdout(Stdio::from(output))
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
wait_socket(&mut process, &root.join("run/dever/deverd.sock"));
|
||||
assert!(fs::metadata(&path).unwrap().len() <= 4096);
|
||||
let info: Value = serde_json::from_slice(&fs::read(path).unwrap()).unwrap();
|
||||
let namespace_pid = u32::try_from(info["child-pid"].as_u64().unwrap()).unwrap();
|
||||
assert_ne!(namespace_pid, 0);
|
||||
let namespace = rustix::process::pidfd_open(
|
||||
rustix::process::Pid::from_raw(i32::try_from(namespace_pid).unwrap()).unwrap(),
|
||||
rustix::process::PidfdFlags::empty(),
|
||||
)
|
||||
.unwrap();
|
||||
Self {
|
||||
process,
|
||||
namespace_pid,
|
||||
namespace,
|
||||
root: root.to_owned(),
|
||||
stopped: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn client(&mut self) -> Command {
|
||||
assert!(self.process.0.try_wait().unwrap().is_none());
|
||||
let target = fs::metadata(format!("/proc/{}/root", self.namespace_pid)).unwrap();
|
||||
let expected = fs::metadata(&self.root).unwrap();
|
||||
assert_eq!(
|
||||
(target.dev(), target.ino()),
|
||||
(expected.dev(), expected.ino())
|
||||
);
|
||||
let mut command = Command::new("/usr/bin/nsenter");
|
||||
command.env_clear().args([
|
||||
"--target",
|
||||
&self.namespace_pid.to_string(),
|
||||
"--all",
|
||||
"--root",
|
||||
"--wdns=/",
|
||||
"--",
|
||||
]);
|
||||
command
|
||||
}
|
||||
|
||||
fn stop(&mut self) -> Result<(), String> {
|
||||
if self.stopped {
|
||||
return Ok(());
|
||||
}
|
||||
// Target the pinned namespace init, not a reusable numeric PID. Its
|
||||
// termination also kills its namespace descendants; waiting only for
|
||||
// the outer bwrap process does not establish daemon lock release.
|
||||
match rustix::process::pidfd_send_signal(&self.namespace, rustix::process::Signal::KILL) {
|
||||
Ok(()) | Err(rustix::io::Errno::SRCH) => {}
|
||||
Err(error) => return Err(format!("cannot stop owned namespace: {error}")),
|
||||
}
|
||||
let expected = fs::metadata(&self.root).map_err(|error| error.to_string())?;
|
||||
let lock = fs::File::open(self.root.join("opt/dever/state/deverd.lock"))
|
||||
.map_err(|error| error.to_string())?;
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let wrapper_exited = self
|
||||
.process
|
||||
.0
|
||||
.try_wait()
|
||||
.map_err(|error| error.to_string())?
|
||||
.is_some();
|
||||
let namespace_exited = match fs::metadata(format!("/proc/{}/root", self.namespace_pid))
|
||||
{
|
||||
Ok(current) => (current.dev(), current.ino()) != (expected.dev(), expected.ino()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => true,
|
||||
Err(error) => return Err(format!("cannot inspect owned namespace exit: {error}")),
|
||||
};
|
||||
if wrapper_exited && namespace_exited {
|
||||
match fs2::FileExt::try_lock_exclusive(&lock) {
|
||||
Ok(()) => {
|
||||
self.stopped = true;
|
||||
return Ok(());
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
|
||||
Err(error) => {
|
||||
return Err(format!("cannot check owned daemon lock release: {error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return Err("owned namespace or daemon lock remained live after 10 seconds".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for IsolatedDaemon {
|
||||
fn drop(&mut self) {
|
||||
// Cleanup must not cause a second panic while unwinding a failed
|
||||
// assertion. Normal stage transitions explicitly check stop().
|
||||
if let Err(error) = self.stop() {
|
||||
eprintln!("bootstrap fixture cleanup failed: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_inputs(author: &Path) {
|
||||
let launcher = input(
|
||||
author,
|
||||
Path::new(env!("CARGO_BIN_EXE_dever-launcher")),
|
||||
"bootstrap/dever",
|
||||
);
|
||||
let daemon = input(
|
||||
author,
|
||||
Path::new(env!("CARGO_BIN_EXE_deverd")),
|
||||
"bootstrap/deverd",
|
||||
);
|
||||
let mut gcc = input(
|
||||
author,
|
||||
Path::new("/lib/x86_64-linux-gnu/libgcc_s.so.1"),
|
||||
"bootstrap/libgcc_s.so.1",
|
||||
);
|
||||
gcc["path"] = json!("libgcc_s.so.1");
|
||||
let path = author.join("config/setting.json");
|
||||
let mut settings: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
settings["bootstrap"] = json!({"launcher":launcher,"daemon":daemon,"libraries":[gcc]});
|
||||
fs::write(path, serde_json::to_vec(&settings).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires optimized author archives and Linux root; installs only into an owned image root"]
|
||||
fn signed_bootstrap_installs_and_serves_two_projects_without_host_libraries() {
|
||||
assert_eq!(rustix::process::geteuid().as_raw(), 0);
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let author = TemporaryDirectory::new();
|
||||
let key = author_inputs(author.path(), &workspace);
|
||||
add_inputs(author.path());
|
||||
let downloaded = machine_temporary_directory(&workspace);
|
||||
let release = downloaded.path().join("release");
|
||||
let manifest = packaging::create(author.path(), &release).unwrap();
|
||||
let image = machine_temporary_directory(&workspace);
|
||||
ecosystems::minimal_os(image.path());
|
||||
let installer = TemporaryDirectory::new();
|
||||
let public = installer.path().join("release.pub");
|
||||
fs::write(
|
||||
&public,
|
||||
key.public_key()
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>(),
|
||||
)
|
||||
.unwrap();
|
||||
write(installer.path(), "config/setting.json", serde_json::to_vec(&json!({
|
||||
"release":release,"system_root":image.path(),"trusted_key":public,"activate_service":false
|
||||
})).unwrap());
|
||||
// This author-built installer is the independently trusted first program;
|
||||
// no executable from an unchecked downloaded directory starts as root.
|
||||
command(
|
||||
Command::new(env!("CARGO_BIN_EXE_dever-launcher"))
|
||||
.arg("--dever-install")
|
||||
.arg(installer.path()),
|
||||
);
|
||||
let layout = Layout::new(image.path().join("opt/dever"));
|
||||
let sandbox_artifact = manifest
|
||||
.artifacts
|
||||
.iter()
|
||||
.find(|artifact| artifact.path == "sandbox/linux-x86_64/bin/bwrap")
|
||||
.unwrap();
|
||||
let sandbox_store = layout.root().join("sandbox");
|
||||
let sandbox_helper = sandbox_store.join(&sandbox_artifact.sha256).join("bwrap");
|
||||
assert_eq!(
|
||||
sha256_file(&sandbox_helper).unwrap(),
|
||||
sandbox_artifact.sha256
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&sandbox_helper).unwrap().mode() & 0o7777,
|
||||
0o755
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(image.path().join("etc/apparmor.d/dever-sandbox")).unwrap(),
|
||||
dever_sandbox::APPARMOR_PROFILE
|
||||
);
|
||||
let launcher = layout.bin().join("dever");
|
||||
let executable = layout.bin().join("deverd");
|
||||
assert_eq!(
|
||||
fs::read_link(image.path().join("usr/local/bin/dever")).unwrap(),
|
||||
Path::new("/opt/dever/bin/dever")
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_link(
|
||||
image
|
||||
.path()
|
||||
.join("etc/systemd/system/multi-user.target.wants/deverd.service")
|
||||
)
|
||||
.unwrap(),
|
||||
Path::new("../deverd.service")
|
||||
);
|
||||
let unit = fs::read_to_string(image.path().join("etc/systemd/system/deverd.service")).unwrap();
|
||||
assert!(unit.contains("ExecStart=/opt/dever/bin/deverd --root /opt/dever"));
|
||||
assert!(unit.contains("User=root"));
|
||||
for file in [
|
||||
&launcher,
|
||||
&executable,
|
||||
&layout.bin().join("lib/libgcc_s.so.1"),
|
||||
&layout.state().join("trusted-release-key"),
|
||||
&image.path().join("etc/systemd/system/deverd.service"),
|
||||
] {
|
||||
let metadata = fs::metadata(file).unwrap();
|
||||
assert_eq!(metadata.uid(), 0);
|
||||
assert_eq!(metadata.mode() & 0o022, 0);
|
||||
}
|
||||
let active = fs::read(layout.state().join("active-version")).unwrap();
|
||||
let launcher_hash = sha256_file(&launcher).unwrap();
|
||||
// Model an older, correctly signed bootstrap service template. Its exact
|
||||
// signed bytes establish ownership; only the incoming payload must match
|
||||
// the current installer's supported service template.
|
||||
let unit_path = image.path().join("etc/systemd/system/deverd.service");
|
||||
let current_unit = fs::read_to_string(&unit_path).unwrap();
|
||||
let old_unit = current_unit.replace("RestartSec=1", "RestartSec=2");
|
||||
assert_ne!(old_unit, current_unit);
|
||||
fs::write(layout.bin().join("deverd.service"), &old_unit).unwrap();
|
||||
let mut old_manifest: Value =
|
||||
serde_json::from_slice(&fs::read(layout.bin().join("manifest.json")).unwrap()).unwrap();
|
||||
let service_artifact = old_manifest["artifacts"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.iter_mut()
|
||||
.find(|artifact| artifact["path"] == "bootstrap/deverd.service")
|
||||
.unwrap();
|
||||
service_artifact["bytes"] = json!(old_unit.len());
|
||||
service_artifact["sha256"] = json!(sha256_file(&layout.bin().join("deverd.service")).unwrap());
|
||||
let encoded = serde_json::to_vec(&old_manifest).unwrap();
|
||||
fs::write(
|
||||
layout.bin().join("manifest.sig"),
|
||||
key.sign(&encoded)
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(layout.bin().join("manifest.json"), encoded).unwrap();
|
||||
fs::write(&unit_path, b"unrelated system unit").unwrap();
|
||||
let rejected = Command::new(env!("CARGO_BIN_EXE_dever-launcher"))
|
||||
.env_clear()
|
||||
.arg("--dever-install")
|
||||
.arg(installer.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!rejected.status.success());
|
||||
assert!(String::from_utf8_lossy(&rejected.stderr).contains("unrelated system service"));
|
||||
assert_eq!(
|
||||
fs::read(layout.state().join("active-version")).unwrap(),
|
||||
active
|
||||
);
|
||||
fs::write(&unit_path, old_unit).unwrap();
|
||||
// Retain a different, correctly signed static helper identity through an
|
||||
// upgrade. ELF trailing padding keeps this executable's actual behavior.
|
||||
let mut previous_helper = fs::read(&sandbox_helper).unwrap();
|
||||
previous_helper.push(0);
|
||||
let previous_hash = dever_sandbox::validate_bwrap(&previous_helper).unwrap();
|
||||
let previous_root = sandbox_store.join(&previous_hash);
|
||||
fs::create_dir(&previous_root).unwrap();
|
||||
fs::write(previous_root.join("bwrap"), &previous_helper).unwrap();
|
||||
fs::set_permissions(
|
||||
previous_root.join("bwrap"),
|
||||
fs::Permissions::from_mode(0o755),
|
||||
)
|
||||
.unwrap();
|
||||
let mut previous_manifest = manifest.clone();
|
||||
let previous_artifact = previous_manifest
|
||||
.artifacts
|
||||
.iter_mut()
|
||||
.find(|artifact| artifact.path == sandbox_artifact.path)
|
||||
.unwrap();
|
||||
previous_artifact.sha256 = previous_hash.clone();
|
||||
previous_artifact.bytes = previous_helper.len() as u64;
|
||||
let previous_encoded = serde_json::to_vec(&previous_manifest).unwrap();
|
||||
fs::write(
|
||||
previous_root.join("manifest.sig"),
|
||||
key.sign(&previous_encoded)
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(previous_root.join("manifest.json"), previous_encoded).unwrap();
|
||||
// Retry is supported, but a later damaged package cannot damage the
|
||||
// published launcher or compiler selection.
|
||||
command(
|
||||
Command::new(env!("CARGO_BIN_EXE_dever-launcher"))
|
||||
.arg("--dever-install")
|
||||
.arg(installer.path()),
|
||||
);
|
||||
assert_eq!(fs::read_to_string(&unit_path).unwrap(), current_unit);
|
||||
assert_eq!(
|
||||
sha256_file(&sandbox_helper).unwrap(),
|
||||
sandbox_artifact.sha256
|
||||
);
|
||||
assert_eq!(
|
||||
sha256_file(&previous_root.join("bwrap")).unwrap(),
|
||||
previous_hash
|
||||
);
|
||||
assert_eq!(fs::read_dir(&sandbox_store).unwrap().count(), 2);
|
||||
let before_bad_install = fs::read(layout.state().join("active-version")).unwrap();
|
||||
fs::write(
|
||||
release.join("bootstrap/lib/libgcc_s.so.1"),
|
||||
b"changed after signing",
|
||||
)
|
||||
.unwrap();
|
||||
let failed = Command::new(env!("CARGO_BIN_EXE_dever-launcher"))
|
||||
.env_clear()
|
||||
.arg("--dever-install")
|
||||
.arg(installer.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!failed.status.success());
|
||||
assert_eq!(
|
||||
fs::read(layout.state().join("active-version")).unwrap(),
|
||||
before_bad_install
|
||||
);
|
||||
assert_eq!(sha256_file(&launcher).unwrap(), launcher_hash);
|
||||
assert!(!active.is_empty());
|
||||
drop(downloaded);
|
||||
drop(author);
|
||||
drop(installer);
|
||||
for name in ["first", "second"] {
|
||||
application(&image.path().join(name), "base");
|
||||
}
|
||||
let mut daemon = IsolatedDaemon::start(image.path());
|
||||
for name in ["first", "second"] {
|
||||
command(
|
||||
daemon
|
||||
.client()
|
||||
.args(["/usr/local/bin/dever", "check", &format!("/{name}")]),
|
||||
);
|
||||
assert_response(command(daemon.client().args([
|
||||
"/usr/local/bin/dever",
|
||||
"run",
|
||||
&format!("/{name}"),
|
||||
"--",
|
||||
"sample.value.exercise",
|
||||
"{}",
|
||||
])));
|
||||
command(daemon.client().args([
|
||||
"/usr/local/bin/dever",
|
||||
"build",
|
||||
&format!("/{name}"),
|
||||
"--output",
|
||||
&format!("/{name}/program"),
|
||||
]));
|
||||
}
|
||||
let status = command(
|
||||
daemon
|
||||
.client()
|
||||
.args(["/usr/local/bin/dever", "cache", "status"]),
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&status.stdout).unwrap()["entries"],
|
||||
1
|
||||
);
|
||||
daemon
|
||||
.stop()
|
||||
.expect("isolated daemon and namespace fully stopped");
|
||||
drop(daemon);
|
||||
|
||||
// Namespace-root execution above proves the complete loader closure. These
|
||||
// clients retain real distinct kernel UIDs, without a single-UID userns
|
||||
// translating file ownership to overflow IDs.
|
||||
fs::set_permissions(image.path(), fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let mut daemon = Daemon(
|
||||
Command::new(&executable)
|
||||
.env_clear()
|
||||
.args(["--root", layout.root().to_str().unwrap()])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
wait_socket(&mut daemon, &layout.service_socket());
|
||||
for (name, uid) in [("first", 65533), ("second", 65534)] {
|
||||
let project = image.path().join(name);
|
||||
chown(&project, Some(uid), Some(uid)).unwrap();
|
||||
assert_response(command(
|
||||
Command::new(&launcher)
|
||||
.uid(uid)
|
||||
.gid(uid)
|
||||
.arg("run")
|
||||
.arg(&project)
|
||||
.args(["--", "sample.value.exercise", "{}"]),
|
||||
));
|
||||
let denied = Command::new(&launcher)
|
||||
.uid(uid)
|
||||
.gid(uid)
|
||||
.env_clear()
|
||||
.args(["use", manifest.version.as_str()])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!denied.status.success());
|
||||
assert_eq!(
|
||||
fs::read(layout.state().join("active-version")).unwrap(),
|
||||
before_bad_install
|
||||
);
|
||||
}
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 1);
|
||||
drop(daemon);
|
||||
for name in ["first", "second"] {
|
||||
fs::remove_dir_all(image.path().join(name).join("module")).unwrap();
|
||||
}
|
||||
fs::remove_dir_all(layout.root()).unwrap();
|
||||
for name in ["first", "second"] {
|
||||
assert_response(command(isolated_os_command(image.path()).args([
|
||||
&format!("/{name}/program"),
|
||||
"sample.value.exercise",
|
||||
"{}",
|
||||
])));
|
||||
}
|
||||
write(&workspace, "target/closure-linux-bootstrap-acceptance.json", serde_json::to_vec_pretty(&json!({
|
||||
"format":"dever-linux-bootstrap-acceptance-v1","platform":"linux-x86_64",
|
||||
"bootstrap":manifest.artifacts.iter().filter(|artifact| artifact.path.starts_with("bootstrap/")).collect::<Vec<_>>(),
|
||||
"checks":{"signed_first_install":true,"stable_public_entry":true,"root_owned_systemd_assets":true,"two_projects_in_minimal_os_root":true,"real_distinct_uids":true,"shared_compile_entries":1,"failed_install_preserves_old_entry":true,"source_and_machine_removed_execution":true},
|
||||
"global_service_actions":"not_run","public_release":"not_published"
|
||||
})).unwrap());
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Owned signed CLI and relocated output; the tool is an ordinary dynamic ELF.
|
||||
use super::*;
|
||||
use dever_cli::libs::LockFile;
|
||||
use dever_cli::packages::LockedPackage;
|
||||
use dever_cli::toolchain::artifact_put;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
fn project(root: &Path, workspace: &Path) {
|
||||
write(
|
||||
root,
|
||||
"module/media/tool/port.dever",
|
||||
"execute(args: List<Text>, stdin: Bytes?) (output: dever.process.Output) fails dever.process.Error\n",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/media/tool/adapter.dever",
|
||||
"external command \"bin/tool\" {}\n",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/media/tool/app.dever",
|
||||
r#"execute() (passed: Bool) {
|
||||
input = dever.bytes.from_ints([0, 255, 128, 10])
|
||||
output = port.execute(["a b", "$(literal);*"], input)
|
||||
passed = output.code == 23 and output.stdout == input and dever.bytes.to_text(output.stderr) == "ordinary command"
|
||||
}
|
||||
"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/media/tool/api.dever",
|
||||
"cmd execute = app.execute\n",
|
||||
);
|
||||
write(root, "config/setting.json", "{}");
|
||||
let directory = root.join("module/media/tool/bin");
|
||||
fs::create_dir_all(directory.join("lib")).unwrap();
|
||||
command(
|
||||
Command::new("/usr/bin/cc")
|
||||
.arg("-B/usr/bin/")
|
||||
.args(["-shared", "-fPIC", "-Wl,-soname,libcommand.so", "-o"])
|
||||
.arg(directory.join("lib/libcommand.so"))
|
||||
.arg(workspace.join("test/dever-cli-tests/support/command_library.c")),
|
||||
);
|
||||
command(
|
||||
Command::new("/usr/bin/cc")
|
||||
.arg("-B/usr/bin/")
|
||||
.arg(workspace.join("test/dever-cli-tests/support/command_tool.c"))
|
||||
.arg("-L")
|
||||
.arg(directory.join("lib"))
|
||||
.arg("-lcommand")
|
||||
.arg("-o")
|
||||
.arg(directory.join("tool")),
|
||||
);
|
||||
}
|
||||
|
||||
fn packaged_project(root: &Path, source: &Path, layout: &Layout) {
|
||||
let manifest = br#"{"format":"dever-package-v1","name":"media","version":"1.0.0","dependencies":{},"lib":[]}"#;
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
zip.start_file(
|
||||
"dever-package.json",
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(manifest).unwrap();
|
||||
for file in [
|
||||
"port.dever",
|
||||
"adapter.dever",
|
||||
"app.dever",
|
||||
"api.dever",
|
||||
"bin/tool",
|
||||
"bin/lib/libcommand.so",
|
||||
] {
|
||||
let name = format!("module/media/tool/{file}");
|
||||
zip.start_file(&name, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(&fs::read(source.join(name)).unwrap())
|
||||
.unwrap();
|
||||
}
|
||||
let archive = zip.finish().unwrap().into_inner();
|
||||
let receipt = artifact_put(layout, &archive).unwrap();
|
||||
fs::create_dir_all(root.join("module")).unwrap();
|
||||
write(
|
||||
root,
|
||||
"config/setting.json",
|
||||
r#"{"package":{"registry":"https://packages.example.test","use":["media@1.0.0"]}}"#,
|
||||
);
|
||||
let mut lock = LockFile::new(Vec::new()).unwrap();
|
||||
lock.packages.push(LockedPackage {
|
||||
name: "media".into(),
|
||||
version: "1.0.0".into(),
|
||||
sha256: receipt.sha256,
|
||||
bytes: archive.len() as u64,
|
||||
dependencies: Vec::new(),
|
||||
manifest_sha256: format!("{:x}", Sha256::digest(manifest)),
|
||||
});
|
||||
lock.write_atomic(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit current runtime archive, author C/LLVM inputs and owned daemon/sandbox"]
|
||||
fn signed_cli_runs_and_packages_ordinary_command_with_private_library() {
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let author = TemporaryDirectory::new();
|
||||
let key = author_inputs(author.path(), &workspace);
|
||||
// This test validates the command boundary, not release-profile trimming.
|
||||
// Use the explicitly rebuilt archive containing the current wire ABI.
|
||||
let current = input(
|
||||
author.path(),
|
||||
&workspace.join("target/native-runtime-abi/debug/libdever_backend_bridge.a"),
|
||||
"command-base.a",
|
||||
);
|
||||
let settings_path = author.path().join("config/setting.json");
|
||||
let mut settings: Value = serde_json::from_slice(&fs::read(&settings_path).unwrap()).unwrap();
|
||||
settings["profiles"]["base"] = current;
|
||||
fs::write(settings_path, serde_json::to_vec(&settings).unwrap()).unwrap();
|
||||
|
||||
let machine = machine_temporary_directory(&workspace);
|
||||
let layout = Layout::new(machine.path());
|
||||
layout.initialize().unwrap();
|
||||
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
||||
let release = layout.downloads().join(version.as_str());
|
||||
packaging::create(author.path(), &release).unwrap();
|
||||
install_release(&layout, &version, &key);
|
||||
let compiler = layout.versions().join(version.as_str()).join("dever-core");
|
||||
let mut daemon = daemon::TestDaemon::start(&layout);
|
||||
let source = TemporaryDirectory::new();
|
||||
project(source.path(), &workspace);
|
||||
command(Command::new(&compiler).arg("check").arg(source.path()));
|
||||
assert_response(command(
|
||||
Command::new(&compiler).arg("run").arg(source.path()).args([
|
||||
"--",
|
||||
"media.tool.execute",
|
||||
"{}",
|
||||
]),
|
||||
));
|
||||
assert!(!source.path().join("dever.lock").exists());
|
||||
|
||||
let deployment = TemporaryDirectory::new();
|
||||
let executable = deployment.path().join("program");
|
||||
command(
|
||||
Command::new(&compiler)
|
||||
.arg("build")
|
||||
.arg(source.path())
|
||||
.arg("--output")
|
||||
.arg(&executable),
|
||||
);
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 1);
|
||||
let package_source = TemporaryDirectory::new();
|
||||
packaged_project(package_source.path(), source.path(), &layout);
|
||||
assert_response(command(
|
||||
Command::new(&compiler)
|
||||
.arg("run")
|
||||
.arg(package_source.path())
|
||||
.args(["--", "media.tool.execute", "{}"]),
|
||||
));
|
||||
let package_executable = deployment.path().join("package-program");
|
||||
command(
|
||||
Command::new(&compiler)
|
||||
.arg("build")
|
||||
.arg(package_source.path())
|
||||
.arg("--output")
|
||||
.arg(&package_executable),
|
||||
);
|
||||
write(deployment.path(), "config/setting.json", "{}");
|
||||
daemon.stop();
|
||||
drop(source);
|
||||
drop(package_source);
|
||||
drop(author);
|
||||
drop(machine);
|
||||
assert_response(command(
|
||||
Command::new(&executable)
|
||||
.args(["media.tool.execute", "{}"])
|
||||
.current_dir(deployment.path()),
|
||||
));
|
||||
assert_response(command(
|
||||
Command::new(&package_executable)
|
||||
.args(["media.tool.execute", "{}"])
|
||||
.current_dir(deployment.path()),
|
||||
));
|
||||
let cache = deployment.path().join("data/cache/lib");
|
||||
let bundle = fs::read_dir(cache).unwrap().next().unwrap().unwrap().path();
|
||||
let commands = bundle.join("commands");
|
||||
let tree = fs::read_dir(commands)
|
||||
.unwrap()
|
||||
.next()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.path();
|
||||
assert!(tree.join("lib/libcommand.so").is_file());
|
||||
fs::write(tree.join("lib/libcommand.so"), b"tampered").unwrap();
|
||||
let capture = deployment.path().join("tamper.stderr");
|
||||
let status = process::status(
|
||||
Command::new(&executable)
|
||||
.env_clear()
|
||||
.current_dir(deployment.path())
|
||||
.args(["media.tool.execute", "{}"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::from(fs::File::create(&capture).unwrap())),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!status.success());
|
||||
assert!(fs::read_to_string(capture).unwrap().contains("resource"));
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! Explicit x86 compiler -> ARM application acceptance; QEMU user mode does
|
||||
//! not establish the namespace/seccomp contract of external Workers.
|
||||
use super::*;
|
||||
use dever_cli::toolchain::BuildTarget;
|
||||
use dever_cli::toolchain::compilation::{CompileKind, CompileRequest};
|
||||
use dever_core::source::SourceMap;
|
||||
use dever_runtime::config::Settings;
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
|
||||
#[path = "cross_ecosystems.rs"]
|
||||
mod ecosystems;
|
||||
|
||||
fn add_arm_inputs(author: &Path, workspace: &Path) {
|
||||
let prepared = workspace.join("target/arm64-cross");
|
||||
let mut profiles = serde_json::Map::new();
|
||||
for profile in PROFILES {
|
||||
let name = format!("{profile}.a");
|
||||
profiles.insert(
|
||||
profile.into(),
|
||||
input(
|
||||
author,
|
||||
&prepared.join("archives").join(&name),
|
||||
&format!("arm/{name}"),
|
||||
),
|
||||
);
|
||||
}
|
||||
let libc = prepared.join("tools/usr/aarch64-linux-gnu/lib");
|
||||
let gcc = prepared.join("tools/usr/lib/gcc-cross/aarch64-linux-gnu/13");
|
||||
let links = |root: &Path, names: &[&str]| -> Vec<Value> {
|
||||
names
|
||||
.iter()
|
||||
.map(|name| {
|
||||
let mut entry = input(author, &root.join(name), &format!("arm/{name}"));
|
||||
entry["path"] = json!(name);
|
||||
entry
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let start = [
|
||||
links(&libc, &["crt1.o", "crti.o"]),
|
||||
links(&gcc, &["crtbeginT.o"]),
|
||||
]
|
||||
.concat();
|
||||
let libraries = [
|
||||
links(&libc, &["libc.a", "libm-2.39.a", "libmvec.a"]),
|
||||
links(&gcc, &["libgcc.a", "libgcc_eh.a"]),
|
||||
]
|
||||
.concat();
|
||||
let end = [links(&gcc, &["crtend.o"]), links(&libc, &["crtn.o"])].concat();
|
||||
let path = author.join("config/setting.json");
|
||||
let mut settings: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
settings["native_targets"] = json!({"linux-aarch64": {"profiles": profiles, "start":start, "libraries":libraries, "end":end}});
|
||||
fs::write(path, serde_json::to_vec(&settings).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
fn build(compiler: &Path, project: &Path, output: &Path, target: BuildTarget) {
|
||||
command(
|
||||
Command::new(compiler)
|
||||
.arg("build")
|
||||
.arg(project)
|
||||
.args(["--target", target.platform(), "--output"])
|
||||
.arg(output),
|
||||
);
|
||||
let bytes = fs::read(output).unwrap();
|
||||
let elf = goblin::elf::Elf::parse(&bytes).unwrap();
|
||||
assert!(elf.is_64 && elf.little_endian);
|
||||
assert!(elf.interpreter.is_none() && elf.libraries.is_empty());
|
||||
assert_eq!(
|
||||
elf.header.e_machine,
|
||||
match target {
|
||||
BuildTarget::LinuxX86_64 => goblin::elf::header::EM_X86_64,
|
||||
BuildTarget::LinuxAarch64 => goblin::elf::header::EM_AARCH64,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fn request(project: &Path, target: BuildTarget) -> CompileRequest {
|
||||
let sources = SourceMap::load_with_packages(&project.join("module"), None, &[]).unwrap();
|
||||
let bindings = Settings::load_project(project)
|
||||
.unwrap()
|
||||
.compilation_bindings();
|
||||
CompileRequest::new(
|
||||
env!("CARGO_PKG_VERSION").into(),
|
||||
CompileKind::Application,
|
||||
&sources,
|
||||
Some(bindings),
|
||||
&[],
|
||||
target,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn private_compiler(
|
||||
installed: &Path,
|
||||
destination: &Path,
|
||||
manifest: &dever_cli::toolchain::ReleaseManifest,
|
||||
) -> PathBuf {
|
||||
for artifact in &manifest.artifacts {
|
||||
if artifact.path != "dever-core"
|
||||
&& !artifact.path.starts_with("lib/")
|
||||
&& !artifact.path.starts_with("runtime/linux-aarch64/")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let relative = if artifact.path == "dever-core" {
|
||||
"dever"
|
||||
} else {
|
||||
&artifact.path
|
||||
};
|
||||
let output = destination.join(relative);
|
||||
fs::create_dir_all(output.parent().unwrap()).unwrap();
|
||||
fs::hard_link(installed.join(&artifact.path), output).unwrap();
|
||||
}
|
||||
destination.join("dever")
|
||||
}
|
||||
|
||||
fn reject_tampered_cache_hit(layout: &Layout, archive: &Path, request: &CompileRequest) {
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(archive)
|
||||
.unwrap();
|
||||
let mut original = [0; 1];
|
||||
file.read_exact(&mut original).unwrap();
|
||||
file.seek(SeekFrom::Start(0)).unwrap();
|
||||
file.write_all(&[original[0] ^ 1]).unwrap();
|
||||
file.sync_all().unwrap();
|
||||
let rejected = dever_cli::toolchain::compile(layout, request);
|
||||
file.seek(SeekFrom::Start(0)).unwrap();
|
||||
file.write_all(&original).unwrap();
|
||||
file.sync_all().unwrap();
|
||||
assert!(rejected.unwrap_err().contains("integrity verification"));
|
||||
}
|
||||
|
||||
fn export_http_application(compiler: &Path, projects: &Path, workspace: &Path) -> Value {
|
||||
let project = projects.join("http");
|
||||
for (path, source) in [
|
||||
(
|
||||
"user/account/model.dever",
|
||||
"global type Account { name: Text(1, 32) }",
|
||||
),
|
||||
(
|
||||
"platform/tenant/model.dever",
|
||||
"global type Tenant { name: Text(1, 32) }",
|
||||
),
|
||||
(
|
||||
"user/account/app.dever",
|
||||
"type Identity { id: Text\nuser_id: user.account.model.id?\ntenant_id: platform.tenant.model.id? }\nverify(claims: dever.auth.Claims) (identity: Identity) { identity = Identity { id = claims.subject\nuser_id = null\ntenant_id = null } }",
|
||||
),
|
||||
(
|
||||
"sample/http/app.dever",
|
||||
"hello() (message: Text) { message = \"arm-http\" }",
|
||||
),
|
||||
("sample/http/api.dever", "public get hello = app.hello"),
|
||||
] {
|
||||
write(&project, &format!("module/{path}"), source);
|
||||
}
|
||||
let settings = serde_json::to_vec(&json!({
|
||||
"http":{"host":"127.0.0.1","port":18080},
|
||||
"runtime":{"mode":"api","shutdown_ms":2000}, "log":{"level":"error"},
|
||||
"database":{"default":{"type":"sqlite","path":"data/http.sqlite","max_connections":1}},
|
||||
"auth":{"providers":{"session":{"verify":"user.account.verify","jwtSecret":"cross-acceptance-only-test-key-2026"}}},
|
||||
"sites":{"test":{"path":"","auth":"session"}}
|
||||
})).unwrap();
|
||||
write(&project, "config/setting.json", &settings);
|
||||
let program = project.join("application");
|
||||
build(compiler, &project, &program, BuildTarget::LinuxAarch64);
|
||||
let exported = workspace.join("target/arm64-cross/guest/apps/http");
|
||||
write(&exported, "config/setting.json", settings);
|
||||
fs::copy(&program, exported.join("application")).unwrap();
|
||||
json!({"sha256":sha256_file(&program).unwrap(), "profile":"sqlite", "url":"http://127.0.0.1:18080/sample/http/hello", "expected_data":"arm-http", "execution":"pending_guest_validation"})
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared x86/ARM four-profile runtime archives and private QEMU; starts only an owned daemon"]
|
||||
fn signed_host_compiler_builds_arm_profiles_and_runs_standalone_under_qemu() {
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()
|
||||
.unwrap();
|
||||
let emulator = workspace.join("target/arm64-cross/tools/usr/bin/qemu-aarch64-static");
|
||||
assert!(
|
||||
emulator.is_file(),
|
||||
"prepare the task-owned static QEMU emulator"
|
||||
);
|
||||
let author = TemporaryDirectory::new();
|
||||
let key = author_inputs(author.path(), &workspace);
|
||||
add_arm_inputs(author.path(), &workspace);
|
||||
let machine = machine_temporary_directory(&workspace);
|
||||
let layout = Layout::new(machine.path());
|
||||
layout.initialize().unwrap();
|
||||
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
||||
let release = layout.downloads().join(version.as_str());
|
||||
let manifest = packaging::create(author.path(), &release).unwrap();
|
||||
assert_eq!(manifest.platform, BuildTarget::LinuxX86_64.platform());
|
||||
install_release(&layout, &version, &key);
|
||||
fs::remove_dir_all(&release).unwrap();
|
||||
drop(author);
|
||||
let compiler = layout.versions().join(version.as_str()).join("dever-core");
|
||||
let mut daemon = daemon::TestDaemon::start(&layout);
|
||||
let projects = TemporaryDirectory::new();
|
||||
let standalone = TemporaryDirectory::new();
|
||||
let mut programs = Vec::new();
|
||||
let mut evidence = serde_json::Map::new();
|
||||
for profile in PROFILES {
|
||||
let project = projects.path().join(profile);
|
||||
application(&project, profile);
|
||||
let destination = standalone.path().join(profile);
|
||||
fs::create_dir_all(destination.join("config")).unwrap();
|
||||
fs::copy(
|
||||
project.join("config/setting.json"),
|
||||
destination.join("config/setting.json"),
|
||||
)
|
||||
.unwrap();
|
||||
let executable = destination.join("application");
|
||||
build(&compiler, &project, &executable, BuildTarget::LinuxAarch64);
|
||||
let before = cache_status(&layout).unwrap().entries;
|
||||
let repeated = project.join("repeated");
|
||||
build(&compiler, &project, &repeated, BuildTarget::LinuxAarch64);
|
||||
assert_eq!(
|
||||
sha256_file(&executable).unwrap(),
|
||||
sha256_file(&repeated).unwrap()
|
||||
);
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, before);
|
||||
evidence.insert(profile.into(), json!({"sha256":sha256_file(&executable).unwrap(), "bytes":fs::metadata(&executable).unwrap().len(), "cache_hit":true}));
|
||||
programs.push((destination, executable));
|
||||
}
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 4);
|
||||
let base_project = projects.path().join("base");
|
||||
let host = standalone.path().join("base/host-application");
|
||||
build(&compiler, &base_project, &host, BuildTarget::LinuxX86_64);
|
||||
assert_response(command(
|
||||
Command::new(&host).args(["sample.value.exercise", "{}"]),
|
||||
));
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 5);
|
||||
|
||||
let private = private_compiler(
|
||||
compiler.parent().unwrap(),
|
||||
&machine.path().join("private"),
|
||||
&manifest,
|
||||
);
|
||||
let private_directory = standalone.path().join("private-base");
|
||||
write(&private_directory, "config/setting.json", b"{}");
|
||||
let private_output = private_directory.join("application");
|
||||
build(
|
||||
&private,
|
||||
&base_project,
|
||||
&private_output,
|
||||
BuildTarget::LinuxAarch64,
|
||||
);
|
||||
programs.push((private_directory, private_output));
|
||||
|
||||
// Use the same canonical request to prove integrity is checked before a hit.
|
||||
let arm_request = request(&base_project, BuildTarget::LinuxAarch64);
|
||||
assert_eq!(
|
||||
dever_cli::toolchain::compile(&layout, &arm_request).unwrap(),
|
||||
fs::read(&programs[0].1).unwrap()
|
||||
);
|
||||
let archive = compiler
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("runtime/linux-aarch64/archives/base.a");
|
||||
reject_tampered_cache_hit(&layout, &archive, &arm_request);
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 5);
|
||||
let http = export_http_application(&compiler, projects.path(), &workspace);
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 6);
|
||||
daemon.stop();
|
||||
drop(projects);
|
||||
drop(machine);
|
||||
|
||||
for (directory, executable) in programs {
|
||||
assert!(!directory.join("module").exists());
|
||||
assert_response(command(
|
||||
Command::new(&emulator)
|
||||
.arg(executable)
|
||||
.current_dir(directory)
|
||||
.args(["sample.value.exercise", "{}"]),
|
||||
));
|
||||
}
|
||||
let report = json!({
|
||||
"format":"dever-cross-application-acceptance-v1", "host":"linux-x86_64", "target":"linux-aarch64",
|
||||
"programs":evidence, "http":http, "qemu_sha256":sha256_file(&emulator).unwrap(),
|
||||
"checks":{"signed_install":true,"profiles":4,"private_arm_build":true,"standalone_after_source_and_machine_removal":true,"sqlite_crud":true,"cache_entries":6,"cache_target_isolation":true,"tampered_target_cache_hit_rejected":true},
|
||||
"postgres_server":"not_run", "worker_sandbox":"requires_arm_kernel_system_emulation"
|
||||
});
|
||||
write(
|
||||
&workspace,
|
||||
"target/arm64-cross/compile-acceptance.json",
|
||||
serde_json::to_vec_pretty(&report).unwrap(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
//! Produce real standalone ARM Worker applications for full-system acceptance.
|
||||
use super::*;
|
||||
use dever_cli::libs::{
|
||||
self, LibSpec, LockFile, LockedArtifact, LockedLib, LockedWorker, RegistryRuntime,
|
||||
};
|
||||
use dever_cli::toolchain::{Artifact, ReleaseManifest};
|
||||
use dever_core::hir::ExternalWorkerContract;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const ECOSYSTEMS: [&str; 3] = ["pip", "npm", "go"];
|
||||
const TARGET: BuildTarget = BuildTarget::LinuxAarch64;
|
||||
|
||||
fn digest(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
fn add_target_sandbox(author: &Path, workspace: &Path) {
|
||||
let root = workspace.join("target/arm64-cross/sandbox");
|
||||
let mut files = Vec::new();
|
||||
let mut inputs = Vec::new();
|
||||
for directory in ["bin", "lib"] {
|
||||
for entry in fs::read_dir(root.join(directory)).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
assert!(entry.file_type().unwrap().is_file());
|
||||
let relative = format!("{directory}/{}", entry.file_name().to_str().unwrap());
|
||||
let bytes = fs::read(entry.path()).unwrap();
|
||||
let source = format!("inputs/arm-sandbox/{relative}");
|
||||
write(author, &source, &bytes);
|
||||
inputs.push(json!({"source":source,"path":relative,"sha256":digest(&bytes)}));
|
||||
files.push((relative, bytes));
|
||||
}
|
||||
}
|
||||
dever_sandbox::validate_assets_for_target(&files, TARGET.platform()).unwrap();
|
||||
let path = author.join("config/setting.json");
|
||||
let mut settings: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
settings["sandbox_targets"] = json!({"linux-aarch64":inputs});
|
||||
fs::write(path, serde_json::to_vec(&settings).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
// Sign the exact prepared archives: their identities also bind each Lib lock.
|
||||
// As in the native ecosystem fixture, this is an explicit private test author.
|
||||
fn sign_target_runtimes(
|
||||
release: &Path,
|
||||
workspace: &Path,
|
||||
key: &Ed25519KeyPair,
|
||||
ecosystems: &[&str],
|
||||
) -> BTreeMap<String, RegistryRuntime> {
|
||||
let mut manifest: ReleaseManifest =
|
||||
serde_json::from_slice(&fs::read(release.join("manifest.json")).unwrap()).unwrap();
|
||||
let mut runtimes = BTreeMap::new();
|
||||
for &ecosystem in ecosystems {
|
||||
let prepared = workspace
|
||||
.join("target/arm64-cross/ecosystems")
|
||||
.join(ecosystem);
|
||||
let settings: Value =
|
||||
serde_json::from_slice(&fs::read(prepared.join("config/setting.json")).unwrap())
|
||||
.unwrap();
|
||||
let runtime: RegistryRuntime = serde_json::from_value(settings["runtime"].clone()).unwrap();
|
||||
assert_eq!(runtime.target, TARGET.platform());
|
||||
let pack = fs::read(prepared.join("runtime.pack")).unwrap();
|
||||
assert_eq!(digest(&pack), runtime.pack.sha256);
|
||||
let prefix = format!("runtime/{ecosystem}/{}", TARGET.platform());
|
||||
write(release, &format!("{prefix}/runtime.pack"), pack);
|
||||
write(
|
||||
release,
|
||||
&format!("{prefix}/manifest.json"),
|
||||
serde_json::to_vec(&json!({"format":"dever-registry-runtime-v1","runtime":runtime}))
|
||||
.unwrap(),
|
||||
);
|
||||
for leaf in ["manifest.json", "runtime.pack"] {
|
||||
let path = format!("{prefix}/{leaf}");
|
||||
assert!(
|
||||
!manifest
|
||||
.artifacts
|
||||
.iter()
|
||||
.any(|artifact| artifact.path == path)
|
||||
);
|
||||
manifest.artifacts.push(Artifact {
|
||||
bytes: fs::metadata(release.join(&path)).unwrap().len(),
|
||||
sha256: sha256_file(&release.join(&path)).unwrap(),
|
||||
path,
|
||||
});
|
||||
}
|
||||
runtimes.insert(ecosystem.to_owned(), runtime);
|
||||
}
|
||||
manifest
|
||||
.artifacts
|
||||
.sort_by(|left, right| left.path.cmp(&right.path));
|
||||
let encoded = serde_json::to_vec(&manifest).unwrap();
|
||||
fs::write(release.join("manifest.json"), &encoded).unwrap();
|
||||
fs::write(
|
||||
release.join("manifest.sig"),
|
||||
key.sign(&encoded)
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>(),
|
||||
)
|
||||
.unwrap();
|
||||
runtimes
|
||||
}
|
||||
|
||||
fn application(root: &Path, ecosystem: &str) -> ExternalWorkerContract {
|
||||
let (extension, library, source) = match ecosystem {
|
||||
"pip" => (
|
||||
"py",
|
||||
"six@1.17.0",
|
||||
include_str!("../../ecosystem-release/arm64/probe.py"),
|
||||
),
|
||||
"npm" => (
|
||||
"mjs",
|
||||
"is-number@7.0.0",
|
||||
include_str!("../../ecosystem-release/arm64/probe.mjs"),
|
||||
),
|
||||
"go" => (
|
||||
"go",
|
||||
"github.com/google/uuid@1.6.0",
|
||||
include_str!("../../ecosystem-release/arm64/probe.go"),
|
||||
),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
write(root, "config/setting.json", "{}\n");
|
||||
write(
|
||||
root,
|
||||
"module/sample/worker/port.dever",
|
||||
"probe() (okay: Bool) fails app.ProbeResult\nreject() (okay: Bool) fails app.ProbeResult\n",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/sample/worker/app.dever",
|
||||
"type ProbeResult { error Unavailable(message: Text) }\nprobe() (okay: Bool) { okay = port.probe() }\nreject() (okay: Bool) { okay = port.reject() }\n",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/sample/worker/api.dever",
|
||||
"cmd probe = app.probe\ncmd reject = app.reject\n",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/sample/worker/adapter.dever",
|
||||
format!("external {ecosystem} \"worker/probe.{extension}\" {{ lib \"{library}\" }}\n"),
|
||||
);
|
||||
let sources = SourceMap::load(&root.join("module")).unwrap();
|
||||
let program = dever_core::check(&sources).unwrap_or_else(|errors| panic!("{errors:?}"));
|
||||
let contract = program.external_worker_contracts().remove(0);
|
||||
write(root, &contract.entry, source);
|
||||
contract
|
||||
}
|
||||
|
||||
fn pure_package_lock(
|
||||
workspace: &Path,
|
||||
contract: &ExternalWorkerContract,
|
||||
runtime: &RegistryRuntime,
|
||||
) -> (LockFile, BTreeMap<String, Vec<u8>>) {
|
||||
let spec: LibSpec = contract.libs[0].parse().unwrap();
|
||||
let (filename, extension) = match contract.ecosystem.as_str() {
|
||||
"pip" => ("six-1.17.0-py2.py3-none-any.whl", "whl"),
|
||||
"npm" => ("is-number-7.0.0.tgz", "tgz"),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let bytes = fs::read(
|
||||
workspace
|
||||
.join("target/arm64-cross/dependencies")
|
||||
.join(filename),
|
||||
)
|
||||
.unwrap();
|
||||
let sha256 = digest(&bytes);
|
||||
let artifact = LockedArtifact {
|
||||
target: TARGET.platform().into(),
|
||||
path: format!(
|
||||
"lib/{}/{}/{}/archive.{extension}",
|
||||
contract.ecosystem,
|
||||
digest(spec.distribution_name().as_bytes()),
|
||||
spec.version
|
||||
),
|
||||
bytes: bytes.len() as u64,
|
||||
sha256: sha256.clone(),
|
||||
};
|
||||
let mut lock = LockFile::new(vec![LockedLib {
|
||||
spec: spec.clone(),
|
||||
dependencies: vec![],
|
||||
runtime: runtime.pack.clone(),
|
||||
artifacts: vec![artifact],
|
||||
schema: contract.schema.clone(),
|
||||
build: None,
|
||||
}])
|
||||
.unwrap();
|
||||
if contract.ecosystem == "npm" {
|
||||
lock.npm.push(libs::npm::Environment::single(spec));
|
||||
}
|
||||
(lock, BTreeMap::from([(sha256, bytes)]))
|
||||
}
|
||||
|
||||
fn go_package_lock(
|
||||
workspace: &Path,
|
||||
runtime: &RegistryRuntime,
|
||||
) -> (LockFile, BTreeMap<String, Vec<u8>>) {
|
||||
let prepared = workspace.join("target/go-managed/dependency");
|
||||
let mut lock = LockFile::decode(&fs::read(prepared.join("dever.lock")).unwrap()).unwrap();
|
||||
libs::doctor(&lock).unwrap();
|
||||
assert!(lock.builds.is_empty() && lock.workers.is_empty());
|
||||
assert_eq!(lock.libs.len(), 1);
|
||||
assert_eq!(lock.libs[0].spec.key(), "go:github.com/google/uuid@1.6.0");
|
||||
let authenticated =
|
||||
libs::sumdb::verify_chain(&lock.go_sumdb, &libs::sumdb::Verifier::official()).unwrap();
|
||||
let mut archives = BTreeMap::new();
|
||||
for library in &mut lock.libs {
|
||||
// Go module ZIPs are source-only; keep the verified source and sumdb
|
||||
// evidence unchanged while selecting the new runtime and build target.
|
||||
library.runtime = runtime.pack.clone();
|
||||
for artifact in &mut library.artifacts {
|
||||
let bytes = fs::read(prepared.join("artifacts").join(&artifact.sha256)).unwrap();
|
||||
assert_eq!(digest(&bytes), artifact.sha256);
|
||||
assert_eq!(bytes.len() as u64, artifact.bytes);
|
||||
authenticated
|
||||
.verify_zip(&library.spec.name, &library.spec.version, &bytes)
|
||||
.unwrap();
|
||||
artifact.target = TARGET.platform().into();
|
||||
archives.insert(artifact.sha256.clone(), bytes);
|
||||
}
|
||||
}
|
||||
(lock, archives)
|
||||
}
|
||||
|
||||
fn prepare_locked_application(
|
||||
project: &Path,
|
||||
workspace: &Path,
|
||||
layout: &Layout,
|
||||
contract: ExternalWorkerContract,
|
||||
runtime: &RegistryRuntime,
|
||||
) -> LockFile {
|
||||
let (mut lock, archives) = if contract.ecosystem == "go" {
|
||||
go_package_lock(workspace, runtime)
|
||||
} else {
|
||||
pure_package_lock(workspace, &contract, runtime)
|
||||
};
|
||||
for (sha256, bytes) in archives {
|
||||
let receipt = dever_cli::toolchain::artifact_put(layout, &bytes).unwrap();
|
||||
assert_eq!(receipt.sha256, sha256);
|
||||
assert_eq!(receipt.bytes, bytes.len() as u64);
|
||||
}
|
||||
lock.workers.push(LockedWorker {
|
||||
libs: contract
|
||||
.libs
|
||||
.iter()
|
||||
.map(|value| value.parse().unwrap())
|
||||
.collect(),
|
||||
port: contract.port,
|
||||
adapter: contract.adapter,
|
||||
ecosystem: contract.ecosystem,
|
||||
runtime: Some(runtime.pack.clone()),
|
||||
entry: contract.entry,
|
||||
schema: contract.schema,
|
||||
capabilities: contract.capabilities,
|
||||
operations: contract.operations,
|
||||
});
|
||||
libs::doctor(&lock).unwrap();
|
||||
lock.write_atomic(project).unwrap();
|
||||
lock
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit ARM runtimes/dependencies/sandbox and native archives; builds with an owned host daemon and exports apps for ARM system QEMU"]
|
||||
fn signed_arm_ecosystems_build_offline_standalone_apps_for_system_guest() {
|
||||
build_guest_apps(&ECOSYSTEMS, "ecosystem-build-acceptance.json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit ARM inputs; rebuilds only Python for SDK shutdown regression under the ARM guest"]
|
||||
fn signed_arm_python_worker_rebuilds_with_current_sdk() {
|
||||
build_guest_apps(&["pip"], "python-build-acceptance.json");
|
||||
}
|
||||
|
||||
fn build_guest_apps(ecosystems: &[&str], report: &str) {
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()
|
||||
.unwrap();
|
||||
let destination = workspace.join("target/arm64-cross/guest/apps");
|
||||
fs::create_dir_all(&destination).unwrap();
|
||||
for &ecosystem in ecosystems {
|
||||
assert!(
|
||||
!destination.join(ecosystem).exists(),
|
||||
"guest app already exists; preserve or remove the prior owned fixture explicitly"
|
||||
);
|
||||
}
|
||||
let author = TemporaryDirectory::new();
|
||||
let key = author_inputs(author.path(), &workspace);
|
||||
add_arm_inputs(author.path(), &workspace);
|
||||
add_target_sandbox(author.path(), &workspace);
|
||||
let machine = machine_temporary_directory(&workspace);
|
||||
let layout = Layout::new(machine.path());
|
||||
layout.initialize().unwrap();
|
||||
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
||||
let release = layout.downloads().join(version.as_str());
|
||||
packaging::create(author.path(), &release).unwrap();
|
||||
let runtimes = sign_target_runtimes(&release, &workspace, &key, ecosystems);
|
||||
install_release(&layout, &version, &key);
|
||||
fs::remove_dir_all(&release).unwrap();
|
||||
drop(author);
|
||||
let compiler = layout.versions().join(version.as_str()).join("dever-core");
|
||||
let mut daemon = daemon::TestDaemon::start(&layout);
|
||||
let projects = TemporaryDirectory::new();
|
||||
let mut programs = serde_json::Map::new();
|
||||
for &ecosystem in ecosystems {
|
||||
let project = projects.path().join(ecosystem);
|
||||
let contract = application(&project, ecosystem);
|
||||
let lock = prepare_locked_application(
|
||||
&project,
|
||||
&workspace,
|
||||
&layout,
|
||||
contract,
|
||||
&runtimes[ecosystem],
|
||||
);
|
||||
command(
|
||||
Command::new(&compiler)
|
||||
.args(["lib", "doctor"])
|
||||
.arg(&project)
|
||||
.args(["--target", TARGET.platform()]),
|
||||
);
|
||||
let output = destination.join(ecosystem);
|
||||
fs::create_dir(&output).unwrap();
|
||||
write(&output, "config/setting.json", "{}\n");
|
||||
let executable = output.join("program");
|
||||
build(&compiler, &project, &executable, TARGET);
|
||||
programs.insert(ecosystem.into(), json!({
|
||||
"path":format!("{ecosystem}/program"), "sha256":sha256_file(&executable).unwrap(),
|
||||
"bytes":fs::metadata(&executable).unwrap().len(), "runtime":runtimes[ecosystem].pack,
|
||||
"libs":lock.libs.iter().map(|lib| lib.spec.key()).collect::<Vec<_>>(),
|
||||
"artifacts":lock.libs.iter().flat_map(|lib| &lib.artifacts).collect::<Vec<_>>()
|
||||
}));
|
||||
}
|
||||
assert_eq!(
|
||||
cache_status(&layout).unwrap().entries,
|
||||
ecosystems.len() as u64
|
||||
);
|
||||
daemon.stop();
|
||||
drop(projects);
|
||||
drop(machine);
|
||||
write(&workspace, &format!("target/arm64-cross/{report}"), serde_json::to_vec_pretty(&json!({
|
||||
"format":"dever-cross-ecosystem-build-v1", "target":TARGET.platform(), "programs":programs,
|
||||
"checks":{"signed_runtime_install":true,"offline_lib_doctor":ecosystems.len(),"actual_cli_arm_build":ecosystems.len(),"source_and_build_machine_removed":true},
|
||||
"guest_probe":{"arguments":["sample.worker.probe","{}"],"expected_data":true},
|
||||
"guest_failure":{"arguments":["sample.worker.reject","{}"],"expected":"runtime fault; next fresh probe still succeeds; no remaining Worker"},
|
||||
"guest_execution":"not_run", "sandbox_execution":"requires full ARM kernel guest"
|
||||
})).unwrap());
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
//! Explicit signed-release acceptance with no host language trees in the final root.
|
||||
use super::*;
|
||||
use std::os::unix::fs::{MetadataExt, chown};
|
||||
|
||||
const ECOSYSTEMS: [&str; 3] = ["pip", "npm", "go"];
|
||||
const STANDALONE_UID: u32 = 65534;
|
||||
|
||||
fn nonroot_standalone(source: &Path, expected_program: &Value) -> Value {
|
||||
let deployment = TemporaryDirectory::new_in(Path::new("/tmp"));
|
||||
let program = deployment.path().join("program");
|
||||
let config = deployment.path().join("config");
|
||||
let settings = config.join("setting.json");
|
||||
fs::create_dir(&config).unwrap();
|
||||
fs::copy(source.join("program"), &program).unwrap();
|
||||
fs::copy(source.join("config/setting.json"), &settings).unwrap();
|
||||
let identity = json!({
|
||||
"bytes": fs::metadata(&program).unwrap().len(),
|
||||
"sha256": sha256_file(&program).unwrap(),
|
||||
});
|
||||
assert_eq!(&identity, expected_program);
|
||||
for (path, mode) in [
|
||||
(program.as_path(), 0o700),
|
||||
(config.as_path(), 0o700),
|
||||
(settings.as_path(), 0o600),
|
||||
(deployment.path(), 0o700),
|
||||
] {
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(mode)).unwrap();
|
||||
chown(path, Some(STANDALONE_UID), Some(STANDALONE_UID)).unwrap();
|
||||
}
|
||||
// Only the standalone executable and private config cross this boundary.
|
||||
// The actual non-root process must extract its own embedded Worker bundle.
|
||||
assert!(!deployment.path().join("data").exists());
|
||||
let output = command(
|
||||
Command::new("/usr/bin/setpriv")
|
||||
.arg(format!("--reuid={STANDALONE_UID}"))
|
||||
.arg(format!("--regid={STANDALONE_UID}"))
|
||||
.args([
|
||||
"--clear-groups",
|
||||
"--inh-caps=-all",
|
||||
"--ambient-caps=-all",
|
||||
"--",
|
||||
])
|
||||
.arg(&program)
|
||||
.args(["sample.worker.probe", "{}"])
|
||||
.current_dir(deployment.path())
|
||||
.stdin(Stdio::null()),
|
||||
);
|
||||
eprintln!(
|
||||
"standalone UID {STANDALONE_UID} {}: {}\n{}",
|
||||
source.display(),
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_response(output);
|
||||
assert_eq!(
|
||||
fs::metadata(deployment.path().join("data/cache/lib"))
|
||||
.unwrap()
|
||||
.uid(),
|
||||
STANDALONE_UID
|
||||
);
|
||||
json!({"passed": true, "uid": STANDALONE_UID, "program": identity})
|
||||
}
|
||||
|
||||
fn dependency_fixture(workspace: &Path, ecosystem: &str) -> PathBuf {
|
||||
workspace.join(match ecosystem {
|
||||
"pip" => "target/source-build",
|
||||
"npm" => "target/npm-build",
|
||||
_ => unreachable!(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The fixtures were built against these exact pack bytes. The test author
|
||||
/// signs those inputs, rather than rewriting receipts for a recompressed pack.
|
||||
fn sign_build_runtimes(
|
||||
release: &Path,
|
||||
workspace: &Path,
|
||||
key: &Ed25519KeyPair,
|
||||
) -> dever_cli::toolchain::ReleaseManifest {
|
||||
use dever_cli::libs::{LockFile, RegistryRuntime, RuntimePack};
|
||||
|
||||
let mut manifest: dever_cli::toolchain::ReleaseManifest =
|
||||
serde_json::from_slice(&fs::read(release.join("manifest.json")).unwrap()).unwrap();
|
||||
for ecosystem in ["pip", "npm"] {
|
||||
let fixture = dependency_fixture(workspace, ecosystem);
|
||||
let settings: Value =
|
||||
serde_json::from_slice(&fs::read(fixture.join("config/setting.json")).unwrap())
|
||||
.unwrap();
|
||||
let runtime: RegistryRuntime = serde_json::from_value(settings["runtime"].clone()).unwrap();
|
||||
assert_eq!(runtime.target, "linux-x86_64");
|
||||
let pack = fixture.join(settings["runtime_file"].as_str().unwrap());
|
||||
assert_eq!(sha256_file(&pack).unwrap(), runtime.pack.sha256);
|
||||
let lock =
|
||||
LockFile::decode(&fs::read(fixture.join("dependency/dever.lock")).unwrap()).unwrap();
|
||||
assert!(
|
||||
!lock.builds.is_empty(),
|
||||
"acceptance requires actual source build receipts"
|
||||
);
|
||||
for receipt in &lock.builds {
|
||||
assert_eq!(receipt.runtime, runtime.pack);
|
||||
if let Some(python) = &receipt.auxiliary_runtime {
|
||||
let python_config: Value = serde_json::from_slice(
|
||||
&fs::read(dependency_fixture(workspace, "pip").join("config/setting.json"))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
*python,
|
||||
serde_json::from_value::<RuntimePack>(python_config["runtime"]["pack"].clone())
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
let prefix = format!("runtime/{ecosystem}/linux-x86_64");
|
||||
fs::copy(pack, release.join(&prefix).join("runtime.pack")).unwrap();
|
||||
fs::write(
|
||||
release.join(&prefix).join("manifest.json"),
|
||||
serde_json::to_vec(&json!({"format":"dever-registry-runtime-v1","runtime":runtime}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
for suffix in ["runtime.pack", "manifest.json"] {
|
||||
let name = format!("{prefix}/{suffix}");
|
||||
let artifact = manifest
|
||||
.artifacts
|
||||
.iter_mut()
|
||||
.find(|artifact| artifact.path == name)
|
||||
.unwrap();
|
||||
artifact.bytes = fs::metadata(release.join(&name)).unwrap().len();
|
||||
artifact.sha256 = sha256_file(&release.join(&name)).unwrap();
|
||||
}
|
||||
}
|
||||
let encoded = serde_json::to_vec(&manifest).unwrap();
|
||||
fs::write(release.join("manifest.json"), &encoded).unwrap();
|
||||
fs::write(
|
||||
release.join("manifest.sig"),
|
||||
key.sign(&encoded)
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>(),
|
||||
)
|
||||
.unwrap();
|
||||
manifest
|
||||
}
|
||||
|
||||
fn import_built_dependencies(project: &Path, fixture: &Path, layout: &Layout) {
|
||||
use dever_cli::libs::{LockFile, LockedWorker, build};
|
||||
|
||||
let mut lock =
|
||||
LockFile::decode(&fs::read(fixture.join("dependency/dever.lock")).unwrap()).unwrap();
|
||||
for artifact in lock
|
||||
.libs
|
||||
.iter()
|
||||
.flat_map(|lib| &lib.artifacts)
|
||||
.chain(build::npm_outputs(&lock).map(|receipt| &receipt.output))
|
||||
{
|
||||
let bytes = fs::read(fixture.join("dependency/artifacts").join(&artifact.sha256)).unwrap();
|
||||
let stored = dever_cli::toolchain::artifact_put(layout, &bytes).unwrap();
|
||||
assert_eq!(stored.sha256, artifact.sha256);
|
||||
assert_eq!(stored.bytes, artifact.bytes);
|
||||
}
|
||||
let sources = dever_core::source::SourceMap::load(&project.join("module")).unwrap();
|
||||
let program = dever_core::check(&sources).unwrap_or_else(|errors| panic!("{errors:?}"));
|
||||
let contract = program.external_worker_contracts().remove(0);
|
||||
let runtime = lock.libs[0].runtime.clone();
|
||||
assert!(lock.libs.iter().all(|lib| lib.runtime == runtime));
|
||||
assert!(lock.workers.is_empty());
|
||||
lock.workers.push(LockedWorker {
|
||||
port: contract.port,
|
||||
adapter: contract.adapter,
|
||||
ecosystem: contract.ecosystem,
|
||||
runtime: Some(runtime),
|
||||
entry: contract.entry,
|
||||
schema: contract.schema,
|
||||
capabilities: contract.capabilities,
|
||||
operations: contract.operations,
|
||||
libs: contract
|
||||
.libs
|
||||
.iter()
|
||||
.map(|spec| spec.parse().unwrap())
|
||||
.collect(),
|
||||
});
|
||||
dever_cli::libs::doctor(&lock).unwrap();
|
||||
lock.write_atomic(project).unwrap();
|
||||
}
|
||||
|
||||
fn ecosystem_inputs(author: &Path, workspace: &Path) {
|
||||
let prepared = workspace.join("target/ecosystem-release-inputs/prepared");
|
||||
let fragment: Value =
|
||||
serde_json::from_slice(&fs::read(prepared.join("config/setting.json")).unwrap()).unwrap();
|
||||
for ecosystem in ECOSYSTEMS {
|
||||
for entry in fragment["runtimes"][ecosystem]["files"].as_array().unwrap() {
|
||||
let relative = entry["source"].as_str().unwrap();
|
||||
let output = author.join(relative);
|
||||
fs::create_dir_all(output.parent().unwrap()).unwrap();
|
||||
// Author staging only; the maker materializes its own immutable pack.
|
||||
fs::hard_link(prepared.join(relative), output).unwrap();
|
||||
}
|
||||
}
|
||||
let config = author.join("config/setting.json");
|
||||
let mut settings: Value = serde_json::from_slice(&fs::read(&config).unwrap()).unwrap();
|
||||
settings["runtimes"] = fragment["runtimes"].clone();
|
||||
fs::write(config, serde_json::to_vec(&settings).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
fn application(root: &Path, ecosystem: &str) {
|
||||
let (extension, source) = match ecosystem {
|
||||
"pip" => ("py", include_str!("../../ecosystem-release/probe.py")),
|
||||
"npm" => ("mjs", include_str!("../../ecosystem-release/probe.mjs")),
|
||||
"go" => ("go", include_str!("../../ecosystem-release/probe.go")),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
write(root, "config/setting.json", "{}\n");
|
||||
write(
|
||||
root,
|
||||
"module/sample/worker/port.dever",
|
||||
"probe() (okay: Bool) fails app.ProbeResult\n",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/sample/worker/app.dever",
|
||||
"type ProbeResult { error Unavailable(message: Text) }\nprobe() (okay: Bool) { okay = port.probe() }\n",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/sample/worker/api.dever",
|
||||
"cmd probe = app.probe\n",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/sample/worker/adapter.dever",
|
||||
format!(
|
||||
"external {ecosystem} \"worker/probe.{extension}\" {{\n{}\n}}\n",
|
||||
match ecosystem {
|
||||
"pip" => "lib \"simplejson@3.20.1\"",
|
||||
"npm" => "lib \"bufferutil@4.0.9\"",
|
||||
"go" => "",
|
||||
_ => unreachable!(),
|
||||
}
|
||||
),
|
||||
);
|
||||
let sources = dever_core::source::SourceMap::load(&root.join("module")).unwrap();
|
||||
let program = dever_core::check(&sources).unwrap_or_else(|errors| panic!("{errors:?}"));
|
||||
let entry = &program.external_worker_contracts()[0].entry;
|
||||
write(root, entry, source);
|
||||
}
|
||||
|
||||
pub(super) fn minimal_os(root: &Path) {
|
||||
// This root contains only the named Linux OS ABI libraries. No host language
|
||||
// executable, standard library, PATH, package manager or source tree is visible.
|
||||
for name in [
|
||||
"libc.so.6",
|
||||
"libdl.so.2",
|
||||
"libm.so.6",
|
||||
"libpthread.so.0",
|
||||
"librt.so.1",
|
||||
"libutil.so.1",
|
||||
"libresolv.so.2",
|
||||
"ld-linux-x86-64.so.2",
|
||||
] {
|
||||
let source = Path::new("/lib/x86_64-linux-gnu").join(name);
|
||||
let output = root.join("lib/x86_64-linux-gnu").join(name);
|
||||
fs::create_dir_all(output.parent().unwrap()).unwrap();
|
||||
fs::copy(source, output).unwrap();
|
||||
}
|
||||
fs::create_dir(root.join("lib64")).unwrap();
|
||||
fs::copy(
|
||||
"/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2",
|
||||
root.join("lib64/ld-linux-x86-64.so.2"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir(root.join("tmp")).unwrap();
|
||||
fs::create_dir(root.join("proc")).unwrap();
|
||||
fs::create_dir(root.join("dev")).unwrap();
|
||||
// The outer namespace supplies only its own proc and synthetic device tree.
|
||||
assert!(!root.join("usr").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared pinned ecosystem inputs and root for owned namespace; no existing services"]
|
||||
fn signed_ecosystems_build_and_run_without_system_language_installations() {
|
||||
assert_eq!(
|
||||
rustix::process::geteuid().as_raw(),
|
||||
0,
|
||||
"explicit no-host acceptance requires the owned root namespace fixture"
|
||||
);
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let projects = TemporaryDirectory::new();
|
||||
for ecosystem in ECOSYSTEMS {
|
||||
application(&projects.path().join(ecosystem), ecosystem);
|
||||
}
|
||||
let author = TemporaryDirectory::new();
|
||||
let key = author_inputs(author.path(), &workspace);
|
||||
ecosystem_inputs(author.path(), &workspace);
|
||||
let machine = machine_temporary_directory(&workspace);
|
||||
let layout = Layout::new(machine.path());
|
||||
layout.initialize().unwrap();
|
||||
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
||||
let release = layout.downloads().join(version.as_str());
|
||||
command_with_timeout(
|
||||
Command::new(workspace.join("target/debug/examples/native-release"))
|
||||
.arg(author.path())
|
||||
.arg("--output")
|
||||
.arg(&release),
|
||||
// Debug gzip compresses complete language distributions; this is author
|
||||
// preparation, independent of the service's bounded compile deadline.
|
||||
Duration::from_secs(900),
|
||||
);
|
||||
let signed = sign_build_runtimes(&release, &workspace, &key);
|
||||
let mut runtimes = serde_json::Map::new();
|
||||
for ecosystem in ECOSYSTEMS {
|
||||
let path = format!("runtime/{ecosystem}/linux-x86_64/runtime.pack");
|
||||
let artifact = signed
|
||||
.artifacts
|
||||
.iter()
|
||||
.find(|artifact| artifact.path == path)
|
||||
.unwrap();
|
||||
runtimes.insert(
|
||||
ecosystem.into(),
|
||||
json!({"bytes":artifact.bytes,"sha256":artifact.sha256}),
|
||||
);
|
||||
}
|
||||
install_release(&layout, &version, &key);
|
||||
fs::remove_dir_all(&release).unwrap();
|
||||
drop(author);
|
||||
let core = layout.versions().join(version.as_str()).join("dever-core");
|
||||
let mut daemon = daemon::TestDaemon::start(&layout);
|
||||
let isolated = TemporaryDirectory::new();
|
||||
minimal_os(isolated.path());
|
||||
let mut programs = serde_json::Map::new();
|
||||
for ecosystem in ECOSYSTEMS {
|
||||
let project = projects.path().join(ecosystem);
|
||||
if ecosystem == "go" {
|
||||
command(Command::new(&core).args(["lib", "add"]).arg(&project));
|
||||
} else {
|
||||
import_built_dependencies(
|
||||
&project,
|
||||
&dependency_fixture(&workspace, ecosystem),
|
||||
&layout,
|
||||
);
|
||||
}
|
||||
let lock =
|
||||
dever_cli::libs::LockFile::decode(&fs::read(project.join("dever.lock")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(lock.workers.len(), 1);
|
||||
assert_eq!(lock.libs.is_empty(), ecosystem == "go");
|
||||
assert_eq!(lock.builds.is_empty(), ecosystem == "go");
|
||||
assert_eq!(
|
||||
lock.workers[0].runtime.as_ref().unwrap().sha256,
|
||||
runtimes[ecosystem]["sha256"].as_str().unwrap()
|
||||
);
|
||||
command(Command::new(&core).arg("check").arg(&project));
|
||||
assert_response(command(
|
||||
Command::new(&core)
|
||||
.arg("run")
|
||||
.arg(&project)
|
||||
.args(["--", "sample.worker.probe", "{}"]),
|
||||
));
|
||||
let destination = isolated.path().join(ecosystem);
|
||||
fs::create_dir(&destination).unwrap();
|
||||
write(&destination, "config/setting.json", "{}\n");
|
||||
let executable = destination.join("program");
|
||||
command(
|
||||
Command::new(&core)
|
||||
.arg("build")
|
||||
.arg(&project)
|
||||
.arg("--output")
|
||||
.arg(&executable),
|
||||
);
|
||||
programs.insert(ecosystem.into(), json!({"bytes":fs::metadata(&executable).unwrap().len(),"sha256":sha256_file(&executable).unwrap()}));
|
||||
}
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 3);
|
||||
daemon.stop();
|
||||
drop(machine);
|
||||
drop(projects);
|
||||
for ecosystem in ECOSYSTEMS {
|
||||
assert_response(command(
|
||||
isolated_os_command(isolated.path())
|
||||
.arg(format!("/{ecosystem}/program"))
|
||||
.args(["sample.worker.probe", "{}"]),
|
||||
));
|
||||
}
|
||||
let mut standalone_nonroot = serde_json::Map::new();
|
||||
for ecosystem in ECOSYSTEMS {
|
||||
standalone_nonroot.insert(
|
||||
ecosystem.into(),
|
||||
nonroot_standalone(&isolated.path().join(ecosystem), &programs[ecosystem]),
|
||||
);
|
||||
}
|
||||
write(&workspace, "target/closure-sandbox-ecosystem-release-acceptance.json", serde_json::to_vec_pretty(&json!({
|
||||
"format":"dever-ecosystem-release-acceptance-v1", "platform":"linux-x86_64",
|
||||
"runtimes":runtimes,"programs":programs,"standalone_nonroot":standalone_nonroot,
|
||||
"checks":{"signed_install":true,"locked_runtimes":3,"managed_check_run_build":3,"standalone_isolated_root":3,"standalone_nonroot":3,"shared_compile_entries":3,"python_stdlib_paths_in_bundle":true,"os_sandbox":true,"offline_build_receipt_replay":true},
|
||||
"os_baseline":"explicit Linux GNU OS libraries and named device nodes inside an owned root",
|
||||
"runtime_pack_provenance":"test author signed the exact pip/npm packs bound to the completed source builds; maker determinism is verified separately",
|
||||
"third_party_packages":{"pip":"simplejson@3.20.1 _speedups C extension","npm":"bufferutil@4.0.9 build/Release addon"}, "other_platforms":"not_run", "public_release":"not_published"
|
||||
})).unwrap());
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
//! Synthetic runtime closures exercise the production maker without host interpreters.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn add_file(author: &mut Author, ecosystem: &str, path: &str, bytes: &[u8]) {
|
||||
let source = format!("inputs/{ecosystem}/{path}");
|
||||
let file = author.root.join(&source);
|
||||
fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
fs::write(&file, bytes).unwrap();
|
||||
author.settings["runtimes"][ecosystem]["files"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(json!({"source": source, "path": path, "sha256": sha256_file(&file).unwrap()}));
|
||||
}
|
||||
|
||||
fn set_manifest(author: &mut Author, ecosystem: &str, change: impl FnOnce(&mut Value)) {
|
||||
let input = author.settings["runtimes"][ecosystem]["files"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.iter_mut()
|
||||
.find(|input| input["path"] == "dever-runtime.json")
|
||||
.unwrap();
|
||||
let path = author.root.join(input["source"].as_str().unwrap());
|
||||
let mut manifest: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
change(&mut manifest);
|
||||
fs::write(&path, serde_json::to_vec(&manifest).unwrap()).unwrap();
|
||||
input["sha256"] = json!(sha256_file(&path).unwrap());
|
||||
}
|
||||
|
||||
fn runtime_author() -> Author {
|
||||
let mut author = Author::new();
|
||||
for (ecosystem, name, version) in [
|
||||
("pip", "cpython", "3.12.0"),
|
||||
("npm", "node", "24.0.0"),
|
||||
("go", "go", "1.25.0"),
|
||||
] {
|
||||
author.settings["runtimes"][ecosystem] = json!({"name":name,"version":version,"files":[]});
|
||||
let mut descriptor = json!({"format":"dever-worker-runtime-v1","ecosystem":ecosystem,"target":platform_identity()});
|
||||
if ecosystem == "go" {
|
||||
let arch = if std::env::consts::ARCH == "x86_64" {
|
||||
"amd64"
|
||||
} else {
|
||||
"arm64"
|
||||
};
|
||||
descriptor["build"] = json!({"host":platform_identity(),"compiler":"bin/compile","linker":"bin/link","analyzer":"bin/analyze","stdlib_importcfg":"stdlib/importcfg","goos":"linux","goarch":arch,"go_version":format!("go version go1.25.0 linux/{arch}")});
|
||||
for path in ["bin/compile", "bin/link", "bin/analyze"] {
|
||||
add_file(
|
||||
&mut author,
|
||||
ecosystem,
|
||||
path,
|
||||
&native_elf::static_executable(),
|
||||
);
|
||||
}
|
||||
let mut importcfg = String::new();
|
||||
for name in ["runtime", "context", "encoding/json"] {
|
||||
let path = format!("stdlib/{name}.a");
|
||||
let object = format!("go object linux {arch} go1.25.0 fixture\n");
|
||||
let archive = format!(
|
||||
"!<arch>\n{:<16}{:<12}{:<6}{:<6}{:<8}{:<10}`\n{object}",
|
||||
"__.PKGDEF",
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
"644",
|
||||
object.len()
|
||||
);
|
||||
add_file(&mut author, ecosystem, &path, archive.as_bytes());
|
||||
importcfg.push_str(&format!("packagefile {name}={path}\n"));
|
||||
}
|
||||
add_file(
|
||||
&mut author,
|
||||
ecosystem,
|
||||
"stdlib/importcfg",
|
||||
importcfg.as_bytes(),
|
||||
);
|
||||
} else {
|
||||
descriptor["executable"] = json!(format!("bin/{name}"));
|
||||
descriptor["arguments"] = if ecosystem == "pip" {
|
||||
json!(["-I", "-S", "-B"])
|
||||
} else {
|
||||
json!([])
|
||||
};
|
||||
add_file(
|
||||
&mut author,
|
||||
ecosystem,
|
||||
&format!("bin/{name}"),
|
||||
&native_elf::static_executable(),
|
||||
);
|
||||
}
|
||||
add_file(
|
||||
&mut author,
|
||||
ecosystem,
|
||||
"dever-runtime.json",
|
||||
&serde_json::to_vec(&descriptor).unwrap(),
|
||||
);
|
||||
}
|
||||
author.settings["runtimes"]["pip"]["python_markers"] = json!({
|
||||
"implementation_name":"cpython","implementation_version":"3.12.0","os_name":"posix",
|
||||
"platform_machine":std::env::consts::ARCH,"platform_python_implementation":"CPython",
|
||||
"platform_release":"","platform_system":"Linux","platform_version":"",
|
||||
"python_full_version":"3.12.0","python_version":"3.12","sys_platform":"linux"
|
||||
});
|
||||
author.settings["runtimes"]["pip"]["python_wheel_tags"] = json!(["py3-none-any"]);
|
||||
let markers = author.settings["runtimes"]["pip"]["python_markers"].clone();
|
||||
set_manifest(&mut author, "pip", |manifest| {
|
||||
manifest["python_markers"] = markers;
|
||||
manifest["python_wheel_tags"] = json!(["py3-none-any"]);
|
||||
manifest["python_extension_suffixes"] = json!([".abi3.so", ".so"]);
|
||||
});
|
||||
author
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ecosystem_archives_are_deterministic_and_signed_with_computed_identities() {
|
||||
let mut author = runtime_author();
|
||||
author.save();
|
||||
let first = author.output("first");
|
||||
let manifest = packaging::create(&author.root, &first).unwrap();
|
||||
// Input order and filesystem mode are not archive metadata.
|
||||
for ecosystem in ["pip", "npm", "go"] {
|
||||
author.settings["runtimes"][ecosystem]["files"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.reverse();
|
||||
}
|
||||
fs::set_permissions(
|
||||
author.root.join("inputs/npm/bin/node"),
|
||||
fs::Permissions::from_mode(0o700),
|
||||
)
|
||||
.unwrap();
|
||||
author.save();
|
||||
let second = author.output("second");
|
||||
assert_eq!(packaging::create(&author.root, &second).unwrap(), manifest);
|
||||
assert_eq!(contents(&first), contents(&second));
|
||||
assert_signed(&author, &first);
|
||||
for ecosystem in ["pip", "npm", "go"] {
|
||||
let prefix = format!("runtime/{ecosystem}/{}", platform_identity());
|
||||
let metadata_path = format!("{prefix}/manifest.json");
|
||||
let pack_path = format!("{prefix}/runtime.pack");
|
||||
let metadata: Value =
|
||||
serde_json::from_slice(&fs::read(first.join(&metadata_path)).unwrap()).unwrap();
|
||||
assert_eq!(metadata["format"], "dever-registry-runtime-v1");
|
||||
assert_eq!(
|
||||
metadata["runtime"]["pack"]["sha256"],
|
||||
sha256_file(&first.join(&pack_path)).unwrap()
|
||||
);
|
||||
for path in [&metadata_path, &pack_path] {
|
||||
let signed = manifest
|
||||
.artifacts
|
||||
.iter()
|
||||
.find(|artifact| &artifact.path == path)
|
||||
.unwrap();
|
||||
assert_eq!(signed.sha256, sha256_file(&first.join(path)).unwrap());
|
||||
}
|
||||
let bytes = fs::read(first.join(&pack_path)).unwrap();
|
||||
let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(bytes.as_slice()));
|
||||
let mut names = Vec::new();
|
||||
for entry in archive.entries().unwrap() {
|
||||
let mut entry = entry.unwrap();
|
||||
let path = entry.path().unwrap().to_str().unwrap().to_owned();
|
||||
assert_eq!(entry.header().mtime().unwrap(), 0);
|
||||
assert_eq!(entry.header().uid().unwrap(), 0);
|
||||
assert_eq!(entry.header().gid().unwrap(), 0);
|
||||
assert_eq!(
|
||||
entry.header().mode().unwrap(),
|
||||
if path.starts_with("bin/") {
|
||||
0o755
|
||||
} else {
|
||||
0o644
|
||||
}
|
||||
);
|
||||
let mut actual = Vec::new();
|
||||
entry.read_to_end(&mut actual).unwrap();
|
||||
assert_eq!(
|
||||
actual,
|
||||
fs::read(author.root.join(format!("inputs/{ecosystem}/{path}"))).unwrap()
|
||||
);
|
||||
names.push(path);
|
||||
}
|
||||
assert!(names.windows(2).all(|pair| pair[0] < pair[1]));
|
||||
}
|
||||
let before = contents(&first);
|
||||
fs::write(author.root.join("inputs/npm/bin/node"), b"changed").unwrap();
|
||||
assert_eq!(contents(&first), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_incomplete_or_mismatched_runtime_descriptors() {
|
||||
for ecosystem in ["pip", "npm", "go"] {
|
||||
for field in ["format", "ecosystem", "target"] {
|
||||
let mut author = runtime_author();
|
||||
set_manifest(&mut author, ecosystem, |manifest| {
|
||||
manifest[field] = json!("wrong")
|
||||
});
|
||||
assert!(author.reject().contains("identity"));
|
||||
}
|
||||
let mut author = runtime_author();
|
||||
set_manifest(&mut author, ecosystem, |manifest| {
|
||||
manifest["unknown"] = json!(true)
|
||||
});
|
||||
assert!(author.reject().contains("manifest"));
|
||||
let mut author = runtime_author();
|
||||
author.settings["runtimes"][ecosystem]["files"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.retain(|input| input["path"] != "dever-runtime.json");
|
||||
assert!(author.reject().contains("dever-runtime.json"));
|
||||
}
|
||||
for (ecosystem, field, value) in [
|
||||
("pip", "arguments", json!(["-I", "-B"])),
|
||||
("npm", "executable", json!("bin/missing")),
|
||||
("npm", "arguments", json!(["source.js"])),
|
||||
("go", "executable", json!("bin/compile")),
|
||||
("go", "build", Value::Null),
|
||||
] {
|
||||
let mut author = runtime_author();
|
||||
set_manifest(&mut author, ecosystem, |manifest| manifest[field] = value);
|
||||
author.reject();
|
||||
}
|
||||
for (field, value) in [
|
||||
("goarch", "unknown"),
|
||||
("compiler", "bin/missing"),
|
||||
("stdlib_importcfg", "missing"),
|
||||
] {
|
||||
let mut author = runtime_author();
|
||||
set_manifest(&mut author, "go", |manifest| {
|
||||
manifest["build"][field] = json!(value)
|
||||
});
|
||||
author.reject();
|
||||
}
|
||||
let mut author = runtime_author();
|
||||
author.settings["runtimes"]["go"]["files"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.retain(|input| input["path"] != "stdlib/context.a");
|
||||
assert!(author.reject().contains("archive"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_cross_pack_binds_host_tools_and_arm_standard_library_separately() {
|
||||
for wrong_stdlib in [false, true] {
|
||||
let mut author = runtime_author();
|
||||
set_manifest(&mut author, "go", |manifest| {
|
||||
manifest["target"] = json!("linux-aarch64");
|
||||
manifest["build"]["goarch"] = json!("arm64");
|
||||
});
|
||||
for input in author.settings["runtimes"]["go"]["files"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
{
|
||||
if !input["path"].as_str().unwrap().ends_with(".a") {
|
||||
continue;
|
||||
}
|
||||
let path = author.root.join(input["source"].as_str().unwrap());
|
||||
let architecture = if wrong_stdlib { "amd64" } else { "arm64" };
|
||||
let object = format!("go object linux {architecture} go1.25.0 fixture\n");
|
||||
let archive = format!(
|
||||
"!<arch>\n{:<16}{:<12}{:<6}{:<6}{:<8}{:<10}`\n{object}",
|
||||
"__.PKGDEF",
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
"644",
|
||||
object.len()
|
||||
);
|
||||
fs::write(&path, archive).unwrap();
|
||||
input["sha256"] = json!(sha256_file(&path).unwrap());
|
||||
}
|
||||
let input = author.settings["runtimes"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("go")
|
||||
.unwrap();
|
||||
author.settings["ecosystem_targets"] = json!({"linux-aarch64":{"go":input}});
|
||||
author.save();
|
||||
let result = packaging::create(&author.root, &author.output("cross-go"));
|
||||
if wrong_stdlib {
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("differs from its target or compiler version")
|
||||
);
|
||||
} else {
|
||||
let manifest = result.unwrap();
|
||||
assert!(
|
||||
manifest
|
||||
.artifacts
|
||||
.iter()
|
||||
.any(|file| file.path == "runtime/go/linux-aarch64/runtime.pack")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_cross_assets_preserve_loader_mode_and_validate_deployment_target() {
|
||||
let mut files = vec![
|
||||
("bin/bwrap".into(), native_elf::static_executable()),
|
||||
("bin/guard".into(), native_elf::static_executable()),
|
||||
(
|
||||
"lib/ld-linux-aarch64.so.1".into(),
|
||||
native_elf::library("ld-linux-aarch64.so.1", &[]),
|
||||
),
|
||||
(
|
||||
"lib/libc.so.6".into(),
|
||||
native_elf::library("libc.so.6", &[]),
|
||||
),
|
||||
];
|
||||
for (_, bytes) in &mut files {
|
||||
bytes[18..20].copy_from_slice(&183u16.to_le_bytes());
|
||||
}
|
||||
dever_sandbox::validate_assets_for_target(&files, "linux-aarch64").unwrap();
|
||||
let mut author = Author::new();
|
||||
let inputs = files
|
||||
.iter()
|
||||
.map(|(path, bytes)| {
|
||||
let source = format!("inputs/arm-sandbox/{path}");
|
||||
let file = author.root.join(&source);
|
||||
fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
fs::write(&file, bytes).unwrap();
|
||||
fs::set_permissions(&file, fs::Permissions::from_mode(0o600)).unwrap();
|
||||
json!({"source":source,"path":path,"sha256":sha256_file(&file).unwrap()})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
author.settings["sandbox_targets"] = json!({"linux-aarch64":inputs});
|
||||
author.save();
|
||||
let output = author.output("cross-sandbox");
|
||||
packaging::create(&author.root, &output).unwrap();
|
||||
for (path, mode) in [
|
||||
("bin/bwrap", 0o755),
|
||||
("bin/guard", 0o755),
|
||||
("lib/ld-linux-aarch64.so.1", 0o755),
|
||||
("lib/libc.so.6", 0o644),
|
||||
] {
|
||||
assert_eq!(
|
||||
fs::metadata(output.join("sandbox/linux-aarch64").join(path))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
mode,
|
||||
"incorrect packaged mode for {path}"
|
||||
);
|
||||
}
|
||||
files[1].1[18..20].copy_from_slice(&62u16.to_le_bytes());
|
||||
assert!(
|
||||
dever_sandbox::validate_assets_for_target(&files, "linux-aarch64")
|
||||
.unwrap_err()
|
||||
.contains("incompatible target")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_registry_metadata_and_unknown_ecosystems() {
|
||||
for (ecosystem, field, value) in [
|
||||
("pip", "python_markers", Value::Null),
|
||||
("pip", "python_wheel_tags", json!([])),
|
||||
("pip", "python_wheel_tags", json!(["invalid"])),
|
||||
(
|
||||
"pip",
|
||||
"python_wheel_tags",
|
||||
json!(["py3-none-any", "py3-none-any"]),
|
||||
),
|
||||
("npm", "python_wheel_tags", json!(["py3-none-any"])),
|
||||
("npm", "name", json!("")),
|
||||
("npm", "name", json!("../node")),
|
||||
("go", "version", json!("latest")),
|
||||
("go", "unknown", json!(true)),
|
||||
] {
|
||||
let mut author = runtime_author();
|
||||
author.settings["runtimes"][ecosystem][field] = value;
|
||||
author.reject();
|
||||
}
|
||||
let mut author = runtime_author();
|
||||
author.settings["runtimes"]["npm"]["python_markers"] =
|
||||
author.settings["runtimes"]["pip"]["python_markers"].clone();
|
||||
author.reject();
|
||||
let mut author = runtime_author();
|
||||
author.settings["runtimes"]["exec"] = author.settings["runtimes"]["npm"].clone();
|
||||
author.reject();
|
||||
|
||||
let author = runtime_author();
|
||||
author.save();
|
||||
let path = author.root.join("config/setting.json");
|
||||
let config = fs::read_to_string(&path).unwrap();
|
||||
let duplicate = config.replacen("\"runtimes\":{", "\"runtimes\":{\"npm\":null,", 1);
|
||||
assert_ne!(duplicate, config);
|
||||
fs::write(path, duplicate).unwrap();
|
||||
assert!(
|
||||
packaging::create(&author.root, &author.output("duplicate"))
|
||||
.unwrap_err()
|
||||
.contains("invalid author configuration")
|
||||
);
|
||||
assert_eq!(fs::read_dir(author.directory.path()).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_runtime_path_conflicts_drift_and_private_key_aliases() {
|
||||
for path in [
|
||||
"../outside",
|
||||
"bin",
|
||||
"bin/node/child",
|
||||
"runtime.pack",
|
||||
"bin/node",
|
||||
] {
|
||||
let mut author = runtime_author();
|
||||
let mut input = author.settings["runtimes"]["npm"]["files"][0].clone();
|
||||
input["path"] = json!(path);
|
||||
author.settings["runtimes"]["npm"]["files"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(input);
|
||||
author.reject();
|
||||
}
|
||||
let author = runtime_author();
|
||||
fs::write(author.root.join("inputs/npm/bin/node"), b"changed").unwrap();
|
||||
assert!(author.reject().contains("SHA-256"));
|
||||
let mut author = self::runtime_author();
|
||||
let alias = author.root.join("inputs/key-alias");
|
||||
fs::hard_link(author.root.join("signing.pk8"), &alias).unwrap();
|
||||
author.settings["runtimes"]["npm"]["files"].as_array_mut().unwrap().push(json!({"source":"inputs/key-alias","path":"secrets/key","sha256":sha256_file(&alias).unwrap()}));
|
||||
assert!(author.reject().contains("cannot be included"));
|
||||
let mut author = self::runtime_author();
|
||||
symlink("node", author.root.join("inputs/npm/bin/link")).unwrap();
|
||||
author.settings["runtimes"]["npm"]["files"][0]["source"] = json!("inputs/npm/bin/link");
|
||||
author.reject();
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
use super::*;
|
||||
use dever_cli::toolchain::runtime_pack::RuntimePack;
|
||||
use dever_runtime::config::RuntimeProfile;
|
||||
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
fn cross_author() -> Author {
|
||||
let mut author = Author::new();
|
||||
let target = BuildTarget::LinuxAarch64;
|
||||
let input = |name: &str, bytes: Vec<u8>| {
|
||||
let source = format!("inputs/arm-{name}");
|
||||
fs::write(author.root.join(&source), bytes).unwrap();
|
||||
json!({"source":source, "sha256":sha256_file(&author.root.join(source)).unwrap()})
|
||||
};
|
||||
let mut profiles = json!({});
|
||||
for profile in ["base", "sqlite", "postgres", "both"] {
|
||||
profiles[profile] = input(profile, native_objects::archive(target, profile));
|
||||
}
|
||||
let link = |name: &str, bytes| {
|
||||
let mut artifact = input(name, bytes);
|
||||
artifact["path"] = json!(name);
|
||||
artifact
|
||||
};
|
||||
author.settings["native_targets"] = json!({"linux-aarch64": {
|
||||
"profiles":profiles,
|
||||
"start":[link("start.o", native_objects::object(target))],
|
||||
"libraries":[link("system.a", native_objects::archive(target, "system.o"))],
|
||||
"end":[link("end.o", native_objects::object(target))],
|
||||
}});
|
||||
author
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_names_and_serde_are_exact() {
|
||||
for (name, target, triple) in [
|
||||
(
|
||||
"linux-x86_64",
|
||||
BuildTarget::LinuxX86_64,
|
||||
"x86_64-unknown-linux-gnu",
|
||||
),
|
||||
(
|
||||
"linux-aarch64",
|
||||
BuildTarget::LinuxAarch64,
|
||||
"aarch64-unknown-linux-gnu",
|
||||
),
|
||||
] {
|
||||
assert_eq!(name.parse::<BuildTarget>().unwrap(), target);
|
||||
assert_eq!(target.platform(), name);
|
||||
assert_eq!(target.triple(), triple);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&target).unwrap(),
|
||||
format!("\"{name}\"")
|
||||
);
|
||||
}
|
||||
for invalid in [
|
||||
"",
|
||||
"arm64",
|
||||
"Linux-aarch64",
|
||||
"linux-arm64",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"linux-aarch64 ",
|
||||
] {
|
||||
assert!(invalid.parse::<BuildTarget>().is_err());
|
||||
assert!(serde_json::from_value::<BuildTarget>(json!(invalid)).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
fn host_core_packages_both_native_targets_with_separate_profiles_and_signed_closures() {
|
||||
let author = cross_author();
|
||||
author.save();
|
||||
let output = author.directory.path().join("cross-release");
|
||||
let manifest = packaging::create(&author.root, &output).unwrap();
|
||||
assert_eq!(manifest.platform, "linux-x86_64");
|
||||
assert_signed(&author, &output);
|
||||
for target in [BuildTarget::LinuxX86_64, BuildTarget::LinuxAarch64] {
|
||||
let closure = runtime_pack::validate(&output, env!("CARGO_PKG_VERSION"), target).unwrap();
|
||||
for input in closure {
|
||||
assert!(
|
||||
manifest
|
||||
.artifacts
|
||||
.iter()
|
||||
.any(|artifact| artifact.path == input.path
|
||||
&& artifact.sha256 == input.sha256
|
||||
&& artifact.bytes == input.bytes)
|
||||
);
|
||||
}
|
||||
for profile in [
|
||||
RuntimeProfile {
|
||||
sqlite: false,
|
||||
postgres: false,
|
||||
},
|
||||
RuntimeProfile {
|
||||
sqlite: true,
|
||||
postgres: false,
|
||||
},
|
||||
RuntimeProfile {
|
||||
sqlite: false,
|
||||
postgres: true,
|
||||
},
|
||||
RuntimeProfile {
|
||||
sqlite: true,
|
||||
postgres: true,
|
||||
},
|
||||
] {
|
||||
assert_eq!(
|
||||
RuntimePack::load(&output, profile, target).unwrap().target,
|
||||
target
|
||||
);
|
||||
}
|
||||
}
|
||||
let profile = RuntimeProfile {
|
||||
sqlite: false,
|
||||
postgres: false,
|
||||
};
|
||||
let arm = RuntimePack::load(&output, profile, BuildTarget::LinuxAarch64).unwrap();
|
||||
let host = RuntimePack::load(&output, profile, BuildTarget::LinuxX86_64).unwrap();
|
||||
assert_ne!(arm.identity, host.identity);
|
||||
fs::write(
|
||||
output.join("runtime/linux-aarch64/archives/base.a"),
|
||||
b"tampered",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(RuntimePack::load(&output, profile, BuildTarget::LinuxAarch64).is_err());
|
||||
RuntimePack::load(&output, profile, BuildTarget::LinuxX86_64).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
fn target_packs_reject_wrong_architecture_and_duplicate_host_declarations() {
|
||||
for (slot, source) in [
|
||||
("/native_targets/linux-aarch64/start/0", "start.o"),
|
||||
("/native_targets/linux-aarch64/profiles/base", "base"),
|
||||
] {
|
||||
let mut author = cross_author();
|
||||
let replacement = if source.ends_with(".o") {
|
||||
native_objects::object(BuildTarget::LinuxX86_64)
|
||||
} else {
|
||||
native_objects::archive(BuildTarget::LinuxX86_64, "wrong.o")
|
||||
};
|
||||
fs::write(
|
||||
author.root.join(format!("inputs/arm-{source}")),
|
||||
replacement,
|
||||
)
|
||||
.unwrap();
|
||||
author.settings.pointer_mut(slot).unwrap()["sha256"] =
|
||||
json!(sha256_file(&author.root.join(format!("inputs/arm-{source}"))).unwrap());
|
||||
assert!(
|
||||
author
|
||||
.reject()
|
||||
.contains("relocatable ELF object for linux-aarch64")
|
||||
);
|
||||
}
|
||||
let mut author = cross_author();
|
||||
author.settings["native_targets"]["linux-x86_64"] =
|
||||
author.settings["native_targets"]["linux-aarch64"].clone();
|
||||
assert!(author.reject().contains("duplicate release destination"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
fn missing_target_pack_never_uses_host_inputs() {
|
||||
let author = Author::new();
|
||||
author.save();
|
||||
let output = author.directory.path().join("host-release");
|
||||
packaging::create(&author.root, &output).unwrap();
|
||||
let error = RuntimePack::load(
|
||||
&output,
|
||||
RuntimeProfile {
|
||||
sqlite: false,
|
||||
postgres: false,
|
||||
},
|
||||
BuildTarget::LinuxAarch64,
|
||||
)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(error.contains("linux-aarch64"), "{error}");
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use dever_core::source::SourceMap;
|
||||
|
||||
static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct Workspace(PathBuf);
|
||||
|
||||
impl Workspace {
|
||||
fn new() -> Self {
|
||||
let sequence = NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed);
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("dever-new-test-{}-{sequence}", std::process::id()));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Workspace {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_formats_create_checked_formatted_projects_with_real_application_tests() {
|
||||
let workspace = Workspace::new();
|
||||
for markdown in [false, true] {
|
||||
let root = workspace
|
||||
.0
|
||||
.join(if markdown { "markdown" } else { "plain" });
|
||||
dever_cli::project::create(&root, markdown).unwrap();
|
||||
let sources = SourceMap::load_project(&root.join("module"), &root.join("test")).unwrap();
|
||||
let result = dever_core::check(&sources);
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
assert_eq!(sources.files().len(), 3);
|
||||
for source in sources.files() {
|
||||
let formatted = dever_core::format::format(source).unwrap();
|
||||
assert_eq!(formatted, source.text(), "{}", source.path().display());
|
||||
}
|
||||
let settings: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(root.join("config/setting.json")).unwrap()).unwrap();
|
||||
assert_eq!(settings["log"]["level"], "info");
|
||||
assert!(root.join("AGENTS.md").is_file());
|
||||
assert!(!root.join("module/main.dever").exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_directories_files_and_symlinks_are_preserved() {
|
||||
let workspace = Workspace::new();
|
||||
let directory = workspace.0.join("existing");
|
||||
fs::create_dir(&directory).unwrap();
|
||||
assert!(dever_cli::project::create(&directory, false).is_err());
|
||||
assert_eq!(fs::read_dir(&directory).unwrap().count(), 0);
|
||||
let file = workspace.0.join("file");
|
||||
fs::write(&file, "keep me").unwrap();
|
||||
assert!(dever_cli::project::create(&file, false).is_err());
|
||||
assert_eq!(fs::read_to_string(&file).unwrap(), "keep me");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let link = workspace.0.join("link");
|
||||
std::os::unix::fs::symlink(workspace.0.join("absent"), &link).unwrap();
|
||||
assert!(dever_cli::project::create(&link, true).is_err());
|
||||
assert!(fs::symlink_metadata(link).unwrap().is_symlink());
|
||||
}
|
||||
assert!(fs::read_dir(&workspace.0).unwrap().all(|entry| {
|
||||
!entry
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with(".dever-new-")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_creation_publishes_one_complete_project_without_staging_leftovers() {
|
||||
let workspace = Workspace::new();
|
||||
let root = workspace.0.join("project");
|
||||
let barrier = std::sync::Barrier::new(2);
|
||||
let results = std::thread::scope(|scope| {
|
||||
let workers: Vec<_> = [false, true]
|
||||
.into_iter()
|
||||
.map(|markdown| {
|
||||
let root = &root;
|
||||
let barrier = &barrier;
|
||||
scope.spawn(move || {
|
||||
barrier.wait();
|
||||
dever_cli::project::create(root, markdown)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
workers
|
||||
.into_iter()
|
||||
.map(|worker| worker.join().unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
|
||||
let sources = SourceMap::load_project(&root.join("module"), &root.join("test")).unwrap();
|
||||
assert!(dever_core::check(&sources).is_ok());
|
||||
assert_eq!(sources.files().len(), 3);
|
||||
assert_eq!(fs::read_dir(&workspace.0).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_creates_before_reading_project_configuration_and_rejects_bad_arguments() {
|
||||
let workspace = Workspace::new();
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.env_clear()
|
||||
.current_dir(&workspace.0)
|
||||
.args(["new", "app", "--markdown"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
workspace
|
||||
.0
|
||||
.join("app/module/hello/greeting/api.dever.md")
|
||||
.is_file()
|
||||
);
|
||||
for arguments in [
|
||||
vec!["new"],
|
||||
vec!["new", "bad", "--unknown"],
|
||||
vec!["new", "--markdown"],
|
||||
] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.env_clear()
|
||||
.current_dir(&workspace.0)
|
||||
.args(arguments)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
}
|
||||
assert!(!workspace.0.join("bad").exists());
|
||||
}
|
||||
@@ -0,0 +1,961 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
#[cfg(unix)]
|
||||
use std::io::Read;
|
||||
use std::io::{Cursor, Write};
|
||||
#[cfg(unix)]
|
||||
use std::path::Path;
|
||||
#[cfg(unix)]
|
||||
use std::process::Command;
|
||||
|
||||
use dever_cli::libs::{ArtifactStore, FixtureArtifactStore, LockFile};
|
||||
#[cfg(unix)]
|
||||
use dever_cli::libs::{LockedWorker, RuntimePack};
|
||||
#[cfg(unix)]
|
||||
use dever_cli::packages::LockedPackage;
|
||||
use dever_cli::packages::{
|
||||
PackageTransport, execute_with, owned_files_with, owned_worker_bytes_with, owns_worker,
|
||||
sources_with,
|
||||
};
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
use dever_cli::toolchain::cache_status;
|
||||
#[cfg(unix)]
|
||||
use dever_cli::toolchain::{
|
||||
Artifact, Layout, MachineManager, ReleaseManifest, Version, artifact_get, artifact_put,
|
||||
};
|
||||
#[cfg(unix)]
|
||||
use dever_cli::workers;
|
||||
#[cfg(unix)]
|
||||
use dever_core::native::EmbeddedResource;
|
||||
#[cfg(unix)]
|
||||
use ring::rand::SystemRandom;
|
||||
#[cfg(unix)]
|
||||
use ring::signature::{Ed25519KeyPair, KeyPair};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[path = "../dever-tests/tests/support/temp.rs"]
|
||||
mod temp;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[path = "support/daemon.rs"]
|
||||
mod daemon;
|
||||
#[cfg(unix)]
|
||||
use daemon::TestDaemon;
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
#[path = "support/llvm_pack.rs"]
|
||||
mod llvm_pack;
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
#[path = "packages/managed_compile.rs"]
|
||||
mod managed_compile;
|
||||
|
||||
use temp::TemporaryDirectory;
|
||||
|
||||
struct Registry(BTreeMap<String, Vec<u8>>);
|
||||
|
||||
impl PackageTransport for Registry {
|
||||
fn get(&self, path: &str, limit: usize) -> Result<Vec<u8>, String> {
|
||||
let bytes = self
|
||||
.0
|
||||
.get(path)
|
||||
.ok_or_else(|| format!("missing registry path {path}"))?;
|
||||
if bytes.len() > limit {
|
||||
return Err("registry response exceeds limit".into());
|
||||
}
|
||||
Ok(bytes.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn archive(
|
||||
name: &str,
|
||||
version: &str,
|
||||
dependencies: serde_json::Value,
|
||||
libs: &[&str],
|
||||
source: &str,
|
||||
) -> Vec<u8> {
|
||||
archive_with_sources(name, version, dependencies, libs, source, &[])
|
||||
}
|
||||
|
||||
fn archive_with_sources(
|
||||
name: &str,
|
||||
version: &str,
|
||||
dependencies: serde_json::Value,
|
||||
libs: &[&str],
|
||||
source: &str,
|
||||
additional_sources: &[(&str, &str)],
|
||||
) -> Vec<u8> {
|
||||
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (path, bytes) in [
|
||||
(
|
||||
"dever-package.json".to_owned(),
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"format": "dever-package-v1", "name": name, "version": version,
|
||||
"dependencies": dependencies, "lib": libs,
|
||||
}))
|
||||
.unwrap(),
|
||||
),
|
||||
(
|
||||
format!("module/{name}/value/app.dever"),
|
||||
source.as_bytes().to_vec(),
|
||||
),
|
||||
] {
|
||||
writer
|
||||
.start_file(path, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
writer.write_all(&bytes).unwrap();
|
||||
}
|
||||
for (path, source) in additional_sources {
|
||||
writer
|
||||
.start_file(*path, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
writer.write_all(source.as_bytes()).unwrap();
|
||||
}
|
||||
writer.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn digest(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
fn registry(archives: &[(&str, &str, Vec<u8>)]) -> Registry {
|
||||
let mut paths = BTreeMap::new();
|
||||
let mut versions = BTreeMap::<String, Vec<serde_json::Value>>::new();
|
||||
for (name, version, bytes) in archives {
|
||||
versions
|
||||
.entry((*name).into())
|
||||
.or_default()
|
||||
.push(serde_json::json!({
|
||||
"version": version, "sha256": digest(bytes), "bytes": bytes.len(),
|
||||
}));
|
||||
paths.insert(format!("/v1/packages/{name}/{version}.zip"), bytes.clone());
|
||||
}
|
||||
for (name, versions) in versions {
|
||||
paths.insert(
|
||||
format!("/v1/packages/{name}/index.json"),
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"format": "dever-package-index-v1", "name": name, "versions": versions,
|
||||
}))
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
Registry(paths)
|
||||
}
|
||||
|
||||
fn project() -> TemporaryDirectory {
|
||||
let root = TemporaryDirectory::new();
|
||||
fs::create_dir_all(root.path().join("config")).unwrap();
|
||||
fs::create_dir_all(root.path().join("module/portal/value")).unwrap();
|
||||
fs::write(
|
||||
root.path().join("config/setting.json"),
|
||||
r#"{"package":{"registry":"https://packages.example.test","use":[]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.path().join("module/portal/value/app.dever"),
|
||||
"value() (result: Text) { result = storefront.value.value() }\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.path().join("module/portal/value/api.dever"),
|
||||
"cmd show = app.value\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(root.path().join("test/portal/value")).unwrap();
|
||||
fs::write(
|
||||
root.path().join("test/portal/value/smoke.dever"),
|
||||
"smoke() () { assert_eq(portal.value.value(), \"two\") }\n",
|
||||
)
|
||||
.unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_add_resolves_transitive_versions_and_feeds_the_common_lib_lock() {
|
||||
let root = project();
|
||||
let registry = registry(&[
|
||||
(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
archive(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
serde_json::json!({"catalog":"^1.0.0"}),
|
||||
&[],
|
||||
"value() (result: Text) { result = catalog.value.value() }\n",
|
||||
),
|
||||
),
|
||||
(
|
||||
"catalog",
|
||||
"1.0.0",
|
||||
archive(
|
||||
"catalog",
|
||||
"1.0.0",
|
||||
serde_json::json!({}),
|
||||
&["pip:fixture@1.0.0"],
|
||||
"value() (result: Text) { result = \"one\" }\n",
|
||||
),
|
||||
),
|
||||
(
|
||||
"catalog",
|
||||
"1.2.0",
|
||||
archive(
|
||||
"catalog",
|
||||
"1.2.0",
|
||||
serde_json::json!({}),
|
||||
&["pip:fixture@1.0.0"],
|
||||
"value() (result: Text) { result = \"two\" }\n",
|
||||
),
|
||||
),
|
||||
(
|
||||
"catalog",
|
||||
"2.0.0",
|
||||
archive(
|
||||
"catalog",
|
||||
"2.0.0",
|
||||
serde_json::json!({}),
|
||||
&[],
|
||||
"value() (result: Text) { result = \"wrong\" }\n",
|
||||
),
|
||||
),
|
||||
]);
|
||||
let store = FixtureArtifactStore::default();
|
||||
execute_with(
|
||||
"add",
|
||||
root.path(),
|
||||
&["storefront@^1.0.0".into()],
|
||||
®istry,
|
||||
&store,
|
||||
)
|
||||
.unwrap();
|
||||
let lock = LockFile::decode(&fs::read(root.path().join("dever.lock")).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
lock.packages
|
||||
.iter()
|
||||
.map(|package| (package.name.as_str(), package.version.as_str()))
|
||||
.collect::<Vec<_>>(),
|
||||
[("catalog", "1.2.0"), ("storefront", "1.0.0")]
|
||||
);
|
||||
assert_eq!(lock.libs.len(), 1);
|
||||
assert_eq!(lock.libs[0].spec.key(), "pip:fixture@1.0.0");
|
||||
assert!(owns_worker(root.path(), "worker/catalog/tool").unwrap());
|
||||
assert!(owns_worker(root.path(), "module/catalog/value/worker/main.py").unwrap());
|
||||
assert!(!owns_worker(root.path(), "module/portal/value/worker/main.py").unwrap());
|
||||
let files = owned_files_with(root.path(), &store).unwrap();
|
||||
assert!(String::from_utf8_lossy(&files["module/catalog/value/app.dever"]).contains("two"));
|
||||
assert!(execute_with("doctor", root.path(), &[], ®istry, &store).is_ok());
|
||||
|
||||
let mut damaged = lock.clone();
|
||||
damaged.packages[0].manifest_sha256 = "0".repeat(64);
|
||||
damaged.write_atomic(root.path()).unwrap();
|
||||
assert!(
|
||||
owned_files_with(root.path(), &store)
|
||||
.unwrap_err()
|
||||
.contains("manifest changed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_resolution_rejects_conflicts_without_changing_project_configuration() {
|
||||
let root = project();
|
||||
let before = fs::read(root.path().join("config/setting.json")).unwrap();
|
||||
let registry = registry(&[
|
||||
(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
archive(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
serde_json::json!({"catalog":"^2.0.0"}),
|
||||
&[],
|
||||
"value() (result: Text) { result = catalog.value.value() }\n",
|
||||
),
|
||||
),
|
||||
(
|
||||
"catalog",
|
||||
"1.0.0",
|
||||
archive(
|
||||
"catalog",
|
||||
"1.0.0",
|
||||
serde_json::json!({}),
|
||||
&[],
|
||||
"value() (result: Text) { result = \"one\" }\n",
|
||||
),
|
||||
),
|
||||
]);
|
||||
let store = FixtureArtifactStore::default();
|
||||
let error = execute_with(
|
||||
"add",
|
||||
root.path(),
|
||||
&["storefront@1.0.0".into()],
|
||||
®istry,
|
||||
&store,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.contains("no Package version satisfies"), "{error}");
|
||||
assert_eq!(
|
||||
fs::read(root.path().join("config/setting.json")).unwrap(),
|
||||
before
|
||||
);
|
||||
assert!(!root.path().join("dever.lock").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_archive_rejects_paths_owned_by_another_component() {
|
||||
let root = project();
|
||||
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (path, bytes) in [
|
||||
(
|
||||
"dever-package.json",
|
||||
r#"{"format":"dever-package-v1","name":"storefront","version":"1.0.0"}"#,
|
||||
),
|
||||
(
|
||||
"module/portal/value/app.dever",
|
||||
"value() (result: Text) { result = \"bad\" }",
|
||||
),
|
||||
] {
|
||||
writer
|
||||
.start_file(path, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
writer.write_all(bytes.as_bytes()).unwrap();
|
||||
}
|
||||
let registry = registry(&[("storefront", "1.0.0", writer.finish().unwrap().into_inner())]);
|
||||
let store = FixtureArtifactStore::default();
|
||||
let error = execute_with(
|
||||
"add",
|
||||
root.path(),
|
||||
&["storefront@1.0.0".into()],
|
||||
®istry,
|
||||
&store,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.contains("unowned path"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_rejects_local_component_even_when_file_paths_do_not_overlap() {
|
||||
let root = project();
|
||||
fs::create_dir_all(root.path().join("module/storefront/other")).unwrap();
|
||||
fs::write(
|
||||
root.path().join("module/storefront/other/app.dever"),
|
||||
"value() (result: Text) { result = \"local\" }\n",
|
||||
)
|
||||
.unwrap();
|
||||
let registry = registry(&[(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
archive(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
serde_json::json!({}),
|
||||
&[],
|
||||
"value() (result: Text) { result = \"package\" }\n",
|
||||
),
|
||||
)]);
|
||||
let store = FixtureArtifactStore::default();
|
||||
let error = execute_with(
|
||||
"add",
|
||||
root.path(),
|
||||
&["storefront@1.0.0".into()],
|
||||
®istry,
|
||||
&store,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.contains("collides with a local component"), "{error}");
|
||||
assert!(!root.path().join("dever.lock").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_only_project_loads_without_a_local_module_directory() {
|
||||
let root = TemporaryDirectory::new();
|
||||
fs::create_dir(root.path().join("config")).unwrap();
|
||||
fs::write(
|
||||
root.path().join("config/setting.json"),
|
||||
r#"{"package":{"registry":"https://packages.example.test","use":[]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let registry = registry(&[(
|
||||
"catalog",
|
||||
"1.0.0",
|
||||
archive(
|
||||
"catalog",
|
||||
"1.0.0",
|
||||
serde_json::json!({}),
|
||||
&[],
|
||||
"value() (result: Text) { result = \"ready\" }\n",
|
||||
),
|
||||
)]);
|
||||
let store = FixtureArtifactStore::default();
|
||||
execute_with(
|
||||
"add",
|
||||
root.path(),
|
||||
&["catalog@1.0.0".into()],
|
||||
®istry,
|
||||
&store,
|
||||
)
|
||||
.unwrap();
|
||||
let packages = sources_with(root.path(), &store).unwrap();
|
||||
let sources = dever_core::source::SourceMap::load_with_packages(
|
||||
&root.path().join("module"),
|
||||
None,
|
||||
&packages,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(sources.files().len(), 1);
|
||||
dever_core::check(&sources).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_package_worker_never_falls_back_to_a_local_file() {
|
||||
let root = project();
|
||||
let registry = registry(&[(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
archive(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
serde_json::json!({}),
|
||||
&[],
|
||||
"value() (result: Text) { result = \"two\" }\n",
|
||||
),
|
||||
)]);
|
||||
let store = FixtureArtifactStore::default();
|
||||
execute_with(
|
||||
"add",
|
||||
root.path(),
|
||||
&["storefront@1.0.0".into()],
|
||||
®istry,
|
||||
&store,
|
||||
)
|
||||
.unwrap();
|
||||
let local = root.path().join("worker/storefront/tool");
|
||||
fs::create_dir_all(local.parent().unwrap()).unwrap();
|
||||
fs::write(&local, b"local fallback must never execute").unwrap();
|
||||
assert!(owns_worker(root.path(), "worker/storefront/tool").unwrap());
|
||||
assert!(owns_worker(root.path(), "module/storefront/value/worker/main.py").unwrap());
|
||||
assert!(!owns_worker(root.path(), "worker/portal/tool").unwrap());
|
||||
let error = owned_worker_bytes_with(root.path(), "worker/storefront/tool", &store).unwrap_err();
|
||||
assert!(error.contains("missing from its locked archive"), "{error}");
|
||||
assert_eq!(
|
||||
owned_worker_bytes_with(root.path(), "worker/portal/tool", &store).unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn managed_package_worker_requires_its_locked_entry_and_rejects_local_collision() {
|
||||
let root = project();
|
||||
let (layout, executable) = signed_core(root.path(), &std::env::current_exe().unwrap(), false);
|
||||
let mut daemon = TestDaemon::start(&layout);
|
||||
let package_archive = archive_with_sources(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
serde_json::json!({}),
|
||||
&[],
|
||||
"type SendResult { error Unavailable(message: Text) }\nvalue() (result: Text) { result = \"two\" }\nsend(value: Int) (receipt: Int) { receipt = port.send(value) }\n",
|
||||
&[
|
||||
(
|
||||
"module/storefront/value/port.dever",
|
||||
"send(value: Int) (receipt: Int) fails app.SendResult\n",
|
||||
),
|
||||
(
|
||||
"module/storefront/value/adapter.dever",
|
||||
"external pip \"worker/main.py\" { }\n",
|
||||
),
|
||||
],
|
||||
);
|
||||
fs::write(
|
||||
root.path().join("config/setting.json"),
|
||||
r#"{"package":{"registry":"https://packages.example.test","use":["storefront@1.0.0"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let store = ServiceStore(layout.clone());
|
||||
let package_sha256 = store.publish(&package_archive, "package").unwrap();
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(&package_archive)).unwrap();
|
||||
let mut manifest = Vec::new();
|
||||
zip.by_name("dever-package.json")
|
||||
.unwrap()
|
||||
.read_to_end(&mut manifest)
|
||||
.unwrap();
|
||||
let mut lock = LockFile::new(Vec::new()).unwrap();
|
||||
lock.packages.push(LockedPackage {
|
||||
name: "storefront".into(),
|
||||
version: "1.0.0".into(),
|
||||
sha256: package_sha256,
|
||||
bytes: package_archive.len() as u64,
|
||||
dependencies: Vec::new(),
|
||||
manifest_sha256: digest(&manifest),
|
||||
});
|
||||
lock.write_atomic(root.path()).unwrap();
|
||||
|
||||
let package_sources = dever_cli::packages::sources_with(root.path(), &store).unwrap();
|
||||
let sources = dever_core::source::SourceMap::load_with_packages(
|
||||
&root.path().join("module"),
|
||||
None,
|
||||
&package_sources,
|
||||
)
|
||||
.unwrap();
|
||||
let program = dever_core::check(&sources).unwrap();
|
||||
let contract = program.external_worker_contracts().remove(0);
|
||||
assert_eq!(contract.entry, "module/storefront/value/worker/main.py");
|
||||
assert!(owns_worker(root.path(), &contract.entry).unwrap());
|
||||
|
||||
let target = dever_cli::toolchain::platform_identity();
|
||||
let metadata = serde_json::to_vec(&serde_json::json!({
|
||||
"format": "dever-worker-runtime-v1", "ecosystem": "pip", "target": target,
|
||||
"executable": "bin/python3", "arguments": ["-I", "-S", "-B"],
|
||||
"python_wheel_tags": ["py3-none-any"],
|
||||
"python_extension_suffixes": [".cpython-312-x86_64-linux-gnu.so", ".abi3.so", ".so"],
|
||||
"python_markers": {
|
||||
"implementation_name":"cpython", "implementation_version":"3.12.0", "os_name":"posix",
|
||||
"platform_machine":"x86_64", "platform_python_implementation":"CPython", "platform_release":"",
|
||||
"platform_system":"Linux", "platform_version":"", "python_full_version":"3.12.0",
|
||||
"python_version":"3.12", "sys_platform":"linux"
|
||||
},
|
||||
}))
|
||||
.unwrap();
|
||||
let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::none());
|
||||
let mut tar = tar::Builder::new(encoder);
|
||||
for (path, bytes) in [
|
||||
("dever-runtime.json", metadata.as_slice()),
|
||||
("bin/python3", b"fixture-interpreter".as_slice()),
|
||||
] {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_mode(0o755);
|
||||
header.set_cksum();
|
||||
tar.append_data(&mut header, path, bytes).unwrap();
|
||||
}
|
||||
let runtime_bytes = tar.into_inner().unwrap().finish().unwrap();
|
||||
let runtime_sha256 = digest(&runtime_bytes);
|
||||
let runtime = RuntimePack {
|
||||
name: "fixture-pip".into(),
|
||||
version: "1".into(),
|
||||
sha256: runtime_sha256.clone(),
|
||||
};
|
||||
lock.workers.push(LockedWorker {
|
||||
port: contract.port,
|
||||
adapter: contract.adapter,
|
||||
ecosystem: contract.ecosystem,
|
||||
runtime: Some(runtime),
|
||||
entry: contract.entry.clone(),
|
||||
schema: contract.schema,
|
||||
capabilities: contract.capabilities,
|
||||
operations: contract.operations,
|
||||
libs: Vec::new(),
|
||||
});
|
||||
lock.write_atomic(root.path()).unwrap();
|
||||
fs::write(root.path().join("test-runtime.pack"), runtime_bytes).unwrap();
|
||||
let output = Command::new(executable)
|
||||
.args([
|
||||
"--exact",
|
||||
"installed_managed_package_worker_guard",
|
||||
"--ignored",
|
||||
"--nocapture",
|
||||
])
|
||||
.current_dir(root.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"signed child test failed: {}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
daemon.stop();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
#[ignore = "run only from the signed private fixture"]
|
||||
fn installed_managed_package_worker_guard() {
|
||||
let root = std::env::current_dir().unwrap();
|
||||
let package_sources = dever_cli::packages::sources(&root).unwrap();
|
||||
let sources = dever_core::source::SourceMap::load_with_packages(
|
||||
&root.join("module"),
|
||||
None,
|
||||
&package_sources,
|
||||
)
|
||||
.unwrap();
|
||||
let program = dever_core::check(&sources).unwrap();
|
||||
let entry = &program.external_worker_contracts()[0].entry;
|
||||
assert_eq!(entry, "module/storefront/value/worker/main.py");
|
||||
assert!(owns_worker(&root, entry).unwrap());
|
||||
let runtime_bytes = fs::read(root.join("test-runtime.pack")).unwrap();
|
||||
let resources = [EmbeddedResource {
|
||||
path: format!(
|
||||
"lib/runtime/pip/{}/runtime.pack",
|
||||
dever_cli::toolchain::platform_identity()
|
||||
),
|
||||
sha256: digest(&runtime_bytes),
|
||||
bytes: runtime_bytes,
|
||||
executable: false,
|
||||
}];
|
||||
let error = workers::prepare(&root, &program, &resources).unwrap_err();
|
||||
assert!(
|
||||
error.contains("locked Package Worker entry") && error.contains("missing"),
|
||||
"{error}"
|
||||
);
|
||||
|
||||
let local = root.join(entry);
|
||||
fs::create_dir_all(local.parent().unwrap()).unwrap();
|
||||
fs::write(local, b"local fallback must never execute").unwrap();
|
||||
let error = workers::prepare(&root, &program, &resources).unwrap_err();
|
||||
assert!(error.contains("collides with a local component"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_package_worker_requires_a_lock_before_local_fallback() {
|
||||
let root = project();
|
||||
fs::write(
|
||||
root.path().join("config/setting.json"),
|
||||
r#"{"package":{"registry":"https://packages.example.test","use":["storefront@1.0.0"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let error = owns_worker(root.path(), "worker/storefront/tool").unwrap_err();
|
||||
assert!(error.contains("dever.lock"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_package_keeps_locked_versions_without_registry_requests() {
|
||||
let root = project();
|
||||
let registry = registry(&[
|
||||
(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
archive(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
serde_json::json!({"catalog":"^1.0.0"}),
|
||||
&[],
|
||||
"value() (result: Text) { result = catalog.value.value() }\n",
|
||||
),
|
||||
),
|
||||
(
|
||||
"catalog",
|
||||
"1.2.0",
|
||||
archive(
|
||||
"catalog",
|
||||
"1.2.0",
|
||||
serde_json::json!({}),
|
||||
&["pip:fixture@1.0.0"],
|
||||
"value() (result: Text) { result = \"two\" }\n",
|
||||
),
|
||||
),
|
||||
]);
|
||||
let store = FixtureArtifactStore::default();
|
||||
execute_with(
|
||||
"add",
|
||||
root.path(),
|
||||
&["storefront@1.0.0".into()],
|
||||
®istry,
|
||||
&store,
|
||||
)
|
||||
.unwrap();
|
||||
execute_with(
|
||||
"add",
|
||||
root.path(),
|
||||
&["catalog@^1.0.0".into()],
|
||||
®istry,
|
||||
&store,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.path().join("module/portal/value/app.dever"),
|
||||
"value() (result: Text) { result = catalog.value.value() }\n",
|
||||
)
|
||||
.unwrap();
|
||||
let offline = Registry(BTreeMap::new());
|
||||
execute_with(
|
||||
"remove",
|
||||
root.path(),
|
||||
&["storefront".into()],
|
||||
&offline,
|
||||
&store,
|
||||
)
|
||||
.unwrap();
|
||||
let lock = LockFile::decode(&fs::read(root.path().join("dever.lock")).unwrap()).unwrap();
|
||||
assert_eq!(lock.packages.len(), 1);
|
||||
assert_eq!(
|
||||
(
|
||||
lock.packages[0].name.as_str(),
|
||||
lock.packages[0].version.as_str()
|
||||
),
|
||||
("catalog", "1.2.0")
|
||||
);
|
||||
assert_eq!(lock.libs[0].spec.key(), "pip:fixture@1.0.0");
|
||||
assert!(
|
||||
!String::from_utf8_lossy(&fs::read(root.path().join("config/setting.json")).unwrap())
|
||||
.contains("storefront@")
|
||||
);
|
||||
fs::write(
|
||||
root.path().join("module/portal/value/app.dever"),
|
||||
"value() (result: Text) { result = \"two\" }\n",
|
||||
)
|
||||
.unwrap();
|
||||
execute_with("remove", root.path(), &["catalog".into()], &offline, &store).unwrap();
|
||||
let settings: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(root.path().join("config/setting.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
settings["package"]["registry"],
|
||||
"https://packages.example.test"
|
||||
);
|
||||
assert_eq!(settings["package"]["use"], serde_json::json!([]));
|
||||
execute_with(
|
||||
"add",
|
||||
root.path(),
|
||||
&["storefront@1.0.0".into()],
|
||||
®istry,
|
||||
&store,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
LockFile::decode(&fs::read(root.path().join("dever.lock")).unwrap())
|
||||
.unwrap()
|
||||
.packages
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn package_update_restores_original_setting_bytes_when_lock_write_fails() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = project();
|
||||
let registry = registry(&[(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
archive(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
serde_json::json!({}),
|
||||
&[],
|
||||
"value() (result: Text) { result = \"two\" }\n",
|
||||
),
|
||||
)]);
|
||||
let store = FixtureArtifactStore::default();
|
||||
execute_with(
|
||||
"add",
|
||||
root.path(),
|
||||
&["storefront@1.0.0".into()],
|
||||
®istry,
|
||||
&store,
|
||||
)
|
||||
.unwrap();
|
||||
let settings = fs::read(root.path().join("config/setting.json")).unwrap();
|
||||
let lock_path = root.path().join("dever.lock");
|
||||
let saved = root.path().join("saved.lock");
|
||||
fs::rename(&lock_path, &saved).unwrap();
|
||||
symlink(&saved, &lock_path).unwrap();
|
||||
let error = execute_with("update", root.path(), &[], ®istry, &store).unwrap_err();
|
||||
assert!(error.contains("symbolic link"), "{error}");
|
||||
assert_eq!(
|
||||
fs::read(root.path().join("config/setting.json")).unwrap(),
|
||||
settings
|
||||
);
|
||||
assert_eq!(fs::read(&saved).unwrap(), fs::read(&lock_path).unwrap());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
struct ServiceStore(Layout);
|
||||
|
||||
#[cfg(unix)]
|
||||
impl ArtifactStore for ServiceStore {
|
||||
fn has(&self, _: &str, _: &str) -> Result<bool, String> {
|
||||
Err("exact length required".into())
|
||||
}
|
||||
fn publish(&self, bytes: &[u8], _: &str) -> Result<String, String> {
|
||||
Ok(artifact_put(&self.0, bytes)?.sha256)
|
||||
}
|
||||
fn get(&self, _: &str, _: &str) -> Result<Option<Vec<u8>>, String> {
|
||||
Err("exact length required".into())
|
||||
}
|
||||
fn get_exact(&self, sha256: &str, bytes: u64, _: &str) -> Result<Option<Vec<u8>>, String> {
|
||||
artifact_get(&self.0, sha256, bytes).map(Some)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
fn installed_dever(root: &Path) -> (Layout, std::path::PathBuf) {
|
||||
signed_core(root, Path::new(env!("CARGO_BIN_EXE_dever")), true)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn signed_core(
|
||||
root: &Path,
|
||||
source_executable: &Path,
|
||||
health_checked_install: bool,
|
||||
) -> (Layout, std::path::PathBuf) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let layout = Layout::new(root.join("machine"));
|
||||
layout.initialize().unwrap();
|
||||
let key_bytes = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
|
||||
let key = Ed25519KeyPair::from_pkcs8(key_bytes.as_ref()).unwrap();
|
||||
fs::write(
|
||||
layout.state().join("trusted-release-key"),
|
||||
format!("{}\n", hex(key.public_key().as_ref())),
|
||||
)
|
||||
.unwrap();
|
||||
let release = if health_checked_install {
|
||||
layout.downloads().join("0.1.0")
|
||||
} else {
|
||||
layout.versions().join("0.1.0")
|
||||
};
|
||||
fs::create_dir(&release).unwrap();
|
||||
let core = release.join("dever-core");
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
if health_checked_install {
|
||||
llvm_pack::compiler(source_executable, &release, "dever-core");
|
||||
} else {
|
||||
fs::copy(source_executable, &core).unwrap();
|
||||
}
|
||||
#[cfg(not(all(target_os = "linux", target_arch = "x86_64")))]
|
||||
fs::copy(source_executable, &core).unwrap();
|
||||
fs::set_permissions(&core, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let bytes = fs::read(&core).unwrap();
|
||||
let artifacts = vec![Artifact {
|
||||
path: "dever-core".into(),
|
||||
bytes: bytes.len() as u64,
|
||||
sha256: digest(&bytes),
|
||||
}];
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
let artifacts = if health_checked_install {
|
||||
artifacts
|
||||
.into_iter()
|
||||
.chain(native_release_inputs(&release))
|
||||
.collect()
|
||||
} else {
|
||||
artifacts
|
||||
};
|
||||
let manifest = ReleaseManifest::new(Version::parse("0.1.0").unwrap(), artifacts);
|
||||
let manifest_bytes = serde_json::to_vec(&manifest).unwrap();
|
||||
fs::write(release.join("manifest.json"), &manifest_bytes).unwrap();
|
||||
fs::write(
|
||||
release.join("manifest.sig"),
|
||||
format!("{}\n", hex(key.sign(&manifest_bytes).as_ref())),
|
||||
)
|
||||
.unwrap();
|
||||
let manager = MachineManager::new(layout.clone());
|
||||
if health_checked_install {
|
||||
manager.install_requested("0.1.0").unwrap();
|
||||
} else {
|
||||
manager
|
||||
.resolve_core(&Version::parse("0.1.0").unwrap())
|
||||
.unwrap();
|
||||
}
|
||||
(layout.clone(), layout.versions().join("0.1.0/dever-core"))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Explicit author inputs included in an ephemeral signature; these are not
|
||||
/// the four trimmed runtime profiles of a formal distribution.
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
fn native_release_inputs(root: &Path) -> Vec<Artifact> {
|
||||
let document = llvm_pack::manifest(root, true);
|
||||
let prefix = format!("runtime/{}", dever_cli::toolchain::platform_identity());
|
||||
let paths = document["files"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|file| format!("{prefix}/{}", file["path"].as_str().unwrap()))
|
||||
.chain([format!("{prefix}/manifest.json")])
|
||||
.chain(
|
||||
fs::read_dir(root.join("lib"))
|
||||
.unwrap()
|
||||
.map(|entry| format!("lib/{}", entry.unwrap().file_name().to_str().unwrap())),
|
||||
);
|
||||
paths
|
||||
.map(|path| {
|
||||
let file = root.join(&path);
|
||||
Artifact {
|
||||
path,
|
||||
bytes: fs::metadata(&file).unwrap().len(),
|
||||
sha256: dever_cli::toolchain::sha256_file(&file).unwrap(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
fn assert_cli_success(executable: &Path, arguments: &[&str]) -> std::process::Output {
|
||||
let output = Command::new(executable)
|
||||
.env_clear()
|
||||
.args(arguments)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{} exited {}\nstdout: {}\nstderr: {}",
|
||||
arguments.join(" "),
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
#[test]
|
||||
#[ignore = "requires explicitly prepared native runtime archive and owned daemon"]
|
||||
fn installed_package_passes_check_test_run_build_and_standalone_execution() {
|
||||
let root = project();
|
||||
let (layout, executable) = installed_dever(root.path());
|
||||
let mut daemon = TestDaemon::start(&layout);
|
||||
let registry = registry(&[
|
||||
(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
archive(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
serde_json::json!({"catalog":"1.2.0"}),
|
||||
&[],
|
||||
"value() (result: Text) { result = catalog.value.value() }\n",
|
||||
),
|
||||
),
|
||||
(
|
||||
"catalog",
|
||||
"1.2.0",
|
||||
archive(
|
||||
"catalog",
|
||||
"1.2.0",
|
||||
serde_json::json!({}),
|
||||
&[],
|
||||
"value() (result: Text) { result = \"two\" }\n",
|
||||
),
|
||||
),
|
||||
]);
|
||||
execute_with(
|
||||
"add",
|
||||
root.path(),
|
||||
&["storefront@1.0.0".into()],
|
||||
®istry,
|
||||
&ServiceStore(layout.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let project = root.path().to_str().unwrap();
|
||||
assert_cli_success(&executable, &["check", project]);
|
||||
assert_cli_success(&executable, &["test", project]);
|
||||
let run = assert_cli_success(
|
||||
&executable,
|
||||
&["run", project, "--", "portal.value.show", "{}"],
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&run.stdout).contains("two"));
|
||||
let output = root.path().join("standalone");
|
||||
assert_cli_success(
|
||||
&executable,
|
||||
&["build", project, "--output", output.to_str().unwrap()],
|
||||
);
|
||||
daemon.stop();
|
||||
let standalone = assert_cli_success(&output, &["portal.value.show", "{}"]);
|
||||
assert!(String::from_utf8_lossy(&standalone.stdout).contains("two"));
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
//! Actual managed compilation, using ephemeral signing keys and an owned daemon.
|
||||
use super::*;
|
||||
use dever_cli::toolchain::compilation::{CompileKind, CompileRequest};
|
||||
use dever_cli::toolchain::{BuildTarget, CacheStore, compile};
|
||||
use dever_core::source::SourceMap;
|
||||
use dever_runtime::config::Settings;
|
||||
use std::io::Read;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn source_project() -> TemporaryDirectory {
|
||||
let root = project();
|
||||
fs::write(
|
||||
root.path().join("module/portal/value/app.dever"),
|
||||
"value() (result: Text) { result = \"two\" }\n",
|
||||
)
|
||||
.unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
fn request(root: &Path, kind: CompileKind) -> CompileRequest {
|
||||
let sources = SourceMap::load_with_packages(
|
||||
&root.join("module"),
|
||||
if kind == CompileKind::TestSuite {
|
||||
Some(root.join("test"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.as_deref(),
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let bindings = (kind == CompileKind::Application)
|
||||
.then(|| Settings::load_project(root).unwrap().compilation_bindings());
|
||||
CompileRequest::new(
|
||||
"0.1.0".into(),
|
||||
kind,
|
||||
&sources,
|
||||
bindings,
|
||||
&[],
|
||||
BuildTarget::host().unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compilation_snapshot_excludes_secrets_and_rejects_ambiguous_or_unsafe_inputs() {
|
||||
let root = source_project();
|
||||
fs::write(root.path().join("config/setting.json"), serde_json::to_vec(&serde_json::json!({
|
||||
"database":{"default":{"type":"sqlite","path":"private-database-marker.sqlite"}},
|
||||
"auth":{"providers":{"session":{"verify":"user.account.verify",
|
||||
"jwtSecret":"private-jwt-marker-long-enough-for-hmac-2026","ttlSeconds":3600,"cookie":"secret-cookie-name"}}},
|
||||
"sites":{"admin":{"path":"admin","auth":"session","origin":"https://private-origin.example"}}
|
||||
})).unwrap()).unwrap();
|
||||
let original = request(root.path(), CompileKind::Application);
|
||||
let encoded = original.encode().unwrap();
|
||||
let text = std::str::from_utf8(&encoded).unwrap();
|
||||
for secret in [
|
||||
"private-database-marker",
|
||||
"private-jwt-marker",
|
||||
"secret-cookie-name",
|
||||
"private-origin",
|
||||
root.path().to_str().unwrap(),
|
||||
] {
|
||||
assert!(!text.contains(secret), "compile input leaked {secret}");
|
||||
}
|
||||
assert!(text.contains("user.account.verify"));
|
||||
assert!(text.contains("sqlite"));
|
||||
CompileRequest::decode(&encoded).unwrap();
|
||||
let duplicate = text.replace("\"databases\":", "\"databases\":{},\"databases\":");
|
||||
assert!(CompileRequest::decode(duplicate.as_bytes()).is_err());
|
||||
for path in ["/tmp/read-me.dever", "../main.dever", "portal/value/app.rs"] {
|
||||
let mut invalid = original.clone();
|
||||
invalid.sources[0].logical_path = path.into();
|
||||
assert!(invalid.encode().is_err(), "accepted source path {path}");
|
||||
}
|
||||
let mut reserved = original.clone();
|
||||
reserved.sources[0].logical_path = "dever/auth/app.dever".into();
|
||||
assert!(
|
||||
dever_core::check(&reserved.source_map().unwrap()).is_err(),
|
||||
"reserved standard namespace bypassed checking"
|
||||
);
|
||||
let mut invalid = original.clone();
|
||||
invalid.sources.push(invalid.sources[0].clone());
|
||||
assert!(invalid.encode().is_err());
|
||||
let mut invalid = original;
|
||||
invalid.sources[0].is_test = true;
|
||||
assert!(invalid.encode().is_err());
|
||||
let suite = request(root.path(), CompileKind::TestSuite);
|
||||
assert!(suite.bindings.is_none());
|
||||
assert!(
|
||||
suite
|
||||
.source_map()
|
||||
.unwrap()
|
||||
.files()
|
||||
.iter()
|
||||
.any(|source| source.is_test())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compilation_target_is_required_canonical_and_part_of_cache_identity() {
|
||||
let root = source_project();
|
||||
let mut input = request(root.path(), CompileKind::Application);
|
||||
input.target = BuildTarget::LinuxX86_64;
|
||||
let host = input.encode().unwrap();
|
||||
input.target = BuildTarget::LinuxAarch64;
|
||||
let arm = input.encode().unwrap();
|
||||
assert_ne!(host, arm);
|
||||
assert_eq!(
|
||||
CompileRequest::decode(&arm).unwrap().target,
|
||||
BuildTarget::LinuxAarch64
|
||||
);
|
||||
let text = std::str::from_utf8(&arm).unwrap();
|
||||
for invalid in [
|
||||
text.replace("\"target\":\"linux-aarch64\",", ""),
|
||||
text.replace("linux-aarch64", "aarch64-unknown-linux-gnu"),
|
||||
text.replace("\"target\":", "\"target\":\"linux-x86_64\",\"target\":"),
|
||||
] {
|
||||
assert!(CompileRequest::decode(invalid.as_bytes()).is_err());
|
||||
}
|
||||
let mut suite = request(root.path(), CompileKind::TestSuite);
|
||||
suite.target = if BuildTarget::host().unwrap() == BuildTarget::LinuxX86_64 {
|
||||
BuildTarget::LinuxAarch64
|
||||
} else {
|
||||
BuildTarget::LinuxX86_64
|
||||
};
|
||||
assert!(suite.encode().unwrap_err().contains("host target"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicitly prepared native archive; starts an owned daemon"]
|
||||
fn trusted_compilation_cache_requires_sources_and_revalidates_signed_pack() {
|
||||
let root = source_project();
|
||||
let (layout, _) = installed_dever(root.path());
|
||||
let _daemon = TestDaemon::start(&layout);
|
||||
let input = request(root.path(), CompileKind::Application);
|
||||
let started = Instant::now();
|
||||
let first = compile(&layout, &input).unwrap();
|
||||
let cold_seconds = started.elapsed().as_secs_f64();
|
||||
let initial_cache = cache_status(&layout).unwrap();
|
||||
assert_eq!(initial_cache.entries, 1);
|
||||
let started = Instant::now();
|
||||
assert_eq!(compile(&layout, &input).unwrap(), first);
|
||||
let hit_seconds = started.elapsed().as_secs_f64();
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 1);
|
||||
assert!(artifact_get(&layout, &digest(&first), first.len() as u64).is_err());
|
||||
let mut invalid = input.clone();
|
||||
invalid
|
||||
.sources
|
||||
.iter_mut()
|
||||
.find(|source| source.logical_path.ends_with("app.dever"))
|
||||
.unwrap()
|
||||
.text = "value() (result: Text) { result = missing_function() }".into();
|
||||
let error = compile(&layout, &invalid).unwrap_err();
|
||||
assert!(error.contains("missing_function"), "{error}");
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 1);
|
||||
assert_eq!(compile(&layout, &input).unwrap(), first);
|
||||
let mut changed = input.clone();
|
||||
changed
|
||||
.sources
|
||||
.iter_mut()
|
||||
.find(|source| source.logical_path.ends_with("app.dever"))
|
||||
.unwrap()
|
||||
.text = "value() (result: Text) { result = \"three\" }".into();
|
||||
// 两个独立客户端同时提交同一份新源码,必须收敛到一个已验证产物。
|
||||
let barrier = std::sync::Barrier::new(2);
|
||||
let concurrent = std::thread::scope(|scope| {
|
||||
let clients: Vec<_> = (0..2)
|
||||
.map(|_| {
|
||||
scope.spawn(|| {
|
||||
barrier.wait();
|
||||
let started = Instant::now();
|
||||
let bytes = compile(&layout, &changed).unwrap();
|
||||
(bytes, started.elapsed().as_secs_f64())
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
clients
|
||||
.into_iter()
|
||||
.map(|client| client.join().unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
assert_eq!(concurrent[0].0, concurrent[1].0);
|
||||
assert_ne!(concurrent[0].0, first);
|
||||
let shared_cache = cache_status(&layout).unwrap();
|
||||
assert_eq!(shared_cache.entries, 2);
|
||||
|
||||
// The worker compiles the suite; only the caller runs its failing case.
|
||||
fs::write(
|
||||
root.path().join("test/portal/value/smoke.dever"),
|
||||
"smoke() () { assert_eq(1, 2) }",
|
||||
)
|
||||
.unwrap();
|
||||
let suite = compile(&layout, &request(root.path(), CompileKind::TestSuite)).unwrap();
|
||||
let program = dever_core::native::NativeProgram::from_verified_bytes(&suite).unwrap();
|
||||
assert!(
|
||||
!Command::new(program.executable())
|
||||
.arg("0")
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap()
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 3);
|
||||
|
||||
// A valid signature over an incomplete list must not authorize unsigned
|
||||
// link inputs, even when the native manifest still names those inputs.
|
||||
let version_root = layout.versions().join("0.1.0");
|
||||
let trusted_key = fs::read(layout.state().join("trusted-release-key")).unwrap();
|
||||
let signed_manifest = fs::read(version_root.join("manifest.json")).unwrap();
|
||||
let signature = fs::read(version_root.join("manifest.sig")).unwrap();
|
||||
let mut manifest: ReleaseManifest = serde_json::from_slice(&signed_manifest).unwrap();
|
||||
manifest
|
||||
.artifacts
|
||||
.retain(|artifact| !artifact.path.ends_with("/crt1.o"));
|
||||
let key_bytes = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
|
||||
let key = Ed25519KeyPair::from_pkcs8(key_bytes.as_ref()).unwrap();
|
||||
let bytes = serde_json::to_vec(&manifest).unwrap();
|
||||
fs::write(
|
||||
layout.state().join("trusted-release-key"),
|
||||
hex(key.public_key().as_ref()),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(version_root.join("manifest.json"), &bytes).unwrap();
|
||||
fs::write(
|
||||
version_root.join("manifest.sig"),
|
||||
hex(key.sign(&bytes).as_ref()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
compile(&layout, &input)
|
||||
.unwrap_err()
|
||||
.contains("not covered by the signed release")
|
||||
);
|
||||
fs::write(layout.state().join("trusted-release-key"), trusted_key).unwrap();
|
||||
fs::write(version_root.join("manifest.json"), signed_manifest).unwrap();
|
||||
fs::write(version_root.join("manifest.sig"), signature).unwrap();
|
||||
|
||||
let pack = layout.versions().join("0.1.0/runtime/linux-x86_64/crt1.o");
|
||||
fs::write(pack, b"changed signed link input").unwrap();
|
||||
let error = compile(&layout, &input).unwrap_err();
|
||||
assert!(error.contains("integrity"), "{error}");
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 3);
|
||||
assert_eq!(
|
||||
fs::read_dir(layout.native_cache().join("staging"))
|
||||
.unwrap()
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_dir(layout.native_cache().join("operations"))
|
||||
.unwrap()
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
let report = serde_json::json!({
|
||||
"format": "dever-shared-compile-measurement-v1",
|
||||
"scope": "owned signed compiler and daemon; client wall time includes verification and IPC; warm OS caches",
|
||||
"cold_seconds": cold_seconds,
|
||||
"cache_hit_seconds": hit_seconds,
|
||||
"artifact_bytes": first.len(),
|
||||
"artifact_sha256": digest(&first),
|
||||
"initial_cache": initial_cache,
|
||||
"concurrent_clients": concurrent.len(),
|
||||
"concurrent_miss_seconds": concurrent.iter().map(|(_, seconds)| seconds).collect::<Vec<_>>(),
|
||||
"concurrent_same_artifact": true,
|
||||
"shared_cache": shared_cache,
|
||||
});
|
||||
fs::write(
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../target/closure-shared-compile-measurement.json"),
|
||||
serde_json::to_vec_pretty(&report).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires root and prepared archive; only owned clients change UID"]
|
||||
fn different_machine_users_share_compilation_without_private_cache() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::os::unix::process::CommandExt;
|
||||
assert_eq!(rustix::process::geteuid().as_raw(), 0);
|
||||
let root = source_project();
|
||||
let other = source_project();
|
||||
// Only these owned fixtures are shared; the usual temporary root is 0700.
|
||||
fs::set_permissions(root.path(), fs::Permissions::from_mode(0o755)).unwrap();
|
||||
fs::set_permissions(other.path(), fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let (layout, core) = installed_dever(root.path());
|
||||
let _daemon = TestDaemon::start(&layout);
|
||||
assert_cli_success(
|
||||
&core,
|
||||
&[
|
||||
"run",
|
||||
root.path().to_str().unwrap(),
|
||||
"--",
|
||||
"portal.value.show",
|
||||
"{}",
|
||||
],
|
||||
);
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 1);
|
||||
let result = Command::new(&core)
|
||||
.uid(65534)
|
||||
.gid(65534)
|
||||
.env_clear()
|
||||
.args([
|
||||
"run",
|
||||
other.path().to_str().unwrap(),
|
||||
"--",
|
||||
"portal.value.show",
|
||||
"{}",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&result.stdout).contains("two"));
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 1);
|
||||
assert!(!core.parent().unwrap().join("cache").exists());
|
||||
assert!(!other.path().join("cache").exists());
|
||||
}
|
||||
|
||||
fn frame(stream: &mut UnixStream) -> serde_json::Value {
|
||||
let mut size = [0; 4];
|
||||
stream.read_exact(&mut size).unwrap();
|
||||
let mut bytes = vec![0; u32::from_be_bytes(size) as usize];
|
||||
stream.read_exact(&mut bytes).unwrap();
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
|
||||
fn upload(layout: &Layout, input: &CompileRequest) -> UnixStream {
|
||||
let payload = input.encode().unwrap();
|
||||
let mut stream = UnixStream::connect(layout.service_socket()).unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(10)))
|
||||
.unwrap();
|
||||
let token = fs::read_to_string(layout.service_token()).unwrap();
|
||||
let control = serde_json::to_vec(&serde_json::json!({"token":token.trim(),
|
||||
"operation":{"compile":{"bytes":payload.len()}},"installed":[]}))
|
||||
.unwrap();
|
||||
stream
|
||||
.write_all(&(control.len() as u32).to_be_bytes())
|
||||
.unwrap();
|
||||
stream.write_all(&control).unwrap();
|
||||
assert_eq!(frame(&mut stream)["ok"], true);
|
||||
stream.write_all(&payload).unwrap();
|
||||
stream
|
||||
}
|
||||
|
||||
fn wait_for<T>(mode: &str, phase: &str, mut action: impl FnMut() -> Option<T>) -> T {
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
if let Some(value) = action() {
|
||||
return value;
|
||||
}
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(10),
|
||||
"owned compilation mode={mode} timed out waiting for {phase}"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared archive, explicit author cc/LLVM inputs; supervises an owned signed sleeper"]
|
||||
fn cancellation_disconnect_and_version_lease_reap_worker_and_staging() {
|
||||
let root = source_project();
|
||||
let sleeper = root.path().join("signed-sleeper");
|
||||
let source = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../test/dever-cli-tests/support/compiler_sleeper.c");
|
||||
let status = Command::new("/usr/bin/cc")
|
||||
.env_clear()
|
||||
.args([
|
||||
"-B/usr/bin/",
|
||||
"-O2",
|
||||
"-Wl,--disable-new-dtags,-rpath,$ORIGIN/lib,--no-as-needed",
|
||||
"-L/usr/lib/llvm-18/lib",
|
||||
"-lLLVM-18",
|
||||
"-lstdc++",
|
||||
"-lz",
|
||||
"-lgcc_s",
|
||||
])
|
||||
.arg(source)
|
||||
.arg("-o")
|
||||
.arg(&sleeper)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(
|
||||
status.success(),
|
||||
"explicit native sleeper preparation failed"
|
||||
);
|
||||
let (layout, _) = signed_core(root.path(), &sleeper, true);
|
||||
let _daemon = TestDaemon::start(&layout);
|
||||
for mode in ["cancel", "disconnect", "deadline"] {
|
||||
let mut stream = upload(&layout, &request(root.path(), CompileKind::Application));
|
||||
let pid = wait_for(mode, "worker PID publication", || {
|
||||
fs::read_dir(layout.native_cache().join("staging"))
|
||||
.ok()?
|
||||
.filter_map(Result::ok)
|
||||
.find_map(|entry| {
|
||||
fs::read_to_string(entry.path().join("worker.pid"))
|
||||
.ok()?
|
||||
.parse::<i32>()
|
||||
.ok()
|
||||
.filter(|pid| *pid > 0)
|
||||
.map(|pid| pid.to_string())
|
||||
})
|
||||
});
|
||||
let lock = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(layout.state().join("install.lock"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
fs2::FileExt::try_lock_exclusive(&lock).is_err(),
|
||||
"active compiler version was not leased"
|
||||
);
|
||||
assert!(
|
||||
CacheStore::new(&layout)
|
||||
.clean(&Default::default())
|
||||
.unwrap_err()
|
||||
.contains("busy")
|
||||
);
|
||||
if mode == "cancel" {
|
||||
stream.write_all(&[1]).unwrap();
|
||||
let response = frame(&mut stream);
|
||||
assert_eq!(response["ok"], false);
|
||||
assert!(response["error"].as_str().unwrap().contains("cancel"));
|
||||
} else if mode == "deadline" {
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(190)))
|
||||
.unwrap();
|
||||
let response = frame(&mut stream);
|
||||
assert_eq!(response["ok"], false);
|
||||
assert!(response["error"].as_str().unwrap().contains("deadline"));
|
||||
}
|
||||
drop(stream);
|
||||
wait_for(mode, "worker exit and staging/operation cleanup", || {
|
||||
let staging = fs::read_dir(layout.native_cache().join("staging"))
|
||||
.ok()?
|
||||
.count();
|
||||
let operations = fs::read_dir(layout.native_cache().join("operations"))
|
||||
.ok()?
|
||||
.count();
|
||||
(staging == 0 && operations == 0 && !Path::new("/proc").join(&pid).exists())
|
||||
.then_some(())
|
||||
});
|
||||
fs2::FileExt::try_lock_exclusive(&lock).unwrap();
|
||||
fs2::FileExt::unlock(&lock).unwrap();
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
use super::super::release::Artifact;
|
||||
use super::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
fn manifest() -> ReleaseManifest {
|
||||
ReleaseManifest::new(
|
||||
Version::parse("1.2.3").unwrap(),
|
||||
vec![Artifact {
|
||||
path: "dever-core".into(),
|
||||
bytes: 4,
|
||||
sha256: super::super::release::hex(&Sha256::digest(b"core")),
|
||||
}],
|
||||
)
|
||||
}
|
||||
|
||||
fn archive(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut archive = tar::Builder::new(Vec::new());
|
||||
for (name, contents) in entries {
|
||||
let mut header = tar::Header::new_ustar();
|
||||
header.set_size(contents.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
archive.append_data(&mut header, name, *contents).unwrap();
|
||||
}
|
||||
archive.into_inner().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_archive_accepts_only_the_exact_signed_regular_files() {
|
||||
let root = Download::new(&std::env::temp_dir()).unwrap();
|
||||
unpack(
|
||||
archive(&[("dever-core", b"core")]).as_slice(),
|
||||
&root.0,
|
||||
&manifest(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fs::read(root.0.join("dever-core")).unwrap(), b"core");
|
||||
for entries in [
|
||||
vec![
|
||||
("dever-core", b"core".as_slice()),
|
||||
("extra", b"x".as_slice()),
|
||||
],
|
||||
vec![
|
||||
("dever-core", b"core".as_slice()),
|
||||
("dever-core", b"core".as_slice()),
|
||||
],
|
||||
vec![("dever-core", b"bad".as_slice())],
|
||||
vec![],
|
||||
] {
|
||||
let root = Download::new(&std::env::temp_dir()).unwrap();
|
||||
assert!(unpack(archive(&entries).as_slice(), &root.0, &manifest()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_archive_rejects_links_and_extension_headers() {
|
||||
for kind in [
|
||||
tar::EntryType::Symlink,
|
||||
tar::EntryType::Link,
|
||||
tar::EntryType::XGlobalHeader,
|
||||
] {
|
||||
let root = Download::new(&std::env::temp_dir()).unwrap();
|
||||
let mut archive = tar::Builder::new(Vec::new());
|
||||
let mut header = tar::Header::new_ustar();
|
||||
header.set_path("dever-core").unwrap();
|
||||
header.set_entry_type(kind);
|
||||
header.set_size(0);
|
||||
header.set_mode(0o644);
|
||||
header.set_link_name("/outside").unwrap();
|
||||
header.set_cksum();
|
||||
archive.append(&header, io::empty()).unwrap();
|
||||
assert!(
|
||||
unpack(
|
||||
archive.into_inner().unwrap().as_slice(),
|
||||
&root.0,
|
||||
&manifest()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(!root.0.join("dever-core").exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_catalog_paths_and_total_size_are_bounded_before_extraction() {
|
||||
for path in [
|
||||
"/absolute",
|
||||
"../outside",
|
||||
"a/../b",
|
||||
"./file",
|
||||
"a//b",
|
||||
"a\\b",
|
||||
"C:drive",
|
||||
"manifest.json",
|
||||
"manifest.sig",
|
||||
] {
|
||||
let mut manifest = manifest();
|
||||
manifest.artifacts[0].path = path.into();
|
||||
assert!(validate_catalog(&manifest).is_err(), "{path}");
|
||||
}
|
||||
let mut excessive = manifest();
|
||||
excessive.artifacts[0].bytes = RELEASE_LIMIT + 1;
|
||||
assert!(validate_catalog(&excessive).is_err());
|
||||
let mut duplicate = manifest();
|
||||
duplicate.artifacts.push(duplicate.artifacts[0].clone());
|
||||
assert!(validate_catalog(&duplicate).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_redirects_reject_http_credentials_ports_and_foreign_hosts() {
|
||||
let current = "https://github.com/shemic/dever-main/releases/latest/download/a";
|
||||
assert!(
|
||||
redirect(
|
||||
current,
|
||||
"https://release-assets.githubusercontent.com/asset?token=opaque"
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(redirect(current, "/shemic/dever-main/releases/download/v1.2.3/a").is_ok());
|
||||
for address in [
|
||||
"http://github.com/a",
|
||||
"https://evil.example/a",
|
||||
"https://github.com.evil.example/a",
|
||||
"https://user@github.com/a",
|
||||
"https://github.com:444/a",
|
||||
"https://github.com/a#fragment",
|
||||
] {
|
||||
assert!(redirect(current, address).is_err(), "{address}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_asset_names_are_versioned_and_platform_specific() {
|
||||
let source = ReleaseSource::official();
|
||||
assert_eq!(
|
||||
source.asset("latest", "manifest.json"),
|
||||
format!(
|
||||
"{OFFICIAL_RELEASES}/latest/download/dever-{}.manifest.json",
|
||||
platform_identity()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
source.asset("1.2.3", "tar.gz"),
|
||||
format!(
|
||||
"{OFFICIAL_RELEASES}/download/v1.2.3/dever-{}.tar.gz",
|
||||
platform_identity()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
fn signed_release() -> (ReleaseManifest, Vec<u8>, Vec<u8>, Vec<u8>) {
|
||||
use ring::signature::{Ed25519KeyPair, KeyPair};
|
||||
let pair = Ed25519KeyPair::from_seed_unchecked(&[37; 32]).unwrap();
|
||||
let mut manifest = manifest();
|
||||
manifest.artifacts.push(Artifact {
|
||||
path: "skills/dever-language/SKILL.md".into(),
|
||||
bytes: 5,
|
||||
sha256: super::super::release::hex(&Sha256::digest(b"skill")),
|
||||
});
|
||||
let bytes = serde_json::to_vec(&manifest).unwrap();
|
||||
let signature = super::super::release::hex(pair.sign(&bytes).as_ref()).into_bytes();
|
||||
(
|
||||
manifest,
|
||||
bytes,
|
||||
signature,
|
||||
pair.public_key().as_ref().to_vec(),
|
||||
)
|
||||
}
|
||||
|
||||
fn server(responses: Vec<Vec<u8>>) -> (ReleaseSource, std::thread::JoinHandle<()>) {
|
||||
use std::io::BufRead;
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let base = format!("http://{}/releases", listener.local_addr().unwrap());
|
||||
let worker = std::thread::spawn(move || {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
for response in responses {
|
||||
let mut stream = loop {
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => break stream,
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"expected release request did not arrive"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
}
|
||||
};
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let mut reader = io::BufReader::new(stream.try_clone().unwrap());
|
||||
let mut request = String::new();
|
||||
reader.read_line(&mut request).unwrap();
|
||||
assert!(request.starts_with("GET /releases/"));
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
if line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
response.len()
|
||||
)
|
||||
.unwrap();
|
||||
stream.write_all(&response).unwrap();
|
||||
}
|
||||
});
|
||||
(ReleaseSource::new(base), worker)
|
||||
}
|
||||
|
||||
fn compressed_release(core: &[u8]) -> Vec<u8> {
|
||||
let bytes = archive(&[
|
||||
("dever-core", core),
|
||||
("skills/dever-language/SKILL.md", b"skill"),
|
||||
]);
|
||||
let mut gzip = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
gzip.write_all(&bytes).unwrap();
|
||||
gzip.finish().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_download_verifies_the_complete_bundle_before_publishing_catalog() {
|
||||
let root = Download::new(&std::env::temp_dir()).unwrap();
|
||||
let layout = Layout::new(root.0.join("machine"));
|
||||
layout.initialize().unwrap();
|
||||
let (manifest, bytes, signature, key) = signed_release();
|
||||
fs::write(
|
||||
layout.state().join("trusted-release-key"),
|
||||
super::super::release::hex(&key),
|
||||
)
|
||||
.unwrap();
|
||||
let (source, worker) = server(vec![bytes, signature, compressed_release(b"core")]);
|
||||
let version = prepare_from(&layout, "latest", &source).unwrap();
|
||||
worker.join().unwrap();
|
||||
assert_eq!(version, manifest.version);
|
||||
let installed = layout.downloads().join(version.as_str());
|
||||
MachineManager::verify_package(&installed, Some(&version), &key).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(installed.join("skills/dever-language/SKILL.md")).unwrap(),
|
||||
b"skill"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(layout.downloads().join("latest")).unwrap(),
|
||||
"1.2.3\n"
|
||||
);
|
||||
assert_eq!(fs::read_dir(layout.staging()).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_signatures_and_corrupt_payloads_preserve_the_previous_catalog_and_active_version() {
|
||||
for bad_signature in [true, false] {
|
||||
let root = Download::new(&std::env::temp_dir()).unwrap();
|
||||
let layout = Layout::new(root.0.join("machine"));
|
||||
layout.initialize().unwrap();
|
||||
let (_, bytes, signature, key) = signed_release();
|
||||
fs::write(
|
||||
layout.state().join("trusted-release-key"),
|
||||
super::super::release::hex(&key),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(layout.downloads().join("latest"), "0.9.0\n").unwrap();
|
||||
let active = layout.state().join("active-version");
|
||||
super::super::release::append_active_version(&active, &Version::parse("0.9.0").unwrap())
|
||||
.unwrap();
|
||||
let previous = fs::read(&active).unwrap();
|
||||
let responses = if bad_signature {
|
||||
vec![bytes, vec![b'0'; 128]]
|
||||
} else {
|
||||
vec![bytes, signature, compressed_release(b"evil")]
|
||||
};
|
||||
let (source, worker) = server(responses);
|
||||
let error = prepare_from(&layout, "latest", &source).unwrap_err();
|
||||
worker.join().unwrap();
|
||||
assert!(error.contains("verification"), "{error}");
|
||||
assert_eq!(fs::read(&active).unwrap(), previous);
|
||||
assert_eq!(
|
||||
fs::read_to_string(layout.downloads().join("latest")).unwrap(),
|
||||
"0.9.0\n"
|
||||
);
|
||||
assert!(!layout.downloads().join("1.2.3").exists());
|
||||
assert_eq!(fs::read_dir(layout.staging()).unwrap().count(), 0);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,2 @@
|
||||
/* Deliberately separate DT_NEEDED dependency for native command packaging. */
|
||||
int command_value(void) { return 42; }
|
||||
@@ -0,0 +1,19 @@
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
extern int command_value(void);
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
const unsigned char expected[] = {0, 255, 128, 10};
|
||||
unsigned char input[sizeof(expected)];
|
||||
if (argc != 3 || strcmp(argv[1], "a b") != 0 ||
|
||||
strcmp(argv[2], "$(literal);*") != 0 || command_value() != 42)
|
||||
return 90;
|
||||
if (fread(input, 1, sizeof(input), stdin) != sizeof(input) ||
|
||||
memcmp(input, expected, sizeof(input)) != 0 || getchar() != EOF)
|
||||
return 91;
|
||||
if (fwrite(input, 1, sizeof(input), stdout) != sizeof(input) ||
|
||||
fputs("ordinary command", stderr) == EOF)
|
||||
return 92;
|
||||
return 23;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/* Native author fixture for compile cancellation and deadline supervision. */
|
||||
#include <stdio.h>
|
||||
#include <errno.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc == 2 && strcmp(argv[1], "--dever-health") == 0) return 0;
|
||||
FILE *pid = fopen("worker.pid", "w");
|
||||
if (!pid) return 2;
|
||||
if (fprintf(pid, "%ld", (long)getpid()) < 0 || fclose(pid) != 0) return 3;
|
||||
struct timespec remaining = {300, 0};
|
||||
while (nanosleep(&remaining, &remaining) != 0) {
|
||||
if (errno != EINTR) return 4;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Lifetime of an explicitly requested test-owned shared compiler service.
|
||||
use std::process::{Child, Command};
|
||||
use std::time::Duration;
|
||||
|
||||
use dever_cli::toolchain::{Layout, cache_status};
|
||||
|
||||
pub struct TestDaemon(Child);
|
||||
|
||||
impl TestDaemon {
|
||||
pub fn start(layout: &Layout) -> Self {
|
||||
let child = Command::new(env!("CARGO_BIN_EXE_deverd"))
|
||||
.env_clear()
|
||||
.current_dir(layout.root())
|
||||
.args(["--root", "."])
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut daemon = Self(child);
|
||||
for _ in 0..100 {
|
||||
if cache_status(layout).is_ok() {
|
||||
return daemon;
|
||||
}
|
||||
assert!(
|
||||
daemon.0.try_wait().unwrap().is_none(),
|
||||
"test deverd exited early"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
panic!("test deverd did not become ready");
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) {
|
||||
self.0.kill().unwrap();
|
||||
self.0.wait().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDaemon {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Explicit Linux author inputs shared by opt-in native fixtures.
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const LLVM_LIBRARY: &str = "/usr/lib/llvm-18/lib/libLLVM.so.1";
|
||||
|
||||
pub fn core_libraries() -> Vec<(&'static str, PathBuf)> {
|
||||
let mut inputs = vec![("libLLVM.so.18.1", PathBuf::from(LLVM_LIBRARY))];
|
||||
for name in [
|
||||
"libstdc++.so.6",
|
||||
"libgcc_s.so.1",
|
||||
"libz.so.1",
|
||||
"libffi.so.8",
|
||||
"libedit.so.2",
|
||||
"libzstd.so.1",
|
||||
"libtinfo.so.6",
|
||||
"libxml2.so.2",
|
||||
"libbsd.so.0",
|
||||
"libmd.so.0",
|
||||
"libicuuc.so.74",
|
||||
"libicudata.so.74",
|
||||
"liblzma.so.5",
|
||||
] {
|
||||
inputs.push((name, Path::new("/usr/lib/x86_64-linux-gnu").join(name)));
|
||||
}
|
||||
inputs
|
||||
.into_iter()
|
||||
.map(|(name, path)| {
|
||||
(
|
||||
name,
|
||||
path.canonicalize()
|
||||
.expect("prepare the explicit GNU compiler-library inputs"),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn native_inputs() -> Vec<(&'static str, PathBuf)> {
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
vec![
|
||||
(
|
||||
"runtime.a",
|
||||
workspace.join("target/native-runtime-abi/debug/libdever_backend_bridge.a"),
|
||||
),
|
||||
("crt1.o", "/usr/lib/x86_64-linux-gnu/crt1.o".into()),
|
||||
("crti.o", "/usr/lib/x86_64-linux-gnu/crti.o".into()),
|
||||
(
|
||||
"crtbeginT.o",
|
||||
"/usr/lib/gcc/x86_64-linux-gnu/13/crtbeginT.o".into(),
|
||||
),
|
||||
(
|
||||
"crtend.o",
|
||||
"/usr/lib/gcc/x86_64-linux-gnu/13/crtend.o".into(),
|
||||
),
|
||||
("crtn.o", "/usr/lib/x86_64-linux-gnu/crtn.o".into()),
|
||||
("libc.a", "/usr/lib/x86_64-linux-gnu/libc.a".into()),
|
||||
("libm.a", "/usr/lib/x86_64-linux-gnu/libm-2.39.a".into()),
|
||||
("libmvec.a", "/usr/lib/x86_64-linux-gnu/libmvec.a".into()),
|
||||
(
|
||||
"libgcc.a",
|
||||
"/usr/lib/gcc/x86_64-linux-gnu/13/libgcc.a".into(),
|
||||
),
|
||||
(
|
||||
"libgcc_eh.a",
|
||||
"/usr/lib/gcc/x86_64-linux-gnu/13/libgcc_eh.a".into(),
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Explicit test inputs: no downloads, recursive builds or host tool lookup.
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use dever_cli::toolchain::sha256_file as hash_file;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[path = "llvm_inputs.rs"]
|
||||
mod inputs;
|
||||
#[cfg(test)]
|
||||
#[path = "native_objects.rs"]
|
||||
mod native_objects;
|
||||
use inputs::{core_libraries, native_inputs};
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn compiler(source: &Path, root: &Path, name: &str) -> PathBuf {
|
||||
fs::create_dir_all(root).unwrap();
|
||||
let path = root.join(name);
|
||||
fs::hard_link(source, &path).unwrap();
|
||||
compiler_library(root).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
pub fn pack_root(root: &Path) -> PathBuf {
|
||||
root.join("runtime")
|
||||
.join(dever_cli::toolchain::platform_identity())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn manifest(root: &Path, real: bool) -> Value {
|
||||
let destination = pack_root(root);
|
||||
fs::create_dir_all(&destination).unwrap();
|
||||
let sources = native_inputs();
|
||||
let mut files = Vec::new();
|
||||
for (name, source) in sources {
|
||||
let output = destination.join(name);
|
||||
if real {
|
||||
assert!(
|
||||
source.is_file(),
|
||||
"prepare the explicit native archive and CRT fixture first: {}",
|
||||
source.display()
|
||||
);
|
||||
// Only immutable reads follow. Corruption cases use synthetic files.
|
||||
fs::hard_link(source, &output).unwrap();
|
||||
} else {
|
||||
fs::write(
|
||||
&output,
|
||||
if name.ends_with(".a") {
|
||||
native_objects::archive(
|
||||
dever_cli::toolchain::BuildTarget::host().unwrap(),
|
||||
"fixture.o",
|
||||
)
|
||||
} else {
|
||||
native_objects::object(dever_cli::toolchain::BuildTarget::host().unwrap())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
files.push(json!({"path":name, "bytes":fs::metadata(&output).unwrap().len(), "sha256":digest(&output)}));
|
||||
}
|
||||
let document = document(files);
|
||||
write_manifest(root, &document);
|
||||
document
|
||||
}
|
||||
|
||||
fn document(files: Vec<Value>) -> Value {
|
||||
let profile = json!({
|
||||
"start":["crt1.o","crti.o","crtbeginT.o"], "runtime":"runtime.a",
|
||||
"libraries":["libc.a","libm.a","libmvec.a","libgcc.a","libgcc_eh.a"],
|
||||
"end":["crtend.o","crtn.o"]
|
||||
});
|
||||
json!({
|
||||
"format":"dever-native-runtime-v1", "compiler_version":env!("CARGO_PKG_VERSION"),
|
||||
"abi":dever_backend_bridge::RUNTIME_ABI_VERSION,
|
||||
"platform":dever_cli::toolchain::platform_identity(),
|
||||
"target":"x86_64-unknown-linux-gnu",
|
||||
"profiles":{"base":profile,"sqlite":profile,"postgres":profile,"both":profile},
|
||||
"files":files
|
||||
})
|
||||
}
|
||||
|
||||
fn compiler_library(root: &Path) -> Result<(), String> {
|
||||
let libraries = root.join("lib");
|
||||
create_or_check_directory(&libraries)?;
|
||||
for (name, source) in core_libraries() {
|
||||
let target = libraries.join(name);
|
||||
match fs::symlink_metadata(&target) {
|
||||
Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => {
|
||||
if hash_file(&target)? != hash_file(&source)? {
|
||||
return Err(
|
||||
"existing compiler library differs from the explicit author SDK".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(_) => return Err("compiler library must be a real file".into()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
fs::hard_link(source, target).map_err(|error| error.to_string())?;
|
||||
}
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_or_check_directory(path: &Path) -> Result<(), String> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => Ok(()),
|
||||
Ok(_) => Err(format!("'{}' must be a real directory", path.display())),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
fs::create_dir(path).map_err(|error| error.to_string())
|
||||
}
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn write_manifest(root: &Path, manifest: &Value) {
|
||||
fs::write(
|
||||
pack_root(root).join("manifest.json"),
|
||||
serde_json::to_vec(manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn digest(path: &Path) -> String {
|
||||
hash_file(path).unwrap()
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Test-only static entry for the already pivoted no-host acceptance namespace.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use rustix::mount::{MountFlags, mount};
|
||||
use rustix::thread::{self, CapabilitySet};
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
let mut arguments = std::env::args_os().skip(1);
|
||||
let executable = arguments.next().ok_or("missing acceptance executable")?;
|
||||
if !std::path::Path::new(&executable).is_absolute() {
|
||||
return Err("acceptance executable must be absolute".into());
|
||||
}
|
||||
// The root-only harness keeps bwrap's original namespace capabilities.
|
||||
// Reject ordinary host execution before touching any mount.
|
||||
if !thread::no_new_privs()?
|
||||
|| !thread::capabilities(None)?
|
||||
.effective
|
||||
.contains(CapabilitySet::SYS_ADMIN)
|
||||
{
|
||||
return Err("acceptance init requires its restricted fixture namespace".into());
|
||||
}
|
||||
// bwrap's readonly proc submounts otherwise prevent a nested user
|
||||
// namespace from mounting its own proc. Replace only this private proc.
|
||||
mount(
|
||||
"proc",
|
||||
"/proc",
|
||||
"proc",
|
||||
MountFlags::NOSUID | MountFlags::NODEV | MountFlags::NOEXEC,
|
||||
None,
|
||||
)?;
|
||||
Err(std::process::Command::new(executable)
|
||||
.args(arguments)
|
||||
.env_clear()
|
||||
.exec()
|
||||
.into())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn main() -> Result<(), &'static str> {
|
||||
Err("native acceptance isolation requires Linux")
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Small ELF metadata fixtures; execution acceptance uses real author inputs.
|
||||
pub fn compiler(needed: &[&str]) -> Vec<u8> {
|
||||
elf(None, needed, Some("$ORIGIN/lib"))
|
||||
}
|
||||
|
||||
pub fn library(name: &str, needed: &[&str]) -> Vec<u8> {
|
||||
elf(Some(name), needed, None)
|
||||
}
|
||||
|
||||
pub fn static_executable() -> Vec<u8> {
|
||||
let mut bytes = elf(None, &[], None);
|
||||
// Change the synthetic PT_INTERP entry to PT_NULL. Execution tests use the
|
||||
// real static author input; this fixture tests only ELF admission rules.
|
||||
bytes[64 + 2 * 56..68 + 2 * 56].copy_from_slice(&0u32.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
pub fn elf(soname: Option<&str>, needed: &[&str], rpath: Option<&str>) -> Vec<u8> {
|
||||
let mut strings = vec![0u8];
|
||||
let mut string = |text: &str| {
|
||||
let offset = strings.len() as u64;
|
||||
strings.extend_from_slice(text.as_bytes());
|
||||
strings.push(0);
|
||||
offset
|
||||
};
|
||||
let mut dynamic = needed
|
||||
.iter()
|
||||
.map(|name| (1u64, string(name)))
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(name) = soname {
|
||||
dynamic.push((14, string(name)));
|
||||
}
|
||||
if let Some(path) = rpath {
|
||||
dynamic.push((15, string(path)));
|
||||
}
|
||||
let interpreter = if cfg!(target_arch = "aarch64") {
|
||||
b"/lib/ld-linux-aarch64.so.1\0".as_slice()
|
||||
} else {
|
||||
b"/lib64/ld-linux-x86-64.so.2\0".as_slice()
|
||||
};
|
||||
let header_count: u16 = if soname.is_some() { 2 } else { 3 };
|
||||
let string_offset = 64 + usize::from(header_count) * 56;
|
||||
let interpreter_offset = string_offset + strings.len();
|
||||
let dynamic_offset = (interpreter_offset + interpreter.len() + 7) & !7;
|
||||
dynamic.extend([
|
||||
(5, string_offset as u64),
|
||||
(10, strings.len() as u64),
|
||||
(0, 0),
|
||||
]);
|
||||
let length = dynamic_offset + dynamic.len() * 16;
|
||||
let mut bytes = vec![0; length];
|
||||
bytes[..7].copy_from_slice(b"\x7fELF\x02\x01\x01");
|
||||
bytes[16..18].copy_from_slice(&3u16.to_le_bytes());
|
||||
let machine = if cfg!(target_arch = "aarch64") {
|
||||
183u16
|
||||
} else {
|
||||
62u16
|
||||
};
|
||||
bytes[18..20].copy_from_slice(&machine.to_le_bytes());
|
||||
bytes[20..24].copy_from_slice(&1u32.to_le_bytes());
|
||||
bytes[32..40].copy_from_slice(&64u64.to_le_bytes());
|
||||
bytes[52..54].copy_from_slice(&64u16.to_le_bytes());
|
||||
bytes[54..56].copy_from_slice(&56u16.to_le_bytes());
|
||||
bytes[56..58].copy_from_slice(&header_count.to_le_bytes());
|
||||
let mut segment = |index: usize, kind: u32, offset: usize, size: usize| {
|
||||
let start = 64 + index * 56;
|
||||
bytes[start..start + 4].copy_from_slice(&kind.to_le_bytes());
|
||||
bytes[start + 4..start + 8].copy_from_slice(&4u32.to_le_bytes());
|
||||
for at in [8, 16, 24] {
|
||||
bytes[start + at..start + at + 8].copy_from_slice(&(offset as u64).to_le_bytes());
|
||||
}
|
||||
for at in [32, 40] {
|
||||
bytes[start + at..start + at + 8].copy_from_slice(&(size as u64).to_le_bytes());
|
||||
}
|
||||
bytes[start + 48..start + 56].copy_from_slice(&1u64.to_le_bytes());
|
||||
};
|
||||
segment(0, 1, 0, length);
|
||||
segment(1, 2, dynamic_offset, dynamic.len() * 16);
|
||||
if soname.is_none() {
|
||||
segment(2, 3, interpreter_offset, interpreter.len());
|
||||
}
|
||||
bytes[string_offset..interpreter_offset].copy_from_slice(&strings);
|
||||
bytes[interpreter_offset..interpreter_offset + interpreter.len()].copy_from_slice(interpreter);
|
||||
for (index, (tag, value)) in dynamic.into_iter().enumerate() {
|
||||
let start = dynamic_offset + index * 16;
|
||||
bytes[start..start + 8].copy_from_slice(&tag.to_le_bytes());
|
||||
bytes[start + 8..start + 16].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Small architecture-bearing link inputs for pack validation tests.
|
||||
use dever_cli::toolchain::BuildTarget;
|
||||
|
||||
pub fn object(target: BuildTarget) -> Vec<u8> {
|
||||
let mut bytes = vec![0; 64];
|
||||
bytes[..7].copy_from_slice(b"\x7fELF\x02\x01\x01");
|
||||
bytes[16..18].copy_from_slice(&1_u16.to_le_bytes());
|
||||
let machine: u16 = match target {
|
||||
BuildTarget::LinuxX86_64 => 62,
|
||||
BuildTarget::LinuxAarch64 => 183,
|
||||
};
|
||||
bytes[18..20].copy_from_slice(&machine.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
pub fn archive(target: BuildTarget, member: &str) -> Vec<u8> {
|
||||
let object = object(target);
|
||||
let header = format!(
|
||||
"{:<16}{:<12}{:<6}{:<6}{:<8}{:<10}`\n",
|
||||
format!("{member}/"),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
"644",
|
||||
object.len()
|
||||
);
|
||||
assert_eq!(header.len(), 60);
|
||||
[b"!<arch>\n".as_slice(), header.as_bytes(), &object].concat()
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub fn metadata(name: &str, version: &str, requires: &[&str], extras: &[&str]) -> String {
|
||||
let mut text = format!("Metadata-Version: 2.4\nName: {name}\nVersion: {version}\n");
|
||||
for requirement in requires {
|
||||
text.push_str(&format!("Requires-Dist: {requirement}\n"));
|
||||
}
|
||||
for extra in extras {
|
||||
text.push_str(&format!("Provides-Extra: {extra}\n"));
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
pub fn pack(
|
||||
name: &str,
|
||||
version: &str,
|
||||
wheel: &str,
|
||||
metadata: &str,
|
||||
extra: &[(&str, Vec<u8>)],
|
||||
) -> Vec<u8> {
|
||||
let info = format!("{}-{version}.dist-info", name.replace('-', "_"));
|
||||
let mut files = vec![
|
||||
(format!("{info}/WHEEL"), wheel.as_bytes().to_vec()),
|
||||
(format!("{info}/METADATA"), metadata.as_bytes().to_vec()),
|
||||
];
|
||||
files.extend(
|
||||
extra
|
||||
.iter()
|
||||
.map(|(path, bytes)| ((*path).to_owned(), bytes.clone())),
|
||||
);
|
||||
let mut record = csv::WriterBuilder::new()
|
||||
.has_headers(false)
|
||||
.from_writer(Vec::new());
|
||||
for (path, bytes) in &files {
|
||||
record
|
||||
.write_record([
|
||||
path.clone(),
|
||||
format!("sha256={}", URL_SAFE_NO_PAD.encode(Sha256::digest(bytes))),
|
||||
bytes.len().to_string(),
|
||||
])
|
||||
.unwrap();
|
||||
}
|
||||
let path = format!("{info}/RECORD");
|
||||
record
|
||||
.write_record([path.clone(), String::new(), String::new()])
|
||||
.unwrap();
|
||||
files.push((path, record.into_inner().unwrap()));
|
||||
zip(&files)
|
||||
}
|
||||
|
||||
pub fn zip(files: &[(String, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (path, bytes) in files {
|
||||
writer
|
||||
.start_file(path, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
writer.write_all(bytes).unwrap();
|
||||
}
|
||||
writer.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
pub fn markers() -> pep508_rs::MarkerEnvironment {
|
||||
pep508_rs::MarkerEnvironmentBuilder {
|
||||
implementation_name: "cpython",
|
||||
implementation_version: "3.12.0",
|
||||
os_name: "posix",
|
||||
platform_machine: "x86_64",
|
||||
platform_python_implementation: "CPython",
|
||||
platform_release: "",
|
||||
platform_system: "Linux",
|
||||
platform_version: "",
|
||||
python_full_version: "3.12.0",
|
||||
python_version: "3.12",
|
||||
sys_platform: "linux",
|
||||
}
|
||||
.try_into()
|
||||
.unwrap()
|
||||
}
|
||||
Reference in new issue
Block a user