AkurAI Build
Menu

AkurAI-Build

public

Latest change 39242b4792b8fa75a53706ca39fc940335170b61 - server: fail closed on the Origin fallback and harden isolation headers 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>,
    },

    /// Restore a verified backup while preserving the previous database.
    Restore {
        from: PathBuf,
        #[arg(long, 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>,
}

#[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 } => {
            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())?;
            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 } => test_project(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, false)
}

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(())
}

fn doctor(args: &DoctorArgs) -> Result<()> {
    let result = artifact::read_manifest(&args.manifest)
        .and_then(|manifest| artifact::verify(&args.artifact, &manifest));
    if args.json {
        let report = DoctorReport {
            ok: result.is_ok(),
            checks: vec![DoctorCheck {
                id: "artifact.manifest",
                status: if result.is_ok() { "pass" } else { "fail" },
                message: if result.is_ok() {
                    "artifact matches manifest"
                } else {
                    "artifact validation failed"
                },
            }],
        };
        println!("{}", serde_json::to_string_pretty(&report)?);
    } else if result.is_ok() {
        println!("ok: artifact matches manifest");
    }
    result
}

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 test_project(pages: &Path, public: &Path) -> Result<()> {
    run_process("cargo", &["test", "--locked"])?;
    smoke_test(pages, public)
}

fn deploy(
    out: &Path,
    pages: &Path,
    public: &Path,
    static_artifact: Option<&Path>,
    static_manifest: Option<&Path>,
    force: bool,
) -> Result<()> {
    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 {
        test_project(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());
    }
}