Merge branch 'v3' into v3-t29
# Conflicts: # host/Cargo.toml # host/src/harness/mod.rs
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
@@ -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("<script>not markup</script>"));
|
||||
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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user