Compare commits
75
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c19bfbad0 | ||
|
|
dab8c019cb | ||
|
|
a65875fd66 | ||
|
|
9454f153fe | ||
|
|
4664f225b3 | ||
|
|
29d41d883c | ||
|
|
bf796f8db2 | ||
|
|
590a270757 | ||
|
|
8d39122294 | ||
|
|
ba560ad226 | ||
|
|
838b6cd3c3 | ||
|
|
8c71ec719a | ||
|
|
4de62650da | ||
|
|
c0b0afe082 | ||
|
|
45c2ade158 | ||
|
|
a5ff591311 | ||
|
|
58a25cf6af | ||
|
|
7a7589ae6e | ||
|
|
0b002cf34f | ||
|
|
6383b20501 | ||
|
|
0c53ff0282 | ||
|
|
f25cd9ee9a | ||
|
|
e6db25497d | ||
|
|
50c5c80527 | ||
|
|
0f86465929 | ||
|
|
70889c7c1c | ||
|
|
7181287dfc | ||
|
|
21e5fb11de | ||
|
|
0d1840249b | ||
|
|
cfffc80c36 | ||
|
|
27b401e025 | ||
|
|
50a828a008 | ||
|
|
5851e6d7f2 | ||
|
|
ddfea7660e | ||
|
|
9f948151b6 | ||
|
|
8613f2df7d | ||
|
|
6c67390567 | ||
|
|
cf2c39f808 | ||
|
|
c6756bba2e | ||
|
|
eb64644c9b | ||
|
|
e6c95894c5 | ||
|
|
9fadd1bcaf | ||
|
|
1d8c205b2e | ||
|
|
10e16bb77d | ||
|
|
f8262bbab8 | ||
|
|
4bc5c231d2 | ||
|
|
1ff50d9b54 | ||
|
|
9f8aad79a3 | ||
|
|
075ea4e440 | ||
|
|
6d43025a30 | ||
|
|
6f5b165831 | ||
|
|
25e8f7ae57 | ||
|
|
f814e808ec | ||
|
|
7873f4359a | ||
|
|
acbaaaa1d6 | ||
|
|
c4a34cf0cc | ||
|
|
c63a750943 | ||
|
|
273aa44244 | ||
|
|
a8b9afc35c | ||
|
|
80e7334513 | ||
|
|
21fe1a7a31 | ||
|
|
dfa04107ab | ||
|
|
d1cd2855e8 | ||
|
|
9e404b0021 | ||
|
|
326616d07e | ||
|
|
e166117c60 | ||
|
|
a7f5ecb56a | ||
|
|
9f09159e2f | ||
|
|
267f4d6842 | ||
|
|
3078d0cee9 | ||
|
|
9d8175f52e | ||
|
|
2998f53de7 | ||
|
|
29cb24a878 | ||
|
|
c0559fa251 | ||
|
|
223aa125de |
Generated
+57
@@ -199,6 +199,7 @@ dependencies = [
|
|||||||
"api-macros-impl",
|
"api-macros-impl",
|
||||||
"axum",
|
"axum",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"error-contract",
|
||||||
"futures",
|
"futures",
|
||||||
"oas3",
|
"oas3",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -837,6 +838,35 @@ version = "0.10.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "content-history"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"sha2 0.11.0",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "content-history-adapters"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"config-source",
|
||||||
|
"content-history",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "content-history-store"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"config-source",
|
||||||
|
"content-history",
|
||||||
|
"content-history-adapters",
|
||||||
|
"rusqlite",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
@@ -1594,6 +1624,18 @@ version = "2.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "feature-permissions"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"schemars",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"toml",
|
||||||
|
"ts-rs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "feature-storage"
|
name = "feature-storage"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -4284,12 +4326,15 @@ name = "runtime-api"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"api-macros",
|
"api-macros",
|
||||||
|
"error-contract",
|
||||||
"manifest",
|
"manifest",
|
||||||
"plugin-plan",
|
"plugin-plan",
|
||||||
"protocol",
|
"protocol",
|
||||||
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"session-store",
|
"session-store",
|
||||||
|
"sha2 0.11.0",
|
||||||
"tokio",
|
"tokio",
|
||||||
"workdir",
|
"workdir",
|
||||||
]
|
]
|
||||||
@@ -4723,6 +4768,8 @@ name = "server-api"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"api-macros",
|
"api-macros",
|
||||||
|
"error-contract",
|
||||||
|
"feature-permissions",
|
||||||
"memory",
|
"memory",
|
||||||
"merge-request",
|
"merge-request",
|
||||||
"plugin-plan",
|
"plugin-plan",
|
||||||
@@ -4768,6 +4815,7 @@ dependencies = [
|
|||||||
"agen",
|
"agen",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"error-contract",
|
||||||
"filetime",
|
"filetime",
|
||||||
"fs4",
|
"fs4",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -4997,6 +5045,7 @@ dependencies = [
|
|||||||
"agen",
|
"agen",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"client",
|
"client",
|
||||||
|
"error-contract",
|
||||||
"feature-storage",
|
"feature-storage",
|
||||||
"fs4",
|
"fs4",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -7272,6 +7321,8 @@ dependencies = [
|
|||||||
"client",
|
"client",
|
||||||
"config-source",
|
"config-source",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
|
"error-contract",
|
||||||
|
"feature-permissions",
|
||||||
"flow",
|
"flow",
|
||||||
"fs-operation",
|
"fs-operation",
|
||||||
"fs4",
|
"fs4",
|
||||||
@@ -7328,6 +7379,7 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"decodal",
|
"decodal",
|
||||||
|
"error-contract",
|
||||||
"flow",
|
"flow",
|
||||||
"fs4",
|
"fs4",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -7530,6 +7582,11 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"config-source",
|
"config-source",
|
||||||
|
"content-history",
|
||||||
|
"content-history-adapters",
|
||||||
|
"content-history-store",
|
||||||
|
"error-contract",
|
||||||
|
"feature-permissions",
|
||||||
"feature-storage",
|
"feature-storage",
|
||||||
"flate2",
|
"flate2",
|
||||||
"flow",
|
"flow",
|
||||||
|
|||||||
+12
@@ -31,10 +31,14 @@ members = [
|
|||||||
"crates/builtin-source",
|
"crates/builtin-source",
|
||||||
"crates/config-source",
|
"crates/config-source",
|
||||||
"crates/config-source-wasm",
|
"crates/config-source-wasm",
|
||||||
|
"crates/content-history",
|
||||||
|
"crates/content-history-store",
|
||||||
|
"crates/content-history-adapters",
|
||||||
"crates/workdir",
|
"crates/workdir",
|
||||||
"crates/tui",
|
"crates/tui",
|
||||||
"crates/memory",
|
"crates/memory",
|
||||||
"crates/feature-storage",
|
"crates/feature-storage",
|
||||||
|
"crates/feature-permissions",
|
||||||
"crates/workspace-drive",
|
"crates/workspace-drive",
|
||||||
"crates/subjektiv",
|
"crates/subjektiv",
|
||||||
"crates/ticket",
|
"crates/ticket",
|
||||||
@@ -74,10 +78,14 @@ default-members = [
|
|||||||
"crates/builtin-source",
|
"crates/builtin-source",
|
||||||
"crates/config-source",
|
"crates/config-source",
|
||||||
"crates/config-source-wasm",
|
"crates/config-source-wasm",
|
||||||
|
"crates/content-history",
|
||||||
|
"crates/content-history-store",
|
||||||
|
"crates/content-history-adapters",
|
||||||
"crates/workdir",
|
"crates/workdir",
|
||||||
"crates/tui",
|
"crates/tui",
|
||||||
"crates/memory",
|
"crates/memory",
|
||||||
"crates/feature-storage",
|
"crates/feature-storage",
|
||||||
|
"crates/feature-permissions",
|
||||||
"crates/workspace-drive",
|
"crates/workspace-drive",
|
||||||
"crates/subjektiv",
|
"crates/subjektiv",
|
||||||
"crates/ticket",
|
"crates/ticket",
|
||||||
@@ -118,6 +126,7 @@ mcp = { path = "crates/mcp" }
|
|||||||
lint-common = { path = "crates/lint-common" }
|
lint-common = { path = "crates/lint-common" }
|
||||||
memory = { path = "crates/memory" }
|
memory = { path = "crates/memory" }
|
||||||
feature-storage = { path = "crates/feature-storage" }
|
feature-storage = { path = "crates/feature-storage" }
|
||||||
|
feature-permissions = { path = "crates/feature-permissions" }
|
||||||
workspace-drive = { path = "crates/workspace-drive" }
|
workspace-drive = { path = "crates/workspace-drive" }
|
||||||
subjektiv = { path = "crates/subjektiv" }
|
subjektiv = { path = "crates/subjektiv" }
|
||||||
merge-request = { path = "crates/merge-request" }
|
merge-request = { path = "crates/merge-request" }
|
||||||
@@ -140,6 +149,9 @@ secrets = { path = "crates/secrets" }
|
|||||||
tools = { path = "crates/tools" }
|
tools = { path = "crates/tools" }
|
||||||
builtin-source = { path = "crates/builtin-source" }
|
builtin-source = { path = "crates/builtin-source" }
|
||||||
config-source = { path = "crates/config-source" }
|
config-source = { path = "crates/config-source" }
|
||||||
|
content-history = { path = "crates/content-history" }
|
||||||
|
content-history-store = { path = "crates/content-history-store" }
|
||||||
|
content-history-adapters = { path = "crates/content-history-adapters" }
|
||||||
error-contract = { path = "crates/error-contract" }
|
error-contract = { path = "crates/error-contract" }
|
||||||
fs-operation = { path = "crates/fs-operation" }
|
fs-operation = { path = "crates/fs-operation" }
|
||||||
workdir = { path = "crates/workdir" }
|
workdir = { path = "crates/workdir" }
|
||||||
|
|||||||
+262
-113
@@ -53,6 +53,7 @@ enum Method {
|
|||||||
#[derive(Clone, Copy, Default)]
|
#[derive(Clone, Copy, Default)]
|
||||||
struct AdapterConfig {
|
struct AdapterConfig {
|
||||||
reqwest: bool,
|
reqwest: bool,
|
||||||
|
client_error_hook: bool,
|
||||||
axum: bool,
|
axum: bool,
|
||||||
openapi: bool,
|
openapi: bool,
|
||||||
}
|
}
|
||||||
@@ -64,9 +65,10 @@ impl Parse for AdapterConfig {
|
|||||||
for adapter in adapters {
|
for adapter in adapters {
|
||||||
match adapter.to_string().as_str() {
|
match adapter.to_string().as_str() {
|
||||||
"reqwest" if !config.reqwest => config.reqwest = true,
|
"reqwest" if !config.reqwest => config.reqwest = true,
|
||||||
|
"client_error_hook" if !config.client_error_hook => config.client_error_hook = true,
|
||||||
"axum" if !config.axum => config.axum = true,
|
"axum" if !config.axum => config.axum = true,
|
||||||
"openapi" if !config.openapi => config.openapi = true,
|
"openapi" if !config.openapi => config.openapi = true,
|
||||||
"reqwest" | "axum" | "openapi" => {
|
"reqwest" | "axum" | "openapi" | "client_error_hook" => {
|
||||||
return Err(syn::Error::new(adapter.span(), "duplicate API adapter"));
|
return Err(syn::Error::new(adapter.span(), "duplicate API adapter"));
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -77,6 +79,12 @@ impl Parse for AdapterConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if config.client_error_hook && !config.reqwest {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
Span::call_site(),
|
||||||
|
"`client_error_hook` requires the `reqwest` adapter",
|
||||||
|
));
|
||||||
|
}
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,6 +129,8 @@ struct RouteArgs {
|
|||||||
responses: Vec<DeclaredResponse>,
|
responses: Vec<DeclaredResponse>,
|
||||||
error_status: Option<LitInt>,
|
error_status: Option<LitInt>,
|
||||||
additional_error_statuses: Vec<LitInt>,
|
additional_error_statuses: Vec<LitInt>,
|
||||||
|
additional_error_content_types: Option<Vec<LitStr>>,
|
||||||
|
client_error_hook: Option<syn::LitBool>,
|
||||||
bearer_auth: Option<syn::LitBool>,
|
bearer_auth: Option<syn::LitBool>,
|
||||||
browser_auth: Option<syn::LitBool>,
|
browser_auth: Option<syn::LitBool>,
|
||||||
normalize_body_errors: Option<syn::LitBool>,
|
normalize_body_errors: Option<syn::LitBool>,
|
||||||
@@ -310,6 +320,8 @@ impl Parse for RouteArgs {
|
|||||||
responses: Vec::new(),
|
responses: Vec::new(),
|
||||||
error_status: None,
|
error_status: None,
|
||||||
additional_error_statuses: Vec::new(),
|
additional_error_statuses: Vec::new(),
|
||||||
|
additional_error_content_types: None,
|
||||||
|
client_error_hook: None,
|
||||||
bearer_auth: None,
|
bearer_auth: None,
|
||||||
browser_auth: None,
|
browser_auth: None,
|
||||||
normalize_body_errors: None,
|
normalize_body_errors: None,
|
||||||
@@ -379,6 +391,25 @@ impl Parse for RouteArgs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"additional_error_content_types" => {
|
||||||
|
let content;
|
||||||
|
syn::bracketed!(content in input);
|
||||||
|
let values = Punctuated::<LitStr, Token![,]>::parse_terminated(&content)?
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
set_once(
|
||||||
|
&mut result.additional_error_content_types,
|
||||||
|
values,
|
||||||
|
&key,
|
||||||
|
"additional_error_content_types",
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
"client_error_hook" => set_once(
|
||||||
|
&mut result.client_error_hook,
|
||||||
|
input.parse::<syn::LitBool>()?,
|
||||||
|
&key,
|
||||||
|
"client_error_hook",
|
||||||
|
)?,
|
||||||
"bearer_auth" => set_once(
|
"bearer_auth" => set_once(
|
||||||
&mut result.bearer_auth,
|
&mut result.bearer_auth,
|
||||||
input.parse::<syn::LitBool>()?,
|
input.parse::<syn::LitBool>()?,
|
||||||
@@ -406,7 +437,7 @@ impl Parse for RouteArgs {
|
|||||||
_ => {
|
_ => {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
key.span(),
|
key.span(),
|
||||||
"unsupported route option; expected operation_id, status, alternate_status, responses, error_status, additional_error_statuses, bearer_auth, browser_auth, normalize_body_errors, or openapi",
|
"unsupported route option; expected operation_id, status, alternate_status, responses, error_status, additional_error_statuses, additional_error_content_types, client_error_hook, bearer_auth, browser_auth, normalize_body_errors, or openapi",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -502,6 +533,8 @@ struct Operation {
|
|||||||
alternate_status: Option<u16>,
|
alternate_status: Option<u16>,
|
||||||
error_status: Option<u16>,
|
error_status: Option<u16>,
|
||||||
additional_error_statuses: Vec<u16>,
|
additional_error_statuses: Vec<u16>,
|
||||||
|
additional_error_content_types: Vec<String>,
|
||||||
|
client_error_hook: bool,
|
||||||
bearer_auth: bool,
|
bearer_auth: bool,
|
||||||
browser_auth: bool,
|
browser_auth: bool,
|
||||||
normalize_body_errors: bool,
|
normalize_body_errors: bool,
|
||||||
@@ -546,7 +579,7 @@ fn normalize_api(
|
|||||||
for mut trait_item in std::mem::take(&mut item.items) {
|
for mut trait_item in std::mem::take(&mut item.items) {
|
||||||
let operation = match &mut trait_item {
|
let operation = match &mut trait_item {
|
||||||
TraitItem::Fn(method) => {
|
TraitItem::Fn(method) => {
|
||||||
let operation = normalize_operation(method)?;
|
let operation = normalize_operation(method, adapters)?;
|
||||||
retained_items.push(trait_item);
|
retained_items.push(trait_item);
|
||||||
operation
|
operation
|
||||||
}
|
}
|
||||||
@@ -778,6 +811,8 @@ fn normalize_websocket_operation(declaration: &mut syn::TraitItemType) -> syn::R
|
|||||||
alternate_status: None,
|
alternate_status: None,
|
||||||
error_status: None,
|
error_status: None,
|
||||||
additional_error_statuses: Vec::new(),
|
additional_error_statuses: Vec::new(),
|
||||||
|
additional_error_content_types: Vec::new(),
|
||||||
|
client_error_hook: false,
|
||||||
bearer_auth: false,
|
bearer_auth: false,
|
||||||
browser_auth: false,
|
browser_auth: false,
|
||||||
normalize_body_errors: false,
|
normalize_body_errors: false,
|
||||||
@@ -796,7 +831,10 @@ fn validate_frame_type(ty: &Type) -> syn::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
|
fn normalize_operation(
|
||||||
|
method: &mut TraitItemFn,
|
||||||
|
adapters: AdapterConfig,
|
||||||
|
) -> syn::Result<Operation> {
|
||||||
validate_signature(method)?;
|
validate_signature(method)?;
|
||||||
|
|
||||||
let mut route = None;
|
let mut route = None;
|
||||||
@@ -845,6 +883,53 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
|
|||||||
validate_named_body_type(ty, "error response")?;
|
validate_named_body_type(ty, "error response")?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let client_error_hook = route
|
||||||
|
.client_error_hook
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|value| value.value);
|
||||||
|
if client_error_hook {
|
||||||
|
let option = route.client_error_hook.as_ref().expect("enabled hook");
|
||||||
|
if !adapters.reqwest {
|
||||||
|
return Err(syn::Error::new_spanned(
|
||||||
|
option,
|
||||||
|
"client_error_hook requires the `reqwest` adapter",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if error_body.is_none() {
|
||||||
|
return Err(syn::Error::new_spanned(
|
||||||
|
option,
|
||||||
|
"client_error_hook requires a public error response type",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut additional_error_content_types = Vec::new();
|
||||||
|
if let Some(content_types) = &route.additional_error_content_types {
|
||||||
|
if error_body.is_none() {
|
||||||
|
return Err(syn::Error::new_spanned(
|
||||||
|
&route.path,
|
||||||
|
"additional_error_content_types require a public error response type",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if content_types.is_empty() {
|
||||||
|
return Err(syn::Error::new_spanned(
|
||||||
|
&route.path,
|
||||||
|
"additional_error_content_types must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for content_type in content_types {
|
||||||
|
let value = validate_error_content_type(content_type)?;
|
||||||
|
if value == "application/json" || additional_error_content_types.contains(&value) {
|
||||||
|
return Err(syn::Error::new_spanned(
|
||||||
|
content_type,
|
||||||
|
"additional error content types must be distinct from each other and application/json",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
additional_error_content_types.push(value);
|
||||||
|
}
|
||||||
|
additional_error_content_types.sort();
|
||||||
|
}
|
||||||
|
|
||||||
let declared_responses = !route.responses.is_empty();
|
let declared_responses = !route.responses.is_empty();
|
||||||
if declared_responses && (route.status.is_some() || route.alternate_status.is_some()) {
|
if declared_responses && (route.status.is_some() || route.alternate_status.is_some()) {
|
||||||
return Err(syn::Error::new_spanned(
|
return Err(syn::Error::new_spanned(
|
||||||
@@ -1196,6 +1281,8 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
|
|||||||
alternate_status,
|
alternate_status,
|
||||||
error_status,
|
error_status,
|
||||||
additional_error_statuses,
|
additional_error_statuses,
|
||||||
|
additional_error_content_types,
|
||||||
|
client_error_hook,
|
||||||
bearer_auth: route.bearer_auth.as_ref().is_some_and(|value| value.value),
|
bearer_auth: route.bearer_auth.as_ref().is_some_and(|value| value.value),
|
||||||
browser_auth: route.browser_auth.as_ref().is_some_and(|value| value.value),
|
browser_auth: route.browser_auth.as_ref().is_some_and(|value| value.value),
|
||||||
normalize_body_errors: route
|
normalize_body_errors: route
|
||||||
@@ -1206,6 +1293,43 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Route media declarations are concrete type/subtype tokens, not media ranges or parameters.
|
||||||
|
fn validate_error_content_type(literal: &LitStr) -> syn::Result<String> {
|
||||||
|
let value = literal.value().to_ascii_lowercase();
|
||||||
|
let valid_token = |token: &str| {
|
||||||
|
!token.is_empty()
|
||||||
|
&& token.bytes().all(|byte| {
|
||||||
|
byte.is_ascii_alphanumeric()
|
||||||
|
|| matches!(
|
||||||
|
byte,
|
||||||
|
b'!' | b'#'
|
||||||
|
| b'$'
|
||||||
|
| b'%'
|
||||||
|
| b'&'
|
||||||
|
| b'\''
|
||||||
|
| b'+'
|
||||||
|
| b'-'
|
||||||
|
| b'.'
|
||||||
|
| b'^'
|
||||||
|
| b'_'
|
||||||
|
| b'`'
|
||||||
|
| b'|'
|
||||||
|
| b'~'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
if !value
|
||||||
|
.split_once('/')
|
||||||
|
.is_some_and(|(kind, subtype)| valid_token(kind) && valid_token(subtype))
|
||||||
|
{
|
||||||
|
return Err(syn::Error::new_spanned(
|
||||||
|
literal,
|
||||||
|
"error content type must be a concrete type/subtype without parameters",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_signature(method: &TraitItemFn) -> syn::Result<()> {
|
fn validate_signature(method: &TraitItemFn) -> syn::Result<()> {
|
||||||
let sig = &method.sig;
|
let sig = &method.sig;
|
||||||
if sig.asyncness.is_none() {
|
if sig.asyncness.is_none() {
|
||||||
@@ -1815,17 +1939,27 @@ fn reqwest_adapter_tokens(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let public_error_decode = if api.adapters.client_error_hook || operation.client_error_hook {
|
||||||
|
quote! {
|
||||||
|
<#error_type as #api_crate::reqwest::HttpClientError>::decode_error(&__response)
|
||||||
|
.map_err(#api_crate::reqwest::ClientError::failure)?
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
quote! {
|
||||||
|
__response
|
||||||
|
.decode_json::<#error_type>(#api_crate::reqwest::DecodeKind::PublicError)
|
||||||
|
.map_err(|_| #api_crate::reqwest::ClientError::failure(
|
||||||
|
#api_crate::reqwest::ClientFailure::ErrorResponseDecode {
|
||||||
|
status: __status.as_u16(),
|
||||||
|
},
|
||||||
|
))?
|
||||||
|
}
|
||||||
|
};
|
||||||
let public_error = match operation.error_body.as_ref() {
|
let public_error = match operation.error_body.as_ref() {
|
||||||
Some(error) => quote! {
|
Some(_) => quote! {
|
||||||
if (400..=599).contains(&__actual) {
|
if (400..=599).contains(&__actual) {
|
||||||
let __status = __response.status();
|
let __status = __response.status();
|
||||||
let __error = __response
|
let __error = #public_error_decode;
|
||||||
.decode_json::<#error>(#api_crate::reqwest::DecodeKind::PublicError)
|
|
||||||
.map_err(|_| #api_crate::reqwest::ClientError::failure(
|
|
||||||
#api_crate::reqwest::ClientFailure::ErrorResponseDecode {
|
|
||||||
status: __status.as_u16(),
|
|
||||||
},
|
|
||||||
))?;
|
|
||||||
return ::core::result::Result::Err(
|
return ::core::result::Result::Err(
|
||||||
#api_crate::reqwest::ClientError::public(__status, __error),
|
#api_crate::reqwest::ClientError::public(__status, __error),
|
||||||
);
|
);
|
||||||
@@ -2098,7 +2232,7 @@ fn axum_adapter_tokens(
|
|||||||
status,
|
status,
|
||||||
rejection.body_text(),
|
rejection.body_text(),
|
||||||
);
|
);
|
||||||
return #api_crate::axum::error_response(
|
return #api_crate::axum::http_error_response(
|
||||||
#api_crate::axum::status(status),
|
#api_crate::axum::status(status),
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
@@ -2132,7 +2266,7 @@ fn axum_adapter_tokens(
|
|||||||
status,
|
status,
|
||||||
rejection.body_text(),
|
rejection.body_text(),
|
||||||
);
|
);
|
||||||
return #api_crate::axum::error_response(
|
return #api_crate::axum::http_error_response(
|
||||||
#api_crate::axum::status(status),
|
#api_crate::axum::status(status),
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
@@ -2218,7 +2352,7 @@ fn axum_adapter_tokens(
|
|||||||
let error = match operation.error_status {
|
let error = match operation.error_status {
|
||||||
Some(_) => quote!({
|
Some(_) => quote!({
|
||||||
let status = #api_crate::HttpError::status_code(&error);
|
let status = #api_crate::HttpError::status_code(&error);
|
||||||
#api_crate::axum::error_response(#api_crate::axum::status(status), error)
|
#api_crate::axum::http_error_response(#api_crate::axum::status(status), error)
|
||||||
}),
|
}),
|
||||||
None => quote!({ let _ = error; #api_crate::axum::empty_response(#api_crate::axum::framework::StatusCode::INTERNAL_SERVER_ERROR) }),
|
None => quote!({ let _ = error; #api_crate::axum::empty_response(#api_crate::axum::framework::StatusCode::INTERNAL_SERVER_ERROR) }),
|
||||||
};
|
};
|
||||||
@@ -2315,110 +2449,115 @@ fn openapi_adapter_tokens(
|
|||||||
matches!(operation.transport, Transport::Http) && !operation.openapi_skip
|
matches!(operation.transport, Transport::Http) && !operation.openapi_skip
|
||||||
})
|
})
|
||||||
.map(|operation| {
|
.map(|operation| {
|
||||||
let method = method_name(operation.method);
|
let method = method_name(operation.method);
|
||||||
let path = &operation.path;
|
let path = &operation.path;
|
||||||
let operation_id = &operation.operation_id;
|
let operation_id = &operation.operation_id;
|
||||||
let parameters = operation.parameters.iter().filter_map(|parameter| {
|
let parameters = operation.parameters.iter().filter_map(|parameter| {
|
||||||
if matches!(parameter.location, Location::Body | Location::Binary | Location::Extension) {
|
if matches!(
|
||||||
return None;
|
parameter.location,
|
||||||
}
|
Location::Body | Location::Binary | Location::Extension
|
||||||
let ty = ¶meter.ty;
|
) {
|
||||||
let name = ¶meter.wire_name;
|
return None;
|
||||||
let location = match parameter.location {
|
|
||||||
Location::Path => "path",
|
|
||||||
Location::Query => "query",
|
|
||||||
Location::Header => "header",
|
|
||||||
Location::Body | Location::Binary | Location::Extension => unreachable!(),
|
|
||||||
};
|
|
||||||
Some(quote! {
|
|
||||||
operation.parameter::<#ty>(#name, #location)?;
|
|
||||||
})
|
|
||||||
});
|
|
||||||
let request = operation.request_body.as_ref().map(|body| match body.wire_kind {
|
|
||||||
RequestWireKind::Json => {
|
|
||||||
let ty = &body.ty;
|
|
||||||
quote! {
|
|
||||||
operation.request_body::<#ty>("application/json")?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RequestWireKind::Binary => quote! {
|
|
||||||
operation.binary_request_body()?;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
let success_responses = operation.success_responses.iter().enumerate().map(|(index, response)| {
|
|
||||||
let status = response.status;
|
|
||||||
let description = if index == 0 {
|
|
||||||
"Successful response"
|
|
||||||
} else {
|
|
||||||
"Alternate successful response"
|
|
||||||
};
|
|
||||||
let body = match response.body.as_ref() {
|
|
||||||
Some(ty) if is_binary_body_type(ty) => quote! {
|
|
||||||
operation.binary_response(#status, #description)?;
|
|
||||||
},
|
|
||||||
Some(ty) => quote! {
|
|
||||||
operation.response::<#ty>(#status, "application/json", #description)?;
|
|
||||||
},
|
|
||||||
None => quote! {
|
|
||||||
operation.empty_response(#status, #description)?;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let headers = response.headers.iter().map(|header| {
|
|
||||||
let ty = &header.ty;
|
|
||||||
let name = &header.wire_name;
|
|
||||||
quote! {
|
|
||||||
operation.response_header::<#ty>(#status, #name)?;
|
|
||||||
}
|
}
|
||||||
|
let ty = ¶meter.ty;
|
||||||
|
let name = ¶meter.wire_name;
|
||||||
|
let location = match parameter.location {
|
||||||
|
Location::Path => "path",
|
||||||
|
Location::Query => "query",
|
||||||
|
Location::Header => "header",
|
||||||
|
Location::Body | Location::Binary | Location::Extension => unreachable!(),
|
||||||
|
};
|
||||||
|
Some(quote! {
|
||||||
|
operation.parameter::<#ty>(#name, #location)?;
|
||||||
|
})
|
||||||
});
|
});
|
||||||
quote! {
|
let request = operation
|
||||||
#body
|
.request_body
|
||||||
#(#headers)*
|
.as_ref()
|
||||||
}
|
.map(|body| match body.wire_kind {
|
||||||
});
|
RequestWireKind::Json => {
|
||||||
let error = operation
|
let ty = &body.ty;
|
||||||
.error_status
|
|
||||||
.zip(operation.error_body.as_ref())
|
|
||||||
.map(|(status, ty)| {
|
|
||||||
quote! {
|
|
||||||
operation.response::<#ty>(#status, "application/json", "Error response")?;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let additional_errors = operation
|
|
||||||
.error_body
|
|
||||||
.as_ref()
|
|
||||||
.map(|ty| {
|
|
||||||
operation
|
|
||||||
.additional_error_statuses
|
|
||||||
.iter()
|
|
||||||
.map(|status| {
|
|
||||||
quote! {
|
quote! {
|
||||||
operation.response::<#ty>(#status, "application/json", "Error response")?;
|
operation.request_body::<#ty>("application/json")?;
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
.collect::<Vec<_>>()
|
RequestWireKind::Binary => quote! {
|
||||||
})
|
operation.binary_request_body()?;
|
||||||
.unwrap_or_default();
|
},
|
||||||
let bearer_auth = operation.bearer_auth.then(|| {
|
});
|
||||||
quote! { operation.bearer_authentication(); }
|
let success_responses = operation.success_responses.iter().enumerate().map(
|
||||||
});
|
|(index, response)| {
|
||||||
let browser_auth = operation.browser_auth.then(|| {
|
let status = response.status;
|
||||||
quote! { operation.browser_authentication(); }
|
let description = if index == 0 {
|
||||||
});
|
"Successful response"
|
||||||
|
} else {
|
||||||
|
"Alternate successful response"
|
||||||
|
};
|
||||||
|
let body = match response.body.as_ref() {
|
||||||
|
Some(ty) if is_binary_body_type(ty) => quote! {
|
||||||
|
operation.binary_response(#status, #description)?;
|
||||||
|
},
|
||||||
|
Some(ty) => quote! {
|
||||||
|
operation.response::<#ty>(#status, "application/json", #description)?;
|
||||||
|
},
|
||||||
|
None => quote! {
|
||||||
|
operation.empty_response(#status, #description)?;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let headers = response.headers.iter().map(|header| {
|
||||||
|
let ty = &header.ty;
|
||||||
|
let name = &header.wire_name;
|
||||||
|
quote! {
|
||||||
|
operation.response_header::<#ty>(#status, #name)?;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
quote! {
|
||||||
|
#body
|
||||||
|
#(#headers)*
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let errors = operation
|
||||||
|
.error_body
|
||||||
|
.as_ref()
|
||||||
|
.map(|ty| {
|
||||||
|
let additional_content_types = &operation.additional_error_content_types;
|
||||||
|
operation
|
||||||
|
.error_status
|
||||||
|
.into_iter()
|
||||||
|
.chain(operation.additional_error_statuses.iter().copied())
|
||||||
|
.map(|status| {
|
||||||
|
quote! {
|
||||||
|
operation.response_content_types::<#ty>(
|
||||||
|
#status,
|
||||||
|
&["application/json", #(#additional_content_types),*],
|
||||||
|
"Error response",
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let bearer_auth = operation.bearer_auth.then(|| {
|
||||||
|
quote! { operation.bearer_authentication(); }
|
||||||
|
});
|
||||||
|
let browser_auth = operation.browser_auth.then(|| {
|
||||||
|
quote! { operation.browser_authentication(); }
|
||||||
|
});
|
||||||
|
|
||||||
quote! {
|
quote! {
|
||||||
{
|
{
|
||||||
let mut operation = builder.operation(#method, #path, #operation_id)?;
|
let mut operation = builder.operation(#method, #path, #operation_id)?;
|
||||||
#(#parameters)*
|
#(#parameters)*
|
||||||
#bearer_auth
|
#bearer_auth
|
||||||
#browser_auth
|
#browser_auth
|
||||||
#request
|
#request
|
||||||
#(#success_responses)*
|
#(#success_responses)*
|
||||||
#error
|
#(#errors)*
|
||||||
#(#additional_errors)*
|
operation.finish()?;
|
||||||
operation.finish()?;
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
|
||||||
quote! {
|
quote! {
|
||||||
#[doc = "Build this trait's deterministic, deployment-independent OpenAPI 3.1 document."]
|
#[doc = "Build this trait's deterministic, deployment-independent OpenAPI 3.1 document."]
|
||||||
@@ -2660,6 +2799,13 @@ fn metadata_tokens(
|
|||||||
} else {
|
} else {
|
||||||
quote!(#api_crate::WireKind::Empty)
|
quote!(#api_crate::WireKind::Empty)
|
||||||
};
|
};
|
||||||
|
let content_types = if response.body.as_ref().is_some_and(is_binary_body_type) {
|
||||||
|
quote!(&["application/octet-stream"])
|
||||||
|
} else if response.body.is_some() {
|
||||||
|
quote!(&["application/json"])
|
||||||
|
} else {
|
||||||
|
quote!(&[])
|
||||||
|
};
|
||||||
let headers = response.headers.iter().map(|header| {
|
let headers = response.headers.iter().map(|header| {
|
||||||
let wire_name = &header.wire_name;
|
let wire_name = &header.wire_name;
|
||||||
let ty = &header.ty;
|
let ty = &header.ty;
|
||||||
@@ -2674,6 +2820,7 @@ fn metadata_tokens(
|
|||||||
#api_crate::ResponseMetadata {
|
#api_crate::ResponseMetadata {
|
||||||
status: #status,
|
status: #status,
|
||||||
body: #api_crate::BodyMetadata { wire_kind: #wire_kind },
|
body: #api_crate::BodyMetadata { wire_kind: #wire_kind },
|
||||||
|
content_types: #content_types,
|
||||||
headers: &[#(#headers),*],
|
headers: &[#(#headers),*],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2683,10 +2830,12 @@ fn metadata_tokens(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.chain(operation.additional_error_statuses.iter().copied());
|
.chain(operation.additional_error_statuses.iter().copied());
|
||||||
let error_responses = error_statuses.map(|status| {
|
let error_responses = error_statuses.map(|status| {
|
||||||
|
let additional_content_types = &operation.additional_error_content_types;
|
||||||
quote! {
|
quote! {
|
||||||
#api_crate::ResponseMetadata {
|
#api_crate::ResponseMetadata {
|
||||||
status: #status,
|
status: #status,
|
||||||
body: #api_crate::BodyMetadata { wire_kind: #api_crate::WireKind::Json },
|
body: #api_crate::BodyMetadata { wire_kind: #api_crate::WireKind::Json },
|
||||||
|
content_types: &["application/json", #(#additional_content_types),*],
|
||||||
headers: &[],
|
headers: &[],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ publish = false
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
error-contract = ["dep:error-contract"]
|
||||||
axum = ["dep:axum", "dep:serde"]
|
axum = ["dep:axum", "dep:serde"]
|
||||||
openapi = ["dep:schemars", "dep:serde_json", "api-macros-impl/openapi"]
|
openapi = ["dep:schemars", "dep:serde_json", "api-macros-impl/openapi"]
|
||||||
reqwest = [
|
reqwest = [
|
||||||
@@ -20,6 +21,7 @@ reqwest = [
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
api-macros-impl = { path = "../api-macros-impl" }
|
api-macros-impl = { path = "../api-macros-impl" }
|
||||||
|
error-contract = { path = "../error-contract", optional = true }
|
||||||
bytes.workspace = true
|
bytes.workspace = true
|
||||||
axum = { workspace = true, optional = true }
|
axum = { workspace = true, optional = true }
|
||||||
futures = { workspace = true, optional = true }
|
futures = { workspace = true, optional = true }
|
||||||
|
|||||||
@@ -6,4 +6,6 @@ Defines transport-independent API metadata from public traits. The same declarat
|
|||||||
|
|
||||||
- [`api`] — API declaration and validation
|
- [`api`] — API declaration and validation
|
||||||
- [`ApiContract`], [`Operation`], and [`WebSocketOperation`] — typed operation contracts
|
- [`ApiContract`], [`Operation`], and [`WebSocketOperation`] — typed operation contracts
|
||||||
|
- [`HttpError`] — opt-in public error encoding, with unchanged JSON defaults
|
||||||
- `axum`, `reqwest`, and `openapi` — adapters and document generation enabled by their respective features
|
- `axum`, `reqwest`, and `openapi` — adapters and document generation enabled by their respective features
|
||||||
|
- `http` — projected error-contract response conversion and bounded reception, enabled by `error-contract` without enabling a framework
|
||||||
|
|||||||
@@ -96,6 +96,34 @@ pub fn error_response<T: Serialize + Send + Sync + 'static>(
|
|||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply the optional public encoding hook, retaining the original server-local error.
|
||||||
|
///
|
||||||
|
/// Invalid hook metadata fails closed with an empty 500 response and no typed extension.
|
||||||
|
/// A hook failure must not expose a potentially private legacy serialization.
|
||||||
|
pub fn http_error_response<T: crate::HttpError + Serialize + Send + Sync + 'static>(
|
||||||
|
status: StatusCode,
|
||||||
|
error: T,
|
||||||
|
) -> Response {
|
||||||
|
let Some(encoded) = error.custom_response() else {
|
||||||
|
return error_response(status, error);
|
||||||
|
};
|
||||||
|
let Ok(status) = StatusCode::from_u16(encoded.status) else {
|
||||||
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
|
};
|
||||||
|
let Ok(content_type) = axum::http::HeaderValue::from_str(encoded.content_type) else {
|
||||||
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
|
};
|
||||||
|
if !(status.is_client_error() || status.is_server_error()) || content_type.is_empty() {
|
||||||
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
|
}
|
||||||
|
let mut response = (status, encoded.body.into_bytes()).into_response();
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(axum::http::header::CONTENT_TYPE, content_type);
|
||||||
|
response.extensions_mut().insert(std::sync::Arc::new(error));
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
/// Emit an exact binary response with the contract status and octet-stream content type.
|
/// Emit an exact binary response with the contract status and octet-stream content type.
|
||||||
pub fn binary_response(status: StatusCode, value: crate::BinaryBody) -> Response {
|
pub fn binary_response(status: StatusCode, value: crate::BinaryBody) -> Response {
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
//! Shared HTTP error-contract adapter, independent of Axum and Reqwest.
|
||||||
|
//!
|
||||||
|
//! Only Browser-projected failures can be encoded here. Receiving a valid failure does not
|
||||||
|
//! grant permission to forward it: domains must reproject with current authorization.
|
||||||
|
|
||||||
|
pub use error_contract::problem::{ApiBoundary, ProblemResponse};
|
||||||
|
use error_contract::{
|
||||||
|
Browser, Budget, ContractError, DecodeFailure, ProjectedFailure, WireDetails,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Encode a projected failure or a static, non-domain 500 fallback under the same budget.
|
||||||
|
///
|
||||||
|
/// `safe_instance` must be selected by the caller's disclosure policy (for example a local
|
||||||
|
/// opaque occurrence URI), never copied from a request URI containing credentials or queries.
|
||||||
|
/// URI syntax validation is not disclosure authorization. If even the static fallback exceeds
|
||||||
|
/// the budget, return an error instead of truncating JSON or silently exceeding the limit.
|
||||||
|
pub fn problem_response<D: WireDetails>(
|
||||||
|
projected: &ProjectedFailure<Browser, D>,
|
||||||
|
boundary: &impl ApiBoundary<D>,
|
||||||
|
safe_instance: &str,
|
||||||
|
budget: Budget,
|
||||||
|
) -> Result<ProblemResponse, ContractError> {
|
||||||
|
error_contract::problem::to_problem_details(projected, boundary, safe_instance, budget)
|
||||||
|
.or_else(|_| error_contract::problem::problem_fallback(budget))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ProblemResponse> for crate::HttpErrorResponse {
|
||||||
|
fn from(response: ProblemResponse) -> Self {
|
||||||
|
Self {
|
||||||
|
status: response.status,
|
||||||
|
content_type: response.content_type,
|
||||||
|
body: response.body().to_vec().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize a bounded HTTP error using actual transport metadata, not a body-claimed status.
|
||||||
|
///
|
||||||
|
/// Unsupported schema/reason, corrupt input, and `Corrupt(TooLarge)` remain distinct. None of
|
||||||
|
/// these results, including a known cause, establish remote execution or retry permission.
|
||||||
|
/// Streaming callers must also bound collection before calling this byte-slice adapter.
|
||||||
|
pub fn decode_problem_response<D: WireDetails>(
|
||||||
|
input: &[u8],
|
||||||
|
actual_status: u16,
|
||||||
|
content_type: &str,
|
||||||
|
boundary: &impl ApiBoundary<D>,
|
||||||
|
budget: Budget,
|
||||||
|
) -> DecodeFailure<D> {
|
||||||
|
error_contract::problem::decode_problem_details(
|
||||||
|
input,
|
||||||
|
actual_status,
|
||||||
|
content_type,
|
||||||
|
boundary,
|
||||||
|
budget,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,6 +14,16 @@ extern crate self as api_macros;
|
|||||||
/// leave authentication, authorization, body limits, and timeouts to the enclosing application.
|
/// leave authentication, authorization, body limits, and timeouts to the enclosing application.
|
||||||
/// Declarations without adapters do not depend on an HTTP framework.
|
/// Declarations without adapters do not depend on an HTTP framework.
|
||||||
///
|
///
|
||||||
|
/// `#[api(reqwest, client_error_hook)]` delegates error decoding to the error type's
|
||||||
|
/// `reqwest::HttpClientError` implementation. A mixed-error API can instead set
|
||||||
|
/// `client_error_hook = true` on individual HTTP operations. Either flag enables the hook;
|
||||||
|
/// a method-level `false` does not override a trait-wide opt-in. Without opt-in, client-only
|
||||||
|
/// errors need only serde deserialization; server traits and codecs are not imposed on them.
|
||||||
|
///
|
||||||
|
/// `additional_error_content_types = ["application/problem+json"]` on an HTTP operation
|
||||||
|
/// declares extra representations for every declared error status, alongside legacy JSON.
|
||||||
|
/// Each representation uses the error type's schema; the producer still owns its encoding.
|
||||||
|
///
|
||||||
/// This declaration checks that a Rust trait and typed operation markers can be defined together.
|
/// This declaration checks that a Rust trait and typed operation markers can be defined together.
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
@@ -71,6 +81,8 @@ pub use api_macros_impl::api;
|
|||||||
|
|
||||||
#[cfg(feature = "axum")]
|
#[cfg(feature = "axum")]
|
||||||
pub mod axum;
|
pub mod axum;
|
||||||
|
#[cfg(feature = "error-contract")]
|
||||||
|
pub mod http;
|
||||||
#[cfg(feature = "openapi")]
|
#[cfg(feature = "openapi")]
|
||||||
pub mod openapi;
|
pub mod openapi;
|
||||||
#[cfg(feature = "reqwest")]
|
#[cfg(feature = "reqwest")]
|
||||||
@@ -213,6 +225,8 @@ pub struct ResponseHeaderMetadata {
|
|||||||
pub struct ResponseMetadata {
|
pub struct ResponseMetadata {
|
||||||
pub status: u16,
|
pub status: u16,
|
||||||
pub body: BodyMetadata,
|
pub body: BodyMetadata,
|
||||||
|
/// Declared media types, empty for bodyless responses. JSON remains the default error media.
|
||||||
|
pub content_types: &'static [&'static str],
|
||||||
pub headers: &'static [ResponseHeaderMetadata],
|
pub headers: &'static [ResponseHeaderMetadata],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,6 +286,24 @@ pub trait WebSocketOperation {
|
|||||||
/// service's typed 401/403/404/409 responses without introducing duplicate route handlers.
|
/// service's typed 401/403/404/409 responses without introducing duplicate route handlers.
|
||||||
pub trait HttpError {
|
pub trait HttpError {
|
||||||
fn status_code(&self) -> u16;
|
fn status_code(&self) -> u16;
|
||||||
|
|
||||||
|
/// Opt in to an already encoded public response instead of the legacy JSON representation.
|
||||||
|
/// The adapter retains the original error as a server-local `Arc<Self>` extension.
|
||||||
|
/// Returning `None` preserves the existing JSON/status behavior exactly.
|
||||||
|
fn custom_response(&self) -> Option<HttpErrorResponse> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An explicitly encoded HTTP error, independent of any server or client framework.
|
||||||
|
///
|
||||||
|
/// Producers own disclosure and byte limits. Adapters reject non-error statuses and invalid
|
||||||
|
/// content types rather than substituting the legacy representation, which may not be public.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct HttpErrorResponse {
|
||||||
|
pub status: u16,
|
||||||
|
pub content_type: &'static str,
|
||||||
|
pub body: BinaryBody,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Successful JSON response that selects one of an operation's declared success statuses.
|
/// Successful JSON response that selects one of an operation's declared success statuses.
|
||||||
|
|||||||
@@ -39,6 +39,19 @@ pub trait OpenApiSchema: JsonSchema {
|
|||||||
fn openapi_schema_name() -> Cow<'static, str> {
|
fn openapi_schema_name() -> Cow<'static, str> {
|
||||||
Self::schema_name()
|
Self::schema_name()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Select the exact wire schema for an explicitly declared response media type.
|
||||||
|
/// Custom error adapters may have distinct JSON and Problem Details bodies;
|
||||||
|
/// this avoids claiming an ambiguous untagged body union for either medium.
|
||||||
|
fn response_schema(
|
||||||
|
builder: &mut OpenApiBuilder,
|
||||||
|
_content_type: &str,
|
||||||
|
) -> Result<Value, OpenApiError>
|
||||||
|
where
|
||||||
|
Self: Sized,
|
||||||
|
{
|
||||||
|
builder.schema_ref::<Self>()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! impl_openapi_schema {
|
macro_rules! impl_openapi_schema {
|
||||||
@@ -256,7 +269,8 @@ impl OpenApiBuilder {
|
|||||||
Ok(OpenApiDocument(document))
|
Ok(OpenApiDocument(document))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn schema_ref<T: OpenApiSchema>(&mut self) -> Result<Value, OpenApiError> {
|
/// Register an explicitly asserted wire schema, retaining collision and bounds checks.
|
||||||
|
pub fn schema_ref<T: OpenApiSchema>(&mut self) -> Result<Value, OpenApiError> {
|
||||||
let name = T::openapi_schema_name().into_owned();
|
let name = T::openapi_schema_name().into_owned();
|
||||||
validate_component_name(&name)?;
|
validate_component_name(&name)?;
|
||||||
|
|
||||||
@@ -411,13 +425,39 @@ impl OpenApiOperation<'_> {
|
|||||||
content_type: &'static str,
|
content_type: &'static str,
|
||||||
description: &'static str,
|
description: &'static str,
|
||||||
) -> Result<(), OpenApiError> {
|
) -> Result<(), OpenApiError> {
|
||||||
let schema = self.parent.schema_ref::<T>()?;
|
self.response_content_types::<T>(status, &[content_type], description)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Declare one response schema under several explicit representations, atomically.
|
||||||
|
/// This does not change conflict detection for repeated status declarations.
|
||||||
|
pub fn response_content_types<T: OpenApiSchema>(
|
||||||
|
&mut self,
|
||||||
|
status: u16,
|
||||||
|
content_types: &[&str],
|
||||||
|
description: &'static str,
|
||||||
|
) -> Result<(), OpenApiError> {
|
||||||
|
let mut content = Map::new();
|
||||||
|
for content_type in content_types {
|
||||||
|
let schema = T::response_schema(self.parent, content_type)?;
|
||||||
|
if content
|
||||||
|
.insert((*content_type).to_owned(), json!({ "schema": schema }))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(OpenApiError::InvalidContract(format!(
|
||||||
|
"duplicate response content type for `{}`",
|
||||||
|
self.operation_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if content.is_empty() {
|
||||||
|
return Err(OpenApiError::InvalidContract(format!(
|
||||||
|
"response content types are empty for `{}`",
|
||||||
|
self.operation_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
self.insert_response(
|
self.insert_response(
|
||||||
status,
|
status,
|
||||||
json!({
|
json!({ "description": description, "content": content }),
|
||||||
"description": description,
|
|
||||||
"content": { content_type: { "schema": schema } },
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -482,6 +482,17 @@ pub fn encode_binary(value: crate::BinaryBody) -> EncodedBody {
|
|||||||
EncodedBody::binary(value)
|
EncodedBody::binary(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Explicit decoder for APIs declared with `#[api(reqwest, client_error_hook)]` or an
|
||||||
|
/// operation-level `client_error_hook = true` option.
|
||||||
|
///
|
||||||
|
/// There is deliberately no blanket `Deserialize` or `HttpError` bound: legacy client-only
|
||||||
|
/// errors keep serde decoding without implementing this trait. Opted-in decoders receive the
|
||||||
|
/// actual status/headers and bytes collected under the client's configured limit. Returning
|
||||||
|
/// a failure does not imply anything about remote execution or authorize a new request.
|
||||||
|
pub trait HttpClientError: Sized {
|
||||||
|
fn decode_error(response: &ReceivedResponse) -> Result<Self, ClientFailure>;
|
||||||
|
}
|
||||||
|
|
||||||
/// Response received under the configured byte limit. Its content is always redacted in Debug.
|
/// Response received under the configured byte limit. Its content is always redacted in Debug.
|
||||||
pub struct ReceivedResponse {
|
pub struct ReceivedResponse {
|
||||||
status: StatusCode,
|
status: StatusCode,
|
||||||
@@ -494,6 +505,48 @@ impl ReceivedResponse {
|
|||||||
self.status
|
self.status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Borrow bytes already collected under the configured response limit.
|
||||||
|
pub fn body(&self) -> &[u8] {
|
||||||
|
&self.bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Require exactly one valid Content-Type header; ambiguous metadata must not select a codec.
|
||||||
|
pub fn content_type(&self) -> Result<&str, ClientFailure> {
|
||||||
|
let name = "content-type";
|
||||||
|
let mut values = self.headers.get_all(name).iter();
|
||||||
|
let value = values
|
||||||
|
.next()
|
||||||
|
.ok_or(ClientFailure::MissingResponseHeader { name })?;
|
||||||
|
if values.next().is_some() {
|
||||||
|
return Err(ClientFailure::DuplicateResponseHeader { name });
|
||||||
|
}
|
||||||
|
value
|
||||||
|
.to_str()
|
||||||
|
.map_err(|_| ClientFailure::InvalidResponseHeader { name })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode without promoting received data to a public projection or execution result.
|
||||||
|
/// Transport collection and contract budgets are enforced independently.
|
||||||
|
#[cfg(feature = "error-contract")]
|
||||||
|
pub fn decode_problem<D: error_contract::WireDetails>(
|
||||||
|
&self,
|
||||||
|
boundary: &impl crate::http::ApiBoundary<D>,
|
||||||
|
budget: error_contract::Budget,
|
||||||
|
) -> error_contract::DecodeFailure<D> {
|
||||||
|
match self.content_type() {
|
||||||
|
Ok(content_type) => crate::http::decode_problem_response(
|
||||||
|
&self.bytes,
|
||||||
|
self.status.as_u16(),
|
||||||
|
content_type,
|
||||||
|
boundary,
|
||||||
|
budget,
|
||||||
|
),
|
||||||
|
Err(_) => {
|
||||||
|
error_contract::DecodeFailure::Corrupt(error_contract::ContractError::Invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn parse_header<T>(&self, name: &'static str) -> Result<T, ClientFailure>
|
pub fn parse_header<T>(&self, name: &'static str) -> Result<T, ClientFailure>
|
||||||
where
|
where
|
||||||
T: std::str::FromStr,
|
T: std::str::FromStr,
|
||||||
@@ -555,13 +608,32 @@ pub enum ClientFailure {
|
|||||||
Connect,
|
Connect,
|
||||||
Transport,
|
Transport,
|
||||||
Timeout,
|
Timeout,
|
||||||
ResponseTooLarge { limit: usize },
|
ResponseTooLarge {
|
||||||
UnexpectedStatus { expected: u16, actual: u16 },
|
limit: usize,
|
||||||
ErrorResponseDecode { status: u16 },
|
},
|
||||||
Decode { kind: DecodeKind },
|
UnexpectedStatus {
|
||||||
MissingResponseHeader { name: &'static str },
|
expected: u16,
|
||||||
DuplicateResponseHeader { name: &'static str },
|
actual: u16,
|
||||||
InvalidResponseHeader { name: &'static str },
|
},
|
||||||
|
ErrorResponseDecode {
|
||||||
|
status: u16,
|
||||||
|
},
|
||||||
|
/// A bounded, structurally valid error used an unsupported schema or reason.
|
||||||
|
UnsupportedErrorResponse {
|
||||||
|
status: u16,
|
||||||
|
},
|
||||||
|
Decode {
|
||||||
|
kind: DecodeKind,
|
||||||
|
},
|
||||||
|
MissingResponseHeader {
|
||||||
|
name: &'static str,
|
||||||
|
},
|
||||||
|
DuplicateResponseHeader {
|
||||||
|
name: &'static str,
|
||||||
|
},
|
||||||
|
InvalidResponseHeader {
|
||||||
|
name: &'static str,
|
||||||
|
},
|
||||||
UnexpectedBody,
|
UnexpectedBody,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,6 +660,12 @@ impl fmt::Display for ClientFailure {
|
|||||||
"expected HTTP status {expected}, received {actual}"
|
"expected HTTP status {expected}, received {actual}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Self::UnsupportedErrorResponse { status } => {
|
||||||
|
write!(
|
||||||
|
formatter,
|
||||||
|
"HTTP {status} error response contract was unsupported"
|
||||||
|
)
|
||||||
|
}
|
||||||
Self::ErrorResponseDecode { status } => {
|
Self::ErrorResponseDecode { status } => {
|
||||||
write!(formatter, "HTTP {status} error response JSON was malformed")
|
write!(formatter, "HTTP {status} error response JSON was malformed")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,463 @@
|
|||||||
|
//! Connect generated Axum responses to generated Reqwest opt-in decoding over isolated loopback.
|
||||||
|
//! Domain disclosure is a fixture; HTTP encoding, bounded reads and metadata checks are production.
|
||||||
|
#![cfg(all(feature = "axum", feature = "reqwest", feature = "error-contract"))]
|
||||||
|
|
||||||
|
use std::{sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
use api_macros::{
|
||||||
|
HttpErrorResponse, api,
|
||||||
|
reqwest::{ClientError, ClientFailure, HttpClientError, ReceivedResponse},
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
body::{Body, to_bytes},
|
||||||
|
http::{Request, StatusCode},
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::get,
|
||||||
|
};
|
||||||
|
use error_contract::{
|
||||||
|
Browser, Budget, Category, ContractError, DecodeFailure, Disclosure, FailureCause,
|
||||||
|
FailureFacts, FailureIdentity, ProjectFor, ProjectedFailure, WireDetails, project_failure,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct Payload {
|
||||||
|
value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Denied {
|
||||||
|
required_operation: String,
|
||||||
|
}
|
||||||
|
impl FailureCause for Denied {
|
||||||
|
fn identity(&self) -> FailureIdentity {
|
||||||
|
FailureIdentity {
|
||||||
|
domain: "subject",
|
||||||
|
reason: "operation_denied",
|
||||||
|
category: Category::Authorization,
|
||||||
|
details_version: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn validate(&self) -> Result<(), ContractError> {
|
||||||
|
if self.required_operation == "session.record" {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ContractError::InvalidDetails)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl WireDetails for Denied {
|
||||||
|
fn supports(domain: &str, reason: &str, version: u16) -> bool {
|
||||||
|
domain == "subject" && reason == "operation_denied" && version == 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl ProjectFor<Browser> for Denied {
|
||||||
|
type Authorization = ();
|
||||||
|
type Details = Self;
|
||||||
|
fn disclose(&self, _: &FailureFacts<Self>, _: &()) -> Result<Disclosure<Self>, ContractError> {
|
||||||
|
Ok(Disclosure::new(
|
||||||
|
self.clone(),
|
||||||
|
"Subject cannot record a session.",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
struct Boundary;
|
||||||
|
impl api_macros::http::ApiBoundary<Denied> for Boundary {
|
||||||
|
fn classify(&self, _: &Denied) -> error_contract::problem::ProblemClass {
|
||||||
|
error_contract::problem::ProblemClass::authorization_denied()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn budget() -> Budget {
|
||||||
|
Budget::new(4096, 4096)
|
||||||
|
}
|
||||||
|
fn projected() -> ProjectedFailure<Browser, Denied> {
|
||||||
|
project_failure(
|
||||||
|
&FailureFacts::new(Denied {
|
||||||
|
required_operation: "session.record".into(),
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
&(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
fn encoded() -> HttpErrorResponse {
|
||||||
|
api_macros::http::problem_response(&projected(), &Boundary, "urn:yoi:failure:local-1", budget())
|
||||||
|
.unwrap()
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not Clone: the original must be retained without duplicating a domain error.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ServerError {
|
||||||
|
legacy: &'static str,
|
||||||
|
#[serde(skip)]
|
||||||
|
mode: u8,
|
||||||
|
}
|
||||||
|
impl api_macros::HttpError for ServerError {
|
||||||
|
fn status_code(&self) -> u16 {
|
||||||
|
422
|
||||||
|
}
|
||||||
|
fn custom_response(&self) -> Option<HttpErrorResponse> {
|
||||||
|
match self.mode {
|
||||||
|
0 => Some(encoded()),
|
||||||
|
1 => None,
|
||||||
|
mode => Some(HttpErrorResponse {
|
||||||
|
status: if mode == 2 { 200 } else { 403 },
|
||||||
|
content_type: if mode == 3 {
|
||||||
|
"bad\ncontent-type"
|
||||||
|
} else {
|
||||||
|
"application/problem+json"
|
||||||
|
},
|
||||||
|
body: b"private-hook-body".to_vec().into(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl api_macros::HttpRequestError for ServerError {
|
||||||
|
fn from_request_rejection(_: u16, _: String) -> Self {
|
||||||
|
Self {
|
||||||
|
legacy: "never serialize",
|
||||||
|
mode: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[api(axum)]
|
||||||
|
pub trait HookServer {
|
||||||
|
#[get("/problem", status = 200, error_status = 422)]
|
||||||
|
async fn problem(&self) -> Result<Payload, ServerError>;
|
||||||
|
#[post("/reject", error_status = 422, normalize_body_errors = true)]
|
||||||
|
async fn reject(&self, #[body] input: Payload) -> Result<Payload, ServerError>;
|
||||||
|
#[post("/reject-binary", error_status = 422, normalize_body_errors = true)]
|
||||||
|
async fn reject_binary(
|
||||||
|
&self,
|
||||||
|
#[binary] input: api_macros::BinaryBody,
|
||||||
|
) -> Result<Payload, ServerError>;
|
||||||
|
}
|
||||||
|
pub struct Service;
|
||||||
|
impl HookServer for Service {
|
||||||
|
async fn problem(&self) -> Result<Payload, ServerError> {
|
||||||
|
Err(ServerError {
|
||||||
|
legacy: "never serialize",
|
||||||
|
mode: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async fn reject(&self, input: Payload) -> Result<Payload, ServerError> {
|
||||||
|
Ok(input)
|
||||||
|
}
|
||||||
|
async fn reject_binary(&self, _input: api_macros::BinaryBody) -> Result<Payload, ServerError> {
|
||||||
|
Ok(Payload { value: "ok".into() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neither Deserialize nor HttpError: client hooks impose no server or serde error bounds.
|
||||||
|
pub struct DecodedError {
|
||||||
|
operation: String,
|
||||||
|
}
|
||||||
|
impl HttpClientError for DecodedError {
|
||||||
|
fn decode_error(response: &ReceivedResponse) -> Result<Self, ClientFailure> {
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
match response.decode_problem::<Denied>(&Boundary, budget()) {
|
||||||
|
DecodeFailure::Known(failure) => Ok(Self {
|
||||||
|
operation: failure.facts().cause().required_operation.clone(),
|
||||||
|
}),
|
||||||
|
DecodeFailure::Unsupported(_) => {
|
||||||
|
Err(ClientFailure::UnsupportedErrorResponse { status })
|
||||||
|
}
|
||||||
|
DecodeFailure::Corrupt(ContractError::TooLarge) => {
|
||||||
|
Err(ClientFailure::ResponseTooLarge {
|
||||||
|
limit: budget().bytes(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
DecodeFailure::Corrupt(_) => Err(ClientFailure::ErrorResponseDecode { status }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[api(reqwest)]
|
||||||
|
pub trait HookClient {
|
||||||
|
#[get("/problem", status = 200, error_status = 422, client_error_hook = true)]
|
||||||
|
async fn problem(&self) -> Result<Payload, DecodedError>;
|
||||||
|
#[get(
|
||||||
|
"/wire/{mode}",
|
||||||
|
status = 200,
|
||||||
|
error_status = 422,
|
||||||
|
client_error_hook = true
|
||||||
|
)]
|
||||||
|
async fn wire(&self, mode: String) -> Result<Payload, DecodedError>;
|
||||||
|
#[get("/legacy", error_status = 422)]
|
||||||
|
async fn legacy(&self) -> Result<Payload, LegacyClientError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A false method flag cannot disable a trait-wide opt-in; the selection is logical OR.
|
||||||
|
#[api(reqwest, client_error_hook)]
|
||||||
|
pub trait TraitHookClient {
|
||||||
|
#[get("/problem", client_error_hook = false)]
|
||||||
|
async fn problem(&self) -> Result<Payload, DecodedError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client-only legacy errors must still compile and decode without either hook trait.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct LegacyClientError {
|
||||||
|
legacy: String,
|
||||||
|
}
|
||||||
|
#[api(reqwest)]
|
||||||
|
pub trait LegacyClient {
|
||||||
|
#[get("/legacy", error_status = 422)]
|
||||||
|
async fn legacy(&self) -> Result<Payload, LegacyClientError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn generated_server_hook_overrides_wire_encoding_and_retains_arc_for_errors_and_rejections() {
|
||||||
|
let app = HookServerAxum::router(Service).layer(axum::extract::DefaultBodyLimit::max(32));
|
||||||
|
for request in [
|
||||||
|
Request::builder()
|
||||||
|
.uri("/problem")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/reject")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from("{"))
|
||||||
|
.unwrap(),
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/reject-binary")
|
||||||
|
.body(Body::from(vec![0; 64]))
|
||||||
|
.unwrap(),
|
||||||
|
] {
|
||||||
|
let response = app.clone().oneshot(request).await.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
|
assert_eq!(
|
||||||
|
response.headers()["content-type"],
|
||||||
|
"application/problem+json"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
response
|
||||||
|
.extensions()
|
||||||
|
.get::<Arc<ServerError>>()
|
||||||
|
.unwrap()
|
||||||
|
.mode,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
let bytes = to_bytes(response.into_body(), 4096).await.unwrap();
|
||||||
|
let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||||
|
assert_eq!(value["status"], 403);
|
||||||
|
assert_eq!(
|
||||||
|
value["yoi_failure"]["details"]["required_operation"],
|
||||||
|
"session.record"
|
||||||
|
);
|
||||||
|
assert!(!String::from_utf8_lossy(&bytes).contains("never serialize"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn absent_hook_preserves_json_and_invalid_hook_never_falls_back_to_legacy() {
|
||||||
|
let legacy = api_macros::axum::http_error_response(
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
ServerError {
|
||||||
|
legacy: "legacy-value",
|
||||||
|
mode: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(legacy.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
assert_eq!(legacy.headers()["content-type"], "application/json");
|
||||||
|
assert!(legacy.extensions().get::<Arc<ServerError>>().is_some());
|
||||||
|
let body = to_bytes(legacy.into_body(), 4096).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_slice::<serde_json::Value>(&body).unwrap()["legacy"],
|
||||||
|
"legacy-value"
|
||||||
|
);
|
||||||
|
for mode in [2, 3] {
|
||||||
|
let response = api_macros::axum::http_error_response(
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
ServerError {
|
||||||
|
legacy: "private",
|
||||||
|
mode,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
assert!(response.extensions().get::<Arc<ServerError>>().is_none());
|
||||||
|
assert!(
|
||||||
|
to_bytes(response.into_body(), 4096)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn problem_conversion_falls_back_without_exceeding_byte_or_depth_budgets() {
|
||||||
|
for (instance, budget) in [
|
||||||
|
("invalid URI", budget()),
|
||||||
|
("", Budget::new(256, 256)),
|
||||||
|
("", budget().with_depth(1, 1)),
|
||||||
|
] {
|
||||||
|
let response =
|
||||||
|
api_macros::http::problem_response(&projected(), &Boundary, instance, budget).unwrap();
|
||||||
|
assert_eq!(response.status, 500);
|
||||||
|
assert_eq!(
|
||||||
|
response.body(),
|
||||||
|
error_contract::problem::STATIC_PROBLEM_FALLBACK
|
||||||
|
);
|
||||||
|
assert!(response.body().len() <= budget.bytes());
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
api_macros::http::problem_response(&projected(), &Boundary, "", Budget::new(1, 1)),
|
||||||
|
Err(ContractError::TooLarge)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wire(axum::extract::Path(mode): axum::extract::Path<String>) -> axum::response::Response {
|
||||||
|
let mut value: serde_json::Value = serde_json::from_slice(encoded().body.as_ref()).unwrap();
|
||||||
|
let mut status = StatusCode::FORBIDDEN;
|
||||||
|
let mut content_type = "application/problem+json; charset=utf-8";
|
||||||
|
match mode.as_str() {
|
||||||
|
"unknown" => value["yoi_failure"]["reason"] = "future_reason".into(),
|
||||||
|
"version" => value["yoi_failure"]["version"] = 99.into(),
|
||||||
|
"status" => status = StatusCode::CONFLICT,
|
||||||
|
"content-type" => content_type = "application/json",
|
||||||
|
"corrupt" => {
|
||||||
|
return (
|
||||||
|
status,
|
||||||
|
[("content-type", content_type)],
|
||||||
|
"{private-raw-body",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
"large" => {
|
||||||
|
return (status, [("content-type", content_type)], "x".repeat(8192)).into_response();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
let mut response = (
|
||||||
|
status,
|
||||||
|
[("content-type", content_type)],
|
||||||
|
serde_json::to_vec(&value).unwrap(),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
if mode == "duplicate" {
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.append("content-type", "application/json".parse().unwrap());
|
||||||
|
}
|
||||||
|
if mode == "missing" {
|
||||||
|
response.headers_mut().remove("content-type");
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
|
struct ServerTask(tokio::task::JoinHandle<()>);
|
||||||
|
impl Drop for ServerTask {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async fn serve() -> (String, ServerTask) {
|
||||||
|
let app = HookServerAxum::router(Service)
|
||||||
|
.route("/wire/{mode}", get(wire))
|
||||||
|
.route(
|
||||||
|
"/legacy",
|
||||||
|
get(|| async {
|
||||||
|
api_macros::axum::http_error_response(
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
ServerError {
|
||||||
|
legacy: "legacy-value",
|
||||||
|
mode: 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
let task = ServerTask(tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
}));
|
||||||
|
(url, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn generated_client_hook_uses_actual_metadata_and_preserves_unknown_corrupt_and_size_failures()
|
||||||
|
{
|
||||||
|
let (url, _server) = serve().await;
|
||||||
|
let client = HookClientClient::builder(&url)
|
||||||
|
.unwrap()
|
||||||
|
.request_timeout(Duration::from_secs(5))
|
||||||
|
.response_body_limit(16384)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
match client.problem().await {
|
||||||
|
Err(ClientError::Public { status, error }) => {
|
||||||
|
assert_eq!(status.as_u16(), 403);
|
||||||
|
assert_eq!(error.operation, "session.record");
|
||||||
|
}
|
||||||
|
_ => panic!("expected a typed problem"),
|
||||||
|
}
|
||||||
|
for mode in ["unknown", "version"] {
|
||||||
|
assert!(matches!(
|
||||||
|
client.wire(mode.into()).await,
|
||||||
|
Err(ClientError::Failure(
|
||||||
|
ClientFailure::UnsupportedErrorResponse { status: 403 }
|
||||||
|
))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for (mode, expected) in [
|
||||||
|
("status", 409),
|
||||||
|
("content-type", 403),
|
||||||
|
("corrupt", 403),
|
||||||
|
("duplicate", 403),
|
||||||
|
("missing", 403),
|
||||||
|
] {
|
||||||
|
let result = client.wire(mode.into()).await;
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(ClientError::Failure(ClientFailure::ErrorResponseDecode { status })) if status == expected),
|
||||||
|
"mode={mode}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Contract budget and streaming transport budget are independent, bounded failures.
|
||||||
|
assert!(matches!(
|
||||||
|
client.wire("large".into()).await,
|
||||||
|
Err(ClientError::Failure(ClientFailure::ResponseTooLarge {
|
||||||
|
limit: 4096
|
||||||
|
}))
|
||||||
|
));
|
||||||
|
let small_client = HookClientClient::builder(&url)
|
||||||
|
.unwrap()
|
||||||
|
.request_timeout(Duration::from_secs(5))
|
||||||
|
.response_body_limit(128)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
small_client.problem().await,
|
||||||
|
Err(ClientError::Failure(ClientFailure::ResponseTooLarge {
|
||||||
|
limit: 128
|
||||||
|
}))
|
||||||
|
));
|
||||||
|
match client.legacy().await {
|
||||||
|
Err(ClientError::Public { status, error }) => {
|
||||||
|
assert_eq!(status.as_u16(), 422);
|
||||||
|
assert_eq!(error.legacy, "legacy-value");
|
||||||
|
}
|
||||||
|
_ => panic!("unrelated error on the same trait must keep serde behavior"),
|
||||||
|
}
|
||||||
|
let trait_client = TraitHookClientClient::builder(&url)
|
||||||
|
.unwrap()
|
||||||
|
.request_timeout(Duration::from_secs(5))
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(trait_client.problem().await, Err(ClientError::Public { error, .. }) if error.operation == "session.record")
|
||||||
|
);
|
||||||
|
let legacy = LegacyClientClient::builder(&url)
|
||||||
|
.unwrap()
|
||||||
|
.request_timeout(Duration::from_secs(5))
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
match legacy.legacy().await {
|
||||||
|
Err(ClientError::Public { status, error }) => {
|
||||||
|
assert_eq!(status.as_u16(), 422);
|
||||||
|
assert_eq!(error.legacy, "legacy-value");
|
||||||
|
}
|
||||||
|
_ => panic!("legacy client-only error must keep serde behavior"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
use api_macros::{
|
use api_macros::{
|
||||||
BinaryBody, api,
|
BinaryBody, api,
|
||||||
openapi::{OpenApiInfo, OpenApiSchema},
|
openapi::{OpenApiBuilder, OpenApiInfo, OpenApiSchema},
|
||||||
};
|
};
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
@@ -465,3 +465,154 @@ fn file_export_writes_the_canonical_document() {
|
|||||||
std::fs::remove_file(path).expect("remove export");
|
std::fs::remove_file(path).expect("remove export");
|
||||||
assert_eq!(written, document.to_json().expect("canonical JSON"));
|
assert_eq!(written, document.to_json().expect("canonical JSON"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
#[serde(untagged, deny_unknown_fields)]
|
||||||
|
pub enum ProblemOrLegacy {
|
||||||
|
Legacy { code: String },
|
||||||
|
Problem { status: u16, detail: String },
|
||||||
|
}
|
||||||
|
impl JsonSchema for ProblemOrLegacy {
|
||||||
|
fn schema_name() -> std::borrow::Cow<'static, str> {
|
||||||
|
"ProblemOrLegacy".into()
|
||||||
|
}
|
||||||
|
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
|
||||||
|
// The owner explicitly attests disjoint closed object shapes instead of admitting anyOf.
|
||||||
|
schemars::json_schema!({ "oneOf": [
|
||||||
|
{ "type": "object", "required": ["code"], "additionalProperties": false,
|
||||||
|
"properties": { "code": { "type": "string" } } },
|
||||||
|
{ "type": "object", "required": ["status", "detail"], "additionalProperties": false,
|
||||||
|
"properties": { "status": { "type": "integer", "format": "uint16" }, "detail": { "type": "string" } } }
|
||||||
|
] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl OpenApiSchema for ProblemOrLegacy {}
|
||||||
|
|
||||||
|
#[api(openapi)]
|
||||||
|
pub trait ErrorMediaApi {
|
||||||
|
#[post(
|
||||||
|
"/create",
|
||||||
|
error_status = 403,
|
||||||
|
additional_error_statuses = [409, 504],
|
||||||
|
additional_error_content_types = ["Application/Problem+JSON"]
|
||||||
|
)]
|
||||||
|
async fn create(&self) -> Result<Widget, ProblemOrLegacy>;
|
||||||
|
|
||||||
|
#[get("/legacy", error_status = 422)]
|
||||||
|
async fn legacy(&self) -> Result<Widget, PublicError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn additional_error_media_share_the_declared_schema_for_every_error_status_only() {
|
||||||
|
use api_macros::{ApiContract, Operation};
|
||||||
|
let document = error_media_api_openapi(OpenApiInfo {
|
||||||
|
title: "Error media",
|
||||||
|
version: "1",
|
||||||
|
source_digest: "fixture:error-media",
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let value = document.as_value();
|
||||||
|
let responses = &value["paths"]["/create"]["post"]["responses"];
|
||||||
|
for status in ["403", "409", "504"] {
|
||||||
|
let content = responses[status]["content"].as_object().unwrap();
|
||||||
|
assert_eq!(content.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
content["application/json"]["schema"],
|
||||||
|
json!({ "$ref": "#/components/schemas/ProblemOrLegacy" })
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
content["application/problem+json"],
|
||||||
|
content["application/json"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(responses["200"]["content"].as_object().unwrap().len(), 1);
|
||||||
|
assert!(
|
||||||
|
responses["200"]["content"]
|
||||||
|
.get("application/problem+json")
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
value["paths"]["/legacy"]["get"]["responses"]["422"]["content"]
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert!(value["components"]["schemas"]["ProblemOrLegacy"]["oneOf"].is_array());
|
||||||
|
let parsed = oas3::from_json(&document.to_json().unwrap()).unwrap();
|
||||||
|
assert_eq!(parsed.validate_version().unwrap().major, 3);
|
||||||
|
|
||||||
|
let operation = error_media_api_operations::Create::METADATA;
|
||||||
|
assert_eq!(
|
||||||
|
operation
|
||||||
|
.error_responses
|
||||||
|
.iter()
|
||||||
|
.map(|response| response.status)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[403, 409, 504]
|
||||||
|
);
|
||||||
|
for response in operation.error_responses {
|
||||||
|
assert_eq!(
|
||||||
|
response.content_types,
|
||||||
|
["application/json", "application/problem+json"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
operation.success_responses[0].content_types,
|
||||||
|
["application/json"]
|
||||||
|
);
|
||||||
|
let legacy = ErrorMediaApiMetadata::OPERATIONS
|
||||||
|
.iter()
|
||||||
|
.find(|operation| operation.path == "/legacy")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
legacy.error_responses[0].content_types,
|
||||||
|
["application/json"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(JsonSchema)]
|
||||||
|
pub struct MediaSpecificError;
|
||||||
|
impl OpenApiSchema for MediaSpecificError {
|
||||||
|
fn response_schema(
|
||||||
|
builder: &mut OpenApiBuilder,
|
||||||
|
content_type: &str,
|
||||||
|
) -> Result<Value, api_macros::openapi::OpenApiError> {
|
||||||
|
match content_type {
|
||||||
|
"application/json" => builder.schema_ref::<PublicError>(),
|
||||||
|
"application/problem+json" => builder.schema_ref::<ProblemOrLegacy>(),
|
||||||
|
_ => unreachable!("only declared media are selected"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[api(openapi)]
|
||||||
|
pub trait MediaSpecificApi {
|
||||||
|
#[post("/media", error_status = 409, additional_error_content_types = ["application/problem+json"])]
|
||||||
|
async fn create(&self) -> Result<Widget, MediaSpecificError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicitly_different_error_representations_register_their_actual_schema_closures() {
|
||||||
|
let document = media_specific_api_openapi(OpenApiInfo {
|
||||||
|
title: "Media-specific",
|
||||||
|
version: "1",
|
||||||
|
source_digest: "fixture:media-specific",
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let value = document.as_value();
|
||||||
|
let content = &value["paths"]["/media"]["post"]["responses"]["409"]["content"];
|
||||||
|
assert_eq!(
|
||||||
|
content["application/json"]["schema"],
|
||||||
|
json!({"$ref":"#/components/schemas/PublicError"})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
content["application/problem+json"]["schema"],
|
||||||
|
json!({"$ref":"#/components/schemas/ProblemOrLegacy"})
|
||||||
|
);
|
||||||
|
assert!(value["components"]["schemas"]["MediaSpecificError"].is_null());
|
||||||
|
oas3::from_json(&document.to_json().unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.validate_version()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
use api_macros::api;
|
||||||
|
|
||||||
|
#[api(client_error_hook)]
|
||||||
|
pub trait Invalid {}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
error: `client_error_hook` requires the `reqwest` adapter
|
||||||
|
--> tests/ui/client_hook_requires_reqwest.rs:3:1
|
||||||
|
|
|
||||||
|
3 | #[api(client_error_hook)]
|
||||||
|
| ^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
|
||||||
|
= note: this error originates in the attribute macro `api` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
use api_macros::api;
|
||||||
|
|
||||||
|
pub struct Error;
|
||||||
|
|
||||||
|
#[api]
|
||||||
|
pub trait Wildcard {
|
||||||
|
#[get("/wildcard", additional_error_content_types = ["application/*"])]
|
||||||
|
async fn wildcard(&self) -> Result<(), Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[api]
|
||||||
|
pub trait DuplicateMedia {
|
||||||
|
#[get("/duplicate", additional_error_content_types = ["application/problem+json", "APPLICATION/PROBLEM+JSON"])]
|
||||||
|
async fn duplicate(&self) -> Result<(), Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[api]
|
||||||
|
pub trait DefaultMedia {
|
||||||
|
#[get("/default", additional_error_content_types = ["application/json"])]
|
||||||
|
async fn default_media(&self) -> Result<(), Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[api]
|
||||||
|
pub trait NoError {
|
||||||
|
#[get("/infallible", additional_error_content_types = ["application/problem+json"])]
|
||||||
|
async fn infallible(&self);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[api]
|
||||||
|
pub trait EmptyMedia {
|
||||||
|
#[get("/empty", additional_error_content_types = [])]
|
||||||
|
async fn empty(&self) -> Result<(), Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[api]
|
||||||
|
pub trait DuplicateOption {
|
||||||
|
#[get("/duplicate-option", additional_error_content_types = [], additional_error_content_types = [])]
|
||||||
|
async fn duplicate_option(&self) -> Result<(), Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[api]
|
||||||
|
pub trait ParameterizedMedia {
|
||||||
|
#[get("/parameterized", additional_error_content_types = ["application/problem+json; charset=utf-8"])]
|
||||||
|
async fn parameterized(&self) -> Result<(), Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
error: error content type must be a concrete type/subtype without parameters
|
||||||
|
--> tests/ui/invalid_error_media.rs:7:58
|
||||||
|
|
|
||||||
|
7 | #[get("/wildcard", additional_error_content_types = ["application/*"])]
|
||||||
|
| ^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
error: additional error content types must be distinct from each other and application/json
|
||||||
|
--> tests/ui/invalid_error_media.rs:13:87
|
||||||
|
|
|
||||||
|
13 | #[get("/duplicate", additional_error_content_types = ["application/problem+json", "APPLICATION/PROBLEM+JSON"])]
|
||||||
|
| ^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
error: additional error content types must be distinct from each other and application/json
|
||||||
|
--> tests/ui/invalid_error_media.rs:19:57
|
||||||
|
|
|
||||||
|
19 | #[get("/default", additional_error_content_types = ["application/json"])]
|
||||||
|
| ^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
error: additional_error_content_types require a public error response type
|
||||||
|
--> tests/ui/invalid_error_media.rs:25:11
|
||||||
|
|
|
||||||
|
25 | #[get("/infallible", additional_error_content_types = ["application/problem+json"])]
|
||||||
|
| ^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
error: additional_error_content_types must not be empty
|
||||||
|
--> tests/ui/invalid_error_media.rs:31:11
|
||||||
|
|
|
||||||
|
31 | #[get("/empty", additional_error_content_types = [])]
|
||||||
|
| ^^^^^^^^
|
||||||
|
|
||||||
|
error: duplicate `additional_error_content_types` route option
|
||||||
|
--> tests/ui/invalid_error_media.rs:37:69
|
||||||
|
|
|
||||||
|
37 | #[get("/duplicate-option", additional_error_content_types = [], additional_error_content_types = [])]
|
||||||
|
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
error: error content type must be a concrete type/subtype without parameters
|
||||||
|
--> tests/ui/invalid_error_media.rs:43:63
|
||||||
|
|
|
||||||
|
43 | #[get("/parameterized", additional_error_content_types = ["application/problem+json; charset=utf-8"])]
|
||||||
|
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
use api_macros::api;
|
||||||
|
|
||||||
|
pub struct Error;
|
||||||
|
|
||||||
|
#[api]
|
||||||
|
pub trait NoReqwest {
|
||||||
|
#[get("/error", client_error_hook = true)]
|
||||||
|
async fn error(&self) -> Result<(), Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[api(reqwest)]
|
||||||
|
pub trait NoError {
|
||||||
|
#[post("/infallible", client_error_hook = true)]
|
||||||
|
async fn infallible(&self);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[api(reqwest)]
|
||||||
|
pub trait DuplicateHook {
|
||||||
|
#[get("/duplicate", client_error_hook = true, client_error_hook = false)]
|
||||||
|
async fn duplicate(&self) -> Result<(), Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
error: client_error_hook requires the `reqwest` adapter
|
||||||
|
--> tests/ui/operation_hook_invalid_combinations.rs:7:41
|
||||||
|
|
|
||||||
|
7 | #[get("/error", client_error_hook = true)]
|
||||||
|
| ^^^^
|
||||||
|
|
||||||
|
error: client_error_hook requires a public error response type
|
||||||
|
--> tests/ui/operation_hook_invalid_combinations.rs:13:47
|
||||||
|
|
|
||||||
|
13 | #[post("/infallible", client_error_hook = true)]
|
||||||
|
| ^^^^
|
||||||
|
|
||||||
|
error: duplicate `client_error_hook` route option
|
||||||
|
--> tests/ui/operation_hook_invalid_combinations.rs:19:51
|
||||||
|
|
|
||||||
|
19 | #[get("/duplicate", client_error_hook = true, client_error_hook = false)]
|
||||||
|
| ^^^^^^^^^^^^^^^^^
|
||||||
@@ -253,6 +253,7 @@ pub enum BackendRuntimeClientError {
|
|||||||
Api(BackendApiClientError),
|
Api(BackendApiClientError),
|
||||||
SessionApi(server_api::ServerApiClientError),
|
SessionApi(server_api::ServerApiClientError),
|
||||||
ContractApi(server_api::client_support::ClientError<server_api::RepositoryApiError>),
|
ContractApi(server_api::client_support::ClientError<server_api::RepositoryApiError>),
|
||||||
|
WorkerCreateApi(server_api::client_support::ClientError<server_api::WorkerCreateApiError>),
|
||||||
Http(reqwest::Error),
|
Http(reqwest::Error),
|
||||||
Protocol(String),
|
Protocol(String),
|
||||||
}
|
}
|
||||||
@@ -264,6 +265,7 @@ impl fmt::Display for BackendRuntimeClientError {
|
|||||||
Self::Api(error) => write!(f, "{error}"),
|
Self::Api(error) => write!(f, "{error}"),
|
||||||
Self::SessionApi(error) => write!(f, "{error}"),
|
Self::SessionApi(error) => write!(f, "{error}"),
|
||||||
Self::ContractApi(error) => write!(f, "{error}"),
|
Self::ContractApi(error) => write!(f, "{error}"),
|
||||||
|
Self::WorkerCreateApi(error) => write!(f, "{error}"),
|
||||||
Self::Http(error) => write!(f, "{error}"),
|
Self::Http(error) => write!(f, "{error}"),
|
||||||
Self::Protocol(message) => f.write_str(message),
|
Self::Protocol(message) => f.write_str(message),
|
||||||
}
|
}
|
||||||
@@ -1016,3 +1018,13 @@ mod tests {
|
|||||||
assert!(serde_json::from_value::<BackendWorkerSummary>(stale).is_err());
|
assert!(serde_json::from_value::<BackendWorkerSummary>(stale).is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<server_api::client_support::ClientError<server_api::WorkerCreateApiError>>
|
||||||
|
for BackendRuntimeClientError
|
||||||
|
{
|
||||||
|
fn from(
|
||||||
|
error: server_api::client_support::ClientError<server_api::WorkerCreateApiError>,
|
||||||
|
) -> Self {
|
||||||
|
Self::WorkerCreateApi(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ pub enum BackendWorkspaceClientError {
|
|||||||
Api(BackendApiClientError),
|
Api(BackendApiClientError),
|
||||||
Http(reqwest::Error),
|
Http(reqwest::Error),
|
||||||
ServerApi(server_api::client_support::ClientError<server_api::RepositoryApiError>),
|
ServerApi(server_api::client_support::ClientError<server_api::RepositoryApiError>),
|
||||||
|
WorkerCreateApi(server_api::client_support::ClientError<server_api::WorkerCreateApiError>),
|
||||||
RuntimeManagementApi(
|
RuntimeManagementApi(
|
||||||
server_api::client_support::ClientError<server_api::RuntimeManagementApiError>,
|
server_api::client_support::ClientError<server_api::RuntimeManagementApiError>,
|
||||||
),
|
),
|
||||||
@@ -51,6 +52,7 @@ impl fmt::Display for BackendWorkspaceClientError {
|
|||||||
Self::Api(error) => write!(f, "{error}"),
|
Self::Api(error) => write!(f, "{error}"),
|
||||||
Self::Http(error) => write!(f, "{error}"),
|
Self::Http(error) => write!(f, "{error}"),
|
||||||
Self::ServerApi(error) => write!(f, "{error}"),
|
Self::ServerApi(error) => write!(f, "{error}"),
|
||||||
|
Self::WorkerCreateApi(error) => write!(f, "{error}"),
|
||||||
Self::RuntimeManagementApi(error) => write!(f, "{error}"),
|
Self::RuntimeManagementApi(error) => write!(f, "{error}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -454,3 +456,31 @@ mod tests {
|
|||||||
assert_eq!(retry, request);
|
assert_eq!(retry, request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn worker_create_client_error(
|
||||||
|
backend: &BackendApiClient,
|
||||||
|
error: server_api::client_support::ClientError<server_api::WorkerCreateApiError>,
|
||||||
|
) -> BackendWorkspaceClientError {
|
||||||
|
use server_api::client_support::ClientError;
|
||||||
|
// Retain typed startup facts even for an operator denial. A Subject execution
|
||||||
|
// condition is not a refusal of this client's credential or a login prompt.
|
||||||
|
match &error {
|
||||||
|
ClientError::Public {
|
||||||
|
status,
|
||||||
|
error: response,
|
||||||
|
} if response.failure().is_none() && *status == reqwest::StatusCode::UNAUTHORIZED => {
|
||||||
|
BackendWorkspaceClientError::Api(BackendApiClientError::Unauthorized {
|
||||||
|
origin: backend.origin().clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ClientError::Public {
|
||||||
|
status,
|
||||||
|
error: response,
|
||||||
|
} if response.failure().is_none() && *status == reqwest::StatusCode::FORBIDDEN => {
|
||||||
|
BackendWorkspaceClientError::Api(BackendApiClientError::Forbidden {
|
||||||
|
origin: backend.origin().clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => BackendWorkspaceClientError::WorkerCreateApi(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,6 +81,23 @@ impl BackendWorkspaceProductClient {
|
|||||||
server_api::client_support::ClientError<server_api::RepositoryApiError>,
|
server_api::client_support::ClientError<server_api::RepositoryApiError>,
|
||||||
>,
|
>,
|
||||||
>,
|
>,
|
||||||
|
{
|
||||||
|
self.generated_normalized(operation, crate::backend_workspace::server_client_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generated_normalized<T, E, F, Fut>(
|
||||||
|
&self,
|
||||||
|
operation: F,
|
||||||
|
normalize: impl FnOnce(
|
||||||
|
&BackendApiClient,
|
||||||
|
server_api::client_support::ClientError<E>,
|
||||||
|
) -> BackendWorkspaceClientError,
|
||||||
|
) -> Result<T, BackendWorkspaceClientError>
|
||||||
|
where
|
||||||
|
F: FnOnce(
|
||||||
|
server_api::ServerApiClient<crate::backend_workspace::ServerBearerAuthorizer>,
|
||||||
|
) -> Fut,
|
||||||
|
Fut: std::future::Future<Output = Result<T, server_api::client_support::ClientError<E>>>,
|
||||||
{
|
{
|
||||||
let client = crate::backend_workspace::server_api_client(&self.api)?;
|
let client = crate::backend_workspace::server_api_client(&self.api)?;
|
||||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
@@ -89,7 +106,7 @@ impl BackendWorkspaceProductClient {
|
|||||||
.map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?;
|
.map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?;
|
||||||
runtime
|
runtime
|
||||||
.block_on(operation(client))
|
.block_on(operation(client))
|
||||||
.map_err(|error| crate::backend_workspace::server_client_error(&self.api, error))
|
.map_err(|error| normalize(&self.api, error))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_ticket_link_summary(
|
pub fn read_ticket_link_summary(
|
||||||
@@ -584,9 +601,9 @@ impl BackendWorkspaceProductClient {
|
|||||||
execution_grant: None,
|
execution_grant: None,
|
||||||
};
|
};
|
||||||
let workspace_id = self.workspace_id.clone();
|
let workspace_id = self.workspace_id.clone();
|
||||||
let response: BrowserCreateWorkerResponse = self.generated(move |client| async move {
|
let response: BrowserCreateWorkerResponse = self.generated_normalized(move |client| async move {
|
||||||
client.workspace_worker_create(workspace_id, request).await
|
client.workspace_worker_create(workspace_id, request).await
|
||||||
})?;
|
}, crate::backend_workspace::worker_create_client_error)?;
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"Started Intake Worker {}/{} for Ticket {ticket_id}",
|
"Started Intake Worker {}/{} for Ticket {ticket_id}",
|
||||||
response.runtime_id, response.worker_id
|
response.runtime_id, response.worker_id
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "content-history-adapters"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
content-history.workspace = true
|
||||||
|
config-source.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# content-history-adapters
|
||||||
|
|
||||||
|
Contract examples mapping configuration sources and Ticket compound documents onto the independent content-history model. These adapters prepare immutable candidates and restore complete content; they do not save, authorize, evaluate, adopt, or migrate business data.
|
||||||
|
|
||||||
|
- [`settings`] preserves virtual paths, full UTF-8 sources, and explicit entry metadata.
|
||||||
|
- [`ticket`] separates document content and original authorship from business state and edit history.
|
||||||
|
- [`Snapshot`] supplies a candidate root and objects for caller-owned store preparation. A successful preparation is not business adoption; the caller must validate and atomically adopt source, projection, and history head in its own transaction.
|
||||||
|
|
||||||
|
Readers must be scoped to an authorized object/root by the store. Knowing a hash is not authorization. Missing or erased content must fail restoration rather than be reconstructed from another scope. Retention, explicit erasure, expected-history-head CAS, and new authenticated editor history on restoration belong to the store and caller, not these examples.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
use config_source::{ConfigContentType, ConfigEntry, ConfigTreeSnapshot, VirtualPath};
|
||||||
|
use content_history_adapters::{settings, ticket};
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let sources = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
|
||||||
|
VirtualPath::parse("nested/main.dcdl")?,
|
||||||
|
ConfigContentType::Decodal,
|
||||||
|
"// preserve this comment\r\n{}\r\n",
|
||||||
|
)?])?;
|
||||||
|
let config = settings::prepare(&sources)?;
|
||||||
|
assert_eq!(
|
||||||
|
settings::restore(config.root, |id| config.get(id))?,
|
||||||
|
sources
|
||||||
|
);
|
||||||
|
|
||||||
|
let document = ticket::Document {
|
||||||
|
ticket_id: "T-example".into(),
|
||||||
|
title: "History contract".into(),
|
||||||
|
initial_author: "user:original".into(),
|
||||||
|
initial_body: "Full text\r\n".into(),
|
||||||
|
posts: vec![ticket::Post {
|
||||||
|
id: "stable-post-id".into(),
|
||||||
|
author: "user:other".into(),
|
||||||
|
body: "Reply\n".into(),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let candidate = ticket::prepare(&document)?;
|
||||||
|
assert_eq!(
|
||||||
|
ticket::restore(candidate.root, "T-example", |id| candidate.get(id))?,
|
||||||
|
document
|
||||||
|
);
|
||||||
|
// Pass candidate.root and candidate.objects to store preparation in a caller-owned transaction.
|
||||||
|
// Do not interpret successful preparation as business adoption or change an authoritative head.
|
||||||
|
println!(
|
||||||
|
"config candidate: {}; Ticket candidate: {}",
|
||||||
|
config.root, candidate.root
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
#![doc = include_str!("../README.md")]
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use content_history::{BlobId, ContentId, Entry, Object, ObjectId, Tree, TreeId};
|
||||||
|
|
||||||
|
pub mod settings;
|
||||||
|
pub mod ticket;
|
||||||
|
|
||||||
|
/// Complete immutable candidate for store preparation, not a saved or adopted version.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Snapshot {
|
||||||
|
pub root: TreeId,
|
||||||
|
pub objects: BTreeMap<ObjectId, Object>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Snapshot {
|
||||||
|
/// In-memory reader for examples. Production readers must enforce authorization and erasure.
|
||||||
|
pub fn get(&self, id: ObjectId) -> Result<Object, Error> {
|
||||||
|
self.objects.get(&id).cloned().ok_or(Error::Missing(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restoration fails closed: no partial document is returned on any read or contract error.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error(transparent)]
|
||||||
|
Model(#[from] content_history::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
Config(#[from] config_source::ConfigTreeError),
|
||||||
|
#[error("missing object: {0:?}")]
|
||||||
|
Missing(ObjectId),
|
||||||
|
#[error("reader returned an object not matching requested identity: {0:?}")]
|
||||||
|
HashMismatch(ObjectId),
|
||||||
|
#[error("invalid adapter structure: {0}")]
|
||||||
|
Structure(&'static str),
|
||||||
|
#[error("unsupported adapter format")]
|
||||||
|
Format,
|
||||||
|
#[error("content is not UTF-8")]
|
||||||
|
Utf8(#[from] std::string::FromUtf8Error),
|
||||||
|
#[error("file/directory conflict: {0}")]
|
||||||
|
PathConflict(String),
|
||||||
|
#[error("Ticket identity does not match the restoration target")]
|
||||||
|
TicketIdentity,
|
||||||
|
#[error("authorized reader failed: {0}")]
|
||||||
|
Read(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Builder {
|
||||||
|
objects: BTreeMap<ObjectId, Object>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Builder {
|
||||||
|
fn blob(&mut self, bytes: impl AsRef<[u8]>) -> ContentId {
|
||||||
|
let object = Object::Blob(bytes.as_ref().to_vec());
|
||||||
|
let ObjectId::Blob(id) = object.id() else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
self.objects.insert(object.id(), object);
|
||||||
|
ContentId::Blob(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tree(&mut self, tree: Tree) -> TreeId {
|
||||||
|
let object = Object::Tree(tree);
|
||||||
|
let ObjectId::Tree(id) = object.id() else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
self.objects.insert(object.id(), object);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map(&mut self, entries: Vec<Entry>) -> Result<TreeId, Error> {
|
||||||
|
Ok(self.tree(Tree::map(entries)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(self, root: TreeId) -> Snapshot {
|
||||||
|
Snapshot {
|
||||||
|
root,
|
||||||
|
objects: self.objects,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry(key: impl Into<String>, target: ContentId) -> Entry {
|
||||||
|
Entry {
|
||||||
|
key: key.into(),
|
||||||
|
target,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_object(
|
||||||
|
read: &mut impl FnMut(ObjectId) -> Result<Object, Error>,
|
||||||
|
id: ObjectId,
|
||||||
|
) -> Result<Object, Error> {
|
||||||
|
let object = read(id)?;
|
||||||
|
if object.id() != id {
|
||||||
|
return Err(Error::HashMismatch(id));
|
||||||
|
}
|
||||||
|
Ok(object)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_tree(
|
||||||
|
read: &mut impl FnMut(ObjectId) -> Result<Object, Error>,
|
||||||
|
id: TreeId,
|
||||||
|
ordered: bool,
|
||||||
|
) -> Result<Tree, Error> {
|
||||||
|
let Object::Tree(tree) = read_object(read, ObjectId::Tree(id))? else {
|
||||||
|
return Err(Error::Structure("expected tree"));
|
||||||
|
};
|
||||||
|
if tree.is_ordered() != ordered {
|
||||||
|
return Err(Error::Structure("incorrect tree ordering"));
|
||||||
|
}
|
||||||
|
Ok(tree)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tree_id(target: ContentId) -> Result<TreeId, Error> {
|
||||||
|
match target {
|
||||||
|
ContentId::Tree(id) => Ok(id),
|
||||||
|
_ => Err(Error::Structure("expected tree target")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blob_id(target: ContentId) -> Result<BlobId, Error> {
|
||||||
|
match target {
|
||||||
|
ContentId::Blob(id) => Ok(id),
|
||||||
|
_ => Err(Error::Structure("expected blob target")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(
|
||||||
|
read: &mut impl FnMut(ObjectId) -> Result<Object, Error>,
|
||||||
|
target: ContentId,
|
||||||
|
) -> Result<String, Error> {
|
||||||
|
let Object::Blob(bytes) = read_object(read, ObjectId::Blob(blob_id(target)?))? else {
|
||||||
|
return Err(Error::Structure("expected blob"));
|
||||||
|
};
|
||||||
|
Ok(String::from_utf8(bytes)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fields(tree: &Tree, expected: &[&str]) -> Result<(), Error> {
|
||||||
|
if tree.is_ordered()
|
||||||
|
|| tree.entries().len() != expected.len()
|
||||||
|
|| expected
|
||||||
|
.iter()
|
||||||
|
.any(|key| !tree.entries().iter().any(|e| e.key == *key))
|
||||||
|
{
|
||||||
|
return Err(Error::Structure("unexpected fields"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field(tree: &Tree, key: &str) -> Result<ContentId, Error> {
|
||||||
|
tree.entries()
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.key == key)
|
||||||
|
.map(|e| e.target)
|
||||||
|
.ok_or(Error::Structure("missing field"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
//! Configuration content is a real directory/name Merkle hierarchy with full-source blob leaves.
|
||||||
|
//!
|
||||||
|
//! The versioned root separates `files` from `types`: explicit content types are keyed by full
|
||||||
|
//! virtual path in `types`. A type change therefore changes the root without changing source bytes.
|
||||||
|
//! Types must not be guessed from suffixes: [`ConfigEntry`] permits explicit metadata independent
|
||||||
|
//! of a filename. Digests are reconstructed through config-source, never substituted by Merkle IDs.
|
||||||
|
//!
|
||||||
|
//! Preparation validates the original snapshot, then rejects file/directory prefix conflicts that
|
||||||
|
//! a flat snapshot can represent but a directory tree cannot. Restoration rejects empty non-root
|
||||||
|
//! directories, malformed paths, unknown types, surplus/missing metadata, invalid UTF-8 and limits.
|
||||||
|
//! Both directions use existing config-source validation; neither parses nor normalizes Decodal.
|
||||||
|
//! Evaluation, policy checks and transactional adoption remain the caller's responsibility.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use config_source::{
|
||||||
|
ConfigContentType, ConfigEntry, ConfigTreeSnapshot, MAX_ENTRY_COUNT, MAX_PATH_BYTES,
|
||||||
|
MAX_TOTAL_BYTES, VirtualPath,
|
||||||
|
};
|
||||||
|
use content_history::{ContentId, Object, ObjectId, TreeId};
|
||||||
|
|
||||||
|
use crate::{Builder, Error, Snapshot, entry, field, fields, read_tree, text, tree_id};
|
||||||
|
|
||||||
|
const FORMAT: &str = "config-tree/v1";
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Directory {
|
||||||
|
children: BTreeMap<String, Node>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Node {
|
||||||
|
File(ContentId),
|
||||||
|
Directory(Directory),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Directory {
|
||||||
|
fn insert(&mut self, parts: &[&str], target: ContentId, path: &str) -> Result<(), Error> {
|
||||||
|
let name = parts[0];
|
||||||
|
if parts.len() == 1 {
|
||||||
|
if self
|
||||||
|
.children
|
||||||
|
.insert(name.into(), Node::File(target))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(Error::PathConflict(path.into()));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let child = self
|
||||||
|
.children
|
||||||
|
.entry(name.into())
|
||||||
|
.or_insert_with(|| Node::Directory(Directory::default()));
|
||||||
|
match child {
|
||||||
|
Node::Directory(directory) => directory.insert(&parts[1..], target, path)?,
|
||||||
|
Node::File(_) => return Err(Error::PathConflict(path.into())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(self, builder: &mut Builder) -> Result<TreeId, Error> {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for (name, node) in self.children {
|
||||||
|
let target = match node {
|
||||||
|
Node::File(target) => target,
|
||||||
|
Node::Directory(directory) => ContentId::Tree(directory.build(builder)?),
|
||||||
|
};
|
||||||
|
entries.push(entry(name, target));
|
||||||
|
}
|
||||||
|
builder.map(entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prepare objects only after validating all original snapshot metadata.
|
||||||
|
pub fn prepare(snapshot: &ConfigTreeSnapshot) -> Result<Snapshot, Error> {
|
||||||
|
snapshot.validate()?;
|
||||||
|
let mut builder = Builder::default();
|
||||||
|
let mut directory = Directory::default();
|
||||||
|
let mut types = Vec::new();
|
||||||
|
for (path, source) in &snapshot.entries {
|
||||||
|
directory.insert(
|
||||||
|
&path.as_str().split('/').collect::<Vec<_>>(),
|
||||||
|
builder.blob(&source.content),
|
||||||
|
path.as_str(),
|
||||||
|
)?;
|
||||||
|
let content_type = match source.content_type {
|
||||||
|
ConfigContentType::Decodal => "decodal",
|
||||||
|
ConfigContentType::Text => "text",
|
||||||
|
};
|
||||||
|
types.push(entry(path.as_str(), builder.blob(content_type)));
|
||||||
|
}
|
||||||
|
let files = directory.build(&mut builder)?;
|
||||||
|
let types = builder.map(types)?;
|
||||||
|
let format = builder.blob(FORMAT);
|
||||||
|
let root = builder.map(vec![
|
||||||
|
entry("format", format),
|
||||||
|
entry("files", ContentId::Tree(files)),
|
||||||
|
entry("types", ContentId::Tree(types)),
|
||||||
|
])?;
|
||||||
|
Ok(builder.finish(root))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read through an authorized reader and reconstruct validated ConfigEntry metadata and digests.
|
||||||
|
/// The returned snapshot is a candidate, not a validated evaluation or an applied configuration.
|
||||||
|
pub fn restore(
|
||||||
|
root: TreeId,
|
||||||
|
mut read: impl FnMut(ObjectId) -> Result<Object, Error>,
|
||||||
|
) -> Result<ConfigTreeSnapshot, Error> {
|
||||||
|
let root = read_tree(&mut read, root, false)?;
|
||||||
|
fields(&root, &["format", "files", "types"])?;
|
||||||
|
if text(&mut read, field(&root, "format")?)? != FORMAT {
|
||||||
|
return Err(Error::Format);
|
||||||
|
}
|
||||||
|
let types = read_tree(&mut read, tree_id(field(&root, "types")?)?, false)?;
|
||||||
|
if types.entries().len() > MAX_ENTRY_COUNT {
|
||||||
|
return Err(Error::Structure("too many config types"));
|
||||||
|
}
|
||||||
|
let mut metadata = BTreeMap::new();
|
||||||
|
for item in types.entries() {
|
||||||
|
let path = VirtualPath::parse(&item.key)?;
|
||||||
|
let kind = match text(&mut read, item.target)?.as_str() {
|
||||||
|
"decodal" => ConfigContentType::Decodal,
|
||||||
|
"text" => ConfigContentType::Text,
|
||||||
|
_ => return Err(Error::Structure("unknown config content type")),
|
||||||
|
};
|
||||||
|
metadata.insert(path, kind);
|
||||||
|
}
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
let mut total = 0;
|
||||||
|
restore_directory(
|
||||||
|
tree_id(field(&root, "files")?)?,
|
||||||
|
"",
|
||||||
|
&mut read,
|
||||||
|
&mut metadata,
|
||||||
|
&mut entries,
|
||||||
|
&mut total,
|
||||||
|
)?;
|
||||||
|
if !metadata.is_empty() {
|
||||||
|
return Err(Error::Structure("surplus config metadata"));
|
||||||
|
}
|
||||||
|
Ok(ConfigTreeSnapshot::from_entries(entries)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_directory(
|
||||||
|
id: TreeId,
|
||||||
|
prefix: &str,
|
||||||
|
read: &mut impl FnMut(ObjectId) -> Result<Object, Error>,
|
||||||
|
metadata: &mut BTreeMap<VirtualPath, ConfigContentType>,
|
||||||
|
entries: &mut Vec<ConfigEntry>,
|
||||||
|
total: &mut usize,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let directory = read_tree(read, id, false)?;
|
||||||
|
if !prefix.is_empty() && directory.entries().is_empty() {
|
||||||
|
return Err(Error::Structure("empty non-root config directory"));
|
||||||
|
}
|
||||||
|
// Limit directory fanout and path length even for malformed externally supplied trees.
|
||||||
|
if directory.entries().len() > MAX_ENTRY_COUNT {
|
||||||
|
return Err(Error::Structure("too many config children"));
|
||||||
|
}
|
||||||
|
for item in directory.entries() {
|
||||||
|
if item.key.contains('/') {
|
||||||
|
return Err(Error::Structure("config child must be one path component"));
|
||||||
|
}
|
||||||
|
let path = if prefix.is_empty() {
|
||||||
|
item.key.clone()
|
||||||
|
} else {
|
||||||
|
format!("{prefix}/{}", item.key)
|
||||||
|
};
|
||||||
|
if path.len() > MAX_PATH_BYTES {
|
||||||
|
return Err(Error::Structure("config path too long"));
|
||||||
|
}
|
||||||
|
let path = VirtualPath::parse(path)?;
|
||||||
|
match item.target {
|
||||||
|
ContentId::Tree(child) => {
|
||||||
|
// A valid directory must lead to an as-yet unrestored metadata entry. This also
|
||||||
|
// bounds traversal of adversarial shared subtrees by the existing entry/path limits.
|
||||||
|
let child_prefix = format!("{path}/");
|
||||||
|
if !metadata
|
||||||
|
.keys()
|
||||||
|
.any(|key| key.as_str().starts_with(&child_prefix))
|
||||||
|
{
|
||||||
|
return Err(Error::Structure("config directory has no file metadata"));
|
||||||
|
}
|
||||||
|
restore_directory(child, path.as_str(), read, metadata, entries, total)?
|
||||||
|
}
|
||||||
|
ContentId::Blob(_) => {
|
||||||
|
let kind = metadata
|
||||||
|
.remove(&path)
|
||||||
|
.ok_or(Error::Structure("missing config metadata"))?;
|
||||||
|
let source = text(read, item.target)?;
|
||||||
|
*total = total
|
||||||
|
.checked_add(source.len())
|
||||||
|
.ok_or(Error::Structure("config bytes overflow"))?;
|
||||||
|
if *total > MAX_TOTAL_BYTES || entries.len() >= MAX_ENTRY_COUNT {
|
||||||
|
return Err(Error::Structure("config snapshot limits exceeded"));
|
||||||
|
}
|
||||||
|
entries.push(ConfigEntry::new(path, kind, source)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
//! Ticket compound-document contract, independent of the Ticket service and business events.
|
||||||
|
//!
|
||||||
|
//! The versioned root map includes `ticket-id`, `title`, `initial` and `posts`. `initial` is a map
|
||||||
|
//! of `author` and `body`; `posts` is an ordered sequence keyed by stable post IDs, each targeting
|
||||||
|
//! an `author`/`body` map. Order is never sorted for hash stability. All text is complete UTF-8,
|
||||||
|
//! including comments, Unicode and original line endings. Empty bodies and titles are preserved.
|
||||||
|
//!
|
||||||
|
//! Ticket identity deliberately participates in content: equal text on different Tickets yields
|
||||||
|
//! different roots. It is a business identity, not a content ID. Restoration requires the expected
|
||||||
|
//! identity and cannot retarget content to a different Ticket. Original authors participate in the
|
||||||
|
//! snapshot; authenticated history editors and edit times belong to a separate History record.
|
||||||
|
//!
|
||||||
|
//! These local types exclude state, assignment, relations, evidence and the existing content_digest.
|
||||||
|
//! Restoration returns only a document candidate; it cannot rewind business events or replace the
|
||||||
|
//! existing API's CAS. A caller restoring an old root must append a new history with current parent
|
||||||
|
//! and authenticated editor facts, and adopt content under its own business authorization/CAS.
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
use content_history::{ContentId, Object, ObjectId, Tree, TreeId};
|
||||||
|
|
||||||
|
use crate::{Builder, Error, Snapshot, entry, field, fields, read_tree, text, tree_id};
|
||||||
|
|
||||||
|
const FORMAT: &str = "ticket-document/v1";
|
||||||
|
|
||||||
|
/// Content-only view. `initial_author` is the original author, not the latest history editor.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Document {
|
||||||
|
pub ticket_id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub initial_body: String,
|
||||||
|
pub initial_author: String,
|
||||||
|
pub posts: Vec<Post>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable business identity and original authorship survive edits and restoration.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Post {
|
||||||
|
pub id: String,
|
||||||
|
pub author: String,
|
||||||
|
pub body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Document {
|
||||||
|
fn validate(&self) -> Result<(), Error> {
|
||||||
|
if self.ticket_id.is_empty() {
|
||||||
|
return Err(Error::Structure("empty Ticket identity"));
|
||||||
|
}
|
||||||
|
let mut ids = BTreeSet::new();
|
||||||
|
for post in &self.posts {
|
||||||
|
if post.id.is_empty() || !ids.insert(&post.id) {
|
||||||
|
return Err(Error::Structure("empty or duplicate post identity"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message(builder: &mut Builder, author: &str, body: &str) -> Result<TreeId, Error> {
|
||||||
|
let author = builder.blob(author);
|
||||||
|
let body = builder.blob(body);
|
||||||
|
builder.map(vec![entry("author", author), entry("body", body)])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prepare a candidate with explicit root components and semantically ordered posts.
|
||||||
|
pub fn prepare(document: &Document) -> Result<Snapshot, Error> {
|
||||||
|
document.validate()?;
|
||||||
|
let mut builder = Builder::default();
|
||||||
|
let initial = message(
|
||||||
|
&mut builder,
|
||||||
|
&document.initial_author,
|
||||||
|
&document.initial_body,
|
||||||
|
)?;
|
||||||
|
let mut posts = Vec::new();
|
||||||
|
for post in &document.posts {
|
||||||
|
let content = message(&mut builder, &post.author, &post.body)?;
|
||||||
|
posts.push(entry(&post.id, ContentId::Tree(content)));
|
||||||
|
}
|
||||||
|
let posts = builder.tree(Tree::sequence(posts)?);
|
||||||
|
let format = builder.blob(FORMAT);
|
||||||
|
let identity = builder.blob(&document.ticket_id);
|
||||||
|
let title = builder.blob(&document.title);
|
||||||
|
let root = builder.map(vec![
|
||||||
|
entry("format", format),
|
||||||
|
entry("ticket-id", identity),
|
||||||
|
entry("title", title),
|
||||||
|
entry("initial", ContentId::Tree(initial)),
|
||||||
|
entry("posts", ContentId::Tree(posts)),
|
||||||
|
])?;
|
||||||
|
Ok(builder.finish(root))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore content only and verify the intended business identity before returning any document.
|
||||||
|
pub fn restore(
|
||||||
|
root: TreeId,
|
||||||
|
expected_ticket_id: &str,
|
||||||
|
mut read: impl FnMut(ObjectId) -> Result<Object, Error>,
|
||||||
|
) -> Result<Document, Error> {
|
||||||
|
let root = read_tree(&mut read, root, false)?;
|
||||||
|
fields(&root, &["format", "ticket-id", "title", "initial", "posts"])?;
|
||||||
|
if text(&mut read, field(&root, "format")?)? != FORMAT {
|
||||||
|
return Err(Error::Format);
|
||||||
|
}
|
||||||
|
let ticket_id = text(&mut read, field(&root, "ticket-id")?)?;
|
||||||
|
if ticket_id != expected_ticket_id {
|
||||||
|
return Err(Error::TicketIdentity);
|
||||||
|
}
|
||||||
|
let title = text(&mut read, field(&root, "title")?)?;
|
||||||
|
let (initial_author, initial_body) = restore_message(&mut read, field(&root, "initial")?)?;
|
||||||
|
let posts = read_tree(&mut read, tree_id(field(&root, "posts")?)?, true)?;
|
||||||
|
let mut restored = Vec::new();
|
||||||
|
for post in posts.entries() {
|
||||||
|
let (author, body) = restore_message(&mut read, post.target)?;
|
||||||
|
restored.push(Post {
|
||||||
|
id: post.key.clone(),
|
||||||
|
author,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let document = Document {
|
||||||
|
ticket_id,
|
||||||
|
title,
|
||||||
|
initial_body,
|
||||||
|
initial_author,
|
||||||
|
posts: restored,
|
||||||
|
};
|
||||||
|
document.validate()?;
|
||||||
|
Ok(document)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_message(
|
||||||
|
read: &mut impl FnMut(ObjectId) -> Result<Object, Error>,
|
||||||
|
target: ContentId,
|
||||||
|
) -> Result<(String, String), Error> {
|
||||||
|
let message = read_tree(read, tree_id(target)?, false)?;
|
||||||
|
fields(&message, &["author", "body"])?;
|
||||||
|
Ok((
|
||||||
|
text(read, field(&message, "author")?)?,
|
||||||
|
text(read, field(&message, "body")?)?,
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use config_source::{ConfigContentType, ConfigEntry, ConfigTreeSnapshot, VirtualPath};
|
||||||
|
use content_history::{ContentId, Entry, History, Object, ObjectId, Tree, TreeId};
|
||||||
|
use content_history_adapters::{Error, Snapshot, settings, ticket};
|
||||||
|
|
||||||
|
fn source(path: &str, kind: ConfigContentType, text: &str) -> ConfigEntry {
|
||||||
|
ConfigEntry::new(VirtualPath::parse(path).unwrap(), kind, text).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config() -> ConfigTreeSnapshot {
|
||||||
|
ConfigTreeSnapshot::from_entries([
|
||||||
|
source(
|
||||||
|
"main.dcdl",
|
||||||
|
ConfigContentType::Decodal,
|
||||||
|
"// comment\r\n{ title: \"雪\" }\r\n",
|
||||||
|
),
|
||||||
|
// Explicit metadata is intentionally independent of the filename extension.
|
||||||
|
source(
|
||||||
|
"nested/prompt.dcdl",
|
||||||
|
ConfigContentType::Text,
|
||||||
|
"# Prompt\r\n<!-- keep -->\r\n\r\n",
|
||||||
|
),
|
||||||
|
source("nested/深い/empty.txt", ConfigContentType::Text, ""),
|
||||||
|
])
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn document() -> ticket::Document {
|
||||||
|
ticket::Document {
|
||||||
|
ticket_id: "T-835".into(),
|
||||||
|
title: "設定の履歴".into(),
|
||||||
|
initial_author: "user:original".into(),
|
||||||
|
initial_body: "<!-- keep -->\r\nbody\r\n".into(),
|
||||||
|
posts: vec![
|
||||||
|
ticket::Post {
|
||||||
|
id: "post-20".into(),
|
||||||
|
author: "user:alice".into(),
|
||||||
|
body: "first\r\n".into(),
|
||||||
|
},
|
||||||
|
ticket::Post {
|
||||||
|
id: "post-03".into(),
|
||||||
|
author: "user:bob".into(),
|
||||||
|
body: "次\n".into(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn version_one_fixed_root_and_full_source_fixtures() {
|
||||||
|
// Constants pin the adapter schema as well as the independent model's canonical encoding.
|
||||||
|
let config = settings::prepare(&config()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
config.root.to_string(),
|
||||||
|
"cbd69a2f456e9c854d24c04725ac400de76d17101d3d1b21b19190d3f24284c9"
|
||||||
|
);
|
||||||
|
let ticket = ticket::prepare(&document()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
ticket.root.to_string(),
|
||||||
|
"12dfd2e11581ea17e797c29b476a2b03ad6c0561cbccb611123f37be0771ff37"
|
||||||
|
);
|
||||||
|
let ContentId::Blob(source) = target(
|
||||||
|
tree(&config, child(&config, config.root, "files")),
|
||||||
|
"main.dcdl",
|
||||||
|
) else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
source.to_string(),
|
||||||
|
"02e6cfe23152f0576d14a0806717168e544a7abb2309e64354f6bb4e62ee81bb"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config.get(ObjectId::Blob(source)).unwrap(),
|
||||||
|
Object::Blob("// comment\r\n{ title: \"雪\" }\r\n".as_bytes().to_vec())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_explicit_type_change_changes_only_metadata_branch() {
|
||||||
|
let original = config();
|
||||||
|
let old = settings::prepare(&original).unwrap();
|
||||||
|
let mut entries = original.entries.values().cloned().collect::<Vec<_>>();
|
||||||
|
entries[0].content_type = ConfigContentType::Text;
|
||||||
|
let candidate = ConfigTreeSnapshot::from_entries(entries).unwrap();
|
||||||
|
let new = settings::prepare(&candidate).unwrap();
|
||||||
|
assert_ne!(old.root, new.root);
|
||||||
|
assert_eq!(
|
||||||
|
child(&old, old.root, "files"),
|
||||||
|
child(&new, new.root, "files")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
settings::restore(new.root, |id| new.get(id)).unwrap(),
|
||||||
|
candidate
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_restore_rejects_missing_surplus_metadata_and_empty_directories() {
|
||||||
|
let original = settings::prepare(&config()).unwrap();
|
||||||
|
let mut invalid = original.clone();
|
||||||
|
let empty = Object::Tree(Tree::map(Vec::new()).unwrap());
|
||||||
|
let ObjectId::Tree(empty) = add(&mut invalid, empty) else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
invalid.root = change_field(&mut invalid, original.root, "types", ContentId::Tree(empty));
|
||||||
|
assert!(settings::restore(invalid.root, |id| invalid.get(id)).is_err());
|
||||||
|
invalid.root = change_field(&mut invalid, original.root, "files", ContentId::Tree(empty));
|
||||||
|
assert!(settings::restore(invalid.root, |id| invalid.get(id)).is_err());
|
||||||
|
|
||||||
|
// An empty non-root directory is not representable by ConfigTreeSnapshot.
|
||||||
|
let ObjectId::Tree(files) = add(
|
||||||
|
&mut invalid,
|
||||||
|
Object::Tree(
|
||||||
|
Tree::map(vec![Entry {
|
||||||
|
key: "empty".into(),
|
||||||
|
target: ContentId::Tree(empty),
|
||||||
|
}])
|
||||||
|
.unwrap(),
|
||||||
|
),
|
||||||
|
) else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
invalid.root = change_field(&mut invalid, original.root, "files", ContentId::Tree(files));
|
||||||
|
assert!(settings::restore(invalid.root, |id| invalid.get(id)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_reader_surfaces_unsupported_model_formats_without_partial_restore() {
|
||||||
|
let snapshot = ticket::prepare(&document()).unwrap();
|
||||||
|
let result = ticket::restore(snapshot.root, "T-835", |id| {
|
||||||
|
let mut bytes = snapshot.get(id)?.encode();
|
||||||
|
bytes[5] = 99;
|
||||||
|
Ok(Object::decode(&bytes)?)
|
||||||
|
});
|
||||||
|
assert!(matches!(result, Err(Error::Model(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tree(snapshot: &Snapshot, id: TreeId) -> &Tree {
|
||||||
|
let Object::Tree(tree) = &snapshot.objects[&ObjectId::Tree(id)] else {
|
||||||
|
panic!("expected tree")
|
||||||
|
};
|
||||||
|
tree
|
||||||
|
}
|
||||||
|
|
||||||
|
fn target(tree: &Tree, key: &str) -> ContentId {
|
||||||
|
tree.entries().iter().find(|e| e.key == key).unwrap().target
|
||||||
|
}
|
||||||
|
|
||||||
|
fn child(snapshot: &Snapshot, id: TreeId, key: &str) -> TreeId {
|
||||||
|
let ContentId::Tree(id) = target(tree(snapshot, id), key) else {
|
||||||
|
panic!("expected tree child")
|
||||||
|
};
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add(snapshot: &mut Snapshot, object: Object) -> ObjectId {
|
||||||
|
let id = object.id();
|
||||||
|
snapshot.objects.insert(id, object);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn change_field(snapshot: &mut Snapshot, id: TreeId, key: &str, replacement: ContentId) -> TreeId {
|
||||||
|
let entries = tree(snapshot, id)
|
||||||
|
.entries()
|
||||||
|
.iter()
|
||||||
|
.map(|item| Entry {
|
||||||
|
key: item.key.clone(),
|
||||||
|
target: if item.key == key {
|
||||||
|
replacement
|
||||||
|
} else {
|
||||||
|
item.target
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let ObjectId::Tree(id) = add(snapshot, Object::Tree(Tree::map(entries).unwrap())) else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blob(snapshot: &mut Snapshot, bytes: &[u8]) -> ContentId {
|
||||||
|
let ObjectId::Blob(id) = add(snapshot, Object::Blob(bytes.to_vec())) else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
ContentId::Blob(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_roundtrip_preserves_sources_paths_types_and_metadata() {
|
||||||
|
let original = config();
|
||||||
|
let snapshot = settings::prepare(&original).unwrap();
|
||||||
|
let restored = settings::restore(snapshot.root, |id| snapshot.get(id)).unwrap();
|
||||||
|
assert_eq!(original, restored);
|
||||||
|
// Directory tree uses actual names, not a flat list of full paths.
|
||||||
|
let files = child(&snapshot, snapshot.root, "files");
|
||||||
|
assert_eq!(
|
||||||
|
tree(&snapshot, files)
|
||||||
|
.entries()
|
||||||
|
.iter()
|
||||||
|
.map(|e| e.key.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["main.dcdl", "nested"]
|
||||||
|
);
|
||||||
|
let nested = child(&snapshot, files, "nested");
|
||||||
|
assert!(matches!(
|
||||||
|
target(tree(&snapshot, nested), "prompt.dcdl"),
|
||||||
|
ContentId::Blob(_)
|
||||||
|
));
|
||||||
|
let deep = child(&snapshot, nested, "深い");
|
||||||
|
assert!(matches!(
|
||||||
|
target(tree(&snapshot, deep), "empty.txt"),
|
||||||
|
ContentId::Blob(_)
|
||||||
|
));
|
||||||
|
// Reopening canonical bytes does not reserialize domain sources or alter root identity.
|
||||||
|
let reopened = snapshot
|
||||||
|
.objects
|
||||||
|
.iter()
|
||||||
|
.map(|(id, object)| (*id, Object::decode(&object.encode()).unwrap()))
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
assert_eq!(
|
||||||
|
settings::restore(snapshot.root, |id| reopened
|
||||||
|
.get(&id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or(Error::Missing(id)))
|
||||||
|
.unwrap(),
|
||||||
|
original
|
||||||
|
);
|
||||||
|
let reverse =
|
||||||
|
ConfigTreeSnapshot::from_entries(original.entries.values().rev().cloned()).unwrap();
|
||||||
|
assert_eq!(snapshot.root, settings::prepare(&reverse).unwrap().root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_edits_names_types_additions_and_deletions_change_root_share_unchanged_blobs() {
|
||||||
|
let original = config();
|
||||||
|
let old = settings::prepare(&original).unwrap();
|
||||||
|
for entries in [
|
||||||
|
vec![source("main.dcdl", ConfigContentType::Decodal, "changed\n")],
|
||||||
|
vec![source(
|
||||||
|
"renamed.dcdl",
|
||||||
|
ConfigContentType::Decodal,
|
||||||
|
"// comment\r\n{ title: \"雪\" }\r\n",
|
||||||
|
)],
|
||||||
|
vec![source(
|
||||||
|
"main.dcdl",
|
||||||
|
ConfigContentType::Text,
|
||||||
|
"// comment\r\n{ title: \"雪\" }\r\n",
|
||||||
|
)],
|
||||||
|
] {
|
||||||
|
assert_ne!(
|
||||||
|
old.root,
|
||||||
|
settings::prepare(&ConfigTreeSnapshot::from_entries(entries).unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.root
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut edited = original.entries.values().cloned().collect::<Vec<_>>();
|
||||||
|
edited[0] = source("main.dcdl", ConfigContentType::Decodal, "new\r\n");
|
||||||
|
edited.push(source("extra.txt", ConfigContentType::Text, "added"));
|
||||||
|
let new = settings::prepare(&ConfigTreeSnapshot::from_entries(edited).unwrap()).unwrap();
|
||||||
|
let files_old = child(&old, old.root, "files");
|
||||||
|
let files_new = child(&new, new.root, "files");
|
||||||
|
assert_eq!(
|
||||||
|
target(tree(&old, files_old), "nested"),
|
||||||
|
target(tree(&new, files_new), "nested")
|
||||||
|
);
|
||||||
|
let mut union = old.objects.clone();
|
||||||
|
union.extend(new.objects);
|
||||||
|
assert_eq!(
|
||||||
|
settings::restore(old.root, |id| union
|
||||||
|
.get(&id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or(Error::Missing(id)))
|
||||||
|
.unwrap(),
|
||||||
|
original
|
||||||
|
);
|
||||||
|
let empty = settings::prepare(&ConfigTreeSnapshot::empty()).unwrap();
|
||||||
|
assert_ne!(old.root, empty.root);
|
||||||
|
assert_eq!(
|
||||||
|
settings::restore(empty.root, |id| empty.get(id)).unwrap(),
|
||||||
|
ConfigTreeSnapshot::empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_rejects_inconsistent_snapshot_and_file_directory_conflicts() {
|
||||||
|
let mut invalid = config();
|
||||||
|
invalid.digest.push('0');
|
||||||
|
assert!(matches!(settings::prepare(&invalid), Err(Error::Config(_))));
|
||||||
|
let mut invalid = config();
|
||||||
|
invalid
|
||||||
|
.entries
|
||||||
|
.values_mut()
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.content
|
||||||
|
.push('!');
|
||||||
|
assert!(matches!(settings::prepare(&invalid), Err(Error::Config(_))));
|
||||||
|
let mut invalid = config();
|
||||||
|
invalid.entries.values_mut().next().unwrap().path = VirtualPath::parse("other.dcdl").unwrap();
|
||||||
|
assert!(matches!(settings::prepare(&invalid), Err(Error::Config(_))));
|
||||||
|
let prefix_conflict = ConfigTreeSnapshot::from_entries([
|
||||||
|
source("a", ConfigContentType::Text, "file"),
|
||||||
|
source("a/b", ConfigContentType::Text, "nested"),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
settings::prepare(&prefix_conflict),
|
||||||
|
Err(Error::PathConflict(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_restore_rejects_invalid_utf8_metadata_paths_and_ordering() {
|
||||||
|
let original = settings::prepare(&config()).unwrap();
|
||||||
|
let mut invalid = original.clone();
|
||||||
|
let files = child(&invalid, invalid.root, "files");
|
||||||
|
let bad = blob(&mut invalid, &[0xff]);
|
||||||
|
let files = change_field(&mut invalid, files, "main.dcdl", bad);
|
||||||
|
invalid.root = change_field(&mut invalid, original.root, "files", ContentId::Tree(files));
|
||||||
|
assert!(matches!(
|
||||||
|
settings::restore(invalid.root, |id| invalid.get(id)),
|
||||||
|
Err(Error::Utf8(_))
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut invalid = original.clone();
|
||||||
|
let types = child(&invalid, invalid.root, "types");
|
||||||
|
let bad = blob(&mut invalid, b"unknown");
|
||||||
|
let types = change_field(&mut invalid, types, "main.dcdl", bad);
|
||||||
|
invalid.root = change_field(&mut invalid, original.root, "types", ContentId::Tree(types));
|
||||||
|
assert!(settings::restore(invalid.root, |id| invalid.get(id)).is_err());
|
||||||
|
|
||||||
|
for key in ["../escape", "a/b", "$builtin", ""] {
|
||||||
|
let mut invalid = original.clone();
|
||||||
|
let value = blob(&mut invalid, b"bad");
|
||||||
|
// Empty keys may already be rejected by the independent model.
|
||||||
|
let Ok(files) = Tree::map(vec![Entry {
|
||||||
|
key: key.into(),
|
||||||
|
target: value,
|
||||||
|
}]) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let ObjectId::Tree(files) = add(&mut invalid, Object::Tree(files)) else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
invalid.root = change_field(&mut invalid, original.root, "files", ContentId::Tree(files));
|
||||||
|
assert!(
|
||||||
|
settings::restore(invalid.root, |id| invalid.get(id)).is_err(),
|
||||||
|
"{key}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut invalid = original.clone();
|
||||||
|
let files = child(&invalid, invalid.root, "files");
|
||||||
|
let ordered = Tree::sequence(tree(&invalid, files).entries().to_vec()).unwrap();
|
||||||
|
let ObjectId::Tree(files) = add(&mut invalid, Object::Tree(ordered)) else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
invalid.root = change_field(&mut invalid, original.root, "files", ContentId::Tree(files));
|
||||||
|
assert!(settings::restore(invalid.root, |id| invalid.get(id)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ticket_roundtrip_explicit_structure_and_semantic_order() {
|
||||||
|
let original = document();
|
||||||
|
let snapshot = ticket::prepare(&original).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
ticket::restore(snapshot.root, "T-835", |id| snapshot.get(id)).unwrap(),
|
||||||
|
original
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tree(&snapshot, snapshot.root)
|
||||||
|
.entries()
|
||||||
|
.iter()
|
||||||
|
.map(|e| e.key.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["format", "initial", "posts", "ticket-id", "title"]
|
||||||
|
);
|
||||||
|
let posts = child(&snapshot, snapshot.root, "posts");
|
||||||
|
assert!(tree(&snapshot, posts).is_ordered());
|
||||||
|
assert_eq!(
|
||||||
|
tree(&snapshot, posts)
|
||||||
|
.entries()
|
||||||
|
.iter()
|
||||||
|
.map(|e| e.key.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["post-20", "post-03"]
|
||||||
|
);
|
||||||
|
let mut swapped = original.clone();
|
||||||
|
swapped.posts.swap(0, 1);
|
||||||
|
assert_ne!(snapshot.root, ticket::prepare(&swapped).unwrap().root);
|
||||||
|
let mut other = original.clone();
|
||||||
|
other.ticket_id = "T-836".into();
|
||||||
|
assert_ne!(snapshot.root, ticket::prepare(&other).unwrap().root);
|
||||||
|
assert!(matches!(
|
||||||
|
ticket::restore(snapshot.root, "T-836", |id| snapshot.get(id)),
|
||||||
|
Err(Error::TicketIdentity)
|
||||||
|
));
|
||||||
|
let mut edited = original;
|
||||||
|
edited.posts[0].body = "edited".into();
|
||||||
|
let restored = ticket::restore(ticket::prepare(&edited).unwrap().root, "T-835", |id| {
|
||||||
|
ticket::prepare(&edited).unwrap().get(id)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(restored.posts[0].id, "post-20");
|
||||||
|
assert_eq!(restored.posts[0].author, "user:alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_old_document_leaves_separate_business_events_and_original_authors_untouched() {
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
struct BusinessState {
|
||||||
|
state: &'static str,
|
||||||
|
assignment: &'static str,
|
||||||
|
events: Vec<&'static str>,
|
||||||
|
}
|
||||||
|
struct Ticket {
|
||||||
|
document: ticket::Document,
|
||||||
|
business: BusinessState,
|
||||||
|
}
|
||||||
|
let original = document();
|
||||||
|
let old = ticket::prepare(&original).unwrap();
|
||||||
|
let mut current = Ticket {
|
||||||
|
document: original.clone(),
|
||||||
|
business: BusinessState {
|
||||||
|
state: "closed",
|
||||||
|
assignment: "worker:new",
|
||||||
|
events: vec!["relation added", "evidence attached"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
current.document.title = "changed".into();
|
||||||
|
current.document =
|
||||||
|
ticket::restore(old.root, ¤t.document.ticket_id, |id| old.get(id)).unwrap();
|
||||||
|
assert_eq!(current.document, original);
|
||||||
|
assert_eq!(
|
||||||
|
current.business,
|
||||||
|
BusinessState {
|
||||||
|
state: "closed",
|
||||||
|
assignment: "worker:new",
|
||||||
|
events: vec!["relation added", "evidence attached"]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let first_edit = History {
|
||||||
|
root: old.root,
|
||||||
|
parent: None,
|
||||||
|
actor: "user:editor".into(),
|
||||||
|
timestamp_micros: 1,
|
||||||
|
};
|
||||||
|
let ObjectId::History(first_id) = Object::History(first_edit.clone()).id() else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let restore_edit = History {
|
||||||
|
root: old.root,
|
||||||
|
parent: Some(first_id),
|
||||||
|
actor: "user:restorer".into(),
|
||||||
|
timestamp_micros: 2,
|
||||||
|
};
|
||||||
|
assert_ne!(
|
||||||
|
Object::History(first_edit).id(),
|
||||||
|
Object::History(restore_edit).id()
|
||||||
|
);
|
||||||
|
assert_eq!(current.document.posts[0].author, "user:alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn readers_fail_closed_on_missing_corruption_wrong_kind_and_unknown_adapter_version() {
|
||||||
|
let snapshot = ticket::prepare(&document()).unwrap();
|
||||||
|
for id in snapshot.objects.keys() {
|
||||||
|
assert!(
|
||||||
|
ticket::restore(snapshot.root, "T-835", |requested| {
|
||||||
|
if requested == *id {
|
||||||
|
Err(Error::Missing(requested))
|
||||||
|
} else {
|
||||||
|
snapshot.get(requested)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
ticket::restore(snapshot.root, "T-835", |_| Ok(Object::Blob(
|
||||||
|
b"tampered".to_vec()
|
||||||
|
))),
|
||||||
|
Err(Error::HashMismatch(_))
|
||||||
|
));
|
||||||
|
let mut invalid = snapshot.clone();
|
||||||
|
let bad = blob(&mut invalid, b"ticket-document/v999");
|
||||||
|
invalid.root = change_field(&mut invalid, snapshot.root, "format", bad);
|
||||||
|
assert!(matches!(
|
||||||
|
ticket::restore(invalid.root, "T-835", |id| invalid.get(id)),
|
||||||
|
Err(Error::Format)
|
||||||
|
));
|
||||||
|
let bad = blob(&mut invalid, b"not a post sequence");
|
||||||
|
invalid.root = change_field(&mut invalid, snapshot.root, "posts", bad);
|
||||||
|
assert!(ticket::restore(invalid.root, "T-835", |id| invalid.get(id)).is_err());
|
||||||
|
let posts = child(&snapshot, snapshot.root, "posts");
|
||||||
|
let unordered = Tree::map(tree(&snapshot, posts).entries().to_vec()).unwrap();
|
||||||
|
let ObjectId::Tree(posts) = add(&mut invalid, Object::Tree(unordered)) else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
invalid.root = change_field(&mut invalid, snapshot.root, "posts", ContentId::Tree(posts));
|
||||||
|
assert!(ticket::restore(invalid.root, "T-835", |id| invalid.get(id)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_business_post_ids_and_empty_ticket_ids_are_not_accepted() {
|
||||||
|
let mut invalid = document();
|
||||||
|
invalid.posts[1].id = invalid.posts[0].id.clone();
|
||||||
|
assert!(ticket::prepare(&invalid).is_err());
|
||||||
|
invalid = document();
|
||||||
|
invalid.ticket_id.clear();
|
||||||
|
assert!(ticket::prepare(&invalid).is_err());
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "content-history-store"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
content-history.workspace = true
|
||||||
|
rusqlite.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
|
content-history-adapters.workspace = true
|
||||||
|
config-source.workspace = true
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# content-history-store
|
||||||
|
|
||||||
|
Persists immutable content and append-only editing history on the caller's SQLite transaction.
|
||||||
|
The pure canonical model belongs to `content-history`; this crate does not evaluate configuration,
|
||||||
|
edit Tickets, authenticate users, or replace their existing CAS contracts.
|
||||||
|
|
||||||
|
[`ScopedStore`] is a trusted Host adapter, not an untrusted SQL service. Its caller implements
|
||||||
|
[`Authority`] using existing current operation authorization for the exact [`ScopeKey`]. Every
|
||||||
|
operation rechecks authority before reading stored metadata. A hash is never a capability: even
|
||||||
|
preparation requires complete verified bytes, and public reads require reachability from an
|
||||||
|
adopted history in that same workspace and target. Physical deduplication does not grant access.
|
||||||
|
|
||||||
|
[`SCHEMA`] is the domain's schema contribution. The eventual adopting Server domain must install
|
||||||
|
it through its existing ordered migration and fresh-schema path, not through a second startup
|
||||||
|
migration runner. This foundation does not change the production configuration writer. The
|
||||||
|
adapter accepts the same `rusqlite::Transaction` used by the Server. The caller prepares complete
|
||||||
|
objects, validates one candidate snapshot, calls [`ScopedStore::adopt`] with the observed history
|
||||||
|
head, and updates source/projection/business records before committing that **same** transaction.
|
||||||
|
An error must roll back the caller's transaction; a successful adapter call alone is not successful
|
||||||
|
business adoption. Each individual mutating method also uses a savepoint, so catching its error
|
||||||
|
cannot retain its partial changes. SQLite writer acquisition and BUSY/retry policy remain caller-owned.
|
||||||
|
No automatic retries or external effects are hidden here.
|
||||||
|
|
||||||
|
Normal edits retain all adopted ancestors and their full snapshots; [`ScopedStore::collect_unadopted`]
|
||||||
|
only removes preparations not used by any adopted history. Object rows cannot be overwritten
|
||||||
|
through the adapter. A supplied hash/bytes mismatch, a differing existing row, a missing child,
|
||||||
|
wrong child kind or unsupported canonical format fails rather than publishing an incomplete head.
|
||||||
|
Stored bytes are verified on every read, not reserialized under a newer version.
|
||||||
|
|
||||||
|
[`ScopedStore::erase`] requires separate explicit authorization, tombstones the exact target
|
||||||
|
permanently and removes its ownership references. Old hashes cannot recreate that target's data.
|
||||||
|
Other targets' retained or prepared references keep shared physical objects alive. A newly created
|
||||||
|
business object needs a new target identity; erasure is not an edit or a rewind. This is logical
|
||||||
|
store erasure, not secure disk overwrite or revocation of already exported copies/backups. The
|
||||||
|
caller owns retention decisions, backup policy and authenticated editor/time facts. Restoring an
|
||||||
|
old content root is a new adoption with the **current** history parent, never resetting head to an
|
||||||
|
old history. Existing Ticket `content_digest`, Ticket/post identities and business events remain
|
||||||
|
separate contracts owned by their domain.
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
#![doc = include_str!("../README.md")]
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use content_history::{History, HistoryId, Object, ObjectId, TreeId, verify};
|
||||||
|
use rusqlite::{OptionalExtension, Transaction, params};
|
||||||
|
|
||||||
|
/// New domain tables, deliberately not an alternate Server migration runner.
|
||||||
|
/// The adopting domain installs these through its ordered schema migration and fresh schema.
|
||||||
|
pub const SCHEMA: &str = include_str!("schema.sql");
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("content access denied")]
|
||||||
|
Denied,
|
||||||
|
#[error("content unavailable in authorized scope")]
|
||||||
|
Unavailable,
|
||||||
|
#[error("content scope was explicitly erased")]
|
||||||
|
Erased,
|
||||||
|
#[error("observed history head is stale")]
|
||||||
|
Conflict,
|
||||||
|
#[error("invalid content: {0}")]
|
||||||
|
Model(#[from] content_history::Error),
|
||||||
|
#[error("invalid stored history reference")]
|
||||||
|
InvalidReference,
|
||||||
|
#[error("immutable content differs from stored bytes")]
|
||||||
|
ImmutableMismatch,
|
||||||
|
#[error("sqlite content store: {0}")]
|
||||||
|
Sql(#[from] rusqlite::Error),
|
||||||
|
}
|
||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
/// The business owner supplies exact identities, not path containment rules.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ScopeKey {
|
||||||
|
pub workspace: String,
|
||||||
|
pub target: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Access {
|
||||||
|
Read,
|
||||||
|
Write,
|
||||||
|
Erase,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Existing caller authority is checked at each operation, before querying objects.
|
||||||
|
/// This is a trusted Host integration seam, not a grant constructor or authentication system.
|
||||||
|
pub trait Authority {
|
||||||
|
fn allows(&self, key: &ScopeKey, access: Access) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An adapter borrows the caller's real transaction; it never commits business adoption.
|
||||||
|
/// Do not expose this type or its authority implementation directly to untrusted code.
|
||||||
|
pub struct ScopedStore<'a, 'conn> {
|
||||||
|
tx: &'a Transaction<'conn>,
|
||||||
|
key: ScopeKey,
|
||||||
|
authority: &'a dyn Authority,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, 'conn> ScopedStore<'a, 'conn> {
|
||||||
|
pub fn new(tx: &'a Transaction<'conn>, key: ScopeKey, authority: &'a dyn Authority) -> Self {
|
||||||
|
Self { tx, key, authority }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authorize(&self, access: Access) -> Result<()> {
|
||||||
|
if self.key.workspace.is_empty()
|
||||||
|
|| self.key.target.is_empty()
|
||||||
|
|| !self.authority.allows(&self.key, access)
|
||||||
|
{
|
||||||
|
return Err(Error::Denied);
|
||||||
|
}
|
||||||
|
let erased: Option<bool> = self
|
||||||
|
.tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT erased FROM content_scopes WHERE workspace=?1 AND target=?2",
|
||||||
|
params![self.key.workspace, self.key.target],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
if erased == Some(true) {
|
||||||
|
return Err(Error::Erased);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_scope(&self) -> Result<()> {
|
||||||
|
self.tx.execute(
|
||||||
|
"INSERT OR IGNORE INTO content_scopes(workspace,target) VALUES(?1,?2)",
|
||||||
|
params![self.key.workspace, self.key.target],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn head(&self) -> Result<Option<HistoryId>> {
|
||||||
|
self.authorize(Access::Read)?;
|
||||||
|
self.head_inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn head_inner(&self) -> Result<Option<HistoryId>> {
|
||||||
|
let raw: Option<Vec<u8>> = self
|
||||||
|
.tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT head FROM content_scopes WHERE workspace=?1 AND target=?2",
|
||||||
|
params![self.key.workspace, self.key.target],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.flatten();
|
||||||
|
raw.map(|v| {
|
||||||
|
Ok(HistoryId::from_bytes(
|
||||||
|
v.try_into().map_err(|_| Error::InvalidReference)?,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preparation requires the full canonical bytes, not possession of another scope's hash.
|
||||||
|
/// Prepared objects are not readable snapshots until adoption succeeds.
|
||||||
|
pub fn put(&self, claimed: ObjectId, bytes: &[u8]) -> Result<()> {
|
||||||
|
self.authorize(Access::Write)?;
|
||||||
|
verify(claimed, bytes)?;
|
||||||
|
self.atomic(|| self.put_inner(claimed, bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_inner(&self, id: ObjectId, bytes: &[u8]) -> Result<()> {
|
||||||
|
self.ensure_scope()?;
|
||||||
|
let (kind, hash) = parts(id);
|
||||||
|
self.tx.execute(
|
||||||
|
"INSERT OR IGNORE INTO content_objects(kind,hash,bytes) VALUES(?1,?2,?3)",
|
||||||
|
params![kind, hash, bytes],
|
||||||
|
)?;
|
||||||
|
let stored: Vec<u8> = self.tx.query_row(
|
||||||
|
"SELECT bytes FROM content_objects WHERE kind=?1 AND hash=?2",
|
||||||
|
params![kind, hash],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
|
if stored != bytes {
|
||||||
|
return Err(Error::ImmutableMismatch);
|
||||||
|
}
|
||||||
|
self.tx.execute("INSERT OR IGNORE INTO content_scope_objects(workspace,target,kind,hash) VALUES(?1,?2,?3,?4)",
|
||||||
|
params![self.key.workspace, self.key.target, kind, hash])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A hash read is confined to objects reachable from this target's adopted history.
|
||||||
|
/// Unknown and cross-target hashes have the same result, regardless of physical deduplication.
|
||||||
|
pub fn read(&self, id: ObjectId) -> Result<Object> {
|
||||||
|
self.authorize(Access::Read)?;
|
||||||
|
let (kind, hash) = parts(id);
|
||||||
|
let allowed: bool = self.tx.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM content_reachable WHERE workspace=?1 AND target=?2 AND kind=?3 AND hash=?4)",
|
||||||
|
params![self.key.workspace, self.key.target, kind, hash], |r| r.get(0))?;
|
||||||
|
if !allowed {
|
||||||
|
return Err(Error::Unavailable);
|
||||||
|
}
|
||||||
|
self.load_prepared(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_prepared(&self, id: ObjectId) -> Result<Object> {
|
||||||
|
let (kind, hash) = parts(id);
|
||||||
|
let bytes: Option<Vec<u8>> = self.tx.query_row(
|
||||||
|
"SELECT o.bytes FROM content_objects o JOIN content_scope_objects s ON s.kind=o.kind AND s.hash=o.hash WHERE s.workspace=?1 AND s.target=?2 AND s.kind=?3 AND s.hash=?4",
|
||||||
|
params![self.key.workspace, self.key.target, kind, hash], |r| r.get(0)).optional()?;
|
||||||
|
Ok(verify(id, &bytes.ok_or(Error::Unavailable)?)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore uses a retained history, not a chain of text deltas. The returned graph includes
|
||||||
|
/// every full blob and structural node and fails on any missing or corrupt descendant.
|
||||||
|
pub fn snapshot(&self, history: HistoryId) -> Result<BTreeMap<ObjectId, Object>> {
|
||||||
|
let Object::History(record) = self.read(ObjectId::History(history))? else {
|
||||||
|
return Err(Error::InvalidReference);
|
||||||
|
};
|
||||||
|
self.walk([ObjectId::Tree(record.root)], true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk(
|
||||||
|
&self,
|
||||||
|
roots: impl IntoIterator<Item = ObjectId>,
|
||||||
|
public: bool,
|
||||||
|
) -> Result<BTreeMap<ObjectId, Object>> {
|
||||||
|
let mut pending: Vec<_> = roots.into_iter().collect();
|
||||||
|
let mut objects = BTreeMap::new();
|
||||||
|
while let Some(id) = pending.pop() {
|
||||||
|
if objects.contains_key(&id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let object = if public {
|
||||||
|
self.read(id)?
|
||||||
|
} else {
|
||||||
|
self.load_prepared(id)?
|
||||||
|
};
|
||||||
|
match &object {
|
||||||
|
Object::Blob(_) => {}
|
||||||
|
Object::Tree(tree) => {
|
||||||
|
pending.extend(tree.entries().iter().map(|e| ObjectId::from(e.target)))
|
||||||
|
}
|
||||||
|
Object::History(history) => {
|
||||||
|
pending.push(ObjectId::Tree(history.root));
|
||||||
|
if let Some(parent) = history.parent {
|
||||||
|
pending.push(ObjectId::History(parent));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
objects.insert(id, object);
|
||||||
|
}
|
||||||
|
Ok(objects)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticated actor and clock facts belong to the caller, not restored post authors.
|
||||||
|
/// Restoring an old root appends a history with the current parent; no rewind operation exists.
|
||||||
|
/// The private savepoint prevents even a caught error from publishing a partial adoption.
|
||||||
|
pub fn adopt(
|
||||||
|
&self,
|
||||||
|
root: TreeId,
|
||||||
|
expected: Option<HistoryId>,
|
||||||
|
actor: String,
|
||||||
|
timestamp_micros: i64,
|
||||||
|
) -> Result<HistoryId> {
|
||||||
|
self.authorize(Access::Write)?;
|
||||||
|
self.atomic(|| {
|
||||||
|
if self.head_inner()? != expected { return Err(Error::Conflict); }
|
||||||
|
let mut graph = self.walk([ObjectId::Tree(root)], false)?;
|
||||||
|
if let Some(parent) = expected {
|
||||||
|
// Validate all retained ancestors too; corruption cannot become a successful new head.
|
||||||
|
graph.extend(self.walk([ObjectId::History(parent)], false)?);
|
||||||
|
}
|
||||||
|
let history = Object::History(History { root, parent: expected, actor, timestamp_micros });
|
||||||
|
let ObjectId::History(id) = history.id() else { unreachable!() };
|
||||||
|
self.put_inner(history.id(), &history.encode())?;
|
||||||
|
graph.insert(history.id(), history);
|
||||||
|
for object in graph.keys() {
|
||||||
|
let (kind, hash) = parts(*object);
|
||||||
|
self.tx.execute("INSERT OR IGNORE INTO content_reachable(workspace,target,kind,hash) VALUES(?1,?2,?3,?4)",
|
||||||
|
params![self.key.workspace, self.key.target, kind, hash])?;
|
||||||
|
}
|
||||||
|
let changed = self.tx.execute(
|
||||||
|
"UPDATE content_scopes SET head=?3 WHERE workspace=?1 AND target=?2 AND erased=0 AND head IS ?4",
|
||||||
|
params![self.key.workspace, self.key.target, id.as_bytes().as_slice(), expected.map(|h| h.as_bytes().to_vec())])?;
|
||||||
|
if changed != 1 { return Err(Error::Conflict); }
|
||||||
|
Ok(id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retains every adopted history. Only unadopted preparations are reclaimed.
|
||||||
|
/// Physical rows survive while *any* target has prepared or retained ownership.
|
||||||
|
pub fn collect_unadopted(&self) -> Result<()> {
|
||||||
|
self.authorize(Access::Write)?;
|
||||||
|
self.atomic(|| {
|
||||||
|
self.tx.execute("DELETE FROM content_scope_objects AS s WHERE workspace=?1 AND target=?2 AND NOT EXISTS(SELECT 1 FROM content_reachable r WHERE r.workspace=s.workspace AND r.target=s.target AND r.kind=s.kind AND r.hash=s.hash)",
|
||||||
|
params![self.key.workspace, self.key.target])?;
|
||||||
|
self.collect_physical()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erasure is permanent for this exact business identity, including its old roots.
|
||||||
|
/// Shared bytes retained by other authorized targets are not destroyed. Backups and bytes
|
||||||
|
/// already exported by callers are outside this adapter's logical erasure guarantee.
|
||||||
|
pub fn erase(&self) -> Result<()> {
|
||||||
|
self.authorize(Access::Erase)?;
|
||||||
|
self.atomic(|| {
|
||||||
|
self.ensure_scope()?;
|
||||||
|
self.tx.execute(
|
||||||
|
"DELETE FROM content_reachable WHERE workspace=?1 AND target=?2",
|
||||||
|
params![self.key.workspace, self.key.target],
|
||||||
|
)?;
|
||||||
|
self.tx.execute(
|
||||||
|
"DELETE FROM content_scope_objects WHERE workspace=?1 AND target=?2",
|
||||||
|
params![self.key.workspace, self.key.target],
|
||||||
|
)?;
|
||||||
|
self.tx.execute(
|
||||||
|
"UPDATE content_scopes SET head=NULL,erased=1 WHERE workspace=?1 AND target=?2",
|
||||||
|
params![self.key.workspace, self.key.target],
|
||||||
|
)?;
|
||||||
|
self.collect_physical()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_physical(&self) -> Result<()> {
|
||||||
|
self.tx.execute("DELETE FROM content_objects AS o WHERE NOT EXISTS(SELECT 1 FROM content_scope_objects s WHERE s.kind=o.kind AND s.hash=o.hash)", [])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn atomic<T>(&self, f: impl FnOnce() -> Result<T>) -> Result<T> {
|
||||||
|
self.tx
|
||||||
|
.execute_batch("SAVEPOINT content_history_operation")?;
|
||||||
|
match f() {
|
||||||
|
Ok(value) => {
|
||||||
|
self.tx.execute_batch("RELEASE content_history_operation")?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.tx.execute_batch(
|
||||||
|
"ROLLBACK TO content_history_operation; RELEASE content_history_operation",
|
||||||
|
)?;
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parts(id: ObjectId) -> (u8, Vec<u8>) {
|
||||||
|
match id {
|
||||||
|
ObjectId::Blob(h) => (1, h.as_bytes().to_vec()),
|
||||||
|
ObjectId::Tree(h) => (2, h.as_bytes().to_vec()),
|
||||||
|
ObjectId::History(h) => (3, h.as_bytes().to_vec()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
CREATE TABLE content_objects (
|
||||||
|
kind INTEGER NOT NULL CHECK(kind IN (1,2,3)),
|
||||||
|
hash BLOB NOT NULL CHECK(length(hash)=32),
|
||||||
|
bytes BLOB NOT NULL,
|
||||||
|
PRIMARY KEY(kind,hash)
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
CREATE TABLE content_scopes (
|
||||||
|
workspace TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
head BLOB CHECK(head IS NULL OR length(head)=32),
|
||||||
|
erased INTEGER NOT NULL DEFAULT 0 CHECK(erased IN (0,1)),
|
||||||
|
PRIMARY KEY(workspace,target),
|
||||||
|
CHECK(erased=0 OR head IS NULL)
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
CREATE TABLE content_scope_objects (
|
||||||
|
workspace TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
kind INTEGER NOT NULL,
|
||||||
|
hash BLOB NOT NULL,
|
||||||
|
PRIMARY KEY(workspace,target,kind,hash),
|
||||||
|
FOREIGN KEY(workspace,target) REFERENCES content_scopes(workspace,target),
|
||||||
|
FOREIGN KEY(kind,hash) REFERENCES content_objects(kind,hash)
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
CREATE TABLE content_reachable (
|
||||||
|
workspace TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
kind INTEGER NOT NULL,
|
||||||
|
hash BLOB NOT NULL,
|
||||||
|
PRIMARY KEY(workspace,target,kind,hash),
|
||||||
|
FOREIGN KEY(workspace,target,kind,hash)
|
||||||
|
REFERENCES content_scope_objects(workspace,target,kind,hash)
|
||||||
|
) WITHOUT ROWID;
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
//! Real SQLite component contracts. Authority is an exact-key Host fixture; SQL, savepoints,
|
||||||
|
//! graph validation and persistence are production implementations. No live Server DB is used.
|
||||||
|
use content_history::{ContentId, Entry, HistoryId, Object, ObjectId, Tree, TreeId};
|
||||||
|
use content_history_store::{Access, Authority, Error, SCHEMA, ScopeKey, ScopedStore};
|
||||||
|
use rusqlite::{Connection, Transaction, params};
|
||||||
|
|
||||||
|
struct Host {
|
||||||
|
key: ScopeKey,
|
||||||
|
erase: bool,
|
||||||
|
}
|
||||||
|
impl Authority for Host {
|
||||||
|
fn allows(&self, key: &ScopeKey, access: Access) -> bool {
|
||||||
|
*key == self.key && (access != Access::Erase || self.erase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn key(workspace: &str, target: &str) -> ScopeKey {
|
||||||
|
ScopeKey {
|
||||||
|
workspace: workspace.into(),
|
||||||
|
target: target.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn host(target: &str) -> Host {
|
||||||
|
Host {
|
||||||
|
key: key("ws", target),
|
||||||
|
erase: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn store<'a, 'c>(tx: &'a Transaction<'c>, h: &'a Host) -> ScopedStore<'a, 'c> {
|
||||||
|
ScopedStore::new(tx, h.key.clone(), h)
|
||||||
|
}
|
||||||
|
fn setup(conn: &mut Connection) {
|
||||||
|
conn.execute_batch("PRAGMA foreign_keys=ON").unwrap();
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
tx.execute_batch(SCHEMA).unwrap();
|
||||||
|
tx.execute_batch("CREATE TABLE business(source BLOB, projection TEXT, adopted BLOB)")
|
||||||
|
.unwrap();
|
||||||
|
tx.commit().unwrap();
|
||||||
|
}
|
||||||
|
fn candidate(s: &ScopedStore<'_, '_>, bytes: &[u8]) -> (TreeId, ObjectId) {
|
||||||
|
let blob = Object::Blob(bytes.to_vec());
|
||||||
|
let ObjectId::Blob(id) = blob.id() else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
s.put(blob.id(), &blob.encode()).unwrap();
|
||||||
|
let tree = Object::Tree(
|
||||||
|
Tree::map(vec![Entry {
|
||||||
|
key: "source".into(),
|
||||||
|
target: ContentId::Blob(id),
|
||||||
|
}])
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
s.put(tree.id(), &tree.encode()).unwrap();
|
||||||
|
let ObjectId::Tree(root) = tree.id() else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
(root, blob.id())
|
||||||
|
}
|
||||||
|
fn adopt(s: &ScopedStore<'_, '_>, root: TreeId, expected: Option<HistoryId>, t: i64) -> HistoryId {
|
||||||
|
s.adopt(root, expected, "authenticated editor".into(), t)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reopen_full_text_restore_aba_and_stale_head_preserve_current() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("content.sqlite");
|
||||||
|
let mut conn = Connection::open(&path).unwrap();
|
||||||
|
setup(&mut conn);
|
||||||
|
let h = host("config");
|
||||||
|
let (a, b, ha, hb, hc, blob_a) = {
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
let s = store(&tx, &h);
|
||||||
|
let (a, blob_a) = candidate(&s, b"# comment\r\nname = \"\xe6\x97\xa5\xe6\x9c\xac\"\r\n");
|
||||||
|
assert!(matches!(s.read(blob_a), Err(Error::Unavailable)));
|
||||||
|
let ha = adopt(&s, a, None, 1);
|
||||||
|
let (b, _) = candidate(&s, b"other\n");
|
||||||
|
let hb = adopt(&s, b, Some(ha), 2);
|
||||||
|
let hc = adopt(&s, a, Some(hb), 3);
|
||||||
|
assert_ne!(ha, hc);
|
||||||
|
assert!(matches!(
|
||||||
|
s.adopt(b, Some(ha), "stale".into(), 4),
|
||||||
|
Err(Error::Conflict)
|
||||||
|
));
|
||||||
|
assert_eq!(s.head().unwrap(), Some(hc));
|
||||||
|
assert!(s.snapshot(ha).unwrap().contains_key(&blob_a));
|
||||||
|
tx.commit().unwrap();
|
||||||
|
(a, b, ha, hb, hc, blob_a)
|
||||||
|
};
|
||||||
|
drop(conn);
|
||||||
|
let mut reopened = Connection::open(path).unwrap();
|
||||||
|
let tx = reopened.transaction().unwrap();
|
||||||
|
let s = store(&tx, &h);
|
||||||
|
assert_ne!(a, b);
|
||||||
|
assert_ne!(ha, hb);
|
||||||
|
assert_eq!(s.head().unwrap(), Some(hc));
|
||||||
|
let old = s.snapshot(ha).unwrap();
|
||||||
|
let restored = s.snapshot(hc).unwrap();
|
||||||
|
assert_eq!(old, restored);
|
||||||
|
assert_eq!(
|
||||||
|
s.read(blob_a).unwrap(),
|
||||||
|
Object::Blob(b"# comment\r\nname = \"\xe6\x97\xa5\xe6\x9c\xac\"\r\n".to_vec())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn caller_rollback_keeps_source_projection_and_head_atomic() {
|
||||||
|
let mut conn = Connection::open_in_memory().unwrap();
|
||||||
|
setup(&mut conn);
|
||||||
|
let h = host("config");
|
||||||
|
let original = {
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
let s = store(&tx, &h);
|
||||||
|
let (root, _) = candidate(&s, b"old");
|
||||||
|
let head = adopt(&s, root, None, 1);
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO business VALUES(?1,'old projection',?2)",
|
||||||
|
params![b"old".as_slice(), head.as_bytes().as_slice()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
tx.commit().unwrap();
|
||||||
|
head
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
let s = store(&tx, &h);
|
||||||
|
let (root, _) = candidate(&s, b"new");
|
||||||
|
let head = adopt(&s, root, Some(original), 2);
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE business SET source=?1,projection='new projection',adopted=?2",
|
||||||
|
params![b"new".as_slice(), head.as_bytes().as_slice()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
tx.rollback().unwrap();
|
||||||
|
}
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
let s = store(&tx, &h);
|
||||||
|
assert_eq!(s.head().unwrap(), Some(original));
|
||||||
|
let row: (Vec<u8>, String, Vec<u8>) = tx
|
||||||
|
.query_row("SELECT * FROM business", [], |r| {
|
||||||
|
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
row,
|
||||||
|
(
|
||||||
|
b"old".to_vec(),
|
||||||
|
"old projection".into(),
|
||||||
|
original.as_bytes().to_vec()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn injected_history_or_head_failure_cannot_publish_partial_adoption_even_if_error_caught() {
|
||||||
|
for trigger in [
|
||||||
|
"CREATE TEMP TRIGGER fail BEFORE INSERT ON content_objects WHEN NEW.kind=3 BEGIN SELECT RAISE(ABORT,'history failure'); END",
|
||||||
|
"CREATE TEMP TRIGGER fail BEFORE UPDATE OF head ON content_scopes BEGIN SELECT RAISE(ABORT,'head failure'); END",
|
||||||
|
"CREATE TEMP TRIGGER fail BEFORE INSERT ON content_reachable BEGIN SELECT RAISE(ABORT,'reachability failure'); END",
|
||||||
|
] {
|
||||||
|
let mut conn = Connection::open_in_memory().unwrap();
|
||||||
|
setup(&mut conn);
|
||||||
|
let h = host("config");
|
||||||
|
let original = {
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
let s = store(&tx, &h);
|
||||||
|
let (root, _) = candidate(&s, b"old");
|
||||||
|
let head = adopt(&s, root, None, 1);
|
||||||
|
tx.commit().unwrap();
|
||||||
|
head
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
let s = store(&tx, &h);
|
||||||
|
let (root, blob) = candidate(&s, b"new");
|
||||||
|
tx.execute_batch(trigger).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
s.adopt(root, Some(original), "editor".into(), 2),
|
||||||
|
Err(Error::Sql(_))
|
||||||
|
));
|
||||||
|
assert_eq!(s.head().unwrap(), Some(original));
|
||||||
|
assert!(matches!(s.read(blob), Err(Error::Unavailable)));
|
||||||
|
tx.execute_batch("DROP TRIGGER fail").unwrap();
|
||||||
|
tx.commit().unwrap();
|
||||||
|
}
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
let s = store(&tx, &h);
|
||||||
|
assert_eq!(s.head().unwrap(), Some(original));
|
||||||
|
let count: i64 = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT count(*) FROM content_objects WHERE kind=3",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_failure_and_missing_or_wrong_kind_children_do_not_publish_head() {
|
||||||
|
let mut conn = Connection::open_in_memory().unwrap();
|
||||||
|
setup(&mut conn);
|
||||||
|
let h = host("config");
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
let s = store(&tx, &h);
|
||||||
|
tx.execute_batch("CREATE TEMP TRIGGER fail BEFORE INSERT ON content_objects BEGIN SELECT RAISE(ABORT,'object failure'); END").unwrap();
|
||||||
|
let blob = Object::Blob(b"not saved".to_vec());
|
||||||
|
assert!(matches!(
|
||||||
|
s.put(blob.id(), &blob.encode()),
|
||||||
|
Err(Error::Sql(_))
|
||||||
|
));
|
||||||
|
tx.execute_batch("DROP TRIGGER fail").unwrap();
|
||||||
|
assert_eq!(s.head().unwrap(), None);
|
||||||
|
let ObjectId::Blob(id) = blob.id() else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let tree = Object::Tree(
|
||||||
|
Tree::map(vec![Entry {
|
||||||
|
key: "missing".into(),
|
||||||
|
target: ContentId::Blob(id),
|
||||||
|
}])
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
s.put(tree.id(), &tree.encode()).unwrap();
|
||||||
|
let ObjectId::Tree(root) = tree.id() else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
s.adopt(root, None, "e".into(), 1),
|
||||||
|
Err(Error::Unavailable)
|
||||||
|
));
|
||||||
|
s.put(blob.id(), &blob.encode()).unwrap();
|
||||||
|
let tree = Object::Tree(
|
||||||
|
Tree::map(vec![Entry {
|
||||||
|
key: "wrong kind".into(),
|
||||||
|
target: ContentId::Tree(TreeId::from_bytes(*id.as_bytes())),
|
||||||
|
}])
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
s.put(tree.id(), &tree.encode()).unwrap();
|
||||||
|
let ObjectId::Tree(root) = tree.id() else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
s.adopt(root, None, "e".into(), 1),
|
||||||
|
Err(Error::Unavailable)
|
||||||
|
));
|
||||||
|
assert_eq!(s.head().unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scoped_hash_reads_and_adoption_cannot_cross_workspace_or_target() {
|
||||||
|
let mut conn = Connection::open_in_memory().unwrap();
|
||||||
|
setup(&mut conn);
|
||||||
|
let a = host("a");
|
||||||
|
let b = host("b");
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
let sa = store(&tx, &a);
|
||||||
|
let (root, blob) = candidate(&sa, b"private");
|
||||||
|
let head = adopt(&sa, root, None, 1);
|
||||||
|
let sb = store(&tx, &b);
|
||||||
|
assert!(matches!(sb.read(blob), Err(Error::Unavailable)));
|
||||||
|
assert!(matches!(
|
||||||
|
sb.read(ObjectId::History(head)),
|
||||||
|
Err(Error::Unavailable)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
sb.adopt(root, None, "e".into(), 1),
|
||||||
|
Err(Error::Unavailable)
|
||||||
|
));
|
||||||
|
let denied = ScopedStore::new(&tx, key("another-workspace", "a"), &a);
|
||||||
|
assert!(matches!(denied.read(blob), Err(Error::Denied)));
|
||||||
|
assert!(matches!(denied.head(), Err(Error::Denied)));
|
||||||
|
let denied = ScopedStore::new(&tx, b.key.clone(), &a);
|
||||||
|
assert!(matches!(denied.read(blob), Err(Error::Denied)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_erasure_never_revives_old_scope_and_preserves_shared_history() {
|
||||||
|
let mut conn = Connection::open_in_memory().unwrap();
|
||||||
|
setup(&mut conn);
|
||||||
|
let a = host("a");
|
||||||
|
let b = host("b");
|
||||||
|
let (root, blob, ha, hb) = {
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
let sa = store(&tx, &a);
|
||||||
|
let sb = store(&tx, &b);
|
||||||
|
let (root, blob) = candidate(&sa, b"shared");
|
||||||
|
let ha = adopt(&sa, root, None, 1);
|
||||||
|
let (root_b, _) = candidate(&sb, b"shared");
|
||||||
|
assert_eq!(root, root_b);
|
||||||
|
let hb = adopt(&sb, root, None, 2);
|
||||||
|
let (old, _) = candidate(&sb, b"new");
|
||||||
|
adopt(&sb, old, Some(hb), 3);
|
||||||
|
sb.collect_unadopted().unwrap();
|
||||||
|
assert!(sb.snapshot(hb).unwrap().contains_key(&blob));
|
||||||
|
sa.erase().unwrap();
|
||||||
|
assert!(matches!(sa.read(blob), Err(Error::Erased)));
|
||||||
|
assert!(matches!(sa.snapshot(ha), Err(Error::Erased)));
|
||||||
|
assert!(sb.snapshot(hb).unwrap().contains_key(&blob));
|
||||||
|
tx.commit().unwrap();
|
||||||
|
(root, blob, ha, hb)
|
||||||
|
};
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
let sa = store(&tx, &a);
|
||||||
|
let sb = store(&tx, &b);
|
||||||
|
assert!(matches!(
|
||||||
|
sa.adopt(root, Some(ha), "e".into(), 4),
|
||||||
|
Err(Error::Erased)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
sa.put(
|
||||||
|
Object::Blob(b"shared".to_vec()).id(),
|
||||||
|
&Object::Blob(b"shared".to_vec()).encode()
|
||||||
|
),
|
||||||
|
Err(Error::Erased)
|
||||||
|
));
|
||||||
|
assert!(sb.snapshot(hb).unwrap().contains_key(&blob));
|
||||||
|
sb.erase().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
tx.query_row("SELECT count(*) FROM content_objects", [], |r| r
|
||||||
|
.get::<_, i64>(0))
|
||||||
|
.unwrap(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tampered_bytes_or_unsupported_format_fail_read_and_immutable_save() {
|
||||||
|
let mut conn = Connection::open_in_memory().unwrap();
|
||||||
|
setup(&mut conn);
|
||||||
|
let h = host("config");
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
let s = store(&tx, &h);
|
||||||
|
let (root, blob) = candidate(&s, b"original");
|
||||||
|
let head = adopt(&s, root, None, 1);
|
||||||
|
let original = s.read(blob).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
s.put(blob, &Object::Blob(b"different".to_vec()).encode()),
|
||||||
|
Err(Error::Model(_))
|
||||||
|
));
|
||||||
|
let ObjectId::Blob(id) = blob else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE content_objects SET bytes=?1 WHERE kind=1 AND hash=?2",
|
||||||
|
params![
|
||||||
|
Object::Blob(b"tamper".to_vec()).encode(),
|
||||||
|
id.as_bytes().as_slice()
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(s.snapshot(head), Err(Error::Model(_))));
|
||||||
|
assert!(matches!(
|
||||||
|
s.put(blob, &original.encode()),
|
||||||
|
Err(Error::ImmutableMismatch)
|
||||||
|
));
|
||||||
|
let mut bad = original.encode();
|
||||||
|
bad[4] = 255;
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE content_objects SET bytes=?1 WHERE kind=1 AND hash=?2",
|
||||||
|
params![bad, id.as_bytes().as_slice()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(s.snapshot(head), Err(Error::Model(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn independent_connections_reject_stale_observer_without_replacing_winner() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("concurrent.sqlite");
|
||||||
|
let mut first = Connection::open(&path).unwrap();
|
||||||
|
setup(&mut first);
|
||||||
|
let mut second = Connection::open(&path).unwrap();
|
||||||
|
let h = host("config");
|
||||||
|
let initial = {
|
||||||
|
let tx = first.transaction().unwrap();
|
||||||
|
let s = store(&tx, &h);
|
||||||
|
let (root, _) = candidate(&s, b"initial");
|
||||||
|
let head = adopt(&s, root, None, 1);
|
||||||
|
tx.commit().unwrap();
|
||||||
|
head
|
||||||
|
};
|
||||||
|
// Both clients observe the same head before either submits its edit.
|
||||||
|
for conn in [&mut first, &mut second] {
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
assert_eq!(store(&tx, &h).head().unwrap(), Some(initial));
|
||||||
|
tx.commit().unwrap();
|
||||||
|
}
|
||||||
|
let winner = {
|
||||||
|
let tx = first.transaction().unwrap();
|
||||||
|
let s = store(&tx, &h);
|
||||||
|
let (root, _) = candidate(&s, b"winner");
|
||||||
|
let winner = adopt(&s, root, Some(initial), 2);
|
||||||
|
tx.commit().unwrap();
|
||||||
|
winner
|
||||||
|
};
|
||||||
|
let tx = second.transaction().unwrap();
|
||||||
|
let s = store(&tx, &h);
|
||||||
|
let (root, _) = candidate(&s, b"loser");
|
||||||
|
assert!(matches!(
|
||||||
|
s.adopt(root, Some(initial), "loser".into(), 3),
|
||||||
|
Err(Error::Conflict)
|
||||||
|
));
|
||||||
|
assert_eq!(s.head().unwrap(), Some(winner));
|
||||||
|
tx.commit().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deduplication_and_collection_preserve_other_preparations_and_all_adopted_ancestors() {
|
||||||
|
let mut conn = Connection::open_in_memory().unwrap();
|
||||||
|
setup(&mut conn);
|
||||||
|
let a = host("a");
|
||||||
|
let b = host("b");
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
let sa = store(&tx, &a);
|
||||||
|
let sb = store(&tx, &b);
|
||||||
|
let (root, blob) = candidate(&sa, b"shared");
|
||||||
|
let first = adopt(&sa, root, None, 1);
|
||||||
|
let count = || {
|
||||||
|
tx.query_row("SELECT count(*) FROM content_objects", [], |r| {
|
||||||
|
r.get::<_, i64>(0)
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
assert_eq!(count(), 3);
|
||||||
|
candidate(&sa, b"shared");
|
||||||
|
candidate(&sb, b"shared");
|
||||||
|
assert_eq!(count(), 3);
|
||||||
|
let (new, _) = candidate(&sa, b"new");
|
||||||
|
adopt(&sa, new, Some(first), 2);
|
||||||
|
candidate(&sa, b"unadopted");
|
||||||
|
let before = count();
|
||||||
|
sa.collect_unadopted().unwrap();
|
||||||
|
assert_eq!(count(), before - 2);
|
||||||
|
assert!(sa.snapshot(first).unwrap().contains_key(&blob));
|
||||||
|
sa.erase().unwrap();
|
||||||
|
assert_eq!(count(), 2); // B's preparation survives A's erasure.
|
||||||
|
let b_head = adopt(&sb, root, None, 3);
|
||||||
|
assert!(sb.snapshot(b_head).unwrap().contains_key(&blob));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn revoked_authority_is_checked_again_on_existing_scoped_handle() {
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
struct Revocable(AtomicBool);
|
||||||
|
impl Authority for Revocable {
|
||||||
|
fn allows(&self, _: &ScopeKey, _: Access) -> bool {
|
||||||
|
self.0.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut conn = Connection::open_in_memory().unwrap();
|
||||||
|
setup(&mut conn);
|
||||||
|
let authority = Revocable(AtomicBool::new(true));
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
let s = ScopedStore::new(&tx, key("ws", "a"), &authority);
|
||||||
|
let (root, blob) = candidate(&s, b"protected");
|
||||||
|
let head = adopt(&s, root, None, 1);
|
||||||
|
authority.0.store(false, Ordering::SeqCst);
|
||||||
|
assert!(matches!(s.read(blob), Err(Error::Denied)));
|
||||||
|
assert!(matches!(s.snapshot(head), Err(Error::Denied)));
|
||||||
|
assert!(matches!(
|
||||||
|
s.adopt(root, Some(head), "e".into(), 2),
|
||||||
|
Err(Error::Denied)
|
||||||
|
));
|
||||||
|
assert!(matches!(s.erase(), Err(Error::Denied)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "content-history"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
sha2.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "wasm_parity"
|
||||||
|
crate-type = ["cdylib"]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Content history
|
||||||
|
|
||||||
|
This crate owns immutable full-text content, typed identities and pure structural
|
||||||
|
operations. It does not own persistence, authorization, retention decisions,
|
||||||
|
transaction adoption, head compare-and-set, application events or text rendering.
|
||||||
|
[`Object`] and [`Tree`] define canonical content; [`History`] separates editing
|
||||||
|
identity from content identity. [`restore`], [`diff`] and [`replace_path`] operate
|
||||||
|
through a caller-scoped [`ObjectReader`].
|
||||||
|
|
||||||
|
## Canonical identity and compatibility
|
||||||
|
|
||||||
|
Version 1 hashes the entire canonical encoding using SHA-256, including object
|
||||||
|
kind and format version. Every object starts with `YCH\0`, a big-endian `u16`
|
||||||
|
version (`1`), and a kind byte (`1` blob, `2` tree, `3` history). Integer lengths
|
||||||
|
are unsigned big-endian `u64`; strings are UTF-8 prefixed by their byte length.
|
||||||
|
|
||||||
|
- A blob contains a length and the exact full bytes.
|
||||||
|
- A tree contains a mode byte (`0` map, `1` sequence), an entry count, then each
|
||||||
|
length-prefixed key, child kind byte (`1` blob, `2` tree), and 32 hash bytes.
|
||||||
|
- A history contains 32 root hash bytes, a parent flag (`0` absent, `1` present),
|
||||||
|
optional 32 parent hash bytes, a length-prefixed actor, and a signed big-endian
|
||||||
|
`i64` timestamp in microseconds.
|
||||||
|
|
||||||
|
Map keys are unique and sorted by exact UTF-8 byte order. Sequences also have
|
||||||
|
unique keys but preserve input order. Decoding never sorts a noncanonical map.
|
||||||
|
There is no Unicode, newline, whitespace, path or source normalization. Empty
|
||||||
|
keys and separator characters are opaque strings; adapters own path validation.
|
||||||
|
No extension fields or trailing bytes are accepted. Unsupported versions fail
|
||||||
|
explicitly: a future migration must retain old bytes and identities, not silently
|
||||||
|
reserialize old objects under a new format. [`verify`] checks kind and hash after
|
||||||
|
strict decoding. Typed IDs are identities, not evidence that an object exists.
|
||||||
|
|
||||||
|
## Store and adapter boundary
|
||||||
|
|
||||||
|
Readers must be bound to an authorized business target and retained root/history.
|
||||||
|
Knowing a hash must not grant access. They must refuse erased or revoked content
|
||||||
|
for that target even when shared physical bytes remain for another target. The
|
||||||
|
model cannot enforce this policy or safely implement global garbage collection.
|
||||||
|
Readers also own I/O buffer bounds; [`TraversalLimits`] bounds work after reading.
|
||||||
|
Full restoration validates every reachable object and returns no partial success.
|
||||||
|
A structural diff intentionally skips equal IDs and does not prove closure integrity.
|
||||||
|
|
||||||
|
Path replacement returns new ancestor objects without writing anything. The
|
||||||
|
caller must validate and save the complete closure, append a history with the
|
||||||
|
current head as its parent, and CAS-adopt the new history in the same caller-owned
|
||||||
|
transaction as business projections. A rollback or stale head must leave the old
|
||||||
|
head and content unchanged. Comparing only content roots would accept stale
|
||||||
|
A-to-B-to-A edits. Restoring an old root reuses content but creates a new history;
|
||||||
|
it never rewinds business events or adopts the old history identity.
|
||||||
|
|
||||||
|
Configuration adapters can map validated virtual path components to map entries
|
||||||
|
and exact source files to blobs. Ticket adapters can map stable document fields
|
||||||
|
to blobs and stable post IDs to a sequence; reordering posts changes content.
|
||||||
|
Ticket IDs, post IDs, existing content digests, content roots and history heads
|
||||||
|
remain distinct contracts. Neither adapter belongs in this pure model. Contract
|
||||||
|
fixtures exercise these shapes without claiming database or authorization coverage.
|
||||||
|
|
||||||
|
## Cross-target fixture
|
||||||
|
|
||||||
|
`tests/fixtures/v1.tsv` freezes bytes and hashes independently derived from the
|
||||||
|
format. The `wasm_parity` raw `cdylib` example exports `parity_len` (digest words)
|
||||||
|
and `parity_word(index)` (four digest bytes interpreted big-endian). It has no JS
|
||||||
|
or host-import dependency. A runner can compare its words with the fixture hashes
|
||||||
|
on native and `wasm32-unknown-unknown`; an out-of-range index returns `u32::MAX`.
|
||||||
|
Building a WASM module alone is not evidence of executed native/WASM parity.
|
||||||
|
The optional execution adapter uses only Deno's built-in WebAssembly runtime;
|
||||||
|
run from the workspace root after registering this crate and installing the target:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo test -p content-history
|
||||||
|
cargo build -p content-history --example wasm_parity --target wasm32-unknown-unknown
|
||||||
|
deno run --allow-read crates/content-history/tests/wasm_parity.ts target/wasm32-unknown-unknown/debug/examples/wasm_parity.wasm
|
||||||
|
```
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
//! Raw WASM parity fixture. No allocator pointers, JavaScript packages or host imports.
|
||||||
|
#[path = "../tests/common/mod.rs"]
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn parity_len() -> u32 {
|
||||||
|
(common::objects().len() * 8) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn parity_word(index: u32) -> u32 {
|
||||||
|
common::parity_word(index)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#![doc = include_str!("../README.md")]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
mod model;
|
||||||
|
mod traversal;
|
||||||
|
|
||||||
|
pub use model::{
|
||||||
|
BlobId, ContentId, Entry, Error, History, HistoryId, Object, ObjectId, Tree, TreeId, verify,
|
||||||
|
};
|
||||||
|
pub use traversal::{
|
||||||
|
Change, ObjectReader, RestoredContent, RestoredEntry, Rewrite, TraversalLimits, diff,
|
||||||
|
replace_path, restore,
|
||||||
|
};
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::{collections::BTreeSet, fmt};
|
||||||
|
|
||||||
|
macro_rules! identifier {
|
||||||
|
($name:ident) => {
|
||||||
|
/// SHA-256 identity of canonical versioned bytes, not an access capability.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct $name([u8; 32]);
|
||||||
|
impl $name {
|
||||||
|
pub const fn from_bytes(bytes: [u8; 32]) -> Self {
|
||||||
|
Self(bytes)
|
||||||
|
}
|
||||||
|
pub const fn as_bytes(&self) -> &[u8; 32] {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl fmt::Display for $name {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
for byte in self.0 {
|
||||||
|
write!(f, "{byte:02x}")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl fmt::Debug for $name {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}({self})", stringify!($name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
identifier!(BlobId);
|
||||||
|
identifier!(TreeId);
|
||||||
|
identifier!(HistoryId);
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum ContentId {
|
||||||
|
Blob(BlobId),
|
||||||
|
Tree(TreeId),
|
||||||
|
}
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum ObjectId {
|
||||||
|
Blob(BlobId),
|
||||||
|
Tree(TreeId),
|
||||||
|
History(HistoryId),
|
||||||
|
}
|
||||||
|
impl From<ContentId> for ObjectId {
|
||||||
|
fn from(id: ContentId) -> Self {
|
||||||
|
match id {
|
||||||
|
ContentId::Blob(id) => Self::Blob(id),
|
||||||
|
ContentId::Tree(id) => Self::Tree(id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Failure is explicit: malformed or incomplete snapshots are never partial successes.
|
||||||
|
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("invalid object magic")]
|
||||||
|
InvalidMagic,
|
||||||
|
#[error("unsupported format version {0}")]
|
||||||
|
UnsupportedVersion(u16),
|
||||||
|
#[error("invalid object tag {0}")]
|
||||||
|
InvalidTag(u8),
|
||||||
|
#[error("invalid child kind {0}")]
|
||||||
|
InvalidChildKind(u8),
|
||||||
|
#[error("invalid tree mode {0}")]
|
||||||
|
InvalidTreeMode(u8),
|
||||||
|
#[error("invalid parent flag {0}")]
|
||||||
|
InvalidParentFlag(u8),
|
||||||
|
#[error("truncated object")]
|
||||||
|
Truncated,
|
||||||
|
#[error("length does not fit remaining bytes or platform")]
|
||||||
|
InvalidLength,
|
||||||
|
#[error("invalid UTF-8")]
|
||||||
|
InvalidUtf8,
|
||||||
|
#[error("trailing bytes")]
|
||||||
|
TrailingBytes,
|
||||||
|
#[error("duplicate tree key {0:?}")]
|
||||||
|
DuplicateKey(String),
|
||||||
|
#[error("map entries are not in strict UTF-8 byte order")]
|
||||||
|
NonCanonicalMap,
|
||||||
|
#[error("object kind or hash does not match requested identity")]
|
||||||
|
HashMismatch,
|
||||||
|
#[error("missing object {0:?}")]
|
||||||
|
MissingObject(ObjectId),
|
||||||
|
#[error("reader failed: {0}")]
|
||||||
|
Read(String),
|
||||||
|
#[error("missing path key {0:?}")]
|
||||||
|
MissingKey(String),
|
||||||
|
#[error("path component is not a tree")]
|
||||||
|
NotTree,
|
||||||
|
#[error("replacement path must not be empty")]
|
||||||
|
EmptyPath,
|
||||||
|
#[error("traversal exceeds {0} limit")]
|
||||||
|
LimitExceeded(&'static str),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Entry {
|
||||||
|
pub key: String,
|
||||||
|
pub target: ContentId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps sort by exact UTF-8 bytes; sequences retain semantic order. Both have unique keys.
|
||||||
|
/// Empty strings, slashes and Unicode are opaque keys, not normalized virtual paths.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Tree {
|
||||||
|
ordered: bool,
|
||||||
|
entries: Vec<Entry>,
|
||||||
|
}
|
||||||
|
impl Tree {
|
||||||
|
pub fn map(mut entries: Vec<Entry>) -> Result<Self, Error> {
|
||||||
|
entries.sort_by(|a, b| a.key.cmp(&b.key));
|
||||||
|
Self::build(entries, false)
|
||||||
|
}
|
||||||
|
pub fn sequence(entries: Vec<Entry>) -> Result<Self, Error> {
|
||||||
|
Self::build(entries, true)
|
||||||
|
}
|
||||||
|
fn build(entries: Vec<Entry>, ordered: bool) -> Result<Self, Error> {
|
||||||
|
let mut keys = BTreeSet::new();
|
||||||
|
for entry in &entries {
|
||||||
|
if !keys.insert(&entry.key) {
|
||||||
|
return Err(Error::DuplicateKey(entry.key.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Self { ordered, entries })
|
||||||
|
}
|
||||||
|
pub fn entries(&self) -> &[Entry] {
|
||||||
|
&self.entries
|
||||||
|
}
|
||||||
|
pub fn is_ordered(&self) -> bool {
|
||||||
|
self.ordered
|
||||||
|
}
|
||||||
|
/// Replace in place, append new sequence keys, or delete an existing key.
|
||||||
|
/// Deleting a missing key is an error; this never mutates the old tree.
|
||||||
|
pub fn replace(&self, key: &str, replacement: Option<ContentId>) -> Result<Self, Error> {
|
||||||
|
let mut entries = self.entries.clone();
|
||||||
|
match (entries.iter().position(|e| e.key == key), replacement) {
|
||||||
|
(Some(index), Some(target)) => entries[index].target = target,
|
||||||
|
(Some(index), None) => {
|
||||||
|
entries.remove(index);
|
||||||
|
}
|
||||||
|
(None, Some(target)) => entries.push(Entry {
|
||||||
|
key: key.into(),
|
||||||
|
target,
|
||||||
|
}),
|
||||||
|
(None, None) => return Err(Error::MissingKey(key.into())),
|
||||||
|
}
|
||||||
|
if self.ordered {
|
||||||
|
Self::sequence(entries)
|
||||||
|
} else {
|
||||||
|
Self::map(entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Caller-authenticated editor/time facts. Restoring old content creates a new record
|
||||||
|
/// whose parent is the current head; this type does not implement adoption or head CAS.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct History {
|
||||||
|
pub root: TreeId,
|
||||||
|
pub parent: Option<HistoryId>,
|
||||||
|
pub actor: String,
|
||||||
|
pub timestamp_micros: i64,
|
||||||
|
}
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Object {
|
||||||
|
Blob(Vec<u8>),
|
||||||
|
Tree(Tree),
|
||||||
|
History(History),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Object {
|
||||||
|
/// Canonical format v1. See the crate documentation for the compatibility boundary.
|
||||||
|
pub fn encode(&self) -> Vec<u8> {
|
||||||
|
let mut out = b"YCH\0\0\x01".to_vec();
|
||||||
|
match self {
|
||||||
|
Self::Blob(bytes) => {
|
||||||
|
out.push(1);
|
||||||
|
put_bytes(&mut out, bytes);
|
||||||
|
}
|
||||||
|
Self::Tree(tree) => {
|
||||||
|
out.push(2);
|
||||||
|
out.push(u8::from(tree.ordered));
|
||||||
|
put_u64(&mut out, tree.entries.len());
|
||||||
|
for entry in &tree.entries {
|
||||||
|
put_bytes(&mut out, entry.key.as_bytes());
|
||||||
|
match entry.target {
|
||||||
|
ContentId::Blob(id) => {
|
||||||
|
out.push(1);
|
||||||
|
out.extend_from_slice(id.as_bytes());
|
||||||
|
}
|
||||||
|
ContentId::Tree(id) => {
|
||||||
|
out.push(2);
|
||||||
|
out.extend_from_slice(id.as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::History(history) => {
|
||||||
|
out.push(3);
|
||||||
|
out.extend_from_slice(history.root.as_bytes());
|
||||||
|
match history.parent {
|
||||||
|
Some(id) => {
|
||||||
|
out.push(1);
|
||||||
|
out.extend_from_slice(id.as_bytes());
|
||||||
|
}
|
||||||
|
None => out.push(0),
|
||||||
|
}
|
||||||
|
put_bytes(&mut out, history.actor.as_bytes());
|
||||||
|
out.extend_from_slice(&history.timestamp_micros.to_be_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
/// Strict decoding rejects unknown versions, duplicate/unsorted maps and trailing bytes.
|
||||||
|
/// Lengths are checked against input before allocation; no input-driven capacity reservation.
|
||||||
|
pub fn decode(bytes: &[u8]) -> Result<Self, Error> {
|
||||||
|
let mut input = Input(bytes);
|
||||||
|
if input.take(4)? != b"YCH\0" {
|
||||||
|
return Err(Error::InvalidMagic);
|
||||||
|
}
|
||||||
|
let version = u16::from_be_bytes(input.array()?);
|
||||||
|
if version != 1 {
|
||||||
|
return Err(Error::UnsupportedVersion(version));
|
||||||
|
}
|
||||||
|
let object = match input.byte()? {
|
||||||
|
1 => Self::Blob(input.bytes()?.to_vec()),
|
||||||
|
2 => {
|
||||||
|
let ordered = match input.byte()? {
|
||||||
|
0 => false,
|
||||||
|
1 => true,
|
||||||
|
mode => return Err(Error::InvalidTreeMode(mode)),
|
||||||
|
};
|
||||||
|
let count = input.length()?;
|
||||||
|
// Each entry needs a u64 key length, a kind and 32 digest bytes.
|
||||||
|
if count > input.0.len() / 41 {
|
||||||
|
return Err(Error::InvalidLength);
|
||||||
|
}
|
||||||
|
let mut entries: Vec<Entry> = Vec::new();
|
||||||
|
for _ in 0..count {
|
||||||
|
let key = input.string()?;
|
||||||
|
if !ordered && entries.last().is_some_and(|last| last.key >= key) {
|
||||||
|
return Err(Error::NonCanonicalMap);
|
||||||
|
}
|
||||||
|
let kind = input.byte()?;
|
||||||
|
let target = match kind {
|
||||||
|
1 => ContentId::Blob(BlobId(input.array()?)),
|
||||||
|
2 => ContentId::Tree(TreeId(input.array()?)),
|
||||||
|
_ => return Err(Error::InvalidChildKind(kind)),
|
||||||
|
};
|
||||||
|
entries.push(Entry { key, target });
|
||||||
|
}
|
||||||
|
Self::Tree(Tree::build(entries, ordered)?)
|
||||||
|
}
|
||||||
|
3 => {
|
||||||
|
let root = TreeId(input.array()?);
|
||||||
|
let parent = match input.byte()? {
|
||||||
|
0 => None,
|
||||||
|
1 => Some(HistoryId(input.array()?)),
|
||||||
|
flag => return Err(Error::InvalidParentFlag(flag)),
|
||||||
|
};
|
||||||
|
let actor = input.string()?;
|
||||||
|
let timestamp_micros = i64::from_be_bytes(input.array()?);
|
||||||
|
Self::History(History {
|
||||||
|
root,
|
||||||
|
parent,
|
||||||
|
actor,
|
||||||
|
timestamp_micros,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
tag => return Err(Error::InvalidTag(tag)),
|
||||||
|
};
|
||||||
|
if !input.0.is_empty() {
|
||||||
|
return Err(Error::TrailingBytes);
|
||||||
|
}
|
||||||
|
Ok(object)
|
||||||
|
}
|
||||||
|
pub fn id(&self) -> ObjectId {
|
||||||
|
let hash: [u8; 32] = Sha256::digest(self.encode()).into();
|
||||||
|
match self {
|
||||||
|
Self::Blob(_) => ObjectId::Blob(BlobId(hash)),
|
||||||
|
Self::Tree(_) => ObjectId::Tree(TreeId(hash)),
|
||||||
|
Self::History(_) => ObjectId::History(HistoryId(hash)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate exact stored bytes and requested kind before returning any content.
|
||||||
|
/// Decoding is intentionally first, so unsupported formats remain diagnosable.
|
||||||
|
pub fn verify(id: ObjectId, bytes: &[u8]) -> Result<Object, Error> {
|
||||||
|
let object = Object::decode(bytes)?;
|
||||||
|
// Hash the stored preimage, not a reserialization under the current writer's format.
|
||||||
|
let hash: [u8; 32] = Sha256::digest(bytes).into();
|
||||||
|
let actual = match &object {
|
||||||
|
Object::Blob(_) => ObjectId::Blob(BlobId(hash)),
|
||||||
|
Object::Tree(_) => ObjectId::Tree(TreeId(hash)),
|
||||||
|
Object::History(_) => ObjectId::History(HistoryId(hash)),
|
||||||
|
};
|
||||||
|
if actual != id {
|
||||||
|
return Err(Error::HashMismatch);
|
||||||
|
}
|
||||||
|
Ok(object)
|
||||||
|
}
|
||||||
|
fn put_u64(out: &mut Vec<u8>, length: usize) {
|
||||||
|
out.extend_from_slice(&(length as u64).to_be_bytes());
|
||||||
|
}
|
||||||
|
fn put_bytes(out: &mut Vec<u8>, bytes: &[u8]) {
|
||||||
|
put_u64(out, bytes.len());
|
||||||
|
out.extend_from_slice(bytes);
|
||||||
|
}
|
||||||
|
struct Input<'a>(&'a [u8]);
|
||||||
|
impl<'a> Input<'a> {
|
||||||
|
fn take(&mut self, count: usize) -> Result<&'a [u8], Error> {
|
||||||
|
if count > self.0.len() {
|
||||||
|
return Err(Error::Truncated);
|
||||||
|
}
|
||||||
|
let (head, tail) = self.0.split_at(count);
|
||||||
|
self.0 = tail;
|
||||||
|
Ok(head)
|
||||||
|
}
|
||||||
|
fn array<const N: usize>(&mut self) -> Result<[u8; N], Error> {
|
||||||
|
Ok(self.take(N)?.try_into().expect("checked slice length"))
|
||||||
|
}
|
||||||
|
fn byte(&mut self) -> Result<u8, Error> {
|
||||||
|
Ok(self.array::<1>()?[0])
|
||||||
|
}
|
||||||
|
fn length(&mut self) -> Result<usize, Error> {
|
||||||
|
usize::try_from(u64::from_be_bytes(self.array()?)).map_err(|_| Error::InvalidLength)
|
||||||
|
}
|
||||||
|
fn bytes(&mut self) -> Result<&'a [u8], Error> {
|
||||||
|
let length = self.length()?;
|
||||||
|
if length > self.0.len() {
|
||||||
|
return Err(Error::InvalidLength);
|
||||||
|
}
|
||||||
|
self.take(length)
|
||||||
|
}
|
||||||
|
fn string(&mut self) -> Result<String, Error> {
|
||||||
|
std::str::from_utf8(self.bytes()?)
|
||||||
|
.map(str::to_owned)
|
||||||
|
.map_err(|_| Error::InvalidUtf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
use crate::{ContentId, Error, Object, ObjectId, Tree, TreeId, verify};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
/// Reader already bound to an authorized target and retention policy by its caller.
|
||||||
|
/// Hash possession is not authority. Revoked/erased objects must fail here even if
|
||||||
|
/// the physical bytes are deduplicated with another target. Missing data is an error.
|
||||||
|
/// The reader owns I/O size limits; traversal checks bytes after each returned buffer.
|
||||||
|
pub trait ObjectReader {
|
||||||
|
fn read(&mut self, id: ObjectId) -> Result<Vec<u8>, Error>;
|
||||||
|
}
|
||||||
|
impl<F: FnMut(ObjectId) -> Result<Vec<u8>, Error>> ObjectReader for F {
|
||||||
|
fn read(&mut self, id: ObjectId) -> Result<Vec<u8>, Error> {
|
||||||
|
self(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Work budgets count all reads (including repeated shared subtrees) and encoded bytes.
|
||||||
|
/// Root depth is zero. Depth is additionally capped at 256 to bound Rust stack use.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct TraversalLimits {
|
||||||
|
pub max_depth: usize,
|
||||||
|
pub max_objects: usize,
|
||||||
|
pub max_bytes: usize,
|
||||||
|
}
|
||||||
|
impl Default for TraversalLimits {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_depth: 128,
|
||||||
|
max_objects: 100_000,
|
||||||
|
max_bytes: 64 * 1024 * 1024,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
struct Budget {
|
||||||
|
limits: TraversalLimits,
|
||||||
|
objects: usize,
|
||||||
|
bytes: usize,
|
||||||
|
}
|
||||||
|
impl Budget {
|
||||||
|
fn new(limits: TraversalLimits) -> Self {
|
||||||
|
Self {
|
||||||
|
limits,
|
||||||
|
objects: 0,
|
||||||
|
bytes: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn depth(&self, depth: usize) -> Result<(), Error> {
|
||||||
|
if depth > self.limits.max_depth.min(256) {
|
||||||
|
return Err(Error::LimitExceeded("depth"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn read<R: ObjectReader + ?Sized>(
|
||||||
|
&mut self,
|
||||||
|
reader: &mut R,
|
||||||
|
id: ObjectId,
|
||||||
|
depth: usize,
|
||||||
|
) -> Result<Object, Error> {
|
||||||
|
self.depth(depth)?;
|
||||||
|
if self.objects >= self.limits.max_objects {
|
||||||
|
return Err(Error::LimitExceeded("objects"));
|
||||||
|
}
|
||||||
|
self.objects += 1;
|
||||||
|
let bytes = reader.read(id)?;
|
||||||
|
self.bytes = self
|
||||||
|
.bytes
|
||||||
|
.checked_add(bytes.len())
|
||||||
|
.ok_or(Error::LimitExceeded("bytes"))?;
|
||||||
|
if self.bytes > self.limits.max_bytes {
|
||||||
|
return Err(Error::LimitExceeded("bytes"));
|
||||||
|
}
|
||||||
|
verify(id, &bytes)
|
||||||
|
}
|
||||||
|
fn tree<R: ObjectReader + ?Sized>(
|
||||||
|
&mut self,
|
||||||
|
reader: &mut R,
|
||||||
|
id: TreeId,
|
||||||
|
depth: usize,
|
||||||
|
) -> Result<Tree, Error> {
|
||||||
|
match self.read(reader, ObjectId::Tree(id), depth)? {
|
||||||
|
Object::Tree(tree) => Ok(tree),
|
||||||
|
_ => Err(Error::NotTree),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fully materialized bytes and exact component order, not an applied business object.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum RestoredContent {
|
||||||
|
Blob(Vec<u8>),
|
||||||
|
Tree {
|
||||||
|
ordered: bool,
|
||||||
|
entries: Vec<RestoredEntry>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct RestoredEntry {
|
||||||
|
pub key: String,
|
||||||
|
pub content: RestoredContent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore a complete content snapshot without reading or replaying history/diffs.
|
||||||
|
/// On failure no partial result is returned. This does not restore state, assignments,
|
||||||
|
/// authorization, or other business events, nor adopt a historical head.
|
||||||
|
pub fn restore<R: ObjectReader + ?Sized>(
|
||||||
|
reader: &mut R,
|
||||||
|
root: TreeId,
|
||||||
|
limits: TraversalLimits,
|
||||||
|
) -> Result<RestoredContent, Error> {
|
||||||
|
restore_content(reader, ContentId::Tree(root), &mut Budget::new(limits), 0)
|
||||||
|
}
|
||||||
|
fn restore_content<R: ObjectReader + ?Sized>(
|
||||||
|
reader: &mut R,
|
||||||
|
id: ContentId,
|
||||||
|
budget: &mut Budget,
|
||||||
|
depth: usize,
|
||||||
|
) -> Result<RestoredContent, Error> {
|
||||||
|
match budget.read(reader, id.into(), depth)? {
|
||||||
|
Object::Blob(bytes) => Ok(RestoredContent::Blob(bytes)),
|
||||||
|
Object::Tree(tree) => {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for entry in tree.entries() {
|
||||||
|
entries.push(RestoredEntry {
|
||||||
|
key: entry.key.clone(),
|
||||||
|
content: restore_content(reader, entry.target, budget, depth + 1)?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(RestoredContent::Tree {
|
||||||
|
ordered: tree.is_ordered(),
|
||||||
|
entries,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Object::History(_) => unreachable!("verification enforces requested kind"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A replacement at an opaque key path. Empty path denotes the root itself.
|
||||||
|
/// Added/removed subtrees and tree mode/sequence layout changes are coarse replacements;
|
||||||
|
/// otherwise differences recurse by key. This is structural, not a text-line diff.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Change {
|
||||||
|
pub path: Vec<String>,
|
||||||
|
pub before: Option<ContentId>,
|
||||||
|
pub after: Option<ContentId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skip identical subtree hashes without even reading them. This is not a closure
|
||||||
|
/// integrity check: unchanged or added/removed subtrees are intentionally not loaded.
|
||||||
|
/// Use [`restore`] when complete snapshot integrity is needed before adoption.
|
||||||
|
/// Order is deterministic: map keys sort by UTF-8; stable-layout sequences follow order.
|
||||||
|
pub fn diff<R: ObjectReader + ?Sized>(
|
||||||
|
reader: &mut R,
|
||||||
|
before: TreeId,
|
||||||
|
after: TreeId,
|
||||||
|
limits: TraversalLimits,
|
||||||
|
) -> Result<Vec<Change>, Error> {
|
||||||
|
let mut changes = Vec::new();
|
||||||
|
diff_content(
|
||||||
|
reader,
|
||||||
|
ContentId::Tree(before),
|
||||||
|
ContentId::Tree(after),
|
||||||
|
&mut Vec::new(),
|
||||||
|
&mut Budget::new(limits),
|
||||||
|
&mut changes,
|
||||||
|
)?;
|
||||||
|
Ok(changes)
|
||||||
|
}
|
||||||
|
fn diff_content<R: ObjectReader + ?Sized>(
|
||||||
|
reader: &mut R,
|
||||||
|
before: ContentId,
|
||||||
|
after: ContentId,
|
||||||
|
path: &mut Vec<String>,
|
||||||
|
budget: &mut Budget,
|
||||||
|
changes: &mut Vec<Change>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
if before == after {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
budget.depth(path.len())?;
|
||||||
|
if let (ContentId::Tree(left), ContentId::Tree(right)) = (before, after) {
|
||||||
|
let left = budget.tree(reader, left, path.len())?;
|
||||||
|
let right = budget.tree(reader, right, path.len())?;
|
||||||
|
let layout_changed = left.is_ordered() != right.is_ordered()
|
||||||
|
|| (left.is_ordered()
|
||||||
|
&& !left
|
||||||
|
.entries()
|
||||||
|
.iter()
|
||||||
|
.map(|e| &e.key)
|
||||||
|
.eq(right.entries().iter().map(|e| &e.key)));
|
||||||
|
if !layout_changed {
|
||||||
|
let left_by_key: BTreeMap<_, _> = left
|
||||||
|
.entries()
|
||||||
|
.iter()
|
||||||
|
.map(|e| (e.key.as_str(), e.target))
|
||||||
|
.collect();
|
||||||
|
let right_by_key: BTreeMap<_, _> = right
|
||||||
|
.entries()
|
||||||
|
.iter()
|
||||||
|
.map(|e| (e.key.as_str(), e.target))
|
||||||
|
.collect();
|
||||||
|
let keys: Vec<&str> = if left.is_ordered() {
|
||||||
|
left.entries().iter().map(|e| e.key.as_str()).collect()
|
||||||
|
} else {
|
||||||
|
left_by_key
|
||||||
|
.keys()
|
||||||
|
.chain(right_by_key.keys())
|
||||||
|
.copied()
|
||||||
|
.collect::<std::collections::BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
for key in keys {
|
||||||
|
path.push(key.into());
|
||||||
|
let old = left_by_key.get(key).copied();
|
||||||
|
let new = right_by_key.get(key).copied();
|
||||||
|
match (old, new) {
|
||||||
|
(Some(old), Some(new)) => {
|
||||||
|
diff_content(reader, old, new, path, budget, changes)?
|
||||||
|
}
|
||||||
|
_ => changes.push(Change {
|
||||||
|
path: path.clone(),
|
||||||
|
before: old,
|
||||||
|
after: new,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
path.pop();
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changes.push(Change {
|
||||||
|
path: path.clone(),
|
||||||
|
before: Some(before),
|
||||||
|
after: Some(after),
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// New ancestor objects, leaf-first. The replacement object itself is caller-owned.
|
||||||
|
/// Nothing is saved or adopted; caller must persist a complete verified closure and
|
||||||
|
/// history before atomically CAS-adopting the new head in its transaction.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Rewrite {
|
||||||
|
pub root: TreeId,
|
||||||
|
pub objects: Vec<Object>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy-on-write replacement along exact keys (no slash splitting/normalization).
|
||||||
|
/// Intermediate keys must exist and refer to trees. The final key may be inserted,
|
||||||
|
/// replaced, or deleted. Sequence insertion appends; replacement preserves position.
|
||||||
|
/// Only ancestors are read; this does not validate the replacement object's closure.
|
||||||
|
pub fn replace_path<R: ObjectReader + ?Sized>(
|
||||||
|
reader: &mut R,
|
||||||
|
root: TreeId,
|
||||||
|
path: &[&str],
|
||||||
|
replacement: Option<ContentId>,
|
||||||
|
limits: TraversalLimits,
|
||||||
|
) -> Result<Rewrite, Error> {
|
||||||
|
if path.is_empty() {
|
||||||
|
return Err(Error::EmptyPath);
|
||||||
|
}
|
||||||
|
let mut budget = Budget::new(limits);
|
||||||
|
budget.depth(path.len())?;
|
||||||
|
let mut objects = Vec::new();
|
||||||
|
let root = rewrite(
|
||||||
|
reader,
|
||||||
|
root,
|
||||||
|
path,
|
||||||
|
replacement,
|
||||||
|
&mut budget,
|
||||||
|
0,
|
||||||
|
&mut objects,
|
||||||
|
)?;
|
||||||
|
Ok(Rewrite { root, objects })
|
||||||
|
}
|
||||||
|
fn rewrite<R: ObjectReader + ?Sized>(
|
||||||
|
reader: &mut R,
|
||||||
|
root: TreeId,
|
||||||
|
path: &[&str],
|
||||||
|
replacement: Option<ContentId>,
|
||||||
|
budget: &mut Budget,
|
||||||
|
depth: usize,
|
||||||
|
objects: &mut Vec<Object>,
|
||||||
|
) -> Result<TreeId, Error> {
|
||||||
|
let tree = budget.tree(reader, root, depth)?;
|
||||||
|
let target = if path.len() == 1 {
|
||||||
|
replacement
|
||||||
|
} else {
|
||||||
|
let entry = tree
|
||||||
|
.entries()
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.key == path[0])
|
||||||
|
.ok_or_else(|| Error::MissingKey(path[0].into()))?;
|
||||||
|
let ContentId::Tree(child) = entry.target else {
|
||||||
|
return Err(Error::NotTree);
|
||||||
|
};
|
||||||
|
Some(ContentId::Tree(rewrite(
|
||||||
|
reader,
|
||||||
|
child,
|
||||||
|
&path[1..],
|
||||||
|
replacement,
|
||||||
|
budget,
|
||||||
|
depth + 1,
|
||||||
|
objects,
|
||||||
|
)?))
|
||||||
|
};
|
||||||
|
let object = Object::Tree(tree.replace(path[0], target)?);
|
||||||
|
let ObjectId::Tree(id) = object.id() else {
|
||||||
|
unreachable!("tree object")
|
||||||
|
};
|
||||||
|
if id != root {
|
||||||
|
objects.push(object);
|
||||||
|
}
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
use content_history::{ContentId, Entry, History, Object, ObjectId, Tree};
|
||||||
|
|
||||||
|
pub fn objects() -> Vec<Object> {
|
||||||
|
let empty = Object::Blob(Vec::new());
|
||||||
|
let text = Object::Blob(b"a\r\n\xc3\xa9\0".to_vec());
|
||||||
|
let ObjectId::Blob(empty_id) = empty.id() else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let ObjectId::Blob(text_id) = text.id() else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let entries = vec![
|
||||||
|
Entry {
|
||||||
|
key: "é".into(),
|
||||||
|
target: ContentId::Blob(text_id),
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
key: "z".into(),
|
||||||
|
target: ContentId::Blob(empty_id),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let map = Object::Tree(Tree::map(entries.clone()).unwrap());
|
||||||
|
let sequence = Object::Tree(Tree::sequence(entries).unwrap());
|
||||||
|
let ObjectId::Tree(root) = map.id() else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let first = Object::History(History {
|
||||||
|
root,
|
||||||
|
parent: None,
|
||||||
|
actor: "user:編集".into(),
|
||||||
|
timestamp_micros: -1,
|
||||||
|
});
|
||||||
|
let ObjectId::History(parent) = first.id() else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let returned = Object::History(History {
|
||||||
|
root,
|
||||||
|
parent: Some(parent),
|
||||||
|
actor: "user:編集".into(),
|
||||||
|
timestamp_micros: 0,
|
||||||
|
});
|
||||||
|
vec![
|
||||||
|
empty,
|
||||||
|
text,
|
||||||
|
Object::Tree(Tree::map(vec![]).unwrap()),
|
||||||
|
Object::Tree(Tree::sequence(vec![]).unwrap()),
|
||||||
|
map,
|
||||||
|
sequence,
|
||||||
|
first,
|
||||||
|
returned,
|
||||||
|
Object::Tree(
|
||||||
|
Tree::map(vec![Entry {
|
||||||
|
key: "nested".into(),
|
||||||
|
target: ContentId::Tree(root),
|
||||||
|
}])
|
||||||
|
.unwrap(),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn digest_bytes(object: &Object) -> [u8; 32] {
|
||||||
|
match object.id() {
|
||||||
|
ObjectId::Blob(id) => *id.as_bytes(),
|
||||||
|
ObjectId::Tree(id) => *id.as_bytes(),
|
||||||
|
ObjectId::History(id) => *id.as_bytes(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parity_word(index: u32) -> u32 {
|
||||||
|
let objects = objects();
|
||||||
|
let Some(object) = objects.get(index as usize / 8) else {
|
||||||
|
return u32::MAX;
|
||||||
|
};
|
||||||
|
let hash = digest_bytes(object);
|
||||||
|
let offset = (index as usize % 8) * 4;
|
||||||
|
u32::from_be_bytes(hash[offset..offset + 4].try_into().unwrap())
|
||||||
|
}
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
//! Pure model contract tests; the fixture store substitutes only scoped byte I/O.
|
||||||
|
//! No claims about database transactions, authorization, head CAS or business adoption.
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use content_history::*;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
fn unhex(text: &str) -> Vec<u8> {
|
||||||
|
text.as_bytes()
|
||||||
|
.chunks_exact(2)
|
||||||
|
.map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
fn content(object: &Object) -> ContentId {
|
||||||
|
match object.id() {
|
||||||
|
ObjectId::Blob(id) => ContentId::Blob(id),
|
||||||
|
ObjectId::Tree(id) => ContentId::Tree(id),
|
||||||
|
_ => panic!("content required"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn entry(key: &str, target: ContentId) -> Entry {
|
||||||
|
Entry {
|
||||||
|
key: key.into(),
|
||||||
|
target,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn map(entries: Vec<Entry>) -> Object {
|
||||||
|
Object::Tree(Tree::map(entries).unwrap())
|
||||||
|
}
|
||||||
|
fn tree_id(object: &Object) -> TreeId {
|
||||||
|
let ContentId::Tree(id) = content(object) else {
|
||||||
|
panic!("tree required")
|
||||||
|
};
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn version_one_vectors_freeze_bytes_hashes_and_parity_words() {
|
||||||
|
let lines: Vec<_> = include_str!("fixtures/v1.tsv")
|
||||||
|
.lines()
|
||||||
|
.filter(|line| !line.starts_with('#'))
|
||||||
|
.collect();
|
||||||
|
let objects = common::objects();
|
||||||
|
assert_eq!(lines.len(), objects.len());
|
||||||
|
for (index, (line, object)) in lines.iter().zip(&objects).enumerate() {
|
||||||
|
let columns: Vec<_> = line.split('\t').collect();
|
||||||
|
let bytes = unhex(columns[1]);
|
||||||
|
let digest = unhex(columns[2]);
|
||||||
|
assert_eq!(object.encode(), bytes, "{} bytes", columns[0]);
|
||||||
|
assert_eq!(
|
||||||
|
common::digest_bytes(object).as_slice(),
|
||||||
|
digest,
|
||||||
|
"{} hash",
|
||||||
|
columns[0]
|
||||||
|
);
|
||||||
|
assert_eq!(Object::decode(&bytes).unwrap(), *object);
|
||||||
|
assert_eq!(verify(object.id(), &bytes).unwrap(), *object);
|
||||||
|
for word in 0..8 {
|
||||||
|
assert_eq!(
|
||||||
|
common::parity_word((index * 8 + word) as u32),
|
||||||
|
u32::from_be_bytes(digest[word * 4..word * 4 + 4].try_into().unwrap())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(common::parity_word((objects.len() * 8) as u32), u32::MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_canonicalize_enumeration_but_sequences_keys_and_exact_bytes_affect_identity() {
|
||||||
|
let blob = Object::Blob(b"x\r\n".to_vec());
|
||||||
|
let target = content(&blob);
|
||||||
|
let a = entry("a", target);
|
||||||
|
let b = entry("é", target);
|
||||||
|
assert_eq!(
|
||||||
|
map(vec![b.clone(), a.clone()]).id(),
|
||||||
|
map(vec![a.clone(), b.clone()]).id()
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
Object::Tree(Tree::sequence(vec![a.clone(), b.clone()]).unwrap()).id(),
|
||||||
|
Object::Tree(Tree::sequence(vec![b.clone(), a.clone()]).unwrap()).id()
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
map(vec![a.clone()]).id(),
|
||||||
|
map(vec![entry("b", target)]).id()
|
||||||
|
);
|
||||||
|
assert_ne!(blob.id(), Object::Blob(b"x\n".to_vec()).id());
|
||||||
|
assert_ne!(
|
||||||
|
Object::Blob("é".as_bytes().to_vec()).id(),
|
||||||
|
Object::Blob("e\u{301}".as_bytes().to_vec()).id()
|
||||||
|
);
|
||||||
|
let ContentId::Blob(hash) = target else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
assert_ne!(
|
||||||
|
map(vec![a.clone()]).id(),
|
||||||
|
map(vec![entry(
|
||||||
|
"a",
|
||||||
|
ContentId::Tree(TreeId::from_bytes(*hash.as_bytes()))
|
||||||
|
)])
|
||||||
|
.id()
|
||||||
|
);
|
||||||
|
for result in [
|
||||||
|
Tree::map(vec![a.clone(), a.clone()]),
|
||||||
|
Tree::sequence(vec![a.clone(), a]),
|
||||||
|
] {
|
||||||
|
assert!(matches!(result, Err(Error::DuplicateKey(_))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decoder_rejects_all_truncations_and_trailing_bytes() {
|
||||||
|
for object in common::objects() {
|
||||||
|
let bytes = object.encode();
|
||||||
|
for end in 0..bytes.len() {
|
||||||
|
assert!(Object::decode(&bytes[..end]).is_err(), "prefix {end}");
|
||||||
|
}
|
||||||
|
let mut trailing = bytes;
|
||||||
|
trailing.push(0);
|
||||||
|
assert_eq!(Object::decode(&trailing), Err(Error::TrailingBytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decoder_rejects_unknown_domains_and_malicious_lengths() {
|
||||||
|
let blob = Object::Blob(vec![]).encode();
|
||||||
|
let mut malformed = blob.clone();
|
||||||
|
malformed[0] = 0;
|
||||||
|
assert_eq!(Object::decode(&malformed), Err(Error::InvalidMagic));
|
||||||
|
malformed = blob.clone();
|
||||||
|
malformed[5] = 2;
|
||||||
|
assert_eq!(
|
||||||
|
Object::decode(&malformed),
|
||||||
|
Err(Error::UnsupportedVersion(2))
|
||||||
|
);
|
||||||
|
malformed = blob.clone();
|
||||||
|
malformed[6] = 99;
|
||||||
|
assert_eq!(Object::decode(&malformed), Err(Error::InvalidTag(99)));
|
||||||
|
malformed = blob;
|
||||||
|
malformed[7..15].fill(255);
|
||||||
|
assert_eq!(Object::decode(&malformed), Err(Error::InvalidLength));
|
||||||
|
let target = ContentId::Blob(BlobId::from_bytes([0; 32]));
|
||||||
|
let tree = map(vec![entry("a", target)]).encode();
|
||||||
|
malformed = tree.clone();
|
||||||
|
malformed[7] = 2;
|
||||||
|
assert_eq!(Object::decode(&malformed), Err(Error::InvalidTreeMode(2)));
|
||||||
|
malformed = tree.clone();
|
||||||
|
malformed[8..16].fill(255);
|
||||||
|
assert_eq!(Object::decode(&malformed), Err(Error::InvalidLength));
|
||||||
|
malformed = tree.clone();
|
||||||
|
malformed[16..24].fill(255);
|
||||||
|
assert_eq!(Object::decode(&malformed), Err(Error::InvalidLength));
|
||||||
|
malformed = tree.clone();
|
||||||
|
malformed[24] = 255;
|
||||||
|
assert_eq!(Object::decode(&malformed), Err(Error::InvalidUtf8));
|
||||||
|
malformed = tree;
|
||||||
|
malformed[25] = 3;
|
||||||
|
assert_eq!(Object::decode(&malformed), Err(Error::InvalidChildKind(3)));
|
||||||
|
let history = common::objects()[6].encode();
|
||||||
|
malformed = history.clone();
|
||||||
|
malformed[39] = 2;
|
||||||
|
assert_eq!(Object::decode(&malformed), Err(Error::InvalidParentFlag(2)));
|
||||||
|
malformed = history;
|
||||||
|
malformed[40..48].fill(255);
|
||||||
|
assert_eq!(Object::decode(&malformed), Err(Error::InvalidLength));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decoder_refuses_unsorted_maps_and_duplicate_sequences_instead_of_repairing_bytes() {
|
||||||
|
let target = ContentId::Blob(BlobId::from_bytes([0; 32]));
|
||||||
|
let mut sequence =
|
||||||
|
Object::Tree(Tree::sequence(vec![entry("b", target), entry("a", target)]).unwrap())
|
||||||
|
.encode();
|
||||||
|
sequence[7] = 0;
|
||||||
|
assert_eq!(Object::decode(&sequence), Err(Error::NonCanonicalMap));
|
||||||
|
sequence[7] = 1;
|
||||||
|
// First entry starts at 16, each single-byte-key entry occupies 42 bytes.
|
||||||
|
sequence[66] = b'b';
|
||||||
|
assert!(matches!(
|
||||||
|
Object::decode(&sequence),
|
||||||
|
Err(Error::DuplicateKey(_))
|
||||||
|
));
|
||||||
|
sequence[7] = 0;
|
||||||
|
assert_eq!(Object::decode(&sequence), Err(Error::NonCanonicalMap));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn integrity_rejects_modified_content_and_wrong_typed_identity() {
|
||||||
|
let object = Object::Blob(b"original".to_vec());
|
||||||
|
assert_eq!(
|
||||||
|
verify(object.id(), &Object::Blob(b"modified".to_vec()).encode()),
|
||||||
|
Err(Error::HashMismatch)
|
||||||
|
);
|
||||||
|
let ObjectId::Blob(id) = object.id() else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
verify(
|
||||||
|
ObjectId::Tree(TreeId::from_bytes(*id.as_bytes())),
|
||||||
|
&object.encode()
|
||||||
|
),
|
||||||
|
Err(Error::HashMismatch)
|
||||||
|
);
|
||||||
|
assert_eq!(id.to_string().len(), 64);
|
||||||
|
assert!(
|
||||||
|
id.to_string()
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Memory {
|
||||||
|
objects: BTreeMap<ObjectId, Vec<u8>>,
|
||||||
|
reads: Vec<ObjectId>,
|
||||||
|
}
|
||||||
|
impl Memory {
|
||||||
|
fn put(&mut self, object: Object) -> ContentId {
|
||||||
|
self.objects.insert(object.id(), object.encode());
|
||||||
|
content(&object)
|
||||||
|
}
|
||||||
|
fn root(&mut self, object: Object) -> TreeId {
|
||||||
|
let root = tree_id(&object);
|
||||||
|
self.put(object);
|
||||||
|
root
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl ObjectReader for Memory {
|
||||||
|
fn read(&mut self, id: ObjectId) -> Result<Vec<u8>, Error> {
|
||||||
|
self.reads.push(id);
|
||||||
|
self.objects
|
||||||
|
.get(&id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or(Error::MissingObject(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn restored_entry(key: &str, content: RestoredContent) -> RestoredEntry {
|
||||||
|
RestoredEntry {
|
||||||
|
key: key.into(),
|
||||||
|
content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn restored_map(entries: Vec<RestoredEntry>) -> RestoredContent {
|
||||||
|
RestoredContent::Tree {
|
||||||
|
ordered: false,
|
||||||
|
entries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configuration_and_ticket_shapes_restore_exact_bytes_and_business_key_order() {
|
||||||
|
let mut store = Memory::default();
|
||||||
|
let source = "# comment\r\nname = \"é\"\n".as_bytes();
|
||||||
|
let file = store.put(Object::Blob(source.to_vec()));
|
||||||
|
let config = store.put(map(vec![entry("settings.dcdl", file)]));
|
||||||
|
let title = store.put(Object::Blob(b"title".to_vec()));
|
||||||
|
let body = store.put(Object::Blob(b"initial body".to_vec()));
|
||||||
|
let posts = store.put(Object::Tree(
|
||||||
|
Tree::sequence(vec![entry("post-z", body), entry("post-a", title)]).unwrap(),
|
||||||
|
));
|
||||||
|
let ticket = store.put(map(vec![
|
||||||
|
entry("title", title),
|
||||||
|
entry("body", body),
|
||||||
|
entry("posts", posts),
|
||||||
|
]));
|
||||||
|
let root = store.root(map(vec![
|
||||||
|
entry("config", config),
|
||||||
|
entry("ticket-identity", ticket),
|
||||||
|
]));
|
||||||
|
let restored = restore(&mut store, root, TraversalLimits::default()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
restored,
|
||||||
|
restored_map(vec![
|
||||||
|
restored_entry(
|
||||||
|
"config",
|
||||||
|
restored_map(vec![restored_entry(
|
||||||
|
"settings.dcdl",
|
||||||
|
RestoredContent::Blob(source.to_vec())
|
||||||
|
)])
|
||||||
|
),
|
||||||
|
restored_entry(
|
||||||
|
"ticket-identity",
|
||||||
|
restored_map(vec![
|
||||||
|
restored_entry("body", RestoredContent::Blob(b"initial body".to_vec())),
|
||||||
|
restored_entry(
|
||||||
|
"posts",
|
||||||
|
RestoredContent::Tree {
|
||||||
|
ordered: true,
|
||||||
|
entries: vec![
|
||||||
|
restored_entry(
|
||||||
|
"post-z",
|
||||||
|
RestoredContent::Blob(b"initial body".to_vec())
|
||||||
|
),
|
||||||
|
restored_entry("post-a", RestoredContent::Blob(b"title".to_vec()))
|
||||||
|
]
|
||||||
|
}
|
||||||
|
),
|
||||||
|
restored_entry("title", RestoredContent::Blob(b"title".to_vec())),
|
||||||
|
])
|
||||||
|
),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copy_on_write_path_replacement_preserves_old_fulltext_and_shares_unchanged_objects() {
|
||||||
|
let mut store = Memory::default();
|
||||||
|
let old_blob = store.put(Object::Blob(b"old full text".to_vec()));
|
||||||
|
let new_blob = store.put(Object::Blob(b"new full text".to_vec()));
|
||||||
|
let child = store.put(map(vec![
|
||||||
|
entry("body", old_blob),
|
||||||
|
entry("unchanged", old_blob),
|
||||||
|
]));
|
||||||
|
let root = store.root(map(vec![entry("document", child)]));
|
||||||
|
let old_snapshot = restore(&mut store, root, TraversalLimits::default()).unwrap();
|
||||||
|
let rewrite = replace_path(
|
||||||
|
&mut store,
|
||||||
|
root,
|
||||||
|
&["document", "body"],
|
||||||
|
Some(new_blob),
|
||||||
|
TraversalLimits::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rewrite.objects.len(), 2);
|
||||||
|
assert_ne!(rewrite.root, root);
|
||||||
|
for object in &rewrite.objects {
|
||||||
|
store.put(object.clone());
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
restore(&mut store, root, TraversalLimits::default()).unwrap(),
|
||||||
|
old_snapshot
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
restore(&mut store, rewrite.root, TraversalLimits::default()).unwrap(),
|
||||||
|
old_snapshot
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
diff(&mut store, root, rewrite.root, TraversalLimits::default()).unwrap(),
|
||||||
|
vec![Change {
|
||||||
|
path: vec!["document".into(), "body".into()],
|
||||||
|
before: Some(old_blob),
|
||||||
|
after: Some(new_blob)
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
let unchanged = replace_path(
|
||||||
|
&mut store,
|
||||||
|
root,
|
||||||
|
&["document", "body"],
|
||||||
|
Some(old_blob),
|
||||||
|
TraversalLimits::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(unchanged.root, root);
|
||||||
|
assert!(unchanged.objects.is_empty());
|
||||||
|
let returned = replace_path(
|
||||||
|
&mut store,
|
||||||
|
rewrite.root,
|
||||||
|
&["document", "body"],
|
||||||
|
Some(old_blob),
|
||||||
|
TraversalLimits::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(returned.root, root);
|
||||||
|
let first = Object::History(History {
|
||||||
|
root,
|
||||||
|
parent: None,
|
||||||
|
actor: "editor".into(),
|
||||||
|
timestamp_micros: 0,
|
||||||
|
});
|
||||||
|
let ObjectId::History(parent) = first.id() else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
let restored = Object::History(History {
|
||||||
|
root: returned.root,
|
||||||
|
parent: Some(parent),
|
||||||
|
actor: "editor".into(),
|
||||||
|
timestamp_micros: 1,
|
||||||
|
});
|
||||||
|
assert_ne!(first.id(), restored.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structural_diff_skips_equal_subtrees_without_reading_them_and_reports_add_delete() {
|
||||||
|
let mut store = Memory::default();
|
||||||
|
let shared = ContentId::Tree(TreeId::from_bytes([42; 32])); // Intentionally unavailable.
|
||||||
|
let old = ContentId::Blob(BlobId::from_bytes([1; 32]));
|
||||||
|
let new = ContentId::Blob(BlobId::from_bytes([2; 32]));
|
||||||
|
let before = store.root(map(vec![
|
||||||
|
entry("shared", shared),
|
||||||
|
entry("removed", old),
|
||||||
|
entry("edit", old),
|
||||||
|
]));
|
||||||
|
let after = store.root(map(vec![
|
||||||
|
entry("shared", shared),
|
||||||
|
entry("added", new),
|
||||||
|
entry("edit", new),
|
||||||
|
]));
|
||||||
|
assert!(
|
||||||
|
diff(&mut store, before, before, TraversalLimits::default())
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert!(store.reads.is_empty());
|
||||||
|
let changes = diff(&mut store, before, after, TraversalLimits::default()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.reads,
|
||||||
|
vec![ObjectId::Tree(before), ObjectId::Tree(after)]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
changes,
|
||||||
|
vec![
|
||||||
|
Change {
|
||||||
|
path: vec!["added".into()],
|
||||||
|
before: None,
|
||||||
|
after: Some(new)
|
||||||
|
},
|
||||||
|
Change {
|
||||||
|
path: vec!["edit".into()],
|
||||||
|
before: Some(old),
|
||||||
|
after: Some(new)
|
||||||
|
},
|
||||||
|
Change {
|
||||||
|
path: vec!["removed".into()],
|
||||||
|
before: Some(old),
|
||||||
|
after: None
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
restore(&mut store, before, TraversalLimits::default()),
|
||||||
|
Err(Error::MissingObject(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sequence_order_and_tree_mode_changes_are_not_hidden_by_equal_children() {
|
||||||
|
let mut store = Memory::default();
|
||||||
|
let blob = ContentId::Blob(BlobId::from_bytes([1; 32]));
|
||||||
|
let entries = vec![entry("a", blob), entry("b", blob)];
|
||||||
|
let before = store.root(Object::Tree(Tree::sequence(entries.clone()).unwrap()));
|
||||||
|
let after = store.root(Object::Tree(
|
||||||
|
Tree::sequence(entries.iter().rev().cloned().collect()).unwrap(),
|
||||||
|
));
|
||||||
|
let mapped = store.root(map(entries));
|
||||||
|
for target in [after, mapped] {
|
||||||
|
assert_eq!(
|
||||||
|
diff(&mut store, before, target, TraversalLimits::default()).unwrap(),
|
||||||
|
vec![Change {
|
||||||
|
path: vec![],
|
||||||
|
before: Some(ContentId::Tree(before)),
|
||||||
|
after: Some(ContentId::Tree(target))
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replacement_uses_opaque_keys_and_preserves_sequence_positions() {
|
||||||
|
let target = ContentId::Blob(BlobId::from_bytes([0; 32]));
|
||||||
|
let other = ContentId::Blob(BlobId::from_bytes([1; 32]));
|
||||||
|
let sequence = Tree::sequence(vec![entry("a/b", target), entry("", target)]).unwrap();
|
||||||
|
let replaced = sequence.replace("a/b", Some(other)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
replaced.entries(),
|
||||||
|
&[entry("a/b", other), entry("", target)]
|
||||||
|
);
|
||||||
|
let appended = replaced.replace("new", Some(target)).unwrap();
|
||||||
|
assert_eq!(appended.entries().last().unwrap().key, "new");
|
||||||
|
assert_eq!(
|
||||||
|
appended.replace("", None).unwrap().entries(),
|
||||||
|
&[entry("a/b", other), entry("new", target)]
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
sequence.replace("missing", None),
|
||||||
|
Err(Error::MissingKey(_))
|
||||||
|
));
|
||||||
|
let mut store = Memory::default();
|
||||||
|
let root = store.root(Object::Tree(sequence));
|
||||||
|
assert!(
|
||||||
|
replace_path(
|
||||||
|
&mut store,
|
||||||
|
root,
|
||||||
|
&["a/b"],
|
||||||
|
Some(other),
|
||||||
|
TraversalLimits::default()
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
replace_path(&mut store, root, &[], None, TraversalLimits::default()),
|
||||||
|
Err(Error::EmptyPath)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
replace_path(
|
||||||
|
&mut store,
|
||||||
|
root,
|
||||||
|
&["a/b", "nested"],
|
||||||
|
None,
|
||||||
|
TraversalLimits::default()
|
||||||
|
),
|
||||||
|
Err(Error::NotTree)
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
replace_path(
|
||||||
|
&mut store,
|
||||||
|
root,
|
||||||
|
&["missing", "nested"],
|
||||||
|
None,
|
||||||
|
TraversalLimits::default()
|
||||||
|
),
|
||||||
|
Err(Error::MissingKey(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restoration_rejects_corruption_and_resource_budget_exhaustion() {
|
||||||
|
let mut store = Memory::default();
|
||||||
|
let blob = store.put(Object::Blob(b"full bytes".to_vec()));
|
||||||
|
let root = store.root(map(vec![entry("leaf", blob)]));
|
||||||
|
for limits in [
|
||||||
|
TraversalLimits {
|
||||||
|
max_depth: 0,
|
||||||
|
..TraversalLimits::default()
|
||||||
|
},
|
||||||
|
TraversalLimits {
|
||||||
|
max_objects: 1,
|
||||||
|
..TraversalLimits::default()
|
||||||
|
},
|
||||||
|
TraversalLimits {
|
||||||
|
max_bytes: 1,
|
||||||
|
..TraversalLimits::default()
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
assert!(matches!(
|
||||||
|
restore(&mut store, root, limits),
|
||||||
|
Err(Error::LimitExceeded(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
store
|
||||||
|
.objects
|
||||||
|
.insert(blob.into(), Object::Blob(b"tampered".to_vec()).encode());
|
||||||
|
assert_eq!(
|
||||||
|
restore(&mut store, root, TraversalLimits::default()),
|
||||||
|
Err(Error::HashMismatch)
|
||||||
|
);
|
||||||
|
let mut denied = |_: ObjectId| Err(Error::Read("unavailable in authorized scope".into()));
|
||||||
|
assert!(matches!(
|
||||||
|
restore(&mut denied, root, TraversalLimits::default()),
|
||||||
|
Err(Error::Read(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_addition_and_deletion_reuse_fulltext_without_mutating_old_roots() {
|
||||||
|
let mut store = Memory::default();
|
||||||
|
let blob = store.put(Object::Blob(b"complete source".to_vec()));
|
||||||
|
let old = store.root(map(vec![entry("old", blob)]));
|
||||||
|
let added = replace_path(
|
||||||
|
&mut store,
|
||||||
|
old,
|
||||||
|
&["added"],
|
||||||
|
Some(blob),
|
||||||
|
TraversalLimits::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for object in added.objects {
|
||||||
|
store.put(object);
|
||||||
|
}
|
||||||
|
let deleted = replace_path(
|
||||||
|
&mut store,
|
||||||
|
added.root,
|
||||||
|
&["old"],
|
||||||
|
None,
|
||||||
|
TraversalLimits::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for object in deleted.objects {
|
||||||
|
store.put(object);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
restore(&mut store, old, TraversalLimits::default()).unwrap(),
|
||||||
|
restored_map(vec![restored_entry(
|
||||||
|
"old",
|
||||||
|
RestoredContent::Blob(b"complete source".to_vec())
|
||||||
|
)])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
restore(&mut store, deleted.root, TraversalLimits::default()).unwrap(),
|
||||||
|
restored_map(vec![restored_entry(
|
||||||
|
"added",
|
||||||
|
RestoredContent::Blob(b"complete source".to_vec())
|
||||||
|
)])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
diff(&mut store, old, deleted.root, TraversalLimits::default()).unwrap(),
|
||||||
|
vec![
|
||||||
|
Change {
|
||||||
|
path: vec!["added".into()],
|
||||||
|
before: None,
|
||||||
|
after: Some(blob)
|
||||||
|
},
|
||||||
|
Change {
|
||||||
|
path: vec!["old".into()],
|
||||||
|
before: Some(blob),
|
||||||
|
after: None
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recursion_is_bounded_even_if_caller_requests_unlimited_depth() {
|
||||||
|
let mut store = Memory::default();
|
||||||
|
let mut root = store.root(map(vec![]));
|
||||||
|
for _ in 0..258 {
|
||||||
|
root = store.root(map(vec![entry("nested", ContentId::Tree(root))]));
|
||||||
|
}
|
||||||
|
let limits = TraversalLimits {
|
||||||
|
max_depth: usize::MAX,
|
||||||
|
..TraversalLimits::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
restore(&mut store, root, limits),
|
||||||
|
Err(Error::LimitExceeded("depth"))
|
||||||
|
);
|
||||||
|
let path = vec!["nested"; 258];
|
||||||
|
assert_eq!(
|
||||||
|
replace_path(&mut store, root, &path, None, limits),
|
||||||
|
Err(Error::LimitExceeded("depth"))
|
||||||
|
);
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# name canonical_hex sha256_hex
|
||||||
|
empty_blob 594348000001010000000000000000 46f38f058e3e3b972193acf3e2f3b633e0f72da69ba0b596465eb6ffd496de03
|
||||||
|
full_blob 594348000001010000000000000006610d0ac3a900 5baf465acf42ec37d7338fbf56b6eb12eaf7a923ce54f662d32c59b7721e72f4
|
||||||
|
empty_map 59434800000102000000000000000000 e90da7b51cdefa812a4dd9f5ef177e13f2ac53d59e693bad83533ef817d43671
|
||||||
|
empty_sequence 59434800000102010000000000000000 da589a35a84b290befc701f31a5c0916eadcf002590c406fb200951d659c2488
|
||||||
|
map 5943480000010200000000000000000200000000000000017a0146f38f058e3e3b972193acf3e2f3b633e0f72da69ba0b596465eb6ffd496de030000000000000002c3a9015baf465acf42ec37d7338fbf56b6eb12eaf7a923ce54f662d32c59b7721e72f4 4ddb749d70468b4a0dc60693f5c1289f81651f346f72b110fec4395fd931f527
|
||||||
|
sequence 594348000001020100000000000000020000000000000002c3a9015baf465acf42ec37d7338fbf56b6eb12eaf7a923ce54f662d32c59b7721e72f400000000000000017a0146f38f058e3e3b972193acf3e2f3b633e0f72da69ba0b596465eb6ffd496de03 ba054669ee973912f8fb1e9ef99a0a9d0c674c58c3d46eaa5af1024491c8ce3f
|
||||||
|
history 594348000001034ddb749d70468b4a0dc60693f5c1289f81651f346f72b110fec4395fd931f52700000000000000000b757365723ae7b7a8e99b86ffffffffffffffff ab0ac8d9c204969f555c0267ed572c3eace9f7a7df4b5f2e9f3b32fd46d0ab95
|
||||||
|
restored_history 594348000001034ddb749d70468b4a0dc60693f5c1289f81651f346f72b110fec4395fd931f52701ab0ac8d9c204969f555c0267ed572c3eace9f7a7df4b5f2e9f3b32fd46d0ab95000000000000000b757365723ae7b7a8e99b860000000000000000 87bb274c7f99ae8aa653e0211eb26ee910d274420adcf8ec49bade8a46620187
|
||||||
|
nested_tree 5943480000010200000000000000000100000000000000066e6573746564024ddb749d70468b4a0dc60693f5c1289f81651f346f72b110fec4395fd931f527 a6a53aebc4f3d7e4da21ad3328b9f35858a339aea23f328750eada698c6248f2
|
||||||
|
@@ -0,0 +1,37 @@
|
|||||||
|
// Optional execution adapter: only Deno's built-in WASM runtime, no JS packages.
|
||||||
|
// Native contracts.rs checks the same golden digests against the native model.
|
||||||
|
const wasmPath = Deno.args[0];
|
||||||
|
if (!wasmPath) {
|
||||||
|
throw new Error("usage: deno run --allow-read tests/wasm_parity.ts <wasm>");
|
||||||
|
}
|
||||||
|
const bytes = await Deno.readFile(wasmPath);
|
||||||
|
const module = await WebAssembly.compile(bytes);
|
||||||
|
if (WebAssembly.Module.imports(module).length !== 0) {
|
||||||
|
throw new Error("raw parity module must have no host imports");
|
||||||
|
}
|
||||||
|
const instance = await WebAssembly.instantiate(module, {});
|
||||||
|
const length = instance.exports.parity_len as () => number;
|
||||||
|
const word = instance.exports.parity_word as (index: number) => number;
|
||||||
|
const fixture = await Deno.readTextFile(
|
||||||
|
new URL("fixtures/v1.tsv", import.meta.url),
|
||||||
|
);
|
||||||
|
const expected = fixture.trim().split("\n").filter((line) =>
|
||||||
|
!line.startsWith("#")
|
||||||
|
)
|
||||||
|
.flatMap((line) =>
|
||||||
|
line.split("\t")[2].match(/.{8}/g)!.map((hex) => parseInt(hex, 16))
|
||||||
|
);
|
||||||
|
if (length() !== expected.length) {
|
||||||
|
throw new Error("fixture digest count differs");
|
||||||
|
}
|
||||||
|
for (let index = 0; index < expected.length; index++) {
|
||||||
|
if ((word(index) >>> 0) !== expected[index]) {
|
||||||
|
throw new Error(`WASM digest word mismatch at ${index}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((word(expected.length) >>> 0) !== 0xffffffff) {
|
||||||
|
throw new Error("out-of-range word did not return sentinel");
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`WASM canonical SHA256 parity: ${expected.length / 8} fixture objects`,
|
||||||
|
);
|
||||||
@@ -162,6 +162,14 @@ fn validate_envelope_common(raw: &ReaderEnvelope) -> Result<(), ContractError> {
|
|||||||
raw.context.validate()
|
raw.context.validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate byte and lexical depth limits before parsing an enclosing JSON DTO.
|
||||||
|
///
|
||||||
|
/// This is a resource guard only: callers must still use their typed JSON parser
|
||||||
|
/// to validate grammar and schema. It grants no disclosure or encoding authority.
|
||||||
|
pub fn validate_json_input(input: &[u8], budget: Budget) -> Result<(), ContractError> {
|
||||||
|
check_json_bounds(input, budget)
|
||||||
|
}
|
||||||
|
|
||||||
/// Linear lexical depth guard runs before serde allocates any JSON tree. Serde validates grammar.
|
/// Linear lexical depth guard runs before serde allocates any JSON tree. Serde validates grammar.
|
||||||
pub(crate) fn check_json_bounds(input: &[u8], budget: Budget) -> Result<(), ContractError> {
|
pub(crate) fn check_json_bounds(input: &[u8], budget: Budget) -> Result<(), ContractError> {
|
||||||
if input.len() > budget.bytes() {
|
if input.len() > budget.bytes() {
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ pub struct CausalLink {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", schemars(rename = "FailureDiagnostic"))]
|
||||||
pub struct Diagnostic {
|
pub struct Diagnostic {
|
||||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
|
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
|
||||||
pub code: String,
|
pub code: String,
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ pub mod schema;
|
|||||||
#[cfg(feature = "test-support")]
|
#[cfg(feature = "test-support")]
|
||||||
pub mod test_support;
|
pub mod test_support;
|
||||||
|
|
||||||
pub use codec::{Budget, DecodeFailure, ReceivedFailure, decode_failure, encode_projected_failure};
|
pub use codec::{
|
||||||
|
Budget, DecodeFailure, ReceivedFailure, decode_failure, encode_projected_failure,
|
||||||
|
validate_json_input,
|
||||||
|
};
|
||||||
pub use facts::*;
|
pub use facts::*;
|
||||||
pub use projection::*;
|
pub use projection::*;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "feature-permissions"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
schemars.workspace = true
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
thiserror.workspace = true
|
||||||
|
ts-rs = "12.0.1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
|
toml.workspace = true
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Feature permissions
|
||||||
|
|
||||||
|
This crate owns payload-free declarations, assignment DTOs, catalog projections,
|
||||||
|
explicit children resolution and operation-key delegation limits. It does not
|
||||||
|
adopt policy, migrate old grants, authorize a real operation, or choose Worker
|
||||||
|
permissions. See [`PermissionHost`] for the trusted registration boundary and
|
||||||
|
[`types`] for the shared text/storage/management/Web representation.
|
||||||
|
|
||||||
|
The Host authenticates a Workspace and a builtin or trusted Plugin identity
|
||||||
|
before registering it. It retains [`FeatureHandle`] and supplies only the bound
|
||||||
|
[`NamespaceHandle`] to that Feature's declaration/query adapter. Guest input is
|
||||||
|
[`NamespaceDeclaration`], never an owner string or a handle. Constructing another
|
||||||
|
Host with the same textual Workspace identity does not create interchangeable
|
||||||
|
handles or results. A namespace conveys ownership, not permission to execute.
|
||||||
|
|
||||||
|
Children are graph edges, not dotted-prefix wildcards. A key can have multiple
|
||||||
|
segments and explicit children; a shared set collects Feature contributions using
|
||||||
|
the same references. Installing a declaration validates its candidate graph
|
||||||
|
atomically. Duplicate definitions, unknown references and cycles reject the update
|
||||||
|
without changing the prior catalog. Duplicate assignment paths are deduplicated,
|
||||||
|
with distinct provenance paths retained in deterministic order. Invalid assignment
|
||||||
|
references deny the whole assignment with diagnostics. Disabled positive
|
||||||
|
contributions are omitted independently. Explicit exclusions still traverse disabled
|
||||||
|
Features' declared keys and set edges, so disabling a Plugin cannot revive another
|
||||||
|
Feature's excluded operation. Removing a Feature retires its handles and withdraws
|
||||||
|
only its declarations. Dangling references deny rather than recovering an old result.
|
||||||
|
|
||||||
|
Every change invalidates prior [`ResolvedPermissions`]. The Host re-resolves the
|
||||||
|
current assignment before use. A resolution retains its actual catalog snapshot;
|
||||||
|
query rejects it unless that snapshot is still the Host's current catalog. There
|
||||||
|
is no independently ordered freshness counter. Unset,
|
||||||
|
explicit empty, exclusion and revocation never select a default. Exclusions expand
|
||||||
|
through the same children graph, then win over every grant path. No snapshot is a
|
||||||
|
policy store: consumers must read current policy and authenticated subject/Job
|
||||||
|
context at their actual operation boundary.
|
||||||
|
|
||||||
|
A [`LimitedGrant`] freezes the expanded keys actually passed at issuance. Later
|
||||||
|
set or children additions can extend a set-holding parent, not that grant. Child
|
||||||
|
execution intersects the fresh parent execution, fresh parent delegation ceiling
|
||||||
|
and frozen grant. A [`JobGrant`] is one further intersection bound to the Host's
|
||||||
|
Job identity; it never substitutes for Subject policy, attempt/writer/input/expiry
|
||||||
|
checks. Revoked grant objects cannot be made active again. Operation-key checks do
|
||||||
|
not replace provider path, recursive, symlink, network or domain constraints.
|
||||||
|
|
||||||
|
A text/storage/management assignment uses an explicit discriminant, for example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"grants": [
|
||||||
|
{"kind":"set", "value":"companion"},
|
||||||
|
{"kind":"namespace", "value":{"namespace":"ticket", "keys":["ticket.hoge.fuga"]}}
|
||||||
|
],
|
||||||
|
"exclusions": [],
|
||||||
|
"revoked": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The same Serde types validate dynamic inputs; unknown fields (including payloads)
|
||||||
|
are rejected. [`typescript`] generates the Web contract. Management projections are
|
||||||
|
serializable observations, not credentials: deserialization cannot manufacture a
|
||||||
|
runtime resolution, namespace handle or delegation grant. The consumer owns policy persistence and its route/subject authentication, not
|
||||||
|
this DTO layer.
|
||||||
|
|
||||||
|
To add a Feature, the trusted loader registers the established identity and gives
|
||||||
|
its registrar a namespace handle. The declaration contains only operation keys
|
||||||
|
and explicit edges; no central domain list needs updating:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use feature_permissions::*;
|
||||||
|
let mut host = PermissionHost::new(WorkspaceId::parse("workspace-a")?);
|
||||||
|
// Host-only: a Plugin receives its bound registrar, not this registration API.
|
||||||
|
let feature = host.register_feature(FeatureIdentity::parse("plugin:example")?)?;
|
||||||
|
let namespace = host.bind_namespace(&feature, NamespaceId::parse("example")?)?;
|
||||||
|
let read = PermissionKey::parse("example.documents.read")?;
|
||||||
|
host.declare(&namespace, NamespaceDeclaration {
|
||||||
|
permissions: vec![PermissionDefinition {
|
||||||
|
key: read.clone(), description: "Read example documents".into(), children: vec![],
|
||||||
|
}],
|
||||||
|
sets: vec![SetContribution {
|
||||||
|
set: PermissionSetId::parse("companion")?,
|
||||||
|
children: vec![PermissionReference::Namespace(NamespacePermissions {
|
||||||
|
namespace: NamespaceId::parse("example")?, keys: vec![read.clone()],
|
||||||
|
})],
|
||||||
|
}],
|
||||||
|
})?;
|
||||||
|
let assignment = PermissionAssignment {
|
||||||
|
grants: vec![PermissionReference::Set(PermissionSetId::parse("companion")?)],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let resolved = host.resolve(Some(&assignment));
|
||||||
|
assert!(host.allows(&namespace, &resolved, &read)?);
|
||||||
|
# Ok::<(), Box<dyn std::error::Error>>(())
|
||||||
|
```
|
||||||
|
|
||||||
|
Wire inputs cannot manufacture an owner handle or executable resolution:
|
||||||
|
|
||||||
|
```compile_fail
|
||||||
|
let _: feature_permissions::NamespaceHandle = serde_json::from_str("{}").unwrap();
|
||||||
|
```
|
||||||
|
|
||||||
|
```compile_fail
|
||||||
|
let _: feature_permissions::ResolvedPermissions = serde_json::from_str("{}").unwrap();
|
||||||
|
```
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
print!("{}", feature_permissions::typescript());
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#![doc = include_str!("../README.md")]
|
||||||
|
|
||||||
|
pub mod types;
|
||||||
|
pub use types::*;
|
||||||
|
|
||||||
|
mod registry;
|
||||||
|
pub use registry::{
|
||||||
|
FeatureHandle, JobGrant, LimitedGrant, NamespaceHandle, PermissionError, PermissionHost,
|
||||||
|
ResolvedPermissions,
|
||||||
|
};
|
||||||
@@ -0,0 +1,688 @@
|
|||||||
|
//! Registration is a trusted Host operation. Wire declarations cannot construct
|
||||||
|
//! handles. A handle is bound to one registry instance and one registration, not
|
||||||
|
//! merely to matching Workspace/Feature strings. Reinstallation retires old handles.
|
||||||
|
|
||||||
|
use crate::types::*;
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error, PartialEq, Eq)]
|
||||||
|
pub enum PermissionError {
|
||||||
|
#[error("handle belongs to another Host or a retired registration")]
|
||||||
|
ForeignHandle,
|
||||||
|
#[error("Feature already registered: {0}")]
|
||||||
|
FeatureCollision(FeatureIdentity),
|
||||||
|
#[error("namespace already owned: {0}")]
|
||||||
|
NamespaceCollision(NamespaceId),
|
||||||
|
#[error("declaration does not belong to namespace: {0}")]
|
||||||
|
Ownership(PermissionKey),
|
||||||
|
#[error("duplicate declaration: {0}")]
|
||||||
|
Duplicate(String),
|
||||||
|
#[error("unknown key: {0}")]
|
||||||
|
UnknownKey(PermissionKey),
|
||||||
|
#[error("unknown set: {0}")]
|
||||||
|
UnknownSet(PermissionSetId),
|
||||||
|
#[error("cycle in permission graph: {0}")]
|
||||||
|
Cycle(String),
|
||||||
|
#[error("resolution is stale; resolve current policy again")]
|
||||||
|
StaleResolution,
|
||||||
|
#[error("requested delegation exceeds current ceiling: {0}")]
|
||||||
|
DelegationExceeded(PermissionKey),
|
||||||
|
#[error("limited grant has been revoked")]
|
||||||
|
RevokedGrant,
|
||||||
|
#[error("Job grant belongs to another Job")]
|
||||||
|
WrongJob,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct FeatureHandle {
|
||||||
|
host: Arc<()>,
|
||||||
|
registration: Arc<()>,
|
||||||
|
identity: FeatureIdentity,
|
||||||
|
}
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct NamespaceHandle {
|
||||||
|
feature: FeatureHandle,
|
||||||
|
namespace: NamespaceId,
|
||||||
|
}
|
||||||
|
impl NamespaceHandle {
|
||||||
|
pub fn namespace(&self) -> &NamespaceId {
|
||||||
|
&self.namespace
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare the actual registrations, never caller-supplied owner names.
|
||||||
|
pub fn is_owned_by(&self, feature: &FeatureHandle) -> bool {
|
||||||
|
Arc::ptr_eq(&self.feature.host, &feature.host)
|
||||||
|
&& Arc::ptr_eq(&self.feature.registration, &feature.registration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct FeatureRecord {
|
||||||
|
registration: Arc<()>,
|
||||||
|
enabled: bool,
|
||||||
|
}
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct NamespaceRecord {
|
||||||
|
owner: FeatureIdentity,
|
||||||
|
declaration: NamespaceDeclaration,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Not deserializable: a management projection cannot become an authorization
|
||||||
|
/// result. Results must be re-resolved after *any* registry change.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ResolvedPermissions {
|
||||||
|
host: Arc<()>,
|
||||||
|
catalog: Arc<RegistryState>,
|
||||||
|
projection: PermissionResolution,
|
||||||
|
}
|
||||||
|
impl ResolvedPermissions {
|
||||||
|
pub fn projection(&self) -> &PermissionResolution {
|
||||||
|
&self.projection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keys are expanded once at issuance and never expanded again. Thus later
|
||||||
|
/// children/set additions cannot enlarge an already issued child grant.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct LimitedGrant {
|
||||||
|
host: Arc<()>,
|
||||||
|
keys: BTreeSet<PermissionKey>,
|
||||||
|
registrations: BTreeMap<PermissionKey, Arc<()>>,
|
||||||
|
revoked: bool,
|
||||||
|
}
|
||||||
|
impl LimitedGrant {
|
||||||
|
pub fn keys(&self) -> &BTreeSet<PermissionKey> {
|
||||||
|
&self.keys
|
||||||
|
}
|
||||||
|
pub fn revoke(&mut self) {
|
||||||
|
self.revoked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Job's operation grant is an additional limit, not an alternative policy.
|
||||||
|
/// The caller supplies the authenticated Job identity at issuance and use; this
|
||||||
|
/// does not validate attempts, writers, deadlines or provider restrictions.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct JobGrant {
|
||||||
|
job: String,
|
||||||
|
limit: LimitedGrant,
|
||||||
|
}
|
||||||
|
impl JobGrant {
|
||||||
|
pub fn revoke(&mut self) {
|
||||||
|
self.limit.revoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PermissionHost {
|
||||||
|
identity: Arc<()>,
|
||||||
|
workspace: WorkspaceId,
|
||||||
|
catalog: Arc<RegistryState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
struct RegistryState {
|
||||||
|
features: BTreeMap<FeatureIdentity, FeatureRecord>,
|
||||||
|
namespaces: BTreeMap<NamespaceId, NamespaceRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PermissionHost {
|
||||||
|
/// Constructed by the trusted Workspace Host, not from a Feature's input.
|
||||||
|
pub fn new(workspace: WorkspaceId) -> Self {
|
||||||
|
Self {
|
||||||
|
identity: Arc::new(()),
|
||||||
|
workspace,
|
||||||
|
catalog: Arc::new(RegistryState::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only the trusted loader calls this after establishing the actual builtin
|
||||||
|
/// or trusted Plugin identity. There is no Feature-facing raw-owner lookup.
|
||||||
|
pub fn register_feature(
|
||||||
|
&mut self,
|
||||||
|
identity: FeatureIdentity,
|
||||||
|
) -> Result<FeatureHandle, PermissionError> {
|
||||||
|
if self.catalog.features.contains_key(&identity) {
|
||||||
|
return Err(PermissionError::FeatureCollision(identity));
|
||||||
|
}
|
||||||
|
let registration = Arc::new(());
|
||||||
|
Arc::make_mut(&mut self.catalog).features.insert(
|
||||||
|
identity.clone(),
|
||||||
|
FeatureRecord {
|
||||||
|
registration: registration.clone(),
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(FeatureHandle {
|
||||||
|
host: self.identity.clone(),
|
||||||
|
registration,
|
||||||
|
identity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_feature(&self, feature: &FeatureHandle) -> Result<(), PermissionError> {
|
||||||
|
if !Arc::ptr_eq(&self.identity, &feature.host)
|
||||||
|
|| !self
|
||||||
|
.catalog
|
||||||
|
.features
|
||||||
|
.get(&feature.identity)
|
||||||
|
.is_some_and(|r| Arc::ptr_eq(&r.registration, &feature.registration))
|
||||||
|
{
|
||||||
|
return Err(PermissionError::ForeignHandle);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn check_namespace(&self, handle: &NamespaceHandle) -> Result<(), PermissionError> {
|
||||||
|
self.check_feature(&handle.feature)?;
|
||||||
|
if !self
|
||||||
|
.catalog
|
||||||
|
.namespaces
|
||||||
|
.get(&handle.namespace)
|
||||||
|
.is_some_and(|r| r.owner == handle.feature.identity)
|
||||||
|
{
|
||||||
|
return Err(PermissionError::ForeignHandle);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bind_namespace(
|
||||||
|
&mut self,
|
||||||
|
feature: &FeatureHandle,
|
||||||
|
namespace: NamespaceId,
|
||||||
|
) -> Result<NamespaceHandle, PermissionError> {
|
||||||
|
self.check_feature(feature)?;
|
||||||
|
if self.catalog.namespaces.contains_key(&namespace) {
|
||||||
|
return Err(PermissionError::NamespaceCollision(namespace));
|
||||||
|
}
|
||||||
|
Arc::make_mut(&mut self.catalog).namespaces.insert(
|
||||||
|
namespace.clone(),
|
||||||
|
NamespaceRecord {
|
||||||
|
owner: feature.identity.clone(),
|
||||||
|
declaration: NamespaceDeclaration {
|
||||||
|
permissions: vec![],
|
||||||
|
sets: vec![],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(NamespaceHandle {
|
||||||
|
feature: feature.clone(),
|
||||||
|
namespace,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace atomically. All keys and set contributions must belong to this
|
||||||
|
/// namespace. References to existing other namespaces do not transfer ownership.
|
||||||
|
pub fn declare(
|
||||||
|
&mut self,
|
||||||
|
handle: &NamespaceHandle,
|
||||||
|
declaration: NamespaceDeclaration,
|
||||||
|
) -> Result<(), PermissionError> {
|
||||||
|
self.check_namespace(handle)?;
|
||||||
|
let mut keys = BTreeSet::new();
|
||||||
|
for definition in &declaration.permissions {
|
||||||
|
if definition.key.namespace() != handle.namespace {
|
||||||
|
return Err(PermissionError::Ownership(definition.key.clone()));
|
||||||
|
}
|
||||||
|
if !keys.insert(definition.key.clone()) {
|
||||||
|
return Err(PermissionError::Duplicate(definition.key.to_string()));
|
||||||
|
}
|
||||||
|
let mut children = BTreeSet::new();
|
||||||
|
for child in &definition.children {
|
||||||
|
if !children.insert(child) {
|
||||||
|
return Err(PermissionError::Duplicate(child.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut sets = BTreeSet::new();
|
||||||
|
for contribution in &declaration.sets {
|
||||||
|
if !sets.insert(&contribution.set) {
|
||||||
|
return Err(PermissionError::Duplicate(contribution.set.to_string()));
|
||||||
|
}
|
||||||
|
for reference in &contribution.children {
|
||||||
|
if let PermissionReference::Namespace(ns) = reference {
|
||||||
|
if ns.namespace != handle.namespace {
|
||||||
|
return Err(PermissionError::ForeignHandle);
|
||||||
|
}
|
||||||
|
for key in &ns.keys {
|
||||||
|
if key.namespace() != ns.namespace {
|
||||||
|
return Err(PermissionError::Ownership(key.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut candidate = self.catalog.namespaces.clone();
|
||||||
|
candidate
|
||||||
|
.get_mut(&handle.namespace)
|
||||||
|
.expect("checked namespace")
|
||||||
|
.declaration = declaration;
|
||||||
|
validate_graph(&candidate)?;
|
||||||
|
Arc::make_mut(&mut self.catalog).namespaces = candidate;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_enabled(
|
||||||
|
&mut self,
|
||||||
|
feature: &FeatureHandle,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<(), PermissionError> {
|
||||||
|
self.check_feature(feature)?;
|
||||||
|
if self.catalog.features[&feature.identity].enabled != enabled {
|
||||||
|
Arc::make_mut(&mut self.catalog)
|
||||||
|
.features
|
||||||
|
.get_mut(&feature.identity)
|
||||||
|
.expect("checked feature")
|
||||||
|
.enabled = enabled;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removal withdraws only this Feature's definitions/contributions. Dangling
|
||||||
|
/// references remain diagnostic and fail closed at resolution, never cached.
|
||||||
|
pub fn remove_feature(&mut self, feature: &FeatureHandle) -> Result<(), PermissionError> {
|
||||||
|
self.check_feature(feature)?;
|
||||||
|
Arc::make_mut(&mut self.catalog)
|
||||||
|
.features
|
||||||
|
.remove(&feature.identity);
|
||||||
|
Arc::make_mut(&mut self.catalog)
|
||||||
|
.namespaces
|
||||||
|
.retain(|_, r| r.owner != feature.identity);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn catalog(&self) -> PermissionCatalog {
|
||||||
|
PermissionCatalog {
|
||||||
|
workspace: self.workspace.clone(),
|
||||||
|
namespaces: self
|
||||||
|
.catalog
|
||||||
|
.namespaces
|
||||||
|
.iter()
|
||||||
|
.map(|(ns, r)| NamespaceCatalog {
|
||||||
|
namespace: ns.clone(),
|
||||||
|
feature: r.owner.clone(),
|
||||||
|
enabled: self.catalog.features[&r.owner].enabled,
|
||||||
|
declaration: r.declaration.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve(&self, assignment: Option<&PermissionAssignment>) -> ResolvedPermissions {
|
||||||
|
let mut projection = PermissionResolution {
|
||||||
|
workspace: self.workspace.clone(),
|
||||||
|
allowed: BTreeMap::new(),
|
||||||
|
diagnostics: vec![],
|
||||||
|
};
|
||||||
|
match assignment {
|
||||||
|
None => projection.diagnostics.push(PermissionDiagnostic::Unset),
|
||||||
|
Some(a) if a.revoked => projection.diagnostics.push(PermissionDiagnostic::Revoked),
|
||||||
|
Some(a) => {
|
||||||
|
let mut expansion = Expansion::new(self, ExpansionPurpose::Grant);
|
||||||
|
for reference in &a.grants {
|
||||||
|
expansion.reference(reference, vec![]);
|
||||||
|
}
|
||||||
|
let mut exclusions = Expansion::new(self, ExpansionPurpose::Exclusion);
|
||||||
|
for reference in &a.exclusions {
|
||||||
|
exclusions.reference(reference, vec![]);
|
||||||
|
}
|
||||||
|
for key in exclusions.allowed.keys() {
|
||||||
|
if expansion.allowed.remove(key).is_some() {
|
||||||
|
expansion
|
||||||
|
.diagnostics
|
||||||
|
.insert(PermissionDiagnostic::Excluded(key.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expansion.diagnostics.extend(exclusions.diagnostics);
|
||||||
|
// Invalid references, including malformed namespace/key pairings,
|
||||||
|
// invalidate the assignment instead of partially accepting a typo.
|
||||||
|
if !expansion.diagnostics.iter().any(|d| {
|
||||||
|
matches!(
|
||||||
|
d,
|
||||||
|
PermissionDiagnostic::UnknownKey(_)
|
||||||
|
| PermissionDiagnostic::UnknownSet(_)
|
||||||
|
| PermissionDiagnostic::UnknownNamespace(_)
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
projection.allowed = expansion
|
||||||
|
.allowed
|
||||||
|
.into_iter()
|
||||||
|
.map(|(key, origins)| (key, origins.into_iter().collect()))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
projection.diagnostics = expansion.diagnostics.into_iter().collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResolvedPermissions {
|
||||||
|
host: self.identity.clone(),
|
||||||
|
catalog: self.catalog.clone(),
|
||||||
|
projection,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_resolution(&self, resolved: &ResolvedPermissions) -> Result<(), PermissionError> {
|
||||||
|
if !Arc::ptr_eq(&self.identity, &resolved.host) {
|
||||||
|
return Err(PermissionError::ForeignHandle);
|
||||||
|
}
|
||||||
|
if !Arc::ptr_eq(&self.catalog, &resolved.catalog) {
|
||||||
|
return Err(PermissionError::StaleResolution);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A key permission is only one conjunct. The execution boundary must still
|
||||||
|
/// check path/provider/context/domain restrictions at the actual operation.
|
||||||
|
pub fn allows(
|
||||||
|
&self,
|
||||||
|
namespace: &NamespaceHandle,
|
||||||
|
resolved: &ResolvedPermissions,
|
||||||
|
key: &PermissionKey,
|
||||||
|
) -> Result<bool, PermissionError> {
|
||||||
|
self.check_namespace(namespace)?;
|
||||||
|
self.check_resolution(resolved)?;
|
||||||
|
if key.namespace() != namespace.namespace {
|
||||||
|
return Err(PermissionError::Ownership(key.clone()));
|
||||||
|
}
|
||||||
|
Ok(resolved.projection.allowed.contains_key(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn issue_delegation(
|
||||||
|
&self,
|
||||||
|
ceiling: &ResolvedPermissions,
|
||||||
|
requested: &[PermissionKey],
|
||||||
|
) -> Result<LimitedGrant, PermissionError> {
|
||||||
|
self.check_resolution(ceiling)?;
|
||||||
|
let mut expansion = Expansion::new(self, ExpansionPurpose::Grant);
|
||||||
|
for key in requested {
|
||||||
|
expansion.key(key, vec![]);
|
||||||
|
}
|
||||||
|
for diagnostic in &expansion.diagnostics {
|
||||||
|
if let PermissionDiagnostic::UnknownKey(key) = diagnostic {
|
||||||
|
return Err(PermissionError::UnknownKey(key.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key in expansion.allowed.keys() {
|
||||||
|
if !ceiling.projection.allowed.contains_key(key) {
|
||||||
|
return Err(PermissionError::DelegationExceeded(key.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(LimitedGrant {
|
||||||
|
host: self.identity.clone(),
|
||||||
|
registrations: expansion
|
||||||
|
.allowed
|
||||||
|
.keys()
|
||||||
|
.map(|key| {
|
||||||
|
let owner = &self.catalog.namespaces[&key.namespace()].owner;
|
||||||
|
(
|
||||||
|
key.clone(),
|
||||||
|
self.catalog.features[owner].registration.clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
keys: expansion.allowed.into_keys().collect(),
|
||||||
|
revoked: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn issue_job_grant(
|
||||||
|
&self,
|
||||||
|
job: String,
|
||||||
|
ceiling: &ResolvedPermissions,
|
||||||
|
requested: &[PermissionKey],
|
||||||
|
) -> Result<JobGrant, PermissionError> {
|
||||||
|
Ok(JobGrant {
|
||||||
|
job,
|
||||||
|
limit: self.issue_delegation(ceiling, requested)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grant_key_is_current(&self, grant: &LimitedGrant, key: &PermissionKey) -> bool {
|
||||||
|
let Some(record) = self.catalog.namespaces.get(&key.namespace()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
grant.registrations.get(key).is_some_and(|registration| {
|
||||||
|
let feature = &self.catalog.features[&record.owner];
|
||||||
|
feature.enabled && Arc::ptr_eq(registration, &feature.registration)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_grant(&self, grant: &LimitedGrant) -> Result<(), PermissionError> {
|
||||||
|
if !Arc::ptr_eq(&self.identity, &grant.host) {
|
||||||
|
return Err(PermissionError::ForeignHandle);
|
||||||
|
}
|
||||||
|
if grant.revoked {
|
||||||
|
return Err(PermissionError::RevokedGrant);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parent execution, parent delegation and actually passed keys all constrain
|
||||||
|
/// the child. This exact-set intersection performs no children expansion.
|
||||||
|
pub fn resolve_limited(
|
||||||
|
&self,
|
||||||
|
execution: &ResolvedPermissions,
|
||||||
|
delegation: &ResolvedPermissions,
|
||||||
|
grant: &LimitedGrant,
|
||||||
|
) -> Result<ResolvedPermissions, PermissionError> {
|
||||||
|
self.check_resolution(execution)?;
|
||||||
|
self.check_resolution(delegation)?;
|
||||||
|
self.check_grant(grant)?;
|
||||||
|
let mut projection = execution.projection.clone();
|
||||||
|
projection.allowed.retain(|key, _| {
|
||||||
|
grant.keys.contains(key)
|
||||||
|
&& self.grant_key_is_current(grant, key)
|
||||||
|
&& delegation.projection.allowed.contains_key(key)
|
||||||
|
});
|
||||||
|
Ok(ResolvedPermissions {
|
||||||
|
host: self.identity.clone(),
|
||||||
|
catalog: self.catalog.clone(),
|
||||||
|
projection,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_job(
|
||||||
|
&self,
|
||||||
|
job: &str,
|
||||||
|
execution: &ResolvedPermissions,
|
||||||
|
delegation: &ResolvedPermissions,
|
||||||
|
child: &LimitedGrant,
|
||||||
|
grant: &JobGrant,
|
||||||
|
) -> Result<ResolvedPermissions, PermissionError> {
|
||||||
|
if grant.job != job {
|
||||||
|
return Err(PermissionError::WrongJob);
|
||||||
|
}
|
||||||
|
self.check_grant(&grant.limit)?;
|
||||||
|
let mut result = self.resolve_limited(execution, delegation, child)?;
|
||||||
|
result.projection.allowed.retain(|key, _| {
|
||||||
|
grant.limit.keys.contains(key) && self.grant_key_is_current(&grant.limit, key)
|
||||||
|
});
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
enum Node {
|
||||||
|
Key(PermissionKey),
|
||||||
|
Set(PermissionSetId),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn graph(
|
||||||
|
namespaces: &BTreeMap<NamespaceId, NamespaceRecord>,
|
||||||
|
) -> Result<BTreeMap<Node, Vec<Node>>, PermissionError> {
|
||||||
|
let mut nodes = BTreeMap::new();
|
||||||
|
for r in namespaces.values() {
|
||||||
|
for d in &r.declaration.permissions {
|
||||||
|
nodes.insert(
|
||||||
|
Node::Key(d.key.clone()),
|
||||||
|
d.children.iter().cloned().map(Node::Key).collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for s in &r.declaration.sets {
|
||||||
|
let edges = nodes
|
||||||
|
.entry(Node::Set(s.set.clone()))
|
||||||
|
.or_insert_with(Vec::new);
|
||||||
|
for reference in &s.children {
|
||||||
|
match reference {
|
||||||
|
PermissionReference::Set(set) => edges.push(Node::Set(set.clone())),
|
||||||
|
PermissionReference::Namespace(ns) => {
|
||||||
|
for key in &ns.keys {
|
||||||
|
if key.namespace() != ns.namespace {
|
||||||
|
return Err(PermissionError::Ownership(key.clone()));
|
||||||
|
}
|
||||||
|
edges.push(Node::Key(key.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_graph(
|
||||||
|
namespaces: &BTreeMap<NamespaceId, NamespaceRecord>,
|
||||||
|
) -> Result<(), PermissionError> {
|
||||||
|
fn visit(
|
||||||
|
node: &Node,
|
||||||
|
nodes: &BTreeMap<Node, Vec<Node>>,
|
||||||
|
active: &mut BTreeSet<Node>,
|
||||||
|
done: &mut BTreeSet<Node>,
|
||||||
|
) -> Result<(), PermissionError> {
|
||||||
|
if done.contains(node) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if !active.insert(node.clone()) {
|
||||||
|
return Err(PermissionError::Cycle(format!("{node:?}")));
|
||||||
|
}
|
||||||
|
let edges = nodes.get(node).ok_or_else(|| match node {
|
||||||
|
Node::Key(k) => PermissionError::UnknownKey(k.clone()),
|
||||||
|
Node::Set(s) => PermissionError::UnknownSet(s.clone()),
|
||||||
|
})?;
|
||||||
|
for child in edges {
|
||||||
|
visit(child, nodes, active, done)?;
|
||||||
|
}
|
||||||
|
active.remove(node);
|
||||||
|
done.insert(node.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
let nodes = graph(namespaces)?;
|
||||||
|
let mut done = BTreeSet::new();
|
||||||
|
for node in nodes.keys() {
|
||||||
|
visit(node, &nodes, &mut BTreeSet::new(), &mut done)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq, Eq)]
|
||||||
|
enum ExpansionPurpose {
|
||||||
|
Grant,
|
||||||
|
// Disabling an owner withdraws positive contributions, not the meaning of
|
||||||
|
// an explicit deny. Its retained declaration still defines exclusion edges.
|
||||||
|
Exclusion,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Expansion<'a> {
|
||||||
|
host: &'a PermissionHost,
|
||||||
|
purpose: ExpansionPurpose,
|
||||||
|
allowed: BTreeMap<PermissionKey, BTreeSet<Vec<OriginStep>>>,
|
||||||
|
diagnostics: BTreeSet<PermissionDiagnostic>,
|
||||||
|
}
|
||||||
|
impl<'a> Expansion<'a> {
|
||||||
|
fn new(host: &'a PermissionHost, purpose: ExpansionPurpose) -> Self {
|
||||||
|
Self {
|
||||||
|
host,
|
||||||
|
purpose,
|
||||||
|
allowed: BTreeMap::new(),
|
||||||
|
diagnostics: BTreeSet::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn reference(&mut self, reference: &PermissionReference, path: Vec<OriginStep>) {
|
||||||
|
match reference {
|
||||||
|
PermissionReference::Namespace(ns) => {
|
||||||
|
if !self.host.catalog.namespaces.contains_key(&ns.namespace) {
|
||||||
|
self.diagnostics
|
||||||
|
.insert(PermissionDiagnostic::UnknownNamespace(ns.namespace.clone()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for key in &ns.keys {
|
||||||
|
if key.namespace() != ns.namespace {
|
||||||
|
self.diagnostics
|
||||||
|
.insert(PermissionDiagnostic::UnknownKey(key.clone()));
|
||||||
|
} else {
|
||||||
|
self.key(key, path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PermissionReference::Set(set) => {
|
||||||
|
// Cycles cannot be installed; the path guard also makes this
|
||||||
|
// traversal robust to future graph-loading implementations.
|
||||||
|
if path.contains(&OriginStep::Set(set.clone())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut found = false;
|
||||||
|
for record in self.host.catalog.namespaces.values() {
|
||||||
|
for contribution in &record.declaration.sets {
|
||||||
|
if &contribution.set != set {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
found = true;
|
||||||
|
if !self.host.catalog.features[&record.owner].enabled {
|
||||||
|
self.diagnostics
|
||||||
|
.insert(PermissionDiagnostic::DisabledFeature(
|
||||||
|
record.owner.clone(),
|
||||||
|
));
|
||||||
|
if self.purpose == ExpansionPurpose::Grant {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut next = path.clone();
|
||||||
|
next.push(OriginStep::Set(set.clone()));
|
||||||
|
next.push(OriginStep::Feature(record.owner.clone()));
|
||||||
|
for child in &contribution.children {
|
||||||
|
self.reference(child, next.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
self.diagnostics
|
||||||
|
.insert(PermissionDiagnostic::UnknownSet(set.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn key(&mut self, key: &PermissionKey, mut path: Vec<OriginStep>) {
|
||||||
|
let Some(record) = self.host.catalog.namespaces.get(&key.namespace()) else {
|
||||||
|
self.diagnostics
|
||||||
|
.insert(PermissionDiagnostic::UnknownKey(key.clone()));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(definition) = record
|
||||||
|
.declaration
|
||||||
|
.permissions
|
||||||
|
.iter()
|
||||||
|
.find(|d| &d.key == key)
|
||||||
|
else {
|
||||||
|
self.diagnostics
|
||||||
|
.insert(PermissionDiagnostic::UnknownKey(key.clone()));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !self.host.catalog.features[&record.owner].enabled {
|
||||||
|
self.diagnostics
|
||||||
|
.insert(PermissionDiagnostic::DisabledFeature(record.owner.clone()));
|
||||||
|
if self.purpose == ExpansionPurpose::Grant {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if path.contains(&OriginStep::Key(key.clone())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
path.push(OriginStep::Feature(record.owner.clone()));
|
||||||
|
path.push(OriginStep::Key(key.clone()));
|
||||||
|
self.allowed
|
||||||
|
.entry(key.clone())
|
||||||
|
.or_default()
|
||||||
|
.insert(path.clone());
|
||||||
|
for child in &definition.children {
|
||||||
|
self.key(child, path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
//! Wire values describe grants, never authority-bearing handles. Unknown fields
|
||||||
|
//! are rejected so old payload-bearing inputs cannot silently broaden a grant.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
macro_rules! identifier {
|
||||||
|
($name:ident, $validate:expr) => {
|
||||||
|
#[derive(
|
||||||
|
Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, TS, schemars::JsonSchema,
|
||||||
|
)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct $name(String);
|
||||||
|
|
||||||
|
impl $name {
|
||||||
|
pub fn parse(value: impl Into<String>) -> Result<Self, String> {
|
||||||
|
let value = value.into();
|
||||||
|
if ($validate)(&value) {
|
||||||
|
Ok(Self(value))
|
||||||
|
} else {
|
||||||
|
Err(format!("invalid {}: {value:?}", stringify!($name)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<'de> Deserialize<'de> for $name {
|
||||||
|
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||||
|
Self::parse(String::deserialize(d)?).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl std::fmt::Display for $name {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn segment(value: &str) -> bool {
|
||||||
|
!value.is_empty()
|
||||||
|
&& value.as_bytes()[0].is_ascii_lowercase()
|
||||||
|
&& value
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
|
||||||
|
}
|
||||||
|
fn identity(value: &str) -> bool {
|
||||||
|
!value.is_empty() && !value.chars().any(|c| c.is_control() || c.is_whitespace())
|
||||||
|
}
|
||||||
|
identifier!(WorkspaceId, identity);
|
||||||
|
identifier!(FeatureIdentity, identity);
|
||||||
|
identifier!(PermissionSetId, segment);
|
||||||
|
identifier!(NamespaceId, segment);
|
||||||
|
identifier!(PermissionKey, |value: &str| value.split('.').count() >= 2
|
||||||
|
&& value.split('.').all(segment));
|
||||||
|
|
||||||
|
impl PermissionKey {
|
||||||
|
pub fn namespace(&self) -> NamespaceId {
|
||||||
|
NamespaceId(self.0.split('.').next().expect("validated key").to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct NamespacePermissions {
|
||||||
|
pub namespace: NamespaceId,
|
||||||
|
pub keys: Vec<PermissionKey>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The discriminant, not an identifier's spelling, selects a reference kind.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)]
|
||||||
|
#[serde(
|
||||||
|
tag = "kind",
|
||||||
|
content = "value",
|
||||||
|
rename_all = "snake_case",
|
||||||
|
deny_unknown_fields
|
||||||
|
)]
|
||||||
|
pub enum PermissionReference {
|
||||||
|
Set(PermissionSetId),
|
||||||
|
Namespace(NamespacePermissions),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `None` at the consuming policy boundary means unset. An empty assignment is
|
||||||
|
/// explicit deny-all; this crate never chooses or reinstates a default.
|
||||||
|
#[derive(
|
||||||
|
Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema,
|
||||||
|
)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PermissionAssignment {
|
||||||
|
pub grants: Vec<PermissionReference>,
|
||||||
|
pub exclusions: Vec<PermissionReference>,
|
||||||
|
pub revoked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PermissionDefinition {
|
||||||
|
pub key: PermissionKey,
|
||||||
|
pub description: String,
|
||||||
|
pub children: Vec<PermissionKey>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SetContribution {
|
||||||
|
pub set: PermissionSetId,
|
||||||
|
pub children: Vec<PermissionReference>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This input deliberately has no owner field. The Host supplies the registered
|
||||||
|
/// identity/namespace handle independently of guest-controlled declaration data.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct NamespaceDeclaration {
|
||||||
|
pub permissions: Vec<PermissionDefinition>,
|
||||||
|
pub sets: Vec<SetContribution>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct NamespaceCatalog {
|
||||||
|
pub namespace: NamespaceId,
|
||||||
|
pub feature: FeatureIdentity,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub declaration: NamespaceDeclaration,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PermissionCatalog {
|
||||||
|
pub workspace: WorkspaceId,
|
||||||
|
pub namespaces: Vec<NamespaceCatalog>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, TS, schemars::JsonSchema,
|
||||||
|
)]
|
||||||
|
#[serde(
|
||||||
|
tag = "kind",
|
||||||
|
content = "value",
|
||||||
|
rename_all = "snake_case",
|
||||||
|
deny_unknown_fields
|
||||||
|
)]
|
||||||
|
pub enum OriginStep {
|
||||||
|
Set(PermissionSetId),
|
||||||
|
Feature(FeatureIdentity),
|
||||||
|
Key(PermissionKey),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, TS, schemars::JsonSchema,
|
||||||
|
)]
|
||||||
|
#[serde(
|
||||||
|
tag = "kind",
|
||||||
|
content = "value",
|
||||||
|
rename_all = "snake_case",
|
||||||
|
deny_unknown_fields
|
||||||
|
)]
|
||||||
|
pub enum PermissionDiagnostic {
|
||||||
|
Unset,
|
||||||
|
Revoked,
|
||||||
|
UnknownSet(PermissionSetId),
|
||||||
|
UnknownNamespace(NamespaceId),
|
||||||
|
UnknownKey(PermissionKey),
|
||||||
|
DisabledFeature(FeatureIdentity),
|
||||||
|
Excluded(PermissionKey),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A management projection, not an executable credential. Only the Host's opaque
|
||||||
|
/// resolution can be queried through a namespace handle.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PermissionResolution {
|
||||||
|
pub workspace: WorkspaceId,
|
||||||
|
pub allowed: BTreeMap<PermissionKey, Vec<Vec<OriginStep>>>,
|
||||||
|
pub diagnostics: Vec<PermissionDiagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The management boundary uses exactly the same assignment as text/storage.
|
||||||
|
/// Workspace comes from authenticated routing, not a repeated permission field.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ValidatePermissionsRequest {
|
||||||
|
pub assignment: PermissionAssignment,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn typescript() -> String {
|
||||||
|
let config = ts_rs::Config::default();
|
||||||
|
let declarations = [
|
||||||
|
WorkspaceId::decl(&config),
|
||||||
|
FeatureIdentity::decl(&config),
|
||||||
|
PermissionSetId::decl(&config),
|
||||||
|
NamespaceId::decl(&config),
|
||||||
|
PermissionKey::decl(&config),
|
||||||
|
NamespacePermissions::decl(&config),
|
||||||
|
PermissionReference::decl(&config),
|
||||||
|
PermissionAssignment::decl(&config),
|
||||||
|
PermissionDefinition::decl(&config),
|
||||||
|
SetContribution::decl(&config),
|
||||||
|
NamespaceDeclaration::decl(&config),
|
||||||
|
NamespaceCatalog::decl(&config),
|
||||||
|
PermissionCatalog::decl(&config),
|
||||||
|
OriginStep::decl(&config),
|
||||||
|
PermissionDiagnostic::decl(&config),
|
||||||
|
PermissionResolution::decl(&config),
|
||||||
|
ValidatePermissionsRequest::decl(&config),
|
||||||
|
];
|
||||||
|
format!(
|
||||||
|
"// Generated by feature-permissions; do not edit.\n{}\n",
|
||||||
|
declarations
|
||||||
|
.iter()
|
||||||
|
.map(|d| format!("export {d}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"grants": [
|
||||||
|
{ "kind": "set", "value": "ticket" },
|
||||||
|
{
|
||||||
|
"kind": "namespace",
|
||||||
|
"value": {
|
||||||
|
"namespace": "ticket",
|
||||||
|
"keys": ["ticket.hoge", "ticket.hoge.fuga.finish"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"exclusions": [
|
||||||
|
{
|
||||||
|
"kind": "namespace",
|
||||||
|
"value": {
|
||||||
|
"namespace": "ticket",
|
||||||
|
"keys": ["ticket.hoge.unlinked"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"revoked": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
revoked = false
|
||||||
|
|
||||||
|
[[grants]]
|
||||||
|
kind = "set"
|
||||||
|
value = "ticket"
|
||||||
|
|
||||||
|
[[grants]]
|
||||||
|
kind = "namespace"
|
||||||
|
[grants.value]
|
||||||
|
namespace = "ticket"
|
||||||
|
keys = ["ticket.hoge", "ticket.hoge.fuga.finish"]
|
||||||
|
|
||||||
|
[[exclusions]]
|
||||||
|
kind = "namespace"
|
||||||
|
[exclusions.value]
|
||||||
|
namespace = "ticket"
|
||||||
|
keys = ["ticket.hoge.unlinked"]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"workspace": "workspace-a",
|
||||||
|
"namespaces": [
|
||||||
|
{
|
||||||
|
"namespace": "ticket",
|
||||||
|
"feature": "builtin:ticket",
|
||||||
|
"enabled": true,
|
||||||
|
"declaration": {
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"key": "ticket.hoge",
|
||||||
|
"description": "Root operation with explicit children",
|
||||||
|
"children": ["ticket.hoge.fuga"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "ticket.hoge.fuga",
|
||||||
|
"description": "Intermediate operation with an explicit child",
|
||||||
|
"children": ["ticket.hoge.fuga.finish"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "ticket.hoge.fuga.finish",
|
||||||
|
"description": "Leaf operation",
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "ticket.hoge.unlinked",
|
||||||
|
"description": "Same prefix without an inclusion edge",
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sets": [
|
||||||
|
{
|
||||||
|
"set": "ticket",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "namespace",
|
||||||
|
"value": { "namespace": "ticket", "keys": ["ticket.hoge"] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
workspace = "workspace-a"
|
||||||
|
|
||||||
|
[[namespaces]]
|
||||||
|
namespace = "ticket"
|
||||||
|
feature = "builtin:ticket"
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
[[namespaces.declaration.permissions]]
|
||||||
|
key = "ticket.hoge"
|
||||||
|
description = "Root operation with explicit children"
|
||||||
|
children = ["ticket.hoge.fuga"]
|
||||||
|
|
||||||
|
[[namespaces.declaration.permissions]]
|
||||||
|
key = "ticket.hoge.fuga"
|
||||||
|
description = "Intermediate operation with an explicit child"
|
||||||
|
children = ["ticket.hoge.fuga.finish"]
|
||||||
|
|
||||||
|
[[namespaces.declaration.permissions]]
|
||||||
|
key = "ticket.hoge.fuga.finish"
|
||||||
|
description = "Leaf operation"
|
||||||
|
children = []
|
||||||
|
|
||||||
|
[[namespaces.declaration.permissions]]
|
||||||
|
key = "ticket.hoge.unlinked"
|
||||||
|
description = "Same prefix without an inclusion edge"
|
||||||
|
children = []
|
||||||
|
|
||||||
|
[[namespaces.declaration.sets]]
|
||||||
|
set = "ticket"
|
||||||
|
|
||||||
|
[[namespaces.declaration.sets.children]]
|
||||||
|
kind = "namespace"
|
||||||
|
[namespaces.declaration.sets.children.value]
|
||||||
|
namespace = "ticket"
|
||||||
|
keys = ["ticket.hoge"]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//! The generated Web contract is an external artifact, not a second hand-written
|
||||||
|
//! list of domains. Normal crate tests reject drift from the declaration types.
|
||||||
|
#[test]
|
||||||
|
fn checked_in_web_contract_matches_the_payload_free_rust_types() {
|
||||||
|
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../web/workspace/src/lib/generated/feature-permissions.generated.ts");
|
||||||
|
let saved = std::fs::read_to_string(path).expect("run the generate_typescript example");
|
||||||
|
assert_eq!(saved, feature_permissions::typescript());
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,287 @@
|
|||||||
|
//! Public JSON/TOML boundary tests using the production Serde DTOs and shared Web
|
||||||
|
//! fixtures. No resolver, Host, storage, or authentication boundary is replaced or
|
||||||
|
//! exercised here. Run with `cargo test -p feature-permissions --test wire_contract`.
|
||||||
|
|
||||||
|
use feature_permissions::{
|
||||||
|
NamespaceCatalog, NamespaceDeclaration, NamespacePermissions, PermissionAssignment,
|
||||||
|
PermissionCatalog, PermissionDefinition, PermissionReference, SetContribution,
|
||||||
|
ValidatePermissionsRequest,
|
||||||
|
};
|
||||||
|
use serde::{Serialize, de::DeserializeOwned};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::fmt::Debug;
|
||||||
|
|
||||||
|
const ASSIGNMENT_JSON: &str = include_str!("fixtures/assignment.json");
|
||||||
|
const ASSIGNMENT_TOML: &str = include_str!("fixtures/assignment.toml");
|
||||||
|
const CATALOG_JSON: &str = include_str!("fixtures/catalog.json");
|
||||||
|
const CATALOG_TOML: &str = include_str!("fixtures/catalog.toml");
|
||||||
|
|
||||||
|
fn paired_fixture<T>(json: &str, toml: &str) -> T
|
||||||
|
where
|
||||||
|
T: DeserializeOwned + Serialize + PartialEq + Debug,
|
||||||
|
{
|
||||||
|
let from_json: T = serde_json::from_str(json).expect("valid JSON fixture");
|
||||||
|
let from_toml: T = toml::from_str(toml).expect("valid TOML fixture");
|
||||||
|
assert_eq!(
|
||||||
|
from_json, from_toml,
|
||||||
|
"text formats must have the same meaning"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&from_json).unwrap(),
|
||||||
|
serde_json::from_str::<Value>(json).unwrap(),
|
||||||
|
"serialization must not drop or synthesize wire fields"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<T>(&serde_json::to_string(&from_toml).unwrap()).unwrap(),
|
||||||
|
from_json
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
toml::from_str::<T>(&toml::to_string(&from_json).unwrap()).unwrap(),
|
||||||
|
from_toml
|
||||||
|
);
|
||||||
|
from_json
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reject_in_both_formats<T: DeserializeOwned>(rule: &str, input: Value) {
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<T>(input.clone()).is_err(),
|
||||||
|
"JSON accepted {rule}: {input}"
|
||||||
|
);
|
||||||
|
let text = toml::to_string(&input).expect("rejection case must be representable as TOML");
|
||||||
|
assert!(
|
||||||
|
toml::from_str::<T>(&text).is_err(),
|
||||||
|
"TOML accepted {rule}: {text}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reject_authority_and_payload_fields<T: DeserializeOwned>(input: Value) {
|
||||||
|
for (field, value) in [
|
||||||
|
("payload", json!({ "path": "/untrusted" })),
|
||||||
|
("owner", json!("other-feature")),
|
||||||
|
("workspace", json!("other-workspace")),
|
||||||
|
] {
|
||||||
|
let mut modified = input.clone();
|
||||||
|
modified
|
||||||
|
.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.insert(field.into(), value);
|
||||||
|
reject_in_both_formats::<T>(field, modified);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assignment_text_formats_preserve_discriminants_and_multisegment_keys() {
|
||||||
|
let assignment: PermissionAssignment = paired_fixture(ASSIGNMENT_JSON, ASSIGNMENT_TOML);
|
||||||
|
// The same spelling is intentional: the tag, not a prefix, chooses the kind.
|
||||||
|
assert!(
|
||||||
|
matches!(&assignment.grants[0], PermissionReference::Set(id) if id.as_str() == "ticket")
|
||||||
|
);
|
||||||
|
let PermissionReference::Namespace(namespace) = &assignment.grants[1] else {
|
||||||
|
panic!("namespace reference lost its discriminant");
|
||||||
|
};
|
||||||
|
assert_eq!(namespace.namespace.as_str(), "ticket");
|
||||||
|
assert_eq!(
|
||||||
|
namespace
|
||||||
|
.keys
|
||||||
|
.iter()
|
||||||
|
.map(|key| key.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["ticket.hoge", "ticket.hoge.fuga.finish"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn namespace_catalog_roundtrip_preserves_only_explicit_multilevel_children() {
|
||||||
|
let catalog: PermissionCatalog = paired_fixture(CATALOG_JSON, CATALOG_TOML);
|
||||||
|
let namespace = &catalog.namespaces[0];
|
||||||
|
// Also roundtrip the standalone management DTO, not only its outer envelope.
|
||||||
|
let standalone: NamespaceCatalog =
|
||||||
|
serde_json::from_value(serde_json::to_value(namespace).unwrap()).unwrap();
|
||||||
|
assert_eq!(&standalone, namespace);
|
||||||
|
let permissions = &standalone.declaration.permissions;
|
||||||
|
let edges: Vec<_> = permissions
|
||||||
|
.iter()
|
||||||
|
.map(|permission| {
|
||||||
|
(
|
||||||
|
permission.key.as_str(),
|
||||||
|
permission
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.map(|key| key.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
edges,
|
||||||
|
[
|
||||||
|
("ticket.hoge", vec!["ticket.hoge.fuga"]),
|
||||||
|
("ticket.hoge.fuga", vec!["ticket.hoge.fuga.finish"]),
|
||||||
|
("ticket.hoge.fuga.finish", vec![]),
|
||||||
|
("ticket.hoge.unlinked", vec![]),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&standalone.declaration.sets[0]).unwrap(),
|
||||||
|
json!({
|
||||||
|
"set": "ticket",
|
||||||
|
"children": [{"kind": "namespace", "value": {"namespace": "ticket", "keys": ["ticket.hoge"]}}]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_empty_and_revocation_survive_without_defaults() {
|
||||||
|
for input in [
|
||||||
|
json!({"grants": [], "exclusions": [], "revoked": false}),
|
||||||
|
json!({"grants": [], "exclusions": [], "revoked": true}),
|
||||||
|
json!({
|
||||||
|
"grants": [{"kind": "namespace", "value": {"namespace": "ticket", "keys": []}}],
|
||||||
|
"exclusions": [],
|
||||||
|
"revoked": false
|
||||||
|
}),
|
||||||
|
] {
|
||||||
|
paired_fixture::<PermissionAssignment>(
|
||||||
|
&serde_json::to_string(&input).unwrap(),
|
||||||
|
&toml::to_string(&input).unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for missing in ["grants", "exclusions", "revoked"] {
|
||||||
|
let mut input = json!({"grants": [], "exclusions": [], "revoked": false});
|
||||||
|
input.as_object_mut().unwrap().remove(missing);
|
||||||
|
reject_in_both_formats::<PermissionAssignment>(missing, input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reference_kind_and_shape_must_match_in_json_and_toml() {
|
||||||
|
for (rule, input) in [
|
||||||
|
("unknown kind", json!({"kind": "all", "value": "ticket"})),
|
||||||
|
(
|
||||||
|
"case-sensitive kind",
|
||||||
|
json!({"kind": "Set", "value": "ticket"}),
|
||||||
|
),
|
||||||
|
("missing kind", json!({"value": "ticket"})),
|
||||||
|
("missing value", json!({"kind": "set"})),
|
||||||
|
(
|
||||||
|
"set with namespace shape",
|
||||||
|
json!({"kind": "set", "value": {"namespace": "ticket", "keys": []}}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"namespace with set shape",
|
||||||
|
json!({"kind": "namespace", "value": "ticket"}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"namespace missing keys",
|
||||||
|
json!({"kind": "namespace", "value": {"namespace": "ticket"}}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"keys are not a scalar",
|
||||||
|
json!({"kind": "namespace", "value": {"namespace": "ticket", "keys": "ticket.hoge"}}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"keys carry no data",
|
||||||
|
json!({"kind": "namespace", "value": {"namespace": "ticket", "keys": [{"key": "ticket.hoge", "payload": {}}]}}),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
reject_in_both_formats::<PermissionReference>(rule, input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_identifiers_reject_invalid_segments_without_truncation() {
|
||||||
|
for invalid in ["", "Ticket", "ticket.read", "ticket read", "*", "1ticket"] {
|
||||||
|
reject_in_both_formats::<PermissionReference>(
|
||||||
|
"invalid set identifier",
|
||||||
|
json!({"kind": "set", "value": invalid}),
|
||||||
|
);
|
||||||
|
reject_in_both_formats::<NamespacePermissions>(
|
||||||
|
"invalid namespace identifier",
|
||||||
|
json!({"namespace": invalid, "keys": []}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for invalid in [
|
||||||
|
"ticket",
|
||||||
|
"ticket..read",
|
||||||
|
".ticket.read",
|
||||||
|
"ticket.read.",
|
||||||
|
"ticket.Read",
|
||||||
|
"ticket.*",
|
||||||
|
"ticket.read path",
|
||||||
|
] {
|
||||||
|
reject_in_both_formats::<NamespacePermissions>(
|
||||||
|
"invalid assigned key",
|
||||||
|
json!({"namespace": "ticket", "keys": [invalid]}),
|
||||||
|
);
|
||||||
|
reject_in_both_formats::<PermissionDefinition>(
|
||||||
|
"invalid declared key",
|
||||||
|
json!({"key": invalid, "description": "", "children": []}),
|
||||||
|
);
|
||||||
|
reject_in_both_formats::<PermissionDefinition>(
|
||||||
|
"invalid child key",
|
||||||
|
json!({"key": "ticket.hoge", "description": "", "children": [invalid]}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (field, invalid) in [
|
||||||
|
("workspace", ""),
|
||||||
|
("workspace", "other workspace"),
|
||||||
|
("feature", ""),
|
||||||
|
("feature", "other\nfeature"),
|
||||||
|
] {
|
||||||
|
let mut catalog: Value = serde_json::from_str(CATALOG_JSON).unwrap();
|
||||||
|
if field == "workspace" {
|
||||||
|
catalog["workspace"] = json!(invalid);
|
||||||
|
} else {
|
||||||
|
catalog["namespaces"][0]["feature"] = json!(invalid);
|
||||||
|
}
|
||||||
|
reject_in_both_formats::<PermissionCatalog>("invalid catalog identity", catalog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grant_inputs_reject_payload_and_caller_supplied_authority_at_every_layer() {
|
||||||
|
let assignment: Value = serde_json::from_str(ASSIGNMENT_JSON).unwrap();
|
||||||
|
reject_authority_and_payload_fields::<PermissionAssignment>(assignment.clone());
|
||||||
|
for reference in assignment["grants"].as_array().unwrap() {
|
||||||
|
reject_authority_and_payload_fields::<PermissionReference>(reference.clone());
|
||||||
|
}
|
||||||
|
reject_authority_and_payload_fields::<NamespacePermissions>(
|
||||||
|
assignment["grants"][1]["value"].clone(),
|
||||||
|
);
|
||||||
|
let request = json!({"assignment": assignment});
|
||||||
|
let parsed: ValidatePermissionsRequest = serde_json::from_value(request.clone()).unwrap();
|
||||||
|
assert_eq!(serde_json::to_value(parsed).unwrap(), request);
|
||||||
|
reject_authority_and_payload_fields::<ValidatePermissionsRequest>(request.clone());
|
||||||
|
let mut nested = request;
|
||||||
|
nested["assignment"]["workspace"] = json!("other-workspace");
|
||||||
|
reject_in_both_formats::<ValidatePermissionsRequest>("nested workspace spoof", nested);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assignment_fields_reject_untyped_grants_and_nonboolean_revocation() {
|
||||||
|
for (field, invalid) in [
|
||||||
|
("grants", json!("ticket.hoge")),
|
||||||
|
("grants", json!([{"ticket.hoge": true}])),
|
||||||
|
("exclusions", json!({})),
|
||||||
|
("revoked", json!("false")),
|
||||||
|
] {
|
||||||
|
let mut input = json!({"grants": [], "exclusions": [], "revoked": false});
|
||||||
|
input[field] = invalid;
|
||||||
|
reject_in_both_formats::<PermissionAssignment>(field, input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn declaration_inputs_reject_payload_and_caller_supplied_authority_at_every_layer() {
|
||||||
|
let catalog: Value = serde_json::from_str(CATALOG_JSON).unwrap();
|
||||||
|
let declaration = &catalog["namespaces"][0]["declaration"];
|
||||||
|
reject_authority_and_payload_fields::<NamespaceDeclaration>(declaration.clone());
|
||||||
|
reject_authority_and_payload_fields::<PermissionDefinition>(
|
||||||
|
declaration["permissions"][0].clone(),
|
||||||
|
);
|
||||||
|
reject_authority_and_payload_fields::<SetContribution>(declaration["sets"][0].clone());
|
||||||
|
// A catalog reports authenticated identities; declaration inputs cannot supply them.
|
||||||
|
let mut input = declaration.clone();
|
||||||
|
input["feature"] = json!("other-feature");
|
||||||
|
reject_in_both_formats::<NamespaceDeclaration>("feature identity injection", input);
|
||||||
|
}
|
||||||
@@ -1,11 +1,66 @@
|
|||||||
# feature-storage
|
# feature-storage
|
||||||
|
|
||||||
Manages the SQLite lifecycle for Features within a trusted Host.
|
Owns physical SQLite identity and lifecycle for trusted Host repositories.
|
||||||
Does not own Feature domain schemas or authentication, or provide SQL access to Workers or models.
|
Features own domain schema, authorization and transactions; storage identity is not a grant.
|
||||||
|
Workers and models do not receive SQL connections.
|
||||||
|
|
||||||
## Main entry points
|
[`ScopedFeatureStorage::provision`] is fresh-only;
|
||||||
|
[`ScopedFeatureStorage::adopt_existing`] requires explicit Host authority and
|
||||||
|
persisted domain attribution. [`ScopedFeatureStorage::open_existing`] consumes
|
||||||
|
an expected Workspace/Feature/physical identity and never creates a missing DB.
|
||||||
|
The lazy [`ScopedFeatureStorage::open`] requests initial Host provisioning only
|
||||||
|
when no ownership record exists; an unbound existing file requires adoption,
|
||||||
|
and a missing previously bound file is an error, not a new incarnation.
|
||||||
|
|
||||||
- [`FeatureStorage`], [`ScopedFeatureStorage`]: registration, scope isolation, storage, and shutdown
|
[`FeatureDatabase`] operations retain the verified connection and shared
|
||||||
- [`FeatureDatabase`]: connections and transactions
|
cross-process lifecycle lease through consumer commit/rollback.
|
||||||
- [`FeatureMigration`]: Feature-owned schema changes
|
[`DatabaseGuard`] extends the short lease over a writer-release decision without
|
||||||
- [`configure_connection`]: shared SQLite connection policy
|
retaining a SQLite transaction. Long Job waits retain only [`DatabaseIdentity`];
|
||||||
|
resumption must reacquire storage and current domain authorization.
|
||||||
|
|
||||||
|
Lock order is scope lifecycle file, manager state, connection, SQLite transaction.
|
||||||
|
The Host must protect the root, owner catalog and stable lockfiles against native
|
||||||
|
code bypassing the owner. A successful same-root lock does not prove arbitrary
|
||||||
|
external writers, a previous Host, or a different filesystem participate.
|
||||||
|
Same-path replacement is checked by physical file identity as well as DB identity;
|
||||||
|
old receipt bytes alone cannot authorize a copied file. Restore records a new
|
||||||
|
physical lifecycle and retains original provenance. A durable erasure fence lives
|
||||||
|
outside the removed scope and cannot be cleared by restoring an older snapshot.
|
||||||
|
|
||||||
|
`restore` recovers missing scope files using the same owner's retained source
|
||||||
|
receipts, never by calling `delete` first. `restore_from` requires the Host to
|
||||||
|
select the source owner explicitly for a cross-root copy. Both check completed
|
||||||
|
snapshot provenance and the source erasure fence through publication; a fresh
|
||||||
|
root is not a way to forget that fence. Cross-root locks are acquired in canonical
|
||||||
|
root order, source shared and target exclusive; same-root recovery takes one
|
||||||
|
exclusive lock. A restore retires, but does not remove, the previous owner
|
||||||
|
receipt. Only the new physical identity is current, including after exact retry.
|
||||||
|
Before creating staging, the owner reserves the complete source-root, snapshot,
|
||||||
|
manifest and per-DB physical/request provenance. An unfinished restore cannot
|
||||||
|
be replaced by a different source or snapshot. Copying uses a separate file;
|
||||||
|
only a verified completed backup is renamed into reusable staging. Unowned
|
||||||
|
staging is diagnosed, not accepted as a completed copy. Identity/completion
|
||||||
|
response loss reconciles that exact request, including after directory publication.
|
||||||
|
Adopted legacy-live scopes can recover after stopped, complete scope-tree loss:
|
||||||
|
the old external record remains unchanged and its marker is reconstructed only
|
||||||
|
after completed owner/snapshot provenance checks. Missing markers in an existing
|
||||||
|
scope, legacy pending/deletion records and new-owner erasure fences still refuse
|
||||||
|
normal admission; this recovery path does not clear erasure.
|
||||||
|
A snapshot without matching source-owner evidence requires explicit, authorized
|
||||||
|
legacy adoption with domain attribution/shape validation instead of blind restore.
|
||||||
|
These checks cover owner-managed operations, not deliberate destruction of the
|
||||||
|
owner catalog/fences or arbitrary Host code inventing authority evidence.
|
||||||
|
|
||||||
|
Adoption identity and domain migrations share a SQLite transaction. A separate
|
||||||
|
owner receipt binds the stable request, target, authority evidence and inode.
|
||||||
|
If the DB commits but completion or its response is lost, the same request
|
||||||
|
reconciles the same physical DB. An unfinished reservation is not an adoption
|
||||||
|
receipt; a different target or request cannot reuse it. Failure after a fresh file
|
||||||
|
is created but before its reservation commits leaves an unowned file requiring
|
||||||
|
explicit Host reconciliation, never discovery-based issuance.
|
||||||
|
|
||||||
|
[`FeatureRegistration`] uses the existing ordered migration owner; a Feature can
|
||||||
|
validate its finite published receipt/shape families without rewriting original
|
||||||
|
migration identities or treating physical acceptance as domain-policy adoption.
|
||||||
|
[`FeatureStorage::open_host_catalog`] connects the Host catalog to the same
|
||||||
|
identity/lifecycle owner; [`configure_connection`] owns shared SQLite durability policy.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,177 @@
|
|||||||
|
//! Retained physical metadata from published FeatureStorage writers. These are
|
||||||
|
//! provenance, not authority: adoption still needs a Host grant, attribution and
|
||||||
|
//! receipt/shape validation. Never rewrite their identity or provisioning receipt.
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) fn identity(path: &Path, scope: &str, feature: &str) -> Result<Option<String>> {
|
||||||
|
let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
|
||||||
|
let exists = |name: &str| -> Result<bool> {
|
||||||
|
Ok(connection.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name=?1)",
|
||||||
|
[name],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?)
|
||||||
|
};
|
||||||
|
if !exists("__yoi_feature_database_identity")? {
|
||||||
|
if exists("__yoi_feature_provisioning_receipt")? {
|
||||||
|
return Err(FeatureStorageError::Identity(
|
||||||
|
"legacy provisioning receipt has no physical identity".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
verify_shape(
|
||||||
|
&connection,
|
||||||
|
"__yoi_feature_database_identity",
|
||||||
|
"CREATE TABLE __yoi_feature_database_identity (singleton INTEGER PRIMARY KEY CHECK(singleton=1), format INTEGER NOT NULL CHECK(format=1), scope_id TEXT NOT NULL,feature_id TEXT NOT NULL,incarnation TEXT NOT NULL)",
|
||||||
|
)?;
|
||||||
|
let count: i64 = connection.query_row(
|
||||||
|
"SELECT COUNT(*) FROM __yoi_feature_database_identity",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
|
let (format,saved_scope,saved_feature,physical): (i64,String,String,String) = connection.query_row(
|
||||||
|
"SELECT format,scope_id,feature_id,incarnation FROM __yoi_feature_database_identity WHERE singleton=1",[],
|
||||||
|
|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?)))?;
|
||||||
|
let id = Uuid::parse_str(&physical)
|
||||||
|
.map_err(|_| FeatureStorageError::Identity("corrupt legacy physical identity".into()))?;
|
||||||
|
if count != 1
|
||||||
|
|| format != 1
|
||||||
|
|| scope != saved_scope
|
||||||
|
|| feature != saved_feature
|
||||||
|
|| id.get_version_num() != 7
|
||||||
|
|| id.to_string() != physical
|
||||||
|
{
|
||||||
|
return Err(FeatureStorageError::Identity(
|
||||||
|
"legacy physical attribution differs or is corrupt".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if exists("__yoi_feature_provisioning_receipt")? {
|
||||||
|
verify_shape(
|
||||||
|
&connection,
|
||||||
|
"__yoi_feature_provisioning_receipt",
|
||||||
|
"CREATE TABLE __yoi_feature_provisioning_receipt (singleton INTEGER PRIMARY KEY CHECK(singleton=1),request_fingerprint TEXT NOT NULL)",
|
||||||
|
)?;
|
||||||
|
let (count,fingerprint): (i64,String) = connection.query_row(
|
||||||
|
"SELECT (SELECT COUNT(*) FROM __yoi_feature_provisioning_receipt),request_fingerprint FROM __yoi_feature_provisioning_receipt WHERE singleton=1",[],|r|Ok((r.get(0)?,r.get(1)?)))?;
|
||||||
|
if count != 1
|
||||||
|
|| fingerprint.len() != 71
|
||||||
|
|| !fingerprint.starts_with("sha256:")
|
||||||
|
|| !fingerprint.as_bytes()[7..]
|
||||||
|
.iter()
|
||||||
|
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(b))
|
||||||
|
{
|
||||||
|
return Err(FeatureStorageError::Identity(
|
||||||
|
"corrupt legacy provisioning receipt".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Some(physical))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn fenced(root: &Path, scope: &str) -> Result<bool> {
|
||||||
|
fenced_inner(root, scope, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only restore may inspect a lost live scope; it must still match snapshot
|
||||||
|
// provenance against completed new-owner receipts before admitting any bytes.
|
||||||
|
pub(super) fn fenced_for_restore(root: &Path, scope: &str) -> Result<bool> {
|
||||||
|
fenced_inner(root, scope, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fenced_inner(root: &Path, scope: &str, recovering_loss: bool) -> Result<bool> {
|
||||||
|
if root
|
||||||
|
.join(".lifecycle")
|
||||||
|
.join(format!("{scope}.deleted"))
|
||||||
|
.try_exists()?
|
||||||
|
{
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
let record_path = root.join(".scope-locks").join(format!("{scope}.lock"));
|
||||||
|
let record = match fs::read_to_string(record_path) {
|
||||||
|
Ok(record) if !record.is_empty() => record,
|
||||||
|
Ok(_) => return Ok(false),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
let fields: Vec<_> = record.trim_end_matches('\n').split(' ').collect();
|
||||||
|
if fields.len() != 3 || fields[0] != "v1" {
|
||||||
|
return Err(FeatureStorageError::Identity(
|
||||||
|
"corrupt legacy scope record".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let id = Uuid::parse_str(fields[2])
|
||||||
|
.map_err(|_| FeatureStorageError::Identity("corrupt legacy scope identity".into()))?;
|
||||||
|
if record != format!("v1 {} {id}\n", fields[1]) {
|
||||||
|
return Err(FeatureStorageError::Identity(
|
||||||
|
"noncanonical legacy scope record".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
match fields[1] {
|
||||||
|
"deleted" | "deleting" => Ok(true),
|
||||||
|
"live" => {
|
||||||
|
let marker = fs::read_to_string(root.join(scope).join(".incarnation"));
|
||||||
|
if recovering_loss
|
||||||
|
&& !root.join(scope).try_exists()?
|
||||||
|
&& marker
|
||||||
|
.as_ref()
|
||||||
|
.is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound)
|
||||||
|
{
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
if marker? != format!("{id}\n") {
|
||||||
|
return Err(FeatureStorageError::Identity(
|
||||||
|
"legacy scope incarnation differs".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
_ => Err(FeatureStorageError::Identity(
|
||||||
|
"unfinished legacy scope requires reconciliation by its owner".into(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconstruct only a previously retained live scope marker, never a deletion
|
||||||
|
/// record or a new identity. Called after matching completed snapshot provenance.
|
||||||
|
pub(super) fn restore_marker(root: &Path, scope: &str, temporary: &Path) -> Result<()> {
|
||||||
|
if fenced_for_restore(root, scope)? {
|
||||||
|
return Err(FeatureStorageError::ScopeMaintenance(scope.into()));
|
||||||
|
}
|
||||||
|
let record = match fs::read_to_string(root.join(".scope-locks").join(format!("{scope}.lock"))) {
|
||||||
|
Ok(record) => record,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
if let Some(id) = record
|
||||||
|
.strip_prefix("v1 live ")
|
||||||
|
.and_then(|s| s.strip_suffix('\n'))
|
||||||
|
{
|
||||||
|
let marker = temporary.join(".incarnation");
|
||||||
|
fs::write(&marker, format!("{id}\n"))?;
|
||||||
|
std::fs::File::open(marker)?.sync_all()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_shape(connection: &Connection, table: &str, expected: &str) -> Result<()> {
|
||||||
|
let sql: String = connection.query_row(
|
||||||
|
"SELECT sql FROM sqlite_schema WHERE type='table' AND name=?1",
|
||||||
|
[table],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
|
// These two frozen DDL definitions contain no SQL string literals. Whitespace
|
||||||
|
// and token case are immaterial; constraints and column programs are not.
|
||||||
|
let normalized = |s: &str| {
|
||||||
|
s.chars()
|
||||||
|
.filter(|c| !c.is_ascii_whitespace())
|
||||||
|
.collect::<String>()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
};
|
||||||
|
if normalized(&sql) != normalized(expected) {
|
||||||
|
return Err(FeatureStorageError::Identity(
|
||||||
|
"legacy physical metadata shape differs".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+953
-114
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,219 @@
|
|||||||
|
//! Durability establishment is local to a manager, not inferred from existence.
|
||||||
|
//! Retained descriptors pin the established ancestor chain and scoped lock inode;
|
||||||
|
//! each acquisition rechecks the configured/canonical paths before and after the
|
||||||
|
//! OS lock. A replacement is rejected for the lifetime of that proof. New
|
||||||
|
//! managers (including new processes) repeat all persistence steps. Failed
|
||||||
|
//! establishment publishes no proof. Configured Host symlinks remain supported,
|
||||||
|
//! but their mutable links require full establishment on every acquisition.
|
||||||
|
//!
|
||||||
|
//! This is only durability evidence. The caller must still verify the current
|
||||||
|
//! owner catalog, DB identity, fences and authorization under the returned lease.
|
||||||
|
//! As with the owner, advisory checks require a Host-protected filesystem; they
|
||||||
|
//! do not sandbox native writers racing after the final path check.
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Instance-local durability evidence, shared only by a manager and its handles.
|
||||||
|
/// It never substitutes for the owner catalog, DB identity, fences or authority.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct EstablishedLocks {
|
||||||
|
scopes: Mutex<HashMap<String, Arc<Proof>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Directory {
|
||||||
|
path: PathBuf,
|
||||||
|
file: File,
|
||||||
|
symlink: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Proof {
|
||||||
|
root: PathBuf,
|
||||||
|
canonical_root: PathBuf,
|
||||||
|
directories: Vec<Directory>,
|
||||||
|
lock_path: PathBuf,
|
||||||
|
lock: File,
|
||||||
|
reusable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn conflict() -> FeatureStorageError {
|
||||||
|
FeatureStorageError::Identity("canonical lifecycle lock path was replaced or redirected".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn same_file(left: &fs::Metadata, right: &fs::Metadata) -> bool {
|
||||||
|
left.dev() == right.dev() && left.ino() == right.ino()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn regular_lock(path: &Path) -> Result<fs::Metadata> {
|
||||||
|
let metadata = fs::symlink_metadata(path)?;
|
||||||
|
if !metadata.is_file() {
|
||||||
|
return Err(conflict());
|
||||||
|
}
|
||||||
|
Ok(metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Proof {
|
||||||
|
fn capture(root: &Path, scope: &str) -> Result<Self> {
|
||||||
|
let root = if root.is_absolute() {
|
||||||
|
root.to_path_buf()
|
||||||
|
} else {
|
||||||
|
std::env::current_dir()?.join(root)
|
||||||
|
};
|
||||||
|
let directory = root.join(".lifecycle");
|
||||||
|
// A configured Host root may contain symlinks. The lock namespace itself
|
||||||
|
// must not: following a replacement there could split the advisory lock.
|
||||||
|
if !fs::symlink_metadata(&directory)?.is_dir() {
|
||||||
|
return Err(conflict());
|
||||||
|
}
|
||||||
|
let canonical_root = fs::canonicalize(&root)?;
|
||||||
|
let resolved = fs::canonicalize(&directory)?;
|
||||||
|
if resolved != canonical_root.join(".lifecycle") {
|
||||||
|
return Err(conflict());
|
||||||
|
}
|
||||||
|
let mut directories = Vec::new();
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
for path in directory.ancestors().chain(resolved.ancestors()) {
|
||||||
|
if seen.insert(path.to_path_buf()) {
|
||||||
|
let symlink = fs::symlink_metadata(path)?.is_symlink();
|
||||||
|
let file = File::open(path)?;
|
||||||
|
if !file.metadata()?.is_dir() {
|
||||||
|
return Err(conflict());
|
||||||
|
}
|
||||||
|
directories.push(Directory {
|
||||||
|
path: path.into(),
|
||||||
|
file,
|
||||||
|
symlink,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let lock_path = directory.join(format!("{scope}.lock"));
|
||||||
|
match fs::symlink_metadata(&lock_path) {
|
||||||
|
Ok(_) => {
|
||||||
|
regular_lock(&lock_path)?;
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
}
|
||||||
|
let lock = OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(false)
|
||||||
|
.open(&lock_path)?;
|
||||||
|
let proof = Self {
|
||||||
|
root,
|
||||||
|
canonical_root,
|
||||||
|
reusable: directories.iter().all(|entry| !entry.symlink),
|
||||||
|
directories,
|
||||||
|
lock_path,
|
||||||
|
lock,
|
||||||
|
};
|
||||||
|
proof.check()?;
|
||||||
|
Ok(proof)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(&self) -> Result<()> {
|
||||||
|
if fs::canonicalize(&self.root)? != self.canonical_root {
|
||||||
|
return Err(conflict());
|
||||||
|
}
|
||||||
|
for directory in &self.directories {
|
||||||
|
let link = fs::symlink_metadata(&directory.path)?;
|
||||||
|
if link.is_symlink() != directory.symlink
|
||||||
|
|| !same_file(&fs::metadata(&directory.path)?, &directory.file.metadata()?)
|
||||||
|
{
|
||||||
|
return Err(conflict());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !same_file(®ular_lock(&self.lock_path)?, &self.lock.metadata()?)
|
||||||
|
|| fs::canonicalize(&self.lock_path)?
|
||||||
|
!= self
|
||||||
|
.canonical_root
|
||||||
|
.join(".lifecycle")
|
||||||
|
.join(self.lock_path.file_name().ok_or_else(conflict)?)
|
||||||
|
{
|
||||||
|
return Err(conflict());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(&self) -> Result<File> {
|
||||||
|
self.check()?;
|
||||||
|
// A fresh open description is required for each flock lease. Cloning
|
||||||
|
// the pinned File would share one OS lock across independent operations.
|
||||||
|
let lock = OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&self.lock_path)?;
|
||||||
|
if !same_file(&lock.metadata()?, &self.lock.metadata()?) {
|
||||||
|
return Err(conflict());
|
||||||
|
}
|
||||||
|
Ok(lock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Lifecycle {
|
||||||
|
pub(super) fn lock_with_sync(
|
||||||
|
&self,
|
||||||
|
exclusive: bool,
|
||||||
|
sync: &mut impl FnMut(&Path) -> std::io::Result<()>,
|
||||||
|
) -> Result<File> {
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
|
self.observation.acquisition();
|
||||||
|
let proof = {
|
||||||
|
let mut scopes = self.established.scopes.lock().map_err(|_| {
|
||||||
|
FeatureStorageError::Operation("lifecycle establishment mutex poisoned".into())
|
||||||
|
})?;
|
||||||
|
let previous = scopes.get(&self.scope);
|
||||||
|
if let Some(proof) = previous {
|
||||||
|
// Replacements are rejected, not silently accepted as a new
|
||||||
|
// lock domain while an old manager/handle might still hold it.
|
||||||
|
proof.check()?;
|
||||||
|
}
|
||||||
|
if let Some(proof) = previous.filter(|proof| proof.reusable) {
|
||||||
|
proof.clone()
|
||||||
|
} else {
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
|
let started = self.observation.sync_start();
|
||||||
|
let result = (|| {
|
||||||
|
let directory = self.root.join(".lifecycle");
|
||||||
|
fs::create_dir_all(&directory)?;
|
||||||
|
let proof = Proof::capture(&self.root, &self.scope)?;
|
||||||
|
// Re-sync even existing ancestors: their presence is not
|
||||||
|
// evidence that an earlier attempt persisted their links.
|
||||||
|
durable_create_directory(&directory, sync)?;
|
||||||
|
proof.lock.sync_all()?;
|
||||||
|
sync(&directory)?;
|
||||||
|
proof.check()?;
|
||||||
|
Ok::<_, FeatureStorageError>(Arc::new(proof))
|
||||||
|
})();
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
|
self.observation.sync_finished(started, result.is_err());
|
||||||
|
let proof = result?;
|
||||||
|
// Publish only after every durability step and path recheck.
|
||||||
|
// Pinned descriptors prevent inode reuse from reviving proof.
|
||||||
|
scopes.insert(self.scope.clone(), proof.clone());
|
||||||
|
proof
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let lock = proof.open()?;
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
|
let started = self.observation.lock_start();
|
||||||
|
let result = if exclusive {
|
||||||
|
lock.lock()
|
||||||
|
} else {
|
||||||
|
lock.lock_shared()
|
||||||
|
};
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
|
self.observation.lock_finished(started, result.is_err());
|
||||||
|
result?;
|
||||||
|
// Do not hold the instance mutex while waiting for another process.
|
||||||
|
// Check again after the OS wait, before the caller can touch its DB.
|
||||||
|
proof.check()?;
|
||||||
|
if !same_file(&lock.metadata()?, &proof.lock.metadata()?) {
|
||||||
|
return Err(conflict());
|
||||||
|
}
|
||||||
|
Ok(lock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "lifecycle_lock_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
//! Component tests use the production manager, lifecycle locks and filesystem.
|
||||||
|
//! Only directory sync failure is injected; DB identity checks remain production.
|
||||||
|
use super::*;
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
fn sync_paths(lifecycle: &Lifecycle) -> Vec<PathBuf> {
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
drop(
|
||||||
|
lifecycle
|
||||||
|
.lock_with_sync(false, &mut |path| {
|
||||||
|
paths.push(path.into());
|
||||||
|
sync_directory(path)
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
paths
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_establishment_retries_every_link_even_when_directories_and_lock_exist() {
|
||||||
|
let reference = tempfile::tempdir().unwrap();
|
||||||
|
let reference_manager = FeatureStorage::new(reference.path().join("nested/storage"));
|
||||||
|
let count = sync_paths(&reference_manager.lifecycle("scope")).len();
|
||||||
|
assert!(count > 2, "ancestor chain and final lock-directory sync");
|
||||||
|
for fail_at in 0..count {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let root = temp.path().join("nested/storage");
|
||||||
|
let manager = FeatureStorage::new(&root);
|
||||||
|
let lifecycle = manager.lifecycle("scope");
|
||||||
|
let mut calls = 0;
|
||||||
|
let result = lifecycle.lock_with_sync(false, &mut |path| {
|
||||||
|
let index = calls;
|
||||||
|
calls += 1;
|
||||||
|
if index == fail_at {
|
||||||
|
Err(std::io::Error::other("injected directory sync failure"))
|
||||||
|
} else {
|
||||||
|
sync_directory(path)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(FeatureStorageError::Io(_))),
|
||||||
|
"{fail_at}"
|
||||||
|
);
|
||||||
|
assert_eq!(calls, fail_at + 1);
|
||||||
|
assert!(root.join(".lifecycle/scope.lock").is_file());
|
||||||
|
assert!(lifecycle.established.scopes.lock().unwrap().is_empty());
|
||||||
|
let retried = sync_paths(&lifecycle);
|
||||||
|
assert_eq!(
|
||||||
|
retried.len(),
|
||||||
|
count,
|
||||||
|
"all durability steps retry at {fail_at}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
retried.first(),
|
||||||
|
retried.last(),
|
||||||
|
"lock directory persisted last"
|
||||||
|
);
|
||||||
|
drop(
|
||||||
|
lifecycle
|
||||||
|
.lock_with_sync(false, &mut |_| panic!("already established"))
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn establishment_is_shared_with_handles_but_not_new_managers_or_scopes() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let root = temp.path().join("storage");
|
||||||
|
let manager = FeatureStorage::new(&root);
|
||||||
|
let paths = sync_paths(&manager.lifecycle("scope"));
|
||||||
|
assert!(!paths.is_empty());
|
||||||
|
assert!(sync_paths(&manager.clone().lifecycle("scope")).is_empty());
|
||||||
|
assert_eq!(sync_paths(&manager.lifecycle("other")), paths);
|
||||||
|
let other = FeatureStorage::new(&root);
|
||||||
|
assert_eq!(
|
||||||
|
sync_paths(&other.lifecycle("scope")),
|
||||||
|
paths,
|
||||||
|
"existence is not proof"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sync_paths(&FeatureStorage::new(&root).lifecycle("scope")),
|
||||||
|
paths
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_database(root: &Path) -> (FeatureStorage, RegisteredFeature, FeatureDatabase) {
|
||||||
|
let manager = FeatureStorage::new(root);
|
||||||
|
let registration = manager
|
||||||
|
.register(FeatureRegistration::new("feature", &[]))
|
||||||
|
.unwrap();
|
||||||
|
let database = manager.scope("scope").unwrap().open(®istration).unwrap();
|
||||||
|
database
|
||||||
|
.transaction(|tx| {
|
||||||
|
tx.execute_batch(
|
||||||
|
"CREATE TABLE marker(value TEXT); INSERT INTO marker VALUES('persisted');",
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
(manager, registration, database)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rejected(
|
||||||
|
manager: &FeatureStorage,
|
||||||
|
registration: &RegisteredFeature,
|
||||||
|
database: &FeatureDatabase,
|
||||||
|
) {
|
||||||
|
assert!(
|
||||||
|
database
|
||||||
|
.with_connection(|_| -> Result<()> { panic!("replaced lock cannot enter DB closure") })
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(database.guard(database.identity()).is_err());
|
||||||
|
assert!(manager.scope("scope").unwrap().open(registration).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn physical_root_directory_lock_and_ancestor_replacement_reject_retained_access() {
|
||||||
|
for replacement in ["root", "directory", "lock", "ancestor"] {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let root = temp.path().join("parent/storage");
|
||||||
|
let (manager, registration, database) = open_database(&root);
|
||||||
|
let path = match replacement {
|
||||||
|
"root" => root.clone(),
|
||||||
|
"directory" => root.join(".lifecycle"),
|
||||||
|
"lock" => root.join(".lifecycle/scope.lock"),
|
||||||
|
"ancestor" => root.parent().unwrap().to_path_buf(),
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
let moved = temp.path().join("moved");
|
||||||
|
fs::rename(&path, &moved).unwrap();
|
||||||
|
if replacement == "lock" {
|
||||||
|
fs::write(&path, b"").unwrap();
|
||||||
|
} else {
|
||||||
|
fs::create_dir_all(&path).unwrap();
|
||||||
|
}
|
||||||
|
rejected(&manager, ®istration, &database);
|
||||||
|
// Rejecting must not erase the old proof and accept the replacement on retry.
|
||||||
|
rejected(&manager, ®istration, &database);
|
||||||
|
fs::remove_file(&path)
|
||||||
|
.or_else(|_| fs::remove_dir_all(&path))
|
||||||
|
.unwrap();
|
||||||
|
fs::rename(&moved, &path).unwrap();
|
||||||
|
let value: String = database
|
||||||
|
.with_connection(|connection| {
|
||||||
|
Ok(connection.query_row("SELECT value FROM marker", [], |r| r.get(0))?)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(value, "persisted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn symlink_replacement_cannot_redirect_or_split_an_established_lock() {
|
||||||
|
for replacement in ["root", "directory", "lock"] {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let root = temp.path().join("storage");
|
||||||
|
let (manager, registration, database) = open_database(&root);
|
||||||
|
let path = match replacement {
|
||||||
|
"root" => root.clone(),
|
||||||
|
"directory" => root.join(".lifecycle"),
|
||||||
|
"lock" => root.join(".lifecycle/scope.lock"),
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
let moved = temp.path().join("moved");
|
||||||
|
fs::rename(&path, &moved).unwrap();
|
||||||
|
symlink(&moved, &path).unwrap();
|
||||||
|
// The target inode is still the original; following metadata alone is insufficient.
|
||||||
|
rejected(&manager, ®istration, &database);
|
||||||
|
fs::remove_file(&path).unwrap();
|
||||||
|
fs::rename(&moved, &path).unwrap();
|
||||||
|
drop(database.guard(database.identity()).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn initial_lock_namespace_symlinks_are_rejected_without_establishment_proof() {
|
||||||
|
for directory_link in [true, false] {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let root = temp.path().join("storage");
|
||||||
|
fs::create_dir(&root).unwrap();
|
||||||
|
let target = temp.path().join("target");
|
||||||
|
if directory_link {
|
||||||
|
fs::create_dir(&target).unwrap();
|
||||||
|
symlink(&target, root.join(".lifecycle")).unwrap();
|
||||||
|
} else {
|
||||||
|
fs::create_dir(root.join(".lifecycle")).unwrap();
|
||||||
|
fs::write(&target, b"").unwrap();
|
||||||
|
symlink(&target, root.join(".lifecycle/scope.lock")).unwrap();
|
||||||
|
}
|
||||||
|
let manager = FeatureStorage::new(&root);
|
||||||
|
let lifecycle = manager.lifecycle("scope");
|
||||||
|
assert!(matches!(
|
||||||
|
lifecycle.lock(false),
|
||||||
|
Err(FeatureStorageError::Identity(_))
|
||||||
|
));
|
||||||
|
assert!(lifecycle.established.scopes.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configured_host_symlinks_reestablish_and_reject_changed_canonical_root() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let target = temp.path().join("target");
|
||||||
|
fs::create_dir(&target).unwrap();
|
||||||
|
let root = temp.path().join("alias");
|
||||||
|
symlink(&target, &root).unwrap();
|
||||||
|
let manager = FeatureStorage::new(&root);
|
||||||
|
let lifecycle = manager.lifecycle("scope");
|
||||||
|
let paths = sync_paths(&lifecycle);
|
||||||
|
assert!(paths.contains(&target));
|
||||||
|
// Mutable symlink links are not pinned by ordinary directory descriptors;
|
||||||
|
// therefore no successful proof can suppress their durability steps.
|
||||||
|
assert_eq!(sync_paths(&lifecycle), paths);
|
||||||
|
let next = temp.path().join("next");
|
||||||
|
fs::create_dir(&next).unwrap();
|
||||||
|
fs::remove_file(&root).unwrap();
|
||||||
|
symlink(&next, &root).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
lifecycle.lock(false),
|
||||||
|
Err(FeatureStorageError::Identity(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
|
#[test]
|
||||||
|
fn replacement_during_os_lock_wait_is_rejected_before_database_access() {
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let root = temp.path().join("storage");
|
||||||
|
let (manager, registration, database) = open_database(&root);
|
||||||
|
let guard = database.guard(database.identity()).unwrap();
|
||||||
|
let attempts = manager.lifecycle_observation().lock_attempts;
|
||||||
|
let lifecycle = manager.lifecycle("scope");
|
||||||
|
let waiter = std::thread::spawn(move || lifecycle.lock(true));
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
while manager.lifecycle_observation().lock_attempts == attempts {
|
||||||
|
assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"waiter did not reach OS lock call"
|
||||||
|
);
|
||||||
|
std::thread::yield_now();
|
||||||
|
}
|
||||||
|
let lock_path = root.join(".lifecycle/scope.lock");
|
||||||
|
fs::rename(&lock_path, root.join(".lifecycle/held.lock")).unwrap();
|
||||||
|
fs::write(&lock_path, b"").unwrap();
|
||||||
|
drop(guard);
|
||||||
|
assert!(matches!(
|
||||||
|
waiter.join().unwrap(),
|
||||||
|
Err(FeatureStorageError::Identity(_))
|
||||||
|
));
|
||||||
|
rejected(&manager, ®istration, &database);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
/// Cumulative lifecycle observations for one storage manager and its clones/handles.
|
||||||
|
/// Counts attempts before starting I/O, including attempts that return errors.
|
||||||
|
/// `acquisitions` counts entries to lifecycle acquisition; `sync_attempts` counts
|
||||||
|
/// lifecycle durability establishment attempts (the full ancestor sync plus
|
||||||
|
/// lockfile and lock-directory persistence), and `lock_attempts` counts OS
|
||||||
|
/// shared/exclusive lock calls, excluding lockfile open failures. Once this
|
||||||
|
/// manager has established and rechecked the same physical paths, ordinary
|
||||||
|
/// acquisitions need no establishment attempt. A fresh manager establishes again.
|
||||||
|
///
|
||||||
|
/// Durations sum monotonic elapsed nanoseconds around those calls, including
|
||||||
|
/// errors and OS lock waits, but not lease holding time or database operations.
|
||||||
|
/// Overlapping threads contribute independently: aggregates are not wall time.
|
||||||
|
/// Fields saturate at u64::MAX and are independently sampled with relaxed loads;
|
||||||
|
/// a concurrent snapshot need not describe one atomic point in time. In-flight
|
||||||
|
/// calls contribute attempts before their duration or failure is recorded.
|
||||||
|
/// These diagnostics retain no database or authority and are not termination,
|
||||||
|
/// durability, or successful-work evidence; they never control storage I/O.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct LifecycleObservation {
|
||||||
|
pub acquisitions: u64,
|
||||||
|
pub sync_attempts: u64,
|
||||||
|
pub sync_nanoseconds: u64,
|
||||||
|
pub failed_syncs: u64,
|
||||||
|
pub lock_attempts: u64,
|
||||||
|
pub lock_nanoseconds: u64,
|
||||||
|
pub failed_locks: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LifecycleObservation {
|
||||||
|
/// Saturating differences between snapshots from the same manager.
|
||||||
|
pub fn since(self, before: Self) -> Self {
|
||||||
|
Self {
|
||||||
|
acquisitions: self.acquisitions.saturating_sub(before.acquisitions),
|
||||||
|
sync_attempts: self.sync_attempts.saturating_sub(before.sync_attempts),
|
||||||
|
sync_nanoseconds: self
|
||||||
|
.sync_nanoseconds
|
||||||
|
.saturating_sub(before.sync_nanoseconds),
|
||||||
|
failed_syncs: self.failed_syncs.saturating_sub(before.failed_syncs),
|
||||||
|
lock_attempts: self.lock_attempts.saturating_sub(before.lock_attempts),
|
||||||
|
lock_nanoseconds: self
|
||||||
|
.lock_nanoseconds
|
||||||
|
.saturating_sub(before.lock_nanoseconds),
|
||||||
|
failed_locks: self.failed_locks.saturating_sub(before.failed_locks),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub(super) struct Counters {
|
||||||
|
acquisitions: AtomicU64,
|
||||||
|
sync_attempts: AtomicU64,
|
||||||
|
sync_nanoseconds: AtomicU64,
|
||||||
|
failed_syncs: AtomicU64,
|
||||||
|
lock_attempts: AtomicU64,
|
||||||
|
lock_nanoseconds: AtomicU64,
|
||||||
|
failed_locks: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Counters {
|
||||||
|
pub(super) fn snapshot(&self) -> LifecycleObservation {
|
||||||
|
LifecycleObservation {
|
||||||
|
acquisitions: self.acquisitions.load(Ordering::Relaxed),
|
||||||
|
sync_attempts: self.sync_attempts.load(Ordering::Relaxed),
|
||||||
|
sync_nanoseconds: self.sync_nanoseconds.load(Ordering::Relaxed),
|
||||||
|
failed_syncs: self.failed_syncs.load(Ordering::Relaxed),
|
||||||
|
lock_attempts: self.lock_attempts.load(Ordering::Relaxed),
|
||||||
|
lock_nanoseconds: self.lock_nanoseconds.load(Ordering::Relaxed),
|
||||||
|
failed_locks: self.failed_locks.load(Ordering::Relaxed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn acquisition(&self) {
|
||||||
|
add(&self.acquisitions, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn sync_start(&self) -> Instant {
|
||||||
|
add(&self.sync_attempts, 1);
|
||||||
|
Instant::now()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn sync_finished(&self, started: Instant, failed: bool) {
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
add(&self.sync_nanoseconds, nanoseconds(elapsed));
|
||||||
|
if failed {
|
||||||
|
add(&self.failed_syncs, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn lock_start(&self) -> Instant {
|
||||||
|
add(&self.lock_attempts, 1);
|
||||||
|
Instant::now()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn lock_finished(&self, started: Instant, failed: bool) {
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
add(&self.lock_nanoseconds, nanoseconds(elapsed));
|
||||||
|
if failed {
|
||||||
|
add(&self.failed_locks, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nanoseconds(duration: std::time::Duration) -> u64 {
|
||||||
|
duration.as_nanos().min(u128::from(u64::MAX)) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add(counter: &AtomicU64, amount: u64) {
|
||||||
|
let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| {
|
||||||
|
Some(value.saturating_add(amount))
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
//! Physical ownership is independent of domain authorization. The trusted Host
|
||||||
|
//! supplies adoption evidence and validates domain attribution inside the identity
|
||||||
|
//! transaction. Merely finding a file never grants adoption authority.
|
||||||
|
//!
|
||||||
|
//! Lock order is lifecycle file, manager state, connection, SQLite transaction.
|
||||||
|
//! The stable scope lock is shared through consumer commit/rollback and exclusive
|
||||||
|
//! during provisioning, adoption, deletion and restore, across managers/processes.
|
||||||
|
//! It is advisory: arbitrary native code bypassing this owner can rename files.
|
||||||
|
//! Such replacement is rejected on the next access using the file identity and
|
||||||
|
//! persisted physical identity. Hosts must protect the root and its lock/owner DB
|
||||||
|
//! against uncooperative writers; this is not a filesystem sandbox.
|
||||||
|
use super::*;
|
||||||
|
use rusqlite::OptionalExtension;
|
||||||
|
use std::fs::{File, OpenOptions};
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
|
||||||
|
const IDENTITY_TABLE: &str = "__yoi_storage_identity";
|
||||||
|
|
||||||
|
#[path = "lifecycle_lock.rs"]
|
||||||
|
mod lifecycle_lock;
|
||||||
|
pub(super) use lifecycle_lock::EstablishedLocks;
|
||||||
|
|
||||||
|
/// Expected storage target, retained with authorization and rechecked on resume.
|
||||||
|
/// This is a physical identity, not an authorization credential or a Job lease.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct DatabaseIdentity {
|
||||||
|
pub scope: String,
|
||||||
|
pub feature: String,
|
||||||
|
pub physical: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(super) struct Binding {
|
||||||
|
pub identity: DatabaseIdentity,
|
||||||
|
pub device: u64,
|
||||||
|
pub inode: u64,
|
||||||
|
pub request: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct Lifecycle {
|
||||||
|
pub root: PathBuf,
|
||||||
|
pub scope: String,
|
||||||
|
pub established: Arc<EstablishedLocks>,
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
|
pub observation: Arc<lifecycle_observation::Counters>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Lifecycle {
|
||||||
|
pub fn lock(&self, exclusive: bool) -> Result<File> {
|
||||||
|
self.lock_with_sync(exclusive, &mut sync_directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn catalog(&self, create: bool) -> Result<Connection> {
|
||||||
|
let flags = OpenFlags::SQLITE_OPEN_READ_WRITE
|
||||||
|
| if create {
|
||||||
|
OpenFlags::SQLITE_OPEN_CREATE
|
||||||
|
} else {
|
||||||
|
OpenFlags::empty()
|
||||||
|
};
|
||||||
|
let connection = Connection::open_with_flags(self.root.join(".owner.sqlite"), flags)?;
|
||||||
|
configure_connection(&connection)?;
|
||||||
|
if create {
|
||||||
|
connection.execute_batch(
|
||||||
|
"
|
||||||
|
CREATE TABLE IF NOT EXISTS storage_requests (
|
||||||
|
request TEXT PRIMARY KEY, scope TEXT NOT NULL, feature TEXT NOT NULL,
|
||||||
|
physical TEXT NOT NULL, device INTEGER NOT NULL, inode INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL, evidence TEXT NOT NULL, completed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
current INTEGER NOT NULL DEFAULT 1);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS storage_current_target ON storage_requests(scope,feature) WHERE current=1;
|
||||||
|
CREATE TABLE IF NOT EXISTS storage_deletions (scope TEXT PRIMARY KEY);
|
||||||
|
CREATE TABLE IF NOT EXISTS storage_restores (scope TEXT PRIMARY KEY, evidence TEXT NOT NULL, published INTEGER NOT NULL DEFAULT 0);
|
||||||
|
",
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(connection)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fenced(&self, scope: &str) -> Result<bool> {
|
||||||
|
if legacy_owner::fenced(&self.root, scope)? {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
if !self.root.join(".owner.sqlite").try_exists()? {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
Ok(self.catalog(false)?.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM storage_deletions WHERE scope=?1)",
|
||||||
|
[scope],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reserve the entire restore before creating staging. An unfinished copy
|
||||||
|
/// cannot be adopted by another source/request, even if its files look valid.
|
||||||
|
pub fn reserve_restore(
|
||||||
|
&self,
|
||||||
|
scope: &str,
|
||||||
|
evidence: &str,
|
||||||
|
temporary: &Path,
|
||||||
|
destination: &Path,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut catalog = self.catalog(true)?;
|
||||||
|
let tx = catalog.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
|
let previous: Option<(String, bool)> = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT evidence,published FROM storage_restores WHERE scope=?1",
|
||||||
|
[scope],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
match previous {
|
||||||
|
Some((saved, _)) if saved == evidence => {}
|
||||||
|
Some((_, false)) => {
|
||||||
|
return Err(FeatureStorageError::Identity(
|
||||||
|
"another restore is unfinished; reconcile its exact source/request".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some((_, true)) if destination.exists() || temporary.exists() => {
|
||||||
|
return Err(FeatureStorageError::Identity(
|
||||||
|
"another restore owns the existing destination/staging".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
None if destination.exists() => {
|
||||||
|
return Err(FeatureStorageError::DestinationExists(
|
||||||
|
destination.display().to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
None if temporary.exists() => {
|
||||||
|
return Err(FeatureStorageError::Identity(
|
||||||
|
"unowned restore staging requires explicit reconciliation".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
tx.execute("INSERT INTO storage_restores(scope,evidence,published) VALUES(?1,?2,0) ON CONFLICT(scope) DO UPDATE SET evidence=excluded.evidence,published=0", params![scope,evidence])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn publish_restore(&self, scope: &str, evidence: &str) -> Result<()> {
|
||||||
|
let changed = self.catalog(false)?.execute(
|
||||||
|
"UPDATE storage_restores SET published=1 WHERE scope=?1 AND evidence=?2",
|
||||||
|
params![scope, evidence],
|
||||||
|
)?;
|
||||||
|
if changed != 1 {
|
||||||
|
return Err(FeatureStorageError::Identity(
|
||||||
|
"restore request changed".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fenced_for_restore(&self, scope: &str) -> Result<bool> {
|
||||||
|
if legacy_owner::fenced_for_restore(&self.root, scope)? {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
if !self.root.join(".owner.sqlite").try_exists()? {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
Ok(self.catalog(false)?.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM storage_deletions WHERE scope=?1)",
|
||||||
|
[scope],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete(&self, scope: &str) -> Result<()> {
|
||||||
|
let directory = self.root.join(".lifecycle");
|
||||||
|
durable_create_directory(&directory, &mut sync_directory)?;
|
||||||
|
let fence = OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(false)
|
||||||
|
.open(directory.join(format!("{scope}.deleted")))?;
|
||||||
|
fence.sync_all()?;
|
||||||
|
sync_directory(&directory)?;
|
||||||
|
self.catalog(true)?.execute(
|
||||||
|
"INSERT OR IGNORE INTO storage_deletions(scope) VALUES(?1)",
|
||||||
|
[scope],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lookup(&self, scope: &str, feature: &str) -> Result<Option<Binding>> {
|
||||||
|
if self.fenced(scope)? {
|
||||||
|
return Err(FeatureStorageError::ScopeMaintenance(scope.into()));
|
||||||
|
}
|
||||||
|
if !self.root.join(".owner.sqlite").try_exists()? {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let catalog = self.catalog(false)?;
|
||||||
|
Ok(catalog.query_row("SELECT physical,device,inode,request FROM storage_requests WHERE scope=?1 AND feature=?2 AND completed=1 AND current=1", params![scope,feature], |r| Ok(Binding {
|
||||||
|
identity: DatabaseIdentity { scope: scope.into(), feature: feature.into(), physical: r.get(0)? },
|
||||||
|
device: r.get(1)?, inode: r.get(2)?, request: r.get(3)?,
|
||||||
|
})).optional()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called under exclusive lifecycle lock. The reservation precedes identity
|
||||||
|
/// persistence and survives response loss; exact retries reconcile the same
|
||||||
|
/// inode and UUID rather than manufacture past provisioning permission.
|
||||||
|
pub fn reserve(
|
||||||
|
&self,
|
||||||
|
scope: &str,
|
||||||
|
feature: &str,
|
||||||
|
path: &Path,
|
||||||
|
request: &str,
|
||||||
|
kind: &str,
|
||||||
|
evidence: &str,
|
||||||
|
) -> Result<Binding> {
|
||||||
|
if request.trim().is_empty() || evidence.trim().is_empty() {
|
||||||
|
return Err(FeatureStorageError::Identity(
|
||||||
|
"request and Host authority evidence are required".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let fenced = if kind == "restore" {
|
||||||
|
self.fenced_for_restore(scope)?
|
||||||
|
} else {
|
||||||
|
self.fenced(scope)?
|
||||||
|
};
|
||||||
|
if fenced {
|
||||||
|
return Err(FeatureStorageError::ScopeMaintenance(scope.into()));
|
||||||
|
}
|
||||||
|
if kind != "provision" && !path.is_file() {
|
||||||
|
return Err(FeatureStorageError::Identity(
|
||||||
|
"expected existing database is missing".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut catalog = self.catalog(true)?;
|
||||||
|
let tx = catalog.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
|
let saved: Option<(String,String,String,u64,u64,String,String,bool)> = tx.query_row(
|
||||||
|
"SELECT scope,feature,physical,device,inode,kind,evidence,current FROM storage_requests WHERE request=?1", [request],
|
||||||
|
|r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?,r.get(7)?))).optional()?;
|
||||||
|
if let Some((s, f, physical, device, inode, k, e, current)) = saved {
|
||||||
|
if s != scope || f != feature || k != kind || e != evidence || !current {
|
||||||
|
return Err(FeatureStorageError::Identity(
|
||||||
|
"request identity was reused for a different target or authority".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let binding = Binding {
|
||||||
|
identity: DatabaseIdentity {
|
||||||
|
scope: s,
|
||||||
|
feature: f,
|
||||||
|
physical,
|
||||||
|
},
|
||||||
|
device,
|
||||||
|
inode,
|
||||||
|
request: request.into(),
|
||||||
|
};
|
||||||
|
binding.check_file(path)?;
|
||||||
|
return Ok(binding);
|
||||||
|
}
|
||||||
|
let busy: bool = tx.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM storage_requests WHERE scope=?1 AND feature=?2 AND current=1)",
|
||||||
|
params![scope, feature],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
|
if busy && kind != "restore" {
|
||||||
|
return Err(FeatureStorageError::Identity(
|
||||||
|
"target already has an ownership request; reconcile that request".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if kind == "provision" {
|
||||||
|
durable_create_directory(
|
||||||
|
path.parent()
|
||||||
|
.ok_or_else(|| FeatureStorageError::Identity("missing parent".into()))?,
|
||||||
|
&mut sync_directory,
|
||||||
|
)?;
|
||||||
|
let file = OpenOptions::new().write(true).create_new(true).open(path)?;
|
||||||
|
file.sync_all()?;
|
||||||
|
sync_directory(path.parent().unwrap())?;
|
||||||
|
}
|
||||||
|
// A verified restore supersedes the active physical lifecycle, but keeps
|
||||||
|
// every old request/receipt as provenance. It cannot replay as current authority.
|
||||||
|
if kind == "restore" {
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE storage_requests SET current=0 WHERE scope=?1 AND feature=?2 AND current=1",
|
||||||
|
params![scope, feature],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
let metadata = fs::metadata(path)?;
|
||||||
|
if !metadata.is_file() {
|
||||||
|
return Err(FeatureStorageError::Identity(
|
||||||
|
"database is not a regular file".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let binding = Binding {
|
||||||
|
identity: DatabaseIdentity {
|
||||||
|
scope: scope.into(),
|
||||||
|
feature: feature.into(),
|
||||||
|
physical: if kind == "adopt" {
|
||||||
|
legacy_owner::identity(path, scope, feature)?
|
||||||
|
.unwrap_or_else(|| Uuid::now_v7().to_string())
|
||||||
|
} else {
|
||||||
|
Uuid::now_v7().to_string()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
device: metadata.dev(),
|
||||||
|
inode: metadata.ino(),
|
||||||
|
request: request.into(),
|
||||||
|
};
|
||||||
|
tx.execute("INSERT INTO storage_requests(request,scope,feature,physical,device,inode,kind,evidence) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",params![request,scope,feature,binding.identity.physical,binding.device,binding.inode,kind,evidence])?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot bytes are not authority. Match their saved identity to this
|
||||||
|
/// explicitly supplied source owner's completed provenance, including an old
|
||||||
|
/// lifecycle superseded by a prior restore. The caller holds its lifecycle lock.
|
||||||
|
pub fn verify_snapshot(
|
||||||
|
&self,
|
||||||
|
scope: &str,
|
||||||
|
feature: &str,
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<(String, String)> {
|
||||||
|
if self.fenced_for_restore(scope)? {
|
||||||
|
return Err(FeatureStorageError::ScopeMaintenance(scope.into()));
|
||||||
|
}
|
||||||
|
if !self.root.join(".owner.sqlite").is_file() {
|
||||||
|
return Err(FeatureStorageError::Identity(
|
||||||
|
"snapshot source owner is missing".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
|
||||||
|
let (s, f, physical, request): (String, String, String, String) = connection.query_row(
|
||||||
|
"SELECT scope,feature,physical,request FROM __yoi_storage_identity WHERE singleton=1",
|
||||||
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
||||||
|
)?;
|
||||||
|
Uuid::parse_str(&physical).map_err(|_| {
|
||||||
|
FeatureStorageError::Identity("invalid snapshot physical identity".into())
|
||||||
|
})?;
|
||||||
|
let known: bool = self.catalog(false)?.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM storage_requests WHERE scope=?1 AND feature=?2 AND physical=?3 AND request=?4 AND completed=1)",
|
||||||
|
params![scope, feature, physical, request], |r| r.get(0))?;
|
||||||
|
if s != scope || f != feature || !known {
|
||||||
|
return Err(FeatureStorageError::Identity(
|
||||||
|
"snapshot does not belong to the selected source owner".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok((physical, request))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request_kind(&self, request: &str) -> Result<Option<String>> {
|
||||||
|
if !self.root.join(".owner.sqlite").try_exists()? {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(self
|
||||||
|
.catalog(false)?
|
||||||
|
.query_row(
|
||||||
|
"SELECT kind FROM storage_requests WHERE request=?1 AND current=1",
|
||||||
|
[request],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.optional()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn complete(&self, binding: &Binding) -> Result<()> {
|
||||||
|
let changed = self.catalog(false)?.execute("UPDATE storage_requests SET completed=1 WHERE request=?1 AND scope=?2 AND feature=?3 AND physical=?4 AND device=?5 AND inode=?6 AND current=1",params![binding.request,binding.identity.scope,binding.identity.feature,binding.identity.physical,binding.device,binding.inode])?;
|
||||||
|
if changed != 1 {
|
||||||
|
return Err(FeatureStorageError::Identity(
|
||||||
|
"ownership reservation changed".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify(&self, binding: &Binding, path: &Path, connection: &Connection) -> Result<()> {
|
||||||
|
let current = self
|
||||||
|
.lookup(&binding.identity.scope, &binding.identity.feature)?
|
||||||
|
.ok_or_else(|| FeatureStorageError::Identity("no completed ownership record".into()))?;
|
||||||
|
if current.identity != binding.identity
|
||||||
|
|| current.device != binding.device
|
||||||
|
|| current.inode != binding.inode
|
||||||
|
|| current.request != binding.request
|
||||||
|
{
|
||||||
|
return Err(FeatureStorageError::Identity(
|
||||||
|
"physical ownership changed".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
binding.check_file(path)?;
|
||||||
|
binding.check_database(connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Binding {
|
||||||
|
pub fn unknown(&self, error: FeatureStorageError) -> FeatureStorageError {
|
||||||
|
FeatureStorageError::OutcomeUnknown {
|
||||||
|
identity: self.identity.clone(),
|
||||||
|
detail: error.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn check_file(&self, path: &Path) -> Result<()> {
|
||||||
|
let metadata = fs::metadata(path)?;
|
||||||
|
if metadata.dev() != self.device || metadata.ino() != self.inode {
|
||||||
|
return Err(FeatureStorageError::Identity(
|
||||||
|
"database path was replaced; explicit authorized adoption is required".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_database(&self, connection: &Connection) -> Result<()> {
|
||||||
|
Uuid::parse_str(&self.identity.physical)
|
||||||
|
.map_err(|_| FeatureStorageError::Identity("invalid physical identity".into()))?;
|
||||||
|
let found: Option<(String, String, String, String)> = connection
|
||||||
|
.query_row(
|
||||||
|
&format!(
|
||||||
|
"SELECT scope,feature,physical,request FROM {IDENTITY_TABLE} WHERE singleton=1"
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
if found
|
||||||
|
!= Some((
|
||||||
|
self.identity.scope.clone(),
|
||||||
|
self.identity.feature.clone(),
|
||||||
|
self.identity.physical.clone(),
|
||||||
|
self.request.clone(),
|
||||||
|
))
|
||||||
|
{
|
||||||
|
return Err(FeatureStorageError::Identity(
|
||||||
|
"database identity/receipt is missing or mismatched".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore is a new physical lifecycle. Retain old provenance but never
|
||||||
|
/// return its identity as authority over the restored inode.
|
||||||
|
pub fn restore_identity(&self, connection: &mut Connection) -> Result<()> {
|
||||||
|
let tx = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
|
let exists: bool = tx.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name='__yoi_storage_identity')",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
|
if exists {
|
||||||
|
if self.check_database(&tx).is_ok() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let (scope, feature): (String, String) = tx.query_row(
|
||||||
|
"SELECT scope,feature FROM __yoi_storage_identity WHERE singleton=1",
|
||||||
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)?;
|
||||||
|
if scope != self.identity.scope || feature != self.identity.feature {
|
||||||
|
return Err(FeatureStorageError::Identity(
|
||||||
|
"snapshot belongs to another scope".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
tx.execute_batch("CREATE TABLE IF NOT EXISTS __yoi_storage_origins (scope TEXT NOT NULL, feature TEXT NOT NULL, physical TEXT NOT NULL, request TEXT NOT NULL, PRIMARY KEY(physical,request)); INSERT OR IGNORE INTO __yoi_storage_origins SELECT scope,feature,physical,request FROM __yoi_storage_identity; DELETE FROM __yoi_storage_identity;")?;
|
||||||
|
}
|
||||||
|
self.persist(&tx)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn persist(&self, tx: &Transaction<'_>) -> Result<()> {
|
||||||
|
tx.execute_batch(&format!(
|
||||||
|
"CREATE TABLE IF NOT EXISTS {IDENTITY_TABLE} (
|
||||||
|
singleton INTEGER PRIMARY KEY CHECK(singleton=1), scope TEXT NOT NULL,
|
||||||
|
feature TEXT NOT NULL, physical TEXT NOT NULL, request TEXT NOT NULL);"
|
||||||
|
))?;
|
||||||
|
let count: i64 =
|
||||||
|
tx.query_row(&format!("SELECT COUNT(*) FROM {IDENTITY_TABLE}"), [], |r| {
|
||||||
|
r.get(0)
|
||||||
|
})?;
|
||||||
|
if count == 0 {
|
||||||
|
tx.execute(
|
||||||
|
&format!("INSERT INTO {IDENTITY_TABLE} VALUES(1,?1,?2,?3,?4)"),
|
||||||
|
params![
|
||||||
|
self.identity.scope,
|
||||||
|
self.identity.feature,
|
||||||
|
self.identity.physical,
|
||||||
|
self.request
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
self.check_database(tx)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -191,7 +191,6 @@ pub enum JobState {
|
|||||||
Pending,
|
Pending,
|
||||||
Completed,
|
Completed,
|
||||||
Failed,
|
Failed,
|
||||||
Unknown,
|
|
||||||
Cancelled,
|
Cancelled,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +202,6 @@ pub enum JobAttemptState {
|
|||||||
Dispatched,
|
Dispatched,
|
||||||
Completed,
|
Completed,
|
||||||
Failed,
|
Failed,
|
||||||
Unknown,
|
|
||||||
Cancelled,
|
Cancelled,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ fn shared_lifecycle_enums_round_trip_all_host_states() {
|
|||||||
(JobState::Pending, "pending"),
|
(JobState::Pending, "pending"),
|
||||||
(JobState::Completed, "completed"),
|
(JobState::Completed, "completed"),
|
||||||
(JobState::Failed, "failed"),
|
(JobState::Failed, "failed"),
|
||||||
(JobState::Unknown, "unknown"),
|
|
||||||
(JobState::Cancelled, "cancelled"),
|
(JobState::Cancelled, "cancelled"),
|
||||||
] {
|
] {
|
||||||
assert_eq!(serde_json::to_value(state).unwrap(), json!(name));
|
assert_eq!(serde_json::to_value(state).unwrap(), json!(name));
|
||||||
@@ -54,7 +53,6 @@ fn shared_lifecycle_enums_round_trip_all_host_states() {
|
|||||||
(JobAttemptState::Dispatched, "dispatched"),
|
(JobAttemptState::Dispatched, "dispatched"),
|
||||||
(JobAttemptState::Completed, "completed"),
|
(JobAttemptState::Completed, "completed"),
|
||||||
(JobAttemptState::Failed, "failed"),
|
(JobAttemptState::Failed, "failed"),
|
||||||
(JobAttemptState::Unknown, "unknown"),
|
|
||||||
(JobAttemptState::Cancelled, "cancelled"),
|
(JobAttemptState::Cancelled, "cancelled"),
|
||||||
] {
|
] {
|
||||||
assert_eq!(serde_json::to_value(state).unwrap(), json!(name));
|
assert_eq!(serde_json::to_value(state).unwrap(), json!(name));
|
||||||
@@ -63,6 +61,9 @@ fn shared_lifecycle_enums_round_trip_all_host_states() {
|
|||||||
state
|
state
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Previous uncertain lifecycle values belong only to versioned migration input.
|
||||||
|
assert!(serde_json::from_value::<JobState>(json!("unknown")).is_err());
|
||||||
|
assert!(serde_json::from_value::<JobAttemptState>(json!("unknown")).is_err());
|
||||||
assert!(serde_json::from_value::<JobState>(json!("running")).is_err());
|
assert!(serde_json::from_value::<JobState>(json!("running")).is_err());
|
||||||
assert!(serde_json::from_value::<JobAttemptState>(json!("idle")).is_err());
|
assert!(serde_json::from_value::<JobAttemptState>(json!("idle")).is_err());
|
||||||
}
|
}
|
||||||
@@ -111,13 +112,6 @@ fn outcome_is_a_neutral_read_snapshot_with_optional_result_and_failure() {
|
|||||||
Some("execution"),
|
Some("execution"),
|
||||||
Some("known failure"),
|
Some("known failure"),
|
||||||
),
|
),
|
||||||
(
|
|
||||||
JobState::Unknown,
|
|
||||||
JobAttemptState::Unknown,
|
|
||||||
None,
|
|
||||||
Some("interrupted"),
|
|
||||||
Some("requires reconciliation"),
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
JobState::Cancelled,
|
JobState::Cancelled,
|
||||||
JobAttemptState::Cancelled,
|
JobAttemptState::Cancelled,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
pub mod identity;
|
pub mod identity;
|
||||||
pub mod invocation;
|
pub mod invocation;
|
||||||
|
pub mod session_failure;
|
||||||
|
pub mod startup_failure;
|
||||||
#[cfg(feature = "stream")]
|
#[cfg(feature = "stream")]
|
||||||
pub mod stream;
|
pub mod stream;
|
||||||
pub mod subscription;
|
pub mod subscription;
|
||||||
|
|||||||
@@ -0,0 +1,662 @@
|
|||||||
|
//! Typed session-registration failures, not lifecycle or retry authority.
|
||||||
|
//!
|
||||||
|
//! Causes contain only fixed enum values. Stored or transferred attachments are
|
||||||
|
//! observations, never permission to disclose: Browser/Tool projections require
|
||||||
|
//! the caller's current visibility decision and fail closed without it.
|
||||||
|
//!
|
||||||
|
//! # Projection boundary
|
||||||
|
//!
|
||||||
|
//! The same closed cause is mapped explicitly into the shared error-contract codec.
|
||||||
|
//! The transport boundary does not convert an internal Session denial into an HTTP
|
||||||
|
//! verdict about a Browser operator. Public consumers must resolve current viewer
|
||||||
|
//! authority before projecting; a saved attachment cannot carry that authority.
|
||||||
|
//!
|
||||||
|
//! | Contract fact | Internal transfer / storage | Authorized Browser / Tool |
|
||||||
|
//! | --- | --- | --- |
|
||||||
|
//! | Domain, reason, principal, required operation | Typed cause | Same cause, never inferred from prose |
|
||||||
|
//! | Explanation | Fixed local template | Fixed local template, not received text |
|
||||||
|
//! | Operation context | Allowlisted operation/stage pairs | Same allowlist, no operation references |
|
||||||
|
//! | Subject, Session, Worker IDs; credentials; paths; raw HTTP body | Not representable in this cause | Not representable in this cause |
|
||||||
|
//! | Resource ownership, persistence, secondary cleanup | Separate startup result facts | Not disclosure/retry authority |
|
||||||
|
//!
|
||||||
|
//! A denied current visibility decision fails projection instead of substituting
|
||||||
|
//! an invented cause. Unknown codec versions and corrupt attachments stay distinct
|
||||||
|
//! from known causes. Missing legacy attachments do not certify legacy diagnostics.
|
||||||
|
//!
|
||||||
|
//! # Failure investigation
|
||||||
|
//!
|
||||||
|
//! A known denial identifies the operation and principal class, not an instruction
|
||||||
|
//! to grant rights. Check the current Subject execution policy and the authenticated
|
||||||
|
//! Runtime/Worker binding at the Session-record authorization boundary. Compare
|
||||||
|
//! existing Session attribution and saved Worker metadata before deciding whether
|
||||||
|
//! any state repair is needed. Operator authority and Subject execution policy are
|
||||||
|
//! different decisions; neither can be derived from an internal HTTP status alone.
|
||||||
|
//!
|
||||||
|
//! Transport and unknown outcomes do not establish non-execution. Reconcile the
|
||||||
|
//! existing Session attribution, Worker aggregate and Runtime operation result;
|
||||||
|
//! a lost response may coexist with a committed write. A conflict invalidates the
|
||||||
|
//! old metadata expectation. Do not retry the startup, create another Worker, or
|
||||||
|
//! enlarge permissions solely because a failure was displayed. Inspect primary,
|
||||||
|
//! secondary, persistence and resource facts independently: rollback failure must
|
||||||
|
//! not replace the refusal, and persistence uncertainty must not be called saved.
|
||||||
|
//!
|
||||||
|
//! The real authorization/factory fixture is owned by workspace-server's normal
|
||||||
|
//! `session_registration_connected` tests. Its signed Session callback revokes the
|
||||||
|
//! real policy at a synchronization point; a real retention pin forces independent
|
||||||
|
//! cleanup failure. Leaf transport/projection tests do not substitute for that
|
||||||
|
//! product connection, and the fixture claims neither a process crash nor restore.
|
||||||
|
|
||||||
|
use error_contract::{
|
||||||
|
Browser, Category, ContractError, Disclosure, FailureCause, FailureFacts, FailureIdentity,
|
||||||
|
InternalTransfer, OperationContext, ProjectFor, Storage, Tool, WireDetails, project_failure,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::startup_failure::StartupFailureAttachment;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub enum SessionRegistrationReason {
|
||||||
|
PermissionDenied,
|
||||||
|
AttributionMissing,
|
||||||
|
BindingMismatch,
|
||||||
|
Transport,
|
||||||
|
OutcomeUnknown,
|
||||||
|
InvalidResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub enum SessionPrincipal {
|
||||||
|
Operator,
|
||||||
|
Subject,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub enum SessionOperation {
|
||||||
|
#[serde(rename = "session.record")]
|
||||||
|
Record,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No account/session identifiers, paths, or free-form diagnostics are retained.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub struct SessionRegistrationCause {
|
||||||
|
pub reason: SessionRegistrationReason,
|
||||||
|
pub principal: SessionPrincipal,
|
||||||
|
pub required_operation: SessionOperation,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionRegistrationCause {
|
||||||
|
pub const fn denied(principal: SessionPrincipal) -> Self {
|
||||||
|
Self::new(SessionRegistrationReason::PermissionDenied, principal)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn attribution_missing() -> Self {
|
||||||
|
Self::new(
|
||||||
|
SessionRegistrationReason::AttributionMissing,
|
||||||
|
SessionPrincipal::Subject,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn binding_mismatch() -> Self {
|
||||||
|
Self::new(
|
||||||
|
SessionRegistrationReason::BindingMismatch,
|
||||||
|
SessionPrincipal::Subject,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn transport() -> Self {
|
||||||
|
Self::new(
|
||||||
|
SessionRegistrationReason::Transport,
|
||||||
|
SessionPrincipal::Unknown,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn outcome_unknown() -> Self {
|
||||||
|
Self::new(
|
||||||
|
SessionRegistrationReason::OutcomeUnknown,
|
||||||
|
SessionPrincipal::Unknown,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn invalid_response() -> Self {
|
||||||
|
Self::new(
|
||||||
|
SessionRegistrationReason::InvalidResponse,
|
||||||
|
SessionPrincipal::Unknown,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn new(reason: SessionRegistrationReason, principal: SessionPrincipal) -> Self {
|
||||||
|
Self {
|
||||||
|
reason,
|
||||||
|
principal,
|
||||||
|
required_operation: SessionOperation::Record,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn explanation(&self) -> &'static str {
|
||||||
|
match self.reason {
|
||||||
|
SessionRegistrationReason::PermissionDenied => {
|
||||||
|
"Session registration permission denied."
|
||||||
|
}
|
||||||
|
SessionRegistrationReason::AttributionMissing => {
|
||||||
|
"Session registration requires Subject attribution."
|
||||||
|
}
|
||||||
|
SessionRegistrationReason::BindingMismatch => {
|
||||||
|
"Session registration Subject binding does not match."
|
||||||
|
}
|
||||||
|
SessionRegistrationReason::Transport => "Session registration transport failed.",
|
||||||
|
SessionRegistrationReason::OutcomeUnknown => "Session registration outcome is unknown.",
|
||||||
|
SessionRegistrationReason::InvalidResponse => {
|
||||||
|
"Session registration returned an invalid response."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disclosure(&self, facts: &FailureFacts<Self>) -> Disclosure<Self> {
|
||||||
|
let mut disclosure = Disclosure::new(*self, self.explanation());
|
||||||
|
for context in &facts.context().operations {
|
||||||
|
if matches!(
|
||||||
|
(context.operation.as_str(), context.stage.as_str()),
|
||||||
|
("session.record", "authorization" | "registration")
|
||||||
|
| ("worker_startup", "binding" | "runtime")
|
||||||
|
) {
|
||||||
|
let mut context = context.clone();
|
||||||
|
context.operation_ref = None;
|
||||||
|
disclosure.context.operations.push(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
disclosure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FailureCause for SessionRegistrationCause {
|
||||||
|
fn identity(&self) -> FailureIdentity {
|
||||||
|
let (reason, category) = match self.reason {
|
||||||
|
SessionRegistrationReason::PermissionDenied => {
|
||||||
|
("permission_denied", Category::Authorization)
|
||||||
|
}
|
||||||
|
SessionRegistrationReason::AttributionMissing => {
|
||||||
|
("attribution_missing", Category::Authorization)
|
||||||
|
}
|
||||||
|
SessionRegistrationReason::BindingMismatch => {
|
||||||
|
("binding_mismatch", Category::Authorization)
|
||||||
|
}
|
||||||
|
SessionRegistrationReason::Transport => ("transport", Category::Dependency),
|
||||||
|
SessionRegistrationReason::OutcomeUnknown => ("outcome_unknown", Category::Unknown),
|
||||||
|
SessionRegistrationReason::InvalidResponse => ("invalid_response", Category::Internal),
|
||||||
|
};
|
||||||
|
FailureIdentity {
|
||||||
|
domain: "session_registration",
|
||||||
|
reason,
|
||||||
|
category,
|
||||||
|
details_version: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(&self) -> Result<(), ContractError> {
|
||||||
|
// All fields are closed enums; arbitrary diagnostic data cannot enter.
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WireDetails for SessionRegistrationCause {
|
||||||
|
fn supports(domain: &str, reason: &str, version: u16) -> bool {
|
||||||
|
domain == "session_registration"
|
||||||
|
&& version == 1
|
||||||
|
&& matches!(
|
||||||
|
reason,
|
||||||
|
"permission_denied"
|
||||||
|
| "attribution_missing"
|
||||||
|
| "binding_mismatch"
|
||||||
|
| "transport"
|
||||||
|
| "outcome_unknown"
|
||||||
|
| "invalid_response"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SessionRegistrationCause {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(self.explanation())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for SessionRegistrationCause {}
|
||||||
|
|
||||||
|
/// A current caller-supplied visibility decision, not a serialized capability.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct SessionFailureVisibility {
|
||||||
|
pub can_view_cause: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProjectFor<InternalTransfer> for SessionRegistrationCause {
|
||||||
|
type Authorization = ();
|
||||||
|
type Details = Self;
|
||||||
|
|
||||||
|
fn disclose(
|
||||||
|
&self,
|
||||||
|
facts: &FailureFacts<Self>,
|
||||||
|
_: &(),
|
||||||
|
) -> Result<Disclosure<Self>, ContractError> {
|
||||||
|
Ok(self.disclosure(facts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProjectFor<Storage> for SessionRegistrationCause {
|
||||||
|
type Authorization = ();
|
||||||
|
type Details = Self;
|
||||||
|
|
||||||
|
fn disclose(
|
||||||
|
&self,
|
||||||
|
facts: &FailureFacts<Self>,
|
||||||
|
_: &(),
|
||||||
|
) -> Result<Disclosure<Self>, ContractError> {
|
||||||
|
Ok(self.disclosure(facts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProjectFor<Browser> for SessionRegistrationCause {
|
||||||
|
type Authorization = SessionFailureVisibility;
|
||||||
|
type Details = Self;
|
||||||
|
|
||||||
|
fn disclose(
|
||||||
|
&self,
|
||||||
|
facts: &FailureFacts<Self>,
|
||||||
|
authorization: &SessionFailureVisibility,
|
||||||
|
) -> Result<Disclosure<Self>, ContractError> {
|
||||||
|
if !authorization.can_view_cause {
|
||||||
|
// Redaction must not substitute a different reason/category identity.
|
||||||
|
return Err(ContractError::InvalidDetails);
|
||||||
|
}
|
||||||
|
Ok(self.disclosure(facts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProjectFor<Tool> for SessionRegistrationCause {
|
||||||
|
type Authorization = SessionFailureVisibility;
|
||||||
|
type Details = Self;
|
||||||
|
|
||||||
|
fn disclose(
|
||||||
|
&self,
|
||||||
|
facts: &FailureFacts<Self>,
|
||||||
|
authorization: &SessionFailureVisibility,
|
||||||
|
) -> Result<Disclosure<Self>, ContractError> {
|
||||||
|
if !authorization.can_view_cause {
|
||||||
|
return Err(ContractError::InvalidDetails);
|
||||||
|
}
|
||||||
|
Ok(self.disclosure(facts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attach fixed registration/binding observations using internal-transfer policy.
|
||||||
|
/// This does not assert registration rollback, retry safety, or startup ownership.
|
||||||
|
pub fn session_registration_attachment(
|
||||||
|
cause: SessionRegistrationCause,
|
||||||
|
) -> Result<StartupFailureAttachment, ContractError> {
|
||||||
|
let mut facts = FailureFacts::new(cause)?;
|
||||||
|
facts.with_context(OperationContext::new("session.record", "registration")?)?;
|
||||||
|
facts.with_context(OperationContext::new("worker_startup", "binding")?)?;
|
||||||
|
let projected = project_failure::<InternalTransfer, _>(&facts, &())?;
|
||||||
|
StartupFailureAttachment::from_projected(&projected)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::startup_failure::StoredStartupFailureAttachment;
|
||||||
|
use error_contract::{
|
||||||
|
Audience, Budget, CausalLink, CorrelationRef, DecodeFailure, ProjectedFailure,
|
||||||
|
decode_failure, encode_projected_failure,
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn budget() -> Budget {
|
||||||
|
Budget::new(
|
||||||
|
error_contract::MAX_FAILURE_BYTES,
|
||||||
|
error_contract::MAX_FAILURE_BYTES,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cases() -> [(
|
||||||
|
SessionRegistrationCause,
|
||||||
|
&'static str,
|
||||||
|
Category,
|
||||||
|
&'static str,
|
||||||
|
); 6] {
|
||||||
|
[
|
||||||
|
(
|
||||||
|
SessionRegistrationCause::denied(SessionPrincipal::Operator),
|
||||||
|
"permission_denied",
|
||||||
|
Category::Authorization,
|
||||||
|
"Session registration permission denied.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SessionRegistrationCause::attribution_missing(),
|
||||||
|
"attribution_missing",
|
||||||
|
Category::Authorization,
|
||||||
|
"Session registration requires Subject attribution.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SessionRegistrationCause::binding_mismatch(),
|
||||||
|
"binding_mismatch",
|
||||||
|
Category::Authorization,
|
||||||
|
"Session registration Subject binding does not match.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SessionRegistrationCause::transport(),
|
||||||
|
"transport",
|
||||||
|
Category::Dependency,
|
||||||
|
"Session registration transport failed.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SessionRegistrationCause::outcome_unknown(),
|
||||||
|
"outcome_unknown",
|
||||||
|
Category::Unknown,
|
||||||
|
"Session registration outcome is unknown.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SessionRegistrationCause::invalid_response(),
|
||||||
|
"invalid_response",
|
||||||
|
Category::Internal,
|
||||||
|
"Session registration returned an invalid response.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constructors_serde_identity_and_fixed_explanations() {
|
||||||
|
for (cause, reason, category, explanation) in cases() {
|
||||||
|
let principal = match cause.reason {
|
||||||
|
SessionRegistrationReason::PermissionDenied => "operator",
|
||||||
|
SessionRegistrationReason::AttributionMissing
|
||||||
|
| SessionRegistrationReason::BindingMismatch => "subject",
|
||||||
|
_ => "unknown",
|
||||||
|
};
|
||||||
|
let value = serde_json::to_value(cause).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
value,
|
||||||
|
json!({
|
||||||
|
"reason": reason,
|
||||||
|
"principal": principal,
|
||||||
|
"required_operation": "session.record"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<SessionRegistrationCause>(value).unwrap(),
|
||||||
|
cause
|
||||||
|
);
|
||||||
|
assert_eq!(cause.required_operation, SessionOperation::Record);
|
||||||
|
assert_eq!(
|
||||||
|
cause.identity(),
|
||||||
|
FailureIdentity {
|
||||||
|
domain: "session_registration",
|
||||||
|
reason,
|
||||||
|
category,
|
||||||
|
details_version: 1,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(SessionRegistrationCause::supports(
|
||||||
|
"session_registration",
|
||||||
|
reason,
|
||||||
|
1
|
||||||
|
));
|
||||||
|
assert_eq!(cause.to_string(), explanation);
|
||||||
|
assert!(std::error::Error::source(&cause).is_none());
|
||||||
|
let facts = FailureFacts::new(cause).unwrap();
|
||||||
|
let projected = project_failure::<InternalTransfer, _>(&facts, &()).unwrap();
|
||||||
|
assert_eq!(projected.explanation(), explanation);
|
||||||
|
let encoded = encode_projected_failure(&projected, budget()).unwrap();
|
||||||
|
match decode_failure::<SessionRegistrationCause>(encoded.as_bytes(), budget()) {
|
||||||
|
DecodeFailure::Known(received) => assert_eq!(received.facts().cause(), &cause),
|
||||||
|
_ => panic!("expected known session-registration failure"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (principal, wire) in [
|
||||||
|
(SessionPrincipal::Operator, "operator"),
|
||||||
|
(SessionPrincipal::Subject, "subject"),
|
||||||
|
(SessionPrincipal::Unknown, "unknown"),
|
||||||
|
] {
|
||||||
|
let cause = SessionRegistrationCause::denied(principal);
|
||||||
|
assert_eq!(cause.principal, principal);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(cause).unwrap()["principal"],
|
||||||
|
json!(wire)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(!SessionRegistrationCause::supports(
|
||||||
|
"worker_startup",
|
||||||
|
"transport",
|
||||||
|
1
|
||||||
|
));
|
||||||
|
assert!(!SessionRegistrationCause::supports(
|
||||||
|
"session_registration",
|
||||||
|
"future",
|
||||||
|
1
|
||||||
|
));
|
||||||
|
assert!(!SessionRegistrationCause::supports(
|
||||||
|
"session_registration",
|
||||||
|
"transport",
|
||||||
|
2
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_rejects_untyped_diagnostic_fields_and_unknown_enum_values() {
|
||||||
|
let valid = serde_json::to_value(SessionRegistrationCause::attribution_missing()).unwrap();
|
||||||
|
for (field, value) in [
|
||||||
|
("reason", json!("raw_error")),
|
||||||
|
("principal", json!("account-secret")),
|
||||||
|
("required_operation", json!("session.delete")),
|
||||||
|
("diagnostic", json!("/private/path TEST_ONLY_SECRET")),
|
||||||
|
] {
|
||||||
|
let mut changed = valid.clone();
|
||||||
|
changed[field] = value;
|
||||||
|
assert!(serde_json::from_value::<SessionRegistrationCause>(changed).is_err());
|
||||||
|
}
|
||||||
|
for field in ["reason", "principal", "required_operation"] {
|
||||||
|
let mut changed = valid.clone();
|
||||||
|
changed.as_object_mut().unwrap().remove(field);
|
||||||
|
assert!(serde_json::from_value::<SessionRegistrationCause>(changed).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codec_distinguishes_unsupported_and_corrupt_and_replaces_received_prose() {
|
||||||
|
let facts = FailureFacts::new(SessionRegistrationCause::transport()).unwrap();
|
||||||
|
let projected = project_failure::<InternalTransfer, _>(&facts, &()).unwrap();
|
||||||
|
let encoded = encode_projected_failure(&projected, budget()).unwrap();
|
||||||
|
let original: serde_json::Value = serde_json::from_slice(encoded.as_bytes()).unwrap();
|
||||||
|
for (field, value) in [
|
||||||
|
("domain", json!("future_domain")),
|
||||||
|
("reason", json!("future_reason")),
|
||||||
|
("details_version", json!(2)),
|
||||||
|
] {
|
||||||
|
let mut changed = original.clone();
|
||||||
|
changed[field] = value;
|
||||||
|
assert!(matches!(
|
||||||
|
decode_failure::<SessionRegistrationCause>(
|
||||||
|
&serde_json::to_vec(&changed).unwrap(),
|
||||||
|
budget()
|
||||||
|
),
|
||||||
|
DecodeFailure::Unsupported(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for (field, value) in [
|
||||||
|
("reason", json!("permission_denied")),
|
||||||
|
("category", json!("authorization")),
|
||||||
|
] {
|
||||||
|
let mut changed = original.clone();
|
||||||
|
changed[field] = value;
|
||||||
|
assert!(matches!(
|
||||||
|
decode_failure::<SessionRegistrationCause>(
|
||||||
|
&serde_json::to_vec(&changed).unwrap(),
|
||||||
|
budget()
|
||||||
|
),
|
||||||
|
DecodeFailure::Corrupt(ContractError::InvalidDetails)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut changed = original;
|
||||||
|
changed["explanation"] = json!("TEST_ONLY_SECRET received prose");
|
||||||
|
changed["context"]["diagnostics"] = json!([
|
||||||
|
{"code": "TEST_ONLY_SECRET", "explanation": "TEST_ONLY_SECRET diagnostic"}
|
||||||
|
]);
|
||||||
|
let bytes = serde_json::to_vec(&changed).unwrap();
|
||||||
|
let DecodeFailure::Known(received) =
|
||||||
|
decode_failure::<SessionRegistrationCause>(&bytes, budget())
|
||||||
|
else {
|
||||||
|
panic!("expected known failure with untrusted supplementary fields");
|
||||||
|
};
|
||||||
|
let browser = project_failure::<Browser, _>(
|
||||||
|
received.facts(),
|
||||||
|
&SessionFailureVisibility {
|
||||||
|
can_view_cause: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
browser.explanation(),
|
||||||
|
"Session registration transport failed."
|
||||||
|
);
|
||||||
|
assert!(browser.context().diagnostics.is_empty());
|
||||||
|
let encoded = encode_projected_failure(&browser, budget()).unwrap();
|
||||||
|
assert!(
|
||||||
|
!std::str::from_utf8(encoded.as_bytes())
|
||||||
|
.unwrap()
|
||||||
|
.contains("TEST_ONLY_SECRET")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_storage_and_current_visibility_preserve_identity_without_granting_access() {
|
||||||
|
let allowed = SessionFailureVisibility {
|
||||||
|
can_view_cause: true,
|
||||||
|
};
|
||||||
|
let denied = SessionFailureVisibility::default();
|
||||||
|
for (cause, _, _, _) in cases() {
|
||||||
|
let attachment = session_registration_attachment(cause).unwrap();
|
||||||
|
match attachment.decode::<SessionRegistrationCause>() {
|
||||||
|
DecodeFailure::Known(received) => {
|
||||||
|
assert_eq!(received.facts().cause(), &cause);
|
||||||
|
assert_eq!(
|
||||||
|
received.facts().context().operations,
|
||||||
|
vec![
|
||||||
|
OperationContext::new("session.record", "registration").unwrap(),
|
||||||
|
OperationContext::new("worker_startup", "binding").unwrap(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => panic!("expected typed startup attachment"),
|
||||||
|
}
|
||||||
|
let storage = attachment
|
||||||
|
.project_for::<Storage, SessionRegistrationCause>(&())
|
||||||
|
.unwrap();
|
||||||
|
let stored = StoredStartupFailureAttachment::from_projected(&storage).unwrap();
|
||||||
|
let loaded: StoredStartupFailureAttachment =
|
||||||
|
serde_json::from_str(&serde_json::to_string(&stored).unwrap()).unwrap();
|
||||||
|
let browser = loaded
|
||||||
|
.project_for::<Browser, SessionRegistrationCause>(&allowed)
|
||||||
|
.unwrap();
|
||||||
|
let tool = loaded
|
||||||
|
.project_for::<Tool, SessionRegistrationCause>(&allowed)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(browser.details(), &cause);
|
||||||
|
assert_eq!(tool.details(), &cause);
|
||||||
|
assert_eq!(browser.identity(), cause.identity());
|
||||||
|
assert_eq!(tool.identity(), cause.identity());
|
||||||
|
assert!(matches!(
|
||||||
|
loaded.project_for::<Browser, SessionRegistrationCause>(&denied),
|
||||||
|
Err(ContractError::InvalidDetails)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
loaded.project_for::<Tool, SessionRegistrationCause>(&denied),
|
||||||
|
Err(ContractError::InvalidDetails)
|
||||||
|
));
|
||||||
|
// Even a previous public projection cannot serve as current permission.
|
||||||
|
let previously_public = StartupFailureAttachment::from_projected(&browser).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
previously_public.project_for::<Browser, SessionRegistrationCause>(&denied),
|
||||||
|
Err(ContractError::InvalidDetails)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
previously_public.project_for::<Tool, SessionRegistrationCause>(&denied),
|
||||||
|
Err(ContractError::InvalidDetails)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_redacted<A: Audience>(projected: &ProjectedFailure<A, SessionRegistrationCause>) {
|
||||||
|
assert_eq!(
|
||||||
|
projected.context().operations,
|
||||||
|
vec![
|
||||||
|
OperationContext::new("session.record", "authorization").unwrap(),
|
||||||
|
OperationContext::new("session.record", "registration").unwrap(),
|
||||||
|
OperationContext::new("worker_startup", "binding").unwrap(),
|
||||||
|
OperationContext::new("worker_startup", "runtime").unwrap(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(projected.context().correlation.is_none());
|
||||||
|
assert!(projected.context().causes.is_empty());
|
||||||
|
assert!(projected.context().diagnostics.is_empty());
|
||||||
|
let encoded = encode_projected_failure(projected, budget()).unwrap();
|
||||||
|
let wire = std::str::from_utf8(encoded.as_bytes()).unwrap();
|
||||||
|
for secret in ["TEST_ONLY_SECRET", "/private/path", "raw_stage"] {
|
||||||
|
assert!(!wire.contains(secret));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_audience_allowlists_operation_stage_pairs_and_drops_references() {
|
||||||
|
let mut facts = FailureFacts::new(SessionRegistrationCause::binding_mismatch()).unwrap();
|
||||||
|
for (operation, stage) in [
|
||||||
|
("session.record", "authorization"),
|
||||||
|
("session.record", "registration"),
|
||||||
|
("worker_startup", "binding"),
|
||||||
|
("worker_startup", "runtime"),
|
||||||
|
("session.record", "binding"),
|
||||||
|
("worker_startup", "authorization"),
|
||||||
|
("/private/path", "registration"),
|
||||||
|
("session.record", "raw_stage"),
|
||||||
|
] {
|
||||||
|
let mut context = OperationContext::new(operation, stage).unwrap();
|
||||||
|
context.operation_ref = Some("TEST_ONLY_SECRET operation reference".into());
|
||||||
|
facts.with_context(context).unwrap();
|
||||||
|
}
|
||||||
|
facts
|
||||||
|
.set_correlation(CorrelationRef::new("TEST_ONLY_SECRET correlation").unwrap())
|
||||||
|
.unwrap();
|
||||||
|
facts
|
||||||
|
.with_causal_link(CausalLink {
|
||||||
|
domain: "TEST_ONLY_SECRET domain".into(),
|
||||||
|
reason: "TEST_ONLY_SECRET reason".into(),
|
||||||
|
correlation: CorrelationRef::new("TEST_ONLY_SECRET causal reference").unwrap(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let allowed = SessionFailureVisibility {
|
||||||
|
can_view_cause: true,
|
||||||
|
};
|
||||||
|
assert_redacted(&project_failure::<InternalTransfer, _>(&facts, &()).unwrap());
|
||||||
|
assert_redacted(&project_failure::<Storage, _>(&facts, &()).unwrap());
|
||||||
|
assert_redacted(&project_failure::<Browser, _>(&facts, &allowed).unwrap());
|
||||||
|
assert_redacted(&project_failure::<Tool, _>(&facts, &allowed).unwrap());
|
||||||
|
// Projection selects output without mutating the owner's facts.
|
||||||
|
assert_eq!(facts.context().operations.len(), 8);
|
||||||
|
assert!(
|
||||||
|
facts
|
||||||
|
.context()
|
||||||
|
.operations
|
||||||
|
.iter()
|
||||||
|
.all(|context| context.operation_ref.is_some())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
//! Startup diagnostics are observations, never lifecycle, ownership, or retry authority.
|
||||||
|
//!
|
||||||
|
//! Attachments contain a domain's explicitly projected error-contract envelope. Receivers
|
||||||
|
//! decode through the existing codec, and must project again for a different audience;
|
||||||
|
//! deserializing stored bytes never creates a disclosure capability. Unknown and corrupt
|
||||||
|
//! attachments remain readable alongside metadata instead of breaking its lifecycle fields.
|
||||||
|
use error_contract::{
|
||||||
|
Audience, Budget, ContractError, DecodeFailure, ProjectedFailure, WireDetails, decode_failure,
|
||||||
|
encode_projected_failure,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// A bounded, projected envelope at the Worker/Runtime or metadata boundary.
|
||||||
|
/// No constructor accepts Display text, arbitrary JSON fields, or an unprojected error.
|
||||||
|
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub struct StartupFailureAttachment(String);
|
||||||
|
|
||||||
|
impl std::fmt::Debug for StartupFailureAttachment {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("StartupFailureAttachment")
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StartupFailureAttachment {
|
||||||
|
pub fn from_projected<A: Audience, D: WireDetails>(
|
||||||
|
failure: &ProjectedFailure<A, D>,
|
||||||
|
) -> Result<Self, ContractError> {
|
||||||
|
let encoded = encode_projected_failure(failure, Self::budget())?;
|
||||||
|
String::from_utf8(encoded.as_bytes().to_vec())
|
||||||
|
.map(Self)
|
||||||
|
.map_err(|_| ContractError::Encoding)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Missing attachments are represented by the enclosing Option, not a fabricated cause.
|
||||||
|
pub fn decode<D: WireDetails>(&self) -> DecodeFailure<D> {
|
||||||
|
decode_failure(self.0.as_bytes(), Self::budget())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Redisclosure requires a fresh domain/audience projection and current authorization.
|
||||||
|
/// Unsupported or corrupt stored bytes are never forwarded as public details.
|
||||||
|
pub fn project_for<A: Audience, D: WireDetails + error_contract::ProjectFor<A>>(
|
||||||
|
&self,
|
||||||
|
authorization: &D::Authorization,
|
||||||
|
) -> Result<ProjectedFailure<A, D::Details>, ContractError> {
|
||||||
|
match self.decode::<D>() {
|
||||||
|
DecodeFailure::Known(received) => {
|
||||||
|
error_contract::project_failure(received.facts(), authorization)
|
||||||
|
}
|
||||||
|
DecodeFailure::Unsupported(_) | DecodeFailure::Corrupt(_) => {
|
||||||
|
Err(ContractError::ProjectionUnavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn budget() -> Budget {
|
||||||
|
Budget::new(
|
||||||
|
error_contract::MAX_FAILURE_BYTES,
|
||||||
|
error_contract::MAX_FAILURE_BYTES,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Storage encoding cannot be constructed by relabeling an internal-transfer attachment.
|
||||||
|
/// A loaded value remains untrusted evidence; publication still requires `project_for`.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub struct StoredStartupFailureAttachment(StartupFailureAttachment);
|
||||||
|
|
||||||
|
impl StoredStartupFailureAttachment {
|
||||||
|
pub fn from_projected<D: WireDetails>(
|
||||||
|
failure: &ProjectedFailure<error_contract::Storage, D>,
|
||||||
|
) -> Result<Self, ContractError> {
|
||||||
|
StartupFailureAttachment::from_projected(failure).map(Self)
|
||||||
|
}
|
||||||
|
pub fn decode<D: WireDetails>(&self) -> DecodeFailure<D> {
|
||||||
|
self.0.decode()
|
||||||
|
}
|
||||||
|
pub fn project_for<A: Audience, D: WireDetails + error_contract::ProjectFor<A>>(
|
||||||
|
&self,
|
||||||
|
authorization: &D::Authorization,
|
||||||
|
) -> Result<ProjectedFailure<A, D::Details>, ContractError> {
|
||||||
|
self.0.project_for::<A, D>(authorization)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owner-selected startup stage; independent of resource cleanup disposition.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub enum StartupStage {
|
||||||
|
Preflight,
|
||||||
|
Metadata,
|
||||||
|
Reconstruction,
|
||||||
|
Recovery,
|
||||||
|
Binding,
|
||||||
|
Feature,
|
||||||
|
ActivationEffects,
|
||||||
|
Controller,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Safe local Runtime cause when no more specific domain attachment was supplied.
|
||||||
|
/// The primary cause is selected where the failure occurs, not parsed from its diagnostic.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub struct RuntimeStartupCause {
|
||||||
|
pub stage: StartupStage,
|
||||||
|
pub reason: RuntimeStartupReason,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coarse local causes do not claim a domain authorization verdict.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub enum RuntimeStartupReason {
|
||||||
|
StartupFailed,
|
||||||
|
SegmentEmpty,
|
||||||
|
SystemPromptMissing,
|
||||||
|
MetadataPending,
|
||||||
|
MetadataMissing,
|
||||||
|
MetadataInactive,
|
||||||
|
ManifestMissing,
|
||||||
|
AllocationFailed,
|
||||||
|
SessionReadFailed,
|
||||||
|
MetadataReadFailed,
|
||||||
|
ProviderFailed,
|
||||||
|
AttributionFailed,
|
||||||
|
FeatureFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl error_contract::FailureCause for RuntimeStartupCause {
|
||||||
|
fn identity(&self) -> error_contract::FailureIdentity {
|
||||||
|
error_contract::FailureIdentity {
|
||||||
|
domain: "worker_startup",
|
||||||
|
reason: "failed",
|
||||||
|
category: error_contract::Category::Internal,
|
||||||
|
details_version: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn validate(&self) -> Result<(), ContractError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl WireDetails for RuntimeStartupCause {
|
||||||
|
fn supports(domain: &str, reason: &str, version: u16) -> bool {
|
||||||
|
domain == "worker_startup" && reason == "failed" && version == 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This cause contains only an enum. Arbitrary provider messages, IO paths and credentials
|
||||||
|
// cannot enter its projection. Domain-specific causes must supply their own projector.
|
||||||
|
macro_rules! project_runtime {
|
||||||
|
($($audience:ty),*) => {$(
|
||||||
|
impl error_contract::ProjectFor<$audience> for RuntimeStartupCause {
|
||||||
|
type Authorization = ();
|
||||||
|
type Details = Self;
|
||||||
|
fn disclose(&self, facts: &error_contract::FailureFacts<Self>, _: &())
|
||||||
|
-> Result<error_contract::Disclosure<Self>, ContractError>
|
||||||
|
{
|
||||||
|
let mut disclosure = error_contract::Disclosure::new(self.clone(), "Worker startup failed.");
|
||||||
|
// Only fixed owner-selected operation/stage labels are published here.
|
||||||
|
for context in &facts.context().operations {
|
||||||
|
if context.operation == "worker_startup" && context.stage == "runtime" {
|
||||||
|
disclosure.context.operations.push(context.clone());
|
||||||
|
if let Some(last) = disclosure.context.operations.last_mut() {
|
||||||
|
last.operation_ref = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(disclosure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)*};
|
||||||
|
}
|
||||||
|
project_runtime!(
|
||||||
|
error_contract::InternalTransfer,
|
||||||
|
error_contract::Storage,
|
||||||
|
error_contract::Browser
|
||||||
|
);
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub enum StartupFailurePhase {
|
||||||
|
Cleanup,
|
||||||
|
RuntimeRecordDelete,
|
||||||
|
RuntimeSnapshotSave,
|
||||||
|
RuntimeRemovalPublish,
|
||||||
|
/// Retaining create reconciliation observations in the canonical Worker record.
|
||||||
|
RuntimeReconciliationRecordSave,
|
||||||
|
/// Publishing a retained record to Runtime subscribers; not a durable write.
|
||||||
|
RuntimeReconciliationPublish,
|
||||||
|
MetadataSave,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub enum StartupCleanupFailure {
|
||||||
|
Ownership,
|
||||||
|
Workdir,
|
||||||
|
Artifacts,
|
||||||
|
Allocation,
|
||||||
|
Feature,
|
||||||
|
Unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A separate secondary fact, not a replacement primary cause or a causal-chain link.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub enum StartupSecondaryFailure {
|
||||||
|
ProjectionUnavailable {
|
||||||
|
phase: StartupFailurePhase,
|
||||||
|
},
|
||||||
|
Cleanup {
|
||||||
|
failure: StartupCleanupFailure,
|
||||||
|
},
|
||||||
|
Persistence {
|
||||||
|
phase: StartupFailurePhase,
|
||||||
|
outcome: StartupPersistenceOutcome,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted is evidence only for this attachment/record write, not execution completion.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub enum StartupPersistenceOutcome {
|
||||||
|
Persisted,
|
||||||
|
NotSaved,
|
||||||
|
Conflict,
|
||||||
|
OutcomeUnknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Independent resource observations from the existing execution owner. These diagnostics
|
||||||
|
/// never promote a result to RolledBack or authorize replay; the backend disposition does.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub enum StartupResources {
|
||||||
|
None,
|
||||||
|
WorkerStartup,
|
||||||
|
Unproven,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub enum StartupCleanup {
|
||||||
|
NotNeeded,
|
||||||
|
Required,
|
||||||
|
Confirmed,
|
||||||
|
Failed(StartupCleanupFailure),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub struct StartupResourceFacts {
|
||||||
|
pub resources: StartupResources,
|
||||||
|
pub cleanup: StartupCleanup,
|
||||||
|
pub durable_effects_possible: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path-free IO classification. Nonportable OS details remain at the resource owner.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub enum StartupIoKind {
|
||||||
|
NotFound,
|
||||||
|
PermissionDenied,
|
||||||
|
AlreadyExists,
|
||||||
|
InvalidInput,
|
||||||
|
InvalidData,
|
||||||
|
Interrupted,
|
||||||
|
TimedOut,
|
||||||
|
WouldBlock,
|
||||||
|
UnexpectedEof,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
impl From<std::io::ErrorKind> for StartupIoKind {
|
||||||
|
fn from(kind: std::io::ErrorKind) -> Self {
|
||||||
|
use std::io::ErrorKind;
|
||||||
|
match kind {
|
||||||
|
ErrorKind::NotFound => Self::NotFound,
|
||||||
|
ErrorKind::PermissionDenied => Self::PermissionDenied,
|
||||||
|
ErrorKind::AlreadyExists => Self::AlreadyExists,
|
||||||
|
ErrorKind::InvalidInput => Self::InvalidInput,
|
||||||
|
ErrorKind::InvalidData => Self::InvalidData,
|
||||||
|
ErrorKind::Interrupted => Self::Interrupted,
|
||||||
|
ErrorKind::TimedOut => Self::TimedOut,
|
||||||
|
ErrorKind::WouldBlock => Self::WouldBlock,
|
||||||
|
ErrorKind::UnexpectedEof => Self::UnexpectedEof,
|
||||||
|
_ => Self::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use error_contract::{
|
||||||
|
Browser, FailureFacts, InternalTransfer, OperationContext, Storage, project_failure,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_projection_preserves_allowlisted_facts_without_secret_context() {
|
||||||
|
let mut facts = FailureFacts::new(RuntimeStartupCause {
|
||||||
|
stage: StartupStage::Binding,
|
||||||
|
reason: RuntimeStartupReason::AttributionFailed,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let mut context = OperationContext::new("worker_startup", "runtime").unwrap();
|
||||||
|
context.operation_ref = Some("TEST_ONLY_CREDENTIAL".into());
|
||||||
|
facts.with_context(context).unwrap();
|
||||||
|
facts
|
||||||
|
.with_context(OperationContext::new("/private/internal/path", "raw_http_body").unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let transfer = project_failure::<InternalTransfer, _>(&facts, &()).unwrap();
|
||||||
|
let attachment = StartupFailureAttachment::from_projected(&transfer).unwrap();
|
||||||
|
let storage_projection = attachment
|
||||||
|
.project_for::<Storage, RuntimeStartupCause>(&())
|
||||||
|
.unwrap();
|
||||||
|
let stored = StoredStartupFailureAttachment::from_projected(&storage_projection).unwrap();
|
||||||
|
let browser = stored
|
||||||
|
.project_for::<Browser, RuntimeStartupCause>(&())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(browser.details(), facts.cause());
|
||||||
|
assert_eq!(browser.context().operations.len(), 1);
|
||||||
|
assert_eq!(browser.context().operations[0].operation_ref, None);
|
||||||
|
let public =
|
||||||
|
encode_projected_failure(&browser, StartupFailureAttachment::budget()).unwrap();
|
||||||
|
let wire = std::str::from_utf8(public.as_bytes()).unwrap();
|
||||||
|
for secret in [
|
||||||
|
"TEST_ONLY_CREDENTIAL",
|
||||||
|
"/private/internal/path",
|
||||||
|
"raw_http_body",
|
||||||
|
] {
|
||||||
|
assert!(!wire.contains(secret));
|
||||||
|
assert!(!serde_json::to_string(&stored).unwrap().contains(secret));
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
stored.decode::<RuntimeStartupCause>(),
|
||||||
|
DecodeFailure::Known(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_and_corrupt_startup_attachments_cannot_be_redisclosed() {
|
||||||
|
let facts = FailureFacts::new(RuntimeStartupCause {
|
||||||
|
stage: StartupStage::Controller,
|
||||||
|
reason: RuntimeStartupReason::StartupFailed,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let projected = project_failure::<InternalTransfer, _>(&facts, &()).unwrap();
|
||||||
|
let attachment = StartupFailureAttachment::from_projected(&projected).unwrap();
|
||||||
|
let future =
|
||||||
|
StartupFailureAttachment(attachment.0.replace("\"version\":1", "\"version\":999"));
|
||||||
|
assert!(matches!(
|
||||||
|
future.decode::<RuntimeStartupCause>(),
|
||||||
|
DecodeFailure::Unsupported(_)
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
future
|
||||||
|
.project_for::<Browser, RuntimeStartupCause>(&())
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let corrupt = StartupFailureAttachment("{invalid TEST_ONLY_SECRET".into());
|
||||||
|
assert!(matches!(
|
||||||
|
corrupt.decode::<RuntimeStartupCause>(),
|
||||||
|
DecodeFailure::Corrupt(_)
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
corrupt
|
||||||
|
.project_for::<Browser, RuntimeStartupCause>(&())
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(!format!("{corrupt:?}").contains("TEST_ONLY_SECRET"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,19 @@ pub fn generated_protocol_types() -> String {
|
|||||||
// Large integer fields are JSON numbers on the wire and are emitted as TypeScript `number`.\n\n",
|
// Large integer fields are JSON numbers on the wire and are emitted as TypeScript `number`.\n\n",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
push_decl::<crate::startup_failure::StartupFailureAttachment>(&cfg, &mut output);
|
||||||
|
push_decl::<crate::startup_failure::StoredStartupFailureAttachment>(&cfg, &mut output);
|
||||||
|
push_decl::<crate::startup_failure::StartupStage>(&cfg, &mut output);
|
||||||
|
push_decl::<crate::startup_failure::RuntimeStartupReason>(&cfg, &mut output);
|
||||||
|
push_decl::<crate::startup_failure::RuntimeStartupCause>(&cfg, &mut output);
|
||||||
|
push_decl::<crate::startup_failure::StartupFailurePhase>(&cfg, &mut output);
|
||||||
|
push_decl::<crate::startup_failure::StartupCleanupFailure>(&cfg, &mut output);
|
||||||
|
push_decl::<crate::startup_failure::StartupPersistenceOutcome>(&cfg, &mut output);
|
||||||
|
push_decl::<crate::startup_failure::StartupSecondaryFailure>(&cfg, &mut output);
|
||||||
|
push_decl::<crate::startup_failure::StartupResources>(&cfg, &mut output);
|
||||||
|
push_decl::<crate::startup_failure::StartupCleanup>(&cfg, &mut output);
|
||||||
|
push_decl::<crate::startup_failure::StartupResourceFacts>(&cfg, &mut output);
|
||||||
|
push_decl::<crate::startup_failure::StartupIoKind>(&cfg, &mut output);
|
||||||
push_decl::<AlertLevel>(&cfg, &mut output);
|
push_decl::<AlertLevel>(&cfg, &mut output);
|
||||||
push_decl::<AlertPosition>(&cfg, &mut output);
|
push_decl::<AlertPosition>(&cfg, &mut output);
|
||||||
push_decl::<AlertSource>(&cfg, &mut output);
|
push_decl::<AlertSource>(&cfg, &mut output);
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
api-macros = { workspace = true, features = ["reqwest", "axum"] }
|
api-macros = { workspace = true, features = ["reqwest", "axum", "openapi"] }
|
||||||
manifest.workspace = true
|
manifest.workspace = true
|
||||||
plugin-plan = { path = "../plugin-plan" }
|
plugin-plan = { path = "../plugin-plan" }
|
||||||
protocol.workspace = true
|
protocol = { workspace = true, features = ["json-schema"] }
|
||||||
|
error-contract = { workspace = true, features = ["json-schema"] }
|
||||||
|
schemars.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
session-store.workspace = true
|
session-store.workspace = true
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
workdir.workspace = true
|
workdir.workspace = true
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ HTTP contract for Worker Runtime management. Owns REST DTOs and generated client
|
|||||||
- [`WorkerRestoreRequest`] / [`WorkerRestoreCoordinationRequest`] — Restore intent and Backend preparation contracts
|
- [`WorkerRestoreRequest`] / [`WorkerRestoreCoordinationRequest`] — Restore intent and Backend preparation contracts
|
||||||
- [`LogicalWorkdirAttachment`] — Logical Workdir attachments determined by Workspace
|
- [`LogicalWorkdirAttachment`] — Logical Workdir attachments determined by Workspace
|
||||||
- [`BackendJobExecutionBinding`] — Job/attempt execution identity fixed by the Host
|
- [`BackendJobExecutionBinding`] — Job/attempt execution identity fixed by the Host
|
||||||
|
- [`startup`] — projected startup Problem Details, independent operation observations and bounded receiver normalization
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#![doc = include_str!("../README.md")]
|
#![doc = include_str!("../README.md")]
|
||||||
|
|
||||||
|
pub mod startup;
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use api_macros::api;
|
use api_macros::api;
|
||||||
@@ -10,6 +12,13 @@ use protocol::{CompletionEntry, CompletionKind, Segment, WorkerId, WorkerStateSn
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use workdir::workspace::{MaterializerKind, RuntimeWorkerRef, RuntimeWorkingDirectorySummary};
|
use workdir::workspace::{MaterializerKind, RuntimeWorkerRef, RuntimeWorkingDirectorySummary};
|
||||||
|
|
||||||
|
pub use protocol::startup_failure::{
|
||||||
|
RuntimeStartupCause, RuntimeStartupReason, StartupCleanup, StartupCleanupFailure,
|
||||||
|
StartupFailureAttachment, StartupFailurePhase, StartupIoKind, StartupPersistenceOutcome,
|
||||||
|
StartupResourceFacts, StartupResources, StartupSecondaryFailure, StartupStage,
|
||||||
|
StoredStartupFailureAttachment,
|
||||||
|
};
|
||||||
|
|
||||||
pub const RUNTIME_API_VERSION: &str = "v1";
|
pub const RUNTIME_API_VERSION: &str = "v1";
|
||||||
pub const RUNTIME_API_BASE_PATH: &str = "/v1";
|
pub const RUNTIME_API_BASE_PATH: &str = "/v1";
|
||||||
/// Runtime transport classification for a materialized Repository source.
|
/// Runtime transport classification for a materialized Repository source.
|
||||||
@@ -96,12 +105,30 @@ pub struct RuntimeApiError {
|
|||||||
pub error: RuntimeApiErrorDetail,
|
pub error: RuntimeApiErrorDetail,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
status: u16,
|
status: u16,
|
||||||
|
/// Local owner/normalized observations, never a second wire attachment.
|
||||||
|
#[serde(skip)]
|
||||||
|
create_failure: Option<startup::WorkerCreateFailure>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transient, allowlisted startup observations copied from the execution owner.
|
||||||
|
/// This is not an operation result, lifecycle record, or permission to retry.
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkerStartupFailure {
|
||||||
|
pub primary: StartupFailureAttachment,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub secondary: Vec<StartupSecondaryFailure>,
|
||||||
|
pub resources: Option<StartupResourceFacts>,
|
||||||
|
pub persistence: Option<StartupPersistenceOutcome>,
|
||||||
|
pub io_kind: Option<StartupIoKind>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
pub struct RuntimeApiErrorDetail {
|
pub struct RuntimeApiErrorDetail {
|
||||||
pub code: String,
|
pub code: String,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub startup: Option<WorkerStartupFailure>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RuntimeApiError {
|
impl RuntimeApiError {
|
||||||
@@ -110,20 +137,40 @@ impl RuntimeApiError {
|
|||||||
error: RuntimeApiErrorDetail {
|
error: RuntimeApiErrorDetail {
|
||||||
code: code.into(),
|
code: code.into(),
|
||||||
message: message.into(),
|
message: message.into(),
|
||||||
|
startup: None,
|
||||||
},
|
},
|
||||||
status,
|
status,
|
||||||
|
create_failure: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn status(&self) -> u16 {
|
pub fn status(&self) -> u16 {
|
||||||
self.status
|
self.status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retain owner-selected facts locally; the HTTP hook projects them afresh.
|
||||||
|
pub fn with_create_failure(mut self, failure: startup::WorkerCreateFailure) -> Self {
|
||||||
|
self.create_failure = Some(failure);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_failure(&self) -> Option<&startup::WorkerCreateFailure> {
|
||||||
|
self.create_failure.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_create_failure(self) -> Option<startup::WorkerCreateFailure> {
|
||||||
|
self.create_failure
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl api_macros::HttpError for RuntimeApiError {
|
impl api_macros::HttpError for RuntimeApiError {
|
||||||
fn status_code(&self) -> u16 {
|
fn status_code(&self) -> u16 {
|
||||||
self.status
|
self.status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn custom_response(&self) -> Option<api_macros::HttpErrorResponse> {
|
||||||
|
startup::http::runtime_error_response(self)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
@@ -1282,7 +1329,7 @@ pub struct SubjectJobRemovalPurgeResponse {
|
|||||||
pub input_fingerprint: String,
|
pub input_fingerprint: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[api(reqwest, axum)]
|
#[api(reqwest, axum, client_error_hook)]
|
||||||
pub trait RuntimeApi {
|
pub trait RuntimeApi {
|
||||||
#[post("/v1/subject-job-removal-purge", status = 200, error_status = 400)]
|
#[post("/v1/subject-job-removal-purge", status = 200, error_status = 400)]
|
||||||
async fn purge_subject_job_removal(
|
async fn purge_subject_job_removal(
|
||||||
@@ -1901,6 +1948,25 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_worker(&self, worker_id: String) -> Result<WorkerResponse, RuntimeApiError> {
|
async fn get_worker(&self, worker_id: String) -> Result<WorkerResponse, RuntimeApiError> {
|
||||||
|
if worker_id == "startup" {
|
||||||
|
use startup::*;
|
||||||
|
let failure = WorkerCreateFailure::from_cause(
|
||||||
|
StartupCause::Session(
|
||||||
|
protocol::session_failure::SessionRegistrationCause::denied(
|
||||||
|
protocol::session_failure::SessionPrincipal::Subject,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
StartupOperationFacts {
|
||||||
|
acceptance: CreationAcceptance::Unknown,
|
||||||
|
execution: ExecutionOutcome::Unknown,
|
||||||
|
finalize: None,
|
||||||
|
backend_compensation: None,
|
||||||
|
correlation: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
return Err(test_error(500).with_create_failure(failure));
|
||||||
|
}
|
||||||
assert_eq!(worker_id, "missing worker");
|
assert_eq!(worker_id, "missing worker");
|
||||||
Err(test_error(404))
|
Err(test_error(404))
|
||||||
}
|
}
|
||||||
@@ -2210,6 +2276,34 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
matches!(error, client_support::ClientError::Public { status, .. } if status.as_u16() == 404)
|
matches!(error, client_support::ClientError::Public { status, .. } if status.as_u16() == 404)
|
||||||
);
|
);
|
||||||
|
let error = client.get_worker("startup".to_string()).await.unwrap_err();
|
||||||
|
let client_support::ClientError::Public { status, error } = error else {
|
||||||
|
panic!("expected a normalized public startup failure");
|
||||||
|
};
|
||||||
|
assert_eq!(status.as_u16(), 403);
|
||||||
|
assert_eq!(error.status(), 403);
|
||||||
|
let failure = error
|
||||||
|
.create_failure()
|
||||||
|
.expect("hook retains non-HTTP observations");
|
||||||
|
assert_eq!(
|
||||||
|
failure.operation().acceptance,
|
||||||
|
startup::CreationAcceptance::Unknown
|
||||||
|
);
|
||||||
|
let browser = failure
|
||||||
|
.problem(
|
||||||
|
startup::StartupBoundary::Browser,
|
||||||
|
&startup::SessionFailureVisibility {
|
||||||
|
can_view_cause: true,
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(browser.status, 409);
|
||||||
|
assert!(
|
||||||
|
std::str::from_utf8(browser.body())
|
||||||
|
.unwrap()
|
||||||
|
.contains("session.record")
|
||||||
|
);
|
||||||
server.abort();
|
server.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,193 @@
|
|||||||
|
//! Runtime API HTTP integration. No HTTP status is converted into execution facts.
|
||||||
|
use api_macros::reqwest::{ClientFailure, HttpClientError, ReceivedResponse};
|
||||||
|
use error_contract::{DecodeFailure, InternalTransfer};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::RuntimeApiError;
|
||||||
|
|
||||||
|
fn unknown_operation() -> StartupOperationFacts {
|
||||||
|
StartupOperationFacts {
|
||||||
|
acceptance: CreationAcceptance::Unknown,
|
||||||
|
execution: ExecutionOutcome::Unknown,
|
||||||
|
finalize: None,
|
||||||
|
backend_compensation: None,
|
||||||
|
correlation: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RuntimeManagementApi invokes this only after checking authenticated Runtime
|
||||||
|
/// scope. That scope permits the minimum public-safe cause schema, not arbitrary
|
||||||
|
/// internal-transfer details. Resolve the public projection afresh on each call;
|
||||||
|
/// no stored/received projection is relabeled as Browser authorization.
|
||||||
|
pub(crate) fn runtime_error_response(
|
||||||
|
error: &RuntimeApiError,
|
||||||
|
) -> Option<api_macros::HttpErrorResponse> {
|
||||||
|
let legacy;
|
||||||
|
let failure = match error.create_failure() {
|
||||||
|
Some(failure) => failure,
|
||||||
|
None => {
|
||||||
|
let startup = error.error.startup.as_ref()?;
|
||||||
|
legacy = WorkerCreateFailure::new(startup.clone(), unknown_operation());
|
||||||
|
match &legacy {
|
||||||
|
Ok(failure) => failure,
|
||||||
|
Err(_) => return Some(static_fallback()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let visibility = SessionFailureVisibility {
|
||||||
|
can_view_cause: true,
|
||||||
|
};
|
||||||
|
Some(
|
||||||
|
match failure.problem(StartupBoundary::Runtime, &visibility, "") {
|
||||||
|
Ok(response) => api_macros::HttpErrorResponse {
|
||||||
|
status: response.status,
|
||||||
|
content_type: response.content_type,
|
||||||
|
body: response.into_body().into(),
|
||||||
|
},
|
||||||
|
// Never expose the legacy/raw attachment if projection cannot be made.
|
||||||
|
Err(_) => static_fallback(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn static_fallback() -> api_macros::HttpErrorResponse {
|
||||||
|
api_macros::HttpErrorResponse {
|
||||||
|
status: 500,
|
||||||
|
content_type: PROBLEM_CONTENT_TYPE,
|
||||||
|
body: error_contract::problem::STATIC_PROBLEM_FALLBACK
|
||||||
|
.to_vec()
|
||||||
|
.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_error(
|
||||||
|
status: u16,
|
||||||
|
failure: WorkerCreateFailure,
|
||||||
|
) -> Result<RuntimeApiError, ClientFailure> {
|
||||||
|
// Received text and legacy labels are not sources of local explanations.
|
||||||
|
// InternalTransfer is an internal observation policy, not Browser authority.
|
||||||
|
let projected = failure
|
||||||
|
.startup()
|
||||||
|
.primary
|
||||||
|
.project_for::<InternalTransfer, StartupCause>(&())
|
||||||
|
.map_err(|_| ClientFailure::ErrorResponseDecode { status })?;
|
||||||
|
let identity = projected.identity();
|
||||||
|
let mut error = RuntimeApiError::new(
|
||||||
|
status,
|
||||||
|
format!("{}.{}", identity.domain, identity.reason),
|
||||||
|
projected.explanation(),
|
||||||
|
);
|
||||||
|
error.error.startup = Some(failure.startup().clone());
|
||||||
|
Ok(error.with_create_failure(failure))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_observation(
|
||||||
|
status: u16,
|
||||||
|
reason: StartupBoundaryReason,
|
||||||
|
) -> Result<RuntimeApiError, ClientFailure> {
|
||||||
|
let failure = WorkerCreateFailure::from_cause(
|
||||||
|
StartupCause::Boundary(StartupBoundaryCause { reason }),
|
||||||
|
unknown_operation(),
|
||||||
|
)
|
||||||
|
.map_err(|_| ClientFailure::ErrorResponseDecode { status })?;
|
||||||
|
normalized_error(status, failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A deliberately narrow legacy schema. Bounded typed parsing avoids arbitrary JSON
|
||||||
|
// allocation; startup attachment strings remain opaque until domain codec validation.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct LegacyError {
|
||||||
|
error: LegacyDetail,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct LegacyDetail {
|
||||||
|
code: String,
|
||||||
|
message: String,
|
||||||
|
startup: Option<WorkerStartupFailure>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpClientError for RuntimeApiError {
|
||||||
|
fn decode_error(response: &ReceivedResponse) -> Result<Self, ClientFailure> {
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
if !(400..=599).contains(&status) {
|
||||||
|
return Err(ClientFailure::ErrorResponseDecode { status });
|
||||||
|
}
|
||||||
|
// The generated client has already bounded collection; enforce this
|
||||||
|
// smaller contract limit independently before any schema allocation.
|
||||||
|
if response.body().len() > MAX_STARTUP_PROBLEM_BYTES {
|
||||||
|
return local_observation(status, StartupBoundaryReason::TooLarge);
|
||||||
|
}
|
||||||
|
let content_type = match response.content_type() {
|
||||||
|
Ok(content_type) => content_type,
|
||||||
|
Err(_) => return local_observation(status, StartupBoundaryReason::InvalidResponse),
|
||||||
|
};
|
||||||
|
let media_type = content_type.split(';').next().unwrap_or("").trim();
|
||||||
|
if media_type.eq_ignore_ascii_case(PROBLEM_CONTENT_TYPE) {
|
||||||
|
return match decode_startup_problem(
|
||||||
|
response.body(),
|
||||||
|
status,
|
||||||
|
content_type,
|
||||||
|
StartupBoundary::Runtime,
|
||||||
|
) {
|
||||||
|
DecodeStartupProblem::Known(failure) => normalized_error(status, failure),
|
||||||
|
DecodeStartupProblem::UnsupportedVersion => {
|
||||||
|
local_observation(status, StartupBoundaryReason::UnsupportedVersion)
|
||||||
|
}
|
||||||
|
DecodeStartupProblem::UnsupportedReason => {
|
||||||
|
local_observation(status, StartupBoundaryReason::UnsupportedReason)
|
||||||
|
}
|
||||||
|
DecodeStartupProblem::Invalid => {
|
||||||
|
local_observation(status, StartupBoundaryReason::InvalidResponse)
|
||||||
|
}
|
||||||
|
DecodeStartupProblem::TooLarge => {
|
||||||
|
local_observation(status, StartupBoundaryReason::TooLarge)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if !media_type.eq_ignore_ascii_case("application/json") {
|
||||||
|
return local_observation(status, StartupBoundaryReason::InvalidResponse);
|
||||||
|
}
|
||||||
|
// The typed legacy parser has fixed nesting and rejects unknown fields.
|
||||||
|
// Its total allocation/opaque attachment size is capped by the byte limit.
|
||||||
|
let legacy = match serde_json::from_slice::<LegacyError>(response.body()) {
|
||||||
|
Ok(legacy) => legacy.error,
|
||||||
|
Err(_) => return local_observation(status, StartupBoundaryReason::Decode),
|
||||||
|
};
|
||||||
|
if error_contract::validate_identifier(&legacy.code).is_err()
|
||||||
|
|| error_contract::validate_explanation(&legacy.message).is_err()
|
||||||
|
{
|
||||||
|
return local_observation(status, StartupBoundaryReason::InvalidResponse);
|
||||||
|
}
|
||||||
|
let Some(mut startup) = legacy.startup else {
|
||||||
|
// Preserve ordinary legacy errors exactly, including their actual status.
|
||||||
|
return Ok(RuntimeApiError::new(status, legacy.code, legacy.message));
|
||||||
|
};
|
||||||
|
let projected = match startup.primary.decode::<StartupCause>() {
|
||||||
|
DecodeFailure::Known(received) => match error_contract::project_failure::<
|
||||||
|
InternalTransfer,
|
||||||
|
_,
|
||||||
|
>(received.facts(), &())
|
||||||
|
{
|
||||||
|
Ok(projected) => projected,
|
||||||
|
Err(_) => return local_observation(status, StartupBoundaryReason::InvalidResponse),
|
||||||
|
},
|
||||||
|
DecodeFailure::Corrupt(error_contract::ContractError::TooLarge) => {
|
||||||
|
return local_observation(status, StartupBoundaryReason::TooLarge);
|
||||||
|
}
|
||||||
|
// The legacy attachment codec intentionally exposes no unsupported
|
||||||
|
// metadata. Do not guess a reason/version from that opaque evidence.
|
||||||
|
_ => return local_observation(status, StartupBoundaryReason::InvalidResponse),
|
||||||
|
};
|
||||||
|
startup.primary = match StartupFailureAttachment::from_projected(&projected) {
|
||||||
|
Ok(primary) => primary,
|
||||||
|
Err(_) => return local_observation(status, StartupBoundaryReason::InvalidResponse),
|
||||||
|
};
|
||||||
|
match WorkerCreateFailure::new(startup, unknown_operation()) {
|
||||||
|
Ok(failure) => normalized_error(status, failure),
|
||||||
|
Err(_) => local_observation(status, StartupBoundaryReason::InvalidResponse),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
//! HTTP-independent snapshot of a Backend coordinator failure. This is a codec
|
||||||
|
//! for the existing reservation result, not a separate ledger or retry authority.
|
||||||
|
use super::*;
|
||||||
|
use crate::StoredStartupFailureAttachment;
|
||||||
|
use error_contract::{Storage, validate_json_input};
|
||||||
|
|
||||||
|
const STORAGE_VERSION: u16 = 1;
|
||||||
|
|
||||||
|
// Private DTO: only the concrete failure encoder may construct a stored snapshot.
|
||||||
|
// In particular no arbitrary error/source, HTTP response or private identity fits here.
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct StoredWorkerCreateFailure {
|
||||||
|
version: u16,
|
||||||
|
primary: StoredStartupFailureAttachment,
|
||||||
|
facts: StartupProblemFacts,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inspect versions from original input before attempting to understand future facts.
|
||||||
|
// IgnoredAny still checks JSON grammar without allocating arbitrary extension trees.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct StorageMetadata {
|
||||||
|
version: u16,
|
||||||
|
#[serde(rename = "primary")]
|
||||||
|
_primary: serde::de::IgnoredAny,
|
||||||
|
facts: ExtensionMetadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProjectFor<Storage> for StartupBoundaryCause {
|
||||||
|
type Authorization = ();
|
||||||
|
type Details = Self;
|
||||||
|
|
||||||
|
fn disclose(&self, _: &FailureFacts<Self>, _: &()) -> Result<Disclosure<Self>, ContractError> {
|
||||||
|
// The boundary domain stores only its local enum, never received context.
|
||||||
|
Ok(Disclosure::new(*self, boundary_explanation(self)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProjectFor<Storage> for StartupCause {
|
||||||
|
type Authorization = ();
|
||||||
|
type Details = Self;
|
||||||
|
|
||||||
|
fn disclose(
|
||||||
|
&self,
|
||||||
|
facts: &FailureFacts<Self>,
|
||||||
|
_: &(),
|
||||||
|
) -> Result<Disclosure<Self>, ContractError> {
|
||||||
|
match self {
|
||||||
|
Self::Session(cause) => {
|
||||||
|
let concrete = domain_facts(*cause, facts)?;
|
||||||
|
let disclosed = <SessionRegistrationCause as ProjectFor<Storage>>::disclose(
|
||||||
|
cause,
|
||||||
|
&concrete,
|
||||||
|
&(),
|
||||||
|
)?;
|
||||||
|
Ok(wrap(disclosed, Self::Session, session_explanation(cause)))
|
||||||
|
}
|
||||||
|
Self::Runtime(cause) => {
|
||||||
|
let concrete = domain_facts(cause.clone(), facts)?;
|
||||||
|
let disclosed =
|
||||||
|
<RuntimeStartupCause as ProjectFor<Storage>>::disclose(cause, &concrete, &())?;
|
||||||
|
Ok(wrap(
|
||||||
|
disclosed,
|
||||||
|
Self::Runtime,
|
||||||
|
runtime_explanation(cause).into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Self::Boundary(cause) => {
|
||||||
|
let concrete = domain_facts(*cause, facts)?;
|
||||||
|
let disclosed =
|
||||||
|
<StartupBoundaryCause as ProjectFor<Storage>>::disclose(cause, &concrete, &())?;
|
||||||
|
Ok(wrap(
|
||||||
|
disclosed,
|
||||||
|
Self::Boundary,
|
||||||
|
boundary_explanation(cause).into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkerCreateFailure {
|
||||||
|
/// Encode a bounded version-1 storage snapshot through each concrete domain's
|
||||||
|
/// Storage allowlist. Never encodes or parses an HTTP body. The caller selects
|
||||||
|
/// the Backend persistence marker (including prospective Persisted for an
|
||||||
|
/// atomic reservation write); encoding alone never proves persistence.
|
||||||
|
pub fn encode_storage(&self) -> Result<String, ContractError> {
|
||||||
|
let facts = self.public_facts();
|
||||||
|
facts.validate()?;
|
||||||
|
let projected = self
|
||||||
|
.startup
|
||||||
|
.primary
|
||||||
|
.project_for::<Storage, StartupCause>(&())?;
|
||||||
|
let dto = StoredWorkerCreateFailure {
|
||||||
|
version: STORAGE_VERSION,
|
||||||
|
primary: StoredStartupFailureAttachment::from_projected(&projected)?,
|
||||||
|
facts,
|
||||||
|
};
|
||||||
|
let encoded = serde_json::to_string(&dto).map_err(|_| ContractError::Encoding)?;
|
||||||
|
// Includes escaped attachment bytes and the entire enclosing snapshot.
|
||||||
|
validate_json_input(encoded.as_bytes(), budget())?;
|
||||||
|
Ok(encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum DecodeStoredWorkerCreateFailure {
|
||||||
|
Known(WorkerCreateFailure),
|
||||||
|
Unsupported,
|
||||||
|
Corrupt(ContractError),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a bounded storage snapshot without an HTTP classification or boundary.
|
||||||
|
/// Unsupported/corrupt evidence never becomes a fabricated primary cause, and a
|
||||||
|
/// Known result still needs current visibility for any later public projection.
|
||||||
|
/// Missing legacy columns are represented by the store's Option, not this codec.
|
||||||
|
pub fn decode_stored_worker_create_failure(input: &str) -> DecodeStoredWorkerCreateFailure {
|
||||||
|
match decode_checked(input) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => DecodeStoredWorkerCreateFailure::Corrupt(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_checked(input: &str) -> Result<DecodeStoredWorkerCreateFailure, ContractError> {
|
||||||
|
use DecodeStoredWorkerCreateFailure::*;
|
||||||
|
// Run the shared lexical guard over the whole input before any JSON parsing.
|
||||||
|
validate_json_input(input.as_bytes(), budget())?;
|
||||||
|
let metadata: StorageMetadata =
|
||||||
|
serde_json::from_str(input).map_err(|_| ContractError::Invalid)?;
|
||||||
|
if metadata.version == 0 || metadata.facts.version == 0 {
|
||||||
|
return Err(ContractError::Invalid);
|
||||||
|
}
|
||||||
|
if metadata.version != STORAGE_VERSION || metadata.facts.version != STARTUP_FACTS_VERSION {
|
||||||
|
return Ok(Unsupported);
|
||||||
|
}
|
||||||
|
// Original-byte typed decoding rejects duplicates rather than collapsing a Value.
|
||||||
|
let dto: StoredWorkerCreateFailure =
|
||||||
|
serde_json::from_str(input).map_err(|_| ContractError::InvalidDetails)?;
|
||||||
|
dto.facts.validate()?;
|
||||||
|
let received = match dto.primary.decode::<StartupCause>() {
|
||||||
|
DecodeFailure::Known(received) => received,
|
||||||
|
DecodeFailure::Unsupported(_) => return Ok(Unsupported),
|
||||||
|
DecodeFailure::Corrupt(error) => return Err(error),
|
||||||
|
};
|
||||||
|
// Deserialization is not a capability. Project again instead of relabeling or
|
||||||
|
// retaining raw loaded attachment bytes (including injected explanations/context).
|
||||||
|
let projected = project_failure::<Storage, _>(received.facts(), &())?;
|
||||||
|
let primary = StartupFailureAttachment::from_projected(&projected)?;
|
||||||
|
let facts = dto.facts;
|
||||||
|
let failure = WorkerCreateFailure::new(
|
||||||
|
WorkerStartupFailure {
|
||||||
|
primary,
|
||||||
|
secondary: facts.secondary,
|
||||||
|
resources: facts.resources,
|
||||||
|
persistence: facts.persistence,
|
||||||
|
io_kind: facts.io_kind,
|
||||||
|
},
|
||||||
|
facts.operation,
|
||||||
|
)?
|
||||||
|
.with_backend_result_persistence(facts.backend_result_persistence);
|
||||||
|
Ok(Known(failure))
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"type":"urn:yoi:problem:dependency","title":"Dependency failure","status":502,"detail":"Worker creation could not complete Session registration: Unknown principal has an unknown session.record outcome. Creation acceptance and execution outcome are reported separately.","instance":"urn:yoi:worker-startup:worker-af7808a90c24ed19bb134795f4460b01f7d40be31567155dff4431898e871343","yoi_failure":{"version":1,"required_extensions":[],"domain":"session_registration","reason":"outcome_unknown","category":"unknown","details_version":1,"details":{"reason":"outcome_unknown","principal":"unknown","required_operation":"session.record"},"explanation":"Worker creation could not complete Session registration: Unknown principal has an unknown session.record outcome. Creation acceptance and execution outcome are reported separately.","context":{"operations":[{"operation":"session.record","stage":"registration","operation_ref":null},{"operation":"worker_startup","stage":"binding","operation_ref":null}],"causes":[],"diagnostics":[],"correlation":null,"supplementary_omitted":false}},"yoi_startup":{"version":1,"operation":{"acceptance":"unknown","execution":"errored","finalize":null,"backend_compensation":"unknown","correlation":"worker-af7808a90c24ed19bb134795f4460b01f7d40be31567155dff4431898e871343"},"secondary":[{"kind":"persistence","phase":"runtime_record_delete","outcome":"outcome_unknown"}],"resources":{"resources":"worker_startup","cleanup":"confirmed","durable_effects_possible":false},"persistence":"persisted","backend_result_persistence":"persisted","io_kind":null}}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"type":"urn:yoi:problem:conflict","title":"Operation conflict","status":409,"detail":"Worker creation could not complete Session registration: Subject is not authorized for required operation session.record. Creation acceptance and execution outcome are reported separately.","instance":"urn:yoi:worker-startup:worker-8d4e95ba9450471443009bb32726792468b06281cc2664258dfb2eae7ffd7bf5","yoi_failure":{"version":1,"required_extensions":[],"domain":"session_registration","reason":"permission_denied","category":"authorization","details_version":1,"details":{"reason":"permission_denied","principal":"subject","required_operation":"session.record"},"explanation":"Worker creation could not complete Session registration: Subject is not authorized for required operation session.record. Creation acceptance and execution outcome are reported separately.","context":{"operations":[{"operation":"session.record","stage":"registration","operation_ref":null},{"operation":"worker_startup","stage":"binding","operation_ref":null}],"causes":[],"diagnostics":[],"correlation":null,"supplementary_omitted":false}},"yoi_startup":{"version":1,"operation":{"acceptance":"unknown","execution":"errored","finalize":null,"backend_compensation":"unknown","correlation":"worker-8d4e95ba9450471443009bb32726792468b06281cc2664258dfb2eae7ffd7bf5"},"secondary":[{"kind":"persistence","phase":"runtime_record_delete","outcome":"outcome_unknown"}],"resources":{"resources":"worker_startup","cleanup":"confirmed","durable_effects_possible":false},"persistence":"persisted","backend_result_persistence":"persisted","io_kind":null}}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"detail": "Worker creation could not complete Session registration: Subject is not authorized for required operation session.record. Creation acceptance and execution outcome are reported separately.",
|
||||||
|
"instance": "/workers/create/fixture-812",
|
||||||
|
"status": 409,
|
||||||
|
"title": "Operation conflict",
|
||||||
|
"type": "urn:yoi:problem:conflict",
|
||||||
|
"yoi_failure": {
|
||||||
|
"category": "authorization",
|
||||||
|
"context": {
|
||||||
|
"causes": [],
|
||||||
|
"correlation": null,
|
||||||
|
"diagnostics": [],
|
||||||
|
"operations": [],
|
||||||
|
"supplementary_omitted": false
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"principal": "subject",
|
||||||
|
"reason": "permission_denied",
|
||||||
|
"required_operation": "session.record"
|
||||||
|
},
|
||||||
|
"details_version": 1,
|
||||||
|
"domain": "session_registration",
|
||||||
|
"explanation": "Worker creation could not complete Session registration: Subject is not authorized for required operation session.record. Creation acceptance and execution outcome are reported separately.",
|
||||||
|
"reason": "permission_denied",
|
||||||
|
"required_extensions": [],
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"yoi_startup": {
|
||||||
|
"io_kind": "permission_denied",
|
||||||
|
"operation": {
|
||||||
|
"acceptance": "rejected",
|
||||||
|
"backend_compensation": null,
|
||||||
|
"correlation": "startup-fixture-812",
|
||||||
|
"execution": "rejected",
|
||||||
|
"finalize": null
|
||||||
|
},
|
||||||
|
"persistence": "outcome_unknown",
|
||||||
|
"resources": {
|
||||||
|
"cleanup": {
|
||||||
|
"failed": "workdir"
|
||||||
|
},
|
||||||
|
"durable_effects_possible": true,
|
||||||
|
"resources": "worker_startup"
|
||||||
|
},
|
||||||
|
"secondary": [
|
||||||
|
{
|
||||||
|
"failure": "workdir",
|
||||||
|
"kind": "cleanup"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "persistence",
|
||||||
|
"outcome": "outcome_unknown",
|
||||||
|
"phase": "metadata_save"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,918 @@
|
|||||||
|
//! Producer/consumer checks against the production constructor and decoder.
|
||||||
|
use error_contract::problem::ApiBoundary as _;
|
||||||
|
use error_contract::{
|
||||||
|
Browser, ContractError, DecodeFailure, FailureCause, FailureFacts, InternalTransfer,
|
||||||
|
OperationContext, ProjectFor, WireDetails, project_failure,
|
||||||
|
};
|
||||||
|
use protocol::session_failure::{SessionPrincipal, SessionRegistrationCause};
|
||||||
|
use runtime_api::startup::*;
|
||||||
|
use runtime_api::{
|
||||||
|
RuntimeStartupCause, RuntimeStartupReason, StartupCleanup, StartupCleanupFailure,
|
||||||
|
StartupFailureAttachment, StartupFailurePhase, StartupIoKind, StartupPersistenceOutcome,
|
||||||
|
StartupResourceFacts, StartupResources, StartupSecondaryFailure, StartupStage,
|
||||||
|
WorkerStartupFailure,
|
||||||
|
};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
fn visible() -> SessionFailureVisibility {
|
||||||
|
SessionFailureVisibility {
|
||||||
|
can_view_cause: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn operation(acceptance: CreationAcceptance, execution: ExecutionOutcome) -> StartupOperationFacts {
|
||||||
|
StartupOperationFacts {
|
||||||
|
acceptance,
|
||||||
|
execution,
|
||||||
|
finalize: None,
|
||||||
|
backend_compensation: None,
|
||||||
|
correlation: Some(StartupCorrelation::new("startup-fixture-812").unwrap()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn session() -> StartupCause {
|
||||||
|
StartupCause::Session(SessionRegistrationCause::denied(SessionPrincipal::Subject))
|
||||||
|
}
|
||||||
|
fn failure(cause: StartupCause) -> WorkerCreateFailure {
|
||||||
|
WorkerCreateFailure::from_cause(
|
||||||
|
cause,
|
||||||
|
operation(CreationAcceptance::Unknown, ExecutionOutcome::Unknown),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
fn decode(value: &Value, status: u16, boundary: StartupBoundary) -> DecodeStartupProblem {
|
||||||
|
decode_startup_problem(
|
||||||
|
&serde_json::to_vec(value).unwrap(),
|
||||||
|
status,
|
||||||
|
PROBLEM_CONTENT_TYPE,
|
||||||
|
boundary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fn known(result: DecodeStartupProblem) -> WorkerCreateFailure {
|
||||||
|
match result {
|
||||||
|
DecodeStartupProblem::Known(failure) => failure,
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn fixture_failure() -> WorkerCreateFailure {
|
||||||
|
let base = failure(session());
|
||||||
|
let (mut startup, _) = base.into_parts();
|
||||||
|
startup.secondary = vec![
|
||||||
|
StartupSecondaryFailure::Cleanup {
|
||||||
|
failure: StartupCleanupFailure::Workdir,
|
||||||
|
},
|
||||||
|
StartupSecondaryFailure::Persistence {
|
||||||
|
phase: StartupFailurePhase::MetadataSave,
|
||||||
|
outcome: StartupPersistenceOutcome::OutcomeUnknown,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
startup.resources = Some(StartupResourceFacts {
|
||||||
|
resources: StartupResources::WorkerStartup,
|
||||||
|
cleanup: StartupCleanup::Failed(StartupCleanupFailure::Workdir),
|
||||||
|
durable_effects_possible: true,
|
||||||
|
});
|
||||||
|
startup.persistence = Some(StartupPersistenceOutcome::OutcomeUnknown);
|
||||||
|
startup.io_kind = Some(StartupIoKind::PermissionDenied);
|
||||||
|
WorkerCreateFailure::new(
|
||||||
|
startup,
|
||||||
|
operation(CreationAcceptance::Rejected, ExecutionOutcome::Rejected),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn production_public_fixture_is_stable_and_consumable() {
|
||||||
|
let failure = fixture_failure();
|
||||||
|
let response = failure
|
||||||
|
.problem(
|
||||||
|
StartupBoundary::Browser,
|
||||||
|
&visible(),
|
||||||
|
"/workers/create/fixture-812",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status, 409);
|
||||||
|
assert_eq!(response.content_type, PROBLEM_CONTENT_TYPE);
|
||||||
|
let value: Value = serde_json::from_slice(response.body()).unwrap();
|
||||||
|
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("tests/fixtures/startup-session-record-denied.problem.json");
|
||||||
|
let fixture: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
value, fixture,
|
||||||
|
"fixture must be generated by the public production constructor"
|
||||||
|
);
|
||||||
|
let decoded = known(decode(&fixture, 409, StartupBoundary::Browser));
|
||||||
|
assert_eq!(decoded.operation(), failure.operation());
|
||||||
|
assert_eq!(decoded.public_facts(), failure.public_facts());
|
||||||
|
assert_eq!(
|
||||||
|
decoded
|
||||||
|
.startup()
|
||||||
|
.primary
|
||||||
|
.decode::<StartupCause>()
|
||||||
|
.known_cause(),
|
||||||
|
session()
|
||||||
|
);
|
||||||
|
assert!(fixture["detail"].as_str().unwrap().contains("Subject"));
|
||||||
|
assert!(
|
||||||
|
fixture["detail"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("session.record")
|
||||||
|
);
|
||||||
|
assert!(fixture["detail"].as_str().unwrap().contains("Worker"));
|
||||||
|
assert!(fixture["yoi_startup"].get("primary").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Captured from the real signed Session registration → Runtime → Browser tests
|
||||||
|
/// in workspace-server. These are observed operation facts, not a declaration
|
||||||
|
/// that an HTTP conflict rejected creation or that successful cleanup erased effects.
|
||||||
|
#[test]
|
||||||
|
fn connected_browser_fixtures_preserve_observed_operation_and_storage_facts() {
|
||||||
|
for (body, status, cause) in [
|
||||||
|
(
|
||||||
|
include_bytes!("fixtures/startup-browser-session-record-denied.problem.json")
|
||||||
|
.as_slice(),
|
||||||
|
409,
|
||||||
|
SessionRegistrationCause::denied(SessionPrincipal::Subject),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
include_bytes!("fixtures/startup-browser-registration-response-lost.problem.json")
|
||||||
|
.as_slice(),
|
||||||
|
502,
|
||||||
|
SessionRegistrationCause::outcome_unknown(),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let failure = known(decode_startup_problem(
|
||||||
|
body,
|
||||||
|
status,
|
||||||
|
PROBLEM_CONTENT_TYPE,
|
||||||
|
StartupBoundary::Browser,
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
failure
|
||||||
|
.startup()
|
||||||
|
.primary
|
||||||
|
.decode::<StartupCause>()
|
||||||
|
.known_cause(),
|
||||||
|
StartupCause::Session(cause)
|
||||||
|
);
|
||||||
|
assert_eq!(failure.operation().acceptance, CreationAcceptance::Unknown);
|
||||||
|
assert_eq!(failure.operation().execution, ExecutionOutcome::Errored);
|
||||||
|
assert_eq!(
|
||||||
|
failure.operation().backend_compensation,
|
||||||
|
Some(CompensationOutcome::Unknown)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
failure.startup().persistence,
|
||||||
|
Some(StartupPersistenceOutcome::Persisted)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
failure.backend_result_persistence(),
|
||||||
|
Some(StartupPersistenceOutcome::Persisted)
|
||||||
|
);
|
||||||
|
let encoded = failure.encode_storage().unwrap();
|
||||||
|
let DecodeStoredWorkerCreateFailure::Known(saved) =
|
||||||
|
decode_stored_worker_create_failure(&encoded)
|
||||||
|
else {
|
||||||
|
panic!("connected result must survive HTTP-independent storage");
|
||||||
|
};
|
||||||
|
assert_eq!(saved.public_facts(), failure.public_facts());
|
||||||
|
let response = saved
|
||||||
|
.problem(StartupBoundary::Browser, &visible(), "")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status, status);
|
||||||
|
let original: Value = serde_json::from_slice(body).unwrap();
|
||||||
|
let projected: Value = serde_json::from_slice(response.body()).unwrap();
|
||||||
|
assert_eq!(projected["detail"], original["detail"]);
|
||||||
|
assert_eq!(projected["yoi_startup"], original["yoi_startup"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tiny test-only adapter so assertions never format raw received data.
|
||||||
|
trait CauseFromDecode {
|
||||||
|
fn known_cause(self) -> StartupCause;
|
||||||
|
}
|
||||||
|
impl CauseFromDecode for DecodeFailure<StartupCause> {
|
||||||
|
fn known_cause(self) -> StartupCause {
|
||||||
|
match self {
|
||||||
|
DecodeFailure::Known(received) => received.facts().cause().clone(),
|
||||||
|
_ => panic!("expected known cause"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn original_domain_wire_identity_and_details_have_no_union_wrapper() {
|
||||||
|
for cause in [
|
||||||
|
session(),
|
||||||
|
StartupCause::Runtime(RuntimeStartupCause {
|
||||||
|
stage: StartupStage::Binding,
|
||||||
|
reason: RuntimeStartupReason::AttributionFailed,
|
||||||
|
}),
|
||||||
|
StartupCause::Boundary(StartupBoundaryCause {
|
||||||
|
reason: StartupBoundaryReason::Transport,
|
||||||
|
}),
|
||||||
|
] {
|
||||||
|
let response = failure(cause.clone())
|
||||||
|
.problem(StartupBoundary::Browser, &visible(), "")
|
||||||
|
.unwrap();
|
||||||
|
let value: Value = serde_json::from_slice(response.body()).unwrap();
|
||||||
|
let id = cause.identity();
|
||||||
|
assert_eq!(value["yoi_failure"]["domain"], id.domain);
|
||||||
|
assert_eq!(value["yoi_failure"]["reason"], id.reason);
|
||||||
|
assert_eq!(value["yoi_failure"]["details_version"], id.details_version);
|
||||||
|
assert_eq!(
|
||||||
|
value["yoi_failure"]["details"],
|
||||||
|
serde_json::to_value(&cause).unwrap()
|
||||||
|
);
|
||||||
|
assert!(value["yoi_failure"]["details"].get("Session").is_none());
|
||||||
|
assert!(StartupCause::supports(
|
||||||
|
id.domain,
|
||||||
|
id.reason,
|
||||||
|
id.details_version
|
||||||
|
));
|
||||||
|
let decoded = known(decode(&value, response.status, StartupBoundary::Browser));
|
||||||
|
assert_eq!(
|
||||||
|
decoded
|
||||||
|
.startup()
|
||||||
|
.primary
|
||||||
|
.decode::<StartupCause>()
|
||||||
|
.known_cause(),
|
||||||
|
cause
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn browser_session_projection_requires_current_visibility_even_after_receipt() {
|
||||||
|
let failure = failure(session());
|
||||||
|
assert_eq!(
|
||||||
|
failure
|
||||||
|
.problem(
|
||||||
|
StartupBoundary::Browser,
|
||||||
|
&SessionFailureVisibility::default(),
|
||||||
|
""
|
||||||
|
)
|
||||||
|
.unwrap_err(),
|
||||||
|
ContractError::InvalidDetails
|
||||||
|
);
|
||||||
|
let wire = failure
|
||||||
|
.problem(StartupBoundary::Runtime, &visible(), "")
|
||||||
|
.unwrap();
|
||||||
|
let received = known(decode_startup_problem(
|
||||||
|
wire.body(),
|
||||||
|
wire.status,
|
||||||
|
wire.content_type,
|
||||||
|
StartupBoundary::Runtime,
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
received
|
||||||
|
.problem(
|
||||||
|
StartupBoundary::Browser,
|
||||||
|
&SessionFailureVisibility::default(),
|
||||||
|
""
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
received
|
||||||
|
.problem(StartupBoundary::Browser, &visible(), "")
|
||||||
|
.unwrap()
|
||||||
|
.status,
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn boundary_classification_is_explicit_not_category_text_or_inner_status() {
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
StartupCause::Session(SessionRegistrationCause::denied(SessionPrincipal::Operator)),
|
||||||
|
403,
|
||||||
|
403,
|
||||||
|
),
|
||||||
|
(session(), 409, 403),
|
||||||
|
(
|
||||||
|
StartupCause::Session(SessionRegistrationCause::attribution_missing()),
|
||||||
|
409,
|
||||||
|
409,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
StartupCause::Session(SessionRegistrationCause::binding_mismatch()),
|
||||||
|
409,
|
||||||
|
409,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
StartupCause::Session(SessionRegistrationCause::transport()),
|
||||||
|
502,
|
||||||
|
502,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
StartupCause::Session(SessionRegistrationCause::outcome_unknown()),
|
||||||
|
502,
|
||||||
|
502,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
StartupCause::Session(SessionRegistrationCause::invalid_response()),
|
||||||
|
502,
|
||||||
|
502,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (cause, browser, runtime) in cases {
|
||||||
|
assert_eq!(
|
||||||
|
StartupBoundary::Browser.classify(&cause).status.code(),
|
||||||
|
browser
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
StartupBoundary::Runtime.classify(&cause).status.code(),
|
||||||
|
runtime
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (reason, status) in [
|
||||||
|
(StartupBoundaryReason::Timeout, 504),
|
||||||
|
(StartupBoundaryReason::Transport, 502),
|
||||||
|
(StartupBoundaryReason::Decode, 502),
|
||||||
|
(StartupBoundaryReason::InvalidResponse, 502),
|
||||||
|
(StartupBoundaryReason::TooLarge, 502),
|
||||||
|
(StartupBoundaryReason::UnsupportedVersion, 502),
|
||||||
|
(StartupBoundaryReason::UnsupportedReason, 502),
|
||||||
|
(StartupBoundaryReason::FinalizeFailed, 500),
|
||||||
|
] {
|
||||||
|
let cause = StartupCause::Boundary(StartupBoundaryCause { reason });
|
||||||
|
for boundary in [StartupBoundary::Browser, StartupBoundary::Runtime] {
|
||||||
|
assert_eq!(boundary.classify(&cause).status.code(), status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (reason, status) in [
|
||||||
|
(RuntimeStartupReason::SegmentEmpty, 400),
|
||||||
|
(RuntimeStartupReason::SystemPromptMissing, 400),
|
||||||
|
(RuntimeStartupReason::MetadataPending, 409),
|
||||||
|
(RuntimeStartupReason::MetadataMissing, 409),
|
||||||
|
(RuntimeStartupReason::MetadataInactive, 409),
|
||||||
|
(RuntimeStartupReason::ManifestMissing, 409),
|
||||||
|
(RuntimeStartupReason::AllocationFailed, 503),
|
||||||
|
(RuntimeStartupReason::SessionReadFailed, 500),
|
||||||
|
(RuntimeStartupReason::MetadataReadFailed, 500),
|
||||||
|
(RuntimeStartupReason::ProviderFailed, 500),
|
||||||
|
(RuntimeStartupReason::AttributionFailed, 500),
|
||||||
|
(RuntimeStartupReason::FeatureFailed, 500),
|
||||||
|
(RuntimeStartupReason::StartupFailed, 500),
|
||||||
|
] {
|
||||||
|
let cause = StartupCause::Runtime(RuntimeStartupCause {
|
||||||
|
stage: StartupStage::Controller,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
for boundary in [StartupBoundary::Browser, StartupBoundary::Runtime] {
|
||||||
|
assert_eq!(boundary.classify(&cause).status.code(), status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_operation_facts_remain_independent_including_unknown_and_finalize() {
|
||||||
|
for acceptance in [
|
||||||
|
CreationAcceptance::Rejected,
|
||||||
|
CreationAcceptance::Accepted,
|
||||||
|
CreationAcceptance::Unknown,
|
||||||
|
] {
|
||||||
|
for execution in [
|
||||||
|
ExecutionOutcome::Accepted,
|
||||||
|
ExecutionOutcome::Busy,
|
||||||
|
ExecutionOutcome::Rejected,
|
||||||
|
ExecutionOutcome::Errored,
|
||||||
|
ExecutionOutcome::Unsupported,
|
||||||
|
ExecutionOutcome::Unknown,
|
||||||
|
] {
|
||||||
|
for compensation in [
|
||||||
|
CompensationOutcome::Completed,
|
||||||
|
CompensationOutcome::Residual,
|
||||||
|
CompensationOutcome::Unknown,
|
||||||
|
] {
|
||||||
|
let (startup, _) = fixture_failure().into_parts();
|
||||||
|
let mut operation = operation(acceptance, execution);
|
||||||
|
operation.finalize = Some(FinalizeFacts {
|
||||||
|
stage: FinalizeStage::WorkerRegistry,
|
||||||
|
compensation,
|
||||||
|
});
|
||||||
|
let original = WorkerCreateFailure::new(startup, operation).unwrap();
|
||||||
|
let response = original
|
||||||
|
.problem(StartupBoundary::Browser, &visible(), "")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status, 409); // primary cause only, not outcomes/cleanup
|
||||||
|
let received = known(decode_startup_problem(
|
||||||
|
response.body(),
|
||||||
|
response.status,
|
||||||
|
response.content_type,
|
||||||
|
StartupBoundary::Browser,
|
||||||
|
));
|
||||||
|
assert_eq!(received.public_facts(), original.public_facts());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_unknown_never_becomes_rejected_or_accepted_from_http_or_hints() {
|
||||||
|
let response = failure(StartupCause::Boundary(StartupBoundaryCause {
|
||||||
|
reason: StartupBoundaryReason::Timeout,
|
||||||
|
}))
|
||||||
|
.problem(StartupBoundary::Browser, &visible(), "")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status, 504);
|
||||||
|
let mut value: Value = serde_json::from_slice(response.body()).unwrap();
|
||||||
|
value["retry_after"] = json!(0);
|
||||||
|
value["accepted"] = json!(false); // unrecognized RFC extension is not outcome authority
|
||||||
|
let decoded = known(decode(&value, 504, StartupBoundary::Browser));
|
||||||
|
assert_eq!(decoded.operation().acceptance, CreationAcceptance::Unknown);
|
||||||
|
assert_eq!(decoded.operation().execution, ExecutionOutcome::Unknown);
|
||||||
|
value["yoi_startup"]["operation"]
|
||||||
|
.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.remove("acceptance");
|
||||||
|
assert_eq!(
|
||||||
|
decode(&value, 504, StartupBoundary::Browser),
|
||||||
|
DecodeStartupProblem::Invalid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn received_text_and_context_are_reprojected_not_forwarded() {
|
||||||
|
let original = failure(session())
|
||||||
|
.problem(StartupBoundary::Browser, &visible(), "")
|
||||||
|
.unwrap();
|
||||||
|
let mut value: Value = serde_json::from_slice(original.body()).unwrap();
|
||||||
|
let secret = "RAW_UPSTREAM_CREDENTIAL_OR_PATH";
|
||||||
|
value["detail"] = json!(secret);
|
||||||
|
value["yoi_failure"]["explanation"] = json!(secret);
|
||||||
|
value["yoi_failure"]["context"]["diagnostics"] =
|
||||||
|
json!([{ "code":"raw", "explanation":secret }]);
|
||||||
|
value["yoi_failure"]["context"]["operations"] = json!([
|
||||||
|
{"operation":"session.record","stage":"registration","operation_ref":secret},
|
||||||
|
{"operation":secret,"stage":"raw","operation_ref":secret}]);
|
||||||
|
value["yoi_failure"]["context"]["correlation"] = json!(secret);
|
||||||
|
let received = known(decode(&value, 409, StartupBoundary::Browser));
|
||||||
|
let public = received
|
||||||
|
.problem(StartupBoundary::Browser, &visible(), "")
|
||||||
|
.unwrap();
|
||||||
|
let text = std::str::from_utf8(public.body()).unwrap();
|
||||||
|
assert!(!text.contains(secret));
|
||||||
|
assert!(text.contains("Subject"));
|
||||||
|
assert!(text.contains("session.record"));
|
||||||
|
assert_eq!(public.status, original.status);
|
||||||
|
let (mut startup, operation) = received.into_parts();
|
||||||
|
startup.secondary.reverse();
|
||||||
|
assert_eq!(
|
||||||
|
WorkerCreateFailure::new(startup, operation)
|
||||||
|
.unwrap()
|
||||||
|
.problem(StartupBoundary::Browser, &visible(), "")
|
||||||
|
.unwrap()
|
||||||
|
.status,
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concrete_domain_allowlists_filter_projection_context() {
|
||||||
|
let mut facts = FailureFacts::new(session()).unwrap();
|
||||||
|
let mut allowed = OperationContext::new("session.record", "authorization").unwrap();
|
||||||
|
allowed.operation_ref = Some("DO_NOT_PUBLISH".into());
|
||||||
|
facts.with_context(allowed).unwrap();
|
||||||
|
facts
|
||||||
|
.with_context(OperationContext::new("DO_NOT_PUBLISH", "raw").unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let projected = project_failure::<Browser, _>(&facts, &visible()).unwrap();
|
||||||
|
assert_eq!(projected.context().operations.len(), 1);
|
||||||
|
assert_eq!(projected.context().operations[0].operation_ref, None);
|
||||||
|
assert!(
|
||||||
|
<StartupCause as ProjectFor<InternalTransfer>>::disclose(facts.cause(), &facts, &())
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_version_reason_and_bad_envelopes_are_distinct() {
|
||||||
|
let response = fixture_failure()
|
||||||
|
.problem(StartupBoundary::Browser, &visible(), "")
|
||||||
|
.unwrap();
|
||||||
|
let original: Value = serde_json::from_slice(response.body()).unwrap();
|
||||||
|
for field in ["version", "details_version"] {
|
||||||
|
let mut value = original.clone();
|
||||||
|
value["yoi_failure"][field] = json!(77);
|
||||||
|
assert_eq!(
|
||||||
|
decode(&value, 409, StartupBoundary::Browser),
|
||||||
|
DecodeStartupProblem::UnsupportedVersion
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut value = original.clone();
|
||||||
|
value["yoi_startup"]["version"] = json!(77);
|
||||||
|
assert_eq!(
|
||||||
|
decode(&value, 409, StartupBoundary::Browser),
|
||||||
|
DecodeStartupProblem::UnsupportedVersion
|
||||||
|
);
|
||||||
|
value = original.clone();
|
||||||
|
value["yoi_failure"]["required_extensions"] = json!(["future-contract"]);
|
||||||
|
assert_eq!(
|
||||||
|
decode(&value, 409, StartupBoundary::Browser),
|
||||||
|
DecodeStartupProblem::UnsupportedVersion
|
||||||
|
);
|
||||||
|
value = original.clone();
|
||||||
|
value["yoi_failure"]["reason"] = json!("future_reason");
|
||||||
|
value["yoi_failure"]["category"] = json!("future_category");
|
||||||
|
assert_eq!(
|
||||||
|
decode(&value, 409, StartupBoundary::Browser),
|
||||||
|
DecodeStartupProblem::UnsupportedReason
|
||||||
|
);
|
||||||
|
value = original.clone();
|
||||||
|
value["yoi_failure"]["domain"] = json!("future_domain");
|
||||||
|
assert_eq!(
|
||||||
|
decode(&value, 409, StartupBoundary::Browser),
|
||||||
|
DecodeStartupProblem::UnsupportedReason
|
||||||
|
);
|
||||||
|
for mutate in ["category", "domain", "reason"] {
|
||||||
|
let mut value = original.clone();
|
||||||
|
// Another KNOWN identity with mismatching typed details must be invalid.
|
||||||
|
value["yoi_failure"][mutate] = match mutate {
|
||||||
|
"category" => json!("internal"),
|
||||||
|
"domain" => json!("startup_boundary"),
|
||||||
|
_ => json!("transport"),
|
||||||
|
};
|
||||||
|
let result = decode(&value, 409, StartupBoundary::Browser);
|
||||||
|
if mutate != "domain" {
|
||||||
|
assert_eq!(result, DecodeStartupProblem::Invalid);
|
||||||
|
} else {
|
||||||
|
assert_eq!(result, DecodeStartupProblem::UnsupportedReason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for field in ["version", "details_version"] {
|
||||||
|
let mut value = original.clone();
|
||||||
|
value["yoi_failure"][field] = json!(0);
|
||||||
|
assert_eq!(
|
||||||
|
decode(&value, 409, StartupBoundary::Browser),
|
||||||
|
DecodeStartupProblem::Invalid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decoder_rejects_incoherence_bad_shapes_duplicate_fields_and_limits() {
|
||||||
|
let response = failure(session())
|
||||||
|
.problem(StartupBoundary::Browser, &visible(), "")
|
||||||
|
.unwrap();
|
||||||
|
let original: Value = serde_json::from_slice(response.body()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
decode_startup_problem(
|
||||||
|
response.body(),
|
||||||
|
403,
|
||||||
|
PROBLEM_CONTENT_TYPE,
|
||||||
|
StartupBoundary::Browser
|
||||||
|
),
|
||||||
|
DecodeStartupProblem::Invalid
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_startup_problem(
|
||||||
|
response.body(),
|
||||||
|
409,
|
||||||
|
"application/json",
|
||||||
|
StartupBoundary::Browser
|
||||||
|
),
|
||||||
|
DecodeStartupProblem::Invalid
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
decode_startup_problem(
|
||||||
|
response.body(),
|
||||||
|
409,
|
||||||
|
"Application/Problem+Json; charset=utf-8",
|
||||||
|
StartupBoundary::Browser
|
||||||
|
),
|
||||||
|
DecodeStartupProblem::Known(_)
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
decode_startup_problem(
|
||||||
|
response.body(),
|
||||||
|
409,
|
||||||
|
PROBLEM_CONTENT_TYPE,
|
||||||
|
StartupBoundary::Runtime
|
||||||
|
),
|
||||||
|
DecodeStartupProblem::Invalid
|
||||||
|
);
|
||||||
|
for (field, replacement) in [
|
||||||
|
("status", json!(502)),
|
||||||
|
("type", json!("urn:wrong")),
|
||||||
|
("title", json!("raw upstream")),
|
||||||
|
("detail", json!("different")),
|
||||||
|
("instance", json!("%zz")),
|
||||||
|
] {
|
||||||
|
let mut value = original.clone();
|
||||||
|
value[field] = replacement;
|
||||||
|
assert_eq!(
|
||||||
|
decode(&value, 409, StartupBoundary::Browser),
|
||||||
|
DecodeStartupProblem::Invalid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for field in ["yoi_failure", "yoi_startup"] {
|
||||||
|
let mut value = original.clone();
|
||||||
|
value.as_object_mut().unwrap().remove(field);
|
||||||
|
assert_eq!(
|
||||||
|
decode(&value, 409, StartupBoundary::Browser),
|
||||||
|
DecodeStartupProblem::Invalid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let text = std::str::from_utf8(response.body()).unwrap();
|
||||||
|
for (needle, duplicated) in [
|
||||||
|
("\"status\":409", "\"status\":409,\"status\":409"),
|
||||||
|
(
|
||||||
|
"\"acceptance\":\"unknown\"",
|
||||||
|
"\"acceptance\":\"unknown\",\"acceptance\":\"rejected\"",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"\"principal\":\"subject\"",
|
||||||
|
"\"principal\":\"subject\",\"principal\":\"operator\"",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let duplicate = text.replace(needle, duplicated);
|
||||||
|
assert_ne!(duplicate, text);
|
||||||
|
assert_eq!(
|
||||||
|
decode_startup_problem(
|
||||||
|
duplicate.as_bytes(),
|
||||||
|
409,
|
||||||
|
PROBLEM_CONTENT_TYPE,
|
||||||
|
StartupBoundary::Browser
|
||||||
|
),
|
||||||
|
DecodeStartupProblem::Invalid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let oversized = vec![b' '; MAX_STARTUP_PROBLEM_BYTES + 1];
|
||||||
|
assert_eq!(
|
||||||
|
decode_startup_problem(
|
||||||
|
&oversized,
|
||||||
|
409,
|
||||||
|
PROBLEM_CONTENT_TYPE,
|
||||||
|
StartupBoundary::Browser
|
||||||
|
),
|
||||||
|
DecodeStartupProblem::TooLarge
|
||||||
|
);
|
||||||
|
let deep = format!("{}0{}", "[".repeat(1000), "]".repeat(1000));
|
||||||
|
assert_eq!(
|
||||||
|
decode_startup_problem(
|
||||||
|
deep.as_bytes(),
|
||||||
|
409,
|
||||||
|
PROBLEM_CONTENT_TYPE,
|
||||||
|
StartupBoundary::Browser
|
||||||
|
),
|
||||||
|
DecodeStartupProblem::Invalid
|
||||||
|
);
|
||||||
|
for bytes in [b"not json".as_slice(), b"\xff".as_slice(), b"{}".as_slice()] {
|
||||||
|
assert_eq!(
|
||||||
|
decode_startup_problem(bytes, 409, PROBLEM_CONTENT_TYPE, StartupBoundary::Browser),
|
||||||
|
DecodeStartupProblem::Invalid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn safe_correlation_and_extension_validation_cannot_be_bypassed_by_serde() {
|
||||||
|
for unsafe_value in [
|
||||||
|
"",
|
||||||
|
"path/to/file",
|
||||||
|
"https://host",
|
||||||
|
"Bearer secret",
|
||||||
|
"query?token=secret",
|
||||||
|
"control\n",
|
||||||
|
] {
|
||||||
|
assert!(StartupCorrelation::new(unsafe_value).is_err());
|
||||||
|
assert!(serde_json::from_value::<StartupCorrelation>(json!(unsafe_value)).is_err());
|
||||||
|
}
|
||||||
|
assert!(StartupCorrelation::new("a".repeat(129)).is_err());
|
||||||
|
let failure = fixture_failure();
|
||||||
|
let mut facts = failure.public_facts();
|
||||||
|
facts.secondary = vec![
|
||||||
|
StartupSecondaryFailure::Cleanup {
|
||||||
|
failure: StartupCleanupFailure::Workdir
|
||||||
|
};
|
||||||
|
9
|
||||||
|
];
|
||||||
|
assert!(facts.validate().is_err());
|
||||||
|
let (mut startup, operation) = failure.into_parts();
|
||||||
|
startup.secondary = facts.secondary;
|
||||||
|
assert!(WorkerCreateFailure::new(startup, operation).is_err());
|
||||||
|
let response = fixture_failure()
|
||||||
|
.problem(StartupBoundary::Browser, &visible(), "")
|
||||||
|
.unwrap();
|
||||||
|
let mut value: Value = serde_json::from_slice(response.body()).unwrap();
|
||||||
|
value["yoi_startup"]["operation"]["correlation"] = json!("/private/path");
|
||||||
|
assert_eq!(
|
||||||
|
decode(&value, 409, StartupBoundary::Browser),
|
||||||
|
DecodeStartupProblem::Invalid
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
fixture_failure()
|
||||||
|
.problem(StartupBoundary::Browser, &visible(), "invalid%uri")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_or_corrupt_internal_attachments_are_retained_but_not_publishable() {
|
||||||
|
for raw in ["{invalid SECRET", "{}"] {
|
||||||
|
let primary: StartupFailureAttachment = serde_json::from_value(json!(raw)).unwrap();
|
||||||
|
let failure = WorkerCreateFailure::new(
|
||||||
|
WorkerStartupFailure {
|
||||||
|
primary,
|
||||||
|
secondary: vec![],
|
||||||
|
resources: None,
|
||||||
|
persistence: None,
|
||||||
|
io_kind: None,
|
||||||
|
},
|
||||||
|
operation(CreationAcceptance::Unknown, ExecutionOutcome::Unknown),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
failure
|
||||||
|
.problem(StartupBoundary::Browser, &visible(), "")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(!format!("{failure:?}").contains("SECRET"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_envelopes_still_reject_duplicate_safe_metadata() {
|
||||||
|
let response = failure(session())
|
||||||
|
.problem(StartupBoundary::Browser, &visible(), "")
|
||||||
|
.unwrap();
|
||||||
|
let text = std::str::from_utf8(response.body())
|
||||||
|
.unwrap()
|
||||||
|
.replace("\"version\":1", "\"version\":77");
|
||||||
|
assert_eq!(
|
||||||
|
decode_startup_problem(
|
||||||
|
text.as_bytes(),
|
||||||
|
409,
|
||||||
|
PROBLEM_CONTENT_TYPE,
|
||||||
|
StartupBoundary::Browser
|
||||||
|
),
|
||||||
|
DecodeStartupProblem::UnsupportedVersion
|
||||||
|
);
|
||||||
|
for (needle, duplicate) in [
|
||||||
|
(
|
||||||
|
"\"reason\":\"permission_denied\"",
|
||||||
|
"\"reason\":\"permission_denied\",\"reason\":\"future\"",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"\"domain\":\"session_registration\"",
|
||||||
|
"\"domain\":\"session_registration\",\"domain\":\"future\"",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"\"category\":\"authorization\"",
|
||||||
|
"\"category\":\"authorization\",\"category\":\"future\"",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let bytes = text.replace(needle, duplicate);
|
||||||
|
assert_ne!(bytes, text);
|
||||||
|
assert_eq!(
|
||||||
|
decode_startup_problem(
|
||||||
|
bytes.as_bytes(),
|
||||||
|
409,
|
||||||
|
PROBLEM_CONTENT_TYPE,
|
||||||
|
StartupBoundary::Browser
|
||||||
|
),
|
||||||
|
DecodeStartupProblem::Invalid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backend_compensation_is_independent_of_finalize_runtime_cleanup_and_outcomes() {
|
||||||
|
for backend in [
|
||||||
|
None,
|
||||||
|
Some(CompensationOutcome::Completed),
|
||||||
|
Some(CompensationOutcome::Residual),
|
||||||
|
Some(CompensationOutcome::Unknown),
|
||||||
|
] {
|
||||||
|
for finalize in [
|
||||||
|
None,
|
||||||
|
Some(FinalizeFacts {
|
||||||
|
stage: FinalizeStage::WorkdirRegistry,
|
||||||
|
compensation: CompensationOutcome::Residual,
|
||||||
|
}),
|
||||||
|
] {
|
||||||
|
let (startup, mut operation) = fixture_failure().into_parts();
|
||||||
|
operation.backend_compensation = backend;
|
||||||
|
operation.finalize = finalize;
|
||||||
|
let original = WorkerCreateFailure::new(startup, operation).unwrap();
|
||||||
|
let response = original
|
||||||
|
.problem(StartupBoundary::Browser, &visible(), "")
|
||||||
|
.unwrap();
|
||||||
|
let received = known(decode_startup_problem(
|
||||||
|
response.body(),
|
||||||
|
response.status,
|
||||||
|
response.content_type,
|
||||||
|
StartupBoundary::Browser,
|
||||||
|
));
|
||||||
|
assert_eq!(received.public_facts(), original.public_facts());
|
||||||
|
assert_eq!(received.operation().backend_compensation, backend);
|
||||||
|
assert_eq!(received.operation().finalize, finalize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for stage in [
|
||||||
|
FinalizeStage::TicketAssignmentBind,
|
||||||
|
FinalizeStage::TicketAssignmentCurrent,
|
||||||
|
FinalizeStage::TicketStateAccept,
|
||||||
|
FinalizeStage::WorkdirRegistry,
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<FinalizeStage>(serde_json::to_value(stage).unwrap()).unwrap(),
|
||||||
|
stage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telemetry_uses_typed_context_and_operation_correlation_not_attachment_identity() {
|
||||||
|
use error_contract::{CorrelationRef, Telemetry, observation_fields};
|
||||||
|
let mut facts = FailureFacts::new(session()).unwrap();
|
||||||
|
let mut context = OperationContext::new("session.record", "registration").unwrap();
|
||||||
|
context.operation_ref = Some("PRIVATE_OPERATION_ID".into());
|
||||||
|
facts.with_context(context).unwrap();
|
||||||
|
facts
|
||||||
|
.with_context(OperationContext::new("SECRET_CONTEXT", "SECRET_STAGE").unwrap())
|
||||||
|
.unwrap();
|
||||||
|
facts
|
||||||
|
.set_correlation(CorrelationRef::new("UNTRUSTED_ATTACHMENT_ID").unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let projected = project_failure::<Telemetry, _>(&facts, &()).unwrap();
|
||||||
|
let fields = observation_fields(&projected);
|
||||||
|
assert_eq!(fields.domain, "session_registration");
|
||||||
|
assert_eq!(fields.reason, "permission_denied");
|
||||||
|
assert_eq!(fields.operation, Some("worker_startup"));
|
||||||
|
assert_eq!(fields.stage, Some("binding"));
|
||||||
|
assert_eq!(fields.correlation, Some("UNTRUSTED_ATTACHMENT_ID"));
|
||||||
|
assert!(
|
||||||
|
projected
|
||||||
|
.context()
|
||||||
|
.operations
|
||||||
|
.iter()
|
||||||
|
.all(|op| op.operation_ref.is_none())
|
||||||
|
);
|
||||||
|
|
||||||
|
let transfer = project_failure::<InternalTransfer, _>(&facts, &()).unwrap();
|
||||||
|
let startup = WorkerStartupFailure {
|
||||||
|
primary: StartupFailureAttachment::from_projected(&transfer).unwrap(),
|
||||||
|
secondary: vec![],
|
||||||
|
resources: None,
|
||||||
|
persistence: None,
|
||||||
|
io_kind: None,
|
||||||
|
};
|
||||||
|
let mut operation = operation(CreationAcceptance::Unknown, ExecutionOutcome::Unknown);
|
||||||
|
operation.correlation = Some(StartupCorrelation::new("fresh-backend-correlation").unwrap());
|
||||||
|
let failure = WorkerCreateFailure::new(startup, operation).unwrap();
|
||||||
|
let telemetry = failure.telemetry().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
observation_fields(&telemetry).correlation,
|
||||||
|
Some("fresh-backend-correlation")
|
||||||
|
);
|
||||||
|
assert_eq!(telemetry.identity(), session().identity());
|
||||||
|
let encoded = error_contract::encode_projected_failure(
|
||||||
|
&telemetry,
|
||||||
|
error_contract::Budget::new(32768, 32768),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let text = std::str::from_utf8(encoded.as_bytes()).unwrap();
|
||||||
|
for secret in [
|
||||||
|
"PRIVATE_OPERATION_ID",
|
||||||
|
"SECRET_CONTEXT",
|
||||||
|
"SECRET_STAGE",
|
||||||
|
"UNTRUSTED_ATTACHMENT_ID",
|
||||||
|
] {
|
||||||
|
assert!(!text.contains(secret));
|
||||||
|
}
|
||||||
|
let (startup, mut operation) = failure.into_parts();
|
||||||
|
operation.correlation = None;
|
||||||
|
assert_eq!(
|
||||||
|
observation_fields(
|
||||||
|
&WorkerCreateFailure::new(startup, operation)
|
||||||
|
.unwrap()
|
||||||
|
.telemetry()
|
||||||
|
.unwrap()
|
||||||
|
)
|
||||||
|
.correlation,
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_dto_schema_is_accessible_for_generator() {
|
||||||
|
let schema = schemars::schema_for!(StartupProblemDetails);
|
||||||
|
let value = serde_json::to_value(schema).unwrap();
|
||||||
|
for field in [
|
||||||
|
"type",
|
||||||
|
"title",
|
||||||
|
"status",
|
||||||
|
"detail",
|
||||||
|
"instance",
|
||||||
|
"yoi_failure",
|
||||||
|
"yoi_startup",
|
||||||
|
] {
|
||||||
|
assert!(value["properties"].get(field).is_some(), "{field}");
|
||||||
|
}
|
||||||
|
let schema_text = serde_json::to_string(&value).unwrap();
|
||||||
|
assert!(!schema_text.contains("StartupFailureAttachment"));
|
||||||
|
assert!(schema_text.contains("CreationAcceptance"));
|
||||||
|
assert!(schema_text.contains("ExecutionOutcome"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
//! Actual generated RuntimeApi client error hook with bounded HTTP metadata/bytes.
|
||||||
|
use api_macros::HttpError as _;
|
||||||
|
use error_contract::{DecodeFailure, FailureCause};
|
||||||
|
use protocol::session_failure::{SessionPrincipal, SessionRegistrationCause};
|
||||||
|
use runtime_api::startup::*;
|
||||||
|
use runtime_api::{RuntimeApiClient, RuntimeApiError, client_support, server_support};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
fn failure() -> WorkerCreateFailure {
|
||||||
|
WorkerCreateFailure::from_cause(
|
||||||
|
StartupCause::Session(SessionRegistrationCause::denied(SessionPrincipal::Subject)),
|
||||||
|
StartupOperationFacts {
|
||||||
|
acceptance: CreationAcceptance::Accepted,
|
||||||
|
execution: ExecutionOutcome::Errored,
|
||||||
|
finalize: Some(FinalizeFacts {
|
||||||
|
stage: FinalizeStage::TicketAssignmentBind,
|
||||||
|
compensation: CompensationOutcome::Unknown,
|
||||||
|
}),
|
||||||
|
backend_compensation: Some(CompensationOutcome::Residual),
|
||||||
|
correlation: Some(StartupCorrelation::new("backend-local-812").unwrap()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
fn cause(error: &RuntimeApiError) -> StartupCause {
|
||||||
|
match error
|
||||||
|
.create_failure()
|
||||||
|
.unwrap()
|
||||||
|
.startup()
|
||||||
|
.primary
|
||||||
|
.decode::<StartupCause>()
|
||||||
|
{
|
||||||
|
DecodeFailure::Known(received) => received.facts().cause().clone(),
|
||||||
|
_ => panic!("expected locally typed cause"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn assert_unknown(error: &RuntimeApiError, reason: StartupBoundaryReason) {
|
||||||
|
let operation = error.create_failure().unwrap().operation();
|
||||||
|
assert_eq!(operation.acceptance, CreationAcceptance::Unknown);
|
||||||
|
assert_eq!(operation.execution, ExecutionOutcome::Unknown);
|
||||||
|
assert_eq!(operation.finalize, None);
|
||||||
|
assert_eq!(operation.backend_compensation, None);
|
||||||
|
assert_eq!(
|
||||||
|
cause(error),
|
||||||
|
StartupCause::Boundary(StartupBoundaryCause { reason })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn receive(status: u16, types: Vec<&'static str>, body: Vec<u8>) -> RuntimeApiError {
|
||||||
|
use server_support::framework::{Body, Response, Router, get};
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let router = Router::new().route(
|
||||||
|
"/v1/workers/{worker_id}",
|
||||||
|
get(move || {
|
||||||
|
let types = types.clone();
|
||||||
|
let body = body.clone();
|
||||||
|
async move {
|
||||||
|
let mut response = Response::builder().status(status);
|
||||||
|
for content_type in types {
|
||||||
|
response = response.header("content-type", content_type);
|
||||||
|
}
|
||||||
|
response.body(Body::from(body)).unwrap()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
server_support::framework::serve(listener, router)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
let client = RuntimeApiClient::builder(&format!("http://{address}"))
|
||||||
|
.unwrap()
|
||||||
|
.request_timeout(std::time::Duration::from_secs(3))
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let result = client.get_worker("test".into()).await;
|
||||||
|
server.abort();
|
||||||
|
match result {
|
||||||
|
Err(client_support::ClientError::Public {
|
||||||
|
status: actual,
|
||||||
|
error,
|
||||||
|
}) => {
|
||||||
|
assert_eq!(actual.as_u16(), status);
|
||||||
|
assert_eq!(error.status(), status);
|
||||||
|
error
|
||||||
|
}
|
||||||
|
_ => panic!("expected normalized public error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_error_projects_public_schema_fresh_and_keeps_local_facts_off_serde() {
|
||||||
|
let failure = failure();
|
||||||
|
let error = RuntimeApiError::new(500, "PRIVATE_CODE", "PRIVATE_MESSAGE")
|
||||||
|
.with_create_failure(failure.clone());
|
||||||
|
let legacy = serde_json::to_value(&error).unwrap();
|
||||||
|
assert!(legacy.get("create_failure").is_none());
|
||||||
|
let reparsed: RuntimeApiError = serde_json::from_value(legacy).unwrap();
|
||||||
|
assert!(reparsed.create_failure().is_none());
|
||||||
|
let response = error.custom_response().unwrap();
|
||||||
|
assert_eq!(response.status, 403); // runtime boundary is not Browser's 409
|
||||||
|
assert_eq!(response.content_type, PROBLEM_CONTENT_TYPE);
|
||||||
|
let text = std::str::from_utf8(response.body.as_ref()).unwrap();
|
||||||
|
assert!(!text.contains("PRIVATE_CODE"));
|
||||||
|
assert!(!text.contains("PRIVATE_MESSAGE"));
|
||||||
|
let decoded = match decode_startup_problem(
|
||||||
|
response.body.as_ref(),
|
||||||
|
response.status,
|
||||||
|
response.content_type,
|
||||||
|
StartupBoundary::Runtime,
|
||||||
|
) {
|
||||||
|
DecodeStartupProblem::Known(failure) => failure,
|
||||||
|
_ => panic!("expected known public-safe Runtime failure"),
|
||||||
|
};
|
||||||
|
assert_eq!(decoded.public_facts(), failure.public_facts());
|
||||||
|
assert_eq!(error.into_create_failure(), Some(failure));
|
||||||
|
let ordinary = RuntimeApiError::new(404, "not_found", "not found");
|
||||||
|
assert!(ordinary.custom_response().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_projection_uses_static_fallback_not_legacy_raw_serialization() {
|
||||||
|
let (mut startup, operation) = failure().into_parts();
|
||||||
|
startup.primary = serde_json::from_value(json!("RAW_PRIVATE_CREDENTIAL")).unwrap();
|
||||||
|
let error = RuntimeApiError::new(500, "PRIVATE_CODE", "PRIVATE_MESSAGE")
|
||||||
|
.with_create_failure(WorkerCreateFailure::new(startup, operation).unwrap());
|
||||||
|
let response = error.custom_response().unwrap();
|
||||||
|
assert_eq!(response.status, 500);
|
||||||
|
assert_eq!(
|
||||||
|
response.body.as_ref(),
|
||||||
|
error_contract::problem::STATIC_PROBLEM_FALLBACK
|
||||||
|
);
|
||||||
|
let mut legacy = RuntimeApiError::new(500, "PRIVATE_CODE", "PRIVATE_MESSAGE");
|
||||||
|
legacy.error.startup = error
|
||||||
|
.create_failure()
|
||||||
|
.map(|failure| failure.startup().clone());
|
||||||
|
assert_eq!(
|
||||||
|
legacy.custom_response().unwrap().body.as_ref(),
|
||||||
|
error_contract::problem::STATIC_PROBLEM_FALLBACK
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn generated_client_normalizes_known_problem_and_actual_metadata() {
|
||||||
|
let original = failure();
|
||||||
|
let response = RuntimeApiError::new(500, "PRIVATE_CODE", "PRIVATE_MESSAGE")
|
||||||
|
.with_create_failure(original.clone())
|
||||||
|
.custom_response()
|
||||||
|
.unwrap();
|
||||||
|
let mut value: Value = serde_json::from_slice(response.body.as_ref()).unwrap();
|
||||||
|
value["detail"] = json!("WIRE_SECRET_EXPLANATION");
|
||||||
|
value["yoi_failure"]["explanation"] = json!("WIRE_SECRET_EXPLANATION");
|
||||||
|
let error = receive(
|
||||||
|
response.status,
|
||||||
|
vec![PROBLEM_CONTENT_TYPE],
|
||||||
|
serde_json::to_vec(&value).unwrap(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
error.create_failure().unwrap().public_facts(),
|
||||||
|
original.public_facts()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cause(&error).identity(),
|
||||||
|
StartupCause::Session(SessionRegistrationCause::denied(SessionPrincipal::Subject))
|
||||||
|
.identity()
|
||||||
|
);
|
||||||
|
assert!(!error.error.message.contains("WIRE_SECRET"));
|
||||||
|
let browser = error
|
||||||
|
.create_failure()
|
||||||
|
.unwrap()
|
||||||
|
.problem(
|
||||||
|
StartupBoundary::Browser,
|
||||||
|
&SessionFailureVisibility {
|
||||||
|
can_view_cause: true,
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(browser.status, 409);
|
||||||
|
let wrong_status = receive(
|
||||||
|
409,
|
||||||
|
vec![PROBLEM_CONTENT_TYPE],
|
||||||
|
serde_json::to_vec(&value).unwrap(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_unknown(&wrong_status, StartupBoundaryReason::InvalidResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unknown_versions_reasons_and_invalid_responses_remain_local_unknown_observations() {
|
||||||
|
let response = failure()
|
||||||
|
.problem(
|
||||||
|
StartupBoundary::Runtime,
|
||||||
|
&SessionFailureVisibility {
|
||||||
|
can_view_cause: true,
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let original: Value = serde_json::from_slice(response.body()).unwrap();
|
||||||
|
for (field, replacement, reason) in [
|
||||||
|
(
|
||||||
|
"version",
|
||||||
|
json!(99),
|
||||||
|
StartupBoundaryReason::UnsupportedVersion,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"details_version",
|
||||||
|
json!(99),
|
||||||
|
StartupBoundaryReason::UnsupportedVersion,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"reason",
|
||||||
|
json!("future_reason"),
|
||||||
|
StartupBoundaryReason::UnsupportedReason,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"category",
|
||||||
|
json!("invalid_category"),
|
||||||
|
StartupBoundaryReason::InvalidResponse,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let mut value = original.clone();
|
||||||
|
value["yoi_failure"][field] = replacement;
|
||||||
|
let error = receive(
|
||||||
|
403,
|
||||||
|
vec![PROBLEM_CONTENT_TYPE],
|
||||||
|
serde_json::to_vec(&value).unwrap(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_unknown(&error, reason);
|
||||||
|
}
|
||||||
|
for content_types in [
|
||||||
|
vec![],
|
||||||
|
vec!["text/plain"],
|
||||||
|
vec![PROBLEM_CONTENT_TYPE, "application/json"],
|
||||||
|
] {
|
||||||
|
let error = receive(403, content_types, response.body().to_vec()).await;
|
||||||
|
assert_unknown(&error, StartupBoundaryReason::InvalidResponse);
|
||||||
|
}
|
||||||
|
let error = receive(
|
||||||
|
502,
|
||||||
|
vec![PROBLEM_CONTENT_TYPE],
|
||||||
|
vec![b'x'; MAX_STARTUP_PROBLEM_BYTES + 1],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_unknown(&error, StartupBoundaryReason::TooLarge);
|
||||||
|
let error = receive(
|
||||||
|
502,
|
||||||
|
vec![PROBLEM_CONTENT_TYPE],
|
||||||
|
b"not JSON PRIVATE_SECRET".to_vec(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_unknown(&error, StartupBoundaryReason::InvalidResponse);
|
||||||
|
assert!(!error.error.message.contains("PRIVATE_SECRET"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn legacy_errors_are_bounded_and_startup_attachments_reprojected_without_outcome_inference() {
|
||||||
|
let ordinary = RuntimeApiError::new(404, "old_not_found", "old not found");
|
||||||
|
let error = receive(
|
||||||
|
404,
|
||||||
|
vec!["application/json"],
|
||||||
|
serde_json::to_vec(&ordinary).unwrap(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(error.error, ordinary.error);
|
||||||
|
assert!(error.create_failure().is_none());
|
||||||
|
let original = failure();
|
||||||
|
let mut legacy = RuntimeApiError::new(502, "OLD_PRIVATE_CODE", "OLD_PRIVATE_MESSAGE");
|
||||||
|
legacy.error.startup = Some(original.startup().clone());
|
||||||
|
let error = receive(
|
||||||
|
502,
|
||||||
|
vec!["application/json; charset=utf-8"],
|
||||||
|
serde_json::to_vec(&legacy).unwrap(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
cause(&error).identity(),
|
||||||
|
cause(&RuntimeApiError::new(500, "unused", "unused").with_create_failure(original.clone()))
|
||||||
|
.identity()
|
||||||
|
);
|
||||||
|
let failure = error.create_failure().unwrap();
|
||||||
|
assert_eq!(failure.operation().acceptance, CreationAcceptance::Unknown);
|
||||||
|
assert_eq!(failure.operation().execution, ExecutionOutcome::Unknown);
|
||||||
|
assert_eq!(failure.startup().secondary, original.startup().secondary);
|
||||||
|
assert!(!error.error.message.contains("OLD_PRIVATE"));
|
||||||
|
let mut value = serde_json::to_value(&legacy).unwrap();
|
||||||
|
value["error"]["startup"]["primary"] = json!("PRIVATE_MALFORMED_ATTACHMENT");
|
||||||
|
let malformed = receive(
|
||||||
|
502,
|
||||||
|
vec!["application/json"],
|
||||||
|
serde_json::to_vec(&value).unwrap(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_unknown(&malformed, StartupBoundaryReason::InvalidResponse);
|
||||||
|
let invalid = receive(
|
||||||
|
502,
|
||||||
|
vec!["application/json"],
|
||||||
|
b"{invalid PRIVATE_SECRET".to_vec(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_unknown(&invalid, StartupBoundaryReason::Decode);
|
||||||
|
let oversized = receive(
|
||||||
|
502,
|
||||||
|
vec!["application/json"],
|
||||||
|
vec![b'x'; MAX_STARTUP_PROBLEM_BYTES + 1],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_unknown(&oversized, StartupBoundaryReason::TooLarge);
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
//! Storage codecs use the production attachment projector, never an HTTP body.
|
||||||
|
use error_contract::{Budget, ContractError, DecodeFailure, validate_json_input};
|
||||||
|
use protocol::session_failure::{SessionPrincipal, SessionRegistrationCause};
|
||||||
|
use runtime_api::startup::*;
|
||||||
|
use runtime_api::{
|
||||||
|
RuntimeStartupCause, RuntimeStartupReason, StartupCleanup, StartupCleanupFailure,
|
||||||
|
StartupFailureAttachment, StartupIoKind, StartupPersistenceOutcome, StartupResourceFacts,
|
||||||
|
StartupResources, StartupSecondaryFailure, StartupStage, WorkerStartupFailure,
|
||||||
|
};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
fn operation() -> StartupOperationFacts {
|
||||||
|
StartupOperationFacts {
|
||||||
|
acceptance: CreationAcceptance::Accepted,
|
||||||
|
execution: ExecutionOutcome::Errored,
|
||||||
|
finalize: Some(FinalizeFacts {
|
||||||
|
stage: FinalizeStage::Publication,
|
||||||
|
compensation: CompensationOutcome::Residual,
|
||||||
|
}),
|
||||||
|
backend_compensation: Some(CompensationOutcome::Unknown),
|
||||||
|
correlation: Some(StartupCorrelation::new("storage-test-812").unwrap()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failure(cause: StartupCause) -> WorkerCreateFailure {
|
||||||
|
let base = WorkerCreateFailure::from_cause(cause, operation()).unwrap();
|
||||||
|
let (mut startup, operation) = base.into_parts();
|
||||||
|
startup.secondary.push(StartupSecondaryFailure::Cleanup {
|
||||||
|
failure: StartupCleanupFailure::Workdir,
|
||||||
|
});
|
||||||
|
startup.resources = Some(StartupResourceFacts {
|
||||||
|
resources: StartupResources::WorkerStartup,
|
||||||
|
cleanup: StartupCleanup::Failed(StartupCleanupFailure::Workdir),
|
||||||
|
durable_effects_possible: true,
|
||||||
|
});
|
||||||
|
// Session metadata write and Backend result write deliberately disagree.
|
||||||
|
startup.persistence = Some(StartupPersistenceOutcome::NotSaved);
|
||||||
|
startup.io_kind = Some(StartupIoKind::PermissionDenied);
|
||||||
|
WorkerCreateFailure::new(startup, operation)
|
||||||
|
.unwrap()
|
||||||
|
.with_backend_result_persistence(Some(StartupPersistenceOutcome::Persisted))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session() -> StartupCause {
|
||||||
|
StartupCause::Session(SessionRegistrationCause::denied(SessionPrincipal::Subject))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn known(input: &str) -> WorkerCreateFailure {
|
||||||
|
match decode_stored_worker_create_failure(input) {
|
||||||
|
DecodeStoredWorkerCreateFailure::Known(failure) => failure,
|
||||||
|
other => panic!("expected known storage snapshot, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn change_primary(value: &mut Value, change: impl FnOnce(&mut Value)) {
|
||||||
|
let mut primary: Value = serde_json::from_str(value["primary"].as_str().unwrap()).unwrap();
|
||||||
|
change(&mut primary);
|
||||||
|
value["primary"] = Value::String(serde_json::to_string(&primary).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn storage_semantic_roundtrip_covers_domains_and_independent_facts() {
|
||||||
|
for cause in [
|
||||||
|
session(),
|
||||||
|
StartupCause::Runtime(RuntimeStartupCause {
|
||||||
|
stage: StartupStage::Feature,
|
||||||
|
reason: RuntimeStartupReason::FeatureFailed,
|
||||||
|
}),
|
||||||
|
StartupCause::Boundary(StartupBoundaryCause {
|
||||||
|
reason: StartupBoundaryReason::FinalizeFailed,
|
||||||
|
}),
|
||||||
|
] {
|
||||||
|
let original = failure(cause.clone());
|
||||||
|
let encoded = original.encode_storage().unwrap();
|
||||||
|
let value: Value = serde_json::from_str(&encoded).unwrap();
|
||||||
|
assert_eq!(value["version"], 1);
|
||||||
|
assert!(value.get("status").is_none());
|
||||||
|
assert!(value.get("yoi_failure").is_none());
|
||||||
|
let loaded = known(&encoded);
|
||||||
|
assert_eq!(loaded.public_facts(), original.public_facts());
|
||||||
|
assert_eq!(loaded.operation(), original.operation());
|
||||||
|
assert_eq!(
|
||||||
|
loaded.startup().persistence,
|
||||||
|
Some(StartupPersistenceOutcome::NotSaved)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
loaded.backend_result_persistence(),
|
||||||
|
Some(StartupPersistenceOutcome::Persisted)
|
||||||
|
);
|
||||||
|
match loaded.startup().primary.decode::<StartupCause>() {
|
||||||
|
DecodeFailure::Known(received) => assert_eq!(received.facts().cause(), &cause),
|
||||||
|
_ => panic!("primary cause lost"),
|
||||||
|
}
|
||||||
|
assert_eq!(loaded.encode_storage().unwrap(), encoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encoding_uses_current_marker_without_replacing_primary_or_inferring_persistence() {
|
||||||
|
let original = failure(session());
|
||||||
|
for marker in [
|
||||||
|
None,
|
||||||
|
Some(StartupPersistenceOutcome::Persisted),
|
||||||
|
Some(StartupPersistenceOutcome::NotSaved),
|
||||||
|
Some(StartupPersistenceOutcome::Conflict),
|
||||||
|
Some(StartupPersistenceOutcome::OutcomeUnknown),
|
||||||
|
] {
|
||||||
|
let updated = original.clone().with_backend_result_persistence(marker);
|
||||||
|
assert_eq!(updated.startup(), original.startup());
|
||||||
|
assert_eq!(updated.operation(), original.operation());
|
||||||
|
let loaded = known(&updated.encode_storage().unwrap());
|
||||||
|
assert_eq!(loaded.backend_result_persistence(), marker);
|
||||||
|
assert_eq!(loaded.startup().persistence, original.startup().persistence);
|
||||||
|
assert_eq!(loaded.public_facts(), updated.public_facts());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_absent_marker_is_none_in_storage_and_public_reader() {
|
||||||
|
let original = failure(session()).with_backend_result_persistence(None);
|
||||||
|
let mut value: Value = serde_json::from_str(&original.encode_storage().unwrap()).unwrap();
|
||||||
|
value["facts"]
|
||||||
|
.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.remove("backend_result_persistence");
|
||||||
|
assert_eq!(known(&value.to_string()).backend_result_persistence(), None);
|
||||||
|
let response = original
|
||||||
|
.problem(
|
||||||
|
StartupBoundary::Browser,
|
||||||
|
&SessionFailureVisibility {
|
||||||
|
can_view_cause: true,
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let public: Value = serde_json::from_slice(response.body()).unwrap();
|
||||||
|
assert!(
|
||||||
|
public["yoi_startup"]
|
||||||
|
.get("backend_result_persistence")
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
match decode_startup_problem(
|
||||||
|
response.body(),
|
||||||
|
response.status,
|
||||||
|
response.content_type,
|
||||||
|
StartupBoundary::Browser,
|
||||||
|
) {
|
||||||
|
DecodeStartupProblem::Known(failure) => {
|
||||||
|
assert_eq!(failure.backend_result_persistence(), None)
|
||||||
|
}
|
||||||
|
other => panic!("legacy public response failed: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_receiver_restores_backend_marker_and_current_visibility_is_required() {
|
||||||
|
let original = failure(session());
|
||||||
|
let loaded = known(&original.encode_storage().unwrap());
|
||||||
|
assert!(
|
||||||
|
loaded
|
||||||
|
.problem(
|
||||||
|
StartupBoundary::Browser,
|
||||||
|
&SessionFailureVisibility {
|
||||||
|
can_view_cause: false
|
||||||
|
},
|
||||||
|
""
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let response = loaded
|
||||||
|
.problem(
|
||||||
|
StartupBoundary::Browser,
|
||||||
|
&SessionFailureVisibility {
|
||||||
|
can_view_cause: true,
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let received = match decode_startup_problem(
|
||||||
|
response.body(),
|
||||||
|
response.status,
|
||||||
|
response.content_type,
|
||||||
|
StartupBoundary::Browser,
|
||||||
|
) {
|
||||||
|
DecodeStartupProblem::Known(failure) => failure,
|
||||||
|
other => panic!("public response failed: {other:?}"),
|
||||||
|
};
|
||||||
|
assert_eq!(received.public_facts(), original.public_facts());
|
||||||
|
let mut updated = operation();
|
||||||
|
updated.acceptance = CreationAcceptance::Unknown;
|
||||||
|
let received = received.with_operation(updated.clone());
|
||||||
|
assert_eq!(received.operation(), &updated);
|
||||||
|
assert_eq!(
|
||||||
|
received.backend_result_persistence(),
|
||||||
|
Some(StartupPersistenceOutcome::Persisted)
|
||||||
|
);
|
||||||
|
// The receiving Backend clears remote evidence before its own write.
|
||||||
|
assert_eq!(
|
||||||
|
received
|
||||||
|
.with_backend_result_persistence(None)
|
||||||
|
.backend_result_persistence(),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_versions_and_primary_identities_are_unsupported() {
|
||||||
|
let base: Value = serde_json::from_str(&failure(session()).encode_storage().unwrap()).unwrap();
|
||||||
|
for mutate in [
|
||||||
|
|v: &mut Value| v["version"] = json!(2),
|
||||||
|
|v: &mut Value| v["facts"]["version"] = json!(2),
|
||||||
|
|v: &mut Value| change_primary(v, |p| p["version"] = json!(2)),
|
||||||
|
|v: &mut Value| change_primary(v, |p| p["reason"] = json!("future_reason")),
|
||||||
|
|v: &mut Value| {
|
||||||
|
change_primary(v, |p| {
|
||||||
|
p["required_extensions"] = json!(["future_extension"])
|
||||||
|
})
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
let mut value = base.clone();
|
||||||
|
mutate(&mut value);
|
||||||
|
assert_eq!(
|
||||||
|
decode_stored_worker_create_failure(&value.to_string()),
|
||||||
|
DecodeStoredWorkerCreateFailure::Unsupported
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_and_duplicate_known_records_are_corrupt() {
|
||||||
|
let base: Value = serde_json::from_str(&failure(session()).encode_storage().unwrap()).unwrap();
|
||||||
|
for input in [
|
||||||
|
"".to_owned(),
|
||||||
|
"null".to_owned(),
|
||||||
|
"{}".to_owned(),
|
||||||
|
"{".to_owned(),
|
||||||
|
base.to_string()
|
||||||
|
.replacen("\"version\":1", "\"version\":1,\"version\":1", 1),
|
||||||
|
] {
|
||||||
|
assert!(matches!(
|
||||||
|
decode_stored_worker_create_failure(&input),
|
||||||
|
DecodeStoredWorkerCreateFailure::Corrupt(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for mutate in [
|
||||||
|
|v: &mut Value| v["version"] = json!(0),
|
||||||
|
|v: &mut Value| v["primary"] = json!("{broken"),
|
||||||
|
|v: &mut Value| v["facts"]["backend_result_persistence"] = json!("invented"),
|
||||||
|
|v: &mut Value| {
|
||||||
|
v["facts"]["secondary"] = json!(vec![json!({"kind":"cleanup","failure":"workdir"}); 9])
|
||||||
|
},
|
||||||
|
|v: &mut Value| change_primary(v, |p| p["category"] = json!("internal")),
|
||||||
|
] {
|
||||||
|
let mut value = base.clone();
|
||||||
|
mutate(&mut value);
|
||||||
|
assert!(matches!(
|
||||||
|
decode_stored_worker_create_failure(&value.to_string()),
|
||||||
|
DecodeStoredWorkerCreateFailure::Corrupt(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn complete_input_and_embedded_attachment_are_bounded_before_parsing() {
|
||||||
|
let oversized = " ".repeat(32 * 1024 + 1);
|
||||||
|
assert_eq!(
|
||||||
|
decode_stored_worker_create_failure(&oversized),
|
||||||
|
DecodeStoredWorkerCreateFailure::Corrupt(ContractError::TooLarge)
|
||||||
|
);
|
||||||
|
let deep = format!("{}0{}", "[".repeat(17), "]".repeat(17));
|
||||||
|
assert_eq!(
|
||||||
|
decode_stored_worker_create_failure(&deep),
|
||||||
|
DecodeStoredWorkerCreateFailure::Corrupt(ContractError::TooDeep)
|
||||||
|
);
|
||||||
|
// A supported-looking prefix must not bypass the whole-input guard.
|
||||||
|
let deep = format!("{{\"version\":2,\"extra\":{deep}}}");
|
||||||
|
assert_eq!(
|
||||||
|
decode_stored_worker_create_failure(&deep),
|
||||||
|
DecodeStoredWorkerCreateFailure::Corrupt(ContractError::TooDeep)
|
||||||
|
);
|
||||||
|
let mut value: Value =
|
||||||
|
serde_json::from_str(&failure(session()).encode_storage().unwrap()).unwrap();
|
||||||
|
change_primary(&mut value, |p| {
|
||||||
|
p["extra"] =
|
||||||
|
serde_json::from_str(&format!("{}0{}", "[".repeat(17), "]".repeat(17))).unwrap()
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
decode_stored_worker_create_failure(&value.to_string()),
|
||||||
|
DecodeStoredWorkerCreateFailure::Corrupt(ContractError::TooDeep)
|
||||||
|
);
|
||||||
|
// Shared wrapper is a bounds check, not a grammar validator or encoder.
|
||||||
|
assert_eq!(validate_json_input(b"[", Budget::new(1024, 1024)), Ok(()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn storage_encoder_and_reader_strip_untrusted_context_and_explanations() {
|
||||||
|
let original = failure(session());
|
||||||
|
let mut value: Value = serde_json::from_str(&original.encode_storage().unwrap()).unwrap();
|
||||||
|
change_primary(&mut value, |p| {
|
||||||
|
p["explanation"] = json!("TEST_ONLY_SECRET_RAW_HTTP");
|
||||||
|
p["context"]["correlation"] = json!("private-worker-id");
|
||||||
|
p["context"]["operations"] = json!([{
|
||||||
|
"operation":"session.record", "stage":"binding", "operation_ref":"TEST_ONLY_SECRET_RAW_HTTP"
|
||||||
|
}, {
|
||||||
|
"operation":"/private/path", "stage":"http-body", "operation_ref":null
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
// Simulate a received attachment without granting a projection capability.
|
||||||
|
let primary: StartupFailureAttachment =
|
||||||
|
serde_json::from_value(value["primary"].clone()).unwrap();
|
||||||
|
let (startup, operation) = original.clone().into_parts();
|
||||||
|
let incoming =
|
||||||
|
WorkerCreateFailure::new(WorkerStartupFailure { primary, ..startup }, operation).unwrap();
|
||||||
|
for encoded in [
|
||||||
|
incoming.encode_storage().unwrap(),
|
||||||
|
known(&value.to_string()).encode_storage().unwrap(),
|
||||||
|
] {
|
||||||
|
for secret in [
|
||||||
|
"TEST_ONLY_SECRET_RAW_HTTP",
|
||||||
|
"private-worker-id",
|
||||||
|
"/private/path",
|
||||||
|
"http-body",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!encoded.contains(secret),
|
||||||
|
"unexpected secret in stored snapshot"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let loaded = known(&encoded);
|
||||||
|
let response = loaded
|
||||||
|
.problem(
|
||||||
|
StartupBoundary::Browser,
|
||||||
|
&SessionFailureVisibility {
|
||||||
|
can_view_cause: true,
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!String::from_utf8_lossy(response.body()).contains("TEST_ONLY_SECRET"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,9 @@ default = []
|
|||||||
typescript = ["dep:ts-rs", "protocol/typescript"]
|
typescript = ["dep:ts-rs", "protocol/typescript"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
api-macros = { workspace = true, features = ["reqwest", "axum", "openapi"] }
|
error-contract = { workspace = true, features = ["json-schema"] }
|
||||||
|
feature-permissions.workspace = true
|
||||||
|
api-macros = { workspace = true, features = ["reqwest", "axum", "openapi", "error-contract"] }
|
||||||
memory.workspace = true
|
memory.workspace = true
|
||||||
plugin-plan.workspace = true
|
plugin-plan.workspace = true
|
||||||
merge-request.workspace = true
|
merge-request.workspace = true
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ HTTP contract shared by Workspace Server and Rust clients. Owns public DTOs, API
|
|||||||
|
|
||||||
- [`ServerApi`] — HTTP operations and transport metadata
|
- [`ServerApi`] — HTTP operations and transport metadata
|
||||||
- [`ServerRequestContext`] — Request context passed to Server authentication adapters
|
- [`ServerRequestContext`] — Request context passed to Server authentication adapters
|
||||||
|
- [`configuration_adoption`] — Owner-only source preview, adoption and exact-request reconciliation
|
||||||
- [`RequestActor`] — Human identity and authentication method
|
- [`RequestActor`] — Human identity and authentication method
|
||||||
- [`WorkerRestoreRequest`] / [`WorkerRestoreResult`] — Observation-based Restore intent and result
|
- [`WorkerRestoreRequest`] / [`WorkerRestoreResult`] — Observation-based Restore intent and result
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
print!(
|
||||||
|
"{}",
|
||||||
|
server_api::configuration_adoption::configuration_contract_openapi()?.to_json()?
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#[cfg(feature = "typescript")]
|
||||||
|
fn main() {
|
||||||
|
print!(
|
||||||
|
"{}",
|
||||||
|
server_api::normalized_typescript(
|
||||||
|
server_api::configuration_adoption::configuration_typescript()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "typescript"))]
|
||||||
|
fn main() {
|
||||||
|
eprintln!("generate_configuration_adoption_types requires --features typescript");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
print!(
|
||||||
|
"{}",
|
||||||
|
server_api::feature_permissions::permission_contract_openapi()?.to_json()?
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
//! Workspace-owner source adoption transport. The Server authenticates the owner
|
||||||
|
//! before reading source, previewing, sealing migration data, adopting or reconciling.
|
||||||
|
//! A result lookup is not a new adoption and absence is not proof of failure.
|
||||||
|
use crate::{
|
||||||
|
ConfigCommitRequest, RepositoryApiError, ServerRequestContext, WorkspaceConfigTreeResponse,
|
||||||
|
};
|
||||||
|
use feature_permissions::{PermissionAssignment, PermissionCatalog};
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ConfigurationAdoptionRequest {
|
||||||
|
pub operation_id: String,
|
||||||
|
pub expected_head: Option<String>,
|
||||||
|
pub expected_catalog_digest: String,
|
||||||
|
pub expected_legacy_export: Option<String>,
|
||||||
|
pub source: ConfigCommitRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RuntimePolicyApplication {
|
||||||
|
NextCurrentPolicyCheck,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ConfigurationAdoptionReceipt {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub operation_id: String,
|
||||||
|
pub history_head: String,
|
||||||
|
pub state: WorkspaceConfigTreeResponse,
|
||||||
|
pub source_stored: bool,
|
||||||
|
pub policy_committed: bool,
|
||||||
|
pub runtime_application: RuntimePolicyApplication,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ConfigurationSubjectPolicy {
|
||||||
|
pub subject_id: String,
|
||||||
|
pub change_id: String,
|
||||||
|
pub execution: PermissionAssignment,
|
||||||
|
pub delegation: PermissionAssignment,
|
||||||
|
/// Exact retained domain restrictions, not editable payloads on permission keys.
|
||||||
|
pub legacy_constraints: Option<crate::authorization::SubjectPermissions>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
|
pub enum ConfigurationMigrationDiagnostic {
|
||||||
|
Converted {
|
||||||
|
subject_id: String,
|
||||||
|
operation: String,
|
||||||
|
},
|
||||||
|
DomainConstraint {
|
||||||
|
subject_id: String,
|
||||||
|
permission: crate::authorization::ScopedPermission,
|
||||||
|
},
|
||||||
|
ExplicitConfigurationRequired {
|
||||||
|
subject_id: String,
|
||||||
|
permission: crate::authorization::ScopedPermission,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ConfigurationSourceResponse {
|
||||||
|
/// Before the first adoption this is an editable, evaluated migration draft.
|
||||||
|
/// source_stored distinguishes it from the unchanged stored source.
|
||||||
|
pub state: WorkspaceConfigTreeResponse,
|
||||||
|
pub history_head: Option<String>,
|
||||||
|
pub source_stored: bool,
|
||||||
|
pub catalog: PermissionCatalog,
|
||||||
|
pub catalog_digest: String,
|
||||||
|
pub expected_legacy_export: Option<String>,
|
||||||
|
pub policies: Vec<ConfigurationSubjectPolicy>,
|
||||||
|
pub migration_diagnostics: Vec<ConfigurationMigrationDiagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ConfigurationPreviewResponse {
|
||||||
|
pub expected_head: Option<String>,
|
||||||
|
pub state: WorkspaceConfigTreeResponse,
|
||||||
|
pub changed_paths: Vec<String>,
|
||||||
|
pub changed_subjects: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
|
pub enum ConfigurationReconciliation {
|
||||||
|
Adopted {
|
||||||
|
receipt: ConfigurationAdoptionReceipt,
|
||||||
|
},
|
||||||
|
PendingTransfer {
|
||||||
|
transfer_identity: String,
|
||||||
|
export_digest: String,
|
||||||
|
},
|
||||||
|
/// No matching evidence was observed. This is not permission to change the ID.
|
||||||
|
NotObserved,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[api_macros::api(reqwest, axum, openapi)]
|
||||||
|
pub trait ConfigurationAdoptionApi {
|
||||||
|
#[get("/api/w/{workspace_id}/config/adoption",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true)]
|
||||||
|
async fn configuration_source(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
) -> Result<ConfigurationSourceResponse, RepositoryApiError>;
|
||||||
|
#[post("/api/w/{workspace_id}/config/adoption/preview",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true,normalize_body_errors=true)]
|
||||||
|
async fn configuration_preview(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[body] request: ConfigurationAdoptionRequest,
|
||||||
|
) -> Result<ConfigurationPreviewResponse, RepositoryApiError>;
|
||||||
|
#[post("/api/w/{workspace_id}/config/adoption/commit",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true,normalize_body_errors=true)]
|
||||||
|
async fn configuration_adopt(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[body] request: ConfigurationAdoptionRequest,
|
||||||
|
) -> Result<ConfigurationAdoptionReceipt, RepositoryApiError>;
|
||||||
|
#[post("/api/w/{workspace_id}/config/adoption/reconcile",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true,normalize_body_errors=true)]
|
||||||
|
async fn configuration_reconcile(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[body] request: ConfigurationAdoptionRequest,
|
||||||
|
) -> Result<ConfigurationReconciliation, RepositoryApiError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl api_macros::openapi::OpenApiSchema for ConfigurationSourceResponse {}
|
||||||
|
impl api_macros::openapi::OpenApiSchema for ConfigurationAdoptionRequest {}
|
||||||
|
impl api_macros::openapi::OpenApiSchema for ConfigurationPreviewResponse {}
|
||||||
|
impl api_macros::openapi::OpenApiSchema for ConfigurationAdoptionReceipt {}
|
||||||
|
impl api_macros::openapi::OpenApiSchema for ConfigurationReconciliation {}
|
||||||
|
|
||||||
|
pub fn configuration_contract_openapi()
|
||||||
|
-> Result<api_macros::openapi::OpenApiDocument, api_macros::openapi::OpenApiError> {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let document = |source_digest: &str| {
|
||||||
|
configuration_adoption_api_openapi(api_macros::openapi::OpenApiInfo {
|
||||||
|
title: "Workspace configuration adoption",
|
||||||
|
version: env!("CARGO_PKG_VERSION"),
|
||||||
|
source_digest,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let normalized =
|
||||||
|
document("sha256:0000000000000000000000000000000000000000000000000000000000000000")?
|
||||||
|
.to_json()?;
|
||||||
|
let digest: String = Sha256::digest(normalized.as_bytes())
|
||||||
|
.iter()
|
||||||
|
.map(|byte| format!("{byte:02x}"))
|
||||||
|
.collect();
|
||||||
|
document(&format!("sha256:{digest}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "typescript")]
|
||||||
|
pub fn configuration_typescript() -> String {
|
||||||
|
use ts_rs::TS;
|
||||||
|
let config = ts_rs::Config::default();
|
||||||
|
let declarations = [
|
||||||
|
ConfigurationAdoptionRequest::decl(&config),
|
||||||
|
RuntimePolicyApplication::decl(&config),
|
||||||
|
ConfigurationAdoptionReceipt::decl(&config),
|
||||||
|
ConfigurationSubjectPolicy::decl(&config),
|
||||||
|
ConfigurationMigrationDiagnostic::decl(&config),
|
||||||
|
ConfigurationSourceResponse::decl(&config),
|
||||||
|
ConfigurationPreviewResponse::decl(&config),
|
||||||
|
ConfigurationReconciliation::decl(&config),
|
||||||
|
];
|
||||||
|
format!(
|
||||||
|
"// Generated by `cargo run -p server-api --features typescript --example generate_configuration_adoption_types`.\n// Do not edit manually.\n\nimport type {{ ConfigCommitRequest, WorkspaceConfigTreeResponse }} from \"./legacy-server-api.ts\";\nimport type {{ ScopedPermission, SubjectPermissions }} from \"./memory-api.ts\";\nimport type {{ PermissionAssignment, PermissionCatalog }} from \"./feature-permissions.generated.ts\";\n\n{}\n",
|
||||||
|
declarations
|
||||||
|
.into_iter()
|
||||||
|
.map(|declaration| format!("export {declaration}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n")
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
//! Declaration/resolution management transport only. These generated routes are
|
||||||
|
//! intentionally not mounted by this crate. The Workspace authority must bind
|
||||||
|
//! authenticated context, read current policy and authorize management access
|
||||||
|
//! before implementing/mounting them; no input can issue a namespace handle.
|
||||||
|
|
||||||
|
use crate::{RepositoryApiError, ServerRequestContext};
|
||||||
|
use feature_permissions::types::{
|
||||||
|
PermissionCatalog, PermissionResolution, ValidatePermissionsRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Transparent wrappers keep the wire representation identical while the REST
|
||||||
|
// owner, not the resolver crate, attests to OpenAPI/Serde agreement.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct PermissionCatalogResponse(pub PermissionCatalog);
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct PermissionValidationRequest(pub ValidatePermissionsRequest);
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct PermissionResolutionResponse(pub PermissionResolution);
|
||||||
|
impl api_macros::openapi::OpenApiSchema for PermissionCatalogResponse {}
|
||||||
|
impl api_macros::openapi::OpenApiSchema for PermissionValidationRequest {}
|
||||||
|
impl api_macros::openapi::OpenApiSchema for PermissionResolutionResponse {}
|
||||||
|
|
||||||
|
#[api_macros::api(reqwest, axum, openapi)]
|
||||||
|
pub trait FeaturePermissionsApi {
|
||||||
|
#[get("/api/w/{workspace_id}/permission-catalog", status = 200, error_status = 403, additional_error_statuses = [400, 404, 500], bearer_auth = true, browser_auth = true)]
|
||||||
|
async fn permission_catalog(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
) -> Result<PermissionCatalogResponse, RepositoryApiError>;
|
||||||
|
|
||||||
|
#[post("/api/w/{workspace_id}/permissions/validate", status = 200, error_status = 403, additional_error_statuses = [400, 404, 500], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
|
||||||
|
async fn validate_permissions(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[body] request: PermissionValidationRequest,
|
||||||
|
) -> Result<PermissionResolutionResponse, RepositoryApiError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic standalone contract. Its source identity hashes the normalized
|
||||||
|
/// schema, not a deployment URL or another API's unrelated route inventory.
|
||||||
|
pub fn permission_contract_openapi()
|
||||||
|
-> Result<api_macros::openapi::OpenApiDocument, api_macros::openapi::OpenApiError> {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let document = |source_digest: &str| {
|
||||||
|
feature_permissions_api_openapi(api_macros::openapi::OpenApiInfo {
|
||||||
|
title: "Feature permission declaration contract",
|
||||||
|
version: env!("CARGO_PKG_VERSION"),
|
||||||
|
source_digest,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let normalized =
|
||||||
|
document("sha256:0000000000000000000000000000000000000000000000000000000000000000")?
|
||||||
|
.to_json()?;
|
||||||
|
let digest: String = Sha256::digest(normalized.as_bytes())
|
||||||
|
.iter()
|
||||||
|
.map(|byte| format!("{byte:02x}"))
|
||||||
|
.collect();
|
||||||
|
document(&format!("sha256:{digest}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use feature_permissions::*;
|
||||||
|
|
||||||
|
/// Wire contract + actual resolver, not a mounted HTTP authorization test.
|
||||||
|
#[test]
|
||||||
|
fn management_wire_preserves_tagged_assignments_and_explicit_children_into_resolution() {
|
||||||
|
let catalog: PermissionCatalog = serde_json::from_str(include_str!(
|
||||||
|
"../../feature-permissions/tests/fixtures/catalog.json"
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let catalog: PermissionCatalogResponse = serde_json::from_slice(
|
||||||
|
&serde_json::to_vec(&PermissionCatalogResponse(catalog)).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let catalog = catalog.0;
|
||||||
|
let mut host = PermissionHost::new(catalog.workspace.clone());
|
||||||
|
for namespace in catalog.namespaces {
|
||||||
|
let owner = host.register_feature(namespace.feature).unwrap();
|
||||||
|
let handle = host.bind_namespace(&owner, namespace.namespace).unwrap();
|
||||||
|
host.declare(&handle, namespace.declaration).unwrap();
|
||||||
|
}
|
||||||
|
let assignment: PermissionAssignment = serde_json::from_str(include_str!(
|
||||||
|
"../../feature-permissions/tests/fixtures/assignment.json"
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let request = PermissionValidationRequest(ValidatePermissionsRequest { assignment });
|
||||||
|
let request: PermissionValidationRequest =
|
||||||
|
serde_json::from_slice(&serde_json::to_vec(&request).unwrap()).unwrap();
|
||||||
|
let result = host.resolve(Some(&request.0.assignment));
|
||||||
|
let response: PermissionResolutionResponse = serde_json::from_slice(
|
||||||
|
&serde_json::to_vec(&PermissionResolutionResponse(result.projection().clone()))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let response = response.0;
|
||||||
|
assert!(
|
||||||
|
response
|
||||||
|
.allowed
|
||||||
|
.contains_key(&PermissionKey::parse("ticket.hoge.fuga.finish").unwrap())
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!response
|
||||||
|
.allowed
|
||||||
|
.contains_key(&PermissionKey::parse("ticket.hoge.unlinked").unwrap())
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<ValidatePermissionsRequest>(
|
||||||
|
serde_json::json!({"assignment":request.0.assignment, "workspace":"other"})
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_management_contract_publishes_payload_free_schema_and_context_bound_routes() {
|
||||||
|
let document = permission_contract_openapi().unwrap();
|
||||||
|
let document: serde_json::Value =
|
||||||
|
serde_json::from_str(&document.to_json().unwrap()).unwrap();
|
||||||
|
let checked_in: serde_json::Value = serde_json::from_str(
|
||||||
|
&std::fs::read_to_string(
|
||||||
|
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../openapi/feature-permissions-api.json"),
|
||||||
|
)
|
||||||
|
.expect("run generate_feature_permissions_openapi"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(document, checked_in);
|
||||||
|
let paths = document["paths"].as_object().unwrap();
|
||||||
|
assert!(paths.contains_key("/api/w/{workspace_id}/permission-catalog"));
|
||||||
|
assert!(paths.contains_key("/api/w/{workspace_id}/permissions/validate"));
|
||||||
|
let schema =
|
||||||
|
serde_json::to_value(schemars::schema_for!(ValidatePermissionsRequest)).unwrap();
|
||||||
|
let serialized = serde_json::to_string(&schema).unwrap();
|
||||||
|
assert!(serialized.contains("namespace"));
|
||||||
|
assert!(serialized.contains("set"));
|
||||||
|
assert!(!serialized.contains("payload"));
|
||||||
|
assert_eq!(schema["additionalProperties"], false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
#![doc = include_str!("../README.md")]
|
#![doc = include_str!("../README.md")]
|
||||||
|
mod worker_create_error;
|
||||||
|
pub use worker_create_error::WorkerCreateApiError;
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
@@ -8,7 +10,10 @@ pub use api_macros::reqwest as client_support;
|
|||||||
pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation};
|
pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation};
|
||||||
pub mod authorization;
|
pub mod authorization;
|
||||||
mod chat;
|
mod chat;
|
||||||
|
pub mod configuration_adoption;
|
||||||
|
pub mod feature_permissions;
|
||||||
pub use chat::*;
|
pub use chat::*;
|
||||||
|
pub use configuration_adoption::*;
|
||||||
mod subject_body;
|
mod subject_body;
|
||||||
pub use subject_body::*;
|
pub use subject_body::*;
|
||||||
mod chat_transport;
|
mod chat_transport;
|
||||||
@@ -87,6 +92,10 @@ pub struct RepositoryApiError {
|
|||||||
pub message: String,
|
pub message: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub diagnostics: Vec<Diagnostic>,
|
pub diagnostics: Vec<Diagnostic>,
|
||||||
|
/// An origin-projected error-contract attachment, not raw HTTP error text.
|
||||||
|
/// Receiving it does not authorize redisclosure to a different audience.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub failure: Option<protocol::startup_failure::StartupFailureAttachment>,
|
||||||
#[serde(skip, default = "default_repository_error_status")]
|
#[serde(skip, default = "default_repository_error_status")]
|
||||||
status: u16,
|
status: u16,
|
||||||
}
|
}
|
||||||
@@ -106,9 +115,18 @@ impl RepositoryApiError {
|
|||||||
error: error.into(),
|
error: error.into(),
|
||||||
message: message.into(),
|
message: message.into(),
|
||||||
diagnostics,
|
diagnostics,
|
||||||
|
failure: None,
|
||||||
status,
|
status,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_failure(
|
||||||
|
mut self,
|
||||||
|
failure: protocol::startup_failure::StartupFailureAttachment,
|
||||||
|
) -> Self {
|
||||||
|
self.failure = Some(failure);
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl api_macros::HttpError for RepositoryApiError {
|
impl api_macros::HttpError for RepositoryApiError {
|
||||||
@@ -1891,7 +1909,9 @@ pub trait ServerApi {
|
|||||||
"/api/workers",
|
"/api/workers",
|
||||||
status = 200,
|
status = 200,
|
||||||
error_status = 400,
|
error_status = 400,
|
||||||
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503],
|
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503, 504],
|
||||||
|
additional_error_content_types = ["application/problem+json"],
|
||||||
|
client_error_hook = true,
|
||||||
bearer_auth = true,
|
bearer_auth = true,
|
||||||
browser_auth = true
|
browser_auth = true
|
||||||
)]
|
)]
|
||||||
@@ -1899,7 +1919,7 @@ pub trait ServerApi {
|
|||||||
&self,
|
&self,
|
||||||
#[extension] context: ServerRequestContext,
|
#[extension] context: ServerRequestContext,
|
||||||
#[body] request: CreateWorkspaceWorkerRequest,
|
#[body] request: CreateWorkspaceWorkerRequest,
|
||||||
) -> Result<BrowserCreateWorkerResponse, RepositoryApiError>;
|
) -> Result<BrowserCreateWorkerResponse, WorkerCreateApiError>;
|
||||||
|
|
||||||
#[get(
|
#[get(
|
||||||
"/api/w/{workspace_id}/orchestrator",
|
"/api/w/{workspace_id}/orchestrator",
|
||||||
@@ -1957,7 +1977,8 @@ pub trait ServerApi {
|
|||||||
"/api/w/{workspace_id}/worker-control/workers",
|
"/api/w/{workspace_id}/worker-control/workers",
|
||||||
status = 200,
|
status = 200,
|
||||||
error_status = 400,
|
error_status = 400,
|
||||||
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503],
|
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503, 504],
|
||||||
|
client_error_hook = true,
|
||||||
openapi = false,
|
openapi = false,
|
||||||
)]
|
)]
|
||||||
async fn worker_control_spawn(
|
async fn worker_control_spawn(
|
||||||
@@ -1965,7 +1986,7 @@ pub trait ServerApi {
|
|||||||
#[extension] context: ServerRequestContext,
|
#[extension] context: ServerRequestContext,
|
||||||
#[path] workspace_id: String,
|
#[path] workspace_id: String,
|
||||||
#[body] request: CreateWorkspaceWorkerRequest,
|
#[body] request: CreateWorkspaceWorkerRequest,
|
||||||
) -> Result<BrowserCreateWorkerResponse, RepositoryApiError>;
|
) -> Result<BrowserCreateWorkerResponse, WorkerCreateApiError>;
|
||||||
#[post(
|
#[post(
|
||||||
"/api/w/{workspace_id}/worker-control/workers/{runtime_id}/{worker_id}/input",
|
"/api/w/{workspace_id}/worker-control/workers/{runtime_id}/{worker_id}/input",
|
||||||
status = 200,
|
status = 200,
|
||||||
@@ -2082,7 +2103,9 @@ pub trait ServerApi {
|
|||||||
"/api/w/{workspace_id}/workers",
|
"/api/w/{workspace_id}/workers",
|
||||||
status = 200,
|
status = 200,
|
||||||
error_status = 400,
|
error_status = 400,
|
||||||
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503],
|
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503, 504],
|
||||||
|
additional_error_content_types = ["application/problem+json"],
|
||||||
|
client_error_hook = true,
|
||||||
bearer_auth = true,
|
bearer_auth = true,
|
||||||
browser_auth = true
|
browser_auth = true
|
||||||
)]
|
)]
|
||||||
@@ -2091,7 +2114,7 @@ pub trait ServerApi {
|
|||||||
#[extension] context: ServerRequestContext,
|
#[extension] context: ServerRequestContext,
|
||||||
#[path] workspace_id: String,
|
#[path] workspace_id: String,
|
||||||
#[body] request: CreateWorkspaceWorkerRequest,
|
#[body] request: CreateWorkspaceWorkerRequest,
|
||||||
) -> Result<BrowserCreateWorkerResponse, RepositoryApiError>;
|
) -> Result<BrowserCreateWorkerResponse, WorkerCreateApiError>;
|
||||||
|
|
||||||
#[get(
|
#[get(
|
||||||
"/api/w/{workspace_id}/workers/{worker_ref}",
|
"/api/w/{workspace_id}/workers/{worker_ref}",
|
||||||
@@ -11083,6 +11106,7 @@ pub struct BrowserWorkspaceOrchestratorResponse {
|
|||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum WorkerOperationState {
|
pub enum WorkerOperationState {
|
||||||
|
Unknown,
|
||||||
Accepted,
|
Accepted,
|
||||||
Unsupported,
|
Unsupported,
|
||||||
Rejected,
|
Rejected,
|
||||||
|
|||||||
@@ -304,6 +304,7 @@ fn validate_worker_operations(
|
|||||||
) -> Result<BTreeSet<String>, GenerationError> {
|
) -> Result<BTreeSet<String>, GenerationError> {
|
||||||
let operations = [
|
let operations = [
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/workers",
|
path: "/api/workers",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "workspace_worker_list_alias",
|
operation_id: "workspace_worker_list_alias",
|
||||||
@@ -311,6 +312,7 @@ fn validate_worker_operations(
|
|||||||
successes: &[("200", "WorkerListResponse")],
|
successes: &[("200", "WorkerListResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "WorkerCreateApiError",
|
||||||
path: "/api/workers",
|
path: "/api/workers",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "workspace_worker_create_alias",
|
operation_id: "workspace_worker_create_alias",
|
||||||
@@ -318,6 +320,7 @@ fn validate_worker_operations(
|
|||||||
successes: &[("200", "BrowserCreateWorkerResponse")],
|
successes: &[("200", "BrowserCreateWorkerResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/orchestrator",
|
path: "/api/w/{workspace_id}/orchestrator",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "workspace_orchestrator_status",
|
operation_id: "workspace_orchestrator_status",
|
||||||
@@ -325,6 +328,7 @@ fn validate_worker_operations(
|
|||||||
successes: &[("200", "BrowserWorkspaceOrchestratorResponse")],
|
successes: &[("200", "BrowserWorkspaceOrchestratorResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/orchestrator",
|
path: "/api/w/{workspace_id}/orchestrator",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "workspace_orchestrator_start",
|
operation_id: "workspace_orchestrator_start",
|
||||||
@@ -332,6 +336,7 @@ fn validate_worker_operations(
|
|||||||
successes: &[("200", "BrowserWorkspaceOrchestratorResponse")],
|
successes: &[("200", "BrowserWorkspaceOrchestratorResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/workers",
|
path: "/api/w/{workspace_id}/workers",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "workspace_worker_list",
|
operation_id: "workspace_worker_list",
|
||||||
@@ -339,6 +344,7 @@ fn validate_worker_operations(
|
|||||||
successes: &[("200", "WorkerListResponse")],
|
successes: &[("200", "WorkerListResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "WorkerCreateApiError",
|
||||||
path: "/api/w/{workspace_id}/workers",
|
path: "/api/w/{workspace_id}/workers",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "workspace_worker_create",
|
operation_id: "workspace_worker_create",
|
||||||
@@ -346,6 +352,7 @@ fn validate_worker_operations(
|
|||||||
successes: &[("200", "BrowserCreateWorkerResponse")],
|
successes: &[("200", "BrowserCreateWorkerResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/workers/{worker_ref}",
|
path: "/api/w/{workspace_id}/workers/{worker_ref}",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "workspace_worker_detail",
|
operation_id: "workspace_worker_detail",
|
||||||
@@ -353,6 +360,7 @@ fn validate_worker_operations(
|
|||||||
successes: &[("200", "WorkerSummary")],
|
successes: &[("200", "WorkerSummary")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/workers/launch-options",
|
path: "/api/workers/launch-options",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "workspace_worker_launch_options_alias",
|
operation_id: "workspace_worker_launch_options_alias",
|
||||||
@@ -360,6 +368,7 @@ fn validate_worker_operations(
|
|||||||
successes: &[("200", "WorkerLaunchOptionsResponse")],
|
successes: &[("200", "WorkerLaunchOptionsResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/workers/launch-options",
|
path: "/api/w/{workspace_id}/workers/launch-options",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "workspace_worker_launch_options",
|
operation_id: "workspace_worker_launch_options",
|
||||||
@@ -375,6 +384,7 @@ fn validate_companion_operations(
|
|||||||
) -> Result<BTreeSet<String>, GenerationError> {
|
) -> Result<BTreeSet<String>, GenerationError> {
|
||||||
let operations = [
|
let operations = [
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/companion/status",
|
path: "/api/companion/status",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "companion_status_alias",
|
operation_id: "companion_status_alias",
|
||||||
@@ -382,6 +392,7 @@ fn validate_companion_operations(
|
|||||||
successes: &[("200", "CompanionStatusResponse")],
|
successes: &[("200", "CompanionStatusResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/companion/status",
|
path: "/api/w/{workspace_id}/companion/status",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "companion_status",
|
operation_id: "companion_status",
|
||||||
@@ -389,6 +400,7 @@ fn validate_companion_operations(
|
|||||||
successes: &[("200", "CompanionStatusResponse")],
|
successes: &[("200", "CompanionStatusResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/companion/transcript",
|
path: "/api/companion/transcript",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "companion_transcript_alias",
|
operation_id: "companion_transcript_alias",
|
||||||
@@ -396,6 +408,7 @@ fn validate_companion_operations(
|
|||||||
successes: &[("200", "CompanionTranscriptProjection")],
|
successes: &[("200", "CompanionTranscriptProjection")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/companion/transcript",
|
path: "/api/w/{workspace_id}/companion/transcript",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "companion_transcript",
|
operation_id: "companion_transcript",
|
||||||
@@ -403,6 +416,7 @@ fn validate_companion_operations(
|
|||||||
successes: &[("200", "CompanionTranscriptProjection")],
|
successes: &[("200", "CompanionTranscriptProjection")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/companion/messages",
|
path: "/api/companion/messages",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "companion_message_alias",
|
operation_id: "companion_message_alias",
|
||||||
@@ -410,6 +424,7 @@ fn validate_companion_operations(
|
|||||||
successes: &[("200", "CompanionMessageResponse")],
|
successes: &[("200", "CompanionMessageResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/companion/messages",
|
path: "/api/w/{workspace_id}/companion/messages",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "companion_message",
|
operation_id: "companion_message",
|
||||||
@@ -417,6 +432,7 @@ fn validate_companion_operations(
|
|||||||
successes: &[("200", "CompanionMessageResponse")],
|
successes: &[("200", "CompanionMessageResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/companion/cancel",
|
path: "/api/companion/cancel",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "companion_cancel_alias",
|
operation_id: "companion_cancel_alias",
|
||||||
@@ -424,6 +440,7 @@ fn validate_companion_operations(
|
|||||||
successes: &[("200", "CompanionMessageResponse")],
|
successes: &[("200", "CompanionMessageResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/companion/cancel",
|
path: "/api/w/{workspace_id}/companion/cancel",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "companion_cancel",
|
operation_id: "companion_cancel",
|
||||||
@@ -439,6 +456,7 @@ fn validate_ticket_browser_operations(
|
|||||||
) -> Result<BTreeSet<String>, GenerationError> {
|
) -> Result<BTreeSet<String>, GenerationError> {
|
||||||
let operations = [
|
let operations = [
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/tickets/{id}/link-summary",
|
path: "/api/w/{workspace_id}/tickets/{id}/link-summary",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "ticket_link_summary_get",
|
operation_id: "ticket_link_summary_get",
|
||||||
@@ -446,6 +464,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketLinkSummary")],
|
successes: &[("200", "TicketLinkSummary")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/tickets/{id}/parent",
|
path: "/api/w/{workspace_id}/tickets/{id}/parent",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "ticket_parent_get",
|
operation_id: "ticket_parent_get",
|
||||||
@@ -453,6 +472,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketParentLink")],
|
successes: &[("200", "TicketParentLink")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/tickets/{id}/parent",
|
path: "/api/w/{workspace_id}/tickets/{id}/parent",
|
||||||
method: "put",
|
method: "put",
|
||||||
operation_id: "ticket_parent_set",
|
operation_id: "ticket_parent_set",
|
||||||
@@ -460,6 +480,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketParentLink")],
|
successes: &[("200", "TicketParentLink")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/tickets/{id}/children",
|
path: "/api/w/{workspace_id}/tickets/{id}/children",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "ticket_children_query",
|
operation_id: "ticket_children_query",
|
||||||
@@ -467,6 +488,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketChildrenPage")],
|
successes: &[("200", "TicketChildrenPage")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/ticket-tags",
|
path: "/api/w/{workspace_id}/ticket-tags",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "workspace_ticket_tag_list",
|
operation_id: "workspace_ticket_tag_list",
|
||||||
@@ -474,6 +496,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "Array_of_WorkspaceTag")],
|
successes: &[("200", "Array_of_WorkspaceTag")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/ticket-tags",
|
path: "/api/w/{workspace_id}/ticket-tags",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "workspace_ticket_tag_mutate",
|
operation_id: "workspace_ticket_tag_mutate",
|
||||||
@@ -481,6 +504,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "WorkspaceTag")],
|
successes: &[("200", "WorkspaceTag")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/tickets/{id}/tags",
|
path: "/api/w/{workspace_id}/tickets/{id}/tags",
|
||||||
method: "put",
|
method: "put",
|
||||||
operation_id: "ticket_tags_set",
|
operation_id: "ticket_tags_set",
|
||||||
@@ -488,6 +512,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "Array_of_WorkspaceTag")],
|
successes: &[("200", "Array_of_WorkspaceTag")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/tickets/browse",
|
path: "/api/w/{workspace_id}/tickets/browse",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "ticket_browse",
|
operation_id: "ticket_browse",
|
||||||
@@ -495,6 +520,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketBrowseQueryResponse")],
|
successes: &[("200", "TicketBrowseQueryResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/tickets/query",
|
path: "/api/w/{workspace_id}/tickets/query",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "ticket_query",
|
operation_id: "ticket_query",
|
||||||
@@ -502,6 +528,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketQueryResponse")],
|
successes: &[("200", "TicketQueryResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/tickets",
|
path: "/api/w/{workspace_id}/tickets",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "ticket_list",
|
operation_id: "ticket_list",
|
||||||
@@ -509,6 +536,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketListResponse")],
|
successes: &[("200", "TicketListResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/tickets",
|
path: "/api/w/{workspace_id}/tickets",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "ticket_create_record",
|
operation_id: "ticket_create_record",
|
||||||
@@ -516,6 +544,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketRef")],
|
successes: &[("200", "TicketRef")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/tickets/{id}",
|
path: "/api/w/{workspace_id}/tickets/{id}",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "ticket_get",
|
operation_id: "ticket_get",
|
||||||
@@ -523,6 +552,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketDetail")],
|
successes: &[("200", "TicketDetail")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/tickets/{id}",
|
path: "/api/w/{workspace_id}/tickets/{id}",
|
||||||
method: "patch",
|
method: "patch",
|
||||||
operation_id: "ticket_edit",
|
operation_id: "ticket_edit",
|
||||||
@@ -530,6 +560,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketDetail")],
|
successes: &[("200", "TicketDetail")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/tickets/{id}/posts/{event_ref}",
|
path: "/api/w/{workspace_id}/tickets/{id}/posts/{event_ref}",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "ticket_post_get",
|
operation_id: "ticket_post_get",
|
||||||
@@ -537,6 +568,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketEventDetail")],
|
successes: &[("200", "TicketEventDetail")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/tickets/{id}/posts/{event_ref}",
|
path: "/api/w/{workspace_id}/tickets/{id}/posts/{event_ref}",
|
||||||
method: "patch",
|
method: "patch",
|
||||||
operation_id: "ticket_post_edit",
|
operation_id: "ticket_post_edit",
|
||||||
@@ -544,6 +576,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketDetail")],
|
successes: &[("200", "TicketDetail")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/tickets/{id}/assignments/{role}",
|
path: "/api/w/{workspace_id}/tickets/{id}/assignments/{role}",
|
||||||
method: "put",
|
method: "put",
|
||||||
operation_id: "ticket_assignment_set",
|
operation_id: "ticket_assignment_set",
|
||||||
@@ -551,6 +584,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketRoleAssignmentMutationResponse")],
|
successes: &[("200", "TicketRoleAssignmentMutationResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/tickets/{id}/implementation-cancellations",
|
path: "/api/w/{workspace_id}/tickets/{id}/implementation-cancellations",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "ticket_implementation_cancel",
|
operation_id: "ticket_implementation_cancel",
|
||||||
@@ -558,6 +592,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketDetail")],
|
successes: &[("200", "TicketDetail")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/case",
|
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/case",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "ticket_case_update",
|
operation_id: "ticket_case_update",
|
||||||
@@ -565,6 +600,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketSummary")],
|
successes: &[("200", "TicketSummary")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments",
|
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "ticket_assignments_page",
|
operation_id: "ticket_assignments_page",
|
||||||
@@ -572,6 +608,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketAssignmentsPage")],
|
successes: &[("200", "TicketAssignmentsPage")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/notification-diagnostics",
|
path: "/api/w/{workspace_id}/notification-diagnostics",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "notification_diagnostics",
|
operation_id: "notification_diagnostics",
|
||||||
@@ -579,6 +616,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "NotificationDiagnosticsPage")],
|
successes: &[("200", "NotificationDiagnosticsPage")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics",
|
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "ticket_assignment_diagnostics",
|
operation_id: "ticket_assignment_diagnostics",
|
||||||
@@ -586,6 +624,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketAssignmentDiagnosticsPage")],
|
successes: &[("200", "TicketAssignmentDiagnosticsPage")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/implementation-results",
|
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/implementation-results",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "ticket_implementation_result",
|
operation_id: "ticket_implementation_result",
|
||||||
@@ -593,6 +632,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketImplementationResultReceipt")],
|
successes: &[("200", "TicketImplementationResultReceipt")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/tickets/{ticket_id}/implementation-results",
|
path: "/api/w/{workspace_id}/tickets/{ticket_id}/implementation-results",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "ticket_implementation_result_browser",
|
operation_id: "ticket_implementation_result_browser",
|
||||||
@@ -600,6 +640,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketImplementationResultReceipt")],
|
successes: &[("200", "TicketImplementationResultReceipt")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/implementation-results/{result_id}",
|
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/implementation-results/{result_id}",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "ticket_implementation_result_get",
|
operation_id: "ticket_implementation_result_get",
|
||||||
@@ -607,6 +648,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketImplementationResultReceipt")],
|
successes: &[("200", "TicketImplementationResultReceipt")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-answer",
|
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-answer",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "ticket_assignment_answer",
|
operation_id: "ticket_assignment_answer",
|
||||||
@@ -614,6 +656,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketAssignmentAnswerReceipt")],
|
successes: &[("200", "TicketAssignmentAnswerReceipt")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments",
|
path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "ticket_assignments_page_browser",
|
operation_id: "ticket_assignments_page_browser",
|
||||||
@@ -621,6 +664,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketAssignmentsPage")],
|
successes: &[("200", "TicketAssignmentsPage")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics",
|
path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "ticket_assignment_diagnostics_browser",
|
operation_id: "ticket_assignment_diagnostics_browser",
|
||||||
@@ -628,6 +672,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketAssignmentDiagnosticsPage")],
|
successes: &[("200", "TicketAssignmentDiagnosticsPage")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-answer",
|
path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-answer",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "ticket_assignment_answer_browser",
|
operation_id: "ticket_assignment_answer_browser",
|
||||||
@@ -635,6 +680,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketAssignmentAnswerReceipt")],
|
successes: &[("200", "TicketAssignmentAnswerReceipt")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking",
|
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "ticket_assignment_tracking_get",
|
operation_id: "ticket_assignment_tracking_get",
|
||||||
@@ -642,6 +688,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "Array_of_TicketAssignmentTrackingProjection")],
|
successes: &[("200", "Array_of_TicketAssignmentTrackingProjection")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking",
|
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "ticket_assignment_tracking_update",
|
operation_id: "ticket_assignment_tracking_update",
|
||||||
@@ -649,6 +696,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketAssignmentTrackingProjection")],
|
successes: &[("200", "TicketAssignmentTrackingProjection")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/history",
|
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/history",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "ticket_assignment_tracking_history",
|
operation_id: "ticket_assignment_tracking_history",
|
||||||
@@ -656,6 +704,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketAssignmentTrackingHistoryPage")],
|
successes: &[("200", "TicketAssignmentTrackingHistoryPage")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/tickets/{id}/ready",
|
path: "/api/w/{workspace_id}/tickets/{id}/ready",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "ticket_ready",
|
operation_id: "ticket_ready",
|
||||||
@@ -663,6 +712,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketDetail")],
|
successes: &[("200", "TicketDetail")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/tickets/{id}/events",
|
path: "/api/w/{workspace_id}/tickets/{id}/events",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "ticket_event_append",
|
operation_id: "ticket_event_append",
|
||||||
@@ -670,6 +720,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketDetail")],
|
successes: &[("200", "TicketDetail")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/tickets/{id}/queue",
|
path: "/api/w/{workspace_id}/tickets/{id}/queue",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "ticket_queue",
|
operation_id: "ticket_queue",
|
||||||
@@ -677,6 +728,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "TicketQueueOutcome")],
|
successes: &[("200", "TicketQueueOutcome")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/objectives",
|
path: "/api/w/{workspace_id}/objectives",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "objective_list",
|
operation_id: "objective_list",
|
||||||
@@ -684,6 +736,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "ObjectiveListResponse")],
|
successes: &[("200", "ObjectiveListResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/objectives/{objective_id}",
|
path: "/api/w/{workspace_id}/objectives/{objective_id}",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "objective_get",
|
operation_id: "objective_get",
|
||||||
@@ -691,6 +744,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "ObjectiveDetail")],
|
successes: &[("200", "ObjectiveDetail")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/merge-requests",
|
path: "/api/w/{workspace_id}/merge-requests",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "merge_request_list",
|
operation_id: "merge_request_list",
|
||||||
@@ -698,6 +752,7 @@ fn validate_ticket_browser_operations(
|
|||||||
successes: &[("200", "MergeRequestListResponse")],
|
successes: &[("200", "MergeRequestListResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/merge-requests/{merge_request_id}",
|
path: "/api/w/{workspace_id}/merge-requests/{merge_request_id}",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "merge_request_show",
|
operation_id: "merge_request_show",
|
||||||
@@ -713,6 +768,7 @@ fn validate_repository_operations(
|
|||||||
) -> Result<BTreeSet<String>, GenerationError> {
|
) -> Result<BTreeSet<String>, GenerationError> {
|
||||||
let operations = [
|
let operations = [
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/repositories",
|
path: "/api/repositories",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "repository_list_alias",
|
operation_id: "repository_list_alias",
|
||||||
@@ -720,6 +776,7 @@ fn validate_repository_operations(
|
|||||||
successes: &[("200", "RepositoryListResponse")],
|
successes: &[("200", "RepositoryListResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/repositories/{repository_key}",
|
path: "/api/repositories/{repository_key}",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "repository_detail_alias",
|
operation_id: "repository_detail_alias",
|
||||||
@@ -727,6 +784,7 @@ fn validate_repository_operations(
|
|||||||
successes: &[("200", "RepositoryDetailResponse")],
|
successes: &[("200", "RepositoryDetailResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/repositories",
|
path: "/api/w/{workspace_id}/repositories",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "repository_list",
|
operation_id: "repository_list",
|
||||||
@@ -734,6 +792,7 @@ fn validate_repository_operations(
|
|||||||
successes: &[("200", "RepositoryListResponse")],
|
successes: &[("200", "RepositoryListResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/repositories",
|
path: "/api/w/{workspace_id}/repositories",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "repository_create",
|
operation_id: "repository_create",
|
||||||
@@ -744,6 +803,7 @@ fn validate_repository_operations(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/repositories/{repository_key}",
|
path: "/api/w/{workspace_id}/repositories/{repository_key}",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "repository_detail",
|
operation_id: "repository_detail",
|
||||||
@@ -751,6 +811,7 @@ fn validate_repository_operations(
|
|||||||
successes: &[("200", "RepositoryDetailResponse")],
|
successes: &[("200", "RepositoryDetailResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/repositories/{repository_key}/ssh-connection-test",
|
path: "/api/w/{workspace_id}/repositories/{repository_key}/ssh-connection-test",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "repository_ssh_connection_probe",
|
operation_id: "repository_ssh_connection_probe",
|
||||||
@@ -758,6 +819,7 @@ fn validate_repository_operations(
|
|||||||
successes: &[("200", "RepositorySshConnectionProbeResponse")],
|
successes: &[("200", "RepositorySshConnectionProbeResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/repositories/{repository_key}/ssh-connection-test",
|
path: "/api/w/{workspace_id}/repositories/{repository_key}/ssh-connection-test",
|
||||||
method: "put",
|
method: "put",
|
||||||
operation_id: "repository_ssh_host_trust_confirm",
|
operation_id: "repository_ssh_host_trust_confirm",
|
||||||
@@ -765,6 +827,7 @@ fn validate_repository_operations(
|
|||||||
successes: &[("200", "RepositorySshHostTrust")],
|
successes: &[("200", "RepositorySshHostTrust")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/repositories/{repository_key}/log",
|
path: "/api/w/{workspace_id}/repositories/{repository_key}/log",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "repository_log",
|
operation_id: "repository_log",
|
||||||
@@ -772,6 +835,7 @@ fn validate_repository_operations(
|
|||||||
successes: &[("200", "RepositoryLogResponse")],
|
successes: &[("200", "RepositoryLogResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/repositories/{repository_key}/log",
|
path: "/api/repositories/{repository_key}/log",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "repository_log_alias",
|
operation_id: "repository_log_alias",
|
||||||
@@ -779,6 +843,7 @@ fn validate_repository_operations(
|
|||||||
successes: &[("200", "RepositoryLogResponse")],
|
successes: &[("200", "RepositoryLogResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/hosts",
|
path: "/api/w/{workspace_id}/hosts",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "host_list",
|
operation_id: "host_list",
|
||||||
@@ -786,6 +851,7 @@ fn validate_repository_operations(
|
|||||||
successes: &[("200", "HostListResponse")],
|
successes: &[("200", "HostListResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/hosts",
|
path: "/api/hosts",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "host_list_alias",
|
operation_id: "host_list_alias",
|
||||||
@@ -793,6 +859,7 @@ fn validate_repository_operations(
|
|||||||
successes: &[("200", "HostListResponse")],
|
successes: &[("200", "HostListResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/hosts/{host_id}/workers",
|
path: "/api/w/{workspace_id}/hosts/{host_id}/workers",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "host_worker_list",
|
operation_id: "host_worker_list",
|
||||||
@@ -800,6 +867,7 @@ fn validate_repository_operations(
|
|||||||
successes: &[("200", "HostWorkerListResponse")],
|
successes: &[("200", "HostWorkerListResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/hosts/{host_id}/workers",
|
path: "/api/hosts/{host_id}/workers",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "host_worker_list_alias",
|
operation_id: "host_worker_list_alias",
|
||||||
@@ -815,6 +883,7 @@ fn validate_workdir_operations(
|
|||||||
) -> Result<BTreeSet<String>, GenerationError> {
|
) -> Result<BTreeSet<String>, GenerationError> {
|
||||||
let operations = [
|
let operations = [
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories",
|
path: "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "runtime_workdir_list",
|
operation_id: "runtime_workdir_list",
|
||||||
@@ -822,6 +891,7 @@ fn validate_workdir_operations(
|
|||||||
successes: &[("200", "WorkingDirectoryListResponse")],
|
successes: &[("200", "WorkingDirectoryListResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories",
|
path: "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "runtime_workdir_create",
|
operation_id: "runtime_workdir_create",
|
||||||
@@ -832,6 +902,7 @@ fn validate_workdir_operations(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories/{working_directory_id}",
|
path: "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories/{working_directory_id}",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "runtime_workdir_detail",
|
operation_id: "runtime_workdir_detail",
|
||||||
@@ -839,6 +910,7 @@ fn validate_workdir_operations(
|
|||||||
successes: &[("200", "WorkingDirectoryDetailResponse")],
|
successes: &[("200", "WorkingDirectoryDetailResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories/{working_directory_id}",
|
path: "/api/w/{workspace_id}/runtimes/{runtime_id}/working-directories/{working_directory_id}",
|
||||||
method: "delete",
|
method: "delete",
|
||||||
operation_id: "runtime_workdir_cleanup",
|
operation_id: "runtime_workdir_cleanup",
|
||||||
@@ -846,6 +918,7 @@ fn validate_workdir_operations(
|
|||||||
successes: &[("200", "WorkingDirectoryRemovalResponse")],
|
successes: &[("200", "WorkingDirectoryRemovalResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/external-workdir-grants",
|
path: "/api/w/{workspace_id}/external-workdir-grants",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "external_workdir_grant_create",
|
operation_id: "external_workdir_grant_create",
|
||||||
@@ -853,6 +926,7 @@ fn validate_workdir_operations(
|
|||||||
successes: &[("201", "ExternalWorkdirGrantResponse")],
|
successes: &[("201", "ExternalWorkdirGrantResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/external-workdir-grants/{grant_id}",
|
path: "/api/w/{workspace_id}/external-workdir-grants/{grant_id}",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "external_workdir_grant_get",
|
operation_id: "external_workdir_grant_get",
|
||||||
@@ -860,6 +934,7 @@ fn validate_workdir_operations(
|
|||||||
successes: &[("200", "ExternalWorkdirGrantResponse")],
|
successes: &[("200", "ExternalWorkdirGrantResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/external-workdir-grants/{grant_id}",
|
path: "/api/w/{workspace_id}/external-workdir-grants/{grant_id}",
|
||||||
method: "delete",
|
method: "delete",
|
||||||
operation_id: "external_workdir_grant_revoke",
|
operation_id: "external_workdir_grant_revoke",
|
||||||
@@ -867,6 +942,7 @@ fn validate_workdir_operations(
|
|||||||
successes: &[("200", "ExternalWorkdirGrantResponse")],
|
successes: &[("200", "ExternalWorkdirGrantResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/working-directories",
|
path: "/api/w/{workspace_id}/working-directories",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "workspace_workdir_list",
|
operation_id: "workspace_workdir_list",
|
||||||
@@ -874,6 +950,7 @@ fn validate_workdir_operations(
|
|||||||
successes: &[("200", "WorkingDirectoryListResponse")],
|
successes: &[("200", "WorkingDirectoryListResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/working-directories",
|
path: "/api/w/{workspace_id}/working-directories",
|
||||||
method: "post",
|
method: "post",
|
||||||
operation_id: "workspace_workdir_create",
|
operation_id: "workspace_workdir_create",
|
||||||
@@ -884,6 +961,7 @@ fn validate_workdir_operations(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/working-directories/{working_directory_id}",
|
path: "/api/w/{workspace_id}/working-directories/{working_directory_id}",
|
||||||
method: "get",
|
method: "get",
|
||||||
operation_id: "workspace_workdir_detail",
|
operation_id: "workspace_workdir_detail",
|
||||||
@@ -891,6 +969,7 @@ fn validate_workdir_operations(
|
|||||||
successes: &[("200", "WorkingDirectoryDetailResponse")],
|
successes: &[("200", "WorkingDirectoryDetailResponse")],
|
||||||
},
|
},
|
||||||
OperationContract {
|
OperationContract {
|
||||||
|
error: "RepositoryApiError",
|
||||||
path: "/api/w/{workspace_id}/working-directories/{working_directory_id}",
|
path: "/api/w/{workspace_id}/working-directories/{working_directory_id}",
|
||||||
method: "delete",
|
method: "delete",
|
||||||
operation_id: "workspace_workdir_cleanup",
|
operation_id: "workspace_workdir_cleanup",
|
||||||
@@ -1332,12 +1411,28 @@ fn validate_operation_contracts(
|
|||||||
&format!("{} response {status}", operation.operation_id),
|
&format!("{} response {status}", operation.operation_id),
|
||||||
)?;
|
)?;
|
||||||
let schema = json_content_schema(response, operation.operation_id)?;
|
let schema = json_content_schema(response, operation.operation_id)?;
|
||||||
|
let startup_error = operation.error == "WorkerCreateApiError"
|
||||||
|
&& !successes.contains_key(status.as_str());
|
||||||
let expected = successes
|
let expected = successes
|
||||||
.get(status.as_str())
|
.get(status.as_str())
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or("RepositoryApiError");
|
.unwrap_or(if startup_error {
|
||||||
|
"RepositoryApiError"
|
||||||
|
} else {
|
||||||
|
operation.error
|
||||||
|
});
|
||||||
require_schema_ref(schema, expected, operation.operation_id)?;
|
require_schema_ref(schema, expected, operation.operation_id)?;
|
||||||
roots.insert(expected.to_owned());
|
roots.insert(expected.to_owned());
|
||||||
|
if startup_error {
|
||||||
|
let content = object_field(response, "content", operation.operation_id)?;
|
||||||
|
let problem =
|
||||||
|
object_field(content, "application/problem+json", operation.operation_id)?;
|
||||||
|
let schema = problem.get("schema").ok_or_else(|| {
|
||||||
|
GenerationError::invalid("missing startup Problem Details schema")
|
||||||
|
})?;
|
||||||
|
require_schema_ref(schema, "StartupProblemDetails", operation.operation_id)?;
|
||||||
|
roots.insert("StartupProblemDetails".to_owned());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (status, expected) in successes {
|
for (status, expected) in successes {
|
||||||
if !responses.contains_key(status) {
|
if !responses.contains_key(status) {
|
||||||
@@ -1352,6 +1447,7 @@ fn validate_operation_contracts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct OperationContract {
|
struct OperationContract {
|
||||||
|
error: &'static str,
|
||||||
path: &'static str,
|
path: &'static str,
|
||||||
method: &'static str,
|
method: &'static str,
|
||||||
operation_id: &'static str,
|
operation_id: &'static str,
|
||||||
@@ -1789,8 +1885,31 @@ fn render_typed_schema(
|
|||||||
) -> Result<String, GenerationError> {
|
) -> Result<String, GenerationError> {
|
||||||
match kind {
|
match kind {
|
||||||
"string" => {
|
"string" => {
|
||||||
ensure_schema_keywords(schema, &["type"], context)?;
|
ensure_schema_keywords(
|
||||||
Ok("string".to_owned())
|
schema,
|
||||||
|
&["type", "minLength", "maxLength", "pattern"],
|
||||||
|
context,
|
||||||
|
)?;
|
||||||
|
let mut constraints = Vec::new();
|
||||||
|
if schema.contains_key("minLength") || schema.contains_key("maxLength") {
|
||||||
|
let range =
|
||||||
|
render_length_range(schema.get("minLength"), schema.get("maxLength"), context)?;
|
||||||
|
constraints.push(format!("readonly \"__yoiStringLength\": \"{range}\";"));
|
||||||
|
}
|
||||||
|
if let Some(pattern) = schema.get("pattern") {
|
||||||
|
let pattern = pattern.as_str().ok_or_else(|| {
|
||||||
|
GenerationError::invalid(format!("{context} string pattern must be a string"))
|
||||||
|
})?;
|
||||||
|
constraints.push(format!(
|
||||||
|
"readonly \"__yoiStringPattern\": {};",
|
||||||
|
serde_json::to_string(pattern).expect("string encoding")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if constraints.is_empty() {
|
||||||
|
Ok("string".to_owned())
|
||||||
|
} else {
|
||||||
|
Ok(format!("string & {{ {} }}", constraints.join(" ")))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"boolean" => {
|
"boolean" => {
|
||||||
ensure_schema_keywords(schema, &["type"], context)?;
|
ensure_schema_keywords(schema, &["type"], context)?;
|
||||||
@@ -1880,9 +1999,12 @@ fn render_typed_schema(
|
|||||||
.join(", ")
|
.join(", ")
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
_ => Err(GenerationError::invalid(format!(
|
(minimum, maximum) => {
|
||||||
"{context} contains unsupported variable array bounds"
|
let range = render_length_range(minimum, maximum, context)?;
|
||||||
))),
|
Ok(format!(
|
||||||
|
"Array<{item}> & {{ readonly \"__yoiArrayLength\": \"{range}\"; }}"
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"object" => {
|
"object" => {
|
||||||
@@ -1899,6 +2021,36 @@ fn render_typed_schema(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nominal markers preserve wire bounds in generated declarations; they are not
|
||||||
|
// runtime validators. Only finite safe non-negative integer constraints are admitted.
|
||||||
|
fn render_length_range(
|
||||||
|
minimum: Option<&Value>,
|
||||||
|
maximum: Option<&Value>,
|
||||||
|
context: &str,
|
||||||
|
) -> Result<String, GenerationError> {
|
||||||
|
let bound = |value: &Value| {
|
||||||
|
value
|
||||||
|
.as_u64()
|
||||||
|
.filter(|value| *value <= MAX_SAFE_INTEGER as u64)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
GenerationError::invalid(format!(
|
||||||
|
"{context} length bound must be a safe non-negative integer"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let minimum = minimum.map(bound).transpose()?.unwrap_or(0);
|
||||||
|
let maximum = maximum.map(bound).transpose()?;
|
||||||
|
if maximum.is_some_and(|maximum| minimum > maximum) {
|
||||||
|
return Err(GenerationError::invalid(format!(
|
||||||
|
"{context} length range is inverted"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(format!(
|
||||||
|
"{minimum}..{}",
|
||||||
|
maximum.map_or_else(|| "unbounded".into(), |maximum| maximum.to_string())
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn render_object(schema: &Map<String, Value>, context: &str) -> Result<String, GenerationError> {
|
fn render_object(schema: &Map<String, Value>, context: &str) -> Result<String, GenerationError> {
|
||||||
let empty_properties = Map::new();
|
let empty_properties = Map::new();
|
||||||
let properties = match schema.get("properties") {
|
let properties = match schema.get("properties") {
|
||||||
@@ -2282,6 +2434,50 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bounded_strings_and_variable_arrays_preserve_constraints_without_becoming_validators() {
|
||||||
|
assert_eq!(
|
||||||
|
render_schema(
|
||||||
|
&serde_json::json!({"type":"string", "minLength":1, "maxLength":128}),
|
||||||
|
"bounded"
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
"string & { readonly \"__yoiStringLength\": \"1..128\"; }"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
render_schema(
|
||||||
|
&serde_json::json!({"type":"array", "items":{"type":"string"}, "maxItems":8}),
|
||||||
|
"bounded"
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
"Array<string> & { readonly \"__yoiArrayLength\": \"0..8\"; }"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
render_schema(
|
||||||
|
&serde_json::json!({"type":"string", "pattern":"^[a-z]+$"}),
|
||||||
|
"pattern"
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
"string & { readonly \"__yoiStringPattern\": \"^[a-z]+$\"; }"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
render_schema(
|
||||||
|
&serde_json::json!({"type":"string", "pattern":42}),
|
||||||
|
"pattern"
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
for schema in [
|
||||||
|
serde_json::json!({"type":"string", "maxLength":-1}),
|
||||||
|
serde_json::json!({"type":"string", "minLength":2, "maxLength":1}),
|
||||||
|
serde_json::json!({"type":"string", "maxLength":1.5}),
|
||||||
|
serde_json::json!({"type":"array", "items":{"type":"string"}, "maxItems":9007199254740992u64}),
|
||||||
|
serde_json::json!({"type":"array", "items":{"type":"string"}, "minItems":9, "maxItems":8}),
|
||||||
|
] {
|
||||||
|
assert!(render_schema(&schema, "bounded").is_err(), "{schema}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn generator_rejects_lossy_reference_siblings() {
|
fn generator_rejects_lossy_reference_siblings() {
|
||||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||||
@@ -2303,13 +2499,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn generator_rejects_unimplemented_ordinary_schema_keywords() {
|
fn generator_rejects_unimplemented_ordinary_schema_keywords() {
|
||||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||||
document["components"]["schemas"]["RepositorySource"]["properties"]["uri"]["maxLength"] =
|
document["components"]["schemas"]["RepositorySource"]["properties"]["uri"]["format"] =
|
||||||
serde_json::json!(1);
|
serde_json::json!("email");
|
||||||
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
|
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
error
|
error
|
||||||
.to_string()
|
.to_string()
|
||||||
.contains("unsupported schema keyword `maxLength`")
|
.contains("unsupported schema keyword `format`")
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
//! Worker creation's HTTP projection is separate from its HTTP-independent result.
|
||||||
|
//! A decoded DTO is never an encoder capability: only a current visibility decision
|
||||||
|
//! can populate the cached public response. Legacy preflight routes keep their body.
|
||||||
|
use api_macros::{HttpError, HttpErrorResponse};
|
||||||
|
use runtime_api::startup::{
|
||||||
|
DecodeStartupProblem, SessionFailureVisibility, StartupBoundary, StartupProblemDetails,
|
||||||
|
WorkerCreateFailure, decode_startup_problem,
|
||||||
|
};
|
||||||
|
use schemars::{JsonSchema, Schema, SchemaGenerator};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::RepositoryApiError;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct WorkerCreateApiError {
|
||||||
|
legacy: RepositoryApiError,
|
||||||
|
failure: Option<WorkerCreateFailure>,
|
||||||
|
projected: Option<runtime_api::startup::ProblemResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkerCreateApiError {
|
||||||
|
/// The service has authenticated and authorized the current creation caller.
|
||||||
|
/// Visibility is a local decision; neither wire labels nor saved results supply it.
|
||||||
|
pub fn startup(failure: WorkerCreateFailure, visibility: &SessionFailureVisibility) -> Self {
|
||||||
|
let instance = failure
|
||||||
|
.operation()
|
||||||
|
.correlation
|
||||||
|
.as_ref()
|
||||||
|
.map(|correlation| format!("urn:yoi:worker-startup:{}", correlation.as_str()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let projected = failure
|
||||||
|
.problem(StartupBoundary::Browser, visibility, &instance)
|
||||||
|
.ok();
|
||||||
|
let status = projected.as_ref().map_or(500, |response| response.status);
|
||||||
|
Self {
|
||||||
|
legacy: RepositoryApiError::new(
|
||||||
|
status,
|
||||||
|
"worker_startup",
|
||||||
|
"Worker creation did not complete; inspect the typed result.",
|
||||||
|
Vec::new(),
|
||||||
|
),
|
||||||
|
failure: Some(failure),
|
||||||
|
projected,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn failure(&self) -> Option<&WorkerCreateFailure> {
|
||||||
|
self.failure.as_ref()
|
||||||
|
}
|
||||||
|
pub fn legacy(&self) -> &RepositoryApiError {
|
||||||
|
&self.legacy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RepositoryApiError> for WorkerCreateApiError {
|
||||||
|
fn from(legacy: RepositoryApiError) -> Self {
|
||||||
|
Self {
|
||||||
|
legacy,
|
||||||
|
failure: None,
|
||||||
|
projected: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpError for WorkerCreateApiError {
|
||||||
|
fn status_code(&self) -> u16 {
|
||||||
|
self.projected
|
||||||
|
.as_ref()
|
||||||
|
.map_or_else(|| self.legacy.status_code(), |response| response.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn custom_response(&self) -> Option<HttpErrorResponse> {
|
||||||
|
if let Some(projected) = &self.projected {
|
||||||
|
return Some(HttpErrorResponse {
|
||||||
|
status: projected.status,
|
||||||
|
content_type: projected.content_type,
|
||||||
|
body: projected.body().to_vec().into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Projection failure does not serialize a possibly private internal result.
|
||||||
|
self.failure.as_ref().map(|_| {
|
||||||
|
error_contract::problem::problem_fallback(error_contract::Budget::new(
|
||||||
|
error_contract::MAX_FAILURE_BYTES,
|
||||||
|
error_contract::MAX_FAILURE_BYTES,
|
||||||
|
))
|
||||||
|
.expect("static fallback fits the fixed boundary budget")
|
||||||
|
.into()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl api_macros::HttpRequestError for WorkerCreateApiError {
|
||||||
|
fn from_request_rejection(status: u16, message: String) -> Self {
|
||||||
|
<RepositoryApiError as api_macros::HttpRequestError>::from_request_rejection(
|
||||||
|
status, message,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for WorkerCreateApiError {
|
||||||
|
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
// Generated HTTP adapters use custom_response. The ordinary serializer remains
|
||||||
|
// legacy-only and cannot serialize the retained internal startup attachment.
|
||||||
|
if let Some(projected) = &self.projected {
|
||||||
|
let dto: StartupProblemDetails =
|
||||||
|
serde_json::from_slice(projected.body()).map_err(serde::ser::Error::custom)?;
|
||||||
|
dto.serialize(serializer)
|
||||||
|
} else {
|
||||||
|
self.legacy.serialize(serializer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<'de> Deserialize<'de> for WorkerCreateApiError {
|
||||||
|
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
RepositoryApiError::deserialize(deserializer).map(Into::into)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(JsonSchema)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum WorkerCreateErrorWire {
|
||||||
|
Problem(StartupProblemDetails),
|
||||||
|
Legacy(RepositoryApiError),
|
||||||
|
}
|
||||||
|
impl JsonSchema for WorkerCreateApiError {
|
||||||
|
fn schema_name() -> std::borrow::Cow<'static, str> {
|
||||||
|
"WorkerCreateApiError".into()
|
||||||
|
}
|
||||||
|
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||||
|
WorkerCreateErrorWire::json_schema(generator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl api_macros::openapi::OpenApiSchema for WorkerCreateApiError {
|
||||||
|
fn response_schema(
|
||||||
|
builder: &mut api_macros::openapi::OpenApiBuilder,
|
||||||
|
content_type: &str,
|
||||||
|
) -> Result<serde_json::Value, api_macros::openapi::OpenApiError> {
|
||||||
|
match content_type {
|
||||||
|
"application/json" => builder.schema_ref::<RepositoryApiError>(),
|
||||||
|
runtime_api::startup::PROBLEM_CONTENT_TYPE => {
|
||||||
|
builder.schema_ref::<StartupProblemDetails>()
|
||||||
|
}
|
||||||
|
_ => Err(api_macros::openapi::OpenApiError::InvalidContract(
|
||||||
|
"unsupported Worker creation error representation".into(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl api_macros::reqwest::HttpClientError for WorkerCreateApiError {
|
||||||
|
fn decode_error(
|
||||||
|
response: &api_macros::reqwest::ReceivedResponse,
|
||||||
|
) -> Result<Self, api_macros::reqwest::ClientFailure> {
|
||||||
|
use api_macros::reqwest::ClientFailure;
|
||||||
|
let content_type = response.content_type()?;
|
||||||
|
if content_type
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case(runtime_api::startup::PROBLEM_CONTENT_TYPE)
|
||||||
|
{
|
||||||
|
return match decode_startup_problem(
|
||||||
|
response.body(),
|
||||||
|
response.status().as_u16(),
|
||||||
|
content_type,
|
||||||
|
StartupBoundary::Browser,
|
||||||
|
) {
|
||||||
|
DecodeStartupProblem::Known(failure) => Ok(Self {
|
||||||
|
legacy: RepositoryApiError::new(
|
||||||
|
response.status().as_u16(),
|
||||||
|
"worker_startup",
|
||||||
|
"Worker creation returned a typed failure.",
|
||||||
|
Vec::new(),
|
||||||
|
),
|
||||||
|
failure: Some(failure),
|
||||||
|
projected: None,
|
||||||
|
}),
|
||||||
|
DecodeStartupProblem::TooLarge => Err(ClientFailure::ResponseTooLarge {
|
||||||
|
limit: runtime_api::startup::MAX_STARTUP_PROBLEM_BYTES,
|
||||||
|
}),
|
||||||
|
DecodeStartupProblem::UnsupportedVersion
|
||||||
|
| DecodeStartupProblem::UnsupportedReason => {
|
||||||
|
Err(ClientFailure::UnsupportedErrorResponse {
|
||||||
|
status: response.status().as_u16(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
DecodeStartupProblem::Invalid => Err(ClientFailure::ErrorResponseDecode {
|
||||||
|
status: response.status().as_u16(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let legacy = response
|
||||||
|
.decode_json::<RepositoryApiError>(api_macros::reqwest::DecodeKind::PublicError)?;
|
||||||
|
Ok(legacy.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
//! Generated Backend client → isolated loopback HTTP response → typed result.
|
||||||
|
//! The server response producer is substituted; authorization and persistence are
|
||||||
|
//! exercised by workspace-server's connected tests, not by this codec connection.
|
||||||
|
use runtime_api::startup::*;
|
||||||
|
use server_api::{CreateWorkspaceWorkerRequest, ServerApiClient, client_support, server_support};
|
||||||
|
use server_support::framework::{Body, Response, Router, post};
|
||||||
|
|
||||||
|
struct ServerTask(tokio::task::JoinHandle<()>);
|
||||||
|
impl Drop for ServerTask {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request() -> CreateWorkspaceWorkerRequest {
|
||||||
|
serde_json::from_value(serde_json::json!({
|
||||||
|
"runtime_id": "runtime", "display_name": "Worker", "feature_connections": {}
|
||||||
|
}))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn every_creation_client_decodes_actual_problem_status_and_operation_facts() {
|
||||||
|
let timeout = WorkerCreateFailure::from_cause(
|
||||||
|
StartupCause::Boundary(StartupBoundaryCause {
|
||||||
|
reason: StartupBoundaryReason::Timeout,
|
||||||
|
}),
|
||||||
|
StartupOperationFacts {
|
||||||
|
acceptance: CreationAcceptance::Unknown,
|
||||||
|
execution: ExecutionOutcome::Unknown,
|
||||||
|
finalize: None,
|
||||||
|
backend_compensation: None,
|
||||||
|
correlation: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.problem(
|
||||||
|
StartupBoundary::Browser,
|
||||||
|
&SessionFailureVisibility::default(),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for (status, body) in [
|
||||||
|
(409, include_bytes!("../../runtime-api/tests/fixtures/startup-browser-session-record-denied.problem.json").to_vec()),
|
||||||
|
(502, include_bytes!("../../runtime-api/tests/fixtures/startup-browser-registration-response-lost.problem.json").to_vec()),
|
||||||
|
(504, timeout.into_body()),
|
||||||
|
] {
|
||||||
|
let DecodeStartupProblem::Known(expected) = decode_startup_problem(&body, status, PROBLEM_CONTENT_TYPE, StartupBoundary::Browser) else {
|
||||||
|
panic!("producer fixture must have a known typed cause");
|
||||||
|
};
|
||||||
|
let router = ["/api/workers", "/api/w/workspace/workers", "/api/w/workspace/worker-control/workers"]
|
||||||
|
.into_iter().fold(Router::new(), |router, path| {
|
||||||
|
let body = body.clone();
|
||||||
|
router.route(path, post(move || {
|
||||||
|
let body = body.clone();
|
||||||
|
async move { Response::builder().status(status).header("content-type", PROBLEM_CONTENT_TYPE).body(Body::from(body)).unwrap() }
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let _server = ServerTask(tokio::spawn(async move {
|
||||||
|
server_support::framework::serve(listener, router).await.unwrap();
|
||||||
|
}));
|
||||||
|
let client = ServerApiClient::builder(format!("http://{address}"))
|
||||||
|
.unwrap().request_timeout(std::time::Duration::from_secs(3)).build().unwrap();
|
||||||
|
for result in [
|
||||||
|
client.workspace_worker_create("workspace".into(), request()).await,
|
||||||
|
client.workspace_worker_create_alias(request()).await,
|
||||||
|
client.worker_control_spawn("workspace".into(), request()).await,
|
||||||
|
] {
|
||||||
|
let Err(client_support::ClientError::Public { status: actual_status, error }) = result else {
|
||||||
|
panic!("generated route must invoke the Problem decoder, not legacy Deserialize");
|
||||||
|
};
|
||||||
|
assert_eq!(actual_status.as_u16(), status);
|
||||||
|
let actual = error.failure().expect("retain typed non-HTTP failure");
|
||||||
|
assert_eq!(actual.startup().primary, expected.startup().primary);
|
||||||
|
assert_eq!(actual.public_facts(), expected.public_facts());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn creation_openapi_declares_gateway_timeout_with_both_error_representations() {
|
||||||
|
let document: serde_json::Value = serde_json::from_str(
|
||||||
|
&server_api::canonical_openapi_document()
|
||||||
|
.unwrap()
|
||||||
|
.to_json()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for path in ["/api/workers", "/api/w/{workspace_id}/workers"] {
|
||||||
|
let content = &document["paths"][path]["post"]["responses"]["504"]["content"];
|
||||||
|
assert!(content.get("application/problem+json").is_some());
|
||||||
|
assert!(content.get("application/json").is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
error-contract.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
agen = { workspace = true }
|
agen = { workspace = true }
|
||||||
fs4.workspace = true
|
fs4.workspace = true
|
||||||
|
|||||||
@@ -114,9 +114,11 @@ pub use uploaded_file::{
|
|||||||
UploadedFileUploadContext,
|
UploadedFileUploadContext,
|
||||||
};
|
};
|
||||||
pub use worker_metadata::{
|
pub use worker_metadata::{
|
||||||
CombinedStore, FsWorkerStore, SubjektivSessionAttributionState, WorkerActiveSegmentRef,
|
CombinedStore, FsWorkerStore, SessionStartupFailure, StartupFailureBindingMismatch,
|
||||||
WorkerAggregateStore, WorkerMetadata, WorkerMetadataStore, WorkerPeer, WorkerReclaimedChild,
|
StartupFailureExpectation, StartupFailureRead, StartupFailureSaveResult,
|
||||||
WorkerSpawnedChild, WorkerSpawnedScopeRule, WorkerStoreError, validate_worker_name,
|
SubjektivSessionAttributionState, WorkerActiveSegmentRef, WorkerAggregateStore, WorkerMetadata,
|
||||||
|
WorkerMetadataStore, WorkerPeer, WorkerReclaimedChild, WorkerSpawnedChild,
|
||||||
|
WorkerSpawnedScopeRule, WorkerStoreError, validate_worker_name,
|
||||||
};
|
};
|
||||||
pub use worker_session_store::WorkerSessionStore;
|
pub use worker_session_store::WorkerSessionStore;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,727 @@
|
|||||||
|
//! Component coverage of startup attachments through real FsWorkerStore files,
|
||||||
|
//! reopen and process-local CAS. Only the directory-sync IO boundary is replaced
|
||||||
|
//! for the post-rename failure case; these tests do not claim OS-crash durability.
|
||||||
|
//! Selected by the normal session-store lib test target.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use error_contract::{FailureFacts, OperationContext, Storage, project_failure};
|
||||||
|
use protocol::startup_failure::{RuntimeStartupCause, RuntimeStartupReason, StartupStage};
|
||||||
|
|
||||||
|
fn attachment(stage: StartupStage) -> StoredStartupFailureAttachment {
|
||||||
|
let mut facts = FailureFacts::new(RuntimeStartupCause {
|
||||||
|
stage,
|
||||||
|
reason: RuntimeStartupReason::StartupFailed,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
facts
|
||||||
|
.with_context(OperationContext {
|
||||||
|
operation: "worker_startup".into(),
|
||||||
|
stage: "runtime".into(),
|
||||||
|
operation_ref: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let projected = project_failure::<Storage, _>(&facts, &()).unwrap();
|
||||||
|
StoredStartupFailureAttachment::from_projected(&projected).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata() -> WorkerMetadata {
|
||||||
|
let session_id = crate::new_session_id();
|
||||||
|
let mut metadata = WorkerMetadata::new(
|
||||||
|
"agent",
|
||||||
|
Some(WorkerActiveSegmentRef::pending_segment(session_id)),
|
||||||
|
)
|
||||||
|
.with_workspace_id("workspace-one");
|
||||||
|
metadata.subjektiv_session_attribution = Some(SubjektivSessionAttributionState::Failed {
|
||||||
|
session_id,
|
||||||
|
diagnostic: "legacy diagnostic is not a typed cause".into(),
|
||||||
|
});
|
||||||
|
metadata.resolved_manifest_snapshot = Some(serde_json::json!({"retained": true}));
|
||||||
|
metadata.peers.push(WorkerPeer {
|
||||||
|
worker_name: "peer".into(),
|
||||||
|
});
|
||||||
|
metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bound_failure(
|
||||||
|
metadata: &WorkerMetadata,
|
||||||
|
attachment: &StoredStartupFailureAttachment,
|
||||||
|
) -> SessionStartupFailure {
|
||||||
|
SessionStartupFailure::from_expected(
|
||||||
|
&StartupFailureExpectation::from_metadata(metadata).unwrap(),
|
||||||
|
attachment,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_stored_cause(metadata: &WorkerMetadata, stage: StartupStage) {
|
||||||
|
match metadata.decode_startup_failure::<RuntimeStartupCause>() {
|
||||||
|
StartupFailureRead::Present(DecodeFailure::Known(received)) => {
|
||||||
|
assert_eq!(received.facts().cause().stage, stage);
|
||||||
|
assert_eq!(
|
||||||
|
received.facts().cause().reason,
|
||||||
|
RuntimeStartupReason::StartupFailed
|
||||||
|
);
|
||||||
|
assert_eq!(received.facts().context().operations.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
received.facts().context().operations[0].operation,
|
||||||
|
"worker_startup"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => panic!("expected a known typed startup cause"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_failure_cas_reopens_typed_cause_and_preserves_unrelated_metadata() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let store = FsWorkerStore::new(temp.path()).unwrap();
|
||||||
|
let original = metadata();
|
||||||
|
store.write(&original).unwrap();
|
||||||
|
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
|
||||||
|
let cause = attachment(StartupStage::Binding);
|
||||||
|
let received = cause.clone();
|
||||||
|
assert!(matches!(
|
||||||
|
store.compare_and_swap_startup_failure(&expected, &cause),
|
||||||
|
StartupFailureSaveResult::Persisted
|
||||||
|
));
|
||||||
|
assert_eq!(cause, received);
|
||||||
|
drop(store);
|
||||||
|
|
||||||
|
let reopened = FsWorkerStore::new(temp.path()).unwrap();
|
||||||
|
let restored = reopened.read_by_name("agent").unwrap().unwrap();
|
||||||
|
assert_stored_cause(&restored, StartupStage::Binding);
|
||||||
|
let mut wanted = original;
|
||||||
|
wanted.startup_failure = Some(SessionStartupFailure::from_expected(&expected, &received));
|
||||||
|
assert_eq!(restored, wanted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_failure_wrapper_fingerprints_attribution_without_copying_raw_diagnostics() {
|
||||||
|
const CREDENTIAL: &str = "credential=TEST_ONLY_METADATA_SENTINEL";
|
||||||
|
const PRIVATE_PATH: &str = "/private/test-only-attribution/session.json";
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let store = FsWorkerStore::new(temp.path()).unwrap();
|
||||||
|
let mut original = metadata();
|
||||||
|
original.subjektiv_session_attribution = Some(SubjektivSessionAttributionState::Failed {
|
||||||
|
session_id: original.active.as_ref().unwrap().session_id,
|
||||||
|
diagnostic: format!("{CREDENTIAL} {PRIVATE_PATH}"),
|
||||||
|
});
|
||||||
|
store.write(&original).unwrap();
|
||||||
|
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
store.compare_and_swap_startup_failure(&expected, &attachment(StartupStage::Binding)),
|
||||||
|
StartupFailureSaveResult::Persisted
|
||||||
|
));
|
||||||
|
drop(store);
|
||||||
|
|
||||||
|
let reopened = FsWorkerStore::new(temp.path()).unwrap();
|
||||||
|
let restored = reopened.read_by_name("agent").unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
restored.subjektiv_session_attribution,
|
||||||
|
original.subjektiv_session_attribution
|
||||||
|
);
|
||||||
|
// The original field still retains the diagnostic: absence from the wrapper
|
||||||
|
// must not be explained by removing or redacting the source metadata instead.
|
||||||
|
let retained_attribution =
|
||||||
|
serde_json::to_string(&restored.subjektiv_session_attribution).unwrap();
|
||||||
|
assert!(retained_attribution.contains(CREDENTIAL));
|
||||||
|
assert!(retained_attribution.contains(PRIVATE_PATH));
|
||||||
|
let wrapper = restored.startup_failure.as_ref().unwrap();
|
||||||
|
let wrapper_json = serde_json::to_string(wrapper).unwrap();
|
||||||
|
assert!(!wrapper_json.contains(CREDENTIAL));
|
||||||
|
assert!(!wrapper_json.contains(PRIVATE_PATH));
|
||||||
|
let wrapper_value = serde_json::to_value(wrapper).unwrap();
|
||||||
|
assert!(wrapper_value.get("attribution").is_none());
|
||||||
|
let expected_fingerprint: [u8; 32] = Sha256::digest(retained_attribution.as_bytes()).into();
|
||||||
|
assert_eq!(bound(wrapper).attribution_fingerprint, expected_fingerprint);
|
||||||
|
assert_stored_cause(&restored, StartupStage::Binding);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_failure_cas_rejects_changed_session_owner_attribution_and_cause_without_rewrite() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let store = FsWorkerStore::new(temp.path()).unwrap();
|
||||||
|
let original = metadata();
|
||||||
|
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
|
||||||
|
let cause = attachment(StartupStage::Binding);
|
||||||
|
let path = temp.path().join("agent/metadata.json");
|
||||||
|
|
||||||
|
// Each row changes one compared fact. Worker-name mismatch is an on-disk
|
||||||
|
// identity mismatch, not a write to a different key.
|
||||||
|
let changed = [
|
||||||
|
{
|
||||||
|
let mut value = original.clone();
|
||||||
|
value.active = Some(WorkerActiveSegmentRef::pending_segment(
|
||||||
|
crate::new_session_id(),
|
||||||
|
));
|
||||||
|
value
|
||||||
|
},
|
||||||
|
{
|
||||||
|
let mut value = original.clone();
|
||||||
|
value.workspace_id = Some("new-owner".into());
|
||||||
|
value
|
||||||
|
},
|
||||||
|
{
|
||||||
|
let mut value = original.clone();
|
||||||
|
value.worker_name = "new-worker".into();
|
||||||
|
value
|
||||||
|
},
|
||||||
|
{
|
||||||
|
let mut value = original.clone();
|
||||||
|
value.subjektiv_session_attribution =
|
||||||
|
Some(SubjektivSessionAttributionState::Confirmed {
|
||||||
|
session_id: expected.session_id,
|
||||||
|
subject_id: "new-subject".into(),
|
||||||
|
});
|
||||||
|
value
|
||||||
|
},
|
||||||
|
{
|
||||||
|
let mut value = original.clone();
|
||||||
|
value.subjektiv_session_attribution = None;
|
||||||
|
value
|
||||||
|
},
|
||||||
|
{
|
||||||
|
let mut value = original.clone();
|
||||||
|
value.subjektiv_session_attribution = Some(SubjektivSessionAttributionState::Failed {
|
||||||
|
session_id: expected.session_id,
|
||||||
|
diagnostic: "new attribution observation".into(),
|
||||||
|
});
|
||||||
|
value
|
||||||
|
},
|
||||||
|
{
|
||||||
|
let mut value = original.clone();
|
||||||
|
value.startup_failure = Some(bound_failure(&value, &attachment(StartupStage::Feature)));
|
||||||
|
value
|
||||||
|
},
|
||||||
|
{
|
||||||
|
let mut value = original.clone();
|
||||||
|
value.active = None;
|
||||||
|
value
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for value in changed {
|
||||||
|
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
// Retain noncanonical whitespace to detect even a no-op rewrite.
|
||||||
|
let bytes = format!(" {}\n", serde_json::to_string(&value).unwrap());
|
||||||
|
fs::write(&path, &bytes).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
store.compare_and_swap_startup_failure(&expected, &cause),
|
||||||
|
StartupFailureSaveResult::Conflict
|
||||||
|
));
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), bytes.as_bytes());
|
||||||
|
assert_eq!(store.read_by_name("agent").unwrap().unwrap(), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_failure_replacement_requires_the_observed_existing_attachment() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let store = FsWorkerStore::new(temp.path()).unwrap();
|
||||||
|
let mut original = metadata();
|
||||||
|
original.startup_failure = Some(bound_failure(&original, &attachment(StartupStage::Feature)));
|
||||||
|
store.write(&original).unwrap();
|
||||||
|
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
|
||||||
|
let mut stale = expected.clone();
|
||||||
|
stale.startup_failure = None;
|
||||||
|
let cause = attachment(StartupStage::Binding);
|
||||||
|
assert!(matches!(
|
||||||
|
store.compare_and_swap_startup_failure(&stale, &cause),
|
||||||
|
StartupFailureSaveResult::Conflict
|
||||||
|
));
|
||||||
|
assert_eq!(store.read_by_name("agent").unwrap().unwrap(), original);
|
||||||
|
assert!(matches!(
|
||||||
|
store.compare_and_swap_startup_failure(&expected, &cause),
|
||||||
|
StartupFailureSaveResult::Persisted
|
||||||
|
));
|
||||||
|
assert_stored_cause(
|
||||||
|
&store.read_by_name("agent").unwrap().unwrap(),
|
||||||
|
StartupStage::Binding,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_failure_cas_missing_metadata_does_not_create_a_worker() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let store = FsWorkerStore::new(temp.path()).unwrap();
|
||||||
|
let expected = StartupFailureExpectation::from_metadata(&metadata()).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
store.compare_and_swap_startup_failure(&expected, &attachment(StartupStage::Binding)),
|
||||||
|
StartupFailureSaveResult::Conflict
|
||||||
|
));
|
||||||
|
assert!(store.read_by_name("agent").unwrap().is_none());
|
||||||
|
assert!(!temp.path().join("agent").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_failure_cas_allows_segment_movement_within_expected_session() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let store = FsWorkerStore::new(temp.path()).unwrap();
|
||||||
|
let original = metadata();
|
||||||
|
store.write(&original).unwrap();
|
||||||
|
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
|
||||||
|
let active =
|
||||||
|
WorkerActiveSegmentRef::active_segment(expected.session_id, crate::new_segment_id());
|
||||||
|
store
|
||||||
|
.set_active("agent", Some(active.clone()), None)
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
store.compare_and_swap_startup_failure(&expected, &attachment(StartupStage::Binding)),
|
||||||
|
StartupFailureSaveResult::Persisted
|
||||||
|
));
|
||||||
|
let restored = store.read_by_name("agent").unwrap().unwrap();
|
||||||
|
assert_eq!(restored.active, Some(active));
|
||||||
|
assert_eq!(
|
||||||
|
restored.subjektiv_session_attribution,
|
||||||
|
original.subjektiv_session_attribution
|
||||||
|
);
|
||||||
|
// A later Segment switch is not a Session/ownership change either.
|
||||||
|
store
|
||||||
|
.set_active(
|
||||||
|
"agent",
|
||||||
|
Some(WorkerActiveSegmentRef::active_segment(
|
||||||
|
expected.session_id,
|
||||||
|
crate::new_segment_id(),
|
||||||
|
)),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_stored_cause(
|
||||||
|
&store.read_by_name("agent").unwrap().unwrap(),
|
||||||
|
StartupStage::Binding,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_failure_saved_binding_rejects_session_owner_and_attribution_changes_after_reopen() {
|
||||||
|
for case in 0..7 {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let store = FsWorkerStore::new(temp.path()).unwrap();
|
||||||
|
let original = metadata();
|
||||||
|
store.write(&original).unwrap();
|
||||||
|
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
|
||||||
|
let cause = attachment(StartupStage::Binding);
|
||||||
|
assert!(matches!(
|
||||||
|
store.compare_and_swap_startup_failure(&expected, &cause),
|
||||||
|
StartupFailureSaveResult::Persisted
|
||||||
|
));
|
||||||
|
let saved = store
|
||||||
|
.read_by_name("agent")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.startup_failure;
|
||||||
|
store
|
||||||
|
.update_by_name("agent", |value| match case {
|
||||||
|
0 => {
|
||||||
|
value.active = Some(WorkerActiveSegmentRef::pending_segment(
|
||||||
|
crate::new_session_id(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
1 => value.workspace_id = Some("new-workspace-owner".into()),
|
||||||
|
2 => {
|
||||||
|
value.subjektiv_session_attribution =
|
||||||
|
Some(SubjektivSessionAttributionState::Confirmed {
|
||||||
|
session_id: expected.session_id,
|
||||||
|
subject_id: "new-subject-owner".into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
3 => {
|
||||||
|
value.subjektiv_session_attribution =
|
||||||
|
Some(SubjektivSessionAttributionState::Failed {
|
||||||
|
session_id: expected.session_id,
|
||||||
|
diagnostic: "changed attribution observation".into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
4 => value.active = None,
|
||||||
|
5 => value.workspace_id = None,
|
||||||
|
6 => value.subjektiv_session_attribution = None,
|
||||||
|
_ => unreachable!(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
drop(store);
|
||||||
|
|
||||||
|
let reopened = FsWorkerStore::new(temp.path()).unwrap();
|
||||||
|
let restored = reopened.read_by_name("agent").unwrap().unwrap();
|
||||||
|
// State changes retain the old cause as evidence; they do not rebind it
|
||||||
|
// to the new Session/owner and do not turn a mismatch into absence.
|
||||||
|
assert_eq!(restored.startup_failure, saved);
|
||||||
|
let path = temp.path().join("agent/metadata.json");
|
||||||
|
let bytes_before_read = fs::read(&path).unwrap();
|
||||||
|
match restored.decode_startup_failure::<RuntimeStartupCause>() {
|
||||||
|
StartupFailureRead::Mismatch(mismatch) => assert_eq!(
|
||||||
|
mismatch,
|
||||||
|
StartupFailureBindingMismatch {
|
||||||
|
worker_name: false,
|
||||||
|
session_id: matches!(case, 0 | 4),
|
||||||
|
workspace_id: matches!(case, 1 | 5),
|
||||||
|
attribution: matches!(case, 2 | 3 | 6),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
_ => panic!("saved cause must not decode for a changed binding (case {case})"),
|
||||||
|
}
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), bytes_before_read);
|
||||||
|
assert!(matches!(
|
||||||
|
reopened.compare_and_swap_startup_failure(&expected, &cause),
|
||||||
|
StartupFailureSaveResult::Conflict
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_failure_copy_to_another_worker_keeps_original_binding() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let store = FsWorkerStore::new(temp.path()).unwrap();
|
||||||
|
let original = metadata();
|
||||||
|
store.write(&original).unwrap();
|
||||||
|
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
store.compare_and_swap_startup_failure(&expected, &attachment(StartupStage::Binding)),
|
||||||
|
StartupFailureSaveResult::Persisted
|
||||||
|
));
|
||||||
|
let mut copied = store.read_by_name("agent").unwrap().unwrap();
|
||||||
|
copied.worker_name = "replacement".into();
|
||||||
|
store.write(&copied).unwrap();
|
||||||
|
drop(store);
|
||||||
|
let reopened = FsWorkerStore::new(temp.path()).unwrap();
|
||||||
|
let restored = reopened.read_by_name("replacement").unwrap().unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
restored.decode_startup_failure::<RuntimeStartupCause>(),
|
||||||
|
StartupFailureRead::Mismatch(StartupFailureBindingMismatch {
|
||||||
|
worker_name: true,
|
||||||
|
session_id: false,
|
||||||
|
workspace_id: false,
|
||||||
|
attribution: false,
|
||||||
|
})
|
||||||
|
));
|
||||||
|
assert_stored_cause(
|
||||||
|
&reopened.read_by_name("agent").unwrap().unwrap(),
|
||||||
|
StartupStage::Binding,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_failure_cas_compares_existing_binding_even_when_attachment_is_unchanged() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let store = FsWorkerStore::new(temp.path()).unwrap();
|
||||||
|
let mut original = metadata();
|
||||||
|
original.startup_failure = Some(bound_failure(&original, &attachment(StartupStage::Feature)));
|
||||||
|
store.write(&original).unwrap();
|
||||||
|
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
|
||||||
|
let mut stale = expected.clone();
|
||||||
|
bound_mut(stale.startup_failure.as_mut().unwrap()).session_id = crate::new_session_id();
|
||||||
|
assert!(matches!(
|
||||||
|
store.compare_and_swap_startup_failure(&stale, &attachment(StartupStage::Binding)),
|
||||||
|
StartupFailureSaveResult::Conflict
|
||||||
|
));
|
||||||
|
assert_eq!(store.read_by_name("agent").unwrap().unwrap(), original);
|
||||||
|
let mut changed_fingerprint = expected.clone();
|
||||||
|
bound_mut(changed_fingerprint.startup_failure.as_mut().unwrap()).attribution_fingerprint[0] ^=
|
||||||
|
1;
|
||||||
|
assert!(matches!(
|
||||||
|
store.compare_and_swap_startup_failure(
|
||||||
|
&changed_fingerprint,
|
||||||
|
&attachment(StartupStage::Binding)
|
||||||
|
),
|
||||||
|
StartupFailureSaveResult::Conflict
|
||||||
|
));
|
||||||
|
assert_eq!(store.read_by_name("agent").unwrap().unwrap(), original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_failure_bound_to_absent_optional_owner_fields_reopens_as_present() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let store = FsWorkerStore::new(temp.path()).unwrap();
|
||||||
|
let mut original = metadata();
|
||||||
|
original.workspace_id = None;
|
||||||
|
original.subjektiv_session_attribution = None;
|
||||||
|
store.write(&original).unwrap();
|
||||||
|
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
store.compare_and_swap_startup_failure(&expected, &attachment(StartupStage::Binding)),
|
||||||
|
StartupFailureSaveResult::Persisted
|
||||||
|
));
|
||||||
|
drop(store);
|
||||||
|
let reopened = FsWorkerStore::new(temp.path()).unwrap();
|
||||||
|
let restored = reopened.read_by_name("agent").unwrap().unwrap();
|
||||||
|
assert_stored_cause(&restored, StartupStage::Binding);
|
||||||
|
let expected_none_fingerprint: [u8; 32] = Sha256::digest(b"null").into();
|
||||||
|
assert_eq!(
|
||||||
|
bound(restored.startup_failure.as_ref().unwrap()).attribution_fingerprint,
|
||||||
|
expected_none_fingerprint
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_failure_cas_independently_opened_stores_have_one_concurrent_winner() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let store = FsWorkerStore::new(temp.path()).unwrap();
|
||||||
|
let original = metadata();
|
||||||
|
store.write(&original).unwrap();
|
||||||
|
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
|
||||||
|
let barrier = Arc::new(std::sync::Barrier::new(3));
|
||||||
|
let handles = [StartupStage::Binding, StartupStage::Feature].map(|stage| {
|
||||||
|
let store = FsWorkerStore::new(temp.path()).unwrap();
|
||||||
|
let expected = expected.clone();
|
||||||
|
let barrier = barrier.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
barrier.wait();
|
||||||
|
(
|
||||||
|
stage,
|
||||||
|
store.compare_and_swap_startup_failure(&expected, &attachment(stage)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
barrier.wait();
|
||||||
|
let mut winner = None;
|
||||||
|
let mut conflicts = 0;
|
||||||
|
for handle in handles {
|
||||||
|
let (stage, result) = handle.join().unwrap();
|
||||||
|
match result {
|
||||||
|
StartupFailureSaveResult::Persisted => {
|
||||||
|
assert!(winner.replace(stage).is_none());
|
||||||
|
}
|
||||||
|
StartupFailureSaveResult::Conflict => conflicts += 1,
|
||||||
|
other => panic!("unexpected persistence result: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(conflicts, 1);
|
||||||
|
drop(store);
|
||||||
|
let reopened = FsWorkerStore::new(temp.path()).unwrap();
|
||||||
|
assert_stored_cause(
|
||||||
|
&reopened.read_by_name("agent").unwrap().unwrap(),
|
||||||
|
winner.unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_failure_legacy_missing_unknown_and_corrupt_survive_reopen_with_attribution() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let original = metadata();
|
||||||
|
let mut future: serde_json::Value = serde_json::from_str(
|
||||||
|
serde_json::to_value(attachment(StartupStage::Binding))
|
||||||
|
.unwrap()
|
||||||
|
.as_str()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
future["version"] = serde_json::json!(65535);
|
||||||
|
let fixtures = [
|
||||||
|
None,
|
||||||
|
Some(future.to_string()),
|
||||||
|
Some("not an envelope".to_owned()),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (case, encoded) in fixtures.into_iter().enumerate() {
|
||||||
|
let mut fixture = serde_json::to_value(&original).unwrap();
|
||||||
|
assert!(fixture.get("startup_failure").is_none());
|
||||||
|
if let Some(encoded) = encoded {
|
||||||
|
// The new field is always a binding wrapper. Unknown/corrupt fixtures
|
||||||
|
// change only its opaque codec payload, not the surrounding binding.
|
||||||
|
let mut bound =
|
||||||
|
serde_json::to_value(bound_failure(&original, &attachment(StartupStage::Binding)))
|
||||||
|
.unwrap();
|
||||||
|
bound["attachment"] = serde_json::Value::String(encoded);
|
||||||
|
fixture["startup_failure"] = bound;
|
||||||
|
}
|
||||||
|
let path = temp.path().join("agent/metadata.json");
|
||||||
|
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
fs::write(path, serde_json::to_vec(&fixture).unwrap()).unwrap();
|
||||||
|
let reopened = FsWorkerStore::new(temp.path()).unwrap();
|
||||||
|
let restored = reopened.read_by_name("agent").unwrap().unwrap();
|
||||||
|
match (
|
||||||
|
case,
|
||||||
|
restored.decode_startup_failure::<RuntimeStartupCause>(),
|
||||||
|
) {
|
||||||
|
(0, StartupFailureRead::Missing)
|
||||||
|
| (1, StartupFailureRead::Present(DecodeFailure::Unsupported(_)))
|
||||||
|
| (2, StartupFailureRead::Present(DecodeFailure::Corrupt(_))) => {}
|
||||||
|
_ => panic!("legacy absence, unknown and corrupt must be distinguishable"),
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
restored.subjektiv_session_attribution,
|
||||||
|
original.subjektiv_session_attribution
|
||||||
|
);
|
||||||
|
assert_eq!(restored.active, original.active);
|
||||||
|
assert_eq!(restored.workspace_id, original.workspace_id);
|
||||||
|
if case != 0 {
|
||||||
|
reopened
|
||||||
|
.update_by_name("agent", |value| {
|
||||||
|
value.workspace_id = Some("new-owner".into())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let changed = reopened.read_by_name("agent").unwrap().unwrap();
|
||||||
|
assert_eq!(changed.startup_failure, restored.startup_failure);
|
||||||
|
assert!(matches!(
|
||||||
|
changed.decode_startup_failure::<RuntimeStartupCause>(),
|
||||||
|
StartupFailureRead::Mismatch(StartupFailureBindingMismatch {
|
||||||
|
workspace_id: true,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_failure_save_read_error_is_not_saved_and_preserves_received_cause() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let store = FsWorkerStore::new(temp.path()).unwrap();
|
||||||
|
let original = metadata();
|
||||||
|
store.write(&original).unwrap();
|
||||||
|
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
|
||||||
|
let path = temp.path().join("agent/metadata.json");
|
||||||
|
fs::write(&path, b"invalid metadata").unwrap();
|
||||||
|
let cause = attachment(StartupStage::Binding);
|
||||||
|
let received = cause.clone();
|
||||||
|
assert!(matches!(
|
||||||
|
store.compare_and_swap_startup_failure(&expected, &cause),
|
||||||
|
StartupFailureSaveResult::NotSaved(WorkerStoreError::Serde(_))
|
||||||
|
));
|
||||||
|
assert_eq!(cause, received);
|
||||||
|
assert_eq!(fs::read(path).unwrap(), b"invalid metadata");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_failure_rename_error_is_not_saved_but_post_rename_sync_error_is_unknown() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let store = FsWorkerStore::new(temp.path()).unwrap();
|
||||||
|
let original = metadata();
|
||||||
|
store.write(&original).unwrap();
|
||||||
|
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
|
||||||
|
let cause = attachment(StartupStage::Binding);
|
||||||
|
let received = cause.clone();
|
||||||
|
|
||||||
|
// Real IO rename failure before publication. The destination is an isolated
|
||||||
|
// directory rather than metadata.json, so no retained metadata is destroyed.
|
||||||
|
let blocked_path = temp.path().join("blocked/metadata.json");
|
||||||
|
fs::create_dir_all(&blocked_path).unwrap();
|
||||||
|
let result =
|
||||||
|
save_startup_failure_locked(&store, &store.update_lock, &expected, &cause, |value| {
|
||||||
|
write_metadata_atomically(value, &blocked_path)
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
StartupFailureSaveResult::NotSaved(WorkerStoreError::Io(_))
|
||||||
|
));
|
||||||
|
assert_eq!(store.read_by_name("agent").unwrap().unwrap(), original);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_dir(blocked_path.parent().unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
// Production serialization + file sync + rename, replacing only directory
|
||||||
|
// sync with an IO failure. Reopen sees publication, but durability is unknown.
|
||||||
|
let path = store.metadata_path("agent").unwrap();
|
||||||
|
let result =
|
||||||
|
save_startup_failure_locked(&store, &store.update_lock, &expected, &cause, |value| {
|
||||||
|
write_metadata_with_directory_sync(value, &path, |_| {
|
||||||
|
Err(std::io::Error::other("directory sync unavailable"))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
StartupFailureSaveResult::OutcomeUnknown(WorkerStoreError::Io(_))
|
||||||
|
));
|
||||||
|
assert_eq!(cause, received);
|
||||||
|
drop(store);
|
||||||
|
let reopened = FsWorkerStore::new(temp.path()).unwrap();
|
||||||
|
let restored = reopened.read_by_name("agent").unwrap().unwrap();
|
||||||
|
assert_stored_cause(&restored, StartupStage::Binding);
|
||||||
|
let mut wanted = original;
|
||||||
|
wanted.startup_failure = Some(SessionStartupFailure::from_expected(&expected, &received));
|
||||||
|
assert_eq!(restored, wanted);
|
||||||
|
// An uncertain attempt is not replayed against its stale expected cause.
|
||||||
|
assert!(matches!(
|
||||||
|
reopened.compare_and_swap_startup_failure(&expected, &cause),
|
||||||
|
StartupFailureSaveResult::Conflict
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_failure_aggregate_and_combined_stores_use_atomic_cas() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let aggregate = WorkerAggregateStore::new(temp.path().join("aggregate"), "agent").unwrap();
|
||||||
|
let original = metadata();
|
||||||
|
aggregate.write(&original).unwrap();
|
||||||
|
let expected = StartupFailureExpectation::from_metadata(&original).unwrap();
|
||||||
|
let store = CombinedStore::new((), aggregate);
|
||||||
|
let cause = attachment(StartupStage::Binding);
|
||||||
|
assert!(matches!(
|
||||||
|
store.compare_and_swap_startup_failure(&expected, &cause),
|
||||||
|
StartupFailureSaveResult::Persisted
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
store.compare_and_swap_startup_failure(&expected, &attachment(StartupStage::Feature)),
|
||||||
|
StartupFailureSaveResult::Conflict
|
||||||
|
));
|
||||||
|
let reopened =
|
||||||
|
WorkerAggregateStore::open_read_only(temp.path().join("aggregate"), "agent").unwrap();
|
||||||
|
assert_stored_cause(
|
||||||
|
&reopened.read_read_only().unwrap().unwrap(),
|
||||||
|
StartupStage::Binding,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bound(failure: &SessionStartupFailure) -> &StartupFailureBinding {
|
||||||
|
match failure {
|
||||||
|
SessionStartupFailure::Bound(binding) => binding,
|
||||||
|
_ => panic!("expected current binding"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn bound_mut(failure: &mut SessionStartupFailure) -> &mut StartupFailureBinding {
|
||||||
|
match failure {
|
||||||
|
SessionStartupFailure::Bound(binding) => binding,
|
||||||
|
_ => panic!("expected current binding"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_binding_unknown_version_and_malformed_value_do_not_destroy_lifecycle_metadata() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let store = FsWorkerStore::new(temp.path()).unwrap();
|
||||||
|
let original = metadata();
|
||||||
|
store.write(&original).unwrap();
|
||||||
|
let mut wire = serde_json::to_value(&original).unwrap();
|
||||||
|
for (value, unsupported) in [
|
||||||
|
(
|
||||||
|
serde_json::json!({"version": 900, "future_binding": true}),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
serde_json::json!({"version": 1, "attachment": {"wrong_type": true}}),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(serde_json::json!("invalid binding"), false),
|
||||||
|
(serde_json::json!([1, 2]), false),
|
||||||
|
] {
|
||||||
|
wire["startup_failure"] = value.clone();
|
||||||
|
std::fs::write(
|
||||||
|
temp.path().join("agent/metadata.json"),
|
||||||
|
serde_json::to_vec(&wire).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let reopened = FsWorkerStore::new(temp.path())
|
||||||
|
.unwrap()
|
||||||
|
.read_by_name("agent")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(reopened.active, original.active);
|
||||||
|
assert_eq!(
|
||||||
|
reopened.subjektiv_session_attribution,
|
||||||
|
original.subjektiv_session_attribution
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reopened.resolved_manifest_snapshot,
|
||||||
|
original.resolved_manifest_snapshot
|
||||||
|
);
|
||||||
|
match reopened.decode_startup_failure::<RuntimeStartupCause>() {
|
||||||
|
StartupFailureRead::UnsupportedBindingVersion(900) if unsupported => {}
|
||||||
|
StartupFailureRead::CorruptBinding if !unsupported => {}
|
||||||
|
_ => panic!("binding diagnosis lost"),
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&reopened).unwrap()["startup_failure"],
|
||||||
|
value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,10 @@
|
|||||||
//! execution data, not proof of liveness.
|
//! execution data, not proof of liveness.
|
||||||
|
|
||||||
use crate::{SegmentId, SessionId};
|
use crate::{SegmentId, SessionId};
|
||||||
|
use error_contract::{DecodeFailure, WireDetails};
|
||||||
|
use protocol::startup_failure::StoredStartupFailureAttachment;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -181,6 +184,168 @@ pub struct WorkerMetadata {
|
|||||||
/// absence is also the legacy representation and never grants visibility.
|
/// absence is also the legacy representation and never grants visibility.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub subjektiv_session_attribution: Option<SubjektivSessionAttributionState>,
|
pub subjektiv_session_attribution: Option<SubjektivSessionAttributionState>,
|
||||||
|
/// Projected startup cause bound to its Session and ownership observation.
|
||||||
|
/// Pointer/ownership changes retain this evidence, but the read accessor
|
||||||
|
/// rejects a stale binding before decoding. This is not execution state.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub startup_failure: Option<SessionStartupFailure>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opaque cause and the exact local binding under which it was saved.
|
||||||
|
///
|
||||||
|
/// Only the CAS save path constructs this binding from its matched expectation.
|
||||||
|
/// Attribution is fingerprinted rather than copied: its existing diagnostics may
|
||||||
|
/// contain credentials or private paths and must not enter this new attachment.
|
||||||
|
/// The fingerprint is an equality fence, not encryption or ownership authority.
|
||||||
|
/// Deserialization merely restores evidence, not current ownership or authority;
|
||||||
|
/// consumers read it through `WorkerMetadata::decode_startup_failure`.
|
||||||
|
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum SessionStartupFailure {
|
||||||
|
Bound(StartupFailureBinding),
|
||||||
|
/// Malformed or future binding representations remain alongside lifecycle metadata.
|
||||||
|
Undecodable(serde_json::Value),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for SessionStartupFailure {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("SessionStartupFailure")
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct StartupFailureBinding {
|
||||||
|
version: u16,
|
||||||
|
worker_name: String,
|
||||||
|
session_id: SessionId,
|
||||||
|
workspace_id: Option<String>,
|
||||||
|
attribution_fingerprint: [u8; 32],
|
||||||
|
attachment: StoredStartupFailureAttachment,
|
||||||
|
}
|
||||||
|
|
||||||
|
// The compact JSON serialization of this typed Option is the fingerprint input:
|
||||||
|
// None is `null`; Some retains every enum field in its declared Serialize order.
|
||||||
|
// There are no maps whose iteration order could vary. Changing that representation
|
||||||
|
// requires a new binding version. Version 1 uses SHA-256 over precisely these bytes.
|
||||||
|
// This enum and SessionId serialize infallibly
|
||||||
|
// to JSON; no caller-controlled Serialize implementation participates here.
|
||||||
|
fn attribution_fingerprint(attribution: &Option<SubjektivSessionAttributionState>) -> [u8; 32] {
|
||||||
|
let encoded = serde_json::to_vec(attribution).expect("typed attribution serializes to JSON");
|
||||||
|
Sha256::digest(encoded).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionStartupFailure {
|
||||||
|
fn from_expected(
|
||||||
|
expected: &StartupFailureExpectation,
|
||||||
|
attachment: &StoredStartupFailureAttachment,
|
||||||
|
) -> Self {
|
||||||
|
Self::Bound(StartupFailureBinding {
|
||||||
|
version: 1,
|
||||||
|
worker_name: expected.worker_name.clone(),
|
||||||
|
session_id: expected.session_id,
|
||||||
|
workspace_id: expected.workspace_id.clone(),
|
||||||
|
attribution_fingerprint: attribution_fingerprint(&expected.attribution),
|
||||||
|
attachment: attachment.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StartupFailureBinding {
|
||||||
|
fn binding_mismatch(&self, metadata: &WorkerMetadata) -> Option<StartupFailureBindingMismatch> {
|
||||||
|
let mismatch = StartupFailureBindingMismatch {
|
||||||
|
worker_name: self.worker_name != metadata.worker_name,
|
||||||
|
session_id: Some(self.session_id)
|
||||||
|
!= metadata.active.as_ref().map(|active| active.session_id),
|
||||||
|
workspace_id: self.workspace_id != metadata.workspace_id,
|
||||||
|
attribution: self.attribution_fingerprint
|
||||||
|
!= attribution_fingerprint(&metadata.subjektiv_session_attribution),
|
||||||
|
};
|
||||||
|
(mismatch.worker_name
|
||||||
|
|| mismatch.session_id
|
||||||
|
|| mismatch.workspace_id
|
||||||
|
|| mismatch.attribution)
|
||||||
|
.then_some(mismatch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each `true` field identifies a binding that differs from current metadata.
|
||||||
|
/// Values and legacy diagnostic text are not copied into the diagnosis.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct StartupFailureBindingMismatch {
|
||||||
|
pub worker_name: bool,
|
||||||
|
/// Also true when there is no longer an active Session.
|
||||||
|
pub session_id: bool,
|
||||||
|
pub workspace_id: bool,
|
||||||
|
pub attribution: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A current binding must match before the consumer's typed codec runs.
|
||||||
|
/// `Present` is only cause evidence, never execution or retry authorization.
|
||||||
|
pub enum StartupFailureRead<D> {
|
||||||
|
Missing,
|
||||||
|
/// Binding encoding is versioned independently of the domain error envelope.
|
||||||
|
UnsupportedBindingVersion(u16),
|
||||||
|
CorruptBinding,
|
||||||
|
/// Retained cause belongs to a different Session/ownership observation.
|
||||||
|
/// No cause decoding is attempted, even for unknown or corrupt attachments.
|
||||||
|
Mismatch(StartupFailureBindingMismatch),
|
||||||
|
/// Known, unsupported and corrupt causes remain distinct in the codec.
|
||||||
|
Present(DecodeFailure<D>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The observed ownership and cause values for one startup-cause write.
|
||||||
|
///
|
||||||
|
/// Segment movement within the same Session does not invalidate this expectation.
|
||||||
|
/// Attribution is compared in full, including legacy diagnostics, never inferred
|
||||||
|
/// from those diagnostics. The existing cause comparison includes its entire saved
|
||||||
|
/// binding, not just attachment bytes. A cause write changes neither attribution
|
||||||
|
/// nor ownership; it builds the new binding from this matched expectation.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct StartupFailureExpectation {
|
||||||
|
pub worker_name: String,
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
|
pub session_id: SessionId,
|
||||||
|
pub attribution: Option<SubjektivSessionAttributionState>,
|
||||||
|
pub startup_failure: Option<SessionStartupFailure>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StartupFailureExpectation {
|
||||||
|
/// Capture an expectation only when metadata identifies an active Session.
|
||||||
|
pub fn from_metadata(metadata: &WorkerMetadata) -> Option<Self> {
|
||||||
|
Some(Self {
|
||||||
|
worker_name: metadata.worker_name.clone(),
|
||||||
|
workspace_id: metadata.workspace_id.clone(),
|
||||||
|
session_id: metadata.active.as_ref()?.session_id,
|
||||||
|
attribution: metadata.subjektiv_session_attribution.clone(),
|
||||||
|
startup_failure: metadata.startup_failure.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare the complete observation without adopting newer ownership or causes.
|
||||||
|
pub fn matches(&self, metadata: &WorkerMetadata) -> bool {
|
||||||
|
metadata.worker_name == self.worker_name
|
||||||
|
&& metadata.workspace_id == self.workspace_id
|
||||||
|
&& metadata.active.as_ref().map(|active| active.session_id) == Some(self.session_id)
|
||||||
|
&& metadata.subjektiv_session_attribution == self.attribution
|
||||||
|
&& metadata.startup_failure == self.startup_failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local persistence evidence, never a replacement for the received startup cause.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum StartupFailureSaveResult {
|
||||||
|
/// The attachment and containing directory were synced successfully.
|
||||||
|
Persisted,
|
||||||
|
/// The observed Session, ownership, attribution, or cause no longer matches.
|
||||||
|
/// No write was attempted. Missing metadata is also a conflict, not creation.
|
||||||
|
Conflict,
|
||||||
|
/// This attempt did not publish metadata. The caller still owns its cause.
|
||||||
|
NotSaved(WorkerStoreError),
|
||||||
|
/// Publication may have happened; reconcile retained metadata before retrying.
|
||||||
|
/// For the filesystem stores this means rename succeeded but directory sync failed.
|
||||||
|
OutcomeUnknown(WorkerStoreError),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkerMetadata {
|
impl WorkerMetadata {
|
||||||
@@ -196,9 +361,44 @@ impl WorkerMetadata {
|
|||||||
peers: Vec::new(),
|
peers: Vec::new(),
|
||||||
resolved_manifest_snapshot: None,
|
resolved_manifest_snapshot: None,
|
||||||
subjektiv_session_attribution: None,
|
subjektiv_session_attribution: None,
|
||||||
|
startup_failure: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Absence is legacy/missing evidence, not a cause inferred from diagnostics.
|
||||||
|
/// A retained cause whose Session or ownership observation no longer matches
|
||||||
|
/// yields `Mismatch`, never `Missing` or a decoded cause for the new owner.
|
||||||
|
/// Segment movement within the same Session does not invalidate the binding.
|
||||||
|
/// Matched attachments use only the consumer's typed codec, preserving known,
|
||||||
|
/// unsupported and corrupt results. No result authorizes execution or retry.
|
||||||
|
pub fn decode_startup_failure<D: WireDetails>(&self) -> StartupFailureRead<D> {
|
||||||
|
let Some(failure) = &self.startup_failure else {
|
||||||
|
return StartupFailureRead::Missing;
|
||||||
|
};
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct BindingVersion {
|
||||||
|
version: u16,
|
||||||
|
}
|
||||||
|
let failure = match failure {
|
||||||
|
SessionStartupFailure::Bound(binding) if binding.version == 1 => binding,
|
||||||
|
SessionStartupFailure::Bound(binding) => {
|
||||||
|
return StartupFailureRead::UnsupportedBindingVersion(binding.version);
|
||||||
|
}
|
||||||
|
SessionStartupFailure::Undecodable(raw) => {
|
||||||
|
return match serde_json::from_value::<BindingVersion>(raw.clone()) {
|
||||||
|
Ok(header) if header.version != 1 => {
|
||||||
|
StartupFailureRead::UnsupportedBindingVersion(header.version)
|
||||||
|
}
|
||||||
|
_ => StartupFailureRead::CorruptBinding,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(mismatch) = failure.binding_mismatch(self) {
|
||||||
|
return StartupFailureRead::Mismatch(mismatch);
|
||||||
|
}
|
||||||
|
StartupFailureRead::Present(failure.attachment.decode())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_workspace_root(mut self, workspace_root: PathBuf) -> Self {
|
pub fn with_workspace_root(mut self, workspace_root: PathBuf) -> Self {
|
||||||
self.workspace_root = Some(workspace_root);
|
self.workspace_root = Some(workspace_root);
|
||||||
self
|
self
|
||||||
@@ -265,6 +465,25 @@ pub trait WorkerMetadataStore: Send + Sync {
|
|||||||
Ok(matched)
|
Ok(matched)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persist a received cause only against the exact observed Session and owner.
|
||||||
|
///
|
||||||
|
/// Implementations must serialize this comparison and publication with their
|
||||||
|
/// other read/modify/write operations. The filesystem stores use the same
|
||||||
|
/// process-local mutex as `update_by_name`; this is not a cross-process lock.
|
||||||
|
/// Uncoordinated direct writes are outside that guarantee. A default backend
|
||||||
|
/// refuses rather than offering a non-atomic read/write approximation.
|
||||||
|
/// The borrowed attachment is never consumed or changed on any result.
|
||||||
|
fn compare_and_swap_startup_failure(
|
||||||
|
&self,
|
||||||
|
_expected: &StartupFailureExpectation,
|
||||||
|
_failure: &StoredStartupFailureAttachment,
|
||||||
|
) -> StartupFailureSaveResult {
|
||||||
|
StartupFailureSaveResult::NotSaved(WorkerStoreError::Io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Unsupported,
|
||||||
|
"atomic startup failure persistence is unsupported by this backend",
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the active pointer while preserving spawned children, workspace ownership, and manifest snapshot.
|
/// Set the active pointer while preserving spawned children, workspace ownership, and manifest snapshot.
|
||||||
fn set_active(
|
fn set_active(
|
||||||
&self,
|
&self,
|
||||||
@@ -404,6 +623,90 @@ pub trait WorkerMetadataStore: Send + Sync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether an atomic metadata replacement reached its publication point.
|
||||||
|
struct MetadataWriteFailure {
|
||||||
|
published: bool,
|
||||||
|
error: WorkerStoreError,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_metadata_atomically(
|
||||||
|
metadata: &WorkerMetadata,
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<(), MetadataWriteFailure> {
|
||||||
|
write_metadata_with_directory_sync(metadata, path, |parent| {
|
||||||
|
std::fs::File::open(parent)?.sync_all()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the directory durability boundary explicit: a failure here follows rename,
|
||||||
|
// unlike failures writing/syncing the temporary file. Tests replace only this IO
|
||||||
|
// boundary, retaining the real serialization, temporary file and rename path.
|
||||||
|
fn write_metadata_with_directory_sync(
|
||||||
|
metadata: &WorkerMetadata,
|
||||||
|
path: &Path,
|
||||||
|
sync_directory: impl FnOnce(&Path) -> std::io::Result<()>,
|
||||||
|
) -> Result<(), MetadataWriteFailure> {
|
||||||
|
let mut published = false;
|
||||||
|
let mut temp = None;
|
||||||
|
let result = (|| -> Result<(), WorkerStoreError> {
|
||||||
|
use std::io::Write;
|
||||||
|
let mut content = serde_json::to_vec_pretty(metadata)?;
|
||||||
|
content.push(b'\n');
|
||||||
|
let parent = path.parent().expect("metadata path has parent");
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
let temporary_path = parent.join(format!(
|
||||||
|
".metadata.json.tmp-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
uuid::Uuid::now_v7()
|
||||||
|
));
|
||||||
|
let mut file = std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(&temporary_path)?;
|
||||||
|
temp = Some(temporary_path.clone());
|
||||||
|
file.write_all(&content)?;
|
||||||
|
file.sync_all()?;
|
||||||
|
drop(file);
|
||||||
|
fs::rename(&temporary_path, path)?;
|
||||||
|
published = true;
|
||||||
|
sync_directory(parent)?;
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
if result.is_err() {
|
||||||
|
if let Some(temp) = temp {
|
||||||
|
let _ = fs::remove_file(temp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.map_err(|error| MetadataWriteFailure { published, error })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_startup_failure_locked<S: WorkerMetadataStore>(
|
||||||
|
store: &S,
|
||||||
|
update_lock: &Mutex<()>,
|
||||||
|
expected: &StartupFailureExpectation,
|
||||||
|
failure: &StoredStartupFailureAttachment,
|
||||||
|
publish: impl FnOnce(&WorkerMetadata) -> Result<(), MetadataWriteFailure>,
|
||||||
|
) -> StartupFailureSaveResult {
|
||||||
|
let _guard = update_lock.lock().expect("metadata update lock poisoned");
|
||||||
|
let mut metadata = match store.read_by_name(&expected.worker_name) {
|
||||||
|
Ok(Some(metadata)) if expected.matches(&metadata) => metadata,
|
||||||
|
Ok(_) => return StartupFailureSaveResult::Conflict,
|
||||||
|
Err(error) => return StartupFailureSaveResult::NotSaved(error),
|
||||||
|
};
|
||||||
|
metadata.startup_failure = Some(SessionStartupFailure::from_expected(expected, failure));
|
||||||
|
match publish(&metadata) {
|
||||||
|
Ok(()) => StartupFailureSaveResult::Persisted,
|
||||||
|
Err(MetadataWriteFailure {
|
||||||
|
published: false,
|
||||||
|
error,
|
||||||
|
}) => StartupFailureSaveResult::NotSaved(error),
|
||||||
|
Err(MetadataWriteFailure {
|
||||||
|
published: true,
|
||||||
|
error,
|
||||||
|
}) => StartupFailureSaveResult::OutcomeUnknown(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Metadata store for one canonical Worker aggregate.
|
/// Metadata store for one canonical Worker aggregate.
|
||||||
///
|
///
|
||||||
/// The Worker key is fixed by Runtime authority and metadata lives directly at
|
/// The Worker key is fixed by Runtime authority and metadata lives directly at
|
||||||
@@ -489,31 +792,7 @@ impl WorkerAggregateStore {
|
|||||||
impl WorkerMetadataStore for WorkerAggregateStore {
|
impl WorkerMetadataStore for WorkerAggregateStore {
|
||||||
fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
|
fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
|
||||||
self.validate_name(&metadata.worker_name)?;
|
self.validate_name(&metadata.worker_name)?;
|
||||||
let mut content = serde_json::to_vec_pretty(metadata)?;
|
write_metadata_atomically(metadata, &self.metadata_path()).map_err(|failure| failure.error)
|
||||||
content.push(b'\n');
|
|
||||||
let path = self.metadata_path();
|
|
||||||
let temp = self.root.join(format!(
|
|
||||||
".metadata.json.tmp-{}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
uuid::Uuid::now_v7()
|
|
||||||
));
|
|
||||||
let result = (|| -> Result<(), WorkerStoreError> {
|
|
||||||
use std::io::Write;
|
|
||||||
let mut file = std::fs::OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.create_new(true)
|
|
||||||
.open(&temp)?;
|
|
||||||
file.write_all(&content)?;
|
|
||||||
file.sync_all()?;
|
|
||||||
drop(file);
|
|
||||||
fs::rename(&temp, &path)?;
|
|
||||||
std::fs::File::open(&self.root)?.sync_all()?;
|
|
||||||
Ok(())
|
|
||||||
})();
|
|
||||||
if result.is_err() {
|
|
||||||
let _ = fs::remove_file(temp);
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
|
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
|
||||||
@@ -553,6 +832,16 @@ impl WorkerMetadataStore for WorkerAggregateStore {
|
|||||||
Ok(metadata)
|
Ok(metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn compare_and_swap_startup_failure(
|
||||||
|
&self,
|
||||||
|
expected: &StartupFailureExpectation,
|
||||||
|
failure: &StoredStartupFailureAttachment,
|
||||||
|
) -> StartupFailureSaveResult {
|
||||||
|
save_startup_failure_locked(self, &self.update_lock, expected, failure, |metadata| {
|
||||||
|
write_metadata_atomically(metadata, &self.metadata_path())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn compare_and_swap_active(
|
fn compare_and_swap_active(
|
||||||
&self,
|
&self,
|
||||||
worker_name: &str,
|
worker_name: &str,
|
||||||
@@ -627,32 +916,7 @@ impl FsWorkerStore {
|
|||||||
impl WorkerMetadataStore for FsWorkerStore {
|
impl WorkerMetadataStore for FsWorkerStore {
|
||||||
fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
|
fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
|
||||||
let path = self.metadata_path(&metadata.worker_name)?;
|
let path = self.metadata_path(&metadata.worker_name)?;
|
||||||
let mut content = serde_json::to_vec_pretty(metadata)?;
|
write_metadata_atomically(metadata, &path).map_err(|failure| failure.error)
|
||||||
content.push(b'\n');
|
|
||||||
let parent = path.parent().expect("metadata path has parent");
|
|
||||||
fs::create_dir_all(parent)?;
|
|
||||||
let temp = parent.join(format!(
|
|
||||||
".metadata.json.tmp-{}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
uuid::Uuid::now_v7()
|
|
||||||
));
|
|
||||||
let result = (|| -> Result<(), WorkerStoreError> {
|
|
||||||
use std::io::Write;
|
|
||||||
let mut file = std::fs::OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.create_new(true)
|
|
||||||
.open(&temp)?;
|
|
||||||
file.write_all(&content)?;
|
|
||||||
file.sync_all()?;
|
|
||||||
drop(file);
|
|
||||||
fs::rename(&temp, &path)?;
|
|
||||||
std::fs::File::open(parent)?.sync_all()?;
|
|
||||||
Ok(())
|
|
||||||
})();
|
|
||||||
if result.is_err() {
|
|
||||||
let _ = fs::remove_file(temp);
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
|
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
|
||||||
@@ -685,6 +949,20 @@ impl WorkerMetadataStore for FsWorkerStore {
|
|||||||
Ok(metadata)
|
Ok(metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn compare_and_swap_startup_failure(
|
||||||
|
&self,
|
||||||
|
expected: &StartupFailureExpectation,
|
||||||
|
failure: &StoredStartupFailureAttachment,
|
||||||
|
) -> StartupFailureSaveResult {
|
||||||
|
let path = match self.metadata_path(&expected.worker_name) {
|
||||||
|
Ok(path) => path,
|
||||||
|
Err(error) => return StartupFailureSaveResult::NotSaved(error),
|
||||||
|
};
|
||||||
|
save_startup_failure_locked(self, &self.update_lock, expected, failure, |metadata| {
|
||||||
|
write_metadata_atomically(metadata, &path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn compare_and_swap_active(
|
fn compare_and_swap_active(
|
||||||
&self,
|
&self,
|
||||||
worker_name: &str,
|
worker_name: &str,
|
||||||
@@ -1007,6 +1285,15 @@ where
|
|||||||
self.worker_metadata_store
|
self.worker_metadata_store
|
||||||
.update_by_name(worker_name, update)
|
.update_by_name(worker_name, update)
|
||||||
}
|
}
|
||||||
|
fn compare_and_swap_startup_failure(
|
||||||
|
&self,
|
||||||
|
expected: &StartupFailureExpectation,
|
||||||
|
failure: &StoredStartupFailureAttachment,
|
||||||
|
) -> StartupFailureSaveResult {
|
||||||
|
self.worker_metadata_store
|
||||||
|
.compare_and_swap_startup_failure(expected, failure)
|
||||||
|
}
|
||||||
|
|
||||||
fn compare_and_swap_active(
|
fn compare_and_swap_active(
|
||||||
&self,
|
&self,
|
||||||
worker_name: &str,
|
worker_name: &str,
|
||||||
@@ -1512,3 +1799,7 @@ mod tests {
|
|||||||
assert_eq!(winners, 1);
|
assert_eq!(winners, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "startup_failure_tests.rs"]
|
||||||
|
mod startup_failure_tests;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user