Skip to main content

parallax_snapshot/
lib.rs

1//! Deterministic `.plx` snapshot format.
2
3#![deny(unsafe_code)]
4#![warn(missing_docs)]
5
6use chrono::{DateTime, Utc};
7use parallax_core::{
8    check_snapshot_format, ErrorCode, ExecutionState, ParallaxError, RuntimeKind, SnapshotId,
9    SNAPSHOT_FORMAT_VERSION,
10};
11use parallax_ir::{content_hash, from_json_bytes, to_json_bytes_compact, PirDocument};
12use serde::{Deserialize, Serialize};
13use std::fs;
14use std::path::Path;
15
16/// Magic bytes for `.plx` files (JSON document; magic in header field).
17pub const SNAPSHOT_MAGIC: &str = "PARALLAX_PLX";
18
19/// On-disk snapshot document.
20#[derive(Clone, Debug, Serialize, Deserialize)]
21pub struct Snapshot {
22    /// Magic marker.
23    pub magic: String,
24    /// Format version.
25    pub format_version: u32,
26    /// Snapshot id.
27    pub id: SnapshotId,
28    /// Creation time.
29    pub created_at: DateTime<Utc>,
30    /// Origin runtime.
31    pub runtime: RuntimeKind,
32    /// Optional label.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub label: Option<String>,
35    /// Execution state shell.
36    pub state: ExecutionState,
37    /// PIR document (bindings / heap).
38    pub pir: PirDocument,
39    /// SHA-256 of canonical pir+state payload (excluding this field and id timestamps variance handled).
40    pub content_hash: String,
41}
42
43impl Snapshot {
44    /// Create a snapshot from state + PIR.
45    pub fn create(
46        runtime: RuntimeKind,
47        state: ExecutionState,
48        pir: PirDocument,
49        label: Option<String>,
50    ) -> parallax_core::Result<Self> {
51        pir.validate()?;
52        let mut snap = Self {
53            magic: SNAPSHOT_MAGIC.into(),
54            format_version: SNAPSHOT_FORMAT_VERSION,
55            id: SnapshotId::new(),
56            created_at: Utc::now(),
57            runtime,
58            label,
59            state,
60            pir,
61            content_hash: String::new(),
62        };
63        snap.content_hash = snap.compute_hash()?;
64        Ok(snap)
65    }
66
67    fn compute_hash(&self) -> parallax_core::Result<String> {
68        let payload = serde_json::json!({
69            "format_version": self.format_version,
70            "runtime": self.runtime,
71            "state": self.state,
72            "pir": self.pir,
73        });
74        let bytes = serde_json::to_vec(&payload).map_err(ParallaxError::from)?;
75        Ok(content_hash(&bytes))
76    }
77
78    /// Validate magic, version, PIR, and content hash.
79    pub fn validate(&self) -> parallax_core::Result<()> {
80        if self.magic != SNAPSHOT_MAGIC {
81            return Err(ParallaxError::new(
82                ErrorCode::InvalidSnapshot,
83                format!("invalid snapshot magic: {}", self.magic),
84            )
85            .with_source("parallax-snapshot")
86            .with_operation("validate"));
87        }
88        check_snapshot_format(self.format_version).map_err(|e| {
89            e.with_source("parallax-snapshot")
90                .with_operation("validate")
91        })?;
92        self.pir.validate()?;
93        let expected = self.compute_hash()?;
94        if expected != self.content_hash {
95            return Err(ParallaxError::new(
96                ErrorCode::InvalidSnapshot,
97                "snapshot content hash mismatch",
98            )
99            .with_source("parallax-snapshot")
100            .with_operation("validate")
101            .context("expected", expected)
102            .context("actual", self.content_hash.clone()));
103        }
104        Ok(())
105    }
106
107    /// Write snapshot to a `.plx` JSON file.
108    pub fn write_to_path(&self, path: &Path) -> parallax_core::Result<()> {
109        self.validate()?;
110        let bytes = serde_json::to_vec_pretty(self).map_err(ParallaxError::from)?;
111        if let Some(parent) = path.parent() {
112            if !parent.as_os_str().is_empty() {
113                fs::create_dir_all(parent)?;
114            }
115        }
116        fs::write(path, bytes)?;
117        Ok(())
118    }
119
120    /// Read and validate a snapshot from disk.
121    pub fn read_from_path(path: &Path) -> parallax_core::Result<Self> {
122        let bytes = fs::read(path)?;
123        let snap: Self = serde_json::from_slice(&bytes).map_err(|e| {
124            ParallaxError::new(ErrorCode::InvalidSnapshot, e.to_string())
125                .with_source("parallax-snapshot")
126                .with_operation("read_from_path")
127        })?;
128        snap.validate()?;
129        Ok(snap)
130    }
131
132    /// Compact inspection summary.
133    pub fn inspect_summary(&self) -> SnapshotInspect {
134        SnapshotInspect {
135            id: self.id.to_string(),
136            runtime: self.runtime.clone(),
137            created_at: self.created_at,
138            label: self.label.clone(),
139            format_version: self.format_version,
140            content_hash: self.content_hash.clone(),
141            binding_names: self.pir.bindings.keys().cloned().collect(),
142            value_count: self.pir.value_count(),
143            pir_schema: self.pir.schema,
144        }
145    }
146}
147
148/// Human/JSON inspect view.
149#[derive(Clone, Debug, Serialize, Deserialize)]
150pub struct SnapshotInspect {
151    /// Snapshot id.
152    pub id: String,
153    /// Runtime.
154    pub runtime: RuntimeKind,
155    /// Created at.
156    pub created_at: DateTime<Utc>,
157    /// Label.
158    pub label: Option<String>,
159    /// Format version.
160    pub format_version: u32,
161    /// Content hash.
162    pub content_hash: String,
163    /// Binding names.
164    pub binding_names: Vec<String>,
165    /// Value node count.
166    pub value_count: usize,
167    /// PIR schema.
168    pub pir_schema: u32,
169}
170
171/// Encode PIR to bytes helper used by tests.
172pub fn encode_pir(doc: &PirDocument) -> parallax_core::Result<Vec<u8>> {
173    to_json_bytes_compact(doc)
174}
175
176/// Decode PIR helper.
177pub fn decode_pir(bytes: &[u8]) -> parallax_core::Result<PirDocument> {
178    from_json_bytes(bytes)
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use parallax_core::{RuntimeCapabilities, RuntimeKind};
185    use parallax_ir::{PirInteger, PirValue};
186    use tempfile::tempdir;
187
188    #[test]
189    fn snapshot_round_trip() {
190        let mut pir = PirDocument::new();
191        pir.set_binding("state", PirValue::int_i64(42));
192        let state = ExecutionState::empty(RuntimeKind::Python, RuntimeCapabilities::python());
193        let snap = Snapshot::create(RuntimeKind::Python, state, pir, Some("t".into())).unwrap();
194        let dir = tempdir().unwrap();
195        let path = dir.path().join("t.plx");
196        snap.write_to_path(&path).unwrap();
197        let loaded = Snapshot::read_from_path(&path).unwrap();
198        assert_eq!(loaded.content_hash, snap.content_hash);
199        assert_eq!(
200            loaded.pir.binding("state"),
201            Some(&PirValue::Int {
202                v: PirInteger::from_i64(42)
203            })
204        );
205    }
206
207    #[test]
208    fn rejects_future_format_version() {
209        let mut pir = PirDocument::new();
210        pir.set_binding("state", PirValue::int_i64(1));
211        let state = ExecutionState::empty(RuntimeKind::Python, RuntimeCapabilities::python());
212        let mut snap = Snapshot::create(RuntimeKind::Python, state, pir, None).unwrap();
213        snap.format_version = SNAPSHOT_FORMAT_VERSION + 1;
214        let err = snap.validate().unwrap_err();
215        assert_eq!(err.code, ErrorCode::InvalidSnapshot);
216        assert!(err.message.contains("unsupported snapshot format"));
217    }
218}