Skip to main content

grex_core/lockfile/
entry.rs

1//! Lockfile entry + error types.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7/// One resolved pack entry. Serialized as a single JSON line.
8///
9/// Marked `#[non_exhaustive]` so future audit fields (timestamps,
10/// resolved-ref metadata, plugin signatures) can land without breaking
11/// out-of-crate consumers that struct-literal-construct entries.
12/// Within `grex-core` the existing struct-literal sites continue to
13/// work unchanged; external callers should use [`LockEntry::new`] (and
14/// the field-level `pub` mutators) instead of struct literals.
15#[non_exhaustive]
16#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
17#[serde(from = "LockEntryRepr")]
18pub struct LockEntry {
19    /// Pack identifier — matches the manifest id.
20    pub id: String,
21    /// Parent-relative POSIX path of this pack's manifest within its
22    /// parent's `manifest.children`. Required for v1.2.0 distributed
23    /// lockfile resolution: each entry knows where its manifest lives
24    /// relative to the parent meta, so the walker can place the dest
25    /// correctly even when the same id appears nested.
26    ///
27    /// **Read-fallback**: v1.1.1 lockfiles do not carry this field; on
28    /// deserialize a missing `path` is filled with `id` (the v1.1.1 1:1
29    /// id↔folder convention). See `LockEntryRepr` and
30    /// `openspec/feat-grex/spec.md` (v1.2.0 distributed lockfile).
31    ///
32    /// **Validation**: must be parent-relative POSIX (no `..`, no
33    /// absolute, no backslash, non-empty). Use [`LockEntry::validate_path`].
34    pub path: String,
35    /// Resolved commit SHA at the time of install.
36    pub sha: String,
37    /// Branch or ref used to resolve `sha`.
38    pub branch: String,
39    /// Timestamp of the last successful install/sync.
40    pub installed_at: DateTime<Utc>,
41    /// Content hash of the declarative actions that ran. Empty for
42    /// imperative packs.
43    pub actions_hash: String,
44    /// Schema version of this entry.
45    pub schema_version: String,
46    /// `true` when the walker synthesised this pack's manifest in-memory
47    /// because the child had no `.grex/pack.yaml` but did carry a
48    /// `.git/` (v1.1.1 plain-git children). `#[serde(default)]` keeps
49    /// pre-v1.1.1 lockfiles forward-compatible — a missing field
50    /// deserialises to `false`. See
51    /// `openspec/changes/feat-v1.1.1-plain-git-children/design.md`.
52    ///
53    /// **v1.3.2 retirement (W1):** the field is no longer emitted by the
54    /// writer (`skip_serializing_if = "skip_synthetic_always"` always
55    /// skips). v1.1.x lockfiles that still carry `synthetic: true`
56    /// continue to deserialise via `#[serde(default)]`, so legacy
57    /// readers (doctor's `check_synthetic_packs`, ls's `~` glyph) keep
58    /// working until Phase 2c retires them. Fresh on-disk lockfiles
59    /// produced by v1.3.2+ contain no `synthetic` key for any entry.
60    /// See `pack-spec.md §v1.2.0` (sync-time auto-synthesis retired).
61    #[serde(default, skip_serializing_if = "skip_synthetic_always")]
62    pub synthetic: bool,
63}
64
65/// Always-skip predicate for the v1.3.2-retired `LockEntry.synthetic`
66/// field. Returning `true` unconditionally tells `serde` to omit the
67/// field from every serialized entry, regardless of in-memory state.
68/// The field is preserved in the struct so legacy v1.1.x lockfile
69/// reads (which may carry `synthetic: true`) still deserialise cleanly
70/// via `#[serde(default)]`.
71#[allow(clippy::trivially_copy_pass_by_ref)]
72fn skip_synthetic_always(_: &bool) -> bool {
73    true
74}
75
76/// Wire-format shadow used solely for deserialization. Carries `path`
77/// as `Option<String>` so v1.1.1 lockfiles (no `path` field) parse
78/// successfully; `From<LockEntryRepr> for LockEntry` then derives the
79/// missing path from `id`.
80///
81/// Stage 1.h (`--migrate-lockfile`, default-OFF) will rewrite v1.1.1
82/// lockfiles to carry the explicit `path`; until then the read-fallback
83/// keeps every existing on-disk lockfile readable.
84#[derive(Deserialize)]
85struct LockEntryRepr {
86    id: String,
87    #[serde(default)]
88    path: Option<String>,
89    sha: String,
90    branch: String,
91    installed_at: DateTime<Utc>,
92    actions_hash: String,
93    schema_version: String,
94    #[serde(default)]
95    synthetic: bool,
96}
97
98impl From<LockEntryRepr> for LockEntry {
99    fn from(r: LockEntryRepr) -> Self {
100        // v1.1.1 read-fallback: missing `path` → derive from `id`. In
101        // v1.1.1 the pack id == folder name (1:1), so `id` is the
102        // correct parent-relative path for legacy entries.
103        let path = r.path.unwrap_or_else(|| r.id.clone());
104        Self {
105            id: r.id,
106            path,
107            sha: r.sha,
108            branch: r.branch,
109            installed_at: r.installed_at,
110            actions_hash: r.actions_hash,
111            schema_version: r.schema_version,
112            synthetic: r.synthetic,
113        }
114    }
115}
116
117impl LockEntry {
118    /// Construct a new entry with every required field. The `synthetic`
119    /// flag defaults to `false`; callers that need a synthetic entry
120    /// should set the field directly after construction (the field is
121    /// `pub`).
122    ///
123    /// `path` defaults to `id` to preserve v1.1.1 caller compatibility
124    /// (v1.1.1's 1:1 id↔folder convention). Callers that need a
125    /// distinct manifest path should set the field directly after
126    /// construction.
127    ///
128    /// Because [`LockEntry`] is `#[non_exhaustive]`, this is the
129    /// canonical constructor for out-of-crate consumers — struct
130    /// literals will not compile from outside the crate.
131    #[must_use]
132    pub fn new(
133        id: impl Into<String>,
134        sha: impl Into<String>,
135        branch: impl Into<String>,
136        installed_at: DateTime<Utc>,
137        actions_hash: impl Into<String>,
138        schema_version: impl Into<String>,
139    ) -> Self {
140        let id = id.into();
141        let path = id.clone();
142        Self {
143            id,
144            path,
145            sha: sha.into(),
146            branch: branch.into(),
147            installed_at,
148            actions_hash: actions_hash.into(),
149            schema_version: schema_version.into(),
150            synthetic: false,
151        }
152    }
153
154    /// Validate that `path` is a parent-relative POSIX path.
155    ///
156    /// Rejects:
157    /// - empty string
158    /// - any `..` segment (parent traversal)
159    /// - absolute paths: leading `/`, or a Windows drive prefix like `C:`
160    /// - backslash separators (Windows-style — POSIX only)
161    ///
162    /// Accepts: simple names (`foo`), nested POSIX (`a/b/c`).
163    pub fn validate_path(path: &str) -> Result<(), LockfileError> {
164        if path.is_empty() {
165            return Err(LockfileError::InvalidPath {
166                path: path.to_string(),
167                reason: "path must not be empty",
168            });
169        }
170        if path.contains('\\') {
171            return Err(LockfileError::InvalidPath {
172                path: path.to_string(),
173                reason: "path must use POSIX `/` separator (no `\\`)",
174            });
175        }
176        if path.starts_with('/') {
177            return Err(LockfileError::InvalidPath {
178                path: path.to_string(),
179                reason: "path must be parent-relative (no leading `/`)",
180            });
181        }
182        // Windows-drive prefix detection: `C:`, `c:`, etc. A colon in
183        // position 1 with an ASCII letter at position 0 is the drive
184        // marker; reject it.
185        if path.len() >= 2 {
186            let mut chars = path.chars();
187            let c0 = chars.next().unwrap();
188            let c1 = chars.next().unwrap();
189            if c0.is_ascii_alphabetic() && c1 == ':' {
190                return Err(LockfileError::InvalidPath {
191                    path: path.to_string(),
192                    reason: "path must be parent-relative (no drive prefix)",
193                });
194            }
195        }
196        for segment in path.split('/') {
197            if segment == ".." {
198                return Err(LockfileError::InvalidPath {
199                    path: path.to_string(),
200                    reason: "path must not contain `..` segments",
201                });
202            }
203        }
204        Ok(())
205    }
206}
207
208/// Errors surfaced by lockfile read/write.
209#[derive(Debug, Error)]
210pub enum LockfileError {
211    /// I/O failure while reading or writing.
212    #[error("lockfile i/o error: {0}")]
213    Io(#[from] std::io::Error),
214
215    /// A line failed to parse. Lockfile corruption is always fatal — there
216    /// is no torn-line recovery rule since writes are atomic.
217    #[error("lockfile corrupted at line {line}: {source}")]
218    Corruption {
219        /// 1-based line number.
220        line: usize,
221        /// Underlying JSON parse error.
222        #[source]
223        source: serde_json::Error,
224    },
225
226    /// Serialization failure when writing.
227    #[error("lockfile serialize error: {0}")]
228    Serialize(serde_json::Error),
229
230    /// `LockEntry.path` failed validation. v1.2.0 distributed-lockfile
231    /// invariant: paths must be parent-relative POSIX (no `..`, no
232    /// absolute, no backslash, non-empty).
233    #[error("invalid lockfile entry path `{path}`: {reason}")]
234    InvalidPath {
235        /// The offending path string.
236        path: String,
237        /// Human-readable reason the path was rejected.
238        reason: &'static str,
239    },
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use chrono::{TimeZone, Utc};
246
247    fn ts() -> DateTime<Utc> {
248        Utc.with_ymd_and_hms(2026, 4, 27, 10, 0, 0).unwrap()
249    }
250
251    fn sample(id: &str, path: &str) -> LockEntry {
252        let mut e = LockEntry::new(id, "deadbeef", "main", ts(), "h", "1");
253        e.path = path.into();
254        e
255    }
256
257    /// v1.2.0 — explicit `path` field survives a JSON round-trip.
258    #[test]
259    fn test_lockentry_path_field_round_trip() {
260        let entry = sample("nested-child", "subdir/nested-child");
261        let line = serde_json::to_string(&entry).expect("serialize");
262        assert!(
263            line.contains(r#""path":"subdir/nested-child""#),
264            "serialized form must carry explicit path field, got: {line}"
265        );
266        let back: LockEntry = serde_json::from_str(&line).expect("deserialize");
267        assert_eq!(back, entry);
268        assert_eq!(back.path, "subdir/nested-child");
269    }
270
271    /// v1.1.1 forward-compat — a v1.1.1-shaped JSON line (no `path`
272    /// field) deserialises with `path` derived from `id` via the
273    /// read-fallback. Existing on-disk lockfiles remain readable.
274    #[test]
275    fn test_lockentry_v1_1_1_read_fallback() {
276        let line = r#"{"id":"alpha","sha":"abc","branch":"main","installed_at":"2026-04-27T10:00:00Z","actions_hash":"h","schema_version":"1"}"#;
277        let entry: LockEntry = serde_json::from_str(line).expect("v1.1.1 line must deserialize");
278        assert_eq!(entry.id, "alpha");
279        assert_eq!(
280            entry.path, "alpha",
281            "missing path must be derived from id for v1.1.1 lockfiles",
282        );
283        assert!(!entry.synthetic);
284    }
285
286    /// v1.1.1 read-fallback also works for synthetic entries.
287    #[test]
288    fn test_lockentry_v1_1_1_read_fallback_synthetic() {
289        let line = r#"{"id":"plain-git","sha":"deadbeef","branch":"main","installed_at":"2026-04-27T10:00:00Z","actions_hash":"","schema_version":"1","synthetic":true}"#;
290        let entry: LockEntry =
291            serde_json::from_str(line).expect("v1.1.1 synthetic must deserialize");
292        assert_eq!(entry.path, "plain-git");
293        assert!(entry.synthetic);
294    }
295
296    /// Validation: parent-traversal `..` segments are rejected.
297    #[test]
298    fn test_lockentry_path_validation_rejects_parent_traversal() {
299        assert!(LockEntry::validate_path("../escape").is_err());
300        assert!(LockEntry::validate_path("foo/../bar").is_err());
301        assert!(LockEntry::validate_path("..").is_err());
302    }
303
304    /// Validation: absolute paths (POSIX or Windows-drive) are rejected.
305    #[test]
306    fn test_lockentry_path_validation_rejects_absolute() {
307        assert!(LockEntry::validate_path("/foo").is_err());
308        assert!(LockEntry::validate_path("/").is_err());
309        assert!(LockEntry::validate_path("C:/foo").is_err());
310        assert!(LockEntry::validate_path("C:\\foo").is_err());
311    }
312
313    /// Validation: backslash separators are rejected — POSIX only.
314    #[test]
315    fn test_lockentry_path_must_be_posix_separator() {
316        assert!(LockEntry::validate_path("foo\\bar").is_err());
317        // sanity: valid POSIX paths pass
318        assert!(LockEntry::validate_path("foo/bar").is_ok());
319        assert!(LockEntry::validate_path("plain-git-child").is_ok());
320        assert!(LockEntry::validate_path("a/b/c").is_ok());
321    }
322
323    /// Validation: the empty string is not a valid path.
324    #[test]
325    fn test_lockentry_path_validation_rejects_empty() {
326        assert!(LockEntry::validate_path("").is_err());
327    }
328}