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