Compare commits
413
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 | ||
|
|
d9295f4439 | ||
|
|
5dfc50f293 | ||
|
|
eb05300a32 | ||
|
|
5d7213b9a4 | ||
|
|
d0f22d871a | ||
|
|
922e5e3fc3 | ||
|
|
bfbddfa443 | ||
|
|
f22b69bb92 | ||
|
|
f113bfa0ee | ||
|
|
4134f9c2bb | ||
|
|
846d35446b | ||
|
|
d08a21e38c | ||
|
|
f7ea2362dd | ||
|
|
e8477882b5 | ||
|
|
af074254ec | ||
|
|
df8cd2353b | ||
|
|
c254870ed6 | ||
|
|
8018f648e4 | ||
|
|
bb70c2304e | ||
|
|
09b9755a16 | ||
|
|
2924e43773 | ||
|
|
86b2981d63 | ||
|
|
c9cd2af845 | ||
|
|
7a2d132c16 | ||
|
|
9e7392256b | ||
|
|
82616c2a41 | ||
|
|
c05854b4b1 | ||
|
|
226d0ecfce | ||
|
|
54dea496ff | ||
|
|
24694dfddc | ||
|
|
1c35089c77 | ||
|
|
05649dd66f | ||
|
|
290edf6808 | ||
|
|
bf23f5a4e4 | ||
|
|
d91181912b | ||
|
|
7b594ded0d | ||
|
|
2f74e00e07 | ||
|
|
52a1ce5607 | ||
|
|
807a476c88 | ||
|
|
7252e61a23 | ||
|
|
6fffe4517b | ||
|
|
ab138d72fc | ||
|
|
5627f47e2b | ||
|
|
2c1f5dfafe | ||
|
|
b508fef698 | ||
|
|
27f3b36ce5 | ||
|
|
5d0e03dc53 | ||
|
|
4f7dcfda61 | ||
|
|
1c03758fc1 | ||
|
|
e7351c16b0 | ||
|
|
00d96b7163 | ||
|
|
2b4bf4b7bd | ||
|
|
e7ebc92f83 | ||
|
|
0f82d6fa8f | ||
|
|
5a317e6d2a | ||
|
|
5329a93e3e | ||
|
|
94d458883e | ||
|
|
b824195b36 | ||
|
|
318c1a9ce9 | ||
|
|
9f13446afd | ||
|
|
479bf0872a | ||
|
|
36932321e0 | ||
|
|
70ce8479b2 | ||
|
|
57b57aed67 | ||
|
|
0cd4811739 | ||
|
|
165153c63c | ||
|
|
3a13301f75 | ||
|
|
732fe5c4c1 | ||
|
|
7c238edc1a | ||
|
|
55ad8c4265 | ||
|
|
1eecccc21e | ||
|
|
c65f145376 | ||
|
|
8661505b5a | ||
|
|
4f096f26b5 | ||
|
|
e7506c5eb8 | ||
|
|
967274bfbd | ||
|
|
6d15569b9b | ||
|
|
8810d3245e | ||
|
|
bd70eda4e0 | ||
|
|
771120778b | ||
|
|
d077302b92 | ||
|
|
c82113f7c4 | ||
|
|
51f0c7e019 | ||
|
|
26cc5e9306 | ||
|
|
6733aeef01 | ||
|
|
c215c72873 | ||
|
|
87a1b6f1eb | ||
|
|
882316fb3b | ||
|
|
74ddd568eb | ||
|
|
4c2bd3ea78 | ||
|
|
dd312b71ee | ||
|
|
2141672310 | ||
|
|
3600843683 | ||
|
|
38a953cd91 | ||
|
|
fc1115b167 | ||
|
|
9b7a58d5c4 | ||
|
|
7ad6030498 | ||
|
|
5087abc5ab | ||
|
|
90398391dd | ||
|
|
6486caf992 | ||
|
|
0ce4fc04ba | ||
|
|
3812126897 | ||
|
|
ec275da601 | ||
|
|
5f0c960048 | ||
|
|
e35cb50f3a | ||
|
|
b8f54eeb6d | ||
|
|
959ed7a554 | ||
|
|
0666434ec8 | ||
|
|
14101d45dc | ||
|
|
4e2f6fcb9c | ||
|
|
043465dc6b | ||
|
|
9b5fad3970 | ||
|
|
45e24375d5 | ||
|
|
d9e9c0f13d | ||
|
|
de4159b20f | ||
|
|
6826db7e0c | ||
|
|
a1fbe1e754 | ||
|
|
8b261cdae1 | ||
|
|
881e241e67 | ||
|
|
00903264f3 | ||
|
|
f598a36ac0 | ||
|
|
2d195f4058 | ||
|
|
06883c51b7 | ||
|
|
a35d9a3d45 | ||
|
|
181b0d637e | ||
|
|
b0733e53b2 | ||
|
|
0710738173 | ||
|
|
960a1b4364 | ||
|
|
a06f6bfb3d | ||
|
|
618880d38d | ||
|
|
79b76c8789 | ||
|
|
897c025b85 | ||
|
|
5b12c3e085 | ||
|
|
623c31615d | ||
|
|
af719e387d | ||
|
|
dddb21bd70 | ||
|
|
e8d4e964cb | ||
|
|
eb24cb04dc | ||
|
|
e84057f402 | ||
|
|
15c3006d09 | ||
|
|
b3209c2012 | ||
|
|
6f5a424661 | ||
|
|
8d47d2670b | ||
|
|
b19de45a82 | ||
|
|
1e88893ef9 | ||
|
|
1fd2e09971 | ||
|
|
f85d831104 | ||
|
|
2b24db5a15 | ||
|
|
e1945d977c | ||
|
|
a10875d68e | ||
|
|
f35dcef4d0 | ||
|
|
f5ebae1134 | ||
|
|
6be4e8388b | ||
|
|
9e3f87ac52 | ||
|
|
ceb01f6eaa | ||
|
|
1b13563e19 | ||
|
|
848a1e9a5b | ||
|
|
a77453338c | ||
|
|
d12bc2ea0b | ||
|
|
a1df3742b1 | ||
|
|
d8818b776b | ||
|
|
40c36ccb1d | ||
|
|
80c151d084 | ||
|
|
c6a8759e7b | ||
|
|
f17b5212c7 | ||
|
|
85f70d4bc1 | ||
|
|
b284934e34 | ||
|
|
2d33321fab | ||
|
|
0975471986 | ||
|
|
214d6e21dd | ||
|
|
fca5c23345 | ||
|
|
f9b1500ba2 | ||
|
|
931bf3b27d | ||
|
|
6bb671be21 | ||
|
|
b8f2fa479c | ||
|
|
1639be9841 | ||
|
|
907392cce8 | ||
|
|
6776973e4c | ||
|
|
73a3e674c9 | ||
|
|
994e75233b | ||
|
|
8b6e3070dd | ||
|
|
b860c67ea2 | ||
|
|
533477c2e7 | ||
|
|
5aa2dc579a | ||
|
|
280b3d2a25 | ||
|
|
ec74f4cdc4 | ||
|
|
5af9dc8e4a | ||
|
|
ca6d4b4b72 | ||
|
|
cccecbe7ca | ||
|
|
7a64c38493 | ||
|
|
b1361d86e9 | ||
|
|
6181426abd | ||
|
|
cd331b93c6 | ||
|
|
60eabea19a | ||
|
|
7394ec6784 | ||
|
|
28b7cc414b | ||
|
|
86d5cf9403 | ||
|
|
6f0a23e5cc | ||
|
|
2f4a46bbdb | ||
|
|
4872cf2aac | ||
|
|
734d4b44ae | ||
|
|
6810f51f46 | ||
|
|
a1748b552c | ||
|
|
62b2d27b4a | ||
|
|
62e2b44305 | ||
|
|
b5c105545c | ||
|
|
aa0fb2f787 | ||
|
|
1b6d4dbfd8 | ||
|
|
61c20b0995 | ||
|
|
4ef7b03651 | ||
|
|
af615ab177 | ||
|
|
338e39bcdb | ||
|
|
3cfe04be3a | ||
|
|
70d153b365 | ||
|
|
ce93849948 | ||
|
|
3c499719fe | ||
|
|
9e1d3df7ad | ||
|
|
18bac16a23 | ||
|
|
9005856f77 | ||
|
|
e39372a52a | ||
|
|
223a74a9e2 | ||
|
|
f38d9a16ea | ||
|
|
b135fa5e99 | ||
|
|
b7a22146a0 | ||
|
|
07b8b6d41b | ||
|
|
8017c55d82 | ||
|
|
b8e548b92d | ||
|
|
90e38551cf | ||
|
|
3e8164e757 | ||
|
|
3b3381900f | ||
|
|
00ad02d047 | ||
|
|
a774925dec | ||
|
|
8f759911ab | ||
|
|
daf1f6ed50 | ||
|
|
86602240fa | ||
|
|
bfeee487fd | ||
|
|
f9830fe7dd | ||
|
|
de96c5b577 | ||
|
|
6e729ed06c | ||
|
|
f38b2a52b1 | ||
|
|
dd5384df3b | ||
|
|
6576850b7a | ||
|
|
4555aace28 | ||
|
|
47b32ef6ab | ||
|
|
bb5c50f9ab | ||
|
|
32a409c15d | ||
|
|
19514a5d6a | ||
|
|
887184796e | ||
|
|
cd0eb5eb00 | ||
|
|
e8dfb3e68d | ||
|
|
434ce34da7 | ||
|
|
bfb51b7ca9 | ||
|
|
38d9069632 | ||
|
|
e5749b9be0 | ||
|
|
387b5ff7df | ||
|
|
ec6c5beda0 | ||
|
|
8e40fae6f4 | ||
|
|
c961f244ab | ||
|
|
cea0225a6e | ||
|
|
b3189d8e85 | ||
|
|
24bc45a31e | ||
|
|
b02360fd75 | ||
|
|
41c9d2d5c7 | ||
|
|
cc66bd5052 | ||
|
|
d1408d3a2f | ||
|
|
19122fb5ea | ||
|
|
1fb216e6ae | ||
|
|
4b14f7e6b3 | ||
|
|
67767eeee7 | ||
|
|
1bfb4ce119 | ||
|
|
651820dc6d | ||
|
|
75b1557d63 | ||
|
|
23ff518209 | ||
|
|
fab9e0f4b5 | ||
|
|
5ee42b8db8 | ||
|
|
daf438701f | ||
|
|
23ee401287 | ||
|
|
8f598da931 | ||
|
|
956aeed6b2 | ||
|
|
adfa422973 | ||
|
|
10d200d9ba | ||
|
|
7a5228b8e6 | ||
|
|
0eabdf3a1f | ||
|
|
b1808b7c4d | ||
|
|
6645ea0a47 | ||
|
|
b544e53bd7 | ||
|
|
04a4e59100 | ||
|
|
52e914eebc | ||
|
|
9c12fb4372 | ||
|
|
3c18916118 | ||
|
|
be1c030350 | ||
|
|
a7faf2cea6 | ||
|
|
5cc5ece997 | ||
|
|
ffaaf2ecb0 | ||
|
|
6667985883 | ||
|
|
22dcd8a29f | ||
|
|
a2816a053d | ||
|
|
d74660d1d1 | ||
|
|
24c6f772f7 | ||
|
|
a060ffada8 | ||
|
|
b8b12442a0 | ||
|
|
d22899d39e | ||
|
|
af008f802d | ||
|
|
7042ec7d50 | ||
|
|
a2cec5afd1 | ||
|
|
b5a307811b | ||
|
|
5bd49760e5 | ||
|
|
9debf43645 | ||
|
|
81af1f085c | ||
|
|
b67aa2de7d | ||
|
|
9f1cbbd385 | ||
|
|
8a3d9d67d2 | ||
|
|
6218c27fb6 | ||
|
|
b744efd104 | ||
|
|
18384482f6 | ||
|
|
885e8045fe | ||
|
|
7b52bea23b | ||
|
|
a31f002a0a | ||
|
|
8ab7ee05db | ||
|
|
62d71ed17e | ||
|
|
cafa2fa6ea | ||
|
|
95ed9e8719 | ||
|
|
84ea23c2e6 | ||
|
|
ac49740a50 | ||
|
|
bdc7c73143 | ||
|
|
011dbb9a98 | ||
|
|
b9e7d5675b | ||
|
|
c155259c8e | ||
|
|
e33729647c | ||
|
|
e71177900e | ||
|
|
f50fa47a48 | ||
|
|
b71fc9ee54 | ||
|
|
e70cba4618 | ||
|
|
d115819f51 | ||
|
|
822a084b31 | ||
|
|
c2df3041f9 | ||
|
|
aabc502f30 | ||
|
|
65ead5fffd |
Generated
+873
-12
File diff suppressed because it is too large
Load Diff
+30
@@ -17,6 +17,8 @@ members = [
|
||||
"crates/runtime-api",
|
||||
"crates/server-api",
|
||||
"crates/plugin-pdk",
|
||||
"crates/plugin-plan",
|
||||
"crates/plugin-runtime",
|
||||
"crates/yoi",
|
||||
"crates/protocol",
|
||||
"crates/session-metrics",
|
||||
@@ -24,14 +26,19 @@ members = [
|
||||
"crates/lint-common",
|
||||
"crates/tools",
|
||||
"crates/fs-operation",
|
||||
"crates/error-contract",
|
||||
"crates/flow",
|
||||
"crates/builtin-source",
|
||||
"crates/config-source",
|
||||
"crates/config-source-wasm",
|
||||
"crates/content-history",
|
||||
"crates/content-history-store",
|
||||
"crates/content-history-adapters",
|
||||
"crates/workdir",
|
||||
"crates/tui",
|
||||
"crates/memory",
|
||||
"crates/feature-storage",
|
||||
"crates/feature-permissions",
|
||||
"crates/workspace-drive",
|
||||
"crates/subjektiv",
|
||||
"crates/ticket",
|
||||
@@ -57,6 +64,8 @@ default-members = [
|
||||
"crates/runtime-api",
|
||||
"crates/server-api",
|
||||
"crates/plugin-pdk",
|
||||
"crates/plugin-plan",
|
||||
"crates/plugin-runtime",
|
||||
"crates/yoi",
|
||||
"crates/protocol",
|
||||
"crates/session-metrics",
|
||||
@@ -64,14 +73,19 @@ default-members = [
|
||||
"crates/lint-common",
|
||||
"crates/tools",
|
||||
"crates/fs-operation",
|
||||
"crates/error-contract",
|
||||
"crates/flow",
|
||||
"crates/builtin-source",
|
||||
"crates/config-source",
|
||||
"crates/config-source-wasm",
|
||||
"crates/content-history",
|
||||
"crates/content-history-store",
|
||||
"crates/content-history-adapters",
|
||||
"crates/workdir",
|
||||
"crates/tui",
|
||||
"crates/memory",
|
||||
"crates/feature-storage",
|
||||
"crates/feature-permissions",
|
||||
"crates/workspace-drive",
|
||||
"crates/subjektiv",
|
||||
"crates/ticket",
|
||||
@@ -91,6 +105,16 @@ debug = "line-tables-only"
|
||||
[profile.dev.package."*"]
|
||||
debug = false
|
||||
|
||||
# Keep the real SQLite engine in schema/transaction tests without paying for
|
||||
# an unoptimized C interpreter on every statement. Rust test assertions stay enabled.
|
||||
[profile.test.package.libsqlite3-sys]
|
||||
opt-level = 2
|
||||
|
||||
# Schema/authority integration tests execute substantial parsing and validation.
|
||||
# Optimize that code without disabling test/debug assertions or overflow checks.
|
||||
[profile.test.package.yoi-workspace-server]
|
||||
opt-level = 1
|
||||
|
||||
[workspace.dependencies]
|
||||
# Internal crates
|
||||
client = { path = "crates/client" }
|
||||
@@ -102,6 +126,7 @@ mcp = { path = "crates/mcp" }
|
||||
lint-common = { path = "crates/lint-common" }
|
||||
memory = { path = "crates/memory" }
|
||||
feature-storage = { path = "crates/feature-storage" }
|
||||
feature-permissions = { path = "crates/feature-permissions" }
|
||||
workspace-drive = { path = "crates/workspace-drive" }
|
||||
subjektiv = { path = "crates/subjektiv" }
|
||||
merge-request = { path = "crates/merge-request" }
|
||||
@@ -110,6 +135,7 @@ project-record = { path = "crates/project-record" }
|
||||
worker = { path = "crates/worker" }
|
||||
worker-runtime = { path = "crates/worker-runtime" }
|
||||
yoi-plugin-pdk = { path = "crates/plugin-pdk" }
|
||||
plugin-plan = { path = "crates/plugin-plan" }
|
||||
yoi = { path = "crates/yoi" }
|
||||
protocol = { path = "crates/protocol" }
|
||||
session-metrics = { path = "crates/session-metrics" }
|
||||
@@ -123,6 +149,10 @@ secrets = { path = "crates/secrets" }
|
||||
tools = { path = "crates/tools" }
|
||||
builtin-source = { path = "crates/builtin-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" }
|
||||
fs-operation = { path = "crates/fs-operation" }
|
||||
workdir = { path = "crates/workdir" }
|
||||
tui = { path = "crates/tui" }
|
||||
|
||||
+76
-51
@@ -1480,15 +1480,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
fn get_unanswered_tool_calls(&self, history: &History<A>) -> (Vec<ToolCall>, Vec<String>) {
|
||||
let mut pending_calls = Vec::new();
|
||||
let mut started_call_ids = Vec::new();
|
||||
let mut answered_call_ids = std::collections::HashSet::new();
|
||||
|
||||
for item in history.items() {
|
||||
if let Item::ToolResult { call_id, .. } = item {
|
||||
answered_call_ids.insert(call_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for item in history.items() {
|
||||
for entry in unanswered_tool_call_entries(history) {
|
||||
if let Item::ToolCall {
|
||||
call_id,
|
||||
name,
|
||||
@@ -1496,8 +1488,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
execution_id,
|
||||
status,
|
||||
..
|
||||
} = item
|
||||
&& !answered_call_ids.contains(call_id)
|
||||
} = &entry.item
|
||||
{
|
||||
if execution_id.is_some() || *status == Some(ItemStatus::InProgress) {
|
||||
started_call_ids.push(call_id.clone());
|
||||
@@ -1639,6 +1630,30 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
} = info;
|
||||
tool_call = call;
|
||||
context.call_id = tool_call.id.clone();
|
||||
if let PreToolAction::SyntheticResult(result) = &action
|
||||
&& result.tool_use_id != expected_tool_use_id
|
||||
{
|
||||
return Err(InterceptorFailure::new(
|
||||
InterceptorPhase::PreToolCall,
|
||||
InterceptorError::new(
|
||||
InterceptorErrorCategory::ContractViolation,
|
||||
"synthetic tool result changed immutable tool call identity",
|
||||
),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let execution_id = if matches!(action, PreToolAction::Pause) {
|
||||
None
|
||||
} else {
|
||||
Some(context.execution_id())
|
||||
};
|
||||
self.append_early_tool_call(history, annotate, &tool_call, call_index, execution_id)?;
|
||||
// Policy runs before this call exists in history. Only the successful
|
||||
// append's annotation may bind the identity, overriding hook changes.
|
||||
context.logical_call_id = history
|
||||
.entries()
|
||||
.last()
|
||||
.and_then(|entry| self.interceptor.logical_tool_call_id(&entry.annotation));
|
||||
batch.call_info_map.insert(
|
||||
tool_call.id.clone(),
|
||||
(tool_call.clone(), meta, tool.clone(), context.clone()),
|
||||
@@ -1647,13 +1662,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
match action {
|
||||
PreToolAction::Continue => {
|
||||
let attempt_id = context.execution_id();
|
||||
self.append_early_tool_call(
|
||||
history,
|
||||
annotate,
|
||||
&tool_call,
|
||||
call_index,
|
||||
Some(attempt_id.clone()),
|
||||
)?;
|
||||
batch
|
||||
.attempt_fence
|
||||
.register(tool_call.id.clone(), attempt_id.clone());
|
||||
@@ -1695,34 +1703,10 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
batch.dispatched_any = true;
|
||||
}
|
||||
PreToolAction::SyntheticResult(result) => {
|
||||
if result.tool_use_id != expected_tool_use_id {
|
||||
return Err(InterceptorFailure::new(
|
||||
InterceptorPhase::PreToolCall,
|
||||
InterceptorError::new(
|
||||
InterceptorErrorCategory::ContractViolation,
|
||||
"synthetic tool result changed immutable tool call identity",
|
||||
),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
self.append_early_tool_call(
|
||||
history,
|
||||
annotate,
|
||||
&tool_call,
|
||||
call_index,
|
||||
Some(context.execution_id()),
|
||||
)?;
|
||||
batch.synthetic_results.push_back(result);
|
||||
batch.dispatched_any = true;
|
||||
}
|
||||
PreToolAction::Skip => {
|
||||
self.append_early_tool_call(
|
||||
history,
|
||||
annotate,
|
||||
&tool_call,
|
||||
call_index,
|
||||
Some(context.execution_id()),
|
||||
)?;
|
||||
batch.synthetic_results.push_back(ToolResult::error(
|
||||
&tool_call.id,
|
||||
"Tool execution skipped by interceptor",
|
||||
@@ -1730,13 +1714,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
batch.dispatched_any = true;
|
||||
}
|
||||
PreToolAction::Abort(reason) => {
|
||||
self.append_early_tool_call(
|
||||
history,
|
||||
annotate,
|
||||
&tool_call,
|
||||
call_index,
|
||||
Some(context.execution_id()),
|
||||
)?;
|
||||
batch.synthetic_results.push_back(ToolResult::error(
|
||||
&tool_call.id,
|
||||
format!("Tool execution aborted: {reason}"),
|
||||
@@ -1745,7 +1722,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
return Err(EngineError::Aborted(reason));
|
||||
}
|
||||
PreToolAction::Pause => {
|
||||
self.append_early_tool_call(history, annotate, &tool_call, call_index, None)?;
|
||||
batch.dispatched_any = true;
|
||||
return Ok(EarlyToolAdmission::Pause);
|
||||
}
|
||||
@@ -2010,10 +1986,24 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
let batch_id = format!("tool-batch-{}", self.tool_execution_batch_count);
|
||||
self.tool_execution_batch_count += 1;
|
||||
|
||||
let mut logical_call_ids: HashMap<String, VecDeque<Option<String>>> = HashMap::new();
|
||||
for entry in unanswered_tool_call_entries(history) {
|
||||
if let Item::ToolCall { call_id, .. } = &entry.item {
|
||||
logical_call_ids
|
||||
.entry(call_id.clone())
|
||||
.or_default()
|
||||
.push_back(self.interceptor.logical_tool_call_id(&entry.annotation));
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1: Apply pre_tool_call interceptor (determine skip/abort/synthetic result)
|
||||
let mut approved_calls = Vec::new();
|
||||
for (call_index, mut tool_call) in tool_calls.into_iter().enumerate() {
|
||||
let expected_tool_use_id = tool_call.id.clone();
|
||||
let logical_call_id = logical_call_ids
|
||||
.get_mut(&tool_call.id)
|
||||
.and_then(VecDeque::pop_front)
|
||||
.flatten();
|
||||
let context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
|
||||
if !tool_call.input.is_object() {
|
||||
synthetic_results.push(ToolResult::error(
|
||||
@@ -2079,6 +2069,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
let tool_call = info.call;
|
||||
let mut context = info.context;
|
||||
context.call_id = tool_call.id.clone();
|
||||
context.logical_call_id = logical_call_id;
|
||||
call_info_map.insert(
|
||||
tool_call.id.clone(),
|
||||
(tool_call, info.meta.clone(), info.tool.clone(), context),
|
||||
@@ -2098,6 +2089,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
tool_call = info.call;
|
||||
let mut context = info.context;
|
||||
context.call_id = tool_call.id.clone();
|
||||
context.logical_call_id = logical_call_id;
|
||||
|
||||
call_info_map.insert(
|
||||
tool_call.id.clone(),
|
||||
@@ -2111,7 +2103,8 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
approved_calls.push((tool_call, context, Some(info.tool)));
|
||||
} else {
|
||||
// Unknown tools go into approved list as-is (will error at execution)
|
||||
let context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
|
||||
let mut context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
|
||||
context.logical_call_id = logical_call_id;
|
||||
approved_calls.push((tool_call, context, None));
|
||||
}
|
||||
}
|
||||
@@ -4333,6 +4326,38 @@ fn item_kind(item: &Item) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pair each result with one preceding unanswered occurrence, never with a later
|
||||
/// call that happens to reuse the provider ID. Keep the exact annotated entries
|
||||
/// so dispatch and restoration resolve the same host-owned identity.
|
||||
fn unanswered_tool_call_entries<A>(history: &History<A>) -> Vec<&HistoryEntry<A>> {
|
||||
let mut calls = Vec::new();
|
||||
let mut by_provider_id: HashMap<&str, VecDeque<usize>> = HashMap::new();
|
||||
for entry in history.entries() {
|
||||
match &entry.item {
|
||||
Item::ToolCall { call_id, .. } => {
|
||||
by_provider_id
|
||||
.entry(call_id.as_str())
|
||||
.or_default()
|
||||
.push_back(calls.len());
|
||||
calls.push(Some(entry));
|
||||
}
|
||||
Item::ToolResult { call_id, .. } => {
|
||||
if let Some(queue) = by_provider_id.get_mut(call_id.as_str())
|
||||
&& let Some(index) = queue.pop_front()
|
||||
{
|
||||
calls[index] = None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
calls.into_iter().flatten().collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/tool_call_identity.rs"]
|
||||
mod tool_call_identity_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -309,7 +309,7 @@ pub struct ToolCallInfo<'a, A = ()> {
|
||||
pub meta: ToolMeta,
|
||||
/// Tool instance (for state access).
|
||||
pub tool: Arc<dyn Tool>,
|
||||
/// Response-local execution context for this call.
|
||||
/// Execution context. The Engine binds durable identity after pre-tool policy.
|
||||
pub context: ToolExecutionContext,
|
||||
}
|
||||
|
||||
@@ -324,7 +324,7 @@ pub struct ToolResultInfo<'a, A = ()> {
|
||||
pub meta: ToolMeta,
|
||||
/// Tool instance (for state access).
|
||||
pub tool: Arc<dyn Tool>,
|
||||
/// Response-local execution context for this call.
|
||||
/// Execution context. The Engine binds durable identity after pre-tool policy.
|
||||
pub context: ToolExecutionContext,
|
||||
}
|
||||
|
||||
@@ -344,6 +344,14 @@ pub struct ToolResultInfo<'a, A = ()> {
|
||||
/// flows, permission checks, and other trusted host adaptation.
|
||||
#[async_trait]
|
||||
pub trait Interceptor<A: Send + Sync = ()>: Send + Sync {
|
||||
/// Pure projection of a committed ToolCall's host annotation into an opaque,
|
||||
/// durable call identity. Agen neither interprets storage metadata nor derives
|
||||
/// this identity from provider IDs. Early pre-tool policy runs before commit;
|
||||
/// the Engine binds this value after policy, before execution.
|
||||
fn logical_tool_call_id(&self, _annotation: &A) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether side effects may start before [`Self::on_assistant_turn_end`].
|
||||
///
|
||||
/// The conservative default preserves the historical whole-response gate.
|
||||
|
||||
@@ -66,11 +66,15 @@ pub(crate) struct CandidatePart {
|
||||
|
||||
/// Function call.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct FunctionCall {
|
||||
/// Function name.
|
||||
pub name: String,
|
||||
/// Arguments.
|
||||
pub args: Option<serde_json::Value>,
|
||||
/// Partial-argument protocol is not a complete args object.
|
||||
pub partial_args: Option<serde_json::Value>,
|
||||
pub will_continue: Option<bool>,
|
||||
}
|
||||
|
||||
/// Usage metadata.
|
||||
@@ -110,6 +114,14 @@ pub(crate) struct SafetyRating {
|
||||
// Event conversion
|
||||
// ============================================================================
|
||||
|
||||
/// Response-local Gemini block allocation. Parts in separate SSE frames may
|
||||
/// restart their array index, and function names are not call identities.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct GeminiStreamState {
|
||||
next_index: usize,
|
||||
last_text_index: Option<usize>,
|
||||
}
|
||||
|
||||
impl GeminiScheme {
|
||||
/// Convert SSE data to Event.
|
||||
///
|
||||
@@ -120,7 +132,11 @@ impl GeminiScheme {
|
||||
/// * `Ok(Some(Vec<Event>))` - Conversion succeeded.
|
||||
/// * `Ok(None)` - Event ignored.
|
||||
/// * `Err(ClientError)` - Parse error.
|
||||
pub(crate) fn parse_event(&self, data: &str) -> Result<Option<Vec<Event>>, ClientError> {
|
||||
pub(crate) fn parse_event(
|
||||
&self,
|
||||
data: &str,
|
||||
state: &mut GeminiStreamState,
|
||||
) -> Result<Option<Vec<Event>>, ClientError> {
|
||||
// Skip empty or invalid data.
|
||||
if data.is_empty() || data == "[DONE]" {
|
||||
return Ok(None);
|
||||
@@ -144,31 +160,39 @@ impl GeminiScheme {
|
||||
// Process candidates.
|
||||
if let Some(candidates) = response.candidates {
|
||||
for candidate in candidates {
|
||||
let candidate_index = candidate.index.unwrap_or(0);
|
||||
|
||||
if let Some(content) = candidate.content {
|
||||
if let Some(parts) = content.parts {
|
||||
for (part_index, part) in parts.iter().enumerate() {
|
||||
for part in &parts {
|
||||
let index = state.next_index;
|
||||
state.next_index += 1;
|
||||
// Text delta.
|
||||
if let Some(text) = &part.text {
|
||||
if !text.is_empty() {
|
||||
// Gemini sends no explicit BlockStart, so emit TextDelta
|
||||
// directly (Timeline handles the implicit start).
|
||||
events.push(Event::text_delta(part_index, text.clone()));
|
||||
state.last_text_index = Some(index);
|
||||
events.push(Event::text_delta(index, text.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// Function call.
|
||||
if let Some(function_call) = &part.function_call {
|
||||
// Function call start.
|
||||
// Gemini usually sends function calls all at once, but may
|
||||
// send partial calls when streaming arguments are enabled.
|
||||
|
||||
// Gemini has no function call IDs, so use the name as the ID.
|
||||
let function_id = format!("call_{}", function_call.name);
|
||||
if function_call.partial_args.is_some()
|
||||
|| function_call.will_continue.is_some()
|
||||
{
|
||||
return Err(ClientError::Api {
|
||||
status: None,
|
||||
code: Some("unsupported_partial_function_call".into()),
|
||||
message: "Gemini partial function arguments require an unsupported streaming protocol".into(),
|
||||
retry_after: None,
|
||||
});
|
||||
}
|
||||
// These synthetic IDs correlate calls within a response;
|
||||
// durable identity still comes from Host history entries.
|
||||
let function_id = format!("call_{index}");
|
||||
|
||||
events.push(Event::BlockStart(BlockStart {
|
||||
index: candidate_index * 10 + part_index, // Composite index.
|
||||
index,
|
||||
block_type: BlockType::ToolUse,
|
||||
metadata: BlockMetadata::ToolUse {
|
||||
id: function_id,
|
||||
@@ -180,12 +204,13 @@ impl GeminiScheme {
|
||||
if let Some(args) = &function_call.args {
|
||||
let args_str = serde_json::to_string(args).unwrap_or_default();
|
||||
if !args_str.is_empty() && args_str != "null" {
|
||||
events.push(Event::tool_input_delta(
|
||||
candidate_index * 10 + part_index,
|
||||
args_str,
|
||||
));
|
||||
events.push(Event::tool_input_delta(index, args_str));
|
||||
}
|
||||
}
|
||||
// Each args object is complete in this wire part. Close
|
||||
// its collector before the next function call starts;
|
||||
// dispatch still waits for the response-complete gate.
|
||||
events.push(Event::tool_use_stop(index));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,13 +225,14 @@ impl GeminiScheme {
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Text block stop.
|
||||
events.push(Event::BlockStop(BlockStop {
|
||||
index: candidate_index,
|
||||
block_type: BlockType::Text,
|
||||
stop_reason,
|
||||
reasoning: None,
|
||||
}));
|
||||
if let Some(index) = state.last_text_index.take() {
|
||||
events.push(Event::BlockStop(BlockStop {
|
||||
index,
|
||||
block_type: BlockType::Text,
|
||||
stop_reason,
|
||||
reasoning: None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,7 +266,10 @@ mod tests {
|
||||
let data =
|
||||
r#"{"candidates":[{"content":{"parts":[{"text":"Hello"}],"role":"model"},"index":0}]}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
let events = scheme
|
||||
.parse_event(data, &mut GeminiStreamState::default())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
if let Event::BlockDelta(delta) = &events[0] {
|
||||
@@ -260,7 +289,10 @@ mod tests {
|
||||
let scheme = GeminiScheme::new();
|
||||
let data = r#"{"candidates":[{"content":{"parts":[{"text":"Hi"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
let events = scheme
|
||||
.parse_event(data, &mut GeminiStreamState::default())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// Should contain a Usage event.
|
||||
let usage_event = events.iter().find(|e| matches!(e, Event::Usage(_)));
|
||||
@@ -278,7 +310,10 @@ mod tests {
|
||||
let scheme = GeminiScheme::new();
|
||||
let data = r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"get_weather","args":{"location":"Tokyo"}}}],"role":"model"},"index":0}]}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
let events = scheme
|
||||
.parse_event(data, &mut GeminiStreamState::default())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// Should contain a BlockStart event.
|
||||
let start_event = events.iter().find(|e| matches!(e, Event::BlockStart(_)));
|
||||
@@ -309,7 +344,10 @@ mod tests {
|
||||
let scheme = GeminiScheme::new();
|
||||
let data = r#"{"candidates":[{"content":{"parts":[{"text":"Done"}],"role":"model"},"finishReason":"STOP","index":0}]}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
let events = scheme
|
||||
.parse_event(data, &mut GeminiStreamState::default())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// Should contain a BlockStop event.
|
||||
let stop_event = events.iter().find(|e| matches!(e, Event::BlockStop(_)));
|
||||
@@ -320,10 +358,85 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_name_calls_have_distinct_ids_and_block_indexes_across_parts_and_frames() {
|
||||
use crate::llm_client::scheme::Scheme;
|
||||
let scheme = GeminiScheme::new();
|
||||
for frames in [
|
||||
vec![
|
||||
r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"echo","args":{"text":"first"}}},{"functionCall":{"name":"echo","args":{"text":"second"}}}]}}]}"#,
|
||||
],
|
||||
vec![
|
||||
r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"echo","args":{"text":"first"}}}]}}]}"#,
|
||||
r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"echo","args":{"text":"second"}}}]}}]}"#,
|
||||
],
|
||||
] {
|
||||
let mut state = Default::default();
|
||||
let mut events = Vec::new();
|
||||
for frame in frames {
|
||||
events.extend(scheme.parse_sse("", frame, &mut state).unwrap());
|
||||
}
|
||||
let calls: Vec<_> = events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
Event::BlockStart(BlockStart {
|
||||
index,
|
||||
metadata: BlockMetadata::ToolUse { id, name },
|
||||
..
|
||||
}) => Some((*index, id.as_str(), name.as_str())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(calls, [(0, "call_0", "echo"), (1, "call_1", "echo")]);
|
||||
let inputs: Vec<_> = events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
Event::BlockDelta(delta) => match &delta.delta {
|
||||
DeltaContent::InputJson(json) => Some((delta.index, json.as_str())),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
inputs,
|
||||
[(0, r#"{"text":"first"}"#), (1, r#"{"text":"second"}"#)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_argument_protocol_is_not_misread_as_an_empty_complete_call() {
|
||||
use crate::llm_client::scheme::Scheme;
|
||||
let scheme = GeminiScheme::new();
|
||||
for call in [
|
||||
r#"{"name":"echo","partialArgs":[{"jsonPath":"$.text","stringValue":"part"}]}"#,
|
||||
r#"{"name":"echo","willContinue":true,"args":{"text":"part"}}"#,
|
||||
r#"{"name":"echo","willContinue":false}"#,
|
||||
] {
|
||||
let data = format!(
|
||||
r#"{{"candidates":[{{"content":{{"parts":[{{"functionCall":{call}}}]}}}}]}}"#
|
||||
);
|
||||
assert!(
|
||||
matches!(scheme.parse_sse("", &data, &mut Default::default()), Err(ClientError::Api { code: Some(code), .. }) if code == "unsupported_partial_function_call")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_empty_data() {
|
||||
let scheme = GeminiScheme::new();
|
||||
assert!(scheme.parse_event("").unwrap().is_none());
|
||||
assert!(scheme.parse_event("[DONE]").unwrap().is_none());
|
||||
assert!(
|
||||
scheme
|
||||
.parse_event("", &mut GeminiStreamState::default())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
scheme
|
||||
.parse_event("[DONE]", &mut GeminiStreamState::default())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,6 +239,8 @@ impl GeminiScheme {
|
||||
let mut contents = Vec::new();
|
||||
let mut pending_model_parts: Vec<GeminiPart> = Vec::new();
|
||||
let mut pending_user_parts: Vec<GeminiPart> = Vec::new();
|
||||
let mut call_names: std::collections::HashMap<&str, std::collections::VecDeque<&str>> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for item in items {
|
||||
match item {
|
||||
@@ -270,8 +272,12 @@ impl GeminiScheme {
|
||||
}
|
||||
|
||||
Item::ToolCall {
|
||||
name, arguments, ..
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
..
|
||||
} => {
|
||||
call_names.entry(call_id).or_default().push_back(name);
|
||||
// Flush pending user parts first
|
||||
if !pending_user_parts.is_empty() {
|
||||
contents.push(GeminiContent {
|
||||
@@ -309,11 +315,17 @@ impl GeminiScheme {
|
||||
Some(c) => format!("{summary}\n{c}"),
|
||||
None => summary.clone(),
|
||||
};
|
||||
// Gemini responses identify the function by its declaration
|
||||
// name, never by the adapter's response-local correlation ID.
|
||||
let name = call_names
|
||||
.get_mut(call_id.as_str())
|
||||
.and_then(std::collections::VecDeque::pop_front)
|
||||
.unwrap_or(call_id.as_str());
|
||||
pending_user_parts.push(GeminiPart::FunctionResponse {
|
||||
function_response: GeminiFunctionResponse {
|
||||
name: call_id.clone(),
|
||||
name: name.to_string(),
|
||||
response: GeminiFunctionResponseContent {
|
||||
name: call_id.clone(),
|
||||
name: name.to_string(),
|
||||
content: Value::String(text),
|
||||
},
|
||||
},
|
||||
@@ -468,6 +480,36 @@ mod tests {
|
||||
assert_eq!(gemini_req.contents[0].role, "user");
|
||||
assert_eq!(gemini_req.contents[1].role, "model");
|
||||
assert_eq!(gemini_req.contents[2].role, "user");
|
||||
let body = serde_json::to_value(gemini_req).unwrap();
|
||||
assert_eq!(
|
||||
body["contents"][2]["parts"][0]["functionResponse"]["name"],
|
||||
"get_weather"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_results_resolve_function_names_not_synthetic_call_ids() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let request = Request::new()
|
||||
.item(Item::tool_call("call_0", "echo", r#"{"text":"first"}"#))
|
||||
.item(Item::tool_call("call_1", "echo", r#"{"text":"second"}"#))
|
||||
.item(Item::tool_result("call_1", "second"))
|
||||
.item(Item::tool_result("call_0", "first"))
|
||||
.item(Item::tool_call("call_0", "other", "{}"))
|
||||
.item(Item::tool_result("call_0", "later"));
|
||||
let body = serde_json::to_value(scheme.build_request(&request, &cap())).unwrap();
|
||||
let responses: Vec<_> = body["contents"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.flat_map(|content| content["parts"].as_array().unwrap())
|
||||
.filter_map(|part| part.get("functionResponse"))
|
||||
.collect();
|
||||
assert_eq!(responses.len(), 3);
|
||||
for (response, name) in responses.iter().zip(["echo", "echo", "other"]) {
|
||||
assert_eq!(response["name"], name);
|
||||
assert_eq!(response["response"]["name"], name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::llm_client::{
|
||||
use super::GeminiScheme;
|
||||
|
||||
impl Scheme for GeminiScheme {
|
||||
type State = ();
|
||||
type State = super::events::GeminiStreamState;
|
||||
|
||||
fn default_base_url(&self) -> &'static str {
|
||||
"https://generativelanguage.googleapis.com"
|
||||
@@ -38,9 +38,9 @@ impl Scheme for GeminiScheme {
|
||||
&self,
|
||||
_event_type: &str,
|
||||
data: &str,
|
||||
_state: &mut Self::State,
|
||||
state: &mut Self::State,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
Ok(self.parse_event(data)?.unwrap_or_default())
|
||||
Ok(self.parse_event(data, state)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn default_capability(&self) -> ModelCapability {
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
//! Engine component tests with a recording tool and scripted host annotations.
|
||||
//! Persistence is represented by the append callback; Server/IPC storage is not
|
||||
//! exercised here. Run with `cargo test -p agen --lib tool_call_identity_tests`.
|
||||
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct NoLlm;
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for NoLlm {
|
||||
async fn stream(&self, _: Request) -> Result<ResponseStream, ClientError> {
|
||||
panic!("identity tests must not request a model response")
|
||||
}
|
||||
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
struct HostIdentity {
|
||||
early: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor<String> for HostIdentity {
|
||||
fn logical_tool_call_id(&self, annotation: &String) -> Option<String> {
|
||||
Some(annotation.clone())
|
||||
}
|
||||
|
||||
async fn pre_tool_call(
|
||||
&self,
|
||||
info: &mut ToolCallInfo<'_, String>,
|
||||
) -> Result<PreToolAction, InterceptorError> {
|
||||
if self.early {
|
||||
assert!(info.history.is_empty());
|
||||
}
|
||||
// Pre-tool hooks may replace the context, but cannot replace persisted identity.
|
||||
info.context.logical_call_id = Some("hook-forged-identity".into());
|
||||
Ok(PreToolAction::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
struct RecordingTool(Arc<Mutex<Vec<ToolExecutionContext>>>);
|
||||
|
||||
#[async_trait]
|
||||
impl crate::tool::Tool for RecordingTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
_: &str,
|
||||
context: ToolExecutionContext,
|
||||
) -> Result<crate::tool::ToolOutput, ToolError> {
|
||||
self.0.lock().unwrap().push(context);
|
||||
Ok("recorded".to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
fn engine(
|
||||
early: bool,
|
||||
) -> (
|
||||
Engine<NoLlm, Mutable, String>,
|
||||
Arc<Mutex<Vec<ToolExecutionContext>>>,
|
||||
) {
|
||||
let mut engine = Engine::new_annotated(NoLlm);
|
||||
engine.set_interceptor(HostIdentity { early });
|
||||
engine.max_turns = Some(0);
|
||||
engine.start_logical_run();
|
||||
let observed = Arc::new(Mutex::new(Vec::new()));
|
||||
let tool = Arc::new(RecordingTool(observed.clone()));
|
||||
engine.register_tool(Arc::new(move || {
|
||||
(crate::tool::ToolMeta::new("record"), tool.clone())
|
||||
}));
|
||||
engine.tool_server_handle().flush_pending();
|
||||
(engine, observed)
|
||||
}
|
||||
|
||||
fn entry(item: Item, id: &str) -> HistoryEntry<String> {
|
||||
HistoryEntry::new(item, id.to_string())
|
||||
}
|
||||
|
||||
fn call(id: &str) -> Item {
|
||||
Item::tool_call_json(id, "record", json!({}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_answer_only_preceding_occurrences_of_reused_provider_ids() {
|
||||
let history = History::from_entries(vec![
|
||||
entry(
|
||||
Item::tool_result("orphan", "no preceding call"),
|
||||
"orphan-result",
|
||||
),
|
||||
entry(call("reused"), "host-first"),
|
||||
entry(call("sibling"), "host-sibling"),
|
||||
entry(
|
||||
Item::tool_result("sibling", "out of order"),
|
||||
"sibling-result",
|
||||
),
|
||||
entry(Item::tool_result("reused", "answered"), "first-result"),
|
||||
entry(call("reused"), "host-pending"),
|
||||
entry(call("orphan"), "host-after-orphan"),
|
||||
]);
|
||||
let pending = unanswered_tool_call_entries(&history);
|
||||
assert_eq!(
|
||||
pending
|
||||
.iter()
|
||||
.map(|entry| entry.annotation.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["host-pending", "host-after-orphan"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn early_dispatch_binds_identity_only_after_successful_append() {
|
||||
for reject_append in [false, true] {
|
||||
let (mut engine, observed) = engine(true);
|
||||
let mut history = History::new();
|
||||
let mut batch = EarlyToolExecutionBatch::new("live-batch".into(), 0);
|
||||
let result = engine
|
||||
.admit_early_tool_calls(
|
||||
&mut history,
|
||||
&mut |_| {
|
||||
if reject_append {
|
||||
Err("storage unavailable".into())
|
||||
} else {
|
||||
Ok("host-committed-entry".into())
|
||||
}
|
||||
},
|
||||
&mut batch,
|
||||
vec![crate::timeline::CollectedToolCall {
|
||||
call_index: 0,
|
||||
call: ToolCall {
|
||||
id: "provider-call".into(),
|
||||
name: "record".into(),
|
||||
input: json!({}),
|
||||
},
|
||||
}],
|
||||
)
|
||||
.await;
|
||||
if reject_append {
|
||||
assert!(matches!(result, Err(EngineError::HistoryAppend(_))));
|
||||
assert!(history.is_empty());
|
||||
assert!(batch.execution_handles.is_empty());
|
||||
assert!(observed.lock().unwrap().is_empty());
|
||||
} else {
|
||||
assert!(matches!(result, Ok(EarlyToolAdmission::Continue)));
|
||||
let (_, terminal) = batch.futures.next().await.unwrap();
|
||||
assert_eq!(terminal.tool_use_id, "provider-call");
|
||||
let observed = observed.lock().unwrap();
|
||||
assert_eq!(observed.len(), 1);
|
||||
assert_eq!(
|
||||
observed[0].logical_call_id.as_deref(),
|
||||
Some(history.entries()[0].annotation.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
observed[0].logical_call_id.as_deref(),
|
||||
Some("host-committed-entry")
|
||||
);
|
||||
assert_eq!(batch.call_info_map["provider-call"].3, observed[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn normal_dispatch_and_unstarted_restore_use_the_exact_unanswered_annotation() {
|
||||
for restore in [false, true] {
|
||||
let (mut engine, observed) = engine(false);
|
||||
let mut history = History::from_entries(vec![
|
||||
entry(call("reused"), "host-answered"),
|
||||
entry(
|
||||
Item::tool_result("reused", "already completed"),
|
||||
"answered-result",
|
||||
),
|
||||
]);
|
||||
engine
|
||||
.append_history_items(&mut history, [call("reused")], &mut |_| {
|
||||
Ok("host-pending".into())
|
||||
})
|
||||
.unwrap();
|
||||
if restore {
|
||||
let persisted = serde_json::to_string(&history).unwrap();
|
||||
history = serde_json::from_str(&persisted).unwrap();
|
||||
}
|
||||
let (pending, started) = engine.get_unanswered_tool_calls(&history);
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert!(started.is_empty());
|
||||
if restore {
|
||||
engine
|
||||
.run_turn_loop(&mut history, &mut |_| Ok("terminal-result".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
} else {
|
||||
let result = engine
|
||||
.execute_tools(&mut history, &mut |_| Ok("terminal-result".into()), pending)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ToolExecutionResult::Completed));
|
||||
}
|
||||
assert_eq!(observed.lock().unwrap().len(), 1);
|
||||
let context = observed.lock().unwrap()[0].clone();
|
||||
assert_eq!(context.call_id, "reused");
|
||||
assert_eq!(context.logical_call_id.as_deref(), Some("host-pending"));
|
||||
// A subsequent resume sees the terminal result, not another live attempt.
|
||||
engine
|
||||
.run_turn_loop(&mut history, &mut |_| Ok("unexpected-append".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(observed.lock().unwrap().len(), 1);
|
||||
assert!(unanswered_tool_call_entries(&history).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn started_restore_with_reused_provider_id_closes_unknown_without_reexecution() {
|
||||
let (mut engine, observed) = engine(false);
|
||||
let mut history = History::from_entries(vec![
|
||||
entry(call("reused"), "host-answered"),
|
||||
entry(
|
||||
Item::tool_result("reused", "already completed"),
|
||||
"answered-result",
|
||||
),
|
||||
entry(
|
||||
call("reused")
|
||||
.with_tool_execution_metadata(0, Some("old-attempt".into()))
|
||||
.with_status(ItemStatus::InProgress),
|
||||
"host-started",
|
||||
),
|
||||
]);
|
||||
let (pending, started) = engine.get_unanswered_tool_calls(&history);
|
||||
assert!(pending.is_empty());
|
||||
assert_eq!(started, ["reused"]);
|
||||
engine
|
||||
.run_turn_loop(&mut history, &mut |_| Ok("unknown-result".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(observed.lock().unwrap().is_empty());
|
||||
assert!(matches!(
|
||||
&history.entries().last().unwrap().item,
|
||||
Item::ToolResult { call_id, disposition: ToolResultDisposition::OutcomeUnknown, .. }
|
||||
if call_id == "reused"
|
||||
));
|
||||
let len = history.len();
|
||||
engine
|
||||
.run_turn_loop(&mut history, &mut |_| Ok("unexpected-append".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(history.len(), len);
|
||||
assert!(observed.lock().unwrap().is_empty());
|
||||
}
|
||||
+14
-6
@@ -336,15 +336,16 @@ pub type ToolDefinition = Arc<dyn Fn() -> (ToolMeta, Arc<dyn Tool>) + Send + Syn
|
||||
|
||||
/// Per-call context supplied by the engine when executing a tool call.
|
||||
///
|
||||
/// The context identifies a tool call within one assistant response's tool-call
|
||||
/// batch without imposing any scheduling policy on the engine. Tool
|
||||
/// implementations may use it for response-local ordering, diagnostics, or
|
||||
/// correlation, but it is intentionally not a handle to engine state, history,
|
||||
/// or session mutation.
|
||||
/// Live batch identity and ordering are response-local. An optional opaque host
|
||||
/// identity comes from the committed ToolCall annotation and survives restore.
|
||||
/// Neither is a handle to engine state, history, or session mutation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ToolExecutionContext {
|
||||
/// Provider/tool-call id for the call being executed.
|
||||
pub call_id: String,
|
||||
/// Opaque host identity from the committed ToolCall annotation, stable across
|
||||
/// restoration. Provider call IDs and live batch IDs are not replay authority.
|
||||
pub logical_call_id: Option<String>,
|
||||
/// Engine-local identity shared by all tool calls from one execution batch.
|
||||
pub batch_id: String,
|
||||
/// Zero-based order of this call in the model-returned tool-call list.
|
||||
@@ -355,11 +356,18 @@ impl ToolExecutionContext {
|
||||
pub fn new(call_id: impl Into<String>, batch_id: impl Into<String>, call_index: usize) -> Self {
|
||||
Self {
|
||||
call_id: call_id.into(),
|
||||
logical_call_id: None,
|
||||
batch_id: batch_id.into(),
|
||||
call_index,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind an identity supplied by the host's persisted-call annotation.
|
||||
pub fn with_logical_call_id(mut self, logical_call_id: impl Into<String>) -> Self {
|
||||
self.logical_call_id = Some(logical_call_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Identifies one live execution attempt without making the batch id a durable
|
||||
/// replay or idempotency authority.
|
||||
pub fn execution_id(&self) -> String {
|
||||
@@ -572,7 +580,7 @@ pub trait Tool: Send + Sync {
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `input_json` - JSON-formatted arguments generated by LLM
|
||||
/// * `ctx` - response-local call identity and ordering context
|
||||
/// * `ctx` - per-call identity and live execution ordering context
|
||||
///
|
||||
/// # Returns
|
||||
/// A [`ToolOutput`] with summary and optional detailed content.
|
||||
|
||||
+262
-113
@@ -53,6 +53,7 @@ enum Method {
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct AdapterConfig {
|
||||
reqwest: bool,
|
||||
client_error_hook: bool,
|
||||
axum: bool,
|
||||
openapi: bool,
|
||||
}
|
||||
@@ -64,9 +65,10 @@ impl Parse for AdapterConfig {
|
||||
for adapter in adapters {
|
||||
match adapter.to_string().as_str() {
|
||||
"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,
|
||||
"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"));
|
||||
}
|
||||
_ => {
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -121,6 +129,8 @@ struct RouteArgs {
|
||||
responses: Vec<DeclaredResponse>,
|
||||
error_status: Option<LitInt>,
|
||||
additional_error_statuses: Vec<LitInt>,
|
||||
additional_error_content_types: Option<Vec<LitStr>>,
|
||||
client_error_hook: Option<syn::LitBool>,
|
||||
bearer_auth: Option<syn::LitBool>,
|
||||
browser_auth: Option<syn::LitBool>,
|
||||
normalize_body_errors: Option<syn::LitBool>,
|
||||
@@ -310,6 +320,8 @@ impl Parse for RouteArgs {
|
||||
responses: Vec::new(),
|
||||
error_status: None,
|
||||
additional_error_statuses: Vec::new(),
|
||||
additional_error_content_types: None,
|
||||
client_error_hook: None,
|
||||
bearer_auth: None,
|
||||
browser_auth: 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(
|
||||
&mut result.bearer_auth,
|
||||
input.parse::<syn::LitBool>()?,
|
||||
@@ -406,7 +437,7 @@ impl Parse for RouteArgs {
|
||||
_ => {
|
||||
return Err(syn::Error::new(
|
||||
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>,
|
||||
error_status: Option<u16>,
|
||||
additional_error_statuses: Vec<u16>,
|
||||
additional_error_content_types: Vec<String>,
|
||||
client_error_hook: bool,
|
||||
bearer_auth: bool,
|
||||
browser_auth: bool,
|
||||
normalize_body_errors: bool,
|
||||
@@ -546,7 +579,7 @@ fn normalize_api(
|
||||
for mut trait_item in std::mem::take(&mut item.items) {
|
||||
let operation = match &mut trait_item {
|
||||
TraitItem::Fn(method) => {
|
||||
let operation = normalize_operation(method)?;
|
||||
let operation = normalize_operation(method, adapters)?;
|
||||
retained_items.push(trait_item);
|
||||
operation
|
||||
}
|
||||
@@ -778,6 +811,8 @@ fn normalize_websocket_operation(declaration: &mut syn::TraitItemType) -> syn::R
|
||||
alternate_status: None,
|
||||
error_status: None,
|
||||
additional_error_statuses: Vec::new(),
|
||||
additional_error_content_types: Vec::new(),
|
||||
client_error_hook: false,
|
||||
bearer_auth: false,
|
||||
browser_auth: 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)?;
|
||||
|
||||
let mut route = None;
|
||||
@@ -845,6 +883,53 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
|
||||
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();
|
||||
if declared_responses && (route.status.is_some() || route.alternate_status.is_some()) {
|
||||
return Err(syn::Error::new_spanned(
|
||||
@@ -1196,6 +1281,8 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
|
||||
alternate_status,
|
||||
error_status,
|
||||
additional_error_statuses,
|
||||
additional_error_content_types,
|
||||
client_error_hook,
|
||||
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),
|
||||
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<()> {
|
||||
let sig = &method.sig;
|
||||
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() {
|
||||
Some(error) => quote! {
|
||||
Some(_) => quote! {
|
||||
if (400..=599).contains(&__actual) {
|
||||
let __status = __response.status();
|
||||
let __error = __response
|
||||
.decode_json::<#error>(#api_crate::reqwest::DecodeKind::PublicError)
|
||||
.map_err(|_| #api_crate::reqwest::ClientError::failure(
|
||||
#api_crate::reqwest::ClientFailure::ErrorResponseDecode {
|
||||
status: __status.as_u16(),
|
||||
},
|
||||
))?;
|
||||
let __error = #public_error_decode;
|
||||
return ::core::result::Result::Err(
|
||||
#api_crate::reqwest::ClientError::public(__status, __error),
|
||||
);
|
||||
@@ -2098,7 +2232,7 @@ fn axum_adapter_tokens(
|
||||
status,
|
||||
rejection.body_text(),
|
||||
);
|
||||
return #api_crate::axum::error_response(
|
||||
return #api_crate::axum::http_error_response(
|
||||
#api_crate::axum::status(status),
|
||||
error,
|
||||
);
|
||||
@@ -2132,7 +2266,7 @@ fn axum_adapter_tokens(
|
||||
status,
|
||||
rejection.body_text(),
|
||||
);
|
||||
return #api_crate::axum::error_response(
|
||||
return #api_crate::axum::http_error_response(
|
||||
#api_crate::axum::status(status),
|
||||
error,
|
||||
);
|
||||
@@ -2218,7 +2352,7 @@ fn axum_adapter_tokens(
|
||||
let error = match operation.error_status {
|
||||
Some(_) => quote!({
|
||||
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) }),
|
||||
};
|
||||
@@ -2315,110 +2449,115 @@ fn openapi_adapter_tokens(
|
||||
matches!(operation.transport, Transport::Http) && !operation.openapi_skip
|
||||
})
|
||||
.map(|operation| {
|
||||
let method = method_name(operation.method);
|
||||
let path = &operation.path;
|
||||
let operation_id = &operation.operation_id;
|
||||
let parameters = operation.parameters.iter().filter_map(|parameter| {
|
||||
if matches!(parameter.location, Location::Body | Location::Binary | Location::Extension) {
|
||||
return None;
|
||||
}
|
||||
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)?;
|
||||
})
|
||||
});
|
||||
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 method = method_name(operation.method);
|
||||
let path = &operation.path;
|
||||
let operation_id = &operation.operation_id;
|
||||
let parameters = operation.parameters.iter().filter_map(|parameter| {
|
||||
if matches!(
|
||||
parameter.location,
|
||||
Location::Body | Location::Binary | Location::Extension
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
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! {
|
||||
#body
|
||||
#(#headers)*
|
||||
}
|
||||
});
|
||||
let error = operation
|
||||
.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| {
|
||||
let request = operation
|
||||
.request_body
|
||||
.as_ref()
|
||||
.map(|body| match body.wire_kind {
|
||||
RequestWireKind::Json => {
|
||||
let ty = &body.ty;
|
||||
quote! {
|
||||
operation.response::<#ty>(#status, "application/json", "Error response")?;
|
||||
operation.request_body::<#ty>("application/json")?;
|
||||
}
|
||||
})
|
||||
.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(); }
|
||||
});
|
||||
}
|
||||
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)?;
|
||||
}
|
||||
});
|
||||
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! {
|
||||
{
|
||||
let mut operation = builder.operation(#method, #path, #operation_id)?;
|
||||
#(#parameters)*
|
||||
#bearer_auth
|
||||
#browser_auth
|
||||
#request
|
||||
#(#success_responses)*
|
||||
#error
|
||||
#(#additional_errors)*
|
||||
operation.finish()?;
|
||||
quote! {
|
||||
{
|
||||
let mut operation = builder.operation(#method, #path, #operation_id)?;
|
||||
#(#parameters)*
|
||||
#bearer_auth
|
||||
#browser_auth
|
||||
#request
|
||||
#(#success_responses)*
|
||||
#(#errors)*
|
||||
operation.finish()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
quote! {
|
||||
#[doc = "Build this trait's deterministic, deployment-independent OpenAPI 3.1 document."]
|
||||
@@ -2660,6 +2799,13 @@ fn metadata_tokens(
|
||||
} else {
|
||||
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 wire_name = &header.wire_name;
|
||||
let ty = &header.ty;
|
||||
@@ -2674,6 +2820,7 @@ fn metadata_tokens(
|
||||
#api_crate::ResponseMetadata {
|
||||
status: #status,
|
||||
body: #api_crate::BodyMetadata { wire_kind: #wire_kind },
|
||||
content_types: #content_types,
|
||||
headers: &[#(#headers),*],
|
||||
}
|
||||
}
|
||||
@@ -2683,10 +2830,12 @@ fn metadata_tokens(
|
||||
.into_iter()
|
||||
.chain(operation.additional_error_statuses.iter().copied());
|
||||
let error_responses = error_statuses.map(|status| {
|
||||
let additional_content_types = &operation.additional_error_content_types;
|
||||
quote! {
|
||||
#api_crate::ResponseMetadata {
|
||||
status: #status,
|
||||
body: #api_crate::BodyMetadata { wire_kind: #api_crate::WireKind::Json },
|
||||
content_types: &["application/json", #(#additional_content_types),*],
|
||||
headers: &[],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ publish = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
error-contract = ["dep:error-contract"]
|
||||
axum = ["dep:axum", "dep:serde"]
|
||||
openapi = ["dep:schemars", "dep:serde_json", "api-macros-impl/openapi"]
|
||||
reqwest = [
|
||||
@@ -20,6 +21,7 @@ reqwest = [
|
||||
|
||||
[dependencies]
|
||||
api-macros-impl = { path = "../api-macros-impl" }
|
||||
error-contract = { path = "../error-contract", optional = true }
|
||||
bytes.workspace = true
|
||||
axum = { 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
|
||||
- [`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
|
||||
- `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
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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.
|
||||
/// 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.
|
||||
///
|
||||
/// ```
|
||||
@@ -71,6 +81,8 @@ pub use api_macros_impl::api;
|
||||
|
||||
#[cfg(feature = "axum")]
|
||||
pub mod axum;
|
||||
#[cfg(feature = "error-contract")]
|
||||
pub mod http;
|
||||
#[cfg(feature = "openapi")]
|
||||
pub mod openapi;
|
||||
#[cfg(feature = "reqwest")]
|
||||
@@ -213,6 +225,8 @@ pub struct ResponseHeaderMetadata {
|
||||
pub struct ResponseMetadata {
|
||||
pub status: u16,
|
||||
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],
|
||||
}
|
||||
|
||||
@@ -272,6 +286,24 @@ pub trait WebSocketOperation {
|
||||
/// service's typed 401/403/404/409 responses without introducing duplicate route handlers.
|
||||
pub trait HttpError {
|
||||
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.
|
||||
|
||||
@@ -39,6 +39,19 @@ pub trait OpenApiSchema: JsonSchema {
|
||||
fn openapi_schema_name() -> Cow<'static, str> {
|
||||
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 {
|
||||
@@ -256,7 +269,8 @@ impl OpenApiBuilder {
|
||||
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();
|
||||
validate_component_name(&name)?;
|
||||
|
||||
@@ -411,13 +425,39 @@ impl OpenApiOperation<'_> {
|
||||
content_type: &'static str,
|
||||
description: &'static str,
|
||||
) -> 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(
|
||||
status,
|
||||
json!({
|
||||
"description": description,
|
||||
"content": { content_type: { "schema": schema } },
|
||||
}),
|
||||
json!({ "description": description, "content": content }),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -482,6 +482,17 @@ pub fn encode_binary(value: crate::BinaryBody) -> EncodedBody {
|
||||
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.
|
||||
pub struct ReceivedResponse {
|
||||
status: StatusCode,
|
||||
@@ -494,6 +505,48 @@ impl ReceivedResponse {
|
||||
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>
|
||||
where
|
||||
T: std::str::FromStr,
|
||||
@@ -555,13 +608,32 @@ pub enum ClientFailure {
|
||||
Connect,
|
||||
Transport,
|
||||
Timeout,
|
||||
ResponseTooLarge { limit: usize },
|
||||
UnexpectedStatus { expected: u16, actual: u16 },
|
||||
ErrorResponseDecode { status: u16 },
|
||||
Decode { kind: DecodeKind },
|
||||
MissingResponseHeader { name: &'static str },
|
||||
DuplicateResponseHeader { name: &'static str },
|
||||
InvalidResponseHeader { name: &'static str },
|
||||
ResponseTooLarge {
|
||||
limit: usize,
|
||||
},
|
||||
UnexpectedStatus {
|
||||
expected: u16,
|
||||
actual: u16,
|
||||
},
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -588,6 +660,12 @@ impl fmt::Display for ClientFailure {
|
||||
"expected HTTP status {expected}, received {actual}"
|
||||
)
|
||||
}
|
||||
Self::UnsupportedErrorResponse { status } => {
|
||||
write!(
|
||||
formatter,
|
||||
"HTTP {status} error response contract was unsupported"
|
||||
)
|
||||
}
|
||||
Self::ErrorResponseDecode { status } => {
|
||||
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::{
|
||||
BinaryBody, api,
|
||||
openapi::{OpenApiInfo, OpenApiSchema},
|
||||
openapi::{OpenApiBuilder, OpenApiInfo, OpenApiSchema},
|
||||
};
|
||||
use schemars::JsonSchema;
|
||||
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");
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1,13 +1,38 @@
|
||||
//! Compile the real public macro through a renamed dependency in an isolated consumer.
|
||||
//! Only Cargo artifacts are shared; Cargo checks the fresh consumer on every invocation.
|
||||
|
||||
use std::{fs, path::PathBuf, process::Command};
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
for attempt in 0..1000 {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"api-macros-renamed-dependency-{}-{attempt}",
|
||||
std::process::id()
|
||||
));
|
||||
match fs::create_dir(&path) {
|
||||
Ok(()) => return Self(path),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => panic!("create renamed dependency fixture: {error}"),
|
||||
}
|
||||
}
|
||||
panic!("could not allocate a renamed dependency fixture directory");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macro_expansion_resolves_a_renamed_support_dependency() {
|
||||
let fixture = std::env::temp_dir().join(format!(
|
||||
"api-macros-renamed-dependency-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&fixture);
|
||||
fs::create_dir_all(fixture.join("src")).expect("create fixture source directory");
|
||||
let fixture = Fixture::new();
|
||||
let fixture = &fixture.0;
|
||||
fs::create_dir(fixture.join("src")).expect("create fixture source directory");
|
||||
|
||||
let package_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let package_path = package_path
|
||||
@@ -49,13 +74,21 @@ pub fn operation_count() -> usize {
|
||||
)
|
||||
.expect("write fixture source");
|
||||
|
||||
// Keep nested Cargo checks off the outer build's target lock. Cargo owns cache
|
||||
// invalidation and locking between concurrent consumers, not a saved
|
||||
// test-success marker.
|
||||
let executable = std::env::current_exe().expect("current test executable");
|
||||
let target_dir = executable
|
||||
.parent()
|
||||
.and_then(|deps| deps.parent())
|
||||
.expect("test executable has a profile directory")
|
||||
.join("compile-tests/api-macros-renamed");
|
||||
let output = Command::new(env!("CARGO"))
|
||||
.args(["check", "--quiet", "--offline"])
|
||||
.current_dir(&fixture)
|
||||
.env("CARGO_TARGET_DIR", fixture.join("target"))
|
||||
.current_dir(fixture)
|
||||
.env("CARGO_TARGET_DIR", target_dir)
|
||||
.output()
|
||||
.expect("run cargo check for renamed dependency fixture");
|
||||
let _ = fs::remove_dir_all(&fixture);
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
|
||||
@@ -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)]
|
||||
| ^^^^^^^^^^^^^^^^^
|
||||
@@ -81,6 +81,16 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
|
||||
resolved_path: "profiles/job.dcdl",
|
||||
}],
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:subjektiv-journal-recording"),
|
||||
path: "profiles/subjektiv-journal-recording.dcdl",
|
||||
source: include_str!("../../../resources/profiles/subjektiv-journal-recording.dcdl"),
|
||||
description: "Journal recording policy with Host-bound sequential Job tools.",
|
||||
imports: &[BuiltinProfileImport {
|
||||
specifier: "./job.dcdl",
|
||||
resolved_path: "profiles/job.dcdl",
|
||||
}],
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:ticket-worker"),
|
||||
path: "profiles/ticket-worker.dcdl",
|
||||
@@ -98,6 +108,13 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
|
||||
description: "Code implementation with optional Git/MR review recipe; no universal Ticket conclusion gate.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:async-chat"),
|
||||
path: "profiles/async-chat.dcdl",
|
||||
source: include_str!("../../../resources/profiles/async-chat.dcdl"),
|
||||
description: "Explicitly connected Subject chat with bounded references and asynchronous Worker delegation.",
|
||||
imports: NO_IMPORTS,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:companion"),
|
||||
path: "profiles/companion.dcdl",
|
||||
@@ -123,7 +140,7 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
|
||||
selector: Some("builtin:orchestrator"),
|
||||
path: "profiles/orchestrator.dcdl",
|
||||
source: include_str!("../../../resources/profiles/orchestrator.dcdl"),
|
||||
description: "Intent-led Ticket orchestration with separate guarded MR integration authority.",
|
||||
description: "Outcome-led orchestration, selection of candidate changes, and risk-proportionate validation.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
|
||||
@@ -39,3 +39,22 @@ fn catalog_import_metadata_has_no_cycles() {
|
||||
visit(resource.path, &mut BTreeSet::new());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_chat_is_public_and_does_not_inherit_broader_profile_defaults() {
|
||||
let catalog = builtin_profile_catalog_snapshot();
|
||||
let path = &catalog.entrypoints["builtin:async-chat"];
|
||||
let resource = BUILTIN_PROFILE_RESOURCES
|
||||
.iter()
|
||||
.find(|resource| resource.path == path)
|
||||
.unwrap();
|
||||
assert_eq!(resource.selector, Some("builtin:async-chat"));
|
||||
assert!(!resource.description.is_empty());
|
||||
assert!(resource.imports.is_empty());
|
||||
assert!(
|
||||
!catalog
|
||||
.imports
|
||||
.keys()
|
||||
.any(|request| request.starts_with(&format!("{path}\0")))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -253,6 +253,7 @@ pub enum BackendRuntimeClientError {
|
||||
Api(BackendApiClientError),
|
||||
SessionApi(server_api::ServerApiClientError),
|
||||
ContractApi(server_api::client_support::ClientError<server_api::RepositoryApiError>),
|
||||
WorkerCreateApi(server_api::client_support::ClientError<server_api::WorkerCreateApiError>),
|
||||
Http(reqwest::Error),
|
||||
Protocol(String),
|
||||
}
|
||||
@@ -264,6 +265,7 @@ impl fmt::Display for BackendRuntimeClientError {
|
||||
Self::Api(error) => write!(f, "{error}"),
|
||||
Self::SessionApi(error) => write!(f, "{error}"),
|
||||
Self::ContractApi(error) => write!(f, "{error}"),
|
||||
Self::WorkerCreateApi(error) => write!(f, "{error}"),
|
||||
Self::Http(error) => write!(f, "{error}"),
|
||||
Self::Protocol(message) => f.write_str(message),
|
||||
}
|
||||
@@ -908,6 +910,7 @@ mod tests {
|
||||
workdir_attachments: Vec::new(),
|
||||
feature_connections: Default::default(),
|
||||
control_operation_id: None,
|
||||
execution_grant: None,
|
||||
};
|
||||
|
||||
let response = create_backend_worker_with_client(&target, &create, &api)
|
||||
@@ -1015,3 +1018,13 @@ mod tests {
|
||||
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),
|
||||
Http(reqwest::Error),
|
||||
ServerApi(server_api::client_support::ClientError<server_api::RepositoryApiError>),
|
||||
WorkerCreateApi(server_api::client_support::ClientError<server_api::WorkerCreateApiError>),
|
||||
RuntimeManagementApi(
|
||||
server_api::client_support::ClientError<server_api::RuntimeManagementApiError>,
|
||||
),
|
||||
@@ -51,6 +52,7 @@ impl fmt::Display for BackendWorkspaceClientError {
|
||||
Self::Api(error) => write!(f, "{error}"),
|
||||
Self::Http(error) => write!(f, "{error}"),
|
||||
Self::ServerApi(error) => write!(f, "{error}"),
|
||||
Self::WorkerCreateApi(error) => write!(f, "{error}"),
|
||||
Self::RuntimeManagementApi(error) => write!(f, "{error}"),
|
||||
}
|
||||
}
|
||||
@@ -454,3 +456,31 @@ mod tests {
|
||||
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>,
|
||||
>,
|
||||
>,
|
||||
{
|
||||
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 runtime = tokio::runtime::Builder::new_current_thread()
|
||||
@@ -89,7 +106,21 @@ impl BackendWorkspaceProductClient {
|
||||
.map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?;
|
||||
runtime
|
||||
.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(
|
||||
&self,
|
||||
reference: &str,
|
||||
) -> Result<ticket::TicketLinkSummary, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let reference = reference.to_owned();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_link_summary_get(workspace_id, reference)
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_workspace_tags(
|
||||
@@ -567,11 +598,12 @@ impl BackendWorkspaceProductClient {
|
||||
workdir_attachments: Vec::new(),
|
||||
feature_connections: Default::default(),
|
||||
control_operation_id: None,
|
||||
execution_grant: None,
|
||||
};
|
||||
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
|
||||
})?;
|
||||
}, crate::backend_workspace::worker_create_client_error)?;
|
||||
Ok(format!(
|
||||
"Started Intake Worker {}/{} for Ticket {ticket_id}",
|
||||
response.runtime_id, response.worker_id
|
||||
@@ -601,6 +633,47 @@ impl BackendWorkspaceProductClient {
|
||||
}
|
||||
|
||||
impl TicketBackend for BackendWorkspaceProductClient {
|
||||
fn get_ticket_parent(&self, id: TicketIdOrSlug) -> ticket::Result<ticket::TicketParentLink> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(&id);
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_parent_get(workspace_id, id)
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
fn set_ticket_parent(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
request: ticket::TicketParentSetRequest,
|
||||
) -> ticket::Result<ticket::TicketParentLink> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(&id);
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_parent_set(workspace_id, id, server_api::TicketParentRequest(request))
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
fn query_ticket_children(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
request: ticket::TicketChildrenRequest,
|
||||
) -> ticket::Result<ticket::TicketChildrenPage> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(&id);
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_children_query(workspace_id, id, server_api::TicketChildrenQuery(request))
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
fn edit_post_with_snapshots(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
|
||||
@@ -4093,3 +4093,52 @@ mod tests {
|
||||
assert_eq!(diagnostics[0].kind, "cycle");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod plugin_preflight_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plugin_closed_schema_matches_shared_native_wasm_projection_and_diagnostic_fixture() {
|
||||
let fixture: serde_json::Value = serde_json::from_str(include_str!(
|
||||
"../../../resources/config-schema/fixtures/plugin-preflight.json"
|
||||
))
|
||||
.unwrap();
|
||||
let bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||
"plugin-preflight",
|
||||
"plugins",
|
||||
"1",
|
||||
fixture["schema"].as_str().unwrap(),
|
||||
)
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let path = VirtualPath::parse("main.dcdl").unwrap();
|
||||
let contract = ToolchainContract::with_schema_bundle(1, vec![path.clone()], 1, bundle);
|
||||
for case in fixture["cases"].as_array().unwrap() {
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
|
||||
path.clone(),
|
||||
ConfigContentType::Decodal,
|
||||
case["source"].as_str().unwrap(),
|
||||
)
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let result = SnapshotEnvironment::new(snapshot).evaluate_contract(&contract);
|
||||
match case.get("diagnostic") {
|
||||
Some(kind) => {
|
||||
let errors = result.unwrap_err();
|
||||
assert_eq!(errors[0].kind, kind.as_str().unwrap());
|
||||
assert_eq!(errors[0].path.as_str(), "main.dcdl");
|
||||
}
|
||||
None => {
|
||||
let evaluated = result.unwrap();
|
||||
assert_eq!(
|
||||
evaluated.projections[0]
|
||||
.data_json
|
||||
.pointer("/plugins/enabled/echo/config/mode"),
|
||||
Some(&case["mode"])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "error-contract"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
json-schema = ["dep:schemars"]
|
||||
typescript = ["dep:ts-rs", "json-schema"]
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
iri-string = { version = "0.7.12", default-features = false }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
schemars = { workspace = true, optional = true }
|
||||
ts-rs = { version = "12.0.1", optional = true }
|
||||
|
||||
[[example]]
|
||||
name = "two_domains"
|
||||
required-features = ["test-support"]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Error contract
|
||||
|
||||
This crate owns typed cause/context preservation, current-context disclosure interfaces,
|
||||
bounded codecs and RFC 9457 primitives. It does not own operation outcomes, authorization,
|
||||
retry, cleanup, persistence, HTTP servers, logging sinks or production domain mappings.
|
||||
|
||||
[FailureFacts](crate::FailureFacts) retains the concrete domain cause. A domain derives
|
||||
identity from its variant and implements [ProjectFor](crate::ProjectFor) per audience,
|
||||
selecting typed wire fields and common metadata using the boundary's current authenticated
|
||||
context and resource visibility. Only [project_failure](crate::project_failure) produces an
|
||||
encodable audience marker. Sources and Display are never automatically serialized.
|
||||
|
||||
[codec] distinguishes validated, unsupported and corrupt input. A received value must be
|
||||
reprojected; opaque unsupported payload cannot be blindly forwarded to another audience.
|
||||
[problem] uses API-boundary classification, not inner status forwarding. The `schema` module
|
||||
(with `json-schema`) exposes the same DTOs to existing schema generators. The `test_support`
|
||||
module (with `test-support`) provides two independent sample domains and shared wire fixtures for
|
||||
consumer contract tests, not production authorization adapters.
|
||||
@@ -0,0 +1,49 @@
|
||||
use error_contract::{compatibility::*, problem::*, test_support::*, *};
|
||||
|
||||
fn main() -> Result<(), ContractError> {
|
||||
let access = CurrentAccess {
|
||||
can_view_resource: true,
|
||||
language: Language::English,
|
||||
};
|
||||
let budget = Budget::new(1024 * 1024, 16 * 1024).with_depth(64, 15);
|
||||
let subject = subject_facts();
|
||||
let browser = project_failure::<Browser, _>(&subject, &access)?;
|
||||
let response =
|
||||
to_problem_details(&browser, &WorkerApi, "urn:yoi:occurrence:example-1", budget)?;
|
||||
assert_eq!(response.status, 409); // The inner Subject API would classify this as 403.
|
||||
assert_no_fixture_secrets(response.body());
|
||||
|
||||
let workdir = workdir_facts();
|
||||
let tool = project_failure::<Tool, _>(&workdir, &access)?;
|
||||
let encoded = encode_projected_failure(&tool, budget)?;
|
||||
assert_semantic_roundtrip(encoded.as_bytes(), tool.details(), budget);
|
||||
|
||||
// Ship this reader before switching any existing result writer. Old diagnostic prose is
|
||||
// deliberately not an input to classification. The real domain result remains separate.
|
||||
assert!(matches!(
|
||||
read_failure_attachment::<SubjectDenied>(None, budget),
|
||||
StoredFailureRead::LegacyWithoutFacts
|
||||
));
|
||||
let storage = project_failure::<Storage, _>(&subject, &access)?;
|
||||
let stored = encode_projected_failure(&storage, budget)?;
|
||||
let received = match read_failure_attachment::<SubjectDenied>(Some(stored.as_bytes()), budget) {
|
||||
StoredFailureRead::Structured(DecodeFailure::Known(value)) => value,
|
||||
_ => return Err(ContractError::Invalid),
|
||||
};
|
||||
let current_access = CurrentAccess {
|
||||
can_view_resource: false,
|
||||
language: Language::Japanese,
|
||||
};
|
||||
let reread = project_failure::<Browser, _>(received.facts(), ¤t_access)?;
|
||||
assert_eq!(
|
||||
reread.details().required_operation,
|
||||
RequiredOperation::SessionRecord
|
||||
);
|
||||
assert!(reread.details().subject_ref.is_none());
|
||||
|
||||
let telemetry = project_failure::<Telemetry, _>(&subject, &access)?;
|
||||
let fields = observation_fields(&telemetry);
|
||||
assert_eq!(fields.stage, Some("register"));
|
||||
// Feed fields to the existing boundary's tracing sink; never log the source chain here.
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"version":1,"required_extensions":[],"domain":"subject","reason":"operation_denied","category":"authorization","details_version":1,"details":{"required_operation":"session.record","subject_ref":"subject-1"},"explanation":"Worker creation cannot register the session: session.record is denied.","context":{"operations":[{"operation":"worker.create","stage":"register","operation_ref":null}],"causes":[],"diagnostics":[],"correlation":"correlation-1","supplementary_omitted":false}}
|
||||
@@ -0,0 +1 @@
|
||||
{"version":1,"required_extensions":[],"domain":"workdir","reason":"in_use","category":"conflict","details_version":1,"details":{"operation":"open","workdir_ref":"workdir-1"},"explanation":"Workdir open cannot proceed because the workdir is in use.","context":{"operations":[{"operation":"workdir.open","stage":"prepare","operation_ref":null}],"causes":[],"diagnostics":[],"correlation":"correlation-2","supplementary_omitted":false}}
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Bounded wire handling. A validated receiver value is still not a disclosure capability.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{io::Write, marker::PhantomData};
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// Both the transport limit and the remaining enclosing-envelope budget are mandatory.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Budget {
|
||||
bytes: usize,
|
||||
depth: usize,
|
||||
}
|
||||
impl Budget {
|
||||
pub fn new(transport_limit: usize, remaining_envelope_bytes: usize) -> Self {
|
||||
Self {
|
||||
bytes: transport_limit
|
||||
.min(remaining_envelope_bytes)
|
||||
.min(MAX_FAILURE_BYTES),
|
||||
depth: MAX_JSON_DEPTH,
|
||||
}
|
||||
}
|
||||
/// Use the smaller transport depth and the remaining depth after enclosing wrappers.
|
||||
/// A JSON-string embedding must additionally budget its escaped bytes at that boundary.
|
||||
pub fn with_depth(mut self, transport_depth: usize, remaining_envelope_depth: usize) -> Self {
|
||||
self.depth = self
|
||||
.depth
|
||||
.min(transport_depth)
|
||||
.min(remaining_envelope_depth);
|
||||
self
|
||||
}
|
||||
pub fn bytes(self) -> usize {
|
||||
self.bytes
|
||||
}
|
||||
pub fn depth(self) -> usize {
|
||||
self.depth
|
||||
}
|
||||
}
|
||||
|
||||
/// DTO/schema source only. Deserialization does not produce a ProjectedFailure.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct FailureEnvelope<D> {
|
||||
#[cfg_attr(feature = "json-schema", schemars(range(min = 1)))]
|
||||
pub version: u16,
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(max = 8)))]
|
||||
pub required_extensions: Vec<String>,
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
|
||||
pub domain: String,
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
|
||||
pub reason: String,
|
||||
pub category: Category,
|
||||
#[cfg_attr(feature = "json-schema", schemars(range(min = 1)))]
|
||||
pub details_version: u16,
|
||||
pub details: D,
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 2048)))]
|
||||
pub explanation: String,
|
||||
pub context: FailureContext,
|
||||
}
|
||||
|
||||
// A tolerant reader header keeps future category spellings opaque. Public schema/TS authority
|
||||
// stays on FailureEnvelope; only supported identities are deserialized into its typed shape.
|
||||
#[derive(Deserialize)]
|
||||
struct ReaderEnvelope {
|
||||
version: u16,
|
||||
required_extensions: Vec<String>,
|
||||
domain: String,
|
||||
reason: String,
|
||||
category: String,
|
||||
details_version: u16,
|
||||
#[serde(rename = "details")]
|
||||
_details: serde_json::Value,
|
||||
explanation: String,
|
||||
context: FailureContext,
|
||||
}
|
||||
|
||||
pub struct ReceivedFailure<D> {
|
||||
facts: FailureFacts<D>,
|
||||
}
|
||||
impl<D: FailureCause> ReceivedFailure<D> {
|
||||
/// Reprojection with current authorization is required before any encoder can use this.
|
||||
pub fn facts(&self) -> &FailureFacts<D> {
|
||||
&self.facts
|
||||
}
|
||||
}
|
||||
|
||||
/// Unknown bytes are retained opaquely, not serializable or available for blind forwarding.
|
||||
/// Readers may use safe local stage/correlation separately; no reason inference is provided.
|
||||
pub struct UnsupportedFailure {
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
impl UnsupportedFailure {
|
||||
pub fn retained_bytes(&self) -> usize {
|
||||
self.payload.len()
|
||||
}
|
||||
}
|
||||
pub enum DecodeFailure<D> {
|
||||
Known(ReceivedFailure<D>),
|
||||
Unsupported(UnsupportedFailure),
|
||||
Corrupt(ContractError),
|
||||
}
|
||||
|
||||
pub fn decode_failure<D: WireDetails>(input: &[u8], budget: Budget) -> DecodeFailure<D> {
|
||||
match decode_checked(input, budget) {
|
||||
Ok(value) => value,
|
||||
Err(error) => DecodeFailure::Corrupt(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode_checked<D: WireDetails>(
|
||||
input: &[u8],
|
||||
budget: Budget,
|
||||
) -> Result<DecodeFailure<D>, ContractError> {
|
||||
check_json_bounds(input, budget)?;
|
||||
let raw: ReaderEnvelope = serde_json::from_slice(input).map_err(|_| ContractError::Invalid)?;
|
||||
validate_envelope_common(&raw)?;
|
||||
if raw.version != SCHEMA_VERSION
|
||||
|| !raw.required_extensions.is_empty()
|
||||
|| !D::supports(&raw.domain, &raw.reason, raw.details_version)
|
||||
{
|
||||
return Ok(DecodeFailure::Unsupported(UnsupportedFailure {
|
||||
payload: input.to_vec(),
|
||||
}));
|
||||
}
|
||||
// Deserialize from the original bytes again, not a Value: typed duplicate fields must fail.
|
||||
let typed: FailureEnvelope<D> =
|
||||
serde_json::from_slice(input).map_err(|_| ContractError::InvalidDetails)?;
|
||||
accept_typed_envelope(typed).map(DecodeFailure::Known)
|
||||
}
|
||||
|
||||
pub(crate) fn accept_typed_envelope<D: WireDetails>(
|
||||
typed: FailureEnvelope<D>,
|
||||
) -> Result<ReceivedFailure<D>, ContractError> {
|
||||
let identity = typed.details.identity();
|
||||
if identity.domain != typed.domain
|
||||
|| identity.reason != typed.reason
|
||||
|| identity.category != typed.category
|
||||
|| identity.details_version != typed.details_version
|
||||
{
|
||||
return Err(ContractError::InvalidDetails);
|
||||
}
|
||||
typed.details.validate()?;
|
||||
let facts = FailureFacts::from_received(typed.details, typed.context);
|
||||
validate_failure(&facts)?;
|
||||
Ok(ReceivedFailure { facts })
|
||||
}
|
||||
|
||||
fn validate_envelope_common(raw: &ReaderEnvelope) -> Result<(), ContractError> {
|
||||
if raw.version == 0 || raw.details_version == 0 {
|
||||
return Err(ContractError::Invalid);
|
||||
}
|
||||
validate_identifier(&raw.domain)?;
|
||||
if raw.required_extensions.len() > MAX_DIAGNOSTICS {
|
||||
return Err(ContractError::Invalid);
|
||||
}
|
||||
for extension in &raw.required_extensions {
|
||||
validate_identifier(extension)?;
|
||||
}
|
||||
validate_identifier(&raw.reason)?;
|
||||
validate_identifier(&raw.category)?;
|
||||
validate_explanation(&raw.explanation)?;
|
||||
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.
|
||||
pub(crate) fn check_json_bounds(input: &[u8], budget: Budget) -> Result<(), ContractError> {
|
||||
if input.len() > budget.bytes() {
|
||||
return Err(ContractError::TooLarge);
|
||||
}
|
||||
let (mut depth, mut in_string, mut escaped) = (0usize, false, false);
|
||||
for &byte in input {
|
||||
if in_string {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if byte == b'\\' {
|
||||
escaped = true;
|
||||
} else if byte == b'"' {
|
||||
in_string = false;
|
||||
}
|
||||
} else {
|
||||
match byte {
|
||||
b'"' => in_string = true,
|
||||
b'{' | b'[' => {
|
||||
depth += 1;
|
||||
if depth > budget.depth() {
|
||||
return Err(ContractError::TooDeep);
|
||||
}
|
||||
}
|
||||
b'}' | b']' => {
|
||||
depth = depth.checked_sub(1).ok_or(ContractError::Invalid)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct BoundedWriter {
|
||||
bytes: Vec<u8>,
|
||||
budget: usize,
|
||||
exceeded: bool,
|
||||
}
|
||||
impl Write for BoundedWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
if bytes.len() > self.budget.saturating_sub(self.bytes.len()) {
|
||||
self.exceeded = true;
|
||||
return Err(std::io::Error::other("failure byte budget"));
|
||||
}
|
||||
self.bytes.extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub(crate) fn encode_bounded<T: Serialize>(
|
||||
value: &T,
|
||||
budget: Budget,
|
||||
) -> Result<Vec<u8>, ContractError> {
|
||||
let mut writer = BoundedWriter {
|
||||
bytes: Vec::new(),
|
||||
budget: budget.bytes(),
|
||||
exceeded: false,
|
||||
};
|
||||
if serde_json::to_writer(&mut writer, value).is_err() {
|
||||
return Err(if writer.exceeded {
|
||||
ContractError::TooLarge
|
||||
} else {
|
||||
ContractError::Encoding
|
||||
});
|
||||
}
|
||||
check_json_bounds(&writer.bytes, budget)?;
|
||||
Ok(writer.bytes)
|
||||
}
|
||||
|
||||
/// The audience remains present until the transport deliberately takes the bytes.
|
||||
pub struct EncodedFailure<A> {
|
||||
bytes: Vec<u8>,
|
||||
audience: PhantomData<A>,
|
||||
}
|
||||
impl<A: Audience> EncodedFailure<A> {
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
&self.bytes
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_projected_failure<A: Audience, D: WireDetails>(
|
||||
failure: &ProjectedFailure<A, D>,
|
||||
budget: Budget,
|
||||
) -> Result<EncodedFailure<A>, ContractError> {
|
||||
let bytes = encode_bounded(&failure.envelope, budget)?;
|
||||
Ok(EncodedFailure {
|
||||
bytes,
|
||||
audience: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
/// Deliberately not a FailureEnvelope: it reports inability to disclose, not a new domain cause.
|
||||
pub const STATIC_FALLBACK: &[u8] =
|
||||
br#"{"failure_unavailable":true,"detail":"Failure details unavailable."}"#;
|
||||
|
||||
pub struct BoundaryEncoding<A> {
|
||||
pub encoded: EncodedFailure<A>,
|
||||
pub conversion_error: Option<ContractError>,
|
||||
/// Kept separate from static payload; the boundary must allowlist it for disclosure.
|
||||
pub correlation: Option<CorrelationRef>,
|
||||
}
|
||||
|
||||
/// No formatting of errors, recursive projection or operation invocation occurs on this path.
|
||||
/// If even the static payload cannot fit, return a boundary error; never truncate required facts.
|
||||
pub fn encode_or_fallback<A: Audience, D: WireDetails>(
|
||||
projection: Result<&ProjectedFailure<A, D>, ContractError>,
|
||||
budget: Budget,
|
||||
correlation: Option<CorrelationRef>,
|
||||
) -> Result<BoundaryEncoding<A>, ContractError> {
|
||||
let result = projection.and_then(|failure| encode_projected_failure(failure, budget));
|
||||
let (encoded, conversion_error) = match result {
|
||||
Ok(encoded) => (encoded, None),
|
||||
Err(error) => {
|
||||
check_json_bounds(STATIC_FALLBACK, budget)?;
|
||||
(
|
||||
EncodedFailure {
|
||||
bytes: STATIC_FALLBACK.to_vec(),
|
||||
audience: PhantomData,
|
||||
},
|
||||
Some(error),
|
||||
)
|
||||
}
|
||||
};
|
||||
Ok(BoundaryEncoding {
|
||||
encoded,
|
||||
conversion_error,
|
||||
correlation,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Reader-first compatibility for optional structured failure attachments.
|
||||
//!
|
||||
//! Existing domain envelopes retain their operation outcome and legacy diagnostics. Ship a
|
||||
//! reader accepting an absent attachment before enabling a writer of a versioned failure.
|
||||
//! Absence is historical text without typed facts, not a fabricated `unknown` domain cause.
|
||||
//! Present-but-corrupt and present-but-unsupported are distinct and must not fall back to
|
||||
//! parsing legacy prose. Readers reproject validated attachments using current visibility.
|
||||
//!
|
||||
//! This module does not migrate DBs or rewrite old archives. The owning store must choose
|
||||
//! its additive field/migration and rollout unit with its consumer. A rollback disables new
|
||||
//! writes first and retains the reader while any new-format results remain. Unknown payload
|
||||
//! retention is local to the original reader; it is never permission to forward to another
|
||||
//! audience. No production writer is enabled merely by depending on this crate.
|
||||
use crate::*;
|
||||
|
||||
pub enum StoredFailureRead<D> {
|
||||
LegacyWithoutFacts,
|
||||
Structured(DecodeFailure<D>),
|
||||
}
|
||||
|
||||
pub fn read_failure_attachment<D: WireDetails>(
|
||||
attachment: Option<&[u8]>,
|
||||
budget: Budget,
|
||||
) -> StoredFailureRead<D> {
|
||||
match attachment {
|
||||
None => StoredFailureRead::LegacyWithoutFacts,
|
||||
Some(bytes) => StoredFailureRead::Structured(decode_failure(bytes, budget)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use crate::*;
|
||||
|
||||
#[derive(Debug, Clone, Copy, 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 Category {
|
||||
Authorization,
|
||||
InvalidInput,
|
||||
Conflict,
|
||||
Dependency,
|
||||
Internal,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Identity is derived from a domain variant, never supplied independently by a caller.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct FailureIdentity {
|
||||
pub domain: &'static str,
|
||||
pub reason: &'static str,
|
||||
pub category: Category,
|
||||
pub details_version: u16,
|
||||
}
|
||||
|
||||
pub trait FailureCause {
|
||||
fn identity(&self) -> FailureIdentity;
|
||||
/// Domain-owned required fields, length and collection bounds.
|
||||
fn validate(&self) -> Result<(), ContractError>;
|
||||
}
|
||||
|
||||
/// Implement on a typed wire allowlist, not on arbitrary error strings or maps.
|
||||
pub trait WireDetails: FailureCause + Serialize + serde::de::DeserializeOwned {
|
||||
fn supports(domain: &str, reason: &str, details_version: u16) -> bool;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct OperationContext {
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
|
||||
pub operation: String,
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
|
||||
pub stage: String,
|
||||
/// A reference, not an idempotency/retry grant. Disclosure remains domain-owned.
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
|
||||
pub operation_ref: Option<String>,
|
||||
}
|
||||
|
||||
impl OperationContext {
|
||||
pub fn new(
|
||||
operation: impl Into<String>,
|
||||
stage: impl Into<String>,
|
||||
) -> Result<Self, ContractError> {
|
||||
let context = Self {
|
||||
operation: operation.into(),
|
||||
stage: stage.into(),
|
||||
operation_ref: None,
|
||||
};
|
||||
context.validate()?;
|
||||
Ok(context)
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), ContractError> {
|
||||
validate_identifier(&self.operation)?;
|
||||
validate_identifier(&self.stage)?;
|
||||
if let Some(reference) = &self.operation_ref {
|
||||
validate_identifier(reference)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Correlation is deliberately a distinct field/type from operation identity.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct CorrelationRef(
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))] String,
|
||||
);
|
||||
|
||||
impl CorrelationRef {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, ContractError> {
|
||||
let value = value.into();
|
||||
validate_identifier(&value)?;
|
||||
Ok(Self(value))
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Only explicit causal references belong here. Cleanup/audit failures stay in domain results.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct CausalLink {
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
|
||||
pub domain: String,
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
|
||||
pub reason: String,
|
||||
pub correlation: CorrelationRef,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", schemars(rename = "FailureDiagnostic"))]
|
||||
pub struct Diagnostic {
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
|
||||
pub code: String,
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 2048)))]
|
||||
pub explanation: String,
|
||||
}
|
||||
|
||||
/// Common metadata is untrusted after deserialization. No audience label confers trust.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct FailureContext {
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(max = 8)))]
|
||||
pub operations: Vec<OperationContext>,
|
||||
/// Ordered immediate-to-root chain, at most four references; never recursive payloads.
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(max = 4)))]
|
||||
pub causes: Vec<CausalLink>,
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(max = 8)))]
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
pub correlation: Option<CorrelationRef>,
|
||||
pub supplementary_omitted: bool,
|
||||
}
|
||||
|
||||
impl FailureContext {
|
||||
pub fn validate(&self) -> Result<(), ContractError> {
|
||||
if self.operations.len() > MAX_CONTEXTS
|
||||
|| self.causes.len() > MAX_CAUSES
|
||||
|| self.diagnostics.len() > MAX_DIAGNOSTICS
|
||||
{
|
||||
return Err(ContractError::Invalid);
|
||||
}
|
||||
for op in &self.operations {
|
||||
op.validate()?;
|
||||
}
|
||||
for cause in &self.causes {
|
||||
validate_identifier(&cause.domain)?;
|
||||
validate_identifier(&cause.reason)?;
|
||||
validate_identifier(cause.correlation.as_str())?;
|
||||
}
|
||||
for diagnostic in &self.diagnostics {
|
||||
validate_identifier(&diagnostic.code)?;
|
||||
validate_explanation(&diagnostic.explanation)?;
|
||||
}
|
||||
if let Some(correlation) = &self.correlation {
|
||||
validate_identifier(correlation.as_str())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Not serializable: the domain retains its concrete error and source chain.
|
||||
#[derive(Debug)]
|
||||
pub struct FailureFacts<D> {
|
||||
cause: D,
|
||||
context: FailureContext,
|
||||
}
|
||||
|
||||
impl<D: FailureCause> FailureFacts<D> {
|
||||
pub fn new(cause: D) -> Result<Self, ContractError> {
|
||||
let facts = Self {
|
||||
cause,
|
||||
context: FailureContext::default(),
|
||||
};
|
||||
validate_failure(&facts)?;
|
||||
Ok(facts)
|
||||
}
|
||||
pub fn cause(&self) -> &D {
|
||||
&self.cause
|
||||
}
|
||||
pub fn context(&self) -> &FailureContext {
|
||||
&self.context
|
||||
}
|
||||
/// A failed append leaves the original facts intact.
|
||||
pub fn with_context(&mut self, context: OperationContext) -> Result<(), ContractError> {
|
||||
context.validate()?;
|
||||
if self.context.operations.len() == MAX_CONTEXTS {
|
||||
return Err(ContractError::Invalid);
|
||||
}
|
||||
self.context.operations.push(context);
|
||||
Ok(())
|
||||
}
|
||||
pub fn set_correlation(&mut self, correlation: CorrelationRef) -> Result<(), ContractError> {
|
||||
validate_identifier(correlation.as_str())?;
|
||||
self.context.correlation = Some(correlation);
|
||||
Ok(())
|
||||
}
|
||||
pub fn with_causal_link(&mut self, cause: CausalLink) -> Result<(), ContractError> {
|
||||
validate_identifier(&cause.domain)?;
|
||||
validate_identifier(&cause.reason)?;
|
||||
validate_identifier(cause.correlation.as_str())?;
|
||||
if self.context.causes.len() == MAX_CAUSES {
|
||||
return Err(ContractError::Invalid);
|
||||
}
|
||||
self.context.causes.push(cause);
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn from_received(cause: D, context: FailureContext) -> Self {
|
||||
Self { cause, context }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_failure<D: FailureCause>(facts: &FailureFacts<D>) -> Result<(), ContractError> {
|
||||
let identity = facts.cause.identity();
|
||||
validate_identifier(identity.domain)?;
|
||||
validate_identifier(identity.reason)?;
|
||||
if identity.details_version == 0 {
|
||||
return Err(ContractError::InvalidDetails);
|
||||
}
|
||||
facts.cause.validate()?;
|
||||
facts.context.validate()
|
||||
}
|
||||
|
||||
impl<D: FailureCause> fmt::Display for FailureFacts<D> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let identity = self.cause.identity();
|
||||
write!(f, "{}.{}", identity.domain, identity.reason)
|
||||
}
|
||||
}
|
||||
impl<D: FailureCause + Error + 'static> Error for FailureFacts<D> {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
Some(&self.cause)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
pub mod codec;
|
||||
pub mod compatibility;
|
||||
pub mod facts;
|
||||
pub mod problem;
|
||||
pub mod projection;
|
||||
#[cfg(feature = "json-schema")]
|
||||
pub mod schema;
|
||||
#[cfg(feature = "test-support")]
|
||||
pub mod test_support;
|
||||
|
||||
pub use codec::{
|
||||
Budget, DecodeFailure, ReceivedFailure, decode_failure, encode_projected_failure,
|
||||
validate_json_input,
|
||||
};
|
||||
pub use facts::*;
|
||||
pub use projection::*;
|
||||
|
||||
pub const SCHEMA_VERSION: u16 = 1;
|
||||
pub const MAX_FAILURE_BYTES: usize = 32 * 1024;
|
||||
pub const MAX_JSON_DEPTH: usize = 16;
|
||||
pub const MAX_CONTEXTS: usize = 8;
|
||||
pub const MAX_CAUSES: usize = 4;
|
||||
pub const MAX_DIAGNOSTICS: usize = 8;
|
||||
pub const MAX_EXPLANATION_BYTES: usize = 2 * 1024;
|
||||
pub const MAX_IDENTIFIER_BYTES: usize = 128;
|
||||
|
||||
/// Conversion errors describe only the boundary, never the original operation's outcome.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum ContractError {
|
||||
#[error("failure exceeds the available byte budget")]
|
||||
TooLarge,
|
||||
#[error("failure exceeds the JSON depth limit")]
|
||||
TooDeep,
|
||||
#[error("invalid failure structure")]
|
||||
Invalid,
|
||||
#[error("invalid typed failure details")]
|
||||
InvalidDetails,
|
||||
#[error("failure projection unavailable")]
|
||||
ProjectionUnavailable,
|
||||
#[error("failure serialization unavailable")]
|
||||
Encoding,
|
||||
}
|
||||
|
||||
pub fn validate_identifier(value: &str) -> Result<(), ContractError> {
|
||||
if value.is_empty() || value.len() > MAX_IDENTIFIER_BYTES || value.chars().any(char::is_control)
|
||||
{
|
||||
return Err(ContractError::Invalid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_explanation(value: &str) -> Result<(), ContractError> {
|
||||
if value.is_empty() || value.len() > MAX_EXPLANATION_BYTES || value.contains('\0') {
|
||||
return Err(ContractError::Invalid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! RFC 9457 primitives, without an HTTP server/client dependency or inner-status forwarding.
|
||||
use crate::{
|
||||
codec::{self, FailureEnvelope},
|
||||
*,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const PROBLEM_CONTENT_TYPE: &str = "application/problem+json";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HttpFailureStatus {
|
||||
BadRequest,
|
||||
Unauthorized,
|
||||
Forbidden,
|
||||
NotFound,
|
||||
Conflict,
|
||||
Unprocessable,
|
||||
TooManyRequests,
|
||||
Internal,
|
||||
BadGateway,
|
||||
Unavailable,
|
||||
GatewayTimeout,
|
||||
}
|
||||
impl HttpFailureStatus {
|
||||
pub fn code(self) -> u16 {
|
||||
match self {
|
||||
Self::BadRequest => 400,
|
||||
Self::Unauthorized => 401,
|
||||
Self::Forbidden => 403,
|
||||
Self::NotFound => 404,
|
||||
Self::Conflict => 409,
|
||||
Self::Unprocessable => 422,
|
||||
Self::TooManyRequests => 429,
|
||||
Self::Internal => 500,
|
||||
Self::BadGateway => 502,
|
||||
Self::Unavailable => 503,
|
||||
Self::GatewayTimeout => 504,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The API boundary, not Category or an inner HTTP response, chooses this mapping.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ProblemClass {
|
||||
pub status: HttpFailureStatus,
|
||||
pub type_uri: &'static str,
|
||||
pub title: &'static str,
|
||||
}
|
||||
impl ProblemClass {
|
||||
pub fn authorization_denied() -> Self {
|
||||
Self {
|
||||
status: HttpFailureStatus::Forbidden,
|
||||
type_uri: "urn:yoi:problem:authorization-denied",
|
||||
title: "Authorization denied",
|
||||
}
|
||||
}
|
||||
pub fn invalid_input() -> Self {
|
||||
Self {
|
||||
status: HttpFailureStatus::BadRequest,
|
||||
type_uri: "urn:yoi:problem:invalid-input",
|
||||
title: "Invalid input",
|
||||
}
|
||||
}
|
||||
pub fn conflict() -> Self {
|
||||
Self {
|
||||
status: HttpFailureStatus::Conflict,
|
||||
type_uri: "urn:yoi:problem:conflict",
|
||||
title: "Operation conflict",
|
||||
}
|
||||
}
|
||||
pub fn dependency_failure() -> Self {
|
||||
Self {
|
||||
status: HttpFailureStatus::BadGateway,
|
||||
type_uri: "urn:yoi:problem:dependency",
|
||||
title: "Dependency failure",
|
||||
}
|
||||
}
|
||||
}
|
||||
pub trait ApiBoundary<D> {
|
||||
fn classify(&self, details: &D) -> ProblemClass;
|
||||
}
|
||||
|
||||
/// Untrusted wire DTO. Unknown RFC extensions are ignored; yoi_failure is mandatory here.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct ProblemDetails<D> {
|
||||
#[serde(rename = "type")]
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(max = 2048)))]
|
||||
pub type_uri: String,
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 2048)))]
|
||||
pub title: String,
|
||||
#[cfg_attr(feature = "json-schema", schemars(range(min = 400, max = 599)))]
|
||||
pub status: u16,
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 2048)))]
|
||||
pub detail: String,
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(max = 2048)))]
|
||||
pub instance: String,
|
||||
pub yoi_failure: FailureEnvelope<D>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ReaderProblem {
|
||||
#[serde(rename = "type")]
|
||||
type_uri: String,
|
||||
title: String,
|
||||
status: u16,
|
||||
detail: String,
|
||||
instance: String,
|
||||
yoi_failure: serde_json::Value,
|
||||
}
|
||||
|
||||
pub struct ProblemResponse {
|
||||
pub status: u16,
|
||||
pub content_type: &'static str,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
impl ProblemResponse {
|
||||
pub fn body(&self) -> &[u8] {
|
||||
&self.body
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_uri(value: &str) -> Result<(), ContractError> {
|
||||
if value.len() > MAX_EXPLANATION_BYTES {
|
||||
return Err(ContractError::Invalid);
|
||||
}
|
||||
// Reuse the RFC 3986 validator already in the transport dependency graph, without its
|
||||
// optional features. Unlike WHATWG URL parsing, borrowed UriReferenceStr validation
|
||||
// neither resolves a base URI nor normalizes malformed input into different text.
|
||||
// Empty references, relative paths, queries and fragments are valid URI-references.
|
||||
iri_string::types::UriReferenceStr::new(value)
|
||||
.map(|_| ())
|
||||
.map_err(|_| ContractError::Invalid)
|
||||
}
|
||||
|
||||
pub fn to_problem_details<D: WireDetails>(
|
||||
projected: &ProjectedFailure<Browser, D>,
|
||||
api_boundary: &impl ApiBoundary<D>,
|
||||
instance: &str,
|
||||
budget: Budget,
|
||||
) -> Result<ProblemResponse, ContractError> {
|
||||
let class = api_boundary.classify(projected.details());
|
||||
validate_uri(class.type_uri)?;
|
||||
validate_uri(instance)?;
|
||||
validate_explanation(class.title)?;
|
||||
// Serialize by reference; no new projection or conversion from arbitrary Display exists.
|
||||
let problem = ProblemDetails {
|
||||
type_uri: class.type_uri.into(),
|
||||
title: class.title.into(),
|
||||
status: class.status.code(),
|
||||
detail: projected.explanation().into(),
|
||||
instance: instance.into(),
|
||||
yoi_failure: FailureEnvelope {
|
||||
version: projected.envelope.version,
|
||||
required_extensions: projected.envelope.required_extensions.clone(),
|
||||
domain: projected.envelope.domain.clone(),
|
||||
reason: projected.envelope.reason.clone(),
|
||||
category: projected.envelope.category,
|
||||
details_version: projected.envelope.details_version,
|
||||
details: &projected.envelope.details,
|
||||
explanation: projected.envelope.explanation.clone(),
|
||||
context: projected.envelope.context.clone(),
|
||||
},
|
||||
};
|
||||
let body = codec::encode_bounded(&problem, budget)?;
|
||||
Ok(ProblemResponse {
|
||||
status: problem.status,
|
||||
content_type: PROBLEM_CONTENT_TYPE,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decode_problem_details<D: WireDetails>(
|
||||
input: &[u8],
|
||||
actual_status: u16,
|
||||
content_type: &str,
|
||||
api_boundary: &impl ApiBoundary<D>,
|
||||
budget: Budget,
|
||||
) -> DecodeFailure<D> {
|
||||
match decode_problem_checked(input, actual_status, content_type, api_boundary, budget) {
|
||||
Ok(result) => result,
|
||||
Err(error) => DecodeFailure::Corrupt(error),
|
||||
}
|
||||
}
|
||||
fn decode_problem_checked<D: WireDetails>(
|
||||
input: &[u8],
|
||||
actual_status: u16,
|
||||
content_type: &str,
|
||||
api_boundary: &impl ApiBoundary<D>,
|
||||
budget: Budget,
|
||||
) -> Result<DecodeFailure<D>, ContractError> {
|
||||
codec::check_json_bounds(input, budget)?;
|
||||
if !content_type
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.eq_ignore_ascii_case(PROBLEM_CONTENT_TYPE)
|
||||
{
|
||||
return Err(ContractError::Invalid);
|
||||
}
|
||||
let raw: ReaderProblem = serde_json::from_slice(input).map_err(|_| ContractError::Invalid)?;
|
||||
if raw.status != actual_status
|
||||
|| !(400..=599).contains(&raw.status)
|
||||
|| Some(raw.detail.as_str())
|
||||
!= raw
|
||||
.yoi_failure
|
||||
.get("explanation")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
{
|
||||
return Err(ContractError::Invalid);
|
||||
}
|
||||
validate_uri(&raw.type_uri)?;
|
||||
validate_uri(&raw.instance)?;
|
||||
validate_explanation(&raw.title)?;
|
||||
let failure_bytes = codec::encode_bounded(&raw.yoi_failure, budget)?;
|
||||
match codec::decode_checked::<D>(&failure_bytes, budget)? {
|
||||
DecodeFailure::Known(_) => {
|
||||
// Use original JSON for typed parsing, preserving duplicate-field rejection.
|
||||
let typed: ProblemDetails<D> =
|
||||
serde_json::from_slice(input).map_err(|_| ContractError::InvalidDetails)?;
|
||||
let class = api_boundary.classify(&typed.yoi_failure.details);
|
||||
if typed.status != class.status.code()
|
||||
|| typed.type_uri != class.type_uri
|
||||
|| typed.title != class.title
|
||||
{
|
||||
return Err(ContractError::Invalid);
|
||||
}
|
||||
codec::accept_typed_envelope(typed.yoi_failure).map(DecodeFailure::Known)
|
||||
}
|
||||
other => Ok(other),
|
||||
}
|
||||
}
|
||||
|
||||
pub const STATIC_PROBLEM_FALLBACK: &[u8] = br#"{"type":"about:blank","title":"Internal Server Error","status":500,"detail":"Failure details unavailable."}"#;
|
||||
/// Static fallback is deliberately outside the domain contract and never formats a failure.
|
||||
pub fn problem_fallback(budget: Budget) -> Result<ProblemResponse, ContractError> {
|
||||
codec::check_json_bounds(STATIC_PROBLEM_FALLBACK, budget)?;
|
||||
Ok(ProblemResponse {
|
||||
status: 500,
|
||||
content_type: PROBLEM_CONTENT_TYPE,
|
||||
body: STATIC_PROBLEM_FALLBACK.to_vec(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Domain allowlists are the only path from internal facts to encodable values.
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use crate::{codec::FailureEnvelope, *};
|
||||
|
||||
mod sealed {
|
||||
pub trait Sealed {}
|
||||
}
|
||||
pub trait Audience: sealed::Sealed {}
|
||||
macro_rules! audiences {
|
||||
($($name:ident),*) => { $(
|
||||
#[derive(Debug)] pub struct $name;
|
||||
impl sealed::Sealed for $name {}
|
||||
impl Audience for $name {}
|
||||
)* };
|
||||
}
|
||||
audiences!(Browser, Tool, InternalTransfer, Storage, Telemetry);
|
||||
|
||||
/// Domain-owned allowlisted output. Context is selected too; it is not copied implicitly.
|
||||
pub struct Disclosure<D> {
|
||||
pub details: D,
|
||||
pub explanation: String,
|
||||
pub context: FailureContext,
|
||||
}
|
||||
impl<D> Disclosure<D> {
|
||||
pub fn new(details: D, explanation: impl Into<String>) -> Self {
|
||||
Self {
|
||||
details,
|
||||
explanation: explanation.into(),
|
||||
context: FailureContext::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Implement separately for each audience. The boundary supplies *current* authorization
|
||||
/// and resource visibility, including when projecting a previously stored/received value.
|
||||
/// This trait does not authenticate a caller or turn an audience name into authority.
|
||||
pub trait ProjectFor<A: Audience>: FailureCause + Sized {
|
||||
type Authorization: ?Sized;
|
||||
type Details: WireDetails;
|
||||
fn disclose(
|
||||
&self,
|
||||
facts: &FailureFacts<Self>,
|
||||
authorization: &Self::Authorization,
|
||||
) -> Result<Disclosure<Self::Details>, ContractError>;
|
||||
}
|
||||
|
||||
/// No Deserialize/Serialize implementation: only projection creates this trust marker.
|
||||
///
|
||||
/// ```compile_fail
|
||||
/// use error_contract::{Browser, Storage, ProjectedFailure};
|
||||
/// fn public_only<D>(_: &ProjectedFailure<Browser, D>) {}
|
||||
/// fn cannot_relabel<D>(stored: &ProjectedFailure<Storage, D>) { public_only(stored); }
|
||||
/// ```
|
||||
///
|
||||
/// ```compile_fail
|
||||
/// use error_contract::{Browser, ProjectedFailure};
|
||||
/// let _: ProjectedFailure<Browser, String> = serde_json::from_str("{}").unwrap();
|
||||
/// ```
|
||||
///
|
||||
/// ```compile_fail
|
||||
/// use error_contract::{Browser, Budget, ReceivedFailure, WireDetails, encode_projected_failure};
|
||||
/// fn received_is_not_projected<D: WireDetails>(received: &ReceivedFailure<D>) {
|
||||
/// encode_projected_failure::<Browser, D>(received, Budget::new(1024, 1024));
|
||||
/// }
|
||||
/// ```
|
||||
pub struct ProjectedFailure<A, D> {
|
||||
pub(crate) envelope: FailureEnvelope<D>,
|
||||
pub(crate) audience: PhantomData<A>,
|
||||
}
|
||||
impl<A: Audience, D: FailureCause> ProjectedFailure<A, D> {
|
||||
pub fn details(&self) -> &D {
|
||||
&self.envelope.details
|
||||
}
|
||||
pub fn identity(&self) -> FailureIdentity {
|
||||
self.envelope.details.identity()
|
||||
}
|
||||
pub fn explanation(&self) -> &str {
|
||||
&self.envelope.explanation
|
||||
}
|
||||
pub fn context(&self) -> &FailureContext {
|
||||
&self.envelope.context
|
||||
}
|
||||
}
|
||||
|
||||
pub fn project_failure<A: Audience, D: ProjectFor<A>>(
|
||||
facts: &FailureFacts<D>,
|
||||
authorization: &D::Authorization,
|
||||
) -> Result<ProjectedFailure<A, D::Details>, ContractError> {
|
||||
validate_failure(facts)?;
|
||||
let disclosure = facts.cause().disclose(facts, authorization)?;
|
||||
disclosure.details.validate()?;
|
||||
let identity = disclosure.details.identity();
|
||||
// A domain can remove optional details, not silently replace a known cause.
|
||||
if identity != facts.cause().identity() {
|
||||
return Err(ContractError::InvalidDetails);
|
||||
}
|
||||
validate_explanation(&disclosure.explanation)?;
|
||||
disclosure.context.validate()?;
|
||||
Ok(ProjectedFailure {
|
||||
envelope: FailureEnvelope {
|
||||
version: SCHEMA_VERSION,
|
||||
required_extensions: Vec::new(),
|
||||
domain: identity.domain.into(),
|
||||
reason: identity.reason.into(),
|
||||
category: identity.category,
|
||||
details_version: identity.details_version,
|
||||
details: disclosure.details,
|
||||
explanation: disclosure.explanation,
|
||||
context: disclosure.context,
|
||||
},
|
||||
audience: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
/// Compose an allowlisted cause and impact without truncating either required part.
|
||||
/// Domains select the translated fragments; consumers must not classify from this prose.
|
||||
pub fn compose_explanation(cause: &str, impact: &str) -> Result<String, ContractError> {
|
||||
validate_explanation(cause)?;
|
||||
validate_explanation(impact)?;
|
||||
let explanation = format!("{impact}: {cause}");
|
||||
validate_explanation(&explanation)?;
|
||||
Ok(explanation)
|
||||
}
|
||||
|
||||
/// Safe local presentation when no supported cause can be disclosed. This is not a
|
||||
/// replacement domain failure or permission to forward unsupported payload bytes.
|
||||
#[derive(Debug)]
|
||||
pub struct UnavailablePresentation {
|
||||
pub detail: &'static str,
|
||||
pub stage: &'static str,
|
||||
pub correlation: Option<CorrelationRef>,
|
||||
}
|
||||
pub fn unavailable_presentation(
|
||||
stage: &'static str,
|
||||
correlation: Option<CorrelationRef>,
|
||||
) -> Result<UnavailablePresentation, ContractError> {
|
||||
validate_identifier(stage)?;
|
||||
if let Some(reference) = &correlation {
|
||||
validate_identifier(reference.as_str())?;
|
||||
}
|
||||
Ok(UnavailablePresentation {
|
||||
detail: "Failure details unavailable.",
|
||||
stage,
|
||||
correlation,
|
||||
})
|
||||
}
|
||||
|
||||
/// Safe fields for an existing tracing sink. No implicit recording at wrappers.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct ObservationFields<'a> {
|
||||
pub domain: &'static str,
|
||||
pub reason: &'static str,
|
||||
pub operation: Option<&'a str>,
|
||||
pub stage: Option<&'a str>,
|
||||
pub correlation: Option<&'a str>,
|
||||
}
|
||||
pub fn observation_fields<D: FailureCause>(
|
||||
failure: &ProjectedFailure<Telemetry, D>,
|
||||
) -> ObservationFields<'_> {
|
||||
let identity = failure.identity();
|
||||
let operation = failure.context().operations.last();
|
||||
ObservationFields {
|
||||
domain: identity.domain,
|
||||
reason: identity.reason,
|
||||
operation: operation.map(|op| op.operation.as_str()),
|
||||
stage: operation.map(|op| op.stage.as_str()),
|
||||
correlation: failure
|
||||
.context()
|
||||
.correlation
|
||||
.as_ref()
|
||||
.map(CorrelationRef::as_str),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! The wire DTOs are the schema source. Domain details supply their own JsonSchema.
|
||||
//! Byte/depth budgets and cross-field validation remain runtime requirements; JSON Schema
|
||||
//! string lengths count codepoints and cannot replace the codec's UTF-8 byte limits.
|
||||
use crate::{codec::FailureEnvelope, problem::ProblemDetails};
|
||||
|
||||
pub fn failure_schema<D: schemars::JsonSchema>() -> schemars::Schema {
|
||||
schemars::schema_for!(FailureEnvelope<D>)
|
||||
}
|
||||
pub fn problem_schema<D: schemars::JsonSchema>() -> schemars::Schema {
|
||||
schemars::schema_for!(ProblemDetails<D>)
|
||||
}
|
||||
|
||||
/// Existing protocol TS generation consumes this fragment; no second DTO/generator is needed.
|
||||
#[cfg(feature = "typescript")]
|
||||
pub fn typescript_declarations() -> String {
|
||||
use crate::*;
|
||||
use ts_rs::TS;
|
||||
let config = ts_rs::Config::default();
|
||||
[
|
||||
Category::decl(&config),
|
||||
OperationContext::decl(&config),
|
||||
CorrelationRef::decl(&config),
|
||||
CausalLink::decl(&config),
|
||||
Diagnostic::decl(&config),
|
||||
FailureContext::decl(&config),
|
||||
FailureEnvelope::<String>::decl(&config),
|
||||
ProblemDetails::<String>::decl(&config),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|declaration| format!("export {declaration}\n"))
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Shared executable examples, not production authorization/domain adapters.
|
||||
//! The fixture authorization context stands in for a boundary's current authenticated policy
|
||||
//! decision; it must never be reconstructed from a received failure or an audience label.
|
||||
use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const SUBJECT_FIXTURE: &str = include_str!("../fixtures/subject-denied.json");
|
||||
pub const WORKDIR_FIXTURE: &str = include_str!("../fixtures/workdir-in-use.json");
|
||||
pub const FIXTURE_SECRETS: &[&str] = &[
|
||||
"credential-secret",
|
||||
"/private/worktree",
|
||||
"raw-http-body",
|
||||
"internal-source",
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Language {
|
||||
English,
|
||||
Japanese,
|
||||
}
|
||||
/// Fixture policy for subject-1/workdir-1 only; a true visibility decision is not a grant
|
||||
/// to disclose arbitrary resource references supplied by a received wire payload.
|
||||
pub struct CurrentAccess {
|
||||
pub can_view_resource: bool,
|
||||
pub language: Language,
|
||||
}
|
||||
impl CurrentAccess {
|
||||
fn visible_reference(&self, reference: Option<&str>, expected: &str) -> Option<String> {
|
||||
reference
|
||||
.filter(|value| self.can_view_resource && *value == expected)
|
||||
.map(str::to_owned)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, 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 RequiredOperation {
|
||||
#[serde(rename = "session.record")]
|
||||
SessionRecord,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, 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 SubjectDenied {
|
||||
pub required_operation: RequiredOperation,
|
||||
pub subject_ref: Option<String>,
|
||||
}
|
||||
impl FailureCause for SubjectDenied {
|
||||
fn identity(&self) -> FailureIdentity {
|
||||
FailureIdentity {
|
||||
domain: "subject",
|
||||
reason: "operation_denied",
|
||||
category: Category::Authorization,
|
||||
details_version: 1,
|
||||
}
|
||||
}
|
||||
fn validate(&self) -> Result<(), ContractError> {
|
||||
if let Some(reference) = &self.subject_ref {
|
||||
validate_identifier(reference)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl WireDetails for SubjectDenied {
|
||||
fn supports(domain: &str, reason: &str, version: u16) -> bool {
|
||||
domain == "subject" && reason == "operation_denied" && version == 1
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, 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 WorkdirOperation {
|
||||
Open,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, 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 WorkdirInUse {
|
||||
pub operation: WorkdirOperation,
|
||||
pub workdir_ref: Option<String>,
|
||||
}
|
||||
impl FailureCause for WorkdirInUse {
|
||||
fn identity(&self) -> FailureIdentity {
|
||||
FailureIdentity {
|
||||
domain: "workdir",
|
||||
reason: "in_use",
|
||||
category: Category::Conflict,
|
||||
details_version: 1,
|
||||
}
|
||||
}
|
||||
fn validate(&self) -> Result<(), ContractError> {
|
||||
if let Some(reference) = &self.workdir_ref {
|
||||
validate_identifier(reference)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl WireDetails for WorkdirInUse {
|
||||
fn supports(domain: &str, reason: &str, version: u16) -> bool {
|
||||
domain == "workdir" && reason == "in_use" && version == 1
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("internal-source credential-secret /private/worktree raw-http-body")]
|
||||
pub struct PrivateSource;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("subject rejected operation")]
|
||||
pub struct SubjectError {
|
||||
pub details: SubjectDenied,
|
||||
#[source]
|
||||
pub source: PrivateSource,
|
||||
}
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("workdir in use")]
|
||||
pub struct WorkdirError {
|
||||
pub details: WorkdirInUse,
|
||||
#[source]
|
||||
pub source: PrivateSource,
|
||||
}
|
||||
macro_rules! cause {
|
||||
($name:ty) => {
|
||||
impl FailureCause for $name {
|
||||
fn identity(&self) -> FailureIdentity {
|
||||
self.details.identity()
|
||||
}
|
||||
fn validate(&self) -> Result<(), ContractError> {
|
||||
self.details.validate()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
cause!(SubjectError);
|
||||
cause!(WorkdirError);
|
||||
|
||||
// The domain selects both details and common metadata. No raw source/Display is inspected.
|
||||
fn allowed_context(context: &FailureContext, access: &CurrentAccess) -> FailureContext {
|
||||
FailureContext {
|
||||
operations: context
|
||||
.operations
|
||||
.iter()
|
||||
.filter_map(|operation| {
|
||||
if !matches!(
|
||||
operation.operation.as_str(),
|
||||
"worker.create" | "workdir.open"
|
||||
) || !matches!(operation.stage.as_str(), "register" | "prepare")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(OperationContext {
|
||||
operation: operation.operation.clone(),
|
||||
stage: operation.stage.clone(),
|
||||
operation_ref: access.visible_reference(
|
||||
operation.operation_ref.as_deref(),
|
||||
"visible-operation-ref",
|
||||
),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
// A wire correlation is not a local proof. This fixture boundary knows only these
|
||||
// two safe occurrence references; production adapters use their current binding.
|
||||
correlation: context
|
||||
.correlation
|
||||
.as_ref()
|
||||
.filter(|reference| matches!(reference.as_str(), "correlation-1" | "correlation-2"))
|
||||
.cloned(),
|
||||
supplementary_omitted: !context.diagnostics.is_empty()
|
||||
|| !context.causes.is_empty()
|
||||
|| context.supplementary_omitted,
|
||||
..FailureContext::default()
|
||||
}
|
||||
}
|
||||
fn subject_disclosure(
|
||||
details: &SubjectDenied,
|
||||
context: &FailureContext,
|
||||
access: &CurrentAccess,
|
||||
) -> Result<Disclosure<SubjectDenied>, ContractError> {
|
||||
let details = SubjectDenied {
|
||||
required_operation: details.required_operation,
|
||||
subject_ref: access.visible_reference(details.subject_ref.as_deref(), "subject-1"),
|
||||
};
|
||||
let explanation = match access.language {
|
||||
Language::English => compose_explanation(
|
||||
"session.record is denied.",
|
||||
"Worker creation cannot register the session",
|
||||
)?,
|
||||
Language::Japanese => compose_explanation(
|
||||
"session.record が拒否されました。",
|
||||
"Worker作成でSessionを登録できません",
|
||||
)?,
|
||||
};
|
||||
Ok(Disclosure {
|
||||
details,
|
||||
explanation,
|
||||
context: allowed_context(context, access),
|
||||
})
|
||||
}
|
||||
fn workdir_disclosure(
|
||||
details: &WorkdirInUse,
|
||||
context: &FailureContext,
|
||||
access: &CurrentAccess,
|
||||
) -> Disclosure<WorkdirInUse> {
|
||||
let details = WorkdirInUse {
|
||||
operation: details.operation,
|
||||
workdir_ref: access.visible_reference(details.workdir_ref.as_deref(), "workdir-1"),
|
||||
};
|
||||
let explanation = match access.language {
|
||||
Language::English => "Workdir open cannot proceed because the workdir is in use.",
|
||||
Language::Japanese => "Workdirは使用中のため開けません。",
|
||||
};
|
||||
Disclosure {
|
||||
details,
|
||||
explanation: explanation.into(),
|
||||
context: allowed_context(context, access),
|
||||
}
|
||||
}
|
||||
macro_rules! adapters {
|
||||
($($audience:ty),*) => { $(
|
||||
impl ProjectFor<$audience> for SubjectDenied {
|
||||
type Authorization = CurrentAccess; type Details = SubjectDenied;
|
||||
fn disclose(&self, facts: &FailureFacts<Self>, access: &CurrentAccess) -> Result<Disclosure<Self::Details>, ContractError> {
|
||||
subject_disclosure(self, facts.context(), access)
|
||||
}
|
||||
}
|
||||
impl ProjectFor<$audience> for WorkdirInUse {
|
||||
type Authorization = CurrentAccess; type Details = WorkdirInUse;
|
||||
fn disclose(&self, facts: &FailureFacts<Self>, access: &CurrentAccess) -> Result<Disclosure<Self::Details>, ContractError> {
|
||||
Ok(workdir_disclosure(self, facts.context(), access))
|
||||
}
|
||||
}
|
||||
impl ProjectFor<$audience> for SubjectError {
|
||||
type Authorization = CurrentAccess; type Details = SubjectDenied;
|
||||
fn disclose(&self, facts: &FailureFacts<Self>, access: &CurrentAccess) -> Result<Disclosure<Self::Details>, ContractError> {
|
||||
subject_disclosure(&self.details, facts.context(), access)
|
||||
}
|
||||
}
|
||||
impl ProjectFor<$audience> for WorkdirError {
|
||||
type Authorization = CurrentAccess; type Details = WorkdirInUse;
|
||||
fn disclose(&self, facts: &FailureFacts<Self>, access: &CurrentAccess) -> Result<Disclosure<Self::Details>, ContractError> {
|
||||
Ok(workdir_disclosure(&self.details, facts.context(), access))
|
||||
}
|
||||
}
|
||||
)* };
|
||||
}
|
||||
adapters!(Browser, Tool, Storage, InternalTransfer, Telemetry);
|
||||
|
||||
pub fn subject_facts() -> FailureFacts<SubjectError> {
|
||||
let mut facts = FailureFacts::new(SubjectError {
|
||||
details: SubjectDenied {
|
||||
required_operation: RequiredOperation::SessionRecord,
|
||||
subject_ref: Some("subject-1".into()),
|
||||
},
|
||||
source: PrivateSource,
|
||||
})
|
||||
.unwrap();
|
||||
facts
|
||||
.with_context(OperationContext::new("worker.create", "register").unwrap())
|
||||
.unwrap();
|
||||
facts
|
||||
.set_correlation(CorrelationRef::new("correlation-1").unwrap())
|
||||
.unwrap();
|
||||
facts
|
||||
}
|
||||
pub fn workdir_facts() -> FailureFacts<WorkdirError> {
|
||||
let mut facts = FailureFacts::new(WorkdirError {
|
||||
details: WorkdirInUse {
|
||||
operation: WorkdirOperation::Open,
|
||||
workdir_ref: Some("workdir-1".into()),
|
||||
},
|
||||
source: PrivateSource,
|
||||
})
|
||||
.unwrap();
|
||||
facts
|
||||
.with_context(OperationContext::new("workdir.open", "prepare").unwrap())
|
||||
.unwrap();
|
||||
facts
|
||||
.set_correlation(CorrelationRef::new("correlation-2").unwrap())
|
||||
.unwrap();
|
||||
facts
|
||||
}
|
||||
|
||||
/// Inner Subject API vs outer Worker API deliberately choose different HTTP classifications.
|
||||
pub struct SubjectApi;
|
||||
pub struct WorkerApi;
|
||||
pub struct WorkdirApi;
|
||||
impl crate::problem::ApiBoundary<SubjectDenied> for SubjectApi {
|
||||
fn classify(&self, _: &SubjectDenied) -> crate::problem::ProblemClass {
|
||||
crate::problem::ProblemClass::authorization_denied()
|
||||
}
|
||||
}
|
||||
impl crate::problem::ApiBoundary<SubjectDenied> for WorkerApi {
|
||||
fn classify(&self, _: &SubjectDenied) -> crate::problem::ProblemClass {
|
||||
crate::problem::ProblemClass::conflict()
|
||||
}
|
||||
}
|
||||
impl crate::problem::ApiBoundary<WorkdirInUse> for WorkdirApi {
|
||||
fn classify(&self, _: &WorkdirInUse) -> crate::problem::ProblemClass {
|
||||
crate::problem::ProblemClass::conflict()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assert_no_fixture_secrets(bytes: &[u8]) {
|
||||
let text = std::str::from_utf8(bytes).expect("JSON is UTF-8");
|
||||
for secret in FIXTURE_SECRETS {
|
||||
assert!(!text.contains(secret), "secret escaped the allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
/// Check the field allowlist on the same wire fixture used by HTTP/TS/storage consumers.
|
||||
pub fn assert_detail_allowlist(bytes: &[u8], allowed: &[&str]) {
|
||||
let value: serde_json::Value = serde_json::from_slice(bytes).expect("fixture JSON");
|
||||
let details = value["details"]
|
||||
.as_object()
|
||||
.expect("fixture details object");
|
||||
for key in details.keys() {
|
||||
assert!(
|
||||
allowed.contains(&key.as_str()),
|
||||
"unexpected public detail field: {key}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assert_context_preserves_details<D: WireDetails + Clone + PartialEq + std::fmt::Debug>(
|
||||
facts: &mut FailureFacts<D>,
|
||||
context: OperationContext,
|
||||
) {
|
||||
let original = facts.cause().clone();
|
||||
facts
|
||||
.with_context(context)
|
||||
.expect("fixture context fits budget");
|
||||
assert_eq!(facts.cause(), &original);
|
||||
}
|
||||
|
||||
/// Semantic check shared by Rust/HTTP/storage fixture consumers. Compare typed details,
|
||||
/// not translated prose or diagnostic order. Reprojection is intentionally a separate step.
|
||||
pub fn assert_semantic_roundtrip<D: WireDetails + PartialEq + std::fmt::Debug>(
|
||||
bytes: &[u8],
|
||||
expected: &D,
|
||||
budget: Budget,
|
||||
) {
|
||||
match decode_failure::<D>(bytes, budget) {
|
||||
DecodeFailure::Known(received) => assert_eq!(received.facts().cause(), expected),
|
||||
_ => panic!("expected a known validated fixture"),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -5,6 +5,9 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
rusqlite.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
@@ -1,11 +1,66 @@
|
||||
# feature-storage
|
||||
|
||||
Manages the SQLite lifecycle for Features within a trusted Host.
|
||||
Does not own Feature domain schemas or authentication, or provide SQL access to Workers or models.
|
||||
Owns physical SQLite identity and lifecycle for trusted Host repositories.
|
||||
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`]: connections and transactions
|
||||
- [`FeatureMigration`]: Feature-owned schema changes
|
||||
- [`configure_connection`]: shared SQLite connection policy
|
||||
[`FeatureDatabase`] operations retain the verified connection and shared
|
||||
cross-process lifecycle lease through consumer commit/rollback.
|
||||
[`DatabaseGuard`] extends the short lease over a writer-release decision without
|
||||
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(())
|
||||
}
|
||||
+978
-104
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,64 @@
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// Read-only observations for one registration (and its clones) across its scopes.
|
||||
/// Counts real I/O boundaries, not API opens, and retains no physical database.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct OpenObservation {
|
||||
pub physical_open_attempts: u64,
|
||||
pub physical_opens: u64,
|
||||
pub completed_initializations: u64,
|
||||
pub directory_sync_attempts: u64,
|
||||
pub directory_sync_successes: u64,
|
||||
}
|
||||
|
||||
impl OpenObservation {
|
||||
pub fn since(self, before: Self) -> Self {
|
||||
Self {
|
||||
physical_open_attempts: self.physical_open_attempts - before.physical_open_attempts,
|
||||
physical_opens: self.physical_opens - before.physical_opens,
|
||||
completed_initializations: self.completed_initializations
|
||||
- before.completed_initializations,
|
||||
directory_sync_attempts: self.directory_sync_attempts - before.directory_sync_attempts,
|
||||
directory_sync_successes: self.directory_sync_successes
|
||||
- before.directory_sync_successes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct Counters {
|
||||
physical_open_attempts: AtomicU64,
|
||||
physical_opens: AtomicU64,
|
||||
completed_initializations: AtomicU64,
|
||||
directory_sync_attempts: AtomicU64,
|
||||
directory_sync_successes: AtomicU64,
|
||||
}
|
||||
|
||||
impl Counters {
|
||||
pub(super) fn snapshot(&self) -> OpenObservation {
|
||||
OpenObservation {
|
||||
physical_open_attempts: self.physical_open_attempts.load(Ordering::Relaxed),
|
||||
physical_opens: self.physical_opens.load(Ordering::Relaxed),
|
||||
completed_initializations: self.completed_initializations.load(Ordering::Relaxed),
|
||||
directory_sync_attempts: self.directory_sync_attempts.load(Ordering::Relaxed),
|
||||
directory_sync_successes: self.directory_sync_successes.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
pub(super) fn open_attempt(&self) {
|
||||
self.physical_open_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
pub(super) fn open_success(&self) {
|
||||
self.physical_opens.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
pub(super) fn initialized(&self) {
|
||||
self.completed_initializations
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
pub(super) fn sync_attempt(&self) {
|
||||
self.directory_sync_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
pub(super) fn sync_success(&self) {
|
||||
self.directory_sync_successes
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -200,6 +200,7 @@ fn run_read_with_limits(
|
||||
total_lines,
|
||||
content_hash: content_hasher.finalize().into(),
|
||||
truncated: end < total_lines || byte_truncated,
|
||||
byte_truncated,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -885,6 +886,34 @@ mod bounded_read_tests {
|
||||
assert!(error.to_string().contains("exceeds provider limit 4096"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_read_distinguishes_utf8_byte_cut_from_complete_line_continuation() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().canonicalize().unwrap();
|
||||
fs::write(root.join("text"), "雪雪needle\nnext").unwrap();
|
||||
for (offset, max_bytes, bytes, more, cut) in [
|
||||
(0, 5, "雪", true, true),
|
||||
(0, 64, "雪雪needle\n", true, false),
|
||||
(1, 64, "next", false, false),
|
||||
] {
|
||||
let result = run_read_bounded(
|
||||
&root,
|
||||
ReadRequest {
|
||||
path: FsPath::new("text").unwrap(),
|
||||
offset,
|
||||
limit: 1,
|
||||
max_bytes,
|
||||
},
|
||||
&RootAccess(root.clone()),
|
||||
BoundedReadLimits::new(1024, 64).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.bytes, bytes.as_bytes());
|
||||
assert_eq!(result.truncated, more);
|
||||
assert_eq!(result.byte_truncated, cut);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_read_caps_retained_response_independently_of_request() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -907,6 +936,7 @@ mod bounded_read_tests {
|
||||
assert_eq!(result.bytes, b"alpha");
|
||||
assert_eq!(result.total_lines, 3);
|
||||
assert!(result.truncated);
|
||||
assert!(result.byte_truncated);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -204,7 +204,11 @@ pub struct ReadResult {
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||
pub total_lines: usize,
|
||||
pub content_hash: ContentHash,
|
||||
/// More source lines remain, or the selected window exceeded the response byte budget.
|
||||
pub truncated: bool,
|
||||
/// The selected line window was cut by its byte budget, possibly within a line.
|
||||
/// Line-based cursors must not treat such a window as completely scanned.
|
||||
pub byte_truncated: bool,
|
||||
}
|
||||
|
||||
/// Binary-safe byte range read with an optional whole-file version fence.
|
||||
|
||||
@@ -191,7 +191,6 @@ pub enum JobState {
|
||||
Pending,
|
||||
Completed,
|
||||
Failed,
|
||||
Unknown,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
@@ -203,7 +202,6 @@ pub enum JobAttemptState {
|
||||
Dispatched,
|
||||
Completed,
|
||||
Failed,
|
||||
Unknown,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ fn shared_lifecycle_enums_round_trip_all_host_states() {
|
||||
(JobState::Pending, "pending"),
|
||||
(JobState::Completed, "completed"),
|
||||
(JobState::Failed, "failed"),
|
||||
(JobState::Unknown, "unknown"),
|
||||
(JobState::Cancelled, "cancelled"),
|
||||
] {
|
||||
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::Completed, "completed"),
|
||||
(JobAttemptState::Failed, "failed"),
|
||||
(JobAttemptState::Unknown, "unknown"),
|
||||
(JobAttemptState::Cancelled, "cancelled"),
|
||||
] {
|
||||
assert_eq!(serde_json::to_value(state).unwrap(), json!(name));
|
||||
@@ -63,6 +61,9 @@ fn shared_lifecycle_enums_round_trip_all_host_states() {
|
||||
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::<JobAttemptState>(json!("idle")).is_err());
|
||||
}
|
||||
@@ -111,13 +112,6 @@ fn outcome_is_a_neutral_read_snapshot_with_optional_result_and_failure() {
|
||||
Some("execution"),
|
||||
Some("known failure"),
|
||||
),
|
||||
(
|
||||
JobState::Unknown,
|
||||
JobAttemptState::Unknown,
|
||||
None,
|
||||
Some("interrupted"),
|
||||
Some("requires reconciliation"),
|
||||
),
|
||||
(
|
||||
JobState::Cancelled,
|
||||
JobAttemptState::Cancelled,
|
||||
|
||||
@@ -15,11 +15,17 @@ serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_ignored = "0.1.14"
|
||||
sha2 = "0.10"
|
||||
crc32fast = "1"
|
||||
wasmparser = "0.244.0"
|
||||
wit-parser = "0.244.0"
|
||||
wit-component = "0.244.0"
|
||||
jsonschema = { version = "0.33.0", default-features = false }
|
||||
secrets = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wit-component = { version = "0.244.0", features = ["dummy-module"] }
|
||||
serial_test = "3.4.0"
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -58,6 +58,10 @@ pub struct WorkerManifestConfig {
|
||||
/// never starts a local MCP subprocess.
|
||||
#[serde(default)]
|
||||
pub mcp: McpConfig,
|
||||
/// Omission retains the lower layer; an explicit section replaces the entire
|
||||
/// selection list, including an empty list. Never unions Plugin grants.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub plugins: Option<crate::PluginProfileConfig>,
|
||||
#[serde(default)]
|
||||
pub compaction: Option<CompactionConfigPartial>,
|
||||
/// First-class web tool opt-in. See [`WebConfig`].
|
||||
@@ -295,6 +299,8 @@ pub struct SubjektivFeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub consolidation_tools: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub chat: Option<FeatureFlagConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub extraction: Option<MemoryExtractionProfileConfigPartial>,
|
||||
}
|
||||
|
||||
@@ -303,6 +309,7 @@ impl SubjektivFeatureConfigPartial {
|
||||
Self {
|
||||
enabled: other.enabled.or(self.enabled),
|
||||
consolidation_tools: other.consolidation_tools.or(self.consolidation_tools),
|
||||
chat: merge_option(self.chat, other.chat, FeatureFlagConfigPartial::merge),
|
||||
extraction: merge_option(
|
||||
self.extraction,
|
||||
other.extraction,
|
||||
@@ -554,6 +561,7 @@ impl From<SubjektivFeatureConfigPartial> for ResolvedSubjektivFeatureConfig {
|
||||
profile: SubjektivFeatureProfileConfig {
|
||||
enabled: value.enabled.unwrap_or_default(),
|
||||
consolidation_tools: value.consolidation_tools.unwrap_or_default(),
|
||||
chat: value.chat.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||
extraction: MemoryExtractionProfileConfig {
|
||||
enabled: extraction.enabled.unwrap_or(true),
|
||||
model: extraction.model,
|
||||
@@ -574,6 +582,7 @@ impl From<ResolvedSubjektivFeatureConfig> for SubjektivFeatureConfigPartial {
|
||||
Self {
|
||||
enabled: Some(value.profile.enabled),
|
||||
consolidation_tools: Some(value.profile.consolidation_tools),
|
||||
chat: Some(value.profile.chat.into()),
|
||||
extraction: Some(MemoryExtractionProfileConfigPartial {
|
||||
enabled: Some(value.profile.extraction.enabled),
|
||||
model: value.profile.extraction.model,
|
||||
@@ -795,11 +804,6 @@ pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Er
|
||||
"unknown field in manifest: memory (removed; configure feature.memory)",
|
||||
));
|
||||
}
|
||||
if value.get("plugins").is_some() {
|
||||
return Err(toml::de::Error::custom(
|
||||
"unknown field in manifest: plugins (dynamic Plugins are not supported)",
|
||||
));
|
||||
}
|
||||
if value
|
||||
.get("feature")
|
||||
.and_then(toml::Value::as_table)
|
||||
@@ -930,6 +934,7 @@ impl WorkerManifestConfig {
|
||||
),
|
||||
feature: self.feature.merge(upper.feature),
|
||||
mcp: merge_mcp_config(self.mcp, upper.mcp),
|
||||
plugins: upper.plugins.or(self.plugins),
|
||||
compaction: merge_option(
|
||||
self.compaction,
|
||||
upper.compaction,
|
||||
@@ -1445,6 +1450,7 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
|
||||
permissions,
|
||||
feature: FeatureConfig::from(cfg.feature),
|
||||
mcp: cfg.mcp,
|
||||
plugins: cfg.plugins.unwrap_or_default(),
|
||||
compaction,
|
||||
web: cfg.web,
|
||||
skills: cfg.skills,
|
||||
@@ -1492,6 +1498,7 @@ mod tests {
|
||||
permissions: None,
|
||||
feature: FeatureConfigPartial::default(),
|
||||
mcp: McpConfig::default(),
|
||||
plugins: None,
|
||||
session: None,
|
||||
compaction: None,
|
||||
web: None,
|
||||
@@ -2988,6 +2995,83 @@ worker_max_turns = 7
|
||||
assert_eq!(encoded["enabled"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subjektiv_chat_requires_explicit_opt_in_and_roundtrips() {
|
||||
for subject in [
|
||||
"",
|
||||
"[feature.subjektiv]\nenabled = true\n",
|
||||
"[feature.subjektiv.chat]\n",
|
||||
] {
|
||||
let config = WorkerManifestConfig::from_toml(subject).unwrap();
|
||||
let feature = FeatureConfig::from(config.feature);
|
||||
assert!(!feature.subjektiv.profile.chat.enabled);
|
||||
}
|
||||
assert!(
|
||||
!ResolvedSubjektivFeatureConfig::default()
|
||||
.profile
|
||||
.chat
|
||||
.enabled
|
||||
);
|
||||
let legacy: ResolvedSubjektivFeatureConfig = serde_json::from_value(serde_json::json!({
|
||||
"profile": { "enabled": true }
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(!legacy.profile.chat.enabled);
|
||||
|
||||
for enabled in [true, false] {
|
||||
let config = WorkerManifestConfig::from_toml(&format!(
|
||||
"[feature.subjektiv]\nenabled = true\n[feature.subjektiv.chat]\nenabled = {enabled}\n"
|
||||
)).unwrap();
|
||||
let resolved = FeatureConfig::from(config.feature).subjektiv;
|
||||
let json = serde_json::to_value(&resolved).unwrap();
|
||||
assert_eq!(json["profile"]["chat"]["enabled"], enabled);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ResolvedSubjektivFeatureConfig>(json).unwrap(),
|
||||
resolved
|
||||
);
|
||||
assert!(resolved.workspace_settings.is_none());
|
||||
assert!(!resolved.execution_enabled());
|
||||
let partial = SubjektivFeatureConfigPartial::from(resolved.clone());
|
||||
let toml = toml::to_string(&partial).unwrap();
|
||||
assert_eq!(
|
||||
ResolvedSubjektivFeatureConfig::from(
|
||||
toml::from_str::<SubjektivFeatureConfigPartial>(&toml).unwrap()
|
||||
),
|
||||
resolved
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subjektiv_chat_merge_preserves_omissions_and_honors_explicit_disable() {
|
||||
let base = WorkerManifestConfig::from_toml(
|
||||
"[feature.subjektiv]\nenabled = true\n[feature.subjektiv.chat]\nenabled = true\n[feature.subjektiv.extraction]\nthreshold = 321\n"
|
||||
).unwrap();
|
||||
for overlay in [
|
||||
"",
|
||||
"[feature.subjektiv]\nconsolidation_tools = false\n",
|
||||
"[feature.subjektiv.chat]\n",
|
||||
] {
|
||||
let merged = base
|
||||
.clone()
|
||||
.merge(WorkerManifestConfig::from_toml(overlay).unwrap());
|
||||
let feature = FeatureConfig::from(merged.feature);
|
||||
assert!(feature.subjektiv.profile.chat.enabled);
|
||||
assert_eq!(feature.subjektiv.profile.extraction.threshold, Some(321));
|
||||
}
|
||||
for enabled in [false, true] {
|
||||
let overlay = WorkerManifestConfig::from_toml(&format!(
|
||||
"[feature.subjektiv.chat]\nenabled = {enabled}\n"
|
||||
))
|
||||
.unwrap();
|
||||
let merged = base.clone().merge(overlay);
|
||||
let feature = FeatureConfig::from(merged.feature);
|
||||
assert_eq!(feature.subjektiv.profile.chat.enabled, enabled);
|
||||
assert!(feature.subjektiv.profile.enabled);
|
||||
assert_eq!(feature.subjektiv.profile.extraction.threshold, Some(321));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_preserves_ref() {
|
||||
let lower = WorkerManifestConfig {
|
||||
|
||||
@@ -7,6 +7,7 @@ mod model;
|
||||
pub mod model_catalog;
|
||||
pub mod paths;
|
||||
pub mod plugin;
|
||||
mod plugin_profile;
|
||||
mod profile;
|
||||
mod scope;
|
||||
|
||||
@@ -24,6 +25,7 @@ pub use model::{
|
||||
AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind,
|
||||
};
|
||||
pub use paths::user_profiles_path;
|
||||
pub use plugin_profile::{PluginProfileConfig, PluginProfileSelection, PluginTicketDataGrant};
|
||||
pub use profile::{
|
||||
ProfileDiscovery, ProfileError, ProfileExecutionTarget, ProfileManifestSnapshot,
|
||||
ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource,
|
||||
@@ -80,6 +82,11 @@ pub struct WorkerManifest {
|
||||
/// consumer boundary.
|
||||
#[serde(default)]
|
||||
pub mcp: McpConfig,
|
||||
/// Narrowing-only Workspace Plugin selections, not enablement or execution pins.
|
||||
/// Empty selections are omitted to preserve existing no-Plugin snapshots and
|
||||
/// their serialized fingerprint inputs.
|
||||
#[serde(default, skip_serializing_if = "PluginProfileConfig::is_empty")]
|
||||
pub plugins: PluginProfileConfig,
|
||||
#[serde(default)]
|
||||
pub compaction: Option<CompactionConfig>,
|
||||
/// First-class web tools configuration. Network access remains fail-closed
|
||||
@@ -412,6 +419,8 @@ pub struct SubjektivFeatureProfileConfig {
|
||||
pub enabled: bool,
|
||||
/// Explicit consolidation tool policy; never grants Job or subject authority.
|
||||
pub consolidation_tools: bool,
|
||||
/// Opt-in conversation tools; the Host must bind an explicit Subject connection.
|
||||
pub chat: FeatureFlagConfig,
|
||||
pub extraction: MemoryExtractionProfileConfig,
|
||||
}
|
||||
|
||||
@@ -420,6 +429,7 @@ impl Default for SubjektivFeatureProfileConfig {
|
||||
Self {
|
||||
enabled: false,
|
||||
consolidation_tools: false,
|
||||
chat: FeatureFlagConfig::disabled(),
|
||||
extraction: MemoryExtractionProfileConfig::default(),
|
||||
}
|
||||
}
|
||||
@@ -1116,6 +1126,7 @@ impl Default for CompactionConfig {
|
||||
impl WorkerManifest {
|
||||
pub fn requires_persisted_execution_snapshot(&self) -> bool {
|
||||
self.profile.is_some()
|
||||
|| !self.plugins.is_empty()
|
||||
|| self.feature.memory.workspace_settings.is_some()
|
||||
|| self.feature.subjektiv.workspace_settings.is_some()
|
||||
}
|
||||
@@ -1595,9 +1606,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
);
|
||||
let error = WorkerManifest::from_toml(&toml).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("dynamic Plugins are not supported"),
|
||||
error.to_string().contains("unknown field `enabled`"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
}
|
||||
@@ -1617,7 +1626,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
}),
|
||||
);
|
||||
let error = serde_json::from_value::<WorkerManifest>(top_level).unwrap_err();
|
||||
assert!(error.to_string().contains("unknown field `plugins`"));
|
||||
assert!(error.to_string().contains("unknown field `resolved`"));
|
||||
|
||||
let mut nested = base;
|
||||
nested
|
||||
|
||||
@@ -100,21 +100,6 @@ pub fn worker_allocation_path() -> Option<PathBuf> {
|
||||
worker_allocation_path_from_runtime_dir(runtime_dir())
|
||||
}
|
||||
|
||||
/// `<runtime_dir>/<worker_name>/` — per-Worker runtime directory.
|
||||
pub fn worker_runtime_dir(worker_name: &str) -> Option<PathBuf> {
|
||||
worker_runtime_dir_from_runtime_dir(runtime_dir(), worker_name)
|
||||
}
|
||||
|
||||
/// `<runtime_dir>/<worker_name>/sock` — Worker's Unix socket path.
|
||||
///
|
||||
/// The `worker` crate's `RuntimeDir::socket_path()` creates the socket inside the
|
||||
/// Worker process. This function **predicts** the path for external callers that
|
||||
/// know the Worker name, such as the TUI attach flow. Both should return the same
|
||||
/// path.
|
||||
pub fn worker_socket_path(worker_name: &str) -> Option<PathBuf> {
|
||||
worker_socket_path_from_runtime_dir(runtime_dir(), worker_name)
|
||||
}
|
||||
|
||||
// ---- internals --------------------------------------------------------------
|
||||
|
||||
fn resolve_config_dir_from_parts(
|
||||
@@ -204,20 +189,6 @@ fn worker_allocation_path_from_runtime_dir(runtime_dir: Option<PathBuf>) -> Opti
|
||||
Some(runtime_dir?.join("workers.json"))
|
||||
}
|
||||
|
||||
fn worker_runtime_dir_from_runtime_dir(
|
||||
runtime_dir: Option<PathBuf>,
|
||||
worker_name: &str,
|
||||
) -> Option<PathBuf> {
|
||||
Some(runtime_dir?.join(worker_name))
|
||||
}
|
||||
|
||||
fn worker_socket_path_from_runtime_dir(
|
||||
runtime_dir: Option<PathBuf>,
|
||||
worker_name: &str,
|
||||
) -> Option<PathBuf> {
|
||||
Some(worker_runtime_dir_from_runtime_dir(runtime_dir, worker_name)?.join("sock"))
|
||||
}
|
||||
|
||||
/// Treats empty environment values as unset. `std::env::var` distinguishes
|
||||
/// `Ok("")` from `Err(NotPresent)`, but path resolution treats both as unset
|
||||
/// for intuitive behavior.
|
||||
@@ -455,16 +426,8 @@ mod tests {
|
||||
PathBuf::from("/sand/sessions")
|
||||
);
|
||||
assert_eq!(
|
||||
worker_allocation_path_from_runtime_dir(runtime_dir.clone()).unwrap(),
|
||||
worker_allocation_path_from_runtime_dir(runtime_dir).unwrap(),
|
||||
PathBuf::from("/sand/run/workers.json")
|
||||
);
|
||||
assert_eq!(
|
||||
worker_runtime_dir_from_runtime_dir(runtime_dir.clone(), "foo").unwrap(),
|
||||
PathBuf::from("/sand/run/foo")
|
||||
);
|
||||
assert_eq!(
|
||||
worker_socket_path_from_runtime_dir(runtime_dir, "foo").unwrap(),
|
||||
PathBuf::from("/sand/run/foo/sock")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+936
-483
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user