Compare commits
469
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 | ||
|
|
63d581914c | ||
|
|
da378fc902 | ||
|
|
8611078058 | ||
|
|
26ab6130e0 | ||
|
|
a82e70047a | ||
|
|
12d1588c05 | ||
|
|
523bade3a6 | ||
|
|
e436b2ae58 | ||
|
|
05ba6a43ad | ||
|
|
4d98119440 | ||
|
|
c6d73e8286 | ||
|
|
5873f25073 | ||
|
|
ebce7ab7b1 | ||
|
|
1a065db0fc | ||
|
|
6d8c49b72d | ||
|
|
925bce2a40 | ||
|
|
b63bdb4402 | ||
|
|
dbcea6c34b | ||
|
|
27b6c688e4 | ||
|
|
a2487b575d | ||
|
|
b10b731531 | ||
|
|
5c5727e3f8 | ||
|
|
c0223a0dea | ||
|
|
d3fcfb2977 | ||
|
|
66bb9d0b82 | ||
|
|
49bfbfb235 | ||
|
|
f597f1b57a | ||
|
|
df9d056d42 | ||
|
|
f3d4aba54e | ||
|
|
9b0d42fdbc | ||
|
|
61533fadb6 | ||
|
|
b1076f1d65 | ||
|
|
56795fb92c | ||
|
|
26434bb867 | ||
|
|
a6a97bca74 | ||
|
|
27bf74a051 | ||
|
|
96e5ceb4fb | ||
|
|
cc9177728d | ||
|
|
ae26cdfbef | ||
|
|
45d8f4d69f | ||
|
|
21df210a7f | ||
|
|
2a55bef036 | ||
|
|
e19742d509 | ||
|
|
2b1f68a3ea | ||
|
|
74d0af93e3 | ||
|
|
520a898bd8 | ||
|
|
9e1de93a04 | ||
|
|
98023b9872 | ||
|
|
7f03db9d79 | ||
|
|
4dd5207d1e | ||
|
|
a558c7656a | ||
|
|
b1fe562e15 | ||
|
|
ba3ebaa68c | ||
|
|
71ab4c06b9 | ||
|
|
e9397fa0dd | ||
|
|
298f8c1330 |
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" }
|
||||
|
||||
@@ -37,6 +37,7 @@ chrono = { version = "0.4", default-features = false, features = ["serde", "cloc
|
||||
agen-macros = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
tempfile = { workspace = true }
|
||||
dotenv = "0.15"
|
||||
|
||||
+247
-178
@@ -88,6 +88,63 @@ pub enum EngineError {
|
||||
ToolAttemptFence(String),
|
||||
}
|
||||
|
||||
/// Policy completion is not permission to start work when an interruption was
|
||||
/// queued during its final poll. Cancel wins when both requests are observed.
|
||||
fn check_policy_interruption(
|
||||
cancel_rx: &mut mpsc::Receiver<()>,
|
||||
pause_rx: &mut mpsc::Receiver<()>,
|
||||
) -> Result<(), EngineError> {
|
||||
if cancel_rx.try_recv().is_ok() {
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
if pause_rx.try_recv().is_ok() {
|
||||
return Err(EngineError::PauseRequested);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll one policy invocation, never a second copy of its side effects. Dropping
|
||||
/// a pending policy stops admission, not any already-owned tool execution; the
|
||||
/// caller must drain that execution through its original attempt fence.
|
||||
async fn await_tool_policy<T>(
|
||||
policy: impl Future<Output = Result<T, InterceptorError>>,
|
||||
phase: InterceptorPhase,
|
||||
cancel_rx: &mut mpsc::Receiver<()>,
|
||||
pause_rx: &mut mpsc::Receiver<()>,
|
||||
) -> Result<T, EngineError> {
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
Some(()) = cancel_rx.recv() => return Err(EngineError::Cancelled),
|
||||
Some(()) = pause_rx.recv() => return Err(EngineError::PauseRequested),
|
||||
result = policy => result,
|
||||
};
|
||||
check_policy_interruption(cancel_rx, pause_rx)?;
|
||||
result.map_err(|error| InterceptorFailure::new(phase, error).into())
|
||||
}
|
||||
|
||||
/// A consumed Pause still bounds post-policy processing while owned tools drain.
|
||||
/// Terminal history and publication happen before this wait, so expiring it
|
||||
/// cannot discard the result or reopen the execution attempt.
|
||||
#[derive(Clone, Copy)]
|
||||
enum PostToolPolicyWait {
|
||||
Skip,
|
||||
Interruptible,
|
||||
UntilPauseSafeBoundary(TokioInstant),
|
||||
}
|
||||
|
||||
impl PostToolPolicyWait {
|
||||
fn for_pause_deadline(deadline: Option<TokioInstant>) -> Self {
|
||||
deadline.map_or(Self::Interruptible, Self::UntilPauseSafeBoundary)
|
||||
}
|
||||
|
||||
fn pause_deadline(self) -> Option<TokioInstant> {
|
||||
match self {
|
||||
Self::UntilPauseSafeBoundary(deadline) => Some(deadline),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool registration error
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ToolRegistryError {
|
||||
@@ -770,14 +827,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
}
|
||||
}
|
||||
|
||||
fn try_paused(&mut self) -> bool {
|
||||
use tokio::sync::mpsc::error::TryRecvError;
|
||||
match self.pause_rx.try_recv() {
|
||||
Ok(()) => true,
|
||||
Err(TryRecvError::Empty | TryRecvError::Disconnected) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a text block observer with scoped callbacks.
|
||||
///
|
||||
/// The setup closure is called once per text block. Inside it, register
|
||||
@@ -975,10 +1024,10 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
|
||||
/// Register a callback invoked once per completed tool execution.
|
||||
///
|
||||
/// Fired after `post_tool_call` interceptors and any `content`
|
||||
/// truncation from `tool_output_limits`, so the callback observes
|
||||
/// exactly what is persisted to history. Intended for callers that need
|
||||
/// to forward tool results to clients.
|
||||
/// Fired after `content` truncation from `tool_output_limits` and terminal
|
||||
/// history commit, before `post_tool_call` policy. The callback observes
|
||||
/// exactly what is persisted even if that policy is interrupted. Intended
|
||||
/// for callers that need to forward tool results to clients.
|
||||
pub fn on_tool_result(&mut self, callback: impl Fn(&ToolResult) + Send + Sync + 'static) {
|
||||
self.tool_result_cbs.push(Box::new(callback));
|
||||
}
|
||||
@@ -1431,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,
|
||||
@@ -1447,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());
|
||||
@@ -1552,43 +1592,24 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
tool,
|
||||
context,
|
||||
};
|
||||
let action = tokio::select! {
|
||||
result = self.interceptor.pre_tool_call(&mut info) => {
|
||||
result.map_err(|error| {
|
||||
EngineError::from(InterceptorFailure::new(
|
||||
InterceptorPhase::PreToolCall,
|
||||
error,
|
||||
))
|
||||
})?
|
||||
}
|
||||
pause = self.pause_rx.recv() => {
|
||||
if pause.is_some() {
|
||||
info!(call_id = %expected_tool_use_id, "Paused while awaiting pre-tool policy");
|
||||
}
|
||||
self.append_early_tool_call(
|
||||
history,
|
||||
annotate,
|
||||
&tool_call,
|
||||
call_index,
|
||||
None,
|
||||
)?;
|
||||
let action = await_tool_policy(
|
||||
self.interceptor.pre_tool_call(&mut info),
|
||||
InterceptorPhase::PreToolCall,
|
||||
&mut self.cancel_rx,
|
||||
&mut self.pause_rx,
|
||||
)
|
||||
.await;
|
||||
let action = match action {
|
||||
Ok(action) => action,
|
||||
Err(error @ (EngineError::PauseRequested | EngineError::Cancelled)) => {
|
||||
self.append_early_tool_call(history, annotate, &tool_call, call_index, None)?;
|
||||
batch.dispatched_any = true;
|
||||
return Ok(EarlyToolAdmission::Pause);
|
||||
}
|
||||
cancel = self.cancel_rx.recv() => {
|
||||
if cancel.is_some() {
|
||||
info!(call_id = %expected_tool_use_id, "Cancelled while awaiting pre-tool policy");
|
||||
}
|
||||
self.append_early_tool_call(
|
||||
history,
|
||||
annotate,
|
||||
&tool_call,
|
||||
call_index,
|
||||
None,
|
||||
)?;
|
||||
batch.dispatched_any = true;
|
||||
return Err(EngineError::Cancelled);
|
||||
return match error {
|
||||
EngineError::PauseRequested => Ok(EarlyToolAdmission::Pause),
|
||||
_ => Err(error),
|
||||
};
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if info.call.id != expected_tool_use_id {
|
||||
return Err(InterceptorFailure::new(
|
||||
@@ -1609,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()),
|
||||
@@ -1617,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());
|
||||
@@ -1665,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",
|
||||
@@ -1700,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}"),
|
||||
@@ -1715,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);
|
||||
}
|
||||
@@ -1737,7 +1743,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
None,
|
||||
batch.turn_id,
|
||||
true,
|
||||
PostToolPolicyWait::Interruptible,
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
@@ -1764,7 +1770,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
None,
|
||||
batch.turn_id,
|
||||
false,
|
||||
PostToolPolicyWait::Skip,
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
@@ -1812,7 +1818,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
Some(&attempt_id),
|
||||
batch.turn_id,
|
||||
false,
|
||||
PostToolPolicyWait::Skip,
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
@@ -1835,7 +1841,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
ToolResult::outcome_unknown(call_id),
|
||||
Some(attempt_id),
|
||||
batch.turn_id,
|
||||
false,
|
||||
PostToolPolicyWait::Skip,
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
@@ -1858,28 +1864,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
force_cancel: bool,
|
||||
initial_pause_requested: bool,
|
||||
) -> Result<ToolExecutionResult, EngineError> {
|
||||
while let Some(result) = batch.synthetic_results.pop_front() {
|
||||
if let Err(error) = self
|
||||
.finalize_and_commit_tool_result(
|
||||
history,
|
||||
annotate,
|
||||
result,
|
||||
None,
|
||||
batch.turn_id,
|
||||
true,
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let cleanup_error = self
|
||||
.cancel_and_terminalize_early_batch(history, annotate, &mut batch)
|
||||
.await;
|
||||
return Err(cleanup_error.unwrap_or(error));
|
||||
}
|
||||
}
|
||||
|
||||
if force_cancel {
|
||||
if let Some(error) = self
|
||||
.cancel_and_terminalize_early_batch(history, annotate, &mut batch)
|
||||
@@ -1893,6 +1877,28 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
let mut pause_requested = initial_pause_requested;
|
||||
let mut pause_deadline = initial_pause_requested
|
||||
.then(|| TokioInstant::now() + self.tool_execution_policy.pause_safe_boundary_timeout);
|
||||
while let Some(result) = batch.synthetic_results.pop_front() {
|
||||
if let Err(error) = self
|
||||
.finalize_and_commit_tool_result(
|
||||
history,
|
||||
annotate,
|
||||
result,
|
||||
None,
|
||||
batch.turn_id,
|
||||
PostToolPolicyWait::for_pause_deadline(pause_deadline),
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let cleanup_error = self
|
||||
.cancel_and_terminalize_early_batch(history, annotate, &mut batch)
|
||||
.await;
|
||||
return Err(cleanup_error.unwrap_or(error));
|
||||
}
|
||||
}
|
||||
|
||||
while !batch.futures.is_empty() {
|
||||
tokio::select! {
|
||||
biased;
|
||||
@@ -1904,7 +1910,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
Some(&attempt_id),
|
||||
batch.turn_id,
|
||||
true,
|
||||
PostToolPolicyWait::for_pause_deadline(pause_deadline),
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
@@ -1966,8 +1972,10 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
|
||||
// A pause observed before provider ownership starts leaves every call
|
||||
// NotStarted and therefore eligible for an explicit later retry.
|
||||
if self.try_paused() {
|
||||
return Ok(ToolExecutionResult::Paused);
|
||||
match check_policy_interruption(&mut self.cancel_rx, &mut self.pause_rx) {
|
||||
Ok(()) => {}
|
||||
Err(EngineError::PauseRequested) => return Ok(ToolExecutionResult::Paused),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
|
||||
// Map from tool call ID to (ToolCall, Meta, Tool, Context)
|
||||
@@ -1978,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(
|
||||
@@ -2006,16 +2028,18 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
context,
|
||||
};
|
||||
|
||||
let pre_tool_action =
|
||||
self.interceptor
|
||||
.pre_tool_call(&mut info)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
EngineError::from(InterceptorFailure::new(
|
||||
InterceptorPhase::PreToolCall,
|
||||
error,
|
||||
))
|
||||
})?;
|
||||
let pre_tool_action = match await_tool_policy(
|
||||
self.interceptor.pre_tool_call(&mut info),
|
||||
InterceptorPhase::PreToolCall,
|
||||
&mut self.cancel_rx,
|
||||
&mut self.pause_rx,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(action) => action,
|
||||
Err(EngineError::PauseRequested) => return Ok(ToolExecutionResult::Paused),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if info.call.id != expected_tool_use_id {
|
||||
return Err(InterceptorFailure::new(
|
||||
InterceptorPhase::PreToolCall,
|
||||
@@ -2045,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),
|
||||
@@ -2064,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(),
|
||||
@@ -2077,11 +2103,20 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
// Recheck after all policies and immediately before provider ownership.
|
||||
// No tool task exists yet: these calls remain proven NotStarted.
|
||||
match check_policy_interruption(&mut self.cancel_rx, &mut self.pause_rx) {
|
||||
Ok(()) => {}
|
||||
Err(EngineError::PauseRequested) => return Ok(ToolExecutionResult::Paused),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
|
||||
// Phase 2: Execute approved tools in parallel. FuturesUnordered yields
|
||||
// each terminal result as soon as that call completes instead of
|
||||
// holding fast siblings behind the slowest call in the batch.
|
||||
@@ -2158,7 +2193,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
None,
|
||||
turn_id,
|
||||
true,
|
||||
PostToolPolicyWait::Interruptible,
|
||||
&call_info_map,
|
||||
&mut attempt_fence,
|
||||
&mut terminal_call_ids,
|
||||
@@ -2190,7 +2225,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
Some(&attempt_id),
|
||||
turn_id,
|
||||
true,
|
||||
PostToolPolicyWait::for_pause_deadline(pause_deadline),
|
||||
&call_info_map,
|
||||
&mut attempt_fence,
|
||||
&mut terminal_call_ids,
|
||||
@@ -2271,7 +2306,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
Some(&attempt_id),
|
||||
turn_id,
|
||||
false,
|
||||
PostToolPolicyWait::Skip,
|
||||
&call_info_map,
|
||||
&mut attempt_fence,
|
||||
&mut terminal_call_ids,
|
||||
@@ -2299,7 +2334,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
ToolResult::outcome_unknown(call_id),
|
||||
Some(attempt_id),
|
||||
turn_id,
|
||||
false,
|
||||
PostToolPolicyWait::Skip,
|
||||
&call_info_map,
|
||||
&mut attempt_fence,
|
||||
&mut terminal_call_ids,
|
||||
@@ -2340,8 +2375,8 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply post-execution policy, bound the model-visible payload, durably
|
||||
/// append one terminal ToolResult, and only then publish it to observers.
|
||||
/// Bound the model-visible payload, durably append one terminal ToolResult,
|
||||
/// publish it, then await interruptible post-execution policy.
|
||||
async fn finalize_and_commit_tool_result(
|
||||
&mut self,
|
||||
history: &mut History<A>,
|
||||
@@ -2349,7 +2384,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
mut tool_result: ToolResult,
|
||||
execution_attempt_id: Option<&str>,
|
||||
turn_id: usize,
|
||||
invoke_post_hook: bool,
|
||||
post_policy_wait: PostToolPolicyWait,
|
||||
call_info_map: &HashMap<
|
||||
String,
|
||||
(
|
||||
@@ -2434,7 +2469,9 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
);
|
||||
self.emit_tool_result(&tool_result);
|
||||
|
||||
if invoke_post_hook && let Some((tool_call, meta, tool, context)) = call_info {
|
||||
if !matches!(post_policy_wait, PostToolPolicyWait::Skip)
|
||||
&& let Some((tool_call, meta, tool, context)) = call_info
|
||||
{
|
||||
let invocation = self.interceptor_invocation(
|
||||
InterceptorPhase::PostToolCall,
|
||||
Some(turn_id),
|
||||
@@ -2450,27 +2487,20 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
tool: tool.clone(),
|
||||
context: context.clone(),
|
||||
};
|
||||
let pause_deadline = post_policy_wait.pause_deadline();
|
||||
let post_tool_action = tokio::select! {
|
||||
result = self.interceptor.post_tool_call(&info) => {
|
||||
result.map_err(|error| {
|
||||
EngineError::from(InterceptorFailure::new(
|
||||
InterceptorPhase::PostToolCall,
|
||||
error,
|
||||
))
|
||||
})?
|
||||
}
|
||||
pause = self.pause_rx.recv() => {
|
||||
if pause.is_some() {
|
||||
info!(call_id = %tool_call.id, "Paused while awaiting post-tool policy");
|
||||
}
|
||||
biased;
|
||||
// Poll the existing safe-boundary deadline even when the Pause
|
||||
// that established it was consumed by an outer policy gate.
|
||||
_ = tokio::time::sleep_until(pause_deadline.unwrap_or_else(TokioInstant::now)), if pause_deadline.is_some() => {
|
||||
return Err(EngineError::PauseRequested);
|
||||
}
|
||||
cancel = self.cancel_rx.recv() => {
|
||||
if cancel.is_some() {
|
||||
info!(call_id = %tool_call.id, "Cancelled while awaiting post-tool policy");
|
||||
}
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
result = await_tool_policy(
|
||||
self.interceptor.post_tool_call(&info),
|
||||
InterceptorPhase::PostToolCall,
|
||||
&mut self.cancel_rx,
|
||||
&mut self.pause_rx,
|
||||
) => result?,
|
||||
};
|
||||
if let PostToolAction::Abort(reason) = post_tool_action {
|
||||
return Err(EngineError::Aborted(reason));
|
||||
@@ -2508,7 +2538,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
ToolResult::outcome_unknown(&call_id),
|
||||
None,
|
||||
self.turn_count,
|
||||
false,
|
||||
PostToolPolicyWait::Skip,
|
||||
&call_info_map,
|
||||
&mut attempt_fence,
|
||||
&mut terminal_call_ids,
|
||||
@@ -2970,22 +3000,29 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
)),
|
||||
0,
|
||||
);
|
||||
let assistant_turn_action = self
|
||||
.interceptor
|
||||
.on_assistant_turn_end(AssistantTurnEndContext {
|
||||
invocation: assistant_invocation,
|
||||
assistant_entries: &history.entries()[assistant_start..],
|
||||
history: history.entries(),
|
||||
tool_calls: &tool_calls,
|
||||
})
|
||||
.await;
|
||||
let assistant_turn_action = await_tool_policy(
|
||||
self.interceptor
|
||||
.on_assistant_turn_end(AssistantTurnEndContext {
|
||||
invocation: assistant_invocation,
|
||||
assistant_entries: &history.entries()[assistant_start..],
|
||||
history: history.entries(),
|
||||
tool_calls: &tool_calls,
|
||||
}),
|
||||
InterceptorPhase::AssistantTurnEnd,
|
||||
&mut self.cancel_rx,
|
||||
&mut self.pause_rx,
|
||||
)
|
||||
.await;
|
||||
let assistant_turn_action = match assistant_turn_action {
|
||||
Ok(action) => action,
|
||||
Err(EngineError::PauseRequested) => {
|
||||
if let Some(batch) = early_tools.take() {
|
||||
self.drain_early_tool_batch(history, annotate, batch, false, true)
|
||||
.await?;
|
||||
}
|
||||
return Ok(EngineResult::Paused);
|
||||
}
|
||||
Err(error) => {
|
||||
let error = EngineError::from(InterceptorFailure::new(
|
||||
InterceptorPhase::AssistantTurnEnd,
|
||||
error,
|
||||
));
|
||||
if let Some(batch) = early_tools.take()
|
||||
&& let Err(cleanup_error) = self
|
||||
.drain_early_tool_batch(history, annotate, batch, true, false)
|
||||
@@ -3551,7 +3588,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
Some(&attempt_id),
|
||||
batch.turn_id,
|
||||
true,
|
||||
PostToolPolicyWait::Interruptible,
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
@@ -4289,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::*;
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
//! keeping host-specific policy out of the Engine. Additional model input must go
|
||||
//! through history appends: inserting new facts only into an in-flight request
|
||||
//! would make its input impossible to reconstruct from stored history.
|
||||
//!
|
||||
//! Tool pre/post policy and assistant-turn-end waits are interruptible by Cancel or Pause;
|
||||
//! Cancel wins when both are observed. Pending policy futures may be dropped, not replayed, so
|
||||
//! hosts must not depend on their completion for cleanup of already-owned effects. The Engine
|
||||
//! drains owned tool attempts through their original fences. Terminal tool history and result
|
||||
//! publication precede post-tool policy; interruption cannot erase that result. Pause draining
|
||||
//! bounds post-tool waits by the existing safe-boundary deadline.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -302,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,
|
||||
}
|
||||
|
||||
@@ -317,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,
|
||||
}
|
||||
|
||||
@@ -337,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 {
|
||||
|
||||
@@ -106,15 +106,23 @@ pub async fn load(path: &Path) -> Result<AuthSnapshot, CodexAuthError> {
|
||||
/// Reload the existing file, update `tokens.{id_token,access_token,refresh_token}`
|
||||
/// and `last_refresh`, and write it back. Equivalent to Codex CLI's `persist_tokens`.
|
||||
///
|
||||
/// Reload and merge before writing to preserve fields if a concurrent
|
||||
/// Codex CLI / another process has already refreshed.
|
||||
/// Reload and merge before writing to preserve fields updated by a concurrent
|
||||
/// Codex CLI / another process. Account and refresh token must still match the
|
||||
/// request's input: merge unrelated fields, but return the current snapshot
|
||||
/// without writing if another login or token rotation is observed.
|
||||
pub async fn persist_refreshed(
|
||||
path: &Path,
|
||||
refresh_input: &AuthSnapshot,
|
||||
new_id_token: Option<String>,
|
||||
new_access_token: Option<String>,
|
||||
new_refresh_token: Option<String>,
|
||||
) -> Result<AuthSnapshot, CodexAuthError> {
|
||||
let mut current = load(path).await?;
|
||||
if current.account_id != refresh_input.account_id
|
||||
|| current.refresh_token != refresh_input.refresh_token
|
||||
{
|
||||
return Ok(current);
|
||||
}
|
||||
let raw = &mut current.raw;
|
||||
let tokens = raw
|
||||
.get_mut("tokens")
|
||||
@@ -243,10 +251,16 @@ mod tests {
|
||||
"agent_identity":{"workspace_id":"w","agent_runtime_id":"r","agent_private_key":"k","registered_at":"x"}
|
||||
}"#,
|
||||
);
|
||||
let updated =
|
||||
persist_refreshed(&path, None, Some("new-acc".into()), Some("new-ref".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
let input = load(&path).await.unwrap();
|
||||
let updated = persist_refreshed(
|
||||
&path,
|
||||
&input,
|
||||
None,
|
||||
Some("new-acc".into()),
|
||||
Some("new-ref".into()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updated.access_token, "new-acc");
|
||||
assert_eq!(updated.refresh_token, "new-ref");
|
||||
// The unknown field agent_identity is preserved
|
||||
@@ -266,7 +280,8 @@ mod tests {
|
||||
);
|
||||
// Change the existing file to 644, then persist → check that it returns to 600
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
persist_refreshed(&path, None, Some("a2".into()), None)
|
||||
let input = load(&path).await.unwrap();
|
||||
persist_refreshed(&path, &input, None, Some("a2".into()), None)
|
||||
.await
|
||||
.unwrap();
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
|
||||
@@ -17,6 +17,8 @@ mod auth_json;
|
||||
mod error;
|
||||
mod jwt;
|
||||
mod refresh;
|
||||
#[cfg(test)]
|
||||
mod refresh_tests;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -40,6 +42,9 @@ pub struct CodexAuthProvider {
|
||||
auth_path: PathBuf,
|
||||
refresh_endpoint: String,
|
||||
http: reqwest::Client,
|
||||
refresh_limits: refresh::RefreshLimits,
|
||||
#[cfg(test)]
|
||||
before_guarded_reload: Option<Arc<tokio::sync::Barrier>>,
|
||||
state: Arc<Mutex<State>>,
|
||||
}
|
||||
|
||||
@@ -76,6 +81,9 @@ impl CodexAuthProvider {
|
||||
auth_path: codex_home.join("auth.json"),
|
||||
refresh_endpoint: refresh::REFRESH_URL.to_string(),
|
||||
http: reqwest::Client::new(),
|
||||
refresh_limits: refresh::RefreshLimits::default(),
|
||||
#[cfg(test)]
|
||||
before_guarded_reload: None,
|
||||
state: Arc::new(Mutex::new(State::default())),
|
||||
}
|
||||
}
|
||||
@@ -106,8 +114,13 @@ impl CodexAuthProvider {
|
||||
return Ok(snap);
|
||||
}
|
||||
|
||||
// 3. Reload immediately before refreshing and check that account_id matches (guarded reload).
|
||||
// If it differs, another process has already updated it → skip refreshing
|
||||
// 3. Reload immediately before refreshing (guarded reload).
|
||||
// Skip refresh if another process supplied fresh tokens or changed account_id.
|
||||
#[cfg(test)]
|
||||
if let Some(barrier) = &self.before_guarded_reload {
|
||||
barrier.wait().await;
|
||||
barrier.wait().await;
|
||||
}
|
||||
let pre_refresh = auth_json::load(&self.auth_path).await?;
|
||||
if !is_stale(&pre_refresh) {
|
||||
state.cached = Some(pre_refresh.clone());
|
||||
@@ -124,12 +137,14 @@ impl CodexAuthProvider {
|
||||
&self.http,
|
||||
&self.refresh_endpoint,
|
||||
&pre_refresh.refresh_token,
|
||||
&self.refresh_limits,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 5. Write back (persist_refreshed reloads and merges before writing)
|
||||
let new_snap = auth_json::persist_refreshed(
|
||||
&self.auth_path,
|
||||
&pre_refresh,
|
||||
refreshed.id_token,
|
||||
refreshed.access_token,
|
||||
refreshed.refresh_token,
|
||||
@@ -210,13 +225,18 @@ mod tests {
|
||||
use wiremock::matchers::{method, path as url_path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn make_jwt(payload: serde_json::Value) -> String {
|
||||
pub(super) fn make_jwt(payload: serde_json::Value) -> String {
|
||||
let p = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.encode(serde_json::to_vec(&payload).unwrap());
|
||||
format!("h.{p}.s")
|
||||
}
|
||||
|
||||
fn write_auth(dir: &std::path::Path, exp: i64, fedramp: bool, refresh: &str) -> PathBuf {
|
||||
pub(super) fn write_auth(
|
||||
dir: &std::path::Path,
|
||||
exp: i64,
|
||||
fedramp: bool,
|
||||
refresh: &str,
|
||||
) -> PathBuf {
|
||||
let id_token = make_jwt(json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "acc-xyz",
|
||||
|
||||
@@ -10,7 +10,26 @@ use super::error::{CodexAuthError, PermanentReason};
|
||||
|
||||
pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
pub const REFRESH_URL: &str = "https://auth.openai.com/oauth/token";
|
||||
pub const DEFAULT_REFRESH_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// Limits cover send, both response bodies, and decode, not auth file I/O.
|
||||
/// OAuth responses contain only a few tokens; 64 KiB leaves ample JWT headroom.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct RefreshLimits {
|
||||
pub timeout: Duration,
|
||||
pub max_body_bytes: usize,
|
||||
#[cfg(test)]
|
||||
pub body_started: Option<std::sync::Arc<tokio::sync::Notify>>,
|
||||
}
|
||||
|
||||
impl Default for RefreshLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
timeout: Duration::from_secs(30),
|
||||
max_body_bytes: 64 * 1024,
|
||||
#[cfg(test)]
|
||||
body_started: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RefreshRequest<'a> {
|
||||
@@ -37,54 +56,94 @@ pub async fn request_refresh(
|
||||
client: &reqwest::Client,
|
||||
endpoint: &str,
|
||||
refresh_token: &str,
|
||||
limits: &RefreshLimits,
|
||||
) -> Result<RefreshResponse, CodexAuthError> {
|
||||
let deadline = tokio::time::Instant::now() + limits.timeout;
|
||||
let result = tokio::time::timeout_at(
|
||||
deadline,
|
||||
request_refresh_bounded(client, endpoint, refresh_token, limits),
|
||||
)
|
||||
.await;
|
||||
// JSON decode is synchronous. The byte bound limits its work, and this
|
||||
// post-check prevents a decode that crosses the deadline from being persisted.
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(CodexAuthError::RefreshTransient(
|
||||
"oauth_token_refresh timed out".into(),
|
||||
));
|
||||
}
|
||||
result.map_err(|_| CodexAuthError::RefreshTransient("oauth_token_refresh timed out".into()))?
|
||||
}
|
||||
|
||||
async fn request_refresh_bounded(
|
||||
client: &reqwest::Client,
|
||||
endpoint: &str,
|
||||
refresh_token: &str,
|
||||
limits: &RefreshLimits,
|
||||
) -> Result<RefreshResponse, CodexAuthError> {
|
||||
let body = RefreshRequest {
|
||||
client_id: CLIENT_ID,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token,
|
||||
};
|
||||
let response = response_with_timeout(
|
||||
client
|
||||
.post(endpoint)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send(),
|
||||
DEFAULT_REFRESH_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
let mut response = client
|
||||
.post(endpoint)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| CodexAuthError::RefreshTransient("send failed".into()))?;
|
||||
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
response
|
||||
.json::<RefreshResponse>()
|
||||
.await
|
||||
.map_err(|e| CodexAuthError::RefreshTransient(format!("parse response: {e}")))
|
||||
} else {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
let (reason, message) = classify_permanent(&body);
|
||||
Err(CodexAuthError::RefreshPermanent { reason, message })
|
||||
} else {
|
||||
Err(CodexAuthError::RefreshTransient(format!(
|
||||
"{status}: {body}"
|
||||
)))
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|len| len > limits.max_body_bytes as u64)
|
||||
{
|
||||
return Err(body_too_large());
|
||||
}
|
||||
#[cfg(test)]
|
||||
if let Some(started) = &limits.body_started {
|
||||
started.notify_one();
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = response
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|_| CodexAuthError::RefreshTransient("read response body failed".into()))?
|
||||
{
|
||||
// Content-Length can be absent or untrustworthy. Bound actual bytes too,
|
||||
// before copying a chunk into the accumulated body.
|
||||
if chunk.len() > limits.max_body_bytes.saturating_sub(bytes.len()) {
|
||||
return Err(body_too_large());
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
if status.is_success() {
|
||||
let tokens: RefreshResponse = serde_json::from_slice(&bytes).map_err(|_| {
|
||||
// Do not expose untrusted body values (which can contain secrets).
|
||||
CodexAuthError::RefreshTransient("decode response failed".into())
|
||||
})?;
|
||||
if tokens.access_token.as_deref().is_none_or(str::is_empty)
|
||||
|| tokens.id_token.as_deref().is_some_and(str::is_empty)
|
||||
|| tokens.refresh_token.as_deref().is_some_and(str::is_empty)
|
||||
{
|
||||
return Err(CodexAuthError::RefreshTransient(
|
||||
"incomplete token response".into(),
|
||||
));
|
||||
}
|
||||
Ok(tokens)
|
||||
} else if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
let (reason, message) = classify_permanent(&String::from_utf8_lossy(&bytes));
|
||||
Err(CodexAuthError::RefreshPermanent { reason, message })
|
||||
} else {
|
||||
// Error bodies are consumed within the same limits but never echoed.
|
||||
Err(CodexAuthError::RefreshTransient(format!(
|
||||
"refresh endpoint returned {status}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
async fn response_with_timeout(
|
||||
future: impl std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
|
||||
timeout: Duration,
|
||||
) -> Result<reqwest::Response, CodexAuthError> {
|
||||
tokio::time::timeout(timeout, future)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
CodexAuthError::RefreshTransient(format!(
|
||||
"oauth_token_refresh timed out after {}s",
|
||||
timeout.as_secs()
|
||||
))
|
||||
})?
|
||||
.map_err(|e| CodexAuthError::RefreshTransient(format!("send: {e}")))
|
||||
fn body_too_large() -> CodexAuthError {
|
||||
CodexAuthError::RefreshTransient("response body exceeds size limit".into())
|
||||
}
|
||||
|
||||
fn classify_permanent(body: &str) -> (PermanentReason, String) {
|
||||
@@ -99,7 +158,7 @@ fn classify_permanent(body: &str) -> (PermanentReason, String) {
|
||||
PermanentReason::Expired => "Your refresh token has expired".to_string(),
|
||||
PermanentReason::Reused => "Your refresh token was already used".to_string(),
|
||||
PermanentReason::Revoked => "Your refresh token was revoked".to_string(),
|
||||
PermanentReason::Other => format!("Unknown 401 from refresh endpoint: {body}"),
|
||||
PermanentReason::Other => "Unknown 401 from refresh endpoint".to_string(),
|
||||
};
|
||||
(reason, message)
|
||||
}
|
||||
@@ -126,23 +185,6 @@ fn extract_error_code(body: &str) -> Option<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_response_timeout_is_transient() {
|
||||
let err = match response_with_timeout(
|
||||
std::future::pending::<Result<reqwest::Response, reqwest::Error>>(),
|
||||
Duration::from_millis(5),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("expected refresh timeout"),
|
||||
Err(err) => err,
|
||||
};
|
||||
|
||||
assert!(
|
||||
matches!(err, CodexAuthError::RefreshTransient(message) if message.contains("timed out"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_expired() {
|
||||
let body = r#"{"error":{"code":"refresh_token_expired"}}"#;
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
//! Real AuthProvider calls against owned auth files and a loopback HTTP peer.
|
||||
//! Gates place callers at response-body / reload boundaries; paused Tokio time
|
||||
//! advances the injected HTTP deadline only after headers have been received.
|
||||
use super::tests::{make_jwt, write_auth};
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
use std::time::Duration as StdDuration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{Notify, mpsc};
|
||||
use tokio::task::{JoinHandle, JoinSet};
|
||||
|
||||
struct Reply {
|
||||
status: u16,
|
||||
body: Vec<u8>,
|
||||
chunked: bool,
|
||||
gate: Option<Arc<Notify>>,
|
||||
truncate: bool,
|
||||
}
|
||||
|
||||
impl Reply {
|
||||
fn body(status: u16, body: impl Into<Vec<u8>>) -> Self {
|
||||
Self {
|
||||
status,
|
||||
body: body.into(),
|
||||
chunked: false,
|
||||
gate: None,
|
||||
truncate: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn gated(status: u16, body: impl Into<Vec<u8>>, gate: &Arc<Notify>) -> Self {
|
||||
Self {
|
||||
gate: Some(gate.clone()),
|
||||
..Self::body(status, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Stub {
|
||||
endpoint: String,
|
||||
requests: mpsc::UnboundedReceiver<Value>,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl Drop for Stub {
|
||||
fn drop(&mut self) {
|
||||
// Dropping the server's JoinSet also aborts stalled connection handlers.
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
impl Stub {
|
||||
async fn start(replies: Vec<Reply>) -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let endpoint = format!("http://{}/oauth/token", listener.local_addr().unwrap());
|
||||
let (tx, requests) = mpsc::unbounded_channel();
|
||||
let task = tokio::spawn(async move {
|
||||
let mut handlers = JoinSet::new();
|
||||
for reply in replies {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let tx = tx.clone();
|
||||
handlers.spawn(async move {
|
||||
let mut request = Vec::new();
|
||||
let header_end = loop {
|
||||
let byte = stream.read_u8().await.unwrap();
|
||||
request.push(byte);
|
||||
if request.ends_with(b"\r\n\r\n") { break request.len(); }
|
||||
assert!(request.len() < 16 * 1024, "fixture request headers too large");
|
||||
};
|
||||
let headers = std::str::from_utf8(&request).unwrap();
|
||||
assert!(headers.starts_with("POST /oauth/token HTTP/1.1\r\n"));
|
||||
let len: usize = headers.lines().find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse().unwrap())
|
||||
}).unwrap();
|
||||
request.resize(header_end + len, 0);
|
||||
stream.read_exact(&mut request[header_end..]).await.unwrap();
|
||||
tx.send(serde_json::from_slice(&request[header_end..]).unwrap()).unwrap();
|
||||
let framing = if reply.chunked {
|
||||
"Transfer-Encoding: chunked".to_string()
|
||||
} else {
|
||||
format!("Content-Length: {}", reply.body.len())
|
||||
};
|
||||
stream.write_all(format!(
|
||||
"HTTP/1.1 {} Fixture\r\nContent-Type: application/json\r\n{framing}\r\nConnection: close\r\n\r\n", reply.status
|
||||
).as_bytes()).await.unwrap();
|
||||
if let Some(gate) = reply.gate { gate.notified().await; }
|
||||
let body = if reply.truncate { &reply.body[..reply.body.len() / 2] } else { &reply.body[..] };
|
||||
if reply.chunked {
|
||||
for chunk in body.chunks(512) {
|
||||
let _ = stream.write_all(format!("{:x}\r\n", chunk.len()).as_bytes()).await;
|
||||
let _ = stream.write_all(chunk).await;
|
||||
let _ = stream.write_all(b"\r\n").await;
|
||||
}
|
||||
let _ = stream.write_all(b"0\r\n\r\n").await;
|
||||
} else {
|
||||
let _ = stream.write_all(body).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
while let Some(result) = handlers.join_next().await {
|
||||
result.unwrap();
|
||||
}
|
||||
});
|
||||
Self {
|
||||
endpoint,
|
||||
requests,
|
||||
task,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rotated_body() -> Vec<u8> {
|
||||
serde_json::to_vec(&json!({
|
||||
"access_token": make_jwt(json!({"exp": Utc::now().timestamp() + 3600})),
|
||||
"refresh_token": "fixture-rotated-secret",
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn provider(dir: &std::path::Path, stub: &Stub, started: &Arc<Notify>) -> Arc<CodexAuthProvider> {
|
||||
let mut provider = CodexAuthProvider::new(dir.to_path_buf())
|
||||
.with_refresh_endpoint(&stub.endpoint)
|
||||
.with_http_client(reqwest::Client::builder().no_proxy().build().unwrap());
|
||||
provider.refresh_limits = refresh::RefreshLimits {
|
||||
timeout: StdDuration::from_secs(10),
|
||||
max_body_bytes: 1024,
|
||||
body_started: Some(started.clone()),
|
||||
};
|
||||
Arc::new(provider)
|
||||
}
|
||||
|
||||
async fn bounded<F: std::future::Future>(future: F) -> F::Output {
|
||||
tokio::time::timeout(StdDuration::from_secs(5), future)
|
||||
.await
|
||||
.expect("fixture/caller did not progress")
|
||||
}
|
||||
|
||||
fn assert_rotated(path: &std::path::Path, headers: &[(HeaderName, HeaderValue)]) {
|
||||
let value: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
|
||||
assert_eq!(value["tokens"]["refresh_token"], "fixture-rotated-secret");
|
||||
assert_eq!(value["fixture_extra"], "preserve me");
|
||||
let auth = headers
|
||||
.iter()
|
||||
.find(|(name, _)| name == "authorization")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
auth.1.to_str().unwrap(),
|
||||
format!(
|
||||
"Bearer {}",
|
||||
value["tokens"]["access_token"].as_str().unwrap()
|
||||
)
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn stale_auth(dir: &std::path::Path) -> PathBuf {
|
||||
let path = write_auth(
|
||||
dir,
|
||||
Utc::now().timestamp() - 60,
|
||||
false,
|
||||
"fixture-old-secret",
|
||||
);
|
||||
let mut value: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
value["fixture_extra"] = json!("preserve me");
|
||||
std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stalled_success_and_error_bodies_timeout_without_persist_and_release_waiting_caller() {
|
||||
for status in [200, 401, 503] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = stale_auth(dir.path());
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
let gate = Arc::new(Notify::new());
|
||||
let started = Arc::new(Notify::new());
|
||||
let mut stub = Stub::start(vec![
|
||||
Reply::gated(status, b"unfinished".to_vec(), &gate),
|
||||
Reply::body(200, rotated_body()),
|
||||
])
|
||||
.await;
|
||||
let provider = provider(dir.path(), &stub, &started);
|
||||
let first = tokio::spawn({
|
||||
let provider = provider.clone();
|
||||
async move { provider.headers().await }
|
||||
});
|
||||
bounded(started.notified()).await; // provider has received headers and is consuming the body
|
||||
let request = bounded(stub.requests.recv()).await.unwrap();
|
||||
assert_eq!(request["refresh_token"], "fixture-old-secret");
|
||||
let second = provider.headers();
|
||||
tokio::pin!(second);
|
||||
assert!(futures::poll!(&mut second).is_pending());
|
||||
assert!(provider.state.try_lock().is_err());
|
||||
assert!(
|
||||
stub.requests.try_recv().is_err(),
|
||||
"waiter must not refresh concurrently"
|
||||
);
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(StdDuration::from_secs(11)).await;
|
||||
let error = first.await.unwrap().unwrap_err();
|
||||
tokio::time::resume();
|
||||
assert!(error.to_string().contains("timed out"), "{error}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
let headers = bounded(second).await.unwrap();
|
||||
assert_rotated(&path, &headers);
|
||||
let retry = bounded(stub.requests.recv()).await.unwrap();
|
||||
assert_eq!(retry["refresh_token"], "fixture-old-secret");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversized_malformed_and_incomplete_bodies_do_not_persist_and_allow_later_refresh() {
|
||||
let mut cases = vec![
|
||||
(Reply::body(200, vec![b'x'; 1025]), "size limit"),
|
||||
(
|
||||
Reply {
|
||||
chunked: true,
|
||||
..Reply::body(200, vec![b'x'; 1025])
|
||||
},
|
||||
"size limit",
|
||||
),
|
||||
(
|
||||
Reply {
|
||||
chunked: true,
|
||||
..Reply::body(401, vec![b'x'; 1025])
|
||||
},
|
||||
"size limit",
|
||||
),
|
||||
(Reply::body(503, vec![b'x'; 1025]), "size limit"),
|
||||
(
|
||||
Reply::body(
|
||||
200,
|
||||
br#"{"access_token": {"secret":"fixture-body-secret"}}"#.to_vec(),
|
||||
),
|
||||
"decode",
|
||||
),
|
||||
(Reply::body(200, b"{".to_vec()), "decode"),
|
||||
(Reply::body(200, b"{}".to_vec()), "incomplete"),
|
||||
(
|
||||
Reply::body(
|
||||
200,
|
||||
br#"{"access_token":"","refresh_token":"fixture-body-secret"}"#.to_vec(),
|
||||
),
|
||||
"incomplete",
|
||||
),
|
||||
(
|
||||
Reply {
|
||||
truncate: true,
|
||||
..Reply::body(200, rotated_body())
|
||||
},
|
||||
"read response body",
|
||||
),
|
||||
(
|
||||
Reply {
|
||||
truncate: true,
|
||||
..Reply::body(401, b"fixture-body-secret".to_vec())
|
||||
},
|
||||
"read response body",
|
||||
),
|
||||
(
|
||||
Reply::body(401, b"fixture-body-secret".to_vec()),
|
||||
"codex login",
|
||||
),
|
||||
(Reply::body(503, b"fixture-body-secret".to_vec()), "503"),
|
||||
];
|
||||
for (reply, expected) in cases.drain(..) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = stale_auth(dir.path());
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
let started = Arc::new(Notify::new());
|
||||
let mut stub = Stub::start(vec![reply, Reply::body(200, rotated_body())]).await;
|
||||
let provider = provider(dir.path(), &stub, &started);
|
||||
let error = bounded(provider.headers()).await.unwrap_err();
|
||||
let message = error.to_string();
|
||||
assert!(message.contains(expected), "expected {expected}: {message}");
|
||||
assert!(!message.contains("fixture-body-secret"));
|
||||
assert!(!message.contains("fixture-old-secret"));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
let headers = bounded(provider.headers()).await.unwrap();
|
||||
assert_rotated(&path, &headers);
|
||||
for _ in 0..2 {
|
||||
assert_eq!(
|
||||
bounded(stub.requests.recv()).await.unwrap()["refresh_token"],
|
||||
"fixture-old-secret"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_body_wait_leaves_auth_unchanged_and_releases_waiting_caller() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = stale_auth(dir.path());
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
let started = Arc::new(Notify::new());
|
||||
let gate = Arc::new(Notify::new());
|
||||
let mut stub = Stub::start(vec![
|
||||
Reply::gated(200, rotated_body(), &gate),
|
||||
Reply::body(200, rotated_body()),
|
||||
])
|
||||
.await;
|
||||
let provider = provider(dir.path(), &stub, &started);
|
||||
let first = tokio::spawn({
|
||||
let provider = provider.clone();
|
||||
async move { provider.headers().await }
|
||||
});
|
||||
bounded(started.notified()).await;
|
||||
bounded(stub.requests.recv()).await.unwrap();
|
||||
let second = provider.headers();
|
||||
tokio::pin!(second);
|
||||
assert!(futures::poll!(&mut second).is_pending());
|
||||
assert!(stub.requests.try_recv().is_err());
|
||||
first.abort();
|
||||
assert!(first.await.unwrap_err().is_cancelled());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
assert_rotated(&path, &bounded(second).await.unwrap());
|
||||
assert_eq!(
|
||||
bounded(stub.requests.recv()).await.unwrap()["refresh_token"],
|
||||
"fixture-old-secret"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_callers_refresh_once_and_use_persisted_rotated_tokens() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = stale_auth(dir.path());
|
||||
let started = Arc::new(Notify::new());
|
||||
let gate = Arc::new(Notify::new());
|
||||
let mut stub = Stub::start(vec![Reply::gated(200, rotated_body(), &gate)]).await;
|
||||
let provider = provider(dir.path(), &stub, &started);
|
||||
let first = tokio::spawn({
|
||||
let provider = provider.clone();
|
||||
async move { provider.headers().await }
|
||||
});
|
||||
bounded(started.notified()).await;
|
||||
let second = provider.headers();
|
||||
tokio::pin!(second);
|
||||
assert!(futures::poll!(&mut second).is_pending());
|
||||
assert_eq!(
|
||||
bounded(stub.requests.recv()).await.unwrap()["refresh_token"],
|
||||
"fixture-old-secret"
|
||||
);
|
||||
let mut external: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
external["concurrent_extra"] = json!("merge me");
|
||||
std::fs::write(&path, serde_json::to_vec(&external).unwrap()).unwrap();
|
||||
gate.notify_one();
|
||||
let first_headers = bounded(first).await.unwrap().unwrap();
|
||||
let second_headers = bounded(second).await.unwrap();
|
||||
assert_eq!(first_headers, second_headers);
|
||||
assert_rotated(&path, &second_headers);
|
||||
let persisted: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(persisted["concurrent_extra"], "merge me");
|
||||
assert!(
|
||||
stub.requests.try_recv().is_err(),
|
||||
"single-flight must make only one request"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_switch_before_guarded_reload_skips_old_account_refresh() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = stale_auth(dir.path());
|
||||
let barrier = Arc::new(tokio::sync::Barrier::new(2));
|
||||
let mut provider = CodexAuthProvider::new(dir.path().to_path_buf());
|
||||
let mut stub = Stub::start(vec![]).await;
|
||||
provider.refresh_endpoint = stub.endpoint.clone();
|
||||
provider.before_guarded_reload = Some(barrier.clone());
|
||||
let task = tokio::spawn(async move { provider.headers().await });
|
||||
bounded(barrier.wait()).await;
|
||||
let mut switched: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
switched["tokens"]["account_id"] = json!("switched-account");
|
||||
switched["tokens"]["refresh_token"] = json!("fixture-switched-secret");
|
||||
std::fs::write(&path, serde_json::to_vec(&switched).unwrap()).unwrap();
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
barrier.wait().await;
|
||||
let headers = bounded(task).await.unwrap().unwrap();
|
||||
assert!(
|
||||
headers
|
||||
.iter()
|
||||
.any(|(name, value)| name == "chatgpt-account-id" && value == "switched-account")
|
||||
);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
assert!(stub.requests.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_or_token_switch_during_refresh_is_not_overwritten_by_old_response() {
|
||||
for account_changed in [false, true] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = stale_auth(dir.path());
|
||||
let started = Arc::new(Notify::new());
|
||||
let gate = Arc::new(Notify::new());
|
||||
let mut stub = Stub::start(vec![Reply::gated(200, rotated_body(), &gate)]).await;
|
||||
let provider = provider(dir.path(), &stub, &started);
|
||||
let task = tokio::spawn({
|
||||
let provider = provider.clone();
|
||||
async move { provider.headers().await }
|
||||
});
|
||||
bounded(started.notified()).await;
|
||||
bounded(stub.requests.recv()).await.unwrap();
|
||||
let mut switched: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
if account_changed {
|
||||
switched["tokens"]["account_id"] = json!("switched-account");
|
||||
} else {
|
||||
switched["tokens"]["refresh_token"] = json!("fixture-switched-secret");
|
||||
}
|
||||
switched["tokens"]["access_token"] =
|
||||
json!(make_jwt(json!({"exp": Utc::now().timestamp() + 7200})));
|
||||
std::fs::write(&path, serde_json::to_vec(&switched).unwrap()).unwrap();
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
gate.notify_one();
|
||||
let headers = bounded(task).await.unwrap().unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
let expected = format!(
|
||||
"Bearer {}",
|
||||
switched["tokens"]["access_token"].as_str().unwrap()
|
||||
);
|
||||
assert!(headers.iter().any(|(name, value)| name == "authorization" && value.to_str().unwrap() == expected));
|
||||
assert_eq!(bounded(provider.headers()).await.unwrap(), headers);
|
||||
assert!(stub.requests.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -2873,3 +2873,737 @@ async fn post_tool_stop_terminalizes_started_parallel_siblings_before_returning(
|
||||
assert!(matches!(next, EngineRunExit::Finished));
|
||||
}
|
||||
}
|
||||
|
||||
// The same policy barrier is exercised through the real response-complete,
|
||||
// provider fallback, interceptor fallback, and early-dispatch engine paths.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum PolicyRoute {
|
||||
AfterResponse,
|
||||
ProviderFallback,
|
||||
InterceptorFallback,
|
||||
Early,
|
||||
}
|
||||
|
||||
impl PolicyRoute {
|
||||
fn completion_support(self) -> ToolCallCompletionSupport {
|
||||
match self {
|
||||
Self::ProviderFallback => ToolCallCompletionSupport::ResponseComplete,
|
||||
_ => ToolCallCompletionSupport::PerBlock,
|
||||
}
|
||||
}
|
||||
|
||||
fn configure(self, engine: &mut Engine<ControlledStreamClient>) {
|
||||
engine.set_max_turns(Some(1));
|
||||
if matches!(self, Self::AfterResponse) {
|
||||
engine.set_tool_call_dispatch_mode(ToolCallDispatchMode::AfterResponse);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NotifyOnDrop(Arc<tokio::sync::Notify>);
|
||||
|
||||
impl Drop for NotifyOnDrop {
|
||||
fn drop(&mut self) {
|
||||
self.0.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PolicyBarrier {
|
||||
phase: InterceptorPhase,
|
||||
early_compatible: bool,
|
||||
entered: Arc<tokio::sync::Notify>,
|
||||
release: Arc<tokio::sync::Notify>,
|
||||
invocations: Arc<AtomicUsize>,
|
||||
continues: Arc<AtomicUsize>,
|
||||
retired: Arc<tokio::sync::Notify>,
|
||||
// Enqueue from the policy's final poll, after select has polled signals.
|
||||
// This deterministically exercises the policy-completion/ownership race.
|
||||
signal_on_continue: Option<tokio::sync::mpsc::Sender<()>>,
|
||||
}
|
||||
|
||||
impl PolicyBarrier {
|
||||
fn new(phase: InterceptorPhase, route: PolicyRoute) -> Self {
|
||||
Self {
|
||||
phase,
|
||||
early_compatible: !matches!(route, PolicyRoute::InterceptorFallback),
|
||||
entered: Arc::new(tokio::sync::Notify::new()),
|
||||
release: Arc::new(tokio::sync::Notify::new()),
|
||||
invocations: Arc::new(AtomicUsize::new(0)),
|
||||
continues: Arc::new(AtomicUsize::new(0)),
|
||||
retired: Arc::new(tokio::sync::Notify::new()),
|
||||
signal_on_continue: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait(&self) {
|
||||
let _retired = NotifyOnDrop(self.retired.clone());
|
||||
self.invocations.fetch_add(1, Ordering::SeqCst);
|
||||
self.entered.notify_one();
|
||||
self.release.notified().await;
|
||||
if let Some(signal) = &self.signal_on_continue {
|
||||
signal.try_send(()).unwrap();
|
||||
}
|
||||
self.continues.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for PolicyBarrier {
|
||||
fn supports_early_tool_dispatch(&self) -> bool {
|
||||
self.early_compatible
|
||||
}
|
||||
|
||||
async fn pre_tool_call(
|
||||
&self,
|
||||
_info: &mut ToolCallInfo<'_>,
|
||||
) -> InterceptorResult<PreToolAction> {
|
||||
if self.phase == InterceptorPhase::PreToolCall {
|
||||
self.wait().await;
|
||||
}
|
||||
Ok(PreToolAction::Continue)
|
||||
}
|
||||
|
||||
async fn on_assistant_turn_end(
|
||||
&self,
|
||||
_context: AssistantTurnEndContext<'_>,
|
||||
) -> InterceptorResult<TurnEndAction> {
|
||||
if self.phase == InterceptorPhase::AssistantTurnEnd {
|
||||
self.wait().await;
|
||||
}
|
||||
Ok(TurnEndAction::Finish)
|
||||
}
|
||||
}
|
||||
|
||||
fn send_policy_response(tx: &tokio::sync::mpsc::UnboundedSender<Result<Event, ClientError>>) {
|
||||
for event in [
|
||||
Event::tool_use_start(0, "policy_call", "policy_tool"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
Event::usage(10, 3),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
] {
|
||||
tx.send(Ok(event)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_policy_interruption(exit: &EngineRunExit, pause: bool) {
|
||||
if pause {
|
||||
assert!(matches!(exit, EngineRunExit::Paused), "{exit:?}");
|
||||
} else {
|
||||
assert!(
|
||||
matches!(
|
||||
exit,
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::Cancelled)
|
||||
),
|
||||
"{exit:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn call_and_result_count(history: &History) -> (usize, usize) {
|
||||
(
|
||||
history
|
||||
.items()
|
||||
.filter(
|
||||
|item| matches!(item, Item::ToolCall { call_id, .. } if call_id == "policy_call"),
|
||||
)
|
||||
.count(),
|
||||
history
|
||||
.items()
|
||||
.filter(
|
||||
|item| matches!(item, Item::ToolResult { call_id, .. } if call_id == "policy_call"),
|
||||
)
|
||||
.count(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_complete_pending_policies_interrupt_before_release_and_preserve_not_started() {
|
||||
for route in [
|
||||
PolicyRoute::AfterResponse,
|
||||
PolicyRoute::ProviderFallback,
|
||||
PolicyRoute::InterceptorFallback,
|
||||
] {
|
||||
for phase in [
|
||||
InterceptorPhase::PreToolCall,
|
||||
InterceptorPhase::AssistantTurnEnd,
|
||||
] {
|
||||
for pause in [false, true] {
|
||||
let (client, tx) = ControlledStreamClient::new(route.completion_support());
|
||||
let mut engine = Engine::new(client);
|
||||
route.configure(&mut engine);
|
||||
let policy = PolicyBarrier::new(phase, route);
|
||||
let probe = policy.clone();
|
||||
engine.set_interceptor(policy);
|
||||
let tool = SlowTool::new("policy_tool", 0);
|
||||
engine.register_tool(tool.definition());
|
||||
let usage = Arc::new(AtomicUsize::new(0));
|
||||
let usage_probe = usage.clone();
|
||||
engine.on_usage(move |_| {
|
||||
usage_probe.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
let signal = if pause {
|
||||
engine.pause_sender()
|
||||
} else {
|
||||
engine.cancel_sender()
|
||||
};
|
||||
let run = tokio::spawn(async move {
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "run").await;
|
||||
(output, history)
|
||||
});
|
||||
send_policy_response(&tx);
|
||||
drop(tx);
|
||||
tokio::time::timeout(Duration::from_secs(2), probe.entered.notified())
|
||||
.await
|
||||
.expect("policy entered");
|
||||
signal.send(()).await.unwrap();
|
||||
let (output, history) = tokio::time::timeout(Duration::from_secs(2), run).await
|
||||
.unwrap_or_else(|_| panic!("{route:?}/{phase:?}/pause={pause}: interruption waited for policy release"))
|
||||
.unwrap();
|
||||
assert_policy_interruption(&output.result, pause);
|
||||
assert_eq!(tool.call_count(), 0);
|
||||
assert_eq!(call_and_result_count(&history), (1, 0));
|
||||
assert!(history.items().any(|item| matches!(
|
||||
item,
|
||||
Item::ToolCall {
|
||||
execution_id: None,
|
||||
status: None | Some(agen::llm_client::ItemStatus::Completed),
|
||||
..
|
||||
}
|
||||
)));
|
||||
assert_eq!(usage.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(probe.invocations.load(Ordering::SeqCst), 1);
|
||||
probe.release.notify_one(); // late permission cannot create a task
|
||||
assert_eq!(probe.continues.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(tool.call_count(), 0);
|
||||
|
||||
// Recreate both history and engine as a restore boundary. Only
|
||||
// proven NotStarted is allowed to run, with normal pre-tool policy.
|
||||
let mut restored_history: History =
|
||||
serde_json::from_str(&serde_json::to_string(&history).unwrap()).unwrap();
|
||||
let mut restored =
|
||||
Engine::new(MockLlmClient::new(vec![Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})]));
|
||||
restored.set_max_turns(Some(1));
|
||||
restored.register_tool(tool.definition());
|
||||
let _ = restored.resume(&mut restored_history).await;
|
||||
assert_eq!(tool.call_count(), 1);
|
||||
assert_eq!(call_and_result_count(&restored_history), (1, 1));
|
||||
assert_eq!(usage.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn policy_final_poll_signal_prevents_provider_ownership_even_with_continue() {
|
||||
for route in [
|
||||
PolicyRoute::AfterResponse,
|
||||
PolicyRoute::ProviderFallback,
|
||||
PolicyRoute::InterceptorFallback,
|
||||
PolicyRoute::Early,
|
||||
] {
|
||||
for pause in [false, true] {
|
||||
let (client, tx) = ControlledStreamClient::new(route.completion_support());
|
||||
let mut engine = Engine::new(client);
|
||||
route.configure(&mut engine);
|
||||
let mut policy = PolicyBarrier::new(InterceptorPhase::PreToolCall, route);
|
||||
policy.signal_on_continue = Some(if pause {
|
||||
engine.pause_sender()
|
||||
} else {
|
||||
engine.cancel_sender()
|
||||
});
|
||||
let probe = policy.clone();
|
||||
engine.set_interceptor(policy);
|
||||
let tool = SlowTool::new("policy_tool", 0);
|
||||
engine.register_tool(tool.definition());
|
||||
let run = tokio::spawn(async move {
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "run").await;
|
||||
(output, history)
|
||||
});
|
||||
send_policy_response(&tx);
|
||||
drop(tx);
|
||||
tokio::time::timeout(Duration::from_secs(2), probe.entered.notified())
|
||||
.await
|
||||
.expect("policy entered");
|
||||
probe.release.notify_one();
|
||||
let (output, history) = tokio::time::timeout(Duration::from_secs(2), run)
|
||||
.await
|
||||
.expect("final-poll signal processed")
|
||||
.unwrap();
|
||||
assert_policy_interruption(&output.result, pause);
|
||||
assert_eq!(probe.continues.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(probe.invocations.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(
|
||||
tool.call_count(),
|
||||
0,
|
||||
"{route:?}/pause={pause}: Continue bypassed queued interruption"
|
||||
);
|
||||
assert_eq!(call_and_result_count(&history), (1, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Provider owns the terminal channel. Cancel acknowledges receipt but does not
|
||||
// manufacture a result, so tests can release a real terminal output or deliver
|
||||
// it after the engine's confirmation deadline (without a live side effect).
|
||||
#[derive(Clone)]
|
||||
struct PolicyOwnedTool {
|
||||
starts: Arc<AtomicUsize>,
|
||||
started: Arc<tokio::sync::Notify>,
|
||||
cancel_requested: Arc<tokio::sync::Notify>,
|
||||
terminal: Arc<tokio::sync::Mutex<Option<tokio::sync::oneshot::Receiver<ToolOutput>>>>,
|
||||
retired: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for PolicyOwnedTool {
|
||||
async fn execute(&self, _: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
|
||||
let _retired = NotifyOnDrop(self.retired.clone());
|
||||
self.starts.fetch_add(1, Ordering::SeqCst);
|
||||
self.started.notify_one();
|
||||
Ok(self.terminal.lock().await.take().unwrap().await.unwrap())
|
||||
}
|
||||
async fn cancel(&self, _: &str) -> Result<(), ToolError> {
|
||||
self.cancel_requested.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_turn_end_interrupts_drain_owned_early_attempt_and_fence_late_result_on_restore() {
|
||||
for pause in [false, true] {
|
||||
for late in [false, true] {
|
||||
let (client, tx) = ControlledStreamClient::new(ToolCallCompletionSupport::PerBlock);
|
||||
let mut engine = Engine::new(client);
|
||||
PolicyRoute::Early.configure(&mut engine);
|
||||
engine.set_tool_execution_policy(ToolExecutionPolicy {
|
||||
pause_safe_boundary_timeout: Duration::ZERO,
|
||||
cancellation_request_timeout: Duration::from_secs(1),
|
||||
terminal_confirmation_timeout: if late {
|
||||
Duration::ZERO
|
||||
} else {
|
||||
Duration::from_secs(2)
|
||||
},
|
||||
});
|
||||
let policy = PolicyBarrier::new(InterceptorPhase::AssistantTurnEnd, PolicyRoute::Early);
|
||||
let probe = policy.clone();
|
||||
engine.set_interceptor(policy);
|
||||
let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel();
|
||||
let tool = PolicyOwnedTool {
|
||||
starts: Arc::new(AtomicUsize::new(0)),
|
||||
started: Arc::new(tokio::sync::Notify::new()),
|
||||
cancel_requested: Arc::new(tokio::sync::Notify::new()),
|
||||
terminal: Arc::new(tokio::sync::Mutex::new(Some(terminal_rx))),
|
||||
retired: Arc::new(tokio::sync::Notify::new()),
|
||||
};
|
||||
let definition: ToolDefinition = {
|
||||
let tool = tool.clone();
|
||||
Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new("policy_tool")
|
||||
.input_schema(serde_json::json!({"type":"object"})),
|
||||
Arc::new(tool.clone()) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
};
|
||||
engine.register_tool(definition.clone());
|
||||
let committed = Arc::new(AtomicUsize::new(0));
|
||||
let commit_probe = committed.clone();
|
||||
engine.on_history_append(move |item| {
|
||||
if matches!(item, Item::ToolResult { .. }) {
|
||||
commit_probe.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
let published = Arc::new(AtomicUsize::new(0));
|
||||
let publish_probe = published.clone();
|
||||
let commit_probe = committed.clone();
|
||||
engine.on_tool_result(move |_| {
|
||||
assert_eq!(
|
||||
commit_probe.load(Ordering::SeqCst),
|
||||
1,
|
||||
"commit before publication"
|
||||
);
|
||||
publish_probe.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
let usage = Arc::new(AtomicUsize::new(0));
|
||||
let usage_probe = usage.clone();
|
||||
engine.on_usage(move |_| {
|
||||
usage_probe.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
let signal = if pause {
|
||||
engine.pause_sender()
|
||||
} else {
|
||||
engine.cancel_sender()
|
||||
};
|
||||
let run = tokio::spawn(async move {
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "run").await;
|
||||
(output, history)
|
||||
});
|
||||
// Wait for ownership before completing the assistant response.
|
||||
for event in [
|
||||
Event::tool_use_start(0, "policy_call", "policy_tool"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
] {
|
||||
tx.send(Ok(event)).unwrap();
|
||||
}
|
||||
tokio::time::timeout(Duration::from_secs(2), tool.started.notified())
|
||||
.await
|
||||
.expect("early provider started");
|
||||
tx.send(Ok(Event::usage(10, 3))).unwrap();
|
||||
tx.send(Ok(Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})))
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
tokio::time::timeout(Duration::from_secs(2), probe.entered.notified())
|
||||
.await
|
||||
.expect("turn-end policy pending");
|
||||
signal.send(()).await.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(2), tool.cancel_requested.notified())
|
||||
.await
|
||||
.expect("owned provider cancellation progresses before policy release");
|
||||
let terminal_tx = if late {
|
||||
Some(terminal_tx)
|
||||
} else {
|
||||
terminal_tx
|
||||
.send(ToolOutput::from("confirmed after interruption".to_string()))
|
||||
.unwrap();
|
||||
None
|
||||
};
|
||||
let (output, history) = tokio::time::timeout(Duration::from_secs(2), run)
|
||||
.await
|
||||
.expect("terminalization does not await turn-end policy")
|
||||
.unwrap();
|
||||
assert_policy_interruption(&output.result, pause);
|
||||
assert_eq!(call_and_result_count(&history), (1, 1));
|
||||
assert_eq!(committed.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(published.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(usage.load(Ordering::SeqCst), 1);
|
||||
assert!(history.items().any(|item| matches!(
|
||||
item,
|
||||
Item::ToolCall {
|
||||
execution_id: Some(_),
|
||||
status: Some(agen::llm_client::ItemStatus::InProgress),
|
||||
..
|
||||
}
|
||||
)));
|
||||
assert!(history.items().any(|item| matches!(item, Item::ToolResult { disposition, .. } if *disposition == if late { ToolResultDisposition::OutcomeUnknown } else { ToolResultDisposition::Success })));
|
||||
if let Some(terminal_tx) = terminal_tx {
|
||||
tokio::time::timeout(Duration::from_secs(2), tool.retired.notified())
|
||||
.await
|
||||
.expect("forced-close retired the owned receiver");
|
||||
// Force-close retires the receiver; a provider's late completion
|
||||
// cannot reenter history or replace OutcomeUnknown.
|
||||
assert!(
|
||||
terminal_tx
|
||||
.send(ToolOutput::from("late completion".to_string()))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
probe.release.notify_one();
|
||||
assert_eq!(probe.continues.load(Ordering::SeqCst), 0);
|
||||
let original_items = history.items().cloned().collect::<Vec<_>>();
|
||||
let mut resumed_history = history.clone();
|
||||
let mut resumed_engine = output.engine;
|
||||
let _ = resumed_engine.resume(&mut resumed_history).await;
|
||||
assert_eq!(
|
||||
tool.starts.load(Ordering::SeqCst),
|
||||
1,
|
||||
"same-engine resume cannot rerun a terminal attempt"
|
||||
);
|
||||
assert_eq!(call_and_result_count(&resumed_history), (1, 1));
|
||||
let mut restored_history: History =
|
||||
serde_json::from_str(&serde_json::to_string(&history).unwrap()).unwrap();
|
||||
let restored_client = MockLlmClient::new(vec![Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})]);
|
||||
let request_probe = restored_client.clone();
|
||||
let mut restored = Engine::new(restored_client);
|
||||
restored.register_tool(definition);
|
||||
restored.set_max_turns(Some(1));
|
||||
let _ = restored.resume(&mut restored_history).await;
|
||||
let requests = request_probe.requests();
|
||||
assert_eq!(requests.len(), 1);
|
||||
let replay = requests[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
Item::ToolCall { call_id, .. } => Some(format!("call:{call_id}")),
|
||||
Item::ToolResult { call_id, .. } => Some(format!("result:{call_id}")),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(replay, ["call:policy_call", "result:policy_call"]);
|
||||
assert_eq!(tool.starts.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(call_and_result_count(&restored_history), (1, 1));
|
||||
assert_eq!(
|
||||
restored_history
|
||||
.items()
|
||||
.take(original_items.len())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>(),
|
||||
original_items,
|
||||
"restore must not rewrite original history"
|
||||
);
|
||||
assert_eq!(published.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_turn_end_pause_preserves_natural_safe_boundary_without_cancelling_started_tool() {
|
||||
let (client, tx) = ControlledStreamClient::new(ToolCallCompletionSupport::PerBlock);
|
||||
let mut engine = Engine::new(client);
|
||||
PolicyRoute::Early.configure(&mut engine);
|
||||
engine.set_tool_execution_policy(ToolExecutionPolicy {
|
||||
pause_safe_boundary_timeout: Duration::from_secs(2),
|
||||
..ToolExecutionPolicy::default()
|
||||
});
|
||||
let policy = PolicyBarrier::new(InterceptorPhase::AssistantTurnEnd, PolicyRoute::Early);
|
||||
let probe = policy.clone();
|
||||
engine.set_interceptor(policy);
|
||||
let tool = SafePauseTool::new();
|
||||
// Use this tool's actual public name in the stream fixture.
|
||||
engine.register_tool(tool.definition());
|
||||
let pause = engine.pause_sender();
|
||||
let run = tokio::spawn(async move {
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "run").await;
|
||||
(output, history)
|
||||
});
|
||||
for event in [
|
||||
Event::tool_use_start(0, "policy_call", "safe_pause"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
] {
|
||||
tx.send(Ok(event)).unwrap();
|
||||
}
|
||||
drop(tx);
|
||||
tokio::time::timeout(Duration::from_secs(2), probe.entered.notified())
|
||||
.await
|
||||
.expect("turn-end entered");
|
||||
pause.send(()).await.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(2), probe.retired.notified())
|
||||
.await
|
||||
.expect("pending policy interrupted before tool release");
|
||||
tool.release.notify_one();
|
||||
let (output, history) = tokio::time::timeout(Duration::from_secs(2), run)
|
||||
.await
|
||||
.expect("safe boundary collected")
|
||||
.unwrap();
|
||||
assert_policy_interruption(&output.result, true);
|
||||
assert_eq!(tool.calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(tool.cancellations.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(call_and_result_count(&history), (1, 1));
|
||||
probe.release.notify_one();
|
||||
assert_eq!(probe.continues.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_end_final_poll_signal_wins_over_finish_without_tools() {
|
||||
for pause in [false, true] {
|
||||
let (client, tx) = ControlledStreamClient::new(ToolCallCompletionSupport::PerBlock);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut policy = PolicyBarrier::new(InterceptorPhase::AssistantTurnEnd, PolicyRoute::Early);
|
||||
policy.signal_on_continue = Some(if pause {
|
||||
engine.pause_sender()
|
||||
} else {
|
||||
engine.cancel_sender()
|
||||
});
|
||||
let probe = policy.clone();
|
||||
engine.set_interceptor(policy);
|
||||
let run = tokio::spawn(async move {
|
||||
let mut history = History::new();
|
||||
engine.run(&mut history, "run").await
|
||||
});
|
||||
tx.send(Ok(Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})))
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
tokio::time::timeout(Duration::from_secs(2), probe.entered.notified())
|
||||
.await
|
||||
.expect("turn-end entered");
|
||||
probe.release.notify_one();
|
||||
let output = tokio::time::timeout(Duration::from_secs(2), run)
|
||||
.await
|
||||
.expect("final-poll signal processed")
|
||||
.unwrap();
|
||||
assert_policy_interruption(&output.result, pause);
|
||||
assert_eq!(probe.continues.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_end_pause_deadline_reclaims_started_sibling_while_post_policy_is_pending() {
|
||||
struct TurnAndPostBarrier {
|
||||
turn: PolicyBarrier,
|
||||
post: PolicyBarrier,
|
||||
}
|
||||
#[async_trait]
|
||||
impl Interceptor for TurnAndPostBarrier {
|
||||
fn supports_early_tool_dispatch(&self) -> bool {
|
||||
true
|
||||
}
|
||||
async fn on_assistant_turn_end(
|
||||
&self,
|
||||
_: AssistantTurnEndContext<'_>,
|
||||
) -> InterceptorResult<TurnEndAction> {
|
||||
self.turn.wait().await;
|
||||
Ok(TurnEndAction::Finish)
|
||||
}
|
||||
async fn post_tool_call(
|
||||
&self,
|
||||
_: &ToolResultInfo<'_>,
|
||||
) -> InterceptorResult<PostToolAction> {
|
||||
self.post.wait().await;
|
||||
Ok(PostToolAction::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
let (client, tx) = ControlledStreamClient::new(ToolCallCompletionSupport::PerBlock);
|
||||
let mut engine = Engine::new(client);
|
||||
engine.set_tool_execution_policy(ToolExecutionPolicy {
|
||||
pause_safe_boundary_timeout: Duration::from_millis(500),
|
||||
cancellation_request_timeout: Duration::from_secs(1),
|
||||
terminal_confirmation_timeout: Duration::from_secs(2),
|
||||
});
|
||||
let turn = PolicyBarrier::new(InterceptorPhase::AssistantTurnEnd, PolicyRoute::Early);
|
||||
let post = PolicyBarrier::new(InterceptorPhase::PostToolCall, PolicyRoute::Early);
|
||||
engine.set_interceptor(TurnAndPostBarrier {
|
||||
turn: turn.clone(),
|
||||
post: post.clone(),
|
||||
});
|
||||
let completed = BarrierTool::new("completed_before_pause");
|
||||
engine.register_tool(completed.definition());
|
||||
let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel();
|
||||
let sibling = PolicyOwnedTool {
|
||||
starts: Arc::new(AtomicUsize::new(0)),
|
||||
started: Arc::new(tokio::sync::Notify::new()),
|
||||
cancel_requested: Arc::new(tokio::sync::Notify::new()),
|
||||
terminal: Arc::new(tokio::sync::Mutex::new(Some(terminal_rx))),
|
||||
retired: Arc::new(tokio::sync::Notify::new()),
|
||||
};
|
||||
let definition: ToolDefinition = {
|
||||
let sibling = sibling.clone();
|
||||
Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new("pending_sibling").input_schema(serde_json::json!({"type":"object"})),
|
||||
Arc::new(sibling.clone()) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
};
|
||||
engine.register_tool(definition.clone());
|
||||
let committed = Arc::new(AtomicUsize::new(0));
|
||||
let commit_probe = committed.clone();
|
||||
engine.on_history_append(move |item| {
|
||||
if matches!(item, Item::ToolResult { .. }) {
|
||||
commit_probe.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
let published = Arc::new(AtomicUsize::new(0));
|
||||
let publish_probe = published.clone();
|
||||
let commit_probe = committed.clone();
|
||||
engine.on_tool_result(move |_| {
|
||||
let n = publish_probe.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
assert_eq!(commit_probe.load(Ordering::SeqCst), n);
|
||||
});
|
||||
let pause = engine.pause_sender();
|
||||
let run = tokio::spawn(async move {
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "run").await;
|
||||
(output, history)
|
||||
});
|
||||
for (index, id, name) in [
|
||||
(0, "policy_call", "completed_before_pause"),
|
||||
(1, "sibling_call", "pending_sibling"),
|
||||
] {
|
||||
for event in [
|
||||
Event::tool_use_start(index, id, name),
|
||||
Event::tool_input_delta(index, "{}"),
|
||||
Event::tool_use_stop(index),
|
||||
] {
|
||||
tx.send(Ok(event)).unwrap();
|
||||
}
|
||||
}
|
||||
tokio::time::timeout(Duration::from_secs(2), completed.started.notified())
|
||||
.await
|
||||
.expect("first provider owned");
|
||||
tokio::time::timeout(Duration::from_secs(2), sibling.started.notified())
|
||||
.await
|
||||
.expect("sibling provider owned");
|
||||
tx.send(Ok(Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})))
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
tokio::time::timeout(Duration::from_secs(2), turn.entered.notified())
|
||||
.await
|
||||
.expect("turn-end pending");
|
||||
pause.send(()).await.unwrap(); // only one interruption signal
|
||||
tokio::time::timeout(Duration::from_secs(2), turn.retired.notified())
|
||||
.await
|
||||
.expect("turn-end interrupted");
|
||||
completed.release.notify_one();
|
||||
tokio::time::timeout(Duration::from_secs(2), post.entered.notified())
|
||||
.await
|
||||
.expect("completed result's post policy pending");
|
||||
assert_eq!(committed.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(published.load(Ordering::SeqCst), 1);
|
||||
tokio::time::timeout(Duration::from_secs(2), sibling.cancel_requested.notified()).await.expect("original Pause deadline must reach sibling cancellation without releasing either policy");
|
||||
terminal_tx
|
||||
.send(ToolOutput::from("confirmed sibling terminal".to_string()))
|
||||
.unwrap();
|
||||
let (output, history) = tokio::time::timeout(Duration::from_secs(2), run)
|
||||
.await
|
||||
.expect("all owned tools reclaimed")
|
||||
.unwrap();
|
||||
assert_policy_interruption(&output.result, true);
|
||||
assert_eq!(committed.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(published.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(
|
||||
history
|
||||
.items()
|
||||
.filter(|item| matches!(item, Item::ToolResult { .. }))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_eq!(completed.starts.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(sibling.starts.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(post.invocations.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(post.continues.load(Ordering::SeqCst), 0);
|
||||
turn.release.notify_one();
|
||||
post.release.notify_one();
|
||||
let mut restored_history: History =
|
||||
serde_json::from_str(&serde_json::to_string(&history).unwrap()).unwrap();
|
||||
let mut restored = Engine::new(MockLlmClient::new(vec![Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})]));
|
||||
restored.register_tool(completed.definition());
|
||||
restored.register_tool(definition);
|
||||
restored.set_max_turns(Some(1));
|
||||
let _ = restored.resume(&mut restored_history).await;
|
||||
assert_eq!(completed.starts.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(sibling.starts.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(
|
||||
restored_history
|
||||
.items()
|
||||
.filter(|item| matches!(item, Item::ToolResult { .. }))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
+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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ use ticket::{
|
||||
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
||||
|
||||
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
|
||||
const UNSUPPORTED_TICKET_STATE_CHANGE: &str = "unsupported legacy Ticket state/close operation; use TicketBackend::update_case with caller-supplied content digest, case state, closure operation key, and replay key (CAS); case updates do not start or stop work";
|
||||
|
||||
/// Workspace-scoped Backend client for Ticket and Objective product state.
|
||||
///
|
||||
@@ -80,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()
|
||||
@@ -88,7 +106,71 @@ 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(
|
||||
&self,
|
||||
request: &ticket::TagListRequest,
|
||||
) -> Result<Vec<ticket::WorkspaceTag>, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let request = request.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.workspace_ticket_tag_list(
|
||||
workspace_id,
|
||||
server_api::WorkspaceTicketTagListRequest(request),
|
||||
)
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn mutate_workspace_tag(
|
||||
&self,
|
||||
request: &ticket::WorkspaceTagMutation,
|
||||
) -> Result<ticket::WorkspaceTag, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let request = request.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.workspace_ticket_tag_mutate(
|
||||
workspace_id,
|
||||
server_api::WorkspaceTicketTagMutationRequest(request),
|
||||
)
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_ticket_tags(
|
||||
&self,
|
||||
id: &TicketIdOrSlug,
|
||||
request: &ticket::TicketTagSetRequest,
|
||||
) -> Result<Vec<ticket::WorkspaceTag>, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(id);
|
||||
let request = request.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_tags_set(workspace_id, id, server_api::TicketTagsSetRequest(request))
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_tickets(
|
||||
@@ -155,76 +237,24 @@ impl BackendWorkspaceProductClient {
|
||||
})
|
||||
}
|
||||
|
||||
fn decision_from_state_change(
|
||||
&self,
|
||||
id: &TicketIdOrSlug,
|
||||
change: &TicketStateChange,
|
||||
) -> Result<ticket::TicketStateUpdate, BackendWorkspaceClientError> {
|
||||
let current = self.show_ticket(id)?;
|
||||
let parse = |state: &str| {
|
||||
ticket::TicketWorkflowState::parse(state).ok_or_else(|| {
|
||||
BackendWorkspaceClientError::InvalidTarget(format!("invalid Ticket state {state}"))
|
||||
})
|
||||
};
|
||||
Ok(ticket::TicketStateUpdate {
|
||||
operation_key: uuid::Uuid::now_v7().to_string(),
|
||||
expected_content_digest: ticket::ticket_content_digest(¤t),
|
||||
expected_state: parse(&change.from)?,
|
||||
state: parse(&change.to)?,
|
||||
reason: if change.body.as_str().is_empty() {
|
||||
change.reason.clone()
|
||||
} else {
|
||||
format!("{}\n\n{}", change.reason, change.body.as_str())
|
||||
},
|
||||
references: change.references.clone(),
|
||||
author: change.author.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_ticket_workflow_state(
|
||||
&self,
|
||||
id: &TicketIdOrSlug,
|
||||
change: &TicketStateChange,
|
||||
_id: &TicketIdOrSlug,
|
||||
_change: &TicketStateChange,
|
||||
) -> Result<(), BackendWorkspaceClientError> {
|
||||
let change = self.decision_from_state_change(id, change)?;
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(id);
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_workflow_state_set(
|
||||
workspace_id,
|
||||
id,
|
||||
server_api::TicketStateChangeRequest(change),
|
||||
)
|
||||
.await
|
||||
})
|
||||
Err(BackendWorkspaceClientError::InvalidTarget(
|
||||
UNSUPPORTED_TICKET_STATE_CHANGE.into(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn close_ticket(
|
||||
&self,
|
||||
id: &TicketIdOrSlug,
|
||||
resolution: &MarkdownText,
|
||||
_id: &TicketIdOrSlug,
|
||||
_resolution: &MarkdownText,
|
||||
) -> Result<(), BackendWorkspaceClientError> {
|
||||
let current = self.show_ticket(id)?;
|
||||
let request = ticket::TicketCompletion {
|
||||
operation_key: uuid::Uuid::now_v7().to_string(),
|
||||
expected_content_digest: ticket::ticket_content_digest(¤t),
|
||||
expected_state: current.meta.workflow_state,
|
||||
reason: resolution.as_str().to_string(),
|
||||
references: Vec::new(),
|
||||
author: None,
|
||||
};
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(id);
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_close_record(
|
||||
workspace_id,
|
||||
id,
|
||||
server_api::TicketCloseRecordRequest(request),
|
||||
)
|
||||
.await
|
||||
})
|
||||
Err(BackendWorkspaceClientError::InvalidTarget(
|
||||
UNSUPPORTED_TICKET_STATE_CHANGE.into(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn add_ticket_relation(
|
||||
@@ -568,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
|
||||
@@ -602,6 +633,88 @@ 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,
|
||||
edit: ticket::TicketPostEdit,
|
||||
) -> ticket::Result<(Ticket, Ticket)> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(&id);
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_record_post_edit(workspace_id, id, server_api::EditTicketPostRequest(edit))
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn list_workspace_tags(
|
||||
&self,
|
||||
request: ticket::TagListRequest,
|
||||
) -> ticket::Result<Vec<ticket::WorkspaceTag>> {
|
||||
BackendWorkspaceProductClient::list_workspace_tags(self, &request)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn mutate_workspace_tag(
|
||||
&self,
|
||||
request: ticket::WorkspaceTagMutation,
|
||||
) -> ticket::Result<ticket::WorkspaceTag> {
|
||||
BackendWorkspaceProductClient::mutate_workspace_tag(self, &request)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn set_ticket_tags(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
request: ticket::TicketTagSetRequest,
|
||||
) -> ticket::Result<Vec<ticket::WorkspaceTag>> {
|
||||
BackendWorkspaceProductClient::set_ticket_tags(self, &id, &request)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn default_intake_ready_state_change_body(&self, from: &str) -> String {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let from = from.to_string();
|
||||
@@ -664,24 +777,12 @@ impl TicketBackend for BackendWorkspaceProductClient {
|
||||
|
||||
fn add_state_changed(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
change: TicketStateChange,
|
||||
_id: TicketIdOrSlug,
|
||||
_change: TicketStateChange,
|
||||
) -> ticket::Result<()> {
|
||||
let change = self
|
||||
.decision_from_state_change(&id, &change)
|
||||
.map_err(ticket_client_error)?;
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(&id);
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_state_change_add(
|
||||
workspace_id,
|
||||
id,
|
||||
server_api::TicketStateChangeRequest(change),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.map_err(ticket_client_error)
|
||||
Err(TicketError::Conflict(
|
||||
UNSUPPORTED_TICKET_STATE_CHANGE.into(),
|
||||
))
|
||||
}
|
||||
|
||||
fn add_intake_summary(
|
||||
@@ -703,59 +804,31 @@ impl TicketBackend for BackendWorkspaceProductClient {
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn set_state_field(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
field: &str,
|
||||
change: TicketStateChange,
|
||||
) -> ticket::Result<()> {
|
||||
if field != "state" {
|
||||
return Err(TicketError::Conflict(
|
||||
"unsupported Ticket state field".into(),
|
||||
));
|
||||
}
|
||||
let change = self
|
||||
.decision_from_state_change(&id, &change)
|
||||
.map_err(ticket_client_error)?;
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(&id);
|
||||
let field = field.to_string();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_state_field_set(
|
||||
workspace_id,
|
||||
id,
|
||||
field,
|
||||
server_api::TicketStateChangeRequest(change),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn set_workflow_state(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
change: TicketStateChange,
|
||||
) -> ticket::Result<()> {
|
||||
self.set_ticket_workflow_state(&id, &change)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn update_state(
|
||||
fn update_case(
|
||||
&self,
|
||||
ticket: &str,
|
||||
request: ticket::TicketStateUpdate,
|
||||
request: ticket::TicketCaseUpdate,
|
||||
) -> ticket::Result<Ticket> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket.to_owned();
|
||||
// Actor identity belongs to authenticated Backend authority, not the caller.
|
||||
let request = server_api::TicketCaseUpdateRequest {
|
||||
expected_content_digest: request.expected_content_digest,
|
||||
expected_state: request.expected_state,
|
||||
expected_closure_operation_key: request.expected_closure_operation_key,
|
||||
operation_key: request.operation_key,
|
||||
action: request.action,
|
||||
explanation: request.explanation,
|
||||
references: request.references,
|
||||
};
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_state_update(
|
||||
workspace_id,
|
||||
id,
|
||||
server_api::TicketStateUpdateRequest(request),
|
||||
)
|
||||
.ticket_case_update(workspace_id.clone(), id.clone(), request)
|
||||
.await?;
|
||||
// The case endpoint returns only a summary. Fetch the authoritative
|
||||
// full record rather than fabricating body, events, or case history.
|
||||
client
|
||||
.ticket_record_get(workspace_id, id)
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
@@ -795,11 +868,6 @@ impl TicketBackend for BackendWorkspaceProductClient {
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> ticket::Result<()> {
|
||||
self.close_ticket(&id, &resolution)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn add_ticket_relation(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
@@ -941,6 +1009,33 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
fn read_http_request(stream: &mut std::net::TcpStream) -> String {
|
||||
stream
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0_u8; 4_096];
|
||||
loop {
|
||||
let bytes = stream.read(&mut buffer).unwrap();
|
||||
assert_ne!(bytes, 0, "incomplete HTTP request");
|
||||
request.extend_from_slice(&buffer[..bytes]);
|
||||
if let Some(header_end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") {
|
||||
let headers = std::str::from_utf8(&request[..header_end]).unwrap();
|
||||
let content_length = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length")
|
||||
.then(|| value.trim().parse::<usize>().unwrap())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if request.len() >= header_end + 4 + content_length {
|
||||
return String::from_utf8(request).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn one_response_server(
|
||||
status: &str,
|
||||
body: &str,
|
||||
@@ -952,11 +1047,7 @@ mod tests {
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let handle = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = vec![0_u8; 8_192];
|
||||
let bytes = stream.read(&mut request).unwrap();
|
||||
sender
|
||||
.send(String::from_utf8_lossy(&request[..bytes]).to_string())
|
||||
.unwrap();
|
||||
sender.send(read_http_request(&mut stream)).unwrap();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
@@ -980,11 +1071,7 @@ mod tests {
|
||||
let handle = thread::spawn(move || {
|
||||
for (status, body) in responses {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = vec![0_u8; 16_384];
|
||||
let bytes = stream.read(&mut request).unwrap();
|
||||
sender
|
||||
.send(String::from_utf8_lossy(&request[..bytes]).to_string())
|
||||
.unwrap();
|
||||
sender.send(read_http_request(&mut stream)).unwrap();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
@@ -997,116 +1084,398 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_state_adapter_uses_snapshot_content_without_overriding_stale_expected_state() {
|
||||
fn post_edit_uses_native_patch_and_returns_server_snapshots_without_reread() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let backend =
|
||||
ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a")
|
||||
.unwrap();
|
||||
let reference = backend.create(NewTicket::new("Adapter snapshot")).unwrap();
|
||||
let snapshot = backend.show(reference.id.clone().into()).unwrap();
|
||||
let body = serde_json::to_string(&snapshot).unwrap();
|
||||
let (base_url, requests, handle) =
|
||||
response_sequence_server(vec![("200 OK", &body), ("204 No Content", "")]);
|
||||
let client =
|
||||
BackendWorkspaceProductClient::new_with_access_token(base_url, "workspace-a", "token")
|
||||
.unwrap();
|
||||
client
|
||||
.set_ticket_workflow_state(
|
||||
&reference.id.clone().into(),
|
||||
&TicketStateChange::new("ready", "done", "Judgment", "Results in the thread"),
|
||||
)
|
||||
let mut input = NewTicket::new("Post snapshots");
|
||||
input.body = MarkdownText::new("Original requirements");
|
||||
let reference = backend.create(input).unwrap();
|
||||
let original = backend.show(reference.id.clone().into()).unwrap();
|
||||
let edit = ticket::TicketPostEdit {
|
||||
event_ref: original.events[0].event_ref.clone(),
|
||||
expected_body: original.document.body.clone(),
|
||||
body: MarkdownText::new("Changed requirements"),
|
||||
author: "authenticated-editor".into(),
|
||||
};
|
||||
let expected = backend
|
||||
.edit_post_with_snapshots(reference.id.into(), edit.clone())
|
||||
.unwrap();
|
||||
assert!(requests.recv().unwrap().starts_with("GET "));
|
||||
let body = serde_json::to_string(&expected).unwrap();
|
||||
let (url, requests, server) = one_response_server("200 OK", &body);
|
||||
let client =
|
||||
BackendWorkspaceProductClient::new_with_access_token(url, "workspace-a", "token")
|
||||
.unwrap();
|
||||
let actual = TicketBackend::edit_post_with_snapshots(
|
||||
&client,
|
||||
TicketIdOrSlug::Query("T-123".into()),
|
||||
edit.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(actual, expected);
|
||||
let request = requests.recv().unwrap();
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert!(request.starts_with("PATCH /api/w/workspace-a/tickets/T-123/posts HTTP/1.1\r\n"));
|
||||
let (_, body) = request.split_once("\r\n\r\n").unwrap();
|
||||
assert_eq!(
|
||||
body["expected_content_digest"],
|
||||
ticket::ticket_content_digest(&snapshot)
|
||||
serde_json::from_str::<ticket::TicketPostEdit>(body).unwrap(),
|
||||
edit
|
||||
);
|
||||
assert_eq!(body["expected_state"], "ready");
|
||||
assert_eq!(body["state"], "done");
|
||||
assert!(!body["operation_key"].as_str().unwrap().is_empty());
|
||||
handle.join().unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_close_adapter_forwards_snapshot_cas_and_resolution_as_judgment() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let backend =
|
||||
ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a")
|
||||
.unwrap();
|
||||
let reference = backend
|
||||
.create(NewTicket::new("Close adapter snapshot"))
|
||||
.unwrap();
|
||||
let snapshot = backend.show(reference.id.clone().into()).unwrap();
|
||||
let body = serde_json::to_string(&snapshot).unwrap();
|
||||
let (base_url, requests, handle) =
|
||||
response_sequence_server(vec![("200 OK", &body), ("204 No Content", "")]);
|
||||
let client =
|
||||
BackendWorkspaceProductClient::new_with_access_token(base_url, "workspace-a", "token")
|
||||
.unwrap();
|
||||
client
|
||||
.close_ticket(
|
||||
&reference.id.into(),
|
||||
&MarkdownText::new("The request was withdrawn"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(requests.recv().unwrap().starts_with("GET "));
|
||||
let request = requests.recv().unwrap();
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
fn legacy_state_and_close_calls_require_explicit_case_cas_without_http() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
format!("http://{}", listener.local_addr().unwrap()),
|
||||
"workspace-a",
|
||||
"token",
|
||||
)
|
||||
.unwrap();
|
||||
let id = TicketIdOrSlug::Query("T-739".into());
|
||||
let change = TicketStateChange::new("open", "closed", "Judgment", "Explanation");
|
||||
let errors = [
|
||||
client
|
||||
.set_ticket_workflow_state(&id, &change)
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
client
|
||||
.close_ticket(&id, &MarkdownText::new("Withdrawn"))
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
TicketBackend::add_state_changed(&client, id, change)
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
];
|
||||
for error in errors {
|
||||
assert!(
|
||||
error.contains("unsupported legacy Ticket state/close operation"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(error.contains("TicketBackend::update_case"), "{error}");
|
||||
assert!(error.contains("caller-supplied"), "{error}");
|
||||
assert!(error.contains("CAS"), "{error}");
|
||||
}
|
||||
assert_eq!(
|
||||
body["expected_content_digest"],
|
||||
ticket::ticket_content_digest(&snapshot)
|
||||
listener.accept().unwrap_err().kind(),
|
||||
std::io::ErrorKind::WouldBlock
|
||||
);
|
||||
assert_eq!(body["expected_state"], "planning");
|
||||
assert_eq!(body["reason"], "The request was withdrawn");
|
||||
assert!(!body["operation_key"].as_str().unwrap().is_empty());
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_backend_completion_forwards_cas_judgment_and_backend_conflicts() {
|
||||
let (base_url, request, handle) = one_response_server(
|
||||
"409 Conflict",
|
||||
r#"{"error":"ticket_conflict","message":"stale content","diagnostics":[]}"#,
|
||||
);
|
||||
fn serialized_update_case_forwards_tagged_actions_and_returns_full_backend_record() {
|
||||
for action in [
|
||||
serde_json::json!({"action": "close", "reason": "completed"}),
|
||||
serde_json::json!({"action": "close", "reason": {"other": "Custom judgment"}}),
|
||||
serde_json::json!({"action": "correct_closure", "reason": "duplicate"}),
|
||||
serde_json::json!({"action": "reopen"}),
|
||||
] {
|
||||
// SQLite only supplies hermetic response fixtures, never a client fallback.
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let backend =
|
||||
ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a")
|
||||
.unwrap();
|
||||
let mut input = NewTicket::new("Full case record");
|
||||
input.body = MarkdownText::new("Full body absent from the endpoint summary");
|
||||
let reference = backend.create(input).unwrap();
|
||||
let tag = backend
|
||||
.mutate_workspace_tag(ticket::WorkspaceTagMutation {
|
||||
operation_key: "classify-create".into(),
|
||||
action: ticket::WorkspaceTagAction::Create {
|
||||
name: "closed".into(),
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
backend
|
||||
.set_ticket_tags(
|
||||
reference.id.clone().into(),
|
||||
ticket::TicketTagSetRequest {
|
||||
operation_key: "classify-set".into(),
|
||||
expected_tag_ids: Vec::new(),
|
||||
tag_ids: vec![tag.id.clone()],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let mut snapshot = backend.show(reference.id.clone().into()).unwrap();
|
||||
if action["action"] != "close" {
|
||||
snapshot = backend
|
||||
.update_case(
|
||||
&reference.id,
|
||||
ticket::TicketCaseUpdate {
|
||||
expected_content_digest: ticket::ticket_content_digest(&snapshot),
|
||||
expected_state: ticket::TicketState::Open,
|
||||
expected_closure_operation_key: None,
|
||||
operation_key: "initial-close".into(),
|
||||
action: ticket::TicketCaseAction::Close {
|
||||
reason: ticket::TicketClosureReason::Unknown,
|
||||
},
|
||||
explanation: "Initial judgment".into(),
|
||||
references: Vec::new(),
|
||||
author: Some("authenticated-actor".into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let expected_request = serde_json::json!({
|
||||
"expected_content_digest": ticket::ticket_content_digest(&snapshot),
|
||||
"expected_state": snapshot.meta.workflow_state.as_str(),
|
||||
"expected_closure_operation_key": snapshot.closure_operation_key,
|
||||
"operation_key": "caller-case-key",
|
||||
"action": action,
|
||||
"explanation": "Caller judgment, not work progress",
|
||||
"references": [{"kind": "ticket", "target": "T-738"}],
|
||||
});
|
||||
let mut domain_request = expected_request.clone();
|
||||
domain_request["author"] = serde_json::json!("caller-cannot-impersonate");
|
||||
let operation =
|
||||
serde_json::from_value::<ticket::TicketBackendOperation>(serde_json::json!({
|
||||
"operation": "update_case",
|
||||
"ticket": reference.id,
|
||||
"request": domain_request,
|
||||
}))
|
||||
.unwrap();
|
||||
let mut fixture_request: ticket::TicketCaseUpdate =
|
||||
serde_json::from_value(expected_request.clone()).unwrap();
|
||||
fixture_request.author = Some("authenticated-actor".into());
|
||||
let expected = backend.update_case(&reference.id, fixture_request).unwrap();
|
||||
let summary =
|
||||
serde_json::to_string(&backend.list(TicketListQuery::all()).unwrap()[0]).unwrap();
|
||||
let full_record = serde_json::to_string(&expected).unwrap();
|
||||
let (base_url, requests, handle) =
|
||||
response_sequence_server(vec![("200 OK", &summary), ("200 OK", &full_record)]);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-token",
|
||||
)
|
||||
.unwrap();
|
||||
let result = ticket::execute_ticket_backend_operation(&client, operation).unwrap();
|
||||
let ticket::TicketBackendOperationResult::Ticket(actual) = result else {
|
||||
panic!("case operation must return a full Ticket");
|
||||
};
|
||||
assert_eq!(actual, expected);
|
||||
assert_eq!(actual.tags, vec![tag]);
|
||||
assert!(!actual.case_history.is_empty());
|
||||
assert_eq!(actual.closure_operation_key, expected.closure_operation_key);
|
||||
let post = requests.recv().unwrap();
|
||||
assert!(
|
||||
post.starts_with(&format!(
|
||||
"POST /workspaces/workspace-a/tickets/{}/case ",
|
||||
reference.id,
|
||||
)),
|
||||
"{post}"
|
||||
);
|
||||
assert!(
|
||||
post.contains("authorization: Bearer test-token\r\n"),
|
||||
"{post}"
|
||||
);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(post.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(body, expected_request);
|
||||
assert!(body.get("author").is_none());
|
||||
assert!(requests.recv().unwrap().starts_with(&format!(
|
||||
"GET /api/w/workspace-a/tickets/{}/record ",
|
||||
reference.id,
|
||||
)));
|
||||
handle.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classification_client_uses_scoped_routes_and_preserves_cas_and_replay_input() {
|
||||
let tag = serde_json::json!({"workspace_id":"workspace-a", "id":"tag-a", "name":"Ready", "last_operation_key":"create-1", "deleted":false});
|
||||
let single = tag.to_string();
|
||||
let list = serde_json::json!([tag]).to_string();
|
||||
let (base_url, requests, handle) = response_sequence_server(vec![
|
||||
("200 OK", &list),
|
||||
("200 OK", &single),
|
||||
("200 OK", &list),
|
||||
]);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-token",
|
||||
)
|
||||
.unwrap();
|
||||
let result = TicketBackend::complete(
|
||||
&client,
|
||||
"T-718",
|
||||
ticket::TicketCompletion {
|
||||
operation_key: "research-done".into(),
|
||||
expected_content_digest: "ticket:1".into(),
|
||||
expected_state: ticket::TicketWorkflowState::Planning,
|
||||
reason: "Research findings recorded".into(),
|
||||
references: Vec::new(),
|
||||
author: None,
|
||||
},
|
||||
);
|
||||
assert!(result.is_err());
|
||||
let request = request.recv().unwrap();
|
||||
let listed = client
|
||||
.list_workspace_tags(&ticket::TagListRequest {
|
||||
after: Some("tag-0".into()),
|
||||
limit: Some(7),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(listed[0].id, "tag-a");
|
||||
let request = requests.recv().unwrap();
|
||||
assert!(
|
||||
request.starts_with("POST /api/w/workspace-a/tickets/T-718/state-update "),
|
||||
request.starts_with("GET /api/w/workspace-a/ticket-tags?"),
|
||||
"{request}"
|
||||
);
|
||||
assert!(
|
||||
request.contains("after=tag-0") && request.contains("limit=7"),
|
||||
"{request}"
|
||||
);
|
||||
assert!(
|
||||
request
|
||||
.to_lowercase()
|
||||
.contains("authorization: bearer test-token")
|
||||
);
|
||||
let mutation = ticket::WorkspaceTagMutation {
|
||||
operation_key: "rename-1".into(),
|
||||
action: ticket::WorkspaceTagAction::Rename {
|
||||
tag_id: "tag-a".into(),
|
||||
expected_last_operation_key: "create-1".into(),
|
||||
name: "New".into(),
|
||||
},
|
||||
};
|
||||
TicketBackend::mutate_workspace_tag(&client, mutation.clone()).unwrap();
|
||||
let request = requests.recv().unwrap();
|
||||
assert!(
|
||||
request.starts_with("POST /api/w/workspace-a/ticket-tags "),
|
||||
"{request}"
|
||||
);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(body["state"], "done");
|
||||
assert_eq!(body["operation_key"], "research-done");
|
||||
assert_eq!(body["expected_content_digest"], "ticket:1");
|
||||
assert_eq!(body["expected_state"], "planning");
|
||||
assert_eq!(body["reason"], "Research findings recorded");
|
||||
assert!(body.get("merge_request_ids").is_none());
|
||||
assert_eq!(body, serde_json::json!(mutation));
|
||||
let set = ticket::TicketTagSetRequest {
|
||||
operation_key: "set-1".into(),
|
||||
expected_tag_ids: vec!["tag-old".into()],
|
||||
tag_ids: vec!["tag-a".into(), "tag-b".into()],
|
||||
};
|
||||
let receipt =
|
||||
TicketBackend::set_ticket_tags(&client, TicketIdOrSlug::from("T-740"), set.clone())
|
||||
.unwrap();
|
||||
assert_eq!(receipt[0].name, "Ready");
|
||||
let request = requests.recv().unwrap();
|
||||
assert!(
|
||||
request.starts_with("PUT /api/w/workspace-a/tickets/T-740/tags "),
|
||||
"{request}"
|
||||
);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(body, serde_json::json!(set));
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classification_client_returns_backend_denials_without_local_fallback() {
|
||||
for status in ["403 Forbidden", "409 Conflict"] {
|
||||
let (base_url, requests, handle) = one_response_server(
|
||||
status,
|
||||
r#"{"error":"ticket_conflict","message":"stale or unauthorized","diagnostics":[]}"#,
|
||||
);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"token",
|
||||
)
|
||||
.unwrap();
|
||||
let result = client.set_ticket_tags(
|
||||
&TicketIdOrSlug::from("T-740"),
|
||||
&ticket::TicketTagSetRequest {
|
||||
operation_key: "retry-key".into(),
|
||||
expected_tag_ids: vec![],
|
||||
tag_ids: vec!["tag-a".into()],
|
||||
},
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
requests
|
||||
.recv()
|
||||
.unwrap()
|
||||
.starts_with("PUT /api/w/workspace-a/tickets/T-740/tags ")
|
||||
);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn case_request() -> ticket::TicketCaseUpdate {
|
||||
ticket::TicketCaseUpdate {
|
||||
operation_key: "caller-case-key".into(),
|
||||
expected_content_digest: "caller-content-digest".into(),
|
||||
expected_state: ticket::TicketState::Closed,
|
||||
expected_closure_operation_key: Some("last-close-key".into()),
|
||||
action: ticket::TicketCaseAction::Reopen,
|
||||
explanation: "Caller reopening judgment".into(),
|
||||
references: Vec::new(),
|
||||
author: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_update_rejection_preserves_caller_cas_without_local_fallback() {
|
||||
for (status, body, expected_error) in [
|
||||
(
|
||||
"409 Conflict",
|
||||
r#"{"error":"ticket_conflict","message":"stale content","diagnostics":[]}"#,
|
||||
"409",
|
||||
),
|
||||
(
|
||||
"403 Forbidden",
|
||||
r#"{"error":"forbidden","message":"not authorized","diagnostics":[]}"#,
|
||||
"403",
|
||||
),
|
||||
] {
|
||||
let (base_url, requests, handle) = one_response_server(status, body);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-token",
|
||||
)
|
||||
.unwrap();
|
||||
let error = TicketBackend::update_case(&client, "T-739", case_request()).unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains(expected_error),
|
||||
"{error}; response {status}: {body}"
|
||||
);
|
||||
let request = requests.recv().unwrap();
|
||||
assert!(
|
||||
request.starts_with("POST /workspaces/workspace-a/tickets/T-739/case "),
|
||||
"{request}"
|
||||
);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(body["expected_content_digest"], "caller-content-digest");
|
||||
assert_eq!(body["expected_state"], "closed");
|
||||
assert_eq!(body["expected_closure_operation_key"], "last-close-key");
|
||||
assert_eq!(body["operation_key"], "caller-case-key");
|
||||
assert_eq!(body["action"], serde_json::json!({"action": "reopen"}));
|
||||
assert!(!error.to_string().contains("test-token"));
|
||||
handle.join().unwrap();
|
||||
assert!(requests.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_case_update_with_failed_show_does_not_fabricate_full_ticket() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let backend =
|
||||
ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a")
|
||||
.unwrap();
|
||||
let reference = backend.create(NewTicket::new("Summary only")).unwrap();
|
||||
let summary =
|
||||
serde_json::to_string(&backend.list(TicketListQuery::all()).unwrap()[0]).unwrap();
|
||||
let (base_url, requests, handle) = response_sequence_server(vec![
|
||||
("200 OK", &summary),
|
||||
(
|
||||
"404 Not Found",
|
||||
r#"{"error":"ticket_not_found","message":"full record unavailable","diagnostics":[]}"#,
|
||||
),
|
||||
]);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-token",
|
||||
)
|
||||
.unwrap();
|
||||
let error = TicketBackend::update_case(&client, &reference.id, case_request()).unwrap_err();
|
||||
assert!(error.to_string().contains("404"), "{error}");
|
||||
assert!(requests.recv().unwrap().starts_with("POST /workspaces/"));
|
||||
assert!(requests.recv().unwrap().starts_with("GET /api/w/"));
|
||||
handle.join().unwrap();
|
||||
assert!(requests.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_document_uses_shared_workspace_scoped_response() {
|
||||
let body = r##"{"body_md":"# Memory\\n","created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-02T00:00:00Z","bytes":10,"record_source":"workspace-sqlite"}"##;
|
||||
@@ -1414,12 +1783,12 @@ mod tests {
|
||||
assert_eq!(
|
||||
ticket_list_state_query(&TicketListQuery {
|
||||
state: TicketStateSelector::States(
|
||||
[TicketListState::Ready, TicketListState::InProgress]
|
||||
[TicketListState::Open, TicketListState::Closed]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
}),
|
||||
"ready,inprogress"
|
||||
"open,closed"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user