1use std::fs::{self, File, OpenOptions};
24use std::io::Read 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<()> {
825 self.save_atomic(self.doc.source())
826 }
827
828 pub(crate) fn save_atomic(&self, new_source: &str) -> DocumentResult<()> {
841 self.ensure_writable("save")?;
845 validate_source_text_for_write(new_source, self.format)?;
852 write_atomic(&self.path, new_source.as_bytes(), "write")
853 }
854
855 fn save_document(&self, document: &Document) -> DocumentResult<()> {
856 document.ensure_writable("save")?;
857 validate_source_for_write(document)?;
858 write_atomic(&self.path, document.source().as_bytes(), "write")
859 }
860}
861
862impl std::ops::Deref for DocumentFile {
863 type Target = Document;
864
865 fn deref(&self) -> &Document {
866 &self.doc
867 }
868}
869
870impl std::ops::DerefMut for DocumentFile {
871 fn deref_mut(&mut self) -> &mut Document {
872 &mut self.doc
873 }
874}
875
876fn resolve_format(path: &Path, format_override: Option<Format>) -> DocumentResult<Format> {
877 match format_override {
878 Some(format) => Ok(format),
879 None => match Format::detect(path) {
884 Some(format) => Ok(format),
885 None => Err(match Format::unavailable(path) {
886 Some(feature) => DocumentError::UnsupportedOperation {
887 format: feature.to_string(),
888 operation: "open".to_string(),
889 detail: format!("requires Cargo feature `{feature}`"),
890 },
891 None => DocumentError::FormatUnknown {
892 path: path.display().to_string(),
893 },
894 }),
895 },
896 }
897}
898
899fn open_read_handle(path: &Path, symlink_policy: SymlinkPolicy) -> DocumentResult<File> {
900 let mut options = OpenOptions::new();
901 options.read(true);
902 #[cfg(all(unix, feature = "libc"))]
903 {
904 use std::os::unix::fs::OpenOptionsExt as _;
905 let mut flags = libc::O_NONBLOCK;
909 if symlink_policy == SymlinkPolicy::NoFollow {
910 flags |= libc::O_NOFOLLOW;
911 }
912 options.custom_flags(flags);
913 }
914 #[cfg(all(unix, not(feature = "libc")))]
915 if symlink_policy == SymlinkPolicy::NoFollow {
916 return Err(DocumentError::UnsupportedOperation {
917 format: "filesystem".to_string(),
918 operation: "open".to_string(),
919 detail: "atomic no-follow reads require Cargo feature `libc` on unix".to_string(),
920 });
921 }
922 #[cfg(not(unix))]
923 if symlink_policy == SymlinkPolicy::NoFollow {
924 return Err(DocumentError::UnsupportedOperation {
925 format: "filesystem".to_string(),
926 operation: "open".to_string(),
927 detail: "atomic no-follow reads are unavailable on this platform".to_string(),
928 });
929 }
930 options.open(path).map_err(|error| DocumentError::IoError {
931 detail: format!("read `{}`: {error}", path.display()),
932 })
933}
934
935fn read_capped_source(file: File, path: &Path, max_bytes: u64) -> DocumentResult<String> {
936 inspect_capped_source(&file, path, max_bytes)?;
937 read_capped_contents(file, path, max_bytes)
938}
939
940fn inspect_capped_source(file: &File, path: &Path, max_bytes: u64) -> DocumentResult<()> {
941 let metadata = file.metadata().map_err(|error| DocumentError::IoError {
942 detail: format!("inspect `{}`: {error}", path.display()),
943 })?;
944 if !metadata.is_file() {
945 return Err(DocumentError::IoError {
946 detail: format!("`{}` is not a regular file", path.display()),
947 });
948 }
949 if metadata.len() > max_bytes {
950 return Err(DocumentError::TooLarge {
951 path: path.display().to_string(),
952 max_bytes,
953 });
954 }
955 Ok(())
956}
957
958fn read_capped_contents(file: File, path: &Path, max_bytes: u64) -> DocumentResult<String> {
959 let read_limit = max_bytes.saturating_add(1);
960 let initial_capacity = usize::try_from(max_bytes.min(1024 * 1024)).unwrap_or(1024 * 1024);
961 let mut bytes = Vec::with_capacity(initial_capacity);
962 file.take(read_limit)
963 .read_to_end(&mut bytes)
964 .map_err(|error| DocumentError::IoError {
965 detail: format!("read `{}`: {error}", path.display()),
966 })?;
967 if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > max_bytes {
968 return Err(DocumentError::TooLarge {
969 path: path.display().to_string(),
970 max_bytes,
971 });
972 }
973 String::from_utf8(bytes).map_err(|error| DocumentError::IoError {
974 detail: format!(
975 "read `{}`: document is not UTF-8 (valid through byte {})",
976 path.display(),
977 error.utf8_error().valid_up_to()
978 ),
979 })
980}
981
982fn validate_source_for_write(document: &Document) -> DocumentResult<()> {
983 validate_source_text_for_write(document.source(), document.format())
984}
985
986fn validate_source_text_for_write(source: &str, format: Format) -> DocumentResult<()> {
987 Document::parse(source, format).map_err(|error| DocumentError::WriteWouldCorrupt {
988 format: format.name().to_string(),
989 detail: error.redacted_message(),
990 })?;
991 Ok(())
992}
993
994fn validate_create_options(options: CreateOptions) -> DocumentResult<()> {
995 if let Some(unix_mode) = options.unix_mode
996 && unix_mode & !0o777 != 0
997 {
998 return Err(DocumentError::InvalidArgument {
999 detail: format!("unix mode {unix_mode:o} contains bits outside 0o777"),
1000 });
1001 }
1002 Ok(())
1003}
1004
1005fn guard_mutation(path: &Path, operation: &str) -> DocumentResult<fs::Metadata> {
1009 let metadata = fs::symlink_metadata(path).map_err(|error| DocumentError::IoError {
1010 detail: format!("{operation} preflight `{}`: {error}", path.display()),
1011 })?;
1012 if metadata.file_type().is_symlink() {
1013 return Err(DocumentError::UnsupportedOperation {
1014 format: "filesystem".to_string(),
1015 operation: operation.to_string(),
1016 detail: format!("refusing to mutate symlink `{}`", path.display()),
1017 });
1018 }
1019 #[cfg(unix)]
1020 {
1021 use std::os::unix::fs::MetadataExt;
1022 if metadata.nlink() > 1 {
1023 return Err(DocumentError::UnsupportedOperation {
1024 format: "filesystem".to_string(),
1025 operation: operation.to_string(),
1026 detail: format!("refusing to mutate hardlinked file `{}`", path.display()),
1027 });
1028 }
1029 }
1030 Ok(metadata)
1031}
1032
1033fn document_io_error(operation: &str, error: crate::atomic_file::AtomicError) -> DocumentError {
1037 DocumentError::IoError {
1038 detail: format!("{operation} {error}"),
1039 }
1040}
1041
1042fn write_atomic(path: &Path, bytes: &[u8], operation: &str) -> DocumentResult<()> {
1045 let metadata = guard_mutation(path, operation)?;
1046 crate::atomic_file::install(
1047 path,
1048 crate::atomic_file::AtomicInstall::replacing(bytes)
1049 .with_permissions(Some(metadata.permissions())),
1050 )
1051 .map_err(|error| document_io_error(operation, error))
1052}
1053
1054fn write_atomic_create(path: &Path, bytes: &[u8], options: CreateOptions) -> DocumentResult<()> {
1055 let operation = "create";
1056 let mut existing_permissions = None;
1057 match fs::symlink_metadata(path) {
1058 Ok(_) => match options.mode {
1059 CreateMode::NewOnly => {
1060 return Err(DocumentError::AlreadyExists {
1061 path: path.display().to_string(),
1062 });
1063 }
1064 CreateMode::Replace => {
1065 let metadata = guard_mutation(path, operation)?;
1066 existing_permissions = Some(metadata.permissions());
1067 }
1068 },
1069 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1070 Err(error) => {
1071 return Err(DocumentError::IoError {
1072 detail: format!("create preflight `{}`: {error}", path.display()),
1073 });
1074 }
1075 }
1076
1077 let unix_mode = options.effective_unix_mode(existing_permissions.is_some());
1080 let preserved = unix_mode
1081 .is_none()
1082 .then(|| existing_permissions.clone())
1083 .flatten();
1084 let mut request = crate::atomic_file::AtomicInstall::replacing(bytes)
1085 .with_permissions(preserved)
1086 .with_unix_mode(unix_mode);
1087 if options.mode == CreateMode::NewOnly {
1088 request = request.new_only();
1089 }
1090 crate::atomic_file::install(path, request).map_err(|error| {
1091 if error.target_exists() {
1094 return DocumentError::AlreadyExists {
1095 path: path.display().to_string(),
1096 };
1097 }
1098 document_io_error(operation, error)
1099 })
1100}
1101
1102#[cfg(test)]
1103mod tests {
1104 #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
1105 use super::*;
1106 use std::io::Cursor;
1107
1108 fn write_temp(dir: &Path, name: &str, contents: &str) -> PathBuf {
1109 let path = dir.join(name);
1110 fs::write(&path, contents).unwrap();
1111 path
1112 }
1113
1114 #[test]
1115 fn round_trip_open_json() {
1116 let dir = tempfile::tempdir().unwrap();
1117 let contents = r#"{"host": "example.com", "port": 993}"#;
1118 let path = write_temp(dir.path(), "config.json", contents);
1119
1120 let doc = DocumentFile::open(&path, None).unwrap();
1121
1122 assert_eq!(doc.format(), Format::Json);
1123 assert_eq!(
1124 doc.value().get("host").and_then(Value::as_str),
1125 Some("example.com")
1126 );
1127 assert_eq!(doc.source(), contents);
1128 }
1129
1130 #[test]
1131 fn value_at_reads_a_nested_address() {
1132 let dir = tempfile::tempdir().unwrap();
1133 let path = write_temp(
1134 dir.path(),
1135 "config.json",
1136 r#"{"database": {"url": "postgres://x"}}"#,
1137 );
1138 let doc = DocumentFile::open(&path, None).unwrap();
1139
1140 assert_eq!(
1141 doc.value_at("database.url").unwrap(),
1142 Value::String("postgres://x".to_string())
1143 );
1144 assert_eq!(
1145 doc.value_at("database.missing").unwrap_err().code(),
1146 "document_path_not_found"
1147 );
1148 }
1149
1150 #[test]
1151 fn open_capped_enforces_size_and_regular_file() {
1152 let dir = tempfile::tempdir().unwrap();
1153 let path = write_temp(dir.path(), "config.json", r#"{"k": "v"}"#);
1154
1155 assert!(DocumentFile::open_capped(&path, None, 1024).is_ok());
1157
1158 let err = DocumentFile::open_capped(&path, None, 4).unwrap_err();
1161 assert_eq!(err.code(), "document_too_large");
1162
1163 let dir_err = DocumentFile::open_capped(dir.path(), Some(Format::Json), 1024).unwrap_err();
1165 assert_eq!(dir_err.code(), "document_io_failed");
1166
1167 let missing =
1169 DocumentFile::open_capped(dir.path().join("absent.json"), None, 1024).unwrap_err();
1170 assert_ne!(missing.code(), "document_too_large");
1171 }
1172
1173 #[cfg(unix)]
1174 #[test]
1175 fn capped_read_uses_the_open_handle_when_the_path_is_replaced() {
1176 let dir = tempfile::tempdir().unwrap();
1177 let original = r#"{"source":"original"}"#;
1178 let path = write_temp(dir.path(), "config.json", original);
1179 let handle = open_read_handle(&path, SymlinkPolicy::Follow).unwrap();
1180 inspect_capped_source(&handle, &path, 64).unwrap();
1181
1182 fs::rename(&path, dir.path().join("original.json")).unwrap();
1183 fs::write(&path, r#"{"source":"replacement"}"#).unwrap();
1184
1185 let source = read_capped_contents(handle, &path, 64).unwrap();
1186 assert_eq!(source, original);
1187 }
1188
1189 #[cfg(unix)]
1190 #[test]
1191 fn capped_read_rechecks_the_actual_bytes_after_metadata() {
1192 use std::io::Write as _;
1193
1194 let dir = tempfile::tempdir().unwrap();
1195 let path = write_temp(dir.path(), "config.json", "{}");
1196 let handle = open_read_handle(&path, SymlinkPolicy::Follow).unwrap();
1197 inspect_capped_source(&handle, &path, 4).unwrap();
1198
1199 let mut writer = OpenOptions::new().append(true).open(&path).unwrap();
1200 writer.write_all(b"123").unwrap();
1201 writer.sync_all().unwrap();
1202
1203 let error = read_capped_contents(handle, &path, 4).unwrap_err();
1204 assert_eq!(error.code(), "document_too_large");
1205 }
1206
1207 #[cfg(all(unix, feature = "libc"))]
1208 #[test]
1209 fn open_capped_can_atomically_refuse_a_symlink() {
1210 let dir = tempfile::tempdir().unwrap();
1211 let target = write_temp(dir.path(), "target.json", r#"{"k": "v"}"#);
1212 let link = dir.path().join("link.json");
1213 std::os::unix::fs::symlink(&target, &link).unwrap();
1214
1215 assert!(
1216 DocumentFile::open_capped_with_policy(&link, None, 1024, SymlinkPolicy::Follow).is_ok()
1217 );
1218 let error =
1219 DocumentFile::open_capped_with_policy(&link, None, 1024, SymlinkPolicy::NoFollow)
1220 .unwrap_err();
1221 assert_eq!(error.code(), "document_io_failed");
1222 }
1223
1224 #[test]
1225 fn create_atomic_is_no_clobber_and_returns_a_file_handle() {
1226 let dir = tempfile::tempdir().unwrap();
1227 let path = dir.path().join("config.json");
1228 let document = Document::parse(r#"{"port": 993}"#, Format::Json).unwrap();
1229
1230 let created = DocumentFile::create_atomic(&path, document, CreateOptions::new()).unwrap();
1231 assert_eq!(created.path(), path);
1232 assert_eq!(fs::read_to_string(&path).unwrap(), r#"{"port": 993}"#);
1233
1234 let replacement = Document::parse(r#"{"port": 1024}"#, Format::Json).unwrap();
1235 let error =
1236 DocumentFile::create_atomic(&path, replacement, CreateOptions::new()).unwrap_err();
1237 assert_eq!(error.code(), "document_target_exists");
1238 assert_eq!(fs::read_to_string(&path).unwrap(), r#"{"port": 993}"#);
1239 }
1240
1241 #[test]
1242 fn create_atomic_requires_explicit_replace() {
1243 let dir = tempfile::tempdir().unwrap();
1244 let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
1245 let replacement = Document::parse(r#"{"port": 1024}"#, Format::Json).unwrap();
1246
1247 let created =
1248 DocumentFile::create_atomic(&path, replacement, CreateOptions::new().replace())
1249 .unwrap();
1250
1251 assert_eq!(created.value_at("port").unwrap(), Value::Integer(1024));
1252 assert_eq!(fs::read_to_string(&path).unwrap(), r#"{"port": 1024}"#);
1253 }
1254
1255 #[cfg(unix)]
1260 #[test]
1261 fn create_atomic_replace_preserves_the_targets_mode_unless_told_otherwise() {
1262 use std::os::unix::fs::PermissionsExt as _;
1263
1264 let dir = tempfile::tempdir().unwrap();
1265 let mode_of = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
1266
1267 for original in [0o644, 0o600, 0o640] {
1268 let path = write_temp(dir.path(), &format!("m{original:o}.json"), r#"{"a": 1}"#);
1269 fs::set_permissions(&path, fs::Permissions::from_mode(original)).unwrap();
1270 let replacement = Document::parse(r#"{"a": 2}"#, Format::Json).unwrap();
1271 DocumentFile::create_atomic(&path, replacement, CreateOptions::new().replace())
1272 .unwrap();
1273 assert_eq!(
1274 mode_of(&path),
1275 original,
1276 "replace must keep the file's mode"
1277 );
1278 }
1279
1280 let path = write_temp(dir.path(), "explicit.json", r#"{"a": 1}"#);
1282 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1283 let replacement = Document::parse(r#"{"a": 2}"#, Format::Json).unwrap();
1284 DocumentFile::create_atomic(
1285 &path,
1286 replacement,
1287 CreateOptions::new().replace().unix_mode(0o600),
1288 )
1289 .unwrap();
1290 assert_eq!(mode_of(&path), 0o600);
1291
1292 let fresh = dir.path().join("fresh.json");
1294 let document = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
1295 DocumentFile::create_atomic(&fresh, document, CreateOptions::new()).unwrap();
1296 assert_eq!(mode_of(&fresh), 0o600);
1297 }
1298
1299 #[cfg(feature = "toml")]
1303 #[test]
1304 fn create_atomic_refuses_a_document_the_path_could_not_reopen() {
1305 let dir = tempfile::tempdir().unwrap();
1306 let path = dir.path().join("mismatch.toml");
1307 let document = Document::parse(r#"{"port": 993}"#, Format::Json).unwrap();
1308
1309 let error = DocumentFile::create_atomic(&path, document, CreateOptions::new()).unwrap_err();
1310
1311 assert_eq!(error.code(), "document_unsupported_operation");
1312 assert!(
1313 !path.exists(),
1314 "nothing may be written when the check fails"
1315 );
1316 }
1317
1318 #[cfg(unix)]
1319 #[test]
1320 fn create_atomic_applies_requested_private_mode() {
1321 use std::os::unix::fs::PermissionsExt as _;
1322
1323 let dir = tempfile::tempdir().unwrap();
1324 let path = dir.path().join("config.json");
1325 let document = Document::parse(r#"{"port": 993}"#, Format::Json).unwrap();
1326
1327 DocumentFile::create_atomic(&path, document, CreateOptions::new().unix_mode(0o640))
1328 .unwrap();
1329
1330 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1331 assert_eq!(mode, 0o640);
1332 }
1333
1334 #[test]
1335 fn create_atomic_rejects_invalid_permission_bits_before_writing() {
1336 let dir = tempfile::tempdir().unwrap();
1337 let path = dir.path().join("config.json");
1338 let document = Document::parse(r#"{"port": 993}"#, Format::Json).unwrap();
1339
1340 let error =
1341 DocumentFile::create_atomic(&path, document, CreateOptions::new().unix_mode(0o1600))
1342 .unwrap_err();
1343
1344 assert_eq!(error.code(), "document_invalid_argument");
1345 assert!(!path.exists());
1346 }
1347
1348 #[cfg(unix)]
1349 #[test]
1350 fn create_atomic_replace_refuses_symlinks_and_hardlinks() {
1351 let dir = tempfile::tempdir().unwrap();
1352 let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
1353 let symlink = dir.path().join("symlink.json");
1354 std::os::unix::fs::symlink(&target, &symlink).unwrap();
1355 let replacement = Document::parse(r#"{"port": 1024}"#, Format::Json).unwrap();
1356
1357 let symlink_error = DocumentFile::create_atomic(
1358 &symlink,
1359 replacement.clone(),
1360 CreateOptions::new().replace(),
1361 )
1362 .unwrap_err();
1363 assert_eq!(symlink_error.code(), "document_unsupported_operation");
1364 assert_eq!(fs::read_to_string(&target).unwrap(), r#"{"port": 993}"#);
1365
1366 let hardlink = dir.path().join("hardlink.json");
1367 fs::hard_link(&target, &hardlink).unwrap();
1368 let hardlink_error =
1369 DocumentFile::create_atomic(&hardlink, replacement, CreateOptions::new().replace())
1370 .unwrap_err();
1371 assert_eq!(hardlink_error.code(), "document_unsupported_operation");
1372 assert_eq!(fs::read_to_string(&target).unwrap(), r#"{"port": 993}"#);
1373 }
1374
1375 #[cfg(unix)]
1376 #[test]
1377 fn create_atomic_replace_refuses_a_dangling_symlink() {
1378 let dir = tempfile::tempdir().unwrap();
1379 let missing = dir.path().join("missing.json");
1380 let symlink = dir.path().join("dangling.json");
1381 std::os::unix::fs::symlink(&missing, &symlink).unwrap();
1382 let replacement = Document::parse(r#"{"port": 1024}"#, Format::Json).unwrap();
1383
1384 let error =
1385 DocumentFile::create_atomic(&symlink, replacement, CreateOptions::new().replace())
1386 .unwrap_err();
1387
1388 assert_eq!(error.code(), "document_unsupported_operation");
1389 assert!(
1390 fs::symlink_metadata(&symlink)
1391 .unwrap()
1392 .file_type()
1393 .is_symlink()
1394 );
1395 assert!(!missing.exists());
1396 }
1397
1398 #[test]
1399 fn edit_rolls_back_memory_and_disk_when_the_closure_fails() {
1400 let dir = tempfile::tempdir().unwrap();
1401 let original = r#"{"port": 993}"#;
1402 let path = write_temp(dir.path(), "config.json", original);
1403 let mut document = DocumentFile::open(&path, None).unwrap();
1404
1405 let error = document
1406 .edit(|draft| {
1407 draft.set("port", Value::Integer(1024))?;
1408 Err(DocumentError::InvalidArgument {
1409 detail: "validation failed".to_string(),
1410 })
1411 })
1412 .unwrap_err();
1413
1414 assert_eq!(error.code(), "document_invalid_argument");
1415 assert_eq!(document.source(), original);
1416 assert_eq!(document.value_at("port").unwrap(), Value::Integer(993));
1417 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1418 }
1419
1420 #[test]
1421 fn typed_get_and_set_enforce_the_stated_type() {
1422 use crate::document::ValueType;
1423 let dir = tempfile::tempdir().unwrap();
1424 let path = write_temp(dir.path(), "config.json", r#"{"port": 8080, "host": "x"}"#);
1425 let mut doc = DocumentFile::open(&path, None).unwrap();
1426
1427 assert!(doc.value_at_typed("port", ValueType::Number).is_ok());
1430 assert_eq!(
1431 doc.value_at_typed("port", ValueType::String)
1432 .unwrap_err()
1433 .code(),
1434 "document_type_mismatch"
1435 );
1436 assert!(doc.value_at_typed("host", ValueType::Json).is_ok());
1437
1438 doc.set_typed("port", Some("9090"), ValueType::Number)
1440 .unwrap();
1441 assert_eq!(
1442 doc.value_at("port").unwrap(),
1443 Value::from(serde_json::json!(9090))
1444 );
1445 assert_eq!(
1446 doc.set_typed("port", Some("not-a-number"), ValueType::Number)
1447 .unwrap_err()
1448 .code(),
1449 "document_parse_failed"
1450 );
1451 }
1452
1453 #[test]
1454 fn decode_and_edit_and_validate_share_one_typed_boundary() {
1455 #[derive(Debug, serde::Deserialize)]
1456 #[serde(deny_unknown_fields)]
1457 struct Config {
1458 port: u16,
1459 }
1460
1461 let dir = tempfile::tempdir().unwrap();
1462 let original = r#"{"port": 8080}"#;
1463 let path = write_temp(dir.path(), "config.json", original);
1464 let mut document = DocumentFile::open(&path, None).unwrap();
1465
1466 assert_eq!(document.decode::<Config>().unwrap().port, 8080);
1467
1468 let error = document
1469 .edit_and_validate::<Config>(|draft| {
1470 draft.set("port", Value::String("invalid".to_string()))
1471 })
1472 .unwrap_err();
1473 assert_eq!(error.code(), "document_type_mismatch");
1474 assert_eq!(document.source(), original);
1475 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1476
1477 let config = document
1478 .edit_and_validate::<Config>(|draft| draft.set("port", Value::Unsigned(9090)))
1479 .unwrap();
1480 assert_eq!(config.port, 9090);
1481 assert_eq!(document.value_at("port").unwrap(), Value::Unsigned(9090));
1482 }
1483
1484 #[cfg(feature = "toml")]
1485 #[test]
1486 fn round_trip_open_toml() {
1487 let dir = tempfile::tempdir().unwrap();
1488 let contents = "# leading comment\nhost = \"example.com\"\nport = 993\n";
1489 let path = write_temp(dir.path(), "config.toml", contents);
1490
1491 let doc = DocumentFile::open(&path, None).unwrap();
1492
1493 assert_eq!(doc.format(), Format::Toml);
1494 assert_eq!(
1495 doc.value().get("host").and_then(Value::as_str),
1496 Some("example.com")
1497 );
1498 assert_eq!(doc.source(), contents);
1499 }
1500
1501 #[cfg(feature = "toml")]
1502 #[test]
1503 fn set_scalar_preserves_toml_comments_and_formatting() {
1504 let dir = tempfile::tempdir().unwrap();
1505 let contents = "# leading comment\nhost = \"example.com\"\nport = 993 # inline comment\n";
1506 let path = write_temp(dir.path(), "config.toml", contents);
1507 let mut doc = DocumentFile::open(&path, None).unwrap();
1508
1509 doc.set("port", Value::Integer(1024)).unwrap();
1510 doc.save().unwrap();
1511
1512 let saved = fs::read_to_string(&path).unwrap();
1513 assert!(saved.contains("# leading comment"));
1514 assert!(saved.contains("port = 1024"));
1515 assert_eq!(
1516 doc.value().get("port").and_then(Value::as_integer),
1517 Some(1024)
1518 );
1519 assert_eq!(doc.source(), saved);
1520 }
1521
1522 #[cfg(feature = "toml")]
1527 #[test]
1528 fn toml_array_edits_never_invent_or_misattribute_a_comment() {
1529 let contents = "paths = [\n \"one\", # first\n \"two\", # second\n]\n";
1530
1531 let mut grown = Document::parse(contents, Format::Toml).unwrap();
1532 grown
1533 .set(
1534 "paths",
1535 Value::Array(vec![
1536 Value::String("a".into()),
1537 Value::String("b".into()),
1538 Value::String("c".into()),
1539 ]),
1540 )
1541 .unwrap();
1542 assert_eq!(
1543 grown.source(),
1544 "paths = [\n \"a\", # first\n \"b\",\n \"c\", # second\n]\n",
1545 "an appended element must carry no comment of its own"
1546 );
1547
1548 let mut shrunk = Document::parse(contents, Format::Toml).unwrap();
1549 shrunk
1550 .set("paths", Value::Array(vec![Value::String("only".into())]))
1551 .unwrap();
1552 assert_eq!(
1553 shrunk.source(),
1554 "paths = [\n \"only\", # first\n]\n",
1555 "the surviving element keeps its own comment, not the removed one's"
1556 );
1557
1558 let mut replaced = Document::parse(contents, Format::Toml).unwrap();
1560 replaced
1561 .set(
1562 "paths",
1563 Value::Array(vec![Value::String("x".into()), Value::String("y".into())]),
1564 )
1565 .unwrap();
1566 assert_eq!(
1567 replaced.source(),
1568 "paths = [\n \"x\", # first\n \"y\", # second\n]\n"
1569 );
1570
1571 let mut inline = Document::parse("paths = [ \"one\" ]\n", Format::Toml).unwrap();
1573 inline
1574 .set(
1575 "paths",
1576 Value::Array(vec![
1577 Value::String("one".into()),
1578 Value::String("two".into()),
1579 ]),
1580 )
1581 .unwrap();
1582 assert_eq!(inline.source(), "paths = [ \"one\", \"two\" ]\n");
1583 }
1584
1585 #[cfg(feature = "toml")]
1586 #[test]
1587 fn set_toml_array_preserves_single_line_decor() {
1588 let contents = "# before\npaths = [ \"old\", 'second', ] # keep this\nother = 42\n";
1589 let mut document = Document::parse(contents, Format::Toml).unwrap();
1590
1591 document
1592 .set(
1593 "paths",
1594 Value::Array(vec![
1595 Value::String("new".to_string()),
1596 Value::String("next".to_string()),
1597 ]),
1598 )
1599 .unwrap();
1600
1601 assert_eq!(
1602 document.source(),
1603 "# before\npaths = [ \"new\", \"next\", ] # keep this\nother = 42\n"
1604 );
1605 }
1606
1607 #[cfg(feature = "toml")]
1608 #[test]
1609 fn set_toml_array_preserves_multiline_comments_and_trailing_comma() {
1610 let contents = "paths = [\n \"one\", # first\n \"two\", # second\n]\nother = 42\n";
1611 let mut document = Document::parse(contents, Format::Toml).unwrap();
1612
1613 document
1614 .set(
1615 "paths",
1616 Value::Array(vec![
1617 Value::String("uno".to_string()),
1618 Value::String("dos".to_string()),
1619 ]),
1620 )
1621 .unwrap();
1622
1623 assert_eq!(
1624 document.source(),
1625 "paths = [\n \"uno\", # first\n \"dos\", # second\n]\nother = 42\n"
1626 );
1627 }
1628
1629 #[cfg(feature = "toml")]
1630 #[test]
1631 fn set_toml_array_element_preserves_its_neighbors() {
1632 let contents = "paths = [\n \"one\", # first\n \"two\", # second\n]\n";
1633 let mut document = Document::parse(contents, Format::Toml).unwrap();
1634
1635 document
1636 .set("paths.1", Value::String("changed".to_string()))
1637 .unwrap();
1638
1639 assert_eq!(
1640 document.source(),
1641 "paths = [\n \"one\", # first\n \"changed\", # second\n]\n"
1642 );
1643 }
1644
1645 #[cfg(feature = "toml")]
1646 #[test]
1647 fn set_toml_array_can_become_empty_without_touching_neighbors() {
1648 let contents = "before = 1\npaths = [ \"one\", ] # list\nafter = 2\n";
1649 let mut document = Document::parse(contents, Format::Toml).unwrap();
1650
1651 document.set("paths", Value::Array(Vec::new())).unwrap();
1652
1653 assert_eq!(
1654 document.source(),
1655 "before = 1\npaths = [ ] # list\nafter = 2\n"
1656 );
1657 }
1658
1659 #[cfg(feature = "toml")]
1660 #[test]
1661 fn set_toml_inline_table_preserves_layout_and_comments() {
1662 let contents = "cache = { ttl_s = 1, enabled = true } # cache\nother = 42\n";
1663 let mut document = Document::parse(contents, Format::Toml).unwrap();
1664 let replacement = Value::from(serde_json::json!({
1665 "enabled": false,
1666 "ttl_s": 60
1667 }));
1668
1669 document.set("cache", replacement).unwrap();
1670
1671 assert_eq!(
1672 document.source(),
1673 "cache = { ttl_s = 60, enabled = false } # cache\nother = 42\n"
1674 );
1675 }
1676
1677 #[cfg(feature = "toml")]
1678 #[test]
1679 fn set_toml_ordinary_table_preserves_header_and_unrelated_section() {
1680 let contents = "# lead\n[cache] # cache header\nttl_s = 1 # ttl\nenabled = true\n\n[next]\nvalue = 9\n";
1681 let mut document = Document::parse(contents, Format::Toml).unwrap();
1682 let replacement = Value::from(serde_json::json!({
1683 "enabled": false,
1684 "ttl_s": 60
1685 }));
1686
1687 document.set("cache", replacement).unwrap();
1688
1689 assert_eq!(
1690 document.source(),
1691 "# lead\n[cache] # cache header\nttl_s = 60 # ttl\nenabled = false\n\n[next]\nvalue = 9\n"
1692 );
1693 }
1694
1695 #[cfg(feature = "toml")]
1696 #[test]
1697 fn set_toml_collection_preserves_unchanged_datetime_syntax() {
1698 let contents = "[cache]\nexpires_at = 2026-08-04T12:30:00Z\npaths = [\"one\"]\n";
1699 let mut document = Document::parse(contents, Format::Toml).unwrap();
1700 let replacement = document.value_at("cache").unwrap();
1701
1702 document.set("cache", replacement).unwrap();
1703
1704 assert_eq!(document.source(), contents);
1705 }
1706
1707 #[cfg(feature = "toml")]
1708 #[test]
1709 fn set_toml_array_of_tables_is_explicitly_refused() {
1710 let contents = "[[servers]]\nname = \"one\"\n[[servers]]\nname = \"two\"\n";
1711 let mut document = Document::parse(contents, Format::Toml).unwrap();
1712 let replacement = document.value_at("servers").unwrap();
1713
1714 let error = document.set("servers", replacement).unwrap_err();
1715
1716 assert_eq!(error.code(), "document_unsupported_operation");
1717 assert_eq!(document.source(), contents);
1718 }
1719
1720 #[cfg(feature = "ini")]
1721 #[test]
1722 fn save_refuses_source_its_own_parser_rejects() {
1723 let dir = tempfile::tempdir().unwrap();
1727 let original = "[db]\nhost=localhost\n";
1728 let path = write_temp(dir.path(), "config.ini", original);
1729 let doc = DocumentFile::open(&path, None).unwrap();
1730
1731 let error = doc
1732 .save_atomic("[db]\nhost=localhost\n\n[db]\nport=5432\n")
1733 .unwrap_err();
1734 assert_eq!(error.code(), "document_write_would_corrupt");
1735 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1737 }
1738
1739 #[cfg(feature = "ini")]
1740 #[test]
1741 fn save_writes_source_the_parser_accepts() {
1742 let dir = tempfile::tempdir().unwrap();
1743 let path = write_temp(dir.path(), "config.ini", "[db]\nhost=localhost\n");
1744 let mut doc = DocumentFile::open(&path, None).unwrap();
1745
1746 doc.set("db.port", Value::String("5432".to_string()))
1747 .unwrap();
1748 doc.save().unwrap();
1749
1750 assert_eq!(
1753 fs::read_to_string(&path).unwrap(),
1754 "[db]\nhost=localhost\nport=5432\n"
1755 );
1756 assert!(DocumentFile::open(&path, None).is_ok());
1757 }
1758
1759 #[cfg(unix)]
1760 #[test]
1761 fn atomic_save_preserves_file_mode() {
1762 use std::os::unix::fs::PermissionsExt;
1763
1764 let dir = tempfile::tempdir().unwrap();
1765 let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
1766 fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
1767 let mut doc = DocumentFile::open(&path, None).unwrap();
1768
1769 doc.set("port", Value::Integer(1024)).unwrap();
1770 doc.save().unwrap();
1771
1772 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1773 assert_eq!(mode, 0o640);
1774 }
1775
1776 #[cfg(unix)]
1777 #[test]
1778 fn symlink_target_is_rejected_for_mutation() {
1779 let dir = tempfile::tempdir().unwrap();
1780 let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
1781 let link = dir.path().join("link.json");
1782 std::os::unix::fs::symlink(&target, &link).unwrap();
1783
1784 let mut doc = DocumentFile::open(&link, None).unwrap();
1786
1787 doc.set("port", Value::Integer(1024)).unwrap();
1789 let err = doc.save().unwrap_err();
1790 assert!(matches!(err, DocumentError::UnsupportedOperation { .. }));
1791
1792 let target_contents = fs::read_to_string(&target).unwrap();
1794 assert_eq!(target_contents, r#"{"port": 993}"#);
1795 }
1796
1797 #[test]
1798 fn from_reader_parses_in_memory_cursor() {
1799 let cursor = Cursor::new(br#"{"host": "example.com"}"#.to_vec());
1800
1801 let doc = Document::from_reader(cursor, Format::Json).unwrap();
1802
1803 assert_eq!(
1804 doc.value().get("host").and_then(Value::as_str),
1805 Some("example.com")
1806 );
1807 }
1808
1809 #[test]
1810 fn document_from_str_encode_round_trip() {
1811 let doc = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
1812 let encoded = doc.encode().unwrap();
1813 let reparsed = Document::parse(&encoded, Format::Json).unwrap();
1814 assert_eq!(
1815 reparsed.value().get("a").and_then(Value::as_integer),
1816 Some(1)
1817 );
1818 }
1819
1820 #[test]
1821 fn document_edits_source_in_memory_without_a_file() {
1822 let mut doc = Document::parse("{\n \"host\": \"old\"\n}\n", Format::Json).unwrap();
1825 doc.set("host", Value::String("new".to_string())).unwrap();
1826 doc.set("imap.port", Value::Integer(993)).unwrap(); assert_eq!(
1829 doc.source(),
1830 "{\n \"host\": \"new\",\n \"imap\": {\n \"port\": 993\n }\n}\n"
1831 );
1832 assert_eq!(
1833 doc.value_at("imap.port").unwrap(),
1834 Value::from(serde_json::json!(993))
1835 );
1836 }
1837
1838 #[test]
1839 fn unset_is_false_for_anything_already_absent() {
1840 let mut doc = Document::parse(
1841 r#"{"service":{"host":"example","ports":[80]}}"#,
1842 Format::Json,
1843 )
1844 .unwrap();
1845
1846 assert!(!doc.unset("service.missing").unwrap());
1849 assert!(!doc.unset("missing.parent").unwrap());
1850 assert!(!doc.unset("missing.deeply.nested").unwrap());
1851
1852 assert!(doc.unset("service.host.child").is_err()); assert!(doc.unset("service.ports.9").is_err()); assert!(doc.unset(r"service\q").is_err()); }
1858
1859 #[cfg(feature = "markdown")]
1860 #[test]
1861 fn every_markdown_write_verb_is_refused() {
1862 let source = "# Title\n\nThe lead.\n";
1863 let mut doc = Document::parse(source, Format::Markdown).unwrap();
1864
1865 assert_eq!(
1868 doc.value_at("h1.0.text").unwrap(),
1869 Value::String("Title".to_string())
1870 );
1871 assert_eq!(
1872 doc.value_at("h1.Tit.paragraph.0.text").unwrap(),
1873 Value::String("The lead.".to_string())
1874 );
1875
1876 let refusals: Vec<DocumentError> = vec![
1880 doc.set("h1.0.text", Value::String("New".to_string()))
1881 .unwrap_err(),
1882 doc.add("preamble", "x", "type", &[]).unwrap_err(),
1883 doc.remove("preamble", "x", "type").unwrap_err(),
1884 doc.unset("h1.0").unwrap_err(),
1885 doc.unset("nothing.here").unwrap_err(),
1886 doc.encode().unwrap_err(),
1887 ];
1888 for error in refusals {
1889 assert_eq!(error.code(), "document_unsupported_operation");
1890 assert!(
1891 error.to_string().contains("read-only"),
1892 "refusal must name the reason: {error}"
1893 );
1894 }
1895
1896 assert_eq!(doc.source(), source);
1898 }
1899
1900 #[cfg(feature = "markdown")]
1901 #[test]
1902 fn markdown_save_never_reaches_disk() {
1903 let dir = tempfile::tempdir().unwrap();
1904 let path = write_temp(dir.path(), "README.md", "# Title\n");
1905 assert!(DocumentFile::open(&path, None).is_err());
1907
1908 let doc = DocumentFile::open(&path, Some(Format::Markdown)).unwrap();
1909 let error = doc.save().unwrap_err();
1911 assert_eq!(error.code(), "document_unsupported_operation");
1912 assert_eq!(fs::read_to_string(&path).unwrap(), "# Title\n");
1913 }
1914
1915 #[cfg(feature = "yaml")]
1916 #[test]
1917 fn yaml_write_rejects_cst_ambiguous_mapping_segments() {
1918 let mut numeric = Document::parse("\"123\": value\n", Format::Yaml).unwrap();
1919 assert!(
1920 numeric
1921 .set("123", Value::String("changed".to_string()))
1922 .is_err()
1923 );
1924 assert!(numeric.unset("123").is_err());
1925
1926 let mut bracketed = Document::parse("\"a[0]\": value\n", Format::Yaml).unwrap();
1927 assert!(
1928 bracketed
1929 .set("a[0]", Value::String("changed".to_string()))
1930 .is_err()
1931 );
1932 }
1933}