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}