Skip to main content

git_policy_cli/
transaction_journal_parse.rs

1//! What:
2//!  Strict parsers of every schema-version-2 journal record.
3//! Why:
4//!  Recovery reads records either wrapper wrote;
5//!  every missing or mistyped field fails
6//!      closed with the record and field named,
7//!  exactly as the incumbent's
8//!      `commit-transaction-journal-parse.ts` and `commit-transaction-journal-reader.ts` do.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! const preparing = parsePreparingRecord(bytes);
13//! ```
14
15/// Record field readers and the fatal decoder.
16use super::json_record::{decode_fatal, number_equals, parse_object, safe_integer, string_field};
17/// The fail-closed recovery failure.
18use super::recovery_error::RecoveryError;
19/// The record types and filenames.
20use super::transaction_journal::{
21    AddedPath, Base, Conclusion, FileIdentity, IndexLockRecord, JOURNAL_SCHEMA_VERSION,
22    LandingOperation, LandingRecord, LockIdentity, PREPARED_FILENAME, PREPARING_FILENAME,
23    PreparedRecord, PreparingRecord, REF_UPDATED_FILENAME, RefFormat, SymbolicHead,
24    TransactionMode,
25};
26/// What:
27///  `Map` is a JSON object's table,
28///  `Value` any JSON value.
29/// Why:
30///   Records are checked field by field from the general form.
31///
32/// In TS you'd write (pseudocode):
33/// ```ts
34/// type Value = unknown;
35/// ```
36use serde_json::{Map, Value};
37
38/// What:
39///  A field reader over one untrusted record object,
40///  naming the record in failures.
41/// Why:
42///   The incumbent's `createRecordReader`:
43///  each accessor fails closed with
44///       "Transaction record <name> has a malformed <key> field."
45///
46/// In TS you'd write (pseudocode):
47/// ```ts
48/// type RecordReader = { string(key): string; ... };
49/// ```
50pub struct RecordReader<'a> {
51    /// Record name used in diagnostics,
52    ///  `#`-joined for nested objects.
53    name: String,
54    /// The record object.
55    map: &'a Map<String, Value>,
56}
57
58/// Typed accessors,
59///  each failing closed with the field named.
60impl<'a> RecordReader<'a> {
61    /// What:
62    ///  A reader over a parsed object.
63    /// Why:
64    ///   Nested objects get their own reader with a `#`-joined name.
65    ///
66    /// In TS you'd write (pseudocode):
67    /// ```ts
68    /// createRecordReader({ name, value })
69    /// ```
70    pub fn new(name: &str, map: &'a Map<String, Value>) -> Self {
71        return Self {
72            name: String::from(name),
73            map,
74        };
75    }
76
77    /// What:
78    ///  The malformed-field failure.
79    /// Why:
80    ///   Every accessor reports the same sentence.
81    ///
82    /// In TS you'd write (pseudocode):
83    /// ```ts
84    /// malformed(key)
85    /// ```
86    pub fn malformed(&self, key: &str) -> RecoveryError {
87        return RecoveryError(format!(
88            "Transaction record {} has a malformed {key} field.",
89            self.name
90        ));
91    }
92
93    /// What:
94    ///  A required text field;
95    ///  any text,
96    ///  empty included,
97    ///  as `typeof === 'string'`.
98    /// Why:
99    ///   The incumbent's `readString`.
100    ///
101    /// In TS you'd write (pseudocode):
102    /// ```ts
103    /// read.string(key)
104    /// ```
105    pub fn string(&self, key: &str) -> Result<String, RecoveryError> {
106        match string_field(self.map, key) {
107            Some(text) => return Ok(String::from(text)),
108            None => return Err(self.malformed(key)),
109        }
110    }
111
112    /// What:
113    ///  An optional text field:
114    ///  absent is `None`,
115    ///  present must be text.
116    /// Why:
117    ///   The incumbent's `optionalString` (`key in value`).
118    ///
119    /// In TS you'd write (pseudocode):
120    /// ```ts
121    /// read.optionalString(key)
122    /// ```
123    pub fn optional_string(&self, key: &str) -> Result<Option<String>, RecoveryError> {
124        if !self.map.contains_key(key) {
125            return Ok(None);
126        }
127        return Ok(Some(self.string(key)?));
128    }
129
130    /// What:
131    ///  A required boolean field.
132    /// Why:
133    ///   The incumbent's `boolean`.
134    ///
135    /// In TS you'd write (pseudocode):
136    /// ```ts
137    /// read.boolean(key)
138    /// ```
139    pub fn boolean(&self, key: &str) -> Result<bool, RecoveryError> {
140        match self.map.get(key) {
141            Some(Value::Bool(flag)) => return Ok(*flag),
142            Some(_) | None => return Err(self.malformed(key)),
143        }
144    }
145
146    /// What:
147    ///  A required safe integer of at least 1.
148    /// Why:
149    ///   The incumbent's `positiveInteger`.
150    ///
151    /// In TS you'd write (pseudocode):
152    /// ```ts
153    /// read.positiveInteger(key)
154    /// ```
155    pub fn positive_integer(&self, key: &str) -> Result<i64, RecoveryError> {
156        match self.map.get(key).and_then(safe_integer) {
157            Some(whole) if whole >= 1 => return Ok(whole),
158            Some(_) | None => return Err(self.malformed(key)),
159        }
160    }
161
162    /// What:
163    ///  A required array of text.
164    /// Why:
165    ///   The incumbent's `strings`.
166    ///
167    /// In TS you'd write (pseudocode):
168    /// ```ts
169    /// read.strings(key)
170    /// ```
171    pub fn strings(&self, key: &str) -> Result<Vec<String>, RecoveryError> {
172        let Some(Value::Array(items)) = self.map.get(key) else {
173            return Err(self.malformed(key));
174        };
175        let mut texts: Vec<String> = Vec::with_capacity(items.len());
176        for item in items {
177            let Value::String(text) = item else {
178                return Err(self.malformed(key));
179            };
180            texts.push(text.clone());
181        }
182        return Ok(texts);
183    }
184
185    /// What:
186    ///  A required nested object,
187    ///  as its own reader.
188    /// Why:
189    ///   The incumbent's `object`;
190    ///  arrays and `null` are not objects here.
191    ///
192    /// In TS you'd write (pseudocode):
193    /// ```ts
194    /// read.object(key)
195    /// ```
196    pub fn object(&self, key: &str) -> Result<RecordReader<'a>, RecoveryError> {
197        match self.map.get(key) {
198            Some(Value::Object(nested)) => {
199                return Ok(RecordReader {
200                    name: format!("{}#{key}", self.name),
201                    map: nested,
202                });
203            }
204            Some(_) | None => return Err(self.malformed(key)),
205        }
206    }
207
208    /// What:
209    ///  A required base.
210    /// Why:
211    ///   The incumbent's `base`:
212    ///  `unborn`,
213    ///  or `commit` with an `oid`.
214    ///
215    /// In TS you'd write (pseudocode):
216    /// ```ts
217    /// read.base(key)
218    /// ```
219    pub fn base(&self, key: &str) -> Result<Base, RecoveryError> {
220        let nested: RecordReader<'a> = self.object(key)?;
221        let kind: String = nested.string("kind")?;
222        if kind == "unborn" {
223            return Ok(Base::Unborn);
224        }
225        if kind != "commit" {
226            return Err(self.malformed(key));
227        }
228        return Ok(Base::Commit(nested.string("oid")?));
229    }
230
231    /// What:
232    ///  A required file identity.
233    /// Why:
234    ///   The incumbent's `identity`.
235    ///
236    /// In TS you'd write (pseudocode):
237    /// ```ts
238    /// read.identity(key)
239    /// ```
240    pub fn identity(&self, key: &str) -> Result<FileIdentity, RecoveryError> {
241        let nested: RecordReader<'a> = self.object(key)?;
242        return Ok(FileIdentity {
243            device: nested.string("device")?,
244            inode: nested.string("inode")?,
245        });
246    }
247
248    /// What:
249    ///  A required lock identity.
250    /// Why:
251    ///   The incumbent's `lock`.
252    ///
253    /// In TS you'd write (pseudocode):
254    /// ```ts
255    /// read.lock(key)
256    /// ```
257    pub fn lock(&self, key: &str) -> Result<LockIdentity, RecoveryError> {
258        return Ok(LockIdentity {
259            file: self.identity(key)?,
260            fs_id: self.object(key)?.string("fsId")?,
261        });
262    }
263
264    /// What:
265    ///  A required list of added-path records.
266    /// Why:
267    ///   The incumbent's `addedPaths`:
268    ///  each item an object with a regular or executable mode.
269    ///
270    /// In TS you'd write (pseudocode):
271    /// ```ts
272    /// read.addedPaths(key)
273    /// ```
274    pub fn added_paths(&self, key: &str) -> Result<Vec<AddedPath>, RecoveryError> {
275        let Some(Value::Array(items)) = self.map.get(key) else {
276            return Err(self.malformed(key));
277        };
278        let mut records: Vec<AddedPath> = Vec::with_capacity(items.len());
279        for item in items {
280            // `typeof item !== 'object' || item === null` admits arrays; their fields are missing.
281            let Value::Object(fields) = item else {
282                return Err(self.malformed(key));
283            };
284            let nested: RecordReader<'_> = RecordReader {
285                name: format!("{}#{key}", self.name),
286                map: fields,
287            };
288            let git_mode: String = nested.string("gitMode")?;
289            if git_mode != "100644" && git_mode != "100755" {
290                return Err(self.malformed(key));
291            }
292            records.push(AddedPath {
293                path: nested.string("path")?,
294                git_mode,
295                original_oid: nested.string("originalOid")?,
296                intended_oid: nested.string("intendedOid")?,
297            });
298        }
299        return Ok(records);
300    }
301}
302
303/// What:
304///  Parse a record's bytes into its object after checking schema version and state.
305///       Returns the parsed object;
306///  the caller builds a reader over it.
307/// Why:
308///   The incumbent's `openRecord`:
309///  fatal UTF-8,
310///  a JSON object,
311///  version 2,
312///  the expected
313///       state;
314///  anything else fails with the record named.
315///
316/// In TS you'd write (pseudocode):
317/// ```ts
318/// function openRecord({ bytes, name, state }): RecordReader;
319/// ```
320pub fn open_record(
321    bytes: &[u8],
322    name: &str,
323    state: &str,
324) -> Result<Map<String, Value>, RecoveryError> {
325    let failure: RecoveryError = RecoveryError(format!(
326        "Transaction record {name} is not a schema-version-2 {state} record."
327    ));
328    let Some(text) = decode_fatal(bytes) else {
329        return Err(failure);
330    };
331    let Some(map) = parse_object(text) else {
332        return Err(failure);
333    };
334    if !number_equals(&map, "schemaVersion", JOURNAL_SCHEMA_VERSION)
335        || string_field(&map, "state") != Some(state)
336    {
337        return Err(failure);
338    }
339    return Ok(map);
340}
341
342/// What:
343///  Parse `preparing.json`.
344/// Why:
345///   The incumbent's `parsePreparingRecord`.
346///
347/// In TS you'd write (pseudocode):
348/// ```ts
349/// function parsePreparingRecord(bytes: Uint8Array): PreparingRecord;
350/// ```
351pub fn parse_preparing(bytes: &[u8]) -> Result<PreparingRecord, RecoveryError> {
352    let map: Map<String, Value> = open_record(bytes, PREPARING_FILENAME, "preparing")?;
353    let read: RecordReader<'_> = RecordReader::new(PREPARING_FILENAME, &map);
354    let mode_text: String = read.string("mode")?;
355    let conclusion_text: String = read.string("conclusion")?;
356    let format_text: String = read.string("refFormat")?;
357    let (Some(mode), Some(conclusion), Some(ref_format)) = (
358        parse_mode(mode_text.as_str()),
359        parse_conclusion(conclusion_text.as_str()),
360        parse_ref_format(format_text.as_str()),
361    ) else {
362        return Err(RecoveryError(String::from(
363            "Transaction record preparing.json has a malformed mode, conclusion, or refFormat field.",
364        )));
365    };
366    let transaction_id: String = read.string("transactionId")?;
367    let base: Base = read.base("base")?;
368    let head: RecordReader<'_> = read.object("symbolicHead")?;
369    let head_kind: String = head.string("kind")?;
370    let symbolic_head: SymbolicHead = if head_kind == "detached" {
371        SymbolicHead::Detached
372    } else if head_kind == "branch" {
373        SymbolicHead::Branch(head.string("ref")?)
374    } else {
375        return Err(RecoveryError(String::from(
376            "Transaction record preparing.json has a malformed symbolicHead field.",
377        )));
378    };
379    return Ok(PreparingRecord {
380        transaction_id,
381        mode,
382        base,
383        symbolic_head,
384        target_ref: read.string("targetRef")?,
385        conclusion,
386        repository_root: read.string("repositoryRoot")?,
387        git_dir: read.string("gitDir")?,
388        common_dir: read.string("commonDir")?,
389        real_index_path: read.string("realIndexPath")?,
390        object_directory: read.string("objectDirectory")?,
391        ref_format,
392        empty_tree_oid: read.string("emptyTreeOid")?,
393        shadow_path: read.string("shadowPath")?,
394        selected_pathspecs: read.strings("selectedPathspecs")?,
395        invoked_at: read.string("invokedAt")?,
396    });
397}
398
399/// What:
400///  The mode a wire spelling names.
401/// Why:
402///   Unknown spellings are malformed.
403///
404/// In TS you'd write (pseudocode):
405/// ```ts
406/// mode === 'explicit-path' || mode === 'index'
407/// ```
408fn parse_mode(text: &str) -> Option<TransactionMode> {
409    match text {
410        "explicit-path" => return Some(TransactionMode::ExplicitPath),
411        "index" => return Some(TransactionMode::Index),
412        _ => return None,
413    }
414}
415
416/// What:
417///  The conclusion kind a wire spelling names.
418/// Why:
419///   Unknown spellings are malformed.
420///
421/// In TS you'd write (pseudocode):
422/// ```ts
423/// isConclusionKind(text)
424/// ```
425fn parse_conclusion(text: &str) -> Option<Conclusion> {
426    match text {
427        "none" => return Some(Conclusion::None),
428        "amend" => return Some(Conclusion::Amend),
429        "merge" => return Some(Conclusion::Merge),
430        "cherry-pick" => return Some(Conclusion::CherryPick),
431        "revert" => return Some(Conclusion::Revert),
432        _ => return None,
433    }
434}
435
436/// What:
437///  The ref format a wire spelling names.
438/// Why:
439///   Unknown spellings are malformed.
440///
441/// In TS you'd write (pseudocode):
442/// ```ts
443/// refFormat === 'files' || refFormat === 'reftable'
444/// ```
445fn parse_ref_format(text: &str) -> Option<RefFormat> {
446    match text {
447        "files" => return Some(RefFormat::Files),
448        "reftable" => return Some(RefFormat::Reftable),
449        _ => return None,
450    }
451}
452
453/// What:
454///  Parse `prepared.json`.
455/// Why:
456///   The incumbent's `parsePreparedRecord`.
457///
458/// In TS you'd write (pseudocode):
459/// ```ts
460/// function parsePreparedRecord(bytes: Uint8Array): PreparedRecord;
461/// ```
462pub fn parse_prepared(bytes: &[u8]) -> Result<PreparedRecord, RecoveryError> {
463    let map: Map<String, Value> = open_record(bytes, PREPARED_FILENAME, "prepared")?;
464    let read: RecordReader<'_> = RecordReader::new(PREPARED_FILENAME, &map);
465    return Ok(PreparedRecord {
466        shadow_path: read.string("shadowPath")?,
467        prepared_oid: read.string("preparedOid")?,
468        signed: read.boolean("signed")?,
469        intended_tree_oid: read.string("intendedTreeOid")?,
470        committed_paths: read.strings("committedPaths")?,
471        added_paths: read.added_paths("addedPaths")?,
472        selected_worktree_paths: read.added_paths("selectedWorktreePaths")?,
473    });
474}
475
476/// What:
477///  Parse `index-lock-<n>.json`.
478/// Why:
479///   The incumbent's `parseIndexLockRecord`.
480///
481/// In TS you'd write (pseudocode):
482/// ```ts
483/// function parseIndexLockRecord(bytes: Uint8Array): IndexLockRecord;
484/// ```
485pub fn parse_index_lock(bytes: &[u8]) -> Result<IndexLockRecord, RecoveryError> {
486    let name: &str = "index-lock record";
487    let map: Map<String, Value> = open_record(bytes, name, "index-locked")?;
488    let read: RecordReader<'_> = RecordReader::new(name, &map);
489    return Ok(IndexLockRecord {
490        attempt: read.positive_integer("attempt")?,
491        lock: read.lock("lock")?,
492    });
493}
494
495/// What:
496///  Parse `landing-<n>.json`.
497/// Why:
498///   The incumbent's `parseLandingRecord`:
499///  a commit landing must name its new OID.
500///
501/// In TS you'd write (pseudocode):
502/// ```ts
503/// function parseLandingRecord(bytes: Uint8Array): LandingRecord;
504/// ```
505pub fn parse_landing(bytes: &[u8]) -> Result<LandingRecord, RecoveryError> {
506    let name: &str = "landing record";
507    let map: Map<String, Value> = open_record(bytes, name, "landing")?;
508    let read: RecordReader<'_> = RecordReader::new(name, &map);
509    let operation_text: String = read.string("operation")?;
510    let new_oid: Option<String> = read.optional_string("newOid")?;
511    let pack_name: Option<String> = read.optional_string("packName")?;
512    let operation: LandingOperation = match operation_text.as_str() {
513        "commit" if new_oid.is_some() => LandingOperation::Commit,
514        "normalize-only" => LandingOperation::NormalizeOnly,
515        _ => {
516            return Err(RecoveryError(String::from(
517                "Transaction record landing record has a malformed operation or newOid field.",
518            )));
519        }
520    };
521    return Ok(LandingRecord {
522        attempt: read.positive_integer("attempt")?,
523        operation,
524        expected_old: read.base("expectedOld")?,
525        new_oid,
526        landed_tree_oid: read.string("landedTreeOid")?,
527        pre_landing_index: read.identity("preLandingIndex")?,
528        post_index: read.identity("postIndex")?,
529        lock: read.lock("lock")?,
530        pack_name,
531        added_paths: read.added_paths("addedPaths")?,
532        selected_worktree_paths: read.added_paths("selectedWorktreePaths")?,
533    });
534}
535
536/// What:
537///  Parse `ref-updated.json` into the landed commit.
538/// Why:
539///   The incumbent's `parseRefUpdatedRecord`.
540///
541/// In TS you'd write (pseudocode):
542/// ```ts
543/// function parseRefUpdatedRecord(bytes: Uint8Array): RefUpdatedRecord;
544/// ```
545pub fn parse_ref_updated(bytes: &[u8]) -> Result<String, RecoveryError> {
546    let map: Map<String, Value> = open_record(bytes, REF_UPDATED_FILENAME, "ref-updated")?;
547    return RecordReader::new(REF_UPDATED_FILENAME, &map).string("landedOid");
548}
549
550/// Incumbent-fixture and malformed-record controls stay out of the release executable.
551#[cfg(test)]
552#[path = "transaction_journal_parse_tests.rs"]
553mod tests;