From d62f87af4230684eeff49d00bfa791429d8cd6a3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 16:10:13 +0300 Subject: [PATCH 01/10] feat(bus): expose minimal hosting vocabulary and declarations Co-authored-by: Medulla --- .github/workflows/ci.yml | 12 +- .github/workflows/release.yml | 12 +- AGENTS.md | 9 +- Cargo.lock | 10 + Cargo.toml | 6 + README.md | 8 + crates/tinyhosts-bus/Cargo.toml | 19 + crates/tinyhosts-bus/README.md | 20 + .../tinyhosts-bus/src}/declarations.json | 0 crates/tinyhosts-bus/src/error.rs | 207 ++++++ crates/tinyhosts-bus/src/inputs.rs | 45 ++ crates/tinyhosts-bus/src/launch.rs | 30 + crates/tinyhosts-bus/src/lib.rs | 46 ++ crates/tinyhosts-bus/src/lib_tests.rs | 45 ++ crates/tinyhosts-bus/src/model.rs | 588 +++++++++++++++++ crates/tinyhosts-bus/src/rpc.rs | 205 ++++++ docs/specs/minimal-bus-contract.md | 24 + examples/verify_module.rs | 25 + src/error/mod.rs | 209 +------ src/host/types.rs | 590 +----------------- src/launch/types.rs | 33 +- src/lib.rs | 3 + src/rpc/mod.rs | 191 +----- src/rpc/mod_tests.rs | 58 ++ src/tinybus_module/mod.rs | 4 +- src/tinybus_module/mod_tests.rs | 3 +- src/tools/mod_tests.rs | 2 +- 27 files changed, 1381 insertions(+), 1023 deletions(-) create mode 100644 crates/tinyhosts-bus/Cargo.toml create mode 100644 crates/tinyhosts-bus/README.md rename {src/tools => crates/tinyhosts-bus/src}/declarations.json (100%) create mode 100644 crates/tinyhosts-bus/src/error.rs create mode 100644 crates/tinyhosts-bus/src/inputs.rs create mode 100644 crates/tinyhosts-bus/src/launch.rs create mode 100644 crates/tinyhosts-bus/src/lib.rs create mode 100644 crates/tinyhosts-bus/src/lib_tests.rs create mode 100644 crates/tinyhosts-bus/src/model.rs create mode 100644 crates/tinyhosts-bus/src/rpc.rs create mode 100644 docs/specs/minimal-bus-contract.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7152f39..b9d97eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,16 +40,16 @@ jobs: run: cargo fmt --all -- --check - name: Clippy - run: cargo clippy --all-targets --all-features -- -D warnings + run: cargo clippy --workspace --all-targets --all-features -- -D warnings - name: Build - run: cargo build --all-targets --all-features + run: cargo build --workspace --all-targets --all-features - name: Test - run: cargo test --all-features + run: cargo test --workspace --all-features - name: Test default features - run: cargo test + run: cargo test --workspace - name: Require 90% line coverage in every source file run: .github/scripts/check-file-coverage.sh 90 coverage.json @@ -78,7 +78,7 @@ jobs: - name: Build documentation env: RUSTDOCFLAGS: -D warnings - run: cargo doc --no-deps --all-features + run: cargo doc --workspace --no-deps --all-features msrv: name: Minimum supported Rust version @@ -108,7 +108,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Build with the declared MSRV - run: cargo build --all-targets --all-features + run: cargo build --workspace --all-targets --all-features supply-chain: name: Supply chain diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ab3d6b..6469e09 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,13 +47,13 @@ jobs: run: cargo fmt --all -- --check - name: Clippy - run: cargo clippy --all-targets --all-features -- -D warnings + run: cargo clippy --workspace --all-targets --all-features -- -D warnings - name: Build - run: cargo build --all-targets --all-features + run: cargo build --workspace --all-targets --all-features - name: Test - run: cargo test --all-features + run: cargo test --workspace --all-features - name: Require 90% line coverage in every source file run: .github/scripts/check-file-coverage.sh 90 target/coverage.json @@ -61,7 +61,7 @@ jobs: - name: Build documentation env: RUSTDOCFLAGS: -D warnings - run: cargo doc --no-deps --all-features + run: cargo doc --workspace --no-deps --all-features - name: Compute next version id: version @@ -140,7 +140,7 @@ jobs: NEXT_VERSION: ${{ steps.version.outputs.next_version }} run: | set -euo pipefail - perl -0pi -e 's/(\[package\][\s\S]*?\nversion = ")[^"]+(")/$1$ENV{NEXT_VERSION}$2/' Cargo.toml + perl -0pi -e 's/(\[package\][\s\S]*?\nversion = ")[^"]+(")/$1$ENV{NEXT_VERSION}$2/' Cargo.toml crates/tinyhosts-bus/Cargo.toml cargo update -p "$CRATE_NAME" --precise "$NEXT_VERSION" - name: Commit version bump and tag @@ -151,7 +151,7 @@ jobs: set -euo pipefail git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add Cargo.toml Cargo.lock + git add Cargo.toml crates/tinyhosts-bus/Cargo.toml Cargo.lock git commit -m "Release ${RELEASE_TAG}" git tag -a "${RELEASE_TAG}" -m "Release ${RELEASE_TAG}" git push origin "HEAD:${GITHUB_REF_NAME}" diff --git a/AGENTS.md b/AGENTS.md index 0170277..2947281 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ library and a TinyBus module: the `cdylib` is the same code behind a JSON method ```text src/ ├── lib.rs # crate docs + the entire public re-export surface -├── error/mod.rs # crate-wide `Error` and `Result` +├── error/mod.rs # compatibility exports of contract `Error` and `Result` ├── credentials/ # the API key: redacted, deserialize-only ├── host/ # the `Host` trait (mod.rs) and the unified vocabulary │ └── types.rs # every provider-independent type @@ -24,6 +24,7 @@ src/ │ # http.rs (status mapping), wire.rs (Vercel's shapes) ├── rpc/ # one JSON request in, one JSON result out └── tinybus_module/ # TinyBus interface, ABI exports, and integration tests +crates/tinyhosts-bus/ # pure vocabulary, JSON envelopes and declarations tests/ # integration tests against the public API only examples/ # runnable, compiled-in-CI usage examples vendor/tinybus/ # pinned TinyBus host types and module SDK @@ -91,9 +92,9 @@ runs exactly them, so a green local run should mean a green CI run. ```sh cargo fmt --all -- --check -cargo clippy --all-targets --all-features -- -D warnings -cargo build --all-targets --all-features -cargo test --all-features +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo build --workspace --all-targets --all-features +cargo test --workspace --all-features ``` Supporting commands: diff --git a/Cargo.lock b/Cargo.lock index 7da6473..c548e3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1366,12 +1366,22 @@ dependencies = [ "thiserror", "tinybus", "tinybus-module", + "tinyhosts-bus", "tinytools", "tokio", "tracing", "wiremock", ] +[[package]] +name = "tinyhosts-bus" +version = "0.2.3" +dependencies = [ + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "tinystr" version = "0.8.4" diff --git a/Cargo.toml b/Cargo.toml index 496cf87..2e817d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,13 @@ exclude = [ # producing the native module artifact that TinyBus loads at runtime. crate-type = ["rlib", "cdylib"] +[workspace] +members = ["crates/tinyhosts-bus"] +exclude = ["vendor/tinybus"] + [dependencies] +# Pure vocabulary shared with dynamically loaded module consumers. +tinyhosts-bus = { path = "crates/tinyhosts-bus", version = "0.2" } # TinyBus defines the message types, interface macro, and frozen module ABI used # by the generated integration. Socket and CLI features are unnecessary here. tinybus = { path = "vendor/tinybus/crates/tinybus", version = "0.1.0", default-features = false, features = ["macros", "modules"], optional = true } diff --git a/README.md b/README.md index 315ad27..c251f22 100644 --- a/README.md +++ b/README.md @@ -196,3 +196,11 @@ hand-edit the version in `Cargo.toml`. ## License GPL-3.0-only. See [LICENSE](LICENSE). + +## Minimal host contract + +Hosts loading the compiled module depend on `tinyhosts-bus`. It supplies the +existing request/result envelopes, hosting records, errors, provider identifiers, +and tool declarations without linking provider implementations. See the +[contract crate](crates/tinyhosts-bus/README.md) and +[boundary spec](docs/specs/minimal-bus-contract.md). diff --git a/crates/tinyhosts-bus/Cargo.toml b/crates/tinyhosts-bus/Cargo.toml new file mode 100644 index 0000000..b1a8182 --- /dev/null +++ b/crates/tinyhosts-bus/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "tinyhosts-bus" +version = "0.2.3" +edition = "2024" +rust-version = "1.88" +license = "GPL-3.0-only" +description = "Transport-free hosting vocabulary for TinyBus hosts." +repository = "https://github.com/tinyhumansai/tinyhosts" + +[dependencies] +# Contract payloads and their JSON schemas. +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# Shared error vocabulary; no provider, filesystem, or transport dependency. +thiserror = "2" + +[lints.rust] +unsafe_code = "forbid" +missing_docs = "warn" diff --git a/crates/tinyhosts-bus/README.md b/crates/tinyhosts-bus/README.md new file mode 100644 index 0000000..4bcaa7f --- /dev/null +++ b/crates/tinyhosts-bus/README.md @@ -0,0 +1,20 @@ +# tinyhosts-bus + +The minimal vocabulary for callers of the compiled TinyHosts module. Dependencies +are restricted to serde, JSON serialization, and error derives; no feature links +a provider, TinyBus transport, HTTP client, filesystem collector, or runtime. + +`Execute(String) -> String` accepts the existing JSON request and returns the +existing tagged result envelope. `Providers() -> String` returns provider slugs. +Request and Operation have generic input slots so the library retains its +validated Bundle and LaunchPlan APIs while hosts use `DeploymentInput` and +`LaunchInput` DTOs. File contents remain standard-base64 strings on the wire; +encoding, path validation, collection, and execution remain implementation work. +Requests and credential inputs are deserialize-only, with redacted Debug output. + +Shared hosting records, errors, schemas, result envelopes, and recorded tool +declarations are re-exported by the implementation for compatibility. Host +approval, authorized workspace selection, credential custody, and lifecycle +policy stay with OpenHuman. The module validates inputs and talks to providers. +The package version follows the module release version; the release workflow +bumps both manifests together. Hosts pin published artifacts and verify digests. diff --git a/src/tools/declarations.json b/crates/tinyhosts-bus/src/declarations.json similarity index 100% rename from src/tools/declarations.json rename to crates/tinyhosts-bus/src/declarations.json diff --git a/crates/tinyhosts-bus/src/error.rs b/crates/tinyhosts-bus/src/error.rs new file mode 100644 index 0000000..80f8017 --- /dev/null +++ b/crates/tinyhosts-bus/src/error.rs @@ -0,0 +1,207 @@ +//! Crate-wide error and result types. +//! +//! Every fallible public function in this crate returns [`Result`], and every +//! failure mode is a distinct [`Error`] variant. Add a variant rather than +//! encoding new context into an existing message: callers match on variants, +//! and message text is not a stable API. +//! +//! Variants carry the data a caller needs to react, keep their `#[error]` +//! message lowercase and free of trailing punctuation, and are documented so +//! the rendered rustdoc explains when each one occurs. +//! +//! Provider failures name the provider they came from. A run may talk to more +//! than one host in the same process, and "unauthorized" is not actionable +//! until the reader knows which credential was rejected. + +/// Errors returned by this crate. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +#[non_exhaustive] +pub enum Error { + /// An API key was empty or contained only whitespace. + #[error("api key must not be empty")] + EmptyApiKey, + + /// No API key was found in the environment for a provider. + /// + /// `variables` lists every name that was searched, in order. + #[error("no api key for {provider}: set one of {variables}")] + MissingApiKey { + /// The provider whose credential is missing. + provider: String, + /// The environment variable names that were searched, comma separated. + variables: String, + }, + + /// A site name was empty or contained only whitespace. + #[error("site name must not be empty")] + EmptySiteName, + + /// A deployment was requested with no files in it. + #[error("deployment bundle contains no files")] + EmptyBundle, + + /// A bundle entry named a path that cannot be deployed. + /// + /// Bundle paths are relative, slash separated, and may not traverse out of + /// the bundle root. + #[error("bundle path {path} is not a relative path inside the bundle")] + InvalidBundlePath { + /// The rejected path, as it was supplied. + path: String, + }, + + /// The filesystem refused a read while building a bundle from a directory. + #[error("cannot read {path}: {reason}")] + ReadBundle { + /// The path that could not be read. + path: String, + /// The operating system's reason. + reason: String, + }, + + /// An environment variable name was empty or contained only whitespace. + #[error("environment variable name must not be empty")] + EmptyEnvKey, + + /// A domain name was empty or contained only whitespace. + #[error("domain name must not be empty")] + EmptyDomain, + + /// An analytics window ended at or before it started. + #[error("analytics window must end after it starts")] + InvalidAnalyticsWindow, + + /// The request never reached the provider, or its response never arrived. + #[error("request to {provider} failed: {reason}")] + Transport { + /// The provider that was being called. + provider: String, + /// The transport-level reason. + reason: String, + }, + + /// The provider rejected the credential. + #[error("{provider} rejected the api key")] + Unauthorized { + /// The provider that rejected it. + provider: String, + }, + + /// The credential is valid but not permitted to touch the resource. + #[error("{provider} denied access to {resource}")] + Forbidden { + /// The provider that denied the request. + provider: String, + /// The resource that was denied, as this crate named it. + resource: String, + }, + + /// The provider has no such resource. + #[error("{resource} not found on {provider}")] + NotFound { + /// The provider that was queried. + provider: String, + /// The resource that was missing. + resource: String, + }, + + /// The provider is throttling this credential. + #[error("{provider} rate limited the request")] + RateLimited { + /// The provider that throttled the request. + provider: String, + }, + + /// The provider returned a failure this crate has no specific variant for. + #[error("{provider} returned {status} for {resource}: {message}")] + Api { + /// The provider that failed. + provider: String, + /// The HTTP status code. + status: u16, + /// The resource that was being fetched, as this crate named it. + resource: String, + /// The provider's own message, or its status text. + message: String, + }, + + /// A provider response did not match the shape this crate expects. + #[error("cannot decode the {provider} response for {resource}: {reason}")] + Decode { + /// The provider whose response could not be read. + provider: String, + /// The resource that was being fetched. + resource: String, + /// The deserialization error. + reason: String, + }, + + /// An alternate API root was given over plain HTTP to a non-loopback host. + /// + /// the implementation provider connector sends the account's bearer + /// credential to every request `base_url` produces, so an `http://` root + /// reaching outside the local machine would carry it in cleartext. + /// `https://` is always accepted; `http://` is accepted only against + /// `localhost`, `127.0.0.1`, or `::1`, which is what a test suite or a + /// local mock needs. + #[error("base url {base_url} must be https, or http against a loopback host")] + InsecureBaseUrl { + /// The rejected root, as it was supplied. + base_url: String, + }, + + /// A provider was named that this build does not have. + /// + /// Either the name is not a provider, or its Cargo feature is off. + #[error("unknown hosting provider {name}")] + UnknownProvider { + /// The name that was supplied. + name: String, + }, + + /// The provider cannot do something the unified API exposes. + /// + /// Not every host has every capability, and a stub that silently succeeds + /// is worse than a refusal that names what is missing. + #[error("{provider} cannot {capability}")] + Unsupported { + /// The provider that lacks the capability. + provider: String, + /// The capability, phrased to complete the sentence "cannot ...". + capability: String, + }, + + /// No installed integration on the account can provision this database. + /// + /// On Vercel a managed database comes from a marketplace integration, so + /// one has to be installed on the account before a store can be created. + #[error("no installed {provider} integration provides a {kind} database")] + NoDatabaseProduct { + /// The provider that was searched. + provider: String, + /// The requested database kind. + kind: String, + }, + + /// A database was created but did not come up. + #[error("database {name} was provisioned but reported status {status}")] + DatabaseNotReady { + /// The database's name. + name: String, + /// The status the provider reported. + status: String, + }, + + /// A JSON envelope crossing a process boundary could not be read. + #[error("cannot decode the request envelope: {reason}")] + Envelope { + /// The deserialization error. + reason: String, + }, +} + +/// The crate's standard result type. +/// +/// Use this alias in public signatures instead of spelling out +/// `std::result::Result`. +pub type Result = std::result::Result; diff --git a/crates/tinyhosts-bus/src/inputs.rs b/crates/tinyhosts-bus/src/inputs.rs new file mode 100644 index 0000000..54ec57f --- /dev/null +++ b/crates/tinyhosts-bus/src/inputs.rs @@ -0,0 +1,45 @@ +//! Serialized deployment input. Byte preparation and validation remain in the implementation. +use crate::model::{DatabaseSpec, DeploymentTarget, EnvVar, Framework, SiteSpec}; +use serde::{Deserialize, Serialize}; +/// A deployment file on the existing base64 JSON wire. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct BundleFile { + /// Relative, slash-separated destination path, validated by the module. + pub path: String, + /// Standard-base64 encoded content; encoding and decoding are implementation work. + pub contents: String, +} +/// Deployment request vocabulary, without filesystem or encoding behavior. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct DeploymentInput { + /// Site name or identifier. + pub site: String, + /// Build framework. + #[serde(default)] + pub framework: Framework, + /// Deployment environment. + #[serde(default)] + pub target: DeploymentTarget, + /// Authorized file contents, on the existing wire representation. + pub bundle: Vec, +} +/// Full launch request vocabulary; the compiled module owns deployment sequencing. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct LaunchInput { + /// Site configuration. + pub site: SiteSpec, + /// Authorized deployment files. + pub bundle: Vec, + /// Optional managed database. + #[serde(default)] + pub database: Option, + /// Environment values to install before deployment. + #[serde(default)] + pub env: Vec, + /// Custom domains to attach. + #[serde(default)] + pub domains: Vec, + /// Deployment environment. + #[serde(default)] + pub target: DeploymentTarget, +} diff --git a/crates/tinyhosts-bus/src/launch.rs b/crates/tinyhosts-bus/src/launch.rs new file mode 100644 index 0000000..10184eb --- /dev/null +++ b/crates/tinyhosts-bus/src/launch.rs @@ -0,0 +1,30 @@ +//! A completed hosting launch. +use crate::model::{Database, Deployment, Domain, Site}; +use serde::{Deserialize, Serialize}; +/// What a launch produced. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Launch { + /// The site the application lives on. + pub site: Site, + /// Whether this launch created the site, rather than finding it. + pub created_site: bool, + /// The database that was provisioned, when the plan asked for one. + #[serde(default)] + pub database: Option, + /// The environment variable names the database injected into the site. + #[serde(default)] + pub database_env_keys: Vec, + /// The domains that were attached. + #[serde(default)] + pub domains: Vec, + /// The deployment, which is usually still building. + pub deployment: Deployment, +} + +impl Launch { + /// The URL the application will serve from, once the deployment is ready. + #[must_use] + pub fn url(&self) -> Option<&str> { + self.deployment.url.as_deref() + } +} diff --git a/crates/tinyhosts-bus/src/lib.rs b/crates/tinyhosts-bus/src/lib.rs new file mode 100644 index 0000000..0219b8d --- /dev/null +++ b/crates/tinyhosts-bus/src/lib.rs @@ -0,0 +1,46 @@ +//! Minimal hosting vocabulary for hosts that execute through compiled TinyBus modules. +pub mod error; +pub mod inputs; +mod launch; +pub mod model; +pub mod rpc; +pub use error::{Error, Result}; +pub use launch::Launch; +pub use model::*; +/// Well-known hosting interface and bus name. +pub const BUS_NAME: &str = "ai.tinyhumans.tinyhosts.Hosting"; +/// Hosting object path. +pub const OBJECT_PATH: &str = "/ai/tinyhumans/tinyhosts/Hosting"; +/// Member names in their existing order and arity. +pub const METHODS: [&str; 2] = ["Execute", "Providers"]; +/// Contract package version, synchronized with the released module manifest. +pub const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); +/// Recorded agent tool declarations, including approval metadata. +pub const TOOL_DECLARATIONS_JSON: &str = include_str!("declarations.json"); + +/// Hosting provider identifiers, independent of provider connection behavior. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ProviderId { + /// Vercel hosting. + #[default] + Vercel, +} + +/// Agent-facing declaration, independent of the implementation Tool trait. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ToolDeclaration { + /// Stable tool name. + pub name: String, + /// Existing model-facing description. + pub description: String, + /// Existing JSON argument schema. + pub parameters_schema: serde_json::Value, + /// Whether OpenHuman must apply its external-effect approval policy. + pub external_effect: bool, +} + +#[cfg(test)] +#[path = "lib_tests.rs"] +mod tests; diff --git a/crates/tinyhosts-bus/src/lib_tests.rs b/crates/tinyhosts-bus/src/lib_tests.rs new file mode 100644 index 0000000..94c2100 --- /dev/null +++ b/crates/tinyhosts-bus/src/lib_tests.rs @@ -0,0 +1,45 @@ +//! Contract wire compatibility without any hosting implementation dependency. +use super::*; + +#[test] +fn requests_keep_provider_defaults_operation_tags_and_secret_redaction() { + let request: rpc::Request = serde_json::from_value(serde_json::json!({ + "operation": "list_sites", "credentials": {"api_key": "private-key"} + })) + .unwrap(); + assert_eq!(request.provider, ProviderId::Vercel); + assert!(matches!( + request.operation, + rpc::Operation::ListSites { limit: 20 } + )); + assert!(!format!("{request:?}").contains("private-key")); +} + +#[test] +fn deployment_bytes_keep_the_existing_base64_wire_shape() { + let operation: rpc::Operation = serde_json::from_value(serde_json::json!({ + "operation": "deploy", "request": { "site": "site", "bundle": [ + {"path": "index.html", "contents": "SGVsbG8="} + ] } + })) + .unwrap(); + let value = serde_json::to_value(operation).unwrap(); + assert_eq!(value["request"]["bundle"][0]["contents"], "SGVsbG8="); + assert_eq!(value["request"]["target"], "preview"); +} + +#[test] +fn tool_declarations_and_result_envelopes_keep_the_existing_wire() { + let tools: Vec = serde_json::from_str(TOOL_DECLARATIONS_JSON).unwrap(); + assert_eq!(tools.len(), 10); + assert_eq!(tools[0].name, "hosting_launch_site"); + assert_eq!(tools.iter().filter(|tool| tool.external_effect).count(), 4); + let result: rpc::Outcome = + serde_json::from_value(serde_json::json!({"result": "done"})).unwrap(); + assert_eq!( + serde_json::to_value(result).unwrap(), + serde_json::json!({"result": "done"}) + ); + assert_eq!(METHODS, ["Execute", "Providers"]); + assert_eq!(CONTRACT_VERSION, env!("CARGO_PKG_VERSION")); +} diff --git a/crates/tinyhosts-bus/src/model.rs b/crates/tinyhosts-bus/src/model.rs new file mode 100644 index 0000000..facd2a0 --- /dev/null +++ b/crates/tinyhosts-bus/src/model.rs @@ -0,0 +1,588 @@ +//! The provider-agnostic vocabulary every host is described in. +//! +//! These types are the standard: a site, a deployment of it, the environment it +//! reads, a managed database, a custom domain, and the traffic it served. A +//! provider adapter's whole job is translating its own API into them, so a +//! caller that can ship a Next.js application to one host can ship it to the +//! next without learning a second vocabulary. +//! +//! Records the provider produced ([`Site`], [`Deployment`], [`Database`]) carry +//! public fields and no invariants — they are whatever the provider said. +//! Requests the caller produces ([`SiteSpec`], [`EnvVar`], +//! [`AnalyticsQuery`]) carry a [`validate`](SiteSpec::validate) that the +//! adapter calls before spending a network round trip. Validation lives at the +//! point of use rather than in a constructor because every one of these types +//! also arrives by deserialization, where a constructor cannot intercept it. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::{Error, Result}; + +/// The framework a site is built with. +/// +/// The framework decides the build, so it is part of the site rather than of a +/// single deployment. [`Framework::NextJs`] is the default because it is what +/// this crate was built to ship. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum Framework { + /// A Next.js application, built by the provider from its source. + #[default] + NextJs, + /// Pre-built static files, served as they are. + Static, + /// Anything else, named the way the provider names it. + Other(String), +} + +impl Framework { + /// The provider-independent slug for this framework. + /// + /// It happens to match Vercel's `framework` values for the two named + /// variants, which is why [`Framework::Other`] passes through untouched. + #[must_use] + pub fn as_str(&self) -> &str { + match self { + Self::NextJs => "nextjs", + Self::Static => "static", + Self::Other(name) => name, + } + } +} + +/// Which environment a deployment serves. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DeploymentTarget { + /// A preview URL, not attached to the site's domains. + #[default] + Preview, + /// The live site, attached to every domain it has. + Production, +} + +impl DeploymentTarget { + /// The provider-independent slug for this target. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Preview => "preview", + Self::Production => "production", + } + } +} + +/// What the caller wants a site to be. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SiteSpec { + /// The site's name, unique within the account. + pub name: String, + /// The framework the provider should build it with. + #[serde(default)] + pub framework: Framework, +} + +impl SiteSpec { + /// A Next.js site called `name`. + #[must_use] + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + framework: Framework::NextJs, + } + } + + /// Builds the site with a different framework. + #[must_use] + pub fn with_framework(mut self, framework: Framework) -> Self { + self.framework = framework; + self + } + + /// Checks the spec before an adapter spends a network round trip on it. + /// + /// # Errors + /// + /// Returns [`Error::EmptySiteName`] when the name is blank. + pub fn validate(&self) -> Result<()> { + if self.name.trim().is_empty() { + return Err(Error::EmptySiteName); + } + Ok(()) + } +} + +/// A site that exists on a provider. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Site { + /// The provider's identifier for it. + pub id: String, + /// Its name, which is what the deployment API keys on. + pub name: String, + /// The framework the provider believes it is built with, when it says. + #[serde(default)] + pub framework: Option, + /// When it was created, in milliseconds since the Unix epoch. + #[serde(default)] + pub created_at_ms: Option, +} + +/// How far along a deployment is. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum DeploymentStatus { + /// Accepted, not started. + Queued, + /// Building. + Building, + /// Live and serving. + Ready, + /// The build or the upload failed. + Failed, + /// Cancelled before it finished. + Canceled, + /// A provider state this crate does not model, named as the provider named + /// it. Reported rather than mapped onto a state it may not mean. + Other(String), +} + +impl DeploymentStatus { + /// Whether the deployment has stopped changing. + /// + /// A poller stops here. [`DeploymentStatus::Other`] counts as non-terminal: + /// an unknown state is more likely a stage of the build than the end of it, + /// and a poller that gives up early reports a live site as a failure. + #[must_use] + pub const fn is_terminal(&self) -> bool { + matches!(self, Self::Ready | Self::Failed | Self::Canceled) + } + + /// Whether the deployment finished and is serving traffic. + #[must_use] + pub const fn is_ready(&self) -> bool { + matches!(self, Self::Ready) + } +} + +/// A deployment of a site. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Deployment { + /// The provider's identifier, which is what a status poll asks about. + pub id: String, + /// The site it belongs to, by name. + pub site: String, + /// Where it is served, as an absolute URL, once the provider assigns one. + #[serde(default)] + pub url: Option, + /// How far along it is. + pub status: DeploymentStatus, + /// Which environment it serves. + #[serde(default)] + pub target: DeploymentTarget, + /// When it was created, in milliseconds since the Unix epoch. + #[serde(default)] + pub created_at_ms: Option, + /// The provider's failure message, when it failed. + #[serde(default)] + pub error_message: Option, +} + +/// One build or deployment event a provider recorded for a deployment. +/// +/// Providers use different event names, so [`kind`](Self::kind) is preserved +/// rather than forced into a small enum. The message is the provider's +/// human-readable payload; it is not a request credential or environment +/// variable value. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeploymentLog { + /// When the provider recorded the event, in milliseconds since the Unix epoch. + #[serde(default)] + pub created_at_ms: Option, + /// The provider's event kind, such as `stdout`, `stderr`, or `error`. + pub kind: String, + /// The event's human-readable message. + pub message: String, +} + +/// An environment variable to set on a site. +/// +/// The value is write-only across this API: it goes out in a request and is +/// never returned, because a provider that hands back decrypted secrets on a +/// list call is a provider this crate would be leaking through. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnvVar { + /// The variable's name. + pub key: String, + /// Its value. + pub value: String, + /// The environments it applies to. Empty means every environment. + #[serde(default)] + pub targets: Vec, + /// Whether the provider should store it write-only. + #[serde(default)] + pub secret: bool, +} + +/// Prints the key and targets, never the value. +/// +/// `EnvVar` reaches [`Operation::SetEnv`](crate::rpc::Operation::SetEnv) and +/// launch plans, both of which derive +/// `Debug`; a derived `Debug` here would put a secret's plaintext value in +/// whatever log line renders one of those. +impl std::fmt::Debug for EnvVar { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("EnvVar") + .field("key", &self.key) + .field("value", &"") + .field("targets", &self.targets) + .field("secret", &self.secret) + .finish() + } +} + +impl EnvVar { + /// A variable set in every environment. + #[must_use] + pub fn new(key: impl Into, value: impl Into) -> Self { + Self { + key: key.into(), + value: value.into(), + targets: Vec::new(), + secret: false, + } + } + + /// Restricts the variable to `targets`. + #[must_use] + pub fn with_targets(mut self, targets: Vec) -> Self { + self.targets = targets; + self + } + + /// Marks the variable as a secret the provider should not read back. + #[must_use] + pub fn secret(mut self) -> Self { + self.secret = true; + self + } + + /// Checks the variable before it is sent. + /// + /// # Errors + /// + /// Returns [`Error::EmptyEnvKey`] when the name is blank. + pub fn validate(&self) -> Result<()> { + if self.key.trim().is_empty() { + return Err(Error::EmptyEnvKey); + } + Ok(()) + } +} + +/// An environment variable that exists on a site, without its value. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnvVarRecord { + /// The provider's identifier for it. + pub id: String, + /// The variable's name. + pub key: String, + /// The environments it applies to. + #[serde(default)] + pub targets: Vec, + /// Whether the provider stores it write-only. + #[serde(default)] + pub secret: bool, +} + +/// The kind of managed database to provision. +/// +/// A kind is a protocol, not a product: which vendor supplies a Postgres is the +/// provider's business, and on Vercel it depends on which marketplace +/// integration the account has installed. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum DatabaseKind { + /// A Postgres database. The default: it is what a Next.js application with + /// an ORM expects to find. + #[default] + Postgres, + /// A Redis-compatible key-value store. + Redis, + /// Blob or object storage. + Blob, + /// Another protocol, matched against the provider's product names. + Other(String), +} + +impl DatabaseKind { + /// The provider-independent slug for this kind. + #[must_use] + pub fn as_str(&self) -> &str { + match self { + Self::Postgres => "postgres", + Self::Redis => "redis", + Self::Blob => "blob", + Self::Other(name) => name, + } + } + + /// Product-name fragments that identify this kind on a provider. + /// + /// A managed Postgres is rarely called "postgres" in a catalogue — it is + /// Neon, or Supabase, or Prisma. Matching a kind to a product means + /// matching against the names vendors actually use. + #[must_use] + pub fn product_hints(&self) -> Vec<&str> { + match self { + Self::Postgres => vec![ + "postgres", + "neon", + "supabase", + "prisma-postgres", + "timescale", + ], + Self::Redis => vec!["redis", "upstash", "kv", "valkey"], + Self::Blob => vec!["blob", "storage", "bucket", "s3"], + Self::Other(name) => vec![name], + } + } +} + +/// What the caller wants a database to be. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DatabaseSpec { + /// The database's name within the account. + pub name: String, + /// The kind of database. + #[serde(default)] + pub kind: DatabaseKind, + /// A specific provider product to use, overriding the kind's matching. + /// + /// Set this when an account has several products that could serve the kind + /// and the choice matters — it is the only escape hatch from + /// [`DatabaseKind::product_hints`]. + #[serde(default)] + pub product: Option, +} + +impl DatabaseSpec { + /// A Postgres database called `name`. + #[must_use] + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + kind: DatabaseKind::Postgres, + product: None, + } + } + + /// Provisions a different kind of database. + #[must_use] + pub fn with_kind(mut self, kind: DatabaseKind) -> Self { + self.kind = kind; + self + } + + /// Pins the provider product instead of matching on the kind. + #[must_use] + pub fn with_product(mut self, product: impl Into) -> Self { + self.product = Some(product.into()); + self + } + + /// Checks the spec before an adapter provisions anything. + /// + /// # Errors + /// + /// Returns [`Error::EmptySiteName`] when the name is blank. A database and + /// a site share the rule and the variant: both are named resources on the + /// account. + pub fn validate(&self) -> Result<()> { + if self.name.trim().is_empty() { + return Err(Error::EmptySiteName); + } + Ok(()) + } +} + +/// A managed database that exists on a provider. +/// +/// `secret_keys` names the environment variables a connected site receives — +/// `DATABASE_URL` and friends — without their values. The values are the +/// provider's to inject; this crate never holds a connection string. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Database { + /// The provider's identifier for the database. + pub id: String, + /// Its name. + pub name: String, + /// The kind it serves. + pub kind: DatabaseKind, + /// The provider product behind it, when the provider names one. + #[serde(default)] + pub product: Option, + /// The provider's status for it, verbatim. + pub status: String, + /// The names of the environment variables a connected site receives. + #[serde(default)] + pub secret_keys: Vec, + /// The scope a later connection call needs, when the provider requires one. + /// + /// On Vercel this is the marketplace installation the store belongs to; + /// connecting the store to a project needs both identifiers. + #[serde(default)] + pub installation_id: Option, +} + +/// A custom domain on a site. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Domain { + /// The domain name. + pub name: String, + /// The site it points at. + pub site: String, + /// Whether the provider has verified ownership. + pub verified: bool, +} + +/// A dimension to break analytics down by. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum AnalyticsDimension { + /// The visitor's country. + Country, + /// Desktop, mobile, or tablet. + DeviceType, + /// The requested path. + RequestPath, + /// The referring host. + ReferrerHostname, + /// The visitor's browser. + BrowserName, + /// The visitor's operating system. + OsName, + /// The matched application route. + Route, +} + +impl AnalyticsDimension { + /// The provider-independent slug for this dimension. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Country => "country", + Self::DeviceType => "deviceType", + Self::RequestPath => "requestPath", + Self::ReferrerHostname => "referrerHostname", + Self::BrowserName => "browserName", + Self::OsName => "osName", + Self::Route => "route", + } + } +} + +/// A window of traffic to report on. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AnalyticsQuery { + /// The site to report on, by name or identifier. + pub site: String, + /// The start of the window, in milliseconds since the Unix epoch. + pub since_ms: u64, + /// The end of the window, in milliseconds since the Unix epoch. + pub until_ms: u64, + /// A dimension to break the totals down by, when one is wanted. + #[serde(default)] + pub breakdown: Option, + /// How many rows the breakdown may return. + #[serde(default = "default_analytics_limit")] + pub limit: u32, +} + +const fn default_analytics_limit() -> u32 { + 10 +} + +impl AnalyticsQuery { + /// A window between two epoch-millisecond timestamps. + #[must_use] + pub fn new(site: impl Into, since_ms: u64, until_ms: u64) -> Self { + Self { + site: site.into(), + since_ms, + until_ms, + breakdown: None, + limit: default_analytics_limit(), + } + } + + /// Breaks the totals down by `dimension`. + #[must_use] + pub fn with_breakdown(mut self, dimension: AnalyticsDimension) -> Self { + self.breakdown = Some(dimension); + self + } + + /// Returns at most `limit` breakdown rows. + #[must_use] + pub fn with_limit(mut self, limit: u32) -> Self { + self.limit = limit; + self + } + + /// Checks the query before an adapter sends it. + /// + /// # Errors + /// + /// Returns [`Error::EmptySiteName`] when the site is blank, or + /// [`Error::InvalidAnalyticsWindow`] when the window does not move forward. + pub fn validate(&self) -> Result<()> { + if self.site.trim().is_empty() { + return Err(Error::EmptySiteName); + } + if self.until_ms <= self.since_ms { + return Err(Error::InvalidAnalyticsWindow); + } + Ok(()) + } +} + +/// One row of an analytics breakdown. +/// +/// `metrics` holds whatever numbers the provider returned for the row rather +/// than a fixed pair of fields. Providers do not agree on what they count, and +/// a struct with `pageviews` and `visitors` would either drop a provider's +/// numbers or invent them. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AnalyticsBucket { + /// The dimension value this row is for. + pub label: String, + /// The metrics the provider reported for it. + pub metrics: BTreeMap, +} + +/// Traffic a site served over a window. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AnalyticsSummary { + /// The site the numbers are for. + pub site: String, + /// The window's start, in milliseconds since the Unix epoch. + pub since_ms: u64, + /// The window's end, in milliseconds since the Unix epoch. + pub until_ms: u64, + /// Distinct visitors, when the provider counts them. + #[serde(default)] + pub visitors: Option, + /// Page views, when the provider counts them. + #[serde(default)] + pub pageviews: Option, + /// The requested breakdown, when one was asked for. + #[serde(default)] + pub breakdown: Vec, +} diff --git a/crates/tinyhosts-bus/src/rpc.rs b/crates/tinyhosts-bus/src/rpc.rs new file mode 100644 index 0000000..d9868bb --- /dev/null +++ b/crates/tinyhosts-bus/src/rpc.rs @@ -0,0 +1,205 @@ +//! Existing hosting JSON envelopes, independent of providers and transport. +use crate::Launch; +use crate::ProviderId; +use crate::model::*; +use serde::{Deserialize, Serialize}; +/// A request to act on one hosting account. +#[derive(Debug, Deserialize)] +#[serde(bound( + deserialize = "C: Deserialize<'de>, P: Deserialize<'de>, D: Deserialize<'de>, K: Deserialize<'de> + Default" +))] +pub struct Request< + C = Credentials, + P = crate::inputs::LaunchInput, + D = crate::inputs::DeploymentInput, + K = ProviderId, +> { + /// Which provider to act on. Defaults to the implementation default provider. + #[serde(default)] + pub provider: K, + /// The account's credential. Omitted, it is read from the environment. + #[serde(default)] + pub credentials: Option, + /// An alternate API root for the provider. + /// + /// Set it when the provider is reached through an egress proxy. Omitted, the + /// provider's own root is used. + #[serde(default)] + pub base_url: Option, + /// What to do. + #[serde(flatten)] + pub operation: Operation, +} + +/// One thing a request can ask for. +/// +/// The variants are exactly the implementation hosting interface surface plus +/// launch operation, so the bus exposes no more authority than the +/// library does. +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "operation", rename_all = "snake_case")] +#[non_exhaustive] +pub enum Operation

