Skip to main content

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;