Skip to main content

lodestone_client/
secure_publication.rs

1//! Secure create-new publication for bounded local workflow artifacts.
2
3use std::error::Error;
4use std::fmt::{self, Display, Formatter};
5use std::path::{Component, Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8
9/// Stable identity recorded by a workflow before publication.
10#[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
26/// One fixed staging file and one fixed create-new destination.
27pub struct SecureCreateNewFilePublisher {
28    #[cfg(any(target_os = "linux", target_os = "macos"))]
29    files: std::sync::Arc<unix::PublisherFiles>,
30}
31
32/// Open handle to one current-user-owned mode-0700 directory tree.
33///
34/// The root and every created component are opened without following links.
35/// This owns directory scaffolding only; artifact bytes still flow through
36/// [`SecureCreateNewFilePublisher`].
37pub struct SecurePrivateDirectoryTree {
38    #[cfg(any(target_os = "linux", target_os = "macos"))]
39    root: std::sync::Arc<std::fs::File>,
40}
41
42impl SecurePrivateDirectoryTree {
43    /// Opens an existing private root or creates exactly that final component.
44    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    /// Creates or verifies one bounded relative directory chain below the root.
70    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    /// Stages secret bytes and clears the caller-owned buffer before the blocking task returns.
130    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    /// Reopens one fixed staged file for a typed owner that will clear secret bytes after decoding.
178    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    /// Reopens the fixed published destination for a typed owner.
195    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    /// Removes only the exact unpublished staging inode.
212    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    /// Publishes fully deterministic, non-sensitive bytes and repairs the
248    /// narrow crash window after the destination link exists but before the
249    /// staging link is removed.
250    ///
251    /// Secret or randomly generated material must instead record the identity
252    /// returned by [`Self::stage_sensitive`] in its sealed workflow journal
253    /// before calling [`Self::publish`].
254    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    /// Validates whether the fixed destination is absent or has the exact recorded identity.
295    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(&current)?;
458        for component in relative.components() {
459            let Component::Normal(name) = component else {
460                return Err(SecurePublicationError::InvalidPath);
461            };
462            match mkdirat(&current, name, Mode::from_raw_mode(DIRECTORY_MODE)) {
463                Ok(()) => fsync(&current).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(&current, 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(&current, 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}