use std::fs::File;
use std::io::{BufReader, Read};
use std::path::{Path, PathBuf};
use surrealism_types::err::{PrefixErr, SurrealismError, SurrealismResult};
use tar::Archive;
use tempfile::TempDir;
use zstd::stream::read::Decoder;
use crate::config::SurrealismConfig;
use crate::exports::ExportsManifest;
const COMPONENT_PREAMBLE: [u8; 8] = [0x00, 0x61, 0x73, 0x6d, 0x0d, 0x00, 0x01, 0x00];
const PNG_SIGNATURE: [u8; 8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
pub const MAX_LOGO_BYTES: usize = 256 * 1024;
pub const DEFAULT_MAX_FS_BYTES: u64 = 100 * 1024 * 1024;
fn verify_component(wasm: &[u8]) -> SurrealismResult<()> {
if wasm.len() < 8 || wasm[..8] != COMPONENT_PREAMBLE {
return Err(SurrealismError::Other(anyhow::anyhow!(
"expected a WASM component but found a core module. \
Core modules (WASI Preview 1) are no longer supported — \
compile with `--target wasm32-wasip2` to produce a component"
)));
}
Ok(())
}
pub fn verify_logo(bytes: &[u8]) -> SurrealismResult<()> {
if bytes.len() > MAX_LOGO_BYTES {
return Err(SurrealismError::Other(anyhow::anyhow!(
"logo.png is too large ({} bytes, max {} bytes / {} KiB)",
bytes.len(),
MAX_LOGO_BYTES,
MAX_LOGO_BYTES / 1024,
)));
}
if bytes.len() < 8 || bytes[..8] != PNG_SIGNATURE {
return Err(SurrealismError::Other(anyhow::anyhow!(
"logo.png is not a valid PNG file (invalid file signature)"
)));
}
Ok(())
}
const FS_PREFIX: &str = "surrealism/fs/";
pub struct AttachedFs {
_dir: TempDir,
root: PathBuf,
}
impl AttachedFs {
pub fn path(&self) -> &Path {
&self.root
}
}
impl std::fmt::Debug for AttachedFs {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AttachedFs").field("root", &self.root).finish()
}
}
pub struct SurrealismPackage {
pub config: SurrealismConfig,
pub wasm: Vec<u8>,
pub exports: ExportsManifest,
pub fs: Option<AttachedFs>,
pub logo: Option<Vec<u8>>,
}
pub struct UnpackOptions<'a> {
pub temp_base: Option<&'a Path>,
pub temp_prefix: &'a str,
pub max_fs_bytes: u64,
}
impl Default for UnpackOptions<'_> {
fn default() -> Self {
Self {
temp_base: None,
temp_prefix: "SURREAL_MODFS_local_",
max_fs_bytes: DEFAULT_MAX_FS_BYTES,
}
}
}
fn create_temp_dir(opts: &UnpackOptions<'_>) -> SurrealismResult<TempDir> {
let mut builder = tempfile::Builder::new();
builder.prefix(opts.temp_prefix);
if let Some(base) = opts.temp_base {
match builder.tempdir_in(base) {
Ok(dir) => return Ok(dir),
Err(e) => {
tracing::warn!(
base = %base.display(),
error = %e,
"Configured temporary directory unusable, falling back to system temp"
);
}
}
}
builder.tempdir().prefix_err(|| {
"Failed to create temporary directory for module filesystem. \
Configure --temporary-directory or ensure the system temp directory is writable"
})
}
pub fn exports_manifest_from_reader<R: Read>(reader: R) -> SurrealismResult<ExportsManifest> {
let zstd_decoder =
Decoder::new(BufReader::new(reader)).prefix_err(|| "Failed to create zstd decoder")?;
let mut archive = Archive::new(zstd_decoder);
for entry in archive.entries().prefix_err(|| "Failed to read archive entries")? {
let mut entry = entry.prefix_err(|| "Failed to read archive entry")?;
let entry_path = entry.path().prefix_err(|| "Failed to get entry path")?;
let entry_str = entry_path.to_string_lossy().to_string();
if entry_str == "surrealism/exports.toml" {
let mut buffer = String::new();
entry
.read_to_string(&mut buffer)
.prefix_err(|| "Failed to read exports file from archive")?;
return ExportsManifest::parse(&buffer).prefix_err(|| "Failed to parse exports.toml");
}
std::io::copy(&mut entry, &mut std::io::sink())
.prefix_err(|| format!("Failed to skip archive entry: {entry_str}"))?;
}
Err(SurrealismError::Other(anyhow::anyhow!("surrealism/exports.toml not found in archive")))
}
impl SurrealismPackage {
pub fn from_file(file: PathBuf) -> SurrealismResult<Self> {
if file.extension().and_then(|s| s.to_str()) != Some("surli") {
return Err(SurrealismError::Other(anyhow::anyhow!("Only .surli files are supported")));
}
if !file.exists() {
return Err(SurrealismError::Other(anyhow::anyhow!(
"File not found: {}",
file.display()
)));
}
let archive_file = File::open(file).prefix_err(|| "Failed to open archive file")?;
Self::from_reader(archive_file, &UnpackOptions::default())
}
pub fn from_reader<R: Read>(reader: R, opts: &UnpackOptions<'_>) -> SurrealismResult<Self> {
let zstd_decoder =
Decoder::new(BufReader::new(reader)).prefix_err(|| "Failed to create zstd decoder")?;
let mut archive = Archive::new(zstd_decoder);
let mut wasm: Option<Vec<u8>> = None;
let mut config: Option<SurrealismConfig> = None;
let mut exports: Option<ExportsManifest> = None;
let mut fs_dir: Option<TempDir> = None;
let mut logo: Option<Vec<u8>> = None;
let mut fs_bytes_total: u64 = 0;
for entry in archive.entries().prefix_err(|| "Failed to read archive entries")? {
let mut entry = entry.prefix_err(|| "Failed to read archive entry")?;
let entry_path = entry.path().prefix_err(|| "Failed to get entry path")?;
let entry_str = entry_path.to_string_lossy().to_string();
if entry_str == "surrealism/mod.wasm" {
let mut buffer = Vec::new();
entry
.read_to_end(&mut buffer)
.prefix_err(|| "Failed to read WASM file from archive")?;
verify_component(&buffer)?;
wasm = Some(buffer);
} else if entry_str == "surrealism/surrealism.toml" {
let mut buffer = String::new();
entry
.read_to_string(&mut buffer)
.prefix_err(|| "Failed to read config file from archive")?;
config = Some(
SurrealismConfig::parse(&buffer)
.prefix_err(|| "Failed to parse surrealism.toml")?,
);
} else if entry_str == "surrealism/exports.toml" {
let mut buffer = String::new();
entry
.read_to_string(&mut buffer)
.prefix_err(|| "Failed to read exports file from archive")?;
exports = Some(
ExportsManifest::parse(&buffer)
.prefix_err(|| "Failed to parse exports.toml")?,
);
} else if entry_str == "surrealism/logo.png" {
let declared = entry
.header()
.size()
.prefix_err(|| "Failed to read surrealism/logo.png tar header")?;
if declared > MAX_LOGO_BYTES as u64 {
return Err(SurrealismError::Other(anyhow::anyhow!(
"surrealism/logo.png is too large ({} bytes, max {} KiB)",
declared,
MAX_LOGO_BYTES / 1024
)));
}
let mut buffer = Vec::with_capacity(declared as usize);
entry
.read_to_end(&mut buffer)
.prefix_err(|| "Failed to read logo.png from archive")?;
logo = Some(buffer);
} else if entry_str.starts_with(FS_PREFIX) {
let entry_size = entry
.header()
.size()
.prefix_err(|| format!("Failed to read tar header for: {entry_str}"))?;
fs_bytes_total += entry_size;
if fs_bytes_total > opts.max_fs_bytes {
return Err(SurrealismError::Other(anyhow::anyhow!(
"attached filesystem exceeds {} MiB limit ({} bytes)",
opts.max_fs_bytes / (1024 * 1024),
opts.max_fs_bytes,
)));
}
if fs_dir.is_none() {
fs_dir = Some(create_temp_dir(opts)?);
}
let Some(dir) = fs_dir.as_ref() else {
unreachable!("fs_dir is always Some after the block above");
};
let unpacked = entry
.unpack_in(dir.path())
.prefix_err(|| format!("Failed to unpack fs entry: {}", entry_str))?;
if !unpacked {
tracing::warn!(
entry = %entry_str,
"Skipped archive fs entry for safety"
);
}
}
}
let wasm = wasm.ok_or_else(|| anyhow::anyhow!("mod.wasm not found in archive"))?;
let config =
config.ok_or_else(|| anyhow::anyhow!("surrealism.toml not found in archive"))?;
let exports =
exports.ok_or_else(|| anyhow::anyhow!("exports.toml not found in archive"))?;
if let Some(ref logo_bytes) = logo {
verify_logo(logo_bytes)?;
}
let fs = fs_dir.map(|dir| {
let root = dir.path().join(FS_PREFIX.trim_end_matches('/'));
AttachedFs {
_dir: dir,
root,
}
});
Ok(SurrealismPackage {
config,
wasm,
exports,
fs,
logo,
})
}
pub fn pack(&self, output: PathBuf, fs_dir: Option<&Path>) -> SurrealismResult<()> {
if output.extension().and_then(|s| s.to_str()) != Some("surli") {
return Err(SurrealismError::Other(anyhow::anyhow!(
"Output file must have .surli extension"
)));
}
match (&self.config.attach.fs, &fs_dir) {
(Some(cfg_fs), None) => {
tracing::warn!(
attach_fs = %cfg_fs,
"config.attach.fs is set but no fs_dir was provided to pack()"
);
}
(None, Some(dir)) => {
tracing::warn!(
fs_dir = %dir.display(),
"fs_dir provided to pack() but config.attach.fs is not set"
);
}
_ => {}
}
let file = File::create(&output).prefix_err(|| "Failed to create output file")?;
let encoder =
zstd::stream::Encoder::new(file, 0).prefix_err(|| "Failed to create zstd encoder")?;
let mut archive = tar::Builder::new(encoder);
let mut wasm_reader = std::io::Cursor::new(&self.wasm);
let mut wasm_header = tar::Header::new_gnu();
wasm_header.set_size(self.wasm.len() as u64);
wasm_header.set_mode(0o644);
archive
.append_data(&mut wasm_header, "surrealism/mod.wasm", &mut wasm_reader)
.prefix_err(|| "Failed to add mod.wasm to archive")?;
let config_str = self.config.to_toml().prefix_err(|| "Failed to serialize config")?;
let config_bytes = config_str.as_bytes();
let mut config_reader = std::io::Cursor::new(config_bytes);
let mut config_header = tar::Header::new_gnu();
config_header.set_size(config_bytes.len() as u64);
config_header.set_mode(0o644);
archive
.append_data(&mut config_header, "surrealism/surrealism.toml", &mut config_reader)
.prefix_err(|| "Failed to add surrealism.toml to archive")?;
let exports_str =
self.exports.to_toml().prefix_err(|| "Failed to serialize exports manifest")?;
let exports_bytes = exports_str.as_bytes();
let mut exports_reader = std::io::Cursor::new(exports_bytes);
let mut exports_header = tar::Header::new_gnu();
exports_header.set_size(exports_bytes.len() as u64);
exports_header.set_mode(0o644);
archive
.append_data(&mut exports_header, "surrealism/exports.toml", &mut exports_reader)
.prefix_err(|| "Failed to add exports.toml to archive")?;
if let Some(ref logo_bytes) = self.logo {
verify_logo(logo_bytes)?;
let mut logo_reader = std::io::Cursor::new(logo_bytes);
let mut logo_header = tar::Header::new_gnu();
logo_header.set_size(logo_bytes.len() as u64);
logo_header.set_mode(0o644);
archive
.append_data(&mut logo_header, "surrealism/logo.png", &mut logo_reader)
.prefix_err(|| "Failed to add logo.png to archive")?;
}
if let Some(dir) = fs_dir {
archive
.append_dir_all("surrealism/fs", dir)
.prefix_err(|| "Failed to add filesystem directory to archive")?;
}
let encoder = archive.into_inner().prefix_err(|| "Failed to get encoder from archive")?;
encoder.finish().prefix_err(|| "Failed to finish zstd encoder")?;
Ok(())
}
}