git_policy_cli/owner_lock_record.rs
1//! What:
2//! The owner record inside every owner-lock directory (`owner.json`,
3//! schema version 1):
4//! its strict parser,
5//! its exact encoding,
6//! and the owner-liveness rule.
7//! Why:
8//! The landing,
9//! reservation,
10//! hook,
11//! capture locks are directories holding this
12//! record.
13//! The incumbent and its hook dispatcher write and read the same record
14//! (`src/owner-lock/owner-lock-record.ts`),
15//! so both wrappers contend on one lock and each
16//! retires the other's dead lock.
17//!
18//! In TS you'd write (pseudocode):
19//! ```ts
20//! const record = parseOwnerLockRecord(text); const alive = await ownerLockHolderIsAlive(record);
21//! ```
22
23/// Record field readers and the ordered writer.
24use super::json_record::{
25 ObjectWriter, number_equals, parse_object, safe_integer_field, string_field,
26};
27/// The rule for an owner that lives in another PID namespace.
28use super::pid_namespace::foreign_owner_standing;
29/// Birth identities and the existence probe.
30use super::process_identity::{ProcessIdentityError, process_birth_identity};
31/// `Path` is a borrowed filesystem path.
32use std::path::Path;
33
34/// Owner record filename inside a published lock directory.
35pub const OWNER_LOCK_RECORD_FILENAME: &str = "owner.json";
36
37/// Owner-lock record schema version.
38pub const OWNER_LOCK_SCHEMA_VERSION: i64 = 1;
39
40/// What:
41/// The durable owner of one published lock.
42/// A `struct` groups named fields;
43/// `Option<String>` is "text or nothing".
44/// Why:
45/// The token distinguishes this acquisition from every other;
46/// the PID and birth
47/// identity prove liveness;
48/// the reservation lock also names its transaction.
49///
50/// In TS you'd write (pseudocode):
51/// ```ts
52/// type OwnerLockRecord = { schemaVersion: 1; token: string; ownerPid: number; ownerBirthIdentity: string; transactionId?: string };
53/// ```
54#[derive(Clone, Debug, Eq, PartialEq)]
55pub struct OwnerLockRecord {
56 /// Unguessable token of this acquisition.
57 pub token: String,
58 /// Owning process ID.
59 pub owner_pid: i64,
60 /// Process-birth identity of the owner.
61 pub owner_birth_identity: String,
62 /// Commit transaction the lock is held for,
63 /// recorded by the landing reservation.
64 pub transaction_id: Option<String>,
65 /// PID namespace of the owner, or nothing for a record written before the field existed.
66 /// Optional on disk; readers that do not know the field ignore it.
67 pub owner_pid_namespace: Option<String>,
68}
69
70/// What:
71/// Parse an owner record's text,
72/// or nothing when it is malformed.
73/// Why:
74/// The incumbent's `parseOwnerLockRecord` accepts exactly:
75/// schema version 1,
76/// a
77/// non-empty token,
78/// a safe-integer PID of at least 1,
79/// a non-empty birth identity,
80/// and
81/// an optional non-empty transaction ID.
82/// Anything else is malformed.
83///
84/// In TS you'd write (pseudocode):
85/// ```ts
86/// function parseOwnerLockRecord(text: string): OwnerLockRecord; // throws when malformed
87/// ```
88pub fn parse_owner_lock_record(text: &str) -> Option<OwnerLockRecord> {
89 let map: serde_json::Map<String, serde_json::Value> = parse_object(text)?;
90 if !number_equals(&map, "schemaVersion", OWNER_LOCK_SCHEMA_VERSION) {
91 return None;
92 }
93 let token: &str = string_field(&map, "token")?;
94 let owner_pid: i64 = safe_integer_field(&map, "ownerPid")?;
95 let identity: &str = string_field(&map, "ownerBirthIdentity")?;
96 if token.is_empty() || owner_pid < 1 || identity.is_empty() {
97 return None;
98 }
99 // A present `transactionId` must be non-empty text; an absent one is no transaction.
100 let transaction_id: Option<String> = match map.get("transactionId") {
101 None => None,
102 Some(serde_json::Value::String(id)) if !id.is_empty() => Some(id.clone()),
103 Some(_) => return None,
104 };
105 return Some(OwnerLockRecord {
106 token: String::from(token),
107 owner_pid,
108 owner_birth_identity: String::from(identity),
109 transaction_id,
110 // A present field must be non-empty text; anything else leaves the PID to decide, as before the field.
111 owner_pid_namespace: string_field(&map, "ownerPidNamespace")
112 .filter(|namespace| { return !namespace.is_empty(); })
113 .map(String::from),
114 });
115}
116
117/// What:
118/// Encode an owner record exactly as the incumbent's `writeCandidate` does:
119/// compact
120/// JSON in field order,
121/// with a trailing newline.
122/// Why:
123/// Records written by either wrapper are byte-identical for the same values.
124///
125/// In TS you'd write (pseudocode):
126/// ```ts
127/// `${JSON.stringify({ schemaVersion, token, ownerPid, ownerBirthIdentity, ...(transactionId && { transactionId }) })}\n`
128/// ```
129pub fn encode_owner_lock_record(record: &OwnerLockRecord) -> String {
130 // `mut` allows appending one field at a time.
131 let mut writer: ObjectWriter = ObjectWriter::new();
132 writer
133 .integer("schemaVersion", OWNER_LOCK_SCHEMA_VERSION)
134 .string("token", record.token.as_str())
135 .integer("ownerPid", record.owner_pid)
136 .string("ownerBirthIdentity", record.owner_birth_identity.as_str());
137 if let Some(id) = &record.transaction_id {
138 writer.string("transactionId", id.as_str());
139 }
140 if let Some(namespace) = &record.owner_pid_namespace {
141 writer.string("ownerPidNamespace", namespace.as_str());
142 }
143 return format!("{}\n", writer.finish());
144}
145
146/// What:
147/// Whether a recorded owner still runs with the same process birth.
148/// Why:
149/// A PID that names no process,
150/// a zombie,
151/// or a younger process reusing the PID is a
152/// dead owner.
153/// The incumbent's `ownerLockHolderIsAlive` compares identities only.
154///
155/// In TS you'd write (pseudocode):
156/// ```ts
157/// async function ownerLockHolderIsAlive(record: OwnerLockRecord): Promise<boolean>;
158/// ```
159pub fn owner_lock_holder_is_alive(record: &OwnerLockRecord, lock_directory: &Path) -> Result<bool, ProcessIdentityError> {
160 // An owner of another PID namespace has a PID this process cannot interpret (issues 1781 and 1784).
161 if let Some(standing) = foreign_owner_standing(
162 record.owner_pid_namespace.as_deref(),
163 lock_directory.join(OWNER_LOCK_RECORD_FILENAME).as_path(),
164 ) {
165 return Ok(standing);
166 }
167 // `match` unpacks the identity answer: a live process with the recorded birth is alive.
168 match process_birth_identity(record.owner_pid)? {
169 Some(current) => return Ok(current == record.owner_birth_identity),
170 None => return Ok(false),
171 }
172}
173
174/// Parser,
175/// encoding and liveness controls stay out of the release executable.
176#[cfg(test)]
177#[path = "owner_lock_record_tests.rs"]
178mod tests;