feat: release Dever 0.1.5 with direct workers and long transactions

This commit is contained in:
Codex committed 2026-10-11 11:17:33 +00:00
1 parent 89629c2197
commit a900d7e8db
153 files changed
+3676 -3708

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@ workspace = true
[dependencies]
dever-backend-bridge = { path = "../dever-backend-bridge", features = ["embedded"] }
dever-core.workspace = true
dever-sandbox = { path = "../dever-sandbox" }
dever-process = { path = "../dever-process" }
dever-runtime = { workspace = true, features = ["database"] }
serde_json.workspace = true
serde.workspace = true
+3 -2
View File
@@ -117,11 +117,11 @@ fn test_profile(program: &Program) -> RuntimeProfile {
pub(super) fn worker_compile(
program: &Program,
sources: &SourceMap,
request: &CompileRequest,
request: &mut CompileRequest,
build: &Path,
output: &Path,
) -> Result<(), String> {
let resources = request.embedded_resources();
let resources = request.take_embedded_resources();
let (profile, ir) = match request.kind {
CompileKind::Application => {
let profile = program.validate_database_bindings(
@@ -209,6 +209,7 @@ fn link_ir(
.open(&path)
.and_then(|mut file| file.write_all(&object))
.map_err(|error| format!("cannot write application object: {error}"))?;
drop(object);
let snapshot = pack.snapshot(build)?;
let inputs = snapshot
.start
+15 -14
View File
@@ -779,13 +779,13 @@ impl build::Inputs for InstalledRegistry {
}
fn assets(&self) -> Result<Vec<(String, Vec<u8>)>, String> {
self.sandbox_resources(crate::toolchain::BuildTarget::host()?)?
self.process_runtime_resources(crate::toolchain::BuildTarget::host()?)?
.into_iter()
.map(|resource| {
let path = resource
.path
.strip_prefix("sandbox/")
.ok_or("invalid sandbox resource path")?
.strip_prefix("process-runtime/")
.ok_or("invalid process_runtime resource path")?
.to_owned();
Ok((path, resource.bytes))
})
@@ -861,11 +861,11 @@ impl InstalledRegistry {
self.resources.read(name)
}
fn sandbox_resources(
fn process_runtime_resources(
&self,
target: crate::toolchain::BuildTarget,
) -> Result<Vec<dever_core::native::EmbeddedResource>, String> {
let prefix = format!("sandbox/{}/", target.platform());
let prefix = format!("process-runtime/{}/", target.platform());
let mut resources = Vec::new();
let mut files = Vec::new();
for artifact in self.resources.artifacts(&prefix)? {
@@ -875,13 +875,13 @@ impl InstalledRegistry {
let bytes = self.signed_artifact(Path::new(&artifact.path))?;
files.push((relative.to_owned(), bytes.clone()));
resources.push(dever_core::native::EmbeddedResource {
path: format!("sandbox/{relative}"),
path: format!("process-runtime/{relative}"),
sha256: artifact.sha256.clone(),
bytes,
executable: dever_sandbox::asset_executable(relative),
executable: dever_process::asset_executable(relative),
});
}
dever_sandbox::validate_assets_for_target(&files, target.platform())?;
dever_process::validate_assets_for_target(&files, target.platform())?;
Ok(resources)
}
}
@@ -1488,13 +1488,14 @@ pub fn prepare_resources(
let mut host_assets = Vec::new();
if program.has_external_adapters() {
let installed = InstalledRegistry::current(target)?;
resources.extend(installed.sandbox_resources(target)?);
resources.extend(installed.process_runtime_resources(target)?);
if program
.external_worker_contracts()
.iter()
.any(|worker| worker.ecosystem == "go")
{
host_assets = installed.sandbox_resources(crate::toolchain::BuildTarget::host()?)?;
host_assets =
installed.process_runtime_resources(crate::toolchain::BuildTarget::host()?)?;
}
}
let packaged = crate::workers::prepare_for_target(
@@ -1725,9 +1726,9 @@ pub fn doctor_with_go_verifier(lock: &LockFile, verifier: &sumdb::Verifier) -> R
.iter()
.any(|node| node.source.archive == lib.spec)
}))
&& !fixtures
&& fixtures
.package(&lib.spec)
.is_some_and(|package| package.locked() == *lib)
.is_none_or(|package| package.locked() != *lib)
{
return Err(
"npm archive requires an exact instance graph, not flat dependencies".into(),
@@ -1735,9 +1736,9 @@ pub fn doctor_with_go_verifier(lock: &LockFile, verifier: &sumdb::Verifier) -> R
}
if lib.spec.ecosystem == Ecosystem::Go
&& !go.contains(&lib.spec.name, &lib.spec.version)
&& !fixtures
&& fixtures
.package(&lib.spec)
.is_some_and(|package| package.locked() == *lib)
.is_none_or(|package| package.locked() != *lib)
{
return Err(format!(
"{} lacks authenticated Go sumdb evidence",
+24 -18
View File
@@ -1,27 +1,33 @@
// Fixed registry-package lifecycle frontend. Runs only in the build sandbox.
const fs = require('node:fs');
const path = require('node:path');
const runScript = require('/worker/npm/frontend/node_modules/npm/node_modules/@npmcli/run-script');
const binLinks = require('/worker/npm/frontend/node_modules/npm/node_modules/bin-links');
const runScript = require(path.join(__dirname, 'npm/frontend/node_modules/npm/node_modules/@npmcli/run-script'));
const binLinks = require(path.join(__dirname, 'npm/frontend/node_modules/npm/node_modules/bin-links'));
const shellQuote = value => "'" + value.replace(/'/g, "'\\''") + "'";
async function main() {
const request = JSON.parse(fs.readFileSync('/worker/request.json', 'utf8'));
const root = '/data/build/output/install';
fs.mkdirSync('/data/build/output');
fs.cpSync('/worker/install', root, { recursive: true, dereference: false, errorOnExist: true });
fs.mkdirSync('/data/build/tmp');
fs.mkdirSync('/data/build/home');
const work = fs.realpathSync(process.argv[2]);
const toolRoot = path.join(__dirname, 'rootfs');
const tools = path.join(toolRoot, 'usr/bin');
const request = JSON.parse(fs.readFileSync(path.join(__dirname, 'request.json'), 'utf8'));
const root = path.join(work, 'output/install');
fs.mkdirSync(path.join(work, 'output'));
fs.cpSync(path.join(__dirname, 'install'), root, { recursive: true, dereference: false, errorOnExist: true });
fs.mkdirSync(path.join(work, 'tmp'));
fs.mkdirSync(path.join(work, 'home'));
Object.assign(process.env, {
PATH: '/worker/runtime/bin:/usr/bin',
CC: '/usr/bin/cc', CXX: '/usr/bin/c++', AR: '/usr/bin/ar',
PATH: [path.join(__dirname, 'runtime/bin'), tools].join(path.delimiter),
CC: `${shellQuote(path.join(tools, 'cc'))} ${shellQuote('--sysroot=' + toolRoot)}`,
CXX: `${shellQuote(path.join(tools, 'c++'))} ${shellQuote('--sysroot=' + toolRoot)}`,
AR: path.join(tools, 'ar'),
PYTHON: request.python,
npm_config_python: request.python,
npm_config_nodedir: '/worker/npm',
npm_config_node_gyp: '/worker/npm/frontend/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js',
npm_config_nodedir: path.join(__dirname, 'npm'),
npm_config_node_gyp: path.join(__dirname, 'npm/frontend/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js'),
npm_config_build_from_source: 'true',
npm_config_cache: '/data/build/npm-cache',
TMPDIR: '/data/build/tmp', SOURCE_DATE_EPOCH: '0',
HOME: '/data/build/home',
npm_config_cache: path.join(work, 'npm-cache'),
TMPDIR: path.join(work, 'tmp'), SOURCE_DATE_EPOCH: '0',
HOME: path.join(work, 'home'),
});
const nodes = new Map(request.instances.map(node => [node.path, node]));
for (const node of request.instances) {
@@ -48,7 +54,7 @@ async function main() {
}
const directory = path.join(root, name);
for (const event of ['preinstall', 'install', 'postinstall']) {
await runScript({ event, path: directory, stdio: 'inherit', scriptShell: '/bin/sh' });
await runScript({ event, path: directory, stdio: 'inherit', scriptShell: path.join(tools, 'sh') });
}
const native = path.join(directory, 'build', 'Release');
if (fs.existsSync(native)) {
@@ -97,7 +103,7 @@ async function main() {
fs.rmSync(path.join(root, node.path, 'build', 'node_gyp_bins'), { recursive: true, force: true });
}
fs.rmSync(path.join(root, 'node_modules', '.bin'), { recursive: true, force: true });
fs.writeFileSync('/data/build/output/result.json', JSON.stringify({ failed: [...failed].sort(), native: [...nativeEntries].filter(file => retained.has(file.split('/build/Release/')[0])).sort() }));
fs.writeFileSync(path.join(work, 'output/result.json'), JSON.stringify({ failed: [...failed].sort(), native: [...nativeEntries].filter(file => retained.has(file.split('/build/Release/')[0])).sort() }));
}
main().catch(error => { process.stderr.write(String(error.stack || error) + '\n'); process.exitCode = 1; });
@@ -70,7 +70,7 @@ impl Environment {
descriptor.validate(runtime, Some(&python))?;
let (manifest, tools) = pack::unpack(&bytes, &descriptor)?;
let assets = inputs.assets()?;
dever_sandbox::validate_assets(&assets)?;
dever_process::validate_assets(&assets)?;
pack::validate_tools(&manifest, &tools, &assets)?;
let mut files = BTreeMap::new();
for (path, bytes) in node_files {
@@ -122,9 +122,9 @@ impl Environment {
}
for (path, bytes) in &self.assets {
stage.write(
&format!("sandbox/{path}"),
&format!("process-runtime/{path}"),
bytes,
dever_sandbox::asset_executable(path),
dever_process::asset_executable(path),
)?;
}
for (path, bytes) in files {
@@ -137,7 +137,7 @@ impl Environment {
let request = serde_json::json!({
"instances":graph.instances, "required":super::required_nodes(graph)?,
"roots":graph.roots.iter().map(|root|format!("node_modules/{}",root.name)).collect::<Vec<_>>(),
"python":format!("/worker/{}", self.python),
"python":input.join(&self.python).to_string_lossy(),
});
stage.write(
"input/request.json",
@@ -147,31 +147,20 @@ impl Environment {
stage.write("input/frontend.js", include_bytes!("../npm.js"), false)?;
let work = stage.0.join("work");
fs::create_dir(&work).map_err(|error| error.to_string())?;
let grants = [dever_sandbox::Grant {
source: work.clone(),
destination: "/data/build".into(),
writable: true,
}];
let arguments = [input.join("frontend.js").into_os_string()];
let arguments = [
input.join("frontend.js").into_os_string(),
work.clone().into_os_string(),
];
let executable = input.join(&self.node);
let assets = stage.0.join("sandbox");
let launch = dever_sandbox::Launch {
let assets = stage.0.join("process-runtime");
let launch = dever_process::Launch {
assets: &assets,
worker: &input,
executable: &executable,
arguments: &arguments,
working_directory: &input,
capabilities: dever_sandbox::Capabilities {
network: false,
process: true,
},
grants: &grants,
};
stage.run(
launch.command_for_build(&input.join("rootfs"))?,
"isolated npm lifecycle",
&work,
)?;
stage.run(launch.command()?, "managed npm lifecycle", &work)?;
inputs.diagnostic(&stage.diagnostics()?);
let mut outputs = super::super::python::output_files(&work.join("output"))?;
let response: ResultMetadata = serde_json::from_slice(
@@ -210,7 +199,7 @@ impl Environment {
rejections: BTreeMap::new(),
};
// Release the large compiler tree before staging the much smaller
// runtime-only native admission sandbox.
// runtime-only native admission process_runtime.
drop(stage);
self.admit_native(inputs, &mut output)?;
Ok(output)
@@ -244,7 +233,7 @@ impl Environment {
files.extend(
self.assets
.iter()
.map(|(path, bytes)| (format!("sandbox/{path}"), bytes.as_slice())),
.map(|(path, bytes)| (format!("process-runtime/{path}"), bytes.as_slice())),
);
files.extend(
output
@@ -254,21 +243,21 @@ impl Environment {
);
let stage = Stage::new()?;
for (path, bytes) in &files {
let relative = if path.starts_with("sandbox/") {
let relative = if path.starts_with("process-runtime/") {
path.clone()
} else {
format!("input/{path}")
};
let executable = path == &self.node
|| path
.strip_prefix("sandbox/")
.is_some_and(dever_sandbox::asset_executable);
.strip_prefix("process-runtime/")
.is_some_and(dever_process::asset_executable);
stage.write(&relative, bytes, executable)?;
}
stage.write("input/probe.cjs", include_bytes!("probe.cjs"), false)?;
let input = stage.0.join("input");
let executable = input.join(&self.node);
let assets = stage.0.join("sandbox");
let assets = stage.0.join("process-runtime");
for (path, bytes) in candidates {
let rejection = match goblin::elf::Elf::parse(bytes) {
Err(_) => Some(NativeRejection::Format),
@@ -293,14 +282,12 @@ impl Environment {
input.join("probe.cjs").into_os_string(),
input.join(path).into_os_string(),
];
let launch = dever_sandbox::Launch {
let launch = dever_process::Launch {
assets: &assets,
worker: &input,
executable: &executable,
arguments: &arguments,
working_directory: &input,
capabilities: dever_sandbox::Capabilities::default(),
grants: &[],
};
let admitted = stage.probe(launch.command()?)?;
inputs.diagnostic(&stage.diagnostics()?);
+1 -1
View File
@@ -156,7 +156,7 @@ pub fn validate_tools(
.map(|(path, bytes)| (path.clone(), bytes.as_slice()))
.collect::<BTreeMap<_, _>>();
for (path, bytes) in assets {
supplied.insert(format!("sandbox/{path}"), bytes);
supplied.insert(format!("process-runtime/{path}"), bytes);
}
crate::workers::python_wheel::native::validate_build_tools(
&manifest.target,
+48 -10
View File
@@ -51,8 +51,6 @@ impl Stage {
let path = self.0.join(relative);
fs::create_dir_all(path.parent().expect("validated relative path"))
.map_err(|error| format!("cannot create build input directory: {error}"))?;
// This tree is private and never a writable child mount. A second write
// must not silently replace a previously verified input.
use std::io::Write;
let mut file = fs::OpenOptions::new()
.write(true)
@@ -89,7 +87,7 @@ impl Stage {
pub(crate) fn probe(&self, command: Command) -> Result<bool, String> {
match self
.execute(command, "isolated Node addon probe", None)?
.execute(command, "managed Node addon probe", None)?
.code()
{
Some(0) => Ok(true),
@@ -107,8 +105,6 @@ impl Stage {
operation: &str,
writable: Option<&Path>,
) -> Result<std::process::ExitStatus, String> {
// Captures are outside every child-writable grant. Namespace teardown
// reaps descendants when the owned bootstrap is killed on any exit path.
let stdout = self.0.join("tool-stdout");
let stderr = self.0.join("tool-stderr");
let child = command
@@ -121,7 +117,7 @@ impl Stage {
))
.spawn()
.map_err(|error| format!("cannot execute {operation}: {error}"))?;
let mut child = OwnedChild(child);
let mut child = OwnedChild::new(child)?;
let deadline = Instant::now() + DEADLINE;
let mut next_scratch_check = Instant::now();
let within_budget = || -> Result<(), String> {
@@ -141,7 +137,6 @@ impl Stage {
next_scratch_check = Instant::now() + Duration::from_millis(250);
}
if let Some(status) = child
.0
.try_wait()
.map_err(|error| format!("cannot wait for {operation}: {error}"))?
{
@@ -176,11 +171,54 @@ impl Drop for Stage {
}
}
struct OwnedChild(Child);
struct OwnedChild {
child: Child,
#[cfg(target_os = "linux")]
group: Option<dever_process::ProcessGroup>,
}
impl OwnedChild {
fn new(mut child: Child) -> Result<Self, String> {
#[cfg(target_os = "linux")]
match dever_process::ProcessGroup::pin(child.id()) {
Ok(group) => Ok(Self {
child,
group: Some(group),
}),
Err(error) => {
let _ = child.kill();
let _ = child.wait();
Err(error)
}
}
#[cfg(not(target_os = "linux"))]
{
let _ = child.kill();
let _ = child.wait();
Err("managed builds require Linux".into())
}
}
fn try_wait(&mut self) -> Result<Option<std::process::ExitStatus>, String> {
#[cfg(target_os = "linux")]
if let Some(group) = &self.group {
if !group.exited()? {
return Ok(None);
}
group.terminate()?;
self.group.take();
}
self.child.try_wait().map_err(|error| error.to_string())
}
}
impl Drop for OwnedChild {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
#[cfg(target_os = "linux")]
if let Some(group) = self.group.take() {
let _ = group.terminate();
}
let _ = self.child.kill();
let _ = self.child.wait();
}
}
@@ -64,5 +64,5 @@ fn visit(path: &Path, depth: usize, budget: &mut Budget) -> Result<(), String> {
#[cfg(not(unix))]
fn visit(_path: &Path, _depth: usize, _budget: &mut Budget) -> Result<(), String> {
Err("managed build scratch accounting requires its Linux sandbox".into())
Err("managed build scratch accounting requires its Linux process_runtime".into())
}
+17 -12
View File
@@ -1,4 +1,4 @@
"""Fixed PEP 517 frontend, executed only inside the managed build sandbox."""
"""Fixed PEP 517 frontend executed with explicit build inputs."""
import importlib
import json
@@ -7,8 +7,11 @@ from pathlib import Path
import re
import shutil
import sys
import shlex
import tomllib
INPUT = Path(__file__).resolve().parent
def build_system(source):
project = source / "pyproject.toml"
@@ -82,7 +85,7 @@ def execute(request, source, output):
return {"metadata": str(prepared.relative_to(output)) if prepared else None}
prepared = None
if request.get("metadata") is not None:
shutil.copytree("/worker/prepared", output / "prepared")
shutil.copytree(INPUT / "prepared", output / "prepared")
prepared = output / "prepared" / request["metadata"]
if not prepared.resolve().is_relative_to((output / "prepared").resolve()):
raise ValueError("prepared metadata path escapes its snapshot")
@@ -93,22 +96,24 @@ def execute(request, source, output):
def main():
request = json.loads(Path("/worker/request.json").read_text("utf-8"))
work = Path("/data/build")
request = json.loads((INPUT / "request.json").read_text("utf-8"))
work = Path(sys.argv[1]).resolve(strict=True)
tool_root = INPUT / "rootfs"
tools = tool_root / "usr/bin"
source = work / "source"
shutil.copytree("/worker/source", source)
shutil.copytree(INPUT / "source", source)
output = work / "output"
output.mkdir()
(work / "tmp").mkdir()
# These values are part of the fixed frontend, never project/host settings.
os.environ.update({
"PATH": "/worker/runtime/bin:/usr/bin",
"CC": "/usr/bin/cc", "CXX": "/usr/bin/c++",
"AR": "/usr/bin/ar", "RANLIB": "/usr/bin/ranlib",
"LDSHARED": "/usr/bin/cc -shared",
"CFLAGS": "-fPIC -ffile-prefix-map=/data/build/source=.",
"CPPFLAGS": "-I/worker/runtime/include/python" + request["python_version"],
"TMPDIR": "/data/build/tmp", "SOURCE_DATE_EPOCH": "0",
"PATH": os.pathsep.join([str(INPUT / "runtime/bin"), str(tools)]),
"CC": shlex.join([str(tools / "cc"), "--sysroot=" + str(tool_root)]), "CXX": shlex.join([str(tools / "c++"), "--sysroot=" + str(tool_root)]),
"AR": str(tools / "ar"), "RANLIB": str(tools / "ranlib"),
"LDSHARED": shlex.join([str(tools / "cc"), "--sysroot=" + str(tool_root), "-shared"]),
"CFLAGS": shlex.join(["-fPIC", "-ffile-prefix-map=" + str(source) + "=."]),
"CPPFLAGS": shlex.join(["-I" + str(INPUT / "runtime/include" / ("python" + request["python_version"]))]),
"TMPDIR": str(work / "tmp"), "SOURCE_DATE_EPOCH": "0",
})
os.chdir(source)
result = execute(request, source, output)
+8 -21
View File
@@ -358,7 +358,7 @@ impl Environment {
descriptor.validate(requested, None)?;
let (tools, tool_files) = pack::unpack(&bytes, &descriptor)?;
let assets = session.inputs.assets()?;
dever_sandbox::validate_assets(&assets)?;
dever_process::validate_assets(&assets)?;
pack::validate_tools(&tools, &tool_files, &assets)?;
for (path, bytes) in tool_files {
let executable = tools.executables.contains(&path);
@@ -402,9 +402,9 @@ impl Environment {
}
for (path, bytes) in &self.assets {
stage.write(
&format!("sandbox/{path}"),
&format!("process-runtime/{path}"),
bytes,
dever_sandbox::asset_executable(path),
dever_process::asset_executable(path),
)?;
}
for (path, bytes) in &source.files {
@@ -472,7 +472,7 @@ impl Environment {
.map(|(path, (bytes, _))| (path.clone(), bytes.as_slice()))
.collect::<BTreeMap<_, _>>();
for (path, bytes) in &self.assets {
supplied.insert(format!("sandbox/{path}"), bytes);
supplied.insert(format!("process-runtime/{path}"), bytes);
}
for (path, bytes) in &installed {
supplied.insert(path.clone(), bytes);
@@ -494,35 +494,22 @@ impl Environment {
stage.write("input/frontend.py", include_bytes!("python.py"), false)?;
let work = stage.0.join("work");
fs::create_dir(&work).map_err(|error| error.to_string())?;
let grants = [dever_sandbox::Grant {
source: work.clone(),
destination: "/data/build".into(),
writable: true,
}];
let arguments = [
OsString::from("-I"),
OsString::from("-B"),
input.join("frontend.py").into_os_string(),
work.clone().into_os_string(),
];
let executable = input.join(&self.executable);
let assets = stage.0.join("sandbox");
let launch = dever_sandbox::Launch {
let assets = stage.0.join("process-runtime");
let launch = dever_process::Launch {
assets: &assets,
worker: &input,
executable: &executable,
arguments: &arguments,
working_directory: &input,
capabilities: dever_sandbox::Capabilities {
network: false,
process: true,
},
grants: &grants,
};
stage.run(
launch.command_for_build(&input.join("rootfs"))?,
"isolated Python build hook",
&work,
)?;
stage.run(launch.command()?, "isolated Python build hook", &work)?;
inputs.diagnostic(&stage.diagnostics()?);
let outputs = output_files(&work.join("output"))?;
let response = serde_json::from_slice(
+1 -1
View File
@@ -56,7 +56,7 @@ impl InstalledRegistryPack {
if self.format != "dever-registry-runtime-v1"
|| self.runtime.target != target
|| (ecosystem == &Ecosystem::Pip) != self.runtime.python_markers.is_some()
|| (ecosystem == &Ecosystem::Pip) != !self.runtime.python_wheel_tags.is_empty()
|| (ecosystem == &Ecosystem::Pip) == self.runtime.python_wheel_tags.is_empty()
{
return Err(format!(
"signed {} runtime manifest does not match this target",
+20 -25
View File
@@ -11,7 +11,6 @@ use sha2::{Digest, Sha256};
use super::release::{self, InstallLock};
use super::{Artifact, Layout, MachineManager, ReleaseManifest, Version};
mod sandbox;
mod service;
mod transaction;
pub use service::{RecoveryAction, recovery_actions};
@@ -45,11 +44,6 @@ pub fn install(installer_root: &Path) -> Result<Version, String> {
})?;
absolute_root(&settings.system_root)?;
trusted_directory(&settings.system_root)?;
if settings.system_root == Path::new("/") {
// Publishing an AppArmor file can trigger a later system-wide reload,
// even when this installer does not load profiles or activate services.
dever_sandbox::validate_host_policy()?;
}
if settings.activate_service && settings.system_root != Path::new("/") {
return Err(
"service activation requires the actual system root; image installs are offline".into(),
@@ -96,7 +90,7 @@ pub fn install(installer_root: &Path) -> Result<Version, String> {
// The private copy is checked again by the version owner before any health
// command executes. A writable download cannot race signed bytes into it.
let manifest = MachineManager::verify_package(&package, None, &key)?;
validate_payload(&package, &manifest.artifacts, true)?;
validate_payload(&package, &manifest.artifacts, true, false)?;
pin_key(&layout, &key)?;
let manager = MachineManager::new(layout.clone());
if manager
@@ -140,6 +134,7 @@ pub(crate) fn validate_payload(
root: &Path,
artifacts: &[Artifact],
current_template: bool,
previous_layout: bool,
) -> Result<(), String> {
let scoped = artifacts
.iter()
@@ -154,10 +149,20 @@ pub(crate) fn validate_payload(
})
})
.collect::<Vec<_>>();
validate_files(&root.join("bootstrap"), &scoped, current_template)
validate_files(
&root.join("bootstrap"),
&scoped,
current_template,
previous_layout,
)
}
fn validate_files(root: &Path, artifacts: &[Artifact], new_payload: bool) -> Result<(), String> {
fn validate_files(
root: &Path,
artifacts: &[Artifact],
new_payload: bool,
previous_layout: bool,
) -> Result<(), String> {
let bytes = artifacts
.iter()
.try_fold(0u64, |total, artifact| total.checked_add(artifact.bytes))
@@ -166,11 +171,12 @@ fn validate_files(root: &Path, artifacts: &[Artifact], new_payload: bool) -> Res
return Err("bootstrap closure exceeds its 64-file/64-MiB budget".into());
}
for artifact in artifacts {
if !matches!(
let supported = matches!(
artifact.path.as_str(),
"dever" | "deverd" | "deverd.service" | "dever-sandbox"
) && !artifact.path.starts_with("lib/")
{
"dever" | "deverd" | "deverd.service"
) || artifact.path.starts_with("lib/")
|| previous_layout && !new_payload && artifact.path == "dever-sandbox";
if !supported {
return Err("bootstrap has an unsupported artifact".into());
}
let path = super::runtime_pack::checked_path(root, &artifact.path, true)?;
@@ -191,17 +197,6 @@ fn validate_files(root: &Path, artifacts: &[Artifact], new_payload: bool) -> Res
"bootstrap service definition is absent or differs from the supported contract".into(),
);
}
if new_payload
&& (!artifacts
.iter()
.any(|artifact| artifact.path == "dever-sandbox")
|| read_bounded(&root.join("dever-sandbox"), 8192)?
!= dever_sandbox::APPARMOR_PROFILE.as_bytes())
{
return Err(
"bootstrap AppArmor profile is absent or differs from the supported contract".into(),
);
}
super::core_libraries::validate_entries(root, artifacts, &["dever", "deverd"])
}
@@ -245,7 +240,7 @@ fn validate_bin(root: &Path, key: &[u8]) -> Result<Vec<u8>, String> {
})
})
.collect::<Vec<_>>();
validate_files(root, &artifacts, false)?;
validate_files(root, &artifacts, false, manifest.uses_sandbox_layout())?;
let expected = artifacts
.iter()
.map(|artifact| artifact.path.as_str())
@@ -1,138 +0,0 @@
//! Preserve signed content-addressed helpers across compiler upgrades/removal.
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use super::{
MachineManager, ReleaseManifest, digest, read_bounded, release, trusted_directory, trusted_file,
};
const MAX_HELPERS: usize = 24;
const MAX_HELPER_BYTES: u64 = 32 * 1024 * 1024;
fn artifact_path() -> String {
format!(
"sandbox/{}/bin/bwrap",
crate::toolchain::platform_identity()
)
}
pub(super) fn stage(
installed: &Path,
next: &Path,
source: &Path,
manifest: &ReleaseManifest,
key: &[u8],
) -> Result<(), String> {
fs::create_dir(next).map_err(|error| error.to_string())?;
fs::set_permissions(next, fs::Permissions::from_mode(0o755))
.map_err(|error| error.to_string())?;
let mut hashes = std::collections::BTreeSet::new();
if installed.exists() {
trusted_directory(installed)?;
for entry in fs::read_dir(installed).map_err(|error| error.to_string())? {
let entry = entry.map_err(|error| error.to_string())?;
let hash = entry
.file_name()
.into_string()
.map_err(|_| "sandbox helper identity is not UTF-8")?;
if hashes.len() >= MAX_HELPERS || !valid_hash(&hash) {
return Err("installed sandbox helper store has unsupported entries or exceeds its version bound".into());
}
validate_installed(&entry.path(), &hash, key)?;
let destination = next.join(&hash);
copy_helper(&entry.path().join("bwrap"), &entry.path(), &destination)?;
hashes.insert(hash);
}
}
if let Some(artifact) = manifest
.artifacts
.iter()
.find(|artifact| artifact.path == artifact_path())
&& !hashes.contains(&artifact.sha256)
{
if hashes.len() >= MAX_HELPERS {
return Err("sandbox helper version store is full; retained applications must be retired before removing old helpers".into());
}
let bytes = read_bounded(&source.join(&artifact.path), MAX_HELPER_BYTES)?;
if bytes.len() as u64 != artifact.bytes
|| dever_sandbox::validate_bwrap(&bytes)? != artifact.sha256
{
return Err("sandbox helper differs from its signed release".into());
}
let destination = next.join(&artifact.sha256);
copy_helper(&source.join(&artifact.path), source, &destination)?;
validate_installed(&destination, &artifact.sha256, key)?;
}
release::sync_tree(next)
}
fn copy_helper(helper: &Path, signed_release: &Path, destination: &Path) -> Result<(), String> {
fs::create_dir(destination).map_err(|error| error.to_string())?;
fs::set_permissions(destination, fs::Permissions::from_mode(0o755))
.map_err(|error| error.to_string())?;
release::copy_file(helper, &destination.join("bwrap"), true)?;
for name in ["manifest.json", "manifest.sig"] {
release::copy_file(&signed_release.join(name), &destination.join(name), false)?;
}
Ok(())
}
fn valid_hash(hash: &str) -> bool {
hash.len() == 64
&& hash
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
fn validate_installed(root: &Path, hash: &str, key: &[u8]) -> Result<(), String> {
use std::os::unix::fs::MetadataExt;
trusted_directory(root)?;
let names = fs::read_dir(root)
.map_err(|error| error.to_string())?
.map(|entry| entry.map(|entry| entry.file_name()))
.collect::<Result<std::collections::BTreeSet<_>, _>>()
.map_err(|error| error.to_string())?;
let expected = ["bwrap", "manifest.json", "manifest.sig"]
.map(std::ffi::OsString::from)
.into_iter()
.collect();
if names != expected {
return Err("installed sandbox helper contains unsigned entries".into());
}
for name in ["bwrap", "manifest.json", "manifest.sig"] {
trusted_file(&root.join(name))?;
}
let helper = root.join("bwrap");
if fs::metadata(&helper)
.map_err(|error| error.to_string())?
.mode()
& 0o6111
!= 0o111
{
return Err("installed sandbox helper has unsupported executable permissions".into());
}
match rustix::fs::lgetxattr(&helper, "security.capability", &mut [0u8; 64]) {
Err(rustix::io::Errno::NODATA | rustix::io::Errno::OPNOTSUPP) => {}
Ok(_) => return Err("installed sandbox helper must not have file capabilities".into()),
Err(error) => {
return Err(format!(
"cannot inspect installed sandbox helper capabilities: {error}"
));
}
}
let manifest = MachineManager::signed_manifest(root, key)?;
let artifact = manifest
.artifacts
.iter()
.find(|artifact| artifact.path == artifact_path())
.ok_or("installed sandbox helper has no signed artifact")?;
let bytes = read_bounded(&helper, MAX_HELPER_BYTES)?;
if artifact.sha256 != hash || artifact.bytes != bytes.len() as u64 || digest(&bytes) != hash {
return Err(
"installed sandbox helper differs from its signature or directory identity".into(),
);
}
dever_sandbox::validate_bwrap(&bytes)?;
Ok(())
}
@@ -95,8 +95,8 @@ fn property(name: &str) -> Result<String, String> {
.map_err(|_| "service state output is not UTF-8")?
.trim()
.to_owned();
if !status.success()
&& !(status.code() == Some(1) && name == "LoadState" && value == "not-found")
if !(status.success()
|| status.code() == Some(1) && name == "LoadState" && value == "not-found")
{
return Err(format!("cannot inspect Dever service {name}: {status}"));
}
@@ -22,17 +22,13 @@ const MAX_TREE_BYTES: u64 = 80 * 1024 * 1024;
#[serde(rename_all = "snake_case")]
enum Target {
Bin,
Sandbox,
Apparmor,
Service,
Entry,
Enable,
Active,
}
const TARGETS: [Target; 7] = [
Target::Sandbox,
Target::Apparmor,
const TARGETS: [Target; 5] = [
Target::Bin,
Target::Service,
Target::Entry,
@@ -44,8 +40,6 @@ impl Target {
fn relative(self) -> &'static str {
match self {
Self::Bin => "opt/dever/bin",
Self::Sandbox => "opt/dever/sandbox",
Self::Apparmor => "etc/apparmor.d/dever-sandbox",
Self::Service => "etc/systemd/system/deverd.service",
Self::Entry => "usr/local/bin/dever",
Self::Enable => "etc/systemd/system/multi-user.target.wants/deverd.service",
@@ -136,16 +130,6 @@ pub(super) fn install(
"bootstrap cannot replace an unrelated public entry or service link".into(),
);
}
} else if target == Target::Apparmor && target.path(root).exists() {
trusted_file(&target.path(root))?;
let installed_profile = layout.bin().join("dever-sandbox");
if previous_service.is_none()
|| !installed_profile.exists()
|| read_bounded(&target.path(root), 8192)?
!= read_bounded(&installed_profile, 8192)?
{
return Err("bootstrap cannot replace an unrelated AppArmor profile".into());
}
} else if target == Target::Service && target.path(root).exists() {
trusted_file(&target.path(root))?;
if previous_service.as_deref()
@@ -220,18 +204,6 @@ pub(super) fn install(
}
fn stage(root: &Path, source: &Path, manifest: &ReleaseManifest, key: &[u8]) -> Result<(), String> {
super::sandbox::stage(
&Target::Sandbox.path(root),
&Target::Sandbox.next(root),
source,
manifest,
key,
)?;
write_new(
&Target::Apparmor.next(root),
dever_sandbox::APPARMOR_PROFILE.as_bytes(),
0o644,
)?;
let bin = Target::Bin.next(root);
fs::create_dir(&bin).map_err(|error| error.to_string())?;
for artifact in &manifest.artifacts {
@@ -276,9 +248,6 @@ fn stage(root: &Path, source: &Path, manifest: &ReleaseManifest, key: &[u8]) ->
}
fn publish(root: &Path, change: &Change) -> Result<(), String> {
if change.target == Target::Apparmor && root == Path::new("/") {
dever_sandbox::validate_host_policy()?;
}
let path = change.target.path(root);
let next = change.target.next(root);
if identity(&path, change.target.link())? != change.before
@@ -169,13 +169,13 @@ impl CompileRequest {
)
}
pub fn embedded_resources(&self) -> Vec<EmbeddedResource> {
pub fn take_embedded_resources(&mut self) -> Vec<EmbeddedResource> {
self.resources
.iter()
.drain(..)
.map(|resource| EmbeddedResource {
path: resource.path.clone(),
bytes: resource.bytes.clone(),
sha256: resource.sha256.clone(),
path: resource.path,
bytes: resource.bytes,
sha256: resource.sha256,
executable: resource.executable,
})
.collect()
@@ -52,7 +52,7 @@ pub(super) fn validate_entries(
entries: &[&str],
) -> Result<(), String> {
runtime_pack::host_target()?;
dever_sandbox::require_no_loader_preload()?;
dever_process::require_no_loader_preload()?;
let mut files = BTreeMap::new();
let mut total = 0u64;
for artifact in artifacts {
+8 -3
View File
@@ -55,7 +55,7 @@ impl ExtensionId {
format!("ext-{}.tar.zst", self.name())
}
fn permits(&self, path: &str) -> bool {
fn permits(&self, path: &str, process_root: &str) -> bool {
let target = self.target.platform();
match &self.kind {
ExtensionKind::Runtime(ecosystem) => {
@@ -66,7 +66,7 @@ impl ExtensionId {
}
ExtensionKind::Target => {
path.starts_with(&format!("runtime/{target}/"))
|| path.starts_with(&format!("sandbox/{target}/"))
|| path.starts_with(&format!("{process_root}/{target}/"))
}
}
}
@@ -113,6 +113,11 @@ pub fn validate_catalog(manifest: &ReleaseManifest) -> Result<(), String> {
}
let mut paths = BTreeSet::new();
validate_artifacts(&manifest.artifacts, &mut paths)?;
let process_root = if manifest.uses_sandbox_layout() {
"sandbox"
} else {
"process-runtime"
};
let mut ids = BTreeSet::new();
for extension in &manifest.extensions {
let id = extension.id();
@@ -126,7 +131,7 @@ pub fn validate_catalog(manifest: &ReleaseManifest) -> Result<(), String> {
if extension
.artifacts
.iter()
.any(|artifact| !id.permits(&artifact.path))
.any(|artifact| !id.permits(&artifact.path, process_root))
{
return Err(format!(
"extension '{}' contains a file outside its resource namespace",
+8 -8
View File
@@ -22,7 +22,7 @@ static NEXT_STAGING: AtomicU64 = AtomicU64::new(0);
mod bootstrap;
mod builds;
mod ecosystems;
mod sandbox;
mod process_runtime;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
@@ -45,7 +45,7 @@ struct Settings {
#[serde(default)]
runtimes: ecosystems::RuntimeInputs,
#[serde(default)]
sandbox: Option<Vec<LinkInput>>,
process_runtime: Option<Vec<LinkInput>>,
#[serde(default)]
builds: builds::Inputs,
#[serde(default)]
@@ -53,7 +53,7 @@ struct Settings {
#[serde(default)]
build_targets: BTreeMap<BuildTarget, builds::Inputs>,
#[serde(default)]
sandbox_targets: BTreeMap<BuildTarget, Vec<LinkInput>>,
process_runtime_targets: BTreeMap<BuildTarget, Vec<LinkInput>>,
}
#[derive(Deserialize)]
@@ -232,14 +232,14 @@ fn assemble(
});
}
}
if let Some(inputs) = settings.sandbox {
artifacts.extend(sandbox::assemble(&mut writer, inputs, host)?);
if let Some(inputs) = settings.process_runtime {
artifacts.extend(process_runtime::assemble(&mut writer, inputs, host)?);
}
for (target, inputs) in settings.sandbox_targets {
for (target, inputs) in settings.process_runtime_targets {
let selected = target_artifacts
.get_mut(&target)
.ok_or("target sandbox requires a matching native target")?;
selected.extend(sandbox::assemble(&mut writer, inputs, target)?);
.ok_or("target process_runtime requires a matching native target")?;
selected.extend(process_runtime::assemble(&mut writer, inputs, target)?);
}
for (target, artifacts) in target_artifacts {
extensions.push(Extension {
@@ -39,26 +39,17 @@ pub(super) fn assemble(
#[cfg(target_os = "linux")]
{
use sha2::{Digest, Sha256};
for (path, bytes) in [
(
"bootstrap/deverd.service",
crate::toolchain::bootstrap::SERVICE.as_bytes(),
),
(
"bootstrap/dever-sandbox",
dever_sandbox::APPARMOR_PROFILE.as_bytes(),
),
] {
let destination = writer.reserve(path)?;
std::fs::write(&destination, bytes).map_err(|error| error.to_string())?;
super::set_mode(&destination, false)?;
artifacts.push(Artifact {
path: path.into(),
bytes: bytes.len() as u64,
sha256: format!("{:x}", Sha256::digest(bytes)),
});
}
crate::toolchain::bootstrap::validate_payload(writer.staging, &artifacts, true)?;
let path = "bootstrap/deverd.service";
let bytes = crate::toolchain::bootstrap::SERVICE.as_bytes();
let destination = writer.reserve(path)?;
std::fs::write(&destination, bytes).map_err(|error| error.to_string())?;
super::set_mode(&destination, false)?;
artifacts.push(Artifact {
path: path.into(),
bytes: bytes.len() as u64,
sha256: format!("{:x}", Sha256::digest(bytes)),
});
crate::toolchain::bootstrap::validate_payload(writer.staging, &artifacts, true, false)?;
}
Ok(artifacts)
}
@@ -110,22 +110,30 @@ pub(super) fn assemble(
files.sort_by(|left, right| left.0.cmp(&right.0));
manifest.validate(&files, &descriptor)?;
let mut assets = Vec::new();
for directory in ["bin", "lib"] {
for entry in fs::read_dir(writer.staging.join(format!("sandbox/{target}/{directory}")))
.map_err(|error| format!("build pack requires prepared sandbox assets: {error}"))?
for directory in ["lib"] {
for entry in fs::read_dir(
writer
.staging
.join(format!("process-runtime/{target}/{directory}")),
)
.map_err(|error| format!("build pack requires prepared process_runtime assets: {error}"))?
{
let entry = entry.map_err(|error| error.to_string())?;
let name = entry
.file_name()
.into_string()
.map_err(|_| "sandbox asset name is not UTF-8")?;
.map_err(|_| "process_runtime asset name is not UTF-8")?;
assets.push((
format!("{directory}/{name}"),
read_bounded(&entry.path(), 32 * 1024 * 1024, "sandbox build asset")?,
read_bounded(
&entry.path(),
32 * 1024 * 1024,
"process_runtime build asset",
)?,
));
}
}
dever_sandbox::validate_assets_for_target(&assets, &target)?;
dever_process::validate_assets_for_target(&assets, &target)?;
pack::validate_tools(&manifest, &files, &assets)?;
let prefix = format!("build/{}/{target}", ecosystem.as_str());
let archive_name = format!("{prefix}/build.pack");
@@ -1,5 +1,3 @@
//! Sandbox tools and OS libraries are authenticated by the existing release.
use super::{Artifact, LinkInput, PayloadWriter, read_bounded};
use sha2::{Digest, Sha256};
@@ -9,30 +7,30 @@ pub(super) fn assemble(
target: crate::toolchain::BuildTarget,
) -> Result<Vec<Artifact>, String> {
if inputs.len() > 128 {
return Err("sandbox pack contains too many files".into());
return Err("process_runtime pack contains too many files".into());
}
let mut files = Vec::new();
let mut remaining = 32 * 1024 * 1024;
for input in inputs {
super::runtime_pack::relative_path(&input.path)?;
let source = writer.source(&input.source, &input.path, &input.sha256)?;
let bytes = read_bounded(&source, remaining, "sandbox asset")?;
let bytes = read_bounded(&source, remaining, "process_runtime asset")?;
if format!("{:x}", Sha256::digest(&bytes)) != input.sha256 {
return Err("sandbox asset changed during packaging".into());
return Err("process_runtime asset changed during packaging".into());
}
remaining -= bytes.len() as u64;
files.push((input.path, bytes));
}
files.sort_by(|left, right| left.0.cmp(&right.0));
dever_sandbox::validate_assets_for_target(&files, target.platform())?;
dever_process::validate_assets_for_target(&files, target.platform())?;
// Leaf artifacts avoid a second manifest/signature or extraction protocol.
let mut artifacts = Vec::new();
for (path, bytes) in files {
let destination = format!("sandbox/{}/{path}", target.platform());
let destination = format!("process-runtime/{}/{path}", target.platform());
let output = writer.reserve(&destination)?;
std::fs::write(&output, &bytes)
.map_err(|error| format!("cannot write sandbox asset: {error}"))?;
super::set_mode(&output, dever_sandbox::asset_executable(&path))?;
.map_err(|error| format!("cannot write process_runtime asset: {error}"))?;
super::set_mode(&output, dever_process::asset_executable(&path))?;
artifacts.push(Artifact {
path: destination,
bytes: bytes.len() as u64,
+33 -5
View File
@@ -78,6 +78,10 @@ pub struct ReleaseManifest {
}
impl ReleaseManifest {
pub(super) fn uses_sandbox_layout(&self) -> bool {
version_components(self.version.as_str()) < [0, 1, 5]
}
pub fn new(version: Version, artifacts: Vec<Artifact>) -> Self {
Self {
format: "dever-release-v2".into(),
@@ -703,7 +707,12 @@ impl MachineManager {
{
// Older installed versions retain their own signed service template.
// Only the explicit bootstrap installer applies the current template.
super::bootstrap::validate_payload(root, &manifest.artifacts, false)?;
super::bootstrap::validate_payload(
root,
&manifest.artifacts,
false,
manifest.uses_sandbox_layout(),
)?;
}
Ok(manifest)
}
@@ -947,16 +956,35 @@ pub(super) fn ensure_real_directory(path: &Path, label: &str) -> Result<(), Stri
}
fn create_private_directory(path: &Path) -> Result<(), String> {
match fs::symlink_metadata(path) {
Ok(_) => return ensure_real_directory(path, "machine directory"),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(format!("cannot inspect '{}': {error}", path.display())),
}
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
create_private_directory(parent)?;
}
let mut builder = fs::DirBuilder::new();
builder.recursive(true);
builder.recursive(false);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o755);
}
builder
.create(path)
.map_err(|error| format!("cannot create '{}': {error}", path.display()))?;
match builder.create(path) {
Ok(()) => {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o755))
.map_err(|error| format!("cannot protect '{}': {error}", path.display()))?;
}
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
Err(error) => return Err(format!("cannot create '{}': {error}", path.display())),
}
ensure_real_directory(path, "machine directory")
}
@@ -84,6 +84,7 @@ pub(super) fn serve(layout: &Layout, stream: &mut UnixStream, bytes: u64) -> Res
.map_err(|error| format!("cannot monitor compilation caller: {error}"))?;
require_connected(stream, started)?;
if let Some(artifact) = store.restore(&identity)? {
drop(compiler);
return send_output(stream, artifact.as_bytes(), started);
}
let directory = store.compilation_directory()?;
@@ -104,6 +105,7 @@ pub(super) fn serve(layout: &Layout, stream: &mut UnixStream, bytes: u64) -> Res
let artifact = store
.restore(&identity)?
.ok_or("compiled output was not published")?;
drop(compiler);
send_output(stream, artifact.as_bytes(), started)
}
@@ -131,16 +133,11 @@ fn verify_pack(
));
}
}
// This is the embedded compiler's fixed Linux LLVM dependency, not a PATH lookup.
if cfg!(target_os = "linux")
&& !compiler
.manifest
.artifacts
.iter()
.any(|artifact| artifact.path == "lib/libLLVM.so.18.1")
{
return Err("signed release is missing lib/libLLVM.so.18.1".into());
}
let core_root = compiler
.core
.parent()
.ok_or("installed compiler has no release directory")?;
super::super::core_libraries::validate(core_root, &compiler.manifest.artifacts)?;
Ok(())
}
+2 -2
View File
@@ -28,7 +28,7 @@ pub(super) fn execute() -> Result<(), String> {
.read_to_end(&mut bytes)
})
.map_err(|error| format!("cannot read compile request: {error}"))?;
let request = CompileRequest::decode(&bytes)?;
let mut request = CompileRequest::decode(&bytes)?;
drop(bytes);
if request.version != version {
return Err("compile request version does not match worker".into());
@@ -63,7 +63,7 @@ pub(super) fn execute() -> Result<(), String> {
crate::compile::worker_compile(
&program,
&sources,
&request,
&mut request,
&directory,
&directory.join(WORKER_OUTPUT_FILE),
)
+9 -33
View File
@@ -127,7 +127,13 @@ pub fn prepare_for_target(
}
for contract in workers {
if contract.ecosystem == "exec" {
output.extend(prepare_exec(&contract, &supplied, target)?);
output.extend(command::prepare(
project_root,
&contract,
&package_files,
&supplied,
target,
)?);
continue;
}
let lock = lock.as_ref().expect("managed Worker has a lock");
@@ -173,36 +179,6 @@ pub fn prepare_for_target(
Ok(output)
}
fn prepare_exec(
contract: &ExternalWorkerContract,
supplied: &BTreeMap<&str, &EmbeddedResource>,
target: crate::toolchain::BuildTarget,
) -> Result<Vec<EmbeddedResource>, String> {
let entry = supplied
.get(contract.entry.as_str())
.ok_or("exec Worker is missing from verified resources")?;
if !entry.executable || entry.bytes.is_empty() {
return Err("exec Worker entry must be a nonempty executable resource".into());
}
runtime::validate_binary(&contract.entry, &entry.bytes, target.platform())?;
let identity =
serde_json::to_vec(&contract.sdk_manifest()).map_err(|error| error.to_string())?;
let tree = format!("workers/{}", digest(&identity));
let executable = format!("{tree}/worker");
let manifest = json!({
"format": "dever-worker-launch-v1", "ecosystem": "exec", "entry": contract.entry,
"executable": executable, "arguments": [], "working_directory": tree,
});
Ok(vec![
resource(executable, entry.bytes.clone(), true),
resource(
format!("{}.dever-worker.json", contract.entry),
serde_json::to_vec(&manifest).map_err(|error| error.to_string())?,
false,
),
])
}
fn prepare_worker(
project_root: &Path,
contract: &ExternalWorkerContract,
@@ -301,7 +277,7 @@ fn prepare_worker(
.build
.as_ref()
.expect("validated Go build descriptor"),
sandbox: build_assets,
process_runtime: build_assets,
},
&mut tree_files,
target,
@@ -696,7 +672,7 @@ fn validate_worker_native(
files.extend(
supplied
.iter()
.filter(|(path, _)| path.starts_with("sandbox/lib/"))
.filter(|(path, _)| path.starts_with("process-runtime/lib/"))
.map(|(path, resource)| ((*path).to_owned(), resource.bytes.as_slice())),
);
python_wheel::validate_native(
+44 -29
View File
@@ -20,12 +20,26 @@ pub(super) fn prepare(
supplied: &BTreeMap<&str, &EmbeddedResource>,
target: BuildTarget,
) -> Result<Vec<EmbeddedResource>, String> {
let worker = contract.ecosystem == "exec";
let tree_kind = if worker { "workers" } else { "commands" };
let binary_name = if worker { "worker" } else { "tool" };
let prefix = format!(
"commands/{}",
"{tree_kind}/{}",
digest(&serde_json::to_vec(&contract.sdk_manifest()).map_err(|error| error.to_string())?)
);
let mut tree = Tree::new(prefix.clone());
let source = command_files(root, &contract.entry, package_files)?;
let entry = if worker {
let entry = supplied
.get(contract.entry.as_str())
.ok_or("exec Worker is missing from verified resources")?;
if !entry.executable || entry.bytes.is_empty() {
return Err("exec Worker entry must be a nonempty executable resource".into());
}
Some(entry.bytes.as_slice())
} else {
None
};
let source = command_files(root, &contract.entry, package_files, entry)?;
let binary = &source["program/tool"];
runtime::validate_binary(&contract.entry, binary, target.platform())?;
let machine = match target {
@@ -44,19 +58,17 @@ pub(super) fn prepare(
files.extend(
supplied
.iter()
.filter(|(path, _)| path.starts_with("sandbox/lib/"))
.filter(|(path, _)| path.starts_with("process-runtime/lib/"))
.map(|(path, entry)| ((*path).to_owned(), entry.bytes.as_slice())),
);
let closure = elf::Closure {
files: &files,
machine,
directories: vec!["program/lib".into(), "sandbox/lib".into()],
directories: vec!["program/lib".into(), "process-runtime/lib".into()],
};
for (path, bytes) in &source {
let object = elf::object(path, bytes, machine)?;
reject_loader_hooks(&object)?;
// The logical program root becomes /worker in the sandbox. A path
// escaping it could otherwise resolve a library from a file grant.
for fields in [&object.rpaths, &object.runpaths] {
for directory in elf::origin_directories(path, fields)? {
if directory != "program" && !directory.starts_with("program/") {
@@ -65,9 +77,9 @@ pub(super) fn prepare(
}
}
if let Some(name) = path.strip_prefix("program/lib/") {
if supplied.contains_key(format!("sandbox/lib/{name}").as_str()) {
if supplied.contains_key(format!("process-runtime/lib/{name}").as_str()) {
return Err(format!(
"command library '{name}' overrides a signed sandbox library"
"command library '{name}' overrides a signed process_runtime library"
));
}
if object.header.e_type != header::ET_DYN
@@ -83,38 +95,38 @@ pub(super) fn prepare(
}
for (path, bytes) in source {
let name = path.strip_prefix("program/").expect("owned command tree");
tree.add(name, bytes, name == "tool")?;
tree.add(
if name == "tool" { binary_name } else { name },
bytes,
name == "tool",
)?;
}
let (executable, arguments) = if let Some(interpreter) = interpreter {
if worker {
tree.add(
"contract.json",
serde_json::to_vec(&contract.sdk_manifest()).map_err(|error| error.to_string())?,
false,
)?;
}
if let Some(interpreter) = interpreter {
let name = interpreter
.rsplit('/')
.next()
.expect("validated GNU loader");
let loader = supplied
.get(format!("sandbox/lib/{name}").as_str())
.get(format!("process-runtime/lib/{name}").as_str())
.ok_or("command requires the signed target GNU loader")?;
runtime::validate_binary(name, &loader.bytes, target.platform())?;
tree.add("loader", loader.bytes.clone(), true)?;
(
format!("{prefix}/loader"),
vec![
json!({"literal": "--inhibit-cache"}),
json!({"literal": "--library-path"}),
json!({"literal": "lib:/lib"}),
json!({"resource": format!("{prefix}/tool")}),
],
)
} else {
(format!("{prefix}/tool"), Vec::new())
};
}
let suffix = if worker { "worker" } else { "command" };
let manifest = json!({
"format": "dever-command-launch-v1", "ecosystem": "command",
"entry": contract.entry, "executable": executable,
"arguments": arguments, "working_directory": prefix,
"format": format!("dever-{suffix}-launch-v1"), "ecosystem": contract.ecosystem,
"entry": contract.entry, "executable": format!("{prefix}/{binary_name}"),
"arguments": [], "working_directory": prefix,
});
let mut output = tree.finish();
output.push(resource(
format!("{}.dever-command.json", contract.entry),
format!("{}.dever-{suffix}.json", contract.entry),
serde_json::to_vec(&manifest).map_err(|error| error.to_string())?,
false,
));
@@ -143,6 +155,7 @@ fn command_files(
root: &Path,
entry: &str,
package_files: &BTreeMap<String, Vec<u8>>,
verified: Option<&[u8]>,
) -> Result<BTreeMap<String, Vec<u8>>, String> {
valid_path(entry)?;
let parent = entry
@@ -151,7 +164,9 @@ fn command_files(
.ok_or("command entry must belong to a domain")?;
let library_prefix = format!("{parent}/lib/");
let packaged = crate::packages::owns_worker(root, entry)?;
let binary = if packaged {
let binary = if let Some(bytes) = verified {
bytes.to_vec()
} else if packaged {
package_files
.get(entry)
.cloned()
+1 -1
View File
@@ -46,7 +46,7 @@ pub(crate) fn origin_directories(path: &str, fields: &[&str]) -> Result<Vec<Stri
}
}
let normalized = normalized.join("/");
if normalized.is_empty() || normalized.starts_with("sandbox/") {
if normalized.is_empty() || normalized.starts_with("process-runtime/") {
return Err("native library path escapes its Worker installation".into());
}
if !result.contains(&normalized) {
+14 -27
View File
@@ -43,7 +43,6 @@ struct Builder {
stage: Stage,
input: PathBuf,
assets: PathBuf,
grants: Vec<dever_sandbox::Grant>,
tools: GoBuildManifest,
entry: String,
stdlib: BTreeMap<String, PathBuf>,
@@ -55,7 +54,7 @@ struct Builder {
pub(super) struct Inputs<'a> {
pub files: &'a [(String, Vec<u8>)],
pub manifest: &'a GoBuildManifest,
pub sandbox: &'a [EmbeddedResource],
pub process_runtime: &'a [EmbeddedResource],
}
pub(super) fn compile_worker(
@@ -69,7 +68,7 @@ pub(super) fn compile_worker(
let Inputs {
files: pack_files,
manifest: build,
sandbox: build_assets,
process_runtime: build_assets,
} = inputs;
let references = build.validate_files(pack_files, target)?;
if build.host != crate::toolchain::BuildTarget::host()? {
@@ -77,24 +76,19 @@ pub(super) fn compile_worker(
}
let stage = Stage::new()?;
let input = stage.0.join("input");
let assets = stage.0.join("sandbox");
let assets = stage.0.join("process-runtime");
for resource in build_assets
.iter()
.filter(|resource| resource.path.starts_with("sandbox/"))
.filter(|resource| resource.path.starts_with("process-runtime/"))
{
if digest(&resource.bytes) != resource.sha256 {
return Err("Go build sandbox asset has a digest mismatch".into());
return Err("Go build process_runtime asset has a digest mismatch".into());
}
stage.write(&resource.path, &resource.bytes, resource.executable)?;
}
let build_directory = stage.0.join("build");
fs::create_dir(&build_directory)
.map_err(|error| format!("cannot create Go build output: {error}"))?;
let grants = vec![dever_sandbox::Grant {
source: build_directory,
destination: "/data/build".into(),
writable: true,
}];
for (path, bytes) in pack_files {
stage.write(
&format!("input/{path}"),
@@ -164,7 +158,6 @@ pub(super) fn compile_worker(
stage,
input,
assets,
grants,
tools: build.clone(),
entry: entry_name(&contract.entry)?.into(),
stdlib,
@@ -235,9 +228,7 @@ impl GoBuildManifest {
}
for tool in [&self.compiler, &self.linker, &self.analyzer] {
if !tool.starts_with("bin/")
|| !pack
.get(tool.as_str())
.is_some_and(|bytes| !bytes.is_empty())
|| pack.get(tool.as_str()).is_none_or(|bytes| bytes.is_empty())
{
return Err(format!("locked Go build tool '{tool}' is missing"));
}
@@ -259,7 +250,7 @@ impl GoBuildManifest {
.ok_or("invalid locked Go standard library importcfg")?;
valid_import(name)?;
if path != format!("stdlib/{name}.a")
|| !pack.get(path).is_some_and(|bytes| !bytes.is_empty())
|| pack.get(path).is_none_or(|bytes| bytes.is_empty())
|| stdlib.insert(name.into(), path.into()).is_some()
{
return Err(
@@ -503,34 +494,30 @@ impl Builder {
fn run(&self, executable: &Path, args: &[&str]) -> Result<Vec<u8>, String> {
let arguments = args.iter().map(OsString::from).collect::<Vec<_>>();
self.stage.run(
self.sandbox(executable, &arguments).command()?,
self.process_runtime(executable, &arguments).command()?,
"locked Go build tool",
&self.stage.0.join("build"),
)
}
fn sandbox<'a>(
fn process_runtime<'a>(
&'a self,
executable: &'a Path,
arguments: &'a [OsString],
) -> dever_sandbox::Launch<'a> {
dever_sandbox::Launch {
) -> dever_process::Launch<'a> {
dever_process::Launch {
assets: &self.assets,
worker: &self.input,
executable,
arguments,
working_directory: &self.input,
capabilities: dever_sandbox::Capabilities::default(),
grants: &self.grants,
}
}
fn visible_path(&self, path: &Path) -> Result<String, String> {
self.sandbox(&self.input, &[])
.argument_path(path)?
.into_os_string()
.into_string()
.map_err(|_| "Go sandbox path is not UTF-8".into())
path.to_str()
.map(str::to_owned)
.ok_or_else(|| "Go build path is not UTF-8".into())
}
}
+1 -1
View File
@@ -463,7 +463,7 @@ impl Wheel {
{
let newline = bytes.iter().position(|byte| *byte == b'\n').unwrap();
bytes = [
format!("#!/worker/{interpreter} -I\n").as_bytes(),
scripts::launcher(interpreter)?.as_bytes(),
&bytes[newline + 1..],
]
.concat();
@@ -5,7 +5,7 @@ use goblin::elf::header;
use std::collections::{BTreeMap, BTreeSet};
/// `files` contains Worker-relative installation files and separately verified
/// OS ABI files at sandbox/lib/*. No host directory is searched or executed.
/// OS ABI files at process-runtime/lib/*. No host directory is searched or executed.
pub fn validate_native(
target: &str,
suffixes: &[String],
@@ -36,7 +36,7 @@ pub fn validate_native(
let closure = Closure {
files,
machine,
directories: vec!["sandbox/lib".into()],
directories: vec!["process-runtime/lib".into()],
};
let interpreter_elf = object(
interpreter,
@@ -121,7 +121,7 @@ pub(crate) fn validate_build_tools(
machine,
directories: vec![
format!("rootfs/usr/lib/{multiarch}"),
"sandbox/lib".into(),
"process-runtime/lib".into(),
"rootfs/usr/lib".into(),
],
};
@@ -146,7 +146,7 @@ pub(crate) fn validate_build_tools(
}
let elf = object(path, bytes, machine)?;
if elf.interpreter.is_some_and(|value| value != interpreter) {
return Err("build tool interpreter differs from signed sandbox loader".into());
return Err("build tool interpreter differs from signed process_runtime loader".into());
}
closure.follow(path, vec![], &BTreeMap::new())?;
}
@@ -14,6 +14,23 @@ fn identifier(path: &str) -> bool {
})
}
pub(super) fn launcher(interpreter: &str) -> Result<String, String> {
let name = interpreter
.rsplit('/')
.next()
.ok_or("missing Python interpreter name")?;
if name.is_empty()
|| !name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
{
return Err("invalid Python interpreter file name".into());
}
Ok(format!(
"#!/bin/sh\n'''exec' \"${{0%/*}}/{name}\" -I \"$0\" \"$@\"\n' '''\n"
))
}
pub(super) fn entry_points(
bytes: &[u8],
prefix: &str,
@@ -82,7 +99,8 @@ pub(super) fn entry_points(
}else{value.trim()};
let (module,function)=value.split_once(':').ok_or("wheel console script must name a callable")?;
if !identifier(module) || !identifier(function) { return Err("wheel script has an invalid Python entry point".into()); }
let source=format!("#!/worker/{interpreter} -I\nimport importlib\nimport re\nimport sys\nentry = importlib.import_module({module:?})\nfor part in {function:?}.split('.'):\n entry = getattr(entry, part)\nsys.argv[0] = re.sub(r'(-script\\.pyw|\\.exe)?$', '', sys.argv[0])\nsys.exit(entry())\n");
let launcher = launcher(interpreter)?;
let source=format!("{launcher}import importlib\nimport re\nimport sys\nentry = importlib.import_module({module:?})\nfor part in {function:?}.split('.'):\n entry = getattr(entry, part)\nsys.argv[0] = re.sub(r'(-script\\.pyw|\\.exe)?$', '', sys.argv[0])\nsys.exit(entry())\n");
Ok(InstalledFile {path:format!("{prefix}/bin/{name}"),bytes:source.into_bytes(),executable:true})
}).collect()
}
+35 -44
View File
@@ -99,11 +99,13 @@ pub(super) fn validate_rest_bindings(program: &Program, errors: &mut Vec<Diagnos
ExpressionKind::Call {
target: CallTarget::Function(target),
..
} => {
if !rest_function_allowed(program, *target, &model_domain) {
errors.push(Diagnostic::error("C013", "REST field bindings call only pure same-domain Domain functions or pure standard library functions", value.span));
}
} if !rest_function_allowed(program, *target, &model_domain) => {
errors.push(Diagnostic::error("C013", "REST field bindings call only pure same-domain Domain functions or pure standard library functions", value.span));
}
ExpressionKind::Call {
target: CallTarget::Function(_),
..
} => {}
ExpressionKind::Call { .. }
| ExpressionKind::ModelOperation { .. }
| ExpressionKind::JobEnqueue { .. }
@@ -270,57 +272,44 @@ fn rest_intrinsic_allowed(operation: crate::intrinsic::Intrinsic) -> bool {
}
pub(super) fn validate_write_transactions(program: &Program, errors: &mut Vec<Diagnostic>) {
let write_routes = program
let roots = program
.api_routes
.iter()
.filter(|route| route.method != "GET")
.map(|route| route.function)
.map(|route| (route.function, route.long))
.chain(
program
.api_commands
.iter()
.map(|command| (command.function, command.long)),
)
.chain(program.jobs.iter().map(|job| (job.function, job.long)))
.collect::<std::collections::BTreeSet<_>>();
for function in &write_routes {
let root = crate::specialize::Specialization {
function: *function,
handlers: Vec::new(),
};
let password_crypto = crate::specialize::uses_intrinsic(
program,
&root,
crate::intrinsic::Intrinsic::CryptoPasswordVerify,
) || crate::specialize::uses_intrinsic(
program,
&root,
crate::intrinsic::Intrinsic::CryptoPasswordHash,
);
if password_crypto
&& let Some(span) = crate::specialize::write_outside_transaction(program, &root)
{
let mut error = Diagnostic::error(
"C014",
"password crypto API writes must be enclosed in an explicit transaction function",
program.functions[*function].span,
);
error.related.push(crate::diagnostic::Label {
span,
message: "database write occurs outside the short transaction boundary".into(),
});
errors.push(error);
}
}
let roots = write_routes
.into_iter()
.chain(program.api_commands.iter().map(|command| command.function))
.chain(program.jobs.iter().map(|job| job.function))
.collect::<std::collections::BTreeSet<_>>();
for function in roots {
for (function, long) in roots {
let root = crate::specialize::Specialization {
function,
handlers: Vec::new(),
};
if crate::specialize::writes_database(program, &root)
if long {
if let Some(span) = crate::specialize::write_outside_transaction(program, &root) {
let mut error = Diagnostic::error(
"C014",
"long entry database writes must be enclosed in an explicit transaction function",
program.functions[function].span,
);
error.related.push(crate::diagnostic::Label {
span,
message: "database write occurs outside the explicit transaction boundary"
.into(),
});
errors.push(error);
}
} else if crate::specialize::writes_database(program, &root)
&& crate::specialize::starts_transaction_unsafe_work(program, &root)
{
errors.push(Diagnostic::error(
"C014",
"an API, CMD, or Job database transaction cannot start concurrent work",
"automatic entry transactions cannot contain external I/O, Workers, waits, concurrency, or password crypto; declare the entry 'long' and enclose its writes in explicit transaction functions",
program.functions[function].span,
));
}
@@ -526,6 +515,7 @@ pub(super) fn register(
let path = format!("/{prefix}/{}", binding.action.text);
if occupy(&mut routes, (method, path.clone()), binding.span, "HTTP route", errors) {
entries.routes.push(ApiRoute { method, path, function: id, inputs,
long: binding.long,
directory: directory.clone(), anonymous: binding.anonymous,
component: component.clone(), domain: domain.clone(),
action: binding.action.text.clone(), auth: None, permission: None,
@@ -536,7 +526,7 @@ pub(super) fn register(
} else {
let name = format!("{domain_name}.{}", binding.action.text);
if occupy(&mut commands, name.clone(), binding.span, "command", errors) {
entries.commands.push(ApiCommand { name, function: id, inputs, components: Vec::new() });
entries.commands.push(ApiCommand { name, function: id, long: binding.long, inputs, components: Vec::new() });
}
}
}
@@ -981,6 +971,7 @@ fn legacy_routes(
inputs,
directory: Vec::new(),
anonymous: false,
long: false,
span: clause.span,
component: function.name.split('.').next().unwrap_or("app").to_owned(),
domain: function.name.split('.').nth(1).unwrap_or("main").to_owned(),
+14 -11
View File
@@ -113,18 +113,19 @@ pub(super) fn register(
let mut scheduled = BTreeSet::new();
for declaration in &package.declarations {
match declaration {
Declaration::Database(binding) if is_job => {
if connection
.replace(ConnectionSelector::Explicit(binding.name.text.clone()))
.is_some()
{
errors.push(Diagnostic::error(
"C002",
"duplicate Job database binding",
binding.span,
));
}
Declaration::Database(binding)
if is_job
&& connection
.replace(ConnectionSelector::Explicit(binding.name.text.clone()))
.is_some() =>
{
errors.push(Diagnostic::error(
"C002",
"duplicate Job database binding",
binding.span,
));
}
Declaration::Database(_) if is_job => {}
Declaration::Function(clause) => {
let declared = matches!(clause.kind, FunctionKind::Job { .. });
if is_job != declared {
@@ -159,6 +160,7 @@ pub(super) fn register(
let FunctionKind::Job {
attempts,
timeout_ms,
long,
} = entry.kind
else {
continue;
@@ -210,6 +212,7 @@ pub(super) fn register(
};
jobs.push(Job {
function,
long,
connection: connection.clone(),
payload,
attempts,
+12 -16
View File
@@ -525,23 +525,19 @@ fn validate_model_declarations(
let mut sql = BTreeSet::new();
for declaration in &package.declarations {
match declaration {
Declaration::Database(binding) => {
if database.replace(binding.span).is_some() {
errors.push(Diagnostic::error(
"C002",
"a Model may declare only one database binding",
binding.span,
));
}
Declaration::Database(binding) if database.replace(binding.span).is_some() => {
errors.push(Diagnostic::error(
"C002",
"a Model may declare only one database binding",
binding.span,
));
}
Declaration::Seed(value) => {
if seed.replace(value.span).is_some() {
errors.push(Diagnostic::error(
"C002",
"a Model may declare only one seed block",
value.span,
));
}
Declaration::Seed(value) if seed.replace(value.span).is_some() => {
errors.push(Diagnostic::error(
"C002",
"a Model may declare only one seed block",
value.span,
));
}
Declaration::Migration(migration) => {
if !migrations.insert(&migration.name.text) {
+1 -6
View File
@@ -548,12 +548,7 @@ pub(super) fn referenced_types(ty: &Type, visit: &mut impl FnMut(usize)) {
| Type::Stream(element)
| Type::AsyncStream(element)
| Type::RowStream(element)
| Type::Related(element)
| Type::Channel(element) => {
if !matches!(ty, Type::Related(_)) {
referenced_types(element, visit)
}
}
| Type::Channel(element) => referenced_types(element, visit),
Type::Map(key, value) | Type::MapEntry(key, value) => {
referenced_types(key, visit);
referenced_types(value, visit);
+2 -2
View File
@@ -588,7 +588,7 @@ fn validate_transactions(program: &Program, summaries: &[Effects], errors: &mut
if concurrent {
errors.push(Diagnostic::error(
"C014",
"transaction functions cannot start concurrent work",
"transaction functions cannot start concurrent work or external I/O",
function.span,
));
}
@@ -669,7 +669,7 @@ fn validate_transactions(program: &Program, summaries: &[Effects], errors: &mut
for function in concurrent {
errors.push(Diagnostic::error(
"C014",
"transaction functions cannot start concurrent work",
"transaction functions cannot start concurrent work or external I/O",
program.functions[function].span,
));
}
+7
View File
@@ -117,6 +117,9 @@ impl Formatter {
if binding.anonymous {
parts.extend([self.token("public"), Doc::space()]);
}
if binding.long {
parts.extend([self.token("long"), Doc::space()]);
}
parts.extend(vec![
self.token(binding.kind.keyword()),
Doc::space(),
@@ -377,6 +380,9 @@ impl Formatter {
parts.extend([self.token("transaction"), Doc::space()]);
}
if matches!(function.kind, FunctionKind::Job { .. }) {
if matches!(function.kind, FunctionKind::Job { long: true, .. }) {
parts.extend([self.token("long"), Doc::space()]);
}
parts.extend([self.token("job"), Doc::space()]);
}
for (index, segment) in function.name.text.split('.').enumerate() {
@@ -394,6 +400,7 @@ impl Formatter {
if let FunctionKind::Job {
attempts,
timeout_ms,
..
} = function.kind
{
for (name, value) in [("retry", attempts), ("timeout", timeout_ms)] {
+3
View File
@@ -44,6 +44,7 @@ pub(crate) struct AuthHook {
#[derive(Clone, Debug)]
pub(crate) struct Job {
pub function: usize,
pub long: bool,
pub connection: crate::model::ConnectionSelector,
pub payload: Option<crate::wire::Schema>,
pub attempts: u32,
@@ -148,6 +149,7 @@ impl TestCase {
#[derive(Clone, Debug)]
pub(crate) struct ApiRoute {
pub multipart: bool,
pub long: bool,
pub method: &'static str,
pub path: String,
pub function: usize,
@@ -166,6 +168,7 @@ pub(crate) struct ApiRoute {
#[derive(Clone, Debug)]
pub(crate) struct ApiCommand {
pub name: String,
pub long: bool,
pub function: usize,
pub inputs: Vec<String>,
pub components: Vec<String>,
+1 -7
View File
@@ -292,13 +292,7 @@ impl Module<'_> {
};
let mut emitter = FunctionEmitter::new(self, instance);
let parameters = emitter.begin_generated_function(&wrapper);
let password = [
Intrinsic::CryptoPasswordHash,
Intrinsic::CryptoPasswordVerify,
]
.iter()
.any(|operation| specialize::uses_intrinsic(emitter.module.program, &target, *operation));
let transaction = !password && emitter.begin_command_transaction(&target, route.span);
let transaction = !route.long && emitter.begin_command_transaction(&target, route.span);
let arguments = app
.parameters
.iter()
+1 -1
View File
@@ -752,7 +752,7 @@ impl FunctionEmitter<'_, '_> {
args.push((ty.clone(), value));
}
let output_ty = output_type(&function.outputs);
let transactional = self.begin_command_transaction(&instance, span);
let transactional = !command.long && self.begin_command_transaction(&instance, span);
let (status, row) = self.invoke_values(&instance, &args, &output_ty, span);
self.propagate_status(&status, span, false);
let value = self.temp();
@@ -213,6 +213,7 @@ impl FunctionEmitter<'_, '_> {
.program
.api_commands
.iter()
.filter(|command| !command.long)
.map(|command| Specialization {
function: command.function,
handlers: Vec::new(),
@@ -224,7 +225,7 @@ impl FunctionEmitter<'_, '_> {
.program
.api_routes
.iter()
.filter(|route| matches!(route.method, "POST" | "PUT" | "DELETE"))
.filter(|route| !route.long && matches!(route.method, "POST" | "PUT" | "DELETE"))
.map(|route| Specialization {
function: route.function,
handlers: Vec::new(),
@@ -248,6 +249,7 @@ impl FunctionEmitter<'_, '_> {
.program
.jobs
.iter()
.filter(|job| !job.long)
.map(|job| Specialization {
function: job.function,
handlers: Vec::new(),
+2 -1
View File
@@ -188,7 +188,8 @@ impl Module<'_> {
)
})
.collect::<Vec<_>>();
let transaction = emitter.begin_command_transaction(&target, function.span);
let transaction = !emitter.module.program.jobs[index].long
&& emitter.begin_command_transaction(&target, function.span);
if specialize::suspends(emitter.module.program, &target) {
let (status, _) =
emitter.invoke_values(&target, &arguments, &Type::Unit, function.span);
+15 -2
View File
@@ -699,9 +699,22 @@ fn emit_database_setup(
for function in program
.api_routes
.iter()
.filter(|route| !route.long)
.map(|route| route.function)
.chain(program.api_commands.iter().map(|command| command.function))
.chain(program.jobs.iter().map(|job| job.function))
.chain(
program
.api_commands
.iter()
.filter(|command| !command.long)
.map(|command| command.function),
)
.chain(
program
.jobs
.iter()
.filter(|job| !job.long)
.map(|job| job.function),
)
{
if function_writes_database(program, function) {
transaction_roots.insert(Specialization {
+4 -11
View File
@@ -352,16 +352,7 @@ fn emit_handler(
function: route.function,
handlers: Vec::new(),
};
let password_crypto = specialize::uses_intrinsic(
program,
&target,
crate::intrinsic::Intrinsic::CryptoPasswordVerify,
) || specialize::uses_intrinsic(
program,
&target,
crate::intrinsic::Intrinsic::CryptoPasswordHash,
);
let transaction_database = (!password_crypto)
let transaction_database = (!route.long)
.then(|| super::transaction_database(program, route.function))
.flatten();
if let Some(database) = &transaction_database {
@@ -478,7 +469,9 @@ pub(super) fn emit_commands(program: &Program, output: &mut String) {
function: command.function,
handlers: Vec::new(),
};
let transaction_database = super::transaction_database(program, command.function);
let transaction_database = (!command.long)
.then(|| super::transaction_database(program, command.function))
.flatten();
if let Some(database) = &transaction_database {
writeln!(output, "let transaction_database = ({database}).map_err(|error| AppError::database(\"command\", error))?;\nlet transaction = transaction_database.begin().await.map_err(|error| AppError::database(\"command\", error))?;")
.expect("string formatting");
+3 -1
View File
@@ -121,7 +121,9 @@ pub(super) fn definitions(
function: *target,
handlers: Vec::new(),
};
let transaction_database = super::transaction_database(emitter.program, *target);
let transaction_database = (!job.long)
.then(|| super::transaction_database(emitter.program, *target))
.flatten();
if let Some(database) = &transaction_database {
writeln!(output, "let transaction_database = match {database} {{ Ok(database) => database, Err(_) => return Outcome::Retry }};\nlet transaction = match transaction_database.begin().await {{ Ok(value) => value, Err(_) => return Outcome::Retry }};")
.expect("string formatting");
+31 -3
View File
@@ -195,6 +195,30 @@ impl Parser {
let mut errors = Vec::new();
while !self.at(&Kind::End) {
let visibility = self.take(&Kind::Public).map(|token| token.span);
let long = if self.at_name("long")
&& self
.tokens
.get(self.cursor + 1)
.is_some_and(|token| token.kind != Kind::LeftParen)
{
Some(self.advance().span)
} else {
None
};
let long_entry = match layout.role() {
Some(crate::source::SourceRole::Api) => ["get", "post", "put", "delete", "cmd"]
.iter()
.any(|name| self.at_name(name)),
Some(crate::source::SourceRole::Job) => self.at_name("job"),
_ => false,
};
if let Some(span) = long.filter(|_| !long_entry) {
errors.push(Diagnostic::error(
"P001",
"'long' is allowed only before an HTTP, CMD, or Job entry; place 'public' before 'long'",
span,
));
}
let anonymous_http = layout.role() == Some(crate::source::SourceRole::Api)
&& ["get", "post", "put", "delete"]
.iter()
@@ -264,8 +288,9 @@ impl Parser {
FunctionKind::Job {
attempts: 0,
timeout_ms: 0,
long: long.is_some(),
},
Some(start),
Some(long.unwrap_or(start)),
)
.map(Declaration::Function)
} else if self.at_name("transaction") {
@@ -280,7 +305,8 @@ impl Parser {
Ok(mut declaration) => {
if let Declaration::Api(binding) = &mut declaration {
binding.anonymous = visibility.is_some() && anonymous_http;
if let Some(start) = visibility {
binding.long = long.is_some();
if let Some(start) = visibility.or(long) {
binding.span = start.through(binding.span);
}
}
@@ -409,6 +435,7 @@ impl Parser {
self.line_end(&Kind::End)?;
Ok(Declaration::Api(ApiBinding {
anonymous: false,
long: false,
kind,
action,
target,
@@ -925,13 +952,14 @@ impl Parser {
let inputs = self.separated(Kind::RightParen, Self::input)?;
self.expect(&Kind::LeftParen, "the named output list '('")?;
let outputs = self.separated(Kind::RightParen, Self::field)?;
if matches!(kind, FunctionKind::Job { .. }) {
if let FunctionKind::Job { long, .. } = kind {
let attempts = self.job_policy("retry", 100)?;
let timeout_ms =
self.job_policy("timeout", dever_runtime::config::MAX_JOB_TIMEOUT_MS)?;
kind = FunctionKind::Job {
attempts,
timeout_ms,
long,
};
}
let (pure, recovery) = self.function_contracts()?;
+12 -5
View File
@@ -172,9 +172,6 @@ pub(crate) fn uses_intrinsic(
})
}
/// Finds a database write reachable without first crossing an explicit
/// transaction function. Password crypto APIs use this to keep slow work out
/// of SQLite transactions while preserving a short, explicit write boundary.
pub(crate) fn write_outside_transaction(
program: &crate::hir::Program,
root: &Specialization,
@@ -237,7 +234,7 @@ pub(crate) fn function_starts_transaction_unsafe_work(
program: &crate::hir::Program,
function: usize,
) -> bool {
let mut unsafe_work = false;
let mut unsafe_work = program.functions[function].external.is_some();
crate::check::visit(&program.functions[function], |expression| {
unsafe_work |= match &expression.kind {
ExpressionKind::RunCall { .. }
@@ -259,7 +256,17 @@ pub(crate) fn function_starts_transaction_unsafe_work(
}
ExpressionKind::Intrinsic { operation, .. } => {
operation.is_async()
&& !matches!(operation, crate::intrinsic::Intrinsic::UploadStore)
|| matches!(
operation,
crate::intrinsic::Intrinsic::CryptoPasswordHash
| crate::intrinsic::Intrinsic::CryptoPasswordVerify
| crate::intrinsic::Intrinsic::FileOpen
| crate::intrinsic::Intrinsic::TimeSleep
| crate::intrinsic::Intrinsic::FileCreate
| crate::intrinsic::Intrinsic::FileRead
| crate::intrinsic::Intrinsic::FileWrite
| crate::intrinsic::Intrinsic::FileChunks
)
}
ExpressionKind::Collection {
operation,
+6 -1
View File
@@ -106,6 +106,7 @@ impl ApiKind {
#[derive(Clone, Debug, PartialEq)]
pub struct ApiBinding {
pub anonymous: bool,
pub long: bool,
pub kind: ApiKind,
pub action: Name,
pub target: Path,
@@ -277,7 +278,11 @@ pub struct FunctionClause {
pub enum FunctionKind {
Ordinary,
Transaction,
Job { attempts: u32, timeout_ms: u32 },
Job {
attempts: u32,
timeout_ms: u32,
long: bool,
},
}
#[derive(Clone, Debug, PartialEq)]
@@ -1,5 +1,5 @@
[package]
name = "dever-sandbox"
name = "dever-process"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
@@ -9,20 +9,11 @@ publish.workspace = true
workspace = true
[dependencies]
sha2.workspace = true
goblin = { version = "=0.10.7", default-features = false, features = ["elf32", "elf64", "endian_fd", "std"] }
[target.'cfg(target_os = "linux")'.dependencies]
seccompiler = { version = "=0.5.0", default-features = false }
libc = "=0.2.189"
command-fds = "=0.3.3"
nix = { version = "=0.31.3", default-features = false }
rustix = { version = "=1.1.2", features = ["fs", "process"] }
[[bin]]
name = "dever-sandbox-guard"
path = "src/main.rs"
[[test]]
name = "isolation"
path = "../../test/dever-sandbox-tests/isolation.rs"
name = "execution"
path = "../../test/dever-process-tests/execution.rs"
@@ -5,17 +5,11 @@ use std::fs;
use std::io::Read;
use std::path::Path;
use goblin::elf::{Elf, dynamic, header, program_header};
use sha2::{Digest, Sha256};
use goblin::elf::{Elf, header};
const MAX_ASSET_BYTES: usize = 32 * 1024 * 1024;
const MAX_ASSET_FILES: usize = 128;
/// The first executable must be independent of every host loader and library.
pub fn validate_bwrap(bytes: &[u8]) -> Result<String, String> {
validate_bwrap_for_target(bytes, host_target())
}
fn host_target() -> &'static str {
if cfg!(target_arch = "aarch64") {
"linux-aarch64"
@@ -36,36 +30,10 @@ fn target_abi(target: &str) -> Result<(u16, &'static str, &'static str), String>
"ld-linux-aarch64.so.1",
"/lib/ld-linux-aarch64.so.1",
)),
_ => Err(format!("unsupported sandbox target '{target}'")),
_ => Err(format!("unsupported process_runtime target '{target}'")),
}
}
pub fn validate_bwrap_for_target(bytes: &[u8], target: &str) -> Result<String, String> {
let elf = Elf::parse(bytes).map_err(|error| format!("invalid sandbox bwrap ELF: {error}"))?;
let (machine, _, _) = target_abi(target)?;
if bytes.len() > MAX_ASSET_BYTES
|| !elf.is_64
|| !elf.little_endian
|| elf.header.e_machine != machine
|| !matches!(elf.header.e_type, header::ET_DYN | header::ET_EXEC)
|| elf
.program_headers
.iter()
.any(|segment| segment.p_type == program_header::PT_INTERP)
|| elf.dynamic.as_ref().is_some_and(|table| {
table
.dyns
.iter()
.any(|entry| entry.d_tag == dynamic::DT_NEEDED)
})
|| !elf.rpaths.is_empty()
|| !elf.runpaths.is_empty()
{
return Err("sandbox bwrap must be a static target ELF without PT_INTERP, DT_NEEDED or library search paths".into());
}
Ok(format!("{:x}", Sha256::digest(bytes)))
}
/// Used both before signing a pack and when launching its verified extraction.
/// Content authentication belongs to the release/bundle owner; this validates
/// that the loader can resolve every dependency without a host-library search.
@@ -78,30 +46,24 @@ pub fn validate_assets_for_target(files: &[(String, Vec<u8>)], target: &str) ->
let total = files
.iter()
.try_fold(0usize, |total, (_, bytes)| total.checked_add(bytes.len()))
.ok_or("sandbox asset size overflow")?;
.ok_or("process_runtime asset size overflow")?;
if files.len() > MAX_ASSET_FILES || total > MAX_ASSET_BYTES {
return Err("sandbox assets exceed their file or byte budget".into());
return Err("process_runtime assets exceed their file or byte budget".into());
}
let mut supplied = BTreeMap::new();
for (name, bytes) in files {
let valid = matches!(name.as_str(), "bin/bwrap" | "bin/guard")
|| name.strip_prefix("lib/").is_some_and(|name| {
!name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
});
let valid = name.strip_prefix("lib/").is_some_and(|name| {
!name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
});
if !valid || supplied.insert(name.as_str(), bytes.as_slice()).is_some() {
return Err("sandbox asset has an invalid or repeated path".into());
return Err("process_runtime asset has an invalid or repeated path".into());
}
}
let loader = format!("lib/{loader_name}");
for required in ["bin/bwrap", "bin/guard", &loader] {
if !supplied.contains_key(required) {
return Err(format!("sandbox asset '{required}' is missing"));
}
if !supplied.contains_key(loader.as_str()) {
return Err(format!("process_runtime asset '{loader}' is missing"));
}
validate_bwrap_for_target(supplied["bin/bwrap"], target)?;
let mut reached = BTreeSet::new();
// The shared OS ABI set also serves packaged language runtimes. Validate
// every supplied library even when the two bootstrap helpers do not use it.
let mut remaining = supplied
.keys()
.map(|name| (*name).to_owned())
@@ -112,29 +74,33 @@ pub fn validate_assets_for_target(files: &[(String, Vec<u8>)], target: &str) ->
}
let bytes = supplied
.get(name.as_str())
.ok_or_else(|| format!("sandbox dependency '{name}' is missing"))?;
let elf =
Elf::parse(bytes).map_err(|error| format!("invalid sandbox ELF asset: {error}"))?;
.ok_or_else(|| format!("process_runtime dependency '{name}' is missing"))?;
let elf = Elf::parse(bytes)
.map_err(|error| format!("invalid process_runtime ELF asset: {error}"))?;
if !elf.is_64
|| !elf.little_endian
|| elf.header.e_machine != machine
|| !matches!(elf.header.e_type, header::ET_DYN | header::ET_EXEC)
{
return Err("sandbox ELF asset has an incompatible target or object type".into());
return Err(
"process_runtime ELF asset has an incompatible target or object type".into(),
);
}
// Reject paths that can outrank the explicit bootstrap library path.
if !elf.rpaths.is_empty() || !elf.runpaths.is_empty() {
return Err("sandbox bootstrap assets must not declare RPATH or RUNPATH".into());
return Err(
"process_runtime bootstrap assets must not declare RPATH or RUNPATH".into(),
);
}
if let Some(actual) = elf.interpreter
&& actual != interpreter
{
return Err("sandbox ELF interpreter differs from the packaged loader".into());
return Err("process_runtime ELF interpreter differs from the packaged loader".into());
}
for library in elf.libraries {
if library.is_empty() || library.contains(['/', '\\']) || matches!(library, "." | "..")
{
return Err("sandbox dependency is not a plain library name".into());
return Err("process_runtime dependency is not a plain library name".into());
}
remaining.push(format!("lib/{library}"));
}
@@ -142,10 +108,10 @@ pub fn validate_assets_for_target(files: &[(String, Vec<u8>)], target: &str) ->
Ok(())
}
pub(super) fn verify_directory(root: &Path) -> Result<String, String> {
pub(super) fn verify_directory(root: &Path) -> Result<(), String> {
let mut files = Vec::new();
let mut remaining = MAX_ASSET_BYTES;
for directory in ["bin", "lib"] {
for directory in ["lib"] {
let directory_path = super::real_path(&root.join(directory))?;
for entry in fs::read_dir(directory_path).map_err(|error| error.to_string())? {
let entry = entry.map_err(|error| error.to_string())?;
@@ -156,27 +122,24 @@ pub(super) fn verify_directory(root: &Path) -> Result<String, String> {
.is_file()
|| files.len() >= MAX_ASSET_FILES
{
return Err("sandbox assets contain a non-regular file or too many files".into());
return Err(
"process_runtime assets contain a non-regular file or too many files".into(),
);
}
let mut bytes = Vec::new();
fs::File::open(path)
.and_then(|file| file.take(remaining as u64 + 1).read_to_end(&mut bytes))
.map_err(|error| format!("cannot read sandbox asset: {error}"))?;
.map_err(|error| format!("cannot read process_runtime asset: {error}"))?;
remaining = remaining
.checked_sub(bytes.len())
.ok_or("sandbox assets exceed their byte budget")?;
.ok_or("process_runtime assets exceed their byte budget")?;
let name = entry
.file_name()
.into_string()
.map_err(|_| "sandbox asset name is not UTF-8")?;
.map_err(|_| "process_runtime asset name is not UTF-8")?;
files.push((format!("{directory}/{name}"), bytes));
}
}
validate_assets(&files)?;
let bytes = &files
.iter()
.find(|(name, _)| name == "bin/bwrap")
.ok_or("sandbox bwrap is missing")?
.1;
Ok(format!("{:x}", Sha256::digest(bytes)))
Ok(())
}
+160
View File
@@ -0,0 +1,160 @@
use std::ffi::OsString;
use std::path::{Component, Path, PathBuf};
use std::process::Command;
mod assets;
pub use assets::{validate_assets, validate_assets_for_target};
pub fn asset_executable(path: &str) -> bool {
matches!(
path,
"lib/ld-linux-x86-64.so.2" | "lib/ld-linux-aarch64.so.1"
)
}
pub struct Launch<'a> {
pub assets: &'a Path,
pub worker: &'a Path,
pub executable: &'a Path,
pub arguments: &'a [OsString],
pub working_directory: &'a Path,
}
pub fn require_no_loader_preload() -> Result<(), String> {
match std::fs::symlink_metadata("/etc/ld.so.preload") {
Ok(_) => {
Err("private executable does not support host loader preload configuration".into())
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(format!(
"cannot inspect host loader preload configuration: {error}"
)),
}
}
impl Launch<'_> {
pub fn command(&self) -> Result<Command, String> {
if !cfg!(all(
target_os = "linux",
any(target_arch = "x86_64", target_arch = "aarch64")
)) {
return Err("managed external programs currently require Linux amd64 or arm64".into());
}
let assets = real_path(self.assets)?;
assets::verify_directory(&assets)?;
require_no_loader_preload()?;
let worker = real_path(self.worker)?;
let executable = real_path(self.executable)?;
let directory = real_path(self.working_directory)?;
if !executable.starts_with(&worker) || !directory.starts_with(&worker) {
return Err("managed execution path escapes its verified resource tree".into());
}
let bytes = std::fs::read(&executable).map_err(|error| error.to_string())?;
if !bytes.starts_with(b"#!/bin/sh\n") {
goblin::elf::Elf::parse(&bytes)
.map_err(|error| format!("invalid executable ELF: {error}"))?;
}
let mut paths = vec![worker.join("lib"), worker.join("runtime/lib")];
if let Some(inherited) = std::env::var_os("LD_LIBRARY_PATH") {
paths.extend(
std::env::split_paths(&inherited).filter(|path| !path.as_os_str().is_empty()),
);
}
let libraries = std::env::join_paths(paths).map_err(|error| error.to_string())?;
let mut command = Command::new(&executable);
command.args(self.arguments).current_dir(directory);
configure(&mut command);
command.env("LD_LIBRARY_PATH", libraries);
Ok(command)
}
}
pub fn configure(command: &mut Command) {
command.env_clear();
for name in [
"CUDA_VISIBLE_DEVICES",
"NVIDIA_VISIBLE_DEVICES",
"NVIDIA_DRIVER_CAPABILITIES",
"CUDA_DEVICE_ORDER",
"LD_LIBRARY_PATH",
] {
if let Some(value) = std::env::var_os(name) {
command.env(name, value);
}
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
}
}
pub fn loader_name() -> &'static str {
if cfg!(target_arch = "aarch64") {
"ld-linux-aarch64.so.1"
} else {
"ld-linux-x86-64.so.2"
}
}
fn real_path(path: &Path) -> Result<PathBuf, String> {
if !path.is_absolute()
|| path
.components()
.any(|part| !matches!(part, Component::RootDir | Component::Normal(_)))
{
return Err("execution path must be absolute and normalized".into());
}
for ancestor in path.ancestors() {
let metadata = std::fs::symlink_metadata(ancestor).map_err(|error| error.to_string())?;
if metadata.file_type().is_symlink() {
return Err("execution input must not contain symbolic links".into());
}
}
Ok(path.to_owned())
}
#[cfg(target_os = "linux")]
pub struct ProcessGroup {
pid: rustix::process::Pid,
descriptor: std::os::fd::OwnedFd,
}
#[cfg(target_os = "linux")]
impl ProcessGroup {
pub fn pin(pid: u32) -> Result<Self, String> {
use rustix::process::{Pid, PidfdFlags, pidfd_open};
let pid = i32::try_from(pid)
.ok()
.and_then(Pid::from_raw)
.ok_or("invalid child PID")?;
let descriptor =
pidfd_open(pid, PidfdFlags::NONBLOCK).map_err(|error| error.to_string())?;
Ok(Self { pid, descriptor })
}
pub fn exited(&self) -> Result<bool, String> {
use rustix::process::{WaitId, WaitIdOptions, waitid};
use std::os::fd::AsFd;
waitid(
WaitId::PidFd(self.descriptor.as_fd()),
WaitIdOptions::EXITED | WaitIdOptions::NOHANG | WaitIdOptions::NOWAIT,
)
.map(|status| status.is_some())
.map_err(|error| error.to_string())
}
pub fn terminate(&self) -> Result<(), String> {
match rustix::process::kill_process_group(self.pid, rustix::process::Signal::KILL) {
Ok(()) | Err(rustix::io::Errno::SRCH) => Ok(()),
Err(error) => Err(format!("cannot stop owned process group: {error}")),
}
}
}
#[cfg(target_os = "linux")]
impl std::os::fd::AsRawFd for ProcessGroup {
fn as_raw_fd(&self) -> std::os::fd::RawFd {
std::os::fd::AsRawFd::as_raw_fd(&self.descriptor)
}
}
+2 -2
View File
@@ -13,14 +13,14 @@ default = []
auth-crypto = ["dep:sha2", "dep:hmac", "dep:subtle"]
crypto = ["auth-crypto", "dep:argon2", "dep:getrandom"]
wire = ["dep:serde", "dep:serde_json", "serde_json/raw_value", "dep:base64", "dep:getrandom"]
external = ["wire", "dep:sha2", "dep:dever-sandbox"]
external = ["wire", "dep:sha2", "dep:dever-process"]
api = ["wire", "auth-crypto"]
database = ["wire", "dep:getrandom"]
sqlite = ["database", "dep:deadpool-sqlite"]
postgres = ["database", "dep:deadpool-postgres", "dep:tokio-postgres", "dep:tokio-postgres-rustls"]
[dependencies]
dever-sandbox = { path = "../dever-sandbox", optional = true }
dever-process = { path = "../dever-process", optional = true }
dec.workspace = true
indexmap.workspace = true
tokio.workspace = true
+29 -18
View File
@@ -5,7 +5,13 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command};
#[cfg(not(feature = "external"))]
use tokio::process::Child;
use tokio::process::{ChildStderr, ChildStdin, ChildStdout, Command};
#[cfg(feature = "external")]
mod child;
#[cfg(feature = "external")]
use child::Child;
use tokio::sync::{Mutex, mpsc, oneshot};
mod command;
@@ -99,7 +105,6 @@ fn session() -> Result<Arc<Session>, String> {
current().ok_or_else(|| "external Adapter requires a Dever invocation".into())
}
/// One settings snapshot owns both Adapter selection and deployment grants.
pub fn adapter_settings() -> Result<Arc<crate::config::Settings>, String> {
#[cfg(feature = "api")]
if let Some(application) = crate::application::current() {
@@ -359,14 +364,11 @@ async fn supervise(
let mut command = worker_command(&definition)?;
command
.arg("--dever-component")
.env_clear()
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
let mut child = command
.spawn()
.map_err(|error| format!("cannot start external Adapter worker: {error}"))?;
let mut child = spawn_child(&mut command)?;
let Some(stdin) = child.stdin.take() else {
terminate_child(&mut child).await?;
return Err("external Adapter worker has no stdin".into());
@@ -395,7 +397,7 @@ fn worker_command(definition: &Definition) -> Result<Command, String> {
#[cfg(not(feature = "external"))]
{
let _ = definition;
Err("external Worker sandbox runtime feature is not enabled".into())
Err("external Worker process_runtime runtime feature is not enabled".into())
}
}
@@ -530,21 +532,30 @@ async fn wait_for_exit(child: &mut Child) -> Result<(), String> {
}
}
async fn terminate_child(child: &mut Child) -> Result<(), String> {
match child.try_wait() {
Ok(Some(_)) => return Ok(()),
Ok(None) => {}
Err(error) => return Err(format!("cannot inspect external Adapter worker: {error}")),
}
if let Err(error) = child.start_kill()
&& !matches!(child.try_wait(), Ok(Some(_)))
fn spawn_child(command: &mut Command) -> Result<Child, String> {
let child = command
.spawn()
.map_err(|error| format!("cannot start external program: {error}"))?;
#[cfg(feature = "external")]
{
return Err(format!("cannot stop external Adapter worker: {error}"));
Child::new(child)
}
#[cfg(not(feature = "external"))]
{
Ok(child)
}
}
async fn terminate_child(child: &mut Child) -> Result<(), String> {
if child.id().is_some() {
child
.start_kill()
.map_err(|error| format!("cannot stop external program: {error}"))?;
}
match tokio::time::timeout(SHUTDOWN_GRACE, child.wait()).await {
Ok(Ok(_)) => Ok(()),
Ok(Err(error)) => Err(format!("cannot wait for external Adapter worker: {error}")),
Err(_) => Err("external Adapter worker could not be reaped before its deadline".into()),
Ok(Err(error)) => Err(format!("cannot reap external program: {error}")),
Err(_) => Err("external program exceeded its cleanup deadline".into()),
}
}
@@ -0,0 +1,76 @@
use std::io;
use std::ops::{Deref, DerefMut};
use std::process::ExitStatus;
pub(super) struct Child {
child: tokio::process::Child,
#[cfg(target_os = "linux")]
group: Option<tokio::io::unix::AsyncFd<dever_process::ProcessGroup>>,
}
impl Child {
pub(super) fn new(mut child: tokio::process::Child) -> Result<Self, String> {
#[cfg(target_os = "linux")]
{
let result =
dever_process::ProcessGroup::pin(child.id().ok_or("new child has no PID")?)
.and_then(|group| {
tokio::io::unix::AsyncFd::new(group).map_err(|error| error.to_string())
});
match result {
Ok(group) => Ok(Self {
child,
group: Some(group),
}),
Err(error) => {
let _ = child.start_kill();
Err(error)
}
}
}
#[cfg(not(target_os = "linux"))]
{
let _ = child.start_kill();
Err("managed external programs currently require Linux".into())
}
}
fn stop_group(&mut self) -> io::Result<()> {
#[cfg(target_os = "linux")]
if let Some(group) = self.group.take() {
group.get_ref().terminate().map_err(io::Error::other)?;
}
Ok(())
}
pub(super) fn start_kill(&mut self) -> io::Result<()> {
self.stop_group()?;
self.child.start_kill()
}
pub(super) async fn wait(&mut self) -> io::Result<ExitStatus> {
#[cfg(target_os = "linux")]
if let Some(group) = &self.group {
let _ready = group.readable().await?;
}
self.stop_group()?;
self.child.wait().await
}
}
impl Deref for Child {
type Target = tokio::process::Child;
fn deref(&self) -> &Self::Target {
&self.child
}
}
impl DerefMut for Child {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.child
}
}
impl Drop for Child {
fn drop(&mut self) {
let _ = self.stop_group();
}
}
+17 -46
View File
@@ -1,14 +1,13 @@
//! One ordinary child per request, owned by the existing Adapter supervisor.
use super::{Definition, Reply, Request, adapter_settings, terminate_child, worker_command};
#[cfg(feature = "external")]
use super::spawn_child;
use super::{Child, Definition, Reply, Request, adapter_settings, terminate_child, worker_command};
use crate::bytes::Bytes;
use crate::config::CommandLimits;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout};
use tokio::process::{ChildStderr, ChildStdin, ChildStdout};
use tokio::sync::{mpsc, oneshot};
mod observation;
use observation::Observation;
#[cfg(feature = "external")]
const ARGUMENT_BYTES: usize = 65_536;
#[cfg(feature = "external")]
@@ -71,7 +70,6 @@ pub(super) async fn supervise(
) -> Result<(), String> {
let prepared: Result<CommandLimits, String> = (|| {
let limits = adapter_settings()?.command_limits(&definition.port)?;
// Validate resources and grants without starting the user's executable.
worker_command(&definition)?;
Ok(limits)
})();
@@ -155,31 +153,27 @@ fn input(payload: &str) -> Result<Input, String> {
}
#[cfg(feature = "external")]
fn spawn(definition: &Definition, payload: &str) -> Result<(Input, Child, Observation), String> {
fn spawn(definition: &Definition, payload: &str) -> Result<(Input, Child), String> {
let input = input(payload)?;
let (command, observation) = crate::external::observed_command(definition)?;
let observation = Observation::new(observation)?;
let command = crate::external::worker_command(definition)?;
let mut command = tokio::process::Command::from(command);
// Resource arguments were mapped by the sandbox owner. Call arguments
// are opaque values, including absolute sandbox paths and shell syntax.
// Resource arguments were mapped by the process_runtime owner. Call arguments
// are opaque values, including absolute process_runtime paths and shell syntax.
command
.args(&input.arguments)
.env_clear()
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
let child = command
.spawn()
.map_err(|error| format!("cannot start external command: {error}"))?;
let child = spawn_child(&mut command)?;
// Dropping Command closes the parent's copies of mapped pipe descriptors.
drop(command);
Ok((input, child, observation))
Ok((input, child))
}
#[cfg(not(feature = "external"))]
fn spawn(_: &Definition, _: &str) -> Result<(Input, Child, Observation), String> {
Err("external command sandbox runtime feature is not enabled".into())
fn spawn(_: &Definition, _: &str) -> Result<(Input, Child), String> {
Err("external command process_runtime runtime feature is not enabled".into())
}
async fn execute(
@@ -188,7 +182,7 @@ async fn execute(
request: &mut Request,
stopping: &mut oneshot::Receiver<()>,
) -> Execution {
let (input, mut child, mut observation) = match spawn(definition, &request.payload) {
let (input, mut child) = match spawn(definition, &request.payload) {
Ok(prepared) => prepared,
Err(error) => {
return Execution {
@@ -198,12 +192,9 @@ async fn execute(
};
}
};
let wrapper = child
.id()
.expect("newly spawned command launcher has a PID");
let pipes = (child.stdin.take(), child.stdout.take(), child.stderr.take());
let (Some(stdin), Some(stdout), Some(stderr)) = pipes else {
let cleanup_error = cleanup(&mut child, &mut observation, wrapper).await.err();
let cleanup_error = terminate_child(&mut child).await.err();
return Execution {
reply: Err(cleanup_error
.clone()
@@ -215,8 +206,7 @@ async fn execute(
let mut stopped = false;
let result = {
let completion = async {
let (_, _, (stdout, stderr), status) = tokio::try_join!(
observation.started(wrapper),
let (_, (stdout, stderr), status) = tokio::try_join!(
write_stdin(stdin, input.stdin),
capture(stdout, stderr, limits.output_limit),
async {
@@ -228,7 +218,7 @@ async fn execute(
)?;
let code = status
.code()
.ok_or("external command sandbox terminated without an exit code")?;
.ok_or("external command process_runtime terminated without an exit code")?;
output(code, stdout, stderr)
};
tokio::select! {
@@ -243,7 +233,7 @@ async fn execute(
}
}
};
let cleanup_error = cleanup(&mut child, &mut observation, wrapper).await.err();
let cleanup_error = terminate_child(&mut child).await.err();
let reply = match (result, &cleanup_error) {
(result, None) => result,
(Ok(_), Some(cleanup)) => Err(cleanup.clone()),
@@ -256,25 +246,6 @@ async fn execute(
}
}
async fn cleanup(
child: &mut Child,
observation: &mut Observation,
wrapper: u32,
) -> Result<(), String> {
let namespace = tokio::time::timeout(super::SHUTDOWN_GRACE, observation.terminate(wrapper))
.await
.unwrap_or_else(|_| Err("external command namespace exceeded its cleanup deadline".into()));
// Reap the owned launcher even if namespace observation itself failed.
// Both stages have independent finite deadlines; neither error is lost.
let launcher = terminate_child(child).await;
match (namespace, launcher) {
(Err(namespace), Err(launcher)) => {
Err(format!("{namespace}; launcher cleanup failed: {launcher}"))
}
(namespace, launcher) => namespace.and(launcher),
}
}
async fn write_stdin(mut stdin: ChildStdin, input: Option<Bytes>) -> Result<(), String> {
if let Some(input) = input {
match stdin.write_all(input.values()).await {
@@ -1,135 +0,0 @@
#[cfg(all(feature = "external", target_os = "linux"))]
use tokio::io::AsyncReadExt;
pub(super) struct Observation {
#[cfg(all(feature = "external", target_os = "linux"))]
startup: tokio::net::unix::pipe::Receiver,
#[cfg(all(feature = "external", target_os = "linux"))]
information: tokio::net::unix::pipe::Receiver,
#[cfg(all(feature = "external", target_os = "linux"))]
information_bytes: Vec<u8>,
#[cfg(all(feature = "external", target_os = "linux"))]
release: Option<std::fs::File>,
#[cfg(all(feature = "external", target_os = "linux"))]
namespace: Option<tokio::io::unix::AsyncFd<dever_sandbox::CommandNamespace>>,
}
impl Observation {
#[cfg(all(feature = "external", target_os = "linux"))]
pub(super) fn new(observation: dever_sandbox::CommandObservation) -> Result<Self, String> {
use tokio::net::unix::pipe::Receiver;
Ok(Self {
startup: Receiver::from_file(observation.startup).map_err(|error| error.to_string())?,
information: Receiver::from_file(observation.namespace)
.map_err(|error| error.to_string())?,
information_bytes: Vec::new(),
release: Some(observation.release),
namespace: None,
})
}
#[cfg(all(feature = "external", not(target_os = "linux")))]
pub(super) fn new(_: dever_sandbox::CommandObservation) -> Result<Self, String> {
Err("external commands require Linux".into())
}
#[cfg(all(feature = "external", target_os = "linux"))]
async fn pin(&mut self, wrapper: u32) -> Result<(), String> {
if self.namespace.is_some() {
return Ok(());
}
let mut buffer = [0; 1024];
loop {
let count = self
.information
.read(&mut buffer)
.await
.map_err(|error| format!("cannot read command namespace identity: {error}"))?;
if count == 0 {
break;
}
if self.information_bytes.len() + count > 4096 {
return Err("command namespace identity exceeds its byte limit".into());
}
self.information_bytes.extend_from_slice(&buffer[..count]);
}
let text = std::str::from_utf8(&self.information_bytes)
.map_err(|_| "command namespace identity is not UTF-8")?;
let node = crate::wire::parse(text)?;
let pid = node
.object()?
.get("child-pid")
.ok_or("command namespace identity is missing")?
.int()?;
let namespace = dever_sandbox::CommandNamespace::pin(
i32::try_from(pid).map_err(|_| "invalid command namespace PID")?,
wrapper,
)?;
self.namespace = Some(
tokio::io::unix::AsyncFd::new(namespace)
.map_err(|error| format!("cannot observe command namespace exit: {error}"))?,
);
Ok(())
}
pub(super) async fn started(&mut self, wrapper: u32) -> Result<(), String> {
#[cfg(all(feature = "external", target_os = "linux"))]
{
self.pin(wrapper).await?;
// EOF releases bwrap only after its namespace is pinned. Neither
// normal cancellation nor a very short process can race PID reuse.
drop(self.release.take());
let mut status = Vec::new();
(&mut self.startup)
.take(4096)
.read_to_end(&mut status)
.await
.map_err(|error| format!("cannot read command startup status: {error}"))?;
match status.as_slice() {
b"R" => Ok(()),
[] => Err("external command sandbox failed before starting its guard".into()),
status => {
let message = status
.strip_prefix(b"RE")
.or_else(|| status.strip_prefix(b"E"));
Err(match message {
Some(message) => format!(
"external command startup failed: {}",
String::from_utf8_lossy(message)
),
None => "invalid external command startup status".into(),
})
}
}
}
#[cfg(not(all(feature = "external", target_os = "linux")))]
{
let _ = wrapper;
Err("external commands require Linux".into())
}
}
pub(super) async fn terminate(&mut self, wrapper: u32) -> Result<(), String> {
#[cfg(all(feature = "external", target_os = "linux"))]
{
self.pin(wrapper).await?;
let namespace = self
.namespace
.as_ref()
.expect("namespace pinned before cleanup");
namespace.get_ref().terminate()?;
// pidfd readiness follows kernel namespace teardown, unlike pipe
// EOF, which occurs while the init is still closing its files.
let _exited = namespace
.readable()
.await
.map_err(|error| format!("cannot wait for command namespace exit: {error}"))?;
Ok(())
}
#[cfg(not(all(feature = "external", target_os = "linux")))]
{
let _ = wrapper;
Err("external commands require Linux".into())
}
}
}
+1 -72
View File
@@ -11,7 +11,7 @@ pub use command::CommandLimits;
pub use compilation::{CompilationBindings, CompilationSite, DatabaseDriver};
const DATA_DIRECTORIES: [&str; 5] = ["db", "upload", "log", "cache", "tmp"];
const ADAPTER_FIELDS: &[&str] = &["use", "setting", "files", "command"];
const ADAPTER_FIELDS: &[&str] = &["use", "setting", "command"];
static SETTINGS: OnceLock<Settings> = OnceLock::new();
pub const MAX_JOB_TIMEOUT_MS: u32 = 3_600_000;
pub const MAX_JOB_LEASE_MS: i64 = 2 * MAX_JOB_TIMEOUT_MS as i64;
@@ -764,77 +764,6 @@ impl Settings {
Ok((index, setting))
}
/// File access is a deployment grant for the selected Port, not Worker
/// business settings. Paths stay below the application and appear at /data.
#[cfg(feature = "external")]
pub fn external_grants(
&self,
identity: &str,
capabilities: &[String],
) -> Result<Vec<dever_sandbox::Grant>, String> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct FileGrant {
path: PathBuf,
#[serde(default)]
write: bool,
}
let Some(adapter) = &self.adapter else {
return Ok(Vec::new());
};
let node = crate::wire::parse(adapter.get())?;
let Some(binding) = node.object()?.get(identity) else {
return Ok(Vec::new());
};
let binding = binding.fields(ADAPTER_FIELDS)?;
let Some(files) = binding.get("files") else {
return Ok(Vec::new());
};
if !capabilities.iter().any(|capability| capability == "file") {
return Err("Adapter file grants require its checked file capability".into());
}
let files: BTreeMap<String, FileGrant> = serde_json::from_str(files.raw())
.map_err(|_| "Adapter files must map names to {path, write} grants")?;
if files.len() > 64 {
return Err("Adapter has too many file grants".into());
}
let mut grants = Vec::new();
for (name, grant) in files {
if name.is_empty()
|| name.len() > 64
|| !name
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
{
return Err("Adapter file grant names must contain lowercase letters, digits or underscores".into());
}
if grant.path.as_os_str().is_empty()
|| grant.path.is_absolute()
|| grant
.path
.components()
.any(|part| !matches!(part, Component::Normal(_)))
{
return Err("Adapter file grant path must be relative to the application".into());
}
let executable_cache = Path::new("data/cache/lib");
if grant.write
&& (grant.path.starts_with(executable_cache)
|| executable_cache.starts_with(&grant.path))
{
return Err(
"Adapter writable file grant overlaps the executable resource cache".into(),
);
}
grants.push(dever_sandbox::Grant {
source: self.executable_directory.join(grant.path),
destination: Path::new("/data").join(name),
writable: grant.write,
});
}
Ok(grants)
}
pub fn initialize_data_directories(&self) -> Result<(), String> {
for name in DATA_DIRECTORIES {
let path = self.executable_directory.join("data").join(name);
+6 -39
View File
@@ -87,59 +87,26 @@ struct LiteralArgument {
pub fn worker_command(
definition: &crate::component::Definition,
) -> Result<std::process::Command, String> {
sandbox_launch(definition, |launch| launch.command())
}
pub(crate) fn observed_command(
definition: &crate::component::Definition,
) -> Result<(std::process::Command, dever_sandbox::CommandObservation), String> {
sandbox_launch(definition, |launch| launch.observed_command())
}
fn sandbox_launch<T>(
definition: &crate::component::Definition,
build: impl FnOnce(dever_sandbox::Launch<'_>) -> Result<T, String>,
) -> Result<T, String> {
let session =
crate::component::current().ok_or("external resources require a Dever invocation")?;
let bundle = session
.resources
.get()
.ok_or("external Worker resources and sandbox were not prepared")?;
.ok_or("external Worker resources were not prepared")?;
let launch = verified_worker_launch(
&bundle.directory,
&bundle.resources,
&definition.entry,
&definition.ecosystem,
)?;
let settings = crate::component::adapter_settings()?
.external_grants(&definition.port, &definition.capabilities)?;
if definition
.capabilities
.iter()
.any(|capability| capability == "gpu")
{
return Err("GPU Worker requires a supported device isolation backend".into());
}
let arguments = launch.arguments;
build(dever_sandbox::Launch {
assets: &bundle.directory.join("sandbox"),
dever_process::Launch {
assets: &bundle.directory.join("process-runtime"),
worker: &launch.working_directory,
executable: &launch.executable,
arguments: &arguments,
arguments: &launch.arguments,
working_directory: &launch.working_directory,
capabilities: dever_sandbox::Capabilities {
network: definition
.capabilities
.iter()
.any(|capability| capability == "network"),
process: definition
.capabilities
.iter()
.any(|capability| capability == "process"),
},
grants: &settings,
})
}
.command()
}
pub fn verified_worker_launch(
-322
View File
@@ -1,322 +0,0 @@
//! Fixed allowlist for packaged Linux runtimes. Mounts own file visibility;
//! this filter owns syscall categories that could bypass that visibility.
use super::Capabilities;
use seccompiler::{
BpfProgram, SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter,
SeccompRule,
};
use std::collections::BTreeMap;
fn condition(argument: u8, op: SeccompCmpOp, value: u64) -> Result<SeccompCondition, String> {
SeccompCondition::new(argument, SeccompCmpArgLen::Qword, op, value)
.map_err(|error| error.to_string())
}
fn rule(conditions: Vec<SeccompCondition>) -> Result<SeccompRule, String> {
SeccompRule::new(conditions).map_err(|error| error.to_string())
}
fn install(
rules: BTreeMap<i64, Vec<SeccompRule>>,
default: SeccompAction,
matched: SeccompAction,
) -> Result<(), String> {
let architecture = std::env::consts::ARCH
.try_into()
.map_err(|error: seccompiler::BackendError| error.to_string())?;
let filter = SeccompFilter::new(rules, default, matched, architecture)
.map_err(|error| error.to_string())?;
let program: BpfProgram = filter
.try_into()
.map_err(|error: seccompiler::BackendError| error.to_string())?;
seccompiler::apply_filter(&program)
.map_err(|error| format!("cannot enforce Worker syscall policy: {error}"))
}
pub(super) fn apply(capabilities: Capabilities) -> Result<(), String> {
// clone3 carries flags behind a pointer. ENOSYS makes supported libc use
// clone, whose scalar flags can be checked without user-memory races.
install(
BTreeMap::from([(libc::SYS_clone3, vec![])]),
SeccompAction::Allow,
SeccompAction::Errno(libc::ENOSYS as u32),
)?;
let mut rules = BTreeMap::new();
for syscall in [
libc::SYS_read,
libc::SYS_write,
libc::SYS_readv,
libc::SYS_writev,
libc::SYS_pread64,
libc::SYS_pwrite64,
libc::SYS_preadv,
libc::SYS_pwritev,
libc::SYS_close,
libc::SYS_close_range,
libc::SYS_lseek,
libc::SYS_fcntl,
libc::SYS_dup,
libc::SYS_dup3,
libc::SYS_pipe2,
libc::SYS_openat,
libc::SYS_fstat,
libc::SYS_statx,
libc::SYS_faccessat,
libc::SYS_faccessat2,
libc::SYS_readlinkat,
// copy2/copytree inspect extended metadata on their visible source.
// These calls only read mounted files; writes remain independently
// governed by the read-only inputs and explicit writable grants.
libc::SYS_listxattr,
libc::SYS_llistxattr,
libc::SYS_flistxattr,
libc::SYS_getxattr,
libc::SYS_lgetxattr,
libc::SYS_fgetxattr,
libc::SYS_getdents64,
libc::SYS_getcwd,
libc::SYS_chdir,
libc::SYS_fchdir,
libc::SYS_mkdirat,
libc::SYS_unlinkat,
libc::SYS_renameat,
libc::SYS_renameat2,
libc::SYS_linkat,
libc::SYS_symlinkat,
libc::SYS_ftruncate,
libc::SYS_truncate,
libc::SYS_fchmod,
libc::SYS_fchmodat,
libc::SYS_fchown,
libc::SYS_fchownat,
libc::SYS_flock,
libc::SYS_fsync,
libc::SYS_fdatasync,
libc::SYS_sync_file_range,
libc::SYS_fallocate,
libc::SYS_fstatfs,
libc::SYS_statfs,
libc::SYS_umask,
libc::SYS_utimensat,
libc::SYS_sendfile,
libc::SYS_copy_file_range,
libc::SYS_mmap,
libc::SYS_mprotect,
libc::SYS_munmap,
libc::SYS_mremap,
libc::SYS_madvise,
libc::SYS_brk,
libc::SYS_msync,
libc::SYS_rt_sigaction,
libc::SYS_rt_sigprocmask,
libc::SYS_rt_sigreturn,
libc::SYS_rt_sigsuspend,
libc::SYS_rt_sigtimedwait,
libc::SYS_sigaltstack,
libc::SYS_tgkill,
libc::SYS_kill,
libc::SYS_getpid,
libc::SYS_getppid,
libc::SYS_gettid,
libc::SYS_getuid,
libc::SYS_geteuid,
libc::SYS_getgid,
libc::SYS_getegid,
libc::SYS_getgroups,
libc::SYS_getpgid,
libc::SYS_getsid,
libc::SYS_setpgid,
libc::SYS_setsid,
libc::SYS_set_tid_address,
libc::SYS_set_robust_list,
libc::SYS_get_robust_list,
libc::SYS_futex,
libc::SYS_rseq,
libc::SYS_sched_yield,
libc::SYS_sched_getaffinity,
libc::SYS_sched_getparam,
libc::SYS_sched_getscheduler,
libc::SYS_clock_gettime,
libc::SYS_clock_getres,
libc::SYS_clock_nanosleep,
libc::SYS_nanosleep,
libc::SYS_gettimeofday,
libc::SYS_times,
libc::SYS_getrusage,
libc::SYS_getrlimit,
libc::SYS_prlimit64,
libc::SYS_uname,
libc::SYS_sysinfo,
libc::SYS_getrandom,
libc::SYS_epoll_create1,
libc::SYS_epoll_ctl,
libc::SYS_epoll_pwait,
libc::SYS_epoll_pwait2,
libc::SYS_ppoll,
libc::SYS_pselect6,
libc::SYS_eventfd2,
libc::SYS_timerfd_create,
libc::SYS_timerfd_settime,
libc::SYS_timerfd_gettime,
libc::SYS_inotify_init1,
libc::SYS_inotify_add_watch,
libc::SYS_inotify_rm_watch,
libc::SYS_recvfrom,
libc::SYS_sendto,
libc::SYS_recvmsg,
libc::SYS_sendmsg,
libc::SYS_recvmmsg,
libc::SYS_sendmmsg,
libc::SYS_shutdown,
libc::SYS_getsockopt,
libc::SYS_setsockopt,
libc::SYS_getsockname,
libc::SYS_getpeername,
libc::SYS_execve,
libc::SYS_exit,
libc::SYS_exit_group,
libc::SYS_wait4,
libc::SYS_waitid,
libc::SYS_clone3,
] {
rules.insert(syscall, vec![]);
}
#[cfg(target_arch = "x86_64")]
for syscall in [
libc::SYS_arch_prctl,
libc::SYS_open,
libc::SYS_stat,
libc::SYS_lstat,
libc::SYS_newfstatat,
libc::SYS_access,
libc::SYS_readlink,
libc::SYS_pipe,
libc::SYS_dup2,
libc::SYS_poll,
libc::SYS_select,
libc::SYS_epoll_wait,
libc::SYS_epoll_create,
libc::SYS_mkdir,
libc::SYS_rmdir,
libc::SYS_unlink,
libc::SYS_rename,
libc::SYS_link,
libc::SYS_symlink,
libc::SYS_chmod,
libc::SYS_chown,
libc::SYS_time,
libc::SYS_alarm,
libc::SYS_getpgrp,
] {
rules.insert(syscall, vec![]);
}
#[cfg(target_arch = "aarch64")]
{
rules.insert(libc::SYS_newfstatat, vec![]);
}
// New namespaces and CLONE_PARENT could escape the owned PID lifecycle.
let forbidden_clone = (libc::CLONE_NEWCGROUP
| libc::CLONE_NEWIPC
| libc::CLONE_NEWNET
| libc::CLONE_NEWNS
| libc::CLONE_NEWPID
| libc::CLONE_NEWUSER
| libc::CLONE_NEWUTS
| libc::CLONE_PARENT
| libc::CLONE_UNTRACED) as u64;
let mut clone = vec![condition(0, SeccompCmpOp::MaskedEq(forbidden_clone), 0)?];
if !capabilities.process {
let required = (libc::CLONE_THREAD | libc::CLONE_VM | libc::CLONE_SIGHAND) as u64;
clone.push(condition(
0,
SeccompCmpOp::MaskedEq(required | 255),
required,
)?);
}
rules.insert(libc::SYS_clone, vec![rule(clone)?]);
if capabilities.process {
// POSIX_SPAWN_RESETIDS restores the caller's real identity before exec.
// Permit that exact reset only; real/saved IDs and group membership
// cannot change. Linux uid_t/gid_t consume the low 32 argument bits.
for (syscall, identity) in [
(libc::SYS_setresuid, rustix::process::getuid().as_raw()),
(libc::SYS_setresgid, rustix::process::getgid().as_raw()),
] {
let conditions = [u32::MAX, identity, u32::MAX]
.into_iter()
.enumerate()
.map(|(argument, value)| {
SeccompCondition::new(
argument as u8,
SeccompCmpArgLen::Dword,
SeccompCmpOp::Eq,
u64::from(value),
)
.map_err(|error| error.to_string())
})
.collect::<Result<Vec<_>, _>>()?;
rules.insert(syscall, vec![rule(conditions)?]);
}
}
#[cfg(target_arch = "x86_64")]
if capabilities.process {
rules.insert(libc::SYS_fork, vec![]);
rules.insert(libc::SYS_vfork, vec![]);
}
// Anonymous socket pairs cannot reach another process. They are needed by
// language runtime event loops even without the network capability.
rules.insert(
libc::SYS_socketpair,
vec![rule(vec![condition(
0,
SeccompCmpOp::Eq,
libc::AF_UNIX as u64,
)?])?],
);
if capabilities.network {
let mut sockets = Vec::new();
for domain in [libc::AF_INET, libc::AF_INET6] {
for kind in [libc::SOCK_STREAM, libc::SOCK_DGRAM] {
sockets.push(rule(vec![
condition(0, SeccompCmpOp::Eq, domain as u64)?,
condition(
1,
SeccompCmpOp::MaskedEq(
!(libc::SOCK_NONBLOCK | libc::SOCK_CLOEXEC) as u32 as u64,
),
kind as u64,
)?,
])?);
}
}
rules.insert(libc::SYS_socket, sockets);
for syscall in [
libc::SYS_connect,
libc::SYS_bind,
libc::SYS_listen,
libc::SYS_accept,
libc::SYS_accept4,
] {
rules.insert(syscall, vec![]);
}
}
// ioctl truncates request to unsigned int. Compare that same width so a
// caller cannot hide a forbidden request behind nonzero upper bits.
let terminal_requests = [libc::TIOCSTI, libc::TIOCLINUX]
.into_iter()
.map(|request| {
SeccompCondition::new(1, SeccompCmpArgLen::Dword, SeccompCmpOp::Ne, request)
.map_err(|error| error.to_string())
})
.collect::<Result<Vec<_>, _>>()?;
rules.insert(libc::SYS_ioctl, vec![rule(terminal_requests)?]);
// The allowlist excludes namespace, mount, ptrace/process_vm, io_uring,
// device creation, kernel administration, BPF and all x32 syscall numbers.
install(
rules,
SeccompAction::Errno(libc::EPERM as u32),
SeccompAction::Allow,
)
}
-321
View File
@@ -1,321 +0,0 @@
//! The fixed Linux execution boundary shared by Lib runtime and build owners.
//! Callers verify signed assets and own every child; this crate never discovers tools.
use std::collections::BTreeSet;
use std::ffi::OsString;
use std::path::{Component, Path, PathBuf};
use std::process::Command;
mod assets;
mod mounts;
mod observation;
mod trusted_helper;
pub use assets::{
validate_assets, validate_assets_for_target, validate_bwrap, validate_bwrap_for_target,
};
pub use observation::CommandNamespace;
pub use trusted_helper::{APPARMOR_PROFILE, TRUSTED_HELPER_ROOT, validate_host_policy};
/// Executable modes are part of the bootstrap resource contract. The private
/// loader is needed by the guard and packaged language runtimes. Authoring and
/// embedding can run on a different host; target validation belongs to assets.
pub fn asset_executable(path: &str) -> bool {
matches!(
path,
"bin/bwrap" | "bin/guard" | "lib/ld-linux-x86-64.so.2" | "lib/ld-linux-aarch64.so.1"
)
}
#[cfg(target_os = "linux")]
mod filter;
#[derive(Clone, Copy, Debug, Default)]
pub struct Capabilities {
pub network: bool,
pub process: bool,
}
/// The source is a parent-validated deployment grant, never a Worker request.
#[derive(Debug)]
pub struct Grant {
pub source: PathBuf,
pub destination: PathBuf,
pub writable: bool,
}
pub struct Launch<'a> {
pub assets: &'a Path,
pub worker: &'a Path,
pub executable: &'a Path,
pub arguments: &'a [OsString],
pub working_directory: &'a Path,
pub capabilities: Capabilities,
pub grants: &'a [Grant],
}
/// Guard exec confirmation and PID-namespace lifetime, separate from user I/O.
pub struct CommandObservation {
pub startup: std::fs::File,
pub namespace: std::fs::File,
pub release: std::fs::File,
}
/// A private ELF closure cannot honor additional host preload libraries.
/// Applies to compiler launches as well as the sandbox bootstrap.
pub fn require_no_loader_preload() -> Result<(), String> {
match std::fs::symlink_metadata("/etc/ld.so.preload") {
Ok(_) => {
Err("private executable does not support host loader preload configuration".into())
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(format!(
"cannot inspect host loader preload configuration: {error}"
)),
}
}
impl Launch<'_> {
pub fn command(&self) -> Result<Command, String> {
self.command_with_tools(None, None)
}
#[cfg(target_os = "linux")]
pub fn observed_command(&self) -> Result<(Command, CommandObservation), String> {
use std::os::fd::OwnedFd;
let (startup, startup_writer) = std::io::pipe().map_err(|error| error.to_string())?;
let (namespace, namespace_writer) = std::io::pipe().map_err(|error| error.to_string())?;
let (blocked, release) = std::io::pipe().map_err(|error| error.to_string())?;
let command = self.command_with_tools(
None,
Some([
std::fs::File::from(OwnedFd::from(startup_writer)),
std::fs::File::from(OwnedFd::from(namespace_writer)),
std::fs::File::from(OwnedFd::from(blocked)),
]),
)?;
Ok((
command,
CommandObservation {
startup: OwnedFd::from(startup).into(),
namespace: OwnedFd::from(namespace).into(),
release: OwnedFd::from(release).into(),
},
))
}
#[cfg(not(target_os = "linux"))]
pub fn observed_command(&self) -> Result<(Command, CommandObservation), String> {
Err("external command sandbox is not supported on this platform".into())
}
/// Only the explicit package-build owner may supply this verified signed
/// root. Ordinary Worker launches never acquire a compiler or /usr mount.
pub fn command_for_build(&self, tool_root: &Path) -> Result<Command, String> {
if self.capabilities.network || !self.capabilities.process {
return Err(
"package builds require isolated network and child-process capability".into(),
);
}
self.command_with_tools(Some(tool_root), None)
}
fn command_with_tools(
&self,
tool_root: Option<&Path>,
observation: Option<[std::fs::File; 3]>,
) -> Result<Command, String> {
if !cfg!(all(
target_os = "linux",
any(target_arch = "x86_64", target_arch = "aarch64")
)) {
return Err("external Worker sandbox is not supported on this platform".into());
}
let assets = real_path(self.assets)?;
let bwrap_hash = assets::verify_directory(&assets)?;
require_no_loader_preload()?;
let worker = real_path(self.worker)?;
let tool_root = tool_root.map(real_path).transpose()?;
let executable = mapped_path(&worker, self.executable)?;
let working_directory = mapped_path(&worker, self.working_directory)?;
let loader = assets.join("lib").join(loader_name());
for file in [
&loader,
&assets.join("bin/bwrap"),
&assets.join("bin/guard"),
] {
if !real_path(file)?.is_file() {
return Err("sandbox executable asset is not a regular file".into());
}
}
// A direct static exec is required for the trusted AppArmor attachment.
let bwrap = trusted_helper::executable(&assets, &bwrap_hash)?;
let mut command = Command::new(bwrap);
command.env_clear().args([
"--unshare-all",
"--die-with-parent",
"--new-session",
"--cap-drop",
"ALL",
]);
if self.capabilities.network {
command.arg("--share-net");
}
let mut mounts = mounts::Mounts::default();
let startup_fd = if let Some([startup, namespace, blocked]) = observation {
let startup = mounts.pass(startup)?;
let namespace = mounts.pass(namespace)?;
let blocked = mounts.pass(blocked)?;
command
.arg("--info-fd")
.arg(namespace.to_string())
.arg("--block-fd")
.arg(blocked.to_string());
Some(startup)
} else {
None
};
mounts.bind(&mut command, &assets, Path::new("/sandbox"), false)?;
mounts.bind(&mut command, &assets.join("lib"), Path::new("/lib"), false)?;
command.args(["--symlink", "lib", "/lib64"]);
mounts.bind(&mut command, &worker, Path::new("/worker"), false)?;
if let Some(root) = &tool_root {
mounts.bind(&mut command, &root.join("usr"), Path::new("/usr"), false)?;
command.args(["--symlink", "usr/bin", "/bin"]);
}
command.args([
"--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--dir", "/data",
]);
if self.capabilities.network {
// DNS is an OS service input, never application configuration.
// Expose only these bounded resolver files, not the host's /etc.
for name in ["resolv.conf", "hosts"] {
let source = Path::new("/etc")
.join(name)
.canonicalize()
.map_err(|error| format!("cannot resolve network resolver input: {error}"))?;
let source = real_path(&source)?;
let metadata = std::fs::metadata(&source).map_err(|error| error.to_string())?;
if !metadata.is_file() || metadata.len() > 65_536 {
return Err("network resolver input is not a bounded regular file".into());
}
mounts.bind(&mut command, &source, &Path::new("/etc").join(name), false)?;
}
}
let mut destinations = BTreeSet::new();
for grant in self.grants {
let source = real_path(&grant.source)?;
if grant.writable
&& [&assets, &worker]
.into_iter()
.chain(tool_root.iter())
.any(|protected| {
source.starts_with(protected) || protected.starts_with(&source)
})
{
return Err("sandbox writable grant overlaps executable inputs".into());
}
let destination = &grant.destination;
normalized_absolute(destination)?;
if !destination.starts_with("/data") || destination == Path::new("/data") {
return Err("sandbox file grant must have a path below /data".into());
}
if destinations.iter().any(|prior: &&Path| {
prior.starts_with(destination) || destination.starts_with(prior)
}) {
return Err("sandbox file grants overlap".into());
}
destinations.insert(destination.as_path());
mounts.bind(&mut command, &source, destination, grant.writable)?;
}
let flags =
u8::from(self.capabilities.network) | (u8::from(self.capabilities.process) << 1);
command
.arg("--chdir")
.arg(working_directory)
.args(["--", "/sandbox/bin/guard"]);
if let Some(fd) = startup_fd {
command.arg("--observe").arg(fd.to_string());
}
command.arg(flags.to_string()).arg(executable);
for argument in self.arguments {
// Only resource arguments are absolute; fixed flags stay literal.
// Other absolute arguments cannot silently expose a host pathname.
let path = Path::new(argument);
command.arg(if path.is_absolute() {
self.argument_path(path)?.into_os_string()
} else {
argument.clone()
});
}
mounts.attach(&mut command)?;
Ok(command)
}
/// Build manifests need the same names as command arguments. Output files
/// may not exist yet; their owning mount was already validated at launch.
pub fn argument_path(&self, path: &Path) -> Result<PathBuf, String> {
normalized_absolute(path)?;
for grant in self.grants {
if let Ok(relative) = path.strip_prefix(&grant.source) {
return Ok(grant.destination.join(relative));
}
}
let relative = path
.strip_prefix(self.worker)
.map_err(|_| "sandbox argument escapes its owned mounts")?;
Ok(Path::new("/worker").join(relative))
}
}
pub fn loader_name() -> &'static str {
if cfg!(target_arch = "aarch64") {
"ld-linux-aarch64.so.1"
} else {
"ld-linux-x86-64.so.2"
}
}
fn normalized_absolute(path: &Path) -> Result<(), String> {
if !path.is_absolute()
|| path
.components()
.any(|part| !matches!(part, Component::RootDir | Component::Normal(_)))
{
return Err("sandbox path must be absolute and normalized".into());
}
Ok(())
}
fn real_path(path: &Path) -> Result<PathBuf, String> {
normalized_absolute(path)?;
for ancestor in path.ancestors() {
let metadata = std::fs::symlink_metadata(ancestor)
.map_err(|error| format!("cannot inspect sandbox input: {error}"))?;
if metadata.file_type().is_symlink() {
return Err("sandbox input must not contain symbolic links".into());
}
}
Ok(path.to_owned())
}
fn mapped_path(worker: &Path, path: &Path) -> Result<PathBuf, String> {
let path = real_path(path)?;
let relative = path
.strip_prefix(worker)
.map_err(|_| "sandbox execution path escapes the Worker tree")?;
Ok(Path::new("/worker").join(relative))
}
/// Called only by the single-threaded guard immediately before exec. The filter
/// is inherited by exec and every subsequently created thread or child.
pub fn restrict(capabilities: Capabilities) -> Result<(), String> {
#[cfg(target_os = "linux")]
{
filter::apply(capabilities)
}
#[cfg(not(target_os = "linux"))]
{
let _ = capabilities;
Err("sandbox is not supported on this platform".into())
}
}
-79
View File
@@ -1,79 +0,0 @@
//! Private signed helper. It accepts a fixed capability mask, never filter code.
#[cfg(target_os = "linux")]
fn execute() -> Result<(), String> {
use std::io::Write;
use std::os::unix::process::CommandExt;
let mut arguments = std::env::args_os().skip(1);
let first = arguments.next();
let mut observation = if first.as_deref() == Some(std::ffi::OsStr::new("--observe")) {
let fd = arguments
.next()
.and_then(|value| value.to_str().and_then(|value| value.parse::<i32>().ok()))
.filter(|fd| *fd >= 3)
.ok_or("invalid sandbox observation descriptor")?;
// Opening proc creates a safe owned CLOEXEC duplicate. Close the
// inherited raw descriptor before exec so neither channel leaks.
let file = std::fs::OpenOptions::new()
.write(true)
.open(format!("/proc/self/fd/{fd}"))
.map_err(|error| format!("cannot open sandbox observation: {error}"))?;
nix::unistd::close(fd)
.map_err(|error| format!("cannot close inherited observation: {error}"))?;
Some(file)
} else {
None
};
let first = if observation.is_some() {
arguments.next()
} else {
first
};
let result = (|| {
let flags = first
.and_then(|value| value.to_str().and_then(|value| value.parse::<u8>().ok()))
.filter(|value| *value <= 3)
.ok_or("invalid sandbox capability mask")?;
let executable = arguments.next().ok_or("missing sandbox executable")?;
if !std::path::Path::new(&executable).starts_with("/worker") {
return Err("sandbox executable must be inside its Worker tree".into());
}
let mut command = std::process::Command::new(executable);
command.args(arguments).env_clear();
dever_sandbox::restrict(dever_sandbox::Capabilities {
network: flags & 1 != 0,
process: flags & 2 != 0,
})?;
if let Some(observation) = &mut observation {
observation
.write_all(b"R")
.map_err(|error| format!("cannot confirm sandbox guard: {error}"))?;
}
Err(format!(
"cannot execute sandboxed Worker: {}",
command.exec()
))
})();
if let (Some(observation), Err(error)) = (&mut observation, &result) {
observation
.write_all(b"E")
.and_then(|_| observation.write_all(error.as_bytes()))
.map_err(|write| format!("{error}; cannot report sandbox startup: {write}"))?;
}
result
}
#[cfg(not(target_os = "linux"))]
fn execute() -> Result<(), String> {
Err("sandbox is not supported on this platform".into())
}
fn main() -> std::process::ExitCode {
match execute() {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(error) => {
eprintln!("{error}");
std::process::ExitCode::FAILURE
}
}
}
-87
View File
@@ -1,87 +0,0 @@
//! Pin deployment grants until bwrap has mounted the exact checked inode.
use std::path::Path;
use std::process::Command;
#[derive(Default)]
pub(super) struct Mounts {
#[cfg(target_os = "linux")]
descriptors: Vec<command_fds::FdMapping>,
}
impl Mounts {
#[cfg(target_os = "linux")]
pub(super) fn pass(&mut self, file: std::fs::File) -> Result<i32, String> {
let child_fd =
i32::try_from(self.descriptors.len()).map_err(|_| "too many sandbox descriptors")? + 3;
self.descriptors.push(command_fds::FdMapping {
parent_fd: file.into(),
child_fd,
});
Ok(child_fd)
}
#[cfg(not(target_os = "linux"))]
pub(super) fn pass(&mut self, _: std::fs::File) -> Result<i32, String> {
Err("sandbox descriptors are not supported on this platform".into())
}
#[cfg(target_os = "linux")]
pub(super) fn bind(
&mut self,
command: &mut Command,
source: &Path,
destination: &Path,
writable: bool,
) -> Result<(), String> {
use rustix::fs::{Mode, OFlags, ResolveFlags, openat2};
let descriptor = openat2(
rustix::fs::CWD,
source,
OFlags::PATH | OFlags::CLOEXEC,
Mode::empty(),
ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS,
)
.map_err(|error| format!("cannot pin sandbox mount without following links: {error}"))?;
let child_fd =
i32::try_from(self.descriptors.len()).map_err(|_| "too many sandbox mounts")? + 3;
command
.arg(if writable {
"--bind-fd"
} else {
"--ro-bind-fd"
})
.arg(child_fd.to_string())
.arg(destination);
self.descriptors.push(command_fds::FdMapping {
parent_fd: descriptor,
child_fd,
});
Ok(())
}
#[cfg(target_os = "linux")]
pub(super) fn attach(self, command: &mut Command) -> Result<(), String> {
use command_fds::CommandFdExt;
command
.fd_mappings(self.descriptors)
.map_err(|error| format!("cannot pass sandbox mount descriptors: {error}"))?;
Ok(())
}
#[cfg(not(target_os = "linux"))]
pub(super) fn bind(
&mut self,
_: &mut Command,
_: &Path,
_: &Path,
_: bool,
) -> Result<(), String> {
Err("sandbox mounts are not supported on this platform".into())
}
#[cfg(not(target_os = "linux"))]
pub(super) fn attach(self, _: &mut Command) -> Result<(), String> {
Err("sandbox mounts are not supported on this platform".into())
}
}
-48
View File
@@ -1,48 +0,0 @@
//! A pinned namespace init is the completion boundary for all descendants.
#[cfg(target_os = "linux")]
use std::os::fd::{AsRawFd, RawFd};
pub struct CommandNamespace {
#[cfg(target_os = "linux")]
descriptor: std::fs::File,
}
impl CommandNamespace {
#[cfg(target_os = "linux")]
pub fn pin(pid: i32, wrapper: u32) -> Result<Self, String> {
use rustix::process::{Pid, PidfdFlags, pidfd_open};
let pid = Pid::from_raw(pid).ok_or("invalid sandbox namespace PID")?;
let descriptor = pidfd_open(pid, PidfdFlags::NONBLOCK)
.map_err(|error| format!("cannot pin sandbox namespace: {error}"))?;
// --block-fd holds the namespace before guard/target execution. If
// setup failed and its PID was reused, do not acquire authority over
// that process: only this bwrap's direct child belongs to this launch.
let status = std::fs::read_to_string(format!("/proc/{}/status", pid.as_raw_pid()))
.map_err(|error| format!("cannot verify sandbox namespace parent: {error}"))?;
let parent = status
.lines()
.find_map(|line| line.strip_prefix("PPid:"))
.and_then(|value| value.trim().parse::<u32>().ok());
if parent != Some(wrapper) {
return Err("sandbox namespace no longer belongs to its launcher".into());
}
Ok(Self {
descriptor: descriptor.into(),
})
}
#[cfg(target_os = "linux")]
pub fn terminate(&self) -> Result<(), String> {
match rustix::process::pidfd_send_signal(&self.descriptor, rustix::process::Signal::KILL) {
Ok(()) | Err(rustix::io::Errno::SRCH) => Ok(()),
Err(error) => Err(format!("cannot stop owned command namespace: {error}")),
}
}
}
#[cfg(target_os = "linux")]
impl AsRawFd for CommandNamespace {
fn as_raw_fd(&self) -> RawFd {
self.descriptor.as_raw_fd()
}
}
-130
View File
@@ -1,130 +0,0 @@
//! The only host integration is a signed, administrator-installed static helper.
use std::path::{Path, PathBuf};
pub const TRUSTED_HELPER_ROOT: &str = "/opt/dever/sandbox";
pub const APPARMOR_PROFILE: &str = include_str!("../../../sdk/apparmor/dever-sandbox");
#[cfg(all(test, target_os = "linux"))]
#[path = "../../../test/dever-sandbox-tests/trusted_helper.rs"]
mod tests;
pub(super) fn executable(assets: &Path, expected: &str) -> Result<PathBuf, String> {
#[cfg(target_os = "linux")]
if rustix::process::geteuid().as_raw() != 0 && validate_host_policy()? {
let path = Path::new(TRUSTED_HELPER_ROOT).join(expected).join("bwrap");
verify_helper(&path, expected)?;
require_profiles()?;
return Ok(path);
}
let _ = expected;
Ok(assets.join("bin/bwrap"))
}
/// Validate the host prerequisite shared by non-root launch and real-host
/// policy installation. This only reads kernel state; administrators own it.
pub fn validate_host_policy() -> Result<bool, String> {
#[cfg(target_os = "linux")]
{
host_policy(|path| std::fs::read_to_string(path))
}
#[cfg(not(target_os = "linux"))]
{
Ok(false)
}
}
#[cfg(target_os = "linux")]
fn host_policy(read: impl Fn(&str) -> std::io::Result<String>) -> Result<bool, String> {
let restricted = policy_flag(&read, "apparmor_restrict_unprivileged_userns")?.unwrap_or(false);
if !restricted {
return Ok(false);
}
if policy_flag(&read, "apparmor_restrict_unprivileged_unconfined")? != Some(true) {
return Err("Dever AppArmor deployment requires kernel.apparmor_restrict_unprivileged_unconfined=1 while user namespace restriction is enabled; an administrator must approve strict profile transitions before installing or loading the Dever policy".into());
}
Ok(true)
}
#[cfg(target_os = "linux")]
fn policy_flag(
read: &impl Fn(&str) -> std::io::Result<String>,
name: &str,
) -> Result<Option<bool>, String> {
match read(&format!("/proc/sys/kernel/{name}")) {
Ok(value) => match value.trim() {
"0" => Ok(Some(false)),
"1" => Ok(Some(true)),
_ => Err(format!(
"invalid AppArmor kernel flag '{name}': expected 0 or 1"
)),
},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(format!(
"cannot inspect AppArmor kernel flag '{name}': {error}"
)),
}
}
#[cfg(target_os = "linux")]
fn verify_helper(path: &Path, expected: &str) -> Result<(), String> {
use std::io::Read;
use std::os::unix::fs::MetadataExt;
for (index, ancestor) in path.ancestors().enumerate() {
let metadata = std::fs::symlink_metadata(ancestor).map_err(|error| format!(
"trusted sandbox helper is missing or inaccessible at '{}': {error}; install the matching signed Dever sandbox assets", path.display()))?;
if metadata.file_type().is_symlink()
|| metadata.uid() != 0
|| metadata.mode() & 0o022 != 0
|| (index == 0
&& (!metadata.is_file()
|| metadata.mode() & 0o6111 != 0o111
|| metadata.nlink() != 1))
|| (index != 0 && !metadata.is_dir())
{
return Err("trusted sandbox helper requires real root-owned paths without shared writes, setuid or setgid".into());
}
}
match rustix::fs::lgetxattr(path, "security.capability", &mut [0u8; 64]) {
Err(rustix::io::Errno::NODATA | rustix::io::Errno::OPNOTSUPP) => {}
Ok(_) => return Err("trusted sandbox helper must not have file capabilities".into()),
Err(error) => {
return Err(format!(
"cannot inspect sandbox helper capabilities: {error}"
));
}
}
let mut bytes = Vec::new();
std::fs::File::open(path)
.and_then(|file| file.take(32 * 1024 * 1024 + 1).read_to_end(&mut bytes))
.map_err(|error| format!("cannot read trusted sandbox helper: {error}"))?;
if super::validate_bwrap(&bytes)? != expected {
return Err("trusted sandbox helper differs from the embedded signed asset".into());
}
Ok(())
}
#[cfg(target_os = "linux")]
fn require_profiles() -> Result<(), String> {
// The profiles list requires policy-admin privilege; the policy filesystem
// exposes each profile's name/mode to ordinary users without changing state.
let entries = std::fs::read_dir("/sys/kernel/security/apparmor/policy/profiles")
.map_err(|error| format!("cannot verify the Dever AppArmor profiles: {error}; an administrator must load /etc/apparmor.d/dever-sandbox"))?;
let mut required = std::collections::BTreeSet::from(["dever-bwrap", "dever-bwrap-child"]);
for entry in entries.take(4096) {
let entry = entry.map_err(|error| format!("cannot inspect AppArmor profiles: {error}"))?;
let name = std::fs::read_to_string(entry.path().join("name"))
.map_err(|error| format!("cannot read AppArmor profile name: {error}"))?;
if required.contains(name.trim()) {
let mode = std::fs::read_to_string(entry.path().join("mode"))
.map_err(|error| format!("cannot read AppArmor profile mode: {error}"))?;
if mode.trim() != "enforce" {
return Err("Dever AppArmor profiles must be loaded in enforce mode".into());
}
required.remove(name.trim());
}
if required.is_empty() {
return Ok(());
}
}
Err("Dever AppArmor profile is not loaded; an administrator must load /etc/apparmor.d/dever-sandbox".into())
}