Merge v3 with T15 into v3-t22
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
//! Credential core acceptance tests with a script fake and host-only temporary storage.
|
||||
use sausage::cred::{
|
||||
grants::Grant,
|
||||
inject::Outcome,
|
||||
items::*,
|
||||
requests::{GrantRequest, Overrides},
|
||||
script::{self, Limits, Script},
|
||||
*,
|
||||
};
|
||||
fn caller(id: &str) -> Caller {
|
||||
Caller {
|
||||
program: "/bin/tool.wasm".into(),
|
||||
pid: 42,
|
||||
conversations: vec![id.into()],
|
||||
}
|
||||
}
|
||||
fn item(free_use: bool) -> Item {
|
||||
Item {
|
||||
id: String::new(),
|
||||
name: "token".into(),
|
||||
version: 0,
|
||||
free_use,
|
||||
rules: vec![Rule {
|
||||
scheme: "https".into(),
|
||||
hosts: vec!["api.example".into()],
|
||||
path_prefixes: vec!["/".into()],
|
||||
methods: None,
|
||||
}],
|
||||
validate_js: None,
|
||||
transform_js: String::new(),
|
||||
fields: vec![Field {
|
||||
field_id: String::new(),
|
||||
name: "token".into(),
|
||||
public: false,
|
||||
value: None,
|
||||
needs_value: true,
|
||||
field_type: types::FieldType::Text,
|
||||
key_type: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
fn parts(host: &str) -> http::request::Parts {
|
||||
http::Request::get(format!("https://{host}/path"))
|
||||
.body(())
|
||||
.unwrap()
|
||||
.into_parts()
|
||||
.0
|
||||
}
|
||||
struct Fake;
|
||||
impl Script for Fake {
|
||||
fn evaluate(
|
||||
&self,
|
||||
source: &str,
|
||||
input: &str,
|
||||
_: Option<&str>,
|
||||
_: Limits,
|
||||
) -> std::result::Result<String, script::Error> {
|
||||
let ctx: serde_json::Value = serde_json::from_str(input).unwrap();
|
||||
match source {
|
||||
"deny"=>Ok("false".into()),
|
||||
"validate"=>{assert!(ctx["credential"].get("token").is_none());Ok("true".into())},
|
||||
"throw"=>Err(script::Error{kind:script::ErrorKind::Throw,message:ctx["credential"]["token"].as_str().unwrap().into()}),
|
||||
"escape"=>Ok(r#"{"url":"https://evil.example/"}"#.into()),
|
||||
_=>Ok(serde_json::json!({"headers":[{"name":"authorization","value":ctx["credential"]["token"]}]}).to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn inject_selection_idempotency_and_subtree() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let c = Cred::open(dir.path()).unwrap();
|
||||
let root = caller("root");
|
||||
assert!(matches!(
|
||||
c.inject(parts("api.example"), &root, &Fake).unwrap(),
|
||||
Outcome::Forward(_)
|
||||
));
|
||||
let i = c.create_item(item(false), &root, "passkey").unwrap();
|
||||
let first = match c.inject(parts("api.example"), &root, &Fake).unwrap() {
|
||||
Outcome::Denied { item, request } => {
|
||||
assert_eq!(item, "token");
|
||||
request
|
||||
}
|
||||
_ => panic!("expected denial"),
|
||||
};
|
||||
let second = match c.inject(parts("api.example"), &root, &Fake).unwrap() {
|
||||
Outcome::Denied { request, .. } => request,
|
||||
_ => panic!("expected denial"),
|
||||
};
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(c.request_list().unwrap().len(), 1);
|
||||
c.set_field(&i.id, &i.fields[0].field_id, "opaque", &root, "passkey")
|
||||
.unwrap();
|
||||
c.approve(&first, "allow", Overrides::default(), Some("passkey"))
|
||||
.unwrap();
|
||||
let child = Caller {
|
||||
program: "/bin/tool.wasm".into(),
|
||||
pid: 43,
|
||||
conversations: vec!["child".into(), "root".into()],
|
||||
};
|
||||
assert!(matches!(
|
||||
c.inject(parts("api.example"), &child, &Fake).unwrap(),
|
||||
Outcome::Forward(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
c.inject(parts("api.example"), &caller("sibling"), &Fake)
|
||||
.unwrap(),
|
||||
Outcome::Denied { .. }
|
||||
));
|
||||
let mut other = item(true);
|
||||
other.name = "other".into();
|
||||
c.create_item(other, &root, "passkey").unwrap();
|
||||
assert_eq!(
|
||||
c.inject(parts("api.example"), &root, &Fake)
|
||||
.err()
|
||||
.unwrap()
|
||||
.0,
|
||||
"ambiguous_item"
|
||||
);
|
||||
assert_eq!(c.grant_list().unwrap()[0].items, vec![i.id]);
|
||||
}
|
||||
#[test]
|
||||
fn free_use_validators_transform_recheck_and_redaction() {
|
||||
for (source, validator, expected) in [
|
||||
("inject", Some("validate"), None),
|
||||
("inject", Some("deny"), Some("validator_denied")),
|
||||
("escape", None, Some("rule_recheck")),
|
||||
("throw", None, Some("script")),
|
||||
] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let c = Cred::open(dir.path()).unwrap();
|
||||
let caller = caller("root");
|
||||
let mut config = item(true);
|
||||
config.transform_js = source.into();
|
||||
config.validate_js = validator.map(Into::into);
|
||||
let i = c.create_item(config, &caller, "passkey").unwrap();
|
||||
let secret = "SECRET_SENTINEL_19";
|
||||
c.set_field(&i.id, &i.fields[0].field_id, secret, &caller, "passkey")
|
||||
.unwrap();
|
||||
let result = c.inject(parts("api.example"), &caller, &Fake);
|
||||
if let Some(expected) = expected {
|
||||
let error = result.err().unwrap();
|
||||
assert_eq!(error.0, expected);
|
||||
assert!(!format!("{error:?}").contains(secret));
|
||||
} else {
|
||||
let Outcome::Forward(p) = result.unwrap() else {
|
||||
panic!("denied")
|
||||
};
|
||||
assert!(p.headers["authorization"] == secret);
|
||||
}
|
||||
c.audit_sign(&i.id, &caller, secret.as_bytes()).unwrap();
|
||||
assert!(!format!("{:?}", c.item_get(&i.id).unwrap()).contains(secret));
|
||||
assert!(
|
||||
!serde_json::to_string(&c.audit_list().unwrap())
|
||||
.unwrap()
|
||||
.contains(secret)
|
||||
);
|
||||
let id = c
|
||||
.file_request(
|
||||
&caller,
|
||||
"review",
|
||||
GrantRequest {
|
||||
items: vec![i.id.clone()],
|
||||
duration_secs: None,
|
||||
max_uses: None,
|
||||
validator: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!format!("{:?}", c.request_diff(&id).unwrap()).contains(secret));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn atomic_budget_and_decision_notification() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let c = std::sync::Arc::new(Cred::open(dir.path()).unwrap());
|
||||
let caller = caller("root");
|
||||
let i = c.create_item(item(false), &caller, "passkey").unwrap();
|
||||
c.set_field(&i.id, &i.fields[0].field_id, "opaque", &caller, "passkey")
|
||||
.unwrap();
|
||||
c.grant_create(
|
||||
Grant {
|
||||
id: String::new(),
|
||||
items: vec![i.id],
|
||||
subject: types::Subject::Conversation("root".into()),
|
||||
exp: None,
|
||||
max_uses: Some(1),
|
||||
uses: 0,
|
||||
revoked: false,
|
||||
validator: None,
|
||||
note: None,
|
||||
},
|
||||
&caller,
|
||||
Some("passkey"),
|
||||
)
|
||||
.unwrap();
|
||||
let handles: Vec<_> = (0..2)
|
||||
.map(|_| {
|
||||
let c = c.clone();
|
||||
let caller = caller.clone();
|
||||
std::thread::spawn(move || {
|
||||
matches!(
|
||||
c.inject(parts("api.example"), &caller, &Fake).unwrap(),
|
||||
Outcome::Forward(_)
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
handles
|
||||
.into_iter()
|
||||
.map(|h| usize::from(h.join().unwrap()))
|
||||
.sum::<usize>(),
|
||||
1
|
||||
);
|
||||
let id = c.request_list().unwrap()[0].id.clone();
|
||||
let mut receiver = c.subscribe();
|
||||
c.deny(&id, "no", Some("passkey")).unwrap();
|
||||
assert_eq!(receiver.try_recv().unwrap(), id);
|
||||
assert_eq!(
|
||||
c.deny(&id, "again", Some("passkey")).unwrap_err().0,
|
||||
"already_decided"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn key_and_secret_persist() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let c = Cred::open(dir.path()).unwrap();
|
||||
let caller = caller("root");
|
||||
let mut config = item(true);
|
||||
config.transform_js = "inject".into();
|
||||
let i = c.create_item(config, &caller, "passkey").unwrap();
|
||||
c.set_field(
|
||||
&i.id,
|
||||
&i.fields[0].field_id,
|
||||
"persistent",
|
||||
&caller,
|
||||
"passkey",
|
||||
)
|
||||
.unwrap();
|
||||
drop(c);
|
||||
assert_eq!(
|
||||
std::fs::metadata(dir.path().join("cred.key"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o600
|
||||
);
|
||||
let c = Cred::open(dir.path()).unwrap();
|
||||
let Outcome::Forward(p) = c.inject(parts("api.example"), &caller, &Fake).unwrap() else {
|
||||
panic!("denied")
|
||||
};
|
||||
assert!(p.headers["authorization"] == "persistent");
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Ported rule boundaries and request/edit ledger regressions.
|
||||
use sausage::cred::{
|
||||
edits::Edit,
|
||||
items::*,
|
||||
requests::{GrantRequest, Overrides},
|
||||
*,
|
||||
};
|
||||
fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let c = Cred::open(dir.path()).unwrap();
|
||||
let caller = Caller {
|
||||
program: "/bin/tool.wasm".into(),
|
||||
pid: 7,
|
||||
conversations: vec!["root".into()],
|
||||
};
|
||||
let i = c
|
||||
.create_item(
|
||||
Item {
|
||||
id: String::new(),
|
||||
name: "item".into(),
|
||||
version: 0,
|
||||
free_use: false,
|
||||
rules: vec![],
|
||||
validate_js: None,
|
||||
transform_js: String::new(),
|
||||
fields: vec![Field {
|
||||
field_id: String::new(),
|
||||
name: "secret".into(),
|
||||
public: false,
|
||||
value: None,
|
||||
needs_value: true,
|
||||
field_type: types::FieldType::Text,
|
||||
key_type: None,
|
||||
}],
|
||||
},
|
||||
&caller,
|
||||
"pk",
|
||||
)
|
||||
.unwrap();
|
||||
(dir, c, caller, i)
|
||||
}
|
||||
#[test]
|
||||
fn rule_matching_table() {
|
||||
let mut rule:Rule=serde_json::from_value(serde_json::json!({"scheme":"https","hosts":["api.example.test","*.service.test"],"path_prefixes":["/v1/","/health"],"methods":["GET","POST"]})).unwrap();
|
||||
for (scheme, host, path, method, allowed) in [
|
||||
("https", "api.example.test", "/v1/a", "GET", true),
|
||||
("HTTPS", "API.EXAMPLE.TEST", "/v1/a", "get", true),
|
||||
("https", "a.service.test", "/health", "POST", true),
|
||||
("https", "a.b.service.test", "/healthz", "POST", true),
|
||||
("https", "service.test", "/health", "GET", false),
|
||||
("https", "evilservice.test", "/health", "GET", false),
|
||||
("https", "a.service.test.evil", "/health", "GET", false),
|
||||
("https", "api.example.test.evil", "/v1/a", "GET", false),
|
||||
("http", "api.example.test", "/v1/a", "GET", false),
|
||||
("https", "api.example.test", "/V1/a", "GET", false),
|
||||
("https", "api.example.test", "/v1", "GET", false),
|
||||
("https", "api.example.test", "/v1/a", "DELETE", false),
|
||||
] {
|
||||
assert_eq!(rule_matches(&rule, scheme, host, path, method), allowed);
|
||||
}
|
||||
rule.methods = None;
|
||||
assert!(rule_matches(
|
||||
&rule,
|
||||
"https",
|
||||
"api.example.test",
|
||||
"/v1/a",
|
||||
"DELETE"
|
||||
));
|
||||
rule.methods = Some(vec![]);
|
||||
assert!(!rule_matches(
|
||||
&rule,
|
||||
"https",
|
||||
"api.example.test",
|
||||
"/v1/a",
|
||||
"GET"
|
||||
));
|
||||
rule.methods = None;
|
||||
rule.path_prefixes.clear();
|
||||
assert!(!rule_matches(
|
||||
&rule,
|
||||
"https",
|
||||
"api.example.test",
|
||||
"/v1/a",
|
||||
"GET"
|
||||
));
|
||||
rule.path_prefixes.push("/".into());
|
||||
rule.hosts.clear();
|
||||
assert!(!rule_matches(
|
||||
&rule,
|
||||
"https",
|
||||
"api.example.test",
|
||||
"/v1/a",
|
||||
"GET"
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn revisions_needs_value_and_rename_are_metadata_only() {
|
||||
let (_dir, c, caller, i) = fixture();
|
||||
c.set_field(&i.id, &i.fields[0].field_id, "opaque", &caller, "pk")
|
||||
.unwrap();
|
||||
let old = c.item_get(&i.id).unwrap();
|
||||
assert_eq!(old.version, 2);
|
||||
assert!(!old.fields[0].needs_value);
|
||||
assert!(old.fields[0].value.is_none());
|
||||
let mut new = old.clone();
|
||||
new.name = "renamed".into();
|
||||
new.fields[0].name = "renamed_field".into();
|
||||
let id = c
|
||||
.file_edit_request(
|
||||
&caller,
|
||||
"rename",
|
||||
Edit {
|
||||
item: i.id.clone(),
|
||||
base_version: 2,
|
||||
config: new,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!c.edit_diff(&id).unwrap().discards_values);
|
||||
c.approve(&id, "yes", Overrides::default(), Some("pk"))
|
||||
.unwrap();
|
||||
let current = c.item_get(&i.id).unwrap();
|
||||
assert_eq!(current.version, 3);
|
||||
assert_eq!(current.fields[0].field_id, old.fields[0].field_id);
|
||||
assert_eq!(current.name, "renamed");
|
||||
assert_eq!(c.item_versions(&i.id).unwrap().len(), 2);
|
||||
}
|
||||
#[test]
|
||||
fn stale_edits_leave_history_and_values_untouched() {
|
||||
let (_dir, c, caller, i) = fixture();
|
||||
let id = c
|
||||
.file_edit_request(
|
||||
&caller,
|
||||
"edit",
|
||||
Edit {
|
||||
item: i.id.clone(),
|
||||
base_version: 1,
|
||||
config: i.clone(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
c.set_field(&i.id, &i.fields[0].field_id, "opaque", &caller, "pk")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
c.approve(&id, "yes", Overrides::default(), Some("pk"))
|
||||
.unwrap_err()
|
||||
.0,
|
||||
"stale"
|
||||
);
|
||||
assert_eq!(c.request_list().unwrap()[0].state, "pending");
|
||||
assert_eq!(c.item_versions(&i.id).unwrap().len(), 1);
|
||||
}
|
||||
#[test]
|
||||
fn key_conflict_renewal_and_expiry() {
|
||||
let (dir, c, caller, i) = fixture();
|
||||
let p = GrantRequest {
|
||||
items: vec![i.id.clone()],
|
||||
duration_secs: None,
|
||||
max_uses: None,
|
||||
validator: None,
|
||||
};
|
||||
let id = c.file_request(&caller, "key", p.clone()).unwrap();
|
||||
let mut other = p.clone();
|
||||
other.max_uses = Some(1);
|
||||
assert_eq!(
|
||||
c.file_request(&caller, "key", other).unwrap_err().0,
|
||||
"key_conflict"
|
||||
);
|
||||
c.deny(&id, "no", Some("pk")).unwrap();
|
||||
let next = c.file_request(&caller, "key", p).unwrap();
|
||||
assert_ne!(id, next);
|
||||
assert_eq!(c.request_decisions().unwrap().len(), 1);
|
||||
let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap();
|
||||
db.execute("UPDATE requests SET expires=0 WHERE id=?", [&next])
|
||||
.unwrap();
|
||||
let mut notification = c.subscribe();
|
||||
assert_eq!(c.expire_requests().unwrap(), vec![next.clone()]);
|
||||
assert_eq!(notification.try_recv().unwrap(), next);
|
||||
assert_eq!(
|
||||
c.approve(&next, "yes", Overrides::default(), Some("pk"))
|
||||
.unwrap_err()
|
||||
.0,
|
||||
"request_expired"
|
||||
);
|
||||
assert!(c.grant_list().unwrap().is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn typed_values_and_secret_configuration_fail_without_writes() {
|
||||
let (_dir, c, caller, i) = fixture();
|
||||
assert_eq!(
|
||||
c.set_field(&i.id, &i.fields[0].field_id, "line\nbreak", &caller, "pk")
|
||||
.unwrap_err()
|
||||
.0,
|
||||
"invalid_value"
|
||||
);
|
||||
assert_eq!(c.item_get(&i.id).unwrap().version, 1);
|
||||
let mut config = i.clone();
|
||||
config.fields[0].value = Some("forbidden".into());
|
||||
assert_eq!(
|
||||
c.create_item(config, &caller, "pk").unwrap_err().0,
|
||||
"secret_field"
|
||||
);
|
||||
assert_eq!(c.item_list().unwrap().len(), 1);
|
||||
assert!(c.item_versions(&i.id).unwrap().is_empty());
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! Explicit edit operations and atomic approval regression tests.
|
||||
use sausage::cred::{
|
||||
edits::{Operation, OperationRequest},
|
||||
items::*,
|
||||
requests::Overrides,
|
||||
*,
|
||||
};
|
||||
fn config(name: &str) -> Item {
|
||||
Item {
|
||||
id: String::new(),
|
||||
name: name.into(),
|
||||
version: 0,
|
||||
free_use: false,
|
||||
rules: vec![],
|
||||
validate_js: None,
|
||||
transform_js: String::new(),
|
||||
fields: vec![Field {
|
||||
field_id: String::new(),
|
||||
name: "private".into(),
|
||||
public: false,
|
||||
value: None,
|
||||
needs_value: true,
|
||||
field_type: types::FieldType::SshPrivateKey,
|
||||
key_type: Some("es256".into()),
|
||||
}],
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn creation_key_generation_and_deletion_keep_secrets_out_of_history() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let c = Cred::open(dir.path()).unwrap();
|
||||
let caller = Caller {
|
||||
program: "/bin/tool.wasm".into(),
|
||||
pid: 3,
|
||||
conversations: vec!["root".into()],
|
||||
};
|
||||
let id = c
|
||||
.file_operation_request(
|
||||
&caller,
|
||||
"generate",
|
||||
OperationRequest {
|
||||
item: None,
|
||||
base_version: None,
|
||||
change: vec![Operation::GenerateEs256Item {
|
||||
config: config("generated"),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(c.item_list().unwrap().is_empty());
|
||||
c.approve(&id, "yes", Overrides::default(), Some("pk"))
|
||||
.unwrap();
|
||||
let item = c.item_list().unwrap().remove(0);
|
||||
assert!(!item.fields[0].needs_value);
|
||||
assert!(item.fields[0].value.is_none());
|
||||
assert!(
|
||||
!serde_json::to_string(&c.request_diff(&id).unwrap())
|
||||
.unwrap()
|
||||
.contains("PRIVATE KEY")
|
||||
);
|
||||
let id = c
|
||||
.file_operation_request(
|
||||
&caller,
|
||||
"delete",
|
||||
OperationRequest {
|
||||
item: Some(item.id.clone()),
|
||||
base_version: Some(item.version),
|
||||
change: vec![Operation::DeleteItem],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
c.approve(&id, "yes", Overrides::default(), Some("pk"))
|
||||
.unwrap();
|
||||
assert!(c.item_list().unwrap().is_empty());
|
||||
assert_eq!(c.item_versions(&item.id).unwrap().len(), 1);
|
||||
let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap();
|
||||
assert_eq!(
|
||||
db.query_row("SELECT COUNT(*) FROM fields", [], |r| r.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn operation_order_name_conflicts_and_stale_resolution() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let c = Cred::open(dir.path()).unwrap();
|
||||
let caller = Caller {
|
||||
program: "/bin/tool.wasm".into(),
|
||||
pid: 3,
|
||||
conversations: vec!["root".into()],
|
||||
};
|
||||
let item = c.create_item(config("first"), &caller, "pk").unwrap();
|
||||
c.create_item(config("taken"), &caller, "pk").unwrap();
|
||||
let request = |change| OperationRequest {
|
||||
item: Some(item.id.clone()),
|
||||
base_version: Some(item.version),
|
||||
change,
|
||||
};
|
||||
assert_eq!(
|
||||
c.file_operation_request(
|
||||
&caller,
|
||||
"conflict",
|
||||
request(vec![Operation::SetName {
|
||||
name: "taken".into()
|
||||
}])
|
||||
)
|
||||
.unwrap_err()
|
||||
.0,
|
||||
"name_conflict"
|
||||
);
|
||||
let id = c
|
||||
.file_operation_request(
|
||||
&caller,
|
||||
"rename",
|
||||
request(vec![
|
||||
Operation::SetName {
|
||||
name: "intermediate".into(),
|
||||
},
|
||||
Operation::SetName {
|
||||
name: "last".into(),
|
||||
},
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
let other = c
|
||||
.file_operation_request(
|
||||
&caller,
|
||||
"other",
|
||||
request(vec![Operation::SetFreeUse { enabled: true }]),
|
||||
)
|
||||
.unwrap();
|
||||
c.approve(&other, "yes", Overrides::default(), Some("pk"))
|
||||
.unwrap();
|
||||
assert!(c.edit_diff(&id).unwrap().stale);
|
||||
assert_eq!(
|
||||
c.approve(&id, "yes", Overrides::default(), Some("pk"))
|
||||
.unwrap_err()
|
||||
.0,
|
||||
"stale"
|
||||
);
|
||||
c.resolve_stale(&id, 2).unwrap();
|
||||
assert!(!c.edit_diff(&id).unwrap().stale);
|
||||
c.approve(&id, "yes", Overrides::default(), Some("pk"))
|
||||
.unwrap();
|
||||
let current = c.item_get(&item.id).unwrap();
|
||||
assert_eq!(current.name, "last");
|
||||
assert!(current.free_use);
|
||||
}
|
||||
#[test]
|
||||
fn field_removal_revert_and_public_visibility() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let c = Cred::open(dir.path()).unwrap();
|
||||
let caller = Caller {
|
||||
program: "/bin/tool.wasm".into(),
|
||||
pid: 3,
|
||||
conversations: vec!["root".into()],
|
||||
};
|
||||
let mut initial = config("item");
|
||||
initial.fields[0].field_type = types::FieldType::Text;
|
||||
initial.fields[0].key_type = None;
|
||||
initial.fields.push(Field {
|
||||
field_id: String::new(),
|
||||
name: "public".into(),
|
||||
public: true,
|
||||
value: Some("first".into()),
|
||||
needs_value: false,
|
||||
field_type: types::FieldType::Text,
|
||||
key_type: None,
|
||||
});
|
||||
let item = c.create_item(initial, &caller, "pk").unwrap();
|
||||
c.set_field(
|
||||
&item.id,
|
||||
&item.fields[0].field_id,
|
||||
"SECRET_REMOVED",
|
||||
&caller,
|
||||
"pk",
|
||||
)
|
||||
.unwrap();
|
||||
let current = c.item_get(&item.id).unwrap();
|
||||
let request = |change| OperationRequest {
|
||||
item: Some(item.id.clone()),
|
||||
base_version: Some(current.version),
|
||||
change,
|
||||
};
|
||||
let id = c
|
||||
.file_operation_request(
|
||||
&caller,
|
||||
"remove",
|
||||
request(vec![
|
||||
Operation::RemoveField {
|
||||
field: item.fields[0].field_id.clone(),
|
||||
},
|
||||
Operation::SetFieldValue {
|
||||
field: item.fields[1].field_id.clone(),
|
||||
value: "second".into(),
|
||||
},
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(c.edit_diff(&id).unwrap().discards_values);
|
||||
c.approve(&id, "yes", Overrides::default(), Some("pk"))
|
||||
.unwrap();
|
||||
let id = c
|
||||
.file_revert_request(&caller, "revert", &item.id, 3, 2)
|
||||
.unwrap();
|
||||
c.approve(&id, "yes", Overrides::default(), Some("pk"))
|
||||
.unwrap();
|
||||
let restored = c.item_get(&item.id).unwrap();
|
||||
assert!(restored.fields[0].needs_value);
|
||||
assert_eq!(restored.fields[0].field_id, item.fields[0].field_id);
|
||||
assert_eq!(restored.fields[1].value.as_deref(), Some("second"));
|
||||
let mut changed = restored.clone();
|
||||
changed.fields[1].public = false;
|
||||
changed.fields[1].value = None;
|
||||
assert_eq!(
|
||||
c.file_edit_request(
|
||||
&caller,
|
||||
"visibility",
|
||||
sausage::cred::edits::Edit {
|
||||
item: item.id,
|
||||
base_version: restored.version,
|
||||
config: changed
|
||||
}
|
||||
)
|
||||
.unwrap_err()
|
||||
.0,
|
||||
"immutable_visibility"
|
||||
);
|
||||
assert!(!format!("{:?}", c.item_list().unwrap()).contains("SECRET_REMOVED"));
|
||||
}
|
||||
#[test]
|
||||
fn decision_failure_rolls_back_every_write() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let c = Cred::open(dir.path()).unwrap();
|
||||
let caller = Caller {
|
||||
program: "/bin/tool.wasm".into(),
|
||||
pid: 3,
|
||||
conversations: vec!["root".into()],
|
||||
};
|
||||
let id = c
|
||||
.file_operation_request(
|
||||
&caller,
|
||||
"create",
|
||||
OperationRequest {
|
||||
item: None,
|
||||
base_version: None,
|
||||
change: vec![Operation::CreateItem {
|
||||
config: config("first"),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap();
|
||||
db.execute_batch("CREATE TRIGGER fail_decision BEFORE UPDATE ON requests BEGIN SELECT RAISE(ABORT, 'forced'); END;").unwrap();
|
||||
assert!(
|
||||
c.approve(&id, "yes", Overrides::default(), Some("pk"))
|
||||
.is_err()
|
||||
);
|
||||
assert!(c.item_list().unwrap().is_empty());
|
||||
assert!(c.audit_list().unwrap().is_empty());
|
||||
assert_eq!(c.request_list().unwrap()[0].state, "pending");
|
||||
db.execute_batch("DROP TRIGGER fail_decision;").unwrap();
|
||||
c.approve(&id, "yes", Overrides::default(), Some("pk"))
|
||||
.unwrap();
|
||||
assert_eq!(c.item_list().unwrap().len(), 1);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! WebCrypto interoperability and host key persistence for secret entry.
|
||||
use deno_core::{JsRuntime, RuntimeOptions};
|
||||
use sausage::cred::entry::{EntryKey, Envelope};
|
||||
use std::{os::unix::fs::PermissionsExt, sync::Arc};
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn webcrypto_entry() -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("entry.key");
|
||||
let key = EntryKey::open(&path)?;
|
||||
assert_eq!(
|
||||
std::fs::metadata(&path)?.permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
assert_eq!(key.public_key(), EntryKey::open(&path)?.public_key());
|
||||
let mut runtime = JsRuntime::new(RuntimeOptions {
|
||||
extensions: vec![
|
||||
deno_webidl::deno_webidl::init(),
|
||||
deno_web::deno_web::init(
|
||||
Arc::new(deno_web::BlobStore::default()),
|
||||
None,
|
||||
false,
|
||||
Default::default(),
|
||||
),
|
||||
deno_crypto::deno_crypto::init(None),
|
||||
],
|
||||
..Default::default()
|
||||
});
|
||||
runtime.execute_script(
|
||||
"crypto.js",
|
||||
"globalThis.crypto = Deno.core.loadExtScript('ext:deno_crypto/00_crypto.js').crypto;",
|
||||
)?;
|
||||
runtime.execute_script("encrypt.js", format!(r#"
|
||||
globalThis.done = (async () => {{
|
||||
const host = await crypto.subtle.importKey('raw', new Uint8Array({}), {{name:'ECDH', namedCurve:'P-256'}}, false, []);
|
||||
const pair = await crypto.subtle.generateKey({{name:'ECDH', namedCurve:'P-256'}}, true, ['deriveBits']);
|
||||
const bits = await crypto.subtle.deriveBits({{name:'ECDH', public:host}}, pair.privateKey, 256);
|
||||
const material = await crypto.subtle.importKey('raw', bits, 'HKDF', false, ['deriveKey']);
|
||||
const info = new Uint8Array(Array.from('sausage/secret-entry/v1', c => c.charCodeAt(0)));
|
||||
const aes = await crypto.subtle.deriveKey({{name:'HKDF', hash:'SHA-256', salt:new Uint8Array(), info}}, 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}}, aes, new Uint8Array(Array.from('browser-only-value', c => c.charCodeAt(0))));
|
||||
globalThis.envelope = JSON.stringify({{ephemeral_public_key:Array.from(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey))), iv:Array.from(iv), ciphertext:Array.from(new Uint8Array(ciphertext))}});
|
||||
}})();
|
||||
"#, serde_json::to_string(&key.public_key())?))?;
|
||||
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)
|
||||
};
|
||||
let mut envelope: Envelope = serde_json::from_str(&text)?;
|
||||
let expected = b"browser-only-value";
|
||||
assert!(key.decrypt(&envelope)? == expected);
|
||||
let hash = envelope.sha256();
|
||||
envelope.ciphertext[0] ^= 1;
|
||||
assert_ne!(hash, envelope.sha256());
|
||||
let error = key.decrypt(&envelope).unwrap_err().to_string();
|
||||
assert!(!error.contains(std::str::from_utf8(expected)?));
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//! Grant expiry, revocation, validators and issuance boundaries from v2.
|
||||
use sausage::cred::{
|
||||
grants::Grant,
|
||||
inject::Outcome,
|
||||
items::*,
|
||||
script::{Limits, Script},
|
||||
*,
|
||||
};
|
||||
struct Fake;
|
||||
impl Script for Fake {
|
||||
fn evaluate(
|
||||
&self,
|
||||
source: &str,
|
||||
input: &str,
|
||||
_: Option<&str>,
|
||||
_: Limits,
|
||||
) -> std::result::Result<String, script::Error> {
|
||||
let ctx: serde_json::Value = serde_json::from_str(input).unwrap();
|
||||
assert_eq!(ctx["grant"]["conversation"], "root");
|
||||
Ok(if source == "deny" { "false" } else { "true" }.into())
|
||||
}
|
||||
}
|
||||
fn parts() -> http::request::Parts {
|
||||
http::Request::get("https://api.test/path")
|
||||
.body(())
|
||||
.unwrap()
|
||||
.into_parts()
|
||||
.0
|
||||
}
|
||||
fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let c = Cred::open(dir.path()).unwrap();
|
||||
let caller = Caller {
|
||||
program: "/bin/tool.wasm".into(),
|
||||
pid: 1,
|
||||
conversations: vec!["root".into()],
|
||||
};
|
||||
let i = c
|
||||
.create_item(
|
||||
Item {
|
||||
id: String::new(),
|
||||
name: "item".into(),
|
||||
version: 0,
|
||||
free_use: false,
|
||||
rules: vec![Rule {
|
||||
scheme: "https".into(),
|
||||
hosts: vec!["api.test".into()],
|
||||
path_prefixes: vec!["/".into()],
|
||||
methods: None,
|
||||
}],
|
||||
validate_js: None,
|
||||
transform_js: String::new(),
|
||||
fields: vec![],
|
||||
},
|
||||
&caller,
|
||||
"pk",
|
||||
)
|
||||
.unwrap();
|
||||
(dir, c, caller, i)
|
||||
}
|
||||
fn grant(item: &Item) -> Grant {
|
||||
Grant {
|
||||
id: String::new(),
|
||||
items: vec![item.id.clone()],
|
||||
subject: types::Subject::Conversation("root".into()),
|
||||
exp: None,
|
||||
max_uses: Some(1),
|
||||
uses: 0,
|
||||
revoked: false,
|
||||
validator: None,
|
||||
note: Some("work".into()),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn program_grant_covers_service_and_agent_but_not_another_program() {
|
||||
let (_dir, c, _caller, _item) = fixture();
|
||||
let service = Caller {
|
||||
pid: 10,
|
||||
program: "/bin/service.wasm".into(),
|
||||
conversations: vec![],
|
||||
};
|
||||
let Outcome::Denied { request, .. } = c.inject(parts(), &service, &Fake).unwrap() else {
|
||||
panic!("expected request")
|
||||
};
|
||||
assert_eq!(
|
||||
c.request_list().unwrap()[0].subject,
|
||||
types::Subject::Program(service.program.clone())
|
||||
);
|
||||
c.approve(
|
||||
&request,
|
||||
"allow",
|
||||
sausage::cred::requests::Overrides::default(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let agent = Caller {
|
||||
pid: 11,
|
||||
program: service.program.clone(),
|
||||
conversations: vec!["agent".into()],
|
||||
};
|
||||
for caller in [&service, &agent] {
|
||||
assert!(matches!(
|
||||
c.inject(parts(), caller, &Fake).unwrap(),
|
||||
Outcome::Forward(_)
|
||||
));
|
||||
}
|
||||
let other = Caller {
|
||||
pid: 12,
|
||||
program: "/bin/other.wasm".into(),
|
||||
conversations: vec![],
|
||||
};
|
||||
assert!(matches!(
|
||||
c.inject(parts(), &other, &Fake).unwrap(),
|
||||
Outcome::Denied { .. }
|
||||
));
|
||||
let decisions = c.audit_list().unwrap();
|
||||
let row = decisions.iter().find(|r| r.event == "approved").unwrap();
|
||||
assert!(row.approver.is_none());
|
||||
assert_eq!(row.program, service.program);
|
||||
assert!(row.conversation.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn expiry_boundary_revoke_and_exhaustion() {
|
||||
let (dir, c, caller, i) = fixture();
|
||||
let g = c.grant_create(grant(&i), &caller, Some("pk")).unwrap();
|
||||
assert!(matches!(
|
||||
c.inject(parts(), &caller, &Fake).unwrap(),
|
||||
Outcome::Forward(_)
|
||||
));
|
||||
assert_eq!(c.grant_status(&g.id).unwrap().uses, 1);
|
||||
assert!(matches!(
|
||||
c.inject(parts(), &caller, &Fake).unwrap(),
|
||||
Outcome::Denied { .. }
|
||||
));
|
||||
assert_eq!(c.grant_status(&g.id).unwrap().uses, 1);
|
||||
let mut unlimited = grant(&i);
|
||||
unlimited.max_uses = None;
|
||||
let g = c.grant_create(unlimited, &caller, Some("pk")).unwrap();
|
||||
let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap();
|
||||
db.execute("UPDATE grants SET exp=0 WHERE id=?", [&g.id])
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
c.inject(parts(), &caller, &Fake).unwrap(),
|
||||
Outcome::Denied { .. }
|
||||
));
|
||||
assert_eq!(c.grant_status(&g.id).unwrap().uses, 0);
|
||||
c.revoke_subjects(&caller.conversations, &caller, Some("pk"))
|
||||
.unwrap();
|
||||
assert!(c.grant_list().unwrap().iter().all(|g| g.revoked));
|
||||
}
|
||||
#[test]
|
||||
fn reject_bad_issuance_without_rows() {
|
||||
let (_dir, c, caller, i) = fixture();
|
||||
let mut g = grant(&i);
|
||||
g.max_uses = Some(0);
|
||||
assert_eq!(
|
||||
c.grant_create(g, &caller, Some("pk")).unwrap_err().0,
|
||||
"invalid_uses"
|
||||
);
|
||||
let mut g = grant(&i);
|
||||
g.subject = types::Subject::Conversation(String::new());
|
||||
assert_eq!(
|
||||
c.grant_create(g, &caller, Some("pk")).unwrap_err().0,
|
||||
"invalid_subject"
|
||||
);
|
||||
assert!(c.grant_list().unwrap().is_empty());
|
||||
assert_eq!(c.audit_list().unwrap().len(), 1);
|
||||
}
|
||||
#[test]
|
||||
fn preflight_spends_nothing_and_validator_denial_spends_one() {
|
||||
let (_dir, c, caller, i) = fixture();
|
||||
let mut g = grant(&i);
|
||||
g.validator = Some("deny".into());
|
||||
let g = c.grant_create(g, &caller, Some("pk")).unwrap();
|
||||
let mut p = parts();
|
||||
p.headers
|
||||
.insert("bad", http::HeaderValue::from_bytes(&[0xff]).unwrap());
|
||||
assert_eq!(
|
||||
c.inject(p, &caller, &Fake).err().unwrap().0,
|
||||
"invalid_headers"
|
||||
);
|
||||
assert_eq!(c.grant_status(&g.id).unwrap().uses, 0);
|
||||
assert_eq!(
|
||||
c.inject(parts(), &caller, &Fake).err().unwrap().0,
|
||||
"validator_denied"
|
||||
);
|
||||
assert_eq!(c.grant_status(&g.id).unwrap().uses, 1);
|
||||
let audit = c.audit_list().unwrap();
|
||||
let use_row = audit.last().unwrap();
|
||||
assert_eq!(use_row.event, "fetch.denied");
|
||||
assert_eq!(use_row.pid, caller.pid);
|
||||
assert_eq!(use_row.conversation.as_deref(), Some("root"));
|
||||
assert!(
|
||||
audit
|
||||
.iter()
|
||||
.filter(|r| r.event == "grant.create")
|
||||
.all(|r| r.approver.as_deref() == Some("pk"))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Refresh form encoding, response validation, and revision-aware cache behavior.
|
||||
use sausage::cred::oauth2::*;
|
||||
use std::{
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
struct Fake(AtomicUsize);
|
||||
impl Http for Fake {
|
||||
async fn send(&self, request: &Request, _: Instant) -> Result<Zeroizing<Vec<u8>>, Error> {
|
||||
assert_eq!(request.method, "POST");
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(Zeroizing::new(br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":3600,"scope":"one two"}"#.to_vec()))
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn encoding_and_cache() {
|
||||
let req = request(
|
||||
"https://tokens.example.test/token",
|
||||
"REFRESH&=+",
|
||||
"CLIENT",
|
||||
"SECRET&=+",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
req.body.as_str()
|
||||
== "grant_type=refresh_token&refresh_token=REFRESH%26%3D%2B&client_id=CLIENT&client_secret=SECRET%26%3D%2B"
|
||||
);
|
||||
assert_eq!(req.headers[1].1, req.body.len().to_string());
|
||||
let fake = Fake(AtomicUsize::new(0));
|
||||
let mut cache = Cache::default();
|
||||
let deadline = Instant::now() + Duration::from_secs(1);
|
||||
let t = cache.refresh("id", 1, &req, &fake, deadline).await.unwrap();
|
||||
assert_eq!(t.scopes.unwrap(), vec!["one", "two"]);
|
||||
cache.refresh("id", 1, &req, &fake, deadline).await.unwrap();
|
||||
assert_eq!(fake.0.load(Ordering::SeqCst), 1);
|
||||
cache.refresh("id", 2, &req, &fake, deadline).await.unwrap();
|
||||
assert_eq!(fake.0.load(Ordering::SeqCst), 2);
|
||||
cache.clear();
|
||||
cache.refresh("id", 2, &req, &fake, deadline).await.unwrap();
|
||||
assert_eq!(fake.0.load(Ordering::SeqCst), 3);
|
||||
for url in [
|
||||
"http://tokens.example.test/token",
|
||||
"https://user@tokens.example.test/token",
|
||||
"https://tokens.example.test/token#x",
|
||||
] {
|
||||
assert!(request(url, "r", "c", "s").is_err());
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn expiry_and_failure() {
|
||||
struct Expired(AtomicUsize);
|
||||
impl Http for Expired {
|
||||
async fn send(&self, _: &Request, _: Instant) -> Result<Zeroizing<Vec<u8>>, Error> {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(Zeroizing::new(
|
||||
br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":1}"#.to_vec(),
|
||||
))
|
||||
}
|
||||
}
|
||||
struct Failed;
|
||||
impl Http for Failed {
|
||||
async fn send(&self, _: &Request, _: Instant) -> Result<Zeroizing<Vec<u8>>, Error> {
|
||||
Err(Error)
|
||||
}
|
||||
}
|
||||
let req = request("https://tokens.example.test/token", "r", "c", "s").unwrap();
|
||||
let mut cache = Cache::default();
|
||||
let http = Expired(AtomicUsize::new(0));
|
||||
let deadline = Instant::now() + Duration::from_secs(1);
|
||||
cache.refresh("id", 1, &req, &http, deadline).await.unwrap();
|
||||
cache.refresh("id", 1, &req, &http, deadline).await.unwrap();
|
||||
assert_eq!(http.0.load(Ordering::SeqCst), 2);
|
||||
assert!(
|
||||
cache
|
||||
.refresh("id", 2, &req, &Failed, deadline)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
cache.refresh("id", 2, &req, &http, deadline).await.unwrap();
|
||||
assert_eq!(http.0.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation() {
|
||||
for data in [
|
||||
br#"{"error":"invalid_grant","secret":"DO NOT ECHO"}"#.as_slice(),
|
||||
br#"{"access_token":"","token_type":"Bearer","expires_in":3600}"#,
|
||||
br#"{"access_token":"x","token_type":"MAC","expires_in":3600}"#,
|
||||
br#"{"access_token":"x","token_type":"Bearer","expires_in":0}"#,
|
||||
br#"{"access_token":"x\r\ny","token_type":"Bearer","expires_in":3600}"#,
|
||||
] {
|
||||
let e = decode(data).err().unwrap();
|
||||
assert!(!e.to_string().contains("DO NOT ECHO"));
|
||||
}
|
||||
assert!(
|
||||
decode(br#"{"access_token":"x","token_type":"Bearer","expires_in":18446744073709551615}"#)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
//! Retained v2 item, grant, and transaction regressions over native SQLite.
|
||||
use sausage::cred::{
|
||||
edits::{Delete, Edit},
|
||||
grants::Grant,
|
||||
items::*,
|
||||
requests::{GrantRequest, Overrides},
|
||||
script::{Limits, Script},
|
||||
*,
|
||||
};
|
||||
struct Fake;
|
||||
impl Script for Fake {
|
||||
fn evaluate(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: Option<&str>,
|
||||
_: Limits,
|
||||
) -> std::result::Result<String, script::Error> {
|
||||
Ok("true".into())
|
||||
}
|
||||
}
|
||||
fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cred = Cred::open(dir.path()).unwrap();
|
||||
let caller = Caller {
|
||||
pid: 3,
|
||||
program: "/bin/tool.wasm".into(),
|
||||
conversations: vec!["root".into()],
|
||||
};
|
||||
let item = cred
|
||||
.create_item(
|
||||
Item {
|
||||
id: String::new(),
|
||||
name: "item".into(),
|
||||
version: 0,
|
||||
free_use: false,
|
||||
rules: vec![Rule {
|
||||
scheme: "https".into(),
|
||||
hosts: vec!["api.test".into()],
|
||||
path_prefixes: vec!["/".into()],
|
||||
methods: None,
|
||||
}],
|
||||
validate_js: None,
|
||||
transform_js: String::new(),
|
||||
fields: vec![
|
||||
Field {
|
||||
field_id: String::new(),
|
||||
name: "first".into(),
|
||||
public: false,
|
||||
value: None,
|
||||
needs_value: true,
|
||||
field_type: types::FieldType::Text,
|
||||
key_type: None,
|
||||
},
|
||||
Field {
|
||||
field_id: String::new(),
|
||||
name: "second".into(),
|
||||
public: false,
|
||||
value: None,
|
||||
needs_value: true,
|
||||
field_type: types::FieldType::Text,
|
||||
key_type: None,
|
||||
},
|
||||
],
|
||||
},
|
||||
&caller,
|
||||
"pk",
|
||||
)
|
||||
.unwrap();
|
||||
(dir, cred, caller, item)
|
||||
}
|
||||
fn parts() -> http::request::Parts {
|
||||
http::Request::get("https://api.test/")
|
||||
.body(())
|
||||
.unwrap()
|
||||
.into_parts()
|
||||
.0
|
||||
}
|
||||
fn grant(item: &Item) -> Grant {
|
||||
Grant {
|
||||
id: String::new(),
|
||||
items: vec![item.id.clone()],
|
||||
subject: types::Subject::Conversation("root".into()),
|
||||
exp: None,
|
||||
max_uses: Some(1),
|
||||
uses: 0,
|
||||
revoked: false,
|
||||
validator: None,
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn missing_values_and_wrong_master_spend_nothing() {
|
||||
let (dir, cred, caller, item) = fixture();
|
||||
let grant = cred
|
||||
.grant_create(grant(&item), &caller, Some("pk"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cred.inject(parts(), &caller, &Fake).err().unwrap().0,
|
||||
"needs_value"
|
||||
);
|
||||
assert_eq!(cred.grant_status(&grant.id).unwrap().uses, 0);
|
||||
for field in &item.fields {
|
||||
cred.set_field(&item.id, &field.field_id, "opaque", &caller, "pk")
|
||||
.unwrap();
|
||||
}
|
||||
drop(cred);
|
||||
std::fs::write(dir.path().join("cred.key"), [7u8; 32]).unwrap();
|
||||
let cred = Cred::open(dir.path()).unwrap();
|
||||
assert_eq!(
|
||||
cred.inject(parts(), &caller, &Fake).err().unwrap().0,
|
||||
"seal"
|
||||
);
|
||||
assert_eq!(cred.grant_status(&grant.id).unwrap().uses, 0);
|
||||
}
|
||||
#[test]
|
||||
fn field_name_swaps_preserve_ciphertexts_and_failed_edits_roll_back() {
|
||||
let (dir, cred, caller, item) = fixture();
|
||||
for field in &item.fields {
|
||||
cred.set_field(&item.id, &field.field_id, "opaque", &caller, "pk")
|
||||
.unwrap();
|
||||
}
|
||||
let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap();
|
||||
let cipher = || {
|
||||
let mut q = db
|
||||
.prepare("SELECT ciphertext FROM fields ORDER BY id")
|
||||
.unwrap();
|
||||
q.query_map([], |r| r.get::<_, Vec<u8>>(0))
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.unwrap()
|
||||
};
|
||||
let before = cipher();
|
||||
let old = cred.item_get(&item.id).unwrap();
|
||||
let mut config = old.clone();
|
||||
config.fields[0].name = "second".into();
|
||||
config.fields[1].name = "first".into();
|
||||
let id = cred
|
||||
.file_edit_request(
|
||||
&caller,
|
||||
"swap",
|
||||
Edit {
|
||||
item: item.id.clone(),
|
||||
base_version: old.version,
|
||||
config,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
|
||||
.unwrap();
|
||||
assert!(cipher() == before);
|
||||
let current = cred.item_get(&item.id).unwrap();
|
||||
let mut config = current.clone();
|
||||
config.fields.clear();
|
||||
let id = cred
|
||||
.file_edit_request(
|
||||
&caller,
|
||||
"remove",
|
||||
Edit {
|
||||
item: item.id.clone(),
|
||||
base_version: current.version,
|
||||
config,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let history = cred.item_versions(&item.id).unwrap().len();
|
||||
let audit = cred.audit_list().unwrap().len();
|
||||
db.execute_batch("CREATE TRIGGER fail_decision BEFORE UPDATE ON requests BEGIN SELECT RAISE(ABORT,'forced'); END;").unwrap();
|
||||
assert!(
|
||||
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
|
||||
.is_err()
|
||||
);
|
||||
assert!(cipher() == before);
|
||||
assert_eq!(cred.item_get(&item.id).unwrap().version, current.version);
|
||||
assert_eq!(cred.item_versions(&item.id).unwrap().len(), history);
|
||||
assert_eq!(cred.audit_list().unwrap().len(), audit);
|
||||
}
|
||||
#[test]
|
||||
fn deleted_item_revert_preserves_identity_but_never_restores_secrets() {
|
||||
let (_dir, cred, caller, item) = fixture();
|
||||
for field in &item.fields {
|
||||
cred.set_field(&item.id, &field.field_id, "opaque", &caller, "pk")
|
||||
.unwrap();
|
||||
}
|
||||
let current = cred.item_get(&item.id).unwrap();
|
||||
let id = cred
|
||||
.file_delete_request(
|
||||
&caller,
|
||||
"delete",
|
||||
Delete {
|
||||
item: item.id.clone(),
|
||||
base_version: current.version,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
|
||||
.unwrap();
|
||||
let id = cred
|
||||
.file_revert_request(
|
||||
&caller,
|
||||
"restore",
|
||||
&item.id,
|
||||
current.version + 1,
|
||||
current.version,
|
||||
)
|
||||
.unwrap();
|
||||
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
|
||||
.unwrap();
|
||||
let restored = cred.item_get(&item.id).unwrap();
|
||||
assert_eq!(restored.id, item.id);
|
||||
assert_eq!(restored.version, current.version + 2);
|
||||
assert!(
|
||||
restored
|
||||
.fields
|
||||
.iter()
|
||||
.all(|f| f.needs_value && f.value.is_none())
|
||||
);
|
||||
assert_eq!(restored.fields[0].field_id, item.fields[0].field_id);
|
||||
}
|
||||
#[test]
|
||||
fn secret_entry_keeps_oauth_cardinality_and_rolls_back_ciphertext() {
|
||||
let (dir, cred, caller, item) = fixture();
|
||||
let input = FieldInput {
|
||||
value: "opaque",
|
||||
field_type: Some(types::FieldType::Oauth2RefreshToken),
|
||||
key_type: None,
|
||||
};
|
||||
cred.set_field_input(&item.id, &item.fields[0].field_id, &input, &caller, "pk")
|
||||
.unwrap();
|
||||
cred.set_field(&item.id, &item.fields[1].field_id, "opaque", &caller, "pk")
|
||||
.unwrap();
|
||||
let current = cred.item_get(&item.id).unwrap();
|
||||
let history = cred.item_versions(&item.id).unwrap().len();
|
||||
let audit = cred.audit_list().unwrap().len();
|
||||
let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap();
|
||||
let cipher = || {
|
||||
db.query_row(
|
||||
"SELECT ciphertext FROM fields WHERE item=?1 AND id=?2",
|
||||
rusqlite::params![item.id, item.fields[1].field_id],
|
||||
|r| r.get::<_, Vec<u8>>(0),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let before = cipher();
|
||||
assert_eq!(
|
||||
cred.set_field_input(&item.id, &item.fields[1].field_id, &input, &caller, "pk")
|
||||
.err()
|
||||
.unwrap()
|
||||
.0,
|
||||
"invalid_params"
|
||||
);
|
||||
assert!(cipher() == before);
|
||||
assert_eq!(cred.item_get(&item.id).unwrap().version, current.version);
|
||||
assert_eq!(cred.item_versions(&item.id).unwrap().len(), history);
|
||||
assert_eq!(cred.audit_list().unwrap().len(), audit);
|
||||
}
|
||||
#[test]
|
||||
fn typed_secret_entry_and_bundle_normalization() {
|
||||
let (_dir, cred, caller, item) = fixture();
|
||||
let mut metadata = item.clone();
|
||||
metadata.fields[0].field_type = types::FieldType::SshPrivateKey;
|
||||
metadata.fields[0].key_type = Some("es256".into());
|
||||
let id = cred
|
||||
.file_edit_request(
|
||||
&caller,
|
||||
"metadata",
|
||||
Edit {
|
||||
item: item.id.clone(),
|
||||
base_version: item.version,
|
||||
config: metadata,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
|
||||
.unwrap();
|
||||
let input = FieldInput {
|
||||
value: "two\nlines",
|
||||
field_type: Some(types::FieldType::Note),
|
||||
key_type: None,
|
||||
};
|
||||
cred.set_field_input(&item.id, &item.fields[0].field_id, &input, &caller, "pk")
|
||||
.unwrap();
|
||||
let current = cred.item_get(&item.id).unwrap();
|
||||
assert_eq!(current.fields[0].field_type, types::FieldType::Note);
|
||||
assert!(current.fields[0].key_type.is_none());
|
||||
assert!(current.fields[0].value.is_none());
|
||||
assert_eq!(
|
||||
cred.item_versions(&item.id).unwrap()[0].config.fields[0].field_type,
|
||||
types::FieldType::Text
|
||||
);
|
||||
let p = GrantRequest {
|
||||
items: vec![item.name.clone(), item.id.clone()],
|
||||
duration_secs: None,
|
||||
max_uses: Some(1),
|
||||
validator: None,
|
||||
};
|
||||
let id = cred.file_request(&caller, "bundle", p).unwrap();
|
||||
let p = GrantRequest {
|
||||
items: vec![item.id],
|
||||
duration_secs: None,
|
||||
max_uses: Some(1),
|
||||
validator: None,
|
||||
};
|
||||
assert_eq!(cred.file_request(&caller, "bundle", p).unwrap(), id);
|
||||
cred.approve(
|
||||
&id,
|
||||
"yes",
|
||||
Overrides {
|
||||
unlimited: true,
|
||||
..Overrides::default()
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let g = cred.grant_list().unwrap().remove(0);
|
||||
assert_eq!(g.items.len(), 1);
|
||||
assert!(g.max_uses.is_none());
|
||||
assert!(g.exp.is_some());
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
//! v2 keyed replay compares intent before current item validation or edit planning.
|
||||
use sausage::cred::{
|
||||
edits::*,
|
||||
items::*,
|
||||
requests::{GrantRequest, Overrides},
|
||||
types::FieldType,
|
||||
*,
|
||||
};
|
||||
fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cred = Cred::open(dir.path()).unwrap();
|
||||
let caller = Caller {
|
||||
pid: 1,
|
||||
program: "/bin/tool".into(),
|
||||
conversations: vec!["root".into()],
|
||||
};
|
||||
let item = cred
|
||||
.create_item(
|
||||
Item {
|
||||
id: String::new(),
|
||||
name: "item".into(),
|
||||
version: 0,
|
||||
free_use: false,
|
||||
rules: vec![],
|
||||
validate_js: None,
|
||||
transform_js: String::new(),
|
||||
fields: vec![Field {
|
||||
field_id: String::new(),
|
||||
name: "token".into(),
|
||||
public: false,
|
||||
value: None,
|
||||
needs_value: true,
|
||||
field_type: FieldType::Text,
|
||||
key_type: None,
|
||||
}],
|
||||
},
|
||||
&caller,
|
||||
"pk",
|
||||
)
|
||||
.unwrap();
|
||||
(dir, cred, caller, item)
|
||||
}
|
||||
#[test]
|
||||
fn approved_operations_and_deletes_replay() {
|
||||
let (_dir, cred, caller, item) = fixture();
|
||||
let request = OperationRequest {
|
||||
item: Some(item.id.clone()),
|
||||
base_version: Some(item.version),
|
||||
change: vec![Operation::SetName {
|
||||
name: "renamed".into(),
|
||||
}],
|
||||
};
|
||||
let id = cred
|
||||
.file_operation_request(&caller, "edit", request.clone())
|
||||
.unwrap();
|
||||
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cred.file_operation_request(&caller, "edit", request)
|
||||
.unwrap(),
|
||||
id
|
||||
);
|
||||
let current = cred.item_get(&item.id).unwrap();
|
||||
let delete = Delete {
|
||||
item: item.id,
|
||||
base_version: current.version,
|
||||
};
|
||||
let id = cred
|
||||
.file_delete_request(&caller, "delete", delete.clone())
|
||||
.unwrap();
|
||||
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cred.file_delete_request(&caller, "delete", delete).unwrap(),
|
||||
id
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn approved_replacements_and_creations_replay() {
|
||||
let (_dir, cred, caller, item) = fixture();
|
||||
let mut config = item.clone();
|
||||
config.name = "renamed".into();
|
||||
let edit = Edit {
|
||||
item: item.id,
|
||||
base_version: item.version,
|
||||
config: config.clone(),
|
||||
};
|
||||
let id = cred
|
||||
.file_edit_request(&caller, "replace", edit.clone())
|
||||
.unwrap();
|
||||
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cred.file_edit_request(&caller, "replace", edit).unwrap(),
|
||||
id
|
||||
);
|
||||
config.name = "new".into();
|
||||
let request = OperationRequest {
|
||||
item: None,
|
||||
base_version: None,
|
||||
change: vec![Operation::CreateItem { config }],
|
||||
};
|
||||
let id = cred
|
||||
.file_operation_request(&caller, "create", request.clone())
|
||||
.unwrap();
|
||||
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cred.file_operation_request(&caller, "create", request)
|
||||
.unwrap(),
|
||||
id
|
||||
);
|
||||
assert_eq!(cred.item_list().unwrap().len(), 2);
|
||||
}
|
||||
#[test]
|
||||
fn grant_defaults_normalize_and_pending_requests_replay_after_deletion() {
|
||||
let (_dir, cred, caller, item) = fixture();
|
||||
let payload = GrantRequest {
|
||||
items: vec![item.id.clone()],
|
||||
duration_secs: None,
|
||||
max_uses: None,
|
||||
validator: Some(String::new()),
|
||||
};
|
||||
let id = cred.file_request(&caller, "grant", payload).unwrap();
|
||||
let payload = GrantRequest {
|
||||
items: vec![item.id.clone()],
|
||||
duration_secs: Some(3600),
|
||||
max_uses: None,
|
||||
validator: None,
|
||||
};
|
||||
assert_eq!(
|
||||
cred.file_request(&caller, "grant", payload.clone())
|
||||
.unwrap(),
|
||||
id
|
||||
);
|
||||
let delete = cred
|
||||
.file_delete_request(
|
||||
&caller,
|
||||
"delete",
|
||||
Delete {
|
||||
item: item.id,
|
||||
base_version: item.version,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
cred.approve(&delete, "yes", Overrides::default(), Some("pk"))
|
||||
.unwrap();
|
||||
assert_eq!(cred.file_request(&caller, "grant", payload).unwrap(), id);
|
||||
assert!(
|
||||
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
cred.request_list()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|r| r.id == id)
|
||||
.unwrap()
|
||||
.state,
|
||||
"pending"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn values_are_checked_at_each_public_operation() {
|
||||
let (_dir, cred, caller, item) = fixture();
|
||||
let mut config = item.clone();
|
||||
config.fields.push(Field {
|
||||
field_id: String::new(),
|
||||
name: "public".into(),
|
||||
public: true,
|
||||
value: Some("old".into()),
|
||||
needs_value: false,
|
||||
field_type: FieldType::Text,
|
||||
key_type: None,
|
||||
});
|
||||
let id = cred
|
||||
.file_edit_request(
|
||||
&caller,
|
||||
"setup",
|
||||
Edit {
|
||||
item: item.id.clone(),
|
||||
base_version: item.version,
|
||||
config,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
|
||||
.unwrap();
|
||||
let current = cred.item_get(&item.id).unwrap();
|
||||
let field = current
|
||||
.fields
|
||||
.iter()
|
||||
.find(|f| f.name == "public")
|
||||
.unwrap()
|
||||
.field_id
|
||||
.clone();
|
||||
let mut request = OperationRequest {
|
||||
item: Some(item.id.clone()),
|
||||
base_version: Some(current.version),
|
||||
change: vec![
|
||||
Operation::SetFieldValue {
|
||||
field: field.clone(),
|
||||
value: "bad\nvalue".into(),
|
||||
},
|
||||
Operation::SetFieldType {
|
||||
field,
|
||||
field_type: FieldType::Note,
|
||||
key_type: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
assert_eq!(
|
||||
cred.file_operation_request(&caller, "invalid", request.clone())
|
||||
.err()
|
||||
.unwrap()
|
||||
.0,
|
||||
"invalid_value"
|
||||
);
|
||||
assert_eq!(cred.item_get(&item.id).unwrap().version, current.version);
|
||||
request.change.reverse();
|
||||
let id = cred
|
||||
.file_operation_request(&caller, "ordered", request)
|
||||
.unwrap();
|
||||
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cred.item_get(&item.id).unwrap().version,
|
||||
current.version + 1
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn secret_retyping_checks_each_operation_only_on_approval() {
|
||||
let (_dir, cred, caller, item) = fixture();
|
||||
let field = &item.fields[0].field_id;
|
||||
cred.set_field_input(
|
||||
&item.id,
|
||||
field,
|
||||
&FieldInput {
|
||||
value: "VALUE_SENTINEL\nTAIL_SENTINEL",
|
||||
field_type: Some(FieldType::Note),
|
||||
key_type: None,
|
||||
},
|
||||
&caller,
|
||||
"pk",
|
||||
)
|
||||
.unwrap();
|
||||
let current = cred.item_get(&item.id).unwrap();
|
||||
let request = OperationRequest {
|
||||
item: Some(item.id.clone()),
|
||||
base_version: Some(current.version),
|
||||
change: vec![
|
||||
Operation::SetFieldType {
|
||||
field: field.clone(),
|
||||
field_type: FieldType::Text,
|
||||
key_type: None,
|
||||
},
|
||||
Operation::SetFieldType {
|
||||
field: field.clone(),
|
||||
field_type: FieldType::Note,
|
||||
key_type: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
let id = cred
|
||||
.file_operation_request(&caller, "types", request)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!cred.request_list().unwrap()[0]
|
||||
.payload
|
||||
.to_string()
|
||||
.contains("VALUE_SENTINEL")
|
||||
);
|
||||
assert_eq!(
|
||||
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
|
||||
.err()
|
||||
.unwrap()
|
||||
.0,
|
||||
"invalid_value"
|
||||
);
|
||||
assert_eq!(cred.item_get(&item.id).unwrap().version, current.version);
|
||||
assert_eq!(cred.request_list().unwrap()[0].state, "pending");
|
||||
let history = cred.item_versions(&item.id).unwrap().len();
|
||||
cred.set_field(&item.id, field, "single-line", &caller, "pk")
|
||||
.unwrap();
|
||||
let next = cred.item_get(&item.id).unwrap();
|
||||
assert_eq!(next.version, current.version + 1);
|
||||
assert_eq!(cred.item_versions(&item.id).unwrap().len(), history + 1);
|
||||
cred.resolve_stale(&id, next.version).unwrap();
|
||||
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
|
||||
.unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn stale_rebases_keep_original_keyed_intent() {
|
||||
let (_dir, cred, caller, item) = fixture();
|
||||
let request = OperationRequest {
|
||||
item: Some(item.id.clone()),
|
||||
base_version: Some(item.version),
|
||||
change: vec![Operation::SetName {
|
||||
name: "renamed".into(),
|
||||
}],
|
||||
};
|
||||
let id = cred
|
||||
.file_operation_request(&caller, "rebase", request.clone())
|
||||
.unwrap();
|
||||
cred.set_field(&item.id, &item.fields[0].field_id, "opaque", &caller, "pk")
|
||||
.unwrap();
|
||||
let current = cred.item_get(&item.id).unwrap();
|
||||
cred.resolve_stale(&id, current.version).unwrap();
|
||||
assert_eq!(
|
||||
cred.file_operation_request(&caller, "rebase", request.clone())
|
||||
.unwrap(),
|
||||
id
|
||||
);
|
||||
cred.approve(&id, "yes", Overrides::default(), Some("pk"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cred.file_operation_request(&caller, "rebase", request)
|
||||
.unwrap(),
|
||||
id
|
||||
);
|
||||
assert!(!cred.item_get(&item.id).unwrap().fields[0].needs_value);
|
||||
}
|
||||
#[test]
|
||||
fn operation_order_is_part_of_key_identity() {
|
||||
let (_dir, cred, caller, item) = fixture();
|
||||
let mut request = OperationRequest {
|
||||
item: Some(item.id),
|
||||
base_version: Some(item.version),
|
||||
change: vec![
|
||||
Operation::SetName {
|
||||
name: "renamed".into(),
|
||||
},
|
||||
Operation::SetFreeUse { enabled: true },
|
||||
],
|
||||
};
|
||||
cred.file_operation_request(&caller, "ordered", request.clone())
|
||||
.unwrap();
|
||||
request.change.reverse();
|
||||
assert_eq!(
|
||||
cred.file_operation_request(&caller, "ordered", request)
|
||||
.err()
|
||||
.unwrap()
|
||||
.0,
|
||||
"key_conflict"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
//! RFC vectors and public codecs for pure signing.
|
||||
use base64::{
|
||||
Engine,
|
||||
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
|
||||
};
|
||||
use sausage::cred::sign::*;
|
||||
fn raw(key: &[u8], alg: &str, payload: &[u8]) -> Vec<u8> {
|
||||
sign(&STANDARD.encode(key), None, alg, payload, "raw", None, None)
|
||||
.unwrap()
|
||||
.bytes
|
||||
.unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn ed25519_readers_and_wire() {
|
||||
use ed25519_dalek::pkcs8::EncodePrivateKey;
|
||||
let key = ed25519_dalek::SigningKey::from_bytes(&[7; 32]);
|
||||
let pem = key.to_pkcs8_pem(Default::default()).unwrap();
|
||||
let jwk = serde_json::json!({"kty":"OKP","crv":"Ed25519","d":URL_SAFE_NO_PAD.encode(key.to_bytes()),"x":URL_SAFE_NO_PAD.encode(key.verifying_key().as_bytes())}).to_string();
|
||||
let ssh = ssh_key::PrivateKey::new(
|
||||
ssh_key::private::Ed25519Keypair::from(&key).into(),
|
||||
"synthetic",
|
||||
)
|
||||
.unwrap()
|
||||
.to_openssh(Default::default())
|
||||
.unwrap();
|
||||
let expected = raw(&key.to_bytes(), "ed25519", b"message");
|
||||
for value in [pem.as_str(), jwk.as_str(), ssh.as_str()] {
|
||||
let sig = sign(value, None, "ed25519", b"message", "ssh", None, None).unwrap();
|
||||
let wire = STANDARD.decode(sig.signature).unwrap();
|
||||
assert_eq!(&wire[..19], b"\x00\x00\x00\x0bssh-ed25519\x00\x00\x00\x40");
|
||||
assert_eq!(&wire[19..], expected);
|
||||
for output in ["pem", "jwk", "ssh", "hex", "base64"] {
|
||||
assert!(public_key(value, None, "ed25519", output).is_ok());
|
||||
}
|
||||
}
|
||||
assert!(public_key(&STANDARD.encode([7; 32]), None, "hmac-sha256", "hex").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc8032_and_4231() {
|
||||
let seed =
|
||||
hex::decode("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60").unwrap();
|
||||
assert_eq!(
|
||||
hex::encode(raw(&seed, "ed25519", b"")),
|
||||
concat!(
|
||||
"e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155",
|
||||
"5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"
|
||||
)
|
||||
);
|
||||
for (alg, expected) in [
|
||||
(
|
||||
"hmac-sha256",
|
||||
"b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7",
|
||||
),
|
||||
(
|
||||
"hmac-sha384",
|
||||
"afd03944d84895626b0825f4ab46907f15f9dadbe4101ec682aa034c7cebc59cfaea9ea9076ede7f4af152e8b2fa9cb6",
|
||||
),
|
||||
(
|
||||
"hmac-sha512",
|
||||
"87aa7cdea5ef619d4ff0b4241a1d6cb02379f4e2ce4ec2787ad0b30545e17cde daa833b7d6b8a702038b274eaea3f4e4be9d914eeb61f1702e696c203a126854",
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
hex::encode(raw(&[0x0b; 20], alg, b"Hi There")),
|
||||
expected.replace(' ', "")
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn ec_readers() {
|
||||
use p256::pkcs8::EncodePrivateKey;
|
||||
let secret = p256::SecretKey::from_slice(&[7; 32]).unwrap();
|
||||
let public = secret.public_key();
|
||||
use p256::elliptic_curve::sec1::ToEncodedPoint;
|
||||
let point = public.to_encoded_point(false);
|
||||
let jwk = serde_json::json!({"kty":"EC","crv":"P-256","d":URL_SAFE_NO_PAD.encode(secret.to_bytes()),"x":URL_SAFE_NO_PAD.encode(point.x().unwrap()),"y":URL_SAFE_NO_PAD.encode(point.y().unwrap())}).to_string();
|
||||
let pkcs8 = secret.to_pkcs8_pem(Default::default()).unwrap();
|
||||
let sec1 = secret.to_sec1_pem(Default::default()).unwrap();
|
||||
let expected = raw(&[7; 32], "es256", b"message");
|
||||
let ssh = ssh_key::PrivateKey::from(ssh_key::private::EcdsaKeypair::NistP256 {
|
||||
public: point,
|
||||
private: ssh_key::private::EcdsaPrivateKey::from(secret.clone()),
|
||||
})
|
||||
.to_openssh(Default::default())
|
||||
.unwrap();
|
||||
for value in [jwk.as_str(), pkcs8.as_str(), sec1.as_str(), ssh.as_str()] {
|
||||
assert_eq!(
|
||||
sign(value, None, "es256", b"message", "raw", None, None)
|
||||
.unwrap()
|
||||
.bytes
|
||||
.unwrap(),
|
||||
expected
|
||||
);
|
||||
let public = ssh_key::PublicKey::from_bytes(
|
||||
&STANDARD
|
||||
.decode(public_key(value, None, "es256", "ssh").unwrap())
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let wire = STANDARD
|
||||
.decode(
|
||||
sign(value, None, "es256", b"message", "ssh", None, None)
|
||||
.unwrap()
|
||||
.signature,
|
||||
)
|
||||
.unwrap();
|
||||
let signature = ssh_key::Signature::try_from(wire.as_slice()).unwrap();
|
||||
ed25519_dalek::Verifier::verify(&public, b"message", &signature).unwrap();
|
||||
}
|
||||
let mut bad: serde_json::Value = serde_json::from_str(&jwk).unwrap();
|
||||
bad["x"] = serde_json::json!(URL_SAFE_NO_PAD.encode([0; 32]));
|
||||
assert!(
|
||||
sign(
|
||||
&bad.to_string(),
|
||||
None,
|
||||
"es256",
|
||||
b"message",
|
||||
"raw",
|
||||
None,
|
||||
None
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc6979_and_formats() {
|
||||
let key =
|
||||
hex::decode("c9afa9d845ba75166b5c215767b1d6934e50c3db36e89b127b8a622b120f6721").unwrap();
|
||||
let expected = concat!(
|
||||
"efd48b2aacb6a8fd1140dd9cd45e81d69d2c877b56aaf991c34d0ea84eaf3716",
|
||||
"f7cb1c942d657c41d436c7a1b6e29f65f3e900dbb9aff4064dc4ab2f843acda8"
|
||||
);
|
||||
assert_eq!(hex::encode(raw(&key, "es256", b"sample")), expected);
|
||||
let key = STANDARD.encode(key);
|
||||
for output in ["hex", "base64", "jws", "ssh"] {
|
||||
let result = sign(&key, None, "es256", b"sample", output, None, None).unwrap();
|
||||
assert!(!result.signature.is_empty());
|
||||
}
|
||||
for output in ["hex", "base64", "pem", "jwk", "ssh"] {
|
||||
assert!(!public_key(&key, None, "es256", output).unwrap().is_empty());
|
||||
}
|
||||
let pem = generate_es256().unwrap();
|
||||
assert!(public_key(&pem, None, "es256", "jwk").is_ok());
|
||||
for bad in [vec![], vec![1; 31], vec![0; 32], vec![255; 32]] {
|
||||
assert!(sign(&STANDARD.encode(bad), None, "es256", b"", "raw", None, None).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn rsa_readers_and_ssh_wire() {
|
||||
use rsa::{
|
||||
pkcs1::EncodeRsaPrivateKey,
|
||||
pkcs8::EncodePrivateKey,
|
||||
traits::{PrivateKeyParts, PublicKeyParts},
|
||||
};
|
||||
use sha2::{Digest, Sha256, Sha512};
|
||||
let key = rsa::RsaPrivateKey::new(&mut rsa::rand_core::OsRng, 1024).unwrap();
|
||||
let pem = key.to_pkcs8_pem(Default::default()).unwrap();
|
||||
let pkcs1 = key.to_pkcs1_pem(Default::default()).unwrap();
|
||||
let ssh = ssh_key::PrivateKey::new(
|
||||
ssh_key::private::RsaKeypair::try_from(&key).unwrap().into(),
|
||||
"synthetic",
|
||||
)
|
||||
.unwrap();
|
||||
let openssh = ssh.to_openssh(Default::default()).unwrap();
|
||||
let encrypted = ssh
|
||||
.encrypt(&mut rsa::rand_core::OsRng, "synthetic")
|
||||
.unwrap()
|
||||
.to_openssh(Default::default())
|
||||
.unwrap();
|
||||
let jwk = serde_json::json!({"kty":"RSA", "n":URL_SAFE_NO_PAD.encode(key.n().to_bytes_be()), "e":URL_SAFE_NO_PAD.encode(key.e().to_bytes_be()), "d":URL_SAFE_NO_PAD.encode(key.d().to_bytes_be()), "p":URL_SAFE_NO_PAD.encode(key.primes()[0].to_bytes_be()), "q":URL_SAFE_NO_PAD.encode(key.primes()[1].to_bytes_be())}).to_string();
|
||||
for (value, pass) in [
|
||||
(pem.as_str(), None),
|
||||
(pkcs1.as_str(), None),
|
||||
(openssh.as_str(), None),
|
||||
(encrypted.as_str(), Some("synthetic")),
|
||||
(jwk.as_str(), None),
|
||||
] {
|
||||
for (alg, hash) in [
|
||||
("rs256", "sha256"),
|
||||
("rsa-sha2-256", "sha256"),
|
||||
("rsa-sha2-512", "sha512"),
|
||||
] {
|
||||
let result = sign(
|
||||
value,
|
||||
pass,
|
||||
alg,
|
||||
b"message",
|
||||
"raw",
|
||||
Some("pkcs1v15"),
|
||||
Some(hash),
|
||||
)
|
||||
.unwrap();
|
||||
let sig = result.bytes.unwrap();
|
||||
if hash == "sha512" {
|
||||
key.to_public_key()
|
||||
.verify(
|
||||
rsa::Pkcs1v15Sign::new::<Sha512>(),
|
||||
&Sha512::digest(b"message"),
|
||||
&sig,
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
key.to_public_key()
|
||||
.verify(
|
||||
rsa::Pkcs1v15Sign::new::<Sha256>(),
|
||||
&Sha256::digest(b"message"),
|
||||
&sig,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let wire = STANDARD
|
||||
.decode(
|
||||
sign(
|
||||
value,
|
||||
pass,
|
||||
alg,
|
||||
b"message",
|
||||
"ssh",
|
||||
Some("pkcs1v15"),
|
||||
Some(hash),
|
||||
)
|
||||
.unwrap()
|
||||
.signature,
|
||||
)
|
||||
.unwrap();
|
||||
let len = u32::from_be_bytes(wire[..4].try_into().unwrap()) as usize;
|
||||
assert_eq!(
|
||||
&wire[4..4 + len],
|
||||
if hash == "sha512" {
|
||||
b"rsa-sha2-512"
|
||||
} else {
|
||||
b"rsa-sha2-256"
|
||||
}
|
||||
);
|
||||
assert_eq!(&wire[8 + len..], sig);
|
||||
}
|
||||
for output in ["pem", "jwk", "ssh", "base64", "hex"] {
|
||||
assert!(public_key(value, pass, "rs256", output).is_ok());
|
||||
}
|
||||
}
|
||||
assert!(sign(&pem, None, "rs256", b"", "raw", None, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_and_mismatched_keys() {
|
||||
for value in [
|
||||
"not a key",
|
||||
"{broken",
|
||||
"{\"kty\":\"unknown\"}",
|
||||
"-----BEGIN UNKNOWN PRIVATE KEY-----\nAAAA\n-----END UNKNOWN PRIVATE KEY-----",
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\ninvalid",
|
||||
"AAAA",
|
||||
] {
|
||||
assert!(sign(value, None, "ed25519", b"", "raw", None, None).is_err());
|
||||
}
|
||||
let bad=serde_json::json!({"kty":"OKP","crv":"Ed25519","d":URL_SAFE_NO_PAD.encode([1;32]),"x":URL_SAFE_NO_PAD.encode([2;32])}).to_string();
|
||||
assert!(sign(&bad, None, "ed25519", b"", "raw", None, None).is_err());
|
||||
let oct = serde_json::json!({"kty":"oct","k":URL_SAFE_NO_PAD.encode([11;20])}).to_string();
|
||||
assert!(sign(&oct, None, "hmac-sha384", b"Hi There", "raw", None, None).is_ok());
|
||||
assert!(sign(&oct, None, "ed25519", b"", "raw", None, None).is_err());
|
||||
assert!(sign(&oct, None, "hmac-sha384", b"", "ssh", None, None).is_err());
|
||||
assert!(sign(&oct, None, "hmac-sha384", b"", "unknown", None, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc8292() {
|
||||
use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
|
||||
let public=URL_SAFE_NO_PAD.decode("BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs").unwrap();
|
||||
let signature=URL_SAFE_NO_PAD.decode("i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA").unwrap();
|
||||
let input = concat!(
|
||||
"eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.",
|
||||
"eyJhdWQiOiJodHRwczovL3B1c2guZXhhbXBsZS5uZXQiLCJleHAiOjE0NTM1MjM3NjgsInN1YiI6Im1haWx0bzpwdXNoQGV4YW1wbGUuY29tIn0"
|
||||
);
|
||||
VerifyingKey::from_sec1_bytes(&public)
|
||||
.unwrap()
|
||||
.verify(
|
||||
input.as_bytes(),
|
||||
&Signature::from_slice(&signature).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Public approval text and template presets from v2, without vault migrations.
|
||||
use sausage::cred::{
|
||||
edits::Operation,
|
||||
items::{Field, FieldInput},
|
||||
summary::*,
|
||||
types::FieldType,
|
||||
};
|
||||
#[test]
|
||||
fn durations_use_plain_language() {
|
||||
for (seconds, expected) in [
|
||||
(0, "0 seconds"),
|
||||
(1, "1 second"),
|
||||
(60, "1 minute"),
|
||||
(3600, "1 hour"),
|
||||
(93604, "1 day 2 hours 4 seconds"),
|
||||
] {
|
||||
assert_eq!(human_duration(seconds), expected);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn summaries_never_include_field_values_or_script_sources() {
|
||||
let value = "SECRET_SUMMARY_SENTINEL";
|
||||
for operation in [
|
||||
Operation::SetFieldValue {
|
||||
field: "id".into(),
|
||||
value: value.into(),
|
||||
},
|
||||
Operation::SetTransformJs {
|
||||
value: value.into(),
|
||||
},
|
||||
Operation::AddField {
|
||||
field: Field {
|
||||
field_id: String::new(),
|
||||
name: "token".into(),
|
||||
public: false,
|
||||
value: Some(value.into()),
|
||||
needs_value: true,
|
||||
field_type: FieldType::Text,
|
||||
key_type: None,
|
||||
},
|
||||
},
|
||||
] {
|
||||
assert!(!operation_summary(&operation).contains(value));
|
||||
}
|
||||
let input = FieldInput {
|
||||
value,
|
||||
field_type: None,
|
||||
key_type: None,
|
||||
};
|
||||
assert!(!format!("{input:?}").contains(value));
|
||||
}
|
||||
#[test]
|
||||
fn summary_names_are_inert_and_stay_on_one_line() {
|
||||
let text = operation_summary(&Operation::SetName {
|
||||
name: "<x>&*\nnext".into(),
|
||||
});
|
||||
assert!(!text.contains('<'));
|
||||
assert!(!text.contains('\n'));
|
||||
assert!(text.contains("<x>&\\* next"));
|
||||
}
|
||||
#[test]
|
||||
fn presets_preserve_headers_and_utf8_basic_encoding() {
|
||||
assert!(
|
||||
script("bearer")
|
||||
.unwrap()
|
||||
.contains("'Bearer ' + get('token')")
|
||||
);
|
||||
assert!(
|
||||
script("basic")
|
||||
.unwrap()
|
||||
.contains("unescape(encodeURIComponent")
|
||||
);
|
||||
assert!(
|
||||
script("header:x-api-key")
|
||||
.unwrap()
|
||||
.contains("const name = \"x-api-key\"")
|
||||
);
|
||||
assert!(
|
||||
script("query:access_token")
|
||||
.unwrap()
|
||||
.contains("url.query.filter")
|
||||
);
|
||||
assert_eq!(script("unknown").unwrap_err().0, "invalid_preset");
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//! Behavioral contract for the credential-only isolate.
|
||||
use sausage::cred::{
|
||||
script::{ErrorKind, Limits, Script},
|
||||
v8::V8,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
fn run(s: &str) -> Result<String, sausage::cred::script::Error> {
|
||||
V8.evaluate(s, "null", None, Limits::default())
|
||||
}
|
||||
#[test]
|
||||
fn modules_promises_and_json() {
|
||||
assert_eq!(
|
||||
V8.evaluate(
|
||||
"JSON.parse=()=>false; export default x=>x",
|
||||
"42",
|
||||
None,
|
||||
Limits::default()
|
||||
)
|
||||
.unwrap(),
|
||||
"42"
|
||||
);
|
||||
let error = run("export default ()=>{throw Error('DO NOT ECHO')}").unwrap_err();
|
||||
assert!(!error.message.contains("DO NOT ECHO"));
|
||||
assert_eq!(
|
||||
V8.evaluate(
|
||||
"export default x=>({value:x}); export const other=x=>x+1",
|
||||
"42",
|
||||
None,
|
||||
Limits::default()
|
||||
)
|
||||
.unwrap(),
|
||||
"{\"value\":42}"
|
||||
);
|
||||
assert_eq!(
|
||||
V8.evaluate(
|
||||
"export const other=x=>x+1",
|
||||
"42",
|
||||
Some("other"),
|
||||
Limits::default()
|
||||
)
|
||||
.unwrap(),
|
||||
"43"
|
||||
);
|
||||
assert_eq!(
|
||||
run("await Promise.resolve(); export default async x=>{await Promise.resolve();return 42}")
|
||||
.unwrap(),
|
||||
"42"
|
||||
);
|
||||
for (s, k) in [
|
||||
("export default (", ErrorKind::Syntax),
|
||||
("export default ()=>{throw Error('oops')}", ErrorKind::Throw),
|
||||
("export default ()=>Promise.reject('no')", ErrorKind::Throw),
|
||||
("export default ()=>undefined", ErrorKind::NonJson),
|
||||
("export default ()=>Symbol()", ErrorKind::NonJson),
|
||||
("export default ()=>()=>1", ErrorKind::NonJson),
|
||||
(
|
||||
"export default ()=>{let a={};a.a=a;return a}",
|
||||
ErrorKind::NonJson,
|
||||
),
|
||||
("export const x=1", ErrorKind::Engine),
|
||||
("export default 1", ErrorKind::Engine),
|
||||
("export default ()=>new Promise(()=>{})", ErrorKind::Engine),
|
||||
] {
|
||||
assert_eq!(run(s).unwrap_err().kind, k, "{s}");
|
||||
}
|
||||
assert_eq!(
|
||||
V8.evaluate("export default x=>x", "invalid", None, Limits::default())
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ErrorKind::Syntax
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn sandbox_and_isolation() {
|
||||
for name in [
|
||||
"Date",
|
||||
"Math.random",
|
||||
"fetch",
|
||||
"setTimeout",
|
||||
"setInterval",
|
||||
"WebSocket",
|
||||
"XMLHttpRequest",
|
||||
"std",
|
||||
"os",
|
||||
"fs",
|
||||
"Deno",
|
||||
"__bootstrap",
|
||||
"console",
|
||||
"performance",
|
||||
"require",
|
||||
"process",
|
||||
"TextEncoder",
|
||||
"Buffer",
|
||||
"Blob",
|
||||
"File",
|
||||
"crypto",
|
||||
] {
|
||||
assert_eq!(
|
||||
run(&format!("export default ()=>typeof {name}")).unwrap(),
|
||||
"\"undefined\"",
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
assert!(run("import x from 'fs'; export default ()=>x").is_err());
|
||||
assert!(run("import {core} from 'ext:core/mod.js'; export default ()=>typeof core").is_err());
|
||||
assert_eq!(
|
||||
run("export default ()=>import('fs')").unwrap_err().kind,
|
||||
ErrorKind::Throw
|
||||
);
|
||||
run("export default ()=>{globalThis.leak=1;btoa.leak=1;return true}").unwrap();
|
||||
assert_eq!(
|
||||
run("export default ()=>[typeof leak,typeof btoa.leak]").unwrap(),
|
||||
"[\"undefined\",\"undefined\"]"
|
||||
);
|
||||
for (expr, expected) in [
|
||||
("btoa('user:pass')", "\"dXNlcjpwYXNz\""),
|
||||
("atob('dXNlcjpwYXNz')", "\"user:pass\""),
|
||||
("btoa('')", "\"\""),
|
||||
("atob('AP+A') === '\\x00\\xff\\x80'", "true"),
|
||||
] {
|
||||
assert_eq!(
|
||||
run(&format!("export default ()=>{expr}")).unwrap(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
for expr in ["atob('!')", "btoa('雪')"] {
|
||||
assert_eq!(
|
||||
run(&format!("export default ()=>{expr}")).unwrap_err().kind,
|
||||
ErrorKind::Throw
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn independent_deadlines() {
|
||||
// Exclude the process-wide platform initialization from the overlap check.
|
||||
assert_eq!(run("export default ()=>true").unwrap(), "true");
|
||||
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
|
||||
let other = barrier.clone();
|
||||
let runaway = std::thread::spawn(move || {
|
||||
other.wait();
|
||||
assert_eq!(
|
||||
V8.evaluate(
|
||||
"export default ()=>{while(true){}}",
|
||||
"null",
|
||||
None,
|
||||
Limits {
|
||||
max_time_ms: Some(1000),
|
||||
..Limits::default()
|
||||
}
|
||||
)
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ErrorKind::Limit
|
||||
);
|
||||
});
|
||||
barrier.wait();
|
||||
// The quick call keeps its default 100 ms execution deadline.
|
||||
assert_eq!(run("export default ()=>true").unwrap(), "true");
|
||||
assert!(
|
||||
!runaway.is_finished(),
|
||||
"the quick call must finish before the runaway call"
|
||||
);
|
||||
runaway.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamped_limits() {
|
||||
for limits in [
|
||||
Limits {
|
||||
max_time_ms: Some(0),
|
||||
max_memory_bytes: Some(0),
|
||||
},
|
||||
Limits {
|
||||
max_time_ms: Some(u64::MAX),
|
||||
max_memory_bytes: Some(usize::MAX),
|
||||
},
|
||||
] {
|
||||
match V8.evaluate("export default ()=>true", "null", None, limits) {
|
||||
Ok(value) => assert_eq!(value, "true"),
|
||||
Err(error) => assert_eq!(error.kind, ErrorKind::Limit),
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
V8.evaluate(
|
||||
"export default ()=>{let a=[];while(1)a.push('abcdefghijklmnopqrstuvwxyz'+a.length)}",
|
||||
"null",
|
||||
None,
|
||||
Limits {
|
||||
max_time_ms: Some(1000),
|
||||
max_memory_bytes: Some(1024 * 1024)
|
||||
}
|
||||
)
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ErrorKind::Limit
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_and_parallel_calls() {
|
||||
let mut workers = Vec::new();
|
||||
for s in [
|
||||
"export default ()=>{while(true){}}",
|
||||
"while(true){};export default x=>x",
|
||||
"export default ()=>({toJSON(){while(true){}}})",
|
||||
"export default ()=>{throw {get message(){while(true){}}}}",
|
||||
"export default ()=>{function f(){return f()+1}return f()}",
|
||||
"export default ()=>new Promise(()=>{function f(){Promise.resolve().then(f)}f()})",
|
||||
"export default ()=>{let a=[];while(1)a.push('abcdefghijklmnopqrstuvwxyz'+a.length)}",
|
||||
] {
|
||||
workers.push(std::thread::spawn(move || {
|
||||
let start = Instant::now();
|
||||
assert_eq!(run(s).unwrap_err().kind, ErrorKind::Limit);
|
||||
assert!(start.elapsed() < Duration::from_secs(2));
|
||||
}));
|
||||
}
|
||||
let normal = std::thread::spawn(|| assert_eq!(run("export default ()=>true").unwrap(), "true"));
|
||||
for worker in workers {
|
||||
worker.join().unwrap();
|
||||
}
|
||||
normal.join().unwrap();
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Software-authenticator coverage for enrollment gates and bound assertions.
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use sausage::cred::webauthn::{Binding, Gate, Passkeys};
|
||||
use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};
|
||||
use webauthn_rs::prelude::*;
|
||||
|
||||
fn binding() -> Binding {
|
||||
Binding::Decision {
|
||||
request_id: "request-1".into(),
|
||||
decision: "approve".into(),
|
||||
overrides: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enrollment_and_assertions() -> anyhow::Result<()> {
|
||||
let db = rusqlite::Connection::open_in_memory()?;
|
||||
let origin = Url::parse("http://localhost:8000")?;
|
||||
let mut host = Passkeys::new(&db, "localhost", origin.as_str())?;
|
||||
let mut auth = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
||||
assert!(host.is_unlocked());
|
||||
let start = host.start_registration("first".into(), None)?;
|
||||
let racing = host.start_registration("racing".into(), None)?;
|
||||
let response = auth.do_registration(origin.clone(), start.options)?;
|
||||
host.finish_registration(&start.id, response)?;
|
||||
assert!(!host.is_unlocked());
|
||||
assert!(host.start_registration("second".into(), None).is_err());
|
||||
let response = auth.do_registration(origin.clone(), racing.options)?;
|
||||
assert!(host.finish_registration(&racing.id, response).is_err());
|
||||
let gate = host.start_assertion(Binding::Enrollment {
|
||||
label: "second".into(),
|
||||
})?;
|
||||
let response = auth.do_authentication(origin.clone(), gate.options)?;
|
||||
let start = host.start_registration(
|
||||
"second".into(),
|
||||
Some(Gate {
|
||||
id: gate.id,
|
||||
response,
|
||||
}),
|
||||
)?;
|
||||
let response = auth.do_registration(origin.clone(), start.options)?;
|
||||
host.finish_registration(&start.id, response)?;
|
||||
|
||||
let start = host.start_assertion(binding())?;
|
||||
let response = auth.do_authentication(origin.clone(), start.options)?;
|
||||
let replay = serde_json::to_value(&response)?;
|
||||
assert_eq!(host.finish_assertion(&start.id, response)?.1, binding());
|
||||
assert!(
|
||||
host.finish_assertion(&start.id, serde_json::from_value(replay)?)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let gate = host.start_assertion(binding())?;
|
||||
let response = auth.do_authentication(origin.clone(), gate.options)?;
|
||||
assert!(
|
||||
host.start_registration(
|
||||
"unauthorized".into(),
|
||||
Some(Gate {
|
||||
id: gate.id,
|
||||
response
|
||||
})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
drop(host);
|
||||
let mut host = Passkeys::new(&db, "localhost", origin.as_str())?;
|
||||
let older = host.start_assertion(binding())?;
|
||||
let newer = host.start_assertion(binding())?;
|
||||
let old_response = auth.do_authentication(origin.clone(), older.options)?;
|
||||
let new_response = auth.do_authentication(origin.clone(), newer.options)?;
|
||||
host.finish_assertion(&newer.id, new_response)?;
|
||||
assert!(host.finish_assertion(&older.id, old_response).is_err());
|
||||
|
||||
// Sign a different, valid random challenge with the same credential.
|
||||
let a = host.start_assertion(binding())?;
|
||||
let b = host.start_assertion(binding())?;
|
||||
let response = auth.do_authentication(origin.clone(), b.options)?;
|
||||
assert!(host.finish_assertion(&a.id, response).is_err());
|
||||
|
||||
let a = host.start_assertion(binding())?;
|
||||
let response = auth.do_authentication(Url::parse("http://localhost:8001")?, a.options)?;
|
||||
assert!(host.finish_assertion(&a.id, response).is_err());
|
||||
|
||||
let mut a = host.start_assertion(binding())?;
|
||||
a.options.public_key.user_verification =
|
||||
serde_json::from_value(serde_json::json!("discouraged"))?;
|
||||
let response = auth.do_authentication(origin.clone(), a.options)?;
|
||||
assert!(host.finish_assertion(&a.id, response).is_err());
|
||||
|
||||
for field in ["signature", "authenticatorData"] {
|
||||
let a = host.start_assertion(binding())?;
|
||||
let response = auth.do_authentication(origin.clone(), a.options)?;
|
||||
let mut json = serde_json::to_value(response)?;
|
||||
let mut bytes = URL_SAFE_NO_PAD.decode(json["response"][field].as_str().unwrap())?;
|
||||
let index = if field == "signature" {
|
||||
bytes.len() - 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
bytes[index] ^= 1; // Preserve signature encoding, or change rpIdHash.
|
||||
json["response"][field] = URL_SAFE_NO_PAD.encode(bytes).into();
|
||||
assert!(
|
||||
host.finish_assertion(&a.id, serde_json::from_value(json)?)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let foreign_db = rusqlite::Connection::open_in_memory()?;
|
||||
let mut foreign = Passkeys::new(&foreign_db, "localhost", origin.as_str())?;
|
||||
let mut foreign_auth = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
||||
let registration = foreign.start_registration("foreign".into(), None)?;
|
||||
let response = foreign_auth.do_registration(origin.clone(), registration.options)?;
|
||||
let credential = foreign.finish_registration(®istration.id, response)?;
|
||||
let mut a = host.start_assertion(binding())?;
|
||||
a.options.public_key.allow_credentials[0].id = credential.cred_id().to_vec().into();
|
||||
let response = foreign_auth.do_authentication(origin.clone(), a.options)?;
|
||||
assert!(host.finish_assertion(&a.id, response).is_err());
|
||||
|
||||
let a = host.start_assertion(binding())?;
|
||||
let response = auth.do_authentication(origin, a.options)?;
|
||||
db.execute("DELETE FROM passkey_credentials", [])?;
|
||||
assert!(host.finish_assertion(&a.id, response).is_err());
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
//! Credential decisions wake an idle faux-model conversation through durable tasks.
|
||||
use sausage::{
|
||||
cred::{
|
||||
items::Item,
|
||||
requests::{GrantRequest, Overrides},
|
||||
store::Cred,
|
||||
types::Caller,
|
||||
},
|
||||
harness::{run_module, run_module_with_credentials},
|
||||
world::World,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn restart_child() -> anyhow::Result<()> {
|
||||
let Ok(dir) = std::env::var("DURABLE_RESTART_DIR") else {
|
||||
return Ok(());
|
||||
};
|
||||
let dir = std::path::Path::new(&dir);
|
||||
let source = format!(
|
||||
"globalThis.expectedState = 'approved';\n{}",
|
||||
include_str!("../js/durable-test-bundle.js")
|
||||
);
|
||||
run_module(
|
||||
World::open(dir)?,
|
||||
sausage::process::exec(World::open(dir)?)?,
|
||||
&source,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sigkill_preserves_request_wait() -> anyhow::Result<()> {
|
||||
use std::{
|
||||
process::Command,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
let dir = std::env::temp_dir().join(format!("sausage-durable-restart-{}", std::process::id()));
|
||||
let world = World::open(&dir)?;
|
||||
let cred = Cred::open(&world.host_dir)?;
|
||||
let admin = Caller {
|
||||
pid: 0,
|
||||
program: "test".into(),
|
||||
conversations: vec![],
|
||||
};
|
||||
let item = cred.create_item(
|
||||
Item {
|
||||
id: String::new(),
|
||||
name: "github".into(),
|
||||
version: 0,
|
||||
free_use: false,
|
||||
rules: vec![],
|
||||
validate_js: None,
|
||||
transform_js: "export default (ctx) => ctx.request".into(),
|
||||
fields: vec![],
|
||||
},
|
||||
&admin,
|
||||
"test",
|
||||
)?;
|
||||
let mut child = Command::new(std::env::current_exe()?)
|
||||
.args(["--exact", "restart_child", "--nocapture"])
|
||||
.env("DURABLE_RESTART_DIR", &dir)
|
||||
.spawn()?;
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
let conversation = loop {
|
||||
if let Ok(id) = std::fs::read_to_string(dir.join("world/conversation"))
|
||||
&& !id.is_empty()
|
||||
{
|
||||
break id;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
child.kill()?;
|
||||
child.wait()?;
|
||||
anyhow::bail!("Conversation did not start");
|
||||
}
|
||||
std::thread::yield_now();
|
||||
};
|
||||
let caller = Caller {
|
||||
conversations: vec![conversation],
|
||||
..admin
|
||||
};
|
||||
let id = cred.file_request(
|
||||
&caller,
|
||||
"restart",
|
||||
GrantRequest {
|
||||
items: vec![item.id],
|
||||
duration_secs: None,
|
||||
max_uses: None,
|
||||
validator: None,
|
||||
},
|
||||
)?;
|
||||
let db = rusqlite::Connection::open(dir.join("host/harness.sqlite"))?;
|
||||
loop {
|
||||
let count: i64 = db.query_row(
|
||||
"SELECT count(*) FROM tasks WHERE json_extract(kind, '$')='sausage.credential.wait'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if count == 1 {
|
||||
break;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
child.kill()?;
|
||||
child.wait()?;
|
||||
anyhow::bail!("Durable wait did not start");
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
child.kill()?;
|
||||
child.wait()?;
|
||||
cred.approve(&id, "yes", Overrides::default(), Some("test"))?;
|
||||
for _ in 0..2 {
|
||||
let mut resumed = Command::new(std::env::current_exe()?)
|
||||
.args(["--exact", "restart_child", "--nocapture"])
|
||||
.env("DURABLE_RESTART_DIR", &dir)
|
||||
.spawn()?;
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
if let Some(status) = resumed.try_wait()? {
|
||||
assert!(status.success());
|
||||
break;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
resumed.kill()?;
|
||||
resumed.wait()?;
|
||||
anyhow::bail!("Restart did not finish");
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
}
|
||||
drop(db);
|
||||
std::fs::remove_dir_all(dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn proxy_child() -> anyhow::Result<()> {
|
||||
let Ok(dir) = std::env::var("DURABLE_PROXY_DIR") else {
|
||||
return Ok(());
|
||||
};
|
||||
let dir = std::path::Path::new(&dir);
|
||||
let source = format!(
|
||||
"globalThis.proxyOnly = true; const proxy = Deno.core.ops.op_model_proxy(); if (proxy.base_url !== 'https://proxy.invalid' || proxy.api_key !== 'faux-private-token') throw new Error('Wrong proxy item');\n{}",
|
||||
include_str!("../js/durable-test-bundle.js")
|
||||
);
|
||||
run_module(
|
||||
World::open(dir)?,
|
||||
sausage::process::exec(World::open(dir)?)?,
|
||||
&source,
|
||||
)
|
||||
.await?;
|
||||
let error = run_module(
|
||||
World::open(dir)?,
|
||||
sausage::process::exec(World::open(dir)?)?,
|
||||
"throw new Error(Deno.core.ops.op_model_proxy().api_key)",
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(!error.to_string().contains("faux-private-token"));
|
||||
assert!(error.to_string().contains("[redacted]"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_bootstrap_once() -> anyhow::Result<()> {
|
||||
let dir = std::env::temp_dir().join(format!("sausage-durable-proxy-{}", std::process::id()));
|
||||
for start in 0..2 {
|
||||
let status = std::process::Command::new(std::env::current_exe()?)
|
||||
.args(["--exact", "proxy_child", "--nocapture"])
|
||||
.env("DURABLE_PROXY_DIR", &dir)
|
||||
.env(
|
||||
"PI_PROXY_BASE_URL",
|
||||
if start == 0 {
|
||||
"https://proxy.invalid"
|
||||
} else {
|
||||
"https://ignored.invalid"
|
||||
},
|
||||
)
|
||||
.env(
|
||||
"PI_PROXY_API_KEY",
|
||||
if start == 0 {
|
||||
"faux-private-token"
|
||||
} else {
|
||||
"ignored-token"
|
||||
},
|
||||
)
|
||||
.status()?;
|
||||
assert!(status.success());
|
||||
}
|
||||
let cred = Cred::open(&dir.join("host"))?;
|
||||
assert_eq!(
|
||||
cred.item_list()?
|
||||
.iter()
|
||||
.filter(|i| i.name == "llm-proxy")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
for file in [
|
||||
"host/cred.sqlite",
|
||||
"host/cred.sqlite-wal",
|
||||
"host/harness.sqlite",
|
||||
"host/harness.sqlite-wal",
|
||||
] {
|
||||
if let Ok(bytes) = std::fs::read(dir.join(file)) {
|
||||
assert!(
|
||||
!bytes
|
||||
.windows(b"faux-private-token".len())
|
||||
.any(|w| w == b"faux-private-token")
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(!dir.join("world/conversation").exists());
|
||||
drop(cred);
|
||||
std::fs::remove_dir_all(dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn decisions_wake_idle_conversation() -> anyhow::Result<()> {
|
||||
for state in ["approved", "denied", "expired"] {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("sausage-durable-{}-{state}", std::process::id()));
|
||||
let world = World::open(&dir)?;
|
||||
let cred = Arc::new(Cred::open(&world.host_dir)?);
|
||||
let admin = Caller {
|
||||
pid: 0,
|
||||
program: "test".into(),
|
||||
conversations: vec![],
|
||||
};
|
||||
let item = cred.create_item(
|
||||
Item {
|
||||
id: String::new(),
|
||||
name: "github".into(),
|
||||
version: 0,
|
||||
free_use: false,
|
||||
rules: vec![],
|
||||
validate_js: None,
|
||||
transform_js: "export default (ctx) => ctx.request".into(),
|
||||
fields: vec![],
|
||||
},
|
||||
&admin,
|
||||
"test",
|
||||
)?;
|
||||
cred.file_request(
|
||||
&admin,
|
||||
"program",
|
||||
GrantRequest {
|
||||
items: vec![item.id.clone()],
|
||||
duration_secs: None,
|
||||
max_uses: None,
|
||||
validator: None,
|
||||
},
|
||||
)?;
|
||||
let source = format!(
|
||||
"globalThis.expectedState = {state:?};\n{}",
|
||||
include_str!("../js/durable-test-bundle.js")
|
||||
);
|
||||
let decide = async {
|
||||
let conversation = loop {
|
||||
if let Ok(id) = std::fs::read_to_string(dir.join("world/conversation"))
|
||||
&& !id.is_empty()
|
||||
{
|
||||
break id;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
};
|
||||
let caller = Caller {
|
||||
conversations: vec![conversation],
|
||||
..admin
|
||||
};
|
||||
let id = cred.file_request(
|
||||
&caller,
|
||||
"test",
|
||||
GrantRequest {
|
||||
items: vec![item.id],
|
||||
duration_secs: None,
|
||||
max_uses: None,
|
||||
validator: None,
|
||||
},
|
||||
)?;
|
||||
let harness_db = rusqlite::Connection::open(dir.join("host/harness.sqlite"))?;
|
||||
loop {
|
||||
let count: i64 = harness_db.query_row(
|
||||
"SELECT count(*) FROM tasks WHERE json_extract(kind, '$')='sausage.credential.wait'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if count == 1 {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
match state {
|
||||
"approved" => cred.approve(&id, "yes", Overrides::default(), Some("test"))?,
|
||||
"denied" => cred.deny(&id, "no", Some("test"))?,
|
||||
_ => {
|
||||
let db = rusqlite::Connection::open(dir.join("host/cred.sqlite"))?;
|
||||
db.execute("UPDATE requests SET expires=0 WHERE id=?", [&id])?;
|
||||
}
|
||||
}
|
||||
Ok::<_, anyhow::Error>(())
|
||||
};
|
||||
let exec = sausage::process::exec(World::open(&dir)?)?;
|
||||
tokio::time::timeout(std::time::Duration::from_secs(20), async {
|
||||
let (a, b) = tokio::join!(
|
||||
run_module_with_credentials(world, Arc::clone(&exec), cred.clone(), &source),
|
||||
decide
|
||||
);
|
||||
a?;
|
||||
b?;
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.await??;
|
||||
let db = rusqlite::Connection::open(dir.join("host/harness.sqlite"))?;
|
||||
let tasks: i64 = db.query_row(
|
||||
"SELECT count(*) FROM tasks WHERE json_extract(kind, '$')='sausage.credential.wait'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
assert_eq!(tasks, 1, "Program requests must create no durable task");
|
||||
drop(db);
|
||||
std::fs::remove_dir_all(dir)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+114
-8
@@ -1,13 +1,13 @@
|
||||
//! P1 integration: a read-only bundle and real shell calls from the durable harness.
|
||||
//! P1 integration: independent bundle copies and real shell calls from the durable harness.
|
||||
use sausage::{harness::run_module, world::World};
|
||||
use sausage_vfs::Open;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn bundle_mount_and_real_shell() -> anyhow::Result<()> {
|
||||
async fn bundle_copy_and_real_shell() -> anyhow::Result<()> {
|
||||
let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let bundle = repo.join("target/userland/bin");
|
||||
if !bundle.join("sh").exists() {
|
||||
let bundle = repo.join("target/userland/bundle");
|
||||
if !bundle.join("bin/sh.wasm").exists() {
|
||||
assert!(
|
||||
std::process::Command::new("nu")
|
||||
.env_remove("CARGO_TARGET_DIR")
|
||||
@@ -18,9 +18,16 @@ async fn bundle_mount_and_real_shell() -> anyhow::Result<()> {
|
||||
}
|
||||
let dir = tempfile::tempdir()?;
|
||||
let world = World::open_with_userland(dir.path(), Some(&bundle))?;
|
||||
assert!(world.root.stat("/bin/sh", true).await?.size > 0);
|
||||
assert!(world.root.stat("/bin/sh.wasm", true).await?.size > 0);
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
for entry in std::fs::read_dir(dir.path().join("world/bin"))? {
|
||||
let entry = entry?;
|
||||
assert_eq!(entry.path().extension().unwrap(), "wasm");
|
||||
assert_eq!(entry.metadata()?.nlink(), 1);
|
||||
assert!(!entry.file_type()?.is_symlink());
|
||||
}
|
||||
assert!(world.root.stat("/usr/local/bin", true).await?.directory);
|
||||
let denied = world
|
||||
let writable = world
|
||||
.root
|
||||
.open(
|
||||
"/bin/forbidden",
|
||||
@@ -31,17 +38,72 @@ async fn bundle_mount_and_real_shell() -> anyhow::Result<()> {
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(denied, Err(e) if e.kind() == std::io::ErrorKind::ReadOnlyFilesystem));
|
||||
assert!(writable.is_ok());
|
||||
let exec = sausage::process::exec(World {
|
||||
root: world.root.clone(),
|
||||
host_dir: world.host_dir.clone(),
|
||||
processes: world.processes.clone(),
|
||||
})?;
|
||||
use sausage::exec::{Command, Options, Stream};
|
||||
for (script, expected) in [
|
||||
("command -v ls", "/bin/ls.wasm\n"),
|
||||
("ls", "bin"),
|
||||
("git --version", "git version"),
|
||||
("printf 'a\\n' | xargs echo", "a\n"),
|
||||
("env FOO=1 printenv FOO", "1\n"),
|
||||
] {
|
||||
let mut out = Vec::new();
|
||||
let code = exec
|
||||
.exec(
|
||||
Command::Shell(script.into()),
|
||||
Options::default(),
|
||||
&mut |stream, bytes| {
|
||||
if matches!(stream, Stream::Stdout) {
|
||||
out.extend_from_slice(bytes);
|
||||
}
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(code, 0, "{script}");
|
||||
assert!(String::from_utf8(out)?.contains(expected), "{script}");
|
||||
}
|
||||
let mut history = Vec::new();
|
||||
assert_eq!(
|
||||
exec.exec(
|
||||
Command::Shell("cd /src/sausage && git log --oneline".into()),
|
||||
Options::default(),
|
||||
&mut |stream, bytes| {
|
||||
if matches!(stream, Stream::Stdout) {
|
||||
history.extend_from_slice(bytes);
|
||||
}
|
||||
},
|
||||
)
|
||||
.await?,
|
||||
0,
|
||||
);
|
||||
let history = String::from_utf8(history)?;
|
||||
assert_eq!(history.lines().count(), 1);
|
||||
assert!(history.contains("Bundle source "));
|
||||
let mut status = Vec::new();
|
||||
assert_eq!(
|
||||
exec.exec(
|
||||
Command::Shell("cd /src/sausage && git status --short".into()),
|
||||
Options::default(),
|
||||
&mut |stream, bytes| {
|
||||
if matches!(stream, Stream::Stdout) {
|
||||
status.extend_from_slice(bytes);
|
||||
}
|
||||
},
|
||||
)
|
||||
.await?,
|
||||
0,
|
||||
);
|
||||
assert!(status.is_empty(), "{}", String::from_utf8_lossy(&status));
|
||||
let source = format!(
|
||||
"globalThis.testP1 = true;\n{}",
|
||||
include_str!("../js/test-bundle.js")
|
||||
);
|
||||
run_module(world, exec, &source).await?;
|
||||
run_module(world, exec.clone(), &source).await?;
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("world/test.txt"))?,
|
||||
"after\n"
|
||||
@@ -59,6 +121,50 @@ async fn bundle_mount_and_real_shell() -> anyhow::Result<()> {
|
||||
let actual = std::fs::read_to_string(spills[0].path())?;
|
||||
let expected: String = (1..=10000).map(|n| format!("{n}\n")).collect();
|
||||
assert_eq!(actual, expected);
|
||||
let source_cat = std::fs::read(bundle.join("bin/cat.wasm"))?;
|
||||
let head = std::fs::read(dir.path().join("world/bin/head.wasm"))?;
|
||||
let mut out = Vec::new();
|
||||
assert_eq!(
|
||||
exec.exec(
|
||||
Command::Shell(
|
||||
"cp /bin/true.wasm /bin/cat.wasm; cat /test.txt; head -n 1 /test.txt".into()
|
||||
),
|
||||
Options::default(),
|
||||
&mut |stream, bytes| {
|
||||
if matches!(stream, Stream::Stdout) {
|
||||
out.extend_from_slice(bytes);
|
||||
}
|
||||
}
|
||||
)
|
||||
.await?,
|
||||
0
|
||||
);
|
||||
assert_eq!(out, b"after\n");
|
||||
assert_eq!(std::fs::read(bundle.join("bin/cat.wasm"))?, source_cat);
|
||||
assert_eq!(std::fs::read(dir.path().join("world/bin/head.wasm"))?, head);
|
||||
assert_eq!(
|
||||
exec.exec(
|
||||
Command::Shell("cp /bin/true.wasm /usr/local/bin/head.wasm; head /test.txt".into()),
|
||||
Options::default(),
|
||||
&mut |_, bytes| assert!(bytes.is_empty())
|
||||
)
|
||||
.await?,
|
||||
0
|
||||
);
|
||||
let changed = std::fs::read(dir.path().join("world/bin/cat.wasm"))?;
|
||||
let reopened = World::open_with_userland(
|
||||
dir.path(),
|
||||
Some(std::path::Path::new("/nonexistent-bundle")),
|
||||
)?;
|
||||
assert_eq!(
|
||||
reopened
|
||||
.root
|
||||
.open("/bin/cat.wasm", Open::default())
|
||||
.await?
|
||||
.read(0, changed.len())
|
||||
.await?,
|
||||
changed
|
||||
);
|
||||
// An unbundled world remains usable by process tests and file-only callers.
|
||||
let unbundled = tempfile::tempdir()?;
|
||||
let world = World::open(unbundled.path())?;
|
||||
|
||||
+56
-11
@@ -9,6 +9,39 @@ use std::{
|
||||
sync::{Arc, Once},
|
||||
time::Duration,
|
||||
};
|
||||
#[test]
|
||||
fn world_copies_programs_and_nested_source_only_once() -> anyhow::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let bundle = temp.path().join("bundle");
|
||||
std::fs::create_dir_all(bundle.join("bin"))?;
|
||||
std::fs::create_dir_all(bundle.join("src/sausage/.git"))?;
|
||||
std::fs::write(bundle.join("bin/probe.wasm"), b"program")?;
|
||||
std::fs::write(
|
||||
bundle.join("src/sausage/.git/HEAD"),
|
||||
b"ref: refs/heads/main\n",
|
||||
)?;
|
||||
std::fs::write(bundle.join("src/sausage/README.md"), b"source")?;
|
||||
let path = temp.path().join("world");
|
||||
let _world = World::open_with_userland(&path, Some(&bundle))?;
|
||||
assert_eq!(
|
||||
std::fs::read(path.join("world/bin/probe.wasm"))?,
|
||||
b"program"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(path.join("world/src/sausage/README.md"))?,
|
||||
b"source"
|
||||
);
|
||||
assert!(path.join("world/src/sausage/.git/HEAD").is_file());
|
||||
std::fs::write(path.join("world/src/sausage/README.md"), b"agent patch")?;
|
||||
let _world = World::open_with_userland(&path, Some(Path::new("/missing-bundle")))?;
|
||||
assert_eq!(
|
||||
std::fs::read(path.join("world/src/sausage/README.md"))?,
|
||||
b"agent patch"
|
||||
);
|
||||
assert!(!path.join("host/compile-cache").exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
mod features {
|
||||
//! Acceptance coverage for preview1, identity, inherited IO, services, HTTP, and bundle aliases.
|
||||
use super::*;
|
||||
@@ -253,28 +286,32 @@ mod features {
|
||||
empty(&processes).await;
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn relative_aliases_share_compilation() {
|
||||
async fn independent_copies_share_compilation() {
|
||||
let _serial = SERIAL.lock().await;
|
||||
let (temp, runtime, _) = world();
|
||||
let bin = temp.path().join("world/bin");
|
||||
for name in ["true", "echo"] {
|
||||
std::os::unix::fs::symlink("cat", bin.join(name)).unwrap();
|
||||
for name in ["read-a.wasm", "read-b.wasm"] {
|
||||
std::fs::copy(bin.join("cat.wasm"), bin.join(name)).unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
run(&runtime, Command::Argv(vec!["/bin/true".into()]), "/")
|
||||
.await
|
||||
.0,
|
||||
run(
|
||||
&runtime,
|
||||
Command::Argv(vec!["/bin/read-a".into(), "/f".into()]),
|
||||
"/"
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
0
|
||||
);
|
||||
assert_eq!(runtime.compiled_programs(), 1);
|
||||
let (code, out, _) = run(
|
||||
&runtime,
|
||||
Command::Argv(vec!["/bin/echo".into(), "shared".into()]),
|
||||
Command::Argv(vec!["/bin/read-b".into(), "/f".into()]),
|
||||
"/",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(out, b"shared\n");
|
||||
assert_eq!(out, b"root\n");
|
||||
assert_eq!(runtime.compiled_programs(), 1);
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -402,13 +439,17 @@ fn world() -> (tempfile::TempDir, Arc<Runtime>, sausage::process::Processes) {
|
||||
let world = World::open(temp.path()).unwrap();
|
||||
let cache = Path::new(env!("CARGO_TARGET_TMPDIR")).join("process-host/compile-cache");
|
||||
std::fs::create_dir_all(&cache).unwrap();
|
||||
std::os::unix::fs::symlink(cache, world.host_dir.join("compile-cache")).unwrap();
|
||||
|
||||
let processes = world.processes.clone();
|
||||
fixture(&temp.path().join("world"));
|
||||
let artifacts = repo().join("userland/target/wasm32-wasip2/release");
|
||||
let bin = temp.path().join("world/bin");
|
||||
for tool in ["cat", "tr", "ls", "wc", "yes", "head", "sleep"] {
|
||||
std::fs::copy(artifacts.join("sausage-coreutils.wasm"), bin.join(tool)).unwrap();
|
||||
std::fs::copy(
|
||||
artifacts.join(format!("{tool}.wasm")),
|
||||
bin.join(format!("{tool}.wasm")),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
for tool in ["ps", "kill"] {
|
||||
std::fs::copy(artifacts.join("sausage-procps.wasm"), bin.join(tool)).unwrap();
|
||||
@@ -429,7 +470,11 @@ fn world() -> (tempfile::TempDir, Arc<Runtime>, sausage::process::Processes) {
|
||||
bin.join("git"),
|
||||
)
|
||||
.unwrap();
|
||||
(temp, Runtime::new(world).unwrap(), processes)
|
||||
(
|
||||
temp,
|
||||
Runtime::new_with_cache(world, &cache).unwrap(),
|
||||
processes,
|
||||
)
|
||||
}
|
||||
async fn run(runtime: &Arc<Runtime>, command: Command, cwd: &str) -> (i32, Vec<u8>, Vec<u8>) {
|
||||
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
|
||||
|
||||
Reference in New Issue
Block a user