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