Skip to main content

u280_hardware_promoter/
state.rs

1//! Descriptor-rooted durable burn of private promotion state.
2
3use 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/// Proof that one promotion identity was appended and fsynced before result
56/// publication. The retained nonblocking lock is held until this value drops.
57#[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    /// Durable preacceptance private-journal record consumed by this transaction.
68    #[must_use]
69    pub const fn record(&self) -> &PreacceptanceJournalRecord {
70        &self.record
71    }
72
73    /// Typed run/intent/burn identity durably consumed by this token.
74    #[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(&current) != 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(&current)?;
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/// Acquires the private journal lock without waiting, verifies the complete
131/// history, rejects replay, appends one canonical record, and fsyncs both the
132/// file and state directory before returning.
133///
134/// A crash after the append but before output publication permanently consumes
135/// the identity. The API deliberately has no rollback operation.
136/// This crate-private helper emits only the explicitly transitional
137/// preacceptance record and is not a final-adapter entry point.
138///
139/// # Errors
140///
141/// Returns [`FailureCode::StateBurn`] for an insecure directory, lock
142/// contention, malformed history, replay, append failure, or failed recheck.
143#[allow(dead_code)] // Retained for preacceptance publication fault tests only.
144pub(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}