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/// The namespace an owner record names and the rule for an owner of another one.
27use super::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}
96
97/// What:
98/// The current process's owner record for a fresh transaction.
99/// Why:
100/// Written into the staging directory before publication.
101///
102/// In TS you'd write (pseudocode):
103/// ```ts
104/// async function createTransactionOwnerRecord({ transactionId, createdAt }): Promise<TransactionOwnerRecord>;
105/// ```
106pub fn current_transaction_owner(
107 transaction_id: &str,
108 created_at: &str,
109) -> Result<TransactionOwner, ProcessIdentityError> {
110 return Ok(TransactionOwner {
111 transaction_id: String::from(transaction_id),
112 owner_pid: i64::from(std::process::id()),
113 owner_identity: current_birth_identity()?,
114 created_at: String::from(created_at),
115 owner_pid_namespace: current_pid_namespace(),
116 });
117}
118
119/// What:
120/// Encode an owner record as compact JSON in the incumbent's field order,
121/// with a newline.
122/// Why:
123/// `encodeTransactionOwner` writes exactly these bytes.
124///
125/// In TS you'd write (pseudocode):
126/// ```ts
127/// `${JSON.stringify({ schemaVersion, transactionId, ownerPid, ownerIdentity, createdAt })}\n`
128/// ```
129pub fn encode_transaction_owner(owner: &TransactionOwner) -> String {
130 // `mut` allows appending one field at a time.
131 let mut writer: ObjectWriter = ObjectWriter::new();
132 writer
133 .integer("schemaVersion", TRANSACTION_OWNER_SCHEMA_VERSION)
134 .string("transactionId", owner.transaction_id.as_str())
135 .integer("ownerPid", owner.owner_pid)
136 .string("ownerIdentity", owner.owner_identity.as_str())
137 .string("createdAt", owner.created_at.as_str());
138 if let Some(namespace) = &owner.owner_pid_namespace {
139 writer.string("ownerPidNamespace", namespace.as_str());
140 }
141 return format!("{}\n", writer.finish());
142}
143
144/// What:
145/// Parse owner record bytes,
146/// or nothing when they are not a valid record.
147/// Why:
148/// The incumbent's `parseTransactionOwner` decodes with a fatal UTF-8 decoder and accepts
149/// schema version 2,
150/// text `transactionId` (possibly empty,
151/// which the directory-name check
152/// then rejects),
153/// a safe-integer PID of at least 1,
154/// a non-empty identity and text
155/// `createdAt`.
156///
157/// In TS you'd write (pseudocode):
158/// ```ts
159/// function parseTransactionOwner(bytes: Uint8Array): TransactionOwnerRecord; // throws when malformed
160/// ```
161pub fn parse_transaction_owner(bytes: &[u8]) -> Option<TransactionOwner> {
162 let map: serde_json::Map<String, serde_json::Value> = parse_object(decode_fatal(bytes)?)?;
163 if !number_equals(&map, "schemaVersion", TRANSACTION_OWNER_SCHEMA_VERSION) {
164 return None;
165 }
166 let transaction_id: &str = string_field(&map, "transactionId")?;
167 let owner_pid: i64 = safe_integer_field(&map, "ownerPid")?;
168 let owner_identity: &str = string_field(&map, "ownerIdentity")?;
169 let created_at: &str = string_field(&map, "createdAt")?;
170 if owner_pid < 1 || owner_identity.is_empty() {
171 return None;
172 }
173 return Some(TransactionOwner {
174 transaction_id: String::from(transaction_id),
175 owner_pid,
176 owner_identity: String::from(owner_identity),
177 created_at: String::from(created_at),
178 // A present field must be non-empty text; anything else leaves the PID to decide, as before the field.
179 owner_pid_namespace: string_field(&map, "ownerPidNamespace")
180 .filter(|namespace| { return !namespace.is_empty(); })
181 .map(String::from),
182 });
183}
184
185/// What:
186/// Whether the process `pid` exists,
187/// as the incumbent's `processIsAlive` asks first.
188/// On systems without signals,
189/// existence is left to the identity probe.
190/// Why:
191/// `kill(pid, 0)` answering `ESRCH` is a dead owner;
192/// any other refusal is reported.
193///
194/// In TS you'd write (pseudocode):
195/// ```ts
196/// function processIsAlive(pid: number): boolean;
197/// ```
198fn process_exists(pid: i64) -> Result<bool, OwnerLivenessError> {
199 #[cfg(unix)]
200 {
201 return super::process_identity::signal_probe(pid).map_err(OwnerLivenessError::Probe);
202 }
203 // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
204 #[cfg(not(unix))]
205 {
206 let _ = pid;
207 return Ok(true);
208 }
209}
210
211/// What:
212/// Classify a recorded owner:
213/// alive only while its PID still names the same process birth.
214/// Why:
215/// An exited process,
216/// a zombie,
217/// or a younger process reusing the PID is a dead owner.
218///
219/// In TS you'd write (pseudocode):
220/// ```ts
221/// async function classifyTransactionOwner({ ownerPid, ownerIdentity }): Promise<'alive' | 'dead'>;
222/// ```
223pub fn classify_transaction_owner(
224 owner_pid: i64,
225 owner_identity: &str,
226) -> Result<OwnerLiveness, OwnerLivenessError> {
227 if !process_exists(owner_pid)? {
228 return Ok(OwnerLiveness::Dead);
229 }
230 match process_birth_identity(owner_pid).map_err(OwnerLivenessError::Identity)? {
231 Some(current) if current == owner_identity => return Ok(OwnerLiveness::Alive),
232 Some(_) | None => return Ok(OwnerLiveness::Dead),
233 }
234}
235
236/// What:
237/// Classify a parsed owner record, trusting its PID only inside the PID namespace that wrote it.
238/// Why:
239/// An owner of another namespace has a PID this process cannot interpret (issues 1781 and 1784), so it counts as
240/// alive until its record file `owner_path` outlives the patience; the same rule serves recovery, the lease check
241/// and the reservation queue.
242///
243/// In TS you'd write (pseudocode):
244/// ```ts
245/// await classifyTransactionOwnerRecord(owner, ownerPath);
246/// ```
247pub fn classify_transaction_owner_record(
248 owner: &TransactionOwner,
249 owner_path: &std::path::Path,
250) -> Result<OwnerLiveness, OwnerLivenessError> {
251 if let Some(standing) = foreign_owner_standing(owner.owner_pid_namespace.as_deref(), owner_path) {
252 return Ok(if standing { OwnerLiveness::Alive } else { OwnerLiveness::Dead });
253 }
254 return classify_transaction_owner(owner.owner_pid, owner.owner_identity.as_str());
255}
256
257/// Owner record and liveness controls stay out of the release executable.
258#[cfg(test)]
259#[path = "transaction_owner_tests.rs"]
260mod tests;