1use crate::protocol::{ErrorPhase, RetryGuidance};
7use chrono::Utc;
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::time::SystemTime;
13
14pub const RESULT_SCHEMA_VERSION: u32 = 1;
15const MAX_RESULT_ID_BYTES: usize = 96;
16const MAX_RESULT_BYTES: usize = 512 * 1024;
17const MAX_DETAIL_BYTES: usize = 256 * 1024;
18static RESULT_SEQUENCE: AtomicU64 = AtomicU64::new(1);
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum, Default)]
22#[serde(rename_all = "lowercase")]
23pub enum ResponseMode {
24 #[default]
25 Minimal,
26 Normal,
27 Diagnostic,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct DetailAvailability {
34 pub result_id: String,
35 pub details_available: bool,
36 pub details_truncated: bool,
37}
38
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct OperationResult {
43 pub status: String,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub execution_id: Option<String>,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub revision: Option<u64>,
48 pub phase: ErrorPhase,
49 pub mutation_possible: bool,
50 pub retry: RetryGuidance,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub effect: Option<Value>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub verification: Option<Value>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub target: Option<Value>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub policy: Option<Value>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub details: Option<Value>,
61 pub detail_availability: DetailAvailability,
62}
63
64impl OperationResult {
65 pub fn project(&self, mode: ResponseMode) -> Value {
67 let mut output = Map::new();
68 for key in [
69 "status",
70 "executionId",
71 "revision",
72 "phase",
73 "mutationPossible",
74 "retry",
75 "effect",
76 "verification",
77 ] {
78 if let Some(value) = serde_json::to_value(self)
79 .ok()
80 .and_then(|value| value.get(key).cloned())
81 {
82 output.insert(key.into(), value);
83 }
84 }
85 if matches!(mode, ResponseMode::Normal | ResponseMode::Diagnostic) {
86 for key in ["target", "policy", "detailAvailability"] {
87 if let Some(value) = serde_json::to_value(self)
88 .ok()
89 .and_then(|value| value.get(key).cloned())
90 {
91 output.insert(key.into(), value);
92 }
93 }
94 }
95 if mode == ResponseMode::Diagnostic
96 && let Some(details) = &self.details
97 {
98 output.insert("details".into(), details.clone());
99 }
100 Value::Object(output)
101 }
102}
103
104pub fn project_value(mut value: Value, mode: ResponseMode) -> Value {
110 if mode != ResponseMode::Diagnostic
111 && let Value::Object(object) = &mut value
112 {
113 object.remove("details");
114 if mode == ResponseMode::Minimal {
115 for key in ["trace", "diagnostics", "screenshot", "dom", "formValues"] {
116 object.remove(key);
117 }
118 }
119 }
120 value
121}
122
123pub fn project_and_store(
125 value: Value,
126 mode: ResponseMode,
127 prefix: &str,
128 root: impl Into<PathBuf>,
129) -> Result<Value, ResultStoreError> {
130 let store = ResultStore::new(root);
131 let result_id = ResultStore::next_id(prefix)?;
132 let availability = store.save(&result_id, &value)?;
133 let mut projected = project_value(value, mode);
134 if let Value::Object(object) = &mut projected {
135 object.insert(
136 "detailAvailability".into(),
137 serde_json::to_value(availability)?,
138 );
139 } else {
140 projected = serde_json::json!({
141 "result": projected,
142 "detailAvailability": availability,
143 });
144 }
145 Ok(projected)
146}
147
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct ResultArtifact {
152 pub schema_version: u32,
153 pub result_id: String,
154 pub created_at: String,
155 pub details_truncated: bool,
156 pub details: Value,
157}
158
159#[derive(Debug, Clone)]
161pub struct ResultStore {
162 root: PathBuf,
163 max_bytes: usize,
164}
165
166impl ResultStore {
167 pub fn new(root: impl Into<PathBuf>) -> Self {
168 Self {
169 root: root.into(),
170 max_bytes: MAX_RESULT_BYTES,
171 }
172 }
173
174 pub fn with_max_bytes(mut self, max_bytes: usize) -> Self {
175 self.max_bytes = max_bytes.max(1024);
176 self
177 }
178
179 pub fn root(&self) -> &Path {
180 &self.root
181 }
182
183 pub fn next_id(prefix: &str) -> Result<String, ResultStoreError> {
184 let id = format!(
185 "{}_{}_{}",
186 sanitize_id(prefix)?,
187 Utc::now().timestamp_millis(),
188 RESULT_SEQUENCE.fetch_add(1, Ordering::Relaxed)
189 );
190 validate_id(&id)?;
191 Ok(id)
192 }
193
194 pub fn save(
195 &self,
196 result_id: &str,
197 details: &Value,
198 ) -> Result<DetailAvailability, ResultStoreError> {
199 validate_id(result_id)?;
200 std::fs::create_dir_all(&self.root)?;
201 let redacted = redact_value(details);
202 let raw = serde_json::to_vec(&redacted)?;
203 let (details, details_truncated) = if raw.len() > MAX_DETAIL_BYTES {
204 (
205 Value::String(String::from("diagnostic details truncated")),
206 true,
207 )
208 } else {
209 (redacted, false)
210 };
211 let artifact = ResultArtifact {
212 schema_version: RESULT_SCHEMA_VERSION,
213 result_id: result_id.to_string(),
214 created_at: Utc::now().to_rfc3339(),
215 details_truncated,
216 details,
217 };
218 let bytes = serde_json::to_vec(&artifact)?;
219 if bytes.len() > self.max_bytes {
220 return Err(ResultStoreError::TooLarge(bytes.len(), self.max_bytes));
221 }
222 let destination = self.path_for(result_id);
223 let temporary = destination.with_extension("json.tmp");
224 std::fs::write(&temporary, bytes)?;
225 std::fs::rename(&temporary, &destination)?;
226 Ok(DetailAvailability {
227 result_id: result_id.to_string(),
228 details_available: true,
229 details_truncated,
230 })
231 }
232
233 pub fn load(&self, result_id: &str) -> Result<ResultArtifact, ResultStoreError> {
234 validate_id(result_id)?;
235 let artifact: ResultArtifact =
236 serde_json::from_slice(&std::fs::read(self.path_for(result_id))?)?;
237 if artifact.schema_version != RESULT_SCHEMA_VERSION || artifact.result_id != result_id {
238 return Err(ResultStoreError::InvalidArtifact(result_id.to_string()));
239 }
240 Ok(artifact)
241 }
242
243 pub fn purge_older_than(&self, age: std::time::Duration) -> Result<usize, ResultStoreError> {
244 if !self.root.exists() {
245 return Ok(0);
246 }
247 let cutoff = SystemTime::now()
248 .checked_sub(age)
249 .unwrap_or(SystemTime::UNIX_EPOCH);
250 let mut removed = 0;
251 for entry in std::fs::read_dir(&self.root)? {
252 let entry = entry?;
253 if entry.path().extension().and_then(|value| value.to_str()) != Some("json") {
254 continue;
255 }
256 if entry.metadata()?.modified().unwrap_or(SystemTime::now()) < cutoff {
257 std::fs::remove_file(entry.path())?;
258 removed += 1;
259 }
260 }
261 Ok(removed)
262 }
263
264 fn path_for(&self, result_id: &str) -> PathBuf {
265 self.root.join(format!("{result_id}.json"))
266 }
267}
268
269pub fn default_result_store_path() -> PathBuf {
271 dirs::cache_dir()
272 .unwrap_or_else(std::env::temp_dir)
273 .join("glass")
274 .join("results")
275}
276
277#[derive(Debug)]
278pub enum ResultStoreError {
279 InvalidId,
280 InvalidArtifact(String),
281 TooLarge(usize, usize),
282 Io(std::io::Error),
283 Json(serde_json::Error),
284}
285
286impl std::fmt::Display for ResultStoreError {
287 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288 match self {
289 Self::InvalidId => formatter.write_str("invalid result ID"),
290 Self::InvalidArtifact(id) => write!(formatter, "result artifact is invalid: {id}"),
291 Self::TooLarge(actual, limit) => write!(
292 formatter,
293 "result artifact is {actual} bytes, exceeding the {limit}-byte budget"
294 ),
295 Self::Io(error) => error.fmt(formatter),
296 Self::Json(error) => error.fmt(formatter),
297 }
298 }
299}
300
301impl std::error::Error for ResultStoreError {}
302
303impl From<std::io::Error> for ResultStoreError {
304 fn from(error: std::io::Error) -> Self {
305 Self::Io(error)
306 }
307}
308
309impl From<serde_json::Error> for ResultStoreError {
310 fn from(error: serde_json::Error) -> Self {
311 Self::Json(error)
312 }
313}
314
315fn sanitize_id(value: &str) -> Result<String, ResultStoreError> {
316 if value.is_empty() || value.len() > MAX_RESULT_ID_BYTES {
317 return Err(ResultStoreError::InvalidId);
318 }
319 let sanitized = value
320 .chars()
321 .map(|character| {
322 if character.is_ascii_alphanumeric() || character == '-' {
323 character
324 } else {
325 '_'
326 }
327 })
328 .collect::<String>();
329 validate_id(&sanitized)?;
330 Ok(sanitized)
331}
332
333fn validate_id(value: &str) -> Result<(), ResultStoreError> {
334 if value.is_empty()
335 || value.len() > MAX_RESULT_ID_BYTES
336 || value == "."
337 || value == ".."
338 || value.contains('/')
339 || value.contains('\\')
340 || value.chars().any(char::is_whitespace)
341 {
342 return Err(ResultStoreError::InvalidId);
343 }
344 Ok(())
345}
346
347fn redact_value(value: &Value) -> Value {
348 match value {
349 Value::Object(object) => {
350 let mut result = Map::new();
351 for (key, value) in object {
352 if is_sensitive_key(key) {
353 result.insert(key.clone(), Value::String("[REDACTED]".into()));
354 } else {
355 result.insert(key.clone(), redact_value(value));
356 }
357 }
358 Value::Object(result)
359 }
360 Value::Array(values) => Value::Array(values.iter().map(redact_value).collect()),
361 other => other.clone(),
362 }
363}
364
365fn is_sensitive_key(key: &str) -> bool {
366 let key = key.to_ascii_lowercase();
367 [
368 "cookie",
369 "cookies",
370 "authorization",
371 "headers",
372 "password",
373 "payment",
374 "typedformvalues",
375 "formvalues",
376 "screenshot",
377 "dom",
378 "console",
379 "network",
380 ]
381 .iter()
382 .any(|sensitive| key.contains(sensitive))
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 #[test]
390 fn response_modes_preserve_recovery_fields_and_bound_details() {
391 let result = OperationResult {
392 status: "indeterminate".into(),
393 execution_id: Some("run_1".into()),
394 revision: Some(4),
395 phase: ErrorPhase::PostDispatch,
396 mutation_possible: true,
397 retry: RetryGuidance {
398 classification: crate::protocol::RetryClassification::UnsafeUntilReconciled,
399 recommended_operation: "recover_run".into(),
400 },
401 effect: Some(serde_json::json!({"observed": false})),
402 verification: None,
403 target: Some(serde_json::json!({"id": "submit"})),
404 policy: Some(serde_json::json!({"allowed": true})),
405 details: Some(serde_json::json!({"trace": [1, 2, 3]})),
406 detail_availability: DetailAvailability {
407 result_id: "run_1".into(),
408 details_available: true,
409 details_truncated: false,
410 },
411 };
412 assert!(result.project(ResponseMode::Minimal)["retry"].is_object());
413 assert!(
414 result
415 .project(ResponseMode::Minimal)
416 .get("details")
417 .is_none()
418 );
419 assert!(result.project(ResponseMode::Normal)["target"].is_object());
420 assert!(result.project(ResponseMode::Diagnostic)["details"].is_object());
421 }
422
423 #[test]
424 fn result_store_redacts_and_round_trips_atomically() {
425 let root = std::env::temp_dir().join(format!("glass-results-{}", std::process::id()));
426 let _ = std::fs::remove_dir_all(&root);
427 let store = ResultStore::new(&root);
428 let availability = store
429 .save(
430 "run_1",
431 &serde_json::json!({"cookie": "secret", "nested": {"password": "secret"}, "value": 3}),
432 )
433 .unwrap();
434 assert!(availability.details_available);
435 let artifact = store.load("run_1").unwrap();
436 assert_eq!(artifact.details["cookie"], "[REDACTED]");
437 assert_eq!(artifact.details["nested"]["password"], "[REDACTED]");
438 assert_eq!(artifact.details["value"], 3);
439 let _ = std::fs::remove_dir_all(root);
440 }
441}