{ + /// Run a whole launch: site, database, environment, domains, deployment. + Launch { + /// The plan to run. + /// + /// Boxed because it carries a whole bundle: unboxed, every other + /// variant of this enum would be as large as an application. + plan: Box

, + }, + /// Create a site. + CreateSite { + /// What the site should be. + spec: SiteSpec, + }, + /// Find a site by name, or report that there is none. + FindSite { + /// The site's name or identifier. + site: String, + }, + /// List sites, newest first. + ListSites { + /// How many to return. + #[serde(default = "default_limit")] + limit: u32, + }, + /// Set environment variables on a site. + SetEnv { + /// The site's name or identifier. + site: String, + /// The variables to set. + vars: Vec, + }, + /// List a site's environment variables, without their values. + ListEnv { + /// The site's name or identifier. + site: String, + }, + /// Provision a managed database. + ProvisionDatabase { + /// What the database should be. + spec: DatabaseSpec, + }, + /// Connect a database to a site. + AttachDatabase { + /// The database, as [`Operation::ProvisionDatabase`] returned it. + database: Database, + /// The site's name or identifier. + site: String, + }, + /// Upload a bundle and start a deployment. + Deploy { + /// The deployment to start. Boxed for the same reason as + /// [`Operation::Launch`]'s plan. + request: Box, + }, + /// Read a deployment's current state. + Deployment { + /// The deployment's identifier. + id: String, + }, + /// List a site's deployments, newest first. + ListDeployments { + /// The site's name or identifier. + site: String, + /// How many to return. + #[serde(default = "default_limit")] + limit: u32, + }, + /// List a deployment's build and deployment events, oldest first. + DeploymentLogs { + /// The deployment's identifier. + id: String, + }, + /// Point production traffic at an existing deployment. + Promote { + /// The site's name or identifier. + site: String, + /// The deployment's identifier. + deployment: String, + }, + /// Add a custom domain to a site. + AddDomain { + /// The site's name or identifier. + site: String, + /// The domain to add. + domain: String, + }, + /// List a site's domains. + ListDomains { + /// The site's name or identifier. + site: String, + }, + /// Report the traffic a site served. + Analytics { + /// The window to report on. + query: AnalyticsQuery, + }, +} + +const fn default_limit() -> u32 { + 20 +} + +/// What an operation produced. +/// +/// The envelope is adjacently tagged — `{"result": "...", "value": ...}` — so a +/// list result and a record result have the same shape on the wire, and a reader +/// can dispatch on one field. +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "result", content = "value", rename_all = "snake_case")] +#[non_exhaustive] +pub enum Outcome { + /// A completed launch. + /// + /// Boxed because it carries a whole site, database and deployment: unboxed, + /// every other variant would be as large as the largest one. + Launch(Box), + /// One site. + Site(Site), + /// A site that does not exist. + NoSite, + /// Several sites. + Sites(Vec), + /// One deployment. + Deployment(Deployment), + /// Several deployments. + Deployments(Vec), + /// A deployment's build and deployment events. + DeploymentLogs(Vec), + /// A site's environment variables, without their values. + Env(Vec), + /// One database. + Database(Database), + /// The environment variable names a database injected. + EnvKeys(Vec), + /// One domain. + Domain(Domain), + /// Several domains. + Domains(Vec), + /// A traffic report. + Analytics(AnalyticsSummary), + /// An operation that produced nothing but succeeded. + Done, +} + +/// Deserialize-only credential input; never serialized into results. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Credentials { + /// Provider API key, consumed only by module-side credential validation. + pub api_key: String, + /// Optional team identifier. + #[serde(default)] + pub team: Option, +} +impl std::fmt::Debug for Credentials { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Credentials") + .field("api_key", &"") + .field("team", &self.team) + .finish() + } +} diff --git a/docs/specs/minimal-bus-contract.md b/docs/specs/minimal-bus-contract.md new file mode 100644 index 0000000..34e591d --- /dev/null +++ b/docs/specs/minimal-bus-contract.md @@ -0,0 +1,24 @@ +# Minimal hosting contract + +Hosts execute hosting operations in the compiled TinyHosts artifact via the +existing `Execute(String) -> String` and `Providers() -> String` members. No +member arity, request tag, result tag, credential input, tool name, or schema +changes. Shared vocabulary lives in `tinyhosts-bus`, with compatibility +re-exports in the library. Provider transport, deployment order, bundle +collection, base64 encoding, and credential validation stay in the implementation. + +The pure RPC Operation enum has generic plan and deployment payload slots. +The library aliases bind these to validated LaunchPlan/DeployRequest; bus hosts +use the data-only LaunchInput/DeploymentInput. Both serialize identically. +Module deserialization still rejects unsafe bundle paths before provider I/O. +Credential fields remain deserialize-only, and their Debug representation is +redacted. Hosts pass credential envelopes through confidential bus calls. + +Recorded tool declarations include external-effect metadata for host approvals. +The host validates authorized workspace input before transferring files; the +module owns deployment sequencing and provider requests. Unavailable modules +fail the affected operation without a linked implementation fallback. + +The contract package version is synchronized with the module release. Consume +this owner change only after upstream availability and pin released artifacts +with verified digests; never represent a local build as a release. diff --git a/examples/verify_module.rs b/examples/verify_module.rs index 87d34da..96594b7 100644 --- a/examples/verify_module.rs +++ b/examples/verify_module.rs @@ -50,6 +50,31 @@ async fn main() -> Result<(), Box> { ); } + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/v10/projects")) + .respond_with( + wiremock::ResponseTemplate::new(200).set_body_json( + serde_json::json!({"projects": [{"id": "fixture", "name": "fixture"}]}), + ), + ) + .mount(&server) + .await; + let operation: tinyhosts_bus::rpc::Operation = + tinyhosts_bus::rpc::Operation::ListSites { limit: 20 }; + let mut request = serde_json::to_value(operation)?; + request["credentials"] = serde_json::json!({"api_key":"local-fixture"}); + request["base_url"] = serde_json::json!(server.uri()); + let response: String = proxy.call("Execute", (request.to_string(),)).await?; + let outcome: tinyhosts_bus::rpc::Outcome = serde_json::from_str(&response)?; + match outcome { + tinyhosts_bus::rpc::Outcome::Sites(sites) + if sites.len() == 1 && sites[0].id == "fixture" => {} + _ => { + return Err(io::Error::other("compiled module did not return the fixture site").into()); + } + } + println!( "verified {} as TinyBus module `{}`", module.display(), diff --git a/src/error/mod.rs b/src/error/mod.rs index 527714a..b97d30f 100644 --- a/src/error/mod.rs +++ b/src/error/mod.rs @@ -1,210 +1,5 @@ -//! Crate-wide error and result types. -//! -//! Every fallible public function in this crate returns [`Result`], and every -//! failure mode is a distinct [`Error`] variant. Add a variant rather than -//! encoding new context into an existing message: callers match on variants, -//! and message text is not a stable API. -//! -//! Variants carry the data a caller needs to react, keep their `#[error]` -//! message lowercase and free of trailing punctuation, and are documented so -//! the rendered rustdoc explains when each one occurs. -//! -//! Provider failures name the provider they came from. A run may talk to more -//! than one host in the same process, and "unauthorized" is not actionable -//! until the reader knows which credential was rejected. - -/// Errors returned by this crate. -#[derive(Debug, thiserror::Error, PartialEq, Eq)] -#[non_exhaustive] -pub enum Error { - /// An API key was empty or contained only whitespace. - #[error("api key must not be empty")] - EmptyApiKey, - - /// No API key was found in the environment for a provider. - /// - /// `variables` lists every name that was searched, in order. - #[error("no api key for {provider}: set one of {variables}")] - MissingApiKey { - /// The provider whose credential is missing. - provider: String, - /// The environment variable names that were searched, comma separated. - variables: String, - }, - - /// A site name was empty or contained only whitespace. - #[error("site name must not be empty")] - EmptySiteName, - - /// A deployment was requested with no files in it. - #[error("deployment bundle contains no files")] - EmptyBundle, - - /// A bundle entry named a path that cannot be deployed. - /// - /// Bundle paths are relative, slash separated, and may not traverse out of - /// the bundle root. - #[error("bundle path {path} is not a relative path inside the bundle")] - InvalidBundlePath { - /// The rejected path, as it was supplied. - path: String, - }, - - /// The filesystem refused a read while building a bundle from a directory. - #[error("cannot read {path}: {reason}")] - ReadBundle { - /// The path that could not be read. - path: String, - /// The operating system's reason. - reason: String, - }, - - /// An environment variable name was empty or contained only whitespace. - #[error("environment variable name must not be empty")] - EmptyEnvKey, - - /// A domain name was empty or contained only whitespace. - #[error("domain name must not be empty")] - EmptyDomain, - - /// An analytics window ended at or before it started. - #[error("analytics window must end after it starts")] - InvalidAnalyticsWindow, - - /// The request never reached the provider, or its response never arrived. - #[error("request to {provider} failed: {reason}")] - Transport { - /// The provider that was being called. - provider: String, - /// The transport-level reason. - reason: String, - }, - - /// The provider rejected the credential. - #[error("{provider} rejected the api key")] - Unauthorized { - /// The provider that rejected it. - provider: String, - }, - - /// The credential is valid but not permitted to touch the resource. - #[error("{provider} denied access to {resource}")] - Forbidden { - /// The provider that denied the request. - provider: String, - /// The resource that was denied, as this crate named it. - resource: String, - }, - - /// The provider has no such resource. - #[error("{resource} not found on {provider}")] - NotFound { - /// The provider that was queried. - provider: String, - /// The resource that was missing. - resource: String, - }, - - /// The provider is throttling this credential. - #[error("{provider} rate limited the request")] - RateLimited { - /// The provider that throttled the request. - provider: String, - }, - - /// The provider returned a failure this crate has no specific variant for. - #[error("{provider} returned {status} for {resource}: {message}")] - Api { - /// The provider that failed. - provider: String, - /// The HTTP status code. - status: u16, - /// The resource that was being fetched, as this crate named it. - resource: String, - /// The provider's own message, or its status text. - message: String, - }, - - /// A provider response did not match the shape this crate expects. - #[error("cannot decode the {provider} response for {resource}: {reason}")] - Decode { - /// The provider whose response could not be read. - provider: String, - /// The resource that was being fetched. - resource: String, - /// The deserialization error. - reason: String, - }, - - /// An alternate API root was given over plain HTTP to a non-loopback host. - /// - /// [`connect_to`](crate::providers::connect_to) sends the account's bearer - /// credential to every request `base_url` produces, so an `http://` root - /// reaching outside the local machine would carry it in cleartext. - /// `https://` is always accepted; `http://` is accepted only against - /// `localhost`, `127.0.0.1`, or `::1`, which is what a test suite or a - /// local mock needs. - #[error("base url {base_url} must be https, or http against a loopback host")] - InsecureBaseUrl { - /// The rejected root, as it was supplied. - base_url: String, - }, - - /// A provider was named that this build does not have. - /// - /// Either the name is not a provider, or its Cargo feature is off. - #[error("unknown hosting provider {name}")] - UnknownProvider { - /// The name that was supplied. - name: String, - }, - - /// The provider cannot do something the unified API exposes. - /// - /// Not every host has every capability, and a stub that silently succeeds - /// is worse than a refusal that names what is missing. - #[error("{provider} cannot {capability}")] - Unsupported { - /// The provider that lacks the capability. - provider: String, - /// The capability, phrased to complete the sentence "cannot ...". - capability: String, - }, - - /// No installed integration on the account can provision this database. - /// - /// On Vercel a managed database comes from a marketplace integration, so - /// one has to be installed on the account before a store can be created. - #[error("no installed {provider} integration provides a {kind} database")] - NoDatabaseProduct { - /// The provider that was searched. - provider: String, - /// The requested database kind. - kind: String, - }, - - /// A database was created but did not come up. - #[error("database {name} was provisioned but reported status {status}")] - DatabaseNotReady { - /// The database's name. - name: String, - /// The status the provider reported. - status: String, - }, - - /// A JSON envelope crossing a process boundary could not be read. - #[error("cannot decode the request envelope: {reason}")] - Envelope { - /// The deserialization error. - reason: String, - }, -} - -/// The crate's standard result type. -/// -/// Use this alias in public signatures instead of spelling out -/// `std::result::Result`. -pub type Result = std::result::Result; +//! Compatibility re-exports of the shared hosting error vocabulary. +pub use tinyhosts_bus::error::{Error, Result}; #[cfg(test)] #[path = "mod_tests.rs"] diff --git a/src/host/types.rs b/src/host/types.rs index 2025e6d..123ae00 100644 --- a/src/host/types.rs +++ b/src/host/types.rs @@ -1,135 +1,8 @@ -//! The provider-agnostic vocabulary every host is described in. -//! -//! These types are the standard: a site, a deployment of it, the environment it -//! reads, a managed database, a custom domain, and the traffic it served. A -//! provider adapter's whole job is translating its own API into them, so a -//! caller that can ship a Next.js application to one host can ship it to the -//! next without learning a second vocabulary. -//! -//! Records the provider produced ([`Site`], [`Deployment`], [`Database`]) carry -//! public fields and no invariants — they are whatever the provider said. -//! Requests the caller produces ([`SiteSpec`], [`DeployRequest`], [`EnvVar`], -//! [`AnalyticsQuery`]) carry a [`validate`](SiteSpec::validate) that the -//! adapter calls before spending a network round trip. Validation lives at the -//! point of use rather than in a constructor because every one of these types -//! also arrives by deserialization, where a constructor cannot intercept it. - -use std::collections::BTreeMap; - -use serde::{Deserialize, Serialize}; - +//! Hosting vocabulary shared with the pure bus contract. use crate::bundle::Bundle; use crate::{Error, Result}; - -/// The framework a site is built with. -/// -/// The framework decides the build, so it is part of the site rather than of a -/// single deployment. [`Framework::NextJs`] is the default because it is what -/// this crate was built to ship. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum Framework { - /// A Next.js application, built by the provider from its source. - #[default] - NextJs, - /// Pre-built static files, served as they are. - Static, - /// Anything else, named the way the provider names it. - Other(String), -} - -impl Framework { - /// The provider-independent slug for this framework. - /// - /// It happens to match Vercel's `framework` values for the two named - /// variants, which is why [`Framework::Other`] passes through untouched. - #[must_use] - pub fn as_str(&self) -> &str { - match self { - Self::NextJs => "nextjs", - Self::Static => "static", - Self::Other(name) => name, - } - } -} - -/// Which environment a deployment serves. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum DeploymentTarget { - /// A preview URL, not attached to the site's domains. - #[default] - Preview, - /// The live site, attached to every domain it has. - Production, -} - -impl DeploymentTarget { - /// The provider-independent slug for this target. - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Preview => "preview", - Self::Production => "production", - } - } -} - -/// What the caller wants a site to be. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct SiteSpec { - /// The site's name, unique within the account. - pub name: String, - /// The framework the provider should build it with. - #[serde(default)] - pub framework: Framework, -} - -impl SiteSpec { - /// A Next.js site called `name`. - #[must_use] - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - framework: Framework::NextJs, - } - } - - /// Builds the site with a different framework. - #[must_use] - pub fn with_framework(mut self, framework: Framework) -> Self { - self.framework = framework; - self - } - - /// Checks the spec before an adapter spends a network round trip on it. - /// - /// # Errors - /// - /// Returns [`Error::EmptySiteName`] when the name is blank. - pub fn validate(&self) -> Result<()> { - if self.name.trim().is_empty() { - return Err(Error::EmptySiteName); - } - Ok(()) - } -} - -/// A site that exists on a provider. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct Site { - /// The provider's identifier for it. - pub id: String, - /// Its name, which is what the deployment API keys on. - pub name: String, - /// The framework the provider believes it is built with, when it says. - #[serde(default)] - pub framework: Option, - /// When it was created, in milliseconds since the Unix epoch. - #[serde(default)] - pub created_at_ms: Option, -} +use serde::{Deserialize, Serialize}; +pub use tinyhosts_bus::model::*; /// A request to deploy a bundle of files as a site. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -188,460 +61,3 @@ impl DeployRequest { Ok(()) } } - -/// How far along a deployment is. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum DeploymentStatus { - /// Accepted, not started. - Queued, - /// Building. - Building, - /// Live and serving. - Ready, - /// The build or the upload failed. - Failed, - /// Cancelled before it finished. - Canceled, - /// A provider state this crate does not model, named as the provider named - /// it. Reported rather than mapped onto a state it may not mean. - Other(String), -} - -impl DeploymentStatus { - /// Whether the deployment has stopped changing. - /// - /// A poller stops here. [`DeploymentStatus::Other`] counts as non-terminal: - /// an unknown state is more likely a stage of the build than the end of it, - /// and a poller that gives up early reports a live site as a failure. - #[must_use] - pub const fn is_terminal(&self) -> bool { - matches!(self, Self::Ready | Self::Failed | Self::Canceled) - } - - /// Whether the deployment finished and is serving traffic. - #[must_use] - pub const fn is_ready(&self) -> bool { - matches!(self, Self::Ready) - } -} - -/// A deployment of a site. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct Deployment { - /// The provider's identifier, which is what a status poll asks about. - pub id: String, - /// The site it belongs to, by name. - pub site: String, - /// Where it is served, as an absolute URL, once the provider assigns one. - #[serde(default)] - pub url: Option, - /// How far along it is. - pub status: DeploymentStatus, - /// Which environment it serves. - #[serde(default)] - pub target: DeploymentTarget, - /// When it was created, in milliseconds since the Unix epoch. - #[serde(default)] - pub created_at_ms: Option, - /// The provider's failure message, when it failed. - #[serde(default)] - pub error_message: Option, -} - -/// One build or deployment event a provider recorded for a deployment. -/// -/// Providers use different event names, so [`kind`](Self::kind) is preserved -/// rather than forced into a small enum. The message is the provider's -/// human-readable payload; it is not a request credential or environment -/// variable value. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct DeploymentLog { - /// When the provider recorded the event, in milliseconds since the Unix epoch. - #[serde(default)] - pub created_at_ms: Option, - /// The provider's event kind, such as `stdout`, `stderr`, or `error`. - pub kind: String, - /// The event's human-readable message. - pub message: String, -} - -/// An environment variable to set on a site. -/// -/// The value is write-only across this API: it goes out in a request and is -/// never returned, because a provider that hands back decrypted secrets on a -/// list call is a provider this crate would be leaking through. -#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct EnvVar { - /// The variable's name. - pub key: String, - /// Its value. - pub value: String, - /// The environments it applies to. Empty means every environment. - #[serde(default)] - pub targets: Vec, - /// Whether the provider should store it write-only. - #[serde(default)] - pub secret: bool, -} - -/// Prints the key and targets, never the value. -/// -/// `EnvVar` reaches [`Operation::SetEnv`](crate::rpc::Operation::SetEnv) and -/// [`LaunchPlan`](crate::launch::types::LaunchPlan), both of which derive -/// `Debug`; a derived `Debug` here would put a secret's plaintext value in -/// whatever log line renders one of those. -impl std::fmt::Debug for EnvVar { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("EnvVar") - .field("key", &self.key) - .field("value", &"") - .field("targets", &self.targets) - .field("secret", &self.secret) - .finish() - } -} - -impl EnvVar { - /// A variable set in every environment. - #[must_use] - pub fn new(key: impl Into, value: impl Into) -> Self { - Self { - key: key.into(), - value: value.into(), - targets: Vec::new(), - secret: false, - } - } - - /// Restricts the variable to `targets`. - #[must_use] - pub fn with_targets(mut self, targets: Vec) -> Self { - self.targets = targets; - self - } - - /// Marks the variable as a secret the provider should not read back. - #[must_use] - pub fn secret(mut self) -> Self { - self.secret = true; - self - } - - /// Checks the variable before it is sent. - /// - /// # Errors - /// - /// Returns [`Error::EmptyEnvKey`] when the name is blank. - pub fn validate(&self) -> Result<()> { - if self.key.trim().is_empty() { - return Err(Error::EmptyEnvKey); - } - Ok(()) - } -} - -/// An environment variable that exists on a site, without its value. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct EnvVarRecord { - /// The provider's identifier for it. - pub id: String, - /// The variable's name. - pub key: String, - /// The environments it applies to. - #[serde(default)] - pub targets: Vec, - /// Whether the provider stores it write-only. - #[serde(default)] - pub secret: bool, -} - -/// The kind of managed database to provision. -/// -/// A kind is a protocol, not a product: which vendor supplies a Postgres is the -/// provider's business, and on Vercel it depends on which marketplace -/// integration the account has installed. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum DatabaseKind { - /// A Postgres database. The default: it is what a Next.js application with - /// an ORM expects to find. - #[default] - Postgres, - /// A Redis-compatible key-value store. - Redis, - /// Blob or object storage. - Blob, - /// Another protocol, matched against the provider's product names. - Other(String), -} - -impl DatabaseKind { - /// The provider-independent slug for this kind. - #[must_use] - pub fn as_str(&self) -> &str { - match self { - Self::Postgres => "postgres", - Self::Redis => "redis", - Self::Blob => "blob", - Self::Other(name) => name, - } - } - - /// Product-name fragments that identify this kind on a provider. - /// - /// A managed Postgres is rarely called "postgres" in a catalogue — it is - /// Neon, or Supabase, or Prisma. Matching a kind to a product means - /// matching against the names vendors actually use. - #[must_use] - pub fn product_hints(&self) -> Vec<&str> { - match self { - Self::Postgres => vec![ - "postgres", - "neon", - "supabase", - "prisma-postgres", - "timescale", - ], - Self::Redis => vec!["redis", "upstash", "kv", "valkey"], - Self::Blob => vec!["blob", "storage", "bucket", "s3"], - Self::Other(name) => vec![name], - } - } -} - -/// What the caller wants a database to be. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct DatabaseSpec { - /// The database's name within the account. - pub name: String, - /// The kind of database. - #[serde(default)] - pub kind: DatabaseKind, - /// A specific provider product to use, overriding the kind's matching. - /// - /// Set this when an account has several products that could serve the kind - /// and the choice matters — it is the only escape hatch from - /// [`DatabaseKind::product_hints`]. - #[serde(default)] - pub product: Option, -} - -impl DatabaseSpec { - /// A Postgres database called `name`. - #[must_use] - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - kind: DatabaseKind::Postgres, - product: None, - } - } - - /// Provisions a different kind of database. - #[must_use] - pub fn with_kind(mut self, kind: DatabaseKind) -> Self { - self.kind = kind; - self - } - - /// Pins the provider product instead of matching on the kind. - #[must_use] - pub fn with_product(mut self, product: impl Into) -> Self { - self.product = Some(product.into()); - self - } - - /// Checks the spec before an adapter provisions anything. - /// - /// # Errors - /// - /// Returns [`Error::EmptySiteName`] when the name is blank. A database and - /// a site share the rule and the variant: both are named resources on the - /// account. - pub fn validate(&self) -> Result<()> { - if self.name.trim().is_empty() { - return Err(Error::EmptySiteName); - } - Ok(()) - } -} - -/// A managed database that exists on a provider. -/// -/// `secret_keys` names the environment variables a connected site receives — -/// `DATABASE_URL` and friends — without their values. The values are the -/// provider's to inject; this crate never holds a connection string. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct Database { - /// The provider's identifier for the database. - pub id: String, - /// Its name. - pub name: String, - /// The kind it serves. - pub kind: DatabaseKind, - /// The provider product behind it, when the provider names one. - #[serde(default)] - pub product: Option, - /// The provider's status for it, verbatim. - pub status: String, - /// The names of the environment variables a connected site receives. - #[serde(default)] - pub secret_keys: Vec, - /// The scope a later connection call needs, when the provider requires one. - /// - /// On Vercel this is the marketplace installation the store belongs to; - /// connecting the store to a project needs both identifiers. - #[serde(default)] - pub installation_id: Option, -} - -/// A custom domain on a site. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct Domain { - /// The domain name. - pub name: String, - /// The site it points at. - pub site: String, - /// Whether the provider has verified ownership. - pub verified: bool, -} - -/// A dimension to break analytics down by. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum AnalyticsDimension { - /// The visitor's country. - Country, - /// Desktop, mobile, or tablet. - DeviceType, - /// The requested path. - RequestPath, - /// The referring host. - ReferrerHostname, - /// The visitor's browser. - BrowserName, - /// The visitor's operating system. - OsName, - /// The matched application route. - Route, -} - -impl AnalyticsDimension { - /// The provider-independent slug for this dimension. - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Country => "country", - Self::DeviceType => "deviceType", - Self::RequestPath => "requestPath", - Self::ReferrerHostname => "referrerHostname", - Self::BrowserName => "browserName", - Self::OsName => "osName", - Self::Route => "route", - } - } -} - -/// A window of traffic to report on. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct AnalyticsQuery { - /// The site to report on, by name or identifier. - pub site: String, - /// The start of the window, in milliseconds since the Unix epoch. - pub since_ms: u64, - /// The end of the window, in milliseconds since the Unix epoch. - pub until_ms: u64, - /// A dimension to break the totals down by, when one is wanted. - #[serde(default)] - pub breakdown: Option, - /// How many rows the breakdown may return. - #[serde(default = "default_analytics_limit")] - pub limit: u32, -} - -const fn default_analytics_limit() -> u32 { - 10 -} - -impl AnalyticsQuery { - /// A window between two epoch-millisecond timestamps. - #[must_use] - pub fn new(site: impl Into, since_ms: u64, until_ms: u64) -> Self { - Self { - site: site.into(), - since_ms, - until_ms, - breakdown: None, - limit: default_analytics_limit(), - } - } - - /// Breaks the totals down by `dimension`. - #[must_use] - pub fn with_breakdown(mut self, dimension: AnalyticsDimension) -> Self { - self.breakdown = Some(dimension); - self - } - - /// Returns at most `limit` breakdown rows. - #[must_use] - pub fn with_limit(mut self, limit: u32) -> Self { - self.limit = limit; - self - } - - /// Checks the query before an adapter sends it. - /// - /// # Errors - /// - /// Returns [`Error::EmptySiteName`] when the site is blank, or - /// [`Error::InvalidAnalyticsWindow`] when the window does not move forward. - pub fn validate(&self) -> Result<()> { - if self.site.trim().is_empty() { - return Err(Error::EmptySiteName); - } - if self.until_ms <= self.since_ms { - return Err(Error::InvalidAnalyticsWindow); - } - Ok(()) - } -} - -/// One row of an analytics breakdown. -/// -/// `metrics` holds whatever numbers the provider returned for the row rather -/// than a fixed pair of fields. Providers do not agree on what they count, and -/// a struct with `pageviews` and `visitors` would either drop a provider's -/// numbers or invent them. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct AnalyticsBucket { - /// The dimension value this row is for. - pub label: String, - /// The metrics the provider reported for it. - pub metrics: BTreeMap, -} - -/// Traffic a site served over a window. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct AnalyticsSummary { - /// The site the numbers are for. - pub site: String, - /// The window's start, in milliseconds since the Unix epoch. - pub since_ms: u64, - /// The window's end, in milliseconds since the Unix epoch. - pub until_ms: u64, - /// Distinct visitors, when the provider counts them. - #[serde(default)] - pub visitors: Option, - /// Page views, when the provider counts them. - #[serde(default)] - pub pageviews: Option, - /// The requested breakdown, when one was asked for. - #[serde(default)] - pub breakdown: Vec, -} diff --git a/src/launch/types.rs b/src/launch/types.rs index cc18bef..c8a2d8c 100644 --- a/src/launch/types.rs +++ b/src/launch/types.rs @@ -4,9 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::Result; use crate::bundle::Bundle; -use crate::host::types::{ - Database, DatabaseSpec, Deployment, DeploymentTarget, Domain, EnvVar, Site, SiteSpec, -}; +use crate::host::types::{DatabaseSpec, DeploymentTarget, EnvVar, SiteSpec}; /// Everything needed to put one application on the internet. /// @@ -120,30 +118,5 @@ impl LaunchPlan { } } -/// What a launch produced. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct Launch { - /// The site the application lives on. - pub site: Site, - /// Whether this launch created the site, rather than finding it. - pub created_site: bool, - /// The database that was provisioned, when the plan asked for one. - #[serde(default)] - pub database: Option, - /// The environment variable names the database injected into the site. - #[serde(default)] - pub database_env_keys: Vec, - /// The domains that were attached. - #[serde(default)] - pub domains: Vec, - /// The deployment, which is usually still building. - pub deployment: Deployment, -} - -impl Launch { - /// The URL the application will serve from, once the deployment is ready. - #[must_use] - pub fn url(&self) -> Option<&str> { - self.deployment.url.as_deref() - } -} +/// The launch response from the shared contract. +pub use tinyhosts_bus::Launch; diff --git a/src/lib.rs b/src/lib.rs index 31f154a..050ef56 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -82,3 +82,6 @@ pub use providers::vercel::Vercel; // next to each other, so they stay in their module rather than being flattened // into the crate root. pub use rpc::{execute, execute_json}; + +/// Transport-free contract for consumers of the compiled module. +pub use tinyhosts_bus as bus; diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index 2233c06..89a4fa1 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -13,188 +13,18 @@ //! tenant wants. The credential is read out of the envelope and never written //! back into a result — see [`Credentials`]. -use serde::{Deserialize, Serialize}; - -use crate::host::types::{ - AnalyticsQuery, AnalyticsSummary, Database, DatabaseSpec, DeployRequest, Deployment, - DeploymentLog, Domain, EnvVar, EnvVarRecord, Site, SiteSpec, -}; -use crate::launch::types::{Launch, LaunchPlan}; +use crate::host::types::DeployRequest; +use crate::launch::types::LaunchPlan; use crate::providers::{ProviderKind, connect_to}; use crate::{Credentials, Error, Result}; -/// A request to act on one hosting account. -#[derive(Debug, Deserialize)] -pub struct Request { - /// Which provider to act on. Defaults to [`ProviderKind::Vercel`]. - #[serde(default)] - pub provider: ProviderKind, - /// The account's credential. Omitted, it is read from the environment. - #[serde(default)] - pub credentials: Option, - /// An alternate API root for the provider. - /// - /// Set it when the provider is reached through an egress proxy. Omitted, the - /// provider's own root is used. - #[serde(default)] - pub base_url: Option, - /// What to do. - #[serde(flatten)] - pub operation: Operation, -} - -/// One thing a request can ask for. -/// -/// The variants are exactly the [`Host`](crate::Host) surface plus -/// [`launch`](crate::launch()), so the bus exposes no more authority than the -/// library does. -#[derive(Debug, Deserialize)] -#[serde(tag = "operation", rename_all = "snake_case")] -#[non_exhaustive] -pub enum Operation { - /// Run a whole launch: site, database, environment, domains, deployment. - Launch { - /// The plan to run. - /// - /// Boxed because it carries a whole bundle: unboxed, every other - /// variant of this enum would be as large as an application. - plan: Box, - }, - /// Create a site. - CreateSite { - /// What the site should be. - spec: SiteSpec, - }, - /// Find a site by name, or report that there is none. - FindSite { - /// The site's name or identifier. - site: String, - }, - /// List sites, newest first. - ListSites { - /// How many to return. - #[serde(default = "default_limit")] - limit: u32, - }, - /// Set environment variables on a site. - SetEnv { - /// The site's name or identifier. - site: String, - /// The variables to set. - vars: Vec, - }, - /// List a site's environment variables, without their values. - ListEnv { - /// The site's name or identifier. - site: String, - }, - /// Provision a managed database. - ProvisionDatabase { - /// What the database should be. - spec: DatabaseSpec, - }, - /// Connect a database to a site. - AttachDatabase { - /// The database, as [`Operation::ProvisionDatabase`] returned it. - database: Database, - /// The site's name or identifier. - site: String, - }, - /// Upload a bundle and start a deployment. - Deploy { - /// The deployment to start. Boxed for the same reason as - /// [`Operation::Launch`]'s plan. - request: Box, - }, - /// Read a deployment's current state. - Deployment { - /// The deployment's identifier. - id: String, - }, - /// List a site's deployments, newest first. - ListDeployments { - /// The site's name or identifier. - site: String, - /// How many to return. - #[serde(default = "default_limit")] - limit: u32, - }, - /// List a deployment's build and deployment events, oldest first. - DeploymentLogs { - /// The deployment's identifier. - id: String, - }, - /// Point production traffic at an existing deployment. - Promote { - /// The site's name or identifier. - site: String, - /// The deployment's identifier. - deployment: String, - }, - /// Add a custom domain to a site. - AddDomain { - /// The site's name or identifier. - site: String, - /// The domain to add. - domain: String, - }, - /// List a site's domains. - ListDomains { - /// The site's name or identifier. - site: String, - }, - /// Report the traffic a site served. - Analytics { - /// The window to report on. - query: AnalyticsQuery, - }, -} - -const fn default_limit() -> u32 { - 20 -} - -/// What an operation produced. -/// -/// The envelope is adjacently tagged — `{"result": "...", "value": ...}` — so a -/// list result and a record result have the same shape on the wire, and a reader -/// can dispatch on one field. -#[derive(Debug, Serialize)] -#[serde(tag = "result", content = "value", rename_all = "snake_case")] -#[non_exhaustive] -pub enum Outcome { - /// A completed launch. - /// - /// Boxed because it carries a whole site, database and deployment: unboxed, - /// every other variant would be as large as the largest one. - Launch(Box), - /// One site. - Site(Site), - /// A site that does not exist. - NoSite, - /// Several sites. - Sites(Vec), - /// One deployment. - Deployment(Deployment), - /// Several deployments. - Deployments(Vec), - /// A deployment's build and deployment events. - DeploymentLogs(Vec), - /// A site's environment variables, without their values. - Env(Vec), - /// One database. - Database(Database), - /// The environment variable names a database injected. - EnvKeys(Vec), - /// One domain. - Domain(Domain), - /// Several domains. - Domains(Vec), - /// A traffic report. - Analytics(AnalyticsSummary), - /// An operation that produced nothing but succeeded. - Done, -} +/// Existing library request, using implementation-owned validated input values. +pub type Request = + tinyhosts_bus::rpc::Request; +/// Existing operations, with implementation-owned deployment inputs. +pub type Operation = tinyhosts_bus::rpc::Operation; +/// Shared result envelope. +pub use tinyhosts_bus::rpc::Outcome; /// Runs one request. /// @@ -249,6 +79,9 @@ pub async fn execute(request: Request) -> Result { } Operation::ListDomains { site } => host.list_domains(&site).await.map(Outcome::Domains), Operation::Analytics { query } => host.analytics(&query).await.map(Outcome::Analytics), + _ => Err(Error::Envelope { + reason: "unsupported operation".into(), + }), } } diff --git a/src/rpc/mod_tests.rs b/src/rpc/mod_tests.rs index b2e78e1..725455d 100644 --- a/src/rpc/mod_tests.rs +++ b/src/rpc/mod_tests.rs @@ -438,3 +438,61 @@ async fn a_request_without_a_credential_falls_back_to_the_environment() { fn the_available_providers_are_listed() { assert_eq!(providers(), ["vercel"]); } + +#[test] +fn pure_bus_deployment_inputs_are_accepted_by_the_validated_library_envelope() { + let operation = + tinyhosts_bus::rpc::Operation::::Deploy { + request: Box::new(tinyhosts_bus::inputs::DeploymentInput { + site: "site".into(), + framework: crate::Framework::Static, + target: crate::DeploymentTarget::Preview, + bundle: vec![tinyhosts_bus::inputs::BundleFile { + path: "index.html".into(), + contents: "SGVsbG8=".into(), + }], + }), + }; + let request: Request = + serde_json::from_value(serde_json::to_value(operation).unwrap()).unwrap(); + match request.operation { + Operation::Deploy { request } => { + assert!(request.validate().is_ok()); + assert_eq!(request.bundle.files()[0].contents(), b"Hello"); + } + _ => panic!("expected a deployment"), + } + let invalid = serde_json::json!({"operation":"deploy", "request": { + "site":"site", "bundle":[{"path":"../private", "contents":"SGVsbG8="}] + }}); + assert!(serde_json::from_value::(invalid).is_err()); +} + +#[tokio::test] +async fn contract_log_and_missing_site_results_keep_their_tagged_envelopes() { + let server = MockServer::start().await; + mount( + &server, + "GET", + "/v3/deployments/deploy/events", + 200, + json!([]), + ) + .await; + let logs = run( + &server, + json!({"operation":"deployment_logs", "id":"deploy"}), + ) + .await; + assert_eq!(logs, json!({"result":"deployment_logs", "value":[]})); + mount( + &server, + "GET", + "/v9/projects/missing", + 404, + json!({"error":{"code":"not_found"}}), + ) + .await; + let site = run(&server, json!({"operation":"find_site", "site":"missing"})).await; + assert_eq!(site, json!({"result":"no_site"})); +} diff --git a/src/tinybus_module/mod.rs b/src/tinybus_module/mod.rs index 8d48a51..3a02f74 100644 --- a/src/tinybus_module/mod.rs +++ b/src/tinybus_module/mod.rs @@ -14,8 +14,8 @@ use tinybus::{Connection, Result as TinyBusResult}; -const INTERFACE: &str = "ai.tinyhumans.tinyhosts.Hosting"; -const OBJECT_PATH: &str = "/ai/tinyhumans/tinyhosts/Hosting"; +const INTERFACE: &str = tinyhosts_bus::BUS_NAME; +const OBJECT_PATH: &str = tinyhosts_bus::OBJECT_PATH; struct HostingService; diff --git a/src/tinybus_module/mod_tests.rs b/src/tinybus_module/mod_tests.rs index 9d7fdb8..3db4431 100644 --- a/src/tinybus_module/mod_tests.rs +++ b/src/tinybus_module/mod_tests.rs @@ -34,7 +34,8 @@ fn declared_methods_match_the_dispatch_table() { .map(|member| member.to_string()) .collect::>(); - assert_eq!(methods, ["Execute", "Providers"]); + assert_eq!(methods, tinyhosts_bus::METHODS); + assert_eq!(tinyhosts_bus::CONTRACT_VERSION, env!("CARGO_PKG_VERSION")); } #[tokio::test] diff --git a/src/tools/mod_tests.rs b/src/tools/mod_tests.rs index e8fed28..33190ed 100644 --- a/src/tools/mod_tests.rs +++ b/src/tools/mod_tests.rs @@ -499,7 +499,7 @@ async fn reading_logs_without_a_deployment_id_is_refused_before_any_call() { /// Every tool's name, description, schema, permission and external-effect flag, /// as the `OpenHuman` host declared them before the tools moved here. A change /// to any of these changes a prompt, so it has to be a change to this file too. -const DECLARATIONS: &str = include_str!("declarations.json"); +const DECLARATIONS: &str = tinyhosts_bus::TOOL_DECLARATIONS_JSON; fn declarations_of(tools: &[Box]) -> serde_json::Value { tools From e9b86003d506430a3f31236a11b53957f90ae30b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 19:52:02 +0300 Subject: [PATCH 02/10] fix(bundle): reject Windows absolute and NUL paths Co-authored-by: Medulla --- src/bundle/mod.rs | 8 +++++++- src/bundle/mod_tests.rs | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/bundle/mod.rs b/src/bundle/mod.rs index 69a7d45..85578ff 100644 --- a/src/bundle/mod.rs +++ b/src/bundle/mod.rs @@ -106,7 +106,13 @@ impl SiteFile { let normalized = supplied.replace('\\', "/"); let trimmed = normalized.trim().trim_start_matches("./"); - let rejected = trimmed.is_empty() + let rejected = trimmed.contains('\0') + || (trimmed.as_bytes().get(1) == Some(&b':') + && trimmed + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphabetic)) + || trimmed.is_empty() || trimmed.starts_with('/') || trimmed.split('/').any(|segment| segment == ".."); if rejected { diff --git a/src/bundle/mod_tests.rs b/src/bundle/mod_tests.rs index ab2c9d9..6b85708 100644 --- a/src/bundle/mod_tests.rs +++ b/src/bundle/mod_tests.rs @@ -205,3 +205,9 @@ fn a_bundle_converts_back_into_its_files() { assert_eq!(files.len(), 1); } + +#[test] +fn windows_absolute_and_null_bundle_paths_are_refused() { + assert!(SiteFile::new("C:\\Windows\\credential", b"secret".to_vec()).is_err()); + assert!(SiteFile::new("entry\0secret", b"secret".to_vec()).is_err()); +} From 3011d3e5ef88ee529ded275e7236f0bd0d95acd6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 19:52:08 +0300 Subject: [PATCH 03/10] feat(module): prepare bounded authorized deployment snapshots Co-authored-by: Medulla --- Cargo.lock | 109 +++++++++ Cargo.toml | 4 + README.md | 40 ++++ crates/tinyhosts-bus/README.md | 12 + crates/tinyhosts-bus/src/error.rs | 18 ++ crates/tinyhosts-bus/src/lib.rs | 11 + crates/tinyhosts-bus/src/lib_tests.rs | 22 ++ crates/tinyhosts-bus/src/preparation.rs | 71 ++++++ crates/tinyhosts-bus/src/rpc.rs | 7 + examples/verify_module.rs | 68 ++++++ src/lib.rs | 1 + src/preparation/mod.rs | 268 ++++++++++++++++++++++ src/preparation/mod_tests.rs | 284 ++++++++++++++++++++++++ src/rpc/mod.rs | 15 ++ src/rpc/mod_tests.rs | 60 +++++ 15 files changed, 990 insertions(+) create mode 100644 crates/tinyhosts-bus/src/preparation.rs create mode 100644 src/preparation/mod.rs create mode 100644 src/preparation/mod_tests.rs diff --git a/Cargo.lock b/Cargo.lock index c548e3b..47e2d80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + [[package]] name = "anyhow" version = "1.0.104" @@ -107,6 +113,48 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cap-fs-ext" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ff379b70af8e08307a8f65e7040c7301cb4a572538ade16b4984f0da77847f" +dependencies = [ + "cap-primitives", + "cap-std", + "io-lifetimes 3.0.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "cap-primitives" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "cap-std" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ec78e242cfa2cfe276807ac2ecc00315a6c97786977414bcd1c3963b6c91b8" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes 3.0.1", + "rustix", +] + [[package]] name = "cc" version = "1.4.2" @@ -322,6 +370,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "futures" version = "0.3.34" @@ -713,6 +772,28 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.52.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + [[package]] name = "ipnet" version = "2.12.1" @@ -772,6 +853,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "memchr" version = "2.8.3" @@ -1042,6 +1129,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix", +] + [[package]] name = "rustls" version = "0.23.45" @@ -1356,6 +1453,8 @@ dependencies = [ "anyhow", "async-trait", "base64 0.23.1", + "cap-fs-ext", + "cap-std", "hex", "percent-encoding", "reqwest", @@ -1859,6 +1958,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.52.0", +] + [[package]] name = "wiremock" version = "0.6.5" diff --git a/Cargo.toml b/Cargo.toml index 2e817d9..d815343 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,10 @@ serde = { version = "1", features = ["derive"] } # Request and response bodies for the provider REST APIs, and the JSON envelope # the `rpc` module exchanges over the bus. serde_json = "1" +# Directory preparation anchors traversal to an authorized directory descriptor. +# These safe capability APIs reject symlink following without platform-specific unsafe code. +cap-std = "4" +cap-fs-ext = "4" # Bundle file contents travel as base64 in JSON: a byte array would triple the # size of every deployment payload crossing the bus. base64 = "0.23" diff --git a/README.md b/README.md index c251f22..33368b6 100644 --- a/README.md +++ b/README.md @@ -204,3 +204,43 @@ existing request/result envelopes, hosting records, errors, provider identifiers and tool declarations without linking provider implementations. See the [contract crate](crates/tinyhosts-bus/README.md) and [boundary spec](docs/specs/minimal-bus-contract.md). + + +### Authorized directory preparation + +Operation vocabulary 1.1 adds `prepare_bundle` inside the existing single-string +`Execute` request. `Providers()` and `Execute(String)` keep their arities, and +all ten model tool declarations remain unchanged. A trusted host must authorize +a concrete canonical workspace and relative source directory before constructing +`AuthorizedDirectory`. This DTO declares scope; it does not prove authorization. +Do not expose generic `Execute` forwarding or this operation to model arguments. +Host path policy, system-root restrictions and deployment approval remain host +responsibilities, including when optional autonomy policy is disabled. + +Preparation returns the actual base64 `bundle`, version, file/byte counts and +examined/skipped entry counts, without credential lookup or provider requests. +Use that exact returned bundle for approval and later `launch`/`deploy`; rereading +the source would invalidate the approved snapshot. There are no leased resources +to release. The library's existing `Bundle::from_dir` behavior is unchanged. +Preparation preserves its directory enumeration order, standard base64 bytes and +build/cache exclusions, adds all `.env.*` files and credential stores to exclusions, +and skips nested symlinks. Unlike the legacy library collector, preparation +refuses symlinks in the workspace or source directory path and rejects every `..` +component. Traversal uses directory handles and opens each component without +following links. Supplied Launch/Deploy bundles also refuse credential stores, +Microsoft credential directories and environment files; absolute Windows paths, +NUL bytes and traversal are rejected by the validated bundle path type. + +Limits per preparation are 4 MiB decoded source, 4,096 files, 16,384 examined +entries (including excluded entries), 64 directory levels, 1,024 UTF-8 bytes per +relative path and a conservative 6 MiB serialized snapshot budget. Raw reads are +bounded; escaped paths and base64 expansion are charged before encoded files are +allocated or published. Execute request JSON is limited to 7 MiB before decoding. +These budgets leave headroom for nested JSON-string escaping under the 16 MiB +TinyBus frame limit. Larger deployments require a future streaming protocol. +Typed `PreparationPath`, `PreparationLimit` and `RequestLimit` errors explain +refusals. File contents are omitted from preparation Debug output. + +The OpenHuman adapter is a separate followup, gated on a published compatible +module artifact and digest. This change does not switch host dependencies or +release package versions. diff --git a/crates/tinyhosts-bus/README.md b/crates/tinyhosts-bus/README.md index 4bcaa7f..6c8229f 100644 --- a/crates/tinyhosts-bus/README.md +++ b/crates/tinyhosts-bus/README.md @@ -18,3 +18,15 @@ approval, authorized workspace selection, credential custody, and lifecycle policy stay with OpenHuman. The module validates inputs and talks to providers. The package version follows the module release version; the release workflow bumps both manifests together. Hosts pin published artifacts and verify digests. + + +Operation vocabulary `WIRE_CONTRACT_VERSION = (1, 1)` adds `prepare_bundle` to +Execute, without changing method arities or model tool declarations. The host +constructs `AuthorizedDirectory` only after authorizing a concrete canonical +workspace and relative input. The module returns a stateless `PreparedBundle` +containing actual files and bounded facts. Approve and deploy this snapshot's +exact bytes, rather than recollecting the directory. The DTO is a scope declaration, +not an authorization token, and must never be constructed from generic model +forwarding. See the implementation README for byte/frame limits, credential +exclusions and the stricter no-symlink source policy. Filesystem traversal and +base64 encoding are absent from this contract crate. diff --git a/crates/tinyhosts-bus/src/error.rs b/crates/tinyhosts-bus/src/error.rs index 80f8017..aee99c8 100644 --- a/crates/tinyhosts-bus/src/error.rs +++ b/crates/tinyhosts-bus/src/error.rs @@ -17,6 +17,24 @@ #[derive(Debug, thiserror::Error, PartialEq, Eq)] #[non_exhaustive] pub enum Error { + /// Execute request JSON exceeded its fixed transport-compatible byte budget. + #[error("request exceeds the {max_bytes}-byte wire limit")] + RequestLimit { + /// Maximum unescaped request JSON bytes. + max_bytes: usize, + }, + /// Authorized preparation named an invalid or out-of-scope directory. + #[error("invalid preparation directory: {reason}")] + PreparationPath { + /// Validation failure, without file contents. + reason: String, + }, + /// Source collection exceeded its configured bounded budget. + #[error("directory preparation exceeds {limit}")] + PreparationLimit { + /// The exhausted budget name. + limit: String, + }, /// An API key was empty or contained only whitespace. #[error("api key must not be empty")] EmptyApiKey, diff --git a/crates/tinyhosts-bus/src/lib.rs b/crates/tinyhosts-bus/src/lib.rs index 0219b8d..44a469f 100644 --- a/crates/tinyhosts-bus/src/lib.rs +++ b/crates/tinyhosts-bus/src/lib.rs @@ -3,6 +3,7 @@ pub mod error; pub mod inputs; mod launch; pub mod model; +pub mod preparation; pub mod rpc; pub use error::{Error, Result}; pub use launch::Launch; @@ -13,6 +14,16 @@ pub const BUS_NAME: &str = "ai.tinyhumans.tinyhosts.Hosting"; pub const OBJECT_PATH: &str = "/ai/tinyhumans/tinyhosts/Hosting"; /// Member names in their existing order and arity. pub const METHODS: [&str; 2] = ["Execute", "Providers"]; +/// Additive hosting operation vocabulary, independent of artifact package releases. +/// 1.0 is the original Execute/Providers surface; 1.1 adds authorized preparation. +pub const WIRE_CONTRACT_VERSION: (u32, u32) = (1, 1); + +/// Whether an artifact serves every operation in this vocabulary. +#[must_use] +pub fn is_compatible(module: (u32, u32)) -> bool { + module.0 == WIRE_CONTRACT_VERSION.0 && module.1 >= WIRE_CONTRACT_VERSION.1 +} + /// Contract package version, synchronized with the released module manifest. pub const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); /// Recorded agent tool declarations, including approval metadata. diff --git a/crates/tinyhosts-bus/src/lib_tests.rs b/crates/tinyhosts-bus/src/lib_tests.rs index 94c2100..a0de50e 100644 --- a/crates/tinyhosts-bus/src/lib_tests.rs +++ b/crates/tinyhosts-bus/src/lib_tests.rs @@ -43,3 +43,25 @@ fn tool_declarations_and_result_envelopes_keep_the_existing_wire() { assert_eq!(METHODS, ["Execute", "Providers"]); assert_eq!(CONTRACT_VERSION, env!("CARGO_PKG_VERSION")); } + +#[test] +fn authorized_preparation_is_additive_versioned_and_not_a_model_tool() { + let op: rpc::Operation = serde_json::from_value(serde_json::json!({"operation":"prepare_bundle", "directory":{"workspace":"/approved", "path":"."}})).unwrap(); + let rpc::Operation::PrepareBundle { directory } = op else { + panic!("wrong operation") + }; + assert_eq!(directory.max_bytes, preparation::MAX_PREPARATION_BYTES); + assert_eq!(directory.max_files, preparation::MAX_PREPARATION_FILES); + assert!(is_compatible((1, 1))); + assert!(is_compatible((1, 2))); + assert!(!is_compatible((1, 0))); + assert!(!is_compatible((2, 1))); + assert_eq!(WIRE_CONTRACT_VERSION, (1, 1)); + assert!(!TOOL_DECLARATIONS_JSON.contains("prepare_bundle")); + assert!( + serde_json::from_value::( + serde_json::json!({"workspace":"/approved", "path":".","extra":true}) + ) + .is_err() + ); +} diff --git a/crates/tinyhosts-bus/src/preparation.rs b/crates/tinyhosts-bus/src/preparation.rs new file mode 100644 index 0000000..c130b1e --- /dev/null +++ b/crates/tinyhosts-bus/src/preparation.rs @@ -0,0 +1,71 @@ +//! Authorized source-directory input and bounded preparation facts. +use crate::inputs::BundleFile; +use serde::{Deserialize, Serialize}; + +/// Maximum source bytes collected by one preparation operation. +pub const MAX_PREPARATION_BYTES: u64 = 4 * 1024 * 1024; +/// Conservative JSON snapshot budget, before nested Execute string escaping. +pub const MAX_PREPARATION_JSON_BYTES: usize = 6 * 1024 * 1024; +/// Maximum Execute request JSON bytes, leaving frame headroom after escaping. +pub const MAX_RPC_REQUEST_BYTES: usize = 7 * 1024 * 1024; +/// Maximum regular files collected by one preparation operation. +pub const MAX_PREPARATION_FILES: u32 = 4096; +/// Maximum filesystem entries examined, including excluded entries. +pub const MAX_PREPARATION_ENTRIES: u32 = 16_384; +/// Maximum UTF-8 bytes in a relative prepared file path. +pub const MAX_PREPARATION_PATH_BYTES: usize = 1024; +/// Maximum nested directory depth beneath the authorized source. +pub const MAX_PREPARATION_DEPTH: usize = 64; + +/// A directory the host has authorized for this read and intended deployment. +/// +/// The host must finish workspace/path policy and external-effect approval before +/// submitting this value. It is a scope declaration, not proof of authorization. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AuthorizedDirectory { + /// Absolute workspace root approved by the host. + pub workspace: String, + /// Relative source directory; absolute paths, traversal and symlinks are refused. + pub path: String, + /// Source-byte budget, bounded by MAX_PREPARATION_BYTES. + #[serde(default = "default_bytes")] + pub max_bytes: u64, + /// Regular-file budget, bounded by MAX_PREPARATION_FILES. + #[serde(default = "default_files")] + pub max_files: u32, +} +const fn default_bytes() -> u64 { + MAX_PREPARATION_BYTES +} +const fn default_files() -> u32 { + MAX_PREPARATION_FILES +} + +/// Collected source files and facts; no provider effect has been performed. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PreparedBundle { + /// Operation vocabulary served by the preparing module. + pub contract_version: (u32, u32), + /// Source files on the existing standard-base64 deployment wire. + pub bundle: Vec, + /// Number of regular source files collected. + pub file_count: u32, + /// Total unencoded source bytes. + pub total_bytes: u64, + /// Excluded entries encountered; excluded directory descendants are not traversed. + pub skipped_entries: u32, + /// Entries examined, including entries skipped without descending. + pub scanned_entries: u32, +} +impl std::fmt::Debug for PreparedBundle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PreparedBundle") + .field("contract_version", &self.contract_version) + .field("file_count", &self.file_count) + .field("total_bytes", &self.total_bytes) + .field("skipped_entries", &self.skipped_entries) + .field("scanned_entries", &self.scanned_entries) + .finish() + } +} diff --git a/crates/tinyhosts-bus/src/rpc.rs b/crates/tinyhosts-bus/src/rpc.rs index d9868bb..4077566 100644 --- a/crates/tinyhosts-bus/src/rpc.rs +++ b/crates/tinyhosts-bus/src/rpc.rs @@ -40,6 +40,11 @@ pub struct Request< #[serde(tag = "operation", rename_all = "snake_case")] #[non_exhaustive] pub enum Operation

