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}