Menu
AkurAI-Build
publicLatest change 1386d4ed668393caf70c32bb53587de12672f5d6 - cli: consistent --json, actionable failures, and scoped commands by Olafur Bui
mod db;
mod server;
use std::{
collections::BTreeMap,
fs::{self, OpenOptions},
net::SocketAddr,
path::{Component, Path, PathBuf},
process::Command as ProcessCommand,
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
use anyhow::{Context, Result, ensure};
use bunfork::artifact::{self, AdmitOptions, Mode, TrailingSlash};
use clap::{Args, Parser, Subcommand, ValueEnum};
use db::{
Database, generate_secret, load_secret, parse_embedding, restore_database, secrets_equal,
};
use serde::{Deserialize, Serialize};
use tracing_subscriber::EnvFilter;
const DEFAULT_ADDRESS: &str = "0.0.0.0:3100";
#[derive(Debug, Parser)]
#[command(
name = "bunfork",
version,
about = "Small, secure Linux-first Rust web runtime (not a fork of Bun)"
)]
struct Cli {
#[arg(
long,
global = true,
env = "BUNFORK_DB",
default_value = "data/bunfork.db"
)]
database: PathBuf,
#[arg(
long,
global = true,
env = "BUNFORK_KEY_FILE",
default_value = ".bunfork.key"
)]
key_file: PathBuf,
#[arg(long, global = true, env = "BUNFORK_TENANT", default_value = "default")]
tenant: String,
#[arg(long, global = true, env = "BUNFORK_MODEL", default_value = "default")]
model: String,
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
/// Create a private 256-bit database key or API token file.
Keygen {
#[arg(long, default_value = ".bunfork.key")]
out: PathBuf,
},
/// Run an explicit frontend build command, then admit its static output.
Build(BuildArgs),
/// Create a deterministic bunfork-static-v1 manifest for prebuilt files.
Admit(ArtifactArgs),
/// Validate a frozen static artifact without starting a listener.
Doctor(DoctorArgs),
/// Run with request-time page and route reloads.
Dev(ServerArgs),
/// Run the production server.
#[command(visible_alias = "start")]
Serve(ServerArgs),
/// Apply all embedded database migrations transactionally.
Migrate,
/// Create a verified, encrypted, atomic database backup.
Backup {
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
json: bool,
},
/// Restore a verified backup while preserving the previous database.
Restore {
from: PathBuf,
#[arg(
long,
required = true,
help = "Required acknowledgement for this destructive operation"
)]
yes: bool,
},
/// Store, search, or delete encrypted vector records.
Vector {
#[command(subcommand)]
command: VectorCommand,
},
/// Run isolated route, migration, backup, and vector checks.
Test {
#[arg(long, default_value = "app/pages")]
pages: PathBuf,
#[arg(long, default_value = "public")]
public: PathBuf,
},
/// Test, release-build, and create a local deployment directory.
Deploy {
#[arg(long, default_value = "bunfork-dist")]
out: PathBuf,
#[arg(long, default_value = "app/pages")]
pages: PathBuf,
#[arg(long, default_value = "public")]
public: PathBuf,
#[arg(long, requires = "static_manifest")]
artifact: Option<PathBuf>,
#[arg(long = "static-manifest", requires = "artifact")]
static_manifest: Option<PathBuf>,
#[arg(long)]
force: bool,
},
}
#[derive(Debug, Args)]
struct BuildArgs {
#[arg(long = "run")]
program: String,
#[arg(long = "arg", allow_hyphen_values = true)]
arguments: Vec<String>,
#[arg(long, default_value = ".")]
cwd: PathBuf,
#[arg(long)]
output: PathBuf,
#[arg(long)]
manifest: PathBuf,
#[command(flatten)]
artifact: ArtifactOptions,
#[arg(long = "env", value_name = "NAME=VALUE")]
environment: Vec<String>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Args)]
struct ArtifactArgs {
#[arg(long)]
artifact: PathBuf,
#[arg(long)]
manifest: PathBuf,
#[command(flatten)]
options: ArtifactOptions,
#[arg(long)]
json: bool,
}
#[derive(Debug, Args)]
struct DoctorArgs {
#[arg(long = "static")]
artifact: PathBuf,
#[arg(long)]
manifest: PathBuf,
#[arg(long)]
json: bool,
}
#[derive(Debug, Args)]
struct ArtifactOptions {
#[arg(long, value_enum, default_value_t = ArtifactMode::Mpa)]
mode: ArtifactMode,
#[arg(long, default_value = "/")]
base: String,
#[arg(long)]
fallback: Option<String>,
#[arg(long, value_enum, default_value_t = ArtifactTrailingSlash::Directory)]
trailing_slash: ArtifactTrailingSlash,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum ArtifactMode {
Mpa,
Spa,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum ArtifactTrailingSlash {
Ignore,
Redirect,
Directory,
}
#[derive(Debug, Args)]
struct ServerArgs {
#[arg(
long,
env = "BUNFORK_ADDRESS",
default_value = DEFAULT_ADDRESS
)]
address: SocketAddr,
/// Serve a verified bunfork-static-v1 artifact without database state.
#[arg(long = "static", value_name = "DIR", requires = "manifest")]
static_site: Option<PathBuf>,
/// Frozen bunfork-static-v1 manifest for --static.
#[arg(long, value_name = "FILE", requires = "static_site")]
manifest: Option<PathBuf>,
#[arg(long, default_value = "app/pages")]
pages: PathBuf,
#[arg(long, default_value = "public")]
public: PathBuf,
#[arg(long, env = "BUNFORK_TOKEN_FILE", default_value = ".bunfork.token")]
token_file: PathBuf,
#[arg(long, env = "BUNFORK_PUBLIC_ORIGIN")]
public_origin: Option<String>,
}
#[derive(Debug, Subcommand)]
enum VectorCommand {
Put {
id: String,
#[arg(long)]
content_file: PathBuf,
#[arg(long, help = "Comma-separated finite f32 values")]
embedding: String,
},
Search {
#[arg(long, help = "Comma-separated finite f32 values")]
embedding: String,
#[arg(long, default_value_t = 10)]
limit: usize,
},
Delete {
id: String,
},
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct DeploymentManifest {
schema: String,
bunfork_version: String,
mode: String,
listen: String,
created_at: u64,
files: BTreeMap<String, artifact::FileRecord>,
}
#[derive(Serialize)]
struct AdmissionReport<'a> {
ok: bool,
schema: &'a str,
mode: &'a str,
base: &'a str,
files: usize,
bytes: u64,
}
#[derive(Serialize)]
struct DoctorReport<'a> {
ok: bool,
checks: Vec<DoctorCheck<'a>>,
}
#[derive(Serialize)]
struct DoctorCheck<'a> {
id: &'a str,
status: &'a str,
message: &'a str,
}
#[tokio::main]
async fn main() -> Result<()> {
init_logging()?;
let cli = Cli::parse();
match &cli.command {
Commands::Keygen { out } => {
generate_secret(out)?;
println!("created {}", out.display());
}
Commands::Build(args) => build_frontend(args)?,
Commands::Admit(args) => {
let manifest = artifact::admit(
&args.artifact,
&args.manifest,
artifact_options(&args.options),
)?;
print_admission(&manifest, args.json)?;
}
Commands::Doctor(args) => doctor(args)?,
Commands::Dev(args) => run_server(&cli, args, true).await?,
Commands::Serve(args) => run_server(&cli, args, false).await?,
Commands::Migrate => {
let key = load_secret(&cli.key_file, "BUNFORK_DB_KEY")?;
let database = Database::open_for_migration(&cli.database, key.as_str())?;
let applied = database.migrate()?;
database.validate_schema()?;
println!("applied {applied} migration(s)");
}
Commands::Backup { out, json } => {
let key = load_secret(&cli.key_file, "BUNFORK_DB_KEY")?;
let database = Database::open_existing(&cli.database, key.as_str())?;
let out = out.clone().unwrap_or_else(|| {
PathBuf::from("backups").join(format!("bunfork-{}.db", timestamp_or_zero()))
});
database.backup(&out, key.as_str())?;
if *json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"ok": true,
"path": out.display().to_string(),
}))?
);
} else {
println!("created verified encrypted backup {}", out.display());
}
}
Commands::Restore { from, yes } => {
ensure!(*yes, "restore requires --yes");
let key = load_secret(&cli.key_file, "BUNFORK_DB_KEY")?;
let preserved = restore_database(&cli.database, from, key.as_str())?;
println!(
"restored {}; previous database kept at {}",
from.display(),
preserved.display()
);
}
Commands::Vector { command } => run_vector_command(&cli, command)?,
Commands::Test { pages, public } => smoke_test(pages, public)?,
Commands::Deploy {
out,
pages,
public,
artifact,
static_manifest,
force,
} => deploy(
out,
pages,
public,
artifact.as_deref(),
static_manifest.as_deref(),
*force,
)?,
}
Ok(())
}
fn artifact_options(options: &ArtifactOptions) -> AdmitOptions {
AdmitOptions {
mode: match options.mode {
ArtifactMode::Mpa => Mode::Mpa,
ArtifactMode::Spa => Mode::Spa,
},
base: options.base.clone(),
fallback: options.fallback.clone(),
trailing_slash: match options.trailing_slash {
ArtifactTrailingSlash::Ignore => TrailingSlash::Ignore,
ArtifactTrailingSlash::Redirect => TrailingSlash::Redirect,
ArtifactTrailingSlash::Directory => TrailingSlash::Directory,
},
}
}
fn build_frontend(args: &BuildArgs) -> Result<()> {
ensure!(!args.program.is_empty(), "build program cannot be empty");
let cwd = fs::canonicalize(&args.cwd).context("resolve build working directory")?;
ensure!(cwd.is_dir(), "build working directory is not a directory");
let mut command = ProcessCommand::new(&args.program);
command
.args(&args.arguments)
.current_dir(&cwd)
.env_clear()
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
for name in [
"PATH",
"HOME",
"USER",
"LOGNAME",
"TMPDIR",
"TMP",
"TEMP",
"XDG_CACHE_HOME",
"CARGO_HOME",
"RUSTUP_HOME",
] {
if let Some(value) = std::env::var_os(name) {
command.env(name, value);
}
}
for assignment in &args.environment {
let (name, value) = assignment
.split_once('=')
.context("--env must use NAME=VALUE")?;
ensure!(
!name.is_empty()
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_'),
"invalid build environment name"
);
ensure!(
!matches!(name, "BUNFORK_DB_KEY" | "BUNFORK_API_TOKEN"),
"refuse to pass Bunfork secrets to a build"
);
command.env(name, value);
}
let status = command
.status()
.with_context(|| format!("run frontend build program {}", args.program))?;
ensure!(status.success(), "frontend build failed with {status}");
let artifact_root = if args.output.is_absolute() {
args.output.clone()
} else {
cwd.join(&args.output)
};
let manifest = artifact::admit(
&artifact_root,
&args.manifest,
artifact_options(&args.artifact),
)?;
print_admission(&manifest, args.json)
}
fn print_admission(manifest: &artifact::Manifest, json: bool) -> Result<()> {
let bytes = manifest.files.values().map(|record| record.bytes).sum();
let report = AdmissionReport {
ok: true,
schema: &manifest.schema,
mode: match manifest.mode {
Mode::Mpa => "mpa",
Mode::Spa => "spa",
},
base: &manifest.base,
files: manifest.files.len(),
bytes,
};
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!(
"admitted {} {} artifact: {} files, {} bytes, base {}",
report.schema, report.mode, report.files, report.bytes, report.base
);
}
Ok(())
}
/// Exit code contract: 0 = verified, 2 = CLI misuse (clap), 3 = verification
/// failed. Other operational errors keep anyhow's exit 1 so scripts can
/// distinguish 'artifact is bad' from 'doctor could not run'.
const EXIT_VERIFICATION_FAILED: i32 = 3;
fn doctor(args: &DoctorArgs) -> Result<()> {
let failure = artifact::read_manifest(&args.manifest)
.and_then(|manifest| artifact::verify(&args.artifact, &manifest))
.err();
let message = match &failure {
None => "artifact matches manifest".to_owned(),
// Include the full anyhow chain so JSON consumers see the actual
// divergence instead of an opaque 'validation failed'.
Some(error) => format!("{error:#}"),
};
if args.json {
let report = DoctorReport {
ok: failure.is_none(),
checks: vec![DoctorCheck {
id: "artifact.manifest",
status: if failure.is_none() { "pass" } else { "fail" },
message: &message,
}],
};
println!("{}", serde_json::to_string_pretty(&report)?);
} else if failure.is_none() {
println!("ok: artifact matches manifest");
} else {
eprintln!("error: {message}");
}
if failure.is_some() {
std::process::exit(EXIT_VERIFICATION_FAILED);
}
Ok(())
}
async fn run_server(cli: &Cli, args: &ServerArgs, development: bool) -> Result<()> {
if let (Some(root), Some(manifest)) = (&args.static_site, &args.manifest) {
return server::serve_static(args.address, root, manifest, development).await;
}
let key = load_secret(&cli.key_file, "BUNFORK_DB_KEY")?;
let token = load_secret(&args.token_file, "BUNFORK_API_TOKEN")?;
ensure!(
!secrets_equal(key.as_str(), token.as_str())?,
"API token must differ from the database key"
);
let database_path = fs::canonicalize(&cli.database).context("resolve database path")?;
let key_file = fs::symlink_metadata(&cli.key_file)
.ok()
.map(|_| fs::canonicalize(&cli.key_file).context("resolve database key file"))
.transpose()?;
let token_file = fs::symlink_metadata(&args.token_file)
.ok()
.map(|_| fs::canonicalize(&args.token_file).context("resolve API token file"))
.transpose()?;
let pages = fs::canonicalize(&args.pages).context("resolve pages directory")?;
let public = fs::canonicalize(&args.public).context("resolve public directory")?;
ensure!(
!pages.starts_with(&public) && !public.starts_with(&pages),
"pages and public directories must be disjoint"
);
for (label, sensitive) in [
("database", Some(&database_path)),
("database key", key_file.as_ref()),
("API token", token_file.as_ref()),
]
.into_iter()
.filter_map(|(label, path)| path.map(|path| (label, path)))
{
ensure!(
!sensitive.starts_with(&pages) && !sensitive.starts_with(&public),
"{label} must be outside the pages and public directories"
);
}
let database = Database::open_existing(&database_path, key.as_str())?;
server::serve(
database,
server::ServeOptions {
address: args.address,
pages,
public,
tenant: cli.tenant.clone(),
model: cli.model.clone(),
development,
api_token: Arc::new(token),
public_origin: args.public_origin.clone(),
},
)
.await
}
fn run_vector_command(cli: &Cli, command: &VectorCommand) -> Result<()> {
let key = load_secret(&cli.key_file, "BUNFORK_DB_KEY")?;
let database = Database::open_existing(&cli.database, key.as_str())?;
match command {
VectorCommand::Put {
id,
content_file,
embedding,
} => {
let content = fs::read_to_string(content_file)
.with_context(|| format!("read {}", content_file.display()))?;
let embedding = parse_embedding(embedding)?;
database.upsert_vector(&cli.tenant, &cli.model, id, &content, &embedding)?;
println!("stored {id}");
}
VectorCommand::Search { embedding, limit } => {
let embedding = parse_embedding(embedding)?;
let matches = database.search_vectors(&cli.tenant, &cli.model, &embedding, *limit)?;
println!("{}", serde_json::to_string_pretty(&matches)?);
}
VectorCommand::Delete { id } => {
ensure!(
database.delete_vector(&cli.tenant, &cli.model, id)?,
"vector not found: {id}"
);
println!("deleted {id}");
}
}
Ok(())
}
fn smoke_test(pages: &Path, public: &Path) -> Result<()> {
ensure!(
public.is_dir(),
"public directory does not exist: {}",
public.display()
);
let routes = server::PageRouter::scan(pages)?;
ensure!(routes.route_count() > 0, "no pages discovered");
let directory = tempfile::tempdir()?;
let key_file = directory.path().join("key");
let live = directory.path().join("live.db");
let backup = directory.path().join("backup.db");
generate_secret(&key_file)?;
let key = load_secret(&key_file, "BUNFORK_TEST_KEY")?;
let database = Database::open_for_migration(&live, key.as_str())?;
ensure!(database.migrate()? == 1, "initial migration did not run");
database.upsert_vector("test", "test", "north", "North", &[1.0, 0.0])?;
database.upsert_vector("test", "test", "east", "East", &[0.0, 1.0])?;
let matches = database.search_vectors("test", "test", &[0.9, 0.1], 1)?;
ensure!(
matches.first().map(|item| item.id.as_str()) == Some("north"),
"vector search failed"
);
database.backup(&backup, key.as_str())?;
Database::open_existing(&backup, key.as_str())?.validate_schema()?;
println!(
"ok: {} routes, encrypted migration/vector/backup checks passed",
routes.route_count()
);
Ok(())
}
fn ensure_source_checkout() -> Result<()> {
ensure!(
Path::new("Cargo.toml").is_file() && Path::new("src").is_dir(),
"this command must run from the bunfork source checkout \
(no Cargo.toml in the current directory)"
);
Ok(())
}
fn deploy(
out: &Path,
pages: &Path,
public: &Path,
static_artifact: Option<&Path>,
static_manifest: Option<&Path>,
force: bool,
) -> Result<()> {
ensure_source_checkout()?;
let project = fs::canonicalize(".")?;
let deploy_lock_path = project.join(".bunfork-deploy.lock");
let deploy_lock = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&deploy_lock_path)
.context("open deployment lock")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&deploy_lock_path, fs::Permissions::from_mode(0o600))?;
}
deploy_lock
.try_lock()
.context("another deployment is already running")?;
validate_deploy_output(out)?;
let static_bundle = match (static_artifact, static_manifest) {
(Some(root), Some(manifest_path)) => {
let root = fs::canonicalize(root).context("resolve static artifact")?;
let manifest_path =
fs::canonicalize(manifest_path).context("resolve static manifest")?;
ensure!(
root.starts_with(&project) && root != project,
"static artifact must be a project child"
);
ensure!(
manifest_path.starts_with(&project),
"static manifest must be a project child"
);
let manifest = artifact::read_manifest(&manifest_path)?;
artifact::verify(&root, &manifest)?;
Some((root, manifest_path))
}
(None, None) => None,
_ => anyhow::bail!("--artifact and --static-manifest must be used together"),
};
let native_sources = if static_bundle.is_none() {
let pages = fs::canonicalize(pages).context("resolve pages directory")?;
let public = fs::canonicalize(public).context("resolve public directory")?;
for (label, source) in [("pages", &pages), ("public", &public)] {
ensure!(
source.starts_with(&project) && source != &project,
"{label} must be a child of the project directory"
);
}
let routes = server::PageRouter::scan(&pages)?;
ensure!(routes.route_count() > 0, "no pages discovered");
ensure!(
public.is_dir(),
"public directory does not exist: {}",
public.display()
);
server::validate_public_tree(&public)?;
Some((pages, public))
} else {
None
};
for document in ["README.md", "LICENSE", "THIRD_PARTY_NOTICES.md"] {
ensure!(
Path::new(document).is_file(),
"missing deployment document {document}"
);
}
if let Some((pages, public)) = &native_sources {
run_process("cargo", &["test", "--locked"])?;
smoke_test(pages, public)?;
} else {
run_process("cargo", &["test", "--locked", "--all-targets"])?;
}
run_process("cargo", &["build", "--release", "--locked"])?;
let release_binary = cargo_target_directory()?
.join("release")
.join(format!("bunfork{}", std::env::consts::EXE_SUFFIX));
let absolute_out = project.join(out);
let temporary = absolute_out.with_extension(format!("tmp-{}", std::process::id()));
let previous = absolute_out.with_extension(format!("previous-{}", std::process::id()));
let sources = native_sources
.as_ref()
.map(|(pages, public)| vec![pages.as_path(), public.as_path()])
.or_else(|| {
static_bundle
.as_ref()
.map(|(root, manifest)| vec![root.as_path(), manifest.as_path()])
})
.unwrap_or_default();
for source in sources {
ensure!(
!absolute_out.starts_with(source)
&& !temporary.starts_with(source)
&& !source.starts_with(&absolute_out),
"deployment sources and output must be disjoint"
);
}
let output_exists = fs::symlink_metadata(&absolute_out).ok();
if let Some(metadata) = &output_exists {
ensure!(
force,
"{} already exists; pass --force to replace it",
out.display()
);
ensure!(
metadata.is_dir() && !metadata.file_type().is_symlink(),
"deployment output must be a real directory"
);
validate_owned_deployment(&absolute_out)?;
}
ensure!(
fs::symlink_metadata(&temporary).is_err(),
"temporary deployment path already exists"
);
ensure!(
fs::symlink_metadata(&previous).is_err(),
"previous deployment path already exists"
);
fs::create_dir_all(&temporary)?;
let result = (|| -> Result<()> {
if let Some((pages, public)) = &native_sources {
copy_tree(pages, &temporary.join("app/pages"))?;
let templates = pages
.parent()
.map(|parent| parent.join("templates"))
.filter(|path| path.is_dir());
if let Some(templates) = templates {
copy_tree(&templates, &temporary.join("app/templates"))?;
}
copy_tree(public, &temporary.join("public"))?;
} else if let Some((root, manifest)) = &static_bundle {
copy_tree(root, &temporary.join("site"))?;
copy_tree(manifest, &temporary.join("bunfork-static.json"))?;
}
copy_tree(Path::new("deploy"), &temporary.join("deploy"))?;
let binary = temporary.join(format!("bunfork{}", std::env::consts::EXE_SUFFIX));
copy_regular_file(&release_binary, &binary, true)
.with_context(|| format!("copy release binary {}", release_binary.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&binary, fs::Permissions::from_mode(0o755))?;
}
for document in ["README.md", "LICENSE", "THIRD_PARTY_NOTICES.md"] {
copy_tree(Path::new(document), &temporary.join(document))?;
}
let manifest = DeploymentManifest {
schema: "bunfork-deployment-v2".to_owned(),
bunfork_version: env!("CARGO_PKG_VERSION").to_owned(),
mode: if static_bundle.is_some() {
"static".to_owned()
} else {
"native".to_owned()
},
listen: DEFAULT_ADDRESS.to_owned(),
created_at: timestamp()?,
files: artifact::inventory_excluding(&temporary, "bunfork.json")?,
};
fs::write(
temporary.join("bunfork.json"),
serde_json::to_vec_pretty(&manifest)?,
)?;
sync_tree(&temporary)?;
validate_owned_deployment(&temporary)?;
if output_exists.is_some() {
fs::rename(&absolute_out, &previous).context("preserve previous deployment")?;
}
if let Err(error) = fs::rename(&temporary, &absolute_out) {
if output_exists.is_some() {
let _ = fs::rename(&previous, &absolute_out);
}
return Err(error).context("install deployment");
}
OpenOptions::new().read(true).open(&project)?.sync_all()?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_dir_all(&temporary);
}
result?;
if output_exists.is_some() {
println!(
"deployment ready at {}; previous release preserved at {}",
absolute_out.display(),
previous.display()
);
} else {
println!(
"deployment ready at {} (runtime database/key/token paths are not bundled)",
absolute_out.display()
);
}
deploy_lock.unlock().context("unlock deployment")?;
Ok(())
}
fn validate_deploy_output(out: &Path) -> Result<()> {
let mut components = out.components();
let Some(Component::Normal(name)) = components.next() else {
anyhow::bail!("deploy output must be one direct child of the project directory");
};
ensure!(
components.next().is_none(),
"deploy output must be one direct child of the project directory"
);
let name = name.to_str().context("deploy output must be valid UTF-8")?;
ensure!(
!name.starts_with('.')
&& !matches!(
name,
"app" | "backups" | "data" | "deploy" | "migrations" | "public" | "src" | "target"
),
"refuse protected deployment output {name}"
);
Ok(())
}
fn validate_owned_deployment(path: &Path) -> Result<()> {
let manifest_path = path.join("bunfork.json");
let metadata = fs::symlink_metadata(&manifest_path)
.with_context(|| format!("refuse to replace unowned directory {}", path.display()))?;
ensure!(
metadata.is_file()
&& !metadata.file_type().is_symlink()
&& metadata.len() <= 32 * 1024 * 1024,
"refuse to replace unowned directory {}: invalid bunfork.json",
path.display()
);
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
ensure!(
metadata.nlink() == 1,
"deployment manifest cannot be hard-linked"
);
}
let manifest: DeploymentManifest = serde_json::from_slice(
&fs::read(&manifest_path).context("read deployment ownership manifest")?,
)
.context("parse deployment ownership manifest")?;
// Ownership is proven by the verified v2 schema and the complete digest
// inventory checked below, not by the producing binary: requiring an
// exact CARGO_PKG_VERSION (or listen address) match stranded every
// prior release after an upgrade, forcing users to delete otherwise
// valid, fully verified deployments by hand.
ensure!(
manifest.schema == "bunfork-deployment-v2"
&& !manifest.bunfork_version.is_empty()
&& matches!(manifest.mode.as_str(), "native" | "static")
&& !manifest.listen.is_empty()
&& !manifest.files.is_empty(),
"refuse to replace unowned or incompatible directory {}",
path.display()
);
let actual = artifact::inventory_excluding(path, "bunfork.json")?;
ensure!(
actual == manifest.files,
"refuse to replace modified deployment {}",
path.display()
);
let mode_root = if manifest.mode == "static" {
"site"
} else {
"app/pages"
};
ensure!(
path.join(mode_root).is_dir(),
"deployment is missing {mode_root}"
);
if manifest.mode == "static" {
let static_manifest = artifact::read_manifest(&path.join("bunfork-static.json"))?;
artifact::verify(&path.join("site"), &static_manifest)?;
} else {
server::PageRouter::scan(&path.join("app/pages"))?;
server::validate_public_tree(&path.join("public"))?;
}
let binary = path.join(format!("bunfork{}", std::env::consts::EXE_SUFFIX));
let binary_metadata = fs::symlink_metadata(&binary)
.with_context(|| format!("refuse to replace unowned directory {}", path.display()))?;
ensure!(
binary_metadata.is_file() && !binary_metadata.file_type().is_symlink(),
"refuse to replace unowned directory {}: invalid bunfork binary",
path.display()
);
Ok(())
}
fn sync_tree(root: &Path) -> Result<()> {
let mut directories = vec![root.to_path_buf()];
let mut index = 0;
while index < directories.len() {
let directory = directories[index].clone();
index += 1;
for entry in fs::read_dir(&directory)? {
let entry = entry?;
let file_type = entry.file_type()?;
ensure!(
!file_type.is_symlink(),
"deployment tree contains a symlink"
);
if file_type.is_dir() {
directories.push(entry.path());
} else {
ensure!(
file_type.is_file(),
"deployment tree contains a special file"
);
OpenOptions::new()
.read(true)
.open(entry.path())?
.sync_all()?;
}
}
}
for directory in directories.into_iter().rev() {
OpenOptions::new().read(true).open(directory)?.sync_all()?;
}
Ok(())
}
fn copy_tree(source: &Path, destination: &Path) -> Result<()> {
let metadata = fs::symlink_metadata(source)
.with_context(|| format!("inspect deployment source {}", source.display()))?;
ensure!(
!metadata.file_type().is_symlink(),
"deployment source cannot contain symlinks"
);
if metadata.is_file() {
return copy_regular_file(source, destination, false);
}
ensure!(
metadata.is_dir(),
"unsupported deployment source {}",
source.display()
);
fs::create_dir_all(destination)?;
let mut entries = fs::read_dir(source)?.collect::<std::io::Result<Vec<_>>>()?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
copy_tree(&entry.path(), &destination.join(entry.file_name()))?;
}
Ok(())
}
fn copy_regular_file(source: &Path, destination: &Path, allow_hard_link: bool) -> Result<()> {
let metadata = fs::symlink_metadata(source)?;
ensure!(
metadata.is_file() && !metadata.file_type().is_symlink(),
"deployment source must be a regular file"
);
#[cfg(unix)]
if !allow_hard_link {
use std::os::unix::fs::MetadataExt;
ensure!(
metadata.nlink() == 1,
"deployment source cannot be hard-linked"
);
}
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
let mut source_options = OpenOptions::new();
source_options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
source_options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
}
let mut input = source_options.open(source)?;
let mut destination_options = OpenOptions::new();
destination_options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
destination_options.mode(0o644);
}
let mut output = destination_options.open(destination)?;
std::io::copy(&mut input, &mut output)?;
output.sync_all()?;
Ok(())
}
fn run_process(program: &str, arguments: &[&str]) -> Result<()> {
let status = ProcessCommand::new(program)
.args(arguments)
// Dependency build scripts run arbitrary code during cargo test/build;
// never hand them live Bunfork secrets from the invoking shell.
.env_remove("BUNFORK_DB_KEY")
.env_remove("BUNFORK_API_TOKEN")
.status()
.with_context(|| format!("run {program}"))?;
ensure!(
status.success(),
"{program} {} failed with {status}",
arguments.join(" ")
);
Ok(())
}
fn cargo_target_directory() -> Result<PathBuf> {
let output = ProcessCommand::new("cargo")
.args(["metadata", "--no-deps", "--format-version", "1", "--locked"])
.output()
.context("read Cargo target directory")?;
ensure!(
output.status.success(),
"cargo metadata failed with {}",
output.status
);
let metadata: serde_json::Value = serde_json::from_slice(&output.stdout)?;
let target = metadata["target_directory"]
.as_str()
.context("cargo metadata omitted target_directory")?;
Ok(PathBuf::from(target))
}
fn init_logging() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("bunfork=info,tower_http=info")),
)
.with_target(false)
.compact()
.try_init()
.map_err(|error| anyhow::anyhow!("initialize logging: {error}"))
}
fn timestamp() -> Result<u64> {
Ok(SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("system clock is before Unix epoch")?
.as_secs())
}
fn timestamp_or_zero() -> u64 {
timestamp().unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn refuses_unowned_deployment_directory() {
let directory = tempfile::tempdir().expect("temporary directory");
let output = directory.path().join("src");
fs::create_dir(&output).expect("create sentinel directory");
fs::write(output.join("sentinel"), b"keep me").expect("write sentinel");
assert!(validate_owned_deployment(&output).is_err());
assert_eq!(
fs::read(output.join("sentinel")).expect("read sentinel"),
b"keep me"
);
}
#[test]
fn refuses_protected_deployment_outputs() {
for path in [
".git",
".refrepos",
"app",
"backups",
"data",
"deploy",
"migrations",
"public",
"src",
"target",
] {
assert!(validate_deploy_output(Path::new(path)).is_err(), "{path}");
}
validate_deploy_output(Path::new("bunfork-dist")).expect("safe output");
}
#[test]
fn accepts_complete_owned_deployment_directory() {
let directory = tempfile::tempdir().expect("temporary directory");
let output = directory.path().join("dist");
for required in ["app/pages", "public", "deploy"] {
fs::create_dir_all(output.join(required)).expect("create deployment tree");
}
fs::write(
output.join(format!("bunfork{}", std::env::consts::EXE_SUFFIX)),
b"binary",
)
.expect("write binary");
// A manifest produced by an older Bunfork on a custom listen
// address must still count as owned: replacement safety comes from
// the digest inventory, not the producing version.
let manifest = DeploymentManifest {
schema: "bunfork-deployment-v2".to_owned(),
bunfork_version: "0.0.1".to_owned(),
mode: "native".to_owned(),
listen: "0.0.0.0:3200".to_owned(),
created_at: 1,
files: artifact::inventory_excluding(&output, "bunfork.json")
.expect("inventory deployment"),
};
fs::write(
output.join("bunfork.json"),
serde_json::to_vec_pretty(&manifest).expect("encode manifest"),
)
.expect("write manifest");
validate_owned_deployment(&output).expect("owned deployment");
fs::write(
output.join(format!("bunfork{}", std::env::consts::EXE_SUFFIX)),
b"mutated",
)
.expect("mutate binary");
assert!(validate_owned_deployment(&output).is_err());
}
}