Skip to main content

git_policy_cli/
transaction_owner.rs

1//! What:
2//!  The owner record of one commit-transaction directory (`owner.json`,
3//!  schema version 2)
4//!       and the liveness rule that decides whether recovery may touch the transaction.
5//! Why:
6//!  The owner record is written before the directory is published,
7//!  so recovery can skip a
8//!      live owner's transaction at every phase.
9//!  Both wrappers write and judge the same record
10//!      (`src/policy-engine/commit-transaction-owner.ts`).
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! const owner = parseTransactionOwner(bytes); const liveness = await classifyTransactionOwner(owner);
15//! ```
16
17/// Record field readers,
18///  the fatal decoder and the ordered writer.
19use super::json_record::{
20    ObjectWriter, decode_fatal, number_equals, parse_object, safe_integer_field, string_field,
21};
22/// Birth identities and the existence probe.
23use super::process_identity::{
24    ProcessIdentityError, current_birth_identity, process_birth_identity,
25};
26
27/// Owner record schema;
28///  version 2 marks the per-transaction directory layout.
29pub const TRANSACTION_OWNER_SCHEMA_VERSION: i64 = 2;
30
31/// What:
32///  The durable owner of one transaction directory.
33/// Why:
34///   The PID and birth identity prove liveness;
35///  `created_at`,
36///  the invocation start time
37///       as ISO-8601 text,
38///  orders dead transactions for recovery and grants the reservation.
39///
40/// In TS you'd write (pseudocode):
41/// ```ts
42/// type TransactionOwnerRecord = { schemaVersion: 2; transactionId: string; ownerPid: number; ownerIdentity: string; createdAt: string };
43/// ```
44#[derive(Clone, Debug, Eq, PartialEq)]
45pub struct TransactionOwner {
46    /// Transaction ID,
47    ///  equal to the directory name.
48    pub transaction_id: String,
49    /// Wrapper process owning the transaction.
50    pub owner_pid: i64,
51    /// Process-birth identity of the owner.
52    pub owner_identity: String,
53    /// ISO-8601 invocation start time.
54    pub created_at: String,
55}
56
57/// What:
58///  Whether a recorded owner still runs.
59/// Why:
60///   Only a dead owner's transaction may be recovered.
61///
62/// In TS you'd write (pseudocode):
63/// ```ts
64/// type TransactionOwnerLiveness = 'alive' | 'dead';
65/// ```
66#[derive(Clone, Copy, Debug, Eq, PartialEq)]
67pub enum OwnerLiveness {
68    /// The PID names the original process.
69    Alive,
70    /// The process exited or its PID was reused.
71    Dead,
72}
73
74/// What:
75///  Why an owner's liveness could not be decided.
76/// Why:
77///   A refused probe (for example another account's process) must stop recovery rather
78///       than let it touch a transaction that may be live,
79///  as the incumbent's throw does.
80///
81/// In TS you'd write (pseudocode):
82/// ```ts
83/// class CommitTransactionRecoveryError extends Error {}
84/// ```
85#[derive(Debug)]
86pub enum OwnerLivenessError {
87    /// The existence probe was refused.
88    Probe(std::io::Error),
89    /// The birth identity could not be read.
90    Identity(ProcessIdentityError),
91}
92
93/// What:
94///  The current process's owner record for a fresh transaction.
95/// Why:
96///   Written into the staging directory before publication.
97///
98/// In TS you'd write (pseudocode):
99/// ```ts
100/// async function createTransactionOwnerRecord({ transactionId, createdAt }): Promise<TransactionOwnerRecord>;
101/// ```
102pub fn current_transaction_owner(
103    transaction_id: &str,
104    created_at: &str,
105) -> Result<TransactionOwner, ProcessIdentityError> {
106    return Ok(TransactionOwner {
107        transaction_id: String::from(transaction_id),
108        owner_pid: i64::from(std::process::id()),
109        owner_identity: current_birth_identity()?,
110        created_at: String::from(created_at),
111    });
112}
113
114/// What:
115///  Encode an owner record as compact JSON in the incumbent's field order,
116///  with a newline.
117/// Why:
118///   `encodeTransactionOwner` writes exactly these bytes.
119///
120/// In TS you'd write (pseudocode):
121/// ```ts
122/// `${JSON.stringify({ schemaVersion, transactionId, ownerPid, ownerIdentity, createdAt })}\n`
123/// ```
124pub fn encode_transaction_owner(owner: &TransactionOwner) -> String {
125    // `mut` allows appending one field at a time.
126    let mut writer: ObjectWriter = ObjectWriter::new();
127    writer
128        .integer("schemaVersion", TRANSACTION_OWNER_SCHEMA_VERSION)
129        .string("transactionId", owner.transaction_id.as_str())
130        .integer("ownerPid", owner.owner_pid)
131        .string("ownerIdentity", owner.owner_identity.as_str())
132        .string("createdAt", owner.created_at.as_str());
133    return format!("{}\n", writer.finish());
134}
135
136/// What:
137///  Parse owner record bytes,
138///  or nothing when they are not a valid record.
139/// Why:
140///   The incumbent's `parseTransactionOwner` decodes with a fatal UTF-8 decoder and accepts
141///       schema version 2,
142///  text `transactionId` (possibly empty,
143///  which the directory-name check
144///       then rejects),
145///  a safe-integer PID of at least 1,
146///  a non-empty identity and text
147///       `createdAt`.
148///
149/// In TS you'd write (pseudocode):
150/// ```ts
151/// function parseTransactionOwner(bytes: Uint8Array): TransactionOwnerRecord; // throws when malformed
152/// ```
153pub fn parse_transaction_owner(bytes: &[u8]) -> Option<TransactionOwner> {
154    let map: serde_json::Map<String, serde_json::Value> = parse_object(decode_fatal(bytes)?)?;
155    if !number_equals(&map, "schemaVersion", TRANSACTION_OWNER_SCHEMA_VERSION) {
156        return None;
157    }
158    let transaction_id: &str = string_field(&map, "transactionId")?;
159    let owner_pid: i64 = safe_integer_field(&map, "ownerPid")?;
160    let owner_identity: &str = string_field(&map, "ownerIdentity")?;
161    let created_at: &str = string_field(&map, "createdAt")?;
162    if owner_pid < 1 || owner_identity.is_empty() {
163        return None;
164    }
165    return Some(TransactionOwner {
166        transaction_id: String::from(transaction_id),
167        owner_pid,
168        owner_identity: String::from(owner_identity),
169        created_at: String::from(created_at),
170    });
171}
172
173/// What:
174///  Whether the process `pid` exists,
175///  as the incumbent's `processIsAlive` asks first.
176///       On systems without signals,
177///  existence is left to the identity probe.
178/// Why:
179///   `kill(pid, 0)` answering `ESRCH` is a dead owner;
180///  any other refusal is reported.
181///
182/// In TS you'd write (pseudocode):
183/// ```ts
184/// function processIsAlive(pid: number): boolean;
185/// ```
186fn process_exists(pid: i64) -> Result<bool, OwnerLivenessError> {
187    #[cfg(unix)]
188    {
189        return super::process_identity::signal_probe(pid).map_err(OwnerLivenessError::Probe);
190    }
191    #[cfg(not(unix))]
192    {
193        let _ = pid;
194        return Ok(true);
195    }
196}
197
198/// What:
199///  Classify a recorded owner:
200///  alive only while its PID still names the same process birth.
201/// Why:
202///   An exited process,
203///  a zombie,
204///  or a younger process reusing the PID is a dead owner.
205///
206/// In TS you'd write (pseudocode):
207/// ```ts
208/// async function classifyTransactionOwner({ ownerPid, ownerIdentity }): Promise<'alive' | 'dead'>;
209/// ```
210pub fn classify_transaction_owner(
211    owner_pid: i64,
212    owner_identity: &str,
213) -> Result<OwnerLiveness, OwnerLivenessError> {
214    if !process_exists(owner_pid)? {
215        return Ok(OwnerLiveness::Dead);
216    }
217    match process_birth_identity(owner_pid).map_err(OwnerLivenessError::Identity)? {
218        Some(current) if current == owner_identity => return Ok(OwnerLiveness::Alive),
219        Some(_) | None => return Ok(OwnerLiveness::Dead),
220    }
221}
222
223/// Owner record and liveness controls stay out of the release executable.
224#[cfg(test)]
225#[path = "transaction_owner_tests.rs"]
226mod tests;