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