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;