feat: publish Dever core with project scaffolding and versioned AI skills
This commit is contained in:
1 parent
70fa73cdc2
commit
ac458ffa7f
960 files changed
+221319
-1163
No files matched your search
@@ -0,0 +1,345 @@
|
||||
use dever_backend_bridge::{
|
||||
BinaryResource, LinkKind, Target, emit_object, emit_object_with_resources, link,
|
||||
};
|
||||
use std::fs;
|
||||
#[path = "dever-tests/tests/support/temp.rs"]
|
||||
mod temp;
|
||||
|
||||
const IR: &str = "define i64 @answer() { ret i64 42 }";
|
||||
|
||||
#[test]
|
||||
fn binary_initializers_reject_invalid_declarations_and_preserve_code_limit() {
|
||||
let resource = BinaryResource {
|
||||
symbol: "payload",
|
||||
bytes: &[1, 2],
|
||||
};
|
||||
for declaration in [
|
||||
"@other = external constant [2 x i8]",
|
||||
"@payload = external constant [3 x i8]",
|
||||
"@payload = external constant [2 x i16]",
|
||||
"@payload = external global [2 x i8]",
|
||||
"@payload = constant [2 x i8] zeroinitializer",
|
||||
"@payload = external thread_local constant [2 x i8]",
|
||||
] {
|
||||
let ir = format!("{declaration}\ndefine ptr @read() {{ ret ptr @payload }}");
|
||||
assert!(emit_object_with_resources(&ir, Target::LinuxX86_64, &[resource]).is_err());
|
||||
}
|
||||
let ir = "@payload = external constant [2 x i8]\ndefine ptr @read() { ret ptr @payload }";
|
||||
assert!(
|
||||
emit_object_with_resources(ir, Target::LinuxX86_64, &[resource, resource])
|
||||
.unwrap_err()
|
||||
.contains("duplicate")
|
||||
);
|
||||
assert!(
|
||||
emit_object_with_resources(
|
||||
"@payload = external constant [2 x i8]",
|
||||
Target::LinuxX86_64,
|
||||
&[resource]
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("used external")
|
||||
);
|
||||
for symbol in ["", "1bad", "with.dot", "bad\0name"] {
|
||||
assert!(
|
||||
emit_object_with_resources(
|
||||
ir,
|
||||
Target::LinuxX86_64,
|
||||
&[BinaryResource { symbol, ..resource }]
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("symbol")
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
emit_object_with_resources(ir, Target::LinuxX86_64, &vec![resource; 4097])
|
||||
.unwrap_err()
|
||||
.contains("4096")
|
||||
);
|
||||
assert!(
|
||||
emit_object(&" ".repeat(8 * 1024 * 1024 + 1), Target::LinuxX86_64)
|
||||
.unwrap_err()
|
||||
.contains("8388608")
|
||||
);
|
||||
let empty = BinaryResource {
|
||||
symbol: "empty",
|
||||
bytes: &[],
|
||||
};
|
||||
for target in Target::ALL {
|
||||
assert!(
|
||||
!emit_object_with_resources(
|
||||
"@empty = external constant [0 x i8]\ndefine ptr @read() { ret ptr @empty }",
|
||||
target,
|
||||
&[empty]
|
||||
)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
#[test]
|
||||
fn binary_payload_larger_than_text_limit_links_and_preserves_every_byte() {
|
||||
let bytes = (0..9 * 1024 * 1024)
|
||||
.map(|index| (index % 251) as u8)
|
||||
.collect::<Vec<_>>();
|
||||
let ir = format!(
|
||||
r#"@payload = external constant [{} x i8]
|
||||
define void @_start() noreturn {{
|
||||
%written = call i64 asm sideeffect "syscall", "={{rax}},{{rax}},{{rdi}},{{rsi}},{{rdx}},~{{rcx}},~{{r11}},~{{memory}}"(i64 1, i64 1, ptr @payload, i64 {})
|
||||
%complete = icmp eq i64 %written, {}
|
||||
%failed = select i1 %complete, i64 0, i64 1
|
||||
call void asm sideeffect "syscall", "{{rax}},{{rdi}},~{{rcx}},~{{r11}},~{{memory}}"(i64 60, i64 %failed)
|
||||
unreachable
|
||||
}}"#,
|
||||
bytes.len(),
|
||||
bytes.len(),
|
||||
bytes.len()
|
||||
);
|
||||
assert!(ir.len() < 1024);
|
||||
let directory = temp::TemporaryDirectory::new();
|
||||
let object = directory.path().join("entry.o");
|
||||
fs::write(
|
||||
&object,
|
||||
emit_object_with_resources(
|
||||
&ir,
|
||||
Target::LinuxX86_64,
|
||||
&[BinaryResource {
|
||||
symbol: "payload",
|
||||
bytes: &bytes,
|
||||
}],
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let executable = directory.path().join("program");
|
||||
link(
|
||||
Target::LinuxX86_64,
|
||||
LinkKind::Executable,
|
||||
&[object],
|
||||
&executable,
|
||||
Some("_start"),
|
||||
)
|
||||
.unwrap();
|
||||
let output = directory.path().join("bytes");
|
||||
let status = std::process::Command::new(executable)
|
||||
.env_clear()
|
||||
.stdout(fs::File::create(&output).unwrap())
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success());
|
||||
assert_eq!(fs::read(output).unwrap(), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn six_targets_emit_real_architecture_specific_objects_and_link() {
|
||||
let directory = temp::TemporaryDirectory::new();
|
||||
for (index, target) in Target::ALL.into_iter().enumerate() {
|
||||
let bytes = emit_object(IR, target).unwrap();
|
||||
assert!(bytes.len() > 64);
|
||||
match target {
|
||||
Target::LinuxX86_64 | Target::LinuxAarch64 => {
|
||||
assert_eq!(&bytes[..4], b"\x7fELF");
|
||||
let machine = u16::from_le_bytes(bytes[18..20].try_into().unwrap());
|
||||
assert_eq!(
|
||||
machine,
|
||||
if target == Target::LinuxX86_64 {
|
||||
62
|
||||
} else {
|
||||
183
|
||||
}
|
||||
);
|
||||
}
|
||||
Target::MacosX86_64 | Target::MacosAarch64 => {
|
||||
assert_eq!(&bytes[..4], b"\xcf\xfa\xed\xfe");
|
||||
let cpu = u32::from_le_bytes(bytes[4..8].try_into().unwrap());
|
||||
assert_eq!(
|
||||
cpu,
|
||||
if target == Target::MacosX86_64 {
|
||||
0x1000007
|
||||
} else {
|
||||
0x100000c
|
||||
}
|
||||
);
|
||||
}
|
||||
Target::WindowsX86_64 | Target::WindowsAarch64 => {
|
||||
let machine = u16::from_le_bytes(bytes[..2].try_into().unwrap());
|
||||
assert_eq!(
|
||||
machine,
|
||||
if target == Target::WindowsX86_64 {
|
||||
0x8664
|
||||
} else {
|
||||
0xaa64
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
let object = directory.path().join(format!("{index}.o"));
|
||||
let output = directory.path().join(format!("{index}.image"));
|
||||
fs::write(&object, bytes).unwrap();
|
||||
link(target, LinkKind::Shared, &[object], &output, None).unwrap();
|
||||
assert!(fs::metadata(output).unwrap().len() > 128);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_ir_target_mismatch_and_failed_link_do_not_poison_later_calls() {
|
||||
assert!(emit_object("not LLVM", Target::LinuxX86_64).is_err());
|
||||
assert!(emit_object("define i64 @bad() { ret i32 3 }", Target::LinuxX86_64).is_err());
|
||||
assert!(emit_object("", Target::LinuxX86_64).is_err());
|
||||
assert!(
|
||||
emit_object("\0", Target::LinuxX86_64)
|
||||
.unwrap_err()
|
||||
.contains("NUL")
|
||||
);
|
||||
let invalid_ssa = "define i64 @bad(i1 %condition) {\nentry: br i1 %condition, label %left, label %right\nleft: %value = add i64 40, 2\nbr label %done\nright: br label %done\ndone: ret i64 %value\n}";
|
||||
assert!(
|
||||
emit_object(invalid_ssa, Target::LinuxX86_64)
|
||||
.unwrap_err()
|
||||
.contains("does not dominate")
|
||||
);
|
||||
assert!(
|
||||
emit_object(
|
||||
"target datalayout = \"e-p:32:32\"\ndefine i64 @answer() { ret i64 42 }",
|
||||
Target::LinuxX86_64
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("layout disagrees")
|
||||
);
|
||||
assert!(
|
||||
emit_object(
|
||||
"target triple = \"aarch64-unknown-linux-gnu\"\n",
|
||||
Target::LinuxX86_64
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("disagrees")
|
||||
);
|
||||
let directory = temp::TemporaryDirectory::new();
|
||||
let object = directory.path().join("bad.o");
|
||||
fs::write(&object, b"not object").unwrap();
|
||||
let output = directory.path().join("image");
|
||||
assert!(
|
||||
link(
|
||||
Target::LinuxX86_64,
|
||||
LinkKind::Shared,
|
||||
std::slice::from_ref(&object),
|
||||
&output,
|
||||
None
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(!output.exists());
|
||||
fs::write(&object, emit_object(IR, Target::LinuxX86_64).unwrap()).unwrap();
|
||||
link(
|
||||
Target::LinuxX86_64,
|
||||
LinkKind::Shared,
|
||||
std::slice::from_ref(&object),
|
||||
&output,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let original = fs::read(&output).unwrap();
|
||||
assert!(
|
||||
link(
|
||||
Target::LinuxX86_64,
|
||||
LinkKind::Shared,
|
||||
&[object],
|
||||
&output,
|
||||
None
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(fs::read(output).unwrap(), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_links_cannot_overwrite_the_same_new_output() {
|
||||
let directory = temp::TemporaryDirectory::new();
|
||||
let object = directory.path().join("entry.o");
|
||||
let output = directory.path().join("image");
|
||||
fs::write(&object, emit_object(IR, Target::LinuxX86_64).unwrap()).unwrap();
|
||||
std::thread::scope(|scope| {
|
||||
let execute = || {
|
||||
link(
|
||||
Target::LinuxX86_64,
|
||||
LinkKind::Shared,
|
||||
std::slice::from_ref(&object),
|
||||
&output,
|
||||
None,
|
||||
)
|
||||
};
|
||||
let left = scope.spawn(execute);
|
||||
let right = scope.spawn(execute);
|
||||
assert_eq!(
|
||||
[left.join().unwrap(), right.join().unwrap()]
|
||||
.iter()
|
||||
.filter(|result| result.is_ok())
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
});
|
||||
assert!(fs::metadata(output).unwrap().len() > 128);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn link_rejects_shared_directories_and_mutable_ancestors_before_claiming_output() {
|
||||
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
|
||||
let directory = temp::TemporaryDirectory::new();
|
||||
let object = directory.path().join("entry.o");
|
||||
fs::write(&object, emit_object(IR, Target::LinuxX86_64).unwrap()).unwrap();
|
||||
let output = directory.path().join("image");
|
||||
fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(
|
||||
link(
|
||||
Target::LinuxX86_64,
|
||||
LinkKind::Shared,
|
||||
std::slice::from_ref(&object),
|
||||
&output,
|
||||
None
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("private directory")
|
||||
);
|
||||
assert!(!output.exists());
|
||||
|
||||
let leaf = directory.path().join("private");
|
||||
let mut builder = fs::DirBuilder::new();
|
||||
builder.mode(0o700).create(&leaf).unwrap();
|
||||
fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o777)).unwrap();
|
||||
let output = leaf.join("image");
|
||||
let result = link(
|
||||
Target::LinuxX86_64,
|
||||
LinkKind::Shared,
|
||||
std::slice::from_ref(&object),
|
||||
&output,
|
||||
None,
|
||||
);
|
||||
fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap();
|
||||
assert!(result.unwrap_err().contains("publicly writable ancestor"));
|
||||
assert!(!output.exists());
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
#[test]
|
||||
fn native_llvm_lld_program_executes_without_rustc_or_cargo() {
|
||||
// Test-owned freestanding process: no runtime or system library dependency.
|
||||
let ir = "define void @_start() noreturn {\ncall void asm sideeffect \"syscall\", \"{rax},{rdi},~{rcx},~{r11},~{memory}\"(i64 60, i64 42)\nunreachable\n}";
|
||||
let directory = temp::TemporaryDirectory::new();
|
||||
let object = directory.path().join("entry.o");
|
||||
fs::write(&object, emit_object(ir, Target::LinuxX86_64).unwrap()).unwrap();
|
||||
let output = directory.path().join("program");
|
||||
link(
|
||||
Target::LinuxX86_64,
|
||||
LinkKind::Executable,
|
||||
&[object],
|
||||
&output,
|
||||
Some("_start"),
|
||||
)
|
||||
.unwrap();
|
||||
let status = std::process::Command::new(output)
|
||||
.env_clear()
|
||||
.status()
|
||||
.unwrap();
|
||||
assert_eq!(status.code(), Some(42));
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
# Linux 质量门
|
||||
|
||||
`run.py` 仅串行编排既有 Cargo、Python、SDK、PostgreSQL fixture 和 performance 运行器;没有下载工具、全局安装或环境变量产品配置。根 `config/setting.json` 保留原有字段,在既有 `performance` 对象内增加 `linux_ci`,与 peer 路径同级。顶层 `linux_ci` 不受支持,会被 runtime 严格配置解析拒绝:
|
||||
|
||||
```json
|
||||
{
|
||||
"performance": {
|
||||
"network_peer": "/absolute/network_bench",
|
||||
"live_peer": "/absolute/live_bench",
|
||||
"linux_ci": {
|
||||
"tools": {
|
||||
"cargo": "/absolute/toolchain/bin/cargo",
|
||||
"cargo_fmt": "/absolute/rustfmt/bin/cargo-fmt",
|
||||
"cargo_clippy": "/absolute/clippy/bin/cargo-clippy",
|
||||
"python": "/absolute/python3",
|
||||
"node": "/absolute/node",
|
||||
"go": "/absolute/go"
|
||||
},
|
||||
"cargo_config": [
|
||||
"profile.dev.package.miniz_oxide.opt-level=3",
|
||||
"profile.dev.package.flate2.opt-level=3",
|
||||
"profile.dev.package.sha2.opt-level=3"
|
||||
],
|
||||
"output": "/absolute/repository/target/linux-ci",
|
||||
"command_timeout_seconds": 14400,
|
||||
"remove_test_executables": true,
|
||||
"cgroup_parent": "/sys/fs/cgroup/system.slice/dever-linux-ci.service",
|
||||
"inputs": [],
|
||||
"required_inputs": {},
|
||||
"author_outputs": [],
|
||||
"author_network": false,
|
||||
"soak": {
|
||||
"artifacts": "/absolute/prepared-protocols",
|
||||
"cms_manifest": "/absolute/prepared-cms/manifest.json",
|
||||
"seconds": 1800,
|
||||
"cms_concurrency": 16,
|
||||
"http2_rate": 5000,
|
||||
"rss_late_growth_mib": 8
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
替换路径,勿直接复制示例运行。下文 CI 字段均位于 `performance.linux_ci`。`inputs` 列出固定原始作者输入及准备好的资产文件/目录,逐文件计 SHA-256;工具、协议产物、CMS manifest/可执行文件/配置和 `performance.network_peer/live_peer` 也自动计摘要,`linux_ci` 对象不作为 peer 路径解析。输出目录不得包含在 inputs 中。报告只存摘要,不复制数据库密码。
|
||||
|
||||
`required_inputs` 为 `author`、`native`、`postgres`、`sandbox`、`bounded-perf`、`soak` 各提供非空绝对路径列表,必须落在 `inputs` 声明范围内。缺路径记录 blocked,不算 pass。作者 fixture 的具体固定路径/准备命令仍以 `sdk/native-release.md`、`test/ecosystem-release/prepare.py`、`test/ecosystem-release/build_inputs.py` 和对应 ignored 原因文本为准;gate 不把现存文件冒充由当前源码重建的 provenance。
|
||||
|
||||
显式 LLVM Worker 验收读取 `target/native-release-inputs/component-fixture`:先用当前源码构建 `dever-tests` 的 `component-fixture` bin,再将程序复制到该准备目录,记录源码/程序摘要,并把固定副本列入 `inputs` 和 `required_inputs.native`。不能将 `target/debug/component-fixture` 当作不可变输入:Cargo 在 default/all-feature 组合间会重新链接这个输出。普通 Worker 测试仍使用 Cargo 为各自组合生成的 `CARGO_BIN_EXE_component-fixture`,不改质量门的输入变更拒绝规则。
|
||||
|
||||
`author_outputs` 精确声明 author 会制作的 pack、下载结果和 receipt 路径。author 阶段只将其余原始输入当作不可变输入,另外记录完整输出摘要;后续验收按准备后完整输入摘要绑定。不要把整个作者输入根排除掉。显式 `author_network: true` 才能执行已分类的固定官方下载/源码构建。新 ignored 必须人工更新 `ignored.json`;inventory 核对源码声明及实际 all-feature libtest 名单,执行时一律使用完整 `--exact` 名称。内部 probe 记录 parent-only,只由所属父用例运行。
|
||||
|
||||
资源准备归外部托管者:自有空父启用 memory/cpu,限制总 CPU<=2、memory<=4GiB、swap=0;runner/Cargo/负载客户端放 `control` 子组,自有 PostgreSQL 可放兄弟子组,64/128MiB 服务子组由现有 performance owner 创建。gate 只读检查现有父限制与自身归属,不创建/修改主机策略。作者工具所需固定 PATH/动态库路径可由外部托管者显式准备,不写入产品配置。
|
||||
|
||||
```sh
|
||||
python3 -B test/ci/run.py plan
|
||||
python3 -B test/ci/run.py run --stage static
|
||||
python3 -B test/ci/run.py run --stage default
|
||||
python3 -B test/ci/run.py run --stage all-features
|
||||
python3 -B test/ci/run.py run --stage features
|
||||
python3 -B test/ci/run.py run --stage inventory
|
||||
python3 -B test/ci/run.py run --stage author
|
||||
python3 -B test/ci/run.py run --stage python
|
||||
python3 -B test/ci/run.py run --stage sdk
|
||||
python3 -B test/ci/run.py run --stage native
|
||||
python3 -B test/ci/run.py run --stage postgres
|
||||
python3 -B test/ci/run.py run --stage sandbox
|
||||
python3 -B test/ci/run.py run --stage bounded-perf
|
||||
```
|
||||
|
||||
以上命令由同一受限托管进程串行执行。若 author 改变输入,应在作者准备完成后再跑正式静态/default/all-feature门;较早输入版本的证据会显示过期。每阶段写入新的独占时间目录,开始即记录 running;缺输入记录 blocked,失败命令保留 exit/count/日志与原始异常。`summary` 与 inventory 消费者只接受最新一次尝试的当前源码/输入证据,不能越过较新的失败或中断回用旧 pass;所有必选阶段通过才返回0。源码摘要覆盖未提交文件,不只看 Git HEAD。
|
||||
|
||||
default/all-features 由实际 Cargo metadata 注册 target 和已解析 feature 形成串行矩阵,覆盖 lib/bin/integration/doctest;default 显式保留 workspace 解析的 package features,最小 feature 阶段另按单 package 验证。只有显式启用 `remove_test_executables` 时才删除 Cargo 本次返回、`profile.test=true` 且位于 target 下 `deps/` 的测试可执行文件,失败阶段同样清理;不删除作者工具、静态 init、依赖 archive 或整个 target。删除对象在日志 JSON 中可审计,重跑 Cargo 可恢复。inventory 按 package/target/name 三元组核对完整性,跨 target 同名用例不能代替缺失项。
|
||||
|
||||
Python 阶段包含 `test/ci`、`test/performance`、`test/ecosystem-release` 三个目录;后两者必须已配置 `performance.network_peer/live_peer`,真实自有 loopback检查由主代理执行。三 SDK 路径全部必需,三个明确成功标志全部出现才通过。
|
||||
|
||||
PostgreSQL 只接受显式 loopback端口和恰好一个 `{case}` 库名模板,拒绝 query 覆盖 host/dbname 等。增加 `performance.linux_ci.owned_postgres: {"data_directory":"/absolute/owned-cluster","port":51234}`,由托管者创建所需基库并在 finally 停止该自有实例;gate复用现有用例的 schema/tenant 清理,不替用户启动或连接既有业务数据库。
|
||||
|
||||
长测复用原 runner,逐阶段分别执行:
|
||||
|
||||
```sh
|
||||
for stage in cms-64 cms-128 http2-64 http2-128 recovery-64 recovery-128 live-64 live-128; do
|
||||
python3 -B test/ci/run.py run --stage "$stage" || exit "$?"
|
||||
done
|
||||
python3 -B test/ci/run.py summary
|
||||
```
|
||||
|
||||
每个 CMS 源码/h2c/TLS/恢复协议/TCP/WS/SSE 子场景至少30分钟,服务在整个子场景保持同一进程。HTTP/2选代表 JSON 路径;其他body路径仍由短合同测试覆盖。live/recovery 使用60×30秒循环,受现有1小时生命周期限制;总默认矩阵约9小时加准备。HTTP/2要求零错误、成功>=99%计划请求;CMS逐回复验证实际发布列表。全部要求OOM=0、断连FD恢复。RSS记录原始采样和四段中位数,最后两段中位数增量默认不得超过8MiB;这是本轮可配置回归预算,不把 allocator保留直接等同泄漏。失效报告和失败负载保留,校准降速后须完整重跑。
|
||||
|
||||
CMS读取时长支持0.1..3600秒;长测客户端用固定2048桶统计延迟,不随请求数保存无限样本。分位数为对数桶上界,包含请求与结果核对时间。报告记录实际时长及统计方式,不与此前精确短测分位数直接混算。
|
||||
|
||||
CMS先关闭控制客户端,再记录FD基线;断连后按FD目标及重复数核对所有非数据库资源。负载前已确定的SQLite主文件/WAL句柄各不得超过配置的`max_connections`,同进程SHM句柄不得超过1。`descriptors_before`、`descriptors_after`和`pooled_descriptor_limits`保存实际依据;总`fd_delta_after_disconnect`保留为观测值,不把正常池扩容当成连接泄漏。每个源码目录的`report.json`在失败时也保存已完成的发布/读取数据、资源采样、预算及错误,总报告收集该失败case。HTTP/2/live无数据库池,继续使用原来的完整FD回落门。
|
||||
@@ -0,0 +1,298 @@
|
||||
"""显式 ignored 验收与现有长测运行器的 Linux gate 接线。"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from urllib.parse import parse_qsl, unquote, urlsplit
|
||||
|
||||
from linux_gate import (ROOT, MissingInput, absolute_file, run_command,
|
||||
target_command, workspace_targets, remove_executables, input_fingerprint)
|
||||
from settings import write_private_json
|
||||
from process import resource_trend
|
||||
from cms import validate_read_descriptor_recovery
|
||||
|
||||
|
||||
def execute(command, config, directory, result, *, single_harness=False):
|
||||
record, content = run_command(command, directory, len(result["commands"]),
|
||||
config.get("command_timeout_seconds", 14400),
|
||||
single_harness=single_harness)
|
||||
result["commands"].append(record)
|
||||
return record, content
|
||||
|
||||
|
||||
def classify_cases(names, manifest, target):
|
||||
by_name = {}
|
||||
for row in manifest:
|
||||
key = (row["package"], row["target"], row["name"])
|
||||
if key in by_name:
|
||||
raise RuntimeError(f"duplicate ignored classification: {key}")
|
||||
by_name[key] = row
|
||||
classified = []
|
||||
for name in names:
|
||||
leaf = name.rsplit("::", 1)[-1]
|
||||
key = (target["package"], target["target"], leaf)
|
||||
if key not in by_name:
|
||||
raise RuntimeError(f"unclassified ignored test: {name}")
|
||||
classified.append({**by_name[key], "exact": name})
|
||||
if len({case["name"] for case in classified}) != len(classified):
|
||||
raise RuntimeError("same ignored leaf appears in multiple modules; classify by a distinct source name")
|
||||
return classified
|
||||
|
||||
|
||||
def audit_classification():
|
||||
manifest = json.loads((ROOT / "test/ci/ignored.json").read_text())
|
||||
stages = {"probe", "author", "native", "postgres", "sandbox", "bounded-perf"}
|
||||
if any(row["stage"] not in stages for row in manifest):
|
||||
raise RuntimeError("ignored classification contains an unknown execution stage")
|
||||
expected = {(row["source"], row["name"], row["reason"]) for row in manifest}
|
||||
actual = set()
|
||||
pattern = re.compile(r'#\[ignore(?:\s*=\s*"([^"]*)")?\]\s*(?:#\[[^\]]*\]\s*)*(?:pub\s+)?(?:async\s+)?fn\s+(\w+)')
|
||||
for directory in ("test", "crates"):
|
||||
for path in (ROOT / directory).rglob("*.rs"):
|
||||
for match in pattern.finditer(path.read_text()):
|
||||
actual.add((str(path.relative_to(ROOT)), match[2], match[1] or ""))
|
||||
if actual != expected:
|
||||
raise RuntimeError(f"ignored classification drift: new={sorted(actual - expected)}, removed={sorted(expected - actual)}")
|
||||
return manifest
|
||||
|
||||
|
||||
def test_executable(content, target):
|
||||
artifacts = []
|
||||
for line in content.splitlines():
|
||||
if not line.startswith("{"):
|
||||
continue
|
||||
message = json.loads(line)
|
||||
if (message.get("reason") == "compiler-artifact" and message.get("profile", {}).get("test")
|
||||
and message.get("target", {}).get("name") == target["target"] and message.get("executable")):
|
||||
artifacts.append(message["executable"])
|
||||
if len(set(artifacts)) != 1:
|
||||
raise RuntimeError(f"expected one libtest executable for {target}")
|
||||
return artifacts[0]
|
||||
|
||||
|
||||
def inventory(config, directory, result):
|
||||
manifest = audit_classification()
|
||||
targets, target_directory = workspace_targets(config, directory, result, True)
|
||||
cases = []
|
||||
for target in targets:
|
||||
if target["selector"] == ["--doc"]:
|
||||
continue
|
||||
_, content = execute([*target_command(config, target), "--no-run", "--message-format=json"], config, directory, result)
|
||||
executable = test_executable(content, target)
|
||||
_, listing = execute([executable, "--ignored", "--list", "--format", "terse"], config, directory, result)
|
||||
names = [line.removesuffix(": test") for line in listing.splitlines() if line.endswith(": test")]
|
||||
cases.extend({**case, "target": target, "target_directory": str(target_directory)}
|
||||
for case in classify_cases(names, manifest, target))
|
||||
result.setdefault("removed_test_executables", []).extend(
|
||||
remove_executables(content, target_directory, config.get("remove_test_executables", False)))
|
||||
write_private_json(directory / "inventory.json", cases, replace=(directory / "inventory.json").exists())
|
||||
missing = {(row["package"], row["target"], row["name"]) for row in manifest} - {
|
||||
(case["target"]["package"], case["target"]["target"], case["name"]) for case in cases}
|
||||
if missing:
|
||||
raise RuntimeError(f"ignored source cases missing from Linux all-feature libtest inventory: {sorted(missing)}")
|
||||
result["classified_cases"] = len(cases)
|
||||
result["internal_probes"] = [{"target": case["target"], "exact": case["exact"],
|
||||
"status": "parent-only", "reason": case["reason"]}
|
||||
for case in cases if case["stage"] == "probe"]
|
||||
|
||||
|
||||
def current_inventory(document, config, result):
|
||||
paths = sorted((Path(config.get("output", ROOT / "target/linux-ci")) / "inventory").glob("*/result.json"))
|
||||
if paths:
|
||||
path = paths[-1]
|
||||
record = json.loads(path.read_text())
|
||||
if (record.get("source"), record.get("inputs"), record.get("status")) == (result["source"], input_fingerprint(document, config, "inventory"), "passed"):
|
||||
return json.loads((path.parent / "inventory.json").read_text())
|
||||
raise MissingInput("run inventory for the current source and input fingerprints first")
|
||||
|
||||
|
||||
def validate_postgres(document):
|
||||
setting = document.get("database", {}).get("postgres_test", {})
|
||||
url = urlsplit(setting.get("url", ""))
|
||||
database = unquote(url.path.removeprefix("/"))
|
||||
if (setting.get("type") != "postgres" or url.scheme not in ("postgres", "postgresql")
|
||||
or url.hostname not in ("127.0.0.1", "::1") or url.port is None
|
||||
or database.count("{case}") != 1
|
||||
or any(key.lower() in ("dbname", "host", "hostaddr", "port", "service") for key, _ in parse_qsl(url.query))):
|
||||
raise MissingInput("database.postgres_test must select the explicitly owned loopback cluster and one {case} database template")
|
||||
|
||||
|
||||
def require_inputs(stage, config):
|
||||
required = config.get("required_inputs", {}).get(stage)
|
||||
if not required:
|
||||
raise MissingInput(f"performance.linux_ci.required_inputs.{stage} must declare the prepared author/fixture input paths")
|
||||
declared = [Path(value).resolve() for value in config.get("inputs", [])]
|
||||
for value in required:
|
||||
path = Path(value)
|
||||
if not path.is_absolute() or not path.exists():
|
||||
raise MissingInput(f"missing {stage} input: {value}")
|
||||
if not any(path.resolve() == root or path.resolve().is_relative_to(root) for root in declared):
|
||||
raise MissingInput(f"{stage} input is not fingerprinted by performance.linux_ci.inputs: {value}")
|
||||
|
||||
|
||||
def ignored_stage(stage, document, config, directory, result):
|
||||
require_inputs(stage, config)
|
||||
if stage == "postgres":
|
||||
validate_postgres(document)
|
||||
if not config.get("owned_postgres"):
|
||||
raise MissingInput("performance.linux_ci.owned_postgres must record the owned cluster data_directory and port")
|
||||
owned = config["owned_postgres"]
|
||||
if urlsplit(document["database"]["postgres_test"]["url"]).port != owned.get("port"):
|
||||
raise MissingInput("PostgreSQL URL does not match performance.linux_ci.owned_postgres.port")
|
||||
if not Path(owned.get("data_directory", "")).is_absolute():
|
||||
raise MissingInput("owned_postgres.data_directory must be absolute")
|
||||
if stage == "author" and config.get("author_network") is not True:
|
||||
raise MissingInput("author stage fetches fixed upstream inputs; explicitly set performance.linux_ci.author_network=true")
|
||||
if stage == "author" and not config.get("author_outputs"):
|
||||
raise MissingInput("declare performance.linux_ci.author_outputs separately from immutable raw author inputs")
|
||||
cases = [case for case in current_inventory(document, config, result) if case["stage"] == stage]
|
||||
if not cases:
|
||||
raise RuntimeError(f"no classified cases for {stage}")
|
||||
# 固定作者先准备工具,再执行真实源码包构建;其他用例按 target 复用链接产物。
|
||||
cases.sort(key=lambda case: (0 if case["name"].startswith("prepare_") else 1,
|
||||
case["target"]["package"], case["target"]["target"], case["exact"]))
|
||||
target_key = None
|
||||
build_content = None
|
||||
target_directory = ROOT / "target"
|
||||
for case in cases:
|
||||
target = case["target"]
|
||||
target_directory = Path(case["target_directory"])
|
||||
result["target_directory"] = str(target_directory)
|
||||
key = json.dumps(target, sort_keys=True)
|
||||
if key != target_key:
|
||||
if build_content is not None:
|
||||
result.setdefault("removed_test_executables", []).extend(
|
||||
remove_executables(build_content, target_directory, config.get("remove_test_executables", False)))
|
||||
_, build_content = execute([*target_command(config, target), "--no-run", "--message-format=json"], config, directory, result)
|
||||
executable = test_executable(build_content, target)
|
||||
target_key = key
|
||||
record, _ = execute([executable, "--ignored", "--exact", case["exact"], "--test-threads=1", "--nocapture"],
|
||||
config, directory, result, single_harness=True)
|
||||
if record.get("rust_tests") != {"passed": 1, "failed": 0, "ignored": 0}:
|
||||
raise RuntimeError(f"exact ignored selection did not execute one case: {case['exact']}")
|
||||
result.setdefault("cases", []).append({"exact": case["exact"], "target": target, "status": "passed"})
|
||||
result.setdefault("removed_test_executables", []).extend(
|
||||
remove_executables(build_content, target_directory, config.get("remove_test_executables", False)))
|
||||
|
||||
|
||||
def validate_budget(config):
|
||||
parent = Path(config.get("cgroup_parent", ""))
|
||||
if not parent.is_absolute() or not (parent / "cgroup.controllers").is_file():
|
||||
raise MissingInput("performance.linux_ci.cgroup_parent must select the owned empty cgroup v2 parent")
|
||||
if (parent / "cgroup.procs").read_text().strip():
|
||||
raise MissingInput("CI runner must be in a control child, not the cgroup parent")
|
||||
if not {"cpu", "memory"} <= set((parent / "cgroup.subtree_control").read_text().split()):
|
||||
raise MissingInput("owned parent requires enabled memory/cpu child controllers")
|
||||
quota, period = (parent / "cpu.max").read_text().split()
|
||||
memory = (parent / "memory.max").read_text().strip()
|
||||
if quota == "max" or int(quota) / int(period) > 2 or memory == "max" or int(memory) > 4 * 1024 ** 3:
|
||||
raise MissingInput("owned parent must bound total CPU <=2 and memory <=4GiB")
|
||||
if (parent / "memory.swap.max").read_text().strip() != "0":
|
||||
raise MissingInput("owned parent must disable swap")
|
||||
membership = Path("/proc/self/cgroup").read_text().strip().split("::", 1)[1]
|
||||
current = Path("/sys/fs/cgroup") / membership.lstrip("/")
|
||||
if current == parent or not current.is_relative_to(parent):
|
||||
raise MissingInput("CI runner is outside the configured owned parent control subgroup")
|
||||
return parent
|
||||
|
||||
|
||||
def validate_soak(report, suite, seconds, growth_limit, memory_mib):
|
||||
if report.get("status") not in ("passed", "complete"):
|
||||
raise RuntimeError("soak runner did not complete")
|
||||
cases = list(report["cases"].values()) if suite == "cms" else report["cases"]
|
||||
expected = 3 if suite == "live" else 2
|
||||
if len(cases) != expected:
|
||||
raise RuntimeError(f"expected {expected} {suite} cases, received {len(cases)}")
|
||||
if suite == "cms":
|
||||
coverage = set(report["cases"])
|
||||
required = {"dever", "md"}
|
||||
else:
|
||||
coverage = {case["protocol" if suite == "live" else "transport"] for case in cases}
|
||||
required = {"tcp", "ws", "sse"} if suite == "live" else {"http", "https"}
|
||||
if any(case["suite"] != suite or (suite != "live" and case["implementation"] != "dever") for case in cases):
|
||||
raise RuntimeError("soak report contains a different suite or implementation")
|
||||
if coverage != required:
|
||||
raise RuntimeError("soak report does not cover every required source or transport")
|
||||
for case in cases:
|
||||
budget = case.get("cgroup") if suite == "cms" else case.get("budget_after_stop")
|
||||
if budget is None or any(budget["events"][key] for key in ("oom", "oom_kill")):
|
||||
raise RuntimeError("missing memory-budget evidence or nonzero OOM")
|
||||
if budget["max"] != str(memory_mib * 1024 ** 2):
|
||||
raise RuntimeError("soak used a different memory limit from the selected stage")
|
||||
if suite == "cms":
|
||||
load = case["concurrent_reads"]
|
||||
if load["elapsed_seconds"] < seconds or load["errors"] or load["succeeded"] == 0:
|
||||
raise RuntimeError("CMS duration, business load or FD recovery failed")
|
||||
validate_read_descriptor_recovery(load)
|
||||
trend = load["trend"]
|
||||
elif suite == "live":
|
||||
if (case["result"]["elapsed_ms"] < seconds * 1000 or case["result"]["errors"]
|
||||
or any(case["fd_delta_after_cycles"])):
|
||||
raise RuntimeError("live duration, protocol or FD recovery failed")
|
||||
samples = [phase["last"] for phase in case["phases"] if phase["phase"] == "disconnected"]
|
||||
trend = resource_trend(samples)
|
||||
else:
|
||||
loads = [case["load"]] if suite == "http2" else [cycle["load"] for cycle in case["cycles"]]
|
||||
disconnected = [case["disconnected"]] if suite == "http2" else [cycle["disconnected"] for cycle in case["cycles"]]
|
||||
if any(sample["fd_delta"] != 0 for sample in disconnected):
|
||||
raise RuntimeError("HTTP/2 did not restore file descriptors after disconnect")
|
||||
if sum(load["elapsed_seconds"] for load in loads) < seconds:
|
||||
raise RuntimeError("HTTP/2 measured load is shorter than the required duration")
|
||||
for load in loads:
|
||||
if load["errors"] or load["succeeded"] <= 0 or load["succeeded"] < .99 * load["scheduled"]:
|
||||
raise RuntimeError("HTTP/2 did not achieve 99% scheduled successful replies with zero errors")
|
||||
trend = resource_trend(case["raw"])
|
||||
if trend is None or trend["rss_late_growth_bytes"] > growth_limit:
|
||||
raise RuntimeError("late RSS growth exceeds the configured budget; inspect raw resource samples")
|
||||
case["gate_resource_trend"] = trend
|
||||
|
||||
|
||||
def soak_stage(stage, config, directory, result):
|
||||
suite, memory = stage.rsplit("-", 1)
|
||||
suite = {"recovery": "http2-recovery"}.get(suite, suite)
|
||||
require_inputs("soak", config)
|
||||
parent = validate_budget(config)
|
||||
settings = config.get("soak", {})
|
||||
seconds = settings.get("seconds", 1800)
|
||||
if not 1800 <= seconds <= 2700:
|
||||
raise ValueError("performance.linux_ci.soak.seconds must be 1800..2700")
|
||||
python = str(absolute_file(config["tools"].get("python"), "performance.linux_ci.tools.python"))
|
||||
output = directory / "measurement"
|
||||
if suite == "cms":
|
||||
manifest = str(absolute_file(settings.get("cms_manifest"), "performance.linux_ci.soak.cms_manifest"))
|
||||
command = [python, "-B", "test/performance/cms.py", "run", "--manifest", manifest,
|
||||
"--concurrency", str(settings.get("cms_concurrency", 16)), "--read-seconds", str(seconds)]
|
||||
else:
|
||||
artifacts = Path(settings.get("artifacts", ""))
|
||||
if not artifacts.is_absolute() or not (artifacts / "manifest.json").is_file():
|
||||
raise MissingInput("performance.linux_ci.soak.artifacts requires prepared protocol artifacts")
|
||||
command = [python, "-B", "test/performance/run.py", "run", "--artifacts", str(artifacts),
|
||||
"--suite", suite, "--implementations", "dever", "--repeats", "1", "--interval", "0.1"]
|
||||
if suite == "http2":
|
||||
command += ["--duration", str(seconds), "--warmup", "5", "--paths", "/json",
|
||||
"--rates", str(settings.get("http2_rate", 5000)), "--http2-connections", "4"]
|
||||
else:
|
||||
if seconds % 30:
|
||||
raise ValueError("cyclic soak seconds must be divisible by 30")
|
||||
command += ["--duration", "30", "--cycles", str(seconds // 30)]
|
||||
command += (["--connection-tiers", "32"] if suite == "live" else
|
||||
["--recovery-rate", str(settings.get("http2_rate", 5000)),
|
||||
"--recovery-path", "/json", "--recovery-connections", "4"])
|
||||
if "server_cpu" in settings:
|
||||
command += ["--server-cpu", str(settings["server_cpu"]), "--client-cpus", str(settings["client_cpu"])]
|
||||
command += ["--output", str(output), "--memory-mib", memory, "--cgroup-parent", str(parent)]
|
||||
execute(command, config, directory, result)
|
||||
report = json.loads((output / "report.json").read_text())
|
||||
validate_soak(report, suite, seconds, settings.get("rss_late_growth_mib", 8) * 1024 ** 2, int(memory))
|
||||
write_private_json(directory / "validated-report.json", report)
|
||||
result["measurement"] = str(output / "report.json")
|
||||
result["coverage"] = "CMS dever/md; HTTP/2 h2c/TLS JSON; recovery h2c/TLS; live TCP/WS/SSE, each selected stage only"
|
||||
|
||||
|
||||
def run_stage(stage, document, config, directory, result):
|
||||
if stage == "inventory":
|
||||
inventory(config, directory, result)
|
||||
elif stage in ("author", "native", "postgres", "sandbox", "bounded-perf"):
|
||||
ignored_stage(stage, document, config, directory, result)
|
||||
else:
|
||||
soak_stage(stage, config, directory, result)
|
||||
File diff suppressed because it is too large.
Load diff
+372
@@ -0,0 +1,372 @@
|
||||
"""Linux 质量门:串行复用现有检查,按源码和输入摘要保存阶段证据。"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.append(str(ROOT / "test/performance"))
|
||||
sys.modules["linux_gate"] = sys.modules[__name__]
|
||||
from settings import configured_peer, write_private_json
|
||||
|
||||
STAGES = ("static", "default", "all-features", "features", "python", "sdk",
|
||||
"inventory", "author", "native", "postgres", "sandbox", "bounded-perf",
|
||||
"cms-64", "cms-128", "http2-64", "http2-128", "recovery-64", "recovery-128",
|
||||
"live-64", "live-128")
|
||||
SOURCE_ROOTS = ("crates", "test", "sdk", "library", "examples", ".cargo")
|
||||
|
||||
|
||||
class MissingInput(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def digest_file(path):
|
||||
with path.open("rb") as source:
|
||||
return hashlib.file_digest(source, "sha256").hexdigest()
|
||||
|
||||
|
||||
def source_fingerprint(root=ROOT):
|
||||
paths = [root / name for name in ("Cargo.toml", "Cargo.lock")]
|
||||
for name in SOURCE_ROOTS:
|
||||
directory = root / name
|
||||
if directory.exists():
|
||||
paths.extend(path for path in directory.rglob("*") if path.is_file()
|
||||
and not {"__pycache__", "target", ".git"}.intersection(path.parts))
|
||||
digest = hashlib.sha256()
|
||||
for path in sorted(paths):
|
||||
digest.update(str(path.relative_to(root)).encode() + b"\0")
|
||||
digest.update(digest_file(path).encode() + b"\0")
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def absolute_file(value, name):
|
||||
path = Path(value or "")
|
||||
if not path.is_absolute() or not path.is_file():
|
||||
raise MissingInput(f"{name} requires an existing absolute file path")
|
||||
return path
|
||||
|
||||
|
||||
def load_config():
|
||||
path = ROOT / "config/setting.json"
|
||||
if not path.is_file():
|
||||
raise MissingInput("create config/setting.json with performance.linux_ci (see test/ci/README.md)")
|
||||
document = json.loads(path.read_text())
|
||||
performance = document.get("performance") if isinstance(document, dict) else None
|
||||
config = performance.get("linux_ci") if isinstance(performance, dict) else None
|
||||
if not isinstance(config, dict):
|
||||
raise MissingInput("config/setting.json must define performance.linux_ci")
|
||||
return document, config
|
||||
|
||||
|
||||
def input_fingerprint(document, config, stage=None):
|
||||
inputs = {}
|
||||
for name, value in config.get("tools", {}).items():
|
||||
path = Path(value)
|
||||
if not path.is_absolute():
|
||||
raise ValueError("performance.linux_ci.tools must use absolute paths")
|
||||
inputs[f"tool:{name}:{value}"] = digest_file(path) if path.is_file() else "missing"
|
||||
paths = set(config.get("inputs", []))
|
||||
paths.update(str(configured_peer(name, ROOT / "config/setting.json"))
|
||||
for name in ("network_peer", "live_peer") if name in document.get("performance", {}))
|
||||
soak = config.get("soak", {})
|
||||
if soak.get("artifacts"):
|
||||
paths.add(soak["artifacts"])
|
||||
if soak.get("cms_manifest"):
|
||||
manifest = Path(soak["cms_manifest"])
|
||||
paths.add(str(manifest))
|
||||
if manifest.is_file():
|
||||
for case in json.loads(manifest.read_text()).get("cases", {}).values():
|
||||
paths.update(case[key] for key in ("executable", "config"))
|
||||
for value in sorted(paths):
|
||||
path = Path(value)
|
||||
if not path.is_absolute():
|
||||
raise ValueError("performance.linux_ci.inputs must use absolute paths")
|
||||
if stage == "inventory":
|
||||
continue # 清单依赖当前源码和工具,不消费作者资产。
|
||||
outputs = [Path(value) for value in config.get("author_outputs", [])] if stage == "author" else []
|
||||
if any(path == output or path.is_relative_to(output) for output in outputs):
|
||||
continue
|
||||
if path.is_dir():
|
||||
for child in sorted(path.rglob("*")):
|
||||
if child.is_file() and not any(child == output or child.is_relative_to(output) for output in outputs):
|
||||
inputs[str(child)] = digest_file(child)
|
||||
else:
|
||||
inputs[value] = digest_file(path) if path.is_file() else "missing"
|
||||
# 保存摘要而非数据库密码/令牌;配置任何变化都使旧证据失效。
|
||||
return hashlib.sha256(json.dumps([document, inputs], sort_keys=True).encode()).hexdigest()
|
||||
|
||||
|
||||
def command_plan(stage, config):
|
||||
tools = config.get("tools", {})
|
||||
cargo = str(absolute_file(tools.get("cargo"), "performance.linux_ci.tools.cargo"))
|
||||
common = ["--offline", "--locked", "-j1", *cargo_config(config)]
|
||||
test = [cargo, "test", *common, "--workspace"]
|
||||
if stage == "static":
|
||||
fmt = str(absolute_file(tools.get("cargo_fmt"), "performance.linux_ci.tools.cargo_fmt"))
|
||||
clippy = str(absolute_file(tools.get("cargo_clippy"), "performance.linux_ci.tools.cargo_clippy"))
|
||||
return [[fmt, "fmt", "--all", "--check"],
|
||||
*[[clippy, "clippy", *common, "--workspace", "--all-targets", *features,
|
||||
"--", "-D", "warnings"] for features in ([], ["--all-features"])]]
|
||||
if stage in ("default", "all-features"):
|
||||
return [[*test, *(["--all-features"] if stage == "all-features" else []),
|
||||
"--", "--test-threads=1"]]
|
||||
if stage == "features":
|
||||
profiles = {
|
||||
"dever-runtime": ["", "auth-crypto", "crypto", "wire", "external", "api", "database", "sqlite", "postgres", "sqlite,postgres"],
|
||||
"dever-backend-bridge": ["", "embedded", "runtime-abi", "runtime-database", "runtime-api", "runtime-external", "runtime-sqlite", "runtime-postgres", "runtime-sqlite,runtime-postgres"],
|
||||
}
|
||||
return [[cargo, "check", *common, "-p", package, "--lib", "--no-default-features",
|
||||
*(["--features", features] if features else [])]
|
||||
for package, combinations in profiles.items() for features in combinations] + [
|
||||
[cargo, "tree", "--offline", "--locked", "-p", "dever-cli", "--edges", "features"]]
|
||||
python = str(absolute_file(tools.get("python"), "performance.linux_ci.tools.python"))
|
||||
if stage == "python":
|
||||
return [[python, "-B", "-m", "unittest", "discover", "-s", directory, "-p", "test_*.py", "-v"]
|
||||
for directory in ("test/ci", "test/performance", "test/ecosystem-release")]
|
||||
if stage == "sdk":
|
||||
return [[python, "-B", "test/external-sdk/check.py", "--python", python,
|
||||
"--node", str(absolute_file(tools.get("node"), "performance.linux_ci.tools.node")),
|
||||
"--go", str(absolute_file(tools.get("go"), "performance.linux_ci.tools.go"))]]
|
||||
raise ValueError(f"stage has a dedicated runner: {stage}")
|
||||
|
||||
|
||||
def cargo_config(config):
|
||||
return [value for override in config.get("cargo_config", []) for value in ("--config", override)]
|
||||
|
||||
|
||||
def workspace_targets(config, directory, result, all_features):
|
||||
cargo = str(absolute_file(config["tools"].get("cargo"), "performance.linux_ci.tools.cargo"))
|
||||
command = [cargo, "metadata", "--offline", "--locked", "--format-version", "1", *cargo_config(config)]
|
||||
if all_features:
|
||||
command.append("--all-features")
|
||||
record, content = run_command(command, directory, len(result["commands"]), 120)
|
||||
result["commands"].append(record)
|
||||
metadata = next(json.loads(line) for line in content.splitlines() if line.startswith('{"packages":'))
|
||||
active = {node["id"]: set(node["features"]) for node in metadata["resolve"]["nodes"]}
|
||||
targets = []
|
||||
result["target_directory"] = metadata["target_directory"]
|
||||
for package in metadata["packages"]:
|
||||
if package["id"] not in metadata["workspace_members"]:
|
||||
continue
|
||||
for target in package["targets"]:
|
||||
required = set(target.get("required-features", []))
|
||||
if not required <= active[package["id"]]:
|
||||
continue
|
||||
kind = target["kind"][0]
|
||||
if target["test"] and kind in ("lib", "rlib", "staticlib", "bin", "test"):
|
||||
selector = ["--lib"] if kind in ("lib", "rlib", "staticlib") else ["--" + kind, target["name"]]
|
||||
targets.append({"package": package["name"], "target": target["name"], "selector": selector,
|
||||
"features": sorted(active[package["id"]])})
|
||||
if target["doctest"] and kind in ("lib", "rlib", "staticlib"):
|
||||
targets.append({"package": package["name"], "target": target["name"], "selector": ["--doc"],
|
||||
"features": sorted(active[package["id"]])})
|
||||
return targets, Path(metadata["target_directory"])
|
||||
|
||||
|
||||
def target_command(config, target, all_features=True):
|
||||
features = (["--all-features"] if all_features else
|
||||
["--features", ",".join(target["features"])] if target.get("features") else [])
|
||||
return [config["tools"]["cargo"], "test", "--offline", "--locked", "-j1", *cargo_config(config),
|
||||
"-p", target["package"], *target["selector"], *features]
|
||||
|
||||
|
||||
def remove_executables(content, target_directory, enabled):
|
||||
if not enabled:
|
||||
return []
|
||||
removed = []
|
||||
for line in content.splitlines():
|
||||
if not line.startswith("{"):
|
||||
continue
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if (message.get("reason") == "compiler-artifact" and message.get("profile", {}).get("test")
|
||||
and message.get("executable")):
|
||||
path = Path(message["executable"]).resolve()
|
||||
if not path.is_relative_to(target_directory.resolve()) or path.parent.name != "deps":
|
||||
raise RuntimeError(f"refusing to remove a non-test output: {path}")
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
removed.append(str(path))
|
||||
return removed
|
||||
|
||||
|
||||
def cleanup_test_executables(directory, config, result):
|
||||
if not config.get("remove_test_executables", False) or "target_directory" not in result:
|
||||
return
|
||||
for command_file in sorted(directory.glob("*.command.json")):
|
||||
command = json.loads(command_file.read_text())
|
||||
arguments = command["command"]
|
||||
if arguments[0] != config["tools"]["cargo"] or "--message-format=json" not in arguments:
|
||||
continue
|
||||
log = Path(command["log"])
|
||||
result.setdefault("removed_test_executables", []).extend(
|
||||
remove_executables(log.read_text(errors="replace"), Path(result["target_directory"]), True))
|
||||
|
||||
|
||||
def run_workspace(stage, config, directory, result):
|
||||
all_features = stage == "all-features"
|
||||
targets, target_directory = workspace_targets(config, directory, result, all_features)
|
||||
result["targets"] = targets
|
||||
passed = 0
|
||||
for target in targets:
|
||||
command = [*target_command(config, target, all_features), "--message-format=json", "--", "--test-threads=1"]
|
||||
print(f"{stage}: {target['package']} {' '.join(target['selector'])}", flush=True)
|
||||
record, content = run_command(command, directory, len(result["commands"]), config.get("command_timeout_seconds", 14400))
|
||||
result["commands"].append(record)
|
||||
passed += record.get("rust_tests", {}).get("passed", 0)
|
||||
result.setdefault("removed_test_executables", []).extend(
|
||||
remove_executables(content, target_directory, config.get("remove_test_executables", False)))
|
||||
if not targets or not passed:
|
||||
raise RuntimeError("workspace target matrix executed no Rust tests")
|
||||
result["passed_tests"] = passed
|
||||
|
||||
|
||||
def run_command(command, directory, number, timeout, *, single_harness=False):
|
||||
path = directory / f"{number:03d}.log"
|
||||
started = time.time()
|
||||
record = {"command": command, "log": str(path), "started": started}
|
||||
write_private_json(directory / f"{number:03d}.command.json", record)
|
||||
failure = None
|
||||
child = None
|
||||
try:
|
||||
with path.open("x") as output:
|
||||
child = subprocess.Popen(command, cwd=ROOT, stdout=output, stderr=subprocess.STDOUT,
|
||||
start_new_session=True)
|
||||
try:
|
||||
child.wait(timeout=timeout)
|
||||
except BaseException:
|
||||
try:
|
||||
# Python 测量器以 KeyboardInterrupt 执行其 finally,回收独立进程组和 cgroup。
|
||||
os.killpg(child.pid, signal.SIGINT)
|
||||
child.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(child.pid, signal.SIGKILL)
|
||||
child.wait(timeout=5)
|
||||
except ProcessLookupError:
|
||||
child.wait()
|
||||
raise
|
||||
except BaseException as error:
|
||||
failure = error
|
||||
record["error"] = str(error) or type(error).__name__
|
||||
record["exit_code"] = child.returncode if child is not None else None
|
||||
record["elapsed_seconds"] = time.time() - started
|
||||
content = path.read_text(errors="replace")
|
||||
counts = re.findall(r"test result: (?:ok|FAILED)\. (\d+) passed; (\d+) failed; (\d+) ignored", content)
|
||||
if single_harness:
|
||||
# 直接运行一个 libtest 时,最后一条是父 harness;前面的可能是其子进程探针。
|
||||
counts = counts[-1:]
|
||||
if counts:
|
||||
record["rust_tests"] = {name: sum(int(row[index]) for row in counts)
|
||||
for index, name in enumerate(("passed", "failed", "ignored"))}
|
||||
record["python_tests"] = sum(map(int, re.findall(r"Ran (\d+) tests? in", content)))
|
||||
write_private_json(directory / f"{number:03d}.command.json", record, replace=True)
|
||||
if failure is not None:
|
||||
raise failure
|
||||
if record["exit_code"]:
|
||||
raise RuntimeError(f"command exited {record['exit_code']}: {path}")
|
||||
if re.search(r"OK \(skipped=|: unavailable", content):
|
||||
raise MissingInput(f"command skipped required verification: {path}")
|
||||
return record, content
|
||||
|
||||
|
||||
def check_output(stage, record, content):
|
||||
if stage in ("default", "all-features") and record.get("rust_tests", {}).get("passed", 0) == 0:
|
||||
raise RuntimeError("workspace command did not execute any Rust tests")
|
||||
if stage == "python" and record["python_tests"] == 0:
|
||||
raise RuntimeError("Python discovery executed no tests")
|
||||
if stage == "sdk" and any(f"{language}: handshake, health, result, business error, cancel, shutdown and rejection passed"
|
||||
not in content for language in ("Python", "JavaScript", "Go")):
|
||||
raise RuntimeError("all three SDK acceptance results are required")
|
||||
if stage == "features" and "dever-core feature \"reference\"" in content:
|
||||
raise RuntimeError("production CLI depends on the reference feature")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("action", choices=("plan", "run", "summary"))
|
||||
parser.add_argument("--stage", choices=STAGES)
|
||||
args = parser.parse_args()
|
||||
document, config = load_config()
|
||||
output = Path(config.get("output", ROOT / "target/linux-ci"))
|
||||
if not output.is_absolute():
|
||||
raise ValueError("performance.linux_ci.output must be absolute")
|
||||
source = source_fingerprint()
|
||||
if args.action == "summary":
|
||||
results = {}
|
||||
for stage in STAGES:
|
||||
candidates = sorted((output / stage).glob("*/result.json"))
|
||||
record = json.loads(candidates[-1].read_text()) if candidates else {}
|
||||
stage_inputs = input_fingerprint(document, config, stage)
|
||||
current = (record.get("source") == source and record.get("inputs") == stage_inputs
|
||||
and (stage != "author" or record.get("outputs") == input_fingerprint(document, config)))
|
||||
results[stage] = record["status"] if current else "missing-current-evidence"
|
||||
print(json.dumps(results, indent=2))
|
||||
return 0 if all(status == "passed" for status in results.values()) else 1
|
||||
if args.action == "plan":
|
||||
inputs = input_fingerprint(document, config, args.stage)
|
||||
print(json.dumps({"stages": STAGES, "source": source, "inputs": inputs,
|
||||
"commands": command_plan(args.stage, config) if args.stage in STAGES[:6] else None}, indent=2))
|
||||
return 0
|
||||
if args.stage is None:
|
||||
parser.error("run requires --stage (all stages are serialized by the caller)")
|
||||
directory = output / args.stage / f"{time.time_ns()}"
|
||||
directory.mkdir(parents=True, exist_ok=False)
|
||||
result = {"stage": args.stage, "source": source, "inputs": None, "status": "running", "commands": []}
|
||||
write_private_json(directory / "result.json", result)
|
||||
try:
|
||||
try:
|
||||
inputs = input_fingerprint(document, config, args.stage)
|
||||
except (ValueError, OSError) as error:
|
||||
raise MissingInput(str(error)) from error
|
||||
result["inputs"] = inputs
|
||||
write_private_json(directory / "result.json", result, replace=True)
|
||||
from acceptance import validate_budget
|
||||
validate_budget(config)
|
||||
if args.stage in ("default", "all-features"):
|
||||
run_workspace(args.stage, config, directory, result)
|
||||
elif args.stage in STAGES[:6]:
|
||||
for number, command in enumerate(command_plan(args.stage, config)):
|
||||
print(f"{args.stage}: {' '.join(command)}", flush=True)
|
||||
record, content = run_command(command, directory, number, config.get("command_timeout_seconds", 14400))
|
||||
result["commands"].append(record)
|
||||
check_output(args.stage, record, content)
|
||||
else:
|
||||
from acceptance import run_stage
|
||||
run_stage(args.stage, document, config, directory, result)
|
||||
current_document, current_config = load_config()
|
||||
final_inputs = input_fingerprint(current_document, current_config, args.stage)
|
||||
if args.stage == "author":
|
||||
result["outputs"] = input_fingerprint(current_document, current_config)
|
||||
if source_fingerprint() != source or final_inputs != inputs:
|
||||
raise RuntimeError("source or declared inputs changed while the stage ran; evidence is stale")
|
||||
result["status"] = "passed"
|
||||
except MissingInput as error:
|
||||
result.update(status="blocked", error=str(error))
|
||||
except (Exception, KeyboardInterrupt) as error:
|
||||
result.update(status="failed", error=str(error) or type(error).__name__)
|
||||
finally:
|
||||
try:
|
||||
cleanup_test_executables(directory, config, result)
|
||||
except Exception as error:
|
||||
result.update(status="failed", cleanup_error=str(error))
|
||||
result["commands"] = [json.loads(path.read_text()) for path in sorted(directory.glob("*.command.json"))]
|
||||
write_private_json(directory / "result.json", result, replace=True)
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0 if result["status"] == "passed" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except (MissingInput, ValueError, OSError) as error:
|
||||
print(f"blocked: {error}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""验证共享分配账本:线程缓存正常析构,真实泄漏和错误结果必须拒绝。"""
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DRIVER = ROOT / "test/native-runtime-abi/managed-driver.c"
|
||||
FIXTURE = ROOT / "test/native-runtime-abi/driver-fixture.c"
|
||||
WRAPPED = ("malloc", "calloc", "realloc", "free", "posix_memalign", "realpath")
|
||||
|
||||
|
||||
class DriverTests(unittest.TestCase):
|
||||
def run_fixture(self, scenario):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
executable = Path(temporary) / "ledger"
|
||||
subprocess.run([
|
||||
"/usr/bin/cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-O2",
|
||||
f"-DDEVER_LEDGER_SCENARIO={scenario}", str(DRIVER), str(FIXTURE),
|
||||
*[f"-Wl,--wrap={name}" for name in WRAPPED], "-pthread", "-B/usr/bin/",
|
||||
"-o", str(executable),
|
||||
], check=True, env={}, capture_output=True, text=True, timeout=20)
|
||||
return subprocess.run([str(executable)], env={}, capture_output=True,
|
||||
text=True, timeout=5)
|
||||
|
||||
def test_late_thread_cache_is_released_before_counting(self):
|
||||
result = self.run_fixture(0)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_owned_leaks_and_changed_results_fail(self):
|
||||
for scenario, message in ((1, "allocation imbalance: 1"),
|
||||
(2, "changed its result on repeated execution: 7")):
|
||||
with self.subTest(scenario=scenario):
|
||||
result = self.run_fixture(scenario)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn(message, result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,317 @@
|
||||
"""质量门防假通过合同;不运行 Cargo、数据库或真实压力测试。"""
|
||||
|
||||
import importlib.util
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
spec = importlib.util.spec_from_file_location("linux_gate", Path(__file__).with_name("run.py"))
|
||||
gate = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = gate
|
||||
spec.loader.exec_module(gate)
|
||||
import acceptance
|
||||
from acceptance import classify_cases, validate_postgres, validate_soak
|
||||
|
||||
|
||||
class GateTests(unittest.TestCase):
|
||||
def test_ci_config_uses_the_shared_performance_section(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
(root / "config").mkdir()
|
||||
setting = root / "config/setting.json"
|
||||
config = {"output": str(root / "results"), "inputs": []}
|
||||
document = {"performance": {"linux_ci": config}}
|
||||
with patch.object(gate, "ROOT", root):
|
||||
with self.assertRaisesRegex(gate.MissingInput, r"performance\.linux_ci"):
|
||||
gate.load_config()
|
||||
setting.write_text(json.dumps(document))
|
||||
self.assertEqual(gate.load_config(), (document, config))
|
||||
before = gate.input_fingerprint(document, config)
|
||||
config["command_timeout_seconds"] = 120
|
||||
self.assertNotEqual(before, gate.input_fingerprint(document, config))
|
||||
for invalid in ({"linux_ci": config}, {"performance": {}},
|
||||
{"performance": []}, {"performance": {"linux_ci": []}}):
|
||||
setting.write_text(json.dumps(invalid))
|
||||
with self.subTest(document=invalid), self.assertRaisesRegex(
|
||||
gate.MissingInput, r"performance\.linux_ci"):
|
||||
gate.load_config()
|
||||
|
||||
def test_performance_peer_fingerprints_resolve_project_relative_paths(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
(root / "config").mkdir()
|
||||
(root / "peers").mkdir()
|
||||
peer = root / "peers/network"
|
||||
peer.write_text("original peer")
|
||||
live_peer = root / "peers/live"
|
||||
live_peer.write_text("original live peer")
|
||||
document = {"performance": {"network_peer": "peers/network", "live_peer": str(live_peer),
|
||||
"linux_ci": {"inputs": []}}}
|
||||
(root / "config/setting.json").write_text(json.dumps(document))
|
||||
with patch.object(gate, "ROOT", root):
|
||||
before = gate.input_fingerprint(document, {})
|
||||
peer.write_text("changed peer")
|
||||
self.assertNotEqual(before, gate.input_fingerprint(document, {}))
|
||||
before = gate.input_fingerprint(document, {})
|
||||
live_peer.write_text("changed live peer")
|
||||
self.assertNotEqual(before, gate.input_fingerprint(document, {}))
|
||||
for config in ({"inputs": ["peers/network"]}, {"tools": {"cargo": "peers/network"}}):
|
||||
with self.subTest(config=config), self.assertRaisesRegex(ValueError, "absolute paths"):
|
||||
gate.input_fingerprint(document, config)
|
||||
|
||||
def test_default_targets_preserve_workspace_resolved_features(self):
|
||||
target = {"package": "dever-backend-bridge", "selector": ["--test", "backend_bridge"],
|
||||
"features": ["embedded", "runtime-api"]}
|
||||
command = gate.target_command({"tools": {"cargo": "/explicit/cargo"}}, target, False)
|
||||
self.assertEqual(command[-2:], ["--features", "embedded,runtime-api"])
|
||||
self.assertNotIn("--all-features", command)
|
||||
|
||||
def test_sdk_missing_and_partial_execution_fail(self):
|
||||
config = {"tools": {"cargo": sys.executable, "python": sys.executable}}
|
||||
with self.assertRaises(gate.MissingInput):
|
||||
gate.command_plan("sdk", config)
|
||||
with self.assertRaises(RuntimeError):
|
||||
gate.check_output("sdk", {}, "Python: handshake passed")
|
||||
with self.assertRaises(RuntimeError):
|
||||
gate.check_output("python", {"python_tests": 0}, "OK")
|
||||
|
||||
def test_classification_rejects_unknown_duplicate_and_wrong_target(self):
|
||||
row = {"package": "sandbox", "target": "isolation", "name": "restricted_probe", "stage": "probe"}
|
||||
target = {"package": "sandbox", "target": "isolation"}
|
||||
self.assertEqual(classify_cases(["restricted_probe"], [row], target)[0]["stage"], "probe")
|
||||
for names, rows, owner in ((["unknown"], [row], target), (["restricted_probe"], [row, row], target),
|
||||
(["restricted_probe"], [row], {**target, "target": "other"}),
|
||||
(["a::restricted_probe", "b::restricted_probe"], [row], target)):
|
||||
with self.assertRaises(RuntimeError):
|
||||
classify_cases(names, rows, owner)
|
||||
|
||||
def test_postgres_requires_owned_loopback_case_template_without_override(self):
|
||||
def document(url):
|
||||
return {"database": {"postgres_test": {"type": "postgres", "url": url}}}
|
||||
validate_postgres(document("postgresql://fixture@127.0.0.1:51234/dever_{case}"))
|
||||
for url in ("postgresql://fixture@db.example:5432/dever_{case}",
|
||||
"postgresql://fixture@127.0.0.1:5432/fixed", "postgresql://fixture@127.0.0.1/dever_{case}",
|
||||
"postgresql://fixture@127.0.0.1:5432/dever_{case}?dbname=production"):
|
||||
with self.assertRaises(gate.MissingInput):
|
||||
validate_postgres(document(url))
|
||||
|
||||
def test_failed_command_keeps_exit_and_counts(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
with self.assertRaises(RuntimeError):
|
||||
gate.run_command([sys.executable, "-c", "print('test result: FAILED. 2 passed; 1 failed; 3 ignored'); raise SystemExit(7)"], directory, 0, 5)
|
||||
record = json.loads((directory / "000.command.json").read_text())
|
||||
self.assertEqual(record["exit_code"], 7)
|
||||
self.assertEqual(record["rust_tests"], {"passed": 2, "failed": 1, "ignored": 3})
|
||||
|
||||
def test_single_harness_counts_parent_instead_of_nested_probe(self):
|
||||
child = "test result: ok. 1 passed; 0 failed; 0 ignored"
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
for number, (single, parent_passed, parent_ignored, exit_code) in enumerate(
|
||||
((False, 1, 0, 0), (True, 1, 0, 0), (True, 0, 0, 0),
|
||||
(True, 0, 1, 0), (True, 0, 0, 7))):
|
||||
parent_failed = int(exit_code != 0)
|
||||
parent = (f"test result: {'FAILED' if parent_failed else 'ok'}. "
|
||||
f"{parent_passed} passed; {parent_failed} failed; {parent_ignored} ignored")
|
||||
command = [sys.executable, "-c",
|
||||
f"print({child!r}); print({parent!r}); raise SystemExit({exit_code})"]
|
||||
with self.subTest(single=single, parent=parent):
|
||||
if exit_code:
|
||||
with self.assertRaises(RuntimeError):
|
||||
gate.run_command(command, directory, number, 5, single_harness=single)
|
||||
else:
|
||||
gate.run_command(command, directory, number, 5, single_harness=single)
|
||||
record = json.loads((directory / f"{number:03d}.command.json").read_text())
|
||||
self.assertEqual(record["exit_code"], exit_code)
|
||||
self.assertEqual(record["rust_tests"], {
|
||||
"passed": parent_passed + int(not single),
|
||||
"failed": parent_failed, "ignored": parent_ignored})
|
||||
|
||||
def test_exact_ignored_acceptance_requires_one_successful_parent(self):
|
||||
target = {"package": "sandbox", "target": "isolation"}
|
||||
case = {"name": "parent", "exact": "parent", "stage": "sandbox",
|
||||
"target": target, "target_directory": "/unused/target"}
|
||||
for counts in ({"passed": 1, "failed": 0, "ignored": 0},
|
||||
{"passed": 0, "failed": 0, "ignored": 0},
|
||||
{"passed": 0, "failed": 0, "ignored": 1},
|
||||
{"passed": 1, "failed": 1, "ignored": 0},
|
||||
{"passed": 2, "failed": 0, "ignored": 0}, None):
|
||||
with (self.subTest(counts=counts),
|
||||
patch.object(acceptance, "require_inputs"),
|
||||
patch.object(acceptance, "current_inventory", return_value=[case]),
|
||||
patch.object(acceptance, "target_command", return_value=["cargo"]),
|
||||
patch.object(acceptance, "test_executable", return_value="/unused/test"),
|
||||
patch.object(acceptance, "remove_executables", return_value=[]),
|
||||
patch.object(acceptance, "execute", side_effect=[({}, "build"),
|
||||
({"rust_tests": counts}, "test")]) as execute):
|
||||
result = {"commands": []}
|
||||
expected = counts == {"passed": 1, "failed": 0, "ignored": 0}
|
||||
with contextlib.nullcontext() if expected else self.assertRaisesRegex(RuntimeError, "one case"):
|
||||
acceptance.ignored_stage("sandbox", {}, {}, Path("/unused"), result)
|
||||
self.assertTrue(execute.call_args.kwargs["single_harness"])
|
||||
self.assertEqual(len(result.get("cases", [])), int(expected))
|
||||
|
||||
def test_source_and_author_input_fingerprints_detect_real_changes(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
for name in ("Cargo.toml", "Cargo.lock"):
|
||||
(root / name).write_text(name)
|
||||
(root / "test").mkdir()
|
||||
before = gate.source_fingerprint(root)
|
||||
(root / "test/new.py").write_text("changed")
|
||||
self.assertNotEqual(before, gate.source_fingerprint(root))
|
||||
output = root / "generated.pack"
|
||||
output.write_text("old")
|
||||
config = {"inputs": [str(root)], "author_outputs": [str(output)]}
|
||||
author_before = gate.input_fingerprint({}, config, "author")
|
||||
native_before = gate.input_fingerprint({}, config, "native")
|
||||
output.write_text("new")
|
||||
self.assertEqual(author_before, gate.input_fingerprint({}, config, "author"))
|
||||
self.assertNotEqual(native_before, gate.input_fingerprint({}, config, "native"))
|
||||
(root / "Cargo.lock").write_text("changed immutable input")
|
||||
self.assertNotEqual(author_before, gate.input_fingerprint({}, config, "author"))
|
||||
|
||||
def test_cleanup_cannot_delete_author_binary(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
executable = root / "native-acceptance-init"
|
||||
executable.write_text("owned author fixture")
|
||||
message = json.dumps({"reason": "compiler-artifact", "profile": {"test": True}, "executable": str(executable)})
|
||||
with self.assertRaises(RuntimeError):
|
||||
gate.remove_executables(message, root, True)
|
||||
self.assertTrue(executable.exists())
|
||||
|
||||
def test_failed_workspace_command_still_cleans_its_test_executable(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
executable = root / "target/debug/deps/test-owned"
|
||||
executable.parent.mkdir(parents=True)
|
||||
executable.write_text("rebuildable executable")
|
||||
config = {"output": str(root / "results"), "remove_test_executables": True,
|
||||
"tools": {"cargo": sys.executable}}
|
||||
|
||||
def fail_workspace(stage, config, directory, result):
|
||||
result["target_directory"] = str(root / "target")
|
||||
message = json.dumps({"reason": "compiler-artifact", "profile": {"test": True},
|
||||
"executable": str(executable)})
|
||||
gate.run_command([sys.executable, "-c", f"print({message!r}); raise SystemExit(7)",
|
||||
"--message-format=json"],
|
||||
directory, 0, 5)
|
||||
|
||||
with (patch.object(gate, "load_config", return_value=({}, config)),
|
||||
patch.object(gate, "source_fingerprint", return_value="source"),
|
||||
patch.object(gate, "run_workspace", side_effect=fail_workspace),
|
||||
patch.object(acceptance, "validate_budget"),
|
||||
patch.object(sys, "argv", ["run.py", "run", "--stage", "default"]),
|
||||
contextlib.redirect_stdout(io.StringIO())):
|
||||
self.assertEqual(gate.main(), 1)
|
||||
self.assertFalse(executable.exists())
|
||||
record = json.loads(next((root / "results/default").glob("*/result.json")).read_text())
|
||||
self.assertEqual(record["status"], "failed")
|
||||
self.assertIn("command exited 7", record["error"])
|
||||
self.assertEqual(record["removed_test_executables"], [str(executable)])
|
||||
|
||||
def test_missing_input_is_saved_as_blocked_stage_evidence(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
config = {"output": temporary}
|
||||
with (patch.object(gate, "load_config", return_value=({}, config)),
|
||||
patch.object(gate, "source_fingerprint", return_value="source"),
|
||||
patch.object(gate, "input_fingerprint", side_effect=FileNotFoundError("missing peer")),
|
||||
patch.object(sys, "argv", ["run.py", "run", "--stage", "python"]),
|
||||
contextlib.redirect_stdout(io.StringIO())):
|
||||
self.assertEqual(gate.main(), 1)
|
||||
record = json.loads(next((Path(temporary) / "python").glob("*/result.json")).read_text())
|
||||
self.assertEqual(record["status"], "blocked")
|
||||
self.assertIn("missing peer", record["error"])
|
||||
|
||||
def test_inventory_never_reuses_pass_after_a_newer_failure_or_interruption(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
config = {"output": temporary}
|
||||
fingerprint = gate.input_fingerprint({}, config, "inventory")
|
||||
for index, status in enumerate(("passed", "running")):
|
||||
directory = root / "inventory" / str(index)
|
||||
directory.mkdir(parents=True)
|
||||
(directory / "result.json").write_text(json.dumps({"source": "source", "inputs": fingerprint,
|
||||
"status": status}))
|
||||
(directory / "inventory.json").write_text("[]")
|
||||
with self.assertRaises(gate.MissingInput):
|
||||
acceptance.current_inventory({}, config, {"source": "source"})
|
||||
output = io.StringIO()
|
||||
with (patch.object(gate, "load_config", return_value=({}, config)),
|
||||
patch.object(gate, "source_fingerprint", return_value="source"),
|
||||
patch.object(gate, "STAGES", ("inventory",)),
|
||||
patch.object(sys, "argv", ["run.py", "summary"]),
|
||||
contextlib.redirect_stdout(output)):
|
||||
self.assertEqual(gate.main(), 1)
|
||||
self.assertEqual(json.loads(output.getvalue()), {"inventory": "running"})
|
||||
(root / "inventory/1/result.json").write_text(json.dumps({
|
||||
"source": "source", "inputs": fingerprint, "status": "failed"}))
|
||||
with self.assertRaises(gate.MissingInput):
|
||||
acceptance.current_inventory({}, config, {"source": "source"})
|
||||
|
||||
def test_inventory_detects_a_missing_owner_even_when_leaf_names_match(self):
|
||||
row = {"package": "package", "target": "first", "name": "same_name", "stage": "native"}
|
||||
target = {"package": "package", "target": "first", "selector": ["--lib"]}
|
||||
artifact = json.dumps({"reason": "compiler-artifact", "profile": {"test": True},
|
||||
"target": {"name": "first"}, "executable": "/owned/test"})
|
||||
with (tempfile.TemporaryDirectory() as temporary,
|
||||
patch.object(acceptance, "audit_classification", return_value=[row, {**row, "target": "second"}]),
|
||||
patch.object(acceptance, "workspace_targets", return_value=([target], Path(temporary))),
|
||||
patch.object(acceptance, "execute", side_effect=[({}, artifact), ({}, "same_name: test\n")])):
|
||||
with self.assertRaisesRegex(RuntimeError, "second"):
|
||||
acceptance.inventory({"tools": {"cargo": "/cargo"}}, Path(temporary), {"commands": []})
|
||||
|
||||
def test_soak_rejects_short_load_errors_drops_missing_budget_and_fd_leak(self):
|
||||
samples = [{"rss_bytes": 100, "fds": 3, "elapsed_seconds": index * 600} for index in range(4)]
|
||||
case = {"suite": "http2", "implementation": "dever", "transport": "http",
|
||||
"budget_after_stop": {"events": {"oom": 0, "oom_kill": 0}, "max": str(64 * 1024 ** 2)},
|
||||
"load": {"elapsed_seconds": 1800, "scheduled": 1000, "succeeded": 1000, "errors": 0},
|
||||
"disconnected": {"fd_delta": 0}, "raw": samples}
|
||||
report = {"status": "complete", "cases": [case, {**case, "transport": "https"}]}
|
||||
validate_soak(report, "http2", 1800, 1024, 64)
|
||||
for field, value in (("elapsed_seconds", 1799), ("errors", 1), ("succeeded", 989)):
|
||||
invalid = json.loads(json.dumps(report))
|
||||
invalid["cases"][0]["load"][field] = value
|
||||
with self.assertRaises(RuntimeError):
|
||||
validate_soak(invalid, "http2", 1800, 1024, 64)
|
||||
for field, value in (("transport", "https"), ("implementation", "hyper"),
|
||||
("disconnected", {"fd_delta": 1}),
|
||||
("budget_after_stop", {"events": {"oom": 0, "oom_kill": 0}, "max": "max"})):
|
||||
invalid = json.loads(json.dumps(report))
|
||||
invalid["cases"][0][field] = value
|
||||
with self.assertRaises(RuntimeError):
|
||||
validate_soak(invalid, "http2", 1800, 1024, 64)
|
||||
case["budget_after_stop"] = None
|
||||
with self.assertRaises(RuntimeError):
|
||||
validate_soak(report, "http2", 1800, 1024, 64)
|
||||
|
||||
def test_cms_soak_checks_descriptor_identity_and_pool_bound_not_total_count(self):
|
||||
load = {"elapsed_seconds": 1800, "succeeded": 1000, "errors": 0,
|
||||
"fd_delta_after_disconnect": 1,
|
||||
"descriptors_before": {"3": "socket:[listener]", "4": "/fixture/platform.db"},
|
||||
"descriptors_after": {"3": "socket:[listener]", "4": "/fixture/platform.db",
|
||||
"5": "/fixture/platform.db"},
|
||||
"pooled_descriptor_limits": {"/fixture/platform.db": 4},
|
||||
"trend": {"rss_late_growth_bytes": 0}}
|
||||
case = {"cgroup": {"events": {"oom": 0, "oom_kill": 0}, "max": str(64 * 1024 ** 2)},
|
||||
"concurrent_reads": load}
|
||||
report = {"status": "passed", "cases": {"dever": case, "md": case}}
|
||||
validate_soak(report, "cms", 1800, 1024, 64)
|
||||
for descriptors in ({**load["descriptors_after"], "3": "socket:[replacement]"},
|
||||
{**load["descriptors_after"], "9": "socket:[leaked]"},
|
||||
{**load["descriptors_after"], **{str(n): "/fixture/platform.db" for n in range(10, 15)}}):
|
||||
invalid = json.loads(json.dumps(report))
|
||||
invalid["cases"]["md"]["concurrent_reads"]["descriptors_after"] = descriptors
|
||||
with self.subTest(descriptors=descriptors), self.assertRaises(RuntimeError):
|
||||
validate_soak(invalid, "cms", 1800, 1024, 64)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,13 @@
|
||||
# CMS 业务验收
|
||||
|
||||
显式运行,使用随机 loopback 端口、真实登录及核心角色授权、临时 SQLite 和正常 Job Worker;不访问部署数据库,不启动压测。
|
||||
|
||||
```sh
|
||||
python3 test/cms/acceptance.py --compiler /absolute/path/to/dever --output /new/report-directory
|
||||
```
|
||||
|
||||
运行器复制 plain CMS,并只在副本追加已有 `test/dever-tests/fixtures/postgres_api` 的真实账号/成员建立 CMD。输出目录保存无配置/凭据的 `cms-app`,可用 `--executable /absolute/path/to/cms-app` 跳过后续重复构建;该程序必须包含同一个 `fixture_member` CMD,`--project` 提供匹配的源项目配置。输入源码、二进制和部署配置均不修改。临时密钥、密码和数据库随工作目录清理。
|
||||
|
||||
覆盖分类、上传与文件清理、关联文章、修订快照、过期/并发版本冲突、作者和角色权限、发布快照、定时/同刻任务、重复执行、失败回滚/重试、排队后撤权和会话撤销/到期。仅在自有 SQLite 加临时拒绝 INSERT 的 trigger 注入晚期故障;重放只重置已完成队列行的执行状态,保留原身份、租户、权限、schema 与 payload;会话到期通过把自有平台库的 admin 会话截止时间设为过去验证,不伪造身份。所有普通业务和授权经真实 API/CMD。不会将数据库回滚等同于已提交存储文件回滚。
|
||||
|
||||
每个 HTTP 请求、构建和进程均有时限;退出时停止并回收自有进程。此验收是有界业务正确性检查,不代表 PostgreSQL、浏览器 HTTPS、长期负载或性能测试。双源码等价由现有编译器 CMS 合同检查负责,不在此复制 Markdown 解析器。
|
||||
@@ -0,0 +1,476 @@
|
||||
"""自有 SQLite/HTTP CMS 业务验收;不启动压测,不使用部署数据库。"""
|
||||
|
||||
import argparse
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import closing, contextmanager
|
||||
from datetime import datetime, timezone
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
from threading import Barrier
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "test/performance"))
|
||||
from cms import (CmsClient, available_port, catalog_key, copy_cms_project,
|
||||
decode_response, execute, isolated_settings, login_client,
|
||||
provision_identity, require)
|
||||
from process import Process
|
||||
from settings import write_private_json
|
||||
|
||||
ARTICLE = "/news/article/admin/manage"
|
||||
REVISION = "/content/revision/admin/manage/create"
|
||||
CATEGORY = "/content/category/admin/manage"
|
||||
ASSET = "/media/asset/admin/manage/upload"
|
||||
SCHEDULE = "/publishing/schedule/admin/manage/schedule"
|
||||
ROLES = "/user/authorization/admin/manage"
|
||||
BUSINESS_TABLES = ("article", "revision", "publication", "schedule", "operation", "asset", "category")
|
||||
|
||||
|
||||
def append_member_fixture(project):
|
||||
"""复用真实凭据/成员关系 fixture;仅追加到本运行器复制的 plain 项目。"""
|
||||
fixtures = ROOT / "test/dever-tests/fixtures/postgres_api"
|
||||
for target, name in (("user/account/app.dever", "account_app.dever"),
|
||||
("user/membership/app.dever", "membership_app.dever"),
|
||||
("user/account/api.dever", "account_api.dever")):
|
||||
path = project / "module" / target
|
||||
require(path.is_file(), "compiler mode requires the plain CMS project")
|
||||
with path.open("a") as destination:
|
||||
destination.write("\n" + (fixtures / name).read_text())
|
||||
|
||||
|
||||
def sql_read(database, sql, parameters=()):
|
||||
with closing(sqlite3.connect(database.as_uri() + "?mode=ro", uri=True, timeout=5)) as connection:
|
||||
connection.row_factory = sqlite3.Row
|
||||
return [dict(row) for row in connection.execute(sql, parameters)]
|
||||
|
||||
|
||||
def snapshot(database):
|
||||
return {table: sql_read(database, f'SELECT * FROM "{table}" ORDER BY id')
|
||||
for table in BUSINESS_TABLES}
|
||||
|
||||
|
||||
def fixture_write(database, sql, parameters=()):
|
||||
# 此入口只接收自有临时数据库。所有正常业务创建和授权仍走真实 API/CMD。
|
||||
with closing(sqlite3.connect(database, timeout=5)) as connection:
|
||||
with connection:
|
||||
connection.execute(sql, parameters)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def reject_inserts(database, table):
|
||||
require(table in BUSINESS_TABLES, "unsupported failure injection table")
|
||||
fixture_write(database, f'''CREATE TRIGGER cms_reject_insert BEFORE INSERT ON "{table}"
|
||||
BEGIN SELECT RAISE(ABORT, 'owned CMS acceptance failure'); END''')
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fixture_write(database, "DROP TRIGGER cms_reject_insert")
|
||||
|
||||
|
||||
def wait_for(check, process, label, timeout=15):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
require(process.child.poll() is None, f"{label}: child exited; see {process.directory}")
|
||||
value = check()
|
||||
if value:
|
||||
return value
|
||||
time.sleep(0.05)
|
||||
raise TimeoutError(f"{label}: timed out after {timeout}s")
|
||||
|
||||
|
||||
def ready(process, client):
|
||||
def probe():
|
||||
try:
|
||||
client.request("GET", ARTICLE + "/list", expected=401)
|
||||
return True
|
||||
except (OSError, http.client.HTTPException):
|
||||
client.close()
|
||||
return False
|
||||
wait_for(probe, process, "API startup", timeout=20)
|
||||
|
||||
|
||||
def multipart(client, content, *, expected=200, field="file"):
|
||||
boundary = "cms-" + secrets.token_hex(12)
|
||||
body = (f'--{boundary}\r\nContent-Disposition: form-data; name="{field}"; '
|
||||
'filename="article.txt"\r\nContent-Type: text/plain\r\n\r\n').encode()
|
||||
body += content + f"\r\n--{boundary}--\r\n".encode()
|
||||
client.connection.request("POST", ASSET, body, {
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
"Origin": client.origin, "Cookie": client.cookies.get("admin", ""),
|
||||
})
|
||||
response = client.connection.getresponse()
|
||||
reply = response.read()
|
||||
require(response.status == expected,
|
||||
f"upload: HTTP {response.status}, expected {expected}: {reply!r}")
|
||||
if expected != 200:
|
||||
require(json.loads(reply)["code"] != 0, "upload rejection returned success")
|
||||
return None
|
||||
return decode_response(reply, response.status)
|
||||
|
||||
|
||||
class Acceptance:
|
||||
def __init__(self, executable, directory, settings, identity, password, member, member_password):
|
||||
self.executable = executable
|
||||
self.directory = directory
|
||||
self.settings = settings
|
||||
self.identity = identity
|
||||
self.password = password
|
||||
self.member_identity = member
|
||||
self.member_password = member_password
|
||||
self.port = settings["http"]["port"]
|
||||
self.owner = CmsClient(self.port)
|
||||
self.member = CmsClient(self.port)
|
||||
self.anonymous = CmsClient(self.port)
|
||||
self.database = directory / "data/tenants" / f"tenant_{identity['tenant_id']}.db"
|
||||
self.worker_number = 0
|
||||
self.checks = []
|
||||
|
||||
def close(self):
|
||||
for client in (self.owner, self.member, self.anonymous):
|
||||
client.close()
|
||||
|
||||
def passed(self, name):
|
||||
self.checks.append(name)
|
||||
print(f"PASS {name}", flush=True)
|
||||
|
||||
def assert_unchanged(self, before, label):
|
||||
require(snapshot(self.database) == before, f"{label}: business rows changed")
|
||||
|
||||
def create(self, client, slug, **fields):
|
||||
return client.request("POST", ARTICLE + "/create", {
|
||||
"slug": slug, "title": f"Title {slug}", "body": "Draft body", **fields,
|
||||
}, site="admin")
|
||||
|
||||
def edit(self, client, article, *, title="Edited title", expected=200):
|
||||
revision = client.request("POST", REVISION, {
|
||||
"selected_article_id": article["id"], "title": title, "body": "Edited body",
|
||||
"expected_version": article["version"], "category_id": article.get("category_id"),
|
||||
"asset_id": article.get("asset_id"),
|
||||
}, site="admin", expected=expected)
|
||||
if expected != 200:
|
||||
return None
|
||||
current = client.request("GET", ARTICLE + "/detail", {"id": article["id"]}, site="admin")
|
||||
require(revision["article_id"] == current["id"] and revision["version"] == current["version"]
|
||||
and revision["title"] == current["title"] and revision["body"] == current["body"],
|
||||
"revision and current article disagree")
|
||||
return current
|
||||
|
||||
def schedule(self, client, article, milliseconds):
|
||||
instant = datetime.fromtimestamp(milliseconds / 1000, timezone.utc)
|
||||
return client.request("POST", SCHEDULE, {
|
||||
"article_id": article["id"],
|
||||
"publish_at": instant.isoformat(timespec="milliseconds").replace("+00:00", "Z"),
|
||||
}, site="admin")
|
||||
|
||||
def job(self, schedule):
|
||||
rows = sql_read(self.database,
|
||||
"SELECT * FROM _dever_jobs WHERE target=? ORDER BY id",
|
||||
("publishing.schedule.job.publish",))
|
||||
matches = [row for row in rows if json.loads(row["payload"])["schedule_id"] == schedule["id"]]
|
||||
require(len(matches) == 1, f"schedule {schedule['id']} must own exactly one Job: {rows}")
|
||||
return matches[0]
|
||||
|
||||
@contextmanager
|
||||
def worker(self):
|
||||
self.worker_number += 1
|
||||
settings = {**self.settings, "runtime": {**self.settings["runtime"], "mode": "worker"}}
|
||||
write_private_json(self.directory / "config/setting.json", settings, replace=True)
|
||||
try:
|
||||
with Process([self.executable], self.directory / f"worker-{self.worker_number}") as process:
|
||||
yield process
|
||||
require(process.child.returncode == 0, f"Worker did not shut down cleanly: {process.directory}")
|
||||
finally:
|
||||
write_private_json(self.directory / "config/setting.json", self.settings, replace=True)
|
||||
|
||||
def authorization(self):
|
||||
login_client(self.owner, self.identity, self.password, "cms")
|
||||
login_client(self.member, self.member_identity, self.member_password, "cms")
|
||||
before = snapshot(self.database)
|
||||
self.anonymous.request("GET", ARTICLE + "/list", expected=401)
|
||||
self.member.request("GET", ARTICLE + "/list", site="admin", expected=403)
|
||||
for client, status in ((self.anonymous, 401), (self.member, 403)):
|
||||
client.request("POST", ARTICLE + "/create", {
|
||||
"slug": "must-not-create", "title": "Rejected", "body": "Rejected",
|
||||
}, site="admin", expected=status)
|
||||
self.assert_unchanged(before, "unauthenticated and ungranted writes")
|
||||
catalog = self.owner.request("GET", ROLES + "/permissions", site="admin")
|
||||
selections = (("news", "article", "create", "POST"),
|
||||
("news", "article", "list", "GET"),
|
||||
("news", "article", "detail", "GET"),
|
||||
("news", "article", "publish", "POST"),
|
||||
("news", "article", "remove", "DELETE"),
|
||||
("content", "revision", "create", "POST"),
|
||||
("publishing", "schedule", "schedule", "POST"))
|
||||
keys = [catalog_key(catalog, component, domain, "admin", action, method)
|
||||
for component, domain, action, method in selections]
|
||||
self.owner.request("POST", ROLES + "/save", {
|
||||
"id": "cms-editor", "name": "CMS editor", "all_permissions": False,
|
||||
"permission_keys": keys,
|
||||
}, site="admin")
|
||||
self.owner.request("POST", ROLES + "/grant", {
|
||||
"user_id": self.member_identity["user_id"], "role_id": "cms-editor",
|
||||
}, site="admin")
|
||||
self.member.request("GET", ARTICLE + "/list", {"size": 20}, site="admin")
|
||||
self.passed("real login, ungranted denial and explicit member role")
|
||||
|
||||
def editorial(self):
|
||||
category = self.owner.request("POST", CATEGORY + "/create", {"name": " CMS News "}, site="admin")
|
||||
upload = multipart(self.owner, b"CMS attachment\n")
|
||||
stored = self.directory / "data/upload" / upload["storage_key"]
|
||||
require(stored.read_bytes() == b"CMS attachment\n", "uploaded bytes changed")
|
||||
files = sorted(path.name for path in stored.parent.iterdir())
|
||||
rows = snapshot(self.database)
|
||||
multipart(self.owner, b"x" * 2049, expected=400)
|
||||
multipart(self.owner, b"bad field", field="unknown", expected=400)
|
||||
require(sorted(path.name for path in stored.parent.iterdir()) == files,
|
||||
"rejected upload persisted a file")
|
||||
require(not list((self.directory / "data/tmp").iterdir()), "upload temporary files leaked")
|
||||
self.assert_unchanged(rows, "rejected uploads")
|
||||
article = self.create(self.owner, "linked-article", category_id=category["id"], asset_id=upload["id"])
|
||||
require(article["category_id"] == category["id"] and article["asset_id"] == upload["id"],
|
||||
"article lost category/asset references")
|
||||
before = snapshot(self.database)
|
||||
self.edit(self.member, article, expected=403)
|
||||
self.member.request("POST", ARTICLE + "/publish", {"article_id": article["id"]},
|
||||
site="admin", expected=403)
|
||||
self.member.request("DELETE", ARTICLE + "/remove", {"id": article["id"]},
|
||||
site="admin", expected=403)
|
||||
self.member.request("POST", SCHEDULE, {
|
||||
"article_id": article["id"],
|
||||
"publish_at": datetime.fromtimestamp(time.time() + 30, timezone.utc).isoformat(timespec="milliseconds"),
|
||||
}, site="admin", expected=403)
|
||||
self.assert_unchanged(before, "non-author mutation")
|
||||
edited = self.edit(self.owner, article)
|
||||
require(edited["version"] == article["version"] + 1, "edit did not advance version")
|
||||
before = snapshot(self.database)
|
||||
self.edit(self.owner, article, expected=409)
|
||||
self.assert_unchanged(before, "stale edit")
|
||||
self.passed("multipart cleanup, attachment references, author and stale-version rejection")
|
||||
|
||||
# 失败发生在文章写入之后,必须连同版本/修订一起回滚。
|
||||
with reject_inserts(self.database, "revision"):
|
||||
self.edit(self.owner, edited, title="Must roll back", expected=500)
|
||||
self.assert_unchanged(before, "revision insert failure")
|
||||
self.passed("HTTP edit failure rolls back article and revision")
|
||||
winner = self.concurrent_edit(edited)
|
||||
before = snapshot(self.database)
|
||||
with reject_inserts(self.database, "publication"):
|
||||
self.owner.request("POST", ARTICLE + "/publish", {"article_id": article["id"]},
|
||||
site="admin", expected=500)
|
||||
self.assert_unchanged(before, "publication insert failure")
|
||||
published = self.owner.request("POST", ARTICLE + "/publish", {"article_id": article["id"]}, site="admin")
|
||||
require(published["title"] == winner["title"] and published["body"] == winner["body"],
|
||||
"publication is not the winning stored revision")
|
||||
before = snapshot(self.database)
|
||||
self.owner.request("POST", ARTICLE + "/publish", {"article_id": article["id"]}, site="admin", expected=409)
|
||||
self.assert_unchanged(before, "repeated manual publish")
|
||||
self.owner.request("DELETE", CATEGORY + "/disable", {"id": category["id"]}, site="admin")
|
||||
before = snapshot(self.database)
|
||||
self.owner.request("POST", ARTICLE + "/create", {
|
||||
"slug": "disabled-category", "title": "Rejected", "body": "Rejected", "category_id": category["id"],
|
||||
}, site="admin", expected=409)
|
||||
self.assert_unchanged(before, "disabled category")
|
||||
self.passed("publish snapshot, late-failure rollback, repeat conflict and disabled category")
|
||||
|
||||
def concurrent_edit(self, article):
|
||||
barrier = Barrier(2, timeout=5)
|
||||
def compete(index):
|
||||
client = CmsClient(self.port)
|
||||
client.cookies = self.owner.cookies.copy()
|
||||
try:
|
||||
barrier.wait()
|
||||
payload = {"selected_article_id": article["id"], "title": f"Concurrent {index}",
|
||||
"body": "Winning body", "expected_version": article["version"],
|
||||
"category_id": article["category_id"], "asset_id": article["asset_id"]}
|
||||
client.connection.request("POST", REVISION, json.dumps(payload), {
|
||||
"Content-Type": "application/json", "Origin": client.origin,
|
||||
"Cookie": client.cookies["admin"],
|
||||
})
|
||||
response = client.connection.getresponse()
|
||||
body = response.read()
|
||||
document = json.loads(body)
|
||||
require(set(document) == {"code", "message", "data"}, "invalid race envelope")
|
||||
require((response.status, document["code"]) in ((200, 0), (409, 409)),
|
||||
f"unexpected concurrent edit: {response.status} {body!r}")
|
||||
return response.status, document["data"]
|
||||
finally:
|
||||
client.close()
|
||||
prior = len(sql_read(self.database, "SELECT id FROM revision WHERE article_id=?", (article["id"],)))
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
results = list(pool.map(compete, (1, 2)))
|
||||
require(sorted(status for status, _ in results) == [200, 409], "concurrent edit must have one winner")
|
||||
winner = next(value for status, value in results if status == 200)
|
||||
require(winner["version"] == article["version"] + 1, "concurrent version advanced twice")
|
||||
revisions = sql_read(self.database, "SELECT * FROM revision WHERE article_id=? ORDER BY version", (article["id"],))
|
||||
require(len(revisions) == prior + 1 and revisions[-1]["title"] == winner["title"],
|
||||
"concurrent edit persisted duplicate or mismatched revisions")
|
||||
self.passed("two simultaneous edits produce one version and one revision")
|
||||
return winner
|
||||
|
||||
def scheduled_publish(self):
|
||||
articles = [self.create(self.owner, f"scheduled-{index}") for index in (1, 2)]
|
||||
due = int(time.time() * 1000) + 3000
|
||||
schedules = [self.schedule(self.owner, article, due) for article in articles]
|
||||
jobs = [self.job(schedule) for schedule in schedules]
|
||||
require(len({job["id"] for job in jobs}) == 2 and all(job["run_at"] == due for job in jobs),
|
||||
"same-time schedules merged or enqueue_at lost the due time")
|
||||
before = snapshot(self.database)
|
||||
with self.worker() as worker:
|
||||
require(time.time() * 1000 < due - 500, "fixture did not leave a pre-due observation window")
|
||||
while time.time() * 1000 < due - 300:
|
||||
require(worker.child.poll() is None, "worker stopped before due time")
|
||||
require(all(self.job(value)["state"] == "pending" for value in schedules), "Job ran early")
|
||||
self.assert_unchanged(before, "not-yet-due schedule")
|
||||
time.sleep(0.05)
|
||||
wait_for(lambda: all(self.job(value)["state"] == "succeeded" for value in schedules), worker,
|
||||
"same-time scheduled publications")
|
||||
for article in articles:
|
||||
require(len(sql_read(self.database, "SELECT id FROM publication WHERE slug=?", (article["slug"],))) == 1,
|
||||
"scheduled article was not published exactly once")
|
||||
self.passed("real future enqueue_at and independent same-time publications")
|
||||
before = snapshot(self.database)
|
||||
for schedule in schedules:
|
||||
# 重放真实已完成任务;保留原身份/租户/权限/指纹/payload,不伪造执行身份。
|
||||
fixture_write(self.database, "UPDATE _dever_jobs SET state='pending',attempt=0,run_at=?,"
|
||||
"claim_token='',lease_until=0,error='' WHERE id=? AND state='succeeded'",
|
||||
(int(time.time() * 1000), self.job(schedule)["id"]))
|
||||
with self.worker() as worker:
|
||||
wait_for(lambda: all(self.job(value)["state"] == "succeeded" for value in schedules), worker,
|
||||
"completed task replay")
|
||||
self.assert_unchanged(before, "completed task replay")
|
||||
self.passed("completed Job replay adds no publication or audit")
|
||||
|
||||
def retry_and_revocation(self):
|
||||
article = self.create(self.owner, "retry-publish")
|
||||
schedule = self.schedule(self.owner, article, int(time.time() * 1000) + 500)
|
||||
before = snapshot(self.database)
|
||||
with reject_inserts(self.database, "publication"):
|
||||
with self.worker() as worker:
|
||||
wait_for(lambda: self.job(schedule)["attempt"] == 1
|
||||
and self.job(schedule)["state"] == "pending", worker, "first failed Job attempt")
|
||||
failed = self.job(schedule)
|
||||
require(failed["error"] == "handler_failed", "fixture did not reach business failure")
|
||||
self.assert_unchanged(before, "failed Job transaction")
|
||||
with self.worker() as worker:
|
||||
wait_for(lambda: self.job(schedule)["state"] == "succeeded", worker, "automatic delayed retry")
|
||||
require(self.job(schedule)["attempt"] == 2, "retry did not use exactly one new claim")
|
||||
self.passed("Job late failure rolls back publication/audit/status and retries successfully")
|
||||
|
||||
article = self.create(self.member, "revoked-publish")
|
||||
schedule = self.schedule(self.member, article, int(time.time() * 1000) + 500)
|
||||
before = snapshot(self.database)
|
||||
self.owner.request("DELETE", ROLES + "/revoke", {
|
||||
"user_id": self.member_identity["user_id"], "role_id": "cms-editor",
|
||||
}, site="admin")
|
||||
self.member.request("GET", ARTICLE + "/list", site="admin", expected=403)
|
||||
with self.worker() as worker:
|
||||
wait_for(lambda: self.job(schedule)["state"] == "blocked", worker, "revoked queued permission")
|
||||
require(self.job(schedule)["error"] == "identity_rejected", "Job blocked for the wrong reason")
|
||||
self.assert_unchanged(before, "permission revoked before Job execution")
|
||||
self.passed("immediate role revocation blocks queued Job before business writes")
|
||||
|
||||
def session_lifecycle(self):
|
||||
before = snapshot(self.database)
|
||||
logged_out = self.owner.request("POST", "/user/account/admin/session/logout", {}, site="admin")
|
||||
require(logged_out["revoked"], "logout did not revoke the session")
|
||||
# 保留旧 Cookie 再请求,证明服务端撤销而非仅清除浏览器 Cookie。
|
||||
self.owner.request("GET", ARTICLE + "/list", site="admin", expected=401)
|
||||
login_client(self.owner, self.identity, self.password, "cms")
|
||||
self.owner.request("GET", ARTICLE + "/list", {"size": 20}, site="admin")
|
||||
control = self.directory / "data/db/platform.db"
|
||||
expired = int(time.time() * 1000) - 1
|
||||
fixture_write(control, "UPDATE session SET expires_at=? WHERE user_id=? AND site='admin'",
|
||||
(expired, self.identity["user_id"]))
|
||||
self.owner.request("GET", ARTICLE + "/list", site="admin", expected=401)
|
||||
self.owner.request("GET", "/news/article/front/browse/list", {"size": 100}, site="front")
|
||||
self.assert_unchanged(before, "revoked and expired sessions")
|
||||
self.passed("logout invalidates the old cookie; expired admin session preserves front isolation")
|
||||
|
||||
def run(self):
|
||||
with Process([self.executable], self.directory / "api") as process:
|
||||
ready(process, self.anonymous)
|
||||
self.authorization()
|
||||
self.editorial()
|
||||
self.scheduled_publish()
|
||||
self.retry_and_revocation()
|
||||
self.session_lifecycle()
|
||||
require(process.child.returncode == 0, f"API did not shut down cleanly: {process.directory}")
|
||||
|
||||
|
||||
def run(args, directory, report):
|
||||
project = directory / "project"
|
||||
copy_cms_project(args.project, project)
|
||||
# 忽略输入工程的任何数据库地址;本验收只生成自有 SQLite 配置。
|
||||
settings = json.loads((project / "config/setting.json").read_text())
|
||||
settings["database"] = {"default": {"type": "sqlite", "path": "data/db/platform.db",
|
||||
"tenant_directory": "data/tenants", "max_connections": 4,
|
||||
"max_page_size": 100}}
|
||||
settings = isolated_settings(settings, available_port())
|
||||
settings["runtime"] = {"mode": "api", "shutdown_ms": 3000}
|
||||
settings["job"] = {"workers": 1, "poll_ms": 50, "lease_ms": 60000,
|
||||
"retry_base_ms": 2000, "retry_max_ms": 2000}
|
||||
settings["http"]["upload"] = {"file_bytes": 2048}
|
||||
write_private_json(project / "config/setting.json", settings, replace=True)
|
||||
if args.compiler:
|
||||
append_member_fixture(project)
|
||||
execute([args.compiler, "check", project], project, "check")
|
||||
execute([args.compiler, "build", project, "--output", project / "cms-app"], project, "build", timeout=300)
|
||||
else:
|
||||
shutil.copy2(args.executable, project / "cms-app")
|
||||
executable = project / "cms-app"
|
||||
# 二进制不含部署配置或fixture凭据,可复用以免调试断言时反复编译。
|
||||
shutil.copy2(executable, args.output / "cms-app")
|
||||
report["executable_bytes"] = executable.stat().st_size
|
||||
identity, password = provision_identity(executable, project, "cms")
|
||||
member_password = secrets.token_urlsafe(24)
|
||||
member = execute([executable, "user.account.fixture_member", json.dumps({
|
||||
"selected_tenant_id": identity["tenant_id"], "email": "member@example.invalid",
|
||||
"display_name": "CMS member", "password": member_password,
|
||||
})], project, "member", envelope=True)
|
||||
acceptance = Acceptance(executable, project, settings, identity, password, member, member_password)
|
||||
report["passed"] = acceptance.checks
|
||||
try:
|
||||
acceptance.run()
|
||||
return {"passed": acceptance.checks, "database": "owned SQLite", "load_test": False}
|
||||
finally:
|
||||
acceptance.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
source = parser.add_mutually_exclusive_group(required=True)
|
||||
source.add_argument("--compiler", type=Path, help="explicit Dever compiler; builds an owned plain source copy")
|
||||
source.add_argument("--executable", type=Path, help="prepared CMS executable including fixture_member CMD")
|
||||
parser.add_argument("--project", type=Path, default=ROOT / "examples/cms/dever")
|
||||
parser.add_argument("--output", type=Path, required=True, help="new directory for credential-free report/logs")
|
||||
args = parser.parse_args()
|
||||
args.project = args.project.resolve(strict=True)
|
||||
for name in ("compiler", "executable"):
|
||||
if getattr(args, name):
|
||||
setattr(args, name, getattr(args, name).resolve(strict=True))
|
||||
args.output.mkdir(parents=True, exist_ok=False)
|
||||
report = {"success": False}
|
||||
with tempfile.TemporaryDirectory(prefix="dever-cms-acceptance-") as temporary:
|
||||
directory = Path(temporary)
|
||||
try:
|
||||
report.update(run(args, directory, report), success=True)
|
||||
except BaseException as error:
|
||||
report["error"] = f"{type(error).__name__}: {error}"
|
||||
raise
|
||||
finally:
|
||||
for log in directory.rglob("*.log"):
|
||||
destination = args.output / log.relative_to(directory)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(log, destination)
|
||||
write_private_json(args.output / "report.json", report)
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,98 @@
|
||||
use super::{Action, BuildTarget, OsString, arguments_for};
|
||||
|
||||
#[test]
|
||||
fn build_target_is_explicit_or_defaults_to_host() {
|
||||
for options in [
|
||||
vec!["--output", "program", "--target", "linux-aarch64"],
|
||||
vec!["--target", "linux-aarch64", "--output", "program"],
|
||||
vec!["--output", "program"],
|
||||
] {
|
||||
let explicit = options.contains(&"--target");
|
||||
let arguments: Vec<_> = [vec!["build", "project"], options]
|
||||
.concat()
|
||||
.into_iter()
|
||||
.map(OsString::from)
|
||||
.collect();
|
||||
let (root, action) = arguments_for(&arguments).unwrap();
|
||||
assert_eq!(root.to_str(), Some("project"));
|
||||
let Action::Build { output, target } = action else {
|
||||
panic!("expected build");
|
||||
};
|
||||
assert_eq!(output.to_str(), Some("program"));
|
||||
assert_eq!(
|
||||
target,
|
||||
if explicit {
|
||||
BuildTarget::LinuxAarch64
|
||||
} else {
|
||||
BuildTarget::host().unwrap()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_rejects_missing_unknown_duplicate_and_misplaced_options() {
|
||||
for options in [
|
||||
vec![],
|
||||
vec!["--output"],
|
||||
vec!["--target"],
|
||||
vec!["--target", "linux-aarch64"],
|
||||
vec!["--output", "program", "--target", "arm64"],
|
||||
vec![
|
||||
"--output",
|
||||
"program",
|
||||
"--target",
|
||||
"linux-aarch64",
|
||||
"--target",
|
||||
"linux-aarch64",
|
||||
],
|
||||
vec!["--output", "program", "--output", "second"],
|
||||
vec!["--output", "--target", "linux-aarch64"],
|
||||
vec!["--output", "program", "--unknown"],
|
||||
vec!["--output", "program", "extra"],
|
||||
vec!["--output", ""],
|
||||
] {
|
||||
let arguments: Vec<_> = [vec!["build", "project"], options]
|
||||
.concat()
|
||||
.into_iter()
|
||||
.map(OsString::from)
|
||||
.collect();
|
||||
assert!(arguments_for(&arguments).is_err(), "accepted {arguments:?}");
|
||||
}
|
||||
for command in ["run", "test", "check"] {
|
||||
let arguments = [command, "project", "--target", "linux-aarch64"].map(OsString::from);
|
||||
assert!(arguments_for(&arguments).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lib_target_keeps_specs_and_rejects_option_drift() {
|
||||
let arguments = [
|
||||
"lib",
|
||||
"add",
|
||||
"project",
|
||||
"pip:demo",
|
||||
"--target",
|
||||
"linux-aarch64",
|
||||
]
|
||||
.map(OsString::from);
|
||||
let (_, action) = arguments_for(&arguments).unwrap();
|
||||
let Action::Lib { specs, target, .. } = action else {
|
||||
panic!("expected lib");
|
||||
};
|
||||
assert_eq!(specs, ["pip:demo"]);
|
||||
assert_eq!(target, BuildTarget::LinuxAarch64);
|
||||
for options in [
|
||||
vec!["--target"],
|
||||
vec!["--target", "arm64"],
|
||||
vec!["--unknown"],
|
||||
vec!["--target", "linux-aarch64", "--target", "linux-aarch64"],
|
||||
] {
|
||||
let arguments: Vec<_> = [vec!["lib", "update", "project"], options]
|
||||
.concat()
|
||||
.into_iter()
|
||||
.map(OsString::from)
|
||||
.collect();
|
||||
assert!(arguments_for(&arguments).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
#[path = "../dever-tests/tests/support/process.rs"]
|
||||
pub mod process;
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
#[path = "support/llvm_pack.rs"]
|
||||
pub mod llvm_pack;
|
||||
|
||||
static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct TemporaryProject(PathBuf);
|
||||
|
||||
impl TemporaryProject {
|
||||
fn new() -> Self {
|
||||
let suffix = NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed);
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("dever-cms-project-{}-{suffix}", std::process::id()));
|
||||
fs::create_dir(&root).unwrap();
|
||||
Self(root)
|
||||
}
|
||||
|
||||
fn copy_from(source: &Path) -> Self {
|
||||
let project = Self::new();
|
||||
copy_directory(&source.join("config"), &project.path().join("config"));
|
||||
copy_directory(&source.join("module"), &project.path().join("module"));
|
||||
let tests = source.join("test");
|
||||
if tests.exists() {
|
||||
copy_directory(&tests, &project.path().join("test"));
|
||||
}
|
||||
fs::create_dir(project.path().join("data")).unwrap();
|
||||
project
|
||||
}
|
||||
|
||||
fn package(source: &Path, executable: &Path) -> Self {
|
||||
let project = Self::new();
|
||||
copy_directory(&source.join("config"), &project.path().join("config"));
|
||||
fs::copy(executable, project.path().join("cms-app")).unwrap();
|
||||
project
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
|
||||
fn block_deployment_database(&self) {
|
||||
// Package metadata needs valid JSON. A directory as the configured
|
||||
// database proves tests use their own SQLite configuration instead.
|
||||
fs::create_dir(self.path().join("data/deployment.db")).unwrap();
|
||||
fs::write(
|
||||
self.path().join("config/setting.json"),
|
||||
r#"{"database":{"default":{"type":"sqlite","path":"data/deployment.db"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TemporaryProject {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_directory(source: &Path, target: &Path) {
|
||||
fs::create_dir(target).unwrap();
|
||||
for entry in fs::read_dir(source).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let source = entry.path();
|
||||
let target = target.join(entry.file_name());
|
||||
if entry.file_type().unwrap().is_dir() {
|
||||
copy_directory(&source, &target);
|
||||
} else {
|
||||
fs::copy(source, target).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dever(project: &Path, action: &str, arguments: &[&str]) -> Output {
|
||||
dever_with_pid(project, action, arguments).1
|
||||
}
|
||||
|
||||
fn dever_with_pid(project: &Path, action: &str, arguments: &[&str]) -> (u32, Output) {
|
||||
output_with_pid(
|
||||
Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.arg(action)
|
||||
.arg(project)
|
||||
.args(arguments),
|
||||
)
|
||||
}
|
||||
|
||||
fn output_with_pid(command: &mut Command) -> (u32, Output) {
|
||||
let capture = TemporaryProject::new();
|
||||
let stdout = capture.path().join("stdout");
|
||||
let stderr = capture.path().join("stderr");
|
||||
let mut child = command
|
||||
.env_clear()
|
||||
.stdout(Stdio::from(fs::File::create(&stdout).unwrap()))
|
||||
.stderr(Stdio::from(fs::File::create(&stderr).unwrap()))
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let pid = child.id();
|
||||
let status = process::wait(&mut child, Duration::from_secs(180)).unwrap();
|
||||
(
|
||||
pid,
|
||||
Output {
|
||||
status,
|
||||
stdout: fs::read(stdout).unwrap(),
|
||||
stderr: fs::read(stderr).unwrap(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_test_artifacts_removed(pid: u32) {
|
||||
let test_prefix = format!("dever-application-test-{pid}-");
|
||||
let build_prefix = format!("dever-build-{pid}-");
|
||||
let leftovers = fs::read_dir(std::env::temp_dir())
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.file_name().to_string_lossy().into_owned())
|
||||
.filter(|name| name.starts_with(&test_prefix) || name.starts_with(&build_prefix))
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
leftovers.is_empty(),
|
||||
"temporary test artifacts remain: {leftovers:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_success(output: &Output) {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn clean_removes_only_stale_owned_dever_artifacts() {
|
||||
use std::os::unix::fs::{MetadataExt, symlink};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let project = TemporaryProject::new();
|
||||
let pid = u64::from(std::process::id()) + 1_000_000;
|
||||
let suffix = NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed);
|
||||
let stale = std::env::temp_dir().join(format!("dever-build-{pid}-{suffix}"));
|
||||
fs::create_dir(&stale).unwrap();
|
||||
let owner = fs::metadata(&stale).unwrap().uid();
|
||||
fs::write(
|
||||
stale.join(".dever-owner"),
|
||||
format!(
|
||||
"dever-build-owner-v1\ncreated=1\npid={pid}\nowner={owner}\ncompiler={}\n",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(stale.join("program"), b"stale").unwrap();
|
||||
|
||||
let fresh_pid = pid + 1;
|
||||
let fresh = std::env::temp_dir().join(format!("dever-build-{fresh_pid}-{suffix}"));
|
||||
fs::create_dir(&fresh).unwrap();
|
||||
let created = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
fs::write(
|
||||
fresh.join(".dever-owner"),
|
||||
format!(
|
||||
"dever-build-owner-v1\ncreated={created}\npid={fresh_pid}\nowner={owner}\ncompiler={}\n",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
),
|
||||
).unwrap();
|
||||
let unknown = std::env::temp_dir().join(format!("dever-build-{}-{suffix}", pid + 2));
|
||||
fs::create_dir(&unknown).unwrap();
|
||||
let link = std::env::temp_dir().join(format!("dever-build-{}-{suffix}", pid + 3));
|
||||
symlink(&unknown, &link).unwrap();
|
||||
|
||||
let mut active_process = Command::new("/bin/sleep").arg("60").spawn().unwrap();
|
||||
let active_pid = u64::from(active_process.id());
|
||||
let active = std::env::temp_dir().join(format!("dever-build-{active_pid}-{suffix}"));
|
||||
fs::create_dir(&active).unwrap();
|
||||
fs::write(
|
||||
active.join(".dever-owner"),
|
||||
format!(
|
||||
"dever-build-owner-v1\ncreated=1\npid={active_pid}\nowner={owner}\ncompiler={}\n",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(active.join("program"), b"active").unwrap();
|
||||
|
||||
let legacy = project.path().join(format!(".dever-run-{pid}"));
|
||||
fs::write(&legacy, b"stale legacy").unwrap();
|
||||
let legacy_file = fs::OpenOptions::new().write(true).open(&legacy).unwrap();
|
||||
legacy_file
|
||||
.set_times(
|
||||
fs::FileTimes::new().set_modified(UNIX_EPOCH + std::time::Duration::from_secs(1)),
|
||||
)
|
||||
.unwrap();
|
||||
let fresh_legacy = project.path().join(format!(".dever-run-{}", pid + 1));
|
||||
fs::write(&fresh_legacy, b"fresh legacy").unwrap();
|
||||
let active_legacy = project.path().join(format!(".dever-run-{active_pid}"));
|
||||
fs::write(&active_legacy, b"active legacy").unwrap();
|
||||
let active_legacy_file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&active_legacy)
|
||||
.unwrap();
|
||||
active_legacy_file
|
||||
.set_times(
|
||||
fs::FileTimes::new().set_modified(UNIX_EPOCH + std::time::Duration::from_secs(1)),
|
||||
)
|
||||
.unwrap();
|
||||
let explicit = project.path().join("application");
|
||||
fs::write(&explicit, b"explicit output").unwrap();
|
||||
|
||||
let output = dever(project.path(), "clean", &[]);
|
||||
active_process.kill().unwrap();
|
||||
active_process.wait().unwrap();
|
||||
assert_success(&output);
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stdout).starts_with("cleaned 1 directories, 3 files, ")
|
||||
);
|
||||
assert!(!stale.exists());
|
||||
assert!(fresh.exists());
|
||||
assert!(unknown.exists());
|
||||
assert!(link.symlink_metadata().is_ok());
|
||||
assert!(active.exists());
|
||||
assert!(!legacy.exists());
|
||||
assert!(fresh_legacy.exists());
|
||||
assert!(active_legacy.exists());
|
||||
assert!(explicit.exists());
|
||||
|
||||
fs::remove_dir_all(fresh).unwrap();
|
||||
fs::remove_file(link).unwrap();
|
||||
fs::remove_dir_all(unknown).unwrap();
|
||||
fs::remove_dir_all(active).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cms_project_checks_runs_and_builds_from_an_isolated_copy() {
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
for format in ["dever", "md"] {
|
||||
let project = TemporaryProject::copy_from(&workspace.join("examples/cms").join(format));
|
||||
let settings_path = project.path().join("config/setting.json");
|
||||
let production_settings = fs::read(&settings_path).unwrap();
|
||||
project.block_deployment_database();
|
||||
for _ in 0..2 {
|
||||
let (pid, output) = dever_with_pid(project.path(), "test", &[]);
|
||||
assert_cms_tests(&output);
|
||||
assert_test_artifacts_removed(pid);
|
||||
}
|
||||
fs::write(&settings_path, production_settings).unwrap();
|
||||
fs::write(project.path().join("test/ignored.dever"), "not valid Dever").unwrap();
|
||||
|
||||
assert_success(&dever(project.path(), "check", &[]));
|
||||
for _ in 0..2 {
|
||||
assert_health_stdout(&dever(
|
||||
project.path(),
|
||||
"run",
|
||||
&["--", "system.health.ping", "{}"],
|
||||
));
|
||||
}
|
||||
|
||||
let executable = build(project.path(), "cms-app");
|
||||
let package = TemporaryProject::package(project.path(), &executable);
|
||||
assert!(!package.path().join("module").exists());
|
||||
for _ in 0..2 {
|
||||
let output = output_with_pid(
|
||||
Command::new(package.path().join("cms-app"))
|
||||
.args(["system.health.ping", "{}"])
|
||||
.current_dir(package.path()),
|
||||
)
|
||||
.1;
|
||||
assert_health_stdout(&output);
|
||||
}
|
||||
assert!(fs::read_dir(project.path()).unwrap().all(|entry| {
|
||||
!entry
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with(".dever-run-")
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_without_tests_reports_zero_and_succeeds() {
|
||||
let project = TemporaryProject::new();
|
||||
fs::create_dir(project.path().join("module")).unwrap();
|
||||
fs::write(project.path().join("module/main.dever"), "main() () {}").unwrap();
|
||||
|
||||
let (pid, output) = dever_with_pid(project.path(), "test", &[]);
|
||||
assert_success(&output);
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
"running 0 tests\n\ntest result: ok. 0 passed; 0 failed; 0 not run\n"
|
||||
);
|
||||
assert_test_artifacts_removed(pid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_suite_initializes_database_only_for_database_cases() {
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let project = TemporaryProject::copy_from(&workspace.join("examples/cms/dever"));
|
||||
project.block_deployment_database();
|
||||
let health = project.path().join("test/system/health");
|
||||
fs::create_dir_all(&health).unwrap();
|
||||
fs::write(
|
||||
health.join("ping.dever"),
|
||||
"ping() () { assert_eq(\"ready\", \"ready\") }\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (pid, output) = dever_with_pid(project.path(), "test", &[]);
|
||||
assert_success(&output);
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
"running 6 tests\ntest content/category/lifecycle ... ok\ntest news/article/publish ... ok\ntest system/health/ping ... ok\ntest user/account/bootstrap ... ok\ntest user/account/validation ... ok\ntest user/session/lifecycle ... ok\n\ntest result: ok. 6 passed; 0 failed; 0 not run\n"
|
||||
);
|
||||
assert_test_artifacts_removed(pid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_test_reports_captured_output_and_does_not_stop_later_tests() {
|
||||
let project = TemporaryProject::new();
|
||||
fs::create_dir(project.path().join("module")).unwrap();
|
||||
fs::create_dir_all(project.path().join("test/sample/case")).unwrap();
|
||||
fs::write(project.path().join("module/main.dever"), "main() () {}").unwrap();
|
||||
fs::write(
|
||||
project.path().join("test/sample/case/fails.dever"),
|
||||
r#"fails() () {
|
||||
blocking(dever.io.println("failed output"))
|
||||
assert_eq(1, 2)
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
project.path().join("test/sample/case/passes.dever"),
|
||||
r#"passes() () {
|
||||
blocking(dever.io.println("hidden successful output"))
|
||||
assert(true)
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (pid, output) = dever_with_pid(project.path(), "test", &[]);
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stderr.is_empty());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let failed = stdout.find("test sample/case/fails ... FAILED").unwrap();
|
||||
let passed = stdout.find("test sample/case/passes ... ok").unwrap();
|
||||
assert!(failed < passed, "{stdout}");
|
||||
assert!(
|
||||
stdout.contains("---- stdout ----\nfailed output\n"),
|
||||
"{stdout}"
|
||||
);
|
||||
assert!(stdout.contains("---- stderr ----"), "{stdout}");
|
||||
assert!(
|
||||
stdout.contains("assert_eq failed: actual = 1, expected = 2"),
|
||||
"{stdout}"
|
||||
);
|
||||
assert!(!stdout.contains("hidden successful output"), "{stdout}");
|
||||
assert!(
|
||||
stdout.ends_with("test result: FAILED. 1 passed; 1 failed; 0 not run\n"),
|
||||
"{stdout}"
|
||||
);
|
||||
assert_test_artifacts_removed(pid);
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
#[test]
|
||||
fn suite_compilation_failure_marks_every_discovered_case_failed() {
|
||||
let project = TemporaryProject::new();
|
||||
fs::create_dir(project.path().join("module")).unwrap();
|
||||
fs::create_dir_all(project.path().join("test/sample/case")).unwrap();
|
||||
fs::write(project.path().join("module/main.dever"), "main() () {}").unwrap();
|
||||
fs::write(
|
||||
project.path().join("test/sample/case/first.dever"),
|
||||
"first() () { assert(true) }\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
project.path().join("test/sample/case/second.dever"),
|
||||
"second() () { assert(true) }\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let toolchain = TemporaryProject::new();
|
||||
let compiler = llvm_pack::compiler(
|
||||
Path::new(env!("CARGO_BIN_EXE_dever")),
|
||||
toolchain.path(),
|
||||
"dever",
|
||||
);
|
||||
let (pid, output) = output_with_pid(Command::new(compiler).arg("test").arg(project.path()));
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stderr.is_empty());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
stdout.contains("test sample/case/first ... FAILED"),
|
||||
"{stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("test sample/case/second ... FAILED"),
|
||||
"{stdout}"
|
||||
);
|
||||
assert_eq!(
|
||||
stdout.matches("cannot inspect runtime pack").count(),
|
||||
2,
|
||||
"{stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.ends_with("test result: FAILED. 0 passed; 2 failed; 0 not run\n"),
|
||||
"{stdout}"
|
||||
);
|
||||
assert_test_artifacts_removed(pid);
|
||||
assert!(!toolchain.path().join("cache").exists());
|
||||
}
|
||||
|
||||
fn build(project: &Path, name: &str) -> PathBuf {
|
||||
let executable = project.join(name);
|
||||
let build = dever(
|
||||
project,
|
||||
"build",
|
||||
&["--output", executable.to_str().unwrap()],
|
||||
);
|
||||
assert_success(&build);
|
||||
assert!(executable.is_file());
|
||||
executable
|
||||
}
|
||||
|
||||
fn assert_health_stdout(output: &Output) {
|
||||
assert_success(output);
|
||||
let body: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(body["code"], 0);
|
||||
assert_eq!(body["message"], "ok");
|
||||
assert_eq!(body["data"]["status"], "ready");
|
||||
}
|
||||
|
||||
fn assert_cms_tests(output: &Output) {
|
||||
assert_success(output);
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
"running 5 tests\ntest content/category/lifecycle ... ok\ntest news/article/publish ... ok\ntest user/account/bootstrap ... ok\ntest user/account/validation ... ok\ntest user/session/lifecycle ... ok\n\ntest result: ok. 5 passed; 0 failed; 0 not run\n"
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,468 @@
|
||||
use super::*;
|
||||
use dever_cli::libs::build::{Inputs, Session, pack};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[path = "../../dever-tests/tests/support/sandbox.rs"]
|
||||
pub(super) mod sandbox;
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Settings {
|
||||
runtime: RegistryRuntime,
|
||||
runtime_file: String,
|
||||
build: pack::Descriptor,
|
||||
build_file: String,
|
||||
}
|
||||
|
||||
struct AuthorInputs {
|
||||
root: PathBuf,
|
||||
settings: Settings,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_build_pack_binds_runtime_layout_tools_and_archive_digest() {
|
||||
let runtime = registry_runtime(Ecosystem::Pip);
|
||||
let mut descriptor = pack::Descriptor {
|
||||
format: "dever-registry-build-v1".into(),
|
||||
ecosystem: Ecosystem::Pip,
|
||||
target: runtime.target.clone(),
|
||||
runtime_sha256: runtime.pack.sha256.clone(),
|
||||
python_runtime_sha256: None,
|
||||
sha256: "0".repeat(64),
|
||||
};
|
||||
descriptor.validate(&runtime, None).unwrap();
|
||||
let manifest = pack::Manifest {
|
||||
format: "dever-lib-build-v1".into(),
|
||||
ecosystem: Ecosystem::Pip,
|
||||
target: runtime.target.clone(),
|
||||
runtime_sha256: runtime.pack.sha256.clone(),
|
||||
python_runtime_sha256: None,
|
||||
executables: ["sh", "cc", "c++", "as", "ld", "ar", "ranlib", "make"]
|
||||
.into_iter()
|
||||
.map(|name| format!("rootfs/usr/bin/{name}"))
|
||||
.collect(),
|
||||
};
|
||||
let mut files = manifest
|
||||
.executables
|
||||
.iter()
|
||||
.map(|path| (path.clone(), b"#!/bin/sh\nexit 0\n".to_vec()))
|
||||
.collect::<Vec<_>>();
|
||||
files.push((
|
||||
"dever-build.json".into(),
|
||||
serde_json::to_vec(&manifest).unwrap(),
|
||||
));
|
||||
manifest.validate(&files, &descriptor).unwrap();
|
||||
let mut changed = descriptor.clone();
|
||||
changed.runtime_sha256 = "f".repeat(64);
|
||||
assert!(changed.validate(&runtime, None).is_err());
|
||||
assert!(manifest.validate(&files, &changed).is_err());
|
||||
let mut changed = files.clone();
|
||||
changed.push(("rootfs/etc/ld.so.preload".into(), b"injected".to_vec()));
|
||||
assert!(manifest.validate(&changed, &descriptor).is_err());
|
||||
let mut changed = manifest.clone();
|
||||
changed.executables.push("rootfs/usr/bin/absent".into());
|
||||
assert!(changed.validate(&files, &descriptor).is_err());
|
||||
let mut archive = tar::Builder::new(flate2::write::GzEncoder::new(
|
||||
Vec::new(),
|
||||
flate2::Compression::default(),
|
||||
));
|
||||
for (path, bytes) in files {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
archive
|
||||
.append_data(&mut header, path, bytes.as_slice())
|
||||
.unwrap();
|
||||
}
|
||||
let bytes = archive.into_inner().unwrap().finish().unwrap();
|
||||
descriptor.sha256 = digest(&bytes);
|
||||
pack::unpack(&bytes, &descriptor).unwrap();
|
||||
let mut changed = bytes;
|
||||
changed[0] ^= 1;
|
||||
assert!(pack::unpack(&changed, &descriptor).is_err());
|
||||
}
|
||||
|
||||
impl Inputs for AuthorInputs {
|
||||
fn diagnostic(&self, output: &[u8]) {
|
||||
eprintln!("{}", String::from_utf8_lossy(output));
|
||||
}
|
||||
|
||||
fn runtime(&self, ecosystem: Ecosystem) -> Result<(RegistryRuntime, Vec<u8>), String> {
|
||||
if ecosystem != Ecosystem::Pip {
|
||||
return Err("fixture only supplies Python".into());
|
||||
}
|
||||
let bytes = fs::read(self.root.join(&self.settings.runtime_file))
|
||||
.map_err(|error| error.to_string())?;
|
||||
if digest(&bytes) != self.settings.runtime.pack.sha256 {
|
||||
return Err("author runtime drift".into());
|
||||
}
|
||||
Ok((self.settings.runtime.clone(), bytes))
|
||||
}
|
||||
fn tools(&self, ecosystem: Ecosystem) -> Result<(pack::Descriptor, Vec<u8>), String> {
|
||||
if ecosystem != Ecosystem::Pip {
|
||||
return Err("fixture only supplies Python".into());
|
||||
}
|
||||
Ok((
|
||||
self.settings.build.clone(),
|
||||
fs::read(self.root.join(&self.settings.build_file))
|
||||
.map_err(|error| error.to_string())?,
|
||||
))
|
||||
}
|
||||
fn assets(&self) -> Result<Vec<(String, Vec<u8>)>, String> {
|
||||
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
Ok(sandbox::assets(&workspace)
|
||||
.into_iter()
|
||||
.map(|asset| {
|
||||
let _mode = asset.executable;
|
||||
(
|
||||
asset.path.trim_start_matches("sandbox/").into(),
|
||||
asset.bytes,
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit author operation: assembles previously hashed native-build-inputs; no network or host execution"]
|
||||
fn prepare_source_build_tool_fixture() {
|
||||
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let runtime: RegistryRuntime = serde_json::from_value(
|
||||
serde_json::from_slice::<serde_json::Value>(
|
||||
&fs::read(workspace.join("target/python-managed/config/setting.json")).unwrap(),
|
||||
)
|
||||
.unwrap()["runtime"]
|
||||
.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let bytes = fs::read(workspace.join("target/python-managed/runtime.pack")).unwrap();
|
||||
assert_eq!(digest(&bytes), runtime.pack.sha256);
|
||||
drop(bytes);
|
||||
let root = workspace.join("target/source-build");
|
||||
let descriptor = prepare_tool_pack(Ecosystem::Pip, &runtime, None, &root);
|
||||
fs::write(
|
||||
root.join("config/setting.json"),
|
||||
serde_json::to_vec_pretty(&Settings {
|
||||
runtime,
|
||||
runtime_file: "../python-managed/runtime.pack".into(),
|
||||
build: descriptor,
|
||||
build_file: "build.pack".into(),
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub(super) fn prepare_tool_pack(
|
||||
ecosystem: Ecosystem,
|
||||
runtime: &RegistryRuntime,
|
||||
python: Option<&RegistryRuntime>,
|
||||
root: &std::path::Path,
|
||||
) -> pack::Descriptor {
|
||||
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let inputs = workspace.join("target/native-build-inputs");
|
||||
let document: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(inputs.join("inputs.json")).unwrap()).unwrap();
|
||||
let mut descriptor = pack::Descriptor {
|
||||
format: "dever-registry-build-v1".into(),
|
||||
ecosystem: ecosystem.clone(),
|
||||
target: runtime.target.clone(),
|
||||
runtime_sha256: runtime.pack.sha256.clone(),
|
||||
python_runtime_sha256: python.map(|runtime| runtime.pack.sha256.clone()),
|
||||
sha256: "0".repeat(64),
|
||||
};
|
||||
descriptor.validate(runtime, python).unwrap();
|
||||
let manifest = pack::Manifest {
|
||||
format: "dever-lib-build-v1".into(),
|
||||
ecosystem: ecosystem.clone(),
|
||||
target: runtime.target.clone(),
|
||||
runtime_sha256: runtime.pack.sha256.clone(),
|
||||
python_runtime_sha256: descriptor.python_runtime_sha256.clone(),
|
||||
executables: serde_json::from_value(document[ecosystem.as_str()]["executables"].clone())
|
||||
.unwrap(),
|
||||
};
|
||||
let mut files: Vec<(String, Vec<u8>)> = vec![(
|
||||
"dever-build.json".into(),
|
||||
serde_json::to_vec(&manifest).unwrap(),
|
||||
)];
|
||||
for input in document[ecosystem.as_str()]["files"].as_array().unwrap() {
|
||||
let source = inputs.join(input["source"].as_str().unwrap());
|
||||
assert!(fs::symlink_metadata(&source).unwrap().is_file());
|
||||
let bytes = fs::read(&source).unwrap();
|
||||
assert_eq!(digest(&bytes), input["sha256"].as_str().unwrap());
|
||||
files.push((input["path"].as_str().unwrap().into(), bytes));
|
||||
}
|
||||
files.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
manifest.validate(&files, &descriptor).unwrap();
|
||||
let assets = sandbox::assets(&workspace)
|
||||
.into_iter()
|
||||
.map(|asset| {
|
||||
(
|
||||
asset.path.trim_start_matches("sandbox/").into(),
|
||||
asset.bytes,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
pack::validate_tools(&manifest, &files, &assets).unwrap();
|
||||
fs::create_dir_all(root.join("config")).unwrap();
|
||||
let encoder = flate2::GzBuilder::new()
|
||||
.mtime(0)
|
||||
.operating_system(255)
|
||||
.write(
|
||||
fs::File::create(root.join("build.pack")).unwrap(),
|
||||
flate2::Compression::default(),
|
||||
);
|
||||
let mut archive = tar::Builder::new(encoder);
|
||||
for (path, bytes) in files {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_mode(if manifest.executables.contains(&path) {
|
||||
0o755
|
||||
} else {
|
||||
0o644
|
||||
});
|
||||
header.set_uid(0);
|
||||
header.set_gid(0);
|
||||
header.set_mtime(0);
|
||||
header.set_cksum();
|
||||
archive
|
||||
.append_data(&mut header, path, bytes.as_slice())
|
||||
.unwrap();
|
||||
}
|
||||
archive.into_inner().unwrap().finish().unwrap();
|
||||
descriptor.sha256 = digest(&fs::read(root.join("build.pack")).unwrap());
|
||||
descriptor
|
||||
}
|
||||
|
||||
struct SourceOnly(dever_cli::libs::HttpRegistry);
|
||||
|
||||
#[test]
|
||||
fn incompatible_sdist_candidate_backtracks_before_loading_build_tools() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
python_release(&mut registry, "app", "1.0.0", &[], &["backend>=1"]);
|
||||
python_release(&mut registry, "backend", "1.0.0", &[], &[]);
|
||||
python_release(&mut registry, "backend", "2.0.0", &[], &[]);
|
||||
let path = (Ecosystem::Pip, "/pypi/backend/2.0.0/json".into());
|
||||
let mut release: serde_json::Value = serde_json::from_slice(®istry.0[&path]).unwrap();
|
||||
release["urls"] = serde_json::json!([{"packagetype":"sdist","requires_python":">=3.13","filename":"backend-2.0.0.tar.gz","url":"must-not-fetch","digests":{"sha256":"0".repeat(64)}}]);
|
||||
registry
|
||||
.0
|
||||
.insert(path, serde_json::to_vec(&release).unwrap());
|
||||
let lock = python_lock(®istry, &["pip:app@1.0.0"]).unwrap();
|
||||
assert!(
|
||||
lock.libs
|
||||
.iter()
|
||||
.any(|lib| lib.spec.key() == "pip:backend@1.0.0")
|
||||
);
|
||||
}
|
||||
|
||||
fn backend_fixture(mutate: bool) -> LocalRegistry {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
python_release(&mut registry, "buildhelper", "1.0.0", &[], &[]);
|
||||
registry.0.insert(
|
||||
(Ecosystem::Pip, "/pypi/sample/json".into()),
|
||||
serde_json::to_vec(&serde_json::json!({"releases":{"1.0.0":[]}})).unwrap(),
|
||||
);
|
||||
let helper = wheel_fixture::pack(
|
||||
"buildhelper",
|
||||
"1.0.0",
|
||||
"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
|
||||
&wheel_fixture::metadata("buildhelper", "1.0.0", &[], &[]),
|
||||
&[
|
||||
(
|
||||
"buildhelper.py",
|
||||
b"def cli():\n print('isolated-cli')\n".to_vec(),
|
||||
),
|
||||
(
|
||||
"buildhelper-1.0.0.dist-info/entry_points.txt",
|
||||
b"[console_scripts]\nbuild-probe = buildhelper:cli\n".to_vec(),
|
||||
),
|
||||
],
|
||||
);
|
||||
let key = (Ecosystem::Pip, "/pypi/buildhelper/1.0.0/json".into());
|
||||
let mut release: serde_json::Value = serde_json::from_slice(®istry.0[&key]).unwrap();
|
||||
let url = release["urls"][0]["url"].as_str().unwrap().to_owned();
|
||||
release["urls"][0]["digests"]["sha256"] = serde_json::json!(digest(&helper));
|
||||
registry
|
||||
.0
|
||||
.insert(key, serde_json::to_vec(&release).unwrap());
|
||||
registry.0.insert((Ecosystem::Pip, url), helper);
|
||||
let original = wheel_fixture::metadata("sample", "1.0.0", &[], &[]);
|
||||
let changed = format!("{original}Summary: changed by wheel hook\n");
|
||||
let wheel = wheel_fixture::pack(
|
||||
"sample",
|
||||
"1.0.0",
|
||||
"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
|
||||
if mutate { &changed } else { &original },
|
||||
&[],
|
||||
);
|
||||
let backend = format!(
|
||||
r#"import os, shutil, subprocess
|
||||
from pathlib import Path
|
||||
def get_requires_for_build_wheel(config_settings=None):
|
||||
try:
|
||||
import buildhelper
|
||||
return []
|
||||
except ImportError:
|
||||
return ['buildhelper==1.0.0']
|
||||
def prepare_metadata_for_build_wheel(directory, config_settings=None):
|
||||
assert subprocess.check_output(['build-probe'], text=True).strip() == 'isolated-cli'
|
||||
metadata = Path(directory) / 'sample-1.0.0.dist-info'
|
||||
metadata.mkdir()
|
||||
shutil.copy('original-metadata', metadata / 'METADATA')
|
||||
os.chdir('/')
|
||||
return metadata.name
|
||||
def build_wheel(directory, config_settings=None, metadata_directory=None):
|
||||
assert Path.cwd().name == 'source', 'previous hook leaked its cwd'
|
||||
if {mutate}:
|
||||
shutil.copy('changed-metadata', Path(metadata_directory) / 'METADATA')
|
||||
name = 'sample-1.0.0-py3-none-any.whl'
|
||||
shutil.copy('result.whl', Path(directory) / name)
|
||||
return name
|
||||
"#,
|
||||
mutate = if mutate { "True" } else { "False" }
|
||||
);
|
||||
let sourcefiles = vec![
|
||||
(
|
||||
"sample-1.0.0/pyproject.toml",
|
||||
b"[build-system]\nrequires = []\nbuild-backend = 'backend'\nbackend-path = ['.']\n"
|
||||
.to_vec(),
|
||||
),
|
||||
("sample-1.0.0/backend.py", backend.into_bytes()),
|
||||
("sample-1.0.0/original-metadata", original.into_bytes()),
|
||||
("sample-1.0.0/changed-metadata", changed.into_bytes()),
|
||||
("sample-1.0.0/result.whl", wheel),
|
||||
];
|
||||
let mut tar = tar::Builder::new(flate2::write::GzEncoder::new(
|
||||
Vec::new(),
|
||||
flate2::Compression::default(),
|
||||
));
|
||||
for (name, bytes) in sourcefiles {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
tar.append_data(&mut header, name, bytes.as_slice())
|
||||
.unwrap();
|
||||
}
|
||||
let source = tar.into_inner().unwrap().finish().unwrap();
|
||||
registry.0.insert((Ecosystem::Pip,"/pypi/sample/1.0.0/json".into()),serde_json::to_vec(&serde_json::json!({"info":{},"urls":[{"packagetype":"sdist","filename":"sample-1.0.0.tar.gz","url":"sample-source","digests":{"sha256":digest(&source)}}]})).unwrap());
|
||||
registry
|
||||
.0
|
||||
.insert((Ecosystem::Pip, "sample-source".into()), source);
|
||||
registry
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "isolated offline PEP517 contracts; requires explicit target/source-build tool/runtime fixture"]
|
||||
fn pep517_hooks_keep_requirements_cli_and_metadata_boundaries() {
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/source-build");
|
||||
let settings: Settings =
|
||||
serde_json::from_slice(&fs::read(root.join("config/setting.json")).unwrap()).unwrap();
|
||||
let inputs = AuthorInputs { root, settings };
|
||||
for mutate in [false, true] {
|
||||
let session = Session::new(&inputs);
|
||||
let transport = backend_fixture(mutate);
|
||||
let store = FixtureArtifactStore::default();
|
||||
let resolver = RegistryResolver {
|
||||
transport: &transport,
|
||||
store: &store,
|
||||
runtimes: BTreeMap::from([(Ecosystem::Pip, inputs.settings.runtime.clone())]),
|
||||
go_sumdb: dever_cli::libs::sumdb::ChecksumDatabase::official(None).unwrap(),
|
||||
build: Some(&session),
|
||||
};
|
||||
let result = dever_cli::libs::LibResolver::resolve(
|
||||
&resolver,
|
||||
&["pip:sample@1.0.0".parse().unwrap()],
|
||||
);
|
||||
if mutate {
|
||||
assert!(result.unwrap_err().contains("metadata differs"));
|
||||
continue;
|
||||
}
|
||||
let lock = result.unwrap();
|
||||
dever_cli::libs::doctor(&lock).unwrap();
|
||||
assert_eq!(lock.builds[0].dynamic_requires, vec!["buildhelper==1.0.0"]);
|
||||
for mutation in 0..3 {
|
||||
let mut altered = lock.clone();
|
||||
match mutation {
|
||||
0 => altered.builds[0].tools.sha256 = "not-a-hash".into(),
|
||||
1 => altered.builds[0].dynamic_requires = vec!["buildhelper>=2".into()],
|
||||
_ => *altered.builds[0].dependencies = LockFile::new(vec![]).unwrap(),
|
||||
}
|
||||
altered.libs[0].build = Some(altered.builds[0].identity().unwrap());
|
||||
assert!(
|
||||
dever_cli::libs::doctor(&altered).is_err(),
|
||||
"accepted contradictory receipt {mutation}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl RegistryTransport for SourceOnly {
|
||||
fn get(&self, ecosystem: Ecosystem, path: &str, limit: usize) -> Result<Vec<u8>, String> {
|
||||
let bytes = self.0.get(ecosystem, path, limit)?;
|
||||
// Explicit author acceptance forces this one package's official sdist.
|
||||
// Backend requirements still use their ordinary authenticated wheels.
|
||||
if path == "/pypi/simplejson/3.20.1/json" {
|
||||
let mut document: serde_json::Value =
|
||||
serde_json::from_slice(&bytes).map_err(|error| error.to_string())?;
|
||||
document["urls"]
|
||||
.as_array_mut()
|
||||
.ok_or("official release lacks files")?
|
||||
.retain(|file| file["packagetype"] == "sdist");
|
||||
serde_json::to_vec(&document).map_err(|error| error.to_string())
|
||||
} else {
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit official PyPI source build: downloads fixed source/build requirements and executes signed managed build tools"]
|
||||
fn builds_official_python_source_with_isolated_pep517() {
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/source-build");
|
||||
let settings: Settings =
|
||||
serde_json::from_slice(&fs::read(root.join("config/setting.json")).unwrap()).unwrap();
|
||||
let inputs = AuthorInputs {
|
||||
root: root.clone(),
|
||||
settings,
|
||||
};
|
||||
let session = Session::new(&inputs);
|
||||
let transport = SourceOnly(dever_cli::libs::HttpRegistry::official());
|
||||
let store = FixtureArtifactStore::default();
|
||||
let resolver = RegistryResolver {
|
||||
transport: &transport,
|
||||
store: &store,
|
||||
runtimes: BTreeMap::from([(Ecosystem::Pip, inputs.settings.runtime.clone())]),
|
||||
go_sumdb: dever_cli::libs::sumdb::ChecksumDatabase::official(None).unwrap(),
|
||||
build: Some(&session),
|
||||
};
|
||||
let requested = vec!["pip:simplejson@3.20.1".parse().unwrap()];
|
||||
let lock = dever_cli::libs::LibResolver::resolve(&resolver, &requested).unwrap();
|
||||
dever_cli::libs::doctor(&lock).unwrap();
|
||||
assert_eq!(lock.builds.len(), 1);
|
||||
assert!(!lock.builds[0].dependencies.libs.is_empty());
|
||||
let artifact = &lock
|
||||
.libs
|
||||
.iter()
|
||||
.find(|lib| lib.spec.name == "simplejson")
|
||||
.unwrap()
|
||||
.artifacts[0];
|
||||
let wheel = store
|
||||
.verify_exact(&artifact.sha256, artifact.bytes, &artifact.target)
|
||||
.unwrap();
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(wheel)).unwrap();
|
||||
assert!((0..archive.len()).any(|index| {
|
||||
let file = archive.by_index(index).unwrap();
|
||||
file.name().contains("_speedups.") && file.name().ends_with(".so")
|
||||
}));
|
||||
assert_eq!(
|
||||
lock,
|
||||
dever_cli::libs::LibResolver::resolve(&resolver, &requested).unwrap(),
|
||||
"same session must replay one exact built artifact"
|
||||
);
|
||||
publish_fixture_artifacts(&root.join("dependency"), &lock, &store);
|
||||
let mut altered = lock.clone();
|
||||
altered.builds[0].frontend = "0".repeat(64);
|
||||
assert!(dever_cli::libs::doctor(&altered).is_err());
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
use super::*;
|
||||
use dever_cli::libs::{LibResolver, LockedWorker, npm, resolve_workers};
|
||||
|
||||
pub(super) fn archive(
|
||||
name: &str,
|
||||
version: &str,
|
||||
mut manifest: serde_json::Value,
|
||||
extra: &[(&str, Vec<u8>)],
|
||||
) -> Vec<u8> {
|
||||
manifest["name"] = name.into();
|
||||
manifest["version"] = version.into();
|
||||
let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
let mut writer = tar::Builder::new(encoder);
|
||||
for (name, bytes) in [
|
||||
(
|
||||
"package/package.json",
|
||||
serde_json::to_vec(&manifest).unwrap(),
|
||||
),
|
||||
(
|
||||
"package/index.js",
|
||||
format!("module.exports = '{name}/{version}';\n").into_bytes(),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(extra.iter().map(|(name, bytes)| (*name, bytes.clone())))
|
||||
{
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
writer
|
||||
.append_data(&mut header, name, bytes.as_slice())
|
||||
.unwrap();
|
||||
}
|
||||
writer.into_inner().unwrap().finish().unwrap()
|
||||
}
|
||||
|
||||
pub(super) fn release(
|
||||
registry: &mut LocalRegistry,
|
||||
name: &str,
|
||||
version: &str,
|
||||
manifest: serde_json::Value,
|
||||
extra: &[(&str, Vec<u8>)],
|
||||
) {
|
||||
let archive = archive(name, version, manifest.clone(), extra);
|
||||
let index_path = (Ecosystem::Npm, format!("/{name}"));
|
||||
let mut index = registry
|
||||
.0
|
||||
.get(&index_path)
|
||||
.map(|bytes| serde_json::from_slice::<serde_json::Value>(bytes).unwrap())
|
||||
.unwrap_or(serde_json::json!({"versions":{}}));
|
||||
index["versions"][version] = serde_json::json!({});
|
||||
registry
|
||||
.0
|
||||
.insert(index_path, serde_json::to_vec(&index).unwrap());
|
||||
let mut document = manifest;
|
||||
document["name"] = name.into();
|
||||
document["version"] = version.into();
|
||||
let url = format!("https://mock/{name}/{version}.tgz");
|
||||
document["dist"] = serde_json::json!({"tarball":url,"integrity":format!("sha512-{}", base64::engine::general_purpose::STANDARD.encode(Sha512::digest(&archive)))});
|
||||
registry.0.insert(
|
||||
(Ecosystem::Npm, format!("/{name}/{version}")),
|
||||
serde_json::to_vec(&document).unwrap(),
|
||||
);
|
||||
registry.0.insert((Ecosystem::Npm, url), archive);
|
||||
}
|
||||
|
||||
fn resolved(registry: &LocalRegistry, requested: &[&str]) -> (LockFile, FixtureArtifactStore) {
|
||||
let store = FixtureArtifactStore::default();
|
||||
let lock = local_resolver(registry, &store)
|
||||
.resolve(
|
||||
&requested
|
||||
.iter()
|
||||
.map(|spec| spec.parse().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.unwrap();
|
||||
doctor(&lock).unwrap();
|
||||
assert_eq!(LockFile::decode(&lock.encode().unwrap()).unwrap(), lock);
|
||||
(lock, store)
|
||||
}
|
||||
|
||||
fn files(lock: &LockFile, store: &FixtureArtifactStore, index: usize) -> BTreeMap<String, Vec<u8>> {
|
||||
let archives = lock
|
||||
.libs
|
||||
.iter()
|
||||
.map(|lib| {
|
||||
(
|
||||
lib.spec.clone(),
|
||||
store
|
||||
.verify_exact(
|
||||
&lib.artifacts[0].sha256,
|
||||
lib.artifacts[0].bytes,
|
||||
&lib.artifacts[0].target,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
npm::files(&lock.npm[index], lock, |lib| {
|
||||
Ok(archives[&lib.spec].as_slice())
|
||||
})
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_versions_and_shared_packages_keep_distinct_peer_contexts() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
for (name, dependencies) in [
|
||||
("left", serde_json::json!({"plugin":"1.0.0","host":"1.0.0"})),
|
||||
(
|
||||
"right",
|
||||
serde_json::json!({"plugin":"1.0.0","host":"2.0.0"}),
|
||||
),
|
||||
] {
|
||||
release(
|
||||
&mut registry,
|
||||
name,
|
||||
"1.0.0",
|
||||
serde_json::json!({"dependencies":dependencies}),
|
||||
&[],
|
||||
);
|
||||
}
|
||||
release(
|
||||
&mut registry,
|
||||
"plugin",
|
||||
"1.0.0",
|
||||
serde_json::json!({"peerDependencies":{"host":">=1.0.0 <3.0.0"}}),
|
||||
&[],
|
||||
);
|
||||
for version in ["1.0.0", "2.0.0"] {
|
||||
release(&mut registry, "host", version, serde_json::json!({}), &[]);
|
||||
}
|
||||
let (lock, store) = resolved(®istry, &["npm:left@1.0.0", "npm:right@1.0.0"]);
|
||||
assert_eq!(lock.libs.len(), 5);
|
||||
let files = files(&lock, &store, 0);
|
||||
for (side, version) in [("left", "1.0.0"), ("right", "2.0.0")] {
|
||||
let node = lock.npm[0]
|
||||
.instances
|
||||
.iter()
|
||||
.find(|node| node.path == format!("node_modules/{side}/node_modules/plugin"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
node.edges[0].target.as_deref(),
|
||||
Some(format!("node_modules/{side}/node_modules/host").as_str())
|
||||
);
|
||||
assert!(
|
||||
String::from_utf8(
|
||||
files[&format!("node_modules/{side}/node_modules/host/index.js")].clone()
|
||||
)
|
||||
.unwrap()
|
||||
.contains(version)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_dependencies_override_regular_edges_and_record_target_or_absence() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
release(
|
||||
&mut registry,
|
||||
"root",
|
||||
"1.0.0",
|
||||
serde_json::json!({"dependencies":{"child":"1.0.0"},"optionalDependencies":{"child":"2.0.0","missing":"*","darwin":"*","musl":"*"}}),
|
||||
&[],
|
||||
);
|
||||
release(&mut registry, "child", "2.0.0", serde_json::json!({}), &[]);
|
||||
release(
|
||||
&mut registry,
|
||||
"darwin",
|
||||
"1.0.0",
|
||||
serde_json::json!({"os":["darwin"]}),
|
||||
&[],
|
||||
);
|
||||
release(
|
||||
&mut registry,
|
||||
"musl",
|
||||
"1.0.0",
|
||||
serde_json::json!({"os":["linux"],"libc":["musl"]}),
|
||||
&[],
|
||||
);
|
||||
let (lock, store) = resolved(®istry, &["npm:root@1.0.0"]);
|
||||
assert_eq!(lock.libs.len(), 2);
|
||||
let root = lock.npm[0]
|
||||
.instances
|
||||
.iter()
|
||||
.find(|node| node.spec.name == "root")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
root.edges
|
||||
.iter()
|
||||
.find(|edge| edge.name == "child")
|
||||
.unwrap()
|
||||
.kind,
|
||||
npm::Kind::Optional
|
||||
);
|
||||
for name in ["darwin", "musl"] {
|
||||
assert_eq!(
|
||||
root.edges
|
||||
.iter()
|
||||
.find(|edge| edge.name == name)
|
||||
.unwrap()
|
||||
.omission,
|
||||
Some(npm::Omission::Target)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
root.edges
|
||||
.iter()
|
||||
.find(|edge| edge.name == "missing")
|
||||
.unwrap()
|
||||
.omission,
|
||||
Some(npm::Omission::Unavailable)
|
||||
);
|
||||
assert!(
|
||||
!files(&lock, &store, 0)
|
||||
.keys()
|
||||
.any(|path| path.contains("darwin") || path.contains("musl"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_integrity_and_protocol_failures_are_never_omissions() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
release(
|
||||
&mut registry,
|
||||
"root",
|
||||
"1.0.0",
|
||||
serde_json::json!({"optionalDependencies":{"child":"*"}}),
|
||||
&[],
|
||||
);
|
||||
release(&mut registry, "child", "1.0.0", serde_json::json!({}), &[]);
|
||||
registry.0.insert(
|
||||
(Ecosystem::Npm, "https://mock/child/1.0.0.tgz".into()),
|
||||
b"tampered".to_vec(),
|
||||
);
|
||||
let store = FixtureArtifactStore::default();
|
||||
assert!(
|
||||
local_resolver(®istry, &store)
|
||||
.resolve(&["npm:root@1.0.0".parse().unwrap()])
|
||||
.unwrap_err()
|
||||
.contains("integrity mismatch")
|
||||
);
|
||||
release(
|
||||
&mut registry,
|
||||
"child",
|
||||
"1.0.0",
|
||||
serde_json::json!({"dependencies":{"other":"not-a-semver"}}),
|
||||
&[],
|
||||
);
|
||||
assert!(
|
||||
local_resolver(®istry, &store)
|
||||
.resolve(&["npm:root@1.0.0".parse().unwrap()])
|
||||
.unwrap_err()
|
||||
.contains("invalid npm range")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_peers_auto_install_and_backtrack_but_optional_peers_do_not() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
release(
|
||||
&mut registry,
|
||||
"root",
|
||||
"1.0.0",
|
||||
serde_json::json!({"dependencies":{"plugin":"*","host":"1.0.0"}}),
|
||||
&[],
|
||||
);
|
||||
release(
|
||||
&mut registry,
|
||||
"plugin",
|
||||
"2.0.0",
|
||||
serde_json::json!({"peerDependencies":{"host":"2.0.0"}}),
|
||||
&[],
|
||||
);
|
||||
release(
|
||||
&mut registry,
|
||||
"plugin",
|
||||
"1.0.0",
|
||||
serde_json::json!({"peerDependencies":{"host":"1.0.0","absent":"*"},"peerDependenciesMeta":{"absent":{"optional":true}}}),
|
||||
&[],
|
||||
);
|
||||
for version in ["1.0.0", "2.0.0"] {
|
||||
release(&mut registry, "host", version, serde_json::json!({}), &[]);
|
||||
}
|
||||
let (lock, _) = resolved(®istry, &["npm:root@1.0.0"]);
|
||||
let plugin = lock.npm[0]
|
||||
.instances
|
||||
.iter()
|
||||
.find(|node| node.spec.name == "plugin")
|
||||
.unwrap();
|
||||
assert_eq!(plugin.spec.version, "1.0.0");
|
||||
assert_eq!(
|
||||
plugin
|
||||
.edges
|
||||
.iter()
|
||||
.find(|edge| edge.name == "absent")
|
||||
.unwrap()
|
||||
.omission,
|
||||
Some(npm::Omission::OptionalPeerAbsent)
|
||||
);
|
||||
let (lock, _) = resolved(®istry, &["npm:plugin@1.0.0"]);
|
||||
assert!(
|
||||
lock.npm[0]
|
||||
.instances
|
||||
.iter()
|
||||
.any(|node| node.path == "node_modules/host")
|
||||
);
|
||||
let store = FixtureArtifactStore::default();
|
||||
assert!(
|
||||
local_resolver(®istry, &store)
|
||||
.resolve(&[
|
||||
"npm:plugin@2.0.0".parse().unwrap(),
|
||||
"npm:host@1.0.0".parse().unwrap()
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_transitive_packages_are_bound_to_parent_and_never_downloaded() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
let extra = vec![
|
||||
(
|
||||
"package/node_modules/b/package.json",
|
||||
br#"{"name":"b","version":"1.0.0","dependencies":{"c":"2.0.0"}}"#.to_vec(),
|
||||
),
|
||||
(
|
||||
"package/node_modules/b/index.js",
|
||||
b"module.exports = require('c');".to_vec(),
|
||||
),
|
||||
(
|
||||
"package/node_modules/c/package.json",
|
||||
br#"{"name":"c","version":"2.0.0"}"#.to_vec(),
|
||||
),
|
||||
(
|
||||
"package/node_modules/c/index.js",
|
||||
b"module.exports = 42;".to_vec(),
|
||||
),
|
||||
];
|
||||
release(
|
||||
&mut registry,
|
||||
"root",
|
||||
"1.0.0",
|
||||
serde_json::json!({"dependencies":{"b":"1.0.0"},"bundleDependencies":["b"]}),
|
||||
&extra,
|
||||
);
|
||||
let (lock, store) = resolved(®istry, &["npm:root@1.0.0"]);
|
||||
assert_eq!(lock.libs.len(), 1);
|
||||
assert_eq!(lock.npm[0].instances.len(), 3);
|
||||
assert_eq!(
|
||||
files(&lock, &store, 0)["node_modules/root/node_modules/c/index.js"],
|
||||
b"module.exports = 42;"
|
||||
);
|
||||
release(
|
||||
&mut registry,
|
||||
"root",
|
||||
"1.0.0",
|
||||
serde_json::json!({}),
|
||||
&extra,
|
||||
);
|
||||
assert!(
|
||||
local_resolver(®istry, &store)
|
||||
.resolve(&["npm:root@1.0.0".parse().unwrap()])
|
||||
.unwrap_err()
|
||||
.contains("undeclared")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_root_sets_keep_separate_npm_environments_and_detect_lock_tampering() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
release(
|
||||
&mut registry,
|
||||
"plugin",
|
||||
"1.0.0",
|
||||
serde_json::json!({"peerDependencies":{"host":"*"}}),
|
||||
&[],
|
||||
);
|
||||
for version in ["1.0.0", "2.0.0"] {
|
||||
release(&mut registry, "host", version, serde_json::json!({}), &[]);
|
||||
}
|
||||
let store = FixtureArtifactStore::default();
|
||||
let worker = |adapter: &str, version: &str| LockedWorker {
|
||||
port: "sample.worker".into(),
|
||||
adapter: adapter.into(),
|
||||
ecosystem: "npm".into(),
|
||||
runtime: Some(registry_runtime(Ecosystem::Npm).pack),
|
||||
entry: format!("worker/{adapter}.mjs"),
|
||||
schema: "checked-port".into(),
|
||||
capabilities: vec![],
|
||||
operations: vec!["read".into()],
|
||||
libs: vec![
|
||||
"npm:plugin@1.0.0".parse().unwrap(),
|
||||
format!("npm:host@{version}").parse().unwrap(),
|
||||
],
|
||||
};
|
||||
let lock = resolve_workers(
|
||||
&local_resolver(®istry, &store),
|
||||
&[],
|
||||
vec![worker("first", "1.0.0"), worker("second", "2.0.0")],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(lock.npm.len(), 2);
|
||||
for index in 0..2 {
|
||||
assert!(files(&lock, &store, index).contains_key("node_modules/plugin/index.js"));
|
||||
}
|
||||
let mut changed = lock.clone();
|
||||
changed.npm[0].instances[0].path = "../escape".into();
|
||||
assert!(doctor(&changed).is_err());
|
||||
let mut changed = lock.clone();
|
||||
changed.npm[0]
|
||||
.instances
|
||||
.iter_mut()
|
||||
.find(|node| node.spec.name == "plugin")
|
||||
.unwrap()
|
||||
.edges[0]
|
||||
.target = Some("node_modules/missing".into());
|
||||
assert!(doctor(&changed).is_err());
|
||||
let mut changed = lock;
|
||||
changed.npm[0]
|
||||
.instances
|
||||
.iter_mut()
|
||||
.find(|node| node.spec.name == "plugin")
|
||||
.unwrap()
|
||||
.edges
|
||||
.clear();
|
||||
let archives = changed
|
||||
.libs
|
||||
.iter()
|
||||
.map(|lib| {
|
||||
(
|
||||
lib.spec.clone(),
|
||||
store
|
||||
.verify_exact(
|
||||
&lib.artifacts[0].sha256,
|
||||
lib.artifacts[0].bytes,
|
||||
&lib.artifacts[0].target,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
assert!(
|
||||
npm::files(&changed.npm[0], &changed, |lib| Ok(
|
||||
archives[&lib.spec].as_slice()
|
||||
))
|
||||
.unwrap_err()
|
||||
.contains("differs from package.json")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_instances_cannot_switch_to_another_parents_same_version_package() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
for name in ["a", "c"] {
|
||||
release(
|
||||
&mut registry,
|
||||
name,
|
||||
"1.0.0",
|
||||
serde_json::json!({"dependencies":{"b":"1.0.0"},"bundleDependencies":["b"]}),
|
||||
&[
|
||||
(
|
||||
"package/node_modules/b/package.json",
|
||||
br#"{"name":"b","version":"1.0.0"}"#.to_vec(),
|
||||
),
|
||||
(
|
||||
"package/node_modules/b/index.js",
|
||||
format!("module.exports = '{name}';").into_bytes(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
release(&mut registry, "b", "1.0.0", serde_json::json!({}), &[]);
|
||||
let (original, store) = resolved(®istry, &["npm:a@1.0.0", "npm:c@1.0.0", "npm:b@1.0.0"]);
|
||||
let archives = original
|
||||
.libs
|
||||
.iter()
|
||||
.map(|lib| {
|
||||
(
|
||||
lib.spec.clone(),
|
||||
store
|
||||
.verify_exact(
|
||||
&lib.artifacts[0].sha256,
|
||||
lib.artifacts[0].bytes,
|
||||
&lib.artifacts[0].target,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
for (archive, prefix) in [
|
||||
("npm:c@1.0.0", "package/node_modules/b/"),
|
||||
("npm:b@1.0.0", "package/"),
|
||||
] {
|
||||
let mut changed = original.clone();
|
||||
changed.npm[0]
|
||||
.instances
|
||||
.iter_mut()
|
||||
.find(|node| node.path == "node_modules/a/node_modules/b")
|
||||
.unwrap()
|
||||
.source = npm::Source {
|
||||
archive: archive.parse().unwrap(),
|
||||
prefix: prefix.into(),
|
||||
};
|
||||
assert!(
|
||||
doctor(&changed)
|
||||
.unwrap_err()
|
||||
.contains("parent archive installation")
|
||||
);
|
||||
assert!(
|
||||
npm::files(&changed.npm[0], &changed, |lib| Ok(
|
||||
archives[&lib.spec].as_slice()
|
||||
))
|
||||
.unwrap_err()
|
||||
.contains("parent archive installation")
|
||||
);
|
||||
// Removing the locked layout cannot hide the original archive binding.
|
||||
for node in &mut changed.npm[0].instances {
|
||||
node.bundled.clear();
|
||||
}
|
||||
assert!(
|
||||
npm::files(&changed.npm[0], &changed, |lib| Ok(
|
||||
archives[&lib.spec].as_slice()
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_and_optional_dependencies_override_same_named_peers() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
release(&mut registry, "host", "2.0.0", serde_json::json!({}), &[]);
|
||||
release(
|
||||
&mut registry,
|
||||
"plugin",
|
||||
"1.0.0",
|
||||
serde_json::json!({"dependencies":{"host":"2.0.0"},"peerDependencies":{"host":"1.0.0"}}),
|
||||
&[],
|
||||
);
|
||||
let (lock, _) = resolved(®istry, &["npm:plugin@1.0.0", "npm:host@2.0.0"]);
|
||||
let plugin = lock.npm[0]
|
||||
.instances
|
||||
.iter()
|
||||
.find(|node| node.spec.name == "plugin")
|
||||
.unwrap();
|
||||
assert_eq!(plugin.edges.len(), 1);
|
||||
assert_eq!(plugin.edges[0].kind, npm::Kind::Dependency);
|
||||
assert_eq!(
|
||||
plugin.edges[0].target.as_deref(),
|
||||
Some("node_modules/plugin/node_modules/host")
|
||||
);
|
||||
release(
|
||||
&mut registry,
|
||||
"plugin",
|
||||
"1.0.0",
|
||||
serde_json::json!({"optionalDependencies":{"absent":"*"},"peerDependencies":{"absent":"1.0.0"}}),
|
||||
&[],
|
||||
);
|
||||
let (lock, _) = resolved(®istry, &["npm:plugin@1.0.0"]);
|
||||
assert_eq!(lock.npm[0].instances[0].edges.len(), 1);
|
||||
assert_eq!(lock.npm[0].instances[0].edges[0].kind, npm::Kind::Optional);
|
||||
assert_eq!(
|
||||
lock.npm[0].instances[0].edges[0].omission,
|
||||
Some(npm::Omission::Unavailable)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_root_prunes_its_entire_dependency_cycle() {
|
||||
for bundled in [false, true] {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
release(&mut registry, "z", "1.0.0", serde_json::json!({}), &[]);
|
||||
let b = serde_json::json!({"name":"b","version":"1.0.0","dependencies":{"a":"1.0.0"}});
|
||||
let mut a = serde_json::json!({"dependencies":{"b":"1.0.0"}});
|
||||
let extra = if bundled {
|
||||
a["bundleDependencies"] = serde_json::json!(["b"]);
|
||||
vec![(
|
||||
"package/node_modules/b/package.json",
|
||||
serde_json::to_vec(&b).unwrap(),
|
||||
)]
|
||||
} else {
|
||||
release(&mut registry, "b", "1.0.0", b, &[]);
|
||||
vec![]
|
||||
};
|
||||
release(&mut registry, "a", "1.0.0", a, &extra);
|
||||
let (lock, _) = resolved(®istry, &["npm:a@1.0.0", "npm:z@1.0.0"]);
|
||||
let root = TemporaryDirectory::new();
|
||||
fs::create_dir(root.path().join("config")).unwrap();
|
||||
fs::write(
|
||||
root.path().join("config/setting.json"),
|
||||
br#"{"lib":["npm:a@1.0.0","npm:z@1.0.0"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
lock.write_atomic(root.path()).unwrap();
|
||||
dever_cli::libs::execute("remove", root.path(), &["npm:a@1.0.0".into()]).unwrap();
|
||||
let retained =
|
||||
LockFile::decode(&fs::read(root.path().join("dever.lock")).unwrap()).unwrap();
|
||||
assert_eq!(retained.libs.len(), 1);
|
||||
assert_eq!(retained.libs[0].spec.name, "z");
|
||||
assert_eq!(retained.npm[0].instances.len(), 1);
|
||||
doctor(&retained).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_dependency_still_required_by_a_remaining_root_is_rejected() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
release(
|
||||
&mut registry,
|
||||
"a",
|
||||
"1.0.0",
|
||||
serde_json::json!({"dependencies":{"b":"1.0.0"}}),
|
||||
&[],
|
||||
);
|
||||
release(&mut registry, "b", "1.0.0", serde_json::json!({}), &[]);
|
||||
let (lock, _) = resolved(®istry, &["npm:a@1.0.0", "npm:b@1.0.0"]);
|
||||
let root = TemporaryDirectory::new();
|
||||
fs::create_dir(root.path().join("config")).unwrap();
|
||||
let setting = br#"{"lib":["npm:a@1.0.0","npm:b@1.0.0"]}"#;
|
||||
fs::write(root.path().join("config/setting.json"), setting).unwrap();
|
||||
lock.write_atomic(root.path()).unwrap();
|
||||
let before = fs::read(root.path().join("dever.lock")).unwrap();
|
||||
assert!(
|
||||
dever_cli::libs::execute("remove", root.path(), &["npm:b@1.0.0".into()])
|
||||
.unwrap_err()
|
||||
.contains("required by")
|
||||
);
|
||||
assert_eq!(fs::read(root.path().join("dever.lock")).unwrap(), before);
|
||||
assert_eq!(
|
||||
fs::read(root.path().join("config/setting.json")).unwrap(),
|
||||
setting
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_archive_instances_are_bounded_before_materialization_clones() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
release(
|
||||
&mut registry,
|
||||
"leaf",
|
||||
"1.0.0",
|
||||
serde_json::json!({}),
|
||||
&[("package/payload.js", vec![b'x'; 2 * 1024 * 1024])],
|
||||
);
|
||||
let mut roots = Vec::new();
|
||||
for number in 0..129 {
|
||||
let name = format!("root-{number}");
|
||||
release(
|
||||
&mut registry,
|
||||
&name,
|
||||
"1.0.0",
|
||||
serde_json::json!({"dependencies":{"leaf":"1.0.0"}}),
|
||||
&[],
|
||||
);
|
||||
roots.push(format!("npm:{name}@1.0.0"));
|
||||
}
|
||||
let (lock, store) = resolved(
|
||||
®istry,
|
||||
&roots.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
assert_eq!(lock.npm[0].instances.len(), 258);
|
||||
let archives = lock
|
||||
.libs
|
||||
.iter()
|
||||
.map(|lib| {
|
||||
(
|
||||
lib.spec.clone(),
|
||||
store
|
||||
.verify_exact(
|
||||
&lib.artifacts[0].sha256,
|
||||
lib.artifacts[0].bytes,
|
||||
&lib.artifacts[0].target,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
assert!(
|
||||
npm::files(
|
||||
&lock.npm[0],
|
||||
&lock,
|
||||
|lib| Ok(archives[&lib.spec].as_slice())
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("tree file/byte budget")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
use super::*;
|
||||
use dever_cli::libs::build::{Inputs, NativeRejection, Receipt, Session, pack};
|
||||
use dever_cli::libs::{LibResolver, npm};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Settings {
|
||||
runtime: RegistryRuntime,
|
||||
runtime_file: String,
|
||||
python: RegistryRuntime,
|
||||
python_file: String,
|
||||
build: pack::Descriptor,
|
||||
build_file: String,
|
||||
}
|
||||
|
||||
struct AuthorInputs {
|
||||
root: PathBuf,
|
||||
settings: Settings,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_lifecycle_output_preserves_locked_identity_and_instance_ownership() {
|
||||
let environment = npm::Environment::single("npm:app@1.0.0".parse().unwrap());
|
||||
let files = vec![
|
||||
(
|
||||
"node_modules/app/package.json".into(),
|
||||
br#"{"name":"app","version":"1.0.0"}"#.to_vec(),
|
||||
),
|
||||
(
|
||||
"node_modules/app/generated.js".into(),
|
||||
b"module.exports=42;".to_vec(),
|
||||
),
|
||||
];
|
||||
npm::validate_built_files(&environment, &files).unwrap();
|
||||
let mut changed = files.clone();
|
||||
changed[0].1 =
|
||||
br#"{"name":"app","version":"1.0.0","dependencies":{"injected":"1.0.0"}}"#.to_vec();
|
||||
assert!(npm::validate_built_files(&environment, &changed).is_err());
|
||||
let mut changed = files.clone();
|
||||
changed.push(("node_modules/injected/index.js".into(), vec![]));
|
||||
assert!(npm::validate_built_files(&environment, &changed).is_err());
|
||||
let mut changed = files.clone();
|
||||
changed.push(("node_modules/app/../escape".into(), vec![]));
|
||||
assert!(npm::validate_built_files(&environment, &changed).is_err());
|
||||
assert!(npm::validate_built_files(&environment, &files[1..]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_registry_install_hooks_do_not_run_publishing_lifecycle() {
|
||||
let metadata = |script: &str| {
|
||||
vec![(
|
||||
"node_modules/app/package.json".into(),
|
||||
serde_json::to_vec(&serde_json::json!({"scripts":{script:"node command.js"}})).unwrap(),
|
||||
)]
|
||||
};
|
||||
for script in ["preinstall", "install", "postinstall"] {
|
||||
assert!(npm::requires_build(&metadata(script)).unwrap());
|
||||
}
|
||||
for script in ["prepare", "prepack", "prepublish"] {
|
||||
assert!(!npm::requires_build(&metadata(script)).unwrap());
|
||||
}
|
||||
assert!(
|
||||
npm::requires_build(&[("node_modules/app/binding.gyp".into(), b"{}".to_vec())]).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
fn locked_installation_fixture() -> (LockFile, Vec<u8>) {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
npm_tests::release(
|
||||
&mut registry,
|
||||
"app",
|
||||
"1.0.0",
|
||||
serde_json::json!({}),
|
||||
&[(
|
||||
"package/prebuilds/foreign/addon.node",
|
||||
b"foreign-object".to_vec(),
|
||||
)],
|
||||
);
|
||||
npm_tests::release(&mut registry, "other", "1.0.0", serde_json::json!({}), &[]);
|
||||
let store = FixtureArtifactStore::default();
|
||||
let mut lock = local_resolver(®istry, &store)
|
||||
.resolve(&[
|
||||
"npm:app@1.0.0".parse().unwrap(),
|
||||
"npm:other@1.0.0".parse().unwrap(),
|
||||
])
|
||||
.unwrap();
|
||||
let sources = lock
|
||||
.libs
|
||||
.iter()
|
||||
.flat_map(|lib| lib.artifacts.clone())
|
||||
.collect();
|
||||
let archives = lock
|
||||
.libs
|
||||
.iter()
|
||||
.map(|lib| {
|
||||
(
|
||||
lib.spec.clone(),
|
||||
store
|
||||
.verify_exact(
|
||||
&lib.artifacts[0].sha256,
|
||||
lib.artifacts[0].bytes,
|
||||
&lib.artifacts[0].target,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let files = npm::build_files(
|
||||
&lock.npm[0],
|
||||
&lock,
|
||||
|lib| Ok(archives[&lib.spec].as_slice()),
|
||||
)
|
||||
.unwrap();
|
||||
let mut writer = tar::Builder::new(flate2::write::GzEncoder::new(
|
||||
Vec::new(),
|
||||
flate2::Compression::default(),
|
||||
));
|
||||
for (path, bytes) in files {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
writer
|
||||
.append_data(&mut header, path, bytes.as_slice())
|
||||
.unwrap();
|
||||
}
|
||||
let bytes = writer.into_inner().unwrap().finish().unwrap();
|
||||
let runtime = registry_runtime(Ecosystem::Npm);
|
||||
let python = registry_runtime(Ecosystem::Pip);
|
||||
let hash = digest(&bytes);
|
||||
let receipt = Receipt {
|
||||
ecosystem: Ecosystem::Npm,
|
||||
roots: lock.npm[0].roots.clone(),
|
||||
sources,
|
||||
graph: Some(lock.npm[0].clone()),
|
||||
runtime: runtime.pack.clone(),
|
||||
auxiliary_runtime: Some(python.pack.clone()),
|
||||
native_entries: Default::default(),
|
||||
native_rejections: BTreeMap::from([(
|
||||
"node_modules/app/prebuilds/foreign/addon.node".into(),
|
||||
NativeRejection::Format,
|
||||
)]),
|
||||
python_markers: None,
|
||||
tools: pack::Descriptor {
|
||||
format: "dever-registry-build-v1".into(),
|
||||
ecosystem: Ecosystem::Npm,
|
||||
target: runtime.target.clone(),
|
||||
runtime_sha256: runtime.pack.sha256.clone(),
|
||||
python_runtime_sha256: Some(python.pack.sha256),
|
||||
sha256: "a".repeat(64),
|
||||
},
|
||||
frontend: digest(
|
||||
&[
|
||||
include_bytes!("../../../crates/dever-cli/src/libs/build/npm.js").as_slice(),
|
||||
include_bytes!("../../../crates/dever-cli/src/libs/build/npm/probe.cjs").as_slice(),
|
||||
]
|
||||
.concat(),
|
||||
),
|
||||
python: None,
|
||||
dynamic_requires: vec![],
|
||||
dependencies: Box::new(LockFile::new(vec![]).unwrap()),
|
||||
config_settings: BTreeMap::new(),
|
||||
output: dever_cli::libs::LockedArtifact {
|
||||
target: runtime.target,
|
||||
path: format!("build/npm/{hash}/installation.tgz"),
|
||||
bytes: bytes.len() as u64,
|
||||
sha256: hash,
|
||||
},
|
||||
};
|
||||
lock.npm[0].build = Some(receipt.identity().unwrap());
|
||||
lock.builds.push(receipt);
|
||||
doctor(&lock).unwrap();
|
||||
(lock, bytes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_expanded_outputs_are_pruned_only_when_no_raw_consumer_remains() {
|
||||
use dever_cli::libs::{LockedWorker, prune_expanded_resources};
|
||||
use dever_core::native::EmbeddedResource;
|
||||
|
||||
let (mut lock, bytes) = locked_installation_fixture();
|
||||
let roots = lock.npm[0].roots.clone();
|
||||
let other = vec!["npm:other@1.0.0".parse().unwrap()];
|
||||
lock.npm = npm::retain(&lock, &[roots.clone(), other.clone()]).unwrap();
|
||||
let output = &lock.builds[0].output;
|
||||
let managed = LockedWorker {
|
||||
port: "sample.worker".into(),
|
||||
adapter: "default".into(),
|
||||
ecosystem: "npm".into(),
|
||||
runtime: Some(registry_runtime(Ecosystem::Npm).pack),
|
||||
entry: "worker/main.cjs".into(),
|
||||
schema: "checked".into(),
|
||||
capabilities: vec![],
|
||||
operations: vec!["probe".into()],
|
||||
libs: roots.clone(),
|
||||
};
|
||||
let resource = EmbeddedResource {
|
||||
path: output.path.clone(),
|
||||
bytes,
|
||||
sha256: output.sha256.clone(),
|
||||
executable: false,
|
||||
};
|
||||
let mut resources = vec![resource.clone()];
|
||||
prune_expanded_resources(&lock, std::slice::from_ref(&managed), &[], &mut resources).unwrap();
|
||||
assert!(
|
||||
resources.is_empty(),
|
||||
"expanded installation archive leaked into program"
|
||||
);
|
||||
|
||||
// A projected environment shares its original build output with the full
|
||||
// Worker. Retention follows current consumers, not the receipt's old roots.
|
||||
for declared in [roots, other] {
|
||||
let mut resources = vec![resource.clone()];
|
||||
prune_expanded_resources(
|
||||
&lock,
|
||||
std::slice::from_ref(&managed),
|
||||
&declared,
|
||||
&mut resources,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(resources.len(), 1);
|
||||
}
|
||||
let mut exec = managed.clone();
|
||||
exec.ecosystem = "exec".into();
|
||||
exec.runtime = None;
|
||||
let mut resources = vec![resource];
|
||||
prune_expanded_resources(&lock, &[managed, exec], &[], &mut resources).unwrap();
|
||||
assert_eq!(resources.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_native_admission_accounts_every_addon_and_prunes_unsupported_objects() {
|
||||
let (lock, bytes) = locked_installation_fixture();
|
||||
let files = dever_cli::libs::build::npm_installation(&lock, &lock.npm[0], &bytes).unwrap();
|
||||
assert!(files.iter().all(|(path, _, _)| !path.ends_with(".node")));
|
||||
let mut missing = lock.clone();
|
||||
missing.builds[0].native_rejections.clear();
|
||||
missing.npm[0].build = Some(missing.builds[0].identity().unwrap());
|
||||
assert!(
|
||||
dever_cli::libs::build::npm_installation(&missing, &missing.npm[0], &bytes)
|
||||
.unwrap_err()
|
||||
.contains("exact addon files")
|
||||
);
|
||||
let mut corrupted = bytes.clone();
|
||||
corrupted[10] ^= 1;
|
||||
assert!(dever_cli::libs::build::npm_installation(&lock, &lock.npm[0], &corrupted).is_err());
|
||||
for change in ["tools", "graph", "runtime"] {
|
||||
let mut changed = lock.clone();
|
||||
match change {
|
||||
"tools" => changed.builds[0].tools.sha256 = "not-a-hash".into(),
|
||||
"graph" => changed.builds[0].graph.as_mut().unwrap().instances.clear(),
|
||||
_ => changed.builds[0].runtime.sha256 = "b".repeat(64),
|
||||
}
|
||||
changed.npm[0].build = Some(changed.builds[0].identity().unwrap());
|
||||
assert!(
|
||||
doctor(&changed).is_err(),
|
||||
"accepted internally inconsistent {change}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_locked_output_projects_retained_roots_without_rerunning_hooks() {
|
||||
let (lock, bytes) = locked_installation_fixture();
|
||||
let project = TemporaryDirectory::new();
|
||||
fs::create_dir(project.path().join("config")).unwrap();
|
||||
fs::write(
|
||||
project.path().join("config/setting.json"),
|
||||
br#"{"lib":["npm:app@1.0.0","npm:other@1.0.0"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
lock.write_atomic(project.path()).unwrap();
|
||||
dever_cli::libs::execute("remove", project.path(), &["npm:app@1.0.0".into()]).unwrap();
|
||||
let retained = LockFile::decode(&fs::read(project.path().join("dever.lock")).unwrap()).unwrap();
|
||||
doctor(&retained).unwrap();
|
||||
let files =
|
||||
dever_cli::libs::build::npm_installation(&retained, &retained.npm[0], &bytes).unwrap();
|
||||
assert_eq!(retained.builds, lock.builds);
|
||||
assert!(
|
||||
files
|
||||
.iter()
|
||||
.all(|(path, _, _)| path.starts_with("node_modules/other/"))
|
||||
);
|
||||
}
|
||||
|
||||
impl AuthorInputs {
|
||||
fn load() -> Self {
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/npm-build");
|
||||
let settings =
|
||||
serde_json::from_slice(&fs::read(root.join("config/setting.json")).unwrap()).unwrap();
|
||||
Self { root, settings }
|
||||
}
|
||||
}
|
||||
|
||||
impl Inputs for AuthorInputs {
|
||||
fn runtime(&self, ecosystem: Ecosystem) -> Result<(RegistryRuntime, Vec<u8>), String> {
|
||||
let (runtime, path) = match ecosystem {
|
||||
Ecosystem::Pip => (&self.settings.python, &self.settings.python_file),
|
||||
Ecosystem::Npm => (&self.settings.runtime, &self.settings.runtime_file),
|
||||
_ => return Err("fixture only supplies Node and auxiliary Python".into()),
|
||||
};
|
||||
let bytes = fs::read(self.root.join(path)).map_err(|error| error.to_string())?;
|
||||
if digest(&bytes) != runtime.pack.sha256 {
|
||||
return Err("author runtime drift".into());
|
||||
}
|
||||
Ok((runtime.clone(), bytes))
|
||||
}
|
||||
fn tools(&self, ecosystem: Ecosystem) -> Result<(pack::Descriptor, Vec<u8>), String> {
|
||||
if ecosystem != Ecosystem::Npm {
|
||||
return Err("fixture only supplies npm tools".into());
|
||||
}
|
||||
Ok((
|
||||
self.settings.build.clone(),
|
||||
fs::read(self.root.join(&self.settings.build_file))
|
||||
.map_err(|error| error.to_string())?,
|
||||
))
|
||||
}
|
||||
fn assets(&self) -> Result<Vec<(String, Vec<u8>)>, String> {
|
||||
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
Ok(build_tests::sandbox::assets(&workspace)
|
||||
.into_iter()
|
||||
.map(|asset| {
|
||||
(
|
||||
asset.path.trim_start_matches("sandbox/").into(),
|
||||
asset.bytes,
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
fn diagnostic(&self, output: &[u8]) {
|
||||
eprintln!("{}", String::from_utf8_lossy(output));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit author operation: assembles hashed npm/compiler/header inputs; no network or host execution"]
|
||||
fn prepare_npm_build_tool_fixture() {
|
||||
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let runtime = |name: &str| {
|
||||
let root = workspace.join(format!("target/{name}-managed"));
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(root.join("config/setting.json")).unwrap()).unwrap();
|
||||
let runtime: RegistryRuntime = serde_json::from_value(config["runtime"].clone()).unwrap();
|
||||
assert_eq!(
|
||||
digest(&fs::read(root.join("runtime.pack")).unwrap()),
|
||||
runtime.pack.sha256
|
||||
);
|
||||
runtime
|
||||
};
|
||||
let node = runtime("npm");
|
||||
let python = runtime("python");
|
||||
let root = workspace.join("target/npm-build");
|
||||
let build = build_tests::prepare_tool_pack(Ecosystem::Npm, &node, Some(&python), &root);
|
||||
let settings = Settings {
|
||||
runtime: node,
|
||||
runtime_file: "../npm-managed/runtime.pack".into(),
|
||||
python,
|
||||
python_file: "../python-managed/runtime.pack".into(),
|
||||
build,
|
||||
build_file: "build.pack".into(),
|
||||
};
|
||||
fs::write(
|
||||
root.join("config/setting.json"),
|
||||
serde_json::to_vec_pretty(&settings).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn resolver<'a>(
|
||||
inputs: &AuthorInputs,
|
||||
session: &'a Session<'a>,
|
||||
transport: &'a dyn RegistryTransport,
|
||||
store: &'a FixtureArtifactStore,
|
||||
) -> RegistryResolver<'a> {
|
||||
RegistryResolver {
|
||||
transport,
|
||||
store,
|
||||
runtimes: BTreeMap::from([(Ecosystem::Npm, inputs.settings.runtime.clone())]),
|
||||
go_sumdb: dever_cli::libs::sumdb::ChecksumDatabase::official(None).unwrap(),
|
||||
build: Some(session),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit official npm native source build; network only in registry resolver, never in lifecycle sandbox"]
|
||||
fn builds_official_npm_addon_with_locked_lifecycle() {
|
||||
let inputs = AuthorInputs::load();
|
||||
let session = Session::new(&inputs);
|
||||
let transport = dever_cli::libs::HttpRegistry::official();
|
||||
let store = FixtureArtifactStore::default();
|
||||
let resolver = resolver(&inputs, &session, &transport, &store);
|
||||
let roots = vec!["npm:bufferutil@4.0.9".parse().unwrap()];
|
||||
let lock = resolver.resolve(&roots).unwrap();
|
||||
doctor(&lock).unwrap();
|
||||
assert_eq!(lock.builds.len(), 1);
|
||||
let receipt = &lock.builds[0];
|
||||
assert!(
|
||||
receipt
|
||||
.native_entries
|
||||
.contains("node_modules/bufferutil/build/Release/bufferutil.node")
|
||||
);
|
||||
let bytes = store
|
||||
.verify_exact(
|
||||
&receipt.output.sha256,
|
||||
receipt.output.bytes,
|
||||
&receipt.output.target,
|
||||
)
|
||||
.unwrap();
|
||||
let files = dever_cli::libs::build::npm_installation(&lock, &lock.npm[0], &bytes).unwrap();
|
||||
assert!(files.iter().any(
|
||||
|(path, bytes, _)| path.ends_with("/build/Release/bufferutil.node")
|
||||
&& bytes.starts_with(b"\x7fELF")
|
||||
));
|
||||
assert_eq!(lock, resolver.resolve(&roots).unwrap());
|
||||
publish_fixture_artifacts(&inputs.root.join("dependency"), &lock, &store);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit managed npm hooks; uses local registry and real signed author runtime/tools"]
|
||||
fn npm_lifecycle_preserves_optional_failures_bins_and_offline_remove() {
|
||||
let inputs = AuthorInputs::load();
|
||||
let session = Session::new(&inputs);
|
||||
let mut transport = LocalRegistry(BTreeMap::new());
|
||||
npm_tests::release(&mut transport, "helper", "1.0.0", serde_json::json!({"bin":{"build-probe":"cli.js"}}), &[("package/cli.js", b"#!/usr/bin/env node\nrequire('fs').writeFileSync('generated.js','module.exports = 42;');\n".to_vec())]);
|
||||
npm_tests::release(
|
||||
&mut transport,
|
||||
"broken",
|
||||
"1.0.0",
|
||||
serde_json::json!({"dependencies":{"cycle":"1.0.0"},"scripts":{"install":"node -e 'process.exit(7)'"}}),
|
||||
&[],
|
||||
);
|
||||
npm_tests::release(
|
||||
&mut transport,
|
||||
"cycle",
|
||||
"1.0.0",
|
||||
serde_json::json!({"dependencies":{"broken":"1.0.0"}}),
|
||||
&[],
|
||||
);
|
||||
npm_tests::release(
|
||||
&mut transport,
|
||||
"app",
|
||||
"1.0.0",
|
||||
serde_json::json!({"dependencies":{"helper":"1.0.0"},"optionalDependencies":{"broken":"1.0.0"},"scripts":{"postinstall":"build-probe"}}),
|
||||
&[],
|
||||
);
|
||||
npm_tests::release(&mut transport, "other", "1.0.0", serde_json::json!({}), &[]);
|
||||
let store = FixtureArtifactStore::default();
|
||||
let resolver = resolver(&inputs, &session, &transport, &store);
|
||||
let lock = resolver
|
||||
.resolve(&[
|
||||
"npm:app@1.0.0".parse().unwrap(),
|
||||
"npm:other@1.0.0".parse().unwrap(),
|
||||
])
|
||||
.unwrap();
|
||||
doctor(&lock).unwrap();
|
||||
assert!(
|
||||
!lock
|
||||
.libs
|
||||
.iter()
|
||||
.any(|lib| matches!(lib.spec.name.as_str(), "broken" | "cycle"))
|
||||
);
|
||||
assert!(
|
||||
lock.npm[0]
|
||||
.instances
|
||||
.iter()
|
||||
.flat_map(|node| &node.edges)
|
||||
.any(|edge| edge.omission == Some(npm::Omission::BuildFailure))
|
||||
);
|
||||
let receipt = &lock.builds[0];
|
||||
let bytes = store
|
||||
.verify_exact(
|
||||
&receipt.output.sha256,
|
||||
receipt.output.bytes,
|
||||
&receipt.output.target,
|
||||
)
|
||||
.unwrap();
|
||||
let files = dever_cli::libs::build::npm_installation(&lock, &lock.npm[0], &bytes).unwrap();
|
||||
assert!(
|
||||
files
|
||||
.iter()
|
||||
.any(|(path, bytes, _)| path == "node_modules/app/generated.js"
|
||||
&& bytes == b"module.exports = 42;")
|
||||
);
|
||||
assert!(
|
||||
resolver
|
||||
.resolve(&["npm:broken@1.0.0".parse().unwrap()])
|
||||
.unwrap_err()
|
||||
.contains("failed")
|
||||
);
|
||||
let project = TemporaryDirectory::new();
|
||||
fs::create_dir(project.path().join("config")).unwrap();
|
||||
fs::write(
|
||||
project.path().join("config/setting.json"),
|
||||
br#"{"lib":["npm:app@1.0.0","npm:other@1.0.0"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
lock.write_atomic(project.path()).unwrap();
|
||||
dever_cli::libs::execute("remove", project.path(), &["npm:app@1.0.0".into()]).unwrap();
|
||||
let retained = LockFile::decode(&fs::read(project.path().join("dever.lock")).unwrap()).unwrap();
|
||||
doctor(&retained).unwrap();
|
||||
let files =
|
||||
dever_cli::libs::build::npm_installation(&retained, &retained.npm[0], &bytes).unwrap();
|
||||
assert!(
|
||||
files
|
||||
.iter()
|
||||
.all(|(path, _, _)| path.starts_with("node_modules/other/"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit managed npm hook: sparse scratch overflow and child cleanup; no network"]
|
||||
fn npm_build_scratch_budget_kills_owned_hook_and_cleans_stage() {
|
||||
let inputs = AuthorInputs::load();
|
||||
let session = Session::new(&inputs);
|
||||
let mut transport = LocalRegistry(BTreeMap::new());
|
||||
npm_tests::release(&mut transport,"oversized","1.0.0",serde_json::json!({"scripts":{"install":"node fill.js"}}),&[("package/fill.js",b"const fs=require('fs');fs.writeFileSync('large',Buffer.alloc(1));fs.truncateSync('large',600*1024*1024);setInterval(()=>{},1000);".to_vec())]);
|
||||
let store = FixtureArtifactStore::default();
|
||||
let resolver = resolver(&inputs, &session, &transport, &store);
|
||||
let owned_stages = || {
|
||||
fs::read_dir(std::env::temp_dir())
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
|
||||
.filter(|name| name.starts_with(&format!("dever-lib-build-{}-", std::process::id())))
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
};
|
||||
let before = owned_stages();
|
||||
let error = resolver
|
||||
.resolve(&["npm:oversized@1.0.0".parse().unwrap()])
|
||||
.unwrap_err();
|
||||
assert!(error.contains("scratch budget"), "{error}");
|
||||
assert_eq!(
|
||||
owned_stages(),
|
||||
before,
|
||||
"failed hook leaked its owned staging tree"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit managed npm hook: internal output aliases are flattened; aliases outside output are rejected"]
|
||||
fn npm_build_output_hard_links_stay_inside_the_exported_tree() {
|
||||
let inputs = AuthorInputs::load();
|
||||
let session = Session::new(&inputs);
|
||||
for (name, source, accepted) in [
|
||||
(
|
||||
"insidealias",
|
||||
"const fs=require('fs');fs.writeFileSync('original.txt','same-bytes');fs.linkSync('original.txt','alias.txt');",
|
||||
true,
|
||||
),
|
||||
(
|
||||
"outsidealias",
|
||||
"const fs=require('fs');fs.writeFileSync('/data/build/outside.txt','outside');fs.linkSync('/data/build/outside.txt','alias.txt');",
|
||||
false,
|
||||
),
|
||||
] {
|
||||
let mut transport = LocalRegistry(BTreeMap::new());
|
||||
npm_tests::release(
|
||||
&mut transport,
|
||||
name,
|
||||
"1.0.0",
|
||||
serde_json::json!({"scripts":{"install":"node aliases.js"}}),
|
||||
&[("package/aliases.js", source.as_bytes().to_vec())],
|
||||
);
|
||||
let store = FixtureArtifactStore::default();
|
||||
let result =
|
||||
resolver(&inputs, &session, &transport, &store)
|
||||
.resolve(&[format!("npm:{name}@1.0.0").parse().unwrap()]);
|
||||
if accepted {
|
||||
let lock = result.unwrap();
|
||||
doctor(&lock).unwrap();
|
||||
let receipt = &lock.builds[0];
|
||||
let bytes = store
|
||||
.verify_exact(
|
||||
&receipt.output.sha256,
|
||||
receipt.output.bytes,
|
||||
&receipt.output.target,
|
||||
)
|
||||
.unwrap();
|
||||
let files =
|
||||
dever_cli::libs::build::npm_installation(&lock, &lock.npm[0], &bytes).unwrap();
|
||||
for leaf in ["original.txt", "alias.txt"] {
|
||||
assert!(files.iter().any(|(path, bytes, _)| path
|
||||
== &format!("node_modules/{name}/{leaf}")
|
||||
&& bytes == b"same-bytes"));
|
||||
}
|
||||
} else {
|
||||
assert!(result.unwrap_err().contains("hard-link alias outside"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
use super::*;
|
||||
use dever_cli::libs::sumdb::{self, ChecksumDatabase, Evidence, Verifier};
|
||||
use ring::signature::{Ed25519KeyPair, KeyPair};
|
||||
|
||||
fn key() -> Ed25519KeyPair {
|
||||
Ed25519KeyPair::from_seed_unchecked(&[47; 32]).unwrap()
|
||||
}
|
||||
|
||||
pub(super) fn verifier() -> Verifier {
|
||||
let mut encoded = vec![1];
|
||||
encoded.extend(key().public_key().as_ref());
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(b"dever-sumdb-test\n");
|
||||
hash.update(&encoded);
|
||||
let id = hash.finalize();
|
||||
Verifier::new(&format!(
|
||||
"dever-sumdb-test+{}+{}",
|
||||
id[..4]
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>(),
|
||||
base64::engine::general_purpose::STANDARD.encode(encoded)
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn signed(size: usize, root: [u8; 32]) -> String {
|
||||
let text = format!(
|
||||
"go.sum database tree\n{size}\n{}\n",
|
||||
base64::engine::general_purpose::STANDARD.encode(root)
|
||||
);
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(b"dever-sumdb-test\n");
|
||||
hash.update([1]);
|
||||
hash.update(key().public_key().as_ref());
|
||||
let mut signature = hash.finalize()[..4].to_vec();
|
||||
signature.extend(key().sign(text.as_bytes()).as_ref());
|
||||
format!(
|
||||
"{text}\n— dever-sumdb-test {}\n",
|
||||
base64::engine::general_purpose::STANDARD.encode(signature)
|
||||
)
|
||||
}
|
||||
|
||||
fn leaf(record: &[u8]) -> [u8; 32] {
|
||||
let mut hash = Sha256::new();
|
||||
hash.update([0]);
|
||||
hash.update(record);
|
||||
hash.finalize().into()
|
||||
}
|
||||
|
||||
fn root(hashes: &[[u8; 32]]) -> [u8; 32] {
|
||||
if hashes.is_empty() {
|
||||
return Sha256::digest([]).into();
|
||||
}
|
||||
if hashes.len() == 1 {
|
||||
return hashes[0];
|
||||
}
|
||||
let split = hashes.len().next_power_of_two() / 2;
|
||||
let mut hash = Sha256::new();
|
||||
hash.update([1]);
|
||||
hash.update(root(&hashes[..split]));
|
||||
hash.update(root(&hashes[split..]));
|
||||
hash.finalize().into()
|
||||
}
|
||||
|
||||
fn unescape(path: &str) -> String {
|
||||
let mut chars = path.chars();
|
||||
let mut output = String::new();
|
||||
while let Some(ch) = chars.next() {
|
||||
output.push(if ch == '!' {
|
||||
chars.next().unwrap().to_ascii_uppercase()
|
||||
} else {
|
||||
ch
|
||||
});
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
pub(super) fn seal(registry: &mut LocalRegistry) {
|
||||
registry
|
||||
.0
|
||||
.retain(|(_, path), _| !path.starts_with("https://sum.golang.org/"));
|
||||
let records = registry
|
||||
.0
|
||||
.iter()
|
||||
.filter_map(|((ecosystem, path), bytes)| {
|
||||
if *ecosystem != Ecosystem::Go || !path.ends_with(".mod") {
|
||||
return None;
|
||||
}
|
||||
let (module, version) = path.strip_prefix('/')?.split_once("/@v/v")?;
|
||||
let version = version.strip_suffix(".mod")?;
|
||||
let module = unescape(module);
|
||||
let archive = registry.0.get(&(
|
||||
Ecosystem::Go,
|
||||
format!("{}zip", path.strip_suffix("mod").unwrap()),
|
||||
))?;
|
||||
let record = format!(
|
||||
"{module} v{version} {}\n{module} v{version}/go.mod {}\n",
|
||||
sumdb::zip_hash(&module, version, archive).unwrap(),
|
||||
sumdb::mod_hash(bytes)
|
||||
);
|
||||
Some((
|
||||
format!(
|
||||
"/lookup/{}@v{version}",
|
||||
path.strip_prefix('/')
|
||||
.unwrap()
|
||||
.split("/@v/")
|
||||
.next()
|
||||
.unwrap()
|
||||
),
|
||||
record,
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let hashes = records
|
||||
.iter()
|
||||
.map(|(_, record)| leaf(record.as_bytes()))
|
||||
.collect::<Vec<_>>();
|
||||
let checkpoint = signed(records.len(), root(&hashes));
|
||||
registry.0.insert(
|
||||
(Ecosystem::Go, "https://sum.golang.org/latest".into()),
|
||||
checkpoint.as_bytes().to_vec(),
|
||||
);
|
||||
for (id, (path, record)) in records.iter().enumerate() {
|
||||
registry.0.insert(
|
||||
(Ecosystem::Go, format!("https://sum.golang.org{path}")),
|
||||
format!("{id}\n{record}\n{checkpoint}").into_bytes(),
|
||||
);
|
||||
}
|
||||
// Fixtures deliberately remain below one tile. Larger trees use official
|
||||
// captured vectors, not a second production tile-address implementation.
|
||||
assert!(hashes.len() < 256);
|
||||
if !hashes.is_empty() {
|
||||
registry.0.insert(
|
||||
(
|
||||
Ecosystem::Go,
|
||||
format!("https://sum.golang.org/tile/8/0/000.p/{}", hashes.len()),
|
||||
),
|
||||
hashes.iter().flatten().copied().collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Transport<'a> {
|
||||
pub registry: &'a dyn RegistryTransport,
|
||||
pub checksums: &'a LocalRegistry,
|
||||
}
|
||||
impl RegistryTransport for Transport<'_> {
|
||||
fn get(&self, ecosystem: Ecosystem, path: &str, limit: usize) -> Result<Vec<u8>, String> {
|
||||
if path.starts_with("https://sum.golang.org/") {
|
||||
self.checksums.get(ecosystem, path, limit)
|
||||
} else {
|
||||
self.registry.get(ecosystem, path, limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolved(
|
||||
registry: &LocalRegistry,
|
||||
previous: Option<String>,
|
||||
roots: &[&str],
|
||||
) -> Result<LockFile, String> {
|
||||
let cache = FixtureArtifactStore::default();
|
||||
let mut resolver = local_resolver(registry, &cache);
|
||||
resolver.go_sumdb = ChecksumDatabase::new(verifier(), previous)?;
|
||||
dever_cli::libs::LibResolver::resolve(
|
||||
&resolver,
|
||||
&roots
|
||||
.iter()
|
||||
.map(|root| root.parse().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_modules_replay_offline_and_reject_content_or_proof_drift() {
|
||||
let registry = local_registry();
|
||||
let lock = resolved(®istry, None, &["go:example.com/a@1.0.0"]).unwrap();
|
||||
let evidence = &lock.go_sumdb[0];
|
||||
let checked = evidence.verify(&verifier()).unwrap();
|
||||
let archive = ®istry.0[&(Ecosystem::Go, "/example.com/a/@v/v1.0.0.zip".into())];
|
||||
checked
|
||||
.verify_zip("example.com/a", "1.0.0", archive)
|
||||
.unwrap();
|
||||
assert!(
|
||||
evidence
|
||||
.verify(&Verifier::official())
|
||||
.unwrap_err()
|
||||
.contains("trusted signature")
|
||||
);
|
||||
let mut changed = evidence.clone();
|
||||
changed.records[0].go_mod.push_str("// changed\n");
|
||||
assert!(changed.verify(&verifier()).unwrap_err().contains("go.mod"));
|
||||
let mut changed = evidence.clone();
|
||||
changed.records[0].module = "wrong.example/a".into();
|
||||
assert!(
|
||||
changed
|
||||
.verify(&verifier())
|
||||
.unwrap_err()
|
||||
.contains("different module/version")
|
||||
);
|
||||
let mut changed = evidence.clone();
|
||||
changed.records[0].inclusion[0] = base64::engine::general_purpose::STANDARD.encode([0; 32]);
|
||||
assert!(
|
||||
changed
|
||||
.verify(&verifier())
|
||||
.unwrap_err()
|
||||
.contains("inclusion proof")
|
||||
);
|
||||
let mut changed = evidence.clone();
|
||||
changed.checkpoint = changed
|
||||
.checkpoint
|
||||
.replace("go.sum database tree", "go.sum database free");
|
||||
assert!(
|
||||
changed
|
||||
.verify(&verifier())
|
||||
.unwrap_err()
|
||||
.contains("signature verification")
|
||||
);
|
||||
assert!(
|
||||
checked
|
||||
.verify_zip(
|
||||
"example.com/a",
|
||||
"1.0.0",
|
||||
&zip_file("example.com/a@v1.0.0/changed.go")
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("sumdb h1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkpoint_updates_require_consistency_and_never_move_backwards() {
|
||||
let mut first = local_registry();
|
||||
first
|
||||
.0
|
||||
.remove(&(Ecosystem::Go, "/example.com/b/@v/v1.2.0.mod".into()));
|
||||
first.0.insert(
|
||||
(Ecosystem::Go, "/example.com/a/@v/v1.0.0.mod".into()),
|
||||
b"module example.com/a\n".to_vec(),
|
||||
);
|
||||
seal(&mut first);
|
||||
let old = resolved(&first, None, &["go:example.com/a@1.0.0"]).unwrap();
|
||||
let checkpoint = old.go_sumdb[0].verify(&verifier()).unwrap().checkpoint;
|
||||
let mut next = first;
|
||||
next.0.insert(
|
||||
(Ecosystem::Go, "/example.com/z/@v/v1.0.0.mod".into()),
|
||||
b"module example.com/z\n".to_vec(),
|
||||
);
|
||||
next.0.insert(
|
||||
(Ecosystem::Go, "/example.com/z/@v/v1.0.0.zip".into()),
|
||||
zip_file("example.com/z@v1.0.0/z.go"),
|
||||
);
|
||||
seal(&mut next);
|
||||
let new = resolved(&next, Some(checkpoint.clone()), &["go:example.com/z@1.0.0"]).unwrap();
|
||||
let current = new.go_sumdb[0].verify(&verifier()).unwrap().checkpoint;
|
||||
assert!(resolved(&next, Some(current.clone()), &["go:example.com/z@1.0.0"]).is_ok());
|
||||
next.0.insert(
|
||||
(Ecosystem::Go, "https://sum.golang.org/latest".into()),
|
||||
checkpoint.into_bytes(),
|
||||
);
|
||||
assert!(
|
||||
resolved(&next, Some(current), &["go:example.com/a@1.0.0"])
|
||||
.unwrap_err()
|
||||
.contains("rollback")
|
||||
);
|
||||
let mut fork = new.go_sumdb[0].clone();
|
||||
fork.consistency[0] = base64::engine::general_purpose::STANDARD.encode([0; 32]);
|
||||
assert!(
|
||||
fork.verify(&verifier())
|
||||
.unwrap_err()
|
||||
.contains("conflicting history")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sumdb_verifier_key_and_upstream_signed_note_vectors() {
|
||||
let note = "go.sum database tree\n63512409\nF7eHRPqN/cqPtqti14NKdGGaI1Giw8coWP3KxihFZbg=\n\n— sum.golang.org Az3gri1OBHKT87Xj8UXU8zjoHGy/X+7Nl94f8TZkELVaasolmGApUsWqwVG9vE6hgqYb/ph/w2vdkLIVRH7bMB/FPQ4=\n";
|
||||
let evidence = Evidence {
|
||||
previous: None,
|
||||
checkpoint: note.into(),
|
||||
consistency: vec![],
|
||||
records: vec![],
|
||||
};
|
||||
evidence.verify(&Verifier::official()).unwrap();
|
||||
assert!(evidence.verify(&verifier()).is_err());
|
||||
assert!(Verifier::new(&sumdb::OFFICIAL_VERIFIER.replace("033de0ae", "033de0af")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_large_tree_inclusion_and_consistency_vectors_verify_offline() {
|
||||
// Captured from sum.golang.org/lookup/github.com/google/uuid@v1.6.0
|
||||
// and its immutable height-8 tiles on 2026-10-02. Hashes were assembled
|
||||
// independently of this Rust implementation. Both notes retain Google's
|
||||
// original Ed25519 signatures; no fixture signer can mint these vectors.
|
||||
let evidence: Evidence = serde_json::from_str(include_str!("sumdb_uuid.json")).unwrap();
|
||||
let trusted = Verifier::official();
|
||||
let verified = evidence.verify(&trusted).unwrap();
|
||||
assert!(verified.contains("github.com/google/uuid", "1.6.0"));
|
||||
assert_eq!(
|
||||
sumdb::mod_hash(b"module github.com/google/uuid\n"),
|
||||
"h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo="
|
||||
);
|
||||
for index in 0..evidence.records[0].inclusion.len() {
|
||||
let mut changed = evidence.clone();
|
||||
changed.records[0].inclusion[index] =
|
||||
base64::engine::general_purpose::STANDARD.encode([0; 32]);
|
||||
assert!(
|
||||
changed.verify(&trusted).is_err(),
|
||||
"accepted corrupt inclusion hash {index}"
|
||||
);
|
||||
}
|
||||
for index in 0..evidence.consistency.len() {
|
||||
let mut changed = evidence.clone();
|
||||
changed.consistency[index] = base64::engine::general_purpose::STANDARD.encode([0; 32]);
|
||||
assert!(
|
||||
changed.verify(&trusted).is_err(),
|
||||
"accepted corrupt consistency hash {index}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_h1_ignores_zip_order_and_compression_and_preserves_module_case() {
|
||||
let archive = |reverse, compression| {
|
||||
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut files = vec![
|
||||
("a.go", b"package a\n".as_slice()),
|
||||
("LICENSE", b"fixture license\n".as_slice()),
|
||||
];
|
||||
if reverse {
|
||||
files.reverse();
|
||||
}
|
||||
for (name, content) in files {
|
||||
writer
|
||||
.start_file(
|
||||
format!("example.com/Mixed@v1.0.0/{name}"),
|
||||
zip::write::SimpleFileOptions::default().compression_method(compression),
|
||||
)
|
||||
.unwrap();
|
||||
writer.write_all(content).unwrap();
|
||||
}
|
||||
writer.finish().unwrap().into_inner()
|
||||
};
|
||||
let first = archive(false, zip::CompressionMethod::Stored);
|
||||
let second = archive(true, zip::CompressionMethod::Deflated);
|
||||
assert_ne!(digest(&first), digest(&second));
|
||||
assert_eq!(
|
||||
sumdb::zip_hash("example.com/Mixed", "1.0.0", &first).unwrap(),
|
||||
sumdb::zip_hash("example.com/Mixed", "1.0.0", &second).unwrap()
|
||||
);
|
||||
let mut registry = LocalRegistry(BTreeMap::from([
|
||||
(
|
||||
(Ecosystem::Go, "/example.com/!mixed/@v/v1.0.0.mod".into()),
|
||||
b"module example.com/Mixed\n".to_vec(),
|
||||
),
|
||||
(
|
||||
(Ecosystem::Go, "/example.com/!mixed/@v/v1.0.0.zip".into()),
|
||||
first,
|
||||
),
|
||||
]));
|
||||
seal(&mut registry);
|
||||
let lock = resolved(®istry, None, &["go:example.com/Mixed@1.0.0"]).unwrap();
|
||||
assert_eq!(lock.libs[0].spec.name, "example.com/Mixed");
|
||||
assert!(
|
||||
lock.go_sumdb[0]
|
||||
.verify(&verifier())
|
||||
.unwrap()
|
||||
.contains("example.com/Mixed", "1.0.0")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_go_mod_consulted_by_mvs_is_authenticated_even_when_not_selected() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
for (name, version, source) in [
|
||||
(
|
||||
"a",
|
||||
"1.0.0",
|
||||
"module example.com/a\nrequire (\nexample.com/b v1.0.0\nexample.com/c v1.0.0\n)\n",
|
||||
),
|
||||
("b", "1.0.0", "module example.com/b\n"),
|
||||
("b", "2.0.0", "module example.com/b\n"),
|
||||
(
|
||||
"c",
|
||||
"1.0.0",
|
||||
"module example.com/c\nrequire example.com/b v2.0.0\n",
|
||||
),
|
||||
] {
|
||||
registry.0.insert(
|
||||
(
|
||||
Ecosystem::Go,
|
||||
format!("/example.com/{name}/@v/v{version}.mod"),
|
||||
),
|
||||
source.as_bytes().to_vec(),
|
||||
);
|
||||
registry.0.insert(
|
||||
(
|
||||
Ecosystem::Go,
|
||||
format!("/example.com/{name}/@v/v{version}.zip"),
|
||||
),
|
||||
zip_file(&format!("example.com/{name}@v{version}/{name}.go")),
|
||||
);
|
||||
}
|
||||
seal(&mut registry);
|
||||
let lock = resolved(®istry, None, &["go:example.com/a@1.0.0"]).unwrap();
|
||||
assert!(
|
||||
!lock
|
||||
.libs
|
||||
.iter()
|
||||
.any(|lib| lib.spec.key() == "go:example.com/b@1.0.0")
|
||||
);
|
||||
assert!(
|
||||
lock.go_sumdb[0]
|
||||
.records
|
||||
.iter()
|
||||
.any(|record| record.module == "example.com/b" && record.version == "1.0.0")
|
||||
);
|
||||
registry
|
||||
.0
|
||||
.get_mut(&(Ecosystem::Go, "/example.com/b/@v/v1.0.0.mod".into()))
|
||||
.unwrap()
|
||||
.extend(b"// modified discarded metadata\n");
|
||||
let cache = FixtureArtifactStore::default();
|
||||
let resolver = local_resolver(®istry, &cache);
|
||||
assert!(
|
||||
dever_cli::libs::LibResolver::resolve(
|
||||
&resolver,
|
||||
&["go:example.com/a@1.0.0".parse().unwrap()]
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("go.mod does not match")
|
||||
);
|
||||
for ((_, path), bytes) in ®istry.0 {
|
||||
if path.ends_with(".zip") {
|
||||
assert!(!cache.has(&digest(bytes), "linux-x86_64").unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_hashzip_includes_empty_directory_entries() {
|
||||
let archive = |directory| {
|
||||
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
writer
|
||||
.start_file(
|
||||
"example.com/a@v1.0.0/a.go",
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
writer.write_all(b"package a\n").unwrap();
|
||||
if directory {
|
||||
writer
|
||||
.add_directory(
|
||||
"example.com/a@v1.0.0/empty/",
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
writer.finish().unwrap().into_inner()
|
||||
};
|
||||
// Independent Hash1 vectors: sorted "sha256(content) filename\n" records,
|
||||
// including the directory's trailing slash and SHA-256 of empty content.
|
||||
assert_eq!(
|
||||
sumdb::zip_hash("example.com/a", "1.0.0", &archive(false)).unwrap(),
|
||||
"h1:VEUcYKYLbKBYq0eA/VEJ3mMQBzi4jjWpusjuduGJcBQ="
|
||||
);
|
||||
assert_eq!(
|
||||
sumdb::zip_hash("example.com/a", "1.0.0", &archive(true)).unwrap(),
|
||||
"h1:y9ZWKWCmVsKnLxdFkIVoxr4MyXyHgBUvp1hgBzZ+IfE="
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_updates_and_removing_last_lib_preserve_official_checkpoint_anchor() {
|
||||
let root = TemporaryDirectory::new();
|
||||
let evidence: Evidence = serde_json::from_str(include_str!("sumdb_uuid.json")).unwrap();
|
||||
let mut lock = LockFile::new(Vec::new()).unwrap();
|
||||
lock.go_sumdb.push(evidence.clone());
|
||||
lock.write_atomic(root.path()).unwrap();
|
||||
let spec = vec!["pip:fixture@1.0.0".into()];
|
||||
for command in ["add", "update", "remove"] {
|
||||
dever_cli::libs::execute(command, root.path(), &spec).unwrap();
|
||||
let lock = LockFile::decode(&fs::read(root.path().join("dever.lock")).unwrap()).unwrap();
|
||||
assert_eq!(lock.go_sumdb, vec![evidence.clone()]);
|
||||
let current = sumdb::verify_chain(&lock.go_sumdb, &Verifier::official()).unwrap();
|
||||
let previous = evidence.previous.clone().unwrap();
|
||||
// A subsequent online resolution cannot accept the older official tree.
|
||||
let mut registry = local_registry();
|
||||
registry.0.insert(
|
||||
(Ecosystem::Go, "https://sum.golang.org/latest".into()),
|
||||
previous.into_bytes(),
|
||||
);
|
||||
let cache = FixtureArtifactStore::default();
|
||||
let mut resolver = local_resolver(®istry, &cache);
|
||||
resolver.go_sumdb = ChecksumDatabase::official(Some(current.checkpoint)).unwrap();
|
||||
assert!(
|
||||
dever_cli::libs::LibResolver::resolve(
|
||||
&resolver,
|
||||
&["go:example.com/a@1.0.0".parse().unwrap()]
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("rollback")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_publication_rejects_offline_byte_and_element_overflow_before_replacement() {
|
||||
let root = TemporaryDirectory::new();
|
||||
let original = LockFile::new(Vec::new()).unwrap();
|
||||
original.write_atomic(root.path()).unwrap();
|
||||
let path = root.path().join("dever.lock");
|
||||
let before = fs::read(&path).unwrap();
|
||||
let receipt = Evidence {
|
||||
previous: None,
|
||||
checkpoint: "x".into(),
|
||||
consistency: Vec::new(),
|
||||
records: Vec::new(),
|
||||
};
|
||||
for overflow in [true, false] {
|
||||
let mut lock = original.clone();
|
||||
let mut receipt = receipt.clone();
|
||||
if overflow {
|
||||
receipt.checkpoint = "x".repeat(dever_runtime::wire::MAX_BYTES);
|
||||
} else {
|
||||
receipt.consistency = vec!["x".into(); dever_runtime::wire::MAX_ELEMENTS];
|
||||
}
|
||||
lock.go_sumdb.push(receipt);
|
||||
assert!(lock.encode().unwrap_err().contains("invalid dever.lock"));
|
||||
assert!(lock.write_atomic(root.path()).is_err());
|
||||
assert_eq!(fs::read(&path).unwrap(), before);
|
||||
assert_eq!(fs::read_dir(root.path()).unwrap().count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_surplus_and_out_of_bounds_sumdb_proofs_are_rejected() {
|
||||
let original: Evidence = serde_json::from_str(include_str!("sumdb_uuid.json")).unwrap();
|
||||
let trusted = Verifier::official();
|
||||
let mut changed = original.clone();
|
||||
changed.records[0].inclusion.pop();
|
||||
assert!(changed.verify(&trusted).is_err());
|
||||
let mut changed = original.clone();
|
||||
changed.records[0]
|
||||
.inclusion
|
||||
.push(base64::engine::general_purpose::STANDARD.encode([0; 32]));
|
||||
assert!(changed.verify(&trusted).is_err());
|
||||
for id in ["66321505", "09223372036854775807", "9223372036854775808"] {
|
||||
let mut changed = original.clone();
|
||||
let (_, suffix) = changed.records[0].lookup.split_once('\n').unwrap();
|
||||
changed.records[0].lookup = format!("{id}\n{suffix}");
|
||||
assert!(changed.verify(&trusted).is_err());
|
||||
}
|
||||
let mut changed = original.clone();
|
||||
changed.checkpoint.pop();
|
||||
assert!(changed.verify(&trusted).is_err());
|
||||
let mut disconnected = original.clone();
|
||||
disconnected.previous = None;
|
||||
disconnected.consistency.clear();
|
||||
assert!(
|
||||
sumdb::verify_chain(&[original, disconnected], &trusted)
|
||||
.unwrap_err()
|
||||
.contains("not connected")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn lib_and_package_mutations_share_a_stable_nonblocking_project_lock() {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let root = TemporaryDirectory::new();
|
||||
let requested = vec!["pip:fixture@1.0.0".into()];
|
||||
dever_cli::libs::execute("add", root.path(), &requested).unwrap();
|
||||
let lock_path = root.path().join("dever.lock");
|
||||
let config_path = root.path().join("config/setting.json");
|
||||
let before_lock = fs::read(&lock_path).unwrap();
|
||||
let before_config = fs::read(&config_path).unwrap();
|
||||
let guard = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.mode(0o600)
|
||||
.open(root.path().join(".dever-project.lock"))
|
||||
.unwrap();
|
||||
fs2::FileExt::try_lock_exclusive(&guard).unwrap();
|
||||
for command in ["add", "update", "remove"] {
|
||||
assert!(
|
||||
dever_cli::libs::execute(command, root.path(), &requested)
|
||||
.unwrap_err()
|
||||
.contains("mutation is busy")
|
||||
);
|
||||
assert!(
|
||||
dever_cli::packages::execute(command, root.path(), &[])
|
||||
.unwrap_err()
|
||||
.contains("mutation is busy")
|
||||
);
|
||||
struct UnusedTransport;
|
||||
impl dever_cli::packages::PackageTransport for UnusedTransport {
|
||||
fn get(&self, _: &str, _: usize) -> Result<Vec<u8>, String> {
|
||||
panic!("busy mutation must not contact a registry")
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
dever_cli::packages::execute_with(
|
||||
command,
|
||||
root.path(),
|
||||
&[],
|
||||
&UnusedTransport,
|
||||
&FixtureArtifactStore::default()
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("mutation is busy")
|
||||
);
|
||||
}
|
||||
assert_eq!(fs::read(&lock_path).unwrap(), before_lock);
|
||||
assert_eq!(fs::read(&config_path).unwrap(), before_config);
|
||||
drop(guard);
|
||||
dever_cli::libs::execute("update", root.path(), &requested).unwrap();
|
||||
assert_eq!(fs::read(&lock_path).unwrap(), before_lock);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn project_mutation_rejects_linked_or_unprotected_lock_paths() {
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
let root = TemporaryDirectory::new();
|
||||
let outside = TemporaryDirectory::new();
|
||||
let target = outside.path().join("untouched");
|
||||
fs::write(&target, "keep").unwrap();
|
||||
let path = root.path().join(".dever-project.lock");
|
||||
symlink(&target, &path).unwrap();
|
||||
let requested = vec!["pip:fixture@1.0.0".into()];
|
||||
assert!(
|
||||
dever_cli::libs::execute("add", root.path(), &requested)
|
||||
.unwrap_err()
|
||||
.contains("without symlinks")
|
||||
);
|
||||
assert_eq!(fs::read(&target).unwrap(), b"keep");
|
||||
fs::remove_file(&path).unwrap();
|
||||
fs::hard_link(&target, &path).unwrap();
|
||||
assert!(
|
||||
dever_cli::libs::execute("add", root.path(), &requested)
|
||||
.unwrap_err()
|
||||
.contains("private and unlinked")
|
||||
);
|
||||
fs::remove_file(&path).unwrap();
|
||||
fs::write(&path, "").unwrap();
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o666)).unwrap();
|
||||
assert!(
|
||||
dever_cli::libs::execute("add", root.path(), &requested)
|
||||
.unwrap_err()
|
||||
.contains("private and unlinked")
|
||||
);
|
||||
fs::set_permissions(root.path(), fs::Permissions::from_mode(0o777)).unwrap();
|
||||
assert!(
|
||||
dever_cli::libs::execute("add", root.path(), &requested)
|
||||
.unwrap_err()
|
||||
.contains("not group/other writable")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_go_workers_merge_connected_offline_evidence() {
|
||||
use dever_cli::libs::{LockedWorker, resolve_workers};
|
||||
let registry = local_registry();
|
||||
let cache = FixtureArtifactStore::default();
|
||||
let resolver = local_resolver(®istry, &cache);
|
||||
let worker = |adapter: &str, spec: &str| LockedWorker {
|
||||
port: "sample.worker".into(),
|
||||
adapter: adapter.into(),
|
||||
ecosystem: "go".into(),
|
||||
runtime: Some(registry_runtime(Ecosystem::Go).pack),
|
||||
entry: format!("worker/{adapter}.go"),
|
||||
schema: "checked-port".into(),
|
||||
capabilities: vec![],
|
||||
operations: vec!["read".into()],
|
||||
libs: vec![spec.parse().unwrap()],
|
||||
};
|
||||
let lock = resolve_workers(
|
||||
&resolver,
|
||||
&[],
|
||||
vec![
|
||||
worker("first", "go:example.com/a@1.0.0"),
|
||||
worker("second", "go:example.com/b@1.2.0"),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(lock.go_sumdb.len(), 2);
|
||||
assert_eq!(
|
||||
lock.go_sumdb[1].previous.as_ref(),
|
||||
Some(&lock.go_sumdb[0].verify(&verifier()).unwrap().checkpoint)
|
||||
);
|
||||
let decoded = LockFile::decode(&lock.encode().unwrap()).unwrap();
|
||||
let verified = sumdb::verify_chain(&decoded.go_sumdb, &verifier()).unwrap();
|
||||
assert!(verified.contains("example.com/a", "1.0.0"));
|
||||
assert!(verified.contains("example.com/b", "1.2.0"));
|
||||
verify_locked_artifacts(&decoded, &cache, "linux-x86_64").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "author operation: explicitly fetches official Go proxy/sumdb; requires target/go-managed/config/setting.json and runtime.pack"]
|
||||
fn prepare_official_go_dependency_fixture() {
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Settings {
|
||||
runtime: RegistryRuntime,
|
||||
}
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/go-managed");
|
||||
let settings: Settings = serde_json::from_slice(
|
||||
&fs::read(root.join("config/setting.json"))
|
||||
.expect("prepare the explicit fixture settings first"),
|
||||
)
|
||||
.unwrap();
|
||||
let pack =
|
||||
fs::read(root.join("runtime.pack")).expect("prepare the explicit Go runtime pack first");
|
||||
assert_eq!(
|
||||
settings.runtime.pack.sha256,
|
||||
digest(&pack),
|
||||
"configured runtime does not match runtime.pack"
|
||||
);
|
||||
assert_eq!(
|
||||
settings.runtime.target,
|
||||
dever_cli::toolchain::platform_identity()
|
||||
);
|
||||
let output = root.join("dependency");
|
||||
let lock_path = output.join("dever.lock");
|
||||
let previous = if lock_path.exists() {
|
||||
let lock = LockFile::decode(&fs::read(&lock_path).unwrap()).unwrap();
|
||||
dever_cli::libs::doctor(&lock).unwrap();
|
||||
let verified = sumdb::verify_chain(&lock.go_sumdb, &Verifier::official()).unwrap();
|
||||
(!verified.checkpoint.is_empty()).then_some(verified.checkpoint)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let target = settings.runtime.target.clone();
|
||||
let store = FixtureArtifactStore::default();
|
||||
let transport = dever_cli::libs::HttpRegistry::official();
|
||||
let resolver = RegistryResolver {
|
||||
build: None,
|
||||
transport: &transport,
|
||||
store: &store,
|
||||
runtimes: BTreeMap::from([(Ecosystem::Go, settings.runtime)]),
|
||||
go_sumdb: ChecksumDatabase::official(previous).unwrap(),
|
||||
};
|
||||
let lock = dever_cli::libs::LibResolver::resolve(
|
||||
&resolver,
|
||||
&["go:github.com/google/uuid@1.6.0".parse().unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
// The same official verifier and offline artifact checks protect normal builds.
|
||||
dever_cli::libs::doctor(&lock).unwrap();
|
||||
dever_cli::libs::verify_locked_artifacts(&lock, &store, &target).unwrap();
|
||||
publish_fixture_artifacts(&output, &lock, &store);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"previous": "go.sum database tree\n63512409\nF7eHRPqN/cqPtqti14NKdGGaI1Giw8coWP3KxihFZbg=\n\n— sum.golang.org Az3gri1OBHKT87Xj8UXU8zjoHGy/X+7Nl94f8TZkELVaasolmGApUsWqwVG9vE6hgqYb/ph/w2vdkLIVRH7bMB/FPQ4=\n",
|
||||
"checkpoint": "go.sum database tree\n66321505\nnL/ZK4zIq2TWUhMi/cDJQRq6hXj67qFBbyEqxks3epY=\n\n— sum.golang.org Az3gruvaP9rIy6foXnttmnViaXM55mqUcP66AeP7N5r+tpFo2ll40wDr7sN0guXk6rXp0fL63fe30pchMCTqmbRo+Ak=\n",
|
||||
"consistency": [
|
||||
"3OyjS9XS6r41pxaFWGnW/WSmX7b1ik3nupquDNipdZ8=",
|
||||
"zX5IsB02+IutT35ka/MwY7gFiPSAlihfnt2cu6smUjw=",
|
||||
"Qc0CqO89A+/onGz1u4ZsDoMOfRIgPZpayRGTETOv8kk=",
|
||||
"JhdrRO/y8Iu+MDeUupFG0uM4+odAADMjVH0mOgLOA/c=",
|
||||
"lOgywJvTdrBWol0uphM6ZulvAebwJGNlekm8MIwiwKM=",
|
||||
"FRf0N51Fw+8eSLHEa/e/AsnDway0EkKo4jI0E1Ne7dk=",
|
||||
"Ak0gyC+RS6/ahVBCBuSBDA1wFN9mgq3acIbYm9uJHQ4=",
|
||||
"FFJ8WrKTTQgCjTuAl5CukBdjDaQ7opSdHBpNSolRYWw=",
|
||||
"rdNcd+MUjUxzlX/nlVGeQP5BkkZyTT89sn/yfg0AXQ0=",
|
||||
"gJzOh5y5rP1BILUrFJw+aGjod840PErDnjy4T0BoUE4=",
|
||||
"7I8BPI+ADxeJmu19mQxO+xt6bNhg3Ueq1j2dIfOppwA=",
|
||||
"urhcUF9QOyTRAYRjJjsRHjHdwIc+BxfiQ6a+NuYeC6E=",
|
||||
"p9eGEUHXeKLFnrIPB7VXHHMuwtaBnvHG4CYwgC0wq+w=",
|
||||
"Gcry0ScaNfFmQujPMT95RQ3rbzKDSynQsL546C8CkfU=",
|
||||
"dIpym+JRT/+z9/tlhpuIRyv+uoWGqgND/MHw0f1MtqA=",
|
||||
"cQrKLJIBbPoWDnRnbGfAEMfpsau0h+Aw3aqDOJ5LAVU=",
|
||||
"C46761Ui7jL+lf4vRXBlveoxuFS6tykAd6mMEfNttPE=",
|
||||
"45Kz4HcZ9iZ84ySMHCy21d4vd2MvibvfNMzYm0hKYRs=",
|
||||
"4JZTJ+4VUtLMTEs11jn4GmCxoz56Lm5HDwkYmkmAWlw=",
|
||||
"2hJV4h1jZ2YujjyXUw+7YYCwLXGYZy+ondhRUgxKmXI=",
|
||||
"lrn/kRfgYdYSDxgPdSQ8/GnZw6u/joKu9++AR+Saa5M=",
|
||||
"hnm9+kaHQdhHWrAcyrvY5fbv8QB+GwVjRMFeobtTg8A=",
|
||||
"rPFmKL/vD6Edsk/0DmpvDa/nMOqWgjfpPGRjBUL9yKs=",
|
||||
"2IzbyezmwwGZZPJ276ejAQZiAnar+RcjAUU3MEMtbIA=",
|
||||
"k3kfr9AECmkV+UA3/D6EMqaBDr67aubL5hXGsvJ+Nng=",
|
||||
"/WnQtddEqG03mbcU1XID1iws1nlKHVkJcaREWknCOiE=",
|
||||
"xJ20xXM4QItt5vxFOtolZ5l18p7ej1+RimNkgWmoRFM="
|
||||
],
|
||||
"records": [{
|
||||
"module": "github.com/google/uuid",
|
||||
"version": "1.6.0",
|
||||
"go_mod": "module github.com/google/uuid\n",
|
||||
"lookup": "22152757\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\n\ngo.sum database tree\n66321505\nnL/ZK4zIq2TWUhMi/cDJQRq6hXj67qFBbyEqxks3epY=\n\n— sum.golang.org Az3gruvaP9rIy6foXnttmnViaXM55mqUcP66AeP7N5r+tpFo2ll40wDr7sN0guXk6rXp0fL63fe30pchMCTqmbRo+Ak=\n",
|
||||
"inclusion": [
|
||||
"vYU6vRfhcMyd5/U04n+AYg52mm3BQrjwyN+27saiSnU=",
|
||||
"syWiyNbj6gybIdLKoAFd7xF3qNltS7OhQjtp4y8rCFg=",
|
||||
"Zqpa9zJsOg/sI/mv0UkXO9d7gvPHLLjMAKnZCPzeiNM=",
|
||||
"tGbcXa1AQlYBIm5gM2KDy8JvdAFDizj4lUop18Eehxs=",
|
||||
"/w9FG1Sae/omKxZdOvj719sT1rCow3tlP8VNo0fDQtE=",
|
||||
"dR6Bk+gh/hyJx7bu65HfhQdfuvT/OdVv/G7RUsOc26Y=",
|
||||
"fRC86/2mWWtFWNPz/Vou6f7hdyvRLHFPo9KaOlVOVmc=",
|
||||
"jJS2b3XVP2sst4CS5srpnF34agHpiluU63h6UQyFPDc=",
|
||||
"cOL2+zAXTEOaAiyvmCrL3Ned6soAg08CJJJ2VW3+a18=",
|
||||
"In5NzPUzIAZfV6bnUL11Bkm4Aqoit/ZG7gJ8nxOdB2w=",
|
||||
"r+8IH0m7Ig3BS+uwCVVG1CZT0I/Rro3bbSUVYcSrN2Q=",
|
||||
"wOgTamt00bdokE1Nbv3aHYu2um06V1xdLFEmOBjt0GI=",
|
||||
"21woobbbCvogV/ZXACdAozfVAFjRv5xMID/iLcryHzw=",
|
||||
"5+o2gn9/zVod+MYr4UuoeMzuz68vt/hA5+9H4YZaVME=",
|
||||
"040B+RrLQeFCJhPU69kzoKr4BTqhSwdrnYEVhknZ+I0=",
|
||||
"ioI6UByPBEr2lEZB0dI6pCXgcNss6UkKxm+TYAADujg=",
|
||||
"HOJ0dLK4hnl0tFeu1tmT7TRrNKRIbi/T8KI/2s4DXDs=",
|
||||
"BBcHtB4mS19jHff/LQTNI/+WCVBZ1Up52gNqyoln71U=",
|
||||
"Oz/OlF1Lj7wkqqE15p324ZSX1XHcnLzDSMKNtUx6rwY=",
|
||||
"ofpmTAcvFvNzlwjZnsrMqeG4iIBCP2lt5wIusvZ2gG4=",
|
||||
"IRpVS0aap6n0qb02jyOy6ikkOPxdFIQtWwckaGF2MAQ=",
|
||||
"liRusDUG3TanNZLGkfjb58ppX/6d40M7X2s3wptjxMw=",
|
||||
"ROjUzjXHDN0Yo/I6EeqHhpszFgIs7FCABVeQniliPTk=",
|
||||
"NTjIv8CJqIIGrOHhNQWSP9FV6XEPL6uGA4lmMfNKGJg=",
|
||||
"bwqUQG2wJXGxo1QLA2CFb3chH17xvlqXe6eQy43p0HY=",
|
||||
"bqa+4xGvzGcI/PYne2fXfUvdULgy8o3zQmD13q/gu1Y="
|
||||
],
|
||||
"consistency": []
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
use super::*;
|
||||
use dever_cli::workers::python_wheel::Wheel;
|
||||
|
||||
fn validate_native(
|
||||
target: &str,
|
||||
suffixes: &[String],
|
||||
interpreter: &str,
|
||||
files: &BTreeMap<String, &[u8]>,
|
||||
) -> Result<(), String> {
|
||||
let wheel_files = files
|
||||
.keys()
|
||||
.filter(|path| !path.starts_with("runtime/") && !path.starts_with("sandbox/"))
|
||||
.cloned()
|
||||
.collect();
|
||||
dever_cli::workers::python_wheel::validate_native(
|
||||
target,
|
||||
suffixes,
|
||||
interpreter,
|
||||
files,
|
||||
&wheel_files,
|
||||
)
|
||||
}
|
||||
|
||||
fn spec() -> LibSpec {
|
||||
"pip:sample@1.0.0".parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_console_scripts_use_the_same_private_prefix_and_record_generated_files() {
|
||||
let bytes=wheel_fixture::pack("sample","1.0.0","Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
|
||||
&wheel_fixture::metadata("sample","1.0.0",&[],&[]),&[("sample-1.0.0.dist-info/entry_points.txt",b"[console_scripts]\nsample-tool = sample.cli:main\n[gui_scripts]\nsample-gui = sample.gui:App.run [desktop]\n".to_vec())]);
|
||||
let installed = Wheel::parse(&spec(), None, &tags(), &bytes)
|
||||
.unwrap()
|
||||
.install("runtime/bin/python3", "3.12")
|
||||
.unwrap();
|
||||
let script = installed
|
||||
.iter()
|
||||
.find(|file| file.path == "runtime/bin/sample-tool")
|
||||
.unwrap();
|
||||
assert!(script.executable);
|
||||
assert!(
|
||||
script
|
||||
.bytes
|
||||
.starts_with(b"#!/worker/runtime/bin/python3 -I\n")
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&script.bytes).contains("sample.cli"));
|
||||
let record = installed
|
||||
.iter()
|
||||
.find(|file| file.path.ends_with(".dist-info/RECORD"))
|
||||
.unwrap();
|
||||
assert!(String::from_utf8_lossy(&record.bytes).contains("../../../bin/sample-tool,sha256="));
|
||||
let collision = wheel_fixture::pack(
|
||||
"sample",
|
||||
"1.0.0",
|
||||
"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
|
||||
&wheel_fixture::metadata("sample", "1.0.0", &[], &[]),
|
||||
&[
|
||||
(
|
||||
"sample-1.0.0.dist-info/entry_points.txt",
|
||||
b"[console_scripts]\ntool = sample:main\n".to_vec(),
|
||||
),
|
||||
("sample-1.0.0.data/scripts/tool", b"#!python\n".to_vec()),
|
||||
],
|
||||
);
|
||||
assert!(
|
||||
Wheel::parse(&spec(), None, &tags(), &collision)
|
||||
.unwrap()
|
||||
.install("runtime/bin/python3", "3.12")
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
fn tags() -> Vec<String> {
|
||||
vec!["py3-none-any".into()]
|
||||
}
|
||||
fn valid() -> Vec<u8> {
|
||||
pure_wheel("sample", "1.0.0", &[], &[])
|
||||
}
|
||||
fn unpack(bytes: &[u8]) -> Vec<(String, Vec<u8>)> {
|
||||
use std::io::Read;
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
(0..archive.len())
|
||||
.map(|index| {
|
||||
let mut file = archive.by_index(index).unwrap();
|
||||
let name = file.name().to_owned();
|
||||
let mut bytes = vec![];
|
||||
file.read_to_end(&mut bytes).unwrap();
|
||||
(name, bytes)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_identity_and_all_compressed_filename_tags_are_bound_to_metadata() {
|
||||
Wheel::parse(
|
||||
&spec(),
|
||||
Some("sample-1.0.0-py3-none-any.whl"),
|
||||
&tags(),
|
||||
&valid(),
|
||||
)
|
||||
.unwrap();
|
||||
for filename in [
|
||||
"other-1.0.0-py3-none-any.whl",
|
||||
"sample-2.0.0-py3-none-any.whl",
|
||||
"sample-1.0.0-py2.py3-none-any.whl",
|
||||
"sample-1.0.0-1-py3-none-any.whl",
|
||||
] {
|
||||
assert!(
|
||||
Wheel::parse(&spec(), Some(filename), &tags(), &valid()).is_err(),
|
||||
"{filename}"
|
||||
);
|
||||
}
|
||||
let compressed = wheel_fixture::pack(
|
||||
"sample",
|
||||
"1.0.0",
|
||||
"Wheel-Version: 1.0\nRoot-Is-Purelib: false\nTag: py2-none-any\nTag: py3-none-any\n",
|
||||
&wheel_fixture::metadata("sample", "1.0.0", &[], &[]),
|
||||
&[],
|
||||
);
|
||||
Wheel::parse(
|
||||
&spec(),
|
||||
Some("sample-1.0.0-py2.py3-none-any.whl"),
|
||||
&tags(),
|
||||
&compressed,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
Wheel::parse(
|
||||
&spec(),
|
||||
None,
|
||||
&["cp312-cp312-linux_x86_64".into()],
|
||||
&valid()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let forged_metadata = wheel_fixture::pack(
|
||||
"sample",
|
||||
"1.0.0",
|
||||
"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
|
||||
&wheel_fixture::metadata("other", "1.0.0", &[], &[]),
|
||||
&[],
|
||||
);
|
||||
assert!(Wheel::parse(&spec(), None, &tags(), &forged_metadata).is_err());
|
||||
let hidden_binary = wheel_fixture::pack(
|
||||
"sample",
|
||||
"1.0.0",
|
||||
"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
|
||||
&wheel_fixture::metadata("sample", "1.0.0", &[], &[]),
|
||||
&[("sample/native.so", elf(62, None, None))],
|
||||
);
|
||||
assert!(Wheel::parse(&spec(), None, &tags(), &hidden_binary).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "author operation: explicitly fetches PyPI simplejson; requires target/python-managed/config/setting.json and runtime.pack"]
|
||||
fn prepare_official_native_python_fixture() {
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Settings {
|
||||
runtime: RegistryRuntime,
|
||||
}
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/python-managed");
|
||||
let settings: Settings =
|
||||
serde_json::from_slice(&fs::read(root.join("config/setting.json")).unwrap()).unwrap();
|
||||
let pack = fs::read(root.join("runtime.pack")).unwrap();
|
||||
assert_eq!(digest(&pack), settings.runtime.pack.sha256);
|
||||
assert_eq!(
|
||||
settings.runtime.target,
|
||||
dever_cli::toolchain::platform_identity()
|
||||
);
|
||||
let target = settings.runtime.target.clone();
|
||||
let store = FixtureArtifactStore::default();
|
||||
let transport = dever_cli::libs::HttpRegistry::official();
|
||||
let resolver = RegistryResolver {
|
||||
build: None,
|
||||
transport: &transport,
|
||||
store: &store,
|
||||
runtimes: BTreeMap::from([(Ecosystem::Pip, settings.runtime)]),
|
||||
go_sumdb: dever_cli::libs::sumdb::ChecksumDatabase::official(None).unwrap(),
|
||||
};
|
||||
let lock = dever_cli::libs::LibResolver::resolve(
|
||||
&resolver,
|
||||
&["pip:simplejson@3.20.1".parse().unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
dever_cli::libs::doctor(&lock).unwrap();
|
||||
dever_cli::libs::verify_locked_artifacts(&lock, &store, &target).unwrap();
|
||||
let artifact = &lock.libs[0].artifacts[0];
|
||||
let wheel = store
|
||||
.verify_exact(&artifact.sha256, artifact.bytes, &target)
|
||||
.unwrap();
|
||||
assert!(
|
||||
unpack(&wheel)
|
||||
.iter()
|
||||
.any(|(path, bytes)| path.contains("_speedups.")
|
||||
&& path.ends_with(".so")
|
||||
&& bytes.starts_with(b"\x7fELF")),
|
||||
"fixture must use native speedups, not the optional pure-Python fallback"
|
||||
);
|
||||
publish_fixture_artifacts(&root.join("dependency"), &lock, &store);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_record_requires_exact_files_hashes_sizes_and_unique_csv_paths() {
|
||||
for mutation in 0..6 {
|
||||
let mut files = unpack(&valid());
|
||||
match mutation {
|
||||
0 => files
|
||||
.iter_mut()
|
||||
.find(|(path, _)| path.ends_with("__init__.py"))
|
||||
.unwrap()
|
||||
.1
|
||||
.push(b'!'),
|
||||
1 => files.push(("sample/unlisted.py".into(), vec![b'x'])),
|
||||
2 => {
|
||||
files.retain(|(path, _)| !path.ends_with("__init__.py"));
|
||||
}
|
||||
_ => {
|
||||
let record = &mut files
|
||||
.iter_mut()
|
||||
.find(|(path, _)| path.ends_with("/RECORD"))
|
||||
.unwrap()
|
||||
.1;
|
||||
let mut content = String::from_utf8(record.clone()).unwrap();
|
||||
match mutation {
|
||||
3 => content = content.replace("sha256=", "sha1="),
|
||||
4 => content.push_str("sample-1.0.0.dist-info/RECORD,,\n"),
|
||||
5 => content.push_str("../outside.py,sha256=abc,1\n"),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
*record = content.into_bytes();
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
Wheel::parse(&spec(), None, &tags(), &wheel_fixture::zip(&files)).is_err(),
|
||||
"mutation {mutation}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_installs_all_standard_schemes_and_rewrites_installed_record() {
|
||||
let bytes = wheel_fixture::pack(
|
||||
"sample",
|
||||
"1.0.0",
|
||||
"Wheel-Version: 1.0\nRoot-Is-Purelib: false\nTag: py3-none-any\n",
|
||||
&wheel_fixture::metadata("sample", "1.0.0", &[], &[]),
|
||||
&[
|
||||
("sample-1.0.0.data/purelib/plain.py", b"plain".to_vec()),
|
||||
(
|
||||
"sample-1.0.0.data/platlib/platform.py",
|
||||
b"platform".to_vec(),
|
||||
),
|
||||
(
|
||||
"sample-1.0.0.data/scripts/tool",
|
||||
b"#!python\nprint('ok')\n".to_vec(),
|
||||
),
|
||||
("sample-1.0.0.data/headers/public.h", b"header".to_vec()),
|
||||
(
|
||||
"sample-1.0.0.data/data/share/sample/schema.json",
|
||||
b"{}".to_vec(),
|
||||
),
|
||||
("sample/name,with\"quotes.py", b"quoted".to_vec()),
|
||||
],
|
||||
);
|
||||
let files = Wheel::parse(&spec(), None, &tags(), &bytes)
|
||||
.unwrap()
|
||||
.install("runtime/bin/python3", "3.12")
|
||||
.unwrap();
|
||||
for path in [
|
||||
"runtime/lib/python3.12/site-packages/plain.py",
|
||||
"runtime/lib/python3.12/site-packages/platform.py",
|
||||
"runtime/include/python3.12/sample/public.h",
|
||||
"runtime/share/sample/schema.json",
|
||||
"runtime/lib/python3.12/site-packages/sample/name,with\"quotes.py",
|
||||
] {
|
||||
assert!(files.iter().any(|file| file.path == path), "{path}");
|
||||
}
|
||||
let script = files
|
||||
.iter()
|
||||
.find(|file| file.path == "runtime/bin/tool")
|
||||
.unwrap();
|
||||
assert!(script.executable);
|
||||
assert!(
|
||||
script
|
||||
.bytes
|
||||
.starts_with(b"#!/worker/runtime/bin/python3 -I\n")
|
||||
);
|
||||
let record = files
|
||||
.iter()
|
||||
.find(|file| file.path.ends_with("/RECORD"))
|
||||
.unwrap();
|
||||
let rows = csv::ReaderBuilder::new()
|
||||
.has_headers(false)
|
||||
.from_reader(record.bytes.as_slice())
|
||||
.records()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
let installed_script = rows
|
||||
.iter()
|
||||
.find(|row| &row[0] == "../../../bin/tool")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
installed_script[1].to_owned(),
|
||||
format!(
|
||||
"sha256={}",
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(Sha256::digest(&script.bytes))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_rejects_scheme_collisions_and_uses_artifact_dependencies() {
|
||||
let bytes = wheel_fixture::pack(
|
||||
"sample",
|
||||
"1.0.0",
|
||||
"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
|
||||
&wheel_fixture::metadata("sample", "1.0.0", &[], &[]),
|
||||
&[
|
||||
("same.py", vec![b'a']),
|
||||
("sample-1.0.0.data/platlib/same.py", vec![b'b']),
|
||||
],
|
||||
);
|
||||
assert!(
|
||||
Wheel::parse(&spec(), None, &tags(), &bytes)
|
||||
.unwrap()
|
||||
.install("runtime/bin/python3", "3.12")
|
||||
.is_err()
|
||||
);
|
||||
let mut registry = local_registry();
|
||||
let release = registry
|
||||
.0
|
||||
.get_mut(&(Ecosystem::Pip, "/pypi/a/1.0.0/json".into()))
|
||||
.unwrap();
|
||||
let mut document: serde_json::Value = serde_json::from_slice(release).unwrap();
|
||||
document["info"]["requires_dist"] = serde_json::json!([]);
|
||||
*release = serde_json::to_vec(&document).unwrap();
|
||||
let lock = python_lock(®istry, &["pip:a@1.0.0"]).unwrap();
|
||||
assert_eq!(dependency_keys(&lock, "pip:a@1.0.0"), ["pip:b@1.1.0"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_backtracks_on_selected_wheel_metadata_and_its_actual_extras() {
|
||||
let mut registry = LocalRegistry(BTreeMap::new());
|
||||
python_release(
|
||||
&mut registry,
|
||||
"root",
|
||||
"1.0.0",
|
||||
&[],
|
||||
&["a[x] >=1", "b ==1.0.0"],
|
||||
);
|
||||
python_release(
|
||||
&mut registry,
|
||||
"a",
|
||||
"2.0.0",
|
||||
&["x"],
|
||||
&["b ==2.0.0; extra == 'x'"],
|
||||
);
|
||||
python_release(
|
||||
&mut registry,
|
||||
"a",
|
||||
"1.0.0",
|
||||
&["x"],
|
||||
&["b ==1.0.0; extra == 'x'"],
|
||||
);
|
||||
python_release(&mut registry, "b", "2.0.0", &[], &[]);
|
||||
python_release(&mut registry, "b", "1.0.0", &[], &[]);
|
||||
for version in ["1.0.0", "2.0.0"] {
|
||||
let json = registry
|
||||
.0
|
||||
.get_mut(&(Ecosystem::Pip, format!("/pypi/a/{version}/json")))
|
||||
.unwrap();
|
||||
let mut release: serde_json::Value = serde_json::from_slice(json).unwrap();
|
||||
release["info"] = serde_json::json!({"requires_dist":null,"provides_extra":null});
|
||||
*json = serde_json::to_vec(&release).unwrap();
|
||||
}
|
||||
let lock = python_lock(®istry, &["pip:root@1.0.0"]).unwrap();
|
||||
assert!(
|
||||
lock.libs
|
||||
.iter()
|
||||
.any(|lib| lib.spec.key() == "pip:a[x]@1.0.0")
|
||||
);
|
||||
assert!(!lock.libs.iter().any(|lib| lib.spec.version == "2.0.0"));
|
||||
assert_eq!(dependency_keys(&lock, "pip:a[x]@1.0.0"), ["pip:b@1.0.0"]);
|
||||
}
|
||||
|
||||
/// ELF64 protocol fixture with PT_LOAD and optional PT_DYNAMIC/DT_STRTAB.
|
||||
/// It is parsed only; no executable bytes are launched by this test.
|
||||
fn elf(machine: u16, needed: Option<&str>, rpath: Option<&str>) -> Vec<u8> {
|
||||
let mut bytes = vec![0u8; 1024];
|
||||
bytes[..7].copy_from_slice(b"\x7fELF\x02\x01\x01");
|
||||
bytes[16..18].copy_from_slice(&3u16.to_le_bytes());
|
||||
bytes[18..20].copy_from_slice(&machine.to_le_bytes());
|
||||
bytes[20..24].copy_from_slice(&1u32.to_le_bytes());
|
||||
bytes[32..40].copy_from_slice(&64u64.to_le_bytes());
|
||||
bytes[52..54].copy_from_slice(&64u16.to_le_bytes());
|
||||
bytes[54..56].copy_from_slice(&56u16.to_le_bytes());
|
||||
bytes[56..58].copy_from_slice(&2u16.to_le_bytes());
|
||||
bytes[64..68].copy_from_slice(&1u32.to_le_bytes());
|
||||
bytes[96..104].copy_from_slice(&1024u64.to_le_bytes());
|
||||
bytes[104..112].copy_from_slice(&1024u64.to_le_bytes());
|
||||
bytes[120..124].copy_from_slice(&2u32.to_le_bytes());
|
||||
bytes[128..136].copy_from_slice(&176u64.to_le_bytes());
|
||||
bytes[136..144].copy_from_slice(&176u64.to_le_bytes());
|
||||
let mut strings = vec![0];
|
||||
let mut dynamic = vec![];
|
||||
for (tag, value) in [(1u64, needed), (15u64, rpath)] {
|
||||
if let Some(value) = value {
|
||||
dynamic.push((tag, strings.len() as u64));
|
||||
strings.extend(value.bytes());
|
||||
strings.push(0);
|
||||
}
|
||||
}
|
||||
dynamic.extend([(5, 512), (10, strings.len() as u64), (0, 0)]);
|
||||
bytes[152..160].copy_from_slice(&((dynamic.len() * 16) as u64).to_le_bytes());
|
||||
bytes[160..168].copy_from_slice(&((dynamic.len() * 16) as u64).to_le_bytes());
|
||||
for (index, (tag, value)) in dynamic.into_iter().enumerate() {
|
||||
bytes[176 + index * 16..184 + index * 16].copy_from_slice(&tag.to_le_bytes());
|
||||
bytes[184 + index * 16..192 + index * 16].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
bytes[512..512 + strings.len()].copy_from_slice(&strings);
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_wheel_checks_target_suffix_and_closed_library_search() {
|
||||
let runtime = elf(62, None, None);
|
||||
let library = elf(62, None, None);
|
||||
let native = elf(62, Some("libsample.so.1"), Some("$ORIGIN/../sample.libs"));
|
||||
let suffixes = vec![
|
||||
".cpython-312-x86_64-linux-gnu.so".into(),
|
||||
".abi3.so".into(),
|
||||
".so".into(),
|
||||
];
|
||||
let mut files = BTreeMap::from([
|
||||
("runtime/bin/python3".into(), runtime.as_slice()),
|
||||
(
|
||||
"site-packages/sample/_native.cpython-312-x86_64-linux-gnu.so".into(),
|
||||
native.as_slice(),
|
||||
),
|
||||
(
|
||||
"site-packages/sample.libs/libsample.so.1".into(),
|
||||
library.as_slice(),
|
||||
),
|
||||
]);
|
||||
validate_native("linux-x86_64", &suffixes, "runtime/bin/python3", &files).unwrap();
|
||||
files.remove("site-packages/sample.libs/libsample.so.1");
|
||||
assert!(
|
||||
validate_native("linux-x86_64", &suffixes, "runtime/bin/python3", &files)
|
||||
.unwrap_err()
|
||||
.contains("packaged dependency closure")
|
||||
);
|
||||
files.insert("sandbox/lib/libsample.so.1".into(), library.as_slice());
|
||||
validate_native("linux-x86_64", &suffixes, "runtime/bin/python3", &files).unwrap();
|
||||
let old = files
|
||||
.remove("site-packages/sample/_native.cpython-312-x86_64-linux-gnu.so")
|
||||
.unwrap();
|
||||
files.insert(
|
||||
"site-packages/sample/_native.cpython-311-x86_64-linux-gnu.so".into(),
|
||||
old,
|
||||
);
|
||||
assert!(
|
||||
validate_native("linux-x86_64", &suffixes, "runtime/bin/python3", &files)
|
||||
.unwrap_err()
|
||||
.contains("suffix")
|
||||
);
|
||||
for (machine, rpath, message) in [
|
||||
(183, "$ORIGIN", "target"),
|
||||
(62, "/usr/lib", "$ORIGIN"),
|
||||
(62, "$ORIGIN/../../../..", "escapes"),
|
||||
] {
|
||||
let rejected = elf(machine, None, Some(rpath));
|
||||
let files = BTreeMap::from([
|
||||
("runtime/bin/python3".into(), runtime.as_slice()),
|
||||
("site-packages/sample/native.so".into(), rejected.as_slice()),
|
||||
]);
|
||||
assert!(
|
||||
validate_native("linux-x86_64", &suffixes, "runtime/bin/python3", &files)
|
||||
.unwrap_err()
|
||||
.contains(message)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_runpath_outranks_ancestor_rpath_and_helpers_keep_import_context() {
|
||||
let runtime = elf(62, None, Some("$ORIGIN/../lib"));
|
||||
let shared = elf(62, None, None);
|
||||
let wrong_target = elf(183, None, None);
|
||||
let mut extension = elf(
|
||||
62,
|
||||
Some("libsample.so.1"),
|
||||
Some("$ORIGIN/../../runtime/alternate"),
|
||||
);
|
||||
// Replace DT_RPATH with DT_RUNPATH in the independently encoded ELF vector.
|
||||
for offset in (176..256).step_by(16) {
|
||||
if u64::from_le_bytes(extension[offset..offset + 8].try_into().unwrap()) == 15 {
|
||||
extension[offset..offset + 8].copy_from_slice(&29u64.to_le_bytes());
|
||||
}
|
||||
}
|
||||
let suffixes = vec![".cpython-312-x86_64-linux-gnu.so".into()];
|
||||
let files = BTreeMap::from([
|
||||
("runtime/bin/python3".into(), runtime.as_slice()),
|
||||
("runtime/lib/libsample.so.1".into(), shared.as_slice()),
|
||||
(
|
||||
"runtime/alternate/libsample.so.1".into(),
|
||||
wrong_target.as_slice(),
|
||||
),
|
||||
(
|
||||
"site-packages/pkg/mod.cpython-312-x86_64-linux-gnu.so".into(),
|
||||
extension.as_slice(),
|
||||
),
|
||||
]);
|
||||
assert!(
|
||||
validate_native("linux-x86_64", &suffixes, "runtime/bin/python3", &files)
|
||||
.unwrap_err()
|
||||
.contains("target")
|
||||
);
|
||||
|
||||
let extension = elf(62, Some("liba.so.1"), Some("$ORIGIN/../vendor"));
|
||||
let helper = elf(62, Some("libb.so.1"), None);
|
||||
let files = BTreeMap::from([
|
||||
("runtime/bin/python3".into(), runtime.as_slice()),
|
||||
(
|
||||
"site-packages/pkg/mod.cpython-312-x86_64-linux-gnu.so".into(),
|
||||
extension.as_slice(),
|
||||
),
|
||||
("site-packages/vendor/liba.so.1".into(), helper.as_slice()),
|
||||
("site-packages/vendor/libb.so.1".into(), shared.as_slice()),
|
||||
]);
|
||||
validate_native("linux-x86_64", &suffixes, "runtime/bin/python3", &files).unwrap();
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,204 @@
|
||||
use super::*;
|
||||
use dever_cli::toolchain::BuildTarget;
|
||||
|
||||
fn command_fixture(root: &Path, binary: &[u8]) -> dever_core::hir::Program {
|
||||
let mut sources = SourceMap::default();
|
||||
sources.add(
|
||||
"media/tool/port.dever",
|
||||
"execute(args: List<Text>) (output: dever.process.Output) fails dever.process.Error",
|
||||
);
|
||||
sources.add(
|
||||
"media/tool/adapter.dever",
|
||||
"external command \"bin/tool\" {}\n",
|
||||
);
|
||||
sources.add("media/tool/app.dever", "execute() (code: Int) {\n output = port.execute([\"a b\", \"$(literal)\"])\n code = output.code\n}\n");
|
||||
sources.add("media/tool/api.dever", "cmd execute = app.execute\n");
|
||||
let program = dever_core::check(&sources).unwrap_or_else(|errors| panic!("{errors:?}"));
|
||||
let entry = &program.external_command_contracts()[0].entry;
|
||||
fs::create_dir_all(root.join(entry).parent().unwrap()).unwrap();
|
||||
fs::write(root.join(entry), binary).unwrap();
|
||||
program
|
||||
}
|
||||
|
||||
fn command_manifest(resources: &[EmbeddedResource]) -> serde_json::Value {
|
||||
let manifest = resources
|
||||
.iter()
|
||||
.find(|resource| resource.path.ends_with(".dever-command.json"))
|
||||
.unwrap();
|
||||
serde_json::from_slice(&manifest.bytes).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_preparation_needs_no_ecosystem_lock_and_keeps_target_identity() {
|
||||
for (target, machine) in [
|
||||
(BuildTarget::LinuxX86_64, 62u16),
|
||||
(BuildTarget::LinuxAarch64, 183u16),
|
||||
] {
|
||||
let temp = TemporaryDirectory::new();
|
||||
let mut binary = native_elf::static_executable();
|
||||
binary[18..20].copy_from_slice(&machine.to_le_bytes());
|
||||
let program = command_fixture(temp.path(), &binary);
|
||||
assert!(program.external_worker_contracts().is_empty());
|
||||
assert!(
|
||||
dever_cli::libs::prepare_program(temp.path(), &program, target)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let resources =
|
||||
workers::prepare_for_target(temp.path(), &program, &[], target, &[]).unwrap();
|
||||
let manifest = command_manifest(&resources);
|
||||
assert_eq!(manifest["format"], "dever-command-launch-v1");
|
||||
assert_eq!(manifest["arguments"], json!([]));
|
||||
assert_eq!(manifest["ecosystem"], "command");
|
||||
let executable = resources
|
||||
.iter()
|
||||
.find(|resource| Some(resource.path.as_str()) == manifest["executable"].as_str())
|
||||
.unwrap();
|
||||
assert!(executable.executable);
|
||||
assert_eq!(executable.bytes, binary);
|
||||
let other = if target == BuildTarget::LinuxX86_64 {
|
||||
BuildTarget::LinuxAarch64
|
||||
} else {
|
||||
BuildTarget::LinuxX86_64
|
||||
};
|
||||
assert!(
|
||||
workers::prepare_for_target(temp.path(), &program, &[], other, &[])
|
||||
.unwrap_err()
|
||||
.contains("differs from target")
|
||||
);
|
||||
assert!(!temp.path().join("dever.lock").exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_preparation_checks_recursive_library_closure_and_loader() {
|
||||
let temp = TemporaryDirectory::new();
|
||||
let program = command_fixture(temp.path(), &native_elf::compiler(&["libfirst.so"]));
|
||||
assert!(
|
||||
workers::prepare(temp.path(), &program, &[])
|
||||
.unwrap_err()
|
||||
.contains("libfirst.so")
|
||||
);
|
||||
let lib = temp.path().join("module/media/tool/bin/lib");
|
||||
fs::create_dir_all(&lib).unwrap();
|
||||
fs::write(
|
||||
lib.join("libfirst.so"),
|
||||
native_elf::library("libfirst.so", &["libsecond.so"]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
workers::prepare(temp.path(), &program, &[])
|
||||
.unwrap_err()
|
||||
.contains("libsecond.so")
|
||||
);
|
||||
fs::write(
|
||||
lib.join("libsecond.so"),
|
||||
native_elf::library("libwrong.so", &[]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
workers::prepare(temp.path(), &program, &[])
|
||||
.unwrap_err()
|
||||
.contains("SONAME")
|
||||
);
|
||||
fs::write(
|
||||
lib.join("libsecond.so"),
|
||||
native_elf::library("libsecond.so", &[]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
workers::prepare(temp.path(), &program, &[])
|
||||
.unwrap_err()
|
||||
.contains("signed target GNU loader")
|
||||
);
|
||||
let loader_name = dever_sandbox::loader_name();
|
||||
let mut loader = resource(
|
||||
format!("sandbox/lib/{loader_name}"),
|
||||
native_elf::static_executable(),
|
||||
);
|
||||
loader.executable = true;
|
||||
let resources = workers::prepare(temp.path(), &program, &[loader]).unwrap();
|
||||
let manifest = command_manifest(&resources);
|
||||
assert!(
|
||||
manifest["executable"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.ends_with("/loader")
|
||||
);
|
||||
assert_eq!(manifest["arguments"][0]["literal"], "--inhibit-cache");
|
||||
for name in ["libfirst.so", "libsecond.so"] {
|
||||
assert!(resources.iter().any(
|
||||
|resource| resource.path.ends_with(&format!("/lib/{name}")) && !resource.executable
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_rejects_host_library_paths_and_signed_library_shadowing() {
|
||||
let temp = TemporaryDirectory::new();
|
||||
let program = command_fixture(
|
||||
temp.path(),
|
||||
&native_elf::elf(None, &["libfirst.so"], Some("/usr/lib")),
|
||||
);
|
||||
assert!(
|
||||
workers::prepare(temp.path(), &program, &[])
|
||||
.unwrap_err()
|
||||
.contains("$ORIGIN")
|
||||
);
|
||||
let entry = &program.external_command_contracts()[0].entry;
|
||||
for path in ["$ORIGIN/../data/grant", "${ORIGIN}/../lib"] {
|
||||
fs::write(
|
||||
temp.path().join(entry),
|
||||
native_elf::elf(None, &[], Some(path)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
workers::prepare(temp.path(), &program, &[])
|
||||
.unwrap_err()
|
||||
.contains("escapes its packaged tree")
|
||||
);
|
||||
}
|
||||
fs::write(
|
||||
temp.path().join(entry),
|
||||
native_elf::compiler(&["libfirst.so"]),
|
||||
)
|
||||
.unwrap();
|
||||
let lib = temp.path().join("module/media/tool/bin/lib");
|
||||
fs::create_dir_all(&lib).unwrap();
|
||||
fs::write(
|
||||
lib.join("libfirst.so"),
|
||||
native_elf::library("libfirst.so", &[]),
|
||||
)
|
||||
.unwrap();
|
||||
let signed = resource(
|
||||
"sandbox/lib/libfirst.so".into(),
|
||||
native_elf::library("libfirst.so", &[]),
|
||||
);
|
||||
assert!(
|
||||
workers::prepare(temp.path(), &program, &[signed])
|
||||
.unwrap_err()
|
||||
.contains("overrides a signed sandbox library")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn command_rejects_symlinks_and_library_subdirectories() {
|
||||
let temp = TemporaryDirectory::new();
|
||||
let program = command_fixture(temp.path(), &native_elf::static_executable());
|
||||
let lib = temp.path().join("module/media/tool/bin/lib");
|
||||
fs::create_dir_all(&lib).unwrap();
|
||||
std::os::unix::fs::symlink("../tool", lib.join("libalias.so")).unwrap();
|
||||
assert!(
|
||||
workers::prepare(temp.path(), &program, &[])
|
||||
.unwrap_err()
|
||||
.contains("regular library files")
|
||||
);
|
||||
fs::remove_file(lib.join("libalias.so")).unwrap();
|
||||
fs::create_dir(lib.join("hidden")).unwrap();
|
||||
assert!(
|
||||
workers::prepare(temp.path(), &program, &[])
|
||||
.unwrap_err()
|
||||
.contains("regular library files")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires the explicit official native npm build fixture at target/npm-build"]
|
||||
fn runs_official_npm_addon_from_offline_resources_and_threads() {
|
||||
let temp = TemporaryDirectory::new();
|
||||
let program = dever_core::check(&worker_sources(
|
||||
"npm",
|
||||
"worker/main.cjs",
|
||||
"lib \"bufferutil@4.0.9\"",
|
||||
))
|
||||
.unwrap();
|
||||
let contract = program.external_worker_contracts().remove(0);
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let prepared = workspace.join("target/npm-build");
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(prepared.join("config/setting.json")).unwrap()).unwrap();
|
||||
let pack = fs::read(prepared.join(config["runtime_file"].as_str().unwrap())).unwrap();
|
||||
let mut lock =
|
||||
LockFile::decode(&fs::read(prepared.join("dependency/dever.lock")).unwrap()).unwrap();
|
||||
let runtime = lock.libs[0].runtime.clone();
|
||||
assert_eq!(sha(&pack), runtime.sha256);
|
||||
let rejected = lock.builds[0]
|
||||
.native_rejections
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
!rejected.is_empty(),
|
||||
"official multi-platform prebuilds must be classified"
|
||||
);
|
||||
let entry = temp.path().join(&contract.entry);
|
||||
fs::create_dir_all(entry.parent().unwrap()).unwrap();
|
||||
let adapter = r#"const addon = require('bufferutil/build/Release/bufferutil.node');
|
||||
const {Worker} = require('node:worker_threads');
|
||||
const path = require('node:path');
|
||||
const rejected = __REJECTED__.map(name => path.join(__dirname, '..', name));
|
||||
exports.send = async payload => {
|
||||
await new Promise((resolve, reject) => {
|
||||
const worker = new Worker(`const {parentPort,workerData}=require('node:worker_threads'); const fs=require('node:fs'); parentPort.postMessage(workerData.every(path=>!fs.existsSync(path)));`, {eval:true,workerData:rejected});
|
||||
worker.once('message', ok => ok ? resolve() : reject(new Error('unverified addon was installed')));
|
||||
worker.once('error', reject);
|
||||
});
|
||||
const input=Buffer.from([payload.value]), output=Buffer.alloc(1), mask=Buffer.from([1,2,3,4]);
|
||||
addon.mask(input,mask,output,0,1); addon.unmask(output,mask);
|
||||
return {receipt:output[0]+1};
|
||||
};
|
||||
"#.replace("__REJECTED__",&serde_json::to_string(&rejected).unwrap());
|
||||
fs::write(&entry, adapter).unwrap();
|
||||
let mut resources = vec![resource(
|
||||
format!(
|
||||
"lib/runtime/npm/{}/runtime.pack",
|
||||
dever_cli::toolchain::platform_identity()
|
||||
),
|
||||
pack,
|
||||
)];
|
||||
for library in &mut lock.libs {
|
||||
library.schema = contract.schema.clone();
|
||||
}
|
||||
for artifact in lock
|
||||
.libs
|
||||
.iter()
|
||||
.flat_map(|lib| &lib.artifacts)
|
||||
.chain(lock.builds.iter().map(|receipt| &receipt.output))
|
||||
{
|
||||
let bytes = fs::read(prepared.join("dependency/artifacts").join(&artifact.sha256)).unwrap();
|
||||
assert_eq!(sha(&bytes), artifact.sha256);
|
||||
resources.push(resource(artifact.path.clone(), bytes));
|
||||
}
|
||||
for asset in sandbox::assets(&workspace) {
|
||||
let mut file = resource(asset.path, asset.bytes);
|
||||
file.executable = asset.executable;
|
||||
resources.push(file);
|
||||
}
|
||||
lock.workers.push(LockedWorker {
|
||||
port: contract.port.clone(),
|
||||
adapter: contract.adapter.clone(),
|
||||
ecosystem: "npm".into(),
|
||||
runtime: Some(runtime),
|
||||
entry: contract.entry.clone(),
|
||||
schema: contract.schema.clone(),
|
||||
capabilities: contract.capabilities.clone(),
|
||||
operations: contract.operations.clone(),
|
||||
libs: contract
|
||||
.libs
|
||||
.iter()
|
||||
.map(|spec| spec.parse().unwrap())
|
||||
.collect(),
|
||||
});
|
||||
lock.write_atomic(temp.path()).unwrap();
|
||||
let mut generated = workers::prepare(temp.path(), &program, &resources).unwrap();
|
||||
let again = workers::prepare(temp.path(), &program, &resources).unwrap();
|
||||
assert_eq!(
|
||||
generated
|
||||
.iter()
|
||||
.map(|file| (&file.path, &file.sha256))
|
||||
.collect::<Vec<_>>(),
|
||||
again
|
||||
.iter()
|
||||
.map(|file| (&file.path, &file.sha256))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert!(
|
||||
generated
|
||||
.iter()
|
||||
.any(|file| file.path.ends_with("/build/Release/bufferutil.node")
|
||||
&& file.bytes.starts_with(b"\x7fELF"))
|
||||
);
|
||||
for rejected in &rejected {
|
||||
assert!(!generated.iter().any(|file| file.path.ends_with(rejected)));
|
||||
}
|
||||
generated.extend(
|
||||
resources
|
||||
.into_iter()
|
||||
.filter(|file| file.path.starts_with("sandbox/")),
|
||||
);
|
||||
fs::remove_file(entry).unwrap();
|
||||
run_packaged_protocol(&temp, &contract, "npm", &generated);
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
//! Actual default CLI boundary. Native cases consume an explicitly prepared
|
||||
//! runtime archive; ordinary negative checks never start a compiler subprocess.
|
||||
#![cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[path = "support/llvm_pack.rs"]
|
||||
mod pack;
|
||||
#[path = "../dever-tests/tests/support/process.rs"]
|
||||
mod process;
|
||||
#[path = "../dever-tests/tests/support/temp.rs"]
|
||||
mod temp;
|
||||
use temp::TemporaryDirectory;
|
||||
|
||||
const APP: &str = r#"type Outcome { error Denied(message: Text, labels: List<Text?>) }
|
||||
echo(name: Text) (value: Text) { dever.task.sleep(1)
|
||||
value = name }
|
||||
divide(divisor: Int) (value: Int) { value = 42 // divisor }
|
||||
deny() (value: Text) { fail(Outcome.Denied("owned denial", ["nested label", null])) }
|
||||
"#;
|
||||
|
||||
fn write(root: &Path, relative: &str, source: &str) {
|
||||
let path = root.join(relative);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, source).unwrap();
|
||||
}
|
||||
|
||||
fn project(root: &Path) {
|
||||
write(root, "module/sample/echo/app.dever", APP);
|
||||
write(
|
||||
root,
|
||||
"module/sample/echo/api.dever",
|
||||
"cmd echo = app.echo\ncmd divide = app.divide\ncmd deny = app.deny",
|
||||
);
|
||||
write(root, "config/setting.json", "{}");
|
||||
}
|
||||
|
||||
fn compiler(root: &Path, name: &str) -> PathBuf {
|
||||
pack::compiler(Path::new(env!("CARGO_BIN_EXE_dever")), root, name)
|
||||
}
|
||||
|
||||
fn output(command: &mut Command) -> Output {
|
||||
let capture = TemporaryDirectory::new();
|
||||
let stdout = capture.path().join("stdout");
|
||||
let stderr = capture.path().join("stderr");
|
||||
command
|
||||
.stdout(Stdio::from(fs::File::create(&stdout).unwrap()))
|
||||
.stderr(Stdio::from(fs::File::create(&stderr).unwrap()));
|
||||
let status = process::status(command, Duration::from_secs(180)).unwrap();
|
||||
Output {
|
||||
status,
|
||||
stdout: fs::read(stdout).unwrap(),
|
||||
stderr: fs::read(stderr).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cli(compiler: &Path, project: &Path, action: &str, args: &[&str]) -> Output {
|
||||
output(
|
||||
Command::new(compiler)
|
||||
.env_clear()
|
||||
.arg(action)
|
||||
.arg(project)
|
||||
.args(args),
|
||||
)
|
||||
}
|
||||
|
||||
fn success(output: &Output) {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn failure(output: &Output, expected: &str) {
|
||||
assert!(!output.status.success(), "unexpected success");
|
||||
let message = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
message.contains(expected),
|
||||
"expected {expected:?}: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_pack_fails_without_host_tools_and_check_and_empty_test_need_no_pack() {
|
||||
let toolchain = TemporaryDirectory::new();
|
||||
let compiler = compiler(toolchain.path(), "dever");
|
||||
let help = output(Command::new(&compiler).env_clear().arg("--help"));
|
||||
success(&help);
|
||||
assert!(String::from_utf8_lossy(&help.stdout).contains("dever run <project-root>"));
|
||||
let project_dir = TemporaryDirectory::new();
|
||||
project(project_dir.path());
|
||||
success(&cli(&compiler, project_dir.path(), "check", &[]));
|
||||
let tests = cli(&compiler, project_dir.path(), "test", &[]);
|
||||
success(&tests);
|
||||
assert!(String::from_utf8_lossy(&tests.stdout).contains("0 passed; 0 failed"));
|
||||
failure(
|
||||
&cli(
|
||||
&compiler,
|
||||
project_dir.path(),
|
||||
"run",
|
||||
&["--", "sample.echo.echo", "{\"name\":\"Ada\"}"],
|
||||
),
|
||||
"runtime pack",
|
||||
);
|
||||
assert!(!toolchain.path().join("cache").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_build_requires_the_selected_pack_even_when_host_pack_exists() {
|
||||
let root = TemporaryDirectory::new();
|
||||
let compiler = compiler(root.path(), "dever");
|
||||
pack::manifest(root.path(), false);
|
||||
let project_dir = TemporaryDirectory::new();
|
||||
project(project_dir.path());
|
||||
let executable = root.path().join("arm-application");
|
||||
failure(
|
||||
&cli(
|
||||
&compiler,
|
||||
project_dir.path(),
|
||||
"build",
|
||||
&[
|
||||
"--target",
|
||||
"linux-aarch64",
|
||||
"--output",
|
||||
executable.to_str().unwrap(),
|
||||
],
|
||||
),
|
||||
"runtime/linux-aarch64",
|
||||
);
|
||||
assert!(!executable.exists());
|
||||
assert!(!root.path().join("cache").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_cores_never_fall_back_to_private_packs_or_cache() {
|
||||
let root = TemporaryDirectory::new();
|
||||
let project_dir = TemporaryDirectory::new();
|
||||
project(project_dir.path());
|
||||
for (directory, diagnostic) in [
|
||||
(
|
||||
root.path().join("standalone"),
|
||||
"managed compiler must be installed",
|
||||
),
|
||||
(
|
||||
root.path().join("versions/0.1.0"),
|
||||
"cannot inspect deverd state directory",
|
||||
),
|
||||
] {
|
||||
let compiler = compiler(&directory, "dever-core");
|
||||
failure(&cli(&compiler, project_dir.path(), "run", &[]), diagnostic);
|
||||
assert!(!directory.join("cache").exists());
|
||||
}
|
||||
let installed = root.path().join("versions/0.1.0/dever-core");
|
||||
let worker = output(
|
||||
Command::new(installed)
|
||||
.env_clear()
|
||||
.current_dir(project_dir.path())
|
||||
.arg("--dever-compile-worker"),
|
||||
);
|
||||
failure(&worker, "service-owned native cache staging directory");
|
||||
assert!(!project_dir.path().join("program").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_contract_rejects_drift_duplicate_paths_and_undeclared_link_inputs() {
|
||||
let root = TemporaryDirectory::new();
|
||||
let compiler = compiler(root.path(), "dever");
|
||||
let original = pack::manifest(root.path(), false);
|
||||
let project_dir = TemporaryDirectory::new();
|
||||
project(project_dir.path());
|
||||
let mutations: &[(&str, Value, &str)] = &[
|
||||
("/format", json!("future"), "does not match"),
|
||||
("/compiler_version", json!("99.0.0"), "does not match"),
|
||||
("/abi", json!(99), "does not match"),
|
||||
(
|
||||
"/target",
|
||||
json!("aarch64-unknown-linux-gnu"),
|
||||
"does not match",
|
||||
),
|
||||
("/profiles/base", Value::Null, "missing the required 'base'"),
|
||||
(
|
||||
"/profiles/base/runtime",
|
||||
json!("missing.a"),
|
||||
"undeclared or duplicate",
|
||||
),
|
||||
(
|
||||
"/profiles/base/libraries/0",
|
||||
json!("runtime.a"),
|
||||
"undeclared or duplicate",
|
||||
),
|
||||
(
|
||||
"/files/0/path",
|
||||
json!("../runtime.a"),
|
||||
"normalized relative",
|
||||
),
|
||||
(
|
||||
"/files/0/path",
|
||||
json!("nested//runtime.a"),
|
||||
"normalized relative",
|
||||
),
|
||||
(
|
||||
"/files/0/path",
|
||||
json!("C:\\runtime.a"),
|
||||
"normalized relative",
|
||||
),
|
||||
(
|
||||
"/files/1/path",
|
||||
json!("runtime.a"),
|
||||
"duplicate runtime file",
|
||||
),
|
||||
("/files/0/bytes", json!(9), "SHA-256 verification"),
|
||||
(
|
||||
"/files/0/sha256",
|
||||
json!("0".repeat(64)),
|
||||
"SHA-256 verification",
|
||||
),
|
||||
];
|
||||
for (pointer, value, expected) in mutations {
|
||||
let mut manifest = original.clone();
|
||||
*manifest.pointer_mut(pointer).unwrap() = value.clone();
|
||||
pack::write_manifest(root.path(), &manifest);
|
||||
failure(&cli(&compiler, project_dir.path(), "run", &[]), expected);
|
||||
assert!(!root.path().join("cache").exists());
|
||||
}
|
||||
let text = serde_json::to_string(&original).unwrap();
|
||||
fs::write(
|
||||
pack::pack_root(root.path()).join("manifest.json"),
|
||||
text.replacen("\"abi\":1", "\"abi\":1,\"abi\":1", 1),
|
||||
)
|
||||
.unwrap();
|
||||
failure(
|
||||
&cli(&compiler, project_dir.path(), "run", &[]),
|
||||
"duplicate field",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_rejects_symlinks_and_disguised_linker_scripts() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let root = TemporaryDirectory::new();
|
||||
let compiler = compiler(root.path(), "dever");
|
||||
let mut manifest = pack::manifest(root.path(), false);
|
||||
let project_dir = TemporaryDirectory::new();
|
||||
project(project_dir.path());
|
||||
let runtime = pack::pack_root(root.path()).join("runtime.a");
|
||||
let outside = root.path().join("outside.a");
|
||||
fs::rename(&runtime, &outside).unwrap();
|
||||
symlink(&outside, &runtime).unwrap();
|
||||
failure(
|
||||
&cli(&compiler, project_dir.path(), "run", &[]),
|
||||
"must be a real file",
|
||||
);
|
||||
fs::remove_file(&runtime).unwrap();
|
||||
for content in [b"INPUT(/usr/lib/forbidden.a)".as_slice(), b"!<thin>\n"] {
|
||||
fs::write(&runtime, content).unwrap();
|
||||
manifest["files"][0]["bytes"] = json!(content.len());
|
||||
manifest["files"][0]["sha256"] = json!(pack::digest(&runtime));
|
||||
pack::write_manifest(root.path(), &manifest);
|
||||
failure(
|
||||
&cli(&compiler, project_dir.path(), "run", &[]),
|
||||
"linker scripts and thin archives are forbidden",
|
||||
);
|
||||
}
|
||||
assert!(!root.path().join("cache").exists());
|
||||
}
|
||||
|
||||
fn cached_programs(root: &Path) -> Vec<PathBuf> {
|
||||
let mut programs = fs::read_dir(root.join("cache/native"))
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().path().join("program"))
|
||||
.filter(|path| path.is_file())
|
||||
.collect::<Vec<_>>();
|
||||
programs.sort();
|
||||
programs
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicitly prepared full runtime archive and Linux CRT files; serial offline CLI execution"]
|
||||
fn default_llvm_runs_builds_moves_and_reuses_cache_without_host_toolchain() {
|
||||
let root = TemporaryDirectory::new();
|
||||
let compiler = compiler(root.path(), "dever");
|
||||
pack::manifest(root.path(), true);
|
||||
let project_dir = TemporaryDirectory::new();
|
||||
project(project_dir.path());
|
||||
let args = ["--", "sample.echo.echo", "{\"name\":\"你好 LLVM\"}"];
|
||||
let marker = root.path().join("forbidden-tool-was-started");
|
||||
for name in ["rustc", "cargo", "cc"] {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let path = root.path().join(name);
|
||||
fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"#!/bin/sh\nprintf forbidden > '{}'\nexit 99\n",
|
||||
marker.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
}
|
||||
let run = output(
|
||||
Command::new(&compiler)
|
||||
.env_clear()
|
||||
.env("PATH", root.path())
|
||||
.env("RUSTC", root.path().join("rustc"))
|
||||
.env("CARGO", root.path().join("cargo"))
|
||||
.env("CC", root.path().join("cc"))
|
||||
.arg("run")
|
||||
.arg(project_dir.path())
|
||||
.args(args),
|
||||
);
|
||||
success(&run);
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"LLVM compilation must not start a host compiler"
|
||||
);
|
||||
let response: Value = serde_json::from_slice(&run.stdout).unwrap();
|
||||
assert_eq!(response["data"], "你好 LLVM");
|
||||
let cached = cached_programs(root.path());
|
||||
assert_eq!(cached.len(), 1);
|
||||
let modified = fs::metadata(&cached[0]).unwrap().modified().unwrap();
|
||||
let binary = project_dir.path().join("standalone");
|
||||
success(&cli(
|
||||
&compiler,
|
||||
project_dir.path(),
|
||||
"build",
|
||||
&["--output", binary.to_str().unwrap()],
|
||||
));
|
||||
assert_eq!(
|
||||
fs::metadata(&cached[0]).unwrap().modified().unwrap(),
|
||||
modified
|
||||
);
|
||||
let first_digest = pack::digest(&binary);
|
||||
failure(
|
||||
&cli(
|
||||
&compiler,
|
||||
project_dir.path(),
|
||||
"build",
|
||||
&["--output", binary.to_str().unwrap()],
|
||||
),
|
||||
"cannot save executable",
|
||||
);
|
||||
assert_eq!(pack::digest(&binary), first_digest);
|
||||
let moved = TemporaryDirectory::new();
|
||||
let relocated = moved.path().join("application");
|
||||
fs::rename(&binary, &relocated).unwrap();
|
||||
write(moved.path(), "config/setting.json", "{}");
|
||||
success(&output(
|
||||
Command::new(&relocated).env_clear().args(&args[1..]),
|
||||
));
|
||||
{
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
let (reader, writer) = UnixStream::pair().unwrap();
|
||||
drop(reader);
|
||||
let stderr = moved.path().join("closed-stdout-error");
|
||||
let status = process::status(
|
||||
Command::new(&relocated)
|
||||
.env_clear()
|
||||
.args(&args[1..])
|
||||
.stdout(Stdio::from(OwnedFd::from(writer)))
|
||||
.stderr(Stdio::from(fs::File::create(&stderr).unwrap())),
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
status.code(),
|
||||
Some(1),
|
||||
"closed stdout must return a checked failure, not SIGPIPE"
|
||||
);
|
||||
let error = fs::read_to_string(stderr).unwrap();
|
||||
assert!(
|
||||
error.contains("Broken pipe") && error.contains(".dever"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
let denied = cli(
|
||||
&compiler,
|
||||
project_dir.path(),
|
||||
"run",
|
||||
&["--", "sample.echo.deny", "{}"],
|
||||
);
|
||||
failure(&denied, "owned denial");
|
||||
assert!(String::from_utf8_lossy(&denied.stderr).contains("nested label"));
|
||||
assert!(String::from_utf8_lossy(&denied.stderr).contains("app.dever"));
|
||||
failure(
|
||||
&cli(
|
||||
&compiler,
|
||||
project_dir.path(),
|
||||
"run",
|
||||
&["--", "sample.echo.echo", "{}"],
|
||||
),
|
||||
"name",
|
||||
);
|
||||
let failure_output = cli(
|
||||
&compiler,
|
||||
project_dir.path(),
|
||||
"run",
|
||||
&["--", "sample.echo.divide", "{\"divisor\":0}"],
|
||||
);
|
||||
failure(&failure_output, "division");
|
||||
assert!(String::from_utf8_lossy(&failure_output.stderr).contains("app.dever"));
|
||||
// Corrupt only the fixture-owned cache copy, never a hard-linked runtime.
|
||||
fs::write(&cached[0], b"corrupt cached executable").unwrap();
|
||||
failure(
|
||||
&cli(&compiler, project_dir.path(), "run", &args),
|
||||
"incomplete or modified",
|
||||
);
|
||||
assert_eq!(fs::read(&cached[0]).unwrap(), b"corrupt cached executable");
|
||||
write(
|
||||
project_dir.path(),
|
||||
"module/sample/echo/app.dever",
|
||||
&APP.replace("value = name", "value = name + \"!\""),
|
||||
);
|
||||
let changed = cli(&compiler, project_dir.path(), "run", &args);
|
||||
success(&changed);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&changed.stdout).unwrap()["data"],
|
||||
"你好 LLVM!"
|
||||
);
|
||||
assert_eq!(cached_programs(root.path()).len(), 2);
|
||||
|
||||
// Even a byte-level manifest change has its own exact pack identity.
|
||||
let manifest = pack::pack_root(root.path()).join("manifest.json");
|
||||
let mut bytes = fs::read(&manifest).unwrap();
|
||||
bytes.push(b'\n');
|
||||
fs::write(&manifest, bytes).unwrap();
|
||||
success(&cli(&compiler, project_dir.path(), "run", &args));
|
||||
assert_eq!(cached_programs(root.path()).len(), 3);
|
||||
|
||||
let markdown = TemporaryDirectory::new();
|
||||
write(markdown.path(), "config/setting.json", "{}");
|
||||
write(
|
||||
markdown.path(),
|
||||
"module/system/health/app.dever.md",
|
||||
include_str!("../../examples/cms/md/module/system/health/app.dever.md"),
|
||||
);
|
||||
write(
|
||||
markdown.path(),
|
||||
"module/system/health/api.dever.md",
|
||||
include_str!("../../examples/cms/md/module/system/health/api.dever.md"),
|
||||
);
|
||||
let response = cli(
|
||||
&compiler,
|
||||
markdown.path(),
|
||||
"run",
|
||||
&["--", "system.health.ping", "{}"],
|
||||
);
|
||||
success(&response);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&response.stdout).unwrap()["data"]["status"],
|
||||
"ready"
|
||||
);
|
||||
assert_eq!(cached_programs(root.path()).len(), 4);
|
||||
let object = pack::pack_root(root.path()).join("crt1.o");
|
||||
let mut corrupt = fs::read(&object).unwrap();
|
||||
corrupt[16] ^= 1;
|
||||
// Break this fixture's hard link before mutation; the host CRT is immutable.
|
||||
fs::remove_file(&object).unwrap();
|
||||
fs::write(&object, corrupt).unwrap();
|
||||
failure(
|
||||
&cli(&compiler, project_dir.path(), "run", &args),
|
||||
"SHA-256 verification",
|
||||
);
|
||||
assert_eq!(cached_programs(root.path()).len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicitly prepared runtime archive and component-fixture; no downloads or language SDK"]
|
||||
fn unsigned_llvm_run_and_build_reject_exec_workers() {
|
||||
let root = TemporaryDirectory::new();
|
||||
let compiler = compiler(root.path(), "dever");
|
||||
pack::manifest(root.path(), true);
|
||||
let project_dir = TemporaryDirectory::new();
|
||||
let directory = project_dir.path();
|
||||
write(
|
||||
directory,
|
||||
"module/notification/delivery/app.dever",
|
||||
"read() (value: Int) { value = port.read() }",
|
||||
);
|
||||
write(
|
||||
directory,
|
||||
"module/notification/delivery/port.dever",
|
||||
"read() (value: Int) fails dever.time.SleepResult",
|
||||
);
|
||||
write(
|
||||
directory,
|
||||
"module/notification/delivery/adapter.dever",
|
||||
"setting { mode: Text }\nexternal exec \"worker\" {}",
|
||||
);
|
||||
write(
|
||||
directory,
|
||||
"module/notification/delivery/api.dever",
|
||||
"cmd read = app.read",
|
||||
);
|
||||
let settings = r#"{"adapter":{"notification.delivery":{"setting":{"mode":"embedded"}}}}"#;
|
||||
write(directory, "config/setting.json", settings);
|
||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../target/native-release-inputs/component-fixture");
|
||||
assert!(
|
||||
fixture.is_file(),
|
||||
"prepare the immutable component-fixture in target/native-release-inputs"
|
||||
);
|
||||
fs::copy(
|
||||
fixture,
|
||||
directory.join("module/notification/delivery/worker"),
|
||||
)
|
||||
.unwrap();
|
||||
let args = ["--", "notification.delivery.read", "{}"];
|
||||
let run = cli(&compiler, directory, "run", &args);
|
||||
let binary = directory.join("packaged");
|
||||
let build = cli(
|
||||
&compiler,
|
||||
directory,
|
||||
"build",
|
||||
&["--output", binary.to_str().unwrap()],
|
||||
);
|
||||
for rejected in [run, build] {
|
||||
assert!(!rejected.status.success());
|
||||
assert!(rejected.stdout.is_empty());
|
||||
assert_eq!(
|
||||
String::from_utf8(rejected.stderr).unwrap().trim(),
|
||||
"real Lib providers require a signed managed Dever release"
|
||||
);
|
||||
}
|
||||
assert!(!binary.exists());
|
||||
assert!(!root.path().join("cache").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicitly prepared full runtime archive; isolated SQLite and case-local fake processes"]
|
||||
fn llvm_test_preserves_case_isolation_fakes_and_failure_continuation() {
|
||||
let root = TemporaryDirectory::new();
|
||||
let compiler = compiler(root.path(), "dever");
|
||||
pack::manifest(root.path(), true);
|
||||
let project_dir = TemporaryDirectory::new();
|
||||
let directory = project_dir.path();
|
||||
write(
|
||||
directory,
|
||||
"module/sample/account/model.dever",
|
||||
"type Account { name: Text }",
|
||||
);
|
||||
write(
|
||||
directory,
|
||||
"module/sample/account/app.dever",
|
||||
"create(name: Text) (count: Int) { row = model.create({ name = name })\n count = model.count() }",
|
||||
);
|
||||
write(
|
||||
directory,
|
||||
"module/notification/mail/app.dever",
|
||||
"read() (value: Int) { value = port.read() }",
|
||||
);
|
||||
write(
|
||||
directory,
|
||||
"module/notification/mail/port.dever",
|
||||
"read() (value: Int) fails dever.time.SleepResult",
|
||||
);
|
||||
write(
|
||||
directory,
|
||||
"module/notification/mail/adapter.dever",
|
||||
"external exec \"worker/must-not-start\" {}",
|
||||
);
|
||||
write(
|
||||
directory,
|
||||
"config/setting.json",
|
||||
r#"{"database":{"default":{"type":"sqlite","path":"deployment.db"}}}"#,
|
||||
);
|
||||
// The CLI reads project dependency metadata from valid JSON, but test
|
||||
// execution must use its generated database, never this unusable directory.
|
||||
fs::create_dir(directory.join("deployment.db")).unwrap();
|
||||
for name in ["first", "second"] {
|
||||
write(
|
||||
directory,
|
||||
&format!("test/sample/account/{name}.dever"),
|
||||
&format!("{name}() () {{ assert_eq(app.create(\"Ada\"), 1) }}"),
|
||||
);
|
||||
}
|
||||
write(
|
||||
directory,
|
||||
"test/notification/mail/blocking.dever",
|
||||
"blocking() () { dever.time.sleep(1)\n assert_eq(app.read(), 11) }\nport.read() (value: Int) { value = 11 }",
|
||||
);
|
||||
write(
|
||||
directory,
|
||||
"test/notification/mail/suspending.dever",
|
||||
"suspending() () { assert_eq(app.read(), 23) }\nport.read() (value: Int) { dever.task.sleep(1)\n value = 23 }",
|
||||
);
|
||||
write(
|
||||
directory,
|
||||
"test/sample/account/failure.dever",
|
||||
"failure() () { assert_eq(1, 2) }",
|
||||
);
|
||||
write(
|
||||
directory,
|
||||
"test/sample/account/business.dever",
|
||||
"type Outcome { error Denied(message: Text) }\nbusiness() () { fail(Outcome.Denied(\"test business denial\")) }",
|
||||
);
|
||||
let result = cli(&compiler, directory, "test", &[]);
|
||||
failure(&result, "4 passed; 2 failed; 0 not run");
|
||||
let report = String::from_utf8_lossy(&result.stdout);
|
||||
assert!(
|
||||
report.contains("test sample/account/second ... ok"),
|
||||
"{report}"
|
||||
);
|
||||
assert!(report.contains("failure.dever"), "{report}");
|
||||
assert!(
|
||||
report.contains("assert_eq failed: actual = 1, expected = 2"),
|
||||
"{report}"
|
||||
);
|
||||
assert!(report.contains("test business denial"), "{report}");
|
||||
assert!(!directory.join("data").exists());
|
||||
assert_eq!(cached_programs(root.path()).len(), 1);
|
||||
let suite = &cached_programs(root.path())[0];
|
||||
for args in [
|
||||
vec![],
|
||||
vec![""],
|
||||
vec!["-1"],
|
||||
vec!["+0"],
|
||||
vec![" 0"],
|
||||
vec!["0 "],
|
||||
vec!["18446744073709551616"],
|
||||
vec!["6"],
|
||||
vec!["0", "extra"],
|
||||
] {
|
||||
let rejected = output(Command::new(suite).env_clear().args(args));
|
||||
failure(&rejected, "test program requires a valid case index");
|
||||
assert_eq!(
|
||||
rejected.stderr,
|
||||
b"test program requires a valid case index\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
//! Authoring contracts use tiny synthetic payloads; real link/install checks are opt-in.
|
||||
#![cfg(all(
|
||||
target_os = "linux",
|
||||
any(target_arch = "x86_64", target_arch = "aarch64")
|
||||
))]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt, symlink};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use dever_cli::toolchain::{BuildTarget, packaging, platform_identity, runtime_pack, sha256_file};
|
||||
use ring::rand::SystemRandom;
|
||||
use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[path = "../dever-tests/tests/support/temp.rs"]
|
||||
mod temp;
|
||||
use temp::TemporaryDirectory;
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
#[path = "native_release/acceptance.rs"]
|
||||
mod acceptance;
|
||||
|
||||
#[path = "native_release/ecosystems.rs"]
|
||||
mod ecosystems;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "native_release/bootstrap.rs"]
|
||||
mod bootstrap;
|
||||
|
||||
#[path = "support/native_elf.rs"]
|
||||
mod native_elf;
|
||||
#[path = "support/native_objects.rs"]
|
||||
mod native_objects;
|
||||
|
||||
#[path = "native_release/targets.rs"]
|
||||
mod targets;
|
||||
|
||||
#[test]
|
||||
fn sandbox_bootstrap_requires_static_elf_and_complete_guard_libraries() {
|
||||
let loader = format!("lib/{}", dever_sandbox::loader_name());
|
||||
let mut files = vec![
|
||||
("bin/bwrap".into(), native_elf::static_executable()),
|
||||
(
|
||||
"bin/guard".into(),
|
||||
native_elf::elf(None, &["libc.so.6"], None),
|
||||
),
|
||||
(
|
||||
loader,
|
||||
native_elf::library(dever_sandbox::loader_name(), &[]),
|
||||
),
|
||||
(
|
||||
"lib/libc.so.6".into(),
|
||||
native_elf::library("libc.so.6", &[]),
|
||||
),
|
||||
];
|
||||
dever_sandbox::validate_assets(&files).unwrap();
|
||||
files[0].1 = native_elf::elf(None, &[], None);
|
||||
assert!(
|
||||
dever_sandbox::validate_assets(&files)
|
||||
.unwrap_err()
|
||||
.contains("static target ELF")
|
||||
);
|
||||
files[0].1 = native_elf::library("bwrap", &["libc.so.6"]);
|
||||
assert!(
|
||||
dever_sandbox::validate_assets(&files)
|
||||
.unwrap_err()
|
||||
.contains("static target ELF")
|
||||
);
|
||||
files[0].1 = native_elf::static_executable();
|
||||
files.pop();
|
||||
assert!(
|
||||
dever_sandbox::validate_assets(&files)
|
||||
.unwrap_err()
|
||||
.contains("sandbox dependency 'lib/libc.so.6' is missing")
|
||||
);
|
||||
}
|
||||
|
||||
struct Author {
|
||||
directory: TemporaryDirectory,
|
||||
root: PathBuf,
|
||||
settings: Value,
|
||||
public_key: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Author {
|
||||
fn new() -> Self {
|
||||
let directory = TemporaryDirectory::new();
|
||||
let root = directory.path().join("author");
|
||||
fs::create_dir_all(root.join("config")).unwrap();
|
||||
fs::create_dir(root.join("inputs")).unwrap();
|
||||
let key = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
|
||||
fs::write(root.join("signing.pk8"), key.as_ref()).unwrap();
|
||||
fs::set_permissions(root.join("signing.pk8"), fs::Permissions::from_mode(0o600)).unwrap();
|
||||
let public_key = Ed25519KeyPair::from_pkcs8(key.as_ref())
|
||||
.unwrap()
|
||||
.public_key()
|
||||
.as_ref()
|
||||
.to_vec();
|
||||
let input = |name: &str, bytes: &[u8]| {
|
||||
let source = format!("inputs/{name}");
|
||||
fs::write(root.join(&source), bytes).unwrap();
|
||||
json!({ "source": source, "sha256": sha256_file(&root.join(source)).unwrap() })
|
||||
};
|
||||
let core = input(
|
||||
"core",
|
||||
&native_elf::compiler(&["libLLVM.so.18.1", "libc.so.6"]),
|
||||
);
|
||||
let mut llvm = input(
|
||||
"llvm",
|
||||
&native_elf::library("libLLVM.so.18.1", &["libc.so.6"]),
|
||||
);
|
||||
llvm["path"] = json!("libLLVM.so.18.1");
|
||||
let mut profiles = json!({});
|
||||
for name in ["base", "sqlite", "postgres", "both"] {
|
||||
profiles[name] = input(
|
||||
name,
|
||||
&native_objects::archive(BuildTarget::host().unwrap(), name),
|
||||
);
|
||||
}
|
||||
let link_input = |name: &str, bytes: &[u8]| {
|
||||
let mut value = input(name, bytes);
|
||||
value["path"] = json!(name);
|
||||
value
|
||||
};
|
||||
let start = link_input(
|
||||
"start.o",
|
||||
&native_objects::object(BuildTarget::host().unwrap()),
|
||||
);
|
||||
let end = link_input(
|
||||
"end.o",
|
||||
&native_objects::object(BuildTarget::host().unwrap()),
|
||||
);
|
||||
let library = link_input("system.a", b"!<arch>\n");
|
||||
let skill = link_input(
|
||||
"SKILL.md",
|
||||
b"---\nname: dever-language\ndescription: Develop Dever applications.\n---\n",
|
||||
);
|
||||
let settings = json!({
|
||||
"format": "dever-native-release-input-v1",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"target": format!("{}-unknown-linux-gnu", std::env::consts::ARCH),
|
||||
"signing_key": "signing.pk8", "core": core, "core_libraries": [llvm],
|
||||
"skill": [skill],
|
||||
"profiles": profiles, "start": [start], "libraries": [library], "end": [end],
|
||||
});
|
||||
Self {
|
||||
directory,
|
||||
root,
|
||||
settings,
|
||||
public_key,
|
||||
}
|
||||
}
|
||||
|
||||
fn save(&self) {
|
||||
fs::write(
|
||||
self.root.join("config/setting.json"),
|
||||
serde_json::to_vec(&self.settings).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn output(&self, name: &str) -> PathBuf {
|
||||
self.directory.path().join(name)
|
||||
}
|
||||
|
||||
fn machine(&self) -> dever_cli::toolchain::Layout {
|
||||
let layout = dever_cli::toolchain::Layout::new(self.output("machine"));
|
||||
layout.initialize().unwrap();
|
||||
let key_path = layout.state().join("trusted-release-key");
|
||||
fs::write(
|
||||
&key_path,
|
||||
self.public_key
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::set_permissions(key_path, fs::Permissions::from_mode(0o644)).unwrap();
|
||||
layout
|
||||
}
|
||||
|
||||
fn reject(&self) -> String {
|
||||
self.save();
|
||||
let output = self.output("rejected");
|
||||
let error = packaging::create(&self.root, &output).unwrap_err();
|
||||
assert!(!output.exists());
|
||||
assert_eq!(
|
||||
fs::read_dir(self.directory.path()).unwrap().count(),
|
||||
1,
|
||||
"staging leaked: {error}"
|
||||
);
|
||||
assert!(
|
||||
!error.contains("signing.pk8"),
|
||||
"private key location leaked: {error}"
|
||||
);
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
fn contents(directory: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
|
||||
fn visit(root: &Path, directory: &Path, files: &mut BTreeMap<PathBuf, Vec<u8>>) {
|
||||
for entry in fs::read_dir(directory).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let path = entry.path();
|
||||
if entry.file_type().unwrap().is_dir() {
|
||||
visit(root, &path, files);
|
||||
} else {
|
||||
files.insert(
|
||||
path.strip_prefix(root).unwrap().to_owned(),
|
||||
fs::read(path).unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut files = BTreeMap::new();
|
||||
visit(directory, directory, &mut files);
|
||||
files
|
||||
}
|
||||
|
||||
fn assert_signed(author: &Author, release: &Path) {
|
||||
let encoded = fs::read_to_string(release.join("manifest.sig")).unwrap();
|
||||
let (pairs, remainder) = encoded.trim().as_bytes().as_chunks::<2>();
|
||||
assert!(remainder.is_empty());
|
||||
let signature = pairs
|
||||
.iter()
|
||||
.map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
UnparsedPublicKey::new(&ED25519, &author.public_key)
|
||||
.verify(
|
||||
&fs::read(release.join("manifest.json")).unwrap(),
|
||||
&signature,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_skill_rejects_missing_entry_and_oversized_files() {
|
||||
let mut author = Author::new();
|
||||
author.settings["skill"] = json!([]);
|
||||
author.save();
|
||||
assert!(packaging::create(&author.root, &author.output("missing")).is_err());
|
||||
assert!(!author.output("missing").exists());
|
||||
let mut author = Author::new();
|
||||
fs::write(
|
||||
author.root.join("inputs/SKILL.md"),
|
||||
vec![b'x'; 1024 * 1024 + 1],
|
||||
)
|
||||
.unwrap();
|
||||
author.settings["skill"][0]["sha256"] =
|
||||
json!(sha256_file(&author.root.join("inputs/SKILL.md")).unwrap());
|
||||
author.save();
|
||||
assert!(
|
||||
packaging::create(&author.root, &author.output("large"))
|
||||
.unwrap_err()
|
||||
.contains("limit")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_inputs_produce_identical_signed_independent_releases() {
|
||||
let author = Author::new();
|
||||
author.save();
|
||||
let first = author.output("first");
|
||||
let second = author.output("second");
|
||||
let manifest = packaging::create(&author.root, &first).unwrap();
|
||||
assert_eq!(packaging::create(&author.root, &second).unwrap(), manifest);
|
||||
let before = contents(&first);
|
||||
assert_eq!(contents(&second), before);
|
||||
assert_eq!(before.len(), manifest.artifacts.len() + 2);
|
||||
assert!(
|
||||
manifest
|
||||
.artifacts
|
||||
.windows(2)
|
||||
.all(|pair| pair[0].path < pair[1].path)
|
||||
);
|
||||
for artifact in &manifest.artifacts {
|
||||
assert_eq!(
|
||||
sha256_file(&first.join(&artifact.path)).unwrap(),
|
||||
artifact.sha256
|
||||
);
|
||||
assert_eq!(fs::metadata(first.join(&artifact.path)).unwrap().nlink(), 1);
|
||||
}
|
||||
assert_signed(&author, &first);
|
||||
let runtime = runtime_pack::validate(
|
||||
&first,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
BuildTarget::host().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(runtime.len() + 3, manifest.artifacts.len());
|
||||
let metadata: Value = serde_json::from_slice(
|
||||
&before[&PathBuf::from(format!("runtime/{}/manifest.json", platform_identity()))],
|
||||
)
|
||||
.unwrap();
|
||||
for profile in ["base", "sqlite", "postgres", "both"] {
|
||||
assert_eq!(
|
||||
metadata["profiles"][profile]["runtime"],
|
||||
format!("archives/{profile}.a")
|
||||
);
|
||||
}
|
||||
fs::write(author.root.join("inputs/base"), b"changed source").unwrap();
|
||||
assert_eq!(contents(&first), before);
|
||||
assert_eq!(contents(&second), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiler_libraries_must_form_a_closed_target_and_search_path() {
|
||||
let invalid_libraries = [
|
||||
(
|
||||
native_elf::library("libLLVM.so.18.1", &["libz.so.1"]),
|
||||
"not supplied",
|
||||
),
|
||||
(
|
||||
native_elf::library("different.so", &["libc.so.6"]),
|
||||
"SONAME",
|
||||
),
|
||||
(
|
||||
native_elf::elf(Some("libLLVM.so.18.1"), &[], Some("/usr/lib")),
|
||||
"search path",
|
||||
),
|
||||
(
|
||||
native_elf::elf(Some("libLLVM.so.18.1"), &[], Some("$ORIGIN/../../lib")),
|
||||
"search path",
|
||||
),
|
||||
(
|
||||
native_elf::library("libLLVM.so.18.1", &["/usr/lib/libz.so.1"]),
|
||||
"plain library",
|
||||
),
|
||||
];
|
||||
for (bytes, diagnostic) in invalid_libraries {
|
||||
let mut author = Author::new();
|
||||
fs::write(author.root.join("inputs/llvm"), bytes).unwrap();
|
||||
author.settings["core_libraries"][0]["sha256"] =
|
||||
json!(sha256_file(&author.root.join("inputs/llvm")).unwrap());
|
||||
assert!(author.reject().contains(diagnostic));
|
||||
}
|
||||
let mut author = Author::new();
|
||||
let mut bytes = native_elf::compiler(&["libLLVM.so.18.1"]);
|
||||
bytes[18..20].copy_from_slice(&0u16.to_le_bytes());
|
||||
fs::write(author.root.join("inputs/core"), bytes).unwrap();
|
||||
author.settings["core"]["sha256"] =
|
||||
json!(sha256_file(&author.root.join("inputs/core")).unwrap());
|
||||
assert!(author.reject().contains("incompatible target"));
|
||||
let mut author = Author::new();
|
||||
author.settings["core_libraries"] = json!([]);
|
||||
assert!(author.reject().contains("not supplied"));
|
||||
let mut author = Author::new();
|
||||
let bytes = native_elf::elf(None, &["libLLVM.so.18.1"], Some("/usr/lib"));
|
||||
fs::write(author.root.join("inputs/core"), bytes).unwrap();
|
||||
author.settings["core"]["sha256"] =
|
||||
json!(sha256_file(&author.root.join("inputs/core")).unwrap());
|
||||
assert!(author.reject().contains("inherited RPATH"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installation_rechecks_the_signed_native_compiler_closure_before_health_execution() {
|
||||
let author = Author::new();
|
||||
author.save();
|
||||
let layout = author.machine();
|
||||
let release = layout.downloads().join(env!("CARGO_PKG_VERSION"));
|
||||
let mut manifest = packaging::create(&author.root, &release).unwrap();
|
||||
manifest
|
||||
.artifacts
|
||||
.retain(|artifact| artifact.path != "lib/libLLVM.so.18.1");
|
||||
fs::remove_file(release.join("lib/libLLVM.so.18.1")).unwrap();
|
||||
let encoded = serde_json::to_vec(&manifest).unwrap();
|
||||
let key =
|
||||
Ed25519KeyPair::from_pkcs8(&fs::read(author.root.join("signing.pk8")).unwrap()).unwrap();
|
||||
fs::write(release.join("manifest.json"), &encoded).unwrap();
|
||||
fs::write(
|
||||
release.join("manifest.sig"),
|
||||
key.sign(&encoded)
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>(),
|
||||
)
|
||||
.unwrap();
|
||||
let error = dever_cli::toolchain::MachineManager::new(layout.clone())
|
||||
.install_requested(env!("CARGO_PKG_VERSION"))
|
||||
.unwrap_err();
|
||||
assert!(error.contains("not supplied"), "{error}");
|
||||
assert!(!layout.versions().join(env!("CARGO_PKG_VERSION")).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_core_resolution_rejects_unsigned_loader_search_entries() {
|
||||
for entry in [
|
||||
"lib/libc.so.6",
|
||||
"lib/glibc-hwcaps/x86-64-v3/libLLVM.so.18.1",
|
||||
] {
|
||||
let author = Author::new();
|
||||
author.save();
|
||||
let layout = author.machine();
|
||||
let version = dever_cli::toolchain::Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
||||
let release = layout.versions().join(version.as_str());
|
||||
packaging::create(&author.root, &release).unwrap();
|
||||
let extra = release.join(entry);
|
||||
fs::create_dir_all(extra.parent().unwrap()).unwrap();
|
||||
fs::write(extra, b"unsigned loader input").unwrap();
|
||||
let error = dever_cli::toolchain::MachineManager::new(layout)
|
||||
.resolve_core(&version)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.contains("unsigned file, directory or link"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_configuration_and_rolls_back_partial_payloads() {
|
||||
let changes = [
|
||||
("/format", json!("unknown")),
|
||||
("/target", json!("x86_64-pc-windows-msvc")),
|
||||
("/version", json!("1.0")),
|
||||
("/unexpected", json!(true)),
|
||||
("/core/unexpected", json!(true)),
|
||||
("/core/source", json!("../outside")),
|
||||
("/core/source", json!("/etc/passwd")),
|
||||
("/core/source", json!("inputs/./core")),
|
||||
("/core/source", json!("inputs/missing")),
|
||||
("/profiles/base/sha256", json!("0".repeat(64))),
|
||||
("/profiles/base/sha256", json!("A".repeat(64))),
|
||||
("/profiles/postgres", Value::Null),
|
||||
("/start", json!([])),
|
||||
("/libraries", json!([])),
|
||||
("/end", json!([])),
|
||||
("/end/0/path", json!("start.o")),
|
||||
("/end/0/path", json!("archives/base.a")),
|
||||
("/end/0/path", json!("manifest.json")),
|
||||
("/end/0/path", json!("../../escape.o")),
|
||||
("/end/0/path", json!("end.txt")),
|
||||
];
|
||||
for (pointer, value) in changes {
|
||||
let mut author = Author::new();
|
||||
if let Some(slot) = author.settings.pointer_mut(pointer) {
|
||||
*slot = value;
|
||||
} else if pointer == "/unexpected" {
|
||||
author.settings["unexpected"] = value;
|
||||
} else {
|
||||
author.settings["core"]["unexpected"] = value;
|
||||
}
|
||||
author.reject();
|
||||
}
|
||||
let mut author = Author::new();
|
||||
author.settings["profiles"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("sqlite");
|
||||
author.reject();
|
||||
for bytes in [b"!<thin>\n".as_slice(), b"linker script".as_slice()] {
|
||||
let mut author = Author::new();
|
||||
fs::write(author.root.join("inputs/base"), bytes).unwrap();
|
||||
author.settings["profiles"]["base"]["sha256"] =
|
||||
json!(sha256_file(&author.root.join("inputs/base")).unwrap());
|
||||
assert!(author.reject().contains("self-contained archive"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_key_and_symlink_inputs_never_enter_the_release() {
|
||||
let author = Author::new();
|
||||
fs::set_permissions(
|
||||
author.root.join("signing.pk8"),
|
||||
fs::Permissions::from_mode(0o644),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(author.reject().contains("permissions"));
|
||||
let mut author = Author::new();
|
||||
symlink("signing.pk8", author.root.join("key-link")).unwrap();
|
||||
author.settings["signing_key"] = json!("key-link");
|
||||
author.reject();
|
||||
let mut author = Author::new();
|
||||
symlink("inputs/core", author.root.join("payload-link")).unwrap();
|
||||
author.settings["core"]["source"] = json!("payload-link");
|
||||
author.reject();
|
||||
let mut author = Author::new();
|
||||
symlink("inputs", author.root.join("directory-link")).unwrap();
|
||||
author.settings["core"]["source"] = json!("directory-link/core");
|
||||
author.reject();
|
||||
let mut author = Author::new();
|
||||
fs::copy(
|
||||
author.root.join("signing.pk8"),
|
||||
author.root.join("inputs/key-copy"),
|
||||
)
|
||||
.unwrap();
|
||||
author.settings["core"] = json!({ "source":"inputs/key-copy", "sha256":sha256_file(&author.root.join("inputs/key-copy")).unwrap() });
|
||||
assert!(author.reject().contains("cannot be included"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_outputs_are_preserved_and_concurrent_creation_has_one_winner() {
|
||||
let author = Author::new();
|
||||
author.save();
|
||||
let existing = author.output("existing");
|
||||
fs::create_dir(&existing).unwrap();
|
||||
fs::write(existing.join("keep"), b"original").unwrap();
|
||||
let before = contents(&existing);
|
||||
assert!(packaging::create(&author.root, &existing).is_err());
|
||||
assert_eq!(contents(&existing), before);
|
||||
let empty = author.output("empty");
|
||||
fs::create_dir(&empty).unwrap();
|
||||
assert!(packaging::create(&author.root, &empty).is_err());
|
||||
assert!(empty.is_dir());
|
||||
let link = author.output("link");
|
||||
symlink(&existing, &link).unwrap();
|
||||
assert!(packaging::create(&author.root, &link).is_err());
|
||||
assert_eq!(contents(&existing), before);
|
||||
let output = author.output("concurrent");
|
||||
let outcomes = std::thread::scope(|scope| {
|
||||
let first = scope.spawn(|| packaging::create(&author.root, &output));
|
||||
let second = scope.spawn(|| packaging::create(&author.root, &output));
|
||||
[first.join().unwrap(), second.join().unwrap()]
|
||||
});
|
||||
assert_eq!(outcomes.iter().filter(|result| result.is_ok()).count(), 1);
|
||||
runtime_pack::validate(
|
||||
&output,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
BuildTarget::host().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fs::read_dir(author.directory.path()).unwrap().count(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_fields_and_invalid_private_keys_fail_before_publication() {
|
||||
let author = Author::new();
|
||||
author.save();
|
||||
let config = author.root.join("config/setting.json");
|
||||
let original = fs::read_to_string(&config).unwrap();
|
||||
let duplicate = original.replacen('{', "{\"signing_key\":\"signing.pk8\",", 1);
|
||||
fs::write(&config, duplicate).unwrap();
|
||||
let error = packaging::create(&author.root, &author.output("duplicate")).unwrap_err();
|
||||
assert!(error.contains("invalid author configuration"));
|
||||
assert!(!error.contains("signing.pk8"));
|
||||
assert_eq!(fs::read_dir(author.directory.path()).unwrap().count(), 1);
|
||||
fs::write(author.root.join("signing.pk8"), b"invalid private key").unwrap();
|
||||
assert!(author.reject().contains("PKCS#8"));
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
//! Explicit Linux acceptance using four separately built optimized archives.
|
||||
use std::fs;
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
use dever_cli::toolchain::{Layout, MachineManager, Version, cache_status, packaging, sha256_file};
|
||||
use ring::rand::SystemRandom;
|
||||
use ring::signature::{Ed25519KeyPair, KeyPair};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[path = "../support/daemon.rs"]
|
||||
mod daemon;
|
||||
#[path = "../support/llvm_inputs.rs"]
|
||||
mod inputs;
|
||||
#[path = "../../dever-tests/tests/support/process.rs"]
|
||||
mod process;
|
||||
#[path = "../../dever-tests/tests/support/sandbox.rs"]
|
||||
mod sandbox;
|
||||
use super::temp::TemporaryDirectory;
|
||||
|
||||
#[path = "ecosystem_acceptance.rs"]
|
||||
mod ecosystems;
|
||||
|
||||
#[path = "bootstrap_acceptance.rs"]
|
||||
mod bootstrap;
|
||||
|
||||
#[path = "cross_acceptance.rs"]
|
||||
mod cross;
|
||||
|
||||
#[path = "command_acceptance.rs"]
|
||||
mod command_acceptance;
|
||||
|
||||
const PROFILES: [&str; 4] = ["base", "sqlite", "postgres", "both"];
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AcceptanceSettings {
|
||||
machine_temporary_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn machine_temporary_root(workspace: &Path) -> Result<Option<PathBuf>, String> {
|
||||
let path = workspace.join("target/native-release-inputs/config/setting.json");
|
||||
let bytes = match fs::read(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(format!("cannot read acceptance settings: {error}")),
|
||||
};
|
||||
let settings: AcceptanceSettings = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| format!("invalid acceptance settings: {error}"))?;
|
||||
if let Some(root) = &settings.machine_temporary_root
|
||||
&& (!root.is_absolute()
|
||||
|| root
|
||||
.components()
|
||||
.any(|part| matches!(part, std::path::Component::ParentDir)))
|
||||
{
|
||||
return Err("machine_temporary_root must be an absolute path without '..'".into());
|
||||
}
|
||||
Ok(settings.machine_temporary_root)
|
||||
}
|
||||
|
||||
fn machine_temporary_directory(workspace: &Path) -> TemporaryDirectory {
|
||||
match machine_temporary_root(workspace).expect("valid native release acceptance settings") {
|
||||
Some(root) => TemporaryDirectory::new_in(&root),
|
||||
None => TemporaryDirectory::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acceptance_machine_directory_uses_only_explicit_author_settings() {
|
||||
let workspace = TemporaryDirectory::new();
|
||||
assert_eq!(machine_temporary_root(workspace.path()).unwrap(), None);
|
||||
let config = "target/native-release-inputs/config/setting.json";
|
||||
write(
|
||||
workspace.path(),
|
||||
config,
|
||||
br#"{"machine_temporary_root":"relative"}"#,
|
||||
);
|
||||
assert!(
|
||||
machine_temporary_root(workspace.path())
|
||||
.unwrap_err()
|
||||
.contains("absolute path")
|
||||
);
|
||||
write(
|
||||
workspace.path(),
|
||||
config,
|
||||
br#"{"machine_temporary_root":"/tmp","unknown":true}"#,
|
||||
);
|
||||
assert!(
|
||||
machine_temporary_root(workspace.path())
|
||||
.unwrap_err()
|
||||
.contains("unknown field")
|
||||
);
|
||||
let selected = TemporaryDirectory::new();
|
||||
write(
|
||||
workspace.path(),
|
||||
config,
|
||||
serde_json::to_vec(&json!({"machine_temporary_root":selected.path()})).unwrap(),
|
||||
);
|
||||
let directory = machine_temporary_directory(workspace.path());
|
||||
assert_eq!(directory.path().parent(), Some(selected.path()));
|
||||
assert!(directory.path().is_dir());
|
||||
let path = directory.path().to_owned();
|
||||
drop(directory);
|
||||
assert!(!path.exists());
|
||||
assert!(selected.path().is_dir());
|
||||
}
|
||||
|
||||
fn write(root: &Path, relative: &str, bytes: impl AsRef<[u8]>) {
|
||||
let path = root.join(relative);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
fn input(author: &Path, source: &Path, name: &str) -> Value {
|
||||
let relative = format!("inputs/{name}");
|
||||
let path = author.join(&relative);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
// This is immutable test input staging, never a published release artifact.
|
||||
fs::hard_link(source, &path).unwrap();
|
||||
json!({"source":relative,"sha256":sha256_file(&path).unwrap()})
|
||||
}
|
||||
|
||||
fn author_inputs(author: &Path, workspace: &Path) -> Ed25519KeyPair {
|
||||
let document = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
|
||||
let key = Ed25519KeyPair::from_pkcs8(document.as_ref()).unwrap();
|
||||
use std::io::Write;
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(author.join("signing.pk8"))
|
||||
.unwrap()
|
||||
.write_all(document.as_ref())
|
||||
.unwrap();
|
||||
let mut profiles = serde_json::Map::new();
|
||||
for profile in PROFILES {
|
||||
let name = format!("{profile}.a");
|
||||
profiles.insert(
|
||||
profile.into(),
|
||||
input(
|
||||
author,
|
||||
&workspace.join("target/native-release-inputs").join(&name),
|
||||
&name,
|
||||
),
|
||||
);
|
||||
}
|
||||
let compiler = input(author, Path::new(env!("CARGO_BIN_EXE_dever")), "dever");
|
||||
let core_libraries = inputs::core_libraries()
|
||||
.into_iter()
|
||||
.map(|(name, source)| {
|
||||
let mut value = input(author, &source, name);
|
||||
value["path"] = json!(name);
|
||||
value
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut links = serde_json::Map::new();
|
||||
for (name, source) in inputs::native_inputs()
|
||||
.into_iter()
|
||||
.filter(|(name, _)| *name != "runtime.a")
|
||||
{
|
||||
let mut entry = input(author, &source, name);
|
||||
entry["path"] = json!(name);
|
||||
links.insert(name.into(), entry);
|
||||
}
|
||||
let ordered = |names: &[&str]| {
|
||||
names
|
||||
.iter()
|
||||
.map(|name| links[*name].clone())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let sandbox = sandbox::assets(workspace).into_iter().map(|asset| {
|
||||
let relative = format!("inputs/{}", asset.path);
|
||||
write(author, &relative, &asset.bytes);
|
||||
fs::set_permissions(author.join(&relative), fs::Permissions::from_mode(if asset.executable { 0o755 } else { 0o644 })).unwrap();
|
||||
json!({"source":relative, "path":asset.path.strip_prefix("sandbox/").unwrap(), "sha256":sha256_file(&author.join(&relative)).unwrap()})
|
||||
}).collect::<Vec<_>>();
|
||||
write(
|
||||
author,
|
||||
"inputs/SKILL.md",
|
||||
b"---\nname: dever-language\ndescription: Dever acceptance skill.\n---\n",
|
||||
);
|
||||
let skill = json!({"source":"inputs/SKILL.md", "path":"SKILL.md", "sha256":sha256_file(&author.join("inputs/SKILL.md")).unwrap()});
|
||||
write(
|
||||
author,
|
||||
"config/setting.json",
|
||||
serde_json::to_vec(&json!({
|
||||
"format":"dever-native-release-input-v1", "version":env!("CARGO_PKG_VERSION"),
|
||||
"target":"x86_64-unknown-linux-gnu", "signing_key":"signing.pk8",
|
||||
"core":compiler, "core_libraries":core_libraries, "profiles":profiles,
|
||||
"skill":[skill],
|
||||
"start":ordered(&["crt1.o","crti.o","crtbeginT.o"]),
|
||||
"libraries":ordered(&["libc.a","libm.a","libmvec.a","libgcc.a","libgcc_eh.a"]),
|
||||
"end":ordered(&["crtend.o","crtn.o"]), "sandbox":sandbox
|
||||
}))
|
||||
.unwrap(),
|
||||
);
|
||||
key
|
||||
}
|
||||
|
||||
fn command(command: &mut Command) -> Output {
|
||||
command_with_timeout(command, Duration::from_secs(190))
|
||||
}
|
||||
|
||||
fn isolated_os_command(root: &Path) -> Command {
|
||||
let mut command = isolated_os_namespace(root);
|
||||
command.args(["--", "/fixture-proc-init"]);
|
||||
command
|
||||
}
|
||||
|
||||
fn isolated_os_namespace(root: &Path) -> Command {
|
||||
let assets = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../target/sandbox-inputs/assets")
|
||||
.canonicalize()
|
||||
.expect("prepare the explicit Linux isolation assets");
|
||||
let init = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../target/debug/examples/native-acceptance-init")
|
||||
.canonicalize()
|
||||
.expect("build the explicit static native-acceptance-init example");
|
||||
let bytes = fs::read(&init).unwrap();
|
||||
let executable = goblin::elf::Elf::parse(&bytes).unwrap();
|
||||
assert!(executable.interpreter.is_none() && executable.libraries.is_empty());
|
||||
// A plain chroot cannot create a nested user namespace. Use a real mount
|
||||
// namespace root so the program can retain its normal Worker sandbox.
|
||||
dever_sandbox::validate_bwrap(&fs::read(assets.join("bin/bwrap")).unwrap()).unwrap();
|
||||
let mut command = Command::new(assets.join("bin/bwrap"));
|
||||
command
|
||||
.env_clear()
|
||||
.args([
|
||||
"--unshare-all",
|
||||
"--die-with-parent",
|
||||
"--new-session",
|
||||
"--bind",
|
||||
])
|
||||
.arg(root)
|
||||
.args(["/", "--proc", "/proc", "--dev", "/dev", "--chdir", "/"])
|
||||
.arg("--ro-bind")
|
||||
.arg(init)
|
||||
.arg("/fixture-proc-init");
|
||||
command
|
||||
}
|
||||
|
||||
fn command_with_timeout(command: &mut Command, timeout: Duration) -> Output {
|
||||
let capture = TemporaryDirectory::new();
|
||||
let stdout = capture.path().join("stdout");
|
||||
let stderr = capture.path().join("stderr");
|
||||
let status = process::status(
|
||||
command
|
||||
.env_clear()
|
||||
.stdout(Stdio::from(fs::File::create(&stdout).unwrap()))
|
||||
.stderr(Stdio::from(fs::File::create(&stderr).unwrap())),
|
||||
timeout,
|
||||
)
|
||||
.unwrap();
|
||||
let output = Output {
|
||||
status,
|
||||
stdout: fs::read(stdout).unwrap(),
|
||||
stderr: fs::read(stderr).unwrap(),
|
||||
};
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{:?}: {}\n{}",
|
||||
command,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
output
|
||||
}
|
||||
|
||||
fn application(root: &Path, profile: &str) {
|
||||
let sqlite = json!({"type":"sqlite","path":"data/owned.sqlite","max_connections":1});
|
||||
let postgres =
|
||||
json!({"type":"postgres","url":"postgres://fixture@127.0.0.1:1/unused", "tls":"disabled"});
|
||||
let settings = match profile {
|
||||
"base" => json!({}),
|
||||
"sqlite" => json!({"database":{"default":sqlite}}),
|
||||
"postgres" => json!({"database":{"default":postgres}}),
|
||||
"both" => json!({"database":{"default":sqlite,"unused":postgres}}),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
write(
|
||||
root,
|
||||
"config/setting.json",
|
||||
serde_json::to_vec(&settings).unwrap(),
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/sample/value/api.dever",
|
||||
"cmd exercise = app.exercise\n",
|
||||
);
|
||||
let app = if matches!(profile, "sqlite" | "both") {
|
||||
write(
|
||||
root,
|
||||
"module/sample/value/model.dever",
|
||||
"type Value { name: Text(1, 32) }\n",
|
||||
);
|
||||
"exercise() (okay: Bool) { saved = model.create({ name = \"pack\" })\n count = model.count()\n removed = model.delete(saved.id)\n okay = count == 1 and removed == 1 }\n"
|
||||
} else {
|
||||
"exercise() (okay: Bool) { dever.task.sleep(1)\n okay = true }\n"
|
||||
};
|
||||
write(root, "module/sample/value/app.dever", app);
|
||||
}
|
||||
|
||||
fn assert_response(output: Output) {
|
||||
assert_response_data(output, json!(true));
|
||||
}
|
||||
|
||||
fn assert_response_data(output: Output, expected: Value) {
|
||||
let value: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(value["code"], 0);
|
||||
assert_eq!(value["data"], expected);
|
||||
}
|
||||
|
||||
fn exec_application(root: &Path, workspace: &Path) {
|
||||
for (name, source) in [
|
||||
("app", "read() (value: Int) { value = port.read() }"),
|
||||
("port", "read() (value: Int) fails dever.time.SleepResult"),
|
||||
(
|
||||
"adapter",
|
||||
"setting { mode: Text }\nexternal exec \"worker\" {}",
|
||||
),
|
||||
("api", "cmd read = app.read"),
|
||||
] {
|
||||
write(
|
||||
root,
|
||||
&format!("module/notification/delivery/{name}.dever"),
|
||||
source,
|
||||
);
|
||||
}
|
||||
write(
|
||||
root,
|
||||
"config/setting.json",
|
||||
br#"{"adapter":{"notification.delivery":{"setting":{"mode":"embedded"}}}}"#,
|
||||
);
|
||||
let fixture = workspace.join("target/native-release-inputs/component-fixture");
|
||||
assert!(
|
||||
fixture.is_file(),
|
||||
"prepare the immutable component-fixture in target/native-release-inputs"
|
||||
);
|
||||
fs::copy(fixture, root.join("module/notification/delivery/worker")).unwrap();
|
||||
}
|
||||
|
||||
fn install_release(layout: &Layout, version: &Version, key: &Ed25519KeyPair) {
|
||||
let public_key = key
|
||||
.public_key()
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>();
|
||||
fs::write(layout.state().join("trusted-release-key"), public_key).unwrap();
|
||||
fs::set_permissions(
|
||||
layout.state().join("trusted-release-key"),
|
||||
fs::Permissions::from_mode(0o644),
|
||||
)
|
||||
.unwrap();
|
||||
MachineManager::new(layout.clone())
|
||||
.install_requested(version.as_str())
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn archive_report(workspace: &Path) -> Value {
|
||||
let mut profiles = serde_json::Map::new();
|
||||
for profile in PROFILES {
|
||||
let path = workspace
|
||||
.join("target/native-release-inputs")
|
||||
.join(format!("{profile}.a"));
|
||||
let output = command(
|
||||
Command::new("/usr/bin/nm")
|
||||
.args(["--defined-only", "--demangle"])
|
||||
.arg(&path),
|
||||
);
|
||||
let symbols = String::from_utf8(output.stdout).unwrap();
|
||||
assert_eq!(
|
||||
symbols.contains(" sqlite3_open"),
|
||||
matches!(profile, "sqlite" | "both"),
|
||||
"{profile} SQLite driver"
|
||||
);
|
||||
assert_eq!(
|
||||
symbols.contains("tokio_postgres"),
|
||||
matches!(profile, "postgres" | "both"),
|
||||
"{profile} PostgreSQL driver"
|
||||
);
|
||||
assert!(!symbols.contains("LLVMContextCreate"));
|
||||
profiles.insert(profile.into(), json!({"bytes":fs::metadata(&path).unwrap().len(),"sha256":sha256_file(&path).unwrap()}));
|
||||
}
|
||||
json!(profiles)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared optimized archives and root for an owned GNU ABI root"]
|
||||
fn signed_core_builds_without_host_compiler_libraries_or_tools() {
|
||||
assert_eq!(
|
||||
rustix::process::geteuid().as_raw(),
|
||||
0,
|
||||
"explicit owned namespace acceptance requires root"
|
||||
);
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let author = TemporaryDirectory::new();
|
||||
author_inputs(author.path(), &workspace);
|
||||
let release = machine_temporary_directory(&workspace);
|
||||
let payload = release.path().join("release");
|
||||
let manifest = packaging::create(author.path(), &payload).unwrap();
|
||||
let isolated = machine_temporary_directory(&workspace);
|
||||
ecosystems::minimal_os(isolated.path());
|
||||
let compiler_root = isolated.path().join("compiler");
|
||||
for artifact in &manifest.artifacts {
|
||||
let relative = if artifact.path == "dever-core" {
|
||||
"dever"
|
||||
} else {
|
||||
&artifact.path
|
||||
};
|
||||
let destination = compiler_root.join(relative);
|
||||
fs::create_dir_all(destination.parent().unwrap()).unwrap();
|
||||
// Immutable signed inputs; no host directory is mounted into the root.
|
||||
fs::hard_link(payload.join(&artifact.path), destination).unwrap();
|
||||
}
|
||||
application(&isolated.path().join("project"), "sqlite");
|
||||
let invoke = |arguments: &[&str]| command(isolated_os_command(isolated.path()).args(arguments));
|
||||
invoke(&["/compiler/dever", "check", "/project"]);
|
||||
invoke(&[
|
||||
"/compiler/dever",
|
||||
"build",
|
||||
"/project",
|
||||
"--output",
|
||||
"/project/program",
|
||||
]);
|
||||
fs::remove_dir_all(isolated.path().join("project/module")).unwrap();
|
||||
assert_response(invoke(&["/project/program", "sample.value.exercise", "{}"]));
|
||||
write(&workspace, "target/closure-core-loader-acceptance.json", serde_json::to_vec_pretty(&json!({
|
||||
"platform":"linux-x86_64", "os_baseline":"GNU libc 2.39 and its named companion libraries",
|
||||
"core_libraries":manifest.artifacts.iter().filter(|artifact| artifact.path.starts_with("lib/")).collect::<Vec<_>>(),
|
||||
"checks":{"signed_payload":true,"check_in_minimal_root":true,"sqlite_build_in_minimal_root":true,"source_removed_execution":true},
|
||||
"host_compiler_libraries":false,"host_language_tools":false
|
||||
})).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires four optimized archives and component-fixture; starts only an owned daemon, SQLite and exec Worker fixtures"]
|
||||
fn optimized_profiles_make_reproducible_signed_release_and_run_through_daemon() {
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let profiles = archive_report(&workspace);
|
||||
let author = TemporaryDirectory::new();
|
||||
let key = author_inputs(author.path(), &workspace);
|
||||
let machine = machine_temporary_directory(&workspace);
|
||||
let layout = Layout::new(machine.path());
|
||||
layout.initialize().unwrap();
|
||||
let first = machine.path().join("first");
|
||||
let expected = packaging::create(author.path(), &first).unwrap();
|
||||
let manifest = fs::read(first.join("manifest.json")).unwrap();
|
||||
let signature = fs::read(first.join("manifest.sig")).unwrap();
|
||||
// Remove only this test's first completed copy before making the second.
|
||||
fs::remove_dir_all(&first).unwrap();
|
||||
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
||||
let release = layout.downloads().join(version.as_str());
|
||||
command(
|
||||
Command::new(workspace.join("target/debug/examples/native-release"))
|
||||
.arg(author.path())
|
||||
.arg("--output")
|
||||
.arg(&release),
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<dever_cli::toolchain::ReleaseManifest>(
|
||||
&fs::read(release.join("manifest.json")).unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(fs::read(release.join("manifest.json")).unwrap(), manifest);
|
||||
assert_eq!(fs::read(release.join("manifest.sig")).unwrap(), signature);
|
||||
install_release(&layout, &version, &key);
|
||||
fs::remove_dir_all(&release).unwrap();
|
||||
drop(author);
|
||||
let compiler = layout.versions().join(version.as_str()).join("dever-core");
|
||||
let mut daemon = daemon::TestDaemon::start(&layout);
|
||||
let projects = TemporaryDirectory::new();
|
||||
let mut binaries = serde_json::Map::new();
|
||||
let mut programs: Vec<(PathBuf, PathBuf)> = Vec::new();
|
||||
for profile in PROFILES {
|
||||
let project = projects.path().join(profile);
|
||||
application(&project, profile);
|
||||
command(Command::new(&compiler).arg("check").arg(&project));
|
||||
assert_response(command(
|
||||
Command::new(&compiler).arg("run").arg(&project).args([
|
||||
"--",
|
||||
"sample.value.exercise",
|
||||
"{}",
|
||||
]),
|
||||
));
|
||||
let executable = project.join("program");
|
||||
command(
|
||||
Command::new(&compiler)
|
||||
.arg("build")
|
||||
.arg(&project)
|
||||
.arg("--output")
|
||||
.arg(&executable),
|
||||
);
|
||||
binaries.insert(profile.into(), json!({"bytes":fs::metadata(&executable).unwrap().len(),"sha256":sha256_file(&executable).unwrap()}));
|
||||
programs.push((project, executable));
|
||||
}
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 4);
|
||||
let exec_project = TemporaryDirectory::new();
|
||||
let exec_source = exec_project.path().to_owned();
|
||||
exec_application(&exec_source, &workspace);
|
||||
assert_response_data(
|
||||
command(Command::new(&compiler).arg("run").arg(&exec_source).args([
|
||||
"--",
|
||||
"notification.delivery.read",
|
||||
"{}",
|
||||
])),
|
||||
json!(7),
|
||||
);
|
||||
let standalone = TemporaryDirectory::new();
|
||||
let exec_program = standalone.path().join("application");
|
||||
fs::create_dir(standalone.path().join("config")).unwrap();
|
||||
fs::copy(
|
||||
exec_source.join("config/setting.json"),
|
||||
standalone.path().join("config/setting.json"),
|
||||
)
|
||||
.unwrap();
|
||||
command(
|
||||
Command::new(&compiler)
|
||||
.arg("build")
|
||||
.arg(&exec_source)
|
||||
.arg("--output")
|
||||
.arg(&exec_program),
|
||||
);
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 5);
|
||||
drop(exec_project);
|
||||
assert!(!exec_source.exists());
|
||||
daemon.stop();
|
||||
assert_response_data(
|
||||
command(
|
||||
Command::new(&exec_program)
|
||||
.current_dir(standalone.path())
|
||||
.args(["notification.delivery.read", "{}"]),
|
||||
),
|
||||
json!(7),
|
||||
);
|
||||
assert!(!standalone.path().join("module").exists());
|
||||
let exec = json!({
|
||||
"bytes": fs::metadata(&exec_program).unwrap().len(),
|
||||
"sha256": sha256_file(&exec_program).unwrap(),
|
||||
"managed_run": true, "standalone_run": true,
|
||||
"source_project_removed": true, "response": 7
|
||||
});
|
||||
for (project, executable) in programs {
|
||||
fs::remove_dir_all(project.join("module")).unwrap();
|
||||
assert_response(command(
|
||||
Command::new(executable).args(["sample.value.exercise", "{}"]),
|
||||
));
|
||||
}
|
||||
let report = json!({
|
||||
"format":"dever-native-release-acceptance-v1", "platform":"linux-x86_64",
|
||||
"version":version.as_str(), "profiles":profiles,"programs":binaries,"exec":exec,
|
||||
"manifest_sha256":format!("{:x}", sha2::Sha256::digest(&manifest)),
|
||||
"checks":{"pack_determinism":true,"signed_install":true,"managed_profiles":4,"standalone_profiles":4,"sqlite_crud":true,"signed_exec_worker":true},
|
||||
"postgres_server":"not_run", "cross_host_source_reproducibility":"not_run"
|
||||
});
|
||||
use sha2::Digest;
|
||||
write(
|
||||
&workspace,
|
||||
"target/closure-native-release-acceptance.json",
|
||||
serde_json::to_vec_pretty(&report).unwrap(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
//! Bootstrap package validation and recovery use only owned fixture directories.
|
||||
use super::*;
|
||||
use dever_cli::toolchain::bootstrap;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[test]
|
||||
fn bootstrap_service_recovery_orders_actions_around_file_restoration() {
|
||||
use bootstrap::RecoveryAction::{Reload, Restart, Stop};
|
||||
for active in [false, true] {
|
||||
for loaded in [false, true] {
|
||||
assert_eq!(
|
||||
bootstrap::recovery_actions(false, active, loaded),
|
||||
(vec![], vec![])
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
bootstrap::recovery_actions(true, false, false),
|
||||
(vec![], vec![Reload])
|
||||
);
|
||||
assert_eq!(
|
||||
bootstrap::recovery_actions(true, false, true),
|
||||
(vec![Stop], vec![Reload])
|
||||
);
|
||||
assert_eq!(
|
||||
bootstrap::recovery_actions(true, true, true),
|
||||
(vec![Stop], vec![Reload, Restart])
|
||||
);
|
||||
assert_eq!(
|
||||
bootstrap::recovery_actions(true, true, false),
|
||||
(vec![], vec![Reload, Restart])
|
||||
);
|
||||
}
|
||||
|
||||
fn add_bootstrap(author: &mut Author) {
|
||||
let input = |name: &str, bytes: &[u8]| {
|
||||
let relative = format!("inputs/{name}");
|
||||
fs::write(author.root.join(&relative), bytes).unwrap();
|
||||
json!({"source":relative,"sha256":sha256_file(&author.root.join(&relative)).unwrap()})
|
||||
};
|
||||
let launcher = input(
|
||||
"dever",
|
||||
&native_elf::compiler(&["libgcc_s.so.1", "libc.so.6"]),
|
||||
);
|
||||
let daemon = input(
|
||||
"deverd",
|
||||
&native_elf::compiler(&["libgcc_s.so.1", "libc.so.6"]),
|
||||
);
|
||||
let mut gcc = input("gcc", &native_elf::library("libgcc_s.so.1", &["libc.so.6"]));
|
||||
gcc["path"] = json!("libgcc_s.so.1");
|
||||
author.settings["bootstrap"] = json!({"launcher":launcher,"daemon":daemon,"libraries":[gcc]});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_payload_has_a_separate_signed_complete_loader_closure() {
|
||||
let mut author = Author::new();
|
||||
add_bootstrap(&mut author);
|
||||
author.save();
|
||||
let release = author.output("complete");
|
||||
let manifest = packaging::create(&author.root, &release).unwrap();
|
||||
assert_signed(&author, &release);
|
||||
for path in [
|
||||
"bootstrap/dever",
|
||||
"bootstrap/deverd",
|
||||
"bootstrap/lib/libgcc_s.so.1",
|
||||
"bootstrap/deverd.service",
|
||||
"bootstrap/dever-sandbox",
|
||||
] {
|
||||
let artifact = manifest
|
||||
.artifacts
|
||||
.iter()
|
||||
.find(|artifact| artifact.path == path)
|
||||
.unwrap();
|
||||
assert_eq!(artifact.sha256, sha256_file(&release.join(path)).unwrap());
|
||||
}
|
||||
let unit = fs::read_to_string(release.join("bootstrap/deverd.service")).unwrap();
|
||||
assert!(unit.contains("ExecStart=/opt/dever/bin/deverd --root /opt/dever"));
|
||||
assert!(unit.contains("RuntimeDirectory=dever"));
|
||||
assert!(!unit.contains("Environment"));
|
||||
assert!(!unit.contains("PATH"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(release.join("bootstrap/dever-sandbox")).unwrap(),
|
||||
dever_sandbox::APPARMOR_PROFILE
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_authoring_rejects_missing_libraries_wrong_sonames_and_search_paths() {
|
||||
let mut author = Author::new();
|
||||
add_bootstrap(&mut author);
|
||||
author.settings["bootstrap"]["libraries"] = json!([]);
|
||||
assert!(author.reject().contains("libgcc_s.so.1"));
|
||||
|
||||
let mut author = Author::new();
|
||||
add_bootstrap(&mut author);
|
||||
fs::write(
|
||||
author.root.join("inputs/gcc"),
|
||||
native_elf::library("libwrong.so", &["libc.so.6"]),
|
||||
)
|
||||
.unwrap();
|
||||
author.settings["bootstrap"]["libraries"][0]["sha256"] =
|
||||
json!(sha256_file(&author.root.join("inputs/gcc")).unwrap());
|
||||
assert!(author.reject().contains("SONAME"));
|
||||
|
||||
let mut author = Author::new();
|
||||
add_bootstrap(&mut author);
|
||||
fs::write(
|
||||
author.root.join("inputs/dever"),
|
||||
native_elf::elf(None, &["libgcc_s.so.1"], Some("/usr/lib")),
|
||||
)
|
||||
.unwrap();
|
||||
author.settings["bootstrap"]["launcher"]["sha256"] =
|
||||
json!(sha256_file(&author.root.join("inputs/dever")).unwrap());
|
||||
assert!(author.reject().contains("RPATH"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_installed_versions_keep_their_original_bootstrap_service_template() {
|
||||
use dever_cli::toolchain::{Layout, MachineManager, Version};
|
||||
let mut author = Author::new();
|
||||
add_bootstrap(&mut author);
|
||||
author.save();
|
||||
let machine = TemporaryDirectory::new();
|
||||
let layout = Layout::new(machine.path().join("machine"));
|
||||
layout.initialize().unwrap();
|
||||
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
||||
let installed = layout.versions().join(version.as_str());
|
||||
let mut manifest = packaging::create(&author.root, &installed).unwrap();
|
||||
let service = installed.join("bootstrap/deverd.service");
|
||||
let current = fs::read_to_string(&service).unwrap();
|
||||
let old = current.replace("RestartSec=1", "RestartSec=2");
|
||||
assert_ne!(current, old);
|
||||
fs::write(&service, old).unwrap();
|
||||
let artifact = manifest
|
||||
.artifacts
|
||||
.iter_mut()
|
||||
.find(|artifact| artifact.path == "bootstrap/deverd.service")
|
||||
.unwrap();
|
||||
artifact.bytes = fs::metadata(&service).unwrap().len();
|
||||
artifact.sha256 = sha256_file(&service).unwrap();
|
||||
let encoded = serde_json::to_vec(&manifest).unwrap();
|
||||
let key =
|
||||
Ed25519KeyPair::from_pkcs8(&fs::read(author.root.join("signing.pk8")).unwrap()).unwrap();
|
||||
fs::write(installed.join("manifest.json"), &encoded).unwrap();
|
||||
fs::write(
|
||||
installed.join("manifest.sig"),
|
||||
key.sign(&encoded)
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
layout.state().join("trusted-release-key"),
|
||||
author
|
||||
.public_key
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>(),
|
||||
)
|
||||
.unwrap();
|
||||
let manager = MachineManager::new(layout);
|
||||
assert_eq!(
|
||||
manager.resolve_core(&version).unwrap(),
|
||||
installed.join("dever-core")
|
||||
);
|
||||
fs::write(service, b"unsigned modification").unwrap();
|
||||
assert!(
|
||||
manager
|
||||
.resolve_core(&version)
|
||||
.unwrap_err()
|
||||
.contains("integrity verification")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "root-only installer trust rejection; never touches the actual machine installation"]
|
||||
fn bootstrap_installer_rejects_untrusted_inputs_before_publication() {
|
||||
assert_eq!(rustix::process::geteuid().as_raw(), 0);
|
||||
assert!(
|
||||
bootstrap::install(Path::new("."))
|
||||
.unwrap_err()
|
||||
.contains("absolute paths")
|
||||
);
|
||||
assert!(
|
||||
bootstrap::recover(Path::new("."))
|
||||
.unwrap_err()
|
||||
.contains("absolute paths")
|
||||
);
|
||||
let mut author = Author::new();
|
||||
add_bootstrap(&mut author);
|
||||
author.save();
|
||||
let package = author.output("release");
|
||||
packaging::create(&author.root, &package).unwrap();
|
||||
let installer = TemporaryDirectory::new();
|
||||
let system = TemporaryDirectory::new();
|
||||
let key = installer.path().join("trusted.pub");
|
||||
fs::write(
|
||||
&key,
|
||||
author
|
||||
.public_key
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir(installer.path().join("config")).unwrap();
|
||||
let save = |key: &Path, activate: bool| {
|
||||
fs::write(installer.path().join("config/setting.json"), serde_json::to_vec(&json!({"release":package,"system_root":system.path(),"trusted_key":key,"activate_service":activate})).unwrap()).unwrap()
|
||||
};
|
||||
save(&key, true);
|
||||
assert!(
|
||||
bootstrap::install(installer.path())
|
||||
.unwrap_err()
|
||||
.contains("actual system root")
|
||||
);
|
||||
let inside = package.join("trusted.pub");
|
||||
fs::copy(&key, &inside).unwrap();
|
||||
save(&inside, false);
|
||||
assert!(
|
||||
bootstrap::install(installer.path())
|
||||
.unwrap_err()
|
||||
.contains("independently")
|
||||
);
|
||||
save(&key, false);
|
||||
fs::set_permissions(&key, fs::Permissions::from_mode(0o666)).unwrap();
|
||||
assert!(
|
||||
bootstrap::install(installer.path())
|
||||
.unwrap_err()
|
||||
.contains("root-owned")
|
||||
);
|
||||
fs::set_permissions(&key, fs::Permissions::from_mode(0o644)).unwrap();
|
||||
let signature = fs::read(package.join("manifest.sig")).unwrap();
|
||||
fs::write(package.join("manifest.sig"), "00".repeat(64)).unwrap();
|
||||
assert!(
|
||||
bootstrap::install(installer.path())
|
||||
.unwrap_err()
|
||||
.contains("signature verification")
|
||||
);
|
||||
fs::write(package.join("manifest.sig"), signature).unwrap();
|
||||
// The key is pinned before the intentionally non-executable synthetic core
|
||||
// fails its health check. Empty/partial temporary files model interrupted
|
||||
// writes without injecting product failpoints or touching real services.
|
||||
let state = system.path().join("opt/dever/state");
|
||||
let temporary_key = state.join(".trusted-release-key.next");
|
||||
let pinned_key = state.join("trusted-release-key");
|
||||
for partial in ["", "abc"] {
|
||||
fs::write(&temporary_key, partial).unwrap();
|
||||
let error = bootstrap::install(installer.path()).unwrap_err();
|
||||
assert!(!error.contains("temporary release key"), "{error}");
|
||||
assert_eq!(
|
||||
fs::read_to_string(&pinned_key).unwrap().trim(),
|
||||
fs::read_to_string(&key).unwrap()
|
||||
);
|
||||
assert!(!temporary_key.exists());
|
||||
fs::remove_file(&pinned_key).unwrap();
|
||||
}
|
||||
fs::write(&pinned_key, "11".repeat(32)).unwrap();
|
||||
assert!(
|
||||
bootstrap::install(installer.path())
|
||||
.unwrap_err()
|
||||
.contains("pinned release key")
|
||||
);
|
||||
assert_eq!(fs::read_to_string(&pinned_key).unwrap(), "11".repeat(32));
|
||||
fs::remove_file(&pinned_key).unwrap();
|
||||
fs::write(package.join("bootstrap/lib/libgcc_s.so.1"), b"tampered").unwrap();
|
||||
assert!(
|
||||
bootstrap::install(installer.path())
|
||||
.unwrap_err()
|
||||
.contains("integrity verification")
|
||||
);
|
||||
fs::remove_file(package.join("bootstrap/lib/libgcc_s.so.1")).unwrap();
|
||||
assert!(bootstrap::install(installer.path()).is_err());
|
||||
assert!(!system.path().join("usr/local/bin/dever").exists());
|
||||
assert!(
|
||||
!system
|
||||
.path()
|
||||
.join("opt/dever/state/active-version")
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
|
||||
// Independent construction of a one-node journal identity lets recovery tests
|
||||
// simulate interruption at publication boundaries without product failpoints.
|
||||
fn node_hash(kind: &str, bytes: &[u8]) -> String {
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(0u64.to_le_bytes());
|
||||
hash.update(
|
||||
if kind == "directory" {
|
||||
0o755u32
|
||||
} else {
|
||||
0o644u32
|
||||
}
|
||||
.to_le_bytes(),
|
||||
);
|
||||
hash.update(kind.as_bytes());
|
||||
if kind == "file" {
|
||||
hash.update((bytes.len() as u64).to_le_bytes());
|
||||
hash.update(bytes);
|
||||
}
|
||||
format!("{:x}", hash.finalize())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "root-only crash-recovery fixture; all journal targets are below an owned image root"]
|
||||
fn bootstrap_recovery_is_idempotent_and_rejects_unrecorded_targets() {
|
||||
assert_eq!(rustix::process::geteuid().as_raw(), 0);
|
||||
let system = TemporaryDirectory::new();
|
||||
let root = system.path();
|
||||
let layout = dever_cli::toolchain::Layout::new(root.join("opt/dever"));
|
||||
layout.initialize().unwrap();
|
||||
for directory in [
|
||||
"usr/local/bin",
|
||||
"etc/systemd/system/multi-user.target.wants",
|
||||
"etc/apparmor.d",
|
||||
] {
|
||||
fs::create_dir_all(root.join(directory)).unwrap();
|
||||
}
|
||||
let service = root.join("etc/systemd/system/deverd.service");
|
||||
fs::write(&service, b"new service").unwrap();
|
||||
let entry = root.join("usr/local/bin/dever");
|
||||
symlink("/opt/dever/bin/dever", &entry).unwrap();
|
||||
let enable_stage = root
|
||||
.join("etc/systemd/system/multi-user.target.wants/.dever-bootstrap-deverd.service.next");
|
||||
symlink("../deverd.service", &enable_stage).unwrap();
|
||||
let active_stage = layout.state().join(".dever-bootstrap-active-version.next");
|
||||
fs::write(&active_stage, b"new active").unwrap();
|
||||
let sentinel = root.join("etc/systemd/system/unrelated.service");
|
||||
fs::write(&sentinel, b"keep").unwrap();
|
||||
let link_hash =
|
||||
|target: &str| format!("{:x}", Sha256::digest(format!("link\0{target}").as_bytes()));
|
||||
let journal = json!({"format":"dever-bootstrap-transaction-v1","phase":"prepared","service_was_active":null,"service_start_requested":false,"changes":[
|
||||
{"target":"sandbox","before":null,"after":node_hash("directory",b"")},
|
||||
{"target":"apparmor","before":null,"after":node_hash("file",b"new profile")},
|
||||
{"target":"bin","before":node_hash("directory",b""),"after":node_hash("directory",b"")},
|
||||
{"target":"service","before":null,"after":node_hash("file",b"new service")},
|
||||
{"target":"entry","before":null,"after":link_hash("/opt/dever/bin/dever")},
|
||||
{"target":"enable","before":null,"after":link_hash("../deverd.service")},
|
||||
{"target":"active","before":null,"after":node_hash("file",b"new active")}
|
||||
]});
|
||||
fs::create_dir(root.join("opt/dever/.dever-bootstrap-sandbox.next")).unwrap();
|
||||
fs::write(
|
||||
root.join("etc/apparmor.d/.dever-bootstrap-dever-sandbox.next"),
|
||||
b"new profile",
|
||||
)
|
||||
.unwrap();
|
||||
let path = layout.state().join("bootstrap-install.json");
|
||||
fs::write(&path, serde_json::to_vec(&journal).unwrap()).unwrap();
|
||||
let mut invalid = journal.clone();
|
||||
invalid["changes"][0]["target"] = json!("../../unrelated");
|
||||
fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
|
||||
assert!(bootstrap::recover(root).is_err());
|
||||
assert_eq!(fs::read(&sentinel).unwrap(), b"keep");
|
||||
fs::write(&path, serde_json::to_vec(&journal).unwrap()).unwrap();
|
||||
fs::write(&service, b"not this transaction").unwrap();
|
||||
assert!(
|
||||
bootstrap::recover(root)
|
||||
.unwrap_err()
|
||||
.contains("outside this transaction")
|
||||
);
|
||||
assert_eq!(fs::read(&service).unwrap(), b"not this transaction");
|
||||
fs::write(&service, b"new service").unwrap();
|
||||
bootstrap::recover(root).unwrap();
|
||||
bootstrap::recover(root).unwrap();
|
||||
assert!(!service.exists());
|
||||
assert!(fs::symlink_metadata(&entry).is_err());
|
||||
assert!(!active_stage.exists());
|
||||
assert!(fs::symlink_metadata(&enable_stage).is_err());
|
||||
assert_eq!(fs::read(&sentinel).unwrap(), b"keep");
|
||||
assert!(!path.exists());
|
||||
|
||||
// A committed publication cleans only its old staging copies; it must not
|
||||
// undo the new active version, including after a failed cleanup retry.
|
||||
fs::write(&service, b"new service").unwrap();
|
||||
symlink("/opt/dever/bin/dever", &entry).unwrap();
|
||||
fs::create_dir(root.join("opt/dever/sandbox")).unwrap();
|
||||
fs::write(root.join("etc/apparmor.d/dever-sandbox"), b"new profile").unwrap();
|
||||
symlink(
|
||||
"../deverd.service",
|
||||
root.join("etc/systemd/system/multi-user.target.wants/deverd.service"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(layout.state().join("active-version"), b"new active").unwrap();
|
||||
fs::write(&active_stage, b"old active").unwrap();
|
||||
let mut committed = journal;
|
||||
committed["phase"] = json!("committed");
|
||||
fs::write(&path, serde_json::to_vec(&committed).unwrap()).unwrap();
|
||||
fs::write(&service, b"unexpected modification").unwrap();
|
||||
assert!(
|
||||
bootstrap::recover(root)
|
||||
.unwrap_err()
|
||||
.contains("committed bootstrap destination changed")
|
||||
);
|
||||
assert!(path.exists());
|
||||
assert_eq!(
|
||||
fs::read(layout.state().join("active-version")).unwrap(),
|
||||
b"new active"
|
||||
);
|
||||
fs::write(&service, b"new service").unwrap();
|
||||
bootstrap::recover(root).unwrap();
|
||||
bootstrap::recover(root).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(layout.state().join("active-version")).unwrap(),
|
||||
b"new active"
|
||||
);
|
||||
assert!(!active_stage.exists());
|
||||
assert!(!path.exists());
|
||||
assert_eq!(fs::read(sentinel).unwrap(), b"keep");
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
//! Real bootstrap installation and execution; no global service is changed.
|
||||
use super::*;
|
||||
use std::os::unix::fs::{MetadataExt, chown};
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::process::Child;
|
||||
|
||||
struct Daemon(Child);
|
||||
impl Drop for Daemon {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_socket(daemon: &mut Daemon, socket: &Path) {
|
||||
for _ in 0..200 {
|
||||
assert!(
|
||||
daemon.0.try_wait().unwrap().is_none(),
|
||||
"installed daemon exited before becoming ready"
|
||||
);
|
||||
if socket.exists() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
panic!("installed daemon did not create its socket");
|
||||
}
|
||||
|
||||
/// Clients share the daemon's PID namespace so SO_PEERCRED can identify both
|
||||
/// peers. Separate bwrap invocations would hide the daemon PID from clients.
|
||||
struct IsolatedDaemon {
|
||||
process: Daemon,
|
||||
namespace_pid: u32,
|
||||
namespace: std::os::fd::OwnedFd,
|
||||
root: PathBuf,
|
||||
stopped: bool,
|
||||
}
|
||||
|
||||
impl IsolatedDaemon {
|
||||
fn start(root: &Path) -> Self {
|
||||
let information = TemporaryDirectory::new();
|
||||
let path = information.path().join("namespace.json");
|
||||
let output = fs::File::create(&path).unwrap();
|
||||
let mut process = Daemon(
|
||||
isolated_os_namespace(root)
|
||||
.args([
|
||||
"--info-fd",
|
||||
"1",
|
||||
"--",
|
||||
"/fixture-proc-init",
|
||||
"/opt/dever/bin/deverd",
|
||||
"--root",
|
||||
"/opt/dever",
|
||||
])
|
||||
.stdout(Stdio::from(output))
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
wait_socket(&mut process, &root.join("run/dever/deverd.sock"));
|
||||
assert!(fs::metadata(&path).unwrap().len() <= 4096);
|
||||
let info: Value = serde_json::from_slice(&fs::read(path).unwrap()).unwrap();
|
||||
let namespace_pid = u32::try_from(info["child-pid"].as_u64().unwrap()).unwrap();
|
||||
assert_ne!(namespace_pid, 0);
|
||||
let namespace = rustix::process::pidfd_open(
|
||||
rustix::process::Pid::from_raw(i32::try_from(namespace_pid).unwrap()).unwrap(),
|
||||
rustix::process::PidfdFlags::empty(),
|
||||
)
|
||||
.unwrap();
|
||||
Self {
|
||||
process,
|
||||
namespace_pid,
|
||||
namespace,
|
||||
root: root.to_owned(),
|
||||
stopped: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn client(&mut self) -> Command {
|
||||
assert!(self.process.0.try_wait().unwrap().is_none());
|
||||
let target = fs::metadata(format!("/proc/{}/root", self.namespace_pid)).unwrap();
|
||||
let expected = fs::metadata(&self.root).unwrap();
|
||||
assert_eq!(
|
||||
(target.dev(), target.ino()),
|
||||
(expected.dev(), expected.ino())
|
||||
);
|
||||
let mut command = Command::new("/usr/bin/nsenter");
|
||||
command.env_clear().args([
|
||||
"--target",
|
||||
&self.namespace_pid.to_string(),
|
||||
"--all",
|
||||
"--root",
|
||||
"--wdns=/",
|
||||
"--",
|
||||
]);
|
||||
command
|
||||
}
|
||||
|
||||
fn stop(&mut self) -> Result<(), String> {
|
||||
if self.stopped {
|
||||
return Ok(());
|
||||
}
|
||||
// Target the pinned namespace init, not a reusable numeric PID. Its
|
||||
// termination also kills its namespace descendants; waiting only for
|
||||
// the outer bwrap process does not establish daemon lock release.
|
||||
match rustix::process::pidfd_send_signal(&self.namespace, rustix::process::Signal::KILL) {
|
||||
Ok(()) | Err(rustix::io::Errno::SRCH) => {}
|
||||
Err(error) => return Err(format!("cannot stop owned namespace: {error}")),
|
||||
}
|
||||
let expected = fs::metadata(&self.root).map_err(|error| error.to_string())?;
|
||||
let lock = fs::File::open(self.root.join("opt/dever/state/deverd.lock"))
|
||||
.map_err(|error| error.to_string())?;
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let wrapper_exited = self
|
||||
.process
|
||||
.0
|
||||
.try_wait()
|
||||
.map_err(|error| error.to_string())?
|
||||
.is_some();
|
||||
let namespace_exited = match fs::metadata(format!("/proc/{}/root", self.namespace_pid))
|
||||
{
|
||||
Ok(current) => (current.dev(), current.ino()) != (expected.dev(), expected.ino()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => true,
|
||||
Err(error) => return Err(format!("cannot inspect owned namespace exit: {error}")),
|
||||
};
|
||||
if wrapper_exited && namespace_exited {
|
||||
match fs2::FileExt::try_lock_exclusive(&lock) {
|
||||
Ok(()) => {
|
||||
self.stopped = true;
|
||||
return Ok(());
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
|
||||
Err(error) => {
|
||||
return Err(format!("cannot check owned daemon lock release: {error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return Err("owned namespace or daemon lock remained live after 10 seconds".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for IsolatedDaemon {
|
||||
fn drop(&mut self) {
|
||||
// Cleanup must not cause a second panic while unwinding a failed
|
||||
// assertion. Normal stage transitions explicitly check stop().
|
||||
if let Err(error) = self.stop() {
|
||||
eprintln!("bootstrap fixture cleanup failed: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_inputs(author: &Path) {
|
||||
let launcher = input(
|
||||
author,
|
||||
Path::new(env!("CARGO_BIN_EXE_dever-launcher")),
|
||||
"bootstrap/dever",
|
||||
);
|
||||
let daemon = input(
|
||||
author,
|
||||
Path::new(env!("CARGO_BIN_EXE_deverd")),
|
||||
"bootstrap/deverd",
|
||||
);
|
||||
let mut gcc = input(
|
||||
author,
|
||||
Path::new("/lib/x86_64-linux-gnu/libgcc_s.so.1"),
|
||||
"bootstrap/libgcc_s.so.1",
|
||||
);
|
||||
gcc["path"] = json!("libgcc_s.so.1");
|
||||
let path = author.join("config/setting.json");
|
||||
let mut settings: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
settings["bootstrap"] = json!({"launcher":launcher,"daemon":daemon,"libraries":[gcc]});
|
||||
fs::write(path, serde_json::to_vec(&settings).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires optimized author archives and Linux root; installs only into an owned image root"]
|
||||
fn signed_bootstrap_installs_and_serves_two_projects_without_host_libraries() {
|
||||
assert_eq!(rustix::process::geteuid().as_raw(), 0);
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let author = TemporaryDirectory::new();
|
||||
let key = author_inputs(author.path(), &workspace);
|
||||
add_inputs(author.path());
|
||||
let downloaded = machine_temporary_directory(&workspace);
|
||||
let release = downloaded.path().join("release");
|
||||
let manifest = packaging::create(author.path(), &release).unwrap();
|
||||
let image = machine_temporary_directory(&workspace);
|
||||
ecosystems::minimal_os(image.path());
|
||||
let installer = TemporaryDirectory::new();
|
||||
let public = installer.path().join("release.pub");
|
||||
fs::write(
|
||||
&public,
|
||||
key.public_key()
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>(),
|
||||
)
|
||||
.unwrap();
|
||||
write(installer.path(), "config/setting.json", serde_json::to_vec(&json!({
|
||||
"release":release,"system_root":image.path(),"trusted_key":public,"activate_service":false
|
||||
})).unwrap());
|
||||
// This author-built installer is the independently trusted first program;
|
||||
// no executable from an unchecked downloaded directory starts as root.
|
||||
command(
|
||||
Command::new(env!("CARGO_BIN_EXE_dever-launcher"))
|
||||
.arg("--dever-install")
|
||||
.arg(installer.path()),
|
||||
);
|
||||
let layout = Layout::new(image.path().join("opt/dever"));
|
||||
let sandbox_artifact = manifest
|
||||
.artifacts
|
||||
.iter()
|
||||
.find(|artifact| artifact.path == "sandbox/linux-x86_64/bin/bwrap")
|
||||
.unwrap();
|
||||
let sandbox_store = layout.root().join("sandbox");
|
||||
let sandbox_helper = sandbox_store.join(&sandbox_artifact.sha256).join("bwrap");
|
||||
assert_eq!(
|
||||
sha256_file(&sandbox_helper).unwrap(),
|
||||
sandbox_artifact.sha256
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&sandbox_helper).unwrap().mode() & 0o7777,
|
||||
0o755
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(image.path().join("etc/apparmor.d/dever-sandbox")).unwrap(),
|
||||
dever_sandbox::APPARMOR_PROFILE
|
||||
);
|
||||
let launcher = layout.bin().join("dever");
|
||||
let executable = layout.bin().join("deverd");
|
||||
assert_eq!(
|
||||
fs::read_link(image.path().join("usr/local/bin/dever")).unwrap(),
|
||||
Path::new("/opt/dever/bin/dever")
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_link(
|
||||
image
|
||||
.path()
|
||||
.join("etc/systemd/system/multi-user.target.wants/deverd.service")
|
||||
)
|
||||
.unwrap(),
|
||||
Path::new("../deverd.service")
|
||||
);
|
||||
let unit = fs::read_to_string(image.path().join("etc/systemd/system/deverd.service")).unwrap();
|
||||
assert!(unit.contains("ExecStart=/opt/dever/bin/deverd --root /opt/dever"));
|
||||
assert!(unit.contains("User=root"));
|
||||
for file in [
|
||||
&launcher,
|
||||
&executable,
|
||||
&layout.bin().join("lib/libgcc_s.so.1"),
|
||||
&layout.state().join("trusted-release-key"),
|
||||
&image.path().join("etc/systemd/system/deverd.service"),
|
||||
] {
|
||||
let metadata = fs::metadata(file).unwrap();
|
||||
assert_eq!(metadata.uid(), 0);
|
||||
assert_eq!(metadata.mode() & 0o022, 0);
|
||||
}
|
||||
let active = fs::read(layout.state().join("active-version")).unwrap();
|
||||
let launcher_hash = sha256_file(&launcher).unwrap();
|
||||
// Model an older, correctly signed bootstrap service template. Its exact
|
||||
// signed bytes establish ownership; only the incoming payload must match
|
||||
// the current installer's supported service template.
|
||||
let unit_path = image.path().join("etc/systemd/system/deverd.service");
|
||||
let current_unit = fs::read_to_string(&unit_path).unwrap();
|
||||
let old_unit = current_unit.replace("RestartSec=1", "RestartSec=2");
|
||||
assert_ne!(old_unit, current_unit);
|
||||
fs::write(layout.bin().join("deverd.service"), &old_unit).unwrap();
|
||||
let mut old_manifest: Value =
|
||||
serde_json::from_slice(&fs::read(layout.bin().join("manifest.json")).unwrap()).unwrap();
|
||||
let service_artifact = old_manifest["artifacts"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.iter_mut()
|
||||
.find(|artifact| artifact["path"] == "bootstrap/deverd.service")
|
||||
.unwrap();
|
||||
service_artifact["bytes"] = json!(old_unit.len());
|
||||
service_artifact["sha256"] = json!(sha256_file(&layout.bin().join("deverd.service")).unwrap());
|
||||
let encoded = serde_json::to_vec(&old_manifest).unwrap();
|
||||
fs::write(
|
||||
layout.bin().join("manifest.sig"),
|
||||
key.sign(&encoded)
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(layout.bin().join("manifest.json"), encoded).unwrap();
|
||||
fs::write(&unit_path, b"unrelated system unit").unwrap();
|
||||
let rejected = Command::new(env!("CARGO_BIN_EXE_dever-launcher"))
|
||||
.env_clear()
|
||||
.arg("--dever-install")
|
||||
.arg(installer.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!rejected.status.success());
|
||||
assert!(String::from_utf8_lossy(&rejected.stderr).contains("unrelated system service"));
|
||||
assert_eq!(
|
||||
fs::read(layout.state().join("active-version")).unwrap(),
|
||||
active
|
||||
);
|
||||
fs::write(&unit_path, old_unit).unwrap();
|
||||
// Retain a different, correctly signed static helper identity through an
|
||||
// upgrade. ELF trailing padding keeps this executable's actual behavior.
|
||||
let mut previous_helper = fs::read(&sandbox_helper).unwrap();
|
||||
previous_helper.push(0);
|
||||
let previous_hash = dever_sandbox::validate_bwrap(&previous_helper).unwrap();
|
||||
let previous_root = sandbox_store.join(&previous_hash);
|
||||
fs::create_dir(&previous_root).unwrap();
|
||||
fs::write(previous_root.join("bwrap"), &previous_helper).unwrap();
|
||||
fs::set_permissions(
|
||||
previous_root.join("bwrap"),
|
||||
fs::Permissions::from_mode(0o755),
|
||||
)
|
||||
.unwrap();
|
||||
let mut previous_manifest = manifest.clone();
|
||||
let previous_artifact = previous_manifest
|
||||
.artifacts
|
||||
.iter_mut()
|
||||
.find(|artifact| artifact.path == sandbox_artifact.path)
|
||||
.unwrap();
|
||||
previous_artifact.sha256 = previous_hash.clone();
|
||||
previous_artifact.bytes = previous_helper.len() as u64;
|
||||
let previous_encoded = serde_json::to_vec(&previous_manifest).unwrap();
|
||||
fs::write(
|
||||
previous_root.join("manifest.sig"),
|
||||
key.sign(&previous_encoded)
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(previous_root.join("manifest.json"), previous_encoded).unwrap();
|
||||
// Retry is supported, but a later damaged package cannot damage the
|
||||
// published launcher or compiler selection.
|
||||
command(
|
||||
Command::new(env!("CARGO_BIN_EXE_dever-launcher"))
|
||||
.arg("--dever-install")
|
||||
.arg(installer.path()),
|
||||
);
|
||||
assert_eq!(fs::read_to_string(&unit_path).unwrap(), current_unit);
|
||||
assert_eq!(
|
||||
sha256_file(&sandbox_helper).unwrap(),
|
||||
sandbox_artifact.sha256
|
||||
);
|
||||
assert_eq!(
|
||||
sha256_file(&previous_root.join("bwrap")).unwrap(),
|
||||
previous_hash
|
||||
);
|
||||
assert_eq!(fs::read_dir(&sandbox_store).unwrap().count(), 2);
|
||||
let before_bad_install = fs::read(layout.state().join("active-version")).unwrap();
|
||||
fs::write(
|
||||
release.join("bootstrap/lib/libgcc_s.so.1"),
|
||||
b"changed after signing",
|
||||
)
|
||||
.unwrap();
|
||||
let failed = Command::new(env!("CARGO_BIN_EXE_dever-launcher"))
|
||||
.env_clear()
|
||||
.arg("--dever-install")
|
||||
.arg(installer.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!failed.status.success());
|
||||
assert_eq!(
|
||||
fs::read(layout.state().join("active-version")).unwrap(),
|
||||
before_bad_install
|
||||
);
|
||||
assert_eq!(sha256_file(&launcher).unwrap(), launcher_hash);
|
||||
assert!(!active.is_empty());
|
||||
drop(downloaded);
|
||||
drop(author);
|
||||
drop(installer);
|
||||
for name in ["first", "second"] {
|
||||
application(&image.path().join(name), "base");
|
||||
}
|
||||
let mut daemon = IsolatedDaemon::start(image.path());
|
||||
for name in ["first", "second"] {
|
||||
command(
|
||||
daemon
|
||||
.client()
|
||||
.args(["/usr/local/bin/dever", "check", &format!("/{name}")]),
|
||||
);
|
||||
assert_response(command(daemon.client().args([
|
||||
"/usr/local/bin/dever",
|
||||
"run",
|
||||
&format!("/{name}"),
|
||||
"--",
|
||||
"sample.value.exercise",
|
||||
"{}",
|
||||
])));
|
||||
command(daemon.client().args([
|
||||
"/usr/local/bin/dever",
|
||||
"build",
|
||||
&format!("/{name}"),
|
||||
"--output",
|
||||
&format!("/{name}/program"),
|
||||
]));
|
||||
}
|
||||
let status = command(
|
||||
daemon
|
||||
.client()
|
||||
.args(["/usr/local/bin/dever", "cache", "status"]),
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&status.stdout).unwrap()["entries"],
|
||||
1
|
||||
);
|
||||
daemon
|
||||
.stop()
|
||||
.expect("isolated daemon and namespace fully stopped");
|
||||
drop(daemon);
|
||||
|
||||
// Namespace-root execution above proves the complete loader closure. These
|
||||
// clients retain real distinct kernel UIDs, without a single-UID userns
|
||||
// translating file ownership to overflow IDs.
|
||||
fs::set_permissions(image.path(), fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let mut daemon = Daemon(
|
||||
Command::new(&executable)
|
||||
.env_clear()
|
||||
.args(["--root", layout.root().to_str().unwrap()])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
wait_socket(&mut daemon, &layout.service_socket());
|
||||
for (name, uid) in [("first", 65533), ("second", 65534)] {
|
||||
let project = image.path().join(name);
|
||||
chown(&project, Some(uid), Some(uid)).unwrap();
|
||||
assert_response(command(
|
||||
Command::new(&launcher)
|
||||
.uid(uid)
|
||||
.gid(uid)
|
||||
.arg("run")
|
||||
.arg(&project)
|
||||
.args(["--", "sample.value.exercise", "{}"]),
|
||||
));
|
||||
let denied = Command::new(&launcher)
|
||||
.uid(uid)
|
||||
.gid(uid)
|
||||
.env_clear()
|
||||
.args(["use", manifest.version.as_str()])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!denied.status.success());
|
||||
assert_eq!(
|
||||
fs::read(layout.state().join("active-version")).unwrap(),
|
||||
before_bad_install
|
||||
);
|
||||
}
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 1);
|
||||
drop(daemon);
|
||||
for name in ["first", "second"] {
|
||||
fs::remove_dir_all(image.path().join(name).join("module")).unwrap();
|
||||
}
|
||||
fs::remove_dir_all(layout.root()).unwrap();
|
||||
for name in ["first", "second"] {
|
||||
assert_response(command(isolated_os_command(image.path()).args([
|
||||
&format!("/{name}/program"),
|
||||
"sample.value.exercise",
|
||||
"{}",
|
||||
])));
|
||||
}
|
||||
write(&workspace, "target/closure-linux-bootstrap-acceptance.json", serde_json::to_vec_pretty(&json!({
|
||||
"format":"dever-linux-bootstrap-acceptance-v1","platform":"linux-x86_64",
|
||||
"bootstrap":manifest.artifacts.iter().filter(|artifact| artifact.path.starts_with("bootstrap/")).collect::<Vec<_>>(),
|
||||
"checks":{"signed_first_install":true,"stable_public_entry":true,"root_owned_systemd_assets":true,"two_projects_in_minimal_os_root":true,"real_distinct_uids":true,"shared_compile_entries":1,"failed_install_preserves_old_entry":true,"source_and_machine_removed_execution":true},
|
||||
"global_service_actions":"not_run","public_release":"not_published"
|
||||
})).unwrap());
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Owned signed CLI and relocated output; the tool is an ordinary dynamic ELF.
|
||||
use super::*;
|
||||
use dever_cli::libs::LockFile;
|
||||
use dever_cli::packages::LockedPackage;
|
||||
use dever_cli::toolchain::artifact_put;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
fn project(root: &Path, workspace: &Path) {
|
||||
write(
|
||||
root,
|
||||
"module/media/tool/port.dever",
|
||||
"execute(args: List<Text>, stdin: Bytes?) (output: dever.process.Output) fails dever.process.Error\n",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/media/tool/adapter.dever",
|
||||
"external command \"bin/tool\" {}\n",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/media/tool/app.dever",
|
||||
r#"execute() (passed: Bool) {
|
||||
input = dever.bytes.from_ints([0, 255, 128, 10])
|
||||
output = port.execute(["a b", "$(literal);*"], input)
|
||||
passed = output.code == 23 and output.stdout == input and dever.bytes.to_text(output.stderr) == "ordinary command"
|
||||
}
|
||||
"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/media/tool/api.dever",
|
||||
"cmd execute = app.execute\n",
|
||||
);
|
||||
write(root, "config/setting.json", "{}");
|
||||
let directory = root.join("module/media/tool/bin");
|
||||
fs::create_dir_all(directory.join("lib")).unwrap();
|
||||
command(
|
||||
Command::new("/usr/bin/cc")
|
||||
.arg("-B/usr/bin/")
|
||||
.args(["-shared", "-fPIC", "-Wl,-soname,libcommand.so", "-o"])
|
||||
.arg(directory.join("lib/libcommand.so"))
|
||||
.arg(workspace.join("test/dever-cli-tests/support/command_library.c")),
|
||||
);
|
||||
command(
|
||||
Command::new("/usr/bin/cc")
|
||||
.arg("-B/usr/bin/")
|
||||
.arg(workspace.join("test/dever-cli-tests/support/command_tool.c"))
|
||||
.arg("-L")
|
||||
.arg(directory.join("lib"))
|
||||
.arg("-lcommand")
|
||||
.arg("-o")
|
||||
.arg(directory.join("tool")),
|
||||
);
|
||||
}
|
||||
|
||||
fn packaged_project(root: &Path, source: &Path, layout: &Layout) {
|
||||
let manifest = br#"{"format":"dever-package-v1","name":"media","version":"1.0.0","dependencies":{},"lib":[]}"#;
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
zip.start_file(
|
||||
"dever-package.json",
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(manifest).unwrap();
|
||||
for file in [
|
||||
"port.dever",
|
||||
"adapter.dever",
|
||||
"app.dever",
|
||||
"api.dever",
|
||||
"bin/tool",
|
||||
"bin/lib/libcommand.so",
|
||||
] {
|
||||
let name = format!("module/media/tool/{file}");
|
||||
zip.start_file(&name, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(&fs::read(source.join(name)).unwrap())
|
||||
.unwrap();
|
||||
}
|
||||
let archive = zip.finish().unwrap().into_inner();
|
||||
let receipt = artifact_put(layout, &archive).unwrap();
|
||||
fs::create_dir_all(root.join("module")).unwrap();
|
||||
write(
|
||||
root,
|
||||
"config/setting.json",
|
||||
r#"{"package":{"registry":"https://packages.example.test","use":["media@1.0.0"]}}"#,
|
||||
);
|
||||
let mut lock = LockFile::new(Vec::new()).unwrap();
|
||||
lock.packages.push(LockedPackage {
|
||||
name: "media".into(),
|
||||
version: "1.0.0".into(),
|
||||
sha256: receipt.sha256,
|
||||
bytes: archive.len() as u64,
|
||||
dependencies: Vec::new(),
|
||||
manifest_sha256: format!("{:x}", Sha256::digest(manifest)),
|
||||
});
|
||||
lock.write_atomic(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit current runtime archive, author C/LLVM inputs and owned daemon/sandbox"]
|
||||
fn signed_cli_runs_and_packages_ordinary_command_with_private_library() {
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let author = TemporaryDirectory::new();
|
||||
let key = author_inputs(author.path(), &workspace);
|
||||
// This test validates the command boundary, not release-profile trimming.
|
||||
// Use the explicitly rebuilt archive containing the current wire ABI.
|
||||
let current = input(
|
||||
author.path(),
|
||||
&workspace.join("target/native-runtime-abi/debug/libdever_backend_bridge.a"),
|
||||
"command-base.a",
|
||||
);
|
||||
let settings_path = author.path().join("config/setting.json");
|
||||
let mut settings: Value = serde_json::from_slice(&fs::read(&settings_path).unwrap()).unwrap();
|
||||
settings["profiles"]["base"] = current;
|
||||
fs::write(settings_path, serde_json::to_vec(&settings).unwrap()).unwrap();
|
||||
|
||||
let machine = machine_temporary_directory(&workspace);
|
||||
let layout = Layout::new(machine.path());
|
||||
layout.initialize().unwrap();
|
||||
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
||||
let release = layout.downloads().join(version.as_str());
|
||||
packaging::create(author.path(), &release).unwrap();
|
||||
install_release(&layout, &version, &key);
|
||||
let compiler = layout.versions().join(version.as_str()).join("dever-core");
|
||||
let mut daemon = daemon::TestDaemon::start(&layout);
|
||||
let source = TemporaryDirectory::new();
|
||||
project(source.path(), &workspace);
|
||||
command(Command::new(&compiler).arg("check").arg(source.path()));
|
||||
assert_response(command(
|
||||
Command::new(&compiler).arg("run").arg(source.path()).args([
|
||||
"--",
|
||||
"media.tool.execute",
|
||||
"{}",
|
||||
]),
|
||||
));
|
||||
assert!(!source.path().join("dever.lock").exists());
|
||||
|
||||
let deployment = TemporaryDirectory::new();
|
||||
let executable = deployment.path().join("program");
|
||||
command(
|
||||
Command::new(&compiler)
|
||||
.arg("build")
|
||||
.arg(source.path())
|
||||
.arg("--output")
|
||||
.arg(&executable),
|
||||
);
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 1);
|
||||
let package_source = TemporaryDirectory::new();
|
||||
packaged_project(package_source.path(), source.path(), &layout);
|
||||
assert_response(command(
|
||||
Command::new(&compiler)
|
||||
.arg("run")
|
||||
.arg(package_source.path())
|
||||
.args(["--", "media.tool.execute", "{}"]),
|
||||
));
|
||||
let package_executable = deployment.path().join("package-program");
|
||||
command(
|
||||
Command::new(&compiler)
|
||||
.arg("build")
|
||||
.arg(package_source.path())
|
||||
.arg("--output")
|
||||
.arg(&package_executable),
|
||||
);
|
||||
write(deployment.path(), "config/setting.json", "{}");
|
||||
daemon.stop();
|
||||
drop(source);
|
||||
drop(package_source);
|
||||
drop(author);
|
||||
drop(machine);
|
||||
assert_response(command(
|
||||
Command::new(&executable)
|
||||
.args(["media.tool.execute", "{}"])
|
||||
.current_dir(deployment.path()),
|
||||
));
|
||||
assert_response(command(
|
||||
Command::new(&package_executable)
|
||||
.args(["media.tool.execute", "{}"])
|
||||
.current_dir(deployment.path()),
|
||||
));
|
||||
let cache = deployment.path().join("data/cache/lib");
|
||||
let bundle = fs::read_dir(cache).unwrap().next().unwrap().unwrap().path();
|
||||
let commands = bundle.join("commands");
|
||||
let tree = fs::read_dir(commands)
|
||||
.unwrap()
|
||||
.next()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.path();
|
||||
assert!(tree.join("lib/libcommand.so").is_file());
|
||||
fs::write(tree.join("lib/libcommand.so"), b"tampered").unwrap();
|
||||
let capture = deployment.path().join("tamper.stderr");
|
||||
let status = process::status(
|
||||
Command::new(&executable)
|
||||
.env_clear()
|
||||
.current_dir(deployment.path())
|
||||
.args(["media.tool.execute", "{}"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::from(fs::File::create(&capture).unwrap())),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!status.success());
|
||||
assert!(fs::read_to_string(capture).unwrap().contains("resource"));
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! Explicit x86 compiler -> ARM application acceptance; QEMU user mode does
|
||||
//! not establish the namespace/seccomp contract of external Workers.
|
||||
use super::*;
|
||||
use dever_cli::toolchain::BuildTarget;
|
||||
use dever_cli::toolchain::compilation::{CompileKind, CompileRequest};
|
||||
use dever_core::source::SourceMap;
|
||||
use dever_runtime::config::Settings;
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
|
||||
#[path = "cross_ecosystems.rs"]
|
||||
mod ecosystems;
|
||||
|
||||
fn add_arm_inputs(author: &Path, workspace: &Path) {
|
||||
let prepared = workspace.join("target/arm64-cross");
|
||||
let mut profiles = serde_json::Map::new();
|
||||
for profile in PROFILES {
|
||||
let name = format!("{profile}.a");
|
||||
profiles.insert(
|
||||
profile.into(),
|
||||
input(
|
||||
author,
|
||||
&prepared.join("archives").join(&name),
|
||||
&format!("arm/{name}"),
|
||||
),
|
||||
);
|
||||
}
|
||||
let libc = prepared.join("tools/usr/aarch64-linux-gnu/lib");
|
||||
let gcc = prepared.join("tools/usr/lib/gcc-cross/aarch64-linux-gnu/13");
|
||||
let links = |root: &Path, names: &[&str]| -> Vec<Value> {
|
||||
names
|
||||
.iter()
|
||||
.map(|name| {
|
||||
let mut entry = input(author, &root.join(name), &format!("arm/{name}"));
|
||||
entry["path"] = json!(name);
|
||||
entry
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let start = [
|
||||
links(&libc, &["crt1.o", "crti.o"]),
|
||||
links(&gcc, &["crtbeginT.o"]),
|
||||
]
|
||||
.concat();
|
||||
let libraries = [
|
||||
links(&libc, &["libc.a", "libm-2.39.a", "libmvec.a"]),
|
||||
links(&gcc, &["libgcc.a", "libgcc_eh.a"]),
|
||||
]
|
||||
.concat();
|
||||
let end = [links(&gcc, &["crtend.o"]), links(&libc, &["crtn.o"])].concat();
|
||||
let path = author.join("config/setting.json");
|
||||
let mut settings: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
settings["native_targets"] = json!({"linux-aarch64": {"profiles": profiles, "start":start, "libraries":libraries, "end":end}});
|
||||
fs::write(path, serde_json::to_vec(&settings).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
fn build(compiler: &Path, project: &Path, output: &Path, target: BuildTarget) {
|
||||
command(
|
||||
Command::new(compiler)
|
||||
.arg("build")
|
||||
.arg(project)
|
||||
.args(["--target", target.platform(), "--output"])
|
||||
.arg(output),
|
||||
);
|
||||
let bytes = fs::read(output).unwrap();
|
||||
let elf = goblin::elf::Elf::parse(&bytes).unwrap();
|
||||
assert!(elf.is_64 && elf.little_endian);
|
||||
assert!(elf.interpreter.is_none() && elf.libraries.is_empty());
|
||||
assert_eq!(
|
||||
elf.header.e_machine,
|
||||
match target {
|
||||
BuildTarget::LinuxX86_64 => goblin::elf::header::EM_X86_64,
|
||||
BuildTarget::LinuxAarch64 => goblin::elf::header::EM_AARCH64,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fn request(project: &Path, target: BuildTarget) -> CompileRequest {
|
||||
let sources = SourceMap::load_with_packages(&project.join("module"), None, &[]).unwrap();
|
||||
let bindings = Settings::load_project(project)
|
||||
.unwrap()
|
||||
.compilation_bindings();
|
||||
CompileRequest::new(
|
||||
env!("CARGO_PKG_VERSION").into(),
|
||||
CompileKind::Application,
|
||||
&sources,
|
||||
Some(bindings),
|
||||
&[],
|
||||
target,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn private_compiler(
|
||||
installed: &Path,
|
||||
destination: &Path,
|
||||
manifest: &dever_cli::toolchain::ReleaseManifest,
|
||||
) -> PathBuf {
|
||||
for artifact in &manifest.artifacts {
|
||||
if artifact.path != "dever-core"
|
||||
&& !artifact.path.starts_with("lib/")
|
||||
&& !artifact.path.starts_with("runtime/linux-aarch64/")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let relative = if artifact.path == "dever-core" {
|
||||
"dever"
|
||||
} else {
|
||||
&artifact.path
|
||||
};
|
||||
let output = destination.join(relative);
|
||||
fs::create_dir_all(output.parent().unwrap()).unwrap();
|
||||
fs::hard_link(installed.join(&artifact.path), output).unwrap();
|
||||
}
|
||||
destination.join("dever")
|
||||
}
|
||||
|
||||
fn reject_tampered_cache_hit(layout: &Layout, archive: &Path, request: &CompileRequest) {
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(archive)
|
||||
.unwrap();
|
||||
let mut original = [0; 1];
|
||||
file.read_exact(&mut original).unwrap();
|
||||
file.seek(SeekFrom::Start(0)).unwrap();
|
||||
file.write_all(&[original[0] ^ 1]).unwrap();
|
||||
file.sync_all().unwrap();
|
||||
let rejected = dever_cli::toolchain::compile(layout, request);
|
||||
file.seek(SeekFrom::Start(0)).unwrap();
|
||||
file.write_all(&original).unwrap();
|
||||
file.sync_all().unwrap();
|
||||
assert!(rejected.unwrap_err().contains("integrity verification"));
|
||||
}
|
||||
|
||||
fn export_http_application(compiler: &Path, projects: &Path, workspace: &Path) -> Value {
|
||||
let project = projects.join("http");
|
||||
for (path, source) in [
|
||||
(
|
||||
"user/account/model.dever",
|
||||
"global type Account { name: Text(1, 32) }",
|
||||
),
|
||||
(
|
||||
"platform/tenant/model.dever",
|
||||
"global type Tenant { name: Text(1, 32) }",
|
||||
),
|
||||
(
|
||||
"user/account/app.dever",
|
||||
"type Identity { id: Text\nuser_id: user.account.model.id?\ntenant_id: platform.tenant.model.id? }\nverify(claims: dever.auth.Claims) (identity: Identity) { identity = Identity { id = claims.subject\nuser_id = null\ntenant_id = null } }",
|
||||
),
|
||||
(
|
||||
"sample/http/app.dever",
|
||||
"hello() (message: Text) { message = \"arm-http\" }",
|
||||
),
|
||||
("sample/http/api.dever", "public get hello = app.hello"),
|
||||
] {
|
||||
write(&project, &format!("module/{path}"), source);
|
||||
}
|
||||
let settings = serde_json::to_vec(&json!({
|
||||
"http":{"host":"127.0.0.1","port":18080},
|
||||
"runtime":{"mode":"api","shutdown_ms":2000}, "log":{"level":"error"},
|
||||
"database":{"default":{"type":"sqlite","path":"data/http.sqlite","max_connections":1}},
|
||||
"auth":{"providers":{"session":{"verify":"user.account.verify","jwtSecret":"cross-acceptance-only-test-key-2026"}}},
|
||||
"sites":{"test":{"path":"","auth":"session"}}
|
||||
})).unwrap();
|
||||
write(&project, "config/setting.json", &settings);
|
||||
let program = project.join("application");
|
||||
build(compiler, &project, &program, BuildTarget::LinuxAarch64);
|
||||
let exported = workspace.join("target/arm64-cross/guest/apps/http");
|
||||
write(&exported, "config/setting.json", settings);
|
||||
fs::copy(&program, exported.join("application")).unwrap();
|
||||
json!({"sha256":sha256_file(&program).unwrap(), "profile":"sqlite", "url":"http://127.0.0.1:18080/sample/http/hello", "expected_data":"arm-http", "execution":"pending_guest_validation"})
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared x86/ARM four-profile runtime archives and private QEMU; starts only an owned daemon"]
|
||||
fn signed_host_compiler_builds_arm_profiles_and_runs_standalone_under_qemu() {
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()
|
||||
.unwrap();
|
||||
let emulator = workspace.join("target/arm64-cross/tools/usr/bin/qemu-aarch64-static");
|
||||
assert!(
|
||||
emulator.is_file(),
|
||||
"prepare the task-owned static QEMU emulator"
|
||||
);
|
||||
let author = TemporaryDirectory::new();
|
||||
let key = author_inputs(author.path(), &workspace);
|
||||
add_arm_inputs(author.path(), &workspace);
|
||||
let machine = machine_temporary_directory(&workspace);
|
||||
let layout = Layout::new(machine.path());
|
||||
layout.initialize().unwrap();
|
||||
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
||||
let release = layout.downloads().join(version.as_str());
|
||||
let manifest = packaging::create(author.path(), &release).unwrap();
|
||||
assert_eq!(manifest.platform, BuildTarget::LinuxX86_64.platform());
|
||||
install_release(&layout, &version, &key);
|
||||
fs::remove_dir_all(&release).unwrap();
|
||||
drop(author);
|
||||
let compiler = layout.versions().join(version.as_str()).join("dever-core");
|
||||
let mut daemon = daemon::TestDaemon::start(&layout);
|
||||
let projects = TemporaryDirectory::new();
|
||||
let standalone = TemporaryDirectory::new();
|
||||
let mut programs = Vec::new();
|
||||
let mut evidence = serde_json::Map::new();
|
||||
for profile in PROFILES {
|
||||
let project = projects.path().join(profile);
|
||||
application(&project, profile);
|
||||
let destination = standalone.path().join(profile);
|
||||
fs::create_dir_all(destination.join("config")).unwrap();
|
||||
fs::copy(
|
||||
project.join("config/setting.json"),
|
||||
destination.join("config/setting.json"),
|
||||
)
|
||||
.unwrap();
|
||||
let executable = destination.join("application");
|
||||
build(&compiler, &project, &executable, BuildTarget::LinuxAarch64);
|
||||
let before = cache_status(&layout).unwrap().entries;
|
||||
let repeated = project.join("repeated");
|
||||
build(&compiler, &project, &repeated, BuildTarget::LinuxAarch64);
|
||||
assert_eq!(
|
||||
sha256_file(&executable).unwrap(),
|
||||
sha256_file(&repeated).unwrap()
|
||||
);
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, before);
|
||||
evidence.insert(profile.into(), json!({"sha256":sha256_file(&executable).unwrap(), "bytes":fs::metadata(&executable).unwrap().len(), "cache_hit":true}));
|
||||
programs.push((destination, executable));
|
||||
}
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 4);
|
||||
let base_project = projects.path().join("base");
|
||||
let host = standalone.path().join("base/host-application");
|
||||
build(&compiler, &base_project, &host, BuildTarget::LinuxX86_64);
|
||||
assert_response(command(
|
||||
Command::new(&host).args(["sample.value.exercise", "{}"]),
|
||||
));
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 5);
|
||||
|
||||
let private = private_compiler(
|
||||
compiler.parent().unwrap(),
|
||||
&machine.path().join("private"),
|
||||
&manifest,
|
||||
);
|
||||
let private_directory = standalone.path().join("private-base");
|
||||
write(&private_directory, "config/setting.json", b"{}");
|
||||
let private_output = private_directory.join("application");
|
||||
build(
|
||||
&private,
|
||||
&base_project,
|
||||
&private_output,
|
||||
BuildTarget::LinuxAarch64,
|
||||
);
|
||||
programs.push((private_directory, private_output));
|
||||
|
||||
// Use the same canonical request to prove integrity is checked before a hit.
|
||||
let arm_request = request(&base_project, BuildTarget::LinuxAarch64);
|
||||
assert_eq!(
|
||||
dever_cli::toolchain::compile(&layout, &arm_request).unwrap(),
|
||||
fs::read(&programs[0].1).unwrap()
|
||||
);
|
||||
let archive = compiler
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("runtime/linux-aarch64/archives/base.a");
|
||||
reject_tampered_cache_hit(&layout, &archive, &arm_request);
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 5);
|
||||
let http = export_http_application(&compiler, projects.path(), &workspace);
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 6);
|
||||
daemon.stop();
|
||||
drop(projects);
|
||||
drop(machine);
|
||||
|
||||
for (directory, executable) in programs {
|
||||
assert!(!directory.join("module").exists());
|
||||
assert_response(command(
|
||||
Command::new(&emulator)
|
||||
.arg(executable)
|
||||
.current_dir(directory)
|
||||
.args(["sample.value.exercise", "{}"]),
|
||||
));
|
||||
}
|
||||
let report = json!({
|
||||
"format":"dever-cross-application-acceptance-v1", "host":"linux-x86_64", "target":"linux-aarch64",
|
||||
"programs":evidence, "http":http, "qemu_sha256":sha256_file(&emulator).unwrap(),
|
||||
"checks":{"signed_install":true,"profiles":4,"private_arm_build":true,"standalone_after_source_and_machine_removal":true,"sqlite_crud":true,"cache_entries":6,"cache_target_isolation":true,"tampered_target_cache_hit_rejected":true},
|
||||
"postgres_server":"not_run", "worker_sandbox":"requires_arm_kernel_system_emulation"
|
||||
});
|
||||
write(
|
||||
&workspace,
|
||||
"target/arm64-cross/compile-acceptance.json",
|
||||
serde_json::to_vec_pretty(&report).unwrap(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
//! Produce real standalone ARM Worker applications for full-system acceptance.
|
||||
use super::*;
|
||||
use dever_cli::libs::{
|
||||
self, LibSpec, LockFile, LockedArtifact, LockedLib, LockedWorker, RegistryRuntime,
|
||||
};
|
||||
use dever_cli::toolchain::{Artifact, ReleaseManifest};
|
||||
use dever_core::hir::ExternalWorkerContract;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const ECOSYSTEMS: [&str; 3] = ["pip", "npm", "go"];
|
||||
const TARGET: BuildTarget = BuildTarget::LinuxAarch64;
|
||||
|
||||
fn digest(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
fn add_target_sandbox(author: &Path, workspace: &Path) {
|
||||
let root = workspace.join("target/arm64-cross/sandbox");
|
||||
let mut files = Vec::new();
|
||||
let mut inputs = Vec::new();
|
||||
for directory in ["bin", "lib"] {
|
||||
for entry in fs::read_dir(root.join(directory)).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
assert!(entry.file_type().unwrap().is_file());
|
||||
let relative = format!("{directory}/{}", entry.file_name().to_str().unwrap());
|
||||
let bytes = fs::read(entry.path()).unwrap();
|
||||
let source = format!("inputs/arm-sandbox/{relative}");
|
||||
write(author, &source, &bytes);
|
||||
inputs.push(json!({"source":source,"path":relative,"sha256":digest(&bytes)}));
|
||||
files.push((relative, bytes));
|
||||
}
|
||||
}
|
||||
dever_sandbox::validate_assets_for_target(&files, TARGET.platform()).unwrap();
|
||||
let path = author.join("config/setting.json");
|
||||
let mut settings: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
settings["sandbox_targets"] = json!({"linux-aarch64":inputs});
|
||||
fs::write(path, serde_json::to_vec(&settings).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
// Sign the exact prepared archives: their identities also bind each Lib lock.
|
||||
// As in the native ecosystem fixture, this is an explicit private test author.
|
||||
fn sign_target_runtimes(
|
||||
release: &Path,
|
||||
workspace: &Path,
|
||||
key: &Ed25519KeyPair,
|
||||
ecosystems: &[&str],
|
||||
) -> BTreeMap<String, RegistryRuntime> {
|
||||
let mut manifest: ReleaseManifest =
|
||||
serde_json::from_slice(&fs::read(release.join("manifest.json")).unwrap()).unwrap();
|
||||
let mut runtimes = BTreeMap::new();
|
||||
for &ecosystem in ecosystems {
|
||||
let prepared = workspace
|
||||
.join("target/arm64-cross/ecosystems")
|
||||
.join(ecosystem);
|
||||
let settings: Value =
|
||||
serde_json::from_slice(&fs::read(prepared.join("config/setting.json")).unwrap())
|
||||
.unwrap();
|
||||
let runtime: RegistryRuntime = serde_json::from_value(settings["runtime"].clone()).unwrap();
|
||||
assert_eq!(runtime.target, TARGET.platform());
|
||||
let pack = fs::read(prepared.join("runtime.pack")).unwrap();
|
||||
assert_eq!(digest(&pack), runtime.pack.sha256);
|
||||
let prefix = format!("runtime/{ecosystem}/{}", TARGET.platform());
|
||||
write(release, &format!("{prefix}/runtime.pack"), pack);
|
||||
write(
|
||||
release,
|
||||
&format!("{prefix}/manifest.json"),
|
||||
serde_json::to_vec(&json!({"format":"dever-registry-runtime-v1","runtime":runtime}))
|
||||
.unwrap(),
|
||||
);
|
||||
for leaf in ["manifest.json", "runtime.pack"] {
|
||||
let path = format!("{prefix}/{leaf}");
|
||||
assert!(
|
||||
!manifest
|
||||
.artifacts
|
||||
.iter()
|
||||
.any(|artifact| artifact.path == path)
|
||||
);
|
||||
manifest.artifacts.push(Artifact {
|
||||
bytes: fs::metadata(release.join(&path)).unwrap().len(),
|
||||
sha256: sha256_file(&release.join(&path)).unwrap(),
|
||||
path,
|
||||
});
|
||||
}
|
||||
runtimes.insert(ecosystem.to_owned(), runtime);
|
||||
}
|
||||
manifest
|
||||
.artifacts
|
||||
.sort_by(|left, right| left.path.cmp(&right.path));
|
||||
let encoded = serde_json::to_vec(&manifest).unwrap();
|
||||
fs::write(release.join("manifest.json"), &encoded).unwrap();
|
||||
fs::write(
|
||||
release.join("manifest.sig"),
|
||||
key.sign(&encoded)
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>(),
|
||||
)
|
||||
.unwrap();
|
||||
runtimes
|
||||
}
|
||||
|
||||
fn application(root: &Path, ecosystem: &str) -> ExternalWorkerContract {
|
||||
let (extension, library, source) = match ecosystem {
|
||||
"pip" => (
|
||||
"py",
|
||||
"six@1.17.0",
|
||||
include_str!("../../ecosystem-release/arm64/probe.py"),
|
||||
),
|
||||
"npm" => (
|
||||
"mjs",
|
||||
"is-number@7.0.0",
|
||||
include_str!("../../ecosystem-release/arm64/probe.mjs"),
|
||||
),
|
||||
"go" => (
|
||||
"go",
|
||||
"github.com/google/uuid@1.6.0",
|
||||
include_str!("../../ecosystem-release/arm64/probe.go"),
|
||||
),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
write(root, "config/setting.json", "{}\n");
|
||||
write(
|
||||
root,
|
||||
"module/sample/worker/port.dever",
|
||||
"probe() (okay: Bool) fails app.ProbeResult\nreject() (okay: Bool) fails app.ProbeResult\n",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/sample/worker/app.dever",
|
||||
"type ProbeResult { error Unavailable(message: Text) }\nprobe() (okay: Bool) { okay = port.probe() }\nreject() (okay: Bool) { okay = port.reject() }\n",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/sample/worker/api.dever",
|
||||
"cmd probe = app.probe\ncmd reject = app.reject\n",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/sample/worker/adapter.dever",
|
||||
format!("external {ecosystem} \"worker/probe.{extension}\" {{ lib \"{library}\" }}\n"),
|
||||
);
|
||||
let sources = SourceMap::load(&root.join("module")).unwrap();
|
||||
let program = dever_core::check(&sources).unwrap_or_else(|errors| panic!("{errors:?}"));
|
||||
let contract = program.external_worker_contracts().remove(0);
|
||||
write(root, &contract.entry, source);
|
||||
contract
|
||||
}
|
||||
|
||||
fn pure_package_lock(
|
||||
workspace: &Path,
|
||||
contract: &ExternalWorkerContract,
|
||||
runtime: &RegistryRuntime,
|
||||
) -> (LockFile, BTreeMap<String, Vec<u8>>) {
|
||||
let spec: LibSpec = contract.libs[0].parse().unwrap();
|
||||
let (filename, extension) = match contract.ecosystem.as_str() {
|
||||
"pip" => ("six-1.17.0-py2.py3-none-any.whl", "whl"),
|
||||
"npm" => ("is-number-7.0.0.tgz", "tgz"),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let bytes = fs::read(
|
||||
workspace
|
||||
.join("target/arm64-cross/dependencies")
|
||||
.join(filename),
|
||||
)
|
||||
.unwrap();
|
||||
let sha256 = digest(&bytes);
|
||||
let artifact = LockedArtifact {
|
||||
target: TARGET.platform().into(),
|
||||
path: format!(
|
||||
"lib/{}/{}/{}/archive.{extension}",
|
||||
contract.ecosystem,
|
||||
digest(spec.distribution_name().as_bytes()),
|
||||
spec.version
|
||||
),
|
||||
bytes: bytes.len() as u64,
|
||||
sha256: sha256.clone(),
|
||||
};
|
||||
let mut lock = LockFile::new(vec![LockedLib {
|
||||
spec: spec.clone(),
|
||||
dependencies: vec![],
|
||||
runtime: runtime.pack.clone(),
|
||||
artifacts: vec![artifact],
|
||||
schema: contract.schema.clone(),
|
||||
build: None,
|
||||
}])
|
||||
.unwrap();
|
||||
if contract.ecosystem == "npm" {
|
||||
lock.npm.push(libs::npm::Environment::single(spec));
|
||||
}
|
||||
(lock, BTreeMap::from([(sha256, bytes)]))
|
||||
}
|
||||
|
||||
fn go_package_lock(
|
||||
workspace: &Path,
|
||||
runtime: &RegistryRuntime,
|
||||
) -> (LockFile, BTreeMap<String, Vec<u8>>) {
|
||||
let prepared = workspace.join("target/go-managed/dependency");
|
||||
let mut lock = LockFile::decode(&fs::read(prepared.join("dever.lock")).unwrap()).unwrap();
|
||||
libs::doctor(&lock).unwrap();
|
||||
assert!(lock.builds.is_empty() && lock.workers.is_empty());
|
||||
assert_eq!(lock.libs.len(), 1);
|
||||
assert_eq!(lock.libs[0].spec.key(), "go:github.com/google/uuid@1.6.0");
|
||||
let authenticated =
|
||||
libs::sumdb::verify_chain(&lock.go_sumdb, &libs::sumdb::Verifier::official()).unwrap();
|
||||
let mut archives = BTreeMap::new();
|
||||
for library in &mut lock.libs {
|
||||
// Go module ZIPs are source-only; keep the verified source and sumdb
|
||||
// evidence unchanged while selecting the new runtime and build target.
|
||||
library.runtime = runtime.pack.clone();
|
||||
for artifact in &mut library.artifacts {
|
||||
let bytes = fs::read(prepared.join("artifacts").join(&artifact.sha256)).unwrap();
|
||||
assert_eq!(digest(&bytes), artifact.sha256);
|
||||
assert_eq!(bytes.len() as u64, artifact.bytes);
|
||||
authenticated
|
||||
.verify_zip(&library.spec.name, &library.spec.version, &bytes)
|
||||
.unwrap();
|
||||
artifact.target = TARGET.platform().into();
|
||||
archives.insert(artifact.sha256.clone(), bytes);
|
||||
}
|
||||
}
|
||||
(lock, archives)
|
||||
}
|
||||
|
||||
fn prepare_locked_application(
|
||||
project: &Path,
|
||||
workspace: &Path,
|
||||
layout: &Layout,
|
||||
contract: ExternalWorkerContract,
|
||||
runtime: &RegistryRuntime,
|
||||
) -> LockFile {
|
||||
let (mut lock, archives) = if contract.ecosystem == "go" {
|
||||
go_package_lock(workspace, runtime)
|
||||
} else {
|
||||
pure_package_lock(workspace, &contract, runtime)
|
||||
};
|
||||
for (sha256, bytes) in archives {
|
||||
let receipt = dever_cli::toolchain::artifact_put(layout, &bytes).unwrap();
|
||||
assert_eq!(receipt.sha256, sha256);
|
||||
assert_eq!(receipt.bytes, bytes.len() as u64);
|
||||
}
|
||||
lock.workers.push(LockedWorker {
|
||||
libs: contract
|
||||
.libs
|
||||
.iter()
|
||||
.map(|value| value.parse().unwrap())
|
||||
.collect(),
|
||||
port: contract.port,
|
||||
adapter: contract.adapter,
|
||||
ecosystem: contract.ecosystem,
|
||||
runtime: Some(runtime.pack.clone()),
|
||||
entry: contract.entry,
|
||||
schema: contract.schema,
|
||||
capabilities: contract.capabilities,
|
||||
operations: contract.operations,
|
||||
});
|
||||
libs::doctor(&lock).unwrap();
|
||||
lock.write_atomic(project).unwrap();
|
||||
lock
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit ARM runtimes/dependencies/sandbox and native archives; builds with an owned host daemon and exports apps for ARM system QEMU"]
|
||||
fn signed_arm_ecosystems_build_offline_standalone_apps_for_system_guest() {
|
||||
build_guest_apps(&ECOSYSTEMS, "ecosystem-build-acceptance.json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit ARM inputs; rebuilds only Python for SDK shutdown regression under the ARM guest"]
|
||||
fn signed_arm_python_worker_rebuilds_with_current_sdk() {
|
||||
build_guest_apps(&["pip"], "python-build-acceptance.json");
|
||||
}
|
||||
|
||||
fn build_guest_apps(ecosystems: &[&str], report: &str) {
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()
|
||||
.unwrap();
|
||||
let destination = workspace.join("target/arm64-cross/guest/apps");
|
||||
fs::create_dir_all(&destination).unwrap();
|
||||
for &ecosystem in ecosystems {
|
||||
assert!(
|
||||
!destination.join(ecosystem).exists(),
|
||||
"guest app already exists; preserve or remove the prior owned fixture explicitly"
|
||||
);
|
||||
}
|
||||
let author = TemporaryDirectory::new();
|
||||
let key = author_inputs(author.path(), &workspace);
|
||||
add_arm_inputs(author.path(), &workspace);
|
||||
add_target_sandbox(author.path(), &workspace);
|
||||
let machine = machine_temporary_directory(&workspace);
|
||||
let layout = Layout::new(machine.path());
|
||||
layout.initialize().unwrap();
|
||||
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
||||
let release = layout.downloads().join(version.as_str());
|
||||
packaging::create(author.path(), &release).unwrap();
|
||||
let runtimes = sign_target_runtimes(&release, &workspace, &key, ecosystems);
|
||||
install_release(&layout, &version, &key);
|
||||
fs::remove_dir_all(&release).unwrap();
|
||||
drop(author);
|
||||
let compiler = layout.versions().join(version.as_str()).join("dever-core");
|
||||
let mut daemon = daemon::TestDaemon::start(&layout);
|
||||
let projects = TemporaryDirectory::new();
|
||||
let mut programs = serde_json::Map::new();
|
||||
for &ecosystem in ecosystems {
|
||||
let project = projects.path().join(ecosystem);
|
||||
let contract = application(&project, ecosystem);
|
||||
let lock = prepare_locked_application(
|
||||
&project,
|
||||
&workspace,
|
||||
&layout,
|
||||
contract,
|
||||
&runtimes[ecosystem],
|
||||
);
|
||||
command(
|
||||
Command::new(&compiler)
|
||||
.args(["lib", "doctor"])
|
||||
.arg(&project)
|
||||
.args(["--target", TARGET.platform()]),
|
||||
);
|
||||
let output = destination.join(ecosystem);
|
||||
fs::create_dir(&output).unwrap();
|
||||
write(&output, "config/setting.json", "{}\n");
|
||||
let executable = output.join("program");
|
||||
build(&compiler, &project, &executable, TARGET);
|
||||
programs.insert(ecosystem.into(), json!({
|
||||
"path":format!("{ecosystem}/program"), "sha256":sha256_file(&executable).unwrap(),
|
||||
"bytes":fs::metadata(&executable).unwrap().len(), "runtime":runtimes[ecosystem].pack,
|
||||
"libs":lock.libs.iter().map(|lib| lib.spec.key()).collect::<Vec<_>>(),
|
||||
"artifacts":lock.libs.iter().flat_map(|lib| &lib.artifacts).collect::<Vec<_>>()
|
||||
}));
|
||||
}
|
||||
assert_eq!(
|
||||
cache_status(&layout).unwrap().entries,
|
||||
ecosystems.len() as u64
|
||||
);
|
||||
daemon.stop();
|
||||
drop(projects);
|
||||
drop(machine);
|
||||
write(&workspace, &format!("target/arm64-cross/{report}"), serde_json::to_vec_pretty(&json!({
|
||||
"format":"dever-cross-ecosystem-build-v1", "target":TARGET.platform(), "programs":programs,
|
||||
"checks":{"signed_runtime_install":true,"offline_lib_doctor":ecosystems.len(),"actual_cli_arm_build":ecosystems.len(),"source_and_build_machine_removed":true},
|
||||
"guest_probe":{"arguments":["sample.worker.probe","{}"],"expected_data":true},
|
||||
"guest_failure":{"arguments":["sample.worker.reject","{}"],"expected":"runtime fault; next fresh probe still succeeds; no remaining Worker"},
|
||||
"guest_execution":"not_run", "sandbox_execution":"requires full ARM kernel guest"
|
||||
})).unwrap());
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
//! Explicit signed-release acceptance with no host language trees in the final root.
|
||||
use super::*;
|
||||
use std::os::unix::fs::{MetadataExt, chown};
|
||||
|
||||
const ECOSYSTEMS: [&str; 3] = ["pip", "npm", "go"];
|
||||
const STANDALONE_UID: u32 = 65534;
|
||||
|
||||
fn nonroot_standalone(source: &Path, expected_program: &Value) -> Value {
|
||||
let deployment = TemporaryDirectory::new_in(Path::new("/tmp"));
|
||||
let program = deployment.path().join("program");
|
||||
let config = deployment.path().join("config");
|
||||
let settings = config.join("setting.json");
|
||||
fs::create_dir(&config).unwrap();
|
||||
fs::copy(source.join("program"), &program).unwrap();
|
||||
fs::copy(source.join("config/setting.json"), &settings).unwrap();
|
||||
let identity = json!({
|
||||
"bytes": fs::metadata(&program).unwrap().len(),
|
||||
"sha256": sha256_file(&program).unwrap(),
|
||||
});
|
||||
assert_eq!(&identity, expected_program);
|
||||
for (path, mode) in [
|
||||
(program.as_path(), 0o700),
|
||||
(config.as_path(), 0o700),
|
||||
(settings.as_path(), 0o600),
|
||||
(deployment.path(), 0o700),
|
||||
] {
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(mode)).unwrap();
|
||||
chown(path, Some(STANDALONE_UID), Some(STANDALONE_UID)).unwrap();
|
||||
}
|
||||
// Only the standalone executable and private config cross this boundary.
|
||||
// The actual non-root process must extract its own embedded Worker bundle.
|
||||
assert!(!deployment.path().join("data").exists());
|
||||
let output = command(
|
||||
Command::new("/usr/bin/setpriv")
|
||||
.arg(format!("--reuid={STANDALONE_UID}"))
|
||||
.arg(format!("--regid={STANDALONE_UID}"))
|
||||
.args([
|
||||
"--clear-groups",
|
||||
"--inh-caps=-all",
|
||||
"--ambient-caps=-all",
|
||||
"--",
|
||||
])
|
||||
.arg(&program)
|
||||
.args(["sample.worker.probe", "{}"])
|
||||
.current_dir(deployment.path())
|
||||
.stdin(Stdio::null()),
|
||||
);
|
||||
eprintln!(
|
||||
"standalone UID {STANDALONE_UID} {}: {}\n{}",
|
||||
source.display(),
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_response(output);
|
||||
assert_eq!(
|
||||
fs::metadata(deployment.path().join("data/cache/lib"))
|
||||
.unwrap()
|
||||
.uid(),
|
||||
STANDALONE_UID
|
||||
);
|
||||
json!({"passed": true, "uid": STANDALONE_UID, "program": identity})
|
||||
}
|
||||
|
||||
fn dependency_fixture(workspace: &Path, ecosystem: &str) -> PathBuf {
|
||||
workspace.join(match ecosystem {
|
||||
"pip" => "target/source-build",
|
||||
"npm" => "target/npm-build",
|
||||
_ => unreachable!(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The fixtures were built against these exact pack bytes. The test author
|
||||
/// signs those inputs, rather than rewriting receipts for a recompressed pack.
|
||||
fn sign_build_runtimes(
|
||||
release: &Path,
|
||||
workspace: &Path,
|
||||
key: &Ed25519KeyPair,
|
||||
) -> dever_cli::toolchain::ReleaseManifest {
|
||||
use dever_cli::libs::{LockFile, RegistryRuntime, RuntimePack};
|
||||
|
||||
let mut manifest: dever_cli::toolchain::ReleaseManifest =
|
||||
serde_json::from_slice(&fs::read(release.join("manifest.json")).unwrap()).unwrap();
|
||||
for ecosystem in ["pip", "npm"] {
|
||||
let fixture = dependency_fixture(workspace, ecosystem);
|
||||
let settings: Value =
|
||||
serde_json::from_slice(&fs::read(fixture.join("config/setting.json")).unwrap())
|
||||
.unwrap();
|
||||
let runtime: RegistryRuntime = serde_json::from_value(settings["runtime"].clone()).unwrap();
|
||||
assert_eq!(runtime.target, "linux-x86_64");
|
||||
let pack = fixture.join(settings["runtime_file"].as_str().unwrap());
|
||||
assert_eq!(sha256_file(&pack).unwrap(), runtime.pack.sha256);
|
||||
let lock =
|
||||
LockFile::decode(&fs::read(fixture.join("dependency/dever.lock")).unwrap()).unwrap();
|
||||
assert!(
|
||||
!lock.builds.is_empty(),
|
||||
"acceptance requires actual source build receipts"
|
||||
);
|
||||
for receipt in &lock.builds {
|
||||
assert_eq!(receipt.runtime, runtime.pack);
|
||||
if let Some(python) = &receipt.auxiliary_runtime {
|
||||
let python_config: Value = serde_json::from_slice(
|
||||
&fs::read(dependency_fixture(workspace, "pip").join("config/setting.json"))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
*python,
|
||||
serde_json::from_value::<RuntimePack>(python_config["runtime"]["pack"].clone())
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
let prefix = format!("runtime/{ecosystem}/linux-x86_64");
|
||||
fs::copy(pack, release.join(&prefix).join("runtime.pack")).unwrap();
|
||||
fs::write(
|
||||
release.join(&prefix).join("manifest.json"),
|
||||
serde_json::to_vec(&json!({"format":"dever-registry-runtime-v1","runtime":runtime}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
for suffix in ["runtime.pack", "manifest.json"] {
|
||||
let name = format!("{prefix}/{suffix}");
|
||||
let artifact = manifest
|
||||
.artifacts
|
||||
.iter_mut()
|
||||
.find(|artifact| artifact.path == name)
|
||||
.unwrap();
|
||||
artifact.bytes = fs::metadata(release.join(&name)).unwrap().len();
|
||||
artifact.sha256 = sha256_file(&release.join(&name)).unwrap();
|
||||
}
|
||||
}
|
||||
let encoded = serde_json::to_vec(&manifest).unwrap();
|
||||
fs::write(release.join("manifest.json"), &encoded).unwrap();
|
||||
fs::write(
|
||||
release.join("manifest.sig"),
|
||||
key.sign(&encoded)
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>(),
|
||||
)
|
||||
.unwrap();
|
||||
manifest
|
||||
}
|
||||
|
||||
fn import_built_dependencies(project: &Path, fixture: &Path, layout: &Layout) {
|
||||
use dever_cli::libs::{LockFile, LockedWorker, build};
|
||||
|
||||
let mut lock =
|
||||
LockFile::decode(&fs::read(fixture.join("dependency/dever.lock")).unwrap()).unwrap();
|
||||
for artifact in lock
|
||||
.libs
|
||||
.iter()
|
||||
.flat_map(|lib| &lib.artifacts)
|
||||
.chain(build::npm_outputs(&lock).map(|receipt| &receipt.output))
|
||||
{
|
||||
let bytes = fs::read(fixture.join("dependency/artifacts").join(&artifact.sha256)).unwrap();
|
||||
let stored = dever_cli::toolchain::artifact_put(layout, &bytes).unwrap();
|
||||
assert_eq!(stored.sha256, artifact.sha256);
|
||||
assert_eq!(stored.bytes, artifact.bytes);
|
||||
}
|
||||
let sources = dever_core::source::SourceMap::load(&project.join("module")).unwrap();
|
||||
let program = dever_core::check(&sources).unwrap_or_else(|errors| panic!("{errors:?}"));
|
||||
let contract = program.external_worker_contracts().remove(0);
|
||||
let runtime = lock.libs[0].runtime.clone();
|
||||
assert!(lock.libs.iter().all(|lib| lib.runtime == runtime));
|
||||
assert!(lock.workers.is_empty());
|
||||
lock.workers.push(LockedWorker {
|
||||
port: contract.port,
|
||||
adapter: contract.adapter,
|
||||
ecosystem: contract.ecosystem,
|
||||
runtime: Some(runtime),
|
||||
entry: contract.entry,
|
||||
schema: contract.schema,
|
||||
capabilities: contract.capabilities,
|
||||
operations: contract.operations,
|
||||
libs: contract
|
||||
.libs
|
||||
.iter()
|
||||
.map(|spec| spec.parse().unwrap())
|
||||
.collect(),
|
||||
});
|
||||
dever_cli::libs::doctor(&lock).unwrap();
|
||||
lock.write_atomic(project).unwrap();
|
||||
}
|
||||
|
||||
fn ecosystem_inputs(author: &Path, workspace: &Path) {
|
||||
let prepared = workspace.join("target/ecosystem-release-inputs/prepared");
|
||||
let fragment: Value =
|
||||
serde_json::from_slice(&fs::read(prepared.join("config/setting.json")).unwrap()).unwrap();
|
||||
for ecosystem in ECOSYSTEMS {
|
||||
for entry in fragment["runtimes"][ecosystem]["files"].as_array().unwrap() {
|
||||
let relative = entry["source"].as_str().unwrap();
|
||||
let output = author.join(relative);
|
||||
fs::create_dir_all(output.parent().unwrap()).unwrap();
|
||||
// Author staging only; the maker materializes its own immutable pack.
|
||||
fs::hard_link(prepared.join(relative), output).unwrap();
|
||||
}
|
||||
}
|
||||
let config = author.join("config/setting.json");
|
||||
let mut settings: Value = serde_json::from_slice(&fs::read(&config).unwrap()).unwrap();
|
||||
settings["runtimes"] = fragment["runtimes"].clone();
|
||||
fs::write(config, serde_json::to_vec(&settings).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
fn application(root: &Path, ecosystem: &str) {
|
||||
let (extension, source) = match ecosystem {
|
||||
"pip" => ("py", include_str!("../../ecosystem-release/probe.py")),
|
||||
"npm" => ("mjs", include_str!("../../ecosystem-release/probe.mjs")),
|
||||
"go" => ("go", include_str!("../../ecosystem-release/probe.go")),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
write(root, "config/setting.json", "{}\n");
|
||||
write(
|
||||
root,
|
||||
"module/sample/worker/port.dever",
|
||||
"probe() (okay: Bool) fails app.ProbeResult\n",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/sample/worker/app.dever",
|
||||
"type ProbeResult { error Unavailable(message: Text) }\nprobe() (okay: Bool) { okay = port.probe() }\n",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/sample/worker/api.dever",
|
||||
"cmd probe = app.probe\n",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/sample/worker/adapter.dever",
|
||||
format!(
|
||||
"external {ecosystem} \"worker/probe.{extension}\" {{\n{}\n}}\n",
|
||||
match ecosystem {
|
||||
"pip" => "lib \"simplejson@3.20.1\"",
|
||||
"npm" => "lib \"bufferutil@4.0.9\"",
|
||||
"go" => "",
|
||||
_ => unreachable!(),
|
||||
}
|
||||
),
|
||||
);
|
||||
let sources = dever_core::source::SourceMap::load(&root.join("module")).unwrap();
|
||||
let program = dever_core::check(&sources).unwrap_or_else(|errors| panic!("{errors:?}"));
|
||||
let entry = &program.external_worker_contracts()[0].entry;
|
||||
write(root, entry, source);
|
||||
}
|
||||
|
||||
pub(super) fn minimal_os(root: &Path) {
|
||||
// This root contains only the named Linux OS ABI libraries. No host language
|
||||
// executable, standard library, PATH, package manager or source tree is visible.
|
||||
for name in [
|
||||
"libc.so.6",
|
||||
"libdl.so.2",
|
||||
"libm.so.6",
|
||||
"libpthread.so.0",
|
||||
"librt.so.1",
|
||||
"libutil.so.1",
|
||||
"libresolv.so.2",
|
||||
"ld-linux-x86-64.so.2",
|
||||
] {
|
||||
let source = Path::new("/lib/x86_64-linux-gnu").join(name);
|
||||
let output = root.join("lib/x86_64-linux-gnu").join(name);
|
||||
fs::create_dir_all(output.parent().unwrap()).unwrap();
|
||||
fs::copy(source, output).unwrap();
|
||||
}
|
||||
fs::create_dir(root.join("lib64")).unwrap();
|
||||
fs::copy(
|
||||
"/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2",
|
||||
root.join("lib64/ld-linux-x86-64.so.2"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir(root.join("tmp")).unwrap();
|
||||
fs::create_dir(root.join("proc")).unwrap();
|
||||
fs::create_dir(root.join("dev")).unwrap();
|
||||
// The outer namespace supplies only its own proc and synthetic device tree.
|
||||
assert!(!root.join("usr").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared pinned ecosystem inputs and root for owned namespace; no existing services"]
|
||||
fn signed_ecosystems_build_and_run_without_system_language_installations() {
|
||||
assert_eq!(
|
||||
rustix::process::geteuid().as_raw(),
|
||||
0,
|
||||
"explicit no-host acceptance requires the owned root namespace fixture"
|
||||
);
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let projects = TemporaryDirectory::new();
|
||||
for ecosystem in ECOSYSTEMS {
|
||||
application(&projects.path().join(ecosystem), ecosystem);
|
||||
}
|
||||
let author = TemporaryDirectory::new();
|
||||
let key = author_inputs(author.path(), &workspace);
|
||||
ecosystem_inputs(author.path(), &workspace);
|
||||
let machine = machine_temporary_directory(&workspace);
|
||||
let layout = Layout::new(machine.path());
|
||||
layout.initialize().unwrap();
|
||||
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
||||
let release = layout.downloads().join(version.as_str());
|
||||
command_with_timeout(
|
||||
Command::new(workspace.join("target/debug/examples/native-release"))
|
||||
.arg(author.path())
|
||||
.arg("--output")
|
||||
.arg(&release),
|
||||
// Debug gzip compresses complete language distributions; this is author
|
||||
// preparation, independent of the service's bounded compile deadline.
|
||||
Duration::from_secs(900),
|
||||
);
|
||||
let signed = sign_build_runtimes(&release, &workspace, &key);
|
||||
let mut runtimes = serde_json::Map::new();
|
||||
for ecosystem in ECOSYSTEMS {
|
||||
let path = format!("runtime/{ecosystem}/linux-x86_64/runtime.pack");
|
||||
let artifact = signed
|
||||
.artifacts
|
||||
.iter()
|
||||
.find(|artifact| artifact.path == path)
|
||||
.unwrap();
|
||||
runtimes.insert(
|
||||
ecosystem.into(),
|
||||
json!({"bytes":artifact.bytes,"sha256":artifact.sha256}),
|
||||
);
|
||||
}
|
||||
install_release(&layout, &version, &key);
|
||||
fs::remove_dir_all(&release).unwrap();
|
||||
drop(author);
|
||||
let core = layout.versions().join(version.as_str()).join("dever-core");
|
||||
let mut daemon = daemon::TestDaemon::start(&layout);
|
||||
let isolated = TemporaryDirectory::new();
|
||||
minimal_os(isolated.path());
|
||||
let mut programs = serde_json::Map::new();
|
||||
for ecosystem in ECOSYSTEMS {
|
||||
let project = projects.path().join(ecosystem);
|
||||
if ecosystem == "go" {
|
||||
command(Command::new(&core).args(["lib", "add"]).arg(&project));
|
||||
} else {
|
||||
import_built_dependencies(
|
||||
&project,
|
||||
&dependency_fixture(&workspace, ecosystem),
|
||||
&layout,
|
||||
);
|
||||
}
|
||||
let lock =
|
||||
dever_cli::libs::LockFile::decode(&fs::read(project.join("dever.lock")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(lock.workers.len(), 1);
|
||||
assert_eq!(lock.libs.is_empty(), ecosystem == "go");
|
||||
assert_eq!(lock.builds.is_empty(), ecosystem == "go");
|
||||
assert_eq!(
|
||||
lock.workers[0].runtime.as_ref().unwrap().sha256,
|
||||
runtimes[ecosystem]["sha256"].as_str().unwrap()
|
||||
);
|
||||
command(Command::new(&core).arg("check").arg(&project));
|
||||
assert_response(command(
|
||||
Command::new(&core)
|
||||
.arg("run")
|
||||
.arg(&project)
|
||||
.args(["--", "sample.worker.probe", "{}"]),
|
||||
));
|
||||
let destination = isolated.path().join(ecosystem);
|
||||
fs::create_dir(&destination).unwrap();
|
||||
write(&destination, "config/setting.json", "{}\n");
|
||||
let executable = destination.join("program");
|
||||
command(
|
||||
Command::new(&core)
|
||||
.arg("build")
|
||||
.arg(&project)
|
||||
.arg("--output")
|
||||
.arg(&executable),
|
||||
);
|
||||
programs.insert(ecosystem.into(), json!({"bytes":fs::metadata(&executable).unwrap().len(),"sha256":sha256_file(&executable).unwrap()}));
|
||||
}
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 3);
|
||||
daemon.stop();
|
||||
drop(machine);
|
||||
drop(projects);
|
||||
for ecosystem in ECOSYSTEMS {
|
||||
assert_response(command(
|
||||
isolated_os_command(isolated.path())
|
||||
.arg(format!("/{ecosystem}/program"))
|
||||
.args(["sample.worker.probe", "{}"]),
|
||||
));
|
||||
}
|
||||
let mut standalone_nonroot = serde_json::Map::new();
|
||||
for ecosystem in ECOSYSTEMS {
|
||||
standalone_nonroot.insert(
|
||||
ecosystem.into(),
|
||||
nonroot_standalone(&isolated.path().join(ecosystem), &programs[ecosystem]),
|
||||
);
|
||||
}
|
||||
write(&workspace, "target/closure-sandbox-ecosystem-release-acceptance.json", serde_json::to_vec_pretty(&json!({
|
||||
"format":"dever-ecosystem-release-acceptance-v1", "platform":"linux-x86_64",
|
||||
"runtimes":runtimes,"programs":programs,"standalone_nonroot":standalone_nonroot,
|
||||
"checks":{"signed_install":true,"locked_runtimes":3,"managed_check_run_build":3,"standalone_isolated_root":3,"standalone_nonroot":3,"shared_compile_entries":3,"python_stdlib_paths_in_bundle":true,"os_sandbox":true,"offline_build_receipt_replay":true},
|
||||
"os_baseline":"explicit Linux GNU OS libraries and named device nodes inside an owned root",
|
||||
"runtime_pack_provenance":"test author signed the exact pip/npm packs bound to the completed source builds; maker determinism is verified separately",
|
||||
"third_party_packages":{"pip":"simplejson@3.20.1 _speedups C extension","npm":"bufferutil@4.0.9 build/Release addon"}, "other_platforms":"not_run", "public_release":"not_published"
|
||||
})).unwrap());
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
//! Synthetic runtime closures exercise the production maker without host interpreters.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn add_file(author: &mut Author, ecosystem: &str, path: &str, bytes: &[u8]) {
|
||||
let source = format!("inputs/{ecosystem}/{path}");
|
||||
let file = author.root.join(&source);
|
||||
fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
fs::write(&file, bytes).unwrap();
|
||||
author.settings["runtimes"][ecosystem]["files"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(json!({"source": source, "path": path, "sha256": sha256_file(&file).unwrap()}));
|
||||
}
|
||||
|
||||
fn set_manifest(author: &mut Author, ecosystem: &str, change: impl FnOnce(&mut Value)) {
|
||||
let input = author.settings["runtimes"][ecosystem]["files"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.iter_mut()
|
||||
.find(|input| input["path"] == "dever-runtime.json")
|
||||
.unwrap();
|
||||
let path = author.root.join(input["source"].as_str().unwrap());
|
||||
let mut manifest: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
change(&mut manifest);
|
||||
fs::write(&path, serde_json::to_vec(&manifest).unwrap()).unwrap();
|
||||
input["sha256"] = json!(sha256_file(&path).unwrap());
|
||||
}
|
||||
|
||||
fn runtime_author() -> Author {
|
||||
let mut author = Author::new();
|
||||
for (ecosystem, name, version) in [
|
||||
("pip", "cpython", "3.12.0"),
|
||||
("npm", "node", "24.0.0"),
|
||||
("go", "go", "1.25.0"),
|
||||
] {
|
||||
author.settings["runtimes"][ecosystem] = json!({"name":name,"version":version,"files":[]});
|
||||
let mut descriptor = json!({"format":"dever-worker-runtime-v1","ecosystem":ecosystem,"target":platform_identity()});
|
||||
if ecosystem == "go" {
|
||||
let arch = if std::env::consts::ARCH == "x86_64" {
|
||||
"amd64"
|
||||
} else {
|
||||
"arm64"
|
||||
};
|
||||
descriptor["build"] = json!({"host":platform_identity(),"compiler":"bin/compile","linker":"bin/link","analyzer":"bin/analyze","stdlib_importcfg":"stdlib/importcfg","goos":"linux","goarch":arch,"go_version":format!("go version go1.25.0 linux/{arch}")});
|
||||
for path in ["bin/compile", "bin/link", "bin/analyze"] {
|
||||
add_file(
|
||||
&mut author,
|
||||
ecosystem,
|
||||
path,
|
||||
&native_elf::static_executable(),
|
||||
);
|
||||
}
|
||||
let mut importcfg = String::new();
|
||||
for name in ["runtime", "context", "encoding/json"] {
|
||||
let path = format!("stdlib/{name}.a");
|
||||
let object = format!("go object linux {arch} go1.25.0 fixture\n");
|
||||
let archive = format!(
|
||||
"!<arch>\n{:<16}{:<12}{:<6}{:<6}{:<8}{:<10}`\n{object}",
|
||||
"__.PKGDEF",
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
"644",
|
||||
object.len()
|
||||
);
|
||||
add_file(&mut author, ecosystem, &path, archive.as_bytes());
|
||||
importcfg.push_str(&format!("packagefile {name}={path}\n"));
|
||||
}
|
||||
add_file(
|
||||
&mut author,
|
||||
ecosystem,
|
||||
"stdlib/importcfg",
|
||||
importcfg.as_bytes(),
|
||||
);
|
||||
} else {
|
||||
descriptor["executable"] = json!(format!("bin/{name}"));
|
||||
descriptor["arguments"] = if ecosystem == "pip" {
|
||||
json!(["-I", "-S", "-B"])
|
||||
} else {
|
||||
json!([])
|
||||
};
|
||||
add_file(
|
||||
&mut author,
|
||||
ecosystem,
|
||||
&format!("bin/{name}"),
|
||||
&native_elf::static_executable(),
|
||||
);
|
||||
}
|
||||
add_file(
|
||||
&mut author,
|
||||
ecosystem,
|
||||
"dever-runtime.json",
|
||||
&serde_json::to_vec(&descriptor).unwrap(),
|
||||
);
|
||||
}
|
||||
author.settings["runtimes"]["pip"]["python_markers"] = json!({
|
||||
"implementation_name":"cpython","implementation_version":"3.12.0","os_name":"posix",
|
||||
"platform_machine":std::env::consts::ARCH,"platform_python_implementation":"CPython",
|
||||
"platform_release":"","platform_system":"Linux","platform_version":"",
|
||||
"python_full_version":"3.12.0","python_version":"3.12","sys_platform":"linux"
|
||||
});
|
||||
author.settings["runtimes"]["pip"]["python_wheel_tags"] = json!(["py3-none-any"]);
|
||||
let markers = author.settings["runtimes"]["pip"]["python_markers"].clone();
|
||||
set_manifest(&mut author, "pip", |manifest| {
|
||||
manifest["python_markers"] = markers;
|
||||
manifest["python_wheel_tags"] = json!(["py3-none-any"]);
|
||||
manifest["python_extension_suffixes"] = json!([".abi3.so", ".so"]);
|
||||
});
|
||||
author
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ecosystem_archives_are_deterministic_and_signed_with_computed_identities() {
|
||||
let mut author = runtime_author();
|
||||
author.save();
|
||||
let first = author.output("first");
|
||||
let manifest = packaging::create(&author.root, &first).unwrap();
|
||||
// Input order and filesystem mode are not archive metadata.
|
||||
for ecosystem in ["pip", "npm", "go"] {
|
||||
author.settings["runtimes"][ecosystem]["files"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.reverse();
|
||||
}
|
||||
fs::set_permissions(
|
||||
author.root.join("inputs/npm/bin/node"),
|
||||
fs::Permissions::from_mode(0o700),
|
||||
)
|
||||
.unwrap();
|
||||
author.save();
|
||||
let second = author.output("second");
|
||||
assert_eq!(packaging::create(&author.root, &second).unwrap(), manifest);
|
||||
assert_eq!(contents(&first), contents(&second));
|
||||
assert_signed(&author, &first);
|
||||
for ecosystem in ["pip", "npm", "go"] {
|
||||
let prefix = format!("runtime/{ecosystem}/{}", platform_identity());
|
||||
let metadata_path = format!("{prefix}/manifest.json");
|
||||
let pack_path = format!("{prefix}/runtime.pack");
|
||||
let metadata: Value =
|
||||
serde_json::from_slice(&fs::read(first.join(&metadata_path)).unwrap()).unwrap();
|
||||
assert_eq!(metadata["format"], "dever-registry-runtime-v1");
|
||||
assert_eq!(
|
||||
metadata["runtime"]["pack"]["sha256"],
|
||||
sha256_file(&first.join(&pack_path)).unwrap()
|
||||
);
|
||||
for path in [&metadata_path, &pack_path] {
|
||||
let signed = manifest
|
||||
.artifacts
|
||||
.iter()
|
||||
.find(|artifact| &artifact.path == path)
|
||||
.unwrap();
|
||||
assert_eq!(signed.sha256, sha256_file(&first.join(path)).unwrap());
|
||||
}
|
||||
let bytes = fs::read(first.join(&pack_path)).unwrap();
|
||||
let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(bytes.as_slice()));
|
||||
let mut names = Vec::new();
|
||||
for entry in archive.entries().unwrap() {
|
||||
let mut entry = entry.unwrap();
|
||||
let path = entry.path().unwrap().to_str().unwrap().to_owned();
|
||||
assert_eq!(entry.header().mtime().unwrap(), 0);
|
||||
assert_eq!(entry.header().uid().unwrap(), 0);
|
||||
assert_eq!(entry.header().gid().unwrap(), 0);
|
||||
assert_eq!(
|
||||
entry.header().mode().unwrap(),
|
||||
if path.starts_with("bin/") {
|
||||
0o755
|
||||
} else {
|
||||
0o644
|
||||
}
|
||||
);
|
||||
let mut actual = Vec::new();
|
||||
entry.read_to_end(&mut actual).unwrap();
|
||||
assert_eq!(
|
||||
actual,
|
||||
fs::read(author.root.join(format!("inputs/{ecosystem}/{path}"))).unwrap()
|
||||
);
|
||||
names.push(path);
|
||||
}
|
||||
assert!(names.windows(2).all(|pair| pair[0] < pair[1]));
|
||||
}
|
||||
let before = contents(&first);
|
||||
fs::write(author.root.join("inputs/npm/bin/node"), b"changed").unwrap();
|
||||
assert_eq!(contents(&first), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_incomplete_or_mismatched_runtime_descriptors() {
|
||||
for ecosystem in ["pip", "npm", "go"] {
|
||||
for field in ["format", "ecosystem", "target"] {
|
||||
let mut author = runtime_author();
|
||||
set_manifest(&mut author, ecosystem, |manifest| {
|
||||
manifest[field] = json!("wrong")
|
||||
});
|
||||
assert!(author.reject().contains("identity"));
|
||||
}
|
||||
let mut author = runtime_author();
|
||||
set_manifest(&mut author, ecosystem, |manifest| {
|
||||
manifest["unknown"] = json!(true)
|
||||
});
|
||||
assert!(author.reject().contains("manifest"));
|
||||
let mut author = runtime_author();
|
||||
author.settings["runtimes"][ecosystem]["files"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.retain(|input| input["path"] != "dever-runtime.json");
|
||||
assert!(author.reject().contains("dever-runtime.json"));
|
||||
}
|
||||
for (ecosystem, field, value) in [
|
||||
("pip", "arguments", json!(["-I", "-B"])),
|
||||
("npm", "executable", json!("bin/missing")),
|
||||
("npm", "arguments", json!(["source.js"])),
|
||||
("go", "executable", json!("bin/compile")),
|
||||
("go", "build", Value::Null),
|
||||
] {
|
||||
let mut author = runtime_author();
|
||||
set_manifest(&mut author, ecosystem, |manifest| manifest[field] = value);
|
||||
author.reject();
|
||||
}
|
||||
for (field, value) in [
|
||||
("goarch", "unknown"),
|
||||
("compiler", "bin/missing"),
|
||||
("stdlib_importcfg", "missing"),
|
||||
] {
|
||||
let mut author = runtime_author();
|
||||
set_manifest(&mut author, "go", |manifest| {
|
||||
manifest["build"][field] = json!(value)
|
||||
});
|
||||
author.reject();
|
||||
}
|
||||
let mut author = runtime_author();
|
||||
author.settings["runtimes"]["go"]["files"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.retain(|input| input["path"] != "stdlib/context.a");
|
||||
assert!(author.reject().contains("archive"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_cross_pack_binds_host_tools_and_arm_standard_library_separately() {
|
||||
for wrong_stdlib in [false, true] {
|
||||
let mut author = runtime_author();
|
||||
set_manifest(&mut author, "go", |manifest| {
|
||||
manifest["target"] = json!("linux-aarch64");
|
||||
manifest["build"]["goarch"] = json!("arm64");
|
||||
});
|
||||
for input in author.settings["runtimes"]["go"]["files"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
{
|
||||
if !input["path"].as_str().unwrap().ends_with(".a") {
|
||||
continue;
|
||||
}
|
||||
let path = author.root.join(input["source"].as_str().unwrap());
|
||||
let architecture = if wrong_stdlib { "amd64" } else { "arm64" };
|
||||
let object = format!("go object linux {architecture} go1.25.0 fixture\n");
|
||||
let archive = format!(
|
||||
"!<arch>\n{:<16}{:<12}{:<6}{:<6}{:<8}{:<10}`\n{object}",
|
||||
"__.PKGDEF",
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
"644",
|
||||
object.len()
|
||||
);
|
||||
fs::write(&path, archive).unwrap();
|
||||
input["sha256"] = json!(sha256_file(&path).unwrap());
|
||||
}
|
||||
let input = author.settings["runtimes"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("go")
|
||||
.unwrap();
|
||||
author.settings["ecosystem_targets"] = json!({"linux-aarch64":{"go":input}});
|
||||
author.save();
|
||||
let result = packaging::create(&author.root, &author.output("cross-go"));
|
||||
if wrong_stdlib {
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("differs from its target or compiler version")
|
||||
);
|
||||
} else {
|
||||
let manifest = result.unwrap();
|
||||
assert!(
|
||||
manifest
|
||||
.artifacts
|
||||
.iter()
|
||||
.any(|file| file.path == "runtime/go/linux-aarch64/runtime.pack")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_cross_assets_preserve_loader_mode_and_validate_deployment_target() {
|
||||
let mut files = vec![
|
||||
("bin/bwrap".into(), native_elf::static_executable()),
|
||||
("bin/guard".into(), native_elf::static_executable()),
|
||||
(
|
||||
"lib/ld-linux-aarch64.so.1".into(),
|
||||
native_elf::library("ld-linux-aarch64.so.1", &[]),
|
||||
),
|
||||
(
|
||||
"lib/libc.so.6".into(),
|
||||
native_elf::library("libc.so.6", &[]),
|
||||
),
|
||||
];
|
||||
for (_, bytes) in &mut files {
|
||||
bytes[18..20].copy_from_slice(&183u16.to_le_bytes());
|
||||
}
|
||||
dever_sandbox::validate_assets_for_target(&files, "linux-aarch64").unwrap();
|
||||
let mut author = Author::new();
|
||||
let inputs = files
|
||||
.iter()
|
||||
.map(|(path, bytes)| {
|
||||
let source = format!("inputs/arm-sandbox/{path}");
|
||||
let file = author.root.join(&source);
|
||||
fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
fs::write(&file, bytes).unwrap();
|
||||
fs::set_permissions(&file, fs::Permissions::from_mode(0o600)).unwrap();
|
||||
json!({"source":source,"path":path,"sha256":sha256_file(&file).unwrap()})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
author.settings["sandbox_targets"] = json!({"linux-aarch64":inputs});
|
||||
author.save();
|
||||
let output = author.output("cross-sandbox");
|
||||
packaging::create(&author.root, &output).unwrap();
|
||||
for (path, mode) in [
|
||||
("bin/bwrap", 0o755),
|
||||
("bin/guard", 0o755),
|
||||
("lib/ld-linux-aarch64.so.1", 0o755),
|
||||
("lib/libc.so.6", 0o644),
|
||||
] {
|
||||
assert_eq!(
|
||||
fs::metadata(output.join("sandbox/linux-aarch64").join(path))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
mode,
|
||||
"incorrect packaged mode for {path}"
|
||||
);
|
||||
}
|
||||
files[1].1[18..20].copy_from_slice(&62u16.to_le_bytes());
|
||||
assert!(
|
||||
dever_sandbox::validate_assets_for_target(&files, "linux-aarch64")
|
||||
.unwrap_err()
|
||||
.contains("incompatible target")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_registry_metadata_and_unknown_ecosystems() {
|
||||
for (ecosystem, field, value) in [
|
||||
("pip", "python_markers", Value::Null),
|
||||
("pip", "python_wheel_tags", json!([])),
|
||||
("pip", "python_wheel_tags", json!(["invalid"])),
|
||||
(
|
||||
"pip",
|
||||
"python_wheel_tags",
|
||||
json!(["py3-none-any", "py3-none-any"]),
|
||||
),
|
||||
("npm", "python_wheel_tags", json!(["py3-none-any"])),
|
||||
("npm", "name", json!("")),
|
||||
("npm", "name", json!("../node")),
|
||||
("go", "version", json!("latest")),
|
||||
("go", "unknown", json!(true)),
|
||||
] {
|
||||
let mut author = runtime_author();
|
||||
author.settings["runtimes"][ecosystem][field] = value;
|
||||
author.reject();
|
||||
}
|
||||
let mut author = runtime_author();
|
||||
author.settings["runtimes"]["npm"]["python_markers"] =
|
||||
author.settings["runtimes"]["pip"]["python_markers"].clone();
|
||||
author.reject();
|
||||
let mut author = runtime_author();
|
||||
author.settings["runtimes"]["exec"] = author.settings["runtimes"]["npm"].clone();
|
||||
author.reject();
|
||||
|
||||
let author = runtime_author();
|
||||
author.save();
|
||||
let path = author.root.join("config/setting.json");
|
||||
let config = fs::read_to_string(&path).unwrap();
|
||||
let duplicate = config.replacen("\"runtimes\":{", "\"runtimes\":{\"npm\":null,", 1);
|
||||
assert_ne!(duplicate, config);
|
||||
fs::write(path, duplicate).unwrap();
|
||||
assert!(
|
||||
packaging::create(&author.root, &author.output("duplicate"))
|
||||
.unwrap_err()
|
||||
.contains("invalid author configuration")
|
||||
);
|
||||
assert_eq!(fs::read_dir(author.directory.path()).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_runtime_path_conflicts_drift_and_private_key_aliases() {
|
||||
for path in [
|
||||
"../outside",
|
||||
"bin",
|
||||
"bin/node/child",
|
||||
"runtime.pack",
|
||||
"bin/node",
|
||||
] {
|
||||
let mut author = runtime_author();
|
||||
let mut input = author.settings["runtimes"]["npm"]["files"][0].clone();
|
||||
input["path"] = json!(path);
|
||||
author.settings["runtimes"]["npm"]["files"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(input);
|
||||
author.reject();
|
||||
}
|
||||
let author = runtime_author();
|
||||
fs::write(author.root.join("inputs/npm/bin/node"), b"changed").unwrap();
|
||||
assert!(author.reject().contains("SHA-256"));
|
||||
let mut author = self::runtime_author();
|
||||
let alias = author.root.join("inputs/key-alias");
|
||||
fs::hard_link(author.root.join("signing.pk8"), &alias).unwrap();
|
||||
author.settings["runtimes"]["npm"]["files"].as_array_mut().unwrap().push(json!({"source":"inputs/key-alias","path":"secrets/key","sha256":sha256_file(&alias).unwrap()}));
|
||||
assert!(author.reject().contains("cannot be included"));
|
||||
let mut author = self::runtime_author();
|
||||
symlink("node", author.root.join("inputs/npm/bin/link")).unwrap();
|
||||
author.settings["runtimes"]["npm"]["files"][0]["source"] = json!("inputs/npm/bin/link");
|
||||
author.reject();
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
use super::*;
|
||||
use dever_cli::toolchain::runtime_pack::RuntimePack;
|
||||
use dever_runtime::config::RuntimeProfile;
|
||||
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
fn cross_author() -> Author {
|
||||
let mut author = Author::new();
|
||||
let target = BuildTarget::LinuxAarch64;
|
||||
let input = |name: &str, bytes: Vec<u8>| {
|
||||
let source = format!("inputs/arm-{name}");
|
||||
fs::write(author.root.join(&source), bytes).unwrap();
|
||||
json!({"source":source, "sha256":sha256_file(&author.root.join(source)).unwrap()})
|
||||
};
|
||||
let mut profiles = json!({});
|
||||
for profile in ["base", "sqlite", "postgres", "both"] {
|
||||
profiles[profile] = input(profile, native_objects::archive(target, profile));
|
||||
}
|
||||
let link = |name: &str, bytes| {
|
||||
let mut artifact = input(name, bytes);
|
||||
artifact["path"] = json!(name);
|
||||
artifact
|
||||
};
|
||||
author.settings["native_targets"] = json!({"linux-aarch64": {
|
||||
"profiles":profiles,
|
||||
"start":[link("start.o", native_objects::object(target))],
|
||||
"libraries":[link("system.a", native_objects::archive(target, "system.o"))],
|
||||
"end":[link("end.o", native_objects::object(target))],
|
||||
}});
|
||||
author
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_names_and_serde_are_exact() {
|
||||
for (name, target, triple) in [
|
||||
(
|
||||
"linux-x86_64",
|
||||
BuildTarget::LinuxX86_64,
|
||||
"x86_64-unknown-linux-gnu",
|
||||
),
|
||||
(
|
||||
"linux-aarch64",
|
||||
BuildTarget::LinuxAarch64,
|
||||
"aarch64-unknown-linux-gnu",
|
||||
),
|
||||
] {
|
||||
assert_eq!(name.parse::<BuildTarget>().unwrap(), target);
|
||||
assert_eq!(target.platform(), name);
|
||||
assert_eq!(target.triple(), triple);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&target).unwrap(),
|
||||
format!("\"{name}\"")
|
||||
);
|
||||
}
|
||||
for invalid in [
|
||||
"",
|
||||
"arm64",
|
||||
"Linux-aarch64",
|
||||
"linux-arm64",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"linux-aarch64 ",
|
||||
] {
|
||||
assert!(invalid.parse::<BuildTarget>().is_err());
|
||||
assert!(serde_json::from_value::<BuildTarget>(json!(invalid)).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
fn host_core_packages_both_native_targets_with_separate_profiles_and_signed_closures() {
|
||||
let author = cross_author();
|
||||
author.save();
|
||||
let output = author.directory.path().join("cross-release");
|
||||
let manifest = packaging::create(&author.root, &output).unwrap();
|
||||
assert_eq!(manifest.platform, "linux-x86_64");
|
||||
assert_signed(&author, &output);
|
||||
for target in [BuildTarget::LinuxX86_64, BuildTarget::LinuxAarch64] {
|
||||
let closure = runtime_pack::validate(&output, env!("CARGO_PKG_VERSION"), target).unwrap();
|
||||
for input in closure {
|
||||
assert!(
|
||||
manifest
|
||||
.artifacts
|
||||
.iter()
|
||||
.any(|artifact| artifact.path == input.path
|
||||
&& artifact.sha256 == input.sha256
|
||||
&& artifact.bytes == input.bytes)
|
||||
);
|
||||
}
|
||||
for profile in [
|
||||
RuntimeProfile {
|
||||
sqlite: false,
|
||||
postgres: false,
|
||||
},
|
||||
RuntimeProfile {
|
||||
sqlite: true,
|
||||
postgres: false,
|
||||
},
|
||||
RuntimeProfile {
|
||||
sqlite: false,
|
||||
postgres: true,
|
||||
},
|
||||
RuntimeProfile {
|
||||
sqlite: true,
|
||||
postgres: true,
|
||||
},
|
||||
] {
|
||||
assert_eq!(
|
||||
RuntimePack::load(&output, profile, target).unwrap().target,
|
||||
target
|
||||
);
|
||||
}
|
||||
}
|
||||
let profile = RuntimeProfile {
|
||||
sqlite: false,
|
||||
postgres: false,
|
||||
};
|
||||
let arm = RuntimePack::load(&output, profile, BuildTarget::LinuxAarch64).unwrap();
|
||||
let host = RuntimePack::load(&output, profile, BuildTarget::LinuxX86_64).unwrap();
|
||||
assert_ne!(arm.identity, host.identity);
|
||||
fs::write(
|
||||
output.join("runtime/linux-aarch64/archives/base.a"),
|
||||
b"tampered",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(RuntimePack::load(&output, profile, BuildTarget::LinuxAarch64).is_err());
|
||||
RuntimePack::load(&output, profile, BuildTarget::LinuxX86_64).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
fn target_packs_reject_wrong_architecture_and_duplicate_host_declarations() {
|
||||
for (slot, source) in [
|
||||
("/native_targets/linux-aarch64/start/0", "start.o"),
|
||||
("/native_targets/linux-aarch64/profiles/base", "base"),
|
||||
] {
|
||||
let mut author = cross_author();
|
||||
let replacement = if source.ends_with(".o") {
|
||||
native_objects::object(BuildTarget::LinuxX86_64)
|
||||
} else {
|
||||
native_objects::archive(BuildTarget::LinuxX86_64, "wrong.o")
|
||||
};
|
||||
fs::write(
|
||||
author.root.join(format!("inputs/arm-{source}")),
|
||||
replacement,
|
||||
)
|
||||
.unwrap();
|
||||
author.settings.pointer_mut(slot).unwrap()["sha256"] =
|
||||
json!(sha256_file(&author.root.join(format!("inputs/arm-{source}"))).unwrap());
|
||||
assert!(
|
||||
author
|
||||
.reject()
|
||||
.contains("relocatable ELF object for linux-aarch64")
|
||||
);
|
||||
}
|
||||
let mut author = cross_author();
|
||||
author.settings["native_targets"]["linux-x86_64"] =
|
||||
author.settings["native_targets"]["linux-aarch64"].clone();
|
||||
assert!(author.reject().contains("duplicate release destination"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
fn missing_target_pack_never_uses_host_inputs() {
|
||||
let author = Author::new();
|
||||
author.save();
|
||||
let output = author.directory.path().join("host-release");
|
||||
packaging::create(&author.root, &output).unwrap();
|
||||
let error = RuntimePack::load(
|
||||
&output,
|
||||
RuntimeProfile {
|
||||
sqlite: false,
|
||||
postgres: false,
|
||||
},
|
||||
BuildTarget::LinuxAarch64,
|
||||
)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(error.contains("linux-aarch64"), "{error}");
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use dever_core::source::SourceMap;
|
||||
|
||||
static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct Workspace(PathBuf);
|
||||
|
||||
impl Workspace {
|
||||
fn new() -> Self {
|
||||
let sequence = NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed);
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("dever-new-test-{}-{sequence}", std::process::id()));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Workspace {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_formats_create_checked_formatted_projects_with_real_application_tests() {
|
||||
let workspace = Workspace::new();
|
||||
for markdown in [false, true] {
|
||||
let root = workspace
|
||||
.0
|
||||
.join(if markdown { "markdown" } else { "plain" });
|
||||
dever_cli::project::create(&root, markdown).unwrap();
|
||||
let sources = SourceMap::load_project(&root.join("module"), &root.join("test")).unwrap();
|
||||
let result = dever_core::check(&sources);
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
assert_eq!(sources.files().len(), 3);
|
||||
for source in sources.files() {
|
||||
let formatted = dever_core::format::format(source).unwrap();
|
||||
assert_eq!(formatted, source.text(), "{}", source.path().display());
|
||||
}
|
||||
let settings: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(root.join("config/setting.json")).unwrap()).unwrap();
|
||||
assert_eq!(settings["log"]["level"], "info");
|
||||
assert!(root.join("AGENTS.md").is_file());
|
||||
assert!(!root.join("module/main.dever").exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_directories_files_and_symlinks_are_preserved() {
|
||||
let workspace = Workspace::new();
|
||||
let directory = workspace.0.join("existing");
|
||||
fs::create_dir(&directory).unwrap();
|
||||
assert!(dever_cli::project::create(&directory, false).is_err());
|
||||
assert_eq!(fs::read_dir(&directory).unwrap().count(), 0);
|
||||
let file = workspace.0.join("file");
|
||||
fs::write(&file, "keep me").unwrap();
|
||||
assert!(dever_cli::project::create(&file, false).is_err());
|
||||
assert_eq!(fs::read_to_string(&file).unwrap(), "keep me");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let link = workspace.0.join("link");
|
||||
std::os::unix::fs::symlink(workspace.0.join("absent"), &link).unwrap();
|
||||
assert!(dever_cli::project::create(&link, true).is_err());
|
||||
assert!(fs::symlink_metadata(link).unwrap().is_symlink());
|
||||
}
|
||||
assert!(fs::read_dir(&workspace.0).unwrap().all(|entry| {
|
||||
!entry
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with(".dever-new-")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_creation_publishes_one_complete_project_without_staging_leftovers() {
|
||||
let workspace = Workspace::new();
|
||||
let root = workspace.0.join("project");
|
||||
let barrier = std::sync::Barrier::new(2);
|
||||
let results = std::thread::scope(|scope| {
|
||||
let workers: Vec<_> = [false, true]
|
||||
.into_iter()
|
||||
.map(|markdown| {
|
||||
let root = &root;
|
||||
let barrier = &barrier;
|
||||
scope.spawn(move || {
|
||||
barrier.wait();
|
||||
dever_cli::project::create(root, markdown)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
workers
|
||||
.into_iter()
|
||||
.map(|worker| worker.join().unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
|
||||
let sources = SourceMap::load_project(&root.join("module"), &root.join("test")).unwrap();
|
||||
assert!(dever_core::check(&sources).is_ok());
|
||||
assert_eq!(sources.files().len(), 3);
|
||||
assert_eq!(fs::read_dir(&workspace.0).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_creates_before_reading_project_configuration_and_rejects_bad_arguments() {
|
||||
let workspace = Workspace::new();
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.env_clear()
|
||||
.current_dir(&workspace.0)
|
||||
.args(["new", "app", "--markdown"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
workspace
|
||||
.0
|
||||
.join("app/module/hello/greeting/api.dever.md")
|
||||
.is_file()
|
||||
);
|
||||
for arguments in [
|
||||
vec!["new"],
|
||||
vec!["new", "bad", "--unknown"],
|
||||
vec!["new", "--markdown"],
|
||||
] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_dever"))
|
||||
.env_clear()
|
||||
.current_dir(&workspace.0)
|
||||
.args(arguments)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
}
|
||||
assert!(!workspace.0.join("bad").exists());
|
||||
}
|
||||
@@ -0,0 +1,961 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
#[cfg(unix)]
|
||||
use std::io::Read;
|
||||
use std::io::{Cursor, Write};
|
||||
#[cfg(unix)]
|
||||
use std::path::Path;
|
||||
#[cfg(unix)]
|
||||
use std::process::Command;
|
||||
|
||||
use dever_cli::libs::{ArtifactStore, FixtureArtifactStore, LockFile};
|
||||
#[cfg(unix)]
|
||||
use dever_cli::libs::{LockedWorker, RuntimePack};
|
||||
#[cfg(unix)]
|
||||
use dever_cli::packages::LockedPackage;
|
||||
use dever_cli::packages::{
|
||||
PackageTransport, execute_with, owned_files_with, owned_worker_bytes_with, owns_worker,
|
||||
sources_with,
|
||||
};
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
use dever_cli::toolchain::cache_status;
|
||||
#[cfg(unix)]
|
||||
use dever_cli::toolchain::{
|
||||
Artifact, Layout, MachineManager, ReleaseManifest, Version, artifact_get, artifact_put,
|
||||
};
|
||||
#[cfg(unix)]
|
||||
use dever_cli::workers;
|
||||
#[cfg(unix)]
|
||||
use dever_core::native::EmbeddedResource;
|
||||
#[cfg(unix)]
|
||||
use ring::rand::SystemRandom;
|
||||
#[cfg(unix)]
|
||||
use ring::signature::{Ed25519KeyPair, KeyPair};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[path = "../dever-tests/tests/support/temp.rs"]
|
||||
mod temp;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[path = "support/daemon.rs"]
|
||||
mod daemon;
|
||||
#[cfg(unix)]
|
||||
use daemon::TestDaemon;
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
#[path = "support/llvm_pack.rs"]
|
||||
mod llvm_pack;
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
#[path = "packages/managed_compile.rs"]
|
||||
mod managed_compile;
|
||||
|
||||
use temp::TemporaryDirectory;
|
||||
|
||||
struct Registry(BTreeMap<String, Vec<u8>>);
|
||||
|
||||
impl PackageTransport for Registry {
|
||||
fn get(&self, path: &str, limit: usize) -> Result<Vec<u8>, String> {
|
||||
let bytes = self
|
||||
.0
|
||||
.get(path)
|
||||
.ok_or_else(|| format!("missing registry path {path}"))?;
|
||||
if bytes.len() > limit {
|
||||
return Err("registry response exceeds limit".into());
|
||||
}
|
||||
Ok(bytes.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn archive(
|
||||
name: &str,
|
||||
version: &str,
|
||||
dependencies: serde_json::Value,
|
||||
libs: &[&str],
|
||||
source: &str,
|
||||
) -> Vec<u8> {
|
||||
archive_with_sources(name, version, dependencies, libs, source, &[])
|
||||
}
|
||||
|
||||
fn archive_with_sources(
|
||||
name: &str,
|
||||
version: &str,
|
||||
dependencies: serde_json::Value,
|
||||
libs: &[&str],
|
||||
source: &str,
|
||||
additional_sources: &[(&str, &str)],
|
||||
) -> Vec<u8> {
|
||||
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (path, bytes) in [
|
||||
(
|
||||
"dever-package.json".to_owned(),
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"format": "dever-package-v1", "name": name, "version": version,
|
||||
"dependencies": dependencies, "lib": libs,
|
||||
}))
|
||||
.unwrap(),
|
||||
),
|
||||
(
|
||||
format!("module/{name}/value/app.dever"),
|
||||
source.as_bytes().to_vec(),
|
||||
),
|
||||
] {
|
||||
writer
|
||||
.start_file(path, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
writer.write_all(&bytes).unwrap();
|
||||
}
|
||||
for (path, source) in additional_sources {
|
||||
writer
|
||||
.start_file(*path, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
writer.write_all(source.as_bytes()).unwrap();
|
||||
}
|
||||
writer.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn digest(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
fn registry(archives: &[(&str, &str, Vec<u8>)]) -> Registry {
|
||||
let mut paths = BTreeMap::new();
|
||||
let mut versions = BTreeMap::<String, Vec<serde_json::Value>>::new();
|
||||
for (name, version, bytes) in archives {
|
||||
versions
|
||||
.entry((*name).into())
|
||||
.or_default()
|
||||
.push(serde_json::json!({
|
||||
"version": version, "sha256": digest(bytes), "bytes": bytes.len(),
|
||||
}));
|
||||
paths.insert(format!("/v1/packages/{name}/{version}.zip"), bytes.clone());
|
||||
}
|
||||
for (name, versions) in versions {
|
||||
paths.insert(
|
||||
format!("/v1/packages/{name}/index.json"),
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"format": "dever-package-index-v1", "name": name, "versions": versions,
|
||||
}))
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
Registry(paths)
|
||||
}
|
||||
|
||||
fn project() -> TemporaryDirectory {
|
||||
let root = TemporaryDirectory::new();
|
||||
fs::create_dir_all(root.path().join("config")).unwrap();
|
||||
fs::create_dir_all(root.path().join("module/portal/value")).unwrap();
|
||||
fs::write(
|
||||
root.path().join("config/setting.json"),
|
||||
r#"{"package":{"registry":"https://packages.example.test","use":[]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.path().join("module/portal/value/app.dever"),
|
||||
"value() (result: Text) { result = storefront.value.value() }\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.path().join("module/portal/value/api.dever"),
|
||||
"cmd show = app.value\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(root.path().join("test/portal/value")).unwrap();
|
||||
fs::write(
|
||||
root.path().join("test/portal/value/smoke.dever"),
|
||||
"smoke() () { assert_eq(portal.value.value(), \"two\") }\n",
|
||||
)
|
||||
.unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_add_resolves_transitive_versions_and_feeds_the_common_lib_lock() {
|
||||
let root = project();
|
||||
let registry = registry(&[
|
||||
(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
archive(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
serde_json::json!({"catalog":"^1.0.0"}),
|
||||
&[],
|
||||
"value() (result: Text) { result = catalog.value.value() }\n",
|
||||
),
|
||||
),
|
||||
(
|
||||
"catalog",
|
||||
"1.0.0",
|
||||
archive(
|
||||
"catalog",
|
||||
"1.0.0",
|
||||
serde_json::json!({}),
|
||||
&["pip:fixture@1.0.0"],
|
||||
"value() (result: Text) { result = \"one\" }\n",
|
||||
),
|
||||
),
|
||||
(
|
||||
"catalog",
|
||||
"1.2.0",
|
||||
archive(
|
||||
"catalog",
|
||||
"1.2.0",
|
||||
serde_json::json!({}),
|
||||
&["pip:fixture@1.0.0"],
|
||||
"value() (result: Text) { result = \"two\" }\n",
|
||||
),
|
||||
),
|
||||
(
|
||||
"catalog",
|
||||
"2.0.0",
|
||||
archive(
|
||||
"catalog",
|
||||
"2.0.0",
|
||||
serde_json::json!({}),
|
||||
&[],
|
||||
"value() (result: Text) { result = \"wrong\" }\n",
|
||||
),
|
||||
),
|
||||
]);
|
||||
let store = FixtureArtifactStore::default();
|
||||
execute_with(
|
||||
"add",
|
||||
root.path(),
|
||||
&["storefront@^1.0.0".into()],
|
||||
®istry,
|
||||
&store,
|
||||
)
|
||||
.unwrap();
|
||||
let lock = LockFile::decode(&fs::read(root.path().join("dever.lock")).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
lock.packages
|
||||
.iter()
|
||||
.map(|package| (package.name.as_str(), package.version.as_str()))
|
||||
.collect::<Vec<_>>(),
|
||||
[("catalog", "1.2.0"), ("storefront", "1.0.0")]
|
||||
);
|
||||
assert_eq!(lock.libs.len(), 1);
|
||||
assert_eq!(lock.libs[0].spec.key(), "pip:fixture@1.0.0");
|
||||
assert!(owns_worker(root.path(), "worker/catalog/tool").unwrap());
|
||||
assert!(owns_worker(root.path(), "module/catalog/value/worker/main.py").unwrap());
|
||||
assert!(!owns_worker(root.path(), "module/portal/value/worker/main.py").unwrap());
|
||||
let files = owned_files_with(root.path(), &store).unwrap();
|
||||
assert!(String::from_utf8_lossy(&files["module/catalog/value/app.dever"]).contains("two"));
|
||||
assert!(execute_with("doctor", root.path(), &[], ®istry, &store).is_ok());
|
||||
|
||||
let mut damaged = lock.clone();
|
||||
damaged.packages[0].manifest_sha256 = "0".repeat(64);
|
||||
damaged.write_atomic(root.path()).unwrap();
|
||||
assert!(
|
||||
owned_files_with(root.path(), &store)
|
||||
.unwrap_err()
|
||||
.contains("manifest changed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_resolution_rejects_conflicts_without_changing_project_configuration() {
|
||||
let root = project();
|
||||
let before = fs::read(root.path().join("config/setting.json")).unwrap();
|
||||
let registry = registry(&[
|
||||
(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
archive(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
serde_json::json!({"catalog":"^2.0.0"}),
|
||||
&[],
|
||||
"value() (result: Text) { result = catalog.value.value() }\n",
|
||||
),
|
||||
),
|
||||
(
|
||||
"catalog",
|
||||
"1.0.0",
|
||||
archive(
|
||||
"catalog",
|
||||
"1.0.0",
|
||||
serde_json::json!({}),
|
||||
&[],
|
||||
"value() (result: Text) { result = \"one\" }\n",
|
||||
),
|
||||
),
|
||||
]);
|
||||
let store = FixtureArtifactStore::default();
|
||||
let error = execute_with(
|
||||
"add",
|
||||
root.path(),
|
||||
&["storefront@1.0.0".into()],
|
||||
®istry,
|
||||
&store,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.contains("no Package version satisfies"), "{error}");
|
||||
assert_eq!(
|
||||
fs::read(root.path().join("config/setting.json")).unwrap(),
|
||||
before
|
||||
);
|
||||
assert!(!root.path().join("dever.lock").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_archive_rejects_paths_owned_by_another_component() {
|
||||
let root = project();
|
||||
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (path, bytes) in [
|
||||
(
|
||||
"dever-package.json",
|
||||
r#"{"format":"dever-package-v1","name":"storefront","version":"1.0.0"}"#,
|
||||
),
|
||||
(
|
||||
"module/portal/value/app.dever",
|
||||
"value() (result: Text) { result = \"bad\" }",
|
||||
),
|
||||
] {
|
||||
writer
|
||||
.start_file(path, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
writer.write_all(bytes.as_bytes()).unwrap();
|
||||
}
|
||||
let registry = registry(&[("storefront", "1.0.0", writer.finish().unwrap().into_inner())]);
|
||||
let store = FixtureArtifactStore::default();
|
||||
let error = execute_with(
|
||||
"add",
|
||||
root.path(),
|
||||
&["storefront@1.0.0".into()],
|
||||
®istry,
|
||||
&store,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.contains("unowned path"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_rejects_local_component_even_when_file_paths_do_not_overlap() {
|
||||
let root = project();
|
||||
fs::create_dir_all(root.path().join("module/storefront/other")).unwrap();
|
||||
fs::write(
|
||||
root.path().join("module/storefront/other/app.dever"),
|
||||
"value() (result: Text) { result = \"local\" }\n",
|
||||
)
|
||||
.unwrap();
|
||||
let registry = registry(&[(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
archive(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
serde_json::json!({}),
|
||||
&[],
|
||||
"value() (result: Text) { result = \"package\" }\n",
|
||||
),
|
||||
)]);
|
||||
let store = FixtureArtifactStore::default();
|
||||
let error = execute_with(
|
||||
"add",
|
||||
root.path(),
|
||||
&["storefront@1.0.0".into()],
|
||||
®istry,
|
||||
&store,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.contains("collides with a local component"), "{error}");
|
||||
assert!(!root.path().join("dever.lock").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_only_project_loads_without_a_local_module_directory() {
|
||||
let root = TemporaryDirectory::new();
|
||||
fs::create_dir(root.path().join("config")).unwrap();
|
||||
fs::write(
|
||||
root.path().join("config/setting.json"),
|
||||
r#"{"package":{"registry":"https://packages.example.test","use":[]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let registry = registry(&[(
|
||||
"catalog",
|
||||
"1.0.0",
|
||||
archive(
|
||||
"catalog",
|
||||
"1.0.0",
|
||||
serde_json::json!({}),
|
||||
&[],
|
||||
"value() (result: Text) { result = \"ready\" }\n",
|
||||
),
|
||||
)]);
|
||||
let store = FixtureArtifactStore::default();
|
||||
execute_with(
|
||||
"add",
|
||||
root.path(),
|
||||
&["catalog@1.0.0".into()],
|
||||
®istry,
|
||||
&store,
|
||||
)
|
||||
.unwrap();
|
||||
let packages = sources_with(root.path(), &store).unwrap();
|
||||
let sources = dever_core::source::SourceMap::load_with_packages(
|
||||
&root.path().join("module"),
|
||||
None,
|
||||
&packages,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(sources.files().len(), 1);
|
||||
dever_core::check(&sources).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_package_worker_never_falls_back_to_a_local_file() {
|
||||
let root = project();
|
||||
let registry = registry(&[(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
archive(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
serde_json::json!({}),
|
||||
&[],
|
||||
"value() (result: Text) { result = \"two\" }\n",
|
||||
),
|
||||
)]);
|
||||
let store = FixtureArtifactStore::default();
|
||||
execute_with(
|
||||
"add",
|
||||
root.path(),
|
||||
&["storefront@1.0.0".into()],
|
||||
®istry,
|
||||
&store,
|
||||
)
|
||||
.unwrap();
|
||||
let local = root.path().join("worker/storefront/tool");
|
||||
fs::create_dir_all(local.parent().unwrap()).unwrap();
|
||||
fs::write(&local, b"local fallback must never execute").unwrap();
|
||||
assert!(owns_worker(root.path(), "worker/storefront/tool").unwrap());
|
||||
assert!(owns_worker(root.path(), "module/storefront/value/worker/main.py").unwrap());
|
||||
assert!(!owns_worker(root.path(), "worker/portal/tool").unwrap());
|
||||
let error = owned_worker_bytes_with(root.path(), "worker/storefront/tool", &store).unwrap_err();
|
||||
assert!(error.contains("missing from its locked archive"), "{error}");
|
||||
assert_eq!(
|
||||
owned_worker_bytes_with(root.path(), "worker/portal/tool", &store).unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn managed_package_worker_requires_its_locked_entry_and_rejects_local_collision() {
|
||||
let root = project();
|
||||
let (layout, executable) = signed_core(root.path(), &std::env::current_exe().unwrap(), false);
|
||||
let mut daemon = TestDaemon::start(&layout);
|
||||
let package_archive = archive_with_sources(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
serde_json::json!({}),
|
||||
&[],
|
||||
"type SendResult { error Unavailable(message: Text) }\nvalue() (result: Text) { result = \"two\" }\nsend(value: Int) (receipt: Int) { receipt = port.send(value) }\n",
|
||||
&[
|
||||
(
|
||||
"module/storefront/value/port.dever",
|
||||
"send(value: Int) (receipt: Int) fails app.SendResult\n",
|
||||
),
|
||||
(
|
||||
"module/storefront/value/adapter.dever",
|
||||
"external pip \"worker/main.py\" { }\n",
|
||||
),
|
||||
],
|
||||
);
|
||||
fs::write(
|
||||
root.path().join("config/setting.json"),
|
||||
r#"{"package":{"registry":"https://packages.example.test","use":["storefront@1.0.0"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let store = ServiceStore(layout.clone());
|
||||
let package_sha256 = store.publish(&package_archive, "package").unwrap();
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(&package_archive)).unwrap();
|
||||
let mut manifest = Vec::new();
|
||||
zip.by_name("dever-package.json")
|
||||
.unwrap()
|
||||
.read_to_end(&mut manifest)
|
||||
.unwrap();
|
||||
let mut lock = LockFile::new(Vec::new()).unwrap();
|
||||
lock.packages.push(LockedPackage {
|
||||
name: "storefront".into(),
|
||||
version: "1.0.0".into(),
|
||||
sha256: package_sha256,
|
||||
bytes: package_archive.len() as u64,
|
||||
dependencies: Vec::new(),
|
||||
manifest_sha256: digest(&manifest),
|
||||
});
|
||||
lock.write_atomic(root.path()).unwrap();
|
||||
|
||||
let package_sources = dever_cli::packages::sources_with(root.path(), &store).unwrap();
|
||||
let sources = dever_core::source::SourceMap::load_with_packages(
|
||||
&root.path().join("module"),
|
||||
None,
|
||||
&package_sources,
|
||||
)
|
||||
.unwrap();
|
||||
let program = dever_core::check(&sources).unwrap();
|
||||
let contract = program.external_worker_contracts().remove(0);
|
||||
assert_eq!(contract.entry, "module/storefront/value/worker/main.py");
|
||||
assert!(owns_worker(root.path(), &contract.entry).unwrap());
|
||||
|
||||
let target = dever_cli::toolchain::platform_identity();
|
||||
let metadata = serde_json::to_vec(&serde_json::json!({
|
||||
"format": "dever-worker-runtime-v1", "ecosystem": "pip", "target": target,
|
||||
"executable": "bin/python3", "arguments": ["-I", "-S", "-B"],
|
||||
"python_wheel_tags": ["py3-none-any"],
|
||||
"python_extension_suffixes": [".cpython-312-x86_64-linux-gnu.so", ".abi3.so", ".so"],
|
||||
"python_markers": {
|
||||
"implementation_name":"cpython", "implementation_version":"3.12.0", "os_name":"posix",
|
||||
"platform_machine":"x86_64", "platform_python_implementation":"CPython", "platform_release":"",
|
||||
"platform_system":"Linux", "platform_version":"", "python_full_version":"3.12.0",
|
||||
"python_version":"3.12", "sys_platform":"linux"
|
||||
},
|
||||
}))
|
||||
.unwrap();
|
||||
let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::none());
|
||||
let mut tar = tar::Builder::new(encoder);
|
||||
for (path, bytes) in [
|
||||
("dever-runtime.json", metadata.as_slice()),
|
||||
("bin/python3", b"fixture-interpreter".as_slice()),
|
||||
] {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_mode(0o755);
|
||||
header.set_cksum();
|
||||
tar.append_data(&mut header, path, bytes).unwrap();
|
||||
}
|
||||
let runtime_bytes = tar.into_inner().unwrap().finish().unwrap();
|
||||
let runtime_sha256 = digest(&runtime_bytes);
|
||||
let runtime = RuntimePack {
|
||||
name: "fixture-pip".into(),
|
||||
version: "1".into(),
|
||||
sha256: runtime_sha256.clone(),
|
||||
};
|
||||
lock.workers.push(LockedWorker {
|
||||
port: contract.port,
|
||||
adapter: contract.adapter,
|
||||
ecosystem: contract.ecosystem,
|
||||
runtime: Some(runtime),
|
||||
entry: contract.entry.clone(),
|
||||
schema: contract.schema,
|
||||
capabilities: contract.capabilities,
|
||||
operations: contract.operations,
|
||||
libs: Vec::new(),
|
||||
});
|
||||
lock.write_atomic(root.path()).unwrap();
|
||||
fs::write(root.path().join("test-runtime.pack"), runtime_bytes).unwrap();
|
||||
let output = Command::new(executable)
|
||||
.args([
|
||||
"--exact",
|
||||
"installed_managed_package_worker_guard",
|
||||
"--ignored",
|
||||
"--nocapture",
|
||||
])
|
||||
.current_dir(root.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"signed child test failed: {}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
daemon.stop();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
#[ignore = "run only from the signed private fixture"]
|
||||
fn installed_managed_package_worker_guard() {
|
||||
let root = std::env::current_dir().unwrap();
|
||||
let package_sources = dever_cli::packages::sources(&root).unwrap();
|
||||
let sources = dever_core::source::SourceMap::load_with_packages(
|
||||
&root.join("module"),
|
||||
None,
|
||||
&package_sources,
|
||||
)
|
||||
.unwrap();
|
||||
let program = dever_core::check(&sources).unwrap();
|
||||
let entry = &program.external_worker_contracts()[0].entry;
|
||||
assert_eq!(entry, "module/storefront/value/worker/main.py");
|
||||
assert!(owns_worker(&root, entry).unwrap());
|
||||
let runtime_bytes = fs::read(root.join("test-runtime.pack")).unwrap();
|
||||
let resources = [EmbeddedResource {
|
||||
path: format!(
|
||||
"lib/runtime/pip/{}/runtime.pack",
|
||||
dever_cli::toolchain::platform_identity()
|
||||
),
|
||||
sha256: digest(&runtime_bytes),
|
||||
bytes: runtime_bytes,
|
||||
executable: false,
|
||||
}];
|
||||
let error = workers::prepare(&root, &program, &resources).unwrap_err();
|
||||
assert!(
|
||||
error.contains("locked Package Worker entry") && error.contains("missing"),
|
||||
"{error}"
|
||||
);
|
||||
|
||||
let local = root.join(entry);
|
||||
fs::create_dir_all(local.parent().unwrap()).unwrap();
|
||||
fs::write(local, b"local fallback must never execute").unwrap();
|
||||
let error = workers::prepare(&root, &program, &resources).unwrap_err();
|
||||
assert!(error.contains("collides with a local component"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_package_worker_requires_a_lock_before_local_fallback() {
|
||||
let root = project();
|
||||
fs::write(
|
||||
root.path().join("config/setting.json"),
|
||||
r#"{"package":{"registry":"https://packages.example.test","use":["storefront@1.0.0"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let error = owns_worker(root.path(), "worker/storefront/tool").unwrap_err();
|
||||
assert!(error.contains("dever.lock"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_package_keeps_locked_versions_without_registry_requests() {
|
||||
let root = project();
|
||||
let registry = registry(&[
|
||||
(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
archive(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
serde_json::json!({"catalog":"^1.0.0"}),
|
||||
&[],
|
||||
"value() (result: Text) { result = catalog.value.value() }\n",
|
||||
),
|
||||
),
|
||||
(
|
||||
"catalog",
|
||||
"1.2.0",
|
||||
archive(
|
||||
"catalog",
|
||||
"1.2.0",
|
||||
serde_json::json!({}),
|
||||
&["pip:fixture@1.0.0"],
|
||||
"value() (result: Text) { result = \"two\" }\n",
|
||||
),
|
||||
),
|
||||
]);
|
||||
let store = FixtureArtifactStore::default();
|
||||
execute_with(
|
||||
"add",
|
||||
root.path(),
|
||||
&["storefront@1.0.0".into()],
|
||||
®istry,
|
||||
&store,
|
||||
)
|
||||
.unwrap();
|
||||
execute_with(
|
||||
"add",
|
||||
root.path(),
|
||||
&["catalog@^1.0.0".into()],
|
||||
®istry,
|
||||
&store,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.path().join("module/portal/value/app.dever"),
|
||||
"value() (result: Text) { result = catalog.value.value() }\n",
|
||||
)
|
||||
.unwrap();
|
||||
let offline = Registry(BTreeMap::new());
|
||||
execute_with(
|
||||
"remove",
|
||||
root.path(),
|
||||
&["storefront".into()],
|
||||
&offline,
|
||||
&store,
|
||||
)
|
||||
.unwrap();
|
||||
let lock = LockFile::decode(&fs::read(root.path().join("dever.lock")).unwrap()).unwrap();
|
||||
assert_eq!(lock.packages.len(), 1);
|
||||
assert_eq!(
|
||||
(
|
||||
lock.packages[0].name.as_str(),
|
||||
lock.packages[0].version.as_str()
|
||||
),
|
||||
("catalog", "1.2.0")
|
||||
);
|
||||
assert_eq!(lock.libs[0].spec.key(), "pip:fixture@1.0.0");
|
||||
assert!(
|
||||
!String::from_utf8_lossy(&fs::read(root.path().join("config/setting.json")).unwrap())
|
||||
.contains("storefront@")
|
||||
);
|
||||
fs::write(
|
||||
root.path().join("module/portal/value/app.dever"),
|
||||
"value() (result: Text) { result = \"two\" }\n",
|
||||
)
|
||||
.unwrap();
|
||||
execute_with("remove", root.path(), &["catalog".into()], &offline, &store).unwrap();
|
||||
let settings: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(root.path().join("config/setting.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
settings["package"]["registry"],
|
||||
"https://packages.example.test"
|
||||
);
|
||||
assert_eq!(settings["package"]["use"], serde_json::json!([]));
|
||||
execute_with(
|
||||
"add",
|
||||
root.path(),
|
||||
&["storefront@1.0.0".into()],
|
||||
®istry,
|
||||
&store,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
LockFile::decode(&fs::read(root.path().join("dever.lock")).unwrap())
|
||||
.unwrap()
|
||||
.packages
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn package_update_restores_original_setting_bytes_when_lock_write_fails() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = project();
|
||||
let registry = registry(&[(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
archive(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
serde_json::json!({}),
|
||||
&[],
|
||||
"value() (result: Text) { result = \"two\" }\n",
|
||||
),
|
||||
)]);
|
||||
let store = FixtureArtifactStore::default();
|
||||
execute_with(
|
||||
"add",
|
||||
root.path(),
|
||||
&["storefront@1.0.0".into()],
|
||||
®istry,
|
||||
&store,
|
||||
)
|
||||
.unwrap();
|
||||
let settings = fs::read(root.path().join("config/setting.json")).unwrap();
|
||||
let lock_path = root.path().join("dever.lock");
|
||||
let saved = root.path().join("saved.lock");
|
||||
fs::rename(&lock_path, &saved).unwrap();
|
||||
symlink(&saved, &lock_path).unwrap();
|
||||
let error = execute_with("update", root.path(), &[], ®istry, &store).unwrap_err();
|
||||
assert!(error.contains("symbolic link"), "{error}");
|
||||
assert_eq!(
|
||||
fs::read(root.path().join("config/setting.json")).unwrap(),
|
||||
settings
|
||||
);
|
||||
assert_eq!(fs::read(&saved).unwrap(), fs::read(&lock_path).unwrap());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
struct ServiceStore(Layout);
|
||||
|
||||
#[cfg(unix)]
|
||||
impl ArtifactStore for ServiceStore {
|
||||
fn has(&self, _: &str, _: &str) -> Result<bool, String> {
|
||||
Err("exact length required".into())
|
||||
}
|
||||
fn publish(&self, bytes: &[u8], _: &str) -> Result<String, String> {
|
||||
Ok(artifact_put(&self.0, bytes)?.sha256)
|
||||
}
|
||||
fn get(&self, _: &str, _: &str) -> Result<Option<Vec<u8>>, String> {
|
||||
Err("exact length required".into())
|
||||
}
|
||||
fn get_exact(&self, sha256: &str, bytes: u64, _: &str) -> Result<Option<Vec<u8>>, String> {
|
||||
artifact_get(&self.0, sha256, bytes).map(Some)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
fn installed_dever(root: &Path) -> (Layout, std::path::PathBuf) {
|
||||
signed_core(root, Path::new(env!("CARGO_BIN_EXE_dever")), true)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn signed_core(
|
||||
root: &Path,
|
||||
source_executable: &Path,
|
||||
health_checked_install: bool,
|
||||
) -> (Layout, std::path::PathBuf) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let layout = Layout::new(root.join("machine"));
|
||||
layout.initialize().unwrap();
|
||||
let key_bytes = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
|
||||
let key = Ed25519KeyPair::from_pkcs8(key_bytes.as_ref()).unwrap();
|
||||
fs::write(
|
||||
layout.state().join("trusted-release-key"),
|
||||
format!("{}\n", hex(key.public_key().as_ref())),
|
||||
)
|
||||
.unwrap();
|
||||
let release = if health_checked_install {
|
||||
layout.downloads().join("0.1.0")
|
||||
} else {
|
||||
layout.versions().join("0.1.0")
|
||||
};
|
||||
fs::create_dir(&release).unwrap();
|
||||
let core = release.join("dever-core");
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
if health_checked_install {
|
||||
llvm_pack::compiler(source_executable, &release, "dever-core");
|
||||
} else {
|
||||
fs::copy(source_executable, &core).unwrap();
|
||||
}
|
||||
#[cfg(not(all(target_os = "linux", target_arch = "x86_64")))]
|
||||
fs::copy(source_executable, &core).unwrap();
|
||||
fs::set_permissions(&core, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let bytes = fs::read(&core).unwrap();
|
||||
let artifacts = vec![Artifact {
|
||||
path: "dever-core".into(),
|
||||
bytes: bytes.len() as u64,
|
||||
sha256: digest(&bytes),
|
||||
}];
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
let artifacts = if health_checked_install {
|
||||
artifacts
|
||||
.into_iter()
|
||||
.chain(native_release_inputs(&release))
|
||||
.collect()
|
||||
} else {
|
||||
artifacts
|
||||
};
|
||||
let manifest = ReleaseManifest::new(Version::parse("0.1.0").unwrap(), artifacts);
|
||||
let manifest_bytes = serde_json::to_vec(&manifest).unwrap();
|
||||
fs::write(release.join("manifest.json"), &manifest_bytes).unwrap();
|
||||
fs::write(
|
||||
release.join("manifest.sig"),
|
||||
format!("{}\n", hex(key.sign(&manifest_bytes).as_ref())),
|
||||
)
|
||||
.unwrap();
|
||||
let manager = MachineManager::new(layout.clone());
|
||||
if health_checked_install {
|
||||
manager.install_requested("0.1.0").unwrap();
|
||||
} else {
|
||||
manager
|
||||
.resolve_core(&Version::parse("0.1.0").unwrap())
|
||||
.unwrap();
|
||||
}
|
||||
(layout.clone(), layout.versions().join("0.1.0/dever-core"))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Explicit author inputs included in an ephemeral signature; these are not
|
||||
/// the four trimmed runtime profiles of a formal distribution.
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
fn native_release_inputs(root: &Path) -> Vec<Artifact> {
|
||||
let document = llvm_pack::manifest(root, true);
|
||||
let prefix = format!("runtime/{}", dever_cli::toolchain::platform_identity());
|
||||
let paths = document["files"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|file| format!("{prefix}/{}", file["path"].as_str().unwrap()))
|
||||
.chain([format!("{prefix}/manifest.json")])
|
||||
.chain(
|
||||
fs::read_dir(root.join("lib"))
|
||||
.unwrap()
|
||||
.map(|entry| format!("lib/{}", entry.unwrap().file_name().to_str().unwrap())),
|
||||
);
|
||||
paths
|
||||
.map(|path| {
|
||||
let file = root.join(&path);
|
||||
Artifact {
|
||||
path,
|
||||
bytes: fs::metadata(&file).unwrap().len(),
|
||||
sha256: dever_cli::toolchain::sha256_file(&file).unwrap(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
fn assert_cli_success(executable: &Path, arguments: &[&str]) -> std::process::Output {
|
||||
let output = Command::new(executable)
|
||||
.env_clear()
|
||||
.args(arguments)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{} exited {}\nstdout: {}\nstderr: {}",
|
||||
arguments.join(" "),
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
#[test]
|
||||
#[ignore = "requires explicitly prepared native runtime archive and owned daemon"]
|
||||
fn installed_package_passes_check_test_run_build_and_standalone_execution() {
|
||||
let root = project();
|
||||
let (layout, executable) = installed_dever(root.path());
|
||||
let mut daemon = TestDaemon::start(&layout);
|
||||
let registry = registry(&[
|
||||
(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
archive(
|
||||
"storefront",
|
||||
"1.0.0",
|
||||
serde_json::json!({"catalog":"1.2.0"}),
|
||||
&[],
|
||||
"value() (result: Text) { result = catalog.value.value() }\n",
|
||||
),
|
||||
),
|
||||
(
|
||||
"catalog",
|
||||
"1.2.0",
|
||||
archive(
|
||||
"catalog",
|
||||
"1.2.0",
|
||||
serde_json::json!({}),
|
||||
&[],
|
||||
"value() (result: Text) { result = \"two\" }\n",
|
||||
),
|
||||
),
|
||||
]);
|
||||
execute_with(
|
||||
"add",
|
||||
root.path(),
|
||||
&["storefront@1.0.0".into()],
|
||||
®istry,
|
||||
&ServiceStore(layout.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let project = root.path().to_str().unwrap();
|
||||
assert_cli_success(&executable, &["check", project]);
|
||||
assert_cli_success(&executable, &["test", project]);
|
||||
let run = assert_cli_success(
|
||||
&executable,
|
||||
&["run", project, "--", "portal.value.show", "{}"],
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&run.stdout).contains("two"));
|
||||
let output = root.path().join("standalone");
|
||||
assert_cli_success(
|
||||
&executable,
|
||||
&["build", project, "--output", output.to_str().unwrap()],
|
||||
);
|
||||
daemon.stop();
|
||||
let standalone = assert_cli_success(&output, &["portal.value.show", "{}"]);
|
||||
assert!(String::from_utf8_lossy(&standalone.stdout).contains("two"));
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
//! Actual managed compilation, using ephemeral signing keys and an owned daemon.
|
||||
use super::*;
|
||||
use dever_cli::toolchain::compilation::{CompileKind, CompileRequest};
|
||||
use dever_cli::toolchain::{BuildTarget, CacheStore, compile};
|
||||
use dever_core::source::SourceMap;
|
||||
use dever_runtime::config::Settings;
|
||||
use std::io::Read;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn source_project() -> TemporaryDirectory {
|
||||
let root = project();
|
||||
fs::write(
|
||||
root.path().join("module/portal/value/app.dever"),
|
||||
"value() (result: Text) { result = \"two\" }\n",
|
||||
)
|
||||
.unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
fn request(root: &Path, kind: CompileKind) -> CompileRequest {
|
||||
let sources = SourceMap::load_with_packages(
|
||||
&root.join("module"),
|
||||
if kind == CompileKind::TestSuite {
|
||||
Some(root.join("test"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.as_deref(),
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let bindings = (kind == CompileKind::Application)
|
||||
.then(|| Settings::load_project(root).unwrap().compilation_bindings());
|
||||
CompileRequest::new(
|
||||
"0.1.0".into(),
|
||||
kind,
|
||||
&sources,
|
||||
bindings,
|
||||
&[],
|
||||
BuildTarget::host().unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compilation_snapshot_excludes_secrets_and_rejects_ambiguous_or_unsafe_inputs() {
|
||||
let root = source_project();
|
||||
fs::write(root.path().join("config/setting.json"), serde_json::to_vec(&serde_json::json!({
|
||||
"database":{"default":{"type":"sqlite","path":"private-database-marker.sqlite"}},
|
||||
"auth":{"providers":{"session":{"verify":"user.account.verify",
|
||||
"jwtSecret":"private-jwt-marker-long-enough-for-hmac-2026","ttlSeconds":3600,"cookie":"secret-cookie-name"}}},
|
||||
"sites":{"admin":{"path":"admin","auth":"session","origin":"https://private-origin.example"}}
|
||||
})).unwrap()).unwrap();
|
||||
let original = request(root.path(), CompileKind::Application);
|
||||
let encoded = original.encode().unwrap();
|
||||
let text = std::str::from_utf8(&encoded).unwrap();
|
||||
for secret in [
|
||||
"private-database-marker",
|
||||
"private-jwt-marker",
|
||||
"secret-cookie-name",
|
||||
"private-origin",
|
||||
root.path().to_str().unwrap(),
|
||||
] {
|
||||
assert!(!text.contains(secret), "compile input leaked {secret}");
|
||||
}
|
||||
assert!(text.contains("user.account.verify"));
|
||||
assert!(text.contains("sqlite"));
|
||||
CompileRequest::decode(&encoded).unwrap();
|
||||
let duplicate = text.replace("\"databases\":", "\"databases\":{},\"databases\":");
|
||||
assert!(CompileRequest::decode(duplicate.as_bytes()).is_err());
|
||||
for path in ["/tmp/read-me.dever", "../main.dever", "portal/value/app.rs"] {
|
||||
let mut invalid = original.clone();
|
||||
invalid.sources[0].logical_path = path.into();
|
||||
assert!(invalid.encode().is_err(), "accepted source path {path}");
|
||||
}
|
||||
let mut reserved = original.clone();
|
||||
reserved.sources[0].logical_path = "dever/auth/app.dever".into();
|
||||
assert!(
|
||||
dever_core::check(&reserved.source_map().unwrap()).is_err(),
|
||||
"reserved standard namespace bypassed checking"
|
||||
);
|
||||
let mut invalid = original.clone();
|
||||
invalid.sources.push(invalid.sources[0].clone());
|
||||
assert!(invalid.encode().is_err());
|
||||
let mut invalid = original;
|
||||
invalid.sources[0].is_test = true;
|
||||
assert!(invalid.encode().is_err());
|
||||
let suite = request(root.path(), CompileKind::TestSuite);
|
||||
assert!(suite.bindings.is_none());
|
||||
assert!(
|
||||
suite
|
||||
.source_map()
|
||||
.unwrap()
|
||||
.files()
|
||||
.iter()
|
||||
.any(|source| source.is_test())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compilation_target_is_required_canonical_and_part_of_cache_identity() {
|
||||
let root = source_project();
|
||||
let mut input = request(root.path(), CompileKind::Application);
|
||||
input.target = BuildTarget::LinuxX86_64;
|
||||
let host = input.encode().unwrap();
|
||||
input.target = BuildTarget::LinuxAarch64;
|
||||
let arm = input.encode().unwrap();
|
||||
assert_ne!(host, arm);
|
||||
assert_eq!(
|
||||
CompileRequest::decode(&arm).unwrap().target,
|
||||
BuildTarget::LinuxAarch64
|
||||
);
|
||||
let text = std::str::from_utf8(&arm).unwrap();
|
||||
for invalid in [
|
||||
text.replace("\"target\":\"linux-aarch64\",", ""),
|
||||
text.replace("linux-aarch64", "aarch64-unknown-linux-gnu"),
|
||||
text.replace("\"target\":", "\"target\":\"linux-x86_64\",\"target\":"),
|
||||
] {
|
||||
assert!(CompileRequest::decode(invalid.as_bytes()).is_err());
|
||||
}
|
||||
let mut suite = request(root.path(), CompileKind::TestSuite);
|
||||
suite.target = if BuildTarget::host().unwrap() == BuildTarget::LinuxX86_64 {
|
||||
BuildTarget::LinuxAarch64
|
||||
} else {
|
||||
BuildTarget::LinuxX86_64
|
||||
};
|
||||
assert!(suite.encode().unwrap_err().contains("host target"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicitly prepared native archive; starts an owned daemon"]
|
||||
fn trusted_compilation_cache_requires_sources_and_revalidates_signed_pack() {
|
||||
let root = source_project();
|
||||
let (layout, _) = installed_dever(root.path());
|
||||
let _daemon = TestDaemon::start(&layout);
|
||||
let input = request(root.path(), CompileKind::Application);
|
||||
let started = Instant::now();
|
||||
let first = compile(&layout, &input).unwrap();
|
||||
let cold_seconds = started.elapsed().as_secs_f64();
|
||||
let initial_cache = cache_status(&layout).unwrap();
|
||||
assert_eq!(initial_cache.entries, 1);
|
||||
let started = Instant::now();
|
||||
assert_eq!(compile(&layout, &input).unwrap(), first);
|
||||
let hit_seconds = started.elapsed().as_secs_f64();
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 1);
|
||||
assert!(artifact_get(&layout, &digest(&first), first.len() as u64).is_err());
|
||||
let mut invalid = input.clone();
|
||||
invalid
|
||||
.sources
|
||||
.iter_mut()
|
||||
.find(|source| source.logical_path.ends_with("app.dever"))
|
||||
.unwrap()
|
||||
.text = "value() (result: Text) { result = missing_function() }".into();
|
||||
let error = compile(&layout, &invalid).unwrap_err();
|
||||
assert!(error.contains("missing_function"), "{error}");
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 1);
|
||||
assert_eq!(compile(&layout, &input).unwrap(), first);
|
||||
let mut changed = input.clone();
|
||||
changed
|
||||
.sources
|
||||
.iter_mut()
|
||||
.find(|source| source.logical_path.ends_with("app.dever"))
|
||||
.unwrap()
|
||||
.text = "value() (result: Text) { result = \"three\" }".into();
|
||||
// 两个独立客户端同时提交同一份新源码,必须收敛到一个已验证产物。
|
||||
let barrier = std::sync::Barrier::new(2);
|
||||
let concurrent = std::thread::scope(|scope| {
|
||||
let clients: Vec<_> = (0..2)
|
||||
.map(|_| {
|
||||
scope.spawn(|| {
|
||||
barrier.wait();
|
||||
let started = Instant::now();
|
||||
let bytes = compile(&layout, &changed).unwrap();
|
||||
(bytes, started.elapsed().as_secs_f64())
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
clients
|
||||
.into_iter()
|
||||
.map(|client| client.join().unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
assert_eq!(concurrent[0].0, concurrent[1].0);
|
||||
assert_ne!(concurrent[0].0, first);
|
||||
let shared_cache = cache_status(&layout).unwrap();
|
||||
assert_eq!(shared_cache.entries, 2);
|
||||
|
||||
// The worker compiles the suite; only the caller runs its failing case.
|
||||
fs::write(
|
||||
root.path().join("test/portal/value/smoke.dever"),
|
||||
"smoke() () { assert_eq(1, 2) }",
|
||||
)
|
||||
.unwrap();
|
||||
let suite = compile(&layout, &request(root.path(), CompileKind::TestSuite)).unwrap();
|
||||
let program = dever_core::native::NativeProgram::from_verified_bytes(&suite).unwrap();
|
||||
assert!(
|
||||
!Command::new(program.executable())
|
||||
.arg("0")
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap()
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 3);
|
||||
|
||||
// A valid signature over an incomplete list must not authorize unsigned
|
||||
// link inputs, even when the native manifest still names those inputs.
|
||||
let version_root = layout.versions().join("0.1.0");
|
||||
let trusted_key = fs::read(layout.state().join("trusted-release-key")).unwrap();
|
||||
let signed_manifest = fs::read(version_root.join("manifest.json")).unwrap();
|
||||
let signature = fs::read(version_root.join("manifest.sig")).unwrap();
|
||||
let mut manifest: ReleaseManifest = serde_json::from_slice(&signed_manifest).unwrap();
|
||||
manifest
|
||||
.artifacts
|
||||
.retain(|artifact| !artifact.path.ends_with("/crt1.o"));
|
||||
let key_bytes = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
|
||||
let key = Ed25519KeyPair::from_pkcs8(key_bytes.as_ref()).unwrap();
|
||||
let bytes = serde_json::to_vec(&manifest).unwrap();
|
||||
fs::write(
|
||||
layout.state().join("trusted-release-key"),
|
||||
hex(key.public_key().as_ref()),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(version_root.join("manifest.json"), &bytes).unwrap();
|
||||
fs::write(
|
||||
version_root.join("manifest.sig"),
|
||||
hex(key.sign(&bytes).as_ref()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
compile(&layout, &input)
|
||||
.unwrap_err()
|
||||
.contains("not covered by the signed release")
|
||||
);
|
||||
fs::write(layout.state().join("trusted-release-key"), trusted_key).unwrap();
|
||||
fs::write(version_root.join("manifest.json"), signed_manifest).unwrap();
|
||||
fs::write(version_root.join("manifest.sig"), signature).unwrap();
|
||||
|
||||
let pack = layout.versions().join("0.1.0/runtime/linux-x86_64/crt1.o");
|
||||
fs::write(pack, b"changed signed link input").unwrap();
|
||||
let error = compile(&layout, &input).unwrap_err();
|
||||
assert!(error.contains("integrity"), "{error}");
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 3);
|
||||
assert_eq!(
|
||||
fs::read_dir(layout.native_cache().join("staging"))
|
||||
.unwrap()
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_dir(layout.native_cache().join("operations"))
|
||||
.unwrap()
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
let report = serde_json::json!({
|
||||
"format": "dever-shared-compile-measurement-v1",
|
||||
"scope": "owned signed compiler and daemon; client wall time includes verification and IPC; warm OS caches",
|
||||
"cold_seconds": cold_seconds,
|
||||
"cache_hit_seconds": hit_seconds,
|
||||
"artifact_bytes": first.len(),
|
||||
"artifact_sha256": digest(&first),
|
||||
"initial_cache": initial_cache,
|
||||
"concurrent_clients": concurrent.len(),
|
||||
"concurrent_miss_seconds": concurrent.iter().map(|(_, seconds)| seconds).collect::<Vec<_>>(),
|
||||
"concurrent_same_artifact": true,
|
||||
"shared_cache": shared_cache,
|
||||
});
|
||||
fs::write(
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../target/closure-shared-compile-measurement.json"),
|
||||
serde_json::to_vec_pretty(&report).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires root and prepared archive; only owned clients change UID"]
|
||||
fn different_machine_users_share_compilation_without_private_cache() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::os::unix::process::CommandExt;
|
||||
assert_eq!(rustix::process::geteuid().as_raw(), 0);
|
||||
let root = source_project();
|
||||
let other = source_project();
|
||||
// Only these owned fixtures are shared; the usual temporary root is 0700.
|
||||
fs::set_permissions(root.path(), fs::Permissions::from_mode(0o755)).unwrap();
|
||||
fs::set_permissions(other.path(), fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let (layout, core) = installed_dever(root.path());
|
||||
let _daemon = TestDaemon::start(&layout);
|
||||
assert_cli_success(
|
||||
&core,
|
||||
&[
|
||||
"run",
|
||||
root.path().to_str().unwrap(),
|
||||
"--",
|
||||
"portal.value.show",
|
||||
"{}",
|
||||
],
|
||||
);
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 1);
|
||||
let result = Command::new(&core)
|
||||
.uid(65534)
|
||||
.gid(65534)
|
||||
.env_clear()
|
||||
.args([
|
||||
"run",
|
||||
other.path().to_str().unwrap(),
|
||||
"--",
|
||||
"portal.value.show",
|
||||
"{}",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&result.stdout).contains("two"));
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 1);
|
||||
assert!(!core.parent().unwrap().join("cache").exists());
|
||||
assert!(!other.path().join("cache").exists());
|
||||
}
|
||||
|
||||
fn frame(stream: &mut UnixStream) -> serde_json::Value {
|
||||
let mut size = [0; 4];
|
||||
stream.read_exact(&mut size).unwrap();
|
||||
let mut bytes = vec![0; u32::from_be_bytes(size) as usize];
|
||||
stream.read_exact(&mut bytes).unwrap();
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
|
||||
fn upload(layout: &Layout, input: &CompileRequest) -> UnixStream {
|
||||
let payload = input.encode().unwrap();
|
||||
let mut stream = UnixStream::connect(layout.service_socket()).unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(10)))
|
||||
.unwrap();
|
||||
let token = fs::read_to_string(layout.service_token()).unwrap();
|
||||
let control = serde_json::to_vec(&serde_json::json!({"token":token.trim(),
|
||||
"operation":{"compile":{"bytes":payload.len()}},"installed":[]}))
|
||||
.unwrap();
|
||||
stream
|
||||
.write_all(&(control.len() as u32).to_be_bytes())
|
||||
.unwrap();
|
||||
stream.write_all(&control).unwrap();
|
||||
assert_eq!(frame(&mut stream)["ok"], true);
|
||||
stream.write_all(&payload).unwrap();
|
||||
stream
|
||||
}
|
||||
|
||||
fn wait_for<T>(mode: &str, phase: &str, mut action: impl FnMut() -> Option<T>) -> T {
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
if let Some(value) = action() {
|
||||
return value;
|
||||
}
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(10),
|
||||
"owned compilation mode={mode} timed out waiting for {phase}"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared archive, explicit author cc/LLVM inputs; supervises an owned signed sleeper"]
|
||||
fn cancellation_disconnect_and_version_lease_reap_worker_and_staging() {
|
||||
let root = source_project();
|
||||
let sleeper = root.path().join("signed-sleeper");
|
||||
let source = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../test/dever-cli-tests/support/compiler_sleeper.c");
|
||||
let status = Command::new("/usr/bin/cc")
|
||||
.env_clear()
|
||||
.args([
|
||||
"-B/usr/bin/",
|
||||
"-O2",
|
||||
"-Wl,--disable-new-dtags,-rpath,$ORIGIN/lib,--no-as-needed",
|
||||
"-L/usr/lib/llvm-18/lib",
|
||||
"-lLLVM-18",
|
||||
"-lstdc++",
|
||||
"-lz",
|
||||
"-lgcc_s",
|
||||
])
|
||||
.arg(source)
|
||||
.arg("-o")
|
||||
.arg(&sleeper)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(
|
||||
status.success(),
|
||||
"explicit native sleeper preparation failed"
|
||||
);
|
||||
let (layout, _) = signed_core(root.path(), &sleeper, true);
|
||||
let _daemon = TestDaemon::start(&layout);
|
||||
for mode in ["cancel", "disconnect", "deadline"] {
|
||||
let mut stream = upload(&layout, &request(root.path(), CompileKind::Application));
|
||||
let pid = wait_for(mode, "worker PID publication", || {
|
||||
fs::read_dir(layout.native_cache().join("staging"))
|
||||
.ok()?
|
||||
.filter_map(Result::ok)
|
||||
.find_map(|entry| {
|
||||
fs::read_to_string(entry.path().join("worker.pid"))
|
||||
.ok()?
|
||||
.parse::<i32>()
|
||||
.ok()
|
||||
.filter(|pid| *pid > 0)
|
||||
.map(|pid| pid.to_string())
|
||||
})
|
||||
});
|
||||
let lock = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(layout.state().join("install.lock"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
fs2::FileExt::try_lock_exclusive(&lock).is_err(),
|
||||
"active compiler version was not leased"
|
||||
);
|
||||
assert!(
|
||||
CacheStore::new(&layout)
|
||||
.clean(&Default::default())
|
||||
.unwrap_err()
|
||||
.contains("busy")
|
||||
);
|
||||
if mode == "cancel" {
|
||||
stream.write_all(&[1]).unwrap();
|
||||
let response = frame(&mut stream);
|
||||
assert_eq!(response["ok"], false);
|
||||
assert!(response["error"].as_str().unwrap().contains("cancel"));
|
||||
} else if mode == "deadline" {
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(190)))
|
||||
.unwrap();
|
||||
let response = frame(&mut stream);
|
||||
assert_eq!(response["ok"], false);
|
||||
assert!(response["error"].as_str().unwrap().contains("deadline"));
|
||||
}
|
||||
drop(stream);
|
||||
wait_for(mode, "worker exit and staging/operation cleanup", || {
|
||||
let staging = fs::read_dir(layout.native_cache().join("staging"))
|
||||
.ok()?
|
||||
.count();
|
||||
let operations = fs::read_dir(layout.native_cache().join("operations"))
|
||||
.ok()?
|
||||
.count();
|
||||
(staging == 0 && operations == 0 && !Path::new("/proc").join(&pid).exists())
|
||||
.then_some(())
|
||||
});
|
||||
fs2::FileExt::try_lock_exclusive(&lock).unwrap();
|
||||
fs2::FileExt::unlock(&lock).unwrap();
|
||||
assert_eq!(cache_status(&layout).unwrap().entries, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
use super::super::release::Artifact;
|
||||
use super::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
fn manifest() -> ReleaseManifest {
|
||||
ReleaseManifest::new(
|
||||
Version::parse("1.2.3").unwrap(),
|
||||
vec![Artifact {
|
||||
path: "dever-core".into(),
|
||||
bytes: 4,
|
||||
sha256: super::super::release::hex(&Sha256::digest(b"core")),
|
||||
}],
|
||||
)
|
||||
}
|
||||
|
||||
fn archive(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut archive = tar::Builder::new(Vec::new());
|
||||
for (name, contents) in entries {
|
||||
let mut header = tar::Header::new_ustar();
|
||||
header.set_size(contents.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
archive.append_data(&mut header, name, *contents).unwrap();
|
||||
}
|
||||
archive.into_inner().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_archive_accepts_only_the_exact_signed_regular_files() {
|
||||
let root = Download::new(&std::env::temp_dir()).unwrap();
|
||||
unpack(
|
||||
archive(&[("dever-core", b"core")]).as_slice(),
|
||||
&root.0,
|
||||
&manifest(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fs::read(root.0.join("dever-core")).unwrap(), b"core");
|
||||
for entries in [
|
||||
vec![
|
||||
("dever-core", b"core".as_slice()),
|
||||
("extra", b"x".as_slice()),
|
||||
],
|
||||
vec![
|
||||
("dever-core", b"core".as_slice()),
|
||||
("dever-core", b"core".as_slice()),
|
||||
],
|
||||
vec![("dever-core", b"bad".as_slice())],
|
||||
vec![],
|
||||
] {
|
||||
let root = Download::new(&std::env::temp_dir()).unwrap();
|
||||
assert!(unpack(archive(&entries).as_slice(), &root.0, &manifest()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_archive_rejects_links_and_extension_headers() {
|
||||
for kind in [
|
||||
tar::EntryType::Symlink,
|
||||
tar::EntryType::Link,
|
||||
tar::EntryType::XGlobalHeader,
|
||||
] {
|
||||
let root = Download::new(&std::env::temp_dir()).unwrap();
|
||||
let mut archive = tar::Builder::new(Vec::new());
|
||||
let mut header = tar::Header::new_ustar();
|
||||
header.set_path("dever-core").unwrap();
|
||||
header.set_entry_type(kind);
|
||||
header.set_size(0);
|
||||
header.set_mode(0o644);
|
||||
header.set_link_name("/outside").unwrap();
|
||||
header.set_cksum();
|
||||
archive.append(&header, io::empty()).unwrap();
|
||||
assert!(
|
||||
unpack(
|
||||
archive.into_inner().unwrap().as_slice(),
|
||||
&root.0,
|
||||
&manifest()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(!root.0.join("dever-core").exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_catalog_paths_and_total_size_are_bounded_before_extraction() {
|
||||
for path in [
|
||||
"/absolute",
|
||||
"../outside",
|
||||
"a/../b",
|
||||
"./file",
|
||||
"a//b",
|
||||
"a\\b",
|
||||
"C:drive",
|
||||
"manifest.json",
|
||||
"manifest.sig",
|
||||
] {
|
||||
let mut manifest = manifest();
|
||||
manifest.artifacts[0].path = path.into();
|
||||
assert!(validate_catalog(&manifest).is_err(), "{path}");
|
||||
}
|
||||
let mut excessive = manifest();
|
||||
excessive.artifacts[0].bytes = RELEASE_LIMIT + 1;
|
||||
assert!(validate_catalog(&excessive).is_err());
|
||||
let mut duplicate = manifest();
|
||||
duplicate.artifacts.push(duplicate.artifacts[0].clone());
|
||||
assert!(validate_catalog(&duplicate).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_redirects_reject_http_credentials_ports_and_foreign_hosts() {
|
||||
let current = "https://github.com/shemic/dever-main/releases/latest/download/a";
|
||||
assert!(
|
||||
redirect(
|
||||
current,
|
||||
"https://release-assets.githubusercontent.com/asset?token=opaque"
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(redirect(current, "/shemic/dever-main/releases/download/v1.2.3/a").is_ok());
|
||||
for address in [
|
||||
"http://github.com/a",
|
||||
"https://evil.example/a",
|
||||
"https://github.com.evil.example/a",
|
||||
"https://user@github.com/a",
|
||||
"https://github.com:444/a",
|
||||
"https://github.com/a#fragment",
|
||||
] {
|
||||
assert!(redirect(current, address).is_err(), "{address}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_asset_names_are_versioned_and_platform_specific() {
|
||||
let source = ReleaseSource::official();
|
||||
assert_eq!(
|
||||
source.asset("latest", "manifest.json"),
|
||||
format!(
|
||||
"{OFFICIAL_RELEASES}/latest/download/dever-{}.manifest.json",
|
||||
platform_identity()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
source.asset("1.2.3", "tar.gz"),
|
||||
format!(
|
||||
"{OFFICIAL_RELEASES}/download/v1.2.3/dever-{}.tar.gz",
|
||||
platform_identity()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
fn signed_release() -> (ReleaseManifest, Vec<u8>, Vec<u8>, Vec<u8>) {
|
||||
use ring::signature::{Ed25519KeyPair, KeyPair};
|
||||
let pair = Ed25519KeyPair::from_seed_unchecked(&[37; 32]).unwrap();
|
||||
let mut manifest = manifest();
|
||||
manifest.artifacts.push(Artifact {
|
||||
path: "skills/dever-language/SKILL.md".into(),
|
||||
bytes: 5,
|
||||
sha256: super::super::release::hex(&Sha256::digest(b"skill")),
|
||||
});
|
||||
let bytes = serde_json::to_vec(&manifest).unwrap();
|
||||
let signature = super::super::release::hex(pair.sign(&bytes).as_ref()).into_bytes();
|
||||
(
|
||||
manifest,
|
||||
bytes,
|
||||
signature,
|
||||
pair.public_key().as_ref().to_vec(),
|
||||
)
|
||||
}
|
||||
|
||||
fn server(responses: Vec<Vec<u8>>) -> (ReleaseSource, std::thread::JoinHandle<()>) {
|
||||
use std::io::BufRead;
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let base = format!("http://{}/releases", listener.local_addr().unwrap());
|
||||
let worker = std::thread::spawn(move || {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
for response in responses {
|
||||
let mut stream = loop {
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => break stream,
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"expected release request did not arrive"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
}
|
||||
};
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let mut reader = io::BufReader::new(stream.try_clone().unwrap());
|
||||
let mut request = String::new();
|
||||
reader.read_line(&mut request).unwrap();
|
||||
assert!(request.starts_with("GET /releases/"));
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
if line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
response.len()
|
||||
)
|
||||
.unwrap();
|
||||
stream.write_all(&response).unwrap();
|
||||
}
|
||||
});
|
||||
(ReleaseSource::new(base), worker)
|
||||
}
|
||||
|
||||
fn compressed_release(core: &[u8]) -> Vec<u8> {
|
||||
let bytes = archive(&[
|
||||
("dever-core", core),
|
||||
("skills/dever-language/SKILL.md", b"skill"),
|
||||
]);
|
||||
let mut gzip = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
gzip.write_all(&bytes).unwrap();
|
||||
gzip.finish().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_download_verifies_the_complete_bundle_before_publishing_catalog() {
|
||||
let root = Download::new(&std::env::temp_dir()).unwrap();
|
||||
let layout = Layout::new(root.0.join("machine"));
|
||||
layout.initialize().unwrap();
|
||||
let (manifest, bytes, signature, key) = signed_release();
|
||||
fs::write(
|
||||
layout.state().join("trusted-release-key"),
|
||||
super::super::release::hex(&key),
|
||||
)
|
||||
.unwrap();
|
||||
let (source, worker) = server(vec![bytes, signature, compressed_release(b"core")]);
|
||||
let version = prepare_from(&layout, "latest", &source).unwrap();
|
||||
worker.join().unwrap();
|
||||
assert_eq!(version, manifest.version);
|
||||
let installed = layout.downloads().join(version.as_str());
|
||||
MachineManager::verify_package(&installed, Some(&version), &key).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(installed.join("skills/dever-language/SKILL.md")).unwrap(),
|
||||
b"skill"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(layout.downloads().join("latest")).unwrap(),
|
||||
"1.2.3\n"
|
||||
);
|
||||
assert_eq!(fs::read_dir(layout.staging()).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_signatures_and_corrupt_payloads_preserve_the_previous_catalog_and_active_version() {
|
||||
for bad_signature in [true, false] {
|
||||
let root = Download::new(&std::env::temp_dir()).unwrap();
|
||||
let layout = Layout::new(root.0.join("machine"));
|
||||
layout.initialize().unwrap();
|
||||
let (_, bytes, signature, key) = signed_release();
|
||||
fs::write(
|
||||
layout.state().join("trusted-release-key"),
|
||||
super::super::release::hex(&key),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(layout.downloads().join("latest"), "0.9.0\n").unwrap();
|
||||
let active = layout.state().join("active-version");
|
||||
super::super::release::append_active_version(&active, &Version::parse("0.9.0").unwrap())
|
||||
.unwrap();
|
||||
let previous = fs::read(&active).unwrap();
|
||||
let responses = if bad_signature {
|
||||
vec![bytes, vec![b'0'; 128]]
|
||||
} else {
|
||||
vec![bytes, signature, compressed_release(b"evil")]
|
||||
};
|
||||
let (source, worker) = server(responses);
|
||||
let error = prepare_from(&layout, "latest", &source).unwrap_err();
|
||||
worker.join().unwrap();
|
||||
assert!(error.contains("verification"), "{error}");
|
||||
assert_eq!(fs::read(&active).unwrap(), previous);
|
||||
assert_eq!(
|
||||
fs::read_to_string(layout.downloads().join("latest")).unwrap(),
|
||||
"0.9.0\n"
|
||||
);
|
||||
assert!(!layout.downloads().join("1.2.3").exists());
|
||||
assert_eq!(fs::read_dir(layout.staging()).unwrap().count(), 0);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,2 @@
|
||||
/* Deliberately separate DT_NEEDED dependency for native command packaging. */
|
||||
int command_value(void) { return 42; }
|
||||
@@ -0,0 +1,19 @@
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
extern int command_value(void);
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
const unsigned char expected[] = {0, 255, 128, 10};
|
||||
unsigned char input[sizeof(expected)];
|
||||
if (argc != 3 || strcmp(argv[1], "a b") != 0 ||
|
||||
strcmp(argv[2], "$(literal);*") != 0 || command_value() != 42)
|
||||
return 90;
|
||||
if (fread(input, 1, sizeof(input), stdin) != sizeof(input) ||
|
||||
memcmp(input, expected, sizeof(input)) != 0 || getchar() != EOF)
|
||||
return 91;
|
||||
if (fwrite(input, 1, sizeof(input), stdout) != sizeof(input) ||
|
||||
fputs("ordinary command", stderr) == EOF)
|
||||
return 92;
|
||||
return 23;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/* Native author fixture for compile cancellation and deadline supervision. */
|
||||
#include <stdio.h>
|
||||
#include <errno.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc == 2 && strcmp(argv[1], "--dever-health") == 0) return 0;
|
||||
FILE *pid = fopen("worker.pid", "w");
|
||||
if (!pid) return 2;
|
||||
if (fprintf(pid, "%ld", (long)getpid()) < 0 || fclose(pid) != 0) return 3;
|
||||
struct timespec remaining = {300, 0};
|
||||
while (nanosleep(&remaining, &remaining) != 0) {
|
||||
if (errno != EINTR) return 4;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Lifetime of an explicitly requested test-owned shared compiler service.
|
||||
use std::process::{Child, Command};
|
||||
use std::time::Duration;
|
||||
|
||||
use dever_cli::toolchain::{Layout, cache_status};
|
||||
|
||||
pub struct TestDaemon(Child);
|
||||
|
||||
impl TestDaemon {
|
||||
pub fn start(layout: &Layout) -> Self {
|
||||
let child = Command::new(env!("CARGO_BIN_EXE_deverd"))
|
||||
.env_clear()
|
||||
.current_dir(layout.root())
|
||||
.args(["--root", "."])
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut daemon = Self(child);
|
||||
for _ in 0..100 {
|
||||
if cache_status(layout).is_ok() {
|
||||
return daemon;
|
||||
}
|
||||
assert!(
|
||||
daemon.0.try_wait().unwrap().is_none(),
|
||||
"test deverd exited early"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
panic!("test deverd did not become ready");
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) {
|
||||
self.0.kill().unwrap();
|
||||
self.0.wait().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDaemon {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Explicit Linux author inputs shared by opt-in native fixtures.
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const LLVM_LIBRARY: &str = "/usr/lib/llvm-18/lib/libLLVM.so.1";
|
||||
|
||||
pub fn core_libraries() -> Vec<(&'static str, PathBuf)> {
|
||||
let mut inputs = vec![("libLLVM.so.18.1", PathBuf::from(LLVM_LIBRARY))];
|
||||
for name in [
|
||||
"libstdc++.so.6",
|
||||
"libgcc_s.so.1",
|
||||
"libz.so.1",
|
||||
"libffi.so.8",
|
||||
"libedit.so.2",
|
||||
"libzstd.so.1",
|
||||
"libtinfo.so.6",
|
||||
"libxml2.so.2",
|
||||
"libbsd.so.0",
|
||||
"libmd.so.0",
|
||||
"libicuuc.so.74",
|
||||
"libicudata.so.74",
|
||||
"liblzma.so.5",
|
||||
] {
|
||||
inputs.push((name, Path::new("/usr/lib/x86_64-linux-gnu").join(name)));
|
||||
}
|
||||
inputs
|
||||
.into_iter()
|
||||
.map(|(name, path)| {
|
||||
(
|
||||
name,
|
||||
path.canonicalize()
|
||||
.expect("prepare the explicit GNU compiler-library inputs"),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn native_inputs() -> Vec<(&'static str, PathBuf)> {
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
vec![
|
||||
(
|
||||
"runtime.a",
|
||||
workspace.join("target/native-runtime-abi/debug/libdever_backend_bridge.a"),
|
||||
),
|
||||
("crt1.o", "/usr/lib/x86_64-linux-gnu/crt1.o".into()),
|
||||
("crti.o", "/usr/lib/x86_64-linux-gnu/crti.o".into()),
|
||||
(
|
||||
"crtbeginT.o",
|
||||
"/usr/lib/gcc/x86_64-linux-gnu/13/crtbeginT.o".into(),
|
||||
),
|
||||
(
|
||||
"crtend.o",
|
||||
"/usr/lib/gcc/x86_64-linux-gnu/13/crtend.o".into(),
|
||||
),
|
||||
("crtn.o", "/usr/lib/x86_64-linux-gnu/crtn.o".into()),
|
||||
("libc.a", "/usr/lib/x86_64-linux-gnu/libc.a".into()),
|
||||
("libm.a", "/usr/lib/x86_64-linux-gnu/libm-2.39.a".into()),
|
||||
("libmvec.a", "/usr/lib/x86_64-linux-gnu/libmvec.a".into()),
|
||||
(
|
||||
"libgcc.a",
|
||||
"/usr/lib/gcc/x86_64-linux-gnu/13/libgcc.a".into(),
|
||||
),
|
||||
(
|
||||
"libgcc_eh.a",
|
||||
"/usr/lib/gcc/x86_64-linux-gnu/13/libgcc_eh.a".into(),
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Explicit test inputs: no downloads, recursive builds or host tool lookup.
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use dever_cli::toolchain::sha256_file as hash_file;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[path = "llvm_inputs.rs"]
|
||||
mod inputs;
|
||||
#[cfg(test)]
|
||||
#[path = "native_objects.rs"]
|
||||
mod native_objects;
|
||||
use inputs::{core_libraries, native_inputs};
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn compiler(source: &Path, root: &Path, name: &str) -> PathBuf {
|
||||
fs::create_dir_all(root).unwrap();
|
||||
let path = root.join(name);
|
||||
fs::hard_link(source, &path).unwrap();
|
||||
compiler_library(root).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
pub fn pack_root(root: &Path) -> PathBuf {
|
||||
root.join("runtime")
|
||||
.join(dever_cli::toolchain::platform_identity())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn manifest(root: &Path, real: bool) -> Value {
|
||||
let destination = pack_root(root);
|
||||
fs::create_dir_all(&destination).unwrap();
|
||||
let sources = native_inputs();
|
||||
let mut files = Vec::new();
|
||||
for (name, source) in sources {
|
||||
let output = destination.join(name);
|
||||
if real {
|
||||
assert!(
|
||||
source.is_file(),
|
||||
"prepare the explicit native archive and CRT fixture first: {}",
|
||||
source.display()
|
||||
);
|
||||
// Only immutable reads follow. Corruption cases use synthetic files.
|
||||
fs::hard_link(source, &output).unwrap();
|
||||
} else {
|
||||
fs::write(
|
||||
&output,
|
||||
if name.ends_with(".a") {
|
||||
native_objects::archive(
|
||||
dever_cli::toolchain::BuildTarget::host().unwrap(),
|
||||
"fixture.o",
|
||||
)
|
||||
} else {
|
||||
native_objects::object(dever_cli::toolchain::BuildTarget::host().unwrap())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
files.push(json!({"path":name, "bytes":fs::metadata(&output).unwrap().len(), "sha256":digest(&output)}));
|
||||
}
|
||||
let document = document(files);
|
||||
write_manifest(root, &document);
|
||||
document
|
||||
}
|
||||
|
||||
fn document(files: Vec<Value>) -> Value {
|
||||
let profile = json!({
|
||||
"start":["crt1.o","crti.o","crtbeginT.o"], "runtime":"runtime.a",
|
||||
"libraries":["libc.a","libm.a","libmvec.a","libgcc.a","libgcc_eh.a"],
|
||||
"end":["crtend.o","crtn.o"]
|
||||
});
|
||||
json!({
|
||||
"format":"dever-native-runtime-v1", "compiler_version":env!("CARGO_PKG_VERSION"),
|
||||
"abi":dever_backend_bridge::RUNTIME_ABI_VERSION,
|
||||
"platform":dever_cli::toolchain::platform_identity(),
|
||||
"target":"x86_64-unknown-linux-gnu",
|
||||
"profiles":{"base":profile,"sqlite":profile,"postgres":profile,"both":profile},
|
||||
"files":files
|
||||
})
|
||||
}
|
||||
|
||||
fn compiler_library(root: &Path) -> Result<(), String> {
|
||||
let libraries = root.join("lib");
|
||||
create_or_check_directory(&libraries)?;
|
||||
for (name, source) in core_libraries() {
|
||||
let target = libraries.join(name);
|
||||
match fs::symlink_metadata(&target) {
|
||||
Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => {
|
||||
if hash_file(&target)? != hash_file(&source)? {
|
||||
return Err(
|
||||
"existing compiler library differs from the explicit author SDK".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(_) => return Err("compiler library must be a real file".into()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
fs::hard_link(source, target).map_err(|error| error.to_string())?;
|
||||
}
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_or_check_directory(path: &Path) -> Result<(), String> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => Ok(()),
|
||||
Ok(_) => Err(format!("'{}' must be a real directory", path.display())),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
fs::create_dir(path).map_err(|error| error.to_string())
|
||||
}
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn write_manifest(root: &Path, manifest: &Value) {
|
||||
fs::write(
|
||||
pack_root(root).join("manifest.json"),
|
||||
serde_json::to_vec(manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn digest(path: &Path) -> String {
|
||||
hash_file(path).unwrap()
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Test-only static entry for the already pivoted no-host acceptance namespace.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use rustix::mount::{MountFlags, mount};
|
||||
use rustix::thread::{self, CapabilitySet};
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
let mut arguments = std::env::args_os().skip(1);
|
||||
let executable = arguments.next().ok_or("missing acceptance executable")?;
|
||||
if !std::path::Path::new(&executable).is_absolute() {
|
||||
return Err("acceptance executable must be absolute".into());
|
||||
}
|
||||
// The root-only harness keeps bwrap's original namespace capabilities.
|
||||
// Reject ordinary host execution before touching any mount.
|
||||
if !thread::no_new_privs()?
|
||||
|| !thread::capabilities(None)?
|
||||
.effective
|
||||
.contains(CapabilitySet::SYS_ADMIN)
|
||||
{
|
||||
return Err("acceptance init requires its restricted fixture namespace".into());
|
||||
}
|
||||
// bwrap's readonly proc submounts otherwise prevent a nested user
|
||||
// namespace from mounting its own proc. Replace only this private proc.
|
||||
mount(
|
||||
"proc",
|
||||
"/proc",
|
||||
"proc",
|
||||
MountFlags::NOSUID | MountFlags::NODEV | MountFlags::NOEXEC,
|
||||
None,
|
||||
)?;
|
||||
Err(std::process::Command::new(executable)
|
||||
.args(arguments)
|
||||
.env_clear()
|
||||
.exec()
|
||||
.into())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn main() -> Result<(), &'static str> {
|
||||
Err("native acceptance isolation requires Linux")
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Small ELF metadata fixtures; execution acceptance uses real author inputs.
|
||||
pub fn compiler(needed: &[&str]) -> Vec<u8> {
|
||||
elf(None, needed, Some("$ORIGIN/lib"))
|
||||
}
|
||||
|
||||
pub fn library(name: &str, needed: &[&str]) -> Vec<u8> {
|
||||
elf(Some(name), needed, None)
|
||||
}
|
||||
|
||||
pub fn static_executable() -> Vec<u8> {
|
||||
let mut bytes = elf(None, &[], None);
|
||||
// Change the synthetic PT_INTERP entry to PT_NULL. Execution tests use the
|
||||
// real static author input; this fixture tests only ELF admission rules.
|
||||
bytes[64 + 2 * 56..68 + 2 * 56].copy_from_slice(&0u32.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
pub fn elf(soname: Option<&str>, needed: &[&str], rpath: Option<&str>) -> Vec<u8> {
|
||||
let mut strings = vec![0u8];
|
||||
let mut string = |text: &str| {
|
||||
let offset = strings.len() as u64;
|
||||
strings.extend_from_slice(text.as_bytes());
|
||||
strings.push(0);
|
||||
offset
|
||||
};
|
||||
let mut dynamic = needed
|
||||
.iter()
|
||||
.map(|name| (1u64, string(name)))
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(name) = soname {
|
||||
dynamic.push((14, string(name)));
|
||||
}
|
||||
if let Some(path) = rpath {
|
||||
dynamic.push((15, string(path)));
|
||||
}
|
||||
let interpreter = if cfg!(target_arch = "aarch64") {
|
||||
b"/lib/ld-linux-aarch64.so.1\0".as_slice()
|
||||
} else {
|
||||
b"/lib64/ld-linux-x86-64.so.2\0".as_slice()
|
||||
};
|
||||
let header_count: u16 = if soname.is_some() { 2 } else { 3 };
|
||||
let string_offset = 64 + usize::from(header_count) * 56;
|
||||
let interpreter_offset = string_offset + strings.len();
|
||||
let dynamic_offset = (interpreter_offset + interpreter.len() + 7) & !7;
|
||||
dynamic.extend([
|
||||
(5, string_offset as u64),
|
||||
(10, strings.len() as u64),
|
||||
(0, 0),
|
||||
]);
|
||||
let length = dynamic_offset + dynamic.len() * 16;
|
||||
let mut bytes = vec![0; length];
|
||||
bytes[..7].copy_from_slice(b"\x7fELF\x02\x01\x01");
|
||||
bytes[16..18].copy_from_slice(&3u16.to_le_bytes());
|
||||
let machine = if cfg!(target_arch = "aarch64") {
|
||||
183u16
|
||||
} else {
|
||||
62u16
|
||||
};
|
||||
bytes[18..20].copy_from_slice(&machine.to_le_bytes());
|
||||
bytes[20..24].copy_from_slice(&1u32.to_le_bytes());
|
||||
bytes[32..40].copy_from_slice(&64u64.to_le_bytes());
|
||||
bytes[52..54].copy_from_slice(&64u16.to_le_bytes());
|
||||
bytes[54..56].copy_from_slice(&56u16.to_le_bytes());
|
||||
bytes[56..58].copy_from_slice(&header_count.to_le_bytes());
|
||||
let mut segment = |index: usize, kind: u32, offset: usize, size: usize| {
|
||||
let start = 64 + index * 56;
|
||||
bytes[start..start + 4].copy_from_slice(&kind.to_le_bytes());
|
||||
bytes[start + 4..start + 8].copy_from_slice(&4u32.to_le_bytes());
|
||||
for at in [8, 16, 24] {
|
||||
bytes[start + at..start + at + 8].copy_from_slice(&(offset as u64).to_le_bytes());
|
||||
}
|
||||
for at in [32, 40] {
|
||||
bytes[start + at..start + at + 8].copy_from_slice(&(size as u64).to_le_bytes());
|
||||
}
|
||||
bytes[start + 48..start + 56].copy_from_slice(&1u64.to_le_bytes());
|
||||
};
|
||||
segment(0, 1, 0, length);
|
||||
segment(1, 2, dynamic_offset, dynamic.len() * 16);
|
||||
if soname.is_none() {
|
||||
segment(2, 3, interpreter_offset, interpreter.len());
|
||||
}
|
||||
bytes[string_offset..interpreter_offset].copy_from_slice(&strings);
|
||||
bytes[interpreter_offset..interpreter_offset + interpreter.len()].copy_from_slice(interpreter);
|
||||
for (index, (tag, value)) in dynamic.into_iter().enumerate() {
|
||||
let start = dynamic_offset + index * 16;
|
||||
bytes[start..start + 8].copy_from_slice(&tag.to_le_bytes());
|
||||
bytes[start + 8..start + 16].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Small architecture-bearing link inputs for pack validation tests.
|
||||
use dever_cli::toolchain::BuildTarget;
|
||||
|
||||
pub fn object(target: BuildTarget) -> Vec<u8> {
|
||||
let mut bytes = vec![0; 64];
|
||||
bytes[..7].copy_from_slice(b"\x7fELF\x02\x01\x01");
|
||||
bytes[16..18].copy_from_slice(&1_u16.to_le_bytes());
|
||||
let machine: u16 = match target {
|
||||
BuildTarget::LinuxX86_64 => 62,
|
||||
BuildTarget::LinuxAarch64 => 183,
|
||||
};
|
||||
bytes[18..20].copy_from_slice(&machine.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
pub fn archive(target: BuildTarget, member: &str) -> Vec<u8> {
|
||||
let object = object(target);
|
||||
let header = format!(
|
||||
"{:<16}{:<12}{:<6}{:<6}{:<8}{:<10}`\n",
|
||||
format!("{member}/"),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
"644",
|
||||
object.len()
|
||||
);
|
||||
assert_eq!(header.len(), 60);
|
||||
[b"!<arch>\n".as_slice(), header.as_bytes(), &object].concat()
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub fn metadata(name: &str, version: &str, requires: &[&str], extras: &[&str]) -> String {
|
||||
let mut text = format!("Metadata-Version: 2.4\nName: {name}\nVersion: {version}\n");
|
||||
for requirement in requires {
|
||||
text.push_str(&format!("Requires-Dist: {requirement}\n"));
|
||||
}
|
||||
for extra in extras {
|
||||
text.push_str(&format!("Provides-Extra: {extra}\n"));
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
pub fn pack(
|
||||
name: &str,
|
||||
version: &str,
|
||||
wheel: &str,
|
||||
metadata: &str,
|
||||
extra: &[(&str, Vec<u8>)],
|
||||
) -> Vec<u8> {
|
||||
let info = format!("{}-{version}.dist-info", name.replace('-', "_"));
|
||||
let mut files = vec![
|
||||
(format!("{info}/WHEEL"), wheel.as_bytes().to_vec()),
|
||||
(format!("{info}/METADATA"), metadata.as_bytes().to_vec()),
|
||||
];
|
||||
files.extend(
|
||||
extra
|
||||
.iter()
|
||||
.map(|(path, bytes)| ((*path).to_owned(), bytes.clone())),
|
||||
);
|
||||
let mut record = csv::WriterBuilder::new()
|
||||
.has_headers(false)
|
||||
.from_writer(Vec::new());
|
||||
for (path, bytes) in &files {
|
||||
record
|
||||
.write_record([
|
||||
path.clone(),
|
||||
format!("sha256={}", URL_SAFE_NO_PAD.encode(Sha256::digest(bytes))),
|
||||
bytes.len().to_string(),
|
||||
])
|
||||
.unwrap();
|
||||
}
|
||||
let path = format!("{info}/RECORD");
|
||||
record
|
||||
.write_record([path.clone(), String::new(), String::new()])
|
||||
.unwrap();
|
||||
files.push((path, record.into_inner().unwrap()));
|
||||
zip(&files)
|
||||
}
|
||||
|
||||
pub fn zip(files: &[(String, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (path, bytes) in files {
|
||||
writer
|
||||
.start_file(path, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
writer.write_all(bytes).unwrap();
|
||||
}
|
||||
writer.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
pub fn markers() -> pep508_rs::MarkerEnvironment {
|
||||
pep508_rs::MarkerEnvironmentBuilder {
|
||||
implementation_name: "cpython",
|
||||
implementation_version: "3.12.0",
|
||||
os_name: "posix",
|
||||
platform_machine: "x86_64",
|
||||
platform_python_implementation: "CPython",
|
||||
platform_release: "",
|
||||
platform_system: "Linux",
|
||||
platform_version: "",
|
||||
python_full_version: "3.12.0",
|
||||
python_version: "3.12",
|
||||
sys_platform: "linux",
|
||||
}
|
||||
.try_into()
|
||||
.unwrap()
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/* Explicit AppArmor acceptance probe. Run only after host-policy approval.
|
||||
* One process attempts capabilities in its own user/network namespaces; it
|
||||
* never execs another program and never changes the host network or policy.
|
||||
* Exit 0: kernel denied a required capability; 1: unsafe access; 2: invalid or
|
||||
* unavailable setup. aa-exec refusing the transition before main is separate
|
||||
* evidence and must be recorded by the caller, not mistaken for this result.
|
||||
*/
|
||||
#define _GNU_SOURCE
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <net/if.h>
|
||||
#include <sched.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static int failure(const char *stage)
|
||||
{
|
||||
int cause = errno;
|
||||
int denied = cause == EPERM || cause == EACCES;
|
||||
printf("%s|%s|errno=%d\n", denied ? "DENIED" : "UNAVAILABLE", stage, cause);
|
||||
return denied ? 0 : 2;
|
||||
}
|
||||
|
||||
static int unavailable(const char *stage)
|
||||
{
|
||||
printf("UNAVAILABLE|%s|errno=%d\n", stage, errno);
|
||||
return 2;
|
||||
}
|
||||
|
||||
static int write_mapping(const char *path, const char *text)
|
||||
{
|
||||
int fd = open(path, O_WRONLY | O_CLOEXEC | O_NOFOLLOW);
|
||||
if (fd < 0)
|
||||
return -1;
|
||||
size_t size = strlen(text);
|
||||
ssize_t written = write(fd, text, size);
|
||||
int cause = errno;
|
||||
close(fd);
|
||||
if (written != (ssize_t)size) {
|
||||
errno = written < 0 ? cause : EIO;
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
uid_t uid = getuid();
|
||||
gid_t gid = getgid();
|
||||
if (uid == 0 || geteuid() == 0) {
|
||||
fputs("UNAVAILABLE|requires_real_nonzero_uid\n", stderr);
|
||||
return 2;
|
||||
}
|
||||
char label[4096];
|
||||
int fd = open("/proc/self/attr/current", O_RDONLY | O_CLOEXEC);
|
||||
if (fd < 0)
|
||||
return unavailable("read_initial_label");
|
||||
ssize_t length = read(fd, label, sizeof(label) - 1);
|
||||
int cause = errno;
|
||||
close(fd);
|
||||
if (length < 0) {
|
||||
errno = cause;
|
||||
return unavailable("read_initial_label");
|
||||
}
|
||||
label[length] = '\0';
|
||||
printf("INITIAL_LABEL|%s\n", label);
|
||||
if (unshare(CLONE_NEWUSER) != 0)
|
||||
return failure("unshare_user");
|
||||
char mapping[80];
|
||||
snprintf(mapping, sizeof(mapping), "0 %u 1\n", (unsigned int)uid);
|
||||
if (write_mapping("/proc/self/uid_map", mapping) != 0)
|
||||
return failure("uid_map");
|
||||
if (write_mapping("/proc/self/setgroups", "deny\n") != 0)
|
||||
return failure("setgroups");
|
||||
snprintf(mapping, sizeof(mapping), "0 %u 1\n", (unsigned int)gid);
|
||||
if (write_mapping("/proc/self/gid_map", mapping) != 0)
|
||||
return failure("gid_map");
|
||||
if (unshare(CLONE_NEWNET) != 0)
|
||||
return failure("unshare_net");
|
||||
puts("UNSAFE_ALLOWED|network_namespace|CAP_SYS_ADMIN_check_passed");
|
||||
fd = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0);
|
||||
if (fd < 0) {
|
||||
unavailable("network_socket");
|
||||
return 1;
|
||||
}
|
||||
struct ifreq interface = {0};
|
||||
memcpy(interface.ifr_name, "lo", 3);
|
||||
if (ioctl(fd, SIOCGIFFLAGS, &interface) != 0) {
|
||||
cause = errno;
|
||||
close(fd);
|
||||
errno = cause;
|
||||
unavailable("read_loopback_flags");
|
||||
return 1;
|
||||
}
|
||||
interface.ifr_flags |= IFF_UP;
|
||||
int result = ioctl(fd, SIOCSIFFLAGS, &interface);
|
||||
cause = errno;
|
||||
close(fd);
|
||||
if (result != 0) {
|
||||
errno = cause;
|
||||
failure("raise_loopback");
|
||||
return 1;
|
||||
}
|
||||
puts("UNSAFE_ALLOWED|userns_and_net_admin_in_single_process");
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
//! Real process tests are supplied with explicit author-owned sandbox assets.
|
||||
|
||||
#[path = "../dever-tests/tests/support/process.rs"]
|
||||
pub mod process;
|
||||
#[path = "../dever-tests/tests/support/temp.rs"]
|
||||
mod temp;
|
||||
|
||||
use dever_sandbox::{Capabilities, Grant, Launch};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn bootstrap_modes_include_the_loader_and_only_declared_executables() {
|
||||
assert!(dever_sandbox::asset_executable("bin/bwrap"));
|
||||
assert!(dever_sandbox::asset_executable("bin/guard"));
|
||||
assert!(dever_sandbox::asset_executable("lib/ld-linux-x86-64.so.2"));
|
||||
assert!(dever_sandbox::asset_executable("lib/ld-linux-aarch64.so.1"));
|
||||
assert!(!dever_sandbox::asset_executable("lib/libc.so.6"));
|
||||
assert!(!dever_sandbox::asset_executable(
|
||||
"other/ld-linux-x86-64.so.2"
|
||||
));
|
||||
assert!(!dever_sandbox::asset_executable(
|
||||
"other/ld-linux-aarch64.so.1"
|
||||
));
|
||||
assert!(!dever_sandbox::asset_executable("lib/ld-linux-armhf.so.3"));
|
||||
assert!(!dever_sandbox::asset_executable(
|
||||
"lib/ld-linux-aarch64.so.1.backup"
|
||||
));
|
||||
assert!(!dever_sandbox::asset_executable("bin/unverified"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_paths_reject_relative_inputs_before_starting_a_process() {
|
||||
use std::path::Path;
|
||||
let launch = dever_sandbox::Launch {
|
||||
assets: Path::new("relative"),
|
||||
worker: Path::new("/missing"),
|
||||
executable: Path::new("/missing/probe"),
|
||||
arguments: &[],
|
||||
working_directory: Path::new("/missing"),
|
||||
capabilities: dever_sandbox::Capabilities::default(),
|
||||
grants: &[],
|
||||
};
|
||||
assert!(
|
||||
launch
|
||||
.command()
|
||||
.unwrap_err()
|
||||
.contains("absolute and normalized")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
struct Fixture {
|
||||
directory: temp::TemporaryDirectory,
|
||||
assets: PathBuf,
|
||||
worker: PathBuf,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
struct SandboxProcess(std::process::Child);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Drop for SandboxProcess {
|
||||
fn drop(&mut self) {
|
||||
// A failing assertion must not leave the owned namespace running.
|
||||
let _ = process::terminate(&mut self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let directory = temp::TemporaryDirectory::new();
|
||||
let assets = directory.path().join("assets");
|
||||
let worker = directory.path().join("worker");
|
||||
let prepared =
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/sandbox-inputs/assets");
|
||||
fs::create_dir_all(assets.join("bin")).unwrap();
|
||||
fs::create_dir_all(assets.join("lib")).unwrap();
|
||||
fs::create_dir(&worker).unwrap();
|
||||
fs::copy(prepared.join("bin/bwrap"), assets.join("bin/bwrap"))
|
||||
.expect("prepare explicit target/sandbox-inputs first");
|
||||
fs::copy(
|
||||
env!("CARGO_BIN_EXE_dever-sandbox-guard"),
|
||||
assets.join("bin/guard"),
|
||||
)
|
||||
.unwrap();
|
||||
for entry in fs::read_dir(prepared.join("lib")).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
assert!(entry.file_type().unwrap().is_file());
|
||||
fs::copy(entry.path(), assets.join("lib").join(entry.file_name())).unwrap();
|
||||
}
|
||||
fs::copy(std::env::current_exe().unwrap(), worker.join("probe")).unwrap();
|
||||
// The unprivileged case can traverse only these explicitly owned trees.
|
||||
fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o755)).unwrap();
|
||||
Self {
|
||||
directory,
|
||||
assets,
|
||||
worker,
|
||||
}
|
||||
}
|
||||
|
||||
fn command(&self, probe: &str, capabilities: Capabilities, grants: &[Grant]) -> Command {
|
||||
Launch {
|
||||
assets: &self.assets,
|
||||
worker: &self.worker,
|
||||
executable: &self.worker.join("probe"),
|
||||
arguments: &[
|
||||
"--ignored".into(),
|
||||
"--exact".into(),
|
||||
probe.into(),
|
||||
"--nocapture".into(),
|
||||
],
|
||||
working_directory: &self.worker,
|
||||
capabilities,
|
||||
grants,
|
||||
}
|
||||
.command()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn assert_success(&self, command: &mut Command) {
|
||||
let stdout = self.directory.path().join("stdout");
|
||||
let stderr = self.directory.path().join("stderr");
|
||||
command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(fs::File::create(&stdout).unwrap())
|
||||
.stderr(fs::File::create(&stderr).unwrap());
|
||||
let result = process::status(command, Duration::from_secs(10));
|
||||
assert!(
|
||||
result.as_ref().is_ok_and(|status| status.success()),
|
||||
"sandbox failed: {result:?}\nstdout: {}\nstderr: {}",
|
||||
fs::read_to_string(stdout).unwrap(),
|
||||
fs::read_to_string(stderr).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "requires explicitly prepared target/sandbox-inputs assets; owns only test processes"]
|
||||
fn enforces_files_network_process_and_threads() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.assert_success(&mut fixture.command("restricted_probe", Capabilities::default(), &[]));
|
||||
let output = fixture.directory.path().join("output");
|
||||
fs::create_dir(&output).unwrap();
|
||||
fixture.assert_success(&mut fixture.command(
|
||||
"granted_probe",
|
||||
Capabilities {
|
||||
network: true,
|
||||
process: true,
|
||||
},
|
||||
&[Grant {
|
||||
source: output.clone(),
|
||||
destination: "/data/output".into(),
|
||||
writable: true,
|
||||
}],
|
||||
));
|
||||
assert_eq!(fs::read(output.join("result")).unwrap(), b"granted");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "requires explicitly prepared sandbox assets; mutates only owned fixture paths"]
|
||||
fn pins_grants_against_directory_replacement() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let fixture = Fixture::new();
|
||||
let granted = fixture.directory.path().join("granted");
|
||||
let outside = fixture.directory.path().join("outside");
|
||||
fs::create_dir(&granted).unwrap();
|
||||
fs::create_dir(&outside).unwrap();
|
||||
fs::write(granted.join("value"), "authorized").unwrap();
|
||||
fs::write(outside.join("value"), "outside").unwrap();
|
||||
let mut command = fixture.command(
|
||||
"pinned_grant_probe",
|
||||
Capabilities::default(),
|
||||
&[Grant {
|
||||
source: granted.clone(),
|
||||
destination: "/data/input".into(),
|
||||
writable: false,
|
||||
}],
|
||||
);
|
||||
fs::rename(&granted, fixture.directory.path().join("moved")).unwrap();
|
||||
symlink(&outside, &granted).unwrap();
|
||||
fixture.assert_success(&mut command);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "internal sandbox descriptor-pinning probe"]
|
||||
fn pinned_grant_probe() {
|
||||
assert_eq!(
|
||||
fs::read_to_string("/data/input/value").unwrap(),
|
||||
"authorized"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "requires explicitly prepared sandbox assets"]
|
||||
fn rejects_writable_aliases_of_executable_inputs() {
|
||||
let fixture = Fixture::new();
|
||||
for source in [
|
||||
&fixture.assets,
|
||||
&fixture.worker,
|
||||
&fixture.directory.path().to_owned(),
|
||||
] {
|
||||
let error = Launch {
|
||||
assets: &fixture.assets,
|
||||
worker: &fixture.worker,
|
||||
executable: &fixture.worker.join("probe"),
|
||||
arguments: &[],
|
||||
working_directory: &fixture.worker,
|
||||
capabilities: Capabilities::default(),
|
||||
grants: &[Grant {
|
||||
source: source.to_owned(),
|
||||
destination: "/data/alias".into(),
|
||||
writable: true,
|
||||
}],
|
||||
}
|
||||
.command()
|
||||
.unwrap_err();
|
||||
assert!(error.contains("overlaps executable inputs"), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "requires explicitly prepared signed-release Python/Node inputs and sandbox assets"]
|
||||
fn prepared_python_and_node_run_without_host_files_or_process_creation() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let fixture = Fixture::new();
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../target/ecosystem-release-inputs/prepared")
|
||||
.canonicalize()
|
||||
.unwrap();
|
||||
let python = "import ctypes, hashlib, json, math, os, pathlib, sqlite3, ssl, sys, threading, zlib; assert all(not p or pathlib.Path(p).is_relative_to('/worker') for p in sys.path); assert not os.path.exists('/usr/bin/python3'); assert json.loads(zlib.decompress(zlib.compress(b'{\"value\":42}')))['value'] == 42; t = threading.Thread(target=lambda: None); t.start(); t.join(); print('python okay')";
|
||||
let javascript = "const assert = require('node:assert/strict'); const fs = require('node:fs'); const crypto = require('node:crypto'); const zlib = require('node:zlib'); assert.equal(fs.existsSync('/usr/bin/node'), false); assert.equal(zlib.gunzipSync(zlib.gzipSync('42')).toString(), '42'); assert.equal(crypto.createHash('sha256').update('probe').digest().length, 32); console.log('node okay');";
|
||||
for (ecosystem, executable, args) in [
|
||||
(
|
||||
"pip",
|
||||
"bin/python3.12",
|
||||
vec!["-I", "-S", "-B", "-c", python],
|
||||
),
|
||||
("npm", "bin/node", vec!["-e", javascript]),
|
||||
] {
|
||||
let source = root.join(ecosystem);
|
||||
let worker = fixture.directory.path().join(ecosystem);
|
||||
let mut pending = vec![PathBuf::new()];
|
||||
while let Some(relative) = pending.pop() {
|
||||
fs::create_dir_all(worker.join(&relative)).unwrap();
|
||||
for entry in fs::read_dir(source.join(&relative)).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let path = relative.join(entry.file_name());
|
||||
let kind = entry.file_type().unwrap();
|
||||
if kind.is_dir() {
|
||||
pending.push(path);
|
||||
} else {
|
||||
assert!(kind.is_file(), "prepared inputs must not contain links");
|
||||
if path == Path::new(executable) {
|
||||
fs::copy(entry.path(), worker.join(&path)).unwrap();
|
||||
fs::set_permissions(worker.join(path), fs::Permissions::from_mode(0o700))
|
||||
.unwrap();
|
||||
} else {
|
||||
fs::hard_link(entry.path(), worker.join(path)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let arguments = args
|
||||
.into_iter()
|
||||
.map(std::ffi::OsString::from)
|
||||
.collect::<Vec<_>>();
|
||||
let mut command = Launch {
|
||||
assets: &fixture.assets,
|
||||
worker: &worker,
|
||||
executable: &worker.join(executable),
|
||||
arguments: &arguments,
|
||||
working_directory: &worker,
|
||||
capabilities: Capabilities::default(),
|
||||
grants: &[],
|
||||
}
|
||||
.command()
|
||||
.unwrap();
|
||||
fixture.assert_success(&mut command);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "requires explicitly prepared assets and host permission for unprivileged user namespaces"]
|
||||
fn enforces_unprivileged_namespace() {
|
||||
if rustix::process::geteuid().as_raw() != 0 {
|
||||
unprivileged_launch();
|
||||
return;
|
||||
}
|
||||
// Select the product helper after the real UID transition. Changing only
|
||||
// Command.uid after root constructed Launch tests a different call flow.
|
||||
let mut command = Command::new("/usr/bin/setpriv");
|
||||
command
|
||||
.env_clear()
|
||||
.args([
|
||||
"--reuid=65534",
|
||||
"--regid=65534",
|
||||
"--clear-groups",
|
||||
"--inh-caps=-all",
|
||||
"--ambient-caps=-all",
|
||||
"--",
|
||||
])
|
||||
.arg(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--ignored",
|
||||
"--exact",
|
||||
"unprivileged_launcher_probe",
|
||||
"--nocapture",
|
||||
])
|
||||
.stdin(Stdio::null());
|
||||
assert!(
|
||||
process::status(&mut command, Duration::from_secs(20))
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn unprivileged_launch() {
|
||||
assert_ne!(rustix::process::geteuid().as_raw(), 0);
|
||||
let fixture = Fixture::new();
|
||||
let mut command = fixture.command("restricted_probe", Capabilities::default(), &[]);
|
||||
fixture.assert_success(&mut command);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "internal non-root launcher probe, invoked by enforces_unprivileged_namespace"]
|
||||
fn unprivileged_launcher_probe() {
|
||||
assert!(rustix::process::getgroups().unwrap().is_empty());
|
||||
unprivileged_launch();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "internal sandbox probe, invoked only inside an owned namespace"]
|
||||
fn restricted_probe() {
|
||||
assert_eq!(std::env::current_dir().unwrap(), Path::new("/worker"));
|
||||
assert!(std::env::vars_os().next().is_none());
|
||||
assert!(fs::read("/etc/passwd").is_err());
|
||||
assert!(fs::read("/proc/1/root/etc/passwd").is_err());
|
||||
assert!(fs::write("/worker/forbidden", b"no").is_err());
|
||||
assert!(std::net::TcpListener::bind("127.0.0.1:0").is_err());
|
||||
assert!(std::os::unix::net::UnixListener::bind("/tmp/denied.sock").is_err());
|
||||
assert!(
|
||||
Command::new("/worker/probe")
|
||||
.arg("--list")
|
||||
.status()
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(std::thread::spawn(|| 42).join().unwrap(), 42);
|
||||
fs::write("/tmp/private", b"temporary").unwrap();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "requires explicitly prepared assets; kills only this test's namespace"]
|
||||
fn terminates_detached_descendants_on_kill() {
|
||||
let fixture = Fixture::new();
|
||||
let output = fixture.directory.path().join("output");
|
||||
fs::create_dir(&output).unwrap();
|
||||
let mut command = fixture.command(
|
||||
"tree_probe",
|
||||
Capabilities {
|
||||
network: false,
|
||||
process: true,
|
||||
},
|
||||
&[Grant {
|
||||
source: output.clone(),
|
||||
destination: "/data/output".into(),
|
||||
writable: true,
|
||||
}],
|
||||
);
|
||||
command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
let mut child = SandboxProcess(command.spawn().unwrap());
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while !output.join("child-ready").is_file() {
|
||||
if std::time::Instant::now() >= deadline || child.0.try_wait().unwrap().is_some() {
|
||||
panic!("sandbox descendant did not start");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
let mut descendants = Vec::new();
|
||||
let mut pending = vec![child.0.id()];
|
||||
while let Some(pid) = pending.pop() {
|
||||
let children =
|
||||
fs::read_to_string(format!("/proc/{pid}/task/{pid}/children")).unwrap_or_default();
|
||||
for child in children
|
||||
.split_whitespace()
|
||||
.map(|value| value.parse::<u32>().unwrap())
|
||||
{
|
||||
descendants.push(child);
|
||||
pending.push(child);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
descendants.len() >= 2,
|
||||
"the probe must own a real descendant tree"
|
||||
);
|
||||
process::terminate(&mut child.0).unwrap();
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(3);
|
||||
loop {
|
||||
let running = descendants
|
||||
.iter()
|
||||
.filter(|pid| {
|
||||
fs::read_to_string(format!("/proc/{pid}/stat")).is_ok_and(|stat| {
|
||||
!stat.rsplit_once(") ").is_some_and(|(_, fields)| {
|
||||
fields.starts_with("Z ") || fields.starts_with("X ")
|
||||
})
|
||||
})
|
||||
})
|
||||
.count();
|
||||
if running == 0 {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"{running} owned sandbox descendants survived termination"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "internal sandbox process-tree probe"]
|
||||
fn tree_probe() {
|
||||
use std::os::unix::process::CommandExt;
|
||||
let mut child = Command::new("/worker/probe")
|
||||
.args(["--ignored", "--exact", "descendant_probe"])
|
||||
.process_group(0)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
assert!(child.wait().unwrap().success());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "internal sandbox descendant probe"]
|
||||
fn descendant_probe() {
|
||||
fs::write("/data/output/child-ready", b"ready").unwrap();
|
||||
std::thread::sleep(Duration::from_secs(30));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "internal sandbox probe, invoked only inside an owned namespace"]
|
||||
fn granted_probe() {
|
||||
use std::net::ToSocketAddrs;
|
||||
assert!(
|
||||
("localhost", 80)
|
||||
.to_socket_addrs()
|
||||
.unwrap()
|
||||
.next()
|
||||
.is_some()
|
||||
);
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
assert_ne!(listener.local_addr().unwrap().port(), 0);
|
||||
assert!(std::os::unix::net::UnixListener::bind("/tmp/denied.sock").is_err());
|
||||
assert!(
|
||||
Command::new("/worker/probe")
|
||||
.arg("--list")
|
||||
.stdout(Stdio::null())
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
fs::write("/data/output/result", b"granted").unwrap();
|
||||
assert!(fs::read("/etc/passwd").is_err());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "requires explicitly prepared target/sandbox-inputs assets"]
|
||||
fn rejects_missing_dependency_before_host_loader_can_supply_it() {
|
||||
let fixture = Fixture::new();
|
||||
fs::remove_file(fixture.assets.join("lib/libc.so.6")).unwrap();
|
||||
let error = Launch {
|
||||
assets: &fixture.assets,
|
||||
worker: &fixture.worker,
|
||||
executable: &fixture.worker.join("probe"),
|
||||
arguments: &[],
|
||||
working_directory: &fixture.worker,
|
||||
capabilities: Capabilities::default(),
|
||||
grants: &[],
|
||||
}
|
||||
.command()
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.contains("sandbox dependency 'lib/libc.so.6' is missing"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "requires explicit assets and the prepared C syscall probe"]
|
||||
fn kernel_rejects_namespace_ptrace_and_truncated_ioctl_requests() {
|
||||
let fixture = Fixture::new();
|
||||
let probe = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/sandbox-inputs/syscalls");
|
||||
let executable = fixture.worker.join("syscalls");
|
||||
fs::copy(probe, &executable).expect("prepare the explicit C syscall probe first");
|
||||
for process in [false, true] {
|
||||
let arguments = if process {
|
||||
vec![]
|
||||
} else {
|
||||
vec!["no-process".into()]
|
||||
};
|
||||
let mut command = Launch {
|
||||
assets: &fixture.assets,
|
||||
worker: &fixture.worker,
|
||||
executable: &executable,
|
||||
arguments: &arguments,
|
||||
working_directory: &fixture.worker,
|
||||
capabilities: Capabilities {
|
||||
network: true,
|
||||
process,
|
||||
},
|
||||
grants: &[],
|
||||
}
|
||||
.command()
|
||||
.unwrap();
|
||||
fixture.assert_success(&mut command);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/* Kernel-level probes for argument truncation and fixed escape denials. */
|
||||
#define _GNU_SOURCE
|
||||
#include <assert.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <linux/sched.h>
|
||||
#include <stdint.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/ptrace.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/xattr.h>
|
||||
#include <unistd.h>
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
(void)argv;
|
||||
/* Spawn may reset only the effective ID to this sandbox's real identity. */
|
||||
const long identity_calls[] = {SYS_setresuid, SYS_setresgid};
|
||||
const uint32_t identities[] = {getuid(), getgid()};
|
||||
for (unsigned i = 0; i < sizeof(identity_calls) / sizeof(identity_calls[0]); i++) {
|
||||
const long call = identity_calls[i];
|
||||
const uint32_t identity = identities[i];
|
||||
errno = 0;
|
||||
const long reset = syscall(call, UINT32_MAX, identity, UINT32_MAX);
|
||||
assert(argc == 1 ? reset == 0 : (reset == -1 && errno == EPERM));
|
||||
if (argc == 1) {
|
||||
assert(syscall(call, UINT64_MAX, UINT64_C(0x100000000) | identity, UINT64_MAX) == 0);
|
||||
}
|
||||
const uint64_t denied[][3] = {
|
||||
{UINT32_MAX, identity ^ 1U, UINT32_MAX},
|
||||
{identity, identity, UINT32_MAX},
|
||||
{UINT32_MAX, identity, identity},
|
||||
};
|
||||
for (unsigned j = 0; j < sizeof(denied) / sizeof(denied[0]); j++) {
|
||||
errno = 0;
|
||||
assert(syscall(call, denied[j][0], denied[j][1], denied[j][2]) == -1 && errno == EPERM);
|
||||
}
|
||||
}
|
||||
/* Python copy2 must be able to inspect metadata of its mounted source. */
|
||||
assert(listxattr("/worker/syscalls", NULL, 0) >= 0);
|
||||
int fd = open("/dev/null", O_RDONLY);
|
||||
assert(fd >= 0);
|
||||
const uint64_t forbidden[] = {TIOCSTI, TIOCLINUX, UINT64_C(0x100000000) | TIOCSTI, UINT64_C(0x100000000) | TIOCLINUX};
|
||||
for (unsigned i = 0; i < sizeof(forbidden) / sizeof(forbidden[0]); i++) {
|
||||
errno = 0;
|
||||
assert(syscall(SYS_ioctl, fd, forbidden[i], 0) == -1 && errno == EPERM);
|
||||
}
|
||||
errno = 0;
|
||||
assert(ioctl(fd, TCGETS, 0) == -1 && errno == ENOTTY);
|
||||
assert(close(fd) == 0);
|
||||
errno = 0;
|
||||
assert(syscall(SYS_unshare, CLONE_NEWUSER) == -1 && errno == EPERM);
|
||||
errno = 0;
|
||||
assert(ptrace(PTRACE_TRACEME, 0, 0, 0) == -1 && errno == EPERM);
|
||||
errno = 0;
|
||||
assert(socket(AF_UNIX, SOCK_STREAM, 0) == -1 && errno == EPERM);
|
||||
errno = 0;
|
||||
assert(syscall(SYS_clone3, 0, 0) == -1 && errno == ENOSYS);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Trust-chain regression checks touch only an explicitly owned target subtree.
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::{PermissionsExt, chown, symlink};
|
||||
|
||||
#[path = "../dever-tests/tests/support/temp.rs"]
|
||||
mod temp;
|
||||
|
||||
#[test]
|
||||
fn restricted_hosts_require_strict_profile_transitions_before_helper_selection() {
|
||||
use std::io::{Error, ErrorKind};
|
||||
for userns in [None, Some("0\n")] {
|
||||
assert!(
|
||||
!host_policy(|path| {
|
||||
assert!(
|
||||
path.ends_with("apparmor_restrict_unprivileged_userns"),
|
||||
"unrestricted hosts must not read the strict flag"
|
||||
);
|
||||
userns
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| Error::from(ErrorKind::NotFound))
|
||||
})
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
for strict in [None, Some("0\n"), Some("1\n")] {
|
||||
let result = host_policy(|path| {
|
||||
if path.ends_with("apparmor_restrict_unprivileged_userns") {
|
||||
return Ok("1\n".into());
|
||||
}
|
||||
assert!(path.ends_with("apparmor_restrict_unprivileged_unconfined"));
|
||||
strict
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| Error::from(ErrorKind::NotFound))
|
||||
});
|
||||
if strict == Some("1\n") {
|
||||
assert!(result.unwrap());
|
||||
} else {
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("kernel.apparmor_restrict_unprivileged_unconfined=1")
|
||||
);
|
||||
}
|
||||
}
|
||||
for failure in [ErrorKind::PermissionDenied, ErrorKind::InvalidData] {
|
||||
assert!(
|
||||
host_policy(|_| Err(Error::from(failure)))
|
||||
.unwrap_err()
|
||||
.contains("cannot inspect")
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
host_policy(|_| Ok("2\n".into()))
|
||||
.unwrap_err()
|
||||
.contains("invalid AppArmor kernel flag")
|
||||
);
|
||||
assert!(
|
||||
host_policy(
|
||||
|path| if path.ends_with("apparmor_restrict_unprivileged_userns") {
|
||||
Ok("1".into())
|
||||
} else {
|
||||
Err(Error::from(ErrorKind::PermissionDenied))
|
||||
}
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("cannot inspect")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires root and the prepared static author bwrap; touches only owned target files"]
|
||||
fn rejects_untrusted_helper_paths_bytes_modes_owners_and_capabilities() {
|
||||
assert_eq!(rustix::process::geteuid().as_raw(), 0);
|
||||
let target = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../target")
|
||||
.canonicalize()
|
||||
.unwrap();
|
||||
let root = temp::TemporaryDirectory::new_in(&target);
|
||||
let bytes = fs::read(target.join("sandbox-inputs/assets/bin/bwrap")).unwrap();
|
||||
let hash = crate::validate_bwrap(&bytes).unwrap();
|
||||
let directory = root.path().join(&hash);
|
||||
fs::create_dir(&directory).unwrap();
|
||||
let helper = directory.join("bwrap");
|
||||
assert!(
|
||||
verify_helper(&helper, &hash)
|
||||
.unwrap_err()
|
||||
.contains("missing")
|
||||
);
|
||||
fs::write(&helper, &bytes).unwrap();
|
||||
fs::set_permissions(&helper, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
verify_helper(&helper, &hash).unwrap();
|
||||
assert!(
|
||||
verify_helper(&helper, &"0".repeat(64))
|
||||
.unwrap_err()
|
||||
.contains("differs")
|
||||
);
|
||||
for mode in [0o775, 0o4755, 0o2755, 0o644] {
|
||||
fs::set_permissions(&helper, fs::Permissions::from_mode(mode)).unwrap();
|
||||
assert!(verify_helper(&helper, &hash).is_err());
|
||||
}
|
||||
fs::set_permissions(&helper, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
chown(&helper, Some(65534), Some(65534)).unwrap();
|
||||
assert!(verify_helper(&helper, &hash).is_err());
|
||||
chown(&helper, Some(0), Some(0)).unwrap();
|
||||
fs::set_permissions(&directory, fs::Permissions::from_mode(0o777)).unwrap();
|
||||
assert!(verify_helper(&helper, &hash).is_err());
|
||||
fs::set_permissions(&directory, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let renamed = root.path().join("retained");
|
||||
fs::rename(&directory, &renamed).unwrap();
|
||||
symlink(&renamed, &directory).unwrap();
|
||||
assert!(verify_helper(&helper, &hash).is_err());
|
||||
fs::remove_file(&directory).unwrap();
|
||||
fs::rename(&renamed, &directory).unwrap();
|
||||
// VFS_CAP_REVISION_2 with only CAP_NET_ADMIN permitted; never execute it.
|
||||
let mut capability = [0u8; 20];
|
||||
capability[..4].copy_from_slice(&0x0200_0001u32.to_le_bytes());
|
||||
capability[4..8].copy_from_slice(&(1u32 << 12).to_le_bytes());
|
||||
rustix::fs::setxattr(
|
||||
&helper,
|
||||
"security.capability",
|
||||
&capability,
|
||||
rustix::fs::XattrFlags::empty(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
verify_helper(&helper, &hash)
|
||||
.unwrap_err()
|
||||
.contains("file capabilities")
|
||||
);
|
||||
rustix::fs::removexattr(&helper, "security.capability").unwrap();
|
||||
verify_helper(&helper, &hash).unwrap();
|
||||
// A sticky /tmp ancestor is acceptable for installer image fixtures, but
|
||||
// cannot be part of the runtime's administrator-controlled attachment path.
|
||||
let shared_ancestor = temp::TemporaryDirectory::new();
|
||||
let shared_helper = shared_ancestor.path().join("bwrap");
|
||||
fs::write(&shared_helper, &bytes).unwrap();
|
||||
fs::set_permissions(&shared_helper, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(verify_helper(&shared_helper, &hash).is_err());
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
[package]
|
||||
name = "dever-tests"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish.workspace = true
|
||||
autotests = false
|
||||
|
||||
[[test]]
|
||||
name = "llvm_lowering"
|
||||
path = "../llvm_lowering.rs"
|
||||
|
||||
[[test]]
|
||||
name = "runtime_entry_cleanup"
|
||||
path = "../runtime_entry_cleanup.rs"
|
||||
|
||||
[[test]]
|
||||
name = "upload_boundary"
|
||||
path = "tests/upload_boundary.rs"
|
||||
required-features = ["api"]
|
||||
|
||||
[[test]]
|
||||
name = "api_declarations"
|
||||
path = "tests/api_declarations.rs"
|
||||
|
||||
[[test]]
|
||||
name = "native_entry"
|
||||
path = "tests/native_entry.rs"
|
||||
|
||||
[[test]]
|
||||
name = "api_context"
|
||||
path = "tests/api_context.rs"
|
||||
|
||||
[[test]]
|
||||
name = "rest_model"
|
||||
path = "tests/rest_model.rs"
|
||||
|
||||
[[test]]
|
||||
name = "durable_jobs"
|
||||
path = "tests/durable_jobs.rs"
|
||||
|
||||
[[test]]
|
||||
name = "port_adapter"
|
||||
path = "tests/port_adapter.rs"
|
||||
|
||||
[[test]]
|
||||
name = "external_component"
|
||||
path = "tests/external_component.rs"
|
||||
required-features = ["external"]
|
||||
|
||||
[[test]]
|
||||
name = "external_resources"
|
||||
path = "tests/external_resources.rs"
|
||||
required-features = ["external"]
|
||||
|
||||
[[bin]]
|
||||
name = "component-fixture"
|
||||
path = "fixtures/component_fixture.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "command-fixture"
|
||||
path = "fixtures/command_fixture.rs"
|
||||
|
||||
[[test]]
|
||||
name = "external_command"
|
||||
path = "tests/external_command.rs"
|
||||
required-features = ["external"]
|
||||
|
||||
[[test]]
|
||||
name = "async_composition"
|
||||
path = "tests/async_composition.rs"
|
||||
|
||||
[[test]]
|
||||
name = "pooled_network"
|
||||
path = "tests/pooled_network.rs"
|
||||
|
||||
[[test]]
|
||||
name = "pooled_library"
|
||||
path = "tests/pooled_library.rs"
|
||||
|
||||
[[test]]
|
||||
name = "markdown_source"
|
||||
path = "tests/markdown_source.rs"
|
||||
|
||||
[[test]]
|
||||
name = "cms_acceptance"
|
||||
path = "tests/cms_acceptance.rs"
|
||||
|
||||
[[test]]
|
||||
name = "native_cache"
|
||||
path = "tests/native_cache.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
bytes.workspace = true
|
||||
dever-core.workspace = true
|
||||
dever-runtime.workspace = true
|
||||
futures-util.workspace = true
|
||||
http-body-util.workspace = true
|
||||
h2 = "=0.4.19"
|
||||
hyper.workspace = true
|
||||
hyper-util.workspace = true
|
||||
rustls.workspace = true
|
||||
sha2.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-rustls.workspace = true
|
||||
tokio-tungstenite.workspace = true
|
||||
|
||||
[[test]]
|
||||
name = "async_network"
|
||||
path = "tests/async_network.rs"
|
||||
|
||||
[[test]]
|
||||
name = "async_stream"
|
||||
path = "tests/async_stream.rs"
|
||||
|
||||
[features]
|
||||
reference = ["dever-core/reference"]
|
||||
sqlite = ["dever-runtime/sqlite"]
|
||||
postgres = ["dever-runtime/postgres"]
|
||||
api = ["dever-runtime/api"]
|
||||
crypto = ["dever-runtime/crypto"]
|
||||
component = ["dever-runtime/wire"]
|
||||
external = ["component", "dever-runtime/external"]
|
||||
|
||||
[[test]]
|
||||
name = "secure_contracts"
|
||||
path = "tests/secure_contracts.rs"
|
||||
required-features = ["crypto"]
|
||||
|
||||
[[test]]
|
||||
name = "contract_syntax"
|
||||
path = "tests/contract_syntax.rs"
|
||||
|
||||
[[test]]
|
||||
name = "contract_api"
|
||||
path = "tests/contract_api.rs"
|
||||
|
||||
[[test]]
|
||||
name = "contract_semantics"
|
||||
path = "tests/contract_semantics.rs"
|
||||
|
||||
[[test]]
|
||||
name = "contract_facts"
|
||||
path = "tests/contract_facts.rs"
|
||||
|
||||
[[test]]
|
||||
name = "contract_redundancy"
|
||||
path = "tests/contract_redundancy.rs"
|
||||
|
||||
[[test]]
|
||||
name = "contract_execution"
|
||||
path = "tests/contract_execution.rs"
|
||||
|
||||
[[test]]
|
||||
name = "error_effects"
|
||||
path = "tests/error_effects.rs"
|
||||
|
||||
[[test]]
|
||||
name = "logging"
|
||||
path = "tests/logging.rs"
|
||||
|
||||
[[test]]
|
||||
name = "model_syntax"
|
||||
path = "tests/model_syntax.rs"
|
||||
|
||||
[[test]]
|
||||
name = "orm_query"
|
||||
path = "tests/orm_query.rs"
|
||||
|
||||
[[test]]
|
||||
name = "orm_relations"
|
||||
path = "tests/orm_relations.rs"
|
||||
required-features = ["sqlite"]
|
||||
|
||||
[[test]]
|
||||
name = "orm_native_sql"
|
||||
path = "tests/orm_native_sql.rs"
|
||||
required-features = ["sqlite"]
|
||||
|
||||
[[test]]
|
||||
name = "orm_performance_fixture"
|
||||
path = "tests/orm_performance_fixture.rs"
|
||||
|
||||
[[test]]
|
||||
name = "performance_port_fixture"
|
||||
path = "tests/performance_port_fixture.rs"
|
||||
|
||||
[[test]]
|
||||
name = "application_config"
|
||||
path = "tests/application_config.rs"
|
||||
|
||||
[[test]]
|
||||
name = "auth_runtime"
|
||||
path = "tests/auth_runtime.rs"
|
||||
required-features = ["api"]
|
||||
|
||||
[[test]]
|
||||
name = "authorization_runtime"
|
||||
path = "tests/authorization_runtime.rs"
|
||||
required-features = ["api", "sqlite"]
|
||||
|
||||
[[test]]
|
||||
name = "authorization_postgres"
|
||||
path = "tests/authorization_postgres.rs"
|
||||
required-features = ["api", "postgres"]
|
||||
|
||||
[[test]]
|
||||
name = "postgres_api"
|
||||
path = "tests/postgres_api.rs"
|
||||
required-features = ["api", "postgres"]
|
||||
|
||||
[[test]]
|
||||
name = "sqlite_runtime"
|
||||
path = "tests/sqlite_runtime.rs"
|
||||
required-features = ["sqlite"]
|
||||
|
||||
[[test]]
|
||||
name = "tenant_database"
|
||||
path = "tests/tenant_database.rs"
|
||||
required-features = ["sqlite"]
|
||||
|
||||
[[test]]
|
||||
name = "sqlite_orm"
|
||||
path = "tests/sqlite_orm.rs"
|
||||
required-features = ["sqlite"]
|
||||
|
||||
[[test]]
|
||||
name = "orm_migration"
|
||||
path = "tests/orm_migration.rs"
|
||||
required-features = ["sqlite"]
|
||||
|
||||
[[test]]
|
||||
name = "orm_data_migration"
|
||||
path = "tests/orm_data_migration.rs"
|
||||
required-features = ["sqlite"]
|
||||
|
||||
[[test]]
|
||||
name = "orm_seed"
|
||||
path = "tests/orm_seed.rs"
|
||||
required-features = ["sqlite"]
|
||||
|
||||
[[test]]
|
||||
name = "orm_connections"
|
||||
path = "tests/orm_connections.rs"
|
||||
required-features = ["sqlite"]
|
||||
|
||||
[[test]]
|
||||
name = "postgres_orm"
|
||||
path = "tests/postgres_orm.rs"
|
||||
required-features = ["postgres"]
|
||||
|
||||
[[test]]
|
||||
name = "differential"
|
||||
path = "tests/differential.rs"
|
||||
required-features = ["reference"]
|
||||
|
||||
[[test]]
|
||||
name = "http_library"
|
||||
path = "tests/http_library.rs"
|
||||
|
||||
[[test]]
|
||||
name = "http_engine"
|
||||
path = "tests/http_engine.rs"
|
||||
|
||||
[[test]]
|
||||
name = "http2_client"
|
||||
path = "tests/http2_client.rs"
|
||||
|
||||
[[test]]
|
||||
name = "http2_server"
|
||||
path = "tests/http2_server.rs"
|
||||
|
||||
[[test]]
|
||||
name = "live_network"
|
||||
path = "tests/live_network.rs"
|
||||
|
||||
[[test]]
|
||||
name = "live_library"
|
||||
path = "tests/live_library.rs"
|
||||
|
||||
[[test]]
|
||||
name = "syntax_and_format"
|
||||
path = "tests/syntax_and_format.rs"
|
||||
|
||||
[[test]]
|
||||
name = "hello_native"
|
||||
path = "tests/hello_native.rs"
|
||||
|
||||
[[test]]
|
||||
name = "core_semantics"
|
||||
path = "tests/core_semantics.rs"
|
||||
|
||||
[[test]]
|
||||
name = "core_native"
|
||||
path = "tests/core_native.rs"
|
||||
|
||||
[[test]]
|
||||
name = "runtime_foundations"
|
||||
path = "tests/runtime_foundations.rs"
|
||||
|
||||
[[test]]
|
||||
name = "formatter"
|
||||
path = "tests/formatter.rs"
|
||||
|
||||
[[test]]
|
||||
name = "json_library"
|
||||
path = "tests/json_library.rs"
|
||||
|
||||
[[test]]
|
||||
name = "standard_library"
|
||||
path = "tests/standard_library.rs"
|
||||
|
||||
[[test]]
|
||||
name = "time_codec"
|
||||
path = "tests/time_codec.rs"
|
||||
required-features = ["api", "reference"]
|
||||
|
||||
[[test]]
|
||||
name = "native_ownership"
|
||||
path = "tests/native_ownership.rs"
|
||||
|
||||
[[test]]
|
||||
name = "concurrency"
|
||||
path = "tests/concurrency.rs"
|
||||
|
||||
[[test]]
|
||||
name = "performance"
|
||||
path = "tests/performance.rs"
|
||||
|
||||
[[test]]
|
||||
name = "async_runtime"
|
||||
path = "tests/async_runtime.rs"
|
||||
|
||||
[[test]]
|
||||
name = "structured_concurrency"
|
||||
path = "tests/structured_concurrency.rs"
|
||||
|
||||
[[test]]
|
||||
name = "async_native"
|
||||
path = "tests/async_native.rs"
|
||||
|
||||
[[test]]
|
||||
name = "source_visibility"
|
||||
path = "tests/source_visibility.rs"
|
||||
|
||||
[[test]]
|
||||
name = "source_architecture"
|
||||
path = "tests/source_architecture.rs"
|
||||
|
||||
[[test]]
|
||||
name = "application_testing"
|
||||
path = "tests/application_testing.rs"
|
||||
|
||||
[[test]]
|
||||
name = "api_routes"
|
||||
path = "tests/api_routes.rs"
|
||||
required-features = ["api"]
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Same structured run/wait workload as the native Dever task fixture.
|
||||
|
||||
use std::io::{self, Write};
|
||||
use std::process::ExitCode;
|
||||
use std::time::Instant;
|
||||
|
||||
use dever_runtime::{bytes::Bytes, number, task};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("async_bench: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let arguments: Vec<_> = std::env::args().skip(1).collect();
|
||||
let [mode, workers, capacity, iterations] = arguments.as_slice() else {
|
||||
return Err("usage: async_bench root|worker WORKERS TASK_CAPACITY ITERATIONS".into());
|
||||
};
|
||||
let config = task::RuntimeConfig {
|
||||
worker_threads: bounded(workers, 64)?,
|
||||
max_blocking_threads: 2,
|
||||
task_capacity: bounded(capacity, task::MAX_TASKS)?,
|
||||
};
|
||||
let iterations = bounded(iterations, 1_048_576)?;
|
||||
match mode.as_str() {
|
||||
"root" => task::run_entry_with(config, batches(iterations)),
|
||||
// One outer structured task moves the batch onto a Tokio worker. It is
|
||||
// outside each timed sample and uses the unchanged production runtime.
|
||||
"worker" => task::run_entry_with(config, async move {
|
||||
let job = task::run(batches(iterations)).await?;
|
||||
task::wait(job).await
|
||||
}),
|
||||
_ => Err("mode must be root or worker".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn bounded(value: &str, maximum: usize) -> Result<usize, String> {
|
||||
let number = value.parse::<usize>().map_err(|error| error.to_string())?;
|
||||
if !(1..=maximum).contains(&number) {
|
||||
return Err(format!("value must be between 1 and {maximum}"));
|
||||
}
|
||||
Ok(number)
|
||||
}
|
||||
|
||||
async fn identity(value: i64) -> Result<i64, String> {
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
async fn task_sum(value: i64, state: i64) -> Result<i64, String> {
|
||||
let job = task::run(identity(value)).await?;
|
||||
number::int_add(state, task::wait(job).await?).map_err(str::to_owned)
|
||||
}
|
||||
|
||||
fn print_line(line: &str) -> Result<(), String> {
|
||||
let mut output = io::stdout().lock();
|
||||
writeln!(output, "{line}").map_err(|error| error.to_string())?;
|
||||
output.flush().map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
async fn batches(iterations: usize) -> Result<(), String> {
|
||||
let input = Bytes::new(vec![b'x'; iterations]);
|
||||
task::blocking(|| print_line("READY|0")).await?;
|
||||
for _ in 0..10 {
|
||||
let begin = Instant::now();
|
||||
let mut checksum = 0;
|
||||
for value in input.values() {
|
||||
checksum = task_sum(i64::from(*value), checksum).await?;
|
||||
}
|
||||
let elapsed = begin.elapsed().as_nanos();
|
||||
// As in the source fixture, all output and blocking dispatch are outside
|
||||
// the clock window. The caller independently verifies the checksum.
|
||||
task::blocking(move || {
|
||||
print_line(&format!("SAMPLE|tasks|{iterations}|{elapsed}|{checksum}"))
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#[path = "live_bench/config.rs"]
|
||||
mod config;
|
||||
#[path = "live_bench/protocol.rs"]
|
||||
mod protocol;
|
||||
#[path = "live_bench/run.rs"]
|
||||
mod run;
|
||||
#[path = "support/benchmark_settings.rs"]
|
||||
mod settings;
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match execute() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("live_bench: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute() -> Result<(), String> {
|
||||
let arguments: Vec<_> = std::env::args().skip(1).collect();
|
||||
if arguments == ["check-config"] {
|
||||
return settings::Settings::check();
|
||||
}
|
||||
let arguments = config::Arguments::parse(arguments)?;
|
||||
run::run(arguments)
|
||||
}
|
||||
|
||||
pub(crate) fn usage() -> String {
|
||||
"usage: live_bench PROTOCOL URL CONNECTIONS CYCLES HOLD_MS TIMEOUT_MS\n live_bench check-config".into()
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use hyper::Uri;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum Protocol {
|
||||
Tcp,
|
||||
WebSocket,
|
||||
Sse,
|
||||
}
|
||||
|
||||
impl Protocol {
|
||||
fn parse(value: &str) -> Result<Self, String> {
|
||||
match value {
|
||||
"tcp" => Ok(Self::Tcp),
|
||||
"ws" => Ok(Self::WebSocket),
|
||||
"sse" => Ok(Self::Sse),
|
||||
_ => Err("PROTOCOL must be tcp, ws, or sse".into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Tcp => "tcp",
|
||||
Self::WebSocket => "ws",
|
||||
Self::Sse => "sse",
|
||||
}
|
||||
}
|
||||
|
||||
fn scheme(self) -> &'static str {
|
||||
match self {
|
||||
Self::Tcp => "tcp",
|
||||
Self::WebSocket => "ws",
|
||||
Self::Sse => "http",
|
||||
}
|
||||
}
|
||||
|
||||
fn path(self) -> &'static str {
|
||||
match self {
|
||||
Self::Tcp => "/",
|
||||
Self::WebSocket => "/ws",
|
||||
Self::Sse => "/events",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Target {
|
||||
pub port: u16,
|
||||
pub path: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
impl Target {
|
||||
fn parse(protocol: Protocol, value: &str) -> Result<Self, String> {
|
||||
let uri = value
|
||||
.parse::<Uri>()
|
||||
.map_err(|error| format!("URL is invalid: {error}"))?;
|
||||
if uri.scheme_str() != Some(protocol.scheme()) {
|
||||
return Err(format!(
|
||||
"{} URL must use the {} scheme",
|
||||
protocol.name(),
|
||||
protocol.scheme()
|
||||
));
|
||||
}
|
||||
if uri.host() != Some("127.0.0.1") {
|
||||
return Err("live benchmark URL must use 127.0.0.1".into());
|
||||
}
|
||||
let port = uri.port_u16().ok_or("URL must include an explicit port")?;
|
||||
if uri.path() != protocol.path() || uri.query().is_some() {
|
||||
return Err(format!(
|
||||
"{} URL must use the {} path without a query",
|
||||
protocol.name(),
|
||||
protocol.path()
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
port,
|
||||
path: uri.path().to_owned(),
|
||||
url: value.to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Arguments {
|
||||
pub protocol: Protocol,
|
||||
pub target: Target,
|
||||
pub connections: usize,
|
||||
pub cycles: usize,
|
||||
pub hold: Duration,
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
impl Arguments {
|
||||
pub fn parse(values: Vec<String>) -> Result<Self, String> {
|
||||
let [protocol, url, connections, cycles, hold_ms, timeout_ms] = values.as_slice() else {
|
||||
return Err(crate::usage());
|
||||
};
|
||||
let protocol = Protocol::parse(protocol)?;
|
||||
let target = Target::parse(protocol, url)?;
|
||||
let connections = number(connections, "CONNECTIONS", 1, 4_096)? as usize;
|
||||
let cycles = number(cycles, "CYCLES", 1, 100)? as usize;
|
||||
let hold_ms = number(hold_ms, "HOLD_MS", 300, 60_000)?;
|
||||
let timeout_ms = number(timeout_ms, "TIMEOUT_MS", 1, 300_000)?;
|
||||
Ok(Self {
|
||||
protocol,
|
||||
target,
|
||||
connections,
|
||||
cycles,
|
||||
hold: Duration::from_millis(hold_ms),
|
||||
timeout: Duration::from_millis(timeout_ms),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn number(value: &str, name: &str, minimum: u64, maximum: u64) -> Result<u64, String> {
|
||||
let value = value
|
||||
.parse::<u64>()
|
||||
.map_err(|_| format!("{name} must be an integer"))?;
|
||||
if !(minimum..=maximum).contains(&value) {
|
||||
return Err(format!("{name} must be between {minimum} and {maximum}"));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use http_body_util::{BodyExt, Empty};
|
||||
use hyper::body::Incoming;
|
||||
use hyper::client::conn::http1;
|
||||
use hyper::header::{CONTENT_TYPE, HOST};
|
||||
use hyper::{Request, StatusCode};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::{Instant, timeout, timeout_at};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::{WebSocketStream, client_async};
|
||||
|
||||
use crate::config::{Protocol, Target};
|
||||
|
||||
const INTERACTION_INTERVAL: Duration = Duration::from_millis(100);
|
||||
const MAX_SSE_BUFFER: usize = 8_192;
|
||||
const SSE_EVENT: &[u8] = b"event: live\nid: 1\ndata: ready\n\n";
|
||||
const SSE_HEARTBEAT: &[u8] = b":\n\n";
|
||||
|
||||
pub struct Session {
|
||||
connection: Connection,
|
||||
cycle: usize,
|
||||
index: usize,
|
||||
timeout: Duration,
|
||||
messages: u64,
|
||||
}
|
||||
|
||||
enum Connection {
|
||||
Tcp(TcpStream),
|
||||
WebSocket(Box<WebSocketStream<TcpStream>>),
|
||||
Sse(SseConnection),
|
||||
}
|
||||
|
||||
struct SseConnection {
|
||||
body: Incoming,
|
||||
driver: ConnectionDriver,
|
||||
pending: Vec<u8>,
|
||||
}
|
||||
|
||||
struct ConnectionDriver(Option<JoinHandle<Result<(), hyper::Error>>>);
|
||||
|
||||
impl ConnectionDriver {
|
||||
fn new(driver: JoinHandle<Result<(), hyper::Error>>) -> Self {
|
||||
Self(Some(driver))
|
||||
}
|
||||
|
||||
async fn close(&mut self) {
|
||||
if let Some(driver) = self.0.take() {
|
||||
driver.abort();
|
||||
let _ = driver.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ConnectionDriver {
|
||||
fn drop(&mut self) {
|
||||
if let Some(driver) = &self.0 {
|
||||
driver.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub async fn connect(
|
||||
protocol: Protocol,
|
||||
target: &Target,
|
||||
cycle: usize,
|
||||
index: usize,
|
||||
operation_timeout: Duration,
|
||||
) -> Result<Self, String> {
|
||||
let stream = timeout(
|
||||
operation_timeout,
|
||||
TcpStream::connect(("127.0.0.1", target.port)),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "connection timed out".to_owned())?
|
||||
.map_err(|error| error.to_string())?;
|
||||
stream
|
||||
.set_nodelay(true)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let connection = match protocol {
|
||||
Protocol::Tcp => Connection::Tcp(stream),
|
||||
Protocol::WebSocket => {
|
||||
let (socket, response) =
|
||||
timeout(operation_timeout, client_async(target.url.as_str(), stream))
|
||||
.await
|
||||
.map_err(|_| "WebSocket handshake timed out".to_owned())?
|
||||
.map_err(|error| error.to_string())?;
|
||||
if response.status() != StatusCode::SWITCHING_PROTOCOLS {
|
||||
return Err(format!(
|
||||
"WebSocket handshake returned {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
Connection::WebSocket(Box::new(socket))
|
||||
}
|
||||
Protocol::Sse => {
|
||||
Connection::Sse(SseConnection::open(stream, target, operation_timeout).await?)
|
||||
}
|
||||
};
|
||||
let mut session = Self {
|
||||
connection,
|
||||
cycle,
|
||||
index,
|
||||
timeout: operation_timeout,
|
||||
messages: 0,
|
||||
};
|
||||
session.exchange(0).await?;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
pub async fn hold(&mut self, duration: Duration) -> Result<(), String> {
|
||||
let deadline = Instant::now() + duration;
|
||||
let mut sequence = 1_u64;
|
||||
while Instant::now() < deadline {
|
||||
if let Connection::Sse(connection) = &mut self.connection {
|
||||
let heartbeats = connection.read_heartbeats(deadline).await?;
|
||||
if heartbeats == 0 {
|
||||
return Err("SSE hold ended without a heartbeat".into());
|
||||
}
|
||||
self.messages += heartbeats;
|
||||
break;
|
||||
}
|
||||
self.exchange(sequence).await?;
|
||||
sequence += 1;
|
||||
let next = (Instant::now() + INTERACTION_INTERVAL).min(deadline);
|
||||
tokio::time::sleep_until(next).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn close(mut self) -> Result<u64, String> {
|
||||
match &mut self.connection {
|
||||
Connection::Tcp(_) => {}
|
||||
Connection::WebSocket(socket) => timeout(self.timeout, socket.as_mut().close(None))
|
||||
.await
|
||||
.map_err(|_| "WebSocket close timed out".to_owned())?
|
||||
.map_err(|error| error.to_string())?,
|
||||
Connection::Sse(connection) => connection.close().await,
|
||||
}
|
||||
Ok(self.messages)
|
||||
}
|
||||
|
||||
async fn exchange(&mut self, sequence: u64) -> Result<(), String> {
|
||||
match &mut self.connection {
|
||||
Connection::Tcp(stream) => {
|
||||
let payload = payload(self.cycle, self.index, sequence);
|
||||
timeout(self.timeout, write_all(stream, &payload))
|
||||
.await
|
||||
.map_err(|_| "TCP write timed out".to_owned())??;
|
||||
let mut received = vec![0; payload.len()];
|
||||
timeout(self.timeout, read_exact(stream, &mut received))
|
||||
.await
|
||||
.map_err(|_| "TCP read timed out".to_owned())??;
|
||||
if received != payload {
|
||||
return Err("TCP echo body mismatch".into());
|
||||
}
|
||||
}
|
||||
Connection::WebSocket(socket) => {
|
||||
let payload = String::from_utf8(payload(self.cycle, self.index, sequence))
|
||||
.expect("benchmark payload is ASCII");
|
||||
timeout(
|
||||
self.timeout,
|
||||
socket.send(Message::Text(payload.clone().into())),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "WebSocket write timed out".to_owned())?
|
||||
.map_err(|error| error.to_string())?;
|
||||
let message = timeout(self.timeout, socket.next())
|
||||
.await
|
||||
.map_err(|_| "WebSocket read timed out".to_owned())?
|
||||
.ok_or("WebSocket ended before echo")?
|
||||
.map_err(|error| error.to_string())?;
|
||||
if !matches!(message, Message::Text(value) if value.as_str() == payload) {
|
||||
return Err("WebSocket echo body mismatch".into());
|
||||
}
|
||||
}
|
||||
Connection::Sse(connection) => connection.read_event(self.timeout).await?,
|
||||
}
|
||||
self.messages += 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl SseConnection {
|
||||
async fn open(
|
||||
stream: TcpStream,
|
||||
target: &Target,
|
||||
operation_timeout: Duration,
|
||||
) -> Result<Self, String> {
|
||||
let (mut sender, connection) =
|
||||
timeout(operation_timeout, http1::handshake(TokioIo::new(stream)))
|
||||
.await
|
||||
.map_err(|_| "SSE HTTP handshake timed out".to_owned())?
|
||||
.map_err(|error| error.to_string())?;
|
||||
let driver = ConnectionDriver::new(tokio::spawn(connection));
|
||||
let request = Request::builder()
|
||||
.method("GET")
|
||||
.uri(&target.path)
|
||||
.header(HOST, format!("127.0.0.1:{}", target.port))
|
||||
.body(Empty::<Bytes>::new())
|
||||
.map_err(|error| error.to_string())?;
|
||||
let response = timeout(operation_timeout, sender.send_request(request))
|
||||
.await
|
||||
.map_err(|_| "SSE request timed out".to_owned())?
|
||||
.map_err(|error| error.to_string())?;
|
||||
if response.status() != StatusCode::OK {
|
||||
return Err(format!("SSE request returned {}", response.status()));
|
||||
}
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
if content_type.split(';').next().map(str::trim) != Some("text/event-stream") {
|
||||
return Err("SSE response has the wrong Content-Type".into());
|
||||
}
|
||||
Ok(Self {
|
||||
body: response.into_body(),
|
||||
driver,
|
||||
pending: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_event(&mut self, operation_timeout: Duration) -> Result<(), String> {
|
||||
while self.pending.len() < SSE_EVENT.len() {
|
||||
let frame = timeout(operation_timeout, self.body.frame())
|
||||
.await
|
||||
.map_err(|_| "SSE event timed out".to_owned())?
|
||||
.ok_or("SSE body ended before the initial event")?
|
||||
.map_err(|error| error.to_string())?;
|
||||
if let Ok(bytes) = frame.into_data() {
|
||||
self.extend(bytes.as_ref())?;
|
||||
}
|
||||
if self.pending.len() < SSE_EVENT.len() && !SSE_EVENT.starts_with(&self.pending) {
|
||||
return Err("SSE initial event body mismatch".into());
|
||||
}
|
||||
}
|
||||
if !self.pending.starts_with(SSE_EVENT) {
|
||||
return Err("SSE initial event body mismatch".into());
|
||||
}
|
||||
self.pending.drain(..SSE_EVENT.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_heartbeats(&mut self, deadline: Instant) -> Result<u64, String> {
|
||||
let mut messages = consume_heartbeats(&mut self.pending)?;
|
||||
loop {
|
||||
let frame = match timeout_at(deadline, self.body.frame()).await {
|
||||
Err(_) => break,
|
||||
Ok(None) => return Err("SSE body ended during hold".into()),
|
||||
Ok(Some(Err(error))) => return Err(error.to_string()),
|
||||
Ok(Some(Ok(frame))) => frame,
|
||||
};
|
||||
if let Ok(bytes) = frame.into_data() {
|
||||
self.extend(bytes.as_ref())?;
|
||||
messages += consume_heartbeats(&mut self.pending)?;
|
||||
}
|
||||
}
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
fn extend(&mut self, bytes: &[u8]) -> Result<(), String> {
|
||||
if self.pending.len().saturating_add(bytes.len()) > MAX_SSE_BUFFER {
|
||||
return Err("SSE validation buffer exceeded its limit".into());
|
||||
}
|
||||
self.pending.extend_from_slice(bytes);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn close(&mut self) {
|
||||
self.driver.close().await;
|
||||
}
|
||||
}
|
||||
|
||||
fn consume_heartbeats(buffer: &mut Vec<u8>) -> Result<u64, String> {
|
||||
let mut messages = 0;
|
||||
while buffer.len() >= SSE_HEARTBEAT.len() {
|
||||
if !buffer.starts_with(SSE_HEARTBEAT) {
|
||||
return Err("SSE hold data was not a heartbeat".into());
|
||||
}
|
||||
buffer.drain(..SSE_HEARTBEAT.len());
|
||||
messages += 1;
|
||||
}
|
||||
if !SSE_HEARTBEAT.starts_with(buffer) {
|
||||
return Err("SSE heartbeat body mismatch".into());
|
||||
}
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
fn payload(cycle: usize, index: usize, sequence: u64) -> Vec<u8> {
|
||||
format!("live:{cycle}:{index}:{sequence}\n").into_bytes()
|
||||
}
|
||||
|
||||
async fn write_all(stream: &TcpStream, bytes: &[u8]) -> Result<(), String> {
|
||||
let mut offset = 0;
|
||||
while offset < bytes.len() {
|
||||
stream.writable().await.map_err(|error| error.to_string())?;
|
||||
match stream.try_write(&bytes[offset..]) {
|
||||
Ok(0) => return Err("TCP write returned zero bytes".into()),
|
||||
Ok(length) => offset += length,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_exact(stream: &TcpStream, bytes: &mut [u8]) -> Result<(), String> {
|
||||
let mut offset = 0;
|
||||
while offset < bytes.len() {
|
||||
stream.readable().await.map_err(|error| error.to_string())?;
|
||||
match stream.try_read(&mut bytes[offset..]) {
|
||||
Ok(0) => return Err("TCP connection ended before the echo completed".into()),
|
||||
Ok(length) => offset += length,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
use std::io::{self, Write};
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::runtime::Builder;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio::task::JoinSet;
|
||||
use tokio::time::{Instant, timeout_at};
|
||||
|
||||
use crate::config::Arguments;
|
||||
use crate::protocol::Session;
|
||||
|
||||
const SETTLE_TIME: Duration = Duration::from_millis(300);
|
||||
|
||||
#[derive(Default)]
|
||||
struct Counters {
|
||||
attempted_connections: u64,
|
||||
connected: u64,
|
||||
disconnected: u64,
|
||||
messages: u64,
|
||||
errors: u64,
|
||||
}
|
||||
|
||||
pub fn run(arguments: Arguments) -> Result<(), String> {
|
||||
let workers = crate::settings::Settings::load()?.workers()?;
|
||||
let runtime = Builder::new_multi_thread()
|
||||
.worker_threads(workers)
|
||||
.max_blocking_threads(1)
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.map_err(|error| format!("cannot start live benchmark runtime: {error}"))?;
|
||||
let started = std::time::Instant::now();
|
||||
let result = runtime.block_on(execute(&arguments));
|
||||
let (counters, failure) = match result {
|
||||
Ok(counters) => (counters, None),
|
||||
Err((counters, error)) => (counters, Some(error)),
|
||||
};
|
||||
println!(
|
||||
"{{\"protocol\":\"{}\",\"connections\":{},\"cycles\":{},\"attempted_connections\":{},\"connected\":{},\"disconnected\":{},\"messages\":{},\"errors\":{},\"elapsed_ms\":{}}}",
|
||||
arguments.protocol.name(),
|
||||
arguments.connections,
|
||||
arguments.cycles,
|
||||
counters.attempted_connections,
|
||||
counters.connected,
|
||||
counters.disconnected,
|
||||
counters.messages,
|
||||
counters.errors,
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
io::stdout().flush().map_err(|error| error.to_string())?;
|
||||
match failure {
|
||||
Some(error) => Err(error),
|
||||
None if counters.errors != 0 => Err("live benchmark recorded connection errors".into()),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute(arguments: &Arguments) -> Result<Counters, (Counters, String)> {
|
||||
let mut counters = Counters::default();
|
||||
for cycle in 0..arguments.cycles {
|
||||
if let Err(error) = execute_cycle(arguments, cycle, &mut counters).await {
|
||||
counters.errors += 1;
|
||||
return Err((counters, error));
|
||||
}
|
||||
}
|
||||
Ok(counters)
|
||||
}
|
||||
|
||||
async fn execute_cycle(
|
||||
arguments: &Arguments,
|
||||
cycle: usize,
|
||||
counters: &mut Counters,
|
||||
) -> Result<(), String> {
|
||||
phase(cycle, "connecting")?;
|
||||
counters.attempted_connections += arguments.connections as u64;
|
||||
|
||||
let (ready_sender, mut ready_receiver) = mpsc::channel(arguments.connections);
|
||||
let (held_sender, mut held_receiver) = mpsc::channel(arguments.connections);
|
||||
let (failure_sender, mut failure_receiver) = mpsc::channel(arguments.connections);
|
||||
let (hold_sender, hold_receiver) = watch::channel(false);
|
||||
let (close_sender, close_receiver) = watch::channel(false);
|
||||
let mut sessions = JoinSet::new();
|
||||
for index in 0..arguments.connections {
|
||||
sessions.spawn(report_session_failure(
|
||||
session_task(
|
||||
arguments.clone(),
|
||||
cycle,
|
||||
index,
|
||||
ready_sender.clone(),
|
||||
held_sender.clone(),
|
||||
hold_receiver.clone(),
|
||||
close_receiver.clone(),
|
||||
),
|
||||
failure_sender.clone(),
|
||||
));
|
||||
}
|
||||
drop(ready_sender);
|
||||
drop(held_sender);
|
||||
drop(failure_sender);
|
||||
|
||||
if let Err(error) = wait_for_phase(
|
||||
&mut ready_receiver,
|
||||
arguments.connections,
|
||||
Instant::now() + arguments.timeout,
|
||||
"connections",
|
||||
&mut failure_receiver,
|
||||
)
|
||||
.await
|
||||
{
|
||||
abort_sessions(&mut sessions).await;
|
||||
return Err(error);
|
||||
}
|
||||
counters.connected += arguments.connections as u64;
|
||||
if let Err(error) = phase(cycle, "connected") {
|
||||
abort_sessions(&mut sessions).await;
|
||||
return Err(error);
|
||||
}
|
||||
if hold_sender.send(true).is_err() {
|
||||
abort_sessions(&mut sessions).await;
|
||||
return Err("connection tasks ended before hold started".into());
|
||||
}
|
||||
|
||||
let hold_deadline = Instant::now() + arguments.hold + arguments.timeout;
|
||||
if let Err(error) = wait_for_phase(
|
||||
&mut held_receiver,
|
||||
arguments.connections,
|
||||
hold_deadline,
|
||||
"held connections",
|
||||
&mut failure_receiver,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = close_sender.send(true);
|
||||
abort_sessions(&mut sessions).await;
|
||||
return Err(error);
|
||||
}
|
||||
if let Err(error) = phase(cycle, "closing") {
|
||||
abort_sessions(&mut sessions).await;
|
||||
return Err(error);
|
||||
}
|
||||
if close_sender.send(true).is_err() {
|
||||
abort_sessions(&mut sessions).await;
|
||||
return Err("connection tasks ended before close started".into());
|
||||
}
|
||||
|
||||
let close_deadline = Instant::now() + arguments.timeout;
|
||||
while !sessions.is_empty() {
|
||||
let result = match timeout_at(close_deadline, sessions.join_next()).await {
|
||||
Err(_) => {
|
||||
abort_sessions(&mut sessions).await;
|
||||
return Err("connection shutdown timed out".into());
|
||||
}
|
||||
Ok(None) => {
|
||||
abort_sessions(&mut sessions).await;
|
||||
return Err("connection task set ended unexpectedly".into());
|
||||
}
|
||||
Ok(Some(Err(error))) => {
|
||||
abort_sessions(&mut sessions).await;
|
||||
return Err(format!("connection task failed: {error}"));
|
||||
}
|
||||
Ok(Some(Ok(result))) => result,
|
||||
};
|
||||
match result {
|
||||
Ok(messages) => {
|
||||
counters.disconnected += 1;
|
||||
counters.messages += messages;
|
||||
}
|
||||
Err(error) => {
|
||||
abort_sessions(&mut sessions).await;
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
phase(cycle, "disconnected")?;
|
||||
tokio::time::sleep(SETTLE_TIME).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn session_task(
|
||||
arguments: Arguments,
|
||||
cycle: usize,
|
||||
index: usize,
|
||||
ready: mpsc::Sender<()>,
|
||||
held: mpsc::Sender<()>,
|
||||
mut hold: watch::Receiver<bool>,
|
||||
mut close: watch::Receiver<bool>,
|
||||
) -> Result<u64, String> {
|
||||
let mut session = Session::connect(
|
||||
arguments.protocol,
|
||||
&arguments.target,
|
||||
cycle,
|
||||
index,
|
||||
arguments.timeout,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| connection_error(cycle, index, error))?;
|
||||
ready.send(()).await.map_err(|_| "ready receiver closed")?;
|
||||
drop(ready);
|
||||
wait_for_signal(&mut hold, arguments.timeout, "hold").await?;
|
||||
session
|
||||
.hold(arguments.hold)
|
||||
.await
|
||||
.map_err(|error| connection_error(cycle, index, error))?;
|
||||
held.send(()).await.map_err(|_| "held receiver closed")?;
|
||||
drop(held);
|
||||
wait_for_signal(&mut close, arguments.timeout, "close").await?;
|
||||
session
|
||||
.close()
|
||||
.await
|
||||
.map_err(|error| connection_error(cycle, index, error))
|
||||
}
|
||||
|
||||
async fn report_session_failure(
|
||||
session: impl std::future::Future<Output = Result<u64, String>>,
|
||||
failure: mpsc::Sender<String>,
|
||||
) -> Result<u64, String> {
|
||||
let result = session.await;
|
||||
if let Err(error) = &result {
|
||||
let _ = failure.send(error.clone()).await;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn wait_for_phase(
|
||||
receiver: &mut mpsc::Receiver<()>,
|
||||
expected: usize,
|
||||
deadline: Instant,
|
||||
name: &str,
|
||||
failures: &mut mpsc::Receiver<String>,
|
||||
) -> Result<(), String> {
|
||||
for completed in 0..expected {
|
||||
tokio::select! {
|
||||
biased;
|
||||
failure = failures.recv() => {
|
||||
return Err(failure.unwrap_or_else(|| format!(
|
||||
"only {completed} of {expected} {name} reached the phase"
|
||||
)));
|
||||
}
|
||||
signal = timeout_at(deadline, receiver.recv()) => match signal {
|
||||
Ok(Some(())) => {}
|
||||
Ok(None) => {
|
||||
return Err(format!(
|
||||
"only {completed} of {expected} {name} reached the phase"
|
||||
));
|
||||
}
|
||||
Err(_) => return Err(format!("timed out waiting for {name}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_signal(
|
||||
receiver: &mut watch::Receiver<bool>,
|
||||
operation_timeout: Duration,
|
||||
name: &str,
|
||||
) -> Result<(), String> {
|
||||
if *receiver.borrow() {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::timeout(operation_timeout, receiver.changed())
|
||||
.await
|
||||
.map_err(|_| format!("timed out waiting for {name} signal"))?
|
||||
.map_err(|_| format!("{name} signal closed"))?;
|
||||
if !*receiver.borrow() {
|
||||
return Err(format!("{name} signal did not start the phase"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn abort_sessions(sessions: &mut JoinSet<Result<u64, String>>) {
|
||||
sessions.abort_all();
|
||||
while sessions.join_next().await.is_some() {}
|
||||
}
|
||||
|
||||
fn phase(cycle: usize, name: &str) -> Result<(), String> {
|
||||
let mut output = io::stdout().lock();
|
||||
writeln!(output, "PHASE|{cycle}|{name}").map_err(|error| error.to_string())?;
|
||||
output.flush().map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn connection_error(cycle: usize, index: usize, error: String) -> String {
|
||||
format!("cycle {cycle} connection {index}: {error}")
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#[path = "network_bench/config.rs"]
|
||||
mod config;
|
||||
#[path = "network_bench/load.rs"]
|
||||
mod load;
|
||||
#[path = "network_bench/metrics.rs"]
|
||||
mod metrics;
|
||||
#[path = "network_bench/response.rs"]
|
||||
mod response;
|
||||
#[path = "network_bench/service.rs"]
|
||||
mod service;
|
||||
#[path = "support/benchmark_settings.rs"]
|
||||
mod settings;
|
||||
#[path = "network_bench/tls.rs"]
|
||||
mod tls;
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use config::ServiceKind;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("network_bench: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let mut arguments = std::env::args().skip(1);
|
||||
let Some(command) = arguments.next() else {
|
||||
return Err(usage());
|
||||
};
|
||||
if command == "check-config" && arguments.next().is_none() {
|
||||
return settings::Settings::check();
|
||||
}
|
||||
if command == "load" {
|
||||
let arguments = load::Arguments::parse(arguments.collect())?;
|
||||
return load::run(arguments);
|
||||
}
|
||||
let kind = ServiceKind::parse(&command)?;
|
||||
if arguments.next().is_some() {
|
||||
return Err(usage());
|
||||
}
|
||||
service::run(kind)
|
||||
}
|
||||
|
||||
fn usage() -> String {
|
||||
concat!(
|
||||
"usage: network_bench runtime-http|runtime-https|runtime-http2|runtime-https2|",
|
||||
"hyper-http|hyper-https|hyper-http2|hyper-https2\n",
|
||||
" network_bench check-config\n",
|
||||
" network_bench load h1|h2 URL PATH RATE SECONDS PHYSICAL_CONNECTIONS ",
|
||||
"STREAMS_PER_CONNECTION TIMEOUT_MS REUSE CA"
|
||||
)
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::settings::Settings;
|
||||
|
||||
pub const HEADER_BYTES: usize = 8_192;
|
||||
pub const BODY_BYTES: usize = 65_536;
|
||||
pub const HTTP2_FRAME_BYTES: u32 = 16_384;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum HttpVersion {
|
||||
H1,
|
||||
H2,
|
||||
}
|
||||
|
||||
impl HttpVersion {
|
||||
pub fn parse(value: &str) -> Result<Self, String> {
|
||||
match value {
|
||||
"h1" => Ok(Self::H1),
|
||||
"h2" => Ok(Self::H2),
|
||||
_ => Err("HTTP_VERSION must be 'h1' or 'h2'".into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::H1 => "h1",
|
||||
Self::H2 => "h2",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn alpn(self) -> &'static [u8] {
|
||||
match self {
|
||||
Self::H1 => b"http/1.1",
|
||||
Self::H2 => b"h2",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ConnectionShape {
|
||||
pub physical_connections: usize,
|
||||
pub streams_per_connection: usize,
|
||||
pub request_slots: usize,
|
||||
}
|
||||
|
||||
impl ConnectionShape {
|
||||
pub fn new(
|
||||
http_version: HttpVersion,
|
||||
physical_connections: usize,
|
||||
streams_per_connection: usize,
|
||||
) -> Result<Self, String> {
|
||||
if !(1..=65_536).contains(&physical_connections) {
|
||||
return Err("PHYSICAL_CONNECTIONS must be between 1 and 65536".into());
|
||||
}
|
||||
match http_version {
|
||||
HttpVersion::H1 if streams_per_connection != 1 => {
|
||||
return Err("HTTP/1 requires STREAMS_PER_CONNECTION to be 1".into());
|
||||
}
|
||||
HttpVersion::H2 if !(1..=65_536).contains(&streams_per_connection) => {
|
||||
return Err(
|
||||
"HTTP/2 requires STREAMS_PER_CONNECTION to be between 1 and 65536".into(),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let request_slots = physical_connections
|
||||
.checked_mul(streams_per_connection)
|
||||
.ok_or("connection shape is too large")?;
|
||||
if request_slots > 65_536 {
|
||||
return Err(
|
||||
"PHYSICAL_CONNECTIONS * STREAMS_PER_CONNECTION must not exceed 65536".into(),
|
||||
);
|
||||
}
|
||||
Ok(Self {
|
||||
physical_connections,
|
||||
streams_per_connection,
|
||||
request_slots,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Http2Config {
|
||||
pub streams: usize,
|
||||
pub stream_window_bytes: u32,
|
||||
pub connection_window_bytes: u32,
|
||||
}
|
||||
|
||||
impl Http2Config {
|
||||
pub fn from_settings(settings: &Settings, streams: usize) -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
streams,
|
||||
stream_window_bytes: settings.integer(
|
||||
"http2_stream_window_bytes",
|
||||
65_535,
|
||||
1,
|
||||
i32::MAX as usize,
|
||||
)? as u32,
|
||||
connection_window_bytes: settings.integer(
|
||||
"http2_connection_window_bytes",
|
||||
262_144,
|
||||
65_535,
|
||||
i32::MAX as usize,
|
||||
)? as u32,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ServiceKind {
|
||||
RuntimeHttp,
|
||||
RuntimeHttps,
|
||||
RuntimeHttp2,
|
||||
RuntimeHttps2,
|
||||
HyperHttp,
|
||||
HyperHttps,
|
||||
HyperHttp2,
|
||||
HyperHttps2,
|
||||
}
|
||||
|
||||
impl ServiceKind {
|
||||
pub fn parse(value: &str) -> Result<Self, String> {
|
||||
match value {
|
||||
"runtime-http" => Ok(Self::RuntimeHttp),
|
||||
"runtime-https" => Ok(Self::RuntimeHttps),
|
||||
"runtime-http2" => Ok(Self::RuntimeHttp2),
|
||||
"runtime-https2" => Ok(Self::RuntimeHttps2),
|
||||
"hyper-http" => Ok(Self::HyperHttp),
|
||||
"hyper-https" => Ok(Self::HyperHttps),
|
||||
"hyper-http2" => Ok(Self::HyperHttp2),
|
||||
"hyper-https2" => Ok(Self::HyperHttps2),
|
||||
_ => Err(format!("unknown service mode '{value}'")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn uses_runtime(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::RuntimeHttp | Self::RuntimeHttps | Self::RuntimeHttp2 | Self::RuntimeHttps2
|
||||
)
|
||||
}
|
||||
|
||||
pub fn uses_tls(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::RuntimeHttps | Self::RuntimeHttps2 | Self::HyperHttps | Self::HyperHttps2
|
||||
)
|
||||
}
|
||||
|
||||
pub fn http_version(self) -> HttpVersion {
|
||||
match self {
|
||||
Self::RuntimeHttp | Self::RuntimeHttps | Self::HyperHttp | Self::HyperHttps => {
|
||||
HttpVersion::H1
|
||||
}
|
||||
Self::RuntimeHttp2 | Self::RuntimeHttps2 | Self::HyperHttp2 | Self::HyperHttps2 => {
|
||||
HttpVersion::H2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ServiceConfig {
|
||||
pub workers: usize,
|
||||
pub connections: usize,
|
||||
pub task_capacity: usize,
|
||||
pub timeout: Duration,
|
||||
pub lifetime: Duration,
|
||||
pub cert_path: Option<String>,
|
||||
pub key_path: Option<String>,
|
||||
pub http2: Option<Http2Config>,
|
||||
}
|
||||
|
||||
impl ServiceConfig {
|
||||
pub fn from_settings(settings: &Settings, kind: ServiceKind) -> Result<Self, String> {
|
||||
let workers = settings.workers()?;
|
||||
let connections = settings.integer("connections", 32, 1, 65_536)?;
|
||||
let default_tasks = connections
|
||||
.saturating_mul(2)
|
||||
.saturating_add(16)
|
||||
.clamp(272, 65_536);
|
||||
let task_capacity = settings.integer("task_capacity", default_tasks, 1, 65_536)?;
|
||||
let timeout_ms = settings.integer("timeout_ms", 2_000, 1, 300_000)?;
|
||||
let lifetime_ms = settings.integer("lifetime_ms", 10_000, 1, 86_400_000)?;
|
||||
let (cert_path, key_path) = if kind.uses_tls() {
|
||||
(
|
||||
Some(required_text(settings, "cert_path")?),
|
||||
Some(required_text(settings, "key_path")?),
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
let http2 = if kind.http_version() == HttpVersion::H2 {
|
||||
let streams = settings.integer("http2_streams", 16, 1, 65_536)?;
|
||||
let request_slots = connections
|
||||
.checked_mul(streams)
|
||||
.ok_or("benchmark.connections * benchmark.http2_streams is too large")?;
|
||||
if request_slots > 65_536 {
|
||||
return Err(
|
||||
"benchmark.connections * benchmark.http2_streams must not exceed 65536".into(),
|
||||
);
|
||||
}
|
||||
Some(Http2Config::from_settings(settings, streams)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Self {
|
||||
workers,
|
||||
connections,
|
||||
task_capacity,
|
||||
timeout: Duration::from_millis(timeout_ms as u64),
|
||||
lifetime: Duration::from_millis(lifetime_ms as u64),
|
||||
cert_path,
|
||||
key_path,
|
||||
http2,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn required_text(settings: &Settings, name: &str) -> Result<String, String> {
|
||||
settings
|
||||
.value(name)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| format!("benchmark.{name} must be nonempty Text"))
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{BodyExt, Empty};
|
||||
use hyper::body::Incoming;
|
||||
use hyper::client::conn::{http1, http2};
|
||||
use hyper::header::{CONTENT_TYPE, HOST, HeaderValue};
|
||||
use hyper::{Request, StatusCode, Uri, Version};
|
||||
use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
|
||||
use tokio::runtime::Builder;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::Instant;
|
||||
|
||||
use crate::config::{ConnectionShape, HEADER_BYTES, HTTP2_FRAME_BYTES, Http2Config, HttpVersion};
|
||||
use crate::metrics::Histogram;
|
||||
use crate::{response, tls};
|
||||
|
||||
const MAX_RATE: u64 = 1_000_000;
|
||||
const MAX_SECONDS: f64 = 3_600.0;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Arguments {
|
||||
http_version: HttpVersion,
|
||||
target: Target,
|
||||
path: String,
|
||||
expected: response::ExpectedResponse,
|
||||
rate: u64,
|
||||
seconds: Duration,
|
||||
shape: ConnectionShape,
|
||||
timeout: Duration,
|
||||
reuse: bool,
|
||||
ca_path: String,
|
||||
}
|
||||
|
||||
impl Arguments {
|
||||
pub fn parse(values: Vec<String>) -> Result<Self, String> {
|
||||
let [
|
||||
http_version,
|
||||
url,
|
||||
path,
|
||||
rate,
|
||||
seconds,
|
||||
physical_connections,
|
||||
streams_per_connection,
|
||||
timeout_ms,
|
||||
reuse,
|
||||
ca,
|
||||
] = values.as_slice()
|
||||
else {
|
||||
return Err(crate::usage());
|
||||
};
|
||||
let http_version = HttpVersion::parse(http_version)?;
|
||||
let target = Target::parse(url)?;
|
||||
let orm_count = if path == response::ORM_COUNT_PATH {
|
||||
let settings = crate::settings::Settings::load()?;
|
||||
if settings.value("orm_count").is_none() {
|
||||
return Err("benchmark.orm_count is required for ORM count requests".into());
|
||||
}
|
||||
Some(settings.integer("orm_count", 0, 1, usize::MAX)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let expected = response::ExpectedResponse::for_path(path, orm_count)?;
|
||||
let rate = number(rate, "RATE", 1, MAX_RATE)?;
|
||||
let seconds = decimal_duration(seconds, "SECONDS", MAX_SECONDS)?;
|
||||
let physical_connections =
|
||||
number(physical_connections, "PHYSICAL_CONNECTIONS", 1, 65_536)? as usize;
|
||||
let streams_per_connection =
|
||||
number(streams_per_connection, "STREAMS_PER_CONNECTION", 1, 65_536)? as usize;
|
||||
let shape =
|
||||
ConnectionShape::new(http_version, physical_connections, streams_per_connection)?;
|
||||
let timeout_ms = number(timeout_ms, "TIMEOUT_MS", 1, 300_000)?;
|
||||
let reuse = match reuse.as_str() {
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
_ => return Err("REUSE must be 'true' or 'false'".into()),
|
||||
};
|
||||
if http_version == HttpVersion::H2 && !reuse {
|
||||
return Err("HTTP/2 requires REUSE to be 'true'".into());
|
||||
}
|
||||
if target.tls && ca == "-" {
|
||||
return Err("CA must name a root PEM file for HTTPS".into());
|
||||
}
|
||||
Ok(Self {
|
||||
http_version,
|
||||
target,
|
||||
path: path.clone(),
|
||||
expected,
|
||||
rate,
|
||||
seconds,
|
||||
shape,
|
||||
timeout: Duration::from_millis(timeout_ms),
|
||||
reuse,
|
||||
ca_path: ca.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Target {
|
||||
tls: bool,
|
||||
port: u16,
|
||||
authority: String,
|
||||
}
|
||||
|
||||
impl Target {
|
||||
fn parse(value: &str) -> Result<Self, String> {
|
||||
let uri = value
|
||||
.parse::<Uri>()
|
||||
.map_err(|error| format!("URL is invalid: {error}"))?;
|
||||
let scheme = uri
|
||||
.scheme_str()
|
||||
.ok_or("URL must include http:// or https://")?;
|
||||
let host = uri.host().ok_or("URL must include a host")?;
|
||||
let tls = match scheme {
|
||||
"http" => false,
|
||||
"https" => true,
|
||||
_ => return Err("URL scheme must be http or https".into()),
|
||||
};
|
||||
if !matches!(host, "127.0.0.1" | "localhost" | "::1") {
|
||||
return Err("benchmark URL must use a loopback host".into());
|
||||
}
|
||||
if tls && host != "localhost" {
|
||||
return Err(
|
||||
"HTTPS benchmark URL must use localhost for certificate verification".into(),
|
||||
);
|
||||
}
|
||||
if uri.path() != "/" || uri.query().is_some() {
|
||||
return Err(
|
||||
"URL must contain only the scheme, loopback host and port; pass PATH separately"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let port = uri.port_u16().ok_or("URL must include an explicit port")?;
|
||||
let authority = uri
|
||||
.authority()
|
||||
.ok_or("URL must include an authority")?
|
||||
.to_string();
|
||||
Ok(Self {
|
||||
tls,
|
||||
port,
|
||||
authority,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(arguments: Arguments) -> Result<(), String> {
|
||||
let settings = crate::settings::Settings::load()?;
|
||||
let workers = settings.workers()?;
|
||||
let runtime = Builder::new_multi_thread()
|
||||
.worker_threads(workers)
|
||||
.max_blocking_threads(1)
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.map_err(|error| format!("cannot start load runtime: {error}"))?;
|
||||
runtime.block_on(execute(arguments, &settings))
|
||||
}
|
||||
|
||||
async fn execute(arguments: Arguments, settings: &crate::settings::Settings) -> Result<(), String> {
|
||||
let tls = arguments
|
||||
.target
|
||||
.tls
|
||||
.then(|| tls::client(&arguments.ca_path, arguments.http_version))
|
||||
.transpose()?;
|
||||
let http2 = (arguments.http_version == HttpVersion::H2)
|
||||
.then(|| Http2Config::from_settings(settings, arguments.shape.streams_per_connection))
|
||||
.transpose()?;
|
||||
let client = Arc::new(ClientConfig {
|
||||
target: arguments.target.clone(),
|
||||
path: arguments.path.clone(),
|
||||
expected: arguments.expected,
|
||||
timeout: arguments.timeout,
|
||||
reuse: arguments.reuse,
|
||||
tls,
|
||||
http2,
|
||||
});
|
||||
let mut http2_connections = if arguments.http_version == HttpVersion::H2 {
|
||||
Http2Connections::connect(&client, arguments.shape).await?
|
||||
} else {
|
||||
Http2Connections::default()
|
||||
};
|
||||
let event_capacity = arguments.shape.request_slots.saturating_mul(2).max(1);
|
||||
let (events, mut event_receiver) = mpsc::channel(event_capacity);
|
||||
let mut commands = Vec::with_capacity(arguments.shape.request_slots);
|
||||
let mut workers = Vec::with_capacity(arguments.shape.request_slots);
|
||||
for id in 0..arguments.shape.request_slots {
|
||||
let (sender, receiver) = mpsc::channel(1);
|
||||
commands.push(sender);
|
||||
let connection = match arguments.http_version {
|
||||
HttpVersion::H1 => WorkerConnection::Http1(None),
|
||||
HttpVersion::H2 => {
|
||||
let connection = id / arguments.shape.streams_per_connection;
|
||||
WorkerConnection::Http2(http2_connections.sender(connection))
|
||||
}
|
||||
};
|
||||
workers.push(tokio::spawn(worker(
|
||||
id,
|
||||
receiver,
|
||||
events.clone(),
|
||||
client.clone(),
|
||||
connection,
|
||||
)));
|
||||
}
|
||||
drop(events);
|
||||
|
||||
let mut ready = VecDeque::with_capacity(arguments.shape.request_slots);
|
||||
while ready.len() < arguments.shape.request_slots {
|
||||
let event = tokio::time::timeout(arguments.timeout, event_receiver.recv())
|
||||
.await
|
||||
.map_err(|_| "load workers did not become ready before TIMEOUT_MS".to_owned())?
|
||||
.ok_or("load workers stopped before becoming ready")?;
|
||||
match event {
|
||||
Event::Ready(id) => ready.push_back(id),
|
||||
Event::Complete(_) => {
|
||||
return Err("load worker completed before receiving a request".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let scheduled_float = arguments.rate as f64 * arguments.seconds.as_secs_f64();
|
||||
if !scheduled_float.is_finite() || scheduled_float > u64::MAX as f64 {
|
||||
return Err("RATE * SECONDS is too large".into());
|
||||
}
|
||||
let scheduled = scheduled_float.ceil() as u64;
|
||||
let start = Instant::now();
|
||||
let end = start + arguments.seconds;
|
||||
let mut result = Results::new(
|
||||
scheduled,
|
||||
arguments.http_version,
|
||||
arguments.shape,
|
||||
http2_connections.successful_handshakes(),
|
||||
);
|
||||
|
||||
for sequence in 0..scheduled {
|
||||
let planned = start + Duration::from_secs_f64(sequence as f64 / arguments.rate as f64);
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = event_receiver.recv() => {
|
||||
let event = event.ok_or("all load workers stopped unexpectedly")?;
|
||||
result.observe(event, &mut ready);
|
||||
}
|
||||
_ = tokio::time::sleep_until(planned) => break,
|
||||
}
|
||||
}
|
||||
while let Ok(event) = event_receiver.try_recv() {
|
||||
result.observe(event, &mut ready);
|
||||
}
|
||||
if Instant::now() >= end {
|
||||
result.dropped += 1;
|
||||
result.dispatch_expired += 1;
|
||||
continue;
|
||||
}
|
||||
let Some(worker) = ready.pop_front() else {
|
||||
result.dropped += 1;
|
||||
result.dropped_capacity += 1;
|
||||
continue;
|
||||
};
|
||||
let job = Job { planned };
|
||||
if commands[worker].try_send(job).is_ok() {
|
||||
result.sent += 1;
|
||||
} else {
|
||||
result.dropped += 1;
|
||||
result.dropped_capacity += 1;
|
||||
}
|
||||
}
|
||||
|
||||
while Instant::now() < end {
|
||||
tokio::select! {
|
||||
event = event_receiver.recv() => {
|
||||
let event = event.ok_or("all load workers stopped unexpectedly")?;
|
||||
result.observe(event, &mut ready);
|
||||
}
|
||||
_ = tokio::time::sleep_until(end) => break,
|
||||
}
|
||||
}
|
||||
|
||||
let drain_deadline = end + arguments.timeout;
|
||||
while result.completed < result.sent {
|
||||
match tokio::time::timeout_at(drain_deadline, event_receiver.recv()).await {
|
||||
Ok(Some(event)) => result.observe(event, &mut ready),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
drop(commands);
|
||||
let pending = result.sent - result.completed;
|
||||
result.completed += pending;
|
||||
result.errors += pending;
|
||||
for worker in &workers {
|
||||
if !worker.is_finished() {
|
||||
worker.abort();
|
||||
}
|
||||
}
|
||||
for worker in workers {
|
||||
let _ = worker.await;
|
||||
}
|
||||
http2_connections.close().await;
|
||||
result.elapsed = start.elapsed();
|
||||
println!("{}", result.json());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Job {
|
||||
planned: Instant,
|
||||
}
|
||||
|
||||
struct Completion {
|
||||
succeeded: bool,
|
||||
successful_handshakes: u64,
|
||||
latency_us: u64,
|
||||
dispatch_lag_us: u64,
|
||||
request_latency_us: u64,
|
||||
}
|
||||
|
||||
enum Event {
|
||||
Ready(usize),
|
||||
Complete(Completion),
|
||||
}
|
||||
|
||||
async fn worker(
|
||||
id: usize,
|
||||
mut jobs: mpsc::Receiver<Job>,
|
||||
events: mpsc::Sender<Event>,
|
||||
config: Arc<ClientConfig>,
|
||||
mut connection: WorkerConnection,
|
||||
) {
|
||||
if events.send(Event::Ready(id)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
while let Some(job) = jobs.recv().await {
|
||||
let dispatched = Instant::now();
|
||||
let outcome = request(&config, &mut connection).await;
|
||||
let completed = Instant::now();
|
||||
let completion = Completion {
|
||||
succeeded: outcome.result.is_ok(),
|
||||
successful_handshakes: outcome.successful_handshakes,
|
||||
latency_us: micros(completed.saturating_duration_since(job.planned)),
|
||||
dispatch_lag_us: micros(dispatched.saturating_duration_since(job.planned)),
|
||||
request_latency_us: micros(completed.saturating_duration_since(dispatched)),
|
||||
};
|
||||
if events.send(Event::Complete(completion)).await.is_err()
|
||||
|| events.send(Event::Ready(id)).await.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let WorkerConnection::Http1(Some(connection)) = connection {
|
||||
connection.close().await;
|
||||
}
|
||||
}
|
||||
|
||||
struct RequestOutcome {
|
||||
result: Result<(), String>,
|
||||
successful_handshakes: u64,
|
||||
}
|
||||
|
||||
async fn request(config: &ClientConfig, connection: &mut WorkerConnection) -> RequestOutcome {
|
||||
let mut successful_handshakes = 0;
|
||||
let result = match tokio::time::timeout(config.timeout, async {
|
||||
match connection {
|
||||
WorkerConnection::Http1(connection) => {
|
||||
if connection.is_none() {
|
||||
*connection = Some(Http1Connection::connect(config).await?);
|
||||
successful_handshakes = 1;
|
||||
}
|
||||
connection
|
||||
.as_mut()
|
||||
.expect("connection established")
|
||||
.request(config)
|
||||
.await
|
||||
}
|
||||
WorkerConnection::Http2(sender) => http2_request(sender, config).await,
|
||||
}
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => Err("request timed out".to_owned()),
|
||||
};
|
||||
if let WorkerConnection::Http1(connection) = connection
|
||||
&& (result.is_err() || !config.reuse)
|
||||
&& let Some(connection) = connection.take()
|
||||
{
|
||||
connection.close().await;
|
||||
}
|
||||
RequestOutcome {
|
||||
result,
|
||||
successful_handshakes,
|
||||
}
|
||||
}
|
||||
|
||||
struct ClientConfig {
|
||||
target: Target,
|
||||
path: String,
|
||||
expected: response::ExpectedResponse,
|
||||
timeout: Duration,
|
||||
reuse: bool,
|
||||
tls: Option<Arc<rustls::ClientConfig>>,
|
||||
http2: Option<Http2Config>,
|
||||
}
|
||||
|
||||
enum WorkerConnection {
|
||||
Http1(Option<Http1Connection>),
|
||||
Http2(http2::SendRequest<Empty<Bytes>>),
|
||||
}
|
||||
|
||||
struct Http1Connection {
|
||||
sender: http1::SendRequest<Empty<Bytes>>,
|
||||
driver: Option<JoinHandle<Result<(), hyper::Error>>>,
|
||||
}
|
||||
|
||||
impl Http1Connection {
|
||||
async fn connect(config: &ClientConfig) -> Result<Self, String> {
|
||||
let stream = tls::connect(
|
||||
config.target.port,
|
||||
config.timeout,
|
||||
config.tls.as_ref(),
|
||||
HttpVersion::H1,
|
||||
)
|
||||
.await?;
|
||||
let (sender, connection) =
|
||||
tokio::time::timeout(config.timeout, http1::handshake(TokioIo::new(stream)))
|
||||
.await
|
||||
.map_err(|_| "HTTP handshake timed out".to_owned())?
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(Self {
|
||||
sender,
|
||||
driver: Some(tokio::spawn(connection)),
|
||||
})
|
||||
}
|
||||
|
||||
async fn request(&mut self, config: &ClientConfig) -> Result<(), String> {
|
||||
self.sender
|
||||
.ready()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let request = Request::builder()
|
||||
.method("GET")
|
||||
.uri(config.path.as_str())
|
||||
.header(
|
||||
HOST,
|
||||
HeaderValue::from_str(&config.target.authority)
|
||||
.map_err(|error| error.to_string())?,
|
||||
)
|
||||
.body(Empty::new())
|
||||
.map_err(|error| error.to_string())?;
|
||||
let response = self
|
||||
.sender
|
||||
.send_request(request)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
validate_response(&config.expected, response).await
|
||||
}
|
||||
|
||||
async fn close(mut self) {
|
||||
if let Some(driver) = self.driver.take() {
|
||||
driver.abort();
|
||||
let _ = driver.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Http1Connection {
|
||||
fn drop(&mut self) {
|
||||
if let Some(driver) = self.driver.take() {
|
||||
driver.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Http2Connections {
|
||||
connections: Vec<Http2Connection>,
|
||||
}
|
||||
|
||||
impl Http2Connections {
|
||||
async fn connect(config: &ClientConfig, shape: ConnectionShape) -> Result<Self, String> {
|
||||
let mut connections = Vec::with_capacity(shape.physical_connections);
|
||||
for _ in 0..shape.physical_connections {
|
||||
match Http2Connection::connect(config).await {
|
||||
Ok(connection) => connections.push(connection),
|
||||
Err(error) => {
|
||||
close_http2_connections(connections).await;
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Self { connections })
|
||||
}
|
||||
|
||||
fn sender(&self, connection: usize) -> http2::SendRequest<Empty<Bytes>> {
|
||||
self.connections[connection].sender.clone()
|
||||
}
|
||||
|
||||
fn successful_handshakes(&self) -> u64 {
|
||||
self.connections.len() as u64
|
||||
}
|
||||
|
||||
async fn close(&mut self) {
|
||||
close_http2_connections(std::mem::take(&mut self.connections)).await;
|
||||
}
|
||||
}
|
||||
|
||||
struct Http2Connection {
|
||||
sender: http2::SendRequest<Empty<Bytes>>,
|
||||
driver: Option<JoinHandle<Result<(), hyper::Error>>>,
|
||||
}
|
||||
|
||||
impl Http2Connection {
|
||||
async fn connect(config: &ClientConfig) -> Result<Self, String> {
|
||||
let limits = config.http2.ok_or("HTTP/2 client has no HTTP/2 limits")?;
|
||||
let stream = tls::connect(
|
||||
config.target.port,
|
||||
config.timeout,
|
||||
config.tls.as_ref(),
|
||||
HttpVersion::H2,
|
||||
)
|
||||
.await?;
|
||||
let mut builder = http2::Builder::new(TokioExecutor::new());
|
||||
builder
|
||||
.timer(TokioTimer::new())
|
||||
.adaptive_window(false)
|
||||
.initial_stream_window_size(limits.stream_window_bytes)
|
||||
.initial_connection_window_size(limits.connection_window_bytes)
|
||||
.initial_max_send_streams(limits.streams)
|
||||
.max_concurrent_streams(0)
|
||||
.max_header_list_size(HEADER_BYTES as u32)
|
||||
.max_frame_size(HTTP2_FRAME_BYTES)
|
||||
.max_send_buf_size(HTTP2_FRAME_BYTES as usize);
|
||||
let (sender, connection) =
|
||||
tokio::time::timeout(config.timeout, builder.handshake(TokioIo::new(stream)))
|
||||
.await
|
||||
.map_err(|_| "HTTP/2 handshake timed out".to_owned())?
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(Self {
|
||||
sender,
|
||||
driver: Some(tokio::spawn(connection)),
|
||||
})
|
||||
}
|
||||
|
||||
async fn close(mut self) {
|
||||
if let Some(driver) = self.driver.take() {
|
||||
driver.abort();
|
||||
let _ = driver.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Http2Connection {
|
||||
fn drop(&mut self) {
|
||||
if let Some(driver) = self.driver.take() {
|
||||
driver.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn close_http2_connections(connections: Vec<Http2Connection>) {
|
||||
for connection in connections {
|
||||
connection.close().await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn http2_request(
|
||||
sender: &mut http2::SendRequest<Empty<Bytes>>,
|
||||
config: &ClientConfig,
|
||||
) -> Result<(), String> {
|
||||
sender.ready().await.map_err(|error| error.to_string())?;
|
||||
let scheme = if config.target.tls { "https" } else { "http" };
|
||||
let uri = format!("{scheme}://{}{}", config.target.authority, config.path);
|
||||
let request = Request::builder()
|
||||
.method("GET")
|
||||
.version(Version::HTTP_2)
|
||||
.uri(uri)
|
||||
.body(Empty::new())
|
||||
.map_err(|error| error.to_string())?;
|
||||
let response = sender
|
||||
.send_request(request)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
validate_response(&config.expected, response).await
|
||||
}
|
||||
|
||||
async fn validate_response(
|
||||
expected: &response::ExpectedResponse,
|
||||
response: hyper::Response<Incoming>,
|
||||
) -> Result<(), String> {
|
||||
if response.status() != StatusCode::OK {
|
||||
return Err(format!("unexpected response status {}", response.status()));
|
||||
}
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.ok_or("response has no Content-Type")?;
|
||||
if content_type.as_bytes() != expected.content_type.as_bytes() {
|
||||
return Err("response Content-Type does not match the benchmark contract".into());
|
||||
}
|
||||
let mut body = response.into_body();
|
||||
let mut offset: usize = 0;
|
||||
while let Some(frame) = body.frame().await {
|
||||
let frame = frame.map_err(|error| error.to_string())?;
|
||||
let data = frame
|
||||
.into_data()
|
||||
.map_err(|_| "response contains unexpected trailers")?;
|
||||
let end = offset.saturating_add(data.len());
|
||||
if end > expected.body.len() || data.as_ref() != &expected.body[offset..end] {
|
||||
return Err("response body does not match the benchmark contract".into());
|
||||
}
|
||||
offset = end;
|
||||
}
|
||||
if offset != expected.body.len() {
|
||||
return Err("response body is shorter than the benchmark contract".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Results {
|
||||
http_version: HttpVersion,
|
||||
shape: ConnectionShape,
|
||||
successful_handshakes: u64,
|
||||
scheduled: u64,
|
||||
sent: u64,
|
||||
completed: u64,
|
||||
succeeded: u64,
|
||||
errors: u64,
|
||||
dropped: u64,
|
||||
dropped_capacity: u64,
|
||||
dispatch_expired: u64,
|
||||
elapsed: Duration,
|
||||
latency: Histogram,
|
||||
dispatch_lag: Histogram,
|
||||
request_latency: Histogram,
|
||||
}
|
||||
|
||||
impl Results {
|
||||
fn new(
|
||||
scheduled: u64,
|
||||
http_version: HttpVersion,
|
||||
shape: ConnectionShape,
|
||||
successful_handshakes: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
http_version,
|
||||
shape,
|
||||
successful_handshakes,
|
||||
scheduled,
|
||||
sent: 0,
|
||||
completed: 0,
|
||||
succeeded: 0,
|
||||
errors: 0,
|
||||
dropped: 0,
|
||||
dropped_capacity: 0,
|
||||
dispatch_expired: 0,
|
||||
elapsed: Duration::ZERO,
|
||||
latency: Histogram::new(),
|
||||
dispatch_lag: Histogram::new(),
|
||||
request_latency: Histogram::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn observe(&mut self, event: Event, ready: &mut VecDeque<usize>) {
|
||||
match event {
|
||||
Event::Ready(id) => ready.push_back(id),
|
||||
Event::Complete(completion) => {
|
||||
self.completed += 1;
|
||||
self.successful_handshakes += completion.successful_handshakes;
|
||||
self.dispatch_lag.record(completion.dispatch_lag_us);
|
||||
if completion.succeeded {
|
||||
self.succeeded += 1;
|
||||
self.latency.record(completion.latency_us);
|
||||
self.request_latency.record(completion.request_latency_us);
|
||||
} else {
|
||||
self.errors += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn json(&self) -> String {
|
||||
let elapsed = self.elapsed.as_secs_f64();
|
||||
let qps = if elapsed == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
self.succeeded as f64 / elapsed
|
||||
};
|
||||
let latency = percentiles_json(&self.latency);
|
||||
let dispatch_lag = percentiles_json(&self.dispatch_lag);
|
||||
let request_latency = percentiles_json(&self.request_latency);
|
||||
format!(
|
||||
concat!(
|
||||
"{{\"http_version\":\"{}\",\"physical_connections\":{},",
|
||||
"\"streams_per_connection\":{},\"request_slots\":{},",
|
||||
"\"successful_handshakes\":{},",
|
||||
"\"scheduled\":{},\"sent\":{},\"completed\":{},",
|
||||
"\"succeeded\":{},\"errors\":{},\"dropped\":{},",
|
||||
"\"dropped_capacity\":{},\"dispatch_expired\":{},",
|
||||
"\"latency_samples\":{},\"dispatch_lag_samples\":{},",
|
||||
"\"request_latency_samples\":{},",
|
||||
"\"elapsed_seconds\":{:.6},\"actual_success_qps\":{:.3},",
|
||||
"\"latency_ms\":{},\"dispatch_lag_ms\":{},",
|
||||
"\"request_latency_ms\":{}}}"
|
||||
),
|
||||
self.http_version.as_str(),
|
||||
self.shape.physical_connections,
|
||||
self.shape.streams_per_connection,
|
||||
self.shape.request_slots,
|
||||
self.successful_handshakes,
|
||||
self.scheduled,
|
||||
self.sent,
|
||||
self.completed,
|
||||
self.succeeded,
|
||||
self.errors,
|
||||
self.dropped,
|
||||
self.dropped_capacity,
|
||||
self.dispatch_expired,
|
||||
self.latency.count(),
|
||||
self.dispatch_lag.count(),
|
||||
self.request_latency.count(),
|
||||
elapsed,
|
||||
qps,
|
||||
latency,
|
||||
dispatch_lag,
|
||||
request_latency,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn percentiles_json(histogram: &Histogram) -> String {
|
||||
if histogram.count() == 0 {
|
||||
return "null".into();
|
||||
}
|
||||
format!(
|
||||
"{{\"sample_count\":{},\"p50\":{:.3},\"p95\":{:.3},\"p99\":{:.3}}}",
|
||||
histogram.count(),
|
||||
histogram.percentile_ms(50),
|
||||
histogram.percentile_ms(95),
|
||||
histogram.percentile_ms(99),
|
||||
)
|
||||
}
|
||||
|
||||
fn micros(duration: Duration) -> u64 {
|
||||
duration.as_micros().min(u128::from(u64::MAX)) as u64
|
||||
}
|
||||
|
||||
fn number(value: &str, name: &str, minimum: u64, maximum: u64) -> Result<u64, String> {
|
||||
let parsed = value
|
||||
.parse::<u64>()
|
||||
.map_err(|_| format!("{name} must be an integer"))?;
|
||||
if !(minimum..=maximum).contains(&parsed) {
|
||||
return Err(format!("{name} must be between {minimum} and {maximum}"));
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn decimal_duration(value: &str, name: &str, maximum: f64) -> Result<Duration, String> {
|
||||
let seconds = value
|
||||
.parse::<f64>()
|
||||
.map_err(|_| format!("{name} must be a number"))?;
|
||||
if !seconds.is_finite() || seconds <= 0.0 || seconds > maximum {
|
||||
return Err(format!(
|
||||
"{name} must be greater than 0 and at most {maximum}"
|
||||
));
|
||||
}
|
||||
Ok(Duration::from_secs_f64(seconds))
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
const EXACT_BUCKETS: usize = 1_024;
|
||||
const SUB_BUCKETS: usize = 64;
|
||||
const BUCKETS: usize = 4_096;
|
||||
|
||||
pub struct Histogram {
|
||||
buckets: Box<[u64; BUCKETS]>,
|
||||
count: u64,
|
||||
}
|
||||
|
||||
impl Histogram {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
buckets: Box::new([0; BUCKETS]),
|
||||
count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record(&mut self, microseconds: u64) {
|
||||
self.buckets[index(microseconds)] += 1;
|
||||
self.count += 1;
|
||||
}
|
||||
|
||||
pub fn count(&self) -> u64 {
|
||||
self.count
|
||||
}
|
||||
|
||||
pub fn percentile_ms(&self, percentile: u64) -> f64 {
|
||||
if self.count == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let target = self.count.saturating_mul(percentile).div_ceil(100);
|
||||
let mut observed = 0;
|
||||
for (index, count) in self.buckets.iter().enumerate() {
|
||||
observed += count;
|
||||
if observed >= target {
|
||||
return upper_bound(index) as f64 / 1_000.0;
|
||||
}
|
||||
}
|
||||
upper_bound(BUCKETS - 1) as f64 / 1_000.0
|
||||
}
|
||||
}
|
||||
|
||||
fn index(microseconds: u64) -> usize {
|
||||
if microseconds < EXACT_BUCKETS as u64 {
|
||||
return microseconds as usize;
|
||||
}
|
||||
let exponent = 63 - microseconds.leading_zeros() as usize;
|
||||
let lower = 1_u64 << exponent;
|
||||
let step = (lower / SUB_BUCKETS as u64).max(1);
|
||||
let sub = ((microseconds - lower) / step).min((SUB_BUCKETS - 1) as u64) as usize;
|
||||
(EXACT_BUCKETS + (exponent - 10) * SUB_BUCKETS + sub).min(BUCKETS - 1)
|
||||
}
|
||||
|
||||
fn upper_bound(index: usize) -> u64 {
|
||||
if index < EXACT_BUCKETS {
|
||||
return index as u64;
|
||||
}
|
||||
let offset = index - EXACT_BUCKETS;
|
||||
let exponent = 10 + offset / SUB_BUCKETS;
|
||||
if exponent >= 63 {
|
||||
return u64::MAX;
|
||||
}
|
||||
let sub = offset % SUB_BUCKETS;
|
||||
let lower = 1_u64 << exponent;
|
||||
let step = lower / SUB_BUCKETS as u64;
|
||||
lower.saturating_add(step.saturating_mul(sub as u64 + 1))
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use bytes::Bytes;
|
||||
use http_body_util::Full;
|
||||
use hyper::header::{CONTENT_TYPE, HeaderValue};
|
||||
use hyper::{Response, StatusCode};
|
||||
|
||||
pub const PLAIN_PATH: &str = "/plain";
|
||||
pub const JSON_PATH: &str = "/json";
|
||||
pub const BYTES_PATH: &str = "/bytes";
|
||||
pub const ORM_COUNT_PATH: &str = "/benchmark/item/count";
|
||||
|
||||
const PLAIN_BODY: &[u8] = b"hello";
|
||||
const JSON_BODY: &[u8] = br#"{"ok":true}"#;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExpectedResponse {
|
||||
pub body: Bytes,
|
||||
pub content_type: &'static str,
|
||||
}
|
||||
|
||||
impl ExpectedResponse {
|
||||
pub fn for_path(path: &str, orm_count: Option<usize>) -> Result<Self, String> {
|
||||
match path {
|
||||
PLAIN_PATH => Ok(Self {
|
||||
body: Bytes::from_static(PLAIN_BODY),
|
||||
content_type: "text/plain",
|
||||
}),
|
||||
JSON_PATH => Ok(Self {
|
||||
body: Bytes::from_static(JSON_BODY),
|
||||
content_type: "application/json",
|
||||
}),
|
||||
BYTES_PATH => Ok(Self {
|
||||
body: Bytes::from(vec![b'x'; 65_536]),
|
||||
content_type: "application/octet-stream",
|
||||
}),
|
||||
ORM_COUNT_PATH => Ok(Self {
|
||||
body: Bytes::from(format!(
|
||||
"{{\"code\":0,\"message\":\"ok\",\"data\":{}}}",
|
||||
orm_count.ok_or("benchmark.orm_count is required for ORM count requests")?
|
||||
)),
|
||||
content_type: "application/json; charset=utf-8",
|
||||
}),
|
||||
_ => Err(format!("unsupported benchmark path '{path}'")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct HyperResponses {
|
||||
bytes: Bytes,
|
||||
}
|
||||
|
||||
impl HyperResponses {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
bytes: Bytes::from(vec![b'x'; 65_536]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn response(&self, path: &str) -> Response<Full<Bytes>> {
|
||||
let (body, content_type) = match path {
|
||||
PLAIN_PATH => (Bytes::from_static(PLAIN_BODY), "text/plain"),
|
||||
JSON_PATH => (Bytes::from_static(JSON_BODY), "application/json"),
|
||||
BYTES_PATH => (self.bytes.clone(), "application/octet-stream"),
|
||||
_ => {
|
||||
let mut response = Response::new(Full::new(Bytes::new()));
|
||||
*response.status_mut() = StatusCode::NOT_FOUND;
|
||||
return response;
|
||||
}
|
||||
};
|
||||
let mut response = Response::new(Full::new(body));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RuntimeResponses {
|
||||
bytes: dever_runtime::bytes::Bytes,
|
||||
}
|
||||
|
||||
impl RuntimeResponses {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
bytes: dever_runtime::bytes::Bytes::new(vec![b'x'; 65_536]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn response(&self, path: &str) -> dever_runtime::http::Response {
|
||||
let (body, content_type) = match path {
|
||||
PLAIN_PATH => (
|
||||
dever_runtime::bytes::Bytes::from_text("hello"),
|
||||
"text/plain",
|
||||
),
|
||||
JSON_PATH => (
|
||||
dever_runtime::bytes::Bytes::from_text(r#"{"ok":true}"#),
|
||||
"application/json",
|
||||
),
|
||||
BYTES_PATH => (self.bytes.clone(), "application/octet-stream"),
|
||||
_ => {
|
||||
return dever_runtime::http::Response {
|
||||
status: 404,
|
||||
headers: vec![],
|
||||
body: dever_runtime::bytes::Bytes::new(vec![]),
|
||||
};
|
||||
}
|
||||
};
|
||||
dever_runtime::http::Response {
|
||||
status: 200,
|
||||
headers: vec![dever_runtime::http::Header {
|
||||
name: "content-type".into(),
|
||||
value: dever_runtime::bytes::Bytes::from_text(content_type),
|
||||
}],
|
||||
body,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
use std::future::Future;
|
||||
use std::io::{self, Write};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::{Bytes, BytesMut};
|
||||
use http_body_util::{BodyExt, Full};
|
||||
use hyper::body::Incoming;
|
||||
use hyper::header::{CONNECTION, HOST, UPGRADE};
|
||||
use hyper::server::conn::{http1, http2};
|
||||
use hyper::service::service_fn;
|
||||
use hyper::{Method, Request, Response, StatusCode, Version};
|
||||
use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::runtime::Builder;
|
||||
use tokio::sync::{Semaphore, watch};
|
||||
use tokio::task::JoinSet;
|
||||
use tokio::time::{Instant, timeout, timeout_at};
|
||||
|
||||
use crate::config::{
|
||||
BODY_BYTES, HEADER_BYTES, HTTP2_FRAME_BYTES, Http2Config, HttpVersion, ServiceConfig,
|
||||
ServiceKind,
|
||||
};
|
||||
use crate::{response, tls};
|
||||
|
||||
pub fn run(kind: ServiceKind) -> Result<(), String> {
|
||||
let settings = crate::settings::Settings::load()?;
|
||||
let config = ServiceConfig::from_settings(&settings, kind)?;
|
||||
if kind.uses_runtime() {
|
||||
run_runtime(config, kind)
|
||||
} else {
|
||||
run_hyper(config, kind)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_runtime(config: ServiceConfig, kind: ServiceKind) -> Result<(), String> {
|
||||
let server_tls = config
|
||||
.cert_path
|
||||
.as_deref()
|
||||
.zip(config.key_path.as_deref())
|
||||
.map(|(cert, key)| tls::runtime_server(cert, key))
|
||||
.transpose()?;
|
||||
let runtime = dever_runtime::task::RuntimeConfig {
|
||||
worker_threads: config.workers,
|
||||
max_blocking_threads: 2,
|
||||
task_capacity: config.task_capacity,
|
||||
};
|
||||
dever_runtime::task::run_entry_with(runtime, async move {
|
||||
let responses = response::RuntimeResponses::new();
|
||||
let listener = dever_runtime::net::listen("127.0.0.1", 0).await?;
|
||||
let port = dever_runtime::net::port(&listener)?;
|
||||
ready(u16::try_from(port).map_err(|_| "listener returned an invalid port")?)?;
|
||||
let limits = dever_runtime::http::Limits {
|
||||
header_bytes: HEADER_BYTES as i64,
|
||||
body_bytes: BODY_BYTES as i64,
|
||||
timeout_ms: config.timeout.as_millis() as i64,
|
||||
connections: config.connections as i64,
|
||||
http2: config.http2.map(|limits| dever_runtime::http::Http2Limits {
|
||||
streams: limits.streams as i64,
|
||||
stream_window_bytes: i64::from(limits.stream_window_bytes),
|
||||
connection_window_bytes: i64::from(limits.connection_window_bytes),
|
||||
}),
|
||||
};
|
||||
let owned = listener.clone();
|
||||
let route = move |request: dever_runtime::http::Request| {
|
||||
let response = responses.response(&request.target);
|
||||
async move { Ok(response) }
|
||||
};
|
||||
let server: Pin<Box<dyn Future<Output = Result<(), String>> + Send>> = match server_tls {
|
||||
Some(tls) if kind.uses_tls() => {
|
||||
Box::pin(dever_runtime::http::serve_tls(route, owned, limits, tls))
|
||||
}
|
||||
_ => Box::pin(dever_runtime::http::serve(route, owned, limits)),
|
||||
};
|
||||
tokio::pin!(server);
|
||||
tokio::select! {
|
||||
result = server.as_mut() => result,
|
||||
_ = tokio::time::sleep(config.lifetime) => {
|
||||
listener.close()?;
|
||||
timeout(config.timeout, server.as_mut())
|
||||
.await
|
||||
.map_err(|_| "runtime server shutdown timed out".to_owned())?
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn run_hyper(config: ServiceConfig, kind: ServiceKind) -> Result<(), String> {
|
||||
let server_tls = config
|
||||
.cert_path
|
||||
.as_deref()
|
||||
.zip(config.key_path.as_deref())
|
||||
.map(|(cert, key)| tls::server(cert, key, kind.http_version()))
|
||||
.transpose()?;
|
||||
let runtime = Builder::new_multi_thread()
|
||||
.worker_threads(config.workers)
|
||||
.max_blocking_threads(1)
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.map_err(|error| format!("cannot start benchmark runtime: {error}"))?;
|
||||
runtime.block_on(hyper_server(
|
||||
config,
|
||||
kind.http_version(),
|
||||
kind.uses_tls().then_some(server_tls).flatten(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn hyper_server(
|
||||
config: ServiceConfig,
|
||||
http_version: HttpVersion,
|
||||
server_tls: Option<Arc<rustls::ServerConfig>>,
|
||||
) -> Result<(), String> {
|
||||
let responses = Arc::new(response::HyperResponses::new());
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
ready(
|
||||
listener
|
||||
.local_addr()
|
||||
.map_err(|error| error.to_string())?
|
||||
.port(),
|
||||
)?;
|
||||
|
||||
let connections = Arc::new(Semaphore::new(config.connections));
|
||||
let (shutdown, shutdown_receiver) = watch::channel(false);
|
||||
let mut tasks = JoinSet::new();
|
||||
let deadline = Instant::now() + config.lifetime;
|
||||
|
||||
loop {
|
||||
while let Some(result) = tasks.try_join_next() {
|
||||
result.map_err(|error| format!("connection task failed: {error}"))??;
|
||||
}
|
||||
let connections = connections.clone();
|
||||
let accepted = timeout_at(deadline, async {
|
||||
let permit = connections
|
||||
.acquire_owned()
|
||||
.await
|
||||
.map_err(|_| "connection limit closed".to_owned())?;
|
||||
let (stream, _) = listener.accept().await.map_err(|error| error.to_string())?;
|
||||
stream
|
||||
.set_nodelay(true)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok::<_, String>((permit, stream))
|
||||
})
|
||||
.await;
|
||||
let Ok(accepted) = accepted else {
|
||||
break;
|
||||
};
|
||||
let (permit, stream) = accepted?;
|
||||
let connection_config = config.clone();
|
||||
let connection_tls = server_tls.clone();
|
||||
let connection_shutdown = shutdown_receiver.clone();
|
||||
let connection_responses = responses.clone();
|
||||
tasks.spawn(async move {
|
||||
let _permit = permit;
|
||||
let stream = match tls::accept(
|
||||
stream,
|
||||
connection_config.timeout,
|
||||
connection_tls.as_ref(),
|
||||
http_version,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(stream) => stream,
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
serve_connection(
|
||||
stream,
|
||||
connection_config.timeout,
|
||||
connection_shutdown,
|
||||
connection_responses,
|
||||
http_version,
|
||||
connection_config.http2,
|
||||
)
|
||||
.await
|
||||
});
|
||||
}
|
||||
|
||||
let _ = shutdown.send(true);
|
||||
if timeout(config.timeout, drain_connections(&mut tasks))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
tasks.abort_all();
|
||||
while tasks.join_next().await.is_some() {}
|
||||
return Err("hyper server shutdown timed out".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn serve_connection(
|
||||
stream: tls::BoxedStream,
|
||||
operation_timeout: std::time::Duration,
|
||||
shutdown: watch::Receiver<bool>,
|
||||
responses: Arc<response::HyperResponses>,
|
||||
http_version: HttpVersion,
|
||||
http2_config: Option<Http2Config>,
|
||||
) -> Result<(), String> {
|
||||
match http_version {
|
||||
HttpVersion::H1 => serve_http1(stream, operation_timeout, shutdown, responses).await,
|
||||
HttpVersion::H2 => {
|
||||
let limits = http2_config.ok_or("HTTP/2 service has no HTTP/2 limits")?;
|
||||
serve_http2(stream, operation_timeout, shutdown, responses, limits).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_http1(
|
||||
stream: tls::BoxedStream,
|
||||
operation_timeout: std::time::Duration,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
responses: Arc<response::HyperResponses>,
|
||||
) -> Result<(), String> {
|
||||
let service = service_fn(move |request| {
|
||||
dispatch(
|
||||
request,
|
||||
operation_timeout,
|
||||
responses.clone(),
|
||||
HttpVersion::H1,
|
||||
)
|
||||
});
|
||||
let mut builder = http1::Builder::new();
|
||||
builder
|
||||
.timer(TokioTimer::new())
|
||||
.header_read_timeout(operation_timeout)
|
||||
.max_buf_size(HEADER_BYTES);
|
||||
let connection = builder.serve_connection(TokioIo::new(stream), service);
|
||||
tokio::pin!(connection);
|
||||
let result = tokio::select! {
|
||||
result = connection.as_mut() => result,
|
||||
_ = shutdown.changed() => {
|
||||
connection.as_mut().graceful_shutdown();
|
||||
timeout(operation_timeout, connection.as_mut())
|
||||
.await
|
||||
.map_err(|_| "connection shutdown timed out".to_owned())?
|
||||
}
|
||||
};
|
||||
// Peer disconnects and malformed requests end only their own connection.
|
||||
let _ = result;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn serve_http2(
|
||||
stream: tls::BoxedStream,
|
||||
operation_timeout: std::time::Duration,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
responses: Arc<response::HyperResponses>,
|
||||
limits: Http2Config,
|
||||
) -> Result<(), String> {
|
||||
let service = service_fn(move |request| {
|
||||
dispatch(
|
||||
request,
|
||||
operation_timeout,
|
||||
responses.clone(),
|
||||
HttpVersion::H2,
|
||||
)
|
||||
});
|
||||
let mut builder = http2::Builder::new(TokioExecutor::new());
|
||||
builder
|
||||
.timer(TokioTimer::new())
|
||||
.adaptive_window(false)
|
||||
.initial_stream_window_size(limits.stream_window_bytes)
|
||||
.initial_connection_window_size(limits.connection_window_bytes)
|
||||
.max_concurrent_streams(limits.streams as u32)
|
||||
.max_header_list_size(HEADER_BYTES as u32)
|
||||
.max_frame_size(HTTP2_FRAME_BYTES)
|
||||
.max_send_buf_size(HTTP2_FRAME_BYTES as usize);
|
||||
let connection = builder.serve_connection(TokioIo::new(stream), service);
|
||||
tokio::pin!(connection);
|
||||
let result = tokio::select! {
|
||||
result = connection.as_mut() => result,
|
||||
_ = shutdown.changed() => {
|
||||
connection.as_mut().graceful_shutdown();
|
||||
timeout(operation_timeout, connection.as_mut())
|
||||
.await
|
||||
.map_err(|_| "connection shutdown timed out".to_owned())?
|
||||
}
|
||||
};
|
||||
// Peer disconnects and malformed requests end only their own connection.
|
||||
let _ = result;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn drain_connections(tasks: &mut JoinSet<Result<(), String>>) -> Result<(), String> {
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
result.map_err(|error| format!("connection task failed: {error}"))??;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn dispatch(
|
||||
request: Request<Incoming>,
|
||||
operation_timeout: std::time::Duration,
|
||||
responses: Arc<response::HyperResponses>,
|
||||
http_version: HttpVersion,
|
||||
) -> Result<Response<Full<Bytes>>, std::convert::Infallible> {
|
||||
let response = timeout(
|
||||
operation_timeout,
|
||||
read_and_route(request, responses, http_version),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| reject(StatusCode::REQUEST_TIMEOUT, http_version));
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn read_and_route(
|
||||
request: Request<Incoming>,
|
||||
responses: Arc<response::HyperResponses>,
|
||||
http_version: HttpVersion,
|
||||
) -> Response<Full<Bytes>> {
|
||||
if request.method() == Method::CONNECT || request.headers().contains_key(UPGRADE) {
|
||||
return reject(StatusCode::NOT_IMPLEMENTED, http_version);
|
||||
}
|
||||
if request.version() == Version::HTTP_11 && request.headers().get_all(HOST).iter().count() != 1
|
||||
{
|
||||
return reject(StatusCode::BAD_REQUEST, http_version);
|
||||
}
|
||||
if header_size(&request) > HEADER_BYTES {
|
||||
return reject(StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE, http_version);
|
||||
}
|
||||
let (head, mut body) = request.into_parts();
|
||||
let mut received = BytesMut::new();
|
||||
while let Some(frame) = body.frame().await {
|
||||
let Ok(frame) = frame else {
|
||||
return reject(StatusCode::BAD_REQUEST, http_version);
|
||||
};
|
||||
if frame.is_trailers() {
|
||||
return reject(StatusCode::NOT_IMPLEMENTED, http_version);
|
||||
}
|
||||
if let Ok(data) = frame.into_data() {
|
||||
if received.len().saturating_add(data.len()) > BODY_BYTES {
|
||||
return reject(StatusCode::PAYLOAD_TOO_LARGE, http_version);
|
||||
}
|
||||
received.extend_from_slice(&data);
|
||||
}
|
||||
}
|
||||
responses.response(head.uri.path())
|
||||
}
|
||||
|
||||
fn header_size(request: &Request<Incoming>) -> usize {
|
||||
let start = request.method().as_str().len() + request.uri().to_string().len() + 14;
|
||||
request
|
||||
.headers()
|
||||
.iter()
|
||||
.fold(start, |total, (name, value)| {
|
||||
total
|
||||
.saturating_add(name.as_str().len())
|
||||
.saturating_add(value.as_bytes().len())
|
||||
.saturating_add(4)
|
||||
})
|
||||
}
|
||||
|
||||
fn reject(status: StatusCode, http_version: HttpVersion) -> Response<Full<Bytes>> {
|
||||
let mut response = Response::new(Full::new(Bytes::new()));
|
||||
*response.status_mut() = status;
|
||||
if http_version == HttpVersion::H1 {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONNECTION, hyper::header::HeaderValue::from_static("close"));
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn ready(port: u16) -> Result<(), String> {
|
||||
let mut output = io::stdout().lock();
|
||||
writeln!(output, "READY|{port}").map_err(|error| error.to_string())?;
|
||||
output.flush().map_err(|error| error.to_string())
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, pem::PemObject};
|
||||
use rustls::{ClientConfig, RootCertStore, ServerConfig};
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_rustls::{TlsAcceptor, TlsConnector};
|
||||
|
||||
use crate::config::HttpVersion;
|
||||
|
||||
pub trait Stream: AsyncRead + AsyncWrite {}
|
||||
impl<T: AsyncRead + AsyncWrite> Stream for T {}
|
||||
|
||||
pub type BoxedStream = Box<dyn Stream + Unpin + Send>;
|
||||
|
||||
pub fn runtime_server(
|
||||
cert_path: &str,
|
||||
key_path: &str,
|
||||
) -> Result<dever_runtime::tls::ServerTls, String> {
|
||||
let certificate = std::fs::read(cert_path)
|
||||
.map_err(|error| format!("cannot read certificate {cert_path}: {error}"))?;
|
||||
let key = std::fs::read(key_path)
|
||||
.map_err(|error| format!("cannot read private key {key_path}: {error}"))?;
|
||||
dever_runtime::tls::server(
|
||||
&dever_runtime::bytes::Bytes::new(certificate),
|
||||
&dever_runtime::bytes::Bytes::new(key),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn server(
|
||||
cert_path: &str,
|
||||
key_path: &str,
|
||||
http_version: HttpVersion,
|
||||
) -> Result<Arc<ServerConfig>, String> {
|
||||
let certificates = certificates(cert_path)?;
|
||||
let key_bytes = std::fs::read(key_path)
|
||||
.map_err(|error| format!("cannot read private key {key_path}: {error}"))?;
|
||||
let key = PrivateKeyDer::from_pem_slice(&key_bytes).map_err(|error| error.to_string())?;
|
||||
let mut config = ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certificates, key)
|
||||
.map_err(|error| error.to_string())?;
|
||||
config.session_storage = rustls::server::ServerSessionMemoryCache::new(32);
|
||||
config.send_tls13_tickets = 1;
|
||||
config.alpn_protocols = vec![http_version.alpn().to_vec()];
|
||||
Ok(Arc::new(config))
|
||||
}
|
||||
|
||||
pub fn client(ca_path: &str, http_version: HttpVersion) -> Result<Arc<ClientConfig>, String> {
|
||||
let mut roots = RootCertStore::empty();
|
||||
for certificate in certificates(ca_path)? {
|
||||
roots.add(certificate).map_err(|error| error.to_string())?;
|
||||
}
|
||||
let mut config = ClientConfig::builder()
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
config.resumption = rustls::client::Resumption::in_memory_sessions(32);
|
||||
config.alpn_protocols = vec![http_version.alpn().to_vec()];
|
||||
Ok(Arc::new(config))
|
||||
}
|
||||
|
||||
pub async fn connect(
|
||||
port: u16,
|
||||
timeout: Duration,
|
||||
tls: Option<&Arc<ClientConfig>>,
|
||||
http_version: HttpVersion,
|
||||
) -> Result<BoxedStream, String> {
|
||||
let tcp = tokio::time::timeout(timeout, TcpStream::connect(("127.0.0.1", port)))
|
||||
.await
|
||||
.map_err(|_| "TCP connection timed out".to_owned())?
|
||||
.map_err(|error| error.to_string())?;
|
||||
tcp.set_nodelay(true).map_err(|error| error.to_string())?;
|
||||
let Some(config) = tls else {
|
||||
return Ok(Box::new(tcp));
|
||||
};
|
||||
let name = ServerName::try_from("localhost").map_err(|error| error.to_string())?;
|
||||
let stream = tokio::time::timeout(
|
||||
timeout,
|
||||
TlsConnector::from(config.clone()).connect(name, tcp),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "TLS handshake timed out".to_owned())?
|
||||
.map_err(|error| error.to_string())?;
|
||||
verify_alpn(stream.get_ref().1.alpn_protocol(), http_version)?;
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
|
||||
pub async fn accept(
|
||||
tcp: TcpStream,
|
||||
timeout: Duration,
|
||||
tls: Option<&Arc<ServerConfig>>,
|
||||
http_version: HttpVersion,
|
||||
) -> Result<BoxedStream, String> {
|
||||
let Some(config) = tls else {
|
||||
return Ok(Box::new(tcp));
|
||||
};
|
||||
let stream = tokio::time::timeout(timeout, TlsAcceptor::from(config.clone()).accept(tcp))
|
||||
.await
|
||||
.map_err(|_| "TLS handshake timed out".to_owned())?
|
||||
.map_err(|error| error.to_string())?;
|
||||
verify_alpn(stream.get_ref().1.alpn_protocol(), http_version)?;
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
|
||||
fn verify_alpn(negotiated: Option<&[u8]>, http_version: HttpVersion) -> Result<(), String> {
|
||||
if http_version == HttpVersion::H2 && negotiated != Some(http_version.alpn()) {
|
||||
return Err("TLS peer did not negotiate h2".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn certificates(path: impl AsRef<Path>) -> Result<Vec<CertificateDer<'static>>, String> {
|
||||
let path = path.as_ref();
|
||||
let bytes = std::fs::read(path)
|
||||
.map_err(|error| format!("cannot read certificate {}: {error}", path.display()))?;
|
||||
let certificates = CertificateDer::pem_slice_iter(&bytes)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| error.to_string())?;
|
||||
if certificates.is_empty() {
|
||||
return Err(format!(
|
||||
"certificate {} contains no certificates",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
Ok(certificates)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
pub struct Settings(Map<String, Value>);
|
||||
|
||||
impl Settings {
|
||||
pub fn load() -> Result<Self, String> {
|
||||
let executable = std::env::current_exe()
|
||||
.map_err(|error| format!("cannot locate benchmark executable: {error}"))?;
|
||||
let root = executable
|
||||
.parent()
|
||||
.ok_or("benchmark executable has no parent")?;
|
||||
Self::read(&root.join("config/setting.json"))
|
||||
}
|
||||
|
||||
pub fn check() -> Result<(), String> {
|
||||
println!("{{\"workers\":{}}}", Self::load()?.workers()?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read(path: &Path) -> Result<Self, String> {
|
||||
let bytes = std::fs::read(path)
|
||||
.map_err(|error| format!("cannot read {}: {error}", path.display()))?;
|
||||
let mut document: Map<String, Value> = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| format!("invalid {}: {error}", path.display()))?;
|
||||
let Some(Value::Object(fields)) = document.remove("benchmark") else {
|
||||
return Err("config/setting.json must contain a benchmark object".into());
|
||||
};
|
||||
for key in fields.keys() {
|
||||
if !matches!(
|
||||
key.as_str(),
|
||||
"workers"
|
||||
| "connections"
|
||||
| "task_capacity"
|
||||
| "timeout_ms"
|
||||
| "lifetime_ms"
|
||||
| "http2_streams"
|
||||
| "http2_stream_window_bytes"
|
||||
| "http2_connection_window_bytes"
|
||||
| "cert_path"
|
||||
| "key_path"
|
||||
| "orm_count"
|
||||
) {
|
||||
return Err(format!("unknown benchmark setting '{key}'"));
|
||||
}
|
||||
}
|
||||
Ok(Self(fields))
|
||||
}
|
||||
|
||||
pub fn integer(
|
||||
&self,
|
||||
name: &str,
|
||||
default: usize,
|
||||
minimum: usize,
|
||||
maximum: usize,
|
||||
) -> Result<usize, String> {
|
||||
let value = match self.value(name) {
|
||||
None => default,
|
||||
Some(value) => value
|
||||
.as_u64()
|
||||
.and_then(|value| usize::try_from(value).ok())
|
||||
.ok_or_else(|| format!("benchmark.{name} must be an unsigned integer"))?,
|
||||
};
|
||||
if !(minimum..=maximum).contains(&value) {
|
||||
return Err(format!(
|
||||
"benchmark.{name} must be between {minimum} and {maximum}"
|
||||
));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub fn workers(&self) -> Result<usize, String> {
|
||||
self.integer("workers", 1, 1, 64)
|
||||
}
|
||||
|
||||
pub fn value(&self, name: &str) -> Option<&Value> {
|
||||
self.0.get(name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Ordinary argv/stdin/stdout fixture: deliberately has no Dever protocol.
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
fn main() {
|
||||
match run() {
|
||||
Ok(code) => std::process::exit(code),
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
if matches!(
|
||||
std::env::args().nth(1).as_deref(),
|
||||
Some("tree" | "wait" | "heartbeat")
|
||||
) && let Some(directory) = std::env::args().nth(2)
|
||||
{
|
||||
let _ = std::fs::write(
|
||||
Path::new(&directory).join("fixture-error"),
|
||||
error.to_string(),
|
||||
);
|
||||
}
|
||||
std::process::exit(91);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<i32, Box<dyn std::error::Error>> {
|
||||
let mut arguments = std::env::args().skip(1);
|
||||
match arguments.next().as_deref() {
|
||||
Some("echo") => {
|
||||
for argument in arguments {
|
||||
std::io::stdout().write_all(argument.as_bytes())?;
|
||||
std::io::stdout().write_all(&[0])?;
|
||||
}
|
||||
}
|
||||
Some("stdin") => {
|
||||
std::io::copy(&mut std::io::stdin(), &mut std::io::stdout())?;
|
||||
std::io::stderr().write_all(&[0, 255, 128, 10])?;
|
||||
}
|
||||
Some("exit") => return Ok(arguments.next().ok_or("missing exit code")?.parse()?),
|
||||
Some("fds") => {
|
||||
let descriptors = std::fs::read_dir("/proc/self/fd")?
|
||||
.map(|entry| entry.map(|entry| entry.path()))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
for descriptor in descriptors {
|
||||
let Some(number) = descriptor
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.and_then(|name| name.parse::<u32>().ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if number > 2 && std::fs::read_link(&descriptor).is_ok() {
|
||||
writeln!(std::io::stdout(), "{number}")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("duplex") => {
|
||||
let count: usize = arguments.next().ok_or("missing output length")?.parse()?;
|
||||
let stdout = std::thread::spawn(move || std::io::stdout().write_all(&vec![254; count]));
|
||||
let stderr = std::thread::spawn(move || std::io::stderr().write_all(&vec![253; count]));
|
||||
let mut input = Vec::new();
|
||||
std::io::stdin().read_to_end(&mut input)?;
|
||||
stdout.join().map_err(|_| "stdout thread panicked")??;
|
||||
stderr.join().map_err(|_| "stderr thread panicked")??;
|
||||
}
|
||||
Some("wait") | Some("tree") => {
|
||||
let directory = arguments.next().ok_or("missing marker directory")?;
|
||||
let directory = Path::new(&directory);
|
||||
let mut starts = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(directory.join("starts"))?;
|
||||
starts.write_all(b"started\n")?;
|
||||
// Every lifecycle case gets a real descendant. PID namespace exit
|
||||
// must stop its heartbeat before the runtime returns cancellation.
|
||||
let mut child = std::process::Command::new(std::env::current_exe()?)
|
||||
.arg("heartbeat")
|
||||
.arg(directory)
|
||||
.spawn()?;
|
||||
std::fs::write(directory.join("ready"), b"ready")?;
|
||||
let status = child.wait()?;
|
||||
return Ok(status.code().unwrap_or(92));
|
||||
}
|
||||
Some("heartbeat") => {
|
||||
let directory = arguments.next().ok_or("missing marker directory")?;
|
||||
let lifetime = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(Path::new(&directory).join("lifetime.lock"))?;
|
||||
lifetime.lock()?;
|
||||
let marker = Path::new(&directory).join("heartbeat");
|
||||
for count in 0..3000 {
|
||||
std::fs::write(&marker, count.to_string())?;
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
_ => return Err("unknown ordinary command mode".into()),
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
use std::io::{self, Read, Write};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const MAX_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
fn main() {
|
||||
if std::env::args().skip(1).collect::<Vec<_>>() != ["--dever-component"] {
|
||||
std::process::exit(2);
|
||||
}
|
||||
if let Err(error) = serve() {
|
||||
eprintln!("component fixture: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn serve() -> Result<(), String> {
|
||||
let hello = read_json()?;
|
||||
require_kind(&hello, "hello")?;
|
||||
let mode = hello
|
||||
.pointer("/setting/mode")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("normal");
|
||||
if mode == "delayed_start" {
|
||||
mark(&hello, "starting")?;
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
let ready = json!({
|
||||
"kind": "ready",
|
||||
"version": field(&hello, "version")?,
|
||||
"port": field(&hello, "port")?,
|
||||
"schema": field(&hello, "schema")?,
|
||||
"adapter": field(&hello, "adapter")?,
|
||||
"capabilities": field(&hello, "capabilities")?,
|
||||
"operations": field(&hello, "operations")?,
|
||||
});
|
||||
match mode {
|
||||
"duplicate_ready" => write_raw(r#"{"kind":"ready","kind":"ready"}"#)?,
|
||||
"capability_mismatch" => {
|
||||
let mut mismatched = ready;
|
||||
mismatched["capabilities"] = json!(["network", "process"]);
|
||||
write_json(&mismatched)?;
|
||||
}
|
||||
_ => write_json(&ready)?,
|
||||
}
|
||||
if matches!(mode, "duplicate_ready" | "capability_mismatch") {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let health = read_json()?;
|
||||
require_kind(&health, "health")?;
|
||||
write_json(&health)?;
|
||||
eprintln!("fixture ready");
|
||||
|
||||
loop {
|
||||
let message = read_json()?;
|
||||
match string_field(&message, "kind")? {
|
||||
"call" => handle_call(&message, mode, &hello)?,
|
||||
"shutdown" => {
|
||||
if mode == "delayed_shutdown" {
|
||||
mark(&hello, "stopping")?;
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
write_json(&json!({"kind": "shutdown"}))?;
|
||||
if mode != "embedded" {
|
||||
mark(&hello, "shutdown")?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
kind => return Err(format!("unexpected {kind} message")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mark(hello: &Value, extension: &str) -> Result<(), String> {
|
||||
let Some(marker) = hello.pointer("/setting/marker").and_then(Value::as_str) else {
|
||||
return Ok(());
|
||||
};
|
||||
let path = std::path::Path::new(marker).with_extension(extension);
|
||||
std::fs::write(path, b"ready").map_err(io_error)
|
||||
}
|
||||
|
||||
fn handle_call(call: &Value, mode: &str, hello: &Value) -> Result<(), String> {
|
||||
let id = field(call, "id")?;
|
||||
// Deliberately malformed business payloads exercise the caller's typed boundary.
|
||||
let payload = match mode {
|
||||
"missing_output" => Some(json!({})),
|
||||
"extra_output" => Some(json!({"value": 7, "unknown": true})),
|
||||
"wrong_output" => Some(json!({"value": "invalid number"})),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(payload) = payload {
|
||||
return write_json(&json!({"kind": "result", "id": id, "payload": payload}));
|
||||
}
|
||||
if matches!(mode, "declared_error" | "invalid_error" | "missing_error") {
|
||||
let identity = hello
|
||||
.pointer("/setting/identity")
|
||||
.ok_or("missing error identity")?;
|
||||
let payload = match mode {
|
||||
"invalid_error" => json!({"reason": 7}),
|
||||
"missing_error" => json!({}),
|
||||
_ => json!({"reason": "fixture"}),
|
||||
};
|
||||
return write_json(
|
||||
&json!({"kind": "error", "id": id, "error": identity, "payload": payload}),
|
||||
);
|
||||
}
|
||||
match string_field(call, "operation")? {
|
||||
"echo" => write_json(&json!({
|
||||
"kind": "result",
|
||||
"id": id,
|
||||
"payload": field(call, "payload")?,
|
||||
})),
|
||||
"read" => write_json(&json!({
|
||||
"kind": "result",
|
||||
"id": id,
|
||||
"payload": {"value": 7},
|
||||
})),
|
||||
"fail" => write_json(&json!({
|
||||
"kind": "error",
|
||||
"id": id,
|
||||
"error": "notification.delivery.Rejected",
|
||||
"payload": {"reason": "fixture"},
|
||||
})),
|
||||
"wait" => {
|
||||
let cancel = read_json()?;
|
||||
require_kind(&cancel, "cancel")?;
|
||||
if field(&cancel, "id")? != id {
|
||||
return Err("cancel id does not match call".into());
|
||||
}
|
||||
write_json(&json!({
|
||||
"kind": "error",
|
||||
"id": id,
|
||||
"error": "notification.delivery.Cancelled",
|
||||
"payload": {},
|
||||
}))
|
||||
}
|
||||
"timeout" => {
|
||||
let cancel = read_json()?;
|
||||
require_kind(&cancel, "cancel")?;
|
||||
if field(&cancel, "id")? != id {
|
||||
return Err("cancel id does not match timed out call".into());
|
||||
}
|
||||
write_json(&json!({"kind": "result", "id": id, "payload": {}}))
|
||||
}
|
||||
"crash" => std::process::exit(7),
|
||||
"duplicate" => write_raw(&format!(
|
||||
r#"{{"kind":"result","id":{id},"id":{id},"payload":{{}}}}"#,
|
||||
id = id
|
||||
)),
|
||||
"out_of_order" => {
|
||||
let id = id.as_u64().ok_or_else(|| "id is not u64".to_owned())?;
|
||||
write_json(&json!({"kind": "result", "id": id + 1, "payload": {}}))
|
||||
}
|
||||
"unknown" => write_json(&json!({"kind": "surprise", "id": id})),
|
||||
"oversize" => {
|
||||
let mut stdout = io::stdout().lock();
|
||||
stdout
|
||||
.write_all(&((MAX_BYTES as u32) + 1).to_be_bytes())
|
||||
.map_err(io_error)?;
|
||||
stdout.flush().map_err(io_error)?;
|
||||
std::process::exit(0);
|
||||
}
|
||||
"truncated" => {
|
||||
let mut stdout = io::stdout().lock();
|
||||
stdout.write_all(&32_u32.to_be_bytes()).map_err(io_error)?;
|
||||
stdout.write_all(b"{").map_err(io_error)?;
|
||||
stdout.flush().map_err(io_error)?;
|
||||
std::process::exit(0);
|
||||
}
|
||||
operation => Err(format!("unknown operation {operation}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_json() -> Result<Value, String> {
|
||||
let text = read_frame()?;
|
||||
serde_json::from_str(&text).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn read_frame() -> Result<String, String> {
|
||||
let mut stdin = io::stdin().lock();
|
||||
let mut length = [0_u8; 4];
|
||||
stdin.read_exact(&mut length).map_err(io_error)?;
|
||||
let length = u32::from_be_bytes(length) as usize;
|
||||
if length == 0 || length > MAX_BYTES {
|
||||
return Err("invalid frame length".into());
|
||||
}
|
||||
let mut body = vec![0_u8; length];
|
||||
stdin.read_exact(&mut body).map_err(io_error)?;
|
||||
String::from_utf8(body).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn write_json(value: &Value) -> Result<(), String> {
|
||||
write_raw(&serde_json::to_string(value).map_err(|error| error.to_string())?)
|
||||
}
|
||||
|
||||
fn write_raw(body: &str) -> Result<(), String> {
|
||||
let length = u32::try_from(body.len()).map_err(|_| "fixture frame is too large")?;
|
||||
let mut stdout = io::stdout().lock();
|
||||
stdout.write_all(&length.to_be_bytes()).map_err(io_error)?;
|
||||
stdout.write_all(body.as_bytes()).map_err(io_error)?;
|
||||
stdout.flush().map_err(io_error)
|
||||
}
|
||||
|
||||
fn require_kind(message: &Value, expected: &str) -> Result<(), String> {
|
||||
if string_field(message, "kind")? == expected {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("expected {expected} message"))
|
||||
}
|
||||
}
|
||||
|
||||
fn string_field<'a>(message: &'a Value, name: &str) -> Result<&'a str, String> {
|
||||
field(message, name)?
|
||||
.as_str()
|
||||
.ok_or_else(|| format!("{name} is not a string"))
|
||||
}
|
||||
|
||||
fn field<'a>(message: &'a Value, name: &str) -> Result<&'a Value, String> {
|
||||
message.get(name).ok_or_else(|| format!("missing {name}"))
|
||||
}
|
||||
|
||||
fn io_error(error: io::Error) -> String {
|
||||
error.to_string()
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
type Buffer {
|
||||
text: Text
|
||||
count: Int
|
||||
}
|
||||
|
||||
type Index {
|
||||
values: Map<Int, Int>
|
||||
next: Int
|
||||
}
|
||||
|
||||
double(value: Int, text: Text) (next: Text) {
|
||||
next = text + text
|
||||
}
|
||||
|
||||
grow(value: Int, state: Buffer) (next: Buffer) {
|
||||
next = state
|
||||
next.text = state.text + "a"
|
||||
next.count = state.count + 1
|
||||
}
|
||||
|
||||
insert(value: Int, state: Index) (next: Index) {
|
||||
next = state
|
||||
next.values = put(state.values, state.next, value)
|
||||
next.next = state.next + 1
|
||||
}
|
||||
|
||||
word(value: Int) (result: Text) {
|
||||
result = "abcdefgh你好"
|
||||
}
|
||||
|
||||
copy(value: Text) (result: Text) {
|
||||
result = value
|
||||
}
|
||||
|
||||
clock(value: dever.time.ClockResult.Read(nanos)) (result: Int) recover("benchmark clock failures are reported") {
|
||||
result = nanos
|
||||
}
|
||||
|
||||
clock(value: dever.time.ClockResult.Failed(message)) (result: Int) recover("benchmark clock failures are reported") {
|
||||
dever.io.println("FAILED:" + message)
|
||||
result = 0
|
||||
}
|
||||
|
||||
json(value: dever.json.value.ParseResult.Parsed(document)) (result: Int) recover("benchmark parse failures are reported") {
|
||||
result = length(document.nodes)
|
||||
}
|
||||
|
||||
json(value: dever.json.value.ParseResult.Failed(position, message)) (result: Int) recover("benchmark parse failures are reported") {
|
||||
dever.io.println("FAILED:" + message)
|
||||
result = 0
|
||||
}
|
||||
|
||||
sample(index: Int, bytes: Bytes) () {
|
||||
start = clock(result(dever.time.monotonic_nanos()))
|
||||
buffer = reduce(
|
||||
grow,
|
||||
bytes,
|
||||
Buffer {
|
||||
text = ""
|
||||
count = 0
|
||||
}
|
||||
)
|
||||
text_end = clock(result(dever.time.monotonic_nanos()))
|
||||
mapped = each(copy, each(word, bytes))
|
||||
list_end = clock(result(dever.time.monotonic_nanos()))
|
||||
document = json(result(dever.json.parse("\"" + buffer.text + "\"")))
|
||||
json_end = clock(result(dever.time.monotonic_nanos()))
|
||||
dever.io.println(
|
||||
"text|" + int.to_text(buffer.count) + "|" + int.to_text(text_end - start)
|
||||
)
|
||||
dever.io.println(
|
||||
"list|" + int.to_text(length(mapped)) + "|" + int.to_text(list_end - text_end)
|
||||
)
|
||||
dever.io.println(
|
||||
"json|" + int.to_text(document) + "|" + int.to_text(json_end - list_end)
|
||||
)
|
||||
}
|
||||
|
||||
run() () {
|
||||
text = reduce(double, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], "a")
|
||||
each(sample, [0, 1, 2, 3, 4, 5], dever.bytes.from_text(text))
|
||||
start = clock(result(dever.time.monotonic_nanos()))
|
||||
index = reduce(
|
||||
insert,
|
||||
dever.bytes.from_text(text),
|
||||
Index {
|
||||
values = {}
|
||||
next = 0
|
||||
}
|
||||
)
|
||||
end = clock(result(dever.time.monotonic_nanos()))
|
||||
dever.io.println(
|
||||
"map|" + int.to_text(length(index.values)) + "|" + int.to_text(end - start)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
main() () {
|
||||
benchmark.allocation.run()
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
cmd fixture_member = app.fixture_member
|
||||
@@ -0,0 +1,27 @@
|
||||
fixture_member(
|
||||
selected_tenant_id: platform.tenant.model.id,
|
||||
email: Text,
|
||||
display_name: Text,
|
||||
password: Secret
|
||||
) (result: BootstrapView) {
|
||||
input = domain.prepare(email, display_name)
|
||||
hash = blocking(dever.crypto.password_hash(password))
|
||||
principal = model.principal.create(
|
||||
{
|
||||
display_name = input.name
|
||||
}
|
||||
)
|
||||
stored = model.create(
|
||||
{
|
||||
principal_id = principal.id
|
||||
email = input.address
|
||||
password_hash = hash
|
||||
}
|
||||
)
|
||||
user.membership.fixture_join(principal.id, selected_tenant_id)
|
||||
result = BootstrapView {
|
||||
tenant_id = selected_tenant_id
|
||||
user_id = principal.id
|
||||
credential_id = stored.id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
fixture_join(
|
||||
account_id: user.account.model.principal.id,
|
||||
selected_tenant_id: platform.tenant.model.id
|
||||
) () {
|
||||
stored = model.create(
|
||||
{
|
||||
user_id = account_id
|
||||
tenant_id = selected_tenant_id
|
||||
role = model.MembershipRole.Reader
|
||||
}
|
||||
)
|
||||
discarded = stored
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBmTCCAT+gAwIBAgIUYx/Ad4tJiQ4uakAPbpXu5Senv2YwCgYIKoZIzj0EAwIw
|
||||
GjEYMBYGA1UEAwwPRGV2ZXItVGVzdC1Sb290MB4XDTI2MDkxMjA4MTY1MFoXDTM2
|
||||
MDkwOTA4MTY1MFowGjEYMBYGA1UEAwwPRGV2ZXItVGVzdC1Sb290MFkwEwYHKoZI
|
||||
zj0CAQYIKoZIzj0DAQcDQgAECOux92VpsFwb2YOTRs6UATfp7HPm+JeCTMkJNXPo
|
||||
rqLZa4Vi/71dLQzNFxxsXxqFyf4oBYoCD0XMEicxYUgnO6NjMGEwHQYDVR0OBBYE
|
||||
FIo/Vdu7qm5dIikkwg6A0yRp1YzVMB8GA1UdIwQYMBaAFIo/Vdu7qm5dIikkwg6A
|
||||
0yRp1YzVMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49
|
||||
BAMCA0gAMEUCICmmB/eG055dU7F9OP7K9BUxOkFnYNtzIpYJfd0VwG6LAiEA4oiP
|
||||
pnep4mLGHAnvsucvvqDA5SVdHzYh22OYPdSgNeY=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,5 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgYl2wcdhzZMZrQxSV
|
||||
ClBRv8AQqRyuJf5bSVXw5w6XLWmhRANCAAROwX6ZplJMfQicbaDXSZ7qoLPGDdr6
|
||||
EMtvQvlJdwVI44/NVX/lIS2dhF0h1GxKAippeSiLj9+Srh2mmg7+zEvK
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,10 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBdjCCARugAwIBAgIBATAKBggqhkjOPQQDAjAaMRgwFgYDVQQDDA9EZXZlci1U
|
||||
ZXN0LVJvb3QwHhcNMjYwOTEyMDgxNjUwWhcNMzYwOTA5MDgxNjUwWjAUMRIwEAYD
|
||||
VQQDDAlsb2NhbGhvc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAROwX6ZplJM
|
||||
fQicbaDXSZ7qoLPGDdr6EMtvQvlJdwVI44/NVX/lIS2dhF0h1GxKAippeSiLj9+S
|
||||
rh2mmg7+zEvKo1gwVjAUBgNVHREEDTALgglsb2NhbGhvc3QwHQYDVR0OBBYEFEc4
|
||||
R7JUtHLl16fHXxBvGDz0qwnKMB8GA1UdIwQYMBaAFIo/Vdu7qm5dIikkwg6A0yRp
|
||||
1YzVMAoGCCqGSM49BAMCA0kAMEYCIQCzl7jB3XrkRoQXTXMoi9j1OzmCPO3UXLr5
|
||||
eDq/F8G/twIhALgVU/5C4hx6hpVtlHUmd49EibizuPlNeaCjRPB1WqDM
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,250 @@
|
||||
use dever_runtime::api::{self, CookieOptions, SameSite};
|
||||
use dever_runtime::bytes::Bytes;
|
||||
use dever_runtime::http::{Header, Request};
|
||||
use dever_runtime::{http, net, task};
|
||||
use std::time::Duration;
|
||||
|
||||
fn request(path: &str, cookie: &str) -> Request {
|
||||
Request {
|
||||
method: "GET".into(),
|
||||
target: path.into(),
|
||||
headers: vec![Header {
|
||||
name: "cookie".into(),
|
||||
value: Bytes::from_text(cookie),
|
||||
}],
|
||||
body: Bytes::from_text(""),
|
||||
}
|
||||
}
|
||||
|
||||
fn cookie_options() -> CookieOptions {
|
||||
CookieOptions {
|
||||
path: "/".into(),
|
||||
domain: None,
|
||||
same_site: SameSite::Lax,
|
||||
secure: true,
|
||||
http_only: true,
|
||||
max_age: Some(3600),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_scope_is_isolated_and_merges_validated_response_metadata() {
|
||||
let first = api::scoped_request(
|
||||
request("/first?ignored=1", "session=v1.Zmlyc3Q; theme=dark"),
|
||||
|_| async {
|
||||
assert_eq!(api::method().unwrap(), "GET");
|
||||
assert_eq!(api::path().unwrap(), "/first");
|
||||
assert!(api::request_id().unwrap().parse::<u64>().is_ok());
|
||||
assert!(api::header("cookie").is_err());
|
||||
assert!(api::header("x-forwarded-for").is_err());
|
||||
assert_eq!(api::cookie("theme").unwrap().as_deref(), Some("dark"));
|
||||
assert!(api::cookie("session").is_err());
|
||||
assert_eq!(api::cookie("missing").unwrap(), None);
|
||||
let incoming = api::secret_cookie("session").unwrap().unwrap();
|
||||
assert_eq!(format!("{incoming:?}"), "Secret([REDACTED])");
|
||||
api::response_header("x-request-state", "first").unwrap();
|
||||
api::response_secret_cookie(
|
||||
"session",
|
||||
dever_runtime::secret::Secret::from_input(vec![0, 255, 0]),
|
||||
cookie_options(),
|
||||
)
|
||||
.unwrap();
|
||||
api::response_secret_cookie("copy", incoming, cookie_options()).unwrap();
|
||||
api::response_cookie("theme", "dark", cookie_options()).unwrap();
|
||||
let mut insecure = cookie_options();
|
||||
insecure.secure = false;
|
||||
assert!(
|
||||
api::response_secret_cookie(
|
||||
"session",
|
||||
dever_runtime::secret::Secret::from_input(b"unsafe".to_vec()),
|
||||
insecure,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
api::commit_response_metadata().unwrap();
|
||||
Ok(api::success(dever_runtime::wire::Encoded::null()))
|
||||
},
|
||||
);
|
||||
let second = api::scoped_request(request("/second", "session=v1.c2Vjb25k"), |_| async {
|
||||
assert_eq!(api::path().unwrap(), "/second");
|
||||
assert!(api::response_header("set-cookie", "bypass").is_err());
|
||||
assert!(api::response_header("content-type", "text/html").is_err());
|
||||
assert!(api::response_header("x-unsafe", "injected\r\nheader").is_err());
|
||||
api::response_header("x-request-state", "second").unwrap();
|
||||
api::commit_response_metadata().unwrap();
|
||||
Ok(api::success(dever_runtime::wire::Encoded::null()))
|
||||
});
|
||||
let (first, second) = tokio::join!(first, second);
|
||||
let first = first.unwrap();
|
||||
let second = second.unwrap();
|
||||
assert!(
|
||||
first
|
||||
.headers
|
||||
.iter()
|
||||
.any(|header| header.name == "x-request-state" && header.value.values() == b"first")
|
||||
);
|
||||
assert!(
|
||||
second
|
||||
.headers
|
||||
.iter()
|
||||
.any(|header| header.name == "x-request-state" && header.value.values() == b"second")
|
||||
);
|
||||
assert!(first.headers.iter().any(|header| {
|
||||
header.name == "set-cookie"
|
||||
&& header
|
||||
.value
|
||||
.values()
|
||||
.starts_with(b"session=v1.AP8A; Path=/")
|
||||
}));
|
||||
assert!(first.headers.iter().any(|header| {
|
||||
header.name == "set-cookie"
|
||||
&& header
|
||||
.value
|
||||
.values()
|
||||
.starts_with(b"copy=v1.Zmlyc3Q; Path=/")
|
||||
}));
|
||||
assert!(
|
||||
first
|
||||
.headers
|
||||
.iter()
|
||||
.any(|header| header.name == "set-cookie"
|
||||
&& header.value.values().starts_with(b"theme=dark; Path=/"))
|
||||
);
|
||||
assert!(
|
||||
!second
|
||||
.headers
|
||||
.iter()
|
||||
.any(|header| header.name == "set-cookie")
|
||||
);
|
||||
assert!(api::request_id().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_or_duplicate_cookie_is_a_bad_request_before_handler_execution() {
|
||||
for cookie in ["session=one; session=two", "session=one; broken"] {
|
||||
let result = api::scoped_request(request("/profile", cookie), |_| async {
|
||||
Err("malformed Cookie reached the API handler".into())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.status, 400);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quoted_cookie_value_uses_the_same_secret_codec() {
|
||||
let response = api::scoped_request(request("/profile", "session=\"v1.Zmlyc3Q\""), |_| async {
|
||||
let token = api::secret_cookie("session")?.ok_or("missing quoted session")?;
|
||||
api::response_secret_cookie("copy", token, cookie_options())?;
|
||||
api::commit_response_metadata()?;
|
||||
Ok(api::success(dever_runtime::wire::Encoded::null()))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(response.headers.iter().any(|header| {
|
||||
header.name == "set-cookie"
|
||||
&& header
|
||||
.value
|
||||
.values()
|
||||
.starts_with(b"copy=v1.Zmlyc3Q; Path=/")
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uncommitted_response_metadata_is_discarded_on_failure() {
|
||||
let response = api::scoped_request(request("/profile", ""), |_| async {
|
||||
api::response_header("x-staged", "discarded")?;
|
||||
Ok(api::internal_error())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status, 500);
|
||||
assert!(
|
||||
!response
|
||||
.headers
|
||||
.iter()
|
||||
.any(|header| header.name == "x-staged")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_and_model_text_validation_reject_invalid_values() {
|
||||
assert!(api::text_bounds("name", Some(""), 1, Some(64)).is_err());
|
||||
assert!(api::text_bounds("name", Some("abc"), 1, Some(2)).is_err());
|
||||
assert!(api::text_bounds("name", None, 1, Some(2)).is_ok());
|
||||
assert!(api::response_cookie("session", "a;b", cookie_options()).is_err());
|
||||
assert!(api::response_cookie("session", "valid", cookie_options()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_address_comes_from_the_tcp_peer_not_forwarded_headers() {
|
||||
let limits = http::Limits {
|
||||
header_bytes: 8192,
|
||||
body_bytes: 1024,
|
||||
timeout_ms: 1000,
|
||||
connections: 2,
|
||||
http2: None,
|
||||
};
|
||||
task::run_entry_with(
|
||||
task::RuntimeConfig {
|
||||
worker_threads: 1,
|
||||
max_blocking_threads: 2,
|
||||
task_capacity: 16,
|
||||
},
|
||||
async {
|
||||
tokio::time::timeout(Duration::from_secs(8), async {
|
||||
let listener = net::listen("127.0.0.1", 0).await?;
|
||||
let port = net::port(&listener)?;
|
||||
let owned = listener.clone();
|
||||
let server = task::run(async move {
|
||||
http::serve(
|
||||
|request| {
|
||||
api::scoped_request(request, |_| async {
|
||||
let peer =
|
||||
api::client_address()?.ok_or("missing TCP peer address")?;
|
||||
let mut body = dever_runtime::wire::Encoder::default();
|
||||
body.text(&peer)?;
|
||||
Ok(api::success(body.finish()?))
|
||||
})
|
||||
},
|
||||
owned,
|
||||
limits,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await?;
|
||||
let response = http::send(
|
||||
"127.0.0.1",
|
||||
port,
|
||||
Request {
|
||||
method: "GET".into(),
|
||||
target: "/peer".into(),
|
||||
headers: vec![
|
||||
Header {
|
||||
name: "host".into(),
|
||||
value: Bytes::from_text("localhost"),
|
||||
},
|
||||
Header {
|
||||
name: "x-forwarded-for".into(),
|
||||
value: Bytes::from_text("203.0.113.99"),
|
||||
},
|
||||
],
|
||||
body: Bytes::new(vec![]),
|
||||
},
|
||||
limits,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(response.status, 200);
|
||||
let envelope: serde_json::Value = serde_json::from_slice(response.body.values())
|
||||
.map_err(|error| error.to_string())?;
|
||||
assert_eq!(envelope["data"], "127.0.0.1");
|
||||
task::stop(server).await?;
|
||||
listener.close()?;
|
||||
Ok::<(), String>(())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "API peer test deadline exceeded".to_owned())?
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,933 @@
|
||||
#[path = "support/temp.rs"]
|
||||
mod temp;
|
||||
|
||||
use std::fs;
|
||||
|
||||
use dever_core::source::SourceMap;
|
||||
use dever_runtime::config::Settings;
|
||||
|
||||
fn sources(files: &[(&str, &str)]) -> SourceMap {
|
||||
let mut sources = SourceMap::default();
|
||||
for (path, source) in files {
|
||||
sources.add(*path, *source);
|
||||
}
|
||||
sources
|
||||
}
|
||||
|
||||
fn check(files: &[(&str, &str)]) {
|
||||
let sources = sources(files);
|
||||
dever_core::check(&sources).unwrap_or_else(|errors| {
|
||||
panic!(
|
||||
"{}",
|
||||
errors
|
||||
.iter()
|
||||
.map(|error| error.render(&sources))
|
||||
.collect::<String>()
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn reject(files: &[(&str, &str)], expected: &str) {
|
||||
let sources = sources(files);
|
||||
let errors = dever_core::check(&sources).expect_err("source must be rejected");
|
||||
let rendered = errors
|
||||
.iter()
|
||||
.map(|error| error.render(&sources))
|
||||
.collect::<String>();
|
||||
assert!(
|
||||
rendered.contains(expected),
|
||||
"expected {expected}: {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
fn auth_settings() -> (temp::TemporaryDirectory, Settings) {
|
||||
let root = temp::TemporaryDirectory::new();
|
||||
fs::create_dir(root.path().join("config")).unwrap();
|
||||
fs::write(
|
||||
root.path().join("config/setting.json"),
|
||||
r#"{
|
||||
"auth": {
|
||||
"providers": {
|
||||
"session": {
|
||||
"verify": "user.account.verify",
|
||||
"jwtSecret": "0123456789abcdef0123456789abcdef"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sites": {
|
||||
"admin": {"path": "admin", "auth": "session"}
|
||||
},
|
||||
"database": {
|
||||
"default": {
|
||||
"type": "sqlite",
|
||||
"path": "data/control.db",
|
||||
"tenant_directory": "data/tenants"
|
||||
}
|
||||
},
|
||||
"tenant": {
|
||||
"database": "default"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let settings = Settings::load_project(root.path()).unwrap();
|
||||
(root, settings)
|
||||
}
|
||||
|
||||
fn multi_provider_settings() -> (temp::TemporaryDirectory, Settings) {
|
||||
let (root, _) = auth_settings();
|
||||
let setting_path = root.path().join("config/setting.json");
|
||||
let mut settings: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&setting_path).unwrap()).unwrap();
|
||||
settings["auth"]["providers"]["other"] = serde_json::json!({
|
||||
"verify": "admin.principal.verify",
|
||||
"jwtSecret": "0123456789abcdef0123456789abcdef",
|
||||
});
|
||||
settings["sites"]["other"] = serde_json::json!({"path": "other", "auth": "other"});
|
||||
fs::write(&setting_path, settings.to_string()).unwrap();
|
||||
let settings = Settings::load_project(root.path()).unwrap();
|
||||
(root, settings)
|
||||
}
|
||||
|
||||
fn identity_sources() -> [(&'static str, &'static str); 3] {
|
||||
[
|
||||
(
|
||||
"user/account/model.dever",
|
||||
"global type Account { email: Text(1, 254) }",
|
||||
),
|
||||
(
|
||||
"platform/tenant/model.dever",
|
||||
"global type Tenant { name: Text(1, 64) }",
|
||||
),
|
||||
(
|
||||
"user/account/app.dever",
|
||||
r#"type Identity {
|
||||
id: Text
|
||||
user_id: user.account.model.id?
|
||||
tenant_id: platform.tenant.model.id?
|
||||
}
|
||||
verify(claims: dever.auth.Claims) (identity: Identity) {
|
||||
identity = Identity {
|
||||
id = claims.subject
|
||||
user_id = null
|
||||
tenant_id = null
|
||||
}
|
||||
}
|
||||
profile() (response: Text) {
|
||||
response = dever.site.key()
|
||||
}"#,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn configured_check(
|
||||
files: &[(&str, &str)],
|
||||
settings: &Settings,
|
||||
) -> Result<dever_core::hir::Program, String> {
|
||||
let sources = sources(files);
|
||||
dever_core::check_with_settings(&sources, settings).map_err(|errors| {
|
||||
errors
|
||||
.iter()
|
||||
.map(|error| error.render(&sources))
|
||||
.collect::<String>()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_bindings_inherit_app_signatures_without_response_name_requirement() {
|
||||
check(&[
|
||||
(
|
||||
"user/account/app.dever",
|
||||
"greet(name: Text) (greeting: Text) { greeting = text.trim(name) }",
|
||||
),
|
||||
(
|
||||
"user/account/api.dever",
|
||||
"get greeting = app.greet\npost greeting = app.greet\nput greeting = app.greet\ndelete greeting = app.greet\ncmd greeting = app.greet",
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_sites_bind_nested_api_directories_to_checked_auth_hooks() {
|
||||
let (_root, settings) = auth_settings();
|
||||
let mut files = identity_sources().to_vec();
|
||||
files.push((
|
||||
"user/account/api/admin/profile.dever",
|
||||
"public get profile = app.profile",
|
||||
));
|
||||
|
||||
let source_map = sources(&files);
|
||||
let program =
|
||||
dever_core::check_with_settings(&source_map, &settings).unwrap_or_else(|errors| {
|
||||
panic!(
|
||||
"{}",
|
||||
errors
|
||||
.iter()
|
||||
.map(|error| error.render(&source_map))
|
||||
.collect::<String>()
|
||||
)
|
||||
});
|
||||
let generated = dever_core::native::emit_project(
|
||||
&program,
|
||||
&source_map,
|
||||
dever_runtime::config::RuntimeProfile::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(generated.contains("dever_runtime::auth::prepare(&[\"admin\"]"));
|
||||
assert!(generated.contains("dever_runtime::auth::scope(auth_prepared, auth_identity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_sites_reject_unmapped_api_directories_and_invalid_verify_records() {
|
||||
let (_root, settings) = auth_settings();
|
||||
let mut unmapped = identity_sources().to_vec();
|
||||
unmapped.push((
|
||||
"user/account/api/front/profile.dever",
|
||||
"get profile = app.profile",
|
||||
));
|
||||
let error = configured_check(&unmapped, &settings).unwrap_err();
|
||||
assert!(
|
||||
error.contains("not assigned to a configured site"),
|
||||
"{error}"
|
||||
);
|
||||
|
||||
let invalid_app = identity_sources()[2]
|
||||
.1
|
||||
.replace("tenant_id: platform.tenant.model.id?", "tenant_id: Int?");
|
||||
let mut invalid = identity_sources().to_vec();
|
||||
invalid[2] = ("user/account/app.dever", &invalid_app);
|
||||
invalid.push((
|
||||
"user/account/api/admin/profile.dever",
|
||||
"get profile = app.profile",
|
||||
));
|
||||
let error = configured_check(&invalid, &settings).unwrap_err();
|
||||
assert!(
|
||||
error.contains("identity must be an App-owned public record"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_is_read_only() {
|
||||
let (_root, settings) = auth_settings();
|
||||
let writing_verify = identity_sources()[2].1.replace(
|
||||
"identity = Identity {",
|
||||
"account = model.create({ email = claims.subject })\n identity = Identity {",
|
||||
);
|
||||
let mut writing = identity_sources().to_vec();
|
||||
writing[2] = ("user/account/app.dever", &writing_verify);
|
||||
writing.push((
|
||||
"user/account/api/admin/profile.dever",
|
||||
"get profile = app.profile",
|
||||
));
|
||||
let error = configured_check(&writing, &settings).unwrap_err();
|
||||
assert!(
|
||||
error.contains("authentication verify cannot write"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_identity_getters_preserve_nominal_ids_and_nullable_representation() {
|
||||
let (_root, settings) = auth_settings();
|
||||
let mut files = identity_sources().to_vec();
|
||||
files.extend([
|
||||
(
|
||||
"news/reader/app.dever",
|
||||
r#"
|
||||
type Profile {
|
||||
user: user.account.model.id?
|
||||
tenant: platform.tenant.model.id?
|
||||
raw: Int?
|
||||
}
|
||||
profile() (profile: Profile) {
|
||||
profile = Profile {
|
||||
user = dever.auth.user_id()
|
||||
tenant = dever.auth.tenant_id()
|
||||
raw = dever.auth.user_id()
|
||||
}
|
||||
}
|
||||
current_user() (user: user.account.model.id) {
|
||||
user = require_user(dever.auth.user_id())
|
||||
}
|
||||
require_user(value: user.account.model.id) (user: user.account.model.id) {
|
||||
user = value
|
||||
}
|
||||
require_user(value: null) (user: user.account.model.id) {
|
||||
fail(dever.api.Error.Unauthorized)
|
||||
}
|
||||
owns(id: user.account.model.id) (owned: Bool) {
|
||||
owned = dever.auth.owns_user(id)
|
||||
}
|
||||
"#,
|
||||
),
|
||||
(
|
||||
"news/reader/api/admin/profile.dever",
|
||||
"get profile = app.profile\nget current = app.current_user\nget owns = app.owns",
|
||||
),
|
||||
]);
|
||||
let source_map = sources(&files);
|
||||
dever_core::check(&source_map).expect("source-only checking defers provider binding");
|
||||
let program =
|
||||
dever_core::check_with_settings(&source_map, &settings).unwrap_or_else(|errors| {
|
||||
panic!(
|
||||
"{}",
|
||||
errors
|
||||
.iter()
|
||||
.map(|error| error.render(&source_map))
|
||||
.collect::<String>()
|
||||
)
|
||||
});
|
||||
let native = dever_core::native::emit_project(
|
||||
&program,
|
||||
&source_map,
|
||||
dever_runtime::config::RuntimeProfile {
|
||||
sqlite: true,
|
||||
postgres: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(native.contains("dever_runtime::auth::user_id().map(|value| value.map(T"));
|
||||
assert!(native.contains("dever_runtime::auth::tenant_id().map(|value| value.map(T"));
|
||||
let llvm = dever_core::llvm::emit_application(&program, &source_map).unwrap();
|
||||
assert!(llvm.contains("@dever_rt_v1_auth_user_id"));
|
||||
assert!(llvm.contains("@dever_rt_v1_auth_tenant_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_identity_getters_reject_wrong_ids_and_implicit_casts() {
|
||||
let (_root, settings) = auth_settings();
|
||||
for (app, message) in [
|
||||
(
|
||||
"profile() (value: platform.tenant.model.id?) { value = dever.auth.user_id() }",
|
||||
"nominal ID must match provider",
|
||||
),
|
||||
(
|
||||
"profile() (value: user.account.model.id?) { value = dever.auth.tenant_id() }",
|
||||
"nominal ID must match provider",
|
||||
),
|
||||
(
|
||||
"profile(id: platform.tenant.model.id) (owned: Bool) { owned = dever.auth.owns_user(id) }",
|
||||
"nominal ID must match provider",
|
||||
),
|
||||
(
|
||||
"profile() (value: user.account.model.id) { value = dever.auth.user_id() }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"profile(raw: Int?) (value: user.account.model.id?) { value = raw }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"profile() (value: user.account.model.id?) { value = 1 }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"profile() (value: user.account.model.id?) { raw = dever.auth.user_id()\nvalue = raw }",
|
||||
"C005",
|
||||
),
|
||||
] {
|
||||
let mut files = identity_sources().to_vec();
|
||||
files.extend([
|
||||
("news/reader/app.dever", app),
|
||||
(
|
||||
"news/reader/api/admin/profile.dever",
|
||||
"get profile = app.profile",
|
||||
),
|
||||
]);
|
||||
let error = configured_check(&files, &settings).unwrap_err();
|
||||
assert!(error.contains(message), "{app}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_identity_getters_require_matching_providers_at_every_route() {
|
||||
let (_root, settings) = multi_provider_settings();
|
||||
let other_provider = identity_sources()[2]
|
||||
.1
|
||||
.replace("user.account.model.id?", "admin.principal.model.id?");
|
||||
for app in [
|
||||
"profile() (value: user.account.model.id?) { value = dever.auth.user_id() }",
|
||||
"profile(id: user.account.model.id) (owned: Bool) { owned = dever.auth.owns_user(id) }",
|
||||
] {
|
||||
let mut files = identity_sources().to_vec();
|
||||
files.extend([
|
||||
(
|
||||
"admin/principal/model.dever",
|
||||
"global type Principal { name: Text }",
|
||||
),
|
||||
("admin/principal/app.dever", other_provider.as_str()),
|
||||
("news/reader/app.dever", app),
|
||||
(
|
||||
"news/reader/api/admin/profile.dever",
|
||||
"get profile = app.profile",
|
||||
),
|
||||
(
|
||||
"news/reader/api/other/profile.dever",
|
||||
"get profile = app.profile",
|
||||
),
|
||||
]);
|
||||
let error = configured_check(&files, &settings).unwrap_err();
|
||||
assert!(
|
||||
error.contains("C006") && error.contains("provider 'other'"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_identity_bindings_preserve_matching_nominal_ids() {
|
||||
let (_root, settings) = auth_settings();
|
||||
let mut files = identity_sources().to_vec();
|
||||
files.extend([
|
||||
(
|
||||
"news/article/model.dever",
|
||||
r#"type Article {
|
||||
private owner author: user.account.model.id = dever.auth.user_id()
|
||||
title: Text
|
||||
linked_user: user.account.model.id? create = dever.auth.user_id() replace = dever.auth.user_id()
|
||||
linked_tenant: platform.tenant.model.id? create = dever.auth.tenant_id() replace = dever.auth.tenant_id()
|
||||
}"#,
|
||||
),
|
||||
("news/article/api/admin/manage.dever", "rest model"),
|
||||
]);
|
||||
check(&files);
|
||||
let program = configured_check(&files, &settings).unwrap();
|
||||
let source_map = sources(&files);
|
||||
let native = dever_core::native::emit_project(
|
||||
&program,
|
||||
&source_map,
|
||||
dever_runtime::config::RuntimeProfile {
|
||||
sqlite: true,
|
||||
postgres: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(native.contains("dever_runtime::auth::user_id().map(|value| value.map(T"));
|
||||
assert!(native.contains("dever_runtime::auth::tenant_id().map(|value| value.map(T"));
|
||||
let llvm = dever_core::llvm::emit_application(&program, &source_map).unwrap();
|
||||
assert!(llvm.contains("@dever_rt_v1_auth_user_id"));
|
||||
assert!(llvm.contains("@dever_rt_v1_auth_tenant_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_identity_bindings_reject_wrong_provider_ids() {
|
||||
let (_root, settings) = auth_settings();
|
||||
for field in [
|
||||
"linked: platform.tenant.model.id? create = dever.auth.user_id()",
|
||||
"linked: platform.tenant.model.id? replace = dever.auth.user_id()",
|
||||
"linked: user.account.model.id? create = dever.auth.tenant_id()",
|
||||
"linked: user.account.model.id? replace = dever.auth.tenant_id()",
|
||||
"private owner linked: platform.tenant.model.id = dever.auth.user_id()",
|
||||
"private owner linked: user.account.model.id = dever.auth.tenant_id()",
|
||||
] {
|
||||
let model = format!("type Article {{\n title: Text\n {field}\n}}");
|
||||
let mut files = identity_sources().to_vec();
|
||||
files.extend([
|
||||
("news/article/model.dever", model.as_str()),
|
||||
("news/article/api/admin/manage.dever", "rest model"),
|
||||
]);
|
||||
check(&files);
|
||||
let error = configured_check(&files, &settings).unwrap_err();
|
||||
assert!(
|
||||
error.contains("C006") && error.contains("provider 'session'"),
|
||||
"{field}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_identity_bindings_match_each_sites_provider() {
|
||||
let (_root, settings) = multi_provider_settings();
|
||||
let other_provider = identity_sources()[2]
|
||||
.1
|
||||
.replace("user.account.model.id?", "admin.principal.model.id?");
|
||||
for field in [
|
||||
"linked: user.account.model.id? create = dever.auth.user_id()",
|
||||
"linked: user.account.model.id? replace = dever.auth.user_id()",
|
||||
"private owner linked: user.account.model.id = dever.auth.user_id()",
|
||||
] {
|
||||
let model = format!("type Article {{\n title: Text\n {field}\n}}");
|
||||
let mut files = identity_sources().to_vec();
|
||||
files.extend([
|
||||
(
|
||||
"admin/principal/model.dever",
|
||||
"global type Principal { name: Text }",
|
||||
),
|
||||
("admin/principal/app.dever", other_provider.as_str()),
|
||||
("news/article/model.dever", model.as_str()),
|
||||
("news/article/api/admin/manage.dever", "rest model"),
|
||||
]);
|
||||
configured_check(&files, &settings).expect("first site's provider matches");
|
||||
files.push(("news/article/api/other/manage.dever", "rest model"));
|
||||
let error = configured_check(&files, &settings).unwrap_err();
|
||||
assert!(
|
||||
error.contains("C006") && error.contains("provider 'other'"),
|
||||
"{field}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nominal_identity_getters_do_not_gain_public_cmd_or_job_context() {
|
||||
let (_root, settings) = auth_settings();
|
||||
let app = "profile() (value: user.account.model.id?) { value = dever.auth.user_id() }";
|
||||
for (path, declaration, message) in [
|
||||
(
|
||||
"news/reader/api/admin/profile.dever",
|
||||
"public get profile = app.profile",
|
||||
"requires a protected API",
|
||||
),
|
||||
(
|
||||
"news/reader/api.dever",
|
||||
"cmd profile = app.profile",
|
||||
"not available from CMD or Job",
|
||||
),
|
||||
(
|
||||
"news/reader/job.dever",
|
||||
"database default\njob sample() () retry(3) timeout(1000) { value = app.profile() }",
|
||||
"not available from CMD or Job",
|
||||
),
|
||||
] {
|
||||
let mut files = identity_sources().to_vec();
|
||||
files.extend([("news/reader/app.dever", app), (path, declaration)]);
|
||||
let error = configured_check(&files, &settings).unwrap_err();
|
||||
assert!(error.contains("C006") && error.contains(message), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nominal_identity_getters_remain_unavailable_to_ordinary_tests() {
|
||||
let (_root, settings) = auth_settings();
|
||||
let mut files = identity_sources().to_vec();
|
||||
files.push((
|
||||
"news/reader/app.dever",
|
||||
"profile() (value: user.account.model.id?) { value = dever.auth.user_id() }",
|
||||
));
|
||||
let mut inputs = sources(&files).compilation_sources().unwrap();
|
||||
inputs.push(dever_core::source::CompilationSource {
|
||||
logical_path: "news/reader/probe.dever".into(),
|
||||
is_test: true,
|
||||
text: "probe() () { value = app.profile() }".into(),
|
||||
});
|
||||
let source_map = SourceMap::from_compilation_sources(&inputs).unwrap();
|
||||
let errors = dever_core::check_with_settings(&source_map, &settings).unwrap_err();
|
||||
let rendered = errors
|
||||
.iter()
|
||||
.map(|error| error.render(&source_map))
|
||||
.collect::<String>();
|
||||
assert!(
|
||||
rendered.contains("C006") && rendered.contains("not available from tests"),
|
||||
"{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protected_api_permissions_are_stable_and_unique_across_paths_and_methods() {
|
||||
let (_root, settings) = auth_settings();
|
||||
let mut files = identity_sources().to_vec();
|
||||
files.push((
|
||||
"user/account/api/admin/profile.dever",
|
||||
"get profile = app.profile",
|
||||
));
|
||||
let program = configured_check(&files, &settings).unwrap();
|
||||
assert!(program.api_snapshot().contains(
|
||||
"permission user.account.admin.profile method=GET component=user domain=account site=admin action=profile"
|
||||
));
|
||||
|
||||
files.push((
|
||||
"user/account/api/admin/alternate.dever",
|
||||
"post profile = app.profile",
|
||||
));
|
||||
let error = configured_check(&files, &settings).unwrap_err();
|
||||
assert!(
|
||||
error.contains("duplicate API permission 'user.account.admin.profile'"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_visibility_and_identity_stay_on_http_app_bindings() {
|
||||
reject(
|
||||
&[
|
||||
(
|
||||
"user/account/app.dever",
|
||||
"profile() (response: Text) { response = \"ok\" }",
|
||||
),
|
||||
(
|
||||
"user/account/api/admin/profile.dever",
|
||||
"public cmd profile = app.profile",
|
||||
),
|
||||
],
|
||||
"application visibility is defined by the source role",
|
||||
);
|
||||
reject(
|
||||
&[(
|
||||
"user/account/domain.dever",
|
||||
"profile() (response: Text) { response = dever.auth.id() }",
|
||||
)],
|
||||
"available only from App functions",
|
||||
);
|
||||
reject(
|
||||
&[
|
||||
(
|
||||
"user/account/app.dever",
|
||||
"manage() (ok: Bool) { dever.auth.disable_role(\"reader\")\nok = true }",
|
||||
),
|
||||
(
|
||||
"user/account/api/admin/manage.dever",
|
||||
"public post manage = app.manage",
|
||||
),
|
||||
],
|
||||
"public API call chains cannot administer authorization",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_api_cannot_reach_a_tenant_component() {
|
||||
let (_root, settings) = auth_settings();
|
||||
let mut files = identity_sources().to_vec();
|
||||
files.extend([
|
||||
(
|
||||
"news/article/model.dever",
|
||||
"type Article { title: Text(1, 160) }",
|
||||
),
|
||||
(
|
||||
"news/article/app.dever",
|
||||
"count() (value: Int) { value = model.count() }",
|
||||
),
|
||||
(
|
||||
"news/article/api/admin/browse.dever",
|
||||
"public get count = app.count",
|
||||
),
|
||||
]);
|
||||
let error = configured_check(&files, &settings).unwrap_err();
|
||||
assert!(
|
||||
error.contains("public API cannot reach tenant components [news]"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_bindings_accept_wire_records_but_query_bindings_require_scalars() {
|
||||
let app = "type Input { name: Text }\nupdate(input: Input) (value: Text) { value = text.trim(input.name) }";
|
||||
check(&[
|
||||
("user/account/app.dever", app),
|
||||
(
|
||||
"user/account/api.dever",
|
||||
"put profile = app.update\ncmd update = app.update",
|
||||
),
|
||||
]);
|
||||
reject(
|
||||
&[
|
||||
("user/account/app.dever", app),
|
||||
("user/account/api.dever", "get profile = app.update"),
|
||||
],
|
||||
"wire-safe scalars",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_bindings_reject_legacy_bodies_cross_domain_and_ambiguous_targets() {
|
||||
reject(
|
||||
&[(
|
||||
"user/account/api.dever",
|
||||
"get_name() (response: Text) { response = \"x\" }",
|
||||
)],
|
||||
"handler function bodies and legacy prefixes",
|
||||
);
|
||||
reject(
|
||||
&[("user/account/api.dever", "post save = user.profile.save")],
|
||||
"same domain",
|
||||
);
|
||||
reject(
|
||||
&[("user/account/api.dever", "post save = app.missing")],
|
||||
"unambiguous same-domain",
|
||||
);
|
||||
reject(
|
||||
&[
|
||||
(
|
||||
"user/account/app.dever",
|
||||
"save() (value: Text) { value = \"x\" }\nsave(name: Text) (value: Text) { value = text.trim(name) }",
|
||||
),
|
||||
("user/account/api.dever", "post save = app.save"),
|
||||
],
|
||||
"unambiguous same-domain",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_routes_and_commands_are_rejected() {
|
||||
let app = "greet() (value: Text) { value = \"hello\" }";
|
||||
for (declaration, diagnostic) in [
|
||||
("get hello = app.greet", "duplicate HTTP route"),
|
||||
("cmd hello = app.greet", "duplicate command"),
|
||||
] {
|
||||
let api = format!("{declaration}\n{declaration}");
|
||||
reject(
|
||||
&[
|
||||
("user/account/app.dever", app),
|
||||
("user/account/api.dever", &api),
|
||||
],
|
||||
diagnostic,
|
||||
);
|
||||
}
|
||||
reject(
|
||||
&[
|
||||
("user/account/app.dever", app),
|
||||
("user/account/api.dever", "cmd hello = app.greet"),
|
||||
(
|
||||
"user/account/api/maintenance.dever",
|
||||
"cmd hello = app.greet",
|
||||
),
|
||||
],
|
||||
"duplicate command",
|
||||
);
|
||||
}
|
||||
|
||||
fn sql_projection_files(sqlite: &str, postgres: &str, method: &str) -> [(&'static str, String); 3] {
|
||||
[
|
||||
(
|
||||
"news/article/model.dever",
|
||||
format!(
|
||||
"type Article {{\n name: Text\n private draft: Bool default true\n}}\n\
|
||||
sql lookup(name: Text, size: Int) (rows: List<app.View>) {{\n\
|
||||
sqlite = {sqlite:?}\n postgres = {postgres:?}\n}}\n"
|
||||
),
|
||||
),
|
||||
(
|
||||
"news/article/app.dever",
|
||||
"type View { name: Text }\n\
|
||||
read(name: Text, size: Int) (rows: List<View>) {\n\
|
||||
rows = model.lookup(name, size)\n}\n"
|
||||
.into(),
|
||||
),
|
||||
(
|
||||
"news/article/api.dever",
|
||||
format!("{method} read = app.read\n"),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_accepts_only_proven_single_model_sql_projections() {
|
||||
for sqlite in [
|
||||
"SELECT name FROM article WHERE name = ?1 ORDER BY id DESC LIMIT ?2",
|
||||
"select \"name\" from \"article\" where \"name\" = ?1 AND name = ?1 order by id desc, name asc limit ?2;",
|
||||
] {
|
||||
let postgres = sqlite.replace('?', "$");
|
||||
let files = sql_projection_files(sqlite, &postgres, "get");
|
||||
check(
|
||||
&files
|
||||
.iter()
|
||||
.map(|(path, source)| (*path, source.as_str()))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unproven_sql_keeps_its_write_effect_in_each_dialect() {
|
||||
let plain = "SELECT name FROM article WHERE name = ?1 ORDER BY id DESC LIMIT ?2";
|
||||
for unproven in [
|
||||
"SELECT name FROM other_article WHERE name = ?1 ORDER BY id DESC LIMIT ?2",
|
||||
"SELECT missing FROM article WHERE name = ?1 ORDER BY id DESC LIMIT ?2",
|
||||
"SELECT lower(name) FROM article WHERE name = ?1 ORDER BY id DESC LIMIT ?2",
|
||||
"WITH changed AS (UPDATE article SET name = ?1 WHERE id = ?2 RETURNING name) SELECT name FROM changed",
|
||||
"SELECT name FROM article WHERE name = ?1 LIMIT ?2; DELETE FROM article",
|
||||
"UPDATE article SET name = ?1 WHERE id = ?2 RETURNING name",
|
||||
"SELECT name INTO copied_article FROM article WHERE name = ?1 LIMIT ?2",
|
||||
"SELECT name FROM article WHERE name = ?1 LIMIT ?2 FOR UPDATE",
|
||||
"SELECT name FROM article WHERE name = ?1 LIMIT ?2 -- unproved comment",
|
||||
] {
|
||||
let plain_postgres = plain.replace('?', "$");
|
||||
let unproven_postgres = unproven.replace('?', "$");
|
||||
for (sqlite, postgres) in [
|
||||
(unproven, plain_postgres.as_str()),
|
||||
(plain, unproven_postgres.as_str()),
|
||||
] {
|
||||
let files = sql_projection_files(sqlite, postgres, "get");
|
||||
reject(
|
||||
&files
|
||||
.iter()
|
||||
.map(|(path, source)| (*path, source.as_str()))
|
||||
.collect::<Vec<_>>(),
|
||||
"GET App call chains cannot write",
|
||||
);
|
||||
let files = sql_projection_files(sqlite, postgres, "post");
|
||||
check(
|
||||
&files
|
||||
.iter()
|
||||
.map(|(path, source)| (*path, source.as_str()))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_rejects_writes_reached_through_cross_domain_app_calls() {
|
||||
let writer = "create(name: Text) (value: Text) { account = model.create({ name = text.trim(name) })\nvalue = account.name }";
|
||||
reject(
|
||||
&[
|
||||
(
|
||||
"user/account/model.dever",
|
||||
"type Account { name: Text(1, 64) }",
|
||||
),
|
||||
("user/account/app.dever", writer),
|
||||
(
|
||||
"user/report/app.dever",
|
||||
"greet(name: Text) (value: Text) { value = \"hello \" + user.account.create(name) }",
|
||||
),
|
||||
("user/report/api.dever", "get greeting = app.greet"),
|
||||
],
|
||||
"GET App call chains cannot write",
|
||||
);
|
||||
check(&[
|
||||
(
|
||||
"user/account/model.dever",
|
||||
"type Account { name: Text(1, 64) }",
|
||||
),
|
||||
("user/account/app.dever", writer),
|
||||
("user/account/api.dever", "post create = app.create"),
|
||||
]);
|
||||
check(&[
|
||||
(
|
||||
"user/account/model.dever",
|
||||
"type Account { name: Text(1, 64) }",
|
||||
),
|
||||
(
|
||||
"user/account/app.dever",
|
||||
"count() (value: Text) { value = int.to_text(model.count()) }",
|
||||
),
|
||||
("user/account/api.dever", "get count = app.count"),
|
||||
]);
|
||||
reject(
|
||||
&[
|
||||
(
|
||||
"user/account/app.dever",
|
||||
"disable() (ok: Bool) { dever.auth.disable_role(\"reader\")\nok = true }",
|
||||
),
|
||||
("user/account/api.dever", "get disable = app.disable"),
|
||||
],
|
||||
"GET App call chains cannot write database or authorization state",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_requires_unambiguous_same_domain_model_and_rejects_duplicate_registration() {
|
||||
let model = "type Account { name: Text(1, 64) }";
|
||||
check(&[
|
||||
("user/account/model.dever", model),
|
||||
("user/account/api.dever", "rest"),
|
||||
]);
|
||||
check(&[
|
||||
("user/account/model.dever", model),
|
||||
("user/account/api.dever", "rest model"),
|
||||
]);
|
||||
reject(
|
||||
&[("user/account/api.dever", "rest")],
|
||||
"requires one same-domain Model",
|
||||
);
|
||||
reject(
|
||||
&[
|
||||
("user/account/model.dever", model),
|
||||
("user/account/api.dever", "rest\nrest"),
|
||||
],
|
||||
"duplicate HTTP route",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_selector_disambiguates_main_and_topic_models() {
|
||||
let main = (
|
||||
"user/account/model.dever",
|
||||
"type Account { name: Text(1, 64) }",
|
||||
);
|
||||
let detail = (
|
||||
"user/account/model/detail.dever",
|
||||
"type Detail { title: Text(1, 64) }",
|
||||
);
|
||||
reject(
|
||||
&[main, detail, ("user/account/api.dever", "rest")],
|
||||
"requires one same-domain Model",
|
||||
);
|
||||
check(&[
|
||||
main,
|
||||
detail,
|
||||
("user/account/api.dever", "rest model.detail"),
|
||||
]);
|
||||
let alternate = (
|
||||
"user/account/model/archive.dever",
|
||||
"type Archive { name: Text(1, 64) }",
|
||||
);
|
||||
reject(
|
||||
&[detail, alternate, ("user/account/api.dever", "rest")],
|
||||
"requires one same-domain Model",
|
||||
);
|
||||
reject(
|
||||
&[main, ("user/account/api.dever", "rest user.profile.model")],
|
||||
"requires one same-domain Model",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_rest_and_custom_bindings_cannot_register_the_same_route() {
|
||||
reject(
|
||||
&[
|
||||
(
|
||||
"user/account/model.dever",
|
||||
"type Account { name: Text(1, 64) }",
|
||||
),
|
||||
(
|
||||
"user/account/app.dever",
|
||||
"details() (value: Text) { value = \"details\" }",
|
||||
),
|
||||
("user/account/api.dever", "get details = app.details"),
|
||||
("user/account/api/details.dever", "rest model"),
|
||||
],
|
||||
"duplicate HTTP route",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_declarations_format_idempotently() {
|
||||
let mut sources = SourceMap::default();
|
||||
sources.add("user/account/api.dever", "# Actions\npost publish=app.publish # publish article\ncmd rebuild =app.rebuild\nrest model\n");
|
||||
let source = sources.files().first().unwrap();
|
||||
let formatted = dever_core::format::format(source).unwrap();
|
||||
assert!(formatted.contains("post publish = app.publish"));
|
||||
assert!(formatted.contains("cmd rebuild = app.rebuild"));
|
||||
assert!(formatted.contains("rest model"));
|
||||
assert!(formatted.contains("# Actions"));
|
||||
assert!(formatted.contains("# publish article"));
|
||||
let mut roundtrip = SourceMap::default();
|
||||
roundtrip.add("user/account/api.dever", formatted.clone());
|
||||
assert_eq!(
|
||||
dever_core::format::format(roundtrip.files().first().unwrap()).unwrap(),
|
||||
formatted
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_api_declarations_share_plain_source_contracts() {
|
||||
let markdown = "# 账户接口\n\n绑定账户业务能力。\n\n- 包:`user.account.api`\n- 公开类型:无\n- 公开方法:无\n- 使用:无\n\n## 问候\n\n提供账户问候接口。\n\n- 声明:`get greeting`\n\n```dever\nget greeting = app.greet\n```\n";
|
||||
check(&[
|
||||
(
|
||||
"user/account/app.dever",
|
||||
"greet() (value: Text) { value = \"hello\" }",
|
||||
),
|
||||
("user/account/api.dever.md", markdown),
|
||||
]);
|
||||
let invalid = markdown.replace("`get greeting`", "`post greeting`");
|
||||
reject(
|
||||
&[
|
||||
(
|
||||
"user/account/app.dever",
|
||||
"greet() (value: Text) { value = \"hello\" }",
|
||||
),
|
||||
("user/account/api.dever.md", &invalid),
|
||||
],
|
||||
"documented Model declaration",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
#[path = "support/temp.rs"]
|
||||
mod temp;
|
||||
|
||||
use std::fs;
|
||||
|
||||
use dever_core::source::SourceMap;
|
||||
use dever_runtime::api::{self, Inputs};
|
||||
use dever_runtime::bytes::Bytes;
|
||||
use dever_runtime::config::Settings;
|
||||
use dever_runtime::http::{Header, Request};
|
||||
use temp::TemporaryDirectory;
|
||||
|
||||
fn sources(main: &str, api_source: &str) -> SourceMap {
|
||||
let mut sources = SourceMap::default();
|
||||
sources.add("main.dever", main);
|
||||
sources.add("user/api/hello.dever", api_source);
|
||||
sources.add(
|
||||
"user/service/greeting.dever",
|
||||
"public type Greeting { message: Text }\n\
|
||||
public greet(name: Text) (greeting: Greeting) {\n\
|
||||
greeting = Greeting { message = \"hello, \" + text.trim(name) }\n\
|
||||
}\n",
|
||||
);
|
||||
sources
|
||||
}
|
||||
|
||||
fn diagnostics(sources: &SourceMap) -> String {
|
||||
dever_core::check(sources)
|
||||
.expect_err("source must be rejected")
|
||||
.iter()
|
||||
.map(|error| error.render(sources))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_serve_compiles_recursive_module_routes_and_json_adapter() {
|
||||
let mut sources = sources(
|
||||
"main() () { dever.api.serve() }",
|
||||
"get_greeting(name: Text) (response: user.service.greeting.Greeting) {\n\
|
||||
response = user.service.greeting.greet(name)\n\
|
||||
}\n\
|
||||
post_greeting(name: Text) (response: user.service.greeting.Greeting) {\n\
|
||||
response = user.service.greeting.greet(name)\n\
|
||||
}",
|
||||
);
|
||||
sources.add(
|
||||
"user/api/admin/health.dever",
|
||||
"get_status() (response: Text) { response = \"ready\" }",
|
||||
);
|
||||
let program = dever_core::check(&sources).unwrap_or_else(|errors| {
|
||||
panic!(
|
||||
"{}",
|
||||
errors
|
||||
.iter()
|
||||
.map(|error| error.render(&sources))
|
||||
.collect::<String>()
|
||||
)
|
||||
});
|
||||
let generated = dever_core::native::emit(&program, &sources, "main.main").unwrap();
|
||||
assert!(generated.contains("(\"GET\", \"/user/hello/greeting\")"));
|
||||
assert!(generated.contains("(\"POST\", \"/user/hello/greeting\")"));
|
||||
assert!(generated.contains("(\"GET\", \"/user/admin/health/status\")"));
|
||||
assert!(generated.contains("dever_runtime::api::serve(api_route).await"));
|
||||
assert!(generated.contains("inputs.finish()"));
|
||||
assert!(generated.contains("dever_runtime::api::success(value)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_api_application_compiles_without_starting_a_listener() {
|
||||
let sources = sources(
|
||||
"main() () { dever.api.serve() }",
|
||||
"get_greeting(name: Text) (response: user.service.greeting.Greeting) {\n\
|
||||
response = user.service.greeting.greet(name)\n\
|
||||
}\n\
|
||||
post_greeting(name: Text) (response: user.service.greeting.Greeting) {\n\
|
||||
response = user.service.greeting.greet(name)\n\
|
||||
}\n\
|
||||
delete_greeting(name: Text) (response: Text) { response = name }",
|
||||
);
|
||||
let program = dever_core::check(&sources).unwrap();
|
||||
let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
|
||||
let native = dever_core::native::compile_application(
|
||||
&program,
|
||||
&sources,
|
||||
"main.main",
|
||||
&rustc,
|
||||
dever_runtime::config::RuntimeProfile::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(native.executable().is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_serve_api_sources_are_checked_but_not_emitted() {
|
||||
let checked_sources = sources(
|
||||
"main() (answer: Int) { answer = 42 }",
|
||||
"get_greeting(name: Text) (response: user.service.greeting.Greeting) {\n\
|
||||
response = user.service.greeting.greet(name)\n\
|
||||
}",
|
||||
);
|
||||
let program = dever_core::check(&checked_sources).unwrap();
|
||||
let generated = dever_core::native::emit(&program, &checked_sources, "main.main").unwrap();
|
||||
assert!(!generated.contains("fn api_route("));
|
||||
assert!(!generated.contains("dever_runtime::api::serve"));
|
||||
let invalid = sources(
|
||||
"main() () {}",
|
||||
"fetch_greeting() (response: Text) { response = \"x\" }",
|
||||
);
|
||||
assert!(diagnostics(&invalid).contains("API handler names must start with"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_only_accepts_private_http_handlers_with_response_output() {
|
||||
for (source, expected) in [
|
||||
(
|
||||
"public get_ok() (response: Text) { response = \"x\" }",
|
||||
"private ordinary",
|
||||
),
|
||||
(
|
||||
"post_create() (result: Text) { result = \"x\" }",
|
||||
"named response",
|
||||
),
|
||||
(
|
||||
"delete_thing(file: dever.system.File) (response: Text) { response = \"x\" }",
|
||||
"API inputs must",
|
||||
),
|
||||
(
|
||||
"get_limit(value: Int >= 1) (response: Int) { response = value }",
|
||||
"API inputs must be plain",
|
||||
),
|
||||
(
|
||||
"type Temporary { value: Text }",
|
||||
"only HTTP handler functions",
|
||||
),
|
||||
] {
|
||||
let errors = diagnostics(&sources("main() () {}", source));
|
||||
assert!(errors.contains(expected), "{source}: {errors}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_and_json_body_bind_the_same_typed_parameters() {
|
||||
let request = Request {
|
||||
method: "GET".into(),
|
||||
target: "/user/hello/greeting?name=%E4%B8%AD%E6%96%87&count=12&active=true".into(),
|
||||
headers: vec![],
|
||||
body: Bytes::from_text(""),
|
||||
};
|
||||
let mut inputs = Inputs::from_request(&request).unwrap();
|
||||
assert_eq!(inputs.text("name").unwrap(), "中文");
|
||||
assert_eq!(inputs.int("count").unwrap(), 12);
|
||||
assert!(inputs.boolean("active").unwrap());
|
||||
inputs.finish().unwrap();
|
||||
|
||||
let request = Request {
|
||||
method: "POST".into(),
|
||||
target: "/user/hello/greeting".into(),
|
||||
headers: vec![Header {
|
||||
name: "content-type".into(),
|
||||
value: Bytes::from_text("application/json"),
|
||||
}],
|
||||
body: Bytes::from_text("{\"name\":\"Ada\",\"count\":2,\"active\":false}"),
|
||||
};
|
||||
let mut inputs = Inputs::from_request(&request).unwrap();
|
||||
assert_eq!(inputs.text("name").unwrap(), "Ada");
|
||||
assert_eq!(inputs.int("count").unwrap(), 2);
|
||||
assert!(!inputs.boolean("active").unwrap());
|
||||
inputs.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_or_extra_inputs_get_structured_errors() {
|
||||
for target in [
|
||||
"/user/hello?name=x&name=y",
|
||||
"/user/hello?name=%GG",
|
||||
"/user/hello?name=x&extra=1",
|
||||
] {
|
||||
let request = Request {
|
||||
method: "GET".into(),
|
||||
target: target.into(),
|
||||
headers: vec![],
|
||||
body: Bytes::from_text(""),
|
||||
};
|
||||
let parsed = Inputs::from_request(&request);
|
||||
if let Ok(mut inputs) = parsed {
|
||||
inputs.text("name").unwrap();
|
||||
assert!(inputs.finish().is_err());
|
||||
}
|
||||
}
|
||||
let response = api::invalid_input(api::InputError("bad value".into()));
|
||||
assert_eq!(response.status, 400);
|
||||
let body: api::Value = serde_json::from_slice(response.body.values()).unwrap();
|
||||
assert_eq!(body["code"], 400);
|
||||
assert_eq!(body["message"], "bad value");
|
||||
assert!(body["data"].is_null());
|
||||
|
||||
for (status, message) in [
|
||||
(400, "invalid request"),
|
||||
(401, "unauthorized"),
|
||||
(403, "forbidden"),
|
||||
(404, "not found"),
|
||||
(409, "conflict"),
|
||||
(429, "too many requests"),
|
||||
] {
|
||||
let response = api::standard_error(status);
|
||||
assert_eq!(response.status, status);
|
||||
let body: api::Value = serde_json::from_slice(response.body.values()).unwrap();
|
||||
assert_eq!(body["code"], status);
|
||||
assert_eq!(body["message"], message);
|
||||
assert!(body["data"].is_null());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listener_settings_are_required_only_for_api_and_come_from_project_config() {
|
||||
let directory = TemporaryDirectory::new();
|
||||
fs::create_dir(directory.path().join("config")).unwrap();
|
||||
let setting = directory.path().join("config/setting.json");
|
||||
fs::write(&setting, "{}\n").unwrap();
|
||||
let settings = Settings::load_project(directory.path()).unwrap();
|
||||
assert!(
|
||||
settings
|
||||
.http()
|
||||
.unwrap_err()
|
||||
.contains("http.host and http.port")
|
||||
);
|
||||
|
||||
fs::write(&setting, r#"{"http":{"host":"127.0.0.1","port":8181}}"#).unwrap();
|
||||
let settings = Settings::load_project(directory.path()).unwrap();
|
||||
let http = settings.http().unwrap();
|
||||
assert_eq!(http.host, "127.0.0.1");
|
||||
assert_eq!(http.port, 8181);
|
||||
|
||||
fs::write(&setting, r#"{"http":{"host":"example.com","port":8181}}"#).unwrap();
|
||||
assert!(
|
||||
Settings::load_project(directory.path())
|
||||
.unwrap()
|
||||
.http()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
mod support;
|
||||
|
||||
use std::fs;
|
||||
|
||||
use dever_runtime::config::{Database, LogLevel, PostgresTls, Settings};
|
||||
|
||||
const MIXED_SETTING: &str = r#"{
|
||||
"http": {},
|
||||
"performance": {
|
||||
"network_peer": "target/network_bench",
|
||||
"live_peer": "target/live_bench",
|
||||
"linux_ci": {"inputs": [], "required_inputs": {}, "author_outputs": []}
|
||||
},
|
||||
"database": {
|
||||
"default": {
|
||||
"type": "sqlite",
|
||||
"path": "data/db/cms.db",
|
||||
"max_connections": 1,
|
||||
"max_page_size": 100
|
||||
},
|
||||
"report": {
|
||||
"type": "postgres",
|
||||
"url": "postgres://example.invalid/report",
|
||||
"tls": "system",
|
||||
"min_connections": 0,
|
||||
"max_connections": 4,
|
||||
"max_page_size": 200
|
||||
}
|
||||
},
|
||||
"log": {}
|
||||
}"#;
|
||||
|
||||
fn project(setting: &str) -> support::temp::TemporaryDirectory {
|
||||
let root = support::temp::TemporaryDirectory::new();
|
||||
fs::create_dir(root.path().join("config")).unwrap();
|
||||
fs::write(root.path().join("config/setting.json"), setting).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
#[cfg(feature = "external")]
|
||||
#[test]
|
||||
fn worker_file_grants_are_explicit_and_cannot_write_executable_cache() {
|
||||
let setting = |path: &str, write: bool| {
|
||||
serde_json::json!({
|
||||
"adapter": {"sample.worker": {"files": {"content": {"path": path, "write": write}}}}
|
||||
})
|
||||
.to_string()
|
||||
};
|
||||
let capabilities = vec!["file".into()];
|
||||
let root = project(&setting("data/upload", true));
|
||||
let settings = Settings::load_project(root.path()).unwrap();
|
||||
let grants = settings
|
||||
.external_grants("sample.worker", &capabilities)
|
||||
.unwrap();
|
||||
assert_eq!(grants.len(), 1);
|
||||
assert_eq!(grants[0].source, root.path().join("data/upload"));
|
||||
assert_eq!(grants[0].destination, std::path::Path::new("/data/content"));
|
||||
assert!(grants[0].writable);
|
||||
assert!(
|
||||
settings
|
||||
.external_grants("sample.worker", &[])
|
||||
.unwrap_err()
|
||||
.contains("file capability")
|
||||
);
|
||||
for path in [
|
||||
"data",
|
||||
"data/cache",
|
||||
"data/cache/lib",
|
||||
"data/cache/lib/worker",
|
||||
] {
|
||||
let root = project(&setting(path, true));
|
||||
let settings = Settings::load_project(root.path()).unwrap();
|
||||
assert!(
|
||||
settings
|
||||
.external_grants("sample.worker", &capabilities)
|
||||
.unwrap_err()
|
||||
.contains("executable resource cache")
|
||||
);
|
||||
}
|
||||
for path in ["../private", "/etc", ""] {
|
||||
let root = project(&setting(path, false));
|
||||
let settings = Settings::load_project(root.path()).unwrap();
|
||||
assert!(
|
||||
settings
|
||||
.external_grants("sample.worker", &capabilities)
|
||||
.unwrap_err()
|
||||
.contains("relative to the application")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_resolve_connections_and_paths_from_the_application_directory() {
|
||||
let root = project(MIXED_SETTING);
|
||||
let settings = Settings::load_project(root.path()).unwrap();
|
||||
assert_eq!(settings.resolve_database(None, "cms").unwrap().0, "default");
|
||||
assert_eq!(
|
||||
settings.resolve_database(None, "report").unwrap().0,
|
||||
"report"
|
||||
);
|
||||
assert!(settings.resolve_database(Some("missing"), "cms").is_err());
|
||||
match settings.database("default").unwrap() {
|
||||
Database::Sqlite {
|
||||
path,
|
||||
max_connections,
|
||||
max_page_size,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(path, &root.path().join("data/db/cms.db"));
|
||||
assert_eq!((*max_connections, *max_page_size), (1, 100));
|
||||
}
|
||||
Database::Postgres { .. } => panic!("default must be SQLite"),
|
||||
}
|
||||
match settings.database("report").unwrap() {
|
||||
Database::Postgres {
|
||||
tls,
|
||||
wait_timeout_ms,
|
||||
io_timeout_ms,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(*tls, PostgresTls::System);
|
||||
assert_eq!((*wait_timeout_ms, *io_timeout_ms), (5_000, 30_000));
|
||||
}
|
||||
Database::Sqlite { .. } => panic!("report must be PostgreSQL"),
|
||||
}
|
||||
assert!(settings.profile().sqlite);
|
||||
assert!(settings.profile().postgres);
|
||||
assert!(
|
||||
settings
|
||||
.validate_transaction_bindings(&[&[(None, "cms"), (None, "news")]])
|
||||
.is_ok()
|
||||
);
|
||||
let error = settings
|
||||
.validate_transaction_bindings(&[&[(None, "cms"), (Some("report"), "cms")]])
|
||||
.unwrap_err();
|
||||
assert!(error.contains("multiple database connections"), "{error}");
|
||||
|
||||
settings.initialize_data_directories().unwrap();
|
||||
for directory in ["db", "upload", "log", "cache", "tmp"] {
|
||||
assert!(root.path().join("data").join(directory).is_dir());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tenant_database_settings_use_only_controlled_physical_targets() {
|
||||
let root = project(
|
||||
r#"{
|
||||
"tenant": {
|
||||
"database": "default",
|
||||
"max_pools": 8,
|
||||
"idle_timeout_ms": 60000
|
||||
},
|
||||
"database": {
|
||||
"default": {
|
||||
"type": "sqlite",
|
||||
"path": "data/platform.db",
|
||||
"tenant_directory": "data/tenants"
|
||||
},
|
||||
"content": {
|
||||
"type": "postgres",
|
||||
"url": "postgres://example.invalid/platform",
|
||||
"tls": "disabled",
|
||||
"tenant_database_prefix": "cms_tenant"
|
||||
},
|
||||
"platform": {
|
||||
"type": "sqlite",
|
||||
"path": "data/platform_only.db"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
let settings = Settings::load_project(root.path()).unwrap();
|
||||
let tenant = settings.tenant().unwrap();
|
||||
assert_eq!(tenant.database(), "default");
|
||||
assert_eq!(tenant.max_pools(), 8);
|
||||
assert_eq!(tenant.idle_timeout_ms(), 60_000);
|
||||
match settings.database("default").unwrap() {
|
||||
Database::Sqlite {
|
||||
tenant_directory, ..
|
||||
} => {
|
||||
assert_eq!(
|
||||
tenant_directory.as_ref().unwrap(),
|
||||
&root.path().join("data/tenants")
|
||||
);
|
||||
}
|
||||
Database::Postgres { .. } => panic!("default must be SQLite"),
|
||||
}
|
||||
match settings.database("content").unwrap() {
|
||||
Database::Postgres {
|
||||
tenant_database_prefix,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(tenant_database_prefix.as_deref(), Some("cms_tenant"));
|
||||
}
|
||||
Database::Sqlite { .. } => panic!("content must be PostgreSQL"),
|
||||
}
|
||||
|
||||
settings
|
||||
.validate_scoped_database_bindings(
|
||||
&[(Some("content"), "news", true), (None, "platform", false)],
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let error = settings
|
||||
.validate_scoped_database_bindings(&[(Some("platform"), "news", true)], &[])
|
||||
.unwrap_err();
|
||||
assert!(error.contains("tenant_directory is required"), "{error}");
|
||||
|
||||
let transaction = [
|
||||
(Some("default"), "news", false),
|
||||
(Some("default"), "news", true),
|
||||
];
|
||||
let error = settings
|
||||
.validate_scoped_database_bindings(&[], &[&transaction])
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.contains("cannot mix global and tenant Models"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_connection_resolution_rejects_table_collisions_and_cross_database_foreign_keys() {
|
||||
let root = project(MIXED_SETTING);
|
||||
let settings = Settings::load_project(root.path()).unwrap();
|
||||
|
||||
let mut collision_sources = dever_core::source::SourceMap::default();
|
||||
collision_sources.add(
|
||||
"user/model/account.dever",
|
||||
"public type Account { email: Text }",
|
||||
);
|
||||
collision_sources.add(
|
||||
"news/model/account.dever",
|
||||
"public type Account { slug: Text }",
|
||||
);
|
||||
let collision = dever_core::check(&collision_sources).unwrap();
|
||||
let error = collision.validate_database_settings(&settings).unwrap_err();
|
||||
assert!(error.contains("share table 'account'"), "{error}");
|
||||
|
||||
let mut foreign_key_sources = dever_core::source::SourceMap::default();
|
||||
foreign_key_sources.add(
|
||||
"user/model/user.dever",
|
||||
"database report\npublic type User { email: Text }",
|
||||
);
|
||||
foreign_key_sources.add(
|
||||
"news/model/news.dever",
|
||||
"public type News { author_id: user.model.user.id }",
|
||||
);
|
||||
let foreign_key = dever_core::check(&foreign_key_sources).unwrap();
|
||||
let error = foreign_key
|
||||
.validate_database_settings(&settings)
|
||||
.unwrap_err();
|
||||
assert!(error.contains("crosses database connections"), "{error}");
|
||||
|
||||
let long_field =
|
||||
"field_name_that_is_longer_than_postgresql_identifier_limit_of_sixty_three_bytes";
|
||||
let mut identifier_sources = dever_core::source::SourceMap::default();
|
||||
identifier_sources.add(
|
||||
"report/model/entry.dever",
|
||||
format!("database report\npublic type Entry {{ {long_field}: Text }}"),
|
||||
);
|
||||
let identifiers = dever_core::check(&identifier_sources).unwrap();
|
||||
let error = identifiers
|
||||
.validate_database_settings(&settings)
|
||||
.unwrap_err();
|
||||
assert!(error.contains("exceeds 63 bytes"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_reject_unknown_fields_missing_default_and_escaping_paths() {
|
||||
for (setting, expected) in [
|
||||
(r#"{"linux_ci":{}}"#, "unknown field `linux_ci`"),
|
||||
(
|
||||
r#"{"database":{"default":{"type":"sqlite","path":"data/app.db","extra":true}}}"#,
|
||||
"unknown field",
|
||||
),
|
||||
(r#"{"database":{}}"#, "database.default is required"),
|
||||
(
|
||||
r#"{"database":{"default":{"type":"sqlite","path":"../outside.db"}}}"#,
|
||||
"must stay below",
|
||||
),
|
||||
(
|
||||
r#"{"database":{"default":{"type":"postgres","url":"postgres://example.invalid/app","tls":"prefer"}}}"#,
|
||||
"unknown variant",
|
||||
),
|
||||
(
|
||||
r#"{"database":{"default":{"type":"sqlite","path":"data/app.db","max_connections":0}}}"#,
|
||||
"must be between",
|
||||
),
|
||||
(
|
||||
r#"{"database":{"default":{"type":"postgres","url":"postgres://example.invalid/app","tls":"disabled","wait_timeout_ms":0}}}"#,
|
||||
"wait_timeout_ms must be between",
|
||||
),
|
||||
(
|
||||
r#"{"database":{"default":{"type":"postgres","url":"postgres://example.invalid/app","tls":"disabled","io_timeout_ms":300001}}}"#,
|
||||
"io_timeout_ms must be between",
|
||||
),
|
||||
(
|
||||
r#"{"database":{"default":{"type":"postgres","url":"postgres://example.invalid/app","tls":"disabled","tenant_database_prefix":"tenant_database_prefix_that_exceeds_forty_three_bytes"}}}"#,
|
||||
"at most 43 lower_snake_case bytes",
|
||||
),
|
||||
(
|
||||
r#"{"database":{"default":{"type":"sqlite","path":"data/app.db","tenant_directory":"data/tenants"}}}"#,
|
||||
"require top-level tenant settings",
|
||||
),
|
||||
(
|
||||
r#"{"tenant":{"database":"missing"},"database":{"default":{"type":"sqlite","path":"data/app.db","tenant_directory":"data/tenants"}}}"#,
|
||||
"unknown database",
|
||||
),
|
||||
(
|
||||
r#"{"tenant":{"mode":"database","control_database":"default"},"database":{"default":{"type":"sqlite","path":"data/app.db","tenant_directory":"data/tenants"}}}"#,
|
||||
"unknown field",
|
||||
),
|
||||
(
|
||||
r#"{"tenant":{"max_pools":8},"database":{"default":{"type":"sqlite","path":"data/app.db","tenant_directory":"data/tenants"}}}"#,
|
||||
"missing field `database`",
|
||||
),
|
||||
(
|
||||
r#"{"tenant":{"database":"default","max_pools":0},"database":{"default":{"type":"sqlite","path":"data/app.db","tenant_directory":"data/tenants"}}}"#,
|
||||
"max_pools must be between",
|
||||
),
|
||||
] {
|
||||
let root = project(setting);
|
||||
let error = Settings::load_project(root.path())
|
||||
.err()
|
||||
.expect("invalid setting must fail");
|
||||
assert!(error.contains(expected), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sites_map_api_parent_directories_to_strict_auth_providers() {
|
||||
let root = project(
|
||||
r#"{
|
||||
"auth": {
|
||||
"providers": {
|
||||
"session": {
|
||||
"verify": "user.account.verify",
|
||||
"jwtSecret": "0123456789abcdef0123456789abcdef",
|
||||
"ttlSeconds": 3600,
|
||||
"cookie": "cms_session"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sites": {
|
||||
"admin": {"path": "admin", "auth": "session", "hosts": ["ADMIN.EXAMPLE.COM:443"], "origin": "https://admin.example.com"},
|
||||
"front": {"path": "front", "auth": "session", "origin": "https://front.example.com:8443"}
|
||||
},
|
||||
"log": {"level": "warn"}
|
||||
}"#,
|
||||
);
|
||||
let settings = Settings::load_project(root.path()).unwrap();
|
||||
let (key, site) = settings
|
||||
.site_for_directory(&["admin", "article"])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(key, "admin");
|
||||
assert_eq!(site.path(), "admin");
|
||||
assert_eq!(site.auth(), "session");
|
||||
assert_eq!(site.hosts(), ["admin.example.com"]);
|
||||
let origin = site.origin().unwrap();
|
||||
assert_eq!(
|
||||
(origin.scheme(), origin.host(), origin.port()),
|
||||
("https", "admin.example.com", 443)
|
||||
);
|
||||
let (_, front) = settings.site_for_directory(&["front"]).unwrap().unwrap();
|
||||
assert_eq!(front.hosts(), ["front.example.com"]);
|
||||
let origin = front.origin().unwrap();
|
||||
assert_eq!(
|
||||
(origin.scheme(), origin.host(), origin.port()),
|
||||
("https", "front.example.com", 8443)
|
||||
);
|
||||
assert!(settings.site_for_directory(&[]).unwrap().is_none());
|
||||
assert!(
|
||||
settings
|
||||
.site_for_directory(&["administrator"])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
settings.auth().provider("session").unwrap().verify(),
|
||||
"user.account.verify"
|
||||
);
|
||||
assert_eq!(settings.log().level, LogLevel::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sites_reject_ambiguous_paths_unknown_providers_and_removed_app_settings() {
|
||||
for (setting, expected) in [
|
||||
(r#"{"app":{"name":"cms"}}"#, "unknown field"),
|
||||
(
|
||||
r#"{"sites":{"admin":{"path":"admin","auth":"missing"}}}"#,
|
||||
"unknown provider",
|
||||
),
|
||||
(
|
||||
r#"{
|
||||
"auth":{"providers":{"session":{"verify":"user.account.verify","jwtSecret":"0123456789abcdef0123456789abcdef"}}},
|
||||
"sites":{
|
||||
"admin":{"path":"admin","auth":"session"},
|
||||
"nested":{"path":"admin/reports","auth":"session"}
|
||||
}
|
||||
}"#,
|
||||
"overlap",
|
||||
),
|
||||
(
|
||||
r#"{
|
||||
"auth":{"providers":{"session":{"verify":"user.account.verify","jwtSecret":"0123456789abcdef0123456789abcdef"}}},
|
||||
"sites":{"front":{"path":"front","auth":"session","origin":"ftp://cms.example.com"}}
|
||||
}"#,
|
||||
"scheme must be http or https",
|
||||
),
|
||||
(
|
||||
r#"{
|
||||
"auth":{"providers":{"session":{"verify":"user.account.verify","jwtSecret":"0123456789abcdef0123456789abcdef"}}},
|
||||
"sites":{"front":{"path":"front","auth":"session","hosts":["api.example.com"],"origin":"https://cms.example.com"}}
|
||||
}"#,
|
||||
"origin host must be present",
|
||||
),
|
||||
] {
|
||||
let root = project(setting);
|
||||
let error = Settings::load_project(root.path())
|
||||
.err()
|
||||
.expect("invalid setting must fail");
|
||||
assert!(error.contains(expected), "{error}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
#[path = "support/temp.rs"]
|
||||
mod temp;
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use dever_core::diagnostic::Diagnostic;
|
||||
use dever_core::source::SourceMap;
|
||||
use temp::TemporaryDirectory;
|
||||
|
||||
fn project(module: &[(&str, &str)], tests: &[(&str, &str)]) -> (TemporaryDirectory, SourceMap) {
|
||||
let root = TemporaryDirectory::new();
|
||||
write_sources(&root.path().join("module"), module);
|
||||
if !tests.is_empty() {
|
||||
write_sources(&root.path().join("test"), tests);
|
||||
}
|
||||
let sources =
|
||||
SourceMap::load_project(&root.path().join("module"), &root.path().join("test")).unwrap();
|
||||
(root, sources)
|
||||
}
|
||||
|
||||
fn write_sources(root: &Path, files: &[(&str, &str)]) {
|
||||
fs::create_dir_all(root).unwrap();
|
||||
for (relative, text) in files {
|
||||
let path = root.join(relative);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, text).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn checked(sources: &SourceMap) -> dever_core::hir::Program {
|
||||
dever_core::check(sources).unwrap_or_else(|errors| {
|
||||
panic!(
|
||||
"{}",
|
||||
errors
|
||||
.iter()
|
||||
.map(|error| error.render(sources))
|
||||
.collect::<String>()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn rejected(sources: &SourceMap, code: &str, message: &str) -> Vec<Diagnostic> {
|
||||
let errors = dever_core::check(sources).expect_err("source must be rejected");
|
||||
assert!(
|
||||
errors
|
||||
.iter()
|
||||
.any(|error| error.code == code && error.message.contains(message)),
|
||||
"expected {code}: {message}; got {}",
|
||||
errors
|
||||
.iter()
|
||||
.map(|error| error.render(sources))
|
||||
.collect::<String>()
|
||||
);
|
||||
errors
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_filename_entries_in_stable_order_and_allows_no_tests() {
|
||||
let (_root, sources) = project(&[("main.dever", "main() () {}")], &[]);
|
||||
assert!(checked(&sources).tests().is_empty());
|
||||
|
||||
let (_root, sources) = project(
|
||||
&[("main.dever", "main() () {}")],
|
||||
&[
|
||||
(
|
||||
"user/account/register.dever",
|
||||
"register() () { assert(true) }",
|
||||
),
|
||||
(
|
||||
"news/article/publish.dever",
|
||||
"publish() () { assert_eq(1, 1) }",
|
||||
),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
checked(&sources)
|
||||
.tests()
|
||||
.iter()
|
||||
.map(|test| test.name())
|
||||
.collect::<Vec<_>>(),
|
||||
["news/article/publish", "user/account/register"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_test_paths_and_entry_contracts() {
|
||||
let (_root, sources) = project(
|
||||
&[("main.dever", "main() () {}")],
|
||||
&[("user/account/nested/register.dever", "register() () {}")],
|
||||
);
|
||||
rejected(&sources, "C003", "test source must be");
|
||||
|
||||
let (_root, sources) = project(
|
||||
&[("main.dever", "main() () {}")],
|
||||
&[("user/account/register.dever", "helper_entry() () {}")],
|
||||
);
|
||||
rejected(
|
||||
&sources,
|
||||
"C005",
|
||||
"must declare the ordinary 'register() ()' entry",
|
||||
);
|
||||
|
||||
let (_root, sources) = project(
|
||||
&[("main.dever", "main() () {}")],
|
||||
&[("user/account/register.dever", "register(value: Int) () {}")],
|
||||
);
|
||||
rejected(&sources, "C005", "zero-input, zero-output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_documented_markdown_tests_and_rejects_duplicate_topics() {
|
||||
let (_root, sources) = project(
|
||||
&[("main.dever", "main() () {}")],
|
||||
&[(
|
||||
"user/account/register.dever.md",
|
||||
r#"# 注册账户测试
|
||||
|
||||
验证账户注册行为。
|
||||
|
||||
- 包:`user.account.register`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 注册账户
|
||||
|
||||
执行注册断言。
|
||||
|
||||
- 函数:`register`
|
||||
- 输入:无
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
register() () { assert(true) }
|
||||
```
|
||||
"#,
|
||||
)],
|
||||
);
|
||||
assert_eq!(checked(&sources).tests()[0].name(), "user/account/register");
|
||||
|
||||
let (_root, sources) = project(
|
||||
&[("main.dever", "main() () {}")],
|
||||
&[
|
||||
("user/account/register.dever", "register() () {}"),
|
||||
(
|
||||
"user/account/register.dever.md",
|
||||
r#"# 注册账户测试
|
||||
|
||||
验证账户注册行为。
|
||||
|
||||
- 包:`user.account.register`
|
||||
- 公开类型:无
|
||||
- 公开方法:无
|
||||
- 使用:无
|
||||
|
||||
## 注册账户
|
||||
|
||||
执行注册断言。
|
||||
|
||||
- 函数:`register`
|
||||
- 输入:无
|
||||
- 输出:无
|
||||
|
||||
```dever
|
||||
register() () {}
|
||||
```
|
||||
"#,
|
||||
),
|
||||
],
|
||||
);
|
||||
rejected(&sources, "C002", "duplicate package");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_visibility_uses_domain_boundaries_without_exposing_test_helpers() {
|
||||
let (_root, sources) = project(
|
||||
&[
|
||||
("main.dever", "main() () {}"),
|
||||
(
|
||||
"user/account/domain.dever",
|
||||
"normalize(value: Text) (result: Text) { result = text.lower(text.trim(value)) }",
|
||||
),
|
||||
(
|
||||
"user/account/app.dever",
|
||||
"normalize(value: Text) (result: Text) { result = domain.normalize(value) }",
|
||||
),
|
||||
(
|
||||
"news/article/app.dever",
|
||||
"label() (result: Text) { result = \"article\" }",
|
||||
),
|
||||
],
|
||||
&[(
|
||||
"user/account/normalize.dever",
|
||||
r#"normalize() () {
|
||||
assert_eq(app.normalize(" Editor "), "editor")
|
||||
assert_eq(domain.normalize(" ADMIN "), "admin")
|
||||
assert_eq(news.article.label(), "article")
|
||||
helper()
|
||||
}
|
||||
helper() () {}"#,
|
||||
)],
|
||||
);
|
||||
assert_eq!(checked(&sources).tests().len(), 1);
|
||||
|
||||
let (_root, sources) = project(
|
||||
&[
|
||||
("main.dever", "main() () {}"),
|
||||
(
|
||||
"news/article/domain.dever",
|
||||
"secret() (result: Text) { result = \"private\" }",
|
||||
),
|
||||
],
|
||||
&[(
|
||||
"user/account/steal.dever",
|
||||
"steal() () { assert_eq(news.article.domain.secret(), \"private\") }",
|
||||
)],
|
||||
);
|
||||
rejected(&sources, "C006", "not callable from this source role");
|
||||
|
||||
let (_root, sources) = project(
|
||||
&[
|
||||
("main.dever", "main() () {}"),
|
||||
("user/account/model.dever", "type Account { email: Text }"),
|
||||
],
|
||||
&[(
|
||||
"user/account/count.dever",
|
||||
"count() () { assert_eq(model.count(), 0) }",
|
||||
)],
|
||||
);
|
||||
rejected(&sources, "C006", "Model operations are private");
|
||||
|
||||
let (_root, sources) = project(
|
||||
&[
|
||||
("main.dever", "main() () {}"),
|
||||
(
|
||||
"user/account/app.dever",
|
||||
"leak() () { user.account.register.helper() }",
|
||||
),
|
||||
],
|
||||
&[(
|
||||
"user/account/register.dever",
|
||||
"register() () {}\nhelper() () {}",
|
||||
)],
|
||||
);
|
||||
rejected(&sources, "C006", "not callable from this source role");
|
||||
|
||||
let (_root, sources) = project(
|
||||
&[("main.dever", "main() () {}")],
|
||||
&[
|
||||
(
|
||||
"user/account/register.dever",
|
||||
"register() () {}\nhelper() () {}",
|
||||
),
|
||||
(
|
||||
"user/account/profile.dever",
|
||||
"profile() () { user.account.register.helper() }",
|
||||
),
|
||||
],
|
||||
);
|
||||
rejected(&sources, "C006", "not callable from this source role");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assertions_are_test_only_exact_and_comparable() {
|
||||
let (_root, sources) = project(&[("main.dever", "main() () { assert(true) }")], &[]);
|
||||
rejected(&sources, "C004", "unknown function 'assert'");
|
||||
|
||||
let (_root, sources) = project(
|
||||
&[("main.dever", "main() () {}")],
|
||||
&[("user/account/value.dever", "value() () { assert(1) }")],
|
||||
);
|
||||
rejected(&sources, "C005", "expected Bool");
|
||||
|
||||
let (_root, sources) = project(
|
||||
&[("main.dever", "main() () {}")],
|
||||
&[(
|
||||
"user/account/types.dever",
|
||||
"types() () { assert_eq(1, 1.0) }",
|
||||
)],
|
||||
);
|
||||
rejected(&sources, "C005", "same type on both sides");
|
||||
|
||||
let (_root, sources) = project(
|
||||
&[("main.dever", "main() () {}")],
|
||||
&[(
|
||||
"user/account/stream.dever",
|
||||
"stream() () { assert_eq(stream([1]), stream([1])) }",
|
||||
)],
|
||||
);
|
||||
rejected(&sources, "C005", "assert_eq does not support");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tests_cannot_start_the_application_api() {
|
||||
let (_root, sources) = project(
|
||||
&[("main.dever", "main() () {}")],
|
||||
&[(
|
||||
"user/account/server.dever",
|
||||
"server() () { dever.api.serve() }",
|
||||
)],
|
||||
);
|
||||
rejected(&sources, "C006", "cannot start the application HTTP API");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tests_use_existing_result_choices_for_business_failures() {
|
||||
let (_root, sources) = project(
|
||||
&[
|
||||
("main.dever", "main() () {}"),
|
||||
(
|
||||
"user/account/app.dever",
|
||||
r#"type RegistrationResult {
|
||||
Created(account: Int)
|
||||
error Invalid(message: Text)
|
||||
}
|
||||
register(value: 0) (account: Int) {
|
||||
fail(RegistrationResult.Invalid("invalid account"))
|
||||
}
|
||||
register(value: other) (account: Int) {
|
||||
account = value
|
||||
}"#,
|
||||
),
|
||||
],
|
||||
&[(
|
||||
"user/account/register.dever",
|
||||
r#"register() () {
|
||||
verify(result(app.register(0)))
|
||||
}
|
||||
verify(result: app.RegistrationResult.Invalid(message)) () recover("test verifies expected failure") {
|
||||
assert_eq(message, "invalid account")
|
||||
}
|
||||
verify(result: app.RegistrationResult.Created(account)) () recover("test verifies expected failure") {
|
||||
assert(false)
|
||||
}"#,
|
||||
)],
|
||||
);
|
||||
assert_eq!(checked(&sources).tests().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_test_suite_dispatches_cases_reports_locations_and_reuses_cache() {
|
||||
let (_root, sources) = project(
|
||||
&[
|
||||
("main.dever", "main() () {}"),
|
||||
("user/profile/model.dever", "type Profile { name: Text }"),
|
||||
],
|
||||
&[
|
||||
(
|
||||
"news/article/publish.dever",
|
||||
"publish() () { assert_eq(1, 1) }",
|
||||
),
|
||||
(
|
||||
"user/account/register.dever",
|
||||
r#"type Pair {
|
||||
left: Int
|
||||
right: Int
|
||||
}
|
||||
type Number {
|
||||
Present(value: Int)
|
||||
Missing
|
||||
}
|
||||
register() () {
|
||||
actual = Pair {
|
||||
left = 1
|
||||
right = 2
|
||||
}
|
||||
expected = Pair {
|
||||
left = 1
|
||||
right = 2
|
||||
}
|
||||
assert_eq(actual, expected)
|
||||
assert_eq(Number.Present(1), Number.Present(1))
|
||||
assert_eq(first([1]), first([1]))
|
||||
assert_eq(missing(), null)
|
||||
assert_eq([1, 2], [1, 2])
|
||||
values = { "one" = 1 }
|
||||
assert_eq(values, { "one" = 1 })
|
||||
assert_eq(entries(values), entries({ "one" = 1 }))
|
||||
assert_eq(1, 2)
|
||||
}
|
||||
missing() (value: Int?) {
|
||||
value = null
|
||||
}"#,
|
||||
),
|
||||
],
|
||||
);
|
||||
let program = checked(&sources);
|
||||
assert!(!program.tests()[0].uses_database());
|
||||
assert!(!program.tests()[1].uses_database());
|
||||
let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| OsString::from("rustc"));
|
||||
let native = dever_core::native::compile_test_suite(&program, &sources, &rustc).unwrap();
|
||||
let success = Command::new(native.executable()).arg("0").output().unwrap();
|
||||
assert!(success.status.success());
|
||||
let output = Command::new(native.executable()).arg("1").output().unwrap();
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("test/user/account/register.dever:26:"),
|
||||
"{stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains("assert_eq failed: actual = 1, expected = 2"),
|
||||
"{stderr}"
|
||||
);
|
||||
drop(native);
|
||||
let cached = dever_core::native::compile_test_suite(&program, &sources, &rustc).unwrap();
|
||||
assert!(cached.cache_hit());
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
mod support;
|
||||
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
use dever_runtime::{channel::Channel, task};
|
||||
|
||||
fn run(future: impl std::future::Future<Output = Result<(), String>>) {
|
||||
task::run_entry_with(
|
||||
task::RuntimeConfig {
|
||||
worker_threads: 1,
|
||||
max_blocking_threads: 2,
|
||||
task_capacity: 32,
|
||||
},
|
||||
future,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_runtime_configuration_validates_limits_and_starts_once() {
|
||||
assert!(task::configured(0, 1, 8).is_err());
|
||||
assert!(task::configured(1, -1, 8).is_err());
|
||||
assert!(task::configured(1, 1, 65537).is_err());
|
||||
let config = task::configured(1, 2, 8).unwrap();
|
||||
task::start_entry(config, async { Ok(()) }).unwrap();
|
||||
assert!(
|
||||
task::start_entry(config, async { Ok(()) })
|
||||
.unwrap_err()
|
||||
.contains("only be started once")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_drains_started_blocking_children_before_returning() {
|
||||
run(async {
|
||||
let started = Arc::new(tokio::sync::Semaphore::new(0));
|
||||
let finished = Arc::new(AtomicBool::new(false));
|
||||
let signal = started.clone();
|
||||
let completed = finished.clone();
|
||||
let job = task::run(async move {
|
||||
task::blocking(move || {
|
||||
signal.add_permits(1);
|
||||
std::thread::sleep(Duration::from_millis(40));
|
||||
completed.store(true, Ordering::Release);
|
||||
Ok(17)
|
||||
})
|
||||
.await
|
||||
})
|
||||
.await?;
|
||||
started.acquire().await.unwrap().forget();
|
||||
assert_eq!(task::wait_timeout(job, 1).await?, None);
|
||||
assert!(finished.load(Ordering::Acquire));
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn race_stops_losers_and_preserves_program_faults() {
|
||||
run(async {
|
||||
struct Dropped(Arc<AtomicBool>);
|
||||
impl Drop for Dropped {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
let ended = Arc::new(AtomicBool::new(false));
|
||||
let owner = Dropped(ended.clone());
|
||||
let slow = task::run(async move {
|
||||
let _owner = owner;
|
||||
std::future::pending::<Result<i64, String>>().await
|
||||
})
|
||||
.await?;
|
||||
let fast = task::run(async { Ok(42) }).await?;
|
||||
assert_eq!(task::race(vec![slow, fast]).await?, 42);
|
||||
assert!(ended.load(Ordering::Acquire));
|
||||
let failed = task::run(async { Err::<i64, _>("original fault".into()) }).await?;
|
||||
let pending = task::run(std::future::pending::<Result<i64, String>>()).await?;
|
||||
assert_eq!(
|
||||
task::race(vec![failed, pending]).await.unwrap_err(),
|
||||
"original fault"
|
||||
);
|
||||
let failed = task::run(async { Err::<(), _>("timer fault".into()) }).await?;
|
||||
assert_eq!(
|
||||
task::wait_timeout(failed, 100).await.unwrap_err(),
|
||||
"timer fault"
|
||||
);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_stream_drains_close_without_closing_independent_aliases() {
|
||||
run(async {
|
||||
let channel = Channel::new(2).unwrap();
|
||||
channel.send(Some(1)).await?;
|
||||
channel.send(None).await?;
|
||||
let stream = channel.clone().stream();
|
||||
assert_eq!(stream.pull().await, Some(Some(1)));
|
||||
stream.close();
|
||||
assert_eq!(channel.receive().await?, Some(None));
|
||||
channel.send(Some(2)).await?;
|
||||
channel.close().await?;
|
||||
let stream = channel.stream();
|
||||
assert_eq!(stream.pull().await, Some(Some(2)));
|
||||
assert_eq!(stream.pull().await, None);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticks_delay_first_value_skip_missed_periods_and_close_pending_pull() {
|
||||
run(async {
|
||||
let ticks = task::ticks(20)?;
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(2), ticks.pull())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(ticks.pull().await, Some(1));
|
||||
tokio::time::sleep(Duration::from_millis(70)).await;
|
||||
assert_eq!(ticks.pull().await, Some(2));
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(2), ticks.pull())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let waiter = ticks.clone();
|
||||
let pending = task::run(async move { Ok(waiter.pull().await) }).await?;
|
||||
ticks.close();
|
||||
assert_eq!(task::wait(pending).await?, None);
|
||||
assert!(task::ticks(0).is_err());
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_composition_rejects_wrong_shapes_and_reused_tasks() {
|
||||
support::rejected(
|
||||
"work() (value: Int) { value = 1 }\nfallback() (value: Text) { value = \"wrong\" }\nmain() () { job = run(work())\nvalue = timeout(job, 1, fallback) }",
|
||||
"signature",
|
||||
);
|
||||
support::rejected(
|
||||
"work() (value: Int) { value = 1 }\nmain() () { job = run(work())\nvalue = race(job, job) }",
|
||||
"consumed",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_composition_and_suspending_memory_handlers() {
|
||||
let source = r#"delayed(value: Int, millis: Int) (answer: Int) recover("test timer") {
|
||||
dever.task.sleep(millis)
|
||||
answer = value
|
||||
}
|
||||
fallback() (answer: Int) { answer = 9 }
|
||||
collect(value: Int, output: Channel<Int>) () { send(output, value) }
|
||||
add(value: Int, state: Int) (result: Int) { result = state + value }
|
||||
identity(value: Int) (result: Int) { result = value }
|
||||
pair(millis: Int) (number: Int, text: Text) recover("test timer") {
|
||||
dever.task.sleep(millis)
|
||||
number = 8
|
||||
text = "pair"
|
||||
}
|
||||
pair_timeout() (number: Int, text: Text) { number = 0
|
||||
text = "timeout" }
|
||||
worker() () {
|
||||
slow = run(delayed(1, 1000))
|
||||
timed = timeout(slow, 1, fallback)
|
||||
first = run(delayed(2, 1000))
|
||||
second = run(delayed(3, 1))
|
||||
fastest = race(first, second)
|
||||
values = each(add, [1, 2], 10)
|
||||
total = reduce(add, [1, 2, 3], 0)
|
||||
mapped = each(identity, [7, 8])
|
||||
mapped_total = sum(mapped)
|
||||
pair_task = run(pair(1000))
|
||||
pair_value = timeout(pair_task, 1, pair_timeout)
|
||||
pair_text = pair_value.text
|
||||
channel = channel(Int, 4)
|
||||
each(collect, [4, 5], channel)
|
||||
parallel_each(collect, dever.bytes.from_text("ab"), 2, channel)
|
||||
close(channel)
|
||||
received = reduce(add, stream(channel), 0)
|
||||
timer = dever.task.ticks(10)
|
||||
close(timer)
|
||||
blocking(dever.io.println(int.to_text(timed)))
|
||||
blocking(dever.io.println(int.to_text(fastest)))
|
||||
blocking(dever.io.println(int.to_text(sum(values))))
|
||||
blocking(dever.io.println(int.to_text(total)))
|
||||
blocking(dever.io.println(int.to_text(received)))
|
||||
blocking(dever.io.println(int.to_text(mapped_total)))
|
||||
blocking(dever.io.println(pair_text))
|
||||
}
|
||||
public main() () {
|
||||
worker()
|
||||
}"#;
|
||||
assert_eq!(support::stdout(source), "9\n3\n23\n6\n204\n15\ntimeout\n");
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
mod support;
|
||||
|
||||
use support::{checked, sources, stdout};
|
||||
|
||||
#[test]
|
||||
fn async_program_compiles_and_runs_every_execution_class() {
|
||||
let output = stdout(
|
||||
r#"sleep_ok(result: dever.time.SleepResult.Done) () recover("test handles timer failures") {}
|
||||
sleep_ok(result: dever.time.SleepResult.Failed(message)) () recover("test handles timer failures") {}
|
||||
pause() (result: dever.time.SleepResult) { result = result(dever.time.sleep(1)) }
|
||||
double(value: Int) (answer: Int) pure { answer = value * 2 }
|
||||
delayed(value: Int, millis: Int) (answer: Int) {
|
||||
sleep_ok(result(dever.task.sleep(millis)))
|
||||
answer = value
|
||||
}
|
||||
send_value(channel: Channel<Int>, value: Int) () {
|
||||
send(channel, value)
|
||||
}
|
||||
wait_until_stopped() () {
|
||||
sleep_ok(result(dever.task.sleep(1000)))
|
||||
}
|
||||
public main() (direct: Int, first: Int, second: Int, received: Int?, grouped: Int?, computed: Int) {
|
||||
direct = delayed(1, 1)
|
||||
first_task = run(delayed(2, 10))
|
||||
second_task = run(delayed(3, 1))
|
||||
second = wait(second_task)
|
||||
first = wait(first_task)
|
||||
|
||||
channel = channel(Int, 2)
|
||||
sender = run(send_value(channel, 4))
|
||||
received = receive(channel)
|
||||
wait(sender)
|
||||
|
||||
workers = group(2)
|
||||
run(workers, send_value(channel, 5))
|
||||
wait(workers)
|
||||
grouped = receive(channel)
|
||||
close(channel)
|
||||
|
||||
cancelled = run(wait_until_stopped())
|
||||
stop(cancelled)
|
||||
computed = parallel(double(21))
|
||||
sleep_ok(blocking(pause()))
|
||||
}"#,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
output,
|
||||
"direct = 1\nfirst = 2\nsecond = 3\nreceived = 4\ngrouped = 5\ncomputed = 42\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_inferred_suspending_entries_start_the_shared_runtime() {
|
||||
let sync_sources = sources("public main() (answer: Int) { answer = 1 }");
|
||||
let sync =
|
||||
dever_core::native::emit(&checked(&sync_sources), &sync_sources, "main.main").unwrap();
|
||||
assert!(sync.contains("match entry()"), "{sync}");
|
||||
assert!(!sync.contains("task::run_entry_typed(entry())"), "{sync}");
|
||||
|
||||
let async_sources = sources(
|
||||
r#"sleep_ok(result: dever.time.SleepResult.Done) () recover("timer failure handled") {}
|
||||
sleep_ok(result: dever.time.SleepResult.Failed(message)) () recover("timer failure handled") {}
|
||||
public main() () { sleep_ok(result(dever.task.sleep(0))) }"#,
|
||||
);
|
||||
let asynchronous =
|
||||
dever_core::native::emit(&checked(&async_sources), &async_sources, "main.main").unwrap();
|
||||
assert!(
|
||||
asynchronous.contains("match task::run_entry_typed(entry())"),
|
||||
"{asynchronous}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_suspension_is_inferred_without_a_source_modifier() {
|
||||
let program_sources = sources(
|
||||
"public main() (result: dever.time.SleepResult) { result = dever.system.task_sleep(1) }",
|
||||
);
|
||||
let generated =
|
||||
dever_core::native::emit(&checked(&program_sources), &program_sources, "main.main")
|
||||
.unwrap();
|
||||
assert!(generated.contains("async fn"), "{generated}");
|
||||
assert!(
|
||||
generated.contains("task::run_entry_typed(entry())"),
|
||||
"{generated}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_parallel_each_runs_outside_the_runtime_worker() {
|
||||
let source = r#"sleep_ok(result: dever.time.SleepResult.Done) () recover("timer failure handled") {}
|
||||
sleep_ok(result: dever.time.SleepResult.Failed(message)) () recover("timer failure handled") {}
|
||||
visit(value: Int) () {}
|
||||
public main() (answer: Int) {
|
||||
sleep_ok(result(dever.task.sleep(0)))
|
||||
parallel_each(visit, [1, 2, 3], 2)
|
||||
parallel_each(visit, dever.bytes.from_text("ab"), 2)
|
||||
answer = 7
|
||||
}"#;
|
||||
let program_sources = sources(source);
|
||||
let generated =
|
||||
dever_core::native::emit(&checked(&program_sources), &program_sources, "main.main")
|
||||
.unwrap();
|
||||
assert!(
|
||||
generated.contains("task::parallel_each_typed(a0.into_values()"),
|
||||
"{generated}"
|
||||
);
|
||||
assert_eq!(stdout(source), "answer = 7\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nullable_channel_items_are_flattened_at_the_language_boundary() {
|
||||
assert_eq!(
|
||||
stdout(
|
||||
"public main() (received: Int?) { channel = channel(Int?, 1)\nsend(channel, null)\nreceived = receive(channel)\nclose(channel) }",
|
||||
),
|
||||
"received = null\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inferred_suspension_requires_explicit_transitive_blocking_boundaries() {
|
||||
support::rejected(
|
||||
r#"sleep_ok(result: dever.time.SleepResult.Done) () recover("timer failure handled") {}
|
||||
sleep_ok(result: dever.time.SleepResult.Failed(message)) () recover("timer failure handled") {}
|
||||
pause() (result: dever.time.SleepResult) { result = result(dever.time.sleep(1)) }
|
||||
public main() (result: dever.time.SleepResult) {
|
||||
sleep_ok(result(dever.task.sleep(0)))
|
||||
result = pause()
|
||||
}"#,
|
||||
"cannot run inline inside a suspending function",
|
||||
);
|
||||
support::rejected(
|
||||
r#"sleep_ok(result: dever.time.SleepResult.Done) () recover("timer failure handled") {}
|
||||
sleep_ok(result: dever.time.SleepResult.Failed(message)) () recover("timer failure handled") {}
|
||||
visit(value: Int) () {}
|
||||
visit_all(values: List<Int>) () { parallel_each(visit, values, 2) }
|
||||
public main() () {
|
||||
sleep_ok(result(dever.task.sleep(0)))
|
||||
visit_all([1, 2, 3])
|
||||
}"#,
|
||||
"cannot run inline inside a suspending function",
|
||||
);
|
||||
checked(&sources(
|
||||
r#"visit(value: Int) () {}
|
||||
visit_all(values: Stream<Int>) () { parallel_each(visit, values, 2) }"#,
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use dever_runtime::{
|
||||
async_stream::AsyncStream, bytes::Bytes, channel::Channel, net, resource::Pull, task,
|
||||
};
|
||||
use futures_util::stream;
|
||||
|
||||
fn run_test(test: impl Future<Output = Result<(), String>>) {
|
||||
task::run_entry_with(
|
||||
task::RuntimeConfig {
|
||||
worker_threads: 1,
|
||||
max_blocking_threads: 2,
|
||||
task_capacity: 32,
|
||||
},
|
||||
async {
|
||||
tokio::time::timeout(Duration::from_secs(5), test)
|
||||
.await
|
||||
.map_err(|_| "async network test exceeded its deadline".to_owned())?
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
struct Dropped(Arc<AtomicBool>);
|
||||
impl Drop for Dropped {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_an_idle_stream_wakes_pull_and_drops_the_producer() {
|
||||
run_test(async {
|
||||
let entered = Channel::new(1)?;
|
||||
let ready = entered.clone();
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let marker = Dropped(dropped.clone());
|
||||
let stream = AsyncStream::<i64>::new(stream::unfold(marker, move |marker| {
|
||||
let ready = ready.clone();
|
||||
async move {
|
||||
ready.send(()).await.unwrap();
|
||||
std::future::pending::<()>().await;
|
||||
Some((Pull::Item(1), marker))
|
||||
}
|
||||
}));
|
||||
let alias = stream.clone();
|
||||
let reader = task::run(async move { Ok(alias.pull().await) }).await?;
|
||||
entered.receive().await?;
|
||||
stream.close();
|
||||
stream.close();
|
||||
assert_eq!(task::wait(reader).await?, None);
|
||||
assert!(dropped.load(Ordering::SeqCst));
|
||||
assert_eq!(stream.pull().await, None);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapped_stream_shares_cursor_and_emits_terminal_failure_once() {
|
||||
run_test(async {
|
||||
let stream = AsyncStream::new(stream::iter([
|
||||
Pull::Item(Ok(7)),
|
||||
Pull::Last(Err("failed")),
|
||||
Pull::Item(Ok(99)),
|
||||
]))
|
||||
.map(|value| value.map(|value| value * 2));
|
||||
let alias = stream.clone();
|
||||
assert_eq!(stream.pull().await, Some(Ok(14)));
|
||||
assert_eq!(alias.pull().await, Some(Err("failed")));
|
||||
assert_eq!(stream.pull().await, None);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_parallelism_applies_backpressure_before_pull_and_stop_drains_children() {
|
||||
run_test(async {
|
||||
let pulls = Arc::new(AtomicUsize::new(0));
|
||||
let observed = pulls.clone();
|
||||
let source_dropped = Arc::new(AtomicBool::new(false));
|
||||
let marker = Dropped(source_dropped.clone());
|
||||
let stream = AsyncStream::new(stream::unfold(marker, move |marker| {
|
||||
observed.fetch_add(1, Ordering::SeqCst);
|
||||
async move { Some((Pull::Item(1_i64), marker)) }
|
||||
}));
|
||||
let alias = stream.clone();
|
||||
let started = Channel::new(1)?;
|
||||
let notify = started.clone();
|
||||
let child_dropped = Arc::new(AtomicBool::new(false));
|
||||
let observed_child = child_dropped.clone();
|
||||
let consumer = task::run(async move {
|
||||
task::parallel_each_stream(stream, 1, move |_| {
|
||||
let notify = notify.clone();
|
||||
let marker = Dropped(observed_child.clone());
|
||||
async move {
|
||||
let _marker = marker;
|
||||
notify.send(()).await?;
|
||||
std::future::pending::<()>().await;
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.await
|
||||
})
|
||||
.await?;
|
||||
started.receive().await?;
|
||||
assert_eq!(pulls.load(Ordering::SeqCst), 1);
|
||||
task::stop(consumer).await?;
|
||||
assert!(source_dropped.load(Ordering::SeqCst));
|
||||
assert!(child_dropped.load(Ordering::SeqCst));
|
||||
assert_eq!(alias.pull().await, None);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_failure_interrupts_an_idle_producer() {
|
||||
run_test(async {
|
||||
let idle = Channel::new(1)?;
|
||||
let notify_idle = idle.clone();
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let stream = AsyncStream::new(stream::unfold(
|
||||
(false, Dropped(dropped.clone())),
|
||||
move |(sent, marker)| {
|
||||
let notify_idle = notify_idle.clone();
|
||||
async move {
|
||||
if sent {
|
||||
notify_idle.send(()).await.unwrap();
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
Some((Pull::Item(1_i64), (true, marker)))
|
||||
}
|
||||
},
|
||||
));
|
||||
let result = task::parallel_each_stream(stream, 2, move |_| {
|
||||
let idle = idle.clone();
|
||||
async move {
|
||||
idle.receive().await?;
|
||||
Err("expected handler failure".into())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert_eq!(result.unwrap_err(), "expected handler failure");
|
||||
assert!(dropped.load(Ordering::SeqCst));
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
async fn pair() -> Result<(net::Socket, net::Socket), String> {
|
||||
let listener = net::listen("127.0.0.1", 0).await?;
|
||||
let client = net::connect_timeout("127.0.0.1", net::port(&listener)?, 1000).await?;
|
||||
let server = net::accept(&listener).await?;
|
||||
net::timeout(&client, 1000)?;
|
||||
net::timeout(&server, 1000)?;
|
||||
Ok((client, server))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_duplex_progresses_with_one_worker_and_pending_read() {
|
||||
run_test(async {
|
||||
let (client, server) = pair().await?;
|
||||
let reading = client.clone();
|
||||
let reader = task::run(async move { net::read(&reading, 1).await }).await?;
|
||||
// A read may be pending on the same Socket while its write completes.
|
||||
net::write(&client, &Bytes::from_text("x")).await?;
|
||||
assert_eq!(net::read(&server, 1).await?.unwrap().to_text()?, "x");
|
||||
net::write(&server, &Bytes::from_text("y")).await?;
|
||||
assert_eq!(task::wait(reader).await?.unwrap().to_text()?, "y");
|
||||
let chunks = net::chunks(client.clone(), 1)?;
|
||||
chunks.close();
|
||||
// Closing a stream releases its alias; the explicitly held socket stays usable.
|
||||
net::write(&client, &Bytes::from_text("z")).await?;
|
||||
assert_eq!(net::read(&server, 1).await?.unwrap().to_text()?, "z");
|
||||
client.close()?;
|
||||
assert!(net::write(&client, &Bytes::from_text("x")).await.is_err());
|
||||
assert_eq!(net::read(&server, 1).await?, None);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_resources_wakes_pending_accepts_and_reads() {
|
||||
run_test(async {
|
||||
let listener = net::listen("127.0.0.1", 0).await?;
|
||||
let accepting = listener.clone();
|
||||
let accept = task::run(async move { net::accept(&accepting).await }).await?;
|
||||
let (client, server) = pair().await?;
|
||||
let reading = server.clone();
|
||||
let read = task::run(async move { net::read(&reading, 1).await }).await?;
|
||||
tokio::task::yield_now().await;
|
||||
listener.close()?;
|
||||
server.close()?;
|
||||
assert_eq!(task::wait(accept).await.unwrap_err(), "resource is closed");
|
||||
assert_eq!(task::wait(read).await.unwrap_err(), "resource is closed");
|
||||
assert_eq!(net::read(&client, 1).await?, None);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_timeout_does_not_close_or_consume_the_next_message() {
|
||||
run_test(async {
|
||||
let (client, server) = pair().await?;
|
||||
net::timeout(&server, 10)?;
|
||||
assert_eq!(
|
||||
net::read(&server, 1).await.unwrap_err(),
|
||||
"network operation timed out"
|
||||
);
|
||||
net::timeout(&server, 1000)?;
|
||||
net::write(&client, &Bytes::from_text("x")).await?;
|
||||
assert_eq!(net::read(&server, 1).await?.unwrap().to_text()?, "x");
|
||||
assert!(net::read(&server, 0).await.is_err());
|
||||
assert!(net::connect_timeout("localhost", 0, 1).await.is_err());
|
||||
assert!(net::connect("127.0.0.1", -1).await.is_err());
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_a_partial_write_closes_socket_before_another_write() {
|
||||
run_test(async {
|
||||
let (client, server) = pair().await?;
|
||||
// More than a loopback socket's send buffer, with no draining reader.
|
||||
let sending = client.clone();
|
||||
let writer =
|
||||
task::run(
|
||||
async move { net::write(&sending, &Bytes::new(vec![7; 16 * 1024 * 1024])).await },
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(net::read(&server, 1).await?.unwrap().values(), &[7]);
|
||||
task::stop(writer).await?;
|
||||
assert_eq!(
|
||||
net::write(&client, &Bytes::from_text("next"))
|
||||
.await
|
||||
.unwrap_err(),
|
||||
"resource is closed"
|
||||
);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopped_connection_consumer_releases_its_listener() {
|
||||
run_test(async {
|
||||
let listener = net::listen("127.0.0.1", 0).await?;
|
||||
let port = net::port(&listener)?;
|
||||
let stream = net::connections(listener);
|
||||
let consumer =
|
||||
task::run(
|
||||
async move { task::parallel_each_stream(stream, 2, |_| async { Ok(()) }).await },
|
||||
)
|
||||
.await?;
|
||||
tokio::task::yield_now().await;
|
||||
task::stop(consumer).await?;
|
||||
let rebound = net::listen("127.0.0.1", port).await?;
|
||||
assert_eq!(net::port(&rebound)?, port);
|
||||
let stream = net::connections(rebound.clone());
|
||||
rebound.close()?;
|
||||
assert!(stream.pull().await.unwrap().is_err());
|
||||
assert!(stream.pull().await.is_none());
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
use dever_runtime::{channel::Channel, task};
|
||||
|
||||
struct DropSignal(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for DropSignal {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tasks_return_values_and_stop_waits_for_cleanup() {
|
||||
task::run_entry(async {
|
||||
let task = task::run(async { Ok::<_, String>(42_i64) }).await?;
|
||||
assert_eq!(task::wait(task).await?, 42);
|
||||
|
||||
let started = Channel::new(1)?;
|
||||
let child_started = started.clone();
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let child_dropped = Arc::clone(&dropped);
|
||||
let task = task::run(async move {
|
||||
let _drop_signal = DropSignal(child_dropped);
|
||||
child_started.send(()).await?;
|
||||
std::future::pending::<()>().await;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(started.receive().await?, Some(()));
|
||||
task::stop(task).await?;
|
||||
assert!(dropped.load(Ordering::SeqCst));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_failure_cancels_and_drains_remaining_tasks() {
|
||||
task::run_entry(async {
|
||||
let started = Channel::new(1)?;
|
||||
let child_started = started.clone();
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let child_dropped = Arc::clone(&dropped);
|
||||
let mut group = task::Group::new(2)?;
|
||||
group
|
||||
.run(async move {
|
||||
let _drop_signal = DropSignal(child_dropped);
|
||||
child_started.send(()).await?;
|
||||
std::future::pending::<()>().await;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(started.receive().await?, Some(()));
|
||||
group
|
||||
.run(async { Err::<(), _>("expected child failure".to_owned()) })
|
||||
.await?;
|
||||
|
||||
assert_eq!(group.wait().await.unwrap_err(), "expected child failure");
|
||||
assert!(dropped.load(Ordering::SeqCst));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_capacity_applies_backpressure_before_starting_more_work() {
|
||||
task::run_entry(async {
|
||||
let release = Channel::new(1)?;
|
||||
let wait_for_release = release.clone();
|
||||
let released = Arc::new(AtomicBool::new(false));
|
||||
let release_recorded = Arc::clone(&released);
|
||||
let mut group = task::Group::new(1)?;
|
||||
group
|
||||
.run(async move {
|
||||
wait_for_release.receive().await?;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
let unblock = task::run(async move {
|
||||
task::sleep(1).await?;
|
||||
release_recorded.store(true, Ordering::SeqCst);
|
||||
release.send(()).await
|
||||
})
|
||||
.await?;
|
||||
group.run(async { Ok(()) }).await?;
|
||||
assert!(released.load(Ordering::SeqCst));
|
||||
group.wait().await?;
|
||||
task::wait(unblock).await?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channels_are_bounded_and_close_after_buffered_values() {
|
||||
task::run_entry(async {
|
||||
let channel = Channel::new(1)?;
|
||||
channel.send(7_i64).await?;
|
||||
channel.close().await?;
|
||||
assert_eq!(channel.receive().await?, Some(7));
|
||||
assert_eq!(channel.receive().await?, None);
|
||||
assert!(channel.send(8).await.is_err());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(Channel::<i64>::new(0).is_err());
|
||||
assert!(Channel::<i64>::new(65_537).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_channel_wakes_blocked_senders_and_receivers() {
|
||||
task::run_entry(async {
|
||||
let full = Channel::new(1)?;
|
||||
full.send(1_i64).await?;
|
||||
let blocked_sender = full.clone();
|
||||
let send = task::run(async move { blocked_sender.send(2).await }).await?;
|
||||
full.close().await?;
|
||||
assert_eq!(task::wait(send).await.unwrap_err(), "channel is closed");
|
||||
assert_eq!(full.receive().await?, Some(1));
|
||||
assert_eq!(full.receive().await?, None);
|
||||
|
||||
let empty = Channel::<i64>::new(1)?;
|
||||
let blocked_receiver = empty.clone();
|
||||
let receive = task::run(async move { blocked_receiver.receive().await }).await?;
|
||||
empty.close().await?;
|
||||
assert_eq!(task::wait(receive).await?, None);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_and_blocking_use_the_bounded_blocking_pool() {
|
||||
task::run_entry(async {
|
||||
task::sleep(0).await?;
|
||||
assert_eq!(
|
||||
task::sleep(-1).await,
|
||||
Err("sleep duration must not be negative")
|
||||
);
|
||||
assert_eq!(task::parallel(|| Ok::<_, String>(6 * 7)).await?, 42);
|
||||
assert_eq!(task::blocking(|| Ok::<_, String>("done")).await?, "done");
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_configuration_rejects_unbounded_thread_counts() {
|
||||
let result = task::run_entry_with(
|
||||
task::RuntimeConfig {
|
||||
worker_threads: task::MAX_ASYNC_WORKERS + 1,
|
||||
max_blocking_threads: 1,
|
||||
task_capacity: 1,
|
||||
},
|
||||
async { Ok::<_, String>(()) },
|
||||
);
|
||||
assert!(result.unwrap_err().contains("async worker count"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_and_parent_failures_wait_for_descendant_cleanup() {
|
||||
let root_dropped = Arc::new(AtomicBool::new(false));
|
||||
let root_child_dropped = Arc::clone(&root_dropped);
|
||||
let root_result = task::run_entry(async move {
|
||||
let started = Channel::new(1)?;
|
||||
let child_started = started.clone();
|
||||
let _child = task::run(async move {
|
||||
let _drop_signal = DropSignal(root_child_dropped);
|
||||
child_started.send(()).await?;
|
||||
std::future::pending::<()>().await;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(started.receive().await?, Some(()));
|
||||
Err::<(), _>("root failure".to_owned())
|
||||
});
|
||||
assert_eq!(root_result.unwrap_err(), "root failure");
|
||||
assert!(root_dropped.load(Ordering::SeqCst));
|
||||
|
||||
let descendant_dropped = Arc::new(AtomicBool::new(false));
|
||||
let child_dropped = Arc::clone(&descendant_dropped);
|
||||
task::run_entry(async move {
|
||||
let child_started = Channel::new(1)?;
|
||||
let wait_for_child = child_started.clone();
|
||||
let parent_ready = Channel::new(1)?;
|
||||
let notify_root = parent_ready.clone();
|
||||
let parent = task::run(async move {
|
||||
let _descendant = task::run(async move {
|
||||
let _drop_signal = DropSignal(child_dropped);
|
||||
child_started.send(()).await?;
|
||||
std::future::pending::<()>().await;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(wait_for_child.receive().await?, Some(()));
|
||||
notify_root.send(()).await?;
|
||||
Err::<(), _>("parent failure".to_owned())
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(parent_ready.receive().await?, Some(()));
|
||||
assert_eq!(task::wait(parent).await.unwrap_err(), "parent failure");
|
||||
assert!(descendant_dropped.load(Ordering::SeqCst));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let group_dropped = Arc::new(AtomicBool::new(false));
|
||||
let group_child_dropped = Arc::clone(&group_dropped);
|
||||
let group_result = task::run_entry(async move {
|
||||
let started = Channel::new(1)?;
|
||||
let child_started = started.clone();
|
||||
let mut group = task::Group::new(1)?;
|
||||
group
|
||||
.run(async move {
|
||||
let _drop_signal = DropSignal(group_child_dropped);
|
||||
child_started.send(()).await?;
|
||||
std::future::pending::<()>().await;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(started.receive().await?, Some(()));
|
||||
Err::<(), _>("group parent failure".to_owned())
|
||||
});
|
||||
assert_eq!(group_result.unwrap_err(), "group parent failure");
|
||||
assert!(group_dropped.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopping_a_task_waits_for_started_blocking_work() {
|
||||
let finished = Arc::new(AtomicBool::new(false));
|
||||
let blocking_finished = Arc::clone(&finished);
|
||||
let (started, running) = mpsc::channel();
|
||||
task::run_entry_with(
|
||||
task::RuntimeConfig {
|
||||
worker_threads: 1,
|
||||
max_blocking_threads: 1,
|
||||
task_capacity: 4,
|
||||
},
|
||||
async move {
|
||||
let child = task::run(async move {
|
||||
task::blocking(move || {
|
||||
started.send(()).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
blocking_finished.store(true, Ordering::SeqCst);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
})
|
||||
.await?;
|
||||
running.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||
task::stop(child).await?;
|
||||
assert!(finished.load(Ordering::SeqCst));
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_parallel_each_shares_the_configured_blocking_limit() {
|
||||
let active = Arc::new(AtomicUsize::new(0));
|
||||
let peak = Arc::new(AtomicUsize::new(0));
|
||||
task::run_entry_with(
|
||||
task::RuntimeConfig {
|
||||
worker_threads: 2,
|
||||
max_blocking_threads: 2,
|
||||
task_capacity: 4,
|
||||
},
|
||||
async {
|
||||
let run_batch = |active: Arc<AtomicUsize>, peak: Arc<AtomicUsize>| async move {
|
||||
task::parallel_each(0..32, 256, move |_| {
|
||||
let current = active.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
peak.fetch_max(current, Ordering::SeqCst);
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
active.fetch_sub(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
};
|
||||
let first = task::run(run_batch(Arc::clone(&active), Arc::clone(&peak))).await?;
|
||||
let second = task::run(run_batch(Arc::clone(&active), Arc::clone(&peak))).await?;
|
||||
task::wait(first).await?;
|
||||
task::wait(second).await?;
|
||||
assert_eq!(peak.load(Ordering::SeqCst), 2);
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
mod support;
|
||||
|
||||
use support::{checked, rejected, sources, stdout};
|
||||
|
||||
#[test]
|
||||
fn tcp_server_example_checks_without_starting_a_service() {
|
||||
checked(&sources(include_str!(
|
||||
"../../../examples/old/dever/async_tcp/module/main.dever"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_stream_handlers_and_transfer_rules_are_checked() {
|
||||
let source = r#"visit(value: Int) () {}
|
||||
public consume(route: handler(value: Int) (), stream: AsyncStream<Int>) () {
|
||||
each(route, stream)
|
||||
parallel_each(route, stream, 2)
|
||||
close(stream)
|
||||
}"#;
|
||||
let input = sources(source);
|
||||
checked(&input);
|
||||
let formatted = dever_core::format::format(&input.files()[0]).unwrap();
|
||||
let roundtrip = sources(&formatted);
|
||||
checked(&roundtrip);
|
||||
assert_eq!(
|
||||
dever_core::format::format(&roundtrip.files()[0]).unwrap(),
|
||||
formatted
|
||||
);
|
||||
checked(&sources(
|
||||
"visit(value: Int) () {}\nconsume(stream: AsyncStream<Int>) () { each(visit, stream) }",
|
||||
));
|
||||
checked(&sources(
|
||||
"visit(value: Int) () {}\nconsume(stream: AsyncStream<Int>) () { parallel_each(visit, stream, 2) }",
|
||||
));
|
||||
rejected(
|
||||
"visit(value: Int) () { dever.io.println(int.to_text(value)) }\nconsume(stream: AsyncStream<Int>) () { each(visit, stream) }",
|
||||
"cannot run inline inside a suspending function",
|
||||
);
|
||||
rejected(
|
||||
"consume(stream: AsyncStream<Stream<Int>>) () { close(stream) }",
|
||||
"must be transferable between threads",
|
||||
);
|
||||
checked(&sources(
|
||||
"main() (result: dever.net.ListenResult) { result = result(dever.net.listen(\"127.0.0.1\", 0)) }",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_async_handler_forwarding_uses_bounded_stream_tasks() {
|
||||
let source = r#"handle(event: dever.net.ConnectResult.Connected(socket), stream: AsyncStream<dever.net.ConnectResult>) () recover("test observes network failures through the client read") {
|
||||
close(stream)
|
||||
dever.net.write(socket, dever.bytes.from_text("x"))
|
||||
dever.net.close(socket)
|
||||
}
|
||||
handle(event: dever.net.ConnectResult.Failed(message), stream: AsyncStream<dever.net.ConnectResult>) () recover("test observes network failures through the client read") { close(stream) }
|
||||
dispatch(route: handler(event: dever.net.ConnectResult, stream: AsyncStream<dever.net.ConnectResult>) (), stream: AsyncStream<dever.net.ConnectResult>) () {
|
||||
parallel_each(route, stream, 2, stream)
|
||||
}
|
||||
received(result: dever.io.ReadResult.Done(state)) (answer: Bool) recover("test asserts the received result") { answer = received_state(state) }
|
||||
received(result: other) (answer: Bool) recover("test asserts the received result") { answer = false }
|
||||
received_state(state: dever.io.ReadState.Read(bytes)) (answer: Bool) { answer = bytes == dever.bytes.from_text("x") }
|
||||
received_state(state: other) (answer: Bool) { answer = false }
|
||||
connected(result: dever.net.ConnectResult.Connected(client), listener: dever.system.Listener) (answer: Bool) recover("test asserts the connection result") {
|
||||
dever.net.timeout(client, 1000)
|
||||
stream = dever.net.connections(listener)
|
||||
dispatch(handle, stream)
|
||||
answer = received(result(dever.net.read(client, 1)))
|
||||
dever.net.close(client)
|
||||
dever.net.close_listener(listener)
|
||||
}
|
||||
connected(result: other, listener: dever.system.Listener) (answer: Bool) recover("test asserts the connection result") { answer = false }
|
||||
bound(result: dever.net.PortResult.Bound(port), listener: dever.system.Listener) (answer: Bool) recover("test asserts the bound result") { answer = connected(result(dever.net.connect("127.0.0.1", port)), listener) }
|
||||
bound(result: other, listener: dever.system.Listener) (answer: Bool) recover("test asserts the bound result") { answer = false }
|
||||
listening(result: dever.net.ListenResult.Listening(listener)) (answer: Bool) recover("test asserts the listen result") { answer = bound(result(dever.net.port(listener)), listener) }
|
||||
listening(result: other) (answer: Bool) recover("test asserts the listen result") { answer = false }
|
||||
public main() (answer: Bool) { answer = listening(result(dever.net.listen("127.0.0.1", 0))) }
|
||||
"#;
|
||||
let input = sources(source);
|
||||
let generated = dever_core::native::emit(&checked(&input), &input, "main.main").unwrap();
|
||||
assert!(generated.contains("task::parallel_each_stream_typed("));
|
||||
assert_eq!(stdout(source), "answer = true\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_listener_stream_yields_one_failure() {
|
||||
assert_eq!(
|
||||
stdout(
|
||||
r#"count(event: dever.net.ConnectResult.Failed(message), state: Int) (next: Int) recover("test observes count failures in its assertions") { next = state + 1 }
|
||||
count(event: dever.net.ConnectResult.Connected(socket), state: Int) (next: Int) recover("test observes count failures in its assertions") { next = state + 100 }
|
||||
listening(result: dever.net.ListenResult.Listening(listener)) (answer: Int) recover("test observes listening failures in its assertions") {
|
||||
stream = dever.net.connections(listener)
|
||||
dever.net.close_listener(listener)
|
||||
answer = reduce(count, stream, 0) + reduce(count, stream, 0)
|
||||
}
|
||||
listening(result: other) (answer: Int) recover("test observes listening failures in its assertions") { answer = -1 }
|
||||
public main() (answer: Int) { answer = listening(result(dever.net.listen("127.0.0.1", 0))) }
|
||||
"#
|
||||
),
|
||||
"answer = 1\n",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_socket_stream_each_executes_async_actions() {
|
||||
assert_eq!(
|
||||
stdout(
|
||||
r#"show(event: dever.io.ReadEvent.Chunk(bytes)) () recover("test observes show failures in its assertions") { blocking(dever.io.println(int.to_text(dever.bytes.length(bytes)))) }
|
||||
show(event: dever.io.ReadEvent.Failed(message)) () recover("test observes show failures in its assertions") { blocking(dever.io.println("failure")) }
|
||||
consume(result: dever.net.ReadStreamResult.Streaming(stream)) () recover("test observes consume failures in its assertions") { each(show, stream) }
|
||||
consume(result: dever.net.ReadStreamResult.Failed(message)) () recover("test observes consume failures in its assertions") { blocking(dever.io.println("failure")) }
|
||||
accepted(result: dever.net.ConnectResult.Connected(server), client: dever.system.Socket, listener: dever.system.Listener) (answer: Bool) recover("test observes accepted failures in its assertions") {
|
||||
dever.net.timeout(server, 1000)
|
||||
dever.net.timeout(client, 1000)
|
||||
dever.net.write(client, dever.bytes.from_text("x"))
|
||||
dever.net.close(client)
|
||||
consume(result(dever.net.chunks(server, 1)))
|
||||
dever.net.close(server)
|
||||
dever.net.close_listener(listener)
|
||||
answer = true
|
||||
}
|
||||
accepted(result: other, client: dever.system.Socket, listener: dever.system.Listener) (answer: Bool) recover("test observes accepted failures in its assertions") { answer = false }
|
||||
connected(result: dever.net.ConnectResult.Connected(client), listener: dever.system.Listener) (answer: Bool) recover("test observes connected failures in its assertions") { answer = accepted(result(dever.net.accept(listener)), client, listener) }
|
||||
connected(result: other, listener: dever.system.Listener) (answer: Bool) recover("test observes connected failures in its assertions") { answer = false }
|
||||
bound(result: dever.net.PortResult.Bound(port), listener: dever.system.Listener) (answer: Bool) recover("test observes bound failures in its assertions") { answer = connected(result(dever.net.connect("127.0.0.1", port)), listener) }
|
||||
bound(result: other, listener: dever.system.Listener) (answer: Bool) recover("test observes bound failures in its assertions") { answer = false }
|
||||
listening(result: dever.net.ListenResult.Listening(listener)) (answer: Bool) recover("test observes listening failures in its assertions") { answer = bound(result(dever.net.port(listener)), listener) }
|
||||
listening(result: other) (answer: Bool) recover("test observes listening failures in its assertions") { answer = false }
|
||||
public main() (answer: Bool) { answer = listening(result(dever.net.listen("127.0.0.1", 0))) }
|
||||
"#
|
||||
),
|
||||
"1\nanswer = true\n",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_use_optimization_preserves_socket_alias_lifetime_until_scope_exit() {
|
||||
assert_eq!(
|
||||
stdout(
|
||||
r#"type Peer { socket: dever.system.Socket }
|
||||
send(peer: Peer) () recover("test observes socket lifetime through the peer read") { dever.net.write(peer.socket, dever.bytes.from_text("x")) }
|
||||
timeout(result: dever.io.ReadResult.Failed(message)) (answer: Bool) recover("test observes timeout failures in its assertions") { answer = true }
|
||||
timeout(result: other) (answer: Bool) recover("test observes timeout failures in its assertions") { answer = false }
|
||||
accepted(result: dever.net.ConnectResult.Connected(server), client: dever.system.Socket, listener: dever.system.Listener) (answer: Bool) recover("test observes accepted failures in its assertions") {
|
||||
dever.net.timeout(server, 20)
|
||||
peer = Peer { socket = client }
|
||||
send(peer)
|
||||
first = dever.net.read(server, 1)
|
||||
answer = timeout(result(dever.net.read(server, 1)))
|
||||
dever.net.close(server)
|
||||
dever.net.close_listener(listener)
|
||||
}
|
||||
accepted(result: other, client: dever.system.Socket, listener: dever.system.Listener) (answer: Bool) recover("test observes accepted failures in its assertions") { answer = false }
|
||||
connected(result: dever.net.ConnectResult.Connected(client), listener: dever.system.Listener) (answer: Bool) recover("test observes connected failures in its assertions") { answer = accepted(result(dever.net.accept(listener)), client, listener) }
|
||||
connected(result: other, listener: dever.system.Listener) (answer: Bool) recover("test observes connected failures in its assertions") { answer = false }
|
||||
bound(result: dever.net.PortResult.Bound(port), listener: dever.system.Listener) (answer: Bool) recover("test observes bound failures in its assertions") { answer = connected(result(dever.net.connect("127.0.0.1", port)), listener) }
|
||||
bound(result: other, listener: dever.system.Listener) (answer: Bool) recover("test observes bound failures in its assertions") { answer = false }
|
||||
listening(result: dever.net.ListenResult.Listening(listener)) (answer: Bool) recover("test observes listening failures in its assertions") { answer = bound(result(dever.net.port(listener)), listener) }
|
||||
listening(result: other) (answer: Bool) recover("test observes listening failures in its assertions") { answer = false }
|
||||
public main() (answer: Bool) { answer = listening(result(dever.net.listen("127.0.0.1", 0))) }
|
||||
"#
|
||||
),
|
||||
"answer = true\n",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
#[path = "support/temp.rs"]
|
||||
mod temp;
|
||||
|
||||
use std::fs;
|
||||
|
||||
use dever_runtime::api;
|
||||
use dever_runtime::auth::{self, Error, Identity};
|
||||
use dever_runtime::bytes::Bytes;
|
||||
use dever_runtime::config::Settings;
|
||||
use dever_runtime::http::{Header, Request};
|
||||
|
||||
fn settings() -> (temp::TemporaryDirectory, Settings) {
|
||||
let root = temp::TemporaryDirectory::new();
|
||||
fs::create_dir(root.path().join("config")).unwrap();
|
||||
fs::write(root.path().join("config/setting.json"), r#"{
|
||||
"auth": {
|
||||
"providers": {
|
||||
"session": {
|
||||
"verify": "user.account.verify",
|
||||
"jwtSecret": "0123456789abcdef0123456789abcdef",
|
||||
"ttlSeconds": 3600,
|
||||
"cookie": "cms_session"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sites": {
|
||||
"admin": {"path": "admin", "auth": "session", "hosts": ["cms.example.com"], "origin": "https://cms.example.com"},
|
||||
"front": {"path": "front", "auth": "session", "hosts": ["cms.example.com"], "origin": "https://cms.example.com"}
|
||||
}
|
||||
}"#).unwrap();
|
||||
let settings = Settings::load_project(root.path()).unwrap();
|
||||
(root, settings)
|
||||
}
|
||||
|
||||
fn request(method: &str, extra_headers: Vec<Header>) -> Request {
|
||||
let mut headers = vec![Header {
|
||||
name: "host".into(),
|
||||
value: Bytes::from_text("cms.example.com"),
|
||||
}];
|
||||
headers.extend(extra_headers);
|
||||
Request {
|
||||
method: method.into(),
|
||||
target: "/news/article/profile".into(),
|
||||
headers,
|
||||
body: Bytes::from_text(""),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn site_scope_issues_and_verifies_a_bound_identity() {
|
||||
let (_root, settings) = settings();
|
||||
let login_request = request(
|
||||
"POST",
|
||||
vec![Header {
|
||||
name: "origin".into(),
|
||||
value: Bytes::from_text("https://cms.example.com"),
|
||||
}],
|
||||
);
|
||||
let login_settings = &settings;
|
||||
let response = api::scoped_request(login_request, move |request| async move {
|
||||
let prepared = auth::prepare_with_settings(login_settings, &["front"], &request, true)
|
||||
.map_err(|error| error.to_string())?;
|
||||
auth::scope(prepared, None, async {
|
||||
auth::issue_cookie("account:7", "session:19", Some("tenant:3"))?;
|
||||
api::commit_response_metadata()?;
|
||||
Ok(api::success(dever_runtime::wire::Encoded::null()))
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let cookie = response
|
||||
.headers
|
||||
.iter()
|
||||
.find(|header| header.name == "set-cookie")
|
||||
.map(|header| {
|
||||
std::str::from_utf8(header.value.values())
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned()
|
||||
})
|
||||
.expect("issued session cookie");
|
||||
let authenticated = request(
|
||||
"GET",
|
||||
vec![Header {
|
||||
name: "cookie".into(),
|
||||
value: Bytes::from_string(cookie.clone()),
|
||||
}],
|
||||
);
|
||||
let prepared =
|
||||
auth::prepare_with_settings(&settings, &["front", "profile"], &authenticated, false)
|
||||
.unwrap();
|
||||
let claims = prepared.claims().unwrap();
|
||||
assert_eq!(claims.subject, "account:7");
|
||||
assert_eq!(claims.session, "session:19");
|
||||
assert_eq!(claims.tenant.as_deref(), Some("tenant:3"));
|
||||
assert_eq!(claims.site, "front");
|
||||
|
||||
let identity = Identity {
|
||||
id: "account:7".into(),
|
||||
user_id: Some(7),
|
||||
tenant_id: Some(3),
|
||||
};
|
||||
auth::scope(prepared, Some(identity), async {
|
||||
assert_eq!(auth::site_key().unwrap(), "front");
|
||||
assert_eq!(auth::id().unwrap(), "account:7");
|
||||
assert_eq!(auth::session().unwrap(), "session:19");
|
||||
assert_eq!(auth::user_id().unwrap(), Some(7));
|
||||
assert_eq!(auth::tenant_id().unwrap(), Some(3));
|
||||
#[cfg(feature = "sqlite")]
|
||||
dever_runtime::job::bind_permission("news.article.front.read").unwrap();
|
||||
#[cfg(feature = "sqlite")]
|
||||
assert!(matches!(
|
||||
dever_runtime::job::execution_identity().unwrap(),
|
||||
dever_runtime::job::ExecutionIdentity::User { permission_key, .. }
|
||||
if permission_key == "news.article.front.read"
|
||||
));
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let cross_site = request(
|
||||
"GET",
|
||||
vec![Header {
|
||||
name: "cookie".into(),
|
||||
value: Bytes::from_string(cookie),
|
||||
}],
|
||||
);
|
||||
assert_eq!(
|
||||
auth::prepare_with_settings(&settings, &["admin"], &cross_site, false)
|
||||
.err()
|
||||
.expect("site-bound token must fail"),
|
||||
Error::Unauthorized
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_routes_reject_bad_credentials_and_cookie_writes_require_origin() {
|
||||
let (_root, settings) = settings();
|
||||
let invalid = request(
|
||||
"GET",
|
||||
vec![Header {
|
||||
name: "authorization".into(),
|
||||
value: Bytes::from_text("Bearer invalid"),
|
||||
}],
|
||||
);
|
||||
assert_eq!(
|
||||
auth::prepare_with_settings(&settings, &["front"], &invalid, true)
|
||||
.err()
|
||||
.expect("invalid credential must not become anonymous"),
|
||||
Error::Unauthorized
|
||||
);
|
||||
|
||||
let cookie_write = request(
|
||||
"DELETE",
|
||||
vec![Header {
|
||||
name: "cookie".into(),
|
||||
value: Bytes::from_text("cms_session=v1.aW52YWxpZA"),
|
||||
}],
|
||||
);
|
||||
assert_eq!(
|
||||
auth::prepare_with_settings(&settings, &["front"], &cookie_write, false)
|
||||
.err()
|
||||
.expect("unsafe cookie request requires Origin"),
|
||||
Error::Forbidden
|
||||
);
|
||||
|
||||
let stale_cookie = request(
|
||||
"POST",
|
||||
vec![Header {
|
||||
name: "cookie".into(),
|
||||
value: Bytes::from_text("cms_session=v1.invalid"),
|
||||
}],
|
||||
);
|
||||
let prepared = auth::prepare_with_settings(&settings, &["front"], &stale_cookie, true)
|
||||
.expect("public login must ignore stale Cookie credentials");
|
||||
assert!(prepared.claims().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsafe_cookie_writes_require_the_configured_external_origin() {
|
||||
let (_root, settings) = settings();
|
||||
for origin in [
|
||||
None,
|
||||
Some("http://cms.example.com"),
|
||||
Some("https://cms.example.com:444"),
|
||||
Some("https://other.example.com"),
|
||||
] {
|
||||
let headers = origin
|
||||
.into_iter()
|
||||
.map(|value| Header {
|
||||
name: "origin".into(),
|
||||
value: Bytes::from_text(value),
|
||||
})
|
||||
.collect();
|
||||
let request = request("POST", headers);
|
||||
let test_settings = &settings;
|
||||
let response = api::scoped_request(request, |request| async move {
|
||||
let prepared = auth::prepare_with_settings(test_settings, &["front"], &request, true)
|
||||
.map_err(|error| error.to_string())?;
|
||||
auth::scope(prepared, None, async {
|
||||
assert!(auth::issue_cookie("account:7", "session:19", Some("tenant:3")).is_err());
|
||||
Ok(api::success(dever_runtime::wire::Encoded::null()))
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!response
|
||||
.headers
|
||||
.iter()
|
||||
.any(|header| header.name == "set-cookie")
|
||||
);
|
||||
}
|
||||
|
||||
let valid = request(
|
||||
"POST",
|
||||
vec![Header {
|
||||
name: "origin".into(),
|
||||
value: Bytes::from_text("https://cms.example.com"),
|
||||
}],
|
||||
);
|
||||
let test_settings = &settings;
|
||||
let response = api::scoped_request(valid, |request| async move {
|
||||
let prepared = auth::prepare_with_settings(test_settings, &["front"], &request, true)
|
||||
.map_err(|error| error.to_string())?;
|
||||
auth::scope(prepared, None, async {
|
||||
auth::issue_cookie("account:7", "session:19", Some("tenant:3"))?;
|
||||
api::commit_response_metadata()?;
|
||||
Ok(api::success(dever_runtime::wire::Encoded::null()))
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
response
|
||||
.headers
|
||||
.iter()
|
||||
.any(|header| header.name == "set-cookie")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
mod support;
|
||||
|
||||
use std::ffi::OsStr;
|
||||
|
||||
use dever_runtime::{config, database};
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires an isolated database configured in config/setting.json"]
|
||||
fn postgres_authorization_preserves_site_scoped_roles_and_catalog() {
|
||||
let executable = std::env::current_exe().unwrap();
|
||||
if executable.file_stem().unwrap() != OsStr::new("authorization-postgres-case") {
|
||||
support::postgres::run_in_isolated_schema("authorization_postgres", |setting| async move {
|
||||
let project = support::temp::TemporaryDirectory::new();
|
||||
std::fs::create_dir(project.path().join("config")).unwrap();
|
||||
std::fs::write(
|
||||
project.path().join("config/setting.json"),
|
||||
setting.settings_json(),
|
||||
)
|
||||
.unwrap();
|
||||
let child = project.path().join("authorization-postgres-case");
|
||||
std::fs::copy(executable, &child).unwrap();
|
||||
let output = std::process::Command::new(child)
|
||||
.args([
|
||||
"--ignored",
|
||||
"--exact",
|
||||
"postgres_authorization_preserves_site_scoped_roles_and_catalog",
|
||||
"--nocapture",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
Ok(())
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
config::bootstrap(
|
||||
config::RuntimeProfile {
|
||||
sqlite: false,
|
||||
postgres: true,
|
||||
},
|
||||
&[(Some("default"), "auth")],
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
dever_runtime::task::run_entry(async {
|
||||
database::prepare().await.unwrap();
|
||||
let catalog = database::database_for(Some("default"), "auth").unwrap();
|
||||
support::authorization::assert_contract(catalog.clone(), catalog).await;
|
||||
database::shutdown().await.unwrap();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
mod support;
|
||||
|
||||
#[cfg(feature = "sqlite")]
|
||||
#[test]
|
||||
fn sqlite_authorization_uses_exact_site_scoped_role_permissions() {
|
||||
use dever_runtime::{config, database};
|
||||
|
||||
let executable = std::env::current_exe().unwrap();
|
||||
if executable.file_stem().unwrap() != "authorization-runtime-case" {
|
||||
let project = support::temp::TemporaryDirectory::new();
|
||||
std::fs::create_dir(project.path().join("config")).unwrap();
|
||||
std::fs::write(
|
||||
project.path().join("config/setting.json"),
|
||||
r#"{"database":{"default":{"type":"sqlite","path":"data/db/auth.db","max_connections":2}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let child = project.path().join("authorization-runtime-case");
|
||||
std::fs::copy(executable, &child).unwrap();
|
||||
let output = std::process::Command::new(child)
|
||||
.args([
|
||||
"--exact",
|
||||
"sqlite_authorization_uses_exact_site_scoped_role_permissions",
|
||||
"--nocapture",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
config::bootstrap(
|
||||
config::RuntimeProfile {
|
||||
sqlite: true,
|
||||
postgres: false,
|
||||
},
|
||||
&[(Some("default"), "auth")],
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
dever_runtime::task::run_entry(async {
|
||||
let catalog = database::database_for(Some("default"), "auth").unwrap();
|
||||
support::authorization::assert_contract(catalog.clone(), catalog).await;
|
||||
database::shutdown().await.unwrap();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use dever_core::source::SourceMap;
|
||||
use std::path::Path;
|
||||
|
||||
fn load(root: &Path) -> dever_core::hir::Program {
|
||||
let sources = SourceMap::load_project(&root.join("module"), &root.join("test"))
|
||||
.unwrap_or_else(|error| panic!("failed to load {}: {error:?}", root.display()));
|
||||
dever_core::check(&sources).unwrap_or_else(|errors| {
|
||||
panic!(
|
||||
"{} failed:\n{}",
|
||||
root.display(),
|
||||
errors
|
||||
.iter()
|
||||
.map(|error| error.render(&sources))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cms_plain_and_markdown_have_the_same_contracts() {
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let plain = load(&workspace.join("examples/cms/dever"));
|
||||
let markdown = load(&workspace.join("examples/cms/md"));
|
||||
assert_eq!(plain.api_snapshot(), markdown.api_snapshot());
|
||||
assert_eq!(plain.model_snapshot(), markdown.model_snapshot());
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
mod support;
|
||||
|
||||
use dever_runtime::concurrent;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit bounded local scheduling benchmark"]
|
||||
fn parallel_task_granularity_measurement() {
|
||||
use std::time::Instant;
|
||||
let values: Vec<usize> = (0..32_768).collect();
|
||||
for rounds in [0, 512] {
|
||||
for sample in 0..6 {
|
||||
let total = AtomicUsize::new(0);
|
||||
let apply = |value: usize| {
|
||||
let mut result = value;
|
||||
for _ in 0..rounds {
|
||||
result =
|
||||
std::hint::black_box(result.wrapping_mul(1664525).wrapping_add(1013904223));
|
||||
}
|
||||
total.fetch_add(result, Ordering::Relaxed);
|
||||
Ok(())
|
||||
};
|
||||
let start = Instant::now();
|
||||
concurrent::each(values.iter().copied(), 4, apply).unwrap();
|
||||
let parallel = start.elapsed();
|
||||
let expected = total.swap(0, Ordering::Relaxed);
|
||||
let start = Instant::now();
|
||||
concurrent::each_slice(&values, 4, |value| apply(*value)).unwrap();
|
||||
let batched = start.elapsed();
|
||||
assert_eq!(total.swap(0, Ordering::Relaxed), expected);
|
||||
let start = Instant::now();
|
||||
for value in &values {
|
||||
apply(*value).unwrap();
|
||||
}
|
||||
let serial = start.elapsed();
|
||||
assert_eq!(total.load(Ordering::Relaxed), expected);
|
||||
println!(
|
||||
"rounds={rounds} sample={sample} queue_ns={} batch_ns={} serial_ns={}",
|
||||
parallel.as_nanos(),
|
||||
batched.as_nanos(),
|
||||
serial.as_nanos()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batched_memory_actions_visit_once_and_stop_within_a_batch_on_failure() {
|
||||
let values: Vec<_> = (0..4096).collect();
|
||||
let seen: Vec<_> = values.iter().map(|_| AtomicUsize::new(0)).collect();
|
||||
concurrent::each_slice(&values, 4, |value| {
|
||||
seen[*value].fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(seen.iter().all(|count| count.load(Ordering::SeqCst) == 1));
|
||||
let calls = AtomicUsize::new(0);
|
||||
let failed = concurrent::each_slice(&values, 1, |_| {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
Err("expected".into())
|
||||
});
|
||||
assert_eq!(failed, Err("expected".into()));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
assert!(
|
||||
concurrent::each_slice(&values, 0, |_| panic!("invalid workers invoked action")).is_err()
|
||||
);
|
||||
|
||||
let calls = AtomicUsize::new(0);
|
||||
concurrent::each_slice(&[1], 256, |_| {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_workers_overlap_and_all_finish_before_returning() {
|
||||
let (started, starts) = mpsc::channel();
|
||||
let (release, releases) = mpsc::channel();
|
||||
let releases = Mutex::new(releases);
|
||||
let finished = Arc::new(AtomicUsize::new(0));
|
||||
let completed = Arc::clone(&finished);
|
||||
let job = std::thread::spawn(move || {
|
||||
concurrent::each(0..2, 2, |_| {
|
||||
started.send(()).unwrap();
|
||||
releases
|
||||
.lock()
|
||||
.unwrap()
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.map_err(|error| error.to_string())?;
|
||||
completed.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
let first = starts.recv_timeout(Duration::from_secs(2));
|
||||
let second = starts.recv_timeout(Duration::from_secs(2));
|
||||
let _ = release.send(());
|
||||
let _ = release.send(());
|
||||
let result = job.join().unwrap();
|
||||
assert!(
|
||||
first.is_ok() && second.is_ok(),
|
||||
"both workers must start before either is released"
|
||||
);
|
||||
result.unwrap();
|
||||
assert_eq!(finished.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_limits_do_not_pull_and_failures_stop_dispatch_without_deadlock() {
|
||||
for limit in [-1, 0, 257, i64::MAX] {
|
||||
let pulled = AtomicUsize::new(0);
|
||||
let values = std::iter::from_fn(|| {
|
||||
pulled.fetch_add(1, Ordering::SeqCst);
|
||||
Some(1)
|
||||
});
|
||||
assert!(concurrent::each(values, limit, |_| Ok(())).is_err());
|
||||
assert_eq!(pulled.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
let called = AtomicUsize::new(0);
|
||||
let result = concurrent::each(0..100, 1, |_| {
|
||||
called.fetch_add(1, Ordering::SeqCst);
|
||||
Err("expected failure".into())
|
||||
});
|
||||
assert_eq!(result, Err("expected failure".into()));
|
||||
assert_eq!(called.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_parallel_handlers_are_static_and_join_before_following_statements() {
|
||||
let output = support::stdout(
|
||||
r#"show(value: Int, prefix: Text) () { dever.io.println(prefix + int.to_text(value)) }
|
||||
invoke(route: handler(value: Int, prefix: Text) (), values: List<Int>) () {
|
||||
parallel_each(route, values, 2, "value=")
|
||||
}
|
||||
public main() () {
|
||||
invoke(show, [1, 2, 3])
|
||||
parallel_each(show, dever.bytes.from_text("ab"), 2, "byte=")
|
||||
dever.io.println("joined")
|
||||
}
|
||||
"#,
|
||||
);
|
||||
let mut lines: Vec<_> = output.lines().collect();
|
||||
assert_eq!(lines.pop(), Some("joined"));
|
||||
lines.sort_unstable();
|
||||
assert_eq!(
|
||||
lines,
|
||||
["byte=97", "byte=98", "value=1", "value=2", "value=3"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_checker_rejects_outputs_and_shared_stream_inputs() {
|
||||
support::rejected(
|
||||
"f(value: Int) (out: Int) { out = value }\npublic main() () { parallel_each(f, [1], 2) }",
|
||||
"zero-output handler",
|
||||
);
|
||||
support::rejected(
|
||||
"public type Box { stream: Stream<Int> }\nf(value: Box) () {}\npublic run(values: List<Box>) () { parallel_each(f, values, 2) }",
|
||||
"cannot contain Stream",
|
||||
);
|
||||
let failed = support::run("f(value: Int) () {}\npublic main() () { parallel_each(f, [1], 0) }");
|
||||
assert!(!failed.status.success());
|
||||
assert!(
|
||||
String::from_utf8_lossy(&failed.stderr)
|
||||
.contains("parallel worker count must be between 1 and 256")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
mod support;
|
||||
|
||||
use dever_core::source::SourceMap;
|
||||
use support::{checked, rejected, sources};
|
||||
|
||||
#[path = "../../../crates/dever-cli/src/api.rs"]
|
||||
mod cli_api;
|
||||
|
||||
#[test]
|
||||
fn application_exports_are_checked_only_with_a_known_entry() {
|
||||
let program = checked(&sources(
|
||||
"public main() (answer: Int) { answer = helper() }\npublic helper() (answer: Int) { answer = 1 }\npublic unused() () {}",
|
||||
));
|
||||
let warnings = program.application_warnings("main.main").unwrap();
|
||||
assert_eq!(warnings.len(), 2);
|
||||
assert!(warnings.iter().all(|warning| warning.code == "W007"));
|
||||
assert!(program.application_warnings("app.missing").is_err());
|
||||
let mut map = SourceMap::default();
|
||||
map.add(
|
||||
"main.dever",
|
||||
"public main() (answer: Int) { answer = utility.read() }",
|
||||
);
|
||||
map.add(
|
||||
"utility.dever",
|
||||
"public read() (answer: Int) { answer = 1 }",
|
||||
);
|
||||
let cross_package_program = checked(&map);
|
||||
assert!(
|
||||
cross_package_program
|
||||
.application_warnings("main.main")
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
!cross_package_program
|
||||
.api_snapshot()
|
||||
.contains("package app depends")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_baseline_is_explicit_never_overwrites_and_blocks_drift() {
|
||||
let directory = support::temp::TemporaryDirectory::new();
|
||||
let path = directory.path().join("dever.api");
|
||||
let original = checked(&sources("public main() (answer: Int) { answer = 1 }"));
|
||||
assert!(
|
||||
original
|
||||
.check_api(
|
||||
&original
|
||||
.api_snapshot()
|
||||
.replace("dever-api 5", "dever-api 4")
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("expected dever-api 5")
|
||||
);
|
||||
cli_api::check_baseline(&original, directory.path()).unwrap();
|
||||
assert!(!path.exists());
|
||||
cli_api::write(&original, Some(&path)).unwrap();
|
||||
cli_api::check_baseline(&original, directory.path()).unwrap();
|
||||
assert!(cli_api::write(&original, Some(&path)).is_err());
|
||||
let expanded = checked(&sources(
|
||||
"public main() (answer: Int) { answer = 1 }\npublic extra() () {}",
|
||||
));
|
||||
assert!(
|
||||
cli_api::check_baseline(&expanded, directory.path())
|
||||
.unwrap_err()
|
||||
.contains("+ function main.extra")
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(path).unwrap(),
|
||||
original.api_snapshot()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_follow_named_and_forwarded_handlers_without_tainting_pure_calls() {
|
||||
let shared = r#"
|
||||
apply(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = route(value) }
|
||||
forward(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = apply(route, value) }
|
||||
identity(value: Int) (answer: Int) pure { answer = value }
|
||||
noisy(value: Int) (answer: Int) { dever.io.println("called")
|
||||
answer = value }
|
||||
"#;
|
||||
checked(&sources(&format!(
|
||||
"{shared}\nmain() (answer: Int) pure {{ answer = forward(identity, 1) }}"
|
||||
)));
|
||||
rejected(
|
||||
&format!("{shared}\nmain() (answer: Int) pure {{ answer = forward(noisy, 1) }}"),
|
||||
"has effects: stdout",
|
||||
);
|
||||
rejected(
|
||||
"apply(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) pure { answer = route(value) }",
|
||||
"has effects: handler",
|
||||
);
|
||||
checked(&sources(&format!(
|
||||
"{shared}\nignore(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) {{ answer = value }}\nmain() (answer: Int) pure {{ answer = ignore(noisy, 1) }}"
|
||||
)));
|
||||
|
||||
let collection = r#"
|
||||
map_values(route: handler(value: Int) (answer: Int), values: List<Int>) (answers: List<Int>) { answers = each(route, values) }
|
||||
identity(value: Int) (answer: Int) pure { answer = value }
|
||||
noisy(value: Int) (answer: Int) { dever.io.println("called")
|
||||
answer = value }
|
||||
"#;
|
||||
checked(&sources(&format!(
|
||||
"{collection}\nmain() (answers: List<Int>) pure {{ answers = map_values(identity, [1]) }}"
|
||||
)));
|
||||
rejected(
|
||||
&format!(
|
||||
"{collection}\nmain() (answers: List<Int>) pure {{ answers = map_values(noisy, [1]) }}"
|
||||
),
|
||||
"has effects: stdout",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_arithmetic_and_text_are_allowed_but_resource_and_clock_effects_are_rejected() {
|
||||
checked(&sources(
|
||||
"public main() (answer: Text) pure { answer = text.trim(\" x \") }",
|
||||
));
|
||||
rejected(
|
||||
"clock() (result: Int) pure { result = dever.time.unix_millis() }",
|
||||
"has effects: time",
|
||||
);
|
||||
rejected(
|
||||
"close_stream(stream: Stream<Int>) () pure { close(stream) }",
|
||||
"has effects: stream",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_snapshot_detects_expansion_and_effect_changes_but_ignores_private_helpers() {
|
||||
let original = checked(&sources("public main() (answer: Int) { answer = 1 }"));
|
||||
let baseline = original.api_snapshot();
|
||||
assert!(baseline.contains("function main.main"));
|
||||
assert!(!baseline.contains("function dever."));
|
||||
original.check_api(&baseline).unwrap();
|
||||
let helper = checked(&sources(
|
||||
"public main() (answer: Int) { answer = helper() }\nhelper() (answer: Int) { answer = 2 }",
|
||||
));
|
||||
helper.check_api(&baseline).unwrap();
|
||||
let expanded = checked(&sources(
|
||||
"public main() (answer: Int) { answer = 1 }\npublic helper() (answer: Int) { answer = 2 }",
|
||||
));
|
||||
let difference = expanded.check_api(&baseline).unwrap_err();
|
||||
assert!(difference.contains("+ function main.helper"));
|
||||
let noisy = checked(&sources(
|
||||
"public main() (answer: Int) { dever.io.println(\"x\")\nanswer = 1 }",
|
||||
));
|
||||
assert!(
|
||||
noisy
|
||||
.check_api(&baseline)
|
||||
.unwrap_err()
|
||||
.contains("effects=[stdout]")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_includes_constraints_and_private_construction_without_internal_representation() {
|
||||
let program = checked(&sources(
|
||||
"public type Port { value: Int >= 1 and <= 65535\nprivate secret: Text }",
|
||||
));
|
||||
let snapshot = program.api_snapshot();
|
||||
assert!(snapshot.contains("construct=private"));
|
||||
assert!(snapshot.contains("1..65535"));
|
||||
assert!(!snapshot.contains("secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_tracks_private_transitive_recovery_without_exposing_helper_names() {
|
||||
let original = checked(&sources("public main() (answer: Int) pure { answer = 1 }"));
|
||||
let changed = checked(&sources(
|
||||
"public main() (answer: Int) pure { answer = bridge() }\nbridge() (answer: Int) pure { answer = fallback() }\nfallback() (answer: Int) pure recover(\"missing configuration uses default\") { answer = 1 }",
|
||||
));
|
||||
let snapshot = changed.api_snapshot();
|
||||
assert!(snapshot.contains("missing configuration uses default"));
|
||||
assert!(!snapshot.contains("bridge"));
|
||||
assert!(!snapshot.contains("fallback"));
|
||||
assert!(changed.check_api(&original.api_snapshot()).is_err());
|
||||
assert!(
|
||||
snapshot.contains("effects=[]"),
|
||||
"recovery itself is not an external effect"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_through_handlers_only_propagates_when_the_handler_is_invoked() {
|
||||
let helpers = "fallback(value: Int) (answer: Int) recover(\"use default\") { answer = value }\napply(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = route(value) }\nignore(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = value }\n";
|
||||
let invoked = checked(&sources(&format!(
|
||||
"{helpers}public main() (answer: Int) pure {{ answer = apply(fallback, 1) }}"
|
||||
)));
|
||||
assert!(invoked.api_snapshot().contains("use default"));
|
||||
let ignored = checked(&sources(&format!(
|
||||
"{helpers}public main() (answer: Int) pure {{ answer = ignore(fallback, 1) }}"
|
||||
)));
|
||||
assert!(!ignored.api_snapshot().contains("use default"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_work_queue_preserves_long_chains_and_unused_handler_cycles() {
|
||||
let mut source = String::from("public main() () { step0() }\n");
|
||||
for level in 0..64 {
|
||||
source.push_str(&format!("step{level}() () {{ step{}() }}\n", level + 1));
|
||||
}
|
||||
source
|
||||
.push_str("step64() () recover(\"report optional event\") { dever.io.println(\"event\") }");
|
||||
let snapshot = checked(&sources(&source)).api_snapshot();
|
||||
assert!(snapshot.contains("effects=[stdout]"), "{snapshot}");
|
||||
assert!(snapshot.contains("report optional event"), "{snapshot}");
|
||||
rejected(
|
||||
&source.replace("main() () {", "main() () pure {"),
|
||||
"has effects: stdout",
|
||||
);
|
||||
|
||||
let cycle = "public main() () pure { bridge() }\nbridge() () { ignore(bridge) }\nignore(route: handler() ()) () recover(\"skip optional callback\") {}";
|
||||
let snapshot = checked(&sources(cycle)).api_snapshot();
|
||||
assert!(snapshot.contains("effects=[]"), "{snapshot}");
|
||||
assert!(snapshot.contains("skip optional callback"), "{snapshot}");
|
||||
let unused = cycle.replace(
|
||||
"bridge() () { ignore(bridge) }",
|
||||
"bridge() () { ignore(bridge)\nignore(noisy) }\nnoisy() () recover(\"unused recovery\") { dever.io.println(\"unused\") }",
|
||||
);
|
||||
let snapshot = checked(&sources(&unused)).api_snapshot();
|
||||
assert!(snapshot.contains("effects=[]"), "{snapshot}");
|
||||
assert!(!snapshot.contains("unused recovery"), "{snapshot}");
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
mod support;
|
||||
|
||||
use dever_core::source::SourceMap;
|
||||
|
||||
#[test]
|
||||
fn cms_formats_share_source_model_api_and_configuration_contracts() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/cms");
|
||||
let plain = SourceMap::load(&root.join("dever/module")).unwrap();
|
||||
let markdown = SourceMap::load(&root.join("md/module")).unwrap();
|
||||
let plain_settings =
|
||||
dever_runtime::config::Settings::load_project(&root.join("dever")).unwrap();
|
||||
let markdown_settings =
|
||||
dever_runtime::config::Settings::load_project(&root.join("md")).unwrap();
|
||||
let plain_program =
|
||||
dever_core::check_with_settings(&plain, &plain_settings).unwrap_or_else(|errors| {
|
||||
panic!(
|
||||
"{}",
|
||||
errors
|
||||
.iter()
|
||||
.map(|error| error.render(&plain))
|
||||
.collect::<String>()
|
||||
)
|
||||
});
|
||||
let markdown_program = dever_core::check_with_settings(&markdown, &markdown_settings)
|
||||
.unwrap_or_else(|errors| {
|
||||
panic!(
|
||||
"{}",
|
||||
errors
|
||||
.iter()
|
||||
.map(|error| error.render(&markdown))
|
||||
.collect::<String>()
|
||||
)
|
||||
});
|
||||
assert_no_application_errors(&plain_program, &plain);
|
||||
assert_no_application_errors(&markdown_program, &markdown);
|
||||
assert_eq!(
|
||||
plain_program.model_snapshot(),
|
||||
markdown_program.model_snapshot(),
|
||||
);
|
||||
assert_eq!(
|
||||
plain_program.api_snapshot(),
|
||||
markdown_program.api_snapshot()
|
||||
);
|
||||
// 应用测试也是双源码合同的一部分,避免 Markdown 测试退化为空断言。
|
||||
let plain_with_tests =
|
||||
SourceMap::load_project(&root.join("dever/module"), &root.join("dever/test")).unwrap();
|
||||
let markdown_with_tests =
|
||||
SourceMap::load_project(&root.join("md/module"), &root.join("md/test")).unwrap();
|
||||
assert_eq!(
|
||||
canonical_cms_sources(&plain_with_tests),
|
||||
canonical_cms_sources(&markdown_with_tests),
|
||||
);
|
||||
|
||||
let configuration = |format: &str| -> serde_json::Value {
|
||||
serde_json::from_str(
|
||||
&std::fs::read_to_string(root.join(format).join("config/setting.json")).unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(configuration("dever"), configuration("md"));
|
||||
for (format, program, settings) in [
|
||||
("dever", &plain_program, &plain_settings),
|
||||
("md", &markdown_program, &markdown_settings),
|
||||
] {
|
||||
let profile = program.validate_database_settings(settings).unwrap();
|
||||
assert!(profile.sqlite && !profile.postgres, "{format}");
|
||||
let sources = if format == "dever" { &plain } else { &markdown };
|
||||
let generated = dever_core::native::emit_project(program, sources, profile).unwrap();
|
||||
assert!(generated.contains(
|
||||
"dever_runtime::job::scope_system(None, cmd_dispatch(&args[0], &args[1])).await"
|
||||
));
|
||||
assert!(generated.contains(
|
||||
"dever_runtime::lifecycle::serve_both(dever_runtime::api::serve(api_route), job_serve()).await"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_no_application_errors(program: &dever_core::hir::Program, sources: &SourceMap) {
|
||||
let errors = program.application_errors().unwrap();
|
||||
assert!(
|
||||
errors.is_empty(),
|
||||
"{}",
|
||||
errors
|
||||
.iter()
|
||||
.map(|error| error.render(sources))
|
||||
.collect::<String>()
|
||||
);
|
||||
}
|
||||
|
||||
fn canonical_cms_sources(sources: &SourceMap) -> std::collections::BTreeMap<String, String> {
|
||||
use dever_core::syntax::Declaration;
|
||||
|
||||
let mut declarations = std::collections::BTreeMap::new();
|
||||
for source in sources.files() {
|
||||
// 使用编译器 AST 的边界抽取声明,避免测试另写 Markdown fence 解析器。
|
||||
let package = dever_core::parse(source).unwrap();
|
||||
let name = package
|
||||
.name
|
||||
.iter()
|
||||
.map(|part| part.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(".");
|
||||
let mut code = String::new();
|
||||
for declaration in package.declarations {
|
||||
let span = match declaration {
|
||||
Declaration::Type(value) => value.span,
|
||||
Declaration::Function(value) => value.span,
|
||||
Declaration::External(value) => value.span,
|
||||
Declaration::Api(value) => value.span,
|
||||
Declaration::Rest(value) => value.span,
|
||||
Declaration::Database(value) => value.span,
|
||||
Declaration::Schedule(value) => value.span,
|
||||
Declaration::ModelIndex(value) => value.span,
|
||||
Declaration::Relation(value) => value.span,
|
||||
Declaration::Seed(value) => value.span,
|
||||
Declaration::Migration(value) => value.span,
|
||||
Declaration::ModelSql(value) => value.span,
|
||||
};
|
||||
code.push_str(&source.text()[span.start..span.end]);
|
||||
code.push('\n');
|
||||
}
|
||||
let mut normalized = SourceMap::default();
|
||||
normalized.add(format!("{}.dever", name.replace('.', "/")), code);
|
||||
let canonical = dever_core::format::format(&normalized.files()[0]).unwrap();
|
||||
assert!(declarations.insert(name, canonical).is_none());
|
||||
}
|
||||
declarations
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maintained_examples_pass_the_same_contract_checker() {
|
||||
for root in example_roots() {
|
||||
let source_root = root.join("module");
|
||||
let program = support::checked(&SourceMap::load(&source_root).unwrap());
|
||||
let baseline = root.join("dever.api");
|
||||
if baseline.exists() {
|
||||
program
|
||||
.check_api(&std::fs::read_to_string(baseline).unwrap())
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn examples_root() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/old/dever")
|
||||
}
|
||||
|
||||
fn example_roots() -> Vec<std::path::PathBuf> {
|
||||
let examples = examples_root()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.to_path_buf();
|
||||
vec![examples.join("cms/dever"), examples.join("cms/md")]
|
||||
}
|
||||
|
||||
fn archived_sources(root: &std::path::Path) -> SourceMap {
|
||||
copy_archived_sources(root, false)
|
||||
}
|
||||
|
||||
fn archived_rejected_sources(root: &std::path::Path) -> SourceMap {
|
||||
copy_archived_sources(root, true)
|
||||
}
|
||||
|
||||
fn copy_archived_sources(root: &std::path::Path, remap_main: bool) -> SourceMap {
|
||||
let loaded = SourceMap::load(root).unwrap();
|
||||
let mut sources = SourceMap::default();
|
||||
for source in loaded.files() {
|
||||
let path = if remap_main && source.path() == std::path::Path::new("main.dever") {
|
||||
std::path::Path::new("app.dever")
|
||||
} else {
|
||||
source.path()
|
||||
};
|
||||
sources.add(path, source.text());
|
||||
}
|
||||
sources
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_examples_demonstrate_the_documented_diagnostic() {
|
||||
for (name, code) in [
|
||||
("range", "C011"),
|
||||
("failure", "C012"),
|
||||
("pure", "C013"),
|
||||
("coverage", "C009"),
|
||||
("private", "C006"),
|
||||
] {
|
||||
let sources =
|
||||
archived_rejected_sources(&examples_root().join("rejected").join(name).join("module"));
|
||||
let errors = dever_core::check(&sources).unwrap_err();
|
||||
assert!(
|
||||
errors.iter().any(|error| error.code == code),
|
||||
"{name}: {errors:#?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraints_and_explicit_recovery_preserve_native_execution() {
|
||||
assert_eq!(
|
||||
run_example("contracts", "main.main"),
|
||||
"configured = domain.Port { value = 8080 }\ninvalid = domain.PortResult.Failed(port must be between 1 and 65535)\nfallback = domain.Port { value = 8080 }\n"
|
||||
);
|
||||
}
|
||||
|
||||
fn run_example(name: &str, entry: &str) -> String {
|
||||
let sources = archived_sources(&examples_root().join(name).join("module"));
|
||||
let program = support::checked(&sources);
|
||||
let compiler = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
|
||||
let native = dever_core::native::compile(&program, &sources, entry, &compiler).unwrap();
|
||||
let output = std::process::Command::new(native.executable())
|
||||
.current_dir(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."))
|
||||
.arg("sample")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{name}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
output.stderr.is_empty(),
|
||||
"{name}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8(output.stdout).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_roots_and_sequence_examples_preserve_expected_native_results() {
|
||||
for (name, entry, expected) in [
|
||||
("hello", "main.main", "hello\n"),
|
||||
("privacy", "main.main", "before = 0\nafter = 1\n"),
|
||||
(
|
||||
"sequences",
|
||||
"main.main",
|
||||
"mapped = [2, 4, 6]\ntotal = 6\nstopped = main.Summary { total = 6, count = 3 }\nempty_total = 0\nbytes_total = 131\nfirst_value = 2\nmissing = null\n",
|
||||
),
|
||||
("stream", "main.main", "total = 208\n"),
|
||||
(
|
||||
"library",
|
||||
"main.main",
|
||||
"json = {\"message\":\"你好 Dever\",\"total\":6}\n",
|
||||
),
|
||||
] {
|
||||
assert_eq!(run_example(name, entry), expected, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_example_preserves_unicode_null_and_explicit_conversion_failures() {
|
||||
let output = run_example("text", "main.main");
|
||||
assert!(output.starts_with("cleaned = dever\ncharacter = 😀\nslice = 中😀\npieces = [a, , b]\njoined = a||b\nparsed = 42\ninvalid = null\nexact = 1.25\nrounded = 2.34\nutf8 = 你好\n"), "{output}");
|
||||
assert!(
|
||||
output.contains("invalid_utf8 = dever.bytes.DecodeResult.Failed("),
|
||||
"{output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("invalid_byte = dever.bytes.BufferResult.Failed("),
|
||||
"{output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_example_joins_every_named_task_before_done() {
|
||||
let output = run_example("parallel", "main.main");
|
||||
let mut lines: Vec<_> = output.lines().collect();
|
||||
assert_eq!(lines.pop(), Some("done"));
|
||||
lines.sort();
|
||||
assert_eq!(lines, ["alpha", "beta", "gamma"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_example_owns_tasks_groups_and_channels_until_completion() {
|
||||
assert_eq!(
|
||||
run_example("async", "main.main"),
|
||||
"task_value = 7\ngrouped_value = 21\ncomputed = 144\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_example_reads_child_inputs_and_returns_invalid_sleep_as_failure() {
|
||||
let output = run_example("system", "main.main");
|
||||
assert!(output.contains("arguments = [sample]"), "{output}");
|
||||
assert!(output.contains("clock = "), "{output}");
|
||||
assert!(
|
||||
output.contains("invalid_sleep = dever.time.SleepResult.Failed("),
|
||||
"{output}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
use dever_core::diagnostic::Diagnostic;
|
||||
use dever_core::source::SourceMap;
|
||||
|
||||
fn checked(declarations: &str) -> Result<dever_core::hir::Program, Vec<Diagnostic>> {
|
||||
let mut sources = SourceMap::default();
|
||||
sources.add("main.dever", declarations);
|
||||
dever_core::check(&sources)
|
||||
}
|
||||
|
||||
fn accepts(declarations: &str) {
|
||||
checked(declarations).unwrap_or_else(|errors| panic!("unexpected errors: {errors:#?}"));
|
||||
}
|
||||
|
||||
fn rejects(declarations: &str) {
|
||||
let errors = checked(declarations).expect_err("constraint proof must fail");
|
||||
assert!(
|
||||
errors.iter().any(|error| error.code == "C011"),
|
||||
"{errors:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrained_construction_requires_a_proof_even_in_unused_functions() {
|
||||
rejects(
|
||||
"type Port { value: Int >= 1 and <= 65535 }\nbad() (port: Port) { port = Port { value = 0 } }",
|
||||
);
|
||||
rejects(
|
||||
"type Port { value: Int >= 1 and <= 65535 }\nbad(value: Int) (port: Port) { port = Port { value = value } }",
|
||||
);
|
||||
accepts(
|
||||
"type Port { value: Int >= 1 and <= 65535 }\nmake() (port: Port) { port = Port { value = 8080 } }",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clause_domains_establish_safe_construction_and_arithmetic() {
|
||||
accepts(
|
||||
"type Port { value: Int >= 1 and <= 65535 }\nmake(value: Int >= 1 and <= 65534) (port: Port) { port = Port { value = value + 1 } }\nmake(value: other) (port: Port) { port = Port { value = 8080 } }",
|
||||
);
|
||||
rejects(
|
||||
"type Port { value: Int >= 1 and <= 65535 }\nmake(value: Int >= 1 and <= 65535) (port: Port) { port = Port { value = value + 1 } }\nmake(value: other) (port: Port) { port = Port { value = 8080 } }",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrained_outputs_and_input_transfer_survive_function_boundaries() {
|
||||
accepts(
|
||||
"type Port { value: Int >= 1 and <= 65535 }\nidentity(value: Int) (result: Int) { result = value }\nnumber(port: Port) (value: Int >= 1 and <= 65535) { value = identity(port.value) }\nmake(port: Port) (result: Port) { result = Port { value = number(port) } }",
|
||||
);
|
||||
rejects("bad(value: Int) (result: Int >= 1) { result = value }");
|
||||
accepts("valid() (result: Int >= 1) { result = 1 }");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choice_payload_constraints_apply_at_construction_and_binding() {
|
||||
rejects(
|
||||
"type Result { Ready(value: Int >= 1) }\nbad() (result: Result) { result = Result.Ready(0) }",
|
||||
);
|
||||
accepts(
|
||||
"type Result { Ready(value: Int >= 1) }\nread(result: Result.Ready(value)) (number: Int >= 1) { number = value }",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_mutation_rechecks_constraints_and_aliases_keep_independent_facts() {
|
||||
rejects(
|
||||
"type Port { value: Int >= 1 }\ntype Config { port: Port }\nbad(config: Config) (result: Config) { config.port.value = 0\nresult = config }",
|
||||
);
|
||||
accepts(
|
||||
"type Box { value: Int }\nmake() (result: Int >= 1) { original = Box { value = 1 }\nalias = original\noriginal.value = 0\nresult = alias.value }",
|
||||
);
|
||||
rejects(
|
||||
"type Box { value: Int }\nmake() (result: Int >= 1) { original = Box { value = 1 }\noriginal.value = 0\nresult = original.value }",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reassignment_removes_previous_range_guarantees() {
|
||||
rejects(
|
||||
"type Port { value: Int >= 1 }\nmake(input: Int) (port: Port) { value = 1\nvalue = input\nport = Port { value = value } }",
|
||||
);
|
||||
rejects(
|
||||
"type Box { value: Int }\nmake(box: Box) (result: Int >= 1) { box.value = 0\nresult = box.value }",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_bounds_reuse_checked_numeric_semantics() {
|
||||
accepts(
|
||||
"type Amount { value: Decimal >= 0.0 and <= 10.0 }\nmake(value: Decimal >= 0.0 and <= 9.0) (result: Amount) { result = Amount { value = value + 1.0 } }\nmake(value: other) (result: Amount) { result = Amount { value = 0.0 } }",
|
||||
);
|
||||
rejects(
|
||||
"type Amount { value: Decimal >= 0.0 }\nmake(value: Decimal) (result: Amount) { result = Amount { value = value } }",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_inputs_use_declared_bounds_including_sequence_adapters() {
|
||||
accepts(
|
||||
"call(step: handler(value: Int >= 1) (result: Int >= 1)) (result: Int >= 1) { result = step(1) }",
|
||||
);
|
||||
rejects(
|
||||
"call(step: handler(value: Int >= 1) (result: Int)) (result: Int) { result = step(0) }",
|
||||
);
|
||||
rejects("call(step: handler(value: Int >= 1) ()) () { each(step, [0]) }");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proven_comparisons_and_call_domains_have_evidence_without_rejecting_callees() {
|
||||
let program = checked("type Port { value: Int >= 1 }\npositive(value: Int >= 1) (result: Bool) { result = true }\npositive(value: other) (result: Bool) { result = false }\ncheck(port: Port) (result: Bool) { compared = port.value < 0\nresult = positive(port.value) and compared }").unwrap();
|
||||
let warnings: Vec<_> = program
|
||||
.warnings()
|
||||
.iter()
|
||||
.filter(|warning| warning.code == "W002")
|
||||
.collect();
|
||||
assert!(
|
||||
warnings
|
||||
.iter()
|
||||
.any(|warning| warning.message.contains("comparison"))
|
||||
);
|
||||
assert!(
|
||||
warnings
|
||||
.iter()
|
||||
.any(|warning| warning.message.contains("cannot run"))
|
||||
);
|
||||
assert!(warnings.iter().all(|warning| !warning.related.is_empty()));
|
||||
accepts(
|
||||
"positive(value: Int >= 1) (result: Bool) { result = true }\npositive(value: other) (result: Bool) { result = false }",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_calls_and_unknown_comparisons_do_not_create_redundancy_noise() {
|
||||
let mut sources = SourceMap::default();
|
||||
let id = sources.add("main.dever", "positive(value: Int >= 1) (result: Bool) { result = true }\npositive(value: other) (result: Bool) { result = false }\nmain(value: Int) (result: Bool) { known = positive(1)\nresult = value < 0 and known }");
|
||||
let program = dever_core::check(&sources).unwrap();
|
||||
assert!(
|
||||
!program
|
||||
.warnings()
|
||||
.iter()
|
||||
.any(|warning| warning.code == "W002" && warning.primary.source == id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bool_null_and_choice_facts_reach_call_sites() {
|
||||
let mut sources = SourceMap::default();
|
||||
let id = sources.add("main.dever", "type Choice { Yes\nNo }\ntype State { enabled: Bool\nchoice: Choice\noptional: Int? }\nflag(value: true) () {}\nflag(value: false) () {}\noption(value: Int) () {}\noption(value: null) () {}\nchoose(value: Choice.Yes) () {}\nchoose(value: Choice.No) () {}\nmain() () { state = State { enabled = true\nchoice = Choice.Yes\noptional = null }\nflag(state.enabled)\noption(state.optional)\nchoose(state.choice) }");
|
||||
let program = dever_core::check(&sources).unwrap();
|
||||
let warnings: Vec<_> = program
|
||||
.warnings()
|
||||
.iter()
|
||||
.filter(|warning| warning.code == "W002" && warning.primary.source == id)
|
||||
.collect();
|
||||
assert_eq!(warnings.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_does_not_assume_wrapping_or_unproved_division_bounds() {
|
||||
rejects(
|
||||
"type Positive { value: Int >= 1 }\nmake(input: Int) (result: Positive) { result = Positive { value = input + 1 } }",
|
||||
);
|
||||
rejects(
|
||||
"type Positive { value: Int >= 1 }\nmake(input: Int) (result: Positive) { result = Positive { value = input // 2 } }",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concrete_handler_outputs_are_proved_from_bodies_without_wrapper_annotations() {
|
||||
accepts(
|
||||
"apply(step: handler(value: Int >= 1) (result: Int >= 1)) (result: Int >= 1) { result = step(1) }\nidentity(value: Int) (result: Int) { result = value }\nmain() (result: Int >= 1) { result = apply(identity) }",
|
||||
);
|
||||
accepts(
|
||||
"apply(step: handler(value: Int >= 1) (result: Int >= 1)) (result: Int >= 1) { result = step(1) }\ngood(value: Int) (result: Int) { result = 2 }\nmain() (result: Int >= 1) { result = apply(good) }",
|
||||
);
|
||||
rejects(
|
||||
"apply(step: handler(value: Int >= 1) (result: Int >= 1)) (result: Int >= 1) { result = step(1) }\nbad(value: Int) (result: Int) { result = 0 }\nmain() (result: Int >= 1) { result = apply(bad) }",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarded_handler_inputs_are_contravariant_and_outputs_covariant() {
|
||||
accepts(
|
||||
"apply(step: handler(value: Int >= 1) (result: Int >= 1)) (result: Int >= 1) { result = step(1) }\nforward(step: handler(value: Int >= 0) (result: Int >= 2)) (result: Int >= 1) { result = apply(step) }",
|
||||
);
|
||||
for signature in [
|
||||
"handler(value: Int >= 2) (result: Int >= 1)",
|
||||
"handler(value: Int >= 0) (result: Int >= 0)",
|
||||
] {
|
||||
let source = format!(
|
||||
"apply(step: handler(value: Int >= 1) (result: Int >= 1)) (result: Int >= 1) {{ result = step(1) }}\nforward(step: {signature}) (result: Int >= 1) {{ result = apply(step) }}"
|
||||
);
|
||||
let errors = checked(&source).expect_err("unsafe handler variance");
|
||||
assert!(
|
||||
errors.iter().any(|error| error.code == "C005"),
|
||||
"{errors:#?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_value_graphs_keep_linear_facts_and_independent_mutation_snapshots() {
|
||||
const DEPTH: usize = 24;
|
||||
let mut declarations = String::from("type Layer0 { value: Int >= 1 }\n");
|
||||
for level in 1..=DEPTH {
|
||||
declarations.push_str(&format!(
|
||||
"type Layer{level} {{ left: Layer{}\nright: Layer{} }}\n",
|
||||
level - 1,
|
||||
level - 1
|
||||
));
|
||||
}
|
||||
for (flag, initial) in [("true", 1), ("false", 2)] {
|
||||
declarations.push_str(&format!(
|
||||
"build(flag: {flag}) (result: Layer{DEPTH}) {{\nv0 = Layer0 {{ value = {initial} }}\n"
|
||||
));
|
||||
for level in 1..=DEPTH {
|
||||
declarations.push_str(&format!(
|
||||
"v{level} = Layer{level} {{ left = v{}\nright = v{} }}\n",
|
||||
level - 1,
|
||||
level - 1
|
||||
));
|
||||
}
|
||||
declarations.push_str(&format!("result = v{DEPTH}\n}}\n"));
|
||||
}
|
||||
let path = format!("{}value", "left.".repeat(DEPTH));
|
||||
declarations.push_str(&format!("main() (result: Int >= 1 and <= 2) {{ value = build(true)\nalias = value\nvalue.{path} = 3\nresult = alias.{path}\n}}"));
|
||||
accepts(&declarations);
|
||||
let mutated = declarations.replace(
|
||||
&format!("result = alias.{path}"),
|
||||
&format!("result = value.{path}"),
|
||||
);
|
||||
rejects(&mutated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excessive_fact_work_is_an_explicit_diagnostic() {
|
||||
// A broad but shallow source exercises the budget without allocating an
|
||||
// exponentially nested value or risking the host process's memory.
|
||||
let mut declarations = String::from("main(input: Int) (result: Int) {\n");
|
||||
for _ in 0..5500 {
|
||||
declarations.push_str("result = input + 1\n");
|
||||
}
|
||||
declarations.push('}');
|
||||
let errors = checked(&declarations).expect_err("proof budget must be enforced");
|
||||
assert!(
|
||||
errors.iter().any(|error| error.code == "C015"),
|
||||
"{errors:#?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
mod support;
|
||||
|
||||
use support::{checked, sources};
|
||||
|
||||
fn warnings(source: &str, code: &str) -> Vec<dever_core::diagnostic::Diagnostic> {
|
||||
let sources = sources(source);
|
||||
let user_source = sources.files()[0].id();
|
||||
checked(&sources)
|
||||
.warnings()
|
||||
.iter()
|
||||
.filter(|warning| warning.code == code && warning.primary.source == user_source)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_private_implementations_are_reported_with_source_evidence() {
|
||||
let source = "first(value: Int) (answer: Int) { answer = value + 1 }\nsecond(renamed: Int) (answer: Int) { answer = renamed + 1 }";
|
||||
let duplicates = warnings(source, "W003");
|
||||
assert_eq!(duplicates.len(), 1);
|
||||
assert_eq!(duplicates[0].related.len(), 1);
|
||||
assert!(duplicates[0].message.contains("main.first"));
|
||||
assert!(duplicates[0].message.contains("main.second"));
|
||||
assert_ne!(duplicates[0].primary, duplicates[0].related[0].span);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_contract_and_operator_differences_are_not_duplicates() {
|
||||
for source in [
|
||||
"public first(value: Int) (answer: Int) { answer = value + 1 }\nsecond(value: Int) (answer: Int) { answer = value + 1 }",
|
||||
"first(value: Int) (answer: Int) pure { answer = value + 1 }\nsecond(value: Int) (answer: Int) { answer = value + 1 }",
|
||||
"first(value: Int) (answer: Int) { answer = value + 1 }\nsecond(value: Int) (answer: Int) { answer = value - 1 }",
|
||||
"first(value: Int) (answer: Int) { answer = value }\nsecond(value: Int) (different: Int) { different = value }",
|
||||
] {
|
||||
assert!(warnings(source, "W003").is_empty(), "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_pure_calls_require_unchanged_inputs_and_an_available_result() {
|
||||
let prefix = "calculate(value: Int) (answer: Int) { answer = value + 1 }\n";
|
||||
let repeated = format!(
|
||||
"{prefix}main(value: Int) (answer: Int) {{ first = calculate(value)\nsecond = calculate(value)\nanswer = first + second }}"
|
||||
);
|
||||
let diagnostics = warnings(&repeated, "W004");
|
||||
assert_eq!(diagnostics.len(), 1);
|
||||
assert_eq!(diagnostics[0].related.len(), 1);
|
||||
for body in [
|
||||
"first = calculate(value)\nvalue = value + 1\nsecond = calculate(value)\nanswer = first + second",
|
||||
"first = calculate(value)\nfirst = 0\nsecond = calculate(value)\nanswer = first + second",
|
||||
] {
|
||||
assert!(
|
||||
warnings(
|
||||
&format!("{prefix}main(value: Int) (answer: Int) {{ {body} }}"),
|
||||
"W004"
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_writes_and_effectful_calls_are_not_reuse_candidates() {
|
||||
let field = "public type Counter { value: Int }\ncalculate(value: Int) (answer: Int) { answer = value + 1 }\npublic main(counter: Counter) (answer: Int) { first = calculate(counter.value)\ncounter.value = counter.value + 1\nsecond = calculate(counter.value)\nanswer = first + second }";
|
||||
assert!(warnings(field, "W004").is_empty());
|
||||
let effects = "noisy(value: Int) (answer: Int) { dever.io.println(\"called\")\nanswer = value }\npublic main(value: Int) (answer: Int) { first = noisy(value)\nsecond = noisy(value)\nanswer = first + second }";
|
||||
assert!(warnings(effects, "W004").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarding_warning_excludes_public_adapters_recovery_and_constrained_outputs() {
|
||||
let direct = "calculate(value: Int) (answer: Int) { answer = value + 1 }\nwrapper(value: Int) (answer: Int) { answer = calculate(value) }";
|
||||
assert_eq!(warnings(direct, "W005").len(), 1);
|
||||
for source in [
|
||||
"calculate(value: Int) (answer: Int) { answer = value + 1 }\npublic wrapper(value: Int) (answer: Int) { answer = calculate(value) }",
|
||||
"calculate(value: Int) (answer: Int) { answer = value + 1 }\nwrapper(value: Int) (answer: Int) recover(\"application policy\") { answer = calculate(value) }",
|
||||
"calculate() (answer: Int >= 1) { answer = 1 }\nwrapper() (answer: Int >= 1) { answer = calculate() }",
|
||||
"type PrivateValue { private value: Int }\ncreate(value: Int) (answer: PrivateValue) { answer = PrivateValue { value = value } }\nwrapper(value: Int) (answer: PrivateValue) { answer = create(value) }",
|
||||
] {
|
||||
assert!(warnings(source, "W005").is_empty(), "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_unused_write_warning_is_not_duplicated_as_dead_computation() {
|
||||
let program = checked(&sources(
|
||||
"calculate(value: Int) (answer: Int) { answer = value + 1 }\npublic main() () { unused = calculate(1) }",
|
||||
));
|
||||
assert!(
|
||||
program
|
||||
.warnings()
|
||||
.iter()
|
||||
.any(|warning| warning.code == "W001")
|
||||
);
|
||||
assert!(
|
||||
!program
|
||||
.warnings()
|
||||
.iter()
|
||||
.any(|warning| warning.code == "W006")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
mod support;
|
||||
|
||||
use support::{checked, rejected, sources};
|
||||
|
||||
const RESULT: &str = r#"type Result { Ready(value: Int)
|
||||
error Failed(message: Text) }
|
||||
fail_value(message: Text) (value: Int) { fail(Result.Failed(message)) }
|
||||
failure(message: Text) (value: Result) { value = result(fail_value(message)) }
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn failure_cannot_be_discarded_overwritten_logged_or_laundered() {
|
||||
for code in [
|
||||
"run() () { result = failure(\"bad\") }",
|
||||
"run() (result: Result) { result = failure(\"bad\")\nresult = Result.Ready(0) }",
|
||||
"run(result: Result.Ready(value)) (out: Int) { out = value }\nrun(result: Result.Failed(message)) (out: Int) { dever.io.println(message)\nout = 0 }",
|
||||
"ignore(result: Result) () {}\nrun() () { ignore(failure(\"bad\")) }",
|
||||
] {
|
||||
rejected(&format!("{RESULT}{code}"), "failure is not handled");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_can_be_returned_translated_or_explicitly_recovered() {
|
||||
for code in [
|
||||
"run() (result: Result) { result = failure(\"bad\") }",
|
||||
"run(result: Result) (out: Result) { out = result }",
|
||||
"run(result: Result.Ready(value)) (out: Result) { out = Result.Ready(value) }\nrun(result: Result.Failed(_)) (out: Result) { fail(Result.Failed(\"translated\")) }",
|
||||
"run(result: Result.Ready(value)) (out: Int) recover(\"optional configuration uses zero\") { out = value }\nrun(result: Result.Failed(_)) (out: Int) recover(\"optional configuration uses zero\") { out = 0 }",
|
||||
] {
|
||||
checked(&sources(&format!("{RESULT}{code}")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_failure_survives_value_copies_and_projection() {
|
||||
rejected(
|
||||
&format!(
|
||||
"{RESULT}type Box {{ result: Result\nnumber: Int }}\nrun() (out: Int) {{ box = Box {{ result = failure(\"bad\")\nnumber = 3 }}\nout = box.number }}"
|
||||
),
|
||||
"failure is not handled",
|
||||
);
|
||||
checked(&sources(&format!(
|
||||
"{RESULT}type Box {{ result: Result }}\nrun() (out: Box) {{ box = Box {{ result = failure(\"bad\") }}\nout = box }}"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn possibly_empty_or_null_outputs_cannot_hide_another_failure() {
|
||||
for (ty, extra) in [
|
||||
("List<Problem>", ""),
|
||||
("Box?", "public type Box { problem: Problem }"),
|
||||
] {
|
||||
rejected(
|
||||
&format!(
|
||||
"public type Problem {{ error Bad }}\n{extra}\npublic run(problem: Problem.Bad, replacement: {ty}) (result: {ty}) {{ result = replacement }}"
|
||||
),
|
||||
"failure is not handled",
|
||||
);
|
||||
}
|
||||
checked(&sources(
|
||||
"public type Problem { error Bad }\npublic type Box { problem: Problem }\npublic run(box: Box, replacement: Problem) (result: Problem) { result = replacement }",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_traversal_does_not_handle_context_and_singleton_projection_transfers_failure() {
|
||||
rejected(
|
||||
"public type Problem { error Bad }\nstep(value: Int, problem: Problem) () recover(\"report optional diagnostic\") {}\npublic run(problem: Problem) () { each(step, [], problem) }",
|
||||
"failure is not handled",
|
||||
);
|
||||
checked(&sources(
|
||||
"public type Problem { error Bad }\npublic run(problem: Problem) (result: Problem?) { result = first([problem]) }",
|
||||
));
|
||||
rejected(
|
||||
"public type Problem { error Bad }\npublic run(first_problem: Problem, second_problem: Problem) (result: Problem?) { result = first([first_problem, second_problem]) }",
|
||||
"failure is not handled",
|
||||
);
|
||||
rejected(
|
||||
"public type Result { Ready\nerror Failed }\npublic run(problem: Result) (result: Map<Text, Result>) { old = {\"key\" = problem}\nresult = put(old, \"key\", Result.Ready) }",
|
||||
"failure is not handled",
|
||||
);
|
||||
rejected(
|
||||
"public type Result { Ready\nerror Failed }\npublic run(problem: Result, key: Text) (result: Map<Text, Result>) { result = {key = problem\n\"key\" = Result.Ready} }",
|
||||
"failure is not handled",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_circuit_does_not_claim_a_skipped_handler_consumes_failures() {
|
||||
for left in ["false", "condition"] {
|
||||
rejected(
|
||||
&format!(
|
||||
"public type Problem {{ error Bad }}\nreport(problem: Problem) (result: Bool) recover(\"report optional failure\") {{ result = true }}\npublic run(problem: Problem, condition: Bool) (result: Bool) {{ result = {left} and report(problem) }}"
|
||||
),
|
||||
"failure is not handled",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_reduce_preserves_the_initial_failure_obligation() {
|
||||
let declarations = "type Problem { error Bad }\nstep(value: Int, state: Problem) (next: Problem) { next = state }\n";
|
||||
rejected(
|
||||
&format!(
|
||||
"{declarations}run(problem: Problem) () {{ discarded = reduce(step, [], problem) }}"
|
||||
),
|
||||
"failure is not handled",
|
||||
);
|
||||
checked(&sources(&format!(
|
||||
"{declarations}run(problem: Problem) (result: Problem) {{ result = reduce(step, [], problem) }}"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_type_graphs_do_not_expand_without_bounded_failure_work() {
|
||||
for error in [false, true] {
|
||||
let mut source = String::from("");
|
||||
source.push_str(if error {
|
||||
"type T0 { error Bad }\n"
|
||||
} else {
|
||||
"type T0 { value: Int }\n"
|
||||
});
|
||||
for level in 1..=24 {
|
||||
source.push_str(&format!(
|
||||
"type T{level} {{ left: T{}\nright: T{} }}\n",
|
||||
level - 1,
|
||||
level - 1
|
||||
));
|
||||
}
|
||||
source.push_str("accept(value: T24) (result: T24) { result = value }");
|
||||
if error {
|
||||
rejected(&source, "failure analysis exceeds its structural limit");
|
||||
} else {
|
||||
checked(&sources(&source));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nominal_depth_is_checked_before_recursive_contract_analysis() {
|
||||
let mut source = String::from("type T0 { error Bad }\n");
|
||||
for level in 1..=256 {
|
||||
source.push_str(&format!("type T{level} {{ value: T{} }}\n", level - 1));
|
||||
}
|
||||
source.push_str("accept(value: T256) (result: T256) { result = value }");
|
||||
rejected(
|
||||
&source,
|
||||
"contract type nesting exceeds the structural depth limit",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_nominal_properties_preserve_equality_and_worker_resource_boundaries() {
|
||||
const DEPTH: usize = 48;
|
||||
for (leaf, comparable, transferable) in [
|
||||
("Text", true, true),
|
||||
("dever.system.File", false, true),
|
||||
("dever.system.Socket", false, true),
|
||||
("dever.system.Listener", false, true),
|
||||
("Stream<Int>", false, false),
|
||||
] {
|
||||
let mut declarations = format!("type T0 {{ value: {leaf} }}\n");
|
||||
// Declare callers before their dependencies, so source order cannot stand in
|
||||
// for the checked type graph. Each level shares the previous type twice.
|
||||
for level in (1..=DEPTH).rev() {
|
||||
declarations.push_str(&format!(
|
||||
"type T{level} {{ left: T{}\nright: T{} }}\n",
|
||||
level - 1,
|
||||
level - 1
|
||||
));
|
||||
}
|
||||
let equality = format!(
|
||||
"{declarations}run(left: T{DEPTH}, right: T{DEPTH}) (same: Bool) {{ same = left == right }}"
|
||||
);
|
||||
if comparable {
|
||||
checked(&sources(&equality));
|
||||
} else {
|
||||
rejected(&equality, "equality requires the same comparable type");
|
||||
}
|
||||
let parallel = format!(
|
||||
"{declarations}accept(value: T{DEPTH}) () {{}}\nrun(values: List<T{DEPTH}>) () {{ parallel_each(accept, values, 2) }}"
|
||||
);
|
||||
if transferable {
|
||||
checked(&sources(¶llel));
|
||||
} else {
|
||||
rejected(¶llel, "cannot contain Stream");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_summaries_follow_long_calls_and_unused_handler_dependency_cycles() {
|
||||
// 已有失败 payload 保留“必定携带失败”的摘要;叶子允许恢复,负向只检验调用者的原失败。
|
||||
let prefix = "type Problem { error Bad }\ntype Result { Ready(problem: Problem)\nEmpty }\n";
|
||||
let mut declarations = String::from(prefix);
|
||||
for level in 0..64 {
|
||||
declarations.push_str(&format!(
|
||||
"step{level}(replacement: Problem) (result: Result) {{ result = step{}(replacement) }}\n",
|
||||
level + 1
|
||||
));
|
||||
}
|
||||
declarations
|
||||
.push_str("step64(replacement: Problem) (result: Result) recover(\"replacement policy\") { result = Result.Ready(replacement) }\n");
|
||||
declarations.push_str("run(problem: Problem, replacement: Problem) (result: Result) { result = step0(replacement) }");
|
||||
checked(&sources(&declarations));
|
||||
rejected(
|
||||
&declarations.replace("Result.Ready(replacement)", "Result.Empty"),
|
||||
"failure is not handled",
|
||||
);
|
||||
|
||||
let cycle = format!(
|
||||
"{prefix}run(problem: Problem, value: Problem) (result: Result) {{ result = replacement(value) }}\nreplacement(value: Problem) (result: Result) {{ result = ignore(replacement, value) }}\nignore(route: handler(value: Problem) (result: Result), value: Problem) (result: Result) recover(\"replacement policy\") {{ result = Result.Ready(value) }}"
|
||||
);
|
||||
checked(&sources(&cycle));
|
||||
rejected(
|
||||
&cycle.replace("Result.Ready(value)", "Result.Empty"),
|
||||
"failure is not handled",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_fields_protect_access_mutation_and_construction() {
|
||||
for expression in [
|
||||
"out = box.value",
|
||||
"box.value = 2\nout = 0",
|
||||
"box = secret.Box { value = 2 }\nout = 0",
|
||||
] {
|
||||
let mut source = sources(&format!(
|
||||
"public run(box: secret.Box) (out: Int) {{ {expression} }}"
|
||||
));
|
||||
source.add("secret.dever", "public type Box { private value: Int }");
|
||||
let diagnostics = dever_core::check(&source).unwrap_err();
|
||||
assert!(diagnostics.iter().any(|error| error.code == "C006"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
use dever_core::source::SourceMap;
|
||||
use dever_core::syntax::{Declaration, InputKind, Package, TypeShape};
|
||||
|
||||
fn parsed(source: &str) -> Package {
|
||||
let mut sources = SourceMap::default();
|
||||
let id = sources.add("main.dever", source);
|
||||
dever_core::parse(sources.get(id)).unwrap_or_else(|errors| {
|
||||
panic!(
|
||||
"{}",
|
||||
errors
|
||||
.iter()
|
||||
.map(|error| error.render(&sources))
|
||||
.collect::<String>()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn formatted(source: &str) -> String {
|
||||
let mut sources = SourceMap::default();
|
||||
let id = sources.add("main.dever", source);
|
||||
dever_core::format::format(sources.get(id)).expect("valid contract syntax")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_package_type_and_function_contracts() {
|
||||
let package = parsed(
|
||||
r#"public type Port {
|
||||
private value: Int >= 1 and <= 65535
|
||||
scale: Decimal > -1.5
|
||||
}
|
||||
public type Result {
|
||||
Ready(port: Port)
|
||||
error Failed(message: Text)
|
||||
}
|
||||
public read(step: handler(value: Int >= 1) (result: Int <= 65535)) (result: Int >= 1) recover("configured fallback") pure {
|
||||
result = 8080
|
||||
}"#,
|
||||
);
|
||||
let Declaration::Type(port) = &package.declarations[0] else {
|
||||
panic!("record")
|
||||
};
|
||||
let TypeShape::Record(fields) = &port.shape else {
|
||||
panic!("record")
|
||||
};
|
||||
assert!(fields[0].private);
|
||||
assert_eq!(fields[0].bounds.len(), 2);
|
||||
assert!(!fields[1].private);
|
||||
assert_eq!(fields[1].bounds[0].number, "-1.5");
|
||||
let Declaration::Type(result) = &package.declarations[1] else {
|
||||
panic!("choice")
|
||||
};
|
||||
let TypeShape::Choice(variants) = &result.shape else {
|
||||
panic!("choice")
|
||||
};
|
||||
assert!(!variants[0].error);
|
||||
assert!(variants[1].error);
|
||||
assert_eq!(variants[1].name.text, "Failed");
|
||||
let Declaration::Function(function) = &package.declarations[2] else {
|
||||
panic!("function")
|
||||
};
|
||||
assert!(function.pure);
|
||||
assert_eq!(function.recovery.as_deref(), Some("configured fallback"));
|
||||
assert_eq!(function.outputs[0].bounds.len(), 1);
|
||||
let InputKind::Handler(handler) = &function.inputs[0].kind else {
|
||||
panic!("handler")
|
||||
};
|
||||
assert_eq!(handler.inputs[0].bounds.len(), 1);
|
||||
assert_eq!(handler.outputs[0].bounds.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_contextual_identifiers() {
|
||||
let package = parsed(
|
||||
"type Record { private: Int }\ntype Choice { error(value: Int) }\ndepends() () {}\npure() () {}\nrecover() () {}",
|
||||
);
|
||||
let Declaration::Type(record) = &package.declarations[0] else {
|
||||
panic!("record")
|
||||
};
|
||||
let TypeShape::Record(fields) = &record.shape else {
|
||||
panic!("record")
|
||||
};
|
||||
assert!(!fields[0].private);
|
||||
assert_eq!(fields[0].name.text, "private");
|
||||
let Declaration::Type(choice) = &package.declarations[1] else {
|
||||
panic!("choice")
|
||||
};
|
||||
let TypeShape::Choice(variants) = &choice.shape else {
|
||||
panic!("choice")
|
||||
};
|
||||
assert!(!variants[0].error);
|
||||
assert_eq!(variants[0].name.text, "error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depends_remains_an_ordinary_function_name() {
|
||||
let source =
|
||||
"depends() (value: Int) { value = 1 }\npublic main() (value: Int) { value = depends() }";
|
||||
let output = formatted(source);
|
||||
assert_eq!(formatted(&output), output);
|
||||
|
||||
let mut sources = SourceMap::default();
|
||||
let id = sources.add("main.dever", output);
|
||||
let package = dever_core::parse(sources.get(id)).unwrap();
|
||||
assert!(dever_core::check(&sources).is_ok());
|
||||
assert!(matches!(package.declarations[0], Declaration::Function(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_removed_package_dependency_clauses() {
|
||||
let mut sources = SourceMap::default();
|
||||
let id = sources.add("main.dever", "package app exposes () depends ()");
|
||||
assert!(dever_core::parse(sources.get(id)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ambiguous_or_invalid_contract_markers() {
|
||||
for declaration in [
|
||||
"f() () pure pure {}",
|
||||
"f() () recover(\"one\") recover(\"two\") {}",
|
||||
"f() () recover(\"\") {}",
|
||||
"f() () recover(\" \") {}",
|
||||
"f() () recover(1) {}",
|
||||
"f() (private result: Int) {}",
|
||||
"f(step: handler(private value: Int) ()) () {}",
|
||||
"type Result { Ready(private value: Int) }",
|
||||
"type Result { private Ready }",
|
||||
"type Port { value: Int >= 1 and }",
|
||||
] {
|
||||
let mut sources = SourceMap::default();
|
||||
let id = sources.add("main.dever", declaration.to_string());
|
||||
assert!(
|
||||
dever_core::parse(sources.get(id)).is_err(),
|
||||
"accepted: {declaration}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatting_preserves_contracts_comments_and_canonical_marker_order() {
|
||||
let source = r#"public type Port {
|
||||
private value:Int >= 1 and <=65535 # valid range
|
||||
}
|
||||
public type Result {
|
||||
Ready(value:Int >= 1)
|
||||
error Failed(message:Text) # failure
|
||||
}
|
||||
public main() (result:Int >=1) recover("use \"configured\" port") # recovery
|
||||
pure # effect
|
||||
{result=8080}
|
||||
"#;
|
||||
let output = formatted(source);
|
||||
assert_eq!(formatted(&output), output);
|
||||
assert!(output.contains("private value: Int >= 1 and <= 65535"));
|
||||
assert!(output.contains("error Failed("));
|
||||
for comment in ["# valid range", "# failure", "# recovery", "# effect"] {
|
||||
assert_eq!(output.matches(comment).count(), 1);
|
||||
}
|
||||
assert!(output.find("pure # effect").unwrap() < output.find("recover(").unwrap());
|
||||
let package = parsed(&output);
|
||||
let Declaration::Function(function) = &package.declarations[2] else {
|
||||
panic!("function")
|
||||
};
|
||||
assert!(function.pure);
|
||||
assert_eq!(
|
||||
function.recovery.as_deref(),
|
||||
Some("use \"configured\" port")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
mod support;
|
||||
|
||||
use support::{checked, run, sources, stdout};
|
||||
|
||||
#[test]
|
||||
fn typed_core_compiles_to_native_values_with_named_outputs() {
|
||||
let output = stdout(include_str!(
|
||||
"../../../examples/old/dever/core/module/main.dever"
|
||||
));
|
||||
assert_eq!(
|
||||
output,
|
||||
concat!(
|
||||
"original = 1\nchanged = 99\nchoice = 7\ncategory = large\nexact = true\n",
|
||||
"quotient = 2.5\nthird = 0.3333333333333333333333333333333333\nrounded = 2.34\n",
|
||||
"integer_division = -2\nremainder = -1\nselected = [3, 6]\nfound = 3\ntotal = 18\n",
|
||||
"empty_total = 0\nnames = ABCabc\nordered_keys = [second, first]\nmaps_equal = false\n",
|
||||
"nan_equal = false\nfinite = true\noptionals = [null, 1, null]\nkey_value = 2\nidentity = true\n",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_field_order_actions_and_short_circuiting_are_preserved() {
|
||||
let output = stdout(
|
||||
r#"type Pair {
|
||||
first: Int
|
||||
second: Int
|
||||
}
|
||||
mark(label: Text, value: Int) (answer: Int) {
|
||||
dever.io.println(label)
|
||||
answer = value
|
||||
}
|
||||
probe(value: Int) (answer: Bool) {
|
||||
dever.io.println("probe")
|
||||
answer = value == 2
|
||||
}
|
||||
show(value: Int) () {
|
||||
dever.io.println("each")
|
||||
}
|
||||
public main() (answer: Int?, first_value: Int?, short: Bool, pair_value: Int) {
|
||||
pair = Pair {
|
||||
second = mark("second", 2)
|
||||
first = mark("first", 1)
|
||||
}
|
||||
pair_value = pair.first + pair.second
|
||||
each(show, [1, 2])
|
||||
answer = find(probe, [1, 2, 3])
|
||||
short = false and probe(10) or true
|
||||
first_value = first([])
|
||||
}
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
output,
|
||||
"second\nfirst\neach\neach\nprobe\nprobe\nanswer = 2\nfirst_value = null\nshort = true\npair_value = 3\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nullable_variants_bind_payloads_and_optional_results_flatten() {
|
||||
let output = stdout(
|
||||
r#"type Result {
|
||||
Found(value: Int)
|
||||
Missing
|
||||
}
|
||||
choose(value: Result.Found(payload)) (answer: Int) { answer = payload }
|
||||
choose(value: Result.Missing) (answer: Int) { answer = -1 }
|
||||
choose(value: null) (answer: Int) { answer = -2 }
|
||||
present(value: Int) (answer: Bool) { answer = true }
|
||||
present(value: null) (answer: Bool) { answer = false }
|
||||
select(values: List<Int?>) (answer: Int?) { answer = first(values) }
|
||||
public main() (payload: Int, missing: Int, absent: Int, first_value: Int?, found: Int?, label: Int?) {
|
||||
payload = choose(Result.Found(7))
|
||||
missing = choose(Result.Missing)
|
||||
absent = choose(null)
|
||||
first_value = select([null, 1])
|
||||
found = find(present, [null, 1, 2])
|
||||
labels = { "first" = optional(4) }
|
||||
label = get(labels, "first")
|
||||
}
|
||||
optional(value: Int) (answer: Int?) { answer = value }
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
output,
|
||||
"payload = 7\nmissing = -1\nabsent = -2\nfirst_value = null\nfound = 1\nlabel = 4\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_numeric_faults_retain_the_original_source_location() {
|
||||
for (function, arguments, message) in [
|
||||
(
|
||||
"divide(left: Int, right: Int) (answer: Int) { answer = left // right }",
|
||||
"1, 0",
|
||||
"Int division by zero",
|
||||
),
|
||||
(
|
||||
"divide(left: Decimal, right: Decimal) (answer: Decimal) { answer = left / right }",
|
||||
"1.0, 0.0",
|
||||
"Decimal division by zero",
|
||||
),
|
||||
(
|
||||
"divide(left: Int, right: Int) (answer: Int) { answer = left + right }",
|
||||
"9223372036854775807, 1",
|
||||
"Int overflow",
|
||||
),
|
||||
] {
|
||||
let output = run(&format!(
|
||||
"{function}\npublic main() () {{ answer = divide({arguments}) }}"
|
||||
));
|
||||
assert!(!output.status.success());
|
||||
let error = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(error.contains("main.dever:2:"), "{error}");
|
||||
assert!(error.contains(message), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_bytes_are_normal_choice_results_and_not_dynamic_values() {
|
||||
let output = stdout(
|
||||
r#"decode(result: dever.bytes.DecodeResult.Decoded(text)) (answer: Text) recover("test observes decode failures in its assertions") { answer = text }
|
||||
decode(result: dever.bytes.DecodeResult.Failed(message)) (answer: Text) recover("test observes decode failures in its assertions") { answer = "invalid" }
|
||||
buffer(result: dever.bytes.BufferResult.Ready(bytes)) (answer: Bytes) recover("test observes buffer failures in its assertions") { answer = bytes }
|
||||
buffer(result: dever.bytes.BufferResult.Failed(message)) (answer: Bytes) recover("test observes buffer failures in its assertions") { answer = dever.bytes.from_text(message) }
|
||||
public main() (decoded: Text, invalid: Text, count: Int, byte: Int?, outside: Int?) {
|
||||
original = dever.bytes.from_text("abc")
|
||||
combined = dever.bytes.concat(original, dever.bytes.from_text("def"))
|
||||
sliced = buffer(result(dever.bytes.slice(combined, 1, 4)))
|
||||
decoded = decode(result(dever.bytes.to_text(sliced)))
|
||||
invalid = decode(result(dever.bytes.to_text(buffer(result(dever.bytes.from_ints([255]))))))
|
||||
count = dever.bytes.length(original)
|
||||
byte = dever.bytes.at(original, 1)
|
||||
outside = dever.bytes.at(original, -1)
|
||||
}
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
output,
|
||||
"decoded = bcd\ninvalid = invalid\ncount = 3\nbyte = 98\noutside = null\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_file_source_compiles_with_explicit_resource_results() {
|
||||
let directory = support::temp::TemporaryDirectory::new();
|
||||
let path = directory.path().join("native.txt");
|
||||
let functions = r#"
|
||||
created(result: dever.io.OpenResult.Opened(file), path: Text) (answer: Bool) recover("test observes created failures in its assertions") {
|
||||
alias = file
|
||||
written = write_ok(result(dever.io.write(file, dever.bytes.from_text("hello"))))
|
||||
closed = close_ok(result(dever.io.close(file)))
|
||||
rejected = write_failed(result(dever.io.write(alias, dever.bytes.from_text("bad"))))
|
||||
content = opened(result(dever.io.open(path)))
|
||||
answer = written and closed and rejected and content
|
||||
}
|
||||
created(result: dever.io.OpenResult.Failed(message), path: Text) (answer: Bool) recover("test observes created failures in its assertions") { answer = false }
|
||||
opened(result: dever.io.OpenResult.Opened(file)) (answer: Bool) recover("test observes opened failures in its assertions") {
|
||||
read = read_ok(result(dever.io.read(file, 64)))
|
||||
closed = close_ok(result(dever.io.close(file)))
|
||||
answer = read and closed
|
||||
}
|
||||
opened(result: dever.io.OpenResult.Failed(message)) (answer: Bool) recover("test observes opened failures in its assertions") { answer = false }
|
||||
read_ok(result: dever.io.ReadResult.Done(state)) (answer: Bool) recover("test observes read_ok failures in its assertions") { answer = read_state_ok(state) }
|
||||
read_ok(result: other) (answer: Bool) recover("test observes read_ok failures in its assertions") { answer = false }
|
||||
read_state_ok(state: dever.io.ReadState.Read(bytes)) (answer: Bool) { answer = decode_ok(result(dever.bytes.to_text(bytes))) }
|
||||
read_state_ok(state: other) (answer: Bool) { answer = false }
|
||||
decode_ok(result: dever.bytes.DecodeResult.Decoded(text)) (answer: Bool) recover("test observes decode_ok failures in its assertions") { answer = text == "hello" }
|
||||
decode_ok(result: other) (answer: Bool) recover("test observes decode_ok failures in its assertions") { answer = false }
|
||||
write_ok(result: dever.io.WriteResult.Done) (answer: Bool) recover("test observes write_ok failures in its assertions") { answer = true }
|
||||
write_ok(result: other) (answer: Bool) recover("test observes write_ok failures in its assertions") { answer = false }
|
||||
write_failed(result: dever.io.WriteResult.Failed(message)) (answer: Bool) recover("test observes write_failed failures in its assertions") { answer = true }
|
||||
write_failed(result: other) (answer: Bool) recover("test observes write_failed failures in its assertions") { answer = false }
|
||||
close_ok(result: dever.io.CloseResult.Closed) (answer: Bool) recover("test observes close_ok failures in its assertions") { answer = true }
|
||||
close_ok(result: other) (answer: Bool) recover("test observes close_ok failures in its assertions") { answer = false }
|
||||
"#;
|
||||
let source = format!(
|
||||
"public main() (result: Bool) {{ result = created(result(dever.io.create({path:?})), {path:?}) }}\n{functions}",
|
||||
path = path.to_str().unwrap()
|
||||
);
|
||||
assert_eq!(stdout(&source), "result = true\n");
|
||||
assert_eq!(std::fs::read_to_string(path).unwrap(), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_tcp_source_runs_a_single_local_connection_and_closes_it() {
|
||||
let output = stdout(
|
||||
r#"public main() (result: Bool) { result = listening(result(dever.net.listen("127.0.0.1", 0))) }
|
||||
listening(result: dever.net.ListenResult.Listening(listener)) (answer: Bool) recover("test observes listening failures in its assertions") {
|
||||
answer = bound(result(dever.net.port(listener)), listener)
|
||||
}
|
||||
listening(result: other) (answer: Bool) recover("test observes listening failures in its assertions") { answer = false }
|
||||
bound(result: dever.net.PortResult.Bound(port), listener: dever.system.Listener) (answer: Bool) recover("test observes bound failures in its assertions") {
|
||||
answer = connected(result(dever.net.connect("127.0.0.1", port)), listener)
|
||||
}
|
||||
bound(result: other, listener: dever.system.Listener) (answer: Bool) recover("test observes bound failures in its assertions") { answer = false }
|
||||
connected(result: dever.net.ConnectResult.Connected(client), listener: dever.system.Listener) (answer: Bool) recover("test observes connected failures in its assertions") {
|
||||
answer = accepted(result(dever.net.accept(listener)), client, listener)
|
||||
}
|
||||
connected(result: other, listener: dever.system.Listener) (answer: Bool) recover("test observes connected failures in its assertions") { answer = false }
|
||||
accepted(result: dever.net.ConnectResult.Connected(server), client: dever.system.Socket, listener: dever.system.Listener) (answer: Bool) recover("test observes accepted failures in its assertions") {
|
||||
client_timeout = write_ok(result(dever.net.timeout(client, 1000)))
|
||||
server_timeout = write_ok(result(dever.net.timeout(server, 1000)))
|
||||
written = write_ok(result(dever.net.write(client, dever.bytes.from_text("x"))))
|
||||
received = read_ok(result(dever.net.read(server, 1)))
|
||||
client_closed = close_ok(result(dever.net.close(client)))
|
||||
server_closed = close_ok(result(dever.net.close(server)))
|
||||
listener_closed = close_ok(result(dever.net.close_listener(listener)))
|
||||
answer = client_timeout and server_timeout and written and received and client_closed and server_closed and listener_closed
|
||||
}
|
||||
accepted(result: other, client: dever.system.Socket, listener: dever.system.Listener) (answer: Bool) recover("test observes accepted failures in its assertions") { answer = false }
|
||||
write_ok(result: dever.io.WriteResult.Done) (answer: Bool) recover("test observes write_ok failures in its assertions") { answer = true }
|
||||
write_ok(result: other) (answer: Bool) recover("test observes write_ok failures in its assertions") { answer = false }
|
||||
close_ok(result: dever.io.CloseResult.Closed) (answer: Bool) recover("test observes close_ok failures in its assertions") { answer = true }
|
||||
close_ok(result: other) (answer: Bool) recover("test observes close_ok failures in its assertions") { answer = false }
|
||||
read_ok(result: dever.io.ReadResult.Done(state)) (answer: Bool) recover("test observes read_ok failures in its assertions") { answer = read_state_ok(state) }
|
||||
read_ok(result: other) (answer: Bool) recover("test observes read_ok failures in its assertions") { answer = false }
|
||||
read_state_ok(state: dever.io.ReadState.Read(bytes)) (answer: Bool) { answer = bytes == dever.bytes.from_text("x") }
|
||||
read_state_ok(state: other) (answer: Bool) { answer = false }
|
||||
"#,
|
||||
);
|
||||
assert_eq!(output, "result = true\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouping_and_null_only_collections_do_not_change_semantics() {
|
||||
let output = stdout(
|
||||
r#"same(value: Int?) (answer: Bool) { answer = (null) == value and value == ((null)) }
|
||||
public main() (size: Int, equal: Bool, first_value: Int?, mapped: List<Text>, appended: List<Int>, found: Int?) {
|
||||
values = [(null), 1]
|
||||
size = length(values)
|
||||
equal = same(null)
|
||||
first_value = first([null])
|
||||
mapped = each((text.trim), [" a ", " b "])
|
||||
appended = append(([]), 1)
|
||||
labels = put(({}), "a", 2)
|
||||
found = get(labels, "a")
|
||||
}
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
output,
|
||||
"size = 2\nequal = true\nfirst_value = null\nmapped = [a, b]\nappended = [1]\nfound = 2\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_patterns_preserve_signed_zero_and_literal_context_is_symmetric() {
|
||||
let output = stdout(
|
||||
r#"sign(value: 0.0e0) (answer: Float) { answer = 1.0e0 / value }
|
||||
sign(value: other) (answer: Float) { answer = 0.0e0 }
|
||||
compare(value: Float) (answer: Bool) { answer = value < 0.0 and 0.0 > value and value == (-1.0) }
|
||||
public main() (negative: Bool, comparison: Bool, ordered_sum: Float) {
|
||||
negative = sign(-0.0e0) < 0.0
|
||||
comparison = compare(-1.0e0)
|
||||
ordered_sum = sum([1.0e16, 1.0e0, -1.0e16])
|
||||
}
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
output,
|
||||
"negative = true\ncomparison = true\nordered_sum = 0\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reduce_bytes_and_forwarded_handlers_are_direct_native_operations() {
|
||||
let output = stdout(
|
||||
r#"add(value: Int, total: Int) (next: Int) { next = total + value }
|
||||
until(value: Int, total: Int) (next: Int, stop: Bool) { next = total + value
|
||||
stop = value == 2 }
|
||||
increment(value: Int) (answer: Int) { answer = value + 1 }
|
||||
apply(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = route(value) }
|
||||
forward(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = apply(route, value) }
|
||||
public main() (list_total: Int, empty_total: Int, stopped: Int, bytes_total: Int, mapped: List<Int>, forwarded: Int) {
|
||||
list_total = reduce(add, [1, 2, 3], 0)
|
||||
empty_total = reduce(add, [], 7)
|
||||
stopped = reduce_until(until, [1, 2, 100], 0)
|
||||
bytes = dever.bytes.from_text("ABC")
|
||||
bytes_total = reduce(add, bytes, 0)
|
||||
mapped = each(increment, bytes)
|
||||
forwarded = forward(increment, 4)
|
||||
}
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
output,
|
||||
"list_total = 6\nempty_total = 7\nstopped = 3\nbytes_total = 198\nmapped = [66, 67, 68]\nforwarded = 5\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_codegen_emits_concrete_specializations_without_function_values() {
|
||||
let sources = sources(
|
||||
r#"increment(value: Int) (answer: Int) { answer = value + 1 }
|
||||
decrement(value: Int) (answer: Int) { answer = value - 1 }
|
||||
apply(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = route(value) }
|
||||
public main() (up: Int, down: Int) { up = apply(increment, 1)
|
||||
down = apply(decrement, 1) }
|
||||
"#,
|
||||
);
|
||||
let generated = dever_core::native::emit(&checked(&sources), &sources, "main.main").unwrap();
|
||||
assert_eq!(
|
||||
generated
|
||||
.lines()
|
||||
.filter(|line| line.starts_with("fn f") && line.contains('h'))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert!(!generated.contains("dyn Fn"));
|
||||
assert!(!generated.contains("fn("));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_reduce_codegen_iterates_bytes_without_materializing_a_list() {
|
||||
let sources = sources(
|
||||
r#"add(value: Int, total: Int) (next: Int) { next = total + value }
|
||||
public main() (answer: Int) {
|
||||
bytes = dever.bytes.from_text("ABC")
|
||||
answer = reduce(add, bytes, 0)
|
||||
}
|
||||
"#,
|
||||
);
|
||||
let generated = dever_core::native::emit(&checked(&sources), &sources, "main.main").unwrap();
|
||||
assert!(generated.contains("for element in a0.values()"));
|
||||
assert!(generated.contains("i64::from(*element)"));
|
||||
assert!(!generated.contains("List::<i64>::new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_file_chunks_stop_and_resume_the_same_stream_alias() {
|
||||
let directory = support::temp::TemporaryDirectory::new();
|
||||
let path = directory.path().join("chunks.txt");
|
||||
std::fs::write(&path, "abcdef").unwrap();
|
||||
let source = format!(
|
||||
r#"decode(result: dever.bytes.DecodeResult.Decoded(text), state: Text) (next: Text) recover("test observes decode failures in its assertions") {{ next = state + text }}
|
||||
decode(result: dever.bytes.DecodeResult.Failed(message), state: Text) (next: Text) recover("test observes decode failures in its assertions") {{ next = state }}
|
||||
collect(event: dever.io.ReadEvent.Chunk(bytes), state: Text) (next: Text) recover("test observes collect failures in its assertions") {{ next = decode(result(dever.bytes.to_text(bytes)), state) }}
|
||||
collect(event: dever.io.ReadEvent.Failed(message), state: Text) (next: Text) recover("test observes collect failures in its assertions") {{ next = state }}
|
||||
first(event: dever.io.ReadEvent.Chunk(bytes), state: Text) (next: Text, stop: Bool) recover("test observes first failures in its assertions") {{ next = decode(result(dever.bytes.to_text(bytes)), state)
|
||||
stop = true }}
|
||||
first(event: dever.io.ReadEvent.Failed(message), state: Text) (next: Text, stop: Bool) recover("test observes first failures in its assertions") {{ next = state
|
||||
stop = true }}
|
||||
streaming(result: dever.io.ReadStreamResult.Streaming(stream)) (answer: Text) recover("test observes streaming failures in its assertions") {{ alias = stream
|
||||
prefix = reduce_until(first, stream, "")
|
||||
answer = reduce(collect, alias, prefix)
|
||||
close(stream) }}
|
||||
streaming(result: dever.io.ReadStreamResult.Failed(message)) (answer: Text) recover("test observes streaming failures in its assertions") {{ answer = message }}
|
||||
opened(result: dever.io.OpenResult.Opened(file)) (answer: Text) recover("test observes opened failures in its assertions") {{ answer = streaming(result(dever.io.chunks(file, 2))) }}
|
||||
opened(result: dever.io.OpenResult.Failed(message)) (answer: Text) recover("test observes opened failures in its assertions") {{ answer = message }}
|
||||
public main() (answer: Text) {{ answer = opened(result(dever.io.open({path:?}))) }}
|
||||
"#,
|
||||
path = path.to_str().unwrap()
|
||||
);
|
||||
assert_eq!(stdout(&source), "answer = abcdef\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_file_chunks_report_creation_failures_without_a_stream() {
|
||||
let directory = support::temp::TemporaryDirectory::new();
|
||||
let path = directory.path().join("creation-failure.txt");
|
||||
std::fs::write(&path, "x").unwrap();
|
||||
let source = format!(
|
||||
r#"failed(result: dever.io.ReadStreamResult.Streaming(stream)) (answer: Bool) recover("test observes failed failures in its assertions") {{ close(stream)
|
||||
answer = false }}
|
||||
failed(result: dever.io.ReadStreamResult.Failed(message)) (answer: Bool) recover("test observes failed failures in its assertions") {{ answer = true }}
|
||||
opened(result: dever.io.OpenResult.Opened(file)) (invalid: Bool, closed: Bool) recover("test observes opened failures in its assertions") {{
|
||||
invalid = failed(result(dever.io.chunks(file, 0)))
|
||||
dever.io.close(file)
|
||||
closed = failed(result(dever.io.chunks(file, 1)))
|
||||
}}
|
||||
opened(result: dever.io.OpenResult.Failed(message)) (invalid: Bool, closed: Bool) recover("test observes opened failures in its assertions") {{ invalid = false
|
||||
closed = false }}
|
||||
public main() (invalid: Bool, closed: Bool) {{ result = opened(result(dever.io.open({path:?})))
|
||||
invalid = result.invalid
|
||||
closed = result.closed }}
|
||||
"#,
|
||||
path = path.to_str().unwrap()
|
||||
);
|
||||
assert_eq!(stdout(&source), "invalid = true\nclosed = true\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_loopback_connections_and_socket_chunks_are_streamed() {
|
||||
let output = stdout(
|
||||
r#"type State {
|
||||
client: dever.system.Socket
|
||||
text: Text
|
||||
}
|
||||
decode(result: dever.bytes.DecodeResult.Decoded(text), state: State) (next: State) recover("test observes decode failures in its assertions") { next = State { client = state.client
|
||||
text = state.text + text } }
|
||||
decode(result: dever.bytes.DecodeResult.Failed(message), state: State) (next: State) recover("test observes decode failures in its assertions") { next = state }
|
||||
collect(event: dever.io.ReadEvent.Chunk(bytes), state: State) (next: State) recover("test observes collect failures in its assertions") { next = decode(result(dever.bytes.to_text(bytes)), state) }
|
||||
collect(event: dever.io.ReadEvent.Failed(message), state: State) (next: State) recover("test observes collect failures in its assertions") { next = state }
|
||||
reading(result: dever.net.ReadStreamResult.Streaming(stream), state: State) (next: State) recover("test observes reading failures in its assertions") { next = reduce(collect, stream, state) }
|
||||
reading(result: dever.net.ReadStreamResult.Failed(message), state: State) (next: State) recover("test observes reading failures in its assertions") { next = state }
|
||||
accepted(result: dever.net.ConnectResult.Connected(server), state: State) (next: State, stop: Bool) recover("test observes accepted failures in its assertions") {
|
||||
dever.net.timeout(state.client, 1000)
|
||||
dever.net.timeout(server, 1000)
|
||||
dever.net.write(state.client, dever.bytes.from_text("xyz"))
|
||||
dever.net.close(state.client)
|
||||
next = reading(result(dever.net.chunks(server, 2)), state)
|
||||
stop = true
|
||||
}
|
||||
accepted(result: dever.net.ConnectResult.Failed(message), state: State) (next: State, stop: Bool) recover("test observes accepted failures in its assertions") { next = state
|
||||
stop = true }
|
||||
connected(result: dever.net.ConnectResult.Connected(client), listener: dever.system.Listener) (answer: Text) recover("test observes connected failures in its assertions") {
|
||||
stream = dever.net.connections(listener)
|
||||
state = State { client = client
|
||||
text = "" }
|
||||
finished = reduce_until(accepted, stream, state)
|
||||
close(stream)
|
||||
answer = finished.text
|
||||
}
|
||||
connected(result: dever.net.ConnectResult.Failed(message), listener: dever.system.Listener) (answer: Text) recover("test observes connected failures in its assertions") { answer = message }
|
||||
bound(result: dever.net.PortResult.Bound(port), listener: dever.system.Listener) (answer: Text) recover("test observes bound failures in its assertions") { answer = connected(result(dever.net.connect("127.0.0.1", port)), listener) }
|
||||
bound(result: dever.net.PortResult.Failed(message), listener: dever.system.Listener) (answer: Text) recover("test observes bound failures in its assertions") { answer = message }
|
||||
listening(result: dever.net.ListenResult.Listening(listener)) (answer: Text) recover("test observes listening failures in its assertions") { answer = bound(result(dever.net.port(listener)), listener) }
|
||||
listening(result: dever.net.ListenResult.Failed(message)) (answer: Text) recover("test observes listening failures in its assertions") { answer = message }
|
||||
public main() (answer: Text) { answer = listening(result(dever.net.listen("127.0.0.1", 0))) }
|
||||
"#,
|
||||
);
|
||||
assert_eq!(output, "answer = xyz\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_consumes_a_stream_only_for_zero_output_actions() {
|
||||
let directory = support::temp::TemporaryDirectory::new();
|
||||
let path = directory.path().join("each.txt");
|
||||
std::fs::write(&path, "abcd").unwrap();
|
||||
let source = format!(
|
||||
r#"print(result: dever.bytes.DecodeResult.Decoded(text)) () recover("test observes print failures in its assertions") {{ dever.io.println(text) }}
|
||||
print(result: dever.bytes.DecodeResult.Failed(message)) () recover("test observes print failures in its assertions") {{ dever.io.println("failed") }}
|
||||
show(event: dever.io.ReadEvent.Chunk(bytes)) () recover("test observes show failures in its assertions") {{ print(result(dever.bytes.to_text(bytes))) }}
|
||||
show(event: dever.io.ReadEvent.Failed(message)) () recover("test observes show failures in its assertions") {{ dever.io.println("failed") }}
|
||||
streaming(result: dever.io.ReadStreamResult.Streaming(stream)) (answer: Bool) recover("test observes streaming failures in its assertions") {{ each(show, stream)
|
||||
answer = true }}
|
||||
streaming(result: dever.io.ReadStreamResult.Failed(message)) (answer: Bool) recover("test observes streaming failures in its assertions") {{ answer = false }}
|
||||
opened(result: dever.io.OpenResult.Opened(file)) (answer: Bool) recover("test observes opened failures in its assertions") {{ answer = streaming(result(dever.io.chunks(file, 2))) }}
|
||||
opened(result: dever.io.OpenResult.Failed(message)) (answer: Bool) recover("test observes opened failures in its assertions") {{ answer = false }}
|
||||
public main() (answer: Bool) {{ answer = opened(result(dever.io.open({path:?}))) }}
|
||||
"#,
|
||||
path = path.to_str().unwrap()
|
||||
);
|
||||
assert_eq!(stdout(&source), "ab\ncd\nanswer = true\n");
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
mod support;
|
||||
|
||||
use support::{checked, rejected, sources};
|
||||
|
||||
#[test]
|
||||
fn full_language_contract_package_checks_with_the_official_sources() {
|
||||
let prd = include_str!("../../../.trellis/tasks/09-04-dever-language-mvp/prd.md");
|
||||
let source = prd
|
||||
.split_once("## Complete Package Example\n")
|
||||
.unwrap()
|
||||
.1
|
||||
.split_once("```dever\n")
|
||||
.unwrap()
|
||||
.1
|
||||
.split_once("\n```")
|
||||
.unwrap()
|
||||
.0;
|
||||
let mut sources = dever_core::source::SourceMap::default();
|
||||
sources.add("account/user.dever", source);
|
||||
checked(&sources);
|
||||
assert_eq!(sources.files().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clause_domains_are_disjoint_exhaustive_and_order_independent() {
|
||||
for clauses in [
|
||||
"choose(value: true) (answer: Int) { answer = 1 }\nchoose(value: false) (answer: Int) { answer = 2 }",
|
||||
"choose(value: false) (answer: Int) { answer = 2 }\nchoose(value: true) (answer: Int) { answer = 1 }",
|
||||
"choose(value: Text) (answer: Int) { answer = 1 }\nchoose(value: null) (answer: Int) { answer = 0 }",
|
||||
"choose(value: Int < 0) (answer: Int) { answer = -1 }\nchoose(value: Int >= 0) (answer: Int) { answer = 1 }",
|
||||
"choose(value: Decimal < 0.0) (answer: Int) { answer = -1 }\nchoose(value: other) (answer: Int) { answer = 1 }",
|
||||
"choose(value: \"yes\") (answer: Int) { answer = 1 }\nchoose(value: other) (answer: Int) { answer = 0 }",
|
||||
"choose(value: 1.0e0) (answer: Int) { answer = 1 }\nchoose(value: other) (answer: Int) { answer = 0 }",
|
||||
] {
|
||||
checked(&sources(clauses));
|
||||
}
|
||||
for (clauses, message) in [
|
||||
("choose(value: true) () {}", "do not cover"),
|
||||
(
|
||||
"choose(value: Bool) () {}\nchoose(value: true) () {}",
|
||||
"overlap",
|
||||
),
|
||||
(
|
||||
"choose(value: true) () {}\nchoose(value: false) () {}\nchoose(value: other) () {}",
|
||||
"unreachable",
|
||||
),
|
||||
(
|
||||
"choose(value: Text?) () {}\nchoose(value: null) () {}",
|
||||
"overlap",
|
||||
),
|
||||
(
|
||||
"choose(value: null) () {}\nchoose(value: other) () {}",
|
||||
"cannot infer",
|
||||
),
|
||||
("choose(value: Int > 0 and < 1) () {}", "unreachable"),
|
||||
(
|
||||
"choose(value: Int > 9223372036854775807) () {}",
|
||||
"unreachable",
|
||||
),
|
||||
(
|
||||
"choose(value: Decimal > 1.0 and < 1.000000000000000000000000000000001) () {}",
|
||||
"unreachable",
|
||||
),
|
||||
("choose(value: Float > 0.0) () {}", "range bounds"),
|
||||
("choose(value: Int > 0 and > 1) () {}", "one lower"),
|
||||
(
|
||||
"choose(left: true, right: Bool) () {}\nchoose(left: other, right: false) () {}",
|
||||
"every input combination",
|
||||
),
|
||||
("choose(left: Bool, right: other) () {}", "cannot infer"),
|
||||
("choose() () {}\nchoose() () {}", "overlap"),
|
||||
(
|
||||
"choose(value: true) (answer: Int) { answer = 1 }\nchoose(value: false) (different: Int) { different = 2 }",
|
||||
"identical ordered output",
|
||||
),
|
||||
] {
|
||||
rejected(clauses, message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_quadrants_cover_a_multi_input_product() {
|
||||
checked(&sources(
|
||||
"public choose(a: true, b: true) () {}\npublic choose(a: true, b: other) () {}\npublic choose(a: other, b: true) () {}\npublic choose(a: other, b: other) () {}",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construction_initialization_and_stable_types_are_checked() {
|
||||
let prefix =
|
||||
"type User { name: Text\nphone: Text? }\ntype Result { Found(user: User)\nMissing }\n";
|
||||
for (body, message) in [
|
||||
("main() () { user = User { name = \"a\" } }", "every field"),
|
||||
(
|
||||
"main() () { user = User { name = \"a\"\nphone = null\nextra = 1 } }",
|
||||
"unknown constructor field",
|
||||
),
|
||||
(
|
||||
"main() () { user = User { name = \"a\"\nname = \"b\"\nphone = null } }",
|
||||
"duplicate constructor",
|
||||
),
|
||||
("main() (user: User) { user.name = \"a\" }", "uninitialized"),
|
||||
("main() (answer: Int) {}", "uninitialized"),
|
||||
("main() () { value = null }", "nullable type context"),
|
||||
("main() () { value = 1\nvalue = \"a\" }", "expected Int"),
|
||||
("main() () { value = Result.Found() }", "payload arity"),
|
||||
("main() () { value = Result.Missing(1) }", "payload arity"),
|
||||
("main() () { values = [] }", "type context"),
|
||||
("main() () { labels = {} }", "type context"),
|
||||
("main() () { values = [1, true] }", "one stable type"),
|
||||
("main() () { values = { 1.5 = 1 } }", "Map key"),
|
||||
(
|
||||
"main() () { values = { \"a\" = 1\n\"a\" = 2 } }",
|
||||
"duplicate Map",
|
||||
),
|
||||
(
|
||||
"main() (a: Int, b: Int) { a = 1\nb = 2 }\ninvalid() () { values = [main()] }",
|
||||
"multi-output",
|
||||
),
|
||||
(
|
||||
"main() (a: Int, b: Int) { a = 1\nb = 2 }\ninvalid() (same: Bool) { same = main() == main() }",
|
||||
"comparable",
|
||||
),
|
||||
] {
|
||||
rejected(&format!("{prefix}{body}"), message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_rules_and_static_faults_are_enforced() {
|
||||
for (expression, message) in [
|
||||
("9223372036854775808", "64-bit"),
|
||||
("-9223372036854775809", "64-bit"),
|
||||
("0.12345678901234567890123456789012345", "34 digits"),
|
||||
("9223372036854775807 + 1", "overflow"),
|
||||
("-9223372036854775808 // -1", "overflow"),
|
||||
("1 // 0", "division by zero"),
|
||||
("1 / 0", "division by zero"),
|
||||
("1 == 1.0", "does not promote"),
|
||||
("1 + 1.0e0", "cannot implicitly mix"),
|
||||
("1.0 // 2", "require Int"),
|
||||
("1 and true", "expected Bool"),
|
||||
] {
|
||||
rejected(&format!("main() () {{ value = {expression} }}"), message);
|
||||
}
|
||||
checked(&sources(
|
||||
"public main() (min: Int, exact: Bool, quotient: Decimal, float: Float) { min = -9223372036854775808\nexact = 0.1 + 0.2 == 0.3\nquotient = 5 / 2\nfloat = 1.5 + 2.5 }",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphs_cover_type_references_and_collection_handlers() {
|
||||
rejected("type Recursive { next: Recursive? }", "recursive type");
|
||||
rejected("type A { b: B }\ntype B { a: List<A> }", "recursive type");
|
||||
rejected(
|
||||
"repeat_value(value: Int) (answer: Int) { values = each(repeat_value, [value])\nanswer = sum(values) }",
|
||||
"recursive function",
|
||||
);
|
||||
rejected(
|
||||
"f(value: true) () {}\ng() () {}\nf(value: false) () {}",
|
||||
"contiguous",
|
||||
);
|
||||
let mut sources = dever_core::source::SourceMap::default();
|
||||
sources.add(
|
||||
"a.dever",
|
||||
"public type A { value: Int }\nstart() () { b.run() }\nfinish() () {}",
|
||||
);
|
||||
sources.add("b.dever", "public run() () {}\nunused(value: a.A) () {}");
|
||||
assert!(
|
||||
dever_core::check(&sources)
|
||||
.unwrap_err()
|
||||
.iter()
|
||||
.any(|error| error.message.contains("cyclic package"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_handlers_are_rejected_before_build() {
|
||||
for (declarations, call, message) in [
|
||||
(
|
||||
"handler(value: Int) (a: Int, b: Int) { a = value\nb = value }",
|
||||
"each(handler, [1])",
|
||||
"invalid outputs",
|
||||
),
|
||||
(
|
||||
"handler(value: Int) (value: Int) { value = 1 }",
|
||||
"filter(handler, [1])",
|
||||
"duplicate local",
|
||||
),
|
||||
(
|
||||
"handler(value: Int) (answer: Int) { answer = value }",
|
||||
"filter(handler, [1])",
|
||||
"invalid outputs",
|
||||
),
|
||||
(
|
||||
"handler(value: Int) () {}",
|
||||
"sum(handler, [1])",
|
||||
"invalid outputs",
|
||||
),
|
||||
(
|
||||
"handler(value: Int) () {}",
|
||||
"each(handler, [1], 2)",
|
||||
"unknown function",
|
||||
),
|
||||
] {
|
||||
rejected(
|
||||
&format!("{declarations}\nmain() () {{ result = {call} }}"),
|
||||
message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unused_writes_are_warnings_not_errors() {
|
||||
let sources = sources("public main() () { value = 1\nvalue = 2 }");
|
||||
let program = checked(&sources);
|
||||
assert_eq!(
|
||||
program
|
||||
.warnings()
|
||||
.iter()
|
||||
.filter(|warning| warning.code == "W001")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert!(
|
||||
program.warnings()[0]
|
||||
.render(&sources)
|
||||
.contains("warning[W001]")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_scalar_and_choice_keys_are_rejected_when_duplicated() {
|
||||
for body in [
|
||||
"labels = { Key.First = 1\nKey.First = 2 }",
|
||||
"key = Key.First\nlabels = { key = 1\nKey.First = 2 }",
|
||||
"key = \"same\"\nlabels = { key = 1\n\"same\" = 2 }",
|
||||
] {
|
||||
rejected(
|
||||
&format!("type Key {{ First\nSecond }}\nmain() () {{ {body} }}"),
|
||||
"duplicate Map literal key",
|
||||
);
|
||||
}
|
||||
rejected(
|
||||
"invalid(key: Id) () { labels = { key = 1\nkey = 2 } }",
|
||||
"duplicate Map literal key",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_numeric_faults_include_local_constants_and_constant_divisors() {
|
||||
rejected(
|
||||
"invalid(value: Int) (answer: Int) { answer = value // 0 }",
|
||||
"division by zero",
|
||||
);
|
||||
rejected(
|
||||
"invalid(value: Decimal) (answer: Decimal) { zero = 0\nanswer = value / zero }",
|
||||
"division by zero",
|
||||
);
|
||||
rejected(
|
||||
"invalid() () { value = 9223372036854775807\nanswer = value + 1 }",
|
||||
"overflow",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_clause_inference_is_stable_under_permutation() {
|
||||
for clauses in [
|
||||
"choose(value: 1) () {}\nchoose(value: 2.0) () {}\nchoose(value: other) () {}",
|
||||
"choose(value: 2.0) () {}\nchoose(value: other) () {}\nchoose(value: 1) () {}",
|
||||
] {
|
||||
checked(&sources(clauses));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_follow_the_existing_language_conventions() {
|
||||
rejected(
|
||||
"package App exposes ()",
|
||||
"package/exposes declarations were removed",
|
||||
);
|
||||
for source in [
|
||||
"type user { name: Text }",
|
||||
"Main() () {}",
|
||||
"main() () { bad__name = 1 }",
|
||||
] {
|
||||
rejected(source, "ASCII");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_literal_context_never_retypes_existing_numeric_values() {
|
||||
checked(&sources(
|
||||
"compare(value: Float) (answer: Bool) { answer = value < 0.0 or 0.0 == value or (-0.0) >= value }",
|
||||
));
|
||||
rejected(
|
||||
"compare(value: Float) (answer: Bool) { decimal = 0.0\nanswer = value == decimal }",
|
||||
"same comparable type",
|
||||
);
|
||||
rejected(
|
||||
"compare(value: Float) (answer: Bool) { answer = value == 0 }",
|
||||
"same comparable type",
|
||||
);
|
||||
rejected(
|
||||
"invalid() () { decimal = 1.0\nvalue = decimal + 1.0e0 }",
|
||||
"cannot implicitly mix",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reduce_bytes_stream_and_static_handler_contracts_are_checked() {
|
||||
checked(&sources(
|
||||
r#"add(value: Int, total: Int) (next: Int) { next = total + value }
|
||||
until(value: Int, total: Int) (next: Int, stop: Bool) { next = total + value
|
||||
stop = value == 2 }
|
||||
show(value: Int) () {}
|
||||
apply(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = route(value) }
|
||||
forward(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = apply(route, value) }
|
||||
consume(stream: Stream<Int>) (total: Int) { each(show, stream)
|
||||
total = 0 }
|
||||
main() () { total = reduce(add, [1, 2], 0)
|
||||
partial = reduce_until(until, dever.bytes.from_text("abc"), 0) }"#,
|
||||
));
|
||||
|
||||
for (source, message) in [
|
||||
(
|
||||
"map(value: Int) (answer: Int) { answer = value }\nbad(stream: Stream<Int>) () { values = each(map, stream) }",
|
||||
"each on a stream",
|
||||
),
|
||||
(
|
||||
"bad(value: Int, state: Int) (next: Int, done: Bool) { next = state\ndone = true }\nmain() () { value = reduce_until(bad, [1], 0) }",
|
||||
"stop: Bool",
|
||||
),
|
||||
(
|
||||
"apply(route: handler(value: Int) (answer: Int)) (answer: Int) { answer = route }",
|
||||
"not runtime values",
|
||||
),
|
||||
(
|
||||
"text(value: Text) (answer: Text) { answer = value }\napply(route: handler(value: Int) (answer: Int)) (answer: Int) { answer = route(1) }\nmain() () { answer = apply(text) }",
|
||||
"signature does not match",
|
||||
),
|
||||
(
|
||||
"wrong(value: Int) (result: Int) { result = value }\napply(route: handler(value: Int) (answer: Int)) (answer: Int) { answer = route(1) }\nmain() () { answer = apply(wrong) }",
|
||||
"signature does not match",
|
||||
),
|
||||
(
|
||||
"apply(route: handler(value: Int) (answer: Int)) () { values = [route] }",
|
||||
"not runtime values",
|
||||
),
|
||||
(
|
||||
"apply(route: handler(value: Int) (answer: Int)) (answer: Bool) { answer = route == route }",
|
||||
"not runtime values",
|
||||
),
|
||||
(
|
||||
"loop(next: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = next(value) }\nagain(value: Int) (answer: Int) { answer = loop(again, value) }",
|
||||
"specialized handler",
|
||||
),
|
||||
] {
|
||||
rejected(source, message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_functions_can_cross_packages_and_forward_statically() {
|
||||
let mut sources = dever_core::source::SourceMap::default();
|
||||
sources.add(
|
||||
"worker.dever",
|
||||
"public double(value: Int) (answer: Int) { answer = value * 2 }",
|
||||
);
|
||||
sources.add(
|
||||
"main.dever",
|
||||
"apply(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = route(value) }\nforward(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = apply(route, value) }\npublic main() (answer: Int) { answer = forward(worker.double, 3) }",
|
||||
);
|
||||
checked(&sources);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_signatures_and_streams_reuse_value_type_restrictions() {
|
||||
for source in [
|
||||
"apply(route: handler(value: Map<List<Int>, Int>) ()) () {}",
|
||||
"apply(route: handler() (value: Map<List<Int>, Int>)) () {}",
|
||||
"bad(values: Map<Stream<Int>, Int>) () {}",
|
||||
] {
|
||||
rejected(source, "invalid Map key type");
|
||||
}
|
||||
rejected(
|
||||
"bad(stream: Stream) () {}",
|
||||
"incorrect number of type arguments",
|
||||
);
|
||||
rejected(
|
||||
"bad(stream: Stream<Int>) (same: Bool) { same = stream == stream }",
|
||||
"same comparable type",
|
||||
);
|
||||
rejected(
|
||||
"bad(values: List<Int>) () { close(values) }",
|
||||
"close requires a Stream",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
mod support;
|
||||
|
||||
use dever_core::{native, reference, source::SourceMap};
|
||||
use std::ffi::OsString;
|
||||
use std::process::Command;
|
||||
use support::{checked, sources};
|
||||
|
||||
fn compare_sources(sources: &SourceMap, expected: &str) {
|
||||
let program = checked(sources);
|
||||
let evaluated = reference::evaluate(&program, "main.main").unwrap();
|
||||
assert!(evaluated.fault.is_none(), "{:?}", evaluated.fault);
|
||||
assert_eq!(evaluated.stdout, expected);
|
||||
let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| OsString::from("rustc"));
|
||||
let executable = native::compile(&program, sources, "main.main", &rustc).unwrap();
|
||||
let output = Command::new(executable.executable()).output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(output.stderr.is_empty());
|
||||
assert_eq!(String::from_utf8(output.stdout).unwrap(), evaluated.stdout);
|
||||
}
|
||||
|
||||
fn compare(source: &str, expected: &str) {
|
||||
compare_sources(&sources(source), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uuid_parsing_and_canonical_text_agree() {
|
||||
compare(
|
||||
r#"display(value: Uuid) (text: Text) { text = uuid.to_text(value) }
|
||||
display(value: null) (text: Text) { text = "invalid" }
|
||||
public main() (parsed: Text, invalid: Text, literal: Text) {
|
||||
parsed = display(uuid.parse("018F08D8-59B0-7CC2-98C4-DC0C0C07398F"))
|
||||
invalid = display(uuid.parse("not-a-uuid"))
|
||||
literal = uuid.to_text("12345678-1234-5678-9012-123456789abc")
|
||||
}"#,
|
||||
"parsed = 018f08d8-59b0-7cc2-98c4-dc0c0c07398f\ninvalid = invalid\nliteral = 12345678-1234-5678-9012-123456789abc\n",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_core_example_has_independent_expected_results() {
|
||||
compare(
|
||||
include_str!("../../../examples/old/dever/core/module/main.dever"),
|
||||
concat!(
|
||||
"original = 1\nchanged = 99\nchoice = 7\ncategory = large\nexact = true\n",
|
||||
"quotient = 2.5\nthird = 0.3333333333333333333333333333333333\nrounded = 2.34\n",
|
||||
"integer_division = -2\nremainder = -1\nselected = [3, 6]\nfound = 3\ntotal = 18\n",
|
||||
"empty_total = 0\nnames = ABCabc\nordered_keys = [second, first]\nmaps_equal = false\n",
|
||||
"nan_equal = false\nfinite = true\noptionals = [null, 1, null]\nkey_value = 2\nidentity = true\n",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expression_order_nested_aliases_and_named_output_projections_agree() {
|
||||
compare(
|
||||
r#"public type Pair { left: Int
|
||||
right: Int }
|
||||
public type Box { pair: Pair
|
||||
values: List<Int> }
|
||||
mark(label: Text, value: Int) (answer: Int) { dever.io.println(label)
|
||||
answer = value }
|
||||
key(label: Text) (answer: Text) { dever.io.println(label)
|
||||
answer = label }
|
||||
parts() (left: Int, right: Int) { left = 3
|
||||
right = 4 }
|
||||
never() (answer: Bool) { dever.io.println("unexpected")
|
||||
answer = true }
|
||||
public main() (saved: Box, changed: Box, pair: Pair, ordered: Map<Text, Int>, old: List<Int>, short: Bool) {
|
||||
pair = Pair { right = mark("right", 2)
|
||||
left = mark("left", 1) }
|
||||
saved = Box { pair = pair
|
||||
values = [1] }
|
||||
changed = saved
|
||||
changed.pair.left = 9
|
||||
changed.values = append(changed.values, 2)
|
||||
old = saved.values
|
||||
outputs = parts()
|
||||
copy = outputs
|
||||
pair.right = copy.left
|
||||
ordered = { key("a") = mark("av", 1)
|
||||
key("b") = mark("bv", 2) }
|
||||
short = false and never() or true or never()
|
||||
}
|
||||
"#,
|
||||
"right\nleft\na\nav\nb\nbv\nsaved = main.Box { pair = main.Pair { left = 1, right = 2 }, values = [1] }\nchanged = main.Box { pair = main.Pair { left = 9, right = 2 }, values = [1, 2] }\npair = main.Pair { left = 1, right = 3 }\nordered = {a = 1, b = 2}\nold = [1]\nshort = true\n",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nullable_choice_payloads_domains_and_signed_zero_agree() {
|
||||
compare(
|
||||
r#"type Result { Found(value: Int)
|
||||
Missing }
|
||||
unwrap(value: Result.Found(payload)) (answer: Int) { answer = payload }
|
||||
unwrap(value: Result.Missing) (answer: Int) { answer = -1 }
|
||||
unwrap(value: null) (answer: Int) { answer = -2 }
|
||||
decimal_kind(value: Decimal < 0) (answer: Int) { answer = -1 }
|
||||
decimal_kind(value: Decimal >= 0 and <= 1) (answer: Int) { answer = 0 }
|
||||
decimal_kind(value: Decimal > 1) (answer: Int) { answer = 1 }
|
||||
label(value: "x") (answer: Int) { answer = 1 }
|
||||
label(value: other) (answer: Int) { answer = 2 }
|
||||
sign(value: 0.0e0) (answer: Float) { answer = 1.0e0 / value }
|
||||
sign(value: other) (answer: Float) { answer = 0.0e0 }
|
||||
present(value: Int) (answer: Bool) { answer = true }
|
||||
present(value: null) (answer: Bool) { answer = false }
|
||||
optional(value: Int) (answer: Int?) { answer = value }
|
||||
public main() (values: List<Int>, first_value: Int?, found: Int?, missing: Int?, negative: Bool, kind: Int, text_kind: Int, ordered: Float) {
|
||||
values = [unwrap(Result.Found(7)), unwrap(Result.Missing), unwrap(null)]
|
||||
first_value = first([null, 1])
|
||||
found = find(present, [null, 2])
|
||||
mapping = { "x" = optional(1) }
|
||||
missing = get(mapping, "none")
|
||||
negative = sign(-0.0e0) < 0.0
|
||||
kind = decimal_kind(-0.1) + decimal_kind(0.5) + decimal_kind(1.1)
|
||||
text_kind = label("x") + label("y")
|
||||
ordered = sum([1.0e16, 1.0e0, -1.0e16])
|
||||
}
|
||||
"#,
|
||||
"values = [7, -1, -2]\nfirst_value = null\nfound = 2\nmissing = null\nnegative = true\nkind = 0\ntext_kind = 3\nordered = 0\n",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_handlers_cross_packages_and_reduce_all_finite_sequences() {
|
||||
let mut sources = sources(
|
||||
r#"add(value: Int, state: Int) (next: Int) { next = state + value }
|
||||
until(value: Int, state: Int) (next: Int, stop: Bool) { next = state + value
|
||||
stop = value == 2 }
|
||||
increment(value: Int) (answer: Int) { answer = value + 1 }
|
||||
show(value: Int) () { dever.io.println(int.to_text(value)) }
|
||||
public main() (list: Int, empty: Int, stopped: Int, bytes: Int, mapped: List<Int>, forwarded: Int) {
|
||||
list = reduce(add, [1, 2, 3], 0)
|
||||
empty = reduce(add, [], 9)
|
||||
stopped = reduce_until(until, [1, 2, 100], 0)
|
||||
raw = dever.bytes.from_text("AB")
|
||||
bytes = reduce(add, raw, 0)
|
||||
mapped = each(increment, raw)
|
||||
each(show, [1, 2])
|
||||
each(show, dever.bytes.from_text("A"))
|
||||
forwarded = helper.forward(increment, 4)
|
||||
}
|
||||
"#,
|
||||
);
|
||||
sources.add("helper.dever", r#"public forward(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = apply(value, route) }
|
||||
apply(value: Int, route: handler(value: Int) (answer: Int)) (answer: Int) { answer = route(value) }
|
||||
"#);
|
||||
compare_sources(
|
||||
&sources,
|
||||
"1\n2\n65\nlist = 6\nempty = 9\nstopped = 3\nbytes = 131\nmapped = [66, 67]\nforwarded = 5\n",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_faults_match_exact_locations_call_traces_and_prior_stdout() {
|
||||
for (function, arguments, expected) in [
|
||||
(
|
||||
"compute_failure(a: Int, b: Int) (answer: Int) { answer = a // b }",
|
||||
"1, 0",
|
||||
"Int division by zero",
|
||||
),
|
||||
(
|
||||
"compute_failure(a: Int, b: Int) (answer: Int) { answer = a + b }",
|
||||
"9223372036854775807, 1",
|
||||
"Int overflow",
|
||||
),
|
||||
(
|
||||
"compute_failure(a: Decimal, b: Decimal) (answer: Decimal) { answer = a / b }",
|
||||
"1.0, 0.0",
|
||||
"Decimal division by zero",
|
||||
),
|
||||
(
|
||||
"compute_failure(a: Int, b: Int) (answer: Map<Int, Int>) { answer = { a = 1\nb = 2 } }",
|
||||
"1, 1",
|
||||
"duplicate Map key",
|
||||
),
|
||||
] {
|
||||
let sources = sources(&format!(
|
||||
"{function}\npublic main() () {{ dever.io.println(\"before\")\nanswer = compute_failure({arguments}) }}"
|
||||
));
|
||||
let program = checked(&sources);
|
||||
let evaluated = reference::evaluate(&program, "main.main").unwrap();
|
||||
assert_eq!(evaluated.stdout, "before\n");
|
||||
let fault = evaluated.fault.unwrap();
|
||||
assert_eq!(fault.message, expected);
|
||||
assert!(fault.spans.len() >= 2);
|
||||
assert_eq!(
|
||||
sources
|
||||
.get(fault.spans[0].source)
|
||||
.position(fault.spans[0].start)
|
||||
.0,
|
||||
1
|
||||
);
|
||||
let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| OsString::from("rustc"));
|
||||
let executable = native::compile(&program, &sources, "main.main", &rustc).unwrap();
|
||||
let output = Command::new(executable.executable()).output().unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert_eq!(String::from_utf8(output.stdout).unwrap(), evaluated.stdout);
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
assert_eq!(stderr.lines().count(), 1, "{stderr}");
|
||||
let record: serde_json::Value = serde_json::from_str(&stderr).unwrap();
|
||||
assert_eq!(
|
||||
record,
|
||||
serde_json::json!({"level": "error", "message": fault.render(&sources), "fields": {}})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_text_numeric_and_bytes_results_agree() {
|
||||
compare(
|
||||
r#"decode(value: dever.bytes.DecodeResult.Decoded(text)) (answer: Text) recover("test observes decode failures in its assertions") { answer = text }
|
||||
decode(value: other) (answer: Text) recover("test observes decode failures in its assertions") { answer = "invalid" }
|
||||
buffer(value: dever.bytes.BufferResult.Ready(bytes)) (answer: Bytes) recover("test observes buffer failures in its assertions") { answer = bytes }
|
||||
buffer(value: other) (answer: Bytes) recover("test observes buffer failures in its assertions") { answer = dever.bytes.from_text("") }
|
||||
public main() (text_value: Text, unicode: Text?, slice: Text?, index: Int?, predicates: Bool, integer: Int?, decimal_value: Decimal?, float_value: Float?, invalid: Int?, numbers: Text, bytes: Text, outside: Int?, bad: Text) {
|
||||
text_value = text.join(text.split(text.upper("a,b"), ","), "-")
|
||||
unicode = text.at("中文", 1)
|
||||
slice = text.slice("中文a", 1, 3)
|
||||
index = text.index_of("中文a", "a")
|
||||
predicates = text.contains("abc", "b") and text.starts_with("abc", "a") and text.ends_with("abc", "c") and text.replace("abc", "b", "x") == "axc"
|
||||
integer = int.parse("-12")
|
||||
decimal_value = decimal.parse("1.25")
|
||||
float_value = float.parse("1.5")
|
||||
invalid = int.parse("no")
|
||||
numbers = int.to_text(4) + decimal.to_text(decimal.from_int(2)) + float.to_text(1.5e0)
|
||||
combined = dever.bytes.concat(dever.bytes.from_text("ab"), dever.bytes.from_text("cd"))
|
||||
bytes = decode(result(dever.bytes.to_text(buffer(result(dever.bytes.slice(combined, 1, 3))))))
|
||||
outside = dever.bytes.at(combined, -1)
|
||||
bad = decode(result(dever.bytes.to_text(buffer(result(dever.bytes.from_ints([255]))))))
|
||||
}
|
||||
"#,
|
||||
"text_value = A-B\nunicode = 文\nslice = 文a\nindex = 2\npredicates = true\ninteger = -12\ndecimal_value = 1.25\nfloat_value = 1.5\ninvalid = null\nnumbers = 421.5\nbytes = bc\noutside = null\nbad = invalid\n",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_stream_aliases_early_stop_resume_and_terminal_close_agree() {
|
||||
let directory = support::temp::TemporaryDirectory::new();
|
||||
let path = directory.path().join("stream.txt");
|
||||
std::fs::write(&path, "ABC").unwrap();
|
||||
let source = r#"add(byte: Int, state: Int) (next: Int) { next = state + byte }
|
||||
collect(event: dever.io.ReadEvent.Chunk(bytes), state: Int) (next: Int) recover("test observes collect failures in its assertions") { next = reduce(add, bytes, state) }
|
||||
collect(event: dever.io.ReadEvent.Failed(message), state: Int) (next: Int) recover("test observes collect failures in its assertions") { next = state + 1000 }
|
||||
first(event: dever.io.ReadEvent, state: Int) (next: Int, stop: Bool) { next = collect(event, state)
|
||||
stop = true }
|
||||
show(event: dever.io.ReadEvent) () recover("test observes show failures in its assertions") { dever.io.println("unexpected") }
|
||||
streaming(result: dever.io.ReadStreamResult.Streaming(stream), file: dever.system.File) (answer: List<Int>) recover("test observes streaming failures in its assertions") {
|
||||
alias = stream
|
||||
prefix = reduce_until(first, stream, 0)
|
||||
suffix = reduce(collect, alias, 0)
|
||||
exhausted = reduce(collect, stream, 7)
|
||||
close(stream)
|
||||
close(alias)
|
||||
each(show, alias)
|
||||
answer = [prefix, suffix, exhausted]
|
||||
}
|
||||
streaming(result: other, file: dever.system.File) (answer: List<Int>) recover("test observes streaming failures in its assertions") { answer = [] }
|
||||
terminal(result: dever.io.ReadStreamResult.Streaming(stream), file: dever.system.File) (answer: List<Int>) recover("test observes terminal failures in its assertions") {
|
||||
dever.io.close(file)
|
||||
once = reduce(collect, stream, 0)
|
||||
again = reduce(collect, stream, 0)
|
||||
answer = [once, again]
|
||||
}
|
||||
terminal(result: other, file: dever.system.File) (answer: List<Int>) recover("test observes terminal failures in its assertions") { answer = [] }
|
||||
opened(result: dever.io.OpenResult.Opened(file)) (answer: List<Int>) recover("test observes opened failures in its assertions") { answer = streaming(result(dever.io.chunks(file, 1)), file) }
|
||||
opened(result: other) (answer: List<Int>) recover("test observes opened failures in its assertions") { answer = [] }
|
||||
closed(result: dever.io.OpenResult.Opened(file)) (answer: List<Int>) recover("test observes closed failures in its assertions") { answer = terminal(result(dever.io.chunks(file, 1)), file) }
|
||||
closed(result: other) (answer: List<Int>) recover("test observes closed failures in its assertions") { answer = [] }
|
||||
public main() (values: List<Int>, failure: List<Int>) {
|
||||
values = opened(result(dever.io.open(PATH)))
|
||||
failure = closed(result(dever.io.open(PATH)))
|
||||
}
|
||||
"#.replace("PATH", &format!("{:?}", path.to_str().unwrap()));
|
||||
compare(&source, "values = [65, 133, 7]\nfailure = [1000, 0]\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_arguments_live_fields_and_growing_reduction_state_keep_value_semantics() {
|
||||
compare(
|
||||
r#"public type Pair { left: List<Int>
|
||||
right: List<Int> }
|
||||
combine(left: List<Int>, right: List<Int>) (answer: List<Int>) {
|
||||
left = append(left, 8)
|
||||
answer = append(right, length(left))
|
||||
}
|
||||
grow(value: Int, state: List<Int>) (next: List<Int>) { next = append(state, value) }
|
||||
public main() (original: List<Int>, combined: List<Int>, old: Pair, changed: Pair, reduced: List<Int>, shared: List<Int>) {
|
||||
original = [1]
|
||||
combined = combine(original, original)
|
||||
changed = Pair { left = original
|
||||
right = original }
|
||||
old = changed
|
||||
changed.left = append(changed.right, length(changed.left))
|
||||
changed.right = changed.left
|
||||
shared = [9]
|
||||
reduced = reduce(grow, [1, 2, 3], shared)
|
||||
}
|
||||
"#,
|
||||
"original = [1]\ncombined = [1, 2]\nold = main.Pair { left = [1], right = [1] }\nchanged = main.Pair { left = [1, 1], right = [1, 1] }\nreduced = [9, 1, 2, 3]\nshared = [9]\n",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_operators_math_nullable_collections_and_key_types_agree() {
|
||||
compare(
|
||||
r#"arithmetic(a: Int, b: Int) (answer: Int) { answer = -(a - b) * b + a % b }
|
||||
decimal_math(a: Decimal, b: Decimal) (answer: Decimal) { answer = -(a - b) * b / 2 }
|
||||
floating(a: Float, b: Float) (answer: Float) { answer = -(a - b) * b + a / b }
|
||||
entry_key(entry: MapEntry<Bool, Text>) (answer: Bool) { answer = entry.key }
|
||||
absent() (answer: Int?) { answer = null }
|
||||
public main() (integer: Int, fixed: Decimal, float_value: Float, math_ok: Bool, no_nan_order: Bool, first_value: Int?, found: Int?, identity: Text, keys: List<Bool>, entry: MapEntry<Bool, Text>?) {
|
||||
integer = arithmetic(7, 3)
|
||||
fixed = decimal_math(1.5, 0.5)
|
||||
float_value = floating(3.0e0, 2.0e0)
|
||||
math_ok = math.sqrt(4.0e0) == 2.0e0 and math.sin(0.0e0) == 0.0e0 and math.cos(0.0e0) == 1.0e0 and math.log(1.0e0) == 0.0e0 and math.pow(2.0e0, 3.0e0) == 8.0e0 and math.is_infinite(1.0e0 / 0.0e0)
|
||||
nan = 0.0e0 / 0.0e0
|
||||
no_nan_order = not (nan < 1.0e0 or nan <= 1.0e0 or nan > 1.0e0 or nan >= 1.0e0) and math.is_nan(nan)
|
||||
first_value = first([])
|
||||
optional_map = { "x" = absent() }
|
||||
found = get(optional_map, "x")
|
||||
id = dever.id.from_text("stable")
|
||||
ids = { id = 7 }
|
||||
identity = dever.id.to_text(id)
|
||||
values = { true = "a"
|
||||
false = "b" }
|
||||
keys = each(entry_key, entries(values))
|
||||
entry = first(entries(values))
|
||||
}
|
||||
"#,
|
||||
"integer = -11\nfixed = -0.25\nfloat_value = -0.5\nmath_ok = true\nno_nan_order = true\nfirst_value = null\nfound = null\nidentity = stable\nkeys = [true, false]\nentry = {key = true, value = a}\n",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_failures_are_source_defined_results() {
|
||||
compare(
|
||||
r#"sleep_failed(result: dever.time.SleepResult.Failed(message)) (answer: Bool) recover("test observes sleep_failed failures in its assertions") { answer = true }
|
||||
sleep_failed(result: other) (answer: Bool) recover("test observes sleep_failed failures in its assertions") { answer = false }
|
||||
clock_ok(result: dever.time.ClockResult.Read(value)) (answer: Bool) recover("test observes clock_ok failures in its assertions") { answer = value >= 0 }
|
||||
clock_ok(result: other) (answer: Bool) recover("test observes clock_ok failures in its assertions") { answer = false }
|
||||
public main() (sleep: Bool, wall: Bool, monotonic: Bool) {
|
||||
sleep = sleep_failed(result(dever.time.sleep(-1)))
|
||||
wall = clock_ok(result(dever.time.unix_millis()))
|
||||
monotonic = clock_ok(result(dever.time.monotonic_nanos()))
|
||||
}
|
||||
"#,
|
||||
"sleep = true\nwall = true\nmonotonic = true\n",
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 960 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user