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