1use crate::canonical::sha256_hex;
4use crate::journal::{
5 PreacceptanceJournalRecord, PromotionIdentity, PromotionTransactionIdentity,
6 verify_promotion_journal,
7};
8use crate::{FailureCode, PromotionError, PromotionResult};
9use rustix::fs::{CWD, FlockOperation, Mode, OFlags, ResolveFlags, flock, fsync, openat2};
10use rustix::process::geteuid;
11use std::fs::{File, Metadata};
12use std::io::{Read, Seek, SeekFrom, Write};
13use std::os::unix::fs::MetadataExt;
14use std::path::{Path, PathBuf};
15
16const JOURNAL_NAME: &str = "promotion-journal.jsonl";
17const MAXIMUM_JOURNAL_BYTES: u64 = 64 * 1024 * 1024;
18
19#[derive(Clone, Debug, Eq, PartialEq)]
20struct Fingerprint {
21 dev: u64,
22 ino: u64,
23 mode: u32,
24 uid: u32,
25 nlink: u64,
26 bytes: u64,
27}
28
29impl Fingerprint {
30 fn from_metadata(metadata: &Metadata) -> Self {
31 Self {
32 dev: metadata.dev(),
33 ino: metadata.ino(),
34 mode: metadata.mode(),
35 uid: metadata.uid(),
36 nlink: metadata.nlink(),
37 bytes: metadata.size(),
38 }
39 }
40
41 fn same_object(&self, other: &Self) -> bool {
42 self.dev == other.dev && self.ino == other.ino
43 }
44}
45
46#[derive(Debug)]
47struct LockedState {
48 directory_path: PathBuf,
49 directory: File,
50 directory_fingerprint: Fingerprint,
51 journal: File,
52 journal_fingerprint: Fingerprint,
53}
54
55#[derive(Debug)]
58pub struct DurablePromotionBurn {
59 state: LockedState,
60 record: PreacceptanceJournalRecord,
61 transaction: PromotionTransactionIdentity,
62 journal_bytes: u64,
63 journal_sha256: String,
64}
65
66impl DurablePromotionBurn {
67 #[must_use]
69 pub const fn record(&self) -> &PreacceptanceJournalRecord {
70 &self.record
71 }
72
73 #[must_use]
75 pub const fn transaction_identity(&self) -> &PromotionTransactionIdentity {
76 &self.transaction
77 }
78
79 pub(crate) fn revalidate(&mut self) -> PromotionResult<()> {
80 revalidate_state_anchor(&self.state)?;
81 let current = read_bounded(&mut self.state.journal)?;
82 let current_metadata = regular_private_file(&self.state.journal, "promotion journal")?;
83 if !current_metadata.same_object(&self.state.journal_fingerprint)
84 || current_metadata.bytes != self.journal_bytes
85 || sha256_hex(¤t) != self.journal_sha256
86 {
87 return Err(PromotionError::new(
88 FailureCode::StateBurn,
89 "private promotion journal changed after its durable append",
90 ));
91 }
92 let path_descriptor = open_regular_beneath(&self.state.directory, JOURNAL_NAME)?;
93 let path_fingerprint = regular_private_file(&path_descriptor, "promotion journal path")?;
94 if !path_fingerprint.same_object(&self.state.journal_fingerprint) {
95 return Err(PromotionError::new(
96 FailureCode::StateBurn,
97 "private promotion journal pathname changed after its durable append",
98 ));
99 }
100 let state = verify_promotion_journal(¤t)?;
101 if state.next_sequence() != self.record.sequence().saturating_add(1)
102 || !self.transaction.eq(&self.record.transaction_identity())
103 {
104 return Err(PromotionError::new(
105 FailureCode::StateBurn,
106 "private promotion journal no longer ends at the burned record",
107 ));
108 }
109 let directory_now = Fingerprint::from_metadata(
110 &self
111 .state
112 .directory
113 .metadata()
114 .map_err(|error| state_error("cannot restat private state directory", error))?,
115 );
116 if !directory_now.same_object(&self.state.directory_fingerprint)
117 || directory_now.uid != geteuid().as_raw()
118 || directory_now.mode & 0o777 != 0o700
119 {
120 return Err(PromotionError::new(
121 FailureCode::StateBurn,
122 "private state directory identity, owner, or mode changed",
123 ));
124 }
125 revalidate_state_anchor(&self.state)?;
126 Ok(())
127 }
128}
129
130#[allow(dead_code)] pub(crate) fn burn_promotion_identity(
145 state_directory: &Path,
146 identity: &PromotionIdentity,
147) -> PromotionResult<DurablePromotionBurn> {
148 let directory = open_private_directory(state_directory)?;
149 let directory_fingerprint = Fingerprint::from_metadata(
150 &directory
151 .metadata()
152 .map_err(|error| state_error("cannot stat private state directory", error))?,
153 );
154 let journal = open_or_create_journal(&directory)?;
155 flock(&journal, FlockOperation::NonBlockingLockExclusive)
156 .map_err(|error| state_error("private promotion journal lock is busy", error))?;
157 let journal_fingerprint = regular_private_file(&journal, "promotion journal")?;
158 let mut locked = LockedState {
159 directory_path: state_directory.to_owned(),
160 directory,
161 directory_fingerprint,
162 journal,
163 journal_fingerprint,
164 };
165 revalidate_state_anchor(&locked)?;
166 let before = read_bounded(&mut locked.journal)?;
167 let state = verify_promotion_journal(&before)?;
168 let record = state.next_record(identity)?;
169 let line = record.canonical_line()?;
170 let prospective = before.len().checked_add(line.len()).ok_or_else(|| {
171 PromotionError::new(
172 FailureCode::StateBurn,
173 "promotion journal byte-count overflow",
174 )
175 })?;
176 if prospective > usize::try_from(MAXIMUM_JOURNAL_BYTES).expect("64 MiB fits usize") {
177 return Err(PromotionError::new(
178 FailureCode::StateBurn,
179 "promotion journal reached its strict 64 MiB bound",
180 ));
181 }
182 revalidate_state_anchor(&locked)?;
183 locked
184 .journal
185 .write_all(&line)
186 .map_err(|error| state_error("cannot append private promotion journal", error))?;
187 locked
188 .journal
189 .sync_all()
190 .map_err(|error| state_error("cannot fsync private promotion journal", error))?;
191 fsync(&locked.directory)
192 .map_err(|error| state_error("cannot fsync private state directory", error))?;
193 revalidate_state_anchor(&locked)?;
194
195 let after = read_bounded(&mut locked.journal)?;
196 if after.len() != prospective || !after.starts_with(&before) || &after[before.len()..] != line {
197 return Err(PromotionError::new(
198 FailureCode::StateBurn,
199 "durable promotion journal append did not preserve its exact prefix and record",
200 ));
201 }
202 let reparsed = verify_promotion_journal(&after)?;
203 if reparsed.next_sequence() != record.sequence().saturating_add(1) {
204 return Err(PromotionError::new(
205 FailureCode::StateBurn,
206 "durable promotion journal reparse did not end at the appended record",
207 ));
208 }
209 let metadata = regular_private_file(&locked.journal, "promotion journal")?;
210 if !metadata.same_object(&locked.journal_fingerprint) {
211 return Err(PromotionError::new(
212 FailureCode::StateBurn,
213 "promotion journal descriptor identity changed during append",
214 ));
215 }
216 locked.journal_fingerprint = metadata;
217 Ok(DurablePromotionBurn {
218 state: locked,
219 transaction: identity.transaction_identity(),
220 record,
221 journal_bytes: u64::try_from(after.len()).expect("bounded journal length fits u64"),
222 journal_sha256: sha256_hex(&after),
223 })
224}
225
226fn open_private_directory(path: &Path) -> PromotionResult<File> {
227 let canonical_path = path.to_str().is_some_and(|value| {
228 value.starts_with('/')
229 && value.len() > 1
230 && !value.ends_with('/')
231 && !value.contains("//")
232 && value
233 .split('/')
234 .skip(1)
235 .all(|component| !component.is_empty() && component != "." && component != "..")
236 });
237 if !canonical_path {
238 return Err(PromotionError::new(
239 FailureCode::StateBurn,
240 "private state directory must be a traversal-free canonical absolute path",
241 ));
242 }
243 let descriptor = openat2(
244 CWD,
245 path,
246 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
247 Mode::empty(),
248 ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS,
249 )
250 .map_err(|error| state_error("cannot securely open private state directory", error))?;
251 let file = File::from(descriptor);
252 let metadata = file
253 .metadata()
254 .map_err(|error| state_error("cannot stat private state directory", error))?;
255 if !metadata.file_type().is_dir()
256 || metadata.uid() != geteuid().as_raw()
257 || metadata.mode() & 0o777 != 0o700
258 {
259 return Err(PromotionError::new(
260 FailureCode::StateBurn,
261 "private state directory must be owned by the effective user with mode 0700",
262 ));
263 }
264 Ok(file)
265}
266
267fn open_or_create_journal(directory: &File) -> PromotionResult<File> {
268 let descriptor = openat2(
269 directory,
270 JOURNAL_NAME,
271 OFlags::RDWR | OFlags::APPEND | OFlags::CREATE | OFlags::CLOEXEC | OFlags::NOFOLLOW,
272 Mode::RUSR | Mode::WUSR,
273 ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS,
274 )
275 .map_err(|error| state_error("cannot securely open private promotion journal", error))?;
276 let file = File::from(descriptor);
277 let _ = regular_private_file(&file, "promotion journal")?;
278 Ok(file)
279}
280
281fn open_regular_beneath(directory: &File, name: &str) -> PromotionResult<File> {
282 let descriptor = openat2(
283 directory,
284 name,
285 OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
286 Mode::empty(),
287 ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS,
288 )
289 .map_err(|error| state_error("cannot reopen private promotion journal", error))?;
290 Ok(File::from(descriptor))
291}
292
293fn regular_private_file(file: &File, label: &str) -> PromotionResult<Fingerprint> {
294 let metadata = file
295 .metadata()
296 .map_err(|error| state_error(&format!("cannot stat {label}"), error))?;
297 if !metadata.file_type().is_file()
298 || metadata.nlink() != 1
299 || metadata.uid() != geteuid().as_raw()
300 || metadata.mode() & 0o777 != 0o600
301 {
302 return Err(PromotionError::new(
303 FailureCode::StateBurn,
304 format!("{label} must be an unaliased owner-only mode-0600 regular file"),
305 ));
306 }
307 Ok(Fingerprint::from_metadata(&metadata))
308}
309
310fn revalidate_state_anchor(state: &LockedState) -> PromotionResult<()> {
311 let path_directory = open_private_directory(&state.directory_path)?;
312 let path_fingerprint = Fingerprint::from_metadata(
313 &path_directory
314 .metadata()
315 .map_err(|error| state_error("cannot restat private state pathname", error))?,
316 );
317 if !path_fingerprint.same_object(&state.directory_fingerprint) {
318 return Err(PromotionError::new(
319 FailureCode::StateBurn,
320 "private state pathname no longer identifies the retained directory",
321 ));
322 }
323 let path_journal = open_regular_beneath(&path_directory, JOURNAL_NAME)?;
324 let journal_fingerprint = regular_private_file(&path_journal, "promotion journal path")?;
325 if !journal_fingerprint.same_object(&state.journal_fingerprint) {
326 return Err(PromotionError::new(
327 FailureCode::StateBurn,
328 "private promotion journal pathname no longer identifies the locked journal",
329 ));
330 }
331 Ok(())
332}
333
334fn read_bounded(file: &mut File) -> PromotionResult<Vec<u8>> {
335 file.seek(SeekFrom::Start(0))
336 .map_err(|error| state_error("cannot seek private promotion journal", error))?;
337 let mut bytes = Vec::new();
338 file.take(MAXIMUM_JOURNAL_BYTES + 1)
339 .read_to_end(&mut bytes)
340 .map_err(|error| state_error("cannot read private promotion journal", error))?;
341 if u64::try_from(bytes.len()).expect("usize fits u64") > MAXIMUM_JOURNAL_BYTES {
342 return Err(PromotionError::new(
343 FailureCode::StateBurn,
344 "private promotion journal exceeds its 64 MiB bound",
345 ));
346 }
347 Ok(bytes)
348}
349
350fn state_error(label: &str, error: impl std::fmt::Display) -> PromotionError {
351 PromotionError::new(FailureCode::StateBurn, format!("{label}: {error}"))
352}