1use std::error::Error;
4use std::fmt::{self, Display, Formatter};
5use std::path::{Component, Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8
9#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
16#[serde(deny_unknown_fields)]
17pub struct PublishedFileIdentity {
18 pub device: u64,
19 pub inode: u64,
20 pub size: u64,
21 pub sha256: [u8; 32],
22}
23
24impl PublishedFileIdentity {
25 #[must_use]
26 pub fn is_valid(&self) -> bool {
27 self.inode != 0 && self.size != 0 && self.sha256.iter().any(|byte| *byte != 0)
28 }
29}
30
31pub struct SecureCreateNewFilePublisher {
33 #[cfg(any(target_os = "linux", target_os = "macos"))]
34 files: std::sync::Arc<unix::PublisherFiles>,
35}
36
37pub struct SecurePrivateDirectoryTree {
43 #[cfg(any(target_os = "linux", target_os = "macos"))]
44 root: std::sync::Arc<std::fs::File>,
45}
46
47impl SecurePrivateDirectoryTree {
48 pub async fn open_or_create_root(path: PathBuf) -> Result<(Self, bool), SecurePublicationError> {
50 if !valid_absolute_path(&path) {
51 return Err(SecurePublicationError::InvalidPath);
52 }
53
54 #[cfg(any(target_os = "linux", target_os = "macos"))]
55 {
56 let (root, created) = tokio::task::spawn_blocking(move || unix::open_or_create_private_root(&path))
57 .await
58 .map_err(|_| SecurePublicationError::WorkerUnavailable)??;
59 Ok((
60 Self {
61 root: std::sync::Arc::new(root),
62 },
63 created,
64 ))
65 }
66
67 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
68 {
69 let _ = path;
70 Err(SecurePublicationError::UnsupportedPlatform)
71 }
72 }
73
74 pub async fn ensure_directory(&self, relative: PathBuf) -> Result<(), SecurePublicationError> {
76 if !valid_relative_directory_path(&relative) {
77 return Err(SecurePublicationError::InvalidPath);
78 }
79
80 #[cfg(any(target_os = "linux", target_os = "macos"))]
81 {
82 let root = std::sync::Arc::clone(&self.root);
83 tokio::task::spawn_blocking(move || unix::ensure_private_directory(&root, &relative))
84 .await
85 .map_err(|_| SecurePublicationError::WorkerUnavailable)?
86 }
87
88 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
89 {
90 let _ = relative;
91 Err(SecurePublicationError::UnsupportedPlatform)
92 }
93 }
94}
95
96impl SecureCreateNewFilePublisher {
97 pub async fn open(
98 stage_directory: PathBuf,
99 stage_name: String,
100 destination: PathBuf,
101 ) -> Result<Self, SecurePublicationError> {
102 if !valid_absolute_path(&stage_directory) || !valid_absolute_path(&destination) || !valid_name(&stage_name) {
103 return Err(SecurePublicationError::InvalidPath);
104 }
105
106 #[cfg(any(target_os = "linux", target_os = "macos"))]
107 {
108 let files = tokio::task::spawn_blocking(move || {
109 unix::PublisherFiles::open(&stage_directory, &stage_name, &destination)
110 })
111 .await
112 .map_err(|_| SecurePublicationError::WorkerUnavailable)??;
113 Ok(Self {
114 files: std::sync::Arc::new(files),
115 })
116 }
117
118 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
119 {
120 let _ = (stage_directory, stage_name, destination);
121 Err(SecurePublicationError::UnsupportedPlatform)
122 }
123 }
124
125 pub async fn stage(
126 &self,
127 bytes: Vec<u8>,
128 maximum_bytes: usize,
129 expected: Option<PublishedFileIdentity>,
130 ) -> Result<PublishedFileIdentity, SecurePublicationError> {
131 self.stage_inner(bytes, maximum_bytes, expected, false).await
132 }
133
134 pub async fn stage_sensitive(
136 &self,
137 bytes: Vec<u8>,
138 maximum_bytes: usize,
139 expected: Option<PublishedFileIdentity>,
140 ) -> Result<PublishedFileIdentity, SecurePublicationError> {
141 self.stage_inner(bytes, maximum_bytes, expected, true).await
142 }
143
144 async fn stage_inner(
145 &self,
146 mut bytes: Vec<u8>,
147 maximum_bytes: usize,
148 expected: Option<PublishedFileIdentity>,
149 sensitive: bool,
150 ) -> Result<PublishedFileIdentity, SecurePublicationError> {
151 if bytes.is_empty() || bytes.len() > maximum_bytes || maximum_bytes == 0 {
152 if sensitive {
153 bytes.fill(0);
154 std::hint::black_box(&mut bytes);
155 }
156 return Err(SecurePublicationError::InvalidMaterial);
157 }
158
159 #[cfg(any(target_os = "linux", target_os = "macos"))]
160 {
161 let files = std::sync::Arc::clone(&self.files);
162 tokio::task::spawn_blocking(move || {
163 let mut bytes = bytes;
164 let result = unix::stage(&files, &bytes, maximum_bytes, expected.as_ref());
165 if sensitive {
166 bytes.fill(0);
167 std::hint::black_box(&mut bytes);
168 }
169 result
170 })
171 .await
172 .map_err(|_| SecurePublicationError::WorkerUnavailable)?
173 }
174
175 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
176 {
177 let _ = (bytes, maximum_bytes, expected, sensitive);
178 Err(SecurePublicationError::UnsupportedPlatform)
179 }
180 }
181
182 pub async fn read_stage(&self, maximum_bytes: usize) -> Result<Option<Vec<u8>>, SecurePublicationError> {
184 #[cfg(any(target_os = "linux", target_os = "macos"))]
185 {
186 let files = std::sync::Arc::clone(&self.files);
187 tokio::task::spawn_blocking(move || unix::read_stage(&files, maximum_bytes))
188 .await
189 .map_err(|_| SecurePublicationError::WorkerUnavailable)?
190 }
191
192 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
193 {
194 let _ = maximum_bytes;
195 Err(SecurePublicationError::UnsupportedPlatform)
196 }
197 }
198
199 pub async fn read_published(&self, maximum_bytes: usize) -> Result<Option<Vec<u8>>, SecurePublicationError> {
201 #[cfg(any(target_os = "linux", target_os = "macos"))]
202 {
203 let files = std::sync::Arc::clone(&self.files);
204 tokio::task::spawn_blocking(move || unix::read_published(&files, maximum_bytes))
205 .await
206 .map_err(|_| SecurePublicationError::WorkerUnavailable)?
207 }
208
209 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
210 {
211 let _ = maximum_bytes;
212 Err(SecurePublicationError::UnsupportedPlatform)
213 }
214 }
215
216 pub async fn remove_unpublished_stage(
218 &self,
219 expected: Option<PublishedFileIdentity>,
220 ) -> Result<(), SecurePublicationError> {
221 #[cfg(any(target_os = "linux", target_os = "macos"))]
222 {
223 let files = std::sync::Arc::clone(&self.files);
224 tokio::task::spawn_blocking(move || unix::remove_unpublished_stage(&files, expected.as_ref()))
225 .await
226 .map_err(|_| SecurePublicationError::WorkerUnavailable)?
227 }
228
229 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
230 {
231 let _ = expected;
232 Err(SecurePublicationError::UnsupportedPlatform)
233 }
234 }
235
236 pub async fn publish(&self, expected: PublishedFileIdentity) -> Result<(), SecurePublicationError> {
237 #[cfg(any(target_os = "linux", target_os = "macos"))]
238 {
239 let files = std::sync::Arc::clone(&self.files);
240 tokio::task::spawn_blocking(move || unix::publish(&files, &expected))
241 .await
242 .map_err(|_| SecurePublicationError::WorkerUnavailable)?
243 }
244
245 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
246 {
247 let _ = expected;
248 Err(SecurePublicationError::UnsupportedPlatform)
249 }
250 }
251
252 pub async fn publish_deterministic(
260 &self,
261 bytes: Vec<u8>,
262 maximum_bytes: usize,
263 ) -> Result<PublishedFileIdentity, SecurePublicationError> {
264 if bytes.is_empty() || bytes.len() > maximum_bytes || maximum_bytes == 0 {
265 return Err(SecurePublicationError::InvalidMaterial);
266 }
267
268 #[cfg(any(target_os = "linux", target_os = "macos"))]
269 {
270 let files = std::sync::Arc::clone(&self.files);
271 tokio::task::spawn_blocking(move || unix::publish_deterministic(&files, &bytes, maximum_bytes))
272 .await
273 .map_err(|_| SecurePublicationError::WorkerUnavailable)?
274 }
275
276 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
277 {
278 let _ = (bytes, maximum_bytes);
279 Err(SecurePublicationError::UnsupportedPlatform)
280 }
281 }
282
283 pub async fn verify_published(&self, expected: PublishedFileIdentity) -> Result<(), SecurePublicationError> {
284 #[cfg(any(target_os = "linux", target_os = "macos"))]
285 {
286 let files = std::sync::Arc::clone(&self.files);
287 tokio::task::spawn_blocking(move || unix::verify_published(&files, &expected))
288 .await
289 .map_err(|_| SecurePublicationError::WorkerUnavailable)?
290 }
291
292 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
293 {
294 let _ = expected;
295 Err(SecurePublicationError::UnsupportedPlatform)
296 }
297 }
298
299 pub async fn validate_destination(
301 &self,
302 expected: Option<PublishedFileIdentity>,
303 required: bool,
304 ) -> Result<(), SecurePublicationError> {
305 #[cfg(any(target_os = "linux", target_os = "macos"))]
306 {
307 let files = std::sync::Arc::clone(&self.files);
308 tokio::task::spawn_blocking(move || unix::validate_destination(&files, expected.as_ref(), required))
309 .await
310 .map_err(|_| SecurePublicationError::WorkerUnavailable)?
311 }
312
313 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
314 {
315 let _ = (expected, required);
316 Err(SecurePublicationError::UnsupportedPlatform)
317 }
318 }
319}
320
321#[derive(Clone, Copy, Debug, Eq, PartialEq)]
322pub enum SecurePublicationError {
323 UnsupportedPlatform,
324 InvalidPath,
325 InsecurePath,
326 DestinationExists,
327 Conflict,
328 InvalidState,
329 InvalidMaterial,
330 Io,
331 WorkerUnavailable,
332}
333
334impl SecurePublicationError {
335 #[must_use]
336 pub const fn code(self) -> &'static str {
337 match self {
338 Self::UnsupportedPlatform => "unsupported_platform",
339 Self::InvalidPath => "invalid_path",
340 Self::InsecurePath => "insecure_path",
341 Self::DestinationExists => "destination_exists",
342 Self::Conflict => "conflict",
343 Self::InvalidState => "invalid_state",
344 Self::InvalidMaterial => "invalid_material",
345 Self::Io => "io",
346 Self::WorkerUnavailable => "worker_unavailable",
347 }
348 }
349}
350
351impl Display for SecurePublicationError {
352 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
353 formatter.write_str(match self {
354 Self::UnsupportedPlatform => "the platform cannot provide secure create-new publication",
355 Self::InvalidPath => "a secure publication path is not canonical and absolute",
356 Self::InsecurePath => "a secure publication path is symlinked, foreign-owned, or broadly writable",
357 Self::DestinationExists => "a secure create-new publication destination already exists",
358 Self::Conflict => "secure publication state conflicts with the expected file identity",
359 Self::InvalidState => "secure publication is incomplete or out of order",
360 Self::InvalidMaterial => "secure publication material is invalid",
361 Self::Io => "secure publication filesystem operation failed",
362 Self::WorkerUnavailable => "secure publication filesystem worker did not complete",
363 })
364 }
365}
366
367impl Error for SecurePublicationError {}
368
369fn valid_absolute_path(path: &Path) -> bool {
370 path.is_absolute()
371 && path.parent().is_some()
372 && path
373 .components()
374 .all(|component| matches!(component, Component::RootDir | Component::Normal(_)))
375 && path.components().collect::<PathBuf>().as_os_str() == path.as_os_str()
376}
377
378fn valid_name(value: &str) -> bool {
379 !value.is_empty()
380 && value.len() <= 255
381 && Path::new(value).components().count() == 1
382 && matches!(Path::new(value).components().next(), Some(Component::Normal(_)))
383 && !value.bytes().any(|byte| byte.is_ascii_control())
384}
385
386fn valid_relative_directory_path(path: &Path) -> bool {
387 !path.as_os_str().is_empty()
388 && !path.is_absolute()
389 && path
390 .components()
391 .all(|component| matches!(component, Component::Normal(_)))
392 && path.components().collect::<PathBuf>().as_os_str() == path.as_os_str()
393}
394
395#[cfg(any(target_os = "linux", target_os = "macos"))]
396mod unix {
397 use std::ffi::{OsStr, OsString};
398 use std::fs::File;
399 use std::io::{Read as _, Seek as _, SeekFrom, Write as _};
400 use std::path::{Component, Path};
401
402 use aws_lc_rs::digest;
403 use rustix::fs::{AtFlags, FileType, Mode, OFlags, fstat, fsync, linkat, mkdirat, open, openat, unlinkat};
404
405 use super::{PublishedFileIdentity, SecurePublicationError};
406
407 const FILE_MODE: rustix::fs::RawMode = 0o600;
408 const DIRECTORY_MODE: rustix::fs::RawMode = 0o700;
409
410 fn stat_value_u64<T: TryInto<u64>>(value: T) -> Option<u64> {
411 value.try_into().ok()
412 }
413
414 pub(super) struct PublisherFiles {
415 stage_directory: File,
416 stage_name: OsString,
417 destination_parent: File,
418 destination_name: OsString,
419 }
420
421 impl PublisherFiles {
422 pub(super) fn open(
423 stage_directory: &Path,
424 stage_name: &str,
425 destination: &Path,
426 ) -> Result<Self, SecurePublicationError> {
427 let destination_parent_path = destination.parent().ok_or(SecurePublicationError::InvalidPath)?;
428 let destination_name = destination.file_name().ok_or(SecurePublicationError::InvalidPath)?;
429 let stage_directory = open_directory_chain(stage_directory)?;
430 let destination_parent = open_directory_chain(destination_parent_path)?;
431 validate_private_directory(&stage_directory)?;
432 validate_destination_parent(&destination_parent)?;
433 Ok(Self {
434 stage_directory,
435 stage_name: OsString::from(stage_name),
436 destination_parent,
437 destination_name: destination_name.to_owned(),
438 })
439 }
440 }
441
442 pub(super) fn open_or_create_private_root(path: &Path) -> Result<(File, bool), SecurePublicationError> {
443 let parent_path = path.parent().ok_or(SecurePublicationError::InvalidPath)?;
444 let name = path.file_name().ok_or(SecurePublicationError::InvalidPath)?;
445 let parent = open_directory_chain(parent_path)?;
446 validate_destination_parent(&parent)?;
447 let created = match mkdirat(&parent, name, Mode::from_raw_mode(DIRECTORY_MODE)) {
448 Ok(()) => {
449 fsync(&parent).map_err(|_| SecurePublicationError::Io)?;
450 true
451 }
452 Err(error) if error == rustix::io::Errno::EXIST => false,
453 Err(error) => return Err(map_open_error(error)),
454 };
455 let root = File::from(openat(&parent, name, directory_flags(), Mode::empty()).map_err(map_open_error)?);
456 validate_private_directory(&root)?;
457 Ok((root, created))
458 }
459
460 pub(super) fn ensure_private_directory(root: &File, relative: &Path) -> Result<(), SecurePublicationError> {
461 let mut current = root.try_clone().map_err(|_| SecurePublicationError::Io)?;
462 validate_private_directory(¤t)?;
463 for component in relative.components() {
464 let Component::Normal(name) = component else {
465 return Err(SecurePublicationError::InvalidPath);
466 };
467 match mkdirat(¤t, name, Mode::from_raw_mode(DIRECTORY_MODE)) {
468 Ok(()) => fsync(¤t).map_err(|_| SecurePublicationError::Io)?,
469 Err(error) if error == rustix::io::Errno::EXIST => {}
470 Err(error) => return Err(map_open_error(error)),
471 }
472 let next = File::from(openat(¤t, name, directory_flags(), Mode::empty()).map_err(map_open_error)?);
473 validate_private_directory(&next)?;
474 current = next;
475 }
476 Ok(())
477 }
478
479 pub(super) fn stage(
480 files: &PublisherFiles,
481 bytes: &[u8],
482 maximum_bytes: usize,
483 expected: Option<&PublishedFileIdentity>,
484 ) -> Result<PublishedFileIdentity, SecurePublicationError> {
485 if open_optional(&files.destination_parent, &files.destination_name)?.is_some() && expected.is_none() {
486 return Err(SecurePublicationError::DestinationExists);
487 }
488 let (file, created) = open_or_create_stage(&files.stage_directory, &files.stage_name)?;
489 if created {
490 (&file).write_all(bytes).map_err(|_| SecurePublicationError::Io)?;
491 file.sync_all().map_err(|_| SecurePublicationError::Io)?;
492 fsync(&files.stage_directory).map_err(|_| SecurePublicationError::Io)?;
493 } else if read_bounded(&file, maximum_bytes)? != bytes {
494 return Err(SecurePublicationError::Conflict);
495 }
496 let identity = file_identity(&file, maximum_bytes, Some(1))?;
497 if expected.is_some_and(|expected| !durable_identity_matches(expected, &identity)) {
498 return Err(SecurePublicationError::Conflict);
499 }
500 Ok(identity)
501 }
502
503 pub(super) fn read_stage(
504 files: &PublisherFiles,
505 maximum_bytes: usize,
506 ) -> Result<Option<Vec<u8>>, SecurePublicationError> {
507 let Some(file) = open_optional(&files.stage_directory, &files.stage_name)? else {
508 return Ok(None);
509 };
510 validate_file(&file, Some(1))?;
511 read_bounded(&file, maximum_bytes).map(Some)
512 }
513
514 pub(super) fn read_published(
515 files: &PublisherFiles,
516 maximum_bytes: usize,
517 ) -> Result<Option<Vec<u8>>, SecurePublicationError> {
518 let Some(file) = open_optional(&files.destination_parent, &files.destination_name)? else {
519 return Ok(None);
520 };
521 validate_file(&file, Some(1))?;
522 read_bounded(&file, maximum_bytes).map(Some)
523 }
524
525 pub(super) fn remove_unpublished_stage(
526 files: &PublisherFiles,
527 expected: Option<&PublishedFileIdentity>,
528 ) -> Result<(), SecurePublicationError> {
529 if open_optional(&files.destination_parent, &files.destination_name)?.is_some() {
530 return Err(SecurePublicationError::Conflict);
531 }
532 let Some(file) = open_optional(&files.stage_directory, &files.stage_name)? else {
533 return Ok(());
534 };
535 match expected {
536 Some(expected) => require_identity(&file, expected, Some(1))?,
537 None => {
538 validate_file(&file, Some(1))?;
539 }
540 }
541 drop(file);
542 unlinkat(&files.stage_directory, &files.stage_name, AtFlags::empty())
543 .map_err(|_| SecurePublicationError::Io)?;
544 fsync(&files.stage_directory).map_err(|_| SecurePublicationError::Io)
545 }
546
547 pub(super) fn publish(
548 files: &PublisherFiles,
549 expected: &PublishedFileIdentity,
550 ) -> Result<(), SecurePublicationError> {
551 let staged = open_optional(&files.stage_directory, &files.stage_name)?;
552 let destination = open_optional(&files.destination_parent, &files.destination_name)?;
553 match (staged, destination) {
554 (Some(staged), None) => {
555 require_identity(&staged, expected, Some(1))?;
556 linkat(
557 &files.stage_directory,
558 &files.stage_name,
559 &files.destination_parent,
560 &files.destination_name,
561 AtFlags::empty(),
562 )
563 .map_err(|error| {
564 if error == rustix::io::Errno::EXIST {
565 SecurePublicationError::DestinationExists
566 } else {
567 SecurePublicationError::Io
568 }
569 })?;
570 fsync(&files.destination_parent).map_err(|_| SecurePublicationError::Io)?;
571 let destination = open_optional(&files.destination_parent, &files.destination_name)?
572 .ok_or(SecurePublicationError::Io)?;
573 require_identity(&staged, expected, Some(2))?;
574 require_identity(&destination, expected, Some(2))?;
575 require_same_inode(&staged, &destination)?;
576 drop(staged);
577 drop(destination);
578 unlinkat(&files.stage_directory, &files.stage_name, AtFlags::empty())
579 .map_err(|_| SecurePublicationError::Io)?;
580 fsync(&files.stage_directory).map_err(|_| SecurePublicationError::Io)?;
581 }
582 (Some(staged), Some(destination)) => {
583 require_identity(&staged, expected, Some(2))?;
584 require_identity(&destination, expected, Some(2))?;
585 require_same_inode(&staged, &destination)?;
586 drop(staged);
587 drop(destination);
588 unlinkat(&files.stage_directory, &files.stage_name, AtFlags::empty())
589 .map_err(|_| SecurePublicationError::Io)?;
590 fsync(&files.stage_directory).map_err(|_| SecurePublicationError::Io)?;
591 }
592 (None, Some(destination)) => require_identity(&destination, expected, Some(1))?,
593 (None, None) => return Err(SecurePublicationError::InvalidState),
594 }
595 verify_published(files, expected)
596 }
597
598 pub(super) fn publish_deterministic(
599 files: &PublisherFiles,
600 bytes: &[u8],
601 maximum_bytes: usize,
602 ) -> Result<PublishedFileIdentity, SecurePublicationError> {
603 let staged = open_optional(&files.stage_directory, &files.stage_name)?;
604 let destination = open_optional(&files.destination_parent, &files.destination_name)?;
605 let identity = match (staged, destination) {
606 (Some(staged), Some(destination)) => {
607 require_same_inode(&staged, &destination)?;
608 if read_bounded(&staged, maximum_bytes)? != bytes || read_bounded(&destination, maximum_bytes)? != bytes
609 {
610 return Err(SecurePublicationError::Conflict);
611 }
612 let identity = file_identity(&staged, maximum_bytes, Some(2))?;
613 require_identity(&destination, &identity, Some(2))?;
614 identity
615 }
616 (Some(staged), None) => {
617 if read_bounded(&staged, maximum_bytes)? != bytes {
618 return Err(SecurePublicationError::Conflict);
619 }
620 file_identity(&staged, maximum_bytes, Some(1))?
621 }
622 (None, Some(destination)) => {
623 if read_bounded(&destination, maximum_bytes)? != bytes {
624 return Err(SecurePublicationError::Conflict);
625 }
626 return file_identity(&destination, maximum_bytes, Some(1));
627 }
628 (None, None) => stage(files, bytes, maximum_bytes, None)?,
629 };
630 publish(files, &identity)?;
631 Ok(identity)
632 }
633
634 pub(super) fn verify_published(
635 files: &PublisherFiles,
636 expected: &PublishedFileIdentity,
637 ) -> Result<(), SecurePublicationError> {
638 let destination = open_optional(&files.destination_parent, &files.destination_name)?
639 .ok_or(SecurePublicationError::InvalidState)?;
640 require_identity(&destination, expected, Some(1))
641 }
642
643 pub(super) fn validate_destination(
644 files: &PublisherFiles,
645 expected: Option<&PublishedFileIdentity>,
646 required: bool,
647 ) -> Result<(), SecurePublicationError> {
648 let destination = open_optional(&files.destination_parent, &files.destination_name)?;
649 match (destination, expected, required) {
650 (Some(file), Some(expected), _) => require_identity(&file, expected, None),
651 (Some(_), None, _) => Err(SecurePublicationError::DestinationExists),
652 (None, _, true) => Err(SecurePublicationError::InvalidState),
653 (None, _, false) => Ok(()),
654 }
655 }
656
657 fn open_directory_chain(path: &Path) -> Result<File, SecurePublicationError> {
658 let mut components = path.components();
659 if !matches!(components.next(), Some(Component::RootDir)) {
660 return Err(SecurePublicationError::InvalidPath);
661 }
662 let mut current = File::from(open("/", directory_flags(), Mode::empty()).map_err(map_open_error)?);
663 for component in components {
664 let Component::Normal(name) = component else {
665 return Err(SecurePublicationError::InvalidPath);
666 };
667 current = File::from(openat(¤t, name, directory_flags(), Mode::empty()).map_err(map_open_error)?);
668 }
669 Ok(current)
670 }
671
672 const fn directory_flags() -> OFlags {
673 OFlags::RDONLY
674 .union(OFlags::DIRECTORY)
675 .union(OFlags::CLOEXEC)
676 .union(OFlags::NOFOLLOW)
677 }
678
679 fn validate_private_directory(directory: &File) -> Result<(), SecurePublicationError> {
680 let stat = fstat(directory).map_err(|_| SecurePublicationError::Io)?;
681 if FileType::from_raw_mode(stat.st_mode) != FileType::Directory
682 || stat.st_uid != rustix::process::geteuid().as_raw()
683 || stat.st_mode & 0o777 != 0o700
684 {
685 return Err(SecurePublicationError::InsecurePath);
686 }
687 Ok(())
688 }
689
690 fn validate_destination_parent(directory: &File) -> Result<(), SecurePublicationError> {
691 let stat = fstat(directory).map_err(|_| SecurePublicationError::Io)?;
692 if FileType::from_raw_mode(stat.st_mode) != FileType::Directory
693 || stat.st_uid != rustix::process::geteuid().as_raw()
694 || stat.st_mode & 0o022 != 0
695 {
696 return Err(SecurePublicationError::InsecurePath);
697 }
698 Ok(())
699 }
700
701 fn open_or_create_stage(directory: &File, name: &OsStr) -> Result<(File, bool), SecurePublicationError> {
702 let create = OFlags::RDWR | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW;
703 match openat(directory, name, create, Mode::from_raw_mode(FILE_MODE)) {
704 Ok(file) => Ok((File::from(file), true)),
705 Err(error) if error == rustix::io::Errno::EXIST => {
706 let file = open_optional(directory, name)?.ok_or(SecurePublicationError::Io)?;
707 validate_file(&file, Some(1))?;
708 Ok((file, false))
709 }
710 Err(error) => Err(map_open_error(error)),
711 }
712 }
713
714 fn open_optional(directory: &File, name: &OsStr) -> Result<Option<File>, SecurePublicationError> {
715 let flags = OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK;
716 match openat(directory, name, flags, Mode::empty()) {
717 Ok(file) => Ok(Some(File::from(file))),
718 Err(error) if error == rustix::io::Errno::NOENT => Ok(None),
719 Err(error) => Err(map_open_error(error)),
720 }
721 }
722
723 fn validate_file(file: &File, links: Option<u64>) -> Result<rustix::fs::Stat, SecurePublicationError> {
724 let stat = fstat(file).map_err(|_| SecurePublicationError::Io)?;
725 if FileType::from_raw_mode(stat.st_mode) != FileType::RegularFile
726 || stat.st_uid != rustix::process::geteuid().as_raw()
727 || stat.st_mode & 0o777 != FILE_MODE
728 || links.is_some_and(|links| stat_value_u64(stat.st_nlink) != Some(links))
729 {
730 return Err(SecurePublicationError::InsecurePath);
731 }
732 Ok(stat)
733 }
734
735 fn file_identity(
736 file: &File,
737 maximum_bytes: usize,
738 links: Option<u64>,
739 ) -> Result<PublishedFileIdentity, SecurePublicationError> {
740 let stat = validate_file(file, links)?;
741 let bytes = read_bounded(file, maximum_bytes)?;
742 if bytes.is_empty() {
743 return Err(SecurePublicationError::InvalidMaterial);
744 }
745 Ok(PublishedFileIdentity {
746 device: stat_value_u64(stat.st_dev).ok_or(SecurePublicationError::InvalidMaterial)?,
747 inode: stat.st_ino,
748 size: u64::try_from(stat.st_size).map_err(|_| SecurePublicationError::InvalidMaterial)?,
749 sha256: sha256(&bytes),
750 })
751 }
752
753 fn require_identity(
754 file: &File,
755 expected: &PublishedFileIdentity,
756 links: Option<u64>,
757 ) -> Result<(), SecurePublicationError> {
758 let maximum = usize::try_from(expected.size).map_err(|_| SecurePublicationError::InvalidMaterial)?;
759 let observed = file_identity(file, maximum, links)?;
760 if !durable_identity_matches(expected, &observed) {
764 return Err(SecurePublicationError::Conflict);
765 }
766 Ok(())
767 }
768
769 fn durable_identity_matches(expected: &PublishedFileIdentity, observed: &PublishedFileIdentity) -> bool {
770 expected.is_valid()
771 && observed.is_valid()
772 && expected.inode == observed.inode
773 && expected.size == observed.size
774 && expected.sha256 == observed.sha256
775 }
776
777 fn require_same_inode(left: &File, right: &File) -> Result<(), SecurePublicationError> {
778 let left = fstat(left).map_err(|_| SecurePublicationError::Io)?;
779 let right = fstat(right).map_err(|_| SecurePublicationError::Io)?;
780 if left.st_dev != right.st_dev || left.st_ino != right.st_ino || left.st_size != right.st_size {
781 return Err(SecurePublicationError::Conflict);
782 }
783 Ok(())
784 }
785
786 fn read_bounded(file: &File, maximum: usize) -> Result<Vec<u8>, SecurePublicationError> {
787 let mut file = file.try_clone().map_err(|_| SecurePublicationError::Io)?;
788 file.seek(SeekFrom::Start(0)).map_err(|_| SecurePublicationError::Io)?;
789 let mut bytes = Vec::new();
790 let limit = u64::try_from(maximum)
791 .map_err(|_| SecurePublicationError::InvalidMaterial)?
792 .saturating_add(1);
793 file.take(limit)
794 .read_to_end(&mut bytes)
795 .map_err(|_| SecurePublicationError::Io)?;
796 if bytes.is_empty() || bytes.len() > maximum {
797 return Err(SecurePublicationError::InvalidMaterial);
798 }
799 Ok(bytes)
800 }
801
802 fn sha256(bytes: &[u8]) -> [u8; 32] {
803 let observed = digest::digest(&digest::SHA256, bytes);
804 let mut output = [0; 32];
805 output.copy_from_slice(observed.as_ref());
806 output
807 }
808
809 fn map_open_error(error: rustix::io::Errno) -> SecurePublicationError {
810 match error {
811 rustix::io::Errno::LOOP | rustix::io::Errno::NOTDIR => SecurePublicationError::InsecurePath,
812 rustix::io::Errno::NOENT => SecurePublicationError::InvalidPath,
813 _ => SecurePublicationError::Io,
814 }
815 }
816}
817
818#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
819mod tests {
820 use std::fs;
821 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _, symlink};
822
823 use super::*;
824
825 struct TemporaryDirectory(PathBuf);
826
827 impl TemporaryDirectory {
828 fn create() -> Result<Self, Box<dyn Error>> {
829 let mut nonce = [0_u8; 16];
830 getrandom::fill(&mut nonce)?;
831 let root = fs::canonicalize(std::env::temp_dir())?
832 .join(format!("lodestone-secure-publication-test-{}", lower_hex(&nonce)));
833 fs::create_dir(&root)?;
834 fs::set_permissions(&root, fs::Permissions::from_mode(0o700))?;
835 Ok(Self(root))
836 }
837
838 fn path(&self) -> &Path {
839 &self.0
840 }
841 }
842
843 impl Drop for TemporaryDirectory {
844 fn drop(&mut self) {
845 let _ = fs::remove_dir_all(&self.0);
846 }
847 }
848
849 fn private_directory(path: &Path) -> Result<(), Box<dyn Error>> {
850 fs::create_dir(path)?;
851 fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
852 Ok(())
853 }
854
855 #[tokio::test]
856 async fn stage_publish_and_resume_preserve_one_inode() -> Result<(), Box<dyn Error>> {
857 let temporary = TemporaryDirectory::create()?;
858 let stage = temporary.path().join("stage");
859 let output = temporary.path().join("output");
860 private_directory(&stage)?;
861 private_directory(&output)?;
862 let destination = output.join("material.json");
863 let publisher =
864 SecureCreateNewFilePublisher::open(stage.clone(), "material.stage".to_owned(), destination.clone()).await?;
865 let identity = publisher.stage(b"material".to_vec(), 128, None).await?;
866 assert!(identity.is_valid());
867 publisher.publish(identity.clone()).await?;
868 publisher.verify_published(identity.clone()).await?;
869 let reopened = SecureCreateNewFilePublisher::open(stage, "material.stage".to_owned(), destination).await?;
870 reopened.verify_published(identity).await?;
871 Ok(())
872 }
873
874 #[tokio::test]
875 async fn published_identity_survives_mount_device_renumbering() -> Result<(), Box<dyn Error>> {
876 let temporary = TemporaryDirectory::create()?;
877 let stage = temporary.path().join("stage");
878 let output = temporary.path().join("output");
879 private_directory(&stage)?;
880 private_directory(&output)?;
881 let destination = output.join("material.json");
882 let publisher = SecureCreateNewFilePublisher::open(stage, "material.stage".to_owned(), destination).await?;
883 let identity = publisher.stage(b"material".to_vec(), 128, None).await?;
884 publisher.publish(identity.clone()).await?;
885
886 let mut remounted = identity.clone();
887 remounted.device = remounted.device.wrapping_add(1);
888 publisher.verify_published(remounted).await?;
889
890 let mut substituted = identity;
891 substituted.inode = substituted.inode.wrapping_add(1);
892 assert_eq!(
893 publisher.verify_published(substituted).await,
894 Err(SecurePublicationError::Conflict)
895 );
896 Ok(())
897 }
898
899 #[tokio::test]
900 async fn staged_identity_survives_mount_device_renumbering() -> Result<(), Box<dyn Error>> {
901 let temporary = TemporaryDirectory::create()?;
902 let stage = temporary.path().join("stage");
903 let output = temporary.path().join("output");
904 private_directory(&stage)?;
905 private_directory(&output)?;
906 let publisher =
907 SecureCreateNewFilePublisher::open(stage, "material.stage".to_owned(), output.join("material.json"))
908 .await?;
909 let identity = publisher.stage(b"material".to_vec(), 128, None).await?;
910 let mut remounted = identity.clone();
911 remounted.device = remounted.device.wrapping_add(1);
912
913 assert_eq!(
914 publisher.stage(b"material".to_vec(), 128, Some(remounted)).await?,
915 identity
916 );
917 Ok(())
918 }
919
920 #[tokio::test]
921 async fn deterministic_publication_repairs_link_before_unlink_crash() -> Result<(), Box<dyn Error>> {
922 let temporary = TemporaryDirectory::create()?;
923 let stage = temporary.path().join("stage");
924 let output = temporary.path().join("output");
925 private_directory(&stage)?;
926 private_directory(&output)?;
927 let destination = output.join("material.json");
928 let publisher =
929 SecureCreateNewFilePublisher::open(stage.clone(), "material.stage".to_owned(), destination.clone()).await?;
930 let staged = publisher.stage(b"deterministic".to_vec(), 128, None).await?;
931 fs::hard_link(stage.join("material.stage"), &destination)?;
932
933 let published = publisher.publish_deterministic(b"deterministic".to_vec(), 128).await?;
934 assert_eq!(published.sha256, staged.sha256);
935 assert!(!stage.join("material.stage").exists());
936 assert_eq!(fs::metadata(&destination)?.nlink(), 1);
937 assert_eq!(
938 publisher.publish_deterministic(b"deterministic".to_vec(), 128).await?,
939 published
940 );
941 assert_eq!(
942 publisher.publish_deterministic(b"different".to_vec(), 128).await,
943 Err(SecurePublicationError::Conflict)
944 );
945 Ok(())
946 }
947
948 #[tokio::test]
949 async fn substitution_symlink_and_existing_destination_refuse() -> Result<(), Box<dyn Error>> {
950 let temporary = TemporaryDirectory::create()?;
951 let stage = temporary.path().join("stage");
952 let output = temporary.path().join("output");
953 private_directory(&stage)?;
954 private_directory(&output)?;
955 let destination = output.join("material.json");
956 let publisher =
957 SecureCreateNewFilePublisher::open(stage.clone(), "material.stage".to_owned(), destination.clone()).await?;
958 let identity = publisher.stage(b"material".to_vec(), 128, None).await?;
959 assert_eq!(
960 publisher.stage(b"different".to_vec(), 128, Some(identity)).await,
961 Err(SecurePublicationError::Conflict)
962 );
963
964 fs::remove_file(stage.join("material.stage"))?;
965 symlink("missing", stage.join("material.stage"))?;
966 assert_eq!(
967 publisher.read_stage(128).await,
968 Err(SecurePublicationError::InsecurePath)
969 );
970
971 fs::remove_file(stage.join("material.stage"))?;
972 fs::write(&destination, b"occupied")?;
973 fs::set_permissions(&destination, fs::Permissions::from_mode(0o600))?;
974 assert_eq!(
975 publisher.stage(b"material".to_vec(), 128, None).await,
976 Err(SecurePublicationError::DestinationExists)
977 );
978 Ok(())
979 }
980
981 #[tokio::test]
982 async fn broad_permissions_and_extra_hard_links_refuse() -> Result<(), Box<dyn Error>> {
983 let temporary = TemporaryDirectory::create()?;
984 let stage = temporary.path().join("stage");
985 let output = temporary.path().join("output");
986 private_directory(&stage)?;
987 private_directory(&output)?;
988 let destination = output.join("material.json");
989 let publisher =
990 SecureCreateNewFilePublisher::open(stage.clone(), "material.stage".to_owned(), destination).await?;
991 let identity = publisher.stage(b"material".to_vec(), 128, None).await?;
992 fs::hard_link(stage.join("material.stage"), stage.join("unexpected-link"))?;
993 assert_eq!(
994 publisher.stage(b"material".to_vec(), 128, Some(identity)).await,
995 Err(SecurePublicationError::InsecurePath)
996 );
997 fs::remove_file(stage.join("unexpected-link"))?;
998 fs::set_permissions(stage.join("material.stage"), fs::Permissions::from_mode(0o644))?;
999 assert_eq!(
1000 publisher.read_stage(128).await,
1001 Err(SecurePublicationError::InsecurePath)
1002 );
1003 Ok(())
1004 }
1005
1006 #[tokio::test]
1007 async fn private_tree_creates_exact_nofollow_scaffold_and_reopens() -> Result<(), Box<dyn Error>> {
1008 let temporary = TemporaryDirectory::create()?;
1009 let root = temporary.path().join("instance");
1010 let (tree, created) = SecurePrivateDirectoryTree::open_or_create_root(root.clone()).await?;
1011 assert!(created);
1012 tree.ensure_directory(PathBuf::from("operator/workflows")).await?;
1013 assert_eq!(
1014 fs::metadata(root.join("operator/workflows"))?.permissions().mode() & 0o777,
1015 0o700
1016 );
1017
1018 let (_, created) = SecurePrivateDirectoryTree::open_or_create_root(root.clone()).await?;
1019 assert!(!created);
1020 fs::set_permissions(&root, fs::Permissions::from_mode(0o755))?;
1021 assert!(matches!(
1022 SecurePrivateDirectoryTree::open_or_create_root(root).await,
1023 Err(SecurePublicationError::InsecurePath)
1024 ));
1025 Ok(())
1026 }
1027
1028 fn lower_hex(bytes: &[u8]) -> String {
1029 const HEX: &[u8; 16] = b"0123456789abcdef";
1030 let mut output = String::with_capacity(bytes.len().saturating_mul(2));
1031 for byte in bytes {
1032 output.push(char::from(HEX[usize::from(byte >> 4)]));
1033 output.push(char::from(HEX[usize::from(byte & 0x0f)]));
1034 }
1035 output
1036 }
1037}