use std::fmt;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_TEMPORARY: AtomicU64 = AtomicU64::new(0);
use sha2::{Digest as _, Sha256};
use super::manifest::{ManifestError, SandboxManifest};
const HEADER_BYTES: usize = 16;
pub const MAX_MANIFEST_BYTES: usize = 64 * 1024;
pub const MAX_MODULE_BYTES: usize = 64 * 1024 * 1024;
pub const MAX_ARTIFACT_BYTES: usize = HEADER_BYTES + MAX_MANIFEST_BYTES + MAX_MODULE_BYTES;
const WASM_MAGIC: &[u8] = b"\0asm";
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ArtifactError {
BadMagic,
UnsupportedFormatVersion {
found: u32,
supported: u32,
},
Truncated {
what: &'static str,
needed: usize,
available: usize,
},
ManifestTooLarge {
found: usize,
max: usize,
},
ArtifactTooLarge {
found: u64,
max: usize,
},
ModuleTooLarge {
found: usize,
max: usize,
},
ManifestNotUtf8,
Manifest(ManifestError),
NotWasm,
DigestMismatch {
declared: String,
actual: String,
},
Io {
path: std::path::PathBuf,
kind: std::io::ErrorKind,
detail: String,
},
}
impl fmt::Display for ArtifactError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::BadMagic => write!(
f,
"not an Autumn sandboxed plugin artifact: the file does not start with `{magic}`",
magic = String::from_utf8_lossy(SandboxArtifact::MAGIC)
),
Self::UnsupportedFormatVersion { found, supported } => write!(
f,
"sandboxed plugin artifact declares container format {found}, but this build \
reads format {supported}"
),
Self::Truncated {
what,
needed,
available,
} => write!(
f,
"truncated sandboxed plugin artifact: {what} needs {needed} bytes but only \
{available} remain"
),
Self::ManifestTooLarge { found, max } => write!(
f,
"sandboxed plugin manifest is {found} bytes, over the {max}-byte ceiling"
),
Self::ArtifactTooLarge { found, max } => write!(
f,
"sandboxed plugin artifact is {found} bytes, over the {max}-byte ceiling; \
refusing to read it"
),
Self::ModuleTooLarge { found, max } => write!(
f,
"sandboxed plugin module is {found} bytes, over the {max}-byte ceiling"
),
Self::ManifestNotUtf8 => {
write!(f, "the sandboxed plugin manifest is not valid UTF-8")
}
Self::Manifest(err) => write!(f, "{err}"),
Self::NotWasm => write!(
f,
"the sandboxed plugin payload is not a WebAssembly module (no `\\0asm` header)"
),
Self::DigestMismatch { declared, actual } => write!(
f,
"sandboxed plugin module digest mismatch: the manifest declares {declared} but \
the module hashes to {actual}. The manifest and the module in this file do not \
belong together"
),
Self::Io { path, detail, .. } => write!(
f,
"sandboxed plugin artifact I/O failure at {path}: {detail}",
path = path.display()
),
}
}
}
impl std::error::Error for ArtifactError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Manifest(err) => Some(err),
_ => None,
}
}
}
impl From<ManifestError> for ArtifactError {
fn from(err: ManifestError) -> Self {
Self::Manifest(err)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SandboxArtifact {
manifest: SandboxManifest,
module: Vec<u8>,
}
impl SandboxArtifact {
pub const MAGIC: &'static [u8] = b"AUTUMNPL";
pub const FORMAT_VERSION: u32 = 1;
#[must_use]
pub fn digest(module: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(module);
hex::encode(hasher.finalize())
}
pub fn seal(mut manifest: SandboxManifest, module: Vec<u8>) -> Result<Self, ArtifactError> {
check_module(&module)?;
manifest.sha256 = Self::digest(&module);
manifest.validate()?;
let manifest = SandboxManifest::parse(&manifest.to_toml()?)?;
Ok(Self { manifest, module })
}
pub fn artifact_digest(&self) -> Result<String, ArtifactError> {
Ok(Self::digest(&self.to_bytes()?))
}
#[must_use]
pub const fn manifest(&self) -> &SandboxManifest {
&self.manifest
}
#[must_use]
pub fn module(&self) -> &[u8] {
&self.module
}
pub fn to_bytes(&self) -> Result<Vec<u8>, ArtifactError> {
let manifest = self.manifest.to_toml()?;
if manifest.len() > MAX_MANIFEST_BYTES {
return Err(ArtifactError::ManifestTooLarge {
found: manifest.len(),
max: MAX_MANIFEST_BYTES,
});
}
let length =
u32::try_from(manifest.len()).map_err(|_| ArtifactError::ManifestTooLarge {
found: manifest.len(),
max: MAX_MANIFEST_BYTES,
})?;
let mut out = Vec::with_capacity(HEADER_BYTES + manifest.len() + self.module.len());
out.extend_from_slice(Self::MAGIC);
out.extend_from_slice(&Self::FORMAT_VERSION.to_le_bytes());
out.extend_from_slice(&length.to_le_bytes());
out.extend_from_slice(manifest.as_bytes());
out.extend_from_slice(&self.module);
Ok(out)
}
pub fn read(bytes: &[u8]) -> Result<Self, ArtifactError> {
let magic = bytes
.get(..Self::MAGIC.len())
.ok_or(ArtifactError::Truncated {
what: "the container magic",
needed: Self::MAGIC.len(),
available: bytes.len(),
})?;
if magic != Self::MAGIC {
return Err(ArtifactError::BadMagic);
}
let header = bytes.get(..HEADER_BYTES).ok_or(ArtifactError::Truncated {
what: "the container header",
needed: HEADER_BYTES,
available: bytes.len(),
})?;
let format = read_u32(header, Self::MAGIC.len())?;
if format != Self::FORMAT_VERSION {
return Err(ArtifactError::UnsupportedFormatVersion {
found: format,
supported: Self::FORMAT_VERSION,
});
}
let manifest_len = read_u32(header, Self::MAGIC.len() + 4)? as usize;
if manifest_len > MAX_MANIFEST_BYTES {
return Err(ArtifactError::ManifestTooLarge {
found: manifest_len,
max: MAX_MANIFEST_BYTES,
});
}
let body = bytes.get(HEADER_BYTES..).unwrap_or_default();
let manifest_bytes = body.get(..manifest_len).ok_or(ArtifactError::Truncated {
what: "the manifest",
needed: manifest_len,
available: body.len(),
})?;
let manifest_src =
std::str::from_utf8(manifest_bytes).map_err(|_| ArtifactError::ManifestNotUtf8)?;
let manifest = SandboxManifest::parse(manifest_src)?;
let module = body.get(manifest_len..).unwrap_or_default();
check_module(module)?;
let module = module.to_vec();
let actual = Self::digest(&module);
if actual != manifest.sha256 {
return Err(ArtifactError::DigestMismatch {
declared: manifest.sha256,
actual,
});
}
Ok(Self { manifest, module })
}
pub fn read_file(path: &Path) -> Result<Self, ArtifactError> {
Self::read(&read_bounded(path, MAX_ARTIFACT_BYTES)?)
}
pub fn write_file(&self, path: &Path) -> Result<(), ArtifactError> {
use std::io::Write as _;
let bytes = self.to_bytes()?;
let unique = NEXT_TEMPORARY.fetch_add(1, Ordering::Relaxed);
let temporary = path.with_extension(format!(
"autumn-plugin-tmp-{pid}-{unique}",
pid = std::process::id()
));
let io = |path: &Path, err: &std::io::Error| ArtifactError::Io {
path: path.to_path_buf(),
kind: err.kind(),
detail: err.to_string(),
};
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary)
.map_err(|err| io(&temporary, &err))?;
if let Err(err) = file.write_all(&bytes).and_then(|()| file.sync_all()) {
drop(file);
let _ = std::fs::remove_file(&temporary);
return Err(io(&temporary, &err));
}
drop(file);
if let Err(err) = std::fs::rename(&temporary, path) {
let _ = std::fs::remove_file(&temporary);
return Err(io(path, &err));
}
Ok(())
}
}
pub fn read_bounded(path: &Path, max: usize) -> Result<Vec<u8>, ArtifactError> {
use std::io::Read as _;
let io = |err: &std::io::Error| ArtifactError::Io {
path: path.to_path_buf(),
kind: err.kind(),
detail: err.to_string(),
};
let too_large = |found: u64| ArtifactError::ArtifactTooLarge { found, max };
let mut file = std::fs::File::open(path).map_err(|err| io(&err))?;
let length = file.metadata().map_err(|err| io(&err))?.len();
if length > max as u64 {
return Err(too_large(length));
}
let mut bytes = Vec::with_capacity(usize::try_from(length).unwrap_or(0).min(max));
let read = file
.by_ref()
.take((max as u64).saturating_add(1))
.read_to_end(&mut bytes)
.map_err(|err| io(&err))?;
if read > max {
return Err(too_large(u64::try_from(read).unwrap_or(u64::MAX)));
}
Ok(bytes)
}
fn read_u32(header: &[u8], at: usize) -> Result<u32, ArtifactError> {
let slice = header
.get(at..at.saturating_add(4))
.ok_or_else(|| ArtifactError::Truncated {
what: "a container header field",
needed: at.saturating_add(4),
available: header.len(),
})?;
let mut buffer = [0u8; 4];
buffer.copy_from_slice(slice);
Ok(u32::from_le_bytes(buffer))
}
fn check_module(module: &[u8]) -> Result<(), ArtifactError> {
if module.len() > MAX_MODULE_BYTES {
return Err(ArtifactError::ModuleTooLarge {
found: module.len(),
max: MAX_MODULE_BYTES,
});
}
if module.get(..WASM_MAGIC.len()) == Some(WASM_MAGIC) {
Ok(())
} else {
Err(ArtifactError::NotWasm)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plugin_sandbox::manifest::SandboxManifest;
const EMPTY_MODULE: &[u8] = b"\0asm\x01\0\0\0";
fn manifest_toml(digest: &str) -> String {
format!(
r#"
name = "autumn-plugin-hello"
version = "0.1.0"
wire_version = 1
prefix = "/hello"
capabilities = ["http-request"]
sha256 = "{digest}"
[[routes]]
method = "GET"
path = "/hello/greet"
"#
)
}
fn sealed() -> SandboxArtifact {
let manifest =
SandboxManifest::parse(&manifest_toml(&SandboxArtifact::digest(EMPTY_MODULE)))
.expect("valid manifest");
SandboxArtifact::seal(manifest, EMPTY_MODULE.to_vec()).expect("seals")
}
#[test]
fn splitting_a_container_on_a_nul_byte_truncates_it_at_the_version_field() {
let bytes = sealed().to_bytes().expect("packs");
assert_eq!(
bytes.iter().position(|byte| *byte == 0),
Some(SandboxArtifact::MAGIC.len() + 1),
"the first NUL should fall in the version field, right after the magic",
);
let split = bytes
.splitn(2, |byte| *byte == 0)
.next()
.expect("splitn always yields once");
assert!(
matches!(
SandboxArtifact::read(split),
Err(ArtifactError::Truncated { .. })
),
"the split half must be too short to be read as a container",
);
SandboxArtifact::read(&bytes).expect("the unsplit container reads");
}
#[test]
fn round_trips_through_the_container() {
let artifact = sealed();
let bytes = artifact.to_bytes().expect("packs");
let read = SandboxArtifact::read(&bytes).expect("reads back");
assert_eq!(read.manifest, artifact.manifest);
assert_eq!(read.module(), EMPTY_MODULE);
}
#[test]
fn the_manifest_is_readable_at_a_fixed_offset() {
let bytes = sealed().to_bytes().expect("packs");
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("name = \"autumn-plugin-hello\""), "{text}");
assert!(bytes.starts_with(SandboxArtifact::MAGIC));
}
#[test]
fn seal_computes_the_digest_so_a_packer_cannot_forget_it() {
let artifact = sealed();
assert_eq!(
artifact.manifest().sha256,
SandboxArtifact::digest(EMPTY_MODULE)
);
}
#[test]
fn an_unlimited_read_cap_reads_the_file_rather_than_nothing() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("small.autumn-plugin");
std::fs::write(&path, b"some bytes").expect("writes");
let bytes = read_bounded(&path, usize::MAX).expect("an unlimited cap must read the file");
assert_eq!(bytes, b"some bytes", "the read came back empty or short");
}
#[test]
fn the_artifact_digest_moves_when_the_grant_does_and_the_module_digest_does_not() {
let reviewed = sealed();
let reviewed_identity = reviewed.artifact_digest().expect("digests");
let widened = SandboxManifest::parse(
&manifest_toml(&SandboxArtifact::digest(EMPTY_MODULE)).replace("/hello", "/admin"),
)
.expect("valid manifest");
let widened = SandboxArtifact::seal(widened, EMPTY_MODULE.to_vec()).expect("seals");
assert_eq!(
widened.manifest().sha256,
reviewed.manifest().sha256,
"the module did not change, so its digest must not",
);
assert_ne!(
widened.artifact_digest().expect("digests"),
reviewed_identity,
"a rewritten grant kept the identity an operator recorded",
);
let mut raised =
SandboxManifest::parse(&manifest_toml(&SandboxArtifact::digest(EMPTY_MODULE)))
.expect("valid manifest");
raised.limits.max_concurrency = raised.limits.max_concurrency.saturating_add(1);
let raised = SandboxArtifact::seal(raised, EMPTY_MODULE.to_vec()).expect("seals");
assert_ne!(
raised.artifact_digest().expect("digests"),
reviewed_identity,
"a raised ceiling kept the identity an operator recorded",
);
let round_tripped =
SandboxArtifact::read(&reviewed.to_bytes().expect("packs")).expect("reads back");
assert_eq!(
round_tripped.artifact_digest().expect("digests"),
reviewed_identity,
"the identity must survive a round trip through the container",
);
}
#[test]
fn a_tampered_module_is_refused() {
let mut bytes = sealed().to_bytes().expect("packs");
let last = bytes.len() - 1;
bytes[last] ^= 0xFF;
let err = SandboxArtifact::read(&bytes).expect_err("tampering must be caught");
assert!(matches!(err, ArtifactError::DigestMismatch { .. }), "{err}");
}
#[test]
fn a_foreign_container_is_refused() {
let mut bytes = sealed().to_bytes().expect("packs");
bytes[0] = b'X';
let err = SandboxArtifact::read(&bytes).expect_err("bad magic must be caught");
assert!(matches!(err, ArtifactError::BadMagic), "{err}");
}
#[test]
fn a_future_container_version_is_refused() {
let mut bytes = sealed().to_bytes().expect("packs");
bytes[8..12].copy_from_slice(&2u32.to_le_bytes());
let err = SandboxArtifact::read(&bytes).expect_err("future version must be caught");
assert!(
matches!(
err,
ArtifactError::UnsupportedFormatVersion { found: 2, .. }
),
"{err}"
);
}
#[test]
fn a_truncated_container_is_refused_at_every_cut() {
let bytes = sealed().to_bytes().expect("packs");
for cut in 0..bytes.len() {
let err = SandboxArtifact::read(&bytes[..cut])
.expect_err("a truncated container must never parse");
let _ = err.to_string();
}
}
#[test]
fn a_manifest_length_past_the_end_is_refused_without_allocating() {
let mut bytes = sealed().to_bytes().expect("packs");
bytes[12..16].copy_from_slice(&u32::MAX.to_le_bytes());
let err = SandboxArtifact::read(&bytes).expect_err("must be caught");
assert!(
matches!(err, ArtifactError::ManifestTooLarge { .. }),
"{err}"
);
#[allow(clippy::cast_possible_truncation)]
let past_end = (MAX_MANIFEST_BYTES - 1) as u32;
bytes[12..16].copy_from_slice(&past_end.to_le_bytes());
let err = SandboxArtifact::read(&bytes).expect_err("must be caught");
assert!(matches!(err, ArtifactError::Truncated { .. }), "{err}");
}
#[test]
fn an_oversized_manifest_is_refused() {
let manifest = "x".repeat(MAX_MANIFEST_BYTES + 1);
let mut bytes = Vec::new();
bytes.extend_from_slice(SandboxArtifact::MAGIC);
bytes.extend_from_slice(&SandboxArtifact::FORMAT_VERSION.to_le_bytes());
#[allow(clippy::cast_possible_truncation)]
bytes.extend_from_slice(&(manifest.len() as u32).to_le_bytes());
bytes.extend_from_slice(manifest.as_bytes());
bytes.extend_from_slice(EMPTY_MODULE);
let err = SandboxArtifact::read(&bytes).expect_err("must be caught");
assert!(
matches!(err, ArtifactError::ManifestTooLarge { .. }),
"{err}"
);
}
#[test]
fn a_payload_that_is_not_a_wasm_module_is_refused() {
let module = b"#!/bin/sh\nrm -rf /\n".to_vec();
let manifest =
SandboxManifest::parse(&manifest_toml(&SandboxArtifact::digest(&module))).expect("ok");
let err = SandboxArtifact::seal(manifest, module).expect_err("must be caught");
assert!(matches!(err, ArtifactError::NotWasm), "{err}");
}
#[test]
fn a_manifest_that_does_not_validate_is_refused_on_read() {
let manifest = manifest_toml(&SandboxArtifact::digest(EMPTY_MODULE))
.replace(r#"prefix = "/hello""#, r#"prefix = "/""#);
let mut bytes = Vec::new();
bytes.extend_from_slice(SandboxArtifact::MAGIC);
bytes.extend_from_slice(&SandboxArtifact::FORMAT_VERSION.to_le_bytes());
#[allow(clippy::cast_possible_truncation)]
bytes.extend_from_slice(&(manifest.len() as u32).to_le_bytes());
bytes.extend_from_slice(manifest.as_bytes());
bytes.extend_from_slice(EMPTY_MODULE);
let err = SandboxArtifact::read(&bytes).expect_err("must be caught");
assert!(matches!(err, ArtifactError::Manifest(_)), "{err}");
}
#[test]
fn an_oversized_file_is_refused_before_it_is_read_into_memory() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("huge.autumn-plugin");
let file = std::fs::File::create(&path).expect("creates");
file.set_len(MAX_ARTIFACT_BYTES as u64 + 1)
.expect("sets len");
drop(file);
let err = SandboxArtifact::read_file(&path).expect_err("must be refused");
assert!(
matches!(err, ArtifactError::ArtifactTooLarge { .. }),
"{err}"
);
}
#[test]
fn a_missing_file_is_distinguishable_from_an_unreadable_one() {
let dir = tempfile::tempdir().expect("tempdir");
let err = SandboxArtifact::read_file(&dir.path().join("absent.autumn-plugin"))
.expect_err("must fail");
assert!(
matches!(
err,
ArtifactError::Io {
kind: std::io::ErrorKind::NotFound,
..
}
),
"{err}"
);
}
#[cfg(unix)]
#[test]
fn writing_never_follows_something_already_sitting_at_the_temporary_path() {
let dir = tempfile::tempdir().expect("tempdir");
let victim = dir.path().join("precious");
std::fs::write(&victim, b"do not clobber me").expect("victim written");
let path = dir.path().join("hello.autumn-plugin");
let predictable = path.with_extension("autumn-plugin-tmp");
std::os::unix::fs::symlink(&victim, &predictable).expect("symlink");
let _ = sealed().write_file(&path);
assert_eq!(
std::fs::read(&victim).expect("victim still readable"),
b"do not clobber me",
"packaging wrote through a symlink at the temporary path"
);
}
#[test]
fn a_failed_write_never_replaces_a_good_artifact_with_a_truncated_one() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("hello.autumn-plugin");
sealed().write_file(&path).expect("writes");
sealed().write_file(&path).expect("rewrites");
assert!(SandboxArtifact::read_file(&path).is_ok());
assert!(
!path.with_extension("autumn-plugin-tmp").exists(),
"the staging file must not survive a successful write"
);
}
#[test]
fn reads_and_writes_a_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("hello.autumn-plugin");
let artifact = sealed();
artifact.write_file(&path).expect("writes");
let read = SandboxArtifact::read_file(&path).expect("reads");
assert_eq!(read.manifest, artifact.manifest);
}
#[test]
fn the_digest_is_lowercase_hex_sha256() {
let digest = SandboxArtifact::digest(b"");
assert_eq!(
digest,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
}