1use std::io::{self, Write};
2
3#[cfg(feature = "fs")]
4use std::path::{Path, PathBuf};
5#[cfg(feature = "fs")]
6use std::sync::atomic::{AtomicU64, Ordering};
7
8use super::{EncodeError, EncodeLimits, encode_with_limits};
9use crate::{CommitCertainty, Pack, PackArchiveBytes};
10
11#[derive(Debug, Clone, Copy, Eq, PartialEq)]
13pub enum StreamWritePhase {
14 Write,
15 Flush,
16 Complete,
17}
18
19#[derive(Debug, Clone, Copy, Eq, PartialEq)]
21pub struct StreamWriteReceipt {
22 visible_prefix: u64,
23}
24
25impl StreamWriteReceipt {
26 pub const fn visible_prefix(&self) -> u64 {
27 self.visible_prefix
28 }
29}
30
31#[derive(Debug, thiserror::Error)]
33#[error(
34 "Pack Archive stream write failed during {phase:?} after a {visible_prefix}-byte visible prefix: {source}"
35)]
36pub struct StreamWriteError {
37 phase: StreamWritePhase,
38 visible_prefix: u64,
39 commit_certainty: CommitCertainty,
40 #[source]
41 source: io::Error,
42}
43
44impl StreamWriteError {
45 pub const fn phase(&self) -> StreamWritePhase {
46 self.phase
47 }
48
49 pub const fn visible_prefix(&self) -> u64 {
50 self.visible_prefix
51 }
52
53 pub const fn commit_certainty(&self) -> CommitCertainty {
54 self.commit_certainty
55 }
56
57 pub const fn io_error(&self) -> &io::Error {
58 &self.source
59 }
60}
61
62pub fn write(
64 mut writer: impl Write,
65 archive: &PackArchiveBytes,
66) -> Result<StreamWriteReceipt, StreamWriteError> {
67 let mut visible_prefix = 0usize;
68 while visible_prefix < archive.as_slice().len() {
69 match writer.write(&archive.as_slice()[visible_prefix..]) {
70 Ok(0) => {
71 return Err(StreamWriteError {
72 phase: StreamWritePhase::Write,
73 visible_prefix: visible_prefix as u64,
74 commit_certainty: CommitCertainty::NotCommitted,
75 source: io::Error::new(
76 io::ErrorKind::WriteZero,
77 "failed to write the complete Pack Archive",
78 ),
79 });
80 }
81 Ok(written) => visible_prefix += written,
82 Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
83 Err(source) => {
84 return Err(StreamWriteError {
85 phase: StreamWritePhase::Write,
86 visible_prefix: visible_prefix as u64,
87 commit_certainty: CommitCertainty::NotCommitted,
88 source,
89 });
90 }
91 }
92 }
93 if let Err(source) = writer.flush() {
94 return Err(StreamWriteError {
95 phase: StreamWritePhase::Flush,
96 visible_prefix: visible_prefix as u64,
97 commit_certainty: CommitCertainty::Indeterminate,
98 source,
99 });
100 }
101 Ok(StreamWriteReceipt {
102 visible_prefix: visible_prefix as u64,
103 })
104}
105
106#[derive(Debug, thiserror::Error)]
108#[non_exhaustive]
109pub enum WritePackError {
110 #[error(transparent)]
111 Encode(#[from] EncodeError),
112 #[error("encoded Pack Archive could not be written: {source}")]
113 Write {
114 archive: PackArchiveBytes,
115 #[source]
116 source: StreamWriteError,
117 },
118}
119
120pub fn write_pack(writer: impl Write, pack: &Pack) -> Result<StreamWriteReceipt, WritePackError> {
122 write_pack_with_limits(writer, pack, EncodeLimits::reference_v1())
123}
124
125pub fn write_pack_with_limits(
127 writer: impl Write,
128 pack: &Pack,
129 encode_limits: EncodeLimits,
130) -> Result<StreamWriteReceipt, WritePackError> {
131 let archive = encode_with_limits(pack, encode_limits)?;
132 match write(writer, &archive) {
133 Ok(receipt) => Ok(receipt),
134 Err(source) => Err(WritePackError::Write { archive, source }),
135 }
136}
137
138#[cfg(feature = "fs")]
140#[derive(Debug, Clone, Copy, Eq, PartialEq)]
141pub enum FileWritePolicy {
142 CreateNew,
143 ReplaceExisting,
144}
145
146#[cfg(feature = "fs")]
148#[derive(Debug, Clone, Copy, Eq, PartialEq)]
149pub enum FileWritePhase {
150 Policy,
151 StagingCreate,
152 StagingWrite,
153 StagingFlush,
154 Commit,
155 StagingCleanup,
156 Complete,
157}
158
159#[cfg(feature = "fs")]
161#[derive(Debug, Clone, Copy, Eq, PartialEq)]
162pub enum StagingResidueStatus {
163 Absent,
164 Present,
165 Indeterminate,
166}
167
168#[cfg(feature = "fs")]
170#[derive(Debug, thiserror::Error)]
171#[non_exhaustive]
172pub enum FileWriteErrorCause {
173 #[error("the platform cannot guarantee the requested {0:?} write policy")]
174 UnsupportedPolicy(FileWritePolicy),
175 #[error(transparent)]
176 Io(#[from] io::Error),
177}
178
179#[cfg(feature = "fs")]
181#[derive(Debug, Clone, Eq, PartialEq)]
182pub struct FileWriteReceipt {
183 destination: PathBuf,
184 policy: FileWritePolicy,
185 byte_length: u64,
186}
187
188#[cfg(feature = "fs")]
189impl FileWriteReceipt {
190 pub fn destination(&self) -> &Path {
191 &self.destination
192 }
193
194 pub const fn policy(&self) -> FileWritePolicy {
195 self.policy
196 }
197
198 pub const fn byte_length(&self) -> u64 {
199 self.byte_length
200 }
201}
202
203#[cfg(feature = "fs")]
205#[derive(Debug, thiserror::Error)]
206#[error(
207 "Pack Archive write to {destination:?} failed during {phase:?} with {commit_certainty:?} certainty: {source}"
208)]
209pub struct FileWriteError {
210 destination: PathBuf,
211 policy: FileWritePolicy,
212 byte_length: u64,
213 phase: FileWritePhase,
214 staging_residue: Option<PathBuf>,
215 staging_residue_status: StagingResidueStatus,
216 commit_certainty: CommitCertainty,
217 #[source]
218 source: FileWriteErrorCause,
219}
220
221#[cfg(feature = "fs")]
222impl FileWriteError {
223 pub fn destination(&self) -> &Path {
224 &self.destination
225 }
226
227 pub const fn policy(&self) -> FileWritePolicy {
228 self.policy
229 }
230
231 pub const fn byte_length(&self) -> u64 {
232 self.byte_length
233 }
234
235 pub const fn phase(&self) -> FileWritePhase {
236 self.phase
237 }
238
239 pub fn staging_residue(&self) -> Option<&Path> {
240 self.staging_residue.as_deref()
241 }
242
243 pub const fn staging_residue_status(&self) -> StagingResidueStatus {
244 self.staging_residue_status
245 }
246
247 pub const fn commit_certainty(&self) -> CommitCertainty {
248 self.commit_certainty
249 }
250
251 pub const fn cause(&self) -> &FileWriteErrorCause {
252 &self.source
253 }
254}
255
256#[cfg(feature = "fs")]
257fn write_error(
258 destination: &Path,
259 policy: FileWritePolicy,
260 byte_length: u64,
261 phase: FileWritePhase,
262 staging_residue: Option<PathBuf>,
263 commit_certainty: CommitCertainty,
264 source: impl Into<FileWriteErrorCause>,
265) -> FileWriteError {
266 let staging_residue_status = staging_residue
267 .as_deref()
268 .map(observe_staging_residue)
269 .unwrap_or(StagingResidueStatus::Absent);
270 let staging_residue = if staging_residue_status == StagingResidueStatus::Absent {
271 None
272 } else {
273 staging_residue
274 };
275 FileWriteError {
276 destination: destination.to_owned(),
277 policy,
278 byte_length,
279 phase,
280 staging_residue,
281 staging_residue_status,
282 commit_certainty,
283 source: source.into(),
284 }
285}
286
287#[cfg(feature = "fs")]
289pub fn write_file(
290 destination: impl AsRef<Path>,
291 archive: &PackArchiveBytes,
292 policy: FileWritePolicy,
293) -> Result<FileWriteReceipt, FileWriteError> {
294 write_file_before_commit(destination.as_ref(), archive, policy, |_| {})
295}
296
297#[cfg(feature = "fs")]
298fn write_file_before_commit(
299 destination: &Path,
300 archive: &PackArchiveBytes,
301 policy: FileWritePolicy,
302 before_commit: impl FnOnce(&Path),
303) -> Result<FileWriteReceipt, FileWriteError> {
304 let byte_length = archive.len();
305 if policy == FileWritePolicy::ReplaceExisting && !replace_existing_supported() {
306 return Err(write_error(
307 destination,
308 policy,
309 byte_length,
310 FileWritePhase::Policy,
311 None,
312 CommitCertainty::NotCommitted,
313 FileWriteErrorCause::UnsupportedPolicy(policy),
314 ));
315 }
316
317 let (staging, mut file) = create_staging(destination).map_err(|source| {
318 write_error(
319 destination,
320 policy,
321 byte_length,
322 FileWritePhase::StagingCreate,
323 None,
324 CommitCertainty::NotCommitted,
325 source,
326 )
327 })?;
328 if let Err((phase, source)) = write_staging(&mut file, archive.as_slice()) {
329 drop(file);
330 return Err(write_error(
331 destination,
332 policy,
333 byte_length,
334 phase,
335 Some(staging),
336 CommitCertainty::NotCommitted,
337 source,
338 ));
339 }
340 drop(file);
341 before_commit(&staging);
342
343 if let Err(source) = commit_staging(&staging, destination, policy) {
344 return Err(write_error(
345 destination,
346 policy,
347 byte_length,
348 FileWritePhase::Commit,
349 Some(staging),
350 CommitCertainty::NotCommitted,
351 source,
352 ));
353 }
354 if let Err(source) = std::fs::remove_file(&staging)
355 && source.kind() != io::ErrorKind::NotFound
356 {
357 return Err(write_error(
358 destination,
359 policy,
360 byte_length,
361 FileWritePhase::StagingCleanup,
362 Some(staging),
363 CommitCertainty::Committed,
364 source,
365 ));
366 }
367
368 Ok(FileWriteReceipt {
369 destination: destination.to_owned(),
370 policy,
371 byte_length,
372 })
373}
374
375#[cfg(feature = "fs")]
376fn create_staging(destination: &Path) -> io::Result<(PathBuf, std::fs::File)> {
377 static NEXT_STAGE: AtomicU64 = AtomicU64::new(0);
378 const ATTEMPTS: usize = 128;
379
380 let parent = destination.parent().unwrap_or_else(|| Path::new("."));
381 let file_name = destination.file_name().ok_or_else(|| {
382 io::Error::new(io::ErrorKind::InvalidInput, "destination has no file name")
383 })?;
384 for _ in 0..ATTEMPTS {
385 let sequence = NEXT_STAGE.fetch_add(1, Ordering::Relaxed);
386 let mut staging_name = file_name.to_os_string();
387 staging_name.push(format!(
388 ".typst-pack-stage-{}-{sequence}",
389 std::process::id()
390 ));
391 let staging = parent.join(staging_name);
392 match std::fs::OpenOptions::new()
393 .write(true)
394 .create_new(true)
395 .open(&staging)
396 {
397 Ok(file) => return Ok((staging, file)),
398 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
399 Err(error) => return Err(error),
400 }
401 }
402 Err(io::Error::new(
403 io::ErrorKind::AlreadyExists,
404 "could not allocate a unique same-directory staging file",
405 ))
406}
407
408#[cfg(feature = "fs")]
409fn observe_staging_residue(staging: &Path) -> StagingResidueStatus {
410 match std::fs::symlink_metadata(staging) {
411 Ok(_) => StagingResidueStatus::Present,
412 Err(error) if error.kind() == io::ErrorKind::NotFound => StagingResidueStatus::Absent,
413 Err(_) => StagingResidueStatus::Indeterminate,
414 }
415}
416
417#[cfg(feature = "fs")]
418fn write_staging(writer: &mut impl Write, bytes: &[u8]) -> Result<(), (FileWritePhase, io::Error)> {
419 let mut written = 0;
420 while written < bytes.len() {
421 match writer.write(&bytes[written..]) {
422 Ok(0) => {
423 return Err((
424 FileWritePhase::StagingWrite,
425 io::Error::new(
426 io::ErrorKind::WriteZero,
427 "failed to write complete staging file",
428 ),
429 ));
430 }
431 Ok(count) => written += count,
432 Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
433 Err(error) => return Err((FileWritePhase::StagingWrite, error)),
434 }
435 }
436 writer
437 .flush()
438 .map_err(|error| (FileWritePhase::StagingFlush, error))
439}
440
441#[cfg(feature = "fs")]
442fn commit_staging(staging: &Path, destination: &Path, policy: FileWritePolicy) -> io::Result<()> {
443 match policy {
444 FileWritePolicy::CreateNew => std::fs::hard_link(staging, destination),
445 FileWritePolicy::ReplaceExisting => replace_existing(staging, destination),
446 }
447}
448
449#[cfg(feature = "fs")]
450const fn replace_existing_supported() -> bool {
451 cfg!(any(
452 target_os = "linux",
453 target_os = "android",
454 target_os = "macos",
455 target_os = "ios"
456 ))
457}
458
459#[cfg(all(feature = "fs", any(target_os = "linux", target_os = "android")))]
460fn replace_existing(staging: &Path, destination: &Path) -> io::Result<()> {
461 use std::ffi::CString;
462 use std::os::unix::ffi::OsStrExt;
463
464 let staging = CString::new(staging.as_os_str().as_bytes())?;
465 let destination = CString::new(destination.as_os_str().as_bytes())?;
466 let result = unsafe {
468 libc::syscall(
469 libc::SYS_renameat2,
470 libc::AT_FDCWD,
471 staging.as_ptr(),
472 libc::AT_FDCWD,
473 destination.as_ptr(),
474 libc::RENAME_EXCHANGE,
475 )
476 };
477 if result == 0 {
478 Ok(())
479 } else {
480 Err(io::Error::last_os_error())
481 }
482}
483
484#[cfg(all(feature = "fs", any(target_os = "macos", target_os = "ios")))]
485fn replace_existing(staging: &Path, destination: &Path) -> io::Result<()> {
486 use std::ffi::CString;
487 use std::os::unix::ffi::OsStrExt;
488
489 let staging = CString::new(staging.as_os_str().as_bytes())?;
490 let destination = CString::new(destination.as_os_str().as_bytes())?;
491 let result =
493 unsafe { libc::renamex_np(staging.as_ptr(), destination.as_ptr(), libc::RENAME_SWAP) };
494 if result == 0 {
495 Ok(())
496 } else {
497 Err(io::Error::last_os_error())
498 }
499}
500
501#[cfg(all(
502 feature = "fs",
503 not(any(
504 target_os = "linux",
505 target_os = "android",
506 target_os = "macos",
507 target_os = "ios"
508 ))
509))]
510fn replace_existing(_staging: &Path, _destination: &Path) -> io::Result<()> {
511 Err(io::Error::new(
512 io::ErrorKind::Unsupported,
513 "strict replace-existing write is unsupported",
514 ))
515}
516
517#[cfg(feature = "fs")]
519#[derive(Debug, thiserror::Error)]
520#[non_exhaustive]
521pub enum SavePackError {
522 #[error(transparent)]
523 Encode(#[from] EncodeError),
524 #[error("encoded Pack Archive could not be written: {source}")]
525 Write {
526 archive: PackArchiveBytes,
527 #[source]
528 source: FileWriteError,
529 },
530}
531
532#[cfg(feature = "fs")]
534pub fn save_pack(
535 destination: impl AsRef<Path>,
536 pack: &Pack,
537 policy: FileWritePolicy,
538) -> Result<FileWriteReceipt, SavePackError> {
539 save_pack_with_limits(destination, pack, EncodeLimits::reference_v1(), policy)
540}
541
542#[cfg(feature = "fs")]
544pub fn save_pack_with_limits(
545 destination: impl AsRef<Path>,
546 pack: &Pack,
547 encode_limits: EncodeLimits,
548 policy: FileWritePolicy,
549) -> Result<FileWriteReceipt, SavePackError> {
550 let archive = encode_with_limits(pack, encode_limits)?;
551 match write_file(destination, &archive, policy) {
552 Ok(receipt) => Ok(receipt),
553 Err(source) => Err(SavePackError::Write { archive, source }),
554 }
555}
556
557#[cfg(all(test, feature = "fs"))]
558mod tests {
559 use super::*;
560
561 #[test]
562 fn create_new_destination_race_does_not_replace_the_racing_file() {
563 let directory = tempfile::tempdir().unwrap();
564 let destination = directory.path().join("archive.typk");
565 let archive = PackArchiveBytes::from_vec(b"new archive".to_vec());
566
567 let error =
568 write_file_before_commit(&destination, &archive, FileWritePolicy::CreateNew, |_| {
569 std::fs::write(&destination, b"racing archive").unwrap()
570 })
571 .unwrap_err();
572
573 assert_eq!(error.phase(), FileWritePhase::Commit);
574 assert_eq!(error.commit_certainty(), CommitCertainty::NotCommitted);
575 assert_eq!(std::fs::read(&destination).unwrap(), b"racing archive");
576 assert_eq!(
577 std::fs::read(error.staging_residue().unwrap()).unwrap(),
578 archive.as_slice()
579 );
580 }
581
582 #[test]
583 fn replace_existing_destination_race_does_not_create_a_new_file() {
584 if !replace_existing_supported() {
585 return;
586 }
587 let directory = tempfile::tempdir().unwrap();
588 let destination = directory.path().join("archive.typk");
589 std::fs::write(&destination, b"old archive").unwrap();
590 let archive = PackArchiveBytes::from_vec(b"new archive".to_vec());
591
592 let error = write_file_before_commit(
593 &destination,
594 &archive,
595 FileWritePolicy::ReplaceExisting,
596 |_| std::fs::remove_file(&destination).unwrap(),
597 )
598 .unwrap_err();
599
600 assert_eq!(error.phase(), FileWritePhase::Commit);
601 assert_eq!(error.commit_certainty(), CommitCertainty::NotCommitted);
602 assert!(!destination.exists());
603 assert_eq!(
604 std::fs::read(error.staging_residue().unwrap()).unwrap(),
605 archive.as_slice()
606 );
607 }
608
609 #[test]
610 fn commit_failure_reports_staging_removed_by_a_race_as_absent() {
611 let directory = tempfile::tempdir().unwrap();
612 let destination = directory.path().join("archive.typk");
613 let archive = PackArchiveBytes::from_vec(b"new archive".to_vec());
614
615 let error = write_file_before_commit(
616 &destination,
617 &archive,
618 FileWritePolicy::CreateNew,
619 |staging| std::fs::remove_file(staging).unwrap(),
620 )
621 .unwrap_err();
622
623 assert_eq!(error.phase(), FileWritePhase::Commit);
624 assert_eq!(error.staging_residue_status(), StagingResidueStatus::Absent);
625 assert_eq!(error.staging_residue(), None);
626 assert!(!destination.exists());
627 }
628
629 #[test]
630 fn staging_write_handles_short_writes() {
631 let mut writer = FaultWriter::new(2, None, false);
632
633 write_staging(&mut writer, b"complete staging bytes").unwrap();
634
635 assert_eq!(writer.bytes, b"complete staging bytes");
636 assert!(writer.flush_called);
637 }
638
639 #[test]
640 fn staging_write_and_flush_faults_keep_their_phase() {
641 let mut write_failure = FaultWriter::new(3, Some(6), false);
642 let (phase, _) = write_staging(&mut write_failure, b"complete staging bytes").unwrap_err();
643 assert_eq!(phase, FileWritePhase::StagingWrite);
644 assert_eq!(write_failure.bytes, b"comple");
645
646 let mut flush_failure = FaultWriter::new(usize::MAX, None, true);
647 let (phase, _) = write_staging(&mut flush_failure, b"complete staging bytes").unwrap_err();
648 assert_eq!(phase, FileWritePhase::StagingFlush);
649 assert_eq!(flush_failure.bytes, b"complete staging bytes");
650 }
651
652 struct FaultWriter {
653 bytes: Vec<u8>,
654 maximum_write: usize,
655 fail_after: Option<usize>,
656 fail_flush: bool,
657 flush_called: bool,
658 }
659
660 impl FaultWriter {
661 fn new(maximum_write: usize, fail_after: Option<usize>, fail_flush: bool) -> Self {
662 Self {
663 bytes: Vec::new(),
664 maximum_write,
665 fail_after,
666 fail_flush,
667 flush_called: false,
668 }
669 }
670 }
671
672 impl Write for FaultWriter {
673 fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
674 if self.fail_after == Some(self.bytes.len()) {
675 return Err(io::Error::other("scripted staging write failure"));
676 }
677 let before_failure = self
678 .fail_after
679 .map_or(usize::MAX, |limit| limit - self.bytes.len());
680 let written = buffer.len().min(self.maximum_write).min(before_failure);
681 self.bytes.extend_from_slice(&buffer[..written]);
682 Ok(written)
683 }
684
685 fn flush(&mut self) -> io::Result<()> {
686 self.flush_called = true;
687 if self.fail_flush {
688 return Err(io::Error::other("scripted staging flush failure"));
689 }
690 Ok(())
691 }
692 }
693}