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///
11/// `device` is retained as an observation for format compatibility. It is not
12/// part of durable equality because operating-system and container-runtime
13/// remounts may renumber the same filesystem. Verification still binds the
14/// fixed no-follow path, owner, mode, link count, inode, size, and digest.
15#[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
31/// One fixed staging file and one fixed create-new destination.
32pub struct SecureCreateNewFilePublisher {
33    #[cfg(any(target_os = "linux", target_os = "macos"))]
34    files: std::sync::Arc<unix::PublisherFiles>,
35}
36
37/// Open handle to one current-user-owned mode-0700 directory tree.
38///
39/// The root and every created component are opened without following links.
40/// This owns directory scaffolding only; artifact bytes still flow through
41/// [`SecureCreateNewFilePublisher`].
42pub struct SecurePrivateDirectoryTree {
43    #[cfg(any(target_os = "linux", target_os = "macos"))]
44    root: std::sync::Arc<std::fs::File>,
45}
46
47impl SecurePrivateDirectoryTree {
48    /// Opens an existing private root or creates exactly that final component.
49    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    /// Creates or verifies one bounded relative directory chain below the root.
75    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    /// Stages secret bytes and clears the caller-owned buffer before the blocking task returns.
135    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    /// Reopens one fixed staged file for a typed owner that will clear secret bytes after decoding.
183    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    /// Reopens the fixed published destination for a typed owner.
200    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    /// Removes only the exact unpublished staging inode.
217    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    /// Publishes fully deterministic, non-sensitive bytes and repairs the
253    /// narrow crash window after the destination link exists but before the
254    /// staging link is removed.
255    ///
256    /// Secret or randomly generated material must instead record the identity
257    /// returned by [`Self::stage_sensitive`] in its sealed workflow journal
258    /// before calling [`Self::publish`].
259    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    /// Validates whether the fixed destination is absent or has the exact recorded identity.
300    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(&current)?;
463        for component in relative.components() {
464            let Component::Normal(name) = component else {
465                return Err(SecurePublicationError::InvalidPath);
466            };
467            match mkdirat(&current, name, Mode::from_raw_mode(DIRECTORY_MODE)) {
468                Ok(()) => fsync(&current).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(&current, 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(&current, 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        // INVARIANT: st_dev is mount-incarnation-local and cannot be a durable
761        // workflow fence. The opened fixed path and all stable file identity
762        // fields still have to match exactly.
763        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}