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;