Skip to main content

git_policy_cli/transaction/
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 crate::json_record::{
20    ObjectWriter, decode_fatal, number_equals, parse_object, safe_integer_field, string_field,
21};
22/// Birth identities and the existence probe.
23use crate::process_identity::{
24    ProcessIdentityError, current_birth_identity, process_birth_identity,
25};
26/// The namespace an owner record names and the rule for an owner of another one.
27use crate::pid_namespace::{current_pid_namespace, foreign_owner_standing};
28
29/// Owner record schema;
30///  version 2 marks the per-transaction directory layout.
31pub const TRANSACTION_OWNER_SCHEMA_VERSION: i64 = 2;
32
33/// What:
34///  The durable owner of one transaction directory.
35/// Why:
36///   The PID and birth identity prove liveness;
37///  `created_at`,
38///  the invocation start time
39///       as ISO-8601 text,
40///  orders dead transactions for recovery and grants the reservation.
41///
42/// In TS you'd write (pseudocode):
43/// ```ts
44/// type TransactionOwnerRecord = { schemaVersion: 2; transactionId: string; ownerPid: number; ownerIdentity: string; createdAt: string };
45/// ```
46#[derive(Clone, Debug, Eq, PartialEq)]
47pub struct TransactionOwner {
48    /// Transaction ID,
49    ///  equal to the directory name.
50    pub transaction_id: String,
51    /// Wrapper process owning the transaction.
52    pub owner_pid: i64,
53    /// Process-birth identity of the owner.
54    pub owner_identity: String,
55    /// ISO-8601 invocation start time.
56    pub created_at: String,
57    /// PID namespace of the owner, or nothing for a record written before the field existed.
58    ///  Optional on disk; readers that do not know the field ignore it.
59    pub owner_pid_namespace: Option<String>,
60}
61
62/// What:
63///  Whether a recorded owner still runs.
64/// Why:
65///   Only a dead owner's transaction may be recovered.
66///
67/// In TS you'd write (pseudocode):
68/// ```ts
69/// type TransactionOwnerLiveness = 'alive' | 'dead';
70/// ```
71#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72pub enum OwnerLiveness {
73    /// The PID names the original process.
74    Alive,
75    /// The process exited or its PID was reused.
76    Dead,
77}
78
79/// What:
80///  Why an owner's liveness could not be decided.
81/// Why:
82///   A refused probe (for example another account's process) must not let recovery touch a transaction that may be
83///  live; the caller decides whether the refusal means "alive" (permission) or stops recovery (anything else).
84///
85/// In TS you'd write (pseudocode):
86/// ```ts
87/// class CommitTransactionRecoveryError extends Error {}
88/// ```
89#[derive(Debug)]
90pub enum OwnerLivenessError {
91    /// The existence probe was refused.
92    Probe(std::io::Error),
93    /// The birth identity could not be read.
94    Identity(ProcessIdentityError),
95    /// The record names another PID namespace and is older than the patience: neither alive nor dead. The text is the error to
96    ///  stop with (`pid_namespace.rs`, `ExpiredForeignRecord`).
97    ExpiredForeign(String),
98}
99
100/// What:
101///  The current process's owner record for a fresh transaction.
102/// Why:
103///   Written into the staging directory before publication.
104///
105/// In TS you'd write (pseudocode):
106/// ```ts
107/// async function createTransactionOwnerRecord({ transactionId, createdAt }): Promise<TransactionOwnerRecord>;
108/// ```
109pub fn current_transaction_owner(
110    transaction_id: &str,
111    created_at: &str,
112) -> Result<TransactionOwner, ProcessIdentityError> {
113    return Ok(TransactionOwner {
114        transaction_id: String::from(transaction_id),
115        owner_pid: i64::from(std::process::id()),
116        owner_identity: current_birth_identity()?,
117        created_at: String::from(created_at),
118        owner_pid_namespace: current_pid_namespace(),
119    });
120}
121
122/// What:
123///  Encode an owner record as compact JSON in the incumbent's field order,
124///  with a newline.
125/// Why:
126///   `encodeTransactionOwner` writes exactly these bytes.
127///
128/// In TS you'd write (pseudocode):
129/// ```ts
130/// `${JSON.stringify({ schemaVersion, transactionId, ownerPid, ownerIdentity, createdAt })}\n`
131/// ```
132pub fn encode_transaction_owner(owner: &TransactionOwner) -> String {
133    // `mut` allows appending one field at a time.
134    let mut writer: ObjectWriter = ObjectWriter::new();
135    writer
136        .integer("schemaVersion", TRANSACTION_OWNER_SCHEMA_VERSION)
137        .string("transactionId", owner.transaction_id.as_str())
138        .integer("ownerPid", owner.owner_pid)
139        .string("ownerIdentity", owner.owner_identity.as_str())
140        .string("createdAt", owner.created_at.as_str());
141    if let Some(namespace) = &owner.owner_pid_namespace {
142        writer.string("ownerPidNamespace", namespace.as_str());
143    }
144    return format!("{}\n", writer.finish());
145}
146
147/// What:
148///  Parse owner record bytes,
149///  or nothing when they are not a valid record.
150/// Why:
151///   The incumbent's `parseTransactionOwner` decodes with a fatal UTF-8 decoder and accepts
152///       schema version 2,
153///  text `transactionId` (possibly empty,
154///  which the directory-name check
155///       then rejects),
156///  a safe-integer PID of at least 1,
157///  a non-empty identity and text
158///       `createdAt`.
159///
160/// In TS you'd write (pseudocode):
161/// ```ts
162/// function parseTransactionOwner(bytes: Uint8Array): TransactionOwnerRecord; // throws when malformed
163/// ```
164pub fn parse_transaction_owner(bytes: &[u8]) -> Option<TransactionOwner> {
165    let map: serde_json::Map<String, serde_json::Value> = parse_object(decode_fatal(bytes)?)?;
166    if !number_equals(&map, "schemaVersion", TRANSACTION_OWNER_SCHEMA_VERSION) {
167        return None;
168    }
169    let transaction_id: &str = string_field(&map, "transactionId")?;
170    let owner_pid: i64 = safe_integer_field(&map, "ownerPid")?;
171    let owner_identity: &str = string_field(&map, "ownerIdentity")?;
172    let created_at: &str = string_field(&map, "createdAt")?;
173    if owner_pid < 1 || owner_identity.is_empty() {
174        return None;
175    }
176    return Some(TransactionOwner {
177        transaction_id: String::from(transaction_id),
178        owner_pid,
179        owner_identity: String::from(owner_identity),
180        created_at: String::from(created_at),
181        // A present field must be non-empty text; anything else leaves the PID to decide, as before the field.
182        owner_pid_namespace: string_field(&map, "ownerPidNamespace")
183            .filter(|namespace| { return !namespace.is_empty(); })
184            .map(String::from),
185    });
186}
187
188/// What:
189///  Whether the process `pid` exists,
190///  as the incumbent's `processIsAlive` asks first.
191///       On systems without signals,
192///  existence is left to the identity probe.
193/// Why:
194///   `kill(pid, 0)` answering `ESRCH` is a dead owner;
195///  any other refusal is reported.
196///
197/// In TS you'd write (pseudocode):
198/// ```ts
199/// function processIsAlive(pid: number): boolean;
200/// ```
201fn process_exists(pid: i64) -> Result<bool, OwnerLivenessError> {
202    #[cfg(unix)]
203    {
204        return crate::process_identity::signal_probe(pid).map_err(OwnerLivenessError::Probe);
205    }
206    // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
207    #[cfg(not(unix))]
208    {
209        let _ = pid;
210        return Ok(true);
211    }
212}
213
214/// What:
215///  Classify a recorded owner:
216///  alive only while its PID still names the same process birth.
217/// Why:
218///   An exited process,
219///  a zombie,
220///  or a younger process reusing the PID is a dead owner.
221///
222/// In TS you'd write (pseudocode):
223/// ```ts
224/// async function classifyTransactionOwner({ ownerPid, ownerIdentity }): Promise<'alive' | 'dead'>;
225/// ```
226pub fn classify_transaction_owner(
227    owner_pid: i64,
228    owner_identity: &str,
229) -> Result<OwnerLiveness, OwnerLivenessError> {
230    if !process_exists(owner_pid)? {
231        return Ok(OwnerLiveness::Dead);
232    }
233    match process_birth_identity(owner_pid).map_err(OwnerLivenessError::Identity)? {
234        Some(current) if current == owner_identity => return Ok(OwnerLiveness::Alive),
235        Some(_) | None => return Ok(OwnerLiveness::Dead),
236    }
237}
238
239/// What:
240///  Classify a parsed owner record, trusting its PID only inside the PID namespace that wrote it.
241/// Why:
242///   An owner of another namespace has a PID this process cannot interpret (issues 1781 and 1784), so it counts as
243///  alive until its record file `owner_path` outlives the patience; the same rule serves recovery, the lease check
244///  and the reservation queue.
245///
246/// In TS you'd write (pseudocode):
247/// ```ts
248/// await classifyTransactionOwnerRecord(owner, ownerPath);
249/// ```
250pub fn classify_transaction_owner_record(
251    owner: &TransactionOwner,
252    owner_path: &std::path::Path,
253) -> Result<OwnerLiveness, OwnerLivenessError> {
254    match foreign_owner_standing(owner.owner_pid_namespace.as_deref(), owner_path) {
255        Some(Ok(())) => return Ok(OwnerLiveness::Alive),
256        Some(Err(expired)) => return Err(OwnerLivenessError::ExpiredForeign(expired.describe(owner.owner_pid))),
257        None => {}
258    }
259    return classify_transaction_owner(owner.owner_pid, owner.owner_identity.as_str());
260}
261
262/// Owner record and liveness controls stay out of the release executable.
263#[cfg(test)]
264#[path = "transaction_owner_tests.rs"]
265mod tests;