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