Initial Dever 0.0.1
This commit is contained in:
commit
81233d098c
1479 files changed
+163782
No files matched your search
@@ -0,0 +1,113 @@
|
||||
use super::fixture::{Fixture, app, command, write};
|
||||
use dever::compiler::project::Project;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn api_baselines_are_checked_before_build_and_cli_never_overwrites_them() {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = &fixture.directory.0;
|
||||
let app_path = "module/demo/value/app.dever";
|
||||
write(root, app_path, app("input + 1"));
|
||||
write(root, "module/demo/value/api.dever", command());
|
||||
let baseline = root.join("dever.api");
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.arg("api")
|
||||
.arg(root)
|
||||
.arg("--output")
|
||||
.arg(&baseline)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let before = std::fs::read(&baseline).unwrap();
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.arg("api")
|
||||
.arg(root)
|
||||
.arg("--output")
|
||||
.arg(&baseline)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert_eq!(before, std::fs::read(&baseline).unwrap());
|
||||
write(root, app_path, app("input + 2"));
|
||||
assert!(Project::load(root).unwrap().check().is_ok());
|
||||
write(root, app_path, app("input + 2").replace("input", "renamed"));
|
||||
let output_path = root.join("must_not_build");
|
||||
for verb in ["check", "run", "build"] {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_dever"));
|
||||
command.arg(verb).arg(root);
|
||||
if verb == "build" {
|
||||
command.arg("--output").arg(&output_path);
|
||||
}
|
||||
let output = command.output().unwrap();
|
||||
assert!(!output.status.success());
|
||||
let error = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(
|
||||
error.contains("public API differs") && error.contains("+ cmd"),
|
||||
"{verb}: {error}"
|
||||
);
|
||||
}
|
||||
assert!(!output_path.exists());
|
||||
let reviewed = root.join("proposed.api");
|
||||
assert!(
|
||||
Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.arg("api")
|
||||
.arg(root)
|
||||
.arg("--output")
|
||||
.arg(&reviewed)
|
||||
.output()
|
||||
.unwrap()
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
assert_eq!(before, std::fs::read(&baseline).unwrap());
|
||||
assert_ne!(before, std::fs::read(reviewed).unwrap());
|
||||
std::fs::remove_file(&baseline).unwrap();
|
||||
std::os::unix::fs::symlink("proposed.api", &baseline).unwrap();
|
||||
assert!(
|
||||
Project::load(root)
|
||||
.unwrap()
|
||||
.check()
|
||||
.unwrap_err()
|
||||
.contains("project input links")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_dependency_contract_changes_are_included_in_project_baseline() {
|
||||
let mut fixture = Fixture::new(&["numbers@*"]);
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[],
|
||||
&[
|
||||
("module/numbers/value/app.dever", &app("input")),
|
||||
("module/numbers/value/api.dever", &command()),
|
||||
],
|
||||
);
|
||||
let baseline = fixture.load().unwrap().api_snapshot().unwrap();
|
||||
write(&fixture.directory.0, "dever.api", &baseline);
|
||||
assert!(fixture.load().unwrap().check().is_ok());
|
||||
fixture.entries.clear();
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[],
|
||||
&[
|
||||
(
|
||||
"module/numbers/value/app.dever",
|
||||
&app("input").replace("input", "renamed"),
|
||||
),
|
||||
("module/numbers/value/api.dever", &command()),
|
||||
],
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.load()
|
||||
.unwrap()
|
||||
.check()
|
||||
.unwrap_err()
|
||||
.contains("public API differs")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use super::fixture::{Fixture, app};
|
||||
|
||||
#[test]
|
||||
fn archives_reject_traversal_foreign_components_and_file_directory_conflicts() {
|
||||
for (path, needle) in [
|
||||
("module/numbers/../escape", "unsafe"),
|
||||
("/module/numbers/escape", "unsafe"),
|
||||
("module/numbers//escape", "unsafe"),
|
||||
("module/numbers/./escape", "unsafe"),
|
||||
("module/numbers/a\\b", "unsafe"),
|
||||
("module/numbers/C:escape", "unsafe"),
|
||||
("module/other/value/app.dever", "unowned"),
|
||||
("config/setting.json", "unowned"),
|
||||
("module/numbers/value", "file/directory conflict"),
|
||||
] {
|
||||
let mut fixture = Fixture::new(&["numbers@*"]);
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[],
|
||||
&[
|
||||
("module/numbers/value/app.dever", &app("input")),
|
||||
(path, "unowned or unsafe"),
|
||||
],
|
||||
);
|
||||
fixture.reject(needle);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archives_reject_duplicate_central_paths_even_when_zip_library_coalesces_them() {
|
||||
let mut fixture = Fixture::new(&["numbers@*"]);
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[],
|
||||
&[
|
||||
("module/numbers/value/app.dever", &app("input")),
|
||||
("module/numbers/assets/first", "same"),
|
||||
("module/numbers/assets/other", "same"),
|
||||
],
|
||||
);
|
||||
fixture.mutate_archive("numbers", |bytes| {
|
||||
let mut cursor = 0;
|
||||
while cursor + 5 <= bytes.len() {
|
||||
if &bytes[cursor..cursor + 5] == b"other" {
|
||||
bytes[cursor..cursor + 5].copy_from_slice(b"first");
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
});
|
||||
fixture.reject("duplicate or inconsistent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archives_reject_symlinks_and_oversized_expansion_before_reading_contents() {
|
||||
for (link, needle) in [(true, "link or unsupported"), (false, "expanded size")] {
|
||||
let mut fixture = Fixture::new(&["numbers@*"]);
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[],
|
||||
&[("module/numbers/value/app.dever", &app("input"))],
|
||||
);
|
||||
fixture.mutate_archive("numbers", |bytes| {
|
||||
let central = bytes
|
||||
.windows(4)
|
||||
.position(|header| header == b"PK\x01\x02")
|
||||
.unwrap();
|
||||
if link {
|
||||
bytes[central + 5] = 3;
|
||||
bytes[central + 38..central + 42]
|
||||
.copy_from_slice(&(0o120777_u32 << 16).to_le_bytes());
|
||||
} else {
|
||||
bytes[central + 24..central + 28]
|
||||
.copy_from_slice(&(256_u32 * 1024 * 1024 + 1).to_le_bytes());
|
||||
}
|
||||
});
|
||||
fixture.reject(needle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
use super::super::Directory;
|
||||
use super::fixture::digest;
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::os::unix::net::UnixListener;
|
||||
|
||||
#[test]
|
||||
fn cache_client_authenticates_receipts_and_rejects_missing_corrupt_or_truncated_bytes() {
|
||||
for behavior in [
|
||||
"valid",
|
||||
"missing",
|
||||
"receipt",
|
||||
"digest",
|
||||
"truncated",
|
||||
"oversized",
|
||||
] {
|
||||
let directory = Directory::new();
|
||||
let state = directory.0.join("state");
|
||||
std::fs::create_dir(&state).unwrap();
|
||||
std::fs::set_permissions(&state, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let token = "a".repeat(64);
|
||||
let token_path = state.join("deverd.token");
|
||||
std::fs::write(&token_path, &token).unwrap();
|
||||
std::fs::set_permissions(token_path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
let endpoint = directory.0.join("cache.sock");
|
||||
let server = UnixListener::bind(&endpoint).unwrap();
|
||||
let bytes = b"archive fixture";
|
||||
let sha256 = digest(bytes);
|
||||
let expected = sha256.clone();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let (mut stream, _) = server.accept().unwrap();
|
||||
let mut length = [0; 4];
|
||||
stream.read_exact(&mut length).unwrap();
|
||||
let mut payload = vec![0; u32::from_be_bytes(length) as usize];
|
||||
stream.read_exact(&mut payload).unwrap();
|
||||
let request: serde_json::Value = serde_json::from_slice(&payload).unwrap();
|
||||
assert_eq!(request["token"], token);
|
||||
assert_eq!(request["operation"]["artifact_get"]["sha256"], expected);
|
||||
assert_eq!(request["operation"]["artifact_get"]["bytes"], bytes.len());
|
||||
if behavior == "oversized" {
|
||||
stream
|
||||
.write_all(&(1024_u32 * 1024 + 1).to_be_bytes())
|
||||
.unwrap();
|
||||
return;
|
||||
}
|
||||
let artifact = if behavior == "missing" {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
serde_json::json!({"sha256":if behavior == "receipt" { "0".repeat(64) } else { expected }, "bytes":bytes.len()})
|
||||
};
|
||||
let response = serde_json::to_vec(
|
||||
&serde_json::json!({"ok":true, "status":null, "error":null, "artifact":artifact}),
|
||||
)
|
||||
.unwrap();
|
||||
stream
|
||||
.write_all(&(response.len() as u32).to_be_bytes())
|
||||
.unwrap();
|
||||
stream.write_all(&response).unwrap();
|
||||
if !matches!(behavior, "missing" | "receipt") {
|
||||
let mut content = bytes.to_vec();
|
||||
if behavior == "digest" {
|
||||
content[0] ^= 1;
|
||||
}
|
||||
if behavior == "truncated" {
|
||||
content.pop();
|
||||
}
|
||||
stream.write_all(&content).unwrap();
|
||||
}
|
||||
});
|
||||
let result = dever::process::cache::read(&state, &endpoint, &sha256, bytes.len() as u64);
|
||||
worker.join().unwrap();
|
||||
if behavior == "valid" {
|
||||
assert_eq!(result.unwrap(), bytes);
|
||||
} else {
|
||||
assert!(result.is_err(), "accepted {behavior}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_client_rejects_untrusted_state_before_connecting() {
|
||||
let directory = Directory::new();
|
||||
std::fs::set_permissions(&directory.0, std::fs::Permissions::from_mode(0o777)).unwrap();
|
||||
let error = dever::process::cache::read(
|
||||
&directory.0,
|
||||
&directory.0.join("absent.sock"),
|
||||
&"a".repeat(64),
|
||||
1,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.contains("not writable"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_upload_requires_both_authenticated_admission_and_final_receipts() {
|
||||
for rejected in ["none", "admission", "publication"] {
|
||||
let directory = Directory::new();
|
||||
let state = directory.0.join("state");
|
||||
std::fs::create_dir(&state).unwrap();
|
||||
std::fs::set_permissions(&state, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let token = "b".repeat(64);
|
||||
let path = state.join("deverd.token");
|
||||
std::fs::write(&path, &token).unwrap();
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
let endpoint = directory.0.join("cache.sock");
|
||||
let listener = UnixListener::bind(&endpoint).unwrap();
|
||||
let bytes = b"content addressed upload";
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let mut prefix = [0; 4];
|
||||
stream.read_exact(&mut prefix).unwrap();
|
||||
let mut payload = vec![0; u32::from_be_bytes(prefix) as usize];
|
||||
stream.read_exact(&mut payload).unwrap();
|
||||
let request: serde_json::Value = serde_json::from_slice(&payload).unwrap();
|
||||
assert_eq!(request["token"], token);
|
||||
let expected = serde_json::json!({"sha256":digest(bytes), "bytes":bytes.len()});
|
||||
assert_eq!(request["operation"]["artifact_put"], expected);
|
||||
for phase in ["admission", "publication"] {
|
||||
let artifact = if rejected == phase {
|
||||
serde_json::json!({"sha256":"0".repeat(64),"bytes":bytes.len()})
|
||||
} else {
|
||||
expected.clone()
|
||||
};
|
||||
let response = serde_json::to_vec(
|
||||
&serde_json::json!({"ok":true,"status":null,"error":null,"artifact":artifact}),
|
||||
)
|
||||
.unwrap();
|
||||
stream
|
||||
.write_all(&(response.len() as u32).to_be_bytes())
|
||||
.unwrap();
|
||||
stream.write_all(&response).unwrap();
|
||||
if phase == "admission" {
|
||||
if rejected == phase {
|
||||
assert_eq!(stream.read(&mut prefix).unwrap(), 0);
|
||||
break;
|
||||
}
|
||||
let mut received = vec![0; bytes.len()];
|
||||
stream.read_exact(&mut received).unwrap();
|
||||
assert_eq!(received, bytes);
|
||||
}
|
||||
}
|
||||
});
|
||||
let result = dever::process::cache::write(&state, &endpoint, bytes);
|
||||
server.join().unwrap();
|
||||
assert_eq!(result.is_ok(), rejected == "none");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_resource_get_uses_the_historical_deverd_envelope_and_exact_receipt() {
|
||||
// 原 service.rs::resource 请求版本/catalog/扩展身份;服务先回复 receipt,再写裸资产。
|
||||
for corrupt in [false, true] {
|
||||
let directory = Directory::new();
|
||||
let state = directory.0.join("state");
|
||||
std::fs::create_dir(&state).unwrap();
|
||||
let token = "c".repeat(64);
|
||||
let token_path = state.join("deverd.token");
|
||||
std::fs::write(&token_path, &token).unwrap();
|
||||
std::fs::set_permissions(token_path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
let endpoint = directory.0.join("resources.sock");
|
||||
let listener = UnixListener::bind(&endpoint).unwrap();
|
||||
let bytes = b"signed runtime fixture";
|
||||
let catalog = "d".repeat(64);
|
||||
let extension = serde_json::json!({"kind":{"type":"runtime","ecosystem":"npm"},"target":"linux-x86_64"});
|
||||
let expected = serde_json::json!({"token":token,"operation":{"resource_get":{"version":"0.0.1","catalog":catalog,"extension":extension,"artifact":{"sha256":digest(bytes),"bytes":bytes.len()}}},"installed":[]});
|
||||
let worker = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let mut prefix = [0; 4];
|
||||
stream.read_exact(&mut prefix).unwrap();
|
||||
let mut request = vec![0; u32::from_be_bytes(prefix) as usize];
|
||||
stream.read_exact(&mut request).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<serde_json::Value>(&request).unwrap(),
|
||||
expected
|
||||
);
|
||||
let response = serde_json::to_vec(&serde_json::json!({"ok":true,"status":null,"error":null,"artifact":{"sha256":digest(bytes),"bytes":bytes.len()}})).unwrap();
|
||||
stream
|
||||
.write_all(&(response.len() as u32).to_be_bytes())
|
||||
.unwrap();
|
||||
stream.write_all(&response).unwrap();
|
||||
let mut content = bytes.to_vec();
|
||||
if corrupt {
|
||||
content[0] ^= 1;
|
||||
}
|
||||
stream.write_all(&content).unwrap();
|
||||
});
|
||||
let result = dever::process::cache::resource(
|
||||
&state,
|
||||
&endpoint,
|
||||
"0.0.1",
|
||||
&catalog,
|
||||
Some(&extension),
|
||||
&digest(bytes),
|
||||
bytes.len() as u64,
|
||||
);
|
||||
worker.join().unwrap();
|
||||
if corrupt {
|
||||
assert!(result.unwrap_err().contains("SHA-256"));
|
||||
} else {
|
||||
assert_eq!(result.unwrap(), bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
//! 使用自有 registry 和缓存验证完整命令;不访问生产 daemon 或真实 registry。
|
||||
|
||||
use super::fixture::digest;
|
||||
use super::{Fixture, app, command, write};
|
||||
use dever::compiler::project::{
|
||||
Project,
|
||||
dependency::{self, Registry, Store},
|
||||
};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Services {
|
||||
indexes: BTreeMap<String, Vec<serde_json::Value>>,
|
||||
downloads: BTreeMap<String, Vec<u8>>,
|
||||
cache: RefCell<BTreeMap<String, Vec<u8>>>,
|
||||
requests: RefCell<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Services {
|
||||
pub(super) fn release(&mut self, name: &str, version: &str, dependencies: &[(&str, &str)]) {
|
||||
let manifest = serde_json::to_vec(&serde_json::json!({
|
||||
"format":"dever-package-v1", "name":name, "version":version,
|
||||
"dependencies":dependencies.iter().copied().collect::<BTreeMap<_, _>>(), "lib":[]
|
||||
}))
|
||||
.unwrap();
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (path, bytes) in [
|
||||
("dever-package.json".into(), manifest),
|
||||
(
|
||||
format!("module/{name}/value/app.dever"),
|
||||
app("input + 1").into_bytes(),
|
||||
),
|
||||
(
|
||||
format!("module/{name}/assets/home.html"),
|
||||
b"<h1>binary \0 resource</h1>".to_vec(),
|
||||
),
|
||||
] {
|
||||
zip.start_file(path, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(&bytes).unwrap();
|
||||
}
|
||||
let bytes = zip.finish().unwrap().into_inner();
|
||||
self.indexes.entry(name.into()).or_default().push(
|
||||
serde_json::json!({ "version":version,"sha256":digest(&bytes),"bytes":bytes.len() }),
|
||||
);
|
||||
self.downloads
|
||||
.insert(format!("/v1/packages/{name}/{version}.zip"), bytes);
|
||||
}
|
||||
}
|
||||
|
||||
impl Registry for Services {
|
||||
fn get(&self, path: &str, limit: usize) -> Result<Vec<u8>, String> {
|
||||
self.requests.borrow_mut().push(path.into());
|
||||
let bytes = if let Some(name) = path
|
||||
.strip_prefix("/v1/packages/")
|
||||
.and_then(|path| path.strip_suffix("/index.json"))
|
||||
{
|
||||
serde_json::to_vec(&serde_json::json!({"format":"dever-package-index-v1","name":name,"versions":self.indexes.get(name).ok_or("missing test index")?})).unwrap()
|
||||
} else {
|
||||
self.downloads
|
||||
.get(path)
|
||||
.ok_or("missing test archive")?
|
||||
.clone()
|
||||
};
|
||||
assert!(bytes.len() <= limit);
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl Store for Services {
|
||||
fn get(&self, sha256: &str, _bytes: u64) -> Result<Option<Vec<u8>>, String> {
|
||||
Ok(self.cache.borrow().get(sha256).cloned())
|
||||
}
|
||||
fn put(&self, bytes: &[u8]) -> Result<(), String> {
|
||||
self.cache
|
||||
.borrow_mut()
|
||||
.insert(digest(bytes), bytes.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn versions(root: &std::path::Path) -> Vec<(String, String)> {
|
||||
let lock: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(root.join("dever.lock")).unwrap()).unwrap();
|
||||
lock["packages"].as_array().map_or(vec![], |packages| {
|
||||
packages
|
||||
.iter()
|
||||
.map(|package| {
|
||||
(
|
||||
package["name"].as_str().unwrap().into(),
|
||||
package["version"].as_str().unwrap().into(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_backtracks_then_update_and_remove_keep_a_valid_offline_closure() {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = &fixture.directory.0;
|
||||
let mut services = Services::default();
|
||||
services.release("alpha", "1.0.0", &[("common_dep", "^1")]);
|
||||
services.release("alpha", "2.0.0", &[("common_dep", "^2")]);
|
||||
services.release("beta", "1.0.0", &[("common_dep", "^1")]);
|
||||
services.release("common_dep", "1.0.0", &[]);
|
||||
services.release("common_dep", "2.0.0", &[]);
|
||||
dependency::execute_with(
|
||||
"add",
|
||||
root,
|
||||
&["alpha@*".into(), "beta@^1".into()],
|
||||
&services,
|
||||
&services,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
versions(root),
|
||||
vec![
|
||||
("alpha".into(), "1.0.0".into()),
|
||||
("beta".into(), "1.0.0".into()),
|
||||
("common_dep".into(), "1.0.0".into())
|
||||
]
|
||||
);
|
||||
services.requests.borrow_mut().clear();
|
||||
dependency::execute_with("remove", root, &["beta".into()], &services, &services).unwrap();
|
||||
assert!(services.requests.borrow().is_empty());
|
||||
assert_eq!(versions(root)[0].1, "1.0.0");
|
||||
dependency::execute_with("update", root, &[], &services, &services).unwrap();
|
||||
assert_eq!(versions(root)[0].1, "2.0.0");
|
||||
dependency::execute_with("doctor", root, &[], &services, &services).unwrap();
|
||||
write(
|
||||
root,
|
||||
"module/demo/value/app.dever",
|
||||
app("alpha.value.value(common_dep.value.value(input))"),
|
||||
);
|
||||
write(root, "module/demo/value/api.dever", command());
|
||||
Project::load_with_artifacts(root, |sha256, bytes| {
|
||||
Store::get(&services, sha256, bytes).map(Option::unwrap)
|
||||
})
|
||||
.unwrap()
|
||||
.check()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_uses_exact_archives_without_resolving_or_rewriting_and_export_preserves_bytes() {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = &fixture.directory.0;
|
||||
let mut services = Services::default();
|
||||
services.release("alpha", "1.0.0", &[]);
|
||||
dependency::execute_with("add", root, &["alpha@^1".into()], &services, &services).unwrap();
|
||||
let lock = std::fs::read(root.join("dever.lock")).unwrap();
|
||||
let setting = std::fs::read(root.join("config/setting.json")).unwrap();
|
||||
services.cache.borrow_mut().clear();
|
||||
services.requests.borrow_mut().clear();
|
||||
services.indexes.clear();
|
||||
dependency::install_with(root, &services, &services).unwrap();
|
||||
assert_eq!(
|
||||
&*services.requests.borrow(),
|
||||
&["/v1/packages/alpha/1.0.0.zip"]
|
||||
);
|
||||
assert_eq!(std::fs::read(root.join("dever.lock")).unwrap(), lock);
|
||||
assert_eq!(
|
||||
std::fs::read(root.join("config/setting.json")).unwrap(),
|
||||
setting
|
||||
);
|
||||
services.requests.borrow_mut().clear();
|
||||
let output = root.join("export");
|
||||
let report = dependency::export_with(root, "alpha", "assets", &output, &services).unwrap();
|
||||
let report: serde_json::Value = serde_json::from_str(&report).unwrap();
|
||||
let bytes = std::fs::read(output.join("home.html")).unwrap();
|
||||
assert_eq!(bytes, b"<h1>binary \0 resource</h1>");
|
||||
assert_eq!(report["files"][0]["sha256"], digest(&bytes));
|
||||
assert!(
|
||||
dependency::export_with(root, "alpha", "assets", &output, &services)
|
||||
.unwrap_err()
|
||||
.contains("already exists")
|
||||
);
|
||||
assert!(services.requests.borrow().is_empty());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
std::fs::metadata(&output).unwrap().permissions().mode() & 0o777,
|
||||
0o700
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::metadata(output.join("home.html"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_integrity_and_collision_errors_preserve_both_project_inputs() {
|
||||
for failure in ["conflict", "corrupt", "cycle", "collision"] {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = &fixture.directory.0;
|
||||
let mut services = Services::default();
|
||||
services.release("alpha", "1.0.0", &[]);
|
||||
dependency::execute_with("add", root, &["alpha@^1".into()], &services, &services).unwrap();
|
||||
let setting = std::fs::read(root.join("config/setting.json")).unwrap();
|
||||
let lock = std::fs::read(root.join("dever.lock")).unwrap();
|
||||
services.release(
|
||||
"beta",
|
||||
"1.0.0",
|
||||
if failure == "cycle" {
|
||||
&[("beta", "^1")]
|
||||
} else if failure == "conflict" {
|
||||
&[("alpha", "^2")]
|
||||
} else {
|
||||
&[]
|
||||
},
|
||||
);
|
||||
if failure == "corrupt" {
|
||||
services
|
||||
.downloads
|
||||
.get_mut("/v1/packages/beta/1.0.0.zip")
|
||||
.unwrap()[0] ^= 1;
|
||||
}
|
||||
if failure == "collision" {
|
||||
write(root, "module/beta/private.txt", "user content");
|
||||
}
|
||||
assert!(
|
||||
dependency::execute_with("add", root, &["beta@^1".into()], &services, &services)
|
||||
.is_err(),
|
||||
"accepted {failure}"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(root.join("config/setting.json")).unwrap(),
|
||||
setting
|
||||
);
|
||||
assert_eq!(std::fs::read(root.join("dever.lock")).unwrap(), lock);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_cache_is_an_error_not_a_download_and_export_rejects_escaping_paths() {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = &fixture.directory.0;
|
||||
let mut services = Services::default();
|
||||
services.release("alpha", "1.0.0", &[]);
|
||||
dependency::execute_with("add", root, &["alpha@^1".into()], &services, &services).unwrap();
|
||||
for relative in [
|
||||
"../assets",
|
||||
"/assets",
|
||||
"assets//icons",
|
||||
"assets/.",
|
||||
"assets\\x",
|
||||
] {
|
||||
assert!(
|
||||
dependency::export_with(root, "alpha", relative, &root.join("out"), &services).is_err()
|
||||
);
|
||||
}
|
||||
services.requests.borrow_mut().clear();
|
||||
services.cache.borrow_mut().values_mut().next().unwrap()[0] ^= 1;
|
||||
assert!(
|
||||
dependency::install_with(root, &services, &services)
|
||||
.unwrap_err()
|
||||
.contains("SHA-256")
|
||||
);
|
||||
assert!(services.requests.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_transport_rejects_redirect_and_oversize_without_following() {
|
||||
use std::io::Read;
|
||||
for (reply, limit, success) in [
|
||||
(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}",
|
||||
2,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\n123",
|
||||
2,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:1/forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||
8,
|
||||
false,
|
||||
),
|
||||
] {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let origin = format!("http://{}", listener.local_addr().unwrap());
|
||||
let worker = std::thread::spawn(move || {
|
||||
let (mut peer, _) = listener.accept().unwrap();
|
||||
peer.set_read_timeout(Some(std::time::Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let mut received = Vec::new();
|
||||
let mut byte = [0];
|
||||
while !received.ends_with(b"\r\n\r\n") {
|
||||
peer.read_exact(&mut byte).unwrap();
|
||||
received.push(byte[0]);
|
||||
assert!(received.len() < 4096);
|
||||
}
|
||||
assert!(received.starts_with(b"GET /v1/packages/alpha/index.json HTTP/1.1\r\n"));
|
||||
peer.write_all(reply.as_bytes()).unwrap();
|
||||
});
|
||||
let registry = dependency::HttpRegistry::loopback(&origin).unwrap();
|
||||
assert_eq!(
|
||||
registry.get("/v1/packages/alpha/index.json", limit).is_ok(),
|
||||
success
|
||||
);
|
||||
worker.join().unwrap();
|
||||
}
|
||||
assert!(dependency::HttpRegistry::new("http://127.0.0.1:1").is_err());
|
||||
assert!(dependency::HttpRegistry::new("https://example.test/root").is_err());
|
||||
assert!(dependency::HttpRegistry::new("https://secret@example.test").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutation_guard_rejects_busy_and_symlink_locks_without_changing_settings() {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = &fixture.directory.0;
|
||||
let before = std::fs::read(root.join("config/setting.json")).unwrap();
|
||||
let path = root.join(".dever-project.lock");
|
||||
let lock = std::fs::File::create(&path).unwrap();
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
lock.lock().unwrap();
|
||||
let services = Services::default();
|
||||
let error = dependency::execute_with("add", root, &["alpha@^1".into()], &services, &services)
|
||||
.unwrap_err();
|
||||
assert!(error.contains("busy"), "{error}");
|
||||
lock.unlock().unwrap();
|
||||
drop(lock);
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
std::os::unix::fs::symlink("config/setting.json", &path).unwrap();
|
||||
assert!(
|
||||
dependency::execute_with("add", root, &["alpha@^1".into()], &services, &services).is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(root.join("config/setting.json")).unwrap(),
|
||||
before
|
||||
);
|
||||
assert!(services.requests.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_reconciles_changed_requests_and_preserves_unrelated_settings() {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = &fixture.directory.0;
|
||||
let mut services = Services::default();
|
||||
services.release("alpha", "1.0.0", &[]);
|
||||
services.release("alpha", "2.0.0", &[]);
|
||||
dependency::execute_with("add", root, &["alpha@^1".into()], &services, &services).unwrap();
|
||||
write(root, "config/setting.json", br#"{"package":{"registry":"https://packages.example.test","use":["alpha@^2"]},"business":{"secret":"unchanged"}}"#);
|
||||
dependency::execute_with("update", root, &[], &services, &services).unwrap();
|
||||
assert_eq!(versions(root)[0].1, "2.0.0");
|
||||
let setting: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(root.join("config/setting.json")).unwrap()).unwrap();
|
||||
assert_eq!(setting["business"]["secret"], "unchanged");
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use super::{Fixture, app, command};
|
||||
|
||||
#[test]
|
||||
fn locked_documentation_uses_verified_assets_and_retains_source_links() {
|
||||
let mut fixture = Fixture::new(&["numbers@1.0.0"]);
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[],
|
||||
&[
|
||||
("module/numbers/value/app.dever", &app("input + 1")),
|
||||
("module/numbers/value/api.dever", &command()),
|
||||
(
|
||||
"module/numbers/value/doc/value.md",
|
||||
"# 值\n\n## numbers.value.value\n\n返回输入加一。\n",
|
||||
),
|
||||
],
|
||||
);
|
||||
let mut reads = 0;
|
||||
let project = dever::compiler::project::Project::load_with_artifacts(
|
||||
&fixture.directory.0,
|
||||
|sha256, _| {
|
||||
reads += 1;
|
||||
fixture
|
||||
.assets
|
||||
.get(sha256)
|
||||
.cloned()
|
||||
.ok_or_else(|| "missing test asset".into())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(reads, 1);
|
||||
fixture.assets.clear();
|
||||
let mut index = project.inspect().unwrap();
|
||||
assert_eq!(reads, 1);
|
||||
assert!(
|
||||
index.documentation_errors.is_empty(),
|
||||
"{:?}",
|
||||
index.documentation_errors
|
||||
);
|
||||
index.select("numbers.value.value", 1).unwrap();
|
||||
let description = index
|
||||
.documentation
|
||||
.iter()
|
||||
.find(|value| value.symbol == "numbers.value.value")
|
||||
.unwrap();
|
||||
assert_eq!(description.markdown, "返回输入加一。");
|
||||
assert_eq!(index.documentation_files.len(), 1);
|
||||
let file = &index.documentation_files[description.file];
|
||||
assert!(
|
||||
file.path
|
||||
.ends_with("package/module/numbers/value/doc/value.md")
|
||||
);
|
||||
assert!(
|
||||
file.text
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains("## numbers.value.value")
|
||||
);
|
||||
let symbol = index
|
||||
.symbols
|
||||
.iter()
|
||||
.find(|value| value.name == "numbers.value.value")
|
||||
.unwrap();
|
||||
let source = &index.files[symbol.declarations[0].file];
|
||||
assert!(source.text.as_ref().unwrap().contains("input + 1"));
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
use super::super::Directory;
|
||||
use dever::compiler::project::Project;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Cursor, Write};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub(super) struct Entry {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub sha256: String,
|
||||
pub bytes: u64,
|
||||
pub dependencies: Vec<Dependency>,
|
||||
pub manifest_sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub(super) struct Dependency {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
pub(super) struct Fixture {
|
||||
pub directory: Directory,
|
||||
pub entries: Vec<Entry>,
|
||||
pub assets: BTreeMap<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
pub fn new(roots: &[&str]) -> Self {
|
||||
let directory = Directory::new();
|
||||
write(
|
||||
&directory.0,
|
||||
"config/setting.json",
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"package":{"registry":"https://packages.example.test","use":roots}
|
||||
}))
|
||||
.unwrap(),
|
||||
);
|
||||
Self {
|
||||
directory,
|
||||
entries: Vec::new(),
|
||||
assets: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn package(&mut self, name: &str, dependencies: &[(&str, &str)], files: &[(&str, &str)]) {
|
||||
let manifest = serde_json::to_vec(&serde_json::json!({
|
||||
"format":"dever-package-v1", "name":name, "version":"1.0.0",
|
||||
"dependencies":dependencies.iter().copied().collect::<BTreeMap<_, _>>(), "lib":[]
|
||||
}))
|
||||
.unwrap();
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated);
|
||||
zip.start_file("dever-package.json", options).unwrap();
|
||||
zip.write_all(&manifest).unwrap();
|
||||
for &(path, text) in files {
|
||||
zip.start_file(path, options).unwrap();
|
||||
zip.write_all(text.as_bytes()).unwrap();
|
||||
}
|
||||
let bytes = zip.finish().unwrap().into_inner();
|
||||
let entry = Entry {
|
||||
name: name.into(),
|
||||
version: "1.0.0".into(),
|
||||
sha256: digest(&bytes),
|
||||
bytes: bytes.len() as u64,
|
||||
dependencies: dependencies
|
||||
.iter()
|
||||
.map(|(name, _)| Dependency {
|
||||
name: (*name).into(),
|
||||
version: "1.0.0".into(),
|
||||
})
|
||||
.collect(),
|
||||
manifest_sha256: digest(&manifest),
|
||||
};
|
||||
self.assets.insert(entry.sha256.clone(), bytes);
|
||||
self.entries.push(entry);
|
||||
self.lock();
|
||||
}
|
||||
|
||||
pub fn lock(&mut self) {
|
||||
#[derive(Serialize)]
|
||||
struct Lock<'entries> {
|
||||
format: &'static str,
|
||||
toolchain: &'static str,
|
||||
libs: Vec<()>,
|
||||
workers: Vec<()>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
packages: &'entries Vec<Entry>,
|
||||
}
|
||||
self.entries
|
||||
.sort_by(|left, right| left.name.cmp(&right.name));
|
||||
for entry in &mut self.entries {
|
||||
entry
|
||||
.dependencies
|
||||
.sort_by(|left, right| left.name.cmp(&right.name));
|
||||
}
|
||||
let lock = Lock {
|
||||
format: "dever-lock-v6",
|
||||
toolchain: env!("CARGO_PKG_VERSION"),
|
||||
libs: vec![],
|
||||
workers: vec![],
|
||||
packages: &self.entries,
|
||||
};
|
||||
write(
|
||||
&self.directory.0,
|
||||
"dever.lock",
|
||||
serde_json::to_string_pretty(&lock).unwrap() + "\n",
|
||||
);
|
||||
}
|
||||
|
||||
pub fn mutate_archive(&mut self, name: &str, change: impl FnOnce(&mut Vec<u8>)) {
|
||||
let entry = self
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|entry| entry.name == name)
|
||||
.unwrap();
|
||||
let mut bytes = self.assets.remove(&entry.sha256).unwrap();
|
||||
change(&mut bytes);
|
||||
entry.sha256 = digest(&bytes);
|
||||
entry.bytes = bytes.len() as u64;
|
||||
self.assets.insert(entry.sha256.clone(), bytes);
|
||||
self.lock();
|
||||
}
|
||||
|
||||
pub fn load(&self) -> Result<Project, String> {
|
||||
Project::load_with_artifacts(&self.directory.0, |sha256, _| {
|
||||
self.assets
|
||||
.get(sha256)
|
||||
.cloned()
|
||||
.ok_or_else(|| "locked asset is missing".into())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reject(&self, needle: &str) {
|
||||
let error = self.load().err().expect("invalid dependencies accepted");
|
||||
assert!(error.contains(needle), "expected {needle}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn digest(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
pub(super) fn write(root: &Path, relative: &str, contents: impl AsRef<[u8]>) {
|
||||
let path = root.join(relative);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, contents).unwrap();
|
||||
}
|
||||
|
||||
pub(super) fn app(expression: &str) -> String {
|
||||
format!("value(input: Int) (answer: Int) {{ answer = {expression} }}\n")
|
||||
}
|
||||
|
||||
pub(super) fn command() -> String {
|
||||
"cmd value = app.value\n".to_owned()
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! 真实 CLI 参数分派及 Package/Lib 双向锁合并,不安装全局运行时。
|
||||
|
||||
use super::super::commands::Services;
|
||||
use super::python_fixture::Python;
|
||||
use dever::compiler::project::dependency::{self, Lock};
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn package_changes_preserve_lib_closure_and_lib_changes_preserve_packages() {
|
||||
let mut python = Python::new();
|
||||
python.transport.release("alpha", "1.0.0", "");
|
||||
python.transport.release("beta", "1.0.0", "");
|
||||
python.command("add", &["pip:alpha@1.0.0"]).unwrap();
|
||||
let before = python.lock();
|
||||
let mut packages = Services::default();
|
||||
packages.release("numbers", "1.0.0", &[]);
|
||||
packages.release("numbers", "2.0.0", &[]);
|
||||
let root = &python.fixture.directory.0;
|
||||
for (action, specs, version) in [
|
||||
("add", vec!["numbers@^1".into()], "1.0.0"),
|
||||
("update", vec!["numbers@^2".into()], "2.0.0"),
|
||||
] {
|
||||
dependency::execute_with(action, root, &specs, &packages, &python.store).unwrap();
|
||||
let lock = python.lock();
|
||||
assert!(lock.libs == before.libs);
|
||||
assert_eq!(lock.packages[0].version, version);
|
||||
}
|
||||
let packages_before = serde_json::to_value(python.lock().packages).unwrap();
|
||||
python.command("add", &["pip:beta@1.0.0"]).unwrap();
|
||||
python.command("remove", &["pip:alpha@1.0.0"]).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(python.lock().packages).unwrap(),
|
||||
packages_before
|
||||
);
|
||||
dependency::execute_with(
|
||||
"remove",
|
||||
root,
|
||||
&["numbers".into()],
|
||||
&packages,
|
||||
&python.store,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(python.lock().packages.is_empty());
|
||||
assert_eq!(python.lock().libs[0].spec.name, "beta");
|
||||
python.command("doctor", &[]).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_cli_routes_lib_install_doctor_list_and_rejects_bad_target_arguments() {
|
||||
let python = Python::new();
|
||||
let root = &python.fixture.directory.0;
|
||||
std::fs::write(root.join("dever.lock"), Lock::new(vec![]).encode().unwrap()).unwrap();
|
||||
let lock = std::fs::read(root.join("dever.lock")).unwrap();
|
||||
let setting = std::fs::read(root.join("config/setting.json")).unwrap();
|
||||
for action in ["doctor", "list", "install"] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.env_clear()
|
||||
.arg("lib")
|
||||
.arg(action)
|
||||
.arg(root)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{action}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
if action != "list" {
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("validated 0 Lib(s)"));
|
||||
}
|
||||
if action != "install" {
|
||||
assert!(!root.join(".dever-project.lock").exists());
|
||||
}
|
||||
}
|
||||
for arguments in [
|
||||
vec!["list", "--target", "linux-x86_64"],
|
||||
vec!["doctor", "--target"],
|
||||
vec!["doctor", "--target", "invalid-target"],
|
||||
vec![
|
||||
"doctor",
|
||||
"--target",
|
||||
"linux-x86_64",
|
||||
"--target",
|
||||
"linux-x86_64",
|
||||
],
|
||||
vec!["install", "pip:unexpected@1.0.0"],
|
||||
] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.env_clear()
|
||||
.arg("lib")
|
||||
.arg(arguments[0])
|
||||
.arg(root)
|
||||
.args(&arguments[1..])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success(), "accepted {arguments:?}");
|
||||
}
|
||||
assert_eq!(std::fs::read(root.join("dever.lock")).unwrap(), lock);
|
||||
assert_eq!(
|
||||
std::fs::read(root.join("config/setting.json")).unwrap(),
|
||||
setting
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_registry_rejects_ambiguous_urls_before_transport() {
|
||||
use dependency::library::registry::official;
|
||||
assert!(official("https://proxy.golang.org/example.com/mod/@v/v1.0.0.zip").is_ok());
|
||||
for value in [
|
||||
"https://pypi.org:443/pypi/a/json",
|
||||
"https://@pypi.org/pypi/a/json",
|
||||
"https://pypi.org/x/%2e%2e/pypi/a/json",
|
||||
"https://pypi.org/x%2fpypi/a/json",
|
||||
"https://pypi.org/../pypi/a/json",
|
||||
"https://pypi.org/pypi/a/json?secret=value",
|
||||
"https://pypi.org/pypi/a/json#fragment",
|
||||
"https://pypi.org.evil.test/pypi/a/json",
|
||||
" http://pypi.org/pypi/a/json",
|
||||
"https://pypi.org\\evil.test/pypi/a/json",
|
||||
] {
|
||||
assert!(official(value).is_err(), "accepted ambiguous registry URL");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! 自有签名、归档和 Lib 输入验证;不连接生产 deverd 或修改机器缓存。
|
||||
|
||||
mod central;
|
||||
mod pep517;
|
||||
mod pep517_fixture;
|
||||
mod python;
|
||||
mod python_fixture;
|
||||
|
||||
use super::{Fixture, fixture::digest, write};
|
||||
use dever::compiler::project::dependency::{
|
||||
library::{Spec, archive},
|
||||
worker::signed::SignedResources,
|
||||
};
|
||||
use ring::signature::{Ed25519KeyPair, KeyPair};
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn lib_identities_normalize_extras_and_reject_ranges_and_path_injection() {
|
||||
assert_eq!(
|
||||
"pip:Some_Name[TEST,docs]@1.2.3"
|
||||
.parse::<Spec>()
|
||||
.unwrap()
|
||||
.key(),
|
||||
"pip:some-name[docs,test]@1.2.3"
|
||||
);
|
||||
assert_eq!(
|
||||
"go:example.org/widget@v1.2.3"
|
||||
.parse::<Spec>()
|
||||
.unwrap()
|
||||
.version,
|
||||
"1.2.3"
|
||||
);
|
||||
assert_eq!(
|
||||
"npm:@scope/name@1.2.3".parse::<Spec>().unwrap().name,
|
||||
"@scope/name"
|
||||
);
|
||||
for value in [
|
||||
"npm:thing@^1",
|
||||
"npm:../thing@1.2.3",
|
||||
"pip:thing[]@1",
|
||||
"pip:thing@>=1",
|
||||
"go:../thing@1.2.3",
|
||||
"npm:@scope/../thing@1.2.3",
|
||||
] {
|
||||
assert!(value.parse::<Spec>().is_err(), "accepted {value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_resources_require_independent_signature_and_exact_catalog_bytes() {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = fixture.directory.0.join("installation");
|
||||
let key = Ed25519KeyPair::from_seed_unchecked(&[91; 32]).unwrap();
|
||||
let hex = |bytes: &[u8]| {
|
||||
bytes
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
};
|
||||
write(
|
||||
&root,
|
||||
"state/trusted-release-key",
|
||||
hex(key.public_key().as_ref()),
|
||||
);
|
||||
let bytes = b"signed fixture contents";
|
||||
let path = "runtime/npm/linux-x86_64/runtime.pack";
|
||||
let target = dever::compiler::toolchain::BuildTarget::host().unwrap();
|
||||
let catalog = serde_json::to_vec(&serde_json::json!({"format":"dever-release-v2","version":"0.0.1","platform":target.name(),"artifacts":[{"path":"base.txt","bytes":bytes.len(),"sha256":digest(bytes)}],"extensions":[{"kind":{"type":"runtime","ecosystem":"npm"},"target":"linux-x86_64","artifacts":[{"path":path,"bytes":bytes.len(),"sha256":digest(bytes)}]}]})).unwrap();
|
||||
write(&root, "versions/0.0.1/base.txt", bytes);
|
||||
write(&root, "versions/0.0.1/manifest.json", &catalog);
|
||||
write(
|
||||
&root,
|
||||
"versions/0.0.1/manifest.sig",
|
||||
hex(key.sign(&catalog).as_ref()),
|
||||
);
|
||||
let signed = SignedResources::load(&root, "0.0.1").unwrap();
|
||||
assert_eq!(signed.read("base.txt").unwrap(), bytes);
|
||||
let actual = signed.read_with(path, |version, catalog_hash, extension, artifact| {
|
||||
assert_eq!(version, "0.0.1");
|
||||
assert_eq!(catalog_hash, digest(&catalog));
|
||||
assert_eq!(extension, &serde_json::json!({"kind":{"type":"runtime","ecosystem":"npm"},"target":"linux-x86_64"}));
|
||||
assert_eq!(artifact.path, path);
|
||||
Ok(bytes.to_vec())
|
||||
}).unwrap();
|
||||
assert_eq!(actual, bytes);
|
||||
assert!(
|
||||
signed
|
||||
.read_with(path, |_, _, _, _| Ok(b"changed".to_vec()))
|
||||
.is_err()
|
||||
);
|
||||
write(&root, "versions/0.0.1/base.txt", b"different signed file");
|
||||
assert!(signed.read("base.txt").is_err());
|
||||
write(&root, "versions/0.0.1/manifest.json", b"{}");
|
||||
assert!(SignedResources::load(&root, "0.0.1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_consumption_rejects_links_and_file_directory_collisions() {
|
||||
let make = |entries: &[(&str, &[u8], tar::EntryType)]| {
|
||||
let gzip = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
let mut archive = tar::Builder::new(gzip);
|
||||
for (path, bytes, kind) in entries {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_entry_type(*kind);
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_mode(0o755);
|
||||
header.set_cksum();
|
||||
archive.append_data(&mut header, path, *bytes).unwrap();
|
||||
}
|
||||
archive.into_inner().unwrap().finish().unwrap()
|
||||
};
|
||||
let bytes = make(&[(
|
||||
"package/main.js",
|
||||
b"export const value = 42;",
|
||||
tar::EntryType::Regular,
|
||||
)]);
|
||||
assert_eq!(archive::files(&bytes, false).unwrap().len(), 1);
|
||||
assert!(archive::entries(&bytes, false).unwrap()["package/main.js"].1);
|
||||
let bytes = make(&[("package/main.js", b"", tar::EntryType::Symlink)]);
|
||||
assert!(archive::files(&bytes, false).unwrap_err().contains("links"));
|
||||
let bytes = make(&[
|
||||
("package", b"not a directory", tar::EntryType::Regular),
|
||||
("package/main.js", b"", tar::EntryType::Regular),
|
||||
]);
|
||||
assert!(
|
||||
archive::files(&bytes, false)
|
||||
.unwrap_err()
|
||||
.contains("collision")
|
||||
);
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
zip.start_file("../escape", zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(b"bad").unwrap();
|
||||
assert!(archive::files(&zip.finish().unwrap().into_inner(), true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lib_zip_rejects_duplicate_central_paths() {
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for name in ["package/first", "package/other"] {
|
||||
zip.start_file(
|
||||
name,
|
||||
zip::write::SimpleFileOptions::default().unix_permissions(0o755),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(name.as_bytes()).unwrap();
|
||||
}
|
||||
let mut bytes = zip.finish().unwrap().into_inner();
|
||||
let files = archive::entries(&bytes, true).unwrap();
|
||||
assert_eq!(files.len(), 2);
|
||||
assert!(files["package/first"].1);
|
||||
let mut cursor = 0;
|
||||
while cursor + 5 <= bytes.len() {
|
||||
if &bytes[cursor..cursor + 5] == b"other" {
|
||||
bytes[cursor..cursor + 5].copy_from_slice(b"first");
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
assert!(
|
||||
archive::entries(&bytes, true)
|
||||
.unwrap_err()
|
||||
.contains("duplicate or inconsistent")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_wheel_identity_abi_and_dependency_edges_are_checked_before_installation() {
|
||||
let bytes = python_fixture::wheel("tiny", "1.0.0", "");
|
||||
let lib = serde_json::from_value(serde_json::json!({"spec":{"ecosystem":"pip","name":"tiny","version":"1.0.0"},"dependencies":[],"runtime":{"name":"python","version":"3.12.1","sha256":"0".repeat(64)},"artifacts":[],"schema":"pypi-wheel-v1"})).unwrap();
|
||||
let markers = serde_json::json!({"implementation_name":"cpython","implementation_version":"3.12.1","os_name":"posix","platform_machine":"x86_64","platform_python_implementation":"CPython","platform_release":"","platform_system":"Linux","platform_version":"","python_full_version":"3.12.1","python_version":"3.12","sys_platform":"linux"});
|
||||
let mut runtime = serde_json::from_value::<dever::compiler::project::dependency::library::runtime::Manifest>(serde_json::json!({"format":"dever-worker-runtime-v1","ecosystem":"pip","target":"linux-x86_64","executable":"bin/python3","arguments":["-I","-S","-B"],"python_wheel_tags":["py3-none-any"],"python_extension_suffixes":[".so"],"python_markers":markers})).unwrap();
|
||||
let files =
|
||||
dever::compiler::project::dependency::library::python::install(&lib, &bytes, &runtime, &[])
|
||||
.unwrap();
|
||||
assert_eq!(files["tiny/__init__.py"], b"VALUE = 42\n");
|
||||
runtime.python_wheel_tags.clear();
|
||||
assert!(
|
||||
dever::compiler::project::dependency::library::python::install(&lib, &bytes, &runtime, &[])
|
||||
.unwrap_err()
|
||||
.contains("tags")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! 源码 hook 真正执行签名 Python/C 工具;安装回放与失败均保持原始项目文件。
|
||||
|
||||
use super::{pep517_fixture, python_fixture::Python};
|
||||
use dever::compiler::project::dependency::library::lock::receipt::Receipt;
|
||||
|
||||
#[test]
|
||||
fn pep517_native_build_and_exact_install_replay_use_locked_static_dynamic_inputs() {
|
||||
let mut fixture = Python::new();
|
||||
pep517_fixture::prepare(&mut fixture, "stable");
|
||||
fixture.command("add", &["pip:built-native@1.0.0"]).unwrap();
|
||||
let lock = fixture.lock();
|
||||
assert_eq!(
|
||||
lock.libs
|
||||
.iter()
|
||||
.map(|lib| lib.spec.key())
|
||||
.collect::<Vec<_>>(),
|
||||
["pip:built-native@1.0.0", "pip:runtime-helper@1.0.0"]
|
||||
);
|
||||
let receipt = Receipt::parse(&lock.builds[0]).unwrap();
|
||||
assert_eq!(
|
||||
receipt
|
||||
.inputs
|
||||
.libs
|
||||
.iter()
|
||||
.map(|lib| lib.spec.key())
|
||||
.collect::<Vec<_>>(),
|
||||
["pip:build-helper@1.0.0", "pip:dynamic-helper@1.0.0"]
|
||||
);
|
||||
assert_eq!(receipt.dynamic_requires, ["dynamic-helper==1.0.0"]);
|
||||
let before = snapshot(&fixture);
|
||||
let original = fixture
|
||||
.store
|
||||
.0
|
||||
.borrow_mut()
|
||||
.remove(&receipt.output.sha256)
|
||||
.unwrap();
|
||||
fixture.transport.requests.borrow_mut().clear();
|
||||
fixture.command("install", &[]).unwrap();
|
||||
assert_eq!(fixture.store.0.borrow()[&receipt.output.sha256], original);
|
||||
assert!(fixture.transport.requests.borrow().is_empty());
|
||||
assert_eq!(snapshot(&fixture), before);
|
||||
fixture.command("doctor", &[]).unwrap();
|
||||
assert_eq!(snapshot(&fixture), before);
|
||||
// 完整冷缓存恢复只下载固定 source/wheel;不请求任何版本 metadata。
|
||||
fixture.store.0.borrow_mut().clear();
|
||||
fixture.command("install", &[]).unwrap();
|
||||
assert!(
|
||||
fixture
|
||||
.transport
|
||||
.requests
|
||||
.borrow()
|
||||
.iter()
|
||||
.all(|url| url.starts_with("https://files.pythonhosted.org/"))
|
||||
);
|
||||
assert_eq!(fixture.store.0.borrow()[&receipt.output.sha256], original);
|
||||
assert_eq!(snapshot(&fixture), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pep517_changed_metadata_and_exit10_preserve_previous_project() {
|
||||
for mode in ["mismatch", "exit10"] {
|
||||
let mut fixture = Python::new();
|
||||
pep517_fixture::prepare(&mut fixture, mode);
|
||||
fixture
|
||||
.command("add", &["pip:runtime-helper@1.0.0"])
|
||||
.unwrap();
|
||||
let before = snapshot(&fixture);
|
||||
let error = fixture
|
||||
.command("add", &["pip:built-native@1.0.0"])
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.contains(if mode == "mismatch" {
|
||||
"prepared metadata"
|
||||
} else {
|
||||
"expected PEP517 exit10"
|
||||
}),
|
||||
"{error}"
|
||||
);
|
||||
assert_eq!(snapshot(&fixture), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pep517_nonreproducible_replay_preserves_lock_and_does_not_publish_replacement() {
|
||||
let mut fixture = Python::new();
|
||||
pep517_fixture::prepare(&mut fixture, "unstable");
|
||||
fixture.command("add", &["pip:built-native@1.0.0"]).unwrap();
|
||||
let lock = fixture.lock();
|
||||
let receipt = Receipt::parse(&lock.builds[0]).unwrap();
|
||||
fixture.store.0.borrow_mut().remove(&receipt.output.sha256);
|
||||
let before = snapshot(&fixture);
|
||||
let cache = fixture.store.0.borrow().clone();
|
||||
assert!(
|
||||
fixture
|
||||
.command("install", &[])
|
||||
.unwrap_err()
|
||||
.contains("not byte reproducible")
|
||||
);
|
||||
assert_eq!(snapshot(&fixture), before);
|
||||
assert_eq!(*fixture.store.0.borrow(), cache);
|
||||
}
|
||||
|
||||
fn snapshot(fixture: &Python) -> [Vec<u8>; 2] {
|
||||
let root = &fixture.fixture.directory.0;
|
||||
[
|
||||
std::fs::read(root.join("config/setting.json")).unwrap(),
|
||||
std::fs::read(root.join("dever.lock")).unwrap(),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Owned test backend: separate hooks, exact build dependencies, real C compilation."""
|
||||
|
||||
import base64
|
||||
import ctypes
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import time
|
||||
import zipfile
|
||||
|
||||
NAME = "built_native"
|
||||
VERSION = "1.0.0"
|
||||
INFO = NAME + "-" + VERSION + ".dist-info"
|
||||
META = "Metadata-Version: 2.1\nName: built-native\nVersion: 1.0.0\nRequires-Dist: runtime-helper>=1\n\n"
|
||||
|
||||
|
||||
def get_requires_for_build_wheel(settings):
|
||||
import build_helper
|
||||
assert build_helper.VALUE == 42
|
||||
assert subprocess.check_output(["build-probe"]).strip() == b"signed-console-42"
|
||||
return ["dynamic-helper==1.0.0"]
|
||||
|
||||
|
||||
def prepare_metadata_for_build_wheel(directory, settings):
|
||||
import dynamic_helper
|
||||
assert dynamic_helper.VALUE == 42
|
||||
assert not Path("hook-was-here").exists()
|
||||
Path("hook-was-here").write_text("metadata hook must run separately")
|
||||
target = Path(directory) / INFO
|
||||
target.mkdir()
|
||||
(target / "METADATA").write_text(META)
|
||||
return INFO
|
||||
|
||||
|
||||
def build_wheel(directory, settings, metadata_directory=None):
|
||||
import dynamic_helper
|
||||
assert dynamic_helper.VALUE == 42
|
||||
assert not Path("hook-was-here").exists()
|
||||
assert metadata_directory and (Path(metadata_directory) / "METADATA").read_text() == META
|
||||
mode = Path("mode.txt").read_text().strip()
|
||||
source = Path("native.c")
|
||||
source.write_text("int answer(void) { return 42; }\n")
|
||||
native = Path("native.so").resolve()
|
||||
compiler = shlex.split(os.environ["CC"])
|
||||
tools = Path(compiler[0]).parent
|
||||
subprocess.run(compiler + ["-B" + str(tools) + "/", "-ffreestanding", "-fno-use-linker-plugin", "-fno-lto", "-shared", "-fPIC", "-nostdlib", str(source), "-o", str(native)], check=True)
|
||||
assert ctypes.CDLL(str(native)).answer() == 42
|
||||
assert subprocess.check_output(["signed-pack-probe", "42"]) == b"signed-tool-42\n"
|
||||
files = {
|
||||
NAME + "/native.so": native.read_bytes(),
|
||||
NAME + "/__init__.py": b"VALUE = 42\n",
|
||||
INFO + "/METADATA": (META + ("Changed: yes\n" if mode == "mismatch" else "")).encode(),
|
||||
INFO + "/WHEEL": b"Wheel-Version: 1.0\nRoot-Is-Purelib: false\nTag: py3-none-linux_x86_64\n",
|
||||
}
|
||||
if mode == "unstable":
|
||||
files[NAME + "/nondeterministic.txt"] = str(time.time_ns()).encode()
|
||||
record = "".join(path + ",sha256=" + base64.urlsafe_b64encode(hashlib.sha256(value).digest()).decode().rstrip("=") + "," + str(len(value)) + "\n" for path, value in sorted(files.items()))
|
||||
files[INFO + "/RECORD"] = (record + INFO + "/RECORD,,\n").encode()
|
||||
filename = NAME + "-" + VERSION + "-py3-none-linux_x86_64.whl"
|
||||
with zipfile.ZipFile(Path(directory) / filename, "w") as archive:
|
||||
for path, value in sorted(files.items()):
|
||||
archive.writestr(zipfile.ZipInfo(path, date_time=(1980, 1, 1, 0, 0, 0)), value)
|
||||
if mode == "exit10":
|
||||
print("expected PEP517 exit10", flush=True)
|
||||
os._exit(10)
|
||||
return filename
|
||||
@@ -0,0 +1,187 @@
|
||||
//! 仅测试读取 dev Pod 工具构造签名 pack;生产路径没有这些工具定位逻辑。
|
||||
|
||||
use super::super::{
|
||||
Fixture,
|
||||
fixture::{digest, write},
|
||||
};
|
||||
use super::python_fixture::Python;
|
||||
use dever::compiler::project::dependency::worker::signed::SignedResources;
|
||||
use ring::signature::{Ed25519KeyPair, KeyPair};
|
||||
use serde_json::json;
|
||||
use std::{collections::BTreeMap, fs, path::Path, process::Command, sync::OnceLock};
|
||||
|
||||
pub(super) fn prepare(fixture: &mut Python, mode: &str) {
|
||||
static ASSETS: OnceLock<BTreeMap<String, Vec<u8>>> = OnceLock::new();
|
||||
fixture.signed = signed(&fixture.fixture, ASSETS.get_or_init(assets));
|
||||
let helper = super::python_fixture::wheel_with_files(
|
||||
"build-helper",
|
||||
"1.0.0",
|
||||
"",
|
||||
BTreeMap::from([
|
||||
(
|
||||
"build_helper/probe.py".into(),
|
||||
b"def main():\n print('signed-console-42')\n".to_vec(),
|
||||
),
|
||||
(
|
||||
"build_helper-1.0.0.dist-info/entry_points.txt".into(),
|
||||
b"[console_scripts]\nbuild-probe = build_helper.probe:main\n".to_vec(),
|
||||
),
|
||||
]),
|
||||
);
|
||||
fixture
|
||||
.transport
|
||||
.release_wheel("build-helper", "1.0.0", helper);
|
||||
fixture.transport.release("dynamic-helper", "1.0.0", "");
|
||||
fixture.transport.release("runtime-helper", "1.0.0", "");
|
||||
let source = super::python_fixture::zip(BTreeMap::from([
|
||||
("built-native-1.0.0/pyproject.toml".into(), b"[build-system]\nrequires = ['build-helper>=1']\nbuild-backend = 'backend'\nbackend-path = ['.']\n".to_vec()),
|
||||
("built-native-1.0.0/backend.py".into(), include_bytes!("pep517_backend.py").to_vec()),
|
||||
("built-native-1.0.0/mode.txt".into(), mode.as_bytes().to_vec()),
|
||||
]));
|
||||
let url = "https://files.pythonhosted.org/packages/built-native-1.0.0.zip";
|
||||
fixture.transport.files.insert(url.into(), source.clone());
|
||||
fixture.transport.files.insert(
|
||||
"https://pypi.org/pypi/built-native/json".into(),
|
||||
serde_json::to_vec(&json!({"releases":{"1.0.0":[]}})).unwrap(),
|
||||
);
|
||||
fixture.transport.files.insert("https://pypi.org/pypi/built-native/1.0.0/json".into(), serde_json::to_vec(&json!({"urls":[{"packagetype":"sdist","filename":"built-native-1.0.0.zip","url":url,"digests":{"sha256":digest(&source)},"size":source.len()}]})).unwrap());
|
||||
}
|
||||
|
||||
fn assets() -> BTreeMap<String, Vec<u8>> {
|
||||
let details = Command::new("/usr/bin/python3").args(["-I", "-S", "-B", "-c", "import sys,sysconfig,json;print(json.dumps([sys.version.split()[0],sysconfig.get_path('stdlib')]))"]).output().unwrap();
|
||||
assert!(details.status.success());
|
||||
let values: Vec<String> = serde_json::from_slice(&details.stdout).unwrap();
|
||||
let version = &values[0];
|
||||
let short = version.rsplit_once('.').unwrap().0;
|
||||
let markers = json!({"implementation_name":"cpython","implementation_version":version,"os_name":"posix","platform_machine":"x86_64","platform_python_implementation":"CPython","platform_release":"","platform_system":"Linux","platform_version":"","python_full_version":version,"python_version":short,"sys_platform":"linux"});
|
||||
let tags = ["py3-none-any", "py3-none-linux_x86_64"];
|
||||
let mut runtime = BTreeMap::new();
|
||||
runtime.insert("bin/python3".into(), fs::read("/usr/bin/python3").unwrap());
|
||||
files(
|
||||
Path::new(&values[1]),
|
||||
&format!("lib/python{short}/"),
|
||||
&mut runtime,
|
||||
);
|
||||
runtime.insert("dever-runtime.json".into(), serde_json::to_vec(&json!({"format":"dever-worker-runtime-v1","ecosystem":"pip","target":"linux-x86_64","executable":"bin/python3","arguments":["-I","-S","-B"],"python_wheel_tags":tags,"python_extension_suffixes":[".so"],"python_markers":markers})).unwrap());
|
||||
let mut libraries = BTreeMap::new();
|
||||
elf_libraries(Path::new("/usr/bin/python3"), &mut libraries);
|
||||
for entry in fs::read_dir(Path::new(&values[1]).join("lib-dynload")).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
if entry.path().extension().is_some_and(|value| value == "so") {
|
||||
elf_libraries(&entry.path(), &mut libraries);
|
||||
}
|
||||
}
|
||||
let runtime = archive(&runtime);
|
||||
let identity = json!({"name":"cpython","version":version,"sha256":digest(&runtime)});
|
||||
let mut tools = BTreeMap::new();
|
||||
let mut executables = Vec::new();
|
||||
for (name, path) in [
|
||||
("sh", "/bin/sh"),
|
||||
("cc", "/usr/bin/cc"),
|
||||
("c++", "/usr/bin/c++"),
|
||||
("as", "/usr/bin/as"),
|
||||
("ld", "/usr/bin/ld"),
|
||||
("ar", "/usr/bin/ar"),
|
||||
("ranlib", "/usr/bin/ranlib"),
|
||||
("make", "/usr/bin/make"),
|
||||
("cc1", "/usr/libexec/gcc/x86_64-linux-gnu/14/cc1"),
|
||||
("collect2", "/usr/libexec/gcc/x86_64-linux-gnu/14/collect2"),
|
||||
] {
|
||||
let relative = format!("rootfs/usr/bin/{name}");
|
||||
tools.insert(relative.clone(), fs::read(path).unwrap());
|
||||
executables.push(relative);
|
||||
elf_libraries(Path::new(path), &mut libraries);
|
||||
}
|
||||
let script = "rootfs/usr/bin/signed-pack-probe";
|
||||
tools.insert(
|
||||
script.into(),
|
||||
b"#!/bin/sh\nprintf 'signed-tool-%s\\n' \"$1\"\n".to_vec(),
|
||||
);
|
||||
executables.push(script.into());
|
||||
tools.insert("dever-build.json".into(), serde_json::to_vec(&json!({"format":"dever-lib-build-v1","ecosystem":"pip","target":"linux-x86_64","runtime_sha256":identity["sha256"],"python_runtime_sha256":null,"executables":executables})).unwrap());
|
||||
let tools = archive(&tools);
|
||||
let mut assets = BTreeMap::from([
|
||||
("runtime/pip/linux-x86_64/manifest.json".into(), serde_json::to_vec(&json!({"format":"dever-registry-runtime-v1","runtime":{"pack":identity,"target":"linux-x86_64","python_markers":markers,"python_wheel_tags":tags}})).unwrap()),
|
||||
("runtime/pip/linux-x86_64/runtime.pack".into(), runtime),
|
||||
("build/pip/linux-x86_64/manifest.json".into(), serde_json::to_vec(&json!({"format":"dever-registry-build-v1","ecosystem":"pip","target":"linux-x86_64","runtime_sha256":identity["sha256"],"python_runtime_sha256":null,"sha256":digest(&tools)})).unwrap()),
|
||||
("build/pip/linux-x86_64/build.pack".into(), tools),
|
||||
]);
|
||||
for (name, bytes) in libraries {
|
||||
assets.insert(format!("process-runtime/linux-x86_64/lib/{name}"), bytes);
|
||||
}
|
||||
assets
|
||||
}
|
||||
|
||||
fn files(root: &Path, prefix: &str, output: &mut BTreeMap<String, Vec<u8>>) {
|
||||
for entry in fs::read_dir(root).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let name = entry.file_name().into_string().unwrap();
|
||||
if name == "__pycache__" || name == "site-packages" || name == "test" {
|
||||
continue;
|
||||
}
|
||||
if entry.file_type().unwrap().is_dir() {
|
||||
files(&entry.path(), &format!("{prefix}{name}/"), output);
|
||||
} else if entry.file_type().unwrap().is_file() {
|
||||
output.insert(format!("{prefix}{name}"), fs::read(entry.path()).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn elf_libraries(program: &Path, output: &mut BTreeMap<String, Vec<u8>>) {
|
||||
let result = Command::new("/usr/bin/ldd").arg(program).output().unwrap();
|
||||
assert!(result.status.success(), "fixture ldd {}", program.display());
|
||||
for name in String::from_utf8(result.stdout)
|
||||
.unwrap()
|
||||
.split_whitespace()
|
||||
.filter(|value| value.starts_with('/'))
|
||||
{
|
||||
let path = Path::new(name);
|
||||
let name = path.file_name().unwrap().to_str().unwrap().to_owned();
|
||||
let bytes = fs::read(path).unwrap();
|
||||
if let Some(previous) = output.insert(name, bytes.clone()) {
|
||||
assert_eq!(previous, bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn archive(files: &BTreeMap<String, Vec<u8>>) -> Vec<u8> {
|
||||
let gzip = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
let mut archive = tar::Builder::new(gzip);
|
||||
for (path, bytes) in files {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_mode(0o755);
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_cksum();
|
||||
archive
|
||||
.append_data(&mut header, path, bytes.as_slice())
|
||||
.unwrap();
|
||||
}
|
||||
archive.into_inner().unwrap().finish().unwrap()
|
||||
}
|
||||
|
||||
fn signed(fixture: &Fixture, assets: &BTreeMap<String, Vec<u8>>) -> SignedResources {
|
||||
let root = fixture.directory.0.join("pep517-signed");
|
||||
let key = Ed25519KeyPair::from_seed_unchecked(&[82; 32]).unwrap();
|
||||
let hex = |bytes: &[u8]| {
|
||||
bytes
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
};
|
||||
let catalog = serde_json::to_vec(&json!({"format":"dever-release-v2","version":"0.0.1","platform":"linux-x86_64","artifacts":assets.iter().map(|(path, bytes)| json!({"path":path,"bytes":bytes.len(),"sha256":digest(bytes)})).collect::<Vec<_>>(),"extensions":[]})).unwrap();
|
||||
write(
|
||||
&root,
|
||||
"state/trusted-release-key",
|
||||
hex(key.public_key().as_ref()),
|
||||
);
|
||||
for (path, bytes) in assets {
|
||||
write(&root, &format!("versions/0.0.1/{path}"), bytes);
|
||||
}
|
||||
write(&root, "versions/0.0.1/manifest.json", &catalog);
|
||||
write(
|
||||
&root,
|
||||
"versions/0.0.1/manifest.sig",
|
||||
hex(key.sign(&catalog).as_ref()),
|
||||
);
|
||||
SignedResources::load(&root, "0.0.1").unwrap()
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
//! 真实 wheel、回环元数据及命令发布回归,不以字符串匹配代替完整闭包与文件校验。
|
||||
|
||||
use super::python_fixture::{Python, wheel, zip};
|
||||
use dever::compiler::project::dependency::{
|
||||
Lock,
|
||||
library::{self, python},
|
||||
};
|
||||
use dever::compiler::toolchain::BuildTarget;
|
||||
|
||||
#[test]
|
||||
fn python_resolves_wheel_metadata_extras_markers_and_backtracks() {
|
||||
let mut fixture = Python::new();
|
||||
fixture.transport.release("alpha", "1.0.0", "Requires-Dist: common>=1,<3\nRequires-Dist: helper; extra == 'test'\nProvides-Extra: test\nRequires-Dist: windows-only; sys_platform == 'win32'\n");
|
||||
fixture
|
||||
.transport
|
||||
.release("common", "2.0.0", "Requires-Dist: helper>=2\n");
|
||||
fixture
|
||||
.transport
|
||||
.release("common", "1.0.0", "Requires-Dist: helper<2\n");
|
||||
fixture.transport.release("helper", "1.0.0", "");
|
||||
fixture.transport.release("helper", "2.0.0", "");
|
||||
fixture
|
||||
.command("add", &["pip:alpha[test]@1.0.0", "pip:helper@1.0.0"])
|
||||
.unwrap();
|
||||
let lock = fixture.lock();
|
||||
assert_eq!(
|
||||
lock.libs
|
||||
.iter()
|
||||
.map(|lib| lib.spec.key())
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
"pip:alpha[test]@1.0.0",
|
||||
"pip:common@1.0.0",
|
||||
"pip:helper@1.0.0"
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.transport
|
||||
.requests
|
||||
.borrow()
|
||||
.iter()
|
||||
.all(|url| !url.contains("wrong-registry-summary") && !url.contains("windows-only"))
|
||||
);
|
||||
let metadata = fixture.runtime();
|
||||
let runtime = library::runtime::load(
|
||||
&fixture.signed,
|
||||
"pip",
|
||||
BuildTarget::host().unwrap(),
|
||||
&metadata.pack,
|
||||
)
|
||||
.unwrap();
|
||||
for lib in &lock.libs {
|
||||
let files = python::install(
|
||||
lib,
|
||||
&fixture.store.0.borrow()[&lib.artifacts[0].sha256],
|
||||
&runtime.manifest,
|
||||
&lock.libs,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(files.keys().any(|path| path.ends_with("__init__.py")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_update_preserves_other_roots_and_exact_install_never_resolves() {
|
||||
let mut fixture = Python::new();
|
||||
for (name, version) in [("alpha", "1.0.0"), ("alpha", "2.0.0"), ("beta", "1.0.0")] {
|
||||
fixture.transport.release(name, version, "");
|
||||
}
|
||||
fixture
|
||||
.command("add", &["pip:alpha@1.0.0", "pip:beta@1.0.0"])
|
||||
.unwrap();
|
||||
fixture.command("update", &["pip:alpha@2.0.0"]).unwrap();
|
||||
let lock = fixture.lock();
|
||||
assert_eq!(
|
||||
lock.libs
|
||||
.iter()
|
||||
.map(|lib| lib.spec.key())
|
||||
.collect::<Vec<_>>(),
|
||||
["pip:alpha@2.0.0", "pip:beta@1.0.0"]
|
||||
);
|
||||
let root = &fixture.fixture.directory.0;
|
||||
let before = [
|
||||
std::fs::read(root.join("config/setting.json")).unwrap(),
|
||||
std::fs::read(root.join("dever.lock")).unwrap(),
|
||||
];
|
||||
fixture.store.0.borrow_mut().clear();
|
||||
fixture.transport.requests.borrow_mut().clear();
|
||||
fixture.command("install", &[]).unwrap();
|
||||
assert_eq!(fixture.transport.requests.borrow().len(), 2);
|
||||
assert!(
|
||||
fixture
|
||||
.transport
|
||||
.requests
|
||||
.borrow()
|
||||
.iter()
|
||||
.all(|url| url.starts_with("https://files.pythonhosted.org/"))
|
||||
);
|
||||
assert_eq!(fixture.store.0.borrow().len(), 2);
|
||||
fixture.transport.requests.borrow_mut().clear();
|
||||
let cache = fixture.store.0.borrow().clone();
|
||||
for action in ["install", "doctor"] {
|
||||
fixture.store.1.borrow_mut().clear();
|
||||
fixture.command(action, &[]).unwrap();
|
||||
let reads = fixture.store.1.borrow();
|
||||
let read_bytes = reads.iter().map(|(_, bytes)| bytes).sum::<usize>();
|
||||
eprintln!(
|
||||
"cache {action}: reads={} bytes={read_bytes} fixtures={:?}",
|
||||
reads.len(),
|
||||
cache.keys().collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(reads.len(), cache.len());
|
||||
assert_eq!(read_bytes, cache.values().map(Vec::len).sum::<usize>());
|
||||
for hash in cache.keys() {
|
||||
assert_eq!(reads.iter().filter(|(key, _)| key == hash).count(), 1);
|
||||
}
|
||||
}
|
||||
assert_eq!(*fixture.store.0.borrow(), cache);
|
||||
assert!(fixture.transport.requests.borrow().is_empty());
|
||||
assert_eq!(
|
||||
before,
|
||||
[
|
||||
std::fs::read(root.join("config/setting.json")).unwrap(),
|
||||
std::fs::read(root.join("dever.lock")).unwrap()
|
||||
]
|
||||
);
|
||||
let hash = cache.keys().next().unwrap();
|
||||
for damaged in [vec![0; cache[hash].len()], vec![]] {
|
||||
fixture.store.0.borrow_mut().insert(hash.clone(), damaged);
|
||||
for action in ["install", "doctor"] {
|
||||
assert!(
|
||||
fixture
|
||||
.command(action, &[])
|
||||
.unwrap_err()
|
||||
.contains("digest or length")
|
||||
);
|
||||
assert!(fixture.transport.requests.borrow().is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_resolution_and_tampered_restore_leave_project_and_cache_unchanged() {
|
||||
let mut fixture = Python::new();
|
||||
fixture.transport.release("alpha", "1.0.0", "");
|
||||
fixture
|
||||
.transport
|
||||
.release("broken", "1.0.0", "Requires-Dist: alpha<1\n");
|
||||
fixture.command("add", &["pip:alpha@1.0.0"]).unwrap();
|
||||
let root = &fixture.fixture.directory.0;
|
||||
let before = [
|
||||
std::fs::read(root.join("config/setting.json")).unwrap(),
|
||||
std::fs::read(root.join("dever.lock")).unwrap(),
|
||||
];
|
||||
assert!(
|
||||
fixture
|
||||
.command("add", &["pip:broken@1.0.0"])
|
||||
.unwrap_err()
|
||||
.contains("conflict")
|
||||
);
|
||||
let source = fixture.lock().libs[0].artifacts[0]
|
||||
.source
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.locator
|
||||
.clone();
|
||||
fixture.transport.files.insert(source, b"tampered".to_vec());
|
||||
fixture.store.0.borrow_mut().clear();
|
||||
assert!(
|
||||
fixture
|
||||
.command("install", &[])
|
||||
.unwrap_err()
|
||||
.contains("digest")
|
||||
);
|
||||
assert!(fixture.store.0.borrow().is_empty());
|
||||
assert_eq!(
|
||||
before,
|
||||
[
|
||||
std::fs::read(root.join("config/setting.json")).unwrap(),
|
||||
std::fs::read(root.join("dever.lock")).unwrap()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_record_rejects_mutation_omission_duplicate_and_weak_hash() {
|
||||
let fixture = Python::new();
|
||||
let bytes = wheel("alpha", "1.0.0", "");
|
||||
let lib = serde_json::from_value(serde_json::json!({"spec":{"ecosystem":"pip","name":"alpha","version":"1.0.0"},"dependencies":[],"runtime":fixture.runtime().pack,"artifacts":[],"schema":"pypi-wheel-v1"})).unwrap();
|
||||
let runtime = library::runtime::load(
|
||||
&fixture.signed,
|
||||
"pip",
|
||||
BuildTarget::host().unwrap(),
|
||||
&fixture.runtime().pack,
|
||||
)
|
||||
.unwrap();
|
||||
let original = library::archive::files(&bytes, true).unwrap();
|
||||
python::install(&lib, &bytes, &runtime.manifest, &[]).unwrap();
|
||||
let record = "alpha-1.0.0.dist-info/RECORD";
|
||||
for mode in 0..4 {
|
||||
let mut files = original.clone();
|
||||
match mode {
|
||||
0 => {
|
||||
files.insert("alpha/__init__.py".into(), b"VALUE = 43\n".to_vec());
|
||||
}
|
||||
1 => {
|
||||
files.insert("alpha/extra.py".into(), b"unlisted".to_vec());
|
||||
}
|
||||
2 => {
|
||||
let duplicate = files[record].clone();
|
||||
files.get_mut(record).unwrap().extend_from_slice(&duplicate);
|
||||
}
|
||||
_ => {
|
||||
let text = String::from_utf8(files[record].clone())
|
||||
.unwrap()
|
||||
.replace("sha256=", "md5=");
|
||||
files.insert(record.into(), text.into_bytes());
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
python::install(&lib, &zip(files), &runtime.manifest, &[]).is_err(),
|
||||
"accepted mode {mode}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_resolver_failure_does_not_mutate_input_lock() {
|
||||
let mut fixture = Python::new();
|
||||
fixture
|
||||
.transport
|
||||
.release("alpha", "1.0.0", "Requires-Dist: missing>=1\n");
|
||||
let mut lock = Lock::new(vec![]);
|
||||
let before = lock.encode().unwrap();
|
||||
let result = python::resolve(
|
||||
&["pip:alpha@1.0.0".parse().unwrap()],
|
||||
BuildTarget::host().unwrap(),
|
||||
&fixture.runtime(),
|
||||
&fixture.transport,
|
||||
&fixture.store,
|
||||
&mut lock,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(lock.encode().unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_requirements_are_checked_without_a_spurious_locked_edge() {
|
||||
let mut fixture = Python::new();
|
||||
fixture
|
||||
.transport
|
||||
.release("alpha", "1.0.0", "Requires-Dist: alpha>=1\n");
|
||||
fixture.command("add", &["pip:alpha@1.0.0"]).unwrap();
|
||||
assert!(fixture.lock().libs[0].dependencies.is_empty());
|
||||
fixture.command("doctor", &[]).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
//! 回环 PyPI 和仅测试签名发行;测试显式使用 dev Pod 的 Python,不修改全局安装。
|
||||
|
||||
use super::super::{
|
||||
Fixture,
|
||||
fixture::{digest, write},
|
||||
};
|
||||
use dever::compiler::project::dependency::{
|
||||
Store,
|
||||
library::{registry::Transport, runtime},
|
||||
worker::signed::SignedResources,
|
||||
};
|
||||
use ring::signature::{Ed25519KeyPair, KeyPair};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::BTreeMap,
|
||||
io::{Read, Write},
|
||||
net::TcpListener,
|
||||
};
|
||||
|
||||
pub(super) struct Python {
|
||||
pub fixture: Fixture,
|
||||
pub signed: SignedResources,
|
||||
pub transport: Registry,
|
||||
pub store: Memory,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Memory(
|
||||
pub RefCell<BTreeMap<String, Vec<u8>>>,
|
||||
pub RefCell<Vec<(String, usize)>>,
|
||||
);
|
||||
impl Store for Memory {
|
||||
fn get(&self, hash: &str, _: u64) -> Result<Option<Vec<u8>>, String> {
|
||||
let bytes = self.0.borrow().get(hash).cloned();
|
||||
self.1
|
||||
.borrow_mut()
|
||||
.push((hash.into(), bytes.as_ref().map_or(0, Vec::len)));
|
||||
Ok(bytes)
|
||||
}
|
||||
fn put(&self, bytes: &[u8]) -> Result<(), String> {
|
||||
self.0.borrow_mut().insert(digest(bytes), bytes.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Registry {
|
||||
pub files: BTreeMap<String, Vec<u8>>,
|
||||
versions: BTreeMap<String, BTreeMap<String, serde_json::Value>>,
|
||||
pub requests: RefCell<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Transport for Registry {
|
||||
fn get(&self, url: &str, limit: usize) -> Result<Vec<u8>, String> {
|
||||
self.requests.borrow_mut().push(url.into());
|
||||
let bytes = self
|
||||
.files
|
||||
.get(url)
|
||||
.ok_or_else(|| format!("test registry misses {url}"))?
|
||||
.clone();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let thread = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
let mut request = Vec::new();
|
||||
while !request.ends_with(b"\r\n\r\n") {
|
||||
let mut byte = [0];
|
||||
stream.read_exact(&mut byte).unwrap();
|
||||
request.push(byte[0]);
|
||||
assert!(request.len() < 4096);
|
||||
}
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
bytes.len()
|
||||
)
|
||||
.unwrap();
|
||||
stream.write_all(&bytes).unwrap();
|
||||
});
|
||||
let client: ureq::Agent = ureq::Agent::config_builder()
|
||||
.proxy(None)
|
||||
.max_redirects(0)
|
||||
.timeout_global(Some(std::time::Duration::from_secs(3)))
|
||||
.build()
|
||||
.into();
|
||||
let mut response = client
|
||||
.get(format!("http://{address}/metadata"))
|
||||
.call()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut bytes = Vec::new();
|
||||
response
|
||||
.body_mut()
|
||||
.as_reader()
|
||||
.take(limit as u64 + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.unwrap();
|
||||
thread.join().unwrap();
|
||||
if bytes.len() > limit {
|
||||
return Err("fixture exceeded transfer budget".into());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
pub fn release(&mut self, name: &str, version: &str, headers: &str) -> Vec<u8> {
|
||||
self.release_wheel(name, version, wheel(name, version, headers))
|
||||
}
|
||||
|
||||
pub fn release_wheel(&mut self, name: &str, version: &str, bytes: Vec<u8>) -> Vec<u8> {
|
||||
let filename = format!("{}-{version}-py3-none-any.whl", name.replace('-', "_"));
|
||||
let url = format!("https://files.pythonhosted.org/packages/{filename}");
|
||||
self.files.insert(url.clone(), bytes.clone());
|
||||
let artifact = serde_json::json!({"packagetype":"bdist_wheel","filename":filename,"url":url,"digests":{"sha256":digest(&bytes)},"size":bytes.len(),"requires_python":">=3.11"});
|
||||
self.files.insert(format!("https://pypi.org/pypi/{name}/{version}/json"), serde_json::to_vec(&serde_json::json!({"info":{"requires_dist":["wrong-registry-summary>=999"]},"urls":[artifact]})).unwrap());
|
||||
self.versions
|
||||
.entry(name.into())
|
||||
.or_default()
|
||||
.insert(version.into(), serde_json::json!([]));
|
||||
self.files.insert(
|
||||
format!("https://pypi.org/pypi/{name}/json"),
|
||||
serde_json::to_vec(&serde_json::json!({"releases":self.versions[name]})).unwrap(),
|
||||
);
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn wheel(name: &str, version: &str, headers: &str) -> Vec<u8> {
|
||||
wheel_with_files(name, version, headers, BTreeMap::new())
|
||||
}
|
||||
|
||||
pub(super) fn wheel_with_files(
|
||||
name: &str,
|
||||
version: &str,
|
||||
headers: &str,
|
||||
extra: BTreeMap<String, Vec<u8>>,
|
||||
) -> Vec<u8> {
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use sha2::{Digest, Sha256};
|
||||
let stem = format!("{}-{version}.dist-info", name.replace('-', "_"));
|
||||
let mut files = BTreeMap::from([
|
||||
(
|
||||
format!("{}/__init__.py", name.replace('-', "_")),
|
||||
b"VALUE = 42\n".to_vec(),
|
||||
),
|
||||
(
|
||||
format!("{stem}/METADATA"),
|
||||
format!("Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n{headers}\n")
|
||||
.into_bytes(),
|
||||
),
|
||||
(
|
||||
format!("{stem}/WHEEL"),
|
||||
b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n".to_vec(),
|
||||
),
|
||||
]);
|
||||
files.extend(extra);
|
||||
let mut record = files
|
||||
.iter()
|
||||
.map(|(path, bytes)| {
|
||||
format!(
|
||||
"{path},sha256={},{}\n",
|
||||
URL_SAFE_NO_PAD.encode(Sha256::digest(bytes)),
|
||||
bytes.len()
|
||||
)
|
||||
})
|
||||
.collect::<String>();
|
||||
record.push_str(&format!("{stem}/RECORD,,\n"));
|
||||
files.insert(format!("{stem}/RECORD"), record.into_bytes());
|
||||
zip(files)
|
||||
}
|
||||
|
||||
pub(super) fn zip(files: BTreeMap<String, Vec<u8>>) -> Vec<u8> {
|
||||
let mut writer = zip::ZipWriter::new(std::io::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()
|
||||
}
|
||||
|
||||
impl Python {
|
||||
pub fn new() -> Self {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = fixture.directory.0.join("test-signed");
|
||||
let executable = std::fs::read("/usr/bin/python3")
|
||||
.expect("Python fixture requires the existing dev Pod interpreter");
|
||||
let result = std::process::Command::new("/usr/bin/python3")
|
||||
.args([
|
||||
"-I",
|
||||
"-S",
|
||||
"-B",
|
||||
"-c",
|
||||
"import sys; print(sys.version.split()[0])",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(result.status.success());
|
||||
let version = String::from_utf8(result.stdout).unwrap().trim().to_owned();
|
||||
let short = version.rsplit_once('.').unwrap().0;
|
||||
let markers = serde_json::json!({"implementation_name":"cpython","implementation_version":version,"os_name":"posix","platform_machine":"x86_64","platform_python_implementation":"CPython","platform_release":"","platform_system":"Linux","platform_version":"","python_full_version":version,"python_version":short,"sys_platform":"linux"});
|
||||
let manifest = serde_json::to_vec(&serde_json::json!({"format":"dever-worker-runtime-v1","ecosystem":"pip","target":"linux-x86_64","executable":"bin/python3","arguments":["-I","-S","-B"],"python_wheel_tags":["py3-none-any"],"python_extension_suffixes":[".so"],"python_markers":markers})).unwrap();
|
||||
let gzip = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
let mut archive = tar::Builder::new(gzip);
|
||||
for (path, bytes) in [
|
||||
("bin/python3", executable),
|
||||
("dever-runtime.json", manifest),
|
||||
] {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_mode(0o755);
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_cksum();
|
||||
archive
|
||||
.append_data(&mut header, path, bytes.as_slice())
|
||||
.unwrap();
|
||||
}
|
||||
let pack = archive.into_inner().unwrap().finish().unwrap();
|
||||
let metadata = serde_json::to_vec(&serde_json::json!({"format":"dever-registry-runtime-v1","runtime":{"pack":{"name":"cpython","version":version,"sha256":digest(&pack)},"target":"linux-x86_64","python_markers":markers,"python_wheel_tags":["py3-none-any"]}})).unwrap();
|
||||
let assets = [
|
||||
("runtime/pip/linux-x86_64/manifest.json", metadata),
|
||||
("runtime/pip/linux-x86_64/runtime.pack", pack),
|
||||
];
|
||||
let key = Ed25519KeyPair::from_seed_unchecked(&[81; 32]).unwrap();
|
||||
let hex = |bytes: &[u8]| {
|
||||
bytes
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
};
|
||||
write(
|
||||
&root,
|
||||
"state/trusted-release-key",
|
||||
hex(key.public_key().as_ref()),
|
||||
);
|
||||
let catalog = serde_json::to_vec(&serde_json::json!({"format":"dever-release-v2","version":"0.0.1","platform":"linux-x86_64","artifacts":assets.iter().map(|(path, bytes)| serde_json::json!({"path":path,"bytes":bytes.len(),"sha256":digest(bytes)})).collect::<Vec<_>>(),"extensions":[]})).unwrap();
|
||||
for (path, bytes) in assets {
|
||||
write(&root, &format!("versions/0.0.1/{path}"), bytes);
|
||||
}
|
||||
write(&root, "versions/0.0.1/manifest.json", &catalog);
|
||||
write(
|
||||
&root,
|
||||
"versions/0.0.1/manifest.sig",
|
||||
hex(key.sign(&catalog).as_ref()),
|
||||
);
|
||||
let signed = SignedResources::load(&root, "0.0.1").unwrap();
|
||||
Self {
|
||||
fixture,
|
||||
signed,
|
||||
transport: Registry::default(),
|
||||
store: Memory::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runtime(&self) -> runtime::Metadata {
|
||||
runtime::metadata(
|
||||
&self.signed,
|
||||
"pip",
|
||||
dever::compiler::toolchain::BuildTarget::host().unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn command(&self, action: &str, specs: &[&str]) -> Result<String, String> {
|
||||
dever::compiler::project::dependency::library::command::execute_with(
|
||||
action,
|
||||
&self.fixture.directory.0,
|
||||
&specs
|
||||
.iter()
|
||||
.map(|value| value.to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
dever::compiler::toolchain::BuildTarget::host().unwrap(),
|
||||
&self.transport,
|
||||
&self.store,
|
||||
Some(&self.signed),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn lock(&self) -> dever::compiler::project::dependency::Lock {
|
||||
dever::compiler::project::dependency::Lock::decode(
|
||||
&std::fs::read_to_string(self.fixture.directory.0.join("dever.lock")).unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
use super::fixture::{Fixture, app, write};
|
||||
|
||||
fn single() -> Fixture {
|
||||
let mut fixture = Fixture::new(&["numbers@^1"]);
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[],
|
||||
&[("module/numbers/value/app.dever", &app("input"))],
|
||||
);
|
||||
fixture
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_bytes_and_manifest_digests_are_verified_before_parsing_sources() {
|
||||
let mut fixture = single();
|
||||
fixture.entries[0].manifest_sha256 = "0".repeat(64);
|
||||
fixture.lock();
|
||||
fixture.reject("manifest changed");
|
||||
let mut fixture = single();
|
||||
fixture.assets.values_mut().next().unwrap()[0] ^= 1;
|
||||
fixture.reject("SHA-256 mismatch");
|
||||
let mut fixture = single();
|
||||
fixture.entries[0].bytes += 1;
|
||||
fixture.lock();
|
||||
fixture.reject("size or SHA-256 mismatch");
|
||||
let mut fixture = single();
|
||||
fixture.assets.clear();
|
||||
fixture.reject("asset is missing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_closure_rejects_stale_missing_cycles_and_orphan_entries() {
|
||||
let fixture = single();
|
||||
write(
|
||||
&fixture.directory.0,
|
||||
"config/setting.json",
|
||||
r#"{"package":{"registry":"https://packages.example.test","use":["numbers@^2"]}}"#,
|
||||
);
|
||||
fixture.reject("lock is stale");
|
||||
let mut fixture = single();
|
||||
fixture.entries[0]
|
||||
.dependencies
|
||||
.push(super::fixture::Dependency {
|
||||
name: "absent".into(),
|
||||
version: "1.0.0".into(),
|
||||
});
|
||||
fixture.lock();
|
||||
fixture.reject("missing or mismatched dependency");
|
||||
let mut fixture = Fixture::new(&["numbers@*"]);
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[("units", "^1")],
|
||||
&[("module/numbers/value/app.dever", "unused")],
|
||||
);
|
||||
fixture.package(
|
||||
"units",
|
||||
&[("numbers", "^1")],
|
||||
&[("module/units/value/app.dever", "unused")],
|
||||
);
|
||||
fixture.reject("cyclic");
|
||||
let mut fixture = single();
|
||||
fixture.package("units", &[], &[("module/units/value/app.dever", "unused")]);
|
||||
fixture.reject("unreferenced");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_dependencies_must_match_locked_edges_and_ranges() {
|
||||
let mut fixture = Fixture::new(&["numbers@*"]);
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[("units", "^2")],
|
||||
&[("module/numbers/value/app.dever", "unused")],
|
||||
);
|
||||
fixture.package("units", &[], &[("module/units/value/app.dever", "unused")]);
|
||||
fixture.reject("dependency units lock is stale");
|
||||
let mut fixture = Fixture::new(&["numbers@*", "units@*"]);
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[("units", "*")],
|
||||
&[("module/numbers/value/app.dever", "unused")],
|
||||
);
|
||||
fixture.package("units", &[], &[("module/units/value/app.dever", "unused")]);
|
||||
fixture.entries[0].dependencies.clear();
|
||||
fixture.lock();
|
||||
fixture.reject("dependencies changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_and_lock_validation_never_fall_back_to_local_sources() {
|
||||
for (settings, needle) in [
|
||||
(
|
||||
r#"{"package":{"registry":"http://packages.test","use":[]}}"#,
|
||||
"HTTPS origin",
|
||||
),
|
||||
(
|
||||
r#"{"package":{"registry":"https://packages.test/path","use":[]}}"#,
|
||||
"HTTPS origin",
|
||||
),
|
||||
(
|
||||
r#"{"package":{"registry":"https://packages.test","use":["numbers@*","numbers@^1"]}}"#,
|
||||
"repeats",
|
||||
),
|
||||
(
|
||||
r#"{"package":{"registry":"https://packages.test","use":["dever@*"]}}"#,
|
||||
"nonreserved",
|
||||
),
|
||||
(
|
||||
r#"{"package":{"registry":"https://packages.test","use":["numbers@*"]},"package":null}"#,
|
||||
"invalid project configuration JSON",
|
||||
),
|
||||
(
|
||||
r#"{"lib":["npm:example@^1"]}"#,
|
||||
"npm requires an exact semantic version",
|
||||
),
|
||||
] {
|
||||
let fixture = single();
|
||||
write(&fixture.directory.0, "config/setting.json", settings);
|
||||
fixture.reject(needle);
|
||||
}
|
||||
let fixture = single();
|
||||
std::fs::remove_file(fixture.directory.0.join("dever.lock")).unwrap();
|
||||
fixture.reject("require dever.lock");
|
||||
let fixture = single();
|
||||
let path = fixture.directory.0.join("dever.lock");
|
||||
let lock = std::fs::read_to_string(&path).unwrap();
|
||||
std::fs::write(&path, lock.trim_end()).unwrap();
|
||||
fixture.reject("not canonical");
|
||||
std::fs::write(&path, lock.replace("\"workers\": []", "\"workers\": [{}]")).unwrap();
|
||||
fixture.reject("missing field `port`");
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
std::os::unix::fs::symlink("config/setting.json", path).unwrap();
|
||||
fixture.reject("project input links");
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
mod api;
|
||||
mod archive;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod cache;
|
||||
mod commands;
|
||||
mod documentation;
|
||||
mod fixture;
|
||||
mod library;
|
||||
mod lock;
|
||||
mod npm;
|
||||
mod worker;
|
||||
|
||||
use fixture::{Fixture, app, command, write};
|
||||
|
||||
#[test]
|
||||
fn locked_sources_build_native_with_transitive_calls_and_original_locations() {
|
||||
let mut fixture = Fixture::new(&["numbers@^1"]);
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[("units", "^1")],
|
||||
&[(
|
||||
"module/numbers/value/app.dever",
|
||||
&app("units.value.value(input) * 2"),
|
||||
)],
|
||||
);
|
||||
fixture.package(
|
||||
"units",
|
||||
&[],
|
||||
&[("module/units/value/app.dever", &app("input + 1"))],
|
||||
);
|
||||
write(
|
||||
&fixture.directory.0,
|
||||
"module/demo/value/app.dever",
|
||||
app("numbers.value.value(input)"),
|
||||
);
|
||||
write(
|
||||
&fixture.directory.0,
|
||||
"module/demo/value/api.dever",
|
||||
command(),
|
||||
);
|
||||
let before = std::fs::read(fixture.directory.0.join("dever.lock")).unwrap();
|
||||
let project = fixture.load().unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let binary = dever::compiler::build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"main",
|
||||
&fixture.directory.0.join("app"),
|
||||
)
|
||||
.unwrap();
|
||||
let result = std::process::Command::new(&binary)
|
||||
.args(["demo.value.value", "{\"input\":20}"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(
|
||||
result.stdout,
|
||||
b"{\"code\":0,\"message\":\"ok\",\"data\":42}\n"
|
||||
);
|
||||
let result = std::process::Command::new(binary)
|
||||
.args(["demo.value.value", "{\"input\":9223372036854775807}"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!result.status.success());
|
||||
assert!(
|
||||
String::from_utf8_lossy(&result.stderr).contains("package/module/units/value/app.dever:")
|
||||
);
|
||||
assert_eq!(
|
||||
before,
|
||||
std::fs::read(fixture.directory.0.join("dever.lock")).unwrap()
|
||||
);
|
||||
assert!(!fixture.directory.0.join("module/numbers").exists());
|
||||
assert!(!fixture.directory.0.join("package").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_only_project_and_local_sources_share_identity_and_layout_checks() {
|
||||
let mut fixture = Fixture::new(&["numbers@1.0.0"]);
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[],
|
||||
&[
|
||||
("module/numbers/value/app.dever", &app("input + 1")),
|
||||
("module/numbers/value/api.dever", &command()),
|
||||
("module/numbers/assets/home.html", "<h1>resource</h1>"),
|
||||
],
|
||||
);
|
||||
assert!(!fixture.directory.0.join("module").exists());
|
||||
let project = fixture.load().unwrap();
|
||||
let program = project.check().unwrap();
|
||||
assert_eq!(program.commands[0].name, "numbers.value.value");
|
||||
write(
|
||||
&fixture.directory.0,
|
||||
"module/numbers/unrelated/readme.txt",
|
||||
"local component must win by explicit error",
|
||||
);
|
||||
fixture.reject("collides with local component");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_source_format_and_unimplemented_roles_cannot_bypass_checking() {
|
||||
for (path, text, load_error) in [
|
||||
(
|
||||
"module/numbers/value/app.dever.md",
|
||||
app("input"),
|
||||
Some("no Dever source"),
|
||||
),
|
||||
(
|
||||
"module/numbers/value/test.dever",
|
||||
app("input"),
|
||||
Some("role 'test' is not implemented yet"),
|
||||
),
|
||||
] {
|
||||
let mut fixture = Fixture::new(&["numbers@1.0.0"]);
|
||||
fixture.package("numbers", &[], &[(path, &text)]);
|
||||
fixture.reject(load_error.unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_roles_allow_a_single_topic_file() {
|
||||
let mut fixture = Fixture::new(&["numbers@1.0.0"]);
|
||||
fixture.package(
|
||||
"numbers",
|
||||
&[],
|
||||
&[
|
||||
("module/numbers/value/app/only.dever", &app("input")),
|
||||
("module/numbers/value/api.dever", &command()),
|
||||
],
|
||||
);
|
||||
let project = fixture.load().unwrap();
|
||||
assert_eq!(
|
||||
project.check().unwrap().commands[0].name,
|
||||
"numbers.value.value"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use super::{Fixture, tools};
|
||||
use dever::compiler::project::dependency::{
|
||||
Lock,
|
||||
library::{command, npm},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{collections::BTreeMap, fs, os::unix::fs::DirBuilderExt};
|
||||
|
||||
#[test]
|
||||
fn npm_signed_hooks_native_addon_and_missing_output_replay_are_real() {
|
||||
let mut fixture = Fixture::new();
|
||||
tools::prepare(&mut fixture);
|
||||
let script=br#"const fs=require('node:fs');const path=require('node:path');const child=require('node:child_process');
|
||||
const tools=process.env.PATH.split(':').find(p=>p.endsWith('/rootfs/usr/bin'));
|
||||
if(!tools)throw Error('signed tools PATH missing');
|
||||
if((fs.statSync('executable.cjs').mode&0o111)===0)throw Error('archive executable bit lost');
|
||||
if((fs.statSync('addon.c').mode&0o111)!==0)throw Error('archive executable bit invented');
|
||||
if(fs.readFileSync(path.join(path.dirname(process.execPath),'..','shared.txt'),'utf8')!=='shared runtime fixture')throw Error('shared runtime path changed');
|
||||
if(child.execFileSync(path.join(tools,'fixture-node'),{encoding:'utf8'})!=='signed script')throw Error('signed script did not execute');
|
||||
fs.mkdirSync('build/Release',{recursive:true});
|
||||
child.execFileSync(path.join(tools,'cc'),['-B'+tools+'/','-fno-use-linker-plugin','-fno-lto','-shared','-fPIC','-nostdlib','addon.c','-o','build/Release/addon.node'],{stdio:'inherit'});
|
||||
fs.writeFileSync('generated.js','module.exports=42;');
|
||||
"#;
|
||||
let source=br#"typedef void* napi_env;typedef void* napi_value;typedef unsigned long size_t;
|
||||
extern int napi_create_string_utf8(napi_env,const char*,size_t,napi_value*);
|
||||
__attribute__((visibility("default"))) napi_value napi_register_module_v1(napi_env env,napi_value exports){napi_value result;napi_create_string_utf8(env,"signed addon",12,&result);return result;}
|
||||
"#;
|
||||
fixture.package(json!({"name":"built","version":"1.0.0","optionalDependencies":{"unavailable":"1.0.0"},"scripts":{"install":"node build.cjs","prepare":"node must-not-run.cjs"}}),BTreeMap::from([("package/build.cjs".into(),script.to_vec()),("package/addon.c".into(),source.to_vec()),("package/executable.cjs".into(),b"#!/usr/bin/env node\n".to_vec())]));
|
||||
fixture.package(json!({"name":"unavailable","version":"1.0.0","scripts":{"install":"node -e 'process.exit(7)'"},"bin":{"fixture-bin":"index.js"}}), BTreeMap::new());
|
||||
let project = fixture.directory.0.join("project");
|
||||
fs::DirBuilder::new().mode(0o700).create(&project).unwrap();
|
||||
fs::create_dir(project.join("config")).unwrap();
|
||||
fs::write(project.join("config/setting.json"), "{\"lib\":[]}").unwrap();
|
||||
command::execute_with(
|
||||
"add",
|
||||
&project,
|
||||
&["npm:built@1.0.0".into()],
|
||||
fixture.target,
|
||||
&fixture,
|
||||
&fixture,
|
||||
Some(&fixture.signed),
|
||||
)
|
||||
.unwrap();
|
||||
let lock_file = fs::read_to_string(project.join("dever.lock")).unwrap();
|
||||
let setting = fs::read(project.join("config/setting.json")).unwrap();
|
||||
fixture.lock = Lock::decode(&lock_file).unwrap();
|
||||
let files = fixture.install().unwrap();
|
||||
assert_eq!(
|
||||
files["node_modules/built/generated.js"],
|
||||
b"module.exports=42;"
|
||||
);
|
||||
assert!(files["node_modules/built/build/Release/addon.node"].starts_with(b"\x7fELF"));
|
||||
assert_eq!(fixture.graph().instances.len(), 1);
|
||||
assert_eq!(
|
||||
fixture.graph().instances[0].edges[0].omission,
|
||||
Some(npm::Omission::BuildFailure)
|
||||
);
|
||||
assert_eq!(fixture.lock.libs.len(), 1);
|
||||
let receipt = &fixture.lock.builds[0];
|
||||
assert_eq!(
|
||||
receipt["native_entries"],
|
||||
json!(["node_modules/built/build/Release/addon.node"])
|
||||
);
|
||||
let output = receipt["output"]["sha256"].as_str().unwrap().to_owned();
|
||||
let original = fixture.cache.borrow_mut().remove(&output).unwrap();
|
||||
let sources = receipt["sources"].as_array().unwrap();
|
||||
assert_eq!(sources.len(), 2);
|
||||
let mut expected_urls = vec![];
|
||||
for source in sources {
|
||||
fixture
|
||||
.cache
|
||||
.borrow_mut()
|
||||
.remove(source["sha256"].as_str().unwrap())
|
||||
.unwrap();
|
||||
expected_urls.push(source["source"]["locator"].as_str().unwrap().to_owned());
|
||||
}
|
||||
let lock_before = fixture.lock.encode().unwrap();
|
||||
let calls = fixture.calls.borrow().len();
|
||||
command::execute_with(
|
||||
"install",
|
||||
&project,
|
||||
&[],
|
||||
fixture.target,
|
||||
&fixture,
|
||||
&fixture,
|
||||
Some(&fixture.signed),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fixture.cache.borrow()[&output], original);
|
||||
assert_eq!(fixture.lock.encode().unwrap(), lock_before);
|
||||
assert_eq!(
|
||||
fs::read_to_string(project.join("dever.lock")).unwrap(),
|
||||
lock_file
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(project.join("config/setting.json")).unwrap(),
|
||||
setting
|
||||
);
|
||||
let mut fetched = fixture.calls.borrow()[calls..].to_vec();
|
||||
expected_urls.sort();
|
||||
fetched.sort();
|
||||
assert_eq!(fetched, expected_urls);
|
||||
nondeterministic_replay_is_rejected(&mut fixture);
|
||||
failed_native_build_is_not_published(&mut fixture);
|
||||
}
|
||||
|
||||
fn nondeterministic_replay_is_rejected(fixture: &mut Fixture) {
|
||||
fixture.lock = Lock::new(vec![]);
|
||||
fixture.package(json!({"name":"unstable","version":"1.0.0","scripts":{"install":"node -e \"require('node:fs').writeFileSync('nonce',require('node:crypto').randomBytes(16))\""}}), BTreeMap::new());
|
||||
fixture.resolve(&["npm:unstable@1.0.0"]).unwrap();
|
||||
let mut lock = fixture.lock.clone();
|
||||
fixture.reads.borrow_mut().clear();
|
||||
npm::prepare(&mut lock, fixture.target, &fixture.signed, fixture).unwrap();
|
||||
for lib in &lock.libs {
|
||||
let artifact = &lib.artifacts[0];
|
||||
let reads = fixture.reads.borrow();
|
||||
let source_reads = reads
|
||||
.iter()
|
||||
.filter(|(hash, _)| hash == &artifact.sha256)
|
||||
.collect::<Vec<_>>();
|
||||
eprintln!(
|
||||
"npm build: fixture={} bytes={} reads={}",
|
||||
artifact.sha256,
|
||||
artifact.bytes,
|
||||
source_reads.len()
|
||||
);
|
||||
assert_eq!(source_reads.len(), 1);
|
||||
assert_eq!(source_reads[0].1 as u64, artifact.bytes);
|
||||
}
|
||||
fixture.lock = lock;
|
||||
let digest = fixture.lock.builds[0]["output"]["sha256"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
fixture.cache.borrow_mut().remove(&digest).unwrap();
|
||||
let before = fixture.lock.encode().unwrap();
|
||||
let cache_entries = fixture.cache.borrow().len();
|
||||
let error = npm::restore(
|
||||
&fixture.lock,
|
||||
&fixture.graph(),
|
||||
fixture.target,
|
||||
&fixture.signed,
|
||||
fixture,
|
||||
fixture,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.contains("not byte reproducible"), "{error}");
|
||||
assert_eq!(fixture.cache.borrow().len(), cache_entries);
|
||||
assert_eq!(fixture.lock.encode().unwrap(), before);
|
||||
}
|
||||
|
||||
fn failed_native_build_is_not_published(fixture: &mut Fixture) {
|
||||
fixture.lock = Lock::new(vec![]);
|
||||
fixture.package(json!({"name":"broken","version":"1.0.0","scripts":{"install":"node -e \"const fs=require('node:fs');fs.mkdirSync('build/Release',{recursive:true});fs.writeFileSync('build/Release/broken.node','invalid addon');\""}}), BTreeMap::new());
|
||||
fixture.resolve(&["npm:broken@1.0.0"]).unwrap();
|
||||
let mut lock = fixture.lock.clone();
|
||||
let before = lock.encode().unwrap();
|
||||
let cache_entries = fixture.cache.borrow().len();
|
||||
let error = npm::prepare(&mut lock, fixture.target, &fixture.signed, fixture).unwrap_err();
|
||||
assert!(error.contains("Lib build failed"), "{error}");
|
||||
assert_eq!(lock.encode().unwrap(), before);
|
||||
assert_eq!(fixture.cache.borrow().len(), cache_entries);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
use super::super::super::Directory;
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use dever::compiler::{
|
||||
project::dependency::{
|
||||
Lock, Store,
|
||||
library::{self, Spec, registry::Transport, runtime},
|
||||
worker::signed::SignedResources,
|
||||
},
|
||||
toolchain::BuildTarget,
|
||||
};
|
||||
use ring::signature::{Ed25519KeyPair, KeyPair};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256, Sha512};
|
||||
use std::{cell::RefCell, collections::BTreeMap, io::Cursor};
|
||||
|
||||
pub(super) fn hash(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
pub(super) fn archive(files: &BTreeMap<String, Vec<u8>>) -> Vec<u8> {
|
||||
let gzip = flate2::GzBuilder::new()
|
||||
.mtime(0)
|
||||
.write(Vec::new(), flate2::Compression::fast());
|
||||
let mut builder = tar::Builder::new(gzip);
|
||||
for (path, bytes) in files {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_mode(if bytes.starts_with(b"#!") {
|
||||
0o755
|
||||
} else {
|
||||
0o644
|
||||
});
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, path, Cursor::new(bytes))
|
||||
.unwrap();
|
||||
}
|
||||
builder.into_inner().unwrap().finish().unwrap()
|
||||
}
|
||||
|
||||
pub(super) struct Fixture {
|
||||
pub directory: Directory,
|
||||
pub documents: BTreeMap<String, Vec<u8>>,
|
||||
pub cache: RefCell<BTreeMap<String, Vec<u8>>>,
|
||||
pub reads: RefCell<Vec<(String, usize)>>,
|
||||
pub calls: RefCell<Vec<String>>,
|
||||
pub metadata: runtime::Metadata,
|
||||
pub target: BuildTarget,
|
||||
pub signed: SignedResources,
|
||||
pub lock: Lock,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
pub fn new() -> Self {
|
||||
let directory = Directory::new();
|
||||
let target = BuildTarget::host().unwrap();
|
||||
let metadata = json!({"pack":{"name":"node","version":"22.1.0","sha256":hash(b"node pack")},"target":target.name(),"python_markers":null,"python_wheel_tags":[],"npm_libc":"glibc"});
|
||||
let signed = signed(
|
||||
&directory,
|
||||
&BTreeMap::from([(
|
||||
format!("runtime/npm/{}/manifest.json", target.name()),
|
||||
serde_json::to_vec(
|
||||
&json!({"format":"dever-registry-runtime-v1","runtime":metadata}),
|
||||
)
|
||||
.unwrap(),
|
||||
)]),
|
||||
);
|
||||
Self {
|
||||
directory,
|
||||
documents: BTreeMap::new(),
|
||||
cache: RefCell::new(BTreeMap::new()),
|
||||
reads: RefCell::new(vec![]),
|
||||
calls: RefCell::new(vec![]),
|
||||
metadata: serde_json::from_value(metadata).unwrap(),
|
||||
target,
|
||||
signed,
|
||||
lock: Lock::new(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn package(&mut self, document: Value, extra: BTreeMap<String, Vec<u8>>) {
|
||||
let name = document["name"].as_str().unwrap();
|
||||
let version = document["version"].as_str().unwrap();
|
||||
let base = format!("https://registry.npmjs.org/{name}");
|
||||
let locator = format!("{base}/-/archive-{version}.tgz");
|
||||
let mut files = extra;
|
||||
files.insert(
|
||||
"package/package.json".into(),
|
||||
serde_json::to_vec(&document).unwrap(),
|
||||
);
|
||||
files.insert("package/index.js".into(), b"module.exports = 42;".to_vec());
|
||||
let bytes = archive(&files);
|
||||
let mut metadata = document.clone();
|
||||
metadata["dist"] = json!({"tarball":locator,"integrity":format!("sha512-{}",STANDARD.encode(Sha512::digest(&bytes)))});
|
||||
let mut index: Value = self
|
||||
.documents
|
||||
.get(&base)
|
||||
.map(|bytes| serde_json::from_slice(bytes).unwrap())
|
||||
.unwrap_or_else(|| json!({"name":name,"versions":{}}));
|
||||
index["versions"][version] = metadata.clone();
|
||||
self.documents
|
||||
.insert(base.clone(), serde_json::to_vec(&index).unwrap());
|
||||
self.documents.insert(
|
||||
format!("{base}/{version}"),
|
||||
serde_json::to_vec(&metadata).unwrap(),
|
||||
);
|
||||
self.documents.insert(locator, bytes);
|
||||
}
|
||||
|
||||
pub fn resolve(&mut self, roots: &[&str]) -> Result<(), String> {
|
||||
let specs = roots
|
||||
.iter()
|
||||
.map(|value| value.parse::<Spec>())
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let mut lock = self.lock.clone();
|
||||
library::npm::resolve(&specs, self.target, &self.metadata, self, self, &mut lock)?;
|
||||
self.lock = lock;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn graph(&self) -> library::npm::Environment {
|
||||
serde_json::from_value(self.lock.npm[0].clone()).unwrap()
|
||||
}
|
||||
|
||||
pub fn install(&self) -> Result<BTreeMap<String, Vec<u8>>, String> {
|
||||
library::npm::install(
|
||||
&self.lock,
|
||||
&self.graph().roots,
|
||||
self.target,
|
||||
self,
|
||||
&self.signed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Transport for Fixture {
|
||||
fn get(&self, url: &str, limit: usize) -> Result<Vec<u8>, String> {
|
||||
library::registry::official(url)?;
|
||||
self.calls.borrow_mut().push(url.into());
|
||||
let bytes = self
|
||||
.documents
|
||||
.get(url)
|
||||
.ok_or_else(|| "registry returned HTTP 404".to_owned())?;
|
||||
assert!(bytes.len() <= limit);
|
||||
Ok(bytes.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl Store for Fixture {
|
||||
fn get(&self, digest: &str, _: u64) -> Result<Option<Vec<u8>>, String> {
|
||||
let bytes = self.cache.borrow().get(digest).cloned();
|
||||
self.reads
|
||||
.borrow_mut()
|
||||
.push((digest.into(), bytes.as_ref().map_or(0, Vec::len)));
|
||||
Ok(bytes)
|
||||
}
|
||||
fn put(&self, bytes: &[u8]) -> Result<(), String> {
|
||||
self.cache.borrow_mut().insert(hash(bytes), bytes.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn signed(directory: &Directory, files: &BTreeMap<String, Vec<u8>>) -> SignedResources {
|
||||
let root = directory.0.join("signed");
|
||||
let key = Ed25519KeyPair::from_seed_unchecked(&[72; 32]).unwrap();
|
||||
let hex = |bytes: &[u8]| {
|
||||
bytes
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
};
|
||||
let write = |path: &str, bytes: &[u8]| {
|
||||
let path = root.join(path);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
};
|
||||
write(
|
||||
"state/trusted-release-key",
|
||||
hex(key.public_key().as_ref()).as_bytes(),
|
||||
);
|
||||
let mut artifacts = vec![json!({"path":"base.txt","bytes":4,"sha256":hash(b"base")})];
|
||||
let target = BuildTarget::host().unwrap();
|
||||
for (path, bytes) in files {
|
||||
artifacts.push(json!({"path":path,"bytes":bytes.len(),"sha256":hash(bytes)}));
|
||||
write(&format!("versions/0.0.1/{path}"), bytes);
|
||||
}
|
||||
write("versions/0.0.1/base.txt", b"base");
|
||||
let catalog=serde_json::to_vec(&json!({"format":"dever-release-v2","version":"0.0.1","platform":target.name(),"artifacts":artifacts,"extensions":[]})).unwrap();
|
||||
write("versions/0.0.1/manifest.json", &catalog);
|
||||
write(
|
||||
"versions/0.0.1/manifest.sig",
|
||||
hex(key.sign(&catalog).as_ref()).as_bytes(),
|
||||
);
|
||||
SignedResources::load(&root, "0.0.1").unwrap()
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
use super::Fixture;
|
||||
use dever::compiler::project::dependency::library::npm::{self, Kind, Omission};
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn npm_scoped_metadata_uses_strict_official_url_policy() {
|
||||
let mut fixture = Fixture::new();
|
||||
fixture.package(
|
||||
json!({"name":"app","version":"1.0.0","dependencies":{"@types/node":"^22"}}),
|
||||
BTreeMap::new(),
|
||||
);
|
||||
fixture.package(
|
||||
json!({"name":"@types/node","version":"22.10.0"}),
|
||||
BTreeMap::new(),
|
||||
);
|
||||
fixture.resolve(&["npm:app@1.0.0"]).unwrap();
|
||||
for address in [
|
||||
"https://registry.npmjs.org/@types/node",
|
||||
"https://registry.npmjs.org/@types/node/22.10.0",
|
||||
"https://registry.npmjs.org/@types/node/-/archive-22.10.0.tgz",
|
||||
] {
|
||||
assert!(fixture.calls.borrow().iter().any(|url| url == address));
|
||||
}
|
||||
let graph = fixture.graph();
|
||||
let node = graph
|
||||
.instances
|
||||
.iter()
|
||||
.find(|node| node.spec.name == "@types/node")
|
||||
.unwrap();
|
||||
let files = fixture.install().unwrap();
|
||||
assert_eq!(
|
||||
files[&format!("{}/index.js", node.path)],
|
||||
b"module.exports = 42;"
|
||||
);
|
||||
let lib = fixture
|
||||
.lock
|
||||
.libs
|
||||
.iter()
|
||||
.find(|lib| lib.spec.name == "@types/node")
|
||||
.unwrap();
|
||||
let mut source = lib.artifacts[0].source.clone().unwrap();
|
||||
for locator in [
|
||||
"https://registry.npmjs.org/@types%2fnode/-/archive-22.10.0.tgz",
|
||||
"https://registry.npmjs.org/@types%2Fnode/-/archive-22.10.0.tgz",
|
||||
"https://registry.npmjs.org/@types/node/%2e%2e/archive-22.10.0.tgz",
|
||||
] {
|
||||
source.locator = locator.into();
|
||||
assert!(source.validate(&lib.spec).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_nested_instances_keep_peer_context_and_install_offline() {
|
||||
let mut fixture = Fixture::new();
|
||||
for document in [
|
||||
json!({"name":"left","version":"1.0.0","dependencies":{"plugin":"1.0.0","engine":"1.0.0"}}),
|
||||
json!({"name":"right","version":"1.0.0","dependencies":{"plugin":"1.0.0","engine":"2.0.0"}}),
|
||||
json!({"name":"plugin","version":"1.0.0","peerDependencies":{"engine":">=1 <3"}}),
|
||||
json!({"name":"engine","version":"1.0.0"}),
|
||||
json!({"name":"engine","version":"2.0.0"}),
|
||||
] {
|
||||
fixture.package(document, BTreeMap::new());
|
||||
}
|
||||
fixture
|
||||
.resolve(&["npm:left@1.0.0", "npm:right@1.0.0"])
|
||||
.unwrap();
|
||||
let graph = fixture.graph();
|
||||
for root in ["left", "right"] {
|
||||
let plugin = graph
|
||||
.instances
|
||||
.iter()
|
||||
.find(|node| node.path == format!("node_modules/{root}/node_modules/plugin"))
|
||||
.unwrap();
|
||||
assert_eq!(plugin.edges[0].kind, Kind::Peer);
|
||||
assert_eq!(
|
||||
plugin.edges[0].target.as_deref(),
|
||||
Some(format!("node_modules/{root}/node_modules/engine").as_str())
|
||||
);
|
||||
}
|
||||
fixture.documents.clear();
|
||||
let calls = fixture.calls.borrow().len();
|
||||
let files = fixture.install().unwrap();
|
||||
assert_eq!(files.len(), 12);
|
||||
assert_eq!(fixture.calls.borrow().len(), calls);
|
||||
assert_eq!(fixture.lock.libs.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_peer_conflict_backtracks_versions_and_incompatible_roots_fail_atomically() {
|
||||
let mut fixture = Fixture::new();
|
||||
for document in [
|
||||
json!({"name":"app","version":"1.0.0","dependencies":{"plugin":"*","engine":"1.0.0"}}),
|
||||
json!({"name":"plugin","version":"2.0.0","peerDependencies":{"engine":"2.0.0"}}),
|
||||
json!({"name":"plugin","version":"1.0.0","peerDependencies":{"engine":"1.0.0"}}),
|
||||
json!({"name":"engine","version":"1.0.0"}),
|
||||
json!({"name":"engine","version":"2.0.0"}),
|
||||
] {
|
||||
fixture.package(document, BTreeMap::new());
|
||||
}
|
||||
fixture.resolve(&["npm:app@1.0.0"]).unwrap();
|
||||
assert_eq!(
|
||||
fixture
|
||||
.graph()
|
||||
.instances
|
||||
.iter()
|
||||
.find(|node| node.spec.name == "plugin")
|
||||
.unwrap()
|
||||
.spec
|
||||
.version,
|
||||
"1.0.0"
|
||||
);
|
||||
let before = fixture.lock.encode().unwrap();
|
||||
assert!(
|
||||
fixture
|
||||
.resolve(&["npm:engine@1.0.0", "npm:engine@2.0.0"])
|
||||
.unwrap_err()
|
||||
.contains("incompatible")
|
||||
);
|
||||
assert_eq!(fixture.lock.encode().unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_optional_target_and_absent_peer_are_explicit_but_integrity_errors_are_fatal() {
|
||||
let mut fixture = Fixture::new();
|
||||
fixture.package(json!({"name":"app","version":"1.0.0","optionalDependencies":{"wrong":"*","missing":"*"},"peerDependencies":{"peer":"*"},"peerDependenciesMeta":{"peer":{"optional":true}}}),BTreeMap::new());
|
||||
fixture.package(
|
||||
json!({"name":"wrong","version":"1.0.0","os":["!linux"]}),
|
||||
BTreeMap::new(),
|
||||
);
|
||||
fixture.resolve(&["npm:app@1.0.0"]).unwrap();
|
||||
let graph = fixture.graph();
|
||||
let edges = &graph.instances[0].edges;
|
||||
for (name, reason) in [
|
||||
("wrong", Omission::Target),
|
||||
("missing", Omission::Unavailable),
|
||||
("peer", Omission::OptionalPeerAbsent),
|
||||
] {
|
||||
assert_eq!(
|
||||
edges
|
||||
.iter()
|
||||
.find(|edge| edge.name == name)
|
||||
.unwrap()
|
||||
.omission,
|
||||
Some(reason)
|
||||
);
|
||||
}
|
||||
fixture.install().unwrap();
|
||||
let before = fixture.lock.encode().unwrap();
|
||||
assert!(
|
||||
fixture
|
||||
.resolve(&["npm:missing@1.0.0"])
|
||||
.unwrap_err()
|
||||
.contains("no npm version")
|
||||
);
|
||||
assert_eq!(fixture.lock.encode().unwrap(), before);
|
||||
fixture.documents.insert(
|
||||
"https://registry.npmjs.org/wrong/-/archive-1.0.0.tgz".into(),
|
||||
b"corrupt optional archive".to_vec(),
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.resolve(&["npm:app@1.0.0"])
|
||||
.unwrap_err()
|
||||
.contains("integrity")
|
||||
);
|
||||
assert_eq!(fixture.lock.encode().unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_one_version_cannot_change_bytes_between_worker_groups() {
|
||||
let mut fixture = Fixture::new();
|
||||
let metadata = json!({"name":"shared","version":"1.0.0"});
|
||||
fixture.package(metadata.clone(), BTreeMap::new());
|
||||
fixture.resolve(&["npm:shared@1.0.0"]).unwrap();
|
||||
let before = fixture.lock.encode().unwrap();
|
||||
fixture.package(
|
||||
metadata,
|
||||
BTreeMap::from([("package/changed.js".into(), b"different bytes".to_vec())]),
|
||||
);
|
||||
let error = fixture.resolve(&["npm:shared@1.0.0"]).unwrap_err();
|
||||
assert!(error.contains("artifact differs"), "{error}");
|
||||
assert_eq!(fixture.lock.encode().unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_bundled_packages_have_one_owner_and_tampered_manifest_or_graph_fails() {
|
||||
let mut fixture = Fixture::new();
|
||||
fixture.package(json!({"name":"app","version":"1.0.0","dependencies":{"tiny":"1.0.0"},"bundleDependencies":["tiny"]}),BTreeMap::from([
|
||||
("package/node_modules/tiny/package.json".into(),serde_json::to_vec(&json!({"name":"tiny","version":"1.0.0"})).unwrap()),
|
||||
("package/node_modules/tiny/index.js".into(),b"module.exports=7".to_vec()),
|
||||
]));
|
||||
fixture.resolve(&["npm:app@1.0.0"]).unwrap();
|
||||
assert_eq!(fixture.lock.libs.len(), 1);
|
||||
assert_eq!(fixture.install().unwrap().len(), 4);
|
||||
let original = fixture.lock.npm[0].clone();
|
||||
fixture.lock.npm[0]["instances"][0]["edges"][0]["requirement"] = json!("2.0.0");
|
||||
assert!(fixture.install().unwrap_err().contains("range"));
|
||||
fixture.lock.npm[0] = original;
|
||||
let mut graph = fixture.graph();
|
||||
graph.instances[1].source.prefix = "package/".into();
|
||||
assert!(
|
||||
npm::validate(&graph, &fixture.lock)
|
||||
.unwrap_err()
|
||||
.contains("owned")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_registry_and_archive_metadata_must_agree_and_urls_are_official() {
|
||||
let mut fixture = Fixture::new();
|
||||
fixture.package(json!({"name":"app","version":"1.0.0"}), BTreeMap::new());
|
||||
let address = "https://registry.npmjs.org/app/1.0.0";
|
||||
let original = fixture.documents[address].clone();
|
||||
let mut metadata: serde_json::Value = serde_json::from_slice(&original).unwrap();
|
||||
metadata["dependencies"] = json!({"hidden":"1.0.0"});
|
||||
fixture
|
||||
.documents
|
||||
.insert(address.into(), serde_json::to_vec(&metadata).unwrap());
|
||||
assert!(
|
||||
fixture
|
||||
.resolve(&["npm:app@1.0.0"])
|
||||
.unwrap_err()
|
||||
.contains("metadata")
|
||||
);
|
||||
let mut metadata: serde_json::Value = serde_json::from_slice(&original).unwrap();
|
||||
metadata["dist"]["tarball"] = json!("https://attacker.invalid/app.tgz");
|
||||
fixture
|
||||
.documents
|
||||
.insert(address.into(), serde_json::to_vec(&metadata).unwrap());
|
||||
assert!(
|
||||
fixture
|
||||
.resolve(&["npm:app@1.0.0"])
|
||||
.unwrap_err()
|
||||
.contains("official")
|
||||
);
|
||||
assert!(
|
||||
!fixture
|
||||
.calls
|
||||
.borrow()
|
||||
.iter()
|
||||
.any(|url| url.contains("attacker"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_cycles_close_without_infinite_nested_copies_and_missing_build_receipt_fails() {
|
||||
let mut fixture = Fixture::new();
|
||||
fixture.package(
|
||||
json!({"name":"alpha","version":"1.0.0","dependencies":{"beta":"1.0.0"}}),
|
||||
BTreeMap::new(),
|
||||
);
|
||||
fixture.package(
|
||||
json!({"name":"beta","version":"1.0.0","dependencies":{"alpha":"1.0.0"}}),
|
||||
BTreeMap::new(),
|
||||
);
|
||||
fixture.resolve(&["npm:alpha@1.0.0"]).unwrap();
|
||||
assert_eq!(fixture.graph().instances.len(), 2);
|
||||
assert_eq!(fixture.install().unwrap().len(), 4);
|
||||
fixture.package(
|
||||
json!({"name":"hook","version":"1.0.0","scripts":{"install":"must not run"}}),
|
||||
BTreeMap::new(),
|
||||
);
|
||||
fixture.resolve(&["npm:hook@1.0.0"]).unwrap();
|
||||
let graph = fixture
|
||||
.lock
|
||||
.npm
|
||||
.iter()
|
||||
.find(|value| value["roots"][0]["name"] == "hook")
|
||||
.unwrap()
|
||||
.clone();
|
||||
fixture.lock.npm = vec![graph];
|
||||
assert!(fixture.install().unwrap_err().contains("lifecycle"));
|
||||
fixture.lock.npm[0]["build"] = json!("0".repeat(64));
|
||||
assert!(fixture.install().unwrap_err().contains("receipt"));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! 注入 registry、签名 catalog 与真实 tgz;不运行宿主 Node/npm,不访问公网。
|
||||
|
||||
mod build;
|
||||
mod fixture;
|
||||
mod graph;
|
||||
mod process;
|
||||
mod receipt;
|
||||
mod tools;
|
||||
|
||||
use fixture::Fixture;
|
||||
@@ -0,0 +1,98 @@
|
||||
//! 共享执行器回归使用 Pod 内的真实子进程,不依赖线上服务。
|
||||
|
||||
use super::super::super::Directory;
|
||||
use dever::compiler::project::dependency::library::build::process;
|
||||
use std::{fs, path::Path, process::Command};
|
||||
|
||||
fn child_gone(path: &Path) {
|
||||
let pid = fs::read_to_string(path).unwrap();
|
||||
assert!(
|
||||
!Path::new("/proc").join(pid.trim()).exists(),
|
||||
"child {pid} was not reaped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lib_build_output_overflow_drains_pipes_and_reaps_its_group() {
|
||||
let directory = Directory::new();
|
||||
let mut command = Command::new("/bin/sh");
|
||||
command.current_dir(&directory.0).args(["-c", "echo $$ > parent; /bin/sleep 300 & echo $! > child; /usr/bin/head -c 33554432 /dev/zero; wait"]);
|
||||
let error = process::run(command, &directory.0).unwrap_err();
|
||||
assert!(error.contains("16 MiB"), "{error}");
|
||||
child_gone(&directory.0.join("parent"));
|
||||
child_gone(&directory.0.join("child"));
|
||||
assert!(
|
||||
fs::read_dir(&directory.0)
|
||||
.unwrap()
|
||||
.all(|entry| entry.unwrap().metadata().unwrap().len() < 32)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lib_build_success_reaps_descendants_holding_output_open() {
|
||||
let directory = Directory::new();
|
||||
let mut command = Command::new("/bin/sh");
|
||||
command
|
||||
.current_dir(&directory.0)
|
||||
.args(["-c", "/bin/sleep 300 & echo $! > child; exit 0"]);
|
||||
assert!(process::run(command, &directory.0).unwrap().success());
|
||||
child_gone(&directory.0.join("child"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lib_build_scratch_budget_kills_and_reaps_before_returning() {
|
||||
let directory = Directory::new();
|
||||
let mut command = Command::new("/bin/sh");
|
||||
command.current_dir(&directory.0).args(["-c", "echo $$ > parent; /bin/sleep 300 & echo $! > child; /usr/bin/truncate -s 536870913 excess; wait"]);
|
||||
let error = process::run(command, &directory.0).unwrap_err();
|
||||
assert!(error.contains("512 MiB"), "{error}");
|
||||
child_gone(&directory.0.join("parent"));
|
||||
child_gone(&directory.0.join("child"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lib_build_probe_distinguishes_unloadable_exit_and_signal_from_budget_errors() {
|
||||
let directory = Directory::new();
|
||||
for script in ["exit 10", "ulimit -c 0; kill -SEGV $$"] {
|
||||
let mut command = Command::new("/bin/sh");
|
||||
command.args(["-c", script]);
|
||||
let status = process::status(command, &directory.0).unwrap();
|
||||
assert!(!status.success());
|
||||
}
|
||||
let mut command = Command::new("/bin/sh");
|
||||
command.args(["-c", "exit 10"]);
|
||||
assert!(
|
||||
process::run(command, &directory.0)
|
||||
.unwrap_err()
|
||||
.contains("Lib build failed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lib_build_script_header_cannot_select_host_tools_or_inject_arguments() {
|
||||
let fixture = super::Fixture::new();
|
||||
for header in [
|
||||
"#!/bin/bash",
|
||||
"#!/usr/bin/env node --eval unsafe",
|
||||
"#!/tmp/untrusted",
|
||||
] {
|
||||
let mut stage = dever::compiler::project::dependency::library::build::stage::Stage::new(
|
||||
&fixture.signed,
|
||||
)
|
||||
.unwrap();
|
||||
stage
|
||||
.write(
|
||||
"input/rootfs/usr/bin/script",
|
||||
format!("{header}\nexit 0\n").into_bytes(),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let tools = ["rootfs/usr/bin/script".to_owned()].into_iter().collect();
|
||||
assert!(
|
||||
stage
|
||||
.prepare_tools(&tools)
|
||||
.unwrap_err()
|
||||
.contains("signed interpreter")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
use super::{
|
||||
Fixture,
|
||||
fixture::{archive, hash, signed},
|
||||
};
|
||||
use dever::compiler::project::dependency::{
|
||||
Lock, Store,
|
||||
library::{
|
||||
Spec,
|
||||
lock::{Artifact, Library, Runtime},
|
||||
npm::Environment,
|
||||
},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
// Fixture 按既有 v6 协议编码,独立于实现中的私有 Receipt 类型。
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct Inputs {
|
||||
format: String,
|
||||
toolchain: String,
|
||||
libs: Vec<Library>,
|
||||
workers: Vec<Value>,
|
||||
npm: Vec<Environment>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct Tools {
|
||||
format: String,
|
||||
ecosystem: String,
|
||||
target: String,
|
||||
runtime_sha256: String,
|
||||
python_runtime_sha256: Option<String>,
|
||||
sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Receipt {
|
||||
ecosystem: String,
|
||||
roots: Vec<Spec>,
|
||||
sources: Vec<Artifact>,
|
||||
graph: Option<Environment>,
|
||||
runtime: Runtime,
|
||||
auxiliary_runtime: Option<Runtime>,
|
||||
native_entries: BTreeSet<String>,
|
||||
native_rejections: BTreeMap<String, String>,
|
||||
python_markers: Option<Value>,
|
||||
tools: Tools,
|
||||
frontend: String,
|
||||
python: Option<Value>,
|
||||
dynamic_requires: Vec<String>,
|
||||
inputs: Inputs,
|
||||
config_settings: BTreeMap<String, Value>,
|
||||
output: Artifact,
|
||||
}
|
||||
|
||||
fn prepare(fixture: &mut Fixture, addons: bool) -> Receipt {
|
||||
let node_pack = archive(&BTreeMap::from([(
|
||||
"fixture/node.txt".into(),
|
||||
b"author fixture runtime".to_vec(),
|
||||
)]));
|
||||
let python_pack = archive(&BTreeMap::from([(
|
||||
"fixture/python.txt".into(),
|
||||
b"author fixture auxiliary runtime".to_vec(),
|
||||
)]));
|
||||
let build_pack = archive(&BTreeMap::from([(
|
||||
"fixture/tools.txt".into(),
|
||||
b"already prepared output; no hooks run".to_vec(),
|
||||
)]));
|
||||
fixture.metadata.pack.sha256 = hash(&node_pack);
|
||||
let mut extra = BTreeMap::new();
|
||||
if addons {
|
||||
extra.insert(
|
||||
"package/foreign.node".into(),
|
||||
b"prebuilt foreign format".to_vec(),
|
||||
);
|
||||
}
|
||||
fixture.package(
|
||||
json!({"name":"built","version":"1.0.0","scripts":{"install":"must never run"}}),
|
||||
extra,
|
||||
);
|
||||
fixture.resolve(&["npm:built@1.0.0"]).unwrap();
|
||||
let graph = fixture.graph();
|
||||
let mut output = BTreeMap::from([
|
||||
(
|
||||
"node_modules/built/package.json".into(),
|
||||
serde_json::to_vec(
|
||||
&json!({"name":"built","version":"1.0.0","scripts":{"install":"must never run"}}),
|
||||
)
|
||||
.unwrap(),
|
||||
),
|
||||
(
|
||||
"node_modules/built/generated.js".into(),
|
||||
b"module.exports=42".to_vec(),
|
||||
),
|
||||
]);
|
||||
if addons {
|
||||
output.insert(
|
||||
"node_modules/built/foreign.node".into(),
|
||||
b"prebuilt foreign format".to_vec(),
|
||||
);
|
||||
}
|
||||
let bytes = archive(&output);
|
||||
fixture.put(&bytes).unwrap();
|
||||
let python = Runtime {
|
||||
name: "python".into(),
|
||||
version: "3.12.1".into(),
|
||||
sha256: hash(&python_pack),
|
||||
};
|
||||
let tools = Tools {
|
||||
format: "dever-registry-build-v1".into(),
|
||||
ecosystem: "npm".into(),
|
||||
target: fixture.target.name().into(),
|
||||
runtime_sha256: fixture.metadata.pack.sha256.clone(),
|
||||
python_runtime_sha256: Some(python.sha256.clone()),
|
||||
sha256: hash(&build_pack),
|
||||
};
|
||||
let target = fixture.target.name();
|
||||
let files=BTreeMap::from([
|
||||
(format!("runtime/npm/{target}/runtime.pack"),node_pack),
|
||||
(format!("runtime/npm/{target}/manifest.json"),serde_json::to_vec(&json!({"format":"dever-registry-runtime-v1","runtime":{"pack":fixture.metadata.pack,"target":target,"python_markers":null,"python_wheel_tags":[],"npm_libc":"glibc"}})).unwrap()),
|
||||
(format!("runtime/pip/{target}/runtime.pack"),python_pack),
|
||||
(format!("runtime/pip/{target}/manifest.json"),serde_json::to_vec(&json!({"format":"dever-registry-runtime-v1","runtime":{"pack":python,"target":target,"python_markers":{"implementation_name":"cpython","implementation_version":"3.12.1","os_name":"posix","platform_machine":"x86_64","platform_python_implementation":"CPython","platform_release":"","platform_system":"Linux","platform_version":"","python_full_version":"3.12.1","python_version":"3.12","sys_platform":"linux"},"python_wheel_tags":["py3-none-any"]}})).unwrap()),
|
||||
(format!("build/npm/{target}/build.pack"),build_pack),
|
||||
(format!("build/npm/{target}/manifest.json"),serde_json::to_vec(&tools).unwrap()),
|
||||
]);
|
||||
fixture.signed = signed(&fixture.directory, &files);
|
||||
Receipt {
|
||||
ecosystem: "npm".into(),
|
||||
roots: graph.roots.clone(),
|
||||
sources: fixture
|
||||
.lock
|
||||
.libs
|
||||
.iter()
|
||||
.flat_map(|lib| lib.artifacts.clone())
|
||||
.collect(),
|
||||
graph: Some(graph),
|
||||
runtime: fixture.metadata.pack.clone(),
|
||||
auxiliary_runtime: Some(python),
|
||||
native_entries: BTreeSet::new(),
|
||||
native_rejections: if addons {
|
||||
BTreeMap::from([("node_modules/built/foreign.node".into(), "format".into())])
|
||||
} else {
|
||||
BTreeMap::new()
|
||||
},
|
||||
python_markers: None,
|
||||
tools,
|
||||
frontend: "cfe67ebbc939762cd4ba7328943bf97a72d06a4b79071f056ebd8e5ab566a721".into(),
|
||||
python: None,
|
||||
dynamic_requires: vec![],
|
||||
inputs: serde_json::from_value(serde_json::to_value(&fixture.lock).unwrap()).unwrap(),
|
||||
config_settings: BTreeMap::new(),
|
||||
output: Artifact {
|
||||
target: target.into(),
|
||||
path: format!("build/npm/{}/installation.tgz", hash(&bytes)),
|
||||
bytes: bytes.len() as u64,
|
||||
sha256: hash(&bytes),
|
||||
source: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn attach(fixture: &mut Fixture, receipt: &Receipt) {
|
||||
fixture.lock.npm[0]["build"] = json!(hash(&serde_json::to_vec(receipt).unwrap()));
|
||||
fixture.lock.builds = vec![serde_json::to_value(receipt).unwrap()];
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_lifecycle_receipt_consumes_signed_inputs_without_network_or_hooks() {
|
||||
let mut fixture = Fixture::new();
|
||||
let receipt = prepare(&mut fixture, false);
|
||||
attach(&mut fixture, &receipt);
|
||||
fixture.documents.clear();
|
||||
let calls = fixture.calls.borrow().len();
|
||||
let files = fixture.install().unwrap();
|
||||
assert_eq!(
|
||||
files["node_modules/built/generated.js"],
|
||||
b"module.exports=42"
|
||||
);
|
||||
assert_eq!(fixture.calls.borrow().len(), calls);
|
||||
let text = fixture.lock.encode().unwrap();
|
||||
let reparsed = Lock::decode(&text).unwrap();
|
||||
fixture.lock = reparsed;
|
||||
fixture.install().unwrap();
|
||||
fixture.lock.builds[0]["frontend"] = json!("0".repeat(64));
|
||||
assert!(fixture.install().unwrap_err().contains("receipt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_native_receipt_accounts_for_all_addons_and_never_admits_failed_build() {
|
||||
let mut fixture = Fixture::new();
|
||||
let mut receipt = prepare(&mut fixture, true);
|
||||
attach(&mut fixture, &receipt);
|
||||
assert!(
|
||||
!fixture
|
||||
.install()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.any(|path| path.ends_with(".node"))
|
||||
);
|
||||
receipt.native_rejections.clear();
|
||||
attach(&mut fixture, &receipt);
|
||||
assert!(fixture.install().unwrap_err().contains("admission"));
|
||||
receipt
|
||||
.native_entries
|
||||
.insert("node_modules/built/foreign.node".into());
|
||||
attach(&mut fixture, &receipt);
|
||||
assert!(fixture.install().unwrap_err().contains("ELF"));
|
||||
receipt.native_entries.clear();
|
||||
receipt
|
||||
.native_rejections
|
||||
.insert("node_modules/built/new.node".into(), "unloadable".into());
|
||||
attach(&mut fixture, &receipt);
|
||||
assert!(fixture.install().unwrap_err().contains("admission"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_replay_rejects_malformed_binding_before_fetching_or_executing() {
|
||||
let mut fixture = Fixture::new();
|
||||
let mut receipt = prepare(&mut fixture, false);
|
||||
receipt.tools.runtime_sha256 = "0".repeat(64);
|
||||
attach(&mut fixture, &receipt);
|
||||
fixture.cache.borrow_mut().clear();
|
||||
let calls = fixture.calls.borrow().len();
|
||||
let error = dever::compiler::project::dependency::library::npm::restore(
|
||||
&fixture.lock,
|
||||
&fixture.graph(),
|
||||
fixture.target,
|
||||
&fixture.signed,
|
||||
&fixture,
|
||||
&fixture,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.contains("binding differs"), "{error}");
|
||||
assert_eq!(fixture.calls.borrow().len(), calls);
|
||||
assert!(fixture.cache.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_cached_receipt_rejects_tampering_but_needs_no_build_tools() {
|
||||
let mut fixture = Fixture::new();
|
||||
let receipt = prepare(&mut fixture, false);
|
||||
attach(&mut fixture, &receipt);
|
||||
let original = fixture.cache.borrow()[&receipt.output.sha256].clone();
|
||||
fixture
|
||||
.cache
|
||||
.borrow_mut()
|
||||
.insert(receipt.output.sha256.clone(), b"tampered".to_vec());
|
||||
assert!(fixture.install().unwrap_err().contains("digest"));
|
||||
fixture
|
||||
.cache
|
||||
.borrow_mut()
|
||||
.insert(receipt.output.sha256.clone(), original);
|
||||
std::fs::remove_file(fixture.directory.0.join(format!(
|
||||
"signed/versions/0.0.1/build/npm/{}/build.pack",
|
||||
fixture.target.name()
|
||||
)))
|
||||
.unwrap();
|
||||
fixture.install().unwrap();
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! 只在开发 Pod 执行:读取已安装工具,组装临时签名输入;不安装或激活软件。
|
||||
|
||||
use super::{
|
||||
Fixture,
|
||||
fixture::{archive, hash, signed},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{collections::BTreeMap, fs, path::Path, process::Command};
|
||||
|
||||
pub(super) fn prepare(fixture: &mut Fixture) {
|
||||
let target = fixture.target.name();
|
||||
let mut assets = BTreeMap::new();
|
||||
let mut dependencies = BTreeMap::new();
|
||||
let node = Path::new("/usr/local/bin/node");
|
||||
let python = Path::new("/usr/bin/python3");
|
||||
let node_bytes = fs::read(node).expect("Node must already exist in the development Pod");
|
||||
let python_bytes = fs::read(python).expect("Python must already exist in the development Pod");
|
||||
libraries(node, &mut dependencies);
|
||||
libraries(python, &mut dependencies);
|
||||
let markers = json!({"implementation_name":"cpython","implementation_version":"3.13.0","os_name":"posix","platform_machine":"x86_64","platform_python_implementation":"CPython","platform_release":"","platform_system":"Linux","platform_version":"","python_full_version":"3.13.0","python_version":"3.13","sys_platform":"linux"});
|
||||
let node_pack=archive(&BTreeMap::from([
|
||||
("bin/node".into(),node_bytes),
|
||||
("shared.txt".into(),b"shared runtime fixture".to_vec()),
|
||||
("dever-runtime.json".into(),serde_json::to_vec(&json!({"format":"dever-worker-runtime-v1","ecosystem":"npm","target":target,"executable":"bin/node","arguments":[],"build":null,"python_wheel_tags":[],"python_extension_suffixes":[],"python_markers":null})).unwrap()),
|
||||
]));
|
||||
let python_pack=archive(&BTreeMap::from([
|
||||
("bin/python3".into(),python_bytes),
|
||||
("shared.txt".into(),b"shared runtime fixture".to_vec()),
|
||||
("dever-runtime.json".into(),serde_json::to_vec(&json!({"format":"dever-worker-runtime-v1","ecosystem":"pip","target":target,"executable":"bin/python3","arguments":["-I","-S","-B"],"build":null,"python_wheel_tags":["py3-none-any"],"python_extension_suffixes":[".cpython-313-x86_64-linux-gnu.so"],"python_markers":markers})).unwrap()),
|
||||
]));
|
||||
fixture.metadata.pack.sha256 = hash(&node_pack);
|
||||
fixture.metadata.pack.version = "24.21.0".into();
|
||||
let python_identity = json!({"name":"python","version":"3.13.0","sha256":hash(&python_pack)});
|
||||
let mut tools = BTreeMap::new();
|
||||
let mut executables = Vec::new();
|
||||
for (name, path) in [
|
||||
("sh", "/bin/sh"),
|
||||
("cc", "/usr/bin/cc"),
|
||||
("c++", "/usr/bin/c++"),
|
||||
("as", "/usr/bin/as"),
|
||||
("ld", "/usr/bin/ld"),
|
||||
("ar", "/usr/bin/ar"),
|
||||
("ranlib", "/usr/bin/ranlib"),
|
||||
("make", "/usr/bin/make"),
|
||||
("cc1", "/usr/libexec/gcc/x86_64-linux-gnu/14/cc1"),
|
||||
("collect2", "/usr/libexec/gcc/x86_64-linux-gnu/14/collect2"),
|
||||
] {
|
||||
let path = Path::new(path);
|
||||
libraries(path, &mut dependencies);
|
||||
let destination = format!("rootfs/usr/bin/{name}");
|
||||
tools.insert(destination.clone(), fs::read(path).unwrap());
|
||||
executables.push(destination);
|
||||
}
|
||||
files(
|
||||
Path::new("/usr/local/lib/node_modules/npm"),
|
||||
"npm/frontend/node_modules/npm/",
|
||||
&mut tools,
|
||||
);
|
||||
tools.insert(
|
||||
"rootfs/usr/bin/fixture-node".into(),
|
||||
b"#!/usr/bin/env node\nprocess.stdout.write('signed script');\n".to_vec(),
|
||||
);
|
||||
executables.push("rootfs/usr/bin/fixture-node".into());
|
||||
tools.insert("dever-build.json".into(),serde_json::to_vec(&json!({"format":"dever-lib-build-v1","ecosystem":"npm","target":target,"runtime_sha256":fixture.metadata.pack.sha256,"python_runtime_sha256":python_identity["sha256"],"executables":executables})).unwrap());
|
||||
let tools_pack = archive(&tools);
|
||||
assets.insert(format!("build/npm/{target}/manifest.json"),serde_json::to_vec(&json!({"format":"dever-registry-build-v1","ecosystem":"npm","target":target,"runtime_sha256":fixture.metadata.pack.sha256,"python_runtime_sha256":python_identity["sha256"],"sha256":hash(&tools_pack)})).unwrap());
|
||||
assets.insert(format!("build/npm/{target}/build.pack"), tools_pack);
|
||||
assets.insert(format!("runtime/npm/{target}/runtime.pack"), node_pack);
|
||||
assets.insert(format!("runtime/pip/{target}/runtime.pack"), python_pack);
|
||||
assets.insert(format!("runtime/npm/{target}/manifest.json"),serde_json::to_vec(&json!({"format":"dever-registry-runtime-v1","runtime":{"pack":fixture.metadata.pack,"target":target,"python_markers":null,"python_wheel_tags":[],"npm_libc":"glibc"}})).unwrap());
|
||||
assets.insert(format!("runtime/pip/{target}/manifest.json"),serde_json::to_vec(&json!({"format":"dever-registry-runtime-v1","runtime":{"pack":python_identity,"target":target,"python_markers":markers,"python_wheel_tags":["py3-none-any"]}})).unwrap());
|
||||
for (name, bytes) in dependencies {
|
||||
assets.insert(format!("process-runtime/{target}/lib/{name}"), bytes);
|
||||
}
|
||||
fixture.signed = signed(&fixture.directory, &assets);
|
||||
}
|
||||
|
||||
fn files(root: &Path, prefix: &str, output: &mut BTreeMap<String, Vec<u8>>) {
|
||||
for entry in fs::read_dir(root).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let name = entry.file_name().into_string().unwrap();
|
||||
let kind = entry.file_type().unwrap();
|
||||
if kind.is_dir() {
|
||||
files(&entry.path(), &format!("{prefix}{name}/"), output);
|
||||
} else if kind.is_file() {
|
||||
output.insert(format!("{prefix}{name}"), fs::read(entry.path()).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn libraries(program: &Path, files: &mut BTreeMap<String, Vec<u8>>) {
|
||||
let output = Command::new("/usr/bin/ldd").arg(program).output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"ldd fixture input {}",
|
||||
program.display()
|
||||
);
|
||||
for path in String::from_utf8(output.stdout)
|
||||
.unwrap()
|
||||
.split_whitespace()
|
||||
.filter(|value| value.starts_with('/'))
|
||||
{
|
||||
let path = Path::new(path);
|
||||
let name = path.file_name().unwrap().to_str().unwrap().to_owned();
|
||||
let bytes = fs::read(path).unwrap();
|
||||
if let Some(previous) = files.insert(name, bytes.clone()) {
|
||||
assert_eq!(previous, bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! 从合法 Dever Port 到实际嵌入 ELF;无 fails 的声明必须在检查期拒绝。
|
||||
|
||||
use super::{Fixture, app, command, write};
|
||||
use dever::compiler::{
|
||||
build::{self, BuildTarget, Resource},
|
||||
project::Project,
|
||||
};
|
||||
use std::{fs, os::unix::fs::PermissionsExt, process::Command};
|
||||
|
||||
mod arguments;
|
||||
mod command;
|
||||
mod jobs;
|
||||
mod roots;
|
||||
|
||||
#[test]
|
||||
fn external_builds_and_executes_after_source_removal() {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = &fixture.directory.0;
|
||||
write(
|
||||
root,
|
||||
"module/demo/value/app.dever",
|
||||
app("port.native(input) + input"),
|
||||
);
|
||||
write(root, "module/demo/value/api.dever", command());
|
||||
write(
|
||||
root,
|
||||
"module/demo/value/port.dever",
|
||||
"native(input: Int) (value: Int)\n",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/demo/value/adapter.dever",
|
||||
"external exec \"worker\" {}\n",
|
||||
);
|
||||
let port = root.join("module/demo/value/port.dever");
|
||||
assert!(
|
||||
Project::load(root)
|
||||
.unwrap()
|
||||
.check()
|
||||
.unwrap_err()
|
||||
.contains("fails")
|
||||
);
|
||||
let source = fs::read_to_string(&port).unwrap().replace(
|
||||
"native(input: Int) (value: Int)",
|
||||
"native(input: Int) (value: Int) fails dever.api.Error",
|
||||
);
|
||||
fs::write(&port, source).unwrap();
|
||||
roots::prepare(root);
|
||||
jobs::prepare(root);
|
||||
let project = Project::load(root).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let contract = program.external_workers().next().unwrap();
|
||||
let manifest: serde_json::Value = serde_json::from_str(&contract.contract).unwrap();
|
||||
let identity = "dever-component-v1;setting=none;operation=native;input=input:wire-v1:Output:Int;;output=value:wire-v1:Output:Int;;dever.api.Error.Invalid;dever.api.Error.Unauthorized;dever.api.Error.Forbidden;dever.api.Error.NotFound;dever.api.Error.Conflict;dever.api.Error.TooManyRequests;";
|
||||
let checksum = identity.bytes().fold(0xcbf29ce484222325u64, |hash, byte| {
|
||||
(hash ^ u64::from(byte)).wrapping_mul(0x100000001b3)
|
||||
});
|
||||
assert_eq!(manifest["schema"], format!("{checksum:016x}"));
|
||||
let worker = root.join("fixture");
|
||||
let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../test/rust/runtime/worker/fixture.rs");
|
||||
let result = Command::new("rustup")
|
||||
.args(["run", "1.95.0", "rustc", "--edition=2024", "-O"])
|
||||
.arg(source)
|
||||
.arg("-o")
|
||||
.arg(&worker)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
let launch = serde_json::to_vec(&serde_json::json!({"format":"dever-worker-launch-v1","ecosystem":"exec","entry":contract.entry,"executable":contract.entry,"arguments":[],"working_directory":"module/demo/value"})).unwrap();
|
||||
let resources = [
|
||||
(contract.entry.clone(), fs::read(&worker).unwrap(), true),
|
||||
(
|
||||
format!("{}.dever-worker.json", contract.entry),
|
||||
launch,
|
||||
false,
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(path, bytes, executable)| Resource {
|
||||
path,
|
||||
bytes,
|
||||
executable,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let binary = build::build_with_resources(
|
||||
&program,
|
||||
&project.sources,
|
||||
"main",
|
||||
&root.join("app"),
|
||||
BuildTarget::host().unwrap(),
|
||||
&resources,
|
||||
)
|
||||
.unwrap();
|
||||
fs::remove_dir_all(root.join("module")).unwrap();
|
||||
fs::remove_file(worker).unwrap();
|
||||
fs::create_dir(root.join("data")).unwrap();
|
||||
fs::set_permissions(root.join("data"), fs::Permissions::from_mode(0o755)).unwrap();
|
||||
roots::commands(&binary);
|
||||
roots::http(&binary, root);
|
||||
jobs::run(&binary, root);
|
||||
}
|
||||
|
||||
fn command_cases(binary: &std::path::Path, entry: &str) {
|
||||
for _ in 0..2 {
|
||||
let result = Command::new(binary)
|
||||
.env_clear()
|
||||
.args([entry, "{\"input\":8}"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(
|
||||
result.stdout,
|
||||
b"{\"code\":0,\"message\":\"ok\",\"data\":50}\n"
|
||||
);
|
||||
}
|
||||
for (input, expected) in [
|
||||
(-1, "Worker output does not match checked Port"),
|
||||
(-2, "Worker returned an undeclared business error"),
|
||||
(-3, "Invalid"),
|
||||
] {
|
||||
let output = Command::new(binary)
|
||||
.env_clear()
|
||||
.args([entry, &format!("{{\"input\":{input}}}")])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
error.contains(expected),
|
||||
"input {input}, expected {expected}: {error}"
|
||||
);
|
||||
if input != -3 {
|
||||
assert!(error.contains("adapter.dever:"), "{error}");
|
||||
}
|
||||
}
|
||||
// Worker 已交付合法终态;根入口仍须等 shutdown,不能提前发布成功结果。
|
||||
for input in [-4, -5] {
|
||||
let output = Command::new(binary)
|
||||
.env_clear()
|
||||
.args([entry, &format!("{{\"input\":{input}}}")])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"input {input}: shutdown failure was discarded"
|
||||
);
|
||||
assert!(
|
||||
output.stdout.is_empty(),
|
||||
"input {input}: CMD published output before cleanup"
|
||||
);
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
error.contains("invalid Worker shutdown acknowledgement"),
|
||||
"input {input}: {error}"
|
||||
);
|
||||
if input == -5 {
|
||||
assert!(
|
||||
error.contains("dever.api.Error.Invalid") && error.contains("cleanup:"),
|
||||
"business error and cleanup must both survive: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! 原始 argv 不消费配置参数或命令名;非法 UTF-8 通过语言失败合同返回。
|
||||
|
||||
use super::{Fixture, Project, build, write};
|
||||
use std::{ffi::OsString, os::unix::ffi::OsStringExt, process::Command};
|
||||
|
||||
#[test]
|
||||
fn native_arguments_preserve_all_values_and_reject_invalid_utf8() {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = &fixture.directory.0;
|
||||
write(
|
||||
root,
|
||||
"arguments.dever",
|
||||
"arguments() (values: List<Text>) { values = dever.process.arguments() }\n",
|
||||
);
|
||||
let project = Project::load(&root.join("arguments.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"arguments",
|
||||
&root.join("arguments"),
|
||||
)
|
||||
.unwrap();
|
||||
for arguments in [
|
||||
vec![],
|
||||
vec![
|
||||
"--config",
|
||||
"配置.json",
|
||||
"demo.run",
|
||||
"",
|
||||
"a b",
|
||||
"$(touch never)",
|
||||
"\"quoted\"",
|
||||
"雪☃",
|
||||
],
|
||||
] {
|
||||
let result = Command::new(&binary)
|
||||
.args(&arguments)
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(
|
||||
String::from_utf8(result.stdout).unwrap(),
|
||||
format!("{arguments:?}\n")
|
||||
);
|
||||
}
|
||||
let result = Command::new(binary)
|
||||
.arg(OsString::from_vec(vec![0xff, 0xfe]))
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!result.status.success());
|
||||
assert!(result.stdout.is_empty());
|
||||
let error = String::from_utf8(result.stderr).unwrap();
|
||||
assert!(
|
||||
error.contains("ArgumentsResult.Failed") && error.contains("lib/dever/process.dever:"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! 真实 Dever command Port 经过检查、Rust 生成与原生执行,删除源码后仍独立运行。
|
||||
|
||||
use super::{BuildTarget, Fixture, Project, Resource, app, build, command, write};
|
||||
use std::{fs, process::Command};
|
||||
|
||||
#[test]
|
||||
fn native_command_preserves_binary_streams_argv_and_nonzero_exit() {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = &fixture.directory.0;
|
||||
let body = "output = port.execute([\"--echo\", \"a b\", \"$(touch never)\", \"雪☃\"], dever.bytes.fromInts([0, 255, 1]))\n answer = output.code == 7 and output.stdout == dever.bytes.fromInts([0, 255, 1]) and output.stderr == dever.bytes.fromText(\"a b\\n$(touch never)\\n雪☃\") and input == 8";
|
||||
write(
|
||||
root,
|
||||
"module/demo/value/app.dever",
|
||||
app("0")
|
||||
.replace("answer: Int", "answer: Bool")
|
||||
.replace("answer = 0", body),
|
||||
);
|
||||
write(root, "module/demo/value/api.dever", command());
|
||||
write(
|
||||
root,
|
||||
"module/demo/value/port.dever",
|
||||
"execute(args: List<Text>, stdin: Bytes?) (output: dever.process.Output) fails dever.process.Error\n",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/demo/value/adapter.dever",
|
||||
"external command \"command\" {}\n",
|
||||
);
|
||||
let project = Project::load(root).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let external = program.external_workers().next().unwrap();
|
||||
let fixture = root.join("fixture");
|
||||
let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../test/rust/runtime/worker/fixture.rs");
|
||||
let compiled = Command::new("rustup")
|
||||
.args(["run", "1.95.0", "rustc", "--edition=2024", "-O"])
|
||||
.arg(source)
|
||||
.arg("-o")
|
||||
.arg(&fixture)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
compiled.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&compiled.stderr)
|
||||
);
|
||||
let launch = serde_json::to_vec(&serde_json::json!({"format":"dever-command-launch-v1","ecosystem":"command","entry":external.entry,"executable":external.entry,"arguments":[],"working_directory":"module/demo/value"})).unwrap();
|
||||
let resources = [
|
||||
(external.entry.clone(), fs::read(&fixture).unwrap(), true),
|
||||
(
|
||||
format!("{}.dever-command.json", external.entry),
|
||||
launch,
|
||||
false,
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(path, bytes, executable)| Resource {
|
||||
path,
|
||||
bytes,
|
||||
executable,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let binary = build::build_with_resources(
|
||||
&program,
|
||||
&project.sources,
|
||||
"main",
|
||||
&root.join("app"),
|
||||
BuildTarget::host().unwrap(),
|
||||
&resources,
|
||||
)
|
||||
.unwrap();
|
||||
fs::remove_dir_all(root.join("module")).unwrap();
|
||||
fs::remove_file(fixture).unwrap();
|
||||
for _ in 0..2 {
|
||||
let result = Command::new(&binary)
|
||||
.env_clear()
|
||||
.args(["demo.value.value", "{\"input\":8}"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(
|
||||
result.stdout,
|
||||
b"{\"code\":0,\"message\":\"ok\",\"data\":true}\n"
|
||||
);
|
||||
}
|
||||
write(
|
||||
root,
|
||||
"config/setting.json",
|
||||
r#"{"adapter":{"demo.value":{"command":{"output_limit":1}}}}"#,
|
||||
);
|
||||
let failed = Command::new(&binary)
|
||||
.env_clear()
|
||||
.args(["demo.value.value", "{\"input\":8}"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!failed.status.success());
|
||||
let error = String::from_utf8_lossy(&failed.stderr);
|
||||
assert!(
|
||||
error.contains("external command failed") && error.contains("adapter.dever:"),
|
||||
"{error}"
|
||||
);
|
||||
let output = serde_json::from_slice::<serde_json::Value>(&failed.stdout);
|
||||
assert!(output.is_err() || output.unwrap()["code"] != 0);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! 在同一 Worker 原生产物上验证持久 Job 的 worker-only 服务根。
|
||||
|
||||
use super::{roots::Server, write};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
fs,
|
||||
path::Path,
|
||||
process::{Command, Stdio},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub(super) fn prepare(root: &Path) {
|
||||
let app = root.join("module/demo/value/app.dever");
|
||||
let source = fs::read_to_string(&app).unwrap();
|
||||
fs::write(app, format!("{source}{APP}")).unwrap();
|
||||
let api = root.join("module/demo/value/api.dever");
|
||||
let source = fs::read_to_string(&api).unwrap();
|
||||
fs::write(api, format!("{source}\ncmd submit = app.submit\n")).unwrap();
|
||||
write(root, "module/demo/value/job.dever", JOB);
|
||||
}
|
||||
|
||||
pub(super) fn run(binary: &Path, root: &Path) {
|
||||
let config = root.join("setting-worker.json");
|
||||
fs::write(
|
||||
&config,
|
||||
json!({
|
||||
"database":{"default":{"type":"sqlite","path":"data/worker.db","max_connections":4}},
|
||||
"sites":{"public":{"path":"","hosts":["fixture.test"]}},
|
||||
"http":{"host":"worker-only-no-listener","port":0},
|
||||
"runtime":{"mode":"worker","shutdown_ms":1000},
|
||||
"job":{"workers":1,"poll_ms":10,"lease_ms":2000}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let database = root.join("data/worker.db");
|
||||
for input in [8, -4] {
|
||||
let submitted = Command::new(binary)
|
||||
.env_clear()
|
||||
.arg("--config")
|
||||
.arg(&config)
|
||||
.args([
|
||||
"demo.value.submit",
|
||||
&json!({"input":input,"key":format!("root-{input}")}).to_string(),
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
submitted.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&submitted.stderr)
|
||||
);
|
||||
let response: Value = serde_json::from_slice(&submitted.stdout).unwrap();
|
||||
let id = response["data"].as_str().unwrap();
|
||||
let pending = queue_row(&database, id);
|
||||
assert_eq!(pending["state"], "pending");
|
||||
assert_eq!(pending["attempt"], 0);
|
||||
assert_eq!(pending["execution"], json!({"kind":"System","tenant":null}));
|
||||
assert_eq!(pending["tenant"], 0);
|
||||
let log = root.join("job.stderr");
|
||||
let mut server = Server(
|
||||
Command::new(binary)
|
||||
.env_clear()
|
||||
.arg("--config")
|
||||
.arg(&config)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(fs::File::create(&log).unwrap())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let completed = loop {
|
||||
assert!(
|
||||
server.0.try_wait().unwrap().is_none(),
|
||||
"worker root exited: {}",
|
||||
fs::read_to_string(&log).unwrap()
|
||||
);
|
||||
let row = queue_row(&database, id);
|
||||
if row["state"] == "completed" {
|
||||
break row;
|
||||
}
|
||||
assert!(
|
||||
row["state"] == "pending" || row["state"] == "running",
|
||||
"{row}"
|
||||
);
|
||||
assert!(Instant::now() < deadline, "Job did not complete: {row}");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
};
|
||||
assert_eq!(completed["attempt"], 1);
|
||||
assert_eq!(completed["execution"], pending["execution"]);
|
||||
assert_eq!(completed["rows"], 0, "readonly Job changed Model data");
|
||||
server.shutdown(&log, input == -4);
|
||||
assert_eq!(queue_row(&database, id), completed);
|
||||
}
|
||||
}
|
||||
|
||||
fn queue_row(database: &Path, id: &str) -> Value {
|
||||
// 使用 dev 已有 sqlite3 模块只读检查本测试队列;不向私有队列表注入数据。
|
||||
let output = Command::new("python3")
|
||||
.args(["-c", r#"import json, pathlib, sqlite3, sys
|
||||
with sqlite3.connect(pathlib.Path(sys.argv[1]).as_uri() + '?mode=ro', uri=True) as database:
|
||||
row = database.execute('SELECT state,attempt,execution,execution_tenant FROM _dever_job WHERE id=?', (sys.argv[2],)).fetchone()
|
||||
rows = database.execute('SELECT COUNT(*) FROM demo_value_model').fetchone()[0]
|
||||
print(json.dumps(dict(state=row[0], attempt=row[1], execution=json.loads(row[2]), tenant=row[3], rows=rows)))
|
||||
"#])
|
||||
.arg(database)
|
||||
.arg(id)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
serde_json::from_slice(&output.stdout).unwrap()
|
||||
}
|
||||
|
||||
const APP: &str = r#"type Work { input: Int }
|
||||
|
||||
submit(input: Int, key: Text) (id: Id) {
|
||||
id = dever.job.enqueue(job.perform, Work { input = input }, key)
|
||||
}
|
||||
|
||||
perform(input: Work) () { answer = stored(input.input) }
|
||||
"#;
|
||||
|
||||
const JOB: &str = r#"database default
|
||||
|
||||
long job perform(input: app.Work) () retry(1) timeout(1000) {
|
||||
app.perform(input)
|
||||
}
|
||||
"#;
|
||||
@@ -0,0 +1,158 @@
|
||||
//! 同一原生产物覆盖普通 CMD、只读数据库 CMD 和服务根的 Worker 收口。
|
||||
|
||||
use super::{app, command, command_cases, write};
|
||||
use std::{
|
||||
fs,
|
||||
io::{Read, Write},
|
||||
net::{TcpListener, TcpStream},
|
||||
path::Path,
|
||||
process::{Child, Command, Stdio},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub(super) fn prepare(root: &Path) {
|
||||
let mut source = app("port.native(input) + input");
|
||||
source.push_str(
|
||||
r#"stored(input: Int) (answer: Int) {
|
||||
count = model.count()
|
||||
answer = count + port.native(input) + input
|
||||
}
|
||||
"#,
|
||||
);
|
||||
write(root, "module/demo/value/app.dever", source);
|
||||
let mut source = command();
|
||||
source.push_str(
|
||||
r#"cmd stored = app.stored
|
||||
|
||||
public get stored = app.stored
|
||||
"#,
|
||||
);
|
||||
write(root, "module/demo/value/api.dever", source);
|
||||
write(
|
||||
root,
|
||||
"module/demo/value/model.dever",
|
||||
r#"global type Value { label: Text }
|
||||
"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"config/setting.json",
|
||||
r#"{"database":{"default":{"type":"sqlite","path":"data/worker.db","max_connections":1}}}"#,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn commands(binary: &Path) {
|
||||
for entry in ["demo.value.value", "demo.value.stored"] {
|
||||
command_cases(binary, entry);
|
||||
}
|
||||
assert!(binary.parent().unwrap().join("data/worker.db").is_file());
|
||||
}
|
||||
|
||||
pub(super) fn http(binary: &Path, root: &Path) {
|
||||
for bad_shutdown in [false, true] {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
drop(listener);
|
||||
let config = root.join("setting-http.json");
|
||||
fs::write(
|
||||
&config,
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"http":{"host":"127.0.0.1","port":port},
|
||||
"sites":{"public":{"path":"","hosts":["fixture.test"]}},
|
||||
"runtime":{"mode":"api","shutdown_ms":1000},
|
||||
"database":{"default":{"type":"sqlite","path":"data/worker.db","max_connections":1}}
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let log = root.join("http.stderr");
|
||||
let mut server = Server(
|
||||
Command::new(binary)
|
||||
.env_clear()
|
||||
.arg("--config")
|
||||
.arg(config)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(fs::File::create(&log).unwrap())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while TcpStream::connect(("127.0.0.1", port)).is_err() {
|
||||
assert!(
|
||||
server.0.try_wait().unwrap().is_none(),
|
||||
"server exited during startup: {}",
|
||||
fs::read_to_string(&log).unwrap()
|
||||
);
|
||||
assert!(Instant::now() < deadline, "server did not bind");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert_eq!(request(port, 8), (200, 50));
|
||||
let (status, identity) = request(port, 99);
|
||||
assert_eq!(status, 200);
|
||||
assert!(identity > 99, "Worker PID was not returned");
|
||||
assert_eq!(request(port, -3).0, 400);
|
||||
if bad_shutdown {
|
||||
// 当前 HTTP 调用先成功;停机错误只能在共享服务根结束时传播。
|
||||
assert_eq!(request(port, -4), (200, 38));
|
||||
}
|
||||
for _ in 0..2 {
|
||||
assert_eq!(request(port, 99), (200, identity));
|
||||
}
|
||||
server.shutdown(&log, bad_shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
fn request(port: u16, input: i64) -> (u16, i64) {
|
||||
let mut socket = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
write!(socket, "GET /demo/value/stored?input={input} HTTP/1.1\r\nHost: fixture.test\r\nConnection: close\r\n\r\n").unwrap();
|
||||
let mut response = String::new();
|
||||
socket.read_to_string(&mut response).unwrap();
|
||||
let (head, body) = response.split_once("\r\n\r\n").unwrap();
|
||||
let status = head.split_whitespace().nth(1).unwrap().parse().unwrap();
|
||||
let body: serde_json::Value = serde_json::from_str(body).unwrap();
|
||||
if status == 200 {
|
||||
assert_eq!(body["code"], 0);
|
||||
(status, body["data"].as_i64().unwrap())
|
||||
} else {
|
||||
assert_eq!(body["code"], status);
|
||||
assert!(body["data"].is_null());
|
||||
(status, 0)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Server(pub(super) Child);
|
||||
|
||||
impl Server {
|
||||
pub(super) fn shutdown(&mut self, log: &Path, bad_shutdown: bool) {
|
||||
rustix::process::kill_process(
|
||||
rustix::process::Pid::from_raw(self.0.id() as i32).unwrap(),
|
||||
rustix::process::Signal::TERM,
|
||||
)
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let status = loop {
|
||||
if let Some(status) = self.0.try_wait().unwrap() {
|
||||
break status;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "service root did not finish");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
let error = fs::read_to_string(log).unwrap();
|
||||
assert_eq!(status.success(), !bad_shutdown, "{error}");
|
||||
assert_eq!(
|
||||
error.contains("invalid Worker shutdown acknowledgement"),
|
||||
bad_shutdown,
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user