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 and push 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/// Bounded no-follow reads of the record file.
28use super::private_storage::{ReadRefusal, read_regular_file};
29/// The rule for an owner that lives in another PID namespace.
30use super::pid_namespace::foreign_owner_standing;
31/// Birth identities and the existence probe.
32use super::process_identity::{ProcessIdentityError, process_birth_identity};
33/// `Path` is a borrowed filesystem path.
34use std::path::Path;
35
36/// Owner record filename inside a published lock directory.
37pub const OWNER_LOCK_RECORD_FILENAME: &str = "owner.json";
38
39/// Owner-lock record schema version.
40pub const OWNER_LOCK_SCHEMA_VERSION: i64 = 1;
41
42/// Largest owner record read;
43///  a record is about 150 bytes,
44///  so anything near this is corrupt.
45pub const OWNER_LOCK_RECORD_LIMIT: u64 = 64 * 1024;
46
47/// What:
48///  The durable owner of one published lock.
49///       A `struct` groups named fields;
50///  `Option<String>` is "text or nothing".
51/// Why:
52///   The token distinguishes this acquisition from every other;
53///  the PID and birth
54///       identity prove liveness;
55///  the reservation lock also names its transaction.
56///
57/// In TS you'd write (pseudocode):
58/// ```ts
59/// type OwnerLockRecord = { schemaVersion: 1; token: string; ownerPid: number; ownerBirthIdentity: string; transactionId?: string };
60/// ```
61#[derive(Clone, Debug, Eq, PartialEq)]
62pub struct OwnerLockRecord {
63    /// Unguessable token of this acquisition.
64    pub token: String,
65    /// Owning process ID.
66    pub owner_pid: i64,
67    /// Process-birth identity of the owner.
68    pub owner_birth_identity: String,
69    /// Commit transaction the lock is held for,
70    ///  recorded by the landing reservation.
71    pub transaction_id: Option<String>,
72    /// PID namespace of the owner, or nothing for a record written before the field existed.
73    ///  Optional on disk; readers that do not know the field ignore it.
74    pub owner_pid_namespace: Option<String>,
75}
76
77/// What:
78///  Parse an owner record's text,
79///  or nothing when it is malformed.
80/// Why:
81///   The incumbent's `parseOwnerLockRecord` accepts exactly:
82///  schema version 1,
83///  a
84///       non-empty token,
85///  a safe-integer PID of at least 1,
86///  a non-empty birth identity,
87///  and
88///       an optional non-empty transaction ID.
89///  Anything else is malformed.
90///
91/// In TS you'd write (pseudocode):
92/// ```ts
93/// function parseOwnerLockRecord(text: string): OwnerLockRecord; // throws when malformed
94/// ```
95pub fn parse_owner_lock_record(text: &str) -> Option<OwnerLockRecord> {
96    let map: serde_json::Map<String, serde_json::Value> = parse_object(text)?;
97    if !number_equals(&map, "schemaVersion", OWNER_LOCK_SCHEMA_VERSION) {
98        return None;
99    }
100    let token: &str = string_field(&map, "token")?;
101    let owner_pid: i64 = safe_integer_field(&map, "ownerPid")?;
102    let identity: &str = string_field(&map, "ownerBirthIdentity")?;
103    if token.is_empty() || owner_pid < 1 || identity.is_empty() {
104        return None;
105    }
106    // A present `transactionId` must be non-empty text; an absent one is no transaction.
107    let transaction_id: Option<String> = match map.get("transactionId") {
108        None => None,
109        Some(serde_json::Value::String(id)) if !id.is_empty() => Some(id.clone()),
110        Some(_) => return None,
111    };
112    return Some(OwnerLockRecord {
113        token: String::from(token),
114        owner_pid,
115        owner_birth_identity: String::from(identity),
116        transaction_id,
117        // A present field must be non-empty text; anything else leaves the PID to decide, as before the field.
118        owner_pid_namespace: string_field(&map, "ownerPidNamespace")
119            .filter(|namespace| { return !namespace.is_empty(); })
120            .map(String::from),
121    });
122}
123
124/// What:
125///  Encode an owner record exactly as the incumbent's `writeCandidate` does:
126///  compact
127///       JSON in field order,
128///  with a trailing newline.
129/// Why:
130///   Records written by either wrapper are byte-identical for the same values.
131///
132/// In TS you'd write (pseudocode):
133/// ```ts
134/// `${JSON.stringify({ schemaVersion, token, ownerPid, ownerBirthIdentity, ...(transactionId && { transactionId }) })}\n`
135/// ```
136pub fn encode_owner_lock_record(record: &OwnerLockRecord) -> String {
137    // `mut` allows appending one field at a time.
138    let mut writer: ObjectWriter = ObjectWriter::new();
139    writer
140        .integer("schemaVersion", OWNER_LOCK_SCHEMA_VERSION)
141        .string("token", record.token.as_str())
142        .integer("ownerPid", record.owner_pid)
143        .string("ownerBirthIdentity", record.owner_birth_identity.as_str());
144    if let Some(id) = &record.transaction_id {
145        writer.string("transactionId", id.as_str());
146    }
147    if let Some(namespace) = &record.owner_pid_namespace {
148        writer.string("ownerPidNamespace", namespace.as_str());
149    }
150    return format!("{}\n", writer.finish());
151}
152
153/// What:
154///  What reading a published lock found.
155///  `Busy` means the lock vanished while it was
156///       read,
157///  so the caller must look again.
158/// Why:
159///   A release or retirement renames the directory away at any moment;
160///  that is a race to
161///       retry,
162///  not corruption.
163///
164/// In TS you'd write (pseudocode):
165/// ```ts
166/// type ReadOwner = OwnerLockRecord | typeof LOCK_BUSY;
167/// ```
168#[derive(Clone, Debug, Eq, PartialEq)]
169pub enum PublishedOwner {
170    /// The lock holds this owner.
171    Owner(OwnerLockRecord),
172    /// The lock or its record vanished during the read.
173    Busy,
174}
175
176/// What:
177///  Why a lock's record could not be judged.
178/// Why:
179///   A malformed or unreadable record must stop the acquirer:
180///  it might belong to a live
181///       owner,
182///  and the wrapper never deletes a lock it cannot prove dead.
183///
184/// In TS you'd write (pseudocode):
185/// ```ts
186/// class OwnerLockError extends Error {}
187/// ```
188#[derive(Debug)]
189pub enum OwnerRecordError {
190    /// The record exists but is not a valid owner record.
191    Malformed(std::path::PathBuf),
192    /// The record could not be read.
193    Unreadable(std::path::PathBuf, std::io::Error),
194}
195
196/// What:
197///  Read the owner of a published lock directory.
198/// Why:
199///   A missing record or directory means the lock vanished (`ENOENT`,
200///  `ENOTDIR`),
201///  which
202///       the incumbent reports as busy so the caller retries.
203///
204/// In TS you'd write (pseudocode):
205/// ```ts
206/// async function readOwnerLockRecord(lockDirectory: string): Promise<OwnerLockRecord | typeof LOCK_BUSY>;
207/// ```
208pub fn read_owner_lock_record(lock_directory: &Path) -> Result<PublishedOwner, OwnerRecordError> {
209    let path: std::path::PathBuf = lock_directory.join(OWNER_LOCK_RECORD_FILENAME);
210    let bytes: Vec<u8> = match read_regular_file(path.as_path(), OWNER_LOCK_RECORD_LIMIT) {
211        Ok(read) => read,
212        Err(ReadRefusal::Missing) => return Ok(PublishedOwner::Busy),
213        Err(ReadRefusal::NotRegular | ReadRefusal::TooLarge) => {
214            return Err(OwnerRecordError::Malformed(path));
215        }
216        Err(ReadRefusal::Io(error)) => return Err(OwnerRecordError::Unreadable(path, error)),
217    };
218    // Node's `readFile(path, 'utf8')` replaces invalid bytes before `JSON.parse`.
219    let text: std::borrow::Cow<'_, str> = String::from_utf8_lossy(bytes.as_slice());
220    match parse_owner_lock_record(text.as_ref()) {
221        Some(record) => return Ok(PublishedOwner::Owner(record)),
222        None => return Err(OwnerRecordError::Malformed(path)),
223    }
224}
225
226/// What:
227///  Whether a recorded owner still runs with the same process birth.
228/// Why:
229///   A PID that names no process,
230///  a zombie,
231///  or a younger process reusing the PID is a
232///       dead owner.
233///  The incumbent's `ownerLockHolderIsAlive` compares identities only.
234///
235/// In TS you'd write (pseudocode):
236/// ```ts
237/// async function ownerLockHolderIsAlive(record: OwnerLockRecord): Promise<boolean>;
238/// ```
239pub fn owner_lock_holder_is_alive(record: &OwnerLockRecord, lock_directory: &Path) -> Result<bool, ProcessIdentityError> {
240    // An owner of another PID namespace has a PID this process cannot interpret (issues 1781 and 1784).
241    if let Some(standing) = foreign_owner_standing(
242        record.owner_pid_namespace.as_deref(),
243        lock_directory.join(OWNER_LOCK_RECORD_FILENAME).as_path(),
244    ) {
245        return Ok(standing);
246    }
247    // `match` unpacks the identity answer: a live process with the recorded birth is alive.
248    match process_birth_identity(record.owner_pid)? {
249        Some(current) => return Ok(current == record.owner_birth_identity),
250        None => return Ok(false),
251    }
252}
253
254/// Parser,
255///  encoding and liveness controls stay out of the release executable.
256#[cfg(test)]
257#[path = "owner_lock_record_tests.rs"]
258mod tests;