1#[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 (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 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 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 pub struct PreparedChained {
139 pub target: PreparedExisting,
140 pub original_digest: Sha256Digest,
141 pub predecessor_digest: Sha256Digest,
142 }
143
144 pub(super) enum DisplacedExpectation {
146 PreparedIdentity,
148 PredecessorContent(Sha256Digest),
151 }
152
153 impl PreparedChained {
154 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 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(¤t, &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(¤t, &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(¤t, &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 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(¤t, &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(¤t, component, Mode::from_raw_mode(0o700))
955 .map_err(super::io_error)?;
956 let next = File::from(
957 rustix::fs::openat(¤t, component, flags, Mode::empty())
958 .map_err(super::io_error)?,
959 );
960 rustix::fs::fsync(¤t).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 ¤t,
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(¤t).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 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 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 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(¤t).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(¤t, &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 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(¤t).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(¤t)? {
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 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 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 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 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}