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    #[serde(default)]
53    pub synthetic: bool,
54}
55
56/// Wire-format shadow used solely for deserialization. Carries `path`
57/// as `Option<String>` so v1.1.1 lockfiles (no `path` field) parse
58/// successfully; `From<LockEntryRepr> for LockEntry` then derives the
59/// missing path from `id`.
60///
61/// Stage 1.h (`--migrate-lockfile`, default-OFF) will rewrite v1.1.1
62/// lockfiles to carry the explicit `path`; until then the read-fallback
63/// keeps every existing on-disk lockfile readable.
64#[derive(Deserialize)]
65struct LockEntryRepr {
66    id: String,
67    #[serde(default)]
68    path: Option<String>,
69    sha: String,
70    branch: String,
71    installed_at: DateTime<Utc>,
72    actions_hash: String,
73    schema_version: String,
74    #[serde(default)]
75    synthetic: bool,
76}
77
78impl From<LockEntryRepr> for LockEntry {
79    fn from(r: LockEntryRepr) -> Self {
80        // v1.1.1 read-fallback: missing `path` → derive from `id`. In
81        // v1.1.1 the pack id == folder name (1:1), so `id` is the
82        // correct parent-relative path for legacy entries.
83        let path = r.path.unwrap_or_else(|| r.id.clone());
84        Self {
85            id: r.id,
86            path,
87            sha: r.sha,
88            branch: r.branch,
89            installed_at: r.installed_at,
90            actions_hash: r.actions_hash,
91            schema_version: r.schema_version,
92            synthetic: r.synthetic,
93        }
94    }
95}
96
97impl LockEntry {
98    /// Construct a new entry with every required field. The `synthetic`
99    /// flag defaults to `false`; callers that need a synthetic entry
100    /// should set the field directly after construction (the field is
101    /// `pub`).
102    ///
103    /// `path` defaults to `id` to preserve v1.1.1 caller compatibility
104    /// (v1.1.1's 1:1 id↔folder convention). Callers that need a
105    /// distinct manifest path should set the field directly after
106    /// construction.
107    ///
108    /// Because [`LockEntry`] is `#[non_exhaustive]`, this is the
109    /// canonical constructor for out-of-crate consumers — struct
110    /// literals will not compile from outside the crate.
111    #[must_use]
112    pub fn new(
113        id: impl Into<String>,
114        sha: impl Into<String>,
115        branch: impl Into<String>,
116        installed_at: DateTime<Utc>,
117        actions_hash: impl Into<String>,
118        schema_version: impl Into<String>,
119    ) -> Self {
120        let id = id.into();
121        let path = id.clone();
122        Self {
123            id,
124            path,
125            sha: sha.into(),
126            branch: branch.into(),
127            installed_at,
128            actions_hash: actions_hash.into(),
129            schema_version: schema_version.into(),
130            synthetic: false,
131        }
132    }
133
134    /// Validate that `path` is a parent-relative POSIX path.
135    ///
136    /// Rejects:
137    /// - empty string
138    /// - any `..` segment (parent traversal)
139    /// - absolute paths: leading `/`, or a Windows drive prefix like `C:`
140    /// - backslash separators (Windows-style — POSIX only)
141    ///
142    /// Accepts: simple names (`foo`), nested POSIX (`a/b/c`).
143    pub fn validate_path(path: &str) -> Result<(), LockfileError> {
144        if path.is_empty() {
145            return Err(LockfileError::InvalidPath {
146                path: path.to_string(),
147                reason: "path must not be empty",
148            });
149        }
150        if path.contains('\\') {
151            return Err(LockfileError::InvalidPath {
152                path: path.to_string(),
153                reason: "path must use POSIX `/` separator (no `\\`)",
154            });
155        }
156        if path.starts_with('/') {
157            return Err(LockfileError::InvalidPath {
158                path: path.to_string(),
159                reason: "path must be parent-relative (no leading `/`)",
160            });
161        }
162        // Windows-drive prefix detection: `C:`, `c:`, etc. A colon in
163        // position 1 with an ASCII letter at position 0 is the drive
164        // marker; reject it.
165        if path.len() >= 2 {
166            let mut chars = path.chars();
167            let c0 = chars.next().unwrap();
168            let c1 = chars.next().unwrap();
169            if c0.is_ascii_alphabetic() && c1 == ':' {
170                return Err(LockfileError::InvalidPath {
171                    path: path.to_string(),
172                    reason: "path must be parent-relative (no drive prefix)",
173                });
174            }
175        }
176        for segment in path.split('/') {
177            if segment == ".." {
178                return Err(LockfileError::InvalidPath {
179                    path: path.to_string(),
180                    reason: "path must not contain `..` segments",
181                });
182            }
183        }
184        Ok(())
185    }
186}
187
188/// Errors surfaced by lockfile read/write.
189#[derive(Debug, Error)]
190pub enum LockfileError {
191    /// I/O failure while reading or writing.
192    #[error("lockfile i/o error: {0}")]
193    Io(#[from] std::io::Error),
194
195    /// A line failed to parse. Lockfile corruption is always fatal — there
196    /// is no torn-line recovery rule since writes are atomic.
197    #[error("lockfile corrupted at line {line}: {source}")]
198    Corruption {
199        /// 1-based line number.
200        line: usize,
201        /// Underlying JSON parse error.
202        #[source]
203        source: serde_json::Error,
204    },
205
206    /// Serialization failure when writing.
207    #[error("lockfile serialize error: {0}")]
208    Serialize(serde_json::Error),
209
210    /// `LockEntry.path` failed validation. v1.2.0 distributed-lockfile
211    /// invariant: paths must be parent-relative POSIX (no `..`, no
212    /// absolute, no backslash, non-empty).
213    #[error("invalid lockfile entry path `{path}`: {reason}")]
214    InvalidPath {
215        /// The offending path string.
216        path: String,
217        /// Human-readable reason the path was rejected.
218        reason: &'static str,
219    },
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use chrono::{TimeZone, Utc};
226
227    fn ts() -> DateTime<Utc> {
228        Utc.with_ymd_and_hms(2026, 4, 27, 10, 0, 0).unwrap()
229    }
230
231    fn sample(id: &str, path: &str) -> LockEntry {
232        let mut e = LockEntry::new(id, "deadbeef", "main", ts(), "h", "1");
233        e.path = path.into();
234        e
235    }
236
237    /// v1.2.0 — explicit `path` field survives a JSON round-trip.
238    #[test]
239    fn test_lockentry_path_field_round_trip() {
240        let entry = sample("nested-child", "subdir/nested-child");
241        let line = serde_json::to_string(&entry).expect("serialize");
242        assert!(
243            line.contains(r#""path":"subdir/nested-child""#),
244            "serialized form must carry explicit path field, got: {line}"
245        );
246        let back: LockEntry = serde_json::from_str(&line).expect("deserialize");
247        assert_eq!(back, entry);
248        assert_eq!(back.path, "subdir/nested-child");
249    }
250
251    /// v1.1.1 forward-compat — a v1.1.1-shaped JSON line (no `path`
252    /// field) deserialises with `path` derived from `id` via the
253    /// read-fallback. Existing on-disk lockfiles remain readable.
254    #[test]
255    fn test_lockentry_v1_1_1_read_fallback() {
256        let line = r#"{"id":"alpha","sha":"abc","branch":"main","installed_at":"2026-04-27T10:00:00Z","actions_hash":"h","schema_version":"1"}"#;
257        let entry: LockEntry = serde_json::from_str(line).expect("v1.1.1 line must deserialize");
258        assert_eq!(entry.id, "alpha");
259        assert_eq!(
260            entry.path, "alpha",
261            "missing path must be derived from id for v1.1.1 lockfiles",
262        );
263        assert!(!entry.synthetic);
264    }
265
266    /// v1.1.1 read-fallback also works for synthetic entries.
267    #[test]
268    fn test_lockentry_v1_1_1_read_fallback_synthetic() {
269        let line = r#"{"id":"plain-git","sha":"deadbeef","branch":"main","installed_at":"2026-04-27T10:00:00Z","actions_hash":"","schema_version":"1","synthetic":true}"#;
270        let entry: LockEntry =
271            serde_json::from_str(line).expect("v1.1.1 synthetic must deserialize");
272        assert_eq!(entry.path, "plain-git");
273        assert!(entry.synthetic);
274    }
275
276    /// Validation: parent-traversal `..` segments are rejected.
277    #[test]
278    fn test_lockentry_path_validation_rejects_parent_traversal() {
279        assert!(LockEntry::validate_path("../escape").is_err());
280        assert!(LockEntry::validate_path("foo/../bar").is_err());
281        assert!(LockEntry::validate_path("..").is_err());
282    }
283
284    /// Validation: absolute paths (POSIX or Windows-drive) are rejected.
285    #[test]
286    fn test_lockentry_path_validation_rejects_absolute() {
287        assert!(LockEntry::validate_path("/foo").is_err());
288        assert!(LockEntry::validate_path("/").is_err());
289        assert!(LockEntry::validate_path("C:/foo").is_err());
290        assert!(LockEntry::validate_path("C:\\foo").is_err());
291    }
292
293    /// Validation: backslash separators are rejected — POSIX only.
294    #[test]
295    fn test_lockentry_path_must_be_posix_separator() {
296        assert!(LockEntry::validate_path("foo\\bar").is_err());
297        // sanity: valid POSIX paths pass
298        assert!(LockEntry::validate_path("foo/bar").is_ok());
299        assert!(LockEntry::validate_path("plain-git-child").is_ok());
300        assert!(LockEntry::validate_path("a/b/c").is_ok());
301    }
302
303    /// Validation: the empty string is not a valid path.
304    #[test]
305    fn test_lockentry_path_validation_rejects_empty() {
306        assert!(LockEntry::validate_path("").is_err());
307    }
308}