{ + /// Collect an already-authorized directory without provider effects or credential lookup. + PrepareBundle { + /// The host-approved workspace scope and relative directory. + directory: crate::preparation::AuthorizedDirectory, + }, /// Run a whole launch: site, database, environment, domains, deployment. Launch { /// The plan to run. @@ -151,6 +156,8 @@ const fn default_limit() -> u32 { #[serde(tag = "result", content = "value", rename_all = "snake_case")] #[non_exhaustive] pub enum Outcome { + /// Bounded source files and preparation facts, ready for Launch or Deploy. + PreparedBundle(crate::preparation::PreparedBundle), /// A completed launch. /// /// Boxed because it carries a whole site, database and deployment: unboxed, diff --git a/examples/verify_module.rs b/examples/verify_module.rs index 96594b7..70a4801 100644 --- a/examples/verify_module.rs +++ b/examples/verify_module.rs @@ -75,6 +75,8 @@ async fn main() -> Result<(), Box> { } } + verify_preparation(&client, &server).await?; + println!( "verified {} as TinyBus module `{}`", module.display(), @@ -95,3 +97,69 @@ fn module_argument() -> Result { ) }) } + +async fn verify_preparation( + client: &Connection, + server: &wiremock::MockServer, +) -> Result<(), Box> { + let proxy = client.proxy(INTERFACE, OBJECT_PATH, INTERFACE)?; + let directory = tempfile::tempdir()?; + std::fs::write(directory.path().join("index.html"), "Hello")?; + let prepared: String = proxy + .call( + "Execute", + (serde_json::json!({ + "operation":"prepare_bundle", "directory":{ + "workspace": directory.path().canonicalize()?.to_string_lossy(), "path":"." + } + }) + .to_string(),), + ) + .await?; + let prepared: tinyhosts_bus::rpc::Outcome = serde_json::from_str(&prepared)?; + let tinyhosts_bus::rpc::Outcome::PreparedBundle(snapshot) = prepared else { + return Err(io::Error::other("missing prepared snapshot").into()); + }; + if snapshot.contract_version != (1, 1) || snapshot.total_bytes != 5 { + return Err(io::Error::other("incorrect preparation facts").into()); + } + std::fs::write( + directory.path().join("index.html"), + "Changed after preparation", + )?; + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/v2/files")) + .and(wiremock::matchers::body_string("Hello")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .expect(1) + .mount(server) + .await; + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/v13/deployments")) + .and(wiremock::matchers::body_partial_json(serde_json::json!({ + "files":[{"file":"index.html", "size":5}] + }))) + .respond_with( + wiremock::ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"id":"snapshot-deploy","readyState":"READY"})), + ) + .expect(1) + .mount(server) + .await; + let deployed: String = proxy + .call( + "Execute", + (serde_json::json!({ + "operation":"deploy", "credentials":{"api_key":"local-fixture"}, + "base_url":server.uri(), "request":{"site":"fixture", "bundle":snapshot.bundle} + }) + .to_string(),), + ) + .await?; + if !deployed.contains("snapshot-deploy") { + return Err(io::Error::other("snapshot deployment did not use captured bytes").into()); + } + server.verify().await; + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 050ef56..a13eded 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,6 +54,7 @@ pub mod credentials; pub mod error; pub mod host; pub mod launch; +pub mod preparation; pub mod providers; pub mod rpc; #[cfg(feature = "tools")] diff --git a/src/preparation/mod.rs b/src/preparation/mod.rs new file mode 100644 index 0000000..18f270f --- /dev/null +++ b/src/preparation/mod.rs @@ -0,0 +1,268 @@ +//! Bounded collection of a host-authorized source directory. +//! +//! Authorization and external-effect approval precede this call. The returned +//! bundle is the prepared snapshot: pass its exact bytes to Launch/Deploy rather +//! than collecting the directory again. This operation never connects a provider +//! or looks up credentials. Directory-handle traversal prevents symlink escapes. +use std::io::Read; +use std::path::{Component, Path}; + +use base64::Engine as _; +use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt, OpenOptionsSyncExt}; +use cap_std::fs::{Dir, OpenOptions}; +use tinyhosts_bus::inputs::BundleFile; +use tinyhosts_bus::preparation::{ + AuthorizedDirectory, MAX_PREPARATION_BYTES, MAX_PREPARATION_DEPTH, MAX_PREPARATION_ENTRIES, + MAX_PREPARATION_FILES, MAX_PREPARATION_JSON_BYTES, MAX_PREPARATION_PATH_BYTES, PreparedBundle, +}; + +use crate::{Error, Result, SiteFile}; + +/// Collect the bounded source snapshot inside an already-authorized directory. +/// +/// # Errors +/// Refuses invalid scopes, symlinks, credential roots, unreadable files, empty +/// bundles and byte/file/depth budgets. No provider request is performed. +pub fn prepare_bundle(input: &AuthorizedDirectory) -> Result { + validate(input)?; + let workspace = Path::new(&input.workspace); + let canonical = workspace + .canonicalize() + .map_err(|error| read_error(&input.workspace, &error))?; + if canonical != workspace || credential_path(&canonical.to_string_lossy()) { + return Err(invalid( + "workspace must be canonical and outside credential directories", + )); + } + // Anchor at the volume root and open every component without following links, + // including the workspace itself: a concurrent root alias cannot change scope. + let volume = workspace + .ancestors() + .last() + .ok_or_else(|| invalid("missing volume root"))?; + let mut source = Dir::open_ambient_dir(volume, cap_std::ambient_authority()) + .map_err(|error| read_error(&input.workspace, &error))?; + for component in workspace.components() { + if let Component::Normal(name) = component { + source = source + .open_dir_nofollow(name) + .map_err(|error| read_error(&input.workspace, &error))?; + } + } + for component in Path::new(&input.path.replace('\\', "/")).components() { + if let Component::Normal(name) = component { + source = source + .open_dir_nofollow(name) + .map_err(|error| read_error(&input.path, &error))?; + } + } + let mut prepared = PreparedBundle { + contract_version: tinyhosts_bus::WIRE_CONTRACT_VERSION, + bundle: Vec::new(), + file_count: 0, + total_bytes: 0, + skipped_entries: 0, + scanned_entries: 0, + }; + let mut wire_bytes = 512; // Reserve envelope/facts space, including integer widths. + collect(&source, "", 0, input, &mut prepared, &mut wire_bytes)?; + if prepared.bundle.is_empty() { + return Err(Error::EmptyBundle); + } + Ok(prepared) +} + +fn invalid(reason: &str) -> Error { + Error::PreparationPath { + reason: reason.into(), + } +} +fn limit(name: &str) -> Error { + Error::PreparationLimit { limit: name.into() } +} +fn read_error(path: &str, error: &impl std::fmt::Display) -> Error { + Error::ReadBundle { + path: path.into(), + reason: error.to_string(), + } +} + +fn validate(input: &AuthorizedDirectory) -> Result<()> { + if input.workspace.contains('\0') || input.path.contains('\0') { + return Err(invalid("null byte in directory scope")); + } + if !Path::new(&input.workspace).is_absolute() { + return Err(invalid("workspace must be absolute")); + } + let normalized = input.path.replace('\\', "/"); + if normalized.starts_with('/') + || normalized.contains(':') + || normalized.split('/').any(|part| part == "..") + { + return Err(invalid("source must be relative without traversal")); + } + if input + .workspace + .replace('\\', "/") + .split('/') + .chain(normalized.split('/')) + .any(credential_entry) + { + return Err(invalid("credential directory cannot be prepared")); + } + if credential_path(&format!("{}/{}", input.workspace, normalized)) { + return Err(invalid("credential directory cannot be prepared")); + } + if input.max_bytes > MAX_PREPARATION_BYTES || input.max_files > MAX_PREPARATION_FILES { + return Err(limit("maximum preparation budget")); + } + Ok(()) +} + +fn credential_entry(name: &str) -> bool { + matches!( + name.to_ascii_lowercase().as_str(), + ".ssh" | ".aws" | ".gnupg" | ".azure" | ".kube" | "keychains" | ".env" + ) || name.starts_with(".env.") +} + +fn credential_path(path: &str) -> bool { + let normalized = path.replace('\\', "/").to_ascii_lowercase(); + let parts: Vec<_> = normalized + .split('/') + .filter(|part| !part.is_empty()) + .collect(); + parts.iter().any(|part| credential_entry(part)) + || parts.windows(2).any(|pair| { + pair[0] == "microsoft" + && matches!(pair[1], "protect" | "credentials" | "crypto" | "vault") + }) +} + +pub(crate) fn validate_deployment_bundle(bundle: &crate::Bundle) -> Result<()> { + for file in bundle.files() { + if credential_path(file.path()) + || file + .path() + .split('/') + .any(|part| part == ".env" || part.starts_with(".env.")) + { + return Err(Error::InvalidBundlePath { + path: file.path().into(), + }); + } + } + Ok(()) +} + +fn excluded(name: &str) -> bool { + crate::EXCLUDED.contains(&name) || name.starts_with(".env.") || credential_entry(name) +} + +fn collect( + dir: &Dir, + prefix: &str, + depth: usize, + input: &AuthorizedDirectory, + out: &mut PreparedBundle, + wire_bytes: &mut usize, +) -> Result<()> { + if depth > MAX_PREPARATION_DEPTH { + return Err(limit("directory depth")); + } + let entries = dir.entries().map_err(|error| read_error(prefix, &error))?; + for entry in entries { + if out.scanned_entries >= MAX_PREPARATION_ENTRIES { + return Err(limit("entry count")); + } + out.scanned_entries += 1; + let entry = entry.map_err(|error| read_error(prefix, &error))?; + let name = entry + .file_name() + .into_string() + .map_err(|_| invalid("non-UTF-8 source name"))?; + let path = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}/{name}") + }; + if path.len() > MAX_PREPARATION_PATH_BYTES { + return Err(limit("relative path bytes")); + } + let kind = entry + .file_type() + .map_err(|error| read_error(&path, &error))?; + if excluded(&name) + || credential_path(&path) + || kind.is_symlink() + || (!kind.is_dir() && !kind.is_file()) + { + out.skipped_entries = out.skipped_entries.saturating_add(1); + } else if kind.is_dir() { + let child = dir + .open_dir_nofollow(&name) + .map_err(|error| read_error(&path, &error))?; + collect(&child, &path, depth + 1, input, out, wire_bytes)?; + } else { + collect_file(dir, &name, &path, input, out, wire_bytes)?; + } + } + Ok(()) +} + +fn collect_file( + dir: &Dir, + name: &str, + path: &str, + input: &AuthorizedDirectory, + out: &mut PreparedBundle, + wire_bytes: &mut usize, +) -> Result<()> { + if out.file_count >= input.max_files { + return Err(limit("file count")); + } + let mut options = OpenOptions::new(); + options.read(true).follow(FollowSymlinks::No).nonblock(true); + let file = dir + .open_with(name, &options) + .map_err(|error| read_error(path, &error))?; + if !file + .metadata() + .map_err(|error| read_error(path, &error))? + .is_file() + { + return Err(invalid("source changed to a non-regular file")); + } + let remaining = input.max_bytes.saturating_sub(out.total_bytes); + let mut bytes = Vec::new(); + file.take(remaining + 1) + .read_to_end(&mut bytes) + .map_err(|error| read_error(path, &error))?; + if bytes.len() as u64 > remaining { + return Err(limit("source bytes")); + } + let file = SiteFile::new(path, bytes)?; + // Charge escaped path JSON, base64 expansion, punctuation and field names + // before allocating the encoded String or growing the returned bundle. + let path_bytes = serde_json::to_string(file.path()) + .map_err(|error| Error::Envelope { + reason: error.to_string(), + })? + .len(); + let next_bytes = path_bytes + file.len().div_ceil(3) * 4 + 32; + if next_bytes > MAX_PREPARATION_JSON_BYTES.saturating_sub(*wire_bytes) { + return Err(limit("serialized snapshot bytes")); + } + *wire_bytes += next_bytes; + out.total_bytes += file.len() as u64; + out.file_count += 1; + out.bundle.push(BundleFile { + path: file.path().into(), + contents: base64::engine::general_purpose::STANDARD.encode(file.contents()), + }); + Ok(()) +} + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; diff --git a/src/preparation/mod_tests.rs b/src/preparation/mod_tests.rs new file mode 100644 index 0000000..aa9322f --- /dev/null +++ b/src/preparation/mod_tests.rs @@ -0,0 +1,284 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; + +fn input(root: &Path) -> AuthorizedDirectory { + AuthorizedDirectory { + workspace: root.to_string_lossy().into(), + path: ".".into(), + max_bytes: MAX_PREPARATION_BYTES, + max_files: MAX_PREPARATION_FILES, + } +} + +#[test] +fn preparation_preserves_source_bytes_exclusions_and_captured_snapshot() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("app")).unwrap(); + std::fs::write(root.path().join("app/index.bin"), [0, 1, 255]).unwrap(); + std::fs::write(root.path().join("package.json"), "{}").unwrap(); + for name in crate::EXCLUDED { + std::fs::write(root.path().join(name), "secret").unwrap(); + } + let legacy = crate::Bundle::from_dir(root.path()).unwrap(); + let expected: Vec = + serde_json::from_value(serde_json::to_value(legacy).unwrap()).unwrap(); + std::fs::create_dir(root.path().join(".ssh")).unwrap(); + std::fs::write(root.path().join(".ssh/id_rsa"), "credential").unwrap(); + let prepared = prepare_bundle(&input(root.path())).unwrap(); + assert_eq!(prepared.file_count, 2); + assert_eq!(prepared.total_bytes, 5); + assert_eq!(prepared.bundle, expected); + assert!( + prepared + .bundle + .iter() + .any(|file| file.path == "app/index.bin" && file.contents == "AAH/") + ); + std::fs::write(root.path().join("package.json"), "changed").unwrap(); + assert_eq!(prepared.bundle, expected); + assert!(!format!("{prepared:?}").contains("AAH/")); +} + +#[test] +fn invalid_scopes_and_bounded_file_reads_are_refused() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("source"), "12345").unwrap(); + for path in [ + "../outside", + "/outside", + "C:\\outside", + "a\\..\\b", + "\0", + ".ssh", + ".aws", + ".gnupg", + ] { + let mut request = input(root.path()); + request.path = path.into(); + assert!(prepare_bundle(&request).is_err(), "accepted {path}"); + } + let mut request = input(root.path()); + request.workspace = "relative".into(); + assert!(prepare_bundle(&request).is_err()); + request = input(root.path()); + request.max_bytes = 4; + assert!(matches!( + prepare_bundle(&request), + Err(Error::PreparationLimit { .. }) + )); + request.max_bytes = 5; + request.max_files = 0; + assert!(matches!( + prepare_bundle(&request), + Err(Error::PreparationLimit { .. }) + )); + request.max_files = MAX_PREPARATION_FILES + 1; + assert!(matches!( + prepare_bundle(&request), + Err(Error::PreparationLimit { .. }) + )); + request.max_files = MAX_PREPARATION_FILES; + request.max_bytes = MAX_PREPARATION_BYTES + 1; + assert!(matches!( + prepare_bundle(&request), + Err(Error::PreparationLimit { .. }) + )); + assert!(matches!( + prepare_bundle(&input(tempfile::tempdir().unwrap().path())), + Err(Error::EmptyBundle) + )); +} + +#[cfg(unix)] +#[test] +fn source_directory_symlinks_are_rejected_and_nested_symlinks_are_skipped() { + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + std::fs::write(outside.path().join("secret"), "outside").unwrap(); + std::fs::write(root.path().join("source"), "inside").unwrap(); + std::os::unix::fs::symlink(outside.path(), root.path().join("linked")).unwrap(); + std::os::unix::fs::symlink( + outside.path().join("secret"), + root.path().join("linked-file"), + ) + .unwrap(); + let prepared = prepare_bundle(&input(root.path())).unwrap(); + assert_eq!(prepared.file_count, 1); + assert_eq!(prepared.skipped_entries, 2); + let mut request = input(root.path()); + request.path = "linked".into(); + assert!(prepare_bundle(&request).is_err()); + request.path = "source".into(); + assert!(prepare_bundle(&request).is_err()); +} + +#[cfg(unix)] +#[test] +fn workspace_symlink_is_refused_before_reading_credential_contents() { + let parent = tempfile::tempdir().unwrap(); + let credentials = parent.path().join(".ssh"); + std::fs::create_dir(&credentials).unwrap(); + std::fs::write(credentials.join("id_rsa"), "credential").unwrap(); + let alias = parent.path().join("approved"); + std::os::unix::fs::symlink(&credentials, &alias).unwrap(); + assert!(matches!( + prepare_bundle(&input(&alias)), + Err(Error::PreparationPath { .. }) + )); +} + +#[test] +fn long_relative_paths_are_refused_before_collection() { + let root = tempfile::tempdir().unwrap(); + let mut path = root.path().to_path_buf(); + for _ in 0..6 { + path.push("x".repeat(200)); + std::fs::create_dir(&path).unwrap(); + } + std::fs::write(path.join("file"), "data").unwrap(); + assert!(matches!( + prepare_bundle(&input(root.path())), + Err(Error::PreparationLimit { .. }) + )); +} + +#[test] +fn serialization_budget_is_charged_before_publishing_encoded_files() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("file"), "data").unwrap(); + let dir = Dir::open_ambient_dir(root.path(), cap_std::ambient_authority()).unwrap(); + let mut out = PreparedBundle { + contract_version: (1, 1), + bundle: vec![], + file_count: 0, + total_bytes: 0, + skipped_entries: 0, + scanned_entries: 0, + }; + let mut charged = MAX_PREPARATION_JSON_BYTES; + assert!(matches!( + collect_file( + &dir, + "file", + "file", + &input(root.path()), + &mut out, + &mut charged + ), + Err(Error::PreparationLimit { .. }) + )); + assert_eq!(out.file_count, 0); + assert!(out.bundle.is_empty()); + charged = 512; + collect_file( + &dir, + "file", + "file", + &input(root.path()), + &mut out, + &mut charged, + ) + .unwrap(); + assert!(serde_json::to_vec(&out).unwrap().len() < charged); + out.scanned_entries = MAX_PREPARATION_ENTRIES; + assert!(matches!( + collect(&dir, "", 0, &input(root.path()), &mut out, &mut charged), + Err(Error::PreparationLimit { .. }) + )); + assert!(matches!( + collect( + &dir, + "", + MAX_PREPARATION_DEPTH + 1, + &input(root.path()), + &mut out, + &mut charged + ), + Err(Error::PreparationLimit { .. }) + )); + assert!( + collect_file( + &dir, + "missing", + "missing", + &input(root.path()), + &mut out, + &mut charged + ) + .is_err() + ); +} + +#[cfg(unix)] +#[test] +fn non_utf8_names_are_refused_without_lossy_path_rewriting() { + use std::os::unix::ffi::OsStringExt; + let root = tempfile::tempdir().unwrap(); + std::fs::write( + root.path().join(std::ffi::OsString::from_vec(vec![255])), + "data", + ) + .unwrap(); + assert!(matches!( + prepare_bundle(&input(root.path())), + Err(Error::PreparationPath { .. }) + )); +} + +#[test] +fn supplied_bundle_security_floor_covers_environment_and_platform_credentials() { + for path in [ + ".env", + "app/.env.production", + ".AWS/token", + "Microsoft/Protect/key", + "Microsoft/Vault/key", + "Keychains/key", + ] { + let bundle = + crate::Bundle::from_files(vec![SiteFile::new(path, b"credential".to_vec()).unwrap()]); + assert!( + validate_deployment_bundle(&bundle).is_err(), + "accepted {path}" + ); + } + let bundle = + crate::Bundle::from_files(vec![SiteFile::new("app/index", b"safe".to_vec()).unwrap()]); + validate_deployment_bundle(&bundle).unwrap(); +} + +#[cfg(unix)] +#[test] +fn source_replaced_by_fifo_is_refused_without_waiting_for_writer() { + let root = tempfile::tempdir().unwrap(); + assert!( + std::process::Command::new("mkfifo") + .arg(root.path().join("file")) + .status() + .unwrap() + .success() + ); + let request = input(root.path()); + let (send, receive) = std::sync::mpsc::channel(); + let dir = Dir::open_ambient_dir(root.path(), cap_std::ambient_authority()).unwrap(); + let worker = std::thread::spawn(move || { + let mut out = PreparedBundle { + contract_version: (1, 1), + bundle: vec![], + file_count: 0, + total_bytes: 0, + skipped_entries: 0, + scanned_entries: 0, + }; + let result = collect_file(&dir, "file", "file", &request, &mut out, &mut 512); + send.send(result).unwrap(); + }); + assert!(matches!( + receive + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap(), + Err(Error::PreparationPath { .. }) + )); + worker.join().unwrap(); +} diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index 89a4fa1..0e3b016 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -34,6 +34,16 @@ pub use tinyhosts_bus::rpc::Outcome; /// the environment holds none, [`Error::UnknownProvider`] when this build has no /// adapter for the named provider, or whatever the operation itself returns. pub async fn execute(request: Request) -> Result { + if let Operation::PrepareBundle { directory } = &request.operation { + return crate::preparation::prepare_bundle(directory).map(Outcome::PreparedBundle); + } + match &request.operation { + Operation::Launch { plan } => crate::preparation::validate_deployment_bundle(&plan.bundle)?, + Operation::Deploy { request } => { + crate::preparation::validate_deployment_bundle(&request.bundle)?; + } + _ => {} + } let credentials = match request.credentials { Some(credentials) => credentials, None => request.provider.credentials_from_env()?, @@ -92,6 +102,11 @@ pub async fn execute(request: Request) -> Result { /// Returns [`Error::Envelope`] when the request is not a [`Request`] or the /// result cannot be serialized, and otherwise whatever [`execute`] returns. pub async fn execute_json(request: &str) -> Result { + if request.len() > tinyhosts_bus::preparation::MAX_RPC_REQUEST_BYTES { + return Err(Error::RequestLimit { + max_bytes: tinyhosts_bus::preparation::MAX_RPC_REQUEST_BYTES, + }); + } let request: Request = serde_json::from_str(request).map_err(|error| Error::Envelope { reason: error.to_string(), })?; diff --git a/src/rpc/mod_tests.rs b/src/rpc/mod_tests.rs index 725455d..f15183a 100644 --- a/src/rpc/mod_tests.rs +++ b/src/rpc/mod_tests.rs @@ -496,3 +496,63 @@ async fn contract_log_and_missing_site_results_keep_their_tagged_envelopes() { let site = run(&server, json!({"operation":"find_site", "site":"missing"})).await; assert_eq!(site, json!({"result":"no_site"})); } + +#[tokio::test] +async fn authorized_directory_preparation_uses_execute_without_provider_or_credentials() { + let directory = tempfile::tempdir().unwrap(); + std::fs::write(directory.path().join("index.html"), "Hello").unwrap(); + let response = execute_json( + &json!({ + "operation":"prepare_bundle", + "directory":{"workspace":directory.path().to_string_lossy(),"path":"."} + }) + .to_string(), + ) + .await + .unwrap(); + let outcome: Value = serde_json::from_str(&response).unwrap(); + assert_eq!(outcome["result"], "prepared_bundle"); + assert_eq!(outcome["value"]["contract_version"], json!([1, 1])); + assert_eq!(outcome["value"]["bundle"][0]["contents"], "SGVsbG8="); + assert_eq!(outcome["value"]["total_bytes"], 5); +} + +#[tokio::test] +async fn supplied_deployment_credentials_are_refused_before_provider_dispatch() { + let server = MockServer::start().await; + mount( + &server, + "POST", + "/v13/deployments", + 200, + json!({"id":"unexpected"}), + ) + .await; + let response = execute_json(&json!({ + "credentials":{"api_key":"fixture"},"base_url":server.uri(), + "operation":"deploy","request":{"site":"site","bundle":[{"path":".ssh/id_rsa","contents":"c2VjcmV0"}]} + }).to_string()).await; + assert!(matches!(response, Err(Error::InvalidBundlePath { .. }))); + assert!(server.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn oversized_execute_json_is_refused_before_deserialization() { + let request = " ".repeat(tinyhosts_bus::preparation::MAX_RPC_REQUEST_BYTES + 1); + assert!(matches!( + execute_json(&request).await, + Err(Error::RequestLimit { .. }) + )); +} + +#[tokio::test] +async fn supplied_launch_credentials_are_refused_before_site_lookup() { + let server = MockServer::start().await; + let request = json!({ "operation":"launch", "credentials":{"api_key":"fixture"}, "base_url":server.uri(), + "plan":{"site":{"name":"shop"},"bundle":[{"path":"app/.env.local","contents":"c2VjcmV0"}]}}); + assert!(matches!( + execute_json(&request.to_string()).await, + Err(Error::InvalidBundlePath { .. }) + )); + assert!(server.received_requests().await.unwrap().is_empty()); +} From d5474b45e9955ee2b6c490144fb6a8ca58fd82df Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 19:59:56 +0300 Subject: [PATCH 04/10] fix(module): preserve legacy deployment wire budgets Co-authored-by: Medulla --- README.md | 11 +++-- crates/tinyhosts-bus/src/error.rs | 6 --- crates/tinyhosts-bus/src/preparation.rs | 2 - examples/verify_module.rs | 49 ++++++++++++++++++++++ src/rpc/mod.rs | 5 --- src/rpc/mod_tests.rs | 54 ++++++++++++++++++++----- 6 files changed, 101 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 33368b6..54fddac 100644 --- a/README.md +++ b/README.md @@ -235,10 +235,13 @@ Limits per preparation are 4 MiB decoded source, 4,096 files, 16,384 examined entries (including excluded entries), 64 directory levels, 1,024 UTF-8 bytes per relative path and a conservative 6 MiB serialized snapshot budget. Raw reads are bounded; escaped paths and base64 expansion are charged before encoded files are -allocated or published. Execute request JSON is limited to 7 MiB before decoding. -These budgets leave headroom for nested JSON-string escaping under the 16 MiB -TinyBus frame limit. Larger deployments require a future streaming protocol. -Typed `PreparationPath`, `PreparationLimit` and `RequestLimit` errors explain +allocated or published. The 6 MiB snapshot budget leaves headroom for at most +twofold nested JSON-string escaping under the 16 MiB TinyBus frame limit. These +preparation limits do not shrink legacy Launch/Deploy request budgets: requests +that fit the existing transport continue to work, including an 8 MiB base64 +bundle. Larger *directory preparation* snapshots require a future streaming +protocol; callers may still supply larger bundles through existing operations. +Typed `PreparationPath` and `PreparationLimit` errors explain refusals. File contents are omitted from preparation Debug output. The OpenHuman adapter is a separate followup, gated on a published compatible diff --git a/crates/tinyhosts-bus/src/error.rs b/crates/tinyhosts-bus/src/error.rs index aee99c8..699e31b 100644 --- a/crates/tinyhosts-bus/src/error.rs +++ b/crates/tinyhosts-bus/src/error.rs @@ -17,12 +17,6 @@ #[derive(Debug, thiserror::Error, PartialEq, Eq)] #[non_exhaustive] pub enum Error { - /// Execute request JSON exceeded its fixed transport-compatible byte budget. - #[error("request exceeds the {max_bytes}-byte wire limit")] - RequestLimit { - /// Maximum unescaped request JSON bytes. - max_bytes: usize, - }, /// Authorized preparation named an invalid or out-of-scope directory. #[error("invalid preparation directory: {reason}")] PreparationPath { diff --git a/crates/tinyhosts-bus/src/preparation.rs b/crates/tinyhosts-bus/src/preparation.rs index c130b1e..58dd9cd 100644 --- a/crates/tinyhosts-bus/src/preparation.rs +++ b/crates/tinyhosts-bus/src/preparation.rs @@ -6,8 +6,6 @@ use serde::{Deserialize, Serialize}; pub const MAX_PREPARATION_BYTES: u64 = 4 * 1024 * 1024; /// Conservative JSON snapshot budget, before nested Execute string escaping. pub const MAX_PREPARATION_JSON_BYTES: usize = 6 * 1024 * 1024; -/// Maximum Execute request JSON bytes, leaving frame headroom after escaping. -pub const MAX_RPC_REQUEST_BYTES: usize = 7 * 1024 * 1024; /// Maximum regular files collected by one preparation operation. pub const MAX_PREPARATION_FILES: u32 = 4096; /// Maximum filesystem entries examined, including excluded entries. diff --git a/examples/verify_module.rs b/examples/verify_module.rs index 70a4801..1c47181 100644 --- a/examples/verify_module.rs +++ b/examples/verify_module.rs @@ -76,6 +76,7 @@ async fn main() -> Result<(), Box> { } verify_preparation(&client, &server).await?; + verify_legacy_large_deployment(&client, &server).await?; println!( "verified {} as TinyBus module `{}`", @@ -163,3 +164,51 @@ async fn verify_preparation( Ok(()) } + +async fn verify_legacy_large_deployment( + client: &Connection, + server: &wiremock::MockServer, +) -> Result<(), Box> { + use base64::Engine as _; + let bytes = vec![b'x'; 6 * 1024 * 1024]; + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/v2/files")) + .and(wiremock::matchers::body_bytes(bytes.clone())) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .expect(1) + .mount(server) + .await; + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/v13/deployments")) + .and(wiremock::matchers::body_partial_json(serde_json::json!({ + "files":[{"file":"large.html", "size":bytes.len()}] + }))) + .respond_with( + wiremock::ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"id":"large-deploy","readyState":"READY"})), + ) + .expect(1) + .mount(server) + .await; + let request = serde_json::json!({ + "operation":"deploy", "credentials":{"api_key":"local-fixture"}, + "base_url":server.uri(), "request":{"site":"fixture", "bundle":[{ + "path":"large.html", "contents":base64::engine::general_purpose::STANDARD.encode(bytes) + }]} + }) + .to_string(); + if request.len() <= 7 * 1024 * 1024 + || serde_json::to_vec(&request)?.len() >= 16 * 1024 * 1024 - 1024 + { + return Err( + io::Error::other("large legacy fixture is outside intended wire budget").into(), + ); + } + let proxy = client.proxy(INTERFACE, OBJECT_PATH, INTERFACE)?; + let deployed: String = proxy.call("Execute", (request,)).await?; + if !deployed.contains("large-deploy") { + return Err(io::Error::other("large legacy deployment failed").into()); + } + server.verify().await; + Ok(()) +} diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index 0e3b016..44ef0d8 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -102,11 +102,6 @@ pub async fn execute(request: Request) -> Result { /// Returns [`Error::Envelope`] when the request is not a [`Request`] or the /// result cannot be serialized, and otherwise whatever [`execute`] returns. pub async fn execute_json(request: &str) -> Result { - if request.len() > tinyhosts_bus::preparation::MAX_RPC_REQUEST_BYTES { - return Err(Error::RequestLimit { - max_bytes: tinyhosts_bus::preparation::MAX_RPC_REQUEST_BYTES, - }); - } let request: Request = serde_json::from_str(request).map_err(|error| Error::Envelope { reason: error.to_string(), })?; diff --git a/src/rpc/mod_tests.rs b/src/rpc/mod_tests.rs index f15183a..6357ace 100644 --- a/src/rpc/mod_tests.rs +++ b/src/rpc/mod_tests.rs @@ -536,15 +536,6 @@ async fn supplied_deployment_credentials_are_refused_before_provider_dispatch() assert!(server.received_requests().await.unwrap().is_empty()); } -#[tokio::test] -async fn oversized_execute_json_is_refused_before_deserialization() { - let request = " ".repeat(tinyhosts_bus::preparation::MAX_RPC_REQUEST_BYTES + 1); - assert!(matches!( - execute_json(&request).await, - Err(Error::RequestLimit { .. }) - )); -} - #[tokio::test] async fn supplied_launch_credentials_are_refused_before_site_lookup() { let server = MockServer::start().await; @@ -556,3 +547,48 @@ async fn supplied_launch_credentials_are_refused_before_site_lookup() { )); assert!(server.received_requests().await.unwrap().is_empty()); } + +#[tokio::test] +async fn legacy_launch_and_deploy_accept_large_bundles_within_transport_budget() { + use base64::Engine as _; + let server = MockServer::start().await; + let source = vec![b'x'; 6 * 1024 * 1024]; + Mock::given(method("POST")) + .and(path("/v2/files")) + .and(wiremock::matchers::body_bytes(source.clone())) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .expect(2) + .mount(&server) + .await; + mount( + &server, + "GET", + "/v9/projects/shop", + 200, + json!({"id":"site","name":"shop"}), + ) + .await; + mount( + &server, + "POST", + "/v13/deployments", + 200, + json!({"id":"large-deploy","readyState":"READY"}), + ) + .await; + let bundle = json!([{"path":"index.html", "contents":base64::engine::general_purpose::STANDARD.encode(source)}]); + for operation in [ + json!({"operation":"deploy", "request":{"site":"shop", "bundle":bundle}}), + json!({"operation":"launch", "plan":{"site":{"name":"shop"}, "bundle":bundle}}), + ] { + let mut request = operation; + request["credentials"] = json!({"api_key":"fixture"}); + request["base_url"] = json!(server.uri()); + let request = request.to_string(); + assert!(request.len() > 7 * 1024 * 1024); + assert!(serde_json::to_vec(&request).unwrap().len() < 16 * 1024 * 1024 - 1024); + let result = execute_json(&request).await.unwrap(); + assert!(result.contains("large-deploy")); + } + server.verify().await; +} From 01edbd56cd92c8b5cc3cbb1611b72ffeecf67d5d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 22:55:38 +0300 Subject: [PATCH 05/10] test: satisfy stable Clippy bundle emptiness assertion Co-authored-by: Medulla --- src/preparation/mod_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/preparation/mod_tests.rs b/src/preparation/mod_tests.rs index aa9322f..84f7e62 100644 --- a/src/preparation/mod_tests.rs +++ b/src/preparation/mod_tests.rs @@ -169,7 +169,7 @@ fn serialization_budget_is_charged_before_publishing_encoded_files() { Err(Error::PreparationLimit { .. }) )); assert_eq!(out.file_count, 0); - assert!(out.bundle.is_empty()); + assert_eq!(out.bundle, [] as [tinyhosts_bus::inputs::BundleFile; 0]); charged = 512; collect_file( &dir, From 2436c3e5379ca7c13035053d143aa8b4b7087f65 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 23:12:46 +0300 Subject: [PATCH 06/10] fix(hosting): preserve contract metadata and protect deployment secrets Co-authored-by: Medulla --- crates/tinyhosts-bus/src/error.rs | 18 ++++++--- crates/tinyhosts-bus/src/lib.rs | 11 ++++++ crates/tinyhosts-bus/src/lib_tests.rs | 54 +++++++++++++++++++++++++++ crates/tinyhosts-bus/src/rpc.rs | 21 +++++++++-- src/preparation/mod.rs | 15 +++++++- src/preparation/mod_tests.rs | 32 ++++++++++++++++ src/rpc/mod.rs | 5 ++- src/rpc/mod_tests.rs | 4 +- 8 files changed, 146 insertions(+), 14 deletions(-) diff --git a/crates/tinyhosts-bus/src/error.rs b/crates/tinyhosts-bus/src/error.rs index 699e31b..ca482d1 100644 --- a/crates/tinyhosts-bus/src/error.rs +++ b/crates/tinyhosts-bus/src/error.rs @@ -14,7 +14,7 @@ //! until the reader knows which credential was rejected. /// Errors returned by this crate. -#[derive(Debug, thiserror::Error, PartialEq, Eq)] +#[derive(thiserror::Error, PartialEq, Eq)] #[non_exhaustive] pub enum Error { /// Authorized preparation named an invalid or out-of-scope directory. @@ -63,7 +63,7 @@ pub enum Error { }, /// The filesystem refused a read while building a bundle from a directory. - #[error("cannot read {path}: {reason}")] + #[error("cannot read bundle entry")] ReadBundle { /// The path that could not be read. path: String, @@ -84,7 +84,7 @@ pub enum Error { InvalidAnalyticsWindow, /// The request never reached the provider, or its response never arrived. - #[error("request to {provider} failed: {reason}")] + #[error("request to {provider} failed")] Transport { /// The provider that was being called. provider: String, @@ -156,7 +156,7 @@ pub enum Error { /// `https://` is always accepted; `http://` is accepted only against /// `localhost`, `127.0.0.1`, or `::1`, which is what a test suite or a /// local mock needs. - #[error("base url {base_url} must be https, or http against a loopback host")] + #[error("base url must be https, or http against a loopback host")] InsecureBaseUrl { /// The rejected root, as it was supplied. base_url: String, @@ -205,13 +205,21 @@ pub enum Error { }, /// A JSON envelope crossing a process boundary could not be read. - #[error("cannot decode the request envelope: {reason}")] + #[error("cannot decode the request envelope")] Envelope { /// The deserialization error. reason: String, }, } +// Match Display's redaction: Debug must not expose rejected URLs, parser input +// or operating-system paths through the retained compatibility fields. +impl std::fmt::Debug for Error { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(self, formatter) + } +} + /// The crate's standard result type. /// /// Use this alias in public signatures instead of spelling out diff --git a/crates/tinyhosts-bus/src/lib.rs b/crates/tinyhosts-bus/src/lib.rs index 44a469f..073351d 100644 --- a/crates/tinyhosts-bus/src/lib.rs +++ b/crates/tinyhosts-bus/src/lib.rs @@ -50,6 +50,17 @@ pub struct ToolDeclaration { pub parameters_schema: serde_json::Value, /// Whether OpenHuman must apply its external-effect approval policy. pub external_effect: bool, + /// Existing approval classification recorded in the tool catalog. + pub permission_level: PermissionLevel, +} + +/// Recorded hosting tool approval vocabulary. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum PermissionLevel { + /// A tool that only reads provider state. + ReadOnly, + /// A tool that changes provider state. + Write, } #[cfg(test)] diff --git a/crates/tinyhosts-bus/src/lib_tests.rs b/crates/tinyhosts-bus/src/lib_tests.rs index a0de50e..6319712 100644 --- a/crates/tinyhosts-bus/src/lib_tests.rs +++ b/crates/tinyhosts-bus/src/lib_tests.rs @@ -1,6 +1,60 @@ //! Contract wire compatibility without any hosting implementation dependency. use super::*; +#[test] +fn typed_declarations_preserve_every_recorded_permission_level() { + let original: serde_json::Value = serde_json::from_str(TOOL_DECLARATIONS_JSON).unwrap(); + let tools: Vec = serde_json::from_value(original.clone()).unwrap(); + assert_eq!(serde_json::to_value(tools).unwrap(), original); +} + +#[test] +fn request_debug_and_error_diagnostics_redact_sensitive_details() { + let secret = "https://user:secret@example.com/?token=credential"; + let request: rpc::Request = serde_json::from_value(serde_json::json!({ + "operation":"list_sites", "base_url":secret, "credentials":{"api_key":"secret"} + })) + .unwrap(); + assert!(!format!("{request:?}").contains(secret)); + for error in [ + Error::InsecureBaseUrl { + base_url: secret.into(), + }, + Error::Transport { + provider: "vercel".into(), + reason: secret.into(), + }, + Error::Envelope { + reason: secret.into(), + }, + Error::ReadBundle { + path: secret.into(), + reason: secret.into(), + }, + ] { + assert!(!error.to_string().contains(secret)); + assert!(!format!("{error:?}").contains(secret)); + } +} + +#[test] +fn raw_credential_debug_redacts_the_key_and_preserves_the_team_wire_field() { + let credentials: rpc::Credentials = serde_json::from_value(serde_json::json!({ + "api_key":"private-key", "team":"team_fixture" + })) + .unwrap(); + let debug = format!("{credentials:?}"); + assert!(!debug.contains("private-key")); + assert!(debug.contains("")); + assert!(debug.contains("team_fixture")); + assert!( + serde_json::from_value::(serde_json::json!({ + "api_key":"private-key", "unexpected":true + })) + .is_err() + ); +} + #[test] fn requests_keep_provider_defaults_operation_tags_and_secret_redaction() { let request: rpc::Request = serde_json::from_value(serde_json::json!({ diff --git a/crates/tinyhosts-bus/src/rpc.rs b/crates/tinyhosts-bus/src/rpc.rs index 4077566..f269993 100644 --- a/crates/tinyhosts-bus/src/rpc.rs +++ b/crates/tinyhosts-bus/src/rpc.rs @@ -4,7 +4,7 @@ use crate::ProviderId; use crate::model::*; use serde::{Deserialize, Serialize}; /// A request to act on one hosting account. -#[derive(Debug, Deserialize)] +#[derive(Deserialize)] #[serde(bound( deserialize = "C: Deserialize<'de>, P: Deserialize<'de>, D: Deserialize<'de>, K: Deserialize<'de> + Default" ))] @@ -31,6 +31,20 @@ pub struct Request< pub operation: Operation, } +impl std::fmt::Debug + for Request +{ + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Request") + .field("provider", &self.provider) + .field("credentials", &"") + .field("base_url", &"") + .field("operation", &self.operation) + .finish() + } +} + /// One thing a request can ask for. /// /// The variants are exactly the implementation hosting interface surface plus @@ -196,10 +210,11 @@ pub enum Outcome { #[serde(deny_unknown_fields)] pub struct Credentials { /// Provider API key, consumed only by module-side credential validation. - pub api_key: String, + #[serde(rename = "api_key")] + _api_key: String, /// Optional team identifier. #[serde(default)] - pub team: Option, + team: Option, } impl std::fmt::Debug for Credentials { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { diff --git a/src/preparation/mod.rs b/src/preparation/mod.rs index 18f270f..46c12fc 100644 --- a/src/preparation/mod.rs +++ b/src/preparation/mod.rs @@ -120,9 +120,20 @@ fn validate(input: &AuthorizedDirectory) -> Result<()> { } fn credential_entry(name: &str) -> bool { + let name = name.to_ascii_lowercase(); matches!( - name.to_ascii_lowercase().as_str(), - ".ssh" | ".aws" | ".gnupg" | ".azure" | ".kube" | "keychains" | ".env" + name.as_str(), + ".ssh" + | ".aws" + | ".gnupg" + | ".azure" + | ".kube" + | "keychains" + | ".env" + | ".npmrc" + | ".netrc" + | ".pypirc" + | ".git-credentials" ) || name.starts_with(".env.") } diff --git a/src/preparation/mod_tests.rs b/src/preparation/mod_tests.rs index 84f7e62..ebe41f0 100644 --- a/src/preparation/mod_tests.rs +++ b/src/preparation/mod_tests.rs @@ -11,6 +11,38 @@ fn input(root: &Path) -> AuthorizedDirectory { } } +#[test] +fn preparation_and_deployment_exclude_standard_credential_files_case_insensitively() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("index.html"), "safe").unwrap(); + let names = [ + ".npmrc", + ".netrc", + ".pypirc", + ".git-credentials", + ".Env.local", + ".NPMRC", + ]; + for name in names { + std::fs::write(root.path().join(name), "secret").unwrap(); + } + let prepared = prepare_bundle(&input(root.path())).unwrap(); + assert_eq!(prepared.file_count, 1); + assert_eq!(prepared.bundle[0].path, "index.html"); + for name in names { + let bundle = crate::Bundle::from_files(vec![ + crate::SiteFile::new(name, b"secret".to_vec()).unwrap(), + ]); + assert!( + matches!( + validate_deployment_bundle(&bundle), + Err(Error::InvalidBundlePath { .. }) + ), + "accepted {name}" + ); + } +} + #[test] fn preparation_preserves_source_bytes_exclusions_and_captured_snapshot() { let root = tempfile::tempdir().unwrap(); diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index 44ef0d8..35fb9ae 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -89,8 +89,9 @@ pub async fn execute(request: Request) -> Result { } Operation::ListDomains { site } => host.list_domains(&site).await.map(Outcome::Domains), Operation::Analytics { query } => host.analytics(&query).await.map(Outcome::Analytics), - _ => Err(Error::Envelope { - reason: "unsupported operation".into(), + _ => Err(Error::Unsupported { + provider: request.provider.as_str().into(), + capability: "execute this operation".into(), }), } } diff --git a/src/rpc/mod_tests.rs b/src/rpc/mod_tests.rs index 6357ace..08a679b 100644 --- a/src/rpc/mod_tests.rs +++ b/src/rpc/mod_tests.rs @@ -518,7 +518,7 @@ async fn authorized_directory_preparation_uses_execute_without_provider_or_crede } #[tokio::test] -async fn supplied_deployment_credentials_are_refused_before_provider_dispatch() { +async fn supplied_deployment_credential_files_are_refused_before_provider_dispatch() { let server = MockServer::start().await; mount( &server, @@ -537,7 +537,7 @@ async fn supplied_deployment_credentials_are_refused_before_provider_dispatch() } #[tokio::test] -async fn supplied_launch_credentials_are_refused_before_site_lookup() { +async fn supplied_launch_credential_files_are_refused_before_site_lookup() { let server = MockServer::start().await; let request = json!({ "operation":"launch", "credentials":{"api_key":"fixture"}, "base_url":server.uri(), "plan":{"site":{"name":"shop"},"bundle":[{"path":"app/.env.local","contents":"c2VjcmV0"}]}}); From 7e95eb5e17a937df7832c7f2f53070f6a49a2d0b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 23:12:46 +0300 Subject: [PATCH 07/10] fix(release): select hosting package and synchronize contract versions Co-authored-by: Medulla --- .github/scripts/check-file-coverage.sh | 14 +++-- .github/workflows/release.yml | 5 +- README.md | 2 +- tests/release_workflow.rs | 79 ++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 9 deletions(-) create mode 100644 tests/release_workflow.rs diff --git a/.github/scripts/check-file-coverage.sh b/.github/scripts/check-file-coverage.sh index 95da178..7491f1a 100755 --- a/.github/scripts/check-file-coverage.sh +++ b/.github/scripts/check-file-coverage.sh @@ -4,19 +4,21 @@ set -euo pipefail minimum="${1:-90}" report="${2:-coverage.json}" workspace_root="$(pwd -P)/" +contract_root="${workspace_root}crates/tinyhosts-bus/src/" source_root="${workspace_root}src/" cargo llvm-cov \ --locked \ + --workspace \ --all-targets \ --all-features \ --json \ --output-path "$report" -covered_files="$(jq --arg source_root "$source_root" ' +covered_files="$(jq --arg source_root "$source_root" --arg contract_root "$contract_root" ' [ .data[].files[] - | select(.filename | startswith($source_root)) + | select(.filename | startswith($source_root) or startswith($contract_root)) | select(.summary.lines.count > 0) ] | length @@ -27,9 +29,9 @@ if [[ "$covered_files" -eq 0 ]]; then exit 1 fi -summary="$(jq -r --arg workspace_root "$workspace_root" --arg source_root "$source_root" ' +summary="$(jq -r --arg workspace_root "$workspace_root" --arg source_root "$source_root" --arg contract_root "$contract_root" ' .data[].files[] - | select(.filename | startswith($source_root)) + | select(.filename | startswith($source_root) or startswith($contract_root)) | select(.summary.lines.count > 0) | [ (.filename | ltrimstr($workspace_root)), @@ -58,10 +60,10 @@ fi failures="$(jq -r \ --arg workspace_root "$workspace_root" \ - --arg source_root "$source_root" \ + --arg source_root "$source_root" --arg contract_root "$contract_root" \ --argjson minimum "$minimum" ' .data[].files[] - | select(.filename | startswith($source_root)) + | select(.filename | startswith($source_root) or startswith($contract_root)) | select(.summary.lines.count > 0) | select(.summary.lines.percent < $minimum) | "\(.filename | ltrimstr($workspace_root)): \(.summary.lines.percent)%" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6469e09..34db374 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,8 +70,8 @@ jobs: set -euo pipefail metadata="$(cargo metadata --format-version 1 --no-deps)" - crate_name="$(jq -r '.packages[0].name' <<< "$metadata")" - current_version="$(jq -r '.packages[0].version' <<< "$metadata")" + crate_name="$(jq -r '.packages[] | select(.name == "tinyhosts") | .name' <<< "$metadata")" + current_version="$(jq -r '.packages[] | select(.name == "tinyhosts") | .version' <<< "$metadata")" if [[ -z "$crate_name" || "$crate_name" == "null" ]]; then echo "Could not resolve the crate name" >&2 exit 1 @@ -141,6 +141,7 @@ jobs: run: | set -euo pipefail perl -0pi -e 's/(\[package\][\s\S]*?\nversion = ")[^"]+(")/$1$ENV{NEXT_VERSION}$2/' Cargo.toml crates/tinyhosts-bus/Cargo.toml + perl -0pi -e 's/(tinyhosts-bus = \{[^\n]*version = ")[^"]+(")/$1$ENV{NEXT_VERSION}$2/' Cargo.toml cargo update -p "$CRATE_NAME" --precise "$NEXT_VERSION" - name: Commit version bump and tag diff --git a/README.md b/README.md index 54fddac..9128ed5 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,6 @@ protocol; callers may still supply larger bundles through existing operations. Typed `PreparationPath` and `PreparationLimit` errors explain refusals. File contents are omitted from preparation Debug output. -The OpenHuman adapter is a separate followup, gated on a published compatible +The OpenHuman adapter is a separate follow-up, gated on a published compatible module artifact and digest. This change does not switch host dependencies or release package versions. diff --git a/tests/release_workflow.rs b/tests/release_workflow.rs new file mode 100644 index 0000000..03a3883 --- /dev/null +++ b/tests/release_workflow.rs @@ -0,0 +1,79 @@ +//! Exercise release shell snippets against local workspace fixtures. + +use std::process::Command; + +const RELEASE: &str = include_str!("../.github/workflows/release.yml"); + +#[test] +fn release_package_selection_is_independent_of_workspace_metadata_order() +-> Result<(), Box> { + let assignments = RELEASE + .lines() + .map(str::trim) + .filter(|line| line.starts_with("crate_name=\"") || line.starts_with("current_version=\"")) + .collect::>() + .join("\n"); + let script = format!( + "set -euo pipefail\nmetadata=\"$RELEASE_TEST_METADATA\"\n{assignments}\nprintf '%s %s' \"$crate_name\" \"$current_version\"" + ); + let output = Command::new("bash").args(["-c", &script]) + .env("RELEASE_TEST_METADATA", r#"{"packages":[{"name":"tinyhosts-bus","version":"0.2.0"},{"name":"tinyhosts","version":"0.2.3"}]}"#) + .output()?; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(String::from_utf8(output.stdout)?, "tinyhosts 0.2.3"); + Ok(()) +} + +#[test] +fn release_minor_and_major_bumps_keep_the_local_contract_dependency_compatible() +-> Result<(), Box> { + let version_commands = RELEASE + .lines() + .map(str::trim) + .filter(|line| line.starts_with("perl -0pi")) + .collect::>() + .join("\n"); + for next in ["0.3.0", "1.0.0"] { + let fixture = tempfile::tempdir()?; + std::fs::create_dir_all(fixture.path().join("crates/tinyhosts-bus/src"))?; + std::fs::create_dir(fixture.path().join("src"))?; + std::fs::write(fixture.path().join("src/lib.rs"), "")?; + std::fs::write(fixture.path().join("crates/tinyhosts-bus/src/lib.rs"), "")?; + std::fs::write( + fixture.path().join("Cargo.toml"), + "[package]\nname = \"tinyhosts\"\nversion = \"0.2.3\"\nedition = \"2024\"\n[workspace]\nmembers = [\"crates/tinyhosts-bus\"]\n[dependencies]\ntinyhosts-bus = { path = \"crates/tinyhosts-bus\", version = \"0.2\" }\n", + )?; + std::fs::write( + fixture.path().join("crates/tinyhosts-bus/Cargo.toml"), + "[package]\nname = \"tinyhosts-bus\"\nversion = \"0.2.3\"\nedition = \"2024\"\n", + )?; + let updated = Command::new("bash") + .args(["-c", &format!("set -euo pipefail\n{version_commands}")]) + .current_dir(fixture.path()) + .env("NEXT_VERSION", next) + .output()?; + assert!( + updated.status.success(), + "{}", + String::from_utf8_lossy(&updated.stderr) + ); + let metadata = Command::new("cargo") + .args(["metadata", "--format-version", "1", "--offline"]) + .current_dir(fixture.path()) + .output()?; + assert!( + metadata.status.success(), + "{}", + String::from_utf8_lossy(&metadata.stderr) + ); + let value: serde_json::Value = serde_json::from_slice(&metadata.stdout)?; + for package in value["packages"].as_array().ok_or("missing packages")? { + assert_eq!(package["version"], next); + } + } + Ok(()) +} From c688c002f98c276dedfa5c14130e2cae92891450 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 23:16:38 +0300 Subject: [PATCH 08/10] docs: align shared error ownership with the bus contract Co-authored-by: Medulla --- AGENTS.md | 9 ++++++--- crates/tinyhosts-bus/src/{error.rs => error/mod.rs} | 0 2 files changed, 6 insertions(+), 3 deletions(-) rename crates/tinyhosts-bus/src/{error.rs => error/mod.rs} (100%) diff --git a/AGENTS.md b/AGENTS.md index 2947281..f020df7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,8 +70,10 @@ missing module. Prefer many small modules that each do one thing well over few broad ones. Keep public exports centralized in `src/lib.rs` so downstream users have one -predictable surface. Put shared error variants in `src/error/mod.rs` and return -the crate-wide `Result` from fallible public APIs. +predictable surface. Shared wire error variants live in +`crates/tinyhosts-bus/src/error/mod.rs`; the implementation's `src/error/mod.rs` +re-exports that vocabulary for compatibility. Return the crate-wide `Result` +from fallible public APIs. ### Provider adapters @@ -129,7 +131,8 @@ Use standard `rustfmt` output and Rust 2024 idioms. Do not hand-format around ### Errors -- One crate-wide `Error` enum in `src/error/mod.rs`, built with `thiserror`. +- One shared `Error` enum in the contract's `src/error/mod.rs`, built with + `thiserror` and re-exported by the implementation's error module. - Fallible public functions return `Result`, the crate alias. - Add a specific variant instead of stuffing context into a string; error messages are lowercase, without trailing punctuation. diff --git a/crates/tinyhosts-bus/src/error.rs b/crates/tinyhosts-bus/src/error/mod.rs similarity index 100% rename from crates/tinyhosts-bus/src/error.rs rename to crates/tinyhosts-bus/src/error/mod.rs From 0e02da2ae72fbad88e7b1548b6c3066c6d4e3da9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 23:19:24 +0300 Subject: [PATCH 09/10] fix(rpc): bound list limits before provider dispatch Co-authored-by: Medulla --- src/rpc/mod.rs | 7 +++++-- src/rpc/mod_tests.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index 35fb9ae..61b04ef 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -59,7 +59,10 @@ pub async fn execute(request: Request) -> Result { Some(site) => Outcome::Site(site), None => Outcome::NoSite, }), - Operation::ListSites { limit } => host.list_sites(limit).await.map(Outcome::Sites), + Operation::ListSites { limit } => host + .list_sites(limit.clamp(1, 100)) + .await + .map(Outcome::Sites), Operation::SetEnv { site, vars } => { host.set_env(&site, &vars).await.map(|()| Outcome::Done) } @@ -74,7 +77,7 @@ pub async fn execute(request: Request) -> Result { Operation::Deploy { request } => host.deploy(&request).await.map(Outcome::Deployment), Operation::Deployment { id } => host.deployment(&id).await.map(Outcome::Deployment), Operation::ListDeployments { site, limit } => host - .list_deployments(&site, limit) + .list_deployments(&site, limit.clamp(1, 100)) .await .map(Outcome::Deployments), Operation::DeploymentLogs { id } => { diff --git a/src/rpc/mod_tests.rs b/src/rpc/mod_tests.rs index 08a679b..18e9cd0 100644 --- a/src/rpc/mod_tests.rs +++ b/src/rpc/mod_tests.rs @@ -27,6 +27,39 @@ async fn run(server: &MockServer, operation: Value) -> Value { serde_json::from_str(&response).unwrap() } +#[tokio::test] +async fn rpc_list_limits_are_bounded_before_provider_dispatch() { + for (requested, expected) in [(0, 1), (u32::MAX, 100), (32, 32)] { + let server = MockServer::start().await; + for (route, body) in [ + ("/v10/projects", json!({"projects":[]})), + ("/v7/deployments", json!({"deployments":[]})), + ] { + Mock::given(method("GET")) + .and(path(route)) + .and(wiremock::matchers::query_param( + "limit", + expected.to_string(), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .expect(1) + .mount(&server) + .await; + } + run( + &server, + json!({"operation":"list_sites", "limit":requested}), + ) + .await; + run( + &server, + json!({"operation":"list_deployments", "site":"shop", "limit":requested}), + ) + .await; + server.verify().await; + } +} + #[tokio::test] async fn creates_a_site() { let server = MockServer::start().await; From 1e2a09f7ed963942515d404e2168e19f0f8d7002 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 23:36:28 +0300 Subject: [PATCH 10/10] fix(preparation): distinguish credential refusal from malformed paths Co-authored-by: Medulla --- crates/tinyhosts-bus/src/error/mod.rs | 7 +++++++ src/preparation/mod.rs | 2 +- src/preparation/mod_tests.rs | 17 +++++++++++++++-- src/rpc/mod_tests.rs | 4 ++-- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/crates/tinyhosts-bus/src/error/mod.rs b/crates/tinyhosts-bus/src/error/mod.rs index ca482d1..137d7ec 100644 --- a/crates/tinyhosts-bus/src/error/mod.rs +++ b/crates/tinyhosts-bus/src/error/mod.rs @@ -62,6 +62,13 @@ pub enum Error { path: String, }, + /// A structurally valid bundle path referred to credential material. + #[error("bundle contains credential material")] + CredentialBundlePath { + /// The refused path; never included in Display or Debug. + path: String, + }, + /// The filesystem refused a read while building a bundle from a directory. #[error("cannot read bundle entry")] ReadBundle { diff --git a/src/preparation/mod.rs b/src/preparation/mod.rs index 46c12fc..969a012 100644 --- a/src/preparation/mod.rs +++ b/src/preparation/mod.rs @@ -158,7 +158,7 @@ pub(crate) fn validate_deployment_bundle(bundle: &crate::Bundle) -> Result<()> { .split('/') .any(|part| part == ".env" || part.starts_with(".env.")) { - return Err(Error::InvalidBundlePath { + return Err(Error::CredentialBundlePath { path: file.path().into(), }); } diff --git a/src/preparation/mod_tests.rs b/src/preparation/mod_tests.rs index ebe41f0..9e06051 100644 --- a/src/preparation/mod_tests.rs +++ b/src/preparation/mod_tests.rs @@ -1,3 +1,4 @@ +//! Bounded directory preparation and deployment credential exclusions. #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use super::*; @@ -36,7 +37,7 @@ fn preparation_and_deployment_exclude_standard_credential_files_case_insensitive assert!( matches!( validate_deployment_bundle(&bundle), - Err(Error::InvalidBundlePath { .. }) + Err(Error::CredentialBundlePath { .. }) ), "accepted {name}" ); @@ -116,8 +117,9 @@ fn invalid_scopes_and_bounded_file_reads_are_refused() { prepare_bundle(&request), Err(Error::PreparationLimit { .. }) )); + let empty = tempfile::tempdir().unwrap(); assert!(matches!( - prepare_bundle(&input(tempfile::tempdir().unwrap().path())), + prepare_bundle(&input(empty.path())), Err(Error::EmptyBundle) )); } @@ -314,3 +316,14 @@ fn source_replaced_by_fifo_is_refused_without_waiting_for_writer() { )); worker.join().unwrap(); } + +#[test] +fn credential_refusal_is_distinct_from_malformed_bundle_paths_without_exposing_paths() { + for path in ["app/.env.local", ".ssh/id_rsa"] { + let bundle = + crate::Bundle::from_files(vec![SiteFile::new(path, b"private".to_vec()).unwrap()]); + let error = validate_deployment_bundle(&bundle).unwrap_err(); + assert_eq!(error.to_string(), "bundle contains credential material"); + assert!(!format!("{error:?}").contains(path)); + } +} diff --git a/src/rpc/mod_tests.rs b/src/rpc/mod_tests.rs index 18e9cd0..b146a84 100644 --- a/src/rpc/mod_tests.rs +++ b/src/rpc/mod_tests.rs @@ -565,7 +565,7 @@ async fn supplied_deployment_credential_files_are_refused_before_provider_dispat "credentials":{"api_key":"fixture"},"base_url":server.uri(), "operation":"deploy","request":{"site":"site","bundle":[{"path":".ssh/id_rsa","contents":"c2VjcmV0"}]} }).to_string()).await; - assert!(matches!(response, Err(Error::InvalidBundlePath { .. }))); + assert!(matches!(response, Err(Error::CredentialBundlePath { .. }))); assert!(server.received_requests().await.unwrap().is_empty()); } @@ -576,7 +576,7 @@ async fn supplied_launch_credential_files_are_refused_before_site_lookup() { "plan":{"site":{"name":"shop"},"bundle":[{"path":"app/.env.local","contents":"c2VjcmV0"}]}}); assert!(matches!( execute_json(&request.to_string()).await, - Err(Error::InvalidBundlePath { .. }) + Err(Error::CredentialBundlePath { .. }) )); assert!(server.received_requests().await.unwrap().is_empty()); }