返回 CodeWhale
policy.rs
根目录 / crates / memory / src / policy.rs
1 use crate::{Draft, Embedding, Error, Result};
2 use regex::RegexSet;
3 use sha2::{Digest, Sha256};
4 use std::sync::OnceLock;
5
6 pub const MAX_BODY_BYTES: usize = 8192;
7 pub const MAX_FRAME_BYTES: usize = 64 * 1024;
8
9 pub fn sha256(bytes: &[u8]) -> String {
10 Sha256::digest(bytes)
11 .iter()
12 .map(|b| format!("{b:02x}"))
13 .collect()
14 }
15 pub fn content_hash(draft: &Draft) -> Result<String> {
16 // Exclude timestamps and provenance so replaying the same normalized content
17 // with a new observation time does not bypass exact-content tombstones.
18 Ok(sha256(&serde_json::to_vec(&(
19 draft.kind,
20 &draft.title,
21 &draft.body,
22 &draft.key,
23 &draft.tags,
24 ))?))
25 }
26 pub fn ensure_no_secret(text: &str) -> Result<()> {
27 static SECRETS: OnceLock<RegexSet> = OnceLock::new();
28 let rules = SECRETS.get_or_init(|| RegexSet::new([
29 r"-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----",
30 r"\b(?:sk-(?:proj-|ant-)?[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,})",
31 r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b",
32 r"\bxox[baprs]-[A-Za-z0-9-]{16,}",
33 r"(?i)\bbearer\s+[A-Za-z0-9._~+/-]{16,}",
34 r#"(?i)\b(?:api[_-]?key|password|passwd|client[_-]?secret|access[_-]?token)\s*[=:]\s*["']?[A-Za-z0-9+/_=.-]{8,}"#,
35 r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}",
36 ]).expect("static secret patterns are valid"));
37 if rules.is_match(text) {
38 Err(Error::SecretDetected)
39 } else {
40 Ok(())
41 }
42 }
43 pub fn bounded(value: &str, name: &str, max: usize, nonempty: bool) -> Result<()> {
44 if value.len() > max || (nonempty && value.trim().is_empty()) || value.contains('\0') {
45 return Err(Error::Invalid(format!(
46 "{name} is empty, contains NUL, or exceeds its size limit"
47 )));
48 }
49 Ok(())
50 }
51 pub fn validate_draft(d: &Draft, now: i64) -> Result<()> {
52 d.scope.validate()?;
53 if d.valid_from.is_some_and(|t| t < 0)
54 || d.valid_until.is_some_and(|t| t < 0)
55 || matches!((d.valid_from,d.valid_until),(Some(a),Some(b)) if a>=b)
56 {
57 return Err(Error::Invalid("invalid valid-time interval".into()));
58 }
59 bounded(&d.title, "title", 256, true)?;
60 bounded(&d.body, "body", MAX_BODY_BYTES, true)?;
61 if let Some(k) = &d.key {
62 bounded(k, "semantic key", 256, true)?;
63 }
64 for v in [d.confidence, d.importance] {
65 if !v.is_finite() || !(0.0..=1.0).contains(&v) {
66 return Err(Error::Invalid(
67 "confidence and importance must be finite values in [0,1]".into(),
68 ));
69 }
70 }
71 if d.evidence.is_empty()
72 || d.evidence.len() > 16
73 || d.tags.len() > 32
74 || d.dependencies.len() > 64
75 || d.parent_ids.len() > 32
76 {
77 return Err(Error::Invalid(
78 "evidence required; collection limits exceeded".into(),
79 ));
80 }
81 for tag in &d.tags {
82 bounded(tag, "tag", 96, true)?;
83 }
84 if d.expires_at.is_some_and(|t| t <= now) {
85 return Err(Error::Invalid("expiry must be in the future".into()));
86 }
87 if let Some(rev) = &d.repository_revision {
88 bounded(rev, "repository revision", 256, true)?;
89 }
90 for e in &d.evidence {
91 bounded(&e.uri, "evidence URI", 1024, true)?;
92 bounded(&e.locator, "evidence locator", 256, false)?;
93 if e.observed_at < 0 || e.observed_at > now.saturating_add(300) {
94 return Err(Error::Invalid("invalid observation time".into()));
95 }
96 if let Some(h) = &e.sha256 {
97 validate_digest(h)?;
98 }
99 }
100 for (path, digest) in &d.dependencies {
101 crate::workspace::validate_relative_path(path)?;
102 validate_digest(digest)?;
103 }
104 if (!d.dependencies.is_empty() || d.repository_revision.is_some())
105 && d.scope.workspace.is_none()
106 {
107 return Err(Error::Invalid(
108 "repository-bound memories require a workspace scope".into(),
109 ));
110 }
111 for id in &d.parent_ids {
112 bounded(id, "parent id", 128, true)?;
113 }
114 ensure_no_secret(&serde_json::to_string(d)?)?;
115 Ok(())
116 }
117 pub fn validate_digest(h: &str) -> Result<()> {
118 if h.len() != 64
119 || !h
120 .bytes()
121 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
122 {
123 return Err(Error::Invalid(
124 "SHA-256 digests must be 64 lowercase hexadecimal characters".into(),
125 ));
126 }
127 Ok(())
128 }
129 pub fn normalize_embedding(e: &Embedding) -> Result<Vec<f32>> {
130 bounded(&e.model, "embedding model identity", 256, true)?;
131 if e.vector.is_empty() || e.vector.len() > 8192 || e.vector.iter().any(|v| !v.is_finite()) {
132 return Err(Error::Invalid(
133 "invalid embedding dimensions or values".into(),
134 ));
135 }
136 let norm = e
137 .vector
138 .iter()
139 .map(|v| (*v as f64).powi(2))
140 .sum::<f64>()
141 .sqrt();
142 if norm <= f64::EPSILON || !norm.is_finite() {
143 return Err(Error::Invalid(
144 "embedding norm must be finite and nonzero".into(),
145 ));
146 }
147 Ok(e.vector.iter().map(|v| (*v as f64 / norm) as f32).collect())
148 }
149 /// All MATCH operators become literal text. Never accept raw FTS syntax from a model.
150 pub fn fts_query(text: &str) -> Result<Option<String>> {
151 bounded(text, "query", 1024, false)?;
152 let tokens: Vec<_> = text
153 .split(|c: char| !c.is_alphanumeric())
154 .filter(|s| !s.is_empty())
155 .take(32)
156 .collect();
157 if tokens.is_empty() {
158 return Ok(None);
159 }
160 Ok(Some(
161 tokens
162 .iter()
163 .map(|s| format!("\"{}\"", s.replace('"', "\"\"")))
164 .collect::<Vec<_>>()
165 .join(" OR "),
166 ))
167 }
168 pub fn excerpt(text: &str, chars: usize) -> String {
169 text.chars().take(chars).collect()
170 }
171
171 lines RUST