1use std::fs::{self, File, OpenOptions};
24use std::io::{Read as _, Write as _};
25use std::path::{Path, PathBuf};
26
27use crate::document::{Addressing, DocumentError, DocumentResult, Format, KeyedList, Value};
28
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
37pub enum SymlinkPolicy {
38 #[default]
40 Follow,
41 NoFollow,
45}
46
47#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
49pub enum CreateMode {
50 #[default]
52 NewOnly,
53 Replace,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct CreateOptions {
60 mode: CreateMode,
61 unix_mode: Option<u32>,
62}
63
64impl CreateOptions {
65 #[must_use]
70 pub const fn new() -> Self {
71 Self {
72 mode: CreateMode::NewOnly,
73 unix_mode: None,
74 }
75 }
76
77 #[must_use]
83 pub const fn unix_mode(mut self, unix_mode: u32) -> Self {
84 self.unix_mode = Some(unix_mode);
85 self
86 }
87
88 #[must_use]
90 pub const fn replace(mut self) -> Self {
91 self.mode = CreateMode::Replace;
92 self
93 }
94
95 #[must_use]
97 pub const fn mode(&self) -> CreateMode {
98 self.mode
99 }
100
101 #[must_use]
106 pub const fn configured_unix_mode(&self) -> Option<u32> {
107 self.unix_mode
108 }
109
110 const fn effective_unix_mode(&self, target_exists: bool) -> Option<u32> {
112 match self.unix_mode {
113 Some(mode) => Some(mode),
114 None if target_exists => None,
119 None => Some(0o600),
120 }
121 }
122}
123
124impl Default for CreateOptions {
125 fn default() -> Self {
126 Self::new()
127 }
128}
129
130#[derive(Debug, Clone)]
138pub struct Document {
139 source: String,
140 value: Value,
141 format: Format,
142}
143
144impl Document {
145 pub fn parse(source: &str, format: Format) -> DocumentResult<Document> {
151 let value = format.load(source)?;
152 Ok(Document {
153 source: source.to_string(),
154 value,
155 format,
156 })
157 }
158
159 pub fn from_reader<R: std::io::Read>(
165 mut reader: R,
166 format: Format,
167 ) -> DocumentResult<Document> {
168 let mut source = String::new();
169 reader.read_to_string(&mut source)?;
170 Document::parse(&source, format)
171 }
172
173 pub fn value(&self) -> &Value {
175 &self.value
176 }
177
178 pub fn source(&self) -> &str {
182 &self.source
183 }
184
185 pub fn format(&self) -> Format {
187 self.format
188 }
189
190 #[must_use]
196 pub fn addressing(&self) -> Addressing<'static> {
197 Addressing::INDEX_ONLY.with_array_rule(self.format.array_rule())
198 }
199
200 #[must_use]
203 pub fn addressing_keyed<'a>(&self, keyed_lists: &'a [KeyedList<'a>]) -> Addressing<'a> {
204 Addressing::keyed(keyed_lists).with_array_rule(self.format.array_rule())
205 }
206
207 pub fn value_at(&self, path: &str) -> DocumentResult<Value> {
215 crate::document::get_path(&self.value, path, self.addressing())
216 }
217
218 pub fn value_at_typed(
222 &self,
223 path: &str,
224 expected: crate::document::ValueType,
225 ) -> DocumentResult<Value> {
226 let value = self.value_at(path)?;
227 if crate::document::value_matches_type(&value, expected) {
228 Ok(value)
229 } else {
230 Err(DocumentError::TypeMismatch {
231 path: path.to_string(),
232 expected: expected.name().to_string(),
233 got: value.kind_name().to_string(),
234 hint: None,
235 })
236 }
237 }
238
239 pub fn decode<T: serde::de::DeserializeOwned>(&self) -> DocumentResult<T> {
245 crate::document::from_value(self.value(), "")
246 }
247
248 pub fn set_typed(
251 &mut self,
252 key: &str,
253 raw: Option<&str>,
254 value_type: crate::document::ValueType,
255 ) -> DocumentResult<()> {
256 let value = crate::document::value_from_type(value_type, raw)?;
257 self.set(key, value)
258 }
259
260 fn ensure_writable(&self, operation: &str) -> DocumentResult<()> {
268 if self.format.is_read_only() {
269 return Err(self.format.read_only_error(operation));
270 }
271 Ok(())
272 }
273
274 pub fn encode(&self) -> DocumentResult<String> {
280 self.format.save(&self.value)
281 }
282}
283
284#[derive(Debug, Clone)]
293pub struct DocumentFile {
294 doc: Document,
295 path: PathBuf,
296}
297
298impl DocumentFile {
299 pub fn open(
305 path: impl AsRef<Path>,
306 format_override: Option<Format>,
307 ) -> DocumentResult<DocumentFile> {
308 let path = path.as_ref().to_path_buf();
309 let format = resolve_format(&path, format_override)?;
310 let source = fs::read_to_string(&path).map_err(|error| DocumentError::IoError {
311 detail: format!("read `{}`: {error}", path.display()),
312 })?;
313 Ok(DocumentFile {
314 doc: Document::parse(&source, format)?,
315 path,
316 })
317 }
318
319 pub fn open_capped(
333 path: impl AsRef<Path>,
334 format_override: Option<Format>,
335 max_bytes: u64,
336 ) -> DocumentResult<DocumentFile> {
337 Self::open_capped_with_policy(path, format_override, max_bytes, SymlinkPolicy::Follow)
338 }
339
340 pub fn open_capped_with_policy(
346 path: impl AsRef<Path>,
347 format_override: Option<Format>,
348 max_bytes: u64,
349 symlink_policy: SymlinkPolicy,
350 ) -> DocumentResult<DocumentFile> {
351 let path = path.as_ref().to_path_buf();
352 let format = resolve_format(&path, format_override)?;
353 let file = open_read_handle(&path, symlink_policy)?;
354 let source = read_capped_source(file, &path, max_bytes)?;
355 Ok(DocumentFile {
356 doc: Document::parse(&source, format)?,
357 path,
358 })
359 }
360
361 pub fn create_atomic(
374 path: impl AsRef<Path>,
375 document: Document,
376 options: CreateOptions,
377 ) -> DocumentResult<DocumentFile> {
378 let path = path.as_ref().to_path_buf();
379 document.ensure_writable("create")?;
380 let path_format = resolve_format(&path, None)?;
383 if path_format != document.format() {
384 return Err(DocumentError::UnsupportedOperation {
385 format: document.format().name().to_string(),
386 operation: "create".to_string(),
387 detail: format!(
388 "path resolves to {}, so the created file could not be reopened",
389 path_format.name()
390 ),
391 });
392 }
393 validate_source_for_write(&document)?;
394 validate_create_options(options)?;
395 write_atomic_create(&path, document.source().as_bytes(), options)?;
396 Ok(DocumentFile {
397 doc: document,
398 path,
399 })
400 }
401
402 pub fn path(&self) -> &Path {
404 &self.path
405 }
406
407 pub fn ensure_mutable(&self, operation: &str) -> DocumentResult<()> {
416 self.doc.ensure_writable(operation)?;
417 guard_mutation(&self.path, operation)?;
418 Ok(())
419 }
420}
421
422impl Document {
423 pub fn set(&mut self, key: &str, value: Value) -> DocumentResult<()> {
437 let addressing = self.addressing();
438 self.set_addressed(key, value, addressing)
439 }
440
441 pub fn set_addressed(
449 &mut self,
450 key: &str,
451 value: Value,
452 addressing: Addressing<'_>,
453 ) -> DocumentResult<()> {
454 self.ensure_writable("set")?;
455 let key = &crate::document::resolve_path(&self.value, key, addressing)?;
456 let mut new_doc = self.value.clone();
457 crate::document::set_path(&mut new_doc, key, &value, Addressing::INDEX_ONLY)?;
458 let target = crate::document::get_path(&new_doc, key, Addressing::INDEX_ONLY)?;
459 #[allow(unreachable_patterns)]
460 let output = match self.format {
461 #[cfg(feature = "toml")]
462 Format::Toml => {
463 crate::document::format::toml::set_preserving(&self.source, key, &target)?
464 }
465 #[cfg(feature = "yaml")]
466 Format::Yaml => {
467 crate::document::format::yaml::set_preserving(&self.source, key, &target)?
468 }
469 Format::Json => {
470 crate::document::format::json::set_preserving(&self.source, key, &target)?
471 }
472 #[cfg(feature = "dotenv")]
473 Format::Dotenv => {
474 crate::document::format::dotenv::set_preserving(&self.source, key, &target)?
475 }
476 #[cfg(feature = "ini")]
477 Format::Ini => {
478 crate::document::format::ini::set_preserving(&self.source, key, &target)?
479 }
480 #[cfg(feature = "toml")]
481 Format::TomlFrontmatter => {
482 let parts = crate::document::format::frontmatter::split(
483 &self.source,
484 crate::document::format::frontmatter::Delimiter::Plus,
485 )?;
486 let new_fm =
487 crate::document::format::toml::set_preserving(parts.frontmatter, key, &target)?;
488 format!("{}{}{}", parts.pre, new_fm, parts.post)
489 }
490 #[cfg(feature = "yaml")]
491 Format::YamlFrontmatter => {
492 let parts = crate::document::format::frontmatter::split(
493 &self.source,
494 crate::document::format::frontmatter::Delimiter::Dash,
495 )?;
496 let new_fm =
497 crate::document::format::yaml::set_preserving(parts.frontmatter, key, &target)?;
498 format!("{}{}{}", parts.pre, new_fm, parts.post)
499 }
500 _ => self.format.save(&new_doc)?,
501 };
502 self.source = output;
503 self.value = new_doc;
504 Ok(())
505 }
506
507 pub fn add(
516 &mut self,
517 key: &str,
518 slug: &str,
519 slug_field: &str,
520 fields: &[(String, Value)],
521 ) -> DocumentResult<()> {
522 self.ensure_writable("add")?;
523 let mut value = self.value.clone();
524 let keyed_lists = [KeyedList {
525 prefix: key,
526 slug_field,
527 }];
528 crate::document::add_keyed(&mut value, key, slug, &keyed_lists, None, fields)?;
529 let array = if key.is_empty() {
530 &value
531 } else {
532 crate::document::get_path_ref(&value, key, self.addressing_keyed(&keyed_lists))?
533 };
534 let item = array
535 .as_array()
536 .and_then(|items| items.last())
537 .ok_or_else(|| DocumentError::UnsupportedOperation {
538 format: self.format.name().to_string(),
539 operation: "add".to_string(),
540 detail: "keyed list did not produce an array item".to_string(),
541 })?;
542 #[allow(unreachable_patterns)]
546 let output: String = match self.format {
547 Format::Json => crate::document::format::json::append_array_item_preserving(
548 &self.source,
549 key,
550 item,
551 )?,
552 #[cfg(feature = "yaml")]
553 Format::Yaml => crate::document::format::yaml::append_array_item_preserving(
554 &self.source,
555 key,
556 item,
557 )?,
558 #[cfg(feature = "yaml")]
563 Format::YamlFrontmatter => {
564 let parts = crate::document::format::frontmatter::split(
565 &self.source,
566 crate::document::format::frontmatter::Delimiter::Dash,
567 )?;
568 let new_fm = crate::document::format::yaml::append_array_item_preserving(
569 parts.frontmatter,
570 key,
571 item,
572 )?;
573 format!("{}{}{}", parts.pre, new_fm, parts.post)
574 }
575 _ => {
576 return Err(DocumentError::UnsupportedOperation {
577 format: self.format.name().to_string(),
578 operation: "add".to_string(),
579 detail: "keyed collection source editor is not implemented for this backend"
580 .to_string(),
581 });
582 }
583 };
584 self.source = output;
585 self.value = value;
586 Ok(())
587 }
588
589 pub fn remove(&mut self, key: &str, slug: &str, slug_field: &str) -> DocumentResult<()> {
597 self.ensure_writable("remove")?;
598 let mut value = self.value.clone();
599 let keyed_lists = [KeyedList {
600 prefix: key,
601 slug_field,
602 }];
603 let removed_index = crate::document::remove_keyed(&mut value, key, slug, &keyed_lists)?;
604 #[allow(unreachable_patterns)]
608 let output: String = match self.format {
609 Format::Json => crate::document::format::json::remove_array_item_preserving(
610 &self.source,
611 key,
612 removed_index,
613 )?,
614 #[cfg(feature = "yaml")]
615 Format::Yaml => crate::document::format::yaml::remove_array_item_preserving(
616 &self.source,
617 key,
618 removed_index,
619 )?,
620 #[cfg(feature = "yaml")]
621 Format::YamlFrontmatter => {
622 let parts = crate::document::format::frontmatter::split(
623 &self.source,
624 crate::document::format::frontmatter::Delimiter::Dash,
625 )?;
626 let new_fm = crate::document::format::yaml::remove_array_item_preserving(
627 parts.frontmatter,
628 key,
629 removed_index,
630 )?;
631 format!("{}{}{}", parts.pre, new_fm, parts.post)
632 }
633 _ => {
634 return Err(DocumentError::UnsupportedOperation {
635 format: self.format.name().to_string(),
636 operation: "remove".to_string(),
637 detail: "keyed collection source editor is not implemented for this backend"
638 .to_string(),
639 });
640 }
641 };
642 self.source = output;
643 self.value = value;
644 Ok(())
645 }
646
647 pub fn unset(&mut self, key: &str) -> DocumentResult<bool> {
672 let addressing = self.addressing();
673 self.unset_addressed(key, addressing)
674 }
675
676 pub fn unset_addressed(
681 &mut self,
682 key: &str,
683 addressing: Addressing<'_>,
684 ) -> DocumentResult<bool> {
685 self.ensure_writable("unset")?;
686 let key = &crate::document::resolve_path(&self.value, key, addressing)?;
687 let segments = crate::document::parse_path(key)?;
688 let (leaf, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
689 let parent = if parents.is_empty() {
690 &self.value
691 } else {
692 let parent_path = crate::document::join_path(parents);
693 match crate::document::get_path_ref(&self.value, &parent_path, Addressing::INDEX_ONLY) {
694 Ok(parent) => parent,
695 Err(DocumentError::UnknownSegment { .. }) => return Ok(false),
697 Err(error) => return Err(error),
698 }
699 };
700 match parent {
701 Value::Object(object) => {
702 if !object.contains_key(leaf) {
703 return Ok(false);
704 }
705 }
706 Value::Array(array) => {
707 let index =
708 leaf.parse::<usize>()
709 .map_err(|_| DocumentError::UnregisteredArray {
710 path: crate::document::join_path(parents),
711 })?;
712 if index >= array.len() {
713 return Err(DocumentError::IndexOutOfBounds {
714 path: crate::document::join_path(parents),
715 index,
716 len: array.len(),
717 });
718 }
719 }
720 value => {
721 return Err(DocumentError::NotTraversable {
722 path: crate::document::join_path(parents),
723 got: value.kind_name().to_string(),
724 });
725 }
726 }
727 let mut value = self.value.clone();
728 crate::document::unset_path(&mut value, key)?;
729 #[allow(unreachable_patterns)]
730 let output = match self.format {
731 Format::Json => crate::document::format::json::unset_preserving(&self.source, key)?,
732 #[cfg(feature = "toml")]
733 Format::Toml => crate::document::format::toml::unset_preserving(&self.source, key)?,
734 #[cfg(feature = "yaml")]
735 Format::Yaml => crate::document::format::yaml::unset_preserving(&self.source, key)?,
736 #[cfg(feature = "dotenv")]
737 Format::Dotenv => crate::document::format::dotenv::unset_preserving(&self.source, key)?,
738 #[cfg(feature = "ini")]
739 Format::Ini => crate::document::format::ini::unset_preserving(&self.source, key)?,
740 #[cfg(feature = "toml")]
741 Format::TomlFrontmatter => {
742 let parts = crate::document::format::frontmatter::split(
743 &self.source,
744 crate::document::format::frontmatter::Delimiter::Plus,
745 )?;
746 let new_fm =
747 crate::document::format::toml::unset_preserving(parts.frontmatter, key)?;
748 format!("{}{}{}", parts.pre, new_fm, parts.post)
749 }
750 #[cfg(feature = "yaml")]
751 Format::YamlFrontmatter => {
752 let parts = crate::document::format::frontmatter::split(
753 &self.source,
754 crate::document::format::frontmatter::Delimiter::Dash,
755 )?;
756 let new_fm =
757 crate::document::format::yaml::unset_preserving(parts.frontmatter, key)?;
758 format!("{}{}{}", parts.pre, new_fm, parts.post)
759 }
760 _ => self.format.save(&value)?,
761 };
762 self.source = output;
763 self.value = value;
764 Ok(true)
765 }
766}
767
768impl DocumentFile {
769 pub fn edit<F>(&mut self, edit: F) -> DocumentResult<()>
776 where
777 F: FnOnce(&mut Document) -> DocumentResult<()>,
778 {
779 let mut draft = self.doc.clone();
780 edit(&mut draft)?;
781 self.save_document(&draft)?;
782 self.doc = draft;
783 Ok(())
784 }
785
786 pub fn edit_and_validate<T>(
797 &mut self,
798 edit: impl FnOnce(&mut Document) -> DocumentResult<()>,
799 ) -> DocumentResult<T>
800 where
801 T: serde::de::DeserializeOwned,
802 {
803 let mut draft = self.doc.clone();
804 edit(&mut draft)?;
805 let decoded = draft.decode::<T>()?;
806 self.save_document(&draft)?;
807 self.doc = draft;
808 Ok(decoded)
809 }
810
811 pub fn save(&self) -> DocumentResult<()> {
821 self.save_atomic(self.doc.source())
822 }
823
824 pub(crate) fn save_atomic(&self, new_source: &str) -> DocumentResult<()> {
837 self.ensure_writable("save")?;
841 validate_source_text_for_write(new_source, self.format)?;
848 write_atomic(&self.path, new_source.as_bytes(), "write")
849 }
850
851 fn save_document(&self, document: &Document) -> DocumentResult<()> {
852 document.ensure_writable("save")?;
853 validate_source_for_write(document)?;
854 write_atomic(&self.path, document.source().as_bytes(), "write")
855 }
856}
857
858impl std::ops::Deref for DocumentFile {
859 type Target = Document;
860
861 fn deref(&self) -> &Document {
862 &self.doc
863 }
864}
865
866impl std::ops::DerefMut for DocumentFile {
867 fn deref_mut(&mut self) -> &mut Document {
868 &mut self.doc
869 }
870}
871
872fn resolve_format(path: &Path, format_override: Option<Format>) -> DocumentResult<Format> {
873 match format_override {
874 Some(format) => Ok(format),
875 None => match Format::detect(path) {
880 Some(format) => Ok(format),
881 None => Err(match Format::unavailable(path) {
882 Some(feature) => DocumentError::UnsupportedOperation {
883 format: feature.to_string(),
884 operation: "open".to_string(),
885 detail: format!("requires Cargo feature `{feature}`"),
886 },
887 None => DocumentError::FormatUnknown {
888 path: path.display().to_string(),
889 },
890 }),
891 },
892 }
893}
894
895fn open_read_handle(path: &Path, symlink_policy: SymlinkPolicy) -> DocumentResult<File> {
896 let mut options = OpenOptions::new();
897 options.read(true);
898 #[cfg(all(unix, feature = "libc"))]
899 {
900 use std::os::unix::fs::OpenOptionsExt as _;
901 let mut flags = libc::O_NONBLOCK;
905 if symlink_policy == SymlinkPolicy::NoFollow {
906 flags |= libc::O_NOFOLLOW;
907 }
908 options.custom_flags(flags);
909 }
910 #[cfg(all(unix, not(feature = "libc")))]
911 if symlink_policy == SymlinkPolicy::NoFollow {
912 return Err(DocumentError::UnsupportedOperation {
913 format: "filesystem".to_string(),
914 operation: "open".to_string(),
915 detail: "atomic no-follow reads require Cargo feature `libc` on unix".to_string(),
916 });
917 }
918 #[cfg(not(unix))]
919 if symlink_policy == SymlinkPolicy::NoFollow {
920 return Err(DocumentError::UnsupportedOperation {
921 format: "filesystem".to_string(),
922 operation: "open".to_string(),
923 detail: "atomic no-follow reads are unavailable on this platform".to_string(),
924 });
925 }
926 options.open(path).map_err(|error| DocumentError::IoError {
927 detail: format!("read `{}`: {error}", path.display()),
928 })
929}
930
931fn read_capped_source(file: File, path: &Path, max_bytes: u64) -> DocumentResult<String> {
932 inspect_capped_source(&file, path, max_bytes)?;
933 read_capped_contents(file, path, max_bytes)
934}
935
936fn inspect_capped_source(file: &File, path: &Path, max_bytes: u64) -> DocumentResult<()> {
937 let metadata = file.metadata().map_err(|error| DocumentError::IoError {
938 detail: format!("inspect `{}`: {error}", path.display()),
939 })?;
940 if !metadata.is_file() {
941 return Err(DocumentError::IoError {
942 detail: format!("`{}` is not a regular file", path.display()),
943 });
944 }
945 if metadata.len() > max_bytes {
946 return Err(DocumentError::TooLarge {
947 path: path.display().to_string(),
948 max_bytes,
949 });
950 }
951 Ok(())
952}
953
954fn read_capped_contents(file: File, path: &Path, max_bytes: u64) -> DocumentResult<String> {
955 let read_limit = max_bytes.saturating_add(1);
956 let initial_capacity = usize::try_from(max_bytes.min(1024 * 1024)).unwrap_or(1024 * 1024);
957 let mut bytes = Vec::with_capacity(initial_capacity);
958 file.take(read_limit)
959 .read_to_end(&mut bytes)
960 .map_err(|error| DocumentError::IoError {
961 detail: format!("read `{}`: {error}", path.display()),
962 })?;
963 if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > max_bytes {
964 return Err(DocumentError::TooLarge {
965 path: path.display().to_string(),
966 max_bytes,
967 });
968 }
969 String::from_utf8(bytes).map_err(|error| DocumentError::IoError {
970 detail: format!(
971 "read `{}`: document is not UTF-8 (valid through byte {})",
972 path.display(),
973 error.utf8_error().valid_up_to()
974 ),
975 })
976}
977
978fn validate_source_for_write(document: &Document) -> DocumentResult<()> {
979 validate_source_text_for_write(document.source(), document.format())
980}
981
982fn validate_source_text_for_write(source: &str, format: Format) -> DocumentResult<()> {
983 Document::parse(source, format).map_err(|error| DocumentError::WriteWouldCorrupt {
984 format: format.name().to_string(),
985 detail: error.redacted_message(),
986 })?;
987 Ok(())
988}
989
990fn validate_create_options(options: CreateOptions) -> DocumentResult<()> {
991 if let Some(unix_mode) = options.unix_mode
992 && unix_mode & !0o777 != 0
993 {
994 return Err(DocumentError::InvalidArgument {
995 detail: format!("unix mode {unix_mode:o} contains bits outside 0o777"),
996 });
997 }
998 Ok(())
999}
1000
1001fn guard_mutation(path: &Path, operation: &str) -> DocumentResult<fs::Metadata> {
1005 let metadata = fs::symlink_metadata(path).map_err(|error| DocumentError::IoError {
1006 detail: format!("{operation} preflight `{}`: {error}", path.display()),
1007 })?;
1008 if metadata.file_type().is_symlink() {
1009 return Err(DocumentError::UnsupportedOperation {
1010 format: "filesystem".to_string(),
1011 operation: operation.to_string(),
1012 detail: format!("refusing to mutate symlink `{}`", path.display()),
1013 });
1014 }
1015 #[cfg(unix)]
1016 {
1017 use std::os::unix::fs::MetadataExt;
1018 if metadata.nlink() > 1 {
1019 return Err(DocumentError::UnsupportedOperation {
1020 format: "filesystem".to_string(),
1021 operation: operation.to_string(),
1022 detail: format!("refusing to mutate hardlinked file `{}`", path.display()),
1023 });
1024 }
1025 }
1026 Ok(metadata)
1027}
1028
1029fn temp_file_name(file_name: &str, pid: u32, attempt: u32) -> String {
1038 const MAX_NAME_BYTES: usize = 255;
1040 let suffix = format!(".afdata-document.{pid}.{attempt}.tmp");
1041 let budget = MAX_NAME_BYTES.saturating_sub(suffix.len() + 1);
1043 let mut stem = file_name;
1044 if stem.len() > budget {
1045 let mut cut = budget;
1046 while cut > 0 && !stem.is_char_boundary(cut) {
1047 cut -= 1;
1048 }
1049 stem = &stem[..cut];
1050 }
1051 format!(".{stem}{suffix}")
1052}
1053
1054fn allocate_private_temp(
1055 parent: &Path,
1056 file_name: &str,
1057 operation: &str,
1058) -> DocumentResult<(PathBuf, File)> {
1059 let pid = std::process::id();
1060 for attempt in 0..32_u32 {
1061 let candidate = parent.join(temp_file_name(file_name, pid, attempt));
1062 let mut options = OpenOptions::new();
1063 options.write(true).create_new(true);
1064 #[cfg(unix)]
1065 {
1066 use std::os::unix::fs::OpenOptionsExt as _;
1067 options.mode(0o600);
1068 }
1069 match options.open(&candidate) {
1070 Ok(file) => return Ok((candidate, file)),
1071 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
1072 Err(error) => {
1073 return Err(DocumentError::IoError {
1074 detail: format!(
1075 "{operation} temporary file in `{}`: {error}",
1076 parent.display()
1077 ),
1078 });
1079 }
1080 }
1081 }
1082 Err(DocumentError::IoError {
1083 detail: format!(
1084 "{operation} could not allocate temporary file in `{}`",
1085 parent.display()
1086 ),
1087 })
1088}
1089
1090fn atomic_parent_and_name<'a>(
1091 path: &'a Path,
1092 operation: &str,
1093) -> DocumentResult<(&'a Path, String)> {
1094 let parent = match path.parent() {
1095 Some(parent) if parent.as_os_str().is_empty() => Path::new("."),
1096 Some(parent) => parent,
1097 None => {
1098 return Err(DocumentError::IoError {
1099 detail: format!(
1100 "{operation} has no parent directory for `{}`",
1101 path.display()
1102 ),
1103 });
1104 }
1105 };
1106 let file_name = path
1107 .file_name()
1108 .and_then(|name| name.to_str())
1109 .ok_or_else(|| DocumentError::IoError {
1110 detail: format!("{operation} path is not valid UTF-8: `{}`", path.display()),
1111 })?
1112 .to_string();
1113 Ok((parent, file_name))
1114}
1115
1116#[cfg(unix)]
1117fn sync_parent(parent: &Path, operation: &str) -> DocumentResult<()> {
1118 File::open(parent)
1119 .and_then(|directory| directory.sync_all())
1120 .map_err(|error| DocumentError::IoError {
1121 detail: format!(
1122 "{operation} fsync parent directory `{}`: {error}",
1123 parent.display()
1124 ),
1125 })
1126}
1127
1128#[cfg(not(unix))]
1129fn sync_parent(_parent: &Path, _operation: &str) -> DocumentResult<()> {
1130 Ok(())
1133}
1134
1135fn write_temp_bytes(
1136 mut temp_file: File,
1137 temp_path: &Path,
1138 target_path: &Path,
1139 bytes: &[u8],
1140 operation: &str,
1141 permissions: Option<fs::Permissions>,
1142 unix_mode: Option<u32>,
1143) -> DocumentResult<()> {
1144 temp_file
1145 .write_all(bytes)
1146 .map_err(|error| DocumentError::IoError {
1147 detail: format!("{operation} write `{}`: {error}", target_path.display()),
1148 })?;
1149 if let Some(permissions) = permissions {
1150 temp_file
1151 .set_permissions(permissions)
1152 .map_err(|error| DocumentError::IoError {
1153 detail: format!(
1154 "{operation} preserve permissions `{}`: {error}",
1155 target_path.display()
1156 ),
1157 })?;
1158 }
1159 #[cfg(unix)]
1160 if let Some(unix_mode) = unix_mode {
1161 use std::os::unix::fs::PermissionsExt as _;
1162 temp_file
1163 .set_permissions(fs::Permissions::from_mode(unix_mode))
1164 .map_err(|error| DocumentError::IoError {
1165 detail: format!(
1166 "{operation} set permissions on `{}`: {error}",
1167 target_path.display()
1168 ),
1169 })?;
1170 }
1171 #[cfg(not(unix))]
1172 let _ = unix_mode;
1173 temp_file
1174 .sync_all()
1175 .map_err(|error| DocumentError::IoError {
1176 detail: format!("{operation} fsync `{}`: {error}", temp_path.display()),
1177 })
1178}
1179
1180fn write_atomic(path: &Path, bytes: &[u8], operation: &str) -> DocumentResult<()> {
1183 let metadata = guard_mutation(path, operation)?;
1184 let (parent, file_name) = atomic_parent_and_name(path, operation)?;
1185 let (temp_path, temp_file) = allocate_private_temp(parent, &file_name, operation)?;
1186 let result = (|| -> DocumentResult<()> {
1187 write_temp_bytes(
1188 temp_file,
1189 &temp_path,
1190 path,
1191 bytes,
1192 operation,
1193 Some(metadata.permissions()),
1194 None,
1195 )?;
1196 fs::rename(&temp_path, path).map_err(|error| DocumentError::IoError {
1197 detail: format!("{operation} atomic replace `{}`: {error}", path.display()),
1198 })?;
1199 sync_parent(parent, operation)?;
1200 Ok(())
1201 })();
1202 if result.is_err() {
1203 let _ = fs::remove_file(&temp_path);
1204 }
1205 result
1206}
1207
1208fn write_atomic_create(path: &Path, bytes: &[u8], options: CreateOptions) -> DocumentResult<()> {
1209 let operation = "create";
1210 let mut existing_permissions = None;
1211 match fs::symlink_metadata(path) {
1212 Ok(_) => match options.mode {
1213 CreateMode::NewOnly => {
1214 return Err(DocumentError::AlreadyExists {
1215 path: path.display().to_string(),
1216 });
1217 }
1218 CreateMode::Replace => {
1219 let metadata = guard_mutation(path, operation)?;
1220 existing_permissions = Some(metadata.permissions());
1221 }
1222 },
1223 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1224 Err(error) => {
1225 return Err(DocumentError::IoError {
1226 detail: format!("create preflight `{}`: {error}", path.display()),
1227 });
1228 }
1229 }
1230
1231 let (parent, file_name) = atomic_parent_and_name(path, operation)?;
1232 let (temp_path, temp_file) = allocate_private_temp(parent, &file_name, operation)?;
1233 let result = (|| -> DocumentResult<()> {
1234 let unix_mode = options.effective_unix_mode(existing_permissions.is_some());
1237 let preserved = unix_mode
1238 .is_none()
1239 .then(|| existing_permissions.clone())
1240 .flatten();
1241 write_temp_bytes(
1242 temp_file, &temp_path, path, bytes, operation, preserved, unix_mode,
1243 )?;
1244 match options.mode {
1245 CreateMode::NewOnly => match fs::hard_link(&temp_path, path) {
1246 Ok(()) => {
1247 let _ = fs::remove_file(&temp_path);
1253 }
1254 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1255 return Err(DocumentError::AlreadyExists {
1256 path: path.display().to_string(),
1257 });
1258 }
1259 Err(error) => {
1260 return Err(DocumentError::IoError {
1261 detail: format!("create install `{}`: {error}", path.display()),
1262 });
1263 }
1264 },
1265 CreateMode::Replace => {
1266 fs::rename(&temp_path, path).map_err(|error| DocumentError::IoError {
1267 detail: format!("create atomic replace `{}`: {error}", path.display()),
1268 })?;
1269 }
1270 }
1271 sync_parent(parent, operation)?;
1272 Ok(())
1273 })();
1274 if result.is_err() {
1275 let _ = fs::remove_file(&temp_path);
1276 }
1277 result
1278}
1279
1280#[cfg(test)]
1281mod tests {
1282 #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
1283 use super::*;
1284 use std::io::Cursor;
1285
1286 fn write_temp(dir: &Path, name: &str, contents: &str) -> PathBuf {
1287 let path = dir.join(name);
1288 fs::write(&path, contents).unwrap();
1289 path
1290 }
1291
1292 #[test]
1293 fn round_trip_open_json() {
1294 let dir = tempfile::tempdir().unwrap();
1295 let contents = r#"{"host": "example.com", "port": 993}"#;
1296 let path = write_temp(dir.path(), "config.json", contents);
1297
1298 let doc = DocumentFile::open(&path, None).unwrap();
1299
1300 assert_eq!(doc.format(), Format::Json);
1301 assert_eq!(
1302 doc.value().get("host").and_then(Value::as_str),
1303 Some("example.com")
1304 );
1305 assert_eq!(doc.source(), contents);
1306 }
1307
1308 #[test]
1309 fn value_at_reads_a_nested_address() {
1310 let dir = tempfile::tempdir().unwrap();
1311 let path = write_temp(
1312 dir.path(),
1313 "config.json",
1314 r#"{"database": {"url": "postgres://x"}}"#,
1315 );
1316 let doc = DocumentFile::open(&path, None).unwrap();
1317
1318 assert_eq!(
1319 doc.value_at("database.url").unwrap(),
1320 Value::String("postgres://x".to_string())
1321 );
1322 assert_eq!(
1323 doc.value_at("database.missing").unwrap_err().code(),
1324 "document_path_not_found"
1325 );
1326 }
1327
1328 #[test]
1329 fn open_capped_enforces_size_and_regular_file() {
1330 let dir = tempfile::tempdir().unwrap();
1331 let path = write_temp(dir.path(), "config.json", r#"{"k": "v"}"#);
1332
1333 assert!(DocumentFile::open_capped(&path, None, 1024).is_ok());
1335
1336 let err = DocumentFile::open_capped(&path, None, 4).unwrap_err();
1339 assert_eq!(err.code(), "document_too_large");
1340
1341 let dir_err = DocumentFile::open_capped(dir.path(), Some(Format::Json), 1024).unwrap_err();
1343 assert_eq!(dir_err.code(), "document_io_failed");
1344
1345 let missing =
1347 DocumentFile::open_capped(dir.path().join("absent.json"), None, 1024).unwrap_err();
1348 assert_ne!(missing.code(), "document_too_large");
1349 }
1350
1351 #[cfg(unix)]
1352 #[test]
1353 fn capped_read_uses_the_open_handle_when_the_path_is_replaced() {
1354 let dir = tempfile::tempdir().unwrap();
1355 let original = r#"{"source":"original"}"#;
1356 let path = write_temp(dir.path(), "config.json", original);
1357 let handle = open_read_handle(&path, SymlinkPolicy::Follow).unwrap();
1358 inspect_capped_source(&handle, &path, 64).unwrap();
1359
1360 fs::rename(&path, dir.path().join("original.json")).unwrap();
1361 fs::write(&path, r#"{"source":"replacement"}"#).unwrap();
1362
1363 let source = read_capped_contents(handle, &path, 64).unwrap();
1364 assert_eq!(source, original);
1365 }
1366
1367 #[cfg(unix)]
1368 #[test]
1369 fn capped_read_rechecks_the_actual_bytes_after_metadata() {
1370 let dir = tempfile::tempdir().unwrap();
1371 let path = write_temp(dir.path(), "config.json", "{}");
1372 let handle = open_read_handle(&path, SymlinkPolicy::Follow).unwrap();
1373 inspect_capped_source(&handle, &path, 4).unwrap();
1374
1375 let mut writer = OpenOptions::new().append(true).open(&path).unwrap();
1376 writer.write_all(b"123").unwrap();
1377 writer.sync_all().unwrap();
1378
1379 let error = read_capped_contents(handle, &path, 4).unwrap_err();
1380 assert_eq!(error.code(), "document_too_large");
1381 }
1382
1383 #[cfg(all(unix, feature = "libc"))]
1384 #[test]
1385 fn open_capped_can_atomically_refuse_a_symlink() {
1386 let dir = tempfile::tempdir().unwrap();
1387 let target = write_temp(dir.path(), "target.json", r#"{"k": "v"}"#);
1388 let link = dir.path().join("link.json");
1389 std::os::unix::fs::symlink(&target, &link).unwrap();
1390
1391 assert!(
1392 DocumentFile::open_capped_with_policy(&link, None, 1024, SymlinkPolicy::Follow).is_ok()
1393 );
1394 let error =
1395 DocumentFile::open_capped_with_policy(&link, None, 1024, SymlinkPolicy::NoFollow)
1396 .unwrap_err();
1397 assert_eq!(error.code(), "document_io_failed");
1398 }
1399
1400 #[test]
1401 fn create_atomic_is_no_clobber_and_returns_a_file_handle() {
1402 let dir = tempfile::tempdir().unwrap();
1403 let path = dir.path().join("config.json");
1404 let document = Document::parse(r#"{"port": 993}"#, Format::Json).unwrap();
1405
1406 let created = DocumentFile::create_atomic(&path, document, CreateOptions::new()).unwrap();
1407 assert_eq!(created.path(), path);
1408 assert_eq!(fs::read_to_string(&path).unwrap(), r#"{"port": 993}"#);
1409
1410 let replacement = Document::parse(r#"{"port": 1024}"#, Format::Json).unwrap();
1411 let error =
1412 DocumentFile::create_atomic(&path, replacement, CreateOptions::new()).unwrap_err();
1413 assert_eq!(error.code(), "document_target_exists");
1414 assert_eq!(fs::read_to_string(&path).unwrap(), r#"{"port": 993}"#);
1415 }
1416
1417 #[test]
1418 fn create_atomic_requires_explicit_replace() {
1419 let dir = tempfile::tempdir().unwrap();
1420 let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
1421 let replacement = Document::parse(r#"{"port": 1024}"#, Format::Json).unwrap();
1422
1423 let created =
1424 DocumentFile::create_atomic(&path, replacement, CreateOptions::new().replace())
1425 .unwrap();
1426
1427 assert_eq!(created.value_at("port").unwrap(), Value::Integer(1024));
1428 assert_eq!(fs::read_to_string(&path).unwrap(), r#"{"port": 1024}"#);
1429 }
1430
1431 #[cfg(unix)]
1436 #[test]
1437 fn create_atomic_replace_preserves_the_targets_mode_unless_told_otherwise() {
1438 use std::os::unix::fs::PermissionsExt as _;
1439
1440 let dir = tempfile::tempdir().unwrap();
1441 let mode_of = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
1442
1443 for original in [0o644, 0o600, 0o640] {
1444 let path = write_temp(dir.path(), &format!("m{original:o}.json"), r#"{"a": 1}"#);
1445 fs::set_permissions(&path, fs::Permissions::from_mode(original)).unwrap();
1446 let replacement = Document::parse(r#"{"a": 2}"#, Format::Json).unwrap();
1447 DocumentFile::create_atomic(&path, replacement, CreateOptions::new().replace())
1448 .unwrap();
1449 assert_eq!(
1450 mode_of(&path),
1451 original,
1452 "replace must keep the file's mode"
1453 );
1454 }
1455
1456 let path = write_temp(dir.path(), "explicit.json", r#"{"a": 1}"#);
1458 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1459 let replacement = Document::parse(r#"{"a": 2}"#, Format::Json).unwrap();
1460 DocumentFile::create_atomic(
1461 &path,
1462 replacement,
1463 CreateOptions::new().replace().unix_mode(0o600),
1464 )
1465 .unwrap();
1466 assert_eq!(mode_of(&path), 0o600);
1467
1468 let fresh = dir.path().join("fresh.json");
1470 let document = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
1471 DocumentFile::create_atomic(&fresh, document, CreateOptions::new()).unwrap();
1472 assert_eq!(mode_of(&fresh), 0o600);
1473 }
1474
1475 #[cfg(feature = "toml")]
1479 #[test]
1480 fn create_atomic_refuses_a_document_the_path_could_not_reopen() {
1481 let dir = tempfile::tempdir().unwrap();
1482 let path = dir.path().join("mismatch.toml");
1483 let document = Document::parse(r#"{"port": 993}"#, Format::Json).unwrap();
1484
1485 let error = DocumentFile::create_atomic(&path, document, CreateOptions::new()).unwrap_err();
1486
1487 assert_eq!(error.code(), "document_unsupported_operation");
1488 assert!(
1489 !path.exists(),
1490 "nothing may be written when the check fails"
1491 );
1492 }
1493
1494 #[test]
1495 fn bare_relative_atomic_paths_use_the_current_directory() {
1496 let (parent, file_name) =
1497 atomic_parent_and_name(Path::new("config.json"), "write").unwrap();
1498
1499 assert_eq!(parent, Path::new("."));
1500 assert_eq!(file_name, "config.json");
1501 }
1502
1503 #[cfg(unix)]
1504 #[test]
1505 fn create_atomic_applies_requested_private_mode() {
1506 use std::os::unix::fs::PermissionsExt as _;
1507
1508 let dir = tempfile::tempdir().unwrap();
1509 let path = dir.path().join("config.json");
1510 let document = Document::parse(r#"{"port": 993}"#, Format::Json).unwrap();
1511
1512 DocumentFile::create_atomic(&path, document, CreateOptions::new().unix_mode(0o640))
1513 .unwrap();
1514
1515 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1516 assert_eq!(mode, 0o640);
1517 }
1518
1519 #[test]
1520 fn create_atomic_rejects_invalid_permission_bits_before_writing() {
1521 let dir = tempfile::tempdir().unwrap();
1522 let path = dir.path().join("config.json");
1523 let document = Document::parse(r#"{"port": 993}"#, Format::Json).unwrap();
1524
1525 let error =
1526 DocumentFile::create_atomic(&path, document, CreateOptions::new().unix_mode(0o1600))
1527 .unwrap_err();
1528
1529 assert_eq!(error.code(), "document_invalid_argument");
1530 assert!(!path.exists());
1531 }
1532
1533 #[cfg(unix)]
1534 #[test]
1535 fn create_atomic_replace_refuses_symlinks_and_hardlinks() {
1536 let dir = tempfile::tempdir().unwrap();
1537 let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
1538 let symlink = dir.path().join("symlink.json");
1539 std::os::unix::fs::symlink(&target, &symlink).unwrap();
1540 let replacement = Document::parse(r#"{"port": 1024}"#, Format::Json).unwrap();
1541
1542 let symlink_error = DocumentFile::create_atomic(
1543 &symlink,
1544 replacement.clone(),
1545 CreateOptions::new().replace(),
1546 )
1547 .unwrap_err();
1548 assert_eq!(symlink_error.code(), "document_unsupported_operation");
1549 assert_eq!(fs::read_to_string(&target).unwrap(), r#"{"port": 993}"#);
1550
1551 let hardlink = dir.path().join("hardlink.json");
1552 fs::hard_link(&target, &hardlink).unwrap();
1553 let hardlink_error =
1554 DocumentFile::create_atomic(&hardlink, replacement, CreateOptions::new().replace())
1555 .unwrap_err();
1556 assert_eq!(hardlink_error.code(), "document_unsupported_operation");
1557 assert_eq!(fs::read_to_string(&target).unwrap(), r#"{"port": 993}"#);
1558 }
1559
1560 #[cfg(unix)]
1561 #[test]
1562 fn create_atomic_replace_refuses_a_dangling_symlink() {
1563 let dir = tempfile::tempdir().unwrap();
1564 let missing = dir.path().join("missing.json");
1565 let symlink = dir.path().join("dangling.json");
1566 std::os::unix::fs::symlink(&missing, &symlink).unwrap();
1567 let replacement = Document::parse(r#"{"port": 1024}"#, Format::Json).unwrap();
1568
1569 let error =
1570 DocumentFile::create_atomic(&symlink, replacement, CreateOptions::new().replace())
1571 .unwrap_err();
1572
1573 assert_eq!(error.code(), "document_unsupported_operation");
1574 assert!(
1575 fs::symlink_metadata(&symlink)
1576 .unwrap()
1577 .file_type()
1578 .is_symlink()
1579 );
1580 assert!(!missing.exists());
1581 }
1582
1583 #[test]
1584 fn edit_rolls_back_memory_and_disk_when_the_closure_fails() {
1585 let dir = tempfile::tempdir().unwrap();
1586 let original = r#"{"port": 993}"#;
1587 let path = write_temp(dir.path(), "config.json", original);
1588 let mut document = DocumentFile::open(&path, None).unwrap();
1589
1590 let error = document
1591 .edit(|draft| {
1592 draft.set("port", Value::Integer(1024))?;
1593 Err(DocumentError::InvalidArgument {
1594 detail: "validation failed".to_string(),
1595 })
1596 })
1597 .unwrap_err();
1598
1599 assert_eq!(error.code(), "document_invalid_argument");
1600 assert_eq!(document.source(), original);
1601 assert_eq!(document.value_at("port").unwrap(), Value::Integer(993));
1602 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1603 }
1604
1605 #[test]
1606 fn typed_get_and_set_enforce_the_stated_type() {
1607 use crate::document::ValueType;
1608 let dir = tempfile::tempdir().unwrap();
1609 let path = write_temp(dir.path(), "config.json", r#"{"port": 8080, "host": "x"}"#);
1610 let mut doc = DocumentFile::open(&path, None).unwrap();
1611
1612 assert!(doc.value_at_typed("port", ValueType::Number).is_ok());
1615 assert_eq!(
1616 doc.value_at_typed("port", ValueType::String)
1617 .unwrap_err()
1618 .code(),
1619 "document_type_mismatch"
1620 );
1621 assert!(doc.value_at_typed("host", ValueType::Json).is_ok());
1622
1623 doc.set_typed("port", Some("9090"), ValueType::Number)
1625 .unwrap();
1626 assert_eq!(
1627 doc.value_at("port").unwrap(),
1628 Value::from(serde_json::json!(9090))
1629 );
1630 assert_eq!(
1631 doc.set_typed("port", Some("not-a-number"), ValueType::Number)
1632 .unwrap_err()
1633 .code(),
1634 "document_parse_failed"
1635 );
1636 }
1637
1638 #[test]
1639 fn decode_and_edit_and_validate_share_one_typed_boundary() {
1640 #[derive(Debug, serde::Deserialize)]
1641 #[serde(deny_unknown_fields)]
1642 struct Config {
1643 port: u16,
1644 }
1645
1646 let dir = tempfile::tempdir().unwrap();
1647 let original = r#"{"port": 8080}"#;
1648 let path = write_temp(dir.path(), "config.json", original);
1649 let mut document = DocumentFile::open(&path, None).unwrap();
1650
1651 assert_eq!(document.decode::<Config>().unwrap().port, 8080);
1652
1653 let error = document
1654 .edit_and_validate::<Config>(|draft| {
1655 draft.set("port", Value::String("invalid".to_string()))
1656 })
1657 .unwrap_err();
1658 assert_eq!(error.code(), "document_type_mismatch");
1659 assert_eq!(document.source(), original);
1660 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1661
1662 let config = document
1663 .edit_and_validate::<Config>(|draft| draft.set("port", Value::Unsigned(9090)))
1664 .unwrap();
1665 assert_eq!(config.port, 9090);
1666 assert_eq!(document.value_at("port").unwrap(), Value::Unsigned(9090));
1667 }
1668
1669 #[cfg(feature = "toml")]
1670 #[test]
1671 fn round_trip_open_toml() {
1672 let dir = tempfile::tempdir().unwrap();
1673 let contents = "# leading comment\nhost = \"example.com\"\nport = 993\n";
1674 let path = write_temp(dir.path(), "config.toml", contents);
1675
1676 let doc = DocumentFile::open(&path, None).unwrap();
1677
1678 assert_eq!(doc.format(), Format::Toml);
1679 assert_eq!(
1680 doc.value().get("host").and_then(Value::as_str),
1681 Some("example.com")
1682 );
1683 assert_eq!(doc.source(), contents);
1684 }
1685
1686 #[cfg(feature = "toml")]
1687 #[test]
1688 fn set_scalar_preserves_toml_comments_and_formatting() {
1689 let dir = tempfile::tempdir().unwrap();
1690 let contents = "# leading comment\nhost = \"example.com\"\nport = 993 # inline comment\n";
1691 let path = write_temp(dir.path(), "config.toml", contents);
1692 let mut doc = DocumentFile::open(&path, None).unwrap();
1693
1694 doc.set("port", Value::Integer(1024)).unwrap();
1695 doc.save().unwrap();
1696
1697 let saved = fs::read_to_string(&path).unwrap();
1698 assert!(saved.contains("# leading comment"));
1699 assert!(saved.contains("port = 1024"));
1700 assert_eq!(
1701 doc.value().get("port").and_then(Value::as_integer),
1702 Some(1024)
1703 );
1704 assert_eq!(doc.source(), saved);
1705 }
1706
1707 #[cfg(feature = "toml")]
1712 #[test]
1713 fn toml_array_edits_never_invent_or_misattribute_a_comment() {
1714 let contents = "paths = [\n \"one\", # first\n \"two\", # second\n]\n";
1715
1716 let mut grown = Document::parse(contents, Format::Toml).unwrap();
1717 grown
1718 .set(
1719 "paths",
1720 Value::Array(vec![
1721 Value::String("a".into()),
1722 Value::String("b".into()),
1723 Value::String("c".into()),
1724 ]),
1725 )
1726 .unwrap();
1727 assert_eq!(
1728 grown.source(),
1729 "paths = [\n \"a\", # first\n \"b\",\n \"c\", # second\n]\n",
1730 "an appended element must carry no comment of its own"
1731 );
1732
1733 let mut shrunk = Document::parse(contents, Format::Toml).unwrap();
1734 shrunk
1735 .set("paths", Value::Array(vec![Value::String("only".into())]))
1736 .unwrap();
1737 assert_eq!(
1738 shrunk.source(),
1739 "paths = [\n \"only\", # first\n]\n",
1740 "the surviving element keeps its own comment, not the removed one's"
1741 );
1742
1743 let mut replaced = Document::parse(contents, Format::Toml).unwrap();
1745 replaced
1746 .set(
1747 "paths",
1748 Value::Array(vec![Value::String("x".into()), Value::String("y".into())]),
1749 )
1750 .unwrap();
1751 assert_eq!(
1752 replaced.source(),
1753 "paths = [\n \"x\", # first\n \"y\", # second\n]\n"
1754 );
1755
1756 let mut inline = Document::parse("paths = [ \"one\" ]\n", Format::Toml).unwrap();
1758 inline
1759 .set(
1760 "paths",
1761 Value::Array(vec![
1762 Value::String("one".into()),
1763 Value::String("two".into()),
1764 ]),
1765 )
1766 .unwrap();
1767 assert_eq!(inline.source(), "paths = [ \"one\", \"two\" ]\n");
1768 }
1769
1770 #[cfg(feature = "toml")]
1771 #[test]
1772 fn set_toml_array_preserves_single_line_decor() {
1773 let contents = "# before\npaths = [ \"old\", 'second', ] # keep this\nother = 42\n";
1774 let mut document = Document::parse(contents, Format::Toml).unwrap();
1775
1776 document
1777 .set(
1778 "paths",
1779 Value::Array(vec![
1780 Value::String("new".to_string()),
1781 Value::String("next".to_string()),
1782 ]),
1783 )
1784 .unwrap();
1785
1786 assert_eq!(
1787 document.source(),
1788 "# before\npaths = [ \"new\", \"next\", ] # keep this\nother = 42\n"
1789 );
1790 }
1791
1792 #[cfg(feature = "toml")]
1793 #[test]
1794 fn set_toml_array_preserves_multiline_comments_and_trailing_comma() {
1795 let contents = "paths = [\n \"one\", # first\n \"two\", # second\n]\nother = 42\n";
1796 let mut document = Document::parse(contents, Format::Toml).unwrap();
1797
1798 document
1799 .set(
1800 "paths",
1801 Value::Array(vec![
1802 Value::String("uno".to_string()),
1803 Value::String("dos".to_string()),
1804 ]),
1805 )
1806 .unwrap();
1807
1808 assert_eq!(
1809 document.source(),
1810 "paths = [\n \"uno\", # first\n \"dos\", # second\n]\nother = 42\n"
1811 );
1812 }
1813
1814 #[cfg(feature = "toml")]
1815 #[test]
1816 fn set_toml_array_element_preserves_its_neighbors() {
1817 let contents = "paths = [\n \"one\", # first\n \"two\", # second\n]\n";
1818 let mut document = Document::parse(contents, Format::Toml).unwrap();
1819
1820 document
1821 .set("paths.1", Value::String("changed".to_string()))
1822 .unwrap();
1823
1824 assert_eq!(
1825 document.source(),
1826 "paths = [\n \"one\", # first\n \"changed\", # second\n]\n"
1827 );
1828 }
1829
1830 #[cfg(feature = "toml")]
1831 #[test]
1832 fn set_toml_array_can_become_empty_without_touching_neighbors() {
1833 let contents = "before = 1\npaths = [ \"one\", ] # list\nafter = 2\n";
1834 let mut document = Document::parse(contents, Format::Toml).unwrap();
1835
1836 document.set("paths", Value::Array(Vec::new())).unwrap();
1837
1838 assert_eq!(
1839 document.source(),
1840 "before = 1\npaths = [ ] # list\nafter = 2\n"
1841 );
1842 }
1843
1844 #[cfg(feature = "toml")]
1845 #[test]
1846 fn set_toml_inline_table_preserves_layout_and_comments() {
1847 let contents = "cache = { ttl_s = 1, enabled = true } # cache\nother = 42\n";
1848 let mut document = Document::parse(contents, Format::Toml).unwrap();
1849 let replacement = Value::from(serde_json::json!({
1850 "enabled": false,
1851 "ttl_s": 60
1852 }));
1853
1854 document.set("cache", replacement).unwrap();
1855
1856 assert_eq!(
1857 document.source(),
1858 "cache = { ttl_s = 60, enabled = false } # cache\nother = 42\n"
1859 );
1860 }
1861
1862 #[cfg(feature = "toml")]
1863 #[test]
1864 fn set_toml_ordinary_table_preserves_header_and_unrelated_section() {
1865 let contents = "# lead\n[cache] # cache header\nttl_s = 1 # ttl\nenabled = true\n\n[next]\nvalue = 9\n";
1866 let mut document = Document::parse(contents, Format::Toml).unwrap();
1867 let replacement = Value::from(serde_json::json!({
1868 "enabled": false,
1869 "ttl_s": 60
1870 }));
1871
1872 document.set("cache", replacement).unwrap();
1873
1874 assert_eq!(
1875 document.source(),
1876 "# lead\n[cache] # cache header\nttl_s = 60 # ttl\nenabled = false\n\n[next]\nvalue = 9\n"
1877 );
1878 }
1879
1880 #[cfg(feature = "toml")]
1881 #[test]
1882 fn set_toml_collection_preserves_unchanged_datetime_syntax() {
1883 let contents = "[cache]\nexpires_at = 2026-08-04T12:30:00Z\npaths = [\"one\"]\n";
1884 let mut document = Document::parse(contents, Format::Toml).unwrap();
1885 let replacement = document.value_at("cache").unwrap();
1886
1887 document.set("cache", replacement).unwrap();
1888
1889 assert_eq!(document.source(), contents);
1890 }
1891
1892 #[cfg(feature = "toml")]
1893 #[test]
1894 fn set_toml_array_of_tables_is_explicitly_refused() {
1895 let contents = "[[servers]]\nname = \"one\"\n[[servers]]\nname = \"two\"\n";
1896 let mut document = Document::parse(contents, Format::Toml).unwrap();
1897 let replacement = document.value_at("servers").unwrap();
1898
1899 let error = document.set("servers", replacement).unwrap_err();
1900
1901 assert_eq!(error.code(), "document_unsupported_operation");
1902 assert_eq!(document.source(), contents);
1903 }
1904
1905 #[cfg(feature = "ini")]
1906 #[test]
1907 fn save_refuses_source_its_own_parser_rejects() {
1908 let dir = tempfile::tempdir().unwrap();
1912 let original = "[db]\nhost=localhost\n";
1913 let path = write_temp(dir.path(), "config.ini", original);
1914 let doc = DocumentFile::open(&path, None).unwrap();
1915
1916 let error = doc
1917 .save_atomic("[db]\nhost=localhost\n\n[db]\nport=5432\n")
1918 .unwrap_err();
1919 assert_eq!(error.code(), "document_write_would_corrupt");
1920 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1922 }
1923
1924 #[cfg(feature = "ini")]
1925 #[test]
1926 fn save_writes_source_the_parser_accepts() {
1927 let dir = tempfile::tempdir().unwrap();
1928 let path = write_temp(dir.path(), "config.ini", "[db]\nhost=localhost\n");
1929 let mut doc = DocumentFile::open(&path, None).unwrap();
1930
1931 doc.set("db.port", Value::String("5432".to_string()))
1932 .unwrap();
1933 doc.save().unwrap();
1934
1935 assert_eq!(
1938 fs::read_to_string(&path).unwrap(),
1939 "[db]\nhost=localhost\nport=5432\n"
1940 );
1941 assert!(DocumentFile::open(&path, None).is_ok());
1942 }
1943
1944 #[cfg(unix)]
1945 #[test]
1946 fn atomic_save_preserves_file_mode() {
1947 use std::os::unix::fs::PermissionsExt;
1948
1949 let dir = tempfile::tempdir().unwrap();
1950 let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
1951 fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
1952 let mut doc = DocumentFile::open(&path, None).unwrap();
1953
1954 doc.set("port", Value::Integer(1024)).unwrap();
1955 doc.save().unwrap();
1956
1957 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1958 assert_eq!(mode, 0o640);
1959 }
1960
1961 #[cfg(unix)]
1962 #[test]
1963 fn symlink_target_is_rejected_for_mutation() {
1964 let dir = tempfile::tempdir().unwrap();
1965 let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
1966 let link = dir.path().join("link.json");
1967 std::os::unix::fs::symlink(&target, &link).unwrap();
1968
1969 let mut doc = DocumentFile::open(&link, None).unwrap();
1971
1972 doc.set("port", Value::Integer(1024)).unwrap();
1974 let err = doc.save().unwrap_err();
1975 assert!(matches!(err, DocumentError::UnsupportedOperation { .. }));
1976
1977 let target_contents = fs::read_to_string(&target).unwrap();
1979 assert_eq!(target_contents, r#"{"port": 993}"#);
1980 }
1981
1982 #[test]
1983 fn from_reader_parses_in_memory_cursor() {
1984 let cursor = Cursor::new(br#"{"host": "example.com"}"#.to_vec());
1985
1986 let doc = Document::from_reader(cursor, Format::Json).unwrap();
1987
1988 assert_eq!(
1989 doc.value().get("host").and_then(Value::as_str),
1990 Some("example.com")
1991 );
1992 }
1993
1994 #[test]
1995 fn document_from_str_encode_round_trip() {
1996 let doc = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
1997 let encoded = doc.encode().unwrap();
1998 let reparsed = Document::parse(&encoded, Format::Json).unwrap();
1999 assert_eq!(
2000 reparsed.value().get("a").and_then(Value::as_integer),
2001 Some(1)
2002 );
2003 }
2004
2005 #[test]
2006 fn document_edits_source_in_memory_without_a_file() {
2007 let mut doc = Document::parse("{\n \"host\": \"old\"\n}\n", Format::Json).unwrap();
2010 doc.set("host", Value::String("new".to_string())).unwrap();
2011 doc.set("imap.port", Value::Integer(993)).unwrap(); assert_eq!(
2014 doc.source(),
2015 "{\n \"host\": \"new\",\n \"imap\": {\n \"port\": 993\n }\n}\n"
2016 );
2017 assert_eq!(
2018 doc.value_at("imap.port").unwrap(),
2019 Value::from(serde_json::json!(993))
2020 );
2021 }
2022
2023 #[test]
2024 fn unset_is_false_for_anything_already_absent() {
2025 let mut doc = Document::parse(
2026 r#"{"service":{"host":"example","ports":[80]}}"#,
2027 Format::Json,
2028 )
2029 .unwrap();
2030
2031 assert!(!doc.unset("service.missing").unwrap());
2034 assert!(!doc.unset("missing.parent").unwrap());
2035 assert!(!doc.unset("missing.deeply.nested").unwrap());
2036
2037 assert!(doc.unset("service.host.child").is_err()); assert!(doc.unset("service.ports.9").is_err()); assert!(doc.unset(r"service\q").is_err()); }
2043
2044 #[cfg(feature = "markdown")]
2045 #[test]
2046 fn every_markdown_write_verb_is_refused() {
2047 let source = "# Title\n\nThe lead.\n";
2048 let mut doc = Document::parse(source, Format::Markdown).unwrap();
2049
2050 assert_eq!(
2053 doc.value_at("h1.0.text").unwrap(),
2054 Value::String("Title".to_string())
2055 );
2056 assert_eq!(
2057 doc.value_at("h1.Tit.paragraph.0.text").unwrap(),
2058 Value::String("The lead.".to_string())
2059 );
2060
2061 let refusals: Vec<DocumentError> = vec![
2065 doc.set("h1.0.text", Value::String("New".to_string()))
2066 .unwrap_err(),
2067 doc.add("preamble", "x", "type", &[]).unwrap_err(),
2068 doc.remove("preamble", "x", "type").unwrap_err(),
2069 doc.unset("h1.0").unwrap_err(),
2070 doc.unset("nothing.here").unwrap_err(),
2071 doc.encode().unwrap_err(),
2072 ];
2073 for error in refusals {
2074 assert_eq!(error.code(), "document_unsupported_operation");
2075 assert!(
2076 error.to_string().contains("read-only"),
2077 "refusal must name the reason: {error}"
2078 );
2079 }
2080
2081 assert_eq!(doc.source(), source);
2083 }
2084
2085 #[cfg(feature = "markdown")]
2086 #[test]
2087 fn markdown_save_never_reaches_disk() {
2088 let dir = tempfile::tempdir().unwrap();
2089 let path = write_temp(dir.path(), "README.md", "# Title\n");
2090 assert!(DocumentFile::open(&path, None).is_err());
2092
2093 let doc = DocumentFile::open(&path, Some(Format::Markdown)).unwrap();
2094 let error = doc.save().unwrap_err();
2096 assert_eq!(error.code(), "document_unsupported_operation");
2097 assert_eq!(fs::read_to_string(&path).unwrap(), "# Title\n");
2098 }
2099
2100 #[cfg(feature = "yaml")]
2101 #[test]
2102 fn yaml_write_rejects_cst_ambiguous_mapping_segments() {
2103 let mut numeric = Document::parse("\"123\": value\n", Format::Yaml).unwrap();
2104 assert!(
2105 numeric
2106 .set("123", Value::String("changed".to_string()))
2107 .is_err()
2108 );
2109 assert!(numeric.unset("123").is_err());
2110
2111 let mut bracketed = Document::parse("\"a[0]\": value\n", Format::Yaml).unwrap();
2112 assert!(
2113 bracketed
2114 .set("a[0]", Value::String("changed".to_string()))
2115 .is_err()
2116 );
2117 }
2118}