1use std::collections::BTreeMap;
2use std::error::Error;
3use std::fmt;
4use std::io::{self, Read, Write};
5
6use cap_std::fs::{Dir, File};
7use vsh_types::{FileStamp, NodeKind, PlatformFileId, TransactionId};
8
9use crate::host::{create_new_file, open_real_file, sync_dir};
10
11const JOURNAL_MAGIC: &[u8; 8] = b"VSHLOG01";
12const MARKER_MAGIC: &[u8; 8] = b"VSHDONE1";
13const MAX_RECORD_PAYLOAD: usize = 64;
14
15pub(crate) const PLAN_FILE: &str = "plan";
16pub(crate) const JOURNAL_FILE: &str = "journal";
17pub(crate) const COMMIT_MARKER: &str = "commit-complete";
18pub(crate) const STAGE_DIRECTORY: &str = "stage";
19pub(crate) const QUARANTINE_DIRECTORY: &str = "quarantine";
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub(crate) struct Witness {
23 pub(crate) kind: NodeKind,
24 pub(crate) file_id: PlatformFileId,
25}
26
27impl From<FileStamp> for Witness {
28 fn from(stamp: FileStamp) -> Self {
29 Self {
30 kind: stamp.kind,
31 file_id: stamp.file_id,
32 }
33 }
34}
35
36#[derive(Debug)]
37pub(crate) struct Journal {
38 file: File,
39}
40
41impl Journal {
42 pub(crate) fn create(transaction_dir: &Dir) -> Result<Self, io::Error> {
43 let mut file = create_new_file(transaction_dir, JOURNAL_FILE)?;
44 file.write_all(JOURNAL_MAGIC)?;
45 file.sync_all()?;
46 sync_dir(transaction_dir)?;
47 Ok(Self { file })
48 }
49
50 pub(crate) fn intent(
51 &mut self,
52 index: u32,
53 source_witness: Option<Witness>,
54 parent_witness: Witness,
55 ) -> Result<(), io::Error> {
56 let mut payload = Vec::with_capacity(40);
57 payload.push(1);
58 payload.extend_from_slice(&index.to_le_bytes());
59 payload.push(u8::from(source_witness.is_some()) | 2);
60 if let Some(witness) = source_witness {
61 encode_witness(witness, &mut payload);
62 }
63 encode_witness(parent_witness, &mut payload);
64 self.append_record(&payload)
65 }
66
67 pub(crate) fn done(&mut self, index: u32, witness: Witness) -> Result<(), io::Error> {
68 let mut payload = Vec::with_capacity(22);
69 payload.push(2);
70 payload.extend_from_slice(&index.to_le_bytes());
71 payload.push(witness.kind.canonical_tag());
72 payload.extend_from_slice(&witness.file_id.high.to_le_bytes());
73 payload.extend_from_slice(&witness.file_id.low.to_le_bytes());
74 self.append_record(&payload)
75 }
76
77 fn append_record(&mut self, payload: &[u8]) -> Result<(), io::Error> {
78 let len = u32::try_from(payload.len())
79 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "journal record too large"))?;
80 self.file.write_all(&len.to_le_bytes())?;
81 self.file.write_all(payload)?;
82 self.file.write_all(&record_digest(payload))?;
83 self.file.sync_all()
84 }
85}
86
87#[derive(Clone, Debug, Default, Eq, PartialEq)]
88pub(crate) struct JournalState {
89 pub(crate) completed: BTreeMap<u32, Witness>,
90 intents: BTreeMap<u32, IntentWitnesses>,
91 pending: Option<u32>,
92 pub(crate) torn_tail: bool,
93}
94
95#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
96struct IntentWitnesses {
97 source: Option<Witness>,
98 parent: Option<Witness>,
99}
100
101impl JournalState {
102 pub(crate) fn has_intent(&self, index: u32) -> bool {
103 self.intents.contains_key(&index)
104 }
105
106 pub(crate) fn witness(&self, index: u32) -> Option<Witness> {
107 self.completed.get(&index).copied()
108 }
109
110 pub(crate) fn intent_witness(&self, index: u32) -> Option<Witness> {
111 self.intents
112 .get(&index)
113 .and_then(|witnesses| witnesses.source)
114 }
115
116 pub(crate) fn parent_witness(&self, index: u32) -> Option<Witness> {
117 self.intents
118 .get(&index)
119 .and_then(|witnesses| witnesses.parent)
120 }
121}
122
123pub(crate) fn read_journal(
124 transaction_dir: &Dir,
125 maximum_bytes: usize,
126) -> Result<JournalState, JournalError> {
127 let mut file = open_real_file(transaction_dir, JOURNAL_FILE).map_err(JournalError::Io)?;
128 let mut bytes = Vec::new();
129 Read::by_ref(&mut file)
130 .take(
131 u64::try_from(maximum_bytes)
132 .unwrap_or(u64::MAX)
133 .saturating_add(1),
134 )
135 .read_to_end(&mut bytes)
136 .map_err(JournalError::Io)?;
137 if bytes.len() > maximum_bytes {
138 return Err(JournalError::RecordLength);
139 }
140 if bytes.len() < JOURNAL_MAGIC.len() || &bytes[..8] != JOURNAL_MAGIC {
141 return Err(JournalError::Magic);
142 }
143 let mut offset = 8_usize;
144 let mut state = JournalState::default();
145 let mut next_index = 0_u32;
146 while offset < bytes.len() {
147 let Some(length_bytes) = bytes.get(offset..offset.saturating_add(4)) else {
148 state.torn_tail = true;
149 break;
150 };
151 let length = u32::from_le_bytes(
152 length_bytes
153 .try_into()
154 .expect("four-byte journal length slice"),
155 ) as usize;
156 if length > MAX_RECORD_PAYLOAD {
157 return Err(JournalError::RecordLength);
158 }
159 let payload_start = offset + 4;
160 let payload_end = payload_start
161 .checked_add(length)
162 .ok_or(JournalError::RecordLength)?;
163 let checksum_end = payload_end
164 .checked_add(32)
165 .ok_or(JournalError::RecordLength)?;
166 let Some(payload) = bytes.get(payload_start..payload_end) else {
167 state.torn_tail = true;
168 break;
169 };
170 let Some(checksum) = bytes.get(payload_end..checksum_end) else {
171 state.torn_tail = true;
172 break;
173 };
174 if record_digest(payload).as_slice() != checksum {
175 return Err(JournalError::Checksum);
176 }
177 match payload.first().copied() {
178 Some(1) if matches!(payload.len(), 5 | 22 | 23 | 40) => {
179 let index = u32::from_le_bytes(
180 payload[1..5]
181 .try_into()
182 .expect("four-byte intent index slice"),
183 );
184 if state.pending.is_some() || index != next_index {
185 return Err(JournalError::Sequence);
186 }
187 let witnesses = match payload.len() {
188 5 => IntentWitnesses::default(),
189 22 => IntentWitnesses {
190 source: Some(decode_witness_at(payload, 5)?),
191 parent: None,
192 },
193 23 | 40 => decode_intent_witnesses(payload)?,
194 _ => unreachable!("guarded intent payload length"),
195 };
196 state.intents.insert(index, witnesses);
197 state.pending = Some(index);
198 }
199 Some(2) if payload.len() == 22 => {
200 let index = u32::from_le_bytes(
201 payload[1..5]
202 .try_into()
203 .expect("four-byte completion index slice"),
204 );
205 if state.pending != Some(index) || index != next_index {
206 return Err(JournalError::Sequence);
207 }
208 state.completed.insert(index, decode_witness(payload)?);
209 state.pending = None;
210 next_index = next_index.checked_add(1).ok_or(JournalError::Sequence)?;
211 }
212 _ => return Err(JournalError::Tag),
213 }
214 offset = checksum_end;
215 }
216 Ok(state)
217}
218
219fn decode_witness(payload: &[u8]) -> Result<Witness, JournalError> {
220 decode_witness_at(payload, 5)
221}
222
223fn encode_witness(witness: Witness, output: &mut Vec<u8>) {
224 output.push(witness.kind.canonical_tag());
225 output.extend_from_slice(&witness.file_id.high.to_le_bytes());
226 output.extend_from_slice(&witness.file_id.low.to_le_bytes());
227}
228
229fn decode_intent_witnesses(payload: &[u8]) -> Result<IntentWitnesses, JournalError> {
230 let flags = payload[5];
231 if flags & !3 != 0 || flags & 2 == 0 {
232 return Err(JournalError::Tag);
233 }
234 let mut offset = 6;
235 let source = if flags & 1 == 1 {
236 let witness = decode_witness_at(payload, offset)?;
237 offset += 17;
238 Some(witness)
239 } else {
240 None
241 };
242 let parent = Some(decode_witness_at(payload, offset)?);
243 offset += 17;
244 if offset != payload.len() {
245 return Err(JournalError::RecordLength);
246 }
247 Ok(IntentWitnesses { source, parent })
248}
249
250fn decode_witness_at(payload: &[u8], offset: usize) -> Result<Witness, JournalError> {
251 let bytes = payload
252 .get(offset..offset.saturating_add(17))
253 .ok_or(JournalError::RecordLength)?;
254 let kind = match bytes[0] {
255 1 => NodeKind::File,
256 2 => NodeKind::Directory,
257 3 => NodeKind::Symlink,
258 _ => return Err(JournalError::Tag),
259 };
260 let high = u64::from_le_bytes(
261 bytes[1..9]
262 .try_into()
263 .expect("eight-byte witness high slice"),
264 );
265 let low = u64::from_le_bytes(
266 bytes[9..17]
267 .try_into()
268 .expect("eight-byte witness low slice"),
269 );
270 Ok(Witness {
271 kind,
272 file_id: PlatformFileId { high, low },
273 })
274}
275
276pub(crate) fn write_commit_marker(
277 transaction_dir: &Dir,
278 transaction: TransactionId,
279) -> Result<(), io::Error> {
280 let mut payload = Vec::with_capacity(72);
281 payload.extend_from_slice(MARKER_MAGIC);
282 payload.extend_from_slice(transaction.as_bytes());
283 let digest = marker_digest(&payload);
284 payload.extend_from_slice(&digest);
285 let mut marker = create_new_file(transaction_dir, COMMIT_MARKER)?;
286 marker.write_all(&payload)?;
287 marker.sync_all()?;
288 sync_dir(transaction_dir)
289}
290
291pub(crate) fn has_valid_commit_marker(
292 transaction_dir: &Dir,
293 transaction: TransactionId,
294) -> Result<bool, JournalError> {
295 let mut marker = match open_real_file(transaction_dir, COMMIT_MARKER) {
296 Ok(marker) => marker,
297 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(false),
298 Err(source) => return Err(JournalError::Io(source)),
299 };
300 let mut bytes = Vec::new();
301 Read::by_ref(&mut marker)
302 .take(73)
303 .read_to_end(&mut bytes)
304 .map_err(JournalError::Io)?;
305 if bytes.len() != 72 || &bytes[..8] != MARKER_MAGIC {
306 return Err(JournalError::Marker);
307 }
308 if &bytes[8..40] != transaction.as_bytes() {
309 return Err(JournalError::Marker);
310 }
311 if marker_digest(&bytes[..40]).as_slice() != &bytes[40..72] {
312 return Err(JournalError::Marker);
313 }
314 Ok(true)
315}
316
317fn record_digest(payload: &[u8]) -> [u8; 32] {
318 let mut hasher = blake3::Hasher::new();
319 hasher.update(b"vsh\0commit-journal-record-v1\0");
320 hasher.update(&(payload.len() as u64).to_le_bytes());
321 hasher.update(payload);
322 *hasher.finalize().as_bytes()
323}
324
325fn marker_digest(payload: &[u8]) -> [u8; 32] {
326 let mut hasher = blake3::Hasher::new();
327 hasher.update(b"vsh\0commit-marker-v1\0");
328 hasher.update(payload);
329 *hasher.finalize().as_bytes()
330}
331
332#[derive(Debug)]
334pub enum JournalError {
335 Io(io::Error),
337 Magic,
339 RecordLength,
341 Checksum,
343 Sequence,
345 Tag,
347 Marker,
349}
350
351impl fmt::Display for JournalError {
352 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
353 formatter.write_str(match self {
354 Self::Io(_) => "commit journal I/O failed",
355 Self::Magic => "commit journal has an unknown format",
356 Self::RecordLength => "commit journal record length is invalid",
357 Self::Checksum => "commit journal checksum mismatch",
358 Self::Sequence => "commit journal operation sequence is invalid",
359 Self::Tag => "commit journal contains an unknown record tag",
360 Self::Marker => "commit-complete marker is invalid",
361 })
362 }
363}
364
365impl Error for JournalError {
366 fn source(&self) -> Option<&(dyn Error + 'static)> {
367 match self {
368 Self::Io(source) => Some(source),
369 Self::Magic
370 | Self::RecordLength
371 | Self::Checksum
372 | Self::Sequence
373 | Self::Tag
374 | Self::Marker => None,
375 }
376 }
377}