1use std::collections::{BTreeMap, BTreeSet};
20use std::io::{Read, Write};
21use std::path::{Path, PathBuf};
22
23use serde::{Deserialize, Serialize};
24use sha2::{Digest, Sha256};
25
26use super::layout::RelPath;
27use super::transport::READ_MAX_FILE_BYTES;
28use crate::error::{CoreError, DeployFinding};
29
30pub const EMPTY_RENDER_SHA256: &str =
33 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
34
35pub fn sha256_hex(bytes: &[u8]) -> String {
37 format!("{:x}", Sha256::digest(bytes))
38}
39
40#[derive(Debug, Clone, Default, PartialEq, Eq)]
43pub struct SourceTree {
44 pub files: BTreeMap<RelPath, Vec<u8>>,
46}
47
48#[derive(Debug, Clone, Default, PartialEq, Eq)]
50pub struct RenderedTree {
51 pub files: BTreeMap<RelPath, Vec<u8>>,
53}
54
55impl RenderedTree {
56 pub fn bytes(&self) -> usize {
58 self.files.values().map(Vec::len).sum()
59 }
60
61 pub fn render_sha256(&self) -> String {
63 let mut hasher = Sha256::new();
64 for (path, bytes) in &self.files {
65 hasher.update(path.as_str().as_bytes());
66 hasher.update([0u8]);
67 hasher.update(sha256_hex(bytes).as_bytes());
68 hasher.update(b"\n");
69 }
70 format!("{:x}", hasher.finalize())
71 }
72}
73
74impl SourceTree {
75 pub fn from_files<I, P, B>(files: I) -> Result<SourceTree, String>
78 where
79 I: IntoIterator<Item = (P, B)>,
80 P: AsRef<str>,
81 B: Into<Vec<u8>>,
82 {
83 let mut map = BTreeMap::new();
84 for (path, bytes) in files {
85 map.insert(RelPath::new(path.as_ref())?, bytes.into());
86 }
87 Ok(SourceTree { files: map })
88 }
89
90 pub fn read_gateway(gateway_dir: &Path, env: &str) -> Result<SourceTree, CoreError> {
106 let gateway = gateway_dir
107 .file_name()
108 .map(|n| n.to_string_lossy().into_owned())
109 .unwrap_or_default();
110 let mut reader = Reader {
111 prefix: format!("gateways/{gateway}"),
112 env: env.to_string(),
113 files: BTreeMap::new(),
114 findings: Vec::new(),
115 };
116 reader.read_root(gateway_dir);
117 if reader.findings.is_empty() {
118 Ok(SourceTree {
119 files: reader.files,
120 })
121 } else {
122 Err(CoreError::deploy_findings(reader.findings))
123 }
124 }
125}
126
127pub const RENDER_MARKER: &str = "render.json";
129
130pub(crate) const TMP_SUFFIX: &str = ".ign-render-tmp";
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(deny_unknown_fields)]
138pub struct RenderMarker {
139 pub schema_version: u32,
141 pub gateway: String,
143 pub env: String,
145 pub mode: String,
147 pub render_sha256: String,
149 pub files: Vec<MarkerFile>,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(deny_unknown_fields)]
156pub struct MarkerFile {
157 pub path: String,
159 pub sha256: String,
161 pub bytes: u64,
163}
164
165impl RenderMarker {
166 pub fn for_tree(gateway: &str, env: &str, mode: &str, tree: &RenderedTree) -> RenderMarker {
168 RenderMarker {
169 schema_version: 1,
170 gateway: gateway.to_string(),
171 env: env.to_string(),
172 mode: mode.to_string(),
173 render_sha256: tree.render_sha256(),
174 files: tree
175 .files
176 .iter()
177 .map(|(path, bytes)| MarkerFile {
178 path: path.as_str().to_string(),
179 sha256: sha256_hex(bytes),
180 bytes: bytes.len() as u64,
181 })
182 .collect(),
183 }
184 }
185
186 pub fn to_bytes(&self) -> Result<Vec<u8>, CoreError> {
188 let mut text = serde_json::to_string_pretty(self)
189 .map_err(|e| CoreError::Internal(format!("cannot serialize {RENDER_MARKER}: {e}")))?;
190 text.push('\n');
191 Ok(text.into_bytes())
192 }
193}
194
195fn refuse(out: &Path, reason: impl std::fmt::Display) -> CoreError {
196 CoreError::InvalidInput {
197 reason: format!(
198 "--out {}: {reason}; remove the directory or choose an empty or absent one",
199 out.display()
200 ),
201 }
202}
203
204enum OutPlan {
206 Create,
208 Empty,
210 Refresh(BTreeSet<String>),
213}
214
215fn check_out(tree: &RenderedTree, out: &Path, marker: &RenderMarker) -> Result<OutPlan, CoreError> {
218 if tree.files.keys().any(|p| p.as_str() == RENDER_MARKER) {
219 return Err(refuse(
220 out,
221 format!("the render holds a file named {RENDER_MARKER}"),
222 ));
223 }
224 let meta = match std::fs::symlink_metadata(out) {
225 Ok(meta) => meta,
226 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(OutPlan::Create),
227 Err(e) => return Err(refuse(out, format!("cannot inspect it ({})", e.kind()))),
228 };
229 if meta.file_type().is_symlink() {
230 return Err(refuse(out, "is a symlink; symlinks are refused"));
231 }
232 if !meta.file_type().is_dir() {
233 return Err(refuse(out, "exists and is not a directory"));
234 }
235
236 let mut files: BTreeMap<String, PathBuf> = BTreeMap::new();
237 let mut dirs: BTreeSet<String> = BTreeSet::new();
238 walk_out(out, "", &mut files, &mut dirs).map_err(|why| refuse(out, why))?;
239 if files.is_empty() && dirs.is_empty() {
240 return Ok(OutPlan::Empty);
241 }
242
243 let marker_path = match files.get(RENDER_MARKER) {
244 Some(path) => path,
245 None => {
246 let first = files
247 .keys()
248 .next()
249 .cloned()
250 .or_else(|| dirs.iter().next().cloned())
251 .unwrap_or_default();
252 return Err(refuse(
253 out,
254 format!("is not empty and holds no {RENDER_MARKER} (first entry: {first})"),
255 ));
256 }
257 };
258 let prior: RenderMarker = std::fs::read(marker_path)
259 .map_err(|e| refuse(out, format!("cannot read {RENDER_MARKER} ({})", e.kind())))
260 .and_then(|bytes| {
261 serde_json::from_slice(&bytes)
262 .map_err(|_| refuse(out, format!("{RENDER_MARKER} is not a valid render marker")))
263 })?;
264 if prior.schema_version != 1 {
265 return Err(refuse(
266 out,
267 format!(
268 "{RENDER_MARKER} has schema_version {}, expected 1",
269 prior.schema_version
270 ),
271 ));
272 }
273 if prior.gateway != marker.gateway || prior.env != marker.env {
274 return Err(refuse(
275 out,
276 format!(
277 "{RENDER_MARKER} belongs to gateway {} env {}, not gateway {} env {}",
278 prior.gateway, prior.env, marker.gateway, marker.env
279 ),
280 ));
281 }
282
283 let mut listed: BTreeSet<String> = BTreeSet::new();
284 for entry in &prior.files {
285 if !listed.insert(entry.path.clone()) {
286 return Err(refuse(
287 out,
288 format!("{RENDER_MARKER} lists {} twice", entry.path),
289 ));
290 }
291 }
292 for (rel, path) in &files {
294 if rel == RENDER_MARKER {
295 continue;
296 }
297 let Some(entry) = prior.files.iter().find(|f| &f.path == rel) else {
298 return Err(refuse(
299 out,
300 format!("{rel} is not listed in {RENDER_MARKER}"),
301 ));
302 };
303 let bytes = std::fs::read(path)
304 .map_err(|e| refuse(out, format!("cannot read {rel} ({})", e.kind())))?;
305 if sha256_hex(&bytes) != entry.sha256 {
306 return Err(refuse(
307 out,
308 format!("{rel} was modified since it was rendered"),
309 ));
310 }
311 }
312 for rel in &listed {
314 if !files.contains_key(rel) {
315 return Err(refuse(
316 out,
317 format!("{rel} is listed in {RENDER_MARKER} but missing"),
318 ));
319 }
320 if !tree.files.keys().any(|p| p.as_str() == rel) {
321 return Err(refuse(
322 out,
323 format!("{rel} was rendered before but this render no longer produces it"),
324 ));
325 }
326 }
327 for dir in &dirs {
329 let prefix = format!("{dir}/");
330 if !listed.iter().any(|rel| rel.starts_with(&prefix)) {
331 return Err(refuse(out, format!("{dir}/ holds no rendered file")));
332 }
333 }
334 for path in tree.files.keys() {
336 let rel = path.as_str();
337 if listed.contains(rel) {
338 continue;
339 }
340 if files.contains_key(rel) || dirs.contains(rel) {
341 return Err(refuse(out, format!("{rel} already exists")));
342 }
343 let mut at = 0;
344 while let Some(i) = rel[at..].find('/') {
345 at += i;
346 if files.contains_key(&rel[..at]) {
347 return Err(refuse(
348 out,
349 format!("{} is a file, not a directory", &rel[..at]),
350 ));
351 }
352 at += 1;
353 }
354 }
355 Ok(OutPlan::Refresh(listed))
356}
357
358pub(crate) fn walk_out(
362 dir: &Path,
363 rel: &str,
364 files: &mut BTreeMap<String, PathBuf>,
365 dirs: &mut BTreeSet<String>,
366) -> Result<(), String> {
367 walk_capped(dir, rel, files, dirs, usize::MAX)
368}
369
370pub(crate) fn walk_capped(
374 dir: &Path,
375 rel: &str,
376 files: &mut BTreeMap<String, PathBuf>,
377 dirs: &mut BTreeSet<String>,
378 max_entries: usize,
379) -> Result<(), String> {
380 let read = std::fs::read_dir(dir)
381 .map_err(|e| format!("cannot read directory {} ({})", join(rel, ""), e.kind()))?;
382 let listing = read.map(|entry| entry.map(|entry| (entry.file_name(), entry.path())));
383 let entries = collect_capped(listing, rel, files.len() + dirs.len(), max_entries)?;
384 for (name, path) in entries {
385 let child = join(rel, &name);
386 let meta = std::fs::symlink_metadata(&path)
387 .map_err(|e| format!("cannot inspect {child} ({})", e.kind()))?;
388 let kind = meta.file_type();
389 if kind.is_symlink() {
390 return Err(format!("{child} is a symlink; symlinks are refused"));
391 } else if kind.is_dir() {
392 dirs.insert(child.clone());
393 if files.len() + dirs.len() > max_entries {
394 return Err(format!("more than {max_entries} entries (at {child})"));
395 }
396 walk_capped(&path, &child, files, dirs, max_entries)?;
397 } else if kind.is_file() {
398 files.insert(child.clone(), path);
399 if files.len() + dirs.len() > max_entries {
400 return Err(format!("more than {max_entries} entries (at {child})"));
401 }
402 } else {
403 return Err(format!("{child} is not a regular file or directory"));
404 }
405 }
406 Ok(())
407}
408
409fn collect_capped(
414 listing: impl Iterator<Item = std::io::Result<(std::ffi::OsString, PathBuf)>>,
415 rel: &str,
416 taken: usize,
417 max_entries: usize,
418) -> Result<Vec<(String, PathBuf)>, String> {
419 let mut entries: Vec<(String, PathBuf)> = Vec::new();
420 for entry in listing {
421 let (raw, path) =
422 entry.map_err(|e| format!("cannot read a directory entry ({})", e.kind()))?;
423 let name = raw.into_string().map_err(|raw| {
424 format!(
425 "{} has a name that is not valid UTF-8",
426 join(rel, &raw.to_string_lossy())
427 )
428 })?;
429 if name.chars().any(char::is_control) {
433 return Err(format!(
434 "{} holds an entry whose name has a control character; refused",
435 join(rel, "")
436 ));
437 }
438 if taken.saturating_add(entries.len()) >= max_entries {
439 return Err(format!(
440 "more than {max_entries} entries (at {})",
441 join(rel, &name)
442 ));
443 }
444 entries.push((name, path));
445 }
446 entries.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
447 Ok(entries)
448}
449
450#[derive(Debug)]
452pub(crate) enum OpenError {
453 Io(std::io::Error),
455 Changed,
458}
459
460pub(crate) fn open_regular_file(
482 root: &Path,
483 rel: &Path,
484 before: &std::fs::Metadata,
485) -> Result<(std::fs::File, std::fs::Metadata), OpenError> {
486 #[cfg(unix)]
487 let file = open_beneath(root, rel)?;
488 #[cfg(windows)]
489 let file = {
490 use std::os::windows::fs::OpenOptionsExt;
491 let mut options = std::fs::OpenOptions::new();
492 options.read(true).custom_flags(0x0020_0000);
494 options.open(root.join(rel)).map_err(OpenError::Io)?
495 };
496 #[cfg(not(any(unix, windows)))]
497 let file = std::fs::File::open(root.join(rel)).map_err(OpenError::Io)?;
498
499 let held = file.metadata().map_err(OpenError::Io)?;
500 if held.file_type().is_symlink() || !held.is_file() {
501 return Err(OpenError::Changed);
502 }
503 #[cfg(unix)]
504 {
505 use std::os::unix::fs::MetadataExt;
506 if held.dev() != before.dev() || held.ino() != before.ino() {
507 return Err(OpenError::Changed);
508 }
509 }
510 #[cfg(not(unix))]
511 let _ = before;
512 Ok((file, held))
513}
514
515#[cfg(unix)]
517fn open_beneath(root: &Path, rel: &Path) -> Result<std::fs::File, OpenError> {
518 use rustix::fs::{Mode, OFlags, open, openat};
519 use rustix::io::Errno;
520 use std::path::Component;
521
522 let map = |e: Errno| -> OpenError {
524 if e == Errno::LOOP || e == Errno::NOTDIR {
525 OpenError::Changed
526 } else {
527 OpenError::Io(e.into())
528 }
529 };
530
531 let mut parts = Vec::new();
532 for component in rel.components() {
533 match component {
534 Component::Normal(name) => parts.push(name),
535 _ => return Err(OpenError::Changed),
537 }
538 }
539 let Some((last, parents)) = parts.split_last() else {
540 return Err(OpenError::Changed);
541 };
542
543 let mut dir = open(
544 root,
545 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
546 Mode::empty(),
547 )
548 .map_err(map)?;
549 let dir_flags = OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::DIRECTORY | OFlags::CLOEXEC;
550 for part in parents {
551 dir = openat(&dir, *part, dir_flags, Mode::empty()).map_err(map)?;
552 }
553 let fd = openat(
557 &dir,
558 *last,
559 OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
560 Mode::empty(),
561 )
562 .map_err(map)?;
563 Ok(std::fs::File::from(fd))
564}
565
566#[cfg(unix)]
580pub(crate) mod dirfd {
581 use std::fs::File;
582 use std::io::Read;
583 use std::os::unix::fs::MetadataExt;
584 use std::path::Path;
585
586 use rustix::fs::{
587 AtFlags, FileType, Mode, OFlags, mkdirat, openat, renameat, statat, unlinkat,
588 };
589 use rustix::io::Errno;
590
591 #[derive(Debug)]
593 pub(crate) enum FdError {
594 Changed,
597 Missing,
598 Exists,
599 NotEmpty,
600 Io(std::io::Error),
601 }
602
603 fn map(e: Errno) -> FdError {
604 if e == Errno::LOOP || e == Errno::NOTDIR || e == Errno::NXIO {
605 FdError::Changed
606 } else if e == Errno::NOENT {
607 FdError::Missing
608 } else if e == Errno::EXIST {
609 FdError::Exists
610 } else if e == Errno::NOTEMPTY {
611 FdError::NotEmpty
612 } else {
613 FdError::Io(e.into())
614 }
615 }
616
617 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
619 pub(crate) enum Kind {
620 Missing,
621 Dir,
622 File,
623 Symlink,
624 Other,
625 }
626
627 const DIR_FLAGS: OFlags = OFlags::RDONLY
628 .union(OFlags::DIRECTORY)
629 .union(OFlags::NOFOLLOW)
630 .union(OFlags::CLOEXEC);
631
632 pub(crate) fn open_root(path: &Path) -> Result<File, FdError> {
634 rustix::fs::open(path, DIR_FLAGS, Mode::empty())
635 .map(File::from)
636 .map_err(map)
637 }
638
639 pub(crate) fn open_dir(dir: &File, name: &str) -> Result<File, FdError> {
641 openat(dir, name, DIR_FLAGS, Mode::empty())
642 .map(File::from)
643 .map_err(map)
644 }
645
646 pub(crate) fn walk(dir: &File, parts: &[&str]) -> Result<File, FdError> {
648 let mut current = dir.try_clone().map_err(FdError::Io)?;
649 for part in parts {
650 current = open_dir(¤t, part)?;
651 }
652 Ok(current)
653 }
654
655 pub(crate) fn ensure_dir(dir: &File, name: &str) -> Result<File, FdError> {
658 match open_dir(dir, name) {
659 Err(FdError::Missing) => {
660 match mkdirat(dir, name, Mode::from_raw_mode(0o777)) {
661 Ok(()) | Err(Errno::EXIST) => {}
662 Err(e) => return Err(map(e)),
663 }
664 open_dir(dir, name)
665 }
666 other => other,
667 }
668 }
669
670 pub(crate) fn ensure_walk(dir: &File, parts: &[&str]) -> Result<File, FdError> {
672 let mut current = dir.try_clone().map_err(FdError::Io)?;
673 for part in parts {
674 current = ensure_dir(¤t, part)?;
675 }
676 Ok(current)
677 }
678
679 pub(crate) fn kind_at(dir: &File, name: &str) -> Result<Kind, FdError> {
681 match statat(dir, name, AtFlags::SYMLINK_NOFOLLOW) {
682 Ok(stat) => Ok(match FileType::from_raw_mode(stat.st_mode) {
683 FileType::Directory => Kind::Dir,
684 FileType::RegularFile => Kind::File,
685 FileType::Symlink => Kind::Symlink,
686 _ => Kind::Other,
687 }),
688 Err(Errno::NOENT) => Ok(Kind::Missing),
689 Err(e) => Err(map(e)),
690 }
691 }
692
693 #[allow(clippy::unnecessary_cast)] pub(crate) fn ident_at(dir: &File, name: &str) -> Result<Option<(u64, u64)>, FdError> {
696 match statat(dir, name, AtFlags::SYMLINK_NOFOLLOW) {
697 Ok(stat) => Ok(Some((stat.st_dev as u64, stat.st_ino as u64))),
698 Err(Errno::NOENT) => Ok(None),
699 Err(e) => Err(map(e)),
700 }
701 }
702
703 pub(crate) fn create_new(dir: &File, name: &str) -> Result<File, FdError> {
706 openat(
707 dir,
708 name,
709 OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
710 Mode::from_raw_mode(0o666),
711 )
712 .map(File::from)
713 .map_err(map)
714 }
715
716 pub(crate) fn open_overwrite(dir: &File, name: &str) -> Result<File, FdError> {
721 let fd = openat(
722 dir,
723 name,
724 OFlags::WRONLY | OFlags::CREATE | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
725 Mode::from_raw_mode(0o666),
726 )
727 .map_err(map)?;
728 let file = File::from(fd);
729 if !file.metadata().map_err(FdError::Io)?.is_file() {
730 return Err(FdError::Changed);
731 }
732 file.set_len(0).map_err(FdError::Io)?;
733 Ok(file)
734 }
735
736 pub(crate) fn read_file(
741 dir: &File,
742 name: &str,
743 cap: u64,
744 ) -> Result<((u64, u64), Vec<u8>), FdError> {
745 let fd = openat(
746 dir,
747 name,
748 OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
749 Mode::empty(),
750 )
751 .map_err(map)?;
752 let mut file = File::from(fd);
753 let meta = file.metadata().map_err(FdError::Io)?;
754 if !meta.is_file() {
755 return Err(FdError::Changed);
756 }
757 let mut bytes = Vec::with_capacity(meta.len().min(cap) as usize);
758 (&mut file)
759 .take(cap + 1)
760 .read_to_end(&mut bytes)
761 .map_err(FdError::Io)?;
762 Ok(((meta.dev(), meta.ino()), bytes))
763 }
764
765 pub(crate) fn rename_at(
767 from_dir: &File,
768 from: &str,
769 to_dir: &File,
770 to: &str,
771 ) -> Result<(), FdError> {
772 renameat(from_dir, from, to_dir, to).map_err(map)
773 }
774
775 pub(crate) fn unlink_at(dir: &File, name: &str) -> Result<(), FdError> {
777 unlinkat(dir, name, AtFlags::empty()).map_err(map)
778 }
779
780 pub(crate) fn rmdir_at(dir: &File, name: &str) -> Result<(), FdError> {
783 unlinkat(dir, name, AtFlags::REMOVEDIR).map_err(map)
784 }
785}
786
787fn tree_meta(repo: &Path, rel: &str, hint: Option<&str>) -> Result<std::fs::Metadata, CoreError> {
789 match std::fs::symlink_metadata(repo.join(rel)) {
790 Ok(meta) if meta.file_type().is_symlink() => Err(CoreError::deploy_tree_invalid(
791 rel,
792 None,
793 None,
794 "is a symlink; symlinks are refused",
795 )),
796 Ok(meta) => Ok(meta),
797 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
798 let message = match hint {
799 Some(hint) => format!("does not exist ({hint})"),
800 None => "does not exist".to_string(),
801 };
802 Err(CoreError::deploy_tree_invalid(rel, None, None, message))
803 }
804 Err(e) => Err(CoreError::deploy_tree_invalid(
805 rel,
806 None,
807 None,
808 format!("cannot inspect ({})", e.kind()),
809 )),
810 }
811}
812
813pub(crate) fn require_repo_dir(repo: &Path, rel: &str) -> Result<(), CoreError> {
815 let meta = tree_meta(repo, rel, None)?;
816 if meta.file_type().is_dir() {
817 Ok(())
818 } else {
819 Err(CoreError::deploy_tree_invalid(
820 rel,
821 None,
822 None,
823 "must be a directory",
824 ))
825 }
826}
827
828pub(crate) fn read_repo_file(
840 repo: &Path,
841 rel: &str,
842 hint: Option<&str>,
843) -> Result<Vec<u8>, CoreError> {
844 if let Some((parent, _)) = rel.rsplit_once('/') {
845 let mut prefix = String::new();
846 for part in parent.split('/') {
847 if !prefix.is_empty() {
848 prefix.push('/');
849 }
850 prefix.push_str(part);
851 match std::fs::symlink_metadata(repo.join(&prefix)) {
852 Err(e) if e.kind() == std::io::ErrorKind::NotFound => break,
854 _ => require_repo_dir(repo, &prefix)?,
855 }
856 }
857 }
858 let meta = tree_meta(repo, rel, hint)?;
859 if !meta.file_type().is_file() {
860 return Err(CoreError::deploy_tree_invalid(
861 rel,
862 None,
863 None,
864 "is not a regular file",
865 ));
866 }
867 let (file, held) = open_regular_file(repo, Path::new(rel), &meta).map_err(|e| match e {
868 OpenError::Changed => CoreError::deploy_tree_invalid(
869 rel,
870 None,
871 None,
872 "changed while it was read; symlinks are refused",
873 ),
874 OpenError::Io(e) => {
875 CoreError::deploy_tree_invalid(rel, None, None, format!("cannot read ({})", e.kind()))
876 }
877 })?;
878 if held.len() > READ_MAX_FILE_BYTES {
879 return Err(CoreError::deploy_tree_invalid(
880 rel,
881 None,
882 None,
883 format!("is over the {READ_MAX_FILE_BYTES} byte file cap"),
884 ));
885 }
886 let mut bytes = Vec::new();
887 file.take(READ_MAX_FILE_BYTES + 1)
888 .read_to_end(&mut bytes)
889 .map_err(|e| {
890 CoreError::deploy_tree_invalid(rel, None, None, format!("cannot read ({})", e.kind()))
891 })?;
892 if bytes.len() as u64 > READ_MAX_FILE_BYTES {
893 return Err(CoreError::deploy_tree_invalid(
894 rel,
895 None,
896 None,
897 format!("grew past the {READ_MAX_FILE_BYTES} byte file cap while it was read"),
898 ));
899 }
900 Ok(bytes)
901}
902
903fn io_failure(what: &str, path: &Path, e: &std::io::Error) -> CoreError {
904 if e.kind() == std::io::ErrorKind::AlreadyExists {
905 return CoreError::InvalidInput {
906 reason: format!(
907 "--out: {} already exists (another writer won the race); nothing was replaced",
908 path.display()
909 ),
910 };
911 }
912 CoreError::Internal(format!("cannot {what} {}: {e}", path.display()))
913}
914
915fn write_replacing(path: &Path, bytes: &[u8]) -> Result<(), CoreError> {
918 let mut tmp = path.as_os_str().to_owned();
919 tmp.push(TMP_SUFFIX);
920 let tmp = PathBuf::from(tmp);
921 let mut file = std::fs::OpenOptions::new()
922 .write(true)
923 .create_new(true)
924 .open(&tmp)
925 .map_err(|e| io_failure("create", &tmp, &e))?;
926 file.write_all(bytes)
927 .map_err(|e| io_failure("write", &tmp, &e))?;
928 drop(file);
929 std::fs::rename(&tmp, path).map_err(|e| io_failure("replace", path, &e))
930}
931
932pub fn write_out(tree: &RenderedTree, out: &Path, marker: &RenderMarker) -> Result<(), CoreError> {
944 let plan = check_out(tree, out, marker)?;
945 let marker_bytes = marker.to_bytes()?;
946
947 match &plan {
948 OutPlan::Create => {
949 if let Some(parent) = out.parent().filter(|p| !p.as_os_str().is_empty()) {
950 std::fs::create_dir_all(parent).map_err(|e| io_failure("create", parent, &e))?;
951 }
952 std::fs::create_dir(out).map_err(|e| io_failure("create", out, &e))?;
953 }
954 OutPlan::Empty | OutPlan::Refresh(_) => {}
955 }
956 let listed = match &plan {
957 OutPlan::Refresh(listed) => Some(listed),
958 _ => None,
959 };
960
961 for (path, bytes) in &tree.files {
962 let rel = path.as_str();
963 let dest = rel.split('/').fold(out.to_path_buf(), |acc, c| acc.join(c));
964 if let Some(parent) = dest.parent() {
965 std::fs::create_dir_all(parent).map_err(|e| io_failure("create", parent, &e))?;
966 }
967 if listed.is_some_and(|l| l.contains(rel)) {
968 write_replacing(&dest, bytes)?;
969 } else {
970 let mut file = std::fs::OpenOptions::new()
971 .write(true)
972 .create_new(true)
973 .open(&dest)
974 .map_err(|e| io_failure("create", &dest, &e))?;
975 file.write_all(bytes)
976 .map_err(|e| io_failure("write", &dest, &e))?;
977 }
978 }
979 write_replacing(&out.join(RENDER_MARKER), &marker_bytes)
980}
981
982struct Reader {
983 prefix: String,
985 env: String,
987 files: BTreeMap<RelPath, Vec<u8>>,
988 findings: Vec<DeployFinding>,
989}
990
991impl Reader {
992 fn finding(&mut self, rel: &str, message: impl Into<String>) {
993 let file = if rel.is_empty() {
994 self.prefix.clone()
995 } else {
996 format!("{}/{rel}", self.prefix)
997 };
998 self.findings.push(DeployFinding {
999 file,
1000 line: None,
1001 column: None,
1002 message: message.into(),
1003 });
1004 }
1005
1006 fn sorted_entries(&mut self, dir: &Path, rel: &str) -> Vec<(String, std::path::PathBuf)> {
1008 let read = match std::fs::read_dir(dir) {
1009 Ok(read) => read,
1010 Err(e) => {
1011 self.finding(rel, format!("cannot read directory ({})", e.kind()));
1012 return Vec::new();
1013 }
1014 };
1015 let mut entries: Vec<(String, std::path::PathBuf)> = Vec::new();
1016 for entry in read {
1017 let entry = match entry {
1018 Ok(entry) => entry,
1019 Err(e) => {
1020 self.finding(rel, format!("cannot read directory entry ({})", e.kind()));
1021 continue;
1022 }
1023 };
1024 match entry.file_name().into_string() {
1025 Ok(name) => entries.push((name, entry.path())),
1026 Err(raw) => {
1027 let lossy = raw.to_string_lossy();
1028 let at = join(rel, &lossy);
1029 self.finding(&at, "name is not valid UTF-8");
1030 }
1031 }
1032 }
1033 entries.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
1034 entries
1035 }
1036
1037 fn read_root(&mut self, dir: &Path) {
1038 for (name, path) in self.sorted_entries(dir, "") {
1039 if self.dot_entry(&path, &name, &name) {
1040 continue;
1041 }
1042 match name.as_str() {
1043 "manifest.yaml" => {
1044 self.check_regular(&path, &name);
1045 }
1046 "resources" | "tags" => self.walk(&path, &name),
1047 "modes" => self.read_modes(&path, &name),
1048 _ => self.finding(
1049 &name,
1050 "unexpected entry at the gateway root (expected: manifest.yaml, resources/, tags/, modes/)",
1051 ),
1052 }
1053 }
1054 }
1055
1056 fn read_modes(&mut self, dir: &Path, rel: &str) {
1059 if !self.require_dir(dir, rel) {
1060 return;
1061 }
1062 for (name, path) in self.sorted_entries(dir, rel) {
1063 let child = join(rel, &name);
1064 if name == ".gitkeep" {
1065 self.dot_entry(&path, &child, &name);
1066 continue;
1067 }
1068 if name == self.env {
1069 self.read_env(&path, &child);
1070 continue;
1071 }
1072 match std::fs::symlink_metadata(&path) {
1073 Ok(meta) if meta.file_type().is_dir() => {}
1075 Ok(_) => self.finding(
1076 &child,
1077 "unexpected entry under modes/ (expected one directory per environment)",
1078 ),
1079 Err(e) => self.finding(&child, format!("cannot inspect ({})", e.kind())),
1080 }
1081 }
1082 }
1083
1084 fn read_env(&mut self, dir: &Path, rel: &str) {
1086 if !self.require_dir(dir, rel) {
1087 return;
1088 }
1089 for (name, path) in self.sorted_entries(dir, rel) {
1090 let child = join(rel, &name);
1091 if self.dot_entry(&path, &child, &name) {
1092 continue;
1093 }
1094 match name.as_str() {
1095 "resources" | "tags" => self.walk(&path, &child),
1096 _ => self.finding(
1097 &child,
1098 "unexpected entry under modes/<env>/ (expected: resources/, tags/)",
1099 ),
1100 }
1101 }
1102 }
1103
1104 fn require_dir(&mut self, dir: &Path, rel: &str) -> bool {
1106 match std::fs::symlink_metadata(dir) {
1107 Ok(meta) if meta.file_type().is_symlink() => {
1108 self.finding(rel, "is a symlink; symlinks are refused");
1109 false
1110 }
1111 Ok(meta) if meta.file_type().is_dir() => true,
1112 Ok(_) => {
1113 self.finding(rel, "must be a directory");
1114 false
1115 }
1116 Err(e) => {
1117 self.finding(rel, format!("cannot inspect ({})", e.kind()));
1118 false
1119 }
1120 }
1121 }
1122
1123 fn check_regular(&mut self, path: &Path, rel: &str) -> bool {
1125 match std::fs::symlink_metadata(path) {
1126 Ok(meta) if meta.file_type().is_symlink() => {
1127 self.finding(rel, "is a symlink; symlinks are refused");
1128 false
1129 }
1130 Ok(meta) if meta.file_type().is_file() => true,
1131 Ok(_) => {
1132 self.finding(rel, "is not a regular file or directory");
1133 false
1134 }
1135 Err(e) => {
1136 self.finding(rel, format!("cannot inspect ({})", e.kind()));
1137 false
1138 }
1139 }
1140 }
1141
1142 fn dot_entry(&mut self, path: &Path, rel: &str, name: &str) -> bool {
1148 if !name.starts_with('.') {
1149 return false;
1150 }
1151 match std::fs::symlink_metadata(path) {
1152 Ok(meta) if name == ".gitkeep" && meta.file_type().is_file() => {}
1153 Ok(meta) if meta.file_type().is_symlink() => {
1154 self.finding(rel, "is a symlink; symlinks are refused");
1155 }
1156 _ => self.finding(
1157 rel,
1158 "name starts with `.`; dot-named entries are refused (only a `.gitkeep` file is skipped)",
1159 ),
1160 }
1161 true
1162 }
1163
1164 fn walk(&mut self, dir: &Path, rel: &str) {
1166 if !self.require_dir(dir, rel) {
1167 return;
1168 }
1169 for (name, path) in self.sorted_entries(dir, rel) {
1170 let child = join(rel, &name);
1171 if self.dot_entry(&path, &child, &name) {
1172 continue;
1173 }
1174 let meta = match std::fs::symlink_metadata(&path) {
1175 Ok(meta) => meta,
1176 Err(e) => {
1177 self.finding(&child, format!("cannot inspect ({})", e.kind()));
1178 continue;
1179 }
1180 };
1181 let kind = meta.file_type();
1182 if kind.is_symlink() {
1183 self.finding(&child, "is a symlink; symlinks are refused");
1184 } else if kind.is_dir() {
1185 self.walk(&path, &child);
1186 } else if kind.is_file() {
1187 match RelPath::new(&child) {
1188 Err(why) => self.finding(&child, format!("unusable path: {why}")),
1189 Ok(key) => match std::fs::read(&path) {
1190 Ok(bytes) => {
1191 self.files.insert(key, bytes);
1192 }
1193 Err(e) => self.finding(&child, format!("cannot read file ({})", e.kind())),
1194 },
1195 }
1196 } else {
1197 self.finding(&child, "is not a regular file or directory");
1198 }
1199 }
1200 }
1201}
1202
1203fn join(rel: &str, name: &str) -> String {
1204 if rel.is_empty() {
1205 name.to_string()
1206 } else {
1207 format!("{rel}/{name}")
1208 }
1209}
1210
1211#[cfg(test)]
1212mod tests {
1213 use super::*;
1214
1215 #[test]
1216 fn empty_listing_digest_is_sha256_of_nothing() {
1217 assert_eq!(RenderedTree::default().render_sha256(), EMPTY_RENDER_SHA256);
1218 assert_eq!(sha256_hex(b""), EMPTY_RENDER_SHA256);
1219 }
1220
1221 #[test]
1222 fn digest_follows_the_documented_listing() {
1223 let mut tree = RenderedTree::default();
1224 tree.files
1225 .insert(RelPath::new("b/x").unwrap(), b"two".to_vec());
1226 tree.files
1227 .insert(RelPath::new("a/x").unwrap(), b"one".to_vec());
1228 let mut listing = Vec::new();
1229 for (p, c) in [("a/x", &b"one"[..]), ("b/x", &b"two"[..])] {
1230 listing.extend_from_slice(p.as_bytes());
1231 listing.push(0);
1232 listing.extend_from_slice(sha256_hex(c).as_bytes());
1233 listing.push(b'\n');
1234 }
1235 assert_eq!(tree.render_sha256(), sha256_hex(&listing));
1236 assert_eq!(tree.bytes(), 6);
1237 }
1238
1239 #[cfg(unix)]
1240 #[test]
1241 fn walk_capped_refuses_a_control_character_in_a_name() {
1242 let dir = tempfile::tempdir().expect("tempdir");
1244 std::fs::write(dir.path().join("ok.json"), b"{}").expect("write");
1245 std::fs::write(dir.path().join("evil\u{1b}[31m.json"), b"{}").expect("write");
1246 let (mut files, mut dirs) = (BTreeMap::new(), BTreeSet::new());
1247 let why = walk_capped(dir.path(), "root", &mut files, &mut dirs, 10)
1248 .expect_err("a control character is refused");
1249 assert!(why.contains("control character"), "{why}");
1250 assert!(
1251 !why.contains('\u{1b}'),
1252 "the message is itself sanitised: {why:?}"
1253 );
1254 }
1255
1256 #[test]
1257 fn walk_capped_stops_at_the_entry_cap() {
1258 let dir = tempfile::tempdir().expect("tempdir");
1259 for name in ["a", "b", "c"] {
1260 std::fs::write(dir.path().join(name), b"x").expect("write");
1261 }
1262 let (mut files, mut dirs) = (BTreeMap::new(), BTreeSet::new());
1263 walk_capped(dir.path(), "root", &mut files, &mut dirs, 3).expect("three fit");
1264 assert_eq!(files.len(), 3);
1265
1266 let (mut files, mut dirs) = (BTreeMap::new(), BTreeSet::new());
1267 let why = walk_capped(dir.path(), "root", &mut files, &mut dirs, 2)
1268 .expect_err("three entries exceed a cap of two");
1269 assert!(why.contains("more than 2 entries"), "{why}");
1270 }
1271
1272 #[test]
1273 fn walk_capped_stops_a_flat_directory_before_enumerating_it() {
1274 let pulled = std::cell::Cell::new(0_usize);
1277 let listing = (0..1_000_000_usize).map(|i| {
1278 pulled.set(pulled.get() + 1);
1279 Ok((
1280 std::ffi::OsString::from(format!("e{i}")),
1281 PathBuf::from("x"),
1282 ))
1283 });
1284 let why = collect_capped(listing, "root", 0, 5)
1285 .expect_err("a flat directory over the cap must fail");
1286 assert!(why.contains("more than 5 entries (at root/"), "{why}");
1287 assert!(pulled.get() <= 6, "pulled {} entries", pulled.get());
1288
1289 let fits = [("b", "p"), ("a", "p")]
1292 .into_iter()
1293 .map(|(n, p)| Ok((std::ffi::OsString::from(n), PathBuf::from(p))));
1294 let got = collect_capped(fits, "root", 3, 5).expect("two more fit under five");
1295 assert_eq!(
1296 got.iter().map(|e| e.0.as_str()).collect::<Vec<_>>(),
1297 ["a", "b"]
1298 );
1299 let fits = [("b", "p"), ("a", "p")]
1300 .into_iter()
1301 .map(|(n, p)| Ok((std::ffi::OsString::from(n), PathBuf::from(p))));
1302 assert!(collect_capped(fits, "root", 4, 5).is_err());
1303 }
1304
1305 #[test]
1306 fn walk_capped_counts_a_flat_directory_on_disk() {
1307 let dir = tempfile::tempdir().expect("tempdir");
1308 for i in 0..10 {
1309 std::fs::write(dir.path().join(format!("f{i}")), b"x").expect("write");
1310 }
1311 let (mut files, mut dirs) = (BTreeMap::new(), BTreeSet::new());
1312 let why = walk_capped(dir.path(), "root", &mut files, &mut dirs, 4)
1313 .expect_err("ten entries exceed a cap of four");
1314 assert!(why.contains("more than 4 entries"), "{why}");
1315 }
1316
1317 #[cfg(unix)]
1318 #[test]
1319 fn open_regular_file_rejects_a_swapped_inode() {
1320 let dir = tempfile::tempdir().expect("tempdir");
1321 let outside = tempfile::tempdir().expect("tempdir");
1322 let inside = dir.path().join("f");
1323 std::fs::write(&inside, b"inside").expect("write");
1324 let secret = outside.path().join("secret");
1325 std::fs::write(&secret, b"outside").expect("write");
1326
1327 let before = std::fs::symlink_metadata(&inside).expect("meta");
1329 let (_, held) =
1330 open_regular_file(dir.path(), Path::new("f"), &before).expect("unswapped file opens");
1331 assert_eq!(held.len(), 6);
1332
1333 std::fs::rename(&inside, dir.path().join("original")).expect("rename");
1338 std::os::unix::fs::symlink(&secret, &inside).expect("symlink");
1339 assert!(matches!(
1340 open_regular_file(dir.path(), Path::new("f"), &before),
1341 Err(OpenError::Changed)
1342 ));
1343
1344 std::fs::remove_file(&inside).expect("remove");
1346 std::fs::write(&inside, b"replaced").expect("write");
1347 assert!(matches!(
1348 open_regular_file(dir.path(), Path::new("f"), &before),
1349 Err(OpenError::Changed)
1350 ));
1351 }
1352
1353 #[cfg(unix)]
1354 #[test]
1355 fn open_regular_file_does_not_block_on_a_swapped_in_fifo() {
1356 let dir = tempfile::tempdir().expect("tempdir");
1359 let path = dir.path().join("f");
1360 std::fs::write(&path, b"regular").expect("write");
1361 let before = std::fs::symlink_metadata(&path).expect("meta");
1362 std::fs::remove_file(&path).expect("remove");
1363 let made = std::process::Command::new("mkfifo")
1364 .arg(&path)
1365 .status()
1366 .expect("mkfifo runs");
1367 assert!(made.success(), "mkfifo");
1368
1369 let root = dir.path().to_path_buf();
1370 let (tx, rx) = std::sync::mpsc::channel();
1371 std::thread::spawn(move || {
1372 let outcome = open_regular_file(&root, Path::new("f"), &before).map(|_| ());
1373 let _ = tx.send(outcome);
1374 });
1375 let outcome = rx
1376 .recv_timeout(std::time::Duration::from_secs(5))
1377 .expect("opening a FIFO must not block");
1378 assert!(matches!(outcome, Err(OpenError::Changed)), "{outcome:?}");
1379 }
1380
1381 #[cfg(unix)]
1382 #[test]
1383 fn open_regular_file_does_not_follow_a_swapped_intermediate_directory() {
1384 let root = tempfile::tempdir().expect("tempdir");
1385 let outside = tempfile::tempdir().expect("tempdir");
1386 std::fs::create_dir(outside.path().join("b")).expect("mkdir");
1389 std::fs::write(outside.path().join("f"), b"outside-secret").expect("write");
1390 std::fs::write(outside.path().join("b/f"), b"outside-secret").expect("write");
1391 std::fs::create_dir_all(root.path().join("a/b")).expect("mkdir");
1392 std::fs::write(root.path().join("a/b/f"), b"inside").expect("write");
1393 let rel = Path::new("a/b/f");
1394
1395 let before = std::fs::symlink_metadata(root.path().join(rel)).expect("meta");
1396 let (mut file, held) = open_regular_file(root.path(), rel, &before).expect("plain open");
1397 assert_eq!(held.len(), 6);
1398 let mut text = String::new();
1399 file.read_to_string(&mut text).expect("read");
1400 assert_eq!(text, "inside");
1401
1402 for swapped in ["a/b", "a"] {
1407 let target = root.path().join(swapped);
1408 let moved = root.path().join("moved");
1409 std::fs::rename(&target, &moved).expect("move aside");
1410 std::os::unix::fs::symlink(outside.path(), &target).expect("symlink");
1411 let after = std::fs::symlink_metadata(root.path().join(rel)).expect("meta");
1412 assert!(after.is_file(), "the full path still resolves to a file");
1413 assert!(
1414 matches!(
1415 open_regular_file(root.path(), rel, &after),
1416 Err(OpenError::Changed)
1417 ),
1418 "a symlinked `{swapped}` must be refused even with fresh metadata"
1419 );
1420 std::fs::remove_file(&target).expect("unlink");
1421 std::fs::rename(&moved, &target).expect("restore");
1422 }
1423
1424 std::fs::remove_file(root.path().join(rel)).expect("remove");
1426 std::os::unix::fs::symlink(outside.path().join("f"), root.path().join(rel))
1427 .expect("symlink");
1428 let after = std::fs::symlink_metadata(root.path().join(rel)).expect("meta");
1429 assert!(matches!(
1430 open_regular_file(root.path(), rel, &after),
1431 Err(OpenError::Changed)
1432 ));
1433 for escape in ["../x", "/etc/passwd", "a/../f", ""] {
1434 assert!(
1435 matches!(
1436 open_regular_file(root.path(), Path::new(escape), &before),
1437 Err(OpenError::Changed)
1438 ),
1439 "{escape:?}"
1440 );
1441 }
1442 assert!(matches!(
1444 open_regular_file(root.path(), Path::new("a/b/missing"), &before),
1445 Err(OpenError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound
1446 ));
1447 }
1448
1449 #[cfg(unix)]
1450 #[test]
1451 fn read_repo_file_refuses_a_symlinked_deep_parent_and_caps_bytes() {
1452 let repo = tempfile::tempdir().expect("tempdir");
1453 let outside = tempfile::tempdir().expect("tempdir");
1454 std::fs::write(outside.path().join("m.json"), b"{}").expect("write");
1455 std::fs::create_dir_all(repo.path().join("a")).expect("mkdir");
1456 std::os::unix::fs::symlink(outside.path(), repo.path().join("a/b")).expect("symlink");
1457 let err = read_repo_file(repo.path(), "a/b/m.json", None)
1458 .expect_err("a symlinked second component must be refused");
1459 assert!(err.to_string().contains("symlink"), "{err}");
1460
1461 std::fs::write(repo.path().join("a/ok.json"), b"{}").expect("write");
1462 assert_eq!(
1463 read_repo_file(repo.path(), "a/ok.json", None).expect("plain read"),
1464 b"{}"
1465 );
1466 }
1467
1468 #[test]
1469 fn from_files_rejects_hostile_paths() {
1470 assert!(SourceTree::from_files([("../x", "a")]).is_err());
1471 assert!(SourceTree::from_files([("resources/a/b/c", "a")]).is_ok());
1472 }
1473}