Skip to main content

projectatlas/
project_state.rs

1//! Admission and publication of automatic project-owned initialization files.
2
3use same_file::Handle;
4use std::fs::{self, Metadata};
5use std::io::{self, Write};
6use std::path::{Component, Path, PathBuf};
7
8#[cfg(target_os = "linux")]
9#[path = "project_state_linux.rs"]
10mod linux_access;
11
12#[cfg(target_os = "macos")]
13#[path = "project_state_macos.rs"]
14mod macos_access;
15
16#[cfg(target_os = "macos")]
17#[path = "project_state_macos_acl.rs"]
18mod macos_acl;
19
20#[cfg(windows)]
21#[path = "project_state_windows_security.rs"]
22mod windows_security;
23
24#[cfg(windows)]
25#[path = "project_state_windows.rs"]
26mod windows_filesystem;
27
28/// Product-owned directory, independent of explicitly selected external storage.
29const STATE_DIRECTORY: &str = ".projectatlas";
30/// Automatic SQLite filename whose aliases must be checked before opening SQLite.
31const DATABASE_FILE: &str = "projectatlas.db";
32/// Default config whose creation remains an automatic project output.
33const CONFIG_FILE: &str = "config.toml";
34/// Legacy root configuration selected by the existing discovery fallback.
35const LEGACY_CONFIG_FILE: &str = "projectatlas.toml";
36/// Optional sidecar next to the default legacy map import location.
37const JSON_MAP_FILE: &str = "projectatlas.json";
38/// Git's fixed root-level exclusion document.
39const GITIGNORE_FILE: &str = ".gitignore";
40/// Default nonsource metadata created by every ordinary initializer.
41const NONSOURCE_FILE: &str = "projectatlas-nonsource-files.toon";
42/// Automatically generated host outputs, all preflighted before identity changes.
43const HOST_FILES: [&str; 3] = [
44    "projectatlas.mcp.json",
45    "projectatlas.claude.mcp.json",
46    "projectatlas.opencode.json",
47];
48
49/// The two sidecars opened by the supported SQLite WAL profile.
50const WAL_SIDECAR_SUFFIXES: [&str; 3] = ["", "-wal", "-shm"];
51
52/// Whether publication creates defaults or replaces an owned generated document.
53#[derive(Clone, Copy)]
54enum FilePublication {
55    /// Preserve a default another initializer has already published.
56    Create,
57    /// Replace the directory entry without modifying a hard-linked target.
58    Replace,
59}
60
61/// Preserve the operation and failed cleanup precondition without an unsafe unlink.
62#[derive(Debug, thiserror::Error)]
63#[error("{operation}; generated-file cleanup was withheld because {cleanup}")]
64struct RetainedStaging {
65    /// Original write or publication failure.
66    #[source]
67    operation: io::Error,
68    /// Ownership or native metadata prevented safe cleanup of the old temporary name.
69    cleanup: io::Error,
70}
71
72/// Keep a failed publication step and its native I/O cause together.
73#[derive(Debug, thiserror::Error)]
74#[error("{action}: {source}")]
75struct PublicationFailure {
76    /// Human-readable operation whose native cause remains available below.
77    action: &'static str,
78    /// Preserve the original native error and its code in the causal chain.
79    #[source]
80    source: io::Error,
81}
82
83/// Add causal operation context without changing the I/O failure category.
84fn publication_failure(action: &'static str, source: io::Error) -> io::Error {
85    io::Error::new(source.kind(), PublicationFailure { action, source })
86}
87
88/// Keep database admission independent of optional generated host documents.
89#[derive(Clone, Copy)]
90enum StateAdmission {
91    /// Inspect only the managed directory and SQLite database/sidecars.
92    Database,
93    /// Inventory/unlink owned database entries without opening SQLite.
94    Reset,
95    /// Also admit bootstrap and generated-host destinations.
96    Generated,
97    /// Mutate only one exact managed document, independently of other outputs.
98    Document(ProjectDocument),
99}
100
101/// Fixed project destinations whose implicit writers must not follow aliases.
102#[derive(Clone, Copy)]
103enum ProjectDocument {
104    /// Configuration in the direct private state directory.
105    Config,
106    /// Legacy root-level configuration.
107    LegacyConfig,
108    /// Explicitly requested default JSON map sidecar.
109    JsonMap,
110    /// Create Git exclusions only when no directory entry already exists.
111    Gitignore,
112}
113
114impl ProjectDocument {
115    /// Return the fixed filename owned by this document kind.
116    const fn filename(self) -> &'static str {
117        match self {
118            Self::Config => CONFIG_FILE,
119            Self::LegacyConfig => LEGACY_CONFIG_FILE,
120            Self::JsonMap => JSON_MAP_FILE,
121            Self::Gitignore => GITIGNORE_FILE,
122        }
123    }
124
125    /// Select the direct directory without following the private namespace.
126    fn directory(self, root: &Path) -> PathBuf {
127        match self {
128            Self::Config | Self::JsonMap => root.join(STATE_DIRECTORY),
129            Self::LegacyConfig | Self::Gitignore => root.to_path_buf(),
130        }
131    }
132}
133
134/// Retain the admitted root and existing state-directory objects during writes.
135///
136/// These checks are repeated at mutation boundaries. They do not lock arbitrary
137/// ancestors against a hostile concurrent filesystem actor.
138pub(crate) struct ProjectStateGuard {
139    /// Canonical selected source root; source-root aliases are resolved by callers.
140    root: PathBuf,
141    /// Retained root object, preventing reuse of its old filesystem identity.
142    root_identity: Handle,
143    /// Admitted publication directory: the root or its direct private directory.
144    directory: PathBuf,
145    /// Existing state-directory object, when one was present at admission.
146    directory_identity: Option<Handle>,
147    /// Selected automatic database; external operator paths remain independent.
148    automatic_database: Option<PathBuf>,
149    /// Exact automatic document; explicit custom/external paths are not inferred.
150    automatic_document: Option<PathBuf>,
151    /// The concrete operation boundary, never a process-global routing setting.
152    admission: StateAdmission,
153}
154
155impl ProjectStateGuard {
156    /// Inspect automatic destinations without creating files or SQLite sidecars.
157    pub(crate) fn preflight(
158        root: &Path,
159        database: Option<&Path>,
160        config: Option<&Path>,
161    ) -> io::Result<Self> {
162        Self::admit(root, database, config, StateAdmission::Generated)
163    }
164
165    /// Admit an exact managed database before even reading its stored root.
166    ///
167    /// Arbitrary external DB names/locations remain operator-selected, including
168    /// conventional-looking names under a different root. Resolve only the known
169    /// selected root; resolving `.projectatlas` here would trust its alias.
170    pub(crate) fn preflight_database_path(
171        database: &Path,
172        selected_root: &Path,
173    ) -> io::Result<Option<Self>> {
174        let root = fs::canonicalize(selected_root)?;
175        if !owned_named_path(database, &root.join(STATE_DIRECTORY), DATABASE_FILE)? {
176            return Ok(None);
177        }
178        Self::admit(&root, Some(database), None, StateAdmission::Database).map(Some)
179    }
180
181    /// Admit explicit reset without reading SQLite or refusing recoverable cohorts.
182    pub(crate) fn preflight_database_reset_path(
183        database: &Path,
184        selected_root: &Path,
185    ) -> io::Result<Option<Self>> {
186        let root = fs::canonicalize(selected_root)?;
187        if !owned_named_path(database, &root.join(STATE_DIRECTORY), DATABASE_FILE)? {
188            return Ok(None);
189        }
190        Self::admit(&root, Some(database), None, StateAdmission::Reset).map(Some)
191    }
192
193    /// Admit one fixed document while preserving custom/external operator paths.
194    pub(crate) fn preflight_document(root: &Path, path: &Path) -> io::Result<Option<Self>> {
195        let root = fs::canonicalize(root)?;
196        for document in [
197            ProjectDocument::Config,
198            ProjectDocument::LegacyConfig,
199            ProjectDocument::JsonMap,
200            ProjectDocument::Gitignore,
201        ] {
202            if !fixed_name_candidate(path, document.filename())? {
203                continue;
204            }
205            let directory = document.directory(&root);
206            let owned = owned_named_path(path, &directory, document.filename())?;
207            if owned {
208                let target = directory.join(document.filename());
209                return Self::admit(
210                    &root,
211                    None,
212                    Some(&target),
213                    StateAdmission::Document(document),
214                )
215                .map(Some);
216            }
217        }
218        Ok(None)
219    }
220
221    /// Capture the exact filesystem owner for one operation role.
222    fn admit(
223        root: &Path,
224        database: Option<&Path>,
225        config: Option<&Path>,
226        admission: StateAdmission,
227    ) -> io::Result<Self> {
228        let canonical = fs::canonicalize(root)?;
229        if canonical != root || indirect(&fs::symlink_metadata(root)?) {
230            return Err(refusal(root, "selected canonical root changed"));
231        }
232        let root_identity = Handle::from_path(root)?;
233        let directory = match admission {
234            StateAdmission::Document(document) => document.directory(root),
235            StateAdmission::Database | StateAdmission::Generated | StateAdmission::Reset => {
236                root.join(STATE_DIRECTORY)
237            }
238        };
239        let directory_identity = match optional_metadata(&directory)? {
240            Some(metadata) => {
241                require_directory(&directory, &metadata)?;
242                Some(Handle::from_path(&directory)?)
243            }
244            None => None,
245        };
246        let automatic_database = match database {
247            Some(path) if owned_named_path(path, &directory, DATABASE_FILE)? => {
248                #[cfg(windows)]
249                {
250                    Some(directory.join(DATABASE_FILE))
251                }
252                #[cfg(not(windows))]
253                Some(path.to_path_buf())
254            }
255            _ => None,
256        };
257        let automatic_document = match admission {
258            StateAdmission::Reset => None,
259            StateAdmission::Document(_) => config.map(Path::to_path_buf),
260            StateAdmission::Database | StateAdmission::Generated => match config {
261                Some(path)
262                    if owned_named_path(path, &directory, CONFIG_FILE)?
263                        || owned_named_path(path, root, LEGACY_CONFIG_FILE)? =>
264                {
265                    Some(path.to_path_buf())
266                }
267                _ => None,
268            },
269        };
270        let guard = Self {
271            root: root.to_path_buf(),
272            root_identity,
273            directory,
274            directory_identity,
275            automatic_database,
276            automatic_document,
277            admission,
278        };
279        guard.verify()?;
280        Ok(guard)
281    }
282
283    /// Return the admitted SQLite spelling; Windows managed aliases share one cohort.
284    pub(crate) fn sqlite_path(&self) -> Option<&Path> {
285        self.automatic_database.as_deref()
286    }
287
288    /// Enumerate only proven alternate recovery entries for an explicit reset.
289    pub(crate) fn alternate_reset_paths(&self) -> io::Result<Vec<PathBuf>> {
290        self.verify_directories()?;
291        #[cfg(windows)]
292        if let Some(database) = &self.automatic_database
293            && let Some(short) = windows_filesystem::alternate_name(database)?
294        {
295            let alternate = self.directory.join(short);
296            return Ok(["-wal", "-shm", "-journal"]
297                .into_iter()
298                .map(|suffix| {
299                    let mut name = alternate.as_os_str().to_os_string();
300                    name.push(suffix);
301                    PathBuf::from(name)
302                })
303                .collect());
304        }
305        Ok(Vec::new())
306    }
307
308    /// Recheck the admitted objects and every automatic host/database output.
309    pub(crate) fn verify(&self) -> io::Result<()> {
310        self.verify_directories()?;
311        // Reset only unlinks/moves selected entries. Preserve recovery of corrupt
312        // databases and leaf aliases without following them through SQLite.
313        if matches!(self.admission, StateAdmission::Reset) {
314            return Ok(());
315        }
316        if matches!(self.admission, StateAdmission::Generated) {
317            for name in HOST_FILES {
318                require_regular_or_absent(&self.directory.join(name))?;
319            }
320            let nonsource = self.directory.join(NONSOURCE_FILE);
321            if !nonsource.exists() {
322                require_regular_or_absent(&nonsource)?;
323            }
324            if let Some(config) = &self.automatic_document
325                && !config.exists()
326            {
327                require_regular_or_absent(config)?;
328            }
329        }
330        if let StateAdmission::Document(document) = self.admission
331            && !matches!(document, ProjectDocument::Gitignore)
332            && let Some(path) = &self.automatic_document
333        {
334            require_regular_or_absent(path)?;
335        }
336        if let Some(database) = &self.automatic_database {
337            for suffix in WAL_SIDECAR_SUFFIXES {
338                let mut name = database.as_os_str().to_os_string();
339                name.push(suffix);
340                let path = PathBuf::from(name);
341                if let Some(metadata) = optional_metadata(&path)? {
342                    require_regular(&path, &metadata)?;
343                    #[cfg(unix)]
344                    let links = link_count(&path, &metadata);
345                    #[cfg(not(unix))]
346                    let links = link_count(&path, &metadata)?;
347                    match links {
348                        1 => {}
349                        0 => {
350                            return Err(refusal(
351                                &path,
352                                "automatic database link identity is unavailable",
353                            ));
354                        }
355                        _ => {
356                            return Err(refusal(
357                                &path,
358                                "automatic database or WAL sidecar has multiple hard links",
359                            ));
360                        }
361                    }
362                }
363            }
364            #[cfg(windows)]
365            if let Some(short) = windows_filesystem::alternate_name(database)? {
366                let alternate = self.directory.join(short);
367                for suffix in ["-wal", "-shm", "-journal"] {
368                    let mut name = alternate.as_os_str().to_os_string();
369                    name.push(suffix);
370                    let path = PathBuf::from(name);
371                    if optional_metadata(&path)?.is_some() {
372                        return Err(refusal(
373                            &path,
374                            "alternate 8.3 SQLite recovery cohort exists; close its users and use external SQLite recovery/checkpoint tooling with that original short spelling before reopening through ProjectAtlas; no cohort is retargeted or removed",
375                        ));
376                    }
377                }
378            }
379        }
380        Ok(())
381    }
382
383    /// Check only retained directory objects, including before staging cleanup.
384    fn verify_directories(&self) -> io::Result<()> {
385        require_directory(&self.root, &fs::symlink_metadata(&self.root)?)?;
386        if fs::canonicalize(&self.root)? != self.root
387            || Handle::from_path(&self.root)? != self.root_identity
388        {
389            return Err(refusal(&self.root, "selected root object changed"));
390        }
391        match optional_metadata(&self.directory)? {
392            Some(metadata) => {
393                require_directory(&self.directory, &metadata)?;
394                if let Some(identity) = &self.directory_identity
395                    && Handle::from_path(&self.directory)? != *identity
396                {
397                    return Err(refusal(&self.directory, "state directory object changed"));
398                }
399            }
400            None if self.directory_identity.is_some() => {
401                return Err(refusal(&self.directory, "state directory disappeared"));
402            }
403            None => {}
404        }
405        Ok(())
406    }
407
408    /// Create only the admitted direct state directory and retain its object.
409    pub(crate) fn ensure_directory(&mut self) -> io::Result<()> {
410        if matches!(
411            self.admission,
412            StateAdmission::Database | StateAdmission::Reset
413        ) {
414            return Err(refusal(
415                &self.directory,
416                "database admission cannot create generated state",
417            ));
418        }
419        self.verify()?;
420        if self.directory_identity.is_none() {
421            match fs::create_dir(&self.directory) {
422                Ok(()) => {}
423                Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
424                Err(source) => return Err(source),
425            }
426            require_directory(&self.directory, &fs::symlink_metadata(&self.directory)?)?;
427            self.directory_identity = Some(Handle::from_path(&self.directory)?);
428        }
429        self.verify()
430    }
431
432    /// Publish a generated entry without writing through an existing file alias.
433    pub(crate) fn write_generated(&self, path: &Path, bytes: &[u8]) -> io::Result<()> {
434        self.publish(path, bytes, FilePublication::Replace, || Ok(()))
435            .map(|_| ())
436    }
437
438    /// Publish defaults without exposing partial bytes or replacing existing data.
439    pub(crate) fn write_new(&self, path: &Path, bytes: &[u8]) -> io::Result<()> {
440        self.publish(path, bytes, FilePublication::Create, || Ok(()))
441            .map(|_| ())
442    }
443
444    /// Replace only the exact admitted document, preserving existing permissions.
445    pub(crate) fn write_document(&mut self, bytes: &[u8]) -> io::Result<()> {
446        self.ensure_directory()?;
447        let path = self.document_path()?;
448        self.publish(path, bytes, FilePublication::Replace, || Ok(()))
449            .map(|_| ())
450    }
451
452    /// Atomically create an admitted document, reporting a racing existing entry.
453    pub(crate) fn create_document(&mut self, bytes: &[u8]) -> io::Result<bool> {
454        self.ensure_directory()?;
455        self.publish(
456            self.document_path()?,
457            bytes,
458            FilePublication::Create,
459            || Ok(()),
460        )
461    }
462
463    /// Keep document publication bound to its constructor-selected target.
464    fn document_path(&self) -> io::Result<&Path> {
465        if !matches!(self.admission, StateAdmission::Document(_)) {
466            return Err(refusal(&self.root, "document admission is required"));
467        }
468        self.automatic_document
469            .as_deref()
470            .ok_or_else(|| refusal(&self.root, "document target is unavailable"))
471    }
472
473    /// Compare actual parent owners while preserving selected path spelling.
474    pub(crate) fn owns_entry(&self, path: &Path) -> io::Result<bool> {
475        let parent = path
476            .parent()
477            .filter(|parent| !parent.as_os_str().is_empty())
478            .unwrap_or_else(|| Path::new("."));
479        Ok(fs::canonicalize(parent)? == fs::canonicalize(&self.directory)?)
480    }
481
482    /// Share atomic publication and a scoped failure boundary across file roles.
483    fn publish(
484        &self,
485        path: &Path,
486        bytes: &[u8],
487        publication: FilePublication,
488        before_publication: impl FnOnce() -> io::Result<()>,
489    ) -> io::Result<bool> {
490        self.publish_with_stage_check(path, bytes, publication, |_| Ok(()), before_publication)
491    }
492
493    /// Observe the real protected stage through a local causal-test seam.
494    fn publish_with_stage_check(
495        &self,
496        path: &Path,
497        bytes: &[u8],
498        publication: FilePublication,
499        stage_check: impl FnOnce(&tempfile::NamedTempFile) -> io::Result<()>,
500        before_publication: impl FnOnce() -> io::Result<()>,
501    ) -> io::Result<bool> {
502        if matches!(self.admission, StateAdmission::Database) {
503            return Err(refusal(
504                path,
505                "database admission cannot publish generated files",
506            ));
507        }
508        if matches!(self.admission, StateAdmission::Document(_)) && path != self.document_path()? {
509            return Err(refusal(
510                path,
511                "document target differs from its admitted entry",
512            ));
513        }
514        if !self.owns_entry(path)? {
515            return Err(refusal(path, "generated output is outside its state owner"));
516        }
517        self.verify()?;
518        let gitignore_creation = matches!(
519            self.admission,
520            StateAdmission::Document(ProjectDocument::Gitignore)
521        ) && matches!(publication, FilePublication::Create);
522        if gitignore_creation && optional_metadata(path)?.is_some() {
523            return Ok(false);
524        }
525        let metadata = if gitignore_creation {
526            None
527        } else {
528            optional_metadata(path)?
529        };
530        let metadata = metadata
531            .map(|metadata| {
532                require_regular(path, &metadata)?;
533                Ok::<_, io::Error>(metadata)
534            })
535            .transpose()?;
536        // A no-clobber collision never replaces the existing policy. Validate
537        // its direct regular entry above without requiring copy/crypto rights.
538        if matches!(publication, FilePublication::Create) && metadata.is_some() {
539            return Ok(false);
540        }
541        #[cfg(target_os = "linux")]
542        let access = metadata
543            .as_ref()
544            .map(|metadata| linux_access::ReplacementPolicy::capture(path, metadata))
545            .transpose()?;
546        #[cfg(any(target_os = "macos", windows))]
547        let access = metadata
548            .as_ref()
549            .map(|metadata| ReplacementSource::capture(path, metadata))
550            .transpose()
551            .map_err(|source| publication_failure("capture destination access policy", source))?;
552        #[cfg(target_os = "macos")]
553        let private = macos_access::ReplacementDirectory::create(self, metadata.is_some())?;
554        #[cfg(target_os = "macos")]
555        let staging_directory = private
556            .as_ref()
557            .map_or(self.directory.as_path(), |private| private.path());
558        #[cfg(not(target_os = "macos"))]
559        let staging_directory = self.directory.as_path();
560        let result = (|| {
561            let staging_identity = Handle::from_path(staging_directory)?;
562            #[cfg(windows)]
563            let creation = access
564                .as_ref()
565                .map(|access| {
566                    windows_security::ReplacementCreation::capture(
567                        &access.file,
568                        staging_directory,
569                        &staging_identity,
570                        &access.security,
571                    )
572                })
573                .transpose()?;
574            #[cfg(target_os = "macos")]
575            if let Some(private) = &private {
576                private.verify()?;
577            }
578            #[cfg(unix)]
579            let mut builder = tempfile::Builder::new();
580            #[cfg(not(unix))]
581            let builder = tempfile::Builder::new();
582            #[cfg(unix)]
583            if metadata.is_none() {
584                use std::os::unix::fs::PermissionsExt;
585                builder.permissions(fs::Permissions::from_mode(0o666));
586            }
587            #[cfg(target_os = "linux")]
588            if let Some(access) = &access {
589                builder.permissions(access.permissions());
590            }
591            #[cfg(not(windows))]
592            let mut temporary = builder.tempfile_in(staging_directory)?;
593            #[cfg(windows)]
594            let mut temporary = builder.make_in(staging_directory, |path| {
595                create_windows_staging_file(path, creation.as_ref())
596            })?;
597            #[cfg(target_os = "linux")]
598            let content_prepared = if let Some(access) = &access {
599                let equivalent = match self
600                    .verify_staging_file(&temporary, &staging_identity)
601                    .and_then(|()| access.matches(temporary.as_file()))
602                {
603                    Ok(equivalent) => equivalent,
604                    Err(source) => {
605                        return Err(self.staging_failure(
606                            &mut temporary,
607                            source,
608                            &staging_identity,
609                        ));
610                    }
611                };
612                if equivalent {
613                    false
614                } else {
615                    if let Err(source) = self.verify_staging_file(&temporary, &staging_identity) {
616                        return Err(self.staging_failure(
617                            &mut temporary,
618                            source,
619                            &staging_identity,
620                        ));
621                    }
622                    // A mismatched named probe never receives generated bytes.
623                    self.discard_staging_probe(temporary, &staging_identity)?;
624                    temporary = access.anonymous_stage(&builder, self, bytes)?;
625                    true
626                }
627            } else {
628                false
629            };
630            #[cfg(not(target_os = "linux"))]
631            let content_prepared = false;
632            let prepared = (|| {
633                self.verify_staging_file(&temporary, &staging_identity)?;
634                #[cfg(windows)]
635                if let Some(creation) = &creation {
636                    creation.verify_initial(temporary.as_file())?;
637                }
638                stage_check(&temporary)?;
639                self.verify_staging_file(&temporary, &staging_identity)?;
640                #[cfg(any(target_os = "macos", windows))]
641                if let Some(access) = &access {
642                    access.verify_destination(path).map_err(|source| {
643                        publication_failure("recheck destination before policy copy", source)
644                    })?;
645                    #[cfg(target_os = "macos")]
646                    let prepared = access.apply(path, &temporary);
647                    #[cfg(windows)]
648                    let prepared = access.apply(
649                        path,
650                        &temporary,
651                        creation.as_ref().ok_or_else(|| {
652                            io::Error::other("replacement creation policy is absent")
653                        })?,
654                    );
655                    prepared.map_err(|source| {
656                        publication_failure("prepare staged native access policy", source)
657                    })?;
658                }
659                #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
660                if let Some(metadata) = &metadata {
661                    preserve_replacement_access(path, metadata, &temporary)?;
662                }
663                self.verify_staging_file(&temporary, &staging_identity)?;
664                if !content_prepared {
665                    temporary
666                        .write_all(bytes)
667                        .map_err(|source| publication_failure("write staged payload", source))?;
668                    temporary
669                        .as_file()
670                        .sync_all()
671                        .map_err(|source| publication_failure("sync staged payload", source))?;
672                }
673                before_publication()?;
674                self.verify().map_err(|source| {
675                    publication_failure("recheck project state before publication", source)
676                })?;
677                self.verify_staging_file(&temporary, &staging_identity)
678                    .map_err(|source| {
679                        publication_failure("recheck staged file before publication", source)
680                    })?;
681                #[cfg(windows)]
682                if let Some(access) = &access {
683                    access.security.system.verify(temporary.as_file())?;
684                }
685                #[cfg(target_os = "linux")]
686                if let Some(access) = &access
687                    && !access.matches(temporary.as_file())?
688                {
689                    return Err(refusal(
690                        path,
691                        "staged native access policy changed before publication",
692                    ));
693                }
694                if matches!(publication, FilePublication::Replace) {
695                    #[cfg(any(target_os = "linux", target_os = "macos", windows))]
696                    if let Some(access) = &access {
697                        access.verify_destination(path).map_err(|source| {
698                            publication_failure("recheck destination before replacement", source)
699                        })?;
700                    }
701                    if metadata.is_none() && optional_metadata(path)?.is_some() {
702                        return Err(refusal(
703                            path,
704                            "absent destination appeared before publication",
705                        ));
706                    }
707                }
708                Ok(())
709            })();
710            if let Err(source) = prepared {
711                return Err(self.staging_failure(&mut temporary, source, &staging_identity));
712            }
713            // Windows replacement refuses a destination with our retained
714            // READ_CONTROL snapshot open, even with delete sharing. Keep every
715            // snapshot through the final identity/policy check, then release all
716            // of its file/identity handles before the Windows rename boundary.
717            #[cfg(windows)]
718            drop(access);
719            #[cfg(windows)]
720            let result: Result<fs::File, tempfile::PersistError> = match windows_filesystem::publish(
721                temporary.as_file(),
722                path,
723                matches!(publication, FilePublication::Replace),
724            ) {
725                Ok(()) => {
726                    // The old temporary name is no longer ours. Never let its
727                    // destructor remove a foreign entry substituted there.
728                    temporary.disable_cleanup(true);
729                    return Ok(true);
730                }
731                Err(error) => Err(tempfile::PersistError {
732                    error,
733                    file: temporary,
734                }),
735            };
736            #[cfg(not(windows))]
737            let result = match publication {
738                FilePublication::Create => temporary.persist_noclobber(path),
739                FilePublication::Replace => temporary.persist(path),
740            };
741            match result {
742                Ok(_) => Ok(true),
743                Err(mut failure) => {
744                    if let Err(source) = self.verify_staging_file(&failure.file, &staging_identity)
745                    {
746                        return Err(self.staging_failure(
747                            &mut failure.file,
748                            source,
749                            &staging_identity,
750                        ));
751                    }
752                    if matches!(publication, FilePublication::Create)
753                        && failure.error.kind() == io::ErrorKind::AlreadyExists
754                    {
755                        if gitignore_creation {
756                            let existing = self
757                                .verify_directories()
758                                .and_then(|()| optional_metadata(path));
759                            match existing {
760                                Ok(Some(_)) => return Ok(false),
761                                Err(source) => {
762                                    return Err(self.staging_failure(
763                                        &mut failure.file,
764                                        source,
765                                        &staging_identity,
766                                    ));
767                                }
768                                Ok(None) => {}
769                            }
770                        }
771                        let existing = self.verify().and_then(|()| optional_metadata(path));
772                        match existing {
773                            Ok(Some(metadata)) if !indirect(&metadata) && metadata.is_file() => {
774                                return Ok(false);
775                            }
776                            Err(source) => {
777                                return Err(self.staging_failure(
778                                    &mut failure.file,
779                                    source,
780                                    &staging_identity,
781                                ));
782                            }
783                            Ok(_) => {}
784                        }
785                    }
786                    Err(self.staging_failure(
787                        &mut failure.file,
788                        publication_failure("persist generated output", failure.error),
789                        &staging_identity,
790                    ))
791                }
792            }
793        })();
794        #[cfg(target_os = "macos")]
795        if let Some(private) = private {
796            return private.finish(result);
797        }
798        result
799    }
800
801    /// Bind path-based metadata, publication and cleanup to the retained file.
802    fn verify_staging_file(
803        &self,
804        temporary: &tempfile::NamedTempFile,
805        directory_identity: &Handle,
806    ) -> io::Result<()> {
807        self.verify_staging_entry(temporary.path(), temporary.as_file(), directory_identity)
808    }
809
810    /// Retain file authority even when an explicit probe unlink fails.
811    #[cfg(target_os = "linux")]
812    fn discard_staging_probe(
813        &self,
814        mut temporary: tempfile::NamedTempFile,
815        directory_identity: &Handle,
816    ) -> io::Result<()> {
817        if let Err(source) = self.verify_staging_file(&temporary, directory_identity) {
818            return Err(self.staging_failure(&mut temporary, source, directory_identity));
819        }
820        let (file, path) = temporary.into_parts();
821        let name = path.to_path_buf();
822        if let Err(operation) = path.close() {
823            return match self.verify_staging_entry(&name, &file, directory_identity) {
824                Ok(()) => Err(operation),
825                Err(ownership) => Err(io::Error::new(
826                    operation.kind(),
827                    RetainedStaging {
828                        operation,
829                        cleanup: ownership,
830                    },
831                )),
832            };
833        }
834        Ok(())
835    }
836
837    /// Verify a direct stage name against both retained directory and file objects.
838    fn verify_staging_entry(
839        &self,
840        path: &Path,
841        file: &fs::File,
842        directory_identity: &Handle,
843    ) -> io::Result<()> {
844        self.verify_directories()?;
845        let directory = path
846            .parent()
847            .ok_or_else(|| refusal(path, "staging parent is unavailable"))?;
848        require_directory(directory, &fs::symlink_metadata(directory)?)?;
849        if Handle::from_path(directory)? != *directory_identity {
850            return Err(refusal(directory, "staging directory object changed"));
851        }
852        let named = fs::symlink_metadata(path)?;
853        require_regular(path, &named)?;
854        #[cfg(unix)]
855        let same = {
856            use std::os::unix::fs::MetadataExt;
857            let retained = file.metadata()?;
858            named.dev() == retained.dev() && named.ino() == retained.ino()
859        };
860        #[cfg(windows)]
861        let same = {
862            use std::os::windows::fs::OpenOptionsExt;
863            // Metadata access does not request competing file-data access.
864            // Keep DELETE-only sharing on the original payload handle.
865            const FILE_READ_ATTRIBUTES: u32 = 0x0000_0080;
866            let current = Handle::from_file(
867                fs::OpenOptions::new()
868                    .access_mode(FILE_READ_ATTRIBUTES)
869                    .open(path)?,
870            )?;
871            current == Handle::from_file(file.try_clone()?)?
872        };
873        #[cfg(not(any(unix, windows)))]
874        let same = Handle::from_path(path)? == Handle::from_file(file.try_clone()?)?;
875        if !same {
876            return Err(refusal(path, "temporary staging file object changed"));
877        }
878        Ok(())
879    }
880
881    /// Never unlink a temporary pathname through a changed directory or file owner.
882    fn staging_failure(
883        &self,
884        temporary: &mut tempfile::NamedTempFile,
885        operation: io::Error,
886        directory_identity: &Handle,
887    ) -> io::Error {
888        let cleanup = self.verify_staging_file(temporary, directory_identity);
889        #[cfg(windows)]
890        let cleanup = cleanup.and_then(|()| {
891            // Classic Windows deletion cannot remove a readonly file.
892            // Normalize only our held, unpublished inode so cleanup does
893            // not depend on a filesystem's optional POSIX-delete support.
894            let mut permissions = temporary.as_file().metadata()?.permissions();
895            if permissions.readonly() {
896                #[expect(
897                    clippy::permissions_set_readonly_false,
898                    reason = "Windows clears only the readonly attribute on the verified held stage"
899                )]
900                permissions.set_readonly(false);
901                temporary.as_file().set_permissions(permissions)?;
902                self.verify_staging_file(temporary, directory_identity)?;
903            }
904            windows_filesystem::discard(temporary.as_file())?;
905            temporary.disable_cleanup(true);
906            Ok(())
907        });
908        match cleanup {
909            Ok(()) => operation,
910            Err(cleanup) => {
911                // keep() itself performs path I/O on Windows. Only disable cleanup:
912                // the original private staging file survives under its old owner.
913                temporary.disable_cleanup(true);
914                io::Error::new(operation.kind(), RetainedStaging { operation, cleanup })
915            }
916        }
917    }
918}
919
920/// Retain the admitted native file and policy instead of reopening a mutable source name.
921#[cfg(any(target_os = "macos", windows))]
922struct ReplacementSource {
923    /// Keep the captured destination alive until publication completes.
924    file: fs::File,
925    /// Compare the destination name with this retained object before replacing it.
926    identity: Handle,
927    /// Original permissions and, on Unix, ownership and inode identity.
928    metadata: Metadata,
929    /// Opaque macOS extended ACL, preserving ordered entries and native flags.
930    #[cfg(target_os = "macos")]
931    acl: macos_acl::AccessAcl,
932    /// Windows discretionary policy and complete or proven-absent system policy.
933    #[cfg(windows)]
934    security: WindowsAccessSnapshot,
935}
936
937#[cfg(any(target_os = "macos", windows))]
938impl ReplacementSource {
939    /// Capture through a non-following descriptor, preserving write-only workflows.
940    fn capture(path: &Path, expected: &Metadata) -> io::Result<Self> {
941        #[cfg(target_os = "macos")]
942        let file = {
943            use rustix::fs::{CWD, Mode, OFlags, openat};
944            let flags = OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC;
945            fs::File::from(
946                match openat(CWD, path, flags | OFlags::RDONLY, Mode::empty()) {
947                    Ok(file) => file,
948                    Err(rustix::io::Errno::ACCESS) => {
949                        openat(CWD, path, flags | OFlags::WRONLY, Mode::empty())?
950                    }
951                    Err(source) => return Err(source.into()),
952                },
953            )
954        };
955        #[cfg(windows)]
956        let (file, complete_system_access) = {
957            use std::os::windows::fs::OpenOptionsExt;
958            use windows_permissions::constants::AccessRights;
959            const FILE_READ_ATTRIBUTES: u32 = 0x0000_0080;
960            const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
961            let open = |system| {
962                fs::OpenOptions::new()
963                    .access_mode(
964                        AccessRights::ReadControl.bits()
965                            | FILE_READ_ATTRIBUTES
966                            | if system {
967                                AccessRights::AccessSystemSecurity.bits()
968                            } else {
969                                0
970                            },
971                    )
972                    .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
973                    .open(path)
974            };
975            match open(true) {
976                Ok(file) => (file, true),
977                Err(source) if matches!(source.raw_os_error(), Some(5 | 1314)) => {
978                    (open(false)?, false)
979                }
980                Err(source) => return Err(source),
981            }
982        };
983        require_regular(path, &file.metadata()?)?;
984        #[cfg(windows)]
985        require_windows_unencrypted(&file, path)?;
986        let identity = Handle::from_file(file.try_clone()?)?;
987        #[cfg(target_os = "macos")]
988        let acl = macos_access::get_acl(&file)?;
989        #[cfg(windows)]
990        let security = windows_access_snapshot(&file, complete_system_access)?;
991        let source = Self {
992            file,
993            identity,
994            metadata: expected.clone(),
995            #[cfg(target_os = "macos")]
996            acl,
997            #[cfg(windows)]
998            security,
999        };
1000        source.verify_destination(path)?;
1001        Ok(source)
1002    }
1003
1004    /// Refuse a changed destination name, ownership, permissions or native policy.
1005    fn verify_destination(&self, path: &Path) -> io::Result<()> {
1006        let named = fs::symlink_metadata(path)?;
1007        require_regular(path, &named)?;
1008        #[cfg(target_os = "macos")]
1009        let matches = {
1010            use std::os::unix::fs::MetadataExt;
1011            let held = self.file.metadata()?;
1012            named.dev() == self.metadata.dev()
1013                && named.ino() == self.metadata.ino()
1014                && held.dev() == self.metadata.dev()
1015                && held.ino() == self.metadata.ino()
1016                && held.uid() == self.metadata.uid()
1017                && held.gid() == self.metadata.gid()
1018                && held.mode() == self.metadata.mode()
1019                && macos_access::get_acl(&self.file)? == self.acl
1020                && Handle::from_file(self.file.try_clone()?)? == self.identity
1021        };
1022        #[cfg(windows)]
1023        let matches = {
1024            use std::os::windows::fs::OpenOptionsExt;
1025            const FILE_READ_ATTRIBUTES: u32 = 0x0000_0080;
1026            const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
1027            require_windows_unencrypted(&self.file, path)?;
1028            let current = fs::OpenOptions::new()
1029                .access_mode(FILE_READ_ATTRIBUTES)
1030                .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
1031                .open(path)?;
1032            require_regular(path, &current.metadata()?)?;
1033            Handle::from_file(current)? == self.identity
1034                && self.file.metadata()?.permissions() == self.metadata.permissions()
1035                && windows_access_snapshot(&self.file, self.security.system.needs_system_access())?
1036                    == self.security
1037        };
1038        if !matches {
1039            return Err(refusal(path, "destination object or access policy changed"));
1040        }
1041        Ok(())
1042    }
1043
1044    /// Preserve the captured file policy before payload I/O and verify native readback.
1045    #[cfg(target_os = "macos")]
1046    fn apply(&self, path: &Path, staged: &tempfile::NamedTempFile) -> io::Result<()> {
1047        #[cfg(target_os = "macos")]
1048        {
1049            use std::os::unix::fs::MetadataExt;
1050            let current = staged.as_file().metadata()?;
1051            if current.uid() != self.metadata.uid() || current.gid() != self.metadata.gid() {
1052                std::os::unix::fs::fchown(
1053                    staged.as_file(),
1054                    Some(self.metadata.uid()),
1055                    Some(self.metadata.gid()),
1056                )?;
1057            }
1058            macos_access::set_acl(staged.as_file(), &self.acl)?;
1059        }
1060        // Ownership changes can clear mode bits. Restore mode after ownership/ACL.
1061        staged
1062            .as_file()
1063            .set_permissions(self.metadata.permissions())?;
1064        #[cfg(target_os = "macos")]
1065        if macos_access::get_acl(staged.as_file())? != self.acl {
1066            return Err(refusal(
1067                path,
1068                "staged access ACL differs from the destination",
1069            ));
1070        }
1071        Ok(())
1072    }
1073
1074    /// Apply only the captured creation policy, never a fresh mutable source policy.
1075    #[cfg(windows)]
1076    fn apply(
1077        &self,
1078        path: &Path,
1079        staged: &tempfile::NamedTempFile,
1080        creation: &windows_security::ReplacementCreation,
1081    ) -> io::Result<()> {
1082        creation.verify_parent()?;
1083        self.security.system.apply(staged.as_file())?;
1084        preserve_windows_descriptor(path, &creation.descriptor, &creation.dacl_image, staged)?;
1085        staged
1086            .as_file()
1087            .set_permissions(self.metadata.permissions())
1088    }
1089}
1090
1091/// Keep access controls on the staged object before writing or publishing bytes.
1092#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
1093fn preserve_replacement_access(
1094    path: &Path,
1095    metadata: &Metadata,
1096    staged: &tempfile::NamedTempFile,
1097) -> io::Result<()> {
1098    #[cfg(unix)]
1099    {
1100        use std::os::unix::fs::MetadataExt;
1101        let current = staged.as_file().metadata()?;
1102        if current.uid() != metadata.uid() || current.gid() != metadata.gid() {
1103            std::os::unix::fs::fchown(
1104                staged.as_file(),
1105                Some(metadata.uid()),
1106                Some(metadata.gid()),
1107            )?;
1108        }
1109    }
1110    // chown can clear mode bits, and a POSIX ACL controls the group-class mask.
1111    // Restore the original mode only after restoring ownership and ACL entries.
1112    staged.as_file().set_permissions(metadata.permissions())?;
1113    let _ = path;
1114    Ok(())
1115}
1116
1117/// Refuse EFS replacement until the complete native encryption policy is preserved.
1118#[cfg(windows)]
1119fn require_windows_unencrypted(file: &fs::File, path: &Path) -> io::Result<()> {
1120    use std::os::windows::fs::MetadataExt;
1121    use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_ENCRYPTED;
1122    if file.metadata()?.file_attributes() & FILE_ATTRIBUTE_ENCRYPTED != 0 {
1123        return Err(refusal(
1124            path,
1125            "EFS-encrypted output cannot be replaced without preserving encryption policy",
1126        ));
1127    }
1128    Ok(())
1129}
1130
1131/// Create an exclusive data handle with policy rights bound to the original stage.
1132#[cfg(windows)]
1133fn create_windows_staging_file(
1134    path: &Path,
1135    creation: Option<&windows_security::ReplacementCreation>,
1136) -> io::Result<fs::File> {
1137    use std::os::windows::fs::OpenOptionsExt;
1138    use windows_permissions::constants::AccessRights;
1139    if let Some(creation) = creation {
1140        return creation.create(path);
1141    }
1142    let rights =
1143        (AccessRights::GenericRead | AccessRights::GenericWrite | AccessRights::Delete).bits();
1144    // Deny competing data handles before native policy is copied; allow rename.
1145    fs::OpenOptions::new()
1146        // Rust validates creation flags independently of the custom access mask.
1147        .write(true)
1148        .access_mode(rights)
1149        .create_new(true)
1150        .share_mode(0x0000_0004)
1151        .open(path)
1152}
1153
1154/// Discretionary access and a complete or proven-absent system-policy snapshot.
1155#[cfg(windows)]
1156#[derive(PartialEq, Eq)]
1157struct WindowsAccessSnapshot {
1158    /// Native owner/group/DACL comparison image.
1159    discretionary: std::ffi::OsString,
1160    /// DACL presence, defaulting and inheritance controls from that same query.
1161    discretionary_control: u16,
1162    /// Opaque system policy and native controls.
1163    system: windows_security::SystemPolicy,
1164}
1165
1166/// Capture native policy without changing the caller's enabled privileges.
1167#[cfg(windows)]
1168fn windows_access_snapshot(
1169    file: &fs::File,
1170    complete_system_access: bool,
1171) -> io::Result<WindowsAccessSnapshot> {
1172    use windows_permissions::constants::{SeObjectType, SecurityInformation};
1173    use windows_permissions::wrappers::{
1174        ConvertSecurityDescriptorToStringSecurityDescriptor, GetSecurityInfo,
1175    };
1176    let information =
1177        SecurityInformation::Owner | SecurityInformation::Group | SecurityInformation::Dacl;
1178    let access = GetSecurityInfo(file, SeObjectType::SE_FILE_OBJECT, information)?;
1179    Ok(WindowsAccessSnapshot {
1180        discretionary: ConvertSecurityDescriptorToStringSecurityDescriptor(&access, information)?,
1181        discretionary_control: windows_security::discretionary_control(&access)?,
1182        system: windows_security::SystemPolicy::capture(file, complete_system_access)?,
1183    })
1184}
1185
1186/// Copy the destination DACL and inheritance policy using native file handles.
1187#[cfg(all(windows, test))]
1188fn preserve_windows_access(
1189    path: &Path,
1190    original: &fs::File,
1191    staged: &tempfile::NamedTempFile,
1192) -> io::Result<()> {
1193    use windows_permissions::constants::{SeObjectType, SecurityInformation};
1194    use windows_permissions::wrappers::{
1195        ConvertSecurityDescriptorToStringSecurityDescriptor, GetSecurityInfo,
1196    };
1197    let information =
1198        SecurityInformation::Owner | SecurityInformation::Group | SecurityInformation::Dacl;
1199    let descriptor = GetSecurityInfo(original, SeObjectType::SE_FILE_OBJECT, information)?;
1200    let dacl = ConvertSecurityDescriptorToStringSecurityDescriptor(
1201        &descriptor,
1202        SecurityInformation::Dacl,
1203    )?;
1204    preserve_windows_descriptor(path, &descriptor, &dacl, staged)
1205}
1206
1207/// Preserve the original opaque descriptor after protected initial creation.
1208#[cfg(windows)]
1209fn preserve_windows_descriptor(
1210    path: &Path,
1211    access: &windows_permissions::SecurityDescriptor,
1212    sddl: &std::ffi::OsStr,
1213    staged: &tempfile::NamedTempFile,
1214) -> io::Result<()> {
1215    use windows_permissions::constants::{SeObjectType, SecurityInformation};
1216    use windows_permissions::wrappers::{
1217        ConvertSecurityDescriptorToStringSecurityDescriptor, GetSecurityInfo, SetSecurityInfo,
1218    };
1219
1220    const DACL_PREFIX: &str = "D:";
1221    const NULL_DACL_FLAG: &str = "NO_ACCESS_CONTROL";
1222    const PROTECTED_FLAG: char = 'P';
1223    let information =
1224        SecurityInformation::Owner | SecurityInformation::Group | SecurityInformation::Dacl;
1225    let current = GetSecurityInfo(staged.as_file(), SeObjectType::SE_FILE_OBJECT, information)?;
1226    let owner_changed = access.owner() != current.owner();
1227    let group_changed = access.group() != current.group();
1228    // This safe wrapper exposes no control-bit getter. Read only the native
1229    // DACL SDDL header, never ACE text or a lossy filesystem path conversion.
1230    let current_dacl =
1231        ConvertSecurityDescriptorToStringSecurityDescriptor(&current, SecurityInformation::Dacl)?;
1232    if !owner_changed && !group_changed && windows_file_dacl_matches(sddl, &current_dacl)? {
1233        return Ok(());
1234    }
1235    let header = sddl
1236        .to_str()
1237        .ok_or_else(|| refusal(path, "native DACL header is not text"))?;
1238    let flags = if header.is_empty() {
1239        None
1240    } else {
1241        Some(
1242            header
1243                .strip_prefix(DACL_PREFIX)
1244                .ok_or_else(|| refusal(path, "native DACL header is invalid"))?
1245                .split('(')
1246                .next()
1247                .ok_or_else(|| refusal(path, "native DACL flags are unavailable"))?,
1248        )
1249    };
1250    let protected = flags.is_some_and(|flags| flags.contains(PROTECTED_FLAG));
1251    // The wrapper's accessor assumes a present ACL pointer is non-null. A
1252    // native NULL DACL is valid; keep it out of that accessor. Native systems
1253    // unable to serialize it return a causal error above before any mutation.
1254    let dacl = if flags.is_none_or(|flags| flags.contains(NULL_DACL_FLAG)) {
1255        None
1256    } else {
1257        access.dacl()
1258    };
1259    let mut information = SecurityInformation::Dacl
1260        | if protected {
1261            SecurityInformation::ProtectedDacl
1262        } else {
1263            SecurityInformation::UnprotectedDacl
1264        };
1265    if owner_changed {
1266        information |= SecurityInformation::Owner;
1267    }
1268    if group_changed {
1269        information |= SecurityInformation::Group;
1270    }
1271    // Duplicate only our already admitted stage handle. Reopening staged.path()
1272    // could mutate a foreign object substituted after the last identity check.
1273    let mut destination = staged.as_file().try_clone()?;
1274    SetSecurityInfo(
1275        &mut destination,
1276        SeObjectType::SE_FILE_OBJECT,
1277        information,
1278        access.owner(),
1279        access.group(),
1280        dacl,
1281        None,
1282    )?;
1283    let applied = GetSecurityInfo(
1284        &destination,
1285        SeObjectType::SE_FILE_OBJECT,
1286        SecurityInformation::Owner | SecurityInformation::Group | SecurityInformation::Dacl,
1287    )?;
1288    let applied_sddl =
1289        ConvertSecurityDescriptorToStringSecurityDescriptor(&applied, SecurityInformation::Dacl)?;
1290    let owner_matches = applied.owner() == access.owner();
1291    let group_matches = applied.group() == access.group();
1292    if !owner_matches || !group_matches || !windows_file_dacl_matches(sddl, &applied_sddl)? {
1293        let difference = windows_dacl_difference(sddl, &applied_sddl)?;
1294        return Err(refusal(
1295            path,
1296            &format!(
1297                "staged DACL differs from the destination: owner_matches={owner_matches}, group_matches={group_matches}, {difference}"
1298            ),
1299        ));
1300    }
1301    Ok(())
1302}
1303
1304/// Borrow the native DACL header and opaque ordered ACE suffix; never parse ACEs.
1305#[cfg(any(windows, test))]
1306fn windows_dacl_parts(value: &std::ffi::OsStr) -> io::Result<(Option<&str>, &str)> {
1307    const DACL_PREFIX: &str = "D:";
1308    let text = value.to_str().ok_or_else(|| {
1309        io::Error::new(io::ErrorKind::InvalidData, "native DACL header is not text")
1310    })?;
1311    if text.is_empty() {
1312        return Ok((None, ""));
1313    }
1314    let dacl = text.strip_prefix(DACL_PREFIX).ok_or_else(|| {
1315        io::Error::new(io::ErrorKind::InvalidData, "native DACL header is invalid")
1316    })?;
1317    let (flags, entries) = dacl.split_at(dacl.find('(').unwrap_or(dacl.len()));
1318    Ok((Some(flags), entries))
1319}
1320
1321/// Native automatic-inheritance state may normalize when applying a file DACL.
1322#[cfg(any(windows, test))]
1323const WINDOWS_DACL_AUTO_INHERITED_FLAG: &str = "AI";
1324
1325/// Preserve regular-file policy while allowing only native AI-flag normalization.
1326///
1327/// Both objects are admitted regular files, with no child ACLs to propagate. Keep
1328/// every ordered ACE (including inherited markers), protection, AR, NULL/empty
1329/// distinction and any unknown header text exact. Ownership is checked separately.
1330#[cfg(any(windows, test))]
1331fn windows_file_dacl_matches(
1332    expected: &std::ffi::OsStr,
1333    observed: &std::ffi::OsStr,
1334) -> io::Result<bool> {
1335    let (expected_flags, expected_entries) = windows_dacl_parts(expected)?;
1336    let (observed_flags, observed_entries) = windows_dacl_parts(observed)?;
1337    let file_flags = |flags: Option<&str>| {
1338        flags.map(|flags| flags.replace(WINDOWS_DACL_AUTO_INHERITED_FLAG, ""))
1339    };
1340    Ok(expected_entries == observed_entries
1341        && file_flags(expected_flags) == file_flags(observed_flags))
1342}
1343
1344/// Describe native DACL differences without exposing ACEs or principal identities.
1345#[cfg(any(windows, test))]
1346fn windows_dacl_difference(
1347    expected: &std::ffi::OsStr,
1348    observed: &std::ffi::OsStr,
1349) -> io::Result<String> {
1350    const NULL_FLAG: &str = "NO_ACCESS_CONTROL";
1351    const PROTECTED_FLAG: &str = "P";
1352    const INHERIT_REQUEST_FLAG: &str = "AR";
1353    let (expected_flags, expected_entries) = windows_dacl_parts(expected)?;
1354    let (observed_flags, observed_entries) = windows_dacl_parts(observed)?;
1355    let flag_matches = |flag| {
1356        expected_flags.is_some_and(|flags| flags.contains(flag))
1357            == observed_flags.is_some_and(|flags| flags.contains(flag))
1358    };
1359    Ok(format!(
1360        "presence_matches={}, null_matches={}, protection_matches={}, inherit_request_matches={}, auto_inherited_matches={}, ordered_aces_match={}",
1361        expected_flags.is_some() == observed_flags.is_some(),
1362        flag_matches(NULL_FLAG),
1363        flag_matches(PROTECTED_FLAG),
1364        flag_matches(INHERIT_REQUEST_FLAG),
1365        flag_matches(WINDOWS_DACL_AUTO_INHERITED_FLAG),
1366        expected_entries == observed_entries,
1367    ))
1368}
1369
1370/// Recognize a managed entry without treating unresolved ownership as external.
1371fn owned_named_path(path: &Path, directory: &Path, expected_name: &str) -> io::Result<bool> {
1372    if !fixed_name_candidate(path, expected_name)? {
1373        return Ok(false);
1374    }
1375    let parent = containing_directory(path);
1376    // Prove direct namespace spelling before following a possibly aliased private
1377    // directory. Volume lookup below supplies case behavior, never ownership.
1378    if direct_directory_spelling(parent, directory)? {
1379        return named(path, expected_name);
1380    }
1381    match fs::canonicalize(parent) {
1382        Ok(resolved) => {
1383            if direct_directory_spelling(&resolved, directory)?
1384                || resolved_logical_directory_reduction(parent, directory, &resolved)?
1385            {
1386                named(path, expected_name)
1387            } else {
1388                Ok(false)
1389            }
1390        }
1391        Err(source) => {
1392            // mkdir_all could make a missing/.. spelling resolve to the managed
1393            // parent. Refuse before that mutation, preserving the original error.
1394            let potential = resolve_existing_parent_prefix(parent)?;
1395            if (direct_directory_spelling(&potential, directory)?
1396                || resolved_logical_directory_reduction(parent, directory, &potential)?)
1397                && named(path, expected_name)?
1398            {
1399                Err(source)
1400            } else {
1401                Ok(false)
1402            }
1403        }
1404    }
1405}
1406
1407/// Keep a reduction through the logical namespace owned even if it is aliased.
1408fn resolved_logical_directory_reduction(
1409    parent: &Path,
1410    directory: &Path,
1411    resolved: &Path,
1412) -> io::Result<bool> {
1413    for ancestor in parent.ancestors().skip(1) {
1414        if direct_directory_spelling(ancestor, directory)?
1415            && fs::canonicalize(ancestor).is_ok_and(|owner| owner == resolved)
1416        {
1417            return Ok(true);
1418        }
1419    }
1420    Ok(false)
1421}
1422
1423/// Compare a fixed directory without following its final namespace component.
1424fn direct_directory_spelling(path: &Path, directory: &Path) -> io::Result<bool> {
1425    if path == directory {
1426        return Ok(true);
1427    }
1428    let Some(name) = directory.file_name().and_then(std::ffi::OsStr::to_str) else {
1429        return Ok(false);
1430    };
1431    // Verbatim Windows paths with literal parent reductions can fail lookup
1432    // with ERROR_INVALID_NAME. Let the owning existing-prefix fallback resolve
1433    // them; this is not evidence of external ownership or permission denial.
1434    let unresolved = |source: &io::Error| {
1435        source.kind() == io::ErrorKind::NotFound
1436            || (cfg!(windows) && source.raw_os_error() == Some(123))
1437    };
1438    // A selected root can have arbitrary Unicode, unlike the product names.
1439    // Prove existing direct directory aliases by object identity, not by a
1440    // modern Unicode fold. Never use this proof for managed file leaves.
1441    let unicode = !name.is_ascii()
1442        || path
1443            .file_name()
1444            .and_then(std::ffi::OsStr::to_str)
1445            .is_some_and(|name| !name.is_ascii());
1446    if unicode {
1447        match same_direct_directory(path, directory) {
1448            Ok(true) => return Ok(true),
1449            Ok(false) => {}
1450            Err(source) if unresolved(&source) => {}
1451            Err(source) => return Err(source),
1452        }
1453    }
1454    if !fixed_name_candidate(path, name)? {
1455        return Ok(false);
1456    }
1457    let prefix = containing_directory(path);
1458    let same_parent = match (fs::canonicalize(prefix), directory.parent()) {
1459        (Ok(resolved), Some(parent)) => {
1460            resolved == parent || same_direct_directory(&resolved, parent)?
1461        }
1462        (Err(source), _) if unresolved(&source) => false,
1463        (Err(source), _) => return Err(source),
1464        (_, None) => false,
1465    };
1466    if same_parent {
1467        named(path, name)
1468    } else {
1469        Ok(false)
1470    }
1471}
1472
1473/// Recognize the private directory before interpreting a relative config root.
1474///
1475/// # Errors
1476/// Returns native ownership/comparison errors for a possible directory alias.
1477pub(crate) fn state_directory_spelling(path: &Path) -> io::Result<bool> {
1478    if path.file_name().is_some_and(|name| name == STATE_DIRECTORY) {
1479        return Ok(true);
1480    }
1481    if !fixed_name_candidate(path, STATE_DIRECTORY)? {
1482        return Ok(false);
1483    }
1484    let parent = resolve_existing_parent_prefix(containing_directory(path))?;
1485    direct_directory_spelling(path, &parent.join(STATE_DIRECTORY))
1486}
1487
1488/// Prove only existing direct directory aliases; file hardlinks do not imply naming rules.
1489fn same_direct_directory(left: &Path, right: &Path) -> io::Result<bool> {
1490    let (Some(left_metadata), Some(right_metadata)) =
1491        (optional_metadata(left)?, optional_metadata(right)?)
1492    else {
1493        return Ok(false);
1494    };
1495    if !left_metadata.is_dir()
1496        || indirect(&left_metadata)
1497        || !right_metadata.is_dir()
1498        || indirect(&right_metadata)
1499    {
1500        return Ok(false);
1501    }
1502    #[cfg(unix)]
1503    {
1504        use std::os::unix::fs::MetadataExt;
1505        // Directory traversal and attributes do not require directory-data read
1506        // access. Do not introduce that right merely to compare identities.
1507        Ok(left_metadata.dev() == right_metadata.dev()
1508            && left_metadata.ino() == right_metadata.ino())
1509    }
1510    #[cfg(windows)]
1511    {
1512        use std::os::windows::fs::OpenOptionsExt;
1513        const FILE_READ_ATTRIBUTES: u32 = 0x0000_0080;
1514        const DIRECTORY_NOFOLLOW: u32 = 0x0200_0000 | 0x0020_0000;
1515        let identity = |path: &Path| -> io::Result<Handle> {
1516            let file = fs::OpenOptions::new()
1517                .access_mode(FILE_READ_ATTRIBUTES)
1518                .custom_flags(DIRECTORY_NOFOLLOW)
1519                .open(path)?;
1520            require_directory(path, &file.metadata()?)?;
1521            Handle::from_file(file)
1522        };
1523        Ok(identity(left)? == identity(right)?)
1524    }
1525    #[cfg(not(any(unix, windows)))]
1526    {
1527        Ok(Handle::from_path(left)? == Handle::from_path(right)?)
1528    }
1529}
1530
1531/// Select a path's lookup directory without treating an empty parent as root.
1532fn containing_directory(path: &Path) -> &Path {
1533    path.parent()
1534        .filter(|parent| !parent.as_os_str().is_empty())
1535        .unwrap_or_else(|| Path::new("."))
1536}
1537
1538/// Resolve actual existing ancestors before reducing only an unresolved suffix.
1539fn resolve_existing_parent_prefix(path: &Path) -> io::Result<PathBuf> {
1540    let absolute = std::path::absolute(path)?;
1541    let mut prefix = absolute.as_path();
1542    let mut resolved = loop {
1543        match fs::canonicalize(prefix) {
1544            Ok(resolved) => break resolved,
1545            Err(source) => match prefix.parent() {
1546                Some(parent) => prefix = parent,
1547                None => return Err(source),
1548            },
1549        }
1550    };
1551    let suffix = absolute.strip_prefix(prefix).map_err(io::Error::other)?;
1552    for component in suffix.components() {
1553        match component {
1554            Component::Normal(name) => resolved.push(name),
1555            Component::ParentDir => {
1556                resolved.pop();
1557            }
1558            Component::CurDir => {}
1559            Component::Prefix(_) | Component::RootDir => {
1560                return Err(refusal(path, "unresolved parent suffix is not relative"));
1561            }
1562        }
1563    }
1564    Ok(resolved)
1565}
1566
1567/// Select a possible fixed name without probing an unrelated filesystem.
1568fn fixed_name_candidate(path: &Path, expected: &str) -> io::Result<bool> {
1569    let Some(name) = path.file_name().and_then(std::ffi::OsStr::to_str) else {
1570        return Ok(false);
1571    };
1572    #[cfg(windows)]
1573    let name = windows_lookup_name(path, name);
1574    if name.eq_ignore_ascii_case(expected) {
1575        return Ok(true);
1576    }
1577    #[cfg(windows)]
1578    if windows_filesystem::short_name_candidate(std::ffi::OsStr::new(name)) {
1579        return Ok(true);
1580    }
1581    #[cfg(target_os = "linux")]
1582    if linux_access::fixed_name_matches(name, expected) {
1583        return Ok(true);
1584    }
1585    // This is only a candidate filter: APFS also folds multi-scalar aliases
1586    // such as the fi ligature. The containing filesystem decides ownership.
1587    if !name.is_ascii() && expected.is_ascii() {
1588        if unicode_name_matches(name, expected) || hfs_managed_name_matches(name, expected) {
1589            return Ok(true);
1590        }
1591        let pattern = format!(r"\A{}\z", regex::escape(expected));
1592        return regex::RegexBuilder::new(&pattern)
1593            .case_insensitive(true)
1594            .build()
1595            .map(|pattern| pattern.is_match(name))
1596            .map_err(io::Error::other);
1597    }
1598    Ok(false)
1599}
1600
1601/// Keep ordinary Win32 leaf normalization out of literal native namespaces.
1602#[cfg(windows)]
1603fn windows_lookup_name<'a>(path: &Path, name: &'a str) -> &'a str {
1604    let literal = matches!(
1605        path.components().next(),
1606        Some(Component::Prefix(prefix))
1607            if prefix.kind().is_verbatim()
1608    );
1609    if literal || name.contains(':') {
1610        name
1611    } else {
1612        name.trim_end_matches(['.', ' '])
1613    }
1614}
1615
1616/// Match the fixed ASCII product alphabet under HFS's frozen comparison rules.
1617///
1618/// TN1150 and Apple's HFS comparison data have no non-ASCII-to-ASCII mappings;
1619/// normalization adds none to this alphabet. Only these sixteen format characters
1620/// are ignored. In particular long-s, soft hyphen, CGJ and Kelvin remain distinct.
1621fn hfs_managed_name_matches(name: &str, expected: &str) -> bool {
1622    expected.is_ascii()
1623        && name
1624            .chars()
1625            .filter(|character| {
1626                !matches!(character,
1627                '\u{200c}'..='\u{200f}' | '\u{202a}'..='\u{202e}'
1628                | '\u{206a}'..='\u{206f}' | '\u{feff}')
1629            })
1630            .map(|character| character.to_ascii_lowercase())
1631            .eq(expected
1632                .chars()
1633                .map(|character| character.to_ascii_lowercase()))
1634}
1635
1636/// Compare ASCII product names using APFS's Unicode 9 full, non-Turkic fold.
1637fn unicode_name_matches(name: &str, expected: &str) -> bool {
1638    use unicode_casefold::{Locale, UnicodeCaseFold, Variant};
1639    name.case_fold_with(Variant::Full, Locale::NonTurkic)
1640        .eq(expected.chars())
1641}
1642
1643/// Match fixed components using the supported platform's case behavior.
1644// Keep one fallible caller contract for native directory/volume queries.
1645#[cfg_attr(
1646    not(any(target_os = "macos", target_os = "linux")),
1647    allow(clippy::unnecessary_wraps)
1648)]
1649fn named(path: &Path, expected: &str) -> io::Result<bool> {
1650    let Some(name) = path.file_name().and_then(std::ffi::OsStr::to_str) else {
1651        return Ok(false);
1652    };
1653    #[cfg(windows)]
1654    let name = windows_lookup_name(path, name);
1655    if name == expected {
1656        return Ok(true);
1657    }
1658    #[cfg(windows)]
1659    {
1660        let parent = resolve_existing_parent_prefix(containing_directory(path))?;
1661        if !name.eq_ignore_ascii_case(expected) {
1662            if !windows_filesystem::short_name_candidate(std::ffi::OsStr::new(name)) {
1663                return Ok(false);
1664            }
1665            let Some(entry) = windows_filesystem::entry_name(&parent.join(name))? else {
1666                return Ok(false);
1667            };
1668            // A native short alias names its long entry even in a sensitive
1669            // directory. Distinct hardlinks and reparse targets supply no proof.
1670            if entry == expected {
1671                return Ok(true);
1672            }
1673            if !entry
1674                .to_str()
1675                .is_some_and(|entry| entry.eq_ignore_ascii_case(expected))
1676            {
1677                return Ok(false);
1678            }
1679        }
1680        let mut prefix = parent.as_path();
1681        let probe = loop {
1682            match fs::canonicalize(prefix) {
1683                Ok(resolved) => break resolved,
1684                Err(source) if source.kind() == io::ErrorKind::NotFound => {
1685                    prefix = prefix.parent().ok_or(source)?;
1686                }
1687                Err(source) => return Err(source),
1688            }
1689        };
1690        Ok(!windows_filesystem::case_sensitive(&probe)?)
1691    }
1692    #[cfg(target_os = "macos")]
1693    {
1694        if !fixed_name_candidate(path, expected)? {
1695            return Ok(false);
1696        }
1697        // Match the ownership proof: resolve the existing original prefix, then
1698        // reduce the unresolved suffix before choosing its destination volume.
1699        // A mount crossed by missing/../.. need not own the resulting parent.
1700        let parent = resolve_existing_parent_prefix(containing_directory(path))?;
1701        let mut prefix = parent.as_path();
1702        let probe = loop {
1703            match fs::canonicalize(prefix) {
1704                Ok(resolved) => break resolved,
1705                Err(source) if source.kind() == io::ErrorKind::NotFound => {
1706                    prefix = match prefix.parent() {
1707                        Some(parent) => parent,
1708                        None => return Err(source),
1709                    };
1710                }
1711                Err(source) => return Err(source),
1712            }
1713        };
1714        let volume = whichdisk::resolve(&probe)?;
1715        match volume.case_sensitive() {
1716            Some(true) => Ok(false),
1717            Some(false) if name.eq_ignore_ascii_case(expected) => Ok(true),
1718            Some(false) if volume.fs_type().eq_ignore_ascii_case("apfs") => {
1719                Ok(unicode_name_matches(name, expected))
1720            }
1721            Some(false)
1722                if ["hfs", "hfs+", "hfsx"]
1723                    .iter()
1724                    .any(|filesystem| volume.fs_type().eq_ignore_ascii_case(filesystem)) =>
1725            {
1726                Ok(hfs_managed_name_matches(name, expected))
1727            }
1728            Some(false) => {
1729                // Retain the non-expanding fallback for other insensitive
1730                // filesystems; APFS and HFS use their dedicated rules above.
1731                let pattern = format!(r"\A{}\z", regex::escape(expected));
1732                regex::RegexBuilder::new(&pattern)
1733                    .case_insensitive(true)
1734                    .build()
1735                    .map(|pattern| pattern.is_match(name))
1736                    .map_err(io::Error::other)
1737            }
1738            None => Err(refusal(
1739                path,
1740                "managed case-alias volume behavior is unknown",
1741            )),
1742        }
1743    }
1744    #[cfg(target_os = "linux")]
1745    {
1746        linux_access::named(path, expected)
1747    }
1748    #[cfg(not(any(windows, target_os = "macos", target_os = "linux")))]
1749    {
1750        Ok(false)
1751    }
1752}
1753
1754/// Inspect a direct entry, preserving real I/O errors and dangling aliases.
1755fn optional_metadata(path: &Path) -> io::Result<Option<Metadata>> {
1756    match fs::symlink_metadata(path) {
1757        Ok(metadata) => Ok(Some(metadata)),
1758        Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(None),
1759        Err(source) => Err(source),
1760    }
1761}
1762
1763/// Reject symbolic links and Windows junction/reparse entries.
1764fn indirect(metadata: &Metadata) -> bool {
1765    #[cfg(windows)]
1766    {
1767        use std::os::windows::fs::MetadataExt;
1768        const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
1769        metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
1770    }
1771    #[cfg(not(windows))]
1772    {
1773        metadata.file_type().is_symlink()
1774    }
1775}
1776
1777/// Admit a direct directory, matching the existing project-state contract.
1778fn require_directory(path: &Path, metadata: &Metadata) -> io::Result<()> {
1779    if indirect(metadata) || !metadata.is_dir() {
1780        return Err(refusal(path, "automatic state requires a direct directory"));
1781    }
1782    Ok(())
1783}
1784
1785/// Admit a regular non-indirect output entry.
1786fn require_regular(path: &Path, metadata: &Metadata) -> io::Result<()> {
1787    if indirect(metadata) || !metadata.is_file() {
1788        return Err(refusal(
1789            path,
1790            "automatic output requires a direct regular file",
1791        ));
1792    }
1793    Ok(())
1794}
1795
1796/// Admit a fresh output or its existing regular entry before publication.
1797fn require_regular_or_absent(path: &Path) -> io::Result<()> {
1798    if let Some(metadata) = optional_metadata(path)? {
1799        require_regular(path, &metadata)?;
1800    }
1801    Ok(())
1802}
1803
1804/// Return the platform's hard-link count before any automatic SQLite open.
1805#[cfg(unix)]
1806fn link_count(_path: &Path, metadata: &Metadata) -> u64 {
1807    use std::os::unix::fs::MetadataExt;
1808    metadata.nlink()
1809}
1810
1811/// Use the existing canonical Windows utility rather than unstable metadata APIs.
1812#[cfg(windows)]
1813fn link_count(path: &Path, _metadata: &Metadata) -> io::Result<u64> {
1814    let file = fs::File::open(path)?;
1815    Ok(winapi_util::file::information(&file)?.number_of_links())
1816}
1817
1818/// Preserve a typed capability refusal on unsupported runtime platforms.
1819#[cfg(not(any(unix, windows)))]
1820fn link_count(path: &Path, _metadata: &Metadata) -> io::Result<u64> {
1821    Err(refusal(
1822        path,
1823        "automatic database link identity is unavailable",
1824    ))
1825}
1826
1827/// Keep the owning path and actual refusal cause in the filesystem error chain.
1828fn refusal(path: &Path, reason: &str) -> io::Error {
1829    io::Error::new(
1830        io::ErrorKind::InvalidInput,
1831        format!("{}: {reason}", path.display()),
1832    )
1833}
1834
1835#[cfg(test)]
1836#[path = "project_state_tests.rs"]
1837mod tests;