clawpowers 2.2.5 → 2.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +186 -160
- package/COMPATIBILITY.md +48 -13
- package/KNOWN_LIMITATIONS.md +20 -19
- package/LICENSE +44 -44
- package/LICENSING.md +10 -10
- package/README.md +486 -462
- package/SECURITY.md +52 -52
- package/dist/index.d.ts +17 -5
- package/dist/index.js +187 -92
- package/dist/index.js.map +1 -1
- package/native/Cargo.lock +4927 -4927
- package/native/Cargo.toml +73 -73
- package/native/crates/canonical/Cargo.toml +24 -24
- package/native/crates/canonical/src/lib.rs +677 -673
- package/native/crates/compression/Cargo.toml +20 -20
- package/native/crates/compression/benches/compression_bench.rs +42 -42
- package/native/crates/compression/src/lib.rs +393 -393
- package/native/crates/evm-eth/Cargo.toml +13 -13
- package/native/crates/evm-eth/src/lib.rs +105 -105
- package/native/crates/fee/Cargo.toml +15 -15
- package/native/crates/fee/src/lib.rs +281 -281
- package/native/crates/index/Cargo.toml +16 -16
- package/native/crates/index/src/lib.rs +277 -277
- package/native/crates/policy/Cargo.toml +17 -17
- package/native/crates/policy/src/lib.rs +614 -614
- package/native/crates/security/Cargo.toml +22 -22
- package/native/crates/security/src/lib.rs +478 -478
- package/native/crates/tokens/Cargo.toml +13 -13
- package/native/crates/tokens/src/lib.rs +534 -534
- package/native/crates/verification/Cargo.toml +23 -23
- package/native/crates/verification/src/lib.rs +333 -333
- package/native/crates/wallet/Cargo.toml +20 -20
- package/native/crates/wallet/src/lib.rs +261 -261
- package/native/crates/x402/Cargo.toml +30 -30
- package/native/crates/x402/src/lib.rs +423 -423
- package/native/ffi/Cargo.toml +34 -34
- package/native/ffi/build.rs +4 -4
- package/native/ffi/src/lib.rs +352 -352
- package/native/ffi/tests/integration.rs +354 -354
- package/native/pyo3/Cargo.toml +26 -26
- package/native/pyo3/pyproject.toml +16 -16
- package/native/pyo3/src/lib.rs +407 -407
- package/native/pyo3/tests/test_smoke.py +180 -180
- package/native/wasm/Cargo.toml +47 -44
- package/native/wasm/pkg/.gitignore +6 -6
- package/native/wasm/pkg/clawpowers_wasm.d.ts +208 -208
- package/native/wasm/pkg/clawpowers_wasm.js +872 -872
- package/native/wasm/pkg/clawpowers_wasm_bg.wasm.d.ts +40 -40
- package/native/wasm/pkg/package.json +16 -16
- package/native/wasm/pkg-node/clawpowers_wasm.d.ts +143 -143
- package/native/wasm/pkg-node/clawpowers_wasm.js +798 -798
- package/native/wasm/pkg-node/clawpowers_wasm_bg.wasm.d.ts +40 -40
- package/native/wasm/pkg-node/package.json +12 -12
- package/native/wasm/src/lib.rs +433 -433
- package/package.json +13 -8
- package/scripts/build-wasm.mjs +59 -0
- package/scripts/generate_hermes_wrappers.py +211 -0
- package/scripts/hermes_wrapper_overrides.json +184 -0
- package/scripts/run-python-script.mjs +48 -0
- package/scripts/verify-consumer-install.mjs +109 -0
- package/scripts/verify-wasm-artifacts.mjs +26 -3
- package/scripts/verify_hermes_wrappers.py +154 -0
- package/skill.json +20 -0
- package/skills/1password/SKILL.md +34 -0
- package/skills/README.md +44 -0
- package/skills/agent-nexus-2/SKILL.md +34 -0
- package/skills/apple-notes/SKILL.md +34 -0
- package/skills/apple-reminders/SKILL.md +34 -0
- package/skills/autoresearch/SKILL.md +43 -0
- package/skills/bear-notes/SKILL.md +34 -0
- package/skills/blogwatcher/SKILL.md +34 -0
- package/skills/blucli/SKILL.md +34 -0
- package/skills/bluebubbles/SKILL.md +34 -0
- package/skills/business-strategy/SKILL.md +41 -0
- package/skills/camsnap/SKILL.md +34 -0
- package/skills/canvas/SKILL.md +34 -0
- package/skills/clawhub/SKILL.md +34 -0
- package/skills/coding-agent/SKILL.md +34 -0
- package/skills/coding-discipline.skill/SKILL.md +34 -0
- package/skills/content-writer/SKILL.md +41 -0
- package/skills/discord/SKILL.md +34 -0
- package/skills/eightctl/SKILL.md +34 -0
- package/skills/execution-validation.skill/SKILL.md +34 -0
- package/skills/gemini/SKILL.md +34 -0
- package/skills/gh-issues/SKILL.md +34 -0
- package/skills/gifgrep/SKILL.md +34 -0
- package/skills/github/SKILL.md +41 -0
- package/skills/gog/SKILL.md +34 -0
- package/skills/goplaces/SKILL.md +34 -0
- package/skills/healthcheck/SKILL.md +34 -0
- package/skills/himalaya/SKILL.md +34 -0
- package/skills/humanize/SKILL.md +41 -0
- package/skills/imsg/SKILL.md +34 -0
- package/skills/itp/SKILL.md +112 -0
- package/skills/mcporter/SKILL.md +34 -0
- package/skills/model-usage/SKILL.md +34 -0
- package/skills/nano-pdf/SKILL.md +34 -0
- package/skills/node-connect/SKILL.md +34 -0
- package/skills/notion/SKILL.md +34 -0
- package/skills/obsidian/SKILL.md +34 -0
- package/skills/openai-whisper/SKILL.md +34 -0
- package/skills/openai-whisper-api/SKILL.md +34 -0
- package/skills/openhue/SKILL.md +34 -0
- package/skills/oracle/SKILL.md +34 -0
- package/skills/ordercli/SKILL.md +34 -0
- package/skills/peekaboo/SKILL.md +34 -0
- package/skills/polyclaw/SKILL.md +34 -0
- package/skills/prospector/SKILL.md +41 -0
- package/skills/rsi.skill/SKILL.md +34 -0
- package/skills/sag/SKILL.md +34 -0
- package/skills/security/SKILL.md +41 -0
- package/skills/session-logs/SKILL.md +34 -0
- package/skills/sherpa-onnx-tts/SKILL.md +34 -0
- package/skills/skill-creator/SKILL.md +34 -0
- package/skills/slack/SKILL.md +34 -0
- package/skills/songsee/SKILL.md +34 -0
- package/skills/sonoscli/SKILL.md +34 -0
- package/skills/spotify-player/SKILL.md +34 -0
- package/skills/strykr-prism/SKILL.md +41 -0
- package/skills/summarize/SKILL.md +34 -0
- package/skills/taskbridge/SKILL.md +34 -0
- package/skills/things-mac/SKILL.md +34 -0
- package/skills/tmux/SKILL.md +34 -0
- package/skills/trello/SKILL.md +34 -0
- package/skills/validator-agent/SKILL.md +41 -0
- package/skills/video-frames/SKILL.md +34 -0
- package/skills/voice-call/SKILL.md +34 -0
- package/skills/wacli/SKILL.md +34 -0
- package/skills/weather/SKILL.md +34 -0
- package/skills/webmcp-payments/SKILL.md +41 -0
- package/skills/xurl/SKILL.md +34 -0
- package/src/skills/catalog.ts +435 -435
- package/src/skills/executor.ts +56 -56
- package/src/skills/index.ts +3 -3
- package/src/skills/itp/SKILL.md +112 -112
- package/src/skills/loader.ts +262 -193
- package/native/ffi/index.node +0 -0
- package/native/wasm/pkg-node/.gitignore +0 -6
|
@@ -1,333 +1,333 @@
|
|
|
1
|
-
//! Exact verification pipeline for TurboMemory.
|
|
2
|
-
//!
|
|
3
|
-
//! The [`VerificationPipeline`] fetches records from a [`CanonicalStore`],
|
|
4
|
-
//! recomputes content hashes, and checks TTL / quarantine metadata before
|
|
5
|
-
//! returning a typed [`VerificationResult`].
|
|
6
|
-
|
|
7
|
-
use chrono::{Duration, Utc};
|
|
8
|
-
use clawpowers_canonical::{CanonicalRecord, CanonicalStore, compute_sha256};
|
|
9
|
-
use thiserror::Error;
|
|
10
|
-
use uuid::Uuid;
|
|
11
|
-
|
|
12
|
-
/// Errors returned by the verification pipeline.
|
|
13
|
-
#[derive(Debug, Error)]
|
|
14
|
-
pub enum VerificationError {
|
|
15
|
-
/// Underlying canonical store error.
|
|
16
|
-
#[error("store error: {0}")]
|
|
17
|
-
Store(#[from] clawpowers_canonical::CanonicalError),
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/// A shorthand result type for [`VerificationError`].
|
|
21
|
-
pub type Result<T> = std::result::Result<T, VerificationError>;
|
|
22
|
-
|
|
23
|
-
// ─── Verification Result ─────────────────────────────────────────────────────
|
|
24
|
-
|
|
25
|
-
/// The outcome of verifying a single record.
|
|
26
|
-
#[derive(Debug)]
|
|
27
|
-
pub enum VerificationResult {
|
|
28
|
-
/// Record is present, hash is valid, not expired, and not quarantined.
|
|
29
|
-
Verified(CanonicalRecord),
|
|
30
|
-
|
|
31
|
-
/// The stored hash does not match the recomputed hash.
|
|
32
|
-
IntegrityFailed {
|
|
33
|
-
/// Record identifier.
|
|
34
|
-
id: Uuid,
|
|
35
|
-
/// Hash value stored in the database.
|
|
36
|
-
expected_hash: String,
|
|
37
|
-
/// Hash value computed from the stored content.
|
|
38
|
-
actual_hash: String,
|
|
39
|
-
},
|
|
40
|
-
|
|
41
|
-
/// The record has a `ttl_seconds` metadata field and the record has
|
|
42
|
-
/// outlived it.
|
|
43
|
-
Expired {
|
|
44
|
-
/// Record identifier.
|
|
45
|
-
id: Uuid,
|
|
46
|
-
/// How far past the TTL deadline the record is.
|
|
47
|
-
ttl_exceeded_by: Duration,
|
|
48
|
-
},
|
|
49
|
-
|
|
50
|
-
/// The record has `"quarantined": true` in its metadata.
|
|
51
|
-
Quarantined {
|
|
52
|
-
/// Record identifier.
|
|
53
|
-
id: Uuid,
|
|
54
|
-
/// Reason, extracted from `metadata["quarantine_reason"]` if present.
|
|
55
|
-
reason: String,
|
|
56
|
-
},
|
|
57
|
-
|
|
58
|
-
/// No record with the given id exists in the store.
|
|
59
|
-
NotFound(Uuid),
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// ─── Pipeline ────────────────────────────────────────────────────────────────
|
|
63
|
-
|
|
64
|
-
/// Verification pipeline wrapping a [`CanonicalStore`].
|
|
65
|
-
pub struct VerificationPipeline {
|
|
66
|
-
store: CanonicalStore,
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
impl VerificationPipeline {
|
|
70
|
-
/// Create a new pipeline backed by `store`.
|
|
71
|
-
pub fn new(store: CanonicalStore) -> Self {
|
|
72
|
-
Self { store }
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/// Access the underlying canonical store.
|
|
76
|
-
pub fn store(&self) -> &CanonicalStore {
|
|
77
|
-
&self.store
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// ── Single Verification ───────────────────────────────────────────────
|
|
81
|
-
|
|
82
|
-
/// Verify a single record identified by `id`.
|
|
83
|
-
///
|
|
84
|
-
/// Steps:
|
|
85
|
-
/// 1. Fetch — [`VerificationResult::NotFound`] if absent.
|
|
86
|
-
/// 2. Hash check — [`VerificationResult::IntegrityFailed`] on mismatch.
|
|
87
|
-
/// 3. TTL check — [`VerificationResult::Expired`] if past `metadata["ttl_seconds"]`.
|
|
88
|
-
/// 4. Quarantine check — [`VerificationResult::Quarantined`] if
|
|
89
|
-
/// `metadata["quarantined"] == true`.
|
|
90
|
-
/// 5. Otherwise [`VerificationResult::Verified`].
|
|
91
|
-
pub fn verify(&self, id: &Uuid) -> Result<VerificationResult> {
|
|
92
|
-
// Step 1: Fetch.
|
|
93
|
-
let record = match self.store.get(id)? {
|
|
94
|
-
None => return Ok(VerificationResult::NotFound(*id)),
|
|
95
|
-
Some(r) => r,
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
// Step 2: Hash check.
|
|
99
|
-
let actual_hash = compute_sha256(&record.content);
|
|
100
|
-
if actual_hash != record.content_hash {
|
|
101
|
-
return Ok(VerificationResult::IntegrityFailed {
|
|
102
|
-
id: *id,
|
|
103
|
-
expected_hash: record.content_hash.clone(),
|
|
104
|
-
actual_hash,
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Step 3: TTL check.
|
|
109
|
-
if let Some(ttl_val) = record.metadata.get("ttl_seconds")
|
|
110
|
-
&& let Some(ttl_secs) = ttl_val.as_i64()
|
|
111
|
-
{
|
|
112
|
-
let deadline = record.created_at + Duration::seconds(ttl_secs);
|
|
113
|
-
let now = Utc::now();
|
|
114
|
-
if now > deadline {
|
|
115
|
-
let exceeded_by = now - deadline;
|
|
116
|
-
return Ok(VerificationResult::Expired {
|
|
117
|
-
id: *id,
|
|
118
|
-
ttl_exceeded_by: exceeded_by,
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Step 4: Quarantine check.
|
|
124
|
-
if record
|
|
125
|
-
.metadata
|
|
126
|
-
.get("quarantined")
|
|
127
|
-
.and_then(|v| v.as_bool())
|
|
128
|
-
.unwrap_or(false)
|
|
129
|
-
{
|
|
130
|
-
let reason = record
|
|
131
|
-
.metadata
|
|
132
|
-
.get("quarantine_reason")
|
|
133
|
-
.and_then(|v| v.as_str())
|
|
134
|
-
.unwrap_or("unknown")
|
|
135
|
-
.to_string();
|
|
136
|
-
return Ok(VerificationResult::Quarantined { id: *id, reason });
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
Ok(VerificationResult::Verified(record))
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// ── Batch Verification ────────────────────────────────────────────────
|
|
143
|
-
|
|
144
|
-
/// Verify a batch of records; each id is verified independently.
|
|
145
|
-
pub fn verify_batch(&self, ids: &[Uuid]) -> Result<Vec<VerificationResult>> {
|
|
146
|
-
ids.iter().map(|id| self.verify(id)).collect()
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// ─── Tests ───────────────────────────────────────────────────────────────────
|
|
151
|
-
|
|
152
|
-
#[cfg(test)]
|
|
153
|
-
mod tests {
|
|
154
|
-
use super::*;
|
|
155
|
-
use clawpowers_canonical::{CanonicalRecord, CanonicalStore};
|
|
156
|
-
use serde_json::json;
|
|
157
|
-
|
|
158
|
-
fn make_pipeline() -> VerificationPipeline {
|
|
159
|
-
let store = CanonicalStore::in_memory().expect("in-memory store");
|
|
160
|
-
VerificationPipeline::new(store)
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
fn insert(
|
|
164
|
-
pipeline: &VerificationPipeline,
|
|
165
|
-
namespace: &str,
|
|
166
|
-
content: &str,
|
|
167
|
-
metadata: serde_json::Value,
|
|
168
|
-
) -> Uuid {
|
|
169
|
-
let rec = CanonicalRecord::new(namespace, content, None, metadata, "test");
|
|
170
|
-
let id = rec.id;
|
|
171
|
-
pipeline.store().insert(&rec).expect("insert");
|
|
172
|
-
id
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// ── Verified ─────────────────────────────────────────────────────────
|
|
176
|
-
|
|
177
|
-
#[test]
|
|
178
|
-
fn test_verify_valid_record() {
|
|
179
|
-
let p = make_pipeline();
|
|
180
|
-
let id = insert(&p, "ns", "hello world", json!({}));
|
|
181
|
-
match p.verify(&id).expect("verify") {
|
|
182
|
-
VerificationResult::Verified(r) => assert_eq!(r.id, id),
|
|
183
|
-
other => panic!("expected Verified, got {other:?}"),
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
#[test]
|
|
188
|
-
fn test_verify_correct_hash_is_verified() {
|
|
189
|
-
let p = make_pipeline();
|
|
190
|
-
let id = insert(&p, "ns", "content for integrity check", json!({}));
|
|
191
|
-
assert!(matches!(
|
|
192
|
-
p.verify(&id).expect("verify"),
|
|
193
|
-
VerificationResult::Verified(_)
|
|
194
|
-
));
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// ── Not Found ─────────────────────────────────────────────────────────
|
|
198
|
-
|
|
199
|
-
#[test]
|
|
200
|
-
fn test_verify_not_found() {
|
|
201
|
-
let p = make_pipeline();
|
|
202
|
-
let missing = Uuid::new_v4();
|
|
203
|
-
match p.verify(&missing).expect("verify") {
|
|
204
|
-
VerificationResult::NotFound(id) => assert_eq!(id, missing),
|
|
205
|
-
other => panic!("expected NotFound, got {other:?}"),
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// ── Integrity (via hash comparison) ───────────────────────────────────
|
|
210
|
-
|
|
211
|
-
#[test]
|
|
212
|
-
fn test_verify_integrity_passes_for_fresh_record() {
|
|
213
|
-
let p = make_pipeline();
|
|
214
|
-
let id = insert(&p, "ns", "fresh record content", json!({}));
|
|
215
|
-
// Freshly inserted record should always pass hash check.
|
|
216
|
-
assert!(matches!(
|
|
217
|
-
p.verify(&id).expect("v"),
|
|
218
|
-
VerificationResult::Verified(_)
|
|
219
|
-
));
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
#[test]
|
|
223
|
-
fn test_integrity_check_uses_sha256() {
|
|
224
|
-
let content = "check sha256 content";
|
|
225
|
-
let expected_hash = compute_sha256(content);
|
|
226
|
-
let p = make_pipeline();
|
|
227
|
-
let rec = CanonicalRecord::new("ns", content, None, json!({}), "test");
|
|
228
|
-
assert_eq!(rec.content_hash, expected_hash);
|
|
229
|
-
let id = p.store().insert(&rec).expect("insert");
|
|
230
|
-
assert!(matches!(
|
|
231
|
-
p.verify(&id).expect("v"),
|
|
232
|
-
VerificationResult::Verified(_)
|
|
233
|
-
));
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// ── TTL ───────────────────────────────────────────────────────────────
|
|
237
|
-
|
|
238
|
-
#[test]
|
|
239
|
-
fn test_verify_expired_ttl() {
|
|
240
|
-
let p = make_pipeline();
|
|
241
|
-
let id = insert(&p, "ns", "old content", json!({"ttl_seconds": -1}));
|
|
242
|
-
match p.verify(&id).expect("verify") {
|
|
243
|
-
VerificationResult::Expired { id: eid, .. } => assert_eq!(eid, id),
|
|
244
|
-
other => panic!("expected Expired, got {other:?}"),
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
#[test]
|
|
249
|
-
fn test_verify_future_ttl_is_valid() {
|
|
250
|
-
let p = make_pipeline();
|
|
251
|
-
let id = insert(&p, "ns", "fresh content", json!({"ttl_seconds": 3600}));
|
|
252
|
-
assert!(matches!(
|
|
253
|
-
p.verify(&id).expect("v"),
|
|
254
|
-
VerificationResult::Verified(_)
|
|
255
|
-
));
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
#[test]
|
|
259
|
-
fn test_no_ttl_field_is_valid() {
|
|
260
|
-
let p = make_pipeline();
|
|
261
|
-
let id = insert(&p, "ns", "no ttl here", json!({"source": "test"}));
|
|
262
|
-
assert!(matches!(
|
|
263
|
-
p.verify(&id).expect("v"),
|
|
264
|
-
VerificationResult::Verified(_)
|
|
265
|
-
));
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// ── Quarantine ────────────────────────────────────────────────────────
|
|
269
|
-
|
|
270
|
-
#[test]
|
|
271
|
-
fn test_verify_quarantined_with_reason() {
|
|
272
|
-
let p = make_pipeline();
|
|
273
|
-
let id = insert(
|
|
274
|
-
&p,
|
|
275
|
-
"ns",
|
|
276
|
-
"suspicious content",
|
|
277
|
-
json!({"quarantined": true, "quarantine_reason": "policy violation"}),
|
|
278
|
-
);
|
|
279
|
-
match p.verify(&id).expect("verify") {
|
|
280
|
-
VerificationResult::Quarantined { id: qid, reason } => {
|
|
281
|
-
assert_eq!(qid, id);
|
|
282
|
-
assert_eq!(reason, "policy violation");
|
|
283
|
-
}
|
|
284
|
-
other => panic!("expected Quarantined, got {other:?}"),
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
#[test]
|
|
289
|
-
fn test_verify_quarantined_default_reason() {
|
|
290
|
-
let p = make_pipeline();
|
|
291
|
-
let id = insert(&p, "ns", "another bad record", json!({"quarantined": true}));
|
|
292
|
-
match p.verify(&id).expect("verify") {
|
|
293
|
-
VerificationResult::Quarantined { reason, .. } => assert_eq!(reason, "unknown"),
|
|
294
|
-
other => panic!("expected Quarantined, got {other:?}"),
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// ── Batch ─────────────────────────────────────────────────────────────
|
|
299
|
-
|
|
300
|
-
#[test]
|
|
301
|
-
fn test_verify_batch_mixed_results() {
|
|
302
|
-
let p = make_pipeline();
|
|
303
|
-
let valid_id = insert(&p, "ns", "valid batch record", json!({}));
|
|
304
|
-
let expired_id = insert(&p, "ns", "expired batch", json!({"ttl_seconds": -1}));
|
|
305
|
-
let missing_id = Uuid::new_v4();
|
|
306
|
-
|
|
307
|
-
let results = p
|
|
308
|
-
.verify_batch(&[valid_id, expired_id, missing_id])
|
|
309
|
-
.expect("batch");
|
|
310
|
-
assert_eq!(results.len(), 3);
|
|
311
|
-
assert!(matches!(results[0], VerificationResult::Verified(_)));
|
|
312
|
-
assert!(matches!(results[1], VerificationResult::Expired { .. }));
|
|
313
|
-
assert!(matches!(results[2], VerificationResult::NotFound(_)));
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
#[test]
|
|
317
|
-
fn test_verify_batch_empty() {
|
|
318
|
-
let p = make_pipeline();
|
|
319
|
-
let results = p.verify_batch(&[]).expect("batch");
|
|
320
|
-
assert!(results.is_empty());
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
#[test]
|
|
324
|
-
fn test_verify_batch_all_not_found() {
|
|
325
|
-
let p = make_pipeline();
|
|
326
|
-
let ids = vec![Uuid::new_v4(), Uuid::new_v4()];
|
|
327
|
-
let results = p.verify_batch(&ids).expect("batch");
|
|
328
|
-
assert_eq!(results.len(), 2);
|
|
329
|
-
for r in results {
|
|
330
|
-
assert!(matches!(r, VerificationResult::NotFound(_)));
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
}
|
|
1
|
+
//! Exact verification pipeline for TurboMemory.
|
|
2
|
+
//!
|
|
3
|
+
//! The [`VerificationPipeline`] fetches records from a [`CanonicalStore`],
|
|
4
|
+
//! recomputes content hashes, and checks TTL / quarantine metadata before
|
|
5
|
+
//! returning a typed [`VerificationResult`].
|
|
6
|
+
|
|
7
|
+
use chrono::{Duration, Utc};
|
|
8
|
+
use clawpowers_canonical::{CanonicalRecord, CanonicalStore, compute_sha256};
|
|
9
|
+
use thiserror::Error;
|
|
10
|
+
use uuid::Uuid;
|
|
11
|
+
|
|
12
|
+
/// Errors returned by the verification pipeline.
|
|
13
|
+
#[derive(Debug, Error)]
|
|
14
|
+
pub enum VerificationError {
|
|
15
|
+
/// Underlying canonical store error.
|
|
16
|
+
#[error("store error: {0}")]
|
|
17
|
+
Store(#[from] clawpowers_canonical::CanonicalError),
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// A shorthand result type for [`VerificationError`].
|
|
21
|
+
pub type Result<T> = std::result::Result<T, VerificationError>;
|
|
22
|
+
|
|
23
|
+
// ─── Verification Result ─────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/// The outcome of verifying a single record.
|
|
26
|
+
#[derive(Debug)]
|
|
27
|
+
pub enum VerificationResult {
|
|
28
|
+
/// Record is present, hash is valid, not expired, and not quarantined.
|
|
29
|
+
Verified(CanonicalRecord),
|
|
30
|
+
|
|
31
|
+
/// The stored hash does not match the recomputed hash.
|
|
32
|
+
IntegrityFailed {
|
|
33
|
+
/// Record identifier.
|
|
34
|
+
id: Uuid,
|
|
35
|
+
/// Hash value stored in the database.
|
|
36
|
+
expected_hash: String,
|
|
37
|
+
/// Hash value computed from the stored content.
|
|
38
|
+
actual_hash: String,
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
/// The record has a `ttl_seconds` metadata field and the record has
|
|
42
|
+
/// outlived it.
|
|
43
|
+
Expired {
|
|
44
|
+
/// Record identifier.
|
|
45
|
+
id: Uuid,
|
|
46
|
+
/// How far past the TTL deadline the record is.
|
|
47
|
+
ttl_exceeded_by: Duration,
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
/// The record has `"quarantined": true` in its metadata.
|
|
51
|
+
Quarantined {
|
|
52
|
+
/// Record identifier.
|
|
53
|
+
id: Uuid,
|
|
54
|
+
/// Reason, extracted from `metadata["quarantine_reason"]` if present.
|
|
55
|
+
reason: String,
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
/// No record with the given id exists in the store.
|
|
59
|
+
NotFound(Uuid),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Pipeline ────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/// Verification pipeline wrapping a [`CanonicalStore`].
|
|
65
|
+
pub struct VerificationPipeline {
|
|
66
|
+
store: CanonicalStore,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
impl VerificationPipeline {
|
|
70
|
+
/// Create a new pipeline backed by `store`.
|
|
71
|
+
pub fn new(store: CanonicalStore) -> Self {
|
|
72
|
+
Self { store }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// Access the underlying canonical store.
|
|
76
|
+
pub fn store(&self) -> &CanonicalStore {
|
|
77
|
+
&self.store
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Single Verification ───────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/// Verify a single record identified by `id`.
|
|
83
|
+
///
|
|
84
|
+
/// Steps:
|
|
85
|
+
/// 1. Fetch — [`VerificationResult::NotFound`] if absent.
|
|
86
|
+
/// 2. Hash check — [`VerificationResult::IntegrityFailed`] on mismatch.
|
|
87
|
+
/// 3. TTL check — [`VerificationResult::Expired`] if past `metadata["ttl_seconds"]`.
|
|
88
|
+
/// 4. Quarantine check — [`VerificationResult::Quarantined`] if
|
|
89
|
+
/// `metadata["quarantined"] == true`.
|
|
90
|
+
/// 5. Otherwise [`VerificationResult::Verified`].
|
|
91
|
+
pub fn verify(&self, id: &Uuid) -> Result<VerificationResult> {
|
|
92
|
+
// Step 1: Fetch.
|
|
93
|
+
let record = match self.store.get(id)? {
|
|
94
|
+
None => return Ok(VerificationResult::NotFound(*id)),
|
|
95
|
+
Some(r) => r,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Step 2: Hash check.
|
|
99
|
+
let actual_hash = compute_sha256(&record.content);
|
|
100
|
+
if actual_hash != record.content_hash {
|
|
101
|
+
return Ok(VerificationResult::IntegrityFailed {
|
|
102
|
+
id: *id,
|
|
103
|
+
expected_hash: record.content_hash.clone(),
|
|
104
|
+
actual_hash,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Step 3: TTL check.
|
|
109
|
+
if let Some(ttl_val) = record.metadata.get("ttl_seconds")
|
|
110
|
+
&& let Some(ttl_secs) = ttl_val.as_i64()
|
|
111
|
+
{
|
|
112
|
+
let deadline = record.created_at + Duration::seconds(ttl_secs);
|
|
113
|
+
let now = Utc::now();
|
|
114
|
+
if now > deadline {
|
|
115
|
+
let exceeded_by = now - deadline;
|
|
116
|
+
return Ok(VerificationResult::Expired {
|
|
117
|
+
id: *id,
|
|
118
|
+
ttl_exceeded_by: exceeded_by,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Step 4: Quarantine check.
|
|
124
|
+
if record
|
|
125
|
+
.metadata
|
|
126
|
+
.get("quarantined")
|
|
127
|
+
.and_then(|v| v.as_bool())
|
|
128
|
+
.unwrap_or(false)
|
|
129
|
+
{
|
|
130
|
+
let reason = record
|
|
131
|
+
.metadata
|
|
132
|
+
.get("quarantine_reason")
|
|
133
|
+
.and_then(|v| v.as_str())
|
|
134
|
+
.unwrap_or("unknown")
|
|
135
|
+
.to_string();
|
|
136
|
+
return Ok(VerificationResult::Quarantined { id: *id, reason });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
Ok(VerificationResult::Verified(record))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Batch Verification ────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/// Verify a batch of records; each id is verified independently.
|
|
145
|
+
pub fn verify_batch(&self, ids: &[Uuid]) -> Result<Vec<VerificationResult>> {
|
|
146
|
+
ids.iter().map(|id| self.verify(id)).collect()
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ─── Tests ───────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
#[cfg(test)]
|
|
153
|
+
mod tests {
|
|
154
|
+
use super::*;
|
|
155
|
+
use clawpowers_canonical::{CanonicalRecord, CanonicalStore};
|
|
156
|
+
use serde_json::json;
|
|
157
|
+
|
|
158
|
+
fn make_pipeline() -> VerificationPipeline {
|
|
159
|
+
let store = CanonicalStore::in_memory().expect("in-memory store");
|
|
160
|
+
VerificationPipeline::new(store)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
fn insert(
|
|
164
|
+
pipeline: &VerificationPipeline,
|
|
165
|
+
namespace: &str,
|
|
166
|
+
content: &str,
|
|
167
|
+
metadata: serde_json::Value,
|
|
168
|
+
) -> Uuid {
|
|
169
|
+
let rec = CanonicalRecord::new(namespace, content, None, metadata, "test");
|
|
170
|
+
let id = rec.id;
|
|
171
|
+
pipeline.store().insert(&rec).expect("insert");
|
|
172
|
+
id
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Verified ─────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
#[test]
|
|
178
|
+
fn test_verify_valid_record() {
|
|
179
|
+
let p = make_pipeline();
|
|
180
|
+
let id = insert(&p, "ns", "hello world", json!({}));
|
|
181
|
+
match p.verify(&id).expect("verify") {
|
|
182
|
+
VerificationResult::Verified(r) => assert_eq!(r.id, id),
|
|
183
|
+
other => panic!("expected Verified, got {other:?}"),
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#[test]
|
|
188
|
+
fn test_verify_correct_hash_is_verified() {
|
|
189
|
+
let p = make_pipeline();
|
|
190
|
+
let id = insert(&p, "ns", "content for integrity check", json!({}));
|
|
191
|
+
assert!(matches!(
|
|
192
|
+
p.verify(&id).expect("verify"),
|
|
193
|
+
VerificationResult::Verified(_)
|
|
194
|
+
));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Not Found ─────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
#[test]
|
|
200
|
+
fn test_verify_not_found() {
|
|
201
|
+
let p = make_pipeline();
|
|
202
|
+
let missing = Uuid::new_v4();
|
|
203
|
+
match p.verify(&missing).expect("verify") {
|
|
204
|
+
VerificationResult::NotFound(id) => assert_eq!(id, missing),
|
|
205
|
+
other => panic!("expected NotFound, got {other:?}"),
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Integrity (via hash comparison) ───────────────────────────────────
|
|
210
|
+
|
|
211
|
+
#[test]
|
|
212
|
+
fn test_verify_integrity_passes_for_fresh_record() {
|
|
213
|
+
let p = make_pipeline();
|
|
214
|
+
let id = insert(&p, "ns", "fresh record content", json!({}));
|
|
215
|
+
// Freshly inserted record should always pass hash check.
|
|
216
|
+
assert!(matches!(
|
|
217
|
+
p.verify(&id).expect("v"),
|
|
218
|
+
VerificationResult::Verified(_)
|
|
219
|
+
));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
#[test]
|
|
223
|
+
fn test_integrity_check_uses_sha256() {
|
|
224
|
+
let content = "check sha256 content";
|
|
225
|
+
let expected_hash = compute_sha256(content);
|
|
226
|
+
let p = make_pipeline();
|
|
227
|
+
let rec = CanonicalRecord::new("ns", content, None, json!({}), "test");
|
|
228
|
+
assert_eq!(rec.content_hash, expected_hash);
|
|
229
|
+
let id = p.store().insert(&rec).expect("insert");
|
|
230
|
+
assert!(matches!(
|
|
231
|
+
p.verify(&id).expect("v"),
|
|
232
|
+
VerificationResult::Verified(_)
|
|
233
|
+
));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── TTL ───────────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
#[test]
|
|
239
|
+
fn test_verify_expired_ttl() {
|
|
240
|
+
let p = make_pipeline();
|
|
241
|
+
let id = insert(&p, "ns", "old content", json!({"ttl_seconds": -1}));
|
|
242
|
+
match p.verify(&id).expect("verify") {
|
|
243
|
+
VerificationResult::Expired { id: eid, .. } => assert_eq!(eid, id),
|
|
244
|
+
other => panic!("expected Expired, got {other:?}"),
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
#[test]
|
|
249
|
+
fn test_verify_future_ttl_is_valid() {
|
|
250
|
+
let p = make_pipeline();
|
|
251
|
+
let id = insert(&p, "ns", "fresh content", json!({"ttl_seconds": 3600}));
|
|
252
|
+
assert!(matches!(
|
|
253
|
+
p.verify(&id).expect("v"),
|
|
254
|
+
VerificationResult::Verified(_)
|
|
255
|
+
));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
#[test]
|
|
259
|
+
fn test_no_ttl_field_is_valid() {
|
|
260
|
+
let p = make_pipeline();
|
|
261
|
+
let id = insert(&p, "ns", "no ttl here", json!({"source": "test"}));
|
|
262
|
+
assert!(matches!(
|
|
263
|
+
p.verify(&id).expect("v"),
|
|
264
|
+
VerificationResult::Verified(_)
|
|
265
|
+
));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── Quarantine ────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
#[test]
|
|
271
|
+
fn test_verify_quarantined_with_reason() {
|
|
272
|
+
let p = make_pipeline();
|
|
273
|
+
let id = insert(
|
|
274
|
+
&p,
|
|
275
|
+
"ns",
|
|
276
|
+
"suspicious content",
|
|
277
|
+
json!({"quarantined": true, "quarantine_reason": "policy violation"}),
|
|
278
|
+
);
|
|
279
|
+
match p.verify(&id).expect("verify") {
|
|
280
|
+
VerificationResult::Quarantined { id: qid, reason } => {
|
|
281
|
+
assert_eq!(qid, id);
|
|
282
|
+
assert_eq!(reason, "policy violation");
|
|
283
|
+
}
|
|
284
|
+
other => panic!("expected Quarantined, got {other:?}"),
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
#[test]
|
|
289
|
+
fn test_verify_quarantined_default_reason() {
|
|
290
|
+
let p = make_pipeline();
|
|
291
|
+
let id = insert(&p, "ns", "another bad record", json!({"quarantined": true}));
|
|
292
|
+
match p.verify(&id).expect("verify") {
|
|
293
|
+
VerificationResult::Quarantined { reason, .. } => assert_eq!(reason, "unknown"),
|
|
294
|
+
other => panic!("expected Quarantined, got {other:?}"),
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── Batch ─────────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
#[test]
|
|
301
|
+
fn test_verify_batch_mixed_results() {
|
|
302
|
+
let p = make_pipeline();
|
|
303
|
+
let valid_id = insert(&p, "ns", "valid batch record", json!({}));
|
|
304
|
+
let expired_id = insert(&p, "ns", "expired batch", json!({"ttl_seconds": -1}));
|
|
305
|
+
let missing_id = Uuid::new_v4();
|
|
306
|
+
|
|
307
|
+
let results = p
|
|
308
|
+
.verify_batch(&[valid_id, expired_id, missing_id])
|
|
309
|
+
.expect("batch");
|
|
310
|
+
assert_eq!(results.len(), 3);
|
|
311
|
+
assert!(matches!(results[0], VerificationResult::Verified(_)));
|
|
312
|
+
assert!(matches!(results[1], VerificationResult::Expired { .. }));
|
|
313
|
+
assert!(matches!(results[2], VerificationResult::NotFound(_)));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
#[test]
|
|
317
|
+
fn test_verify_batch_empty() {
|
|
318
|
+
let p = make_pipeline();
|
|
319
|
+
let results = p.verify_batch(&[]).expect("batch");
|
|
320
|
+
assert!(results.is_empty());
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
#[test]
|
|
324
|
+
fn test_verify_batch_all_not_found() {
|
|
325
|
+
let p = make_pipeline();
|
|
326
|
+
let ids = vec![Uuid::new_v4(), Uuid::new_v4()];
|
|
327
|
+
let results = p.verify_batch(&ids).expect("batch");
|
|
328
|
+
assert_eq!(results.len(), 2);
|
|
329
|
+
for r in results {
|
|
330
|
+
assert!(matches!(r, VerificationResult::NotFound(_)));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|