1use 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
28const STATE_DIRECTORY: &str = ".projectatlas";
30const DATABASE_FILE: &str = "projectatlas.db";
32const CONFIG_FILE: &str = "config.toml";
34const LEGACY_CONFIG_FILE: &str = "projectatlas.toml";
36const JSON_MAP_FILE: &str = "projectatlas.json";
38const GITIGNORE_FILE: &str = ".gitignore";
40const NONSOURCE_FILE: &str = "projectatlas-nonsource-files.toon";
42const HOST_FILES: [&str; 3] = [
44 "projectatlas.mcp.json",
45 "projectatlas.claude.mcp.json",
46 "projectatlas.opencode.json",
47];
48
49const WAL_SIDECAR_SUFFIXES: [&str; 3] = ["", "-wal", "-shm"];
51
52#[derive(Clone, Copy)]
54enum FilePublication {
55 Create,
57 Replace,
59}
60
61#[derive(Debug, thiserror::Error)]
63#[error("{operation}; generated-file cleanup was withheld because {cleanup}")]
64struct RetainedStaging {
65 #[source]
67 operation: io::Error,
68 cleanup: io::Error,
70}
71
72#[derive(Debug, thiserror::Error)]
74#[error("{action}: {source}")]
75struct PublicationFailure {
76 action: &'static str,
78 #[source]
80 source: io::Error,
81}
82
83fn publication_failure(action: &'static str, source: io::Error) -> io::Error {
85 io::Error::new(source.kind(), PublicationFailure { action, source })
86}
87
88#[derive(Clone, Copy)]
90enum StateAdmission {
91 Database,
93 Reset,
95 Generated,
97 Document(ProjectDocument),
99}
100
101#[derive(Clone, Copy)]
103enum ProjectDocument {
104 Config,
106 LegacyConfig,
108 JsonMap,
110 Gitignore,
112}
113
114impl ProjectDocument {
115 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 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
134pub(crate) struct ProjectStateGuard {
139 root: PathBuf,
141 root_identity: Handle,
143 directory: PathBuf,
145 directory_identity: Option<Handle>,
147 automatic_database: Option<PathBuf>,
149 automatic_document: Option<PathBuf>,
151 admission: StateAdmission,
153}
154
155impl ProjectStateGuard {
156 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 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 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 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 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 pub(crate) fn sqlite_path(&self) -> Option<&Path> {
285 self.automatic_database.as_deref()
286 }
287
288 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 pub(crate) fn verify(&self) -> io::Result<()> {
310 self.verify_directories()?;
311 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 #[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 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 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 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 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 temporary.disable_cleanup(true);
914 io::Error::new(operation.kind(), RetainedStaging { operation, cleanup })
915 }
916 }
917 }
918}
919
920#[cfg(any(target_os = "macos", windows))]
922struct ReplacementSource {
923 file: fs::File,
925 identity: Handle,
927 metadata: Metadata,
929 #[cfg(target_os = "macos")]
931 acl: macos_acl::AccessAcl,
932 #[cfg(windows)]
934 security: WindowsAccessSnapshot,
935}
936
937#[cfg(any(target_os = "macos", windows))]
938impl ReplacementSource {
939 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 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, ¤t.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 #[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 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 #[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#[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 staged.as_file().set_permissions(metadata.permissions())?;
1113 let _ = path;
1114 Ok(())
1115}
1116
1117#[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#[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 fs::OpenOptions::new()
1146 .write(true)
1148 .access_mode(rights)
1149 .create_new(true)
1150 .share_mode(0x0000_0004)
1151 .open(path)
1152}
1153
1154#[cfg(windows)]
1156#[derive(PartialEq, Eq)]
1157struct WindowsAccessSnapshot {
1158 discretionary: std::ffi::OsString,
1160 discretionary_control: u16,
1162 system: windows_security::SystemPolicy,
1164}
1165
1166#[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#[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#[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 let current_dacl =
1231 ConvertSecurityDescriptorToStringSecurityDescriptor(¤t, SecurityInformation::Dacl)?;
1232 if !owner_changed && !group_changed && windows_file_dacl_matches(sddl, ¤t_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 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 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#[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#[cfg(any(windows, test))]
1323const WINDOWS_DACL_AUTO_INHERITED_FLAG: &str = "AI";
1324
1325#[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#[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
1370fn 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 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 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
1407fn 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
1423fn 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 let unresolved = |source: &io::Error| {
1435 source.kind() == io::ErrorKind::NotFound
1436 || (cfg!(windows) && source.raw_os_error() == Some(123))
1437 };
1438 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
1473pub(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
1488fn 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 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
1531fn 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
1538fn 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
1567fn 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 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#[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
1616fn 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
1636fn 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#[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 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 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 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
1754fn 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
1763fn 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
1777fn 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
1785fn 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
1796fn 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#[cfg(unix)]
1806fn link_count(_path: &Path, metadata: &Metadata) -> u64 {
1807 use std::os::unix::fs::MetadataExt;
1808 metadata.nlink()
1809}
1810
1811#[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#[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
1827fn 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;