Merge branch 'v3' into v3-t29

# Conflicts:
#	host/Cargo.toml
#	host/src/harness/mod.rs
This commit is contained in:
2026-11-06 23:46:27 +00:00
41 changed files with 3873 additions and 229 deletions
Generated
+19
View File
@@ -885,6 +885,12 @@ dependencies = [
"serde",
]
[[package]]
name = "chunked_transfer"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]]
name = "cipher"
version = "0.4.4"
@@ -5043,6 +5049,7 @@ dependencies = [
"sys_traits",
"tempfile",
"thiserror 2.0.21",
"tiny_http",
"tokio",
"tokio-rustls",
"toml",
@@ -6303,6 +6310,18 @@ dependencies = [
"crunchy",
]
[[package]]
name = "tiny_http"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
dependencies = [
"ascii",
"chunked_transfer",
"httpdate",
"log",
]
[[package]]
name = "tinystr"
version = "0.8.5"
+1
View File
@@ -61,6 +61,7 @@ http-body = "1.1.0"
http-body-util = "0.1.5"
hyper-util = { version = "0.1.21", features = ["tokio"] }
hyper = { version = "1.12.0", features = ["client", "http1", "server"] }
tiny_http = "0.12"
[dev-dependencies]
tempfile = "3"
+133
View File
@@ -0,0 +1,133 @@
# Host API and host pages
`handle(&Api, Caller, Request<Bytes>)` answers requests without a transport.
The later `wasi:http` hook supplies the caller from the process table.
It must not accept caller identity from headers, environment variables, or JSON.
Reads are open to every caller.
JSON routes return 200 on success.
Errors use `{"error":"kind","message":"kind"}` with static, secret-free text.
Missing routes return 404. Invalid parameters return 400. Authorization failures return 403.
Conflicts return 409. The filing budget returns 429.
## Host listener
`sausage run WORLD` starts the console and the host listener with one shared `Arc<Cred>`.
The listener owns its passkey connection and handles requests on a dedicated thread.
The console receives the same credentials store, including its decision notifications.
The listener stops when the console returns.
No protected harness or process module needs a change.
Options on `sausage run`:
- `--api-listen 127.0.0.1:8081`: the default listener address.
- `--api-rp-id localhost`: the default WebAuthn RP ID.
- `--api-origin http://localhost:8081`: the default browser origin.
The origin must name the host pages, not the world server.
The listener speaks HTTP. A production HTTPS origin needs a reverse proxy to this listener.
The world's UI can link to the host pages. It does not serve their code.
The listener identifies browser calls as `/host/browser`, with the host PID and no conversation.
Passkey proofs supply the approver identity independently of that caller.
## Pages and assets
- `GET /approve/{request}` renders the host's stored summary and public details.
The page sends only the human's decision and optional limit overrides.
The host builds the binding from the stored request and its payload digest.
- `GET /register` registers the first passkey or adds another passkey.
- `GET /entry/{item}/{field}` displays a blank secret-entry form.
The host embeds its public encryption key and the selected field's public metadata.
- `GET /app.js` serves the shared ceremony code and WebCrypto encryption.
The checked-in pages use plain HTML and JavaScript with no build step or external dependencies.
The host escapes review data before it enters HTML.
No page includes a stored secret value.
## Public JSON routes
- `GET /whoami`: the transport's caller.
- `GET /cred/items`: public item snapshots.
- `GET /cred/items/{id}`: one public snapshot.
- `GET /cred/requests?state=pending`: requests with diffs and summaries.
Omit `state` to return every request.
- `GET /cred/requests/{id}`: one request with its diff and summary.
- `POST /cred/requests`: `{"kind":"grant","key":"stable-key","request":{...}}`.
The request contains `items` and optional `duration_secs`, `max_uses`, and `validator`.
Kind `edit` accepts `OperationRequest` with `item`, `base_version`, and `change`.
The response is `{"request":"id"}`. Filing does not approve anything.
- `GET /cred/grants`: the grant ledger.
Explicit request keys cannot begin with `inject:` or `ssh:`.
Those prefixes belong to automatic requests.
Explicit requests, automatic SSH requests, and new ceremonies share a per-process budget.
The budget permits 32 attempts per 60-second window.
Reads and granted SSH operations do not spend that budget.
## Decisions
- `POST /cred/requests/{id}/challenge`: `{"decision":"approve","overrides":{...}}`.
Decisions are `approve` or `deny`.
Overrides use T27's typed `Overrides` structure. Unknown fields fail.
A denial accepts only default overrides.
With no passkey, the response is `{"unlocked":true}`.
Otherwise, the response is `{"id":"challenge id","options":{...}}`.
- `POST /cred/requests/{id}/decide`: `{"challenge_id":"id","assertion":{...}}`.
The host reads the decision and overrides from the verified binding.
An optional explicit decision must match that binding.
The library checks the request ID, decision, overrides, and stored payload digest inside its transaction.
Without passkeys, this route accepts `{"decision":"approve","overrides":{...}}` instead.
The library checks its `Unlocked` proof inside the transaction.
- `POST /cred/grants/{id}/revoke`: `{"key":"stable-key"}`.
This route files a revocation request and returns `{"request":"id"}`.
Use the normal challenge and decision routes to approve it.
Successful mutations return `{"ok":true}`.
A successful assertion produces a non-cloneable `Verified` proof.
A replay or a mismatched binding cannot authorize another action.
## Passkeys
- `GET /cred/passkeys`: public credential IDs and labels, not serialized passkeys.
- `POST /cred/passkeys/register/challenge`: `{"label":"new label"}`.
The returned assertion challenge binds the exact label.
- `POST /cred/passkeys/register/start`: `{"label":"new label","gate":{"id":"challenge id","response":{...}}}`.
The first registration permits an absent gate, as D53 requires.
Every later registration needs the existing passkey's enrollment assertion.
- `POST /cred/passkeys/register/finish`: `{"challenge_id":"id","response":{...}}`.
WebAuthn options and responses use the library's browser JSON formats.
The host retains the RP configuration and pending ceremony state.
Restart discards pending ceremonies but retains registered passkeys.
## Secret entry
- `GET /cred/entry/key`: `{"public_key":[...]}` in SEC1 format.
- `POST /cred/items/{id}/fields/{field}/challenge`: `{"ciphertext_sha256":"lowercase hex"}`.
The host loads the field metadata and constructs the binding itself.
- `POST /cred/items/{id}/fields/{field}`: `{"challenge_id":"id","assertion":{...},"envelope":{...}}`.
The field parameter is its stable ID.
Secret entry requires an assertion. Register a passkey first.
The host compares the item, field, and envelope hash before it calls the encrypted field API.
That API checks the typed proof and metadata before it decrypts inside its transaction.
Public fields cannot use secret entry.
Non-UTF-8 plaintext and invalid envelopes fail without an echo.
See `cred/entry.rs` for the ECDH, HKDF, AES-GCM, and hash formats.
## SSH
- `POST /ssh/identity`: `{"host":"example.com"}`.
The response is `{"item":"id","public_key":"base64 SSH public key blob","user":null}`.
A public `user` field supplies the optional default user.
- `POST /ssh/sign`: `{"host":"example.com","item":"id","payload":"base64"}`.
The response is `{"signature":"base64 SSH wire signature"}`.
SSH operations match SSH rules and enforce free use or a caller grant.
Both operations evaluate item and grant validators through QuickJS, as D58 requires.
Identity discovery checks access without spending a signing use.
Signing reserves one use. Preparation commits its pending audit row in the same transaction.
Successful operations complete that row. Signing records only the payload hash, never the payload.
An ungranted caller receives 403 with `error`, `message`, `item`, and `request`.
The host files the request before it responds.
Two matching items produce 409. The signing item must match the selected host rule.
No SSH response returns a private key or decrypted field value.
+81
View File
@@ -0,0 +1,81 @@
// Host-only ceremonies. No world scripts or external dependencies run here.
const bytes = text => Uint8Array.from(atob(text.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
const encoded = buffer => btoa(String.fromCharCode(...new Uint8Array(buffer))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const post = async (path, body) => {
const response = await fetch(path, {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(body)});
const result = await response.json();
if (!response.ok) throw new Error(result.message);
return result;
};
function options(challenge) {
const value = challenge.options;
value.publicKey.challenge = bytes(value.publicKey.challenge);
if (value.publicKey.user) value.publicKey.user.id = bytes(value.publicKey.user.id);
for (const key of ['allowCredentials', 'excludeCredentials']) {
for (const credential of value.publicKey[key] || []) credential.id = bytes(credential.id);
}
return value;
}
function serialize(credential) {
const response = {};
for (const key of ['clientDataJSON', 'attestationObject', 'authenticatorData', 'signature', 'userHandle']) {
if (credential.response[key]) response[key] = encoded(credential.response[key]);
}
return {id: credential.id, rawId: encoded(credential.rawId), type: credential.type, response, clientExtensionResults: credential.getClientExtensionResults()};
}
async function assertion(challenge) {
return {challenge_id: challenge.id, assertion: serialize(await navigator.credentials.get(options(challenge)))};
}
async function encrypt(publicKey, secret) {
const host = await crypto.subtle.importKey('raw', new Uint8Array(publicKey), {name: 'ECDH', namedCurve: 'P-256'}, false, []);
const pair = await crypto.subtle.generateKey({name: 'ECDH', namedCurve: 'P-256'}, true, ['deriveBits']);
const shared = await crypto.subtle.deriveBits({name: 'ECDH', public: host}, pair.privateKey, 256);
const material = await crypto.subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey']);
const key = await crypto.subtle.deriveKey({name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(), info: new TextEncoder().encode('sausage/secret-entry/v1')}, material, {name: 'AES-GCM', length: 256}, false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, key, new TextEncoder().encode(secret));
const ephemeral_public_key = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey));
const envelope = {ephemeral_public_key: [...ephemeral_public_key], iv: [...iv], ciphertext: [...new Uint8Array(ciphertext)]};
const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array([...envelope.ephemeral_public_key, ...envelope.iv, ...envelope.ciphertext])));
return {envelope, ciphertext_sha256: [...hash].map(byte => byte.toString(16).padStart(2, '0')).join('')};
}
const form = document.querySelector('form');
const status = document.querySelector('[role=status]');
form.addEventListener('submit', async event => {
event.preventDefault();
const buttons = [...form.querySelectorAll('button')];
buttons.forEach(button => button.disabled = true);
status.textContent = 'Complete the passkey prompt, if one appears.';
try {
const data = new FormData(form);
if (document.body.dataset.page === 'approve') {
const path = `/cred/requests/${encodeURIComponent(document.body.dataset.request)}`;
const decision = event.submitter.value;
const overrides = decision === 'deny' || document.body.dataset.grant !== 'true' ? {} : {duration_secs: Number(data.get('duration')), max_uses: data.get('uses') ? Number(data.get('uses')) : null};
const challenge = await post(`${path}/challenge`, {decision, overrides});
await post(`${path}/decide`, challenge.unlocked ? {decision, overrides} : await assertion(challenge));
} else if (document.body.dataset.page === 'register') {
const label = data.get('label');
const state = await (await fetch('/cred/passkeys')).json();
let gate;
if (state.length) {
const proof = await assertion(await post('/cred/passkeys/register/challenge', {label}));
gate = {id: proof.challenge_id, response: proof.assertion};
}
const challenge = await post('/cred/passkeys/register/start', {label, gate});
const credential = await navigator.credentials.create(options(challenge));
await post('/cred/passkeys/register/finish', {challenge_id: challenge.id, response: serialize(credential)});
} else {
const path = `/cred/items/${encodeURIComponent(document.body.dataset.item)}/fields/${encodeURIComponent(document.body.dataset.field)}`;
const encrypted = await encrypt(JSON.parse(document.body.dataset.publicKey), data.get('secret'));
form.elements.secret.value = '';
const challenge = await post(`${path}/challenge`, {ciphertext_sha256: encrypted.ciphertext_sha256});
await post(path, {...await assertion(challenge), envelope: encrypted.envelope});
}
status.textContent = 'Complete.';
} catch {
status.textContent = 'The request failed. Review the page and try again.';
} finally {
buttons.forEach(button => button.disabled = false);
}
});
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>Approve a credential request</title>
<body data-page="approve" data-request="{{request_id}}" data-grant="{{grant_request}}">
<h1>Review the credential request</h1>
<p>{{summary}}</p><pre>{{details}}</pre>
<form><fieldset {{limits_hidden}}><legend>Grant limits</legend>
<label>Duration in seconds <input name="duration" type="number" min="1" required value="{{duration}}"></label>
<label>Maximum uses (optional override) <input name="uses" type="number" min="1" value="{{uses}}" placeholder="Use the requested limit"></label></fieldset>
<button name="decision" value="approve">Approve</button><button name="decision" value="deny">Deny</button></form>
<p role="status"></p><script type="module" src="/app.js"></script></body></html>
+220
View File
@@ -0,0 +1,220 @@
//! Authorization ceremonies. Build bindings from stored data and consume typed proofs.
use super::{Api, Error, Result, parse, string, value};
use crate::cred::{
self, Caller,
requests::Overrides,
webauthn::{Binding, Decision, Gate, Proof, Verified},
};
use serde_json::{Value, json};
fn choice(body: &Value) -> Result<(Decision, Overrides)> {
let decision: Decision = parse(body["decision"].clone())?;
let overrides: Overrides = parse(body.get("overrides").cloned().unwrap_or(json!({})))?;
if decision == Decision::Deny && overrides != Overrides::default() {
return Err(Error(400, "invalid_overrides"));
}
Ok((decision, overrides))
}
fn verify(ctx: &Api<'_>, body: &Value) -> Result<Verified> {
ctx.passkeys
.lock()
.map_err(|_| Error(500, "storage"))?
.finish_assertion(
string(body, "challenge_id")?,
parse(body["assertion"].clone())?,
)
.map_err(|_| Error(403, "assertion_failed"))
}
pub(super) fn field(ctx: &Api<'_>, item: &str, field: &str) -> Result<cred::items::Field> {
let field = ctx
.cred
.item_get(item)?
.fields
.into_iter()
.find(|f| f.field_id == field)
.ok_or(Error(404, "field_missing"))?;
if field.public {
return Err(Error(400, "secret_field_required"));
}
Ok(field)
}
pub(super) fn route(
ctx: &Api<'_>,
caller: &Caller,
method: &str,
path: &[&str],
body: Value,
) -> Result<Value> {
match (method, path) {
("POST", ["cred", "requests", id, "challenge"]) => {
ctx.rate_limit(caller)?;
let (decision, overrides) = choice(&body)?;
let binding = ctx.cred.decision_binding(id, decision, overrides)?;
let mut passkeys = ctx.passkeys.lock().map_err(|_| Error(500, "storage"))?;
if passkeys.is_unlocked() {
return Ok(json!({"unlocked":true}));
}
value(
passkeys
.start_assertion(binding)
.map_err(|_| Error(403, "assertion_failed"))?,
)
}
("POST", ["cred", "requests", id, "decide"]) => {
let (decision, overrides, proof) = if ctx
.passkeys
.lock()
.map_err(|_| Error(500, "storage"))?
.is_unlocked()
{
let (decision, overrides) = choice(&body)?;
(decision, overrides, Proof::from(ctx.cred.unlocked()?))
} else {
let verified = verify(ctx, &body)?;
let Binding::Decision {
request_id,
decision,
overrides,
..
} = verified.binding()
else {
return Err(Error(403, "binding_mismatch"));
};
if request_id.as_str() != *id
|| (body.get("decision").is_some()
&& choice(&body)? != (*decision, overrides.clone()))
{
return Err(Error(403, "binding_mismatch"));
}
(*decision, overrides.clone(), Proof::from(verified))
};
match decision {
Decision::Approve => ctx.cred.approve(id, decision, overrides, proof)?,
Decision::Deny => ctx.cred.deny(id, decision, proof)?,
}
Ok(json!({"ok":true}))
}
("GET", ["cred", "passkeys"]) => value(
ctx.passkeys
.lock()
.map_err(|_| Error(500, "storage"))?
.list()
.map_err(|_| Error(500, "storage"))?,
),
("POST", ["cred", "passkeys", "register", "challenge"]) => {
ctx.rate_limit(caller)?;
value(
ctx.passkeys
.lock()
.map_err(|_| Error(500, "storage"))?
.start_assertion(Binding::Enrollment {
label: string(&body, "label")?.into(),
})
.map_err(|_| Error(403, "assertion_failed"))?,
)
}
("POST", ["cred", "passkeys", "register", "start"]) => {
ctx.rate_limit(caller)?;
let gate: Option<Gate> = body
.get("gate")
.filter(|v| !v.is_null())
.cloned()
.map(parse)
.transpose()?;
value(
ctx.passkeys
.lock()
.map_err(|_| Error(500, "storage"))?
.start_registration(string(&body, "label")?.into(), gate)
.map_err(|_| Error(403, "registration_failed"))?,
)
}
("POST", ["cred", "passkeys", "register", "finish"]) => {
ctx.passkeys
.lock()
.map_err(|_| Error(500, "storage"))?
.finish_registration(
string(&body, "challenge_id")?,
parse(body["response"].clone())?,
)
.map_err(|_| Error(403, "registration_failed"))?;
Ok(json!({"ok":true}))
}
("GET", ["cred", "entry", "key"]) => Ok(json!({"public_key":ctx.entry.public_key()})),
("POST", ["cred", "items", item, "fields", name, "challenge"]) => {
ctx.rate_limit(caller)?;
let field = field(ctx, item, name)?;
let hash = string(&body, "ciphertext_sha256")?;
if hash.len() != 64
|| !hash
.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
{
return Err(Error(400, "invalid_hash"));
}
let binding = Binding::SecretEntry {
item: (*item).into(),
field: (*name).into(),
ciphertext_sha256: hash.into(),
field_type: Some(field.field_type),
key_type: field.key_type,
};
value(
ctx.passkeys
.lock()
.map_err(|_| Error(500, "storage"))?
.start_assertion(binding)
.map_err(|_| Error(403, "assertion_failed"))?,
)
}
("POST", ["cred", "items", item, "fields", name]) => {
let envelope: cred::entry::Envelope = parse(body["envelope"].clone())?;
let proof = verify(ctx, &body)?;
let Binding::SecretEntry {
item: bound_item,
field: bound_field,
ciphertext_sha256,
field_type,
key_type,
} = proof.binding()
else {
return Err(Error(403, "binding_mismatch"));
};
if bound_item.as_str() != *item
|| bound_field.as_str() != *name
|| *ciphertext_sha256 != envelope.sha256()
{
return Err(Error(403, "binding_mismatch"));
}
field(ctx, item, name)?;
let input = cred::items::FieldInput {
value: cred::items::FieldValue::Encrypted {
key: &ctx.entry,
envelope: &envelope,
},
field_type: field_type.clone(),
key_type: key_type.clone(),
};
ctx.cred
.set_field_input(item, name, &input, caller, proof)?;
Ok(json!({"ok":true}))
}
("POST", ["cred", "grants", id, "revoke"]) => {
ctx.rate_limit(caller)?;
ctx.cred.grant_status(id)?;
let request = ctx.cred.file_operation_request(
caller,
super::request_key(&body)?,
cred::edits::OperationRequest {
item: None,
base_version: None,
change: vec![cred::edits::Operation::GrantRevoke {
target: (*id).into(),
}],
},
)?;
Ok(json!({"request":request}))
}
_ => Err(Error(404, "route_missing")),
}
}
+8
View File
@@ -0,0 +1,8 @@
<!doctype html>
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>Enter a credential secret</title>
<body data-page="entry" data-item="{{item_id}}" data-field="{{field_id}}" data-public-key="{{public_key}}">
<h1>Enter a secret for {{item_name}}: {{field_name}}</h1>
<p>This host page encrypts the value before it sends the request.</p>
<form><label>Secret value <textarea name="secret" required autocomplete="off" spellcheck="false"></textarea></label><button>Store the secret</button></form>
<p role="status"></p><script type="module" src="/app.js"></script></body></html>
+229
View File
@@ -0,0 +1,229 @@
//! JSON routes for host.sausage. The transport supplies the trusted caller.
//! Host-owned pages use passkeys and typed proofs for every authorization ceremony.
mod auth;
mod pages;
mod server;
mod ssh;
pub use server::{Listener, Options, run_with_userland};
use crate::cred::{self, Caller, Cred, entry::EntryKey, webauthn::Passkeys};
use bytes::Bytes;
use http::{Request, Response};
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use std::{
collections::HashMap,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
pub struct Api<'a> {
pub cred: Arc<Cred>,
script: cred::quickjs::QuickJs,
pub passkeys: Mutex<Passkeys<'a>>,
pub entry: EntryKey,
requests: Mutex<HashMap<u32, (Instant, u32)>>,
}
impl<'a> Api<'a> {
/// Passkeys owns the RP configuration. Its database must remain host-only.
pub fn new(cred: impl Into<Arc<Cred>>, passkeys: Passkeys<'a>, entry: EntryKey) -> Self {
Self {
cred: cred.into(),
script: cred::quickjs::QuickJs::default(),
passkeys: Mutex::new(passkeys),
entry,
requests: Mutex::new(HashMap::new()),
}
}
fn rate_limit(&self, caller: &Caller) -> Result<()> {
let now = Instant::now();
let mut requests = self.requests.lock().map_err(|_| Error(500, "storage"))?;
requests.retain(|_, (start, _)| now.duration_since(*start) < Duration::from_secs(60));
let (_, count) = requests.entry(caller.pid).or_insert((now, 0));
if *count >= 32 {
return Err(Error(429, "rate_limited"));
}
*count += 1;
Ok(())
}
}
struct Error(u16, &'static str);
type Result<T> = std::result::Result<T, Error>;
impl From<cred::Error> for Error {
fn from(error: cred::Error) -> Self {
let status = match error.0 {
"item_deleted" | "request_missing" | "grant_missing" => 404,
"ambiguous_item" | "stale" | "already_decided" | "key_conflict" => 409,
"assertion_required" | "binding_mismatch" => 403,
"storage" => 500,
_ => 400,
};
Self(status, error.0)
}
}
fn parse<T: DeserializeOwned>(value: Value) -> Result<T> {
serde_json::from_value(value).map_err(|_| Error(400, "invalid_json"))
}
fn string<'a>(value: &'a Value, key: &str) -> Result<&'a str> {
value
.get(key)
.and_then(Value::as_str)
.ok_or(Error(400, "invalid_params"))
}
fn request_key(body: &Value) -> Result<&str> {
let key = string(body, "key")?;
if key.starts_with("inject:") || key.starts_with("ssh:") {
return Err(Error(400, "reserved_key"));
}
Ok(key)
}
fn value<T: serde::Serialize>(data: T) -> Result<Value> {
serde_json::to_value(data).map_err(|_| Error(500, "serialization"))
}
fn request_view(request: cred::requests::Request) -> Result<Value> {
let summary = if let Some(requested) = request.payload.get("requested") {
let requested: cred::edits::OperationRequest = parse(requested.clone())?;
requested
.change
.iter()
.map(cred::summary::operation_summary)
.collect::<Vec<_>>()
.join(" ")
} else if request.payload.get("grant_revoke").is_some() {
"Revoke the grant. All access through this grant will stop.".into()
} else if request.payload.get("grant_create").is_some() {
"Create a grant. Review the subject and limits in Details.".into()
} else if request.payload.get("delete").is_some() {
"Delete the item and permanently discard its field values.".into()
} else if request.payload.get("revert").is_some() {
"Restore the public configuration. Discarded secret values cannot be recovered.".into()
} else {
let grant: cred::requests::GrantRequest = parse(request.payload.clone())?;
format!(
"Give access to {} credential items for {}.",
grant.items.len(),
cred::summary::human_duration(grant.duration_secs.unwrap_or(3600))
)
};
let mut out = value(&request)?;
out["diff"] = request.payload;
out["summary"] = json!(summary);
Ok(out)
}
/// Handle a request without a network transport. Errors contain only static error kinds.
pub async fn handle(ctx: &Api<'_>, caller: Caller, request: Request<Bytes>) -> Response<Bytes> {
respond(ctx, caller, request)
}
fn respond(ctx: &Api<'_>, caller: Caller, request: Request<Bytes>) -> Response<Bytes> {
let result = if request.method() == http::Method::GET {
match pages::get(ctx, request.uri().path()) {
Ok(Some(response)) => return response,
Ok(None) => route(ctx, &caller, request),
Err(error) => Err(error),
}
} else {
route(ctx, &caller, request)
};
let (status, body) = match result {
Ok(body) => (
if body.get("error").and_then(Value::as_str) == Some("grant_required") {
403
} else {
200
},
body,
),
Err(Error(status, kind)) => (status, json!({"error":kind,"message":kind})),
};
Response::builder()
.status(status)
.header("content-type", "application/json")
.body(Bytes::from(serde_json::to_vec(&body).expect("JSON value")))
.expect("valid response")
}
fn route(ctx: &Api<'_>, caller: &Caller, request: Request<Bytes>) -> Result<Value> {
let path: Vec<_> = request.uri().path().trim_matches('/').split('/').collect();
let method = request.method().as_str();
let body: Value = if request.body().is_empty() {
json!({})
} else {
serde_json::from_slice(request.body()).map_err(|_| Error(400, "invalid_json"))?
};
match (method, path.as_slice()) {
("GET", ["whoami"]) => value(caller),
("GET", ["cred", "items"]) => value(ctx.cred.item_list()?),
("GET", ["cred", "items", id]) => value(ctx.cred.item_get(id)?),
("GET", ["cred", "requests"]) => {
let state = url::form_urlencoded::parse(request.uri().query().unwrap_or("").as_bytes())
.find(|(key, _)| key == "state")
.map(|(_, state)| state.into_owned());
ctx.cred
.request_list()?
.into_iter()
.filter(|r| state.as_ref().is_none_or(|s| s == &r.state))
.map(request_view)
.collect::<Result<Vec<_>>>()
.map(Value::Array)
}
("GET", ["cred", "requests", id]) => request_view(
ctx.cred
.request_list()?
.into_iter()
.find(|r| r.id == *id)
.ok_or(Error(404, "request_missing"))?,
),
("POST", ["cred", "requests"]) => {
let key = request_key(&body)?;
ctx.rate_limit(caller)?;
let id = match string(&body, "kind")? {
"grant" => ctx
.cred
.file_request(caller, key, parse(body["request"].clone())?)?,
"edit" => {
ctx.cred
.file_operation_request(caller, key, parse(body["request"].clone())?)?
}
_ => return Err(Error(400, "invalid_kind")),
};
Ok(json!({"request":id}))
}
("GET", ["cred", "grants"]) => value(ctx.cred.grant_list()?),
("POST", ["ssh", action @ ("identity" | "sign")]) => {
ssh::handle(ctx, caller, action, &body)
}
_ => auth::route(ctx, caller, method, &path, body),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_budget_is_per_caller_and_bounded() -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
let db = rusqlite::Connection::open_in_memory()?;
let api = Api::new(
Cred::open(dir.path())?,
Passkeys::new(&db, "localhost", "http://localhost:8080")?,
EntryKey::open(&dir.path().join("entry.key"))?,
);
let caller = Caller::default();
for _ in 0..32 {
assert!(api.rate_limit(&caller).is_ok());
}
assert!(matches!(
api.rate_limit(&caller),
Err(Error(429, "rate_limited"))
));
assert!(
api.rate_limit(&Caller {
pid: 1,
..Default::default()
})
.is_ok()
);
Ok(())
}
}
+117
View File
@@ -0,0 +1,117 @@
//! Host-owned HTML and JavaScript. Public review data enters HTML only as escaped text.
use super::{Api, Error, Result, request_view};
use bytes::Bytes;
use http::Response;
fn escape(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
pub(super) fn get(ctx: &Api<'_>, path: &str) -> Result<Option<Response<Bytes>>> {
let parts: Vec<_> = path.trim_matches('/').split('/').collect();
let (mime, text) = match parts.as_slice() {
["app.js"] => (
"text/javascript; charset=utf-8",
include_str!("app.js").to_string(),
),
["register"] => (
"text/html; charset=utf-8",
include_str!("register.html").to_string(),
),
["approve", id] => {
let request = ctx
.cred
.request_list()?
.into_iter()
.find(|request| request.id == *id)
.ok_or(Error(404, "request_missing"))?;
let items = request
.payload
.get("items")
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default();
let grant = request
.payload
.get("grant_create")
.unwrap_or(&request.payload);
let grant_request = request.payload.get("grant_create").is_some() || !items.is_empty();
let duration = grant
.get("duration_secs")
.and_then(serde_json::Value::as_u64)
.or_else(|| {
grant
.get("exp")
.and_then(serde_json::Value::as_i64)
.map(|expiry| {
expiry.saturating_sub(crate::cred::store::now()).max(1) as u64
})
})
.unwrap_or(3600);
let uses = grant
.get("max_uses")
.and_then(serde_json::Value::as_u64)
.map(|uses| uses.to_string())
.unwrap_or_default();
let mut review = request_view(request)?;
if !items.is_empty() {
review["items"] = super::value(
ctx.cred
.item_list()?
.into_iter()
.filter(|item| items.iter().any(|id| id.as_str() == Some(&item.id)))
.collect::<Vec<_>>(),
)?;
}
let text = include_str!("approve.html")
.replace(
"{{grant_request}}",
if grant_request { "true" } else { "false" },
)
.replace(
"{{limits_hidden}}",
if grant_request { "" } else { "hidden disabled" },
)
.replace("{{duration}}", &duration.to_string())
.replace("{{uses}}", &uses)
.replace("{{request_id}}", &escape(id))
.replace(
"{{summary}}",
&escape(review["summary"].as_str().expect("summary string")),
)
.replace(
"{{details}}",
&escape(
&serde_json::to_string_pretty(&review)
.map_err(|_| Error(500, "serialization"))?,
),
);
("text/html; charset=utf-8", text)
}
["entry", item, field] => {
let snapshot = ctx.cred.item_get(item)?;
let selected = super::auth::field(ctx, item, field)?;
let text = include_str!("entry.html")
.replace("{{item_id}}", &escape(item))
.replace("{{field_id}}", &escape(field))
.replace("{{item_name}}", &escape(&snapshot.name))
.replace("{{field_name}}", &escape(&selected.name))
.replace(
"{{public_key}}",
&serde_json::to_string(&ctx.entry.public_key())
.map_err(|_| Error(500, "serialization"))?,
);
("text/html; charset=utf-8", text)
}
_ => return Ok(None),
};
Ok(Some(
Response::builder()
.header("content-type", mime)
.body(Bytes::from(text))
.expect("static headers"),
))
}
+6
View File
@@ -0,0 +1,6 @@
<!doctype html>
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>Register a passkey</title><body data-page="register">
<h1>Register a passkey</h1><p>The first passkey needs no gate. Later passkeys require an existing passkey.</p>
<form><label>Label <input name="label" required autocomplete="off"></label><button>Register</button></form>
<p role="status"></p><script type="module" src="/app.js"></script></body></html>
+150
View File
@@ -0,0 +1,150 @@
//! The host HTTP listener and console startup with one shared credentials store.
use super::{Api, respond};
use crate::cred::{Caller, Cred, entry::EntryKey, webauthn::Passkeys};
use std::{net::SocketAddr, path::Path, sync::Arc};
#[derive(Clone, Debug, clap::Args)]
pub struct Options {
/// Address of the host's approval, enrollment, and secret-entry listener.
#[arg(long, default_value = "127.0.0.1:8081")]
pub api_listen: SocketAddr,
/// WebAuthn relying-party ID for the host pages.
#[arg(long, default_value = "localhost")]
pub api_rp_id: String,
/// Browser origin of the host pages, not the world's origin.
#[arg(long, default_value = "http://localhost:8081")]
pub api_origin: String,
}
pub struct Listener {
server: Arc<tiny_http::Server>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl Listener {
pub fn start(cred: Arc<Cred>, host_dir: &Path, options: Options) -> anyhow::Result<Self> {
let server = Arc::new(
tiny_http::Server::http(options.api_listen)
.map_err(|_| anyhow::anyhow!("host API address unavailable"))?,
);
let serving = server.clone();
let host_dir = host_dir.to_owned();
let (ready, started) = std::sync::mpsc::sync_channel(1);
let thread = std::thread::spawn(move || {
let setup = || -> anyhow::Result<_> {
Ok((
rusqlite::Connection::open(host_dir.join("cred.sqlite"))?,
EntryKey::open(&host_dir.join("entry.key"))?,
))
};
let serve = || -> anyhow::Result<()> {
let (db, entry) = setup()?;
let passkeys = Passkeys::new(&db, &options.api_rp_id, &options.api_origin)?;
let api = Api::new(cred, passkeys, entry);
let _ = ready.send(true);
for mut request in serving.incoming_requests() {
let response = (|| -> anyhow::Result<_> {
let mut body = Vec::new();
request.as_reader().read_to_end(&mut body)?;
let request = http::Request::builder()
.method(request.method().as_str())
.uri(request.url())
.body(bytes::Bytes::from(body))?;
Ok(respond(
&api,
Caller {
program: "/host/browser".into(),
pid: std::process::id(),
conversations: Vec::new(),
},
request,
))
})();
let response = response.unwrap_or_else(|_| {
http::Response::builder()
.status(400)
.header("content-type", "application/json")
.body(bytes::Bytes::from_static(
b"{\"error\":\"invalid_request\",\"message\":\"invalid_request\"}",
))
.expect("static response")
});
let content_type = response.headers()["content-type"].as_bytes();
let reply = tiny_http::Response::from_data(response.body().to_vec())
.with_status_code(response.status().as_u16())
.with_header(
tiny_http::Header::from_bytes(b"Content-Type", content_type)
.expect("static header"),
);
let _ = request.respond(reply);
}
Ok(())
};
if serve().is_err() {
let _ = ready.send(false);
}
});
if !started.recv().unwrap_or(false) {
let _ = thread.join();
anyhow::bail!("host API initialization failed");
}
Ok(Self {
server,
thread: Some(thread),
})
}
pub fn address(&self) -> SocketAddr {
self.server.server_addr().to_ip().expect("TCP listener")
}
}
impl Drop for Listener {
fn drop(&mut self) {
self.server.unblock();
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
/// Preserve the console lifecycle while sharing Cred with the host page listener.
pub async fn run_with_userland(
path: &Path,
userland: Option<&Path>,
options: Options,
) -> anyhow::Result<()> {
let world = crate::world::World::open_with_userland(path, userland)?;
let cred = Arc::new(Cred::open(&world.host_dir)?);
let _listener = Listener::start(cred.clone(), &world.host_dir, options)?;
let runtime = crate::process::Runtime::new(crate::world::World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
})?;
let services = crate::process::supervise(runtime.clone());
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(crate::harness::run_module_with_credentials(
world,
Arc::new(runtime),
cred,
include_str!("../../js/bundle.js"),
))
})
.await
.map_err(anyhow::Error::from)
.and_then(|result| result)
.map_err(|error| {
let mut text = error.to_string();
for key in ["PI_PROXY_BASE_URL", "PI_PROXY_API_KEY"] {
if let Ok(value) = std::env::var(key)
&& !value.is_empty()
{
text = text.replace(&value, "[redacted]");
}
}
anyhow::anyhow!(text)
});
services.shutdown().await;
result
}
+110
View File
@@ -0,0 +1,110 @@
//! SSH signing oracle. Rules and grants precede every key operation.
use super::{Api, Error, Result, string};
use crate::cred::{self, Caller, script::Script, types::FieldType};
use base64::{Engine, engine::general_purpose::STANDARD};
use serde_json::{Value, json};
pub(super) fn handle(ctx: &Api<'_>, caller: &Caller, action: &str, body: &Value) -> Result<Value> {
let cred = &ctx.cred;
let host = string(body, "host")?;
let mut items = cred.item_list()?.into_iter().filter(|item| {
item.rules
.iter()
.any(|rule| cred::items::rule_matches(rule, "ssh", host, "/", "POST"))
});
let item = items.next().ok_or(Error(404, "item_missing"))?;
if items.next().is_some() {
return Err(Error(409, "ambiguous_item"));
}
if action == "sign" && string(body, "item")? != item.id {
return Err(Error(403, "item_mismatch"));
}
let event = if action == "sign" {
"ssh.sign.pending"
} else {
"ssh.identity.pending"
};
let prepared = cred.prepare_use(&item, caller, action == "sign", event)?;
let Some((grant, values, audit_id)) = prepared else {
ctx.rate_limit(caller)?;
let request = cred.file_request(
caller,
&format!("ssh:{}", item.id),
cred::requests::GrantRequest {
items: vec![item.id.clone()],
duration_secs: None,
max_uses: None,
validator: None,
},
)?;
return Ok(
json!({"error":"grant_required","message":"grant_required","item":item.id,"request":request}),
);
};
let public = cred.credential(&item, false)?;
let context = json!({"request":{"method":"POST","url":format!("ssh://{host}/"),"headers":[]},"credential":public,"item":{"id":item.id,"name":item.name},"grant":grant.as_ref().map(|g|json!({"conversation":match &g.subject {cred::types::Subject::Conversation(id)=>Some(id),cred::types::Subject::Program(_)=>None},"subject":g.subject,"uses_left":g.max_uses.map(|m|m-g.uses),"exp":g.exp}))});
for source in [
item.validate_js.as_deref(),
grant.as_ref().and_then(|g| g.validator.as_deref()),
]
.into_iter()
.flatten()
{
let output = ctx
.script
.evaluate(source, &context.to_string(), None, Default::default())
.map_err(|_| Error(403, "validator_denied"))?;
let output: Value =
serde_json::from_str(&output).map_err(|_| Error(403, "validator_denied"))?;
if output
.as_bool()
.or_else(|| output.get("allow").and_then(Value::as_bool))
!= Some(true)
{
return Err(Error(403, "validator_denied"));
}
}
let field = item
.fields
.iter()
.find(|field| field.field_type == FieldType::SshPrivateKey)
.ok_or(Error(400, "signing_key_missing"))?;
let secret = values
.get(&field.name)
.and_then(Value::as_str)
.ok_or(Error(400, "signing_key_missing"))?;
let format = field.key_type.as_deref().ok_or(Error(400, "invalid_key"))?;
if action == "identity" {
let public_key = cred::sign::public_key(secret, None, format, "ssh")
.map_err(|_| Error(400, "invalid_key"))?;
cred.access()?
.db
.execute(
"UPDATE audit SET event='ssh.identity' WHERE id=?",
[audit_id],
)
.map_err(cred::Error::from)?;
Ok(
json!({"item":item.id,"public_key":public_key,"user":public.get("user").and_then(Value::as_str)}),
)
} else {
let payload = STANDARD
.decode(string(body, "payload")?)
.map_err(|_| Error(400, "invalid_payload"))?;
let (padding, hash) = match format {
"rs256" | "rsa-sha2-256" => (Some("pkcs1v15"), Some("sha256")),
"rsa-sha2-512" => (Some("pkcs1v15"), Some("sha512")),
_ => (None, None),
};
let signature = cred::sign::sign(secret, None, format, &payload, "ssh", padding, hash)
.map_err(|_| Error(400, "sign_failed"))?;
cred.access()?
.db
.execute(
"UPDATE audit SET event='sign',payload_sha256=?1 WHERE id=?2",
rusqlite::params![cred::webauthn::digest(&payload), audit_id],
)
.map_err(cred::Error::from)?;
Ok(json!({"signature":signature.signature}))
}
}
+14 -2
View File
@@ -188,6 +188,16 @@ impl Cred {
&self,
item: &super::items::Item,
caller: &Caller,
) -> Result<Option<(Option<Grant>, serde_json::Value, i64)>> {
self.prepare_use(item, caller, true, "fetch.pending")
}
/// Identity discovery checks access without spending a signing use.
pub(crate) fn prepare_use(
&self,
item: &super::items::Item,
caller: &Caller,
consume: bool,
event: &str,
) -> Result<Option<(Option<Grant>, serde_json::Value, i64)>> {
let mut s = self.access()?;
let master = s.master.clone();
@@ -205,14 +215,16 @@ impl Cred {
return Err(Error("stale"));
}
let credential = super::items::credential(&tx, &master, item, true)?;
if let Some(g) = &mut grant {
if let Some(g) = &mut grant
&& consume
{
let changed=tx.execute("UPDATE grants SET uses=uses+1 WHERE id=?1 AND revoked=0 AND (exp IS NULL OR exp>?2) AND (max_uses IS NULL OR uses<max_uses)",params![g.id,now()])?;
if changed != 1 {
return Err(Error("grant_exhausted"));
}
g.uses += 1;
}
audit::record(&tx, "fetch.pending", caller, Some(&item.id), None)?;
audit::record(&tx, event, caller, Some(&item.id), None)?;
let audit_id = tx.last_insert_rowid();
tx.commit()?;
Ok(Some((grant, credential, audit_id)))
+15
View File
@@ -183,6 +183,21 @@ impl<'a> Passkeys<'a> {
)
.unwrap_or(false)
}
/// Public labels and credential IDs, without serialized passkey records.
pub fn list(&self) -> Result<Vec<serde_json::Value>> {
let mut statement = self
.db
.prepare("SELECT id,label FROM passkey_credentials ORDER BY rowid")?;
let rows = statement.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
rows.map(|row| {
let (id, label) = row?;
let id: CredentialID = serde_json::from_str(&id)?;
Ok(serde_json::json!({"id":id,"label":label}))
})
.collect()
}
fn credentials(&self) -> Result<Vec<Passkey>> {
let mut q = self
.db
+1 -2
View File
@@ -258,7 +258,6 @@ pub async fn run_with_userland(path: &Path, userland: Option<&Path>) -> anyhow::
)?;
let services = crate::process::supervise(runtime.clone());
let result = run_with_shared_credentials(world, Arc::new(runtime), cred).await;
services.abort();
let _ = services.await;
services.shutdown().await;
result
}
+1
View File
@@ -1,6 +1,7 @@
//! The Sausage v3 host: a world folder behind a virtual filesystem, WASI programs in a process table,
//! and the durable agent harness. See `~/research/sausage-v3/PLAN.md` until the docs move here.
pub mod api;
pub mod cred;
pub mod exec;
pub mod harness;
+8 -2
View File
@@ -19,6 +19,8 @@ enum Command {
world: PathBuf,
#[arg(long)]
userland: Option<PathBuf>,
#[command(flatten)]
api: sausage::api::Options,
},
/// Run a shell in a world. With `-c`, run one command and exit with its status.
Sh {
@@ -37,10 +39,14 @@ async fn main() -> anyhow::Result<()> {
sausage::process::configure_cache(cache)?;
}
match cli.command {
Command::Run { world, userland } => {
Command::Run {
world,
userland,
api,
} => {
let bundle =
userland.or_else(|| std::env::var_os("SAUSAGE_USERLAND").map(PathBuf::from));
sausage::harness::run_with_userland(&world, bundle.as_deref()).await
sausage::api::run_with_userland(&world, bundle.as_deref(), api).await
}
Command::Sh {
world,
+19
View File
@@ -41,6 +41,7 @@ mod bindings {
with: {
"wasi:io": wasmtime_wasi::p2::bindings::io,
"sausage:process/host.child": super::Child,
"sausage:process/host.watcher": super::Watcher,
},
});
}
@@ -55,6 +56,8 @@ pub fn default_script() -> Arc<dyn crate::cred::script::Script> {
}
pub use cache::configure as configure_cache;
mod services;
mod watcher;
pub use watcher::Watcher;
mod stdio;
pub use services::supervise;
use stdio::Io;
@@ -567,7 +570,23 @@ impl Host for State {
Ok(self.runtime.kill_pid(pid).map_err(|e| e.to_string()))
}
}
#[async_trait::async_trait]
impl wasmtime_wasi::p2::Pollable for Child {
async fn ready(&mut self) {
while self.code.borrow_and_update().is_none() {
if self.code.changed().await.is_err() {
break;
}
}
}
}
impl HostChild for State {
async fn subscribe(
&mut self,
child: Resource<Child>,
) -> wasmtime::Result<Resource<wasmtime_wasi::p2::DynPollable>> {
wasmtime_wasi::p2::subscribe(&mut self.table, child)
}
async fn pid(&mut self, child: Resource<Child>) -> wasmtime::Result<u32> {
Ok(self.table.get(&child)?.pid)
}
+65 -146
View File
@@ -1,49 +1,28 @@
//! Supervises the world's services list, live edits, crash limits, and graceful stops.
use super::{GroupGuard, Request, Runtime, Started, Stdio};
use serde::Deserialize;
use std::{
collections::{BTreeMap, VecDeque},
sync::Arc,
time::Duration,
};
use tokio::time::Instant;
#[derive(Clone, Deserialize, PartialEq, Eq)]
struct Spec {
name: String,
argv: Vec<String>,
#[serde(default = "root")]
cwd: String,
#[serde(default)]
env: BTreeMap<String, String>,
//! Starts world init, reaps its command tree, and applies host restart and shutdown deadlines.
use super::{GroupGuard, Request, Runtime, Stdio};
use std::{sync::Arc, time::Duration};
pub struct Supervisor {
shutdown: tokio::sync::oneshot::Sender<()>,
task: tokio::task::JoinHandle<()>,
}
fn root() -> String {
"/".into()
impl Supervisor {
pub async fn shutdown(self) {
let _ = self.shutdown.send(());
let _ = self.task.await;
}
}
struct Live {
child: Started,
_guard: GroupGuard,
}
struct Service {
spec: Spec,
live: Option<Live>,
crashes: VecDeque<Instant>,
stopped: bool,
}
impl Service {
async fn start(&mut self, runtime: &Arc<Runtime>) {
match runtime
.start(
/// The host knows only init. Unit policy belongs to the WASI program.
pub fn supervise(runtime: Arc<Runtime>) -> Supervisor {
let (shutdown, mut stop) = tokio::sync::oneshot::channel();
let task = tokio::spawn(async move {
let mut backoff = Duration::from_secs(1);
loop {
let start = runtime.start(
Request {
program: self.spec.argv.first().cloned().unwrap_or_default(),
argv: self.spec.argv.clone(),
cwd: self.spec.cwd.clone(),
env: self
.spec
.env
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
program: "/bin/init.wasm".into(),
argv: vec!["/bin/init.wasm".into()],
cwd: "/".into(),
env: Vec::new(),
group: None,
stdin: Stdio::Pipe,
stdout: Stdio::Null,
@@ -54,116 +33,56 @@ impl Service {
0,
Vec::new(),
None,
)
.await
{
Ok(child) => {
);
let child = tokio::select! { _ = &mut stop => return, child = start => child };
if let Ok(mut child) = child {
let guard = GroupGuard {
runtime: runtime.clone(),
group: child.pid,
};
self.live = Some(Live {
child,
_guard: guard,
});
let exiting = async {
while child.code.borrow_and_update().is_none() {
if child.code.changed().await.is_err() {
break;
}
}
};
let shutdown = tokio::select! { _ = &mut stop => true, _ = exiting => false };
if shutdown {
drop(child.input.take());
let _ = tokio::time::timeout(Duration::from_secs(15), async {
while child.code.borrow_and_update().is_none() {
if child.code.changed().await.is_err() {
break;
}
}
})
.await;
drop(guard);
reaped(&runtime, child.pid).await;
return;
}
// This guard kills all descendants, including separate process groups.
drop(guard);
reaped(&runtime, child.pid).await;
}
Err(_) => self.crashed(),
tokio::select! { _ = &mut stop => return, _ = tokio::time::sleep(backoff) => () }
backoff = (backoff * 2).min(Duration::from_secs(30));
}
}
fn crashed(&mut self) {
let now = Instant::now();
while self
.crashes
.front()
.is_some_and(|t| now.duration_since(*t) >= Duration::from_secs(10))
});
Supervisor { shutdown, task }
}
async fn reaped(runtime: &Runtime, command: u32) {
loop {
if !runtime
.entries
.lock()
.unwrap()
.values()
.any(|entry| entry.command == command)
{
self.crashes.pop_front();
}
self.crashes.push_back(now);
self.stopped = self.crashes.len() > 5;
}
async fn stop(&mut self) {
if let Some(mut live) = self.live.take() {
drop(live.child.input.take());
let _ = tokio::time::timeout(Duration::from_secs(5), async {
loop {
if live.child.code.borrow().is_some() {
break;
}
if live.child.code.changed().await.is_err() {
break;
}
}
})
.await;
// The guard stops remaining descendants, or the process after its grace period.
return;
}
tokio::task::yield_now().await;
}
}
/// Start the supervisor. Aborting its task stops its service processes.
/// The YAML file contains name, argv, optional cwd, and an optional map of environment variables.
pub fn supervise(runtime: Arc<Runtime>) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut services: BTreeMap<String, Service> = BTreeMap::new();
let mut previous = Vec::new();
let mut interval = tokio::time::interval(Duration::from_millis(100));
loop {
interval.tick().await;
let content = match runtime
.world
.root
.open("/config/services.yaml", Default::default())
.await
{
Ok(file) => match file.stat().await {
Ok(stat) => file.read(0, stat.size as usize).await.ok(),
Err(_) => None,
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(b"[]".to_vec()),
Err(_) => None,
};
if let Some(content) = content.filter(|c| *c != previous)
&& let Ok(specs) = serde_yaml_ng::from_slice::<Vec<Spec>>(&content)
{
let specs: BTreeMap<_, _> =
specs.into_iter().map(|s| (s.name.clone(), s)).collect();
let removed: Vec<_> = services
.keys()
.filter(|name| !specs.contains_key(*name))
.cloned()
.collect();
for name in removed {
services.remove(&name).unwrap().stop().await;
}
for (name, spec) in specs {
let service = services.entry(name).or_insert_with(|| Service {
spec: spec.clone(),
live: None,
crashes: VecDeque::new(),
stopped: false,
});
if service.spec != spec {
service.stop().await;
}
service.spec = spec;
service.crashes.clear();
service.stopped = false;
}
previous = content;
}
for service in services.values_mut() {
if service
.live
.as_ref()
.is_some_and(|live| live.child.code.borrow().is_some())
{
service.live = None;
service.crashed();
}
if service.live.is_none() && !service.stopped {
service.start(&runtime).await;
}
}
}
})
}
+111
View File
@@ -0,0 +1,111 @@
//! Adapts VFS observation to bounded, pollable world-path change queues.
use super::{
State,
bindings::sausage::process::host::{Change, ChangeKind, HostWatcher},
};
use sausage_vfs::observe::{ChangeKind as Kind, under};
use tokio::sync::broadcast::{Receiver, error::TryRecvError};
use wasmtime::component::Resource;
use wasmtime_wasi::p2::{DynPollable, Pollable, subscribe};
pub struct Watcher {
paths: Vec<String>,
events: Receiver<(String, Kind)>,
pending: Vec<Change>,
}
impl Watcher {
fn push(&mut self, path: String, kind: Kind) {
if self.paths.iter().any(|base| under(&path, base)) {
if self
.pending
.iter()
.any(|c| matches!(c.kind, ChangeKind::Overflow))
{
return;
}
if self.pending.len() >= 256 {
self.overflow();
return;
}
self.pending.push(Change {
path,
kind: match kind {
Kind::Created => ChangeKind::Created,
Kind::Modified => ChangeKind::Modified,
Kind::Removed => ChangeKind::Removed,
},
});
}
}
fn overflow(&mut self) {
self.pending.clear();
self.pending.push(Change {
path: "/".into(),
kind: ChangeKind::Overflow,
});
}
fn drain(&mut self) {
loop {
match self.events.try_recv() {
Ok((path, kind)) => self.push(path, kind),
Err(TryRecvError::Lagged(_)) => self.overflow(),
Err(_) => break,
}
}
}
}
#[async_trait::async_trait]
impl Pollable for Watcher {
async fn ready(&mut self) {
loop {
self.drain();
if !self.pending.is_empty() {
return;
}
match self.events.recv().await {
Ok((path, kind)) => self.push(path, kind),
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => self.overflow(),
Err(_) => return,
}
}
}
}
impl HostWatcher for State {
async fn new(&mut self, paths: Vec<String>) -> wasmtime::Result<Resource<Watcher>> {
let observer = self
.runtime
.world
.root
.observer()
.ok_or_else(|| wasmtime::format_err!("filesystem has no observer"))?;
let paths: Vec<String> = paths
.iter()
.map(|p| {
sausage_vfs::resolve("/", p)
.trim_end_matches('/')
.to_owned()
})
.map(|p| if p.is_empty() { "/".into() } else { p })
.collect();
Ok(self.table.push(Watcher {
events: observer.subscribe(paths.clone()),
paths,
pending: Vec::new(),
})?)
}
async fn subscribe(
&mut self,
watcher: Resource<Watcher>,
) -> wasmtime::Result<Resource<DynPollable>> {
subscribe(&mut self.table, watcher)
}
async fn changes(&mut self, watcher: Resource<Watcher>) -> wasmtime::Result<Vec<Change>> {
let watcher = self.table.get_mut(&watcher)?;
watcher.drain();
Ok(std::mem::take(&mut watcher.pending))
}
async fn drop(&mut self, watcher: Resource<Watcher>) -> wasmtime::Result<()> {
self.table.delete(watcher)?;
Ok(())
}
}
+235
View File
@@ -0,0 +1,235 @@
//! Direct host API tests for reads, request filing, and the SSH signing oracle.
use anyhow::Result;
use base64::{Engine, engine::general_purpose::STANDARD};
use bytes::Bytes;
use sausage::{
api::{Api, handle},
cred::{self, Caller, Cred, entry::EntryKey, webauthn::Passkeys},
};
use serde_json::{Value, json};
fn caller() -> Caller {
Caller {
pid: 17,
program: "/bin/agent".into(),
conversations: vec!["conversation".into()],
}
}
async fn send(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<(u16, Value)> {
let request = http::Request::builder()
.method(method)
.uri(path)
.header("x-sausage-pid", "999")
.body(Bytes::from(serde_json::to_vec(&body)?))?;
let response = handle(api, caller(), request).await;
let text = std::str::from_utf8(response.body())?;
assert!(!text.contains("browser-only-value"));
assert!(!text.contains(&STANDARD.encode([7u8; 32])));
assert_eq!(response.headers()["content-type"], "application/json");
Ok((
response.status().as_u16(),
serde_json::from_slice(response.body())?,
))
}
async fn ok(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<Value> {
let (status, value) = send(api, method, path, body).await?;
assert_eq!(status, 200, "unexpected API status");
Ok(value)
}
fn item(cred: &Cred, name: &str) -> Result<cred::items::Item> {
Ok(cred.create_item(serde_json::from_value(json!({"name":name,"rules":[{"scheme":"ssh","hosts":["example.com"],"path_prefixes":["/"]}],"transform_js":"","fields":[{"name":"secret","public":false,"field_type":"ssh_private_key","key_type":"ed25519-raw"}]}))?,&caller(),cred.unlocked()?)?)
}
#[tokio::test(flavor = "current_thread")]
async fn public_routes_and_ssh() -> Result<()> {
let dir = tempfile::tempdir()?;
let db = rusqlite::Connection::open(dir.path().join("cred.sqlite"))?;
let cred = Cred::open(dir.path())?;
let item = item(&cred, "ssh")?;
cred.set_field(
&item.id,
&item.fields[0].field_id,
&STANDARD.encode([7u8; 32]),
&caller(),
cred.unlocked()?,
)?;
let api = Api::new(
cred,
Passkeys::new(&db, "localhost", "http://localhost:8080")?,
EntryKey::open(&dir.path().join("entry.key"))?,
);
let who = ok(
&api,
"GET",
"/whoami",
json!({"pid":999,"conversations":["forged"]}),
)
.await?;
assert_eq!(who["pid"], 17);
assert_eq!(who["conversations"], json!(["conversation"]));
let mut pending = String::new();
for action in ["identity", "sign"] {
let (status, denied) = send(
&api,
"POST",
&format!("/ssh/{action}"),
json!({"host":"example.com","item":item.id,"payload":"YWJj"}),
)
.await?;
assert_eq!(status, 403);
assert_eq!(denied["item"], item.id);
let request = denied["request"].as_str().unwrap();
if pending.is_empty() {
pending = request.into();
} else {
assert_eq!(pending, request);
}
}
for prefix in ["inject:", "ssh:"] {
assert_eq!(
send(
&api,
"POST",
"/cred/requests",
json!({"kind":"grant","key":format!("{prefix}spoof"),"request":{"items":[item.id]}})
)
.await?
.1["error"],
"reserved_key"
);
}
let filed = ok(
&api,
"POST",
"/cred/requests",
json!({"kind":"grant","key":"explicit","request":{"items":[item.id]},"caller":{"pid":999}}),
)
.await?;
let id = filed["request"].as_str().unwrap();
let request = ok(&api, "GET", &format!("/cred/requests/{id}"), json!({})).await?;
assert_eq!(request["pid"], 17);
assert_eq!(
request["subject"],
json!({"kind":"conversation","id":"conversation"})
);
assert!(request["diff"].is_object());
assert!(request["summary"].is_string());
assert_eq!(
ok(&api, "GET", "/cred/requests?state=approved", json!({})).await?,
json!([])
);
assert_eq!(
ok(&api, "GET", "/cred/requests?state=pending", json!({}))
.await?
.as_array()
.unwrap()
.len(),
2
);
let edit = ok(&api,"POST","/cred/requests",json!({"kind":"edit","key":"rename","request":{"item":item.id,"base_version":api.cred.item_get(&item.id)?.version,"change":[{"op":"set_name","name":"renamed"}]}})).await?;
let view = ok(
&api,
"GET",
&format!("/cred/requests/{}", edit["request"].as_str().unwrap()),
json!({}),
)
.await?;
assert!(view["summary"].as_str().unwrap().contains("Rename"));
assert_eq!(api.cred.item_get(&item.id)?.name, "ssh");
// Trusted fixture setup only. No world route creates this grant.
api.cred.grant_create(
cred::grants::Grant {
id: String::new(),
items: vec![item.id.clone()],
subject: cred::types::Subject::Conversation("conversation".into()),
exp: None,
max_uses: Some(1),
uses: 0,
revoked: false,
validator: None,
note: None,
},
&caller(),
api.cred.unlocked()?,
)?;
let identity = ok(&api, "POST", "/ssh/identity", json!({"host":"example.com"})).await?;
assert_eq!(identity["item"], item.id);
assert_eq!(api.cred.grant_list()?[0].uses, 0);
let public = STANDARD.decode(identity["public_key"].as_str().unwrap())?;
let expected = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]).verifying_key();
assert!(public.ends_with(expected.as_bytes()));
assert_ne!(
send(
&api,
"POST",
"/ssh/sign",
json!({"host":"example.com","item":"different","payload":"YWJj"})
)
.await?
.0,
200
);
let signed = ok(
&api,
"POST",
"/ssh/sign",
json!({"host":"example.com","item":item.id,"payload":STANDARD.encode(b"payload")}),
)
.await?;
assert_eq!(api.cred.grant_list()?[0].uses, 1);
let audit = api.cred.audit_list()?;
assert!(
audit
.iter()
.any(|row| row.event == "sign" && row.payload_sha256.is_some())
);
assert!(
!audit
.iter()
.any(|row| row.event == "ssh.sign.pending" || row.event == "fetch.pending")
);
let signature = STANDARD.decode(signed["signature"].as_str().unwrap())?;
use ed25519_dalek::Verifier;
assert!(signature.starts_with(b"\0\0\0\x0bssh-ed25519\0\0\0\x40"));
expected.verify(
b"payload",
&ed25519_dalek::Signature::from_slice(&signature[19..])?,
)?;
assert_eq!(
send(
&api,
"POST",
"/ssh/sign",
json!({"host":"example.com","item":item.id,"payload":"YWJj"})
)
.await?
.0,
403
);
ok(&api, "GET", "/cred/items", json!({})).await?;
ok(&api, "GET", &format!("/cred/items/{}", item.id), json!({})).await?;
ok(&api, "GET", "/cred/grants", json!({})).await?;
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn ssh_rejects_ambiguous_items() -> Result<()> {
let dir = tempfile::tempdir()?;
let db = rusqlite::Connection::open_in_memory()?;
let cred = Cred::open(dir.path())?;
item(&cred, "one")?;
item(&cred, "two")?;
let api = Api::new(
cred,
Passkeys::new(&db, "localhost", "http://localhost:8080")?,
EntryKey::open(&dir.path().join("entry.key"))?,
);
assert_eq!(
send(&api, "POST", "/ssh/identity", json!({"host":"example.com"}))
.await?
.0,
409
);
assert!(api.cred.request_list()?.is_empty());
Ok(())
}
+433
View File
@@ -0,0 +1,433 @@
//! Host page routes with a software authenticator and the page's WebCrypto implementation.
use anyhow::Result;
use bytes::Bytes;
use sausage::{
api::{Api, handle},
cred::{
self, Caller, Cred,
entry::{EntryKey, Envelope},
webauthn::{Challenge, Passkeys},
},
};
use serde_json::{Value, json};
use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};
use webauthn_rs::prelude::{CreationChallengeResponse, RequestChallengeResponse, Url};
const ORIGIN: &str = "http://localhost:8081";
const SECRET: &str = "browser-only-value";
fn caller() -> Caller {
Caller {
pid: 17,
program: "/bin/agent".into(),
conversations: vec!["conversation".into()],
}
}
async fn send(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<(u16, Value)> {
let response = handle(
api,
caller(),
http::Request::builder()
.method(method)
.uri(path)
.body(Bytes::from(serde_json::to_vec(&body)?))?,
)
.await;
assert!(!std::str::from_utf8(response.body())?.contains(SECRET));
Ok((
response.status().as_u16(),
serde_json::from_slice(response.body())?,
))
}
async fn ok(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<Value> {
let (status, value) = send(api, method, path, body).await?;
assert_eq!(status, 200, "unexpected API status");
Ok(value)
}
async fn page(api: &Api<'_>, path: &str) -> Result<String> {
let response = handle(
api,
caller(),
http::Request::builder().uri(path).body(Bytes::new())?,
)
.await;
assert_eq!(response.status(), 200);
assert_eq!(
response.headers()["content-type"],
"text/html; charset=utf-8"
);
let text = std::str::from_utf8(response.body())?;
assert!(!text.contains(SECRET));
assert!(text.contains("/app.js"));
Ok(text.into())
}
fn item(cred: &Cred, name: &str) -> Result<cred::items::Item> {
Ok(cred.create_item(serde_json::from_value(json!({"name":name,"rules":[],"transform_js":"","fields":[{"name":"first","field_type":"text"},{"name":"second","field_type":"text"}]}))?,&caller(),cred.unlocked()?)?)
}
async fn file(api: &Api<'_>, item: &str, key: &str) -> Result<String> {
let result = ok(
api,
"POST",
"/cred/requests",
json!({"kind":"grant","key":key,"request":{"items":[item],"duration_secs":300}}),
)
.await?;
Ok(result["request"].as_str().unwrap().into())
}
async fn register(api: &Api<'_>) -> Result<WebauthnAuthenticator<SoftPasskey>> {
let start: Challenge<CreationChallengeResponse> = serde_json::from_value(
ok(
api,
"POST",
"/cred/passkeys/register/start",
json!({"label":"browser"}),
)
.await?,
)?;
let mut auth = WebauthnAuthenticator::new(SoftPasskey::new(true));
let response = auth.do_registration(Url::parse(ORIGIN)?, start.options)?;
ok(
api,
"POST",
"/cred/passkeys/register/finish",
json!({"challenge_id":start.id,"response":response}),
)
.await?;
Ok(auth)
}
async fn assertion(
api: &Api<'_>,
auth: &mut WebauthnAuthenticator<SoftPasskey>,
path: &str,
body: Value,
) -> Result<Value> {
let start: Challenge<RequestChallengeResponse> =
serde_json::from_value(ok(api, "POST", path, body).await?)?;
let assertion = auth.do_authentication(Url::parse(ORIGIN)?, start.options)?;
Ok(json!({"challenge_id":start.id,"assertion":assertion}))
}
#[tokio::test(flavor = "current_thread")]
async fn approvals_registration_and_revocation() -> Result<()> {
let dir = tempfile::tempdir()?;
let db = rusqlite::Connection::open(dir.path().join("cred.sqlite"))?;
let cred = Cred::open(dir.path())?;
let first = item(&cred, "bootstrap")?;
let second = item(&cred, "review <script>not markup</script>")?;
let api = Api::new(
cred,
Passkeys::new(&db, "localhost", ORIGIN)?,
EntryKey::open(&dir.path().join("entry.key"))?,
);
page(&api, "/register").await?;
let bootstrap = file(&api, &first.id, "bootstrap").await?;
let bootstrap_path = format!("/cred/requests/{bootstrap}");
assert_eq!(
ok(
&api,
"POST",
&format!("{bootstrap_path}/challenge"),
json!({"decision":"approve"})
)
.await?["unlocked"],
true
);
ok(
&api,
"POST",
&format!("{bootstrap_path}/decide"),
json!({"decision":"approve"}),
)
.await?;
assert!(
api.cred
.grant_list()?
.iter()
.any(|g| g.items.contains(&first.id))
);
let mut auth = register(&api).await?;
let list = ok(&api, "GET", "/cred/passkeys", json!({})).await?;
assert_eq!(list[0]["label"], "browser");
assert!(!list[0]["id"].as_str().unwrap().contains('"'));
assert_eq!(
send(
&api,
"POST",
"/cred/passkeys/register/start",
json!({"label":"ungated"})
)
.await?
.0,
403
);
let id = file(&api, &second.id, "approval").await?;
let other = file(&api, &second.id, "other").await?;
let path = format!("/cred/requests/{id}");
let review = page(&api, &format!("/approve/{id}")).await?;
assert!(review.contains("data-grant=\"true\""));
assert!(review.contains("value=\"300\""));
assert!(review.contains("&lt;script&gt;not markup&lt;/script&gt;"));
assert!(!review.contains("<script>not markup</script>"));
assert_ne!(
send(
&api,
"POST",
&format!("{path}/decide"),
json!({"decision":"approve"})
)
.await?
.0,
200
);
assert_eq!(
send(
&api,
"POST",
&format!("{path}/challenge"),
json!({"decision":"approve","overrides":{"max_use":1}})
)
.await?
.0,
400
);
let gate = assertion(
&api,
&mut auth,
&format!("{path}/challenge"),
json!({"decision":"approve"}),
)
.await?;
assert_eq!(
send(
&api,
"POST",
&format!("/cred/requests/{other}/decide"),
gate.clone()
)
.await?
.0,
403
);
assert_eq!(
send(&api, "POST", &format!("{path}/decide"), gate).await?.0,
403
);
let mut gate = assertion(
&api,
&mut auth,
&format!("{path}/challenge"),
json!({"decision":"deny"}),
)
.await?;
gate["decision"] = json!("approve");
assert_eq!(
send(&api, "POST", &format!("{path}/decide"), gate).await?.0,
403
);
let gate = assertion(
&api,
&mut auth,
&format!("{path}/challenge"),
json!({"decision":"approve","overrides":{"max_uses":2}}),
)
.await?;
ok(&api, "POST", &format!("{path}/decide"), gate.clone()).await?;
assert_eq!(
send(&api, "POST", &format!("{path}/decide"), gate).await?.0,
403
);
let grant = api
.cred
.grant_list()?
.into_iter()
.find(|g| g.items.contains(&second.id))
.unwrap();
assert!(grant.subject.covers(&caller()));
assert_eq!(grant.max_uses, Some(2));
let revoke = ok(
&api,
"POST",
&format!("/cred/grants/{}/revoke", grant.id),
json!({"key":"revoke"}),
)
.await?;
assert!(!api.cred.grant_status(&grant.id)?.revoked);
let revoke = revoke["request"].as_str().unwrap();
let gate = assertion(
&api,
&mut auth,
&format!("/cred/requests/{revoke}/challenge"),
json!({"decision":"approve"}),
)
.await?;
ok(
&api,
"POST",
&format!("/cred/requests/{revoke}/decide"),
gate,
)
.await?;
assert!(api.cred.grant_status(&grant.id)?.revoked);
let edit = ok(&api,"POST","/cred/requests",json!({"kind":"edit","key":"rename","request":{"item":second.id,"base_version":api.cred.item_get(&second.id)?.version,"change":[{"op":"set_name","name":"renamed"}]}})).await?;
let edit = edit["request"].as_str().unwrap();
let edit_page = page(&api, &format!("/approve/{edit}")).await?;
assert!(edit_page.contains("data-grant=\"false\""));
assert!(edit_page.contains("<fieldset hidden disabled>"));
let edit_gate = assertion(
&api,
&mut auth,
&format!("/cred/requests/{edit}/challenge"),
json!({"decision":"approve"}),
)
.await?;
ok(
&api,
"POST",
&format!("/cred/requests/{edit}/decide"),
edit_gate,
)
.await?;
assert_eq!(api.cred.item_get(&second.id)?.name, "renamed");
let gate = assertion(
&api,
&mut auth,
"/cred/passkeys/register/challenge",
json!({"label":"second"}),
)
.await?;
let start: Challenge<CreationChallengeResponse> = serde_json::from_value(ok(&api,"POST","/cred/passkeys/register/start",json!({"label":"second","gate":{"id":gate["challenge_id"],"response":gate["assertion"]}})).await?)?;
let mut another = WebauthnAuthenticator::new(SoftPasskey::new(true));
let response = another.do_registration(Url::parse(ORIGIN)?, start.options)?;
ok(
&api,
"POST",
"/cred/passkeys/register/finish",
json!({"challenge_id":start.id,"response":response}),
)
.await?;
assert_eq!(
ok(&api, "GET", "/cred/passkeys", json!({}))
.await?
.as_array()
.unwrap()
.len(),
2
);
Ok(())
}
async fn encrypt(api: &Api<'_>, public_key: Value) -> Result<Envelope> {
use deno_core::{JsRuntime, RuntimeOptions};
let script = handle(
api,
caller(),
http::Request::builder().uri("/app.js").body(Bytes::new())?,
)
.await;
assert_eq!(script.status(), 200);
assert!(!std::str::from_utf8(script.body())?.contains(SECRET));
let mut runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![
deno_webidl::deno_webidl::init(),
deno_web::deno_web::init(
std::sync::Arc::new(deno_web::BlobStore::default()),
None,
false,
Default::default(),
),
deno_crypto::deno_crypto::init(None),
],
..Default::default()
});
runtime.execute_script("browser.js","globalThis.crypto = Deno.core.loadExtScript('ext:deno_crypto/00_crypto.js').crypto; globalThis.TextEncoder = Deno.core.loadExtScript('ext:deno_web/08_text_encoding.js').TextEncoder; globalThis.document = {querySelector: () => ({addEventListener() {}})};")?;
runtime.execute_script("app.js", std::str::from_utf8(script.body())?.to_string())?;
runtime.execute_script("encrypt.js",format!("globalThis.done = encrypt({public_key},'{SECRET}').then(result => globalThis.envelope = JSON.stringify(result.envelope));"))?;
runtime.run_event_loop(Default::default()).await?;
let value = runtime.execute_script("result.js", "envelope")?;
let text = {
deno_core::scope!(scope, &mut runtime);
let value = deno_core::v8::Local::new(scope, value);
value.to_rust_string_lossy(scope)
};
Ok(serde_json::from_str(&text)?)
}
#[tokio::test(flavor = "current_thread")]
async fn encrypted_entry_uses_the_host_page_code() -> Result<()> {
let dir = tempfile::tempdir()?;
let db = rusqlite::Connection::open(dir.path().join("cred.sqlite"))?;
let cred = Cred::open(dir.path())?;
let item = item(&cred, "secret")?;
let api = Api::new(
cred,
Passkeys::new(&db, "localhost", ORIGIN)?,
EntryKey::open(&dir.path().join("entry.key"))?,
);
let mut auth = register(&api).await?;
let public = ok(&api, "GET", "/cred/entry/key", json!({})).await?;
let entry_page = page(
&api,
&format!("/entry/{}/{}", item.id, item.fields[0].field_id),
)
.await?;
assert!(entry_page.contains(&public["public_key"].to_string()));
let envelope = encrypt(&api, public["public_key"].clone()).await?;
let path = format!("/cred/items/{}/fields/{}", item.id, item.fields[0].field_id);
for target in [
format!("/cred/items/{}/fields/{}", item.id, item.fields[1].field_id),
format!("/cred/items/other/fields/{}", item.fields[0].field_id),
] {
let mut gate = assertion(
&api,
&mut auth,
&format!("{path}/challenge"),
json!({"ciphertext_sha256":envelope.sha256()}),
)
.await?;
gate["envelope"] = serde_json::to_value(&envelope)?;
assert_eq!(send(&api, "POST", &target, gate).await?.0, 403);
}
let mut gate = assertion(
&api,
&mut auth,
&format!("{path}/challenge"),
json!({"ciphertext_sha256":"0".repeat(64)}),
)
.await?;
gate["envelope"] = serde_json::to_value(&envelope)?;
assert_eq!(send(&api, "POST", &path, gate).await?.0, 403);
assert!(api.cred.item_get(&item.id)?.fields[0].needs_value);
let mut gate = assertion(
&api,
&mut auth,
&format!("{path}/challenge"),
json!({"ciphertext_sha256":envelope.sha256()}),
)
.await?;
gate["envelope"] = serde_json::to_value(&envelope)?;
ok(&api, "POST", &path, gate.clone()).await?;
assert_eq!(send(&api, "POST", &path, gate).await?.0, 403);
assert!(!api.cred.item_get(&item.id)?.fields[0].needs_value);
ok(&api, "GET", &format!("/cred/items/{}", item.id), json!({})).await?;
let master = std::fs::read(dir.path().join("cred.key"))?;
let cipher: Vec<u8> = db.query_row(
"SELECT ciphertext FROM fields WHERE item=?",
[&item.id],
|r| r.get(0),
)?;
use chacha20poly1305::{
XChaCha20Poly1305, XNonce,
aead::{Aead, KeyInit, Payload},
};
let aad = format!("{}|{}", item.id, item.fields[0].field_id);
let plaintext = XChaCha20Poly1305::new_from_slice(&master)
.unwrap()
.decrypt(
XNonce::from_slice(&cipher[..24]),
Payload {
msg: &cipher[24..],
aad: aad.as_bytes(),
},
)
.map_err(|_| anyhow::anyhow!("fixture decrypt failed"))?;
assert!(plaintext == SECRET.as_bytes());
Ok(())
}
+57
View File
@@ -0,0 +1,57 @@
//! Native HTTP smoke test for host pages and the listener's CLI options.
use anyhow::Result;
use sausage::{
api::{Listener, Options},
cred::Cred,
};
use std::{
io::{Read, Write},
sync::Arc,
};
#[test]
fn listener_serves_host_pages() -> Result<()> {
let dir = tempfile::tempdir()?;
let cred = Arc::new(Cred::open(dir.path())?);
let listener = Listener::start(
cred,
dir.path(),
Options {
api_listen: "127.0.0.1:0".parse()?,
api_rp_id: "localhost".into(),
api_origin: "http://localhost:8081".into(),
},
)?;
for (path, expected) in [
("/register", "Register a passkey"),
("/app.js", "navigator.credentials"),
("/whoami", "/host/browser"),
] {
let mut connection = std::net::TcpStream::connect(listener.address())?;
write!(
connection,
"GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
)?;
let mut response = String::new();
connection.read_to_string(&mut response)?;
assert!(response.starts_with("HTTP/1.1 200"));
assert!(response.contains(expected));
assert!(!response.contains("PRIVATE KEY"));
}
drop(listener);
let help = std::process::Command::new(env!("CARGO_BIN_EXE_sausage"))
.args(["run", "--help"])
.output()?;
assert!(help.status.success());
let help = std::str::from_utf8(&help.stdout)?;
for option in [
"--api-listen",
"127.0.0.1:8081",
"--api-rp-id",
"--api-origin",
"http://localhost:8081",
] {
assert!(help.contains(option));
}
Ok(())
}
+525
View File
@@ -0,0 +1,525 @@
//! Init acceptance tests use real WASI programs, a persistent guest cache, and temporary worlds.
use sausage::{
exec::{Command, Exec, Options, Stream},
process::{Runtime, supervise},
world::World,
};
use sausage_vfs::{Layer, Open};
use std::{
path::{Path, PathBuf},
sync::{Arc, OnceLock},
time::Duration,
};
static SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
fn guests() -> &'static Path {
static BUILT: OnceLock<PathBuf> = OnceLock::new();
BUILT.get_or_init(|| {
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("init-guests");
let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland/Cargo.toml");
assert!(
std::process::Command::new("cargo")
.args([
"build",
"--release",
"--target",
"wasm32-wasip2",
"-p",
"sausage-init",
"--manifest-path"
])
.arg(manifest)
.env("CARGO_TARGET_DIR", &target)
.status()
.unwrap()
.success()
);
target.join("wasm32-wasip2/release")
})
}
fn world() -> (tempfile::TempDir, Arc<Runtime>, Arc<dyn Layer>) {
let temp = tempfile::tempdir().unwrap();
let bin = temp.path().join("world/bin");
std::fs::create_dir_all(&bin).unwrap();
for (source, name) in [
("init", "init"),
("systemctl", "systemctl"),
("journalctl", "journalctl"),
("systemd-run", "systemd-run"),
("init-probe", "probe"),
] {
std::fs::copy(
guests().join(format!("{source}.wasm")),
bin.join(format!("{name}.wasm")),
)
.unwrap();
}
std::fs::create_dir_all(
temp.path()
.join("world/etc/systemd/system/default.target.wants"),
)
.unwrap();
let cache = Path::new(env!("CARGO_TARGET_TMPDIR")).join("init-components");
std::fs::create_dir_all(&cache).unwrap();
std::fs::create_dir_all(temp.path().join("host")).unwrap();
std::os::unix::fs::symlink(cache, temp.path().join("host/compile-cache")).unwrap();
let world = World::open(temp.path()).unwrap();
let root = world.root.clone();
(temp, Runtime::new(world).unwrap(), root)
}
async fn write(root: &Arc<dyn Layer>, path: &str, text: &[u8]) {
let file = root
.open(
path,
Open {
create: true,
write: true,
truncate: true,
..Default::default()
},
)
.await
.unwrap();
file.write(0, text.to_vec()).await.unwrap();
}
async fn unit(root: &Arc<dyn Layer>, name: &str, body: &str) {
write(
root,
&format!("/etc/systemd/system/default.target.wants/{name}.service"),
b"",
)
.await;
write(
root,
&format!("/etc/systemd/system/{name}.service"),
body.as_bytes(),
)
.await;
}
async fn run(runtime: &Arc<Runtime>, argv: &[&str]) -> (i32, String) {
let mut out = Vec::new();
let code = runtime
.exec(
Command::Argv(argv.iter().map(|a| (*a).into()).collect()),
Options {
timeout: Some(Duration::from_secs(30)),
cwd: "/".into(),
..Default::default()
},
&mut |stream, bytes| {
if stream == Stream::Stdout {
out.extend_from_slice(bytes);
} else {
eprint!("{}", String::from_utf8_lossy(bytes));
}
},
)
.await
.unwrap();
(code, String::from_utf8_lossy(&out).into_owned())
}
async fn wait(mut check: impl FnMut() -> bool) {
tokio::time::timeout(Duration::from_secs(30), async {
while !check() {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
}
async fn state(root: &Arc<dyn Layer>, unit: &str, expected: &str) {
tokio::time::timeout(Duration::from_secs(30), async {
loop {
if let Ok(file) = root
.open(
&format!("/run/systemd/state/{unit}.service"),
Default::default(),
)
.await
&& let Ok(bytes) = file.read(0, 65536).await
&& serde_json::from_slice::<serde_json::Value>(&bytes)
.is_ok_and(|s| s["state"] == expected)
{
break;
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
}
fn lines(path: &Path) -> Vec<String> {
std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
.map(str::to_owned)
.collect()
}
#[tokio::test]
async fn unrelated_bursts_and_watched_overflow_only_reload_changed_units() {
let _serial = SERIAL.lock().await;
let (temp, runtime, root) = world();
let supervisor = supervise(runtime.clone());
let path = temp.path().join("world");
wait(|| path.join("var/log/journal").exists()).await;
for name in ["changed", "unchanged"] {
write(
&root,
&format!("/run/systemd/transient/{name}.service"),
format!("[Service]\nExecStart=probe server /{name}.log\n").as_bytes(),
)
.await;
assert_eq!(
run(&runtime, &["/bin/systemctl.wasm", "start", name])
.await
.0,
0
);
wait(|| lines(&path.join(format!("{name}.log"))) == ["start"]).await;
}
tokio::task::unconstrained(async {
for _ in 0..4000 {
write(&root, "/run/unrelated", b"noise").await;
}
})
.await;
assert_eq!(
run(&runtime, &["/bin/systemctl.wasm", "start", "unchanged"])
.await
.0,
0
);
assert_eq!(lines(&path.join("changed.log")), ["start"]);
assert_eq!(lines(&path.join("unchanged.log")), ["start"]);
// This single-thread executor cannot schedule init during ready tmpfs writes.
// The watched queue therefore overflows before init can consume the edits.
tokio::task::unconstrained(async {
write(
&root,
"/run/systemd/transient/changed.service",
b"[Service]\nExecStart=probe server /changed.log\nEnvironment=REV=2\n",
)
.await;
for _ in 0..1000 {
write(&root, "/run/systemd/transient/noise", b"noise").await;
}
})
.await;
wait(|| lines(&path.join("changed.log")) == ["start", "stop", "start"]).await;
assert_eq!(
run(&runtime, &["/bin/systemctl.wasm", "start", "unchanged"])
.await
.0,
0
);
assert_eq!(lines(&path.join("unchanged.log")), ["start"]);
// Bare names also watch the resolved executable, not the literal name.
let mut bytes = std::fs::read(guests().join("init-probe.wasm")).unwrap();
bytes.extend_from_slice(&[0, 2, 1, b'x']);
write(&root, "/bin/replacement.wasm", &bytes).await;
root.rename("/bin/replacement.wasm", "/bin/probe.wasm")
.await
.unwrap();
wait(|| lines(&path.join("unchanged.log")) == ["start", "stop", "start"]).await;
supervisor.shutdown().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn watcher_and_child_exit_pollable() {
let _serial = SERIAL.lock().await;
let (_temp, runtime, _) = world();
let result = run(&runtime, &["/bin/probe.wasm", "watch"]).await;
assert_eq!(result.0, 0, "{}", result.1);
}
#[tokio::test(flavor = "multi_thread")]
async fn dependencies_restart_limit_and_journal() {
let _serial = SERIAL.lock().await;
let (temp, runtime, root) = world();
unit(
&root,
"prepare",
"[Service]\nType=oneshot\nExecStart=/bin/probe.wasm oneshot /order.log\n",
)
.await;
unit(&root, "after", "[Unit]\nAfter=prepare.service\nRequires=prepare.service\n[Service]\nExecStart=/bin/probe.wasm after /after.log\n").await;
unit(
&root,
"fail",
"[Service]\nType=oneshot\nExecStart=/bin/probe.wasm exit 2\n",
)
.await;
unit(&root, "blocked", "[Unit]\nAfter=fail.service\nRequires=fail.service\n[Service]\nExecStart=/bin/probe.wasm oneshot /blocked.log\n").await;
unit(&root, "crash", "[Service]\nExecStart=/bin/probe.wasm crash /crash.log\nRestart=on-failure\nRestartSec=0.01\n").await;
unit(&root, "always", "[Service]\nExecStart=/bin/probe.wasm oneshot /always.log\nRestart=always\nRestartSec=0.01\nStartLimitBurst=2\n").await;
unit(
&root,
"success",
"[Service]\nExecStart=/bin/probe.wasm oneshot /success.log\nRestart=on-failure\n",
)
.await;
unit(
&root,
"no",
"[Service]\nExecStart=/bin/probe.wasm crash /no.log\nRestart=no\n",
)
.await;
unit(
&root,
"linger",
"[Service]\nExecStart=/bin/probe.wasm linger /linger.log\nTimeoutStopSec=0.02\n",
)
.await;
let supervisor = supervise(runtime.clone());
let path = temp.path().join("world");
wait(|| {
lines(&path.join("after.log")) == ["start"] && lines(&path.join("crash.log")).len() == 5
})
.await;
state(&root, "always", "failed").await;
assert_eq!(lines(&path.join("always.log")).len(), 2);
assert_eq!(lines(&path.join("success.log")).len(), 1);
assert_eq!(lines(&path.join("no.log")).len(), 1);
state(&root, "crash", "failed").await;
state(&root, "after", "active").await;
assert_eq!(lines(&path.join("order.log")), ["start"]);
assert!(!path.join("blocked.log").exists());
wait(|| {
std::fs::read_to_string(path.join("var/log/journal/after.service.log"))
.unwrap_or_default()
.contains("line 10")
})
.await;
assert_eq!(
run(&runtime, &["/bin/systemctl.wasm", "is-active", "after"])
.await
.0,
0
);
assert_eq!(
run(&runtime, &["/bin/systemctl.wasm", "is-active", "crash"])
.await
.0,
3
);
assert_eq!(
run(&runtime, &["/bin/systemctl.wasm", "is-failed", "crash"])
.await
.0,
0
);
let (code, output) = run(
&runtime,
&["/bin/journalctl.wasm", "-u", "after", "-n", "5"],
)
.await;
assert_eq!(code, 0);
assert_eq!(output, "line 6\nline 7\nline 8\nline 9\nline 10\n");
state(&root, "linger", "active").await;
assert_eq!(
run(&runtime, &["/bin/systemctl.wasm", "stop", "linger"])
.await
.0,
0
);
state(&root, "linger", "inactive").await;
assert_eq!(lines(&path.join("linger.log")), ["start"]);
supervisor.shutdown().await;
assert_eq!(lines(&path.join("after.log")), ["start", "stop"]);
}
#[tokio::test(flavor = "multi_thread")]
async fn live_unit_changes_and_binary_replacement() {
let _serial = SERIAL.lock().await;
let (temp, runtime, root) = world();
let supervisor = supervise(runtime.clone());
let path = temp.path().join("world");
unit(
&root,
"live",
"[Service]\nExecStart=/bin/probe.wasm server /live.log\n",
)
.await;
wait(|| lines(&path.join("live.log")) == ["start"]).await;
write(
&root,
"/etc/systemd/system/live.service",
b"[Service]\nExecStart=/bin/probe.wasm server /live.log\nEnvironment=REV=2\n",
)
.await;
wait(|| lines(&path.join("live.log")) == ["start", "stop", "start"]).await;
let mut bytes = std::fs::read(guests().join("init-probe.wasm")).unwrap();
// Add a valid custom section, so the replacement differs in content.
bytes.extend_from_slice(&[0, 2, 1, b'x']);
write(&root, "/bin/replacement.wasm", &bytes).await;
root.rename("/bin/replacement.wasm", "/bin/probe.wasm")
.await
.unwrap();
wait(|| lines(&path.join("live.log")).len() >= 5).await;
root.remove_file("/etc/systemd/system/live.service")
.await
.unwrap();
wait(|| {
lines(&path.join("live.log"))
.last()
.is_some_and(|l| l == "stop")
})
.await;
supervisor.shutdown().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn enable_persists_but_transient_units_do_not() {
let _serial = SERIAL.lock().await;
let (temp, runtime, root) = world();
write(&root, "/etc/systemd/system/persistent.service", b"[Service]\nExecStart=/bin/probe.wasm server /persistent.log\n[Install]\nWantedBy=default.target\n").await;
let supervisor = supervise(runtime.clone());
let path = temp.path().join("world");
wait(|| path.join("var/log/journal").exists()).await;
assert_eq!(
run(
&runtime,
&["/bin/systemctl.wasm", "enable", "--now", "persistent"]
)
.await
.0,
0
);
wait(|| lines(&path.join("persistent.log")) == ["start"]).await;
assert_eq!(
run(
&runtime,
&["/bin/systemctl.wasm", "is-enabled", "persistent"]
)
.await
.0,
0
);
assert_eq!(
run(
&runtime,
&[
"/bin/systemd-run.wasm",
"--unit",
"transient",
"--wait",
"/bin/probe.wasm",
"oneshot",
"/transient.log"
]
)
.await
.0,
0
);
supervisor.shutdown().await;
drop(root);
drop(runtime);
let world = World::open(temp.path()).unwrap();
assert!(
world
.root
.stat("/run/systemd/transient/transient.service", true)
.await
.is_err()
);
let runtime = Runtime::new(world).unwrap();
let supervisor = supervise(runtime.clone());
wait(|| lines(&path.join("persistent.log")) == ["start", "stop", "start"]).await;
assert_eq!(lines(&path.join("transient.log")), ["start"]);
supervisor.shutdown().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn init_crash_reaps_children_and_restarts_enabled_units() {
let _serial = SERIAL.lock().await;
let (temp, runtime, root) = world();
unit(
&root,
"server",
"[Service]\nExecStart=/bin/probe.wasm server /server.log\n",
)
.await;
let supervisor = supervise(runtime.clone());
let path = temp.path().join("world/server.log");
wait(|| lines(&path) == ["start"]).await;
assert_eq!(run(&runtime, &["/bin/probe.wasm", "kill-init"]).await.0, 0);
wait(|| {
lines(&path)
.iter()
.filter(|line| line.as_str() == "start")
.count()
== 2
})
.await;
let table = root
.open("/proc/processes", Default::default())
.await
.unwrap()
.read(0, 65536)
.await
.unwrap();
let table = String::from_utf8(table).unwrap();
assert_eq!(
table
.lines()
.filter(|l| l.contains("/bin/probe.wasm server"))
.count(),
1
);
supervisor.shutdown().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn journal_follow_observes_new_lines() {
let _serial = SERIAL.lock().await;
let (_temp, runtime, root) = world();
// Init creates the public journal directory.
let supervisor = supervise(runtime.clone());
tokio::time::timeout(Duration::from_secs(30), async {
while root.stat("/var/log/journal", true).await.is_err() {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
write(&root, "/var/log/journal/x.service.log", b"old\n").await;
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let executor = runtime.clone();
let task = tokio::spawn(async move {
executor
.exec(
Command::Argv(vec![
"/bin/journalctl.wasm".into(),
"-u".into(),
"x".into(),
"-f".into(),
]),
Options {
cwd: "/".into(),
..Default::default()
},
&mut |_, bytes| {
let _ = tx.send(bytes.to_vec());
},
)
.await
});
let first = tokio::time::timeout(Duration::from_secs(30), rx.recv())
.await
.unwrap()
.unwrap();
assert_eq!(first, b"old\n");
let file = root
.open(
"/var/log/journal/x.service.log",
Open {
write: true,
..Default::default()
},
)
.await
.unwrap();
file.append(b"new\n".to_vec()).await.unwrap();
let next = tokio::time::timeout(Duration::from_secs(30), rx.recv())
.await
.unwrap()
.unwrap();
assert_eq!(next, b"new\n");
task.abort();
let _ = task.await;
supervisor.shutdown().await;
}
+1 -63
View File
@@ -45,7 +45,7 @@ fn world_copies_programs_and_nested_source_only_once() -> anyhow::Result<()> {
mod features {
//! Acceptance coverage for preview1, identity, inherited IO, services, HTTP, and bundle aliases.
use super::*;
use std::{collections::BTreeMap, time::Instant};
use std::collections::BTreeMap;
async fn poll(mut condition: impl FnMut() -> bool) {
tokio::time::timeout(Duration::from_secs(10), async {
@@ -56,13 +56,6 @@ mod features {
.await
.unwrap();
}
fn lines(path: &Path) -> Vec<String> {
std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
.map(str::to_owned)
.collect()
}
async fn prepare(runtime: &Arc<Runtime>, argv: Vec<String>) {
assert_eq!(
runtime
@@ -322,61 +315,6 @@ mod features {
assert_eq!(runtime.compiled_programs(), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn services_restart_edit_limit_and_eof() {
let _serial = SERIAL.lock().await;
let (temp, runtime, processes) = world();
prepare(&runtime, vec!["/bin/launcher".into()]).await;
let root = temp.path().join("world");
std::fs::create_dir_all(root.join("config")).unwrap();
let spec = |name: &str, mode: &str, log: &str| serde_json::json!({"name": name, "argv": ["/bin/launcher", mode, log]});
let write = |specs: Vec<serde_json::Value>| {
std::fs::write(
root.join("config/services.yaml"),
serde_yaml_ng::to_string(&specs).unwrap(),
)
.unwrap()
};
write(vec![
spec("a", "server", "/a.log"),
spec("crash", "crash", "/crash.log"),
]);
let supervisor = sausage::process::supervise(runtime.clone());
poll(|| {
lines(&root.join("a.log")) == ["start"] && lines(&root.join("crash.log")).len() == 6
})
.await;
prepare(&runtime, vec!["/bin/launcher".into()]).await;
assert_eq!(lines(&root.join("crash.log")).len(), 6);
assert!(
processes
.lock()
.unwrap()
.values()
.all(|p| p.conversation.is_none())
);
write(vec![spec("b", "server", "/b.log")]);
poll(|| {
lines(&root.join("a.log")) == ["start", "stop"]
&& lines(&root.join("b.log")) == ["start"]
})
.await;
let mut changed = spec("b", "server", "/b.log");
changed["env"] = serde_json::json!({"REV": "new"});
write(vec![changed]);
poll(|| lines(&root.join("b.log")) == ["start", "stop", "start"]).await;
write(vec![]);
poll(|| lines(&root.join("b.log")) == ["start", "stop", "start", "stop"]).await;
write(vec![spec("linger", "linger", "/linger.log")]);
poll(|| lines(&root.join("linger.log")) == ["start"]).await;
let stop = Instant::now();
write(vec![]);
poll(|| processes.lock().unwrap().is_empty()).await;
assert!(stop.elapsed() >= Duration::from_secs(5));
supervisor.abort();
let _ = supervisor.await;
empty(&processes).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn git_local_and_anonymous_https_clone() {
let _serial = SERIAL.lock().await;
let (temp, runtime, _) = world();
+9
View File
@@ -3,6 +3,14 @@ package sausage:process@0.1.0;
/// Programs share one world. These resources provide lifecycle and bounded stdio transport.
interface host {
use wasi:io/streams@0.2.12.{input-stream, output-stream};
use wasi:io/poll@0.2.12.{pollable};
enum change-kind { created, modified, removed, overflow }
record change { path: string, kind: change-kind }
resource watcher {
constructor(paths: list<string>);
subscribe: func() -> pollable;
changes: func() -> list<change>;
}
enum stdio { pipe, inherit, null }
record request {
program: string,
@@ -21,6 +29,7 @@ interface host {
stdin: func() -> output-stream;
stdout: func() -> input-stream;
stderr: func() -> input-stream;
subscribe: func() -> pollable;
status: func() -> option<s32>;
wait: func() -> s32;
kill: func();
+12
View File
@@ -4446,6 +4446,17 @@ dependencies = [
"walkdir",
]
[[package]]
name = "sausage-init"
version = "0.1.0"
dependencies = [
"sausage-spawn",
"serde",
"serde_json",
"shlex 1.3.0",
"wasi 0.14.7+wasi-0.2.4",
]
[[package]]
name = "sausage-procps"
version = "0.1.0"
@@ -4464,6 +4475,7 @@ dependencies = [
name = "sausage-spawn"
version = "0.1.0"
dependencies = [
"wasi 0.14.7+wasi-0.2.4",
"wit-bindgen 0.46.0",
]
+1 -1
View File
@@ -1,7 +1,7 @@
# The default CLIs of a world, built for wasm32-wasip2. Its own workspace, outside the host's.
[workspace]
resolver = "3"
members = ["git", "coreutils", "procps", "cwd-init/probe", "ripgrep", "diffutils", "jaq", "sed", "grep", "fd", "spawn", "findutils", "exec-tools", "ssh-client", "ssh", "curl"]
members = ["git", "coreutils", "procps", "cwd-init/probe", "ripgrep", "diffutils", "jaq", "sed", "grep", "fd", "spawn", "findutils", "exec-tools", "ssh-client", "ssh", "curl", "init"]
# WASI compatibility patches: git/vendor/PATCHES.md.
[patch.crates-io]
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "sausage-init"
version = "0.1.0"
edition = "2024"
[dependencies]
sausage-spawn = { path = "../spawn" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
wasi = "0.14"
shlex = "1"
[[bin]]
name = "init-probe"
path = "src/probe.rs"
[[bin]]
name = "init"
path = "src/main.rs"
[[bin]]
name = "systemctl"
path = "src/systemctl.rs"
[[bin]]
name = "journalctl"
path = "src/journalctl.rs"
[[bin]]
name = "systemd-run"
path = "src/systemd-run.rs"
+83
View File
@@ -0,0 +1,83 @@
# World init
`/bin/init.wasm` owns unit policy. The host starts init without a conversation.
The host restarts init after an exit and kills its descendants before the next start.
Init reads persistent units from `/etc/systemd/system/*.service`.
It reads transient units from `/run/systemd/transient/*.service`.
The host mounts `/run` in memory, so transient units disappear after a host restart.
Unit files use `[Unit]`, `[Service]`, and `[Install]` sections.
Init supports dependency ordering, simple and oneshot services, environment variables, and restart policies.
The default start limit permits five starts within ten seconds.
A stop closes stdin. Init kills the process group after `TimeoutStopSec`, which defaults to ten seconds.
When init receives EOF, it stops dependents before prerequisites.
Enable records are files under `default.target.wants` or `multi-user.target.wants` in the persistent unit directory.
The `[Install]` section supplies `WantedBy` for `systemctl enable`.
Init observes VFS changes and applies unit edits without `daemon-reload`.
A change to a running unit's executable content restarts that unit.
Init resolves bare executable names through `PATH`, which defaults to `/usr/local/bin:/bin`.
Each directory tries the bare name before the `.wasm` suffix.
Init watches those candidate paths and compares the resolved executable's content hash.
After an overflow, init rescans definitions and executable hashes.
It restarts only units with a changed definition or executable.
Unrelated world writes do not enter these watcher queues.
Init and request clients block on pollables, with timers for lifecycle deadlines.
## Control protocol
The protocol uses UTF-8 JSON files. Writers create a temporary file, then rename it to the final path.
Each request uses a unique identifier.
1. The client watches `/run/systemd/replies/<id>` before it submits the request.
2. The client writes `/run/systemd/requests/<id>.json`.
3. Init reads the request and applies the action.
4. Init writes `/run/systemd/replies/<id>` and removes the request.
5. The client reads and removes the reply.
Request example:
```json
{"action":"start","unit":"example.service"}
```
Actions are `start`, `stop`, and `restart`.
A reply acknowledges the action. It does not imply that the service completed its start or stop.
The control tools then wait for the requested state. A failed start returns code 1.
Reply example:
```json
{"code":0,"text":""}
```
A missing unit returns code 5. An unknown action returns code 1.
Clients read `/run/systemd/state/<unit>` for the current state:
```json
{"state":"active","pid":42,"code":null}
```
States are `inactive`, `activating`, `active`, `deactivating`, and `failed`.
The `code` field records the most recent exit status.
`systemctl is-active` returns 3 for an inactive unit.
`systemctl is-failed` returns 0 for a failed unit and 1 otherwise.
`daemon-reload` returns 0 without an action.
## Journal
Init drains each unit's stdout and stderr into `/var/log/journal/<unit>.log`.
The cap is 1 MiB. Init keeps one rotated file with the `.1` suffix.
`journalctl -u <unit> -n <count>` prints the last lines.
`journalctl -f` observes the journal file and prints appended bytes.
## Process observation
`sausage-spawn` exposes `Watcher`, `ChangeKind`, and child exit pollables.
A watcher matches each supplied path and all its descendants.
Its pollable remains ready while changes remain pending.
`changes()` returns and clears the pending changes.
Each watcher filters paths before its independent queue holds at most 256 changes.
An `overflow` event requires a rescan.
Observers report successful VFS mutations, not direct host filesystem writes outside the VFS.
+66
View File
@@ -0,0 +1,66 @@
//! Reads a unit journal, with optional live output through the world watcher.
use sausage_init::name;
use std::{
fs,
io::{self, Write},
};
fn run() -> io::Result<()> {
let mut args = std::env::args().skip(1);
let (mut unit, mut lines, mut follow) = (String::new(), 10usize, false);
while let Some(arg) = args.next() {
match arg.as_str() {
"-u" => {
unit = name(
&args
.next()
.ok_or_else(|| io::Error::other("missing unit"))?,
)
}
"-n" => {
lines = args
.next()
.and_then(|n| n.parse().ok())
.ok_or_else(|| io::Error::other("missing count"))?
}
"-f" => follow = true,
"--no-pager" => (),
_ => return Err(io::Error::other("unknown argument")),
}
}
let path = format!("/var/log/journal/{unit}.log");
let watcher = sausage_spawn::Watcher::new(&[path.clone()]);
let bytes = fs::read(&path).unwrap_or_default();
let mut starts = vec![0];
for (i, b) in bytes.iter().enumerate() {
if *b == b'\n' && i + 1 < bytes.len() {
starts.push(i + 1);
}
}
let start = if lines == 0 {
bytes.len()
} else {
starts[starts.len().saturating_sub(lines)]
};
io::stdout().write_all(&bytes[start..])?;
io::stdout().flush()?;
let mut offset = bytes.len();
while follow {
let pollable = watcher.subscribe();
pollable.block();
drop(pollable);
watcher.changes();
let bytes = fs::read(&path).unwrap_or_default();
if bytes.len() < offset {
offset = 0;
}
io::stdout().write_all(&bytes[offset..])?;
io::stdout().flush()?;
offset = bytes.len();
}
Ok(())
}
fn main() {
if run().is_err() {
std::process::exit(1);
}
}
+191
View File
@@ -0,0 +1,191 @@
//! Unit definitions and the file protocol shared by init and its control tools.
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, fs, io, time::Duration};
/// WASI 0.2's legacy exit call loses numeric error codes. Use the standard extended call.
pub fn exit(code: i32) -> ! {
wasi::cli::exit::exit_with_code(code as u8);
unreachable!("exit returned")
}
pub const RUN: &str = "/run/systemd";
pub const UNITS: &str = "/etc/systemd/system";
pub const TRANSIENT: &str = "/run/systemd/transient";
#[derive(Clone, Debug, PartialEq)]
pub struct Unit {
pub values: BTreeMap<String, String>,
pub source: String,
}
impl Unit {
pub fn parse(source: String) -> io::Result<Self> {
let mut values = BTreeMap::new();
let mut section = "";
for line in source.lines().map(str::trim) {
if line.is_empty() || line.starts_with(['#', ';']) {
continue;
}
if line.starts_with('[') && line.ends_with(']') {
section = &line[1..line.len() - 1];
continue;
}
let (key, value) = line
.split_once('=')
.ok_or_else(|| io::Error::other("expected Key=Value"))?;
let key = format!("{section}.{}", key.trim());
if matches!(
key.as_str(),
"Unit.After"
| "Unit.Before"
| "Unit.Requires"
| "Unit.Wants"
| "Service.Environment"
) {
values
.entry(key)
.and_modify(|v: &mut String| {
v.push(' ');
v.push_str(value.trim());
})
.or_insert(value.trim().into());
} else {
values.insert(key, value.trim().into());
}
}
if !values.contains_key("Service.ExecStart") {
return Err(io::Error::other("missing ExecStart"));
}
Ok(Self { values, source })
}
pub fn get(&self, key: &str) -> &str {
self.values.get(key).map(String::as_str).unwrap_or("")
}
pub fn words(&self, key: &str) -> Vec<String> {
self.get(key).split_whitespace().map(name).collect()
}
pub fn seconds(&self, key: &str, default: f64) -> Duration {
Duration::from_secs_f64(
self.get(key)
.trim_end_matches('s')
.parse::<f64>()
.ok()
.filter(|n| n.is_finite() && *n >= 0.0)
.unwrap_or(default),
)
}
}
pub fn name(s: &str) -> String {
if s.ends_with(".service") || s.ends_with(".target") {
s.into()
} else {
format!("{s}.service")
}
}
pub fn unit_path(name: &str) -> String {
let persistent = format!("{UNITS}/{name}");
if std::path::Path::new(&persistent).exists() {
persistent
} else {
format!("{TRANSIENT}/{name}")
}
}
pub fn enabled(name: &str) -> bool {
["default.target", "multi-user.target"]
.iter()
.any(|target| std::path::Path::new(&format!("{UNITS}/{target}.wants/{name}")).exists())
}
#[derive(Serialize, Deserialize, Default)]
pub struct Status {
pub state: String,
pub pid: Option<u32>,
pub code: Option<i32>,
}
#[derive(Serialize, Deserialize)]
pub struct Request {
pub action: String,
pub unit: String,
}
#[derive(Serialize, Deserialize)]
pub struct Reply {
pub code: i32,
pub text: String,
}
pub fn atomic(path: &str, bytes: &[u8]) -> io::Result<()> {
let tmp = format!("{path}.tmp");
fs::write(&tmp, bytes)?;
fs::rename(tmp, path)
}
pub fn request(action: &str, unit: &str) -> io::Result<Reply> {
let id = format!(
"{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let reply = format!("{RUN}/replies/{id}");
let state = format!("{RUN}/state/{}", name(unit));
let watcher = sausage_spawn::Watcher::new(&[reply.clone(), state.clone()]);
atomic(
&format!("{RUN}/requests/{id}.json"),
&serde_json::to_vec(&Request {
action: action.into(),
unit: name(unit),
})?,
)?;
let deadline = std::time::Instant::now() + Duration::from_secs(60);
let timeout = wasi::clocks::monotonic_clock::subscribe_duration(
Duration::from_secs(60).as_nanos() as u64,
);
let changed = watcher.subscribe();
let mut acknowledged = false;
loop {
if !acknowledged && let Ok(bytes) = fs::read(&reply) {
fs::remove_file(&reply)?;
let reply: Reply = serde_json::from_slice(&bytes)?;
if reply.code != 0 {
return Ok(reply);
}
acknowledged = true;
}
if acknowledged && let Ok(bytes) = fs::read(&state) {
let status: Status = serde_json::from_slice(&bytes)?;
let done = if action == "stop" {
status.state == "inactive"
} else {
status.state == "active"
|| status.state == "failed"
|| status.state == "inactive" && status.code.is_some()
};
if done {
return Ok(Reply {
code: if action != "stop" && status.state == "failed" {
1
} else {
0
},
text: String::new(),
});
}
}
if std::time::Instant::now() >= deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"init did not reply",
));
}
sausage_spawn::poll(&[&changed, &timeout]);
let _ = watcher.changes();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unit_sections_and_defaults() {
let unit = Unit::parse("[Unit]\nAfter=prepare\nRequires=prepare\n[Service]\nType=oneshot\nExecStart=/bin/task.wasm\n".into()).unwrap();
assert_eq!(unit.words("Unit.Requires"), ["prepare.service"]);
assert_eq!(
unit.seconds("Service.TimeoutStopSec", 10.),
Duration::from_secs(10)
);
assert!(Unit::parse("[Unit]\nDescription=missing command".into()).is_err());
}
}
+495
View File
@@ -0,0 +1,495 @@
//! Runs the world's unit state machine, journal drains, and file-based control requests.
use sausage_init::*;
use sausage_spawn::{Child, Command, Stdio};
use std::{
collections::{BTreeMap, BTreeSet, VecDeque},
fs,
io::{self, Write},
time::Instant,
};
fn program_paths(unit: &Unit) -> Vec<String> {
let program = shlex::split(unit.get("Service.ExecStart"))
.and_then(|args| args.first().cloned())
.unwrap_or_default();
if program.contains('/') {
return vec![program];
}
let path = shlex::split(unit.get("Service.Environment"))
.unwrap_or_default()
.into_iter()
.find_map(|v| v.strip_prefix("PATH=").map(str::to_owned))
.unwrap_or_else(|| "/usr/local/bin:/bin".into());
path.split(':')
.flat_map(|dir| [format!("{dir}/{program}"), format!("{dir}/{program}.wasm")])
.collect()
}
fn binary(unit: &Unit) -> Option<(String, u64)> {
use std::hash::{Hash, Hasher};
program_paths(unit).into_iter().find_map(|path| {
let bytes = fs::read(&path).ok()?;
let mut hash = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut hash);
Some((path, hash.finish()))
})
}
fn wait_events(
services: &BTreeMap<String, Service>,
mut pollables: Vec<sausage_spawn::Pollable>,
tick_at: Instant,
) {
let now = Instant::now();
let mut deadline = None;
for service in services.values() {
if let Some(child) = &service.child {
if child.try_wait().is_none() {
pollables.push(child.subscribe());
}
for stream in [&child.stdout, &child.stderr].into_iter().flatten() {
pollables.push(stream.subscribe());
}
}
let time = service
.stopping
.or_else(|| (service.desired && service.child.is_none()).then_some(service.next));
if let Some(time) = time.filter(|time| *time > tick_at) {
deadline = Some(deadline.map_or(time, |old: Instant| old.min(time)));
}
}
if let Some(deadline) = deadline {
pollables.push(wasi::clocks::monotonic_clock::subscribe_duration(
deadline.saturating_duration_since(now).as_nanos() as u64,
));
}
if !pollables.is_empty() {
sausage_spawn::poll(&pollables.iter().collect::<Vec<_>>());
}
}
struct Service {
binary: Option<(String, u64)>,
unit: Unit,
child: Option<Child>,
state: String,
code: Option<i32>,
desired: bool,
starts: VecDeque<Instant>,
next: Instant,
stopping: Option<Instant>,
}
impl Service {
fn new(unit: Unit) -> Self {
Self {
binary: binary(&unit),
unit,
child: None,
state: "inactive".into(),
code: None,
desired: false,
starts: VecDeque::new(),
next: Instant::now(),
stopping: None,
}
}
fn stop(&mut self) {
self.desired = false;
if let Some(child) = &mut self.child {
child.stdin.take();
self.stopping
.get_or_insert(Instant::now() + self.unit.seconds("Service.TimeoutStopSec", 10.));
self.state = "deactivating".into();
} else {
self.state = "inactive".into();
}
}
fn start(&mut self) {
let now = Instant::now();
let interval = self.unit.seconds("Service.StartLimitIntervalSec", 10.);
while self
.starts
.front()
.is_some_and(|t| now.duration_since(*t) >= interval)
{
self.starts.pop_front();
}
let burst = self
.unit
.get("Service.StartLimitBurst")
.parse()
.unwrap_or(5);
if burst != 0 && self.starts.len() >= burst {
self.state = "failed".into();
self.desired = false;
return;
}
self.starts.push_back(now);
let Some(argv) = shlex::split(self.unit.get("Service.ExecStart")).filter(|a| !a.is_empty())
else {
self.state = "failed".into();
self.desired = false;
return;
};
let program = self
.binary
.as_ref()
.map(|(path, _)| path.as_str())
.unwrap_or(&argv[0]);
let mut command = Command::new(program);
command
.args(&argv[1..])
.new_group()
.stdin(Stdio::Pipe)
.stdout(Stdio::Pipe)
.stderr(Stdio::Pipe);
let cwd = self.unit.get("Service.WorkingDirectory");
command.current_dir(if cwd.is_empty() { "/" } else { cwd });
for assignment in shlex::split(self.unit.get("Service.Environment")).unwrap_or_default() {
if let Some((key, value)) = assignment.split_once('=') {
command.env(key, value);
}
}
match command.spawn() {
Ok(child) => {
self.child = Some(child);
self.state = if self.unit.get("Service.Type") == "oneshot" {
"activating"
} else {
"active"
}
.into();
self.code = None;
}
Err(_) => {
self.code = Some(127);
self.exited(127);
}
}
}
fn exited(&mut self, code: i32) {
self.code = Some(code);
if self.stopping.take().is_some() {
self.state = if self.desired {
"activating"
} else {
"inactive"
}
.into();
if self.desired {
self.code = None;
}
return;
}
self.state = if code != 0 {
"failed"
} else if matches!(
self.unit.get("Service.RemainAfterExit"),
"yes" | "true" | "1"
) {
"active"
} else {
"inactive"
}
.into();
let restart = self.unit.get("Service.Restart");
if restart == "always" || restart == "on-failure" && code != 0 {
self.next = Instant::now() + self.unit.seconds("Service.RestartSec", 0.1);
} else {
self.desired = false;
}
}
fn tick(&mut self, name: &str) {
if let Some(child) = &mut self.child {
for slot in [&mut child.stdout, &mut child.stderr] {
if let Some(stream) = slot {
loop {
match stream.read(8192) {
Ok(bytes) if !bytes.is_empty() => journal(name, &bytes),
Ok(_) => break,
Err(_) => {
*slot = None;
break;
}
}
}
}
}
if child.try_wait().is_none() && self.stopping.is_some_and(|t| Instant::now() >= t) {
let _ = sausage_spawn::kill(child.id());
}
if let Some(code) = child.try_wait() {
if child.stdout.is_none() && child.stderr.is_none() {
self.child = None;
self.exited(code);
}
}
}
}
fn publish(&self, name: &str) {
let status = Status {
state: self.state.clone(),
pid: self.child.as_ref().map(Child::id),
code: self.code,
};
let bytes = serde_json::to_vec(&status).unwrap();
let path = format!("{RUN}/state/{name}");
if fs::read(&path).ok().as_deref() != Some(bytes.as_slice()) {
let _ = atomic(&path, &bytes);
}
}
}
fn journal(name: &str, bytes: &[u8]) {
let path = format!("/var/log/journal/{name}.log");
if fs::metadata(&path).is_ok_and(|m| m.len() + bytes.len() as u64 > 1024 * 1024) {
let _ = fs::rename(&path, format!("{path}.1"));
}
if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(path) {
let _ = f.write_all(bytes);
}
}
fn activate(name: &str, services: &mut BTreeMap<String, Service>, seen: &mut BTreeSet<String>) {
if !seen.insert(name.into()) {
return;
}
let Some(service) = services.get(name) else {
return;
};
let deps = [
service.unit.words("Unit.Requires"),
service.unit.words("Unit.Wants"),
]
.concat();
for dep in deps {
activate(&dep, services, seen);
}
let service = services.get_mut(name).unwrap();
service.desired = true;
if service.child.is_none() {
service.state = "activating".into();
service.code = None;
}
service.next = Instant::now();
}
fn predecessors(name: &str, services: &BTreeMap<String, Service>) -> Vec<String> {
let mut deps = services[name].unit.words("Unit.After");
deps.extend(services[name].unit.words("Unit.Requires"));
for (other, service) in services {
if service.unit.words("Unit.Before").iter().any(|s| s == name) {
deps.push(other.clone());
}
}
deps
}
fn scan(services: &mut BTreeMap<String, Service>) {
let mut found = BTreeMap::new();
let mut present = BTreeSet::new();
for dir in [TRANSIENT, UNITS] {
for entry in fs::read_dir(dir).into_iter().flatten().flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if !name.ends_with(".service") {
continue;
}
present.insert(name.clone());
if let Ok(source) = fs::read_to_string(entry.path()) {
if let Ok(unit) = Unit::parse(source) {
found.insert(name, unit);
}
}
}
}
for (name, service) in services.iter_mut() {
if !present.contains(name) {
service.stop();
}
}
services.retain(|name, service| present.contains(name) || service.child.is_some());
let mut new_enabled = Vec::new();
for (name, unit) in found {
if !services.contains_key(&name) && enabled(&name) {
new_enabled.push(name.clone());
}
let service = services
.entry(name.clone())
.or_insert_with(|| Service::new(unit.clone()));
let binary = binary(&unit);
if service.unit != unit || service.binary != binary {
service.binary = binary;
let desired = service.desired || service.state == "active";
service.stop();
service.unit = unit;
service.desired = desired;
service.starts.clear();
service.next = Instant::now();
}
}
for name in new_enabled {
activate(&name, services, &mut BTreeSet::new());
}
}
fn controls(services: &mut BTreeMap<String, Service>) {
for entry in fs::read_dir(format!("{RUN}/requests"))
.into_iter()
.flatten()
.flatten()
{
let filename = entry.file_name().to_string_lossy().into_owned();
if !filename.ends_with(".json") {
continue;
}
let Ok(bytes) = fs::read(entry.path()) else {
continue;
};
let Ok(request) = serde_json::from_slice::<Request>(&bytes) else {
continue;
};
let mut reply = Reply {
code: 0,
text: String::new(),
};
if !services.contains_key(&request.unit) {
reply.code = 5;
reply.text = "unit not found".into();
} else {
match request.action.as_str() {
"start" => {
services.get_mut(&request.unit).unwrap().starts.clear();
activate(&request.unit, services, &mut BTreeSet::new());
}
"stop" => services.get_mut(&request.unit).unwrap().stop(),
"restart" => {
let service = services.get_mut(&request.unit).unwrap();
service.stop();
service.desired = true;
if service.child.is_none() {
service.state = "activating".into();
service.code = None;
}
service.starts.clear();
service.next = Instant::now();
}
_ => {
reply.code = 1;
reply.text = "unknown action".into();
}
}
}
if let Some(service) = services.get(&request.unit) {
service.publish(&request.unit);
}
let id = filename.trim_end_matches(".json");
let _ = atomic(
&format!("{RUN}/replies/{id}"),
&serde_json::to_vec(&reply).unwrap(),
);
let _ = fs::remove_file(entry.path());
}
}
fn main() -> io::Result<()> {
for dir in [
UNITS,
TRANSIENT,
"/run/systemd/requests",
"/run/systemd/replies",
"/run/systemd/state",
"/var/log/journal",
] {
fs::create_dir_all(dir)?;
}
let mut paths = vec![UNITS.into(), TRANSIENT.into(), format!("{RUN}/requests")];
let mut watcher = sausage_spawn::Watcher::new(&paths);
let stdin = wasi::cli::stdin::get_stdin();
let mut services = BTreeMap::new();
scan(&mut services);
loop {
if stdin.read(1).is_err() {
break;
}
let changes = watcher.changes();
if !changes.is_empty() {
scan(&mut services);
}
controls(&mut services);
let tick_at = Instant::now();
let names: Vec<_> = services.keys().cloned().collect();
for name in &names {
services.get_mut(name).unwrap().tick(name);
}
for name in &names {
let service = &services[name];
if !service.desired
|| service.child.is_some()
|| Instant::now() < service.next
|| service.stopping.is_some()
{
continue;
}
let required = service.unit.words("Unit.Requires");
if required
.iter()
.any(|dep| !services.contains_key(dep) || services[dep].state == "failed")
{
let service = services.get_mut(name).unwrap();
service.state = "failed".into();
service.desired = false;
continue;
}
if predecessors(name, &services).iter().any(|dep| {
services.get(dep).is_some_and(|s| {
s.state == "activating" || s.desired && s.child.is_none() && s.code.is_none()
})
}) {
continue;
}
services.get_mut(name).unwrap().start();
}
for (name, service) in &services {
service.publish(name);
}
let mut next_paths = vec![UNITS.into(), TRANSIENT.into(), format!("{RUN}/requests")];
for service in services.values() {
next_paths.extend(program_paths(&service.unit));
}
next_paths.sort();
next_paths.dedup();
if paths != next_paths {
paths = next_paths;
watcher = sausage_spawn::Watcher::new(&paths);
// Close the snapshot/subscription race before blocking.
scan(&mut services);
continue;
}
wait_events(
&services,
vec![watcher.subscribe(), stdin.subscribe()],
tick_at,
);
}
// Stop dependents before their prerequisites. A cycle stops together.
while services.values().any(|s| s.child.is_some()) {
let live: Vec<_> = services
.iter()
.filter(|(_, s)| s.child.is_some())
.map(|(n, _)| n.clone())
.collect();
let mut leaves: Vec<_> = live
.iter()
.filter(|name| {
!live
.iter()
.any(|other| other != *name && predecessors(other, &services).contains(name))
})
.cloned()
.collect();
if leaves.is_empty() {
leaves = live;
}
let tick_at = Instant::now();
for name in leaves {
let service = services.get_mut(&name).unwrap();
service.stop();
service.tick(&name);
service.publish(&name);
}
if services.values().any(|s| s.child.is_some()) {
wait_events(&services, Vec::new(), tick_at);
}
}
Ok(())
}
+96
View File
@@ -0,0 +1,96 @@
//! Acceptance guest for unit lifecycle and process-interface readiness tests.
use std::{
fs,
io::{Read, Write},
};
fn main() {
let args: Vec<_> = std::env::args().collect();
match args.get(1).map(String::as_str).unwrap_or("") {
"kill-init" => {
let table = fs::read_to_string("/proc/processes").unwrap();
let row = table
.lines()
.find(|line| line.ends_with("/bin/init.wasm"))
.unwrap();
sausage_spawn::kill(row.split('\t').next().unwrap().parse().unwrap()).unwrap();
}
"watch" => {
use sausage_spawn::{ChangeKind, Watcher};
let watcher = Watcher::new(&["/run/watched".into()]);
let poll = watcher.subscribe();
assert!(!poll.ready());
fs::create_dir_all("/run/watched").unwrap();
poll.block();
assert!(
watcher
.changes()
.iter()
.any(|c| matches!(c.kind, ChangeKind::Created))
);
fs::write("/run/watched/a", "a").unwrap();
watcher.changes();
fs::write("/run/watched/a", "b").unwrap();
assert!(
watcher
.changes()
.iter()
.any(|c| c.path == "/run/watched/a" && matches!(c.kind, ChangeKind::Modified))
);
fs::remove_file("/run/watched/a").unwrap();
assert!(
watcher
.changes()
.iter()
.any(|c| matches!(c.kind, ChangeKind::Removed))
);
for i in 0..300 {
fs::write(format!("/run/watched/{i}"), "").unwrap();
}
assert!(
watcher
.changes()
.iter()
.any(|c| matches!(c.kind, ChangeKind::Overflow))
);
drop(poll);
let mut cmd = sausage_spawn::Command::new("/bin/probe.wasm");
cmd.arg("exit").arg("7");
let child = cmd.spawn().unwrap();
let exit = child.subscribe();
exit.block();
assert_eq!(child.try_wait(), Some(7));
}
"exit" => sausage_init::exit(args.get(2).and_then(|a| a.parse().ok()).unwrap_or(0)),
"oneshot" | "server" | "crash" | "after" | "linger" => {
if args[1] == "after" {
assert!(
!fs::read_to_string("/proc/processes")
.unwrap()
.contains("oneshot /order.log")
);
}
let mut log = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&args[2])
.unwrap();
writeln!(log, "start").unwrap();
for n in 1..=10 {
println!("line {n}");
}
if args[1] == "linger" {
loop {
std::thread::sleep(std::time::Duration::from_secs(1));
}
}
if args[1] == "crash" {
std::process::exit(1);
}
if args[1] == "server" || args[1] == "after" {
let _ = std::io::stdin().read_to_end(&mut Vec::new());
writeln!(log, "stop").unwrap();
}
}
_ => panic!("unknown probe mode"),
}
}
+123
View File
@@ -0,0 +1,123 @@
//! Controls init and reads its public unit state files.
use sausage_init::*;
use std::{fs, io};
fn run() -> io::Result<i32> {
let args: Vec<_> = std::env::args().skip(1).collect();
let action = args.first().map(String::as_str).unwrap_or("list-units");
if action == "daemon-reload" {
return Ok(0);
}
if action == "list-units" {
for entry in fs::read_dir(format!("{RUN}/state"))? {
let entry = entry?;
let status: Status = serde_json::from_slice(&fs::read(entry.path())?)?;
println!("{} {}", entry.file_name().to_string_lossy(), status.state);
}
return Ok(0);
}
let now = args.iter().any(|a| a == "--now");
let units: Vec<_> = args
.iter()
.skip(1)
.filter(|a| !a.starts_with('-'))
.map(|a| name(a))
.collect();
if units.is_empty() {
return Ok(1);
}
let mut result = 0;
for unit in units {
let status = fs::read(format!("{RUN}/state/{unit}"))
.ok()
.and_then(|b| serde_json::from_slice::<Status>(&b).ok())
.unwrap_or_default();
let code = match action {
"is-active" => {
println!(
"{}",
if status.state.is_empty() {
"unknown"
} else {
&status.state
}
);
if status.state == "active" { 0 } else { 3 }
}
"is-failed" => {
println!(
"{}",
if status.state.is_empty() {
"unknown"
} else {
&status.state
}
);
if status.state == "failed" { 0 } else { 1 }
}
"is-enabled" => {
println!(
"{}",
if enabled(&unit) {
"enabled"
} else {
"disabled"
}
);
if enabled(&unit) { 0 } else { 1 }
}
"status" => {
println!("{unit}: {}", status.state);
if status.state == "active" {
0
} else if status.state.is_empty() {
4
} else {
3
}
}
"cat" => {
print!("{}", fs::read_to_string(unit_path(&unit))?);
0
}
"enable" | "disable" => {
let spec = Unit::parse(fs::read_to_string(unit_path(&unit))?)?;
let targets: Vec<_> = spec
.get("Install.WantedBy")
.split_whitespace()
.filter(|t| matches!(*t, "default.target" | "multi-user.target"))
.collect();
if action == "enable" && targets.is_empty() {
return Ok(1);
}
for target in ["default.target", "multi-user.target"] {
let dir = format!("{UNITS}/{target}.wants");
let path = format!("{dir}/{unit}");
if action == "enable" && targets.contains(&target) {
fs::create_dir_all(dir)?;
fs::write(path, b"")?;
} else if action == "disable" {
let _ = fs::remove_file(path);
}
}
if now {
request(if action == "enable" { "start" } else { "stop" }, &unit)?.code
} else {
0
}
}
"start" | "stop" | "restart" => {
let reply = request(action, &unit)?;
if !reply.text.is_empty() {
println!("{}", reply.text);
}
reply.code
}
_ => 1,
};
result = result.max(code);
}
Ok(result)
}
fn main() {
sausage_init::exit(run().unwrap_or(1));
}
+85
View File
@@ -0,0 +1,85 @@
//! Creates transient unit files and optionally waits for their final state.
use sausage_init::*;
use std::{fs, io};
fn run() -> io::Result<i32> {
let mut args = std::env::args().skip(1).peekable();
let mut unit = format!(
"run-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let (mut wait, mut properties, mut command) = (false, Vec::new(), Vec::new());
while let Some(arg) = args.next() {
match arg.as_str() {
"--unit" => {
unit = args
.next()
.ok_or_else(|| io::Error::other("missing unit"))?
}
"--wait" => wait = true,
"-p" => properties.push(
args.next()
.ok_or_else(|| io::Error::other("missing property"))?,
),
"--working-directory" => properties.push(format!(
"WorkingDirectory={}",
args.next()
.ok_or_else(|| io::Error::other("missing directory"))?
)),
"-E" => properties.push(format!(
"Environment={}",
args.next()
.ok_or_else(|| io::Error::other("missing environment"))?
)),
"--" => {
command.extend(args);
break;
}
_ if arg.starts_with("--unit=") => unit = arg[7..].into(),
_ if arg.starts_with('-') => return Ok(1),
_ => {
command.push(arg);
command.extend(args);
break;
}
}
}
if command.is_empty() {
return Ok(1);
}
let unit = name(&unit);
let mut text = format!(
"[Service]\nExecStart={}\n",
shlex::try_join(command.iter().map(String::as_str)).map_err(io::Error::other)?
);
for property in properties {
text.push_str(&property);
text.push('\n');
}
fs::create_dir_all(TRANSIENT)?;
atomic(&format!("{TRANSIENT}/{unit}"), text.as_bytes())?;
let reply = request("start", &unit)?;
if reply.code != 0 {
return Ok(reply.code);
}
if wait {
let watcher = sausage_spawn::Watcher::new(&[format!("{RUN}/state/{unit}")]);
let changed = watcher.subscribe();
loop {
if let Ok(bytes) = fs::read(format!("{RUN}/state/{unit}")) {
let status: Status = serde_json::from_slice(&bytes)?;
if let Some(code) = status.code {
return Ok(code);
}
}
changed.block();
let _ = watcher.changes();
}
}
Ok(0)
}
fn main() {
sausage_init::exit(run().unwrap_or(1));
}
+1
View File
@@ -5,3 +5,4 @@ edition = "2024"
[dependencies]
wit-bindgen = "=0.46.0"
wasi = "0.14"
+6 -2
View File
@@ -3,11 +3,14 @@
//! Pipe callers must drain stdout and stderr before wait to avoid bounded-pipe deadlocks.
use std::{collections::BTreeMap, io, path::{Path, PathBuf}};
mod bindings {
wit_bindgen::generate!({ path: "../../host/wit", world: "program", generate_all });
wit_bindgen::generate!({ path: "../../host/wit", world: "program", generate_all, with: { "wasi:io/poll@0.2.12": ::wasi::io::poll } });
}
use bindings::sausage::process::host;
pub use bindings::wasi::io::streams::{InputStream, OutputStream};
pub use host::Stdio;
pub use host::{Stdio, Watcher, Change, ChangeKind};
pub use wasi::io::poll::{Pollable, poll};
/// Hard-stop a process group by a live process ID.
pub fn kill(pid: u32) -> io::Result<()> { host::kill(pid).map_err(io::Error::other) }
/// A prepared child process.
pub struct Command {
@@ -57,6 +60,7 @@ pub struct Child {
}
impl Child {
pub fn id(&self) -> u32 { self.child.pid() }
pub fn subscribe(&self) -> Pollable { self.child.subscribe() }
pub fn try_wait(&self) -> Option<i32> { self.child.status() }
pub fn wait(&self) -> i32 { self.child.wait() }
/// Stop every process in this child's group.
+4
View File
@@ -67,6 +67,10 @@ pub trait File: Send + Sync {
/// Implementations must retain open file identity and return portable IO errors.
#[async_trait]
pub trait Layer: Send + Sync {
/// Shared observation of mutations in this namespace, when available.
fn observer(&self) -> Option<observe::Observer> {
None
}
async fn stat(&self, path: &str, follow: bool) -> Result<Stat>;
/// Return the target of a symbolic link, or InvalidInput for another node.
async fn readlink(&self, _path: &str) -> Result<String> {
+15 -3
View File
@@ -6,15 +6,24 @@ struct Mount {
readonly: bool,
}
/// A per-process mount namespace. Mount the root before use.
#[derive(Default)]
pub struct Mounts {
mounts: BTreeMap<String, Mount>,
observer: observe::Observer,
}
impl Default for Mounts {
fn default() -> Self {
Self {
mounts: BTreeMap::new(),
observer: observe::Observer::new(|_| {}),
}
}
}
impl Mounts {
/// Add or replace a mount. Read-only mounts reject every mutation.
pub fn mount(&mut self, path: &str, layer: Arc<dyn Layer>, readonly: bool) {
self.mounts
.insert(resolve("/", path), Mount { layer, readonly });
let path = resolve("/", path);
let layer = self.observer.layer(layer, &path, Process::default());
self.mounts.insert(path, Mount { layer, readonly });
}
pub(crate) async fn metadata_file(&self, path: &str, follow: bool) -> Result<Arc<dyn File>> {
let (m, p) = self.route(path, true)?;
@@ -49,6 +58,9 @@ impl Mounts {
}
#[async_trait]
impl Layer for Mounts {
fn observer(&self) -> Option<observe::Observer> {
Some(self.observer.clone())
}
async fn stat(&self, path: &str, follow: bool) -> Result<Stat> {
let (m, p) = self.route(path, false)?;
m.layer.stat(&p, follow).await
+85 -8
View File
@@ -6,6 +6,14 @@ use std::{
};
use tokio::sync::{OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock};
/// The successful namespace or content mutation observed at a world path.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChangeKind {
Created,
Modified,
Removed,
}
type Namespace = Mutex<BTreeMap<String, Weak<RwLock<()>>>>;
enum Held {
Read { _guard: OwnedRwLockReadGuard<()> },
@@ -15,11 +23,16 @@ enum Held {
type TrackedPath = Mutex<Option<String>>;
type Path = Arc<TrackedPath>;
type Callback = dyn Fn(&str) + Send + Sync;
type Subscription = (
Vec<String>,
tokio::sync::broadcast::Sender<(String, ChangeKind)>,
);
/// Shared observation for mount namespaces. Paths supplied to the callback are virtual paths.
/// Callbacks run after filesystem locks have been released; they must not block.
#[derive(Clone)]
pub struct Observer {
changed: Arc<Callback>,
events: Arc<Mutex<Vec<Subscription>>>,
paths: Arc<Mutex<Vec<Weak<TrackedPath>>>>,
// Only open-registration versus rename/remove needs ordering. Lock the affected
// subtrees, not all IO: a blocked open must not stall unrelated namespaces.
@@ -29,10 +42,32 @@ impl Observer {
pub fn new(changed: impl Fn(&str) + Send + Sync + 'static) -> Self {
Self {
changed: Arc::new(changed),
events: Arc::default(),
paths: Arc::default(),
namespace: Arc::default(),
}
}
/// Subscribe before the first mutation of interest. Lag means callers must rescan.
pub fn subscribe(
&self,
paths: Vec<String>,
) -> tokio::sync::broadcast::Receiver<(String, ChangeKind)> {
let (sender, receiver) = tokio::sync::broadcast::channel(256);
self.events.lock().unwrap().push((paths, sender));
receiver
}
fn notify(&self, path: &str, kind: ChangeKind) {
(self.changed)(path);
self.events.lock().unwrap().retain(|(paths, sender)| {
if sender.receiver_count() == 0 {
return false;
}
if paths.iter().any(|base| under(path, base)) {
let _ = sender.send((path.to_owned(), kind));
}
true
});
}
/// Wrap a layer at its canonical namespace prefix (e.g. `/run`).
pub fn layer(&self, layer: Arc<dyn Layer>, prefix: &str, process: Process) -> Arc<dyn Layer> {
Arc::new(Observed {
@@ -149,7 +184,7 @@ impl Handle {
let result = f.await?;
let path = path.lock().unwrap().clone();
if let Some(path) = path {
(observer.changed)(&path);
observer.notify(&path, ChangeKind::Modified);
}
Ok(result)
})
@@ -178,6 +213,7 @@ impl Layer for Observed {
self.process
.operation(async move {
let order = observer.order(&[(&path, false)]).await;
let existed = !options.create || layer.stat(&local, options.follow).await.is_ok();
let file = layer.open(&local, options).await?;
let handle = Arc::new(Handle {
file,
@@ -187,7 +223,14 @@ impl Layer for Observed {
});
drop(order);
if options.create || options.truncate {
(observer.changed)(&path);
observer.notify(
&path,
if existed {
ChangeKind::Modified
} else {
ChangeKind::Created
},
);
}
Ok(handle as Arc<dyn File>)
})
@@ -203,7 +246,7 @@ impl Layer for Observed {
self.process
.operation(async move {
layer.mkdir(&local).await?;
(observer.changed)(&path);
observer.notify(&path, ChangeKind::Created);
Ok(())
})
.await
@@ -226,13 +269,22 @@ impl Layer for Observed {
self.process
.operation(async move {
let order = observer.order(&[(&from, true), (&to, true)]).await;
let replaced = layer.stat(&b, false).await.is_ok();
layer.rename(&a, &b).await?;
if from != to {
observer.moved(&from, Some(&to));
if from == to {
return Ok(());
}
observer.moved(&from, Some(&to));
drop(order);
(observer.changed)(&from);
(observer.changed)(&to);
observer.notify(&from, ChangeKind::Removed);
observer.notify(
&to,
if replaced {
ChangeKind::Modified
} else {
ChangeKind::Created
},
);
Ok(())
})
.await
@@ -256,7 +308,7 @@ impl Observed {
}
observer.moved(&path, None);
drop(order);
(observer.changed)(&path);
observer.notify(&path, ChangeKind::Removed);
Ok(())
})
.await
@@ -299,6 +351,31 @@ impl File for Handle {
#[cfg(test)]
mod tests {
#[test]
fn queues_filter_before_the_bound_and_overflow_independently() {
let observer = super::Observer::new(|_| {});
let mut a = observer.subscribe(vec!["/watched/a".into()]);
let mut b = observer.subscribe(vec!["/watched/b".into()]);
for _ in 0..4000 {
observer.notify("/elsewhere", super::ChangeKind::Modified);
}
assert!(matches!(
a.try_recv(),
Err(tokio::sync::broadcast::error::TryRecvError::Empty)
));
for _ in 0..300 {
observer.notify("/watched/a", super::ChangeKind::Modified);
}
assert!(matches!(
a.try_recv(),
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_))
));
assert!(matches!(
b.try_recv(),
Err(tokio::sync::broadcast::error::TryRecvError::Empty)
));
}
use super::*;
use tokio::sync::mpsc;
#[test]