Skip to main content

cookie_agent_tools/
fs_cap.rs

1//! Unix descriptor-anchored filesystem capabilities used by prepared tools.
2
3#[cfg(unix)]
4mod unix {
5    use std::{
6        collections::VecDeque,
7        ffi::{OsStr, OsString},
8        fs::{self, File},
9        io::{Read, Seek, SeekFrom, Write},
10        os::fd::AsRawFd,
11        os::unix::{ffi::OsStrExt, fs::MetadataExt},
12        path::{Component, Path, PathBuf},
13        sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
14    };
15
16    use cookie_agent_engine::ToolError;
17    use cookie_agent_protocol::Sha256Digest;
18    use rustix::fs::{AtFlags, Dir, FileType, Mode, OFlags, RawMode};
19
20    static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
21    static PREPARED_WEIGHT: AtomicUsize = AtomicUsize::new(0);
22    static PREPARED_WEIGHT_LIMIT: AtomicUsize = AtomicUsize::new(16_384);
23    static ATOMIC_WRITES_POISONED: AtomicBool = AtomicBool::new(false);
24
25    #[derive(Clone, Debug, Default, Eq, PartialEq)]
26    pub struct AtomicWriteOutcome {
27        pub cleanup_warning: Option<String>,
28    }
29
30    struct BudgetReservation {
31        weight: usize,
32    }
33
34    impl BudgetReservation {
35        fn reserve(weight: usize) -> Result<Self, ToolError> {
36            let limit = PREPARED_WEIGHT_LIMIT.load(Ordering::Acquire);
37            reserve_weight(&PREPARED_WEIGHT, limit, weight)?;
38            Ok(Self { weight })
39        }
40
41        fn grow(&mut self, weight: usize) -> Result<(), ToolError> {
42            reserve_weight(
43                &PREPARED_WEIGHT,
44                PREPARED_WEIGHT_LIMIT.load(Ordering::Acquire),
45                weight,
46            )?;
47            self.weight += weight;
48            Ok(())
49        }
50    }
51
52    pub(super) fn reserve_weight(
53        counter: &AtomicUsize,
54        limit: usize,
55        weight: usize,
56    ) -> Result<(), ToolError> {
57        counter
58            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| {
59                used.checked_add(weight).filter(|next| *next <= limit)
60            })
61            .map(|_| ())
62            .map_err(|_| {
63                ToolError::resource_limit(format!(
64                    "aggregate prepared-object weight would exceed {limit}"
65                ))
66            })
67    }
68
69    impl Drop for BudgetReservation {
70        fn drop(&mut self) {
71            PREPARED_WEIGHT.fetch_sub(self.weight, Ordering::AcqRel);
72        }
73    }
74
75    #[derive(Clone, Debug, Eq, PartialEq)]
76    pub struct ObjectIdentity {
77        pub device: u64,
78        pub inode: u64,
79        pub mode: u32,
80        pub size: u64,
81    }
82
83    impl ObjectIdentity {
84        fn from_metadata(metadata: &fs::Metadata) -> Self {
85            Self {
86                device: metadata.dev(),
87                inode: metadata.ino(),
88                mode: metadata.mode(),
89                size: metadata.len(),
90            }
91        }
92
93        pub fn canonical_bytes(&self) -> Vec<u8> {
94            let mut bytes = Vec::new();
95            bytes.extend_from_slice(&self.device.to_be_bytes());
96            bytes.extend_from_slice(&self.inode.to_be_bytes());
97            bytes.extend_from_slice(&self.mode.to_be_bytes());
98            bytes.extend_from_slice(&self.size.to_be_bytes());
99            bytes
100        }
101    }
102
103    #[allow(clippy::unnecessary_cast)]
104    fn stat_identity(stat: &rustix::fs::Stat) -> (u64, u64) {
105        // rustix exposes native widths; Darwin's dev_t is narrower than MetadataExt's.
106        (stat.st_dev as u64, stat.st_ino as u64)
107    }
108
109    #[allow(clippy::unnecessary_cast)]
110    fn permission_mode(mode: u32) -> Mode {
111        // RawMode is u32 on Linux but Darwin's libc mode_t is u16.
112        Mode::from_raw_mode((mode & 0o777) as RawMode)
113    }
114
115    pub(super) struct ChainNode {
116        pub(super) parent: File,
117        pub(super) name: OsString,
118        pub(super) identity: ObjectIdentity,
119        // Keep links and exited directories alive so their inodes cannot be reused.
120        pub(super) _object: File,
121        pub(super) link_target: Option<OsString>,
122    }
123
124    pub struct PreparedExisting {
125        pub parent: File,
126        pub file: File,
127        pub basename: OsString,
128        pub display_path: PathBuf,
129        pub identity: ObjectIdentity,
130        pub content_digest: Sha256Digest,
131        pub directory: bool,
132        chain: Vec<ChainNode>,
133        _budget: BudgetReservation,
134    }
135
136    /// A prepared existing target whose inode may be replaced by an earlier
137    /// call in the same serialized batch.
138    pub struct PreparedChained {
139        pub target: PreparedExisting,
140        pub original_digest: Sha256Digest,
141        pub predecessor_digest: Sha256Digest,
142    }
143
144    /// What an atomic replacement expects to displace at the target.
145    pub(super) enum DisplacedExpectation {
146        /// The originally prepared identity and content.
147        PreparedIdentity,
148        /// The output of the preceding call in a serialized chain: the
149        /// displaced inode legitimately differs, only content must match.
150        PredecessorContent(Sha256Digest),
151    }
152
153    impl PreparedChained {
154        /// Digest the target's current content by name: an earlier call in the
155        /// chain may have atomically replaced the target, which leaves
156        /// `target.file` pinned to the superseded inode.
157        fn current_digest(&self) -> Result<Sha256Digest, ToolError> {
158            let file = File::from(
159                rustix::fs::openat(
160                    &self.target.parent,
161                    &self.target.basename,
162                    OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
163                    Mode::empty(),
164                )
165                .map_err(|error| match error {
166                    rustix::io::Errno::NOENT | rustix::io::Errno::LOOP => {
167                        ToolError::operation_changed(
168                            "prepared target changed during chained execution",
169                        )
170                    }
171                    error => super::io_error(error),
172                })?,
173            );
174            Ok(Sha256Digest::of_bytes(&read_all(&file)?))
175        }
176
177        pub fn revalidate(&self) -> Result<(), ToolError> {
178            revalidate_chain(&self.target.chain)?;
179            let digest = self.current_digest()?;
180            if digest != self.original_digest && digest != self.predecessor_digest {
181                return Err(ToolError::operation_changed(
182                    "prepared target content changed",
183                ));
184            }
185            Ok(())
186        }
187
188        pub fn revalidate_for_execution(&self) -> Result<(), ToolError> {
189            revalidate_chain(&self.target.chain)?;
190            let digest = self.current_digest()?;
191            if digest != self.predecessor_digest {
192                return Err(ToolError::operation_changed(
193                    "prepared target content changed",
194                ));
195            }
196            Ok(())
197        }
198
199        pub fn replace_atomically(&self, bytes: &[u8]) -> Result<AtomicWriteOutcome, ToolError> {
200            self.target.replace_atomically_inner(
201                bytes,
202                || {},
203                &DisplacedExpectation::PredecessorContent(self.predecessor_digest.clone()),
204            )
205        }
206    }
207
208    pub struct PreparedAbsent {
209        pub parent: File,
210        pub basename: OsString,
211        pub display_path: PathBuf,
212        missing: Vec<OsString>,
213        chain: Vec<ChainNode>,
214        _budget: BudgetReservation,
215    }
216
217    pub enum PreparedTarget {
218        Existing(PreparedExisting),
219        Absent(PreparedAbsent),
220    }
221
222    impl PreparedTarget {
223        pub fn revalidate(&self) -> Result<(), ToolError> {
224            match self {
225                Self::Existing(target) => target.revalidate(),
226                Self::Absent(target) => target.revalidate(),
227            }
228        }
229
230        pub fn serialization_bytes(&self) -> Result<Vec<u8>, ToolError> {
231            let mut bytes = Vec::new();
232            match self {
233                Self::Existing(target) => {
234                    bytes.extend_from_slice(&target.identity.device.to_be_bytes());
235                    bytes.extend_from_slice(&target.identity.inode.to_be_bytes());
236                }
237                Self::Absent(target) => {
238                    let parent = ObjectIdentity::from_metadata(
239                        &target.parent.metadata().map_err(super::io_error)?,
240                    );
241                    bytes.extend_from_slice(&parent.device.to_be_bytes());
242                    bytes.extend_from_slice(&parent.inode.to_be_bytes());
243                    bytes.extend_from_slice(target.basename.as_encoded_bytes());
244                }
245            }
246            Ok(bytes)
247        }
248
249        pub fn manifest_bytes(&self) -> Result<Vec<u8>, ToolError> {
250            match self {
251                Self::Existing(target) => target.manifest_bytes(),
252                Self::Absent(target) => target.manifest_bytes(),
253            }
254        }
255    }
256
257    pub fn cwd_context_bytes(cwd: &Path) -> Result<Vec<u8>, ToolError> {
258        let mut budget = BudgetReservation::reserve(4)?;
259        let resolved = resolve_path(&normalize_absolute(cwd.to_owned())?, &mut budget)?;
260        if !resolved.missing.is_empty() {
261            return Err(ToolError::execution("prepared directory does not exist"));
262        }
263        let directory = File::from(
264            rustix::fs::openat(
265                &resolved.parent,
266                &resolved.basename,
267                OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
268                Mode::empty(),
269            )
270            .map_err(super::io_error)?,
271        );
272        revalidate_chain(&resolved.chain)?;
273        Ok(
274            ObjectIdentity::from_metadata(&directory.metadata().map_err(super::io_error)?)
275                .canonical_bytes(),
276        )
277    }
278
279    pub fn ensure_atomic_write_supported() -> Result<(), ToolError> {
280        #[cfg(target_os = "linux")]
281        {
282            Ok(())
283        }
284        #[cfg(not(target_os = "linux"))]
285        {
286            Err(ToolError::unsupported_platform(
287                "atomic expected-target replacement is unsupported on this platform",
288            ))
289        }
290    }
291
292    pub fn prepare_existing(cwd: &Path, requested: &Path) -> Result<PreparedExisting, ToolError> {
293        match prepare_target(cwd, requested)? {
294            PreparedTarget::Existing(existing) => Ok(existing),
295            PreparedTarget::Absent(_) => {
296                Err(ToolError::execution("prepared target does not exist"))
297            }
298        }
299    }
300
301    pub fn prepare_target(cwd: &Path, requested: &Path) -> Result<PreparedTarget, ToolError> {
302        let absolute = normalize_absolute(if requested.is_absolute() {
303            requested.to_owned()
304        } else {
305            cwd.join(requested)
306        })?;
307        if absolute.file_name().is_none() {
308            return Err(ToolError::unsupported_security("target has no basename"));
309        }
310        let mut budget = BudgetReservation::reserve(4)?;
311        let ResolvedPath {
312            parent,
313            basename,
314            chain,
315            missing,
316        } = resolve_path(&absolute, &mut budget)?;
317        if !missing.is_empty() {
318            revalidate_chain(&chain)?;
319            return Ok(PreparedTarget::Absent(PreparedAbsent {
320                parent,
321                basename,
322                display_path: absolute,
323                missing,
324                chain,
325                _budget: budget,
326            }));
327        }
328        let flags = OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK;
329        match rustix::fs::openat(&parent, &basename, flags, Mode::empty()) {
330            Ok(fd) => {
331                let file = File::from(fd);
332                let metadata = file.metadata().map_err(super::io_error)?;
333                if !metadata.is_file() && !metadata.is_dir() {
334                    return Err(ToolError::unsupported_security(
335                        "prepared filesystem object is not a regular file or directory",
336                    ));
337                }
338                let identity = ObjectIdentity::from_metadata(&metadata);
339                let content_digest = if metadata.is_dir() {
340                    directory_digest(&file)?
341                } else {
342                    Sha256Digest::of_bytes(&read_all(&file)?)
343                };
344                revalidate_chain(&chain)?;
345                Ok(PreparedTarget::Existing(PreparedExisting {
346                    parent,
347                    file,
348                    basename,
349                    display_path: absolute,
350                    identity,
351                    content_digest,
352                    directory: metadata.is_dir(),
353                    chain,
354                    _budget: budget,
355                }))
356            }
357            Err(rustix::io::Errno::NOENT) => Ok(PreparedTarget::Absent(PreparedAbsent {
358                parent,
359                basename: basename.clone(),
360                display_path: absolute,
361                missing: vec![basename],
362                chain,
363                _budget: budget,
364            })),
365            Err(rustix::io::Errno::LOOP) => Err(ToolError::operation_changed(
366                "prepared target became a symlink during resolution",
367            )),
368            Err(error) => Err(super::io_error(error)),
369        }
370    }
371
372    impl PreparedExisting {
373        pub fn manifest_bytes(&self) -> Result<Vec<u8>, ToolError> {
374            let parent =
375                ObjectIdentity::from_metadata(&self.parent.metadata().map_err(super::io_error)?);
376            Ok(existing_manifest_bytes(
377                &self.chain,
378                &parent,
379                &self.basename,
380                &self.identity,
381                self.directory,
382                &self.content_digest,
383            ))
384        }
385
386        pub fn proc_fd_path(&self) -> PathBuf {
387            PathBuf::from(format!("/proc/self/fd/{}", self.file.as_raw_fd()))
388        }
389
390        pub fn read_bytes(&self) -> Result<Vec<u8>, ToolError> {
391            read_all(&self.file)
392        }
393
394        pub fn verified_bytes(&self) -> Result<Vec<u8>, ToolError> {
395            self.revalidate()?;
396            let bytes = read_all(&self.file)?;
397            if Sha256Digest::of_bytes(&bytes) != self.content_digest {
398                return Err(ToolError::operation_changed(
399                    "prepared target content changed",
400                ));
401            }
402            Ok(bytes)
403        }
404
405        pub fn directory_entries(&self) -> Result<Vec<(String, bool)>, ToolError> {
406            directory_entries(&self.file)
407        }
408
409        pub fn revalidate(&self) -> Result<(), ToolError> {
410            revalidate_chain(&self.chain)?;
411            let stat = rustix::fs::statat(&self.parent, &self.basename, AtFlags::SYMLINK_NOFOLLOW)
412                .map_err(|_| {
413                    ToolError::operation_changed("prepared target disappeared or changed")
414                })?;
415            let (device, inode) = stat_identity(&stat);
416            if device != self.identity.device || inode != self.identity.inode {
417                return Err(ToolError::operation_changed(
418                    "prepared target identity changed",
419                ));
420            }
421            let digest = if self.file.metadata().map_err(super::io_error)?.is_dir() {
422                directory_digest(&self.file)?
423            } else {
424                Sha256Digest::of_bytes(&read_all(&self.file)?)
425            };
426            if digest != self.content_digest {
427                return Err(ToolError::operation_changed(
428                    "prepared target content changed",
429                ));
430            }
431            Ok(())
432        }
433
434        pub fn replace_atomically(&self, bytes: &[u8]) -> Result<AtomicWriteOutcome, ToolError> {
435            self.replace_atomically_inner(bytes, || {}, &DisplacedExpectation::PreparedIdentity)
436        }
437
438        pub(super) fn replace_atomically_inner(
439            &self,
440            bytes: &[u8],
441            before_exchange: impl FnOnce(),
442            expectation: &DisplacedExpectation,
443        ) -> Result<AtomicWriteOutcome, ToolError> {
444            if ATOMIC_WRITES_POISONED.load(Ordering::Acquire) {
445                return Err(ToolError::operation_changed(
446                    "atomic write subsystem is poisoned after an ambiguous rollback",
447                ));
448            }
449            if matches!(expectation, DisplacedExpectation::PreparedIdentity) {
450                self.revalidate()?;
451            }
452            let temporary = stage(&self.parent, bytes, self.identity.mode)?;
453            before_exchange();
454            if let Err(error) = revalidate_chain(&self.chain) {
455                let _ = rustix::fs::unlinkat(&self.parent, &temporary, AtFlags::empty());
456                return Err(error);
457            }
458            if let Err(error) = rename_exchange(&self.parent, &temporary, &self.basename) {
459                let _ = rustix::fs::unlinkat(&self.parent, &temporary, AtFlags::empty());
460                return Err(ToolError::operation_changed(format!(
461                    "target changed during atomic replacement: {}",
462                    error.message()
463                )));
464            }
465            let verification = (|| {
466                revalidate_chain(&self.chain)?;
467                injected_exchange_failure(ExchangeFailurePoint::OpenDisplaced)?;
468                let displaced = rustix::fs::openat(
469                    &self.parent,
470                    &temporary,
471                    OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
472                    Mode::empty(),
473                )
474                .map(File::from)
475                .map_err(super::io_error)?;
476                injected_exchange_failure(ExchangeFailurePoint::StatDisplaced)?;
477                let displaced_identity =
478                    ObjectIdentity::from_metadata(&displaced.metadata().map_err(super::io_error)?);
479                injected_exchange_failure(ExchangeFailurePoint::ReadDisplaced)?;
480                let displaced_digest = Sha256Digest::of_bytes(&read_all(&displaced)?);
481                injected_exchange_failure(ExchangeFailurePoint::IdentityMismatch)?;
482                if matches!(expectation, DisplacedExpectation::PreparedIdentity)
483                    && (displaced_identity.device != self.identity.device
484                        || displaced_identity.inode != self.identity.inode)
485                {
486                    return Err(ToolError::operation_changed(
487                        "target changed during atomic replacement",
488                    ));
489                }
490                injected_exchange_failure(ExchangeFailurePoint::DigestMismatch)?;
491                let expected_digest = match expectation {
492                    DisplacedExpectation::PreparedIdentity => &self.content_digest,
493                    DisplacedExpectation::PredecessorContent(digest) => digest,
494                };
495                if &displaced_digest != expected_digest {
496                    return Err(ToolError::operation_changed(
497                        "target changed during atomic replacement",
498                    ));
499                }
500                injected_exchange_failure(ExchangeFailurePoint::CommitFsync)?;
501                rustix::fs::fsync(&self.parent).map_err(super::io_error)
502            })();
503            if let Err(error) = verification {
504                if rollback_exchange(&self.parent, &temporary, &self.basename).is_err() {
505                    ATOMIC_WRITES_POISONED.store(true, Ordering::Release);
506                    return Err(ToolError::operation_changed(
507                        "atomic replacement rollback durability is ambiguous; writes are poisoned",
508                    ));
509                }
510                return Err(ToolError::operation_changed(format!(
511                    "target changed during atomic replacement: {}",
512                    error.message()
513                )));
514            }
515            // Commit point: the validated exchange is now durable. Cleanup can
516            // no longer turn the committed mutation into an ordinary failure.
517            if let Err(error) = injected_exchange_failure(ExchangeFailurePoint::CleanupUnlink)
518                .and_then(|()| {
519                    rustix::fs::unlinkat(&self.parent, &temporary, AtFlags::empty())
520                        .map_err(super::io_error)
521                })
522            {
523                return Ok(AtomicWriteOutcome {
524                    cleanup_warning: Some(format!(
525                        "replacement committed; displaced object remains recoverable as {}: {}",
526                        temporary.to_string_lossy(),
527                        error.message()
528                    )),
529                });
530            }
531            if let Err(error) = injected_exchange_failure(ExchangeFailurePoint::CleanupFsync)
532                .and_then(|()| rustix::fs::fsync(&self.parent).map_err(super::io_error))
533            {
534                return Ok(AtomicWriteOutcome {
535                    cleanup_warning: Some(format!(
536                        "replacement committed; displaced-object cleanup durability is uncertain: {}",
537                        error.message()
538                    )),
539                });
540            }
541            Ok(AtomicWriteOutcome::default())
542        }
543    }
544
545    impl PreparedAbsent {
546        pub fn revalidate(&self) -> Result<(), ToolError> {
547            revalidate_chain(&self.chain)?;
548            let first = self
549                .missing
550                .first()
551                .ok_or_else(|| ToolError::execution("missing subtree is empty"))?;
552            match rustix::fs::statat(&self.parent, first, AtFlags::SYMLINK_NOFOLLOW) {
553                Err(rustix::io::Errno::NOENT) => Ok(()),
554                Ok(_) => Err(ToolError::operation_changed(
555                    "a prepared missing path component was inserted",
556                )),
557                Err(error) => Err(super::io_error(error)),
558            }
559        }
560
561        pub fn manifest_bytes(&self) -> Result<Vec<u8>, ToolError> {
562            let mut bytes = b"expected-absent\0".to_vec();
563            for node in &self.chain {
564                append_tagged_os_string(&mut bytes, b"ancestor-name", &node.name);
565                bytes.extend_from_slice(b"ancestor-identity\0");
566                bytes.extend_from_slice(&node.identity.canonical_bytes());
567                append_link_manifest(&mut bytes, node);
568            }
569            bytes.extend_from_slice(b"parent-identity\0");
570            bytes.extend_from_slice(
571                &ObjectIdentity::from_metadata(&self.parent.metadata().map_err(super::io_error)?)
572                    .canonical_bytes(),
573            );
574            for component in &self.missing {
575                append_tagged_os_string(&mut bytes, b"missing-component", component);
576            }
577            Ok(bytes)
578        }
579
580        pub fn create_atomically(&self, bytes: &[u8]) -> Result<AtomicWriteOutcome, ToolError> {
581            self.create_atomically_inner(bytes, || {})
582        }
583
584        pub(super) fn create_atomically_inner(
585            &self,
586            bytes: &[u8],
587            before_publish: impl FnOnce(),
588        ) -> Result<AtomicWriteOutcome, ToolError> {
589            if ATOMIC_WRITES_POISONED.load(Ordering::Acquire) {
590                return Err(ToolError::operation_changed(
591                    "atomic write subsystem is poisoned after an ambiguous rollback",
592                ));
593            }
594            self.revalidate()?;
595            let first = self
596                .missing
597                .first()
598                .ok_or_else(|| ToolError::execution("missing subtree is empty"))?;
599            let before_publish = || {
600                before_publish();
601                self.revalidate()
602            };
603            if self.missing.len() == 1 {
604                let temporary = stage(&self.parent, bytes, 0o100600)?;
605                if let Err(error) = before_publish()
606                    .and_then(|()| rename_noreplace(&self.parent, &temporary, first))
607                {
608                    let _ = rustix::fs::unlinkat(&self.parent, &temporary, AtFlags::empty());
609                    return Err(error);
610                }
611            } else {
612                publish_missing_subtree(&self.parent, &self.missing, bytes, before_publish)?;
613            }
614            rustix::fs::fsync(&self.parent).map_err(super::io_error)?;
615            Ok(AtomicWriteOutcome::default())
616        }
617    }
618
619    fn rollback_exchange(
620        parent: &File,
621        temporary: &OsStr,
622        target: &OsStr,
623    ) -> Result<(), ToolError> {
624        rename_exchange(parent, temporary, target)?;
625        rustix::fs::unlinkat(parent, temporary, AtFlags::empty()).map_err(super::io_error)?;
626        rustix::fs::fsync(parent).map_err(super::io_error)
627    }
628
629    fn normalize_absolute(path: PathBuf) -> Result<PathBuf, ToolError> {
630        if !path.is_absolute() {
631            return Err(ToolError::unsupported_security(
632                "filesystem path is not absolute",
633            ));
634        }
635        let mut normalized = PathBuf::from("/");
636        for component in path.components() {
637            match component {
638                Component::RootDir | Component::CurDir => {}
639                Component::Normal(name) => normalized.push(name),
640                Component::ParentDir => {
641                    if !normalized.pop() {
642                        return Err(ToolError::unsupported_security(
643                            "filesystem path traverses above its root",
644                        ));
645                    }
646                }
647                Component::Prefix(_) => {
648                    return Err(ToolError::unsupported_security(
649                        "filesystem path has an unsupported prefix",
650                    ));
651                }
652            }
653        }
654        Ok(normalized)
655    }
656
657    struct ResolvedPath {
658        parent: File,
659        basename: OsString,
660        chain: Vec<ChainNode>,
661        missing: Vec<OsString>,
662    }
663
664    fn resolve_path(
665        path: &Path,
666        budget: &mut BudgetReservation,
667    ) -> Result<ResolvedPath, ToolError> {
668        if !path.is_absolute() {
669            return Err(ToolError::unsupported_security(
670                "filesystem anchor is not absolute",
671            ));
672        }
673        let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
674        let mut current =
675            File::from(rustix::fs::open("/", flags, Mode::empty()).map_err(super::io_error)?);
676        let mut chain = Vec::new();
677        let mut pending = path
678            .components()
679            .map(|part| part.as_os_str().to_owned())
680            .collect::<VecDeque<_>>();
681        let mut links = 0;
682        let mut steps = 0;
683        while let Some(name) = pending.pop_front() {
684            steps += 1;
685            if steps > 4096 || pending.len() > 4096 {
686                return Err(ToolError::resource_limit(
687                    "prepared path resolution is too deep",
688                ));
689            }
690            if name == "/" {
691                current = File::from(
692                    rustix::fs::open("/", flags, Mode::empty()).map_err(super::io_error)?,
693                );
694                continue;
695            }
696            if name == "." {
697                continue;
698            }
699            let stat = match rustix::fs::statat(&current, &name, AtFlags::SYMLINK_NOFOLLOW) {
700                Ok(stat) => stat,
701                Err(rustix::io::Errno::NOENT) => {
702                    if pending.back().is_some_and(|part| part == ".") {
703                        return Err(ToolError::execution(
704                            "symlink destination requires a directory",
705                        ));
706                    }
707                    let mut missing = vec![name];
708                    for part in pending {
709                        if part == ".." {
710                            return Err(ToolError::execution(
711                                "symlink target traverses a missing directory",
712                            ));
713                        }
714                        if part != "." {
715                            missing.push(part);
716                        }
717                    }
718                    budget.grow(missing.len())?;
719                    let basename = missing.last().expect("missing leaf").clone();
720                    return Ok(ResolvedPath {
721                        parent: current,
722                        basename,
723                        chain,
724                        missing,
725                    });
726                }
727                Err(error) => return Err(super::io_error(error)),
728            };
729            if FileType::from_raw_mode(stat.st_mode) == FileType::Symlink {
730                links += 1;
731                if links > 40 {
732                    return Err(ToolError::unsupported_security(
733                        "too many symlinks in prepared path",
734                    ));
735                }
736                budget.grow(2)?;
737                let object = open_link(&current, &name)?;
738                let identity =
739                    ObjectIdentity::from_metadata(&object.metadata().map_err(super::io_error)?);
740                if stat_identity(&stat) != (identity.device, identity.inode) {
741                    return Err(ToolError::operation_changed(
742                        "symlink changed during resolution",
743                    ));
744                }
745                let target =
746                    rustix::fs::readlinkat(&current, &name, Vec::new()).map_err(super::io_error)?;
747                let target = OsStr::from_bytes(target.as_bytes()).to_owned();
748                chain.push(ChainNode {
749                    parent: current.try_clone().map_err(super::io_error)?,
750                    name,
751                    identity,
752                    _object: object,
753                    link_target: Some(target.clone()),
754                });
755                // Expand target components in place: `..` is evaluated from the
756                // resolved directory, never lexically across another symlink.
757                if target.as_bytes().ends_with(b"/") || target.as_bytes().ends_with(b"/.") {
758                    pending.push_front(OsString::from("."));
759                }
760                for part in Path::new(&target).components().rev() {
761                    pending.push_front(part.as_os_str().to_owned());
762                }
763                continue;
764            }
765            if pending.is_empty() && name != ".." {
766                return Ok(ResolvedPath {
767                    parent: current,
768                    basename: name,
769                    chain,
770                    missing: Vec::new(),
771                });
772            }
773            budget.grow(2)?;
774            let next = File::from(
775                rustix::fs::openat(&current, &name, flags, Mode::empty())
776                    .map_err(super::io_error)?,
777            );
778            let identity =
779                ObjectIdentity::from_metadata(&next.metadata().map_err(super::io_error)?);
780            if stat_identity(&stat) != (identity.device, identity.inode) {
781                return Err(ToolError::operation_changed(
782                    "ancestor changed during resolution",
783                ));
784            }
785            chain.push(ChainNode {
786                parent: current,
787                name,
788                identity,
789                _object: next.try_clone().map_err(super::io_error)?,
790                link_target: None,
791            });
792            current = next;
793        }
794        Ok(ResolvedPath {
795            parent: current,
796            basename: OsString::from("."),
797            chain,
798            missing: Vec::new(),
799        })
800    }
801
802    fn open_link(parent: &File, name: &OsStr) -> Result<File, ToolError> {
803        #[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))]
804        let flags = OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC;
805        #[cfg(target_vendor = "apple")]
806        let flags =
807            OFlags::from_bits_retain(libc::O_SYMLINK as _) | OFlags::NOFOLLOW | OFlags::CLOEXEC;
808        #[cfg(any(
809            target_os = "linux",
810            target_os = "android",
811            target_os = "freebsd",
812            target_vendor = "apple"
813        ))]
814        {
815            rustix::fs::openat(parent, name, flags, Mode::empty())
816                .map(File::from)
817                .map_err(super::io_error)
818        }
819        #[cfg(not(any(
820            target_os = "linux",
821            target_os = "android",
822            target_os = "freebsd",
823            target_vendor = "apple"
824        )))]
825        {
826            let _ = (parent, name);
827            Err(ToolError::unsupported_platform(
828                "pinning symlink objects is unsupported on this Unix platform",
829            ))
830        }
831    }
832
833    fn revalidate_chain(chain: &[ChainNode]) -> Result<(), ToolError> {
834        for node in chain {
835            let stat = rustix::fs::statat(&node.parent, &node.name, AtFlags::SYMLINK_NOFOLLOW)
836                .map_err(|_| ToolError::operation_changed("prepared ancestor changed"))?;
837            let (device, inode) = stat_identity(&stat);
838            if device != node.identity.device || inode != node.identity.inode {
839                return Err(ToolError::operation_changed(
840                    "prepared ancestor identity changed",
841                ));
842            }
843            if let Some(target) = &node.link_target {
844                let actual = rustix::fs::readlinkat(&node.parent, &node.name, Vec::new())
845                    .map_err(|_| ToolError::operation_changed("prepared symlink changed"))?;
846                if actual.as_bytes() != target.as_encoded_bytes() {
847                    return Err(ToolError::operation_changed(
848                        "prepared symlink target changed",
849                    ));
850                }
851            }
852        }
853        Ok(())
854    }
855
856    pub(super) fn existing_manifest_bytes(
857        chain: &[ChainNode],
858        parent: &ObjectIdentity,
859        basename: &OsStr,
860        leaf: &ObjectIdentity,
861        directory: bool,
862        digest: &Sha256Digest,
863    ) -> Vec<u8> {
864        let mut bytes = b"expected-existing\0".to_vec();
865        for node in chain {
866            append_tagged_os_string(&mut bytes, b"ancestor-name", &node.name);
867            bytes.extend_from_slice(b"ancestor-identity\0");
868            bytes.extend_from_slice(&node.identity.canonical_bytes());
869            append_link_manifest(&mut bytes, node);
870        }
871        bytes.extend_from_slice(b"parent-identity\0");
872        bytes.extend_from_slice(&parent.canonical_bytes());
873        append_tagged_os_string(&mut bytes, b"basename", basename);
874        bytes.extend_from_slice(b"leaf-identity\0");
875        bytes.extend_from_slice(&leaf.canonical_bytes());
876        bytes.extend_from_slice(if directory {
877            b"leaf-type\0directory\0"
878        } else {
879            b"leaf-type\0file\0"
880        });
881        bytes.extend_from_slice(b"leaf-content-digest\0");
882        bytes.extend_from_slice(digest.as_str().as_bytes());
883        bytes.push(0);
884        bytes
885    }
886
887    fn append_link_manifest(bytes: &mut Vec<u8>, node: &ChainNode) {
888        if let Some(target) = &node.link_target {
889            append_tagged_os_string(bytes, b"symlink-target", target);
890        }
891    }
892
893    fn append_tagged_os_string(bytes: &mut Vec<u8>, tag: &[u8], value: &OsStr) {
894        bytes.extend_from_slice(tag);
895        bytes.push(0);
896        bytes.extend_from_slice(&(value.as_encoded_bytes().len() as u64).to_be_bytes());
897        bytes.extend_from_slice(value.as_encoded_bytes());
898    }
899
900    fn read_all(file: &File) -> Result<Vec<u8>, ToolError> {
901        let mut file = file.try_clone().map_err(super::io_error)?;
902        file.seek(SeekFrom::Start(0)).map_err(super::io_error)?;
903        let mut bytes = Vec::new();
904        file.read_to_end(&mut bytes).map_err(super::io_error)?;
905        Ok(bytes)
906    }
907
908    fn directory_entries(file: &File) -> Result<Vec<(String, bool)>, ToolError> {
909        let mut directory = Dir::read_from(file).map_err(super::io_error)?;
910        let mut entries = Vec::new();
911        for entry in &mut directory {
912            let entry = entry.map_err(super::io_error)?;
913            let name = entry.file_name().to_string_lossy().into_owned();
914            if matches!(name.as_str(), "." | "..") {
915                continue;
916            }
917            entries.push((name, entry.file_type() == FileType::Directory));
918        }
919        entries.sort();
920        Ok(entries)
921    }
922
923    fn directory_digest(file: &File) -> Result<Sha256Digest, ToolError> {
924        serde_json::to_vec(&directory_entries(file)?)
925            .map(|bytes| Sha256Digest::of_bytes(&bytes))
926            .map_err(super::io_error)
927    }
928
929    fn publish_missing_subtree(
930        anchor: &File,
931        missing: &[OsString],
932        bytes: &[u8],
933        before_publish: impl FnOnce() -> Result<(), ToolError>,
934    ) -> Result<(), ToolError> {
935        let temporary = OsString::from(format!(
936            ".cookie-agent-subtree-{}-{}",
937            std::process::id(),
938            TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
939        ));
940        rustix::fs::mkdirat(anchor, &temporary, Mode::from_raw_mode(0o700))
941            .map_err(super::io_error)?;
942        let cleanup_path = PathBuf::from(format!(
943            "/proc/self/fd/{}/{}",
944            anchor.as_raw_fd(),
945            temporary.to_string_lossy()
946        ));
947        let result = (|| {
948            let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
949            let mut current = File::from(
950                rustix::fs::openat(anchor, &temporary, flags, Mode::empty())
951                    .map_err(super::io_error)?,
952            );
953            for component in &missing[1..missing.len() - 1] {
954                rustix::fs::mkdirat(&current, component, Mode::from_raw_mode(0o700))
955                    .map_err(super::io_error)?;
956                let next = File::from(
957                    rustix::fs::openat(&current, component, flags, Mode::empty())
958                        .map_err(super::io_error)?,
959                );
960                rustix::fs::fsync(&current).map_err(super::io_error)?;
961                current = next;
962            }
963            let leaf = missing
964                .last()
965                .ok_or_else(|| ToolError::execution("missing subtree has no leaf"))?;
966            let fd = rustix::fs::openat(
967                &current,
968                leaf,
969                OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC,
970                Mode::from_raw_mode(0o600),
971            )
972            .map_err(super::io_error)?;
973            let mut file = File::from(fd);
974            file.write_all(bytes).map_err(super::io_error)?;
975            file.sync_all().map_err(super::io_error)?;
976            rustix::fs::fsync(&current).map_err(super::io_error)?;
977            before_publish()?;
978            rename_noreplace(anchor, &temporary, &missing[0])
979        })();
980        if result.is_err() {
981            let _ = fs::remove_dir_all(&cleanup_path);
982        }
983        result
984    }
985
986    fn stage(parent: &File, bytes: &[u8], mode: u32) -> Result<OsString, ToolError> {
987        let name = OsString::from(format!(
988            ".cookie-agent-stage-{}-{}",
989            std::process::id(),
990            TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
991        ));
992        let fd = rustix::fs::openat(
993            parent,
994            &name,
995            OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC,
996            permission_mode(mode),
997        )
998        .map_err(super::io_error)?;
999        let mut file = File::from(fd);
1000        if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
1001            let _ = rustix::fs::unlinkat(parent, &name, AtFlags::empty());
1002            return Err(super::io_error(error));
1003        }
1004        Ok(name)
1005    }
1006
1007    #[cfg(target_os = "linux")]
1008    fn rename_exchange(parent: &File, old: &OsStr, new: &OsStr) -> Result<(), ToolError> {
1009        rustix::fs::renameat_with(parent, old, parent, new, rustix::fs::RenameFlags::EXCHANGE)
1010            .map_err(super::io_error)
1011    }
1012
1013    #[cfg(not(target_os = "linux"))]
1014    fn rename_exchange(_: &File, _: &OsStr, _: &OsStr) -> Result<(), ToolError> {
1015        Err(ToolError::unsupported_platform(
1016            "atomic expected-target exchange is unsupported on this Unix platform",
1017        ))
1018    }
1019
1020    #[cfg(target_os = "linux")]
1021    fn rename_noreplace(parent: &File, old: &OsStr, new: &OsStr) -> Result<(), ToolError> {
1022        rustix::fs::renameat_with(parent, old, parent, new, rustix::fs::RenameFlags::NOREPLACE)
1023            .map_err(|error| {
1024                if error == rustix::io::Errno::EXIST {
1025                    ToolError::operation_changed("prepared absent path was inserted before commit")
1026                } else {
1027                    super::io_error(error)
1028                }
1029            })
1030    }
1031
1032    #[cfg(not(target_os = "linux"))]
1033    fn rename_noreplace(_: &File, _: &OsStr, _: &OsStr) -> Result<(), ToolError> {
1034        Err(ToolError::unsupported_platform(
1035            "atomic no-replace is unsupported on this Unix platform",
1036        ))
1037    }
1038
1039    #[cfg(test)]
1040    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
1041    pub(super) enum ExchangeFailurePoint {
1042        OpenDisplaced,
1043        StatDisplaced,
1044        ReadDisplaced,
1045        IdentityMismatch,
1046        DigestMismatch,
1047        CommitFsync,
1048        CleanupUnlink,
1049        CleanupFsync,
1050    }
1051
1052    #[cfg(not(test))]
1053    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
1054    enum ExchangeFailurePoint {
1055        OpenDisplaced,
1056        StatDisplaced,
1057        ReadDisplaced,
1058        IdentityMismatch,
1059        DigestMismatch,
1060        CommitFsync,
1061        CleanupUnlink,
1062        CleanupFsync,
1063    }
1064
1065    #[cfg(test)]
1066    std::thread_local! {
1067        static EXCHANGE_FAILURE: std::cell::Cell<Option<ExchangeFailurePoint>> = const { std::cell::Cell::new(None) };
1068    }
1069
1070    #[cfg(test)]
1071    pub(super) fn inject_exchange_failure(point: ExchangeFailurePoint) {
1072        EXCHANGE_FAILURE.set(Some(point));
1073    }
1074
1075    #[cfg(test)]
1076    fn injected_exchange_failure(point: ExchangeFailurePoint) -> Result<(), ToolError> {
1077        let injected = EXCHANGE_FAILURE.get() == Some(point);
1078        if injected {
1079            EXCHANGE_FAILURE.set(None);
1080            if matches!(
1081                point,
1082                ExchangeFailurePoint::IdentityMismatch | ExchangeFailurePoint::DigestMismatch
1083            ) {
1084                Err(ToolError::operation_changed(format!("injected {point:?}")))
1085            } else {
1086                Err(ToolError::execution(format!("injected {point:?}")))
1087            }
1088        } else {
1089            Ok(())
1090        }
1091    }
1092
1093    #[cfg(not(test))]
1094    fn injected_exchange_failure(_: ExchangeFailurePoint) -> Result<(), ToolError> {
1095        Ok(())
1096    }
1097}
1098
1099#[cfg(unix)]
1100pub use unix::*;
1101
1102#[cfg(windows)]
1103mod windows {
1104    //! Windows path-based filesystem capabilities.
1105    //!
1106    //! Documented gaps versus the Unix descriptor backend: Windows validation
1107    //! cannot provide TOCTOU immunity between revalidation and mutation, and
1108    //! Windows has no atomic two-file exchange for expected-target rollback.
1109
1110    use std::{
1111        collections::VecDeque,
1112        ffi::OsString,
1113        fs,
1114        io::Write,
1115        os::windows::{
1116            ffi::{OsStrExt, OsStringExt},
1117            fs::OpenOptionsExt as _,
1118            io::AsRawHandle as _,
1119        },
1120        path::{Component, Path, PathBuf},
1121        sync::atomic::{AtomicU64, Ordering},
1122    };
1123
1124    use cookie_agent_engine::ToolError;
1125    use cookie_agent_protocol::Sha256Digest;
1126    use windows_sys::Win32::{
1127        Foundation::{ERROR_ACCESS_DENIED, HANDLE, INVALID_HANDLE_VALUE},
1128        Storage::FileSystem::{
1129            BY_HANDLE_FILE_INFORMATION, DELETE, FILE_ATTRIBUTE_REPARSE_POINT,
1130            FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_FLAG_WRITE_THROUGH,
1131            FILE_RENAME_INFO, FILE_RENAME_INFO_0, FILE_SHARE_DELETE, FILE_SHARE_READ,
1132            FILE_SHARE_WRITE, FileRenameInfoEx, FindClose, FindFirstFileW,
1133            GetFileInformationByHandle, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
1134            MoveFileExW, SetFileInformationByHandle, WIN32_FIND_DATAW,
1135        },
1136    };
1137
1138    static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
1139    const MAX_LINK_TRAVERSALS: usize = 40;
1140    const MAX_ROUTE_COMPONENTS: usize = 4096;
1141
1142    pub(crate) fn lexical_path_spelling(path: &Path) -> PathBuf {
1143        if !path.is_absolute() || !path.as_os_str().as_encoded_bytes().contains(&b'~') {
1144            return path.to_owned();
1145        }
1146        let mut result = PathBuf::new();
1147        let mut components = path.components();
1148        while let Some(component) = components.next() {
1149            let Component::Normal(name) = component else {
1150                result.push(component.as_os_str());
1151                continue;
1152            };
1153            let candidate = result.join(name);
1154            let found = validate_windows_component(name)
1155                .and_then(|()| wide_path(&candidate))
1156                .ok()
1157                .and_then(|wide| {
1158                    let mut data = WIN32_FIND_DATAW::default();
1159                    let handle = unsafe { FindFirstFileW(wide.as_ptr(), &mut data) };
1160                    if handle == INVALID_HANDLE_VALUE {
1161                        return None;
1162                    }
1163                    unsafe { FindClose(handle) };
1164                    Some(data)
1165                });
1166            let Some(data) = found else {
1167                result.push(name);
1168                result.extend(components.map(|part| part.as_os_str()));
1169                break;
1170            };
1171            let length = data
1172                .cFileName
1173                .iter()
1174                .position(|value| *value == 0)
1175                .unwrap_or(data.cFileName.len());
1176            result.push(OsString::from_wide(&data.cFileName[..length]));
1177            // FindFirstFile reports the link's own entry name. Never inspect its
1178            // descendants for spelling: permission labels must retain the alias.
1179            if data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
1180                result.extend(components.map(|part| part.as_os_str()));
1181                break;
1182            }
1183        }
1184        result
1185    }
1186
1187    #[derive(Clone, Debug, Eq, PartialEq)]
1188    pub struct ObjectIdentity {
1189        pub device: u64,
1190        pub inode: u64,
1191        pub mode: u32,
1192        pub size: u64,
1193    }
1194    impl ObjectIdentity {
1195        pub fn canonical_bytes(&self) -> Vec<u8> {
1196            let mut bytes = Vec::new();
1197            bytes.extend_from_slice(&self.device.to_be_bytes());
1198            bytes.extend_from_slice(&self.inode.to_be_bytes());
1199            bytes.extend_from_slice(&self.mode.to_be_bytes());
1200            bytes.extend_from_slice(&self.size.to_be_bytes());
1201            bytes
1202        }
1203    }
1204    pub struct PreparedExisting {
1205        pub display_path: PathBuf,
1206        pub identity: ObjectIdentity,
1207        pub content_digest: Sha256Digest,
1208        pub directory: bool,
1209        execution_path: PathBuf,
1210        sandbox_root: PathBuf,
1211        route: Vec<RouteNode>,
1212    }
1213    pub struct PreparedChained {
1214        pub target: PreparedExisting,
1215        pub original_digest: Sha256Digest,
1216        pub predecessor_digest: Sha256Digest,
1217    }
1218    pub struct PreparedAbsent {
1219        pub display_path: PathBuf,
1220        execution_path: PathBuf,
1221        first_missing: PathBuf,
1222        sandbox_root: PathBuf,
1223        route: Vec<RouteNode>,
1224    }
1225    struct RouteNode {
1226        path: PathBuf,
1227        identity: ObjectIdentity,
1228        link_target: Option<PathBuf>,
1229        _object: fs::File,
1230    }
1231    struct ResolvedPath {
1232        execution_path: PathBuf,
1233        first_missing: Option<PathBuf>,
1234        route: Vec<RouteNode>,
1235    }
1236    enum PendingComponent {
1237        Normal(OsString),
1238        Parent,
1239        RequireDirectory,
1240    }
1241    #[derive(Clone, Debug, Default, Eq, PartialEq)]
1242    pub struct AtomicWriteOutcome {
1243        pub cleanup_warning: Option<String>,
1244    }
1245    pub enum PreparedTarget {
1246        Existing(PreparedExisting),
1247        Absent(PreparedAbsent),
1248    }
1249    impl PreparedTarget {
1250        pub fn revalidate(&self) -> Result<(), ToolError> {
1251            match self {
1252                Self::Existing(target) => target.revalidate(),
1253                Self::Absent(target) => target.revalidate(),
1254            }
1255        }
1256        pub fn serialization_bytes(&self) -> Result<Vec<u8>, ToolError> {
1257            self.manifest_bytes()
1258        }
1259        pub fn manifest_bytes(&self) -> Result<Vec<u8>, ToolError> {
1260            match self {
1261                Self::Existing(target) => target.manifest_bytes(),
1262                Self::Absent(target) => target.manifest_bytes(),
1263            }
1264        }
1265    }
1266    impl PreparedExisting {
1267        pub fn manifest_bytes(&self) -> Result<Vec<u8>, ToolError> {
1268            let mut bytes = b"windows-existing\0".to_vec();
1269            append_path(&mut bytes, &self.sandbox_root);
1270            append_path(&mut bytes, &self.display_path);
1271            append_path(&mut bytes, &self.execution_path);
1272            append_route(&mut bytes, &self.route);
1273            bytes.extend_from_slice(&self.identity.canonical_bytes());
1274            bytes.extend_from_slice(self.content_digest.as_str().as_bytes());
1275            Ok(bytes)
1276        }
1277        pub fn read_bytes(&self) -> Result<Vec<u8>, ToolError> {
1278            if self.directory {
1279                return Err(ToolError::execution("prepared target is a directory"));
1280            }
1281            fs::read(&self.execution_path).map_err(super::io_error)
1282        }
1283        pub fn verified_bytes(&self) -> Result<Vec<u8>, ToolError> {
1284            self.revalidate()?;
1285            let bytes = self.read_bytes()?;
1286            if Sha256Digest::of_bytes(&bytes) != self.content_digest {
1287                return Err(ToolError::operation_changed(
1288                    "prepared target content changed",
1289                ));
1290            }
1291            Ok(bytes)
1292        }
1293        pub fn directory_entries(&self) -> Result<Vec<(String, bool)>, ToolError> {
1294            if !self.directory {
1295                return Err(ToolError::execution("prepared target is not a directory"));
1296            }
1297            directory_entries(&self.execution_path)
1298        }
1299        pub fn revalidate(&self) -> Result<(), ToolError> {
1300            revalidate_route(&self.route)?;
1301            let resolved = resolve_path(&self.display_path, false).map_err(|error| {
1302                ToolError::operation_changed(format!("prepared target route changed: {error}"))
1303            })?;
1304            if !paths_equal(&resolved.execution_path, &self.execution_path)
1305                || resolved.first_missing.is_some()
1306                || !routes_equal(&resolved.route, &self.route)
1307            {
1308                return Err(ToolError::operation_changed(
1309                    "prepared target resolved to another path",
1310                ));
1311            }
1312            validate_contained_path(&self.sandbox_root, &resolved.execution_path).map_err(
1313                |error| {
1314                    ToolError::operation_changed(format!(
1315                        "prepared target containment changed: {error}"
1316                    ))
1317                },
1318            )?;
1319            let file = open_for_identity(&self.execution_path).map_err(|error| {
1320                ToolError::operation_changed(format!("prepared target changed: {error}"))
1321            })?;
1322            let identity = identity(&file).map_err(|error| {
1323                ToolError::operation_changed(format!("prepared target changed: {error}"))
1324            })?;
1325            if identity.device != self.identity.device
1326                || identity.inode != self.identity.inode
1327                || identity.mode != self.identity.mode
1328            {
1329                return Err(ToolError::operation_changed(
1330                    "prepared target identity changed",
1331                ));
1332            }
1333            let digest = content_digest(&self.execution_path, self.directory).map_err(|error| {
1334                ToolError::operation_changed(format!("prepared target changed: {error}"))
1335            })?;
1336            if digest != self.content_digest {
1337                return Err(ToolError::operation_changed(
1338                    "prepared target content changed",
1339                ));
1340            }
1341            Ok(())
1342        }
1343        fn replace_atomically_impl(
1344            &self,
1345            bytes: &[u8],
1346            validate: bool,
1347        ) -> Result<AtomicWriteOutcome, ToolError> {
1348            if self.directory {
1349                return Err(ToolError::unsupported_security(
1350                    "cannot replace a directory with file content",
1351                ));
1352            }
1353            if validate {
1354                self.revalidate()?;
1355            }
1356            let temporary = stage_sibling(&self.execution_path, bytes)?;
1357            if let Err(error) = move_file(&temporary, &self.execution_path, true) {
1358                let _ = fs::remove_file(&temporary);
1359                return Err(error);
1360            }
1361            Ok(AtomicWriteOutcome::default())
1362        }
1363        pub fn replace_atomically(&self, bytes: &[u8]) -> Result<AtomicWriteOutcome, ToolError> {
1364            self.replace_atomically_impl(bytes, true)
1365        }
1366        pub fn proc_fd_path(&self) -> PathBuf {
1367            self.execution_path.clone()
1368        }
1369    }
1370    impl PreparedAbsent {
1371        pub fn revalidate(&self) -> Result<(), ToolError> {
1372            revalidate_route(&self.route)?;
1373            match fs::symlink_metadata(&self.first_missing) {
1374                Ok(_) => {
1375                    return Err(ToolError::operation_changed(
1376                        "an originally missing path component was inserted",
1377                    ));
1378                }
1379                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1380                Err(error) => {
1381                    return Err(ToolError::operation_changed(format!(
1382                        "prepared missing route changed: {error}"
1383                    )));
1384                }
1385            }
1386            let resolved = resolve_path(&self.display_path, true).map_err(|error| {
1387                ToolError::operation_changed(format!("prepared target route changed: {error}"))
1388            })?;
1389            if !paths_equal(&resolved.execution_path, &self.execution_path)
1390                || resolved.first_missing.as_ref() != Some(&self.first_missing)
1391                || !routes_equal(&resolved.route, &self.route)
1392            {
1393                return Err(ToolError::operation_changed(
1394                    "prepared absent target resolved to another path",
1395                ));
1396            }
1397            validate_contained_path(&self.sandbox_root, &resolved.execution_path).map_err(
1398                |error| {
1399                    ToolError::operation_changed(format!(
1400                        "prepared target containment changed: {error}"
1401                    ))
1402                },
1403            )?;
1404            if self.execution_path.exists() {
1405                return Err(ToolError::operation_changed(
1406                    "a prepared absent path was inserted",
1407                ));
1408            }
1409            Ok(())
1410        }
1411        pub fn manifest_bytes(&self) -> Result<Vec<u8>, ToolError> {
1412            let mut bytes = b"windows-absent\0".to_vec();
1413            append_path(&mut bytes, &self.sandbox_root);
1414            append_path(&mut bytes, &self.display_path);
1415            append_path(&mut bytes, &self.execution_path);
1416            append_path(&mut bytes, &self.first_missing);
1417            append_route(&mut bytes, &self.route);
1418            Ok(bytes)
1419        }
1420        pub fn create_atomically(&self, bytes: &[u8]) -> Result<AtomicWriteOutcome, ToolError> {
1421            self.revalidate()?;
1422            let parent = self
1423                .execution_path
1424                .parent()
1425                .ok_or_else(|| ToolError::unsupported_security("target has no parent"))?;
1426            create_private_parents(&self.sandbox_root, parent)?;
1427            let temporary = stage_sibling(&self.execution_path, bytes)?;
1428            if let Err(error) = move_file(&temporary, &self.execution_path, false) {
1429                let _ = fs::remove_file(&temporary);
1430                return Err(error);
1431            }
1432            Ok(AtomicWriteOutcome::default())
1433        }
1434    }
1435    impl PreparedChained {
1436        /// Ancestor route validation for chained targets: the leaf identity
1437        /// legitimately changes when a predecessor replaces it, so only the
1438        /// ancestors are pinned; the leaf itself is checked below.
1439        fn revalidate_ancestors(&self) -> Result<(), ToolError> {
1440            let (_, ancestors) =
1441                self.target.route.split_last().ok_or_else(|| {
1442                    ToolError::operation_changed("prepared target route is empty")
1443                })?;
1444            revalidate_route(ancestors)?;
1445            let leaf = open_for_identity(&self.target.execution_path).map_err(|error| {
1446                ToolError::operation_changed(format!("prepared target changed: {error}"))
1447            })?;
1448            if opened_file_is_reparse(&leaf).map_err(|error| {
1449                ToolError::operation_changed(format!("prepared target changed: {error}"))
1450            })? {
1451                return Err(ToolError::operation_changed(
1452                    "prepared target became a link during chained execution",
1453                ));
1454            }
1455            validate_contained_path(&self.target.sandbox_root, &self.target.execution_path).map_err(
1456                |error| {
1457                    ToolError::operation_changed(format!(
1458                        "prepared target containment changed: {error}"
1459                    ))
1460                },
1461            )
1462        }
1463
1464        pub fn revalidate(&self) -> Result<(), ToolError> {
1465            self.revalidate_ancestors()?;
1466            let digest = content_digest(&self.target.execution_path, self.target.directory)
1467                .map_err(super::io_error)?;
1468            if digest != self.original_digest && digest != self.predecessor_digest {
1469                return Err(ToolError::operation_changed(
1470                    "prepared target content changed",
1471                ));
1472            }
1473            Ok(())
1474        }
1475        pub fn revalidate_for_execution(&self) -> Result<(), ToolError> {
1476            self.revalidate_ancestors()?;
1477            let digest = content_digest(&self.target.execution_path, self.target.directory)
1478                .map_err(super::io_error)?;
1479            if digest != self.predecessor_digest {
1480                return Err(ToolError::operation_changed(
1481                    "prepared target content changed",
1482                ));
1483            }
1484            Ok(())
1485        }
1486        pub fn replace_atomically(&self, bytes: &[u8]) -> Result<AtomicWriteOutcome, ToolError> {
1487            self.target.replace_atomically_impl(bytes, false)
1488        }
1489    }
1490    pub fn cwd_context_bytes(cwd: &Path) -> Result<Vec<u8>, ToolError> {
1491        let cwd = normalize_absolute(cwd)?;
1492        let resolved = resolve_path(&cwd, false)?;
1493        Ok(identity(&open_for_identity(&resolved.execution_path)?)?.canonical_bytes())
1494    }
1495    pub fn ensure_atomic_write_supported() -> Result<(), ToolError> {
1496        Ok(())
1497    }
1498    pub fn prepare_existing(cwd: &Path, requested: &Path) -> Result<PreparedExisting, ToolError> {
1499        match prepare_target(cwd, requested)? {
1500            PreparedTarget::Existing(existing) => Ok(existing),
1501            PreparedTarget::Absent(_) => {
1502                Err(ToolError::execution("prepared target does not exist"))
1503            }
1504        }
1505    }
1506    pub fn prepare_target(cwd: &Path, requested: &Path) -> Result<PreparedTarget, ToolError> {
1507        let display_path = absolute_requested(cwd, requested)?;
1508        let sandbox_root = sandbox_root(cwd, &display_path, requested.is_absolute())?;
1509        let resolved = resolve_path(&display_path, true)?;
1510        validate_contained_path(&sandbox_root, &resolved.execution_path)?;
1511        match resolved.first_missing {
1512            None => {
1513                let file = open_for_identity(&resolved.execution_path)?;
1514                let identity = identity(&file)?;
1515                let directory = file.metadata().map_err(super::io_error)?.is_dir();
1516                let content_digest = content_digest(&resolved.execution_path, directory)?;
1517                revalidate_route(&resolved.route)?;
1518                Ok(PreparedTarget::Existing(PreparedExisting {
1519                    display_path,
1520                    identity,
1521                    content_digest,
1522                    directory,
1523                    execution_path: resolved.execution_path,
1524                    sandbox_root,
1525                    route: resolved.route,
1526                }))
1527            }
1528            Some(first_missing) => {
1529                revalidate_route(&resolved.route)?;
1530                match fs::symlink_metadata(&first_missing) {
1531                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1532                    Ok(_) => {
1533                        return Err(ToolError::operation_changed(
1534                            "missing route component was inserted during preparation",
1535                        ));
1536                    }
1537                    Err(error) => return Err(super::io_error(error)),
1538                }
1539                Ok(PreparedTarget::Absent(PreparedAbsent {
1540                    display_path,
1541                    execution_path: resolved.execution_path,
1542                    first_missing,
1543                    sandbox_root,
1544                    route: resolved.route,
1545                }))
1546            }
1547        }
1548    }
1549
1550    fn resolve_path(path: &Path, allow_missing: bool) -> Result<ResolvedPath, ToolError> {
1551        let path = normalize_absolute(path)?;
1552        let (mut current, names) = root_and_names(&path)?;
1553        let mut pending = names
1554            .into_iter()
1555            .map(PendingComponent::Normal)
1556            .collect::<VecDeque<_>>();
1557        let mut route = Vec::new();
1558        let mut links = 0;
1559        ensure_resolution_bound(1, pending.len())?;
1560        pin_route_node(&mut route, current.clone(), None)?;
1561
1562        while let Some(component) = pending.pop_front() {
1563            let name = match component {
1564                PendingComponent::Normal(name) => name,
1565                PendingComponent::Parent => {
1566                    if current.file_name().is_none() || !current.pop() {
1567                        return Err(ToolError::unsupported_security(
1568                            "link target traverses above its native root",
1569                        ));
1570                    }
1571                    continue;
1572                }
1573                PendingComponent::RequireDirectory => {
1574                    if !fs::metadata(&current).map_err(super::io_error)?.is_dir() {
1575                        return Err(ToolError::execution(
1576                            "symlink destination requires a directory",
1577                        ));
1578                    }
1579                    continue;
1580                }
1581            };
1582            let candidate = current.join(&name);
1583            match fs::symlink_metadata(&candidate) {
1584                Ok(_) => {
1585                    ensure_resolution_bound(route.len() + 1, pending.len())?;
1586                    let object = open_for_identity(&candidate)?;
1587                    let is_reparse = opened_file_is_reparse(&object)?;
1588                    let directory = object.metadata().map_err(super::io_error)?.is_dir();
1589                    let link_target = if is_reparse {
1590                        Some(read_supported_link(&candidate)?)
1591                    } else {
1592                        None
1593                    };
1594                    pin_open_route_node(
1595                        &mut route,
1596                        candidate.clone(),
1597                        link_target.clone(),
1598                        object,
1599                    )?;
1600                    if let Some(target) = link_target {
1601                        links += 1;
1602                        if links > MAX_LINK_TRAVERSALS {
1603                            return Err(ToolError::unsupported_security(
1604                                "filesystem path exceeds the Windows link traversal limit",
1605                            ));
1606                        }
1607                        let (target_root, target_components) =
1608                            link_target_components(&current, &target)?;
1609                        ensure_resolution_bound(
1610                            route.len() + usize::from(target_root.is_some()),
1611                            pending.len().saturating_add(target_components.len()),
1612                        )?;
1613                        if let Some(target_root) = target_root {
1614                            current = target_root;
1615                            pin_route_node(&mut route, current.clone(), None)?;
1616                        }
1617                        for component in target_components.into_iter().rev() {
1618                            pending.push_front(component);
1619                        }
1620                    } else {
1621                        if !pending.is_empty() && !directory {
1622                            return Err(ToolError::execution(format!(
1623                                "filesystem path component is not a directory: {}",
1624                                candidate.display()
1625                            )));
1626                        }
1627                        current = candidate;
1628                    }
1629                }
1630                Err(error) if error.kind() == std::io::ErrorKind::NotFound && allow_missing => {
1631                    ensure_resolution_bound(route.len(), pending.len().saturating_add(1))?;
1632                    if matches!(pending.back(), Some(PendingComponent::RequireDirectory)) {
1633                        return Err(ToolError::execution(
1634                            "symlink destination requires a directory",
1635                        ));
1636                    }
1637                    let canonical_parent = current.canonicalize().map_err(super::io_error)?;
1638                    let first_missing = canonical_parent.join(&name);
1639                    current = first_missing.clone();
1640                    for component in pending {
1641                        match component {
1642                            PendingComponent::Normal(component) => current.push(component),
1643                            PendingComponent::Parent => {
1644                                return Err(ToolError::execution(
1645                                    "link target traverses a missing directory",
1646                                ));
1647                            }
1648                            PendingComponent::RequireDirectory => {}
1649                        }
1650                    }
1651                    return Ok(ResolvedPath {
1652                        execution_path: current,
1653                        first_missing: Some(first_missing),
1654                        route,
1655                    });
1656                }
1657                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1658                    return Err(ToolError::operation_changed(
1659                        "prepared filesystem object disappeared",
1660                    ));
1661                }
1662                Err(error) => {
1663                    return Err(super::io_error(format!(
1664                        "symlink_metadata failed for {}: {error}",
1665                        candidate.display()
1666                    )));
1667                }
1668            }
1669        }
1670        Ok(ResolvedPath {
1671            execution_path: current.canonicalize().map_err(super::io_error)?,
1672            first_missing: None,
1673            route,
1674        })
1675    }
1676
1677    pub(super) fn ensure_resolution_bound(route: usize, pending: usize) -> Result<(), ToolError> {
1678        if route
1679            .checked_add(pending)
1680            .is_none_or(|total| total > MAX_ROUTE_COMPONENTS)
1681        {
1682            Err(ToolError::resource_limit(format!(
1683                "Windows filesystem route exceeds {MAX_ROUTE_COMPONENTS} components"
1684            )))
1685        } else {
1686            Ok(())
1687        }
1688    }
1689
1690    fn link_target_components(
1691        current: &Path,
1692        target: &Path,
1693    ) -> Result<(Option<PathBuf>, Vec<PendingComponent>), ToolError> {
1694        let rooted = target.has_root();
1695        let root = if target.is_absolute() {
1696            Some(native_root(target)?)
1697        } else if rooted {
1698            Some(native_root(current)?)
1699        } else {
1700            None
1701        };
1702        let mut components = Vec::new();
1703        for component in target.components() {
1704            match component {
1705                Component::Normal(name) => {
1706                    validate_windows_component(name)?;
1707                    components.push(PendingComponent::Normal(name.to_owned()));
1708                }
1709                Component::ParentDir => components.push(PendingComponent::Parent),
1710                Component::CurDir | Component::RootDir if rooted => {}
1711                Component::CurDir => {}
1712                Component::Prefix(_) if target.is_absolute() => {}
1713                Component::Prefix(_) => {
1714                    return Err(ToolError::unsupported_security(
1715                        "drive-relative link targets are unsupported",
1716                    ));
1717                }
1718                Component::RootDir => {
1719                    return Err(ToolError::unsupported_security(
1720                        "link target has an unsupported root",
1721                    ));
1722                }
1723            }
1724        }
1725        if link_target_requires_directory(target) {
1726            components.push(PendingComponent::RequireDirectory);
1727        }
1728        Ok((root, components))
1729    }
1730
1731    fn link_target_requires_directory(target: &Path) -> bool {
1732        let target = target.as_os_str().encode_wide().collect::<Vec<_>>();
1733        match target.as_slice() {
1734            [.., character] if *character == u16::from(b'/') || *character == u16::from(b'\\') => {
1735                true
1736            }
1737            [.., prior, character]
1738                if *character == u16::from(b'.')
1739                    && (*prior == u16::from(b'/') || *prior == u16::from(b'\\')) =>
1740            {
1741                true
1742            }
1743            _ => false,
1744        }
1745    }
1746
1747    fn native_root(path: &Path) -> Result<PathBuf, ToolError> {
1748        let mut root = PathBuf::new();
1749        for component in path.components() {
1750            match component {
1751                Component::Prefix(prefix) => root.push(prefix.as_os_str()),
1752                Component::RootDir => {
1753                    root.push(Path::new("\\"));
1754                    break;
1755                }
1756                _ => break,
1757            }
1758        }
1759        if !root.is_absolute() {
1760            return Err(ToolError::unsupported_security(
1761                "filesystem path has no absolute Windows root",
1762            ));
1763        }
1764        Ok(root)
1765    }
1766
1767    fn root_and_names(path: &Path) -> Result<(PathBuf, Vec<OsString>), ToolError> {
1768        let mut root = PathBuf::new();
1769        let mut names = Vec::new();
1770        for component in path.components() {
1771            match component {
1772                Component::Prefix(prefix) => root.push(prefix.as_os_str()),
1773                Component::RootDir => root.push(Path::new("\\")),
1774                Component::Normal(name) => names.push(name.to_owned()),
1775                Component::CurDir => {}
1776                Component::ParentDir => {
1777                    return Err(ToolError::unsupported_security(
1778                        "filesystem path is not lexically normalized",
1779                    ));
1780                }
1781            }
1782        }
1783        if !root.is_absolute() {
1784            return Err(ToolError::unsupported_security(
1785                "filesystem path has no absolute Windows root",
1786            ));
1787        }
1788        Ok((root, names))
1789    }
1790
1791    fn pin_route_node(
1792        route: &mut Vec<RouteNode>,
1793        path: PathBuf,
1794        link_target: Option<PathBuf>,
1795    ) -> Result<(), ToolError> {
1796        ensure_resolution_bound(route.len() + 1, 0)?;
1797        let object = open_for_identity(&path)?;
1798        pin_open_route_node(route, path, link_target, object)
1799    }
1800
1801    fn pin_open_route_node(
1802        route: &mut Vec<RouteNode>,
1803        path: PathBuf,
1804        link_target: Option<PathBuf>,
1805        object: fs::File,
1806    ) -> Result<(), ToolError> {
1807        let identity = identity(&object)?;
1808        route.push(RouteNode {
1809            path,
1810            identity,
1811            link_target,
1812            _object: object,
1813        });
1814        Ok(())
1815    }
1816
1817    fn routes_equal(left: &[RouteNode], right: &[RouteNode]) -> bool {
1818        left.len() == right.len()
1819            && left.iter().zip(right).all(|(left, right)| {
1820                paths_equal(&left.path, &right.path)
1821                    && same_route_identity(&left.identity, &right.identity)
1822                    && left.link_target == right.link_target
1823            })
1824    }
1825
1826    fn same_route_identity(left: &ObjectIdentity, right: &ObjectIdentity) -> bool {
1827        left.device == right.device && left.inode == right.inode && left.mode == right.mode
1828    }
1829
1830    fn revalidate_route(route: &[RouteNode]) -> Result<(), ToolError> {
1831        for node in route {
1832            let file = open_for_identity(&node.path).map_err(|_| {
1833                ToolError::operation_changed("prepared route component disappeared")
1834            })?;
1835            let found = identity(&file)
1836                .map_err(|_| ToolError::operation_changed("prepared route component changed"))?;
1837            if !same_route_identity(&found, &node.identity) {
1838                return Err(ToolError::operation_changed(
1839                    "prepared route component identity changed",
1840                ));
1841            }
1842            let is_reparse = opened_file_is_reparse(&file).map_err(|error| {
1843                ToolError::operation_changed(format!("prepared route component changed: {error}"))
1844            })?;
1845            if is_reparse != node.link_target.is_some() {
1846                return Err(ToolError::operation_changed(
1847                    "prepared route component type changed",
1848                ));
1849            }
1850            if let Some(expected) = &node.link_target {
1851                let found = fs::read_link(&node.path)
1852                    .map_err(|_| ToolError::operation_changed("prepared link target changed"))?;
1853                if found != *expected {
1854                    return Err(ToolError::operation_changed("prepared link target changed"));
1855                }
1856            }
1857        }
1858        Ok(())
1859    }
1860
1861    fn absolute_requested(cwd: &Path, requested: &Path) -> Result<PathBuf, ToolError> {
1862        let path = if requested.is_absolute() {
1863            requested.to_owned()
1864        } else {
1865            cwd.join(requested)
1866        };
1867        normalize_absolute(&path)
1868    }
1869
1870    fn sandbox_root(
1871        cwd: &Path,
1872        requested: &Path,
1873        absolute_request: bool,
1874    ) -> Result<PathBuf, ToolError> {
1875        if absolute_request {
1876            return native_root(requested);
1877        }
1878        let cwd = normalize_absolute(cwd)?;
1879        let resolved = resolve_path(&cwd, false)?;
1880        if !fs::metadata(&resolved.execution_path)
1881            .map_err(super::io_error)?
1882            .is_dir()
1883        {
1884            return Err(ToolError::unsupported_security(
1885                "filesystem sandbox root is not a directory",
1886            ));
1887        }
1888        Ok(resolved.execution_path)
1889    }
1890
1891    fn normalize_absolute(path: &Path) -> Result<PathBuf, ToolError> {
1892        if !path.is_absolute() {
1893            return Err(ToolError::unsupported_security(
1894                "filesystem path is not absolute",
1895            ));
1896        }
1897        let mut normalized = PathBuf::new();
1898        for component in path.components() {
1899            match component {
1900                Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
1901                Component::RootDir => normalized.push(Path::new("\\")),
1902                Component::CurDir => {}
1903                Component::Normal(name) => {
1904                    validate_windows_component(name)?;
1905                    normalized.push(name);
1906                }
1907                Component::ParentDir => {
1908                    if normalized.file_name().is_none() || !normalized.pop() {
1909                        return Err(ToolError::unsupported_security(
1910                            "filesystem path traverses above its root",
1911                        ));
1912                    }
1913                }
1914            }
1915        }
1916        Ok(normalized)
1917    }
1918
1919    fn validate_windows_component(name: &std::ffi::OsStr) -> Result<(), ToolError> {
1920        let name = name.to_string_lossy();
1921        if name.is_empty()
1922            || name.ends_with('.')
1923            || name.ends_with(' ')
1924            || name.chars().any(|character| {
1925                matches!(
1926                    character,
1927                    '\0' | '/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*'
1928                )
1929            })
1930        {
1931            Err(ToolError::unsupported_security(
1932                "filesystem path contains an unsafe Windows component",
1933            ))
1934        } else {
1935            Ok(())
1936        }
1937    }
1938
1939    fn validate_contained_path(root: &Path, path: &Path) -> Result<(), ToolError> {
1940        if components_start_with(path, root) {
1941            Ok(())
1942        } else {
1943            Err(ToolError::unsupported_security(
1944                "filesystem target escapes the prepared sandbox",
1945            ))
1946        }
1947    }
1948
1949    pub(super) fn components_start_with(path: &Path, root: &Path) -> bool {
1950        let mut path = path.components();
1951        for root_component in root.components() {
1952            let Some(path_component) = path.next() else {
1953                return false;
1954            };
1955            if !component_equal(path_component.as_os_str(), root_component.as_os_str()) {
1956                return false;
1957            }
1958        }
1959        true
1960    }
1961
1962    fn component_equal(left: &std::ffi::OsStr, right: &std::ffi::OsStr) -> bool {
1963        let normalize = |value: &std::ffi::OsStr| {
1964            let value = value.to_string_lossy();
1965            if let Some(unc) = value.strip_prefix(r"\\?\UNC\") {
1966                format!(r"\\{unc}").to_ascii_lowercase()
1967            } else {
1968                value
1969                    .strip_prefix(r"\\?\")
1970                    .unwrap_or(&value)
1971                    .to_ascii_lowercase()
1972            }
1973        };
1974        normalize(left) == normalize(right)
1975    }
1976
1977    pub(super) fn paths_equal(left: &Path, right: &Path) -> bool {
1978        let left = left.components().collect::<Vec<_>>();
1979        let right = right.components().collect::<Vec<_>>();
1980        left.len() == right.len()
1981            && left
1982                .iter()
1983                .zip(&right)
1984                .all(|(left, right)| component_equal(left.as_os_str(), right.as_os_str()))
1985    }
1986
1987    fn wide_path(path: &Path) -> Result<Vec<u16>, ToolError> {
1988        let mut wide = path.as_os_str().encode_wide().collect::<Vec<_>>();
1989        if wide.contains(&0) {
1990            return Err(ToolError::unsupported_security(
1991                "filesystem path contains an invalid character",
1992            ));
1993        }
1994        wide.push(0);
1995        Ok(wide)
1996    }
1997
1998    fn opened_path_is_reparse(path: &Path) -> Result<bool, ToolError> {
1999        let file = open_for_identity(path)?;
2000        opened_file_is_reparse(&file)
2001    }
2002
2003    fn opened_file_is_reparse(file: &fs::File) -> Result<bool, ToolError> {
2004        let mut information = BY_HANDLE_FILE_INFORMATION::default();
2005        let handle = file.as_raw_handle() as HANDLE;
2006        if unsafe { GetFileInformationByHandle(handle, &mut information) } == 0 {
2007            return Err(super::io_error(std::io::Error::last_os_error()));
2008        }
2009        Ok(information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0)
2010    }
2011
2012    fn read_supported_link(path: &Path) -> Result<PathBuf, ToolError> {
2013        // Windows std accepts symbolic-link and mount-point (junction) tags here
2014        // and rejects every other reparse tag as unsupported.
2015        fs::read_link(path).map_err(|error| {
2016            ToolError::unsupported_security(format!(
2017                "unsupported reparse point at {}: {error}",
2018                path.display()
2019            ))
2020        })
2021    }
2022
2023    fn open_for_identity(path: &Path) -> Result<fs::File, ToolError> {
2024        fs::OpenOptions::new()
2025            .access_mode(0)
2026            .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
2027            .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
2028            .open(path)
2029            .map_err(|error| {
2030                super::io_error(format!(
2031                    "CreateFileW failed for {}: {error}",
2032                    path.display()
2033                ))
2034            })
2035    }
2036
2037    fn identity(file: &fs::File) -> Result<ObjectIdentity, ToolError> {
2038        let metadata = file.metadata().map_err(super::io_error)?;
2039        let mut information = BY_HANDLE_FILE_INFORMATION::default();
2040        let handle = file.as_raw_handle() as HANDLE;
2041        if unsafe { GetFileInformationByHandle(handle, &mut information) } == 0 {
2042            return Err(super::io_error(std::io::Error::last_os_error()));
2043        }
2044        Ok(ObjectIdentity {
2045            device: u64::from(information.dwVolumeSerialNumber),
2046            inode: (u64::from(information.nFileIndexHigh) << 32)
2047                | u64::from(information.nFileIndexLow),
2048            mode: if metadata.is_dir() {
2049                0o040755
2050            } else {
2051                0o100755
2052            },
2053            size: metadata.len(),
2054        })
2055    }
2056
2057    fn content_digest(path: &Path, directory: bool) -> Result<Sha256Digest, ToolError> {
2058        if directory {
2059            serde_json::to_vec(&directory_entries(path)?)
2060                .map(|bytes| Sha256Digest::of_bytes(&bytes))
2061                .map_err(super::io_error)
2062        } else {
2063            fs::read(path)
2064                .map(|bytes| Sha256Digest::of_bytes(&bytes))
2065                .map_err(super::io_error)
2066        }
2067    }
2068
2069    fn directory_entries(path: &Path) -> Result<Vec<(String, bool)>, ToolError> {
2070        let mut entries = fs::read_dir(path)
2071            .map_err(super::io_error)?
2072            .map(|entry| {
2073                let entry = entry.map_err(super::io_error)?;
2074                let file_type = entry.file_type().map_err(super::io_error)?;
2075                Ok((
2076                    entry.file_name().to_string_lossy().into_owned(),
2077                    file_type.is_dir(),
2078                ))
2079            })
2080            .collect::<Result<Vec<_>, ToolError>>()?;
2081        entries.sort();
2082        Ok(entries)
2083    }
2084
2085    fn create_private_parents(root: &Path, parent: &Path) -> Result<(), ToolError> {
2086        validate_contained_path(root, parent)?;
2087        let mut current = PathBuf::new();
2088        for component in parent.components() {
2089            current.push(component.as_os_str());
2090            if !matches!(component, Component::Normal(_)) {
2091                continue;
2092            }
2093            if !current.exists() {
2094                fs::create_dir(&current).map_err(|error| {
2095                    super::io_error(format!(
2096                        "create_dir failed for {}: {error}",
2097                        current.display()
2098                    ))
2099                })?;
2100            }
2101            if opened_path_is_reparse(&current)? {
2102                return Err(ToolError::unsupported_security(
2103                    "prepared path contains a symlink or junction",
2104                ));
2105            }
2106        }
2107        Ok(())
2108    }
2109
2110    fn stage_sibling(target: &Path, bytes: &[u8]) -> Result<PathBuf, ToolError> {
2111        let parent = target
2112            .parent()
2113            .ok_or_else(|| ToolError::unsupported_security("target has no parent"))?;
2114        let name = target
2115            .file_name()
2116            .ok_or_else(|| ToolError::unsupported_security("target has no basename"))?;
2117        let temporary = parent.join(format!(
2118            ".{}.cookie-stage-{}-{}",
2119            name.to_string_lossy(),
2120            std::process::id(),
2121            TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
2122        ));
2123        let mut file = fs::OpenOptions::new()
2124            .write(true)
2125            .create_new(true)
2126            .open(&temporary)
2127            .map_err(|error| {
2128                super::io_error(format!(
2129                    "CreateFileW failed for staging file {}: {error}",
2130                    temporary.display()
2131                ))
2132            })?;
2133        if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
2134            let _ = fs::remove_file(&temporary);
2135            return Err(super::io_error(format!(
2136                "write or flush failed for staging file {}: {error}",
2137                temporary.display()
2138            )));
2139        }
2140        Ok(temporary)
2141    }
2142
2143    fn move_file(source: &Path, target: &Path, replace: bool) -> Result<(), ToolError> {
2144        let source_wide = wide_path(source)?;
2145        let target_wide = wide_path(target)?;
2146        // A replacing publish prefers the superseding POSIX rename: classic
2147        // MoveFileExW unlinks the target before linking the source, so a reader
2148        // outside the sandbox that opens the published path by name can find it
2149        // absent mid-write. Every failure falls through to the classic call
2150        // below, which keeps the existing error reporting and its own
2151        // ERROR_ACCESS_DENIED retry. A non-replacing publish must fail when the
2152        // target exists, which this rename cannot express, so it is unchanged.
2153        if replace && rename_over_open_target(source, &target_wide).is_ok() {
2154            return Ok(());
2155        }
2156        let flags = MOVEFILE_WRITE_THROUGH
2157            | if replace {
2158                MOVEFILE_REPLACE_EXISTING
2159            } else {
2160                0
2161            };
2162        if unsafe { MoveFileExW(source_wide.as_ptr(), target_wide.as_ptr(), flags) } == 0 {
2163            let mut error = std::io::Error::last_os_error();
2164            if replace && error.raw_os_error() == Some(ERROR_ACCESS_DENIED as i32) {
2165                // NTFS classic rename-over rejects an open destination even when
2166                // it shares DELETE. Keep the pins and use a write-through POSIX
2167                // rename, rather than weakening identity or durability checks.
2168                match rename_over_open_target(source, &target_wide) {
2169                    Ok(()) => return Ok(()),
2170                    Err(rename_error) => error = rename_error,
2171                }
2172            }
2173            Err(ToolError::operation_changed(format!(
2174                "atomic rename failed from {} to {}: {}",
2175                source.display(),
2176                target.display(),
2177                error
2178            )))
2179        } else {
2180            Ok(())
2181        }
2182    }
2183
2184    fn rename_over_open_target(source: &Path, target: &[u16]) -> std::io::Result<()> {
2185        const REPLACE_IF_EXISTS: u32 = 0x1;
2186        const POSIX_SEMANTICS: u32 = 0x2;
2187
2188        let source = fs::OpenOptions::new()
2189            .access_mode(DELETE)
2190            .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
2191            .custom_flags(
2192                FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_WRITE_THROUGH,
2193            )
2194            .open(source)?;
2195        let filename_bytes = u32::try_from(std::mem::size_of_val(&target[..target.len() - 1]))
2196            .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?;
2197        let buffer_bytes =
2198            std::mem::offset_of!(FILE_RENAME_INFO, FileName) + std::mem::size_of_val(target);
2199        let buffer_length = u32::try_from(buffer_bytes)
2200            .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?;
2201        let mut buffer = vec![
2202            FILE_RENAME_INFO::default();
2203            buffer_bytes.div_ceil(std::mem::size_of::<FILE_RENAME_INFO>())
2204        ];
2205        let info = buffer.as_mut_ptr();
2206        // The header array provides FILE_RENAME_INFO alignment and enough storage
2207        // for its variable-length UTF-16 tail, including the terminator.
2208        unsafe {
2209            (*info).Anonymous = FILE_RENAME_INFO_0 {
2210                Flags: REPLACE_IF_EXISTS | POSIX_SEMANTICS,
2211            };
2212            (*info).RootDirectory = std::ptr::null_mut();
2213            (*info).FileNameLength = filename_bytes;
2214            std::ptr::copy_nonoverlapping(
2215                target.as_ptr(),
2216                std::ptr::addr_of_mut!((*info).FileName).cast::<u16>(),
2217                target.len(),
2218            );
2219            if SetFileInformationByHandle(
2220                source.as_raw_handle() as HANDLE,
2221                FileRenameInfoEx,
2222                info.cast(),
2223                buffer_length,
2224            ) == 0
2225            {
2226                return Err(std::io::Error::last_os_error());
2227            }
2228        }
2229        Ok(())
2230    }
2231
2232    fn append_path(bytes: &mut Vec<u8>, path: &Path) {
2233        let encoded = path.as_os_str().to_string_lossy();
2234        bytes.extend_from_slice(&(encoded.len() as u64).to_be_bytes());
2235        bytes.extend_from_slice(encoded.as_bytes());
2236    }
2237
2238    fn append_route(bytes: &mut Vec<u8>, route: &[RouteNode]) {
2239        bytes.extend_from_slice(&(route.len() as u64).to_be_bytes());
2240        for node in route {
2241            append_path(bytes, &node.path);
2242            bytes.extend_from_slice(&node.identity.device.to_be_bytes());
2243            bytes.extend_from_slice(&node.identity.inode.to_be_bytes());
2244            bytes.extend_from_slice(&node.identity.mode.to_be_bytes());
2245            match &node.link_target {
2246                Some(target) => {
2247                    bytes.push(1);
2248                    append_path(bytes, target);
2249                }
2250                None => bytes.push(0),
2251            }
2252        }
2253    }
2254}
2255
2256#[cfg(windows)]
2257pub use windows::*;
2258
2259fn io_error(error: impl std::fmt::Display) -> cookie_agent_engine::ToolError {
2260    cookie_agent_engine::ToolError::execution(error.to_string())
2261}
2262
2263#[cfg(all(test, unix))]
2264mod tests;
2265
2266#[cfg(all(test, windows))]
2267mod windows_tests {
2268    use std::{fs, path::Path, process::Command};
2269
2270    use cookie_agent_engine::ToolError;
2271
2272    use super::{
2273        PreparedTarget, components_start_with, ensure_resolution_bound, paths_equal,
2274        prepare_existing, prepare_target,
2275    };
2276
2277    #[test]
2278    fn windows_path_comparisons_are_case_insensitive_and_component_bounded() {
2279        let root = std::path::Path::new(r"c:\users\runneradmin\work");
2280        let target = std::path::Path::new(r"\\?\C:\Users\RUNNERADMIN\Work\nested\file.txt");
2281        assert!(components_start_with(target, root));
2282        assert!(paths_equal(
2283            std::path::Path::new(r"C:\Users\RunnerAdmin\Work"),
2284            root
2285        ));
2286        assert!(!components_start_with(
2287            std::path::Path::new(r"C:\Users\runneradmin-other\file.txt"),
2288            std::path::Path::new(r"C:\Users\runneradmin")
2289        ));
2290        assert!(components_start_with(
2291            std::path::Path::new(r"\\?\UNC\server\share\root\file.txt"),
2292            std::path::Path::new(r"\\server\share\root")
2293        ));
2294    }
2295
2296    #[test]
2297    fn windows_capability_enforces_prefix_and_publishes_atomically() {
2298        let root = tempfile::tempdir().expect("sandbox");
2299        let outside = tempfile::tempdir().expect("outside");
2300        fs::write(root.path().join("existing.txt"), "old").expect("existing");
2301        let existing = prepare_existing(root.path(), std::path::Path::new("existing.txt"))
2302            .expect("prepare existing");
2303        existing.replace_atomically(b"new").expect("replace");
2304        assert_eq!(fs::read(root.path().join("existing.txt")).unwrap(), b"new");
2305
2306        let PreparedTarget::Absent(absent) =
2307            prepare_target(root.path(), std::path::Path::new("nested/new.txt"))
2308                .expect("prepare absent")
2309        else {
2310            panic!("absent target")
2311        };
2312        absent.create_atomically(b"created").expect("create");
2313        assert_eq!(
2314            fs::read(root.path().join("nested/new.txt")).unwrap(),
2315            b"created"
2316        );
2317
2318        let outside_path = outside.path().join("external.txt");
2319        fs::write(&outside_path, "external").expect("outside fixture");
2320        let outside_from_root =
2321            prepare_existing(root.path(), &outside_path).expect("prepare absolute outside target");
2322        let outside_direct = prepare_existing(outside.path(), std::path::Path::new("external.txt"))
2323            .expect("prepare direct outside target");
2324        assert!(paths_equal(&outside_from_root.display_path, &outside_path));
2325        assert_eq!(outside_from_root.identity, outside_direct.identity);
2326        assert!(!components_start_with(
2327            &outside_from_root.display_path,
2328            &root.path().canonicalize().expect("canonical sandbox")
2329        ));
2330
2331        let relative_escape = Path::new("..").join(
2332            outside
2333                .path()
2334                .file_name()
2335                .expect("outside directory basename"),
2336        );
2337        assert!(matches!(
2338            prepare_target(root.path(), &relative_escape.join("external.txt")),
2339            Err(ToolError::UnsupportedSecurity(_))
2340        ));
2341    }
2342
2343    #[test]
2344    fn windows_replacement_preserves_open_preimage_and_delete_sharing() {
2345        use std::{io::Read as _, os::windows::fs::OpenOptionsExt as _};
2346        use windows_sys::Win32::Storage::FileSystem::{
2347            FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
2348        };
2349
2350        let root = tempfile::tempdir().expect("sandbox");
2351        let path = root.path().join("target.txt");
2352        fs::write(&path, "original").expect("target");
2353        let mut held = fs::OpenOptions::new()
2354            .read(true)
2355            .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
2356            .open(&path)
2357            .expect("held destination");
2358        let target = prepare_existing(root.path(), Path::new("target.txt")).expect("prepare");
2359        target
2360            .replace_atomically(b"replacement")
2361            .expect("replace open destination");
2362        let mut preimage = String::new();
2363        held.read_to_string(&mut preimage)
2364            .expect("read held preimage");
2365        assert_eq!(preimage, "original");
2366        assert_eq!(fs::read(&path).unwrap(), b"replacement");
2367
2368        let target = prepare_existing(root.path(), Path::new("target.txt")).expect("prepare again");
2369        let _deny_delete = fs::OpenOptions::new()
2370            .read(true)
2371            .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
2372            .open(&path)
2373            .expect("deny-delete holder");
2374        assert!(matches!(
2375            target.replace_atomically(b"forbidden"),
2376            Err(ToolError::OperationChanged(_))
2377        ));
2378        assert_eq!(fs::read(&path).unwrap(), b"replacement");
2379        assert!(fs::read_dir(root.path()).unwrap().all(|entry| {
2380            !entry
2381                .unwrap()
2382                .file_name()
2383                .to_string_lossy()
2384                .contains("cookie-stage")
2385        }));
2386    }
2387
2388    #[test]
2389    fn windows_capability_resolves_links_but_keeps_a_lexical_display_path() {
2390        let root = tempfile::tempdir().expect("sandbox");
2391        fs::create_dir(root.path().join("target")).expect("target directory");
2392        fs::write(root.path().join("target/file.txt"), "inside").expect("target");
2393        let link = root.path().join("link");
2394        if let Err(error) = std::os::windows::fs::symlink_dir("target", &link) {
2395            if error.kind() == std::io::ErrorKind::PermissionDenied {
2396                return;
2397            }
2398            panic!("create symlink: {error}");
2399        }
2400        let target = prepare_existing(root.path(), Path::new(r".\link\file.txt"))
2401            .expect("prepare through link");
2402        assert!(paths_equal(
2403            &target.display_path,
2404            &root.path().join("link/file.txt")
2405        ));
2406        assert_eq!(
2407            target.verified_bytes().expect("read destination"),
2408            b"inside"
2409        );
2410    }
2411
2412    #[test]
2413    fn windows_relative_link_parent_components_resolve_from_the_link_parent() {
2414        let root = tempfile::tempdir().expect("sandbox");
2415        fs::create_dir_all(root.path().join("real/directory")).expect("directory");
2416        fs::create_dir(root.path().join("real/value")).expect("value directory");
2417        fs::write(root.path().join("real/value/file.txt"), "value").expect("target");
2418        let directory = root.path().join("directory");
2419        if let Err(error) =
2420            std::os::windows::fs::symlink_dir(Path::new(r"real\directory"), &directory)
2421        {
2422            if error.kind() == std::io::ErrorKind::PermissionDenied {
2423                return;
2424            }
2425            panic!("create directory symlink: {error}");
2426        }
2427        let link = root.path().join("link");
2428        if let Err(error) =
2429            std::os::windows::fs::symlink_dir(Path::new(r"directory\..\value"), &link)
2430        {
2431            if error.kind() == std::io::ErrorKind::PermissionDenied {
2432                return;
2433            }
2434            panic!("create symlink: {error}");
2435        }
2436        let target = prepare_existing(root.path(), Path::new("link/file.txt"))
2437            .expect("prepare through relative target");
2438        assert_eq!(target.verified_bytes().expect("read destination"), b"value");
2439    }
2440
2441    #[test]
2442    fn windows_link_parent_cannot_traverse_through_a_regular_file() {
2443        let root = tempfile::tempdir().expect("sandbox");
2444        fs::write(root.path().join("file"), "not a directory").expect("file");
2445        fs::create_dir(root.path().join("value")).expect("value directory");
2446        fs::write(root.path().join("value/result.txt"), "wrong").expect("result");
2447        let link = root.path().join("link");
2448        if let Err(error) = std::os::windows::fs::symlink_dir(Path::new(r"file\..\value"), &link) {
2449            if error.kind() == std::io::ErrorKind::PermissionDenied {
2450                return;
2451            }
2452            panic!("create symlink: {error}");
2453        }
2454        assert!(matches!(
2455            prepare_existing(root.path(), Path::new("link/result.txt")),
2456            Err(ToolError::Failed(_))
2457        ));
2458    }
2459
2460    #[test]
2461    fn windows_link_target_dot_suffix_requires_an_existing_directory() {
2462        let root = tempfile::tempdir().expect("sandbox");
2463        fs::write(root.path().join("file"), "not a directory").expect("file");
2464        let link = root.path().join("link");
2465        if let Err(error) = std::os::windows::fs::symlink_file(Path::new(r"file\."), &link) {
2466            if error.kind() == std::io::ErrorKind::PermissionDenied {
2467                return;
2468            }
2469            panic!("create symlink: {error}");
2470        }
2471        assert!(matches!(
2472            prepare_existing(root.path(), Path::new("link")),
2473            Err(ToolError::Failed(_))
2474        ));
2475    }
2476
2477    #[test]
2478    fn windows_dangling_link_trailing_separator_cannot_create_a_regular_file() {
2479        let root = tempfile::tempdir().expect("sandbox");
2480        let link = root.path().join("link");
2481        if let Err(error) = std::os::windows::fs::symlink_dir(Path::new(r"missing\"), &link) {
2482            if error.kind() == std::io::ErrorKind::PermissionDenied {
2483                return;
2484            }
2485            panic!("create symlink: {error}");
2486        }
2487        assert!(matches!(
2488            prepare_target(root.path(), Path::new("link")),
2489            Err(ToolError::Failed(_))
2490        ));
2491        assert!(!root.path().join("missing").exists());
2492    }
2493
2494    #[test]
2495    fn windows_resolution_component_budget_is_bounded() {
2496        assert!(ensure_resolution_bound(4095, 1).is_ok());
2497        assert!(matches!(
2498            ensure_resolution_bound(4096, 1),
2499            Err(ToolError::ResourceLimit(_))
2500        ));
2501        assert!(matches!(
2502            ensure_resolution_bound(usize::MAX, 1),
2503            Err(ToolError::ResourceLimit(_))
2504        ));
2505    }
2506
2507    #[test]
2508    fn windows_relative_link_destination_must_remain_in_the_resolved_sandbox() {
2509        let root = tempfile::tempdir().expect("sandbox");
2510        let outside = tempfile::tempdir().expect("outside");
2511        fs::write(outside.path().join("file.txt"), "outside").expect("target");
2512        let link = root.path().join("link");
2513        if let Err(error) = std::os::windows::fs::symlink_dir(outside.path(), &link) {
2514            if error.kind() == std::io::ErrorKind::PermissionDenied {
2515                return;
2516            }
2517            panic!("create symlink: {error}");
2518        }
2519        assert!(matches!(
2520            prepare_existing(root.path(), Path::new("link/file.txt")),
2521            Err(ToolError::UnsupportedSecurity(_))
2522        ));
2523    }
2524
2525    #[test]
2526    fn windows_dangling_directory_link_supports_atomic_destination_creation() {
2527        let root = tempfile::tempdir().expect("sandbox");
2528        let link = root.path().join("link");
2529        if let Err(error) = std::os::windows::fs::symlink_dir("destination", &link) {
2530            if error.kind() == std::io::ErrorKind::PermissionDenied {
2531                return;
2532            }
2533            panic!("create symlink: {error}");
2534        }
2535        let PreparedTarget::Absent(target) =
2536            prepare_target(root.path(), Path::new("link/nested/file.txt")).expect("prepare")
2537        else {
2538            panic!("absent destination")
2539        };
2540        assert!(paths_equal(
2541            &target.display_path,
2542            &root.path().join("link/nested/file.txt")
2543        ));
2544        target.create_atomically(b"created").expect("create");
2545        assert_eq!(
2546            fs::read(root.path().join("destination/nested/file.txt")).expect("destination"),
2547            b"created"
2548        );
2549        let slash_link = root.path().join("slash-link");
2550        std::os::windows::fs::symlink_dir("other\\", &slash_link).expect("directory suffix");
2551        let PreparedTarget::Absent(target) =
2552            prepare_target(root.path(), Path::new("slash-link/nested/file.txt"))
2553                .expect("prepare child")
2554        else {
2555            panic!("absent child destination")
2556        };
2557        target.create_atomically(b"child").expect("create child");
2558        assert_eq!(
2559            fs::read(root.path().join("other/nested/file.txt")).unwrap(),
2560            b"child"
2561        );
2562        assert!(fs::read_link(slash_link).is_ok());
2563    }
2564
2565    #[test]
2566    fn windows_link_target_change_is_operation_changed() {
2567        let root = tempfile::tempdir().expect("sandbox");
2568        fs::create_dir(root.path().join("first")).expect("first");
2569        fs::create_dir(root.path().join("second")).expect("second");
2570        fs::write(root.path().join("first/file.txt"), "first").expect("first file");
2571        fs::write(root.path().join("second/file.txt"), "second").expect("second file");
2572        let link = root.path().join("link");
2573        if let Err(error) = std::os::windows::fs::symlink_dir("first", &link) {
2574            if error.kind() == std::io::ErrorKind::PermissionDenied {
2575                return;
2576            }
2577            panic!("create symlink: {error}");
2578        }
2579        let target =
2580            prepare_existing(root.path(), Path::new("link/file.txt")).expect("prepare target");
2581        fs::remove_dir(&link).expect("remove old link");
2582        std::os::windows::fs::symlink_dir("second", &link).expect("replace link");
2583        assert!(matches!(
2584            target.revalidate(),
2585            Err(ToolError::OperationChanged(_))
2586        ));
2587    }
2588
2589    #[test]
2590    fn windows_route_ancestor_replacement_is_operation_changed() {
2591        let root = tempfile::tempdir().expect("sandbox");
2592        fs::create_dir(root.path().join("ancestor")).expect("ancestor");
2593        fs::write(root.path().join("ancestor/file.txt"), "same").expect("file");
2594        let target =
2595            prepare_existing(root.path(), Path::new("ancestor/file.txt")).expect("prepare target");
2596        // NTFS forbids renaming a directory with open descendants. Move the
2597        // pinned leaf out first, then put that same object under the new ancestor.
2598        fs::rename(
2599            root.path().join("ancestor/file.txt"),
2600            root.path().join("held-file.txt"),
2601        )
2602        .expect("move pinned leaf out");
2603        fs::rename(root.path().join("ancestor"), root.path().join("displaced"))
2604            .expect("displace ancestor");
2605        fs::create_dir(root.path().join("ancestor")).expect("replacement ancestor");
2606        fs::rename(
2607            root.path().join("held-file.txt"),
2608            root.path().join("ancestor/file.txt"),
2609        )
2610        .expect("restore original leaf");
2611        let replacement = prepare_existing(root.path(), Path::new("ancestor/file.txt"))
2612            .expect("prepare replacement route");
2613        assert_eq!(target.identity, replacement.identity);
2614        assert!(matches!(
2615            target.revalidate(),
2616            Err(ToolError::OperationChanged(_))
2617        ));
2618    }
2619
2620    #[test]
2621    fn windows_route_identity_ignores_mutable_directory_size() {
2622        let root = tempfile::tempdir().expect("sandbox");
2623        fs::create_dir(root.path().join("ancestor")).expect("ancestor");
2624        fs::write(root.path().join("ancestor/file.txt"), "value").expect("file");
2625        let target =
2626            prepare_existing(root.path(), Path::new("ancestor/file.txt")).expect("prepare target");
2627        fs::write(root.path().join("ancestor/sibling.txt"), "sibling").expect("sibling");
2628        target.revalidate().expect("stable route identity");
2629    }
2630
2631    #[test]
2632    fn windows_junctions_are_supported_route_components() {
2633        let root = tempfile::tempdir().expect("sandbox");
2634        fs::create_dir(root.path().join("target")).expect("target");
2635        fs::write(root.path().join("target/file.txt"), "junction").expect("file");
2636        let junction = root.path().join("junction");
2637        let output = Command::new("cmd.exe")
2638            .args(["/D", "/C", "mklink", "/J"])
2639            .arg(&junction)
2640            .arg(root.path().join("target"))
2641            .output()
2642            .expect("run mklink");
2643        assert!(
2644            output.status.success(),
2645            "mklink failed: {}",
2646            String::from_utf8_lossy(&output.stderr)
2647        );
2648        let target = prepare_existing(root.path(), Path::new("junction/file.txt"))
2649            .expect("prepare through junction");
2650        assert_eq!(
2651            target.verified_bytes().expect("read destination"),
2652            b"junction"
2653        );
2654    }
2655
2656    #[test]
2657    fn windows_link_loops_are_bounded() {
2658        let root = tempfile::tempdir().expect("sandbox");
2659        let link = root.path().join("loop");
2660        if let Err(error) = std::os::windows::fs::symlink_dir("loop", &link) {
2661            if error.kind() == std::io::ErrorKind::PermissionDenied {
2662                return;
2663            }
2664            panic!("create symlink: {error}");
2665        }
2666        assert!(matches!(
2667            prepare_target(root.path(), Path::new("loop/file.txt")),
2668            Err(ToolError::UnsupportedSecurity(_))
2669        ));
2670    }
2671
2672    #[test]
2673    fn windows_absent_leaf_insertion_is_operation_changed() {
2674        let root = tempfile::tempdir().expect("sandbox");
2675        let PreparedTarget::Absent(absent) =
2676            prepare_target(root.path(), std::path::Path::new("target.txt")).expect("prepare")
2677        else {
2678            panic!("absent target")
2679        };
2680        fs::write(root.path().join("target.txt"), "attacker").expect("insert target");
2681        assert!(matches!(
2682            absent.create_atomically(b"new"),
2683            Err(ToolError::OperationChanged(_))
2684        ));
2685        assert_eq!(
2686            fs::read_to_string(root.path().join("target.txt")).unwrap(),
2687            "attacker"
2688        );
2689    }
2690
2691    #[test]
2692    fn windows_missing_subtree_insertion_is_operation_changed() {
2693        let root = tempfile::tempdir().expect("sandbox");
2694        let PreparedTarget::Absent(absent) =
2695            prepare_target(root.path(), std::path::Path::new("one/two/target.txt"))
2696                .expect("prepare")
2697        else {
2698            panic!("absent target")
2699        };
2700        fs::create_dir(root.path().join("one")).expect("insert subtree");
2701        fs::write(root.path().join("one/attacker"), "attacker").expect("attacker");
2702        assert!(matches!(
2703            absent.create_atomically(b"new"),
2704            Err(ToolError::OperationChanged(_))
2705        ));
2706        assert!(!root.path().join("one/two/target.txt").exists());
2707        assert_eq!(
2708            fs::read_to_string(root.path().join("one/attacker")).unwrap(),
2709            "attacker"
2710        );
2711    }
2712}